From 104d960a9d0b0a7c9001bba1232efdae20a74a46 Mon Sep 17 00:00:00 2001 From: Ingwannu Date: Tue, 11 Aug 2026 14:47:00 +0000 Subject: [PATCH 1/4] fix(deepseek): preserve parallel reasoning replay --- .../src/content/docs/reference/adapters.md | 5 ++ src/adapters/openai-responses.ts | 62 +++++++++---- src/providers/registry.ts | 7 +- src/types.ts | 6 +- structure/04_transports-and-sidecars.md | 15 ++++ tests/deepseek-inbound-wire.test.ts | 87 +++++++++++++++++++ 6 files changed, 159 insertions(+), 23 deletions(-) diff --git a/docs-site/src/content/docs/reference/adapters.md b/docs-site/src/content/docs/reference/adapters.md index 84b47b3670..b64090127b 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 7aa3fa8ec8..3306d9f17c 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 b94483259a..42eef9369d 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; /** @@ -1462,7 +1462,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 33a6e3e6d4..788822e953 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 6c88a61d07..aa355da85a 100644 --- a/structure/04_transports-and-sidecars.md +++ b/structure/04_transports-and-sidecars.md @@ -379,6 +379,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 301edbae11..fe204d6969 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", From 7e6428609a1b91fbac6a8c7a8ae1e1e43f20574b Mon Sep 17 00:00:00 2001 From: Wibias <37517432+Wibias@users.noreply.github.com> Date: Wed, 12 Aug 2026 01:37:45 +0200 Subject: [PATCH 2/4] fix(deepseek): fail closed on unmatched tool results --- .../src/content/docs/reference/adapters.md | 2 +- src/adapters/openai-responses.ts | 4 ++-- structure/04_transports-and-sidecars.md | 2 +- tests/deepseek-inbound-wire.test.ts | 22 +++++++++++++++++++ 4 files changed, 26 insertions(+), 4 deletions(-) diff --git a/docs-site/src/content/docs/reference/adapters.md b/docs-site/src/content/docs/reference/adapters.md index b64090127b..f3eb80c1d9 100644 --- a/docs-site/src/content/docs/reference/adapters.md +++ b/docs-site/src/content/docs/reference/adapters.md @@ -61,7 +61,7 @@ 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. + providers and ambiguous duplicate, missing, or out-of-order 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"`. diff --git a/src/adapters/openai-responses.ts b/src/adapters/openai-responses.ts index 3306d9f17c..f6c5b840c7 100644 --- a/src/adapters/openai-responses.ts +++ b/src/adapters/openai-responses.ts @@ -548,7 +548,7 @@ function repairOrphanedInputItems(body: unknown, dropReasoning: boolean): unknow * - 검토한 주요 대안: 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. + * - 장점, 단점 및 영향: DeepSeek keeps one reasoning-bearing assistant turn for parallel calls and still accepts hook-interleaved single calls; tolerant providers stay byte/order equivalent, and duplicate, missing, or backwards call/result pairs are not guessed. */ function normalizeResponsesToolResultAdjacency(body: unknown): unknown { if (!isPlainObject(body) || !Array.isArray(body.input)) return body; @@ -579,7 +579,7 @@ function normalizeResponsesToolResultAdjacency(body: unknown): unknown { const pairs: Array<{ callIndex: number; outputIndex: number }> = []; for (const [key, callIndices] of calls) { const outputIndices = outputs.get(key); - if (!outputIndices) continue; + if (!outputIndices) return body; if (callIndices.length !== 1 || outputIndices.length !== 1) return body; const callIndex = callIndices[0]!; const outputIndex = outputIndices[0]!; diff --git a/structure/04_transports-and-sidecars.md b/structure/04_transports-and-sidecars.md index aa355da85a..4e139fa227 100644 --- a/structure/04_transports-and-sidecars.md +++ b/structure/04_transports-and-sidecars.md @@ -384,7 +384,7 @@ Calls emitted before the first matched output stay together as one assistant bat 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. +and duplicate, missing, 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. diff --git a/tests/deepseek-inbound-wire.test.ts b/tests/deepseek-inbound-wire.test.ts index fe204d6969..6fec650372 100644 --- a/tests/deepseek-inbound-wire.test.ts +++ b/tests/deepseek-inbound-wire.test.ts @@ -906,6 +906,28 @@ describe("stateless Responses upstreams get no stateful parameters", () => { expect(body.input).toEqual(input); }); + test("DeepSeek fails closed when a collected call has no matching result", () => { + 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 outputB = { type: "function_call_output", call_id: "call_b", output: "B" }; + const injected = { type: "message", role: "developer", content: [{ type: "input_text", text: "context" }] }; + const input = [callA, callB, injected, outputB]; + + const body = buildBody(deepseekProvider(), { input }) as { input: unknown[] }; + expect(body.input).toEqual(input); + }); + + test("DeepSeek fails closed when a collected call/result pair is backwards", () => { + 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 outputB = { type: "function_call_output", call_id: "call_b", output: "B" }; + const outputA = { type: "function_call_output", call_id: "call_a", output: "A" }; + const input = [callB, callA, outputB, outputA]; + + 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", From e851219f2db77b2564fd7a9f6e7b50394353cbf2 Mon Sep 17 00:00:00 2001 From: Wibias <37517432+Wibias@users.noreply.github.com> Date: Wed, 12 Aug 2026 02:34:28 +0200 Subject: [PATCH 3/4] fix(deepseek): fail closed on unmatched or reversed tool results --- .../src/content/docs/ja/reference/adapters.md | 5 +++ .../src/content/docs/ko/reference/adapters.md | 5 +++ .../src/content/docs/ru/reference/adapters.md | 6 ++++ .../content/docs/zh-cn/reference/adapters.md | 4 +++ .../content/docs/zh-tw/reference/adapters.md | 4 +++ src/adapters/openai-responses.ts | 14 ++++++++ src/types.ts | 4 +-- tests/deepseek-inbound-wire.test.ts | 33 +++++++++++++++++++ 8 files changed, 73 insertions(+), 2 deletions(-) diff --git a/docs-site/src/content/docs/ja/reference/adapters.md b/docs-site/src/content/docs/ja/reference/adapters.md index c0211e6d08..fc05e0a617 100644 --- a/docs-site/src/content/docs/ja/reference/adapters.md +++ b/docs-site/src/content/docs/ja/reference/adapters.md @@ -51,6 +51,11 @@ interface ProviderAdapter { 先立って、同じキーで同一リクエストを待機して再送します。カスタム `runTurn` トランスポートは HTTP リトライ ループの対象外です。 +- DeepSeek のステートレス Responses パーサーは、プロバイダーにスコープされた履歴正規化を受けます: フックで + 注入されたコンテキストは、あいまいさのない tool-call/result バッチの後に移動します。並列呼び出しは、 + それぞれの出力の前にグループ化されたままなので、すべての呼び出しが推論を含むアシスタントターンにとどまり + ます。寛容なプロバイダーと、重複・欠落・順序不正の call ID は元の入力順を保持します。 + - `forward` URL → `{baseUrl}/responses`。`key` provider はデフォルトで従来の `{baseUrl}/v1/responses` 構築を使います。 - `key` provider は検証済みの相対 `responsesPath` を設定できます。adapter は `baseUrl` 末尾の `/` を 1 つ除き、`{trimmedBaseUrl}{responsesPath}` に送信します。Ark Agent Plan では `baseUrl: "https://ark.cn-beijing.volces.com/api/plan/v3"` と `responsesPath: "/responses"` を使います。 - `forward` モードでは安全なヘッダー許可リスト(`FORWARD_HEADERS`)だけを中継します。authorization、ChatGPT account id、OpenAI beta/originator/session ヘッダーが対象です。この ChatGPT ログイン経路は [サイドカー](/ja/guides/sidecars/) にも使われます。 diff --git a/docs-site/src/content/docs/ko/reference/adapters.md b/docs-site/src/content/docs/ko/reference/adapters.md index 1c9e75ee24..e742eafa72 100644 --- a/docs-site/src/content/docs/ko/reference/adapters.md +++ b/docs-site/src/content/docs/ko/reference/adapters.md @@ -58,6 +58,11 @@ interface ProviderAdapter { 같은 키로 동일 요청을 대기 후 재전송합니다. 커스텀 `runTurn` 전송은 HTTP 재시도 루프에 포함되지 않습니다. +- DeepSeek의 stateless Responses 파서는 제공자 범위의 기록 정규화를 받습니다: 훅으로 + 주입된 컨텍스트는 명확한 tool-call/result 배치 뒤로 이동합니다. 병렬 호출은 각 결과 앞에 + 함께 묶여 있어 모든 호출이 추론을 담은 어시스턴트 턴에 남습니다. 관대한 제공자와 중복되거나 + 누락되거나 순서가 잘못된 call ID는 원래 입력 순서를 유지합니다. + - `forward` URL → `{baseUrl}/responses`. `key` provider는 기본적으로 기존 `{baseUrl}/v1/responses` 구성을 사용합니다. - `key` provider는 검증된 상대 `responsesPath`를 설정할 수 있습니다. adapter는 `baseUrl` 끝의 `/` 하나를 제거하고 `{trimmedBaseUrl}{responsesPath}`로 전송합니다. Ark Agent Plan은 `baseUrl: "https://ark.cn-beijing.volces.com/api/plan/v3"`와 `responsesPath: "/responses"`를 사용합니다. - `forward` 모드에서는 안전한 헤더 허용 목록(`FORWARD_HEADERS`)만 중계합니다. authorization, diff --git a/docs-site/src/content/docs/ru/reference/adapters.md b/docs-site/src/content/docs/ru/reference/adapters.md index d18892c5f1..eb0cd89594 100644 --- a/docs-site/src/content/docs/ru/reference/adapters.md +++ b/docs-site/src/content/docs/ru/reference/adapters.md @@ -62,6 +62,12 @@ interface ProviderAdapter { том же ключе, как и в переводимом пути `openai-chat`/Anthropic. Пользовательские транспорты `runTurn` в цикл HTTP-повторов не входят. +- Stateless-парсер DeepSeek Responses получает нормализацию истории на уровне провайдера: + контекст, внедрённый хуком, переносится после однозначного батча call/result. Параллельные вызовы + остаются сгруппированными перед своими результатами, поэтому каждый вызов сохраняет свой + assistant-ход с рассуждениями. Толерантные провайдеры и неоднозначные (дублирующиеся, + отсутствующие или неупорядоченные) идентификаторы call сохраняют исходный порядок входа. + - URL для `forward` → `{baseUrl}/responses`. Провайдер с `key` по умолчанию сохраняет прежнее построение `{baseUrl}/v1/responses`. - Провайдер с `key` может задать проверенный относительный `responsesPath`: адаптер удаляет один завершающий `/` из `baseUrl` и отправляет запрос на `{trimmedBaseUrl}{responsesPath}`. Для Ark Agent Plan используйте `baseUrl: "https://ark.cn-beijing.volces.com/api/plan/v3"` и `responsesPath: "/responses"`. - В режиме `forward` ретранслируется только безопасный allowlist заголовков (`FORWARD_HEADERS`): diff --git a/docs-site/src/content/docs/zh-cn/reference/adapters.md b/docs-site/src/content/docs/zh-cn/reference/adapters.md index a7c65825ec..e6d1c30635 100644 --- a/docs-site/src/content/docs/zh-cn/reference/adapters.md +++ b/docs-site/src/content/docs/zh-cn/reference/adapters.md @@ -54,6 +54,10 @@ interface ProviderAdapter { 会等待并先于其他处理或故障转移,在相同 key 上重放完全相同请求,与翻译后的 `openai-chat`/Anthropic 请求路径一致。自定义 `runTurn` 传输不在 HTTP 重试循环之内。 +- DeepSeek 的 stateless Responses parser 会收到按 provider 范围的历史归一化:hook 注入的上下文会移动到 + 明确的 tool-call/result 批次之后。并行调用保持在其对应输出之前分组,因此每个调用都留在承载 + 推理的 assistant 回合中。宽容的 provider 和歧义的(重复、缺失或乱序的)call ID 保留原始输入顺序。 + - `forward` URL → `{baseUrl}/responses`。`key` provider 默认保留原有的 `{baseUrl}/v1/responses` 构造。 - `key` provider 可设置经过验证的相对 `responsesPath`;adapter 会移除 `baseUrl` 末尾的一个 `/`,并向 `{trimmedBaseUrl}{responsesPath}` 发送请求。Ark Agent Plan 使用 `baseUrl: "https://ark.cn-beijing.volces.com/api/plan/v3"` 和 `responsesPath: "/responses"`。 - `forward` 模式只会转发安全的 header allowlist(`FORWARD_HEADERS`):authorization、ChatGPT diff --git a/docs-site/src/content/docs/zh-tw/reference/adapters.md b/docs-site/src/content/docs/zh-tw/reference/adapters.md index 2859d0d501..2e90aac16b 100644 --- a/docs-site/src/content/docs/zh-tw/reference/adapters.md +++ b/docs-site/src/content/docs/zh-tw/reference/adapters.md @@ -43,6 +43,10 @@ interface ProviderAdapter { **不經轉換**地流式傳回。 **認證:** `forward`(轉發呼叫方 header)或 `key`。 +- DeepSeek 的 stateless Responses parser 會收到按 provider 範圍的歷史正規化:hook 注入的內容會移動到 + 明確的 tool-call/result 批次之後。並行呼叫保持在其對應輸出之前分組,因此每個呼叫都留在承載 + 推理的 assistant 回合中。寬容的 provider 和歧義的(重複、缺失或亂序的)call ID 保留原始輸入順序。 + - `forward` URL → `{baseUrl}/responses`。`key` provider 預設保留原有的 `{baseUrl}/v1/responses` 構造。 - `key` provider 可設定經過驗證的相對 `responsesPath`;adapter 會移除 `baseUrl` 末尾的一個 `/`,並向 `{trimmedBaseUrl}{responsesPath}` 傳送請求。Ark Agent Plan 使用 `baseUrl: "https://ark.cn-beijing.volces.com/api/plan/v3"` 和 `responsesPath: "/responses"`。 - `forward` 模式只會轉發安全的 header allowlist(`FORWARD_HEADERS`):authorization、ChatGPT diff --git a/src/adapters/openai-responses.ts b/src/adapters/openai-responses.ts index f6c5b840c7..b5be70d168 100644 --- a/src/adapters/openai-responses.ts +++ b/src/adapters/openai-responses.ts @@ -586,6 +586,13 @@ function normalizeResponsesToolResultAdjacency(body: unknown): unknown { if (outputIndex <= callIndex) return body; pairs.push({ callIndex, outputIndex }); } + // Reject any collected output that lacks exactly one matching call. A lone or + // duplicated output is ambiguous, and normalizing on top of it could sever a + // result from the reasoning-bearing call turn it belongs to. + for (const [key, outputIndices] of outputs) { + const callIndices = calls.get(key); + if (!callIndices || callIndices.length !== 1 || outputIndices.length !== 1) return body; + } pairs.sort((left, right) => left.callIndex - right.callIndex); const movedIndices = new Set(); @@ -600,6 +607,13 @@ function normalizeResponsesToolResultAdjacency(body: unknown): unknown { next += 1; } + // Within one reasoning turn the outputs must appear in the same order as their + // calls. If they are reversed, normalizing would fabricate a new output order; + // leave the ambiguous history untouched instead. + for (let groupIndex = 1; groupIndex < group.length; groupIndex += 1) { + if (group[groupIndex]!.outputIndex < group[groupIndex - 1]!.outputIndex) return body; + } + const batch = [ ...group.map(pair => input[pair.callIndex]), ...group.map(pair => input[pair.outputIndex]), diff --git a/src/types.ts b/src/types.ts index 788822e953..b50d4cb2ca 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1228,8 +1228,8 @@ export interface OcxProviderConfig { statelessResponses?: boolean; /** * 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. + * result batch to remain contiguous. Hook-injected context that splits the batch is + * preserved after it, and parallel calls stay together with the reasoning turn that produced them. */ requiresAdjacentResponsesToolResults?: boolean; /** diff --git a/tests/deepseek-inbound-wire.test.ts b/tests/deepseek-inbound-wire.test.ts index 6fec650372..77455e67db 100644 --- a/tests/deepseek-inbound-wire.test.ts +++ b/tests/deepseek-inbound-wire.test.ts @@ -927,6 +927,39 @@ describe("stateless Responses upstreams get no stateful parameters", () => { const body = buildBody(deepseekProvider(), { input }) as { input: unknown[] }; expect(body.input).toEqual(input); }); + test("DeepSeek leaves reversed outputs in their original order", () => { + 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 outputB = { type: "function_call_output", call_id: "call_b", output: "B" }; + const outputA = { type: "function_call_output", call_id: "call_a", output: "A" }; + const injected = { type: "message", role: "developer", content: [{ type: "input_text", text: "context" }] }; + const input = [callA, callB, injected, outputB, outputA]; + + const body = buildBody(deepseekProvider(), { input }) as { input: unknown[] }; + expect(body.input).toEqual(input); + }); + + test("DeepSeek keeps a valid pair in order when an unrelated output has no matching call", () => { + // The orphan-output repair runs before the normalizer and flattens the unmatched + // tool result into a user message, so the matched pair is still normalized and the + // unmatchable output is never forwarded as a raw tool output. + const callA = { type: "function_call", call_id: "call_a", name: "read_file", arguments: "{}" }; + const outputA = { type: "function_call_output", call_id: "call_a", output: "A" }; + const injected = { type: "message", role: "developer", content: [{ type: "input_text", text: "context" }] }; + const orphanOutput = { type: "function_call_output", call_id: "call_orphan", output: "orphan" }; + const tail = { type: "message", role: "user", content: [{ type: "input_text", text: "continue" }] }; + const input = [callA, injected, outputA, orphanOutput, tail]; + + const body = buildBody(deepseekProvider(), { input }) as { input: unknown[] }; + expect(body.input[0]).toEqual(callA); + expect(body.input[1]).toEqual(outputA); + expect(body.input).toContainEqual(injected); + expect(body.input).toContainEqual(tail); + expect(body.input.some(item => item.type === "function_call_output")).toBe(true); + // The orphan output must not be forwarded raw; it is flattened into a user message. + expect(body.input.some(item => item.type === "function_call_output" && item.call_id === "call_orphan")).toBe(false); + }); + test("tolerant Responses providers keep interleaved tool history unchanged", () => { const provider: OcxProviderConfig = { From 9b56a617d36e5e45e53b974b1bdc64aadf567149 Mon Sep 17 00:00:00 2001 From: Wibias <37517432+Wibias@users.noreply.github.com> Date: Wed, 12 Aug 2026 02:38:53 +0200 Subject: [PATCH 4/4] Update adapters.md --- docs-site/src/content/docs/ru/reference/adapters.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs-site/src/content/docs/ru/reference/adapters.md b/docs-site/src/content/docs/ru/reference/adapters.md index eb0cd89594..6386236c4c 100644 --- a/docs-site/src/content/docs/ru/reference/adapters.md +++ b/docs-site/src/content/docs/ru/reference/adapters.md @@ -65,7 +65,7 @@ interface ProviderAdapter { - Stateless-парсер DeepSeek Responses получает нормализацию истории на уровне провайдера: контекст, внедрённый хуком, переносится после однозначного батча call/result. Параллельные вызовы остаются сгруппированными перед своими результатами, поэтому каждый вызов сохраняет свой - assistant-ход с рассуждениями. Толерантные провайдеры и неоднозначные (дублирующиеся, + один assistant-ход с рассуждениями. Толерантные провайдеры и неоднозначные (дублирующиеся, отсутствующие или неупорядоченные) идентификаторы call сохраняют исходный порядок входа. - URL для `forward` → `{baseUrl}/responses`. Провайдер с `key` по умолчанию сохраняет прежнее построение `{baseUrl}/v1/responses`.