Skip to content
Merged
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
1 change: 1 addition & 0 deletions scripts/test-layout/layout.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
18 changes: 15 additions & 3 deletions src/adapters/openai-responses/passthrough.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
1 change: 1 addition & 0 deletions src/config/schema/leaf-validators.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand Down
6 changes: 6 additions & 0 deletions src/providers/derive.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 }
: {}),
Expand Down Expand Up @@ -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;
}
Expand Down
11 changes: 11 additions & 0 deletions src/providers/registry/entries-core.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
5 changes: 5 additions & 0 deletions src/providers/registry/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
4 changes: 4 additions & 0 deletions src/router.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 }
Expand Down
1 change: 1 addition & 0 deletions src/server/auth-cors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -913,6 +913,7 @@ const PROVIDER_CONFIG_FIELD_POLICY = {
commandCodeVersion: "editor",
statelessResponses: "editor",
requiresAdjacentResponsesToolResults: "editor",
requiresPairedResponsesToolResults: "editor",
annotateEmptyToolOutputs: "editor",
supportsServiceTier: "editor",
modelSupportsServiceTier: "editor",
Expand Down
13 changes: 13 additions & 0 deletions src/types/provider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
21 changes: 20 additions & 1 deletion structure/providers/chat-compat.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
6 changes: 6 additions & 0 deletions structure/providers/xai-grok.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
1 change: 1 addition & 0 deletions tests/fixtures/test-layout-expected.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
Loading
Loading