6bf8bebf51
CI / Test and Build (push) Failing after 1s
CI / Migrate Dev DB (push) Has been skipped
CI / Migrate DB (push) Has been skipped
CodeQL / Analyze actions (push) Has been cancelled
CodeQL / Analyze javascript-typescript (push) Has been cancelled
CI / Detect Version (push) Has been cancelled
CI / Detect Desktop Changes (push) Has been cancelled
CI / Build AMD64 (blacksmith-2vcpu-ubuntu-2404, ./docker/cron.Dockerfile, ubuntu-latest, ghcr.io/simstudioai/cron) (push) Has been cancelled
CI / Build AMD64 (blacksmith-2vcpu-ubuntu-2404, ./docker/db.Dockerfile, ECR_MIGRATIONS, ubuntu-latest, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build AMD64 (blacksmith-4vcpu-ubuntu-2404, ./docker/pii.Dockerfile, ECR_PII, ubuntu-latest, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build AMD64 (blacksmith-4vcpu-ubuntu-2404, ./docker/realtime.Dockerfile, ECR_REALTIME, ubuntu-latest, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Build AMD64 (blacksmith-8vcpu-ubuntu-2404, ./docker/app.Dockerfile, ECR_APP, linux-x64-8-core, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (blacksmith-4vcpu-ubuntu-2404-arm, ./docker/cron.Dockerfile, ubuntu-24.04-arm, ghcr.io/simstudioai/cron) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (blacksmith-4vcpu-ubuntu-2404-arm, ./docker/db.Dockerfile, ubuntu-24.04-arm, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (blacksmith-4vcpu-ubuntu-2404-arm, ./docker/pii.Dockerfile, ubuntu-24.04-arm, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (blacksmith-4vcpu-ubuntu-2404-arm, ./docker/realtime.Dockerfile, ubuntu-24.04-arm, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (blacksmith-8vcpu-ubuntu-2404-arm, ./docker/app.Dockerfile, linux-arm64-8-core, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Check Docs Changes (push) Has been cancelled
Publish CLI Package / publish-npm (push) Has been cancelled
Publish Python SDK / publish-pypi (push) Has been cancelled
CI / Deploy Trigger.dev (Dev) (push) Has been cancelled
Helm Chart / Lint, test, and validate chart (push) Has been cancelled
Helm Chart / Chart version bumped (push) Has been cancelled
Publish TypeScript SDK / publish-npm (push) Has been cancelled
CI / Build Dev ECR (blacksmith-8vcpu-ubuntu-2404, ./docker/app.Dockerfile, ECR_APP, linux-x64-8-core) (push) Has been cancelled
CI / Promote Images (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/cron) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Build Dev ECR (blacksmith-2vcpu-ubuntu-2404, ./docker/db.Dockerfile, ECR_MIGRATIONS, ubuntu-latest) (push) Has been cancelled
CI / Build Dev ECR (blacksmith-4vcpu-ubuntu-2404, ./docker/pii.Dockerfile, ECR_PII, ubuntu-latest) (push) Has been cancelled
CI / Build Dev ECR (blacksmith-4vcpu-ubuntu-2404, ./docker/realtime.Dockerfile, ECR_REALTIME, ubuntu-latest) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Process Docs (push) Has been cancelled
CI / Create GitHub Release (push) Has been cancelled
CI / Check Desktop Signing Secrets (push) Has been cancelled
CI / Desktop Release (push) Has been cancelled
CI / Create Desktop Prerelease (push) Has been cancelled
CI / Desktop Prerelease Build (push) Has been cancelled
CI / Publish Desktop Prerelease (push) Has been cancelled
CI / Prune Desktop Prereleases (push) Has been cancelled
Helm Chart / Install on kind and run helm test (push) Has been cancelled
1673 lines
57 KiB
TypeScript
1673 lines
57 KiB
TypeScript
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<ProviderId, ProviderMetadata> = {
|
|
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<void> {
|
|
const { updateOpenRouterModels } = await import('@/providers/models')
|
|
updateOpenRouterModels(models)
|
|
providers.openrouter.models = getProviderModelsFromDefinitions('openrouter')
|
|
}
|
|
|
|
export async function updateFireworksProviderModels(models: string[]): Promise<void> {
|
|
const { updateFireworksModels } = await import('@/providers/models')
|
|
updateFireworksModels(models)
|
|
providers.fireworks.models = getProviderModelsFromDefinitions('fireworks')
|
|
}
|
|
|
|
export async function updateOllamaCloudProviderModels(models: string[]): Promise<void> {
|
|
const { updateOllamaCloudModels } = await import('@/providers/models')
|
|
updateOllamaCloudModels(models)
|
|
providers['ollama-cloud'].models = getProviderModelsFromDefinitions('ollama-cloud')
|
|
}
|
|
|
|
export async function updateTogetherProviderModels(models: string[]): Promise<void> {
|
|
const { updateTogetherModels } = await import('@/providers/models')
|
|
updateTogetherModels(models)
|
|
providers.together.models = getProviderModelsFromDefinitions('together')
|
|
}
|
|
|
|
export async function updateBasetenProviderModels(models: string[]): Promise<void> {
|
|
const { updateBasetenModels } = await import('@/providers/models')
|
|
updateBasetenModels(models)
|
|
providers.baseten.models = getProviderModelsFromDefinitions('baseten')
|
|
}
|
|
|
|
export function getBaseModelProviders(): Record<string, ProviderId> {
|
|
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<string, ProviderId>
|
|
)
|
|
|
|
return filterBlacklistedModelsFromProviderMap(allProviders)
|
|
}
|
|
|
|
function filterBlacklistedModelsFromProviderMap(
|
|
providerMap: Record<string, ProviderId>
|
|
): Record<string, ProviderId> {
|
|
const filtered: Record<string, ProviderId> = {}
|
|
for (const [model, providerId] of Object.entries(providerMap)) {
|
|
if (isProviderBlacklisted(providerId)) {
|
|
continue
|
|
}
|
|
if (!isModelBlacklisted(model)) {
|
|
filtered[model] = providerId
|
|
}
|
|
}
|
|
return filtered
|
|
}
|
|
|
|
export function getAllModelProviders(): Record<string, ProviderId> {
|
|
return Object.entries(providers).reduce(
|
|
(map, [providerId, config]) => {
|
|
config.models.forEach((model) => {
|
|
map[model.toLowerCase()] = providerId as ProviderId
|
|
})
|
|
return map
|
|
},
|
|
{} as Record<string, ProviderId>
|
|
)
|
|
}
|
|
|
|
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<string, any>,
|
|
canonicalGroups: CanonicalGroup[],
|
|
scopedCanonicalModes?: CanonicalModeOverrides
|
|
): Record<string, any> {
|
|
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<string, any> = {}
|
|
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<string, any>
|
|
): Record<string, any> {
|
|
if (!blockedParams?.length) return llmArgs
|
|
const blocked = new Set(blockedParams)
|
|
const filtered: Record<string, any> = {}
|
|
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<any>
|
|
canonicalModes?: Record<string, 'basic' | 'advanced'>
|
|
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<CustomBlockToolBinding | null>
|
|
/**
|
|
* 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<ProviderToolConfig | null> {
|
|
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<string, unknown>
|
|
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<string, any>) => Record<string, any>)
|
|
| undefined
|
|
const blockInputDefs = blockDef?.inputs as Record<string, any> | undefined
|
|
|
|
const needsTransform = blockParamsFn || blockInputDefs || canonicalGroups.length > 0
|
|
const paramsTransform = needsTransform
|
|
? (params: Record<string, any>): Record<string, any> => {
|
|
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<string, unknown>): Record<string, unknown> {
|
|
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<string, unknown>)
|
|
result.required = propKeys
|
|
result.properties = Object.fromEntries(
|
|
Object.entries(result.properties as Record<string, unknown>).map(([key, value]) => [
|
|
key,
|
|
enforceStrictSchema(value as Record<string, unknown>),
|
|
])
|
|
)
|
|
}
|
|
}
|
|
|
|
if (result.type === 'array' && result.items) {
|
|
result.items = enforceStrictSchema(result.items as Record<string, unknown>)
|
|
}
|
|
|
|
for (const keyword of ['anyOf', 'oneOf', 'allOf']) {
|
|
if (Array.isArray(result[keyword])) {
|
|
result[keyword] = (result[keyword] as Record<string, unknown>[]).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<string, unknown>).map(([key, value]) => [
|
|
key,
|
|
enforceStrictSchema(value as Record<string, unknown>),
|
|
])
|
|
)
|
|
}
|
|
}
|
|
|
|
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<string, unknown>[]): number {
|
|
if (!toolResults?.length) return 0
|
|
let total = 0
|
|
for (const tr of toolResults) {
|
|
const cost = tr?.cost as Record<string, unknown> | 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<string, any>
|
|
parameters?: Record<string, any>
|
|
modelBlockedParams?: string[]
|
|
paramsTransform?: (params: Record<string, any>) => Record<string, any>
|
|
},
|
|
llmArgs: Record<string, any>,
|
|
request: {
|
|
workflowId?: string
|
|
workspaceId?: string
|
|
chatId?: string
|
|
userId?: string
|
|
environmentVariables?: Record<string, any>
|
|
workflowVariables?: Record<string, any>
|
|
blockData?: Record<string, any>
|
|
blockNameMapping?: Record<string, string>
|
|
isDeployedContext?: boolean
|
|
callChain?: string[]
|
|
billingAttribution?: BillingAttributionSnapshot
|
|
/** Invoking run's execution id — see `ProviderRequest.executionId`. */
|
|
executionId?: string
|
|
}
|
|
): {
|
|
toolParams: Record<string, any>
|
|
executionParams: Record<string, any>
|
|
} {
|
|
// 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<string, any>
|
|
const inputProvenance = getProviderToolInputProvenance(tool)
|
|
const inputRegistry = inputProvenance?.registry.forkForInputPaths([inputProvenance.sourcePath])
|
|
let projectedToolParams = inputProvenance
|
|
? (mergeToolParameters(inputProvenance.projectedParams, modelParams) as Record<string, any>)
|
|
: 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 }
|
|
}
|