fix(reasoning): preserve compatible response state (#10574)

Preserves authentic plaintext reasoning continuations across Chat Completions and Responses (streaming + non-streaming), applying one target-aware reasoning transport policy before protocol translation. Fixes #10550.

Validated in an isolated worktree boarded onto origin/release/v3.8.50 (0 conflicts, 39 files):
- 446/446 focused node:test tests pass (chat-route-coverage, chatcore-translation-paths, combo-attempt-body-isolation-7847, combo-config, executor-codex, kimi-coding-translator, moonshot-k3, reasoning-cache, response-sanitizer, responses-handler, responses-translation-fixes, strip-reasoning-blobs-agentic-context-1599, translator-openai-responses-req).
- 12/12 vitest tests pass (edit-connection-modal-free-models.test.tsx).
- check-changelog-integrity: OK.
- typecheck:core: clean.
- check-complexity / check-cognitive-complexity: OK, both under baseline.
- file-size: chatHelpers.ts crossed the frozen cap by +2 lines (irreducible reasoningTransportFallback option threading) — rebaselined 1017->1019 with justification, pushed to the PR branch (fix-in-place), re-validated after a base-drift re-merge against the latest release tip.

Co-authored-by: jackjinke <jackjinke@users.noreply.github.com>
This commit is contained in:
Ke Jin
2026-08-21 09:20:54 +08:00
committed by GitHub
parent 2cd14b1696
commit 769ab62fa3
40 changed files with 2075 additions and 456 deletions

View File

@@ -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)

View File

@@ -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)."
},

View File

@@ -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,

View File

@@ -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,

View File

@@ -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",

View File

@@ -14,6 +14,7 @@ export const muse_codeProvider: RegistryEntry = buildOpenAiCompatibleRegistryEnt
id: "muse-code",
alias: "mc",
passthroughModels: true,
reasoningTransport: "opaque",
defaultContextLength: 200000,
models: [
{

View File

@@ -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,

View File

@@ -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,

View File

@@ -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. */

View File

@@ -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;

View File

@@ -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<string, unknown>,
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);

View File

@@ -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<string, unknown> || {}),
...((normalized.input_tokens_details as Record<string, unknown>) || {}),
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<string, unknown> || {}),
...((normalized.input_tokens_details as Record<string, unknown>) || {}),
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,

View File

@@ -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<string, unknown>;
@@ -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;

View File

@@ -0,0 +1,339 @@
import { REGISTRY } from "../config/providerRegistry.ts";
import type { ReasoningTransport } from "../config/providerRegistry.ts";
type JsonRecord = Record<string, unknown>;
const REASONING_TRANSPORTS = new Map<string, ReasoningTransport>();
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<string, unknown>,
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 };
}

View File

@@ -1,55 +0,0 @@
type JsonRecord = Record<string, unknown>;
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<string, unknown>,
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;
});
}

View File

@@ -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<string, unknown>)
: {};
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,

View File

@@ -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<Record<string, unknown>>,
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<string, unknown>)
: 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<Record<string, unknown>>;
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 &&

View File

@@ -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(

View File

@@ -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) {

View File

@@ -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);

View File

@@ -13,8 +13,6 @@
* that proxy to thinking-mode models.
*/
import { requiresReasoningReplay } from "../services/reasoningCache.ts";
const PLACEHOLDER = " ";
type JsonRecord = Record<string, unknown>;
@@ -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)
);
}

View File

