Compare commits

..

10 Commits

Author SHA1 Message Date
Vikhyath Mondreti
7ebe751e8f remove dup test 2026-02-06 21:54:16 -08:00
Vikhyath Mondreti
f615be61f2 fix ollama and vllm visibility 2026-02-06 21:26:31 -08:00
Vikhyath Mondreti
c9691fc437 make webhooks consistent 2026-02-06 21:15:25 -08:00
Vikhyath Mondreti
7ce442f499 fix mcp tools 2026-02-06 20:39:43 -08:00
Vikhyath Mondreti
cf1792e408 Merge branch 'staging' into fix/logs-files 2026-02-06 20:20:14 -08:00
Vikhyath Mondreti
36e6133a08 fix type check 2026-02-06 20:08:49 -08:00
Vikhyath Mondreti
94ad777e5e fix tag defs flag 2026-02-06 20:06:45 -08:00
Vikhyath Mondreti
6ef3b96395 fix tests 2026-02-06 20:02:21 -08:00
Vikhyath Mondreti
8b6796eabe correct degree of access control 2026-02-06 19:52:45 -08:00
Vikhyath Mondreti
895eec3c41 fix(logs): execution files should always use our internal route 2026-02-06 19:33:58 -08:00
39 changed files with 602 additions and 1158 deletions

View File

