Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions docs-site/src/content/docs/reference/adapters.md
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,13 @@ of the HTTP retry loop.
ChatGPT account id, and the OpenAI beta/originator/session headers. This is the ChatGPT-login path
that also powers the [sidecars](/guides/sidecars/).

For providers that declare `requiresAdjacentResponsesToolResults` (currently DeepSeek), the adapter
normalizes an unambiguous Responses tool history so one parallel-tool-call assistant turn stays
together as a call batch followed by its matching results in call order. Hook-injected context that
interleaved the batch moves immediately after it rather than being dropped. Histories that are
missing, duplicate, or out-of-order (backward) are ambiguous and are forwarded unchanged — they are
left for the upstream to reject rather than being guessed.

## `anthropic`

**Targets:** Anthropic **Messages** (`/v1/messages`).
Expand Down
78 changes: 61 additions & 17 deletions src/adapters/openai-responses.ts
Original file line number Diff line number Diff line change
Expand Up @@ -540,15 +540,27 @@ 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 letting a strict upstream reject the matching tool results.
* - 기존 구현 및 제약 조건: The orphan repair verifies only pair presence, while a 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). Pair-by-pair handling also skipped calls
* without a matched result, so a partially matched history could still be reordered.
* - 검토한 주요 대안: Disable parallel calls (DeepSeek always enables them); duplicate reasoning per
* call; reorder each pair; or normalize only a complete, unambiguous call/output batch.
* - 선택한 방식: Treat calls emitted before the first matched result as one batch, emit all calls
* followed by their matched outputs in call order, and preserve intervening non-tool items after
* the batch. Any missing, duplicate, backward, or otherwise ambiguous call/result history is left
* unchanged and fails closed upstream.
* - 다른 대안 대신 이 방식을 선택한 이유: Batch normalization matches the Responses parallel-call shape
* without fabricating reasoning, while the provider gate and explicit completeness check keep the
* blast radius narrow and never reorder a history we cannot prove unambiguous.
* - 장점, 단점 및 영향: DeepSeek keeps one reasoning-bearing assistant turn for parallel calls and still
* accepts hook-interleaved single calls; tolerant providers stay byte/order equivalent, and missing,
* duplicate, or backward call/result histories are not guessed.
*/
function normalizeResponsesToolResultAdjacency(body: unknown): unknown {
if (!isPlainObject(body) || !Array.isArray(body.input)) return body;
Expand Down Expand Up @@ -576,24 +588,56 @@ function normalizeResponsesToolResultAdjacency(body: unknown): unknown {
}
}

const movedOutputIndices = new Set<number>();
const outputAfterCall = new Map<number, unknown>();
// Fail closed: only normalize when every collected call has exactly one matching
// result and that result appears after its call. Missing, duplicate, or backward
// histories are ambiguous and must be left untouched for the upstream to reject.
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 (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 });
Comment on lines +591 to +601

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Reject unmatched and result-reversed tool histories.

Lines 595-601 validate calls, but they do not reject an output whose call_id has no matching call. Lines 609-622 also accept callA, callB, outputB, outputA and rewrite it to callA, callB, outputA, outputB. Both cases are ambiguous histories. The adapter must return body unchanged.

Validate every entry in outputs against exactly one call. Before building each batch, require output indices to be strictly increasing in call order. Add regression cases for an orphan output beside an otherwise valid batch and for reversed parallel outputs.

Proposed fix
+  for (const [key, outputIndices] of outputs) {
+    if (calls.get(key)?.length !== 1 || outputIndices.length !== 1) return body;
+  }
+
   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) return body;
     while (next < pairs.length && pairs[next]!.callIndex < firstOutputIndex) {
       group.push(pairs[next]!);
       firstOutputIndex = Math.min(firstOutputIndex, pairs[next]!.outputIndex);
       next += 1;
     }
+    if (group.some((pair, index) =>
+      index > 0 && pair.outputIndex <= group[index - 1]!.outputIndex,
+    )) return body;

As per path instructions, “Preserve histories with missing, duplicate, reversed, or otherwise out-of-order calls/results unchanged.”

