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 0000000000..d34c433deb --- /dev/null +++ b/changelog.d/fixes/10550-responses-reasoning-transport.md @@ -0,0 +1 @@ +- 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/config/quality/file-size-baseline.json b/config/quality/file-size-baseline.json index 7a65a56808..f19fc0ea0b 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.)", @@ -450,7 +451,7 @@ "src/lib/modelCapabilities.ts": 1006, "src/app/(dashboard)/dashboard/providers/[id]/providerPageHelpers.ts": 1014, "open-sse/config/imageRegistry.ts": 1034, - "src/sse/handlers/chatHelpers.ts": 1017, + "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)." }, 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 a1ccb6b13c..1c290668f9 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", + 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 7d6fee4557..6b67797fa3 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", + reasoningTransport: "opaque", authType: "oauth", authHeader: "bearer", defaultContextLength: 400000, diff --git a/open-sse/config/providers/registry/grok-cli/index.ts b/open-sse/config/providers/registry/grok-cli/index.ts index f257f8d60a..e65ced0e76 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", + 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 66f59f9f42..37db1e988d 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, + 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 63b6a30fa3..60a276a948 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", + 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 efd0a72e3a..dcf9124441 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", + reasoningTransport: "opaque", authType: "apikey", authHeader: "bearer", models: [ @@ -54,6 +55,7 @@ export const xai_oauthProvider: RegistryEntry = { executor: "xai-oauth", baseUrl: xaiProvider.baseUrl, responsesBaseUrl: xaiProvider.responsesBaseUrl, + 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 d65a0240e0..d87250644d 100644 --- a/open-sse/config/providers/shared.ts +++ b/open-sse/config/providers/shared.ts @@ -108,6 +108,8 @@ export interface RegistryOAuth { pollUrlBase?: string; } +export type ReasoningTransport = "plaintext" | "opaque" | "none"; + export interface RegistryEntry { id: string; alias?: string; @@ -120,6 +122,8 @@ export interface RegistryEntry { /** Override models URL used only for API key validation, not catalog discovery. */ testKeyModelsUrl?: string; responsesBaseUrl?: string; + /** 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 b7bc008ec3..ab595c3cbb 100644 --- a/open-sse/executors/codex.ts +++ b/open-sse/executors/codex.ts @@ -34,7 +34,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"; @@ -1389,10 +1389,12 @@ export class CodexExecutor extends BaseExecutor { delete body.session_id; delete body.conversation_id; - applyResponsesInputPolicy( - body, - credentials?.providerSpecificData?.preserveEncryptedReasoning === true - ); + applyReasoningInputPolicy(body, "responses", { + provider: "codex", + preserveEncryptedReasoning: + credentials?.providerSpecificData?.preserveEncryptedReasoning === true, + onIncompatibleReasoning: "drop", + }); if (nativeCodexPassthrough) { return body; diff --git a/open-sse/handlers/chatCore.ts b/open-sse/handlers/chatCore.ts index dcc281d3e3..beb842bf2d 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 { createRoutingEvent, emitRoutingEvent, @@ -511,6 +511,7 @@ export async function handleChatCore({ conversationId = null, modelPinned = false, skipResourcePressureGuard = false, + reasoningTransportFallback = "skip", managedLease = null, }) { let { provider, model, extendedContext } = modelInfo; @@ -1193,11 +1194,30 @@ export async function handleChatCore({ return cacheHit; } - if (targetFormat === FORMATS.OPENAI_RESPONSES && body && typeof body === "object") { - applyResponsesInputPolicy( + const reasoningInputFormat = + sourceFormat === FORMATS.OPENAI_RESPONSES + ? "responses" + : sourceFormat === FORMATS.OPENAI + ? "chat" + : null; + if (reasoningInputFormat && body && typeof body === "object") { + const policy = applyReasoningInputPolicy( body as Record, - credentials?.providerSpecificData?.preserveEncryptedReasoning === true + reasoningInputFormat, + { + provider, + preserveEncryptedReasoning: + credentials?.providerSpecificData?.preserveEncryptedReasoning === true, + onIncompatibleReasoning: reasoningTransportFallback === "drop" ? "drop" : "reject", + } ); + 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/handlers/responseSanitizer.ts b/open-sse/handlers/responseSanitizer.ts index 06d3202f5f..a2210681d7 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/open-sse/handlers/responseTranslator.ts b/open-sse/handlers/responseTranslator.ts index 407393966d..01bdd4c14a 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/reasoningInputPolicy.ts"; import { sanitizeToolId } from "../translator/helpers/schemaCoercion.ts"; type JsonRecord = Record; @@ -178,7 +179,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) { @@ -192,16 +194,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") { @@ -238,8 +246,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/reasoningInputPolicy.ts b/open-sse/services/reasoningInputPolicy.ts new file mode 100644 index 0000000000..71a283a706 --- /dev/null +++ b/open-sse/services/reasoningInputPolicy.ts @@ -0,0 +1,339 @@ +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, + stripOrphanedSummaries: 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) || stripOrphanedSummaries)) { + 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 do not affect compatibility; stateless input drops orphan summaries. + */ +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, + body.store === false + ); + return { incompatibleReasoning: false }; +} diff --git a/open-sse/services/responsesInputPolicy.ts b/open-sse/services/responsesInputPolicy.ts deleted file mode 100644 index d80dcc7bec..0000000000 --- a/open-sse/services/responsesInputPolicy.ts +++ /dev/null @@ -1,55 +0,0 @@ -type JsonRecord = Record; - -const SERVER_ITEM_ID_PATTERN = /^(rs|fc|resp|msg)_/; - -/** - * 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. - */ -export function applyResponsesInputPolicy( - body: Record, - preserveEncryptedReasoning = false -): void { - 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; - - body.input = body.input.filter((item) => { - if (typeof item === "string" && SERVER_ITEM_ID_PATTERN.test(item)) { - return false; - } - - const record = - item && typeof item === "object" && !Array.isArray(item) ? (item as JsonRecord) : null; - if (!record) return true; - - if (record.type === "item_reference") { - return false; - } - - if ( - record.type === "reasoning" && - (!preserveEncryptedReasoning || - typeof record.encrypted_content !== "string" || - record.encrypted_content.trim().length === 0) - ) { - return false; - } - - if (typeof record.id === "string" && SERVER_ITEM_ID_PATTERN.test(record.id)) { - delete record.id; - } - - return true; - }); -} diff --git a/open-sse/translator/helpers/responsesApiHelper.ts b/open-sse/translator/helpers/responsesApiHelper.ts index 625daf174f..a6c0ea29d5 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/translator/index.ts b/open-sse/translator/index.ts index 979fce6e5f..501d2ffdbc 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 df65ab5f3c..b886650252 100644 --- a/open-sse/translator/request/openai-responses.ts +++ b/open-sse/translator/request/openai-responses.ts @@ -8,6 +8,11 @@ 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, + extractReplayableResponsesReasoningText, +} from "../../services/reasoningInputPolicy.ts"; import { getRegisteredProviders, requiresPlainStringContent, @@ -73,14 +78,6 @@ 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)) - .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; @@ -456,10 +453,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 = 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 f91b66f918..bee5efab1a 100644 --- a/open-sse/translator/request/openai-responses/toResponses.ts +++ b/open-sse/translator/request/openai-responses/toResponses.ts @@ -4,6 +4,8 @@ * Extracted verbatim from openai-responses.ts. Registration stays in the host. */ import { isOpenAIResponsesStoreEnabled } from "@/lib/providers/requestDefaults"; +import { isInternalReasoningPlaceholder } from "../../../utils/reasoningPlaceholder.ts"; +import { getReadableReasoningValue } from "../../../utils/reasoningFields.ts"; import { generateToolCallId } from "../../helpers/toolCallHelper.ts"; import { JsonRecord, @@ -192,12 +194,18 @@ 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 = getReadableReasoningValue(msg).trim(); + if (reasoning && !isInternalReasoningPlaceholder(reasoning)) { + // 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 + // 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/open-sse/translator/response/openai-responses.ts b/open-sse/translator/response/openai-responses.ts index 09ff1c8d7c..0c59fac4fb 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/reasoningInputPolicy.ts"; import { normalizeToolName, stripEmptyOptionalToolArgs, @@ -542,7 +543,8 @@ function emitToolCall(state, emit, tc) { const toolName = state.funcNames[tcIdx] || funcName || ""; const lowerName = toolName.toLowerCase(); const isCustomTool = - ((lowerName === "apply_patch" || lowerName === "applypatch") && !state.toolSchemas?.has?.(toolName)) || + ((lowerName === "apply_patch" || lowerName === "applypatch") && + !state.toolSchemas?.has?.(toolName)) || state.customToolNames?.has?.(toolName) === true; if (!state.funcCallIds[tcIdx] && newCallId) state.funcCallIds[tcIdx] = newCallId; @@ -614,7 +616,8 @@ function closeToolCall(state, emit, idx, recordAsCompleted = true) { // same classification independently for their respective add/close call sites). const lowerName = toolName.toLowerCase(); const isCustomTool = - ((lowerName === "apply_patch" || lowerName === "applypatch") && !state.toolSchemas?.has?.(toolName)) || + ((lowerName === "apply_patch" || lowerName === "applypatch") && + !state.toolSchemas?.has?.(toolName)) || state.customToolNames?.has?.(toolName) === true; let funcItem; @@ -1042,6 +1045,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). @@ -1055,6 +1069,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); @@ -1314,12 +1350,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) : ""; @@ -1334,6 +1366,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/open-sse/utils/reasoningContentInjector.ts b/open-sse/utils/reasoningContentInjector.ts index 375057cf77..a1d69ebf6d 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/src/app/(dashboard)/dashboard/combos/page.tsx b/src/app/(dashboard)/dashboard/combos/page.tsx index f456d8c6d9..fd9d1e3b7e 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. 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([ @@ -3230,213 +3232,232 @@ function ComboFormModal({ isOpen, combo, onClose, onSave, activeProviders, combo {builderSelectionMode === "step" && ( <>
-
- - -
+
+ + +
-
- - -
+
+ + +
-
- - -
-
- - {builderConnectionId === COMBO_BUILDER_AUTO_CONNECTION && - selectedBuilderConnections.length > 1 ? ( -
- -
- {selectedBuilderConnections.map((connection) => { - const checked = builderAllowedConnectionIds.includes(connection.id); - return ( - - ); - })} +
+ + +
-

- {getI18nOrFallback( - t, - "builderRestrictAccountsHint", - "Leave empty to use the whole active pool. When selected, round-robin / weighted picks stay within this subset of accounts." - )} -

-
- ) : null} - {isExpertMode ? ( -
- - {builderHasDuplicate && ( - - {getI18nOrFallback( - t, - "builderDuplicateExact", - "This exact provider/model/account step is already in the combo." - )} - - )} -
- ) : ( -
-

- {getI18nOrFallback(t, "builderPreview", "Current step preview")} -

-

- {builderCandidateStep - ? formatModelDisplay(builderCandidateStep) - : getI18nOrFallback( - t, - "previewNextStep", - "Choose provider and model to preview the next step." - )} -

-
- - {builderHasDuplicate && ( - + {builderConnectionId === COMBO_BUILDER_AUTO_CONNECTION && + selectedBuilderConnections.length > 1 ? ( +
+
-
- )} + +
+ {selectedBuilderConnections.map((connection) => { + const checked = builderAllowedConnectionIds.includes(connection.id); + return ( + + ); + })} +
+

+ {getI18nOrFallback( + t, + "builderRestrictAccountsHint", + "Leave empty to use the whole active pool. When selected, round-robin / weighted picks stay within this subset of accounts." + )} +

+
+ ) : null} -
- -
- - -
-
+ + ) : ( +
+

+ {getI18nOrFallback(t, "builderPreview", "Current step preview")} +

+

+ {builderCandidateStep + ? formatModelDisplay(builderCandidateStep) + : getI18nOrFallback( + t, + "previewNextStep", + "Choose provider and model to preview the next step." + )} +

+
+ + {builderHasDuplicate && ( + + {getI18nOrFallback( + t, + "builderDuplicateExact", + "This exact provider/model/account step is already in the combo." + )} + + )} +
+
+ )} + +
+ +
+ + +
+
)} @@ -3823,6 +3844,58 @@ function ComboFormModal({ isOpen, combo, onClose, onSave, activeProviders, combo
+
+ + + {config.reasoningTransportFallback !== "skip" && ( +

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

+ )} +
setFormData({ ...formData, preserveEncryptedReasoning: checked })} diff --git a/src/shared/validation/schemas/combo.ts b/src/shared/validation/schemas/combo.ts index 79bb9ca56c..c616e63372 100644 --- a/src/shared/validation/schemas/combo.ts +++ b/src/shared/validation/schemas/combo.ts @@ -186,6 +186,7 @@ export const comboRuntimeConfigSchema = z nestedComboMode: z.enum(["flatten", "execute"]).optional(), trackMetrics: z.boolean().optional(), reasoningTokenBufferEnabled: z.boolean().optional(), + reasoningTransportFallback: z.enum(["skip", "drop"]).optional(), compressionMode: compressionModeSchema.optional(), failoverBeforeRetry: z.boolean().optional(), maxSetRetries: z.coerce.number().int().min(0).max(10).optional(), diff --git a/src/sse/handlers/chat.ts b/src/sse/handlers/chat.ts index b200674d32..3ec6e6c6d6 100644 --- a/src/sse/handlers/chat.ts +++ b/src/sse/handlers/chat.ts @@ -1012,6 +1012,8 @@ async function handleChatImplementation( const relayConfig = combo.strategy === "context-relay" ? resolveComboConfig(combo, settings) : null; + const reasoningTransportFallback = + 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); @@ -1087,6 +1089,7 @@ async function handleChatImplementation( correlationId: reqId, conversationId, modelPinned: (target as any)?.modelPinned ?? false, + reasoningTransportFallback, reasoningDecision, reasoningIntent, reasoningRequestTags: requestRoutingTags.tags, @@ -1300,6 +1303,7 @@ async function handleSingleModelChat( reasoningDecision?: ReasoningRuleDecision | null; reasoningIntent?: ExtractedReasoningIntent | null; reasoningRequestTags?: string[]; + reasoningTransportFallback?: "skip" | "drop"; managedLease?: ManagedLeaseDispatchContext | null; /** * Per-target abort signal from combo.ts's targetTimeoutRunner @@ -1374,6 +1378,8 @@ async function handleSingleModelChat( allowRateLimitedConnection: resolvedTarget?.allowRateLimitedConnection === true, providerId: resolvedTarget?.providerId ?? null, correlationId: runtimeOptions?.correlationId ?? null, + reasoningTransportFallback: + redirectCombo.config?.reasoningTransportFallback === "skip" ? "skip" : "drop", conversationId: runtimeOptions?.conversationId ?? null, managedLease: runtimeOptions.managedLease ?? null, // #7360 follow-up — see the primary handleSingleModel closure above. @@ -1845,6 +1851,7 @@ async function handleSingleModelChat( modelPinned: runtimeOptions?.modelPinned ?? false, routingComboId: runtimeOptions?.routingComboId ?? null, sessionAffinityKey: runtimeOptions.sessionAffinityKey ?? null, + reasoningTransportFallback: runtimeOptions.reasoningTransportFallback ?? "skip", managedLease: runtimeOptions.managedLease ?? null, }, runtimeOptions diff --git a/src/sse/handlers/chatHelpers.ts b/src/sse/handlers/chatHelpers.ts index 866ca28b66..63aa617bab 100644 --- a/src/sse/handlers/chatHelpers.ts +++ b/src/sse/handlers/chatHelpers.ts @@ -422,6 +422,7 @@ export async function executeChatWithBreaker({ conversationId = null, modelPinned = false, routingComboId = null, + reasoningTransportFallback = "skip", sessionAffinityKey = null, managedLease = null, }: ExecuteChatWithBreakerOptions): Promise { @@ -481,6 +482,7 @@ export async function executeChatWithBreaker({ modelPinned, routingComboId, sessionAffinityKey, + reasoningTransportFallback, managedLease, skipResourcePressureGuard: true, onCredentialsRefreshed: async (newCreds: any) => { diff --git a/tests/unit/chat-route-coverage.test.ts b/tests/unit/chat-route-coverage.test.ts index 0b8d4c8806..ff82a1caa2 100644 --- a/tests/unit/chat-route-coverage.test.ts +++ b/tests/unit/chat-route-coverage.test.ts @@ -316,6 +316,87 @@ test("handleChat keeps protected combo fallback separate from Global Fallback Mo assert.equal(json.choices[0].message.content, "Global fallback answered"); }); +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", + strategy: "priority", + config: { + maxRetries: 0, + retryDelayMs: 0, + }, + 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 8ae3d4d36d..08cc564cc4 100644 --- a/tests/unit/chatcore-translation-paths.test.ts +++ b/tests/unit/chatcore-translation-paths.test.ts @@ -212,6 +212,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, @@ -311,6 +369,7 @@ async function invokeChatCore({ onCredentialsRefreshed = null, onRequestSuccess = null, sessionAffinityKey = null, + reasoningTransportFallback = "skip", managedLease = null, cachedSettings = null, }: any = {}) { @@ -359,6 +418,7 @@ async function invokeChatCore({ sessionAffinityKey, isCombo, comboStrategy, + reasoningTransportFallback, managedLease, cachedSettings, onCredentialsRefreshed, @@ -571,7 +631,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: "" }, @@ -580,38 +640,300 @@ 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({ + 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", + }); + + 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" }, + { type: "reasoning", summary: [{ text: "not self-contained" }] }, + ] + ); + assert.equal( + input.some((item) => item.type === "item_reference"), + false + ); + 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/responses", + endpoint: "/v1/chat/completions", credentials: { apiKey: "sk-test", providerSpecificData: { - apiType: "responses", + apiType: "openai", baseUrl: "https://proxy.example.com/v1", prefix: "sp-openai", - preserveEncryptedReasoning, }, }, - body: { model: "gpt-5.4", stream: false, input: reasoningItems }, - responseFormat: "openai-responses", + 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(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, `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", + 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", + 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 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. @@ -785,14 +1107,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", @@ -810,7 +1132,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 6238baf8ff..9373c51e7c 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 { 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"); @@ -164,6 +166,85 @@ 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 = applyReasoningInputPolicy(received, "responses", { + provider: "openai", + onIncompatibleReasoning: "drop", + }); + assert.equal(policy.incompatibleReasoning, false); + 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 = applyReasoningInputPolicy(received, "responses", { + 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/combo-config.test.ts b/tests/unit/combo-config.test.ts index 61fe705d0a..d09ecf6999 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/executor-codex.test.ts b/tests/unit/executor-codex.test.ts index 9913e02b5c..359768a3bd 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/kimi-coding-translator.test.ts b/tests/unit/kimi-coding-translator.test.ts index f1ec07421b..42cd017663 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 d85b67855b..ec77244240 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 9c144596b4..e13dfec7a9 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(); @@ -645,7 +688,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", @@ -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/response-sanitizer.test.ts b/tests/unit/response-sanitizer.test.ts index 7fecd106fd..925b9c3795 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", diff --git a/tests/unit/responses-handler.test.ts b/tests/unit/responses-handler.test.ts index 0ec6adf448..7afbe12d31 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", diff --git a/tests/unit/responses-translation-fixes.test.ts b/tests/unit/responses-translation-fixes.test.ts index 1f3fec359a..66807e11ec 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", 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 246c0668db..cab5e9c37f 100644 --- a/tests/unit/strip-reasoning-blobs-agentic-context-1599.test.ts +++ b/tests/unit/strip-reasoning-blobs-agentic-context-1599.test.ts @@ -1,18 +1,18 @@ 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"; -// 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 reject opaque reasoning and ignore display summaries", () => { 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,82 +24,315 @@ test("applyResponsesInputPolicy drops object items with type=reasoning", () => { ], }; - applyResponsesInputPolicy(body); + const originalInput = structuredClone(body.input); + const result = applyReasoningInputPolicy(body, "responses"); - const input = body.input as Array>; - // Both reasoning items must be gone. - assert.equal( - input.some((it) => it && it.type === "reasoning"), - false, - "reasoning items must be stripped" - ); - // 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"); + assert.equal(result.incompatibleReasoning, true); + assert.deepEqual(body.input, originalInput, "rejection must not mutate the request"); }); -test("selected connection policy preserves encrypted reasoning input", () => { +test("unannotated targets preserve 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 = applyReasoningInputPolicy(body, "responses", { provider: "opencode-go" }); + 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("display summaries coexist with plaintext continuation without affecting compatibility", () => { const body: Record = { input: [ - { id: "rs_encrypted123", type: "reasoning", encrypted_content: "encrypted-blob" }, + { + 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 = { + 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 = 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 only the incompatible active transport and preserves summaries", () => { + 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", + summary: [{ type: "summary_text", text: "display only" }], + }; + const body: Record = { + input: [ + plaintextReasoning, + opaqueReasoning, + { id: "fc_call", type: "function_call", call_id: "call_1", name: "search" }, + ], + }; + + const result = applyReasoningInputPolicy(body, "responses", { + provider: "deepseek", + onIncompatibleReasoning: "drop", + }); + + assert.equal(result.incompatibleReasoning, false); + 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.encrypted_content, "provider-state"); +}); + +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 = applyReasoningInputPolicy(body, "responses", { + 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", + type: "reasoning", + encrypted_content: "encrypted-blob", + summary: [{ type: "summary_text", text: "safe summary" }], + status: "completed", + }; + const body: Record = { + input: [ + 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 = applyReasoningInputPolicy(body, "responses", { 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 = applyReasoningInputPolicy(body, "responses", { 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" }], + }; + + 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 preserved independently of active 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 = 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" }] }, ]); }); +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: [ diff --git a/tests/unit/translator-openai-responses-req.test.ts b/tests/unit/translator-openai-responses-req.test.ts index 3aa5178a06..e0d0a51773 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,69 @@ 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 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( + () => + 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 +209,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 +447,103 @@ test("Chat -> Responses clamps call_id to 64 chars and keeps the pair matched (p ); }); +test("Chat -> Responses defaults unannotated targets to plaintext reasoning", () => { + const result = openaiToOpenAIResponsesRequest( + "deepseek-v4-flash", + { + 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: "opencode-go" } + ) 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 -> 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 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", 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 0e7abd35c3..9445443a53 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" + ); }); });