@@ -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" && (
<>
<div className="grid grid-cols-1 md:grid-cols-3 gap-2 mt-3">
<div>
<label className="text-[10px] font-medium uppercase tracking-wide text-text-muted block mb-1">
1. {getI18nOrFallback(t, "builderProvider", "Provider")}
</label>
<select
value={builderProviderId}
onChange={handleBuilderProviderChange}
data-testid="combo-builder-provider"
className="w-full text-xs py-2 px-2 rounded border border-black/10 dark:border-white/10 bg-white dark:bg-white/5 text-text-main focus:border-primary focus:outline-none"
>
<option value="">
{builderLoading
? getI18nOrFallback(t, "builderLoadingProviders", "Loading providers…")
: getI18nOrFallback(t, "builderSelectProvider", "Select provider")}
</option>
{builderProviders.map((provider) => (
<option key={provider.providerId} value={provider.providerId}>
{provider.displayName} ({provider.connectionCount} acct
{provider.connectionCount === 1 ? "" : "s"})
</option>
))}
</select>
</div>
<div>
<label className="text-[10px] font-medium uppercase tracking-wide text-text-muted block mb-1">
1. {getI18nOrFallback(t, "builderProvider", "Provider")}
</label>
<select
value={builderProviderId}
onChange={handleBuilderProviderChange}
data-testid="combo-builder-provider"
className="w-full text-xs py-2 px-2 rounded border border-black/10 dark:border-white/10 bg-white dark:bg-white/5 text-text-main focus:border-primary focus:outline-none"
>
<option value="">
{builderLoading
? getI18nOrFallback(
t,
"builderLoadingProviders",
"Loading providers…"
)
: getI18nOrFallback(t, "builderSelectProvider", "Select provider")}
</option>
{builderProviders.map((provider) => (
<option key={provider.providerId} value={provider.providerId}>
{provider.displayName} ({provider.connectionCount} acct
{provider.connectionCount === 1 ? "" : "s"})
</option>
))}
</select>
</div>
<div>
<label className="text-[10px] font-medium uppercase tracking-wide text-text-muted block mb-1">
2. {getI18nOrFallback(t, "builderModel", "Model")}
</label>
<select
value={builderModelId}
onChange={handleBuilderModelChange}
disabled={!selectedBuilderProvider}
data-testid="combo-builder-model"
className="w-full text-xs py-2 px-2 rounded border border-black/10 dark:border-white/10 bg-white dark:bg-white/5 text-text-main focus:border-primary focus:outline-none disabled:opacity-50"
>
<option value="">
{selectedBuilderProvider
? getI18nOrFallback(t, "builderSelectModel", "Select model")
: getI18nOrFallback(t, "builderProviderFirst", "Choose provider first")}
</option>
{(selectedBuilderProvider?.models || []).map((model) => (
<option key={model.id} value={model.id}>
{model.name}
{model.source ? ` · ${model.source}` : ""}
</option>
))}
</select>
</div>
<div>
<label className="text-[10px] font-medium uppercase tracking-wide text-text-muted block mb-1">
2. {getI18nOrFallback(t, "builderModel", "Model")}
</label>
<select
value={builderModelId}
onChange={handleBuilderModelChange}
disabled={!selectedBuilderProvider}
data-testid="combo-builder-model"
className="w-full text-xs py-2 px-2 rounded border border-black/10 dark:border-white/10 bg-white dark:bg-white/5 text-text-main focus:border-primary focus:outline-none disabled:opacity-50"
>
<option value="">
{selectedBuilderProvider
? getI18nOrFallback(t, "builderSelectModel", "Select model")
: getI18nOrFallback(
t,
"builderProviderFirst",
"Choose provider first"
)}
</option>
{(selectedBuilderProvider?.models || []).map((model) => (
<option key={model.id} value={model.id}>
{model.name}
{model.source ? ` · ${model.source}` : ""}
</option>
))}
</select>
</div>
<div>
<label className="text-[10px] font-medium uppercase tracking-wide text-text-muted block mb-1">
3. {getI18nOrFallback(t, "builderAccount", "Account")}
</label>
<select
value={builderConnectionId}
onChange={handleBuilderConnectionChange}
disabled={!selectedBuilderModel}
data-testid="combo-builder-account"
className="w-full text-xs py-2 px-2 rounded border border-black/10 dark:border-white/10 bg-white dark:bg-white/5 text-text-main focus:border-primary focus:outline-none disabled:opacity-50"
>
<option value={COMBO_BUILDER_AUTO_CONNECTION}>
{getI18nOrFallback(
t,
"autoSelectAccount",
"Auto-select account at runtime"
)}
</option>
{selectedBuilderConnections.map((connection) => (
<option key={connection.id} value={connection.id}>
{pickDisplayValue([connection.label], emailsVisible, connection.label)}
{connection.status !== "active" ? ` · ${connection.status}` : ""}
</option>
))}
</select>
</div>
</div>
{builderConnectionId === COMBO_BUILDER_AUTO_CONNECTION &&
selectedBuilderConnections.length > 1 ? (
<div className="mt-2 rounded-md border border-black/8 dark:border-white/8 bg-white/70 dark:bg-white/[0.03] px-2.5 py-2">
<label className="text-[10px] font-medium uppercase tracking-wide text-text-muted block mb-1.5">
{getI18nOrFallback(
t,
"builderRestrictAccounts",
"Restrict to accounts (optional)"
)}
</label>
<div className="flex flex-wrap gap-1.5" data-testid="combo-builder-allowlist">
{selectedBuilderConnections.map((connection) => {
const checked = builderAllowedConnectionIds.includes(connection.id);
return (
<button
type="button"
key={connection.id}
onClick={() => handleBuilderAllowedConnectionToggle(connection.id)}
aria-pressed={checked}
className={`text-[11px] px-2 py-1 rounded border transition-colors ${
checked
? "border-primary bg-primary/10 text-primary"
: "border-black/10 dark:border-white/10 text-text-muted hover:border-primary/40"
}`}
>
{pickDisplayValue([connection.label], emailsVisible, connection.label)}
</button>
);
})}
<div>
<label className="text-[10px] font-medium uppercase tracking-wide text-text-muted block mb-1">
3. {getI18nOrFallback(t, "builderAccount", "Account")}
</label>
<select
value={builderConnectionId}
onChange={handleBuilderConnectionChange}
disabled={!selectedBuilderModel}
data-testid="combo-builder-account"
className="w-full text-xs py-2 px-2 rounded border border-black/10 dark:border-white/10 bg-white dark:bg-white/5 text-text-main focus:border-primary focus:outline-none disabled:opacity-50"
>
<option value={COMBO_BUILDER_AUTO_CONNECTION}>
{getI18nOrFallback(
t,
"autoSelectAccount",
"Auto-select account at runtime"
)}
</option>
{selectedBuilderConnections.map((connection) => (
<option key={connection.id} value={connection.id}>
{pickDisplayValue(
[connection.label],
emailsVisible,
connection.label
)}
{connection.status !== "active" ? ` · ${connection.status}` : ""}
</option>
))}
</select>
</div>
</div>
<p className="text-[10px] text-text-muted mt-1.5">
{getI18nOrFallback(
t,
"builderRestrictAccountsHint",
"Leave empty to use the whole active pool. When selected, round-robin / weighted picks stay within this subset of accounts."
)}
</p>
</div>
) : null}
{isExpertMode ? (
<div className="mt-2 flex flex-wrap items-center gap-2">
<Button
onClick={handleAddBuilderStep}
size="sm"
disabled={!builderCandidateStep || !!builderHasDuplicate}
data-testid="combo-builder-add-step"
>
{getI18nOrFallback(t, "builderAddStep", "Add detailed step")}
</Button>
{builderHasDuplicate && (
<span className="text-[10px] text-amber-600 dark:text-amber-300">
{getI18nOrFallback(
t,
"builderDuplicateExact",
"This exact provider/model/account step is already in the combo."
)}
</span>
)}
</div>
) : (
<div className="mt-2 rounded-md border border-black/8 dark:border-white/8 bg-white/70 dark:bg-white/[0.03] px-2.5 py-2">
<p className="text-[10px] uppercase tracking-wide text-text-muted">
{getI18nOrFallback(t, "builderPreview", "Current step preview")}
</p>
<p className="text-xs text-text-main mt-1">
{builderCandidateStep
? formatModelDisplay(builderCandidateStep)
: getI18nOrFallback(
t,
"previewNextStep",
"Choose provider and model to preview the next step."
)}
</p>
<div className="flex flex-wrap items-center gap-1.5 mt-2">
<Button
onClick={handleAddBuilderStep}
size="sm"
disabled={!builderCandidateStep || !!builderHasDuplicate}
data-testid="combo-builder-add-step"
>
{getI18nOrFallback(t, "builderAddStep", "Add detailed step")}
</Button>
{builderHasDuplicate && (
<span className="text-[10px] text-amber-600 dark:text-amber-300">
{builderConnectionId === COMBO_BUILDER_AUTO_CONNECTION &&
selectedBuilderConnections.length > 1 ? (
<div className="mt-2 rounded-md border border-black/8 dark:border-white/8 bg-white/70 dark:bg-white/[0.03] px-2.5 py-2">
<label className="text-[10px] font-medium uppercase tracking-wide text-text-muted block mb-1.5">
{getI18nOrFallback(
t,
"builderDuplicateExact",
"This exact provider/model/account step is already in the combo."
"builderRestrictAccounts",
"Restrict to accounts (optional)"
)}
</span>
)}
</div>
</div>
)}
</label>
<div
className="flex flex-wrap gap-1.5"
data-testid="combo-builder-allowlist"
>
{selectedBuilderConnections.map((connection) => {
const checked = builderAllowedConnectionIds.includes(connection.id);
return (
<button
type="button"
key={connection.id}
onClick={() => handleBuilderAllowedConnectionToggle(connection.id)}
aria-pressed={checked}
className={`text-[11px] px-2 py-1 rounded border transition-colors ${
checked
? "border-primary bg-primary/10 text-primary"
: "border-black/10 dark:border-white/10 text-text-muted hover:border-primary/40"
}`}
>
{pickDisplayValue(
[connection.label],
emailsVisible,
connection.label
)}
</button>
);
})}
</div>
<p className="text-[10px] text-text-muted mt-1.5">
{getI18nOrFallback(
t,
"builderRestrictAccountsHint",
"Leave empty to use the whole active pool. When selected, round-robin / weighted picks stay within this subset of accounts."
)}
</p>
</div>
) : null}
<div className="mt-3 pt-3 border-t border-black/5 dark:border-white/5">
<label className="text-[10px] font-medium uppercase tracking-wide text-text-muted block mb-1">
{getI18nOrFallback(t, "builderComboRef", "Reference another combo")}
</label>
<div className="flex flex-col sm:flex-row gap-2">
<select
value={builderComboRefName}
onChange={(e) => setBuilderComboRefName(e.target.value)}
className="flex-1 text-xs py-2 px-2 rounded border border-black/10 dark:border-white/10 bg-white dark:bg-white/5 text-text-main focus:border-primary focus:outline-none"
>
<option value="">
{getI18nOrFallback(
t,
"selectComboToReference",
"Select an existing combo to reference"
{isExpertMode ? (
<div className="mt-2 flex flex-wrap items-center gap-2">
<Button
onClick={handleAddBuilderStep}
size="sm"
disabled={!builderCandidateStep || !!builderHasDuplicate}
data-testid="combo-builder-add-step"
>
{getI18nOrFallback(t, "builderAddStep", "Add detailed step")}
</Button>
{builderHasDuplicate && (
<span className="text-[10px] text-amber-600 dark:text-amber-300">
{getI18nOrFallback(
t,
"builderDuplicateExact",
"This exact provider/model/account step is already in the combo."
)}
</span>
)}
</option>
{builderComboRefs.map((comboRef) => (
<option key={comboRef.id} value={comboRef.name}>
{comboRef.name} · {comboRef.strategy} · {comboRef.stepCount} step
{comboRef.stepCount === 1 ? "" : "s"}
</option>
))}
</select>
<Button
onClick={handleAddComboReference}
variant="ghost"
size="sm"
disabled={!builderComboRefName}
>
{getI18nOrFallback(t, "builderAddComboRef", "Add combo ref")}
</Button>
</div>
</div>
</div>
) : (
<div className="mt-2 rounded-md border border-black/8 dark:border-white/8 bg-white/70 dark:bg-white/[0.03] px-2.5 py-2">
<p className="text-[10px] uppercase tracking-wide text-text-muted">
{getI18nOrFallback(t, "builderPreview", "Current step preview")}
</p>
<p className="text-xs text-text-main mt-1">
{builderCandidateStep
? formatModelDisplay(builderCandidateStep)
: getI18nOrFallback(
t,
"previewNextStep",
"Choose provider and model to preview the next step."
)}
</p>
<div className="flex flex-wrap items-center gap-1.5 mt-2">
<Button
onClick={handleAddBuilderStep}
size="sm"
disabled={!builderCandidateStep || !!builderHasDuplicate}
data-testid="combo-builder-add-step"
>
{getI18nOrFallback(t, "builderAddStep", "Add detailed step")}
</Button>
{builderHasDuplicate && (
<span className="text-[10px] text-amber-600 dark:text-amber-300">
{getI18nOrFallback(
t,
"builderDuplicateExact",
"This exact provider/model/account step is already in the combo."
)}
</span>
)}
</div>
</div>
)}
<div className="mt-3 pt-3 border-t border-black/5 dark:border-white/5">
<label className="text-[10px] font-medium uppercase tracking-wide text-text-muted block mb-1">
{getI18nOrFallback(t, "builderComboRef", "Reference another combo")}
</label>
<div className="flex flex-col sm:flex-row gap-2">
<select
value={builderComboRefName}
onChange={(e) => setBuilderComboRefName(e.target.value)}
className="flex-1 text-xs py-2 px-2 rounded border border-black/10 dark:border-white/10 bg-white dark:bg-white/5 text-text-main focus:border-primary focus:outline-none"
>
<option value="">
{getI18nOrFallback(
t,
"selectComboToReference",
"Select an existing combo to reference"
)}
</option>
{builderComboRefs.map((comboRef) => (
<option key={comboRef.id} value={comboRef.name}>
{comboRef.name} · {comboRef.strategy} · {comboRef.stepCount} step
{comboRef.stepCount === 1 ? "" : "s"}
</option>
))}
</select>
<Button
onClick={handleAddComboReference}
variant="ghost"
size="sm"
disabled={!builderComboRefName}
>
{getI18nOrFallback(t, "builderAddComboRef", "Add combo ref")}
</Button>
</div>
</div>
</>
)}
@@ -3823,6 +3844,58 @@ function ComboFormModal({ isOpen, combo, onClose, onSave, activeProviders, combo
<div className="col-span-2">
<ReasoningTokenBufferToggle config={config} setConfig={setConfig} t={t} />
</div>
<div>
<FieldLabelWithHelp
label={getI18nOrFallback(
t,
"reasoningTransportFallback",
"Reasoning transport fallback"
)}
help={getI18nOrFallback(
t,
"advancedHelp.reasoningTransportFallback",
ADVANCED_FIELD_HELP_FALLBACK.reasoningTransportFallback
)}
showHelp={!isExpertMode}
htmlFor="combo-reasoning-transport-fallback"
/>
<select
id="combo-reasoning-transport-fallback"
value={config.reasoningTransportFallback === "skip" ? "skip" : "drop"}
onChange={(e) =>
setConfig({
...config,
reasoningTransportFallback:
e.target.value === "skip" ? "skip" : undefined,
})
}
className="w-full text-xs py-1.5 px-2 rounded border border-black/10 dark:border-white/10 bg-surface-1 focus:border-primary focus:outline-none"
>
<option value="skip">
{getI18nOrFallback(
t,
"reasoningTransportFallbackSkip",
"Skip incompatible target (fall through)"
)}
</option>
<option value="drop">
{getI18nOrFallback(
t,
"reasoningTransportFallbackDrop",
"Drop reasoning and try target"
)}
</option>
</select>
{config.reasoningTransportFallback !== "skip" && (
<p className="text-[10px] text-amber-600 dark:text-amber-300 mt-1">
{getI18nOrFallback(
t,
"reasoningTransportFallbackDropWarning",
"May lose continuation context or cause tool-call continuations to fail."
)}
</p>
)}
</div>
<div>
<FieldLabelWithHelp
label={t("maxSetRetries")}

View File

@@ -208,6 +208,7 @@ export default function EditConnectionModal({
(provider.startsWith("openai-compatible-responses-") ||
connectionProviderSpecificData?.apiType === "responses" ||
formData.targetFormat === "openai-responses"));
const isCustomResponsesConnection = isResponsesConnection && !isCodex && provider !== "openai";
const isClaude = provider === "claude";
const isAntigravityFamily = provider === "antigravity" || provider === "agy";
const localProviderMetadata = getLocalProviderMetadata(provider);
@@ -667,8 +668,12 @@ export default function EditConnectionModal({
}
}
if (isResponsesConnection && updates.providerSpecificData) {
updates.providerSpecificData.preserveEncryptedReasoning =
formData.preserveEncryptedReasoning === true;
if (isCustomResponsesConnection) {
updates.providerSpecificData.preserveEncryptedReasoning =
formData.preserveEncryptedReasoning === true;
} else {
delete updates.providerSpecificData.preserveEncryptedReasoning;
}
updates.providerSpecificData.openaiStoreEnabled =
formData.openaiResponsesStoreEnabled === true;
}
@@ -701,7 +706,7 @@ export default function EditConnectionModal({
!testResult?.valid && testResult?.diagnosis?.type
? ERROR_TYPE_LABELS[testResult.diagnosis.type] || null
: null;
const preserveEncryptedReasoningToggle = isResponsesConnection ? (
const preserveEncryptedReasoningToggle = isCustomResponsesConnection ? (
<Toggle
checked={formData.preserveEncryptedReasoning}
onChange={(checked) => setFormData({ ...formData, preserveEncryptedReasoning: checked })}

View File

@@ -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(),

View File

@@ -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

View File

@@ -422,6 +422,7 @@ export async function executeChatWithBreaker({
conversationId = null,
modelPinned = false,
routingComboId = null,
reasoningTransportFallback = "skip",
sessionAffinityKey = null,
managedLease = null,
}: ExecuteChatWithBreakerOptions): Promise<ExecuteChatWithBreakerResult> {
@@ -481,6 +482,7 @@ export async function executeChatWithBreaker({
modelPinned,
routingComboId,
sessionAffinityKey,
reasoningTransportFallback,
managedLease,
skipResourcePressureGuard: true,
onCredentialsRefreshed: async (newCreds: any) => {

View File

@@ -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" });

View File

@@ -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<Record<string, unknown>>;
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<Record<string, unknown>>;
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<string, unknown> & { 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"),

View File

@@ -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<string, unknown>) => {
attempts++;
const input = received.input as Array<Record<string, unknown>>;
if (attempts === 1) {
const policy = applyReasoningInputPolicy(received, "responses", {
provider: "openai",
onIncompatibleReasoning: "drop",
});
assert.equal(policy.incompatibleReasoning, false);
assert.equal(
(received.input as Array<Record<string, unknown>>).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<Record<string, unknown>>)[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<string, unknown>).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();

View File

@@ -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",

View File

@@ -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(

View File

@@ -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",

View File

@@ -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",

View File

@@ -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<string, unknown> }> };
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<string, unknown> }> };
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();

View File

@@ -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<string, unknown>;
assert.deepEqual(sanitized.output, [plaintext, opaque]);
});
test("sanitizeStreamingChunk keeps only safe chunk fields and preserves readable reasoning aliases", () => {
const sanitized = sanitizeStreamingChunk({
id: "chunk_1",

View File

@@ -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",

View File

@@ -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",

View File

@@ -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<string, unknown> = {
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<Record<string, unknown>>;
// 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<string, unknown> = {
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<string, unknown> = {
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<string, unknown> = {
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<string, unknown> = {
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<string, unknown> = {
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<string, unknown> = {
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<string, unknown> = {
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<string, unknown> = {
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<Record<string, unknown>>)[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<string, unknown> = {
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<string, unknown> = {
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<string, unknown> = {
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: [

View File

@@ -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<Record<string, unknown>> };
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<Record<string, unknown>> };
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<Record<string, unknown>> };
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<Record<string, unknown>> };
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<Record<string, unknown>> };
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",

View File

@@ -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<HTMLButtonElement>(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<HTMLButtonElement>(
'button[role="switch"][aria-label="disableCoolingLabel"]'
)!;
const reasoningToggle = el.querySelector<HTMLButtonElement>(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"
);
});
});