diff --git a/docs-site/src/content/docs/reference/adapters.md b/docs-site/src/content/docs/reference/adapters.md index 84b47b367..b64090127 100644 --- a/docs-site/src/content/docs/reference/adapters.md +++ b/docs-site/src/content/docs/reference/adapters.md @@ -58,6 +58,11 @@ waits and replays the identical request on the same key before any other handlin the translated `openai-chat` / Anthropic request path. Custom `runTurn` transports are not part of the HTTP retry loop. +- DeepSeek's stateless Responses parser receives provider-scoped history normalization: hook-injected + context moves after an unambiguous tool-call/result batch. Parallel calls remain grouped before + their matching outputs so every call stays in the reasoning-bearing assistant turn. Tolerant + providers and ambiguous duplicate call IDs keep their original input order. + - `forward` URL → `{baseUrl}/responses`. A `key` provider defaults to the legacy `{baseUrl}/v1/responses` construction. - A `key` provider may set a validated relative `responsesPath`; the adapter removes one trailing slash from `baseUrl` and sends `{trimmedBaseUrl}{responsesPath}`. For Ark Agent Plan, use `baseUrl: "https://ark.cn-beijing.volces.com/api/plan/v3"` with `responsesPath: "/responses"`. - In `forward` mode only a safe header allowlist is relayed (`FORWARD_HEADERS`): authorization, diff --git a/src/adapters/openai-responses.ts b/src/adapters/openai-responses.ts index 7aa3fa8ec..3306d9f17 100644 --- a/src/adapters/openai-responses.ts +++ b/src/adapters/openai-responses.ts @@ -540,15 +540,15 @@ function repairOrphanedInputItems(body: unknown, dropReasoning: boolean): unknow } /** - * Make unambiguous Responses tool pairs adjacent for upstream parsers that require it. + * Make unambiguous Responses tool batches contiguous for upstream parsers that require it. * * [Decision Log] - * - 목적과 의도: Keep Codex hook-injected developer context without letting it make a strict upstream reject the matching tool result. - * - 기존 구현 및 제약 조건: The orphan repair verifies only pair presence; globally reordering valid history would change tolerant providers unnecessarily. - * - 검토한 주요 대안: Reorder every Responses request, drop the intervening message, or gate a lossless reorder behind provider capability metadata. - * - 선택한 방식: Reorder only unique call/result pairs for providers that explicitly require adjacency, preserving every intervening item immediately after the result. - * - 다른 대안 대신 이 방식을 선택한 이유: The provider gate limits semantic blast radius, while refusing ambiguous duplicate ids avoids guessing which result belongs to which call. - * - 장점, 단점 및 영향: DeepSeek receives the adjacency its parser requires; tolerant providers stay byte/order equivalent. Ambiguous duplicate ids still fail upstream rather than being silently rewritten. + * - 목적과 의도: Keep Codex hook-injected developer context without splitting a parallel tool-call turn away from its reasoning or making a strict upstream reject matching results. + * - 기존 구현 및 제약 조건: The orphan repair verifies only pair presence, while the original pair-by-pair reorder turned `reasoning, call A, call B, output A, output B` into two assistant turns and made DeepSeek reject call B for missing reasoning (#1477). + * - 검토한 주요 대안: Disable parallel calls (DeepSeek always enables them); duplicate reasoning per call; reorder each pair; or normalize the complete unambiguous call batch. + * - 선택한 방식: Treat calls emitted before the first matched result as one batch, emit all calls followed by their matched outputs, and preserve intervening non-tool items immediately after the batch. + * - 다른 대안 대신 이 방식을 선택한 이유: Batch normalization matches the Responses parallel-call shape without fabricating reasoning, while the provider gate and unique-pair requirement keep the blast radius narrow. + * - 장점, 단점 및 영향: DeepSeek keeps one reasoning-bearing assistant turn for parallel calls and still accepts hook-interleaved single calls; tolerant providers stay byte/order equivalent, and ambiguous duplicate ids are not guessed. */ function normalizeResponsesToolResultAdjacency(body: unknown): unknown { if (!isPlainObject(body) || !Array.isArray(body.input)) return body; @@ -576,24 +576,52 @@ function normalizeResponsesToolResultAdjacency(body: unknown): unknown { } } - const movedOutputIndices = new Set(); - const outputAfterCall = new Map(); + const pairs: Array<{ callIndex: number; outputIndex: number }> = []; for (const [key, callIndices] of calls) { const outputIndices = outputs.get(key); - if (callIndices.length !== 1 || outputIndices?.length !== 1) continue; + if (!outputIndices) continue; + if (callIndices.length !== 1 || outputIndices.length !== 1) return body; const callIndex = callIndices[0]!; const outputIndex = outputIndices[0]!; - if (outputIndex === callIndex + 1) continue; - movedOutputIndices.add(outputIndex); - outputAfterCall.set(callIndex, input[outputIndex]); + if (outputIndex <= callIndex) return body; + pairs.push({ callIndex, outputIndex }); } - if (movedOutputIndices.size === 0) return body; + pairs.sort((left, right) => left.callIndex - right.callIndex); + + const movedIndices = new Set(); + const batchAt = new Map(); + for (let cursor = 0; cursor < pairs.length;) { + const group = [pairs[cursor]!]; + let firstOutputIndex = pairs[cursor]!.outputIndex; + let next = cursor + 1; + while (next < pairs.length && pairs[next]!.callIndex < firstOutputIndex) { + group.push(pairs[next]!); + firstOutputIndex = Math.min(firstOutputIndex, pairs[next]!.outputIndex); + next += 1; + } + + const batch = [ + ...group.map(pair => input[pair.callIndex]), + ...group.map(pair => input[pair.outputIndex]), + ]; + const anchor = group[0]!.callIndex; + const alreadyContiguous = batch.every((item, offset) => input[anchor + offset] === item); + if (!alreadyContiguous) { + batchAt.set(anchor, batch); + for (const pair of group) { + movedIndices.add(pair.callIndex); + movedIndices.add(pair.outputIndex); + } + } + cursor = next; + } + if (batchAt.size === 0) return body; const normalized: unknown[] = []; for (let index = 0; index < input.length; index += 1) { - if (movedOutputIndices.has(index)) continue; - normalized.push(input[index]); - if (outputAfterCall.has(index)) normalized.push(outputAfterCall.get(index)); + const batch = batchAt.get(index); + if (batch) normalized.push(...batch); + if (!movedIndices.has(index)) normalized.push(input[index]); } return { ...body, input: normalized }; } diff --git a/src/providers/registry.ts b/src/providers/registry.ts index 54c623027..2ee0fb292 100644 --- a/src/providers/registry.ts +++ b/src/providers/registry.ts @@ -194,8 +194,8 @@ export interface ProviderRegistryEntry { */ statelessResponses?: boolean; /** - * Responses parser requires a matched tool result directly after its call. This is - * seeded/backfilled like other fixed upstream wire-contract capabilities. + * Responses parser requires an unambiguous call batch and its matched result batch + * to stay contiguous. This is seeded/backfilled like other fixed wire capabilities. */ requiresAdjacentResponsesToolResults?: boolean; /** @@ -1453,7 +1453,8 @@ export const PROVIDER_REGISTRY: readonly ProviderRegistryEntry[] = [ // server." https://api-docs.deepseek.com/api/create-response/ statelessResponses: true, // DeepSeek rejects a valid Codex continuation when hook-provided developer - // context is persisted between a call and its matching result (#1292). + // context splits a call from its result (#1292); parallel calls remain one + // reasoning-bearing assistant batch rather than being split per pair (#1477). requiresAdjacentResponsesToolResults: true, /* [Decision Log] - 목적: DeepSeek V4 thinking mode multi-turn/tool-call requests must replay prior assistant reasoning_content. diff --git a/src/types.ts b/src/types.ts index 33a6e3e6d..788822e95 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1227,9 +1227,9 @@ export interface OcxProviderConfig { */ statelessResponses?: boolean; /** - * Responses upstream whose parser requires each tool result to immediately follow - * its matching call. When enabled, only unambiguous matched pairs are reordered; - * intervening messages are preserved after the result instead of being dropped. + * Responses upstream whose parser requires an unambiguous call batch and its matched + * result batch to remain contiguous. Intervening messages are preserved after the + * batch, and parallel calls stay together with the reasoning turn that produced them. */ requiresAdjacentResponsesToolResults?: boolean; /** diff --git a/structure/04_transports-and-sidecars.md b/structure/04_transports-and-sidecars.md index e3b052d72..088c53d05 100644 --- a/structure/04_transports-and-sidecars.md +++ b/structure/04_transports-and-sidecars.md @@ -356,6 +356,21 @@ replays are explicit and receive the same repair. These compatibility guards are covered by focused tests and should stay close to the adapters that need them. +DeepSeek's stateless Responses compatibility pass normalizes only unambiguous tool-call batches. +Calls emitted before the first matched output stay together as one assistant batch, followed by +their outputs in call order; hook-injected messages that split the batch move after it without being +dropped. This preserves #1292's single-call adjacency repair without splitting a same-turn parallel +batch away from its preceding plaintext reasoning (#1477). Tolerant providers never enter this pass, +and duplicate or backwards call/result pairs are left for the upstream to reject rather than guessed. + +[Decision Log] +- 목적과 의도: Preserve DeepSeek reasoning replay for parallel tool calls while retaining the provider-scoped repair for hook-interleaved results. +- 기존 구현 및 제약 조건: Pair-by-pair adjacency fixed one call but split parallel calls into separate assistant turns; DeepSeek always enables parallel tool calling and merges adjacent reasoning and calls into one assistant message. +- 검토한 주요 대안: Disable parallel calls, duplicate reasoning, remove the #1292 repair, or normalize one unambiguous call/output batch. +- 선택한 방식: Group calls that occur before the first matched output, emit the call batch followed by outputs in call order, and retain intervening non-tool items after the batch. +- 다른 대안 대신 이 방식을 선택한 이유: The batch shape matches the documented Responses contract without inventing reasoning or reintroducing hook-interleaving failures. +- 장점, 단점 및 영향: Sequential and parallel tool continuations both retain their reasoning contract; only the declared strict provider changes order, and ambiguous histories still fail closed upstream. + ## Cursor parameterized models Cursor Router's parameterized `default` model is represented in Codex by four catalog rows: diff --git a/tests/deepseek-inbound-wire.test.ts b/tests/deepseek-inbound-wire.test.ts index 301edbae1..fe204d696 100644 --- a/tests/deepseek-inbound-wire.test.ts +++ b/tests/deepseek-inbound-wire.test.ts @@ -99,6 +99,10 @@ function deepseekProvider(): OcxProviderConfig { return { ...providerConfigSeed(getProviderRegistryEntry("deepseek")!), apiKey: "sk-test" }; } +function deepseekReasoningProvider(): OcxProviderConfig { + return { ...deepseekProvider(), preserveResponsesReasoningContent: true }; +} + describe("DeepSeek wire selection is scoped to the inbound protocol", () => { test("a Responses inbound rides the native Responses wire", () => { const resolved = resolveWireProtocolOverride("deepseek", MODEL, deepseekProvider(), "responses"); @@ -819,6 +823,89 @@ describe("stateless Responses upstreams get no stateful parameters", () => { expect(body.input).toEqual([call, output, injected, tail]); }); + test("DeepSeek keeps a parallel call batch attached to one reasoning turn", () => { + const reasoning = { + type: "reasoning", + content: [{ type: "reasoning_text", text: "read both files" }], + summary: [], + }; + const callA = { type: "function_call", call_id: "call_a", name: "read_file", arguments: "{}" }; + const callB = { type: "function_call", call_id: "call_b", name: "read_file", arguments: "{}" }; + const outputA = { type: "function_call_output", call_id: "call_a", output: "A" }; + const outputB = { type: "function_call_output", call_id: "call_b", output: "B" }; + + const body = buildBody(deepseekReasoningProvider(), { + input: [reasoning, callA, callB, outputA, outputB], + }) as { input: unknown[] }; + expect(body.input).toEqual([reasoning, callA, callB, outputA, outputB]); + }); + + test("DeepSeek moves injected context after the complete parallel call and result batches", () => { + const reasoning = { + type: "reasoning", + content: [{ type: "reasoning_text", text: "read both files" }], + summary: [], + }; + const callA = { type: "function_call", call_id: "call_a", name: "read_file", arguments: "{}" }; + const callB = { type: "function_call", call_id: "call_b", name: "read_file", arguments: "{}" }; + const outputA = { type: "function_call_output", call_id: "call_a", output: "A" }; + const outputB = { type: "function_call_output", call_id: "call_b", output: "B" }; + const injected = { + type: "message", + role: "developer", + content: [{ type: "input_text", text: "[planning-with-files] ACTIVE PLAN" }], + }; + const tail = { type: "message", role: "user", content: [{ type: "input_text", text: "continue" }] }; + + const body = buildBody(deepseekReasoningProvider(), { + input: [reasoning, callA, injected, callB, outputA, outputB, tail], + }) as { input: unknown[] }; + expect(body.input).toEqual([ + reasoning, + callA, + callB, + outputA, + outputB, + injected, + tail, + ]); + }); + + test("DeepSeek keeps sequential reasoning and tool rounds separate", () => { + const reasoningA = { + type: "reasoning", + content: [{ type: "reasoning_text", text: "first" }], + summary: [], + }; + const reasoningB = { + type: "reasoning", + content: [{ type: "reasoning_text", text: "second" }], + summary: [], + }; + const callA = { type: "function_call", call_id: "call_a", name: "read_file", arguments: "{}" }; + const callB = { type: "function_call", call_id: "call_b", name: "read_file", arguments: "{}" }; + const outputA = { type: "function_call_output", call_id: "call_a", output: "A" }; + const outputB = { type: "function_call_output", call_id: "call_b", output: "B" }; + + const body = buildBody(deepseekReasoningProvider(), { + input: [reasoningA, callA, outputA, reasoningB, callB, outputB], + }) as { input: unknown[] }; + expect(body.input).toEqual([reasoningA, callA, outputA, reasoningB, callB, outputB]); + }); + + test("DeepSeek leaves duplicate call ids unchanged rather than guessing a batch", () => { + const uniqueCall = { type: "function_call", call_id: "call_unique", name: "unique", arguments: "{}" }; + const uniqueOutput = { type: "function_call_output", call_id: "call_unique", output: "unique" }; + const firstCall = { type: "function_call", call_id: "call_dup", name: "first", arguments: "{}" }; + const secondCall = { type: "function_call", call_id: "call_dup", name: "second", arguments: "{}" }; + const injected = { type: "message", role: "developer", content: [{ type: "input_text", text: "context" }] }; + const output = { type: "function_call_output", call_id: "call_dup", output: "ambiguous" }; + const input = [uniqueCall, injected, uniqueOutput, firstCall, secondCall, output]; + + const body = buildBody(deepseekProvider(), { input }) as { input: unknown[] }; + expect(body.input).toEqual(input); + }); + test("tolerant Responses providers keep interleaved tool history unchanged", () => { const provider: OcxProviderConfig = { adapter: "openai-responses",