import { createLogger, type Logger } from '@sim/logger' import { getErrorMessage } from '@sim/utils/errors' import { omit } from '@sim/utils/object' import type OpenAI from 'openai' import type { BillingAttributionSnapshot } from '@/lib/billing/core/billing-attribution' import { formatCreditCost } from '@/lib/billing/credits/conversion' import { env } from '@/lib/core/config/env' import { getBlacklistedProvidersFromEnv, isHosted } from '@/lib/core/config/env-flags' import { normalizeRecord, normalizeStringRecord, normalizeWorkflowVariables, } from '@/lib/core/utils/records' import type { CustomBlockToolBinding } from '@/lib/workflows/custom-blocks/operations' import { isFileFieldType, type WorkflowInputField } from '@/lib/workflows/input-format' import { buildCanonicalIndex, type CanonicalGroup, type CanonicalModeOverrides, isCanonicalPair, resolveActiveCanonicalValue, scopeCanonicalModesForTool, } from '@/lib/workflows/subblocks/visibility' import { assembleCustomBlockInputMapping, isCustomBlockType } from '@/blocks/custom/build-config' import { isCustomTool } from '@/executor/constants' import { getComputerUseModels, getEmbeddingModelPricing, getHostedModels as getHostedModelsFromDefinitions, getMaxOutputTokensForModel as getMaxOutputTokensForModelFromDefinitions, getMaxTemperature as getMaxTempFromDefinitions, getModelPricing as getModelPricingFromDefinitions, getModelsWithDeepResearch, getModelsWithoutMemory, getModelsWithPromptCaching, getModelsWithReasoningEffort, getModelsWithTemperatureRange, getModelsWithTemperatureSupport, getModelsWithThinking, getModelsWithVerbosity, getProviderDefaultModel as getProviderDefaultModelFromDefinitions, getProviderModels as getProviderModelsFromDefinitions, getProvidersWithToolUsageControl, getReasoningEffortValuesForModel as getReasoningEffortValuesForModelFromDefinitions, getThinkingLevelsForModel as getThinkingLevelsForModelFromDefinitions, getVerbosityValuesForModel as getVerbosityValuesForModelFromDefinitions, isKnownModelLevelValue, PROVIDER_DEFINITIONS, supportsTemperature as supportsTemperatureFromDefinitions, supportsToolUsageControl as supportsToolUsageControlFromDefinitions, updateOllamaModels as updateOllamaModelsInDefinitions, } from '@/providers/models' import { getProviderToolInputProvenance, registerPreparedProviderToolInputProvenance, } from '@/providers/tool-input-provenance' import type { ProviderId, ProviderToolConfig } from '@/providers/types' import { useProvidersStore } from '@/stores/providers/store' import { mergeToolParameters } from '@/tools/merge-params' import type { WorkflowToolExecutionContext } from '@/tools/types' const logger = createLogger('ProviderUtils') /** * Checks if a workflow description is a default/placeholder description */ function isDefaultWorkflowDescription( description: string | null | undefined, name?: string ): boolean { if (!description) return true const normalizedDesc = description.toLowerCase().trim() return ( description === name || normalizedDesc === 'new workflow' || normalizedDesc === 'your first workflow - start building here!' ) } /** * Fetches workflow metadata (name and description) from the API */ async function fetchWorkflowMetadata( workflowId: string ): Promise<{ name: string; description: string | null } | null> { try { const { buildAuthHeaders, buildAPIUrl } = await import('@/executor/utils/http') const headers = await buildAuthHeaders() const url = buildAPIUrl(`/api/workflows/${workflowId}`) const response = await fetch(url.toString(), { headers }) if (!response.ok) { await response.text().catch(() => {}) logger.warn(`Failed to fetch workflow metadata for ${workflowId}`) return null } const { data } = await response.json() return { name: data?.name || 'Workflow', description: data?.description || null, } } catch (error) { logger.error('Error fetching workflow metadata:', error) return null } } /** * Client-safe provider metadata. * This object contains only model lists and patterns - no executeRequest implementations. * For server-side execution, use @/providers/registry. */ export interface ProviderMetadata { id: string name: string description: string version: string models: string[] defaultModel: string computerUseModels?: string[] modelPatterns?: RegExp[] } /** * Build provider metadata from PROVIDER_DEFINITIONS. * This is client-safe as it doesn't import any provider implementations. */ function buildProviderMetadata(providerId: ProviderId): ProviderMetadata { const def = PROVIDER_DEFINITIONS[providerId] return { id: providerId, name: def?.name || providerId, description: def?.description || '', version: '1.0.0', models: getProviderModelsFromDefinitions(providerId), defaultModel: getProviderDefaultModelFromDefinitions(providerId), modelPatterns: def?.modelPatterns, } } export const providers: Record = { ollama: buildProviderMetadata('ollama'), 'ollama-cloud': buildProviderMetadata('ollama-cloud'), vllm: buildProviderMetadata('vllm'), litellm: buildProviderMetadata('litellm'), openai: { ...buildProviderMetadata('openai'), computerUseModels: ['computer-use-preview'], }, anthropic: { ...buildProviderMetadata('anthropic'), computerUseModels: getComputerUseModels().filter((model) => getProviderModelsFromDefinitions('anthropic').includes(model) ), }, google: buildProviderMetadata('google'), vertex: buildProviderMetadata('vertex'), 'azure-openai': buildProviderMetadata('azure-openai'), 'azure-anthropic': buildProviderMetadata('azure-anthropic'), deepseek: buildProviderMetadata('deepseek'), xai: buildProviderMetadata('xai'), cerebras: buildProviderMetadata('cerebras'), groq: buildProviderMetadata('groq'), sakana: buildProviderMetadata('sakana'), nvidia: buildProviderMetadata('nvidia'), meta: buildProviderMetadata('meta'), zai: buildProviderMetadata('zai'), kimi: buildProviderMetadata('kimi'), mistral: buildProviderMetadata('mistral'), bedrock: buildProviderMetadata('bedrock'), openrouter: buildProviderMetadata('openrouter'), fireworks: buildProviderMetadata('fireworks'), together: buildProviderMetadata('together'), baseten: buildProviderMetadata('baseten'), } export function updateOllamaProviderModels(models: string[]): void { updateOllamaModelsInDefinitions(models) providers.ollama.models = getProviderModelsFromDefinitions('ollama') } export function updateVLLMProviderModels(models: string[]): void { const { updateVLLMModels } = require('@/providers/models') updateVLLMModels(models) providers.vllm.models = getProviderModelsFromDefinitions('vllm') } export function updateLiteLLMProviderModels(models: string[]): void { const { updateLiteLLMModels } = require('@/providers/models') updateLiteLLMModels(models) providers.litellm.models = getProviderModelsFromDefinitions('litellm') } export async function updateOpenRouterProviderModels(models: string[]): Promise { const { updateOpenRouterModels } = await import('@/providers/models') updateOpenRouterModels(models) providers.openrouter.models = getProviderModelsFromDefinitions('openrouter') } export async function updateFireworksProviderModels(models: string[]): Promise { const { updateFireworksModels } = await import('@/providers/models') updateFireworksModels(models) providers.fireworks.models = getProviderModelsFromDefinitions('fireworks') } export async function updateOllamaCloudProviderModels(models: string[]): Promise { const { updateOllamaCloudModels } = await import('@/providers/models') updateOllamaCloudModels(models) providers['ollama-cloud'].models = getProviderModelsFromDefinitions('ollama-cloud') } export async function updateTogetherProviderModels(models: string[]): Promise { const { updateTogetherModels } = await import('@/providers/models') updateTogetherModels(models) providers.together.models = getProviderModelsFromDefinitions('together') } export async function updateBasetenProviderModels(models: string[]): Promise { const { updateBasetenModels } = await import('@/providers/models') updateBasetenModels(models) providers.baseten.models = getProviderModelsFromDefinitions('baseten') } export function getBaseModelProviders(): Record { const allProviders = Object.entries(providers) .filter( ([providerId]) => providerId !== 'ollama' && providerId !== 'ollama-cloud' && providerId !== 'vllm' && providerId !== 'litellm' && providerId !== 'openrouter' && providerId !== 'fireworks' && providerId !== 'together' && providerId !== 'baseten' ) .reduce( (map, [providerId, config]) => { config.models.forEach((model) => { map[model.toLowerCase()] = providerId as ProviderId }) return map }, {} as Record ) return filterBlacklistedModelsFromProviderMap(allProviders) } function filterBlacklistedModelsFromProviderMap( providerMap: Record ): Record { const filtered: Record = {} for (const [model, providerId] of Object.entries(providerMap)) { if (isProviderBlacklisted(providerId)) { continue } if (!isModelBlacklisted(model)) { filtered[model] = providerId } } return filtered } export function getAllModelProviders(): Record { return Object.entries(providers).reduce( (map, [providerId, config]) => { config.models.forEach((model) => { map[model.toLowerCase()] = providerId as ProviderId }) return map }, {} as Record ) } export function getProviderFromModel(model: string): ProviderId { const normalizedModel = model.toLowerCase() let providerId: ProviderId | null = null if (normalizedModel in getAllModelProviders()) { providerId = getAllModelProviders()[normalizedModel] } else { for (const [id, config] of Object.entries(providers)) { if (config.modelPatterns) { for (const pattern of config.modelPatterns) { if (pattern.test(normalizedModel)) { providerId = id as ProviderId break } } } if (providerId) break } } if (!providerId) { logger.warn(`No provider found for model: ${model}, defaulting to ollama`) providerId = 'ollama' } if (isProviderBlacklisted(providerId)) { throw new Error(`Provider "${providerId}" is not available`) } if (isModelBlacklisted(normalizedModel)) { throw new Error(`Model "${model}" is not available`) } return providerId } export function getProvider(id: string): ProviderMetadata | undefined { const providerId = id.split('/')[0] as ProviderId return providers[providerId] } export function getProviderConfigFromModel(model: string): ProviderMetadata | undefined { const providerId = getProviderFromModel(model) return providers[providerId] } export function getAllModels(): string[] { return Object.values(providers).flatMap((provider) => provider.models || []) } export function getAllProviderIds(): ProviderId[] { return Object.keys(providers) as ProviderId[] } export function getProviderModels(providerId: ProviderId): string[] { return getProviderModelsFromDefinitions(providerId) } export function isProviderBlacklisted(providerId: string): boolean { return getBlacklistedProvidersFromEnv().includes(providerId.toLowerCase()) } /** * Get the list of blacklisted models from env var. * BLACKLISTED_MODELS supports: * - Exact model names: "gpt-4,claude-3-opus" * - Prefix patterns with *: "claude-*,gpt-4-*" (matches models starting with that prefix) */ function getBlacklistedModels(): { models: string[]; prefixes: string[] } { if (!env.BLACKLISTED_MODELS) return { models: [], prefixes: [] } const entries = env.BLACKLISTED_MODELS.split(',').map((m) => m.trim().toLowerCase()) const models = entries.filter((e) => !e.endsWith('*')) const prefixes = entries.filter((e) => e.endsWith('*')).map((e) => e.slice(0, -1)) return { models, prefixes } } function isModelBlacklisted(model: string): boolean { const lowerModel = model.toLowerCase() const blacklist = getBlacklistedModels() if (blacklist.models.includes(lowerModel)) { return true } if (blacklist.prefixes.some((prefix) => lowerModel.startsWith(prefix))) { return true } return false } export function filterBlacklistedModels(models: string[]): string[] { return models.filter((model) => !isModelBlacklisted(model)) } export function getProviderIcon(model: string): React.ComponentType<{ className?: string }> | null { const providerId = getProviderFromModel(model) return PROVIDER_DEFINITIONS[providerId]?.icon || null } /** * Generates prompt instructions for structured JSON output from a JSON schema. * Used as a fallback when native structured outputs are not supported. */ export function generateSchemaInstructions(schema: any, schemaName?: string): string { const name = schemaName || 'response' 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. Schema name: ${name} JSON Schema: ${JSON.stringify(schema, null, 2)} Your response must be valid JSON that exactly matches this schema structure.` } export function generateStructuredOutputInstructions(responseFormat: any): string { if (!responseFormat) return '' if (responseFormat.schema || (responseFormat.type === 'object' && responseFormat.properties)) { return '' } if (!responseFormat.fields) return '' function generateFieldStructure(field: any): string { if (field.type === 'object' && field.properties) { return `{ ${Object.entries(field.properties) .map(([key, prop]: [string, any]) => `"${key}": ${prop.type === 'number' ? '0' : '"value"'}`) .join(',\n ')} }` } return field.type === 'string' ? '"value"' : field.type === 'number' ? '0' : field.type === 'boolean' ? 'true/false' : '[]' } const exampleFormat = responseFormat.fields .map((field: any) => ` "${field.name}": ${generateFieldStructure(field)}`) .join(',\n') const fieldDescriptions = responseFormat.fields .map((field: any) => { let desc = `${field.name} (${field.type})` if (field.description) desc += `: ${field.description}` if (field.type === 'object' && field.properties) { desc += '\nProperties:' Object.entries(field.properties).forEach(([key, prop]: [string, any]) => { desc += `\n - ${key} (${(prop as any).type}): ${(prop as any).description || ''}` }) } return desc }) .join('\n') return ` Please provide your response in the following JSON format: { ${exampleFormat} } Field descriptions: ${fieldDescriptions} Your response MUST be valid JSON and include all the specified fields with their correct types. Each metric should be an object containing 'score' (number) and 'reasoning' (string).` } export function extractAndParseJSON(content: string): any { const trimmed = content.trim() const firstBrace = trimmed.indexOf('{') const lastBrace = trimmed.lastIndexOf('}') if (firstBrace === -1 || lastBrace === -1) { throw new Error('No JSON object found in content') } const jsonStr = trimmed.slice(firstBrace, lastBrace + 1) try { return JSON.parse(jsonStr) } catch (_error) { const cleaned = jsonStr .replace(/\n/g, ' ') .replace(/\s+/g, ' ') .replace(/,\s*([}\]])/g, '$1') try { return JSON.parse(cleaned) } catch (innerError) { logger.error('Failed to parse JSON response', { contentLength: content.length, extractedLength: jsonStr.length, cleanedLength: cleaned.length, error: getErrorMessage(innerError, 'Unknown error'), }) throw new Error( `Failed to parse JSON after cleanup: ${getErrorMessage(innerError, 'Unknown error')}` ) } } } /** * Resolves canonical pair ids (e.g. `tableId`, `knowledgeBaseId`) from a tool's * raw params, preferring the active basic/advanced selector subblock source over * a previously resolved canonical value. * * Selector subblocks persist their value under the subblock id (e.g. * `tableSelector`), not the canonical id, so any lookup that keys off the * canonical id — like the unique-tool-id suffix below — must resolve it first. * Mode selection mirrors {@link transformBlockTool}'s execution-time * `paramsTransform` so the resolved id matches the params the tool actually runs * with. When the active selector has no value, the original canonical value is * preserved for direct-id callers and nested tools in advanced mode. * * @returns The params with canonical resource ids resolved (non-destructive) */ function resolveCanonicalResourceParams( params: Record, canonicalGroups: CanonicalGroup[], scopedCanonicalModes?: CanonicalModeOverrides ): Record { if (canonicalGroups.length === 0) return params const resolved = { ...params } for (const group of canonicalGroups) { // Route through the canonical SOT: an explicit scoped override wins, else the value heuristic - // no `?? 'basic'` (which ignored an advanced-only value when basic was empty). const explicitMode = scopedCanonicalModes?.[group.canonicalId] const chosen = resolveActiveCanonicalValue( group, params, explicitMode ? { [group.canonicalId]: explicitMode } : undefined ) if (chosen !== undefined) resolved[group.canonicalId] = chosen } return resolved } /** JSON-schema type for a workflow input field (LLM tool schema). */ function inputFieldSchemaType(fieldType: string): string { switch (fieldType) { case 'number': return 'number' case 'boolean': return 'boolean' case 'object': return 'object' case 'array': return 'array' default: return 'string' } } /** * Build the LLM tool schema for a custom block used as an agent tool: a single * `inputMapping` object whose properties are the block's deployed input fields, * keyed by the field's stable id (so it lines up with `assembleCustomBlockInputMapping` * and the child's id→name remap) and marked required per the publisher's overrides. * `file[]` fields are omitted — the model can't synthesize uploaded-file descriptors. */ function buildCustomBlockInputMappingSchema( blockName: string, inputFields: WorkflowInputField[], requiredInputIds: string[] ): ProviderToolConfig['parameters'] { const requiredSet = new Set(requiredInputIds) const properties: Record = {} const requiredFields: string[] = [] for (const field of inputFields) { if (isFileFieldType(field.type)) continue const key = field.id ?? field.name properties[key] = { type: inputFieldSchemaType(field.type), description: field.description ? `${field.name} — ${field.description}` : field.name, } if (requiredSet.has(key)) requiredFields.push(key) } return { type: 'object', properties: { inputMapping: { type: 'object', description: `Input values for ${blockName}`, properties, required: requiredFields, }, }, required: requiredFields.length > 0 ? ['inputMapping'] : [], } } /** * Transforms a block tool into a provider tool config with operation selection * * @param block The block to transform * @param options Additional options including dependencies and selected operation * @returns The provider tool config or null if transform fails */ /** * Drops model-supplied arguments for params the tool declares off-limits to the * model (`user-only` / `hidden`). * * Deliberately keyed on the declared visibility rather than on "absent from * `parameters.properties`": an MCP or custom tool may legitimately accept keys * beyond its advertised properties (`additionalProperties`), and silently * dropping those would truncate its arguments. Only a param the tool itself * marked as not-for-the-model is removed. */ function stripModelBlockedParams( blockedParams: string[] | undefined, llmArgs: Record ): Record { if (!blockedParams?.length) return llmArgs const blocked = new Set(blockedParams) const filtered: Record = {} for (const [key, value] of Object.entries(llmArgs)) { if (!blocked.has(key)) filtered[key] = value } return filtered } /** Reads a multi-select value that may still be JSON-encoded from `StoredTool.params`. */ function readMountedSecretNames(raw: unknown): string[] { let value = raw if (typeof value === 'string') { try { value = JSON.parse(value) } catch { // A bare name rather than a list — treat it as a single entry. return value ? [value as string] : [] } } return Array.isArray(value) ? value.filter((name): name is string => typeof name === 'string' && name.length > 0) : [] } export async function transformBlockTool( block: any, options: { selectedOperation?: string getAllBlocks: () => any[] getTool: (toolId: string) => any getToolAsync?: (toolId: string) => Promise canonicalModes?: Record enrichmentContext?: WorkflowToolExecutionContext /** * Server-only resolver for a custom (deploy-as-block) tool's binding (bound * workflow + input schema), org-scoped to the consumer. Injected as a dependency * — like `getAllBlocks`/`getTool` — so this client-reachable module never imports * the DB-backed `operations` module. Omit for non-server callers that can't * resolve authority; a custom block is then simply not offered as a tool. */ resolveCustomBlockBinding?: (blockType: string) => Promise /** * Position of this tool within its parent agent block's `tool-input` array. Canonical-mode * overrides are stored scoped by this index (`${toolIndex}:${canonicalId}`) rather than by * `block.type`, so that two tool entries of the same type (e.g. two Table tools) don't share * a canonical-mode override. Omit for tools with no such array position (e.g. Pi local tools). */ toolIndex?: number } ): Promise { const { selectedOperation, getAllBlocks, getTool, getToolAsync, canonicalModes, enrichmentContext, toolIndex, } = options const scopedCanonicalModes = scopeCanonicalModesForTool(canonicalModes, toolIndex, block.type) const blockDef = getAllBlocks().find((b: any) => b.type === block.type) if (!blockDef) { logger.warn(`Block definition not found for type: ${block.type}`) return null } // Custom (deploy-as-block) blocks resolve to the generic `workflow_executor`, but // as an agent tool they must run through the authority boundary (owner identity, // latest deployment, curated outputs) — not the plain workflow executor. Route // them to the dedicated in-process `deployed_block_executor` tool, carrying the // block TYPE (never a source workflow id) so authority is re-resolved server-side. // Dynamic imports keep the DB/executor dependency graph out of client bundles. if (isCustomBlockType(block.type)) { const binding = await options.resolveCustomBlockBinding?.(block.type) if (!binding) { logger.warn(`Custom block tool binding not resolved for type: ${block.type}`) return null } const customToolConfig = getTool('deployed_block_executor') if (!customToolConfig) { logger.warn('deployed_block_executor tool not registered') return null } const inputMapping = assembleCustomBlockInputMapping(block.params || {}) // A `file[]` field is omitted from the model schema (the model can't synthesize // upload descriptors). If such a field is REQUIRED and the user hasn't // pre-filled it on the block, no invocation could ever satisfy the child's // required-input check — so don't offer an unusable tool at all. const prefilled = JSON.parse(inputMapping) as Record const requiredIds = new Set(binding.requiredInputIds) const unfillableFileField = binding.inputFields.find((field) => { const key = field.id ?? field.name return isFileFieldType(field.type) && requiredIds.has(key) && !(key in prefilled) }) if (unfillableFileField) { logger.warn( `Custom block ${block.type} not offered as a tool: required file input "${unfillableFileField.name}" has no preset value and cannot be supplied by the model` ) return null } return { // Unique per block so two custom-block tools never collide on the wire. id: `deployed_block_executor_${block.type}`, // Name/description come from the block itself — never the source workflow's // metadata, which the consumer has no access to. name: blockDef.name, description: blockDef.description || customToolConfig.description, params: { blockType: block.type, inputMapping, }, parameters: buildCustomBlockInputMappingSchema( blockDef.name, binding.inputFields, binding.requiredInputIds ), } } let toolId: string | null = null if ((blockDef.tools?.access?.length || 0) > 1) { if (selectedOperation && blockDef.tools?.config?.tool) { try { toolId = blockDef.tools.config.tool({ ...block.params, operation: selectedOperation, }) } catch (error) { logger.error('Error selecting tool for block', { blockType: block.type, operation: selectedOperation, error, }) return null } } else { toolId = blockDef.tools.access[0] } } else { toolId = blockDef.tools?.access?.[0] || null } if (!toolId) { logger.warn(`No tool ID found for block: ${block.type}`) return null } let toolConfig: any if (isCustomTool(toolId) && getToolAsync) { toolConfig = await getToolAsync(toolId) } else { toolConfig = getTool(toolId) } if (!toolConfig) { logger.warn(`Tool config not found for ID: ${toolId}`) return null } const { createLLMToolSchema } = await import('@/tools/params') const userProvidedParams = block.params || {} const canonicalGroups: CanonicalGroup[] = blockDef?.subBlocks ? Object.values(buildCanonicalIndex(blockDef.subBlocks).groupsById).filter(isCanonicalPair) : [] const resolvedResourceParams = resolveCanonicalResourceParams( userProvidedParams, canonicalGroups, scopedCanonicalModes ) const { schema: llmSchema, enrichedDescription, modelBlockedParams, } = await createLLMToolSchema(toolConfig, resolvedResourceParams, enrichmentContext) let uniqueToolId = toolConfig.id let toolName = toolConfig.name let toolDescription = enrichedDescription || toolConfig.description if (toolId === 'workflow_executor' && resolvedResourceParams.workflowId) { uniqueToolId = `${toolConfig.id}_${resolvedResourceParams.workflowId}` const workflowMetadata = await fetchWorkflowMetadata(resolvedResourceParams.workflowId) if (workflowMetadata) { toolName = workflowMetadata.name || toolConfig.name if ( workflowMetadata.description && !isDefaultWorkflowDescription(workflowMetadata.description, workflowMetadata.name) ) { toolDescription = workflowMetadata.description } } } else if (toolId === 'function_execute' && resolvedResourceParams.secretScope === 'selected') { // Scoping alone would leave the model guessing: the secrets are injected // server-side and nothing else advertises them. Names only — values never // enter the provider request, matching the copilot's workspace-context rule. // `StoredTool.params` holds strings, so a multi-select arrives JSON-encoded; // the executor's paramsTransform parses it later, but this runs before that. const mounted = readMountedSecretNames(resolvedResourceParams.mountedSecrets) toolDescription = mounted.length ? `${toolDescription}\n\nWorkspace secret names available to this code: ${mounted.join(', ')}. Reference one with the exact {{NAME}} syntax. Its value is bound only while the code executes and is not included in the model request. No other secrets are readable.` : `${toolDescription}\n\nThis code has no access to workspace secrets.` } else if (toolId.startsWith('knowledge_') && resolvedResourceParams.knowledgeBaseId) { uniqueToolId = `${toolConfig.id}_${resolvedResourceParams.knowledgeBaseId}` } else if (toolId.startsWith('table_') && resolvedResourceParams.tableId) { uniqueToolId = `${toolConfig.id}_${resolvedResourceParams.tableId}` } const blockParamsFn = blockDef?.tools?.config?.params as | ((p: Record) => Record) | undefined const blockInputDefs = blockDef?.inputs as Record | undefined const needsTransform = blockParamsFn || blockInputDefs || canonicalGroups.length > 0 const paramsTransform = needsTransform ? (params: Record): Record => { let result = { ...params } for (const group of canonicalGroups) { // Route through the canonical SOT: an explicit scoped override wins, else the value // heuristic - no `?? 'basic'` (which dropped an advanced-only value when basic was empty). const explicitMode = scopedCanonicalModes?.[group.canonicalId] const chosen = resolveActiveCanonicalValue( group, result, explicitMode ? { [group.canonicalId]: explicitMode } : undefined ) const sourceIds = [group.basicId, ...group.advancedIds].filter(Boolean) as string[] result = omit(result, sourceIds) if (chosen !== undefined) { result[group.canonicalId] = chosen } } if (blockParamsFn) { const transformed = blockParamsFn(result) result = { ...result, ...transformed } } if (blockInputDefs) { for (const [key, schema] of Object.entries(blockInputDefs)) { const value = result[key] if (typeof value === 'string' && value.trim().length > 0) { const inputType = typeof schema === 'object' ? schema.type : schema if (inputType === 'json' || inputType === 'array') { try { result[key] = JSON.parse(value.trim()) } catch { // Not valid JSON — keep as string } } } } } return result } : undefined return { id: uniqueToolId, name: toolName, description: toolDescription, params: userProvidedParams, parameters: llmSchema, modelBlockedParams, paramsTransform, } } /** * Calculate cost for token usage based on model pricing * * @param model The model name * @param promptTokens Number of prompt tokens used * @param completionTokens Number of completion tokens used * @param useCachedInput Whether to use cached input pricing (default: false) * @param customMultiplier Optional custom multiplier to override the default cost multiplier * @returns Cost calculation results with input, output and total costs */ export function calculateCost( model: string, promptTokens = 0, completionTokens = 0, useCachedInput = false, inputMultiplier?: number, outputMultiplier?: number ) { let pricing = getEmbeddingModelPricing(model) if (!pricing) { pricing = getModelPricingFromDefinitions(model) } if (!pricing) { const defaultPricing = { input: 1.0, cachedInput: 0.5, output: 5.0, updatedAt: '2025-03-21', } return { input: 0, output: 0, total: 0, pricing: defaultPricing, } } const inputCost = promptTokens * (useCachedInput && pricing.cachedInput ? pricing.cachedInput / 1_000_000 : pricing.input / 1_000_000) const outputCost = completionTokens * (pricing.output / 1_000_000) const finalInputCost = inputCost * (inputMultiplier ?? 1) const finalOutputCost = outputCost * (outputMultiplier ?? 1) const finalTotalCost = finalInputCost + finalOutputCost return { input: Number.parseFloat(finalInputCost.toFixed(8)), output: Number.parseFloat(finalOutputCost.toFixed(8)), total: Number.parseFloat(finalTotalCost.toFixed(8)), pricing, } } /** * Recursively enforces OpenAI strict-mode requirements on a JSON schema: * - Sets `additionalProperties: false` on every object type. * - Forces `required` to include ALL property keys. * * Required for any OpenAI-compatible backend that validates strict structured * outputs (OpenAI, Azure OpenAI, and OpenAI routes behind proxies like LiteLLM), * which reject schemas missing these constraints with an HTTP 400. */ export function enforceStrictSchema(schema: Record): Record { if (!schema || typeof schema !== 'object') return schema const result = { ...schema } if (result.type === 'object') { result.additionalProperties = false if (result.properties && typeof result.properties === 'object') { const propKeys = Object.keys(result.properties as Record) result.required = propKeys result.properties = Object.fromEntries( Object.entries(result.properties as Record).map(([key, value]) => [ key, enforceStrictSchema(value as Record), ]) ) } } if (result.type === 'array' && result.items) { result.items = enforceStrictSchema(result.items as Record) } for (const keyword of ['anyOf', 'oneOf', 'allOf']) { if (Array.isArray(result[keyword])) { result[keyword] = (result[keyword] as Record[]).map(enforceStrictSchema) } } for (const defKey of ['$defs', 'definitions']) { if (result[defKey] && typeof result[defKey] === 'object') { result[defKey] = Object.fromEntries( Object.entries(result[defKey] as Record).map(([key, value]) => [ key, enforceStrictSchema(value as Record), ]) ) } } return result } /** * Sums the `cost.total` from each tool result returned during a provider tool loop. * Tool results may carry a `cost` object injected by `applyHostedKeyCostToResult`. */ export function sumToolCosts(toolResults?: Record[]): number { if (!toolResults?.length) return 0 let total = 0 for (const tr of toolResults) { const cost = tr?.cost as Record | undefined if (cost?.total && typeof cost.total === 'number') total += cost.total } return total } export function getModelPricing(modelId: string): any { const embeddingPricing = getEmbeddingModelPricing(modelId) if (embeddingPricing) { return embeddingPricing } return getModelPricingFromDefinitions(modelId) } /** * Format cost as a credit string for display. * Internally cost is in USD; this converts to credits (1 USD = 200 credits). * * @param cost Cost in USD * @returns Formatted credit string (e.g. "200 credits", "<1 credit", "0 credits") */ export function formatCost(cost: number): string { return formatCreditCost(cost) ?? '—' } /** * Get the list of models that are hosted by the platform (don't require user API keys) * These are the models for which we hide the API key field in the hosted environment */ export function getHostedModels(): string[] { return getHostedModelsFromDefinitions() } /** * Determine if model usage should be billed to the user * * @param model The model name * @returns true if the usage should be billed to the user */ export function shouldBillModelUsage(model: string): boolean { const hostedModels = getHostedModels() return hostedModels.some((hostedModel) => model.toLowerCase() === hostedModel.toLowerCase()) } /** * Placeholder returned for providers that use their own credential mechanism * rather than a user-supplied API key (e.g. AWS Bedrock via IAM/instance profiles). * Must be truthy so upstream key-presence checks don't reject it. */ export const PROVIDER_PLACEHOLDER_KEY = 'provider-uses-own-credentials' /** * Get an API key for a specific provider, handling rotation and fallbacks * For use server-side only */ export function getApiKey(provider: string, model: string, userProvidedKey?: string): string { const hasUserKey = !!userProvidedKey const isOllamaModel = provider === 'ollama' || useProvidersStore.getState().providers.ollama.models.includes(model) if (isOllamaModel) { return 'empty' } const isVllmModel = provider === 'vllm' || useProvidersStore.getState().providers.vllm.models.includes(model) if (isVllmModel) { return userProvidedKey || 'empty' } const isLitellmModel = provider === 'litellm' || useProvidersStore.getState().providers.litellm.models.includes(model) if (isLitellmModel) { return userProvidedKey || 'empty' } // Bedrock uses its own credentials (bedrockAccessKeyId/bedrockSecretKey), not apiKey const isBedrockModel = provider === 'bedrock' || model.startsWith('bedrock/') if (isBedrockModel) { return PROVIDER_PLACEHOLDER_KEY } const isOpenAIModel = provider === 'openai' const isClaudeModel = provider === 'anthropic' const isGeminiModel = provider === 'google' const isZaiModel = provider === 'zai' const isXaiModel = provider === 'xai' const isKimiModel = provider === 'kimi' if ( isHosted && (isOpenAIModel || isClaudeModel || isGeminiModel || isZaiModel || isXaiModel || isKimiModel) ) { const hostedModels = getHostedModels() const isModelHosted = hostedModels.some((m) => m.toLowerCase() === model.toLowerCase()) if (isModelHosted) { try { const { getRotatingApiKey } = require('@/lib/core/config/api-keys') const serverKey = getRotatingApiKey(isGeminiModel ? 'gemini' : provider) return serverKey } catch (_error) { if (hasUserKey) { return userProvidedKey! } throw new Error(`No API key available for ${provider} ${model}`) } } } if (!hasUserKey) { throw new Error(`API key is required for ${provider} ${model}`) } return userProvidedKey! } /** * Prepares tool configuration for provider requests with consistent tool usage control behavior * * @param tools Array of tools in provider-specific format * @param providerTools Original tool configurations with usage control settings * @param logger Logger instance to use for logging * @param provider Optional provider ID to adjust format for specific providers * @returns Object with prepared tools and tool_choice settings */ export function prepareToolsWithUsageControl( tools: any[] | undefined, providerTools: any[] | undefined, logger: any, provider?: string ): { tools: any[] | undefined toolChoice: | 'auto' | 'none' | { type: 'function'; function: { name: string } } | { type: 'tool'; name: string } | { type: 'any'; any: { model: string; name: string } } | undefined toolConfig?: { functionCallingConfig: { mode: 'AUTO' | 'ANY' | 'NONE' allowedFunctionNames?: string[] } } hasFilteredTools: boolean forcedTools: string[] } { if (!tools || tools.length === 0) { return { tools: undefined, toolChoice: undefined, hasFilteredTools: false, forcedTools: [], } } const filteredTools = tools.filter((tool) => { const toolId = tool.function?.name || tool.name const toolConfig = providerTools?.find((t) => t.id === toolId) return toolConfig?.usageControl !== 'none' }) const hasFilteredTools = filteredTools.length < tools.length if (hasFilteredTools) { logger.info( `Filtered out ${tools.length - filteredTools.length} tools with usageControl='none'` ) } if (filteredTools.length === 0) { logger.info('All tools were filtered out due to usageControl="none"') return { tools: undefined, toolChoice: undefined, hasFilteredTools: true, forcedTools: [], } } const forcedTools = providerTools?.filter((tool) => tool.usageControl === 'force') || [] const forcedToolIds = forcedTools.map((tool) => tool.id) let toolChoice: | 'auto' | 'none' | { type: 'function'; function: { name: string } } | { type: 'tool'; name: string } | { type: 'any'; any: { model: string; name: string } } = 'auto' let toolConfig: | { functionCallingConfig: { mode: 'AUTO' | 'ANY' | 'NONE' allowedFunctionNames?: string[] } } | undefined if (forcedTools.length > 0) { const forcedTool = forcedTools[0] if (provider === 'anthropic') { toolChoice = { type: 'tool', name: forcedTool.id, } } else if (provider === 'google') { toolConfig = { functionCallingConfig: { mode: 'ANY', allowedFunctionNames: forcedTools.length === 1 ? [forcedTool.id] : forcedToolIds, }, } toolChoice = 'auto' } else { toolChoice = { type: 'function', function: { name: forcedTool.id }, } } logger.info(`Forcing use of tool: ${forcedTool.id}`) if (forcedTools.length > 1) { logger.info( `Multiple tools set to 'force' mode (${forcedToolIds.join(', ')}). Will cycle through them sequentially.` ) } } else { toolChoice = 'auto' if (provider === 'google') { toolConfig = { functionCallingConfig: { mode: 'AUTO' } } } logger.info('Setting tool_choice to auto - letting model decide which tools to use') } return { tools: filteredTools, toolChoice, toolConfig, hasFilteredTools, forcedTools: forcedToolIds, } } /** * Narrows the SDK's `ChatCompletionMessageToolCall` union to its function variant. * * v5 of the `openai` SDK widened that union with a `custom` tool call carrying no `function` * field, so every `.function` access needs narrowing first. Sim only ever declares function * tools, so a custom call should not arrive. * * Deliberately tests for the `function` payload rather than `type === 'function'`: many * OpenAI-compatible vendors omit `type` on tool calls entirely, and discriminating on it would * silently drop every tool call those providers return. Total by construction, because these * same gateways are the ones that emit a malformed `tool_calls` entry, and this now runs on * every tool-bearing response. */ export function isFunctionToolCall( toolCall: OpenAI.Chat.Completions.ChatCompletionMessageToolCall ): toolCall is OpenAI.Chat.Completions.ChatCompletionMessageFunctionToolCall { return ( typeof toolCall === 'object' && toolCall !== null && 'function' in toolCall && toolCall.function != null ) } /** * Checks if a forced tool has been used in a response and manages the tool_choice accordingly * * @param toolCallsResponse Array of tool calls in the response * @param originalToolChoice The original tool_choice setting used in the request * @param logger Logger instance to use for logging * @param provider Optional provider ID to adjust format for specific providers * @param forcedTools Array of all tool IDs that should be forced in sequence * @param usedForcedTools Array of tool IDs that have already been used * @returns Object containing tracking information and next tool choice */ export function trackForcedToolUsage( toolCallsResponse: any[] | undefined, originalToolChoice: any, logger: any, provider?: string, forcedTools: string[] = [], usedForcedTools: string[] = [] ): { hasUsedForcedTool: boolean usedForcedTools: string[] nextToolChoice?: | 'auto' | { type: 'function'; function: { name: string } } | { type: 'tool'; name: string } | { type: 'any'; any: { model: string; name: string } } | null nextToolConfig?: { functionCallingConfig: { mode: 'AUTO' | 'ANY' | 'NONE' allowedFunctionNames?: string[] } } } { let hasUsedForcedTool = false let nextToolChoice = originalToolChoice let nextToolConfig: | { functionCallingConfig: { mode: 'AUTO' | 'ANY' | 'NONE' allowedFunctionNames?: string[] } } | undefined const updatedUsedForcedTools = [...usedForcedTools] const isGoogleFormat = provider === 'google' let forcedToolNames: string[] = [] if (isGoogleFormat && originalToolChoice?.functionCallingConfig?.allowedFunctionNames) { forcedToolNames = originalToolChoice.functionCallingConfig.allowedFunctionNames } else if ( typeof originalToolChoice === 'object' && (originalToolChoice?.function?.name || (originalToolChoice?.type === 'tool' && originalToolChoice?.name) || (originalToolChoice?.type === 'any' && originalToolChoice?.any?.name)) ) { forcedToolNames = [ originalToolChoice?.function?.name || originalToolChoice?.name || originalToolChoice?.any?.name, ].filter(Boolean) } if (forcedToolNames.length > 0 && toolCallsResponse && toolCallsResponse.length > 0) { const toolNames = toolCallsResponse.map((tc) => tc.function?.name || tc.name || tc.id) const toolNameSet = new Set(toolNames) const usedTools = forcedToolNames.filter((toolName) => toolNameSet.has(toolName)) if (usedTools.length > 0) { hasUsedForcedTool = true updatedUsedForcedTools.push(...usedTools) const usedSet = new Set(updatedUsedForcedTools) const remainingTools = forcedTools.filter((tool) => !usedSet.has(tool)) if (remainingTools.length > 0) { const nextToolToForce = remainingTools[0] if (provider === 'anthropic') { nextToolChoice = { type: 'tool', name: nextToolToForce, } } else if (provider === 'google') { nextToolConfig = { functionCallingConfig: { mode: 'ANY', allowedFunctionNames: remainingTools.length === 1 ? [nextToolToForce] : remainingTools, }, } } else { nextToolChoice = { type: 'function', function: { name: nextToolToForce }, } } logger.info( `Forced tool(s) ${usedTools.join(', ')} used, switching to next forced tool(s): ${remainingTools.join(', ')}` ) } else { if (provider === 'anthropic') { nextToolChoice = null } else if (provider === 'google') { nextToolConfig = { functionCallingConfig: { mode: 'AUTO' } } } else { nextToolChoice = 'auto' } logger.info('All forced tools have been used, switching to auto mode for future iterations') } } } return { hasUsedForcedTool, usedForcedTools: updatedUsedForcedTools, nextToolChoice: hasUsedForcedTool ? nextToolChoice : originalToolChoice, nextToolConfig: isGoogleFormat ? hasUsedForcedTool ? nextToolConfig : originalToolChoice : undefined, } } export const MODELS_TEMP_RANGE_0_2 = getModelsWithTemperatureRange(2) export const MODELS_TEMP_RANGE_0_15 = getModelsWithTemperatureRange(1.5) export const MODELS_TEMP_RANGE_0_1 = getModelsWithTemperatureRange(1) export const MODELS_WITH_TEMPERATURE_SUPPORT = getModelsWithTemperatureSupport() export const MODELS_WITH_REASONING_EFFORT = getModelsWithReasoningEffort() export const MODELS_WITH_VERBOSITY = getModelsWithVerbosity() export const MODELS_WITH_THINKING = getModelsWithThinking() export const MODELS_WITH_PROMPT_CACHING = getModelsWithPromptCaching() export const MODELS_WITH_DEEP_RESEARCH = getModelsWithDeepResearch() export const MODELS_WITHOUT_MEMORY = getModelsWithoutMemory() export const PROVIDERS_WITH_TOOL_USAGE_CONTROL = getProvidersWithToolUsageControl() export function supportsTemperature(model: string): boolean { return supportsTemperatureFromDefinitions(model) } /** * Levels the pickers offer on top of what a model declares. `auto` means "say nothing" and * `none` means "explicitly off"; provider adapters special-case both, so neither is an * unrecognized level. */ const MODEL_LEVEL_SENTINELS = new Set(['auto', 'none']) /** * Renders a tuning level for a log line or an error message. * * The agent block's reasoning effort, verbosity, and thinking level fields accept variable and * environment references, so an unrecognized level is not necessarily a mistyped level — it is * whatever the reference resolved to, up to and including secret content. Only a level the * catalogue declares somewhere is safe to echo; anything else is reported by length alone, * which still distinguishes a stray level from a resolved blob. * * Every site that puts a caller-supplied level into a message must go through this. */ export function describeModelLevel(value: string | undefined): string { if (!value) return '(unset)' const isSafe = MODEL_LEVEL_SENTINELS.has(value) || isKnownModelLevelValue(value) return isSafe ? value : `[redacted ${value.length} chars]` } 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()) } /** Whether the model accepts caller-placed prompt-cache breakpoints. */ export function supportsPromptCaching(model: string): boolean { return MODELS_WITH_PROMPT_CACHING.includes(model.toLowerCase()) } export function isDeepResearchModel(model: string): boolean { return MODELS_WITH_DEEP_RESEARCH.includes(model.toLowerCase()) } export function isGemini3Model(model: string): boolean { const normalized = model.toLowerCase().replace(/^vertex\//, '') return normalized.startsWith('gemini-3') } /** * Get the maximum temperature value for a model * @returns Maximum temperature value (1 or 2) or undefined if temperature not supported */ export function getMaxTemperature(model: string): number | undefined { return getMaxTempFromDefinitions(model) } export function supportsToolUsageControl(provider: string): boolean { return supportsToolUsageControlFromDefinitions(provider) } /** * Get reasoning effort values for a specific model * Returns the valid options for that model, or null if the model doesn't support reasoning effort */ export function getReasoningEffortValuesForModel(model: string): string[] | null { return getReasoningEffortValuesForModelFromDefinitions(model) } /** * Get verbosity values for a specific model * Returns the valid options for that model, or null if the model doesn't support verbosity */ export function getVerbosityValuesForModel(model: string): string[] | null { return getVerbosityValuesForModelFromDefinitions(model) } /** * Get thinking levels for a specific model * Returns the valid levels for that model, or null if the model doesn't support thinking */ export function getThinkingLevelsForModel(model: string): string[] | null { return getThinkingLevelsForModelFromDefinitions(model) } /** * Get max output tokens for a specific model. * * @param model - The model ID */ export function getMaxOutputTokensForModel(model: string): number { return getMaxOutputTokensForModelFromDefinitions(model) } /** * Prepare tool execution parameters, separating tool parameters from system parameters */ export function prepareToolExecution( tool: { params?: Record parameters?: Record modelBlockedParams?: string[] paramsTransform?: (params: Record) => Record }, llmArgs: Record, request: { workflowId?: string workspaceId?: string chatId?: string userId?: string environmentVariables?: Record workflowVariables?: Record blockData?: Record blockNameMapping?: Record isDeployedContext?: boolean callChain?: string[] billingAttribution?: BillingAttributionSnapshot /** Invoking run's execution id — see `ProviderRequest.executionId`. */ executionId?: string } ): { toolParams: Record executionParams: Record } { // Providers are supposed to emit only declared arguments, but nothing enforces // it on the parsed tool call — and `mergeToolParameters` seeds its result from // the model's args, so an undeclared key survives whenever the user's value is // empty. That is a privilege escalation for `user-only` params: a Function tool // scoped to "Selected secrets" with an empty list is an explicit deny, and a // model emitting `mountedSecrets: ['STRIPE_KEY']` would otherwise mount it. const modelParams = stripModelBlockedParams(tool.modelBlockedParams, llmArgs) let toolParams = mergeToolParameters(tool.params || {}, modelParams) as Record const inputProvenance = getProviderToolInputProvenance(tool) const inputRegistry = inputProvenance?.registry.forkForInputPaths([inputProvenance.sourcePath]) let projectedToolParams = inputProvenance ? (mergeToolParameters(inputProvenance.projectedParams, modelParams) as Record) : undefined if (tool.paramsTransform) { let transformed = false try { toolParams = tool.paramsTransform(toolParams) transformed = true } catch (err) { logger.warn('paramsTransform failed, using raw params', { error: err }) } if (transformed && projectedToolParams && inputRegistry) { try { projectedToolParams = tool.paramsTransform(projectedToolParams) } catch { inputRegistry.markIncomplete() projectedToolParams = undefined } } } const executionParams = { ...toolParams, ...(request.workflowId || request.billingAttribution ? { _context: { ...(request.workflowId ? { workflowId: request.workflowId } : {}), ...(request.workspaceId ? { workspaceId: request.workspaceId } : {}), ...(request.chatId ? { chatId: request.chatId } : {}), ...(request.userId ? { userId: request.userId } : {}), ...(request.isDeployedContext !== undefined ? { isDeployedContext: request.isDeployedContext } : {}), ...(request.callChain ? { callChain: request.callChain } : {}), ...(request.executionId ? { executionId: request.executionId } : {}), ...(request.billingAttribution ? { billingAttribution: request.billingAttribution } : {}), }, } : {}), ...(request.environmentVariables ? { envVars: normalizeStringRecord(request.environmentVariables) } : {}), ...(request.workflowVariables ? { workflowVariables: normalizeWorkflowVariables(request.workflowVariables) } : {}), ...(request.blockData ? { blockData: normalizeRecord(request.blockData) } : {}), ...(request.blockNameMapping ? { blockNameMapping: normalizeStringRecord(request.blockNameMapping) } : {}), ...(tool.parameters ? { _toolSchema: tool.parameters } : {}), } if (inputProvenance && inputRegistry) { const inputPaths = [['params']] as const if (projectedToolParams) { inputRegistry.recordTransformedInputProjection( { params: toolParams }, { params: projectedToolParams } ) } registerPreparedProviderToolInputProvenance(executionParams, { registry: inputRegistry, inputPaths, }) } return { toolParams, executionParams } } /** * Checks if a forced tool was used in an OpenAI-compatible response and updates tracking. * This is a shared utility used by OpenAI-compatible providers: * OpenAI, Groq, DeepSeek, xAI, OpenRouter, Mistral, Ollama, vLLM, Azure OpenAI, Cerebras * * @param response - The API response containing tool calls * @param toolChoice - The tool choice configuration (string or object) * @param providerName - Name of the provider for logging purposes * @param forcedTools - Array of forced tool names * @param usedForcedTools - Array of already used forced tools * @param customLogger - Optional custom logger instance * @returns Object with hasUsedForcedTool flag and updated usedForcedTools array */ export function checkForForcedToolUsageOpenAI( response: OpenAI.Chat.Completions.ChatCompletion, toolChoice: string | { type: string; function?: { name: string }; name?: string }, providerName: string, forcedTools: string[], usedForcedTools: string[], customLogger?: Logger ): { hasUsedForcedTool: boolean; usedForcedTools: string[] } { const checkLogger = customLogger || createLogger(`${providerName}Utils`) let hasUsedForcedTool = false let updatedUsedForcedTools = [...usedForcedTools] const toolCallsResponse = typeof toolChoice === 'object' ? response.choices?.[0]?.message?.tool_calls?.filter(isFunctionToolCall) : undefined if (toolCallsResponse?.length) { const result = trackForcedToolUsage( toolCallsResponse, toolChoice, checkLogger, providerName.toLowerCase().replace(/\s+/g, '-'), forcedTools, updatedUsedForcedTools ) hasUsedForcedTool = result.hasUsedForcedTool updatedUsedForcedTools = result.usedForcedTools } return { hasUsedForcedTool, usedForcedTools: updatedUsedForcedTools } }