diff --git a/apps/docs/content/docs/workflows/blocks/agent.mdx b/apps/docs/content/docs/workflows/blocks/agent.mdx index b880916cd78..dde3a58689c 100644 --- a/apps/docs/content/docs/workflows/blocks/agent.mdx +++ b/apps/docs/content/docs/workflows/blocks/agent.mdx @@ -27,6 +27,10 @@ Answer in two sentences, cite the doc you used, and never guess a price. The model that runs the step. Defaults to `claude-sonnet-4-6`. Type or pick any model from OpenAI, Anthropic, Google, xAI, Groq, Cerebras, DeepSeek, Azure, AWS Bedrock, Google Vertex, or OpenRouter, or a local model through Ollama or VLLM. +For a custom cloud deployment, enter its provider prefix and model ID: `azure/my-deployment`, `azure-anthropic/my-deployment`, `bedrock/my-inference-profile`, or `vertex/my-gemini-model`. The prefix selects the provider and shows its credential fields even when the ID is absent from the catalog. Bedrock accepts full inference profile ARNs after `bedrock/`; Vertex uses the Gemini API and accepts Google model resource names. The deployment must support the selected provider's API. Custom IDs have no catalog pricing or token limits. + +Ollama Cloud, OpenRouter, Fireworks, Together AI, Baseten, Ollama, vLLM, and LiteLLM load their available models from the configured provider. New models appear through that discovery without a Sim catalog release. You can also enter a namespaced ID directly, such as `ollama-cloud/deepseek-v4.1-flash`, `openrouter/provider/model`, or `ollama/my-local-model`. Provider prefixes are case-insensitive; the model ID after the prefix keeps its original casing. + ### Files Files for the model to read: images for a vision-capable model, or documents for text. Upload them on the block, or pass a file from an earlier block, such as an upload trigger or an [API](/workflows/blocks/api) response, with a connection tag. @@ -110,18 +114,19 @@ Live tool-call chips stream for **OpenAI, Anthropic, Azure Anthropic, Google, Ve | Provider | Streamed thinking | Models | |----------|-------------------|--------| -| OpenAI | Summaries only — Requires OpenAI organization verification; falls back to no summaries. | `gpt-6-astra`, `gpt-5.6-sol`, `gpt-5.6-terra`, `gpt-5.6-luna`, `gpt-5.5-pro`, `gpt-5.5`, `gpt-5.4-pro`, `gpt-5.4`, `gpt-5.4-mini`, `gpt-5.4-nano`, `gpt-5.2-pro`, `gpt-5.2`, `gpt-5.1`, `gpt-5-pro`, `gpt-5`, `gpt-5-mini`, `gpt-5-nano`, `o4-mini`, `o3`, `o3-mini`, `o1` | +| OpenAI | Summaries only — Requires OpenAI organization verification; falls back to no summaries. | `gpt-6-astra`, `gpt-5.6-sol`, `gpt-5.6-terra`, `gpt-5.6-luna`, `gpt-5.5-pro`, `gpt-5.5`, `gpt-5.4-pro`, `gpt-5.4`, `gpt-5.4-mini`, `gpt-5.4-nano`, `gpt-5.3-codex`, `gpt-5.2-pro`, `gpt-5.2`, `gpt-5.1`, `gpt-5-pro`, `gpt-5`, `gpt-5-mini`, `gpt-5-nano`, `o4-mini`, `o3`, `o3-mini`, `o1` | | Anthropic | Summaries only — These generations omit full thinking; Sim requests summarized thinking on streaming runs. | `claude-fable-5-1`, `claude-fable-5`, `claude-sonnet-5`, `claude-opus-5`, `claude-opus-4-8`, `claude-opus-4-7`, `claude-opus-4-6`, `claude-sonnet-4-6`, `claude-opus-4-5`, `claude-sonnet-4-5`, `claude-haiku-4-5` | -| Azure OpenAI | Summaries only — Requires OpenAI organization verification; falls back to no summaries. | `azure/gpt-5.4`, `azure/gpt-5.4-mini`, `azure/gpt-5.4-nano`, `azure/gpt-5.2`, `azure/gpt-5.1`, `azure/gpt-5.1-codex`, `azure/gpt-5`, `azure/gpt-5-mini`, `azure/gpt-5-nano`, `azure/o3`, `azure/o4-mini` | -| Azure Anthropic | Summaries only — These generations omit full thinking; Sim requests summarized thinking on streaming runs. | `azure-anthropic/claude-opus-4-6`, `azure-anthropic/claude-opus-4-5`, `azure-anthropic/claude-sonnet-4-5`, `azure-anthropic/claude-opus-4-1`, `azure-anthropic/claude-haiku-4-5` | -| Google | Summaries only | `gemini-3.8-flash`, `gemini-3.6-flash`, `gemini-3.5-flash-lite`, `gemini-3.5-flash`, `gemini-3.1-pro-preview`, `gemini-3.1-flash-lite`, `gemini-3-flash-preview`, `gemini-2.5-pro`, `gemini-2.5-flash`, `gemini-2.5-flash-lite` | -| Vertex AI | Summaries only | `vertex/gemini-3.5-flash`, `vertex/gemini-3.1-pro-preview`, `vertex/gemini-3.1-flash-lite`, `vertex/gemini-3-flash-preview`, `vertex/gemini-2.5-pro`, `vertex/gemini-2.5-flash`, `vertex/gemini-2.5-flash-lite` | -| DeepSeek | Full thinking deltas | `deepseek-v4-pro`, `deepseek-v4-flash`, `deepseek-reasoner` | +| Azure OpenAI | Summaries only — Requires OpenAI organization verification; falls back to no summaries. | `azure/gpt-6-astra`, `azure/gpt-5.6-sol`, `azure/gpt-5.6-terra`, `azure/gpt-5.6-luna`, `azure/gpt-5.5`, `azure/gpt-5.4-pro`, `azure/gpt-5.4`, `azure/gpt-5.4-mini`, `azure/gpt-5.4-nano`, `azure/gpt-5.2`, `azure/gpt-5.1`, `azure/gpt-5.1-codex`, `azure/gpt-5`, `azure/gpt-5-mini`, `azure/gpt-5-nano`, `azure/o3`, `azure/o4-mini` | +| Azure Anthropic | Summaries only — These generations omit full thinking; Sim requests summarized thinking on streaming runs. | `azure-anthropic/claude-fable-5-1`, `azure-anthropic/claude-opus-5`, `azure-anthropic/claude-opus-4-8`, `azure-anthropic/claude-opus-4-7`, `azure-anthropic/claude-opus-4-6`, `azure-anthropic/claude-opus-4-5`, `azure-anthropic/claude-sonnet-5`, `azure-anthropic/claude-sonnet-4-6`, `azure-anthropic/claude-sonnet-4-5`, `azure-anthropic/claude-opus-4-1`, `azure-anthropic/claude-haiku-4-5` | +| Google | Summaries only | `gemini-3.8-flash`, `gemini-3.7-flash`, `gemini-3.6-flash`, `gemini-3.5-flash-lite`, `gemini-3.5-flash`, `gemini-3.1-pro-preview`, `gemini-3.1-flash-lite`, `gemini-3-flash-preview`, `gemini-2.5-pro`, `gemini-2.5-flash`, `gemini-2.5-flash-lite` | +| Vertex AI | Summaries only | `vertex/gemini-3.8-flash`, `vertex/gemini-3.7-flash`, `vertex/gemini-3.6-flash`, `vertex/gemini-3.5-flash-lite`, `vertex/gemini-3.5-flash`, `vertex/gemini-3.1-pro-preview`, `vertex/gemini-3.1-flash-lite`, `vertex/gemini-3-flash-preview`, `vertex/gemini-2.5-pro`, `vertex/gemini-2.5-flash`, `vertex/gemini-2.5-flash-lite` | +| DeepSeek | Full thinking deltas | `deepseek-v4-pro`, `deepseek-flash`, `deepseek-v4-flash`, `deepseek-reasoner` | | xAI | Full thinking deltas | `grok-4.6`, `grok-4.5`, `grok-4.3`, `grok-4.20-multi-agent-0309` | -| Groq | Full thinking deltas | `groq/openai/gpt-oss-120b`, `groq/openai/gpt-oss-20b`, `groq/openai/gpt-oss-safeguard-20b`, `groq/qwen/qwen3.6-27b` | +| Cerebras | Full thinking deltas | `cerebras/qwen-3.8-27b` | +| Groq | Full thinking deltas | `groq/openai/gpt-oss-120b`, `groq/openai/gpt-oss-20b`, `groq/openai/gpt-oss-safeguard-20b`, `groq/qwen/qwen3.8-27b`, `groq/qwen/qwen3.6-27b` | | Meta | Not streamed | `muse-spark-1.3`, `muse-spark-1.1` | -| Kimi | Full thinking deltas | `kimi-k2.6` | -| Z.ai | Full thinking deltas | `glm-5.3`, `glm-5.2`, `glm-5.1`, `glm-5`, `glm-5-turbo`, `glm-4.7`, `glm-4.6`, `glm-4.5`, `glm-4.5-air` | +| Kimi | Full thinking deltas | `kimi-k3`, `kimi-k2.6` | +| Z.ai | Full thinking deltas | `glm-5.3`, `glm-5.3-flash`, `glm-5.2`, `glm-5.1`, `glm-5`, `glm-5-turbo`, `glm-4.7`, `glm-4.6`, `glm-4.5`, `glm-4.5-air` | {/* agent-stream-capabilities:end */} diff --git a/apps/sim/app/api/providers/ollama-cloud/models/route.test.ts b/apps/sim/app/api/providers/ollama-cloud/models/route.test.ts index a4b4cbfedda..3c6d2537159 100644 --- a/apps/sim/app/api/providers/ollama-cloud/models/route.test.ts +++ b/apps/sim/app/api/providers/ollama-cloud/models/route.test.ts @@ -144,6 +144,29 @@ describe('GET /api/providers/ollama-cloud/models', () => { expect(fetchAuthHeader()).toBe('Bearer byok-ollama-key') }) + it('discovers newly available models on subsequent requests without a static catalog update', async () => { + grantWorkspaceAccess() + mockGetBYOKKey.mockResolvedValue({ apiKey: 'byok-ollama-key' }) + mockFetch + .mockResolvedValueOnce(okResponse({ models: [{ name: 'kimi-k3' }] })) + .mockResolvedValueOnce( + okResponse({ + models: [{ name: 'kimi-k3' }, { name: 'deepseek-v4.1-flash' }, { name: 'glm-5.3' }], + }) + ) + + const first = await GET(requestWithWorkspace('ws-1')) + expect(await first.json()).toEqual({ models: ['ollama-cloud/kimi-k3'] }) + const refreshed = await GET(requestWithWorkspace('ws-1')) + expect(await refreshed.json()).toEqual({ + models: ['ollama-cloud/kimi-k3', 'ollama-cloud/deepseek-v4.1-flash', 'ollama-cloud/glm-5.3'], + }) + expect(mockFetch).toHaveBeenLastCalledWith( + OLLAMA_CLOUD_TAGS_URL, + expect.objectContaining({ cache: 'no-store' }) + ) + }) + it('does not call getBYOKKey when there is a workspaceId but no session', async () => { mockGetSession.mockResolvedValue(null) diff --git a/apps/sim/blocks/provider-credentials.test.ts b/apps/sim/blocks/provider-credentials.test.ts new file mode 100644 index 00000000000..dcaaee7aceb --- /dev/null +++ b/apps/sim/blocks/provider-credentials.test.ts @@ -0,0 +1,34 @@ +/** + * @vitest-environment node + */ +import { describe, expect, it } from 'vitest' +import { evaluateSubBlockCondition } from '@/lib/workflows/subblocks/visibility' +import { getProviderCredentialSubBlocks } from '@/blocks/utils' + +describe('provider credential visibility', () => { + const subBlocks = getProviderCredentialSubBlocks().filter(({ id }) => id !== 'apiKey') + + it.each([ + ['azure/MyDeployment', ['azureEndpoint', 'azureApiVersion']], + ['AZURE/MyDeployment', ['azureEndpoint', 'azureApiVersion']], + ['azure-anthropic/MyDeployment', ['azureEndpoint', 'azureApiVersion']], + ['bedrock/custom-profile', ['bedrockAccessKeyId', 'bedrockSecretKey', 'bedrockRegion']], + [ + 'vertex/publishers/google/models/custom-gemini', + ['vertexCredential', 'vertexManualCredential', 'vertexProject', 'vertexLocation'], + ], + [ + 'VERTEX/CustomModel', + ['vertexCredential', 'vertexManualCredential', 'vertexProject', 'vertexLocation'], + ], + ['gpt-4o', []], + ['unknown/model', []], + ['', []], + ])('shows only the routed provider credentials for %s', (model, expected) => { + const visible = subBlocks + .filter((subBlock) => evaluateSubBlockCondition(subBlock.condition, { model })) + .map(({ id }) => id) + + expect(visible).toEqual(expected) + }) +}) diff --git a/apps/sim/blocks/utils.test.ts b/apps/sim/blocks/utils.test.ts index 1cd89fa4229..c260de06e87 100644 --- a/apps/sim/blocks/utils.test.ts +++ b/apps/sim/blocks/utils.test.ts @@ -31,7 +31,8 @@ const { mockProviders } = vi.hoisted(() => ({ }, })) -vi.mock('@/providers/models', () => ({ +vi.mock('@/providers/models', async (importOriginal) => ({ + ...(await importOriginal()), getProviderFileAttachment: vi .fn() .mockReturnValue({ maxBytes: 10 * 1024 * 1024, strategy: 'inline' }), @@ -180,6 +181,16 @@ describe('getApiKeyCondition / shouldRequireApiKeyForModel', () => { }) describe('provider store lookup (client-side)', () => { + it('requires the cloud key even when a local discovered name uses its namespace', () => { + mockProviders.value.ollama.models = ['azure/MyDeployment', 'ollama-cloud/MyModel'] + expect(evaluateCondition('azure/MyDeployment')).toBe(true) + expect(evaluateCondition('ollama-cloud/MyModel')).toBe(true) + }) + + it('does not require an API key for an undiscovered namespaced Ollama model', () => { + expect(evaluateCondition('OLLAMA/Org/CustomModel')).toBe(false) + }) + it('does not require API key when model is in the Ollama store bucket', () => { mockProviders.value.ollama.models = ['llama3:latest', 'mistral:latest'] expect(evaluateCondition('llama3:latest')).toBe(false) diff --git a/apps/sim/blocks/utils.ts b/apps/sim/blocks/utils.ts index dca2389b503..bc81c39cc2a 100644 --- a/apps/sim/blocks/utils.ts +++ b/apps/sim/blocks/utils.ts @@ -6,12 +6,14 @@ import { getScopesForService } from '@/lib/oauth/utils' import { containsReference } from '@/lib/workflows/sanitization/references' import type { SubBlockConfig } from '@/blocks/types' import { + findProviderFromModel, getBaseModelProviders, getHostedModels, getModelSunsetStatus, getProviderIcon, getProviderModels, isAutoModel, + isCustomModelId, orderModelIdsByReleaseDate, SIM_AUTO_MODEL_ID, } from '@/providers/models' @@ -20,8 +22,6 @@ import type { ProviderId } from '@/providers/types' import { getProviderFromModel } from '@/providers/utils' import { useProvidersStore } from '@/stores/providers/store' -export const VERTEX_MODELS = getProviderModels('vertex') -export const BEDROCK_MODELS = getProviderModels('bedrock') export const AZURE_MODELS = [ ...getProviderModels('azure-openai'), ...getProviderModels('azure-anthropic'), @@ -164,10 +164,16 @@ function shouldRequireApiKeyForModel(model: string): boolean { ) { return false } - if (normalizedModel.startsWith('vllm/') || normalizedModel.startsWith('litellm/')) { + if ( + normalizedModel.startsWith('ollama/') || + normalizedModel.startsWith('vllm/') || + normalizedModel.startsWith('litellm/') + ) { return false } + if (isCustomModelId(normalizedModel)) return true + const storeProvider = getProviderFromStore(normalizedModel) if (storeProvider === 'ollama' || storeProvider === 'vllm' || storeProvider === 'litellm') return false @@ -272,6 +278,14 @@ export function getCohereRerankerApiKeyCondition() { } } +function getModelProviderCondition(...providerIds: ProviderId[]) { + return (values?: Record) => { + const model = typeof values?.model === 'string' ? values.model : '' + const provider = findProviderFromModel(model.trim()) + return buildModelVisibilityCondition(model, provider !== null && providerIds.includes(provider)) + } +} + /** * Returns the standard provider credential subblocks used by LLM-based blocks. * This includes: Vertex AI OAuth, API Key, Azure (OpenAI + Anthropic), Vertex AI config, and Bedrock config. @@ -290,10 +304,7 @@ export function getProviderCredentialSubBlocks(): SubBlockConfig[] { requiredScopes: getScopesForService('vertex-ai'), placeholder: 'Select Google Cloud account', required: true, - condition: { - field: 'model', - value: VERTEX_MODELS, - }, + condition: getModelProviderCondition('vertex'), }, { id: 'vertexManualCredential', @@ -303,10 +314,7 @@ export function getProviderCredentialSubBlocks(): SubBlockConfig[] { mode: 'advanced', placeholder: 'Enter credential ID', required: true, - condition: { - field: 'model', - value: VERTEX_MODELS, - }, + condition: getModelProviderCondition('vertex'), }, { id: 'apiKey', @@ -326,10 +334,7 @@ export function getProviderCredentialSubBlocks(): SubBlockConfig[] { placeholder: 'https://your-resource.services.ai.azure.com', connectionDroppable: false, hideWhenEnvSet: 'NEXT_PUBLIC_AZURE_CONFIGURED', - condition: { - field: 'model', - value: AZURE_MODELS, - }, + condition: getModelProviderCondition('azure-openai', 'azure-anthropic'), }, { id: 'azureApiVersion', @@ -338,10 +343,7 @@ export function getProviderCredentialSubBlocks(): SubBlockConfig[] { placeholder: 'Enter API version', connectionDroppable: false, hideWhenEnvSet: 'NEXT_PUBLIC_AZURE_CONFIGURED', - condition: { - field: 'model', - value: AZURE_MODELS, - }, + condition: getModelProviderCondition('azure-openai', 'azure-anthropic'), }, { id: 'vertexProject', @@ -351,10 +353,7 @@ export function getProviderCredentialSubBlocks(): SubBlockConfig[] { placeholder: 'your-gcp-project-id', connectionDroppable: false, required: true, - condition: { - field: 'model', - value: VERTEX_MODELS, - }, + condition: getModelProviderCondition('vertex'), }, { id: 'vertexLocation', @@ -363,10 +362,7 @@ export function getProviderCredentialSubBlocks(): SubBlockConfig[] { placeholder: 'us-central1', connectionDroppable: false, required: true, - condition: { - field: 'model', - value: VERTEX_MODELS, - }, + condition: getModelProviderCondition('vertex'), }, { id: 'bedrockAccessKeyId', @@ -377,10 +373,7 @@ export function getProviderCredentialSubBlocks(): SubBlockConfig[] { connectionDroppable: false, required: true, hideWhenEnvSet: 'NEXT_PUBLIC_BEDROCK_DEFAULT_CREDENTIALS', - condition: { - field: 'model', - value: BEDROCK_MODELS, - }, + condition: getModelProviderCondition('bedrock'), }, { id: 'bedrockSecretKey', @@ -391,10 +384,7 @@ export function getProviderCredentialSubBlocks(): SubBlockConfig[] { connectionDroppable: false, required: true, hideWhenEnvSet: 'NEXT_PUBLIC_BEDROCK_DEFAULT_CREDENTIALS', - condition: { - field: 'model', - value: BEDROCK_MODELS, - }, + condition: getModelProviderCondition('bedrock'), }, { id: 'bedrockRegion', @@ -402,10 +392,7 @@ export function getProviderCredentialSubBlocks(): SubBlockConfig[] { type: 'short-input', placeholder: 'us-east-1', connectionDroppable: false, - condition: { - field: 'model', - value: BEDROCK_MODELS, - }, + condition: getModelProviderCondition('bedrock'), }, ] } diff --git a/apps/sim/lib/api-key/byok.test.ts b/apps/sim/lib/api-key/byok.test.ts index abba750dbac..5a905237cf2 100644 --- a/apps/sim/lib/api-key/byok.test.ts +++ b/apps/sim/lib/api-key/byok.test.ts @@ -451,6 +451,99 @@ describe('getBYOKKey', () => { }) }) +describe('getApiKeyWithBYOK provider classification', () => { + const dynamicProviders = [ + 'ollama', + 'vllm', + 'litellm', + 'fireworks', + 'together', + 'baseten', + 'ollama-cloud', + ] as const + + beforeEach(() => { + vi.clearAllMocks() + resetDbChainMock() + mockIsHosted.value = true + mockEnv.AZURE_OPENAI_API_KEY = 'azure-env-key' + mockEnv.AZURE_ANTHROPIC_API_KEY = 'azure-anthropic-env-key' + mockEnv.VLLM_API_KEY = 'vllm-env-key' + mockEnv.LITELLM_API_KEY = 'litellm-env-key' + dbChainMockFns.orderBy.mockResolvedValue([storedKey('other-provider-key')]) + mockDecryptSecret.mockImplementation(async (encrypted: string) => ({ + decrypted: encrypted.replace('encrypted-', 'decrypted-'), + })) + }) + + it.each(dynamicProviders)( + 'keeps Azure credentials when %s discovery contains the same model ID', + async (discoveredProvider) => { + const model = 'AZURE/CustomDeployment' + vi.mocked(useProvidersStore.getState).mockReturnValue({ + providers: Object.fromEntries( + dynamicProviders.map((provider) => [ + provider, + { models: provider === discoveredProvider ? [model] : [] }, + ]) + ), + } as ReturnType) + + const result = await getApiKeyWithBYOK('azure-openai', model, uniqueWorkspaceId()) + + expect(result).toEqual({ apiKey: 'azure-env-key', isBYOK: false }) + expect(dbChainMockFns.where).not.toHaveBeenCalled() + expect(mockGetRotatingApiKey).not.toHaveBeenCalled() + } + ) + + it.each([ + ['vertex', 'vertex/CustomDeployment', 'vertex-access-token'], + ['azure-anthropic', 'azure-anthropic/CustomDeployment', 'azure-anthropic-user-key'], + ])( + 'retains caller credentials for %s despite a local model name collision', + async (provider, model, apiKey) => { + vi.mocked(useProvidersStore.getState).mockReturnValue({ + providers: Object.fromEntries(dynamicProviders.map((name) => [name, { models: [model] }])), + } as ReturnType) + + expect(await getApiKeyWithBYOK(provider, model, uniqueWorkspaceId(), apiKey)).toEqual({ + apiKey, + isBYOK: false, + }) + expect(dbChainMockFns.where).not.toHaveBeenCalled() + } + ) + + it.each([ + ['ollama', 'empty'], + ['vllm', 'vllm-env-key'], + ['litellm', 'litellm-env-key'], + ])('preserves %s authentication for a custom unprefixed model', async (provider, apiKey) => { + expect(await getApiKeyWithBYOK(provider, 'MyCustomModel', uniqueWorkspaceId())).toEqual({ + apiKey, + isBYOK: false, + }) + expect(dbChainMockFns.where).not.toHaveBeenCalled() + }) + + it.each(['vllm', 'litellm'])( + 'prefers a caller key to the configured %s key for a local model', + async (provider) => { + expect( + await getApiKeyWithBYOK(provider, 'MyCustomModel', uniqueWorkspaceId(), 'caller-key') + ).toEqual({ apiKey: 'caller-key', isBYOK: false }) + } + ) + + it('uses Bedrock credentials for an uncataloged inference profile', async () => { + expect( + await getApiKeyWithBYOK('bedrock', 'BEDROCK/MyInferenceProfile', uniqueWorkspaceId()) + ).toEqual({ apiKey: 'placeholder', isBYOK: false }) + expect(dbChainMockFns.where).not.toHaveBeenCalled() + }) +}) + describe('getApiKeyWithBYOK for Fireworks', () => { const HOSTED_POOL_MODEL = 'fireworks/glm-5.2' diff --git a/apps/sim/lib/api-key/byok.ts b/apps/sim/lib/api-key/byok.ts index 1bacd8af607..a9ce7d43e08 100644 --- a/apps/sim/lib/api-key/byok.ts +++ b/apps/sim/lib/api-key/byok.ts @@ -10,7 +10,6 @@ import { isHosted } from '@/lib/core/config/env-flags' import { decryptSecret } from '@/lib/core/security/encryption' import { getHostedModels } from '@/providers/models' import { PROVIDER_PLACEHOLDER_KEY } from '@/providers/utils' -import { useProvidersStore } from '@/stores/providers/store' import type { BYOKProviderId } from '@/tools/types' const logger = createLogger('BYOKKeys') @@ -183,6 +182,9 @@ export async function getBYOKKey( } /** + * Resolves credentials for the provider already selected by model routing. + * Discovery lists must not override that provider and select a different key pool. + * * `scope` is present only when the key came from a stored BYOK pool; a * Sim-hosted, env, or caller-supplied key has no scope. Declared rather than * dropped so the returned type matches what a BYOK branch actually hands back. @@ -193,28 +195,19 @@ export async function getApiKeyWithBYOK( workspaceId: string | undefined | null, userProvidedKey?: string ): Promise<{ apiKey: string; isBYOK: boolean; scope?: BYOKKeyScopeName }> { - const isOllamaModel = - provider === 'ollama' || useProvidersStore.getState().providers.ollama.models.includes(model) - if (isOllamaModel) { + if (provider === 'ollama') { return { apiKey: 'empty', isBYOK: false } } - const isVllmModel = - provider === 'vllm' || useProvidersStore.getState().providers.vllm.models.includes(model) - if (isVllmModel) { + if (provider === 'vllm') { return { apiKey: userProvidedKey || env.VLLM_API_KEY || 'empty', isBYOK: false } } - const isLitellmModel = - provider === 'litellm' || useProvidersStore.getState().providers.litellm.models.includes(model) - if (isLitellmModel) { + if (provider === 'litellm') { return { apiKey: userProvidedKey || env.LITELLM_API_KEY || 'empty', isBYOK: false } } - const isFireworksModel = - provider === 'fireworks' || - useProvidersStore.getState().providers.fireworks.models.includes(model) - if (isFireworksModel) { + if (provider === 'fireworks') { if (workspaceId) { const byokResult = await getBYOKKey(workspaceId, 'fireworks') if (byokResult) { @@ -260,10 +253,7 @@ export async function getApiKeyWithBYOK( throw new Error(`API key is required for Fireworks ${model}`) } - const isTogetherModel = - provider === 'together' || - useProvidersStore.getState().providers.together.models.includes(model) - if (isTogetherModel) { + if (provider === 'together') { if (workspaceId) { const byokResult = await getBYOKKey(workspaceId, 'together') if (byokResult) { @@ -284,9 +274,7 @@ export async function getApiKeyWithBYOK( throw new Error(`API key is required for Together AI ${model}`) } - const isBasetenModel = - provider === 'baseten' || useProvidersStore.getState().providers.baseten.models.includes(model) - if (isBasetenModel) { + if (provider === 'baseten') { if (workspaceId) { const byokResult = await getBYOKKey(workspaceId, 'baseten') if (byokResult) { @@ -303,10 +291,7 @@ export async function getApiKeyWithBYOK( throw new Error(`API key is required for Baseten ${model}`) } - const isOllamaCloudModel = - provider === 'ollama-cloud' || - useProvidersStore.getState().providers['ollama-cloud'].models.includes(model) - if (isOllamaCloudModel) { + if (provider === 'ollama-cloud') { if (workspaceId) { const byokResult = await getBYOKKey(workspaceId, 'ollama-cloud') if (byokResult) { @@ -324,8 +309,7 @@ export async function getApiKeyWithBYOK( throw new Error(`API key is required for Ollama Cloud ${model}`) } - const isBedrockModel = provider === 'bedrock' || model.startsWith('bedrock/') - if (isBedrockModel) { + if (provider === 'bedrock') { return { apiKey: PROVIDER_PLACEHOLDER_KEY, isBYOK: false } } diff --git a/apps/sim/lib/core/security/input-validation.test.ts b/apps/sim/lib/core/security/input-validation.test.ts index d86b7d87d49..7c06184c525 100644 --- a/apps/sim/lib/core/security/input-validation.test.ts +++ b/apps/sim/lib/core/security/input-validation.test.ts @@ -1402,6 +1402,8 @@ describe('validateGoogleCloudLocation', () => { 'africa-south1', 'me-central2', 'global', + 'us', + 'eu', ])('should accept %s', (location) => { const result = validateGoogleCloudLocation(location) expect(result.isValid).toBe(true) diff --git a/apps/sim/lib/core/security/input-validation.ts b/apps/sim/lib/core/security/input-validation.ts index 9aeb1b6eb19..4a1e460d19f 100644 --- a/apps/sim/lib/core/security/input-validation.ts +++ b/apps/sim/lib/core/security/input-validation.ts @@ -661,7 +661,7 @@ export function validateAwsRegion( * and relocate the request — along with any attached credential — to an * attacker-controlled host. * - * Accepts `global` plus the documented `{geography}-{direction}{index}` region + * Accepts `global`, the `us` and `eu` multi-regions, and the `{geography}-{direction}{index}` region * form (e.g. us-central1, europe-west4, northamerica-northeast1, me-central2). * * @param value - The location to validate @@ -677,7 +677,7 @@ export function validateGoogleCloudLocation( } const googleLocationPattern = - /^(global|(africa|asia|australia|europe|me|northamerica|southamerica|us)-(central|east|north|northeast|northwest|south|southeast|southwest|west)\d{1,2})$/ + /^(global|us|eu|(africa|asia|australia|europe|me|northamerica|southamerica|us)-(central|east|north|northeast|northwest|south|southeast|southwest|west)\d{1,2})$/ if (!googleLocationPattern.test(value)) { logger.warn('Invalid Google Cloud location format', { diff --git a/apps/sim/lib/workflows/editing/validation.test.ts b/apps/sim/lib/workflows/editing/validation.test.ts index bd1a65a2d89..1f192cb1721 100644 --- a/apps/sim/lib/workflows/editing/validation.test.ts +++ b/apps/sim/lib/workflows/editing/validation.test.ts @@ -493,6 +493,40 @@ describe('validateInputsForBlock', () => { expect(result.validInputs.model).toBe('ollama/my-private-model') }) + it.each([ + 'azure/MyDeployment', + 'AZURE/MyDeployment', + 'azure-anthropic/MyDeployment', + 'bedrock/custom-inference-profile', + 'vertex/publishers/google/models/custom-gemini', + 'GROQ/Org/CustomModel', + 'CEREBRAS/CustomModel', + 'NVIDIA/CustomModel', + ])('accepts a custom cloud model ID: %s', (model) => { + for (const blockType of ['agent', 'router_v2']) { + const result = validateInputsForBlock(blockType, { model: ` ${model} ` }, 'block-1') + expect(result.errors).toEqual([]) + expect(result.validInputs.model).toBe(model) + } + }) + + it.each([ + 'azure/', + 'azure-anthropic/', + 'bedrock/', + 'vertex/', + 'groq/', + 'cerebras/', + 'nvidia/', + 'ollama/', + 'ollama-cloud/', + 'unknown/model', + ])('rejects incomplete or unsupported cloud namespaces: %s', (model) => { + const result = validateInputsForBlock('agent', { model }, 'agent-1') + expect(result.validInputs.model).toBeUndefined() + expect(result.errors[0]?.error).toContain('Unknown model id') + }) + it('validates the model field on router_v2 blocks too', () => { const valid = validateInputsForBlock('router_v2', { model: 'claude-sonnet-4-6' }, 'router-1') expect(valid.errors).toHaveLength(0) diff --git a/apps/sim/lib/workflows/editing/validation.ts b/apps/sim/lib/workflows/editing/validation.ts index ec2a366a1bd..e02ca16ef02 100644 --- a/apps/sim/lib/workflows/editing/validation.ts +++ b/apps/sim/lib/workflows/editing/validation.ts @@ -24,7 +24,12 @@ import type { SubBlockConfig } from '@/blocks/types' import { getModelOptions } from '@/blocks/utils' import { overlayVisibility } from '@/blocks/visibility/context' import { BlockType, EDGE, normalizeName } from '@/executor/constants' -import { isAutoModel, isKnownModelId, suggestModelIdsForUnknownModel } from '@/providers/models' +import { + isAutoModel, + isCustomModelId, + isKnownModelId, + suggestModelIdsForUnknownModel, +} from '@/providers/models' import { isPiByokOnlyMode } from '@/providers/pi-providers' import { getTool } from '@/tools/utils' import { @@ -680,7 +685,7 @@ export function validateValueForSubBlockType( if (trimmed !== '' && isAutoModel(trimmed) && isHostedDeployment) { return { valid: true, value: trimmed.toLowerCase() } } - if (trimmed !== '' && !isKnownModelId(trimmed)) { + if (trimmed !== '' && !isKnownModelId(trimmed) && !isCustomModelId(trimmed)) { const suggestions = suggestModelIdsForUnknownModel(trimmed) const suggestionText = suggestions.length > 0 ? ` Valid options include: ${suggestions.join(', ')}.` : '' @@ -691,7 +696,7 @@ export function validateValueForSubBlockType( blockType, field: fieldName, value, - error: `Unknown model id "${trimmed}" for block "${blockType}". Read components/blocks/${blockType}.json (the model.options array) for valid ids; prefer entries with recommended: true and avoid deprecated: true. For user-configured models (Ollama, Ollama Cloud, vLLM, LiteLLM, OpenRouter, Fireworks, Together AI, Baseten), prefix the id with the provider slash, e.g. "ollama/llama3.1:8b" or "ollama-cloud/gpt-oss:120b".${suggestionText}`, + error: `Unknown model id "${trimmed}" for block "${blockType}". Read components/blocks/${blockType}.json (the model.options array) for valid ids; prefer entries with recommended: true and avoid deprecated: true. For user-configured models, use a supported provider namespace, e.g. "azure/my-deployment", "azure-anthropic/my-deployment", "bedrock/my-inference-profile", "vertex/my-model", "ollama/llama3.1:8b", or "openrouter/provider/model".${suggestionText}`, }, } } diff --git a/apps/sim/providers/azure-anthropic/index.test.ts b/apps/sim/providers/azure-anthropic/index.test.ts index 1de56f6ed15..cb0c777c82b 100644 --- a/apps/sim/providers/azure-anthropic/index.test.ts +++ b/apps/sim/providers/azure-anthropic/index.test.ts @@ -110,6 +110,17 @@ describe('azureAnthropicProvider — SSRF pinning', () => { expect(buildClientOptions().defaultHeaders).not.toHaveProperty('anthropic-beta') }) + it('preserves custom deployment casing when removing an uppercase routing prefix', async () => { + setEnv({ AZURE_ANTHROPIC_ENDPOINT: 'https://custom.services.ai.azure.com' }) + const providerRequest = request({ model: 'AZURE-ANTHROPIC/Team-Claude-Deployment' }) + + await azureAnthropicProvider.executeRequest(providerRequest) + + const [forwardedRequest, config] = mockExecuteAnthropic.mock.calls[0] + expect(forwardedRequest.model).toBe('AZURE-ANTHROPIC/Team-Claude-Deployment') + expect(config.resolveWireModel(forwardedRequest)).toBe('Team-Claude-Deployment') + }) + it('throws and never builds a client when validation blocks the endpoint', async () => { mockValidate.mockResolvedValue({ isValid: false, error: 'resolves to a blocked IP address' }) diff --git a/apps/sim/providers/azure-anthropic/index.ts b/apps/sim/providers/azure-anthropic/index.ts index dff9f1be274..8431204cd73 100644 --- a/apps/sim/providers/azure-anthropic/index.ts +++ b/apps/sim/providers/azure-anthropic/index.ts @@ -64,7 +64,7 @@ export const azureAnthropicProvider: ProviderConfig = { return executeAnthropicProviderRequest(request, { providerId: 'azure-anthropic', providerLabel: 'Azure Anthropic', - resolveWireModel: ({ model }) => model.replace(/^azure-anthropic\//, ''), + resolveWireModel: ({ model }) => model.replace(/^azure-anthropic\//i, ''), createClient: (apiKey) => { const cacheKey = [ 'azure-anthropic', diff --git a/apps/sim/providers/azure-openai/index.test.ts b/apps/sim/providers/azure-openai/index.test.ts index 48c7431cb36..bf64de8121d 100644 --- a/apps/sim/providers/azure-openai/index.test.ts +++ b/apps/sim/providers/azure-openai/index.test.ts @@ -163,6 +163,20 @@ describe('azureOpenAIProvider — SSRF pinning', () => { expect(responsesConfig().fetch).toBeUndefined() }) + it.each([false, true])( + 'preserves a custom deployment name through Responses routing (full endpoint: %s)', + async (fullEndpoint) => { + mockIsResponsesEndpoint.mockReturnValue(fullEndpoint) + setEnv({ AZURE_OPENAI_ENDPOINT: 'https://custom.openai.azure.com' }) + const providerRequest = request({ model: 'AZURE/Team-GPT-Deployment' }) + + await azureOpenAIProvider.executeRequest(providerRequest) + + expect(mockExecuteResponses.mock.calls[0][0].model).toBe('AZURE/Team-GPT-Deployment') + expect(responsesConfig().modelName).toBe('Team-GPT-Deployment') + } + ) + it('throws and never reaches the Responses core when validation blocks the endpoint', async () => { mockValidate.mockResolvedValue({ isValid: false, error: 'resolves to a blocked IP address' }) @@ -215,6 +229,21 @@ describe('azureOpenAIProvider — SSRF pinning', () => { expect(azureOpenAIArgs[0]).not.toHaveProperty('fetch') }) + it('preserves a custom deployment name through Chat Completions routing', async () => { + mockIsChatCompletionsEndpoint.mockReturnValue(true) + setEnv({ + AZURE_OPENAI_ENDPOINT: 'https://custom.openai.azure.com/openai/v1/chat/completions', + }) + mockChatCreate.mockResolvedValue({ + choices: [{ message: { content: 'hi' } }], + usage: { prompt_tokens: 1, completion_tokens: 1, total_tokens: 2 }, + }) + + await azureOpenAIProvider.executeRequest(request({ model: 'AZURE/Team-GPT-Deployment' })) + + expect(mockChatCreate.mock.calls[0][0].model).toBe('Team-GPT-Deployment') + }) + it('projects the settled tool-loop answer without a final streaming request', async () => { mockIsChatCompletionsEndpoint.mockReturnValue(true) mockValidate.mockResolvedValue({ isValid: true, resolvedIP: '203.0.113.10' }) diff --git a/apps/sim/providers/azure-openai/index.ts b/apps/sim/providers/azure-openai/index.ts index 72251763d99..5905b59f4f2 100644 --- a/apps/sim/providers/azure-openai/index.ts +++ b/apps/sim/providers/azure-openai/index.ts @@ -703,7 +703,7 @@ export const azureOpenAIProvider: ProviderConfig = { // Try to extract deployment from URL, fall back to model name const urlDeployment = extractDeploymentFromUrl(azureEndpoint) - const deploymentName = urlDeployment || request.model.replace('azure/', '') + const deploymentName = urlDeployment || request.model.replace(/^azure\//i, '') // Try to extract api-version from URL, fall back to request param or env or default const urlApiVersion = extractApiVersionFromUrl(azureEndpoint) @@ -733,7 +733,7 @@ export const azureOpenAIProvider: ProviderConfig = { if (isResponsesEndpoint(azureEndpoint)) { logger.info('Detected full responses endpoint URL, using it directly') - const deploymentName = request.model.replace('azure/', '') + const deploymentName = request.model.replace(/^azure\//i, '') // Use the URL as-is since it's already complete return executeResponsesProviderRequest( @@ -758,7 +758,7 @@ export const azureOpenAIProvider: ProviderConfig = { logger.info('Using base endpoint, constructing Responses API URL') const azureApiVersion = request.azureApiVersion || env.AZURE_OPENAI_API_VERSION || '2024-07-01-preview' - const deploymentName = request.model.replace('azure/', '') + const deploymentName = request.model.replace(/^azure\//i, '') const apiUrl = `${azureEndpoint.replace(/\/$/, '')}/openai/v1/responses?api-version=${azureApiVersion}` return executeResponsesProviderRequest( diff --git a/apps/sim/providers/baseten/index.test.ts b/apps/sim/providers/baseten/index.test.ts index d0a4ed0308c..2dabec8909e 100644 --- a/apps/sim/providers/baseten/index.test.ts +++ b/apps/sim/providers/baseten/index.test.ts @@ -155,6 +155,14 @@ describe('basetenProvider', () => { await expect(basetenProvider.executeRequest(baseRequest)).rejects.toBeInstanceOf(ProviderError) }) + it('preserves custom model casing after an uppercase provider prefix', async () => { + mockCreate.mockResolvedValueOnce(textResponse('ok')) + + await basetenProvider.executeRequest({ ...baseRequest, model: 'BASETEN/Org/Custom-Model' }) + + expect(callBody(0).model).toBe('Org/Custom-Model') + }) + it('streams directly when there are no tools', async () => { mockCreate.mockResolvedValueOnce({}) diff --git a/apps/sim/providers/baseten/index.ts b/apps/sim/providers/baseten/index.ts index 5b00cecfcf0..79463252954 100644 --- a/apps/sim/providers/baseten/index.ts +++ b/apps/sim/providers/baseten/index.ts @@ -92,7 +92,7 @@ export const basetenProvider: ProviderConfig = { baseURL: 'https://inference.baseten.co/v1', }) - const requestedModel = request.model.replace(/^baseten\//, '') + const requestedModel = request.model.replace(/^baseten\//i, '') logger.info('Preparing Baseten request', { model: requestedModel, diff --git a/apps/sim/providers/bedrock/index.test.ts b/apps/sim/providers/bedrock/index.test.ts index bf4b2fdecdd..487cc092d2f 100644 --- a/apps/sim/providers/bedrock/index.test.ts +++ b/apps/sim/providers/bedrock/index.test.ts @@ -22,6 +22,7 @@ vi.mock('@/providers/bedrock/utils', () => ({ checkForForcedToolUsage: vi.fn(), createReadableStreamFromBedrockStream: vi.fn(), generateToolUseId: vi.fn().mockReturnValue('tool-1'), + getBedrockBaseModelId: (model: string) => model.replace(/^bedrock\//i, ''), getBedrockStreamError: vi.fn().mockReturnValue(null), // The mocked inference profile above is a Claude model, which supports it. supportsToolResultStatus: vi.fn().mockReturnValue(true), @@ -35,6 +36,8 @@ vi.mock('@/providers/models', () => ({ getProviderModels: vi.fn().mockReturnValue([]), getProviderDefaultModel: vi.fn().mockReturnValue('us.anthropic.claude-3-5-sonnet-20241022-v2:0'), supportsNativeStructuredOutputs: vi.fn().mockReturnValue(false), + getModelCapabilities: vi.fn().mockReturnValue({ temperature: { min: 0, max: 1 } }), + isKnownModelId: vi.fn().mockReturnValue(true), })) vi.mock('@/providers/utils', () => ({ @@ -64,6 +67,7 @@ import { BedrockRuntimeClient, ConverseCommand } from '@aws-sdk/client-bedrock-r import type { StreamingExecution } from '@/executor/types' import { bedrockProvider } from '@/providers/bedrock/index' import { clearProviderClientCacheForTests } from '@/providers/client-cache' +import { getModelCapabilities, isKnownModelId } from '@/providers/models' import { prepareToolsWithUsageControl } from '@/providers/utils' describe('bedrockProvider credential handling', () => { @@ -135,6 +139,34 @@ describe('bedrockProvider credential handling', () => { }) }) + it('omits temperature for catalog models that do not support it', async () => { + vi.mocked(getModelCapabilities).mockReturnValueOnce({ maxOutputTokens: 128000 }) + await bedrockProvider.executeRequest({ + ...baseRequest, + model: 'bedrock/anthropic.claude-opus-5', + temperature: 0.7, + }) + expect(ConverseCommand).toHaveBeenCalledWith(expect.objectContaining({ inferenceConfig: {} })) + }) + + it('preserves explicit temperature for a custom model without catalog capabilities', async () => { + vi.mocked(isKnownModelId).mockReturnValueOnce(false) + await bedrockProvider.executeRequest({ + ...baseRequest, + model: 'bedrock/MyCustomProfile', + temperature: 0.2, + }) + expect(ConverseCommand).toHaveBeenCalledWith( + expect.objectContaining({ inferenceConfig: { temperature: 0.2 } }) + ) + }) + + it('leaves temperature to the service default for a custom model when omitted', async () => { + vi.mocked(isKnownModelId).mockReturnValueOnce(false) + await bedrockProvider.executeRequest({ ...baseRequest, model: 'bedrock/MyCustomProfile' }) + expect(ConverseCommand).toHaveBeenCalledWith(expect.objectContaining({ inferenceConfig: {} })) + }) + it('uses the live loop for streaming tool requests without a caller flag', async () => { vi.mocked(prepareToolsWithUsageControl).mockReturnValueOnce({ tools: [ diff --git a/apps/sim/providers/bedrock/index.ts b/apps/sim/providers/bedrock/index.ts index 28b9b112d27..944785aca63 100644 --- a/apps/sim/providers/bedrock/index.ts +++ b/apps/sim/providers/bedrock/index.ts @@ -26,13 +26,16 @@ import { checkForForcedToolUsage, createReadableStreamFromBedrockStream, generateToolUseId, + getBedrockBaseModelId, getBedrockInferenceProfileId, supportsToolResultStatus, } from '@/providers/bedrock/utils' import { getCachedProviderClient } from '@/providers/client-cache' import { + getModelCapabilities, getProviderDefaultModel, getProviderModels, + isKnownModelId, supportsNativeStructuredOutputs, } from '@/providers/models' import { executeProviderTool } from '@/providers/runtime-context' @@ -376,8 +379,15 @@ export const bedrockProvider: ProviderConfig = { const systemPromptWithSchema = systemContent - const inferenceConfig: { temperature: number; maxTokens?: number } = { - temperature: Number.parseFloat(String(request.temperature ?? 0.7)), + const canonicalModelId = `bedrock/${getBedrockBaseModelId(request.model)}` + const knownModel = isKnownModelId(canonicalModelId) + const modelCapabilities = getModelCapabilities(canonicalModelId) + const inferenceConfig: { temperature?: number; maxTokens?: number } = {} + if ( + (knownModel && modelCapabilities?.temperature) || + (!knownModel && request.temperature != null) + ) { + inferenceConfig.temperature = Number.parseFloat(String(request.temperature ?? 0.7)) } if (request.maxTokens != null) { inferenceConfig.maxTokens = Number.parseInt(String(request.maxTokens)) diff --git a/apps/sim/providers/bedrock/streaming-tool-loop.test.ts b/apps/sim/providers/bedrock/streaming-tool-loop.test.ts index ebb828e1dea..cbc4e73eb00 100644 --- a/apps/sim/providers/bedrock/streaming-tool-loop.test.ts +++ b/apps/sim/providers/bedrock/streaming-tool-loop.test.ts @@ -1,6 +1,7 @@ /** * @vitest-environment node */ +import type { ConverseStreamCommand } from '@aws-sdk/client-bedrock-runtime' import { beforeEach, describe, expect, it, vi } from 'vitest' import { createBedrockStreamingToolLoopStream } from '@/providers/bedrock/streaming-tool-loop' import type { AgentStreamEvent } from '@/providers/stream-events' @@ -55,18 +56,51 @@ describe('createBedrockStreamingToolLoopStream', () => { }) }) - it('emits tool_call_start/end and final text; no invented thinking', async () => { + it('preserves signed and redacted reasoning across tool turns without exposing it', async () => { const turns = [ (async function* () { yield { - contentBlockStart: { + contentBlockDelta: { contentBlockIndex: 0, - start: { toolUse: { toolUseId: 'tooluse_1', name: 'http_request' } }, + delta: { reasoningContent: { text: 'Check the endpoint.' } }, + }, + } + yield { + contentBlockDelta: { + contentBlockIndex: 0, + delta: { reasoningContent: { signature: 'signature-' } }, }, } yield { contentBlockDelta: { contentBlockIndex: 0, + delta: { reasoningContent: { signature: 'value' } }, + }, + } + yield { + contentBlockDelta: { contentBlockIndex: 1, delta: { text: 'Checking now.' } }, + } + yield { + contentBlockDelta: { + contentBlockIndex: 2, + delta: { reasoningContent: { redactedContent: new Uint8Array([1, 2]) } }, + }, + } + yield { + contentBlockDelta: { + contentBlockIndex: 2, + delta: { reasoningContent: { redactedContent: new Uint8Array([3]) } }, + }, + } + yield { + contentBlockStart: { + contentBlockIndex: 3, + start: { toolUse: { toolUseId: 'tooluse_1', name: 'http_request' } }, + }, + } + yield { + contentBlockDelta: { + contentBlockIndex: 3, delta: { toolUse: { input: '{"url":"https://example.com"}' } }, }, } @@ -91,7 +125,7 @@ describe('createBedrockStreamingToolLoopStream', () => { let turnIdx = 0 const client = { - send: vi.fn(async () => ({ stream: turns[turnIdx++] })), + send: vi.fn(async (_command: ConverseStreamCommand) => ({ stream: turns[turnIdx++] })), } const onComplete = vi.fn() @@ -128,6 +162,26 @@ describe('createBedrockStreamingToolLoopStream', () => { const events = await collectEvents(stream) + expect(client.send.mock.calls[1][0].input.messages?.[1]).toEqual({ + role: 'assistant', + content: [ + { + reasoningContent: { + reasoningText: { text: 'Check the endpoint.', signature: 'signature-value' }, + }, + }, + { text: 'Checking now.' }, + { reasoningContent: { redactedContent: new Uint8Array([1, 2, 3]) } }, + { + toolUse: { + toolUseId: 'tooluse_1', + name: 'http_request', + input: { url: 'https://example.com' }, + }, + }, + ], + }) + expect(events.some((e) => e.type === 'thinking_delta')).toBe(false) expect(events.filter((e) => e.type === 'tool_call_start')).toEqual([ { type: 'tool_call_start', id: 'tooluse_1', name: 'http_request' }, @@ -141,7 +195,7 @@ describe('createBedrockStreamingToolLoopStream', () => { .filter((e) => e.type === 'text_delta' && e.turn === 'pending') .map((e) => e.text) .join('') - ).toBe('Request completed.') + ).toBe('Checking now.Request completed.') expect(events.filter((e) => e.type === 'turn_end').map((e) => e.turn)).toEqual([ 'intermediate', 'final', diff --git a/apps/sim/providers/bedrock/streaming-tool-loop.ts b/apps/sim/providers/bedrock/streaming-tool-loop.ts index 0ecd6c276cf..13b3d8646e2 100644 --- a/apps/sim/providers/bedrock/streaming-tool-loop.ts +++ b/apps/sim/providers/bedrock/streaming-tool-loop.ts @@ -1,8 +1,8 @@ /** * Live Bedrock ConverseStream tool loop. * - * Capability-honest: text + tool_call_start/end only — Sim does not request - * Bedrock reasoning, so no thinking is invented. Text emits live as `pending` + * Text + tool_call_start/end events, with provider reasoning preserved for + * subsequent model requests. Text emits live as `pending` * deltas and a `turn_end` event classifies each turn, so the pump projects * only final-turn text to the answer channel. Abort → cancelled. */ @@ -47,7 +47,7 @@ export interface CreateBedrockStreamingToolLoopStreamOptions { request: ProviderRequest messages: BedrockMessage[] system?: SystemContentBlock[] - inferenceConfig: { temperature: number; maxTokens?: number } + inferenceConfig: { temperature?: number; maxTokens?: number } bedrockTools: Tool[] toolChoice: ToolConfiguration['toolChoice'] logger: Logger @@ -62,6 +62,8 @@ interface AssembledToolUse { inputJson: string } +type DrainedContentBlock = ContentBlock | { pendingToolUseId: string } + type ToolUseInput = NonNullable function parseToolInput(inputJson: string): Record { @@ -84,12 +86,18 @@ async function drainBedrockTurn( ): Promise<{ text: string toolUses: AssembledToolUse[] + content: DrainedContentBlock[] inputTokens: number outputTokens: number stopReason?: string }> { let text = '' const toolsByIndex = new Map() + const textByIndex = new Map() + const reasoningByIndex = new Map< + number, + { text: string; signature: string; redacted: Uint8Array[] } + >() let currentIndex: number | undefined let inputTokens = 0 let outputTokens = 0 @@ -119,8 +127,23 @@ async function drainBedrockTurn( if (event.contentBlockDelta) { const idx = event.contentBlockDelta.contentBlockIndex ?? currentIndex const delta = event.contentBlockDelta.delta + if (delta?.reasoningContent && typeof idx === 'number') { + let reasoning = reasoningByIndex.get(idx) + if (!reasoning) { + reasoning = { text: '', signature: '', redacted: [] } + reasoningByIndex.set(idx, reasoning) + } + reasoning.text += delta.reasoningContent.text ?? '' + reasoning.signature += delta.reasoningContent.signature ?? '' + if (delta.reasoningContent.redactedContent) { + reasoning.redacted.push(delta.reasoningContent.redactedContent) + } + } if (delta?.text) { text += delta.text + if (typeof idx === 'number') { + textByIndex.set(idx, (textByIndex.get(idx) ?? '') + delta.text) + } // Live pending text: sinks render it now; the pump projects it to the // answer only when this turn's turn_end says 'final'. controller.enqueue({ type: 'text_delta', text: delta.text, turn: 'pending' }) @@ -145,9 +168,39 @@ async function drainBedrockTurn( } } + const contentByIndex = new Map() + for (const [index, blockText] of textByIndex) { + if (blockText.trim()) contentByIndex.set(index, { text: blockText }) + } + for (const [index, tool] of toolsByIndex) { + contentByIndex.set(index, { pendingToolUseId: tool.toolUseId }) + } + for (const [index, reasoning] of reasoningByIndex) { + if (reasoning.redacted.length > 0) { + const redactedContent = new Uint8Array( + reasoning.redacted.reduce((size, chunk) => size + chunk.length, 0) + ) + let offset = 0 + for (const chunk of reasoning.redacted) { + redactedContent.set(chunk, offset) + offset += chunk.length + } + contentByIndex.set(index, { reasoningContent: { redactedContent } }) + } else { + contentByIndex.set(index, { + reasoningContent: { + reasoningText: { text: reasoning.text, signature: reasoning.signature }, + }, + }) + } + } + return { text, toolUses: [...toolsByIndex.values()], + content: [...contentByIndex.entries()] + .sort(([left], [right]) => left - right) + .map(([, block]) => block), inputTokens, outputTokens, stopReason, @@ -499,21 +552,17 @@ export function createBedrockStreamingToolLoopStream( toolsTime += Date.now() - toolsStartTime - const assistantContent: ContentBlock[] = [ - // Bedrock rejects a blank text block, and a model can emit only - // whitespace before a tool call. - ...(drained.text.trim() ? [{ text: drained.text }] : []), - ...assembledToolUses.map((toolUse) => ({ - toolUse: { - toolUseId: toolUse.toolUseId, - name: toolUse.name, - input: toolUse.input, - }, - })), - ] + const toolUsesById = new Map( + assembledToolUses.map((toolUse) => [toolUse.toolUseId, toolUse]) + ) currentMessages.push({ role: 'assistant' as ConversationRole, - content: assistantContent, + content: drained.content.map((block) => { + if (!('pendingToolUseId' in block)) return block + const toolUse = toolUsesById.get(block.pendingToolUseId) + if (!toolUse) throw new Error('Missing assembled Bedrock tool use') + return { toolUse } + }), }) const toolResultContent: ContentBlock[] = [] diff --git a/apps/sim/providers/bedrock/utils.test.ts b/apps/sim/providers/bedrock/utils.test.ts index 421e46bcff4..851a84991aa 100644 --- a/apps/sim/providers/bedrock/utils.test.ts +++ b/apps/sim/providers/bedrock/utils.test.ts @@ -26,6 +26,34 @@ describe('getBedrockInferenceProfileId', () => { ) }) + it.each([ + 'arn:aws:bedrock:us-east-1:123456789012:application-inference-profile/MyProfile', + 'arn:aws:bedrock:us-east-1:123456789012:custom-model-deployment/MyDeployment', + 'MyCustomProfile', + 'future.vendor-model-v1:0', + 'global.anthropic.claude-opus-5', + ])('preserves caller-supplied model ID %s', (model) => { + expect(getBedrockInferenceProfileId(`BEDROCK/${model}`, 'us-east-1')).toBe(model) + }) + + it.concurrent('uses only published geographic prefixes for new catalog models', () => { + expect(getBedrockInferenceProfileId('bedrock/anthropic.claude-opus-5', 'us-east-1')).toBe( + 'us.anthropic.claude-opus-5' + ) + expect(getBedrockInferenceProfileId('bedrock/anthropic.claude-opus-5', 'ap-southeast-2')).toBe( + 'au.anthropic.claude-opus-5' + ) + expect(getBedrockInferenceProfileId('bedrock/openai.gpt-5.6-sol', 'us-east-1')).toBe( + 'us.openai.gpt-5.6-sol' + ) + expect(() => getBedrockInferenceProfileId('bedrock/openai.gpt-5.6-sol', 'eu-west-1')).toThrow( + 'Supply an explicit bedrock/global.' + ) + expect(getBedrockInferenceProfileId('bedrock/openai.gpt-oss-120b-1:0', 'us-east-1')).toBe( + 'openai.gpt-oss-120b-1:0' + ) + }) + it.concurrent('returns the bare model ID for models without geo profile support', () => { expect( getBedrockInferenceProfileId('bedrock/mistral.mistral-large-3-675b-instruct', 'us-east-1') diff --git a/apps/sim/providers/bedrock/utils.ts b/apps/sim/providers/bedrock/utils.ts index a8e837b1142..b84cba8c9a0 100644 --- a/apps/sim/providers/bedrock/utils.ts +++ b/apps/sim/providers/bedrock/utils.ts @@ -122,22 +122,49 @@ export function generateToolUseId(toolName: string): string { } /** - * Models whose AWS model cards state geo/cross-region inference profiles are - * not supported ("Geo inference ID: Not supported"). These must be invoked - * with the bare in-region model ID — prefixing them with a geo profile - * (e.g. us.mistral...) produces an invalid model identifier. + * Catalog models with documented geographic inference profiles. Unknown model + * IDs and caller-supplied inference profile IDs/ARNs must pass through unchanged. */ -const GEO_PROFILE_UNSUPPORTED_MODEL_IDS = new Set([ - 'mistral.mistral-large-3-675b-instruct', - 'mistral.mistral-large-2407-v1:0', - 'mistral.magistral-small-2509', - 'mistral.ministral-3-14b-instruct', - 'mistral.ministral-3-8b-instruct', - 'mistral.ministral-3-3b-instruct', - 'mistral.mixtral-8x7b-instruct-v0:1', - 'amazon.titan-text-premier-v1:0', - 'cohere.command-r-v1:0', - 'cohere.command-r-plus-v1:0', +const GEO_PROFILE_MODEL_IDS = new Set([ + 'anthropic.claude-opus-4-5-20251101-v1:0', + 'anthropic.claude-sonnet-4-5-20250929-v1:0', + 'anthropic.claude-haiku-4-5-20251001-v1:0', + 'anthropic.claude-opus-4-1-20250805-v1:0', + 'amazon.nova-2-lite-v1:0', + 'amazon.nova-premier-v1:0', + 'amazon.nova-pro-v1:0', + 'amazon.nova-lite-v1:0', + 'amazon.nova-micro-v1:0', + 'meta.llama4-maverick-17b-instruct-v1:0', + 'meta.llama4-scout-17b-instruct-v1:0', + 'meta.llama3-3-70b-instruct-v1:0', + 'meta.llama3-2-90b-instruct-v1:0', + 'meta.llama3-2-11b-instruct-v1:0', + 'meta.llama3-2-3b-instruct-v1:0', + 'meta.llama3-2-1b-instruct-v1:0', + 'meta.llama3-1-405b-instruct-v1:0', + 'meta.llama3-1-70b-instruct-v1:0', + 'meta.llama3-1-8b-instruct-v1:0', + 'mistral.pixtral-large-2502-v1:0', +]) + +/** Current Claude profiles use AU rather than the older APAC geography. */ +const CLAUDE_GEO_PROFILE_MODEL_IDS = new Set([ + 'anthropic.claude-opus-5', + 'anthropic.claude-sonnet-5', + 'anthropic.claude-opus-4-8', + 'anthropic.claude-opus-4-7', + 'anthropic.claude-opus-4-6-v1', + 'anthropic.claude-sonnet-4-6', +]) + +/** These models currently publish US and global inference profiles only. */ +const US_GEO_PROFILE_MODEL_IDS = new Set([ + 'anthropic.claude-fable-5', + 'openai.gpt-6-astra', + 'openai.gpt-5.6-sol', + 'openai.gpt-5.6-terra', + 'openai.gpt-5.6-luna', ]) /** Cross-region inference profile prefixes Bedrock prepends to a base model ID. */ @@ -147,8 +174,8 @@ const GEO_PROFILE_PREFIX_PATTERN = /^(us-gov|us|eu|apac|au|ca|jp|global)\./ * Strips Sim's `bedrock/` namespace and any cross-region inference prefix, * leaving the bare `.` ID that capability checks key off. */ -function getBedrockBaseModelId(modelId: string): string { - const withoutNamespace = modelId.startsWith('bedrock/') ? modelId.slice(8) : modelId +export function getBedrockBaseModelId(modelId: string): string { + const withoutNamespace = modelId.replace(/^bedrock\//i, '') return withoutNamespace.replace(GEO_PROFILE_PREFIX_PATTERN, '') } @@ -177,13 +204,33 @@ export function supportsToolResultStatus(modelId: string): boolean { * @returns The inference profile ID (e.g., "us.anthropic.claude-sonnet-4-5-20250929-v1:0") */ export function getBedrockInferenceProfileId(modelId: string, region: string): string { - const baseModelId = modelId.startsWith('bedrock/') ? modelId.slice(8) : modelId + const baseModelId = modelId.replace(/^bedrock\//i, '') if (GEO_PROFILE_PREFIX_PATTERN.test(baseModelId)) { return baseModelId } - if (GEO_PROFILE_UNSUPPORTED_MODEL_IDS.has(baseModelId)) { + if (CLAUDE_GEO_PROFILE_MODEL_IDS.has(baseModelId)) { + if ((region.startsWith('us-') && !region.startsWith('us-gov-')) || region.startsWith('ca-')) { + return `us.${baseModelId}` + } + if (region.startsWith('eu-')) return `eu.${baseModelId}` + if (region === 'ap-southeast-2' || region === 'ap-southeast-4') return `au.${baseModelId}` + throw new Error( + `No geographic inference profile is configured for ${baseModelId} in ${region}. ` + + 'Supply an explicit bedrock/global. model ID or an inference profile ARN.' + ) + } + + if (US_GEO_PROFILE_MODEL_IDS.has(baseModelId)) { + if (region.startsWith('us-') && !region.startsWith('us-gov-')) return `us.${baseModelId}` + throw new Error( + `${baseModelId} only has a US geographic inference profile. ` + + 'Supply an explicit bedrock/global. model ID to use global inference.' + ) + } + + if (!GEO_PROFILE_MODEL_IDS.has(baseModelId)) { return baseModelId } diff --git a/apps/sim/providers/cerebras/index.ts b/apps/sim/providers/cerebras/index.ts index bb79fcfa807..14613a0b7bb 100644 --- a/apps/sim/providers/cerebras/index.ts +++ b/apps/sim/providers/cerebras/index.ts @@ -81,11 +81,14 @@ export const cerebrasProvider: ProviderConfig = { : undefined const payload: any = { - model: request.model.replace('cerebras/', ''), + model: request.model.replace(/^cerebras\//i, ''), messages: formattedMessages, } if (request.temperature !== undefined) payload.temperature = request.temperature if (request.maxTokens != null) payload.max_completion_tokens = request.maxTokens + if (request.reasoningEffort && request.reasoningEffort !== 'auto') { + payload.reasoning_effort = request.reasoningEffort + } if (request.responseFormat) { payload.response_format = { type: 'json_schema', diff --git a/apps/sim/providers/deepseek/index.test.ts b/apps/sim/providers/deepseek/index.test.ts index 174123c7ffa..c1c363f38a1 100644 --- a/apps/sim/providers/deepseek/index.test.ts +++ b/apps/sim/providers/deepseek/index.test.ts @@ -108,6 +108,20 @@ describe('deepseekProvider thinking payload', () => { expect(payload.thinking).toBeUndefined() }) + it.each([ + ['low', 'low'], + ['minimal', 'low'], + ['medium', 'high'], + ['xhigh', 'high'], + ['max', 'max'], + ] as const)('maps Flash reasoning effort %s to %s', async (reasoningEffort, expected) => { + await deepseekProvider.executeRequest(request({ model: 'deepseek-flash', reasoningEffort })) + expect(mockCreate.mock.calls[0][0]).toMatchObject({ + model: 'deepseek-flash', + reasoning_effort: expected, + }) + }) + it('selects the live tool loop without a caller flag', async () => { mockPrepareToolsWithUsageControl.mockReturnValue({ tools: [ diff --git a/apps/sim/providers/deepseek/index.ts b/apps/sim/providers/deepseek/index.ts index 2897d8e9ab0..7dd067f5a26 100644 --- a/apps/sim/providers/deepseek/index.ts +++ b/apps/sim/providers/deepseek/index.ts @@ -105,9 +105,9 @@ export const deepseekProvider: ProviderConfig = { } if (request.reasoningEffort && !['auto', 'none'].includes(request.reasoningEffort)) { payload.reasoning_effort = - request.reasoningEffort === 'xhigh' - ? 'max' - : request.reasoningEffort === 'low' || request.reasoningEffort === 'medium' + request.reasoningEffort === 'minimal' + ? 'low' + : request.reasoningEffort === 'xhigh' || request.reasoningEffort === 'medium' ? 'high' : request.reasoningEffort } diff --git a/apps/sim/providers/fireworks/index.test.ts b/apps/sim/providers/fireworks/index.test.ts index 9dcd6d9cfe5..8a5ef735ad3 100644 --- a/apps/sim/providers/fireworks/index.test.ts +++ b/apps/sim/providers/fireworks/index.test.ts @@ -127,6 +127,18 @@ describe('fireworksProvider', () => { apiKey: 'fw-test-key', } + it('preserves custom deployment paths when stripping an uppercase namespace', async () => { + mockCreate.mockResolvedValueOnce(textResponse('ok')) + await fireworksProvider.executeRequest({ + ...baseRequest, + model: 'FIREWORKS/accounts/Example/models/CustomModel', + }) + expect(mockResolveFireworksWireModel).toHaveBeenCalledWith( + 'accounts/Example/models/CustomModel' + ) + expect(callBody(0).model).toBe('accounts/Example/models/CustomModel') + }) + it('throws when the API key is missing', async () => { await expect( fireworksProvider.executeRequest({ ...baseRequest, apiKey: undefined }) diff --git a/apps/sim/providers/fireworks/index.ts b/apps/sim/providers/fireworks/index.ts index 5ebb31606cd..f82370e02d8 100644 --- a/apps/sim/providers/fireworks/index.ts +++ b/apps/sim/providers/fireworks/index.ts @@ -93,7 +93,7 @@ export const fireworksProvider: ProviderConfig = { baseURL: 'https://api.fireworks.ai/inference/v1', }) - const requestedModel = resolveFireworksWireModel(request.model.replace(/^fireworks\//, '')) + const requestedModel = resolveFireworksWireModel(request.model.replace(/^fireworks\//i, '')) logger.info('Preparing Fireworks request', { model: requestedModel, diff --git a/apps/sim/providers/gemini/core.request.test.ts b/apps/sim/providers/gemini/core.request.test.ts new file mode 100644 index 00000000000..1d813deb3a5 --- /dev/null +++ b/apps/sim/providers/gemini/core.request.test.ts @@ -0,0 +1,172 @@ +/** + * @vitest-environment node + */ +import type { GenerateContentParameters, GenerateContentResponse } from '@google/genai' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type { StreamingExecution } from '@/executor/types' +import { executeGeminiRequest } from '@/providers/gemini/core' +import type { ProviderRequest, ProviderResponse } from '@/providers/types' + +const { mockExecuteTool } = vi.hoisted(() => ({ mockExecuteTool: vi.fn() })) + +vi.mock('@/tools', () => ({ executeTool: mockExecuteTool })) +vi.mock('@/providers', () => ({ MAX_TOOL_ITERATIONS: 5 })) + +function textTurn(): GenerateContentResponse { + return { + candidates: [{ content: { role: 'model', parts: [{ text: 'answer' }] }, finishReason: 'STOP' }], + usageMetadata: { + promptTokenCount: 1000, + cachedContentTokenCount: 200, + candidatesTokenCount: 100, + totalTokenCount: 1100, + }, + } as GenerateContentResponse +} + +async function run( + model: string, + generateContent: ReturnType, + overrides: Partial = {} +) { + return (await executeGeminiRequest({ + ai: { models: { generateContent, generateContentStream: vi.fn() } } as never, + model: model.replace(/^vertex\//i, ''), + providerType: 'vertex', + request: { + model, + apiKey: 'test-key', + messages: [{ role: 'user', content: 'Look this up' }], + temperature: 0.5, + thinkingLevel: 'medium', + ...overrides, + }, + })) as ProviderResponse +} + +describe('Vertex Gemini request compatibility', () => { + beforeEach(() => { + vi.clearAllMocks() + mockExecuteTool.mockResolvedValue({ success: true, output: { value: 'tool result' } }) + }) + + it.each([ + 'vertex/gemini-3.8-flash', + 'vertex/gemini-3.7-flash', + 'vertex/gemini-3.6-flash', + 'vertex/gemini-3.5-flash-lite', + ])('uses thinking levels and omits ignored temperature for %s', async (model) => { + const generateContent = vi.fn().mockResolvedValue(textTurn()) + await run(model, generateContent) + + const params = generateContent.mock.calls[0][0] as GenerateContentParameters + expect(params.model).toBe(model.replace('vertex/', '')) + expect(params.config).not.toHaveProperty('temperature') + expect(params.config?.thinkingConfig).toEqual({ + thinkingLevel: 'MEDIUM', + includeThoughts: false, + }) + }) + + it.each(['vertex/gemini-2.5-flash', 'vertex/Custom-Gemini-Model'])( + 'preserves temperature for supported or uncataloged %s', + async (model) => { + const generateContent = vi.fn().mockResolvedValue(textTurn()) + await run(model, generateContent) + + expect(generateContent.mock.calls[0][0].config.temperature).toBe(0.5) + } + ) + + it('prices Vertex-only catalog entries using the namespaced model ID', async () => { + const generateContent = vi.fn().mockResolvedValue(textTurn()) + const result = await run('vertex/gemini-3.8-flash', generateContent) + + expect(result.cost?.input).toBeCloseTo((800 * 0.75 + 200 * 0.075) / 1e6, 10) + expect(result.cost?.output).toBeCloseTo((100 * 3.75) / 1e6, 10) + }) + + it.each([false, true])( + 'retains Vertex pricing when streaming with tools enabled: %s', + async (withTools) => { + const generateContentStream = vi.fn().mockImplementation(async function* () { + yield textTurn() + }) + const result = (await executeGeminiRequest({ + ai: { models: { generateContentStream } } as never, + model: 'gemini-3.8-flash', + providerType: 'vertex', + request: { + model: 'vertex/gemini-3.8-flash', + messages: [{ role: 'user', content: 'Hello' }], + stream: true, + ...(withTools + ? { + tools: [ + { + id: 'lookup', + name: 'lookup', + description: 'Look up a query', + parameters: { type: 'object', properties: {}, required: [] }, + }, + ], + } + : {}), + }, + })) as StreamingExecution + const reader = result.stream.getReader() + while (!(await reader.read()).done) {} + reader.releaseLock() + + expect(result.execution.output.cost?.input).toBeCloseTo((800 * 0.75 + 200 * 0.075) / 1e6, 10) + expect(result.execution.output.cost?.output).toBeCloseTo((100 * 3.75) / 1e6, 10) + } + ) + + it('echoes IDs and thought signatures for parallel same-name function calls', async () => { + const functionCalls = [ + { id: 'call-first', name: 'lookup', args: { query: 'first' } }, + { id: 'call-second', name: 'lookup', args: { query: 'second' } }, + ] + const parts = functionCalls.map((functionCall, index) => ({ + functionCall, + thoughtSignature: `signature-${index}`, + })) + const generateContent = vi + .fn() + .mockResolvedValueOnce({ + candidates: [{ content: { role: 'model', parts }, finishReason: 'STOP' }], + functionCalls, + usageMetadata: { promptTokenCount: 10, candidatesTokenCount: 20, totalTokenCount: 30 }, + }) + .mockResolvedValueOnce(textTurn()) + + await run('vertex/gemini-3.8-flash', generateContent, { + tools: [ + { + id: 'lookup', + name: 'lookup', + description: 'Look up a query', + parameters: { + type: 'object', + properties: { query: { type: 'string' } }, + required: ['query'], + }, + }, + ], + }) + + expect(generateContent).toHaveBeenCalledTimes(2) + const followUp = generateContent.mock.calls[1][0] as GenerateContentParameters + expect(followUp.contents).toEqual([ + expect.objectContaining({ role: 'user' }), + { role: 'model', parts }, + { + role: 'user', + parts: functionCalls.map(({ id, name }) => ({ + functionResponse: { id, name, response: { value: 'tool result' } }, + })), + }, + ]) + }) +}) diff --git a/apps/sim/providers/gemini/core.ts b/apps/sim/providers/gemini/core.ts index 175b71d96b7..d47e4c06cfa 100644 --- a/apps/sim/providers/gemini/core.ts +++ b/apps/sim/providers/gemini/core.ts @@ -31,6 +31,7 @@ import { mapToThinkingLevel, supportsDisablingGemini25Thinking, } from '@/providers/google/utils' +import { getModelCapabilities, isKnownModelId } from '@/providers/models' import { executeProviderTool } from '@/providers/runtime-context' import { createSettledAgentEventStream } from '@/providers/stream-events' import { createStreamingExecution } from '@/providers/streaming-execution' @@ -215,6 +216,7 @@ async function executeToolCallsBatch( functionResponse: { name: r.toolName, response: 'modelResultContent' in r ? r.modelResultContent : r.resultContent, + ...(r.part.functionCall?.id ? { id: r.part.functionCall.id } : {}), }, })) @@ -966,7 +968,10 @@ export async function executeGeminiRequest( if (request.abortSignal) { geminiConfig.abortSignal = request.abortSignal } - if (request.temperature !== undefined) { + if ( + request.temperature !== undefined && + (!isKnownModelId(request.model) || getModelCapabilities(request.model)?.temperature) + ) { geminiConfig.temperature = request.temperature } if (request.maxTokens != null) { @@ -1148,7 +1153,7 @@ export async function executeGeminiRequest( streamingResult.execution.output.content = content streamingResult.execution.output.tokens = { ...split, total: usage.totalTokenCount } - streamingResult.execution.output.cost = priceGeminiTokens(model, split) + streamingResult.execution.output.cost = priceGeminiTokens(request.model, split) if (thinking) { const segment = streamingResult.execution.output.providerTiming?.timeSegments?.[0] @@ -1192,11 +1197,11 @@ export async function executeGeminiRequest( initialUsage, firstResponseTime, initialCallTime, - model, + request.model, toolConfig ) enrichLastModelSegmentFromGeminiResponse(state.timeSegments, response, { - model, + model: request.model, }) const forcedTools = preparedTools?.forcedTools ?? [] @@ -1225,12 +1230,12 @@ export async function executeGeminiRequest( const finalState = updateStateWithResponse( currentState, finalResponse, - model, + request.model, finalStartTime, Date.now() ) enrichLastModelSegmentFromGeminiResponse(finalState.timeSegments, finalResponse, { - model, + model: request.model, }) return { state: finalState, response: finalResponse } } @@ -1324,9 +1329,15 @@ export async function executeGeminiRequest( contents: state.contents, config: nextConfig, }) - state = updateStateWithResponse(state, nextResponse, model, nextModelStartTime, Date.now()) + state = updateStateWithResponse( + state, + nextResponse, + request.model, + nextModelStartTime, + Date.now() + ) enrichLastModelSegmentFromGeminiResponse(state.timeSegments, nextResponse, { - model, + model: request.model, }) currentResponse = nextResponse diff --git a/apps/sim/providers/gemini/streaming-tool-loop.ts b/apps/sim/providers/gemini/streaming-tool-loop.ts index 25167caeb19..646f19acfca 100644 --- a/apps/sim/providers/gemini/streaming-tool-loop.ts +++ b/apps/sim/providers/gemini/streaming-tool-loop.ts @@ -339,7 +339,7 @@ export function createGeminiStreamingToolLoopStream( tokens.cacheRead += split.cacheRead tokens.total += drained.usage.totalTokenCount - const turnCost = priceGeminiTokens(model, split) + const turnCost = priceGeminiTokens(request.model, split) costInput += turnCost.input costOutput += turnCost.output costTotal += turnCost.total diff --git a/apps/sim/providers/google/utils.test.ts b/apps/sim/providers/google/utils.test.ts index ead73247543..12f21ffdc2e 100644 --- a/apps/sim/providers/google/utils.test.ts +++ b/apps/sim/providers/google/utils.test.ts @@ -280,6 +280,10 @@ describe('convertToGeminiFormat', () => { const result = convertToGeminiFormat(request) + expect(result.contents[1].parts?.[0].functionCall).toMatchObject({ + id: 'call_123', + name: 'get_weather', + }) const toolResponseContent = result.contents.find( (c) => c.parts?.[0] && 'functionResponse' in c.parts[0] ) diff --git a/apps/sim/providers/google/utils.ts b/apps/sim/providers/google/utils.ts index 63822da75d0..79aa652fb74 100644 --- a/apps/sim/providers/google/utils.ts +++ b/apps/sim/providers/google/utils.ts @@ -162,6 +162,7 @@ export function convertToGeminiFormat( if (message.role === 'assistant' && message.tool_calls?.length) { const functionCalls = message.tool_calls.map((toolCall) => ({ functionCall: { + id: toolCall.id, name: toolCall.function?.name, args: JSON.parse(toolCall.function?.arguments || '{}') as Record, }, diff --git a/apps/sim/providers/groq/index.test.ts b/apps/sim/providers/groq/index.test.ts index a10fdce4454..b946d249153 100644 --- a/apps/sim/providers/groq/index.test.ts +++ b/apps/sim/providers/groq/index.test.ts @@ -140,6 +140,23 @@ describe('groqProvider reasoning payload', () => { expect(payload.reasoning_effort).toBe('none') }) + it.each(['none', 'low', 'medium', 'high'] as const)( + 'Qwen 3.8 forwards explicit reasoning effort %s', + async (reasoningEffort) => { + await groqProvider.executeRequest( + request({ model: 'groq/qwen/qwen3.8-27b', reasoningEffort }) + ) + const payload = mockCreate.mock.calls[0][0] + expect(payload.reasoning_effort).toBe(reasoningEffort) + expect(payload.reasoning_format).toBe(reasoningEffort === 'none' ? undefined : 'parsed') + } + ) + + it('strips only the leading routing prefix and preserves custom model case', async () => { + await groqProvider.executeRequest(request({ model: 'Groq/Custom/Model-A' })) + expect(mockCreate.mock.calls[0][0].model).toBe('Custom/Model-A') + }) + it('selects the live tool loop without a caller flag', async () => { mockPrepareToolsWithUsageControl.mockReturnValue({ tools: [ diff --git a/apps/sim/providers/groq/index.ts b/apps/sim/providers/groq/index.ts index 6bccdb9479a..4b6c310b96f 100644 --- a/apps/sim/providers/groq/index.ts +++ b/apps/sim/providers/groq/index.ts @@ -79,7 +79,7 @@ export const groqProvider: ProviderConfig = { : undefined const payload: any = { - model: request.model.replace('groq/', ''), + model: request.model.replace(/^groq\//i, ''), messages: formattedMessages, } @@ -101,6 +101,9 @@ export const groqProvider: ProviderConfig = { if (isGptOss && (hasExplicitEffort || hasThinkingLevel)) { payload.include_reasoning = true payload.reasoning_effort = hasExplicitEffort ? request.reasoningEffort : 'medium' + } else if (isQwenReasoning && hasExplicitEffort) { + payload.reasoning_effort = request.reasoningEffort + if (request.reasoningEffort !== 'none') payload.reasoning_format = 'parsed' } else if (isQwenReasoning && hasThinkingLevel) { payload.reasoning_format = 'parsed' } else if (isQwenReasoning && request.thinkingLevel === 'none') { diff --git a/apps/sim/providers/index.test.ts b/apps/sim/providers/index.test.ts index f6f962b71e5..e7219714432 100644 --- a/apps/sim/providers/index.test.ts +++ b/apps/sim/providers/index.test.ts @@ -1599,6 +1599,30 @@ describe('executeProviderRequest — model level normalization', () => { expect(sentRequest().reasoningEffort).toBe('high') }) + it.each([ + ['azure-openai', 'azure/MyDeployment'], + ['azure-anthropic', 'azure-anthropic/MyDeployment'], + ['bedrock', 'bedrock/custom-inference-profile'], + ['vertex', 'vertex/custom-gemini'], + ])('preserves tuning levels for a custom %s deployment', async (provider, model) => { + await executeProviderRequest(provider, { + model, + workspaceId: 'ws-1', + reasoningEffort: 'high', + verbosity: 'low', + thinkingLevel: 'high', + temperature: 0.7, + }) + + expect(sentRequest()).toMatchObject({ + model, + reasoningEffort: 'high', + verbosity: 'low', + thinkingLevel: 'high', + temperature: 0.7, + }) + }) + it('still drops levels for a dynamic-provider model that does not take them', async () => { await executeProviderRequest('ollama', { model: 'ollama/llama3', diff --git a/apps/sim/providers/index.ts b/apps/sim/providers/index.ts index 01a64387c99..8c7b315f677 100644 --- a/apps/sim/providers/index.ts +++ b/apps/sim/providers/index.ts @@ -114,10 +114,6 @@ function sanitizeRequest(request: ProviderRequest): ProviderRequest { sanitizedRequest.verbosity = normalizeModelLevel(sanitizedRequest.verbosity) sanitizedRequest.thinkingLevel = normalizeModelLevel(sanitizedRequest.thinkingLevel) - if (model && !supportsTemperature(model)) { - sanitizedRequest.temperature = undefined - } - /** * A model absent from the catalogue is unknown, not known-incapable. The model field is an * editable combobox, so a model newer than `models.ts` reaches this point routed by pattern @@ -128,6 +124,10 @@ function sanitizeRequest(request: ProviderRequest): ProviderRequest { */ const isCatalogued = Boolean(model) && isKnownModelId(model) + if (model && isCatalogued && !supportsTemperature(model)) { + sanitizedRequest.temperature = undefined + } + if (model && isCatalogued && !supportsReasoningEffort(model)) { sanitizedRequest.reasoningEffort = undefined } diff --git a/apps/sim/providers/kimi/index.ts b/apps/sim/providers/kimi/index.ts index 0b25f27b5fe..c36a2450116 100644 --- a/apps/sim/providers/kimi/index.ts +++ b/apps/sim/providers/kimi/index.ts @@ -48,6 +48,16 @@ const THINKING_TOGGLE_MODELS = new Set( ) ) +function buildRequiredToolPayload( + tools: OpenAI.Chat.Completions.ChatCompletionTool[], + name: string +) { + return { + tools: tools.filter((tool) => tool.type === 'function' && tool.function.name === name), + tool_choice: 'required' as const, + } +} + function buildResponseFormatPayload( responseFormat: NonNullable ) { @@ -78,8 +88,8 @@ function buildResponseFormatPayload( * rejects the object form whenever thinking is enabled ("tool_choice 'specified' is * incompatible with thinking enabled", verified live). On models with a thinking toggle the * adapter therefore sends `thinking: { type: "disabled" }` for the duration of a forced-tool - * request; on always-thinking models (kimi-k3, kimi-k2.7-code) it downgrades the forced - * choice to `"auto"` with a warning, mirroring the Z.ai adapter's behavior. + * request. K3 supports `required`, so it forces a named tool by offering only that tool; + * K2.7 Code falls back to `auto` because it supports neither forcing mechanism. */ export const kimiProvider: ProviderConfig = { id: 'kimi', @@ -137,6 +147,9 @@ export const kimiProvider: ProviderConfig = { } if (request.maxTokens != null) payload.max_completion_tokens = request.maxTokens + if (request.reasoningEffort && request.reasoningEffort !== 'auto') { + payload.reasoning_effort = request.reasoningEffort + } if ( THINKING_TOGGLE_MODELS.has(request.model) && @@ -162,7 +175,12 @@ export const kimiProvider: ProviderConfig = { hasActiveTools = true if (typeof toolChoice === 'object') { - if (THINKING_TOGGLE_MODELS.has(request.model)) { + if (request.model === 'kimi-k3' && toolChoice.type === 'function') { + Object.assign( + payload, + buildRequiredToolPayload(filteredTools, toolChoice.function.name) + ) + } else if (THINKING_TOGGLE_MODELS.has(request.model)) { if (payload.thinking?.type === 'enabled') { logger.warn( 'Kimi rejects forced tool_choice while thinking is enabled — disabling thinking for this forced-tool request', @@ -241,7 +259,8 @@ export const kimiProvider: ProviderConfig = { } const initialCallTime = Date.now() - const originalToolChoice = payload.tool_choice + const originalToolChoice = + request.model === 'kimi-k3' ? preparedTools?.toolChoice : payload.tool_choice const forcedTools = preparedTools?.forcedTools || [] let usedForcedTools: string[] = [] @@ -457,22 +476,33 @@ export const kimiProvider: ProviderConfig = { const nextPayload = { ...payload, messages: currentMessages, + tools: preparedTools?.tools, } + let nextToolChoice = nextPayload.tool_choice if ( typeof originalToolChoice === 'object' && - hasUsedForcedTool && + (hasUsedForcedTool || request.model === 'kimi-k3') && forcedTools.length > 0 ) { const remainingTools = forcedTools.filter((tool) => !usedForcedTools.includes(tool)) if (remainingTools.length > 0) { - nextPayload.tool_choice = { + nextToolChoice = { type: 'function', function: { name: remainingTools[0] }, } + if (request.model === 'kimi-k3') { + Object.assign( + nextPayload, + buildRequiredToolPayload(preparedTools?.tools || [], remainingTools[0]) + ) + } else { + nextPayload.tool_choice = nextToolChoice + } logger.info(`Forcing next tool: ${remainingTools[0]}`) } else { + nextToolChoice = 'auto' nextPayload.tool_choice = 'auto' logger.info('All forced tools have been used, switching to auto tool_choice') } @@ -486,10 +516,10 @@ export const kimiProvider: ProviderConfig = { const toolCallsResponse = currentResponse.choices[0]?.message?.tool_calls?.filter(isFunctionToolCall) - if (typeof nextPayload.tool_choice === 'object' && toolCallsResponse?.length) { + if (typeof nextToolChoice === 'object' && toolCallsResponse?.length) { const result = trackForcedToolUsage( toolCallsResponse, - nextPayload.tool_choice, + nextToolChoice, logger, 'openai', forcedTools, diff --git a/apps/sim/providers/litellm/index.test.ts b/apps/sim/providers/litellm/index.test.ts index 09525507656..7bc9c3253d0 100644 --- a/apps/sim/providers/litellm/index.test.ts +++ b/apps/sim/providers/litellm/index.test.ts @@ -140,6 +140,11 @@ describe('litellmProvider.executeRequest', () => { mockExecuteTool.mockResolvedValue({ success: true, output: { ok: true } }) }) + it('preserves a custom proxy model name when stripping an uppercase namespace', async () => { + await run({ model: 'LITELLM/Org/CustomModel' }) + expect(firstPayload().model).toBe('Org/CustomModel') + }) + it('assembles messages, strips the model prefix, and maps params', async () => { const result = await run({ systemPrompt: 'You are helpful.', diff --git a/apps/sim/providers/litellm/index.ts b/apps/sim/providers/litellm/index.ts index 2bdcb7e4a66..aa670263b3e 100644 --- a/apps/sim/providers/litellm/index.ts +++ b/apps/sim/providers/litellm/index.ts @@ -142,7 +142,7 @@ export const litellmProvider: ProviderConfig = { : undefined const payload: any = { - model: request.model.replace(/^litellm\//, ''), + model: request.model.replace(/^litellm\//i, ''), messages: formattedMessages, } diff --git a/apps/sim/providers/models.test.ts b/apps/sim/providers/models.test.ts index d6fa3568d05..bcb1d103709 100644 --- a/apps/sim/providers/models.test.ts +++ b/apps/sim/providers/models.test.ts @@ -3,6 +3,7 @@ */ import { describe, expect, it } from 'vitest' import { + findProviderFromModel, getBaseModelProviders, getHostedModels, getModelCapabilities, @@ -11,14 +12,100 @@ import { getPromptCachingMinimumTokens, getProviderModels, getThinkingStreamVisibility, + isCustomModelId, + isKnownModelId, isModelDeprecated, orderModelIdsByReleaseDate, PROVIDER_DEFINITIONS, supportsForcedToolUse, updateFireworksModels, + updateOllamaModels, } from '@/providers/models' import { supportsPromptCaching } from '@/providers/utils' +describe('custom cloud model routing', () => { + it.each([ + 'ollama', + 'ollama-cloud', + 'vllm', + 'litellm', + 'openrouter', + 'fireworks', + 'together', + 'baseten', + ] as const)( + 'accepts new model IDs in the %s namespace without accepting an empty ID', + (provider) => { + expect(findProviderFromModel(`${provider.toUpperCase()}/Org/CustomModel`)).toBe(provider) + expect(isKnownModelId(`${provider}/Org/CustomModel`)).toBe(true) + expect(isKnownModelId(`${provider}/`)).toBe(false) + expect(isKnownModelId(`${provider}/ `)).toBe(false) + } + ) + + it('keeps explicit provider namespaces authoritative over discovered local model names', () => { + const originalModels = PROVIDER_DEFINITIONS.ollama.models + try { + updateOllamaModels([ + 'azure/MyDeployment', + 'bedrock/CustomModel', + 'vertex/CustomModel', + 'openrouter/Org/CustomModel', + 'groq/Org/CustomModel', + 'cerebras/CustomModel', + ]) + expect(findProviderFromModel('azure/MyDeployment')).toBe('azure-openai') + expect(findProviderFromModel('bedrock/CustomModel')).toBe('bedrock') + expect(findProviderFromModel('vertex/CustomModel')).toBe('vertex') + expect(findProviderFromModel('openrouter/Org/CustomModel')).toBe('openrouter') + expect(findProviderFromModel('groq/Org/CustomModel')).toBe('groq') + expect(findProviderFromModel('cerebras/CustomModel')).toBe('cerebras') + } finally { + PROVIDER_DEFINITIONS.ollama.models = originalModels + } + }) + + it.each([ + ['azure/MyDeployment', 'azure-openai'], + ['AZURE/MyDeployment', 'azure-openai'], + ['azure-anthropic/MyDeployment', 'azure-anthropic'], + ['bedrock/custom-model:0', 'bedrock'], + ['BEDROCK/custom-model:0', 'bedrock'], + ['vertex/publishers/google/models/custom-gemini', 'vertex'], + ['VERTEX/CustomModel', 'vertex'], + ['GROQ/Org/CustomModel', 'groq'], + ['CEREBRAS/CustomModel', 'cerebras'], + ['NVIDIA/CustomModel', 'nvidia'], + ])('routes %s without requiring a catalog entry', (model, provider) => { + expect(findProviderFromModel(model)).toBe(provider) + expect(isCustomModelId(model)).toBe(true) + expect(isKnownModelId(model)).toBe(false) + expect(getModelPricing(model)).toBeNull() + expect(getHostedModels()).not.toContain(model) + }) + + it.each([ + 'azure/', + 'azure/ ', + 'azure-anthropic/', + 'bedrock/', + 'vertex/', + 'groq/', + 'cerebras/', + 'nvidia/', + 'unknown/model', + 'gpt-100/model', + 'mistral/model', + ])('does not accept an empty or unrecognized namespace as a custom model: %s', (model) => { + expect(isCustomModelId(model)).toBe(false) + }) + + it('keeps catalog name typos distinct from custom reseller IDs', () => { + expect(isCustomModelId('claude-sonnet-4.6')).toBe(false) + expect(isCustomModelId('gpt-100-ultra')).toBe(false) + }) +}) + describe('OpenAI provider definition', () => { const openai = PROVIDER_DEFINITIONS.openai @@ -52,6 +139,19 @@ describe('OpenAI provider definition', () => { }) }) +describe('direct provider catalog additions', () => { + it.each([ + ['chat-latest', 'openai'], + ['gpt-5.3-codex', 'openai'], + ['gemini-3.7-flash', 'google'], + ])('routes %s through its hosted provider %s', (model, provider) => { + expect(findProviderFromModel(model)).toBe(provider) + expect(isKnownModelId(model)).toBe(true) + expect(getHostedModels()).toContain(model) + expect(getModelPricing(model)?.input).toBeGreaterThan(0) + }) +}) + describe('catalog featured model metadata', () => { it('defines at most one active featured model per provider', () => { for (const provider of Object.values(PROVIDER_DEFINITIONS)) { @@ -292,18 +392,34 @@ describe('sakana provider definition', () => { expect(sakana.modelPatterns).toEqual([/^fugu/]) }) - it('exposes fugu and fugu-ultra with a 1M context window', () => { - expect(sakana.models.map((m) => m.id)).toEqual(['fugu', 'fugu-ultra']) - for (const model of sakana.models) { - expect(model.contextWindow).toBe(1000000) - } + it('preserves existing aliases and exposes current versioned models', () => { + expect(sakana.models.map((model) => model.id)).toEqual( + expect.arrayContaining([ + 'fugu', + 'fugu-ultra', + 'fugu-ultra-v2.0', + 'fugu-max', + 'fugu-max-v1.0', + 'sakana-namazu', + 'sakana-namazu-v1.0', + ]) + ) + expect(sakana.models.find((model) => model.id === 'fugu')?.pricing).toMatchObject({ + input: 5, + output: 30, + cachedInput: 0.5, + updatedAt: '2026-06-22', + }) }) - it('prices both models at the documented fugu-ultra ceiling', () => { - for (const model of sakana.models) { - expect(model.pricing.input).toBe(5) - expect(model.pricing.output).toBe(30) - expect(model.pricing.cachedInput).toBe(0.5) + it('keeps current Ultra aliases aligned with the versioned long-context rate', () => { + for (const id of ['fugu-ultra', 'fugu-ultra-v2.0']) { + expect(sakana.models.find((model) => model.id === id)?.pricing).toMatchObject({ + input: 5, + output: 30, + cachedInput: 0.5, + tiers: [{ aboveInputTokens: 272000, input: 10, cachedInput: 1, output: 45 }], + }) } }) @@ -311,6 +427,8 @@ describe('sakana provider definition', () => { const baseModels = getBaseModelProviders() expect(baseModels.fugu).toBe('sakana') expect(baseModels['fugu-ultra']).toBe('sakana') + expect(baseModels['fugu-max-v1.0']).toBe('sakana') + expect(baseModels['sakana-namazu-v1.0']).toBe('sakana') }) }) @@ -318,6 +436,7 @@ describe('nvidia provider definition', () => { const nvidia = PROVIDER_DEFINITIONS.nvidia const expectedModels = [ + { id: 'nvidia/nemotron-3.5-lightning-30b-a3b', contextWindow: 1000000 }, { id: 'nvidia/llama-3.1-nemotron-70b-instruct', contextWindow: 128000 }, { id: 'nvidia/llama-3.1-nemotron-ultra-253b-v1', contextWindow: 131072 }, { id: 'nvidia/llama-3.3-nemotron-super-49b-v1.5', contextWindow: 131072 }, @@ -333,7 +452,7 @@ describe('nvidia provider definition', () => { expect(nvidia.modelPatterns).toEqual([/^nvidia\//]) }) - it('exposes all six Nemotron models with the documented context windows', () => { + it('exposes Nemotron models with the documented context windows', () => { expect(nvidia.models.map((m) => m.id)).toEqual(expectedModels.map((m) => m.id)) for (const expected of expectedModels) { const model = nvidia.models.find((m) => m.id === expected.id) diff --git a/apps/sim/providers/models.ts b/apps/sim/providers/models.ts index 37ccde6c448..edd140d3b28 100644 --- a/apps/sim/providers/models.ts +++ b/apps/sim/providers/models.ts @@ -635,6 +635,23 @@ export const PROVIDER_DEFINITIONS: Record = { releaseDate: '2026-03-17', speedOptimized: true, }, + { + id: 'gpt-5.3-codex', + pricing: { + input: 1.75, + cachedInput: 0.175, + output: 14.0, + updatedAt: '2026-09-14', + }, + capabilities: { + reasoningEffort: { + values: ['low', 'medium', 'high', 'xhigh'], + }, + maxOutputTokens: 128000, + }, + contextWindow: 400000, + releaseDate: '2026-02-05', + }, // GPT-5.2 family { id: 'gpt-5.2-pro', @@ -770,6 +787,20 @@ export const PROVIDER_DEFINITIONS: Record = { contextWindow: 400000, releaseDate: '2025-08-07', }, + { + id: 'chat-latest', + pricing: { + input: 5.0, + cachedInput: 0.5, + output: 30.0, + updatedAt: '2026-09-14', + }, + capabilities: { + maxOutputTokens: 128000, + }, + contextWindow: 400000, + releaseDate: '2026-05-05', + }, { id: 'gpt-5-chat-latest', pricing: { @@ -1245,6 +1276,132 @@ export const PROVIDER_DEFINITIONS: Record = { icon: AzureIcon, isReseller: true, models: [ + { + id: 'azure/gpt-6-astra', + pricing: { + input: 10, + cachedInput: 1, + output: 50, + updatedAt: '2026-09-14', + }, + capabilities: { + reasoningEffort: { + values: ['low', 'medium', 'high', 'xhigh', 'max'], + }, + verbosity: { + values: ['low', 'medium', 'high'], + }, + maxOutputTokens: 128000, + }, + contextWindow: 1050000, + releaseDate: '2026-09-03', + }, + { + id: 'azure/gpt-5.6-sol', + pricing: { + input: 4, + cachedInput: 0.5, + output: 20, + updatedAt: '2026-09-14', + }, + capabilities: { + reasoningEffort: { + values: ['none', 'low', 'medium', 'high', 'xhigh', 'max'], + }, + verbosity: { + values: ['low', 'medium', 'high'], + }, + maxOutputTokens: 128000, + }, + contextWindow: 1050000, + releaseDate: '2026-07-09', + }, + { + id: 'azure/gpt-5.6-terra', + pricing: { + input: 2, + cachedInput: 0.2, + output: 12, + updatedAt: '2026-09-14', + }, + capabilities: { + reasoningEffort: { + values: ['none', 'low', 'medium', 'high', 'xhigh', 'max'], + }, + verbosity: { + values: ['low', 'medium', 'high'], + }, + maxOutputTokens: 128000, + }, + contextWindow: 1050000, + releaseDate: '2026-07-09', + }, + { + id: 'azure/gpt-5.6-luna', + pricing: { + input: 0.2, + cachedInput: 0.02, + output: 1.2, + updatedAt: '2026-09-14', + }, + capabilities: { + reasoningEffort: { + values: ['none', 'low', 'medium', 'high', 'xhigh', 'max'], + }, + verbosity: { + values: ['low', 'medium', 'high'], + }, + maxOutputTokens: 128000, + }, + contextWindow: 1050000, + releaseDate: '2026-07-09', + }, + { + id: 'azure/gpt-5.5', + pricing: { + input: 5, + cachedInput: 0.5, + output: 30, + updatedAt: '2026-09-14', + }, + capabilities: { + reasoningEffort: { + values: ['none', 'low', 'medium', 'high', 'xhigh'], + }, + verbosity: { + values: ['low', 'medium', 'high'], + }, + maxOutputTokens: 128000, + }, + contextWindow: 1050000, + releaseDate: '2026-04-24', + }, + { + id: 'azure/gpt-5.4-pro', + pricing: { + input: 30, + output: 180, + tiers: [ + { + aboveInputTokens: 272000, + input: 60, + output: 270, + }, + ], + updatedAt: '2026-09-14', + }, + capabilities: { + reasoningEffort: { + values: ['medium', 'high', 'xhigh'], + }, + verbosity: { + values: ['low', 'medium', 'high'], + }, + maxOutputTokens: 128000, + }, + contextWindow: 1050000, + releaseDate: '2026-03-05', + }, { id: 'azure/gpt-4o', pricing: { @@ -1563,6 +1720,90 @@ export const PROVIDER_DEFINITIONS: Record = { promptCaching: { minimumCacheableTokens: 1024 }, }, models: [ + { + id: 'azure-anthropic/claude-fable-5-1', + pricing: { + input: 10, + cachedInput: 0.25, + output: 50, + updatedAt: '2026-09-14', + }, + capabilities: { + forcedToolUse: false, + nativeStructuredOutputs: true, + maxOutputTokens: 128000, + promptCaching: { minimumCacheableTokens: 512 }, + thinking: { + levels: ['low', 'medium', 'high', 'xhigh'], + default: 'high', + streamed: 'summary', + }, + }, + contextWindow: 1000000, + releaseDate: '2026-09-01', + }, + { + id: 'azure-anthropic/claude-opus-5', + pricing: { + input: 5, + cachedInput: 0.5, + output: 25, + updatedAt: '2026-09-14', + }, + capabilities: { + nativeStructuredOutputs: true, + maxOutputTokens: 128000, + promptCaching: { minimumCacheableTokens: 512 }, + thinking: { + levels: ['low', 'medium', 'high', 'xhigh', 'max'], + default: 'high', + streamed: 'summary', + }, + }, + contextWindow: 1000000, + releaseDate: '2026-07-24', + }, + { + id: 'azure-anthropic/claude-opus-4-8', + pricing: { + input: 5, + cachedInput: 0.5, + output: 25, + updatedAt: '2026-09-14', + }, + capabilities: { + nativeStructuredOutputs: true, + maxOutputTokens: 128000, + thinking: { + levels: ['low', 'medium', 'high', 'xhigh', 'max'], + default: 'high', + streamed: 'summary', + }, + }, + contextWindow: 1000000, + releaseDate: '2026-05-28', + }, + { + id: 'azure-anthropic/claude-opus-4-7', + pricing: { + input: 5, + cachedInput: 0.5, + output: 25, + updatedAt: '2026-09-14', + }, + capabilities: { + nativeStructuredOutputs: true, + maxOutputTokens: 128000, + promptCaching: { minimumCacheableTokens: 2048 }, + thinking: { + levels: ['low', 'medium', 'high', 'xhigh', 'max'], + default: 'high', + streamed: 'summary', + }, + }, + contextWindow: 1000000, + releaseDate: '2026-04-16', + }, { id: 'azure-anthropic/claude-opus-4-6', pricing: { @@ -1607,6 +1848,47 @@ export const PROVIDER_DEFINITIONS: Record = { contextWindow: 200000, releaseDate: '2025-11-24', }, + { + id: 'azure-anthropic/claude-sonnet-5', + pricing: { + input: 2, + cachedInput: 0.2, + output: 10, + updatedAt: '2026-09-14', + }, + capabilities: { + nativeStructuredOutputs: true, + maxOutputTokens: 128000, + thinking: { + levels: ['low', 'medium', 'high', 'xhigh', 'max'], + default: 'high', + streamed: 'summary', + }, + }, + contextWindow: 1000000, + releaseDate: '2026-06-30', + }, + { + id: 'azure-anthropic/claude-sonnet-4-6', + pricing: { + input: 3, + cachedInput: 0.3, + output: 15, + updatedAt: '2026-09-14', + }, + capabilities: { + temperature: { min: 0, max: 1 }, + nativeStructuredOutputs: true, + maxOutputTokens: 128000, + thinking: { + levels: ['low', 'medium', 'high', 'max'], + default: 'high', + streamed: 'summary', + }, + }, + contextWindow: 1000000, + releaseDate: '2026-02-17', + }, { id: 'azure-anthropic/claude-sonnet-4-5', pricing: { @@ -1707,13 +1989,32 @@ export const PROVIDER_DEFINITIONS: Record = { featured: true, recommended: true, }, + { + id: 'gemini-3.7-flash', + pricing: { + input: 0.75, + cachedInput: 0.075, + output: 3.75, + updatedAt: '2026-09-14', + }, + capabilities: { + temperature: { min: 0, max: 2 }, + thinking: { + levels: ['low', 'medium', 'high'], + default: 'medium', + }, + maxOutputTokens: 65536, + }, + contextWindow: 1048576, + releaseDate: '2026-08-13', + }, { id: 'gemini-3.6-flash', pricing: { - input: 1.5, - cachedInput: 0.15, - output: 7.5, - updatedAt: '2026-07-21', + input: 0.75, + cachedInput: 0.075, + output: 3.75, + updatedAt: '2026-09-14', }, capabilities: { temperature: { min: 0, max: 2 }, @@ -1771,7 +2072,15 @@ export const PROVIDER_DEFINITIONS: Record = { input: 2.0, cachedInput: 0.2, output: 12.0, - updatedAt: '2026-06-11', + tiers: [ + { + aboveInputTokens: 200000, + input: 4.0, + cachedInput: 0.4, + output: 18.0, + }, + ], + updatedAt: '2026-09-14', }, capabilities: { temperature: { min: 0, max: 2 }, @@ -1829,7 +2138,15 @@ export const PROVIDER_DEFINITIONS: Record = { input: 1.25, cachedInput: 0.125, output: 10.0, - updatedAt: '2026-06-11', + tiers: [ + { + aboveInputTokens: 200000, + input: 2.5, + cachedInput: 0.25, + output: 15.0, + }, + ], + updatedAt: '2026-09-14', }, capabilities: { temperature: { min: 0, max: 2 }, @@ -1942,6 +2259,79 @@ export const PROVIDER_DEFINITIONS: Record = { toolUsageControl: true, }, models: [ + { + id: 'vertex/gemini-3.8-flash', + pricing: { + input: 0.75, + cachedInput: 0.075, + output: 3.75, + updatedAt: '2026-09-14', + }, + capabilities: { + thinking: { + levels: ['low', 'medium', 'high'], + default: 'medium', + }, + maxOutputTokens: 65536, + }, + contextWindow: 1048576, + releaseDate: '2026-09-02', + }, + { + id: 'vertex/gemini-3.7-flash', + pricing: { + input: 0.75, + cachedInput: 0.075, + output: 3.75, + updatedAt: '2026-09-14', + }, + capabilities: { + thinking: { + levels: ['low', 'medium', 'high'], + default: 'medium', + }, + maxOutputTokens: 65536, + }, + contextWindow: 1048576, + releaseDate: '2026-08-13', + }, + { + id: 'vertex/gemini-3.6-flash', + pricing: { + input: 0.75, + cachedInput: 0.075, + output: 3.75, + updatedAt: '2026-09-14', + }, + capabilities: { + thinking: { + levels: ['minimal', 'low', 'medium', 'high'], + default: 'medium', + }, + maxOutputTokens: 65536, + }, + contextWindow: 1048576, + releaseDate: '2026-07-21', + }, + { + id: 'vertex/gemini-3.5-flash-lite', + pricing: { + input: 0.3, + cachedInput: 0.03, + output: 2.5, + updatedAt: '2026-09-14', + }, + capabilities: { + thinking: { + levels: ['minimal', 'low', 'medium', 'high'], + default: 'minimal', + }, + maxOutputTokens: 65536, + }, + contextWindow: 1048576, + releaseDate: '2026-07-21', + speedOptimized: true, + }, { id: 'vertex/gemini-3.5-flash', pricing: { @@ -2159,14 +2549,15 @@ export const PROVIDER_DEFINITIONS: Record = { { id: 'deepseek-v4-pro', pricing: { - input: 0.435, - cachedInput: 0.003625, - output: 0.87, - updatedAt: '2026-06-16', + /** Peak rates; off-peak API requests cost half these rates. */ + input: 1.32, + cachedInput: 0.044, + output: 3.96, + updatedAt: '2026-09-14', }, capabilities: { reasoningEffort: { - values: ['high', 'max'], + values: ['low', 'high', 'max'], }, thinking: { levels: ['none', 'enabled'], @@ -2177,18 +2568,42 @@ export const PROVIDER_DEFINITIONS: Record = { contextWindow: 1000000, releaseDate: '2026-04-24', }, + { + id: 'deepseek-flash', + pricing: { + /** Peak rates; off-peak API requests cost half these rates. */ + input: 0.3, + cachedInput: 0.006, + output: 1.2, + updatedAt: '2026-09-14', + }, + capabilities: { + temperature: { min: 0, max: 2 }, + reasoningEffort: { + values: ['low', 'high', 'max'], + }, + thinking: { + levels: ['none', 'enabled'], + default: 'enabled', + }, + maxOutputTokens: 384000, + }, + contextWindow: 1000000, + releaseDate: '2026-09-10', + }, { id: 'deepseek-v4-flash', pricing: { - input: 0.14, - cachedInput: 0.0028, - output: 0.28, - updatedAt: '2026-06-16', + /** Legacy alias now serves V4.1 Flash, including its peak pricing. */ + input: 0.3, + cachedInput: 0.006, + output: 1.2, + updatedAt: '2026-09-14', }, capabilities: { temperature: { min: 0, max: 2 }, reasoningEffort: { - values: ['high', 'max'], + values: ['low', 'high', 'max'], }, thinking: { levels: ['none', 'enabled'], @@ -2289,7 +2704,15 @@ export const PROVIDER_DEFINITIONS: Record = { input: 2.0, cachedInput: 0.5, output: 6.0, - updatedAt: '2026-08-12', + tiers: [ + { + aboveInputTokens: 200000, + input: 4.0, + cachedInput: 1.0, + output: 12.0, + }, + ], + updatedAt: '2026-09-14', }, capabilities: { temperature: { min: 0, max: 2 }, @@ -2529,6 +2952,23 @@ export const PROVIDER_DEFINITIONS: Record = { toolUsageControl: true, }, models: [ + { + id: 'cerebras/qwen-3.8-27b', + pricing: { + input: 0.99, + output: 1.49, + updatedAt: '2026-09-14', + }, + capabilities: { + temperature: { min: 0, max: 2 }, + reasoningEffort: { + values: ['none', 'low', 'medium', 'high'], + }, + maxOutputTokens: 40960, + }, + contextWindow: 131072, + releaseDate: '2026-08-14', + }, { id: 'cerebras/gpt-oss-120b', pricing: { @@ -2681,6 +3121,23 @@ export const PROVIDER_DEFINITIONS: Record = { releaseDate: '2025-04-29', sunset: { status: 'deprecated' }, }, + { + id: 'groq/qwen/qwen3.8-27b', + pricing: { + input: 0.8, + output: 4.0, + updatedAt: '2026-09-14', + }, + capabilities: { + temperature: { min: 0, max: 2 }, + reasoningEffort: { + values: ['none', 'low', 'medium', 'high'], + }, + maxOutputTokens: 16384, + }, + contextWindow: 131042, + releaseDate: '2026-08-14', + }, { id: 'groq/qwen/qwen3.6-27b', pricing: { @@ -2689,7 +3146,7 @@ export const PROVIDER_DEFINITIONS: Record = { updatedAt: '2026-07-10', }, capabilities: { - maxOutputTokens: 32768, + maxOutputTokens: 16384, thinking: { levels: ['enabled'], default: 'enabled', @@ -2756,16 +3213,69 @@ export const PROVIDER_DEFINITIONS: Record = { sakana: { id: 'sakana', name: 'Sakana AI', - description: "Sakana AI's Fugu multi-agent models via an OpenAI-compatible API", + description: 'Sakana AI Fugu and Namazu models via an OpenAI-compatible API', defaultModel: 'fugu', modelPatterns: [/^fugu/], icon: SakanaIcon, color: '#E60000', capabilities: { - temperature: { min: 0, max: 2 }, toolUsageControl: true, }, models: [ + { + id: 'fugu-ultra-v2.0', + pricing: { + input: 5, + cachedInput: 0.5, + output: 30, + tiers: [{ aboveInputTokens: 272000, input: 10, cachedInput: 1, output: 45 }], + updatedAt: '2026-09-14', + }, + capabilities: {}, + releaseDate: '2026-09-11', + }, + { + id: 'fugu-max', + pricing: { + input: 2, + cachedInput: 0.25, + output: 6, + updatedAt: '2026-09-14', + }, + capabilities: {}, + releaseDate: '2026-09-11', + }, + { + id: 'fugu-max-v1.0', + pricing: { + input: 2, + cachedInput: 0.25, + output: 6, + updatedAt: '2026-09-14', + }, + capabilities: {}, + releaseDate: '2026-09-11', + }, + { + id: 'sakana-namazu', + pricing: { + input: 0.95, + cachedInput: 0.15, + output: 4, + updatedAt: '2026-09-14', + }, + capabilities: {}, + }, + { + id: 'sakana-namazu-v1.0', + pricing: { + input: 0.95, + cachedInput: 0.15, + output: 4, + updatedAt: '2026-09-14', + }, + capabilities: {}, + }, { id: 'fugu', pricing: { @@ -2785,7 +3295,8 @@ export const PROVIDER_DEFINITIONS: Record = { input: 5, cachedInput: 0.5, output: 30, - updatedAt: '2026-06-22', + tiers: [{ aboveInputTokens: 272000, input: 10, cachedInput: 1, output: 45 }], + updatedAt: '2026-09-14', }, capabilities: {}, contextWindow: 1000000, @@ -2805,10 +3316,22 @@ export const PROVIDER_DEFINITIONS: Record = { isReseller: true, contextInformationAvailable: true, capabilities: { - temperature: { min: 0, max: 2 }, toolUsageControl: true, }, models: [ + { + id: 'nvidia/nemotron-3.5-lightning-30b-a3b', + pricing: { + input: 0, + output: 0, + updatedAt: '2026-09-14', + }, + capabilities: { + nativeStructuredOutputs: false, + }, + contextWindow: 1000000, + releaseDate: '2026-08-11', + }, { id: 'nvidia/llama-3.1-nemotron-70b-instruct', pricing: { @@ -2976,6 +3499,9 @@ export const PROVIDER_DEFINITIONS: Record = { }, capabilities: { toolUsageControl: true, + reasoningEffort: { + values: ['low', 'high', 'max'], + }, maxOutputTokens: 1048576, }, contextWindow: 1048576, @@ -3048,8 +3574,9 @@ export const PROVIDER_DEFINITIONS: Record = { id: 'glm-5.3', pricing: { input: 1.4, + cachedInput: 0.26, output: 4.4, - updatedAt: '2026-08-26', + updatedAt: '2026-09-14', }, capabilities: { temperature: { min: 0, max: 1 }, @@ -3067,13 +3594,17 @@ export const PROVIDER_DEFINITIONS: Record = { id: 'glm-5.3-flash', pricing: { input: 0.15, + cachedInput: 0.03, output: 0.5, - updatedAt: '2026-08-26', + updatedAt: '2026-09-14', }, capabilities: { temperature: { min: 0, max: 1 }, toolUsageControl: true, maxOutputTokens: 131072, + reasoningEffort: { + values: ['low', 'high', 'max'], + }, }, contextWindow: 1000000, releaseDate: '2026-08-26', @@ -3311,6 +3842,65 @@ export const PROVIDER_DEFINITIONS: Record = { toolUsageControl: true, }, models: [ + { + id: 'mistral-medium-3-5', + pricing: { + input: 1.5, + cachedInput: 0.15, + output: 7.5, + updatedAt: '2026-09-14', + }, + capabilities: { + temperature: { min: 0, max: 1.5 }, + }, + contextWindow: 256000, + releaseDate: '2026-04-28', + }, + { + id: 'mistral-medium-3', + pricing: { + input: 1.5, + cachedInput: 0.15, + output: 7.5, + updatedAt: '2026-09-14', + }, + capabilities: { + temperature: { min: 0, max: 1.5 }, + }, + contextWindow: 256000, + releaseDate: '2026-04-28', + }, + { + id: 'zai-glm-5-2', + pricing: { + input: 1.4, + cachedInput: 0.14, + output: 4.4, + updatedAt: '2026-09-14', + }, + capabilities: { + temperature: { min: 0, max: 1.5 }, + maxOutputTokens: 128000, + }, + contextWindow: 1000000, + releaseDate: '2026-08-06', + }, + { + /** Free preview retires September 30, 2026: https://docs.mistral.ai/resources/changelogs */ + id: 'labs-leanstral-1-5', + pricing: { + input: 0, + cachedInput: 0, + output: 0, + updatedAt: '2026-09-14', + }, + capabilities: { + temperature: { min: 0, max: 1.5 }, + maxOutputTokens: 128000, + }, + contextWindow: 256000, + releaseDate: '2026-06-30', + }, { id: 'mistral-large-latest', pricing: { @@ -3435,15 +4025,16 @@ export const PROVIDER_DEFINITIONS: Record = { { id: 'mistral-medium-latest', pricing: { - input: 0.4, - output: 2.0, - updatedAt: '2026-06-11', + input: 1.5, + cachedInput: 0.15, + output: 7.5, + updatedAt: '2026-09-14', }, capabilities: { temperature: { min: 0, max: 1.5 }, }, - contextWindow: 128000, - releaseDate: '2025-08-12', + contextWindow: 256000, + releaseDate: '2026-04-28', }, { id: 'mistral-medium-2604', @@ -3696,7 +4287,7 @@ export const PROVIDER_DEFINITIONS: Record = { name: 'Ollama', description: 'Local LLM models via Ollama', defaultModel: '', - modelPatterns: [], + modelPatterns: [/^ollama\//], icon: OllamaIcon, capabilities: { toolUsageControl: false, // Ollama does not support tool_choice parameter @@ -3714,10 +4305,111 @@ export const PROVIDER_DEFINITIONS: Record = { color: '#FF9900', isReseller: true, capabilities: { - temperature: { min: 0, max: 1 }, toolUsageControl: true, }, models: [ + { + id: 'bedrock/anthropic.claude-opus-5', + pricing: { + input: 5.5, + cachedInput: 0.55, + output: 27.5, + updatedAt: '2026-09-14', + }, + capabilities: { + maxOutputTokens: 128000, + }, + contextWindow: 1000000, + releaseDate: '2026-07-24', + }, + { + id: 'bedrock/anthropic.claude-sonnet-5', + pricing: { + input: 2.2, + cachedInput: 0.22, + output: 11, + updatedAt: '2026-09-14', + }, + capabilities: { + maxOutputTokens: 128000, + }, + contextWindow: 1000000, + releaseDate: '2026-06-30', + }, + { + id: 'bedrock/anthropic.claude-fable-5', + pricing: { + input: 11, + cachedInput: 1.1, + output: 55, + updatedAt: '2026-09-14', + }, + capabilities: { + maxOutputTokens: 128000, + }, + contextWindow: 1000000, + releaseDate: '2026-06-09', + }, + { + id: 'bedrock/anthropic.claude-opus-4-8', + pricing: { + input: 5.5, + cachedInput: 0.55, + output: 27.5, + updatedAt: '2026-09-14', + }, + capabilities: { + maxOutputTokens: 128000, + }, + contextWindow: 1000000, + releaseDate: '2026-05-28', + }, + { + id: 'bedrock/anthropic.claude-opus-4-7', + pricing: { + input: 5.5, + cachedInput: 0.55, + output: 27.5, + updatedAt: '2026-09-14', + }, + capabilities: { + maxOutputTokens: 128000, + }, + contextWindow: 1000000, + releaseDate: '2026-04-16', + }, + { + id: 'bedrock/anthropic.claude-sonnet-4-6', + pricing: { + input: 3.3, + cachedInput: 0.33, + output: 16.5, + updatedAt: '2026-09-14', + }, + capabilities: { + temperature: { min: 0, max: 1 }, + nativeStructuredOutputs: true, + maxOutputTokens: 64000, + }, + contextWindow: 1000000, + releaseDate: '2026-02-17', + }, + { + id: 'bedrock/anthropic.claude-opus-4-6-v1', + pricing: { + input: 5.5, + cachedInput: 0.55, + output: 27.5, + updatedAt: '2026-09-14', + }, + capabilities: { + temperature: { min: 0, max: 1 }, + nativeStructuredOutputs: true, + maxOutputTokens: 128000, + }, + contextWindow: 1000000, + releaseDate: '2026-02-05', + }, { id: 'bedrock/anthropic.claude-opus-4-5-20251101-v1:0', pricing: { @@ -3785,6 +4477,140 @@ export const PROVIDER_DEFINITIONS: Record = { releaseDate: '2025-08-05', sunset: { status: 'legacy' }, }, + { + id: 'bedrock/openai.gpt-6-astra', + pricing: { + input: 11, + output: 55, + tiers: [{ aboveInputTokens: 272000, input: 22, output: 82.5 }], + updatedAt: '2026-09-14', + }, + capabilities: { + maxOutputTokens: 128000, + }, + contextWindow: 1050000, + releaseDate: '2026-09-08', + }, + { + id: 'bedrock/openai.gpt-5.6-sol', + pricing: { + input: 4.4, + output: 22, + tiers: [{ aboveInputTokens: 272000, input: 8.8, output: 33 }], + updatedAt: '2026-09-14', + }, + capabilities: {}, + contextWindow: 1000000, + releaseDate: '2026-07-13', + }, + { + id: 'bedrock/openai.gpt-5.6-terra', + pricing: { + input: 2.2, + output: 13.2, + tiers: [{ aboveInputTokens: 272000, input: 4.4, output: 19.8 }], + updatedAt: '2026-09-14', + }, + capabilities: {}, + contextWindow: 1000000, + releaseDate: '2026-07-13', + }, + { + id: 'bedrock/openai.gpt-5.6-luna', + pricing: { + input: 0.22, + output: 1.32, + tiers: [{ aboveInputTokens: 272000, input: 0.44, output: 1.98 }], + updatedAt: '2026-09-14', + }, + capabilities: {}, + contextWindow: 1000000, + releaseDate: '2026-07-13', + }, + { + id: 'bedrock/openai.gpt-oss-120b-1:0', + pricing: { + input: 0.15, + output: 0.6, + updatedAt: '2026-09-14', + }, + capabilities: { + nativeStructuredOutputs: true, + maxOutputTokens: 16000, + }, + contextWindow: 128000, + releaseDate: '2025-08-05', + }, + { + id: 'bedrock/openai.gpt-oss-20b-1:0', + pricing: { + input: 0.07, + output: 0.3, + updatedAt: '2026-09-14', + }, + capabilities: { + nativeStructuredOutputs: true, + maxOutputTokens: 16000, + }, + contextWindow: 128000, + releaseDate: '2025-08-05', + }, + { + id: 'bedrock/minimax.minimax-m2.5', + pricing: { + input: 0.3, + output: 1.2, + updatedAt: '2026-09-14', + }, + capabilities: { + nativeStructuredOutputs: true, + maxOutputTokens: 8000, + }, + contextWindow: 196000, + releaseDate: '2026-02-12', + }, + { + id: 'bedrock/zai.glm-5', + pricing: { + input: 1, + output: 3.2, + updatedAt: '2026-09-14', + }, + capabilities: { + nativeStructuredOutputs: true, + maxOutputTokens: 128000, + }, + contextWindow: 200000, + releaseDate: '2026-02-11', + }, + { + id: 'bedrock/moonshotai.kimi-k2.5', + pricing: { + input: 0.6, + output: 3, + updatedAt: '2026-09-14', + }, + capabilities: { + nativeStructuredOutputs: true, + maxOutputTokens: 16000, + }, + contextWindow: 256000, + releaseDate: '2026-01-27', + }, + { + id: 'bedrock/deepseek.v3.2', + pricing: { + input: 0.62, + output: 1.85, + updatedAt: '2026-09-14', + }, + capabilities: { + nativeStructuredOutputs: true, + maxOutputTokens: 8000, + }, + contextWindow: 164000, + releaseDate: '2025-12-01', + }, { id: 'bedrock/amazon.nova-2-lite-v1:0', pricing: { @@ -4233,6 +5059,18 @@ export const DYNAMIC_MODEL_PROVIDERS = [ 'baseten', ] as const +/** Providers whose adapter accepts namespaced IDs, rather than only a native model-family name. */ +const NAMESPACED_MODEL_PROVIDERS = new Set( + Object.entries(PROVIDER_DEFINITIONS) + .filter( + ([providerId, provider]) => + provider.isReseller || + (DYNAMIC_MODEL_PROVIDERS as readonly string[]).includes(providerId) || + provider.models.some((model) => model.id.startsWith(`${providerId}/`)) + ) + .map(([providerId]) => providerId) +) + function getAllStaticModelIds(): string[] { const ids: string[] = [] for (const [providerId, provider] of Object.entries(PROVIDER_DEFINITIONS)) { @@ -4253,7 +5091,7 @@ export function isKnownModelId(modelId: string): boolean { const lowered = trimmed.toLowerCase() for (const provider of DYNAMIC_MODEL_PROVIDERS) { - if (lowered.startsWith(`${provider}/`)) return true + if (lowered.startsWith(`${provider}/`) && lowered.slice(provider.length + 1).trim()) return true } return false @@ -4347,9 +5185,20 @@ export function getBaseModelProviders(): Record { ) } -export function getProviderFromModel(model: string): ProviderId { +/** Resolves catalog entries and provider patterns without guessing a fallback provider. */ +export function findProviderFromModel(model: string): ProviderId | null { const normalizedModel = model.toLowerCase() + /** Explicit provider namespaces take precedence over names discovered on a local server. */ + for (const [providerId, provider] of Object.entries(PROVIDER_DEFINITIONS)) { + if ( + NAMESPACED_MODEL_PROVIDERS.has(providerId) && + provider.modelPatterns?.some((pattern) => pattern.test(normalizedModel)) + ) { + return providerId as ProviderId + } + } + for (const [providerId, provider] of Object.entries(PROVIDER_DEFINITIONS)) { if ( provider.models.some((providerModel) => providerModel.id.toLowerCase() === normalizedModel) @@ -4364,7 +5213,20 @@ export function getProviderFromModel(model: string): ProviderId { } } - return 'ollama' + return null +} + +export function getProviderFromModel(model: string): ProviderId { + return findProviderFromModel(model) ?? 'ollama' +} + +/** Recognized provider namespaces accept deployment and model IDs outside the static catalog. */ +export function isCustomModelId(modelId: string): boolean { + const separator = modelId.indexOf('/') + if (separator < 1 || modelId.slice(separator + 1).trim().length === 0) return false + + const providerId = findProviderFromModel(modelId) + return providerId !== null && NAMESPACED_MODEL_PROVIDERS.has(providerId) } export function getProviderIcon(model: string): React.ComponentType<{ className?: string }> | null { diff --git a/apps/sim/providers/nvidia/index.ts b/apps/sim/providers/nvidia/index.ts index 5973ba2af54..4f29d730527 100644 --- a/apps/sim/providers/nvidia/index.ts +++ b/apps/sim/providers/nvidia/index.ts @@ -6,7 +6,11 @@ import type { ChatCompletionChunk } from 'openai/resources/chat/completions' import type { StreamingExecution } from '@/executor/types' import { MAX_TOOL_ITERATIONS } from '@/providers' import { formatMessagesForProvider } from '@/providers/attachments' -import { getProviderDefaultModel, getProviderModels } from '@/providers/models' +import { + getModelCapabilities, + getProviderDefaultModel, + getProviderModels, +} from '@/providers/models' import { createReadableStreamFromNvidiaStream } from '@/providers/nvidia/utils' import { createOpenAICompatAssistantHistory } from '@/providers/openai-compat/assistant-history' import { executeProviderTool } from '@/providers/runtime-context' @@ -25,6 +29,7 @@ import type { import { ProviderError } from '@/providers/types' import { calculateCost, + generateSchemaInstructions, isFunctionToolCall, prepareToolExecution, prepareToolsWithUsageControl, @@ -86,13 +91,22 @@ export const nvidiaProvider: ProviderConfig = { allMessages.push(...request.messages) } const formattedMessages = formatMessagesForProvider(allMessages, 'nvidia') + const useJsonMode = + !!request.responseFormat && + getModelCapabilities(request.model)?.nativeStructuredOutputs === false + if (useJsonMode) { + formattedMessages.push({ + role: 'system', + content: generateSchemaInstructions(request.responseFormat), + }) + } const tools = request.tools?.length ? request.tools.map((tool) => adaptOpenAIChatToolSchema(tool)) : undefined const payload: any = { - model: request.model, + model: request.model.replace(/^nvidia\//i, 'nvidia/'), messages: formattedMessages, } @@ -100,16 +114,20 @@ export const nvidiaProvider: ProviderConfig = { if (request.maxTokens != null) payload.max_tokens = request.maxTokens const responseFormatPayload = request.responseFormat - ? { - type: 'json_schema' as const, - json_schema: { - name: request.responseFormat.name || 'response_schema', - schema: request.responseFormat.schema || request.responseFormat, - strict: request.responseFormat.strict !== false, - }, - } + ? useJsonMode + ? { type: 'json_object' as const } + : { + type: 'json_schema' as const, + json_schema: { + name: request.responseFormat.name || 'response_schema', + schema: request.responseFormat.schema || request.responseFormat, + strict: request.responseFormat.strict !== false, + }, + } : undefined + if (useJsonMode) payload.chat_template_kwargs = { enable_thinking: false } + let preparedTools: ReturnType | null = null let hasActiveTools = false diff --git a/apps/sim/providers/ollama-cloud/index.test.ts b/apps/sim/providers/ollama-cloud/index.test.ts index cbd33346641..ceed999a97a 100644 --- a/apps/sim/providers/ollama-cloud/index.test.ts +++ b/apps/sim/providers/ollama-cloud/index.test.ts @@ -184,6 +184,18 @@ describe('ollamaCloudProvider.executeRequest', () => { expect(result).toMatchObject({ content: 'hello', model: 'gpt-oss:120b' }) }) + it.each([ + ['ollama-cloud/deepseek-v4.1-flash', 'deepseek-v4.1-flash'], + ['ollama-cloud/glm-5.3', 'glm-5.3'], + ['OLLAMA-CLOUD/Org/CustomModel', 'Org/CustomModel'], + ])( + 'forwards new and custom cloud models without changing their IDs: %s', + async (model, wireModel) => { + await ollamaCloudProvider.executeRequest({ ...baseRequest, model }) + expect(mockCreate.mock.calls[0][0].model).toBe(wireModel) + } + ) + it('assembles system, context, then history in order and forwards params', async () => { await ollamaCloudProvider.executeRequest({ ...baseRequest, diff --git a/apps/sim/providers/ollama-cloud/index.ts b/apps/sim/providers/ollama-cloud/index.ts index 11d7bb4372b..b23f7ccc8f2 100644 --- a/apps/sim/providers/ollama-cloud/index.ts +++ b/apps/sim/providers/ollama-cloud/index.ts @@ -28,7 +28,7 @@ export const ollamaCloudProvider: ProviderConfig = { throw new Error('API key is required for Ollama Cloud') } - const requestedModel = request.model.replace(/^ollama-cloud\//, '') + const requestedModel = request.model.replace(/^ollama-cloud\//i, '') return executeOllamaProviderRequest( { ...request, model: requestedModel }, diff --git a/apps/sim/providers/ollama/index.test.ts b/apps/sim/providers/ollama/index.test.ts index feda81c9afa..d3940ca3fe7 100644 --- a/apps/sim/providers/ollama/index.test.ts +++ b/apps/sim/providers/ollama/index.test.ts @@ -149,6 +149,14 @@ describe('ollamaProvider.executeRequest', () => { mockExecuteTool.mockResolvedValue({ success: true, output: { ok: true } }) }) + it.each(['ollama/Org/CustomModel', 'OLLAMA/Org/CustomModel', 'Org/CustomModel'])( + 'preserves the local model ID while removing only its optional namespace: %s', + async (model) => { + await ollamaProvider.executeRequest({ ...baseRequest, model }) + expect(mockCreate.mock.calls[0][0].model).toBe('Org/CustomModel') + } + ) + it('assembles system, context, then history in order and forwards params', async () => { const result = (await ollamaProvider.executeRequest({ ...baseRequest, diff --git a/apps/sim/providers/ollama/index.ts b/apps/sim/providers/ollama/index.ts index cd0e16f8bd7..fac7a49a8d1 100644 --- a/apps/sim/providers/ollama/index.ts +++ b/apps/sim/providers/ollama/index.ts @@ -48,17 +48,20 @@ export const ollamaProvider: ProviderConfig = { executeRequest: async ( request: ProviderRequest ): Promise => { - return executeOllamaProviderRequest(request, { - providerId: 'ollama', - providerLabel: 'Ollama', - createClient: () => - new OpenAI({ - ...openAICompatTransport(), - apiKey: 'empty', - baseURL: `${OLLAMA_HOST}/v1`, - }), - createStream: createReadableStreamFromOllamaStream, - logger, - }) + return executeOllamaProviderRequest( + { ...request, model: request.model.replace(/^ollama\//i, '') }, + { + providerId: 'ollama', + providerLabel: 'Ollama', + createClient: () => + new OpenAI({ + ...openAICompatTransport(), + apiKey: 'empty', + baseURL: `${OLLAMA_HOST}/v1`, + }), + createStream: createReadableStreamFromOllamaStream, + logger, + } + ) }, } diff --git a/apps/sim/providers/openrouter/index.test.ts b/apps/sim/providers/openrouter/index.test.ts index 4af5747f4a3..694606065fb 100644 --- a/apps/sim/providers/openrouter/index.test.ts +++ b/apps/sim/providers/openrouter/index.test.ts @@ -174,6 +174,12 @@ describe('openRouterProvider.executeRequest', () => { expect(payload.messages.at(-1)).toEqual({ role: 'user', content: 'Hello' }) }) + it('preserves custom provider paths when stripping an uppercase namespace', async () => { + mockCreate.mockResolvedValueOnce(textResponse('ok')) + await openRouterProvider.executeRequest({ ...baseRequest, model: 'OPENROUTER/Org/CustomModel' }) + expect(mockCreate.mock.calls[0][0].model).toBe('Org/CustomModel') + }) + it('inserts context as a user message between system and history', async () => { mockCreate.mockResolvedValueOnce(textResponse('ok')) diff --git a/apps/sim/providers/openrouter/index.ts b/apps/sim/providers/openrouter/index.ts index fbfeddd6193..2ca900a9905 100644 --- a/apps/sim/providers/openrouter/index.ts +++ b/apps/sim/providers/openrouter/index.ts @@ -107,7 +107,7 @@ export const openRouterProvider: ProviderConfig = { baseURL: 'https://openrouter.ai/api/v1', }) - const requestedModel = request.model.replace(/^openrouter\//, '') + const requestedModel = request.model.replace(/^openrouter\//i, '') logger.info('Preparing OpenRouter request', { model: requestedModel, diff --git a/apps/sim/providers/openrouter/utils.ts b/apps/sim/providers/openrouter/utils.ts index 2f8a7850fc9..c6763c6b514 100644 --- a/apps/sim/providers/openrouter/utils.ts +++ b/apps/sim/providers/openrouter/utils.ts @@ -77,7 +77,7 @@ export async function getOpenRouterModelCapabilities( cacheTimestamp = now } - const normalizedId = modelId.replace(/^openrouter\//, '') + const normalizedId = modelId.replace(/^openrouter\//i, '') return modelCapabilitiesCache.get(normalizedId) ?? null } diff --git a/apps/sim/providers/sakana/index.ts b/apps/sim/providers/sakana/index.ts index 33c9861a626..4648bdc7a72 100644 --- a/apps/sim/providers/sakana/index.ts +++ b/apps/sim/providers/sakana/index.ts @@ -7,6 +7,7 @@ import type { StreamingExecution } from '@/executor/types' import { MAX_TOOL_ITERATIONS } from '@/providers' import { formatMessagesForProvider } from '@/providers/attachments' import { getProviderDefaultModel, getProviderModels } from '@/providers/models' +import { createOpenAICompatAssistantHistory } from '@/providers/openai-compat/assistant-history' import { executeProviderTool } from '@/providers/runtime-context' import { createReadableStreamFromSakanaStream } from '@/providers/sakana/utils' import { createSettledAgentEventStream } from '@/providers/stream-events' @@ -38,7 +39,7 @@ const SAKANA_BASE_URL = 'https://api.sakana.ai/v1' export const sakanaProvider: ProviderConfig = { id: 'sakana', name: 'Sakana AI', - description: "Sakana AI's Fugu multi-agent models via an OpenAI-compatible API", + description: 'Sakana AI Fugu and Namazu models via an OpenAI-compatible API', version: '1.0.0', models: getProviderModels('sakana'), defaultModel: getProviderDefaultModel('sakana'), @@ -339,18 +340,16 @@ export const sakanaProvider: ProviderConfig = { const executionResults = await Promise.all(toolExecutionPromises) - currentMessages.push({ - role: 'assistant', - content: null, - tool_calls: toolCallsInResponse.map((tc) => ({ - id: tc.id, - type: 'function', - function: { - name: tc.function.name, - arguments: tc.function.arguments, - }, - })), - }) + const assistantMessage = currentResponse.choices[0]?.message + if (assistantMessage) { + currentMessages.push( + createOpenAICompatAssistantHistory({ + message: assistantMessage, + toolCalls: toolCallsInResponse, + reasoningFields: ['reasoning_content'], + }) + ) + } for (const executionResult of executionResults) { const { toolCall, toolName, toolParams, result, startTime, endTime, duration } = diff --git a/apps/sim/providers/settled-tool-streams.test.ts b/apps/sim/providers/settled-tool-streams.test.ts index 44f4d4622ab..69fbfdae77a 100644 --- a/apps/sim/providers/settled-tool-streams.test.ts +++ b/apps/sim/providers/settled-tool-streams.test.ts @@ -48,7 +48,11 @@ vi.mock('@/providers/models', () => ({ .fn() .mockReturnValue({ maxBytes: 10 * 1024 * 1024, strategy: 'inline' }), INLINE_ATTACHMENT_MAX_BYTES: 10 * 1024 * 1024, - getModelCapabilities: vi.fn(), + getModelCapabilities: vi.fn((model: string) => + model === 'nvidia/nemotron-3.5-lightning-30b-a3b' + ? { nativeStructuredOutputs: false } + : undefined + ), getProviderModels: vi.fn((provider: string) => [`${provider}/test-model`]), getProviderDefaultModel: vi.fn((provider: string) => `${provider}/test-model`), })) @@ -181,6 +185,12 @@ const PROVIDERS = [ ] as const const REASONING_HISTORY_PROVIDERS = [ + { + name: 'Sakana Namazu', + provider: sakanaProvider, + model: 'sakana-namazu-v1.0', + field: 'reasoning_content', + }, { name: 'Cerebras', provider: cerebrasProvider, @@ -238,6 +248,13 @@ const CAPPED_PROVIDERS = [ ] as const const STRUCTURED_OUTPUT_PROVIDERS = [ + { + name: 'NVIDIA Lightning', + provider: nvidiaProvider, + model: 'nvidia/nemotron-3.5-lightning-30b-a3b', + responseFormatType: 'json_object', + disablesTools: 'none', + }, { name: 'Baseten', provider: basetenProvider, @@ -389,6 +406,36 @@ describe('settled provider tool streams', () => { mockExecuteTool.mockResolvedValue({ success: true, output: { value: 'found' } }) }) + it('normalizes the NVIDIA namespace while preserving the upstream model name', async () => { + mockCreate.mockResolvedValueOnce(response('ok')) + await nvidiaProvider.executeRequest({ + apiKey: 'test-key', + model: 'NVIDIA/CustomModel', + messages: [{ role: 'user', content: 'Hello' }], + }) + expect(mockCreate.mock.calls[0][0].model).toBe('nvidia/CustomModel') + }) + + it('uses NVIDIA Lightning JSON mode with schema instructions and reasoning disabled', async () => { + mockCreate.mockResolvedValueOnce(response('{"value":"found"}')) + + await nvidiaProvider.executeRequest({ + apiKey: 'test-key', + model: 'nvidia/nemotron-3.5-lightning-30b-a3b', + messages: [{ role: 'user', content: 'Return a value' }], + responseFormat: { + name: 'result', + schema: { type: 'object', properties: { value: { type: 'string' } } }, + }, + }) + + expect(mockCreate.mock.calls[0][0]).toMatchObject({ + response_format: { type: 'json_object' }, + chat_template_kwargs: { enable_thinking: false }, + messages: expect.arrayContaining([{ role: 'system', content: 'SCHEMA_INSTRUCTIONS' }]), + }) + }) + it.each(PROVIDERS)( '$name projects the existing final answer without another provider call', async ({ provider, model }) => { diff --git a/apps/sim/providers/specialist-reasoning.test.ts b/apps/sim/providers/specialist-reasoning.test.ts new file mode 100644 index 00000000000..5122cd6398f --- /dev/null +++ b/apps/sim/providers/specialist-reasoning.test.ts @@ -0,0 +1,164 @@ +/** + * @vitest-environment node + */ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type { ProviderRequest } from '@/providers/types' + +const { mockCreate } = vi.hoisted(() => ({ mockCreate: vi.fn() })) + +vi.mock('openai', () => ({ + default: vi.fn().mockImplementation( + class { + chat = { completions: { create: mockCreate } } + } + ), +})) + +vi.mock('@cerebras/cerebras_cloud_sdk', () => ({ + Cerebras: vi.fn().mockImplementation( + class { + chat = { completions: { create: mockCreate } } + } + ), +})) + +vi.mock('@/providers', () => ({ MAX_TOOL_ITERATIONS: 3 })) +vi.mock('@/providers/attachments', () => ({ + formatMessagesForProvider: vi.fn((messages) => messages), +})) +vi.mock('@/providers/trace-enrichment', () => ({ + enrichLastModelSegmentFromChatCompletions: vi.fn(), +})) +vi.mock('@/providers/runtime-context', () => ({ + executeProviderTool: vi.fn().mockResolvedValue({ + rawResponse: { success: true, output: { result: 'found' } }, + modelResponse: { success: true, output: { result: 'found' } }, + }), +})) + +import { cerebrasProvider } from '@/providers/cerebras' +import { kimiProvider } from '@/providers/kimi' + +function request(model: string, overrides: Partial = {}): ProviderRequest { + return { + model, + apiKey: 'test-key', + messages: [{ role: 'user', content: 'hello' }], + ...overrides, + } +} + +describe('specialist provider reasoning parameters', () => { + beforeEach(() => { + mockCreate.mockReset() + mockCreate.mockResolvedValue({ + choices: [{ message: { content: 'ok', tool_calls: [] } }], + usage: { prompt_tokens: 1, completion_tokens: 1, total_tokens: 2 }, + }) + }) + + it.each(['none', 'low', 'medium', 'high'] as const)( + 'Cerebras forwards Qwen 3.8 effort %s', + async (reasoningEffort) => { + await cerebrasProvider.executeRequest( + request('cerebras/qwen-3.8-27b', { reasoningEffort, maxTokens: 40960 }) + ) + expect(mockCreate.mock.calls[0][0]).toMatchObject({ + model: 'qwen-3.8-27b', + reasoning_effort: reasoningEffort, + max_completion_tokens: 40960, + }) + } + ) + + it.each(['low', 'high', 'max'] as const)( + 'Kimi K3 forwards effort %s', + async (reasoningEffort) => { + await kimiProvider.executeRequest(request('kimi-k3', { reasoningEffort, temperature: 0.2 })) + const payload = mockCreate.mock.calls[0][0] + expect(payload.reasoning_effort).toBe(reasoningEffort) + expect(payload.temperature).toBeUndefined() + expect(payload.thinking).toBeUndefined() + } + ) + + it.each([ + { provider: cerebrasProvider, model: 'cerebras/qwen-3.8-27b' }, + { provider: kimiProvider, model: 'kimi-k3' }, + ])('preserves the server default for $model', async ({ provider, model }) => { + await provider.executeRequest(request(model, { reasoningEffort: 'auto' })) + expect(mockCreate.mock.calls[0][0].reasoning_effort).toBeUndefined() + }) + + it('preserves custom Cerebras identifiers while removing only the leading prefix', async () => { + await cerebrasProvider.executeRequest(request('Cerebras/Organization/Model-A')) + expect(mockCreate.mock.calls[0][0].model).toBe('Organization/Model-A') + }) + + it('K3 requires each forced tool, then restores all tools with automatic choice', async () => { + for (const name of ['search', 'lookup']) { + mockCreate.mockResolvedValueOnce({ + choices: [ + { + message: { + content: null, + reasoning_content: `Use ${name}`, + tool_calls: [ + { id: `call-${name}`, type: 'function', function: { name, arguments: '{}' } }, + ], + }, + }, + ], + usage: { prompt_tokens: 1, completion_tokens: 1, total_tokens: 2 }, + }) + } + await kimiProvider.executeRequest( + request('kimi-k3', { + tools: ['search', 'lookup', 'optional'].map((id) => ({ + id, + name: id, + description: id, + params: {}, + parameters: { type: 'object', properties: {} }, + usageControl: id === 'optional' ? 'auto' : 'force', + })), + }) + ) + const payloads = mockCreate.mock.calls.map(([payload]) => payload) + expect(payloads).toHaveLength(3) + expect(payloads[0].tool_choice).toBe('required') + expect( + payloads[0].tools.map((tool: { function: { name: string } }) => tool.function.name) + ).toEqual(['search']) + expect(payloads[1].tool_choice).toBe('required') + expect( + payloads[1].tools.map((tool: { function: { name: string } }) => tool.function.name) + ).toEqual(['lookup']) + expect(payloads[2].tool_choice).toBe('auto') + expect(payloads[2].tools).toHaveLength(3) + expect(payloads[1].messages).toContainEqual( + expect.objectContaining({ reasoning_content: 'Use search' }) + ) + }) + + it.each(['kimi-k2.7-code', 'kimi-k2.7-code-highspeed'])( + '%s keeps automatic tool choice when forced selection is unsupported', + async (model) => { + await kimiProvider.executeRequest( + request(model, { + tools: [ + { + id: 'lookup', + description: 'Lookup', + params: {}, + parameters: {}, + usageControl: 'force', + }, + ], + }) + ) + expect(mockCreate.mock.calls[0][0].tool_choice).toBe('auto') + expect(mockCreate.mock.calls[0][0].thinking).toBeUndefined() + } + ) +}) diff --git a/apps/sim/providers/together/index.test.ts b/apps/sim/providers/together/index.test.ts index c9c75846cb9..123da1e9c03 100644 --- a/apps/sim/providers/together/index.test.ts +++ b/apps/sim/providers/together/index.test.ts @@ -154,6 +154,14 @@ describe('togetherProvider', () => { expect(result).toHaveProperty('execution') }) + it('preserves custom model casing after an uppercase provider prefix', async () => { + mockCreate.mockResolvedValueOnce(textResponse('ok')) + + await togetherProvider.executeRequest({ ...baseRequest, model: 'TOGETHER/Org/Custom-Model' }) + + expect(callBody(0).model).toBe('Org/Custom-Model') + }) + it('sends a json_schema response_format with no strict field', async () => { mockCreate.mockResolvedValueOnce(textResponse('{}')) diff --git a/apps/sim/providers/together/index.ts b/apps/sim/providers/together/index.ts index 27d0f7ed769..55047796976 100644 --- a/apps/sim/providers/together/index.ts +++ b/apps/sim/providers/together/index.ts @@ -92,7 +92,7 @@ export const togetherProvider: ProviderConfig = { baseURL: 'https://api.together.ai/v1', }) - const requestedModel = request.model.replace(/^together\//, '') + const requestedModel = request.model.replace(/^together\//i, '') logger.info('Preparing Together request', { model: requestedModel, diff --git a/apps/sim/providers/utils.test.ts b/apps/sim/providers/utils.test.ts index 14fbe4d68d1..a3c148277e0 100644 --- a/apps/sim/providers/utils.test.ts +++ b/apps/sim/providers/utils.test.ts @@ -37,6 +37,7 @@ import { getReasoningEffortValuesForModel, getThinkingLevelsForModel, getVerbosityValuesForModel, + isGemini3Model, isProviderBlacklisted, MODELS_TEMP_RANGE_0_1, MODELS_TEMP_RANGE_0_2, @@ -57,6 +58,7 @@ import { transformBlockTool, updateOllamaProviderModels, } from '@/providers/utils' +import { useProvidersStore } from '@/stores/providers/store' const mockGetRotatingApiKey = vi.fn().mockReturnValue('rotating-server-key') const originalRequire = module.require @@ -163,6 +165,25 @@ describe('getApiKey', () => { expect(key2).toBe('empty') }) + it.each(['ollama', 'vllm', 'litellm'] as const)( + 'uses the routed cloud provider credentials despite a name collision in %s discovery', + (localProvider) => { + const originalProviders = useProvidersStore.getState().providers + useProvidersStore.setState({ + providers: { + ...originalProviders, + [localProvider]: { ...originalProviders[localProvider], models: ['azure/MyDeployment'] }, + }, + }) + try { + expect(getApiKey('azure-openai', 'azure/MyDeployment', 'azure-key')).toBe('azure-key') + expect(() => getApiKey('azure-openai', 'azure/MyDeployment')).toThrow('API key is required') + } finally { + useProvidersStore.setState({ providers: originalProviders }) + } + } + ) + it('should return empty or user-provided key for vllm provider without requiring API key', () => { setEnvFlags({ isHosted: false }) @@ -546,10 +567,11 @@ describe('Model Capabilities', () => { (m) => m.includes('gpt-5') && !m.includes('chat-latest') && - !m.includes('gpt-5.5-pro') && - !m.includes('gpt-5.4-pro') && - !m.includes('gpt-5.2-pro') && - !m.includes('gpt-5-pro') + m !== 'gpt-5.5-pro' && + m !== 'gpt-5.4-pro' && + m !== 'gpt-5.3-codex' && + m !== 'gpt-5.2-pro' && + m !== 'gpt-5-pro' ) const gpt5ModelsWithVerbosity = MODELS_WITH_VERBOSITY.filter( (m) => m.includes('gpt-5') && !m.includes('chat-latest') @@ -562,6 +584,9 @@ describe('Model Capabilities', () => { expect(MODELS_WITH_REASONING_EFFORT).toContain('gpt-5.4-pro') expect(MODELS_WITH_VERBOSITY).not.toContain('gpt-5.4-pro') + expect(MODELS_WITH_REASONING_EFFORT).toContain('gpt-5.3-codex') + expect(MODELS_WITH_VERBOSITY).not.toContain('gpt-5.3-codex') + expect(MODELS_WITH_REASONING_EFFORT).toContain('gpt-5.2-pro') expect(MODELS_WITH_VERBOSITY).not.toContain('gpt-5.2-pro') @@ -828,6 +853,27 @@ describe('Cost Calculation', () => { expect(longContext).toMatchObject({ input: 1.088004, output: 1.8, total: 2.888004 }) }) + it.each([ + ['gemini-3.1-pro-preview', 2, 0.2, 12, 4, 0.4, 18], + ['gemini-2.5-pro', 1.25, 0.125, 10, 2.5, 0.25, 15], + ['grok-4.6', 2, 0.5, 6, 4, 1, 12], + ])( + 'applies %s long-context rates only above 200k prompt tokens, including cached input', + (model, input, cached, output, longInput, longCached, longOutput) => { + const shortContext = calculateCost(model, 200_000, 100_000) + const longContext = calculateCost(model, 200_001, 100_000) + const shortCached = calculateCost(model, 200_000, 100_000, true) + const longCachedCost = calculateCost(model, 200_001, 100_000, true) + + expect(shortContext.input).toBeCloseTo(input * 0.2, 10) + expect(shortContext.output).toBeCloseTo(output * 0.1, 10) + expect(longContext.input).toBeCloseTo((longInput * 200_001) / 1e6, 10) + expect(longContext.output).toBeCloseTo(longOutput * 0.1, 10) + expect(shortCached.input).toBeCloseTo(cached * 0.2, 10) + expect(longCachedCost.input).toBeCloseTo((longCached * 200_001) / 1e6, 10) + } + ) + it('should return default pricing for unknown models', () => { const result = calculateCost('unknown-model', 1000, 500, false) @@ -2105,6 +2151,18 @@ describe('describeModelLevel', () => { }) describe('findProviderFromModel', () => { + it.each([ + ['azure/MyDeployment', 'azure-openai'], + ['AZURE/MyDeployment', 'azure-openai'], + ['azure-anthropic/MyDeployment', 'azure-anthropic'], + ['bedrock/custom-inference-profile', 'bedrock'], + ['vertex/publishers/google/models/custom-gemini', 'vertex'], + ])('uses the declared provider namespace for %s', (model, provider) => { + expect(findProviderFromModel(model)).toBe(provider) + expect(getProviderFromModel(model)).toBe(provider) + expect(shouldBillModelUsage(model)).toBe(false) + }) + it('resolves a chat model to its declaring provider', () => { expect(findProviderFromModel('claude-sonnet-5')).toBe('anthropic') expect(findProviderFromModel('gpt-5.2')).toBe('openai') @@ -2128,6 +2186,26 @@ describe('findProviderFromModel', () => { }) }) +describe('isGemini3Model', () => { + it.each([ + 'gemini-3.8-flash', + 'VERTEX/gemini-3.8-flash', + 'vertex/google/gemini-3.8-flash', + 'vertex/publishers/google/models/gemini-3.8-flash', + 'vertex/projects/test-project/locations/global/publishers/google/models/gemini-3.8-flash', + ])('recognizes the Gemini family in %s', (model) => { + expect(isGemini3Model(model)).toBe(true) + }) + + it.each([ + 'vertex/gemini-2.5-pro', + 'vertex/custom-gemini-3-deployment', + 'vertex/publishers/another-provider/models/gemini-3.8-flash', + ])('does not infer Gemini 3 behavior from %s', (model) => { + expect(isGemini3Model(model)).toBe(false) + }) +}) + describe('transformBlockTool param decoding', () => { /** * `StoredTool.params` stringifies every value, so a tool row hands a block the same diff --git a/apps/sim/providers/utils.ts b/apps/sim/providers/utils.ts index ba9d19a2728..be2d64b46dd 100644 --- a/apps/sim/providers/utils.ts +++ b/apps/sim/providers/utils.ts @@ -25,6 +25,7 @@ import { assembleCustomBlockInputMapping, isCustomBlockType } from '@/blocks/cus import type { SubBlockConfig } from '@/blocks/types' import { isCustomTool } from '@/executor/constants' import { + findProviderFromModel as findProviderFromDefinitions, getComputerUseModels, getHostedModels as getHostedModelsFromDefinitions, getMaxOutputTokensForModel as getMaxOutputTokensForModelFromDefinitions, @@ -60,7 +61,6 @@ import { registerPreparedProviderToolInputProvenance, } from '@/providers/tool-input-provenance' import type { ModelPricing, ProviderId, ProviderToolConfig } from '@/providers/types' -import { useProvidersStore } from '@/stores/providers/store' import { mergeToolParameters } from '@/tools/merge-params' import { buildToolParamShapes, decodeToolParams } from '@/tools/param-shape' import type { WorkflowToolExecutionContext } from '@/tools/types' @@ -282,18 +282,7 @@ export function getAllModelProviders(): Record { * that was never about it. */ export function findProviderFromModel(model: string): ProviderId | null { - const normalizedModel = model.toLowerCase() - - const declared = getAllModelProviders()[normalizedModel] - if (declared) return declared - - for (const [id, config] of Object.entries(providers)) { - for (const pattern of config.modelPatterns ?? []) { - if (pattern.test(normalizedModel)) return id as ProviderId - } - } - - return null + return findProviderFromDefinitions(model) } export function getProviderFromModel(model: string): ProviderId { @@ -1163,27 +1152,20 @@ export const PROVIDER_PLACEHOLDER_KEY = 'provider-uses-own-credentials' export function getApiKey(provider: string, model: string, userProvidedKey?: string): string { const hasUserKey = !!userProvidedKey - const isOllamaModel = - provider === 'ollama' || useProvidersStore.getState().providers.ollama.models.includes(model) - if (isOllamaModel) { + if (provider === 'ollama') { return 'empty' } - const isVllmModel = - provider === 'vllm' || useProvidersStore.getState().providers.vllm.models.includes(model) - if (isVllmModel) { + if (provider === 'vllm') { return userProvidedKey || 'empty' } - const isLitellmModel = - provider === 'litellm' || useProvidersStore.getState().providers.litellm.models.includes(model) - if (isLitellmModel) { + if (provider === 'litellm') { return userProvidedKey || 'empty' } - // Bedrock uses its own credentials (bedrockAccessKeyId/bedrockSecretKey), not apiKey - const isBedrockModel = provider === 'bedrock' || model.startsWith('bedrock/') - if (isBedrockModel) { + /** Bedrock authenticates through its configured AWS credentials. */ + if (provider === 'bedrock') { return PROVIDER_PLACEHOLDER_KEY } @@ -1569,7 +1551,13 @@ export function isDeepResearchModel(model: string): boolean { } export function isGemini3Model(model: string): boolean { - const normalized = model.toLowerCase().replace(/^vertex\//, '') + const normalized = model + .toLowerCase() + .replace(/^vertex\//, '') + .replace( + /^(?:google\/|(?:projects\/[^/]+\/locations\/[^/]+\/)?publishers\/google\/models\/)/, + '' + ) return normalized.startsWith('gemini-3') } diff --git a/apps/sim/providers/vertex/index.test.ts b/apps/sim/providers/vertex/index.test.ts index 35282292617..424dcee8c5b 100644 --- a/apps/sim/providers/vertex/index.test.ts +++ b/apps/sim/providers/vertex/index.test.ts @@ -25,10 +25,6 @@ vi.mock('google-auth-library', () => ({ }, })) vi.mock('@/providers/gemini/core', () => ({ executeGeminiRequest: mockExecuteGeminiRequest })) -vi.mock('@/providers/models', () => ({ - getProviderModels: () => ['vertex/gemini-2.0-flash'], - getProviderDefaultModel: () => 'vertex/gemini-2.0-flash', -})) vi.mock('@/lib/core/config/env', () => ({ env: {} })) import { vertexProvider } from '@/providers/vertex' @@ -102,4 +98,43 @@ describe('vertexProvider location and project validation', () => { expect(genAIArgs[0]).toMatchObject({ location: 'us-central1' }) }) + + it.each([ + 'vertex/gemini-3.8-flash', + 'vertex/gemini-3.7-flash', + 'vertex/gemini-3.6-flash', + 'vertex/gemini-3.5-flash-lite', + 'vertex/publishers/google/models/gemini-3.8-flash', + ])('defaults %s to the supported global endpoint', async (model) => { + await vertexProvider.executeRequest(request({ model })) + + expect(genAIArgs[0]).toMatchObject({ location: 'global' }) + }) + + it.each(['us', 'eu', 'global'])( + 'preserves the explicitly selected %s endpoint for Gemini 3', + async (vertexLocation) => { + await vertexProvider.executeRequest( + request({ model: 'vertex/gemini-3.8-flash', vertexLocation }) + ) + + expect(genAIArgs[0]).toMatchObject({ location: vertexLocation }) + } + ) + + it.each([ + ['VERTEX/Custom-Deployment', 'Custom-Deployment'], + ['vertex/publishers/google/models/Custom-Model', 'publishers/google/models/Custom-Model'], + [ + 'vertex/projects/MyProject/locations/global/publishers/google/models/Custom-Model', + 'projects/MyProject/locations/global/publishers/google/models/Custom-Model', + ], + ['publishers/vertex/models/Custom-Model', 'publishers/vertex/models/Custom-Model'], + ])('preserves the custom model identifier in %s', async (model, expectedModel) => { + await vertexProvider.executeRequest(request({ model })) + + expect(mockExecuteGeminiRequest).toHaveBeenCalledWith( + expect.objectContaining({ model: expectedModel, providerType: 'vertex' }) + ) + }) }) diff --git a/apps/sim/providers/vertex/index.ts b/apps/sim/providers/vertex/index.ts index 59c22efc2ad..65e49151d81 100644 --- a/apps/sim/providers/vertex/index.ts +++ b/apps/sim/providers/vertex/index.ts @@ -10,6 +10,7 @@ import type { StreamingExecution } from '@/executor/types' import { executeGeminiRequest } from '@/providers/gemini/core' import { getProviderDefaultModel, getProviderModels } from '@/providers/models' import type { ProviderConfig, ProviderRequest, ProviderResponse } from '@/providers/types' +import { isGemini3Model } from '@/providers/utils' const logger = createLogger('VertexProvider') @@ -34,13 +35,13 @@ export const vertexProvider: ProviderConfig = { executeRequest: async ( request: ProviderRequest ): Promise => { + const model = request.model.replace(/^vertex\//i, '') const vertexProject = request.vertexProject || env.VERTEX_PROJECT - // Hostnames are case-insensitive, so a mixed-case location reaches Google fine - // today. Normalize before validating rather than rejecting it as malformed. + /** Gemini 3 models use global or multi-region endpoints instead of legacy regions. */ const vertexLocation = ( request.vertexLocation || env.VERTEX_LOCATION || - 'us-central1' + (isGemini3Model(model) ? 'global' : 'us-central1') ).toLowerCase() if (!vertexProject) { @@ -71,9 +72,6 @@ export const vertexProvider: ProviderConfig = { ) } - // Strip 'vertex/' prefix from model name if present - const model = request.model.replace('vertex/', '') - logger.info('Creating Vertex AI client', { project: vertexProject, location: vertexLocation, diff --git a/apps/sim/providers/vllm/index.test.ts b/apps/sim/providers/vllm/index.test.ts index 1057bb89e5e..29459978c6d 100644 --- a/apps/sim/providers/vllm/index.test.ts +++ b/apps/sim/providers/vllm/index.test.ts @@ -150,6 +150,15 @@ describe('vllmProvider', () => { mockCreatePinnedFetch.mockReturnValue(pinnedFetchFn) }) + it('preserves a custom served-model name when stripping an uppercase namespace', async () => { + mockCreate.mockResolvedValueOnce(chatResponse('hello')) + await vllmProvider.executeRequest({ + model: 'VLLM/Org/CustomModel', + messages: [{ role: 'user', content: 'hi' }], + }) + expect(createPayload(0).model).toBe('Org/CustomModel') + }) + describe('endpoint SSRF protection', () => { it('does not validate or pin when no endpoint is supplied (uses env base URL)', async () => { mockCreate.mockResolvedValueOnce(chatResponse('hi')) diff --git a/apps/sim/providers/vllm/index.ts b/apps/sim/providers/vllm/index.ts index 3bad1cba441..d1394a94230 100644 --- a/apps/sim/providers/vllm/index.ts +++ b/apps/sim/providers/vllm/index.ts @@ -181,7 +181,7 @@ export const vllmProvider: ProviderConfig = { : undefined const payload: any = { - model: request.model.replace(/^vllm\//, ''), + model: request.model.replace(/^vllm\//i, ''), messages: formattedMessages, }