Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 8 additions & 15 deletions apps/sim/blocks/blocks/agent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import {
getModelCapabilityCondition,
getModelOptions,
getProviderCredentialSubBlocks,
getSerializedModelProviderId,
normalizeFileInput,
RESPONSE_FORMAT_WAND_CONFIG,
} from '@/blocks/utils'
Expand All @@ -29,6 +30,9 @@ import { useSubBlockStore } from '@/stores/workflows/subblock/store'
import type { ToolResponse } from '@/tools/types'

const logger = createLogger('AgentBlock')

/** Model the agent block falls back to when `model` is unset or the auto pseudo-model. */
const AGENT_FALLBACK_MODEL = 'claude-sonnet-5'
const MODELS_WITH_REASONING_EFFORT = getModelsWithReasoningEffort()
const MODELS_WITH_VERBOSITY = getModelsWithVerbosity()
const MODELS_WITH_THINKING = getModelsWithThinking()
Expand Down Expand Up @@ -521,21 +525,10 @@ Return ONLY the JSON array.`,
],
config: {
tool: (params: Record<string, any>) => {
const model = params.model || 'claude-sonnet-5'
if (!model) {
throw new Error('No model selected')
}
// 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}`)
}
return tool
const model = params.model || AGENT_FALLBACK_MODEL
// sim-auto has no provider of its own until the pool resolves it at execution time.
const lookupModel = isAutoModel(model) ? AGENT_FALLBACK_MODEL : model
return getSerializedModelProviderId(lookupModel, AGENT_FALLBACK_MODEL)
},
params: (params: Record<string, any>) => {
const normalizedFiles = normalizeFileInput(params.files)
Expand Down
15 changes: 2 additions & 13 deletions apps/sim/blocks/blocks/evaluator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,9 @@ import type { BlockConfig, ParamType } from '@/blocks/types'
import {
getModelOptions,
getProviderCredentialSubBlocks,
getSerializedModelProviderId,
PROVIDER_CREDENTIAL_INPUTS,
} from '@/blocks/utils'
import { getBaseModelProviders } from '@/providers/models'
import type { ProviderId } from '@/providers/types'
import type { ToolResponse } from '@/tools/types'

const logger = createLogger('EvaluatorBlock')
Expand Down Expand Up @@ -253,17 +252,7 @@ export const EvaluatorBlock: BlockConfig<EvaluatorResponse> = {
'deepseek_reasoner',
],
config: {
tool: (params: Record<string, any>) => {
const model = params.model || 'gpt-4o'
if (!model) {
throw new Error('No model selected')
}
const tool = getBaseModelProviders()[model as ProviderId]
if (!tool) {
throw new Error(`Invalid model selected: ${model}`)
}
return tool
},
tool: (params: Record<string, any>) => getSerializedModelProviderId(params.model),
},
},
inputs: {
Expand Down
27 changes: 3 additions & 24 deletions apps/sim/blocks/blocks/router.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,9 @@ import { AuthMode, type BlockConfig } from '@/blocks/types'
import {
getModelOptions,
getProviderCredentialSubBlocks,
getSerializedModelProviderId,
PROVIDER_CREDENTIAL_INPUTS,
} from '@/blocks/utils'
import { getBaseModelProviders } from '@/providers/models'
import type { ProviderId } from '@/providers/types'
import type { ToolResponse } from '@/tools/types'

interface RouterResponse extends ToolResponse {
Expand Down Expand Up @@ -215,17 +214,7 @@ export const RouterBlock: BlockConfig<RouterResponse> = {
'deepseek_reasoner',
],
config: {
tool: (params: Record<string, any>) => {
const model = params.model || 'gpt-4o'
if (!model) {
throw new Error('No model selected')
}
const tool = getBaseModelProviders()[model as ProviderId]
if (!tool) {
throw new Error(`Invalid model selected: ${model}`)
}
return tool
},
tool: (params: Record<string, any>) => getSerializedModelProviderId(params.model),
},
},
inputs: {
Expand Down Expand Up @@ -325,17 +314,7 @@ export const RouterV2Block: BlockConfig<RouterV2Response> = {
'deepseek_reasoner',
],
config: {
tool: (params: Record<string, any>) => {
const model = params.model || 'gpt-4o'
if (!model) {
throw new Error('No model selected')
}
const tool = getBaseModelProviders()[model as ProviderId]
if (!tool) {
throw new Error(`Invalid model selected: ${model}`)
}
return tool
},
tool: (params: Record<string, any>) => getSerializedModelProviderId(params.model),
},
},
inputs: {
Expand Down
45 changes: 45 additions & 0 deletions apps/sim/blocks/utils.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -72,11 +72,13 @@ import {
BUILT_IN_TOOL_TYPES,
getApiKeyCondition,
getDependsOnFields,
getSerializedModelProviderId,
getSubBlocksDependingOnChange,
parseOptionalBooleanInput,
parseOptionalJsonInput,
parseOptionalNumberInput,
} from '@/blocks/utils'
import { getProviderFromModel } from '@/providers/utils'

describe('BUILT_IN_TOOL_TYPES', () => {
it('classifies the current File block instead of the legacy File block', () => {
Expand Down Expand Up @@ -464,3 +466,46 @@ describe('getSubBlocksDependingOnChange', () => {
).toEqual(['projectId'])
})
})

describe('getSerializedModelProviderId', () => {
const resolver = vi.mocked(getProviderFromModel)

beforeEach(() => {
resolver.mockReset()
resolver.mockImplementation(((model: string) => {
if (model.startsWith('openrouter/')) return 'openrouter'
if (model === 'gpt-4o') return 'openai'
if (model === 'claude-sonnet-5') return 'anthropic'
throw new Error(`No provider found for model: ${model}`)
}) as unknown as typeof getProviderFromModel)
})

it('resolves a gateway model that the base model map deliberately omits', () => {
expect(getSerializedModelProviderId('openrouter/meta-llama/llama-4-maverick')).toBe(
'openrouter'
)
})

it('uses the fallback model when the model is still an unresolved reference', () => {
expect(getSerializedModelProviderId('openrouter/<variable.vllm>')).toBe('openai')
expect(resolver).not.toHaveBeenCalledWith('openrouter/<variable.vllm>')
})

it('honours a caller-supplied fallback model', () => {
expect(getSerializedModelProviderId(undefined, 'claude-sonnet-5')).toBe('anthropic')
})

it('never throws when the resolver rejects the model', () => {
expect(() => getSerializedModelProviderId('totally-unknown-model')).not.toThrow()
expect(getSerializedModelProviderId('totally-unknown-model')).toBe('openai')
})

it('never throws when the resolver rejects every model, including the fallback', () => {
resolver.mockImplementation((() => {
throw new Error('Provider "openai" is not available')
}) as unknown as typeof getProviderFromModel)

expect(() => getSerializedModelProviderId('gpt-4o')).not.toThrow()
expect(getSerializedModelProviderId('gpt-4o')).toBe('openai')
})
})
40 changes: 40 additions & 0 deletions apps/sim/blocks/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import {
SIM_AUTO_MODEL_ID,
} from '@/providers/models'
import { isPiSupportedModel } from '@/providers/pi-providers'
import type { ProviderId } from '@/providers/types'
import { getProviderFromModel } from '@/providers/utils'
import { useProvidersStore } from '@/stores/providers/store'

Expand Down Expand Up @@ -236,6 +237,45 @@ function shouldRequireApiKeyForModel(model: string): boolean {
return true
}

/** Model whose provider is recorded when a block's own `model` cannot be resolved. */
const SERIALIZATION_FALLBACK_MODEL = 'gpt-4o'

/** Last-resort provider for when even {@link SERIALIZATION_FALLBACK_MODEL} cannot be resolved. */
const SERIALIZATION_FALLBACK_PROVIDER: ProviderId = 'openai'

/**
* Provider id a model-driven block records for `model` during serialization.
*
* Serialization runs before variable resolution, and every model block's handler
* re-derives the provider from the *resolved* model without ever reading this
* value — so it only has to be shape-correct, and it must never throw. Two cases
* reach here that {@link getBaseModelProviders} cannot answer: `model` may still
* hold a `<variable.x>` reference, and gateway providers (OpenRouter, vLLM,
* LiteLLM, Ollama, …) are deliberately absent from that map even when the model
* id is perfectly valid. A reference resolves to {@link SERIALIZATION_FALLBACK_MODEL}'s
* provider; anything else is left to `getProviderFromModel`, which defaults an
* unrecognised id to `ollama` rather than failing serialization with an error the
* user cannot act on.
*
* The remaining throw is a blacklisted provider or model, which is env-driven and
* can name the fallback itself — so recovery returns
* {@link SERIALIZATION_FALLBACK_PROVIDER} outright rather than resolving a second
* time through the function that just threw.
*/
export function getSerializedModelProviderId(
model: unknown,
fallbackModel: string = SERIALIZATION_FALLBACK_MODEL
): ProviderId {
const candidate =
typeof model === 'string' && model && !containsReference(model) ? model : fallbackModel

try {
return getProviderFromModel(candidate)
} catch {
return SERIALIZATION_FALLBACK_PROVIDER
}
}

/**
* Visibility condition for a model-tuning field that only some models accept, such as
* reasoning effort or verbosity. Gates on the capability list, but keeps the field visible
Expand Down
9 changes: 9 additions & 0 deletions apps/sim/providers/utils.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -948,6 +948,15 @@ describe('Provider Management', () => {
expect(getProviderFromModel('unknown-model')).toBe('ollama')
})

it('should resolve gateway models that getBaseModelProviders deliberately omits', () => {
// getBaseModelProviders() filters these providers out entirely, so a model
// block that looked models up there rejected valid ids like these.
expect(getProviderFromModel('openrouter/meta-llama/llama-4-maverick')).toBe('openrouter')
expect(getProviderFromModel('together/some-model')).toBe('together')
expect(getProviderFromModel('fireworks/some-model')).toBe('fireworks')
expect(getBaseModelProviders()['openrouter/meta-llama/llama-4-maverick']).toBeUndefined()
})

it('should be case insensitive', () => {
expect(getProviderFromModel('GPT-4O')).toBe('openai')
expect(getProviderFromModel('CLAUDE-SONNET-4-0')).toBe('anthropic')
Expand Down
40 changes: 40 additions & 0 deletions packages/logger/src/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -263,6 +263,46 @@ describe('Logger', () => {
expect(parsed.self.self).toBe('[Circular]')
})

test('should render an Error held under a key as its message, not {}', () => {
const error = new Error('boom')

createEnabledLogger().error('failed', { error })

const parsed = JSON.parse(consoleErrorSpy.mock.calls[0][0] as string)
expect(parsed.error).toBe('boom')
expect(parsed.stack).toBe(error.stack)
})

test('should render an Error under a non-conventional key without hijacking stack', () => {
createEnabledLogger().error('failed', { cause: new Error('inner'), stack: 'caller-supplied' })

const parsed = JSON.parse(consoleErrorSpy.mock.calls[0][0] as string)
expect(parsed.cause).toBe('inner')
expect(parsed.stack).toBe('caller-supplied')
})

test('should keep sibling keys alongside an Error value', () => {
createEnabledLogger().error('failed', { error: new Error('boom'), toolId: 'slack_message' })

const parsed = JSON.parse(consoleErrorSpy.mock.calls[0][0] as string)
expect(parsed.error).toBe('boom')
expect(parsed.toolId).toBe('slack_message')
})

test('should unwrap an Error held under a key on the colorized path too', () => {
const colorized = new Logger('Test', {
enabled: true,
colorize: true,
logLevel: LogLevel.DEBUG,
})

colorized.error('failed', { error: new Error('boom') })

const printed = consoleErrorSpy.mock.calls[0].join(' ')
expect(printed).toContain('boom')
expect(printed).not.toContain('"error":{}')
})

test('should emit a line instead of throwing on BigInt metadata', () => {
expect(() => createEnabledLogger().error('boom', { size: 10n })).not.toThrow()
expect(consoleErrorSpy).toHaveBeenCalledTimes(1)
Expand Down
68 changes: 57 additions & 11 deletions packages/logger/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -130,39 +130,85 @@ const getLogConfig = () => {
}
}

/**
* Renders an error as the plain object `JSON.stringify` cannot produce for it.
*
* `message`, `stack` and `name` are non-enumerable on `Error.prototype`, so a
* plain stringify emits `{}`. Own enumerable properties are copied too — driver
* and HTTP errors carry the useful part (`code`, `status`) there.
*/
const errorToPlainObject = (error: Error, isDev: boolean): Record<string, unknown> => {
const errorObj: Record<string, unknown> = {
message: error.message,
stack: isDev ? error.stack : undefined,
name: error.name,
}
for (const key of Object.keys(error)) {
if (!(key in errorObj)) {
errorObj[key] = (error as unknown as Record<string, unknown>)[key]
}
}
return errorObj
}

/**
* Format objects for logging
*
* Errors held under a key are unwrapped as well as bare ones — `{ error }` is
* the common call shape, and it would otherwise print as `{"error":{}}`.
*/
const formatObject = (obj: unknown, isDev: boolean): string => {
try {
if (obj instanceof Error) {
const errorObj: Record<string, unknown> = {
message: obj.message,
stack: isDev ? obj.stack : undefined,
name: obj.name,
return JSON.stringify(errorToPlainObject(obj, isDev), null, isDev ? 2 : 0)
}
if (obj && typeof obj === 'object' && !Array.isArray(obj)) {
let unwrapped: Record<string, unknown> | undefined
for (const [key, value] of Object.entries(obj as Record<string, unknown>)) {
if (!(value instanceof Error)) continue
unwrapped ??= { ...(obj as Record<string, unknown>) }
unwrapped[key] = errorToPlainObject(value, isDev)
}
for (const key of Object.keys(obj)) {
if (!(key in errorObj)) {
errorObj[key] = (obj as unknown as Record<string, unknown>)[key]
}
if (unwrapped) {
return JSON.stringify(unwrapped, null, isDev ? 2 : 0)
}
return JSON.stringify(errorObj, null, isDev ? 2 : 0)
}
return JSON.stringify(obj, null, isDev ? 2 : 0)
} catch {
return '[Circular or Non-Serializable Object]'
}
}

/** Merges caller-supplied log arguments into the structured entry. */
/**
* Merges caller-supplied log arguments into the structured entry.
*
* `Error.message` and `Error.stack` are non-enumerable, so `JSON.stringify`
* renders an error held under a key as `{}` — and `logger.x('...', { error })`
* is by far the most common call shape, which would otherwise reduce the one
* field worth reading to an empty object. Errors nested in an object argument
* are therefore unwrapped like a bare `Error` argument. `error` stays a plain
* message string so log queries can group on it; richer diagnostics are opt-in
* via `describeError` from `@sim/utils/errors`.
*/
const mergeArgs = (entry: Record<string, unknown>, args: unknown[]): Record<string, unknown> => {
for (const arg of args) {
if (arg === null || arg === undefined) continue
if (arg instanceof Error) {
entry.error = arg.message
entry.stack = arg.stack
} else if (typeof arg === 'object') {
Object.assign(entry, arg)
const source = arg as Record<string, unknown>
for (const key of Object.keys(source)) {
const value = source[key]
if (value instanceof Error) {
entry[key] = value.message
if (key === 'error' && entry.stack === undefined) {
entry.stack = value.stack
}
} else {
entry[key] = value
}
}
} else {
entry.extra = arg
}
Expand Down
Loading