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
5 changes: 5 additions & 0 deletions docs-site/src/content/docs/reference/adapters.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Comment on lines +61 to +64

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.

📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Document all fail-closed history cases.

The documentation must state that missing and out-of-order call/result histories retain their original order. The public adapter reference currently names only duplicate IDs. The design document names duplicate and backward pairs but omits missing pairs.

  • docs-site/src/content/docs/reference/adapters.md#L61-L64: State that duplicate, missing, and out-of-order call IDs remain unchanged.
  • structure/04_transports-and-sidecars.md#L359-L364: Add missing call/result pairs to the fail-closed list.

As per path instructions, the openai-responses reference must document duplicate, missing, and out-of-order IDs as unchanged.

📍 Affects 2 files
  • docs-site/src/content/docs/reference/adapters.md#L61-L64 (this comment)
  • structure/04_transports-and-sidecars.md#L359-L364
🤖 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 `@docs-site/src/content/docs/reference/adapters.md` around lines 61 - 64,
Update docs-site/src/content/docs/reference/adapters.md lines 61-64 to state
that the openai-responses adapter preserves original order for duplicate,
missing, and out-of-order call IDs. Update
structure/04_transports-and-sidecars.md lines 359-364 to include missing
call/result pairs in the fail-closed history cases alongside duplicate and
backward/out-of-order pairs.

Source: Path instructions


- `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,
Expand Down
62 changes: 45 additions & 17 deletions src/adapters/openai-responses.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -576,24 +576,52 @@ function normalizeResponsesToolResultAdjacency(body: unknown): unknown {
}
}

const movedOutputIndices = new Set<number>();
const outputAfterCall = new Map<number, unknown>();
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 });
}
Comment on lines 580 to 588

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.

🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

Reject a history when any tool call has no matching result.

Line 582 skips a call with no result. The function can then reorder a later pair in the same ambiguous history.

For example, [callA, callB, injected, outputB] becomes [callA, callB, outputB, injected], even though callA has no result. Return body when any collected call lacks exactly one later matching result. Add a regression test for this case and for a backward result.

Proposed fix
   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;

As per path instructions, normalization must leave “duplicate, missing, or out-of-order call IDs unchanged (fail closed).”

📝 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
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 });
}
for (const [key, callIndices] of calls) {
const outputIndices = outputs.get(key);
if (!outputIndices) return body;
if (callIndices.length !== 1 || outputIndices.length !== 1) return body;
const callIndex = callIndices[0]!;
const outputIndex = outputIndices[0]!;
if (outputIndex <= callIndex) return body;
pairs.push({ callIndex, outputIndex });
}
🤖 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 580 - 588, Update the
call/output pairing logic around the calls iteration to fail closed whenever any
collected tool call lacks exactly one matching result, rather than skipping
unmatched calls. Validate that every call has one later result and that no
result is backward; return the original body unchanged for duplicate, missing,
or out-of-order IDs, and add regression coverage for a missing earlier call
result and a backward result.

Source: Path instructions

if (movedOutputIndices.size === 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: 4 additions & 3 deletions src/providers/registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
/**
Expand Down Expand Up @@ -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.
Expand Down
6 changes: 3 additions & 3 deletions src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
/**
Expand Down
15 changes: 15 additions & 0 deletions structure/04_transports-and-sidecars.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
87 changes: 87 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 };
}

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 @@ -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",
Expand Down
Loading