Skip to content

Commit 8711860

Browse files
committed
auto model
1 parent 8f4f24f commit 8711860

23 files changed

Lines changed: 682 additions & 129 deletions

File tree

Lines changed: 3 additions & 0 deletions
Loading
23.5 KB
Loading
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
{
2+
"fill": {
3+
"solid": "srgb:1.00000,1.00000,1.00000,1.00000"
4+
},
5+
"groups": [
6+
{
7+
"layers": [
8+
{
9+
"image-name": "logo.png",
10+
"is-glass": false,
11+
"name": "Sim"
12+
},
13+
{
14+
"image-name": "border.svg",
15+
"is-glass": false,
16+
"name": "Dev Border"
17+
}
18+
],
19+
"shadow": {
20+
"kind": "neutral",
21+
"opacity": 0
22+
},
23+
"specular": false,
24+
"translucency": {
25+
"enabled": false,
26+
"value": 0
27+
}
28+
}
29+
],
30+
"supported-platforms": {
31+
"squares": ["macOS"]
32+
}
33+
}

apps/sim/.env.example

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -63,7 +63,11 @@ API_ENCRYPTION_KEY=your_api_encryption_key # Use `openssl rand -hex 32` to gener
6363
# VLLM_API_KEY= # Optional bearer token if your vLLM instance requires auth
6464
# LITELLM_BASE_URL=http://localhost:4000 # Base URL for your LiteLLM proxy (OpenAI-compatible)
6565
# LITELLM_API_KEY= # Optional bearer token if your LiteLLM proxy requires auth
66-
# FIREWORKS_API_KEY= # Optional Fireworks AI API key for model listing
66+
# NEXT_PUBLIC_FORCE_HOSTED=true # Dev only: treat this instance as hosted Sim (sim-auto pool, platform keys); ignored in production builds
67+
# FIREWORKS_API_KEY= # Optional Fireworks AI API key for model listing and inference
68+
# FIREWORKS_API_KEY_1= # Optional Fireworks API key for rotation (hosted deployments)
69+
# FIREWORKS_API_KEY_2= # Additional Fireworks API key for load balancing
70+
# FIREWORKS_API_KEY_3= # Additional Fireworks API key for load balancing
6771
# 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.
6872
# AZURE_OPENAI_ENDPOINT= # Azure OpenAI endpoint (hides field in UI when set alongside NEXT_PUBLIC_AZURE_CONFIGURED)
6973
# AZURE_OPENAI_API_KEY= # Azure OpenAI API key

apps/sim/app/api/providers/fireworks/models/route.ts

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import { validationErrorResponse } from '@/lib/api/server'
1010
import { getBYOKKey } from '@/lib/api-key/byok'
1111
import { getSession } from '@/lib/auth'
1212
import { env } from '@/lib/core/config/env'
13+
import { isHosted } from '@/lib/core/config/env-flags'
1314
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
1415
import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils'
1516
import { filterBlacklistedModels, isProviderBlacklisted } from '@/providers/utils'
@@ -54,7 +55,13 @@ export const GET = withRouteHandler(async (request: NextRequest) => {
5455
}
5556
}
5657

