feat(model): sim auto model (#6103)

* Add sim-auto: automatic model routing for the agent block (hosted)

- 'Auto' model option (Sim wordmark icon, hosted-only, new-block default;
  runtime fallback for unset models stays claude-sonnet-5)
- Resolver (lib/model-router): classifies each execution via mothership's
  /api/model-router and routes over two ladders — text: fireworks/glm-5.2 ->
  fireworks/kimi-k3 (new static hosted Fireworks catalog entries on the
  platform FIREWORKS_API_KEY); attachments: claude-haiku-4-5 -> gpt-5.5
  (Fireworks OSS endpoints reject images). Trivial tasks skip the router;
  5-min decision cache; 2s timeout; never fails the workflow
- Hidden identity preamble on every auto run (English by default, don't
  volunteer the underlying model)
- Fireworks executor: wire-name map for catalog ids (glm-5.2 -> glm-5p2),
  pricing keyed on the full catalog id
- Billing: routing cost applied to non-streaming output cost only when
  mothership marks the call billable (bill-model-router flag, default off)
- sim-auto special-cases: API-key condition, serialization tool lookup,
  edit-workflow validation (hosted), VFS model options projection

* auto model

* agent auto model

* fix lint
This commit is contained in:
Siddharth Ganesan
2026-07-30 12:55:54 -07:00
committed by GitHub
parent 66478087f7
commit aeb7eae3f8
28 changed files with 1391 additions and 33 deletions
@@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" width="1024" height="1024" viewBox="0 0 1024 1024">
<rect x="88" y="88" width="848" height="848" rx="192" fill="none" stroke="#f97316" stroke-width="24"/>
</svg>

After

Width:  |  Height:  |  Size: 204 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

@@ -0,0 +1,33 @@
{
"fill": {
"solid": "srgb:1.00000,1.00000,1.00000,1.00000"
},
"groups": [
{
"layers": [
{
"image-name": "logo.png",
"is-glass": false,
"name": "Sim"
},
{
"image-name": "border.svg",
"is-glass": false,
"name": "Dev Border"
}
],
"shadow": {
"kind": "neutral",
"opacity": 0
},
"specular": false,
"translucency": {
"enabled": false,
"value": 0
}
}
],
"supported-platforms": {
"squares": ["macOS"]
}
}
+5 -1
View File
@@ -63,7 +63,11 @@ API_ENCRYPTION_KEY=your_api_encryption_key # Use `openssl rand -hex 32` to gener
# VLLM_API_KEY= # Optional bearer token if your vLLM instance requires auth
# LITELLM_BASE_URL=http://localhost:4000 # Base URL for your LiteLLM proxy (OpenAI-compatible)
# LITELLM_API_KEY= # Optional bearer token if your LiteLLM proxy requires auth
# FIREWORKS_API_KEY= # Optional Fireworks AI API key for model listing
# NEXT_PUBLIC_FORCE_HOSTED=true # Dev only: treat this instance as hosted Sim (sim-auto pool, platform keys); ignored in production builds
# FIREWORKS_API_KEY= # Optional Fireworks AI API key for model listing and inference
# FIREWORKS_API_KEY_1= # Optional Fireworks API key for rotation (hosted deployments)
# FIREWORKS_API_KEY_2= # Additional Fireworks API key for load balancing
# FIREWORKS_API_KEY_3= # Additional Fireworks API key for load balancing
# NEXT_PUBLIC_BEDROCK_DEFAULT_CREDENTIALS=true # Set when using AWS default credential chain (IAM roles, ECS task roles, IRSA). Hides credential fields in Agent block UI.
# AZURE_OPENAI_ENDPOINT= # Azure OpenAI endpoint (hides field in UI when set alongside NEXT_PUBLIC_AZURE_CONFIGURED)
# AZURE_OPENAI_API_KEY= # Azure OpenAI API key
@@ -10,6 +10,7 @@ import { validationErrorResponse } from '@/lib/api/server'
import { getBYOKKey } from '@/lib/api-key/byok'
import { getSession } from '@/lib/auth'
import { env } from '@/lib/core/config/env'
import { isHosted } from '@/lib/core/config/env-flags'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils'
import { filterBlacklistedModels, isProviderBlacklisted } from '@/providers/utils'
@@ -54,7 +55,13 @@ export const GET = withRouteHandler(async (request: NextRequest) => {
}
}
if (!apiKey) {
/**
* On hosted Sim the platform key is the sim-auto pool's inference key, not a
* workspace credential: enumerating the whole Fireworks catalog from it would
* offer every serverless model as selectable when no key can actually run it
* (`getApiKeyWithBYOK` serves the platform key to catalog models only).
*/
if (!apiKey && !isHosted) {
apiKey = env.FIREWORKS_API_KEY
}
+8 -1
View File
@@ -20,6 +20,7 @@ import {
getReasoningEffortValuesForModel,
getThinkingLevelsForModel,
getVerbosityValuesForModel,
isAutoModel,
supportsTemperature,
} from '@/providers/models'
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
@@ -522,7 +523,13 @@ Return ONLY the JSON array.`,
if (!model) {
throw new Error('No model selected')
}
const tool = getBaseModelProviders()[model]
// sim-auto resolves to a concrete pool model at execution time, where
// the agent handler derives the provider from the resolved model and
// never reads this serialized value. Serialization still needs the
// same provider-id shape every other model stores, so look up the
// runtime fallback model's provider.
const lookupModel = isAutoModel(model) ? 'claude-sonnet-5' : model
const tool = getBaseModelProviders()[lookupModel]
if (!tool) {
throw new Error(`Invalid model selected: ${model}`)
}
+2
View File
@@ -40,6 +40,8 @@ vi.mock('@/providers/models', () => ({
getProviderModels: mockGetProviderModels,
getProviderIcon: mockGetProviderIcon,
getBaseModelProviders: mockGetBaseModelProviders,
SIM_AUTO_MODEL_ID: 'sim-auto',
isAutoModel: (model: string) => model.trim().toLowerCase() === 'sim-auto',
}))
vi.mock('@/providers/utils', () => ({
+18 -1
View File
@@ -1,4 +1,5 @@
import { toError } from '@sim/utils/errors'
import { SimAutoIcon } from '@/components/icons'
import {
isAzureConfigured,
isCohereConfigured,
@@ -14,7 +15,9 @@ import {
getModelSunsetStatus,
getProviderIcon,
getProviderModels,
isAutoModel,
orderModelIdsByReleaseDate,
SIM_AUTO_MODEL_ID,
} from '@/providers/models'
import { isPiSupportedModel } from '@/providers/pi-providers'
import { getProviderFromModel } from '@/providers/utils'
@@ -75,12 +78,21 @@ export function getModelOptions() {
])
)
return allModels
const options = allModels
.filter((model) => getModelSunsetStatus(model) !== 'deprecated')
.map((model) => {
const icon = getProviderIcon(model)
return { label: model, id: model, ...(icon && { icon }) }
})
// Hosted-only automatic model. Deliberately LAST in the list (limited
// visibility for the initial release): available to anyone who scrolls or
// searches for it, but never the first thing the dropdown offers.
if (isHosted) {
options.push({ label: 'Auto', id: SIM_AUTO_MODEL_ID, icon: SimAutoIcon })
}
return options
}
/**
@@ -183,6 +195,11 @@ function shouldRequireApiKeyForModel(model: string): boolean {
const normalizedModel = model.trim().toLowerCase()
if (!normalizedModel) return false
// On hosted Sim the auto pseudo-model resolves server-side to a hosted pool
// model. On self-hosted it exists only via imported workflows and always
// falls back to the default Anthropic model, so the key field must show.
if (isAutoModel(normalizedModel)) return !isHosted
if (isHosted) {
const hostedModels = getHostedModels()
if (hostedModels.some((m) => m.toLowerCase() === normalizedModel)) return false
+25
View File
@@ -7558,6 +7558,31 @@ export function SixtyfourIcon(props: SVGProps<SVGSVGElement>) {
)
}
/**
* The "sim" brand wordmark (v1.0 brand guide simLogotype paths the same mark
* the navbar/login header renders), inked with the theme-adaptive
* `--text-body`. Used as the icon for the Auto model option; the wide viewBox
* letterboxes itself inside square icon slots.
*/
export function SimAutoIcon(props: SVGProps<SVGSVGElement>) {
return (
<svg
{...props}
viewBox='0 0 441 212'
fill='none'
xmlns='http://www.w3.org/2000/svg'
aria-hidden='true'
>
<g fill='var(--text-body)'>
<path d='M0 160.9H29.51C29.51 169.08 32.46 175.61 38.37 180.48C44.27 185.12 52.25 187.44 62.31 187.44C73.24 187.44 81.65 185.34 87.56 181.14C93.46 176.71 96.41 170.85 96.41 163.55C96.41 158.24 94.77 153.82 91.49 150.28C88.43 146.74 82.75 143.86 74.44 141.65L46.24 135.01C32.03 131.47 21.42 126.05 14.43 118.75C7.65 111.45 4.26 101.83 4.26 89.88C4.26 79.93 6.78 71.3 11.81 64C17.05 56.7 24.16 51.06 33.12 47.08C42.3 43.09 52.8 41.1 64.6 41.1C76.41 41.1 86.57 43.2 95.1 47.41C103.84 51.61 110.62 57.47 115.43 64.99C120.46 72.52 123.08 81.48 123.3 91.87H93.79C93.57 83.47 90.84 76.94 85.59 72.3C80.34 67.65 73.02 65.33 63.62 65.33C54 65.33 46.57 67.43 41.32 71.63C36.07 75.83 33.45 81.59 33.45 88.89C33.45 99.73 41.32 107.14 57.06 111.12L85.26 118.09C98.81 121.19 108.98 126.28 115.76 133.35C122.53 140.21 125.92 149.61 125.92 161.56C125.92 171.74 123.19 180.7 117.73 188.44C112.26 195.96 104.72 201.82 95.1 206.03C85.7 210.01 74.55 212 61.65 212C42.85 212 27.87 207.35 16.72 198.06C5.57 188.77 0 176.38 0 160.9Z' />
<path d='M232.8 212H202.13L202.13 49.76H229.54V77.39C232.8 68.34 239.11 60.66 247.81 54.7C256.73 48.52 267.5 45.43 280.12 45.43C294.26 45.43 306.01 49.29 315.36 57.02C324.72 64.75 330.81 75.01 333.64 87.82H328.09C330.27 75.01 336.25 64.75 346.04 57.02C355.83 49.29 367.9 45.43 382.26 45.43C400.54 45.43 414.89 50.84 425.34 61.66C435.78 72.47 441 87.26 441 106.03V212H410.98V113.65C410.98 100.84 407.71 91.02 401.19 84.17C394.88 77.11 386.29 73.58 375.41 73.58C367.79 73.58 361.05 75.34 355.17 78.88C349.52 82.19 345.06 87.04 341.8 93.45C338.53 99.85 336.9 107.36 336.9 115.97V212H306.55V113.32C306.55 100.51 303.4 90.8 297.09 84.17C290.78 77.33 282.19 73.91 271.31 73.91C263.69 73.91 256.95 75.67 251.08 79.21C245.42 82.52 240.96 87.38 237.7 93.78C234.43 99.96 232.8 107.36 232.8 115.97V212Z' />
<path d='M184.83 20.55C184.83 31.9 175.64 41.1 164.29 41.1C152.95 41.1 143.76 31.9 143.76 20.55C143.76 9.2 152.95 0 164.29 0C175.64 0 184.83 9.2 184.83 20.55Z' />
<path d='M179.43 212H149.16V49.76C153.76 51.91 158.88 53.12 164.29 53.12C169.7 53.12 174.83 51.91 179.43 49.76V212Z' />
</g>
</svg>
)
}
export function SimTriggerIcon(props: SVGProps<SVGSVGElement>) {
return (
<svg
@@ -17,10 +17,12 @@ import {
vi,
} from 'vitest'
import { getAllBlocks } from '@/blocks'
import { BlockType, isMcpTool } from '@/executor/constants'
import { AGENT, BlockType, isMcpTool } from '@/executor/constants'
import { AgentBlockHandler } from '@/executor/handlers/agent/agent-handler'
import type { ExecutionContext, StreamingExecution } from '@/executor/types'
import { executeProviderRequest } from '@/providers'
import { installStreamingCostPolicy } from '@/providers/cost-policy'
import { SIM_AUTO_MODEL_ID } from '@/providers/models'
import { getProviderFromModel, transformBlockTool } from '@/providers/utils'
import type { SerializedBlock, SerializedWorkflow } from '@/serializer/types'
import { executeTool } from '@/tools'
@@ -275,6 +277,127 @@ describe('AgentBlockHandler', () => {
expect(result).toEqual(expectedOutput)
})
it('reports a sim-auto run under the sim-auto identity, not the model that served it', async () => {
mockExecuteProviderRequest.mockResolvedValue({
content: 'Mocked response content',
model: AGENT.DEFAULT_MODEL,
tokens: { input: 10, output: 20, total: 30 },
toolCalls: [],
cost: { input: 0.001, output: 0.002, total: 0.003 },
timing: {
total: 100,
timeSegments: [
{ type: 'model', name: AGENT.DEFAULT_MODEL, provider: 'anthropic', duration: 100 },
],
},
})
const result = (await handler.execute(mockContext, mockBlock, {
model: SIM_AUTO_MODEL_ID,
userPrompt: 'Hello!',
})) as {
model: string
cost: unknown
tokens: unknown
providerTiming: { timeSegments: Array<{ name?: string; provider?: string }> }
}
expect(result.model).toBe(SIM_AUTO_MODEL_ID)
expect(result.providerTiming.timeSegments[0].name).toBe(SIM_AUTO_MODEL_ID)
expect(result.providerTiming.timeSegments[0].provider).toBeUndefined()
// Only the label changes: tokens and the already-priced cost are untouched.
expect(result.tokens).toEqual({ input: 10, output: 20, total: 30 })
expect(result.cost).toEqual({ input: 0.001, output: 0.002, total: 0.003 })
})
/** Reaches the private signal builder; routing depends on nothing else. */
const buildAutoRoutingSignalsFor = (inputs: Record<string, unknown>) =>
(
handler as unknown as {
buildAutoRoutingSignals: (i: unknown, rf: unknown) => { mediaKind: string }
}
).buildAutoRoutingSignals(inputs, undefined)
const png = { id: 'f1', type: 'image/png' }
const pdf = { id: 'f2', type: 'application/pdf' }
it('reports no media when neither the files input nor any message carries one', async () => {
const signals = buildAutoRoutingSignalsFor({
messages: [{ role: 'user' as const, content: 'Summarize this text' }],
})
expect(signals.mediaKind).toBe('none')
})
it('detects media carried on inbound messages, not just the files input', async () => {
const signals = buildAutoRoutingSignalsFor({
messages: [{ role: 'user' as const, content: 'What is in this image?', files: [png] }],
})
expect(signals.mediaKind).toBe('image')
})
it('classifies an all-image attachment set as image', async () => {
expect(buildAutoRoutingSignalsFor({ files: [png, png] }).mediaKind).toBe('image')
})
it('classifies a mixed image + document set as file', async () => {
expect(buildAutoRoutingSignalsFor({ files: [png, pdf] }).mediaKind).toBe('file')
})
it('treats an unknown MIME type as file rather than assuming it is an image', async () => {
expect(buildAutoRoutingSignalsFor({ files: [{ id: 'f3' }] }).mediaKind).toBe('file')
})
it('overlays the routing charge on a streaming cost written after the fact', async () => {
// Mirrors the real streaming shape: the policy accessor is installed at
// provider-return time, the drain writes the final cost long after the
// handler returned, and consumers read it at log time.
const output: Record<string, unknown> = { cost: { input: 0, output: 0, total: 0 } }
installStreamingCostPolicy(output as never, { billable: true, multiplier: 1 })
const streaming = { stream: new ReadableStream(), execution: { output } }
;(
handler as unknown as { applyRoutingCost: (r: unknown, c: number) => void }
).applyRoutingCost(streaming, 0.002)
// The drain settles the model cost afterwards.
;(output as { cost: unknown }).cost = { input: 0.01, output: 0.02, total: 0.03 }
expect(output.cost).toEqual({
input: 0.01,
output: 0.02,
total: expect.closeTo(0.032, 10),
routing: 0.002,
})
})
it('adds the routing charge to a settled non-streaming cost', async () => {
const result: Record<string, unknown> = { cost: { input: 0.01, output: 0.02, total: 0.03 } }
;(
handler as unknown as { applyRoutingCost: (r: unknown, c: number) => void }
).applyRoutingCost(result, 0.002)
expect(result.cost).toEqual({
input: 0.01,
output: 0.02,
total: expect.closeTo(0.032, 10),
routing: 0.002,
})
})
it('leaves the reported model alone for an explicitly selected model', async () => {
const result = (await handler.execute(mockContext, mockBlock, {
model: 'gpt-4o',
userPrompt: 'Hello!',
apiKey: 'test-api-key',
})) as { model: string }
expect(result.model).toBe('mock-model')
})
it('should attach files to the last user message only', async () => {
const inputs = {
model: 'gpt-4o',
@@ -7,7 +7,18 @@ import { truncate } from '@sim/utils/string'
import { and, eq, inArray, isNull } from 'drizzle-orm'
import { normalizeStringRecord, normalizeWorkflowVariables } from '@/lib/core/utils/records'
import { createMcpToolId } from '@/lib/mcp/utils'
import { processFilesToUserFiles, type RawFileInput } from '@/lib/uploads/utils/file-utils'
import {
type AutoMediaKind,
type AutoRoutingResult,
type AutoRoutingSignals,
resolveAutoModel,
SIM_AUTO_SYSTEM_PREAMBLE,
} from '@/lib/model-router/resolve'
import {
MODEL_SUPPORTED_IMAGE_MIME_TYPES,
processFilesToUserFiles,
type RawFileInput,
} from '@/lib/uploads/utils/file-utils'
import { hydrateUserFilesWithBase64 } from '@/lib/uploads/utils/user-file-base64.server'
import { resolveCustomBlockToolBinding } from '@/lib/workflows/custom-blocks/operations'
import { getCustomToolById } from '@/lib/workflows/custom-tools/operations'
@@ -46,6 +57,7 @@ import {
shouldUseLargeFilePath,
supportsFileAttachments,
} from '@/providers/attachments'
import { isAutoModel, SIM_AUTO_MODEL_ID } from '@/providers/models'
import { getProviderFromModel, transformBlockTool } from '@/providers/utils'
import type { SerializedBlock } from '@/serializer/types'
import { filterSchemaForLLM, type ToolSchema } from '@/tools/params'
@@ -77,7 +89,32 @@ export class AgentBlockHandler implements BlockHandler {
await this.validateToolPermissions(ctx, filteredInputs.tools || [])
const responseFormat = parseResponseFormat(filteredInputs.responseFormat)
const model = filteredInputs.model || AGENT.DEFAULT_MODEL
const configuredModel = filteredInputs.model || AGENT.DEFAULT_MODEL
let model = configuredModel
let autoRouting: AutoRoutingResult | null = null
if (isAutoModel(configuredModel)) {
autoRouting = await resolveAutoModel({
ctx,
blockId: block.id,
signals: this.buildAutoRoutingSignals(filteredInputs, responseFormat),
fallbackModel: AGENT.DEFAULT_MODEL,
})
model = autoRouting.model
logger.info('Resolved sim-auto model', {
blockId: block.id,
model,
tier: autoRouting.tier,
decidedBy: autoRouting.decidedBy,
})
// Hidden identity preamble for every auto execution (fallback included):
// keeps pool models in English by default and off the topic of which
// underlying model they are. Applied after signal building so the
// preamble never influences classification.
filteredInputs.systemPrompt = [SIM_AUTO_SYSTEM_PREAMBLE, filteredInputs.systemPrompt]
.filter(Boolean)
.join('\n\n')
}
await validateModelProvider(ctx.userId, ctx.workspaceId, model, ctx)
@@ -126,6 +163,14 @@ export class AgentBlockHandler implements BlockHandler {
const result = await this.executeProviderRequest(ctx, providerRequest, block, responseFormat)
if (autoRouting && autoRouting.billableRoutingCost > 0) {
this.applyRoutingCost(result, autoRouting.billableRoutingCost)
}
if (autoRouting) {
this.applyAutoModelLabel(result, model)
}
if (this.isStreamingExecution(result)) {
if (filteredInputs.memoryType && filteredInputs.memoryType !== 'none') {
return this.wrapStreamForMemoryPersistence(
@@ -144,6 +189,142 @@ export class AgentBlockHandler implements BlockHandler {
return result
}
/**
* Derives the compact routing signals for sim-auto resolution from the
* block's resolved inputs. Excerpts only — the resolver truncates further
* and mothership re-clamps server-side.
*/
private buildAutoRoutingSignals(inputs: AgentInputs, responseFormat: any): AutoRoutingSignals {
const lastMessage =
typeof inputs.userPrompt === 'string'
? inputs.userPrompt
: inputs.userPrompt != null
? stringifyJSON(inputs.userPrompt)
: (inputs.messages?.at(-1)?.content ?? '')
const systemPrompt = inputs.systemPrompt ?? ''
const normalizedFiles = normalizeFileInput(inputs.files)
const approxChars =
systemPrompt.length +
lastMessage.length +
(inputs.messages ? stringifyJSON(inputs.messages).length : 0)
return {
systemPrompt,
lastMessage,
messageCount: (inputs.messages?.length ?? 0) + (inputs.userPrompt ? 1 : 0),
toolNames: (inputs.tools ?? []).map((t) => t.title || t.type || 'tool'),
mediaKind: this.resolveMediaKind(inputs, normalizedFiles),
hasResponseFormat: Boolean(responseFormat),
approxInputTokens: Math.ceil(approxChars / 4),
}
}
/**
* Classifies what the block attaches, which decides the sim-auto pool column.
*
* Media reaches the provider by two routes — the block's `files` input and
* files already carried on inbound messages (chat deployments, memory, an
* upstream block feeding `messages`) — and both count. A file whose MIME type
* is missing or unrecognized counts as `file`, the column served by the
* providers that accept the most input types, because the alternative is
* handing a document to a model whose API models no such content part.
*/
private resolveMediaKind(inputs: AgentInputs, normalizedFiles: unknown): AutoMediaKind {
const attached: Array<{ type?: string }> = [
...(Array.isArray(normalizedFiles) ? (normalizedFiles as Array<{ type?: string }>) : []),
...(inputs.messages ?? []).flatMap((message) => message.files ?? []),
]
if (attached.length === 0) return 'none'
return attached.every((file) =>
MODEL_SUPPORTED_IMAGE_MIME_TYPES.has((file.type ?? '').toLowerCase())
)
? 'image'
: 'file'
}
/**
* Reports a completed auto run under the `sim-auto` identity everywhere the
* run is observed — the block output, the trace span, and the usage-ledger
* row keyed on the model name — so the pool model that served the request
* stays an implementation detail (matching the block's configured model and
* the hidden identity preamble the models themselves run under).
*
* Runs after `executeProviderRequest`, whose billability gate and pricing
* key on the concrete pool model: tokens and cost are already settled here,
* only the label changes.
*/
private applyAutoModelLabel(
result: BlockOutput | StreamingExecution,
resolvedModel: string
): void {
const output = this.isStreamingExecution(result)
? (result as StreamingExecution).execution?.output
: (result as BlockOutput)
if (!output || typeof output !== 'object') return
const target = output as {
model?: string
providerTiming?: {
timeSegments?: Array<{ type?: string; name?: string; provider?: string }>
}
}
target.model = SIM_AUTO_MODEL_ID
// Model segments name themselves after the model (every provider does) and
// carry the serving provider, which the log detail renders as that
// provider's icon — the same leak by two other routes.
for (const segment of target.providerTiming?.timeSegments ?? []) {
if (segment.type !== 'model') continue
if (segment.name?.toLowerCase() === resolvedModel.toLowerCase()) {
segment.name = SIM_AUTO_MODEL_ID
}
segment.provider = undefined
}
}
/**
* Adds the billable sim-auto routing charge to the output's cost breakdown
* as a distinct `routing` component.
*
* A non-streaming cost is final, so it is mutated in place. A streaming
* output's cost settles at stream end — written through the accessor
* `installStreamingCostPolicy` installed — so the charge is layered as a
* read-time overlay on the property instead: whatever the drain writes,
* every later read (trace spans, cost summary, usage ledger) sees the
* routing component on top.
*/
private applyRoutingCost(result: BlockOutput | StreamingExecution, routingCost: number): void {
const output = this.isStreamingExecution(result)
? (result as StreamingExecution).execution?.output
: (result as BlockOutput)
if (!output || typeof output !== 'object') return
const target = output as { cost?: Record<string, number> }
const withRouting = (cost: Record<string, number> | undefined): Record<string, number> =>
cost && typeof cost.total === 'number'
? { ...cost, routing: routingCost, total: cost.total + routingCost }
: { input: 0, output: 0, routing: routingCost, total: routingCost }
if (!this.isStreamingExecution(result)) {
target.cost = withRouting(target.cost)
return
}
const prior = Object.getOwnPropertyDescriptor(target, 'cost')
let raw = prior?.get ? undefined : (target.cost as Record<string, number> | undefined)
Object.defineProperty(target, 'cost', {
get: () => withRouting(prior?.get ? (prior.get.call(target) as never) : raw),
set: (value: Record<string, number> | undefined) => {
if (prior?.set) prior.set.call(target, value)
else raw = value
},
configurable: true,
enumerable: true,
})
}
private async validateToolPermissions(ctx: ExecutionContext, tools: ToolInput[]): Promise<void> {
if (!Array.isArray(tools) || tools.length === 0) return
+88 -4
View File
@@ -13,11 +13,24 @@ vi.mock('@/lib/core/security/encryption', () => ({
}))
vi.mock('@/lib/core/config/api-keys', () => ({
getRotatingApiKey: vi.fn(),
getRotatingApiKey: mockGetRotatingApiKey,
}))
const { mockEnv, mockGetRotatingApiKey, mockGetHostedModels, mockIsHosted } = vi.hoisted(() => ({
mockEnv: {} as Record<string, string | undefined>,
mockGetRotatingApiKey: vi.fn(),
mockGetHostedModels: vi.fn(() => [] as string[]),
mockIsHosted: { value: true },
}))
vi.mock('@/lib/core/config/env', () => ({
env: {},
env: mockEnv,
}))
vi.mock('@/lib/core/config/env-flags', () => ({
get isHosted() {
return mockIsHosted.value
},
}))
vi.mock('@/providers/models', () => ({
@@ -25,7 +38,7 @@ vi.mock('@/providers/models', () => ({
.fn()
.mockReturnValue({ maxBytes: 10 * 1024 * 1024, strategy: 'inline' }),
INLINE_ATTACHMENT_MAX_BYTES: 10 * 1024 * 1024,
getHostedModels: vi.fn(() => []),
getHostedModels: mockGetHostedModels,
}))
vi.mock('@/providers/utils', () => ({
@@ -36,7 +49,8 @@ vi.mock('@/stores/providers/store', () => ({
useProvidersStore: { getState: vi.fn() },
}))
import { getBYOKKey } from '@/lib/api-key/byok'
import { getApiKeyWithBYOK, getBYOKKey } from '@/lib/api-key/byok'
import { useProvidersStore } from '@/stores/providers/store'
/**
* Rotation counters in the module under test are keyed by
@@ -152,3 +166,73 @@ describe('getBYOKKey', () => {
expect(await getBYOKKey(uniqueWorkspaceId(), 'openai')).toBeNull()
})
})
describe('getApiKeyWithBYOK for Fireworks', () => {
const HOSTED_POOL_MODEL = 'fireworks/glm-5.2'
beforeEach(() => {
vi.clearAllMocks()
resetDbChainMock()
mockIsHosted.value = true
mockEnv.FIREWORKS_API_KEY = 'platform-fireworks-key'
mockGetHostedModels.mockReturnValue([HOSTED_POOL_MODEL, 'fireworks/kimi-k3'])
mockGetRotatingApiKey.mockReturnValue('rotated-fireworks-key')
;(useProvidersStore.getState as ReturnType<typeof vi.fn>).mockReturnValue({
providers: {
ollama: { models: [] },
vllm: { models: [] },
litellm: { models: [] },
fireworks: { models: [] },
together: { models: [] },
baseten: { models: [] },
'ollama-cloud': { models: [] },
},
})
})
it('serves the rotating platform key for a hosted catalog model', async () => {
const result = await getApiKeyWithBYOK('fireworks', HOSTED_POOL_MODEL, uniqueWorkspaceId())
expect(mockGetRotatingApiKey).toHaveBeenCalledWith('fireworks')
expect(result).toEqual({ apiKey: 'rotated-fireworks-key', isBYOK: false })
})
it('prefers a workspace BYOK key over the platform key, as hosted models do', async () => {
dbChainMockFns.orderBy.mockResolvedValue([storedKey('key-1')])
const result = await getApiKeyWithBYOK('fireworks', HOSTED_POOL_MODEL, uniqueWorkspaceId())
expect(result).toEqual({ apiKey: 'decrypted-key-1', isBYOK: true })
expect(mockGetRotatingApiKey).not.toHaveBeenCalled()
})
it('never serves the platform key to a dynamic model on hosted', async () => {
await expect(
getApiKeyWithBYOK('fireworks', 'fireworks/accounts/acme/models/custom', uniqueWorkspaceId())
).rejects.toThrow('API key is required for Fireworks')
expect(mockGetRotatingApiKey).not.toHaveBeenCalled()
})
it('serves a user-provided key for a dynamic model on hosted', async () => {
const result = await getApiKeyWithBYOK(
'fireworks',
'fireworks/accounts/acme/models/custom',
uniqueWorkspaceId(),
'user-key'
)
expect(result).toEqual({ apiKey: 'user-key', isBYOK: false })
})
it('falls back to the env key for any model when self-hosted', async () => {
mockIsHosted.value = false
const result = await getApiKeyWithBYOK(
'fireworks',
'fireworks/accounts/acme/models/custom',
uniqueWorkspaceId()
)
expect(result).toEqual({ apiKey: 'platform-fireworks-key', isBYOK: false })
})
})
+29
View File
@@ -122,6 +122,35 @@ export async function getApiKeyWithBYOK(
return byokResult
}
}
/**
* On hosted Sim the platform Fireworks key backs the static catalog (the
* sim-auto pool) and nothing else, exactly as the platform Anthropic and
* OpenAI keys back only their catalogued models. A dynamic `fireworks/*`
* id a workspace configured itself carries no catalog pricing, so
* `shouldBillModelUsage` would return false for it — serving it on Sim's
* key would be unmetered inference.
*/
if (isHosted) {
const isModelHosted = getHostedModels().some((m) => m.toLowerCase() === model.toLowerCase())
if (isModelHosted) {
try {
const serverKey = getRotatingApiKey('fireworks')
return { apiKey: serverKey, isBYOK: false }
} catch (_error) {
if (userProvidedKey) {
return { apiKey: userProvidedKey, isBYOK: false }
}
throw new Error(`No API key available for fireworks ${model}`)
}
}
if (userProvidedKey) {
return { apiKey: userProvidedKey, isBYOK: false }
}
throw new Error(`API key is required for Fireworks ${model}`)
}
if (userProvidedKey) {
return { apiKey: userProvidedKey, isBYOK: false }
}
@@ -2,6 +2,7 @@ import { createLogger } from '@sim/logger'
import { toError } from '@sim/utils/errors'
import { omit } from '@sim/utils/object'
import { validateSelectorIds } from '@/lib/copilot/validation/selector-validator'
import { isHosted as isHostedDeployment } from '@/lib/core/config/env-flags'
import { isBlockTypeAccessControlExempt } from '@/lib/permission-groups/block-access'
import type { PermissionGroupConfig } from '@/lib/permission-groups/types'
import { getCustomToolById } from '@/lib/workflows/custom-tools/operations'
@@ -16,7 +17,7 @@ import { getBlock } from '@/blocks/registry'
import type { SubBlockConfig } from '@/blocks/types'
import { getModelOptions } from '@/blocks/utils'
import { BlockType, EDGE, normalizeName } from '@/executor/constants'
import { isKnownModelId, suggestModelIdsForUnknownModel } from '@/providers/models'
import { isAutoModel, isKnownModelId, suggestModelIdsForUnknownModel } from '@/providers/models'
import { isPiByokOnlyMode } from '@/providers/pi-providers'
import { getTool } from '@/tools/utils'
import { TRIGGER_RUNTIME_SUBBLOCK_IDS, TRIGGER_WEBHOOK_URL_FIELD } from '@/triggers/constants'
@@ -557,6 +558,12 @@ export function validateValueForSubBlockType(
if (usesProviderCatalog) {
const stringValue = typeof value === 'string' ? value : String(value)
const trimmed = stringValue.trim()
// sim-auto is a valid model value on hosted Sim only (mirrors the
// options array the agent reads: it is absent from self-hosted
// snapshots, so writes of it there are rejected as unknown).
if (trimmed !== '' && isAutoModel(trimmed) && isHostedDeployment) {
return { valid: true, value: trimmed.toLowerCase() }
}
if (trimmed !== '' && !isKnownModelId(trimmed)) {
const suggestions = suggestModelIdsForUnknownModel(trimmed)
const suggestionText =
+16 -1
View File
@@ -12,7 +12,11 @@ import { getBlock } from '@/blocks'
import { isCustomBlockType } from '@/blocks/custom/build-config'
import type { BlockConfig, SubBlockConfig } from '@/blocks/types'
import { isHiddenUnder } from '@/blocks/visibility/context'
import { DYNAMIC_MODEL_PROVIDERS, PROVIDER_DEFINITIONS } from '@/providers/models'
import {
DYNAMIC_MODEL_PROVIDERS,
PROVIDER_DEFINITIONS,
SIM_AUTO_MODEL_ID,
} from '@/providers/models'
import type { ToolConfig, ToolHostingCondition } from '@/tools/types'
/** The service-account alternative to OAuth for a service, when it offers one. */
@@ -500,6 +504,17 @@ function getStaticModelOptionsForVFS(): StaticModelOption[] {
const models: StaticModelOption[] = []
// Hosted-only automatic model. Deliberately not `recommended` and given no
// prompt guidance (limited-visibility release): the build agent can write it
// when a user explicitly asks for the auto model, but is never steered to it.
if (isHosted) {
models.push({
id: SIM_AUTO_MODEL_ID,
provider: 'sim',
hosted: true,
})
}
for (const [providerId, def] of Object.entries(PROVIDER_DEFINITIONS)) {
if (dynamicProviders.has(providerId)) continue
for (const model of def.models) {
+9 -1
View File
@@ -14,7 +14,8 @@ export function getRotatingApiKey(provider: string): string {
provider !== 'cohere' &&
provider !== 'zai' &&
provider !== 'xai' &&
provider !== 'kimi'
provider !== 'kimi' &&
provider !== 'fireworks'
) {
throw new Error(`No rotation implemented for provider: ${provider}`)
}
@@ -49,6 +50,13 @@ export function getRotatingApiKey(provider: string): string {
if (env.KIMI_API_KEY_1) keys.push(env.KIMI_API_KEY_1)
if (env.KIMI_API_KEY_2) keys.push(env.KIMI_API_KEY_2)
if (env.KIMI_API_KEY_3) keys.push(env.KIMI_API_KEY_3)
} else if (provider === 'fireworks') {
if (env.FIREWORKS_API_KEY_1) keys.push(env.FIREWORKS_API_KEY_1)
if (env.FIREWORKS_API_KEY_2) keys.push(env.FIREWORKS_API_KEY_2)
if (env.FIREWORKS_API_KEY_3) keys.push(env.FIREWORKS_API_KEY_3)
// The platform Fireworks key predates the rotation slots and ships as a
// single secret; it stands in as a one-key pool until slots are populated.
if (keys.length === 0 && env.FIREWORKS_API_KEY) keys.push(env.FIREWORKS_API_KEY)
}
if (keys.length === 0) {
+10 -1
View File
@@ -34,7 +34,16 @@ try {
} catch {
// invalid URL — isHosted stays false
}
export const isHosted = appHostname === 'sim.ai' || appHostname.endsWith('.sim.ai')
/**
* Local-development escape hatch for exercising hosted-only paths (the sim-auto
* pool, platform keys, hosted-only UI) without pointing `NEXT_PUBLIC_APP_URL` at
* a sim.ai hostname, which would break local callback URLs. Ignored in
* production builds, so a self-hosted deployment can never claim to be Sim's
* hosted environment.
*/
const forceHosted = !isProd && isTruthy(getEnv('NEXT_PUBLIC_FORCE_HOSTED'))
export const isHosted = forceHosted || appHostname === 'sim.ai' || appHostname.endsWith('.sim.ai')
/**
* Enables the strict attributed-v1 Sim/Copilot billing protocol after the Go
+4 -1
View File
@@ -173,7 +173,10 @@ export const env = createEnv({
VLLM_API_KEY: z.string().optional(), // Optional bearer token for vLLM
LITELLM_BASE_URL: z.string().url().optional(), // LiteLLM proxy base URL (OpenAI-compatible)
LITELLM_API_KEY: z.string().optional(), // Optional bearer token for LiteLLM
FIREWORKS_API_KEY: z.string().optional(), // Optional Fireworks AI API key for model listing
FIREWORKS_API_KEY: z.string().optional(), // Platform Fireworks AI key backing the hosted sim-auto pool (and self-hosted model listing/inference)
FIREWORKS_API_KEY_1: z.string().min(1).optional(), // Primary Fireworks API key for load balancing
FIREWORKS_API_KEY_2: z.string().min(1).optional(), // Additional Fireworks API key for load balancing
FIREWORKS_API_KEY_3: z.string().min(1).optional(), // Additional Fireworks API key for load balancing
TOGETHER_API_KEY: z.string().optional(), // Optional Together AI API key for model listing and inference
BASETEN_API_KEY: z.string().optional(), // Optional Baseten API key for model listing and inference
COHERE_API_KEY: z.string().min(1).optional(), // Cohere API key for reranker (rerank-v4.0-pro, rerank-v4.0-fast, rerank-v3.5)
+26
View File
@@ -24,6 +24,9 @@ beforeAll(() => {
XAI_API_KEY_1: 'test-xai-key-1',
XAI_API_KEY_2: 'test-xai-key-2',
XAI_API_KEY_3: 'test-xai-key-3',
FIREWORKS_API_KEY_1: 'test-fireworks-key-1',
FIREWORKS_API_KEY_2: 'test-fireworks-key-2',
FIREWORKS_API_KEY_3: 'test-fireworks-key-3',
})
})
@@ -338,6 +341,29 @@ describe('getRotatingApiKey', () => {
expect(result).toMatch(/^test-xai-key-[1-3]$/)
})
it.concurrent('should return Fireworks API key based on current minute', () => {
const result = getRotatingApiKey('fireworks')
expect(result).toMatch(/^test-fireworks-key-[1-3]$/)
})
it('falls back to the single platform Fireworks key when no rotation slot is set', () => {
setEnv({
FIREWORKS_API_KEY_1: undefined,
FIREWORKS_API_KEY_2: undefined,
FIREWORKS_API_KEY_3: undefined,
FIREWORKS_API_KEY: 'test-fireworks-platform-key',
})
expect(getRotatingApiKey('fireworks')).toBe('test-fireworks-platform-key')
setEnv({
FIREWORKS_API_KEY: undefined,
FIREWORKS_API_KEY_1: 'test-fireworks-key-1',
FIREWORKS_API_KEY_2: 'test-fireworks-key-2',
FIREWORKS_API_KEY_3: 'test-fireworks-key-3',
})
})
it.concurrent('should throw error for unsupported provider', () => {
expect(() => getRotatingApiKey('unsupported')).toThrow('No rotation implemented for provider')
})
+288
View File
@@ -0,0 +1,288 @@
/**
* @vitest-environment node
*/
import { beforeEach, describe, expect, it, vi } from 'vitest'
const {
mockFetchGo,
mockValidateModelProvider,
mockGetMothershipBaseURL,
mockGetProviderFromModel,
} = vi.hoisted(() => ({
mockFetchGo: vi.fn(),
mockValidateModelProvider: vi.fn(),
mockGetMothershipBaseURL: vi.fn(),
mockGetProviderFromModel: vi.fn(),
}))
vi.mock('@/lib/core/config/env-flags', () => ({
isHosted: true,
getCostMultiplier: () => 2,
}))
vi.mock('@/lib/core/config/env', () => ({
env: { COPILOT_API_KEY: 'test-copilot-key' },
}))
vi.mock('@/lib/copilot/request/go/fetch', () => ({
fetchGo: mockFetchGo,
}))
vi.mock('@/lib/copilot/server/agent-url', () => ({
getMothershipBaseURL: mockGetMothershipBaseURL,
}))
vi.mock('@/ee/access-control/utils/permission-check', () => ({
validateModelProvider: mockValidateModelProvider,
}))
vi.mock('@/providers/utils', () => ({
getProviderFromModel: mockGetProviderFromModel,
}))
import { type AutoRoutingSignals, resolveAutoModel } from '@/lib/model-router/resolve'
import type { ExecutionContext } from '@/executor/types'
const ctx = {
userId: 'user-1',
workspaceId: 'ws-1',
workflowId: 'wf-1',
executionId: 'exec-1',
} as unknown as ExecutionContext
/** Distinct-by-default signals so the module-level decision cache never collides across tests. */
let signalSeq = 0
function makeSignals(overrides: Partial<AutoRoutingSignals> = {}): AutoRoutingSignals {
return {
systemPrompt: `analyze the quarterly report ${++signalSeq}`,
lastMessage: 'here is the data to reconcile against the ledger',
messageCount: 1,
toolNames: ['exa_search'],
mediaKind: 'none',
hasResponseFormat: false,
approxInputTokens: 5000,
...overrides,
}
}
function routerResponse(body: unknown, ok = true, status = 200) {
return { ok, status, json: () => Promise.resolve(body) }
}
describe('resolveAutoModel', () => {
beforeEach(() => {
vi.clearAllMocks()
mockGetMothershipBaseURL.mockResolvedValue('https://copilot.test')
mockValidateModelProvider.mockResolvedValue(undefined)
mockGetProviderFromModel.mockReturnValue('fireworks')
})
/** The full pool, exactly as specified: media kind → tier → model. */
const POOL_CASES: Array<{ mediaKind: AutoRoutingSignals['mediaKind']; byTier: string[] }> = [
{ mediaKind: 'none', byTier: ['fireworks/glm-5.2', 'fireworks/glm-5.2', 'fireworks/kimi-k3'] },
{ mediaKind: 'image', byTier: ['gemini-3.6-flash', 'fireworks/kimi-k3', 'fireworks/kimi-k3'] },
{ mediaKind: 'file', byTier: ['gemini-3.6-flash', 'claude-sonnet-5', 'gpt-5.6-sol'] },
]
for (const { mediaKind, byTier } of POOL_CASES) {
byTier.forEach((expected, index) => {
const tier = String(index + 1)
it(`routes ${mediaKind} tier ${tier} to ${expected}`, async () => {
mockFetchGo.mockResolvedValue(routerResponse({ choice: tier }))
const result = await resolveAutoModel({
ctx,
blockId: 'b1',
signals: makeSignals({ mediaKind }),
fallbackModel: 'claude-sonnet-5',
})
expect(result.model).toBe(expected)
expect(result.tier).toBe(tier)
})
})
}
it('offers all three tiers and no model name to the router', async () => {
mockFetchGo.mockResolvedValue(routerResponse({ choice: '2' }))
await resolveAutoModel({
ctx,
blockId: 'b1',
signals: makeSignals({ mediaKind: 'image' }),
fallbackModel: 'claude-sonnet-5',
})
const body = JSON.parse(mockFetchGo.mock.calls[0][1].body as string)
expect(body.candidates.map((c: { id: string }) => c.id)).toEqual(['1', '2', '3'])
// Media kind is Sim's business: the wire carries only its presence.
expect(body.signals.hasMedia).toBe(true)
expect(body.signals.mediaKind).toBeUndefined()
for (const model of ['glm', 'kimi', 'gemini', 'gpt-5.6-sol', 'sonnet']) {
expect(JSON.stringify(body)).not.toContain(model)
}
})
it('never crosses media kinds when walking down from a denied tier', async () => {
mockFetchGo.mockResolvedValue(routerResponse({ choice: '3' }))
// gpt-5.6-sol denied; the file column must drop to sonnet, never to a
// Fireworks model that cannot accept the attachment at all.
mockValidateModelProvider.mockImplementation(async (_u, _w, model: string) => {
if (model === 'gpt-5.6-sol') throw new Error('provider blocked')
})
const result = await resolveAutoModel({
ctx,
blockId: 'b1',
signals: makeSignals({ mediaKind: 'file' }),
fallbackModel: 'claude-sonnet-5',
})
expect(result.model).toBe('claude-sonnet-5')
expect(result.tier).toBe('3')
})
it('classifies even a tiny toolless prompt instead of assuming the lowest tier', async () => {
mockFetchGo.mockResolvedValue(routerResponse({ choice: '3' }))
const result = await resolveAutoModel({
ctx,
blockId: 'b1',
signals: makeSignals({ approxInputTokens: 20, toolNames: [], hasResponseFormat: false }),
fallbackModel: 'claude-sonnet-5',
})
expect(mockFetchGo).toHaveBeenCalledTimes(1)
expect(result.model).toBe('fireworks/kimi-k3')
expect(result.decidedBy).toBe('llm')
})
it('uses the router choice and applies the cost multiplier when billable', async () => {
mockFetchGo.mockResolvedValue(
routerResponse({
choice: '2',
decidedBy: 'llm',
usage: {
model: 'glm-5.2',
inputTokens: 900,
cachedInputTokens: 0,
outputTokens: 2,
cost: 0.001,
},
billable: true,
})
)
const result = await resolveAutoModel({
ctx,
blockId: 'b1',
signals: makeSignals(),
fallbackModel: 'claude-sonnet-5',
})
expect(result.model).toBe('fireworks/glm-5.2')
expect(result.tier).toBe('2')
expect(result.decidedBy).toBe('llm')
expect(result.billableRoutingCost).toBeCloseTo(0.002)
})
it('does not bill when the response omits billable (fail-safe)', async () => {
mockFetchGo.mockResolvedValue(
routerResponse({
choice: '1',
usage: {
model: 'glm-5.2',
inputTokens: 900,
cachedInputTokens: 0,
outputTokens: 2,
cost: 0.001,
},
})
)
const result = await resolveAutoModel({
ctx,
blockId: 'b1',
signals: makeSignals(),
fallbackModel: 'claude-sonnet-5',
})
expect(result.model).toBe('fireworks/glm-5.2')
expect(result.billableRoutingCost).toBe(0)
})
it('falls back when the router call fails', async () => {
mockFetchGo.mockRejectedValue(new Error('timeout'))
const result = await resolveAutoModel({
ctx,
blockId: 'b1',
signals: makeSignals(),
fallbackModel: 'claude-sonnet-5',
})
expect(result.model).toBe('claude-sonnet-5')
expect(result.decidedBy).toBe('fallback')
})
it('falls back when the router returns an unknown choice', async () => {
mockFetchGo.mockResolvedValue(routerResponse({ choice: 'banana' }))
const result = await resolveAutoModel({
ctx,
blockId: 'b1',
signals: makeSignals(),
fallbackModel: 'claude-sonnet-5',
})
expect(result.model).toBe('claude-sonnet-5')
expect(result.decidedBy).toBe('fallback')
})
it('falls back when every pool model is denied by workspace permissions', async () => {
mockFetchGo.mockResolvedValue(routerResponse({ choice: '1' }))
mockValidateModelProvider.mockRejectedValue(new Error('provider blocked'))
const result = await resolveAutoModel({
ctx,
blockId: 'b1',
signals: makeSignals(),
fallbackModel: 'claude-sonnet-5',
})
expect(result.model).toBe('claude-sonnet-5')
expect(result.decidedBy).toBe('fallback')
})
it('falls back when every pool model resolves to a blacklisted provider', async () => {
mockFetchGo.mockResolvedValue(routerResponse({ choice: '2' }))
mockGetProviderFromModel.mockImplementation(() => {
throw new Error('Provider "fireworks" is not available')
})
const result = await resolveAutoModel({
ctx,
blockId: 'b1',
signals: makeSignals(),
fallbackModel: 'claude-sonnet-5',
})
expect(result.model).toBe('claude-sonnet-5')
expect(result.decidedBy).toBe('fallback')
})
it('serves repeated identical signals from the decision cache without re-calling the router', async () => {
mockFetchGo.mockResolvedValue(routerResponse({ choice: '1' }))
const signals = makeSignals()
const first = await resolveAutoModel({
ctx,
blockId: 'b1',
signals,
fallbackModel: 'claude-sonnet-5',
})
const second = await resolveAutoModel({
ctx,
blockId: 'b1',
signals,
fallbackModel: 'claude-sonnet-5',
})
expect(first.decidedBy).toBe('llm')
expect(second.decidedBy).toBe('cache')
expect(second.model).toBe('fireworks/glm-5.2')
expect(second.tier).toBe('1')
expect(second.billableRoutingCost).toBe(0)
expect(mockFetchGo).toHaveBeenCalledTimes(1)
})
})
+308
View File
@@ -0,0 +1,308 @@
import { createHash } from 'crypto'
import { createLogger } from '@sim/logger'
import { getErrorMessage } from '@sim/utils/errors'
import { fetchGo } from '@/lib/copilot/request/go/fetch'
import { getMothershipBaseURL } from '@/lib/copilot/server/agent-url'
import { env } from '@/lib/core/config/env'
import { getCostMultiplier, isHosted } from '@/lib/core/config/env-flags'
import { validateModelProvider } from '@/ee/access-control/utils/permission-check'
import type { ExecutionContext } from '@/executor/types'
import { getProviderFromModel } from '@/providers/utils'
const logger = createLogger('ModelRouter')
/**
* The difficulty scale the classifier chooses from. These are the ONLY routing
* facts that cross the wire — the router never sees a model name, so the pool
* below can change without touching mothership.
*/
const TIERS = [
{
id: '1',
hint: 'low — short mechanical work: extraction, formatting, classification, simple lookups',
},
{
id: '2',
hint: 'standard — typical multi-step work: tool use, drafting, moderate reasoning over the inputs',
},
{
id: '3',
hint: 'max — hardest reasoning, synthesis across many inputs, long context, high-stakes output',
},
] as const
type AutoTier = (typeof TIERS)[number]
type AutoTierId = AutoTier['id']
/**
* What the task attaches. Capability, not difficulty: it decides which models
* can serve a tier at all, so it is resolved on Sim's side and never asked of
* the classifier.
*/
export type AutoMediaKind = 'none' | 'image' | 'file'
/**
* The sim-auto pool: media kind → tier → candidate models, ordered by
* preference. Every entry is a hosted-billable catalog model, filtered through
* the deployment blacklist and workspace model permissions at resolve time.
*
* Text and pure-image tasks ride the Fireworks OSS models on the platform key
* — glm-5.2 beats the small proprietary models per dollar, and kimi-k3 is
* natively multimodal (text + `image_url` parts only; the Fireworks chat API
* models no other content part, and glm-5.2 rejects images outright).
* Anything else attached — PDFs, audio, video, text documents — must ride the
* native providers, which are the only ones that accept those parts.
*/
const POOL: Record<AutoMediaKind, Record<AutoTierId, readonly string[]>> = {
none: {
'1': ['fireworks/glm-5.2'],
'2': ['fireworks/glm-5.2'],
'3': ['fireworks/kimi-k3'],
},
image: {
'1': ['gemini-3.6-flash'],
'2': ['fireworks/kimi-k3'],
'3': ['fireworks/kimi-k3'],
},
file: {
'1': ['gemini-3.6-flash'],
'2': ['claude-sonnet-5'],
'3': ['gpt-5.6-sol'],
},
}
/**
* Hidden identity preamble prepended to the system prompt of every sim-auto
* execution (including fallback runs), so pool models behave consistently
* regardless of which one routing picked.
*/
export const SIM_AUTO_SYSTEM_PREAMBLE = `You are the Sim auto model on sim.ai. Respond in English unless the user writes in another language or explicitly asks for one. Do not volunteer information about which underlying model powers you; if asked directly, say you are the Sim auto model.`
const MODEL_ROUTER_TIMEOUT_MS = 2000
const MAX_SIGNAL_CHARS = 2000
const DECISION_CACHE_TTL_MS = 5 * 60 * 1000
const DECISION_CACHE_MAX_ENTRIES = 500
/** Compact facts about the pending agent-block task; never the full payload. */
export interface AutoRoutingSignals {
systemPrompt?: string
lastMessage?: string
messageCount: number
toolNames: string[]
/**
* What the task attaches, which selects the pool column. Only its presence
* (`!== 'none'`) is sent to the router as `hasMedia`: the classifier picks a
* difficulty tier, and Sim owns which model can actually serve it.
*/
mediaKind: AutoMediaKind
hasResponseFormat: boolean
approxInputTokens: number
}
/** Mirrors mothership's model-router-v1 response usage block. */
interface ModelRouterUsage {
model: string
inputTokens: number
cachedInputTokens: number
outputTokens: number
cost: number
}
interface ModelRouterResponse {
choice?: string
decidedBy?: string
usage?: ModelRouterUsage
billable?: boolean
}
export interface AutoRoutingResult {
model: string
tier: AutoTierId | null
decidedBy: 'llm' | 'cache' | 'fallback'
/**
* Cost of the routing call itself in USD with the hosted cost multiplier
* applied — non-zero only when mothership marked the call billable. Absent
* `billable` in the response is treated as not billable (fail-safe).
*/
billableRoutingCost: number
usage?: ModelRouterUsage
}
const decisionCache = new Map<string, { tier: AutoTierId; expires: number }>()
function cacheKey(signals: AutoRoutingSignals): string {
const tokenBucket = Math.round(signals.approxInputTokens / 500)
return createHash('sha256')
.update(
JSON.stringify([
signals.systemPrompt ?? '',
(signals.lastMessage ?? '').slice(0, 500),
[...signals.toolNames].sort(),
signals.hasResponseFormat,
signals.mediaKind,
tokenBucket,
])
)
.digest('hex')
}
function readDecisionCache(key: string): AutoTierId | null {
const entry = decisionCache.get(key)
if (!entry) return null
if (entry.expires < Date.now()) {
decisionCache.delete(key)
return null
}
return entry.tier
}
function writeDecisionCache(key: string, tier: AutoTierId): void {
if (decisionCache.size >= DECISION_CACHE_MAX_ENTRIES) {
const oldest = decisionCache.keys().next().value
if (oldest !== undefined) decisionCache.delete(oldest)
}
decisionCache.set(key, { tier, expires: Date.now() + DECISION_CACHE_TTL_MS })
}
/**
* Picks the first model of the chosen tier — then of each lower tier for the
* same media kind — that is actually usable: its provider resolves and is not
* blacklisted by the deployment, and it passes workspace model permissions.
* Never crosses into another media kind's column, so a text-only model can
* never be handed an image. Returns null when nothing in the column is usable
* and the caller falls back.
*/
async function pickModelForTier(
mediaKind: AutoMediaKind,
tier: AutoTierId,
ctx: ExecutionContext
): Promise<string | null> {
const startIndex = TIERS.findIndex((t) => t.id === tier)
const tried = new Set<string>()
for (let i = startIndex; i >= 0; i--) {
for (const model of POOL[mediaKind][TIERS[i].id]) {
// Tiers may share a model; a second permission round-trip buys nothing.
if (tried.has(model)) continue
tried.add(model)
try {
// Throws when the provider or model is blacklisted, which would
// otherwise surface as a hard block failure after resolution.
getProviderFromModel(model)
await validateModelProvider(ctx.userId, ctx.workspaceId ?? undefined, model, ctx)
return model
} catch {
logger.info('sim-auto candidate unavailable, trying the next one', { model })
}
}
}
return null
}
async function callModelRouter(
signals: AutoRoutingSignals,
ctx: ExecutionContext,
blockId: string
): Promise<ModelRouterResponse | null> {
const baseURL = await getMothershipBaseURL({ userId: ctx.userId ?? '' })
const res = await fetchGo(`${baseURL}/api/model-router`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
...(env.COPILOT_API_KEY ? { 'x-api-key': env.COPILOT_API_KEY } : {}),
},
body: JSON.stringify({
signals: {
systemPrompt: (signals.systemPrompt ?? '').slice(0, MAX_SIGNAL_CHARS),
lastMessage: (signals.lastMessage ?? '').slice(0, MAX_SIGNAL_CHARS),
messageCount: signals.messageCount,
toolNames: signals.toolNames,
hasMedia: signals.mediaKind !== 'none',
hasResponseFormat: signals.hasResponseFormat,
approxInputTokens: signals.approxInputTokens,
},
candidates: TIERS.map((t) => ({ id: t.id, hint: t.hint })),
workspaceId: ctx.workspaceId,
userId: ctx.userId,
workflowId: ctx.workflowId,
blockId,
executionId: ctx.executionId,
}),
signal: AbortSignal.timeout(MODEL_ROUTER_TIMEOUT_MS),
spanName: 'sim → go /api/model-router',
operation: 'model_router',
})
if (!res.ok) {
logger.warn('Model router returned non-OK status', { status: res.status })
return null
}
return (await res.json().catch(() => null)) as ModelRouterResponse | null
}
/**
* Resolves the sim-auto pseudo-model to a concrete model for one agent-block
* execution. Never throws and never fails the workflow: any error, timeout,
* non-hosted deployment, or fully unavailable pool column falls back to
* `fallbackModel` (the block's standard default).
*/
export async function resolveAutoModel(args: {
ctx: ExecutionContext
blockId: string
signals: AutoRoutingSignals
fallbackModel: string
}): Promise<AutoRoutingResult> {
const { ctx, blockId, signals, fallbackModel } = args
const fallback: AutoRoutingResult = {
model: fallbackModel,
tier: null,
decidedBy: 'fallback',
billableRoutingCost: 0,
}
if (!isHosted) return fallback
try {
// Every execution is classified: a short prompt is not a simple task, and
// a local size rule can only ever route DOWN, which is the expensive
// mistake. The cache below replays a prior router decision, never a
// locally derived one.
const key = cacheKey(signals)
const cachedTier = readDecisionCache(key)
if (cachedTier) {
const model = await pickModelForTier(signals.mediaKind, cachedTier, ctx)
if (!model) return fallback
return { model, tier: cachedTier, decidedBy: 'cache', billableRoutingCost: 0 }
}
const response = await callModelRouter(signals, ctx, blockId)
const tier = TIERS.find((t) => t.id === response?.choice)?.id
if (!tier) {
logger.warn('sim-auto: router returned no usable choice, using fallback model', {
blockId,
choice: response?.choice,
})
return fallback
}
writeDecisionCache(key, tier)
const model = await pickModelForTier(signals.mediaKind, tier, ctx)
if (!model) return fallback
const billable = response?.billable === true && (response.usage?.cost ?? 0) > 0
return {
model,
tier,
decidedBy: 'llm',
billableRoutingCost: billable ? (response!.usage!.cost ?? 0) * getCostMultiplier() : 0,
usage: response?.usage,
}
} catch (error) {
logger.warn('sim-auto: routing failed, using fallback model', {
blockId,
error: getErrorMessage(error, 'unknown routing error'),
})
return fallback
}
}
+21 -1
View File
@@ -9,11 +9,13 @@ const {
mockSupportsNativeStructuredOutputs,
mockPrepareToolsWithUsageControl,
mockExecuteTool,
mockResolveFireworksWireModel,
} = vi.hoisted(() => ({
mockCreate: vi.fn(),
mockSupportsNativeStructuredOutputs: vi.fn(),
mockPrepareToolsWithUsageControl: vi.fn(),
mockExecuteTool: vi.fn(),
mockResolveFireworksWireModel: vi.fn(),
}))
vi.mock('openai', () => ({
@@ -45,6 +47,7 @@ vi.mock('@/providers/fireworks/utils', () => ({
() => new ReadableStream({ start: (controller) => controller.close() })
),
checkForForcedToolUsage: vi.fn(() => ({ hasUsedForcedTool: false, usedForcedTools: [] })),
resolveFireworksWireModel: mockResolveFireworksWireModel,
}))
vi.mock('@/providers/trace-enrichment', () => ({
@@ -109,6 +112,7 @@ describe('fireworksProvider', () => {
forcedTools: [],
}))
mockExecuteTool.mockResolvedValue({ success: true, output: { ok: true } })
mockResolveFireworksWireModel.mockImplementation((stripped: string) => stripped)
})
const baseRequest = {
@@ -131,11 +135,27 @@ describe('fireworksProvider', () => {
expect(result).toMatchObject({
content: 'hi there',
model: 'llama-v3p1-70b-instruct',
model: 'fireworks/llama-v3p1-70b-instruct',
tokens: { input: 10, output: 5, total: 15 },
})
})
it('sends the resolved wire model name while reporting the catalog id', async () => {
mockCreate.mockResolvedValueOnce(textResponse('ok'))
mockResolveFireworksWireModel.mockReturnValueOnce('accounts/fireworks/models/glm-5p2')
const result = await fireworksProvider.executeRequest({
...baseRequest,
model: 'fireworks/glm-5.2',
})
expect(mockResolveFireworksWireModel).toHaveBeenCalledWith('glm-5.2')
expect(callBody(0).model).toBe('accounts/fireworks/models/glm-5p2')
// The catalog id is the billing/logging identity: the central cost policy,
// the usage ledger row, and the trace span all key on it.
expect(result).toMatchObject({ model: 'fireworks/glm-5.2' })
})
it('wraps API errors in a ProviderError', async () => {
mockCreate.mockRejectedValueOnce(new Error('boom'))
+13 -6
View File
@@ -9,6 +9,7 @@ import { formatMessagesForProvider } from '@/providers/attachments'
import {
checkForForcedToolUsage,
createReadableStreamFromOpenAIStream,
resolveFireworksWireModel,
supportsNativeStructuredOutputs,
} from '@/providers/fireworks/utils'
import { getProviderDefaultModel, getProviderModels } from '@/providers/models'
@@ -89,7 +90,7 @@ export const fireworksProvider: ProviderConfig = {
baseURL: 'https://api.fireworks.ai/inference/v1',
})
const requestedModel = request.model.replace(/^fireworks\//, '')
const requestedModel = resolveFireworksWireModel(request.model.replace(/^fireworks\//, ''))
logger.info('Preparing Fireworks request', {
model: requestedModel,
@@ -165,7 +166,10 @@ export const fireworksProvider: ProviderConfig = {
)
const streamingResult = createStreamingExecution({
model: requestedModel,
// Echo the catalog id, never the wire name: it is the billing and
// logging identity (cost policy, ledger row, trace span, model
// breakdown) the way every other Sim-hosted provider reports it.
model: request.model,
providerStartTime,
providerStartTimeISO,
timing: { kind: 'simple', segmentName: request.model },
@@ -181,8 +185,10 @@ export const fireworksProvider: ProviderConfig = {
total: usage.total_tokens,
}
// Pricing keys on the catalog id (fireworks/<name>), not the wire
// name — static hosted entries price; dynamic ids stay unpriced.
const costResult = calculateCost(
requestedModel,
request.model,
usage.prompt_tokens,
usage.completion_tokens
)
@@ -547,7 +553,8 @@ export const fireworksProvider: ProviderConfig = {
}
if (request.stream) {
const accumulatedCost = calculateCost(requestedModel, tokens.input, tokens.output)
// Pricing keys on the catalog id (fireworks/<name>), not the wire name.
const accumulatedCost = calculateCost(request.model, tokens.input, tokens.output)
const toolCost = sumToolCosts(toolResults)
const finalCost = {
input: accumulatedCost.input,
@@ -557,7 +564,7 @@ export const fireworksProvider: ProviderConfig = {
}
const streamingResult = createStreamingExecution({
model: requestedModel,
model: request.model,
providerStartTime,
providerStartTimeISO,
timing: {
@@ -591,7 +598,7 @@ export const fireworksProvider: ProviderConfig = {
return {
content,
model: requestedModel,
model: request.model,
tokens,
toolCalls: toolCalls.length > 0 ? toolCalls : undefined,
toolResults: toolResults.length > 0 ? toolResults : undefined,
@@ -0,0 +1,18 @@
/**
* @vitest-environment node
*/
import { describe, expect, it } from 'vitest'
import { resolveFireworksWireModel } from '@/providers/fireworks/utils'
describe('resolveFireworksWireModel', () => {
it('maps the static hosted catalog ids to their serverless resource paths', () => {
expect(resolveFireworksWireModel('glm-5.2')).toBe('accounts/fireworks/models/glm-5p2')
expect(resolveFireworksWireModel('kimi-k3')).toBe('accounts/fireworks/models/kimi-k3')
})
it('passes user-configured dynamic ids through untouched', () => {
expect(resolveFireworksWireModel('accounts/acme/models/custom')).toBe(
'accounts/acme/models/custom'
)
})
})
+20
View File
@@ -4,6 +4,26 @@ import { createOpenAICompatibleAgentEventStream } from '@/providers/openai-compa
import type { AgentStreamEvent } from '@/providers/stream-events'
import { checkForForcedToolUsageOpenAI } from '@/providers/utils'
/**
* Wire model names for the static hosted Fireworks catalog entries (the
* sim-auto pool). Catalog ids are the short `fireworks/<name>` form; the
* Fireworks API requires the full serverless resource path. User-configured
* dynamic ids are sent verbatim after prefix-stripping, exactly as before —
* only ids in this map are rewritten.
*/
const FIREWORKS_WIRE_NAMES: Record<string, string> = {
'glm-5.2': 'accounts/fireworks/models/glm-5p2',
'kimi-k3': 'accounts/fireworks/models/kimi-k3',
}
/**
* Resolves the wire model name Fireworks expects from a prefix-stripped
* catalog id.
*/
export function resolveFireworksWireModel(strippedModel: string): string {
return FIREWORKS_WIRE_NAMES[strippedModel] ?? strippedModel
}
/**
* Checks if a model supports native structured outputs (json_schema).
* Fireworks AI supports structured outputs across their inference API.
+40
View File
@@ -5,12 +5,15 @@ import { describe, expect, it } from 'vitest'
import {
getBaseModelProviders,
getHostedModels,
getModelPricing,
getModelsWithPromptCaching,
getPromptCachingMinimumTokens,
getProviderModels,
getThinkingStreamVisibility,
isModelDeprecated,
orderModelIdsByReleaseDate,
PROVIDER_DEFINITIONS,
updateFireworksModels,
} from '@/providers/models'
import { supportsPromptCaching } from '@/providers/utils'
@@ -370,6 +373,43 @@ describe('xai provider definition', () => {
})
})
describe('fireworks static catalog (the sim-auto pool)', () => {
const poolModels = ['fireworks/glm-5.2', 'fireworks/kimi-k3']
it('is included in getHostedModels since Sim provides the Fireworks key server-side', () => {
for (const model of poolModels) {
expect(getHostedModels()).toContain(model)
}
})
it('prices every pool model so hosted usage is billable', () => {
for (const model of poolModels) {
const pricing = getModelPricing(model)
expect(pricing?.input).toBeGreaterThan(0)
expect(pricing?.output).toBeGreaterThan(0)
}
})
it('survives a dynamic model sync, which merges rather than replaces', () => {
updateFireworksModels(['fireworks/accounts/acme/models/custom'])
for (const model of poolModels) {
expect(getHostedModels()).toContain(model)
expect(getModelPricing(model)?.input).toBeGreaterThan(0)
}
expect(getProviderModels('fireworks')).toContain('fireworks/accounts/acme/models/custom')
})
it('does not duplicate a pool model the dynamic listing also returns', () => {
updateFireworksModels(['fireworks/glm-5.2'])
expect(getProviderModels('fireworks').filter((id) => id === 'fireworks/glm-5.2')).toHaveLength(
1
)
expect(getModelPricing('fireworks/glm-5.2')?.input).toBeGreaterThan(0)
})
})
describe('isModelDeprecated', () => {
it('returns true for a catalogued deprecated model (case-insensitive)', () => {
const id = firstDeprecatedModelId()
+83 -10
View File
@@ -164,7 +164,47 @@ export const PROVIDER_DEFINITIONS: Record<string, ProviderDefinition> = {
toolUsageControl: true,
},
contextInformationAvailable: false,
models: [],
/**
* Static Fireworks serverless entries: the sim-auto routing pool. These are
* hosted-billable (platform FIREWORKS_API_KEY) and priced at the Fireworks
* serverless rate, which differs from the vendors' native rates. Text-only:
* the Fireworks serving endpoints reject image inputs. User-configured
* `fireworks/<anything-else>` ids remain dynamic/BYO-key as before.
*/
models: [
{
id: 'fireworks/glm-5.2',
pricing: {
input: 1.4,
cachedInput: 0.14,
output: 4.4,
updatedAt: '2026-07-29',
},
capabilities: {
toolUsageControl: true,
maxOutputTokens: 131072,
},
contextWindow: 1048576,
releaseDate: '2026-06-13',
recommended: true,
},
{
id: 'fireworks/kimi-k3',
pricing: {
input: 3.0,
cachedInput: 0.3,
output: 15.0,
updatedAt: '2026-07-29',
},
capabilities: {
toolUsageControl: true,
maxOutputTokens: 1048576,
},
contextWindow: 1048576,
releaseDate: '2026-07-16',
recommended: true,
},
],
},
together: {
id: 'together',
@@ -4037,6 +4077,19 @@ export function suggestModelIdsForUnknownModel(_modelId: string, limit = 5): str
.slice(0, limit)
}
/**
* Pseudo-model id for the agent block's automatic model. Not a real catalog
* entry: at execution time the resolver (lib/model-router) classifies the task
* via mothership and swaps in a concrete model from the hosted Fireworks pool
* before any provider/pricing lookup runs. Hosted Sim only.
*/
export const SIM_AUTO_MODEL_ID = 'sim-auto'
/** True when the configured model is the sim-auto pseudo-model. */
export function isAutoModel(model: string): boolean {
return model.trim().toLowerCase() === SIM_AUTO_MODEL_ID
}
export function getBaseModelProviders(): Record<string, ProviderId> {
return Object.entries(PROVIDER_DEFINITIONS)
.filter(
@@ -4166,6 +4219,11 @@ export function getHostedModels(): string[] {
...getProviderModels('zai'),
...getProviderModels('xai'),
...getProviderModels('kimi'),
// The STATIC Fireworks catalog only (the sim-auto pool, platform key) —
// deliberately not the live provider list, which `updateFireworksModels`
// merges a workspace's own dynamic ids into. Those must never enter the
// hosted set: it gates both billing and the platform-key handout.
...STATIC_FIREWORKS_MODELS.map((model) => model.id),
]
}
@@ -4231,16 +4289,31 @@ export function updateLiteLLMModels(models: string[]): void {
}))
}
/**
* The static Fireworks catalog (the hosted sim-auto pool), captured at module
* load before any dynamic sync runs. Hosted billing, pricing, and the agent
* block's API-key condition all key on these ids, so a workspace's own
* Fireworks models are merged on top of them rather than replacing them —
* unlike the other dynamic providers, whose static list is empty.
*/
const STATIC_FIREWORKS_MODELS = PROVIDER_DEFINITIONS.fireworks.models
export function updateFireworksModels(models: string[]): void {
PROVIDER_DEFINITIONS.fireworks.models = models.map((modelId) => ({
id: modelId,
pricing: {
input: 0,
output: 0,
updatedAt: new Date().toISOString().split('T')[0],
},
capabilities: {},
}))
const staticIds = new Set(STATIC_FIREWORKS_MODELS.map((model) => model.id.toLowerCase()))
PROVIDER_DEFINITIONS.fireworks.models = [
...STATIC_FIREWORKS_MODELS,
...models
.filter((modelId) => !staticIds.has(modelId.toLowerCase()))
.map((modelId) => ({
id: modelId,
pricing: {
input: 0,
output: 0,
updatedAt: new Date().toISOString().split('T')[0],
},
capabilities: {},
})),
]
}
export function updateTogetherModels(models: string[]): void {
@@ -104,6 +104,7 @@ vi.mock('@/providers/fireworks/utils', () => ({
})),
createReadableStreamFromOpenAIStream: vi.fn(() => createEmptyStream()),
supportsNativeStructuredOutputs: vi.fn(() => true),
resolveFireworksWireModel: vi.fn((stripped: string) => stripped),
}))
vi.mock('@/providers/openrouter/utils', () => ({
checkForForcedToolUsage: vi.fn(() => ({