diff --git a/apps/sim/providers/anthropic/core.ts b/apps/sim/providers/anthropic/core.ts index e9bdab06049..27d84febb1d 100644 --- a/apps/sim/providers/anthropic/core.ts +++ b/apps/sim/providers/anthropic/core.ts @@ -82,6 +82,12 @@ const THINKING_BUDGET_TOKENS: Record = { high: 32768, } +/** Anthropic's documented floor for `budget_tokens` (Messages API reference: "Must be >=1024 and less than max_tokens"). */ +const ANTHROPIC_MIN_BUDGET_TOKENS = 1024 + +/** Headroom reserved for text output above the thinking budget when computing max_tokens. */ +const ANTHROPIC_THINKING_OUTPUT_HEADROOM = 4096 + /** * Checks if a model supports adaptive thinking (thinking.type: "adaptive"). * Fable 5 supports ONLY adaptive thinking (always on; type: "disabled" is rejected). @@ -338,16 +344,26 @@ export async function executeAnthropicProviderRequest( payload.output_config = thinkingConfig.outputConfig } - // Per Anthropic docs: budget_tokens must be less than max_tokens. - // Ensure max_tokens leaves room for both thinking and text output. + // Keep budget_tokens < max_tokens (see constants above) by shrinking the budget + // itself when the model's output cap is too tight — clamping max_tokens alone + // can leave budget_tokens >= max_tokens. if ( thinkingConfig.thinking.type === 'enabled' && 'budget_tokens' in thinkingConfig.thinking ) { - const budgetTokens = thinkingConfig.thinking.budget_tokens - const minMaxTokens = budgetTokens + 4096 + const modelMax = getMaxOutputTokensForModel(request.model) + let budgetTokens = thinkingConfig.thinking.budget_tokens + + if (budgetTokens + ANTHROPIC_THINKING_OUTPUT_HEADROOM > modelMax) { + budgetTokens = Math.max( + ANTHROPIC_MIN_BUDGET_TOKENS, + modelMax - ANTHROPIC_THINKING_OUTPUT_HEADROOM + ) + thinkingConfig.thinking.budget_tokens = budgetTokens + } + + const minMaxTokens = budgetTokens + ANTHROPIC_THINKING_OUTPUT_HEADROOM if (payload.max_tokens < minMaxTokens) { - const modelMax = getMaxOutputTokensForModel(request.model) payload.max_tokens = Math.min(minMaxTokens, modelMax) logger.info( `Adjusted max_tokens to ${payload.max_tokens} to satisfy budget_tokens (${budgetTokens}) constraint` diff --git a/apps/sim/providers/gemini/core.ts b/apps/sim/providers/gemini/core.ts index be1161ef09f..1419df73782 100644 --- a/apps/sim/providers/gemini/core.ts +++ b/apps/sim/providers/gemini/core.ts @@ -24,7 +24,9 @@ import { ensureStructResponse, extractAllFunctionCallParts, extractTextContent, + mapToThinkingBudget, mapToThinkingLevel, + supportsDisablingGemini25Thinking, } from '@/providers/google/utils' import { enrichLastModelSegment } from '@/providers/trace-enrichment' import type { @@ -952,13 +954,23 @@ export async function executeGeminiRequest( ) } - // Configure thinking only when the user explicitly selects a thinking level + // Gemini 3.x takes thinkingLevel directly; Gemini 2.5-series rejects it and needs thinkingBudget. if (request.thinkingLevel && request.thinkingLevel !== 'none') { - const thinkingConfig: ThinkingConfig = { - includeThoughts: false, - thinkingLevel: mapToThinkingLevel(request.thinkingLevel), + const thinkingConfig: ThinkingConfig = { includeThoughts: false } + if (isGemini3Model(model)) { + thinkingConfig.thinkingLevel = mapToThinkingLevel(request.thinkingLevel) + } else { + thinkingConfig.thinkingBudget = mapToThinkingBudget(model, request.thinkingLevel) } geminiConfig.thinkingConfig = thinkingConfig + } else if ( + request.thinkingLevel === 'none' && + !isGemini3Model(model) && + supportsDisablingGemini25Thinking(model) + ) { + // Omitting thinkingConfig falls back to the API's dynamic default (ON for gemini-2.5-flash), + // so disabling requires an explicit budget of 0. + geminiConfig.thinkingConfig = { includeThoughts: false, thinkingBudget: 0 } } // Prepare tools diff --git a/apps/sim/providers/google/utils.test.ts b/apps/sim/providers/google/utils.test.ts index 7489216049a..7cd7fff8277 100644 --- a/apps/sim/providers/google/utils.test.ts +++ b/apps/sim/providers/google/utils.test.ts @@ -2,9 +2,67 @@ * @vitest-environment node */ import { describe, expect, it } from 'vitest' -import { convertToGeminiFormat, ensureStructResponse } from '@/providers/google/utils' +import { + convertToGeminiFormat, + ensureStructResponse, + mapToThinkingBudget, + supportsDisablingGemini25Thinking, +} from '@/providers/google/utils' import type { ProviderRequest } from '@/providers/types' +describe('mapToThinkingBudget', () => { + it('maps named levels to a within-range budget for gemini-2.5-pro (128-32768, cannot disable)', () => { + expect(mapToThinkingBudget('gemini-2.5-pro', 'low')).toBeGreaterThanOrEqual(128) + expect(mapToThinkingBudget('gemini-2.5-pro', 'high')).toBeLessThanOrEqual(32768) + }) + + it('maps named levels to a within-range budget for gemini-2.5-flash (0-24576)', () => { + expect(mapToThinkingBudget('gemini-2.5-flash', 'low')).toBeLessThanOrEqual(24576) + expect(mapToThinkingBudget('gemini-2.5-flash', 'high')).toBeLessThanOrEqual(24576) + }) + + it('maps named levels to a within-range budget for gemini-2.5-flash-lite (512-24576)', () => { + expect(mapToThinkingBudget('gemini-2.5-flash-lite', 'low')).toBeGreaterThanOrEqual(512) + expect(mapToThinkingBudget('gemini-2.5-flash-lite', 'high')).toBeLessThanOrEqual(24576) + }) + + it('strips the vertex/ prefix before looking up the model', () => { + expect(mapToThinkingBudget('vertex/gemini-2.5-flash', 'medium')).toBe( + mapToThinkingBudget('gemini-2.5-flash', 'medium') + ) + }) + + it('falls back to dynamic budget (-1) for models with no explicit mapping', () => { + expect(mapToThinkingBudget('gemini-2.0-flash', 'medium')).toBe(-1) + }) + + it('falls back to the high budget for an unrecognized level on a mapped model', () => { + expect(mapToThinkingBudget('gemini-2.5-flash', 'unknown-level')).toBe( + mapToThinkingBudget('gemini-2.5-flash', 'high') + ) + }) +}) + +describe('supportsDisablingGemini25Thinking', () => { + it('returns true for gemini-2.5-flash and gemini-2.5-flash-lite', () => { + expect(supportsDisablingGemini25Thinking('gemini-2.5-flash')).toBe(true) + expect(supportsDisablingGemini25Thinking('gemini-2.5-flash-lite')).toBe(true) + }) + + it('returns false for gemini-2.5-pro, which cannot disable thinking', () => { + expect(supportsDisablingGemini25Thinking('gemini-2.5-pro')).toBe(false) + }) + + it('strips the vertex/ prefix before checking', () => { + expect(supportsDisablingGemini25Thinking('vertex/gemini-2.5-flash')).toBe(true) + expect(supportsDisablingGemini25Thinking('vertex/gemini-2.5-pro')).toBe(false) + }) + + it('returns false for models with no explicit mapping', () => { + expect(supportsDisablingGemini25Thinking('gemini-3.5-flash')).toBe(false) + }) +}) + describe('ensureStructResponse', () => { describe('should return objects unchanged', () => { it('should return plain object unchanged', () => { diff --git a/apps/sim/providers/google/utils.ts b/apps/sim/providers/google/utils.ts index bd98db115c1..05aa74328f7 100644 --- a/apps/sim/providers/google/utils.ts +++ b/apps/sim/providers/google/utils.ts @@ -350,6 +350,47 @@ export function mapToThinkingLevel(level: string): ThinkingLevel { } } +/** + * Per-model thinkingBudget ranges for Gemini 2.5-series models. Unlike Gemini 3.x, these + * models reject `thinkingLevel` entirely (Gemini API docs: "Gemini 2.5 series models don't + * support thinkingLevel; use thinkingBudget instead") and require a numeric token budget + * within each model's own documented range. + */ +const GEMINI_25_THINKING_BUDGETS: Record> = { + 'gemini-2.5-pro': { low: 2048, medium: 8192, high: 32768 }, // valid range 128-32768, cannot disable + 'gemini-2.5-flash': { low: 2048, medium: 8192, high: 24576 }, // valid range 0-24576 + 'gemini-2.5-flash-lite': { low: 1024, medium: 8192, high: 24576 }, // valid range 512-24576 +} + +/** + * Maps a named thinking level to a `thinkingBudget` token count for Gemini 2.5-series models. + * Falls back to -1 (dynamic/automatic budget) for any model not in the explicit table above, + * rather than guessing a number that could fall outside an unmapped model's valid range. + */ +export function mapToThinkingBudget(model: string, level: string): number { + const normalized = model.toLowerCase().replace(/^vertex\//, '') + const budgets = GEMINI_25_THINKING_BUDGETS[normalized] + if (!budgets) return -1 + return budgets[level.toLowerCase()] ?? budgets.high +} + +/** + * Gemini 2.5-series models that accept `thinkingBudget: 0` to explicitly disable thinking. + * gemini-2.5-pro cannot disable thinking at all (its documented budget floor is 128, not 0), + * so it's deliberately excluded here. + */ +const GEMINI_25_MODELS_SUPPORTING_DISABLE = new Set(['gemini-2.5-flash', 'gemini-2.5-flash-lite']) + +/** + * Whether this Gemini 2.5-series model supports explicitly disabling thinking via budget=0. + * Omitting thinkingConfig entirely (the 'none' no-op path) falls back to the API's own + * dynamic default, which is ON for gemini-2.5-flash — not the same as actually disabling it. + */ +export function supportsDisablingGemini25Thinking(model: string): boolean { + const normalized = model.toLowerCase().replace(/^vertex\//, '') + return GEMINI_25_MODELS_SUPPORTING_DISABLE.has(normalized) +} + /** * Result of checking forced tool usage */ diff --git a/apps/sim/providers/models.ts b/apps/sim/providers/models.ts index 830492aeb2f..e1de7772e08 100644 --- a/apps/sim/providers/models.ts +++ b/apps/sim/providers/models.ts @@ -315,7 +315,7 @@ export const PROVIDER_DEFINITIONS: Record = { }, capabilities: { reasoningEffort: { - values: ['none', 'low', 'medium', 'high', 'xhigh', 'max'], + values: ['none', 'low', 'medium', 'high', 'xhigh'], }, verbosity: { values: ['low', 'medium', 'high'], @@ -335,7 +335,7 @@ export const PROVIDER_DEFINITIONS: Record = { }, capabilities: { reasoningEffort: { - values: ['none', 'low', 'medium', 'high', 'xhigh', 'max'], + values: ['none', 'low', 'medium', 'high', 'xhigh'], }, verbosity: { values: ['low', 'medium', 'high'], @@ -841,7 +841,7 @@ export const PROVIDER_DEFINITIONS: Record = { capabilities: { temperature: { min: 0, max: 1 }, nativeStructuredOutputs: true, - maxOutputTokens: 64000, + maxOutputTokens: 128000, thinking: { levels: ['low', 'medium', 'high', 'max'], default: 'high', @@ -1509,7 +1509,6 @@ export const PROVIDER_DEFINITIONS: Record = { }, contextWindow: 1048576, releaseDate: '2025-12-17', - deprecated: true, }, { id: 'gemini-2.5-pro', @@ -1521,6 +1520,10 @@ export const PROVIDER_DEFINITIONS: Record = { }, capabilities: { temperature: { min: 0, max: 2 }, + thinking: { + levels: ['low', 'medium', 'high'], + default: 'high', + }, maxOutputTokens: 65536, }, contextWindow: 1048576, @@ -1536,6 +1539,10 @@ export const PROVIDER_DEFINITIONS: Record = { }, capabilities: { temperature: { min: 0, max: 2 }, + thinking: { + levels: ['low', 'medium', 'high'], + default: 'medium', + }, maxOutputTokens: 65536, }, contextWindow: 1048576, @@ -1551,6 +1558,10 @@ export const PROVIDER_DEFINITIONS: Record = { }, capabilities: { temperature: { min: 0, max: 2 }, + thinking: { + levels: ['low', 'medium', 'high'], + default: 'low', + }, maxOutputTokens: 65536, }, contextWindow: 1048576, @@ -1725,6 +1736,10 @@ export const PROVIDER_DEFINITIONS: Record = { }, capabilities: { temperature: { min: 0, max: 2 }, + thinking: { + levels: ['low', 'medium', 'high'], + default: 'high', + }, maxOutputTokens: 65535, }, contextWindow: 1048576, @@ -1740,6 +1755,10 @@ export const PROVIDER_DEFINITIONS: Record = { }, capabilities: { temperature: { min: 0, max: 2 }, + thinking: { + levels: ['low', 'medium', 'high'], + default: 'medium', + }, maxOutputTokens: 65535, }, contextWindow: 1048576, @@ -1755,6 +1774,10 @@ export const PROVIDER_DEFINITIONS: Record = { }, capabilities: { temperature: { min: 0, max: 2 }, + thinking: { + levels: ['low', 'medium', 'high'], + default: 'low', + }, maxOutputTokens: 65535, }, contextWindow: 1048576, @@ -2876,6 +2899,7 @@ export const PROVIDER_DEFINITIONS: Record = { }, contextWindow: 200000, releaseDate: '2025-08-05', + deprecated: true, }, { id: 'bedrock/amazon.nova-2-pro-v1:0', diff --git a/apps/sim/providers/utils.test.ts b/apps/sim/providers/utils.test.ts index 0182491436e..1e9c877a976 100644 --- a/apps/sim/providers/utils.test.ts +++ b/apps/sim/providers/utils.test.ts @@ -196,8 +196,8 @@ describe('Model Capabilities', () => { 'gpt-5-chat-latest', 'azure/gpt-5-chat', 'gemini-2.5-flash', - 'claude-sonnet-4-0', - 'claude-opus-4-0', + 'claude-sonnet-4-5', + 'claude-opus-4-1', 'grok-3-latest', 'grok-3-fast-latest', 'deepseek-v3', @@ -243,7 +243,7 @@ describe('Model Capabilities', () => { it.concurrent('should be case insensitive', () => { expect(supportsTemperature('GPT-4O')).toBe(true) - expect(supportsTemperature('claude-sonnet-4-0')).toBe(true) + expect(supportsTemperature('claude-sonnet-4-5')).toBe(true) }) it.concurrent( @@ -277,7 +277,7 @@ describe('Model Capabilities', () => { }) it.concurrent('should return 1 for models with temperature range 0-1', () => { - const modelsRange01 = ['claude-sonnet-4-0', 'claude-opus-4-0'] + const modelsRange01 = ['claude-sonnet-4-5', 'claude-opus-4-1'] for (const model of modelsRange01) { expect(getMaxTemperature(model)).toBe(1) @@ -316,7 +316,7 @@ describe('Model Capabilities', () => { it.concurrent('should be case insensitive', () => { expect(getMaxTemperature('GPT-4O')).toBe(2) - expect(getMaxTemperature('CLAUDE-SONNET-4-0')).toBe(1) + expect(getMaxTemperature('CLAUDE-SONNET-4-5')).toBe(1) }) it.concurrent( @@ -413,7 +413,7 @@ describe('Model Capabilities', () => { expect(supportsThinking('claude-opus-4-6')).toBe(true) expect(supportsThinking('claude-opus-4-5')).toBe(true) expect(supportsThinking('claude-sonnet-4-5')).toBe(true) - expect(supportsThinking('claude-sonnet-4-0')).toBe(true) + expect(supportsThinking('claude-sonnet-4-5')).toBe(true) expect(supportsThinking('claude-haiku-4-5')).toBe(true) expect(supportsThinking('gemini-3-flash-preview')).toBe(true) }) @@ -438,11 +438,11 @@ describe('Model Capabilities', () => { expect(MODELS_TEMP_RANGE_0_2).toContain('gemini-2.5-flash') expect(MODELS_TEMP_RANGE_0_2).toContain('deepseek-v3') expect(MODELS_TEMP_RANGE_0_2).toContain('grok-3-latest') - expect(MODELS_TEMP_RANGE_0_2).not.toContain('claude-sonnet-4-0') + expect(MODELS_TEMP_RANGE_0_2).not.toContain('claude-sonnet-4-5') }) it.concurrent('should have correct models in MODELS_TEMP_RANGE_0_1', () => { - expect(MODELS_TEMP_RANGE_0_1).toContain('claude-sonnet-4-0') + expect(MODELS_TEMP_RANGE_0_1).toContain('claude-sonnet-4-5') expect(MODELS_TEMP_RANGE_0_1).not.toContain('grok-3-latest') expect(MODELS_TEMP_RANGE_0_1).not.toContain('gpt-4o') }) @@ -464,7 +464,7 @@ describe('Model Capabilities', () => { MODELS_TEMP_RANGE_0_1.length ) expect(MODELS_WITH_TEMPERATURE_SUPPORT).toContain('gpt-4o') - expect(MODELS_WITH_TEMPERATURE_SUPPORT).toContain('claude-sonnet-4-0') + expect(MODELS_WITH_TEMPERATURE_SUPPORT).toContain('claude-sonnet-4-5') } ) @@ -496,7 +496,7 @@ describe('Model Capabilities', () => { expect(MODELS_WITH_REASONING_EFFORT).not.toContain('azure/gpt-5-chat') expect(MODELS_WITH_REASONING_EFFORT).not.toContain('gpt-4o') - expect(MODELS_WITH_REASONING_EFFORT).not.toContain('claude-sonnet-4-0') + expect(MODELS_WITH_REASONING_EFFORT).not.toContain('claude-sonnet-4-5') }) it.concurrent('should have correct models in MODELS_WITH_VERBOSITY', () => { @@ -525,16 +525,14 @@ describe('Model Capabilities', () => { expect(MODELS_WITH_VERBOSITY).not.toContain('o4-mini') expect(MODELS_WITH_VERBOSITY).not.toContain('gpt-4o') - expect(MODELS_WITH_VERBOSITY).not.toContain('claude-sonnet-4-0') + expect(MODELS_WITH_VERBOSITY).not.toContain('claude-sonnet-4-5') }) it.concurrent('should have correct models in MODELS_WITH_THINKING', () => { expect(MODELS_WITH_THINKING).toContain('claude-opus-4-6') expect(MODELS_WITH_THINKING).toContain('claude-opus-4-5') expect(MODELS_WITH_THINKING).toContain('claude-opus-4-1') - expect(MODELS_WITH_THINKING).toContain('claude-opus-4-0') expect(MODELS_WITH_THINKING).toContain('claude-sonnet-4-5') - expect(MODELS_WITH_THINKING).toContain('claude-sonnet-4-0') expect(MODELS_WITH_THINKING).toContain('gemini-3-flash-preview') @@ -659,7 +657,7 @@ describe('Model Capabilities', () => { }) it.concurrent('should return correct levels for other Claude models (budget_tokens)', () => { - for (const model of ['claude-opus-4-5', 'claude-sonnet-4-5', 'claude-sonnet-4-0']) { + for (const model of ['claude-opus-4-5', 'claude-sonnet-4-5', 'claude-haiku-4-5']) { const levels = getThinkingLevelsForModel(model) expect(levels).toBeDefined() expect(levels).toContain('low') @@ -713,7 +711,7 @@ describe('Max Output Tokens', () => { }) it.concurrent('should return updated max for Claude Sonnet 4.6', () => { - expect(getMaxOutputTokensForModel('claude-sonnet-4-6')).toBe(64000) + expect(getMaxOutputTokensForModel('claude-sonnet-4-6')).toBe(128000) }) it.concurrent('should return published max for Gemini 2.5 Pro', () => { @@ -873,8 +871,8 @@ describe('getHostedModels', () => { expect(hostedModels).toContain('gpt-4o') expect(hostedModels).toContain('o1') - expect(hostedModels).toContain('claude-sonnet-4-0') - expect(hostedModels).toContain('claude-opus-4-0') + expect(hostedModels).toContain('claude-sonnet-4-5') + expect(hostedModels).toContain('claude-opus-4-1') expect(hostedModels).toContain('gemini-2.5-pro') expect(hostedModels).toContain('gemini-2.5-flash') @@ -899,8 +897,8 @@ describe('shouldBillModelUsage', () => { expect(shouldBillModelUsage('gpt-4o')).toBe(true) expect(shouldBillModelUsage('o1')).toBe(true) - expect(shouldBillModelUsage('claude-sonnet-4-0')).toBe(true) - expect(shouldBillModelUsage('claude-opus-4-0')).toBe(true) + expect(shouldBillModelUsage('claude-sonnet-4-5')).toBe(true) + expect(shouldBillModelUsage('claude-opus-4-1')).toBe(true) expect(shouldBillModelUsage('gemini-2.5-pro')).toBe(true) expect(shouldBillModelUsage('gemini-2.5-flash')).toBe(true) @@ -921,7 +919,7 @@ describe('shouldBillModelUsage', () => { it.concurrent('should be case insensitive', () => { expect(shouldBillModelUsage('GPT-4O')).toBe(true) - expect(shouldBillModelUsage('Claude-Sonnet-4-0')).toBe(true) + expect(shouldBillModelUsage('Claude-Sonnet-4-5')).toBe(true) expect(shouldBillModelUsage('GEMINI-2.5-PRO')).toBe(true) }) @@ -936,7 +934,7 @@ describe('Provider Management', () => { describe('getProviderFromModel', () => { it.concurrent('should return correct provider for known models', () => { expect(getProviderFromModel('gpt-4o')).toBe('openai') - expect(getProviderFromModel('claude-sonnet-4-0')).toBe('anthropic') + expect(getProviderFromModel('claude-sonnet-4-5')).toBe('anthropic') expect(getProviderFromModel('gemini-2.5-pro')).toBe('google') expect(getProviderFromModel('azure/gpt-4o')).toBe('azure-openai') }) @@ -985,7 +983,7 @@ describe('Provider Management', () => { expect(config).toBeDefined() expect(config?.id).toBe('openai') - const anthropicConfig = getProviderConfigFromModel('claude-sonnet-4-0') + const anthropicConfig = getProviderConfigFromModel('claude-sonnet-4-5') expect(anthropicConfig).toBeDefined() expect(anthropicConfig?.id).toBe('anthropic') }) @@ -998,7 +996,7 @@ describe('Provider Management', () => { expect(allModels.length).toBeGreaterThan(0) expect(allModels).toContain('gpt-4o') - expect(allModels).toContain('claude-sonnet-4-0') + expect(allModels).toContain('claude-sonnet-4-5') expect(allModels).toContain('gemini-2.5-pro') }) }) @@ -1022,8 +1020,8 @@ describe('Provider Management', () => { expect(openaiModels).toContain('o1') const anthropicModels = getProviderModels('anthropic') - expect(anthropicModels).toContain('claude-sonnet-4-0') - expect(anthropicModels).toContain('claude-opus-4-0') + expect(anthropicModels).toContain('claude-sonnet-4-5') + expect(anthropicModels).toContain('claude-opus-4-1') }) it.concurrent('should return empty array for unknown providers', () => { @@ -1037,7 +1035,7 @@ describe('Provider Management', () => { const allProviders = getAllModelProviders() expect(typeof allProviders).toBe('object') expect(allProviders['gpt-4o']).toBe('openai') - expect(allProviders['claude-sonnet-4-0']).toBe('anthropic') + expect(allProviders['claude-sonnet-4-5']).toBe('anthropic') const baseProviders = getBaseModelProviders() expect(typeof baseProviders).toBe('object')