Skip to content

Commit 2691d00

Browse files
committed
fix(deepseek): preserve Responses reasoning replay on continuations (#875)
1 parent 52a9fa2 commit 2691d00

2 files changed

Lines changed: 144 additions & 10 deletions

File tree

src/adapters/openai-responses.ts

Lines changed: 28 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { createHash } from "node:crypto";
22
import type { IncomingMeta, ProviderAdapter } from "./base";
3-
import { namespacedToolName, type AdapterEvent, type OcxParsedRequest, type OcxProviderConfig, type OcxUsage } from "../types";
3+
import { modelInList, namespacedToolName, type AdapterEvent, type OcxParsedRequest, type OcxProviderConfig, type OcxUsage } from "../types";
44
import { catalogModelSupportsReasoningSummaries } from "../codex/catalog";
55
import { COMPACT_PROMPT, decodeCompactionSummary, SUMMARY_PREFIX } from "../responses/compaction";
66
import { collectResponsesToolGroups } from "../responses/tool-groups";
@@ -32,27 +32,42 @@ export const FORWARD_HEADERS = [
3232
"x-responsesapi-include-timing-metrics",
3333
];
3434

35-
export function sanitizeReasoningInputContent(body: unknown): unknown {
35+
export function sanitizeReasoningInputContent(
36+
body: unknown,
37+
opts?: { preserveContentModels?: string[]; modelId?: string },
38+
): unknown {
3639
if (!body || typeof body !== "object" || Array.isArray(body)) return body;
3740
const raw = body as Record<string, unknown>;
3841
if (!Array.isArray(raw.input)) return body;
42+
const preserveContent = modelInList(opts?.preserveContentModels, opts?.modelId ?? "");
3943

4044
let changed = false;
4145
const input = raw.input.map(item => {
4246
if (!item || typeof item !== "object" || Array.isArray(item)) return item;
4347
const rec = item as Record<string, unknown>;
4448
if (rec.type !== "reasoning") return item;
4549
const hasRawContent = Array.isArray(rec.content) && rec.content.length > 0;
46-
// ocxr1 envelopes are proxy-minted (Anthropic signatures), not OpenAI encryption — the native
47-
// backend cannot decrypt them and would reject the request. Strip regardless of content shape.
50+
// ocxr1 envelopes are proxy-minted (Anthropic signatures), not OpenAI encryption — no
51+
// backend can decrypt them. Strip regardless of content shape or preserve setting.
4852
const hasOcxEnvelope = typeof rec.encrypted_content === "string" && rec.encrypted_content.startsWith(OCX_REASONING_PREFIX);
4953
if (!hasRawContent && !hasOcxEnvelope) return item;
54+
const next: Record<string, unknown> = { ...rec };
55+
let mutated = false;
56+
if (hasOcxEnvelope) {
57+
delete next.encrypted_content;
58+
mutated = true;
59+
}
60+
// Routed models can produce raw `reasoning_text` output items. ChatGPT's Responses backend
61+
// accepts reasoning input only with empty `content`, so native passthrough keeps summaries/ids
62+
// and drops the raw content to avoid a 400. Stateless Responses backends such as DeepSeek
63+
// instead require the caller to replay the reasoning text on every continuation, so models in
64+
// `preserveReasoningContentModels` keep it (issue #875).
65+
if (hasRawContent && !preserveContent) {
66+
next.content = [];
67+
mutated = true;
68+
}
69+
if (!mutated) return item;
5070
changed = true;
51-
// Routed models can produce raw `reasoning_text` output items. Codex echoes those in later
52-
// native GPT requests, but ChatGPT's Responses backend accepts reasoning input only with empty
53-
// `content`; keep summaries/ids and drop the raw content so native passthrough does not 400.
54-
const next: Record<string, unknown> = { ...rec, content: [] };
55-
if (hasOcxEnvelope) delete next.encrypted_content;
5671
return next;
5772
});
5873

@@ -1024,7 +1039,10 @@ export function createResponsesPassthroughAdapter(provider: OcxProviderConfig):
10241039
if (parsed._compactionRequest === true && !isCanonicalOpenAiForwardProvider(provider)) {
10251040
outBody = buildRoutedCompactionBody(outBody);
10261041
}
1027-
const sanitizedBody = normalizeToolSchemas(stripSparkCompatibility(stripUnsupportedReasoningParams(stripItemIdsWhenUnstored(stripInvalidItemIds(stripUnsupportedHostedTools(sanitizeReasoningInputContent(scrubOcxCompactionItems(outBody))))))));
1042+
const sanitizedBody = normalizeToolSchemas(stripSparkCompatibility(stripUnsupportedReasoningParams(stripItemIdsWhenUnstored(stripInvalidItemIds(stripUnsupportedHostedTools(sanitizeReasoningInputContent(scrubOcxCompactionItems(outBody), {
1043+
preserveContentModels: provider.preserveReasoningContentModels,
1044+
modelId: parsed.modelId,
1045+
})))))));
10281046
const body = JSON.stringify(stripDisabledReasoningSummaries(
10291047
normalizeConfiguredReasoningSummaryDelivery(sanitizedBody, provider, parsed.modelId),
10301048
provider,
Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,116 @@
1+
/**
2+
* DeepSeek V4 is stateless on the Responses API and requires the caller to replay
3+
* prior assistant reasoning text on every continuation (issue #875). The passthrough
4+
* sanitizer must therefore keep `reasoning.content` for models listed in
5+
* `preserveReasoningContentModels` (seeded for DeepSeek thinking models) while still
6+
* blanking it for ChatGPT-native passthrough and still stripping proxy-minted ocxr1
7+
* envelopes. A routable DeepSeek entry also has to be usable as a sub-agent model.
8+
*/
9+
import { afterEach, describe, expect, test } from "bun:test";
10+
import { sanitizeReasoningInputContent } from "../src/adapters/openai-responses";
11+
import { buildSubagentModelChain, isSubagentModelUnavailable } from "../src/codex/subagent-model-fallback";
12+
import { providerConfigSeed } from "../src/providers/derive";
13+
import { getProviderRegistryEntry } from "../src/providers/registry";
14+
import { OCX_REASONING_PREFIX } from "../src/responses/reasoning-envelope";
15+
import { handleResponses } from "../src/server/responses/core";
16+
import type { OcxConfig, OcxProviderConfig } from "../src/types";
17+
const MODEL = "deepseek-v4-flash";
18+
19+
const RAW_REASONING = {
20+
type: "reasoning",
21+
id: "rs_1",
22+
summary: [],
23+
content: [{ type: "reasoning_text", text: "inspect the failing build" }],
24+
};
25+
26+
function deepseekProvider(): OcxProviderConfig {
27+
return { ...providerConfigSeed(getProviderRegistryEntry("deepseek")!), apiKey: "sk-test" };
28+
}
29+
30+
describe("sanitizeReasoningInputContent respects replay-capable models", () => {
31+
test("keeps raw reasoning content for a preserve-listed model", () => {
32+
const out = sanitizeReasoningInputContent(
33+
{ input: [RAW_REASONING] },
34+
{ preserveContentModels: ["deepseek-v4-flash"], modelId: MODEL },
35+
) as { input: Array<Record<string, unknown>> };
36+
expect(out.input[0].content).toEqual(RAW_REASONING.content);
37+
});
38+
39+
test("still blanks raw reasoning content for non-preserving passthrough", () => {
40+
const out = sanitizeReasoningInputContent(
41+
{ input: [RAW_REASONING] },
42+
{ modelId: "gpt-5.6" },
43+
) as { input: Array<Record<string, unknown>> };
44+
expect(out.input[0].content).toEqual([]);
45+
});
46+
47+
test("still strips ocxr1 envelopes while preserving content for DeepSeek", () => {
48+
const enveloped = {
49+
...RAW_REASONING,
50+
id: "rs_2",
51+
encrypted_content: `${OCX_REASONING_PREFIX}abc`,
52+
};
53+
const out = sanitizeReasoningInputContent(
54+
{ input: [enveloped] },
55+
{ preserveContentModels: ["deepseek-v4-flash"], modelId: MODEL },
56+
) as { input: Array<Record<string, unknown>> };
57+
expect(out.input[0].content).toEqual(RAW_REASONING.content);
58+
expect(out.input[0].encrypted_content).toBeUndefined();
59+
});
60+
});
61+
62+
describe("a DeepSeek Responses continuation keeps its reasoning replay", () => {
63+
const originalFetch = globalThis.fetch;
64+
afterEach(() => { globalThis.fetch = originalFetch; });
65+
66+
test("a tool-call follow-up carries reasoning content and tool output upstream", async () => {
67+
let capturedBody: Record<string, unknown> | undefined;
68+
globalThis.fetch = (async (_input: RequestInfo | URL, init?: RequestInit) => {
69+
capturedBody = JSON.parse(String(init?.body ?? "{}")) as Record<string, unknown>;
70+
return Response.json({
71+
id: "resp_deepseek_2",
72+
object: "response",
73+
status: "completed",
74+
output: [],
75+
});
76+
}) as typeof fetch;
77+
78+
const config = { providers: { deepseek: deepseekProvider() } } as unknown as OcxConfig;
79+
await handleResponses(
80+
new Request("http://localhost/v1/responses", {
81+
method: "POST",
82+
headers: { "content-type": "application/json" },
83+
body: JSON.stringify({
84+
model: MODEL,
85+
stream: false,
86+
store: false,
87+
input: [
88+
RAW_REASONING,
89+
{ type: "function_call", id: "fc_1", call_id: "call_1", name: "exec_command", arguments: "{}" },
90+
{ type: "function_call_output", call_id: "call_1", output: "build failed" },
91+
],
92+
}),
93+
}),
94+
config,
95+
{ model: "", provider: "" },
96+
);
97+
98+
const input = capturedBody!.input as unknown[];
99+
expect(input).toEqual(expect.arrayContaining([
100+
expect.objectContaining({ type: "reasoning", content: RAW_REASONING.content }),
101+
expect.objectContaining({ type: "function_call_output", call_id: "call_1" }),
102+
]));
103+
});
104+
});
105+
106+
describe("DeepSeek V4 Flash is a viable sub-agent candidate", () => {
107+
test("it is routable and sits first in a sub-agent fallback chain", () => {
108+
const config = {
109+
defaultProvider: "deepseek",
110+
providers: { deepseek: deepseekProvider() },
111+
} as unknown as OcxConfig;
112+
const chain = buildSubagentModelChain("deepseek/deepseek-v4-flash", config);
113+
expect(chain[0]).toBe("deepseek/deepseek-v4-flash");
114+
expect(isSubagentModelUnavailable("deepseek/deepseek-v4-flash", config)).toBe(false);
115+
});
116+
});

0 commit comments

Comments
 (0)