From 0697e8cd6032ad61089d683564d5298805cbbdd5 Mon Sep 17 00:00:00 2001 From: Danny Avila Date: Tue, 17 Feb 2026 15:24:03 -0500 Subject: [PATCH] =?UTF-8?q?=F0=9F=A4=96=20feat:=20Claude=20Sonnet=204.6=20?= =?UTF-8?q?support=20(#11829)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 🤖 feat: Claude Sonnet 4.6 support - Updated .env.example to include claude-sonnet-4-6 in the list of available models. - Enhanced token value assignments in api/models/tx.js and packages/api/src/utils/tokens.ts to accommodate claude-sonnet-4-6. - Added tests in packages/data-provider/specs/bedrock.spec.ts to verify support for claude-sonnet-4-6 in adaptive thinking and context-1m functionalities. - Modified bedrock.ts to correctly parse and identify the version of claude-sonnet-4-6 for adaptive thinking checks. - Included claude-sonnet-4-6 in sharedAnthropicModels and bedrockModels for consistent model availability. * chore: additional Claude Sonnet 4.6 tests - Added unit tests for Claude Sonnet 4.6 in `tokens.spec.js` to verify context length and max output tokens. - Updated `helpers.ts` documentation to reflect adaptive thinking support for Sonnet 4.6. - Enhanced `llm.spec.ts` with tests for context headers and adaptive thinking configurations for Claude Sonnet 4.6. - Improved `bedrock.spec.ts` to ensure correct parsing and handling of Claude Sonnet 4.6 model variations with adaptive thinking. --- .env.example | 6 +- api/models/tx.js | 3 + api/utils/tokens.spec.js | 50 ++++++++++++ .../api/src/endpoints/anthropic/helpers.ts | 2 +- .../api/src/endpoints/anthropic/llm.spec.ts | 78 +++++++++++++++++++ packages/api/src/utils/tokens.ts | 2 + packages/data-provider/specs/bedrock.spec.ts | 66 +++++++++++++++- packages/data-provider/src/bedrock.ts | 25 +++--- packages/data-provider/src/config.ts | 2 + 9 files changed, 218 insertions(+), 16 deletions(-) diff --git a/.env.example b/.env.example index ec848c72a0..a6ff6157ce 100644 --- a/.env.example +++ b/.env.example @@ -135,7 +135,7 @@ PROXY= #============# ANTHROPIC_API_KEY=user_provided -# ANTHROPIC_MODELS=claude-opus-4-6,claude-opus-4-20250514,claude-sonnet-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-sonnet-4-6,claude-opus-4-6,claude-opus-4-20250514,claude-sonnet-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 @@ -170,8 +170,8 @@ ANTHROPIC_API_KEY=user_provided # BEDROCK_AWS_SESSION_TOKEN=someSessionToken # 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-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-opus-4-6-v1,global.anthropic.claude-opus-4-6-v1 +# BEDROCK_AWS_MODELS=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-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/models/tx.js b/api/models/tx.js index 959c88e2b4..9a6305ec5c 100644 --- a/api/models/tx.js +++ b/api/models/tx.js @@ -176,6 +176,7 @@ const tokenValues = Object.assign( 'claude-opus-4-5': { prompt: 5, completion: 25 }, 'claude-opus-4-6': { prompt: 5, completion: 25 }, 'claude-sonnet-4': { prompt: 3, completion: 15 }, + 'claude-sonnet-4-6': { prompt: 3, completion: 15 }, 'command-r': { prompt: 0.5, completion: 1.5 }, 'command-r-plus': { prompt: 3, completion: 15 }, 'command-text': { prompt: 1.5, completion: 2.0 }, @@ -309,6 +310,7 @@ const cacheTokenValues = { 'claude-3-haiku': { write: 0.3, read: 0.03 }, 'claude-haiku-4-5': { write: 1.25, read: 0.1 }, 'claude-sonnet-4': { write: 3.75, read: 0.3 }, + 'claude-sonnet-4-6': { write: 3.75, read: 0.3 }, '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 }, @@ -337,6 +339,7 @@ const cacheTokenValues = { */ const premiumTokenValues = { 'claude-opus-4-6': { threshold: 200000, prompt: 10, completion: 37.5 }, + 'claude-sonnet-4-6': { threshold: 200000, prompt: 6, completion: 22.5 }, }; /** diff --git a/api/utils/tokens.spec.js b/api/utils/tokens.spec.js index 0cfdc30227..18905d6d18 100644 --- a/api/utils/tokens.spec.js +++ b/api/utils/tokens.spec.js @@ -1162,6 +1162,56 @@ describe('Claude Model Tests', () => { expect(matchModelName(model, EModelEndpoint.anthropic)).toBe('claude-opus-4-6'); }); }); + + it('should return correct context length for Claude Sonnet 4.6 (1M)', () => { + expect(getModelMaxTokens('claude-sonnet-4-6', EModelEndpoint.anthropic)).toBe( + maxTokensMap[EModelEndpoint.anthropic]['claude-sonnet-4-6'], + ); + expect(getModelMaxTokens('claude-sonnet-4-6')).toBe( + maxTokensMap[EModelEndpoint.anthropic]['claude-sonnet-4-6'], + ); + }); + + it('should return correct max output tokens for Claude Sonnet 4.6 (64K)', () => { + const { getModelMaxOutputTokens } = require('@librechat/api'); + expect(getModelMaxOutputTokens('claude-sonnet-4-6', EModelEndpoint.anthropic)).toBe( + maxOutputTokensMap[EModelEndpoint.anthropic]['claude-sonnet-4-6'], + ); + }); + + it('should handle Claude Sonnet 4.6 model name variations', () => { + const modelVariations = [ + 'claude-sonnet-4-6', + 'claude-sonnet-4-6-20260101', + 'claude-sonnet-4-6-latest', + 'anthropic/claude-sonnet-4-6', + 'claude-sonnet-4-6/anthropic', + 'claude-sonnet-4-6-preview', + ]; + + modelVariations.forEach((model) => { + const modelKey = findMatchingPattern(model, maxTokensMap[EModelEndpoint.anthropic]); + expect(modelKey).toBe('claude-sonnet-4-6'); + expect(getModelMaxTokens(model, EModelEndpoint.anthropic)).toBe( + maxTokensMap[EModelEndpoint.anthropic]['claude-sonnet-4-6'], + ); + }); + }); + + it('should match model names correctly for Claude Sonnet 4.6', () => { + const modelVariations = [ + 'claude-sonnet-4-6', + 'claude-sonnet-4-6-20260101', + 'claude-sonnet-4-6-latest', + 'anthropic/claude-sonnet-4-6', + 'claude-sonnet-4-6/anthropic', + 'claude-sonnet-4-6-preview', + ]; + + modelVariations.forEach((model) => { + expect(matchModelName(model, EModelEndpoint.anthropic)).toBe('claude-sonnet-4-6'); + }); + }); }); describe('Moonshot/Kimi Model Tests', () => { diff --git a/packages/api/src/endpoints/anthropic/helpers.ts b/packages/api/src/endpoints/anthropic/helpers.ts index d9b1c1ccfe..d33116a2ac 100644 --- a/packages/api/src/endpoints/anthropic/helpers.ts +++ b/packages/api/src/endpoints/anthropic/helpers.ts @@ -65,7 +65,7 @@ function getClaudeHeaders( /** * Configures reasoning-related options for Claude models. - * Models supporting adaptive thinking (Opus 4.6+, Sonnet 5+) use effort control instead of manual budget_tokens. + * Models supporting adaptive thinking (Opus 4.6+, Sonnet 4.6+) use effort control instead of manual budget_tokens. */ function configureReasoning( anthropicInput: AnthropicClientOptions & { max_tokens?: number }, diff --git a/packages/api/src/endpoints/anthropic/llm.spec.ts b/packages/api/src/endpoints/anthropic/llm.spec.ts index 7734097a77..b945eacb34 100644 --- a/packages/api/src/endpoints/anthropic/llm.spec.ts +++ b/packages/api/src/endpoints/anthropic/llm.spec.ts @@ -121,6 +121,39 @@ describe('getLLMConfig', () => { }); }); + it('should add "context-1m" beta header for claude-sonnet-4-6 model', () => { + const modelOptions = { + model: 'claude-sonnet-4-6', + promptCache: true, + }; + const result = getLLMConfig('test-key', { modelOptions }); + const clientOptions = result.llmConfig.clientOptions; + expect(clientOptions?.defaultHeaders).toBeDefined(); + expect(clientOptions?.defaultHeaders).toHaveProperty('anthropic-beta'); + const defaultHeaders = clientOptions?.defaultHeaders as Record; + expect(defaultHeaders['anthropic-beta']).toBe('context-1m-2025-08-07'); + expect(result.llmConfig.promptCache).toBe(true); + }); + + it('should add "context-1m" beta header for claude-sonnet-4-6 model formats', () => { + const modelVariations = [ + 'claude-sonnet-4-6', + 'claude-sonnet-4-6-20260101', + 'anthropic/claude-sonnet-4-6', + ]; + + modelVariations.forEach((model) => { + const modelOptions = { model, promptCache: true }; + const result = getLLMConfig('test-key', { modelOptions }); + const clientOptions = result.llmConfig.clientOptions; + expect(clientOptions?.defaultHeaders).toBeDefined(); + expect(clientOptions?.defaultHeaders).toHaveProperty('anthropic-beta'); + const defaultHeaders = clientOptions?.defaultHeaders as Record; + expect(defaultHeaders['anthropic-beta']).toBe('context-1m-2025-08-07'); + expect(result.llmConfig.promptCache).toBe(true); + }); + }); + it('should pass promptCache boolean for claude-opus-4-5 model (no beta header needed)', () => { const modelOptions = { model: 'claude-opus-4-5', @@ -963,6 +996,51 @@ describe('getLLMConfig', () => { }); }); + it('should use adaptive thinking for Sonnet 4.6 instead of enabled + budget_tokens', () => { + const result = getLLMConfig('test-key', { + modelOptions: { + model: 'claude-sonnet-4-6', + thinking: true, + thinkingBudget: 10000, + }, + }); + + expect((result.llmConfig.thinking as unknown as { type: string }).type).toBe('adaptive'); + expect(result.llmConfig.thinking).not.toHaveProperty('budget_tokens'); + expect(result.llmConfig.maxTokens).toBe(64000); + }); + + it('should set effort via output_config for Sonnet 4.6', () => { + const result = getLLMConfig('test-key', { + modelOptions: { + model: 'claude-sonnet-4-6', + thinking: true, + effort: AnthropicEffort.high, + }, + }); + + expect((result.llmConfig.thinking as unknown as { type: string }).type).toBe('adaptive'); + expect(result.llmConfig.invocationKwargs).toHaveProperty('output_config'); + expect(result.llmConfig.invocationKwargs?.output_config).toEqual({ + effort: AnthropicEffort.high, + }); + }); + + it('should exclude topP/topK for Sonnet 4.6 with adaptive thinking', () => { + const result = getLLMConfig('test-key', { + modelOptions: { + model: 'claude-sonnet-4-6', + thinking: true, + topP: 0.9, + topK: 40, + }, + }); + + expect((result.llmConfig.thinking as unknown as { type: string }).type).toBe('adaptive'); + expect(result.llmConfig).not.toHaveProperty('topP'); + expect(result.llmConfig).not.toHaveProperty('topK'); + }); + it('should NOT set adaptive thinking or effort for non-adaptive models', () => { const nonAdaptiveModels = [ 'claude-opus-4-5', diff --git a/packages/api/src/utils/tokens.ts b/packages/api/src/utils/tokens.ts index 0f19b6eb55..a824afa489 100644 --- a/packages/api/src/utils/tokens.ts +++ b/packages/api/src/utils/tokens.ts @@ -148,6 +148,7 @@ const anthropicModels = { 'claude-3.5-sonnet-latest': 200000, 'claude-haiku-4-5': 200000, 'claude-sonnet-4': 1000000, + 'claude-sonnet-4-6': 1000000, 'claude-4': 200000, 'claude-opus-4': 200000, 'claude-opus-4-5': 200000, @@ -401,6 +402,7 @@ const anthropicMaxOutputs = { 'claude-3-opus': 4096, 'claude-haiku-4-5': 64000, 'claude-sonnet-4': 64000, + 'claude-sonnet-4-6': 64000, '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 ead41b47fa..55bd0a2e08 100644 --- a/packages/data-provider/specs/bedrock.spec.ts +++ b/packages/data-provider/specs/bedrock.spec.ts @@ -46,6 +46,30 @@ describe('supportsAdaptiveThinking', () => { expect(supportsAdaptiveThinking('claude-opus-4-0')).toBe(false); }); + test('should return true for claude-sonnet-4-6', () => { + expect(supportsAdaptiveThinking('claude-sonnet-4-6')).toBe(true); + }); + + test('should return true for claude-sonnet-4.6', () => { + expect(supportsAdaptiveThinking('claude-sonnet-4.6')).toBe(true); + }); + + test('should return true for claude-sonnet-4-7 (future)', () => { + expect(supportsAdaptiveThinking('claude-sonnet-4-7')).toBe(true); + }); + + test('should return true for anthropic.claude-sonnet-4-6 (Bedrock)', () => { + expect(supportsAdaptiveThinking('anthropic.claude-sonnet-4-6')).toBe(true); + }); + + test('should return true for us.anthropic.claude-sonnet-4-6 (cross-region Bedrock)', () => { + expect(supportsAdaptiveThinking('us.anthropic.claude-sonnet-4-6')).toBe(true); + }); + + test('should return true for claude-4-6-sonnet (alternate naming)', () => { + expect(supportsAdaptiveThinking('claude-4-6-sonnet')).toBe(true); + }); + test('should return false for claude-sonnet-4-5', () => { expect(supportsAdaptiveThinking('claude-sonnet-4-5')).toBe(false); }); @@ -104,6 +128,14 @@ describe('supportsContext1m', () => { expect(supportsContext1m('claude-sonnet-4-5')).toBe(true); }); + test('should return true for claude-sonnet-4-6', () => { + expect(supportsContext1m('claude-sonnet-4-6')).toBe(true); + }); + + test('should return true for anthropic.claude-sonnet-4-6 (Bedrock)', () => { + expect(supportsContext1m('anthropic.claude-sonnet-4-6')).toBe(true); + }); + test('should return true for claude-sonnet-5 (future)', () => { expect(supportsContext1m('claude-sonnet-5')).toBe(true); }); @@ -237,14 +269,42 @@ describe('bedrockInputParser', () => { ]); }); - test('should match anthropic.claude-4-7-sonnet model with 1M context header', () => { + test('should match anthropic.claude-sonnet-4-6 with adaptive thinking and 1M context header', () => { + const input = { + model: 'anthropic.claude-sonnet-4-6', + }; + const result = bedrockInputParser.parse(input) as Record; + const additionalFields = result.additionalModelRequestFields as Record; + expect(additionalFields.thinking).toEqual({ type: 'adaptive' }); + expect(additionalFields.thinkingBudget).toBeUndefined(); + expect(additionalFields.anthropic_beta).toEqual([ + 'output-128k-2025-02-19', + 'context-1m-2025-08-07', + ]); + }); + + test('should match us.anthropic.claude-sonnet-4-6 with adaptive thinking and 1M context header', () => { + const input = { + model: 'us.anthropic.claude-sonnet-4-6', + }; + const result = bedrockInputParser.parse(input) as Record; + const additionalFields = result.additionalModelRequestFields as Record; + expect(additionalFields.thinking).toEqual({ type: 'adaptive' }); + expect(additionalFields.thinkingBudget).toBeUndefined(); + expect(additionalFields.anthropic_beta).toEqual([ + 'output-128k-2025-02-19', + 'context-1m-2025-08-07', + ]); + }); + + test('should match anthropic.claude-4-7-sonnet model with adaptive thinking and 1M context header', () => { const input = { model: 'anthropic.claude-4-7-sonnet', }; const result = bedrockInputParser.parse(input) as Record; const additionalFields = result.additionalModelRequestFields as Record; - expect(additionalFields.thinking).toBe(true); - expect(additionalFields.thinkingBudget).toBe(2000); + expect(additionalFields.thinking).toEqual({ type: 'adaptive' }); + expect(additionalFields.thinkingBudget).toBeUndefined(); expect(additionalFields.anthropic_beta).toEqual([ 'output-128k-2025-02-19', 'context-1m-2025-08-07', diff --git a/packages/data-provider/src/bedrock.ts b/packages/data-provider/src/bedrock.ts index a037245fc0..54f5c8b23f 100644 --- a/packages/data-provider/src/bedrock.ts +++ b/packages/data-provider/src/bedrock.ts @@ -35,27 +35,34 @@ function parseOpusVersion(model: string): { major: number; minor: number } | nul return null; } -/** Extracts sonnet major version from both naming formats */ -function parseSonnetVersion(model: string): number | null { - const nameFirst = model.match(/claude-sonnet[-.]?(\d+)/); +/** Extracts sonnet major/minor version from both naming formats. + * Uses single-digit minor capture to avoid matching date suffixes (e.g., -20250514). */ +function parseSonnetVersion(model: string): { major: number; minor: number } | null { + const nameFirst = model.match(/claude-sonnet[-.]?(\d+)(?:[-.](\d)(?!\d))?/); if (nameFirst) { - return parseInt(nameFirst[1], 10); + return { + major: parseInt(nameFirst[1], 10), + minor: nameFirst[2] != null ? parseInt(nameFirst[2], 10) : 0, + }; } - const numFirst = model.match(/claude-(\d+)(?:[-.]?\d+)?-sonnet/); + const numFirst = model.match(/claude-(\d+)(?:[-.](\d)(?!\d))?-sonnet/); if (numFirst) { - return parseInt(numFirst[1], 10); + return { + major: parseInt(numFirst[1], 10), + minor: numFirst[2] != null ? parseInt(numFirst[2], 10) : 0, + }; } return null; } -/** Checks if a model supports adaptive thinking (Opus 4.6+, Sonnet 5+) */ +/** Checks if a model supports adaptive thinking (Opus 4.6+, Sonnet 4.6+) */ export function supportsAdaptiveThinking(model: string): boolean { const opus = parseOpusVersion(model); if (opus && (opus.major > 4 || (opus.major === 4 && opus.minor >= 6))) { return true; } const sonnet = parseSonnetVersion(model); - if (sonnet != null && sonnet >= 5) { + if (sonnet != null && (sonnet.major > 4 || (sonnet.major === 4 && sonnet.minor >= 6))) { return true; } return false; @@ -64,7 +71,7 @@ export function supportsAdaptiveThinking(model: string): boolean { /** Checks if a model qualifies for the context-1m beta header (Sonnet 4+, Opus 4.6+, Opus 5+) */ export function supportsContext1m(model: string): boolean { const sonnet = parseSonnetVersion(model); - if (sonnet != null && sonnet >= 4) { + if (sonnet != null && sonnet.major >= 4) { return true; } const opus = parseOpusVersion(model); diff --git a/packages/data-provider/src/config.ts b/packages/data-provider/src/config.ts index 02174b6496..c730e3e8fb 100644 --- a/packages/data-provider/src/config.ts +++ b/packages/data-provider/src/config.ts @@ -1133,6 +1133,7 @@ const sharedOpenAIModels = [ ]; const sharedAnthropicModels = [ + 'claude-sonnet-4-6', 'claude-opus-4-6', 'claude-sonnet-4-5', 'claude-sonnet-4-5-20250929', @@ -1154,6 +1155,7 @@ const sharedAnthropicModels = [ ]; export const bedrockModels = [ + 'anthropic.claude-sonnet-4-6', 'anthropic.claude-opus-4-6-v1', 'anthropic.claude-sonnet-4-5-20250929-v1:0', 'anthropic.claude-haiku-4-5-20251001-v1:0',