57-
if (!apiKey) {
58+
/**
59+
* On hosted Sim the platform key is the sim-auto pool's inference key, not a
60+
* workspace credential: enumerating the whole Fireworks catalog from it would
61+
* offer every serverless model as selectable when no key can actually run it
62+
* (`getApiKeyWithBYOK` serves the platform key to catalog models only).
63+
*/
64+
if (!apiKey && !isHosted) {
5865
apiKey = env.FIREWORKS_API_KEY
5966
}
6067

apps/sim/blocks/blocks/agent.ts

Lines changed: 1 addition & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,5 @@
11
import { createLogger } from '@sim/logger'
22
import { AgentIcon } from '@/components/icons'
3-
import { isHosted } from '@/lib/core/config/env-flags'
43
import type { BlockConfig } from '@/blocks/types'
54
import { AuthMode, IntegrationType } from '@/blocks/types'
65
import {
@@ -22,7 +21,6 @@ import {
2221
getThinkingLevelsForModel,
2322
getVerbosityValuesForModel,
2423
isAutoModel,
25-
SIM_AUTO_MODEL_ID,
2624
supportsTemperature,
2725
} from '@/providers/models'
2826
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
@@ -135,9 +133,7 @@ Return ONLY the JSON array.`,
135133
type: 'combobox',
136134
placeholder: 'Type or select a model...',
137135
required: true,
138-
// Hosted Sim defaults new agent blocks to the automatic model; the
139-
// runtime fallback for blocks with no model set stays AGENT.DEFAULT_MODEL.
140-
defaultValue: isHosted ? SIM_AUTO_MODEL_ID : 'claude-sonnet-5',
136+
defaultValue: 'claude-sonnet-5',
141137
options: getModelOptions,
142138
commandSearchable: true,
143139
},

apps/sim/blocks/utils.ts

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -85,8 +85,11 @@ export function getModelOptions() {
8585
return { label: model, id: model, ...(icon && { icon }) }
8686
})
8787

88+
// Hosted-only automatic model. Deliberately LAST in the list (limited
89+
// visibility for the initial release): available to anyone who scrolls or
90+
// searches for it, but never the first thing the dropdown offers.
8891
if (isHosted) {
89-
options.unshift({ label: 'Auto', id: SIM_AUTO_MODEL_ID, icon: SimAutoIcon })
92+
options.push({ label: 'Auto', id: SIM_AUTO_MODEL_ID, icon: SimAutoIcon })
9093
}
9194

9295
return options
@@ -192,8 +195,10 @@ function shouldRequireApiKeyForModel(model: string): boolean {
192195
const normalizedModel = model.trim().toLowerCase()
193196
if (!normalizedModel) return false
194197

195-
// The auto pseudo-model resolves server-side to a hosted pool model.
196-
if (isAutoModel(normalizedModel)) return false
198+
// On hosted Sim the auto pseudo-model resolves server-side to a hosted pool
199+
// model. On self-hosted it exists only via imported workflows and always
200+
// falls back to the default Anthropic model, so the key field must show.
201+
if (isAutoModel(normalizedModel)) return !isHosted
197202

198203
if (isHosted) {
199204
const hostedModels = getHostedModels()

apps/sim/executor/handlers/agent/agent-handler.test.ts

Lines changed: 84 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,10 +17,11 @@ import {
1717
vi,
1818
} from 'vitest'
1919
import { getAllBlocks } from '@/blocks'
20-
import { BlockType, isMcpTool } from '@/executor/constants'
20+
import { AGENT, BlockType, isMcpTool } from '@/executor/constants'
2121
import { AgentBlockHandler } from '@/executor/handlers/agent/agent-handler'
2222
import type { ExecutionContext, StreamingExecution } from '@/executor/types'
2323
import { executeProviderRequest } from '@/providers'
24+
import { SIM_AUTO_MODEL_ID } from '@/providers/models'
2425
import { getProviderFromModel, transformBlockTool } from '@/providers/utils'
2526
import type { SerializedBlock, SerializedWorkflow } from '@/serializer/types'
2627
import { executeTool } from '@/tools'
@@ -275,6 +276,88 @@ describe('AgentBlockHandler', () => {
275276
expect(result).toEqual(expectedOutput)
276277
})
277278

279+
it('reports a sim-auto run under the sim-auto identity, not the model that served it', async () => {
280+
mockExecuteProviderRequest.mockResolvedValue({
281+
content: 'Mocked response content',
282+
model: AGENT.DEFAULT_MODEL,
283+
tokens: { input: 10, output: 20, total: 30 },
284+
toolCalls: [],
285+
cost: { input: 0.001, output: 0.002, total: 0.003 },
286+
timing: {
287+
total: 100,
288+
timeSegments: [
289+
{ type: 'model', name: AGENT.DEFAULT_MODEL, provider: 'anthropic', duration: 100 },
290+
],
291+
},
292+
})
293+
294+
const result = (await handler.execute(mockContext, mockBlock, {
295+
model: SIM_AUTO_MODEL_ID,
296+
userPrompt: 'Hello!',
297+
})) as {
298+
model: string
299+
cost: unknown
300+
tokens: unknown
301+
providerTiming: { timeSegments: Array<{ name?: string; provider?: string }> }
302+
}
303+
304+
expect(result.model).toBe(SIM_AUTO_MODEL_ID)
305+
expect(result.providerTiming.timeSegments[0].name).toBe(SIM_AUTO_MODEL_ID)
306+
expect(result.providerTiming.timeSegments[0].provider).toBeUndefined()
307+
// Only the label changes: tokens and the already-priced cost are untouched.
308+
expect(result.tokens).toEqual({ input: 10, output: 20, total: 30 })
309+
expect(result.cost).toEqual({ input: 0.001, output: 0.002, total: 0.003 })
310+
})
311+
312+
/** Reaches the private signal builder; routing depends on nothing else. */
313+
const buildAutoRoutingSignalsFor = (inputs: Record<string, unknown>) =>
314+
(
315+
handler as unknown as {
316+
buildAutoRoutingSignals: (i: unknown, rf: unknown) => { mediaKind: string }
317+
}
318+
).buildAutoRoutingSignals(inputs, undefined)
319+
320+
const png = { id: 'f1', type: 'image/png' }
321+
const pdf = { id: 'f2', type: 'application/pdf' }
322+
323+
it('reports no media when neither the files input nor any message carries one', async () => {
324+
const signals = buildAutoRoutingSignalsFor({
325+
messages: [{ role: 'user' as const, content: 'Summarize this text' }],
326+
})
327+
328+
expect(signals.mediaKind).toBe('none')
329+
})
330+
331+
it('detects media carried on inbound messages, not just the files input', async () => {
332+
const signals = buildAutoRoutingSignalsFor({
333+
messages: [{ role: 'user' as const, content: 'What is in this image?', files: [png] }],
334+
})
335+
336+
expect(signals.mediaKind).toBe('image')
337+
})
338+
339+
it('classifies an all-image attachment set as image', async () => {
340+
expect(buildAutoRoutingSignalsFor({ files: [png, png] }).mediaKind).toBe('image')
341+
})
342+
343+
it('classifies a mixed image + document set as file', async () => {
344+
expect(buildAutoRoutingSignalsFor({ files: [png, pdf] }).mediaKind).toBe('file')
345+
})
346+
347+
it('treats an unknown MIME type as file rather than assuming it is an image', async () => {
348+
expect(buildAutoRoutingSignalsFor({ files: [{ id: 'f3' }] }).mediaKind).toBe('file')
349+
})
350+
351+
it('leaves the reported model alone for an explicitly selected model', async () => {
352+
const result = (await handler.execute(mockContext, mockBlock, {
353+
model: 'gpt-4o',
354+
userPrompt: 'Hello!',
355+
apiKey: 'test-api-key',
356+
})) as { model: string }
357+
358+
expect(result.model).toBe('mock-model')
359+
})
360+
278361
it('should attach files to the last user message only', async () => {
279362
const inputs = {
280363
model: 'gpt-4o',

apps/sim/executor/handlers/agent/agent-handler.ts

Lines changed: 77 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -8,12 +8,17 @@ import { and, eq, inArray, isNull } from 'drizzle-orm'
88
import { normalizeStringRecord, normalizeWorkflowVariables } from '@/lib/core/utils/records'
99
import { createMcpToolId } from '@/lib/mcp/utils'
1010
import {
11+
type AutoMediaKind,
1112
type AutoRoutingResult,
1213
type AutoRoutingSignals,
1314
resolveAutoModel,
1415
SIM_AUTO_SYSTEM_PREAMBLE,
1516
} from '@/lib/model-router/resolve'
16-
import { processFilesToUserFiles, type RawFileInput } from '@/lib/uploads/utils/file-utils'
17+
import {
18+
MODEL_SUPPORTED_IMAGE_MIME_TYPES,
19+
processFilesToUserFiles,
20+
type RawFileInput,
21+
} from '@/lib/uploads/utils/file-utils'
1722
import { hydrateUserFilesWithBase64 } from '@/lib/uploads/utils/user-file-base64.server'
1823
import { resolveCustomBlockToolBinding } from '@/lib/workflows/custom-blocks/operations'
1924
import { getCustomToolById } from '@/lib/workflows/custom-tools/operations'
@@ -52,7 +57,7 @@ import {
5257
shouldUseLargeFilePath,
5358
supportsFileAttachments,
5459
} from '@/providers/attachments'
55-
import { isAutoModel } from '@/providers/models'
60+
import { isAutoModel, SIM_AUTO_MODEL_ID } from '@/providers/models'
5661
import { getProviderFromModel, transformBlockTool } from '@/providers/utils'
5762
import type { SerializedBlock } from '@/serializer/types'
5863
import { filterSchemaForLLM, type ToolSchema } from '@/tools/params'
@@ -166,6 +171,10 @@ export class AgentBlockHandler implements BlockHandler {
166171
this.applyRoutingCost(result as BlockOutput, autoRouting.billableRoutingCost)
167172
}
168173

174+
if (autoRouting) {
175+
this.applyAutoModelLabel(result, model)
176+
}
177+
169178
if (this.isStreamingExecution(result)) {
170179
if (filteredInputs.memoryType && filteredInputs.memoryType !== 'none') {
171180
return this.wrapStreamForMemoryPersistence(
@@ -208,14 +217,77 @@ export class AgentBlockHandler implements BlockHandler {
208217
lastMessage,
209218
messageCount: (inputs.messages?.length ?? 0) + (inputs.userPrompt ? 1 : 0),
210219
toolNames: (inputs.tools ?? []).map((t) => t.title || t.type || 'tool'),
211-
hasAttachments: Array.isArray(normalizedFiles)
212-
? normalizedFiles.length > 0
213-
: Boolean(normalizedFiles),
220+
mediaKind: this.resolveMediaKind(inputs, normalizedFiles),
214221
hasResponseFormat: Boolean(responseFormat),
215222
approxInputTokens: Math.ceil(approxChars / 4),
216223
}
217224
}
218225

226+
/**
227+
* Classifies what the block attaches, which decides the sim-auto pool column.
228+
*
229+
* Media reaches the provider by two routes — the block's `files` input and
230+
* files already carried on inbound messages (chat deployments, memory, an
231+
* upstream block feeding `messages`) — and both count. A file whose MIME type
232+
* is missing or unrecognized counts as `file`, the column served by the
233+
* providers that accept the most input types, because the alternative is
234+
* handing a document to a model whose API models no such content part.
235+
*/
236+
private resolveMediaKind(inputs: AgentInputs, normalizedFiles: unknown): AutoMediaKind {
237+
const attached: Array<{ type?: string }> = [
238+
...(Array.isArray(normalizedFiles) ? (normalizedFiles as Array<{ type?: string }>) : []),
239+
...(inputs.messages ?? []).flatMap((message) => message.files ?? []),
240+
]
241+
242+
if (attached.length === 0) return 'none'
243+
244+
return attached.every((file) =>
245+
MODEL_SUPPORTED_IMAGE_MIME_TYPES.has((file.type ?? '').toLowerCase())
246+
)
247+
? 'image'
248+
: 'file'
249+
}
250+
251+
/**
252+
* Reports a completed auto run under the `sim-auto` identity everywhere the
253+
* run is observed — the block output, the trace span, and the usage-ledger
254+
* row keyed on the model name — so the pool model that served the request
255+
* stays an implementation detail (matching the block's configured model and
256+
* the hidden identity preamble the models themselves run under).
257+
*
258+
* Runs after `executeProviderRequest`, whose billability gate and pricing
259+
* key on the concrete pool model: tokens and cost are already settled here,
260+
* only the label changes.
261+
*/
262+
private applyAutoModelLabel(
263+
result: BlockOutput | StreamingExecution,
264+
resolvedModel: string
265+
): void {
266+
const output = this.isStreamingExecution(result)
267+
? (result as StreamingExecution).execution?.output
268+
: (result as BlockOutput)
269+
if (!output || typeof output !== 'object') return
270+
271+
const target = output as {
272+
model?: string
273+
providerTiming?: {
274+
timeSegments?: Array<{ type?: string; name?: string; provider?: string }>
275+
}
276+
}
277+
target.model = SIM_AUTO_MODEL_ID
278+
279+
// Model segments name themselves after the model (every provider does) and
280+
// carry the serving provider, which the log detail renders as that
281+
// provider's icon — the same leak by two other routes.
282+
for (const segment of target.providerTiming?.timeSegments ?? []) {
283+
if (segment.type !== 'model') continue
284+
if (segment.name?.toLowerCase() === resolvedModel.toLowerCase()) {
285+
segment.name = SIM_AUTO_MODEL_ID
286+
}
287+
segment.provider = undefined
288+
}
289+
}
290+
219291
/**
220292
* Adds the billable sim-auto routing charge to a non-streaming output's
221293
* cost breakdown as a distinct `routing` component.

0 commit comments

Comments
 (0)