fix(translator): strip plaintext reasoning content for opaque responses backends (#12128) (#12171)

Signed-off-by: Minxi Hou <houminxi@gmail.com>
This commit is contained in:
Bob.Hou
2026-08-31 13:10:26 -04:00
committed by GitHub
parent 6706c382d8
commit 298ad0fd64
3 changed files with 110 additions and 1 deletions

View File

@@ -43,7 +43,19 @@ export function resolveReasoningTransport(
): ReasoningTransport {
const normalized = typeof provider === "string" ? provider.trim().toLowerCase() : "";
const transport = REASONING_TRANSPORTS.get(normalized);
return transport ?? (preserveEncryptedReasoning ? "opaque" : "plaintext");
if (transport) return transport;
// #12128: Generic Responses-protocol endpoints (e.g. openai-compatible-responses-*,
// custom-openai-responses, proxy backends) implement the OpenAI/Codex Responses API
// where reasoning input items cannot accept plaintext content (maxItems: 0).
if (
normalized.startsWith("openai-compatible-responses") ||
normalized.startsWith("custom-openai-responses") ||
normalized.includes("codex") ||
normalized.includes("responses")
) {
return "opaque";
}
return preserveEncryptedReasoning ? "opaque" : "plaintext";
}
function asRecord(value: unknown): JsonRecord | null {

View File

@@ -40,6 +40,7 @@ import {
normalizeResponsesReasoningEffort,
RESPONSES_STORE_MARKER,
} from "./request/openai-responses/helpers.ts";
import { applyReasoningInputPolicy } from "../services/reasoningInputPolicy.ts";
bootstrapTranslatorRegistry();
export { register } from "./registry.ts";
@@ -575,6 +576,14 @@ export function translateRequest(
// Normalize openai-responses input shape for providers that require list input.
if (targetFormat === FORMATS.OPENAI_RESPONSES) {
result = normalizeOpenAIResponsesRequest(result);
// #12128: Sanitize reasoning input items for Responses targets (strip plaintext content for opaque backends)
applyReasoningInputPolicy(result as Record<string, unknown>, "responses", {
provider,
preserveEncryptedReasoning:
(credentials as { providerSpecificData?: { preserveEncryptedReasoning?: boolean } } | null)
?.providerSpecificData?.preserveEncryptedReasoning === true,
onIncompatibleReasoning: "drop",
});
}
// Second role normalization: only for OPENAI_RESPONSES. Here messages are built from input

View File

@@ -0,0 +1,88 @@
import test from "node:test";
import assert from "node:assert/strict";
const { translateRequest } = await import("../../open-sse/translator/index.ts");
const { resolveReasoningTransport, applyReasoningInputPolicy } = await import(
"../../open-sse/services/reasoningInputPolicy.ts"
);
const { FORMATS } = await import("../../open-sse/translator/formats.ts");
test("#12128: resolveReasoningTransport identifies openai-compatible-responses and custom responses variants as opaque transport", () => {
assert.equal(resolveReasoningTransport("openai-compatible-responses-codex"), "opaque");
assert.equal(resolveReasoningTransport("custom-openai-responses"), "opaque");
assert.equal(resolveReasoningTransport("codex-proxy"), "opaque");
assert.equal(resolveReasoningTransport("openai"), "opaque");
assert.equal(resolveReasoningTransport("codex"), "opaque");
});
test("#12128: translateRequest to Responses target strips plaintext reasoning.content for opaque responses providers", () => {
const chatBody = {
model: "gpt-5.6-codex",
messages: [
{ role: "user", content: "hello" },
{
role: "assistant",
content: "Hi there!",
reasoning_content: "Let me think about how to greet the user properly.",
},
{ role: "user", content: "what is 2+2?" },
],
};
const translated = translateRequest(
FORMATS.OPENAI,
FORMATS.OPENAI_RESPONSES,
"gpt-5.6-codex",
chatBody,
false,
null,
"openai-compatible-responses-codex"
) as Record<string, unknown>;
assert.ok(Array.isArray(translated.input), "translated.input must be an array");
const input = translated.input as Record<string, unknown>[];
const reasoningItems = input.filter((item) => item.type === "reasoning");
// For opaque responses targets without encrypted continuation, orphaned plaintext reasoning items
// must either be stripped entirely or have content.length === 0, so strict Codex backends do not 400.
for (const r of reasoningItems) {
assert.ok(
!r.content || (Array.isArray(r.content) && r.content.length === 0),
"reasoning item must not carry non-empty content array to strict responses endpoints"
);
}
});
test("#12128: applyReasoningInputPolicy directly sanitizes replayed plaintext reasoning for openai-compatible-responses", () => {
const body: Record<string, unknown> = {
input: [
{
type: "reasoning",
content: [{ type: "reasoning_text", text: "step 1 plan" }],
summary: [],
},
{
type: "message",
role: "assistant",
content: [{ type: "output_text", text: "Done" }],
},
],
};
applyReasoningInputPolicy(body, "responses", {
provider: "openai-compatible-responses-vllm",
onIncompatibleReasoning: "drop",
});
const input = body.input as Record<string, unknown>[];
const reasoningItems = input.filter((item) => item.type === "reasoning");
for (const r of reasoningItems) {
assert.equal(
r.content,
undefined,
"plaintext reasoning content must be dropped for opaque responses providers"
);
}
});