mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-21 22:52:19 +03:00
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:
1
changelog.d/fixes/10550-responses-reasoning-transport.md
Normal file
1
changelog.d/fixes/10550-responses-reasoning-transport.md
Normal 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)
|
||||
@@ -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)."
|
||||
},
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -14,6 +14,7 @@ export const muse_codeProvider: RegistryEntry = buildOpenAiCompatibleRegistryEnt
|
||||
id: "muse-code",
|
||||
alias: "mc",
|
||||
passthroughModels: true,
|
||||
reasoningTransport: "opaque",
|
||||
defaultContextLength: 200000,
|
||||
models: [
|
||||
{
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
339
open-sse/services/reasoningInputPolicy.ts
Normal file
339
open-sse/services/reasoningInputPolicy.ts
Normal 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 };
|
||||
}
|
||||
@@ -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;
|
||||
});
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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 &&
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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")}
|
||||
|
||||
@@ -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 })}
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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" });
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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: [
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user