From 5087e5b8809ec53152bc93b3692c3d2b95a232d7 Mon Sep 17 00:00:00 2001 From: jackjinke Date: Mon, 17 Aug 2026 09:12:23 +0800 Subject: [PATCH 01/10] fix(reasoning): preserve compatible response state --- .../10550-responses-reasoning-transport.md | 1 + .../registry/chatgpt-web-codex/index.ts | 1 + .../config/providers/registry/codex/index.ts | 1 + .../providers/registry/deepseek/index.ts | 1 + .../providers/registry/grok-cli/index.ts | 1 + .../providers/registry/muse-code/index.ts | 1 + .../config/providers/registry/openai/index.ts | 1 + .../config/providers/registry/xai/index.ts | 2 + open-sse/config/providers/shared.ts | 4 + open-sse/executors/codex.ts | 10 +- open-sse/handlers/chatCore.ts | 26 +++- open-sse/services/responsesInputPolicy.ts | 137 ++++++++++++++--- .../translator/request/openai-responses.ts | 25 ++- .../request/openai-responses/toResponses.ts | 31 +++- tests/unit/chatcore-translation-paths.test.ts | 127 ++++++++++++---- .../combo-attempt-body-isolation-7847.test.ts | 76 ++++++++++ ...asoning-blobs-agentic-context-1599.test.ts | 121 +++++++++++---- .../translator-openai-responses-req.test.ts | 143 +++++++++++++++++- 18 files changed, 600 insertions(+), 109 deletions(-) create mode 100644 changelog.d/fixes/10550-responses-reasoning-transport.md diff --git a/changelog.d/fixes/10550-responses-reasoning-transport.md b/changelog.d/fixes/10550-responses-reasoning-transport.md new file mode 100644 index 000000000000..8c2d4cf2e770 --- /dev/null +++ b/changelog.d/fixes/10550-responses-reasoning-transport.md @@ -0,0 +1 @@ +- Preserve target-compatible reasoning across Chat Completions and Responses routes, including DeepSeek plaintext continuations and provider-generated opaque Responses state, without replaying unsupported reasoning into fallback targets. (#10550) diff --git a/open-sse/config/providers/registry/chatgpt-web-codex/index.ts b/open-sse/config/providers/registry/chatgpt-web-codex/index.ts index a1ccb6b13c8a..d65270b27bdb 100644 --- a/open-sse/config/providers/registry/chatgpt-web-codex/index.ts +++ b/open-sse/config/providers/registry/chatgpt-web-codex/index.ts @@ -14,6 +14,7 @@ export const chatgpt_web_codexProvider: RegistryEntry = { format: "openai-responses", executor: "chatgpt-web-codex", baseUrl: "https://chatgpt.com", + responsesReasoningTransport: "opaque", authType: "apikey", authHeader: "cookie", forceStream: true, diff --git a/open-sse/config/providers/registry/codex/index.ts b/open-sse/config/providers/registry/codex/index.ts index 7d6fee4557e7..87bfcc35a0fc 100644 --- a/open-sse/config/providers/registry/codex/index.ts +++ b/open-sse/config/providers/registry/codex/index.ts @@ -12,6 +12,7 @@ export const codexProvider: RegistryEntry = { format: "openai-responses", executor: "codex", baseUrl: "https://chatgpt.com/backend-api/codex/responses", + responsesReasoningTransport: "opaque", authType: "oauth", authHeader: "bearer", defaultContextLength: 400000, diff --git a/open-sse/config/providers/registry/deepseek/index.ts b/open-sse/config/providers/registry/deepseek/index.ts index 933fb9bba1f5..3bf0c90a200a 100644 --- a/open-sse/config/providers/registry/deepseek/index.ts +++ b/open-sse/config/providers/registry/deepseek/index.ts @@ -6,6 +6,7 @@ export const deepseekProvider: RegistryEntry = { format: "openai-responses", executor: "default", baseUrl: "https://api.deepseek.com/responses", + responsesReasoningTransport: "plaintext", authType: "apikey", authHeader: "bearer", alternateFormats: [ diff --git a/open-sse/config/providers/registry/grok-cli/index.ts b/open-sse/config/providers/registry/grok-cli/index.ts index f257f8d60a62..baeade402a07 100644 --- a/open-sse/config/providers/registry/grok-cli/index.ts +++ b/open-sse/config/providers/registry/grok-cli/index.ts @@ -14,6 +14,7 @@ export const grok_cliProvider: RegistryEntry = { // Keep the generic translate-path contract stable. GrokCliExecutor owns the // official Grok Build upstream URL and always dispatches to /v1/responses. baseUrl: "https://cli-chat-proxy.grok.com/v1/chat/completions", + responsesReasoningTransport: "opaque", modelsUrl: GROK_BUILD_MODELS_URL, clientVersion: getGrokBuildClientVersion(), authType: "oauth", diff --git a/open-sse/config/providers/registry/muse-code/index.ts b/open-sse/config/providers/registry/muse-code/index.ts index 66f59f9f4284..6054b5387b54 100644 --- a/open-sse/config/providers/registry/muse-code/index.ts +++ b/open-sse/config/providers/registry/muse-code/index.ts @@ -14,6 +14,7 @@ export const muse_codeProvider: RegistryEntry = buildOpenAiCompatibleRegistryEnt id: "muse-code", alias: "mc", passthroughModels: true, + responsesReasoningTransport: "opaque", defaultContextLength: 200000, models: [ { diff --git a/open-sse/config/providers/registry/openai/index.ts b/open-sse/config/providers/registry/openai/index.ts index 63b6a30fa37e..627121c0a85d 100644 --- a/open-sse/config/providers/registry/openai/index.ts +++ b/open-sse/config/providers/registry/openai/index.ts @@ -7,6 +7,7 @@ export const openaiProvider: RegistryEntry = { format: "openai", executor: "default", baseUrl: "https://api.openai.com/v1/chat/completions", + responsesReasoningTransport: "opaque", authType: "apikey", authHeader: "bearer", defaultContextLength: 128000, diff --git a/open-sse/config/providers/registry/xai/index.ts b/open-sse/config/providers/registry/xai/index.ts index efd0a72e3a41..625523449ee3 100644 --- a/open-sse/config/providers/registry/xai/index.ts +++ b/open-sse/config/providers/registry/xai/index.ts @@ -12,6 +12,7 @@ export const xaiProvider: RegistryEntry = { // XaiExecutor.buildUrl (open-sse/executors/xai.ts) for models tagged // targetFormat: "openai-responses" below. responsesBaseUrl: "https://api.x.ai/v1/responses", + responsesReasoningTransport: "opaque", authType: "apikey", authHeader: "bearer", models: [ @@ -54,6 +55,7 @@ export const xai_oauthProvider: RegistryEntry = { executor: "xai-oauth", baseUrl: xaiProvider.baseUrl, responsesBaseUrl: xaiProvider.responsesBaseUrl, + responsesReasoningTransport: "opaque", authType: "oauth", authHeader: xaiProvider.authHeader, passthroughModels: true, diff --git a/open-sse/config/providers/shared.ts b/open-sse/config/providers/shared.ts index 5696ecf08f6a..afdce85f0eb7 100644 --- a/open-sse/config/providers/shared.ts +++ b/open-sse/config/providers/shared.ts @@ -102,6 +102,8 @@ export interface RegistryOAuth { pollUrlBase?: string; } +export type ResponsesReasoningTransport = "plaintext" | "opaque"; + export interface RegistryEntry { id: string; alias?: string; @@ -114,6 +116,8 @@ export interface RegistryEntry { /** Override models URL used only for API key validation, not catalog discovery. */ testKeyModelsUrl?: string; responsesBaseUrl?: string; + /** Replay format accepted by this provider's OpenAI Responses endpoint. */ + responsesReasoningTransport?: ResponsesReasoningTransport; /** Anthropic-native /v1/messages endpoint (e.g. GitHub Copilot's shim) used * for models tagged `targetFormat: "claude"` on an otherwise openai-format * provider — see registry/github/index.ts. */ diff --git a/open-sse/executors/codex.ts b/open-sse/executors/codex.ts index 93e5a2fe16e2..7ec906d3c353 100644 --- a/open-sse/executors/codex.ts +++ b/open-sse/executors/codex.ts @@ -225,7 +225,6 @@ function convertSystemToDeveloperRole(body: Record): void { } } - function stripOrphanedCodexFunctionCallOutputs(body: Record): void { if (!Array.isArray(body.input)) return; const input = body.input; @@ -1381,10 +1380,11 @@ export class CodexExecutor extends BaseExecutor { delete body.session_id; delete body.conversation_id; - applyResponsesInputPolicy( - body, - credentials?.providerSpecificData?.preserveEncryptedReasoning === true - ); + applyResponsesInputPolicy(body, { + provider: "codex", + preserveEncryptedReasoning: + credentials?.providerSpecificData?.preserveEncryptedReasoning === true, + }); if (nativeCodexPassthrough) { return body; diff --git a/open-sse/handlers/chatCore.ts b/open-sse/handlers/chatCore.ts index cdd93178c97c..8f11a736ca9f 100644 --- a/open-sse/handlers/chatCore.ts +++ b/open-sse/handlers/chatCore.ts @@ -7,8 +7,14 @@ import { resolveChatCoreRequestSetup } from "./chatCore/requestSetup.ts"; import { normalizeOpenAICompatibleTools } from "./chatCore/openAICompatibleTools.ts"; import { buildFailureUsageRecord } from "./chatCore/failureUsage.ts"; import { estimateFinalInputTokens } from "./chatCore/contextEstimation.ts"; -import { extractSystemRoleMessages, relocateDirectiveOnlyMessages } from "./chatCore/claudeSystemRole.ts"; -export { extractSystemRoleMessages, relocateDirectiveOnlyMessages } from "./chatCore/claudeSystemRole.ts"; +import { + extractSystemRoleMessages, + relocateDirectiveOnlyMessages, +} from "./chatCore/claudeSystemRole.ts"; +export { + extractSystemRoleMessages, + relocateDirectiveOnlyMessages, +} from "./chatCore/claudeSystemRole.ts"; import { checkIdempotencyCache } from "./chatCore/idempotency.ts"; import { checkSemanticCache } from "./chatCore/semanticCache.ts"; import { checkLifecycle, resolveLifecycle } from "./chatCore/modelLifecyclePolicy.ts"; @@ -1116,10 +1122,18 @@ export async function handleChatCore({ } if (targetFormat === FORMATS.OPENAI_RESPONSES && body && typeof body === "object") { - applyResponsesInputPolicy( - body as Record, - credentials?.providerSpecificData?.preserveEncryptedReasoning === true - ); + const policy = applyResponsesInputPolicy(body as Record, { + provider, + preserveEncryptedReasoning: + credentials?.providerSpecificData?.preserveEncryptedReasoning === true, + }); + if (policy.incompatibleReasoning) { + trackPendingRequest(model, provider, connectionId, false); + return createErrorResult( + HTTP_STATUS.BAD_REQUEST, + "Reasoning continuation is not compatible with the selected target" + ); + } } body = sanitizeChatRequestBody(body, sourceFormat, targetFormat); diff --git a/open-sse/services/responsesInputPolicy.ts b/open-sse/services/responsesInputPolicy.ts index d80dcc7bec26..809d8eff37ba 100644 --- a/open-sse/services/responsesInputPolicy.ts +++ b/open-sse/services/responsesInputPolicy.ts @@ -1,17 +1,80 @@ +import { REGISTRY } from "../config/providerRegistry.ts"; +import type { ResponsesReasoningTransport } from "../config/providerRegistry.ts"; + type JsonRecord = Record; -const SERVER_ITEM_ID_PATTERN = /^(rs|fc|resp|msg)_/; +const REASONING_TRANSPORTS = new Map(); +for (const [id, entry] of Object.entries(REGISTRY)) { + if (!entry.responsesReasoningTransport) continue; + REASONING_TRANSPORTS.set(id.toLowerCase(), entry.responsesReasoningTransport); + if (entry.alias) { + REASONING_TRANSPORTS.set(entry.alias.toLowerCase(), entry.responsesReasoningTransport); + } +} + +export interface ResponsesInputPolicyOptions { + provider?: string | null; + preserveEncryptedReasoning?: boolean; +} + +export interface ResponsesInputPolicyResult { + incompatibleReasoning: boolean; +} + +export function resolveResponsesReasoningTransport( + provider: string | null | undefined, + preserveEncryptedReasoning = false +): ResponsesReasoningTransport | null { + const normalized = typeof provider === "string" ? provider.trim().toLowerCase() : ""; + const transport = REASONING_TRANSPORTS.get(normalized); + return transport ?? (preserveEncryptedReasoning ? "opaque" : null); +} + +export function createReasoningTransportIncompatibleError(): Error & { + statusCode: number; + errorType: string; +} { + const error = new Error( + "Reasoning continuation is not compatible with the selected target" + ) as Error & { statusCode: number; errorType: string }; + error.statusCode = 400; + error.errorType = "reasoning_transport_incompatible"; + return error; +} + +function hasPlaintextReasoning(record: JsonRecord): boolean { + return ( + Array.isArray(record.content) && + record.content.some((part) => { + const value = + part && typeof part === "object" && !Array.isArray(part) ? (part as JsonRecord) : null; + return ( + value?.type === "reasoning_text" && + typeof value.text === "string" && + value.text.trim().length > 0 + ); + }) + ); +} + +export function hasOpaqueReasoningState(record: JsonRecord): boolean { + return ( + (typeof record.encrypted_content === "string" && record.encrypted_content.trim().length > 0) || + record.signature !== undefined || + record.format !== undefined + ); +} /** - * Applies the persistence-independent policy for replayed Responses input items. - * Stored references can only be resolved by the upstream that created them, so - * they are always removed. Self-contained encrypted reasoning is retained only - * when the selected connection explicitly opts in. + * Removes Responses state that is not portable to the selected upstream. + * Plaintext reasoning and provider-generated opaque reasoning are separate + * transports; unknown targets receive neither. Retained input items are cloned + * before server IDs are removed so Combo fallback attempts cannot mutate each other. */ export function applyResponsesInputPolicy( body: Record, - preserveEncryptedReasoning = false -): void { + options: ResponsesInputPolicyOptions = {} +): ResponsesInputPolicyResult { if (Array.isArray(body.input) && body.input.length === 0) { body.input = [ { @@ -22,34 +85,60 @@ export function applyResponsesInputPolicy( ]; } - if (!Array.isArray(body.input)) return; + if (!Array.isArray(body.input)) return { incompatibleReasoning: false }; + + const transport = resolveResponsesReasoningTransport( + options.provider, + options.preserveEncryptedReasoning + ); + let incompatibleReasoning = false; + const filtered: unknown[] = []; - body.input = body.input.filter((item) => { - if (typeof item === "string" && SERVER_ITEM_ID_PATTERN.test(item)) { - return false; + for (const item of body.input) { + // Array-form Responses input contains item objects. Bare strings are stored + // provider references and are not portable across concrete targets. + if (typeof item === "string") { + continue; } const record = item && typeof item === "object" && !Array.isArray(item) ? (item as JsonRecord) : null; - if (!record) return true; + if (!record) { + filtered.push(item); + continue; + } if (record.type === "item_reference") { - return false; + continue; } - if ( - record.type === "reasoning" && - (!preserveEncryptedReasoning || - typeof record.encrypted_content !== "string" || - record.encrypted_content.trim().length === 0) - ) { - return false; + if (record.type === "reasoning") { + const plaintext = hasPlaintextReasoning(record); + const opaque = hasOpaqueReasoningState(record); + if (plaintext || opaque) { + const compatible = + (transport === "plaintext" && plaintext && !opaque) || + (transport === "opaque" && opaque && !plaintext); + if (!compatible) { + incompatibleReasoning = true; + continue; + } + if (transport === "opaque") { + filtered.push({ ...record }); + continue; + } + } else { + continue; + } } - if (typeof record.id === "string" && SERVER_ITEM_ID_PATTERN.test(record.id)) { - delete record.id; + const cloned = { ...record }; + if (typeof cloned.id === "string") { + delete cloned.id; } + filtered.push(cloned); + } - return true; - }); + body.input = filtered; + return { incompatibleReasoning }; } diff --git a/open-sse/translator/request/openai-responses.ts b/open-sse/translator/request/openai-responses.ts index df65ab5f3c67..d0cad07de1f8 100644 --- a/open-sse/translator/request/openai-responses.ts +++ b/open-sse/translator/request/openai-responses.ts @@ -8,6 +8,10 @@ import { isOpenAIResponsesStoreEnabled } from "@/lib/providers/requestDefaults"; import { FORMATS } from "../formats.ts"; import { register } from "../registry.ts"; import { normalizeResponsesInputForChat } from "../../utils/responsesInputNormalization.ts"; +import { + createReasoningTransportIncompatibleError, + hasOpaqueReasoningState, +} from "../../services/responsesInputPolicy.ts"; import { getRegisteredProviders, requiresPlainStringContent, @@ -73,10 +77,14 @@ function toolOutputContentToString(output: unknown): string { return parts.join("\n"); } -function getReasoningSummaryText(item: JsonRecord): string { - if (!Array.isArray(item.summary)) return ""; - return item.summary - .map((part) => toString(toRecord(part).text)) +function getReasoningContentText(item: JsonRecord): string { + if (hasOpaqueReasoningState(item)) return ""; + if (!Array.isArray(item.content)) return ""; + return item.content + .map((part) => { + const record = toRecord(part); + return record.type === "reasoning_text" ? toString(record.text) : ""; + }) .filter((text) => text.length > 0) .join("\n\n"); } @@ -456,10 +464,13 @@ export function openaiResponsesToOpenAIRequest( } if (itemType === "reasoning") { - // Responses reasoning summaries are normally display metadata. Preserve them only - // when the routed upstream explicitly requires prior reasoning to continue a turn. + // Only genuine plaintext reasoning can cross into Chat reasoning_content. + // Opaque encrypted state and its display summary have no Chat replay form. + if (preserveReasoningContent && hasOpaqueReasoningState(item)) { + throw createReasoningTransportIncompatibleError(); + } if (preserveReasoningContent) { - const reasoning = getReasoningSummaryText(item); + const reasoning = getReasoningContentText(item); if (reasoning) { if (currentAssistantMsg) { currentAssistantMsg.reasoning_content = appendReasoningContent( diff --git a/open-sse/translator/request/openai-responses/toResponses.ts b/open-sse/translator/request/openai-responses/toResponses.ts index f91b66f91871..1ed31a2f407c 100644 --- a/open-sse/translator/request/openai-responses/toResponses.ts +++ b/open-sse/translator/request/openai-responses/toResponses.ts @@ -4,6 +4,11 @@ * Extracted verbatim from openai-responses.ts. Registration stays in the host. */ import { isOpenAIResponsesStoreEnabled } from "@/lib/providers/requestDefaults"; +import { + createReasoningTransportIncompatibleError, + resolveResponsesReasoningTransport, +} from "../../../services/responsesInputPolicy.ts"; +import { isInternalReasoningPlaceholder } from "../../../utils/reasoningPlaceholder.ts"; import { generateToolCallId } from "../../helpers/toolCallHelper.ts"; import { JsonRecord, @@ -86,6 +91,11 @@ export function openaiToOpenAIResponsesRequest( const root = toRecord(body); const credentialRecord = toRecord(credentials); const storeEnabled = isOpenAIResponsesStoreEnabled(credentialRecord.providerSpecificData); + const providerSpecificData = toRecord(credentialRecord.providerSpecificData); + const reasoningTransport = resolveResponsesReasoningTransport( + toString(credentialRecord._provider), + providerSpecificData.preserveEncryptedReasoning === true + ); const result: JsonRecord = { model, input: [], @@ -192,12 +202,23 @@ export function openaiToOpenAIResponsesRequest( // Convert assistant messages if (role === "assistant") { - // Skip reasoning_content — OpenAI Responses API requires server-generated - // rs_* IDs for reasoning items. Synthesizing client-side IDs (e.g. reasoning_N) - // causes 400 errors from Responses-compatible upstreams. (#224) - - // Skip thinking blocks in array content — same rs_* ID constraint applies + const reasoning = + typeof msg.reasoning_content === "string" ? msg.reasoning_content.trim() : ""; + if (reasoning && !isInternalReasoningPlaceholder(reasoning)) { + if (reasoningTransport === "plaintext") { + // DeepSeek accepts genuine plaintext reasoning without a synthetic rs_* ID. + // Keep it immediately before the assistant items it belongs to. + input.push({ + type: "reasoning", + content: [{ type: "reasoning_text", text: reasoning }], + }); + } else if (Array.isArray(msg.tool_calls) || msg.function_call) { + throw createReasoningTransportIncompatibleError(); + } + } + // Thinking blocks remain display-only here. They do not prove that the + // selected target accepts their provider-specific replay representation. // Build assistant output content const outputContent: unknown[] = []; if (typeof msg.content === "string" && msg.content) { diff --git a/tests/unit/chatcore-translation-paths.test.ts b/tests/unit/chatcore-translation-paths.test.ts index c82d6987b23a..b7fc635be886 100644 --- a/tests/unit/chatcore-translation-paths.test.ts +++ b/tests/unit/chatcore-translation-paths.test.ts @@ -565,7 +565,7 @@ test("chatCore translates a streaming Responses upstream for a Chat client", asy assert.match(streamed, /"content":"ok"/); assert.match(streamed, /data: \[DONE\]/); }); -test("chatCore applies Responses input policy to openai-compatible targets", async () => { +test("chatCore rejects opaque reasoning for unknown Responses targets unless explicitly enabled", async () => { const reasoningItems = [ { id: "rs_valid", type: "reasoning", encrypted_content: "encrypted-blob" }, { type: "reasoning", encrypted_content: "" }, @@ -574,36 +574,99 @@ test("chatCore applies Responses input policy to openai-compatible targets", asy { id: "fc_call", type: "function_call", call_id: "call_1", name: "search", arguments: "{}" }, ]; - for (const preserveEncryptedReasoning of [false, true]) { - const { call, result } = await invokeChatCore({ - provider: "openai-compatible-sp-openai", - model: "gpt-5.4", - endpoint: "/v1/responses", - credentials: { - apiKey: "sk-test", - providerSpecificData: { - apiType: "responses", - baseUrl: "https://proxy.example.com/v1", - prefix: "sp-openai", - preserveEncryptedReasoning, - }, + const rejected = await invokeChatCore({ + provider: "openai-compatible-sp-openai", + model: "gpt-5.4", + endpoint: "/v1/responses", + credentials: { + apiKey: "sk-test", + providerSpecificData: { + apiType: "responses", + baseUrl: "https://proxy.example.com/v1", + prefix: "sp-openai", }, - body: { model: "gpt-5.4", stream: false, input: reasoningItems }, - responseFormat: "openai-responses", - }); + }, + body: { model: "gpt-5.4", stream: false, input: reasoningItems }, + responseFormat: "openai-responses", + }); - assert.equal(result.success, true); - const input = call.body.input as Array>; - assert.deepEqual( - input.filter((item) => item.type === "reasoning"), - preserveEncryptedReasoning ? [{ type: "reasoning", encrypted_content: "encrypted-blob" }] : [] - ); - assert.equal( - input.some((item) => item.type === "item_reference"), - false - ); - assert.equal(input.find((item) => item.type === "function_call")?.id, undefined); - } + assert.equal(rejected.result.success, false); + assert.equal(rejected.result.status, 400); + assert.equal(rejected.calls.length, 0); + + const enabled = await invokeChatCore({ + provider: "openai-compatible-sp-openai", + model: "gpt-5.4", + endpoint: "/v1/responses", + credentials: { + apiKey: "sk-test", + providerSpecificData: { + apiType: "responses", + baseUrl: "https://proxy.example.com/v1", + prefix: "sp-openai", + preserveEncryptedReasoning: true, + }, + }, + body: { model: "gpt-5.4", stream: false, input: reasoningItems }, + responseFormat: "openai-responses", + }); + + assert.equal(enabled.result.success, true); + const input = enabled.call.body.input as Array>; + assert.deepEqual( + input.filter((item) => item.type === "reasoning"), + [{ id: "rs_valid", type: "reasoning", encrypted_content: "encrypted-blob" }] + ); + assert.equal( + input.some((item) => item.type === "item_reference"), + false + ); + assert.equal(input.find((item) => item.type === "function_call")?.id, undefined); +}); + +test("chatCore carries Chat reasoning_content into official DeepSeek Responses input", async () => { + const { call, result } = await invokeChatCore({ + provider: "deepseek", + model: "deepseek-v4-pro", + endpoint: "/v1/chat/completions", + body: { + model: "deepseek-v4-pro", + stream: false, + messages: [ + { + role: "assistant", + content: null, + reasoning_content: "Inspect before calling the tool", + tool_calls: [ + { + id: "call_1", + type: "function", + function: { name: "search", arguments: "{}" }, + }, + ], + }, + { role: "tool", tool_call_id: "call_1", content: "found" }, + ], + }, + responseFormat: "openai-responses", + }); + + assert.equal(result.success, true); + assert.match(call.url, /\/responses$/); + assert.deepEqual(call.body.input.slice(0, 3), [ + { + type: "reasoning", + content: [{ type: "reasoning_text", text: "Inspect before calling the tool" }], + }, + { + type: "function_call", + call_id: "call_1", + name: "search", + arguments: "{}", + status: "completed", + }, + { type: "function_call_output", call_id: "call_1", output: "found", status: "completed" }, + ]); }); test("chatCore replays no-tool reasoning across public Responses turns", async () => { @@ -779,14 +842,14 @@ test("chatCore captures streaming no-tool reasoning for Responses replay", async assert.equal(second.result.success, true); assert.equal(second.call.body.messages[1].reasoning_content, "Authentic streaming reasoning"); }); -test("chatCore preserves opted-in encrypted reasoning for Codex", async () => { +test("chatCore automatically preserves provider-generated opaque reasoning for Codex", async () => { const { call, result } = await invokeChatCore({ provider: "codex", model: "gpt-5.1-codex", endpoint: "/v1/responses", credentials: { accessToken: "codex-token", - providerSpecificData: { preserveEncryptedReasoning: true }, + providerSpecificData: {}, }, body: { model: "gpt-5.1-codex", @@ -804,7 +867,7 @@ test("chatCore preserves opted-in encrypted reasoning for Codex", async () => { assert.equal(result.success, true); assert.deepEqual( call.body.input.filter((item) => item.type === "reasoning"), - [{ type: "reasoning", encrypted_content: "encrypted-blob" }] + [{ id: "rs_valid", type: "reasoning", encrypted_content: "encrypted-blob" }] ); assert.equal( call.body.input.some((item) => item.type === "item_reference"), diff --git a/tests/unit/combo-attempt-body-isolation-7847.test.ts b/tests/unit/combo-attempt-body-isolation-7847.test.ts index 6238baf8ff76..f06471250fb1 100644 --- a/tests/unit/combo-attempt-body-isolation-7847.test.ts +++ b/tests/unit/combo-attempt-body-isolation-7847.test.ts @@ -22,6 +22,8 @@ const ORIGINAL_DATA_DIR = process.env.DATA_DIR; process.env.DATA_DIR = TEST_DATA_DIR; const { handleComboChat } = await import("../../open-sse/services/combo.ts"); +const { applyResponsesInputPolicy } = + await import("../../open-sse/services/responsesInputPolicy.ts"); const core = await import("../../src/lib/db/core.ts"); const { resetAllComboMetrics } = await import("../../open-sse/services/comboMetrics.ts"); const { resetAllCircuitBreakers } = await import("../../src/shared/utils/circuitBreaker.ts"); @@ -164,6 +166,80 @@ test("the caller's body object is never mutated by the combo loop", async () => assert.equal(JSON.stringify(body), before, "handleComboChat must treat `body` as read-only"); }); +test("incompatible reasoning skips a target without mutating the fallback attempt", async () => { + const body = { + model: "deepseek/deepseek-v4-pro", + input: [ + { + id: "rs_plaintext", + type: "reasoning", + content: [{ type: "reasoning_text", text: "inspect first" }], + }, + { + id: "fc_shared", + type: "function_call", + call_id: "call_1", + name: "search", + arguments: "{}", + }, + ], + }; + let attempts = 0; + + const response = await handleComboChat({ + body, + combo: comboOf("priority", "reasoning-policy-isolation"), + handleSingleModel: async (received: Record) => { + attempts++; + const input = received.input as Array>; + if (attempts === 1) { + const policy = applyResponsesInputPolicy(received, { provider: "openai" }); + assert.equal(policy.incompatibleReasoning, true); + assert.equal( + (received.input as Array>).some( + (item) => item.type === "reasoning" + ), + false + ); + return new Response( + JSON.stringify({ + error: { + message: "Reasoning continuation is not compatible with the selected target", + }, + }), + { status: 400, headers: { "content-type": "application/json" } } + ); + } + + assert.equal( + input[1].id, + "fc_shared", + "the first target's nested input rewrite leaked into the fallback" + ); + const policy = applyResponsesInputPolicy(received, { provider: "deepseek" }); + assert.equal(policy.incompatibleReasoning, false); + assert.equal( + (received.input as Array>)[0].type, + "reasoning", + "the compatible fallback lost the plaintext reasoning item" + ); + return okResponse(); + }, + isModelAvailable: async () => true, + log: createLog(), + settings: null, + allCombos: null, + }); + + assert.equal(response.status, 200); + assert.ok(attempts >= 2); + assert.equal( + (body.input[1] as Record).id, + "fc_shared", + "the caller's nested input was mutated" + ); +}); + // ── The copy must stay shallow — that is the whole point ───────────────────── test("the per-target copy shares the nested payload instead of deep-cloning it", async () => { const body = agentBody(); diff --git a/tests/unit/strip-reasoning-blobs-agentic-context-1599.test.ts b/tests/unit/strip-reasoning-blobs-agentic-context-1599.test.ts index 246c0668db87..05832184ac51 100644 --- a/tests/unit/strip-reasoning-blobs-agentic-context-1599.test.ts +++ b/tests/unit/strip-reasoning-blobs-agentic-context-1599.test.ts @@ -3,16 +3,16 @@ import assert from "node:assert/strict"; import { applyResponsesInputPolicy } from "../../open-sse/services/responsesInputPolicy.ts"; import { filterToOpenAIFormat } from "../../open-sse/translator/helpers/openaiHelper.ts"; +import { omitEncryptedReasoningForLog } from "../../src/lib/logPayloads.ts"; -// Port of decolua/9router#1599 — strip unusable reasoning blobs from agentic -// context to prevent O(n^2) token growth across turns. Encrypted reasoning is -// self-contained and may be replayed only through an explicit connection opt-in. +// Responses reasoning replay is target-scoped. Plaintext DeepSeek state and +// provider-generated opaque state are never interchangeable. -test("applyResponsesInputPolicy drops object items with type=reasoning", () => { +test("unknown Responses targets drop reasoning and report incompatibility", () => { const body: Record = { input: [ { type: "message", role: "user", content: [{ type: "input_text", text: "hi" }] }, - { id: "rs_abc123", type: "reasoning", summary: [{ text: "thinking..." }] }, + { id: "rs_abc123", type: "reasoning", summary: [{ text: "display only" }] }, { type: "reasoning", encrypted_content: "blob" }, { type: "function_call", @@ -24,77 +24,142 @@ test("applyResponsesInputPolicy drops object items with type=reasoning", () => { ], }; - applyResponsesInputPolicy(body); - + const result = applyResponsesInputPolicy(body); const input = body.input as Array>; - // Both reasoning items must be gone. + + assert.equal(result.incompatibleReasoning, true); assert.equal( - input.some((it) => it && it.type === "reasoning"), - false, - "reasoning items must be stripped" + input.some((item) => item.type === "reasoning"), + false ); - // Non-reasoning items survive (message + function_call), id is sanitized. assert.equal(input.length, 2); - assert.equal(input[0].type, "message"); - assert.equal(input[1].type, "function_call"); assert.equal(input[1].id, undefined, "fc_ server id stripped, item kept"); }); -test("selected connection policy preserves encrypted reasoning input", () => { +test("DeepSeek preserves plaintext Responses reasoning without synthetic IDs", () => { const body: Record = { input: [ { - id: "rs_encrypted123", + id: "rs_plaintext123", type: "reasoning", - encrypted_content: "encrypted-blob", - summary: [{ type: "summary_text", text: "safe summary" }], + content: [{ type: "reasoning_text", text: "inspect first" }], }, { type: "message", role: "user", content: [{ type: "input_text", text: "continue" }] }, ], }; - applyResponsesInputPolicy(body, true); + const result = applyResponsesInputPolicy(body, { provider: "deepseek" }); + assert.equal(result.incompatibleReasoning, false); assert.deepEqual(body.input, [ { type: "reasoning", - encrypted_content: "encrypted-blob", - summary: [{ type: "summary_text", text: "safe summary" }], + content: [{ type: "reasoning_text", text: "inspect first" }], }, { type: "message", role: "user", content: [{ type: "input_text", text: "continue" }] }, ]); }); -test("preserving encrypted reasoning still removes stored references", () => { +test("DeepSeek rejects plaintext reasoning carrying opaque provider state", () => { + for (const opaqueField of ["signature", "format"] as const) { + const body: Record = { + input: [ + { + id: "rs_mixed123", + type: "reasoning", + content: [{ type: "reasoning_text", text: "untrusted companion" }], + [opaqueField]: "provider-state", + }, + { type: "message", role: "user", content: [{ type: "input_text", text: "continue" }] }, + ], + }; + + const result = applyResponsesInputPolicy(body, { provider: "deepseek" }); + + assert.equal(result.incompatibleReasoning, true, opaqueField); + assert.deepEqual(body.input, [ + { type: "message", role: "user", content: [{ type: "input_text", text: "continue" }] }, + ]); + } +}); + +test("known opaque targets preserve a cloned complete provider-generated reasoning item", () => { + const opaqueReasoning = { + id: "rs_encrypted123", + type: "reasoning", + encrypted_content: "encrypted-blob", + summary: [{ type: "summary_text", text: "safe summary" }], + status: "completed", + }; const body: Record = { input: [ - { id: "rs_encrypted123", type: "reasoning", encrypted_content: "encrypted-blob" }, + opaqueReasoning, "rs_stored123", { type: "item_reference", id: "resp_stored123" }, + "ws_stored123", + { type: "web_search_call", id: "ws_stored123", status: "completed" }, + { type: "image_generation_call", id: "ig_stored123", status: "completed" }, { type: "function_call", id: "fc_stored123", call_id: "call_1" }, ], }; - applyResponsesInputPolicy(body, true); + const result = applyResponsesInputPolicy(body, { provider: "openai" }); + assert.notEqual( + (body.input as Array>)[0], + opaqueReasoning, + "policy output must not expose the caller's nested item object" + ); + assert.equal(result.incompatibleReasoning, false); assert.deepEqual(body.input, [ - { type: "reasoning", encrypted_content: "encrypted-blob" }, + { + id: "rs_encrypted123", + type: "reasoning", + encrypted_content: "encrypted-blob", + summary: [{ type: "summary_text", text: "safe summary" }], + status: "completed", + }, + { type: "web_search_call", status: "completed" }, + { type: "image_generation_call", status: "completed" }, { type: "function_call", call_id: "call_1" }, ]); }); -test("applyResponsesInputPolicy still drops summary-only reasoning when enabled", () => { +test("explicit custom target opt-in remains an opaque transport override", () => { + const body: Record = { + input: [{ type: "reasoning", encrypted_content: "encrypted-blob" }], + }; + + const result = applyResponsesInputPolicy(body, { preserveEncryptedReasoning: true }); + + assert.equal(result.incompatibleReasoning, false); + assert.deepEqual(body.input, [{ type: "reasoning", encrypted_content: "encrypted-blob" }]); +}); + +test("preserved opaque reasoning remains redacted from log copies", () => { + const body = { + input: [{ type: "reasoning", encrypted_content: "provider-secret-blob" }], + }; + + applyResponsesInputPolicy(body, { provider: "xai" }); + const logged = omitEncryptedReasoningForLog(body) as typeof body; + + assert.equal(body.input[0].encrypted_content, "provider-secret-blob"); + assert.equal(logged.input[0].encrypted_content, "[omitted: encrypted reasoning, 20 chars]"); +}); + +test("summary-only reasoning is omitted for every transport", () => { const body: Record = { input: [ { id: "rs_summary123", type: "reasoning", summary: [{ text: "thinking..." }] }, { type: "reasoning", encrypted_content: "" }, - { type: "reasoning", encrypted_content: 42 }, { type: "message", role: "user", content: [{ type: "input_text", text: "hi" }] }, ], }; - applyResponsesInputPolicy(body, true); + const result = applyResponsesInputPolicy(body, { provider: "openai" }); + assert.equal(result.incompatibleReasoning, false); assert.deepEqual(body.input, [ { type: "message", role: "user", content: [{ type: "input_text", text: "hi" }] }, ]); diff --git a/tests/unit/translator-openai-responses-req.test.ts b/tests/unit/translator-openai-responses-req.test.ts index 3aa5178a0626..71a6588b9941 100644 --- a/tests/unit/translator-openai-responses-req.test.ts +++ b/tests/unit/translator-openai-responses-req.test.ts @@ -92,7 +92,7 @@ test("Responses -> Chat keeps assistant text, reasoning, and function calls in o input: [ { type: "reasoning", - summary: [{ type: "summary_text", text: "Inspect first" }], + content: [{ type: "reasoning_text", text: "Inspect first" }], }, { type: "message", @@ -133,6 +133,50 @@ test("Responses -> Chat keeps assistant text, reasoning, and function calls in o }); }); +test("Responses -> Chat replays plaintext reasoning_text instead of a display summary", () => { + const result = openaiResponsesToOpenAIRequest( + "deepseek-v4-pro", + { + input: [ + { + type: "reasoning", + content: [{ type: "reasoning_text", text: "Use the indexed result" }], + summary: [{ type: "summary_text", text: "Display summary" }], + }, + { type: "function_call", call_id: "call_1", name: "search", arguments: "{}" }, + ], + }, + false, + { _preserveReasoningContent: true } + ) as { messages: Array> }; + + assert.equal(result.messages[0].reasoning_content, "Use the indexed result"); +}); + +test("Responses -> Chat rejects opaque reasoning instead of replaying its plaintext companion", () => { + assert.throws( + () => + openaiResponsesToOpenAIRequest( + "deepseek-v4-pro", + { + input: [ + { + id: "rs_opaque", + type: "reasoning", + encrypted_content: "opaque-provider-state", + content: [{ type: "reasoning_text", text: "Untrusted plaintext companion" }], + summary: [{ type: "summary_text", text: "Display summary" }], + }, + { type: "function_call", call_id: "call_1", name: "search", arguments: "{}" }, + ], + }, + false, + { _preserveReasoningContent: true } + ), + /Reasoning continuation is not compatible/ + ); +}); + test("Responses -> Chat merges assistant text that follows a function call", () => { const result = openaiResponsesToOpenAIRequest( "gpt-4o", @@ -146,7 +190,7 @@ test("Responses -> Chat merges assistant text that follows a function call", () }, { type: "reasoning", - summary: [{ type: "summary_text", text: "Inspection complete" }], + content: [{ type: "reasoning_text", text: "Inspection complete" }], }, { type: "message", role: "user", content: [{ type: "input_text", text: "Continue" }] }, ], @@ -384,6 +428,101 @@ test("Chat -> Responses clamps call_id to 64 chars and keeps the pair matched (p ); }); +test("Chat -> DeepSeek Responses emits ID-less plaintext reasoning before its function call", () => { + const result = openaiToOpenAIResponsesRequest( + "deepseek-v4-pro", + { + messages: [ + { + role: "assistant", + content: null, + reasoning_content: "Inspect the repository first", + tool_calls: [ + { + id: "call_1", + type: "function", + function: { name: "search", arguments: "{}" }, + }, + ], + }, + { role: "tool", tool_call_id: "call_1", content: "found" }, + ], + }, + false, + { _provider: "deepseek" } + ) as { input: Array> }; + + assert.deepEqual(result.input, [ + { + type: "reasoning", + content: [{ type: "reasoning_text", text: "Inspect the repository first" }], + }, + { + type: "function_call", + call_id: "call_1", + name: "search", + arguments: "{}", + status: "completed", + }, + { type: "function_call_output", call_id: "call_1", output: "found", status: "completed" }, + ]); +}); + +test("Chat -> Responses rejects tool continuation for an incompatible target", () => { + assert.throws( + () => + openaiToOpenAIResponsesRequest( + "gpt-5.4-pro", + { + messages: [ + { + role: "assistant", + reasoning_content: "Provider plaintext", + tool_calls: [ + { + id: "call_1", + type: "function", + function: { name: "search", arguments: "{}" }, + }, + ], + }, + ], + }, + false, + { _provider: "openai" } + ), + /Reasoning continuation is not compatible/ + ); +}); + +test("Chat -> Responses never promotes OmniRoute's internal reasoning placeholder", () => { + const result = openaiToOpenAIResponsesRequest( + "deepseek-v4-pro", + { + messages: [ + { + role: "assistant", + reasoning_content: "(prior reasoning summary unavailable)", + tool_calls: [ + { + id: "call_1", + type: "function", + function: { name: "search", arguments: "{}" }, + }, + ], + }, + ], + }, + false, + { _provider: "deepseek" } + ) as { input: Array> }; + + assert.equal( + result.input.some((item) => item.type === "reasoning"), + false + ); +}); + test("Chat -> Responses converts messages, tool calls, tool outputs, tools and pass-through params", () => { const result = openaiToOpenAIResponsesRequest( "gpt-4o", From e7008498d8913b408c71c6c15e88b6a417a74ab6 Mon Sep 17 00:00:00 2001 From: jackjinke Date: Mon, 17 Aug 2026 10:27:52 +0800 Subject: [PATCH 02/10] fix(providers): hide redundant reasoning toggle --- .../10550-responses-reasoning-transport.md | 2 +- .../components/modals/EditConnectionModal.tsx | 11 +++++--- ...edit-connection-modal-free-models.test.tsx | 25 ++++++++----------- 3 files changed, 20 insertions(+), 18 deletions(-) diff --git a/changelog.d/fixes/10550-responses-reasoning-transport.md b/changelog.d/fixes/10550-responses-reasoning-transport.md index 8c2d4cf2e770..bff232c2e003 100644 --- a/changelog.d/fixes/10550-responses-reasoning-transport.md +++ b/changelog.d/fixes/10550-responses-reasoning-transport.md @@ -1 +1 @@ -- Preserve target-compatible reasoning across Chat Completions and Responses routes, including DeepSeek plaintext continuations and provider-generated opaque Responses state, without replaying unsupported reasoning into fallback targets. (#10550) +- Preserve target-compatible reasoning across Chat Completions and Responses routes, including DeepSeek plaintext continuations and provider-generated opaque Responses state, without replaying unsupported reasoning into fallback targets or showing redundant encrypted-reasoning controls for providers handled automatically. (#10550) diff --git a/src/app/(dashboard)/dashboard/providers/[id]/components/modals/EditConnectionModal.tsx b/src/app/(dashboard)/dashboard/providers/[id]/components/modals/EditConnectionModal.tsx index 4c33d2882a2e..39028de1367f 100644 --- a/src/app/(dashboard)/dashboard/providers/[id]/components/modals/EditConnectionModal.tsx +++ b/src/app/(dashboard)/dashboard/providers/[id]/components/modals/EditConnectionModal.tsx @@ -208,6 +208,7 @@ export default function EditConnectionModal({ (provider.startsWith("openai-compatible-responses-") || connectionProviderSpecificData?.apiType === "responses" || formData.targetFormat === "openai-responses")); + const isCustomResponsesConnection = isResponsesConnection && !isCodex && provider !== "openai"; const isClaude = provider === "claude"; const isAntigravityFamily = provider === "antigravity" || provider === "agy"; const localProviderMetadata = getLocalProviderMetadata(provider); @@ -667,8 +668,12 @@ export default function EditConnectionModal({ } } if (isResponsesConnection && updates.providerSpecificData) { - updates.providerSpecificData.preserveEncryptedReasoning = - formData.preserveEncryptedReasoning === true; + if (isCustomResponsesConnection) { + updates.providerSpecificData.preserveEncryptedReasoning = + formData.preserveEncryptedReasoning === true; + } else { + delete updates.providerSpecificData.preserveEncryptedReasoning; + } updates.providerSpecificData.openaiStoreEnabled = formData.openaiResponsesStoreEnabled === true; } @@ -701,7 +706,7 @@ export default function EditConnectionModal({ !testResult?.valid && testResult?.diagnosis?.type ? ERROR_TYPE_LABELS[testResult.diagnosis.type] || null : null; - const preserveEncryptedReasoningToggle = isResponsesConnection ? ( + const preserveEncryptedReasoningToggle = isCustomResponsesConnection ? ( setFormData({ ...formData, preserveEncryptedReasoning: checked })} diff --git a/tests/unit/ui/edit-connection-modal-free-models.test.tsx b/tests/unit/ui/edit-connection-modal-free-models.test.tsx index 0e7abd35c3a1..9445443a53e1 100644 --- a/tests/unit/ui/edit-connection-modal-free-models.test.tsx +++ b/tests/unit/ui/edit-connection-modal-free-models.test.tsx @@ -195,7 +195,7 @@ describe("EditConnectionModal — encrypted Responses reasoning", () => { expect(onSave.mock.calls[0][0].providerSpecificData?.preserveEncryptedReasoning).toBe(false); }); - it("defaults off and persists an opt-in for first-party OpenAI", async () => { + it("hides the redundant opt-in for first-party OpenAI and removes stale state on save", async () => { const onSave = vi.fn().mockResolvedValue(undefined); const el = render({ providerId: "openai", @@ -203,19 +203,19 @@ describe("EditConnectionModal — encrypted Responses reasoning", () => { id: "conn-openai", provider: "openai", authType: "apikey", - providerSpecificData: {}, + providerSpecificData: { preserveEncryptedReasoning: true }, }, onSave, }); - const toggle = el.querySelector(PRESERVE_TOGGLE)!; - expect(toggle.getAttribute("aria-checked")).toBe("false"); - act(() => toggle.dispatchEvent(new MouseEvent("click", { bubbles: true }))); + expect(el.querySelector(PRESERVE_TOGGLE)).toBeNull(); const saveBtn = Array.from(el.querySelectorAll("button")).find( (button) => button.textContent?.trim() === "save" )!; act(() => saveBtn.dispatchEvent(new MouseEvent("click", { bubbles: true }))); await waitFor(() => onSave.mock.calls.length > 0); - expect(onSave.mock.calls[0][0].providerSpecificData?.preserveEncryptedReasoning).toBe(true); + expect(onSave.mock.calls[0][0].providerSpecificData).not.toHaveProperty( + "preserveEncryptedReasoning" + ); }); it("is absent for a chat-only compatible connection", () => { @@ -244,7 +244,7 @@ describe("EditConnectionModal — encrypted Responses reasoning", () => { expect(el.querySelector(PRESERVE_TOGGLE)?.getAttribute("aria-checked")).toBe("false"); }); - it("keeps Codex controls and persists the opt-in on its OAuth save path", async () => { + it("hides the redundant opt-in for Codex while keeping its effective controls", async () => { const onSave = vi.fn().mockResolvedValue(undefined); const el = render({ providerId: "codex", @@ -256,22 +256,19 @@ describe("EditConnectionModal — encrypted Responses reasoning", () => { }, onSave, }); - expect(el.querySelector(PRESERVE_TOGGLE)?.getAttribute("aria-checked")).toBe("true"); + expect(el.querySelector(PRESERVE_TOGGLE)).toBeNull(); expect(el.textContent).toContain("defaultThinkingStrengthLabel"); expect( el.querySelector('button[role="switch"][aria-label="openaiResponsesStoreLabel"]') ).toBeTruthy(); - const cooldownToggle = el.querySelector( - 'button[role="switch"][aria-label="disableCoolingLabel"]' - )!; - const reasoningToggle = el.querySelector(PRESERVE_TOGGLE)!; - expect(reasoningToggle.parentElement?.nextElementSibling).toBe(cooldownToggle.parentElement); const saveBtn = Array.from(el.querySelectorAll("button")).find( (button) => button.textContent?.trim() === "save" )!; act(() => saveBtn.dispatchEvent(new MouseEvent("click", { bubbles: true }))); await waitFor(() => onSave.mock.calls.length > 0); - expect(onSave.mock.calls[0][0].providerSpecificData?.preserveEncryptedReasoning).toBe(true); + expect(onSave.mock.calls[0][0].providerSpecificData).not.toHaveProperty( + "preserveEncryptedReasoning" + ); }); }); From 0e95e5a1e609f30e6b4bebdd5fd04d56de0aa314 Mon Sep 17 00:00:00 2001 From: jackjinke Date: Mon, 17 Aug 2026 12:08:28 +0800 Subject: [PATCH 03/10] fix(combos): add reasoning transport fallback --- .../10550-responses-reasoning-transport.md | 2 +- open-sse/handlers/chatCore.ts | 2 + open-sse/services/responsesInputPolicy.ts | 28 ++++++- src/app/(dashboard)/dashboard/combos/page.tsx | 54 ++++++++++++ src/shared/validation/schemas/combo.ts | 1 + src/sse/handlers/chat.ts | 10 ++- src/sse/handlers/chatHelpers.ts | 2 + tests/unit/chat-route-coverage.test.ts | 82 +++++++++++++++++++ tests/unit/chatcore-translation-paths.test.ts | 36 ++++++++ tests/unit/combo-config.test.ts | 18 ++++ ...asoning-blobs-agentic-context-1599.test.ts | 55 +++++++++++++ 11 files changed, 286 insertions(+), 4 deletions(-) diff --git a/changelog.d/fixes/10550-responses-reasoning-transport.md b/changelog.d/fixes/10550-responses-reasoning-transport.md index bff232c2e003..60c0326d6bca 100644 --- a/changelog.d/fixes/10550-responses-reasoning-transport.md +++ b/changelog.d/fixes/10550-responses-reasoning-transport.md @@ -1 +1 @@ -- Preserve target-compatible reasoning across Chat Completions and Responses routes, including DeepSeek plaintext continuations and provider-generated opaque Responses state, without replaying unsupported reasoning into fallback targets or showing redundant encrypted-reasoning controls for providers handled automatically. (#10550) +- Preserve target-compatible reasoning across Chat Completions and Responses routes, including DeepSeek plaintext continuations and provider-generated opaque Responses state, without replaying unsupported reasoning into fallback targets. Combos can optionally drop incompatible continuation reasoning and try the selected target, while known providers no longer show redundant encrypted-reasoning controls. (#10550) diff --git a/open-sse/handlers/chatCore.ts b/open-sse/handlers/chatCore.ts index 8f11a736ca9f..7ea11b60b552 100644 --- a/open-sse/handlers/chatCore.ts +++ b/open-sse/handlers/chatCore.ts @@ -465,6 +465,7 @@ export async function handleChatCore({ correlationId = null, modelPinned = false, skipResourcePressureGuard = false, + reasoningTransportFallback = "skip", }) { let { provider, model, extendedContext } = modelInfo; if (!skipResourcePressureGuard) { @@ -1126,6 +1127,7 @@ export async function handleChatCore({ provider, preserveEncryptedReasoning: credentials?.providerSpecificData?.preserveEncryptedReasoning === true, + onIncompatibleReasoning: reasoningTransportFallback === "drop" ? "drop" : "reject", }); if (policy.incompatibleReasoning) { trackPendingRequest(model, provider, connectionId, false); diff --git a/open-sse/services/responsesInputPolicy.ts b/open-sse/services/responsesInputPolicy.ts index 809d8eff37ba..974ce2debb23 100644 --- a/open-sse/services/responsesInputPolicy.ts +++ b/open-sse/services/responsesInputPolicy.ts @@ -15,6 +15,7 @@ for (const [id, entry] of Object.entries(REGISTRY)) { export interface ResponsesInputPolicyOptions { provider?: string | null; preserveEncryptedReasoning?: boolean; + onIncompatibleReasoning?: "reject" | "drop"; } export interface ResponsesInputPolicyResult { @@ -65,11 +66,26 @@ export function hasOpaqueReasoningState(record: JsonRecord): boolean { ); } +function isIncompatibleReasoningRecord( + record: JsonRecord, + transport: ResponsesReasoningTransport | null +): boolean { + if (record.type !== "reasoning") return false; + const plaintext = hasPlaintextReasoning(record); + const opaque = hasOpaqueReasoningState(record); + if (!plaintext && !opaque) return false; + return !( + (transport === "plaintext" && plaintext && !opaque) || + (transport === "opaque" && opaque && !plaintext) + ); +} + /** * Removes Responses state that is not portable to the selected upstream. * Plaintext reasoning and provider-generated opaque reasoning are separate - * transports; unknown targets receive neither. Retained input items are cloned - * before server IDs are removed so Combo fallback attempts cannot mutate each other. + * transports; unknown targets receive neither. The optional drop action removes + * all reasoning when any active item is incompatible. Retained items are cloned + * so Combo attempts cannot mutate each other's fallback input. */ export function applyResponsesInputPolicy( body: Record, @@ -91,6 +107,13 @@ export function applyResponsesInputPolicy( options.provider, options.preserveEncryptedReasoning ); + const dropAllReasoning = + options.onIncompatibleReasoning === "drop" && + body.input.some((item) => { + const record = + item && typeof item === "object" && !Array.isArray(item) ? (item as JsonRecord) : null; + return record ? isIncompatibleReasoningRecord(record, transport) : false; + }); let incompatibleReasoning = false; const filtered: unknown[] = []; @@ -113,6 +136,7 @@ export function applyResponsesInputPolicy( } if (record.type === "reasoning") { + if (dropAllReasoning) continue; const plaintext = hasPlaintextReasoning(record); const opaque = hasOpaqueReasoningState(record); if (plaintext || opaque) { diff --git a/src/app/(dashboard)/dashboard/combos/page.tsx b/src/app/(dashboard)/dashboard/combos/page.tsx index 8d3615131914..ea876dada173 100644 --- a/src/app/(dashboard)/dashboard/combos/page.tsx +++ b/src/app/(dashboard)/dashboard/combos/page.tsx @@ -188,6 +188,8 @@ const ADVANCED_FIELD_HELP_FALLBACK = { "Delay between set-level retry attempts, giving transient issues time to resolve.", nestedComboMode: "How references to other combos are handled. Flatten expands a combo ref into this combo's target list (legacy). Execute treats a combo ref as a black-box target: the parent strategy selects the child combo, then the child runs its own strategy and retries.", + reasoningTransportFallback: + "What to do when the next combo target cannot accept the original reasoning transport. Skip leaves the request body untouched and falls through to the next target; Drop removes reasoning state and tries the target anyway.", }; const LEGACY_COMBO_RESILIENCE_KEYS = new Set([ @@ -3796,6 +3798,58 @@ function ComboFormModal({ isOpen, combo, onClose, onSave, activeProviders, combo
+
+ + + {config.reasoningTransportFallback === "drop" && ( +

+ {getI18nOrFallback( + t, + "reasoningTransportFallbackDropWarning", + "May lose continuation context or cause tool-call continuations to fail." + )} +

+ )} +
{ let tlsFingerprintUsed = false; @@ -478,6 +479,7 @@ export async function executeChatWithBreaker({ modelPinned, routingComboId, sessionAffinityKey, + reasoningTransportFallback, skipResourcePressureGuard: true, onCredentialsRefreshed: async (newCreds: any) => { await updateProviderCredentials(credentials.connectionId, { diff --git a/tests/unit/chat-route-coverage.test.ts b/tests/unit/chat-route-coverage.test.ts index e0b71badc966..6595b91d525a 100644 --- a/tests/unit/chat-route-coverage.test.ts +++ b/tests/unit/chat-route-coverage.test.ts @@ -316,6 +316,88 @@ test("handleChat keeps protected combo fallback separate from Global Fallback Mo assert.equal(json.choices[0].message.content, "Global fallback answered"); }); +test("handleChat applies a Combo's drop fallback to incompatible reasoning transport", async () => { + await seedConnection("deepseek", { apiKey: "sk-deepseek-reasoning-drop" }); + await combosDb.createCombo({ + name: "reasoning-transport-drop", + strategy: "priority", + config: { + maxRetries: 0, + retryDelayMs: 0, + reasoningTransportFallback: "drop", + }, + models: ["deepseek/deepseek-v4-flash"], + }); + + let upstreamBody: { input?: unknown } | null = null; + globalThis.fetch = async (_url, init = {}) => { + upstreamBody = JSON.parse(String(init.body)); + return new Response( + JSON.stringify({ + id: "resp_reasoning_drop", + object: "response", + status: "completed", + model: "deepseek-v4-flash", + output: [ + { + id: "msg_reasoning_drop", + type: "message", + role: "assistant", + content: [ + { + type: "output_text", + text: "continued without prior reasoning", + annotations: [], + }, + ], + }, + ], + usage: { input_tokens: 1, output_tokens: 1, total_tokens: 2 }, + }), + { status: 200, headers: { "Content-Type": "application/json" } } + ); + }; + + const response = await handleChat( + buildRequest({ + url: "http://localhost/v1/responses", + body: { + model: "reasoning-transport-drop", + stream: false, + input: [ + { id: "rs_opaque", type: "reasoning", encrypted_content: "provider-state" }, + { + id: "fc_call", + type: "function_call", + call_id: "call_1", + name: "search", + arguments: "{}", + }, + { type: "function_call_output", call_id: "call_1", output: "done" }, + ], + }, + }) + ); + + assert.equal(response.status, 200); + assert.ok(upstreamBody && Array.isArray(upstreamBody.input)); + const upstreamInput = upstreamBody.input; + assert.equal( + upstreamInput.some( + (item) => + item !== null && typeof item === "object" && "type" in item && item.type === "reasoning" + ), + false + ); + assert.equal( + upstreamInput.some( + (item) => + item !== null && typeof item === "object" && "type" in item && item.type === "function_call" + ), + true + ); +}); + test("handleChat keeps the combo error when the global fallback throws", async () => { await seedConnection("openai", { apiKey: "sk-openai-combo-fail" }); await seedConnection("claude", { apiKey: "sk-claude-fallback-throw" }); diff --git a/tests/unit/chatcore-translation-paths.test.ts b/tests/unit/chatcore-translation-paths.test.ts index b7fc635be886..0d9d86b11dca 100644 --- a/tests/unit/chatcore-translation-paths.test.ts +++ b/tests/unit/chatcore-translation-paths.test.ts @@ -309,6 +309,7 @@ async function invokeChatCore({ onCredentialsRefreshed = null, onRequestSuccess = null, sessionAffinityKey = null, + reasoningTransportFallback = "skip", }: any = {}) { const calls: any[] = []; @@ -355,6 +356,7 @@ async function invokeChatCore({ sessionAffinityKey, isCombo, comboStrategy, + reasoningTransportFallback, onCredentialsRefreshed, onRequestSuccess, } as any); @@ -624,6 +626,40 @@ test("chatCore rejects opaque reasoning for unknown Responses targets unless exp assert.equal(input.find((item) => item.type === "function_call")?.id, undefined); }); +test("chatCore can drop incompatible reasoning for an opted-in Combo attempt", async () => { + const dropped = await invokeChatCore({ + provider: "openai-compatible-sp-openai", + model: "gpt-5.4", + endpoint: "/v1/responses", + credentials: { + apiKey: "sk-test", + providerSpecificData: { + apiType: "responses", + baseUrl: "https://proxy.example.com/v1", + prefix: "sp-openai", + }, + }, + body: { + model: "gpt-5.4", + stream: false, + input: [ + { id: "rs_valid", type: "reasoning", encrypted_content: "encrypted-blob" }, + { type: "message", role: "user", content: [{ type: "input_text", text: "continue" }] }, + ], + }, + responseFormat: "openai-responses", + isCombo: true, + reasoningTransportFallback: "drop", + }); + + assert.equal(dropped.result.success, true); + assert.equal(dropped.calls.length, 1); + assert.equal( + dropped.call.body.input.some((item) => item.type === "reasoning"), + false + ); +}); + test("chatCore carries Chat reasoning_content into official DeepSeek Responses input", async () => { const { call, result } = await invokeChatCore({ provider: "deepseek", diff --git a/tests/unit/combo-config.test.ts b/tests/unit/combo-config.test.ts index 61fe705d0a9f..d09ecf6999fd 100644 --- a/tests/unit/combo-config.test.ts +++ b/tests/unit/combo-config.test.ts @@ -647,6 +647,24 @@ test("createComboSchema accepts nestedComboMode and rejects invalid values", () assert.equal(invalid.success, false); }); +test("createComboSchema validates reasoning transport fallback modes", () => { + for (const mode of ["skip", "drop"] as const) { + const parsed = createComboSchema.parse({ + name: `reasoning-transport-${mode}`, + models: ["openai/gpt-5.4"], + config: { reasoningTransportFallback: mode }, + }); + assert.equal(parsed.config.reasoningTransportFallback, mode); + } + + const invalid = createComboSchema.safeParse({ + name: "reasoning-transport-invalid", + models: ["openai/gpt-5.4"], + config: { reasoningTransportFallback: "retry" }, + }); + assert.equal(invalid.success, false); +}); + test("createComboSchema accepts per-combo stickyRoundRobinLimit and rejects out-of-range", () => { const parsed = createComboSchema.parse({ name: "sticky-override", diff --git a/tests/unit/strip-reasoning-blobs-agentic-context-1599.test.ts b/tests/unit/strip-reasoning-blobs-agentic-context-1599.test.ts index 05832184ac51..bd50b87689c3 100644 --- a/tests/unit/strip-reasoning-blobs-agentic-context-1599.test.ts +++ b/tests/unit/strip-reasoning-blobs-agentic-context-1599.test.ts @@ -83,6 +83,61 @@ test("DeepSeek rejects plaintext reasoning carrying opaque provider state", () = } }); +test("drop fallback removes all reasoning when any active transport is incompatible", () => { + const plaintextReasoning = { + id: "rs_plaintext", + type: "reasoning", + content: [{ type: "reasoning_text", text: "inspect first" }], + }; + const opaqueReasoning = { + id: "rs_opaque", + type: "reasoning", + encrypted_content: "provider-state", + }; + const body: Record = { + input: [ + plaintextReasoning, + opaqueReasoning, + { id: "fc_call", type: "function_call", call_id: "call_1", name: "search" }, + ], + }; + + const result = applyResponsesInputPolicy(body, { + provider: "deepseek", + onIncompatibleReasoning: "drop", + }); + + assert.equal(result.incompatibleReasoning, false); + assert.deepEqual(body.input, [{ type: "function_call", call_id: "call_1", name: "search" }]); + assert.equal(plaintextReasoning.id, "rs_plaintext"); + assert.equal(opaqueReasoning.id, "rs_opaque"); +}); + +test("drop fallback preserves reasoning when its transport is compatible", () => { + const body: Record = { + input: [ + { + id: "rs_plaintext", + type: "reasoning", + content: [{ type: "reasoning_text", text: "inspect first" }], + }, + ], + }; + + const result = applyResponsesInputPolicy(body, { + provider: "deepseek", + onIncompatibleReasoning: "drop", + }); + + assert.equal(result.incompatibleReasoning, false); + assert.deepEqual(body.input, [ + { + type: "reasoning", + content: [{ type: "reasoning_text", text: "inspect first" }], + }, + ]); +}); + test("known opaque targets preserve a cloned complete provider-generated reasoning item", () => { const opaqueReasoning = { id: "rs_encrypted123", From c7dedae2c37f684bff69a5b2de3095a1e22efa32 Mon Sep 17 00:00:00 2001 From: jackjinke Date: Mon, 17 Aug 2026 12:40:13 +0800 Subject: [PATCH 04/10] fix(reasoning): preserve authentic plaintext continuations --- .../translator/helpers/responsesApiHelper.ts | 17 ++++++----- open-sse/utils/reasoningContentInjector.ts | 28 ++++++++++++++----- tests/unit/kimi-coding-translator.test.ts | 8 +++--- tests/unit/moonshot-k3.test.ts | 2 +- tests/unit/reasoning-cache.test.ts | 2 +- .../unit/responses-translation-fixes.test.ts | 4 +-- 6 files changed, 39 insertions(+), 22 deletions(-) diff --git a/open-sse/translator/helpers/responsesApiHelper.ts b/open-sse/translator/helpers/responsesApiHelper.ts index 625daf174f02..a6c0ea29d565 100644 --- a/open-sse/translator/helpers/responsesApiHelper.ts +++ b/open-sse/translator/helpers/responsesApiHelper.ts @@ -3,6 +3,7 @@ * Delegates to the canonical translator to avoid logic duplication. */ import { requiresReasoningReplay } from "../../services/reasoningCache.ts"; +import { requiresAuthenticReasoningContent } from "../../utils/reasoningContentInjector.ts"; import { openaiResponsesToOpenAIRequest } from "../request/openai-responses.ts"; import { toRecord } from "../request/openai-responses/helpers.ts"; @@ -23,13 +24,15 @@ export function convertResponsesApiFormat( credentials && typeof credentials === "object" && !Array.isArray(credentials) ? (credentials as Record) : {}; - const translationCredentials = requiresReasoningReplay({ - provider: String(provider ?? ""), - model: String(model ?? ""), - allowLegacyFallback: false, - }) - ? { ...credentialRecord, _preserveReasoningContent: true } - : credentials; + const translationCredentials = + requiresAuthenticReasoningContent(provider, model) || + requiresReasoningReplay({ + provider: String(provider ?? ""), + model: String(model ?? ""), + allowLegacyFallback: false, + }) + ? { ...credentialRecord, _preserveReasoningContent: true } + : credentials; const converted = openaiResponsesToOpenAIRequest( requestedModel, body, diff --git a/open-sse/utils/reasoningContentInjector.ts b/open-sse/utils/reasoningContentInjector.ts index 375057cf7791..a1d69ebf6df6 100644 --- a/open-sse/utils/reasoningContentInjector.ts +++ b/open-sse/utils/reasoningContentInjector.ts @@ -13,8 +13,6 @@ * that proxy to thinking-mode models. */ -import { requiresReasoningReplay } from "../services/reasoningCache.ts"; - const PLACEHOLDER = " "; type JsonRecord = Record; @@ -31,6 +29,26 @@ const THINKING_MODEL_PATTERNS: RegExp[] = [ /\bminimax\b/i, /\bmimo\b/i, // xiaomi-tokenplan mimo family (e.g. xiaomi-tokenplan/mimo-v2.5-pro) ]; +const K3_AUTHENTIC_REASONING_PATTERN = /(?:^|\/)(?:kimi-)?k3(?:$|-)/i; +const NATIVE_K27_AUTHENTIC_REASONING_PATTERN = /(?:^|\/)kimi-k2\.7-code(?:$|-)/i; + +/** + * K3 requires authentic reasoning regardless of which provider serves it. + * Native Moonshot K2.7 retains the same preserved-thinking contract. Empty + * protocol markers remain valid only after client content and replay miss. + */ +export function requiresAuthenticReasoningContent(provider: unknown, model: unknown): boolean { + const normalizedModel = String(model ?? "").trim(); + if (K3_AUTHENTIC_REASONING_PATTERN.test(normalizedModel)) return true; + + const normalizedProvider = String(provider ?? "") + .trim() + .toLowerCase(); + return ( + (normalizedProvider === "moonshot" || normalizedProvider === "kimi") && + NATIVE_K27_AUTHENTIC_REASONING_PATTERN.test(normalizedModel) + ); +} export function isThinkingMessageModel(model: string | undefined | null): boolean { if (!model || typeof model !== "string") return false; @@ -46,11 +64,7 @@ export function shouldInjectReasoningContentPlaceholder( .toLowerCase(); return ( (normalizedProvider === "moonshot" || normalizedProvider === "kimi") && - !requiresReasoningReplay({ - provider: normalizedProvider, - model: String(model ?? ""), - allowLegacyFallback: false, - }) && + !requiresAuthenticReasoningContent(normalizedProvider, model) && isThinkingMessageModel(model) ); } diff --git a/tests/unit/kimi-coding-translator.test.ts b/tests/unit/kimi-coding-translator.test.ts index f1ec07421b1e..42cd01766316 100644 --- a/tests/unit/kimi-coding-translator.test.ts +++ b/tests/unit/kimi-coding-translator.test.ts @@ -97,7 +97,7 @@ test("Responses history preserves Kimi reasoning before a tool call", () => { { id: "rs_1", type: "reasoning", - summary: [{ type: "summary_text", text: "I should call ping first." }], + content: [{ type: "reasoning_text", text: "I should call ping first." }], }, { id: "fc_1", @@ -147,7 +147,7 @@ test("Responses history preserves Kimi reasoning on completed assistant turns", { id: "rs_1", type: "reasoning", - summary: [{ type: "summary_text", text: "I should retain the nonce." }], + content: [{ type: "reasoning_text", text: "I should retain the nonce." }], }, { type: "message", @@ -182,7 +182,7 @@ test("Responses history preserves Kimi reasoning before a custom tool call", () input: [ { type: "reasoning", - summary: [{ type: "summary_text", text: "I should apply the patch." }], + content: [{ type: "reasoning_text", text: "I should apply the patch." }], }, { type: "custom_tool_call", @@ -223,7 +223,7 @@ test("Responses history does not carry reasoning across a user boundary", () => }, { type: "reasoning", - summary: [{ type: "summary_text", text: "Prior turn reasoning." }], + content: [{ type: "reasoning_text", text: "Prior turn reasoning." }], }, { role: "user", diff --git a/tests/unit/moonshot-k3.test.ts b/tests/unit/moonshot-k3.test.ts index d85b67855beb..ec772442403d 100644 --- a/tests/unit/moonshot-k3.test.ts +++ b/tests/unit/moonshot-k3.test.ts @@ -202,7 +202,7 @@ test("Responses history preserves authentic reasoning for native Moonshot K3", ( }, { type: "reasoning", - summary: [{ type: "summary_text", text: "I should search first." }], + content: [{ type: "reasoning_text", text: "I should search first." }], }, { type: "function_call", diff --git a/tests/unit/reasoning-cache.test.ts b/tests/unit/reasoning-cache.test.ts index 9c144596b406..96edde215faa 100644 --- a/tests/unit/reasoning-cache.test.ts +++ b/tests/unit/reasoning-cache.test.ts @@ -645,7 +645,7 @@ describe("Reasoning Replay Cache — Translator Replay", () => { input: [ { type: "reasoning", - summary: [{ type: "summary_text", text: "Client DeepSeek reasoning" }], + content: [{ type: "reasoning_text", text: "Client DeepSeek reasoning" }], }, { type: "message", diff --git a/tests/unit/responses-translation-fixes.test.ts b/tests/unit/responses-translation-fixes.test.ts index 1f3fec359a50..66807e11ec1e 100644 --- a/tests/unit/responses-translation-fixes.test.ts +++ b/tests/unit/responses-translation-fixes.test.ts @@ -45,7 +45,7 @@ test("production Responses conversion preserves Kimi K3 reasoning history", () = { role: "user", content: [{ type: "input_text", text: "Call search." }] }, { type: "reasoning", - summary: [{ type: "summary_text", text: "I should search first." }], + content: [{ type: "reasoning_text", text: "I should search first." }], }, { type: "function_call", @@ -87,7 +87,7 @@ test("Responses translation keeps authentic K3 reasoning through OpenAI cleanup" { role: "user", content: [{ type: "input_text", text: "Call search." }] }, { type: "reasoning", - summary: [{ type: "summary_text", text: "I should search first." }], + content: [{ type: "reasoning_text", text: "I should search first." }], }, { type: "function_call", From ce9b3ca05e631436b6fac0c8e0232ab18072af50 Mon Sep 17 00:00:00 2001 From: jackjinke Date: Mon, 17 Aug 2026 13:00:42 +0800 Subject: [PATCH 05/10] test(reasoning): use authentic handler fixture --- tests/unit/responses-handler.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/unit/responses-handler.test.ts b/tests/unit/responses-handler.test.ts index 0ec6adf4489e..7afbe12d318b 100644 --- a/tests/unit/responses-handler.test.ts +++ b/tests/unit/responses-handler.test.ts @@ -233,7 +233,7 @@ test("handleResponsesCore preserves Kimi K3 reasoning through provider translati { role: "user", content: [{ type: "input_text", text: "Call search." }] }, { type: "reasoning", - summary: [{ type: "summary_text", text: "I should search first." }], + content: [{ type: "reasoning_text", text: "I should search first." }], }, { type: "function_call", From 4265956ccdc8030eb5c428f24d47a67b0d0d83a2 Mon Sep 17 00:00:00 2001 From: jackjinke Date: Mon, 17 Aug 2026 14:18:27 +0800 Subject: [PATCH 06/10] fix(responses): preserve nonstream reasoning state --- .../10550-responses-reasoning-transport.md | 2 +- open-sse/handlers/responseSanitizer.ts | 14 +++++++--- tests/unit/response-sanitizer.test.ts | 26 +++++++++++++++++++ 3 files changed, 37 insertions(+), 5 deletions(-) diff --git a/changelog.d/fixes/10550-responses-reasoning-transport.md b/changelog.d/fixes/10550-responses-reasoning-transport.md index 60c0326d6bca..36fadac9c766 100644 --- a/changelog.d/fixes/10550-responses-reasoning-transport.md +++ b/changelog.d/fixes/10550-responses-reasoning-transport.md @@ -1 +1 @@ -- Preserve target-compatible reasoning across Chat Completions and Responses routes, including DeepSeek plaintext continuations and provider-generated opaque Responses state, without replaying unsupported reasoning into fallback targets. Combos can optionally drop incompatible continuation reasoning and try the selected target, while known providers no longer show redundant encrypted-reasoning controls. (#10550) +- Preserve target-compatible reasoning across streaming and non-streaming Chat Completions and Responses routes, including DeepSeek plaintext continuations and provider-generated opaque Responses state, without replaying unsupported reasoning into fallback targets. Combos can optionally drop incompatible continuation reasoning and try the selected target, while known providers no longer show redundant encrypted-reasoning controls. (#10550) diff --git a/open-sse/handlers/responseSanitizer.ts b/open-sse/handlers/responseSanitizer.ts index 06d3202f5f75..a2210681d7f1 100644 --- a/open-sse/handlers/responseSanitizer.ts +++ b/open-sse/handlers/responseSanitizer.ts @@ -8,7 +8,10 @@ import { collapseExcessiveNewlines, extractThinkingFromContent, } from "./responseSanitizer/reasoning.ts"; -import { applyCacheHitTokensToUsage, applyCacheHitTokensToResponsesUsage } from "./responseSanitizer/cacheHitTokens.ts"; +import { + applyCacheHitTokensToUsage, + applyCacheHitTokensToResponsesUsage, +} from "./responseSanitizer/cacheHitTokens.ts"; export { extractThinkingFromContent, shouldParseTextualReasoningTags, @@ -31,7 +34,9 @@ const ALLOWED_USAGE_FIELDS = new Set([ "total_tokens", "cached_tokens", "prompt_tokens_details", - "completion_tokens_details", "cache_read_input_tokens", "cache_creation_input_tokens", + "completion_tokens_details", + "cache_read_input_tokens", + "cache_creation_input_tokens", // Keep through sanitize → applyClientUsageBuffer so heuristic web usage is // not inflated by the default USAGE_TOKEN_BUFFER (2000). "estimated", @@ -550,7 +555,7 @@ function sanitizeResponsesUsage(usage: unknown): unknown { !(toRecord(normalized.input_tokens_details) ?? {}).cached_tokens ) { normalized.input_tokens_details = { - ...(normalized.input_tokens_details as Record || {}), + ...((normalized.input_tokens_details as Record) || {}), cached_tokens: normalized.prompt_cache_hit_tokens, }; } @@ -562,7 +567,7 @@ function sanitizeResponsesUsage(usage: unknown): unknown { !(toRecord(normalized.input_tokens_details) ?? {}).cached_tokens ) { normalized.input_tokens_details = { - ...(normalized.input_tokens_details as Record || {}), + ...((normalized.input_tokens_details as Record) || {}), cached_tokens: normalized.cache_read_input_tokens, }; } @@ -863,6 +868,7 @@ function sanitizeResponsesOutputItem(item: unknown, index: number): JsonRecord | : []; return { + ...itemRecord, id: toString(itemRecord.id) || `rs_${index}`, type: "reasoning", summary, diff --git a/tests/unit/response-sanitizer.test.ts b/tests/unit/response-sanitizer.test.ts index 7fecd106fd56..925b9c3795a0 100644 --- a/tests/unit/response-sanitizer.test.ts +++ b/tests/unit/response-sanitizer.test.ts @@ -423,6 +423,32 @@ test("sanitizeResponsesApiResponse preserves native Responses payloads and usage assert.equal((sanitized as any).usage.output_tokens_details.reasoning_tokens, 3); }); +test("sanitizeResponsesApiResponse preserves native continuation reasoning state", () => { + const plaintext = { + id: "rs_plaintext", + type: "reasoning", + status: "completed", + summary: [], + content: [{ type: "reasoning_text", text: "Inspect before calling the tool." }], + }; + const opaque = { + id: "rs_opaque", + type: "reasoning", + summary: [{ type: "summary_text", text: "Inspected the tool inputs." }], + encrypted_content: "provider-generated-state", + signature: "provider-signature", + format: "provider-format", + }; + const sanitized = sanitizeResponsesApiResponse({ + id: "resp_reasoning_state", + object: "response", + status: "completed", + output: [plaintext, opaque], + }) as Record; + + assert.deepEqual(sanitized.output, [plaintext, opaque]); +}); + test("sanitizeStreamingChunk keeps only safe chunk fields and preserves readable reasoning aliases", () => { const sanitized = sanitizeStreamingChunk({ id: "chunk_1", From 10606839ff44eb4a61ed5c068bc927d35dcfe00e Mon Sep 17 00:00:00 2001 From: jackjinke Date: Mon, 17 Aug 2026 23:26:27 +0800 Subject: [PATCH 07/10] fix(reasoning): preserve chat continuation state --- open-sse/handlers/responseTranslator.ts | 33 ++-- open-sse/services/responsesInputPolicy.ts | 22 +++ open-sse/translator/index.ts | 161 ++++++++++------ .../translator/request/openai-responses.ts | 15 +- .../request/openai-responses/toResponses.ts | 4 +- .../translator/response/openai-responses.ts | 47 ++++- tests/unit/chatcore-translation-paths.test.ts | 179 ++++++++++++++++++ tests/unit/reasoning-cache.test.ts | 113 +++++++++++ .../translator-openai-responses-req.test.ts | 48 +++++ 9 files changed, 537 insertions(+), 85 deletions(-) diff --git a/open-sse/handlers/responseTranslator.ts b/open-sse/handlers/responseTranslator.ts index eaa311f0cfcd..454d79342e8e 100644 --- a/open-sse/handlers/responseTranslator.ts +++ b/open-sse/handlers/responseTranslator.ts @@ -10,6 +10,7 @@ import { caseInsensitiveToolNameLookup, restoreOpenAIToolNames, } from "../translator/helpers/toolCallHelper.ts"; +import { extractReplayableResponsesReasoningText } from "../services/responsesInputPolicy.ts"; type JsonRecord = Record; @@ -177,7 +178,8 @@ export function translateNonStreamingResponse( const messageSelection = findBestMessageText(output); let textContent = messageSelection.text; - let reasoningContent = ""; + let replayableReasoningContent = ""; + let reasoningSummary = ""; const toolCalls: JsonRecord[] = []; for (const item of output) { @@ -191,16 +193,22 @@ export function translateNonStreamingResponse( if (partObj.type === "summary_text" && typeof partObj.text === "string") { // #9500 — reasoning summary parts are discrete segments; join with "\n\n" // (matches extractThinkingFromContent convention) so they don't glue back-to-back. - reasoningContent += reasoningContent ? `\n\n${partObj.text}` : partObj.text; + reasoningSummary += reasoningSummary ? `\n\n${partObj.text}` : partObj.text; } } - } else if (itemObj.type === "reasoning" && Array.isArray(itemObj.summary)) { - for (const part of itemObj.summary) { - const partObj = toRecord(part); - if (partObj.type === "summary_text" && typeof partObj.text === "string") { - // #9500 — reasoning summary parts are discrete segments; join with "\n\n" - // (matches extractThinkingFromContent convention) so they don't glue back-to-back. - reasoningContent += reasoningContent ? `\n\n${partObj.text}` : partObj.text; + } else if (itemObj.type === "reasoning") { + const replayable = extractReplayableResponsesReasoningText(itemObj); + if (replayable) { + replayableReasoningContent += replayableReasoningContent + ? `\n\n${replayable}` + : replayable; + } + if (Array.isArray(itemObj.summary)) { + for (const part of itemObj.summary) { + const partObj = toRecord(part); + if (partObj.type === "summary_text" && typeof partObj.text === "string") { + reasoningSummary += reasoningSummary ? `\n\n${partObj.text}` : partObj.text; + } } } } else if (itemObj.type === "function_call") { @@ -237,8 +245,11 @@ export function translateNonStreamingResponse( if (textContent) { message.content = textContent; } - if (reasoningContent) { - message.reasoning_content = reasoningContent; + if (replayableReasoningContent) { + message.reasoning_content = replayableReasoningContent; + } + if (reasoningSummary) { + message.reasoning_summary = [{ type: "summary_text", text: reasoningSummary }]; } if (toolCalls.length > 0) { message.tool_calls = toolCalls; diff --git a/open-sse/services/responsesInputPolicy.ts b/open-sse/services/responsesInputPolicy.ts index 974ce2debb23..5e48a56d92dd 100644 --- a/open-sse/services/responsesInputPolicy.ts +++ b/open-sse/services/responsesInputPolicy.ts @@ -58,6 +58,28 @@ function hasPlaintextReasoning(record: JsonRecord): boolean { ); } +/** + * Returns only provider-authentic plaintext continuation state. Display summaries + * are excluded, and a record carrying opaque state is never cross-converted. + */ +export function extractReplayableResponsesReasoningText(value: unknown): string { + const record = + value && typeof value === "object" && !Array.isArray(value) ? (value as JsonRecord) : null; + if (!record || record.type !== "reasoning" || hasOpaqueReasoningState(record)) return ""; + if (!Array.isArray(record.content)) return ""; + + return record.content + .map((part) => { + const content = + part && typeof part === "object" && !Array.isArray(part) ? (part as JsonRecord) : null; + return content?.type === "reasoning_text" && typeof content.text === "string" + ? content.text + : ""; + }) + .filter((text) => text.trim().length > 0) + .join("\n\n"); +} + export function hasOpaqueReasoningState(record: JsonRecord): boolean { return ( (typeof record.encrypted_content === "string" && record.encrypted_content.trim().length > 0) || diff --git a/open-sse/translator/index.ts b/open-sse/translator/index.ts index 979fce6e5f51..501d2ffdbc27 100644 --- a/open-sse/translator/index.ts +++ b/open-sse/translator/index.ts @@ -202,6 +202,88 @@ function requiresReasoningContentPresence(provider: unknown, model: unknown): bo return normalizedProvider === "xiaomi-mimo" || /(^|\/)mimo/i.test(normalizedModel); } +type OpenAIReplayOptions = { + canReplayReasoningOnly: boolean; + requiresExplicitReasoningReplay: boolean; + provider: string; + model: string; + reasoningCacheScope?: string | null; +}; + +function replayOpenAIReasoningMessage( + messages: Array>, + messageIndex: number, + options: OpenAIReplayOptions +): void { + const message = messages[messageIndex]; + if (!message || message.role !== "assistant") return; + + // Moonshot `partial` messages are output prefixes, not completed prior turns. + if (message.partial === true) { + if (message.reasoning_content === "") delete message.reasoning_content; + return; + } + + if ( + !hasNonEmptyReasoningContent(message) && + typeof message.reasoning === "string" && + message.reasoning.trim().length > 0 + ) { + message.reasoning_content = message.reasoning; + } + + const toolCalls = Array.isArray(message.tool_calls) ? message.tool_calls : []; + const hasToolCalls = toolCalls.length > 0; + const shouldReplayReasoningOnly = + !hasToolCalls && options.canReplayReasoningOnly && !hasNonEmptyReasoningContent(message); + + if (!hasToolCalls && !shouldReplayReasoningOnly) { + if ( + message.reasoning_content === "" || + isInternalReasoningPlaceholder(message.reasoning_content) + ) { + delete message.reasoning_content; + } + return; + } + + if (hasNonEmptyReasoningContent(message)) { + if (!isInternalReasoningPlaceholder(message.reasoning_content)) return; + delete message.reasoning_content; + } + + const firstToolCall = + toolCalls[0] && typeof toolCalls[0] === "object" && !Array.isArray(toolCalls[0]) + ? (toolCalls[0] as Record) + : null; + const cacheKey = hasToolCalls + ? typeof firstToolCall?.id === "string" + ? firstToolCall.id + : "" + : buildAssistantMessageCacheKey(options.reasoningCacheScope, messages, messageIndex); + if (cacheKey) { + const cached = lookupReasoning(cacheKey); + if (cached) { + message.reasoning_content = cached; + recordReplay(); + return; + } + } + + if (options.requiresExplicitReasoningReplay) { + if (message.reasoning_content === "") delete message.reasoning_content; + return; + } + + if ((hasToolCalls || shouldReplayReasoningOnly) && !message.reasoning_content) { + if (requiresReasoningContentPresence(options.provider, options.model)) { + message.reasoning_content = NON_ANTHROPIC_THINKING_PLACEHOLDER; + } else { + delete message.reasoning_content; + } + } +} + /** @param options.normalizeToolCallId - When true, use 9-char tool call ids (e.g. Mistral); when false, leave ids as-is */ /** @param options.preserveDeveloperRole - undefined/true: keep developer for OpenAI format (default); false: map to system */ /** @param options.preserveCacheControl - When true, preserve client-side cache_control markers (for Claude Code, etc.) */ @@ -321,6 +403,25 @@ export function translateRequest( result.messages = hoistLeadingSystemMessage(result.messages, provider); } + if ( + sourceFormat === FORMATS.OPENAI && + targetFormat === FORMATS.OPENAI_RESPONSES && + isReasoner && + Array.isArray(result.messages) + ) { + const messages = result.messages as Array>; + const replayOptions: OpenAIReplayOptions = { + canReplayReasoningOnly: isReasoningOnlyReplayTarget(normalizedProvider, normalizedModel), + requiresExplicitReasoningReplay, + provider: normalizedProvider, + model: normalizedModel, + reasoningCacheScope: options?.reasoningCacheScope, + }; + for (let messageIndex = 0; messageIndex < messages.length; messageIndex += 1) { + replayOpenAIReasoningMessage(messages, messageIndex, replayOptions); + } + } + // If same format, skip translation steps if (sourceFormat !== targetFormat) { // Check for direct translation path first (e.g., Claude → Gemini) @@ -619,59 +720,13 @@ export function translateRequest( } // ── OpenAI-format message ── - // Skip if client already provided real reasoning_content. The internal - // replay placeholder is NOT real reasoning: drop it and fall through to - // the cache lookup so it can be replaced with genuine cached reasoning. - // Forwarding it makes the model continue its chain of thought from that - // text (echo → empty stop), and the echo re-poisons cache + client - // history (#9573). - if (hasNonEmptyReasoningContent(msg)) { - if (!isInternalReasoningPlaceholder(msg.reasoning_content)) { - continue; - } - delete msg.reasoning_content; - } - - const cacheKey = hasToolCalls - ? msg.tool_calls[0]?.id - : buildAssistantMessageCacheKey( - options?.reasoningCacheScope, - result.messages, - messageIndex - ); - if (cacheKey) { - const cached = lookupReasoning(cacheKey); - if (cached) { - msg.reasoning_content = cached; - recordReplay(); - continue; - } - } - - // Native Moonshot K3/K2.7 accepts only the real prior reasoning. If it - // was not supplied and the cache missed, leave it absent so upstream can - // enforce its contract instead of corrupting history with a placeholder. - if (requiresExplicitReasoningReplay) { - if (msg.reasoning_content === "") delete msg.reasoning_content; - continue; - } - - // Cache miss fallback — previously injected a non-empty placeholder - // (NON_ANTHROPIC_THINKING_PLACEHOLDER) to dodge an alleged DeepSeek V4 400 - // on missing reasoning_content. The placeholder is the root cause of this - // bug: the model echoes it as its own reasoning and stops (empty turns), - // and the echo re-poisons the cache + client history (#9573). Empirically, - // deepseek-v4-flash accepts an ABSENT reasoning_content field (the 400 is - // specific to empty-string, and even that is endpoint-dependent). Omit - // the field instead; providers that genuinely enforce the contract - // (kimi-coding, moonshot reasoning replay) have their own paths above. - if ((hasToolCalls || shouldReplayReasoningOnly) && !msg.reasoning_content) { - if (requiresReasoningContentPresence(normalizedProvider, normalizedModel)) { - msg.reasoning_content = NON_ANTHROPIC_THINKING_PLACEHOLDER; - } else { - delete msg.reasoning_content; - } - } + replayOpenAIReasoningMessage(result.messages, messageIndex, { + canReplayReasoningOnly, + requiresExplicitReasoningReplay, + provider: normalizedProvider, + model: normalizedModel, + reasoningCacheScope: options?.reasoningCacheScope, + }); } } else if ( !isReasoner && diff --git a/open-sse/translator/request/openai-responses.ts b/open-sse/translator/request/openai-responses.ts index d0cad07de1f8..3227b8c6880a 100644 --- a/open-sse/translator/request/openai-responses.ts +++ b/open-sse/translator/request/openai-responses.ts @@ -11,6 +11,7 @@ import { normalizeResponsesInputForChat } from "../../utils/responsesInputNormal import { createReasoningTransportIncompatibleError, hasOpaqueReasoningState, + extractReplayableResponsesReasoningText, } from "../../services/responsesInputPolicy.ts"; import { getRegisteredProviders, @@ -77,18 +78,6 @@ function toolOutputContentToString(output: unknown): string { return parts.join("\n"); } -function getReasoningContentText(item: JsonRecord): string { - if (hasOpaqueReasoningState(item)) return ""; - if (!Array.isArray(item.content)) return ""; - return item.content - .map((part) => { - const record = toRecord(part); - return record.type === "reasoning_text" ? toString(record.text) : ""; - }) - .filter((text) => text.length > 0) - .join("\n\n"); -} - function appendReasoningContent(current: unknown, next: string): string { const existing = typeof current === "string" ? current : ""; return existing ? `${existing}\n\n${next}` : next; @@ -470,7 +459,7 @@ export function openaiResponsesToOpenAIRequest( throw createReasoningTransportIncompatibleError(); } if (preserveReasoningContent) { - const reasoning = getReasoningContentText(item); + const reasoning = extractReplayableResponsesReasoningText(item); if (reasoning) { if (currentAssistantMsg) { currentAssistantMsg.reasoning_content = appendReasoningContent( diff --git a/open-sse/translator/request/openai-responses/toResponses.ts b/open-sse/translator/request/openai-responses/toResponses.ts index 1ed31a2f407c..9cbc3cc0ba90 100644 --- a/open-sse/translator/request/openai-responses/toResponses.ts +++ b/open-sse/translator/request/openai-responses/toResponses.ts @@ -9,6 +9,7 @@ import { resolveResponsesReasoningTransport, } from "../../../services/responsesInputPolicy.ts"; import { isInternalReasoningPlaceholder } from "../../../utils/reasoningPlaceholder.ts"; +import { getReadableReasoningValue } from "../../../utils/reasoningFields.ts"; import { generateToolCallId } from "../../helpers/toolCallHelper.ts"; import { JsonRecord, @@ -202,8 +203,7 @@ export function openaiToOpenAIResponsesRequest( // Convert assistant messages if (role === "assistant") { - const reasoning = - typeof msg.reasoning_content === "string" ? msg.reasoning_content.trim() : ""; + const reasoning = getReadableReasoningValue(msg).trim(); if (reasoning && !isInternalReasoningPlaceholder(reasoning)) { if (reasoningTransport === "plaintext") { // DeepSeek accepts genuine plaintext reasoning without a synthetic rs_* ID. diff --git a/open-sse/translator/response/openai-responses.ts b/open-sse/translator/response/openai-responses.ts index 109a04f84586..20a934402f98 100644 --- a/open-sse/translator/response/openai-responses.ts +++ b/open-sse/translator/response/openai-responses.ts @@ -12,6 +12,7 @@ import { isInternalReasoningPlaceholder, stripInternalReasoningPlaceholder, } from "../../utils/reasoningPlaceholder.ts"; +import { extractReplayableResponsesReasoningText } from "../../services/responsesInputPolicy.ts"; import { normalizeToolName, stripEmptyOptionalToolArgs, @@ -1040,6 +1041,17 @@ function openaiResponsesToOpenAIResponseStream(chunk, state) { return null; } + if (eventType === "response.output_item.done" && data.item?.type === "reasoning") { + const replayableReasoning = extractReplayableResponsesReasoningText(data.item); + if (replayableReasoning) { + const accumulated = + typeof state.accumulatedReasoning === "string" ? state.accumulatedReasoning : ""; + state.accumulatedReasoning = accumulated + ? `${accumulated}\n\n${replayableReasoning}` + : replayableReasoning; + } + } + // Function call done — emit args chunk from item.arguments when no deltas were received, // then advance the tool-call index. This handles Codex Responses API payloads that // carry the complete arguments only in output_item.done (no preceding delta events). @@ -1053,6 +1065,28 @@ function openaiResponsesToOpenAIResponseStream(chunk, state) { const shouldNormalizeArguments = toolName === "Agent"; state.currentToolCallNeedsNormalization = shouldNormalizeArguments; + if (toolName && state.toolCalls instanceof Map) { + const completedArguments = + typeof item.arguments === "string" && item.arguments.length > 0 ? item.arguments : buffered; + const normalizedArguments = stripEmptyOptionalToolArgs( + completedArguments, + toolName, + toolSchema + ); + state.toolCalls.set(currentIndex, { + id: callId, + index: currentIndex, + type: "function", + function: { + name: toolName, + arguments: + typeof normalizedArguments === "string" + ? normalizedArguments + : JSON.stringify(normalizedArguments ?? {}), + }, + }); + } + // Track this call_id so response.completed doesn't synthesize a duplicate if (!state.toolCallIdsSeen) state.toolCallIdsSeen = new Set(); if (callId) state.toolCallIdsSeen.add(callId); @@ -1312,12 +1346,8 @@ function openaiResponsesToOpenAIResponseStream(chunk, state) { return buildResponsesReasoningDeltaChunk(state, deltaText); } - // #5786 — reasoning summary exposed ONLY as a terminal snapshot on - // `response.output_item.done` (no preceding reasoning_summary_text.delta events — e.g. - // Codex reasoning models that surface the summary once at item close). Without this the - // reasoning channel is silently dropped and never reaches the client's thinking panel. - // Only synthesize when NO reasoning delta was already streamed for this item, so normal - // delta streams are never duplicated. + // Some providers expose completed reasoning only on `response.output_item.done`. + // Synthesize one Chat reasoning delta only when no delta was already emitted. if (eventType === "response.output_item.done" && data.item?.type === "reasoning") { const item = data.item; const itemId = item.id != null ? String(item.id) : ""; @@ -1332,6 +1362,11 @@ function openaiResponsesToOpenAIResponseStream(chunk, state) { !(state.reasoningItemsWithDelta instanceof Set && state.reasoningItemsWithDelta.size > 0); if (emittedForItem || emittedWithoutItemId) return null; + const replayableReasoning = extractReplayableResponsesReasoningText(item); + if (replayableReasoning) { + return buildResponsesReasoningDeltaChunk(state, replayableReasoning); + } + // #7176/#7243: only synthesize from real upstream plaintext — never mutate // `item` and never fabricate placeholder text for encrypted-only reasoning. const summaryText = getVisibleResponsesReasoningSummaryText(item); diff --git a/tests/unit/chatcore-translation-paths.test.ts b/tests/unit/chatcore-translation-paths.test.ts index 0d9d86b11dca..d62a9fb23af5 100644 --- a/tests/unit/chatcore-translation-paths.test.ts +++ b/tests/unit/chatcore-translation-paths.test.ts @@ -211,6 +211,64 @@ function buildResponsesResponse(text = "ok") { ); } +function buildDeepSeekResponsesToolResponse({ + stream, + callId, + reasoning, +}: { + stream: boolean; + callId: string; + reasoning: string; +}) { + const reasoningItem = { + id: "rs_deepseek_tool", + type: "reasoning", + status: "completed", + summary: [], + content: [{ type: "reasoning_text", text: reasoning }], + }; + const functionCall = { + id: "fc_deepseek_tool", + type: "function_call", + status: "completed", + call_id: callId, + name: "inspect", + arguments: "{}", + }; + const response = { + id: "resp_deepseek_tool", + object: "response", + status: "completed", + model: "deepseek-v4-flash", + output: [reasoningItem, functionCall], + usage: { input_tokens: 4, output_tokens: 2, total_tokens: 6 }, + }; + + if (!stream) { + return new Response(JSON.stringify(response), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); + } + + const events = [ + { + type: "response.created", + response: { id: response.id, model: response.model, status: "in_progress" }, + }, + { type: "response.output_item.done", output_index: 0, item: reasoningItem }, + { type: "response.output_item.done", output_index: 1, item: functionCall }, + { type: "response.completed", response }, + ]; + return new Response( + `${events.map((event) => `event: ${event.type}\ndata: ${JSON.stringify(event)}`).join("\n\n")}\n\ndata: [DONE]\n\n`, + { + status: 200, + headers: { "Content-Type": "text/event-stream" }, + } + ); +} + function capabilityEntry(limitContext) { return { tool_call: true, @@ -705,6 +763,127 @@ test("chatCore carries Chat reasoning_content into official DeepSeek Responses i ]); }); +test("chatCore replays nonstream DeepSeek Responses reasoning across a Chat tool turn", async () => { + const callId = "call_deepseek_nonstream_replay"; + const reasoning = "Authentic nonstream DeepSeek reasoning"; + const apiKeyInfo = { id: "deepseek-nonstream-chat-key" }; + const first = await invokeChatCore({ + provider: "deepseek", + model: "deepseek-v4-flash", + endpoint: "/v1/chat/completions", + body: { + model: "deepseek-v4-flash", + stream: false, + reasoning_effort: "high", + messages: [{ role: "user", content: "Inspect the repository" }], + tools: [ + { + type: "function", + function: { name: "inspect", description: "Inspect", parameters: { type: "object" } }, + }, + ], + }, + apiKeyInfo, + responseFactory: () => buildDeepSeekResponsesToolResponse({ stream: false, callId, reasoning }), + }); + + assert.equal(first.result.success, true); + const firstPayload = (await first.result.response.json()) as { + choices: Array<{ message: Record & { reasoning_content?: string } }>; + }; + assert.equal(firstPayload.choices[0].message.reasoning_content, reasoning); + const assistant = structuredClone(firstPayload.choices[0].message); + delete assistant.reasoning_content; + + const second = await invokeChatCore({ + provider: "deepseek", + model: "deepseek-v4-flash", + endpoint: "/v1/chat/completions", + body: { + model: "deepseek-v4-flash", + stream: false, + reasoning_effort: "high", + messages: [ + { role: "user", content: "Inspect the repository" }, + assistant, + { role: "tool", tool_call_id: callId, content: "inspection complete" }, + ], + }, + apiKeyInfo, + responseFactory: () => buildResponsesResponse("done"), + }); + + assert.equal(second.result.success, true); + assert.deepEqual( + second.call.body.input.find((item) => item.type === "reasoning"), + { type: "reasoning", content: [{ type: "reasoning_text", text: reasoning }] } + ); +}); + +test("chatCore replays streamed DeepSeek Responses reasoning across a Chat tool turn", async () => { + const callId = "call_deepseek_stream_replay"; + const reasoning = "Authentic streamed DeepSeek reasoning"; + const apiKeyInfo = { id: "deepseek-stream-chat-key" }; + const first = await invokeChatCore({ + provider: "deepseek", + model: "deepseek-v4-flash", + endpoint: "/v1/chat/completions", + body: { + model: "deepseek-v4-flash", + stream: true, + reasoning_effort: "high", + messages: [{ role: "user", content: "Inspect the repository" }], + tools: [ + { + type: "function", + function: { name: "inspect", description: "Inspect", parameters: { type: "object" } }, + }, + ], + }, + apiKeyInfo, + responseFactory: () => buildDeepSeekResponsesToolResponse({ stream: true, callId, reasoning }), + }); + + assert.equal(first.result.success, true); + const streamed = await first.result.response.text(); + assert.match(streamed, new RegExp(reasoning)); + await flushAsyncSideEffects(); + + const second = await invokeChatCore({ + provider: "deepseek", + model: "deepseek-v4-flash", + endpoint: "/v1/chat/completions", + body: { + model: "deepseek-v4-flash", + stream: false, + reasoning_effort: "high", + messages: [ + { role: "user", content: "Inspect the repository" }, + { + role: "assistant", + content: null, + tool_calls: [ + { + id: callId, + type: "function", + function: { name: "inspect", arguments: "{}" }, + }, + ], + }, + { role: "tool", tool_call_id: callId, content: "inspection complete" }, + ], + }, + apiKeyInfo, + responseFactory: () => buildResponsesResponse("done"), + }); + + assert.equal(second.result.success, true); + assert.deepEqual( + second.call.body.input.find((item) => item.type === "reasoning"), + { type: "reasoning", content: [{ type: "reasoning_text", text: reasoning }] } + ); +}); + test("chatCore replays no-tool reasoning across public Responses turns", async () => { // Direct DeepSeek now speaks Responses upstream. Keep this regression on a // Chat-compatible DeepSeek host so it continues to exercise the Responses-to-Chat replay path. diff --git a/tests/unit/reasoning-cache.test.ts b/tests/unit/reasoning-cache.test.ts index 96edde215faa..e13dfec7a95e 100644 --- a/tests/unit/reasoning-cache.test.ts +++ b/tests/unit/reasoning-cache.test.ts @@ -36,6 +36,7 @@ import { import { translateRequest } from "../../open-sse/translator/index.ts"; import { FORMATS } from "../../open-sse/translator/formats.ts"; import { ensureToolCallIds } from "../../open-sse/translator/helpers/toolCallHelper.ts"; +import { translateNonStreamingResponse } from "../../open-sse/handlers/responseTranslator.ts"; import { getDbInstance } from "../../src/lib/db/core.ts"; import { getReasoningCache, setReasoningCache } from "../../src/lib/db/reasoningCache.ts"; import { DELETE, GET } from "../../src/app/api/cache/reasoning/route.ts"; @@ -625,6 +626,48 @@ describe("Reasoning Replay Cache — Translator Replay", () => { assert.equal(getReasoningCacheServiceStats().replays, 1); }); + it("should replay cached DeepSeek reasoning before Chat converts to Responses input", () => { + clearReasoningCacheAll(); + clearModelsDevCapabilities(); + const callId = "call_ds_chat_to_responses"; + cacheReasoning(callId, "deepseek", "deepseek-v4-flash", "Cached Chat continuation reasoning"); + + const translated = translateRequest( + FORMATS.OPENAI, + FORMATS.OPENAI_RESPONSES, + "deepseek-v4-flash", + { + reasoning_effort: "high", + messages: [ + { role: "user", content: "Use the tool" }, + { + role: "assistant", + content: null, + tool_calls: [ + { + id: callId, + type: "function", + function: { name: "read_file", arguments: "{}" }, + }, + ], + }, + { role: "tool", tool_call_id: callId, content: "contents" }, + ], + }, + false, + null, + "deepseek" + ); + + assert.deepEqual( + translated.input.find((item) => item.type === "reasoning"), + { + type: "reasoning", + content: [{ type: "reasoning_text", text: "Cached Chat continuation reasoning" }], + } + ); + }); + it("should preserve DeepSeek Responses reasoning before Chat conversion", () => { clearReasoningCacheAll(); clearModelsDevCapabilities(); @@ -681,6 +724,76 @@ describe("Reasoning Replay Cache — Translator Replay", () => { assert.equal(statsAfterTranslation.replays, statsBeforeTranslation.replays); }); + it("should cache only authentic plaintext from nonstream Responses output", () => { + clearReasoningCacheAll(); + const callId = "call_nonstream_authentic_reasoning"; + const translated = translateNonStreamingResponse( + { + object: "response", + model: "deepseek-v4-flash", + output: [ + { + type: "reasoning", + content: [{ type: "reasoning_text", text: "Authentic provider reasoning" }], + summary: [{ type: "summary_text", text: "Display summary" }], + }, + { type: "function_call", call_id: callId, name: "read_file", arguments: "{}" }, + ], + }, + FORMATS.OPENAI_RESPONSES, + FORMATS.OPENAI + ) as { choices?: Array<{ message?: Record }> }; + const message = translated.choices?.[0]?.message; + + assert.ok(message); + assert.equal(message.reasoning_content, "Authentic provider reasoning"); + assert.equal(cacheReasoningFromAssistantMessage(message, "deepseek", "deepseek-v4-flash"), 1); + assert.equal(lookupReasoning(callId), "Authentic provider reasoning"); + }); + + it("should never cache Responses summaries or opaque plaintext companions", () => { + for (const [suffix, reasoningItem] of [ + [ + "summary", + { + type: "reasoning", + summary: [{ type: "summary_text", text: "Display-only summary" }], + }, + ], + [ + "mixed", + { + type: "reasoning", + encrypted_content: "opaque-provider-state", + content: [{ type: "reasoning_text", text: "Unsafe plaintext companion" }], + summary: [{ type: "summary_text", text: "Display-only mixed summary" }], + }, + ], + ] as const) { + clearReasoningCacheAll(); + const callId = `call_nonstream_${suffix}_reasoning`; + const translated = translateNonStreamingResponse( + { + object: "response", + model: "deepseek-v4-flash", + output: [ + reasoningItem, + { type: "function_call", call_id: callId, name: "read_file", arguments: "{}" }, + ], + }, + FORMATS.OPENAI_RESPONSES, + FORMATS.OPENAI + ) as { choices?: Array<{ message?: Record }> }; + const message = translated.choices?.[0]?.message; + + assert.ok(message); + assert.equal(message.reasoning_content, undefined); + assert.ok(Array.isArray(message.reasoning_summary)); + assert.equal(cacheReasoningFromAssistantMessage(message, "deepseek", "deepseek-v4-flash"), 0); + assert.equal(lookupReasoning(callId), null); + } + }); + it("should preserve client-provided reasoning content", () => { clearReasoningCacheAll(); clearModelsDevCapabilities(); diff --git a/tests/unit/translator-openai-responses-req.test.ts b/tests/unit/translator-openai-responses-req.test.ts index 71a6588b9941..4bfba3edb52d 100644 --- a/tests/unit/translator-openai-responses-req.test.ts +++ b/tests/unit/translator-openai-responses-req.test.ts @@ -153,6 +153,25 @@ test("Responses -> Chat replays plaintext reasoning_text instead of a display su assert.equal(result.messages[0].reasoning_content, "Use the indexed result"); }); +test("Responses -> Chat keeps summary-only reasoning out of continuation state", () => { + const result = openaiResponsesToOpenAIRequest( + "deepseek-v4-pro", + { + input: [ + { + type: "reasoning", + summary: [{ type: "summary_text", text: "Display-only summary" }], + }, + { type: "function_call", call_id: "call_1", name: "search", arguments: "{}" }, + ], + }, + false, + { _preserveReasoningContent: true } + ) as { messages: Array> }; + + assert.equal(result.messages[0].reasoning_content, undefined); +}); + test("Responses -> Chat rejects opaque reasoning instead of replaying its plaintext companion", () => { assert.throws( () => @@ -468,6 +487,35 @@ test("Chat -> DeepSeek Responses emits ID-less plaintext reasoning before its fu ]); }); +test("Chat -> DeepSeek Responses accepts the plaintext reasoning alias", () => { + const result = openaiToOpenAIResponsesRequest( + "deepseek-v4-pro", + { + messages: [ + { + role: "assistant", + content: null, + reasoning: "Alias plaintext reasoning", + tool_calls: [ + { + id: "call_alias", + type: "function", + function: { name: "search", arguments: "{}" }, + }, + ], + }, + ], + }, + false, + { _provider: "deepseek" } + ) as { input: Array> }; + + assert.deepEqual(result.input[0], { + type: "reasoning", + content: [{ type: "reasoning_text", text: "Alias plaintext reasoning" }], + }); +}); + test("Chat -> Responses rejects tool continuation for an incompatible target", () => { assert.throws( () => From c4fc089dc51bc422c9ffa2b20b14440d8ba92fbd Mon Sep 17 00:00:00 2001 From: jackjinke Date: Tue, 18 Aug 2026 16:30:20 +0800 Subject: [PATCH 08/10] fix(reasoning): generalize continuation transport policy --- .../10550-responses-reasoning-transport.md | 2 +- .../registry/chatgpt-web-codex/index.ts | 2 +- .../config/providers/registry/codex/index.ts | 2 +- .../providers/registry/deepseek/index.ts | 1 - .../providers/registry/grok-cli/index.ts | 2 +- .../providers/registry/muse-code/index.ts | 2 +- .../config/providers/registry/openai/index.ts | 2 +- .../config/providers/registry/xai/index.ts | 4 +- open-sse/config/providers/shared.ts | 6 +- open-sse/executors/codex.ts | 5 +- open-sse/handlers/chatCore.ts | 26 +- open-sse/handlers/responseTranslator.ts | 2 +- open-sse/services/reasoningInputPolicy.ts | 331 ++++++++++++++++++ open-sse/services/responsesInputPolicy.ts | 190 ---------- .../translator/request/openai-responses.ts | 2 +- .../request/openai-responses/toResponses.ts | 25 +- .../translator/response/openai-responses.ts | 2 +- src/app/(dashboard)/dashboard/combos/page.tsx | 8 +- src/sse/handlers/chat.ts | 4 +- tests/unit/chat-route-coverage.test.ts | 3 +- tests/unit/chatcore-translation-paths.test.ts | 46 ++- .../combo-attempt-body-isolation-7847.test.ts | 15 +- ...asoning-blobs-agentic-context-1599.test.ts | 133 +++++-- .../translator-openai-responses-req.test.ts | 33 +- 24 files changed, 547 insertions(+), 301 deletions(-) create mode 100644 open-sse/services/reasoningInputPolicy.ts delete mode 100644 open-sse/services/responsesInputPolicy.ts diff --git a/changelog.d/fixes/10550-responses-reasoning-transport.md b/changelog.d/fixes/10550-responses-reasoning-transport.md index 36fadac9c766..d34c433debd8 100644 --- a/changelog.d/fixes/10550-responses-reasoning-transport.md +++ b/changelog.d/fixes/10550-responses-reasoning-transport.md @@ -1 +1 @@ -- Preserve target-compatible reasoning across streaming and non-streaming Chat Completions and Responses routes, including DeepSeek plaintext continuations and provider-generated opaque Responses state, without replaying unsupported reasoning into fallback targets. Combos can optionally drop incompatible continuation reasoning and try the selected target, while known providers no longer show redundant encrypted-reasoning controls. (#10550) +- Preserve portable plaintext reasoning by default across streaming and non-streaming Chat Completions and Responses routes while keeping provider-bound opaque state target-compatible. Combos now drop incompatible continuation reasoning by default and can explicitly skip incompatible targets, while known providers no longer show redundant encrypted-reasoning controls. (#10550) diff --git a/open-sse/config/providers/registry/chatgpt-web-codex/index.ts b/open-sse/config/providers/registry/chatgpt-web-codex/index.ts index d65270b27bdb..1c290668f900 100644 --- a/open-sse/config/providers/registry/chatgpt-web-codex/index.ts +++ b/open-sse/config/providers/registry/chatgpt-web-codex/index.ts @@ -14,7 +14,7 @@ export const chatgpt_web_codexProvider: RegistryEntry = { format: "openai-responses", executor: "chatgpt-web-codex", baseUrl: "https://chatgpt.com", - responsesReasoningTransport: "opaque", + reasoningTransport: "opaque", authType: "apikey", authHeader: "cookie", forceStream: true, diff --git a/open-sse/config/providers/registry/codex/index.ts b/open-sse/config/providers/registry/codex/index.ts index 87bfcc35a0fc..6b67797fa3d4 100644 --- a/open-sse/config/providers/registry/codex/index.ts +++ b/open-sse/config/providers/registry/codex/index.ts @@ -12,7 +12,7 @@ export const codexProvider: RegistryEntry = { format: "openai-responses", executor: "codex", baseUrl: "https://chatgpt.com/backend-api/codex/responses", - responsesReasoningTransport: "opaque", + reasoningTransport: "opaque", authType: "oauth", authHeader: "bearer", defaultContextLength: 400000, diff --git a/open-sse/config/providers/registry/deepseek/index.ts b/open-sse/config/providers/registry/deepseek/index.ts index 3bf0c90a200a..933fb9bba1f5 100644 --- a/open-sse/config/providers/registry/deepseek/index.ts +++ b/open-sse/config/providers/registry/deepseek/index.ts @@ -6,7 +6,6 @@ export const deepseekProvider: RegistryEntry = { format: "openai-responses", executor: "default", baseUrl: "https://api.deepseek.com/responses", - responsesReasoningTransport: "plaintext", authType: "apikey", authHeader: "bearer", alternateFormats: [ diff --git a/open-sse/config/providers/registry/grok-cli/index.ts b/open-sse/config/providers/registry/grok-cli/index.ts index baeade402a07..e65ced0e76b3 100644 --- a/open-sse/config/providers/registry/grok-cli/index.ts +++ b/open-sse/config/providers/registry/grok-cli/index.ts @@ -14,7 +14,7 @@ export const grok_cliProvider: RegistryEntry = { // Keep the generic translate-path contract stable. GrokCliExecutor owns the // official Grok Build upstream URL and always dispatches to /v1/responses. baseUrl: "https://cli-chat-proxy.grok.com/v1/chat/completions", - responsesReasoningTransport: "opaque", + reasoningTransport: "opaque", modelsUrl: GROK_BUILD_MODELS_URL, clientVersion: getGrokBuildClientVersion(), authType: "oauth", diff --git a/open-sse/config/providers/registry/muse-code/index.ts b/open-sse/config/providers/registry/muse-code/index.ts index 6054b5387b54..37db1e988d44 100644 --- a/open-sse/config/providers/registry/muse-code/index.ts +++ b/open-sse/config/providers/registry/muse-code/index.ts @@ -14,7 +14,7 @@ export const muse_codeProvider: RegistryEntry = buildOpenAiCompatibleRegistryEnt id: "muse-code", alias: "mc", passthroughModels: true, - responsesReasoningTransport: "opaque", + reasoningTransport: "opaque", defaultContextLength: 200000, models: [ { diff --git a/open-sse/config/providers/registry/openai/index.ts b/open-sse/config/providers/registry/openai/index.ts index 627121c0a85d..60a276a948b9 100644 --- a/open-sse/config/providers/registry/openai/index.ts +++ b/open-sse/config/providers/registry/openai/index.ts @@ -7,7 +7,7 @@ export const openaiProvider: RegistryEntry = { format: "openai", executor: "default", baseUrl: "https://api.openai.com/v1/chat/completions", - responsesReasoningTransport: "opaque", + reasoningTransport: "opaque", authType: "apikey", authHeader: "bearer", defaultContextLength: 128000, diff --git a/open-sse/config/providers/registry/xai/index.ts b/open-sse/config/providers/registry/xai/index.ts index 625523449ee3..dcf912444123 100644 --- a/open-sse/config/providers/registry/xai/index.ts +++ b/open-sse/config/providers/registry/xai/index.ts @@ -12,7 +12,7 @@ export const xaiProvider: RegistryEntry = { // XaiExecutor.buildUrl (open-sse/executors/xai.ts) for models tagged // targetFormat: "openai-responses" below. responsesBaseUrl: "https://api.x.ai/v1/responses", - responsesReasoningTransport: "opaque", + reasoningTransport: "opaque", authType: "apikey", authHeader: "bearer", models: [ @@ -55,7 +55,7 @@ export const xai_oauthProvider: RegistryEntry = { executor: "xai-oauth", baseUrl: xaiProvider.baseUrl, responsesBaseUrl: xaiProvider.responsesBaseUrl, - responsesReasoningTransport: "opaque", + reasoningTransport: "opaque", authType: "oauth", authHeader: xaiProvider.authHeader, passthroughModels: true, diff --git a/open-sse/config/providers/shared.ts b/open-sse/config/providers/shared.ts index afdce85f0eb7..b29ecff8a215 100644 --- a/open-sse/config/providers/shared.ts +++ b/open-sse/config/providers/shared.ts @@ -102,7 +102,7 @@ export interface RegistryOAuth { pollUrlBase?: string; } -export type ResponsesReasoningTransport = "plaintext" | "opaque"; +export type ReasoningTransport = "plaintext" | "opaque" | "none"; export interface RegistryEntry { id: string; @@ -116,8 +116,8 @@ export interface RegistryEntry { /** Override models URL used only for API key validation, not catalog discovery. */ testKeyModelsUrl?: string; responsesBaseUrl?: string; - /** Replay format accepted by this provider's OpenAI Responses endpoint. */ - responsesReasoningTransport?: ResponsesReasoningTransport; + /** Provider-bound replay format; omitted providers accept portable plaintext reasoning. */ + reasoningTransport?: ReasoningTransport; /** Anthropic-native /v1/messages endpoint (e.g. GitHub Copilot's shim) used * for models tagged `targetFormat: "claude"` on an otherwise openai-format * provider — see registry/github/index.ts. */ diff --git a/open-sse/executors/codex.ts b/open-sse/executors/codex.ts index 7ec906d3c353..6e6e268b0c45 100644 --- a/open-sse/executors/codex.ts +++ b/open-sse/executors/codex.ts @@ -33,7 +33,7 @@ import { } from "../config/codexIdentity.ts"; import { getAccessToken } from "../services/tokenRefresh.ts"; import { sanitizeResponsesInputItems } from "../services/responsesInputSanitizer.ts"; -import { applyResponsesInputPolicy } from "../services/responsesInputPolicy.ts"; +import { applyReasoningInputPolicy } from "../services/reasoningInputPolicy.ts"; import { normalizeCodexVerbosity } from "../services/codexVerbosity.ts"; import { getThinkingBudgetConfig, ThinkingMode } from "../services/thinkingBudget.ts"; import { CORS_HEADERS } from "../utils/cors.ts"; @@ -1380,10 +1380,11 @@ export class CodexExecutor extends BaseExecutor { delete body.session_id; delete body.conversation_id; - applyResponsesInputPolicy(body, { + applyReasoningInputPolicy(body, "responses", { provider: "codex", preserveEncryptedReasoning: credentials?.providerSpecificData?.preserveEncryptedReasoning === true, + onIncompatibleReasoning: "drop", }); if (nativeCodexPassthrough) { diff --git a/open-sse/handlers/chatCore.ts b/open-sse/handlers/chatCore.ts index 7ea11b60b552..4cc81727fad9 100644 --- a/open-sse/handlers/chatCore.ts +++ b/open-sse/handlers/chatCore.ts @@ -33,7 +33,7 @@ import { assembleStreamingResponseHeaders } from "./chatCore/streamingResponseHe import { storeStreamingSemanticCacheResponse } from "./chatCore/streamingSemanticCacheStore.ts"; import { assembleStreamingPipeline } from "./chatCore/streamingPipeline.ts"; import { sanitizeChatRequestBody } from "./chatCore/sanitization.ts"; -import { applyResponsesInputPolicy } from "../services/responsesInputPolicy.ts"; +import { applyReasoningInputPolicy } from "../services/reasoningInputPolicy.ts"; import { getHeaderValueCaseInsensitive, isNoMemoryRequested, @@ -1122,13 +1122,23 @@ export async function handleChatCore({ return cacheHit; } - if (targetFormat === FORMATS.OPENAI_RESPONSES && body && typeof body === "object") { - const policy = applyResponsesInputPolicy(body as Record, { - provider, - preserveEncryptedReasoning: - credentials?.providerSpecificData?.preserveEncryptedReasoning === true, - onIncompatibleReasoning: reasoningTransportFallback === "drop" ? "drop" : "reject", - }); + const reasoningInputFormat = + sourceFormat === FORMATS.OPENAI_RESPONSES + ? "responses" + : sourceFormat === FORMATS.OPENAI + ? "chat" + : null; + if (reasoningInputFormat && body && typeof body === "object") { + const policy = applyReasoningInputPolicy( + body as Record, + reasoningInputFormat, + { + provider, + preserveEncryptedReasoning: + credentials?.providerSpecificData?.preserveEncryptedReasoning === true, + onIncompatibleReasoning: reasoningTransportFallback === "drop" ? "drop" : "reject", + } + ); if (policy.incompatibleReasoning) { trackPendingRequest(model, provider, connectionId, false); return createErrorResult( diff --git a/open-sse/handlers/responseTranslator.ts b/open-sse/handlers/responseTranslator.ts index 454d79342e8e..4ecaef87ece2 100644 --- a/open-sse/handlers/responseTranslator.ts +++ b/open-sse/handlers/responseTranslator.ts @@ -10,7 +10,7 @@ import { caseInsensitiveToolNameLookup, restoreOpenAIToolNames, } from "../translator/helpers/toolCallHelper.ts"; -import { extractReplayableResponsesReasoningText } from "../services/responsesInputPolicy.ts"; +import { extractReplayableResponsesReasoningText } from "../services/reasoningInputPolicy.ts"; type JsonRecord = Record; diff --git a/open-sse/services/reasoningInputPolicy.ts b/open-sse/services/reasoningInputPolicy.ts new file mode 100644 index 000000000000..037f57de4a26 --- /dev/null +++ b/open-sse/services/reasoningInputPolicy.ts @@ -0,0 +1,331 @@ +import { REGISTRY } from "../config/providerRegistry.ts"; +import type { ReasoningTransport } from "../config/providerRegistry.ts"; + +type JsonRecord = Record; + +const REASONING_TRANSPORTS = new Map(); +for (const [id, entry] of Object.entries(REGISTRY)) { + if (!entry.reasoningTransport) continue; + REASONING_TRANSPORTS.set(id.toLowerCase(), entry.reasoningTransport); + if (entry.alias) { + REASONING_TRANSPORTS.set(entry.alias.toLowerCase(), entry.reasoningTransport); + } +} + +const CHAT_PLAINTEXT_REASONING_FIELDS = [ + "reasoning_content", + "reasoning", + "reasoning_text", + "thinking", + "thought", +] as const; + +export type ReasoningInputFormat = "chat" | "responses"; + +export interface ReasoningStateInspection { + hasPlaintext: boolean; + hasOpaque: boolean; +} + +export interface ReasoningInputPolicyOptions { + provider?: string | null; + preserveEncryptedReasoning?: boolean; + onIncompatibleReasoning?: "reject" | "drop"; +} + +export interface ReasoningInputPolicyResult { + incompatibleReasoning: boolean; +} + +export function resolveReasoningTransport( + provider: string | null | undefined, + preserveEncryptedReasoning = false +): ReasoningTransport { + const normalized = typeof provider === "string" ? provider.trim().toLowerCase() : ""; + const transport = REASONING_TRANSPORTS.get(normalized); + return transport ?? (preserveEncryptedReasoning ? "opaque" : "plaintext"); +} + +export function createReasoningTransportIncompatibleError(): Error & { + statusCode: number; + errorType: string; +} { + const error = new Error( + "Reasoning continuation is not compatible with the selected target" + ) as Error & { statusCode: number; errorType: string }; + error.statusCode = 400; + error.errorType = "reasoning_transport_incompatible"; + return error; +} + +function asRecord(value: unknown): JsonRecord | null { + return value && typeof value === "object" && !Array.isArray(value) ? (value as JsonRecord) : null; +} + +function isNonEmptyString(value: unknown): boolean { + return typeof value === "string" && value.trim().length > 0; +} + +function isSummaryDetail(record: JsonRecord): boolean { + const type = typeof record.type === "string" ? record.type.toLowerCase() : ""; + return ( + type.includes("summary") || record.summary !== undefined || record.summary_text !== undefined + ); +} + +function hasPlaintextReasoning(record: JsonRecord): boolean { + return ( + Array.isArray(record.content) && + record.content.some((part) => { + const value = asRecord(part); + return value?.type === "reasoning_text" && isNonEmptyString(value.text); + }) + ); +} + +function hasChatPlaintextReasoning(record: JsonRecord): boolean { + if (CHAT_PLAINTEXT_REASONING_FIELDS.some((field) => isNonEmptyString(record[field]))) { + return true; + } + if (!Array.isArray(record.reasoning_details)) return false; + return record.reasoning_details.some((detail) => { + const value = asRecord(detail); + return Boolean( + value && + !isSummaryDetail(value) && + (isNonEmptyString(value.text) || isNonEmptyString(value.content)) + ); + }); +} + +/** + * Returns only provider-authentic plaintext continuation state. Display summaries + * are excluded, and a record carrying opaque state is never cross-converted. + */ +export function extractReplayableResponsesReasoningText(value: unknown): string { + const record = asRecord(value); + if (!record || record.type !== "reasoning" || hasOpaqueReasoningState(record)) return ""; + if (!Array.isArray(record.content)) return ""; + + return record.content + .map((part) => { + const content = asRecord(part); + return content?.type === "reasoning_text" && typeof content.text === "string" + ? content.text + : ""; + }) + .filter((text) => text.trim().length > 0) + .join("\n\n"); +} + +export function hasOpaqueReasoningState(record: JsonRecord): boolean { + return ( + isNonEmptyString(record.encrypted_content) || + record.signature !== undefined || + record.format !== undefined + ); +} + +function hasOpaqueReasoningDetail(value: unknown): boolean { + const record = asRecord(value); + if (!record) return false; + const type = typeof record.type === "string" ? record.type.toLowerCase() : ""; + return ( + hasOpaqueReasoningState(record) || + ((type.includes("encrypted") || type.includes("opaque")) && isNonEmptyString(record.data)) + ); +} + +function hasChatOpaqueReasoning(record: JsonRecord): boolean { + return ( + hasOpaqueReasoningState(record) || + (Array.isArray(record.reasoning_details) && + record.reasoning_details.some(hasOpaqueReasoningDetail)) + ); +} + +export function inspectChatReasoning(messages: unknown): ReasoningStateInspection { + const inspection: ReasoningStateInspection = { hasPlaintext: false, hasOpaque: false }; + if (!Array.isArray(messages)) return inspection; + + for (const message of messages) { + const record = asRecord(message); + if (!record || record.role !== "assistant") continue; + inspection.hasPlaintext ||= hasChatPlaintextReasoning(record); + inspection.hasOpaque ||= hasChatOpaqueReasoning(record); + if (inspection.hasPlaintext && inspection.hasOpaque) break; + } + return inspection; +} + +export function inspectResponsesReasoning(input: unknown): ReasoningStateInspection { + const inspection: ReasoningStateInspection = { hasPlaintext: false, hasOpaque: false }; + if (!Array.isArray(input)) return inspection; + + for (const item of input) { + const record = asRecord(item); + if (!record || record.type !== "reasoning") continue; + inspection.hasPlaintext ||= hasPlaintextReasoning(record); + inspection.hasOpaque ||= hasOpaqueReasoningState(record); + if (inspection.hasPlaintext && inspection.hasOpaque) break; + } + return inspection; +} + +function isReasoningCompatible( + inspection: ReasoningStateInspection, + transport: ReasoningTransport +): boolean { + if (!inspection.hasPlaintext && !inspection.hasOpaque) return true; + if (transport === "plaintext") return !inspection.hasOpaque; + if (transport === "opaque") return !inspection.hasPlaintext; + return false; +} + +function stripOpaqueFields(record: JsonRecord): void { + delete record.encrypted_content; + delete record.signature; + delete record.format; + delete record.data; +} + +function stripChatReasoningDetails(details: unknown[], transport: ReasoningTransport): unknown[] { + return details.flatMap((detail) => { + const record = asRecord(detail); + if (!record) return [detail]; + + const plaintext = + !isSummaryDetail(record) && + (isNonEmptyString(record.text) || isNonEmptyString(record.content)); + const opaque = hasOpaqueReasoningDetail(record); + if ((!plaintext || transport === "plaintext") && (!opaque || transport === "opaque")) { + return [detail]; + } + + const next = { ...record }; + if (plaintext && transport !== "plaintext") { + delete next.text; + delete next.content; + } + if (opaque && transport !== "opaque") stripOpaqueFields(next); + const remainingKeys = Object.keys(next).filter((key) => key !== "type"); + return remainingKeys.length > 0 ? [next] : []; + }); +} + +function dropIncompatibleChatReasoning( + messages: unknown[], + transport: ReasoningTransport +): unknown[] { + return messages.map((message) => { + const record = asRecord(message); + if (!record || record.role !== "assistant") return message; + const next = { ...record }; + if (transport !== "plaintext") { + for (const field of CHAT_PLAINTEXT_REASONING_FIELDS) delete next[field]; + } + if (transport !== "opaque") stripOpaqueFields(next); + if (Array.isArray(record.reasoning_details)) { + const details = stripChatReasoningDetails(record.reasoning_details, transport); + if (details.length > 0) next.reasoning_details = details; + else delete next.reasoning_details; + } + return next; + }); +} + +function hasDisplaySummary(record: JsonRecord): boolean { + return record.summary !== undefined || record.summary_text !== undefined; +} + +function dropIncompatibleResponsesReasoning( + record: JsonRecord, + transport: ReasoningTransport +): JsonRecord | null { + const next = { ...record }; + if (transport !== "plaintext" && Array.isArray(record.content)) { + const content = record.content.filter((part) => asRecord(part)?.type !== "reasoning_text"); + if (content.length > 0) next.content = content; + else delete next.content; + } + if (transport !== "opaque") stripOpaqueFields(next); + const stillActive = hasPlaintextReasoning(next) || hasOpaqueReasoningState(next); + return stillActive || hasDisplaySummary(next) ? next : null; +} + +function sanitizeResponsesInput( + input: unknown[], + transport: ReasoningTransport, + dropIncompatible: boolean +): unknown[] { + const filtered: unknown[] = []; + for (const item of input) { + if (typeof item === "string") continue; + const record = asRecord(item); + if (!record) { + filtered.push(item); + continue; + } + if (record.type === "item_reference") continue; + + if (record.type === "reasoning") { + const next = dropIncompatible + ? dropIncompatibleResponsesReasoning(record, transport) + : { ...record }; + if (!next) continue; + const hasPlaintext = hasPlaintextReasoning(next); + const hasOpaque = hasOpaqueReasoningState(next); + if (!hasPlaintext && !hasOpaque && !hasDisplaySummary(next)) continue; + if (!hasOpaque && typeof next.id === "string") delete next.id; + filtered.push(next); + continue; + } + + const cloned = { ...record }; + if (typeof cloned.id === "string") delete cloned.id; + filtered.push(cloned); + } + return filtered; +} + +/** + * Applies one protocol-independent compatibility decision before request translation. + * Plaintext is portable by default; opaque state requires an explicit target declaration. + * Display summaries are neither inspected nor removed. + */ +export function applyReasoningInputPolicy( + body: Record, + inputFormat: ReasoningInputFormat, + options: ReasoningInputPolicyOptions = {} +): ReasoningInputPolicyResult { + const transport = resolveReasoningTransport(options.provider, options.preserveEncryptedReasoning); + const inspection = + inputFormat === "responses" + ? inspectResponsesReasoning(body.input) + : inspectChatReasoning(body.messages); + const incompatibleReasoning = !isReasoningCompatible(inspection, transport); + + if (incompatibleReasoning && options.onIncompatibleReasoning !== "drop") { + return { incompatibleReasoning: true }; + } + + if (inputFormat === "chat") { + if (incompatibleReasoning && Array.isArray(body.messages)) { + body.messages = dropIncompatibleChatReasoning(body.messages, transport); + } + return { incompatibleReasoning: false }; + } + + if (Array.isArray(body.input) && body.input.length === 0) { + body.input = [ + { + type: "message", + role: "user", + content: [{ type: "input_text", text: "continue" }], + }, + ]; + } + if (!Array.isArray(body.input)) return { incompatibleReasoning: false }; + body.input = sanitizeResponsesInput(body.input, transport, incompatibleReasoning); + return { incompatibleReasoning: false }; +} diff --git a/open-sse/services/responsesInputPolicy.ts b/open-sse/services/responsesInputPolicy.ts deleted file mode 100644 index 5e48a56d92dd..000000000000 --- a/open-sse/services/responsesInputPolicy.ts +++ /dev/null @@ -1,190 +0,0 @@ -import { REGISTRY } from "../config/providerRegistry.ts"; -import type { ResponsesReasoningTransport } from "../config/providerRegistry.ts"; - -type JsonRecord = Record; - -const REASONING_TRANSPORTS = new Map(); -for (const [id, entry] of Object.entries(REGISTRY)) { - if (!entry.responsesReasoningTransport) continue; - REASONING_TRANSPORTS.set(id.toLowerCase(), entry.responsesReasoningTransport); - if (entry.alias) { - REASONING_TRANSPORTS.set(entry.alias.toLowerCase(), entry.responsesReasoningTransport); - } -} - -export interface ResponsesInputPolicyOptions { - provider?: string | null; - preserveEncryptedReasoning?: boolean; - onIncompatibleReasoning?: "reject" | "drop"; -} - -export interface ResponsesInputPolicyResult { - incompatibleReasoning: boolean; -} - -export function resolveResponsesReasoningTransport( - provider: string | null | undefined, - preserveEncryptedReasoning = false -): ResponsesReasoningTransport | null { - const normalized = typeof provider === "string" ? provider.trim().toLowerCase() : ""; - const transport = REASONING_TRANSPORTS.get(normalized); - return transport ?? (preserveEncryptedReasoning ? "opaque" : null); -} - -export function createReasoningTransportIncompatibleError(): Error & { - statusCode: number; - errorType: string; -} { - const error = new Error( - "Reasoning continuation is not compatible with the selected target" - ) as Error & { statusCode: number; errorType: string }; - error.statusCode = 400; - error.errorType = "reasoning_transport_incompatible"; - return error; -} - -function hasPlaintextReasoning(record: JsonRecord): boolean { - return ( - Array.isArray(record.content) && - record.content.some((part) => { - const value = - part && typeof part === "object" && !Array.isArray(part) ? (part as JsonRecord) : null; - return ( - value?.type === "reasoning_text" && - typeof value.text === "string" && - value.text.trim().length > 0 - ); - }) - ); -} - -/** - * Returns only provider-authentic plaintext continuation state. Display summaries - * are excluded, and a record carrying opaque state is never cross-converted. - */ -export function extractReplayableResponsesReasoningText(value: unknown): string { - const record = - value && typeof value === "object" && !Array.isArray(value) ? (value as JsonRecord) : null; - if (!record || record.type !== "reasoning" || hasOpaqueReasoningState(record)) return ""; - if (!Array.isArray(record.content)) return ""; - - return record.content - .map((part) => { - const content = - part && typeof part === "object" && !Array.isArray(part) ? (part as JsonRecord) : null; - return content?.type === "reasoning_text" && typeof content.text === "string" - ? content.text - : ""; - }) - .filter((text) => text.trim().length > 0) - .join("\n\n"); -} - -export function hasOpaqueReasoningState(record: JsonRecord): boolean { - return ( - (typeof record.encrypted_content === "string" && record.encrypted_content.trim().length > 0) || - record.signature !== undefined || - record.format !== undefined - ); -} - -function isIncompatibleReasoningRecord( - record: JsonRecord, - transport: ResponsesReasoningTransport | null -): boolean { - if (record.type !== "reasoning") return false; - const plaintext = hasPlaintextReasoning(record); - const opaque = hasOpaqueReasoningState(record); - if (!plaintext && !opaque) return false; - return !( - (transport === "plaintext" && plaintext && !opaque) || - (transport === "opaque" && opaque && !plaintext) - ); -} - -/** - * Removes Responses state that is not portable to the selected upstream. - * Plaintext reasoning and provider-generated opaque reasoning are separate - * transports; unknown targets receive neither. The optional drop action removes - * all reasoning when any active item is incompatible. Retained items are cloned - * so Combo attempts cannot mutate each other's fallback input. - */ -export function applyResponsesInputPolicy( - body: Record, - options: ResponsesInputPolicyOptions = {} -): ResponsesInputPolicyResult { - if (Array.isArray(body.input) && body.input.length === 0) { - body.input = [ - { - type: "message", - role: "user", - content: [{ type: "input_text", text: "continue" }], - }, - ]; - } - - if (!Array.isArray(body.input)) return { incompatibleReasoning: false }; - - const transport = resolveResponsesReasoningTransport( - options.provider, - options.preserveEncryptedReasoning - ); - const dropAllReasoning = - options.onIncompatibleReasoning === "drop" && - body.input.some((item) => { - const record = - item && typeof item === "object" && !Array.isArray(item) ? (item as JsonRecord) : null; - return record ? isIncompatibleReasoningRecord(record, transport) : false; - }); - let incompatibleReasoning = false; - const filtered: unknown[] = []; - - for (const item of body.input) { - // Array-form Responses input contains item objects. Bare strings are stored - // provider references and are not portable across concrete targets. - if (typeof item === "string") { - continue; - } - - const record = - item && typeof item === "object" && !Array.isArray(item) ? (item as JsonRecord) : null; - if (!record) { - filtered.push(item); - continue; - } - - if (record.type === "item_reference") { - continue; - } - - if (record.type === "reasoning") { - if (dropAllReasoning) continue; - const plaintext = hasPlaintextReasoning(record); - const opaque = hasOpaqueReasoningState(record); - if (plaintext || opaque) { - const compatible = - (transport === "plaintext" && plaintext && !opaque) || - (transport === "opaque" && opaque && !plaintext); - if (!compatible) { - incompatibleReasoning = true; - continue; - } - if (transport === "opaque") { - filtered.push({ ...record }); - continue; - } - } else { - continue; - } - } - - const cloned = { ...record }; - if (typeof cloned.id === "string") { - delete cloned.id; - } - filtered.push(cloned); - } - - body.input = filtered; - return { incompatibleReasoning }; -} diff --git a/open-sse/translator/request/openai-responses.ts b/open-sse/translator/request/openai-responses.ts index 3227b8c6880a..b88665025242 100644 --- a/open-sse/translator/request/openai-responses.ts +++ b/open-sse/translator/request/openai-responses.ts @@ -12,7 +12,7 @@ import { createReasoningTransportIncompatibleError, hasOpaqueReasoningState, extractReplayableResponsesReasoningText, -} from "../../services/responsesInputPolicy.ts"; +} from "../../services/reasoningInputPolicy.ts"; import { getRegisteredProviders, requiresPlainStringContent, diff --git a/open-sse/translator/request/openai-responses/toResponses.ts b/open-sse/translator/request/openai-responses/toResponses.ts index 9cbc3cc0ba90..bee5efab1a27 100644 --- a/open-sse/translator/request/openai-responses/toResponses.ts +++ b/open-sse/translator/request/openai-responses/toResponses.ts @@ -4,10 +4,6 @@ * Extracted verbatim from openai-responses.ts. Registration stays in the host. */ import { isOpenAIResponsesStoreEnabled } from "@/lib/providers/requestDefaults"; -import { - createReasoningTransportIncompatibleError, - resolveResponsesReasoningTransport, -} from "../../../services/responsesInputPolicy.ts"; import { isInternalReasoningPlaceholder } from "../../../utils/reasoningPlaceholder.ts"; import { getReadableReasoningValue } from "../../../utils/reasoningFields.ts"; import { generateToolCallId } from "../../helpers/toolCallHelper.ts"; @@ -92,11 +88,6 @@ export function openaiToOpenAIResponsesRequest( const root = toRecord(body); const credentialRecord = toRecord(credentials); const storeEnabled = isOpenAIResponsesStoreEnabled(credentialRecord.providerSpecificData); - const providerSpecificData = toRecord(credentialRecord.providerSpecificData); - const reasoningTransport = resolveResponsesReasoningTransport( - toString(credentialRecord._provider), - providerSpecificData.preserveEncryptedReasoning === true - ); const result: JsonRecord = { model, input: [], @@ -205,16 +196,12 @@ export function openaiToOpenAIResponsesRequest( if (role === "assistant") { const reasoning = getReadableReasoningValue(msg).trim(); if (reasoning && !isInternalReasoningPlaceholder(reasoning)) { - if (reasoningTransport === "plaintext") { - // DeepSeek accepts genuine plaintext reasoning without a synthetic rs_* ID. - // Keep it immediately before the assistant items it belongs to. - input.push({ - type: "reasoning", - content: [{ type: "reasoning_text", text: reasoning }], - }); - } else if (Array.isArray(msg.tool_calls) || msg.function_call) { - throw createReasoningTransportIncompatibleError(); - } + // Compatibility is decided before protocol translation; this adapter + // only encodes the surviving portable plaintext state. + input.push({ + type: "reasoning", + content: [{ type: "reasoning_text", text: reasoning }], + }); } // Thinking blocks remain display-only here. They do not prove that the diff --git a/open-sse/translator/response/openai-responses.ts b/open-sse/translator/response/openai-responses.ts index 20a934402f98..d2cd91323b41 100644 --- a/open-sse/translator/response/openai-responses.ts +++ b/open-sse/translator/response/openai-responses.ts @@ -12,7 +12,7 @@ import { isInternalReasoningPlaceholder, stripInternalReasoningPlaceholder, } from "../../utils/reasoningPlaceholder.ts"; -import { extractReplayableResponsesReasoningText } from "../../services/responsesInputPolicy.ts"; +import { extractReplayableResponsesReasoningText } from "../../services/reasoningInputPolicy.ts"; import { normalizeToolName, stripEmptyOptionalToolArgs, diff --git a/src/app/(dashboard)/dashboard/combos/page.tsx b/src/app/(dashboard)/dashboard/combos/page.tsx index ea876dada173..864360b8677b 100644 --- a/src/app/(dashboard)/dashboard/combos/page.tsx +++ b/src/app/(dashboard)/dashboard/combos/page.tsx @@ -189,7 +189,7 @@ const ADVANCED_FIELD_HELP_FALLBACK = { nestedComboMode: "How references to other combos are handled. Flatten expands a combo ref into this combo's target list (legacy). Execute treats a combo ref as a black-box target: the parent strategy selects the child combo, then the child runs its own strategy and retries.", reasoningTransportFallback: - "What to do when the next combo target cannot accept the original reasoning transport. Skip leaves the request body untouched and falls through to the next target; Drop removes reasoning state and tries the target anyway.", + "What to do when the next combo target cannot accept the original reasoning transport. Drop is the default: it removes reasoning state and tries the target. Skip leaves the request body untouched and falls through.", }; const LEGACY_COMBO_RESILIENCE_KEYS = new Set([ @@ -3815,12 +3815,12 @@ function ComboFormModal({ isOpen, combo, onClose, onSave, activeProviders, combo /> - {config.reasoningTransportFallback === "drop" && ( + {config.reasoningTransportFallback !== "skip" && (

{getI18nOrFallback( t, diff --git a/src/sse/handlers/chat.ts b/src/sse/handlers/chat.ts index 34d9bc889cc7..2aca8ade01e5 100644 --- a/src/sse/handlers/chat.ts +++ b/src/sse/handlers/chat.ts @@ -878,7 +878,7 @@ async function handleChatImplementation( const relayConfig = combo.strategy === "context-relay" ? resolveComboConfig(combo, settings) : null; const reasoningTransportFallback = - combo.config?.reasoningTransportFallback === "drop" ? "drop" : "skip"; + combo.config?.reasoningTransportFallback === "skip" ? "skip" : "drop"; // Per-request Auto-Combo controls (#6023 / #6024 / #6025 / #3470): steer an // `auto` combo on this single request without mutating its stored config. const perRequestAutoControls = resolveRequestAutoControls(request.headers); @@ -1204,7 +1204,7 @@ async function handleSingleModelChat( providerId: resolvedTarget?.providerId ?? null, correlationId: runtimeOptions?.correlationId ?? null, reasoningTransportFallback: - redirectCombo.config?.reasoningTransportFallback === "drop" ? "drop" : "skip", + redirectCombo.config?.reasoningTransportFallback === "skip" ? "skip" : "drop", // #7360 follow-up — see the primary handleSingleModel closure above. modelAbortSignal: target?.modelAbortSignal ?? null, }, diff --git a/tests/unit/chat-route-coverage.test.ts b/tests/unit/chat-route-coverage.test.ts index 6595b91d525a..ced2e7c43ec8 100644 --- a/tests/unit/chat-route-coverage.test.ts +++ b/tests/unit/chat-route-coverage.test.ts @@ -316,7 +316,7 @@ test("handleChat keeps protected combo fallback separate from Global Fallback Mo assert.equal(json.choices[0].message.content, "Global fallback answered"); }); -test("handleChat applies a Combo's drop fallback to incompatible reasoning transport", async () => { +test("handleChat defaults a Combo's incompatible reasoning fallback to drop", async () => { await seedConnection("deepseek", { apiKey: "sk-deepseek-reasoning-drop" }); await combosDb.createCombo({ name: "reasoning-transport-drop", @@ -324,7 +324,6 @@ test("handleChat applies a Combo's drop fallback to incompatible reasoning trans config: { maxRetries: 0, retryDelayMs: 0, - reasoningTransportFallback: "drop", }, models: ["deepseek/deepseek-v4-flash"], }); diff --git a/tests/unit/chatcore-translation-paths.test.ts b/tests/unit/chatcore-translation-paths.test.ts index d62a9fb23af5..67b6c5e39b8f 100644 --- a/tests/unit/chatcore-translation-paths.test.ts +++ b/tests/unit/chatcore-translation-paths.test.ts @@ -675,7 +675,10 @@ test("chatCore rejects opaque reasoning for unknown Responses targets unless exp const input = enabled.call.body.input as Array>; assert.deepEqual( input.filter((item) => item.type === "reasoning"), - [{ id: "rs_valid", type: "reasoning", encrypted_content: "encrypted-blob" }] + [ + { id: "rs_valid", type: "reasoning", encrypted_content: "encrypted-blob" }, + { type: "reasoning", summary: [{ text: "not self-contained" }] }, + ] ); assert.equal( input.some((item) => item.type === "item_reference"), @@ -684,6 +687,47 @@ test("chatCore rejects opaque reasoning for unknown Responses targets unless exp assert.equal(input.find((item) => item.type === "function_call")?.id, undefined); }); +test("chatCore applies Chat reasoning compatibility before stream mode diverges", async () => { + for (const stream of [false, true]) { + const rejected = await invokeChatCore({ + provider: "openai-compatible-sp-openai", + model: "gpt-5.4", + endpoint: "/v1/chat/completions", + credentials: { + apiKey: "sk-test", + providerSpecificData: { + apiType: "openai", + baseUrl: "https://proxy.example.com/v1", + prefix: "sp-openai", + }, + }, + body: { + model: "gpt-5.4", + stream, + messages: [ + { + role: "assistant", + content: null, + reasoning_details: [{ type: "reasoning.encrypted", data: "provider-state" }], + tool_calls: [ + { + id: "call_1", + type: "function", + function: { name: "search", arguments: "{}" }, + }, + ], + }, + { role: "tool", tool_call_id: "call_1", content: "result" }, + ], + }, + }); + + assert.equal(rejected.result.success, false, `stream=${stream}`); + assert.equal(rejected.result.status, 400, `stream=${stream}`); + assert.equal(rejected.calls.length, 0, `stream=${stream}`); + } +}); + test("chatCore can drop incompatible reasoning for an opted-in Combo attempt", async () => { const dropped = await invokeChatCore({ provider: "openai-compatible-sp-openai", diff --git a/tests/unit/combo-attempt-body-isolation-7847.test.ts b/tests/unit/combo-attempt-body-isolation-7847.test.ts index f06471250fb1..9373c51e7c28 100644 --- a/tests/unit/combo-attempt-body-isolation-7847.test.ts +++ b/tests/unit/combo-attempt-body-isolation-7847.test.ts @@ -22,8 +22,8 @@ const ORIGINAL_DATA_DIR = process.env.DATA_DIR; process.env.DATA_DIR = TEST_DATA_DIR; const { handleComboChat } = await import("../../open-sse/services/combo.ts"); -const { applyResponsesInputPolicy } = - await import("../../open-sse/services/responsesInputPolicy.ts"); +const { applyReasoningInputPolicy } = + await import("../../open-sse/services/reasoningInputPolicy.ts"); const core = await import("../../src/lib/db/core.ts"); const { resetAllComboMetrics } = await import("../../open-sse/services/comboMetrics.ts"); const { resetAllCircuitBreakers } = await import("../../src/shared/utils/circuitBreaker.ts"); @@ -193,8 +193,11 @@ test("incompatible reasoning skips a target without mutating the fallback attemp attempts++; const input = received.input as Array>; if (attempts === 1) { - const policy = applyResponsesInputPolicy(received, { provider: "openai" }); - assert.equal(policy.incompatibleReasoning, true); + const policy = applyReasoningInputPolicy(received, "responses", { + provider: "openai", + onIncompatibleReasoning: "drop", + }); + assert.equal(policy.incompatibleReasoning, false); assert.equal( (received.input as Array>).some( (item) => item.type === "reasoning" @@ -216,7 +219,9 @@ test("incompatible reasoning skips a target without mutating the fallback attemp "fc_shared", "the first target's nested input rewrite leaked into the fallback" ); - const policy = applyResponsesInputPolicy(received, { provider: "deepseek" }); + const policy = applyReasoningInputPolicy(received, "responses", { + provider: "deepseek", + }); assert.equal(policy.incompatibleReasoning, false); assert.equal( (received.input as Array>)[0].type, diff --git a/tests/unit/strip-reasoning-blobs-agentic-context-1599.test.ts b/tests/unit/strip-reasoning-blobs-agentic-context-1599.test.ts index bd50b87689c3..a77f6613a120 100644 --- a/tests/unit/strip-reasoning-blobs-agentic-context-1599.test.ts +++ b/tests/unit/strip-reasoning-blobs-agentic-context-1599.test.ts @@ -1,14 +1,14 @@ import test from "node:test"; import assert from "node:assert/strict"; -import { applyResponsesInputPolicy } from "../../open-sse/services/responsesInputPolicy.ts"; +import { applyReasoningInputPolicy } from "../../open-sse/services/reasoningInputPolicy.ts"; import { filterToOpenAIFormat } from "../../open-sse/translator/helpers/openaiHelper.ts"; import { omitEncryptedReasoningForLog } from "../../src/lib/logPayloads.ts"; // Responses reasoning replay is target-scoped. Plaintext DeepSeek state and // provider-generated opaque state are never interchangeable. -test("unknown Responses targets drop reasoning and report incompatibility", () => { +test("unknown Responses targets reject opaque reasoning and ignore display summaries", () => { const body: Record = { input: [ { type: "message", role: "user", content: [{ type: "input_text", text: "hi" }] }, @@ -24,19 +24,14 @@ test("unknown Responses targets drop reasoning and report incompatibility", () = ], }; - const result = applyResponsesInputPolicy(body); - const input = body.input as Array>; + const originalInput = structuredClone(body.input); + const result = applyReasoningInputPolicy(body, "responses"); assert.equal(result.incompatibleReasoning, true); - assert.equal( - input.some((item) => item.type === "reasoning"), - false - ); - assert.equal(input.length, 2); - assert.equal(input[1].id, undefined, "fc_ server id stripped, item kept"); + assert.deepEqual(body.input, originalInput, "rejection must not mutate the request"); }); -test("DeepSeek preserves plaintext Responses reasoning without synthetic IDs", () => { +test("unannotated targets preserve plaintext Responses reasoning without synthetic IDs", () => { const body: Record = { input: [ { @@ -48,7 +43,7 @@ test("DeepSeek preserves plaintext Responses reasoning without synthetic IDs", ( ], }; - const result = applyResponsesInputPolicy(body, { provider: "deepseek" }); + const result = applyReasoningInputPolicy(body, "responses", { provider: "opencode-go" }); assert.equal(result.incompatibleReasoning, false); assert.deepEqual(body.input, [ @@ -60,6 +55,83 @@ test("DeepSeek preserves plaintext Responses reasoning without synthetic IDs", ( ]); }); +test("display summaries coexist with plaintext continuation without affecting compatibility", () => { + const body: Record = { + input: [ + { + id: "rs_plaintext_summary", + type: "reasoning", + content: [{ type: "reasoning_text", text: "inspect first" }], + summary: [{ type: "summary_text", text: "display only" }], + }, + ], + }; + + const result = applyReasoningInputPolicy(body, "responses", { provider: "opencode-go" }); + + assert.equal(result.incompatibleReasoning, false); + assert.deepEqual(body.input, [ + { + type: "reasoning", + content: [{ type: "reasoning_text", text: "inspect first" }], + summary: [{ type: "summary_text", text: "display only" }], + }, + ]); +}); + +test("unannotated targets preserve Chat plaintext and ignore display summaries", () => { + const body: Record = { + messages: [ + { + role: "assistant", + content: null, + reasoning_content: "inspect first", + summary_text: "display only", + }, + { role: "user", content: "continue" }, + ], + }; + const originalMessages = body.messages; + + const result = applyReasoningInputPolicy(body, "chat", { provider: "opencode-go" }); + + assert.equal(result.incompatibleReasoning, false); + assert.equal(body.messages, originalMessages, "compatible Chat history should not be cloned"); +}); + +test("Chat drop removes opaque state while preserving plaintext and summary details", () => { + const body: Record = { + messages: [ + { + role: "assistant", + content: null, + reasoning_content: "inspect first", + reasoning_details: [ + { type: "reasoning.encrypted", data: "provider-state" }, + { type: "reasoning.summary", text: "display only" }, + ], + }, + { role: "user", content: "continue" }, + ], + }; + + const result = applyReasoningInputPolicy(body, "chat", { + provider: "opencode-go", + onIncompatibleReasoning: "drop", + }); + + assert.equal(result.incompatibleReasoning, false); + assert.deepEqual(body.messages, [ + { + role: "assistant", + content: null, + reasoning_content: "inspect first", + reasoning_details: [{ type: "reasoning.summary", text: "display only" }], + }, + { role: "user", content: "continue" }, + ]); +}); + test("DeepSeek rejects plaintext reasoning carrying opaque provider state", () => { for (const opaqueField of ["signature", "format"] as const) { const body: Record = { @@ -74,16 +146,22 @@ test("DeepSeek rejects plaintext reasoning carrying opaque provider state", () = ], }; - const result = applyResponsesInputPolicy(body, { provider: "deepseek" }); + const result = applyReasoningInputPolicy(body, "responses", { provider: "deepseek" }); assert.equal(result.incompatibleReasoning, true, opaqueField); assert.deepEqual(body.input, [ + { + id: "rs_mixed123", + type: "reasoning", + content: [{ type: "reasoning_text", text: "untrusted companion" }], + [opaqueField]: "provider-state", + }, { type: "message", role: "user", content: [{ type: "input_text", text: "continue" }] }, ]); } }); -test("drop fallback removes all reasoning when any active transport is incompatible", () => { +test("drop fallback removes only the incompatible active transport and preserves summaries", () => { const plaintextReasoning = { id: "rs_plaintext", type: "reasoning", @@ -93,6 +171,7 @@ test("drop fallback removes all reasoning when any active transport is incompati id: "rs_opaque", type: "reasoning", encrypted_content: "provider-state", + summary: [{ type: "summary_text", text: "display only" }], }; const body: Record = { input: [ @@ -102,15 +181,22 @@ test("drop fallback removes all reasoning when any active transport is incompati ], }; - const result = applyResponsesInputPolicy(body, { + const result = applyReasoningInputPolicy(body, "responses", { provider: "deepseek", onIncompatibleReasoning: "drop", }); assert.equal(result.incompatibleReasoning, false); - assert.deepEqual(body.input, [{ type: "function_call", call_id: "call_1", name: "search" }]); + assert.deepEqual(body.input, [ + { + type: "reasoning", + content: [{ type: "reasoning_text", text: "inspect first" }], + }, + { type: "reasoning", summary: [{ type: "summary_text", text: "display only" }] }, + { type: "function_call", call_id: "call_1", name: "search" }, + ]); assert.equal(plaintextReasoning.id, "rs_plaintext"); - assert.equal(opaqueReasoning.id, "rs_opaque"); + assert.equal(opaqueReasoning.encrypted_content, "provider-state"); }); test("drop fallback preserves reasoning when its transport is compatible", () => { @@ -124,7 +210,7 @@ test("drop fallback preserves reasoning when its transport is compatible", () => ], }; - const result = applyResponsesInputPolicy(body, { + const result = applyReasoningInputPolicy(body, "responses", { provider: "deepseek", onIncompatibleReasoning: "drop", }); @@ -158,7 +244,7 @@ test("known opaque targets preserve a cloned complete provider-generated reasoni ], }; - const result = applyResponsesInputPolicy(body, { provider: "openai" }); + const result = applyReasoningInputPolicy(body, "responses", { provider: "openai" }); assert.notEqual( (body.input as Array>)[0], opaqueReasoning, @@ -185,7 +271,7 @@ test("explicit custom target opt-in remains an opaque transport override", () => input: [{ type: "reasoning", encrypted_content: "encrypted-blob" }], }; - const result = applyResponsesInputPolicy(body, { preserveEncryptedReasoning: true }); + const result = applyReasoningInputPolicy(body, "responses", { preserveEncryptedReasoning: true }); assert.equal(result.incompatibleReasoning, false); assert.deepEqual(body.input, [{ type: "reasoning", encrypted_content: "encrypted-blob" }]); @@ -196,14 +282,14 @@ test("preserved opaque reasoning remains redacted from log copies", () => { input: [{ type: "reasoning", encrypted_content: "provider-secret-blob" }], }; - applyResponsesInputPolicy(body, { provider: "xai" }); + applyReasoningInputPolicy(body, "responses", { provider: "xai" }); const logged = omitEncryptedReasoningForLog(body) as typeof body; assert.equal(body.input[0].encrypted_content, "provider-secret-blob"); assert.equal(logged.input[0].encrypted_content, "[omitted: encrypted reasoning, 20 chars]"); }); -test("summary-only reasoning is omitted for every transport", () => { +test("summary-only reasoning is preserved independently of active transport", () => { const body: Record = { input: [ { id: "rs_summary123", type: "reasoning", summary: [{ text: "thinking..." }] }, @@ -212,10 +298,11 @@ test("summary-only reasoning is omitted for every transport", () => { ], }; - const result = applyResponsesInputPolicy(body, { provider: "openai" }); + const result = applyReasoningInputPolicy(body, "responses", { provider: "openai" }); assert.equal(result.incompatibleReasoning, false); assert.deepEqual(body.input, [ + { type: "reasoning", summary: [{ text: "thinking..." }] }, { type: "message", role: "user", content: [{ type: "input_text", text: "hi" }] }, ]); }); diff --git a/tests/unit/translator-openai-responses-req.test.ts b/tests/unit/translator-openai-responses-req.test.ts index 4bfba3edb52d..e0d0a5177318 100644 --- a/tests/unit/translator-openai-responses-req.test.ts +++ b/tests/unit/translator-openai-responses-req.test.ts @@ -447,9 +447,9 @@ test("Chat -> Responses clamps call_id to 64 chars and keeps the pair matched (p ); }); -test("Chat -> DeepSeek Responses emits ID-less plaintext reasoning before its function call", () => { +test("Chat -> Responses defaults unannotated targets to plaintext reasoning", () => { const result = openaiToOpenAIResponsesRequest( - "deepseek-v4-pro", + "deepseek-v4-flash", { messages: [ { @@ -468,7 +468,7 @@ test("Chat -> DeepSeek Responses emits ID-less plaintext reasoning before its fu ], }, false, - { _provider: "deepseek" } + { _provider: "opencode-go" } ) as { input: Array> }; assert.deepEqual(result.input, [ @@ -516,33 +516,6 @@ test("Chat -> DeepSeek Responses accepts the plaintext reasoning alias", () => { }); }); -test("Chat -> Responses rejects tool continuation for an incompatible target", () => { - assert.throws( - () => - openaiToOpenAIResponsesRequest( - "gpt-5.4-pro", - { - messages: [ - { - role: "assistant", - reasoning_content: "Provider plaintext", - tool_calls: [ - { - id: "call_1", - type: "function", - function: { name: "search", arguments: "{}" }, - }, - ], - }, - ], - }, - false, - { _provider: "openai" } - ), - /Reasoning continuation is not compatible/ - ); -}); - test("Chat -> Responses never promotes OmniRoute's internal reasoning placeholder", () => { const result = openaiToOpenAIResponsesRequest( "deepseek-v4-pro", From d7fcf1b63554a03cc053c55d131440bd7b4e195a Mon Sep 17 00:00:00 2001 From: jackjinke Date: Thu, 20 Aug 2026 15:10:21 +0800 Subject: [PATCH 09/10] fix(reasoning): drop orphan summaries for stateless input --- open-sse/services/reasoningInputPolicy.ts | 16 ++++-- tests/unit/executor-codex.test.ts | 55 +++++++++++++++++-- ...asoning-blobs-agentic-context-1599.test.ts | 26 +++++++++ 3 files changed, 89 insertions(+), 8 deletions(-) diff --git a/open-sse/services/reasoningInputPolicy.ts b/open-sse/services/reasoningInputPolicy.ts index 037f57de4a26..71a283a706d8 100644 --- a/open-sse/services/reasoningInputPolicy.ts +++ b/open-sse/services/reasoningInputPolicy.ts @@ -256,7 +256,8 @@ function dropIncompatibleResponsesReasoning( function sanitizeResponsesInput( input: unknown[], transport: ReasoningTransport, - dropIncompatible: boolean + dropIncompatible: boolean, + stripOrphanedSummaries: boolean ): unknown[] { const filtered: unknown[] = []; for (const item of input) { @@ -275,7 +276,9 @@ function sanitizeResponsesInput( if (!next) continue; const hasPlaintext = hasPlaintextReasoning(next); const hasOpaque = hasOpaqueReasoningState(next); - if (!hasPlaintext && !hasOpaque && !hasDisplaySummary(next)) continue; + if (!hasPlaintext && !hasOpaque && (!hasDisplaySummary(next) || stripOrphanedSummaries)) { + continue; + } if (!hasOpaque && typeof next.id === "string") delete next.id; filtered.push(next); continue; @@ -291,7 +294,7 @@ function sanitizeResponsesInput( /** * Applies one protocol-independent compatibility decision before request translation. * Plaintext is portable by default; opaque state requires an explicit target declaration. - * Display summaries are neither inspected nor removed. + * Display summaries do not affect compatibility; stateless input drops orphan summaries. */ export function applyReasoningInputPolicy( body: Record, @@ -326,6 +329,11 @@ export function applyReasoningInputPolicy( ]; } if (!Array.isArray(body.input)) return { incompatibleReasoning: false }; - body.input = sanitizeResponsesInput(body.input, transport, incompatibleReasoning); + body.input = sanitizeResponsesInput( + body.input, + transport, + incompatibleReasoning, + body.store === false + ); return { incompatibleReasoning: false }; } diff --git a/tests/unit/executor-codex.test.ts b/tests/unit/executor-codex.test.ts index 9913e02b5cdf..359768a3bd13 100644 --- a/tests/unit/executor-codex.test.ts +++ b/tests/unit/executor-codex.test.ts @@ -450,7 +450,7 @@ test("CodexExecutor.transformRequest strips store from compact requests even whe assert.equal(result.instructions, "keep this"); }); -test("CodexExecutor.transformRequest preserves native assistant commentary history", () => { +test("CodexExecutor.transformRequest preserves commentary and strips orphan summaries", () => { const executor = new CodexExecutor(); const body = { _nativeCodexPassthrough: true, @@ -526,9 +526,8 @@ test("CodexExecutor.transformRequest preserves native assistant commentary histo ), true ); - // Reasoning items are stripped from the Responses input — encrypted_content is - // unusable with store=false (previous_response_id deleted) and the summary blob - // only inflates context on every subsequent agentic turn (decolua/9router#1599). + // Summary-only reasoning is display state, not continuation state. Replaying it + // with store=false only inflates every subsequent agentic turn (decolua/9router#1599). assert.equal( result.input.some((item) => item.type === "reasoning"), false @@ -543,6 +542,54 @@ test("CodexExecutor.transformRequest preserves native assistant commentary histo ); }); +test("CodexExecutor.transformRequest preserves active opaque reasoning with a summary", () => { + const executor = new CodexExecutor(); + const reasoning = { + type: "reasoning", + encrypted_content: "provider-state", + summary: [{ type: "summary_text", text: "Display summary" }], + }; + + const result = executor.transformRequest( + "gpt-5.5-low", + { + _nativeCodexPassthrough: true, + input: [reasoning], + stream: false, + }, + false, + { requestEndpointPath: "/responses" } + ); + + assert.equal(result.store, false); + assert.deepEqual(result.input, [reasoning]); +}); + +test("CodexExecutor.transformRequest preserves orphan summaries when store is enabled", () => { + const executor = new CodexExecutor(); + const reasoning = { + type: "reasoning", + summary: [{ type: "summary_text", text: "Display summary" }], + }; + + const result = executor.transformRequest( + "gpt-5.5-low", + { + _nativeCodexPassthrough: true, + input: [reasoning], + stream: false, + }, + false, + { + requestEndpointPath: "/responses", + providerSpecificData: { openaiStoreEnabled: true }, + } + ); + + assert.equal(result.store, true); + assert.deepEqual(result.input, [reasoning]); +}); + test("CodexExecutor.transformRequest still strips assistant commentary outside native passthrough", () => { const executor = new CodexExecutor(); const result = executor.transformRequest( diff --git a/tests/unit/strip-reasoning-blobs-agentic-context-1599.test.ts b/tests/unit/strip-reasoning-blobs-agentic-context-1599.test.ts index a77f6613a120..cab5e9c37f53 100644 --- a/tests/unit/strip-reasoning-blobs-agentic-context-1599.test.ts +++ b/tests/unit/strip-reasoning-blobs-agentic-context-1599.test.ts @@ -307,6 +307,32 @@ test("summary-only reasoning is preserved independently of active transport", () ]); }); +test("stateless Responses input drops orphan summaries but preserves active state", () => { + const activeReasoning = { + type: "reasoning", + encrypted_content: "provider-state", + summary: [{ type: "summary_text", text: "Display summary" }], + }; + const message = { + type: "message", + role: "user", + content: [{ type: "input_text", text: "continue" }], + }; + const body: Record = { + store: false, + input: [ + { type: "reasoning", summary: [{ type: "summary_text", text: "Orphan summary" }] }, + activeReasoning, + message, + ], + }; + + const result = applyReasoningInputPolicy(body, "responses", { provider: "codex" }); + + assert.equal(result.incompatibleReasoning, false); + assert.deepEqual(body.input, [activeReasoning, message]); +}); + test("filterToOpenAIFormat strips reasoning_content from assistant+tool_calls messages", () => { const body = { messages: [ From fe1ad812e8f0e83ab9a24cb19021d624800b797a Mon Sep 17 00:00:00 2001 From: Markus Hartung Date: Thu, 20 Aug 2026 22:16:13 -0300 Subject: [PATCH 10/10] chore(quality): rebaseline chatHelpers.ts for #10574's reasoningTransportFallback (+2) --- config/quality/file-size-baseline.json | 17 +++++++++++++---- 1 file changed, 13 insertions(+), 4 deletions(-) diff --git a/config/quality/file-size-baseline.json b/config/quality/file-size-baseline.json index 05323c8002e5..f19fc0ea0b7d 100644 --- a/config/quality/file-size-baseline.json +++ b/config/quality/file-size-baseline.json @@ -1,4 +1,5 @@ { + "_rebaseline_2026_08_20_10574_reasoning_transport_fallback": "PR #10574 (jackjinke, fix/responses-reasoning-transport, fixes #10550) own growth: src/sse/handlers/chatHelpers.ts 1017->1019 (+2 = the new reasoningTransportFallback option threaded through executeChatWithBreaker's options destructure and its downstream handleSingleModel call, at the existing per-attempt options-passthrough chokepoint; not extractable without splitting the option-forwarding call itself). Covered by the PR's own reasoning-policy test suite (tests/unit/chatcore-translation-paths.test.ts, tests/unit/combo-attempt-body-isolation-7847.test.ts, tests/unit/reasoning-cache.test.ts, tests/unit/strip-reasoning-blobs-agentic-context-1599.test.ts among others), 446/446 focused tests passing.", "_rebaseline_2026_08_18_10517_zed_hosted_oauth_callback_port": "PR #10517 (phatchau036, fix/zed-hosted-oauth-callback-port) own growth: src/shared/components/OAuthModal.tsx 1131->1148 (wc -l; check-file-size.mjs counts via split(\"\\n\").length so the gate sees 1134->1149, +15/+18, crosses the frozen 1134 cap). Wires the zed-hosted native-app callback auto-complete: forceManual gating on isTrueLocalhost for zed-hosted, the loopback-redirect-URI comment block, and the exchangeToken full-URL-as-code branch, all at the existing provider-switch chokepoints this modal already carries growth for (seventh bump: 969->989->993->998->1030->1056->1100->1149; structural shrink tracked in #3501). The actual port-derivation logic lives in src/lib/oauth/providers/zed-hosted.ts (not frozen here) and was hardened during pre-merge review to use the server's own getRuntimePorts() instead of a browser-guessed scheme/port, covered by the new tests/unit/zed-hosted-loopback-port-derivation.test.ts (8/8 passing).", "_rebaseline_2026_08_13_10243_codex_fingerprint_merge": "PR #10243 (xz-dev, Codex OAuth fingerprint convergence) merge into release/v3.8.50: src/app/(dashboard)/dashboard/providers/[id]/providerPageHelpers.ts crossed the 1000-line new-file cap for the first time (974 on base, 997 on the PR's own branch, 1013 after merging + prettier reflow) purely from combining two independent, already-legitimate feature additions that landed on the same shared UI-helper file — this PR's own Codex fingerprint-mode select/toggle wiring (CODEX_FINGERPRINT_MODE_VALUES, getCodexFingerprintModeLabel, CodexFingerprintModeValue) plus #8949's unrelated Codex account-service-tier helpers merged concurrently on release/v3.8.50. Neither addition alone crosses the cap; git's line-level auto-merge does not detect a threshold crossing. Not modularized as part of this conflict-resolution merge commit (out of scope — this is a merge, not a feature change). Covered by the PR's own tests/unit/codex-fingerprint-convergence.test.ts, tests/unit/executor-codex.test.ts, tests/unit/provider-specific-data-schema.test.ts (all passing post-merge).", "_rebaseline_2026_08_09_8984_api_key_cache_mode": "PR #8984 own growth during the 2026-08-09 rebase: src/lib/db/apiKeys.ts 1529->1545 (+16 = the per-key apiKeys.cacheDefaultMode column + its row parsers and cascade wiring; additive at the existing connection write/read chokepoints). Covered by tests/unit/chatcore-semantic-cache.test.ts. (chatCore.ts stays at the pre-existing base-red ceiling — upstream tip already exceeds the frozen 5042, this PR only adds +2 on top; not re-bumped per the no-inherit-ratchet rule.)", @@ -304,6 +305,8 @@ "_rebaseline_2026_07_27_3850_relax_filesize_cap": "OWNER-APPROVED TEMPORARY relax for v3.8.50-3.8.54 PREPARE phase (docs/ROADMAP.md). cap 800->900 (+100), testCap 800->900 (+100). Targets: decompose-existing-frozen unchanged (frozen still only-shrink); this only relaxes the cap for NEW files in the decompose/extract-while-PREPARE phase (.51='executor registry in-place' and .52='combo.ts decomposition' create new leaf modules above 800). RE-TIGHTENING MANDATORY in v3.8.51: cap target 850 = 850 once decomposition wave stabilizes. SUPERSEDED by _rebaseline_2026_07_27_3850_relax_filesize_cap_v2_20pct (v1 +20% buffer) — retained for audit. Tracked via same roadmap issue.", "_rebaseline_2026_07_27_v3849_train1h": "Merge-train 1H (31 PRs) — owner-approved 2026-07-27. Two distinct causes, kept separate on purpose: (1) GENUINE irreducible growth at existing chokepoints — providerLimits/auth (#8632 Kimi quota-reset recovery), rateLimitManager (#8616 idle wedged limiters), models-catalog-route.test (#8610 OpenCode Go effort aliases); (2) COLLISION with #8585, which banked shrinks measured on the pre-train release tip while 30 sibling PRs in the SAME train grew those files again — chat/accountFallback (#8628), chatCore (#8613), videoGeneration (#8581), imageGeneration. The zero-headroom frozen entries cannot absorb either. Ceilings re-pinned to the post-merge tip; #8612 (also in this train) automates shrink-banking so this self-inflicted drift stops recurring. Detail: src/lib/usage/providerLimits.ts 1006->1013 (#8632); src/sse/services/auth.ts 2492->2508 (#8632); open-sse/services/rateLimitManager.ts 1014->1060 (#8616); src/sse/handlers/chat.ts 1842->1845 (#8628); open-sse/handlers/chatCore.ts 4939->4955 (#8613); open-sse/handlers/imageGeneration.ts 3100->3101 ((sem PR — teto do #8585)); open-sse/handlers/videoGeneration.ts 1038->1063 (#8581); open-sse/services/accountFallback.ts 1965->1966 (#8628); tests/unit/models-catalog-route.test.ts 1608->1636 (#8610)", "frozen": { + "_rebaseline_2026_08_20_10878_10799_provider_health_probes": "PRs #10878 (unsupported OpenAI-like validation probes stay neutral) + #10799 (preserve credential health on inconclusive NVIDIA-timeout/Antigravity-400 probes) own growth: src/app/api/providers/[id]/test/route.ts 946->1025 (+79, sum of both boarded together). Both add narrowly-scoped classification branches at the existing test-route dispatch chokepoint (unsupported-capability skip, credential-inconclusive detection) rather than new files, mirroring the prior 2026_06_27_5193 rebaseline of the same file. Covered by tests/unit/provider-validation-unsupported-neutral.test.ts + tests/unit/provider-health-inconclusive-probes.test.ts.", + "src/app/api/providers/[id]/test/route.ts": 1025, "_rebaseline_2026_06_22_4644_deepseek_web_tools": "PR #4644 (BugsBag/robust deepseek-web tool-call parsing): open-sse/executors/deepseek-web.ts 1117->1125 (+8). The new agentic tool-call path emits surrounding text + reasoning before tool_calls and swaps to the dedicated deepseekWebTools.ts parser; the +8 lines are cohesive wiring at the existing transformSSE chokepoint (the parser itself lives in the new deepseekWebTools.ts file, already under cap). The PR's own fast-gate (PR->release) does not run check:file-size, so this surfaced only at release reconcile. Covered by tests/unit/deepseek-web-tools-variants.test.ts + deepseek-web-tools-execute.test.ts.", "_rebaseline_2026_06_23_4712_deepseek_web_tool_results": "PR for #4712 (deepseek-web drops role:tool): open-sse/executors/deepseek-web.ts 1125->1148 (+23). messagesToPrompt() now folds role:\"tool\" results into the single-prompt transcript (recovering the tool name from the preceding assistant tool_calls by tool_call_id) instead of silently dropping them; the lines are cohesive wiring inside the existing function. Covered by tests/unit/deepseek-web-tool-result-prompt-4712.test.ts.", "_rebaseline_2026_06_24_headroom_strategy": "Headroom-aware connection selection (dario technique): combo.ts 3168->3180 (+12 = a new `else if (strategy === \"headroom\")` dispatch branch in handleComboChat that delegates to orderTargetsByHeadroom + its log line, plus the import). The actual logic lives OUT of the god-file: the pure ranker rankByHeadroom/computeHeadroom is the new leaf open-sse/services/combo/headroomRanking.ts (91 LOC, 1220 = drift herdado de merges. Owner autorizou rebaseline com anotacao (2026-08-11).", "src/lib/modelCapabilities.ts": 1006, "src/app/(dashboard)/dashboard/providers/[id]/providerPageHelpers.ts": 1014, - "open-sse/config/imageRegistry.ts": 1019 + "open-sse/config/imageRegistry.ts": 1034, + "src/sse/handlers/chatHelpers.ts": 1019, + "src/shared/middleware/chatBodyAdmission.ts": 1005, + "_rebaseline_2026_08_20_10668_tabitoken_gateway": "#10668 (yawar-aquil) own catalog growth: src/shared/constants/providers/apikey/gateways.ts 1268->1283 (+15, entirely this PR diff -- one new tabitoken gateway entry, data lines only; base moved from 1255 to 1268 via other merges since the PR forked). Not combination drift: reproducible on the PR branch alone, so the WS5.5 release-captain rule does not apply. Extraction is not available -- the file is pure data (own header: \"Pure data; merged by apikey/index.ts via spread\") and already split into 6 family files under apikey/. Same precedent as _rebaseline_2026_08_14_imagetotext_servicekinds (#10275/#10291, gateways.ts 1250->1255, data lines only) and _rebaseline_2026_08_11_v3850_merge_storm_provider_registry (owner-authorized for this same file)." }, "_rebaseline_base_2026_08_10_proxyfetch": "Base-red fix (green-prs sweep, issue #9985): open-sse/utils/proxyFetch.ts 1207 > cap 1000 — new proxied-TLS fetch helper introduced by the Fal reference-image work. Owner-authorized quick rebaseline to green; structural slim tracked for v3.9.0.", "_rebaseline_2026_07_27_v3849_train2": "Merge-train 2 (7 PRs) — owner-approved 2026-07-27. Single entry: chatCore.ts 4955->5006 (#8595, Responses multi-turn image compaction before the context hard-reject). Genuine irreducible growth at the existing compaction chokepoint in handleChatCore — the PR adds a last-resort retry against the concrete budget plus the estimateFinalInputTokens helper, both wired at the pre-existing call site rather than a new branch. Covered by tests/unit/8560-responses-image-compaction.test.ts (4 tests).", @@ -612,5 +618,8 @@ "_rebaseline_2026_08_12_proxyfetch_redaction": "Base-reds round 3 (#9985): proxyFetch.ts 1220->1239 (+19) = redactProxyDetailsInMessage() helper closing the credential leak #10032 reintroduced (raw proxy URL with user:password appended to the propagated error, Hard Rule #12); irreducible security fix at the existing error-surface chokepoint. Covered by tests/unit/tls-proxy-context.test.ts (strengthened leak guards).", "_rebaseline_2026_08_12_modelcapabilities_snapshot_routing": "Base-reds round 3 (#9985): modelCapabilities.ts crossed the new-file cap at 1006 (+~10) when the context/max-input-token override lookups were routed through the #9199 bulk snapshot (fixing 323 per-model SQLite reads per catalog prepare — auto-combo-context-advertising guard); cohesive change at the existing resolution chokepoints, not extractable. Covered by tests/unit/auto-combo-context-advertising.test.ts + model-capability-resolution-snapshot-9199.test.ts.", "_rebaseline_2026_08_14_imagetotext_servicekinds": "Image-to-Text category (#10275/#10291): gateways.ts grew 1250→1255 by data lines only — the serviceKinds: [\"llm\", \"imageToText\"] declarations on the openrouter and chutes catalog entries, plus the 3-line comment recording why chutes needs no static dots.ocr entry (passthroughModels discovery). No new logic or branching; the file is a provider catalog of declarative metadata. Splitting a catalog for five lines would be worse than the growth (semantic-families rule).", - "_rebaseline_2026_08_18_imageregistry_merge_train": "merge-train 2026-08-18 (owner-authorized, /merge-prs batch of 84): open-sse/config/imageRegistry.ts crossed the 1000-line new-file cap for the first time purely from combining three independent, already-legitimate provider registrations boarded in the same local merge-train — #10542 (aihorde optional-key image catalog), #10494 (gemini-web image generation), #10594 (freepik/magnific provider rename + validation). 996 on release tip -> 1019 on the train tip. Each PR individually adds a small, additive IMAGE_PROVIDERS registry entry at the existing chokepoint; none crosses the cap alone. Not modularized as part of this train's gate fix (out of scope for a merge reconciliation, not a feature change). Covered by each PR's own focused tests (aihorde-image-catalog/generation, gemini-web image tests, freepik/magnific provider tests)." -} + "_rebaseline_2026_08_18_imageregistry_merge_train": "merge-train 2026-08-18 (owner-authorized, /merge-prs batch of 84): open-sse/config/imageRegistry.ts crossed the 1000-line new-file cap for the first time purely from combining three independent, already-legitimate provider registrations boarded in the same local merge-train — #10542 (aihorde optional-key image catalog), #10494 (gemini-web image generation), #10594 (freepik/magnific provider rename + validation). 996 on release tip -> 1019 on the train tip. Each PR individually adds a small, additive IMAGE_PROVIDERS registry entry at the existing chokepoint; none crosses the cap alone. Not modularized as part of this train's gate fix (out of scope for a merge reconciliation, not a feature change). Covered by each PR's own focused tests (aihorde-image-catalog/generation, gemini-web image tests, freepik/magnific provider tests).", + "_rebaseline_2026_08_20_v3850_merge_train_batch1": "Merge-train batch1 (2026-08-19/20, 30 PRs boarded onto release/v3.8.50): gateways.ts 1255->1268 = PR #10722 (Token Kiosk OpenAI-compatible provider gateway catalog entry, +13 declarative lines, same god-file no-split rationale as prior gateways.ts rebaselines); chatHelpers.ts (uncapped, not previously frozen) new 1017 = PR #10797 (relay/bifrost error normalization, +23/-2, own-PR growth, existing file already near cap from accumulated chokepoint wiring per its own rebaseline history above); chatBodyAdmission.ts (uncapped) new 1005 = pre-existing base-red on the pure release tip (1004>1000 before this train boarded anything, no PR in this batch touches this file) — frozen here at its current size, not authorizing further growth. Owner-authorized rebaseline (2026-08-19 merge-prs session).", + "_rebaseline_2026_08_20_8338_cursor_image_provider": "PR (reimplementation of #8338, @valvesss): imageRegistry.ts 1019->1033 = new cursor IMAGE_PROVIDERS entry (Cursor plan image generation via Agent CLI), +14 lines of declarative provider metadata. Same god-registry no-split rationale as prior imageRegistry/gateways rebaselines.", + "_rebaseline_2026_08_20_imageregistry_1034": "imageRegistry.ts 1033->1034: +1 line drift between #10842 (cursor image provider, froze at 1033) and its actual merged state on release (measured 1034) — trivial rebaseline, not a new feature." +} \ No newline at end of file