diff --git a/scripts/test-layout/layout.json b/scripts/test-layout/layout.json index c56dfe6117..f2d26e28ff 100644 --- a/scripts/test-layout/layout.json +++ b/scripts/test-layout/layout.json @@ -1458,6 +1458,7 @@ "xai-client.test.ts": "images", "xai-oauth-retry.test.ts": "providers/xai", "xai-refresh-lock.test.ts": "providers/xai", + "xai-responses-adjacency.test.ts": "providers/xai", "xai-tool-schema.test.ts": "providers/xai", "xai-transport.test.ts": "providers/xai", "xai-video-client.test.ts": "videos", diff --git a/src/adapters/openai-responses/passthrough.ts b/src/adapters/openai-responses/passthrough.ts index 740130ab04..64dcbc61e4 100644 --- a/src/adapters/openai-responses/passthrough.ts +++ b/src/adapters/openai-responses/passthrough.ts @@ -270,19 +270,31 @@ export function createResponsesPassthroughAdapter(provider: OcxProviderConfig): // tier write so a force-fast/default decision can never mutate parsed._rawBody. outBody = applyTierDecisionToResponsesBody(outBody, parsed.options?.tierDecision); const stateless = provider.statelessResponses === true; + const adjacentToolResults = provider.requiresAdjacentResponsesToolResults === true; + // Adjacency reorders items the upstream would accept in some order. Pairing synthesizes an + // item the client never sent, which is a larger claim about the conversation, so it is its + // own capability: Kimi carries the adjacency flag but accepts a dangling call (#4726) and + // must not start receiving placeholders it never needed. + const pairedToolResults = provider.requiresPairedResponsesToolResults === true; if (stateless) outBody = stripStatefulResponsesParams(outBody); // A replay miss can leave a function_call_output whose paired function_call sat // in the prefix that was never expanded. A stateless upstream cannot resolve the // pair from its own storage either, so it needs the same repair the forward // backend gets — dropping previous_response_id is not much use if the body that // reaches the wire is unparseable. + // A parser can also 400 on a function_call with no matching output at all. DeepSeek gets + // that repair through statelessResponses. xAI cannot be marked stateless: its Responses API + // stores conversations for 30 days and documents previous_response_id. So it carries the + // pairing capability instead, which reuses the orphan-call placeholder without touching + // store or previous_response_id. if (provider.annotateEmptyToolOutputs === true) { outBody = annotateEmptyResponsesToolOutputs(outBody, true); } - if (forward || stateless) { - outBody = repairOrphanedInputItems(outBody, unexpandedMiss, stateless && !forward); + const synthesizeMissingCallOutputs = !forward && (stateless || pairedToolResults); + if (forward || stateless || pairedToolResults) { + outBody = repairOrphanedInputItems(outBody, unexpandedMiss, synthesizeMissingCallOutputs); } - if (provider.requiresAdjacentResponsesToolResults === true) { + if (adjacentToolResults) { outBody = normalizeResponsesToolResultAdjacency(outBody); } if (forward) { diff --git a/src/config/schema/leaf-validators.ts b/src/config/schema/leaf-validators.ts index f9deab442b..b0ace501c5 100644 --- a/src/config/schema/leaf-validators.ts +++ b/src/config/schema/leaf-validators.ts @@ -243,6 +243,7 @@ export const providerConfigSchema = z.object({ chatCompletionsPath: z.string().min(1).optional(), statelessResponses: z.boolean().optional(), requiresAdjacentResponsesToolResults: z.boolean().optional(), + requiresPairedResponsesToolResults: z.boolean().optional(), annotateEmptyToolOutputs: z.boolean().optional(), fastWire: fastWireSchema.nullable().optional(), supportsServiceTier: z.boolean().optional(), diff --git a/src/providers/derive.ts b/src/providers/derive.ts index 02b3ba20dd..fb1050522f 100644 --- a/src/providers/derive.ts +++ b/src/providers/derive.ts @@ -266,6 +266,9 @@ export function providerConfigSeed(entry: ProviderRegistryEntry): OcxProviderCon ...(entry.requiresAdjacentResponsesToolResults !== undefined ? { requiresAdjacentResponsesToolResults: entry.requiresAdjacentResponsesToolResults } : {}), + ...(entry.requiresPairedResponsesToolResults !== undefined + ? { requiresPairedResponsesToolResults: entry.requiresPairedResponsesToolResults } + : {}), ...(entry.annotateEmptyToolOutputs !== undefined ? { annotateEmptyToolOutputs: entry.annotateEmptyToolOutputs } : {}), @@ -541,6 +544,9 @@ export function enrichProviderFromRegistry(name: string, prov: OcxProviderConfig if (prov.requiresAdjacentResponsesToolResults === undefined && seed.requiresAdjacentResponsesToolResults !== undefined) { prov.requiresAdjacentResponsesToolResults = seed.requiresAdjacentResponsesToolResults; } + if (prov.requiresPairedResponsesToolResults === undefined && seed.requiresPairedResponsesToolResults !== undefined) { + prov.requiresPairedResponsesToolResults = seed.requiresPairedResponsesToolResults; + } if (prov.annotateEmptyToolOutputs === undefined && seed.annotateEmptyToolOutputs !== undefined) { prov.annotateEmptyToolOutputs = seed.annotateEmptyToolOutputs; } diff --git a/src/providers/registry/entries-core.ts b/src/providers/registry/entries-core.ts index 54196adda1..1c36394256 100644 --- a/src/providers/registry/entries-core.ts +++ b/src/providers/registry/entries-core.ts @@ -282,6 +282,17 @@ export const PROVIDER_REGISTRY_CORE: readonly ProviderRegistryEntry[] = [ forwardCallerServiceTier: false, }, }, + // Grok 4.6/4.5 OAuth Responses replays Codex tool history. After a mid-stream 502/reset, + // the client can resend a function_call without a matching output, or with hook-injected + // developer context between the pair. Google already synthesizes a missing tool_result + // (#2199). xAI's Responses parser does not, so the next turns 400 and the thread snowballs. + // Reuse the existing adjacency capability (Kimi #4726, DeepSeek #1292). Do not set + // statelessResponses: xAI stores responses for 30 days and documents previous_response_id. + // https://docs.x.ai/developers/model-capabilities/text/comparison + requiresAdjacentResponsesToolResults: true, + // The dangling half of the same failure: a call whose output never arrived. Kimi accepts that + // shape, so this is a second capability rather than a widening of the one above. + requiresPairedResponsesToolResults: true, // Vision lineup per docs.x.ai model-capabilities/images/understanding: the grok-4.x chat // models accept image input (JPEG/PNG, URL or base64). Without this the catalog leaves // inputModalities undefined, and deriveComboCatalogModel defaults an undefined member to diff --git a/src/providers/registry/types.ts b/src/providers/registry/types.ts index f71c0fafe4..a940be2390 100644 --- a/src/providers/registry/types.ts +++ b/src/providers/registry/types.ts @@ -217,6 +217,11 @@ export interface ProviderRegistryEntry { * to stay contiguous. This is seeded/backfilled like other fixed wire capabilities. */ requiresAdjacentResponsesToolResults?: boolean; + /** + * Responses upstream that also rejects a tool call with no matching output anywhere in the + * replayed input. Seeded/backfilled like other fixed wire capabilities. + */ + requiresPairedResponsesToolResults?: boolean; /** * When enabled, tool results that are present but empty are annotated on the wire. * Seeded/backfilled like other fixed wire capabilities. diff --git a/src/router.ts b/src/router.ts index 70e427b74b..3e28a91186 100644 --- a/src/router.ts +++ b/src/router.ts @@ -384,6 +384,10 @@ export function routedProviderConfig(providerName: string, provider: OcxProvider && registryEntry.requiresAdjacentResponsesToolResults !== undefined ? { requiresAdjacentResponsesToolResults: registryEntry.requiresAdjacentResponsesToolResults } : {}), + ...(provider.requiresPairedResponsesToolResults === undefined + && registryEntry.requiresPairedResponsesToolResults !== undefined + ? { requiresPairedResponsesToolResults: registryEntry.requiresPairedResponsesToolResults } + : {}), ...(provider.annotateEmptyToolOutputs === undefined && registryEntry.annotateEmptyToolOutputs !== undefined ? { annotateEmptyToolOutputs: registryEntry.annotateEmptyToolOutputs } diff --git a/src/server/auth-cors.ts b/src/server/auth-cors.ts index be6bfd3fca..32fc9fe8ce 100644 --- a/src/server/auth-cors.ts +++ b/src/server/auth-cors.ts @@ -913,6 +913,7 @@ const PROVIDER_CONFIG_FIELD_POLICY = { commandCodeVersion: "editor", statelessResponses: "editor", requiresAdjacentResponsesToolResults: "editor", + requiresPairedResponsesToolResults: "editor", annotateEmptyToolOutputs: "editor", supportsServiceTier: "editor", modelSupportsServiceTier: "editor", diff --git a/src/types/provider.ts b/src/types/provider.ts index 49c9132b0a..ae69ecc39d 100644 --- a/src/types/provider.ts +++ b/src/types/provider.ts @@ -311,6 +311,19 @@ export interface OcxProviderConfig { * preserved after it, and parallel calls stay together with the reasoning turn that produced them. */ requiresAdjacentResponsesToolResults?: boolean; + /** + * Responses upstream whose parser also rejects a tool call that has no matching output + * anywhere in the replayed input, not merely one whose result sits out of order. A call left + * dangling by an interrupted stream is answered with an explicit unknown-status placeholder + * so the thread can continue. + * + * Separate from `requiresAdjacentResponsesToolResults` on purpose: adjacency reorders items a + * strict parser already accepts in some order, while this synthesizes an item the client never + * sent. Kimi accepts a dangling call (#4726), so it must not inherit the synthesis. + * `statelessResponses` implies this, because an upstream that stores nothing cannot resolve + * the missing half from its own history either. + */ + requiresPairedResponsesToolResults?: boolean; /** * When enabled, a tool result that is present but empty (no usable text or content * part) is rewritten to an explicit annotation before it reaches the upstream wire, diff --git a/structure/providers/chat-compat.md b/structure/providers/chat-compat.md index 381276d04b..f143be0f6e 100644 --- a/structure/providers/chat-compat.md +++ b/structure/providers/chat-compat.md @@ -145,10 +145,29 @@ That pass is gated by `requiresAdjacentResponsesToolResults`, not by provider na Responses endpoint enforces the same strict shape and rejects a hook-split pair with HTTP 400 (#4726), so `kimi` and `kimi-code` carry the flag as well. The flag is inert while those presets use the Chat wire and takes effect when a row is configured onto `openai-responses`, which is the configuration the -report exercised. No upstream specification documents the requirement; the evidence is the observed +report exercised. xAI Grok 4.6/4.5 subscription Responses carries the same flag: after a mid-stream +interrupt, Codex can replay a `function_call` with hook-injected developer context between it and +its output, and later turns 400. The adjacency pass itself still does not invent duplicate or +backwards pairs. No upstream specification documents the adjacency requirement; the evidence is the observed 400 and DeepSeek's identical failure shape, which is why this stays a per-provider capability rather than a wire-wide default — upstream Codex leaves an intervening developer message where it is. +A mid-stream interrupt produces a second, different shape: a call whose output never arrived at all. +That is `requiresPairedResponsesToolResults`, a separate capability, and the separation is the whole +point. Adjacency reorders items the upstream would accept in some order; pairing synthesizes an item +the client never sent, which puts a tool turn into the conversation that did not happen. The evidence +differs too — #4726 shows Kimi accepting a call with no result at all, so `kimi` and `kimi-code` keep +adjacency and do not receive placeholders. `xai` carries both. `statelessResponses` implies pairing, +which is how DeepSeek already had it: an upstream that stores nothing cannot resolve the missing half +from its own history either. + +xAI's public Responses API is stateful (`store` defaults true; `previous_response_id` continues a +stored conversation), so the provider is not marked `statelessResponses`. The pairing repair +synthesizes an honest unknown-status placeholder without touching `store` or +`previous_response_id`: repairing an interrupted history must not cost the thread its server-side +state. Forward auth suppresses the synthesis regardless of the flag, because the backend that holds +the conversation can resolve the pair itself. + > Decision record: [ADR-0052](../decisions/ADR-0052-reasoning-and-tool-result-compatibility.md) ## OpenRouter provider routing diff --git a/structure/providers/xai-grok.md b/structure/providers/xai-grok.md index e2b5ee6f0f..8fc9514b1a 100644 --- a/structure/providers/xai-grok.md +++ b/structure/providers/xai-grok.md @@ -3,6 +3,12 @@ xAI uses the same shared credential and delivery policies through the Responses [core module ownership](../transports/responses.md#core-module-ownership). This surface retains its existing behavior. +One Responses capability is seeded for xAI alone: `requiresPairedResponsesToolResults`, which +answers a replayed tool call whose output never arrived. It is deliberately not the same flag as +`requiresAdjacentResponsesToolResults`, which xAI also carries and shares with the Kimi presets. +The contract for both, and the reason they do not collapse into one, is specified in +[chat-compat](./chat-compat.md); it is not restated here. + The configuration-only [plaintext V2 contract](../subagents.md#plaintext-v2-agent-messages) is scoped to canonical ChatGPT Responses forwarding; other source-area behavior described here is unchanged. diff --git a/tests/fixtures/test-layout-expected.json b/tests/fixtures/test-layout-expected.json index 36a7c442d2..21ee14a2fe 100644 --- a/tests/fixtures/test-layout-expected.json +++ b/tests/fixtures/test-layout-expected.json @@ -1286,6 +1286,7 @@ "xai-client.test.ts": "images", "xai-oauth-retry.test.ts": "providers/xai", "xai-refresh-lock.test.ts": "providers/xai", + "xai-responses-adjacency.test.ts": "providers/xai", "xai-tool-schema.test.ts": "providers/xai", "xai-transport.test.ts": "providers/xai", "xai-video-client.test.ts": "videos", diff --git a/tests/providers/xai/xai-responses-adjacency.test.ts b/tests/providers/xai/xai-responses-adjacency.test.ts new file mode 100644 index 0000000000..1ccc885aa3 --- /dev/null +++ b/tests/providers/xai/xai-responses-adjacency.test.ts @@ -0,0 +1,176 @@ +import { describe, expect, test } from "bun:test"; +import { createResponsesPassthroughAdapter as createResponsesPassthroughAdapterProduction } from "../../../src/adapters/openai-responses"; +import { enrichProviderFromRegistry, providerConfigSeed } from "../../../src/providers/derive"; +import { getProviderRegistryEntry } from "../../../src/providers/registry"; +import { routedProviderConfig } from "../../../src/router"; +import type { OcxProviderConfig } from "../../../src/types"; +import { withTestTranslatorBudget } from "../../helpers/translator-budget"; + +const MODEL = "grok-4.6"; + +const createResponsesPassthroughAdapter = ( + ...args: Parameters +) => withTestTranslatorBudget(createResponsesPassthroughAdapterProduction(...args)); + +function xaiOauthResponses(overrides: Partial = {}): OcxProviderConfig { + return { + adapter: "openai-responses", + baseUrl: "https://api.x.ai/v1", + authMode: "oauth", + ...overrides, + }; +} + +function buildBody(provider: OcxProviderConfig, rawBody: Record): Record { + const built = createResponsesPassthroughAdapter(provider).buildRequest({ + modelId: MODEL, + context: { messages: [] }, + stream: true, + options: {}, + _rawBody: { model: MODEL, ...rawBody }, + } as Parameters["buildRequest"]>[0], { + headers: new Headers(), + }); + return JSON.parse(String(built.body)) as Record; +} + +describe("xAI Responses tool-result adjacency", () => { + test("the xAI registry entry seeds adjacency and pairing without marking the provider stateless", () => { + const entry = getProviderRegistryEntry("xai")!; + expect(entry.requiresAdjacentResponsesToolResults).toBe(true); + expect(entry.requiresPairedResponsesToolResults).toBe(true); + expect(entry.statelessResponses).toBeUndefined(); + const seed = providerConfigSeed(entry); + expect(seed.requiresAdjacentResponsesToolResults).toBe(true); + expect(seed.requiresPairedResponsesToolResults).toBe(true); + expect(seed.statelessResponses).toBeUndefined(); + }); + + test("a stale persisted xAI row is backfilled and repairs a dangling function_call on replay", () => { + const stale: OcxProviderConfig = xaiOauthResponses(); + const routedStale = routedProviderConfig("xai", { ...stale }); + const call = { type: "function_call", call_id: "call_interrupted", name: "exec_command", arguments: "{}" }; + const nextTurn = { + type: "message", + role: "user", + content: [{ type: "input_text", text: "continue" }], + }; + + expect(stale.requiresAdjacentResponsesToolResults).toBeUndefined(); + expect(routedStale.requiresAdjacentResponsesToolResults).toBe(true); + expect(routedStale.requiresPairedResponsesToolResults).toBe(true); + enrichProviderFromRegistry("xai", stale); + expect(stale.requiresAdjacentResponsesToolResults).toBe(true); + expect(stale.requiresPairedResponsesToolResults).toBe(true); + expect(stale.statelessResponses).toBeUndefined(); + + const body = buildBody(stale, { + previous_response_id: "resp_xai_store", + store: true, + input: [call, nextTurn], + }); + expect(body.previous_response_id).toBe("resp_xai_store"); + expect(body.store).toBe(true); + const input = body.input as Array>; + expect(input[0]).toMatchObject({ type: "function_call", call_id: "call_interrupted" }); + expect(input[1]).toMatchObject({ type: "function_call_output", call_id: "call_interrupted" }); + expect(String(input[1].output)).toContain("no tool result was recorded"); + expect(input[2]).toMatchObject({ type: "message", role: "user" }); + }); + + test("moves a result next to its call while preserving an intervening developer message", () => { + const provider = xaiOauthResponses({ requiresAdjacentResponsesToolResults: true }); + const call = { type: "function_call", call_id: "call_exec", name: "exec_command", arguments: "{}" }; + const injected = { + type: "message", + role: "developer", + content: [{ type: "input_text", text: "[hook] LSP diagnostics: none" }], + }; + const output = { type: "function_call_output", call_id: "call_exec", output: "ok" }; + const body = buildBody(provider, { input: [call, injected, output] }); + expect(body.input).toEqual([call, output, injected]); + }); + + test("keeps call_id pairing for two outstanding replayed calls and synthesizes only the missing output", () => { + const provider = xaiOauthResponses({ + requiresAdjacentResponsesToolResults: true, + requiresPairedResponsesToolResults: true, + }); + const callA = { type: "function_call", call_id: "call_a", name: "exec_command", arguments: "{}" }; + const callB = { type: "function_call", call_id: "call_b", name: "exec_command", arguments: "{}" }; + const injected = { + type: "message", + role: "developer", + content: [{ type: "input_text", text: "[hook] replay diagnostics" }], + }; + const outputB = { type: "function_call_output", call_id: "call_b", output: "B" }; + const body = buildBody(provider, { input: [callA, callB, injected, outputB] }); + const input = body.input as Array>; + expect(input[0]).toMatchObject({ type: "function_call", call_id: "call_a" }); + expect(input[1]).toMatchObject({ type: "function_call", call_id: "call_b" }); + expect(input[2]).toMatchObject({ type: "function_call_output", call_id: "call_a" }); + expect(String(input[2].output)).toContain("no tool result was recorded"); + expect(input[3]).toMatchObject({ type: "function_call_output", call_id: "call_b", output: "B" }); + expect(input[4]).toMatchObject({ type: "message", role: "developer" }); + }); + + test("forward-auth xAI replay still does not synthesize a dangling call", () => { + const provider = xaiOauthResponses({ + authMode: "forward", + requiresAdjacentResponsesToolResults: true, + requiresPairedResponsesToolResults: true, + headers: { authorization: "Bearer xai-oauth" }, + }); + const call = { type: "function_call", call_id: "call_fwd", name: "exec_command", arguments: "{}" }; + const body = buildBody(provider, { input: [call] }); + const input = body.input as Array>; + expect(input).toHaveLength(1); + expect(input[0]).toMatchObject({ type: "function_call", call_id: "call_fwd" }); + expect(JSON.stringify(body)).not.toContain("no tool result was recorded"); + }); + + test("adjacency alone never synthesizes an output the client did not send", () => { + // Kimi and kimi-code carry the adjacency flag because their parser rejects a hook-split pair + // (#4726), but that same report shows a call left without any result is accepted. Inventing a + // placeholder there would put a tool turn into the conversation that never happened, so the + // two capabilities stay separate rather than one widening into the other. + for (const providerName of ["kimi", "kimi-code"]) { + const entry = getProviderRegistryEntry(providerName)!; + expect(entry.requiresAdjacentResponsesToolResults).toBe(true); + expect(entry.requiresPairedResponsesToolResults).toBeUndefined(); + expect(entry.statelessResponses).toBeUndefined(); + } + + const provider = xaiOauthResponses({ requiresAdjacentResponsesToolResults: true }); + const call = { type: "function_call", call_id: "call_dangling", name: "exec_command", arguments: "{}" }; + const next = { type: "message", role: "user", content: [{ type: "input_text", text: "continue" }] }; + const body = buildBody(provider, { input: [call, next] }); + + expect(body.input).toEqual([call, next]); + expect(JSON.stringify(body)).not.toContain("no tool result was recorded"); + }); + + test("a dangling custom_tool_call reaches xAI as a paired, lowered function call", () => { + // The pairing repair runs before rewriteRoutedCustomToolsForUpstream, so a custom call + // interrupted mid-stream is answered first and the pair is lowered together. xAI rejects the + // native custom shape (supportsResponsesCustomTools: false on the registry entry), which is + // what makes the lowering run at all, so the production shape is what this pins. + const provider = xaiOauthResponses({ + requiresAdjacentResponsesToolResults: true, + requiresPairedResponsesToolResults: true, + supportsResponsesCustomTools: false, + }); + const call = { type: "custom_tool_call", call_id: "call_custom", name: "apply_patch", input: "patch" }; + const next = { type: "message", role: "user", content: [{ type: "input_text", text: "continue" }] }; + const body = buildBody(provider, { + tools: [{ type: "custom", name: "apply_patch", description: "Apply a patch." }], + input: [call, next], + }); + const input = body.input as Array>; + + expect(input[0]).toMatchObject({ type: "function_call", call_id: "call_custom", name: "apply_patch" }); + expect(input[1]).toMatchObject({ type: "function_call_output", call_id: "call_custom" }); + expect(String(input[1].output)).toContain("no tool result was recorded"); + expect(input[2]).toMatchObject({ type: "message", role: "user" }); + }); +});