@@ -89,7 +89,7 @@ export function WorkflowSelector({
onMouseDown={(e) => handleRemove(e, w.id)} onMouseDown={(e) => handleRemove(e, w.id)}
> >
{w.name} {w.name}
<X className='!text-[var(--text-primary)] h-4 w-4 flex-shrink-0 opacity-50' /> <X className='h-3 w-3' />
</Badge> </Badge>
))} ))}
{selectedWorkflows.length > 2 && ( {selectedWorkflows.length > 2 && (

View File

@@ -35,7 +35,6 @@ interface CredentialSelectorProps {
disabled?: boolean disabled?: boolean
isPreview?: boolean isPreview?: boolean
previewValue?: any | null previewValue?: any | null
previewContextValues?: Record<string, unknown>
} }
export function CredentialSelector({ export function CredentialSelector({
@@ -44,7 +43,6 @@ export function CredentialSelector({
disabled = false, disabled = false,
isPreview = false, isPreview = false,
previewValue, previewValue,
previewContextValues,
}: CredentialSelectorProps) { }: CredentialSelectorProps) {
const [showOAuthModal, setShowOAuthModal] = useState(false) const [showOAuthModal, setShowOAuthModal] = useState(false)
const [editingValue, setEditingValue] = useState('') const [editingValue, setEditingValue] = useState('')
@@ -69,11 +67,7 @@ export function CredentialSelector({
canUseCredentialSets canUseCredentialSets
) )
const { depsSatisfied, dependsOn } = useDependsOnGate(blockId, subBlock, { const { depsSatisfied, dependsOn } = useDependsOnGate(blockId, subBlock, { disabled, isPreview })
disabled,
isPreview,
previewContextValues,
})
const hasDependencies = dependsOn.length > 0 const hasDependencies = dependsOn.length > 0
const effectiveDisabled = disabled || (hasDependencies && !depsSatisfied) const effectiveDisabled = disabled || (hasDependencies && !depsSatisfied)

View File

@@ -5,7 +5,6 @@ import { Tooltip } from '@/components/emcn'
import { SelectorCombobox } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/selector-combobox/selector-combobox' import { SelectorCombobox } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/selector-combobox/selector-combobox'
import { useDependsOnGate } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/hooks/use-depends-on-gate' import { useDependsOnGate } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/hooks/use-depends-on-gate'
import { useSubBlockValue } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/hooks/use-sub-block-value' import { useSubBlockValue } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/hooks/use-sub-block-value'
import { resolvePreviewContextValue } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/utils'
import type { SubBlockConfig } from '@/blocks/types' import type { SubBlockConfig } from '@/blocks/types'
import type { SelectorContext } from '@/hooks/selectors/types' import type { SelectorContext } from '@/hooks/selectors/types'
@@ -34,9 +33,7 @@ export function DocumentSelector({
previewContextValues, previewContextValues,
}) })
const [knowledgeBaseIdFromStore] = useSubBlockValue(blockId, 'knowledgeBaseId') const [knowledgeBaseIdFromStore] = useSubBlockValue(blockId, 'knowledgeBaseId')
const knowledgeBaseIdValue = previewContextValues const knowledgeBaseIdValue = previewContextValues?.knowledgeBaseId ?? knowledgeBaseIdFromStore
? resolvePreviewContextValue(previewContextValues.knowledgeBaseId)
: knowledgeBaseIdFromStore
const normalizedKnowledgeBaseId = const normalizedKnowledgeBaseId =
typeof knowledgeBaseIdValue === 'string' && knowledgeBaseIdValue.trim().length > 0 typeof knowledgeBaseIdValue === 'string' && knowledgeBaseIdValue.trim().length > 0
? knowledgeBaseIdValue ? knowledgeBaseIdValue

View File

@@ -17,7 +17,6 @@ import { formatDisplayText } from '@/app/workspace/[workspaceId]/w/[workflowId]/
import { TagDropdown } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/tag-dropdown/tag-dropdown' import { TagDropdown } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/tag-dropdown/tag-dropdown'
import { useSubBlockInput } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/hooks/use-sub-block-input' import { useSubBlockInput } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/hooks/use-sub-block-input'
import { useSubBlockValue } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/hooks/use-sub-block-value' import { useSubBlockValue } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/hooks/use-sub-block-value'
import { resolvePreviewContextValue } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/utils'
import { useAccessibleReferencePrefixes } from '@/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-accessible-reference-prefixes' import { useAccessibleReferencePrefixes } from '@/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-accessible-reference-prefixes'
import type { SubBlockConfig } from '@/blocks/types' import type { SubBlockConfig } from '@/blocks/types'
import { useKnowledgeBaseTagDefinitions } from '@/hooks/kb/use-knowledge-base-tag-definitions' import { useKnowledgeBaseTagDefinitions } from '@/hooks/kb/use-knowledge-base-tag-definitions'
@@ -78,9 +77,7 @@ export function DocumentTagEntry({
}) })
const [knowledgeBaseIdFromStore] = useSubBlockValue(blockId, 'knowledgeBaseId') const [knowledgeBaseIdFromStore] = useSubBlockValue(blockId, 'knowledgeBaseId')
const knowledgeBaseIdValue = previewContextValues const knowledgeBaseIdValue = previewContextValues?.knowledgeBaseId ?? knowledgeBaseIdFromStore
? resolvePreviewContextValue(previewContextValues.knowledgeBaseId)
: knowledgeBaseIdFromStore
const knowledgeBaseId = const knowledgeBaseId =
typeof knowledgeBaseIdValue === 'string' && knowledgeBaseIdValue.trim().length > 0 typeof knowledgeBaseIdValue === 'string' && knowledgeBaseIdValue.trim().length > 0
? knowledgeBaseIdValue ? knowledgeBaseIdValue

View File

@@ -9,7 +9,6 @@ import { SelectorCombobox } from '@/app/workspace/[workspaceId]/w/[workflowId]/c
import { useDependsOnGate } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/hooks/use-depends-on-gate' import { useDependsOnGate } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/hooks/use-depends-on-gate'
import { useForeignCredential } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/hooks/use-foreign-credential' import { useForeignCredential } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/hooks/use-foreign-credential'
import { useSubBlockValue } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/hooks/use-sub-block-value' import { useSubBlockValue } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/hooks/use-sub-block-value'
import { resolvePreviewContextValue } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/utils'
import { getBlock } from '@/blocks/registry' import { getBlock } from '@/blocks/registry'
import type { SubBlockConfig } from '@/blocks/types' import type { SubBlockConfig } from '@/blocks/types'
import { isDependency } from '@/blocks/utils' import { isDependency } from '@/blocks/utils'
@@ -63,56 +62,42 @@ export function FileSelectorInput({
const [domainValueFromStore] = useSubBlockValue(blockId, 'domain') const [domainValueFromStore] = useSubBlockValue(blockId, 'domain')
const connectedCredential = previewContextValues const connectedCredential = previewContextValues?.credential ?? blockValues.credential
? resolvePreviewContextValue(previewContextValues.credential) const domainValue = previewContextValues?.domain ?? domainValueFromStore
: blockValues.credential
const domainValue = previewContextValues
? resolvePreviewContextValue(previewContextValues.domain)
: domainValueFromStore
const teamIdValue = useMemo( const teamIdValue = useMemo(
() => () =>
previewContextValues previewContextValues?.teamId ??
? resolvePreviewContextValue(previewContextValues.teamId) resolveDependencyValue('teamId', blockValues, canonicalIndex, canonicalModeOverrides),
: resolveDependencyValue('teamId', blockValues, canonicalIndex, canonicalModeOverrides), [previewContextValues?.teamId, blockValues, canonicalIndex, canonicalModeOverrides]
[previewContextValues, blockValues, canonicalIndex, canonicalModeOverrides]
) )
const siteIdValue = useMemo( const siteIdValue = useMemo(
() => () =>
previewContextValues previewContextValues?.siteId ??
? resolvePreviewContextValue(previewContextValues.siteId) resolveDependencyValue('siteId', blockValues, canonicalIndex, canonicalModeOverrides),
: resolveDependencyValue('siteId', blockValues, canonicalIndex, canonicalModeOverrides), [previewContextValues?.siteId, blockValues, canonicalIndex, canonicalModeOverrides]
[previewContextValues, blockValues, canonicalIndex, canonicalModeOverrides]
) )
const collectionIdValue = useMemo( const collectionIdValue = useMemo(
() => () =>
previewContextValues previewContextValues?.collectionId ??
? resolvePreviewContextValue(previewContextValues.collectionId) resolveDependencyValue('collectionId', blockValues, canonicalIndex, canonicalModeOverrides),
: resolveDependencyValue( [previewContextValues?.collectionId, blockValues, canonicalIndex, canonicalModeOverrides]
'collectionId',
blockValues,
canonicalIndex,
canonicalModeOverrides
),
[previewContextValues, blockValues, canonicalIndex, canonicalModeOverrides]
) )
const projectIdValue = useMemo( const projectIdValue = useMemo(
() => () =>
previewContextValues previewContextValues?.projectId ??
? resolvePreviewContextValue(previewContextValues.projectId) resolveDependencyValue('projectId', blockValues, canonicalIndex, canonicalModeOverrides),
: resolveDependencyValue('projectId', blockValues, canonicalIndex, canonicalModeOverrides), [previewContextValues?.projectId, blockValues, canonicalIndex, canonicalModeOverrides]
[previewContextValues, blockValues, canonicalIndex, canonicalModeOverrides]
) )
const planIdValue = useMemo( const planIdValue = useMemo(
() => () =>
previewContextValues previewContextValues?.planId ??
? resolvePreviewContextValue(previewContextValues.planId) resolveDependencyValue('planId', blockValues, canonicalIndex, canonicalModeOverrides),
: resolveDependencyValue('planId', blockValues, canonicalIndex, canonicalModeOverrides), [previewContextValues?.planId, blockValues, canonicalIndex, canonicalModeOverrides]
[previewContextValues, blockValues, canonicalIndex, canonicalModeOverrides]
) )
const normalizedCredentialId = const normalizedCredentialId =

View File

@@ -6,7 +6,6 @@ import { SelectorCombobox } from '@/app/workspace/[workspaceId]/w/[workflowId]/c
import { useDependsOnGate } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/hooks/use-depends-on-gate' import { useDependsOnGate } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/hooks/use-depends-on-gate'
import { useForeignCredential } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/hooks/use-foreign-credential' import { useForeignCredential } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/hooks/use-foreign-credential'
import { useSubBlockValue } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/hooks/use-sub-block-value' import { useSubBlockValue } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/hooks/use-sub-block-value'
import { resolvePreviewContextValue } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/utils'
import type { SubBlockConfig } from '@/blocks/types' import type { SubBlockConfig } from '@/blocks/types'
import { resolveSelectorForSubBlock } from '@/hooks/selectors/resolution' import { resolveSelectorForSubBlock } from '@/hooks/selectors/resolution'
import { useCollaborativeWorkflow } from '@/hooks/use-collaborative-workflow' import { useCollaborativeWorkflow } from '@/hooks/use-collaborative-workflow'
@@ -18,7 +17,6 @@ interface FolderSelectorInputProps {
disabled?: boolean disabled?: boolean
isPreview?: boolean isPreview?: boolean
previewValue?: any | null previewValue?: any | null
previewContextValues?: Record<string, unknown>
} }
export function FolderSelectorInput({ export function FolderSelectorInput({
@@ -27,13 +25,9 @@ export function FolderSelectorInput({
disabled = false, disabled = false,
isPreview = false, isPreview = false,
previewValue, previewValue,
previewContextValues,
}: FolderSelectorInputProps) { }: FolderSelectorInputProps) {
const [storeValue] = useSubBlockValue(blockId, subBlock.id) const [storeValue] = useSubBlockValue(blockId, subBlock.id)
const [credentialFromStore] = useSubBlockValue(blockId, 'credential') const [connectedCredential] = useSubBlockValue(blockId, 'credential')
const connectedCredential = previewContextValues
? resolvePreviewContextValue(previewContextValues.credential)
: credentialFromStore
const { collaborativeSetSubblockValue } = useCollaborativeWorkflow() const { collaborativeSetSubblockValue } = useCollaborativeWorkflow()
const { activeWorkflowId } = useWorkflowRegistry() const { activeWorkflowId } = useWorkflowRegistry()
const [selectedFolderId, setSelectedFolderId] = useState<string>('') const [selectedFolderId, setSelectedFolderId] = useState<string>('')
@@ -53,11 +47,7 @@ export function FolderSelectorInput({
) )
// Central dependsOn gating // Central dependsOn gating
const { finalDisabled } = useDependsOnGate(blockId, subBlock, { const { finalDisabled } = useDependsOnGate(blockId, subBlock, { disabled, isPreview })
disabled,
isPreview,
previewContextValues,
})
// Get the current value from the store or prop value if in preview mode // Get the current value from the store or prop value if in preview mode
useEffect(() => { useEffect(() => {

View File

@@ -7,7 +7,6 @@ import { formatDisplayText } from '@/app/workspace/[workspaceId]/w/[workflowId]/
import { TagDropdown } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/tag-dropdown/tag-dropdown' import { TagDropdown } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/tag-dropdown/tag-dropdown'
import { useSubBlockInput } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/hooks/use-sub-block-input' import { useSubBlockInput } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/hooks/use-sub-block-input'
import { useSubBlockValue } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/hooks/use-sub-block-value' import { useSubBlockValue } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/hooks/use-sub-block-value'
import { resolvePreviewContextValue } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/utils'
import { useAccessibleReferencePrefixes } from '@/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-accessible-reference-prefixes' import { useAccessibleReferencePrefixes } from '@/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-accessible-reference-prefixes'
import { useWorkflowState } from '@/hooks/queries/workflows' import { useWorkflowState } from '@/hooks/queries/workflows'
@@ -38,8 +37,6 @@ interface InputMappingProps {
isPreview?: boolean isPreview?: boolean
previewValue?: Record<string, unknown> previewValue?: Record<string, unknown>
disabled?: boolean disabled?: boolean
/** Sub-block values from the preview context for resolving sibling sub-block values */
previewContextValues?: Record<string, unknown>
} }
/** /**
@@ -53,13 +50,9 @@ export function InputMapping({
isPreview = false, isPreview = false,
previewValue, previewValue,
disabled = false, disabled = false,
previewContextValues,
}: InputMappingProps) { }: InputMappingProps) {
const [mapping, setMapping] = useSubBlockValue(blockId, subBlockId) const [mapping, setMapping] = useSubBlockValue(blockId, subBlockId)
const [storeWorkflowId] = useSubBlockValue(blockId, 'workflowId') const [selectedWorkflowId] = useSubBlockValue(blockId, 'workflowId')
const selectedWorkflowId = previewContextValues
? resolvePreviewContextValue(previewContextValues.workflowId)
: storeWorkflowId
const inputController = useSubBlockInput({ const inputController = useSubBlockInput({
blockId, blockId,

View File

@@ -17,7 +17,6 @@ import { type FilterFieldType, getOperatorsForFieldType } from '@/lib/knowledge/
import { formatDisplayText } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/formatted-text' import { formatDisplayText } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/formatted-text'
import { TagDropdown } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/tag-dropdown/tag-dropdown' import { TagDropdown } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/tag-dropdown/tag-dropdown'
import { useSubBlockInput } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/hooks/use-sub-block-input' import { useSubBlockInput } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/hooks/use-sub-block-input'
import { resolvePreviewContextValue } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/utils'
import { useAccessibleReferencePrefixes } from '@/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-accessible-reference-prefixes' import { useAccessibleReferencePrefixes } from '@/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-accessible-reference-prefixes'
import type { SubBlockConfig } from '@/blocks/types' import type { SubBlockConfig } from '@/blocks/types'
import { useKnowledgeBaseTagDefinitions } from '@/hooks/kb/use-knowledge-base-tag-definitions' import { useKnowledgeBaseTagDefinitions } from '@/hooks/kb/use-knowledge-base-tag-definitions'
@@ -70,9 +69,7 @@ export function KnowledgeTagFilters({
const overlayRefs = useRef<Record<string, HTMLDivElement>>({}) const overlayRefs = useRef<Record<string, HTMLDivElement>>({})
const [knowledgeBaseIdFromStore] = useSubBlockValue(blockId, 'knowledgeBaseId') const [knowledgeBaseIdFromStore] = useSubBlockValue(blockId, 'knowledgeBaseId')
const knowledgeBaseIdValue = previewContextValues const knowledgeBaseIdValue = previewContextValues?.knowledgeBaseId ?? knowledgeBaseIdFromStore
? resolvePreviewContextValue(previewContextValues.knowledgeBaseId)
: knowledgeBaseIdFromStore
const knowledgeBaseId = const knowledgeBaseId =
typeof knowledgeBaseIdValue === 'string' && knowledgeBaseIdValue.trim().length > 0 typeof knowledgeBaseIdValue === 'string' && knowledgeBaseIdValue.trim().length > 0
? knowledgeBaseIdValue ? knowledgeBaseIdValue

View File

@@ -6,7 +6,6 @@ import { cn } from '@/lib/core/utils/cn'
import { LongInput } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/long-input/long-input' import { LongInput } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/long-input/long-input'
import { ShortInput } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/short-input/short-input' import { ShortInput } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/short-input/short-input'
import { useSubBlockValue } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/hooks/use-sub-block-value' import { useSubBlockValue } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/hooks/use-sub-block-value'
import { resolvePreviewContextValue } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/utils'
import type { SubBlockConfig } from '@/blocks/types' import type { SubBlockConfig } from '@/blocks/types'
import { useMcpTools } from '@/hooks/mcp/use-mcp-tools' import { useMcpTools } from '@/hooks/mcp/use-mcp-tools'
import { formatParameterLabel } from '@/tools/params' import { formatParameterLabel } from '@/tools/params'
@@ -19,7 +18,6 @@ interface McpDynamicArgsProps {
disabled?: boolean disabled?: boolean
isPreview?: boolean isPreview?: boolean
previewValue?: any previewValue?: any
previewContextValues?: Record<string, unknown>
} }
/** /**
@@ -49,19 +47,12 @@ export function McpDynamicArgs({
disabled = false, disabled = false,
isPreview = false, isPreview = false,
previewValue, previewValue,
previewContextValues,
}: McpDynamicArgsProps) { }: McpDynamicArgsProps) {
const params = useParams() const params = useParams()
const workspaceId = params.workspaceId as string const workspaceId = params.workspaceId as string
const { mcpTools, isLoading } = useMcpTools(workspaceId) const { mcpTools, isLoading } = useMcpTools(workspaceId)
const [toolFromStore] = useSubBlockValue(blockId, 'tool') const [selectedTool] = useSubBlockValue(blockId, 'tool')
const selectedTool = previewContextValues const [cachedSchema] = useSubBlockValue(blockId, '_toolSchema')
? resolvePreviewContextValue(previewContextValues.tool)
: toolFromStore
const [schemaFromStore] = useSubBlockValue(blockId, '_toolSchema')
const cachedSchema = previewContextValues
? resolvePreviewContextValue(previewContextValues._toolSchema)
: schemaFromStore
const [toolArgs, setToolArgs] = useSubBlockValue(blockId, subBlockId) const [toolArgs, setToolArgs] = useSubBlockValue(blockId, subBlockId)
const selectedToolConfig = mcpTools.find((tool) => tool.id === selectedTool) const selectedToolConfig = mcpTools.find((tool) => tool.id === selectedTool)

View File

@@ -4,7 +4,6 @@ import { useEffect, useMemo, useState } from 'react'
import { useParams } from 'next/navigation' import { useParams } from 'next/navigation'
import { Combobox } from '@/components/emcn/components' import { Combobox } from '@/components/emcn/components'
import { useSubBlockValue } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/hooks/use-sub-block-value' import { useSubBlockValue } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/hooks/use-sub-block-value'
import { resolvePreviewContextValue } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/utils'
import type { SubBlockConfig } from '@/blocks/types' import type { SubBlockConfig } from '@/blocks/types'
import { useMcpTools } from '@/hooks/mcp/use-mcp-tools' import { useMcpTools } from '@/hooks/mcp/use-mcp-tools'
@@ -14,7 +13,6 @@ interface McpToolSelectorProps {
disabled?: boolean disabled?: boolean
isPreview?: boolean isPreview?: boolean
previewValue?: string | null previewValue?: string | null
previewContextValues?: Record<string, unknown>
} }
export function McpToolSelector({ export function McpToolSelector({
@@ -23,7 +21,6 @@ export function McpToolSelector({
disabled = false, disabled = false,
isPreview = false, isPreview = false,
previewValue, previewValue,
previewContextValues,
}: McpToolSelectorProps) { }: McpToolSelectorProps) {
const params = useParams() const params = useParams()
const workspaceId = params.workspaceId as string const workspaceId = params.workspaceId as string
@@ -34,10 +31,7 @@ export function McpToolSelector({
const [storeValue, setStoreValue] = useSubBlockValue(blockId, subBlock.id) const [storeValue, setStoreValue] = useSubBlockValue(blockId, subBlock.id)
const [, setSchemaCache] = useSubBlockValue(blockId, '_toolSchema') const [, setSchemaCache] = useSubBlockValue(blockId, '_toolSchema')
const [serverFromStore] = useSubBlockValue(blockId, 'server') const [serverValue] = useSubBlockValue(blockId, 'server')
const serverValue = previewContextValues
? resolvePreviewContextValue(previewContextValues.server)
: serverFromStore
const label = subBlock.placeholder || 'Select tool' const label = subBlock.placeholder || 'Select tool'

View File

@@ -9,7 +9,6 @@ import { SelectorCombobox } from '@/app/workspace/[workspaceId]/w/[workflowId]/c
import { useDependsOnGate } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/hooks/use-depends-on-gate' import { useDependsOnGate } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/hooks/use-depends-on-gate'
import { useForeignCredential } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/hooks/use-foreign-credential' import { useForeignCredential } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/hooks/use-foreign-credential'
import { useSubBlockValue } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/hooks/use-sub-block-value' import { useSubBlockValue } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/hooks/use-sub-block-value'
import { resolvePreviewContextValue } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/utils'
import { getBlock } from '@/blocks/registry' import { getBlock } from '@/blocks/registry'
import type { SubBlockConfig } from '@/blocks/types' import type { SubBlockConfig } from '@/blocks/types'
import { resolveSelectorForSubBlock } from '@/hooks/selectors/resolution' import { resolveSelectorForSubBlock } from '@/hooks/selectors/resolution'
@@ -56,19 +55,14 @@ export function ProjectSelectorInput({
return (workflowValues as Record<string, Record<string, unknown>>)[blockId] || {} return (workflowValues as Record<string, Record<string, unknown>>)[blockId] || {}
}) })
const connectedCredential = previewContextValues const connectedCredential = previewContextValues?.credential ?? blockValues.credential
? resolvePreviewContextValue(previewContextValues.credential) const jiraDomain = previewContextValues?.domain ?? jiraDomainFromStore
: blockValues.credential
const jiraDomain = previewContextValues
? resolvePreviewContextValue(previewContextValues.domain)
: jiraDomainFromStore
const linearTeamId = useMemo( const linearTeamId = useMemo(
() => () =>
previewContextValues previewContextValues?.teamId ??
? resolvePreviewContextValue(previewContextValues.teamId) resolveDependencyValue('teamId', blockValues, canonicalIndex, canonicalModeOverrides),
: resolveDependencyValue('teamId', blockValues, canonicalIndex, canonicalModeOverrides), [previewContextValues?.teamId, blockValues, canonicalIndex, canonicalModeOverrides]
[previewContextValues, blockValues, canonicalIndex, canonicalModeOverrides]
) )
const serviceId = subBlock.serviceId || '' const serviceId = subBlock.serviceId || ''

View File

@@ -8,7 +8,6 @@ import { buildCanonicalIndex, resolveDependencyValue } from '@/lib/workflows/sub
import { SelectorCombobox } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/selector-combobox/selector-combobox' import { SelectorCombobox } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/selector-combobox/selector-combobox'
import { useDependsOnGate } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/hooks/use-depends-on-gate' import { useDependsOnGate } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/hooks/use-depends-on-gate'
import { useForeignCredential } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/hooks/use-foreign-credential' import { useForeignCredential } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/hooks/use-foreign-credential'
import { resolvePreviewContextValue } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/utils'
import { getBlock } from '@/blocks/registry' import { getBlock } from '@/blocks/registry'
import type { SubBlockConfig } from '@/blocks/types' import type { SubBlockConfig } from '@/blocks/types'
import { resolveSelectorForSubBlock, type SelectorResolution } from '@/hooks/selectors/resolution' import { resolveSelectorForSubBlock, type SelectorResolution } from '@/hooks/selectors/resolution'
@@ -67,12 +66,9 @@ export function SheetSelectorInput({
[blockValues, canonicalIndex, canonicalModeOverrides] [blockValues, canonicalIndex, canonicalModeOverrides]
) )
const connectedCredential = previewContextValues const connectedCredential = previewContextValues?.credential ?? connectedCredentialFromStore
? resolvePreviewContextValue(previewContextValues.credential)
: connectedCredentialFromStore
const spreadsheetId = previewContextValues const spreadsheetId = previewContextValues
? (resolvePreviewContextValue(previewContextValues.spreadsheetId) ?? ? (previewContextValues.spreadsheetId ?? previewContextValues.manualSpreadsheetId)
resolvePreviewContextValue(previewContextValues.manualSpreadsheetId))
: spreadsheetIdFromStore : spreadsheetIdFromStore
const normalizedCredentialId = const normalizedCredentialId =

View File

@@ -8,7 +8,6 @@ import { SelectorCombobox } from '@/app/workspace/[workspaceId]/w/[workflowId]/c
import { useDependsOnGate } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/hooks/use-depends-on-gate' import { useDependsOnGate } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/hooks/use-depends-on-gate'
import { useForeignCredential } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/hooks/use-foreign-credential' import { useForeignCredential } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/hooks/use-foreign-credential'
import { useSubBlockValue } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/hooks/use-sub-block-value' import { useSubBlockValue } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/hooks/use-sub-block-value'
import { resolvePreviewContextValue } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/utils'
import type { SubBlockConfig } from '@/blocks/types' import type { SubBlockConfig } from '@/blocks/types'
import type { SelectorContext, SelectorKey } from '@/hooks/selectors/types' import type { SelectorContext, SelectorKey } from '@/hooks/selectors/types'
@@ -59,15 +58,9 @@ export function SlackSelectorInput({
const [botToken] = useSubBlockValue(blockId, 'botToken') const [botToken] = useSubBlockValue(blockId, 'botToken')
const [connectedCredential] = useSubBlockValue(blockId, 'credential') const [connectedCredential] = useSubBlockValue(blockId, 'credential')
const effectiveAuthMethod = previewContextValues const effectiveAuthMethod = previewContextValues?.authMethod ?? authMethod
? resolvePreviewContextValue(previewContextValues.authMethod) const effectiveBotToken = previewContextValues?.botToken ?? botToken
: authMethod const effectiveCredential = previewContextValues?.credential ?? connectedCredential
const effectiveBotToken = previewContextValues
? resolvePreviewContextValue(previewContextValues.botToken)
: botToken
const effectiveCredential = previewContextValues
? resolvePreviewContextValue(previewContextValues.credential)
: connectedCredential
const [_selectedValue, setSelectedValue] = useState<string | null>(null) const [_selectedValue, setSelectedValue] = useState<string | null>(null)
const serviceId = subBlock.serviceId || '' const serviceId = subBlock.serviceId || ''

View File

@@ -332,7 +332,6 @@ function FolderSelectorSyncWrapper({
dependsOn: uiComponent.dependsOn, dependsOn: uiComponent.dependsOn,
}} }}
disabled={disabled} disabled={disabled}
previewContextValues={previewContextValues}
/> />
</GenericSyncWrapper> </GenericSyncWrapper>
) )

View File

@@ -797,7 +797,6 @@ function SubBlockComponent({
disabled={isDisabled} disabled={isDisabled}
isPreview={isPreview} isPreview={isPreview}
previewValue={previewValue} previewValue={previewValue}
previewContextValues={isPreview ? subBlockValues : undefined}
/> />
) )
@@ -833,7 +832,6 @@ function SubBlockComponent({
disabled={isDisabled} disabled={isDisabled}
isPreview={isPreview} isPreview={isPreview}
previewValue={previewValue} previewValue={previewValue}
previewContextValues={isPreview ? subBlockValues : undefined}
/> />
) )
@@ -845,7 +843,6 @@ function SubBlockComponent({
disabled={isDisabled} disabled={isDisabled}
isPreview={isPreview} isPreview={isPreview}
previewValue={previewValue} previewValue={previewValue}
previewContextValues={isPreview ? subBlockValues : undefined}
/> />
) )
@@ -868,7 +865,6 @@ function SubBlockComponent({
disabled={isDisabled} disabled={isDisabled}
isPreview={isPreview} isPreview={isPreview}
previewValue={previewValue as any} previewValue={previewValue as any}
previewContextValues={isPreview ? subBlockValues : undefined}
/> />
) )
@@ -880,7 +876,6 @@ function SubBlockComponent({
disabled={isDisabled} disabled={isDisabled}
isPreview={isPreview} isPreview={isPreview}
previewValue={previewValue as any} previewValue={previewValue as any}
previewContextValues={isPreview ? subBlockValues : undefined}
/> />
) )
@@ -892,7 +887,6 @@ function SubBlockComponent({
disabled={isDisabled} disabled={isDisabled}
isPreview={isPreview} isPreview={isPreview}
previewValue={previewValue as any} previewValue={previewValue as any}
previewContextValues={isPreview ? subBlockValues : undefined}
/> />
) )
@@ -917,7 +911,6 @@ function SubBlockComponent({
isPreview={isPreview} isPreview={isPreview}
previewValue={previewValue as any} previewValue={previewValue as any}
disabled={isDisabled} disabled={isDisabled}
previewContextValues={isPreview ? subBlockValues : undefined}
/> />
) )
@@ -953,7 +946,6 @@ function SubBlockComponent({
disabled={isDisabled} disabled={isDisabled}
isPreview={isPreview} isPreview={isPreview}
previewValue={previewValue} previewValue={previewValue}
previewContextValues={isPreview ? subBlockValues : undefined}
/> />
) )
@@ -987,7 +979,6 @@ function SubBlockComponent({
disabled={isDisabled} disabled={isDisabled}
isPreview={isPreview} isPreview={isPreview}
previewValue={previewValue as any} previewValue={previewValue as any}
previewContextValues={isPreview ? subBlockValues : undefined}
/> />
) )
@@ -999,7 +990,6 @@ function SubBlockComponent({
disabled={isDisabled} disabled={isDisabled}
isPreview={isPreview} isPreview={isPreview}
previewValue={previewValue} previewValue={previewValue}
previewContextValues={isPreview ? subBlockValues : undefined}
/> />
) )

View File

@@ -1,18 +0,0 @@
/**
* Extracts the raw value from a preview context entry.
*
* @remarks
* In the sub-block preview context, values are wrapped as `{ value: T }` objects
* (the full sub-block state). In the tool-input preview context, values are already
* raw. This function normalizes both cases to return the underlying value.
*
* @param raw - The preview context entry, which may be a raw value or a `{ value: T }` wrapper
* @returns The unwrapped value, or `null` if the input is nullish
*/
export function resolvePreviewContextValue(raw: unknown): unknown {
if (raw === null || raw === undefined) return null
if (typeof raw === 'object' && !Array.isArray(raw) && 'value' in raw) {
return (raw as Record<string, unknown>).value ?? null
}
return raw
}

View File

@@ -784,12 +784,8 @@ function PreviewEditorContent({
? childWorkflowSnapshotState ? childWorkflowSnapshotState
: childWorkflowState : childWorkflowState
const resolvedIsLoadingChildWorkflow = isExecutionMode ? false : isLoadingChildWorkflow const resolvedIsLoadingChildWorkflow = isExecutionMode ? false : isLoadingChildWorkflow
const isBlockNotExecuted = isExecutionMode && !executionData
const isMissingChildWorkflow = const isMissingChildWorkflow =
Boolean(childWorkflowId) && Boolean(childWorkflowId) && !resolvedIsLoadingChildWorkflow && !resolvedChildWorkflowState
!isBlockNotExecuted &&
!resolvedIsLoadingChildWorkflow &&
!resolvedChildWorkflowState
/** Drills down into the child workflow or opens it in a new tab */ /** Drills down into the child workflow or opens it in a new tab */
const handleExpandChildWorkflow = useCallback(() => { const handleExpandChildWorkflow = useCallback(() => {
@@ -1196,7 +1192,7 @@ function PreviewEditorContent({
<div ref={subBlocksRef} className='subblocks-section flex flex-1 flex-col overflow-hidden'> <div ref={subBlocksRef} className='subblocks-section flex flex-1 flex-col overflow-hidden'>
<div className='flex-1 overflow-y-auto overflow-x-hidden'> <div className='flex-1 overflow-y-auto overflow-x-hidden'>
{/* Not Executed Banner - shown when in execution mode but block wasn't executed */} {/* Not Executed Banner - shown when in execution mode but block wasn't executed */}
{isBlockNotExecuted && ( {isExecutionMode && !executionData && (
<div className='flex min-w-0 flex-col gap-[8px] overflow-hidden border-[var(--border)] border-b px-[12px] py-[10px]'> <div className='flex min-w-0 flex-col gap-[8px] overflow-hidden border-[var(--border)] border-b px-[12px] py-[10px]'>
<div className='flex items-center justify-between'> <div className='flex items-center justify-between'>
<Badge variant='gray-secondary' size='sm' dot> <Badge variant='gray-secondary' size='sm' dot>
@@ -1423,9 +1419,7 @@ function PreviewEditorContent({
) : ( ) : (
<div className='flex h-full items-center justify-center bg-[var(--surface-3)]'> <div className='flex h-full items-center justify-center bg-[var(--surface-3)]'>
<span className='text-[13px] text-[var(--text-tertiary)]'> <span className='text-[13px] text-[var(--text-tertiary)]'>
{isBlockNotExecuted {isMissingChildWorkflow
? 'Not Executed'
: isMissingChildWorkflow
? DELETED_WORKFLOW_LABEL ? DELETED_WORKFLOW_LABEL
: 'Unable to load preview'} : 'Unable to load preview'}
</span> </span>

View File

@@ -154,7 +154,6 @@ Return ONLY the JSON array.`,
type: 'dropdown', type: 'dropdown',
placeholder: 'Select reasoning effort...', placeholder: 'Select reasoning effort...',
options: [ options: [
{ label: 'auto', id: 'auto' },
{ label: 'low', id: 'low' }, { label: 'low', id: 'low' },
{ label: 'medium', id: 'medium' }, { label: 'medium', id: 'medium' },
{ label: 'high', id: 'high' }, { label: 'high', id: 'high' },
@@ -164,12 +163,9 @@ Return ONLY the JSON array.`,
const { useSubBlockStore } = await import('@/stores/workflows/subblock/store') const { useSubBlockStore } = await import('@/stores/workflows/subblock/store')
const { useWorkflowRegistry } = await import('@/stores/workflows/registry/store') const { useWorkflowRegistry } = await import('@/stores/workflows/registry/store')
const autoOption = { label: 'auto', id: 'auto' }
const activeWorkflowId = useWorkflowRegistry.getState().activeWorkflowId const activeWorkflowId = useWorkflowRegistry.getState().activeWorkflowId
if (!activeWorkflowId) { if (!activeWorkflowId) {
return [ return [
autoOption,
{ label: 'low', id: 'low' }, { label: 'low', id: 'low' },
{ label: 'medium', id: 'medium' }, { label: 'medium', id: 'medium' },
{ label: 'high', id: 'high' }, { label: 'high', id: 'high' },
@@ -182,7 +178,6 @@ Return ONLY the JSON array.`,
if (!modelValue) { if (!modelValue) {
return [ return [
autoOption,
{ label: 'low', id: 'low' }, { label: 'low', id: 'low' },
{ label: 'medium', id: 'medium' }, { label: 'medium', id: 'medium' },
{ label: 'high', id: 'high' }, { label: 'high', id: 'high' },
@@ -192,16 +187,15 @@ Return ONLY the JSON array.`,
const validOptions = getReasoningEffortValuesForModel(modelValue) const validOptions = getReasoningEffortValuesForModel(modelValue)
if (!validOptions) { if (!validOptions) {
return [ return [
autoOption,
{ label: 'low', id: 'low' }, { label: 'low', id: 'low' },
{ label: 'medium', id: 'medium' }, { label: 'medium', id: 'medium' },
{ label: 'high', id: 'high' }, { label: 'high', id: 'high' },
] ]
} }
return [autoOption, ...validOptions.map((opt) => ({ label: opt, id: opt }))] return validOptions.map((opt) => ({ label: opt, id: opt }))
}, },
mode: 'advanced', value: () => 'medium',
condition: { condition: {
field: 'model', field: 'model',
value: MODELS_WITH_REASONING_EFFORT, value: MODELS_WITH_REASONING_EFFORT,
@@ -213,7 +207,6 @@ Return ONLY the JSON array.`,
type: 'dropdown', type: 'dropdown',
placeholder: 'Select verbosity...', placeholder: 'Select verbosity...',
options: [ options: [
{ label: 'auto', id: 'auto' },
{ label: 'low', id: 'low' }, { label: 'low', id: 'low' },
{ label: 'medium', id: 'medium' }, { label: 'medium', id: 'medium' },
{ label: 'high', id: 'high' }, { label: 'high', id: 'high' },
@@ -223,12 +216,9 @@ Return ONLY the JSON array.`,
const { useSubBlockStore } = await import('@/stores/workflows/subblock/store') const { useSubBlockStore } = await import('@/stores/workflows/subblock/store')
const { useWorkflowRegistry } = await import('@/stores/workflows/registry/store') const { useWorkflowRegistry } = await import('@/stores/workflows/registry/store')
const autoOption = { label: 'auto', id: 'auto' }
const activeWorkflowId = useWorkflowRegistry.getState().activeWorkflowId const activeWorkflowId = useWorkflowRegistry.getState().activeWorkflowId
if (!activeWorkflowId) { if (!activeWorkflowId) {
return [ return [
autoOption,
{ label: 'low', id: 'low' }, { label: 'low', id: 'low' },
{ label: 'medium', id: 'medium' }, { label: 'medium', id: 'medium' },
{ label: 'high', id: 'high' }, { label: 'high', id: 'high' },
@@ -241,7 +231,6 @@ Return ONLY the JSON array.`,
if (!modelValue) { if (!modelValue) {
return [ return [
autoOption,
{ label: 'low', id: 'low' }, { label: 'low', id: 'low' },
{ label: 'medium', id: 'medium' }, { label: 'medium', id: 'medium' },
{ label: 'high', id: 'high' }, { label: 'high', id: 'high' },
@@ -251,16 +240,15 @@ Return ONLY the JSON array.`,
const validOptions = getVerbosityValuesForModel(modelValue) const validOptions = getVerbosityValuesForModel(modelValue)
if (!validOptions) { if (!validOptions) {
return [ return [
autoOption,
{ label: 'low', id: 'low' }, { label: 'low', id: 'low' },
{ label: 'medium', id: 'medium' }, { label: 'medium', id: 'medium' },
{ label: 'high', id: 'high' }, { label: 'high', id: 'high' },
] ]
} }
return [autoOption, ...validOptions.map((opt) => ({ label: opt, id: opt }))] return validOptions.map((opt) => ({ label: opt, id: opt }))
}, },
mode: 'advanced', value: () => 'medium',
condition: { condition: {
field: 'model', field: 'model',
value: MODELS_WITH_VERBOSITY, value: MODELS_WITH_VERBOSITY,
@@ -272,7 +260,6 @@ Return ONLY the JSON array.`,
type: 'dropdown', type: 'dropdown',
placeholder: 'Select thinking level...', placeholder: 'Select thinking level...',
options: [ options: [
{ label: 'none', id: 'none' },
{ label: 'minimal', id: 'minimal' }, { label: 'minimal', id: 'minimal' },
{ label: 'low', id: 'low' }, { label: 'low', id: 'low' },
{ label: 'medium', id: 'medium' }, { label: 'medium', id: 'medium' },
@@ -284,11 +271,12 @@ Return ONLY the JSON array.`,
const { useSubBlockStore } = await import('@/stores/workflows/subblock/store') const { useSubBlockStore } = await import('@/stores/workflows/subblock/store')
const { useWorkflowRegistry } = await import('@/stores/workflows/registry/store') const { useWorkflowRegistry } = await import('@/stores/workflows/registry/store')
const noneOption = { label: 'none', id: 'none' }
const activeWorkflowId = useWorkflowRegistry.getState().activeWorkflowId const activeWorkflowId = useWorkflowRegistry.getState().activeWorkflowId
if (!activeWorkflowId) { if (!activeWorkflowId) {
return [noneOption, { label: 'low', id: 'low' }, { label: 'high', id: 'high' }] return [
{ label: 'low', id: 'low' },
{ label: 'high', id: 'high' },
]
} }
const workflowValues = useSubBlockStore.getState().workflowValues[activeWorkflowId] const workflowValues = useSubBlockStore.getState().workflowValues[activeWorkflowId]
@@ -296,17 +284,23 @@ Return ONLY the JSON array.`,
const modelValue = blockValues?.model as string const modelValue = blockValues?.model as string
if (!modelValue) { if (!modelValue) {
return [noneOption, { label: 'low', id: 'low' }, { label: 'high', id: 'high' }] return [
{ label: 'low', id: 'low' },
{ label: 'high', id: 'high' },
]
} }
const validOptions = getThinkingLevelsForModel(modelValue) const validOptions = getThinkingLevelsForModel(modelValue)
if (!validOptions) { if (!validOptions) {
return [noneOption, { label: 'low', id: 'low' }, { label: 'high', id: 'high' }] return [
{ label: 'low', id: 'low' },
{ label: 'high', id: 'high' },
]
} }
return [noneOption, ...validOptions.map((opt) => ({ label: opt, id: opt }))] return validOptions.map((opt) => ({ label: opt, id: opt }))
}, },
mode: 'advanced', value: () => 'high',
condition: { condition: {
field: 'model', field: 'model',
value: MODELS_WITH_THINKING, value: MODELS_WITH_THINKING,
@@ -397,16 +391,6 @@ Return ONLY the JSON array.`,
value: providers.bedrock.models, value: providers.bedrock.models,
}, },
}, },
{
id: 'apiKey',
title: 'API Key',
type: 'short-input',
placeholder: 'Enter your API key',
password: true,
connectionDroppable: false,
required: true,
condition: getApiKeyCondition(),
},
{ {
id: 'tools', id: 'tools',
title: 'Tools', title: 'Tools',
@@ -419,6 +403,16 @@ Return ONLY the JSON array.`,
type: 'skill-input', type: 'skill-input',
defaultValue: [], defaultValue: [],
}, },
{
id: 'apiKey',
title: 'API Key',
type: 'short-input',
placeholder: 'Enter your API key',
password: true,
connectionDroppable: false,
required: true,
condition: getApiKeyCondition(),
},
{ {
id: 'memoryType', id: 'memoryType',
title: 'Memory', title: 'Memory',
@@ -473,7 +467,6 @@ Return ONLY the JSON array.`,
min: 0, min: 0,
max: 1, max: 1,
defaultValue: 0.3, defaultValue: 0.3,
mode: 'advanced',
condition: () => ({ condition: () => ({
field: 'model', field: 'model',
value: (() => { value: (() => {
@@ -491,7 +484,6 @@ Return ONLY the JSON array.`,
min: 0, min: 0,
max: 2, max: 2,
defaultValue: 0.3, defaultValue: 0.3,
mode: 'advanced',
condition: () => ({ condition: () => ({
field: 'model', field: 'model',
value: (() => { value: (() => {
@@ -507,7 +499,6 @@ Return ONLY the JSON array.`,
title: 'Max Output Tokens', title: 'Max Output Tokens',
type: 'short-input', type: 'short-input',
placeholder: 'Enter max tokens (e.g., 4096)...', placeholder: 'Enter max tokens (e.g., 4096)...',
mode: 'advanced',
}, },
{ {
id: 'responseFormat', id: 'responseFormat',

View File

@@ -915,17 +915,24 @@ export class AgentBlockHandler implements BlockHandler {
} }
} }
// Find first system message
const firstSystemIndex = messages.findIndex((msg) => msg.role === 'system') const firstSystemIndex = messages.findIndex((msg) => msg.role === 'system')
if (firstSystemIndex === -1) { if (firstSystemIndex === -1) {
// No system message exists - add at position 0
messages.unshift({ role: 'system', content }) messages.unshift({ role: 'system', content })
} else if (firstSystemIndex === 0) { } else if (firstSystemIndex === 0) {
// System message already at position 0 - replace it
// Explicit systemPrompt parameter takes precedence over memory/messages
messages[0] = { role: 'system', content } messages[0] = { role: 'system', content }
} else { } else {
// System message exists but not at position 0 - move it to position 0
// and update with new content
messages.splice(firstSystemIndex, 1) messages.splice(firstSystemIndex, 1)
messages.unshift({ role: 'system', content }) messages.unshift({ role: 'system', content })
} }
// Remove any additional system messages (keep only the first one)
for (let i = messages.length - 1; i >= 1; i--) { for (let i = messages.length - 1; i >= 1; i--) {
if (messages[i].role === 'system') { if (messages[i].role === 'system') {
messages.splice(i, 1) messages.splice(i, 1)
@@ -991,14 +998,13 @@ export class AgentBlockHandler implements BlockHandler {
workflowId: ctx.workflowId, workflowId: ctx.workflowId,
workspaceId: ctx.workspaceId, workspaceId: ctx.workspaceId,
stream: streaming, stream: streaming,
messages: messages?.map(({ executionId, ...msg }) => msg), messages,
environmentVariables: ctx.environmentVariables || {}, environmentVariables: ctx.environmentVariables || {},
workflowVariables: ctx.workflowVariables || {}, workflowVariables: ctx.workflowVariables || {},
blockData, blockData,
blockNameMapping, blockNameMapping,
reasoningEffort: inputs.reasoningEffort, reasoningEffort: inputs.reasoningEffort,
verbosity: inputs.verbosity, verbosity: inputs.verbosity,
thinkingLevel: inputs.thinkingLevel,
} }
} }
@@ -1068,7 +1074,6 @@ export class AgentBlockHandler implements BlockHandler {
isDeployedContext: ctx.isDeployedContext, isDeployedContext: ctx.isDeployedContext,
reasoningEffort: providerRequest.reasoningEffort, reasoningEffort: providerRequest.reasoningEffort,
verbosity: providerRequest.verbosity, verbosity: providerRequest.verbosity,
thinkingLevel: providerRequest.thinkingLevel,
}) })
return this.processProviderResponse(response, block, responseFormat) return this.processProviderResponse(response, block, responseFormat)
@@ -1086,6 +1091,8 @@ export class AgentBlockHandler implements BlockHandler {
logger.info(`[${requestId}] Resolving Vertex AI credential: ${credentialId}`) logger.info(`[${requestId}] Resolving Vertex AI credential: ${credentialId}`)
// Get the credential - we need to find the owner
// Since we're in a workflow context, we can query the credential directly
const credential = await db.query.account.findFirst({ const credential = await db.query.account.findFirst({
where: eq(account.id, credentialId), where: eq(account.id, credentialId),
}) })
@@ -1094,6 +1101,7 @@ export class AgentBlockHandler implements BlockHandler {
throw new Error(`Vertex AI credential not found: ${credentialId}`) throw new Error(`Vertex AI credential not found: ${credentialId}`)
} }
// Refresh the token if needed
const { accessToken } = await refreshTokenIfNeeded(requestId, credential, credentialId) const { accessToken } = await refreshTokenIfNeeded(requestId, credential, credentialId)
if (!accessToken) { if (!accessToken) {

View File

@@ -34,7 +34,6 @@ export interface AgentInputs {
bedrockRegion?: string bedrockRegion?: string
reasoningEffort?: string reasoningEffort?: string
verbosity?: string verbosity?: string
thinkingLevel?: string
} }
export interface ToolInput { export interface ToolInput {

View File

@@ -33,25 +33,11 @@ export class SnapshotService implements ISnapshotService {
const existingSnapshot = await this.getSnapshotByHash(workflowId, stateHash) const existingSnapshot = await this.getSnapshotByHash(workflowId, stateHash)
if (existingSnapshot) { if (existingSnapshot) {
let refreshedState: WorkflowState = existingSnapshot.stateData
try {
await db
.update(workflowExecutionSnapshots)
.set({ stateData: state })
.where(eq(workflowExecutionSnapshots.id, existingSnapshot.id))
refreshedState = state
} catch (error) {
logger.warn(
`Failed to refresh snapshot stateData for ${existingSnapshot.id}, continuing with existing data`,
error
)
}
logger.info( logger.info(
`Reusing existing snapshot for workflow ${workflowId} (hash: ${stateHash.slice(0, 12)}...)` `Reusing existing snapshot for workflow ${workflowId} (hash: ${stateHash.slice(0, 12)}...)`
) )
return { return {
snapshot: { ...existingSnapshot, stateData: refreshedState }, snapshot: existingSnapshot,
isNew: false, isNew: false,
} }
} }

View File

@@ -1,6 +1,5 @@
import type Anthropic from '@anthropic-ai/sdk' import type Anthropic from '@anthropic-ai/sdk'
import { transformJSONSchema } from '@anthropic-ai/sdk/lib/transform-json-schema' import { transformJSONSchema } from '@anthropic-ai/sdk/lib/transform-json-schema'
import type { RawMessageStreamEvent } from '@anthropic-ai/sdk/resources/messages/messages'
import type { Logger } from '@sim/logger' import type { Logger } from '@sim/logger'
import type { StreamingExecution } from '@/executor/types' import type { StreamingExecution } from '@/executor/types'
import { MAX_TOOL_ITERATIONS } from '@/providers' import { MAX_TOOL_ITERATIONS } from '@/providers'
@@ -35,21 +34,11 @@ export interface AnthropicProviderConfig {
logger: Logger logger: Logger
} }
/**
* Custom payload type extending the SDK's base message creation params.
* Adds fields not yet in the SDK: adaptive thinking, output_format, output_config.
*/
interface AnthropicPayload extends Omit<Anthropic.Messages.MessageStreamParams, 'thinking'> {
thinking?: Anthropic.Messages.ThinkingConfigParam | { type: 'adaptive' }
output_format?: { type: 'json_schema'; schema: Record<string, unknown> }
output_config?: { effort: string }
}
/** /**
* Generates prompt-based schema instructions for older models that don't support native structured outputs. * Generates prompt-based schema instructions for older models that don't support native structured outputs.
* This is a fallback approach that adds schema requirements to the system prompt. * This is a fallback approach that adds schema requirements to the system prompt.
*/ */
function generateSchemaInstructions(schema: Record<string, unknown>, schemaName?: string): string { function generateSchemaInstructions(schema: any, schemaName?: string): string {
const name = schemaName || 'response' const name = schemaName || 'response'
return `IMPORTANT: You must respond with a valid JSON object that conforms to the following schema. return `IMPORTANT: You must respond with a valid JSON object that conforms to the following schema.
Do not include any text before or after the JSON object. Only output the JSON. Do not include any text before or after the JSON object. Only output the JSON.
@@ -124,30 +113,6 @@ function buildThinkingConfig(
} }
} }
/**
* The Anthropic SDK requires streaming for non-streaming requests when max_tokens exceeds
* this threshold, to avoid HTTP timeouts. When thinking is enabled and pushes max_tokens
* above this limit, we use streaming internally and collect the final message.
*/
const ANTHROPIC_SDK_NON_STREAMING_MAX_TOKENS = 21333
/**
* Creates an Anthropic message, automatically using streaming internally when max_tokens
* exceeds the SDK's non-streaming threshold. Returns the same Message object either way.
*/
async function createMessage(
anthropic: Anthropic,
payload: AnthropicPayload
): Promise<Anthropic.Messages.Message> {
if (payload.max_tokens > ANTHROPIC_SDK_NON_STREAMING_MAX_TOKENS && !payload.stream) {
const stream = anthropic.messages.stream(payload as Anthropic.Messages.MessageStreamParams)
return stream.finalMessage()
}
return anthropic.messages.create(
payload as Anthropic.Messages.MessageCreateParamsNonStreaming
) as Promise<Anthropic.Messages.Message>
}
/** /**
* Executes a request using the Anthropic API with full tool loop support. * Executes a request using the Anthropic API with full tool loop support.
* This is the shared core implementation used by both the standard Anthropic provider * This is the shared core implementation used by both the standard Anthropic provider
@@ -170,7 +135,7 @@ export async function executeAnthropicProviderRequest(
const anthropic = config.createClient(request.apiKey, useNativeStructuredOutputs) const anthropic = config.createClient(request.apiKey, useNativeStructuredOutputs)
const messages: Anthropic.Messages.MessageParam[] = [] const messages: any[] = []
let systemPrompt = request.systemPrompt || '' let systemPrompt = request.systemPrompt || ''
if (request.context) { if (request.context) {
@@ -188,8 +153,8 @@ export async function executeAnthropicProviderRequest(
content: [ content: [
{ {
type: 'tool_result', type: 'tool_result',
tool_use_id: msg.name || '', tool_use_id: msg.name,
content: msg.content || undefined, content: msg.content,
}, },
], ],
}) })
@@ -223,12 +188,12 @@ export async function executeAnthropicProviderRequest(
systemPrompt = '' systemPrompt = ''
} }
let anthropicTools: Anthropic.Messages.Tool[] | undefined = request.tools?.length let anthropicTools = request.tools?.length
? request.tools.map((tool) => ({ ? request.tools.map((tool) => ({
name: tool.id, name: tool.id,
description: tool.description, description: tool.description,
input_schema: { input_schema: {
type: 'object' as const, type: 'object',
properties: tool.parameters.properties, properties: tool.parameters.properties,
required: tool.parameters.required, required: tool.parameters.required,
}, },
@@ -273,12 +238,13 @@ export async function executeAnthropicProviderRequest(
} }
} }
const payload: AnthropicPayload = { const payload: any = {
model: request.model, model: request.model,
messages, messages,
system: systemPrompt, system: systemPrompt,
max_tokens: max_tokens:
Number.parseInt(String(request.maxTokens)) || getMaxOutputTokensForModel(request.model), Number.parseInt(String(request.maxTokens)) ||
getMaxOutputTokensForModel(request.model, request.stream ?? false),
temperature: Number.parseFloat(String(request.temperature ?? 0.7)), temperature: Number.parseFloat(String(request.temperature ?? 0.7)),
} }
@@ -302,35 +268,13 @@ export async function executeAnthropicProviderRequest(
} }
// Add extended thinking configuration if supported and requested // Add extended thinking configuration if supported and requested
// The 'none' sentinel means "disable thinking" — skip configuration entirely. if (request.thinkingLevel) {
if (request.thinkingLevel && request.thinkingLevel !== 'none') {
const thinkingConfig = buildThinkingConfig(request.model, request.thinkingLevel) const thinkingConfig = buildThinkingConfig(request.model, request.thinkingLevel)
if (thinkingConfig) { if (thinkingConfig) {
payload.thinking = thinkingConfig.thinking payload.thinking = thinkingConfig.thinking
if (thinkingConfig.outputConfig) { if (thinkingConfig.outputConfig) {
payload.output_config = thinkingConfig.outputConfig payload.output_config = thinkingConfig.outputConfig
} }
// Per Anthropic docs: budget_tokens must be less than max_tokens.
// Ensure max_tokens leaves room for both thinking and text output.
if (
thinkingConfig.thinking.type === 'enabled' &&
'budget_tokens' in thinkingConfig.thinking
) {
const budgetTokens = thinkingConfig.thinking.budget_tokens
const minMaxTokens = budgetTokens + 4096
if (payload.max_tokens < minMaxTokens) {
const modelMax = getMaxOutputTokensForModel(request.model)
payload.max_tokens = Math.min(minMaxTokens, modelMax)
logger.info(
`Adjusted max_tokens to ${payload.max_tokens} to satisfy budget_tokens (${budgetTokens}) constraint`
)
}
}
// Per Anthropic docs: thinking is not compatible with temperature or top_k modifications.
payload.temperature = undefined
const isAdaptive = thinkingConfig.thinking.type === 'adaptive' const isAdaptive = thinkingConfig.thinking.type === 'adaptive'
logger.info( logger.info(
`Using ${isAdaptive ? 'adaptive' : 'extended'} thinking for model: ${modelId} with ${isAdaptive ? `effort: ${request.thinkingLevel}` : `budget: ${(thinkingConfig.thinking as { budget_tokens: number }).budget_tokens}`}` `Using ${isAdaptive ? 'adaptive' : 'extended'} thinking for model: ${modelId} with ${isAdaptive ? `effort: ${request.thinkingLevel}` : `budget: ${(thinkingConfig.thinking as { budget_tokens: number }).budget_tokens}`}`
@@ -344,16 +288,7 @@ export async function executeAnthropicProviderRequest(
if (anthropicTools?.length) { if (anthropicTools?.length) {
payload.tools = anthropicTools payload.tools = anthropicTools
// Per Anthropic docs: forced tool_choice (type: "tool" or "any") is incompatible with if (toolChoice !== 'auto') {
// thinking. Only auto and none are supported when thinking is enabled.
if (payload.thinking) {
// Per Anthropic docs: only 'auto' (default) and 'none' work with thinking.
if (toolChoice === 'none') {
payload.tool_choice = { type: 'none' }
}
} else if (toolChoice === 'none') {
payload.tool_choice = { type: 'none' }
} else if (toolChoice !== 'auto') {
payload.tool_choice = toolChoice payload.tool_choice = toolChoice
} }
} }
@@ -366,15 +301,13 @@ export async function executeAnthropicProviderRequest(
const providerStartTime = Date.now() const providerStartTime = Date.now()
const providerStartTimeISO = new Date(providerStartTime).toISOString() const providerStartTimeISO = new Date(providerStartTime).toISOString()
const streamResponse = await anthropic.messages.create({ const streamResponse: any = await anthropic.messages.create({
...payload, ...payload,
stream: true, stream: true,
} as Anthropic.Messages.MessageCreateParamsStreaming) })
const streamingResult = { const streamingResult = {
stream: createReadableStreamFromAnthropicStream( stream: createReadableStreamFromAnthropicStream(streamResponse, (content, usage) => {
streamResponse as AsyncIterable<RawMessageStreamEvent>,
(content, usage) => {
streamingResult.execution.output.content = content streamingResult.execution.output.content = content
streamingResult.execution.output.tokens = { streamingResult.execution.output.tokens = {
input: usage.input_tokens, input: usage.input_tokens,
@@ -398,14 +331,12 @@ export async function executeAnthropicProviderRequest(
streamEndTime - providerStartTime streamEndTime - providerStartTime
if (streamingResult.execution.output.providerTiming.timeSegments?.[0]) { if (streamingResult.execution.output.providerTiming.timeSegments?.[0]) {
streamingResult.execution.output.providerTiming.timeSegments[0].endTime = streamingResult.execution.output.providerTiming.timeSegments[0].endTime = streamEndTime
streamEndTime
streamingResult.execution.output.providerTiming.timeSegments[0].duration = streamingResult.execution.output.providerTiming.timeSegments[0].duration =
streamEndTime - providerStartTime streamEndTime - providerStartTime
} }
} }
} }),
),
execution: { execution: {
success: true, success: true,
output: { output: {
@@ -454,13 +385,21 @@ export async function executeAnthropicProviderRequest(
const providerStartTime = Date.now() const providerStartTime = Date.now()
const providerStartTimeISO = new Date(providerStartTime).toISOString() const providerStartTimeISO = new Date(providerStartTime).toISOString()
// Cap intermediate calls at non-streaming limit to avoid SDK timeout errors,
// but allow users to set lower values if desired
const nonStreamingLimit = getMaxOutputTokensForModel(request.model, false)
const nonStreamingMaxTokens = request.maxTokens
? Math.min(Number.parseInt(String(request.maxTokens)), nonStreamingLimit)
: nonStreamingLimit
const intermediatePayload = { ...payload, max_tokens: nonStreamingMaxTokens }
try { try {
const initialCallTime = Date.now() const initialCallTime = Date.now()
const originalToolChoice = payload.tool_choice const originalToolChoice = intermediatePayload.tool_choice
const forcedTools = preparedTools?.forcedTools || [] const forcedTools = preparedTools?.forcedTools || []
let usedForcedTools: string[] = [] let usedForcedTools: string[] = []
let currentResponse = await createMessage(anthropic, payload) let currentResponse = await anthropic.messages.create(intermediatePayload)
const firstResponseTime = Date.now() - initialCallTime const firstResponseTime = Date.now() - initialCallTime
let content = '' let content = ''
@@ -529,10 +468,10 @@ export async function executeAnthropicProviderRequest(
const toolExecutionPromises = toolUses.map(async (toolUse) => { const toolExecutionPromises = toolUses.map(async (toolUse) => {
const toolCallStartTime = Date.now() const toolCallStartTime = Date.now()
const toolName = toolUse.name const toolName = toolUse.name
const toolArgs = toolUse.input as Record<string, unknown> const toolArgs = toolUse.input as Record<string, any>
try { try {
const tool = request.tools?.find((t) => t.id === toolName) const tool = request.tools?.find((t: any) => t.id === toolName)
if (!tool) return null if (!tool) return null
const { toolParams, executionParams } = prepareToolExecution(tool, toolArgs, request) const { toolParams, executionParams } = prepareToolExecution(tool, toolArgs, request)
@@ -573,8 +512,17 @@ export async function executeAnthropicProviderRequest(
const executionResults = await Promise.allSettled(toolExecutionPromises) const executionResults = await Promise.allSettled(toolExecutionPromises)
// Collect all tool_use and tool_result blocks for batching // Collect all tool_use and tool_result blocks for batching
const toolUseBlocks: Anthropic.Messages.ToolUseBlockParam[] = [] const toolUseBlocks: Array<{
const toolResultBlocks: Anthropic.Messages.ToolResultBlockParam[] = [] type: 'tool_use'
id: string
name: string
input: Record<string, unknown>
}> = []
const toolResultBlocks: Array<{
type: 'tool_result'
tool_use_id: string
content: string
}> = []
for (const settledResult of executionResults) { for (const settledResult of executionResults) {
if (settledResult.status === 'rejected' || !settledResult.value) continue if (settledResult.status === 'rejected' || !settledResult.value) continue
@@ -635,25 +583,11 @@ export async function executeAnthropicProviderRequest(
}) })
} }
// Per Anthropic docs: thinking blocks must be preserved in assistant messages // Add ONE assistant message with ALL tool_use blocks
// during tool use to maintain reasoning continuity.
const thinkingBlocks = currentResponse.content.filter(
(
item
): item is
| Anthropic.Messages.ThinkingBlock
| Anthropic.Messages.RedactedThinkingBlock =>
item.type === 'thinking' || item.type === 'redacted_thinking'
)
// Add ONE assistant message with thinking + tool_use blocks
if (toolUseBlocks.length > 0) { if (toolUseBlocks.length > 0) {
currentMessages.push({ currentMessages.push({
role: 'assistant', role: 'assistant',
content: [ content: toolUseBlocks as unknown as Anthropic.Messages.ContentBlock[],
...thinkingBlocks,
...toolUseBlocks,
] as Anthropic.Messages.ContentBlockParam[],
}) })
} }
@@ -661,23 +595,19 @@ export async function executeAnthropicProviderRequest(
if (toolResultBlocks.length > 0) { if (toolResultBlocks.length > 0) {
currentMessages.push({ currentMessages.push({
role: 'user', role: 'user',
content: toolResultBlocks as Anthropic.Messages.ContentBlockParam[], content: toolResultBlocks as unknown as Anthropic.Messages.ContentBlockParam[],
}) })
} }
const thisToolsTime = Date.now() - toolsStartTime const thisToolsTime = Date.now() - toolsStartTime
toolsTime += thisToolsTime toolsTime += thisToolsTime
const nextPayload: AnthropicPayload = { const nextPayload = {
...payload, ...intermediatePayload,
messages: currentMessages, messages: currentMessages,
} }
// Per Anthropic docs: forced tool_choice is incompatible with thinking.
// Only auto and none are supported when thinking is enabled.
const thinkingEnabled = !!payload.thinking
if ( if (
!thinkingEnabled &&
typeof originalToolChoice === 'object' && typeof originalToolChoice === 'object' &&
hasUsedForcedTool && hasUsedForcedTool &&
forcedTools.length > 0 forcedTools.length > 0
@@ -694,11 +624,7 @@ export async function executeAnthropicProviderRequest(
nextPayload.tool_choice = undefined nextPayload.tool_choice = undefined
logger.info('All forced tools have been used, removing tool_choice parameter') logger.info('All forced tools have been used, removing tool_choice parameter')
} }
} else if ( } else if (hasUsedForcedTool && typeof originalToolChoice === 'object') {
!thinkingEnabled &&
hasUsedForcedTool &&
typeof originalToolChoice === 'object'
) {
nextPayload.tool_choice = undefined nextPayload.tool_choice = undefined
logger.info( logger.info(
'Removing tool_choice parameter for subsequent requests after forced tool was used' 'Removing tool_choice parameter for subsequent requests after forced tool was used'
@@ -707,7 +633,7 @@ export async function executeAnthropicProviderRequest(
const nextModelStartTime = Date.now() const nextModelStartTime = Date.now()
currentResponse = await createMessage(anthropic, nextPayload) currentResponse = await anthropic.messages.create(nextPayload)
const nextCheckResult = checkForForcedToolUsage( const nextCheckResult = checkForForcedToolUsage(
currentResponse, currentResponse,
@@ -756,14 +682,10 @@ export async function executeAnthropicProviderRequest(
tool_choice: undefined, tool_choice: undefined,
} }
const streamResponse = await anthropic.messages.create( const streamResponse: any = await anthropic.messages.create(streamingPayload)
streamingPayload as Anthropic.Messages.MessageCreateParamsStreaming
)
const streamingResult = { const streamingResult = {
stream: createReadableStreamFromAnthropicStream( stream: createReadableStreamFromAnthropicStream(streamResponse, (streamContent, usage) => {
streamResponse as AsyncIterable<RawMessageStreamEvent>,
(streamContent, usage) => {
streamingResult.execution.output.content = streamContent streamingResult.execution.output.content = streamContent
streamingResult.execution.output.tokens = { streamingResult.execution.output.tokens = {
input: tokens.input + usage.input_tokens, input: tokens.input + usage.input_tokens,
@@ -786,8 +708,7 @@ export async function executeAnthropicProviderRequest(
streamingResult.execution.output.providerTiming.duration = streamingResult.execution.output.providerTiming.duration =
streamEndTime - providerStartTime streamEndTime - providerStartTime
} }
} }),
),
execution: { execution: {
success: true, success: true,
output: { output: {
@@ -857,13 +778,21 @@ export async function executeAnthropicProviderRequest(
const providerStartTime = Date.now() const providerStartTime = Date.now()
const providerStartTimeISO = new Date(providerStartTime).toISOString() const providerStartTimeISO = new Date(providerStartTime).toISOString()
// Cap intermediate calls at non-streaming limit to avoid SDK timeout errors,
// but allow users to set lower values if desired
const nonStreamingLimit = getMaxOutputTokensForModel(request.model, false)
const toolLoopMaxTokens = request.maxTokens
? Math.min(Number.parseInt(String(request.maxTokens)), nonStreamingLimit)
: nonStreamingLimit
const toolLoopPayload = { ...payload, max_tokens: toolLoopMaxTokens }
try { try {
const initialCallTime = Date.now() const initialCallTime = Date.now()
const originalToolChoice = payload.tool_choice const originalToolChoice = toolLoopPayload.tool_choice
const forcedTools = preparedTools?.forcedTools || [] const forcedTools = preparedTools?.forcedTools || []
let usedForcedTools: string[] = [] let usedForcedTools: string[] = []
let currentResponse = await createMessage(anthropic, payload) let currentResponse = await anthropic.messages.create(toolLoopPayload)
const firstResponseTime = Date.now() - initialCallTime const firstResponseTime = Date.now() - initialCallTime
let content = '' let content = ''
@@ -943,7 +872,7 @@ export async function executeAnthropicProviderRequest(
const toolExecutionPromises = toolUses.map(async (toolUse) => { const toolExecutionPromises = toolUses.map(async (toolUse) => {
const toolCallStartTime = Date.now() const toolCallStartTime = Date.now()
const toolName = toolUse.name const toolName = toolUse.name
const toolArgs = toolUse.input as Record<string, unknown> const toolArgs = toolUse.input as Record<string, any>
// Preserve the original tool_use ID from Claude's response // Preserve the original tool_use ID from Claude's response
const toolUseId = toolUse.id const toolUseId = toolUse.id
@@ -989,8 +918,17 @@ export async function executeAnthropicProviderRequest(
const executionResults = await Promise.allSettled(toolExecutionPromises) const executionResults = await Promise.allSettled(toolExecutionPromises)
// Collect all tool_use and tool_result blocks for batching // Collect all tool_use and tool_result blocks for batching
const toolUseBlocks: Anthropic.Messages.ToolUseBlockParam[] = [] const toolUseBlocks: Array<{
const toolResultBlocks: Anthropic.Messages.ToolResultBlockParam[] = [] type: 'tool_use'
id: string
name: string
input: Record<string, unknown>
}> = []
const toolResultBlocks: Array<{
type: 'tool_result'
tool_use_id: string
content: string
}> = []
for (const settledResult of executionResults) { for (const settledResult of executionResults) {
if (settledResult.status === 'rejected' || !settledResult.value) continue if (settledResult.status === 'rejected' || !settledResult.value) continue
@@ -1051,23 +989,11 @@ export async function executeAnthropicProviderRequest(
}) })
} }
// Per Anthropic docs: thinking blocks must be preserved in assistant messages // Add ONE assistant message with ALL tool_use blocks
// during tool use to maintain reasoning continuity.
const thinkingBlocks = currentResponse.content.filter(
(
item
): item is Anthropic.Messages.ThinkingBlock | Anthropic.Messages.RedactedThinkingBlock =>
item.type === 'thinking' || item.type === 'redacted_thinking'
)
// Add ONE assistant message with thinking + tool_use blocks
if (toolUseBlocks.length > 0) { if (toolUseBlocks.length > 0) {
currentMessages.push({ currentMessages.push({
role: 'assistant', role: 'assistant',
content: [ content: toolUseBlocks as unknown as Anthropic.Messages.ContentBlock[],
...thinkingBlocks,
...toolUseBlocks,
] as Anthropic.Messages.ContentBlockParam[],
}) })
} }
@@ -1075,27 +1001,19 @@ export async function executeAnthropicProviderRequest(
if (toolResultBlocks.length > 0) { if (toolResultBlocks.length > 0) {
currentMessages.push({ currentMessages.push({
role: 'user', role: 'user',
content: toolResultBlocks as Anthropic.Messages.ContentBlockParam[], content: toolResultBlocks as unknown as Anthropic.Messages.ContentBlockParam[],
}) })
} }
const thisToolsTime = Date.now() - toolsStartTime const thisToolsTime = Date.now() - toolsStartTime
toolsTime += thisToolsTime toolsTime += thisToolsTime
const nextPayload: AnthropicPayload = { const nextPayload = {
...payload, ...toolLoopPayload,
messages: currentMessages, messages: currentMessages,
} }
// Per Anthropic docs: forced tool_choice is incompatible with thinking. if (typeof originalToolChoice === 'object' && hasUsedForcedTool && forcedTools.length > 0) {
// Only auto and none are supported when thinking is enabled.
const thinkingEnabled = !!payload.thinking
if (
!thinkingEnabled &&
typeof originalToolChoice === 'object' &&
hasUsedForcedTool &&
forcedTools.length > 0
) {
const remainingTools = forcedTools.filter((tool) => !usedForcedTools.includes(tool)) const remainingTools = forcedTools.filter((tool) => !usedForcedTools.includes(tool))
if (remainingTools.length > 0) { if (remainingTools.length > 0) {
@@ -1108,11 +1026,7 @@ export async function executeAnthropicProviderRequest(
nextPayload.tool_choice = undefined nextPayload.tool_choice = undefined
logger.info('All forced tools have been used, removing tool_choice parameter') logger.info('All forced tools have been used, removing tool_choice parameter')
} }
} else if ( } else if (hasUsedForcedTool && typeof originalToolChoice === 'object') {
!thinkingEnabled &&
hasUsedForcedTool &&
typeof originalToolChoice === 'object'
) {
nextPayload.tool_choice = undefined nextPayload.tool_choice = undefined
logger.info( logger.info(
'Removing tool_choice parameter for subsequent requests after forced tool was used' 'Removing tool_choice parameter for subsequent requests after forced tool was used'
@@ -1121,7 +1035,7 @@ export async function executeAnthropicProviderRequest(
const nextModelStartTime = Date.now() const nextModelStartTime = Date.now()
currentResponse = await createMessage(anthropic, nextPayload) currentResponse = await anthropic.messages.create(nextPayload)
const nextCheckResult = checkForForcedToolUsage( const nextCheckResult = checkForForcedToolUsage(
currentResponse, currentResponse,
@@ -1184,14 +1098,10 @@ export async function executeAnthropicProviderRequest(
tool_choice: undefined, tool_choice: undefined,
} }
const streamResponse = await anthropic.messages.create( const streamResponse: any = await anthropic.messages.create(streamingPayload)
streamingPayload as Anthropic.Messages.MessageCreateParamsStreaming
)
const streamingResult = { const streamingResult = {
stream: createReadableStreamFromAnthropicStream( stream: createReadableStreamFromAnthropicStream(streamResponse, (streamContent, usage) => {
streamResponse as AsyncIterable<RawMessageStreamEvent>,
(streamContent, usage) => {
streamingResult.execution.output.content = streamContent streamingResult.execution.output.content = streamContent
streamingResult.execution.output.tokens = { streamingResult.execution.output.tokens = {
input: tokens.input + usage.input_tokens, input: tokens.input + usage.input_tokens,
@@ -1214,8 +1124,7 @@ export async function executeAnthropicProviderRequest(
streamingResult.execution.output.providerTiming.duration = streamingResult.execution.output.providerTiming.duration =
streamEndTime - providerStartTime streamEndTime - providerStartTime
} }
} }),
),
execution: { execution: {
success: true, success: true,
output: { output: {
@@ -1270,7 +1179,7 @@ export async function executeAnthropicProviderRequest(
toolCalls.length > 0 toolCalls.length > 0
? toolCalls.map((tc) => ({ ? toolCalls.map((tc) => ({
name: tc.name, name: tc.name,
arguments: tc.arguments as Record<string, unknown>, arguments: tc.arguments as Record<string, any>,
startTime: tc.startTime, startTime: tc.startTime,
endTime: tc.endTime, endTime: tc.endTime,
duration: tc.duration, duration: tc.duration,

View File

@@ -1,14 +1,6 @@
import { createLogger } from '@sim/logger' import { createLogger } from '@sim/logger'
import { AzureOpenAI } from 'openai' import { AzureOpenAI } from 'openai'
import type { import type { ChatCompletionCreateParamsStreaming } from 'openai/resources/chat/completions'
ChatCompletion,
ChatCompletionCreateParamsBase,
ChatCompletionCreateParamsStreaming,
ChatCompletionMessageParam,
ChatCompletionTool,
ChatCompletionToolChoiceOption,
} from 'openai/resources/chat/completions'
import type { ReasoningEffort } from 'openai/resources/shared'
import { env } from '@/lib/core/config/env' import { env } from '@/lib/core/config/env'
import type { StreamingExecution } from '@/executor/types' import type { StreamingExecution } from '@/executor/types'
import { MAX_TOOL_ITERATIONS } from '@/providers' import { MAX_TOOL_ITERATIONS } from '@/providers'
@@ -24,7 +16,6 @@ import {
import { getProviderDefaultModel, getProviderModels } from '@/providers/models' import { getProviderDefaultModel, getProviderModels } from '@/providers/models'
import { executeResponsesProviderRequest } from '@/providers/openai/core' import { executeResponsesProviderRequest } from '@/providers/openai/core'
import type { import type {
FunctionCallResponse,
ProviderConfig, ProviderConfig,
ProviderRequest, ProviderRequest,
ProviderResponse, ProviderResponse,
@@ -68,7 +59,7 @@ async function executeChatCompletionsRequest(
endpoint: azureEndpoint, endpoint: azureEndpoint,
}) })
const allMessages: ChatCompletionMessageParam[] = [] const allMessages: any[] = []
if (request.systemPrompt) { if (request.systemPrompt) {
allMessages.push({ allMessages.push({
@@ -85,12 +76,12 @@ async function executeChatCompletionsRequest(
} }
if (request.messages) { if (request.messages) {
allMessages.push(...(request.messages as ChatCompletionMessageParam[])) allMessages.push(...request.messages)
} }
const tools: ChatCompletionTool[] | undefined = request.tools?.length const tools = request.tools?.length
? request.tools.map((tool) => ({ ? request.tools.map((tool) => ({
type: 'function' as const, type: 'function',
function: { function: {
name: tool.id, name: tool.id,
description: tool.description, description: tool.description,
@@ -99,7 +90,7 @@ async function executeChatCompletionsRequest(
})) }))
: undefined : undefined
const payload: ChatCompletionCreateParamsBase & { verbosity?: string } = { const payload: any = {
model: deploymentName, model: deploymentName,
messages: allMessages, messages: allMessages,
} }
@@ -107,10 +98,8 @@ async function executeChatCompletionsRequest(
if (request.temperature !== undefined) payload.temperature = request.temperature if (request.temperature !== undefined) payload.temperature = request.temperature
if (request.maxTokens != null) payload.max_completion_tokens = request.maxTokens if (request.maxTokens != null) payload.max_completion_tokens = request.maxTokens
if (request.reasoningEffort !== undefined && request.reasoningEffort !== 'auto') if (request.reasoningEffort !== undefined) payload.reasoning_effort = request.reasoningEffort
payload.reasoning_effort = request.reasoningEffort as ReasoningEffort if (request.verbosity !== undefined) payload.verbosity = request.verbosity
if (request.verbosity !== undefined && request.verbosity !== 'auto')
payload.verbosity = request.verbosity
if (request.responseFormat) { if (request.responseFormat) {
payload.response_format = { payload.response_format = {
@@ -132,8 +121,8 @@ async function executeChatCompletionsRequest(
const { tools: filteredTools, toolChoice } = preparedTools const { tools: filteredTools, toolChoice } = preparedTools
if (filteredTools?.length && toolChoice) { if (filteredTools?.length && toolChoice) {
payload.tools = filteredTools as ChatCompletionTool[] payload.tools = filteredTools
payload.tool_choice = toolChoice as ChatCompletionToolChoiceOption payload.tool_choice = toolChoice
logger.info('Azure OpenAI request configuration:', { logger.info('Azure OpenAI request configuration:', {
toolCount: filteredTools.length, toolCount: filteredTools.length,
@@ -242,7 +231,7 @@ async function executeChatCompletionsRequest(
const forcedTools = preparedTools?.forcedTools || [] const forcedTools = preparedTools?.forcedTools || []
let usedForcedTools: string[] = [] let usedForcedTools: string[] = []
let currentResponse = (await azureOpenAI.chat.completions.create(payload)) as ChatCompletion let currentResponse = await azureOpenAI.chat.completions.create(payload)
const firstResponseTime = Date.now() - initialCallTime const firstResponseTime = Date.now() - initialCallTime
let content = currentResponse.choices[0]?.message?.content || '' let content = currentResponse.choices[0]?.message?.content || ''
@@ -251,8 +240,8 @@ async function executeChatCompletionsRequest(
output: currentResponse.usage?.completion_tokens || 0, output: currentResponse.usage?.completion_tokens || 0,
total: currentResponse.usage?.total_tokens || 0, total: currentResponse.usage?.total_tokens || 0,
} }
const toolCalls: (FunctionCallResponse & { success: boolean })[] = [] const toolCalls = []
const toolResults: Record<string, unknown>[] = [] const toolResults = []
const currentMessages = [...allMessages] const currentMessages = [...allMessages]
let iterationCount = 0 let iterationCount = 0
let modelTime = firstResponseTime let modelTime = firstResponseTime
@@ -271,7 +260,7 @@ async function executeChatCompletionsRequest(
const firstCheckResult = checkForForcedToolUsage( const firstCheckResult = checkForForcedToolUsage(
currentResponse, currentResponse,
originalToolChoice ?? 'auto', originalToolChoice,
logger, logger,
forcedTools, forcedTools,
usedForcedTools usedForcedTools
@@ -367,10 +356,10 @@ async function executeChatCompletionsRequest(
duration: duration, duration: duration,
}) })
let resultContent: Record<string, unknown> let resultContent: any
if (result.success) { if (result.success) {
toolResults.push(result.output as Record<string, unknown>) toolResults.push(result.output)
resultContent = result.output as Record<string, unknown> resultContent = result.output
} else { } else {
resultContent = { resultContent = {
error: true, error: true,
@@ -420,11 +409,11 @@ async function executeChatCompletionsRequest(
} }
const nextModelStartTime = Date.now() const nextModelStartTime = Date.now()
currentResponse = (await azureOpenAI.chat.completions.create(nextPayload)) as ChatCompletion currentResponse = await azureOpenAI.chat.completions.create(nextPayload)
const nextCheckResult = checkForForcedToolUsage( const nextCheckResult = checkForForcedToolUsage(
currentResponse, currentResponse,
nextPayload.tool_choice ?? 'auto', nextPayload.tool_choice,
logger, logger,
forcedTools, forcedTools,
usedForcedTools usedForcedTools

View File

@@ -1,5 +1,4 @@
import type { Logger } from '@sim/logger' import type { Logger } from '@sim/logger'
import type OpenAI from 'openai'
import type { ChatCompletionChunk } from 'openai/resources/chat/completions' import type { ChatCompletionChunk } from 'openai/resources/chat/completions'
import type { CompletionUsage } from 'openai/resources/completions' import type { CompletionUsage } from 'openai/resources/completions'
import type { Stream } from 'openai/streaming' import type { Stream } from 'openai/streaming'
@@ -21,8 +20,8 @@ export function createReadableStreamFromAzureOpenAIStream(
* Uses the shared OpenAI-compatible forced tool usage helper. * Uses the shared OpenAI-compatible forced tool usage helper.
*/ */
export function checkForForcedToolUsage( export function checkForForcedToolUsage(
response: OpenAI.Chat.Completions.ChatCompletion, response: any,
toolChoice: string | { type: string; function?: { name: string }; name?: string }, toolChoice: string | { type: string; function?: { name: string }; name?: string; any?: any },
_logger: Logger, _logger: Logger,
forcedTools: string[], forcedTools: string[],
usedForcedTools: string[] usedForcedTools: string[]

View File

@@ -197,9 +197,6 @@ export const bedrockProvider: ProviderConfig = {
} else if (tc.type === 'function' && tc.function?.name) { } else if (tc.type === 'function' && tc.function?.name) {
toolChoice = { tool: { name: tc.function.name } } toolChoice = { tool: { name: tc.function.name } }
logger.info(`Using Bedrock tool_choice format: force tool "${tc.function.name}"`) logger.info(`Using Bedrock tool_choice format: force tool "${tc.function.name}"`)
} else if (tc.type === 'any') {
toolChoice = { any: {} }
logger.info('Using Bedrock tool_choice format: any tool')
} else { } else {
toolChoice = { auto: {} } toolChoice = { auto: {} }
} }
@@ -416,7 +413,6 @@ export const bedrockProvider: ProviderConfig = {
input: initialCost.input, input: initialCost.input,
output: initialCost.output, output: initialCost.output,
total: initialCost.total, total: initialCost.total,
pricing: initialCost.pricing,
} }
const toolCalls: any[] = [] const toolCalls: any[] = []
@@ -864,12 +860,6 @@ export const bedrockProvider: ProviderConfig = {
content, content,
model: request.model, model: request.model,
tokens, tokens,
cost: {
input: cost.input,
output: cost.output,
total: cost.total,
pricing: cost.pricing,
},
toolCalls: toolCalls:
toolCalls.length > 0 toolCalls.length > 0
? toolCalls.map((tc) => ({ ? toolCalls.map((tc) => ({

View File

@@ -24,6 +24,7 @@ import {
extractTextContent, extractTextContent,
mapToThinkingLevel, mapToThinkingLevel,
} from '@/providers/google/utils' } from '@/providers/google/utils'
import { getThinkingCapability } from '@/providers/models'
import type { FunctionCallResponse, ProviderRequest, ProviderResponse } from '@/providers/types' import type { FunctionCallResponse, ProviderRequest, ProviderResponse } from '@/providers/types'
import { import {
calculateCost, calculateCost,
@@ -431,11 +432,13 @@ export async function executeGeminiRequest(
logger.warn('Gemini does not support responseFormat with tools. Structured output ignored.') logger.warn('Gemini does not support responseFormat with tools. Structured output ignored.')
} }
// Configure thinking only when the user explicitly selects a thinking level // Configure thinking for models that support it
if (request.thinkingLevel && request.thinkingLevel !== 'none') { const thinkingCapability = getThinkingCapability(model)
if (thinkingCapability) {
const level = request.thinkingLevel ?? thinkingCapability.default ?? 'high'
const thinkingConfig: ThinkingConfig = { const thinkingConfig: ThinkingConfig = {
includeThoughts: false, includeThoughts: false,
thinkingLevel: mapToThinkingLevel(request.thinkingLevel), thinkingLevel: mapToThinkingLevel(level),
} }
geminiConfig.thinkingConfig = thinkingConfig geminiConfig.thinkingConfig = thinkingConfig
} }

View File

@@ -8,10 +8,7 @@ import {
calculateCost, calculateCost,
generateStructuredOutputInstructions, generateStructuredOutputInstructions,
shouldBillModelUsage, shouldBillModelUsage,
supportsReasoningEffort,
supportsTemperature, supportsTemperature,
supportsThinking,
supportsVerbosity,
} from '@/providers/utils' } from '@/providers/utils'
const logger = createLogger('Providers') const logger = createLogger('Providers')
@@ -24,24 +21,11 @@ export const MAX_TOOL_ITERATIONS = 20
function sanitizeRequest(request: ProviderRequest): ProviderRequest { function sanitizeRequest(request: ProviderRequest): ProviderRequest {
const sanitizedRequest = { ...request } const sanitizedRequest = { ...request }
const model = sanitizedRequest.model
if (model && !supportsTemperature(model)) { if (sanitizedRequest.model && !supportsTemperature(sanitizedRequest.model)) {
sanitizedRequest.temperature = undefined sanitizedRequest.temperature = undefined
} }
if (model && !supportsReasoningEffort(model)) {
sanitizedRequest.reasoningEffort = undefined
}
if (model && !supportsVerbosity(model)) {
sanitizedRequest.verbosity = undefined
}
if (model && !supportsThinking(model)) {
sanitizedRequest.thinkingLevel = undefined
}
return sanitizedRequest return sanitizedRequest
} }

View File

@@ -141,6 +141,7 @@ export const mistralProvider: ProviderConfig = {
const streamingParams: ChatCompletionCreateParamsStreaming = { const streamingParams: ChatCompletionCreateParamsStreaming = {
...payload, ...payload,
stream: true, stream: true,
stream_options: { include_usage: true },
} }
const streamResponse = await mistral.chat.completions.create(streamingParams) const streamResponse = await mistral.chat.completions.create(streamingParams)
@@ -452,6 +453,7 @@ export const mistralProvider: ProviderConfig = {
messages: currentMessages, messages: currentMessages,
tool_choice: 'auto', tool_choice: 'auto',
stream: true, stream: true,
stream_options: { include_usage: true },
} }
const streamResponse = await mistral.chat.completions.create(streamingParams) const streamResponse = await mistral.chat.completions.create(streamingParams)

View File

@@ -34,8 +34,17 @@ export interface ModelCapabilities {
toolUsageControl?: boolean toolUsageControl?: boolean
computerUse?: boolean computerUse?: boolean
nativeStructuredOutputs?: boolean nativeStructuredOutputs?: boolean
/** Maximum supported output tokens for this model */ /**
maxOutputTokens?: number * Max output tokens configuration for Anthropic SDK's streaming timeout workaround.
* The Anthropic SDK throws an error for non-streaming requests that may take >10 minutes.
* This only applies to direct Anthropic API calls, not Bedrock (which uses AWS SDK).
*/
maxOutputTokens?: {
/** Maximum tokens for streaming requests */
max: number
/** Safe default for non-streaming requests (to avoid Anthropic SDK timeout errors) */
default: number
}
reasoningEffort?: { reasoningEffort?: {
values: string[] values: string[]
} }
@@ -100,7 +109,7 @@ export const PROVIDER_DEFINITIONS: Record<string, ProviderDefinition> = {
name: 'OpenAI', name: 'OpenAI',
description: "OpenAI's models", description: "OpenAI's models",
defaultModel: 'gpt-4o', defaultModel: 'gpt-4o',
modelPatterns: [/^gpt/, /^o\d/, /^text-embedding/], modelPatterns: [/^gpt/, /^o1/, /^text-embedding/],
icon: OpenAIIcon, icon: OpenAIIcon,
capabilities: { capabilities: {
toolUsageControl: true, toolUsageControl: true,
@@ -129,7 +138,7 @@ export const PROVIDER_DEFINITIONS: Record<string, ProviderDefinition> = {
}, },
capabilities: { capabilities: {
reasoningEffort: { reasoningEffort: {
values: ['none', 'low', 'medium', 'high', 'xhigh'], values: ['none', 'minimal', 'low', 'medium', 'high', 'xhigh'],
}, },
verbosity: { verbosity: {
values: ['low', 'medium', 'high'], values: ['low', 'medium', 'high'],
@@ -155,6 +164,60 @@ export const PROVIDER_DEFINITIONS: Record<string, ProviderDefinition> = {
}, },
contextWindow: 400000, contextWindow: 400000,
}, },
// {
// id: 'gpt-5.1-mini',
// pricing: {
// input: 0.25,
// cachedInput: 0.025,
// output: 2.0,
// updatedAt: '2025-11-14',
// },
// capabilities: {
// reasoningEffort: {
// values: ['none', 'low', 'medium', 'high'],
// },
// verbosity: {
// values: ['low', 'medium', 'high'],
// },
// },
// contextWindow: 400000,
// },
// {
// id: 'gpt-5.1-nano',
// pricing: {
// input: 0.05,
// cachedInput: 0.005,
// output: 0.4,
// updatedAt: '2025-11-14',
// },
// capabilities: {
// reasoningEffort: {
// values: ['none', 'low', 'medium', 'high'],
// },
// verbosity: {
// values: ['low', 'medium', 'high'],
// },
// },
// contextWindow: 400000,
// },
// {
// id: 'gpt-5.1-codex',
// pricing: {
// input: 1.25,
// cachedInput: 0.125,
// output: 10.0,
// updatedAt: '2025-11-14',
// },
// capabilities: {
// reasoningEffort: {
// values: ['none', 'medium', 'high'],
// },
// verbosity: {
// values: ['low', 'medium', 'high'],
// },
// },
// contextWindow: 400000,
// },
{ {
id: 'gpt-5', id: 'gpt-5',
pricing: { pricing: {
@@ -217,10 +280,8 @@ export const PROVIDER_DEFINITIONS: Record<string, ProviderDefinition> = {
output: 10.0, output: 10.0,
updatedAt: '2025-08-07', updatedAt: '2025-08-07',
}, },
capabilities: { capabilities: {},
temperature: { min: 0, max: 2 }, contextWindow: 400000,
},
contextWindow: 128000,
}, },
{ {
id: 'o1', id: 'o1',
@@ -250,7 +311,7 @@ export const PROVIDER_DEFINITIONS: Record<string, ProviderDefinition> = {
values: ['low', 'medium', 'high'], values: ['low', 'medium', 'high'],
}, },
}, },
contextWindow: 200000, contextWindow: 128000,
}, },
{ {
id: 'o4-mini', id: 'o4-mini',
@@ -265,7 +326,7 @@ export const PROVIDER_DEFINITIONS: Record<string, ProviderDefinition> = {
values: ['low', 'medium', 'high'], values: ['low', 'medium', 'high'],
}, },
}, },
contextWindow: 200000, contextWindow: 128000,
}, },
{ {
id: 'gpt-4.1', id: 'gpt-4.1',
@@ -330,7 +391,7 @@ export const PROVIDER_DEFINITIONS: Record<string, ProviderDefinition> = {
capabilities: { capabilities: {
temperature: { min: 0, max: 1 }, temperature: { min: 0, max: 1 },
nativeStructuredOutputs: true, nativeStructuredOutputs: true,
maxOutputTokens: 128000, maxOutputTokens: { max: 128000, default: 8192 },
thinking: { thinking: {
levels: ['low', 'medium', 'high', 'max'], levels: ['low', 'medium', 'high', 'max'],
default: 'high', default: 'high',
@@ -349,10 +410,10 @@ export const PROVIDER_DEFINITIONS: Record<string, ProviderDefinition> = {
capabilities: { capabilities: {
temperature: { min: 0, max: 1 }, temperature: { min: 0, max: 1 },
nativeStructuredOutputs: true, nativeStructuredOutputs: true,
maxOutputTokens: 64000, maxOutputTokens: { max: 64000, default: 8192 },
thinking: { thinking: {
levels: ['low', 'medium', 'high'], levels: ['low', 'medium', 'high'],
default: 'high', default: 'medium',
}, },
}, },
contextWindow: 200000, contextWindow: 200000,
@@ -368,10 +429,10 @@ export const PROVIDER_DEFINITIONS: Record<string, ProviderDefinition> = {
capabilities: { capabilities: {
temperature: { min: 0, max: 1 }, temperature: { min: 0, max: 1 },
nativeStructuredOutputs: true, nativeStructuredOutputs: true,
maxOutputTokens: 64000, maxOutputTokens: { max: 64000, default: 8192 },
thinking: { thinking: {
levels: ['low', 'medium', 'high'], levels: ['low', 'medium', 'high'],
default: 'high', default: 'medium',
}, },
}, },
contextWindow: 200000, contextWindow: 200000,
@@ -386,10 +447,10 @@ export const PROVIDER_DEFINITIONS: Record<string, ProviderDefinition> = {
}, },
capabilities: { capabilities: {
temperature: { min: 0, max: 1 }, temperature: { min: 0, max: 1 },
maxOutputTokens: 64000, maxOutputTokens: { max: 64000, default: 8192 },
thinking: { thinking: {
levels: ['low', 'medium', 'high'], levels: ['low', 'medium', 'high'],
default: 'high', default: 'medium',
}, },
}, },
contextWindow: 200000, contextWindow: 200000,
@@ -405,10 +466,10 @@ export const PROVIDER_DEFINITIONS: Record<string, ProviderDefinition> = {
capabilities: { capabilities: {
temperature: { min: 0, max: 1 }, temperature: { min: 0, max: 1 },
nativeStructuredOutputs: true, nativeStructuredOutputs: true,
maxOutputTokens: 64000, maxOutputTokens: { max: 64000, default: 8192 },
thinking: { thinking: {
levels: ['low', 'medium', 'high'], levels: ['low', 'medium', 'high'],
default: 'high', default: 'medium',
}, },
}, },
contextWindow: 200000, contextWindow: 200000,
@@ -423,10 +484,10 @@ export const PROVIDER_DEFINITIONS: Record<string, ProviderDefinition> = {
}, },
capabilities: { capabilities: {
temperature: { min: 0, max: 1 }, temperature: { min: 0, max: 1 },
maxOutputTokens: 64000, maxOutputTokens: { max: 64000, default: 8192 },
thinking: { thinking: {
levels: ['low', 'medium', 'high'], levels: ['low', 'medium', 'high'],
default: 'high', default: 'medium',
}, },
}, },
contextWindow: 200000, contextWindow: 200000,
@@ -442,10 +503,10 @@ export const PROVIDER_DEFINITIONS: Record<string, ProviderDefinition> = {
capabilities: { capabilities: {
temperature: { min: 0, max: 1 }, temperature: { min: 0, max: 1 },
nativeStructuredOutputs: true, nativeStructuredOutputs: true,
maxOutputTokens: 64000, maxOutputTokens: { max: 64000, default: 8192 },
thinking: { thinking: {
levels: ['low', 'medium', 'high'], levels: ['low', 'medium', 'high'],
default: 'high', default: 'medium',
}, },
}, },
contextWindow: 200000, contextWindow: 200000,
@@ -454,13 +515,13 @@ export const PROVIDER_DEFINITIONS: Record<string, ProviderDefinition> = {
id: 'claude-3-haiku-20240307', id: 'claude-3-haiku-20240307',
pricing: { pricing: {
input: 0.25, input: 0.25,
cachedInput: 0.03, cachedInput: 0.025,
output: 1.25, output: 1.25,
updatedAt: '2026-02-05', updatedAt: '2026-02-05',
}, },
capabilities: { capabilities: {
temperature: { min: 0, max: 1 }, temperature: { min: 0, max: 1 },
maxOutputTokens: 4096, maxOutputTokens: { max: 4096, default: 4096 },
}, },
contextWindow: 200000, contextWindow: 200000,
}, },
@@ -475,10 +536,10 @@ export const PROVIDER_DEFINITIONS: Record<string, ProviderDefinition> = {
capabilities: { capabilities: {
temperature: { min: 0, max: 1 }, temperature: { min: 0, max: 1 },
computerUse: true, computerUse: true,
maxOutputTokens: 64000, maxOutputTokens: { max: 8192, default: 8192 },
thinking: { thinking: {
levels: ['low', 'medium', 'high'], levels: ['low', 'medium', 'high'],
default: 'high', default: 'medium',
}, },
}, },
contextWindow: 200000, contextWindow: 200000,
@@ -519,7 +580,7 @@ export const PROVIDER_DEFINITIONS: Record<string, ProviderDefinition> = {
}, },
capabilities: { capabilities: {
reasoningEffort: { reasoningEffort: {
values: ['none', 'low', 'medium', 'high', 'xhigh'], values: ['none', 'minimal', 'low', 'medium', 'high', 'xhigh'],
}, },
verbosity: { verbosity: {
values: ['low', 'medium', 'high'], values: ['low', 'medium', 'high'],
@@ -545,6 +606,42 @@ export const PROVIDER_DEFINITIONS: Record<string, ProviderDefinition> = {
}, },
contextWindow: 400000, contextWindow: 400000,
}, },
{
id: 'azure/gpt-5.1-mini',
pricing: {
input: 0.25,
cachedInput: 0.025,
output: 2.0,
updatedAt: '2025-11-14',
},
capabilities: {
reasoningEffort: {
values: ['none', 'low', 'medium', 'high'],
},
verbosity: {
values: ['low', 'medium', 'high'],
},
},
contextWindow: 400000,
},
{
id: 'azure/gpt-5.1-nano',
pricing: {
input: 0.05,
cachedInput: 0.005,
output: 0.4,
updatedAt: '2025-11-14',
},
capabilities: {
reasoningEffort: {
values: ['none', 'low', 'medium', 'high'],
},
verbosity: {
values: ['low', 'medium', 'high'],
},
},
contextWindow: 400000,
},
{ {
id: 'azure/gpt-5.1-codex', id: 'azure/gpt-5.1-codex',
pricing: { pricing: {
@@ -555,7 +652,7 @@ export const PROVIDER_DEFINITIONS: Record<string, ProviderDefinition> = {
}, },
capabilities: { capabilities: {
reasoningEffort: { reasoningEffort: {
values: ['none', 'low', 'medium', 'high'], values: ['none', 'medium', 'high'],
}, },
verbosity: { verbosity: {
values: ['low', 'medium', 'high'], values: ['low', 'medium', 'high'],
@@ -625,25 +722,23 @@ export const PROVIDER_DEFINITIONS: Record<string, ProviderDefinition> = {
output: 10.0, output: 10.0,
updatedAt: '2025-08-07', updatedAt: '2025-08-07',
}, },
capabilities: { capabilities: {},
temperature: { min: 0, max: 2 }, contextWindow: 400000,
},
contextWindow: 128000,
}, },
{ {
id: 'azure/o3', id: 'azure/o3',
pricing: { pricing: {
input: 2, input: 10,
cachedInput: 0.5, cachedInput: 2.5,
output: 8, output: 40,
updatedAt: '2026-02-06', updatedAt: '2025-06-15',
}, },
capabilities: { capabilities: {
reasoningEffort: { reasoningEffort: {
values: ['low', 'medium', 'high'], values: ['low', 'medium', 'high'],
}, },
}, },
contextWindow: 200000, contextWindow: 128000,
}, },
{ {
id: 'azure/o4-mini', id: 'azure/o4-mini',
@@ -658,7 +753,7 @@ export const PROVIDER_DEFINITIONS: Record<string, ProviderDefinition> = {
values: ['low', 'medium', 'high'], values: ['low', 'medium', 'high'],
}, },
}, },
contextWindow: 200000, contextWindow: 128000,
}, },
{ {
id: 'azure/gpt-4.1', id: 'azure/gpt-4.1',
@@ -668,35 +763,7 @@ export const PROVIDER_DEFINITIONS: Record<string, ProviderDefinition> = {
output: 8.0, output: 8.0,
updatedAt: '2025-06-15', updatedAt: '2025-06-15',
}, },
capabilities: { capabilities: {},
temperature: { min: 0, max: 2 },
},
contextWindow: 1000000,
},
{
id: 'azure/gpt-4.1-mini',
pricing: {
input: 0.4,
cachedInput: 0.1,
output: 1.6,
updatedAt: '2025-06-15',
},
capabilities: {
temperature: { min: 0, max: 2 },
},
contextWindow: 1000000,
},
{
id: 'azure/gpt-4.1-nano',
pricing: {
input: 0.1,
cachedInput: 0.025,
output: 0.4,
updatedAt: '2025-06-15',
},
capabilities: {
temperature: { min: 0, max: 2 },
},
contextWindow: 1000000, contextWindow: 1000000,
}, },
{ {
@@ -708,7 +775,7 @@ export const PROVIDER_DEFINITIONS: Record<string, ProviderDefinition> = {
updatedAt: '2025-06-15', updatedAt: '2025-06-15',
}, },
capabilities: {}, capabilities: {},
contextWindow: 200000, contextWindow: 1000000,
}, },
], ],
}, },
@@ -734,7 +801,7 @@ export const PROVIDER_DEFINITIONS: Record<string, ProviderDefinition> = {
capabilities: { capabilities: {
temperature: { min: 0, max: 1 }, temperature: { min: 0, max: 1 },
nativeStructuredOutputs: true, nativeStructuredOutputs: true,
maxOutputTokens: 128000, maxOutputTokens: { max: 128000, default: 8192 },
thinking: { thinking: {
levels: ['low', 'medium', 'high', 'max'], levels: ['low', 'medium', 'high', 'max'],
default: 'high', default: 'high',
@@ -753,10 +820,10 @@ export const PROVIDER_DEFINITIONS: Record<string, ProviderDefinition> = {
capabilities: { capabilities: {
temperature: { min: 0, max: 1 }, temperature: { min: 0, max: 1 },
nativeStructuredOutputs: true, nativeStructuredOutputs: true,
maxOutputTokens: 64000, maxOutputTokens: { max: 64000, default: 8192 },
thinking: { thinking: {
levels: ['low', 'medium', 'high'], levels: ['low', 'medium', 'high'],
default: 'high', default: 'medium',
}, },
}, },
contextWindow: 200000, contextWindow: 200000,
@@ -772,10 +839,10 @@ export const PROVIDER_DEFINITIONS: Record<string, ProviderDefinition> = {
capabilities: { capabilities: {
temperature: { min: 0, max: 1 }, temperature: { min: 0, max: 1 },
nativeStructuredOutputs: true, nativeStructuredOutputs: true,
maxOutputTokens: 64000, maxOutputTokens: { max: 64000, default: 8192 },
thinking: { thinking: {
levels: ['low', 'medium', 'high'], levels: ['low', 'medium', 'high'],
default: 'high', default: 'medium',
}, },
}, },
contextWindow: 200000, contextWindow: 200000,
@@ -791,10 +858,10 @@ export const PROVIDER_DEFINITIONS: Record<string, ProviderDefinition> = {
capabilities: { capabilities: {
temperature: { min: 0, max: 1 }, temperature: { min: 0, max: 1 },
nativeStructuredOutputs: true, nativeStructuredOutputs: true,
maxOutputTokens: 64000, maxOutputTokens: { max: 64000, default: 8192 },
thinking: { thinking: {
levels: ['low', 'medium', 'high'], levels: ['low', 'medium', 'high'],
default: 'high', default: 'medium',
}, },
}, },
contextWindow: 200000, contextWindow: 200000,
@@ -810,10 +877,10 @@ export const PROVIDER_DEFINITIONS: Record<string, ProviderDefinition> = {
capabilities: { capabilities: {
temperature: { min: 0, max: 1 }, temperature: { min: 0, max: 1 },
nativeStructuredOutputs: true, nativeStructuredOutputs: true,
maxOutputTokens: 64000, maxOutputTokens: { max: 64000, default: 8192 },
thinking: { thinking: {
levels: ['low', 'medium', 'high'], levels: ['low', 'medium', 'high'],
default: 'high', default: 'medium',
}, },
}, },
contextWindow: 200000, contextWindow: 200000,
@@ -2481,11 +2548,14 @@ export function getThinkingLevelsForModel(modelId: string): string[] | null {
} }
/** /**
* Get the max output tokens for a specific model. * Get the max output tokens for a specific model
* Returns the model's max capacity for streaming requests,
* or the model's safe default for non-streaming requests to avoid timeout issues.
* *
* @param modelId - The model ID * @param modelId - The model ID
* @param streaming - Whether the request is streaming (default: false)
*/ */
export function getMaxOutputTokensForModel(modelId: string): number { export function getMaxOutputTokensForModel(modelId: string, streaming = false): number {
const normalizedModelId = modelId.toLowerCase() const normalizedModelId = modelId.toLowerCase()
const STANDARD_MAX_OUTPUT_TOKENS = 4096 const STANDARD_MAX_OUTPUT_TOKENS = 4096
@@ -2493,7 +2563,11 @@ export function getMaxOutputTokensForModel(modelId: string): number {
for (const model of provider.models) { for (const model of provider.models) {
const baseModelId = model.id.toLowerCase() const baseModelId = model.id.toLowerCase()
if (normalizedModelId === baseModelId || normalizedModelId.startsWith(`${baseModelId}-`)) { if (normalizedModelId === baseModelId || normalizedModelId.startsWith(`${baseModelId}-`)) {
return model.capabilities.maxOutputTokens || STANDARD_MAX_OUTPUT_TOKENS const outputTokens = model.capabilities.maxOutputTokens
if (outputTokens) {
return streaming ? outputTokens.max : outputTokens.default
}
return STANDARD_MAX_OUTPUT_TOKENS
} }
} }
} }

View File

@@ -1,5 +1,4 @@
import type { Logger } from '@sim/logger' import type { Logger } from '@sim/logger'
import type OpenAI from 'openai'
import type { StreamingExecution } from '@/executor/types' import type { StreamingExecution } from '@/executor/types'
import { MAX_TOOL_ITERATIONS } from '@/providers' import { MAX_TOOL_ITERATIONS } from '@/providers'
import type { Message, ProviderRequest, ProviderResponse, TimeSegment } from '@/providers/types' import type { Message, ProviderRequest, ProviderResponse, TimeSegment } from '@/providers/types'
@@ -31,7 +30,7 @@ type ToolChoice = PreparedTools['toolChoice']
* - Sets additionalProperties: false on all object types. * - Sets additionalProperties: false on all object types.
* - Ensures required includes ALL property keys. * - Ensures required includes ALL property keys.
*/ */
function enforceStrictSchema(schema: Record<string, unknown>): Record<string, unknown> { function enforceStrictSchema(schema: any): any {
if (!schema || typeof schema !== 'object') return schema if (!schema || typeof schema !== 'object') return schema
const result = { ...schema } const result = { ...schema }
@@ -42,26 +41,23 @@ function enforceStrictSchema(schema: Record<string, unknown>): Record<string, un
// Recursively process properties and ensure required includes all keys // Recursively process properties and ensure required includes all keys
if (result.properties && typeof result.properties === 'object') { if (result.properties && typeof result.properties === 'object') {
const propKeys = Object.keys(result.properties as Record<string, unknown>) const propKeys = Object.keys(result.properties)
result.required = propKeys // Strict mode requires ALL properties result.required = propKeys // Strict mode requires ALL properties
result.properties = Object.fromEntries( result.properties = Object.fromEntries(
Object.entries(result.properties as Record<string, unknown>).map(([key, value]) => [ Object.entries(result.properties).map(([key, value]) => [key, enforceStrictSchema(value)])
key,
enforceStrictSchema(value as Record<string, unknown>),
])
) )
} }
} }
// Handle array items // Handle array items
if (result.type === 'array' && result.items) { if (result.type === 'array' && result.items) {
result.items = enforceStrictSchema(result.items as Record<string, unknown>) result.items = enforceStrictSchema(result.items)
} }
// Handle anyOf, oneOf, allOf // Handle anyOf, oneOf, allOf
for (const keyword of ['anyOf', 'oneOf', 'allOf']) { for (const keyword of ['anyOf', 'oneOf', 'allOf']) {
if (Array.isArray(result[keyword])) { if (Array.isArray(result[keyword])) {
result[keyword] = (result[keyword] as Record<string, unknown>[]).map(enforceStrictSchema) result[keyword] = result[keyword].map(enforceStrictSchema)
} }
} }
@@ -69,10 +65,7 @@ function enforceStrictSchema(schema: Record<string, unknown>): Record<string, un
for (const defKey of ['$defs', 'definitions']) { for (const defKey of ['$defs', 'definitions']) {
if (result[defKey] && typeof result[defKey] === 'object') { if (result[defKey] && typeof result[defKey] === 'object') {
result[defKey] = Object.fromEntries( result[defKey] = Object.fromEntries(
Object.entries(result[defKey] as Record<string, unknown>).map(([key, value]) => [ Object.entries(result[defKey]).map(([key, value]) => [key, enforceStrictSchema(value)])
key,
enforceStrictSchema(value as Record<string, unknown>),
])
) )
} }
} }
@@ -130,29 +123,29 @@ export async function executeResponsesProviderRequest(
const initialInput = buildResponsesInputFromMessages(allMessages) const initialInput = buildResponsesInputFromMessages(allMessages)
const basePayload: Record<string, unknown> = { const basePayload: Record<string, any> = {
model: config.modelName, model: config.modelName,
} }
if (request.temperature !== undefined) basePayload.temperature = request.temperature if (request.temperature !== undefined) basePayload.temperature = request.temperature
if (request.maxTokens != null) basePayload.max_output_tokens = request.maxTokens if (request.maxTokens != null) basePayload.max_output_tokens = request.maxTokens
if (request.reasoningEffort !== undefined && request.reasoningEffort !== 'auto') { if (request.reasoningEffort !== undefined) {
basePayload.reasoning = { basePayload.reasoning = {
effort: request.reasoningEffort, effort: request.reasoningEffort,
summary: 'auto', summary: 'auto',
} }
} }
if (request.verbosity !== undefined && request.verbosity !== 'auto') { if (request.verbosity !== undefined) {
basePayload.text = { basePayload.text = {
...((basePayload.text as Record<string, unknown>) ?? {}), ...(basePayload.text ?? {}),
verbosity: request.verbosity, verbosity: request.verbosity,
} }
} }
// Store response format config - for Azure with tools, we defer applying it until after tool calls complete // Store response format config - for Azure with tools, we defer applying it until after tool calls complete
let deferredTextFormat: OpenAI.Responses.ResponseFormatTextJSONSchemaConfig | undefined let deferredTextFormat: { type: string; name: string; schema: any; strict: boolean } | undefined
const hasTools = !!request.tools?.length const hasTools = !!request.tools?.length
const isAzure = config.providerId === 'azure-openai' const isAzure = config.providerId === 'azure-openai'
@@ -178,7 +171,7 @@ export async function executeResponsesProviderRequest(
) )
} else { } else {
basePayload.text = { basePayload.text = {
...((basePayload.text as Record<string, unknown>) ?? {}), ...(basePayload.text ?? {}),
format: textFormat, format: textFormat,
} }
logger.info(`Added JSON schema response format to ${config.providerLabel} request`) logger.info(`Added JSON schema response format to ${config.providerLabel} request`)
@@ -238,10 +231,7 @@ export async function executeResponsesProviderRequest(
} }
} }
const createRequestBody = ( const createRequestBody = (input: ResponsesInputItem[], overrides: Record<string, any> = {}) => ({
input: ResponsesInputItem[],
overrides: Record<string, unknown> = {}
) => ({
...basePayload, ...basePayload,
input, input,
...overrides, ...overrides,
@@ -257,9 +247,7 @@ export async function executeResponsesProviderRequest(
} }
} }
const postResponses = async ( const postResponses = async (body: Record<string, any>) => {
body: Record<string, unknown>
): Promise<OpenAI.Responses.Response> => {
const response = await fetch(config.endpoint, { const response = await fetch(config.endpoint, {
method: 'POST', method: 'POST',
headers: config.headers, headers: config.headers,
@@ -508,10 +496,10 @@ export async function executeResponsesProviderRequest(
duration: duration, duration: duration,
}) })
let resultContent: Record<string, unknown> let resultContent: any
if (result.success) { if (result.success) {
toolResults.push(result.output) toolResults.push(result.output)
resultContent = result.output as Record<string, unknown> resultContent = result.output
} else { } else {
resultContent = { resultContent = {
error: true, error: true,
@@ -627,11 +615,11 @@ export async function executeResponsesProviderRequest(
} }
// Make final call with the response format - build payload without tools // Make final call with the response format - build payload without tools
const finalPayload: Record<string, unknown> = { const finalPayload: Record<string, any> = {
model: config.modelName, model: config.modelName,
input: formattedInput, input: formattedInput,
text: { text: {
...((basePayload.text as Record<string, unknown>) ?? {}), ...(basePayload.text ?? {}),
format: deferredTextFormat, format: deferredTextFormat,
}, },
} }
@@ -639,15 +627,15 @@ export async function executeResponsesProviderRequest(
// Copy over non-tool related settings // Copy over non-tool related settings
if (request.temperature !== undefined) finalPayload.temperature = request.temperature if (request.temperature !== undefined) finalPayload.temperature = request.temperature
if (request.maxTokens != null) finalPayload.max_output_tokens = request.maxTokens if (request.maxTokens != null) finalPayload.max_output_tokens = request.maxTokens
if (request.reasoningEffort !== undefined && request.reasoningEffort !== 'auto') { if (request.reasoningEffort !== undefined) {
finalPayload.reasoning = { finalPayload.reasoning = {
effort: request.reasoningEffort, effort: request.reasoningEffort,
summary: 'auto', summary: 'auto',
} }
} }
if (request.verbosity !== undefined && request.verbosity !== 'auto') { if (request.verbosity !== undefined) {
finalPayload.text = { finalPayload.text = {
...((finalPayload.text as Record<string, unknown>) ?? {}), ...finalPayload.text,
verbosity: request.verbosity, verbosity: request.verbosity,
} }
} }
@@ -691,10 +679,10 @@ export async function executeResponsesProviderRequest(
const accumulatedCost = calculateCost(request.model, tokens.input, tokens.output) const accumulatedCost = calculateCost(request.model, tokens.input, tokens.output)
// For Azure with deferred format in streaming mode, include the format in the streaming call // For Azure with deferred format in streaming mode, include the format in the streaming call
const streamOverrides: Record<string, unknown> = { stream: true, tool_choice: 'auto' } const streamOverrides: Record<string, any> = { stream: true, tool_choice: 'auto' }
if (deferredTextFormat) { if (deferredTextFormat) {
streamOverrides.text = { streamOverrides.text = {
...((basePayload.text as Record<string, unknown>) ?? {}), ...(basePayload.text ?? {}),
format: deferredTextFormat, format: deferredTextFormat,
} }
} }

View File

@@ -1,5 +1,4 @@
import { createLogger } from '@sim/logger' import { createLogger } from '@sim/logger'
import type OpenAI from 'openai'
import type { Message } from '@/providers/types' import type { Message } from '@/providers/types'
const logger = createLogger('ResponsesUtils') const logger = createLogger('ResponsesUtils')
@@ -39,7 +38,7 @@ export interface ResponsesToolDefinition {
type: 'function' type: 'function'
name: string name: string
description?: string description?: string
parameters?: Record<string, unknown> parameters?: Record<string, any>
} }
/** /**
@@ -86,15 +85,7 @@ export function buildResponsesInputFromMessages(messages: Message[]): ResponsesI
/** /**
* Converts tool definitions to the Responses API format. * Converts tool definitions to the Responses API format.
*/ */
export function convertToolsToResponses( export function convertToolsToResponses(tools: any[]): ResponsesToolDefinition[] {
tools: Array<{
type?: string
name?: string
description?: string
parameters?: Record<string, unknown>
function?: { name: string; description?: string; parameters?: Record<string, unknown> }
}>
): ResponsesToolDefinition[] {
return tools return tools
.map((tool) => { .map((tool) => {
const name = tool.function?.name ?? tool.name const name = tool.function?.name ?? tool.name
@@ -140,7 +131,7 @@ export function toResponsesToolChoice(
return 'auto' return 'auto'
} }
function extractTextFromMessageItem(item: Record<string, unknown>): string { function extractTextFromMessageItem(item: any): string {
if (!item) { if (!item) {
return '' return ''
} }
@@ -179,7 +170,7 @@ function extractTextFromMessageItem(item: Record<string, unknown>): string {
/** /**
* Extracts plain text from Responses API output items. * Extracts plain text from Responses API output items.
*/ */
export function extractResponseText(output: OpenAI.Responses.ResponseOutputItem[]): string { export function extractResponseText(output: unknown): string {
if (!Array.isArray(output)) { if (!Array.isArray(output)) {
return '' return ''
} }
@@ -190,7 +181,7 @@ export function extractResponseText(output: OpenAI.Responses.ResponseOutputItem[
continue continue
} }
const text = extractTextFromMessageItem(item as unknown as Record<string, unknown>) const text = extractTextFromMessageItem(item)
if (text) { if (text) {
textParts.push(text) textParts.push(text)
} }
@@ -202,9 +193,7 @@ export function extractResponseText(output: OpenAI.Responses.ResponseOutputItem[
/** /**
* Converts Responses API output items into input items for subsequent calls. * Converts Responses API output items into input items for subsequent calls.
*/ */
export function convertResponseOutputToInputItems( export function convertResponseOutputToInputItems(output: unknown): ResponsesInputItem[] {
output: OpenAI.Responses.ResponseOutputItem[]
): ResponsesInputItem[] {
if (!Array.isArray(output)) { if (!Array.isArray(output)) {
return [] return []
} }
@@ -216,7 +205,7 @@ export function convertResponseOutputToInputItems(
} }
if (item.type === 'message') { if (item.type === 'message') {
const text = extractTextFromMessageItem(item as unknown as Record<string, unknown>) const text = extractTextFromMessageItem(item)
if (text) { if (text) {
items.push({ items.push({
role: 'assistant', role: 'assistant',
@@ -224,20 +213,18 @@ export function convertResponseOutputToInputItems(
}) })
} }
// Handle Chat Completions-style tool_calls nested under message items const toolCalls = Array.isArray(item.tool_calls) ? item.tool_calls : []
const msgRecord = item as unknown as Record<string, unknown>
const toolCalls = Array.isArray(msgRecord.tool_calls) ? msgRecord.tool_calls : []
for (const toolCall of toolCalls) { for (const toolCall of toolCalls) {
const tc = toolCall as Record<string, unknown> const callId = toolCall?.id
const fn = tc.function as Record<string, unknown> | undefined const name = toolCall?.function?.name ?? toolCall?.name
const callId = tc.id as string | undefined
const name = (fn?.name ?? tc.name) as string | undefined
if (!callId || !name) { if (!callId || !name) {
continue continue
} }
const argumentsValue = const argumentsValue =
typeof fn?.arguments === 'string' ? fn.arguments : JSON.stringify(fn?.arguments ?? {}) typeof toolCall?.function?.arguments === 'string'
? toolCall.function.arguments
: JSON.stringify(toolCall?.function?.arguments ?? {})
items.push({ items.push({
type: 'function_call', type: 'function_call',
@@ -251,18 +238,14 @@ export function convertResponseOutputToInputItems(
} }
if (item.type === 'function_call') { if (item.type === 'function_call') {
const fc = item as OpenAI.Responses.ResponseFunctionToolCall const callId = item.call_id ?? item.id
const fcRecord = item as unknown as Record<string, unknown> const name = item.name ?? item.function?.name
const callId = fc.call_id ?? (fcRecord.id as string | undefined)
const name =
fc.name ??
((fcRecord.function as Record<string, unknown> | undefined)?.name as string | undefined)
if (!callId || !name) { if (!callId || !name) {
continue continue
} }
const argumentsValue = const argumentsValue =
typeof fc.arguments === 'string' ? fc.arguments : JSON.stringify(fc.arguments ?? {}) typeof item.arguments === 'string' ? item.arguments : JSON.stringify(item.arguments ?? {})
items.push({ items.push({
type: 'function_call', type: 'function_call',
@@ -279,9 +262,7 @@ export function convertResponseOutputToInputItems(
/** /**
* Extracts tool calls from Responses API output items. * Extracts tool calls from Responses API output items.
*/ */
export function extractResponseToolCalls( export function extractResponseToolCalls(output: unknown): ResponsesToolCall[] {
output: OpenAI.Responses.ResponseOutputItem[]
): ResponsesToolCall[] {
if (!Array.isArray(output)) { if (!Array.isArray(output)) {
return [] return []
} }
@@ -294,18 +275,14 @@ export function extractResponseToolCalls(
} }
if (item.type === 'function_call') { if (item.type === 'function_call') {
const fc = item as OpenAI.Responses.ResponseFunctionToolCall const callId = item.call_id ?? item.id
const fcRecord = item as unknown as Record<string, unknown> const name = item.name ?? item.function?.name
const callId = fc.call_id ?? (fcRecord.id as string | undefined)
const name =
fc.name ??
((fcRecord.function as Record<string, unknown> | undefined)?.name as string | undefined)
if (!callId || !name) { if (!callId || !name) {
continue continue
} }
const argumentsValue = const argumentsValue =
typeof fc.arguments === 'string' ? fc.arguments : JSON.stringify(fc.arguments ?? {}) typeof item.arguments === 'string' ? item.arguments : JSON.stringify(item.arguments ?? {})
toolCalls.push({ toolCalls.push({
id: callId, id: callId,
@@ -315,20 +292,18 @@ export function extractResponseToolCalls(
continue continue
} }
// Handle Chat Completions-style tool_calls nested under message items if (item.type === 'message' && Array.isArray(item.tool_calls)) {
const msgRecord = item as unknown as Record<string, unknown> for (const toolCall of item.tool_calls) {
if (item.type === 'message' && Array.isArray(msgRecord.tool_calls)) { const callId = toolCall?.id
for (const toolCall of msgRecord.tool_calls) { const name = toolCall?.function?.name ?? toolCall?.name
const tc = toolCall as Record<string, unknown>
const fn = tc.function as Record<string, unknown> | undefined
const callId = tc.id as string | undefined
const name = (fn?.name ?? tc.name) as string | undefined
if (!callId || !name) { if (!callId || !name) {
continue continue
} }
const argumentsValue = const argumentsValue =
typeof fn?.arguments === 'string' ? fn.arguments : JSON.stringify(fn?.arguments ?? {}) typeof toolCall?.function?.arguments === 'string'
? toolCall.function.arguments
: JSON.stringify(toolCall?.function?.arguments ?? {})
toolCalls.push({ toolCalls.push({
id: callId, id: callId,
@@ -348,17 +323,15 @@ export function extractResponseToolCalls(
* Note: output_tokens is expected to include reasoning tokens; fall back to reasoning_tokens * Note: output_tokens is expected to include reasoning tokens; fall back to reasoning_tokens
* when output_tokens is missing or zero. * when output_tokens is missing or zero.
*/ */
export function parseResponsesUsage( export function parseResponsesUsage(usage: any): ResponsesUsageTokens | undefined {
usage: OpenAI.Responses.ResponseUsage | undefined if (!usage || typeof usage !== 'object') {
): ResponsesUsageTokens | undefined {
if (!usage) {
return undefined return undefined
} }
const inputTokens = usage.input_tokens ?? 0 const inputTokens = Number(usage.input_tokens ?? 0)
const outputTokens = usage.output_tokens ?? 0 const outputTokens = Number(usage.output_tokens ?? 0)
const cachedTokens = usage.input_tokens_details?.cached_tokens ?? 0 const cachedTokens = Number(usage.input_tokens_details?.cached_tokens ?? 0)
const reasoningTokens = usage.output_tokens_details?.reasoning_tokens ?? 0 const reasoningTokens = Number(usage.output_tokens_details?.reasoning_tokens ?? 0)
const completionTokens = Math.max(outputTokens, reasoningTokens) const completionTokens = Math.max(outputTokens, reasoningTokens)
const totalTokens = inputTokens + completionTokens const totalTokens = inputTokens + completionTokens
@@ -425,7 +398,7 @@ export function createReadableStreamFromResponses(
continue continue
} }
let event: Record<string, unknown> let event: any
try { try {
event = JSON.parse(data) event = JSON.parse(data)
} catch (error) { } catch (error) {
@@ -443,8 +416,7 @@ export function createReadableStreamFromResponses(
eventType === 'error' || eventType === 'error' ||
eventType === 'response.failed' eventType === 'response.failed'
) { ) {
const errorObj = event.error as Record<string, unknown> | undefined const message = event?.error?.message || 'Responses API stream error'
const message = (errorObj?.message as string) || 'Responses API stream error'
controller.error(new Error(message)) controller.error(new Error(message))
return return
} }
@@ -454,13 +426,12 @@ export function createReadableStreamFromResponses(
eventType === 'response.output_json.delta' eventType === 'response.output_json.delta'
) { ) {
let deltaText = '' let deltaText = ''
const delta = event.delta as string | Record<string, unknown> | undefined if (typeof event.delta === 'string') {
if (typeof delta === 'string') { deltaText = event.delta
deltaText = delta } else if (event.delta && typeof event.delta.text === 'string') {
} else if (delta && typeof delta.text === 'string') { deltaText = event.delta.text
deltaText = delta.text } else if (event.delta && event.delta.json !== undefined) {
} else if (delta && delta.json !== undefined) { deltaText = JSON.stringify(event.delta.json)
deltaText = JSON.stringify(delta.json)
} else if (event.json !== undefined) { } else if (event.json !== undefined) {
deltaText = JSON.stringify(event.json) deltaText = JSON.stringify(event.json)
} else if (typeof event.text === 'string') { } else if (typeof event.text === 'string') {
@@ -474,11 +445,7 @@ export function createReadableStreamFromResponses(
} }
if (eventType === 'response.completed') { if (eventType === 'response.completed') {
const responseObj = event.response as Record<string, unknown> | undefined finalUsage = parseResponsesUsage(event?.response?.usage ?? event?.usage)
const usageData = (responseObj?.usage ?? event.usage) as
| OpenAI.Responses.ResponseUsage
| undefined
finalUsage = parseResponsesUsage(usageData)
} }
} }
} }

View File

@@ -431,13 +431,19 @@ export const openRouterProvider: ProviderConfig = {
const accumulatedCost = calculateCost(requestedModel, tokens.input, tokens.output) const accumulatedCost = calculateCost(requestedModel, tokens.input, tokens.output)
const streamingParams: ChatCompletionCreateParamsStreaming & { provider?: any } = { const streamingParams: ChatCompletionCreateParamsStreaming & { provider?: any } = {
...payload, model: payload.model,
messages: [...currentMessages], messages: [...currentMessages],
tool_choice: 'auto',
stream: true, stream: true,
stream_options: { include_usage: true }, stream_options: { include_usage: true },
} }
if (payload.temperature !== undefined) {
streamingParams.temperature = payload.temperature
}
if (payload.max_tokens !== undefined) {
streamingParams.max_tokens = payload.max_tokens
}
if (request.responseFormat) { if (request.responseFormat) {
;(streamingParams as any).messages = await applyResponseFormat( ;(streamingParams as any).messages = await applyResponseFormat(
streamingParams as any, streamingParams as any,

View File

@@ -12,32 +12,23 @@ import {
getApiKey, getApiKey,
getBaseModelProviders, getBaseModelProviders,
getHostedModels, getHostedModels,
getMaxOutputTokensForModel,
getMaxTemperature, getMaxTemperature,
getModelPricing,
getProvider, getProvider,
getProviderConfigFromModel, getProviderConfigFromModel,
getProviderFromModel, getProviderFromModel,
getProviderModels, getProviderModels,
getReasoningEffortValuesForModel,
getThinkingLevelsForModel,
getVerbosityValuesForModel,
isProviderBlacklisted, isProviderBlacklisted,
MODELS_TEMP_RANGE_0_1, MODELS_TEMP_RANGE_0_1,
MODELS_TEMP_RANGE_0_2, MODELS_TEMP_RANGE_0_2,
MODELS_WITH_REASONING_EFFORT, MODELS_WITH_REASONING_EFFORT,
MODELS_WITH_TEMPERATURE_SUPPORT, MODELS_WITH_TEMPERATURE_SUPPORT,
MODELS_WITH_THINKING,
MODELS_WITH_VERBOSITY, MODELS_WITH_VERBOSITY,
PROVIDERS_WITH_TOOL_USAGE_CONTROL, PROVIDERS_WITH_TOOL_USAGE_CONTROL,
prepareToolExecution, prepareToolExecution,
prepareToolsWithUsageControl, prepareToolsWithUsageControl,
shouldBillModelUsage, shouldBillModelUsage,
supportsReasoningEffort,
supportsTemperature, supportsTemperature,
supportsThinking,
supportsToolUsageControl, supportsToolUsageControl,
supportsVerbosity,
updateOllamaProviderModels, updateOllamaProviderModels,
} from '@/providers/utils' } from '@/providers/utils'
@@ -178,8 +169,6 @@ describe('Model Capabilities', () => {
'gpt-4.1', 'gpt-4.1',
'gpt-4.1-mini', 'gpt-4.1-mini',
'gpt-4.1-nano', 'gpt-4.1-nano',
'gpt-5-chat-latest',
'azure/gpt-5-chat-latest',
'gemini-2.5-flash', 'gemini-2.5-flash',
'claude-sonnet-4-0', 'claude-sonnet-4-0',
'claude-opus-4-0', 'claude-opus-4-0',
@@ -197,27 +186,34 @@ describe('Model Capabilities', () => {
it.concurrent('should return false for models that do not support temperature', () => { it.concurrent('should return false for models that do not support temperature', () => {
const unsupportedModels = [ const unsupportedModels = [
'unsupported-model', 'unsupported-model',
'cerebras/llama-3.3-70b', 'cerebras/llama-3.3-70b', // Cerebras models don't have temperature defined
'groq/meta-llama/llama-4-scout-17b-16e-instruct', 'groq/meta-llama/llama-4-scout-17b-16e-instruct', // Groq models don't have temperature defined
// Reasoning models that don't support temperature
'o1', 'o1',
'o3', 'o3',
'o4-mini', 'o4-mini',
'azure/o3', 'azure/o3',
'azure/o4-mini', 'azure/o4-mini',
'deepseek-r1', 'deepseek-r1',
// Chat models that don't support temperature
'deepseek-chat', 'deepseek-chat',
'azure/gpt-4.1',
'azure/model-router', 'azure/model-router',
// GPT-5.1 models don't support temperature (removed in our implementation)
'gpt-5.1', 'gpt-5.1',
'azure/gpt-5.1', 'azure/gpt-5.1',
'azure/gpt-5.1-mini', 'azure/gpt-5.1-mini',
'azure/gpt-5.1-nano', 'azure/gpt-5.1-nano',
'azure/gpt-5.1-codex', 'azure/gpt-5.1-codex',
// GPT-5 models don't support temperature (removed in our implementation)
'gpt-5', 'gpt-5',
'gpt-5-mini', 'gpt-5-mini',
'gpt-5-nano', 'gpt-5-nano',
'gpt-5-chat-latest',
'azure/gpt-5', 'azure/gpt-5',
'azure/gpt-5-mini', 'azure/gpt-5-mini',
'azure/gpt-5-nano', 'azure/gpt-5-nano',
'azure/gpt-5-chat-latest',
] ]
for (const model of unsupportedModels) { for (const model of unsupportedModels) {
@@ -244,8 +240,6 @@ describe('Model Capabilities', () => {
const modelsRange02 = [ const modelsRange02 = [
'gpt-4o', 'gpt-4o',
'azure/gpt-4o', 'azure/gpt-4o',
'gpt-5-chat-latest',
'azure/gpt-5-chat-latest',
'gemini-2.5-pro', 'gemini-2.5-pro',
'gemini-2.5-flash', 'gemini-2.5-flash',
'deepseek-v3', 'deepseek-v3',
@@ -274,23 +268,28 @@ describe('Model Capabilities', () => {
expect(getMaxTemperature('unsupported-model')).toBeUndefined() expect(getMaxTemperature('unsupported-model')).toBeUndefined()
expect(getMaxTemperature('cerebras/llama-3.3-70b')).toBeUndefined() expect(getMaxTemperature('cerebras/llama-3.3-70b')).toBeUndefined()
expect(getMaxTemperature('groq/meta-llama/llama-4-scout-17b-16e-instruct')).toBeUndefined() expect(getMaxTemperature('groq/meta-llama/llama-4-scout-17b-16e-instruct')).toBeUndefined()
// Reasoning models that don't support temperature
expect(getMaxTemperature('o1')).toBeUndefined() expect(getMaxTemperature('o1')).toBeUndefined()
expect(getMaxTemperature('o3')).toBeUndefined() expect(getMaxTemperature('o3')).toBeUndefined()
expect(getMaxTemperature('o4-mini')).toBeUndefined() expect(getMaxTemperature('o4-mini')).toBeUndefined()
expect(getMaxTemperature('azure/o3')).toBeUndefined() expect(getMaxTemperature('azure/o3')).toBeUndefined()
expect(getMaxTemperature('azure/o4-mini')).toBeUndefined() expect(getMaxTemperature('azure/o4-mini')).toBeUndefined()
expect(getMaxTemperature('deepseek-r1')).toBeUndefined() expect(getMaxTemperature('deepseek-r1')).toBeUndefined()
// GPT-5.1 models don't support temperature
expect(getMaxTemperature('gpt-5.1')).toBeUndefined() expect(getMaxTemperature('gpt-5.1')).toBeUndefined()
expect(getMaxTemperature('azure/gpt-5.1')).toBeUndefined() expect(getMaxTemperature('azure/gpt-5.1')).toBeUndefined()
expect(getMaxTemperature('azure/gpt-5.1-mini')).toBeUndefined() expect(getMaxTemperature('azure/gpt-5.1-mini')).toBeUndefined()
expect(getMaxTemperature('azure/gpt-5.1-nano')).toBeUndefined() expect(getMaxTemperature('azure/gpt-5.1-nano')).toBeUndefined()
expect(getMaxTemperature('azure/gpt-5.1-codex')).toBeUndefined() expect(getMaxTemperature('azure/gpt-5.1-codex')).toBeUndefined()
// GPT-5 models don't support temperature
expect(getMaxTemperature('gpt-5')).toBeUndefined() expect(getMaxTemperature('gpt-5')).toBeUndefined()
expect(getMaxTemperature('gpt-5-mini')).toBeUndefined() expect(getMaxTemperature('gpt-5-mini')).toBeUndefined()
expect(getMaxTemperature('gpt-5-nano')).toBeUndefined() expect(getMaxTemperature('gpt-5-nano')).toBeUndefined()
expect(getMaxTemperature('gpt-5-chat-latest')).toBeUndefined()
expect(getMaxTemperature('azure/gpt-5')).toBeUndefined() expect(getMaxTemperature('azure/gpt-5')).toBeUndefined()
expect(getMaxTemperature('azure/gpt-5-mini')).toBeUndefined() expect(getMaxTemperature('azure/gpt-5-mini')).toBeUndefined()
expect(getMaxTemperature('azure/gpt-5-nano')).toBeUndefined() expect(getMaxTemperature('azure/gpt-5-nano')).toBeUndefined()
expect(getMaxTemperature('azure/gpt-5-chat-latest')).toBeUndefined()
}) })
it.concurrent('should be case insensitive', () => { it.concurrent('should be case insensitive', () => {
@@ -336,94 +335,18 @@ describe('Model Capabilities', () => {
) )
}) })
describe('supportsReasoningEffort', () => {
it.concurrent('should return true for models with reasoning effort capability', () => {
expect(supportsReasoningEffort('gpt-5')).toBe(true)
expect(supportsReasoningEffort('gpt-5-mini')).toBe(true)
expect(supportsReasoningEffort('gpt-5.1')).toBe(true)
expect(supportsReasoningEffort('gpt-5.2')).toBe(true)
expect(supportsReasoningEffort('o3')).toBe(true)
expect(supportsReasoningEffort('o4-mini')).toBe(true)
expect(supportsReasoningEffort('azure/gpt-5')).toBe(true)
expect(supportsReasoningEffort('azure/o3')).toBe(true)
})
it.concurrent('should return false for models without reasoning effort capability', () => {
expect(supportsReasoningEffort('gpt-4o')).toBe(false)
expect(supportsReasoningEffort('gpt-4.1')).toBe(false)
expect(supportsReasoningEffort('claude-sonnet-4-5')).toBe(false)
expect(supportsReasoningEffort('claude-opus-4-6')).toBe(false)
expect(supportsReasoningEffort('gemini-2.5-flash')).toBe(false)
expect(supportsReasoningEffort('unknown-model')).toBe(false)
})
it.concurrent('should be case-insensitive', () => {
expect(supportsReasoningEffort('GPT-5')).toBe(true)
expect(supportsReasoningEffort('O3')).toBe(true)
expect(supportsReasoningEffort('GPT-4O')).toBe(false)
})
})
describe('supportsVerbosity', () => {
it.concurrent('should return true for models with verbosity capability', () => {
expect(supportsVerbosity('gpt-5')).toBe(true)
expect(supportsVerbosity('gpt-5-mini')).toBe(true)
expect(supportsVerbosity('gpt-5.1')).toBe(true)
expect(supportsVerbosity('gpt-5.2')).toBe(true)
expect(supportsVerbosity('azure/gpt-5')).toBe(true)
})
it.concurrent('should return false for models without verbosity capability', () => {
expect(supportsVerbosity('gpt-4o')).toBe(false)
expect(supportsVerbosity('o3')).toBe(false)
expect(supportsVerbosity('o4-mini')).toBe(false)
expect(supportsVerbosity('claude-sonnet-4-5')).toBe(false)
expect(supportsVerbosity('unknown-model')).toBe(false)
})
it.concurrent('should be case-insensitive', () => {
expect(supportsVerbosity('GPT-5')).toBe(true)
expect(supportsVerbosity('GPT-4O')).toBe(false)
})
})
describe('supportsThinking', () => {
it.concurrent('should return true for models with thinking capability', () => {
expect(supportsThinking('claude-opus-4-6')).toBe(true)
expect(supportsThinking('claude-opus-4-5')).toBe(true)
expect(supportsThinking('claude-sonnet-4-5')).toBe(true)
expect(supportsThinking('claude-sonnet-4-0')).toBe(true)
expect(supportsThinking('claude-haiku-4-5')).toBe(true)
expect(supportsThinking('gemini-3-pro-preview')).toBe(true)
expect(supportsThinking('gemini-3-flash-preview')).toBe(true)
})
it.concurrent('should return false for models without thinking capability', () => {
expect(supportsThinking('gpt-4o')).toBe(false)
expect(supportsThinking('gpt-5')).toBe(false)
expect(supportsThinking('o3')).toBe(false)
expect(supportsThinking('deepseek-v3')).toBe(false)
expect(supportsThinking('unknown-model')).toBe(false)
})
it.concurrent('should be case-insensitive', () => {
expect(supportsThinking('CLAUDE-OPUS-4-6')).toBe(true)
expect(supportsThinking('GPT-4O')).toBe(false)
})
})
describe('Model Constants', () => { describe('Model Constants', () => {
it.concurrent('should have correct models in MODELS_TEMP_RANGE_0_2', () => { it.concurrent('should have correct models in MODELS_TEMP_RANGE_0_2', () => {
expect(MODELS_TEMP_RANGE_0_2).toContain('gpt-4o') expect(MODELS_TEMP_RANGE_0_2).toContain('gpt-4o')
expect(MODELS_TEMP_RANGE_0_2).toContain('gemini-2.5-flash') expect(MODELS_TEMP_RANGE_0_2).toContain('gemini-2.5-flash')
expect(MODELS_TEMP_RANGE_0_2).toContain('deepseek-v3') expect(MODELS_TEMP_RANGE_0_2).toContain('deepseek-v3')
expect(MODELS_TEMP_RANGE_0_2).not.toContain('claude-sonnet-4-0') expect(MODELS_TEMP_RANGE_0_2).not.toContain('claude-sonnet-4-0') // Should be in 0-1 range
}) })
it.concurrent('should have correct models in MODELS_TEMP_RANGE_0_1', () => { it.concurrent('should have correct models in MODELS_TEMP_RANGE_0_1', () => {
expect(MODELS_TEMP_RANGE_0_1).toContain('claude-sonnet-4-0') expect(MODELS_TEMP_RANGE_0_1).toContain('claude-sonnet-4-0')
expect(MODELS_TEMP_RANGE_0_1).toContain('grok-3-latest') expect(MODELS_TEMP_RANGE_0_1).toContain('grok-3-latest')
expect(MODELS_TEMP_RANGE_0_1).not.toContain('gpt-4o') expect(MODELS_TEMP_RANGE_0_1).not.toContain('gpt-4o') // Should be in 0-2 range
}) })
it.concurrent('should have correct providers in PROVIDERS_WITH_TOOL_USAGE_CONTROL', () => { it.concurrent('should have correct providers in PROVIDERS_WITH_TOOL_USAGE_CONTROL', () => {
@@ -440,19 +363,20 @@ describe('Model Capabilities', () => {
expect(MODELS_WITH_TEMPERATURE_SUPPORT.length).toBe( expect(MODELS_WITH_TEMPERATURE_SUPPORT.length).toBe(
MODELS_TEMP_RANGE_0_2.length + MODELS_TEMP_RANGE_0_1.length MODELS_TEMP_RANGE_0_2.length + MODELS_TEMP_RANGE_0_1.length
) )
expect(MODELS_WITH_TEMPERATURE_SUPPORT).toContain('gpt-4o') expect(MODELS_WITH_TEMPERATURE_SUPPORT).toContain('gpt-4o') // From 0-2 range
expect(MODELS_WITH_TEMPERATURE_SUPPORT).toContain('claude-sonnet-4-0') expect(MODELS_WITH_TEMPERATURE_SUPPORT).toContain('claude-sonnet-4-0') // From 0-1 range
} }
) )
it.concurrent('should have correct models in MODELS_WITH_REASONING_EFFORT', () => { it.concurrent('should have correct models in MODELS_WITH_REASONING_EFFORT', () => {
// Should contain GPT-5.1 models that support reasoning effort
expect(MODELS_WITH_REASONING_EFFORT).toContain('gpt-5.1') expect(MODELS_WITH_REASONING_EFFORT).toContain('gpt-5.1')
expect(MODELS_WITH_REASONING_EFFORT).toContain('azure/gpt-5.1') expect(MODELS_WITH_REASONING_EFFORT).toContain('azure/gpt-5.1')
expect(MODELS_WITH_REASONING_EFFORT).toContain('azure/gpt-5.1-mini')
expect(MODELS_WITH_REASONING_EFFORT).toContain('azure/gpt-5.1-nano')
expect(MODELS_WITH_REASONING_EFFORT).toContain('azure/gpt-5.1-codex') expect(MODELS_WITH_REASONING_EFFORT).toContain('azure/gpt-5.1-codex')
expect(MODELS_WITH_REASONING_EFFORT).not.toContain('azure/gpt-5.1-mini') // Should contain GPT-5 models that support reasoning effort
expect(MODELS_WITH_REASONING_EFFORT).not.toContain('azure/gpt-5.1-nano')
expect(MODELS_WITH_REASONING_EFFORT).toContain('gpt-5') expect(MODELS_WITH_REASONING_EFFORT).toContain('gpt-5')
expect(MODELS_WITH_REASONING_EFFORT).toContain('gpt-5-mini') expect(MODELS_WITH_REASONING_EFFORT).toContain('gpt-5-mini')
expect(MODELS_WITH_REASONING_EFFORT).toContain('gpt-5-nano') expect(MODELS_WITH_REASONING_EFFORT).toContain('gpt-5-nano')
@@ -460,30 +384,35 @@ describe('Model Capabilities', () => {
expect(MODELS_WITH_REASONING_EFFORT).toContain('azure/gpt-5-mini') expect(MODELS_WITH_REASONING_EFFORT).toContain('azure/gpt-5-mini')
expect(MODELS_WITH_REASONING_EFFORT).toContain('azure/gpt-5-nano') expect(MODELS_WITH_REASONING_EFFORT).toContain('azure/gpt-5-nano')
// Should contain gpt-5.2 models
expect(MODELS_WITH_REASONING_EFFORT).toContain('gpt-5.2') expect(MODELS_WITH_REASONING_EFFORT).toContain('gpt-5.2')
expect(MODELS_WITH_REASONING_EFFORT).toContain('azure/gpt-5.2') expect(MODELS_WITH_REASONING_EFFORT).toContain('azure/gpt-5.2')
// Should contain o-series reasoning models (reasoning_effort added Dec 17, 2024)
expect(MODELS_WITH_REASONING_EFFORT).toContain('o1') expect(MODELS_WITH_REASONING_EFFORT).toContain('o1')
expect(MODELS_WITH_REASONING_EFFORT).toContain('o3') expect(MODELS_WITH_REASONING_EFFORT).toContain('o3')
expect(MODELS_WITH_REASONING_EFFORT).toContain('o4-mini') expect(MODELS_WITH_REASONING_EFFORT).toContain('o4-mini')
expect(MODELS_WITH_REASONING_EFFORT).toContain('azure/o3') expect(MODELS_WITH_REASONING_EFFORT).toContain('azure/o3')
expect(MODELS_WITH_REASONING_EFFORT).toContain('azure/o4-mini') expect(MODELS_WITH_REASONING_EFFORT).toContain('azure/o4-mini')
// Should NOT contain non-reasoning GPT-5 models
expect(MODELS_WITH_REASONING_EFFORT).not.toContain('gpt-5-chat-latest') expect(MODELS_WITH_REASONING_EFFORT).not.toContain('gpt-5-chat-latest')
expect(MODELS_WITH_REASONING_EFFORT).not.toContain('azure/gpt-5-chat-latest') expect(MODELS_WITH_REASONING_EFFORT).not.toContain('azure/gpt-5-chat-latest')
// Should NOT contain other models
expect(MODELS_WITH_REASONING_EFFORT).not.toContain('gpt-4o') expect(MODELS_WITH_REASONING_EFFORT).not.toContain('gpt-4o')
expect(MODELS_WITH_REASONING_EFFORT).not.toContain('claude-sonnet-4-0') expect(MODELS_WITH_REASONING_EFFORT).not.toContain('claude-sonnet-4-0')
}) })
it.concurrent('should have correct models in MODELS_WITH_VERBOSITY', () => { it.concurrent('should have correct models in MODELS_WITH_VERBOSITY', () => {
// Should contain GPT-5.1 models that support verbosity
expect(MODELS_WITH_VERBOSITY).toContain('gpt-5.1') expect(MODELS_WITH_VERBOSITY).toContain('gpt-5.1')
expect(MODELS_WITH_VERBOSITY).toContain('azure/gpt-5.1') expect(MODELS_WITH_VERBOSITY).toContain('azure/gpt-5.1')
expect(MODELS_WITH_VERBOSITY).toContain('azure/gpt-5.1-mini')
expect(MODELS_WITH_VERBOSITY).toContain('azure/gpt-5.1-nano')
expect(MODELS_WITH_VERBOSITY).toContain('azure/gpt-5.1-codex') expect(MODELS_WITH_VERBOSITY).toContain('azure/gpt-5.1-codex')
expect(MODELS_WITH_VERBOSITY).not.toContain('azure/gpt-5.1-mini') // Should contain GPT-5 models that support verbosity
expect(MODELS_WITH_VERBOSITY).not.toContain('azure/gpt-5.1-nano')
expect(MODELS_WITH_VERBOSITY).toContain('gpt-5') expect(MODELS_WITH_VERBOSITY).toContain('gpt-5')
expect(MODELS_WITH_VERBOSITY).toContain('gpt-5-mini') expect(MODELS_WITH_VERBOSITY).toContain('gpt-5-mini')
expect(MODELS_WITH_VERBOSITY).toContain('gpt-5-nano') expect(MODELS_WITH_VERBOSITY).toContain('gpt-5-nano')
@@ -491,39 +420,26 @@ describe('Model Capabilities', () => {
expect(MODELS_WITH_VERBOSITY).toContain('azure/gpt-5-mini') expect(MODELS_WITH_VERBOSITY).toContain('azure/gpt-5-mini')
expect(MODELS_WITH_VERBOSITY).toContain('azure/gpt-5-nano') expect(MODELS_WITH_VERBOSITY).toContain('azure/gpt-5-nano')
// Should contain gpt-5.2 models
expect(MODELS_WITH_VERBOSITY).toContain('gpt-5.2') expect(MODELS_WITH_VERBOSITY).toContain('gpt-5.2')
expect(MODELS_WITH_VERBOSITY).toContain('azure/gpt-5.2') expect(MODELS_WITH_VERBOSITY).toContain('azure/gpt-5.2')
// Should NOT contain non-reasoning GPT-5 models
expect(MODELS_WITH_VERBOSITY).not.toContain('gpt-5-chat-latest') expect(MODELS_WITH_VERBOSITY).not.toContain('gpt-5-chat-latest')
expect(MODELS_WITH_VERBOSITY).not.toContain('azure/gpt-5-chat-latest') expect(MODELS_WITH_VERBOSITY).not.toContain('azure/gpt-5-chat-latest')
// Should NOT contain o-series models (they support reasoning_effort but not verbosity)
expect(MODELS_WITH_VERBOSITY).not.toContain('o1') expect(MODELS_WITH_VERBOSITY).not.toContain('o1')
expect(MODELS_WITH_VERBOSITY).not.toContain('o3') expect(MODELS_WITH_VERBOSITY).not.toContain('o3')
expect(MODELS_WITH_VERBOSITY).not.toContain('o4-mini') expect(MODELS_WITH_VERBOSITY).not.toContain('o4-mini')
// Should NOT contain other models
expect(MODELS_WITH_VERBOSITY).not.toContain('gpt-4o') expect(MODELS_WITH_VERBOSITY).not.toContain('gpt-4o')
expect(MODELS_WITH_VERBOSITY).not.toContain('claude-sonnet-4-0') expect(MODELS_WITH_VERBOSITY).not.toContain('claude-sonnet-4-0')
}) })
it.concurrent('should have correct models in MODELS_WITH_THINKING', () => {
expect(MODELS_WITH_THINKING).toContain('claude-opus-4-6')
expect(MODELS_WITH_THINKING).toContain('claude-opus-4-5')
expect(MODELS_WITH_THINKING).toContain('claude-opus-4-1')
expect(MODELS_WITH_THINKING).toContain('claude-opus-4-0')
expect(MODELS_WITH_THINKING).toContain('claude-sonnet-4-5')
expect(MODELS_WITH_THINKING).toContain('claude-sonnet-4-0')
expect(MODELS_WITH_THINKING).toContain('gemini-3-pro-preview')
expect(MODELS_WITH_THINKING).toContain('gemini-3-flash-preview')
expect(MODELS_WITH_THINKING).toContain('claude-haiku-4-5')
expect(MODELS_WITH_THINKING).not.toContain('gpt-4o')
expect(MODELS_WITH_THINKING).not.toContain('gpt-5')
expect(MODELS_WITH_THINKING).not.toContain('o3')
})
it.concurrent('should have GPT-5 models in both reasoning effort and verbosity arrays', () => { it.concurrent('should have GPT-5 models in both reasoning effort and verbosity arrays', () => {
// GPT-5 series models support both reasoning effort and verbosity
const gpt5ModelsWithReasoningEffort = MODELS_WITH_REASONING_EFFORT.filter( const gpt5ModelsWithReasoningEffort = MODELS_WITH_REASONING_EFFORT.filter(
(m) => m.includes('gpt-5') && !m.includes('chat-latest') (m) => m.includes('gpt-5') && !m.includes('chat-latest')
) )
@@ -532,201 +448,11 @@ describe('Model Capabilities', () => {
) )
expect(gpt5ModelsWithReasoningEffort.sort()).toEqual(gpt5ModelsWithVerbosity.sort()) expect(gpt5ModelsWithReasoningEffort.sort()).toEqual(gpt5ModelsWithVerbosity.sort())
// o-series models have reasoning effort but NOT verbosity
expect(MODELS_WITH_REASONING_EFFORT).toContain('o1') expect(MODELS_WITH_REASONING_EFFORT).toContain('o1')
expect(MODELS_WITH_VERBOSITY).not.toContain('o1') expect(MODELS_WITH_VERBOSITY).not.toContain('o1')
}) })
}) })
describe('Reasoning Effort Values Per Model', () => {
it.concurrent('should return correct values for GPT-5.2', () => {
const values = getReasoningEffortValuesForModel('gpt-5.2')
expect(values).toBeDefined()
expect(values).toContain('none')
expect(values).toContain('low')
expect(values).toContain('medium')
expect(values).toContain('high')
expect(values).toContain('xhigh')
expect(values).not.toContain('minimal')
})
it.concurrent('should return correct values for GPT-5', () => {
const values = getReasoningEffortValuesForModel('gpt-5')
expect(values).toBeDefined()
expect(values).toContain('minimal')
expect(values).toContain('low')
expect(values).toContain('medium')
expect(values).toContain('high')
})
it.concurrent('should return correct values for o-series models', () => {
for (const model of ['o1', 'o3', 'o4-mini']) {
const values = getReasoningEffortValuesForModel(model)
expect(values).toBeDefined()
expect(values).toContain('low')
expect(values).toContain('medium')
expect(values).toContain('high')
expect(values).not.toContain('none')
expect(values).not.toContain('minimal')
}
})
it.concurrent('should return null for non-reasoning models', () => {
expect(getReasoningEffortValuesForModel('gpt-4o')).toBeNull()
expect(getReasoningEffortValuesForModel('claude-sonnet-4-5')).toBeNull()
expect(getReasoningEffortValuesForModel('gemini-2.5-flash')).toBeNull()
})
it.concurrent('should return correct values for Azure GPT-5.2', () => {
const values = getReasoningEffortValuesForModel('azure/gpt-5.2')
expect(values).toBeDefined()
expect(values).not.toContain('minimal')
expect(values).toContain('xhigh')
})
})
describe('Verbosity Values Per Model', () => {
it.concurrent('should return correct values for GPT-5 family', () => {
for (const model of ['gpt-5.2', 'gpt-5.1', 'gpt-5', 'gpt-5-mini', 'gpt-5-nano']) {
const values = getVerbosityValuesForModel(model)
expect(values).toBeDefined()
expect(values).toContain('low')
expect(values).toContain('medium')
expect(values).toContain('high')
}
})
it.concurrent('should return null for o-series models', () => {
expect(getVerbosityValuesForModel('o1')).toBeNull()
expect(getVerbosityValuesForModel('o3')).toBeNull()
expect(getVerbosityValuesForModel('o4-mini')).toBeNull()
})
it.concurrent('should return null for non-reasoning models', () => {
expect(getVerbosityValuesForModel('gpt-4o')).toBeNull()
expect(getVerbosityValuesForModel('claude-sonnet-4-5')).toBeNull()
})
})
describe('Thinking Levels Per Model', () => {
it.concurrent('should return correct levels for Claude Opus 4.6 (adaptive)', () => {
const levels = getThinkingLevelsForModel('claude-opus-4-6')
expect(levels).toBeDefined()
expect(levels).toContain('low')
expect(levels).toContain('medium')
expect(levels).toContain('high')
expect(levels).toContain('max')
})
it.concurrent('should return correct levels for other Claude models (budget_tokens)', () => {
for (const model of ['claude-opus-4-5', 'claude-sonnet-4-5', 'claude-sonnet-4-0']) {
const levels = getThinkingLevelsForModel(model)
expect(levels).toBeDefined()
expect(levels).toContain('low')
expect(levels).toContain('medium')
expect(levels).toContain('high')
expect(levels).not.toContain('max')
}
})
it.concurrent('should return correct levels for Gemini 3 models', () => {
const proLevels = getThinkingLevelsForModel('gemini-3-pro-preview')
expect(proLevels).toBeDefined()
expect(proLevels).toContain('low')
expect(proLevels).toContain('high')
const flashLevels = getThinkingLevelsForModel('gemini-3-flash-preview')
expect(flashLevels).toBeDefined()
expect(flashLevels).toContain('minimal')
expect(flashLevels).toContain('low')
expect(flashLevels).toContain('medium')
expect(flashLevels).toContain('high')
})
it.concurrent('should return correct levels for Claude Haiku 4.5', () => {
const levels = getThinkingLevelsForModel('claude-haiku-4-5')
expect(levels).toBeDefined()
expect(levels).toContain('low')
expect(levels).toContain('medium')
expect(levels).toContain('high')
})
it.concurrent('should return null for non-thinking models', () => {
expect(getThinkingLevelsForModel('gpt-4o')).toBeNull()
expect(getThinkingLevelsForModel('gpt-5')).toBeNull()
expect(getThinkingLevelsForModel('o3')).toBeNull()
})
})
})
describe('Max Output Tokens', () => {
describe('getMaxOutputTokensForModel', () => {
it.concurrent('should return correct max for Claude Opus 4.6', () => {
expect(getMaxOutputTokensForModel('claude-opus-4-6')).toBe(128000)
})
it.concurrent('should return correct max for Claude Sonnet 4.5', () => {
expect(getMaxOutputTokensForModel('claude-sonnet-4-5')).toBe(64000)
})
it.concurrent('should return correct max for Claude Opus 4.1', () => {
expect(getMaxOutputTokensForModel('claude-opus-4-1')).toBe(64000)
})
it.concurrent('should return standard default for models without maxOutputTokens', () => {
expect(getMaxOutputTokensForModel('gpt-4o')).toBe(4096)
})
it.concurrent('should return standard default for unknown models', () => {
expect(getMaxOutputTokensForModel('unknown-model')).toBe(4096)
})
})
})
describe('Model Pricing Validation', () => {
it.concurrent('should have correct pricing for key Anthropic models', () => {
const opus46 = getModelPricing('claude-opus-4-6')
expect(opus46).toBeDefined()
expect(opus46.input).toBe(5.0)
expect(opus46.output).toBe(25.0)
const sonnet45 = getModelPricing('claude-sonnet-4-5')
expect(sonnet45).toBeDefined()
expect(sonnet45.input).toBe(3.0)
expect(sonnet45.output).toBe(15.0)
})
it.concurrent('should have correct pricing for key OpenAI models', () => {
const gpt4o = getModelPricing('gpt-4o')
expect(gpt4o).toBeDefined()
expect(gpt4o.input).toBe(2.5)
expect(gpt4o.output).toBe(10.0)
const o3 = getModelPricing('o3')
expect(o3).toBeDefined()
expect(o3.input).toBe(2.0)
expect(o3.output).toBe(8.0)
})
it.concurrent('should have correct pricing for Azure OpenAI o3', () => {
const azureO3 = getModelPricing('azure/o3')
expect(azureO3).toBeDefined()
expect(azureO3.input).toBe(2.0)
expect(azureO3.output).toBe(8.0)
})
it.concurrent('should return null for unknown models', () => {
expect(getModelPricing('unknown-model')).toBeNull()
})
})
describe('Context Window Validation', () => {
it.concurrent('should have correct context windows for key models', () => {
const allModels = getAllModels()
expect(allModels).toContain('gpt-5-chat-latest')
expect(allModels).toContain('o3')
expect(allModels).toContain('o4-mini')
})
}) })
describe('Cost Calculation', () => { describe('Cost Calculation', () => {
@@ -738,7 +464,7 @@ describe('Cost Calculation', () => {
expect(result.output).toBeGreaterThan(0) expect(result.output).toBeGreaterThan(0)
expect(result.total).toBeCloseTo(result.input + result.output, 6) expect(result.total).toBeCloseTo(result.input + result.output, 6)
expect(result.pricing).toBeDefined() expect(result.pricing).toBeDefined()
expect(result.pricing.input).toBe(2.5) expect(result.pricing.input).toBe(2.5) // GPT-4o pricing
}) })
it.concurrent('should handle cached input pricing when enabled', () => { it.concurrent('should handle cached input pricing when enabled', () => {
@@ -746,7 +472,7 @@ describe('Cost Calculation', () => {
const cachedCost = calculateCost('gpt-4o', 1000, 500, true) const cachedCost = calculateCost('gpt-4o', 1000, 500, true)
expect(cachedCost.input).toBeLessThan(regularCost.input) expect(cachedCost.input).toBeLessThan(regularCost.input)
expect(cachedCost.output).toBe(regularCost.output) expect(cachedCost.output).toBe(regularCost.output) // Output cost should be same
}) })
it.concurrent('should return default pricing for unknown models', () => { it.concurrent('should return default pricing for unknown models', () => {
@@ -755,7 +481,7 @@ describe('Cost Calculation', () => {
expect(result.input).toBe(0) expect(result.input).toBe(0)
expect(result.output).toBe(0) expect(result.output).toBe(0)
expect(result.total).toBe(0) expect(result.total).toBe(0)
expect(result.pricing.input).toBe(1.0) expect(result.pricing.input).toBe(1.0) // Default pricing
}) })
it.concurrent('should handle zero tokens', () => { it.concurrent('should handle zero tokens', () => {
@@ -802,15 +528,19 @@ describe('getHostedModels', () => {
it.concurrent('should return OpenAI, Anthropic, and Google models as hosted', () => { it.concurrent('should return OpenAI, Anthropic, and Google models as hosted', () => {
const hostedModels = getHostedModels() const hostedModels = getHostedModels()
// OpenAI models
expect(hostedModels).toContain('gpt-4o') expect(hostedModels).toContain('gpt-4o')
expect(hostedModels).toContain('o1') expect(hostedModels).toContain('o1')
// Anthropic models
expect(hostedModels).toContain('claude-sonnet-4-0') expect(hostedModels).toContain('claude-sonnet-4-0')
expect(hostedModels).toContain('claude-opus-4-0') expect(hostedModels).toContain('claude-opus-4-0')
// Google models
expect(hostedModels).toContain('gemini-2.5-pro') expect(hostedModels).toContain('gemini-2.5-pro')
expect(hostedModels).toContain('gemini-2.5-flash') expect(hostedModels).toContain('gemini-2.5-flash')
// Should not contain models from other providers
expect(hostedModels).not.toContain('deepseek-v3') expect(hostedModels).not.toContain('deepseek-v3')
expect(hostedModels).not.toContain('grok-4-latest') expect(hostedModels).not.toContain('grok-4-latest')
}) })
@@ -828,24 +558,31 @@ describe('getHostedModels', () => {
describe('shouldBillModelUsage', () => { describe('shouldBillModelUsage', () => {
it.concurrent('should return true for exact matches of hosted models', () => { it.concurrent('should return true for exact matches of hosted models', () => {
// OpenAI models
expect(shouldBillModelUsage('gpt-4o')).toBe(true) expect(shouldBillModelUsage('gpt-4o')).toBe(true)
expect(shouldBillModelUsage('o1')).toBe(true) expect(shouldBillModelUsage('o1')).toBe(true)
// Anthropic models
expect(shouldBillModelUsage('claude-sonnet-4-0')).toBe(true) expect(shouldBillModelUsage('claude-sonnet-4-0')).toBe(true)
expect(shouldBillModelUsage('claude-opus-4-0')).toBe(true) expect(shouldBillModelUsage('claude-opus-4-0')).toBe(true)
// Google models
expect(shouldBillModelUsage('gemini-2.5-pro')).toBe(true) expect(shouldBillModelUsage('gemini-2.5-pro')).toBe(true)
expect(shouldBillModelUsage('gemini-2.5-flash')).toBe(true) expect(shouldBillModelUsage('gemini-2.5-flash')).toBe(true)
}) })
it.concurrent('should return false for non-hosted models', () => { it.concurrent('should return false for non-hosted models', () => {
// Other providers
expect(shouldBillModelUsage('deepseek-v3')).toBe(false) expect(shouldBillModelUsage('deepseek-v3')).toBe(false)
expect(shouldBillModelUsage('grok-4-latest')).toBe(false) expect(shouldBillModelUsage('grok-4-latest')).toBe(false)
// Unknown models
expect(shouldBillModelUsage('unknown-model')).toBe(false) expect(shouldBillModelUsage('unknown-model')).toBe(false)
}) })
it.concurrent('should return false for versioned model names not in hosted list', () => { it.concurrent('should return false for versioned model names not in hosted list', () => {
// Versioned model names that are NOT in the hosted list
// These should NOT be billed (user provides own API key)
expect(shouldBillModelUsage('claude-sonnet-4-20250514')).toBe(false) expect(shouldBillModelUsage('claude-sonnet-4-20250514')).toBe(false)
expect(shouldBillModelUsage('gpt-4o-2024-08-06')).toBe(false) expect(shouldBillModelUsage('gpt-4o-2024-08-06')).toBe(false)
expect(shouldBillModelUsage('claude-3-5-sonnet-20241022')).toBe(false) expect(shouldBillModelUsage('claude-3-5-sonnet-20241022')).toBe(false)
@@ -858,7 +595,8 @@ describe('shouldBillModelUsage', () => {
}) })
it.concurrent('should not match partial model names', () => { it.concurrent('should not match partial model names', () => {
expect(shouldBillModelUsage('gpt-4')).toBe(false) // Should not match partial/prefix models
expect(shouldBillModelUsage('gpt-4')).toBe(false) // gpt-4o is hosted, not gpt-4
expect(shouldBillModelUsage('claude-sonnet')).toBe(false) expect(shouldBillModelUsage('claude-sonnet')).toBe(false)
expect(shouldBillModelUsage('gemini')).toBe(false) expect(shouldBillModelUsage('gemini')).toBe(false)
}) })
@@ -874,8 +612,8 @@ describe('Provider Management', () => {
}) })
it.concurrent('should use model patterns for pattern matching', () => { it.concurrent('should use model patterns for pattern matching', () => {
expect(getProviderFromModel('gpt-5-custom')).toBe('openai') expect(getProviderFromModel('gpt-5-custom')).toBe('openai') // Matches /^gpt/ pattern
expect(getProviderFromModel('claude-custom-model')).toBe('anthropic') expect(getProviderFromModel('claude-custom-model')).toBe('anthropic') // Matches /^claude/ pattern
}) })
it.concurrent('should default to ollama for unknown models', () => { it.concurrent('should default to ollama for unknown models', () => {
@@ -929,6 +667,7 @@ describe('Provider Management', () => {
expect(Array.isArray(allModels)).toBe(true) expect(Array.isArray(allModels)).toBe(true)
expect(allModels.length).toBeGreaterThan(0) expect(allModels.length).toBeGreaterThan(0)
// Should contain models from different providers
expect(allModels).toContain('gpt-4o') expect(allModels).toContain('gpt-4o')
expect(allModels).toContain('claude-sonnet-4-0') expect(allModels).toContain('claude-sonnet-4-0')
expect(allModels).toContain('gemini-2.5-pro') expect(allModels).toContain('gemini-2.5-pro')
@@ -973,6 +712,7 @@ describe('Provider Management', () => {
const baseProviders = getBaseModelProviders() const baseProviders = getBaseModelProviders()
expect(typeof baseProviders).toBe('object') expect(typeof baseProviders).toBe('object')
// Should exclude ollama models
}) })
}) })
@@ -980,8 +720,10 @@ describe('Provider Management', () => {
it.concurrent('should update ollama models', () => { it.concurrent('should update ollama models', () => {
const mockModels = ['llama2', 'codellama', 'mistral'] const mockModels = ['llama2', 'codellama', 'mistral']
// This should not throw
expect(() => updateOllamaProviderModels(mockModels)).not.toThrow() expect(() => updateOllamaProviderModels(mockModels)).not.toThrow()
// Verify the models were updated
const ollamaModels = getProviderModels('ollama') const ollamaModels = getProviderModels('ollama')
expect(ollamaModels).toEqual(mockModels) expect(ollamaModels).toEqual(mockModels)
}) })
@@ -1012,7 +754,7 @@ describe('JSON and Structured Output', () => {
}) })
it.concurrent('should clean up common JSON issues', () => { it.concurrent('should clean up common JSON issues', () => {
const content = '{\n "key": "value",\n "number": 42,\n}' const content = '{\n "key": "value",\n "number": 42,\n}' // Trailing comma
const result = extractAndParseJSON(content) const result = extractAndParseJSON(content)
expect(result).toEqual({ key: 'value', number: 42 }) expect(result).toEqual({ key: 'value', number: 42 })
}) })
@@ -1203,13 +945,13 @@ describe('prepareToolExecution', () => {
const { toolParams } = prepareToolExecution(tool, llmArgs, request) const { toolParams } = prepareToolExecution(tool, llmArgs, request)
expect(toolParams.apiKey).toBe('user-key') expect(toolParams.apiKey).toBe('user-key')
expect(toolParams.channel).toBe('#general') expect(toolParams.channel).toBe('#general') // User value wins
expect(toolParams.message).toBe('Hello world') expect(toolParams.message).toBe('Hello world')
}) })
it.concurrent('should filter out empty string user params', () => { it.concurrent('should filter out empty string user params', () => {
const tool = { const tool = {
params: { apiKey: 'user-key', channel: '' }, params: { apiKey: 'user-key', channel: '' }, // Empty channel
} }
const llmArgs = { message: 'Hello', channel: '#llm-channel' } const llmArgs = { message: 'Hello', channel: '#llm-channel' }
const request = {} const request = {}
@@ -1217,7 +959,7 @@ describe('prepareToolExecution', () => {
const { toolParams } = prepareToolExecution(tool, llmArgs, request) const { toolParams } = prepareToolExecution(tool, llmArgs, request)
expect(toolParams.apiKey).toBe('user-key') expect(toolParams.apiKey).toBe('user-key')
expect(toolParams.channel).toBe('#llm-channel') expect(toolParams.channel).toBe('#llm-channel') // LLM value used since user is empty
expect(toolParams.message).toBe('Hello') expect(toolParams.message).toBe('Hello')
}) })
}) })
@@ -1227,7 +969,7 @@ describe('prepareToolExecution', () => {
const tool = { const tool = {
params: { params: {
workflowId: 'child-workflow-123', workflowId: 'child-workflow-123',
inputMapping: '{}', inputMapping: '{}', // Empty JSON string from UI
}, },
} }
const llmArgs = { const llmArgs = {
@@ -1237,6 +979,7 @@ describe('prepareToolExecution', () => {
const { toolParams } = prepareToolExecution(tool, llmArgs, request) const { toolParams } = prepareToolExecution(tool, llmArgs, request)
// LLM values should be used since user object is empty
expect(toolParams.inputMapping).toEqual({ query: 'search term', limit: 10 }) expect(toolParams.inputMapping).toEqual({ query: 'search term', limit: 10 })
expect(toolParams.workflowId).toBe('child-workflow-123') expect(toolParams.workflowId).toBe('child-workflow-123')
}) })
@@ -1245,7 +988,7 @@ describe('prepareToolExecution', () => {
const tool = { const tool = {
params: { params: {
workflowId: 'child-workflow', workflowId: 'child-workflow',
inputMapping: '{"query": "", "customField": "user-value"}', inputMapping: '{"query": "", "customField": "user-value"}', // Partial values
}, },
} }
const llmArgs = { const llmArgs = {
@@ -1255,6 +998,7 @@ describe('prepareToolExecution', () => {
const { toolParams } = prepareToolExecution(tool, llmArgs, request) const { toolParams } = prepareToolExecution(tool, llmArgs, request)
// LLM fills empty query, user's customField preserved, LLM's limit included
expect(toolParams.inputMapping).toEqual({ expect(toolParams.inputMapping).toEqual({
query: 'llm-search', query: 'llm-search',
limit: 10, limit: 10,
@@ -1276,6 +1020,7 @@ describe('prepareToolExecution', () => {
const { toolParams } = prepareToolExecution(tool, llmArgs, request) const { toolParams } = prepareToolExecution(tool, llmArgs, request)
// User values win, but LLM's extra field is included
expect(toolParams.inputMapping).toEqual({ expect(toolParams.inputMapping).toEqual({
query: 'user-search', query: 'user-search',
limit: 5, limit: 5,
@@ -1287,7 +1032,7 @@ describe('prepareToolExecution', () => {
const tool = { const tool = {
params: { params: {
workflowId: 'child-workflow', workflowId: 'child-workflow',
inputMapping: { query: '', customField: 'user-value' }, inputMapping: { query: '', customField: 'user-value' }, // Object, not string
}, },
} }
const llmArgs = { const llmArgs = {
@@ -1306,7 +1051,7 @@ describe('prepareToolExecution', () => {
it.concurrent('should use LLM inputMapping when user does not provide it', () => { it.concurrent('should use LLM inputMapping when user does not provide it', () => {
const tool = { const tool = {
params: { workflowId: 'child-workflow' }, params: { workflowId: 'child-workflow' }, // No inputMapping
} }
const llmArgs = { const llmArgs = {
inputMapping: { query: 'llm-search', limit: 10 }, inputMapping: { query: 'llm-search', limit: 10 },
@@ -1325,7 +1070,7 @@ describe('prepareToolExecution', () => {
inputMapping: '{"query": "user-search"}', inputMapping: '{"query": "user-search"}',
}, },
} }
const llmArgs = {} const llmArgs = {} // No inputMapping from LLM
const request = {} const request = {}
const { toolParams } = prepareToolExecution(tool, llmArgs, request) const { toolParams } = prepareToolExecution(tool, llmArgs, request)
@@ -1347,6 +1092,7 @@ describe('prepareToolExecution', () => {
const { toolParams } = prepareToolExecution(tool, llmArgs, request) const { toolParams } = prepareToolExecution(tool, llmArgs, request)
// Should use LLM values since user JSON is invalid
expect(toolParams.inputMapping).toEqual({ query: 'llm-search' }) expect(toolParams.inputMapping).toEqual({ query: 'llm-search' })
}) })
@@ -1359,8 +1105,9 @@ describe('prepareToolExecution', () => {
const { toolParams } = prepareToolExecution(tool, llmArgs, request) const { toolParams } = prepareToolExecution(tool, llmArgs, request)
// Normal behavior: user values override LLM values
expect(toolParams.apiKey).toBe('user-key') expect(toolParams.apiKey).toBe('user-key')
expect(toolParams.channel).toBe('#general') expect(toolParams.channel).toBe('#general') // User value wins
expect(toolParams.message).toBe('Hello') expect(toolParams.message).toBe('Hello')
}) })
@@ -1378,6 +1125,8 @@ describe('prepareToolExecution', () => {
const { toolParams } = prepareToolExecution(tool, llmArgs, request) const { toolParams } = prepareToolExecution(tool, llmArgs, request)
// 0 and false should be preserved (they're valid values)
// empty string should be filled by LLM
expect(toolParams.inputMapping).toEqual({ expect(toolParams.inputMapping).toEqual({
limit: 0, limit: 0,
enabled: false, enabled: false,

View File

@@ -1,5 +1,4 @@
import { createLogger, type Logger } from '@sim/logger' import { createLogger, type Logger } from '@sim/logger'
import type OpenAI from 'openai'
import type { ChatCompletionChunk } from 'openai/resources/chat/completions' import type { ChatCompletionChunk } from 'openai/resources/chat/completions'
import type { CompletionUsage } from 'openai/resources/completions' import type { CompletionUsage } from 'openai/resources/completions'
import { env } from '@/lib/core/config/env' import { env } from '@/lib/core/config/env'
@@ -114,8 +113,6 @@ function buildProviderMetadata(providerId: ProviderId): ProviderMetadata {
} }
export const providers: Record<ProviderId, ProviderMetadata> = { export const providers: Record<ProviderId, ProviderMetadata> = {
ollama: buildProviderMetadata('ollama'),
vllm: buildProviderMetadata('vllm'),
openai: { openai: {
...buildProviderMetadata('openai'), ...buildProviderMetadata('openai'),
computerUseModels: ['computer-use-preview'], computerUseModels: ['computer-use-preview'],
@@ -126,17 +123,19 @@ export const providers: Record<ProviderId, ProviderMetadata> = {
getProviderModelsFromDefinitions('anthropic').includes(model) getProviderModelsFromDefinitions('anthropic').includes(model)
), ),
}, },
'azure-anthropic': buildProviderMetadata('azure-anthropic'),
google: buildProviderMetadata('google'), google: buildProviderMetadata('google'),
vertex: buildProviderMetadata('vertex'), vertex: buildProviderMetadata('vertex'),
'azure-openai': buildProviderMetadata('azure-openai'),
'azure-anthropic': buildProviderMetadata('azure-anthropic'),
deepseek: buildProviderMetadata('deepseek'), deepseek: buildProviderMetadata('deepseek'),
xai: buildProviderMetadata('xai'), xai: buildProviderMetadata('xai'),
cerebras: buildProviderMetadata('cerebras'), cerebras: buildProviderMetadata('cerebras'),
groq: buildProviderMetadata('groq'), groq: buildProviderMetadata('groq'),
vllm: buildProviderMetadata('vllm'),
mistral: buildProviderMetadata('mistral'), mistral: buildProviderMetadata('mistral'),
bedrock: buildProviderMetadata('bedrock'), 'azure-openai': buildProviderMetadata('azure-openai'),
openrouter: buildProviderMetadata('openrouter'), openrouter: buildProviderMetadata('openrouter'),
ollama: buildProviderMetadata('ollama'),
bedrock: buildProviderMetadata('bedrock'),
} }
export function updateOllamaProviderModels(models: string[]): void { export function updateOllamaProviderModels(models: string[]): void {
@@ -959,18 +958,6 @@ export function supportsTemperature(model: string): boolean {
return supportsTemperatureFromDefinitions(model) return supportsTemperatureFromDefinitions(model)
} }
export function supportsReasoningEffort(model: string): boolean {
return MODELS_WITH_REASONING_EFFORT.includes(model.toLowerCase())
}
export function supportsVerbosity(model: string): boolean {
return MODELS_WITH_VERBOSITY.includes(model.toLowerCase())
}
export function supportsThinking(model: string): boolean {
return MODELS_WITH_THINKING.includes(model.toLowerCase())
}
/** /**
* Get the maximum temperature value for a model * Get the maximum temperature value for a model
* @returns Maximum temperature value (1 or 2) or undefined if temperature not supported * @returns Maximum temperature value (1 or 2) or undefined if temperature not supported
@@ -1008,12 +995,15 @@ export function getThinkingLevelsForModel(model: string): string[] | null {
} }
/** /**
* Get max output tokens for a specific model. * Get max output tokens for a specific model
* Returns the model's maxOutputTokens capability for streaming requests,
* or a conservative default (8192) for non-streaming requests to avoid timeout issues.
* *
* @param model - The model ID * @param model - The model ID
* @param streaming - Whether the request is streaming (default: false)
*/ */
export function getMaxOutputTokensForModel(model: string): number { export function getMaxOutputTokensForModel(model: string, streaming = false): number {
return getMaxOutputTokensForModelFromDefinitions(model) return getMaxOutputTokensForModelFromDefinitions(model, streaming)
} }
/** /**
@@ -1136,8 +1126,8 @@ export function createOpenAICompatibleStream(
* @returns Object with hasUsedForcedTool flag and updated usedForcedTools array * @returns Object with hasUsedForcedTool flag and updated usedForcedTools array
*/ */
export function checkForForcedToolUsageOpenAI( export function checkForForcedToolUsageOpenAI(
response: OpenAI.Chat.Completions.ChatCompletion, response: any,
toolChoice: string | { type: string; function?: { name: string }; name?: string }, toolChoice: string | { type: string; function?: { name: string }; name?: string; any?: any },
providerName: string, providerName: string,
forcedTools: string[], forcedTools: string[],
usedForcedTools: string[], usedForcedTools: string[],

View File

@@ -21,8 +21,7 @@ export function setupConnectionHandlers(socket: AuthenticatedSocket, roomManager
cleanupPendingSubblocksForSocket(socket.id) cleanupPendingSubblocksForSocket(socket.id)
cleanupPendingVariablesForSocket(socket.id) cleanupPendingVariablesForSocket(socket.id)
const workflowIdHint = [...socket.rooms].find((roomId) => roomId !== socket.id) const workflowId = await roomManager.removeUserFromRoom(socket.id)
const workflowId = await roomManager.removeUserFromRoom(socket.id, workflowIdHint)
if (workflowId) { if (workflowId) {
await roomManager.broadcastPresenceUpdate(workflowId) await roomManager.broadcastPresenceUpdate(workflowId)

View File

@@ -51,66 +51,26 @@ export function setupWorkflowHandlers(socket: AuthenticatedSocket, roomManager:
const currentWorkflowId = await roomManager.getWorkflowIdForSocket(socket.id) const currentWorkflowId = await roomManager.getWorkflowIdForSocket(socket.id)
if (currentWorkflowId) { if (currentWorkflowId) {
socket.leave(currentWorkflowId) socket.leave(currentWorkflowId)
await roomManager.removeUserFromRoom(socket.id, currentWorkflowId) await roomManager.removeUserFromRoom(socket.id)
await roomManager.broadcastPresenceUpdate(currentWorkflowId) await roomManager.broadcastPresenceUpdate(currentWorkflowId)
} }
// Keep this above Redis socket key TTL (1h) so a normal idle user is not evicted too aggressively. const STALE_THRESHOLD_MS = 60_000
const STALE_THRESHOLD_MS = 75 * 60 * 1000
const now = Date.now() const now = Date.now()
const existingUsers = await roomManager.getWorkflowUsers(workflowId) const existingUsers = await roomManager.getWorkflowUsers(workflowId)
let liveSocketIds = new Set<string>()
let canCheckLiveness = false
try {
const liveSockets = await roomManager.io.in(workflowId).fetchSockets()
liveSocketIds = new Set(liveSockets.map((liveSocket) => liveSocket.id))
canCheckLiveness = true
} catch (error) {
logger.warn(
`Skipping stale cleanup for ${workflowId} due to live socket lookup failure`,
error
)
}
for (const existingUser of existingUsers) { for (const existingUser of existingUsers) {
try { if (existingUser.userId === userId && existingUser.socketId !== socket.id) {
if (existingUser.socketId === socket.id) { const isSameTab = tabSessionId && existingUser.tabSessionId === tabSessionId
continue const isStale =
}
const isSameTab = Boolean(
existingUser.userId === userId &&
tabSessionId &&
existingUser.tabSessionId === tabSessionId
)
if (isSameTab) {
logger.info(
`Cleaning up socket ${existingUser.socketId} for user ${existingUser.userId} (same tab)`
)
await roomManager.removeUserFromRoom(existingUser.socketId, workflowId)
await roomManager.io.in(existingUser.socketId).socketsLeave(workflowId)
continue
}
if (!canCheckLiveness || liveSocketIds.has(existingUser.socketId)) {
continue
}
const isStaleByActivity =
now - (existingUser.lastActivity || existingUser.joinedAt || 0) > STALE_THRESHOLD_MS now - (existingUser.lastActivity || existingUser.joinedAt || 0) > STALE_THRESHOLD_MS
if (!isStaleByActivity) {
continue
}
if (isSameTab || isStale) {
logger.info( logger.info(
`Cleaning up socket ${existingUser.socketId} for user ${existingUser.userId} (stale activity)` `Cleaning up socket ${existingUser.socketId} for user ${userId} (${isSameTab ? 'same tab' : 'stale'})`
) )
await roomManager.removeUserFromRoom(existingUser.socketId, workflowId) await roomManager.removeUserFromRoom(existingUser.socketId)
await roomManager.io.in(existingUser.socketId).socketsLeave(workflowId) roomManager.io.in(existingUser.socketId).socketsLeave(workflowId)
} catch (error) { }
logger.warn(`Best-effort cleanup failed for socket ${existingUser.socketId}`, error)
} }
} }
@@ -176,7 +136,7 @@ export function setupWorkflowHandlers(socket: AuthenticatedSocket, roomManager:
logger.error('Error joining workflow:', error) logger.error('Error joining workflow:', error)
// Undo socket.join and room manager entry if any operation failed // Undo socket.join and room manager entry if any operation failed
socket.leave(workflowId) socket.leave(workflowId)
await roomManager.removeUserFromRoom(socket.id, workflowId) await roomManager.removeUserFromRoom(socket.id)
const isReady = roomManager.isReady() const isReady = roomManager.isReady()
socket.emit('join-workflow-error', { socket.emit('join-workflow-error', {
error: isReady ? 'Failed to join workflow' : 'Realtime unavailable', error: isReady ? 'Failed to join workflow' : 'Realtime unavailable',
@@ -196,7 +156,7 @@ export function setupWorkflowHandlers(socket: AuthenticatedSocket, roomManager:
if (workflowId && session) { if (workflowId && session) {
socket.leave(workflowId) socket.leave(workflowId)
await roomManager.removeUserFromRoom(socket.id, workflowId) await roomManager.removeUserFromRoom(socket.id)
await roomManager.broadcastPresenceUpdate(workflowId) await roomManager.broadcastPresenceUpdate(workflowId)
logger.info(`User ${session.userId} (${session.userName}) left workflow ${workflowId}`) logger.info(`User ${session.userId} (${session.userName}) left workflow ${workflowId}`)

View File

@@ -66,7 +66,7 @@ export class MemoryRoomManager implements IRoomManager {
logger.debug(`Added user ${presence.userId} to workflow ${workflowId} (socket: ${socketId})`) logger.debug(`Added user ${presence.userId} to workflow ${workflowId} (socket: ${socketId})`)
} }
async removeUserFromRoom(socketId: string, _workflowIdHint?: string): Promise<string | null> { async removeUserFromRoom(socketId: string): Promise<string | null> {
const workflowId = this.socketToWorkflow.get(socketId) const workflowId = this.socketToWorkflow.get(socketId)
if (!workflowId) { if (!workflowId) {

View File

@@ -10,11 +10,9 @@ const KEYS = {
workflowMeta: (wfId: string) => `workflow:${wfId}:meta`, workflowMeta: (wfId: string) => `workflow:${wfId}:meta`,
socketWorkflow: (socketId: string) => `socket:${socketId}:workflow`, socketWorkflow: (socketId: string) => `socket:${socketId}:workflow`,
socketSession: (socketId: string) => `socket:${socketId}:session`, socketSession: (socketId: string) => `socket:${socketId}:session`,
socketPresenceWorkflow: (socketId: string) => `socket:${socketId}:presence-workflow`,
} as const } as const
const SOCKET_KEY_TTL = 3600 const SOCKET_KEY_TTL = 3600
const SOCKET_PRESENCE_WORKFLOW_KEY_TTL = 24 * 60 * 60
/** /**
* Lua script for atomic user removal from room. * Lua script for atomic user removal from room.
@@ -24,21 +22,11 @@ const SOCKET_PRESENCE_WORKFLOW_KEY_TTL = 24 * 60 * 60
const REMOVE_USER_SCRIPT = ` const REMOVE_USER_SCRIPT = `
local socketWorkflowKey = KEYS[1] local socketWorkflowKey = KEYS[1]
local socketSessionKey = KEYS[2] local socketSessionKey = KEYS[2]
local socketPresenceWorkflowKey = KEYS[3]
local workflowUsersPrefix = ARGV[1] local workflowUsersPrefix = ARGV[1]
local workflowMetaPrefix = ARGV[2] local workflowMetaPrefix = ARGV[2]
local socketId = ARGV[3] local socketId = ARGV[3]
local workflowIdHint = ARGV[4]
local workflowId = redis.call('GET', socketWorkflowKey) local workflowId = redis.call('GET', socketWorkflowKey)
if not workflowId then
workflowId = redis.call('GET', socketPresenceWorkflowKey)
end
if not workflowId and workflowIdHint ~= '' then
workflowId = workflowIdHint
end
if not workflowId then if not workflowId then
return nil return nil
end end
@@ -47,7 +35,7 @@ local workflowUsersKey = workflowUsersPrefix .. workflowId .. ':users'
local workflowMetaKey = workflowMetaPrefix .. workflowId .. ':meta' local workflowMetaKey = workflowMetaPrefix .. workflowId .. ':meta'
redis.call('HDEL', workflowUsersKey, socketId) redis.call('HDEL', workflowUsersKey, socketId)
redis.call('DEL', socketWorkflowKey, socketSessionKey, socketPresenceWorkflowKey) redis.call('DEL', socketWorkflowKey, socketSessionKey)
local remaining = redis.call('HLEN', workflowUsersKey) local remaining = redis.call('HLEN', workflowUsersKey)
if remaining == 0 then if remaining == 0 then
@@ -66,13 +54,11 @@ const UPDATE_ACTIVITY_SCRIPT = `
local workflowUsersKey = KEYS[1] local workflowUsersKey = KEYS[1]
local socketWorkflowKey = KEYS[2] local socketWorkflowKey = KEYS[2]
local socketSessionKey = KEYS[3] local socketSessionKey = KEYS[3]
local socketPresenceWorkflowKey = KEYS[4]
local socketId = ARGV[1] local socketId = ARGV[1]
local cursorJson = ARGV[2] local cursorJson = ARGV[2]
local selectionJson = ARGV[3] local selectionJson = ARGV[3]
local lastActivity = ARGV[4] local lastActivity = ARGV[4]
local ttl = tonumber(ARGV[5]) local ttl = tonumber(ARGV[5])
local presenceWorkflowTtl = tonumber(ARGV[6])
local existingJson = redis.call('HGET', workflowUsersKey, socketId) local existingJson = redis.call('HGET', workflowUsersKey, socketId)
if not existingJson then if not existingJson then
@@ -92,7 +78,6 @@ existing.lastActivity = tonumber(lastActivity)
redis.call('HSET', workflowUsersKey, socketId, cjson.encode(existing)) redis.call('HSET', workflowUsersKey, socketId, cjson.encode(existing))
redis.call('EXPIRE', socketWorkflowKey, ttl) redis.call('EXPIRE', socketWorkflowKey, ttl)
redis.call('EXPIRE', socketSessionKey, ttl) redis.call('EXPIRE', socketSessionKey, ttl)
redis.call('EXPIRE', socketPresenceWorkflowKey, presenceWorkflowTtl)
return 1 return 1
` `
@@ -179,8 +164,6 @@ export class RedisRoomManager implements IRoomManager {
pipeline.hSet(KEYS.workflowMeta(workflowId), 'lastModified', Date.now().toString()) pipeline.hSet(KEYS.workflowMeta(workflowId), 'lastModified', Date.now().toString())
pipeline.set(KEYS.socketWorkflow(socketId), workflowId) pipeline.set(KEYS.socketWorkflow(socketId), workflowId)
pipeline.expire(KEYS.socketWorkflow(socketId), SOCKET_KEY_TTL) pipeline.expire(KEYS.socketWorkflow(socketId), SOCKET_KEY_TTL)
pipeline.set(KEYS.socketPresenceWorkflow(socketId), workflowId)
pipeline.expire(KEYS.socketPresenceWorkflow(socketId), SOCKET_PRESENCE_WORKFLOW_KEY_TTL)
pipeline.hSet(KEYS.socketSession(socketId), { pipeline.hSet(KEYS.socketSession(socketId), {
userId: presence.userId, userId: presence.userId,
userName: presence.userName, userName: presence.userName,
@@ -204,11 +187,7 @@ export class RedisRoomManager implements IRoomManager {
} }
} }
async removeUserFromRoom( async removeUserFromRoom(socketId: string, retried = false): Promise<string | null> {
socketId: string,
workflowIdHint?: string,
retried = false
): Promise<string | null> {
if (!this.removeUserScriptSha) { if (!this.removeUserScriptSha) {
logger.error('removeUserFromRoom called before initialize()') logger.error('removeUserFromRoom called before initialize()')
return null return null
@@ -216,25 +195,19 @@ export class RedisRoomManager implements IRoomManager {
try { try {
const workflowId = await this.redis.evalSha(this.removeUserScriptSha, { const workflowId = await this.redis.evalSha(this.removeUserScriptSha, {
keys: [ keys: [KEYS.socketWorkflow(socketId), KEYS.socketSession(socketId)],
KEYS.socketWorkflow(socketId), arguments: ['workflow:', 'workflow:', socketId],
KEYS.socketSession(socketId),
KEYS.socketPresenceWorkflow(socketId),
],
arguments: ['workflow:', 'workflow:', socketId, workflowIdHint ?? ''],
}) })
if (typeof workflowId === 'string' && workflowId.length > 0) { if (workflowId) {
logger.debug(`Removed socket ${socketId} from workflow ${workflowId}`) logger.debug(`Removed socket ${socketId} from workflow ${workflowId}`)
return workflowId
} }
return workflowId as string | null
return null
} catch (error) { } catch (error) {
if ((error as Error).message?.includes('NOSCRIPT') && !retried) { if ((error as Error).message?.includes('NOSCRIPT') && !retried) {
logger.warn('Lua script not found, reloading...') logger.warn('Lua script not found, reloading...')
this.removeUserScriptSha = await this.redis.scriptLoad(REMOVE_USER_SCRIPT) this.removeUserScriptSha = await this.redis.scriptLoad(REMOVE_USER_SCRIPT)
return this.removeUserFromRoom(socketId, workflowIdHint, true) return this.removeUserFromRoom(socketId, true)
} }
logger.error(`Failed to remove user from room: ${socketId}`, error) logger.error(`Failed to remove user from room: ${socketId}`, error)
return null return null
@@ -242,12 +215,7 @@ export class RedisRoomManager implements IRoomManager {
} }
async getWorkflowIdForSocket(socketId: string): Promise<string | null> { async getWorkflowIdForSocket(socketId: string): Promise<string | null> {
const workflowId = await this.redis.get(KEYS.socketWorkflow(socketId)) return this.redis.get(KEYS.socketWorkflow(socketId))
if (workflowId) {
return workflowId
}
return this.redis.get(KEYS.socketPresenceWorkflow(socketId))
} }
async getUserSession(socketId: string): Promise<UserSession | null> { async getUserSession(socketId: string): Promise<UserSession | null> {
@@ -310,7 +278,6 @@ export class RedisRoomManager implements IRoomManager {
KEYS.workflowUsers(workflowId), KEYS.workflowUsers(workflowId),
KEYS.socketWorkflow(socketId), KEYS.socketWorkflow(socketId),
KEYS.socketSession(socketId), KEYS.socketSession(socketId),
KEYS.socketPresenceWorkflow(socketId),
], ],
arguments: [ arguments: [
socketId, socketId,
@@ -318,7 +285,6 @@ export class RedisRoomManager implements IRoomManager {
updates.selection !== undefined ? JSON.stringify(updates.selection) : '', updates.selection !== undefined ? JSON.stringify(updates.selection) : '',
(updates.lastActivity ?? Date.now()).toString(), (updates.lastActivity ?? Date.now()).toString(),
SOCKET_KEY_TTL.toString(), SOCKET_KEY_TTL.toString(),
SOCKET_PRESENCE_WORKFLOW_KEY_TTL.toString(),
], ],
}) })
} catch (error) { } catch (error) {
@@ -382,7 +348,7 @@ export class RedisRoomManager implements IRoomManager {
// Remove all users from Redis state // Remove all users from Redis state
for (const user of users) { for (const user of users) {
await this.removeUserFromRoom(user.socketId, workflowId) await this.removeUserFromRoom(user.socketId)
} }
// Clean up room data // Clean up room data

View File

@@ -65,10 +65,9 @@ export interface IRoomManager {
/** /**
* Remove a user from their current room * Remove a user from their current room
* Optional workflowIdHint is used when socket mapping keys are missing/expired. * Returns the workflowId they were in, or null if not in any room
* Returns the workflowId they were in, or null if not in any room.
*/ */
removeUserFromRoom(socketId: string, workflowIdHint?: string): Promise<string | null> removeUserFromRoom(socketId: string): Promise<string | null>
/** /**
* Get the workflow ID for a socket * Get the workflow ID for a socket