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
62 changes: 62 additions & 0 deletions src/adapters/openai-responses.ts
Original file line number Diff line number Diff line change
Expand Up @@ -538,6 +538,65 @@ function repairOrphanedInputItems(body: unknown, dropReasoning: boolean): unknow
return changed ? { ...body, input: repaired } : body;
}

/**
* Make unambiguous Responses tool pairs adjacent 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.
*/
function normalizeResponsesToolResultAdjacency(body: unknown): unknown {
if (!isPlainObject(body) || !Array.isArray(body.input)) return body;
const input = body.input;
const calls = new Map<string, number[]>();
const outputs = new Map<string, number[]>();

const appendIndex = (map: Map<string, number[]>, key: string, index: number): void => {
const existing = map.get(key);
if (existing) existing.push(index);
else map.set(key, [index]);
};

for (let index = 0; index < input.length; index += 1) {
const item = input[index];
if (!isPlainObject(item) || typeof item.call_id !== "string" || item.call_id.length === 0) continue;
if (item.type === "function_call" || item.type === "local_shell_call") {
appendIndex(calls, `function:${item.call_id}`, index);
} else if (item.type === "custom_tool_call") {
appendIndex(calls, `custom:${item.call_id}`, index);
} else if (item.type === "function_call_output") {
appendIndex(outputs, `function:${item.call_id}`, index);
} else if (item.type === "custom_tool_call_output") {
appendIndex(outputs, `custom:${item.call_id}`, index);
}
}

const movedOutputIndices = new Set<number>();
const outputAfterCall = new Map<number, unknown>();
for (const [key, callIndices] of calls) {
const outputIndices = outputs.get(key);
if (callIndices.length !== 1 || outputIndices?.length !== 1) continue;
const callIndex = callIndices[0]!;
const outputIndex = outputIndices[0]!;
if (outputIndex === callIndex + 1) continue;
movedOutputIndices.add(outputIndex);
outputAfterCall.set(callIndex, input[outputIndex]);
}
if (movedOutputIndices.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));
}
return { ...body, input: normalized };
}

