🧢 fix: Raise Claude Sonnet 4.6 Output Cap (#14115)

* fix: raise Claude Sonnet 4.6 output cap

* fix: handle Sonnet 4.6 token edge cases

* fix: align Sonnet token aliases

* test: update Bedrock Sonnet output cap expectation

* fix: align future Sonnet token aliases

* fix: cap Bedrock Sonnet 4.6 default output

* fix: support double-digit Sonnet 4 minors

* style: format Sonnet token helpers

* fix: match number-first Sonnet aliases
This commit is contained in:
Danny Avila 2026-07-05 12:04:37 -04:00 committed by GitHub
parent 2d4ef52c22
commit a03c574bef
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 255 additions and 17 deletions

View file

@ -1587,6 +1587,9 @@ describe('Claude Model Tests', () => {
expect(getModelMaxTokens('claude-sonnet-4-6', EModelEndpoint.anthropic)).toBe(
maxTokensMap[EModelEndpoint.anthropic]['claude-sonnet-4-6'],
);
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'],
);
@ -1595,11 +1598,77 @@ describe('Claude Model Tests', () => {
);
});
it('should return correct max output tokens for Claude Sonnet 4.6 (64K)', () => {
it('should return correct max output tokens for Claude Sonnet 4.6 (128K)', () => {
const { getModelMaxOutputTokens } = require('@librechat/api');
expect(getModelMaxOutputTokens('claude-sonnet-4-6', EModelEndpoint.anthropic)).toBe(
maxOutputTokensMap[EModelEndpoint.anthropic]['claude-sonnet-4-6'],
);
expect(getModelMaxOutputTokens('claude-sonnet-4.6', EModelEndpoint.anthropic)).toBe(
maxOutputTokensMap[EModelEndpoint.anthropic]['claude-sonnet-4.6'],
);
});
it('should return correct context length for Claude Sonnet 4.7+ aliases (1M)', () => {
[
'claude-sonnet-4-7',
'claude-sonnet-4.7',
'claude-sonnet-4-8',
'claude-sonnet-4.8',
'claude-sonnet-4-9',
'claude-sonnet-4.9',
'claude-sonnet-4-10',
'claude-sonnet-4.10',
'claude-4-10-sonnet',
'claude-4.10-sonnet',
'anthropic.claude-4-10-sonnet',
].forEach((model) => {
expect(getModelMaxTokens(model, EModelEndpoint.anthropic)).toBe(1000000);
});
expect(getModelMaxTokens('anthropic.claude-4-10-sonnet', EModelEndpoint.bedrock)).toBe(1000000);
});
it('should return correct max output tokens for Claude Sonnet 4.7+ aliases (128K)', () => {
const { getModelMaxOutputTokens } = require('@librechat/api');
[
'claude-sonnet-4-7',
'claude-sonnet-4.7',
'claude-sonnet-4-8',
'claude-sonnet-4.8',
'claude-sonnet-4-9',
'claude-sonnet-4.9',
'claude-sonnet-4-10',
'claude-sonnet-4.10',
'claude-4-10-sonnet',
'claude-4.10-sonnet',
].forEach((model) => {
expect(getModelMaxOutputTokens(model, EModelEndpoint.anthropic)).toBe(128000);
});
});
it('should not treat dated Claude Sonnet 4 IDs as Claude Sonnet 4.6+ aliases', () => {
const { getModelMaxOutputTokens } = require('@librechat/api');
expect(getModelMaxTokens('claude-sonnet-4-20250514', EModelEndpoint.anthropic)).toBe(
maxTokensMap[EModelEndpoint.anthropic]['claude-sonnet-4'],
);
expect(getModelMaxOutputTokens('claude-sonnet-4-20250514', EModelEndpoint.anthropic)).toBe(
maxOutputTokensMap[EModelEndpoint.anthropic]['claude-sonnet-4'],
);
expect(getModelMaxTokens('claude-4-20250514-sonnet', EModelEndpoint.bedrock)).toBe(
maxTokensMap[EModelEndpoint.bedrock]['claude-4'],
);
});
it('should keep double-digit Claude Sonnet 4 minor names when matching models', () => {
expect(matchModelName('claude-sonnet-4-10', EModelEndpoint.anthropic)).toBe(
'claude-sonnet-4-10',
);
expect(matchModelName('claude-sonnet-4.10-latest', EModelEndpoint.anthropic)).toBe(
'claude-sonnet-4.10-latest',
);
expect(matchModelName('claude-4-10-sonnet', EModelEndpoint.bedrock)).toBe('claude-4-10-sonnet');
expect(matchModelName('anthropic.claude-4.10-sonnet', EModelEndpoint.bedrock)).toBe(
'anthropic.claude-4.10-sonnet',
);
});
it('should handle Claude Sonnet 4.6 model name variations', () => {
@ -1650,7 +1719,7 @@ describe('Claude Model Tests', () => {
expect(getModelMaxOutputTokens('claude-sonnet-5', EModelEndpoint.anthropic)).toBe(
maxOutputTokensMap[EModelEndpoint.anthropic]['claude-sonnet-5'],
);
expect(getModelMaxOutputTokens('claude-sonnet-5', EModelEndpoint.anthropic)).toBeGreaterThan(
expect(getModelMaxOutputTokens('claude-sonnet-5', EModelEndpoint.anthropic)).toBe(
getModelMaxOutputTokens('claude-sonnet-4-6', EModelEndpoint.anthropic),
);
});

View file

@ -957,8 +957,8 @@ describe('getLLMConfig', () => {
});
});
it('should default future Claude 4.x Sonnet/Haiku models to 64K (future-proofing)', () => {
const testCases = ['claude-sonnet-4-20250514', 'claude-sonnet-4-9', 'claude-haiku-4-8'];
it('should keep Claude 4.x Sonnet before 4.6 and Haiku models at 64K', () => {
const testCases = ['claude-sonnet-4-20250514', 'claude-sonnet-4-5', 'claude-haiku-4-8'];
testCases.forEach((model) => {
const result = getLLMConfig('test-key', {
@ -968,6 +968,23 @@ describe('getLLMConfig', () => {
});
});
it('should default Claude Sonnet 4.6+ models to 128K tokens', () => {
const testCases = [
'claude-sonnet-4-6',
'claude-sonnet-4.6',
'claude-sonnet-4-9',
'claude-sonnet-4-10',
'claude-sonnet-4.10',
];
testCases.forEach((model) => {
const result = getLLMConfig('test-key', {
modelOptions: { model },
});
expect(result.llmConfig.maxTokens).toBe(128000);
});
});
it('should default future Claude 4.x Opus models (future-proofing)', () => {
// opus-4-0 through opus-4-4 get 32K
const opus32kModels = ['claude-opus-4-0', 'claude-opus-4-1', 'claude-opus-4-4'];
@ -1116,7 +1133,7 @@ describe('getLLMConfig', () => {
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);
expect(result.llmConfig.maxTokens).toBe(128000);
});
it('should set effort via output_config for Sonnet 4.6', () => {

View file

@ -156,6 +156,13 @@ const anthropicModels = {
'claude-sonnet-4': 200000,
'claude-sonnet-4-5': 200000,
'claude-sonnet-4-6': 1000000,
'claude-sonnet-4.6': 1000000,
'claude-sonnet-4-7': 1000000,
'claude-sonnet-4.7': 1000000,
'claude-sonnet-4-8': 1000000,
'claude-sonnet-4.8': 1000000,
'claude-sonnet-4-9': 1000000,
'claude-sonnet-4.9': 1000000,
'claude-sonnet-5': 1000000,
'claude-opus-4-6': 1000000,
'claude-opus-4-7': 1000000,
@ -164,6 +171,41 @@ const anthropicModels = {
'claude-mythos-5': 1000000,
};
const ANTHROPIC_SONNET_4_6_PLUS_CONTEXT = 1000000;
const ANTHROPIC_SONNET_4_6_PLUS_OUTPUT = 128000;
const ANTHROPIC_SONNET_4_6_PLUS_PATTERN =
/(?:claude-sonnet[-.]?4[-.]?(?:[6-9]|\d{2})|claude[-.]?4[-.]?(?:[6-9]|\d{2})[-.]?sonnet)(?=$|[^0-9])/;
function usesAnthropicContextMap(endpoint: EModelEndpoint): boolean {
return (
endpoint === EModelEndpoint.anthropic ||
endpoint === EModelEndpoint.bedrock ||
endpoint === EModelEndpoint.openAI ||
endpoint === EModelEndpoint.agents ||
endpoint === EModelEndpoint.custom
);
}
function getAnthropicSonnet46PlusContext(
modelName: string,
endpoint: EModelEndpoint,
): number | undefined {
if (!usesAnthropicContextMap(endpoint) || !ANTHROPIC_SONNET_4_6_PLUS_PATTERN.test(modelName)) {
return undefined;
}
return ANTHROPIC_SONNET_4_6_PLUS_CONTEXT;
}
function getAnthropicSonnet46PlusOutput(
modelName: string,
endpoint: EModelEndpoint,
): number | undefined {
if (endpoint !== EModelEndpoint.anthropic || !ANTHROPIC_SONNET_4_6_PLUS_PATTERN.test(modelName)) {
return undefined;
}
return ANTHROPIC_SONNET_4_6_PLUS_OUTPUT;
}
const deepseekModels = {
deepseek: 128000,
'deepseek-chat': 128000,
@ -408,7 +450,14 @@ const anthropicMaxOutputs = {
'claude-3-opus': 4096,
'claude-haiku-4-5': 64000,
'claude-sonnet-4': 64000,
'claude-sonnet-4-6': 64000,
'claude-sonnet-4-6': 128000,
'claude-sonnet-4.6': 128000,
'claude-sonnet-4-7': 128000,
'claude-sonnet-4.7': 128000,
'claude-sonnet-4-8': 128000,
'claude-sonnet-4.8': 128000,
'claude-sonnet-4-9': 128000,
'claude-sonnet-4.9': 128000,
'claude-sonnet-5': 128000,
'claude-opus-4': 32000,
'claude-opus-4-5': 64000,
@ -530,6 +579,10 @@ export function getModelMaxTokens(
return overrideValue;
}
}
const sonnet46PlusValue = getAnthropicSonnet46PlusContext(modelName, endpoint);
if (sonnet46PlusValue != null) {
return sonnet46PlusValue;
}
return getModelTokenValue(modelName, maxTokensMap[endpoint as keyof typeof maxTokensMap]);
}
@ -553,6 +606,10 @@ export function getModelMaxOutputTokens(
return overrideValue;
}
}
const sonnet46PlusValue = getAnthropicSonnet46PlusOutput(modelName, endpoint);
if (sonnet46PlusValue != null) {
return sonnet46PlusValue;
}
return getModelTokenValue(
modelName,
maxOutputTokensMap[endpoint as keyof typeof maxOutputTokensMap],
@ -591,6 +648,12 @@ export function matchModelName(
}
const matchedPattern = findMatchingPattern(modelName, tokensMap);
if (
(matchedPattern === 'claude-sonnet-4' || matchedPattern === 'claude-4') &&
getAnthropicSonnet46PlusContext(modelName, endpoint) != null
) {
return modelName;
}
return matchedPattern || modelName;
}

View file

@ -92,6 +92,16 @@ describe('supportsAdaptiveThinking', () => {
expect(supportsAdaptiveThinking('claude-sonnet-4-7')).toBe(true);
});
test('should return true for double-digit claude-sonnet-4 minors', () => {
expect(supportsAdaptiveThinking('claude-sonnet-4-10')).toBe(true);
expect(supportsAdaptiveThinking('claude-sonnet-4.10')).toBe(true);
expect(supportsAdaptiveThinking('claude-4-10-sonnet')).toBe(true);
});
test('should not parse Sonnet 4 date suffixes as double-digit minors', () => {
expect(supportsAdaptiveThinking('claude-sonnet-4-20250514')).toBe(false);
});
test('should return true for anthropic.claude-sonnet-4-6 (Bedrock)', () => {
expect(supportsAdaptiveThinking('anthropic.claude-sonnet-4-6')).toBe(true);
});
@ -166,6 +176,16 @@ describe('supportsContext1m', () => {
expect(supportsContext1m('claude-sonnet-4-6')).toBe(true);
});
test('should return true for double-digit claude-sonnet-4 minors', () => {
expect(supportsContext1m('claude-sonnet-4-10')).toBe(true);
expect(supportsContext1m('claude-sonnet-4.10')).toBe(true);
expect(supportsContext1m('claude-4-10-sonnet')).toBe(true);
});
test('should not treat Sonnet 4 date suffixes as 1M context models', () => {
expect(supportsContext1m('claude-sonnet-4-20250514')).toBe(false);
});
test('should return true for anthropic.claude-sonnet-4-6 (Bedrock)', () => {
expect(supportsContext1m('anthropic.claude-sonnet-4-6')).toBe(true);
});
@ -1222,6 +1242,17 @@ describe('bedrockInputParser', () => {
expect(output.maxOutputTokens).toBeUndefined();
});
test('defaults Bedrock Claude Sonnet 4.6 adaptive maxTokens to its Bedrock max output when unset', () => {
const parsed = bedrockInputParser.parse({
model: 'anthropic.claude-sonnet-4-6',
}) as Record<string, unknown>;
const output = bedrockOutputParser(parsed as Record<string, unknown>);
const amrf = output.additionalModelRequestFields as Record<string, unknown>;
expect(amrf.thinking).toEqual({ type: 'adaptive' });
expect(output.maxTokens).toBe(64000);
expect(output.maxOutputTokens).toBeUndefined();
});
test('should respect user-provided maxTokens for adaptive model', () => {
const parsed = bedrockInputParser.parse({
model: 'anthropic.claude-opus-4-6-v1',
@ -1289,7 +1320,7 @@ describe('bedrockInputParser', () => {
// thinking models here but are not matched by the family-first reset()
// regex; they must still resolve to the real ceiling, not the 8192 fallback.
test.each([
['anthropic.claude-4-7-sonnet', 64000],
['anthropic.claude-4-7-sonnet', 128000],
['claude-5-sonnet', 128000],
['anthropic.claude-4-8-opus', 128000],
])('defaults number-first adaptive alias %s to its real max output', (model, expected) => {

View file

@ -2,6 +2,7 @@ import { z } from 'zod';
import * as s from './schemas';
const DEFAULT_THINKING_BUDGET = 2000;
const BEDROCK_CLAUDE_SONNET_4_6_MAX_OUTPUT = 64000;
export const BEDROCK_OUTPUT_128K_BETA = 'output-128k-2025-02-19';
export const BEDROCK_FINE_GRAINED_TOOL_STREAMING_BETA = 'fine-grained-tool-streaming-2025-05-14';
@ -93,16 +94,16 @@ function parseOpusVersion(model: string): { major: number; minor: number } | nul
}
/** Extracts sonnet major/minor version from both naming formats.
* Uses single-digit minor capture to avoid matching date suffixes (e.g., -20250514). */
* Uses bounded 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))?/);
const nameFirst = model.match(/claude-sonnet[-.]?(\d+)(?:[-.](\d{1,2})(?!\d))?/);
if (nameFirst) {
return {
major: parseInt(nameFirst[1], 10),
minor: nameFirst[2] != null ? parseInt(nameFirst[2], 10) : 0,
};
}
const numFirst = model.match(/claude-(\d+)(?:[-.](\d)(?!\d))?-sonnet/);
const numFirst = model.match(/claude-(\d+)(?:[-.](\d{1,2})(?!\d))?-sonnet/);
if (numFirst) {
return {
major: parseInt(numFirst[1], 10),
@ -683,6 +684,10 @@ function toFamilyFirstClaudeId(model: string): string {
return model.replace(/claude-(\d+(?:[-.]\d+)?)-(sonnet|opus|haiku)/, 'claude-$2-$1');
}
function isBedrockClaudeSonnet46(model: string): boolean {
return /claude-sonnet[-.]?4[-.]?6(?=$|[^0-9])/.test(toFamilyFirstClaudeId(model));
}
/**
* Thinking tokens share the `maxTokens` output budget with tool-call arguments
* (e.g. a `create_file` `content`), so a low default truncates large authored
@ -695,6 +700,9 @@ function resolveThinkingMaxTokens(data: AnthropicInput): number {
return explicit;
}
const model = typeof data.model === 'string' ? data.model : '';
if (isBedrockClaudeSonnet46(model)) {
return BEDROCK_CLAUDE_SONNET_4_6_MAX_OUTPUT;
}
return s.anthropicSettings.maxOutputTokens.reset(toFamilyFirstClaudeId(model));
}

View file

@ -10,7 +10,7 @@ describe('anthropicSettings', () => {
describe('maxOutputTokens.reset()', () => {
const { reset } = anthropicSettings.maxOutputTokens;
describe('Claude Sonnet 4.x models (64K limit)', () => {
describe('Claude Sonnet 4/4.5 models (64K limit)', () => {
it('should return 64K for claude-sonnet-4', () => {
expect(reset('claude-sonnet-4')).toBe(64000);
});
@ -19,8 +19,31 @@ describe('anthropicSettings', () => {
expect(reset('claude-sonnet-4-5')).toBe(64000);
});
it('should return 64K for claude-sonnet-4-6', () => {
expect(reset('claude-sonnet-4-6')).toBe(64000);
it('should return 64K for dated claude-sonnet-4', () => {
expect(reset('claude-sonnet-4-20250514')).toBe(64000);
});
});
describe('Claude Sonnet 4.6+ models (128K limit)', () => {
it('should return 128K for claude-sonnet-4-6', () => {
expect(reset('claude-sonnet-4-6')).toBe(128000);
});
it('should return 128K for claude-sonnet-4.6', () => {
expect(reset('claude-sonnet-4.6')).toBe(128000);
});
it('should return 128K for claude-sonnet-4-7', () => {
expect(reset('claude-sonnet-4-7')).toBe(128000);
});
it('should return 128K for claude-sonnet-4-9', () => {
expect(reset('claude-sonnet-4-9')).toBe(128000);
});
it('should return 128K for double-digit claude-sonnet-4 minors', () => {
expect(reset('claude-sonnet-4-10')).toBe(128000);
expect(reset('claude-sonnet-4.10')).toBe(128000);
});
});
@ -261,11 +284,15 @@ describe('anthropicSettings', () => {
describe('maxOutputTokens.set()', () => {
const { set } = anthropicSettings.maxOutputTokens;
describe('Claude Sonnet and Haiku 4+ models (64K cap)', () => {
describe('Claude Sonnet 4/4.5 and Haiku 4+ models (64K cap)', () => {
it('should cap at 64K for claude-sonnet-4 when value exceeds', () => {
expect(set(100000, 'claude-sonnet-4')).toBe(64000);
});
it('should cap at 64K for dated claude-sonnet-4 when value exceeds', () => {
expect(set(100000, 'claude-sonnet-4-20250514')).toBe(64000);
});
it('should allow 50K for claude-sonnet-4', () => {
expect(set(50000, 'claude-sonnet-4')).toBe(50000);
});
@ -275,7 +302,28 @@ describe('anthropicSettings', () => {
});
});
describe('Claude Sonnet 5+ models (128K cap)', () => {
describe('Claude Sonnet 4.6+ models (128K cap)', () => {
it('should allow 100K for claude-sonnet-4-6', () => {
expect(set(100000, 'claude-sonnet-4-6')).toBe(100000);
});
it('should cap at 128K for claude-sonnet-4-6 when value exceeds', () => {
expect(set(150000, 'claude-sonnet-4-6')).toBe(128000);
});
it('should allow 100K for claude-sonnet-4.6', () => {
expect(set(100000, 'claude-sonnet-4.6')).toBe(100000);
});
it('should cap at 128K for claude-sonnet-4.6 when value exceeds', () => {
expect(set(150000, 'claude-sonnet-4.6')).toBe(128000);
});
it('should cap at 128K for double-digit claude-sonnet-4 minors', () => {
expect(set(100000, 'claude-sonnet-4-10')).toBe(100000);
expect(set(150000, 'claude-sonnet-4.10')).toBe(128000);
});
it('should allow 100K for claude-sonnet-5', () => {
expect(set(100000, 'claude-sonnet-5')).toBe(100000);
});

View file

@ -487,6 +487,8 @@ const CLAUDE_4_64K_MAX_OUTPUT = 64000 as const;
const CLAUDE_32K_MAX_OUTPUT = 32000 as const;
const DEFAULT_MAX_OUTPUT = 8192 as const;
const LEGACY_ANTHROPIC_MAX_OUTPUT = 4096 as const;
const CLAUDE_SONNET_128K_OUTPUT_PATTERN =
/claude-sonnet[-.]?(?:4[-.]?(?:[6-9]|\d{2})|[5-9]|\d{2,})(?=$|[^0-9])/;
/**
* Claude "Mythos-class" model families new top-level classes (peers of
@ -547,7 +549,7 @@ export const anthropicSettings = {
return ANTHROPIC_MAX_OUTPUT;
}
if (/claude-sonnet[-.]?(?:[5-9]|\d{2,})/.test(modelName)) {
if (CLAUDE_SONNET_128K_OUTPUT_PATTERN.test(modelName)) {
return ANTHROPIC_MAX_OUTPUT;
}
@ -580,7 +582,7 @@ export const anthropicSettings = {
return value;
}
if (/claude-sonnet[-.]?(?:[5-9]|\d{2,})/.test(modelName)) {
if (CLAUDE_SONNET_128K_OUTPUT_PATTERN.test(modelName)) {
if (value > ANTHROPIC_MAX_OUTPUT) {
return ANTHROPIC_MAX_OUTPUT;
}