Files
OmniRoute/open-sse/services/reasoningInputPolicy.ts
Ke Jin 769ab62fa3 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>
2026-08-20 22:20:54 -03:00

340 lines
11 KiB
TypeScript

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