From 9f8b6d92c02cf1d612dfb8947bde519f32c796f7 Mon Sep 17 00:00:00 2001 From: Danny Avila Date: Tue, 30 Jun 2026 19:26:33 -0400 Subject: [PATCH] =?UTF-8?q?=F0=9F=A4=96=20feat:=20Add=20Claude=20Sonnet=20?= =?UTF-8?q?5=20Support=20(#14042)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * ✨ feat: Add Claude Sonnet 5 Support Wire up the claude-sonnet-5 model across token, pricing, and model-list config: - Context window (1M) and max output (128K) in @librechat/api token maps - Standard pricing ($3/$15 per MTok) and cache rates in data-schemas tx - 128K output-token carve-out in anthropicSettings (the family-wide 64K rule capped Sonnet 5 below its real limit); Bedrock/Vertex thinking and 1M-context detection already cover sonnet major >= 5 generically - Add to shared Anthropic, Bedrock, and Vertex default model lists, plus the .env.example examples - Tests for context/output/pricing/matching across the affected packages * ✅ test: Align Sonnet 5+ maxOutputTokens defaults with 128K spec getLLMConfig defaults flow from anthropicSettings.maxOutputTokens.reset(), which now returns 128K for Sonnet 5+. Update the future-proofing assertions in llm.spec.ts (Sonnet 5.x and 6-9.x) that still expected the old family-wide 64K cap. Haiku stays 64K; Opus stays 128K. * đŸŽšī¸ fix: Gate Sonnet 5 capability behaviors (sampling, thinking) Adding claude-sonnet-5 to the default list exposed it without the Anthropic capability gates, all confirmed against the live API: - omitsSamplingParameters: Sonnet 5 returns 400 on non-default temperature/ top_p/top_k ('deprecated for this model'); now dropped so selecting the model with saved sampling settings no longer fails. - requiresExplicitThinkingDisabled: omitting 'thinking' runs adaptive ON by default on Sonnet 5, so disabling thinking now sends { type: 'disabled' } (verified: 200, no thinking block) instead of omitting the field. - omitsThinkingByDefault: thinking.display defaults to omitted (empty thinking blocks); the display resolver now returns 'summarized' for Sonnet 5+ so the Thoughts UI keeps working (verified: 757-char summary returned). Gates apply to both the direct Anthropic and Bedrock paths. Tests added in bedrock.spec and llm.spec. * 🩹 fix: Sonnet 5 Bedrock availability + thinking-off persistence Round-2 Codex review (all verified against the live API / Anthropic docs): - Sonnet 5 is NOT available on the legacy Bedrock InvokeModel/Converse surface (Anthropic docs: 'use Claude in Amazon Bedrock or Claude Platform on AWS'), which is what LibreChat's ChatBedrockConverse uses. Removed it from the default Bedrock model lists (config + .env.example). Opus 4.8/4.7/Fable 5 stay — those ARE reachable via InvokeModel. Sonnet 5 remains on the direct Anthropic API and Vertex, where it works. - Reverted the Bedrock-side explicit-disabled thinking handling added last round: with Sonnet 5 off Bedrock, no Bedrock model needs { type: 'disabled' }, so that path (and its round-trip concern) no longer applies. - Direct Anthropic path: a persisted { type: 'disabled' } thinking object now normalizes to a boolean flag in getLLMConfig, so a user's Sonnet 5 'thinking off' setting stays off across the model_parameters round trip instead of flipping back to adaptive (a truthy object skipped the disabled branch). * â†Šī¸ fix: Restore Sonnet 5 on Bedrock (Converse) — verified live Reverses the round-2 removal: Sonnet 5 IS available on AWS Bedrock. Tested live via the Converse API: - global.anthropic.claude-sonnet-5 returns a normal response - bare anthropic.claude-sonnet-5 needs an inference profile — but that's identical to the already-shipping Opus 4.8 / Fable 5 / Sonnet 4.6 entries, which all fail bare on-demand the same way - temperature=0.5 -> 400 'deprecated for this model'; thinking {type:disabled} suppresses reasoning — same as the direct API The 'legacy' Bedrock docs page that claimed Sonnet 5 wasn't on the surface is stale. Restored: - anthropic.claude-sonnet-5 in bedrockModels + .env.example - the Bedrock explicit-disabled thinking handling (requiresExplicitThinkingDisabled -> { type: 'disabled' }) - the Finding 4 round-trip fix in bedrockInputSchema (coerce a persisted disabled AMRF.thinking to thinking=false instead of !!thinking -> true), with an end-to-end schema->parser test proving 'thinking off' stays sticky. Direct-path round-trip fix (getLLMConfig thinkingFlag) is unchanged. * đŸ’ĩ fix: Sonnet 5 intro pricing + sticky disabled thinking on Bedrock reload Round-4 Codex review (both verified): - Pricing: Anthropic lists Sonnet 5 at introductory $2/$10 per MTok (cache $2.50/$0.20) through 2026-08-31, reverting to $3/$15 ($3.75/$0.30) on Sep 1 (confirmed on platform.claude.com/pricing). The static tx multiplier table is used for real balance transactions, so the post-intro rates were overcharging ~50% during the launch window. Switched to the intro rates with a revert comment on both the token and cache entries. - Bedrock disabled-thinking persistence: initializeBedrock feeds persisted model_parameters straight through bedrockInputParser (NOT bedrockInputSchema), where additionalModelRequestFields is a known key — so a prior thinking:{type:'disabled'} was ignored and rebuilt as adaptive on reload. bedrockInputParser now surfaces a persisted disabled AMRF.thinking as thinking=false so it re-emits {type:'disabled'}. Verified end-to-end against the real initializeBedrock call path. --- .env.example | 6 +- api/utils/tokens.spec.js | 53 ++++++++++++ .../api/src/endpoints/anthropic/helpers.ts | 11 +++ .../api/src/endpoints/anthropic/llm.spec.ts | 72 +++++++++++++++-- packages/api/src/endpoints/anthropic/llm.ts | 13 ++- packages/api/src/utils/tokens.ts | 2 + packages/data-provider/specs/bedrock.spec.ts | 80 ++++++++++++++++++- packages/data-provider/src/bedrock.ts | 60 +++++++++++++- packages/data-provider/src/config.ts | 2 + packages/data-provider/src/schemas.spec.ts | 34 ++++++-- packages/data-provider/src/schemas.ts | 11 +++ packages/data-schemas/src/app/vertex.ts | 1 + packages/data-schemas/src/methods/tx.spec.ts | 49 ++++++++++++ packages/data-schemas/src/methods/tx.ts | 4 + 14 files changed, 377 insertions(+), 21 deletions(-) diff --git a/.env.example b/.env.example index 75b4c490aa..3f6798fc6b 100644 --- a/.env.example +++ b/.env.example @@ -279,7 +279,7 @@ PROXY= #============# ANTHROPIC_API_KEY=user_provided -# ANTHROPIC_MODELS=claude-fable-5,claude-opus-4-8,claude-opus-4-7,claude-sonnet-4-6,claude-opus-4-6,claude-opus-4-20250514,claude-3-7-sonnet-20250219,claude-3-5-sonnet-20241022,claude-3-5-haiku-20241022,claude-3-opus-20240229,claude-3-sonnet-20240229,claude-3-haiku-20240307 +# ANTHROPIC_MODELS=claude-fable-5,claude-opus-4-8,claude-opus-4-7,claude-sonnet-5,claude-sonnet-4-6,claude-opus-4-6,claude-opus-4-20250514,claude-3-7-sonnet-20250219,claude-3-5-sonnet-20241022,claude-3-5-haiku-20241022,claude-3-opus-20240229,claude-3-sonnet-20240229,claude-3-haiku-20240307 # ANTHROPIC_REVERSE_PROXY= # Set to true to use Anthropic models through Google Vertex AI instead of direct API @@ -345,8 +345,8 @@ ANTHROPIC_API_KEY=user_provided # BEDROCK_AWS_BEARER_TOKEN=yourBedrockApiKey # Note: This example list is not meant to be exhaustive. If omitted, all known, supported model IDs will be included for you. -# BEDROCK_AWS_MODELS=anthropic.claude-fable-5,anthropic.claude-opus-4-8,anthropic.claude-opus-4-7,anthropic.claude-sonnet-4-6,anthropic.claude-opus-4-6-v1,anthropic.claude-3-5-sonnet-20240620-v1:0,meta.llama3-1-8b-instruct-v1:0 -# Cross-region inference model IDs: us.anthropic.claude-fable-5,us.anthropic.claude-opus-4-8,us.anthropic.claude-opus-4-7,us.anthropic.claude-sonnet-4-6,us.anthropic.claude-opus-4-6-v1,global.anthropic.claude-opus-4-6-v1 +# BEDROCK_AWS_MODELS=anthropic.claude-fable-5,anthropic.claude-opus-4-8,anthropic.claude-opus-4-7,anthropic.claude-sonnet-5,anthropic.claude-sonnet-4-6,anthropic.claude-opus-4-6-v1,anthropic.claude-3-5-sonnet-20240620-v1:0,meta.llama3-1-8b-instruct-v1:0 +# Cross-region inference model IDs: us.anthropic.claude-fable-5,us.anthropic.claude-opus-4-8,us.anthropic.claude-opus-4-7,us.anthropic.claude-sonnet-5,us.anthropic.claude-sonnet-4-6,us.anthropic.claude-opus-4-6-v1,global.anthropic.claude-opus-4-6-v1 # See all Bedrock model IDs here: https://docs.aws.amazon.com/bedrock/latest/userguide/model-ids.html#model-ids-arns diff --git a/api/utils/tokens.spec.js b/api/utils/tokens.spec.js index 82c5a8b31f..9089bd8a41 100644 --- a/api/utils/tokens.spec.js +++ b/api/utils/tokens.spec.js @@ -1635,6 +1635,59 @@ describe('Claude Model Tests', () => { expect(matchModelName(model, EModelEndpoint.anthropic)).toBe('claude-sonnet-4-6'); }); }); + + it('should return correct context length for Claude Sonnet 5 (1M)', () => { + expect(getModelMaxTokens('claude-sonnet-5', EModelEndpoint.anthropic)).toBe( + maxTokensMap[EModelEndpoint.anthropic]['claude-sonnet-5'], + ); + expect(getModelMaxTokens('claude-sonnet-5')).toBe( + maxTokensMap[EModelEndpoint.anthropic]['claude-sonnet-5'], + ); + }); + + it('should return correct max output tokens for Claude Sonnet 5 (128K)', () => { + const { getModelMaxOutputTokens } = require('@librechat/api'); + expect(getModelMaxOutputTokens('claude-sonnet-5', EModelEndpoint.anthropic)).toBe( + maxOutputTokensMap[EModelEndpoint.anthropic]['claude-sonnet-5'], + ); + expect(getModelMaxOutputTokens('claude-sonnet-5', EModelEndpoint.anthropic)).toBeGreaterThan( + getModelMaxOutputTokens('claude-sonnet-4-6', EModelEndpoint.anthropic), + ); + }); + + it('should not match Claude Sonnet 5 against Claude Sonnet 4 keys', () => { + const modelVariations = [ + 'claude-sonnet-5', + 'claude-sonnet-5-20260101', + 'claude-sonnet-5-latest', + 'anthropic/claude-sonnet-5', + 'claude-sonnet-5/anthropic', + 'anthropic.claude-sonnet-5', + ]; + + modelVariations.forEach((model) => { + const modelKey = findMatchingPattern(model, maxTokensMap[EModelEndpoint.anthropic]); + expect(modelKey).toBe('claude-sonnet-5'); + expect(getModelMaxTokens(model, EModelEndpoint.anthropic)).toBe( + maxTokensMap[EModelEndpoint.anthropic]['claude-sonnet-5'], + ); + }); + }); + + it('should match model names correctly for Claude Sonnet 5', () => { + const modelVariations = [ + 'claude-sonnet-5', + 'claude-sonnet-5-20260101', + 'claude-sonnet-5-latest', + 'anthropic/claude-sonnet-5', + 'claude-sonnet-5/anthropic', + 'claude-sonnet-5-preview', + ]; + + modelVariations.forEach((model) => { + expect(matchModelName(model, EModelEndpoint.anthropic)).toBe('claude-sonnet-5'); + }); + }); }); describe('Moonshot/Kimi Model Tests', () => { diff --git a/packages/api/src/endpoints/anthropic/helpers.ts b/packages/api/src/endpoints/anthropic/helpers.ts index 33431b30e1..7839687da6 100644 --- a/packages/api/src/endpoints/anthropic/helpers.ts +++ b/packages/api/src/endpoints/anthropic/helpers.ts @@ -8,6 +8,7 @@ import { isMythosClassModel, resolveThinkingDisplay, supportsAdaptiveThinking, + requiresExplicitThinkingDisabled, } from 'librechat-data-provider'; import { matchModelName } from '~/utils/tokens'; @@ -99,6 +100,16 @@ function configureReasoning( const currentMaxTokens = updatedOptions.max_tokens ?? updatedOptions.maxTokens; const modelName = updatedOptions.model ?? ''; + /** + * Sonnet 5 runs adaptive thinking by default when the `thinking` field is + * omitted, so honoring a user who turns thinking off requires sending an + * explicit disabled config rather than leaving the field unset. + */ + if (!extendedOptions.thinking && modelName && requiresExplicitThinkingDisabled(modelName)) { + updatedOptions.thinking = { type: 'disabled' } as AnthropicClientOptions['thinking']; + return updatedOptions; + } + if (extendedOptions.thinking && modelName && supportsAdaptiveThinking(modelName)) { /** * For Opus 4.7+, Anthropic omits thinking content from responses by diff --git a/packages/api/src/endpoints/anthropic/llm.spec.ts b/packages/api/src/endpoints/anthropic/llm.spec.ts index 062d906294..9d242b35b0 100644 --- a/packages/api/src/endpoints/anthropic/llm.spec.ts +++ b/packages/api/src/endpoints/anthropic/llm.spec.ts @@ -1215,6 +1215,64 @@ describe('getLLMConfig', () => { expect(result.llmConfig).not.toHaveProperty('topK'); }); + it('should request summarized thinking display for Sonnet 5 (opt back in)', () => { + const result = getLLMConfig('test-key', { + modelOptions: { model: 'claude-sonnet-5', thinking: true }, + }); + + const thinking = result.llmConfig.thinking as unknown as { + type: string; + display?: string; + }; + expect(thinking.type).toBe('adaptive'); + expect(thinking.display).toBe('summarized'); + }); + + it('should send explicit disabled thinking for Sonnet 5 when thinking is off', () => { + const result = getLLMConfig('test-key', { + modelOptions: { model: 'claude-sonnet-5', thinking: false }, + }); + + expect((result.llmConfig.thinking as unknown as { type: string }).type).toBe('disabled'); + }); + + it('should keep Sonnet 5 thinking off when a disabled config round-trips from persistence', () => { + const result = getLLMConfig('test-key', { + modelOptions: { + model: 'claude-sonnet-5', + // Persisted model_parameters round-trip the prior disabled object, + // not a boolean — it must stay disabled, not flip back to adaptive. + thinking: { type: 'disabled' } as unknown as boolean, + }, + }); + + expect((result.llmConfig.thinking as unknown as { type: string }).type).toBe('disabled'); + }); + + it('should omit sampling parameters for Sonnet 5', () => { + const result = getLLMConfig('test-key', { + modelOptions: { + model: 'claude-sonnet-5', + thinking: true, + temperature: 0.7, + topP: 0.9, + topK: 40, + }, + }); + + expect(result.llmConfig).not.toHaveProperty('temperature'); + expect(result.llmConfig).not.toHaveProperty('topP'); + expect(result.llmConfig).not.toHaveProperty('topK'); + }); + + it('should NOT send explicit disabled thinking for pre-5 Sonnet (omission is off)', () => { + const result = getLLMConfig('test-key', { + modelOptions: { model: 'claude-sonnet-4-6', thinking: false }, + }); + + expect(result.llmConfig.thinking).toBeUndefined(); + }); + it('should NOT set thinking.display for pre-Opus-4.7 adaptive models', () => { const pre47Models = ['claude-opus-4-6', 'claude-sonnet-4-6']; @@ -1408,7 +1466,7 @@ describe('getLLMConfig', () => { }); }); - it('should future-proof Claude 5.x Sonnet models with 64K default', () => { + it('should default Claude 5.x Sonnet models to 128K (matches Anthropic spec)', () => { const testCases = [ 'claude-sonnet-5', 'claude-sonnet-5-0', @@ -1420,7 +1478,7 @@ describe('getLLMConfig', () => { const result = getLLMConfig('test-key', { modelOptions: { model }, }); - expect(result.llmConfig.maxTokens).toBe(64000); + expect(result.llmConfig.maxTokens).toBe(128000); }); }); @@ -1458,20 +1516,20 @@ describe('getLLMConfig', () => { it('should future-proof Claude 6-9.x models with correct defaults', () => { const testCases = [ - // Claude 6.x - Sonnet/Haiku get 64K, Opus gets 128K - { model: 'claude-sonnet-6', expected: 64000 }, + // Claude 6.x - Sonnet/Opus get 128K, Haiku gets 64K + { model: 'claude-sonnet-6', expected: 128000 }, { model: 'claude-haiku-6-0', expected: 64000 }, { model: 'claude-opus-6-1', expected: 128000 }, // Claude 7.x - { model: 'claude-sonnet-7-20270101', expected: 64000 }, + { model: 'claude-sonnet-7-20270101', expected: 128000 }, { model: 'claude-haiku-7.5', expected: 64000 }, { model: 'claude-opus-7', expected: 128000 }, // Claude 8.x - { model: 'claude-sonnet-8', expected: 64000 }, + { model: 'claude-sonnet-8', expected: 128000 }, { model: 'claude-haiku-8-2', expected: 64000 }, { model: 'claude-opus-8-latest', expected: 128000 }, // Claude 9.x - { model: 'claude-sonnet-9', expected: 64000 }, + { model: 'claude-sonnet-9', expected: 128000 }, { model: 'claude-haiku-9', expected: 64000 }, { model: 'claude-opus-9', expected: 128000 }, ]; diff --git a/packages/api/src/endpoints/anthropic/llm.ts b/packages/api/src/endpoints/anthropic/llm.ts index 17dcf73c8e..7be535080b 100644 --- a/packages/api/src/endpoints/anthropic/llm.ts +++ b/packages/api/src/endpoints/anthropic/llm.ts @@ -145,8 +145,19 @@ function getLLMConfig( ? ((persistedThinking as { display: string }).display as ThinkingDisplay | string) : undefined; + /** + * `thinking` may round-trip as the full Anthropic object rather than a + * boolean. Normalize to a flag so a persisted `{ type: 'disabled' }` (e.g. a + * Sonnet 5 "thinking off" config stored back into `model_parameters`) is + * treated as off — a truthy object would otherwise flip thinking back on. + */ + const thinkingFlag = + typeof persistedThinking === 'object' && persistedThinking != null + ? (persistedThinking as { type?: string }).type !== 'disabled' + : (persistedThinking ?? anthropicSettings.thinking.default); + const systemOptions = { - thinking: options.modelOptions?.thinking ?? anthropicSettings.thinking.default, + thinking: thinkingFlag, promptCache: options.modelOptions?.promptCache ?? anthropicSettings.promptCache.default, promptCacheTtl: options.modelOptions?.promptCacheTtl ?? anthropicSettings.promptCacheTtl.default, diff --git a/packages/api/src/utils/tokens.ts b/packages/api/src/utils/tokens.ts index b9e248bbca..e607f78106 100644 --- a/packages/api/src/utils/tokens.ts +++ b/packages/api/src/utils/tokens.ts @@ -156,6 +156,7 @@ const anthropicModels = { 'claude-sonnet-4': 200000, 'claude-sonnet-4-5': 200000, 'claude-sonnet-4-6': 1000000, + 'claude-sonnet-5': 1000000, 'claude-opus-4-6': 1000000, 'claude-opus-4-7': 1000000, 'claude-opus-4-8': 1000000, @@ -408,6 +409,7 @@ const anthropicMaxOutputs = { 'claude-haiku-4-5': 64000, 'claude-sonnet-4': 64000, 'claude-sonnet-4-6': 64000, + 'claude-sonnet-5': 128000, 'claude-opus-4': 32000, 'claude-opus-4-5': 64000, 'claude-opus-4-6': 128000, diff --git a/packages/data-provider/specs/bedrock.spec.ts b/packages/data-provider/specs/bedrock.spec.ts index 1d0a804fc3..36f94803df 100644 --- a/packages/data-provider/specs/bedrock.spec.ts +++ b/packages/data-provider/specs/bedrock.spec.ts @@ -4,6 +4,7 @@ import { supportsAdaptiveThinking, omitsSamplingParameters, omitsThinkingByDefault, + requiresExplicitThinkingDisabled, resolveThinkingDisplay, bedrockOutputParser, bedrockInputParser, @@ -288,10 +289,17 @@ describe('omitsThinkingByDefault', () => { expect(omitsThinkingByDefault('claude-sonnet-4-6')).toBe(false); }); - test('returns false for claude-sonnet-4-7 (Sonnet is not affected by the Opus 4.7 default)', () => { + test('returns false for claude-sonnet-4-7 (pre-5 Sonnet keeps summarized default)', () => { expect(omitsThinkingByDefault('claude-sonnet-4-7')).toBe(false); }); + test('returns true for Sonnet 5+ (omits thinking display by default)', () => { + expect(omitsThinkingByDefault('claude-sonnet-5')).toBe(true); + expect(omitsThinkingByDefault('claude-sonnet-5-20260101')).toBe(true); + expect(omitsThinkingByDefault('anthropic.claude-sonnet-5')).toBe(true); + expect(omitsThinkingByDefault('claude-sonnet-9')).toBe(true); + }); + test('returns false for claude-haiku-4-5', () => { expect(omitsThinkingByDefault('claude-haiku-4-5')).toBe(false); }); @@ -334,12 +342,27 @@ describe('omitsSamplingParameters', () => { }); }); - test('returns false for older Opus and non-Opus models', () => { + test('returns true for Sonnet 5+ models (non-default sampling params rejected)', () => { + const models = [ + 'claude-sonnet-5', + 'claude-sonnet-5-20260101', + 'anthropic.claude-sonnet-5', + 'us.anthropic.claude-sonnet-5', + 'claude-sonnet-9', + ]; + + models.forEach((model) => { + expect(omitsSamplingParameters(model)).toBe(true); + }); + }); + + test('returns false for older Opus and pre-5 Sonnet models', () => { const models = [ 'claude-opus-4-20250514', 'anthropic.claude-opus-4-20250514-v1:0', 'claude-opus-4-1-20250805', 'claude-opus-4-6', + 'claude-sonnet-4-6', 'claude-sonnet-4-7', ]; @@ -349,6 +372,24 @@ describe('omitsSamplingParameters', () => { }); }); +describe('requiresExplicitThinkingDisabled', () => { + test('returns true for Sonnet 5+ (omitted thinking runs adaptive by default)', () => { + expect(requiresExplicitThinkingDisabled('claude-sonnet-5')).toBe(true); + expect(requiresExplicitThinkingDisabled('claude-sonnet-5-20260101')).toBe(true); + expect(requiresExplicitThinkingDisabled('anthropic.claude-sonnet-5')).toBe(true); + expect(requiresExplicitThinkingDisabled('claude-sonnet-9')).toBe(true); + }); + + test('returns false for pre-5 Sonnet, Opus, and Mythos-class models', () => { + // Opus 4.7+ omit -> off; Fable/Mythos reject an explicit disabled config (400) + expect(requiresExplicitThinkingDisabled('claude-sonnet-4-6')).toBe(false); + expect(requiresExplicitThinkingDisabled('claude-opus-4-8')).toBe(false); + expect(requiresExplicitThinkingDisabled('claude-fable-5')).toBe(false); + expect(requiresExplicitThinkingDisabled('claude-mythos-5')).toBe(false); + expect(requiresExplicitThinkingDisabled('gpt-4o')).toBe(false); + }); +}); + describe('resolveThinkingDisplay', () => { test('returns "summarized" for Opus 4.7 when explicit is auto/null/undefined', () => { expect(resolveThinkingDisplay('claude-opus-4-7', ThinkingDisplay.auto)).toBe('summarized'); @@ -539,6 +580,29 @@ describe('bedrockInputParser', () => { expect(additionalFields.anthropic_beta).toEqual(BEDROCK_CLAUDE_4_BETAS); }); + test('should send explicit disabled thinking for Bedrock Sonnet 5 when thinking is off', () => { + const input = { + model: 'anthropic.claude-sonnet-5', + thinking: false, + }; + const result = bedrockInputParser.parse(input) as Record; + const additionalFields = result.additionalModelRequestFields as Record; + expect(additionalFields.thinking).toEqual({ type: 'disabled' }); + }); + + test('should keep Bedrock Sonnet 5 thinking off when only the persisted AMRF disabled config remains', () => { + // initializeBedrock feeds persisted model_parameters straight through this + // parser. A prior "thinking off" survives only as AMRF.thinking; it must + // not be rebuilt as adaptive on reload. + const input = { + model: 'anthropic.claude-sonnet-5', + additionalModelRequestFields: { thinking: { type: 'disabled' } }, + }; + const result = bedrockInputParser.parse(input) as Record; + const additionalFields = result.additionalModelRequestFields as Record; + expect(additionalFields.thinking).toEqual({ type: 'disabled' }); + }); + test('should respect custom thinking budget', () => { const input = { model: 'anthropic.claude-sonnet-4', @@ -804,6 +868,18 @@ describe('bedrockInputParser', () => { expect(persisted.thinkingDisplay).toBe('summarized'); }); + test('coerces a persisted disabled AMRF.thinking back to thinking=false (Sonnet 5)', () => { + /** A persisted Sonnet 5 "thinking off" carries AMRF.thinking = + * { type: 'disabled' }. The schema must coerce that to top-level + * thinking=false (not the truthy-object default of true), so the parser + * re-emits the disabled config instead of flipping back to adaptive. */ + const persisted = bedrockInputSchema.parse({ + model: 'anthropic.claude-sonnet-5', + additionalModelRequestFields: { thinking: { type: 'disabled' } }, + }) as Record; + expect(persisted.thinking).toBe(false); + }); + test('ignores unknown display values during round-trip extraction', () => { const persisted = bedrockInputSchema.parse({ model: 'anthropic.claude-opus-4-7', diff --git a/packages/data-provider/src/bedrock.ts b/packages/data-provider/src/bedrock.ts index 7e97d594a4..7cb233b46f 100644 --- a/packages/data-provider/src/bedrock.ts +++ b/packages/data-provider/src/bedrock.ts @@ -10,7 +10,8 @@ const bedrockReasoningConfigValues = new Set(Object.values(s.BedrockReas type ThinkingConfig = | { type: 'enabled'; budget_tokens: number } - | { type: 'adaptive'; display?: s.ThinkingDisplayWireValue }; + | { type: 'adaptive'; display?: s.ThinkingDisplayWireValue } + | { type: 'disabled' }; /** * Resolves the final `thinking.display` value for an adaptive-thinking request. @@ -142,6 +143,10 @@ export function omitsThinkingByDefault(model: string): boolean { if (opus && (opus.major > 4 || (opus.major === 4 && opus.minor >= 7))) { return true; } + const sonnet = parseSonnetVersion(model); + if (sonnet != null && sonnet.major >= 5) { + return true; + } if (s.isMythosClassModel(model)) { return true; } @@ -153,12 +158,33 @@ export function omitsSamplingParameters(model: string): boolean { if (opus && (opus.major > 4 || (opus.major === 4 && opus.minor >= 7))) { return true; } + const sonnet = parseSonnetVersion(model); + if (sonnet != null && sonnet.major >= 5) { + return true; + } if (s.isMythosClassModel(model)) { return true; } return false; } +/** + * Whether disabling thinking requires sending an explicit `{ type: 'disabled' }` + * config rather than simply omitting the `thinking` field. + * + * Sonnet 5 treats an omitted `thinking` field as adaptive thinking ON by + * default, so honoring a user who turns thinking off means sending the disabled + * config explicitly. Opus 4.7+ run without thinking when the field is omitted, + * and Fable/Mythos reject an explicit disabled config (400, thinking always + * on), so both are excluded. + * + * See https://platform.claude.com/docs/en/about-claude/models/migration-guide#migrating-to-claude-sonnet-5 + */ +export function requiresExplicitThinkingDisabled(model: string): boolean { + const sonnet = parseSonnetVersion(model); + return sonnet != null && sonnet.major >= 5; +} + /** Checks if a model has a 1M context window (Sonnet 4.6+, Opus 4.6+, Opus 5+, Fable/Mythos) */ export function supportsContext1m(model: string): boolean { const sonnet = parseSonnetVersion(model); @@ -265,7 +291,11 @@ export const bedrockInputSchema = s.tConversationSchema if ((obj as AnthropicInput).additionalModelRequestFields?.thinking != null) { const _obj = obj as AnthropicInput; const thinking = _obj.additionalModelRequestFields.thinking; - obj.thinking = !!thinking; + const isDisabled = + typeof thinking === 'object' && + thinking !== null && + (thinking as { type?: string }).type === 'disabled'; + obj.thinking = isDisabled ? false : !!thinking; obj.thinkingBudget = typeof thinking === 'object' && 'budget_tokens' in thinking ? thinking.budget_tokens @@ -357,6 +387,26 @@ export const bedrockInputParser = s.tConversationSchema } }); + /** + * Persisted `model_parameters` can carry a prior "thinking off" only inside + * `additionalModelRequestFields.thinking = { type: 'disabled' }` (a known + * key that isn't spread into `additionalFields`). `initializeBedrock` feeds + * those params straight through this parser, so surface that as + * `thinking: false` — otherwise the disabled branch is skipped and the + * config rebuilds adaptive, flipping a user's Sonnet 5 setting back on. + */ + const persistedThinking = ( + typedData.additionalModelRequestFields as { thinking?: unknown } | undefined + )?.thinking; + if ( + additionalFields.thinking === undefined && + typeof persistedThinking === 'object' && + persistedThinking !== null && + (persistedThinking as { type?: string }).type === 'disabled' + ) { + additionalFields.thinking = false; + } + /** Configure thinking for Bedrock Anthropic models: 3.7 Sonnet, Claude 4+ (opus/sonnet/haiku), and Mythos-class (Fable/Mythos). */ if ( typeof typedData.model === 'string' && @@ -376,9 +426,13 @@ export const bedrockInputParser = s.tConversationSchema delete additionalFields.effort; if (additionalFields.thinking === false) { - delete additionalFields.thinking; delete additionalFields.thinkingBudget; delete additionalFields.thinkingDisplay; + if (requiresExplicitThinkingDisabled(typedData.model as string)) { + additionalFields.thinking = { type: 'disabled' }; + } else { + delete additionalFields.thinking; + } } else { /** * Persisted agent `model_parameters` round-trip back through this diff --git a/packages/data-provider/src/config.ts b/packages/data-provider/src/config.ts index fefa3305a5..db2cdefb6d 100644 --- a/packages/data-provider/src/config.ts +++ b/packages/data-provider/src/config.ts @@ -1996,6 +1996,7 @@ const sharedAnthropicModels = [ 'claude-fable-5', 'claude-opus-4-8', 'claude-opus-4-7', + 'claude-sonnet-5', 'claude-sonnet-4-6', 'claude-opus-4-6', 'claude-sonnet-4-5', @@ -2021,6 +2022,7 @@ export const bedrockModels = [ 'anthropic.claude-fable-5', 'anthropic.claude-opus-4-8', 'anthropic.claude-opus-4-7', + 'anthropic.claude-sonnet-5', 'anthropic.claude-sonnet-4-6', 'anthropic.claude-opus-4-6-v1', 'anthropic.claude-sonnet-4-5-20250929-v1:0', diff --git a/packages/data-provider/src/schemas.spec.ts b/packages/data-provider/src/schemas.spec.ts index 2e1f00532f..25f1808143 100644 --- a/packages/data-provider/src/schemas.spec.ts +++ b/packages/data-provider/src/schemas.spec.ts @@ -10,7 +10,7 @@ describe('anthropicSettings', () => { describe('maxOutputTokens.reset()', () => { const { reset } = anthropicSettings.maxOutputTokens; - describe('Claude Sonnet models', () => { + describe('Claude Sonnet 4.x models (64K limit)', () => { it('should return 64K for claude-sonnet-4', () => { expect(reset('claude-sonnet-4')).toBe(64000); }); @@ -19,12 +19,22 @@ describe('anthropicSettings', () => { expect(reset('claude-sonnet-4-5')).toBe(64000); }); - it('should return 64K for claude-sonnet-5', () => { - expect(reset('claude-sonnet-5')).toBe(64000); + it('should return 64K for claude-sonnet-4-6', () => { + expect(reset('claude-sonnet-4-6')).toBe(64000); + }); + }); + + describe('Claude Sonnet 5+ models (128K limit)', () => { + it('should return 128K for claude-sonnet-5', () => { + expect(reset('claude-sonnet-5')).toBe(128000); }); - it('should return 64K for future versions like claude-sonnet-9', () => { - expect(reset('claude-sonnet-9')).toBe(64000); + it('should return 128K for future versions like claude-sonnet-9', () => { + expect(reset('claude-sonnet-9')).toBe(128000); + }); + + it('should return 128K for double-digit minor versions like claude-sonnet-5-10', () => { + expect(reset('claude-sonnet-5-10')).toBe(128000); }); }); @@ -265,6 +275,20 @@ describe('anthropicSettings', () => { }); }); + describe('Claude Sonnet 5+ models (128K cap)', () => { + it('should allow 100K for claude-sonnet-5', () => { + expect(set(100000, 'claude-sonnet-5')).toBe(100000); + }); + + it('should cap at 128K for claude-sonnet-5 when value exceeds', () => { + expect(set(150000, 'claude-sonnet-5')).toBe(128000); + }); + + it('should cap at 128K for future versions like claude-sonnet-9', () => { + expect(set(150000, 'claude-sonnet-9')).toBe(128000); + }); + }); + describe('Claude Opus 4.5+ models (64K cap)', () => { it('should cap at 64K for claude-opus-4-5 when value exceeds', () => { expect(set(100000, 'claude-opus-4-5')).toBe(64000); diff --git a/packages/data-provider/src/schemas.ts b/packages/data-provider/src/schemas.ts index 4358b53e14..50375e9ebc 100644 --- a/packages/data-provider/src/schemas.ts +++ b/packages/data-provider/src/schemas.ts @@ -547,6 +547,10 @@ export const anthropicSettings = { return ANTHROPIC_MAX_OUTPUT; } + if (/claude-sonnet[-.]?(?:[5-9]|\d{2,})/.test(modelName)) { + return ANTHROPIC_MAX_OUTPUT; + } + if (/claude-(?:sonnet|haiku)[-.]?[4-9]/.test(modelName)) { return CLAUDE_4_64K_MAX_OUTPUT; } @@ -576,6 +580,13 @@ export const anthropicSettings = { return value; } + if (/claude-sonnet[-.]?(?:[5-9]|\d{2,})/.test(modelName)) { + if (value > ANTHROPIC_MAX_OUTPUT) { + return ANTHROPIC_MAX_OUTPUT; + } + return value; + } + if (/claude-(?:sonnet|haiku)[-.]?[4-9]/.test(modelName) && value > CLAUDE_4_64K_MAX_OUTPUT) { return CLAUDE_4_64K_MAX_OUTPUT; } diff --git a/packages/data-schemas/src/app/vertex.ts b/packages/data-schemas/src/app/vertex.ts index c773729bed..dd34751a28 100644 --- a/packages/data-schemas/src/app/vertex.ts +++ b/packages/data-schemas/src/app/vertex.ts @@ -18,6 +18,7 @@ import logger from '~/config/winston'; * These are the standard Anthropic model names as served by Vertex AI */ export const defaultVertexModels: string[] = [ + 'claude-sonnet-5', 'claude-sonnet-4-6', 'claude-3-7-sonnet-20250219', 'claude-3-5-sonnet-v2@20241022', diff --git a/packages/data-schemas/src/methods/tx.spec.ts b/packages/data-schemas/src/methods/tx.spec.ts index 4c22218a2d..87a148dfa8 100644 --- a/packages/data-schemas/src/methods/tx.spec.ts +++ b/packages/data-schemas/src/methods/tx.spec.ts @@ -2473,6 +2473,54 @@ describe('Claude Model Tests', () => { cacheTokenValues['claude-mythos-5'].read, ); }); + + it('should return correct prompt and completion rates for Claude Sonnet 5', () => { + expect(getMultiplier({ model: 'claude-sonnet-5', tokenType: 'prompt' })).toBe( + tokenValues['claude-sonnet-5'].prompt, + ); + expect(getMultiplier({ model: 'claude-sonnet-5', tokenType: 'completion' })).toBe( + tokenValues['claude-sonnet-5'].completion, + ); + }); + + it('should pin Claude Sonnet 5 to introductory $2 / $10 per MTok (through 2026-08-31)', () => { + expect(tokenValues['claude-sonnet-5']).toEqual({ prompt: 2, completion: 10 }); + }); + + it('should apply introductory cache rates ($2.50 / $0.20) for Claude Sonnet 5', () => { + expect(cacheTokenValues['claude-sonnet-5']).toEqual({ write: 2.5, read: 0.2 }); + }); + + it('should handle Claude Sonnet 5 model name variations without matching Sonnet 4', () => { + const modelVariations = [ + 'claude-sonnet-5', + 'claude-sonnet-5-20260101', + 'claude-sonnet-5-latest', + 'anthropic/claude-sonnet-5', + 'claude-sonnet-5/anthropic', + 'anthropic.claude-sonnet-5', + ]; + + modelVariations.forEach((model) => { + const valueKey = getValueKey(model); + expect(valueKey).toBe('claude-sonnet-5'); + expect(getMultiplier({ model, tokenType: 'prompt' })).toBe( + tokenValues['claude-sonnet-5'].prompt, + ); + expect(getMultiplier({ model, tokenType: 'completion' })).toBe( + tokenValues['claude-sonnet-5'].completion, + ); + }); + }); + + it('should return correct cache rates for Claude Sonnet 5', () => { + expect(getCacheMultiplier({ model: 'claude-sonnet-5', cacheType: 'write' })).toBe( + cacheTokenValues['claude-sonnet-5'].write, + ); + expect(getCacheMultiplier({ model: 'claude-sonnet-5', cacheType: 'read' })).toBe( + cacheTokenValues['claude-sonnet-5'].read, + ); + }); }); describe('Premium Token Pricing', () => { @@ -2500,6 +2548,7 @@ describe('Premium Token Pricing', () => { 'claude-fable-5', 'claude-mythos-5', 'claude-sonnet-4-6', + 'claude-sonnet-5', ]; claudeModels.forEach((model) => { expect(premiumTokenValues[model]).toBeUndefined(); diff --git a/packages/data-schemas/src/methods/tx.ts b/packages/data-schemas/src/methods/tx.ts index 37e519baf4..3cbff54663 100644 --- a/packages/data-schemas/src/methods/tx.ts +++ b/packages/data-schemas/src/methods/tx.ts @@ -172,6 +172,8 @@ export const tokenValues: Record 'claude-sonnet-4': { prompt: 3, completion: 15 }, 'claude-sonnet-4-5': { prompt: 3, completion: 15 }, 'claude-sonnet-4-6': { prompt: 3, completion: 15 }, + // Sonnet 5 introductory pricing through 2026-08-31; revert to { prompt: 3, completion: 15 } after. + 'claude-sonnet-5': { prompt: 2, completion: 10 }, 'command-r': { prompt: 0.5, completion: 1.5 }, 'command-r-plus': { prompt: 3, completion: 15 }, 'command-text': { prompt: 1.5, completion: 2.0 }, @@ -305,6 +307,8 @@ export const cacheTokenValues: Record = 'claude-sonnet-4': { write: 3.75, read: 0.3 }, 'claude-sonnet-4-5': { write: 3.75, read: 0.3 }, 'claude-sonnet-4-6': { write: 3.75, read: 0.3 }, + // Sonnet 5 introductory pricing through 2026-08-31; revert to { write: 3.75, read: 0.3 } after. + 'claude-sonnet-5': { write: 2.5, read: 0.2 }, 'claude-opus-4': { write: 18.75, read: 1.5 }, 'claude-opus-4-5': { write: 6.25, read: 0.5 }, 'claude-opus-4-6': { write: 6.25, read: 0.5 },