Also applies to: 609-622

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/adapters/openai-responses.ts` around lines 591 - 601, Update the
normalization logic around the calls/outputs validation and batch construction
to reject orphan outputs by requiring every entry in outputs to map to exactly
one call. Before rewriting each batch, require matched output indices to be
strictly increasing in call order; preserve body unchanged for missing,
duplicate, reversed, or otherwise out-of-order histories. Add regression
coverage for an orphan output in an otherwise valid batch and reversed parallel
outputs.

Source: Path instructions

}
if (movedOutputIndices.size === 0) return body;
if (pairs.length === 0) return body;

pairs.sort((left, right) => left.callIndex - right.callIndex);

const movedIndices = new Set<number>();
const batchAt = new Map<number, unknown[]>();
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 };
}
Expand Down
7 changes: 7 additions & 0 deletions structure/04_transports-and-sidecars.md
Original file line number Diff line number Diff line change
Expand Up @@ -262,6 +262,13 @@ as `response.incomplete`, never synthetic success. The repair shares the per-tur
budget, preserves backpressure, and composes ahead of item-id/snapshot rewrites so HTTP/SSE and
WebSocket clients observe the same canonical lifecycle.

DeepSeek also opts into a provider-scoped Responses history normalization
(`requiresAdjacentResponsesToolResults`). Only an unambiguous call/output batch is normalized:
calls emitted before the first matched result stay together as one assistant batch, followed by
their matching results in call order, and any intervening hook-injected context moves after the
batch. Missing, duplicate, or backward (out-of-order) call/result histories are ambiguous and are
left unchanged so the strict upstream rejects them rather than guessing.

`ws-bridge.ts` preserves upstream `failed` and `incomplete` status values in the final WebSocket
frame rather than always emitting `response.completed`. If the response status is `failed`, a
`response.failed` frame is sent; otherwise `response.completed` carries through the original status.
Expand Down
107 changes: 107 additions & 0 deletions tests/deepseek-inbound-wire.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,10 @@ function deepseekProvider(): OcxProviderConfig {
return { ...providerConfigSeed(getProviderRegistryEntry("deepseek")!), apiKey: "sk-test" };
}

function deepseekReasoningProvider(): OcxProviderConfig {
return { ...deepseekProvider(), preserveResponsesReasoningContent: true };
}
Comment on lines +102 to +104

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Exercise the registered DeepSeek reasoning configuration.

deepseekReasoningProvider() forces preserveResponsesReasoningContent: true. The new tests therefore pass even if the DeepSeek registry entry stops enabling this required provider behavior. In that failure case, buildRequest can remove replayed plaintext reasoning before the request reaches DeepSeek.

Return the unmodified deepseekProvider() result. Assert that preserveResponsesReasoningContent is true before using it.

Proposed fix
 function deepseekReasoningProvider(): OcxProviderConfig {
-  return { ...deepseekProvider(), preserveResponsesReasoningContent: true };
+  const provider = deepseekProvider();
+  expect(provider.preserveResponsesReasoningContent).toBe(true);
+  return provider;
 }

As per path instructions, “A behavior change in src/ should come with a focused regression test near the existing tests for that subsystem.” The PR objective requires the built-in DeepSeek preset to preserve reasoning content.

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
function deepseekReasoningProvider(): OcxProviderConfig {
return { ...deepseekProvider(), preserveResponsesReasoningContent: true };
}
function deepseekReasoningProvider(): OcxProviderConfig {
const provider = deepseekProvider();
expect(provider.preserveResponsesReasoningContent).toBe(true);
return provider;
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tests/deepseek-inbound-wire.test.ts` around lines 102 - 104, Update
deepseekReasoningProvider() to return the unmodified deepseekProvider() result
instead of overriding preserveResponsesReasoningContent. Add an assertion that
the returned configuration has preserveResponsesReasoningContent set to true
before the tests use it, so the tests validate the registered DeepSeek preset
behavior.

Source: Path instructions


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");
Expand Down Expand Up @@ -836,6 +840,109 @@ describe("stateless Responses upstreams get no stateful parameters", () => {
expect(body.input).toEqual(input);
});

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 a history with a missing call result unchanged (fail closed)", () => {
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 injected = {
type: "message",
role: "developer",
content: [{ type: "input_text", text: "[planning-with-files] ACTIVE PLAN" }],
};
const outputB = { type: "function_call_output", call_id: "call_b", output: "B" };
const tail = { type: "message", role: "user", content: [{ type: "input_text", text: "continue" }] };

const input = [callA, callB, injected, outputB, tail];
const body = buildBody(deepseekReasoningProvider(), { input }) as { input: unknown[] };
expect(body.input).toEqual(input);
});

test("DeepSeek leaves a backward call/result pair unchanged (fail closed)", () => {
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: "[planning-with-files] ACTIVE PLAN" }],
};

// outputA appears before its own callA, which is ambiguous.
const input = [outputA, injected, callA];
const body = buildBody(deepseekReasoningProvider(), { input }) as { input: unknown[] };
expect(body.input).toEqual(input);
});

test("DeepSeek leaves a duplicate call/result history unchanged (fail closed)", () => {
const callA1 = { type: "function_call", call_id: "call_a", name: "read_file", arguments: "{}" };
const callA2 = { type: "function_call", call_id: "call_a", name: "read_file", arguments: "{}" };
const outputA = { type: "function_call_output", call_id: "call_a", output: "A" };

const input = [callA1, callA2, outputA];
const body = buildBody(deepseekReasoningProvider(), { input }) as { input: unknown[] };
expect(body.input).toEqual(input);
});

test("a replay miss does not forward an orphaned tool result", () => {
// On a replay miss the delta can open with a function_call_output whose paired
// function_call sat in the prefix that was never expanded. A stateless upstream
Expand Down
Loading