diff --git a/apps/desktop/build/generated-icon.icon/Assets/border.svg b/apps/desktop/build/generated-icon.icon/Assets/border.svg new file mode 100644 index 00000000000..fb9d4f8c789 --- /dev/null +++ b/apps/desktop/build/generated-icon.icon/Assets/border.svg @@ -0,0 +1,3 @@ + + + diff --git a/apps/desktop/build/generated-icon.icon/Assets/logo.png b/apps/desktop/build/generated-icon.icon/Assets/logo.png new file mode 100644 index 00000000000..6fce2046095 Binary files /dev/null and b/apps/desktop/build/generated-icon.icon/Assets/logo.png differ diff --git a/apps/desktop/build/generated-icon.icon/icon.json b/apps/desktop/build/generated-icon.icon/icon.json new file mode 100644 index 00000000000..f53f60fc7e4 --- /dev/null +++ b/apps/desktop/build/generated-icon.icon/icon.json @@ -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"] + } +} diff --git a/apps/sim/.env.example b/apps/sim/.env.example index ef123188499..3c2dcf8f229 100644 --- a/apps/sim/.env.example +++ b/apps/sim/.env.example @@ -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 diff --git a/apps/sim/app/api/providers/fireworks/models/route.ts b/apps/sim/app/api/providers/fireworks/models/route.ts index ef3c000d960..82754eb53fb 100644 --- a/apps/sim/app/api/providers/fireworks/models/route.ts +++ b/apps/sim/app/api/providers/fireworks/models/route.ts @@ -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 } diff --git a/apps/sim/blocks/blocks/agent.ts b/apps/sim/blocks/blocks/agent.ts index fb2e9756c38..29619bff5b7 100644 --- a/apps/sim/blocks/blocks/agent.ts +++ b/apps/sim/blocks/blocks/agent.ts @@ -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}`) } diff --git a/apps/sim/blocks/utils.test.ts b/apps/sim/blocks/utils.test.ts index bf90c25d656..9d5a3adf24f 100644 --- a/apps/sim/blocks/utils.test.ts +++ b/apps/sim/blocks/utils.test.ts @@ -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', () => ({ diff --git a/apps/sim/blocks/utils.ts b/apps/sim/blocks/utils.ts index f0c7d11f13a..848b8d36363 100644 --- a/apps/sim/blocks/utils.ts +++ b/apps/sim/blocks/utils.ts @@ -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 diff --git a/apps/sim/components/icons.tsx b/apps/sim/components/icons.tsx index efedba2000d..5a2e8424b26 100644 --- a/apps/sim/components/icons.tsx +++ b/apps/sim/components/icons.tsx @@ -7558,6 +7558,31 @@ export function SixtyfourIcon(props: SVGProps) { ) } +/** + * 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) { + return ( + + ) +} + export function SimTriggerIcon(props: SVGProps) { return ( { 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) => + ( + 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 = { 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 = { 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', diff --git a/apps/sim/executor/handlers/agent/agent-handler.ts b/apps/sim/executor/handlers/agent/agent-handler.ts index fc721b990f2..a126b7e00b4 100644 --- a/apps/sim/executor/handlers/agent/agent-handler.ts +++ b/apps/sim/executor/handlers/agent/agent-handler.ts @@ -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 } + + const withRouting = (cost: Record | undefined): Record => + 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 | undefined) + Object.defineProperty(target, 'cost', { + get: () => withRouting(prior?.get ? (prior.get.call(target) as never) : raw), + set: (value: Record | undefined) => { + if (prior?.set) prior.set.call(target, value) + else raw = value + }, + configurable: true, + enumerable: true, + }) + } + private async validateToolPermissions(ctx: ExecutionContext, tools: ToolInput[]): Promise { if (!Array.isArray(tools) || tools.length === 0) return diff --git a/apps/sim/lib/api-key/byok.test.ts b/apps/sim/lib/api-key/byok.test.ts index 7bd905b16c5..bcc8d81bfcb 100644 --- a/apps/sim/lib/api-key/byok.test.ts +++ b/apps/sim/lib/api-key/byok.test.ts @@ -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, + 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).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 }) + }) +}) diff --git a/apps/sim/lib/api-key/byok.ts b/apps/sim/lib/api-key/byok.ts index aa456962264..ba7c9f7e611 100644 --- a/apps/sim/lib/api-key/byok.ts +++ b/apps/sim/lib/api-key/byok.ts @@ -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 } } diff --git a/apps/sim/lib/copilot/tools/server/workflow/edit-workflow/validation.ts b/apps/sim/lib/copilot/tools/server/workflow/edit-workflow/validation.ts index 23b8de61ba7..36bc722a0f8 100644 --- a/apps/sim/lib/copilot/tools/server/workflow/edit-workflow/validation.ts +++ b/apps/sim/lib/copilot/tools/server/workflow/edit-workflow/validation.ts @@ -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 = diff --git a/apps/sim/lib/copilot/vfs/serializers.ts b/apps/sim/lib/copilot/vfs/serializers.ts index d394ed07347..0972b7a0d1f 100644 --- a/apps/sim/lib/copilot/vfs/serializers.ts +++ b/apps/sim/lib/copilot/vfs/serializers.ts @@ -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) { diff --git a/apps/sim/lib/core/config/api-keys.ts b/apps/sim/lib/core/config/api-keys.ts index c8812aa05e4..839df609a1a 100644 --- a/apps/sim/lib/core/config/api-keys.ts +++ b/apps/sim/lib/core/config/api-keys.ts @@ -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) { diff --git a/apps/sim/lib/core/config/env-flags.ts b/apps/sim/lib/core/config/env-flags.ts index 115242cbfbc..29792c6af1a 100644 --- a/apps/sim/lib/core/config/env-flags.ts +++ b/apps/sim/lib/core/config/env-flags.ts @@ -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 diff --git a/apps/sim/lib/core/config/env.ts b/apps/sim/lib/core/config/env.ts index ab6a5de43f1..eb2ee89c7e2 100644 --- a/apps/sim/lib/core/config/env.ts +++ b/apps/sim/lib/core/config/env.ts @@ -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) diff --git a/apps/sim/lib/core/utils.test.ts b/apps/sim/lib/core/utils.test.ts index 62753f61204..1c882094c52 100644 --- a/apps/sim/lib/core/utils.test.ts +++ b/apps/sim/lib/core/utils.test.ts @@ -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') }) diff --git a/apps/sim/lib/model-router/resolve.test.ts b/apps/sim/lib/model-router/resolve.test.ts new file mode 100644 index 00000000000..d4f65f7b6e5 --- /dev/null +++ b/apps/sim/lib/model-router/resolve.test.ts @@ -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 { + 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) + }) +}) diff --git a/apps/sim/lib/model-router/resolve.ts b/apps/sim/lib/model-router/resolve.ts new file mode 100644 index 00000000000..9a4a1921570 --- /dev/null +++ b/apps/sim/lib/model-router/resolve.ts @@ -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> = { + 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() + +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 { + const startIndex = TIERS.findIndex((t) => t.id === tier) + const tried = new Set() + + 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 { + 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 { + 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 + } +} diff --git a/apps/sim/providers/fireworks/index.test.ts b/apps/sim/providers/fireworks/index.test.ts index f20d8568630..563117f4b27 100644 --- a/apps/sim/providers/fireworks/index.test.ts +++ b/apps/sim/providers/fireworks/index.test.ts @@ -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')) diff --git a/apps/sim/providers/fireworks/index.ts b/apps/sim/providers/fireworks/index.ts index 0ec540a8c2b..c7965c64072 100644 --- a/apps/sim/providers/fireworks/index.ts +++ b/apps/sim/providers/fireworks/index.ts @@ -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/), 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/), 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, diff --git a/apps/sim/providers/fireworks/utils.test.ts b/apps/sim/providers/fireworks/utils.test.ts new file mode 100644 index 00000000000..79f51b4b866 --- /dev/null +++ b/apps/sim/providers/fireworks/utils.test.ts @@ -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' + ) + }) +}) diff --git a/apps/sim/providers/fireworks/utils.ts b/apps/sim/providers/fireworks/utils.ts index c4abd3111bd..23793f26bfe 100644 --- a/apps/sim/providers/fireworks/utils.ts +++ b/apps/sim/providers/fireworks/utils.ts @@ -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/` 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 = { + '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. diff --git a/apps/sim/providers/models.test.ts b/apps/sim/providers/models.test.ts index e8deb2a9311..e58b4b65f6d 100644 --- a/apps/sim/providers/models.test.ts +++ b/apps/sim/providers/models.test.ts @@ -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() diff --git a/apps/sim/providers/models.ts b/apps/sim/providers/models.ts index aac911d4d5b..98be92cd78d 100644 --- a/apps/sim/providers/models.ts +++ b/apps/sim/providers/models.ts @@ -164,7 +164,47 @@ export const PROVIDER_DEFINITIONS: Record = { 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/` 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 { 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 { diff --git a/apps/sim/providers/settled-tool-streams.test.ts b/apps/sim/providers/settled-tool-streams.test.ts index d7d1ea5c4c9..0cba631abe4 100644 --- a/apps/sim/providers/settled-tool-streams.test.ts +++ b/apps/sim/providers/settled-tool-streams.test.ts @@ -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(() => ({