/**
* Remove `previous_response_id` before forwarding. Two triggers:
* - the proxy expanded the request into a full input replay (the id is now redundant), or
Expand Down Expand Up @@ -1158,6 +1217,9 @@ export function createResponsesPassthroughAdapter(provider: OcxProviderConfig):
if (forward || stateless) {
outBody = repairOrphanedInputItems(outBody, unexpandedMiss);
}
if (provider.requiresAdjacentResponsesToolResults === true) {
outBody = normalizeResponsesToolResultAdjacency(outBody);
}
if (forward) {
outBody = stripUnsupportedForwardParams(outBody);
} else {
Expand Down
1 change: 1 addition & 0 deletions src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -613,6 +613,7 @@ const providerConfigSchema = z.object({
apiKeyTransport: z.enum(["x-api-key", "bearer"]).optional(),
responsesPath: z.string().min(1).optional(),
statelessResponses: z.boolean().optional(),
requiresAdjacentResponsesToolResults: z.boolean().optional(),
supportsServiceTier: z.boolean().optional(),
preserveResponsesReasoningContent: z.boolean().optional(),
allowPrivateNetwork: z.boolean().optional(),
Expand Down
6 changes: 6 additions & 0 deletions src/providers/derive.ts
Original file line number Diff line number Diff line change
Expand Up @@ -152,6 +152,9 @@ export function providerConfigSeed(entry: ProviderRegistryEntry): OcxProviderCon
...(entry.promptCacheKey !== undefined ? { promptCacheKey: entry.promptCacheKey } : {}),
...(entry.responsesPath !== undefined ? { responsesPath: entry.responsesPath } : {}),
...(entry.statelessResponses !== undefined ? { statelessResponses: entry.statelessResponses } : {}),
...(entry.requiresAdjacentResponsesToolResults !== undefined
? { requiresAdjacentResponsesToolResults: entry.requiresAdjacentResponsesToolResults }
: {}),
...(entry.autoToolChoiceOnlyModels ? { autoToolChoiceOnlyModels: [...entry.autoToolChoiceOnlyModels] } : {}),
...(entry.preserveReasoningContentModels ? { preserveReasoningContentModels: [...entry.preserveReasoningContentModels] } : {}),
...(entry.reasoningSplitModels ? { reasoningSplitModels: [...entry.reasoningSplitModels] } : {}),
Expand Down Expand Up @@ -323,6 +326,9 @@ export function enrichProviderFromRegistry(name: string, prov: OcxProviderConfig
// learned this route still gets backfilled.
if (prov.responsesPath === undefined && seed.responsesPath !== undefined) prov.responsesPath = seed.responsesPath;
if (prov.statelessResponses === undefined && seed.statelessResponses !== undefined) prov.statelessResponses = seed.statelessResponses;
if (prov.requiresAdjacentResponsesToolResults === undefined && seed.requiresAdjacentResponsesToolResults !== undefined) {
prov.requiresAdjacentResponsesToolResults = seed.requiresAdjacentResponsesToolResults;
}
// Registry-only metadata (never seeded into saved config): backfill straight from
// the entry so an explicit user value stays distinguishable from the default.
if (prov.supportsServiceTier === undefined && entry.supportsServiceTier !== undefined) prov.supportsServiceTier = entry.supportsServiceTier;
Expand Down
8 changes: 8 additions & 0 deletions src/providers/registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -186,6 +186,11 @@ export interface ProviderRegistryEntry {
* replay miss are repaired rather than forwarded.
*/
statelessResponses?: boolean;
/**
* Responses parser requires a matched tool result directly after its call. This is
* seeded/backfilled like other fixed upstream wire-contract capabilities.
*/
requiresAdjacentResponsesToolResults?: boolean;
/**
* Registry default for the provider's Responses `service_tier` support; see
* `OcxProviderConfig.supportsServiceTier`. Registry-only: backfilled (never
Expand Down Expand Up @@ -1359,6 +1364,9 @@ export const PROVIDER_REGISTRY: readonly ProviderRegistryEntry[] = [
// "The API is stateless: responses and conversations are not stored on the
// 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).
requiresAdjacentResponsesToolResults: true,
/* [Decision Log]
- 목적: DeepSeek V4 thinking mode multi-turn/tool-call requests must replay prior assistant reasoning_content.
- 대안 분석: Globally preserve reasoning_content for all OpenAI-compatible models; preserve it for legacy deepseek-reasoner too; mark only V4 thinking models in registry metadata.
Expand Down
4 changes: 4 additions & 0 deletions src/router.ts
Original file line number Diff line number Diff line change
Expand Up @@ -307,6 +307,10 @@ function routedProviderConfig(providerName: string, provider: OcxProviderConfig)
...(provider.responsesPath === undefined && registryEntry.responsesPath !== undefined
? { responsesPath: registryEntry.responsesPath }
: {}),
...(provider.requiresAdjacentResponsesToolResults === undefined
&& registryEntry.requiresAdjacentResponsesToolResults !== undefined
? { requiresAdjacentResponsesToolResults: registryEntry.requiresAdjacentResponsesToolResults }
: {}),
...(provider.supportsServiceTier === undefined && registryEntry.supportsServiceTier !== undefined
? { supportsServiceTier: registryEntry.supportsServiceTier }
: {}),
Expand Down
6 changes: 6 additions & 0 deletions src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1158,6 +1158,12 @@ export interface OcxProviderConfig {
* forwarded to an upstream that cannot resolve their pair.
*/
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.
*/
requiresAdjacentResponsesToolResults?: boolean;
/**
* Whether this provider's Responses route honours the OpenAI `service_tier`
* parameter. Tri-state: `true` lets fast mode inject/remove the field (an unset
Expand Down
40 changes: 39 additions & 1 deletion tests/deepseek-inbound-wire.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@
* assert the captured upstream URL, which is externally observable.
*/
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
import { providerConfigSeed } from "../src/providers/derive";
import { enrichProviderFromRegistry, providerConfigSeed } from "../src/providers/derive";
import { getProviderRegistryEntry, PROVIDER_REGISTRY } from "../src/providers/registry";
import { createResponsesPassthroughAdapter as createResponsesPassthroughAdapterProduction } from "../src/adapters/openai-responses";
import { resolveWireProtocolOverride } from "../src/server/adapter-resolve";
Expand Down Expand Up @@ -439,7 +439,45 @@ describe("stateless Responses upstreams get no stateful parameters", () => {

test("the seed and backfill carry the capability, and only for declaring entries", () => {
expect(providerConfigSeed(getProviderRegistryEntry("deepseek")!).statelessResponses).toBe(true);
expect(providerConfigSeed(getProviderRegistryEntry("deepseek")!).requiresAdjacentResponsesToolResults).toBe(true);
expect(providerConfigSeed(getProviderRegistryEntry("cerebras")!).statelessResponses).toBeUndefined();
expect(providerConfigSeed(getProviderRegistryEntry("cerebras")!).requiresAdjacentResponsesToolResults).toBeUndefined();

const stale = deepseekProvider();
delete stale.requiresAdjacentResponsesToolResults;
enrichProviderFromRegistry("deepseek", stale);
expect(stale.requiresAdjacentResponsesToolResults).toBe(true);
});

test("DeepSeek makes a matched tool result adjacent without dropping injected developer context", () => {
const call = { type: "function_call", call_id: "call_plan", name: "shell_command", arguments: "{}" };
const injected = {
type: "message",
role: "developer",
content: [{ type: "input_text", text: "[planning-with-files] ACTIVE PLAN" }],
};
const output = { type: "function_call_output", call_id: "call_plan", output: "Exit code: 0" };
const tail = { type: "message", role: "user", content: [{ type: "input_text", text: "continue" }] };

const body = buildBody(deepseekProvider(), { input: [call, injected, output, tail] }) as { input: unknown[] };
expect(body.input).toEqual([call, output, injected, tail]);
});

test("tolerant Responses providers keep interleaved tool history unchanged", () => {
const provider: OcxProviderConfig = {
adapter: "openai-responses",
baseUrl: "https://api.openai.example/v1",
authMode: "key",
apiKey: "sk-test",
};
const input = [
{ type: "function_call", call_id: "call_plan", name: "shell_command", arguments: "{}" },
{ type: "message", role: "developer", content: [{ type: "input_text", text: "plan" }] },
{ type: "function_call_output", call_id: "call_plan", output: "done" },
];

const body = buildBody(provider, { input }) as { input: unknown[] };
expect(body.input).toEqual(input);
});

test("a replay miss does not forward an orphaned tool result", () => {
Expand Down
Loading