From e31ae67d243106d6196f4161d732d20f73a778d1 Mon Sep 17 00:00:00 2001 From: Danny Avila Date: Mon, 28 Sep 2026 14:10:31 -0400 Subject: [PATCH] =?UTF-8?q?=F0=9F=8E=BC=20feat:=20Add=20Claude=20Sonnet=20?= =?UTF-8?q?5.5=20Support?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Sonnet 5.5 (`claude-sonnet-5-5`) rejects `thinking.type: disabled` and takes `between_tools` as its lowest thinking setting, binds thinking blocks to the conversation prefix, caps effort at `high` while thinking is off, rejects sampling parameters, supports the `updates` thinking display, and ships with 128k output / 1M context at Sonnet 5 pricing. Family gates in data-provider now use "5.5 or later" semantics so Opus 5.6+, Sonnet 5.6+ and the Mythos-class line (Fable) inherit the newest contract instead of falling back to the Opus 5 / Sonnet 5 paths. --- client/src/locales/en/translation.json | 4 +- .../api/src/endpoints/anthropic/helpers.ts | 27 ++- .../api/src/endpoints/anthropic/llm.spec.ts | 166 ++++++++++++- packages/api/src/endpoints/anthropic/llm.ts | 39 ++- packages/api/src/utils/tokens.spec.ts | 14 ++ packages/api/src/utils/tokens.ts | 18 +- packages/data-provider/specs/bedrock.spec.ts | 191 ++++++++++++++- packages/data-provider/src/bedrock.ts | 226 +++++++++++------- packages/data-provider/src/config.ts | 2 + .../src/parameterSettings.spec.ts | 19 ++ .../data-provider/src/parameterSettings.ts | 17 +- packages/data-provider/src/schemas.ts | 11 +- packages/data-schemas/src/app/vertex.ts | 1 + packages/data-schemas/src/methods/tx.spec.ts | 24 ++ packages/data-schemas/src/methods/tx.ts | 4 + 15 files changed, 626 insertions(+), 137 deletions(-) diff --git a/client/src/locales/en/translation.json b/client/src/locales/en/translation.json index 9a310542efb..65e8393b116 100644 --- a/client/src/locales/en/translation.json +++ b/client/src/locales/en/translation.json @@ -362,9 +362,10 @@ "com_endpoint_anthropic_prompt_cache_ttl": "How long a cached prompt prefix stays warm between requests. Leave unset to use the provider default (1 hour). The 1-hour cache keeps the prefix warm across longer gaps at a higher one-time write cost; choose 5 minutes for the legacy behavior.", "com_endpoint_anthropic_temp": "Ranges from 0 to 1. Use temp closer to 0 for analytical / multiple choice, and closer to 1 for creative and generative tasks. We recommend altering this or Top P but not both.", "com_endpoint_anthropic_thinking": "Enables internal reasoning for supported Claude models. For newer models (Opus 4.6+), uses adaptive thinking controlled by the Effort parameter. For legacy models, requires \"Thinking Budget\" to be set and lower than \"Max Output Tokens\".", + "com_endpoint_anthropic_thinking_between_tools": "Enables adaptive thinking controlled by the Effort parameter. This model cannot turn thinking off entirely: switching this off uses its lowest setting, which skips extended thinking and keeps only brief notes between tool calls. Effort is capped at High while off.", "com_endpoint_anthropic_thinking_budget": "Determines the max number of tokens Claude is allowed to use for its internal reasoning process. Larger budgets can improve response quality by enabling more thorough analysis for complex problems, although Claude may not use the entire budget allocated, especially at ranges above 32K. This setting must be lower than \"Max Output Tokens.\"", "com_endpoint_anthropic_thinking_display": "Thought Visibility", - "com_endpoint_anthropic_thinking_display_desc": "Controls whether Claude's reasoning is returned. 'Auto' opts in to summarized thoughts for models that hide them by default (Opus 4.7+); 'Summarized' always shows them; 'Omitted' always hides them for slightly lower latency.", + "com_endpoint_anthropic_thinking_display_desc": "Controls whether Claude's reasoning is returned. 'Auto' opts in to summarized thoughts for models that hide them by default (Opus 4.7+); 'Summarized' always shows them; 'Omitted' always hides them for slightly lower latency; 'Updates' shows only the progress notes Claude writes between tool calls and hides the reasoning itself.", "com_endpoint_anthropic_topk": "Top-k changes how the model selects tokens for output. A top-k of 1 means the selected token is the most probable among all tokens in the model's vocabulary (also called greedy decoding), while a top-k of 3 means that the next token is selected from among the 3 most probable tokens (using temperature).", "com_endpoint_anthropic_topp": "Top-p changes how the model selects tokens for output. Tokens are selected from most K (see topK parameter) probable to least until the sum of their probabilities equals the top-p value.", "com_endpoint_anthropic_use_web_search": "Enable web search functionality using Anthropic's built-in search capabilities. This allows the model to search the web for up-to-date information and provide more accurate, current responses.", @@ -2894,6 +2895,7 @@ "com_ui_update_shared_link_confirm_description": "This publishes the latest messages and your current file-sharing choice to the existing link. The URL stays the same, and anyone with access can see the updated snapshot.", "com_ui_update_shared_link_confirm_title": "Update shared link?", "com_ui_updated_file": "Updated {{0}}", + "com_ui_updates": "Updates", "com_ui_updating": "Updating...", "com_ui_upload": "Upload", "com_ui_upload_agent_avatar": "Successfully updated agent avatar", diff --git a/packages/api/src/endpoints/anthropic/helpers.ts b/packages/api/src/endpoints/anthropic/helpers.ts index e31f3e0add9..0267a3004a1 100644 --- a/packages/api/src/endpoints/anthropic/helpers.ts +++ b/packages/api/src/endpoints/anthropic/helpers.ts @@ -1,15 +1,16 @@ import { logger } from '@librechat/data-schemas'; import { AnthropicClientOptions } from '@librechat/agents'; import { - isOpus55Model, - OPUS_55_BLOCK_BINDING, ThinkingDisplay, AnthropicEffort, anthropicSettings, + bindsThinkingBlocks, + supportsPromptCache, + hasAlwaysOnThinking, + THINKING_BLOCK_BINDING, resolveThinkingDisplay, supportsAdaptiveThinking, - supportsPromptCache, - requiresExplicitThinkingDisabled, + resolveThinkingOffConfig, } from 'librechat-data-provider'; const FINE_GRAINED_TOOL_STREAMING_BETA = 'fine-grained-tool-streaming-2025-05-14'; @@ -87,17 +88,21 @@ function configureReasoning( /** * Sonnet 5 and Opus 5 run 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. - * This returns before effort is applied, which is why the Opus 5 effort cap - * is enforced by the caller. + * sending an explicit disabled config rather than leaving the field unset; + * Sonnet 5.5+ rejects `disabled` and takes `between_tools` instead. This + * returns before effort is applied, which is why the effort cap for these + * configs is enforced by the caller. Always-on models (Opus 5.5+, Fable) + * ignore a stored "off" and always send the adaptive config. */ - if (!extendedOptions.thinking && modelName && requiresExplicitThinkingDisabled(modelName)) { - updatedOptions.thinking = { type: 'disabled' } as AnthropicClientOptions['thinking']; + const thinkingOffConfig = + !extendedOptions.thinking && modelName ? resolveThinkingOffConfig(modelName) : undefined; + if (thinkingOffConfig) { + updatedOptions.thinking = thinkingOffConfig as AnthropicClientOptions['thinking']; return updatedOptions; } if ( - (extendedOptions.thinking || isOpus55Model(modelName)) && + (extendedOptions.thinking || hasAlwaysOnThinking(modelName)) && modelName && supportsAdaptiveThinking(modelName) ) { @@ -113,7 +118,7 @@ function configureReasoning( const adaptive = { type: 'adaptive' as const, ...(display ? { display } : {}), - ...(isOpus55Model(modelName) ? { block_binding: { ...OPUS_55_BLOCK_BINDING } } : {}), + ...(bindsThinkingBlocks(modelName) ? { block_binding: { ...THINKING_BLOCK_BINDING } } : {}), }; /** * TODO: Remove the cast once `@librechat/agents` updates its diff --git a/packages/api/src/endpoints/anthropic/llm.spec.ts b/packages/api/src/endpoints/anthropic/llm.spec.ts index 7e343a0c0c8..bbce5239e79 100644 --- a/packages/api/src/endpoints/anthropic/llm.spec.ts +++ b/packages/api/src/endpoints/anthropic/llm.spec.ts @@ -1,5 +1,11 @@ import { Providers, getChatModelClass } from '@librechat/agents'; -import { AuthKeys, AnthropicEffort, ThinkingDisplay } from 'librechat-data-provider'; +import { + AuthKeys, + AnthropicEffort, + ThinkingDisplay, + bindsThinkingBlocks, + THINKING_BINDING_BETA, +} from 'librechat-data-provider'; import type * as t from '~/types'; import { FINE_GRAINED_TOOL_STREAMING_BETA } from './helpers'; import { getLLMConfig } from './llm'; @@ -1367,6 +1373,154 @@ describe('getLLMConfig', () => { expect(result.llmConfig.thinking).toEqual({ type: 'adaptive', display: 'summarized' }); }); + describe('Sonnet 5.5', () => { + const SONNET_55_IDS = ['claude-sonnet-5-5', 'claude-sonnet-5.5']; + + it.each(SONNET_55_IDS)( + 'binds adaptive thinking blocks, omits sampling and caches prompts for %s', + (model) => { + const result = getLLMConfig('test-key', { + modelOptions: { + model, + thinking: true, + effort: AnthropicEffort.max, + temperature: 0.7, + topP: 0.9, + topK: 40, + }, + }); + + expect(result.llmConfig.model).toBe(model); + expect(result.llmConfig.thinking).toEqual({ + type: 'adaptive', + display: 'summarized', + block_binding: { prefix_mismatch_behavior: 'drop_block' }, + }); + expect(result.llmConfig.outputConfig).toEqual({ effort: AnthropicEffort.max }); + expect(result.llmConfig).not.toHaveProperty('temperature'); + expect(result.llmConfig).not.toHaveProperty('topP'); + expect(result.llmConfig).not.toHaveProperty('topK'); + expect(result.llmConfig.maxTokens).toBe(128000); + expect(result.llmConfig).toHaveProperty('promptCache', true); + const beta = (result.llmConfig.clientOptions?.defaultHeaders as Record)[ + 'anthropic-beta' + ]; + expect(beta).toContain('thinking-binding-controls-2026-08-01'); + expect(beta).not.toContain('fine-grained-tool-streaming-2025-05-14'); + }, + ); + + it.each(SONNET_55_IDS)( + 'maps thinking off to between_tools without display, binding or budget for %s', + (model) => { + const result = getLLMConfig('test-key', { + modelOptions: { + model, + thinking: false, + thinkingBudget: 5000, + thinkingDisplay: ThinkingDisplay.summarized, + effort: AnthropicEffort.high, + }, + }); + + expect(result.llmConfig.thinking).toEqual({ type: 'between_tools' }); + expect(result.llmConfig.outputConfig).toEqual({ effort: AnthropicEffort.high }); + }, + ); + + it.each([AnthropicEffort.xhigh, AnthropicEffort.max])( + 'clamps effort %s to high under between_tools', + (effort) => { + const { llmConfig } = getLLMConfig('test-key', { + modelOptions: { model: 'claude-sonnet-5-5', thinking: false, effort }, + }); + const Anthropic = getChatModelClass(Providers.ANTHROPIC); + const payload = new Anthropic(llmConfig).invocationParams(); + + expect(payload.thinking).toEqual({ type: 'between_tools' }); + expect(payload.output_config).toEqual({ effort: AnthropicEffort.high }); + expect(payload).not.toHaveProperty('temperature'); + }, + ); + + it('keeps low and medium effort under between_tools', () => { + for (const effort of [AnthropicEffort.low, AnthropicEffort.medium]) { + const { llmConfig } = getLLMConfig('test-key', { + modelOptions: { model: 'claude-sonnet-5-5', thinking: false, effort }, + }); + expect(llmConfig.outputConfig).toEqual({ effort }); + } + }); + + it('treats a persisted between_tools config as thinking off', () => { + const result = getLLMConfig('test-key', { + modelOptions: { + model: 'claude-sonnet-5-5', + thinking: { type: 'between_tools' } as unknown as boolean, + }, + }); + + expect(result.llmConfig.thinking).toEqual({ type: 'between_tools' }); + }); + + it('never sends disabled thinking for Sonnet 5.5', () => { + const result = getLLMConfig('test-key', { + modelOptions: { + model: 'claude-sonnet-5-5', + thinking: { type: 'disabled' } as unknown as boolean, + }, + }); + + expect(result.llmConfig.thinking).toEqual({ type: 'between_tools' }); + }); + + it('requests display updates with its beta header', () => { + const result = getLLMConfig('test-key', { + modelOptions: { + model: 'claude-sonnet-5-5', + thinking: true, + thinkingDisplay: ThinkingDisplay.updates, + }, + }); + + expect(result.llmConfig.thinking).toMatchObject({ + type: 'adaptive', + display: 'updates', + }); + const beta = (result.llmConfig.clientOptions?.defaultHeaders as Record)[ + 'anthropic-beta' + ]; + expect(beta.split(',')).toEqual( + expect.arrayContaining([ + 'thinking-binding-controls-2026-08-01', + 'thinking-display-updates-2026-08-18', + ]), + ); + }); + + it('demotes display updates to summarized when client options are dropped', () => { + const result = getLLMConfig('test-key', { + modelOptions: { + model: 'claude-sonnet-5-5', + thinking: true, + thinkingDisplay: ThinkingDisplay.updates, + }, + dropParams: ['clientOptions'], + }); + + expect(result.llmConfig).not.toHaveProperty('clientOptions'); + expect(result.llmConfig.thinking).toEqual({ type: 'adaptive', display: 'summarized' }); + }); + + it('leaves Sonnet 5 on explicit disabled thinking', () => { + const result = getLLMConfig('test-key', { + modelOptions: { model: 'claude-sonnet-5', thinking: false }, + }); + + expect(result.llmConfig.thinking).toEqual({ type: 'disabled' }); + }); + }); + it('should omit sampling parameters for Opus 5', () => { const result = getLLMConfig('test-key', { modelOptions: { @@ -2000,12 +2154,16 @@ describe('getLLMConfig', () => { const headers = result.llmConfig.clientOptions?.defaultHeaders; expect(headers).toBeDefined(); const betaHeader = (headers as Record)['anthropic-beta']; - expect(betaHeader).toContain(FINE_GRAINED_TOOL_STREAMING_BETA); + /** Sonnet 5.5+ (here `claude-sonnet-6`) swaps the streaming beta for the binding beta. */ + const baseBeta = bindsThinkingBlocks(model) + ? THINKING_BINDING_BETA + : FINE_GRAINED_TOOL_STREAMING_BETA; + expect(betaHeader).toContain(baseBeta); if (shouldHaveHeaders) { - expect(betaHeader).not.toBe(FINE_GRAINED_TOOL_STREAMING_BETA); + expect(betaHeader).not.toBe(baseBeta); } else { - expect(betaHeader).toBe(FINE_GRAINED_TOOL_STREAMING_BETA); + expect(betaHeader).toBe(baseBeta); } if (shouldHavePromptCache) { diff --git a/packages/api/src/endpoints/anthropic/llm.ts b/packages/api/src/endpoints/anthropic/llm.ts index 05f09b238fa..888c8d75016 100644 --- a/packages/api/src/endpoints/anthropic/llm.ts +++ b/packages/api/src/endpoints/anthropic/llm.ts @@ -2,11 +2,13 @@ import { Agent } from 'undici'; import { logger } from '@librechat/data-schemas'; import { AnthropicClientOptions } from '@librechat/agents'; import { - isOpus55Model, THINKING_BINDING_BETA, + THINKING_DISPLAY_UPDATES_BETA, + requestsThinkingDisplayUpdates, clampOutputConfigEffort, omitsSamplingParameters, - isThinkingDisabled, + bindsThinkingBlocks, + isThinkingOffConfig, anthropicSettings, removeNullishValues, ThinkingDisplay, @@ -152,12 +154,13 @@ function getLLMConfig( /** * `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. + * Sonnet 5 "thinking off" config stored back into `model_parameters`) or + * `{ type: 'between_tools' }` (Sonnet 5.5) 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' + ? !isThinkingOffConfig(persistedThinking) : (persistedThinking ?? anthropicSettings.thinking.default); const systemOptions = { @@ -261,12 +264,12 @@ function getLLMConfig( } /** - * Opus 5 rejects `xhigh`/`max` effort while thinking is disabled (400). - * `configureReasoning` returns before setting effort on the disabled path, so - * the value applied just above is the one that would ship — clamp it to the - * highest level the model accepts in that combination. + * Opus 5 rejects `xhigh`/`max` effort while thinking is disabled, and Sonnet + * 5.5 does the same under `between_tools` (400). `configureReasoning` returns + * before setting effort on that path, so the value applied just above is the + * one that would ship — clamp it to the highest level the model accepts. */ - if (isThinkingDisabled(requestOptions.thinking)) { + if (isThinkingOffConfig(requestOptions.thinking)) { clampOutputConfigEffort(resolvedModel, requestOptions.invocationKwargs?.output_config); } @@ -381,8 +384,9 @@ function getLLMConfig( requestOptions.outputConfig = requestOptions.invocationKwargs.output_config; } - /** block_binding is invalid without its beta header. Honor an administrator - * dropping clientOptions without leaving a beta-only field in the body. */ + /** block_binding and display `updates` are invalid without their beta headers. + * Honor an administrator dropping clientOptions without leaving a beta-only + * field in the body. */ if ( shouldDropClientOptions && requestOptions.thinking && @@ -390,6 +394,9 @@ function getLLMConfig( ) { delete requestOptions.thinking.block_binding; } + if (shouldDropClientOptions && requestsThinkingDisplayUpdates(requestOptions.thinking)) { + (requestOptions.thinking as { display?: string }).display = ThinkingDisplay.summarized; + } if (shouldOmitSamplingParameters) { delete requestOptions.temperature; @@ -423,8 +430,14 @@ function getLLMConfig( } requestOptions.clientOptions.defaultHeaders = appendAnthropicBetaHeader( requestOptions.clientOptions.defaultHeaders as Record | undefined, - isOpus55Model(resolvedModel) ? THINKING_BINDING_BETA : FINE_GRAINED_TOOL_STREAMING_BETA, + bindsThinkingBlocks(resolvedModel) ? THINKING_BINDING_BETA : FINE_GRAINED_TOOL_STREAMING_BETA, ); + if (requestsThinkingDisplayUpdates(requestOptions.thinking)) { + requestOptions.clientOptions.defaultHeaders = appendAnthropicBetaHeader( + requestOptions.clientOptions.defaultHeaders as Record | undefined, + THINKING_DISPLAY_UPDATES_BETA, + ); + } } /** diff --git a/packages/api/src/utils/tokens.spec.ts b/packages/api/src/utils/tokens.spec.ts index d7b2ca717ba..b8f3ae3b936 100644 --- a/packages/api/src/utils/tokens.spec.ts +++ b/packages/api/src/utils/tokens.spec.ts @@ -297,6 +297,20 @@ describe('Opus 5.5 token limits', () => { }); }); +describe('Sonnet 5.5 token limits', () => { + it.each([ + 'claude-sonnet-5-5', + 'claude-sonnet-5.5', + 'anthropic/claude-sonnet-5-5', + 'global.anthropic.claude-sonnet-5-5', + ])('resolves %s to the modern Claude profile', (model) => { + expect(getModelMaxTokens(model)).toBe(1000000); + expect(getModelMaxOutputTokens(model)).toBe(128000); + expect(getModelMaxTokens(model, EModelEndpoint.anthropic)).toBe(1000000); + expect(getModelMaxOutputTokens(model, EModelEndpoint.anthropic)).toBe(128000); + }); +}); + describe.each(['gpt-6-sol', 'gpt-6-luna'])('%s token limits', (model) => { it('resolves exact, snapshot, and provider-prefixed IDs', () => { for (const name of [model, `${model}-2026-09-22`, `openai/${model}`]) { diff --git a/packages/api/src/utils/tokens.ts b/packages/api/src/utils/tokens.ts index 07ce2637d94..6c9d4c506f4 100644 --- a/packages/api/src/utils/tokens.ts +++ b/packages/api/src/utils/tokens.ts @@ -1,5 +1,5 @@ import z from 'zod'; -import { EModelEndpoint, isOpus55Model, supportsContext1m } from 'librechat-data-provider'; +import { EModelEndpoint, supportsContext1m, supportsOutput128k } from 'librechat-data-provider'; import type { EndpointTokenConfig, TokenConfig } from '~/types'; /** @@ -181,6 +181,8 @@ const anthropicModels = { 'claude-sonnet-4-9': 1000000, 'claude-sonnet-4.9': 1000000, 'claude-sonnet-5': 1000000, + 'claude-sonnet-5-5': 1000000, + 'claude-sonnet-5.5': 1000000, 'claude-opus-4-6': 1000000, 'claude-opus-4-7': 1000000, 'claude-opus-4-8': 1000000, @@ -194,7 +196,7 @@ const anthropicModels = { }; const ANTHROPIC_CONTEXT_1M = 1000000; -const ANTHROPIC_OPUS_55_OUTPUT = 128000; +const ANTHROPIC_OUTPUT_128K = 128000; 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])/; @@ -226,11 +228,11 @@ function getAnthropicSonnet46PlusOutput( return ANTHROPIC_SONNET_4_6_PLUS_OUTPUT; } -function getAnthropicOpus55Output(modelName: string, endpoint: EModelEndpoint): number | undefined { - if (!usesAnthropicContextMap(endpoint) || !isOpus55Model(modelName)) { +function getAnthropicOutput128k(modelName: string, endpoint: EModelEndpoint): number | undefined { + if (!usesAnthropicContextMap(endpoint) || !supportsOutput128k(modelName)) { return undefined; } - return ANTHROPIC_OPUS_55_OUTPUT; + return ANTHROPIC_OUTPUT_128K; } const deepseekModels = { @@ -742,9 +744,9 @@ export function getModelMaxOutputTokens( return overrideValue; } } - const opus55Value = getAnthropicOpus55Output(modelName, endpoint); - if (opus55Value != null) { - return opus55Value; + const output128kValue = getAnthropicOutput128k(modelName, endpoint); + if (output128kValue != null) { + return output128kValue; } const sonnet46PlusValue = getAnthropicSonnet46PlusOutput(modelName, endpoint); if (sonnet46PlusValue != null) { diff --git a/packages/data-provider/specs/bedrock.spec.ts b/packages/data-provider/specs/bedrock.spec.ts index 4b4bfc2971c..1ad72800360 100644 --- a/packages/data-provider/specs/bedrock.spec.ts +++ b/packages/data-provider/specs/bedrock.spec.ts @@ -12,11 +12,19 @@ import { requiresExplicitThinkingDisabled, capsEffortWhenThinkingDisabled, clampEffortForDisabledThinking, + requestsThinkingDisplayUpdates, + resolveThinkingOffConfig, resolveThinkingDisplay, + isThinkingOffConfig, + bindsThinkingBlocks, bedrockOutputParser, bedrockInputParser, bedrockInputSchema, + supportsPromptCache, supportsContext1m, + hasAlwaysOnThinking, + hasBetweenToolsThinkingFloor, + supportsOutput128k, BEDROCK_FINE_GRAINED_TOOL_STREAMING_BETA, } from '../src/bedrock'; @@ -406,7 +414,6 @@ describe('requiresExplicitThinkingDisabled', () => { 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 true for Opus 5+ (omitted thinking runs adaptive by default)', () => { @@ -414,7 +421,11 @@ describe('requiresExplicitThinkingDisabled', () => { expect(requiresExplicitThinkingDisabled('claude-opus-5-20260701')).toBe(true); expect(requiresExplicitThinkingDisabled('anthropic.claude-opus-5')).toBe(true); expect(requiresExplicitThinkingDisabled('us.anthropic.claude-opus-5')).toBe(true); - expect(requiresExplicitThinkingDisabled('claude-opus-9')).toBe(true); + }); + + test('later releases inherit the 5.5 contract, which rejects an explicit disabled', () => { + expect(requiresExplicitThinkingDisabled('claude-sonnet-9')).toBe(false); + expect(requiresExplicitThinkingDisabled('claude-opus-9')).toBe(false); }); test('returns false for pre-5 Sonnet, pre-5 Opus, and Mythos-class models', () => { @@ -429,10 +440,16 @@ describe('requiresExplicitThinkingDisabled', () => { }); describe('capsEffortWhenThinkingDisabled', () => { - test('returns true for Opus 5+', () => { + test('returns true for Opus 5 and Sonnet 5.5+', () => { expect(capsEffortWhenThinkingDisabled('claude-opus-5')).toBe(true); expect(capsEffortWhenThinkingDisabled('anthropic.claude-opus-5')).toBe(true); - expect(capsEffortWhenThinkingDisabled('claude-opus-9')).toBe(true); + expect(capsEffortWhenThinkingDisabled('claude-sonnet-5-5')).toBe(true); + expect(capsEffortWhenThinkingDisabled('claude-sonnet-9')).toBe(true); + }); + + test('never applies to always-on models, whose thinking cannot be turned off', () => { + expect(capsEffortWhenThinkingDisabled('claude-opus-5-5')).toBe(false); + expect(capsEffortWhenThinkingDisabled('claude-opus-9')).toBe(false); }); test('returns false for models that accept every effort with thinking off', () => { @@ -499,7 +516,163 @@ describe('resolveThinkingDisplay', () => { }); }); +describe('Sonnet 5.5 model gates', () => { + const ids = ['claude-sonnet-5-5', 'claude-sonnet-5.5', 'anthropic/claude-sonnet-5-5']; + + test.each(ids)('%s is detected across every family gate', (model) => { + expect(hasBetweenToolsThinkingFloor(model)).toBe(true); + expect(hasAlwaysOnThinking(model)).toBe(false); + expect(bindsThinkingBlocks(model)).toBe(true); + expect(supportsOutput128k(model)).toBe(true); + expect(supportsAdaptiveThinking(model)).toBe(true); + expect(omitsSamplingParameters(model)).toBe(true); + expect(supportsPromptCache(model)).toBe(true); + expect(supportsContext1m(model)).toBe(true); + expect(requiresExplicitThinkingDisabled(model)).toBe(false); + expect(capsEffortWhenThinkingDisabled(model)).toBe(true); + expect(resolveThinkingOffConfig(model)).toEqual({ type: 'between_tools' }); + expect(resolveThinkingDisplay(model)).toBe('summarized'); + }); + + test('is not confused with Sonnet 5, Opus 5.5 or date-suffixed ids', () => { + expect(hasBetweenToolsThinkingFloor('claude-sonnet-5')).toBe(false); + expect(hasBetweenToolsThinkingFloor('claude-sonnet-5-20260501')).toBe(false); + expect(hasBetweenToolsThinkingFloor('claude-opus-5-5')).toBe(false); + expect(hasBetweenToolsThinkingFloor('claude-sonnet-4-5')).toBe(false); + expect(resolveThinkingOffConfig('claude-sonnet-5')).toEqual({ type: 'disabled' }); + expect(resolveThinkingOffConfig('claude-opus-4-8')).toBeUndefined(); + }); + + test.each(['claude-sonnet-5-6', 'claude-sonnet-6', 'claude-6-sonnet', 'claude-sonnet-5.10'])( + 'later Sonnet release %s inherits the 5.5 contract instead of the Sonnet 5 path', + (model) => { + expect(hasBetweenToolsThinkingFloor(model)).toBe(true); + expect(bindsThinkingBlocks(model)).toBe(true); + expect(supportsOutput128k(model)).toBe(true); + expect(requiresExplicitThinkingDisabled(model)).toBe(false); + expect(resolveThinkingOffConfig(model)).toEqual({ type: 'between_tools' }); + }, + ); + + test('isThinkingOffConfig recognizes both floors', () => { + expect(isThinkingOffConfig({ type: 'disabled' })).toBe(true); + expect(isThinkingOffConfig({ type: 'between_tools' })).toBe(true); + expect(isThinkingOffConfig({ type: 'adaptive' })).toBe(false); + expect(isThinkingOffConfig(false)).toBe(false); + }); + + test('display updates passes through for adaptive models', () => { + expect(resolveThinkingDisplay('claude-sonnet-5-5', 'updates')).toBe('updates'); + expect(resolveThinkingDisplay('claude-opus-4-6', 'updates')).toBe('updates'); + expect(requestsThinkingDisplayUpdates({ type: 'adaptive', display: 'updates' })).toBe(true); + expect(requestsThinkingDisplayUpdates({ type: 'between_tools' })).toBe(false); + }); +}); + +describe('always-on thinking gates', () => { + test.each([ + 'claude-opus-5-5', + 'claude-opus-5.5', + 'claude-opus-5-6', + 'claude-opus-6', + 'claude-6-opus', + ])('%s is always-on, bound and uncapped', (model) => { + expect(hasAlwaysOnThinking(model)).toBe(true); + expect(hasBetweenToolsThinkingFloor(model)).toBe(false); + expect(bindsThinkingBlocks(model)).toBe(true); + expect(supportsOutput128k(model)).toBe(true); + expect(requiresExplicitThinkingDisabled(model)).toBe(false); + expect(capsEffortWhenThinkingDisabled(model)).toBe(false); + expect(resolveThinkingOffConfig(model)).toBeUndefined(); + }); + + test.each(['claude-fable-5-1', 'claude-mythos-5', 'anthropic/claude-fable-5-1'])( + 'Mythos-class %s shares the always-on contract', + (model) => { + expect(hasAlwaysOnThinking(model)).toBe(true); + expect(bindsThinkingBlocks(model)).toBe(true); + expect(supportsOutput128k(model)).toBe(true); + expect(requiresExplicitThinkingDisabled(model)).toBe(false); + expect(capsEffortWhenThinkingDisabled(model)).toBe(false); + expect(resolveThinkingOffConfig(model)).toBeUndefined(); + }, + ); + + test('Opus 5 and Opus 4.x keep their own thinking-off paths', () => { + expect(hasAlwaysOnThinking('claude-opus-5')).toBe(false); + expect(hasAlwaysOnThinking('claude-opus-5-20260301')).toBe(false); + expect(hasAlwaysOnThinking('claude-opus-4-8')).toBe(false); + expect(bindsThinkingBlocks('claude-opus-5')).toBe(false); + expect(supportsOutput128k('claude-opus-5')).toBe(false); + expect(requiresExplicitThinkingDisabled('claude-opus-5')).toBe(true); + expect(capsEffortWhenThinkingDisabled('claude-opus-5')).toBe(true); + }); +}); + describe('bedrockInputParser', () => { + test('maps Sonnet 5.5 thinking off to between_tools and caps effort', () => { + const result = bedrockInputParser.parse({ + model: 'global.anthropic.claude-sonnet-5-5', + thinking: false, + thinkingDisplay: 'summarized', + thinkingBudget: 4000, + effort: 'max', + }) as Record; + const additionalFields = result.additionalModelRequestFields as Record; + + expect(additionalFields.thinking).toEqual({ type: 'between_tools' }); + expect(additionalFields.output_config).toEqual({ effort: 'high' }); + expect(additionalFields).not.toHaveProperty('thinkingBudget'); + expect(additionalFields).not.toHaveProperty('thinkingDisplay'); + expect(additionalFields.anthropic_beta).toEqual(['thinking-binding-controls-2026-08-01']); + }); + + test('binds Sonnet 5.5 adaptive thinking and adds the display-updates beta on request', () => { + const result = bedrockInputParser.parse({ + model: 'global.anthropic.claude-sonnet-5-5', + thinking: true, + thinkingDisplay: 'updates', + }) as Record; + const additionalFields = result.additionalModelRequestFields as Record; + + expect(additionalFields.thinking).toEqual({ + type: 'adaptive', + display: 'updates', + block_binding: { prefix_mismatch_behavior: 'drop_block' }, + }); + expect(additionalFields.anthropic_beta).toEqual([ + 'thinking-binding-controls-2026-08-01', + 'thinking-display-updates-2026-08-18', + ]); + }); + + test('round-trips a persisted Sonnet 5.5 between_tools config as thinking off', () => { + const first = bedrockOutputParser( + bedrockInputParser.parse({ + model: 'global.anthropic.claude-sonnet-5-5', + thinking: false, + }), + ); + expect(first.additionalModelRequestFields).toMatchObject({ + thinking: { type: 'between_tools' }, + }); + expect(bedrockOutputParser(bedrockInputParser.parse(first))).toEqual(first); + + const persisted = bedrockInputParser.parse({ + model: 'global.anthropic.claude-sonnet-5-5', + additionalModelRequestFields: { thinking: { type: 'between_tools' } }, + }) as Record; + expect(persisted.additionalModelRequestFields).toMatchObject({ + thinking: { type: 'between_tools' }, + }); + expect( + bedrockInputSchema.parse({ + model: 'global.anthropic.claude-sonnet-5-5', + additionalModelRequestFields: { thinking: { type: 'between_tools' } }, + }), + ).toMatchObject({ thinking: false }); + }); + test('keeps Opus 5.5 adaptive thinking enabled and binds prior blocks', () => { const result = bedrockInputParser.parse({ model: 'anthropic.claude-opus-5-5', @@ -1091,10 +1264,14 @@ describe('bedrockInputParser', () => { expect(additionalFields.top_p).toBeUndefined(); expect(additionalFields.top_k).toBeUndefined(); expect(additionalFields.custom_flag).toBe(true); - expect(additionalFields.thinking).toEqual({ type: 'adaptive', display: 'summarized' }); + expect(additionalFields.thinking).toEqual({ + type: 'adaptive', + display: 'summarized', + block_binding: { prefix_mismatch_behavior: 'drop_block' }, + }); expect(additionalFields.output_config).toEqual({ effort: 'high' }); - /** Mythos-class models do not receive the legacy output-128k / fine-grained-tool-streaming betas. */ - expect(additionalFields.anthropic_beta).toBeUndefined(); + /** Mythos-class models bind thinking blocks and skip the legacy output-128k / fine-grained-tool-streaming betas. */ + expect(additionalFields.anthropic_beta).toEqual(['thinking-binding-controls-2026-08-01']); }); test('should set thinking.display to "summarized" so Opus 4.7 returns reasoning blocks', () => { diff --git a/packages/data-provider/src/bedrock.ts b/packages/data-provider/src/bedrock.ts index ef88313ac20..2a41c54c216 100644 --- a/packages/data-provider/src/bedrock.ts +++ b/packages/data-provider/src/bedrock.ts @@ -2,6 +2,7 @@ import { z } from 'zod'; import * as s from './schemas'; export const THINKING_BINDING_BETA = 'thinking-binding-controls-2026-08-01'; +export const THINKING_DISPLAY_UPDATES_BETA = 'thinking-display-updates-2026-08-18'; const DEFAULT_THINKING_BUDGET = 2000; const BEDROCK_CLAUDE_SONNET_4_6_MAX_OUTPUT = 64000; @@ -14,6 +15,7 @@ const GENERATED_BEDROCK_BETAS = new Set([ BEDROCK_OUTPUT_128K_BETA, BEDROCK_FINE_GRAINED_TOOL_STREAMING_BETA, THINKING_BINDING_BETA, + THINKING_DISPLAY_UPDATES_BETA, ]); const bedrockReasoningConfigValues = new Set(Object.values(s.BedrockReasoningConfig)); @@ -23,9 +25,10 @@ type ThinkingConfig = | { type: 'adaptive'; display?: s.ThinkingDisplayWireValue; - block_binding?: typeof OPUS_55_BLOCK_BINDING; + block_binding?: typeof THINKING_BLOCK_BINDING; } - | { type: 'disabled' }; + | { type: 'disabled' } + | { type: 'between_tools' }; /** * Resolves the final `thinking.display` value for an adaptive-thinking request. @@ -65,6 +68,9 @@ export function resolveThinkingDisplay( if (explicit === s.ThinkingDisplay.omitted) { return s.ThinkingDisplay.omitted; } + if (explicit === s.ThinkingDisplay.updates) { + return s.ThinkingDisplay.updates; + } if (omitsThinkingByDefault(model)) { return s.ThinkingDisplay.summarized; } @@ -81,8 +87,17 @@ type AnthropicInput = BedrockConverseInput & { AnthropicReasoning; }; +type ClaudeVersion = { major: number; minor: number }; + +function isAtLeast(version: ClaudeVersion | null, major: number, minor: number): boolean { + if (version == null) { + return false; + } + return version.major > major || (version.major === major && version.minor >= minor); +} + /** Extracts opus major/minor version from both naming formats */ -function parseOpusVersion(model: string): { major: number; minor: number } | null { +function parseOpusVersion(model: string): ClaudeVersion | null { const nameFirst = model.match(/claude-opus[-.]?(\d+)(?:[-.](\d{1,2})(?!\d))?/); if (nameFirst) { return { @@ -100,20 +115,55 @@ function parseOpusVersion(model: string): { major: number; minor: number } | nul return null; } -/** Opus 5.5 has an always-on, conversation-bound thinking contract unlike Opus 5. */ -export function isOpus55Model(model: string): boolean { - const opus = parseOpusVersion(model); - return opus?.major === 5 && opus.minor === 5; +/** + * Opus 5.5 and later, and the Mythos-class line (Fable/Mythos), run thinking + * always on and bound to the conversation prefix: both `disabled` and + * `between_tools` are rejected (400), so the toggle is hidden and effort is + * the only lever. Unlike Opus 5, which needs an explicit `disabled`. + */ +export function hasAlwaysOnThinking(model: string): boolean { + return isAtLeast(parseOpusVersion(model), 5, 5) || s.isMythosClassModel(model); +} + +/** + * Sonnet 5.5 and later share the conversation-bound thinking contract but keep + * a lowest setting, `between_tools`, in place of `disabled`. + */ +export function hasBetweenToolsThinkingFloor(model: string): boolean { + return isAtLeast(parseSonnetVersion(model), 5, 5); } /** LibreChat permits edits and compaction of earlier turns; drop only invalidated blocks. */ -export const OPUS_55_BLOCK_BINDING = { +export const THINKING_BLOCK_BINDING = { prefix_mismatch_behavior: 'drop_block', } as const; +/** Models whose thinking blocks are bound to the conversation prefix (Opus 5.5+, Sonnet 5.5+, Fable/Mythos). */ +export function bindsThinkingBlocks(model: string): boolean { + return hasAlwaysOnThinking(model) || hasBetweenToolsThinkingFloor(model); +} + +export const BETWEEN_TOOLS_THINKING = { type: 'between_tools' } as const; + +/** + * Resolves the wire config for a user's "thinking off" choice. Sonnet 5.5+ + * rejects `disabled` and offers `between_tools` as its floor; Sonnet 5 and + * Opus 5 need an explicit `disabled`; the rest turn thinking off by omission + * (`undefined`). Always-on models never reach here because their toggle is hidden. + */ +export function resolveThinkingOffConfig(model: string): ThinkingConfig | undefined { + if (hasBetweenToolsThinkingFloor(model)) { + return { ...BETWEEN_TOOLS_THINKING }; + } + if (requiresExplicitThinkingDisabled(model)) { + return { type: 'disabled' }; + } + return undefined; +} + /** Extracts sonnet major/minor version from both naming formats. * Uses bounded minor capture to avoid matching date suffixes (e.g., -20250514). */ -function parseSonnetVersion(model: string): { major: number; minor: number } | null { +function parseSonnetVersion(model: string): ClaudeVersion | null { const nameFirst = model.match(/claude-sonnet[-.]?(\d+)(?:[-.](\d{1,2})(?!\d))?/); if (nameFirst) { return { @@ -139,18 +189,11 @@ function parseSonnetVersion(model: string): { major: number; minor: number } | n /** Checks if a model supports adaptive thinking (Opus 4.6+, Sonnet 4.6+, Fable/Mythos) */ 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.major > 4 || (sonnet.major === 4 && sonnet.minor >= 6))) { - return true; - } - if (s.isMythosClassModel(model)) { - return true; - } - return false; + return ( + isAtLeast(parseOpusVersion(model), 4, 6) || + isAtLeast(parseSonnetVersion(model), 4, 6) || + s.isMythosClassModel(model) + ); } /** @@ -164,33 +207,19 @@ export function supportsAdaptiveThinking(model: string): boolean { * See https://platform.claude.com/docs/en/about-claude/models/whats-new-claude-4-7#thinking-content-omitted-by-default */ export function omitsThinkingByDefault(model: string): boolean { - const opus = parseOpusVersion(model); - 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; + return ( + isAtLeast(parseOpusVersion(model), 4, 7) || + isAtLeast(parseSonnetVersion(model), 5, 0) || + s.isMythosClassModel(model) + ); } export function omitsSamplingParameters(model: string): boolean { - const opus = parseOpusVersion(model); - 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; + return ( + isAtLeast(parseOpusVersion(model), 4, 7) || + isAtLeast(parseSonnetVersion(model), 5, 0) || + s.isMythosClassModel(model) + ); } /** @@ -200,21 +229,17 @@ export function omitsSamplingParameters(model: string): boolean { * Sonnet 5 and Opus 5 treat 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/4.8 run without thinking when the field - * is omitted, and Fable/Mythos reject an explicit disabled config (400, - * thinking always on), so both are excluded. + * is omitted, and Fable/Mythos, Opus 5.5+ and Sonnet 5.5+ reject an explicit + * disabled config (400), so those are excluded — Sonnet 5.5+ falls back to + * `between_tools` via `resolveThinkingOffConfig`. * * See https://platform.claude.com/docs/en/about-claude/models/migration-guide#migrating-to-claude-sonnet-5 */ export function requiresExplicitThinkingDisabled(model: string): boolean { - if (isOpus55Model(model)) { + if (hasAlwaysOnThinking(model) || hasBetweenToolsThinkingFloor(model)) { return false; } - const sonnet = parseSonnetVersion(model); - if (sonnet != null && sonnet.major >= 5) { - return true; - } - const opus = parseOpusVersion(model); - return opus != null && opus.major >= 5; + return isAtLeast(parseSonnetVersion(model), 5, 0) || isAtLeast(parseOpusVersion(model), 5, 0); } /** Effort levels Opus 5 rejects while thinking is explicitly disabled. */ @@ -228,16 +253,19 @@ const EFFORTS_REJECTED_WHEN_THINKING_DISABLED = new Set([ * * Opus 5 rejects `xhigh`/`max` in that combination with a 400: "output_config * .effort 'xhigh' is not supported when thinking is disabled on this model. Use - * effort 'high' or below, or enable thinking." Opus 4.7/4.8, Sonnet 5, and - * Sonnet 4.6 accept every effort level they otherwise support with thinking - * off, so the cap is Opus 5+ only. + * effort 'high' or below, or enable thinking." Sonnet 5.5+ returns the same + * error for `between_tools`. Opus 4.7/4.8, Sonnet 5, and Sonnet 4.6 accept + * every effort level they otherwise support with thinking off. Always-on + * models cannot turn thinking off, so the cap never applies to them. */ export function capsEffortWhenThinkingDisabled(model: string): boolean { - if (isOpus55Model(model)) { + if (hasAlwaysOnThinking(model)) { return false; } - const opus = parseOpusVersion(model); - return opus != null && opus.major >= 5; + if (hasBetweenToolsThinkingFloor(model)) { + return true; + } + return isAtLeast(parseOpusVersion(model), 5, 0); } /** @@ -283,20 +311,42 @@ export function isThinkingDisabled(thinking: unknown): boolean { return thinking.type === 'disabled'; } -/** 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); - if (sonnet != null && (sonnet.major > 4 || (sonnet.major === 4 && sonnet.minor >= 6))) { - return true; - } - const opus = parseOpusVersion(model); - if (opus && (opus.major > 4 || (opus.major === 4 && opus.minor >= 6))) { - return true; +/** Whether a resolved thinking config asks for mid-thinking display updates (beta-gated). */ +export function requestsThinkingDisplayUpdates(thinking: unknown): boolean { + if (typeof thinking !== 'object' || thinking === null || !('display' in thinking)) { + return false; } - if (s.isMythosClassModel(model)) { - return true; + return thinking.display === s.ThinkingDisplay.updates; +} + +/** + * Whether a resolved thinking config is the model's floor — `disabled`, or + * `between_tools` on Sonnet 5.5 — i.e. the wire form of the user's "thinking + * off" choice. Both share the effort cap and the no-display/no-binding rules. + */ +export function isThinkingOffConfig(thinking: unknown): boolean { + if (typeof thinking !== 'object' || thinking === null || !('type' in thinking)) { + return false; } - return false; + return thinking.type === 'disabled' || thinking.type === 'between_tools'; +} + +/** 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 { + return ( + isAtLeast(parseSonnetVersion(model), 4, 6) || + isAtLeast(parseOpusVersion(model), 4, 6) || + s.isMythosClassModel(model) + ); +} + +/** Opus 5.5+, Sonnet 5.5+ and Fable/Mythos emit up to 128k output tokens. */ +export function supportsOutput128k(model: string): boolean { + return ( + isAtLeast(parseOpusVersion(model), 5, 5) || + isAtLeast(parseSonnetVersion(model), 5, 5) || + s.isMythosClassModel(model) + ); } /** @@ -345,7 +395,7 @@ function isBedrockClaudeModel(model: string): boolean { * @returns Array of beta header strings, or empty array if not applicable */ function getBedrockAnthropicBetaHeaders(model: string): string[] { - if (isOpus55Model(model)) { + if (bindsThinkingBlocks(model)) { return [THINKING_BINDING_BETA]; } const betaHeaders: string[] = []; @@ -442,11 +492,7 @@ export const bedrockInputSchema = s.tConversationSchema if ((obj as AnthropicInput).additionalModelRequestFields?.thinking != null) { const _obj = obj as AnthropicInput; const thinking = _obj.additionalModelRequestFields.thinking; - const isDisabled = - typeof thinking === 'object' && - thinking !== null && - (thinking as { type?: string }).type === 'disabled'; - obj.thinking = isDisabled ? false : !!thinking; + obj.thinking = isThinkingOffConfig(thinking) ? false : !!thinking; obj.thinkingBudget = typeof thinking === 'object' && 'budget_tokens' in thinking ? thinking.budget_tokens @@ -455,7 +501,8 @@ export const bedrockInputSchema = s.tConversationSchema const persistedDisplay = extractPersistedDisplay({ thinking }); if ( persistedDisplay === s.ThinkingDisplay.summarized || - persistedDisplay === s.ThinkingDisplay.omitted + persistedDisplay === s.ThinkingDisplay.omitted || + persistedDisplay === s.ThinkingDisplay.updates ) { obj.thinkingDisplay = persistedDisplay as s.ThinkingDisplay; } @@ -549,12 +596,7 @@ export const bedrockInputParser = s.tConversationSchema const persistedThinking = ( typedData.additionalModelRequestFields as { thinking?: unknown } | undefined )?.thinking; - if ( - additionalFields.thinking === undefined && - typeof persistedThinking === 'object' && - persistedThinking !== null && - (persistedThinking as { type?: string }).type === 'disabled' - ) { + if (additionalFields.thinking === undefined && isThinkingOffConfig(persistedThinking)) { additionalFields.thinking = false; } @@ -575,7 +617,7 @@ export const bedrockInputParser = s.tConversationSchema | Record | undefined; const thinkingDisabled = - additionalFields.thinking === false && !isOpus55Model(typedData.model as string); + additionalFields.thinking === false && !hasAlwaysOnThinking(typedData.model as string); const effort = additionalFields.effort; if (typeof effort === 'string' && effort !== '') { additionalFields.output_config = { effort }; @@ -602,8 +644,9 @@ export const bedrockInputParser = s.tConversationSchema if (thinkingDisabled) { delete additionalFields.thinkingBudget; delete additionalFields.thinkingDisplay; - if (requiresExplicitThinkingDisabled(typedData.model as string)) { - additionalFields.thinking = { type: 'disabled' }; + const thinkingOffConfig = resolveThinkingOffConfig(typedData.model as string); + if (thinkingOffConfig) { + additionalFields.thinking = thinkingOffConfig; } else { delete additionalFields.thinking; /** Disable-by-omission models (Opus 4.7+): drop the persisted @@ -628,8 +671,8 @@ export const bedrockInputParser = s.tConversationSchema | undefined; const persistedDisplay = extractPersistedDisplay(typedData.additionalModelRequestFields); const thinkingConfig: ThinkingConfig = { type: 'adaptive' }; - if (isOpus55Model(typedData.model as string)) { - thinkingConfig.block_binding = { ...OPUS_55_BLOCK_BINDING }; + if (bindsThinkingBlocks(typedData.model as string)) { + thinkingConfig.block_binding = { ...THINKING_BLOCK_BINDING }; } const display = resolveThinkingDisplay( typedData.model as string, @@ -673,6 +716,9 @@ export const bedrockInputParser = s.tConversationSchema if (isBedrockClaudeModel(typedData.model as string)) { const betaHeaders = getBedrockAnthropicBetaHeaders(typedData.model as string); + if (requestsThinkingDisplayUpdates(additionalFields.thinking)) { + betaHeaders.push(THINKING_DISPLAY_UPDATES_BETA); + } if (betaHeaders.length > 0) { const existingBetaHeaders = ( typedData.additionalModelRequestFields as Record | undefined diff --git a/packages/data-provider/src/config.ts b/packages/data-provider/src/config.ts index 21288b3dda4..bc84677a2fd 100644 --- a/packages/data-provider/src/config.ts +++ b/packages/data-provider/src/config.ts @@ -3349,6 +3349,7 @@ const sharedAnthropicModels = [ 'claude-fable-5', 'claude-opus-5-5', 'claude-opus-5', + 'claude-sonnet-5-5', 'claude-opus-4-8', 'claude-opus-4-7', 'claude-sonnet-5', @@ -3386,6 +3387,7 @@ export const bedrockModels = [ 'global.anthropic.claude-fable-5', 'global.anthropic.claude-opus-5-5', 'global.anthropic.claude-opus-5', + 'global.anthropic.claude-sonnet-5-5', 'global.anthropic.claude-opus-4-8', 'global.anthropic.claude-opus-4-7', 'global.anthropic.claude-sonnet-5', diff --git a/packages/data-provider/src/parameterSettings.spec.ts b/packages/data-provider/src/parameterSettings.spec.ts index 35c6f01e0be..f6ac41507a1 100644 --- a/packages/data-provider/src/parameterSettings.spec.ts +++ b/packages/data-provider/src/parameterSettings.spec.ts @@ -79,6 +79,25 @@ describe('applyModelAwareDefaults', () => { expect(hasSetting(result, 'effort')).toBe(true); }); + it.each(['claude-sonnet-5-5', 'claude-sonnet-5.5'])( + 'keeps the thinking toggle but hides budget and sampling controls for %s', + (model) => { + const result = applyModelAwareDefaults(anthropicParams, EModelEndpoint.anthropic, model); + + expect(hasSetting(result, 'thinking')).toBe(true); + expect(result.find((setting) => setting.key === 'thinking')?.description).toBe( + 'com_endpoint_anthropic_thinking_between_tools', + ); + expect(hasSetting(result, 'thinkingBudget')).toBe(false); + expect(hasSetting(result, 'temperature')).toBe(false); + expect(hasSetting(result, 'topP')).toBe(false); + expect(hasSetting(result, 'topK')).toBe(false); + expect(hasSetting(result, 'effort')).toBe(true); + expect(hasSetting(result, 'thinkingDisplay')).toBe(true); + expect(hasSetting(result, 'promptCache')).toBe(true); + }, + ); + it('returns settings unchanged when no model is provided', () => { expect(applyModelAwareDefaults(googleParams, EModelEndpoint.google, '')).toBe(googleParams); }); diff --git a/packages/data-provider/src/parameterSettings.ts b/packages/data-provider/src/parameterSettings.ts index 155760b8e3f..a30f5109c35 100644 --- a/packages/data-provider/src/parameterSettings.ts +++ b/packages/data-provider/src/parameterSettings.ts @@ -17,9 +17,9 @@ import { BedrockProviders, anthropicSettings, } from './types'; +import { hasAlwaysOnThinking, hasBetweenToolsThinkingFloor, supportsPromptCache } from './bedrock'; import { SettingDefinition, SettingsConfiguration } from './generate'; import { resolveEffectiveUseResponsesApi } from './file-config'; -import { isOpus55Model, supportsPromptCache } from './bedrock'; // Base definitions const baseDefinitions: Record = { @@ -552,6 +552,7 @@ const anthropic: Record = { [ThinkingDisplay.auto]: 'com_ui_auto', [ThinkingDisplay.summarized]: 'com_ui_summarized', [ThinkingDisplay.omitted]: 'com_ui_omitted', + [ThinkingDisplay.updates]: 'com_ui_updates', }, optionType: 'model', columnSpan: 4, @@ -1360,12 +1361,24 @@ export function applyModelAwareDefaults( return setting; }); } - if (isOpus55Model(model)) { + if (hasAlwaysOnThinking(model)) { return settings.filter( (setting) => !['thinking', 'thinkingBudget', 'temperature', 'topP', 'topK'].includes(setting.key), ); } + /** Sonnet 5.5+ keeps the toggle: "off" maps to its `between_tools` floor. */ + if (hasBetweenToolsThinkingFloor(model)) { + return settings + .map((setting) => + setting.key === 'thinking' + ? { ...setting, description: 'com_endpoint_anthropic_thinking_between_tools' } + : setting, + ) + .filter( + (setting) => !['thinkingBudget', 'temperature', 'topP', 'topK'].includes(setting.key), + ); + } const modelAwareSettings = endpoint === EModelEndpoint.google ? settings.map((setting) => { diff --git a/packages/data-provider/src/schemas.ts b/packages/data-provider/src/schemas.ts index a2709edaedf..ea8b1c00152 100644 --- a/packages/data-provider/src/schemas.ts +++ b/packages/data-provider/src/schemas.ts @@ -271,6 +271,9 @@ export enum AnthropicEffort { * omit by default (Opus 4.7+), leave the field off for older models. * - `'summarized'` - always request a post-hoc summary of the reasoning. * - `'omitted'` - always suppress reasoning content. Slightly lower latency. + * - `'updates'` - return only the progress notes Claude writes between tool + * calls (mid-thinking display updates); reasoning blocks stay empty. Needs + * the `thinking-display-updates-2026-08-18` beta header. * * See https://platform.claude.com/docs/en/about-claude/models/whats-new-claude-4-7#thinking-content-omitted-by-default */ @@ -278,6 +281,7 @@ export enum ThinkingDisplay { auto = 'auto', summarized = 'summarized', omitted = 'omitted', + updates = 'updates', } /** @@ -769,7 +773,12 @@ export const anthropicSettings = { }, thinkingDisplay: { default: ThinkingDisplay.auto, - options: [ThinkingDisplay.auto, ThinkingDisplay.summarized, ThinkingDisplay.omitted], + options: [ + ThinkingDisplay.auto, + ThinkingDisplay.summarized, + ThinkingDisplay.omitted, + ThinkingDisplay.updates, + ], }, web_search: { default: false as const, diff --git a/packages/data-schemas/src/app/vertex.ts b/packages/data-schemas/src/app/vertex.ts index 81f5821fba3..2d300f7ff51 100644 --- a/packages/data-schemas/src/app/vertex.ts +++ b/packages/data-schemas/src/app/vertex.ts @@ -20,6 +20,7 @@ import logger from '~/config/winston'; export const defaultVertexModels: string[] = [ 'claude-opus-5', 'claude-opus-5-5', + 'claude-sonnet-5-5', 'claude-opus-4-8', 'claude-opus-4-7', 'claude-opus-4-6', diff --git a/packages/data-schemas/src/methods/tx.spec.ts b/packages/data-schemas/src/methods/tx.spec.ts index 23a4fb9cce0..7afb7abdc3b 100644 --- a/packages/data-schemas/src/methods/tx.spec.ts +++ b/packages/data-schemas/src/methods/tx.spec.ts @@ -3220,6 +3220,30 @@ describe('Opus 5.5 pricing', () => { }); }); +describe('Sonnet 5.5 pricing', () => { + it.each([ + 'claude-sonnet-5-5', + 'claude-sonnet-5.5', + 'anthropic/claude-sonnet-5-5', + 'global.anthropic.claude-sonnet-5-5', + ])('prices %s at the Sonnet 5 rate, without a long-context surcharge', (model) => { + for (const inputTokenCount of [1000, 200000, 1000000]) { + expect(getMultiplier({ model, tokenType: 'prompt', inputTokenCount })).toBe( + tokenValues['claude-sonnet-5'].prompt, + ); + expect(getMultiplier({ model, tokenType: 'completion', inputTokenCount })).toBe( + tokenValues['claude-sonnet-5'].completion, + ); + expect(getCacheMultiplier({ model, cacheType: 'write', inputTokenCount })).toBe( + cacheTokenValues['claude-sonnet-5'].write, + ); + expect(getCacheMultiplier({ model, cacheType: 'read', inputTokenCount })).toBe( + cacheTokenValues['claude-sonnet-5'].read, + ); + } + }); +}); + describe.each([ ['gpt-6-sol', 2, 0.2, 2.5, 10], ['gpt-6-luna', 0.1, 0.01, 0.125, 0.5], diff --git a/packages/data-schemas/src/methods/tx.ts b/packages/data-schemas/src/methods/tx.ts index 74ff8f8e6a2..f1325c0cf0f 100644 --- a/packages/data-schemas/src/methods/tx.ts +++ b/packages/data-schemas/src/methods/tx.ts @@ -198,6 +198,8 @@ export const tokenValues: Record '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 }, + 'claude-sonnet-5-5': { prompt: 2, completion: 10 }, + 'claude-sonnet-5.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 }, @@ -369,6 +371,8 @@ export const cacheTokenValues: Record = '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-sonnet-5-5': { write: 2.5, read: 0.2 }, + 'claude-sonnet-5.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 },