diff --git a/src/adapters/openai-responses.ts b/src/adapters/openai-responses.ts index 8bb40769a..9e9b46acc 100644 --- a/src/adapters/openai-responses.ts +++ b/src/adapters/openai-responses.ts @@ -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(); + const outputs = new Map(); + + const appendIndex = (map: Map, 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(); + const outputAfterCall = new Map(); + 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 @@ -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 { diff --git a/src/config.ts b/src/config.ts index b662959bd..57dc80646 100644 --- a/src/config.ts +++ b/src/config.ts @@ -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(), diff --git a/src/providers/derive.ts b/src/providers/derive.ts index c947c0b4f..db7287d18 100644 --- a/src/providers/derive.ts +++ b/src/providers/derive.ts @@ -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] } : {}), @@ -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; diff --git a/src/providers/registry.ts b/src/providers/registry.ts index 5856b6edf..a2ac24141 100644 --- a/src/providers/registry.ts +++ b/src/providers/registry.ts @@ -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 @@ -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. diff --git a/src/router.ts b/src/router.ts index ec1d5bb91..7dda3eef1 100644 --- a/src/router.ts +++ b/src/router.ts @@ -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 } : {}), diff --git a/src/types.ts b/src/types.ts index 6e3054e19..510c073a2 100644 --- a/src/types.ts +++ b/src/types.ts @@ -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 diff --git a/tests/deepseek-inbound-wire.test.ts b/tests/deepseek-inbound-wire.test.ts index 426f9753c..e5a650a09 100644 --- a/tests/deepseek-inbound-wire.test.ts +++ b/tests/deepseek-inbound-wire.test.ts @@ -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"; @@ -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", () => {