From 034b672d0cee20a7d86fe4cc34f4f6ad09a8891d Mon Sep 17 00:00:00 2001 From: Danny Avila Date: Thu, 16 Apr 2026 21:56:52 -0400 Subject: [PATCH] =?UTF-8?q?=F0=9F=AB=A7=20feat:=20Claude=20Opus=204.7=20Re?= =?UTF-8?q?asoning=20Visibility=20(#12701)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 🫧 fix: Restore Claude Opus 4.7 Reasoning Visibility Claude Opus 4.7 omits `thinking` content from Messages API responses by default — empty thinking blocks still stream, but the `thinking` field is blank unless the caller passes `display: "summarized"` in the adaptive thinking config. This silenced the LibreChat "Thoughts" UI for Anthropic (and Anthropic-on-Bedrock) adaptive models. - Extend `ThinkingConfigAdaptive` in `packages/api/src/types/anthropic.ts` with an optional `display: 'summarized' | 'omitted'` field - Emit `{ type: 'adaptive', display: 'summarized' }` from `configureReasoning` in `packages/api/src/endpoints/anthropic/helpers.ts` - Emit `{ type: 'adaptive', display: 'summarized' }` from `bedrockInputParser` in `packages/data-provider/src/bedrock.ts` and update the local `ThinkingConfig` union - Update existing adaptive-thinking assertions to include the new field - Add dedicated tests asserting `display: 'summarized'` flows through both the Anthropic endpoint and the Bedrock parser See https://platform.claude.com/docs/en/about-claude/models/whats-new-claude-4-7#thinking-content-omitted-by-default * refactor: Gate `display: summarized` on Opus 4.7+ Narrow the reasoning-visibility opt-in to the models that actually omit thinking content by default, instead of applying it to every adaptive model. Pre-Opus-4.7 adaptive models (Opus 4.6, Sonnet 4.6) already return summaries, so sending the field is unnecessary noise. - Add `omitsThinkingByDefault(model)` in `packages/data-provider/src/bedrock.ts` that returns true only for Opus 4.7+ (including future majors like Opus 5+) - Bedrock parser now only attaches `display: 'summarized'` when the helper matches, keeping the adaptive object unchanged for older models - Anthropic endpoint `configureReasoning` uses the same helper so its emit path matches the Bedrock one - Tests: replace the blanket `display: 'summarized'` assertions with model-specific ones (Opus 4.7 gets it, Opus 4.6 / Sonnet 4.6 do not), add a dedicated `omitsThinkingByDefault` suite covering naming variants and future versions * feat: Configurable Thought Visibility for Anthropic Adaptive Models Expose the Anthropic `thinking.display` API field as a user-facing parameter so users can override the `auto` default (which stays as the Opus-4.7+ opt-in added earlier in this PR). Also fixes the CI type error by widening the adaptive thinking type assignment via a resolver helper that returns a properly-typed object. - Add `ThinkingDisplay` enum (`auto` | `summarized` | `omitted`) and matching zod schema in `packages/data-provider/src/schemas.ts` - Add `thinkingDisplay` to `tConversationSchema`, `anthropicSettings`, and the pick lists for Bedrock input/parser + Anthropic agent params - Add `resolveThinkingDisplay(model, explicit)` helper in `packages/data-provider/src/bedrock.ts` that returns the wire value or undefined (auto → model default, explicit → always honored) - `bedrockInputParser` now reads `thinkingDisplay` from input and emits `display` only when the resolver returns a value; strips the field on non-adaptive-model branches so it does not leak - `configureReasoning` in the Anthropic endpoint threads `thinkingDisplay` through, uses the resolver, and casts the adaptive config to `AnthropicClientOptions['thinking']` so the widened shape compiles against the stale installed SDK types - Add UI slider for `thinkingDisplay` in `parameterSettings.ts` next to `effort`, with three-position `com_ui_auto` / `com_ui_summarized` / `com_ui_omitted` labels - Add translation keys `com_endpoint_anthropic_thinking_display`, `com_endpoint_anthropic_thinking_display_desc`, `com_ui_summarized`, `com_ui_omitted` - Add tests: `resolveThinkingDisplay` suite (5 cases covering auto / explicit / unknown input), parser round-trip tests for all three modes on Opus 4.6 and Opus 4.7, Anthropic endpoint tests for explicit summarized/omitted overrides * fix: Drop `thinkingDisplay` When Adaptive Thinking Is Disabled If a user turns adaptive thinking off but had previously selected a `thinkingDisplay` value, the stale field was left in `additionalFields` and ended up merged into the Bedrock request's `additionalModelRequestFields`. That leaks a non-Bedrock key into the payload and can round-trip back into `llmConfig`. - Delete `additionalFields.thinkingDisplay` alongside `thinking` and `thinkingBudget` in the `thinking === false` branch of `bedrockInputParser` - Add a regression test asserting `thinking`, `thinkingBudget`, and `thinkingDisplay` are all absent when adaptive thinking is disabled on an Opus 4.7 request Reported by chatgpt-codex-connector on PR #12701. * refactor: Consolidate `ThinkingDisplay` Types and Preserve Persisted Display Address review findings on PR #12701: - [Codex P2] `bedrockInputSchema.transform` now extracts `thinking.display` from persisted `additionalModelRequestFields` back into the top-level `thinkingDisplay` field so explicit `'omitted'` round-trips through storage instead of being silently reverted to `'summarized'` on the next parse. - [Codex P2] `getLLMConfig` in the Anthropic endpoint now reads `.display` from a persisted `thinking` object (agents store the full Anthropic shape) and uses it as the fallback for `thinkingDisplay` when no top-level override is present. - [Audit #2] Collapse the three parallel wire-value types into a single `ThinkingDisplayWireValue = Exclude` exported from `schemas.ts`; remove the duplicate `ThinkingDisplay` alias in `packages/api/src/types/anthropic.ts` (which collided with the enum name) and the `ThinkingDisplayValue` alias in `bedrock.ts`. - [Audit #3] Add `thinkingDisplay` to the `TEndpointOption` pick list next to `effort`. - [Audit #4] Add a TODO comment next to the `as AnthropicClientOptions['thinking']` cast explaining the stale `@librechat/agents` SDK types that require it. - Add tests: four round-trip cases asserting `bedrockInputSchema` recovers `display` from persisted AMRF (Opus 4.7 omitted, pre-4.7 summarized, unknown-value ignore, explicit top-level wins), and two `getLLMConfig` cases asserting the Anthropic endpoint preserves and overrides persisted `thinking.display`. * fix: Preserve Persisted `thinking.display` in bedrockInputParser The parser constructed a fresh adaptive thinking config without looking at any `display` already embedded in the incoming `additionalModelRequestFields.thinking`. On round-trip through `initializeBedrock`, a persisted user choice of `'omitted'` on Opus 4.7+ was silently reverted to `'summarized'` by the auto fallback. - Extract `extractPersistedDisplay` helper and reuse it in both the schema transform (form-state round-trip) and the parser (wire-request round-trip) - `bedrockInputParser` now feeds the persisted display as the resolver's explicit value when no top-level `thinkingDisplay` override is set - Add regression tests: parser preserves `display: 'omitted'` for persisted Opus 4.7 AMRF, and top-level `thinkingDisplay` still wins over persisted AMRF display Reported by chatgpt-codex-connector (P1) on PR #12701. --- client/src/locales/en/translation.json | 4 + .../api/src/endpoints/anthropic/helpers.ts | 23 +- .../api/src/endpoints/anthropic/llm.spec.ts | 101 ++++++- packages/api/src/endpoints/anthropic/llm.ts | 28 +- packages/api/src/types/anthropic.ts | 2 + packages/data-provider/specs/bedrock.spec.ts | 260 +++++++++++++++++- packages/data-provider/src/bedrock.ts | 105 ++++++- .../data-provider/src/parameterSettings.ts | 23 ++ packages/data-provider/src/schemas.ts | 31 +++ packages/data-provider/src/types.ts | 1 + 10 files changed, 571 insertions(+), 7 deletions(-) diff --git a/client/src/locales/en/translation.json b/client/src/locales/en/translation.json index 2bfe8fa66e..653e920bf0 100644 --- a/client/src/locales/en/translation.json +++ b/client/src/locales/en/translation.json @@ -226,6 +226,8 @@ "com_endpoint_agent_placeholder": "Please select an Agent", "com_endpoint_ai": "AI", "com_endpoint_anthropic_effort": "Controls how much computational effort Claude applies. Lower effort saves tokens and reduces latency; higher effort produces more thorough responses. 'Max' enables the deepest reasoning (Opus 4.6 only).", + "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_maxoutputtokens": "Maximum number of tokens that can be generated in the response. Specify a lower value for shorter responses and a higher value for longer responses. Note: models may stop before reaching this maximum.", "com_endpoint_anthropic_prompt_cache": "Prompt caching allows reusing large context or instructions across API calls, reducing costs and latency", "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.", @@ -1251,6 +1253,7 @@ "com_ui_open_source_chat_new_tab_title": "Open Source Chat in New Tab - {{title}}", "com_ui_open_var": "Open {{0}}", "com_ui_openai": "OpenAI", + "com_ui_omitted": "Omitted", "com_ui_optional": "(optional)", "com_ui_options": "options", "com_ui_output": "Output", @@ -1451,6 +1454,7 @@ "com_ui_storage": "Storage", "com_ui_storage_filter_sort": "Filter and Sort by Storage", "com_ui_submit": "Submit", + "com_ui_summarized": "Summarized", "com_ui_summarizing": "Summarizing...", "com_ui_support_contact": "Support Contact", "com_ui_support_contact_email": "Email", diff --git a/packages/api/src/endpoints/anthropic/helpers.ts b/packages/api/src/endpoints/anthropic/helpers.ts index d33116a2ac..e55ab5b67a 100644 --- a/packages/api/src/endpoints/anthropic/helpers.ts +++ b/packages/api/src/endpoints/anthropic/helpers.ts @@ -2,9 +2,11 @@ import { logger } from '@librechat/data-schemas'; import { AnthropicClientOptions } from '@librechat/agents'; import { EModelEndpoint, + ThinkingDisplay, AnthropicEffort, anthropicSettings, supportsContext1m, + resolveThinkingDisplay, supportsAdaptiveThinking, } from 'librechat-data-provider'; import { matchModelName } from '~/utils/tokens'; @@ -73,6 +75,7 @@ function configureReasoning( thinking?: boolean; thinkingBudget?: number | null; effort?: AnthropicEffort | string | null; + thinkingDisplay?: ThinkingDisplay | string | null; } = {}, ): AnthropicClientOptions & { max_tokens?: number } { const updatedOptions = { ...anthropicInput }; @@ -80,7 +83,25 @@ function configureReasoning( const modelName = updatedOptions.model ?? ''; if (extendedOptions.thinking && modelName && supportsAdaptiveThinking(modelName)) { - updatedOptions.thinking = { type: 'adaptive' }; + /** + * For Opus 4.7+, Anthropic omits thinking content from responses by + * default. Resolver returns `'summarized'` for those models (so the + * LibreChat "Thoughts" UI keeps working) and leaves the field off for + * older adaptive models, while honoring an explicit user choice. + * + * https://platform.claude.com/docs/en/about-claude/models/whats-new-claude-4-7#thinking-content-omitted-by-default + */ + const display = resolveThinkingDisplay(modelName, extendedOptions.thinkingDisplay); + const adaptive = display + ? { type: 'adaptive' as const, display } + : { type: 'adaptive' as const }; + /** + * TODO: Remove the cast once `@librechat/agents` updates its + * `ChatAnthropicMessages['thinking']` type to include the `display` field + * added with Claude Opus 4.7. The cast is required because the installed + * agents SDK still uses the pre-4.7 `ThinkingConfigAdaptive` shape. + */ + updatedOptions.thinking = adaptive as AnthropicClientOptions['thinking']; const effort = extendedOptions.effort; if (effort && effort !== AnthropicEffort.unset) { diff --git a/packages/api/src/endpoints/anthropic/llm.spec.ts b/packages/api/src/endpoints/anthropic/llm.spec.ts index e4b893d487..666441a744 100644 --- a/packages/api/src/endpoints/anthropic/llm.spec.ts +++ b/packages/api/src/endpoints/anthropic/llm.spec.ts @@ -1,4 +1,4 @@ -import { AnthropicEffort } from 'librechat-data-provider'; +import { AnthropicEffort, ThinkingDisplay } from 'librechat-data-provider'; import type * as t from '~/types'; import { getLLMConfig } from './llm'; @@ -1041,6 +1041,105 @@ describe('getLLMConfig', () => { }); }); + it('should request summarized thinking display for Opus 4.7 (opt back in)', () => { + const result = getLLMConfig('test-key', { + modelOptions: { model: 'claude-opus-4-7', 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 NOT set thinking.display for pre-Opus-4.7 adaptive models', () => { + const pre47Models = ['claude-opus-4-6', 'claude-sonnet-4-6']; + + pre47Models.forEach((model) => { + const result = getLLMConfig('test-key', { + modelOptions: { model, thinking: true }, + }); + + const thinking = result.llmConfig.thinking as unknown as { + type: string; + display?: string; + }; + expect(thinking.type).toBe('adaptive'); + expect(thinking.display).toBeUndefined(); + }); + }); + + it('should honor explicit thinkingDisplay="summarized" on Opus 4.6', () => { + const result = getLLMConfig('test-key', { + modelOptions: { + model: 'claude-opus-4-6', + thinking: true, + thinkingDisplay: ThinkingDisplay.summarized, + }, + }); + + const thinking = result.llmConfig.thinking as unknown as { + type: string; + display?: string; + }; + expect(thinking.type).toBe('adaptive'); + expect(thinking.display).toBe('summarized'); + }); + + it('should honor explicit thinkingDisplay="omitted" on Opus 4.7', () => { + const result = getLLMConfig('test-key', { + modelOptions: { + model: 'claude-opus-4-7', + thinking: true, + thinkingDisplay: ThinkingDisplay.omitted, + }, + }); + + const thinking = result.llmConfig.thinking as unknown as { + type: string; + display?: string; + }; + expect(thinking.type).toBe('adaptive'); + expect(thinking.display).toBe('omitted'); + }); + + it('should recover display from persisted agent thinking object (Opus 4.7 omitted)', () => { + /** Agents persist `thinking` as the full Anthropic object. Without + * extracting `.display` back into `thinkingDisplay`, Opus 4.7's auto + * resolver would silently flip it to 'summarized'. */ + const result = getLLMConfig('test-key', { + modelOptions: { + model: 'claude-opus-4-7', + thinking: { type: 'adaptive', display: 'omitted' }, + } as unknown as t.AnthropicModelOptions, + }); + + const thinking = result.llmConfig.thinking as unknown as { + type: string; + display?: string; + }; + expect(thinking.type).toBe('adaptive'); + expect(thinking.display).toBe('omitted'); + }); + + it('explicit thinkingDisplay wins over persisted thinking.display', () => { + const result = getLLMConfig('test-key', { + modelOptions: { + model: 'claude-opus-4-7', + thinking: { type: 'adaptive', display: 'summarized' }, + thinkingDisplay: ThinkingDisplay.omitted, + } as unknown as t.AnthropicModelOptions, + }); + + const thinking = result.llmConfig.thinking as unknown as { + type: string; + display?: string; + }; + expect(thinking.display).toBe('omitted'); + }); + it('should exclude topP/topK for Sonnet 4.6 with adaptive thinking', () => { const result = getLLMConfig('test-key', { modelOptions: { diff --git a/packages/api/src/endpoints/anthropic/llm.ts b/packages/api/src/endpoints/anthropic/llm.ts index d792adef4e..100bb8fc0b 100644 --- a/packages/api/src/endpoints/anthropic/llm.ts +++ b/packages/api/src/endpoints/anthropic/llm.ts @@ -1,7 +1,12 @@ import { Dispatcher, ProxyAgent } from 'undici'; import { logger } from '@librechat/data-schemas'; import { AnthropicClientOptions } from '@librechat/agents'; -import { anthropicSettings, removeNullishValues, AuthKeys } from 'librechat-data-provider'; +import { + anthropicSettings, + removeNullishValues, + ThinkingDisplay, + AuthKeys, +} from 'librechat-data-provider'; import type { AnthropicLLMConfigResult, AnthropicConfigOptions, @@ -83,12 +88,32 @@ function getLLMConfig( credentials: string | AnthropicCredentials | undefined, options: AnthropicConfigOptions = {}, ): AnthropicLLMConfigResult { + /** + * Persisted agent `model_parameters` may round-trip `thinking` as the full + * Anthropic object `{ type: 'adaptive', display: 'omitted' }` rather than a + * boolean. Pull any `.display` out of it so an explicit user choice is not + * silently demoted to `'auto'` (which would then resolve to `'summarized'` + * on Opus 4.7+). + */ + const persistedThinking = options.modelOptions?.thinking; + const persistedDisplay = + typeof persistedThinking === 'object' && + persistedThinking != null && + 'display' in persistedThinking && + typeof (persistedThinking as { display?: unknown }).display === 'string' + ? ((persistedThinking as { display: string }).display as ThinkingDisplay | string) + : undefined; + const systemOptions = { thinking: options.modelOptions?.thinking ?? anthropicSettings.thinking.default, promptCache: options.modelOptions?.promptCache ?? anthropicSettings.promptCache.default, thinkingBudget: options.modelOptions?.thinkingBudget ?? anthropicSettings.thinkingBudget.default, effort: options.modelOptions?.effort ?? anthropicSettings.effort.default, + thinkingDisplay: + options.modelOptions?.thinkingDisplay ?? + persistedDisplay ?? + anthropicSettings.thinkingDisplay.default, }; if (options.modelOptions) { @@ -96,6 +121,7 @@ function getLLMConfig( delete options.modelOptions.promptCache; delete options.modelOptions.thinkingBudget; delete options.modelOptions.effort; + delete options.modelOptions.thinkingDisplay; } else { throw new Error('No modelOptions provided'); } diff --git a/packages/api/src/types/anthropic.ts b/packages/api/src/types/anthropic.ts index d3ba744488..7f62a14974 100644 --- a/packages/api/src/types/anthropic.ts +++ b/packages/api/src/types/anthropic.ts @@ -2,6 +2,7 @@ import { z } from 'zod'; import { Dispatcher } from 'undici'; import { AuthKeys, anthropicSchema, TVertexAISchema } from 'librechat-data-provider'; import type { AnthropicClientOptions } from '@librechat/agents'; +import type { ThinkingDisplayWireValue } from 'librechat-data-provider'; import type { LLMConfigResult } from './openai'; import type { GoogleServiceKey } from '../utils/key'; @@ -46,6 +47,7 @@ export interface ThinkingConfigEnabled { export interface ThinkingConfigAdaptive { type: 'adaptive'; + display?: ThinkingDisplayWireValue; } /** diff --git a/packages/data-provider/specs/bedrock.spec.ts b/packages/data-provider/specs/bedrock.spec.ts index 014fe0dec0..95ae369d3a 100644 --- a/packages/data-provider/specs/bedrock.spec.ts +++ b/packages/data-provider/specs/bedrock.spec.ts @@ -1,7 +1,11 @@ +import { ThinkingDisplay } from '../src/schemas'; import { supportsAdaptiveThinking, + omitsThinkingByDefault, + resolveThinkingDisplay, bedrockOutputParser, bedrockInputParser, + bedrockInputSchema, supportsContext1m, } from '../src/bedrock'; @@ -193,6 +197,101 @@ describe('supportsContext1m', () => { }); }); +describe('omitsThinkingByDefault', () => { + test('returns true for claude-opus-4-7', () => { + expect(omitsThinkingByDefault('claude-opus-4-7')).toBe(true); + }); + + test('returns true for claude-opus-4.7', () => { + expect(omitsThinkingByDefault('claude-opus-4.7')).toBe(true); + }); + + test('returns true for anthropic.claude-opus-4-7 (Bedrock)', () => { + expect(omitsThinkingByDefault('anthropic.claude-opus-4-7')).toBe(true); + }); + + test('returns true for us.anthropic.claude-opus-4-7 (cross-region Bedrock)', () => { + expect(omitsThinkingByDefault('us.anthropic.claude-opus-4-7')).toBe(true); + }); + + test('returns true for claude-opus-4-8 (future Opus 4.x)', () => { + expect(omitsThinkingByDefault('claude-opus-4-8')).toBe(true); + }); + + test('returns true for claude-opus-5 (future major Opus)', () => { + expect(omitsThinkingByDefault('claude-opus-5')).toBe(true); + }); + + test('returns true for claude-opus-9 (far-future Opus)', () => { + expect(omitsThinkingByDefault('claude-opus-9')).toBe(true); + }); + + test('returns false for claude-opus-4-6 (adaptive but pre-4.7)', () => { + expect(omitsThinkingByDefault('claude-opus-4-6')).toBe(false); + }); + + test('returns false for claude-opus-4-5', () => { + expect(omitsThinkingByDefault('claude-opus-4-5')).toBe(false); + }); + + test('returns false for claude-sonnet-4-6', () => { + 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)', () => { + expect(omitsThinkingByDefault('claude-sonnet-4-7')).toBe(false); + }); + + test('returns false for claude-haiku-4-5', () => { + expect(omitsThinkingByDefault('claude-haiku-4-5')).toBe(false); + }); + + test('returns false for claude-3-7-sonnet', () => { + expect(omitsThinkingByDefault('claude-3-7-sonnet')).toBe(false); + }); + + test('returns false for unrelated models', () => { + expect(omitsThinkingByDefault('gpt-4o')).toBe(false); + expect(omitsThinkingByDefault('')).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'); + expect(resolveThinkingDisplay('claude-opus-4-7', null)).toBe('summarized'); + expect(resolveThinkingDisplay('claude-opus-4-7', undefined)).toBe('summarized'); + }); + + test('returns undefined for Opus 4.6 when explicit is auto/null/undefined', () => { + expect(resolveThinkingDisplay('claude-opus-4-6', ThinkingDisplay.auto)).toBeUndefined(); + expect(resolveThinkingDisplay('claude-opus-4-6', null)).toBeUndefined(); + expect(resolveThinkingDisplay('claude-opus-4-6', undefined)).toBeUndefined(); + }); + + test('explicit summarized wins for any adaptive model', () => { + expect(resolveThinkingDisplay('claude-opus-4-6', ThinkingDisplay.summarized)).toBe( + 'summarized', + ); + expect(resolveThinkingDisplay('claude-sonnet-4-6', ThinkingDisplay.summarized)).toBe( + 'summarized', + ); + expect(resolveThinkingDisplay('claude-opus-4-7', ThinkingDisplay.summarized)).toBe( + 'summarized', + ); + }); + + test('explicit omitted wins even for Opus 4.7', () => { + expect(resolveThinkingDisplay('claude-opus-4-7', ThinkingDisplay.omitted)).toBe('omitted'); + expect(resolveThinkingDisplay('claude-opus-4-6', ThinkingDisplay.omitted)).toBe('omitted'); + }); + + test('unknown string values fall through to auto behavior', () => { + expect(resolveThinkingDisplay('claude-opus-4-7', 'bogus')).toBe('summarized'); + expect(resolveThinkingDisplay('claude-opus-4-6', 'bogus')).toBeUndefined(); + }); +}); + describe('bedrockInputParser', () => { describe('Model Matching for Reasoning Configuration', () => { test('should match anthropic.claude-3-7-sonnet model', () => { @@ -226,7 +325,7 @@ describe('bedrockInputParser', () => { }; const result = bedrockInputParser.parse(input) as Record; const additionalFields = result.additionalModelRequestFields as Record; - expect(additionalFields.thinking).toEqual({ type: 'adaptive' }); + expect(additionalFields.thinking).toEqual({ type: 'adaptive', display: 'summarized' }); expect(additionalFields.thinkingBudget).toBeUndefined(); expect(additionalFields.anthropic_beta).toEqual([ 'output-128k-2025-02-19', @@ -448,11 +547,168 @@ describe('bedrockInputParser', () => { }; const result = bedrockInputParser.parse(input) as Record; const additionalFields = result.additionalModelRequestFields as Record; - expect(additionalFields.thinking).toEqual({ type: 'adaptive' }); + expect(additionalFields.thinking).toEqual({ type: 'adaptive', display: 'summarized' }); expect(additionalFields.output_config).toEqual({ effort: 'xhigh' }); expect(additionalFields.effort).toBeUndefined(); }); + test('should set thinking.display to "summarized" so Opus 4.7 returns reasoning blocks', () => { + const input = { + model: 'anthropic.claude-opus-4-7', + }; + const result = bedrockInputParser.parse(input) as Record; + const additionalFields = result.additionalModelRequestFields as Record; + expect(additionalFields.thinking).toEqual({ type: 'adaptive', display: 'summarized' }); + }); + + test('should NOT set thinking.display for pre-Opus-4.7 adaptive models', () => { + const pre47Models = [ + 'anthropic.claude-opus-4-6-v1', + 'anthropic.claude-sonnet-4-6', + 'us.anthropic.claude-opus-4-6-v1', + ]; + + pre47Models.forEach((model) => { + const result = bedrockInputParser.parse({ model }) as Record; + const additionalFields = result.additionalModelRequestFields as Record; + expect(additionalFields.thinking).toEqual({ type: 'adaptive' }); + expect(additionalFields.thinking).not.toHaveProperty('display'); + }); + }); + + test('explicit thinkingDisplay="summarized" forces display even on Opus 4.6', () => { + const result = bedrockInputParser.parse({ + model: 'anthropic.claude-opus-4-6-v1', + thinkingDisplay: ThinkingDisplay.summarized, + }) as Record; + const additionalFields = result.additionalModelRequestFields as Record; + expect(additionalFields.thinking).toEqual({ type: 'adaptive', display: 'summarized' }); + expect(additionalFields.thinkingDisplay).toBeUndefined(); + }); + + test('explicit thinkingDisplay="omitted" wins even on Opus 4.7', () => { + const result = bedrockInputParser.parse({ + model: 'anthropic.claude-opus-4-7', + thinkingDisplay: ThinkingDisplay.omitted, + }) as Record; + const additionalFields = result.additionalModelRequestFields as Record; + expect(additionalFields.thinking).toEqual({ type: 'adaptive', display: 'omitted' }); + expect(additionalFields.thinkingDisplay).toBeUndefined(); + }); + + test('thinkingDisplay="auto" defers to model default', () => { + const opus47 = bedrockInputParser.parse({ + model: 'anthropic.claude-opus-4-7', + thinkingDisplay: ThinkingDisplay.auto, + }) as Record; + expect((opus47.additionalModelRequestFields as Record).thinking).toEqual({ + type: 'adaptive', + display: 'summarized', + }); + + const opus46 = bedrockInputParser.parse({ + model: 'anthropic.claude-opus-4-6-v1', + thinkingDisplay: ThinkingDisplay.auto, + }) as Record; + expect((opus46.additionalModelRequestFields as Record).thinking).toEqual({ + type: 'adaptive', + }); + }); + + test('thinkingDisplay is stripped when model does not support adaptive thinking', () => { + const result = bedrockInputParser.parse({ + model: 'anthropic.claude-3-5-sonnet-20241022-v2:0', + thinkingDisplay: ThinkingDisplay.summarized, + }) as Record; + const additionalFields = result.additionalModelRequestFields as Record; + expect(additionalFields?.thinkingDisplay).toBeUndefined(); + }); + + test('thinkingDisplay is stripped when adaptive thinking is disabled', () => { + const result = bedrockInputParser.parse({ + model: 'anthropic.claude-opus-4-7', + thinking: false, + thinkingDisplay: ThinkingDisplay.summarized, + }) as Record; + const additionalFields = result.additionalModelRequestFields as Record; + expect(additionalFields.thinking).toBeUndefined(); + expect(additionalFields.thinkingBudget).toBeUndefined(); + expect(additionalFields.thinkingDisplay).toBeUndefined(); + }); + + test('round-trips persisted display from AMRF.thinking.display (Opus 4.7 omitted)', () => { + /** Simulates a persisted conversation where the prior parse already set + * display on the nested AMRF.thinking object but did not persist the + * top-level thinkingDisplay field. The schema (bedrockInputSchema) should + * recover display → thinkingDisplay so the parser can honor the explicit + * choice on subsequent requests. */ + const persisted = bedrockInputSchema.parse({ + model: 'anthropic.claude-opus-4-7', + additionalModelRequestFields: { + thinking: { type: 'adaptive', display: 'omitted' }, + }, + }) as Record; + expect(persisted.thinkingDisplay).toBe('omitted'); + }); + + test('round-trips persisted display from AMRF.thinking.display (summarized)', () => { + const persisted = bedrockInputSchema.parse({ + model: 'anthropic.claude-opus-4-6-v1', + additionalModelRequestFields: { + thinking: { type: 'adaptive', display: 'summarized' }, + }, + }) as Record; + expect(persisted.thinkingDisplay).toBe('summarized'); + }); + + test('ignores unknown display values during round-trip extraction', () => { + const persisted = bedrockInputSchema.parse({ + model: 'anthropic.claude-opus-4-7', + additionalModelRequestFields: { + thinking: { type: 'adaptive', display: 'bogus' }, + }, + }) as Record; + expect(persisted.thinkingDisplay).toBeUndefined(); + }); + + test('top-level thinkingDisplay wins over persisted AMRF display', () => { + const persisted = bedrockInputSchema.parse({ + model: 'anthropic.claude-opus-4-7', + thinkingDisplay: ThinkingDisplay.omitted, + additionalModelRequestFields: { + thinking: { type: 'adaptive', display: 'summarized' }, + }, + }) as Record; + expect(persisted.thinkingDisplay).toBe(ThinkingDisplay.omitted); + }); + + test('bedrockInputParser preserves persisted AMRF.thinking.display (Opus 4.7 omitted)', () => { + /** initializeBedrock calls bedrockInputParser directly on persisted + * model_parameters. Without the parser-side extraction, the 'omitted' + * user choice baked into AMRF would be silently reverted to + * 'summarized' by the Opus 4.7+ auto fallback. */ + const result = bedrockInputParser.parse({ + model: 'anthropic.claude-opus-4-7', + additionalModelRequestFields: { + thinking: { type: 'adaptive', display: 'omitted' }, + }, + }) as Record; + const amrf = result.additionalModelRequestFields as Record; + expect(amrf.thinking).toEqual({ type: 'adaptive', display: 'omitted' }); + }); + + test('bedrockInputParser: top-level thinkingDisplay wins over persisted AMRF display', () => { + const result = bedrockInputParser.parse({ + model: 'anthropic.claude-opus-4-7', + thinkingDisplay: ThinkingDisplay.summarized, + additionalModelRequestFields: { + thinking: { type: 'adaptive', display: 'omitted' }, + }, + }) as Record; + const amrf = result.additionalModelRequestFields as Record; + expect(amrf.thinking).toEqual({ type: 'adaptive', display: 'summarized' }); + }); + test('should not include output_config when effort is unset (empty string)', () => { const input = { model: 'anthropic.claude-opus-4-6-v1', diff --git a/packages/data-provider/src/bedrock.ts b/packages/data-provider/src/bedrock.ts index bdf5e19605..6bf840bb5c 100644 --- a/packages/data-provider/src/bedrock.ts +++ b/packages/data-provider/src/bedrock.ts @@ -6,7 +6,53 @@ const DEFAULT_THINKING_BUDGET = 2000; const bedrockReasoningConfigValues = new Set(Object.values(s.BedrockReasoningConfig)); -type ThinkingConfig = { type: 'enabled'; budget_tokens: number } | { type: 'adaptive' }; +type ThinkingConfig = + | { type: 'enabled'; budget_tokens: number } + | { type: 'adaptive'; display?: s.ThinkingDisplayWireValue }; + +/** + * Resolves the final `thinking.display` value for an adaptive-thinking request. + * + * Starting with Claude Opus 4.7, the Messages API returns empty `thinking` + * blocks unless the request sets `thinking.display`. This helper encodes the + * three user-facing modes — `'auto'` (LibreChat decides), `'summarized'`, and + * `'omitted'` — into the wire value (or `undefined` when the field should be + * left off). + * + * See https://platform.claude.com/docs/en/about-claude/models/whats-new-claude-4-7#thinking-content-omitted-by-default + */ +/** + * Safely extracts a nested `thinking.display` string from a persisted + * `additionalModelRequestFields` object, returning `undefined` if the shape + * isn't what we expect. + */ +function extractPersistedDisplay(amrf: unknown): string | undefined { + if (typeof amrf !== 'object' || amrf === null) { + return undefined; + } + const thinking = (amrf as Record).thinking; + if (typeof thinking !== 'object' || thinking === null) { + return undefined; + } + const display = (thinking as Record).display; + return typeof display === 'string' ? display : undefined; +} + +export function resolveThinkingDisplay( + model: string, + explicit?: s.ThinkingDisplay | string | null, +): s.ThinkingDisplayWireValue | undefined { + if (explicit === s.ThinkingDisplay.summarized) { + return s.ThinkingDisplay.summarized; + } + if (explicit === s.ThinkingDisplay.omitted) { + return s.ThinkingDisplay.omitted; + } + if (omitsThinkingByDefault(model)) { + return s.ThinkingDisplay.summarized; + } + return undefined; +} type AnthropicReasoning = { thinking?: ThinkingConfig | boolean; @@ -70,6 +116,24 @@ export function supportsAdaptiveThinking(model: string): boolean { return false; } +/** + * Checks if a model omits `thinking` content from responses by default. + * + * Starting with Claude Opus 4.7, the Messages API returns empty `thinking` + * blocks unless the request explicitly opts in via `thinking.display = + * "summarized"`. This helper narrows the opt-in to Opus 4.7+ (and any future + * major Opus version) so older adaptive-thinking models are left untouched. + * + * 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; + } + return false; +} + /** 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); @@ -136,6 +200,7 @@ export const bedrockInputSchema = s.tConversationSchema thinking: true, thinkingBudget: true, effort: true, + thinkingDisplay: true, reasoning_effort: true, promptCache: true, /* Catch-all fields */ @@ -151,6 +216,15 @@ export const bedrockInputSchema = s.tConversationSchema typeof thinking === 'object' && 'budget_tokens' in thinking ? thinking.budget_tokens : undefined; + if (obj.thinkingDisplay == null) { + const persistedDisplay = extractPersistedDisplay({ thinking }); + if ( + persistedDisplay === s.ThinkingDisplay.summarized || + persistedDisplay === s.ThinkingDisplay.omitted + ) { + obj.thinkingDisplay = persistedDisplay as s.ThinkingDisplay; + } + } delete obj.additionalModelRequestFields; } return s.removeNullishValues(obj); @@ -181,6 +255,7 @@ export const bedrockInputParser = s.tConversationSchema thinking: true, thinkingBudget: true, effort: true, + thinkingDisplay: true, reasoning_effort: true, promptCache: true, /* Catch-all fields */ @@ -242,9 +317,33 @@ export const bedrockInputParser = s.tConversationSchema if (additionalFields.thinking === false) { delete additionalFields.thinking; delete additionalFields.thinkingBudget; + delete additionalFields.thinkingDisplay; } else { - additionalFields.thinking = { type: 'adaptive' }; + /** + * Persisted agent `model_parameters` round-trip back through this + * parser with the prior `thinking.display` embedded in + * `additionalModelRequestFields`. Surface it as the resolver's + * explicit value when no top-level `thinkingDisplay` is set so the + * prior user choice (e.g. 'omitted') survives instead of being + * clobbered by the Opus 4.7+ auto → 'summarized' fallback. + */ + const topLevelDisplay = additionalFields.thinkingDisplay as + | s.ThinkingDisplay + | string + | null + | undefined; + const persistedDisplay = extractPersistedDisplay(typedData.additionalModelRequestFields); + const thinkingConfig: ThinkingConfig = { type: 'adaptive' }; + const display = resolveThinkingDisplay( + typedData.model as string, + topLevelDisplay ?? persistedDisplay, + ); + if (display) { + thinkingConfig.display = display; + } + additionalFields.thinking = thinkingConfig; delete additionalFields.thinkingBudget; + delete additionalFields.thinkingDisplay; } } else { if (additionalFields.thinking === undefined) { @@ -258,6 +357,7 @@ export const bedrockInputParser = s.tConversationSchema additionalFields.thinkingBudget = DEFAULT_THINKING_BUDGET; } delete additionalFields.effort; + delete additionalFields.thinkingDisplay; } /** Anthropic uses 'effort' via output_config, not reasoning_config */ @@ -273,6 +373,7 @@ export const bedrockInputParser = s.tConversationSchema delete additionalFields.thinking; delete additionalFields.thinkingBudget; delete additionalFields.effort; + delete additionalFields.thinkingDisplay; delete additionalFields.output_config; delete additionalFields.anthropic_beta; diff --git a/packages/data-provider/src/parameterSettings.ts b/packages/data-provider/src/parameterSettings.ts index ab98417ac4..6bc8c2b162 100644 --- a/packages/data-provider/src/parameterSettings.ts +++ b/packages/data-provider/src/parameterSettings.ts @@ -2,6 +2,7 @@ import { Verbosity, ImageDetail, ThinkingLevel, + ThinkingDisplay, EModelEndpoint, openAISettings, googleSettings, @@ -468,6 +469,24 @@ const anthropic: Record = { optionType: 'model', columnSpan: 4, }, + thinkingDisplay: { + key: 'thinkingDisplay', + label: 'com_endpoint_anthropic_thinking_display', + labelCode: true, + description: 'com_endpoint_anthropic_thinking_display_desc', + descriptionCode: true, + type: 'enum', + default: anthropicSettings.thinkingDisplay.default, + component: 'slider', + options: anthropicSettings.thinkingDisplay.options, + enumMappings: { + [ThinkingDisplay.auto]: 'com_ui_auto', + [ThinkingDisplay.summarized]: 'com_ui_summarized', + [ThinkingDisplay.omitted]: 'com_ui_omitted', + }, + optionType: 'model', + columnSpan: 4, + }, }; const bedrock: Record = { @@ -810,6 +829,7 @@ const anthropicConfig: SettingsConfiguration = [ anthropic.thinking, anthropic.thinkingBudget, anthropic.effort, + anthropic.thinkingDisplay, anthropic.web_search, librechat.fileTokenLimit, ]; @@ -831,6 +851,7 @@ const anthropicCol2: SettingsConfiguration = [ anthropic.thinking, anthropic.thinkingBudget, anthropic.effort, + anthropic.thinkingDisplay, anthropic.web_search, librechat.fileTokenLimit, ]; @@ -850,6 +871,7 @@ const bedrockAnthropic: SettingsConfiguration = [ anthropic.thinking, anthropic.thinkingBudget, anthropic.effort, + anthropic.thinkingDisplay, librechat.fileTokenLimit, ]; @@ -908,6 +930,7 @@ const bedrockAnthropicCol2: SettingsConfiguration = [ anthropic.thinking, anthropic.thinkingBudget, anthropic.effort, + anthropic.thinkingDisplay, librechat.fileTokenLimit, ]; diff --git a/packages/data-provider/src/schemas.ts b/packages/data-provider/src/schemas.ts index 45c0653f92..d8b834362d 100644 --- a/packages/data-provider/src/schemas.ts +++ b/packages/data-provider/src/schemas.ts @@ -186,6 +186,28 @@ export enum AnthropicEffort { max = 'max', } +/** + * Controls whether the model's reasoning content is returned in responses. + * + * - `'auto'` - LibreChat decides: opt in to `'summarized'` for models that + * 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. + * + * See https://platform.claude.com/docs/en/about-claude/models/whats-new-claude-4-7#thinking-content-omitted-by-default + */ +export enum ThinkingDisplay { + auto = 'auto', + summarized = 'summarized', + omitted = 'omitted', +} + +/** + * Wire-level values accepted by the Anthropic Messages API `thinking.display` + * field. Excludes the LibreChat-only `'auto'` sentinel. + */ +export type ThinkingDisplayWireValue = Exclude; + export enum BedrockReasoningConfig { low = 'low', medium = 'medium', @@ -229,6 +251,7 @@ export const imageDetailValue = { export const eImageDetailSchema = z.nativeEnum(ImageDetail); export const eReasoningEffortSchema = z.nativeEnum(ReasoningEffort); export const eAnthropicEffortSchema = z.nativeEnum(AnthropicEffort); +export const eThinkingDisplaySchema = z.nativeEnum(ThinkingDisplay); export const eReasoningSummarySchema = z.nativeEnum(ReasoningSummary); export const eVerbositySchema = z.nativeEnum(Verbosity); export const eThinkingLevelSchema = z.nativeEnum(ThinkingLevel); @@ -496,6 +519,10 @@ export const anthropicSettings = { AnthropicEffort.max, ], }, + thinkingDisplay: { + default: ThinkingDisplay.auto, + options: [ThinkingDisplay.auto, ThinkingDisplay.summarized, ThinkingDisplay.omitted], + }, web_search: { default: false as const, }, @@ -774,6 +801,8 @@ export const tConversationSchema = z.object({ useResponsesApi: z.boolean().optional(), /* Anthropic: Effort control */ effort: eAnthropicEffortSchema.optional().nullable(), + /* Anthropic: Thinking visibility (Opus 4.7+ opt-in) */ + thinkingDisplay: eThinkingDisplaySchema.optional().nullable(), /* OpenAI Responses API / Anthropic API / Google API */ web_search: z.boolean().optional(), /* disable streaming */ @@ -898,6 +927,7 @@ export const tQueryParamsSchema = tConversationSchema thinkingBudget: true, thinkingLevel: true, effort: true, + thinkingDisplay: true, /** @endpoints bedrock */ region: true, /** @endpoints bedrock */ @@ -1222,6 +1252,7 @@ export const anthropicBaseSchema = tConversationSchema.pick({ thinking: true, thinkingBudget: true, effort: true, + thinkingDisplay: true, artifacts: true, iconURL: true, greeting: true, diff --git a/packages/data-provider/src/types.ts b/packages/data-provider/src/types.ts index b8942dd173..85ac417f6c 100644 --- a/packages/data-provider/src/types.ts +++ b/packages/data-provider/src/types.ts @@ -54,6 +54,7 @@ export type TEndpointOption = Pick< | 'thinkingBudget' | 'thinkingLevel' | 'effort' + | 'thinkingDisplay' // Assistant/Agent fields | 'assistant_id' | 'agent_id'