From 2ce99ad4dca8ab6811e8d1b01a5427069abdc972 Mon Sep 17 00:00:00 2001 From: cshyang Date: Tue, 29 Sep 2026 16:50:03 +0800 Subject: [PATCH] fix(responses): drop the internal summary:none marker before it reaches the upstream --- src/adapters/openai-responses/passthrough.ts | 3 +- src/adapters/openai-responses/reasoning.ts | 15 ++++++++++ .../openai-responses-passthrough.test.ts | 28 +++++++++++++++++++ 3 files changed, 45 insertions(+), 1 deletion(-) diff --git a/src/adapters/openai-responses/passthrough.ts b/src/adapters/openai-responses/passthrough.ts index a3e5a85e1ca..d4533405dd0 100644 --- a/src/adapters/openai-responses/passthrough.ts +++ b/src/adapters/openai-responses/passthrough.ts @@ -34,7 +34,7 @@ import { import { createAdapterTierMetadata, } from "../../providers/fastwire"; -import { dropResponsesReasoningInputItems, mapRoutedResponsesReasoningEffort, normalizeConfiguredReasoningSummaryDelivery, sanitizeReasoningInputContent, stripDisabledReasoningSummaries, stripDisabledVerbosity, stripUnsupportedReasoningSummaryDelivery } from "./reasoning"; +import { dropResponsesReasoningInputItems, mapRoutedResponsesReasoningEffort, normalizeConfiguredReasoningSummaryDelivery, sanitizeReasoningInputContent, stripDisabledReasoningSummaries, stripDisabledVerbosity, stripNoneReasoningSummary, stripUnsupportedReasoningSummaryDelivery } from "./reasoning"; import { scrubOcxCompactionItems, stripCanonicalOnlyToolFields, stripCanonicalOnlyTopLevelFields, stripInternalChatMessageMetadataPassthrough, stripInvalidItemIds, stripItemIdsWhenUnstored, stripRejectedSamplingParams } from "./request-strips"; import { stripCanonicalForwardPromptCacheOptions, stripDeprecatedPromptCacheRetention } from "./prompt-cache"; import { isPlainObject } from "./internal"; @@ -352,6 +352,7 @@ export function createResponsesPassthroughAdapter(provider: OcxProviderConfig): outBody = repairOversizedReplayCallIds(outBody); } outBody = stripUnsupportedReasoningSummaryDelivery(outBody, parsed.modelId); + outBody = stripNoneReasoningSummary(outBody); // #4587: on a bridged provider, hand the destination back the search call and result the // proxy executed on its behalf, in place of the hosted cell the caller replays. Scoped to // its exact conversation and serving identity and recorded by the bridge itself, so a diff --git a/src/adapters/openai-responses/reasoning.ts b/src/adapters/openai-responses/reasoning.ts index 5f19edec946..5b79f9d7cec 100644 --- a/src/adapters/openai-responses/reasoning.ts +++ b/src/adapters/openai-responses/reasoning.ts @@ -156,6 +156,21 @@ export function sanitizeReasoningInputContent( return changed ? { ...raw, input } : body; } +/** + * `reasoning.summary: "none"` is an internal "hide the thinking summary" marker: Claude's + * `thinking.display: "omitted"` maps to it, and the parser reads it into + * `options.hideThinkingSummary`. It is not a Responses API value (valid: auto, concise, + * detailed), so drop it from the body that is serialized to the upstream. + */ +export function stripNoneReasoningSummary(body: unknown): unknown { + if (!isPlainObject(body) || !isPlainObject(body.reasoning) || body.reasoning.summary !== "none") return body; + const { summary: _summary, ...rest } = body.reasoning; + const next = { ...body }; + if (Object.keys(rest).length > 0) next.reasoning = rest; + else delete next.reasoning; + return next; +} + export function stripUnsupportedReasoningSummaryDelivery(body: unknown, modelId: string): unknown { if (catalogModelSupportsReasoningSummaries(modelId) !== false) return body; if (!isPlainObject(body) || !isPlainObject(body.stream_options)) return body; diff --git a/tests/responses/openai-responses-passthrough.test.ts b/tests/responses/openai-responses-passthrough.test.ts index f9c2029f38a..c6863823af6 100644 --- a/tests/responses/openai-responses-passthrough.test.ts +++ b/tests/responses/openai-responses-passthrough.test.ts @@ -1825,6 +1825,34 @@ describe("OpenAI Responses passthrough sanitization", () => { expect(body.reasoning).toEqual({ effort: "high" }); }); + test("Claude omitted thinking display never puts summary:none on the Responses wire", () => { + const claudeBody = anthropicToResponsesBody({ + model: "gpt-6-astra", + max_tokens: 10, + messages: [{ role: "user", content: "hi" }], + thinking: { type: "adaptive", display: "omitted" }, + output_config: { effort: "high" }, + }); + // The translator still emits the marker, so the parser can hide the thinking summary. + expect((claudeBody as { reasoning?: unknown }).reasoning).toEqual({ summary: "none", effort: "high" }); + + for (const target of [ + { ...provider, baseUrl: "https://chatgpt.com/backend-api/codex", authMode: "forward" as const }, + { adapter: "openai-responses", baseUrl: "https://api.openai.com/v1", authMode: "key" as const, apiKey: "sk-test" }, + ]) { + const parsed = parseRequest(claudeBody); + expect(parsed.options.hideThinkingSummary).toBe(true); + const request = createResponsesPassthroughAdapter(target).buildRequest(parsed, { headers: new Headers() }); + const wire = JSON.parse(request.body) as { reasoning?: Record }; + expect(wire.reasoning).toEqual({ effort: "high" }); + } + + // A summary-only marker leaves no empty reasoning object behind. + const summaryOnly = parseRequest({ model: "gpt-6-astra", input: "hi", reasoning: { summary: "none" } }); + const wire = JSON.parse(createResponsesPassthroughAdapter(provider).buildRequest(summaryOnly, { headers: new Headers() }).body); + expect(wire.reasoning).toBeUndefined(); + }); + function routedXaiResponsesProvider() { const entry = getProviderRegistryEntry("xai")!; const route = routeModel({