mirror of
https://github.com/simstudioai/sim.git
synced 2026-04-28 03:00:29 -04:00
Merge branch 'staging' into fix/logs-files
This commit is contained in:
@@ -285,6 +285,14 @@ export async function POST(req: NextRequest) {
|
||||
apiVersion: 'preview',
|
||||
endpoint: env.AZURE_OPENAI_ENDPOINT,
|
||||
}
|
||||
} else if (providerEnv === 'azure-anthropic') {
|
||||
providerConfig = {
|
||||
provider: 'azure-anthropic',
|
||||
model: envModel,
|
||||
apiKey: env.AZURE_ANTHROPIC_API_KEY,
|
||||
apiVersion: env.AZURE_ANTHROPIC_API_VERSION,
|
||||
endpoint: env.AZURE_ANTHROPIC_ENDPOINT,
|
||||
}
|
||||
} else if (providerEnv === 'vertex') {
|
||||
providerConfig = {
|
||||
provider: 'vertex',
|
||||
|
||||
@@ -845,6 +845,8 @@ export async function POST(req: NextRequest) {
|
||||
contextVariables,
|
||||
timeoutMs: timeout,
|
||||
requestId,
|
||||
ownerKey: `user:${auth.userId}`,
|
||||
ownerWeight: 1,
|
||||
})
|
||||
|
||||
const executionTime = Date.now() - startTime
|
||||
|
||||
@@ -23,7 +23,16 @@ export async function POST(request: NextRequest) {
|
||||
topK,
|
||||
model,
|
||||
apiKey,
|
||||
azureEndpoint,
|
||||
azureApiVersion,
|
||||
vertexProject,
|
||||
vertexLocation,
|
||||
vertexCredential,
|
||||
bedrockAccessKeyId,
|
||||
bedrockSecretKey,
|
||||
bedrockRegion,
|
||||
workflowId,
|
||||
workspaceId,
|
||||
piiEntityTypes,
|
||||
piiMode,
|
||||
piiLanguage,
|
||||
@@ -110,7 +119,18 @@ export async function POST(request: NextRequest) {
|
||||
topK,
|
||||
model,
|
||||
apiKey,
|
||||
{
|
||||
azureEndpoint,
|
||||
azureApiVersion,
|
||||
vertexProject,
|
||||
vertexLocation,
|
||||
vertexCredential,
|
||||
bedrockAccessKeyId,
|
||||
bedrockSecretKey,
|
||||
bedrockRegion,
|
||||
},
|
||||
workflowId,
|
||||
workspaceId,
|
||||
piiEntityTypes,
|
||||
piiMode,
|
||||
piiLanguage,
|
||||
@@ -178,7 +198,18 @@ async function executeValidation(
|
||||
topK: string | undefined,
|
||||
model: string,
|
||||
apiKey: string | undefined,
|
||||
providerCredentials: {
|
||||
azureEndpoint?: string
|
||||
azureApiVersion?: string
|
||||
vertexProject?: string
|
||||
vertexLocation?: string
|
||||
vertexCredential?: string
|
||||
bedrockAccessKeyId?: string
|
||||
bedrockSecretKey?: string
|
||||
bedrockRegion?: string
|
||||
},
|
||||
workflowId: string | undefined,
|
||||
workspaceId: string | undefined,
|
||||
piiEntityTypes: string[] | undefined,
|
||||
piiMode: string | undefined,
|
||||
piiLanguage: string | undefined,
|
||||
@@ -219,7 +250,9 @@ async function executeValidation(
|
||||
topK: topK ? Number.parseInt(topK) : 10, // Default topK is 10
|
||||
model: model,
|
||||
apiKey,
|
||||
providerCredentials,
|
||||
workflowId,
|
||||
workspaceId,
|
||||
requestId,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -325,6 +325,11 @@ export async function POST(req: NextRequest, { params }: { params: Promise<{ id:
|
||||
requestId
|
||||
)
|
||||
|
||||
// Client-side sessions and personal API keys bill/permission-check the
|
||||
// authenticated user, not the workspace billed account.
|
||||
const useAuthenticatedUserAsActor =
|
||||
isClientSession || (auth.authType === 'api_key' && auth.apiKeyType === 'personal')
|
||||
|
||||
const preprocessResult = await preprocessExecution({
|
||||
workflowId,
|
||||
userId,
|
||||
@@ -334,6 +339,7 @@ export async function POST(req: NextRequest, { params }: { params: Promise<{ id:
|
||||
checkDeployment: !shouldUseDraftState,
|
||||
loggingSession,
|
||||
useDraftState: shouldUseDraftState,
|
||||
useAuthenticatedUserAsActor,
|
||||
})
|
||||
|
||||
if (!preprocessResult.success) {
|
||||
|
||||
@@ -130,39 +130,52 @@ export function SkillInput({
|
||||
onOpenChange={setOpen}
|
||||
/>
|
||||
|
||||
{selectedSkills.length > 0 && (
|
||||
<div className='flex flex-wrap gap-[4px]'>
|
||||
{selectedSkills.map((stored) => {
|
||||
const fullSkill = workspaceSkills.find((s) => s.id === stored.skillId)
|
||||
return (
|
||||
{selectedSkills.length > 0 &&
|
||||
selectedSkills.map((stored) => {
|
||||
const fullSkill = workspaceSkills.find((s) => s.id === stored.skillId)
|
||||
return (
|
||||
<div
|
||||
key={stored.skillId}
|
||||
className='group relative flex flex-col overflow-hidden rounded-[4px] border border-[var(--border-1)] transition-all duration-200 ease-in-out'
|
||||
>
|
||||
<div
|
||||
key={stored.skillId}
|
||||
className='flex cursor-pointer items-center gap-[4px] rounded-[4px] border border-[var(--border-1)] bg-[var(--surface-5)] px-[6px] py-[2px] font-medium text-[12px] text-[var(--text-secondary)] hover:bg-[var(--surface-6)]'
|
||||
className='flex cursor-pointer items-center justify-between gap-[8px] rounded-t-[4px] bg-[var(--surface-4)] px-[8px] py-[6.5px]'
|
||||
onClick={() => {
|
||||
if (fullSkill && !disabled && !isPreview) {
|
||||
setEditingSkill(fullSkill)
|
||||
}
|
||||
}}
|
||||
>
|
||||
<AgentSkillsIcon className='h-[10px] w-[10px] text-[var(--text-tertiary)]' />
|
||||
<span className='max-w-[140px] truncate'>{resolveSkillName(stored)}</span>
|
||||
{!disabled && !isPreview && (
|
||||
<button
|
||||
type='button'
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
handleRemove(stored.skillId)
|
||||
}}
|
||||
className='ml-[2px] rounded-[2px] p-[1px] text-[var(--text-tertiary)] hover:bg-[var(--surface-7)] hover:text-[var(--text-secondary)]'
|
||||
<div className='flex min-w-0 flex-1 items-center gap-[8px]'>
|
||||
<div
|
||||
className='flex h-[16px] w-[16px] flex-shrink-0 items-center justify-center rounded-[4px]'
|
||||
style={{ backgroundColor: '#e0e0e0' }}
|
||||
>
|
||||
<XIcon className='h-[10px] w-[10px]' />
|
||||
</button>
|
||||
)}
|
||||
<AgentSkillsIcon className='h-[10px] w-[10px] text-[#333]' />
|
||||
</div>
|
||||
<span className='truncate font-medium text-[13px] text-[var(--text-primary)]'>
|
||||
{resolveSkillName(stored)}
|
||||
</span>
|
||||
</div>
|
||||
<div className='flex flex-shrink-0 items-center gap-[8px]'>
|
||||
{!disabled && !isPreview && (
|
||||
<button
|
||||
type='button'
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
handleRemove(stored.skillId)
|
||||
}}
|
||||
className='flex items-center justify-center text-[var(--text-tertiary)] transition-colors hover:text-[var(--text-primary)]'
|
||||
aria-label='Remove skill'
|
||||
>
|
||||
<XIcon className='h-[13px] w-[13px]' />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
<SkillModal
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
isSubBlockVisibleForMode,
|
||||
} from '@/lib/workflows/subblocks/visibility'
|
||||
import type { BlockConfig, SubBlockConfig, SubBlockType } from '@/blocks/types'
|
||||
import { usePermissionConfig } from '@/hooks/use-permission-config'
|
||||
import { useWorkflowDiffStore } from '@/stores/workflow-diff'
|
||||
import { mergeSubblockState } from '@/stores/workflows/utils'
|
||||
import { useWorkflowStore } from '@/stores/workflows/workflow/store'
|
||||
@@ -35,6 +36,7 @@ export function useEditorSubblockLayout(
|
||||
const blockDataFromStore = useWorkflowStore(
|
||||
useCallback((state) => state.blocks?.[blockId]?.data, [blockId])
|
||||
)
|
||||
const { config: permissionConfig } = usePermissionConfig()
|
||||
|
||||
return useMemo(() => {
|
||||
// Guard against missing config or block selection
|
||||
@@ -100,6 +102,9 @@ export function useEditorSubblockLayout(
|
||||
const visibleSubBlocks = (config.subBlocks || []).filter((block) => {
|
||||
if (block.hidden) return false
|
||||
|
||||
// Hide skill-input subblock when skills are disabled via permissions
|
||||
if (block.type === 'skill-input' && permissionConfig.disableSkills) return false
|
||||
|
||||
// Check required feature if specified - declarative feature gating
|
||||
if (!isSubBlockFeatureEnabled(block)) return false
|
||||
|
||||
@@ -149,5 +154,6 @@ export function useEditorSubblockLayout(
|
||||
activeWorkflowId,
|
||||
isSnapshotView,
|
||||
blockDataFromStore,
|
||||
permissionConfig.disableSkills,
|
||||
])
|
||||
}
|
||||
|
||||
@@ -40,6 +40,7 @@ import { useCustomTools } from '@/hooks/queries/custom-tools'
|
||||
import { useMcpServers, useMcpToolsQuery } from '@/hooks/queries/mcp'
|
||||
import { useCredentialName } from '@/hooks/queries/oauth-credentials'
|
||||
import { useReactivateSchedule, useScheduleInfo } from '@/hooks/queries/schedules'
|
||||
import { useSkills } from '@/hooks/queries/skills'
|
||||
import { useDeployChildWorkflow } from '@/hooks/queries/workflows'
|
||||
import { useSelectorDisplayName } from '@/hooks/use-selector-display-name'
|
||||
import { useVariablesStore } from '@/stores/panel'
|
||||
@@ -618,6 +619,48 @@ const SubBlockRow = memo(function SubBlockRow({
|
||||
return `${toolNames[0]}, ${toolNames[1]} +${toolNames.length - 2}`
|
||||
}, [subBlock?.type, rawValue, customTools, workspaceId])
|
||||
|
||||
/**
|
||||
* Hydrates skill references to display names.
|
||||
* Resolves skill IDs to their current names from the skills query.
|
||||
*/
|
||||
const { data: workspaceSkills = [] } = useSkills(workspaceId || '')
|
||||
|
||||
const skillsDisplayValue = useMemo(() => {
|
||||
if (subBlock?.type !== 'skill-input' || !Array.isArray(rawValue) || rawValue.length === 0) {
|
||||
return null
|
||||
}
|
||||
|
||||
interface StoredSkill {
|
||||
skillId: string
|
||||
name?: string
|
||||
}
|
||||
|
||||
const skillNames = rawValue
|
||||
.map((skill: StoredSkill) => {
|
||||
if (!skill || typeof skill !== 'object') return null
|
||||
|
||||
// Priority 1: Resolve skill name from the skills query (fresh data)
|
||||
if (skill.skillId) {
|
||||
const foundSkill = workspaceSkills.find((s) => s.id === skill.skillId)
|
||||
if (foundSkill?.name) return foundSkill.name
|
||||
}
|
||||
|
||||
// Priority 2: Fall back to stored name (for deleted skills)
|
||||
if (skill.name && typeof skill.name === 'string') return skill.name
|
||||
|
||||
// Priority 3: Use skillId as last resort
|
||||
if (skill.skillId) return skill.skillId
|
||||
|
||||
return null
|
||||
})
|
||||
.filter((name): name is string => !!name)
|
||||
|
||||
if (skillNames.length === 0) return null
|
||||
if (skillNames.length === 1) return skillNames[0]
|
||||
if (skillNames.length === 2) return `${skillNames[0]}, ${skillNames[1]}`
|
||||
return `${skillNames[0]}, ${skillNames[1]} +${skillNames.length - 2}`
|
||||
}, [subBlock?.type, rawValue, workspaceSkills])
|
||||
|
||||
const isPasswordField = subBlock?.password === true
|
||||
const maskedValue = isPasswordField && value && value !== '-' ? '•••' : null
|
||||
|
||||
@@ -627,6 +670,7 @@ const SubBlockRow = memo(function SubBlockRow({
|
||||
dropdownLabel ||
|
||||
variablesDisplayValue ||
|
||||
toolsDisplayValue ||
|
||||
skillsDisplayValue ||
|
||||
knowledgeBaseDisplayName ||
|
||||
workflowSelectionName ||
|
||||
mcpServerDisplayName ||
|
||||
|
||||
@@ -27,6 +27,13 @@ interface SkillModalProps {
|
||||
|
||||
const KEBAB_CASE_REGEX = /^[a-z0-9]+(-[a-z0-9]+)*$/
|
||||
|
||||
interface FieldErrors {
|
||||
name?: string
|
||||
description?: string
|
||||
content?: string
|
||||
general?: string
|
||||
}
|
||||
|
||||
export function SkillModal({
|
||||
open,
|
||||
onOpenChange,
|
||||
@@ -43,7 +50,7 @@ export function SkillModal({
|
||||
const [name, setName] = useState('')
|
||||
const [description, setDescription] = useState('')
|
||||
const [content, setContent] = useState('')
|
||||
const [formError, setFormError] = useState('')
|
||||
const [errors, setErrors] = useState<FieldErrors>({})
|
||||
const [saving, setSaving] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
@@ -57,7 +64,7 @@ export function SkillModal({
|
||||
setDescription('')
|
||||
setContent('')
|
||||
}
|
||||
setFormError('')
|
||||
setErrors({})
|
||||
}
|
||||
}, [open, initialValues])
|
||||
|
||||
@@ -71,24 +78,26 @@ export function SkillModal({
|
||||
}, [name, description, content, initialValues])
|
||||
|
||||
const handleSave = async () => {
|
||||
const newErrors: FieldErrors = {}
|
||||
|
||||
if (!name.trim()) {
|
||||
setFormError('Name is required')
|
||||
return
|
||||
}
|
||||
if (name.length > 64) {
|
||||
setFormError('Name must be 64 characters or less')
|
||||
return
|
||||
}
|
||||
if (!KEBAB_CASE_REGEX.test(name)) {
|
||||
setFormError('Name must be kebab-case (e.g. my-skill)')
|
||||
return
|
||||
newErrors.name = 'Name is required'
|
||||
} else if (name.length > 64) {
|
||||
newErrors.name = 'Name must be 64 characters or less'
|
||||
} else if (!KEBAB_CASE_REGEX.test(name)) {
|
||||
newErrors.name = 'Name must be kebab-case (e.g. my-skill)'
|
||||
}
|
||||
|
||||
if (!description.trim()) {
|
||||
setFormError('Description is required')
|
||||
return
|
||||
newErrors.description = 'Description is required'
|
||||
}
|
||||
|
||||
if (!content.trim()) {
|
||||
setFormError('Content is required')
|
||||
newErrors.content = 'Content is required'
|
||||
}
|
||||
|
||||
if (Object.keys(newErrors).length > 0) {
|
||||
setErrors(newErrors)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -113,7 +122,7 @@ export function SkillModal({
|
||||
error instanceof Error && error.message.includes('already exists')
|
||||
? error.message
|
||||
: 'Failed to save skill. Please try again.'
|
||||
setFormError(message)
|
||||
setErrors({ general: message })
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
@@ -135,12 +144,17 @@ export function SkillModal({
|
||||
value={name}
|
||||
onChange={(e) => {
|
||||
setName(e.target.value)
|
||||
if (formError) setFormError('')
|
||||
if (errors.name || errors.general)
|
||||
setErrors((prev) => ({ ...prev, name: undefined, general: undefined }))
|
||||
}}
|
||||
/>
|
||||
<span className='text-[11px] text-[var(--text-muted)]'>
|
||||
Lowercase letters, numbers, and hyphens (e.g. my-skill)
|
||||
</span>
|
||||
{errors.name ? (
|
||||
<p className='text-[12px] text-[var(--text-error)]'>{errors.name}</p>
|
||||
) : (
|
||||
<span className='text-[11px] text-[var(--text-muted)]'>
|
||||
Lowercase letters, numbers, and hyphens (e.g. my-skill)
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className='flex flex-col gap-[4px]'>
|
||||
@@ -153,10 +167,14 @@ export function SkillModal({
|
||||
value={description}
|
||||
onChange={(e) => {
|
||||
setDescription(e.target.value)
|
||||
if (formError) setFormError('')
|
||||
if (errors.description || errors.general)
|
||||
setErrors((prev) => ({ ...prev, description: undefined, general: undefined }))
|
||||
}}
|
||||
maxLength={1024}
|
||||
/>
|
||||
{errors.description && (
|
||||
<p className='text-[12px] text-[var(--text-error)]'>{errors.description}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className='flex flex-col gap-[4px]'>
|
||||
@@ -169,13 +187,19 @@ export function SkillModal({
|
||||
value={content}
|
||||
onChange={(e: ChangeEvent<HTMLTextAreaElement>) => {
|
||||
setContent(e.target.value)
|
||||
if (formError) setFormError('')
|
||||
if (errors.content || errors.general)
|
||||
setErrors((prev) => ({ ...prev, content: undefined, general: undefined }))
|
||||
}}
|
||||
className='min-h-[200px] resize-y font-mono text-[13px]'
|
||||
/>
|
||||
{errors.content && (
|
||||
<p className='text-[12px] text-[var(--text-error)]'>{errors.content}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{formError && <span className='text-[11px] text-[var(--text-error)]'>{formError}</span>}
|
||||
{errors.general && (
|
||||
<p className='text-[12px] text-[var(--text-error)]'>{errors.general}</p>
|
||||
)}
|
||||
</div>
|
||||
</ModalBody>
|
||||
<ModalFooter className='items-center justify-between'>
|
||||
|
||||
@@ -333,11 +333,11 @@ Return ONLY the JSON array.`,
|
||||
id: 'azureApiVersion',
|
||||
title: 'Azure API Version',
|
||||
type: 'short-input',
|
||||
placeholder: '2024-07-01-preview',
|
||||
placeholder: 'Enter API version',
|
||||
connectionDroppable: false,
|
||||
condition: {
|
||||
field: 'model',
|
||||
value: providers['azure-openai'].models,
|
||||
value: [...providers['azure-openai'].models, ...providers['azure-anthropic'].models],
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -715,7 +715,7 @@ Example 3 (Array Input):
|
||||
},
|
||||
model: { type: 'string', description: 'AI model to use' },
|
||||
apiKey: { type: 'string', description: 'Provider API key' },
|
||||
azureEndpoint: { type: 'string', description: 'Azure OpenAI endpoint URL' },
|
||||
azureEndpoint: { type: 'string', description: 'Azure endpoint URL' },
|
||||
azureApiVersion: { type: 'string', description: 'Azure API version' },
|
||||
vertexProject: { type: 'string', description: 'Google Cloud project ID for Vertex AI' },
|
||||
vertexLocation: { type: 'string', description: 'Google Cloud location for Vertex AI' },
|
||||
|
||||
@@ -76,8 +76,9 @@ export const TranslateBlock: BlockConfig = {
|
||||
vertexProject: params.vertexProject,
|
||||
vertexLocation: params.vertexLocation,
|
||||
vertexCredential: params.vertexCredential,
|
||||
bedrockRegion: params.bedrockRegion,
|
||||
bedrockAccessKeyId: params.bedrockAccessKeyId,
|
||||
bedrockSecretKey: params.bedrockSecretKey,
|
||||
bedrockRegion: params.bedrockRegion,
|
||||
}),
|
||||
},
|
||||
},
|
||||
|
||||
@@ -80,7 +80,7 @@ export function getApiKeyCondition() {
|
||||
|
||||
/**
|
||||
* Returns the standard provider credential subblocks used by LLM-based blocks.
|
||||
* This includes: Vertex AI OAuth, API Key, Azure OpenAI, Vertex AI config, and Bedrock config.
|
||||
* This includes: Vertex AI OAuth, API Key, Azure (OpenAI + Anthropic), Vertex AI config, and Bedrock config.
|
||||
*
|
||||
* Usage: Spread into your block's subBlocks array after block-specific fields
|
||||
*/
|
||||
@@ -111,25 +111,25 @@ export function getProviderCredentialSubBlocks(): SubBlockConfig[] {
|
||||
},
|
||||
{
|
||||
id: 'azureEndpoint',
|
||||
title: 'Azure OpenAI Endpoint',
|
||||
title: 'Azure Endpoint',
|
||||
type: 'short-input',
|
||||
password: true,
|
||||
placeholder: 'https://your-resource.openai.azure.com',
|
||||
placeholder: 'https://your-resource.services.ai.azure.com',
|
||||
connectionDroppable: false,
|
||||
condition: {
|
||||
field: 'model',
|
||||
value: providers['azure-openai'].models,
|
||||
value: [...providers['azure-openai'].models, ...providers['azure-anthropic'].models],
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'azureApiVersion',
|
||||
title: 'Azure API Version',
|
||||
type: 'short-input',
|
||||
placeholder: '2024-07-01-preview',
|
||||
placeholder: 'Enter API version',
|
||||
connectionDroppable: false,
|
||||
condition: {
|
||||
field: 'model',
|
||||
value: providers['azure-openai'].models,
|
||||
value: [...providers['azure-openai'].models, ...providers['azure-anthropic'].models],
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -202,7 +202,7 @@ export function getProviderCredentialSubBlocks(): SubBlockConfig[] {
|
||||
*/
|
||||
export const PROVIDER_CREDENTIAL_INPUTS = {
|
||||
apiKey: { type: 'string', description: 'Provider API key' },
|
||||
azureEndpoint: { type: 'string', description: 'Azure OpenAI endpoint URL' },
|
||||
azureEndpoint: { type: 'string', description: 'Azure endpoint URL' },
|
||||
azureApiVersion: { type: 'string', description: 'Azure API version' },
|
||||
vertexProject: { type: 'string', description: 'Google Cloud project ID for Vertex AI' },
|
||||
vertexLocation: { type: 'string', description: 'Google Cloud location for Vertex AI' },
|
||||
|
||||
@@ -5468,18 +5468,18 @@ export function AgentSkillsIcon(props: SVGProps<SVGSVGElement>) {
|
||||
<svg
|
||||
{...props}
|
||||
xmlns='http://www.w3.org/2000/svg'
|
||||
width='24'
|
||||
height='24'
|
||||
viewBox='0 0 32 32'
|
||||
width='16'
|
||||
height='16'
|
||||
viewBox='0 0 16 16'
|
||||
fill='none'
|
||||
>
|
||||
<path d='M16 0.5L29.4234 8.25V23.75L16 31.5L2.57661 23.75V8.25L16 0.5Z' fill='currentColor' />
|
||||
<path
|
||||
d='M16 6L24.6603 11V21L16 26L7.33975 21V11L16 6Z'
|
||||
fill='currentColor'
|
||||
stroke='var(--background, white)'
|
||||
strokeWidth='3'
|
||||
d='M8 1L14.0622 4.5V11.5L8 15L1.93782 11.5V4.5L8 1Z'
|
||||
stroke='currentColor'
|
||||
strokeWidth='1.5'
|
||||
fill='none'
|
||||
/>
|
||||
<path d='M8 4.5L11 6.25V9.75L8 11.5L5 9.75V6.25L8 4.5Z' fill='currentColor' />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -326,6 +326,7 @@ export class AgentBlockHandler implements BlockHandler {
|
||||
_context: {
|
||||
workflowId: ctx.workflowId,
|
||||
workspaceId: ctx.workspaceId,
|
||||
userId: ctx.userId,
|
||||
isDeployedContext: ctx.isDeployedContext,
|
||||
},
|
||||
},
|
||||
|
||||
@@ -72,6 +72,7 @@ export class ApiBlockHandler implements BlockHandler {
|
||||
workflowId: ctx.workflowId,
|
||||
workspaceId: ctx.workspaceId,
|
||||
executionId: ctx.executionId,
|
||||
userId: ctx.userId,
|
||||
isDeployedContext: ctx.isDeployedContext,
|
||||
},
|
||||
},
|
||||
|
||||
@@ -48,6 +48,7 @@ export async function evaluateConditionExpression(
|
||||
_context: {
|
||||
workflowId: ctx.workflowId,
|
||||
workspaceId: ctx.workspaceId,
|
||||
userId: ctx.userId,
|
||||
isDeployedContext: ctx.isDeployedContext,
|
||||
},
|
||||
},
|
||||
|
||||
@@ -121,26 +121,17 @@ export class EvaluatorBlockHandler implements BlockHandler {
|
||||
|
||||
temperature: EVALUATOR.DEFAULT_TEMPERATURE,
|
||||
apiKey: finalApiKey,
|
||||
azureEndpoint: inputs.azureEndpoint,
|
||||
azureApiVersion: inputs.azureApiVersion,
|
||||
vertexProject: evaluatorConfig.vertexProject,
|
||||
vertexLocation: evaluatorConfig.vertexLocation,
|
||||
bedrockAccessKeyId: evaluatorConfig.bedrockAccessKeyId,
|
||||
bedrockSecretKey: evaluatorConfig.bedrockSecretKey,
|
||||
bedrockRegion: evaluatorConfig.bedrockRegion,
|
||||
workflowId: ctx.workflowId,
|
||||
workspaceId: ctx.workspaceId,
|
||||
}
|
||||
|
||||
if (providerId === 'vertex') {
|
||||
providerRequest.vertexProject = evaluatorConfig.vertexProject
|
||||
providerRequest.vertexLocation = evaluatorConfig.vertexLocation
|
||||
}
|
||||
|
||||
if (providerId === 'azure-openai') {
|
||||
providerRequest.azureEndpoint = inputs.azureEndpoint
|
||||
providerRequest.azureApiVersion = inputs.azureApiVersion
|
||||
}
|
||||
|
||||
if (providerId === 'bedrock') {
|
||||
providerRequest.bedrockAccessKeyId = evaluatorConfig.bedrockAccessKeyId
|
||||
providerRequest.bedrockSecretKey = evaluatorConfig.bedrockSecretKey
|
||||
providerRequest.bedrockRegion = evaluatorConfig.bedrockRegion
|
||||
}
|
||||
|
||||
const response = await fetch(url.toString(), {
|
||||
method: 'POST',
|
||||
headers: await buildAuthHeaders(),
|
||||
|
||||
@@ -39,6 +39,7 @@ export class FunctionBlockHandler implements BlockHandler {
|
||||
_context: {
|
||||
workflowId: ctx.workflowId,
|
||||
workspaceId: ctx.workspaceId,
|
||||
userId: ctx.userId,
|
||||
isDeployedContext: ctx.isDeployedContext,
|
||||
},
|
||||
},
|
||||
|
||||
@@ -66,6 +66,7 @@ export class GenericBlockHandler implements BlockHandler {
|
||||
workflowId: ctx.workflowId,
|
||||
workspaceId: ctx.workspaceId,
|
||||
executionId: ctx.executionId,
|
||||
userId: ctx.userId,
|
||||
isDeployedContext: ctx.isDeployedContext,
|
||||
},
|
||||
},
|
||||
|
||||
@@ -605,6 +605,7 @@ export class HumanInTheLoopBlockHandler implements BlockHandler {
|
||||
_context: {
|
||||
workflowId: ctx.workflowId,
|
||||
workspaceId: ctx.workspaceId,
|
||||
userId: ctx.userId,
|
||||
isDeployedContext: ctx.isDeployedContext,
|
||||
},
|
||||
blockData: blockDataWithPause,
|
||||
|
||||
@@ -96,26 +96,17 @@ export class RouterBlockHandler implements BlockHandler {
|
||||
context: JSON.stringify(messages),
|
||||
temperature: ROUTER.INFERENCE_TEMPERATURE,
|
||||
apiKey: finalApiKey,
|
||||
azureEndpoint: inputs.azureEndpoint,
|
||||
azureApiVersion: inputs.azureApiVersion,
|
||||
vertexProject: routerConfig.vertexProject,
|
||||
vertexLocation: routerConfig.vertexLocation,
|
||||
bedrockAccessKeyId: routerConfig.bedrockAccessKeyId,
|
||||
bedrockSecretKey: routerConfig.bedrockSecretKey,
|
||||
bedrockRegion: routerConfig.bedrockRegion,
|
||||
workflowId: ctx.workflowId,
|
||||
workspaceId: ctx.workspaceId,
|
||||
}
|
||||
|
||||
if (providerId === 'vertex') {
|
||||
providerRequest.vertexProject = routerConfig.vertexProject
|
||||
providerRequest.vertexLocation = routerConfig.vertexLocation
|
||||
}
|
||||
|
||||
if (providerId === 'azure-openai') {
|
||||
providerRequest.azureEndpoint = inputs.azureEndpoint
|
||||
providerRequest.azureApiVersion = inputs.azureApiVersion
|
||||
}
|
||||
|
||||
if (providerId === 'bedrock') {
|
||||
providerRequest.bedrockAccessKeyId = routerConfig.bedrockAccessKeyId
|
||||
providerRequest.bedrockSecretKey = routerConfig.bedrockSecretKey
|
||||
providerRequest.bedrockRegion = routerConfig.bedrockRegion
|
||||
}
|
||||
|
||||
const response = await fetch(url.toString(), {
|
||||
method: 'POST',
|
||||
headers: await buildAuthHeaders(),
|
||||
@@ -234,6 +225,13 @@ export class RouterBlockHandler implements BlockHandler {
|
||||
context: JSON.stringify(messages),
|
||||
temperature: ROUTER.INFERENCE_TEMPERATURE,
|
||||
apiKey: finalApiKey,
|
||||
azureEndpoint: inputs.azureEndpoint,
|
||||
azureApiVersion: inputs.azureApiVersion,
|
||||
vertexProject: routerConfig.vertexProject,
|
||||
vertexLocation: routerConfig.vertexLocation,
|
||||
bedrockAccessKeyId: routerConfig.bedrockAccessKeyId,
|
||||
bedrockSecretKey: routerConfig.bedrockSecretKey,
|
||||
bedrockRegion: routerConfig.bedrockRegion,
|
||||
workflowId: ctx.workflowId,
|
||||
workspaceId: ctx.workspaceId,
|
||||
responseFormat: {
|
||||
@@ -257,22 +255,6 @@ export class RouterBlockHandler implements BlockHandler {
|
||||
},
|
||||
}
|
||||
|
||||
if (providerId === 'vertex') {
|
||||
providerRequest.vertexProject = routerConfig.vertexProject
|
||||
providerRequest.vertexLocation = routerConfig.vertexLocation
|
||||
}
|
||||
|
||||
if (providerId === 'azure-openai') {
|
||||
providerRequest.azureEndpoint = inputs.azureEndpoint
|
||||
providerRequest.azureApiVersion = inputs.azureApiVersion
|
||||
}
|
||||
|
||||
if (providerId === 'bedrock') {
|
||||
providerRequest.bedrockAccessKeyId = routerConfig.bedrockAccessKeyId
|
||||
providerRequest.bedrockSecretKey = routerConfig.bedrockSecretKey
|
||||
providerRequest.bedrockRegion = routerConfig.bedrockRegion
|
||||
}
|
||||
|
||||
const response = await fetch(url.toString(), {
|
||||
method: 'POST',
|
||||
headers: await buildAuthHeaders(),
|
||||
|
||||
@@ -511,6 +511,8 @@ export class LoopOrchestrator {
|
||||
contextVariables: {},
|
||||
timeoutMs: LOOP_CONDITION_TIMEOUT_MS,
|
||||
requestId,
|
||||
ownerKey: `user:${ctx.userId}`,
|
||||
ownerWeight: 1,
|
||||
})
|
||||
|
||||
if (vmResult.error) {
|
||||
|
||||
@@ -1,7 +1,4 @@
|
||||
import { db } from '@sim/db'
|
||||
import { workflow } from '@sim/db/schema'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { eq } from 'drizzle-orm'
|
||||
import type { NextRequest } from 'next/server'
|
||||
import { authenticateApiKeyFromHeader, updateApiKeyLastUsed } from '@/lib/api-key/service'
|
||||
import { getSession } from '@/lib/auth'
|
||||
@@ -13,35 +10,33 @@ export interface AuthResult {
|
||||
success: boolean
|
||||
userId?: string
|
||||
authType?: 'session' | 'api_key' | 'internal_jwt'
|
||||
apiKeyType?: 'personal' | 'workspace'
|
||||
error?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves userId from a verified internal JWT token.
|
||||
* Extracts workflowId/userId from URL params or POST body, then looks up userId if needed.
|
||||
* Extracts userId from the JWT payload, URL search params, or POST body.
|
||||
*/
|
||||
async function resolveUserFromJwt(
|
||||
request: NextRequest,
|
||||
verificationUserId: string | null,
|
||||
options: { requireWorkflowId?: boolean }
|
||||
): Promise<AuthResult> {
|
||||
let workflowId: string | null = null
|
||||
let userId: string | null = verificationUserId
|
||||
|
||||
const { searchParams } = new URL(request.url)
|
||||
workflowId = searchParams.get('workflowId')
|
||||
if (!userId) {
|
||||
const { searchParams } = new URL(request.url)
|
||||
userId = searchParams.get('userId')
|
||||
}
|
||||
|
||||
if (!workflowId && !userId && request.method === 'POST') {
|
||||
if (!userId && request.method === 'POST') {
|
||||
try {
|
||||
const clonedRequest = request.clone()
|
||||
const bodyText = await clonedRequest.text()
|
||||
if (bodyText) {
|
||||
const body = JSON.parse(bodyText)
|
||||
workflowId = body.workflowId || body._context?.workflowId
|
||||
userId = userId || body.userId || body._context?.userId
|
||||
userId = body.userId || body._context?.userId || null
|
||||
}
|
||||
} catch {
|
||||
// Ignore JSON parse errors
|
||||
@@ -52,22 +47,8 @@ async function resolveUserFromJwt(
|
||||
return { success: true, userId, authType: 'internal_jwt' }
|
||||
}
|
||||
|
||||
if (workflowId) {
|
||||
const [workflowData] = await db
|
||||
.select({ userId: workflow.userId })
|
||||
.from(workflow)
|
||||
.where(eq(workflow.id, workflowId))
|
||||
.limit(1)
|
||||
|
||||
if (!workflowData) {
|
||||
return { success: false, error: 'Workflow not found' }
|
||||
}
|
||||
|
||||
return { success: true, userId: workflowData.userId, authType: 'internal_jwt' }
|
||||
}
|
||||
|
||||
if (options.requireWorkflowId !== false) {
|
||||
return { success: false, error: 'workflowId or userId required for internal JWT calls' }
|
||||
return { success: false, error: 'userId required for internal JWT calls' }
|
||||
}
|
||||
|
||||
return { success: true, authType: 'internal_jwt' }
|
||||
@@ -222,6 +203,7 @@ export async function checkHybridAuth(
|
||||
success: true,
|
||||
userId: result.userId!,
|
||||
authType: 'api_key',
|
||||
apiKeyType: result.keyType,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -12,6 +12,7 @@ const VALID_PROVIDER_IDS: readonly ProviderId[] = [
|
||||
'openai',
|
||||
'azure-openai',
|
||||
'anthropic',
|
||||
'azure-anthropic',
|
||||
'google',
|
||||
'deepseek',
|
||||
'xai',
|
||||
|
||||
@@ -147,6 +147,13 @@ export type CopilotProviderConfig =
|
||||
apiVersion?: string
|
||||
endpoint?: string
|
||||
}
|
||||
| {
|
||||
provider: 'azure-anthropic'
|
||||
model: string
|
||||
apiKey?: string
|
||||
apiVersion?: string
|
||||
endpoint?: string
|
||||
}
|
||||
| {
|
||||
provider: 'vertex'
|
||||
model: string
|
||||
@@ -155,7 +162,7 @@ export type CopilotProviderConfig =
|
||||
vertexLocation?: string
|
||||
}
|
||||
| {
|
||||
provider: Exclude<ProviderId, 'azure-openai' | 'vertex'>
|
||||
provider: Exclude<ProviderId, 'azure-openai' | 'azure-anthropic' | 'vertex'>
|
||||
model?: string
|
||||
apiKey?: string
|
||||
}
|
||||
|
||||
@@ -95,6 +95,9 @@ export const env = createEnv({
|
||||
AZURE_OPENAI_ENDPOINT: z.string().url().optional(), // Shared Azure OpenAI service endpoint
|
||||
AZURE_OPENAI_API_VERSION: z.string().optional(), // Shared Azure OpenAI API version
|
||||
AZURE_OPENAI_API_KEY: z.string().min(1).optional(), // Shared Azure OpenAI API key
|
||||
AZURE_ANTHROPIC_ENDPOINT: z.string().url().optional(), // Azure Anthropic service endpoint
|
||||
AZURE_ANTHROPIC_API_KEY: z.string().min(1).optional(), // Azure Anthropic API key
|
||||
AZURE_ANTHROPIC_API_VERSION: z.string().min(1).optional(), // Azure Anthropic API version (e.g. 2023-06-01)
|
||||
KB_OPENAI_MODEL_NAME: z.string().optional(), // Knowledge base OpenAI model name (works with both regular OpenAI and Azure OpenAI)
|
||||
WAND_OPENAI_MODEL_NAME: z.string().optional(), // Wand generation OpenAI model name (works with both regular OpenAI and Azure OpenAI)
|
||||
OCR_AZURE_ENDPOINT: z.string().url().optional(), // Azure Mistral OCR service endpoint
|
||||
@@ -180,6 +183,24 @@ export const env = createEnv({
|
||||
EXECUTION_TIMEOUT_ASYNC_TEAM: z.string().optional().default('5400'), // 90 minutes
|
||||
EXECUTION_TIMEOUT_ASYNC_ENTERPRISE: z.string().optional().default('5400'), // 90 minutes
|
||||
|
||||
// Isolated-VM Worker Pool Configuration
|
||||
IVM_POOL_SIZE: z.string().optional().default('4'), // Max worker processes in pool
|
||||
IVM_MAX_CONCURRENT: z.string().optional().default('10000'), // Max concurrent executions globally
|
||||
IVM_MAX_PER_WORKER: z.string().optional().default('2500'), // Max concurrent executions per worker
|
||||
IVM_WORKER_IDLE_TIMEOUT_MS: z.string().optional().default('60000'), // Worker idle cleanup timeout (ms)
|
||||
IVM_MAX_QUEUE_SIZE: z.string().optional().default('10000'), // Max pending queued executions in memory
|
||||
IVM_MAX_FETCH_RESPONSE_BYTES: z.string().optional().default('8388608'),// Max bytes read from sandbox fetch responses
|
||||
IVM_MAX_FETCH_RESPONSE_CHARS: z.string().optional().default('4000000'),// Max chars returned to sandbox from fetch body
|
||||
IVM_MAX_FETCH_OPTIONS_JSON_CHARS: z.string().optional().default('262144'), // Max JSON payload size for sandbox fetch options
|
||||
IVM_MAX_FETCH_URL_LENGTH: z.string().optional().default('8192'), // Max URL length accepted by sandbox fetch
|
||||
IVM_MAX_STDOUT_CHARS: z.string().optional().default('200000'), // Max captured stdout characters per execution
|
||||
IVM_MAX_ACTIVE_PER_OWNER: z.string().optional().default('200'), // Max active executions per owner (per process)
|
||||
IVM_MAX_QUEUED_PER_OWNER: z.string().optional().default('2000'), // Max queued executions per owner (per process)
|
||||
IVM_MAX_OWNER_WEIGHT: z.string().optional().default('5'), // Max accepted weight for weighted owner scheduling
|
||||
IVM_DISTRIBUTED_MAX_INFLIGHT_PER_OWNER:z.string().optional().default('2200'), // Max owner in-flight leases across replicas
|
||||
IVM_DISTRIBUTED_LEASE_MIN_TTL_MS: z.string().optional().default('120000'), // Min TTL for distributed in-flight leases (ms)
|
||||
IVM_QUEUE_TIMEOUT_MS: z.string().optional().default('300000'), // Max queue wait before rejection (ms)
|
||||
|
||||
// Knowledge Base Processing Configuration - Shared across all processing methods
|
||||
KB_CONFIG_MAX_DURATION: z.number().optional().default(600), // Max processing duration in seconds (10 minutes)
|
||||
KB_CONFIG_MAX_ATTEMPTS: z.number().optional().default(3), // Max retry attempts
|
||||
|
||||
@@ -103,6 +103,7 @@ export interface SecureFetchOptions {
|
||||
body?: string | Buffer | Uint8Array
|
||||
timeout?: number
|
||||
maxRedirects?: number
|
||||
maxResponseBytes?: number
|
||||
}
|
||||
|
||||
export class SecureFetchHeaders {
|
||||
@@ -165,6 +166,7 @@ export async function secureFetchWithPinnedIP(
|
||||
redirectCount = 0
|
||||
): Promise<SecureFetchResponse> {
|
||||
const maxRedirects = options.maxRedirects ?? DEFAULT_MAX_REDIRECTS
|
||||
const maxResponseBytes = options.maxResponseBytes
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const parsed = new URL(url)
|
||||
@@ -237,14 +239,32 @@ export async function secureFetchWithPinnedIP(
|
||||
}
|
||||
|
||||
const chunks: Buffer[] = []
|
||||
let totalBytes = 0
|
||||
let responseTerminated = false
|
||||
|
||||
res.on('data', (chunk: Buffer) => chunks.push(chunk))
|
||||
res.on('data', (chunk: Buffer) => {
|
||||
if (responseTerminated) return
|
||||
|
||||
totalBytes += chunk.length
|
||||
if (
|
||||
typeof maxResponseBytes === 'number' &&
|
||||
maxResponseBytes > 0 &&
|
||||
totalBytes > maxResponseBytes
|
||||
) {
|
||||
responseTerminated = true
|
||||
res.destroy(new Error(`Response exceeded maximum size of ${maxResponseBytes} bytes`))
|
||||
return
|
||||
}
|
||||
|
||||
chunks.push(chunk)
|
||||
})
|
||||
|
||||
res.on('error', (error) => {
|
||||
reject(error)
|
||||
})
|
||||
|
||||
res.on('end', () => {
|
||||
if (responseTerminated) return
|
||||
const bodyBuffer = Buffer.concat(chunks)
|
||||
const body = bodyBuffer.toString('utf-8')
|
||||
const headersRecord: Record<string, string> = {}
|
||||
|
||||
@@ -9,6 +9,21 @@ const USER_CODE_START_LINE = 4
|
||||
const pendingFetches = new Map()
|
||||
let fetchIdCounter = 0
|
||||
const FETCH_TIMEOUT_MS = 300000 // 5 minutes
|
||||
const MAX_STDOUT_CHARS = Number.parseInt(process.env.IVM_MAX_STDOUT_CHARS || '', 10) || 200000
|
||||
const MAX_FETCH_OPTIONS_JSON_CHARS =
|
||||
Number.parseInt(process.env.IVM_MAX_FETCH_OPTIONS_JSON_CHARS || '', 10) || 256 * 1024
|
||||
|
||||
function stringifyLogValue(value) {
|
||||
if (typeof value !== 'object' || value === null) {
|
||||
return String(value)
|
||||
}
|
||||
|
||||
try {
|
||||
return JSON.stringify(value)
|
||||
} catch {
|
||||
return '[unserializable]'
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract line and column from error stack or message
|
||||
@@ -101,8 +116,32 @@ function convertToCompatibleError(errorInfo, userCode) {
|
||||
async function executeCode(request) {
|
||||
const { code, params, envVars, contextVariables, timeoutMs, requestId } = request
|
||||
const stdoutChunks = []
|
||||
let stdoutLength = 0
|
||||
let stdoutTruncated = false
|
||||
let isolate = null
|
||||
|
||||
const appendStdout = (line) => {
|
||||
if (stdoutTruncated || !line) return
|
||||
|
||||
const remaining = MAX_STDOUT_CHARS - stdoutLength
|
||||
if (remaining <= 0) {
|
||||
stdoutTruncated = true
|
||||
stdoutChunks.push('[stdout truncated]\n')
|
||||
return
|
||||
}
|
||||
|
||||
if (line.length <= remaining) {
|
||||
stdoutChunks.push(line)
|
||||
stdoutLength += line.length
|
||||
return
|
||||
}
|
||||
|
||||
stdoutChunks.push(line.slice(0, remaining))
|
||||
stdoutChunks.push('\n[stdout truncated]\n')
|
||||
stdoutLength = MAX_STDOUT_CHARS
|
||||
stdoutTruncated = true
|
||||
}
|
||||
|
||||
try {
|
||||
isolate = new ivm.Isolate({ memoryLimit: 128 })
|
||||
const context = await isolate.createContext()
|
||||
@@ -111,18 +150,14 @@ async function executeCode(request) {
|
||||
await jail.set('global', jail.derefInto())
|
||||
|
||||
const logCallback = new ivm.Callback((...args) => {
|
||||
const message = args
|
||||
.map((arg) => (typeof arg === 'object' ? JSON.stringify(arg) : String(arg)))
|
||||
.join(' ')
|
||||
stdoutChunks.push(`${message}\n`)
|
||||
const message = args.map((arg) => stringifyLogValue(arg)).join(' ')
|
||||
appendStdout(`${message}\n`)
|
||||
})
|
||||
await jail.set('__log', logCallback)
|
||||
|
||||
const errorCallback = new ivm.Callback((...args) => {
|
||||
const message = args
|
||||
.map((arg) => (typeof arg === 'object' ? JSON.stringify(arg) : String(arg)))
|
||||
.join(' ')
|
||||
stdoutChunks.push(`ERROR: ${message}\n`)
|
||||
const message = args.map((arg) => stringifyLogValue(arg)).join(' ')
|
||||
appendStdout(`ERROR: ${message}\n`)
|
||||
})
|
||||
await jail.set('__error', errorCallback)
|
||||
|
||||
@@ -178,6 +213,9 @@ async function executeCode(request) {
|
||||
} catch {
|
||||
throw new Error('fetch options must be JSON-serializable');
|
||||
}
|
||||
if (optionsJson.length > ${MAX_FETCH_OPTIONS_JSON_CHARS}) {
|
||||
throw new Error('fetch options exceed maximum payload size');
|
||||
}
|
||||
}
|
||||
const resultJson = await __fetchRef.apply(undefined, [url, optionsJson], { result: { promise: true } });
|
||||
let result;
|
||||
|
||||
500
apps/sim/lib/execution/isolated-vm.test.ts
Normal file
500
apps/sim/lib/execution/isolated-vm.test.ts
Normal file
@@ -0,0 +1,500 @@
|
||||
import { EventEmitter } from 'node:events'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
type MockProc = EventEmitter & {
|
||||
connected: boolean
|
||||
stderr: EventEmitter
|
||||
send: (message: unknown) => boolean
|
||||
kill: () => boolean
|
||||
}
|
||||
|
||||
type SpawnFactory = () => MockProc
|
||||
type RedisEval = (...args: any[]) => unknown | Promise<unknown>
|
||||
type SecureFetchImpl = (...args: any[]) => unknown | Promise<unknown>
|
||||
|
||||
function createBaseProc(): MockProc {
|
||||
const proc = new EventEmitter() as MockProc
|
||||
proc.connected = true
|
||||
proc.stderr = new EventEmitter()
|
||||
proc.send = () => true
|
||||
proc.kill = () => {
|
||||
if (!proc.connected) return true
|
||||
proc.connected = false
|
||||
setImmediate(() => proc.emit('exit', 0))
|
||||
return true
|
||||
}
|
||||
return proc
|
||||
}
|
||||
|
||||
function createStartupFailureProc(): MockProc {
|
||||
const proc = createBaseProc()
|
||||
setImmediate(() => {
|
||||
proc.connected = false
|
||||
proc.emit('exit', 1)
|
||||
})
|
||||
return proc
|
||||
}
|
||||
|
||||
function createReadyProc(result: unknown): MockProc {
|
||||
const proc = createBaseProc()
|
||||
proc.send = (message: unknown) => {
|
||||
const msg = message as { type?: string; executionId?: number }
|
||||
if (msg.type === 'execute') {
|
||||
setImmediate(() => {
|
||||
proc.emit('message', {
|
||||
type: 'result',
|
||||
executionId: msg.executionId,
|
||||
result: { result, stdout: '' },
|
||||
})
|
||||
})
|
||||
}
|
||||
return true
|
||||
}
|
||||
setImmediate(() => proc.emit('message', { type: 'ready' }))
|
||||
return proc
|
||||
}
|
||||
|
||||
function createReadyProcWithDelay(delayMs: number): MockProc {
|
||||
const proc = createBaseProc()
|
||||
proc.send = (message: unknown) => {
|
||||
const msg = message as { type?: string; executionId?: number; request?: { requestId?: string } }
|
||||
if (msg.type === 'execute') {
|
||||
setTimeout(() => {
|
||||
proc.emit('message', {
|
||||
type: 'result',
|
||||
executionId: msg.executionId,
|
||||
result: { result: msg.request?.requestId ?? 'unknown', stdout: '' },
|
||||
})
|
||||
}, delayMs)
|
||||
}
|
||||
return true
|
||||
}
|
||||
setImmediate(() => proc.emit('message', { type: 'ready' }))
|
||||
return proc
|
||||
}
|
||||
|
||||
function createReadyFetchProxyProc(fetchMessage: { url: string; optionsJson?: string }): MockProc {
|
||||
const proc = createBaseProc()
|
||||
let currentExecutionId = 0
|
||||
|
||||
proc.send = (message: unknown) => {
|
||||
const msg = message as { type?: string; executionId?: number; request?: { requestId?: string } }
|
||||
|
||||
if (msg.type === 'execute') {
|
||||
currentExecutionId = msg.executionId ?? 0
|
||||
setImmediate(() => {
|
||||
proc.emit('message', {
|
||||
type: 'fetch',
|
||||
fetchId: 1,
|
||||
requestId: msg.request?.requestId ?? 'fetch-test',
|
||||
url: fetchMessage.url,
|
||||
optionsJson: fetchMessage.optionsJson,
|
||||
})
|
||||
})
|
||||
return true
|
||||
}
|
||||
|
||||
if (msg.type === 'fetchResponse') {
|
||||
const fetchResponse = message as { response?: string }
|
||||
setImmediate(() => {
|
||||
proc.emit('message', {
|
||||
type: 'result',
|
||||
executionId: currentExecutionId,
|
||||
result: { result: fetchResponse.response ?? '', stdout: '' },
|
||||
})
|
||||
})
|
||||
return true
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
setImmediate(() => proc.emit('message', { type: 'ready' }))
|
||||
return proc
|
||||
}
|
||||
|
||||
async function loadExecutionModule(options: {
|
||||
envOverrides?: Record<string, string>
|
||||
spawns: SpawnFactory[]
|
||||
redisEvalImpl?: RedisEval
|
||||
secureFetchImpl?: SecureFetchImpl
|
||||
}) {
|
||||
vi.resetModules()
|
||||
|
||||
const spawnQueue = [...options.spawns]
|
||||
const spawnMock = vi.fn(() => {
|
||||
const next = spawnQueue.shift()
|
||||
if (!next) {
|
||||
throw new Error('No mock spawn factory configured')
|
||||
}
|
||||
return next() as any
|
||||
})
|
||||
|
||||
vi.doMock('@sim/logger', () => ({
|
||||
createLogger: () => ({
|
||||
info: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
error: vi.fn(),
|
||||
}),
|
||||
}))
|
||||
|
||||
const secureFetchMock = vi.fn(
|
||||
options.secureFetchImpl ??
|
||||
(async () => ({
|
||||
ok: true,
|
||||
status: 200,
|
||||
statusText: 'OK',
|
||||
headers: new Map<string, string>(),
|
||||
text: async () => '',
|
||||
json: async () => ({}),
|
||||
arrayBuffer: async () => new ArrayBuffer(0),
|
||||
}))
|
||||
)
|
||||
vi.doMock('@/lib/core/security/input-validation.server', () => ({
|
||||
secureFetchWithValidation: secureFetchMock,
|
||||
}))
|
||||
|
||||
vi.doMock('@/lib/core/config/env', () => ({
|
||||
env: {
|
||||
IVM_POOL_SIZE: '1',
|
||||
IVM_MAX_CONCURRENT: '100',
|
||||
IVM_MAX_PER_WORKER: '100',
|
||||
IVM_WORKER_IDLE_TIMEOUT_MS: '60000',
|
||||
IVM_MAX_QUEUE_SIZE: '10',
|
||||
IVM_MAX_ACTIVE_PER_OWNER: '100',
|
||||
IVM_MAX_QUEUED_PER_OWNER: '10',
|
||||
IVM_MAX_OWNER_WEIGHT: '5',
|
||||
IVM_DISTRIBUTED_MAX_INFLIGHT_PER_OWNER: '100',
|
||||
IVM_DISTRIBUTED_LEASE_MIN_TTL_MS: '1000',
|
||||
IVM_QUEUE_TIMEOUT_MS: '1000',
|
||||
...(options.envOverrides ?? {}),
|
||||
},
|
||||
}))
|
||||
|
||||
const redisEval = options.redisEvalImpl ? vi.fn(options.redisEvalImpl) : undefined
|
||||
vi.doMock('@/lib/core/config/redis', () => ({
|
||||
getRedisClient: vi.fn(() =>
|
||||
redisEval
|
||||
? ({
|
||||
eval: redisEval,
|
||||
} as any)
|
||||
: null
|
||||
),
|
||||
}))
|
||||
|
||||
vi.doMock('node:child_process', () => ({
|
||||
execSync: vi.fn(() => Buffer.from('v23.11.0')),
|
||||
spawn: spawnMock,
|
||||
}))
|
||||
|
||||
const mod = await import('./isolated-vm')
|
||||
return { ...mod, spawnMock, secureFetchMock }
|
||||
}
|
||||
|
||||
describe('isolated-vm scheduler', () => {
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
vi.resetModules()
|
||||
})
|
||||
|
||||
it('recovers from an initial spawn failure and drains queued work', async () => {
|
||||
const { executeInIsolatedVM, spawnMock } = await loadExecutionModule({
|
||||
spawns: [createStartupFailureProc, () => createReadyProc('ok')],
|
||||
})
|
||||
|
||||
const result = await executeInIsolatedVM({
|
||||
code: 'return "ok"',
|
||||
params: {},
|
||||
envVars: {},
|
||||
contextVariables: {},
|
||||
timeoutMs: 100,
|
||||
requestId: 'req-1',
|
||||
})
|
||||
|
||||
expect(result.error).toBeUndefined()
|
||||
expect(result.result).toBe('ok')
|
||||
expect(spawnMock).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('rejects new requests when the queue is full', async () => {
|
||||
const { executeInIsolatedVM } = await loadExecutionModule({
|
||||
envOverrides: {
|
||||
IVM_MAX_QUEUE_SIZE: '1',
|
||||
IVM_QUEUE_TIMEOUT_MS: '200',
|
||||
},
|
||||
spawns: [createStartupFailureProc, createStartupFailureProc, createStartupFailureProc],
|
||||
})
|
||||
|
||||
const firstPromise = executeInIsolatedVM({
|
||||
code: 'return 1',
|
||||
params: {},
|
||||
envVars: {},
|
||||
contextVariables: {},
|
||||
timeoutMs: 100,
|
||||
requestId: 'req-2',
|
||||
ownerKey: 'user:a',
|
||||
})
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 25))
|
||||
|
||||
const second = await executeInIsolatedVM({
|
||||
code: 'return 2',
|
||||
params: {},
|
||||
envVars: {},
|
||||
contextVariables: {},
|
||||
timeoutMs: 100,
|
||||
requestId: 'req-3',
|
||||
ownerKey: 'user:b',
|
||||
})
|
||||
|
||||
expect(second.error?.message).toContain('at capacity')
|
||||
|
||||
const first = await firstPromise
|
||||
expect(first.error?.message).toContain('timed out waiting')
|
||||
})
|
||||
|
||||
it('enforces per-owner queued limit', async () => {
|
||||
const { executeInIsolatedVM } = await loadExecutionModule({
|
||||
envOverrides: {
|
||||
IVM_MAX_QUEUED_PER_OWNER: '1',
|
||||
IVM_QUEUE_TIMEOUT_MS: '200',
|
||||
},
|
||||
spawns: [createStartupFailureProc, createStartupFailureProc, createStartupFailureProc],
|
||||
})
|
||||
|
||||
const firstPromise = executeInIsolatedVM({
|
||||
code: 'return 1',
|
||||
params: {},
|
||||
envVars: {},
|
||||
contextVariables: {},
|
||||
timeoutMs: 100,
|
||||
requestId: 'req-4',
|
||||
ownerKey: 'user:hog',
|
||||
})
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 25))
|
||||
|
||||
const second = await executeInIsolatedVM({
|
||||
code: 'return 2',
|
||||
params: {},
|
||||
envVars: {},
|
||||
contextVariables: {},
|
||||
timeoutMs: 100,
|
||||
requestId: 'req-5',
|
||||
ownerKey: 'user:hog',
|
||||
})
|
||||
|
||||
expect(second.error?.message).toContain('Too many concurrent')
|
||||
|
||||
const first = await firstPromise
|
||||
expect(first.error?.message).toContain('timed out waiting')
|
||||
})
|
||||
|
||||
it('enforces distributed owner in-flight lease limit when Redis is configured', async () => {
|
||||
const { executeInIsolatedVM } = await loadExecutionModule({
|
||||
envOverrides: {
|
||||
IVM_DISTRIBUTED_MAX_INFLIGHT_PER_OWNER: '1',
|
||||
REDIS_URL: 'redis://localhost:6379',
|
||||
},
|
||||
spawns: [() => createReadyProc('ok')],
|
||||
redisEvalImpl: (...args: any[]) => {
|
||||
const script = String(args[0] ?? '')
|
||||
if (script.includes('ZREMRANGEBYSCORE')) {
|
||||
return 0
|
||||
}
|
||||
return 1
|
||||
},
|
||||
})
|
||||
|
||||
const result = await executeInIsolatedVM({
|
||||
code: 'return "blocked"',
|
||||
params: {},
|
||||
envVars: {},
|
||||
contextVariables: {},
|
||||
timeoutMs: 100,
|
||||
requestId: 'req-6',
|
||||
ownerKey: 'user:distributed',
|
||||
})
|
||||
|
||||
expect(result.error?.message).toContain('Too many concurrent')
|
||||
})
|
||||
|
||||
it('fails closed when Redis is configured but unavailable', async () => {
|
||||
const { executeInIsolatedVM } = await loadExecutionModule({
|
||||
envOverrides: {
|
||||
REDIS_URL: 'redis://localhost:6379',
|
||||
},
|
||||
spawns: [() => createReadyProc('ok')],
|
||||
})
|
||||
|
||||
const result = await executeInIsolatedVM({
|
||||
code: 'return "blocked"',
|
||||
params: {},
|
||||
envVars: {},
|
||||
contextVariables: {},
|
||||
timeoutMs: 100,
|
||||
requestId: 'req-7',
|
||||
ownerKey: 'user:redis-down',
|
||||
})
|
||||
|
||||
expect(result.error?.message).toContain('temporarily unavailable')
|
||||
})
|
||||
|
||||
it('fails closed when Redis lease evaluation errors', async () => {
|
||||
const { executeInIsolatedVM } = await loadExecutionModule({
|
||||
envOverrides: {
|
||||
REDIS_URL: 'redis://localhost:6379',
|
||||
},
|
||||
spawns: [() => createReadyProc('ok')],
|
||||
redisEvalImpl: (...args: any[]) => {
|
||||
const script = String(args[0] ?? '')
|
||||
if (script.includes('ZREMRANGEBYSCORE')) {
|
||||
throw new Error('redis timeout')
|
||||
}
|
||||
return 1
|
||||
},
|
||||
})
|
||||
|
||||
const result = await executeInIsolatedVM({
|
||||
code: 'return "blocked"',
|
||||
params: {},
|
||||
envVars: {},
|
||||
contextVariables: {},
|
||||
timeoutMs: 100,
|
||||
requestId: 'req-8',
|
||||
ownerKey: 'user:redis-error',
|
||||
})
|
||||
|
||||
expect(result.error?.message).toContain('temporarily unavailable')
|
||||
})
|
||||
|
||||
it('applies weighted owner scheduling when draining queued executions', async () => {
|
||||
const { executeInIsolatedVM } = await loadExecutionModule({
|
||||
envOverrides: {
|
||||
IVM_MAX_PER_WORKER: '1',
|
||||
},
|
||||
spawns: [() => createReadyProcWithDelay(10)],
|
||||
})
|
||||
|
||||
const completionOrder: string[] = []
|
||||
const pushCompletion = (label: string) => (res: { result: unknown }) => {
|
||||
completionOrder.push(String(res.result ?? label))
|
||||
return res
|
||||
}
|
||||
|
||||
const p1 = executeInIsolatedVM({
|
||||
code: 'return 1',
|
||||
params: {},
|
||||
envVars: {},
|
||||
contextVariables: {},
|
||||
timeoutMs: 500,
|
||||
requestId: 'a-1',
|
||||
ownerKey: 'user:a',
|
||||
ownerWeight: 2,
|
||||
}).then(pushCompletion('a-1'))
|
||||
|
||||
const p2 = executeInIsolatedVM({
|
||||
code: 'return 2',
|
||||
params: {},
|
||||
envVars: {},
|
||||
contextVariables: {},
|
||||
timeoutMs: 500,
|
||||
requestId: 'a-2',
|
||||
ownerKey: 'user:a',
|
||||
ownerWeight: 2,
|
||||
}).then(pushCompletion('a-2'))
|
||||
|
||||
const p3 = executeInIsolatedVM({
|
||||
code: 'return 3',
|
||||
params: {},
|
||||
envVars: {},
|
||||
contextVariables: {},
|
||||
timeoutMs: 500,
|
||||
requestId: 'b-1',
|
||||
ownerKey: 'user:b',
|
||||
ownerWeight: 1,
|
||||
}).then(pushCompletion('b-1'))
|
||||
|
||||
const p4 = executeInIsolatedVM({
|
||||
code: 'return 4',
|
||||
params: {},
|
||||
envVars: {},
|
||||
contextVariables: {},
|
||||
timeoutMs: 500,
|
||||
requestId: 'b-2',
|
||||
ownerKey: 'user:b',
|
||||
ownerWeight: 1,
|
||||
}).then(pushCompletion('b-2'))
|
||||
|
||||
const p5 = executeInIsolatedVM({
|
||||
code: 'return 5',
|
||||
params: {},
|
||||
envVars: {},
|
||||
contextVariables: {},
|
||||
timeoutMs: 500,
|
||||
requestId: 'a-3',
|
||||
ownerKey: 'user:a',
|
||||
ownerWeight: 2,
|
||||
}).then(pushCompletion('a-3'))
|
||||
|
||||
await Promise.all([p1, p2, p3, p4, p5])
|
||||
|
||||
expect(completionOrder.slice(0, 3)).toEqual(['a-1', 'a-2', 'a-3'])
|
||||
expect(completionOrder).toEqual(['a-1', 'a-2', 'a-3', 'b-1', 'b-2'])
|
||||
})
|
||||
|
||||
it('rejects oversized fetch options payloads before outbound call', async () => {
|
||||
const { executeInIsolatedVM, secureFetchMock } = await loadExecutionModule({
|
||||
envOverrides: {
|
||||
IVM_MAX_FETCH_OPTIONS_JSON_CHARS: '50',
|
||||
},
|
||||
spawns: [
|
||||
() =>
|
||||
createReadyFetchProxyProc({
|
||||
url: 'https://example.com',
|
||||
optionsJson: 'x'.repeat(100),
|
||||
}),
|
||||
],
|
||||
})
|
||||
|
||||
const result = await executeInIsolatedVM({
|
||||
code: 'return "fetch-options"',
|
||||
params: {},
|
||||
envVars: {},
|
||||
contextVariables: {},
|
||||
timeoutMs: 100,
|
||||
requestId: 'req-fetch-options',
|
||||
})
|
||||
|
||||
const payload = JSON.parse(String(result.result))
|
||||
expect(payload.error).toContain('Fetch options exceed maximum payload size')
|
||||
expect(secureFetchMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('rejects overly long fetch URLs before outbound call', async () => {
|
||||
const { executeInIsolatedVM, secureFetchMock } = await loadExecutionModule({
|
||||
envOverrides: {
|
||||
IVM_MAX_FETCH_URL_LENGTH: '30',
|
||||
},
|
||||
spawns: [
|
||||
() =>
|
||||
createReadyFetchProxyProc({
|
||||
url: 'https://example.com/path/to/a/very/long/resource',
|
||||
}),
|
||||
],
|
||||
})
|
||||
|
||||
const result = await executeInIsolatedVM({
|
||||
code: 'return "fetch-url"',
|
||||
params: {},
|
||||
envVars: {},
|
||||
contextVariables: {},
|
||||
timeoutMs: 100,
|
||||
requestId: 'req-fetch-url',
|
||||
})
|
||||
|
||||
const payload = JSON.parse(String(result.result))
|
||||
expect(payload.error).toContain('fetch URL exceeds maximum length')
|
||||
expect(secureFetchMock).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
File diff suppressed because it is too large
Load Diff
@@ -124,6 +124,7 @@ export interface PreprocessExecutionOptions {
|
||||
workspaceId?: string // If known, used for billing resolution
|
||||
loggingSession?: LoggingSession // If provided, will be used for error logging
|
||||
isResumeContext?: boolean // If true, allows fallback billing on resolution failure (for paused workflow resumes)
|
||||
useAuthenticatedUserAsActor?: boolean // If true, use the authenticated userId as actorUserId (for client-side executions and personal API keys)
|
||||
/** @deprecated No longer used - background/async executions always use deployed state */
|
||||
useDraftState?: boolean
|
||||
}
|
||||
@@ -170,6 +171,7 @@ export async function preprocessExecution(
|
||||
workspaceId: providedWorkspaceId,
|
||||
loggingSession: providedLoggingSession,
|
||||
isResumeContext = false,
|
||||
useAuthenticatedUserAsActor = false,
|
||||
} = options
|
||||
|
||||
logger.info(`[${requestId}] Starting execution preprocessing`, {
|
||||
@@ -257,7 +259,14 @@ export async function preprocessExecution(
|
||||
let actorUserId: string | null = null
|
||||
|
||||
try {
|
||||
if (workspaceId) {
|
||||
// For client-side executions and personal API keys, the authenticated
|
||||
// user is the billing and permission actor — not the workspace owner.
|
||||
if (useAuthenticatedUserAsActor && userId) {
|
||||
actorUserId = userId
|
||||
logger.info(`[${requestId}] Using authenticated user as actor: ${actorUserId}`)
|
||||
}
|
||||
|
||||
if (!actorUserId && workspaceId) {
|
||||
actorUserId = await getWorkspaceBilledAccountUserId(workspaceId)
|
||||
if (actorUserId) {
|
||||
logger.info(`[${requestId}] Using workspace billed account: ${actorUserId}`)
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
import { db } from '@sim/db'
|
||||
import { account } from '@sim/db/schema'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { eq } from 'drizzle-orm'
|
||||
import { getBaseUrl } from '@/lib/core/utils/urls'
|
||||
import { refreshTokenIfNeeded } from '@/app/api/auth/oauth/utils'
|
||||
import { executeProviderRequest } from '@/providers'
|
||||
import { getApiKey, getProviderFromModel } from '@/providers/utils'
|
||||
import { getProviderFromModel } from '@/providers/utils'
|
||||
|
||||
const logger = createLogger('HallucinationValidator')
|
||||
|
||||
@@ -19,7 +23,18 @@ export interface HallucinationValidationInput {
|
||||
topK: number // Number of chunks to retrieve, default 10
|
||||
model: string
|
||||
apiKey?: string
|
||||
providerCredentials?: {
|
||||
azureEndpoint?: string
|
||||
azureApiVersion?: string
|
||||
vertexProject?: string
|
||||
vertexLocation?: string
|
||||
vertexCredential?: string
|
||||
bedrockAccessKeyId?: string
|
||||
bedrockSecretKey?: string
|
||||
bedrockRegion?: string
|
||||
}
|
||||
workflowId?: string
|
||||
workspaceId?: string
|
||||
requestId: string
|
||||
}
|
||||
|
||||
@@ -89,7 +104,9 @@ async function scoreHallucinationWithLLM(
|
||||
userInput: string,
|
||||
ragContext: string[],
|
||||
model: string,
|
||||
apiKey: string,
|
||||
apiKey: string | undefined,
|
||||
providerCredentials: HallucinationValidationInput['providerCredentials'],
|
||||
workspaceId: string | undefined,
|
||||
requestId: string
|
||||
): Promise<{ score: number; reasoning: string }> {
|
||||
try {
|
||||
@@ -127,6 +144,23 @@ Evaluate the consistency and provide your score and reasoning in JSON format.`
|
||||
|
||||
const providerId = getProviderFromModel(model)
|
||||
|
||||
let finalApiKey: string | undefined = apiKey
|
||||
if (providerId === 'vertex' && providerCredentials?.vertexCredential) {
|
||||
const credential = await db.query.account.findFirst({
|
||||
where: eq(account.id, providerCredentials.vertexCredential),
|
||||
})
|
||||
if (credential) {
|
||||
const { accessToken } = await refreshTokenIfNeeded(
|
||||
requestId,
|
||||
credential,
|
||||
providerCredentials.vertexCredential
|
||||
)
|
||||
if (accessToken) {
|
||||
finalApiKey = accessToken
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const response = await executeProviderRequest(providerId, {
|
||||
model,
|
||||
systemPrompt,
|
||||
@@ -137,7 +171,15 @@ Evaluate the consistency and provide your score and reasoning in JSON format.`
|
||||
},
|
||||
],
|
||||
temperature: 0.1, // Low temperature for consistent scoring
|
||||
apiKey,
|
||||
apiKey: finalApiKey,
|
||||
azureEndpoint: providerCredentials?.azureEndpoint,
|
||||
azureApiVersion: providerCredentials?.azureApiVersion,
|
||||
vertexProject: providerCredentials?.vertexProject,
|
||||
vertexLocation: providerCredentials?.vertexLocation,
|
||||
bedrockAccessKeyId: providerCredentials?.bedrockAccessKeyId,
|
||||
bedrockSecretKey: providerCredentials?.bedrockSecretKey,
|
||||
bedrockRegion: providerCredentials?.bedrockRegion,
|
||||
workspaceId,
|
||||
})
|
||||
|
||||
if (response instanceof ReadableStream || ('stream' in response && 'execution' in response)) {
|
||||
@@ -184,8 +226,18 @@ Evaluate the consistency and provide your score and reasoning in JSON format.`
|
||||
export async function validateHallucination(
|
||||
input: HallucinationValidationInput
|
||||
): Promise<HallucinationValidationResult> {
|
||||
const { userInput, knowledgeBaseId, threshold, topK, model, apiKey, workflowId, requestId } =
|
||||
input
|
||||
const {
|
||||
userInput,
|
||||
knowledgeBaseId,
|
||||
threshold,
|
||||
topK,
|
||||
model,
|
||||
apiKey,
|
||||
providerCredentials,
|
||||
workflowId,
|
||||
workspaceId,
|
||||
requestId,
|
||||
} = input
|
||||
|
||||
try {
|
||||
if (!userInput || userInput.trim().length === 0) {
|
||||
@@ -202,17 +254,6 @@ export async function validateHallucination(
|
||||
}
|
||||
}
|
||||
|
||||
let finalApiKey: string
|
||||
try {
|
||||
const providerId = getProviderFromModel(model)
|
||||
finalApiKey = getApiKey(providerId, model, apiKey)
|
||||
} catch (error: any) {
|
||||
return {
|
||||
passed: false,
|
||||
error: `API key error: ${error.message}`,
|
||||
}
|
||||
}
|
||||
|
||||
// Step 1: Query knowledge base with RAG
|
||||
const ragContext = await queryKnowledgeBase(
|
||||
knowledgeBaseId,
|
||||
@@ -234,7 +275,9 @@ export async function validateHallucination(
|
||||
userInput,
|
||||
ragContext,
|
||||
model,
|
||||
finalApiKey,
|
||||
apiKey,
|
||||
providerCredentials,
|
||||
workspaceId,
|
||||
requestId
|
||||
)
|
||||
|
||||
|
||||
@@ -21,6 +21,11 @@ export const TOKENIZATION_CONFIG = {
|
||||
confidence: 'high',
|
||||
supportedMethods: ['heuristic', 'fallback'],
|
||||
},
|
||||
'azure-anthropic': {
|
||||
avgCharsPerToken: 4.5,
|
||||
confidence: 'high',
|
||||
supportedMethods: ['heuristic', 'fallback'],
|
||||
},
|
||||
google: {
|
||||
avgCharsPerToken: 5,
|
||||
confidence: 'medium',
|
||||
|
||||
@@ -204,6 +204,7 @@ export function estimateTokenCount(text: string, providerId?: string): TokenEsti
|
||||
estimatedTokens = estimateOpenAITokens(text)
|
||||
break
|
||||
case 'anthropic':
|
||||
case 'azure-anthropic':
|
||||
estimatedTokens = estimateAnthropicTokens(text)
|
||||
break
|
||||
case 'google':
|
||||
|
||||
@@ -35,6 +35,8 @@ export const azureAnthropicProvider: ProviderConfig = {
|
||||
// The SDK appends /v1/messages automatically
|
||||
const baseURL = `${request.azureEndpoint.replace(/\/$/, '')}/anthropic`
|
||||
|
||||
const anthropicVersion = request.azureApiVersion || '2023-06-01'
|
||||
|
||||
return executeAnthropicProviderRequest(
|
||||
{
|
||||
...request,
|
||||
@@ -49,7 +51,7 @@ export const azureAnthropicProvider: ProviderConfig = {
|
||||
apiKey,
|
||||
defaultHeaders: {
|
||||
'api-key': apiKey,
|
||||
'anthropic-version': '2023-06-01',
|
||||
'anthropic-version': anthropicVersion,
|
||||
...(useNativeStructuredOutputs
|
||||
? { 'anthropic-beta': 'structured-outputs-2025-11-13' }
|
||||
: {}),
|
||||
|
||||
@@ -9,6 +9,14 @@ export interface GuardrailsValidateInput {
|
||||
topK?: string
|
||||
model?: string
|
||||
apiKey?: string
|
||||
azureEndpoint?: string
|
||||
azureApiVersion?: string
|
||||
vertexProject?: string
|
||||
vertexLocation?: string
|
||||
vertexCredential?: string
|
||||
bedrockAccessKeyId?: string
|
||||
bedrockSecretKey?: string
|
||||
bedrockRegion?: string
|
||||
piiEntityTypes?: string[]
|
||||
piiMode?: string
|
||||
piiLanguage?: string
|
||||
@@ -166,6 +174,14 @@ export const guardrailsValidateTool: ToolConfig<GuardrailsValidateInput, Guardra
|
||||
topK: params.topK,
|
||||
model: params.model,
|
||||
apiKey: params.apiKey,
|
||||
azureEndpoint: params.azureEndpoint,
|
||||
azureApiVersion: params.azureApiVersion,
|
||||
vertexProject: params.vertexProject,
|
||||
vertexLocation: params.vertexLocation,
|
||||
vertexCredential: params.vertexCredential,
|
||||
bedrockAccessKeyId: params.bedrockAccessKeyId,
|
||||
bedrockSecretKey: params.bedrockSecretKey,
|
||||
bedrockRegion: params.bedrockRegion,
|
||||
piiEntityTypes: params.piiEntityTypes,
|
||||
piiMode: params.piiMode,
|
||||
piiLanguage: params.piiLanguage,
|
||||
|
||||
@@ -247,7 +247,8 @@ export async function executeTool(
|
||||
// If it's a custom tool, use the async version with workflowId
|
||||
if (isCustomTool(normalizedToolId)) {
|
||||
const workflowId = params._context?.workflowId
|
||||
tool = await getToolAsync(normalizedToolId, workflowId)
|
||||
const userId = params._context?.userId
|
||||
tool = await getToolAsync(normalizedToolId, workflowId, userId)
|
||||
if (!tool) {
|
||||
logger.error(`[${requestId}] Custom tool not found: ${normalizedToolId}`)
|
||||
}
|
||||
@@ -286,26 +287,25 @@ export async function executeTool(
|
||||
try {
|
||||
const baseUrl = getBaseUrl()
|
||||
|
||||
const workflowId = contextParams._context?.workflowId
|
||||
const userId = contextParams._context?.userId
|
||||
|
||||
const tokenPayload: OAuthTokenPayload = {
|
||||
credentialId: contextParams.credential as string,
|
||||
}
|
||||
|
||||
// Add workflowId if it exists in params, context, or executionContext
|
||||
const workflowId =
|
||||
contextParams.workflowId ||
|
||||
contextParams._context?.workflowId ||
|
||||
executionContext?.workflowId
|
||||
if (workflowId) {
|
||||
tokenPayload.workflowId = workflowId
|
||||
}
|
||||
|
||||
logger.info(`[${requestId}] Fetching access token from ${baseUrl}/api/auth/oauth/token`)
|
||||
|
||||
// Build token URL and also include workflowId in query so server auth can read it
|
||||
const tokenUrlObj = new URL('/api/auth/oauth/token', baseUrl)
|
||||
if (workflowId) {
|
||||
tokenUrlObj.searchParams.set('workflowId', workflowId)
|
||||
}
|
||||
if (userId) {
|
||||
tokenUrlObj.searchParams.set('userId', userId)
|
||||
}
|
||||
|
||||
// Always send Content-Type; add internal auth on server-side runs
|
||||
const tokenHeaders: Record<string, string> = { 'Content-Type': 'application/json' }
|
||||
@@ -609,6 +609,10 @@ async function executeToolRequest(
|
||||
if (workflowId) {
|
||||
fullUrlObj.searchParams.set('workflowId', workflowId)
|
||||
}
|
||||
const userId = params._context?.userId
|
||||
if (userId) {
|
||||
fullUrlObj.searchParams.set('userId', userId)
|
||||
}
|
||||
}
|
||||
|
||||
const fullUrl = fullUrlObj.toString()
|
||||
|
||||
@@ -311,7 +311,8 @@ export function getTool(toolId: string): ToolConfig | undefined {
|
||||
// Get a tool by its ID asynchronously (supports server-side)
|
||||
export async function getToolAsync(
|
||||
toolId: string,
|
||||
workflowId?: string
|
||||
workflowId?: string,
|
||||
userId?: string
|
||||
): Promise<ToolConfig | undefined> {
|
||||
// Check for built-in tools
|
||||
const builtInTool = tools[toolId]
|
||||
@@ -319,7 +320,7 @@ export async function getToolAsync(
|
||||
|
||||
// Check if it's a custom tool
|
||||
if (isCustomTool(toolId)) {
|
||||
return fetchCustomToolFromAPI(toolId, workflowId)
|
||||
return fetchCustomToolFromAPI(toolId, workflowId, userId)
|
||||
}
|
||||
|
||||
return undefined
|
||||
@@ -366,7 +367,8 @@ function createToolConfig(customTool: any, customToolId: string): ToolConfig {
|
||||
// Create a tool config from a custom tool definition by fetching from API
|
||||
async function fetchCustomToolFromAPI(
|
||||
customToolId: string,
|
||||
workflowId?: string
|
||||
workflowId?: string,
|
||||
userId?: string
|
||||
): Promise<ToolConfig | undefined> {
|
||||
const identifier = customToolId.replace('custom_', '')
|
||||
|
||||
@@ -374,10 +376,12 @@ async function fetchCustomToolFromAPI(
|
||||
const baseUrl = getBaseUrl()
|
||||
const url = new URL('/api/tools/custom', baseUrl)
|
||||
|
||||
// Add workflowId as a query parameter if available
|
||||
if (workflowId) {
|
||||
url.searchParams.append('workflowId', workflowId)
|
||||
}
|
||||
if (userId) {
|
||||
url.searchParams.append('userId', userId)
|
||||
}
|
||||
|
||||
// For server-side calls (during workflow execution), use internal JWT token
|
||||
const headers: Record<string, string> = {}
|
||||
|
||||
Reference in New Issue
Block a user