From cdbcfaab88ca7c415a31df5673c07b5149fce846 Mon Sep 17 00:00:00 2001 From: Omar <13569609+omar391@users.noreply.github.com> Date: Sat, 24 Oct 2026 01:52:52 +0600 Subject: [PATCH 01/14] fix(responses): promote additional_tools to top-level tools for xAI and strict gateways - In OpenAI Responses wire protocol, tool declarations belong in top-level tools, while items in input are strictly restricted to the ResponseItem enum (message, reasoning, function_call, function_call_output, etc.). - Codex Desktop packages client-loaded tool definitions inside an item in the input array: { type: "additional_tools", tools: [...] }. - Non-forward OpenAI Responses destinations with strict schema deserializers (such as xAI api.x.ai and cli-chat-proxy.grok.com) reject this item type with HTTP 422 Unprocessable Entity (unknown item type "additional_tools"). - Promote additional_tools declarations to top-level tools and strip the wrapper from input for official xAI endpoints (api.x.ai and cli-chat-proxy.grok.com), and for custom gateways that configure promoteAdditionalTools: true. - Respect replayPrefixLength: historical turns before the current turn have the wrapper stripped without promoting stale tools. - Add comprehensive test coverage in tests/providers/xai/xai-additional-tools.test.ts. - Update docs-site provider configuration reference. --- .../docs/reference/configuration/providers.md | 1 + src/adapters/openai-responses/passthrough.ts | 11 +- src/adapters/opencode-go-additional-tools.ts | 39 ++++- .../xai/xai-additional-tools.test.ts | 150 ++++++++++++++++++ 4 files changed, 194 insertions(+), 7 deletions(-) create mode 100644 tests/providers/xai/xai-additional-tools.test.ts diff --git a/docs-site/src/content/docs/reference/configuration/providers.md b/docs-site/src/content/docs/reference/configuration/providers.md index 64b316c1e0c..4d72679d9c9 100644 --- a/docs-site/src/content/docs/reference/configuration/providers.md +++ b/docs-site/src/content/docs/reference/configuration/providers.md @@ -370,6 +370,7 @@ Providers can expose a built-in shorthand, such as `agy` for `google-antigravity | `xaiResponsesXSearch?` | `boolean` | Disabled by default. On an xAI Responses destination, append the provider-hosted `x_search` declaration only when a live `web_search` tool survives final request normalization. Existing declarations are not duplicated, caller `tool_choice`/`allowed_tools` selectors are never widened, and this is separate from the web-search sidecar's `search.xSearch` options. | | `modelPreferHostedTools?` | `Record` | Exact-model opt-in for non-forward Responses gateways that reserve a hosted-tool namespace. Currently accepts only `["image_generation"]`; a matching model must use the `openai-responses` wire and support that hosted tool. It removes colliding client `image_gen` declarations and rewrites their selectors to preserve caller tool choice. For OpenAI API virtual `-pro` models, the selected public ID is matched first and the resolved base wire-model ID is a fallback. `modelAdapters` resolves the public ID first, then the base ID; the second resolution determines the final wire. Other models retain normal alias behavior. | | `annotateEmptyToolOutputs?` | `boolean` | Replace a present-but-empty tool result with a short marker before it reaches the model, so a blank result is not read as a missing one. Applies to blank strings and text-only part arrays; image, file, and encrypted parts are never touched. Defaults to `true` for DeepSeek from the built-in registry and is otherwise unset. Set `false` to opt a provider out — an explicit `false` is preserved across later edits that omit the field. `PATCH /api/providers?name=` accepts `true`, `false`, or `null` to clear the override and return to registry-default behavior. | +| `promoteAdditionalTools?` | `boolean` | Whether to promote Codex Desktop's `additional_tools` input items into top-level `tools` and strip the wrapper from `input`. Enabled automatically for OpenCode Go and xAI (`api.x.ai` and `cli-chat-proxy.grok.com`); other OpenAI-strict third-party gateways that return HTTP 422 for unknown item types in `input` can opt in here. `PATCH /api/providers?name=` accepts a boolean or `null` to clear it. | | `unsupportedHostedTools?` | `string[]` | Hosted tool declarations this Responses destination rejects, so they are stripped from `tools`, from client-loaded `additional_tools`, and from `tool_choice` instead of being forwarded and rejected upstream. Use it for an OpenAI-compatible gateway with a narrower capability set — one that accepts plain Responses requests and `function` tools but returns HTTP 400 for hosted `web_search` — so a text-only prompt is not failed by a capability it never needed. Accepts only hosted tool type names (`web_search`, `web_search_preview`, `file_search`, `computer_use_preview`, `computer_use`, `code_interpreter`, `image_generation`, `image_gen`, `mcp`, `tool_search`, `local_shell`, `x_search`); an unrecognized name is rejected rather than silently ignored. Spelling variants of one capability are aliased, so `["web_search"]` also denies `web_search_preview`. A provider cannot both deny a hosted tool here and prefer it in `modelPreferHostedTools`. Xiaomi MiMo Responses destinations (`xiaomimimo.com` and its subdomains, including `api.xiaomimimo.com` and `token-plan-cn.xiaomimimo.com`) automatically strip `web_search` and `web_search_preview` while preserving function tools; no declaration is needed for these hosts. This is independent of `supportsResponsesCustomTools`; set both for a gateway that also rejects native custom tools. `PATCH /api/providers?name=` accepts an array or `null` to clear it. | | `reasoningEffortMap?` | `Record` | Provider-wide wire aliases for reasoning labels. Map a label to `"__omit__"` to drop the reasoning field from the upstream request entirely: `reasoning_effort` on an OpenAI-compatible wire, and Ollama's native `think` field on the Ollama native adapter (#2356). | | `modelReasoningEffortMap?` | `Record>` | Per-model wire aliases for reasoning labels. Map a label to `"__omit__"` to drop the reasoning field from the upstream request entirely. | diff --git a/src/adapters/openai-responses/passthrough.ts b/src/adapters/openai-responses/passthrough.ts index 753dd0ad2bb..0d6ee1f9b6b 100644 --- a/src/adapters/openai-responses/passthrough.ts +++ b/src/adapters/openai-responses/passthrough.ts @@ -3,7 +3,7 @@ import { normalizeRoutedAgentMessages } from "../routed-agent-messages"; import { frameCompactionHistory } from "../../responses/compaction-history-framing"; import { nameRoutedIdentity, repairIdentityInResponsesBody, stripRoutedIdentity } from "../identity"; import { stripBracketedModelSuffix } from "../openai-chat"; -import { normalizeOpenCodeGoAdditionalTools } from "../opencode-go-additional-tools"; +import { isOpenCodeGoResponsesUrl, isXaiResponsesUrl, promoteResponsesAdditionalTools, normalizeOpenCodeGoAdditionalTools } from "../opencode-go-additional-tools"; import { isXaiResponsesDestination } from "../../providers/xai-transport"; import { applyGithubCopilotContextTier } from "../../providers/github-copilot-context"; import { Buffer } from "node:buffer"; @@ -356,7 +356,14 @@ export function createResponsesPassthroughAdapter(provider: OcxProviderConfig): outBody = stripCanonicalOnlyToolFields(outBody, provider.supportsOpenAiWebSearchToolFields === false); } if (!forward) { - outBody = normalizeOpenCodeGoAdditionalTools(outBody, url, parsed._replayPrefixLen); + if ( + isOpenCodeGoResponsesUrl(url) + || isXaiResponsesDestination(provider) + || isXaiResponsesUrl(url) + || (provider as { promoteAdditionalTools?: boolean }).promoteAdditionalTools === true + ) { + outBody = promoteResponsesAdditionalTools(outBody, parsed._replayPrefixLen); + } } // Same native-v2/portable predicate as the routedCompaction gate in handleResponses(): // neither forward auth nor native v1 endpoint support implies trigger support. The diff --git a/src/adapters/opencode-go-additional-tools.ts b/src/adapters/opencode-go-additional-tools.ts index ea41ec1aa17..0763ec47699 100644 --- a/src/adapters/opencode-go-additional-tools.ts +++ b/src/adapters/opencode-go-additional-tools.ts @@ -20,13 +20,32 @@ export function isOpenCodeGoResponsesUrl(responseUrl: string): boolean { && !destination.href.includes("?") && !destination.href.includes("#"); } -/** Console Go accepts public tools but rejects the private additional_tools input wrapper. */ -export function normalizeOpenCodeGoAdditionalTools( +const XAI_RESPONSES_HOSTS = new Set(["api.x.ai", "cli-chat-proxy.grok.com"]); + +/** + * True when the Responses target is an official xAI endpoint (public API or Grok CLI proxy). + */ +export function isXaiResponsesUrl(responseUrl: string): boolean { + let destination: URL; + try { + destination = new URL(responseUrl); + } catch { + return false; + } + return destination.protocol === "https:" + && XAI_RESPONSES_HOSTS.has(destination.hostname.toLowerCase()) + && (destination.port === "" || destination.port === "443"); +} + +/** + * Promotes client-loaded `additional_tools` input items into top-level `tools` and strips + * the wrapper from `input`. Required for gateways whose Rust/OpenAI-strict deserializer + * rejects unknown items in the `input` array. + */ +export function promoteResponsesAdditionalTools( body: unknown, - responseUrl: string, replayPrefixLength = 0, ): unknown { - if (!isOpenCodeGoResponsesUrl(responseUrl)) return body; if (!isRecord(body) || !Array.isArray(body.input)) return body; // Do not replace a malformed top-level catalog with a partial promoted one. if (body.tools !== undefined && !Array.isArray(body.tools)) return body; @@ -41,7 +60,7 @@ export function normalizeOpenCodeGoAdditionalTools( if (isRecord(item) && item.type === "additional_tools" && Array.isArray(item.tools)) { changed = true; // Replayed wrappers are conversation history, not authority for the current request. - // Go cannot accept the wrapper itself, so remove it without promoting its catalog. + // Gateways cannot accept the wrapper itself, so remove it without promoting its catalog. if (index < currentTurnStart) continue; // Custom/search/namespace lowering already owns identity and deduplication. This pass // only moves declarations, including hosted tools that intentionally have no name. @@ -52,3 +71,13 @@ export function normalizeOpenCodeGoAdditionalTools( } return changed ? { ...body, input, tools: [...(body.tools ?? []), ...promoted] } : body; } + +/** Console Go accepts public tools but rejects the private additional_tools input wrapper. */ +export function normalizeOpenCodeGoAdditionalTools( + body: unknown, + responseUrl: string, + replayPrefixLength = 0, +): unknown { + if (!isOpenCodeGoResponsesUrl(responseUrl)) return body; + return promoteResponsesAdditionalTools(body, replayPrefixLength); +} diff --git a/tests/providers/xai/xai-additional-tools.test.ts b/tests/providers/xai/xai-additional-tools.test.ts new file mode 100644 index 00000000000..a4d73a99330 --- /dev/null +++ b/tests/providers/xai/xai-additional-tools.test.ts @@ -0,0 +1,150 @@ +import { describe, expect, test } from "bun:test"; +import { createResponsesPassthroughAdapter as createProductionAdapter } from "../../../src/adapters/openai-responses"; +import { + isXaiResponsesUrl, + promoteResponsesAdditionalTools, +} from "../../../src/adapters/opencode-go-additional-tools"; +import { withTestTranslatorBudget } from "../../helpers/translator-budget"; +import type { OcxProviderConfig } from "../../../src/types"; + +const lookup = { + type: "function" as const, + name: "lookup", + description: "finds records", + parameters: { type: "object", properties: { q: { type: "string" } } }, +}; + +function build(providerConfig: OcxProviderConfig, rawBody: Record, replayPrefixLen?: number) { + const adapter = withTestTranslatorBudget(createProductionAdapter(providerConfig)); + const request = adapter.buildRequest({ + modelId: "grok-4.7", + context: { messages: [] }, + stream: true, + options: {}, + _rawBody: rawBody, + ...(replayPrefixLen !== undefined ? { _replayPrefixLen: replayPrefixLen } : {}), + }); + return { request, body: JSON.parse(request.body) as Record }; +} + +describe("xAI Responses additional_tools promotion", () => { + test("isXaiResponsesUrl matches official xAI endpoints and rejects lookalikes", () => { + expect(isXaiResponsesUrl("https://api.x.ai/v1/responses")).toBe(true); + expect(isXaiResponsesUrl("https://api.x.ai:443/v1/responses")).toBe(true); + expect(isXaiResponsesUrl("https://cli-chat-proxy.grok.com/v1/responses")).toBe(true); + expect(isXaiResponsesUrl("https://cli-chat-proxy.grok.com:443/v1/responses")).toBe(true); + + expect(isXaiResponsesUrl("http://api.x.ai/v1/responses")).toBe(false); + expect(isXaiResponsesUrl("https://api.x.ai:8443/v1/responses")).toBe(false); + expect(isXaiResponsesUrl("https://api.x.ai.evil.test/v1/responses")).toBe(false); + expect(isXaiResponsesUrl("https://example.com/v1/responses")).toBe(false); + }); + + test("promoteResponsesAdditionalTools strips wrapper from input and moves tools to top-level", () => { + const raw = { + input: [ + { type: "message", role: "user", content: "hello" }, + { type: "additional_tools", tools: [lookup] }, + ], + }; + const result = promoteResponsesAdditionalTools(raw) as { input: unknown[]; tools: unknown[] }; + expect(result.input).toEqual([{ type: "message", role: "user", content: "hello" }]); + expect(result.tools).toEqual([lookup]); + }); + + test("promoteResponsesAdditionalTools respects replay prefix length", () => { + const historicalTool = { type: "function", name: "old_tool" }; + const raw = { + input: [ + { type: "additional_tools", tools: [historicalTool] }, + { type: "message", role: "user", content: "turn 1" }, + { type: "additional_tools", tools: [lookup] }, + ], + }; + // replayPrefixLength = 2 means index 0 and 1 are historical; only index 2 is current turn + const result = promoteResponsesAdditionalTools(raw, 2) as { input: unknown[]; tools: unknown[] }; + expect(result.input).toEqual([{ type: "message", role: "user", content: "turn 1" }]); + expect(result.tools).toEqual([lookup]); + }); + + test("xAI provider strips additional_tools and promotes declarations on buildRequest", () => { + const xaiProvider: OcxProviderConfig = { + adapter: "openai-responses", + baseUrl: "https://api.x.ai/v1", + headers: { authorization: "Bearer xai-key" }, + }; + + const raw = { + model: "grok-4.7", + input: [ + { type: "message", role: "user", content: "test query" }, + { type: "additional_tools", tools: [lookup] }, + ], + }; + + const { body } = build(xaiProvider, raw); + expect(body.input).toEqual([{ type: "message", role: "user", content: "test query" }]); + expect(body.tools).toBeArray(); + expect((body.tools as any[]).some(t => t.name === "lookup")).toBe(true); + }); + + test("cli-chat-proxy.grok.com also promotes additional_tools on buildRequest", () => { + const grokCliProvider: OcxProviderConfig = { + adapter: "openai-responses", + baseUrl: "https://cli-chat-proxy.grok.com/v1", + headers: { authorization: "Bearer grok-cli-token" }, + }; + + const raw = { + model: "grok-4.7", + input: [ + { type: "message", role: "user", content: "test query" }, + { type: "additional_tools", tools: [lookup] }, + ], + }; + + const { body } = build(grokCliProvider, raw); + expect(body.input).toEqual([{ type: "message", role: "user", content: "test query" }]); + expect((body.tools as any[]).some(t => t.name === "lookup")).toBe(true); + }); + + test("custom provider with promoteAdditionalTools: true promotes on buildRequest", () => { + const customProvider = { + adapter: "openai-responses", + baseUrl: "https://custom-gateway.internal/v1", + promoteAdditionalTools: true, + headers: { authorization: "Bearer internal-token" }, + }; + + const raw = { + model: "custom-model", + input: [ + { type: "message", role: "user", content: "custom query" }, + { type: "additional_tools", tools: [lookup] }, + ], + }; + + const { body } = build(customProvider as unknown as OcxProviderConfig, raw); + expect(body.input).toEqual([{ type: "message", role: "user", content: "custom query" }]); + expect((body.tools as any[]).some(t => t.name === "lookup")).toBe(true); + }); + + test("unapproved custom provider without flag leaves input untouched", () => { + const unapprovedProvider: OcxProviderConfig = { + adapter: "openai-responses", + baseUrl: "https://unapproved.internal/v1", + headers: { authorization: "Bearer token" }, + }; + + const raw = { + model: "custom-model", + input: [ + { type: "message", role: "user", content: "custom query" }, + { type: "additional_tools", tools: [lookup] }, + ], + }; + + const { body } = build(unapprovedProvider, raw); + expect(body.input).toEqual(raw.input); + }); +}); From 0e6b3f4422964948c98eb48339200bfada0a4a9a Mon Sep 17 00:00:00 2001 From: Omar <13569609+omar391@users.noreply.github.com> Date: Sat, 24 Oct 2026 08:46:21 +0600 Subject: [PATCH 02/14] fix(management): typed promoteAdditionalTools configuration, PATCH support, and test inventory registration - Add typed promoteAdditionalTools?: boolean to OcxProviderConfig and validate in leaf-validators schema. - Support boolean and null values in management PATCH /api/providers and carry the field across same-destination overwrites. - Expose promoteAdditionalTools in GET /api/providers and register in auth-cors field policy and model-rename roles. - Add management round-trip, PATCH set/disable/clear, and carry tests in tests/server/management-provider-compat-carry.test.ts. - Register tests/providers/xai/xai-additional-tools.test.ts in test layout inventories (scripts/test-layout/layout.json and tests/fixtures/test-layout-expected.json). - Document promoteAdditionalTools semantics in structure/transports/responses.md and structure/gui-and-management-api.md within 600-line budget. --- scripts/test-layout/layout.json | 1 + src/adapters/openai-responses/passthrough.ts | 2 +- src/config/schema/leaf-validators.ts | 1 + src/providers/model-rename-fields.ts | 1 + src/server/auth-cors.ts | 1 + .../management/provider-overwrite-carry.ts | 10 +++++ src/server/management/provider-routes.ts | 1 + src/types/provider.ts | 7 ++++ structure/gui-and-management-api.md | 2 +- structure/transports/responses.md | 38 +++++++++---------- tests/fixtures/test-layout-expected.json | 1 + .../xai/xai-additional-tools.test.ts | 9 ++++- .../management-provider-compat-carry.test.ts | 35 +++++++++++++++++ 13 files changed, 87 insertions(+), 22 deletions(-) diff --git a/scripts/test-layout/layout.json b/scripts/test-layout/layout.json index a61f3c0d32b..dd55fc0f59d 100644 --- a/scripts/test-layout/layout.json +++ b/scripts/test-layout/layout.json @@ -1963,6 +1963,7 @@ "codex-ws-handshake.test.ts": "responses", "codex-ws-handshake-validation.test.ts": "responses", "ws-upstream-socks5.test.ts": "responses", "ws-upstream.test.ts": "responses", + "xai-additional-tools.test.ts": "providers/xai", "xai-client.test.ts": "images", "xai-empty-catalog-tool-choice.test.ts": "providers/xai", "xai-no-stop.test.ts": "providers/xai", diff --git a/src/adapters/openai-responses/passthrough.ts b/src/adapters/openai-responses/passthrough.ts index 0d6ee1f9b6b..bb7c9d4e8f4 100644 --- a/src/adapters/openai-responses/passthrough.ts +++ b/src/adapters/openai-responses/passthrough.ts @@ -360,7 +360,7 @@ export function createResponsesPassthroughAdapter(provider: OcxProviderConfig): isOpenCodeGoResponsesUrl(url) || isXaiResponsesDestination(provider) || isXaiResponsesUrl(url) - || (provider as { promoteAdditionalTools?: boolean }).promoteAdditionalTools === true + || provider.promoteAdditionalTools === true ) { outBody = promoteResponsesAdditionalTools(outBody, parsed._replayPrefixLen); } diff --git a/src/config/schema/leaf-validators.ts b/src/config/schema/leaf-validators.ts index 0268b5822ac..8ecfbbb020a 100644 --- a/src/config/schema/leaf-validators.ts +++ b/src/config/schema/leaf-validators.ts @@ -359,6 +359,7 @@ export const providerConfigSchema = z.object({ inferContextWindow: z.boolean().optional(), modelSupportsServiceTier: z.record(z.string().min(1), z.boolean()).optional(), modelSuppressSyntheticMax: z.record(z.string().min(1), z.boolean()).optional(), + promoteAdditionalTools: z.boolean().optional(), preserveResponsesReasoningContent: z.boolean().optional(), preserveResponsesInputItemIds: z.boolean().optional(), preserveResponsesMessageMetadata: z.boolean().optional(), diff --git a/src/providers/model-rename-fields.ts b/src/providers/model-rename-fields.ts index 7638a46d33f..aa28a7b535b 100644 --- a/src/providers/model-rename-fields.ts +++ b/src/providers/model-rename-fields.ts @@ -29,6 +29,7 @@ export const PROVIDER_MODEL_RENAME_ROLES = { supportsServiceTier: "none", fastEnabled: "none", modelSupportsServiceTier: "record", + promoteAdditionalTools: "none", preserveResponsesReasoningContent: "none", preserveResponsesInputItemIds: "none", preserveResponsesMessageMetadata: "none", diff --git a/src/server/auth-cors.ts b/src/server/auth-cors.ts index 578426c8f31..0a876363d65 100644 --- a/src/server/auth-cors.ts +++ b/src/server/auth-cors.ts @@ -1088,6 +1088,7 @@ const PROVIDER_CONFIG_FIELD_POLICY = { annotateEmptyToolOutputs: "editor", supportsServiceTier: "editor", modelSupportsServiceTier: "editor", + promoteAdditionalTools: "editor", preserveResponsesReasoningContent: "editor", preserveResponsesInputItemIds: "editor", preserveResponsesMessageMetadata: "editor", diff --git a/src/server/management/provider-overwrite-carry.ts b/src/server/management/provider-overwrite-carry.ts index b0473a4c155..9c5e396dc0c 100644 --- a/src/server/management/provider-overwrite-carry.ts +++ b/src/server/management/provider-overwrite-carry.ts @@ -51,6 +51,7 @@ const RETRY_POLICY_VALIDATORS = { export const PROVIDER_COMPAT_CARRY_FIELDS = [ "preserveReasoningContentModels", "preserveResponsesReasoningContent", + "promoteAdditionalTools", "requiresReasoningPlaceholderModels", "foldDeveloperRoleToSystem", "reasoningWireFormat", @@ -163,6 +164,8 @@ export function providerCompatFieldConfigError(provider: Record if (hideRawReasoning !== undefined && typeof hideRawReasoning !== "boolean") return "hideRawReasoning must be a boolean"; const preserveResponses = provider.preserveResponsesReasoningContent; if (preserveResponses !== undefined && typeof preserveResponses !== "boolean") return "preserveResponsesReasoningContent must be a boolean"; + const promoteAdditionalTools = provider.promoteAdditionalTools; + if (promoteAdditionalTools !== undefined && typeof promoteAdditionalTools !== "boolean") return "promoteAdditionalTools must be a boolean"; const wire = provider.reasoningWireFormat; if (wire !== undefined && !PROVIDER_REASONING_WIRE_FORMATS.includes(wire as never)) { return `reasoningWireFormat must be one of: ${PROVIDER_REASONING_WIRE_FORMATS.join(", ")}`; @@ -214,6 +217,13 @@ export function applyProviderCompatPatchFields( else return { error: "preserveResponsesReasoningContent must be a boolean" }; touched = true; } + if (Object.hasOwn(rawBody, "promoteAdditionalTools")) { + const value = rawBody.promoteAdditionalTools; + if (value === null) delete next.promoteAdditionalTools; + else if (typeof value === "boolean") next.promoteAdditionalTools = value; + else return { error: "promoteAdditionalTools must be a boolean" }; + touched = true; + } if (Object.hasOwn(rawBody, "inferContextWindow")) { const value = rawBody.inferContextWindow; if (value === null) delete next.inferContextWindow; diff --git a/src/server/management/provider-routes.ts b/src/server/management/provider-routes.ts index 6e4826a959d..db694f1c39e 100644 --- a/src/server/management/provider-routes.ts +++ b/src/server/management/provider-routes.ts @@ -907,6 +907,7 @@ export async function handleProviderRoutes(ctx: ManagementContext): Promise 0, allowPrivateNetwork: p.allowPrivateNetwork === true, preserveResponsesReasoningContent: p.preserveResponsesReasoningContent, + promoteAdditionalTools: p.promoteAdditionalTools, liveModels: p.liveModels !== false, requestPacing: p.requestPacing, models: p.models ?? [], diff --git a/src/types/provider.ts b/src/types/provider.ts index c41256d14b8..8677148d1f8 100644 --- a/src/types/provider.ts +++ b/src/types/provider.ts @@ -441,6 +441,13 @@ export interface OcxProviderConfig { * items instead of blanking it the way the ChatGPT backend requires; proxy-minted * `ocxr1` envelopes are still stripped because no upstream can decrypt them. */ + /** + * Whether to promote Codex Desktop's `additional_tools` input items into top-level `tools` + * and strip the wrapper from `input`. Enabled automatically for OpenCode Go and xAI + * (`api.x.ai` and `cli-chat-proxy.grok.com`); other strict gateways that return HTTP 422 + * for unknown item types in `input` can opt in here. + */ + promoteAdditionalTools?: boolean; preserveResponsesReasoningContent?: boolean; /** * Whether to preserve Codex-private `input[].id` on `store: false` requests. diff --git a/structure/gui-and-management-api.md b/structure/gui-and-management-api.md index d5b88fb31fa..9d32839db4c 100644 --- a/structure/gui-and-management-api.md +++ b/structure/gui-and-management-api.md @@ -275,7 +275,7 @@ per-request first-party callback reads that live object; a failed write leaves i | Startup safety | `GET /api/startup-health` reports whether injected Codex routing is restart-safe, with secret-free service/shim diagnostics. `POST /api/startup-action` provides allowlisted one-click installation for the background service or launcher shim. On Windows a healthy script shim is CLI-only; Codex Desktop requires the background service for full protection. Optional Codex compatibility uses the [confirmed local runtime](clients/codex-desktop.md#optional-compatibility-runtime), [machine-local dashboard consent](clients/codex-desktop.md#dashboard-controls), and [revision-bound next-start preference with shared awaited teardown](clients/codex-desktop.md#proxy-startup-preference); saving that preference performs no runtime action. | | Windows tray | `GET/POST /api/windows-tray` controls an owned, per-user HKCU login tray. The tray delegates fixed actions to the CLI and is never a proxy supervisor or restart-protection signal. | | Updates | `GET /api/update/check`, `POST /api/update/run`, and `GET /api/update/status` own dashboard self-update state. A launched worker PID is persisted in `update-job.json`; dead PIDs recover immediately, while legacy active records without a PID recover only after ten minutes. Live PIDs remain exclusive regardless of record age. `GET /api/update/check` and `POST /api/update/run` await one per-channel asynchronous registry lookup and write successful results through to the package cache; run passes that result to the job starter. `GET /api/update/badge` only reads the cache and reports unknown after 40 hours, on missing cache, or on channel mismatch. The badge links to the update surface rather than gating other actions. `GET /api/update/badge?surface=desktop&session=` projects only that process-local Tauri session; missing or expired state is unknown and never falls back to the package cache. `POST /api/update/desktop-snapshot` is a 1 KiB bounded display-state mutation with no install permission. It accepts the existing admin-token principal or a dedicated single-use, ten-second snapshot capability bound to nonce, method, path, PID, port and the SHA-256 digest of the exact body bytes; that capability cannot authorize another route. | -| Providers | Create/update/delete ordinary provider configs and enrich registry metadata. A `POST /api/providers` overwrite of an existing name keeps the nine operator compatibility settings (`PROVIDER_COMPAT_CARRY_FIELDS` in `src/server/management/provider-overwrite-carry.ts`) and the stored key pool only while the destination (adapter, normalized base URL, auth mode when named) is unchanged. It keeps omitted `hideRawReasoning` on every overwrite of the same name, including destination moves, because it is a display policy; a submitted boolean wins. An omitted `inferContextWindow` likewise keeps the latest live same-name state after asynchronous validation, including `false` or a concurrent clear; a submitted boolean wins. It never merges the rest of the old row. `PATCH` is a field mask and keeps every field it does not name; `hideRawReasoning`, `preserveResponsesReasoningContent` and `inferContextWindow` accept a boolean or `null` to clear them, rejecting other types. `GET /api/providers` exposes configured `inferContextWindow` as an optional boolean without substituting a default; its [custom-only, default-off inference policy](catalog-vendor-context.md) is unchanged. Custom Responses provider forms expose `preserveResponsesReasoningContent` as a default-off plaintext replay switch; unrelated settings saves preserve omission and curated/OpenAI destinations keep their existing defaults. The reserved `openai` card exposes Pool(default)/Direct account mode; `openai-apikey` remains the separate API route. | +| Providers | Create/update/delete ordinary provider configs and enrich registry metadata. A `POST /api/providers` overwrite of an existing name keeps the ten operator compatibility settings (`PROVIDER_COMPAT_CARRY_FIELDS` in `src/server/management/provider-overwrite-carry.ts`) and the stored key pool only while the destination (adapter, normalized base URL, auth mode when named) is unchanged. It keeps omitted `hideRawReasoning` on every overwrite of the same name, including destination moves, because it is a display policy; a submitted boolean wins. An omitted `inferContextWindow` likewise keeps the latest live same-name state after asynchronous validation, including `false` or a concurrent clear; a submitted boolean wins. It never merges the rest of the old row. `PATCH` is a field mask and keeps every field it does not name; `hideRawReasoning`, `preserveResponsesReasoningContent`, `promoteAdditionalTools` and `inferContextWindow` accept a boolean or `null` to clear them, rejecting other types. `GET /api/providers` exposes configured `inferContextWindow` as an optional boolean without substituting a default; its [custom-only, default-off inference policy](catalog-vendor-context.md) is unchanged. Custom Responses provider forms expose `preserveResponsesReasoningContent` as a default-off plaintext replay switch; unrelated settings saves preserve omission and curated/OpenAI destinations keep their existing defaults. The reserved `openai` card exposes Pool(default)/Direct account mode; `openai-apikey` remains the separate API route. | | Models | Fetch routed model lists, disabled model visibility, and catalog-facing ids. New non-OAuth registration holds exposure until authoritative discovery; 20 or more distinct switch rows start OFF without disabling the provider. Pending rows cannot accept visibility changes. Ordinary model reads also apply the [new-arrival catalog contract](catalog.md#shared-catalog) before returning models. With a matching persisted configuration, discovery records the baseline, NEW badges, and automatic disables together; HTTP listing does not wait for a separate Codex sync. Inventory already drifted from disk uses a detached policy projection: arrivals remain disabled in management rows without changing live config or disk. | | OAuth | Login/status/logout for OAuth-backed providers, plus multiauth account management: `GET /api/oauth/accounts`, `PUT /api/oauth/accounts/active`, `PUT /api/oauth/accounts/alias`, `PUT /api/oauth/accounts/pause`, and `DELETE /api/oauth/accounts` list masked accounts per provider, switch the active one, edit its display-only alias, pause/resume a generic OAuth account, and remove one. Paused generic OAuth accounts are excluded from request selection, 429 failover, and proactive token refresh; pausing an active account selects the next usable account when available, and resuming an account restores an active selection if the current one remains paused. With no unpaused account, requests return 403 rather than a login error. Kiro account-list rows include the current automatic-selection projection and closed exclusion reason; an active singleton may still send. The login flow itself is `GET /api/oauth/providers`, `POST /api/oauth/login`, `POST /api/oauth/login/code`, `POST /api/oauth/login/cancel`, `POST /api/oauth/logout`, and `GET /api/oauth/status`; pool controls are `GET/PUT/PATCH /api/oauth/accounts/pool` and `POST /api/oauth/accounts/clear-cooldown`. Login accepts `addAccount: true` to force a fresh browser identity. Meta Muse login start and manual-code continuation require the server-resolved `gui-session` principal before credential acquisition or code submission (including reauth); see the [provider contract](providers-and-adapters.md). Device flows return a structured `deviceCode`; the GUI highlights and copies it before the user opens the verification page. Google Antigravity account rows in `gui/src/components/provider-workspace/ProviderAuthPanel.tsx` display the account-summary `plan` as a badge and include it in the account button’s accessible name. The value is the canonical upstream tier ID (for example `free-tier` or `gemini-pro`), preserved literally across locales rather than inferred as a product name. Absent plans render no badge; pause and reauthentication controls keep their existing behavior. | | Key providers | `GET /api/key-providers` exposes API-key provider presets for setup and dashboard flows, and `GET/POST/DELETE /api/keys` owns the proxy's own admission keys. Machine links live in `src/server/management/link-routes.ts`: dashboard sessions reach `GET /api/link/candidates`, `POST /api/link/probe`, `POST /api/link/confirm-host` and `POST /api/link/apply`, meaning a paired session or, on a standalone runtime, the current loopback-issued session on trusted loopback ingress; `POST /api/link/join` instead requires an operator-paired session because it durably redirects local client traffic, and also refuses a runtime that is not standalone (`409 standalone_required`) or not listening on its configured port (`409 join_port_mismatch`) before any SSH, then restarts this runtime as a client; `GET /api/link/status` reports `joinAvailable` and typed `joinDenied` causes only to GUI-session callers, distinguishing pairing, runtime-role and port refusals without changing the admin/CLI DTO, so the dashboard enables the Child role only when the join gates pass; `GET /api/link/status`, `POST /api/link/{id}/reconnect` and `DELETE /api/link/{id}` also accept the admin token on a trusted loopback ingress, and reconnect restarts a Home-initiated link's tunnel now; `POST /api/link/issue` accepts only that admin token. Tailscale-identity sessions are refused on every link route. See [Remote Link](remote-link.md). Multi-key pool per key-auth provider: `GET /api/providers/keys`, `POST /api/providers/keys`, `PUT /api/providers/keys/active`, `PUT /api/providers/keys/alias`, `DELETE /api/providers/keys` masked list, add (upsert + activate), switch, rename, and remove keys. `provider.apiKey` always mirrors the active pool entry so routing stays single-key. | diff --git a/structure/transports/responses.md b/structure/transports/responses.md index 810c8d782e4..e33af39428f 100644 --- a/structure/transports/responses.md +++ b/structure/transports/responses.md @@ -203,27 +203,27 @@ when the shared xAI normalizer can preserve its meaning; an unsafe function is o letting one incompatible declaration reject the entire request before inference. This is scoped to `cli-chat-proxy.grok.com`: public `api.x.ai` keeps native root unions, as do unrelated Responses gateways. Both top-level `tools` and Responses Lite `additional_tools` pass through this policy. - -Only the ROOT rejects a union, so exclusivity is preserved by moving it down rather than widening -it: a root `oneOf` whose branches differ in one property becomes that property's `oneOf`, or its -`anyOf` when the branches are provably disjoint and the two keywords describe the same set. That -property is also promoted into `required`, because absent it matched every branch — which the root -`oneOf` rejects. Branches that are wholly identical validate nothing and have no faithful -flattening, so they omit the tool. The walk carries depth, node, and variant budgets, since nested -unions are combinatorial and a `$ref` diamond amplifies the same way without ever cycling; -exceeding a budget omits that one function rather than expanding until memory is gone. - -Omitting a function makes `tool_choice` the loose end. A selector naming a dropped tool would reach -Grok as a dangling reference, and relaxing it to `auto` is worse — the turn would quietly run -without the tool the caller required. So an `allowed_tools` list drops the omitted entries while any -remain, and a selection with nothing left to point at fails locally with the same 400 a tool catalog -this proxy cannot lower already returns. +Codex Desktop packages client tools inside an `additional_tools` input item; strict gateways +rejecting non-ResponseItem input types—including official xAI endpoints (`api.x.ai`, +`cli-chat-proxy.grok.com`), OpenCode Go, and custom providers opting in with +`promoteAdditionalTools: true`—promote declarations to top-level `tools` and strip the wrapper +from `input` while preserving turn boundaries via `_replayPrefixLen`. + +Only the ROOT rejects a union, so exclusivity is preserved by moving it down rather than widening it: +a root `oneOf` whose branches differ in one property becomes that property's `oneOf`, or its `anyOf` +when the branches are provably disjoint and the two keywords describe the same set. That property is also +promoted into `required`, because absent it matched every branch — which the root `oneOf` rejects. +Branches that are wholly identical validate nothing and have no faithful flattening, so they omit the tool. +The walk carries depth, node, and variant budgets, since nested unions are combinatorial and a `$ref` +diamond amplifies the same way without ever cycling; exceeding a budget omits that one function. + +Omitting a function makes `tool_choice` the loose end: a selector naming a dropped tool would reach +Grok as a dangling reference, and relaxing to `auto` runs without the tool the caller required. +An `allowed_tools` list drops omitted entries while any remain, and an empty selection fails with 400. The same noncanonical boundary strips ChatGPT's private `external_web_access` bit from routed -`web_search` declarations. The public tool remains enabled and all other options remain intact; -canonical OpenAI forwarding preserves the bit. xAI's public Responses schema enables browsing by -the presence of `web_search` and rejects the private argument, so forwarding it made the first -post-namespace request fail with HTTP 400. +`web_search` declarations while keeping the public tool and other options intact (canonical OpenAI +forwarding preserves the bit; xAI rejects the private argument with HTTP 400). The option-aware `openai` provider uses `openai-responses` with `authMode: "forward"`. Pool mode resolves main plus added accounts through affinity/quota/cooldown ownership; Direct forwards only diff --git a/tests/fixtures/test-layout-expected.json b/tests/fixtures/test-layout-expected.json index 9e46d6aaa6d..0f435a2dabe 100644 --- a/tests/fixtures/test-layout-expected.json +++ b/tests/fixtures/test-layout-expected.json @@ -1458,6 +1458,7 @@ "ws-replay-refusal-attribution.test.ts": "responses", "combo-replay-refusal-attribution.test.ts": "responses", "ws-upstream.test.ts": "responses", + "xai-additional-tools.test.ts": "providers/xai", "xai-client.test.ts": "images", "xai-empty-catalog-tool-choice.test.ts": "providers/xai", "xai-no-stop.test.ts": "providers/xai", diff --git a/tests/providers/xai/xai-additional-tools.test.ts b/tests/providers/xai/xai-additional-tools.test.ts index a4d73a99330..c1cc64b0fbd 100644 --- a/tests/providers/xai/xai-additional-tools.test.ts +++ b/tests/providers/xai/xai-additional-tools.test.ts @@ -1,3 +1,4 @@ +import { providerConfigSchema } from "../../../src/config/schema/leaf-validators"; import { describe, expect, test } from "bun:test"; import { createResponsesPassthroughAdapter as createProductionAdapter } from "../../../src/adapters/openai-responses"; import { @@ -124,11 +125,17 @@ describe("xAI Responses additional_tools promotion", () => { ], }; - const { body } = build(customProvider as unknown as OcxProviderConfig, raw); + const { body } = build(customProvider, raw); expect(body.input).toEqual([{ type: "message", role: "user", content: "custom query" }]); expect((body.tools as any[]).some(t => t.name === "lookup")).toBe(true); }); + test("providerConfigSchema validates promoteAdditionalTools as optional boolean", () => { + expect(providerConfigSchema.safeParse({ adapter: "openai-responses", baseUrl: "https://example.com/v1", promoteAdditionalTools: true }).success).toBe(true); + expect(providerConfigSchema.safeParse({ adapter: "openai-responses", baseUrl: "https://example.com/v1", promoteAdditionalTools: false }).success).toBe(true); + expect(providerConfigSchema.safeParse({ adapter: "openai-responses", baseUrl: "https://example.com/v1", promoteAdditionalTools: "true" }).success).toBe(false); + }); + test("unapproved custom provider without flag leaves input untouched", () => { const unapprovedProvider: OcxProviderConfig = { adapter: "openai-responses", diff --git a/tests/server/management-provider-compat-carry.test.ts b/tests/server/management-provider-compat-carry.test.ts index e60f0de323b..01d812ec3d4 100644 --- a/tests/server/management-provider-compat-carry.test.ts +++ b/tests/server/management-provider-compat-carry.test.ts @@ -121,6 +121,22 @@ const CASES: Record = { return (JSON.parse(String(built.body)) as { input: Array<{ content: unknown }> }).input[0]!.content; }, }, + promoteAdditionalTools: { + seed: { promoteAdditionalTools: true }, + probe: provider => { + const parsed = parseRequest({ + model: MODEL, + input: [ + { type: "message", role: "user", content: "hi" }, + { type: "additional_tools", tools: [tool] }, + ], + }); + parsed.modelId = MODEL; + const built = withTestTranslatorBudget(createResponsesPassthroughAdapter({ ...provider, adapter: "openai-responses" })).buildRequest(parsed, { headers: new Headers() }); + const body = JSON.parse(String(built.body)) as { input: Array<{ type: string }>; tools?: unknown[] }; + return { inputTypes: body.input.map(i => i.type), hasTools: Array.isArray(body.tools) && body.tools.length > 0 }; + }, + }, foldDeveloperRoleToSystem: { seed: { foldDeveloperRoleToSystem: true }, probe: provider => (nativeBody(provider, { @@ -281,6 +297,25 @@ describe("an overwrite that moves the provider carries none of it", () => { }); describe("PATCH and POST validate the settings they name", () => { + test("PATCH writes and clears promoteAdditionalTools", async () => { + await withServer({ relay: { adapter: "openai-responses", baseUrl: BASE_URL } }, async url => { + const set = await send(url, "/api/providers?name=relay", "PATCH", { promoteAdditionalTools: true }); + expect(set.status).toBe(200); + expect(loadConfig().providers.relay?.promoteAdditionalTools).toBe(true); + + const disable = await send(url, "/api/providers?name=relay", "PATCH", { promoteAdditionalTools: false }); + expect(disable.status).toBe(200); + expect(loadConfig().providers.relay?.promoteAdditionalTools).toBe(false); + + const bad = await send(url, "/api/providers?name=relay", "PATCH", { promoteAdditionalTools: "yes" }); + expect(bad.status).toBe(400); + + const clear = await send(url, "/api/providers?name=relay", "PATCH", { promoteAdditionalTools: null }); + expect(clear.status).toBe(200); + expect(loadConfig().providers.relay).not.toHaveProperty("promoteAdditionalTools"); + }); + }); + test("PATCH writes and clears foldDeveloperRoleToSystem and reasoningWireFormat", async () => { await withServer({ relay: { adapter: "openai-chat", baseUrl: BASE_URL } }, async url => { const set = await send(url, "/api/providers?name=relay", "PATCH", { From ecc1bbafd0cf7cc4cda966d15c7d4821df9ed60b Mon Sep 17 00:00:00 2001 From: Omar <13569609+omar391@users.noreply.github.com> Date: Sun, 25 Oct 2026 00:09:33 +0600 Subject: [PATCH 03/14] fix(responses): recalculate replay boundary when dropping reasoning items --- src/adapters/openai-responses/passthrough.ts | 15 +++++++++-- .../xai/xai-additional-tools.test.ts | 27 +++++++++++++++++++ 2 files changed, 40 insertions(+), 2 deletions(-) diff --git a/src/adapters/openai-responses/passthrough.ts b/src/adapters/openai-responses/passthrough.ts index bb7c9d4e8f4..0bd63032f1d 100644 --- a/src/adapters/openai-responses/passthrough.ts +++ b/src/adapters/openai-responses/passthrough.ts @@ -229,7 +229,18 @@ export function createResponsesPassthroughAdapter(provider: OcxProviderConfig): repairOrphanOutputs, ); } + let replayPrefixLen = parsed._replayPrefixLen; if (provider.dropResponsesReasoningItems === true) { + if ( + typeof replayPrefixLen === "number" + && replayPrefixLen > 0 + && isPlainObject(outBody) + && Array.isArray(outBody.input) + ) { + const prefixItems = outBody.input.slice(0, Math.min(outBody.input.length, Math.trunc(replayPrefixLen))); + const droppedInPrefix = prefixItems.filter(item => isPlainObject(item) && item.type === "reasoning").length; + replayPrefixLen = Math.max(0, replayPrefixLen - droppedInPrefix); + } outBody = dropResponsesReasoningInputItems(outBody); } if (adjacentToolResults) { @@ -337,7 +348,7 @@ export function createResponsesPassthroughAdapter(provider: OcxProviderConfig): // generic capability fallback removes the private OpenAI fields. Grok relayed through // OpenCode Go speaks the same dialect, which the final model id and URL identify. outBody = normalizeXaiResponsesWebSearch(outBody, provider, { modelId: parsed.modelId, responseUrl: url }); - outBody = injectXaiResponsesXSearch(outBody, provider, parsed._replayPrefixLen); + outBody = injectXaiResponsesXSearch(outBody, provider, replayPrefixLen); // xAI and explicitly classified compatible gateways reject these OpenAI web_search // extensions. Keep them for OpenAI API-key traffic and unclassified gateways. if (provider.supportsOpenAiWebSearchToolFields === false) { @@ -362,7 +373,7 @@ export function createResponsesPassthroughAdapter(provider: OcxProviderConfig): || isXaiResponsesUrl(url) || provider.promoteAdditionalTools === true ) { - outBody = promoteResponsesAdditionalTools(outBody, parsed._replayPrefixLen); + outBody = promoteResponsesAdditionalTools(outBody, replayPrefixLen); } } // Same native-v2/portable predicate as the routedCompaction gate in handleResponses(): diff --git a/tests/providers/xai/xai-additional-tools.test.ts b/tests/providers/xai/xai-additional-tools.test.ts index c1cc64b0fbd..0044d8a159f 100644 --- a/tests/providers/xai/xai-additional-tools.test.ts +++ b/tests/providers/xai/xai-additional-tools.test.ts @@ -154,4 +154,31 @@ describe("xAI Responses additional_tools promotion", () => { const { body } = build(unapprovedProvider, raw); expect(body.input).toEqual(raw.input); }); + + test("promoteResponsesAdditionalTools recalculates replay boundary when reasoning items are dropped", () => { + const xaiProvider: OcxProviderConfig = { + adapter: "openai-responses", + baseUrl: "https://api.x.ai/v1", + dropResponsesReasoningItems: true, + headers: { authorization: "Bearer xai-key" }, + }; + + const raw = { + model: "grok-4.7", + input: [ + { type: "reasoning", content: "thinking..." }, + { type: "message", role: "user", content: "hello" }, + { type: "additional_tools", tools: [lookup] }, + ], + }; + + // Original prefix len is 2 (reasoning + user message). + // When reasoning is dropped, prefix len must adjust from 2 to 1. + // additional_tools moves from index 2 to index 1, which matches the new prefix boundary (>= 1), + // so its tools are promoted to top-level tools. + const { body } = build(xaiProvider, raw, 2); + expect(body.input).toEqual([{ type: "message", role: "user", content: "hello" }]); + expect(body.tools).toBeArray(); + expect((body.tools as any[]).some(t => t.name === "lookup")).toBe(true); + }); }); From 33a613e68899f2fa3c6a08af6a58cf817220eac7 Mon Sep 17 00:00:00 2001 From: Omar <13569609+omar391@users.noreply.github.com> Date: Sun, 25 Oct 2026 09:35:58 +0600 Subject: [PATCH 04/14] fix(responses): allow explicit promoteAdditionalTools for forward auth providers --- src/adapters/openai-responses/passthrough.ts | 12 +++--- .../xai/xai-additional-tools.test.ts | 40 +++++++++++++++++++ 2 files changed, 46 insertions(+), 6 deletions(-) diff --git a/src/adapters/openai-responses/passthrough.ts b/src/adapters/openai-responses/passthrough.ts index 0bd63032f1d..100f5bc5504 100644 --- a/src/adapters/openai-responses/passthrough.ts +++ b/src/adapters/openai-responses/passthrough.ts @@ -366,15 +366,15 @@ export function createResponsesPassthroughAdapter(provider: OcxProviderConfig): // Last, so promoted namespace children are also cleared of Codex-private fields. outBody = stripCanonicalOnlyToolFields(outBody, provider.supportsOpenAiWebSearchToolFields === false); } - if (!forward) { - if ( + if ( + provider.promoteAdditionalTools === true + || (!forward && ( isOpenCodeGoResponsesUrl(url) || isXaiResponsesDestination(provider) || isXaiResponsesUrl(url) - || provider.promoteAdditionalTools === true - ) { - outBody = promoteResponsesAdditionalTools(outBody, replayPrefixLen); - } + )) + ) { + outBody = promoteResponsesAdditionalTools(outBody, replayPrefixLen); } // Same native-v2/portable predicate as the routedCompaction gate in handleResponses(): // neither forward auth nor native v1 endpoint support implies trigger support. The diff --git a/tests/providers/xai/xai-additional-tools.test.ts b/tests/providers/xai/xai-additional-tools.test.ts index 0044d8a159f..db56f7b4506 100644 --- a/tests/providers/xai/xai-additional-tools.test.ts +++ b/tests/providers/xai/xai-additional-tools.test.ts @@ -155,6 +155,46 @@ describe("xAI Responses additional_tools promotion", () => { expect(body.input).toEqual(raw.input); }); + test("custom provider with authMode: forward and promoteAdditionalTools: true promotes tools", () => { + const forwardOptInProvider: OcxProviderConfig = { + adapter: "openai-responses", + baseUrl: "https://forward-proxy.example.com/v1", + authMode: "forward", + promoteAdditionalTools: true, + }; + + const raw = { + model: "custom-model", + input: [ + { type: "message", role: "user", content: "forward query" }, + { type: "additional_tools", tools: [lookup] }, + ], + }; + + const { body } = build(forwardOptInProvider, raw); + expect(body.input).toEqual([{ type: "message", role: "user", content: "forward query" }]); + expect((body.tools as any[]).some(t => t.name === "lookup")).toBe(true); + }); + + test("custom provider with authMode: forward without flag leaves input untouched", () => { + const forwardNoOptInProvider: OcxProviderConfig = { + adapter: "openai-responses", + baseUrl: "https://api.x.ai/v1", + authMode: "forward", + }; + + const raw = { + model: "custom-model", + input: [ + { type: "message", role: "user", content: "forward query" }, + { type: "additional_tools", tools: [lookup] }, + ], + }; + + const { body } = build(forwardNoOptInProvider, raw); + expect(body.input).toEqual(raw.input); + }); + test("promoteResponsesAdditionalTools recalculates replay boundary when reasoning items are dropped", () => { const xaiProvider: OcxProviderConfig = { adapter: "openai-responses", From 52b3ab206f291e511b35f5fc9d66966b54b23a30 Mon Sep 17 00:00:00 2001 From: Omar <13569609+omar391@users.noreply.github.com> Date: Wed, 28 Oct 2026 11:44:10 +0600 Subject: [PATCH 05/14] docs(providers): extract keychain configuration to linked sibling page to satisfy file-size ratchet --- .../docs/reference/configuration/keychain.md | 55 +++++++++++++++++++ .../docs/reference/configuration/providers.md | 55 +------------------ 2 files changed, 58 insertions(+), 52 deletions(-) create mode 100644 docs-site/src/content/docs/reference/configuration/keychain.md diff --git a/docs-site/src/content/docs/reference/configuration/keychain.md b/docs-site/src/content/docs/reference/configuration/keychain.md new file mode 100644 index 00000000000..c8a4e40d384 --- /dev/null +++ b/docs-site/src/content/docs/reference/configuration/keychain.md @@ -0,0 +1,55 @@ +--- +title: Storing keys in the OS keychain +description: Storing provider API keys and Vertex credentials in macOS Keychain, Windows Credential Manager, or Linux Secret Service. +--- + +For Vertex, the dedicated API-key pool input (`POST /api/providers/keys`) accepts complete GCP +`service_account` or `authorized_user` credential JSON after the provider has been created. +The provider create/edit form and `ocx provider add --api-key` refuse credential JSON before +saving: create the provider without a key, then add the JSON through its key pool. Paste the +JSON contents, not a Windows, Unix, or relative file path. Multiple +JSON objects separated by commas or whitespace are stored together as separate pool entries; +the first becomes active. Configuration contains only `gcp-sa:` references, never the +credential JSON. Mixed JSON/literal-key bulk pastes are rejected as a whole; add literal keys +separately. Storage failures leave the configuration unchanged and report a fixed error without +echoing credential material. Empty marker accounts are invalid and do not fall back to host credentials. +Paste one credential object or a sequence of objects, without an enclosing array or quoted string. +Malformed credential text is rejected. If a selected stored credential is removed or becomes unreadable, +Vertex refuses the request even when it previously cached an access token for that credential. +Removing a key or provider, or replacing a provider with `ocx provider add --force`, deletes its unreferenced GCP credentials after the configuration is +saved; a credential referenced by another provider is retained. If OS-store cleanup fails, a +warning requests manual inspection without undoing the committed configuration removal. +Ordinary keychain store/restore actions leave GCP markers and JSON in the OS store. Restoring +credential JSON through a `keychain:` alias is refused, and cleanup preserves accounts still +referenced through either marker spelling. +Using a `keychain:` alias containing GCP JSON as an API key also refuses the request; select its +`gcp-sa:` reference for Vertex authentication instead. +Literal API keys must be plain token text: JSON objects, arrays and quoted JSON strings are refused +in literal-key resolution and restore, including wrappers containing escaped credential text. +Credential writes and marker persistence share the cleanup transaction lock, so concurrent +removal cannot delete the stored JSON between read-back and the marker commit. + +By default a provider's `apiKey` and `apiKeyPool` sit in `config.json` (mode 0600, atomic writes). +If you would rather keep the key material out of the file, move it into the OS credential store: + +```bash +ocx provider keychain deepseek status # store: file | env | keychain, and whether the keychain answers +ocx provider keychain deepseek store # move active key + pool into the OS keychain +ocx provider keychain deepseek restore # bring the plaintext back and delete the keychain items +``` + +The same operations are `GET`/`POST /api/providers/keychain`. After `store`, `config.json` holds +`"apiKey": "keychain:deepseek"` (pool entries `keychain:deepseek/`) and the secret lives under the +`opencodex.provider-api-key.v1` service in macOS Keychain, Windows Credential Manager, or the Linux +Secret Service. Backups of `config.json` therefore carry references only. Key rotation and failover +keep working: pool entries compare by reference, so a rotation never writes plaintext back. + +Before touching the config, `store` writes and reads back every entry; if the keychain is unavailable +or the read-back does not match, it refuses with 503 and leaves the file as it was. At request time +a reference that cannot be read yields no credential and one warning per key — there is no plaintext +fallback, by design. + +When not to opt in: a proxy running as a headless service (systemd, launchd, Task Scheduler) or in a +container usually has no unlocked keychain session, so requests would fail closed. Use an +`${ENV_VAR}` reference in the service environment there instead. Env references are left untouched +by `store`. diff --git a/docs-site/src/content/docs/reference/configuration/providers.md b/docs-site/src/content/docs/reference/configuration/providers.md index 4d72679d9c9..ba622739727 100644 --- a/docs-site/src/content/docs/reference/configuration/providers.md +++ b/docs-site/src/content/docs/reference/configuration/providers.md @@ -326,7 +326,7 @@ Providers can expose a built-in shorthand, such as `agy` for `google-antigravity | `preserveResponsesMessageMetadata?` | `boolean` | Default off. Preserve private `internal_chat_message_metadata_passthrough` on input items for explicitly trusted launcher relays, including custom native v1 compact requests. This forwards private turn/session metadata to that destination; do not enable for ordinary gateways. | | `dropResponsesReasoningItems?` | `boolean` | Remove replayed Responses `reasoning` items from continuation input entirely, including custom native v1 compact requests, instead of forwarding them with blanked content. This is lossy: summaries, item ids, and `encrypted_content` go with them, so no reasoning item from the previous turn remains in the forwarded input. It does not remove `previous_response_id`, so an upstream holding its own turn state may still reach it. Enable only for an upstream that rejects reasoning replay outright — the built-in Volcengine Ark Coding Plan preset sets it because validated tool continuations answered `400 InvalidParameter` otherwise. Set it to `false` on that provider to forward reasoning items again. | | `disabled?` | `boolean` | Keep the provider on disk but exclude it from routing and model/catalog listings. | -| `apiKey?` | `string` | API key, an `${ENV_VAR}` / `$ENV_VAR` reference, or a `keychain:` reference written by `ocx provider keychain store`. References resolve at request time. See [Storing keys in the OS keychain](#storing-keys-in-the-os-keychain). | +| `apiKey?` | `string` | API key, an `${ENV_VAR}` / `$ENV_VAR` reference, or a `keychain:` reference written by `ocx provider keychain store`. References resolve at request time. See [Storing keys in the OS keychain](/reference/configuration/keychain/). | | `apiKeyTransport?` | `"x-api-key" \| "bearer"` | Anthropic key header style. Defaults to native `x-api-key`; valid only for key-auth `anthropic` providers. | | `apiKeyPool?` | `ApiKeyPoolEntry[]` | Multi-key pool. `apiKey` mirrors the active entry; each item has `id`, `key`, optional `label`, and optional numeric `addedAt`. | | `apiKeyPoolStrategy?` | `"round-robin" \| "fill-first" \| "quota"` | How a warm key is chosen **before** the first attempt when the committed key is already cooling. Omitted keeps rotation reactive-only: the pool moves after a 429 or 401 and not before. `round-robin` takes the next key in the pool, `fill-first` keeps the first eligible one, and `quota` prefers the key with the most remaining headroom, falling back to `fill-first` order for a provider whose per-key quota is unknown. A healthy committed key is never overridden, so a manual key selection stands. | @@ -1590,60 +1590,11 @@ same mapping applies to a native `vercel/` selector: use the encoded `vercel-ai-gateway/vercel-` selector in OpenCodex and keep `vercel/` as the model key. -## Static model allowlists - ## Storing keys in the OS keychain -For Vertex, the dedicated API-key pool input (`POST /api/providers/keys`) accepts complete GCP -`service_account` or `authorized_user` credential JSON after the provider has been created. -The provider create/edit form and `ocx provider add --api-key` refuse credential JSON before -saving: create the provider without a key, then add the JSON through its key pool. Paste the -JSON contents, not a Windows, Unix, or relative file path. Multiple -JSON objects separated by commas or whitespace are stored together as separate pool entries; -the first becomes active. Configuration contains only `gcp-sa:` references, never the -credential JSON. Mixed JSON/literal-key bulk pastes are rejected as a whole; add literal keys -separately. Storage failures leave the configuration unchanged and report a fixed error without -echoing credential material. Empty marker accounts are invalid and do not fall back to host credentials. -Paste one credential object or a sequence of objects, without an enclosing array or quoted string. -Malformed credential text is rejected. If a selected stored credential is removed or becomes unreadable, -Vertex refuses the request even when it previously cached an access token for that credential. -Removing a key or provider, or replacing a provider with `ocx provider add --force`, deletes its unreferenced GCP credentials after the configuration is -saved; a credential referenced by another provider is retained. If OS-store cleanup fails, a -warning requests manual inspection without undoing the committed configuration removal. -Ordinary keychain store/restore actions leave GCP markers and JSON in the OS store. Restoring -credential JSON through a `keychain:` alias is refused, and cleanup preserves accounts still -referenced through either marker spelling. -Using a `keychain:` alias containing GCP JSON as an API key also refuses the request; select its -`gcp-sa:` reference for Vertex authentication instead. -Literal API keys must be plain token text: JSON objects, arrays and quoted JSON strings are refused -in literal-key resolution and restore, including wrappers containing escaped credential text. -Credential writes and marker persistence share the cleanup transaction lock, so concurrent -removal cannot delete the stored JSON between read-back and the marker commit. - -By default a provider's `apiKey` and `apiKeyPool` sit in `config.json` (mode 0600, atomic writes). -If you would rather keep the key material out of the file, move it into the OS credential store: - -```bash -ocx provider keychain deepseek status # store: file | env | keychain, and whether the keychain answers -ocx provider keychain deepseek store # move active key + pool into the OS keychain -ocx provider keychain deepseek restore # bring the plaintext back and delete the keychain items -``` +API keys and credential references can be stored in the OS credential store (macOS Keychain, Windows Credential Manager, Linux Secret Service) instead of `config.json`. See [Storing keys in the OS keychain](/reference/configuration/keychain/) for details on migration commands, storage rules, and headless-service caveats. -The same operations are `GET`/`POST /api/providers/keychain`. After `store`, `config.json` holds -`"apiKey": "keychain:deepseek"` (pool entries `keychain:deepseek/`) and the secret lives under the -`opencodex.provider-api-key.v1` service in macOS Keychain, Windows Credential Manager, or the Linux -Secret Service. Backups of `config.json` therefore carry references only. Key rotation and failover -keep working: pool entries compare by reference, so a rotation never writes plaintext back. - -Before touching the config, `store` writes and reads back every entry; if the keychain is unavailable -or the read-back does not match, it refuses with 503 and leaves the file as it was. At request time -a reference that cannot be read yields no credential and one warning per key — there is no plaintext -fallback, by design. - -When not to opt in: a proxy running as a headless service (systemd, launchd, Task Scheduler) or in a -container usually has no unlocked keychain session, so requests would fail closed. Use an -`${ENV_VAR}` reference in the service environment there instead. Env references are left untouched -by `store`. +## Static model allowlists The `zhipu-bigmodel-responses` preset seeds `glm-5.3`, `glm-5.3-flash` and `glm-5-turbo` with `liveModels: false` for `https://open.bigmodel.cn/api/v1`. Its static roster and From 8a3552a522cc05abe6e1fc1ccb50c74da9098857 Mon Sep 17 00:00:00 2001 From: Omar <13569609+omar391@users.noreply.github.com> Date: Wed, 28 Oct 2026 12:40:14 +0600 Subject: [PATCH 06/14] fix(responses): recalculate replay boundary when stripping canonical-only items --- src/adapters/openai-responses/passthrough.ts | 12 ++++++++- .../openai-responses/request-strips.ts | 11 +++++--- .../xai/xai-additional-tools.test.ts | 26 +++++++++++++++++++ 3 files changed, 44 insertions(+), 5 deletions(-) diff --git a/src/adapters/openai-responses/passthrough.ts b/src/adapters/openai-responses/passthrough.ts index 100f5bc5504..20cbea326c6 100644 --- a/src/adapters/openai-responses/passthrough.ts +++ b/src/adapters/openai-responses/passthrough.ts @@ -40,7 +40,7 @@ import { emittedFastWire, } from "../../providers/fastwire"; import { dropResponsesReasoningInputItems, mapRoutedResponsesReasoningEffort, normalizeConfiguredReasoningSummaryDelivery, sanitizeReasoningInputContent, stripDisabledReasoningSummaries, stripDisabledVerbosity, stripInternalReasoningEnabled, stripNoneReasoningSummary, stripUnsupportedReasoningSummaryDelivery } from "./reasoning"; -import { repairUrlCitationAnnotations, scrubOcxCompactionItems, stripCanonicalOnlyInputItems, stripCanonicalOnlyToolFields, stripCanonicalOnlyTopLevelFields, stripInternalChatMessageMetadataPassthrough, stripInvalidItemIds, stripItemIdsWhenUnstored, stripProviderInputItemMetadata, stripRejectedSamplingParams } from "./request-strips"; +import { isCanonicalOnlyInputItem, repairUrlCitationAnnotations, scrubOcxCompactionItems, stripCanonicalOnlyInputItems, stripCanonicalOnlyToolFields, stripCanonicalOnlyTopLevelFields, stripInternalChatMessageMetadataPassthrough, stripInvalidItemIds, stripItemIdsWhenUnstored, stripProviderInputItemMetadata, stripRejectedSamplingParams } from "./request-strips"; import { notePlaintextReasoningDropped } from "./plaintext-reasoning-notice"; import { stripCanonicalForwardPromptCacheOptions, stripDeprecatedPromptCacheRetention } from "./prompt-cache"; import { isPlainObject } from "./internal"; @@ -317,6 +317,16 @@ export function createResponsesPassthroughAdapter(provider: OcxProviderConfig): if (!isOpenAiOperatedResponsesDestination(provider)) { if (parsed._compactionRequest !== true && isPlainObject(outBody)) outBody = frameCompactionHistory(outBody); outBody = stripCanonicalOnlyTopLevelFields(outBody); + if ( + typeof replayPrefixLen === "number" + && replayPrefixLen > 0 + && isPlainObject(outBody) + && Array.isArray(outBody.input) + ) { + const prefixItems = outBody.input.slice(0, Math.min(outBody.input.length, Math.trunc(replayPrefixLen))); + const droppedInPrefix = prefixItems.filter(isCanonicalOnlyInputItem).length; + replayPrefixLen = Math.max(0, replayPrefixLen - droppedInPrefix); + } outBody = stripCanonicalOnlyInputItems(outBody); } outBody = promoteClientLoadedTools(outBody); diff --git a/src/adapters/openai-responses/request-strips.ts b/src/adapters/openai-responses/request-strips.ts index 2b5ab93d25c..263a81970e7 100644 --- a/src/adapters/openai-responses/request-strips.ts +++ b/src/adapters/openai-responses/request-strips.ts @@ -271,12 +271,15 @@ const CANONICAL_ONLY_INPUT_ITEM_TYPES: ReadonlySet = new Set([ * {@link stripCanonicalOnlyTopLevelFields}; the canonical and OpenAI-operated destinations keep * the items verbatim and in order. Returns the input unchanged when none is present. */ +export function isCanonicalOnlyInputItem(item: unknown): boolean { + return isPlainObject(item) + && typeof item.type === "string" && CANONICAL_ONLY_INPUT_ITEM_TYPES.has(item.type); +} + export function stripCanonicalOnlyInputItems(body: unknown): unknown { if (!isPlainObject(body) || !Array.isArray(body.input)) return body; - const isPrivate = (item: unknown): boolean => isPlainObject(item) - && typeof item.type === "string" && CANONICAL_ONLY_INPUT_ITEM_TYPES.has(item.type); - if (!body.input.some(isPrivate)) return body; - return { ...body, input: body.input.filter(item => !isPrivate(item)) }; + if (!body.input.some(isCanonicalOnlyInputItem)) return body; + return { ...body, input: body.input.filter(item => !isCanonicalOnlyInputItem(item)) }; } /** diff --git a/tests/providers/xai/xai-additional-tools.test.ts b/tests/providers/xai/xai-additional-tools.test.ts index db56f7b4506..3821bcbc49b 100644 --- a/tests/providers/xai/xai-additional-tools.test.ts +++ b/tests/providers/xai/xai-additional-tools.test.ts @@ -221,4 +221,30 @@ describe("xAI Responses additional_tools promotion", () => { expect(body.tools).toBeArray(); expect((body.tools as any[]).some(t => t.name === "lookup")).toBe(true); }); + + test("promoteResponsesAdditionalTools recalculates replay boundary when canonical-only items are dropped", () => { + const xaiProvider: OcxProviderConfig = { + adapter: "openai-responses", + baseUrl: "https://api.x.ai/v1", + headers: { authorization: "Bearer xai-key" }, + }; + + const raw = { + model: "grok-4.7", + input: [ + { type: "configuration_update", reasoning: { effort: "high" } }, + { type: "message", role: "user", content: "hello" }, + { type: "additional_tools", tools: [lookup] }, + ], + }; + + // Original prefix len is 2 (configuration_update + user message). + // When configuration_update is dropped, prefix len must adjust from 2 to 1. + // additional_tools moves from index 2 to index 1, which matches the new prefix boundary (>= 1), + // so its tools are promoted to top-level tools. + const { body } = build(xaiProvider, raw, 2); + expect(body.input).toEqual([{ type: "message", role: "user", content: "hello" }]); + expect(body.tools).toBeArray(); + expect((body.tools as any[]).some(t => t.name === "lookup")).toBe(true); + }); }); From fc2629e36729974bc0140d7bce5214aed34d8502 Mon Sep 17 00:00:00 2001 From: Omar <13569609+omar391@users.noreply.github.com> Date: Wed, 28 Oct 2026 20:17:17 +0600 Subject: [PATCH 07/14] fix(responses): isolate historical tool wrappers and preserve provenance across transformations - Finding 1: Isolate historical additional_tools emitted sets during namespace rewriting so historical declarations do not suppress current declarations or top-level tools. - Finding 2: Direct promoteClientLoadedTools to current-turn additional_tools wrappers (or fallback to top-level tools) rather than historical wrappers. - Finding 3: Track historical replay provenance via non-serializable symbol metadata and recompute the replay boundary dynamically after input array transformations (orphan repair, reasoning drops, adjacency normalization, canonical stripping). --- src/adapters/openai-responses/passthrough.ts | 50 ++++++------ .../openai-responses/tool-output-recovery.ts | 33 ++++++-- src/adapters/openai-responses/tool-schema.ts | 24 ++++-- src/adapters/opencode-go-additional-tools.ts | 6 +- src/responses/item-provenance.ts | 61 ++++++++++++++ src/responses/namespace-tool-compat.ts | 16 +++- .../xai/xai-additional-tools.test.ts | 79 +++++++++++++++++++ 7 files changed, 229 insertions(+), 40 deletions(-) create mode 100644 src/responses/item-provenance.ts diff --git a/src/adapters/openai-responses/passthrough.ts b/src/adapters/openai-responses/passthrough.ts index 20cbea326c6..4a866ebfdda 100644 --- a/src/adapters/openai-responses/passthrough.ts +++ b/src/adapters/openai-responses/passthrough.ts @@ -48,6 +48,7 @@ import { usageFromResponsesPayload } from "./usage"; import { normalizeToolSchemas, promoteClientLoadedTools, stripUnsupportedHostedTools } from "./tool-schema"; import { annotateEmptyResponsesToolOutputs, backfillWebSearchQueries, normalizeResponsesToolResultAdjacency, repairOrphanedInputItems, repairOversizedReplayCallIds, repairUnidentifiedToolOutputItems, restoreBridgedWebSearchCalls } from "./tool-output-recovery"; import { bridgeSearchReplayScope } from "../../responses/bridge-search-replay-cache"; +import { computeReplayPrefixLength, tagHistoricalInputItems } from "../../responses/item-provenance"; import { applyTierDecisionToResponsesBody, normalizeCanonicalForwardContinuationEnvelope, normalizeCanonicalForwardPromptEnvelope, stripCanonicalForwardSamplingParams, stripPreviousResponseId, stripStatefulResponsesParams, stripUnsupportedForwardParams } from "./canonical-forward"; import { normalizeImageGenClientTools, preferConfiguredHostedTools } from "./image-gen"; import { stripMuseSparkUnsupportedWebSearchFields, stripOpenAiOnlyWebSearchFields } from "./web-search"; @@ -214,6 +215,18 @@ export function createResponsesPassthroughAdapter(provider: OcxProviderConfig): // 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. + let replayPrefixLen = parsed._replayPrefixLen; + if ( + typeof replayPrefixLen === "number" + && replayPrefixLen > 0 + && isPlainObject(outBody) + && Array.isArray(outBody.input) + ) { + outBody = { + ...outBody, + input: tagHistoricalInputItems(outBody.input, replayPrefixLen), + }; + } if (provider.annotateEmptyToolOutputs === true) { outBody = annotateEmptyResponsesToolOutputs(outBody, true); } @@ -228,23 +241,21 @@ export function createResponsesPassthroughAdapter(provider: OcxProviderConfig): synthesizeMissingCallOutputs, repairOrphanOutputs, ); + if (isPlainObject(outBody) && Array.isArray(outBody.input)) { + replayPrefixLen = computeReplayPrefixLength(outBody.input, replayPrefixLen); + } } - let replayPrefixLen = parsed._replayPrefixLen; if (provider.dropResponsesReasoningItems === true) { - if ( - typeof replayPrefixLen === "number" - && replayPrefixLen > 0 - && isPlainObject(outBody) - && Array.isArray(outBody.input) - ) { - const prefixItems = outBody.input.slice(0, Math.min(outBody.input.length, Math.trunc(replayPrefixLen))); - const droppedInPrefix = prefixItems.filter(item => isPlainObject(item) && item.type === "reasoning").length; - replayPrefixLen = Math.max(0, replayPrefixLen - droppedInPrefix); - } outBody = dropResponsesReasoningInputItems(outBody); + if (isPlainObject(outBody) && Array.isArray(outBody.input)) { + replayPrefixLen = computeReplayPrefixLength(outBody.input, replayPrefixLen); + } } if (adjacentToolResults) { outBody = normalizeResponsesToolResultAdjacency(outBody); + if (isPlainObject(outBody) && Array.isArray(outBody.input)) { + replayPrefixLen = computeReplayPrefixLength(outBody.input, replayPrefixLen); + } } const hostedToolSubstitutions: NonNullable[number][] = []; if (forward) { @@ -317,19 +328,12 @@ export function createResponsesPassthroughAdapter(provider: OcxProviderConfig): if (!isOpenAiOperatedResponsesDestination(provider)) { if (parsed._compactionRequest !== true && isPlainObject(outBody)) outBody = frameCompactionHistory(outBody); outBody = stripCanonicalOnlyTopLevelFields(outBody); - if ( - typeof replayPrefixLen === "number" - && replayPrefixLen > 0 - && isPlainObject(outBody) - && Array.isArray(outBody.input) - ) { - const prefixItems = outBody.input.slice(0, Math.min(outBody.input.length, Math.trunc(replayPrefixLen))); - const droppedInPrefix = prefixItems.filter(isCanonicalOnlyInputItem).length; - replayPrefixLen = Math.max(0, replayPrefixLen - droppedInPrefix); - } outBody = stripCanonicalOnlyInputItems(outBody); + if (isPlainObject(outBody) && Array.isArray(outBody.input)) { + replayPrefixLen = computeReplayPrefixLength(outBody.input, replayPrefixLen); + } } - outBody = promoteClientLoadedTools(outBody); + outBody = promoteClientLoadedTools(outBody, replayPrefixLen); } if (!isCanonicalOpenAiForwardProvider(provider)) { const rewritten = rewriteRoutedCustomToolsForUpstream( @@ -351,7 +355,7 @@ export function createResponsesPassthroughAdapter(provider: OcxProviderConfig): // Codex 0.147 emits private namespace tool groups, while public/third-party Responses // gateways accept only flat tool variants. Run after custom/tool-search lowering so // namespace children already carry their final public kind before they are promoted. - const rewritten = rewriteRoutedNamespaceToolsForUpstream(outBody, convertedRoutedCustomToolNames); + const rewritten = rewriteRoutedNamespaceToolsForUpstream(outBody, convertedRoutedCustomToolNames, replayPrefixLen); outBody = rewritten.body; convertedRoutedNamespaceToolAliases = rewritten.aliases; // Preserve xAI's cached-only fail-closed semantics and image-search mapping before the diff --git a/src/adapters/openai-responses/tool-output-recovery.ts b/src/adapters/openai-responses/tool-output-recovery.ts index f2218ecb181..ef196405981 100644 --- a/src/adapters/openai-responses/tool-output-recovery.ts +++ b/src/adapters/openai-responses/tool-output-recovery.ts @@ -3,6 +3,7 @@ import { EMPTY_TOOL_OUTPUT_ANNOTATION, isWhitespaceOnlyTextPartArray } from "../ import { isPlainObject } from "./internal"; import { peekBridgeSearchReplay } from "../../responses/bridge-search-replay-cache"; import { externalTaskInputResponsesContent } from "../../responses/task-input"; +import { isHistoricalItem, markHistoricalItem } from "../../responses/item-provenance"; const MAX_RESPONSES_CALL_ID_LENGTH = 64; @@ -336,7 +337,7 @@ export function restoreBridgedWebSearchCalls(body: unknown, destinationScope: st if (memo && !occupiedCallIds.has(memo.callId)) { changed = true; occupiedCallIds.add(memo.callId); - restored.push({ + const fnCall: Record = { type: "function_call", ...(memo.sourceItemId ? { id: memo.sourceItemId } : {}), call_id: memo.callId, @@ -344,8 +345,18 @@ export function restoreBridgedWebSearchCalls(body: unknown, destinationScope: st // The bridge records the complete arguments text from the call's own done frame; the // empty-object fallback matches what a continuation leg would have sent. arguments: memo.argumentsText.length > 0 ? memo.argumentsText : "{}", - }); - restored.push({ type: "function_call_output", call_id: memo.callId, output: memo.output }); + }; + const fnOutput: Record = { + type: "function_call_output", + call_id: memo.callId, + output: memo.output, + }; + if (isHistoricalItem(item)) { + markHistoricalItem(fnCall); + markHistoricalItem(fnOutput); + } + restored.push(fnCall); + restored.push(fnOutput); continue; } } @@ -409,11 +420,15 @@ export function repairOrphanedInputItems( const knownNullOutput = callId.length > 0 && item.output == null; if (repairOrphanOutputs && !paired && (knownNullOutput || usableOutput)) { changed = true; - repaired.push({ + const msg: Record = { type: "message", role: "user", content: orphanedToolOutputContent(item.output, callId), - }); + }; + if (isHistoricalItem(item)) { + markHistoricalItem(msg); + } + repaired.push(msg); continue; } } @@ -429,9 +444,13 @@ export function repairOrphanedInputItems( const name = typeof item.name === "string" && item.name.length > 0 ? item.name : callId; const text = `[ocx] no tool result was recorded for "${name}"; execution status unknown — do not treat this as success, failure, or user-provided input.`; syntheticKeys.add(`${isFnCall ? "function" : "custom"}:${callId}`); - pendingSyntheticOutputs.push(isFnCall + const synthetic: Record = isFnCall ? { type: "function_call_output", call_id: callId, output: text } - : { type: "custom_tool_call_output", call_id: callId, output: text }); + : { type: "custom_tool_call_output", call_id: callId, output: text }; + if (isHistoricalItem(item)) { + markHistoricalItem(synthetic); + } + pendingSyntheticOutputs.push(synthetic); } } continue; diff --git a/src/adapters/openai-responses/tool-schema.ts b/src/adapters/openai-responses/tool-schema.ts index 12d11093cdd..a1123c12e86 100644 --- a/src/adapters/openai-responses/tool-schema.ts +++ b/src/adapters/openai-responses/tool-schema.ts @@ -8,6 +8,7 @@ import { XaiToolSchemaCompatibilityError, } from "../xai-tool-schema"; import { isPlainObject } from "./internal"; +import { isHistoricalItem } from "../../responses/item-provenance"; function normalizeFunctionToolSchema(tool: unknown, xaiTarget: boolean): unknown | undefined { if (!isPlainObject(tool) || tool.type !== "function") return tool; @@ -195,7 +196,7 @@ function mergeLoadedTools(declaredTools: unknown[], loadedTools: unknown[]): unk * serializing the raw request, so activate those returned definitions for upstreams that do not * implement the native deferred-loading handshake themselves. */ -export function promoteClientLoadedTools(body: unknown): unknown { +export function promoteClientLoadedTools(body: unknown, replayPrefixLength?: number): unknown { if (!isPlainObject(body) || !Array.isArray(body.input)) return body; const loadedTools = body.input.flatMap(item => @@ -210,17 +211,26 @@ export function promoteClientLoadedTools(body: unknown): unknown { return tools === body.tools ? body : { ...body, tools }; } - const additionalToolsIndex = body.input.findIndex(item => - isPlainObject(item) && item.type === "additional_tools" && Array.isArray(item.tools) - ); - if (additionalToolsIndex < 0) return { ...body, tools: mergeLoadedTools([], loadedTools) }; + const currentTurnStart = Number.isFinite(replayPrefixLength) + ? Math.max(0, Math.trunc(replayPrefixLength!)) + : 0; + const hasProvenance = body.input.some(isHistoricalItem); + + const currentAdditionalToolsIndex = body.input.findIndex((item, index) => { + const isHistorical = hasProvenance ? isHistoricalItem(item) : (index < currentTurnStart); + return !isHistorical && isPlainObject(item) && item.type === "additional_tools" && Array.isArray(item.tools); + }); + + if (currentAdditionalToolsIndex < 0) { + return { ...body, tools: mergeLoadedTools([], loadedTools) }; + } - const additionalTools = body.input[additionalToolsIndex]; + const additionalTools = body.input[currentAdditionalToolsIndex]; if (!isPlainObject(additionalTools) || !Array.isArray(additionalTools.tools)) return body; const tools = mergeLoadedTools(additionalTools.tools, loadedTools); if (tools === additionalTools.tools) return body; const input = [...body.input]; - input[additionalToolsIndex] = { ...additionalTools, tools }; + input[currentAdditionalToolsIndex] = { ...additionalTools, tools }; return { ...body, input }; } diff --git a/src/adapters/opencode-go-additional-tools.ts b/src/adapters/opencode-go-additional-tools.ts index 0763ec47699..64c3081f200 100644 --- a/src/adapters/opencode-go-additional-tools.ts +++ b/src/adapters/opencode-go-additional-tools.ts @@ -1,3 +1,5 @@ +import { isHistoricalItem } from "../responses/item-provenance"; + function isRecord(value: unknown): value is Record { return !!value && typeof value === "object" && !Array.isArray(value); } @@ -55,13 +57,15 @@ export function promoteResponsesAdditionalTools( const currentTurnStart = Number.isFinite(replayPrefixLength) ? Math.min(body.input.length, Math.max(0, Math.trunc(replayPrefixLength))) : 0; + const hasProvenance = body.input.some(isHistoricalItem); let changed = false; for (const [index, item] of body.input.entries()) { if (isRecord(item) && item.type === "additional_tools" && Array.isArray(item.tools)) { changed = true; // Replayed wrappers are conversation history, not authority for the current request. // Gateways cannot accept the wrapper itself, so remove it without promoting its catalog. - if (index < currentTurnStart) continue; + const isHistorical = hasProvenance ? isHistoricalItem(item) : (index < currentTurnStart); + if (isHistorical) continue; // Custom/search/namespace lowering already owns identity and deduplication. This pass // only moves declarations, including hosted tools that intentionally have no name. for (const tool of item.tools) promoted.push(tool); diff --git a/src/responses/item-provenance.ts b/src/responses/item-provenance.ts new file mode 100644 index 00000000000..6ecb9e10611 --- /dev/null +++ b/src/responses/item-provenance.ts @@ -0,0 +1,61 @@ +export const REPLAY_HISTORICAL_ITEM = Symbol.for("ocx.responses.replayHistoricalItem"); + +function isPlainObject(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +/** + * Mark an input item object as belonging to the historical replay prefix. + * Uses an enumerable symbol so it is preserved across shallow object spreads (`{ ...item }`), + * but completely ignored by `JSON.stringify` and `Object.keys`. + */ +export function markHistoricalItem(item: T): T { + Object.defineProperty(item, REPLAY_HISTORICAL_ITEM, { + value: true, + enumerable: true, + configurable: true, + writable: true, + }); + return item; +} + +/** + * Check whether an input item originated in the historical replay. + */ +export function isHistoricalItem(item: unknown): boolean { + return ( + typeof item === "object" + && item !== null + && (item as Record)[REPLAY_HISTORICAL_ITEM] === true + ); +} + +/** + * Tag the initial slice of items [0 ... prefixLength) as historical. + */ +export function tagHistoricalInputItems(input: unknown[], prefixLength: number): unknown[] { + if (!Array.isArray(input)) return input; + const prefix = Math.min(input.length, Math.max(0, Math.trunc(prefixLength))); + if (prefix <= 0) return input; + return input.map((item, index) => { + if (index < prefix && isPlainObject(item)) { + return markHistoricalItem({ ...item }); + } + return item; + }); +} + +/** + * Compute the current replay prefix length dynamically. + * If items carry provenance markers, the boundary is the index of the first non-historical item. + * Otherwise, falls back to the provided fallback prefix length. + */ +export function computeReplayPrefixLength(input: unknown[], fallback = 0): number { + if (!Array.isArray(input)) return fallback; + const hasProvenance = input.some(isHistoricalItem); + if (!hasProvenance) { + return Math.min(input.length, Math.max(0, Math.trunc(fallback))); + } + const firstLiveIndex = input.findIndex(item => !isHistoricalItem(item)); + return firstLiveIndex === -1 ? input.length : firstLiveIndex; +} diff --git a/src/responses/namespace-tool-compat.ts b/src/responses/namespace-tool-compat.ts index 24b26e8a2ef..5161b38aed1 100644 --- a/src/responses/namespace-tool-compat.ts +++ b/src/responses/namespace-tool-compat.ts @@ -1,6 +1,7 @@ import { dottedToolName, namespacedToolName } from "../types"; import { collectResponsesToolGroups } from "./tool-groups"; import { collectAmbiguousDottedAliases, dottedAliasIsUnambiguous } from "./tool-name-aliases"; +import { isHistoricalItem } from "./item-provenance"; export interface RoutedNamespaceToolIdentity { namespace: string; @@ -344,6 +345,7 @@ function rewriteInputItem(item: unknown, plan: NamespaceRewritePlan, emitted: Se export function rewriteRoutedNamespaceToolsForUpstream( body: unknown, convertedCustomToolNames?: ReadonlySet, + replayPrefixLength?: number, ): { body: unknown; aliases: Map; @@ -352,6 +354,11 @@ export function rewriteRoutedNamespaceToolsForUpstream( const groups = collectResponsesToolGroups(body); const plan = buildRewritePlan(groups); + const currentTurnStart = Number.isFinite(replayPrefixLength) + ? Math.max(0, Math.trunc(replayPrefixLength!)) + : 0; + const hasProvenance = Array.isArray(body.input) && body.input.some(isHistoricalItem); + // Deliberately not gated on the plan being non-empty: a turn whose catalog is absent can still // replay call items carrying a private `namespace`. const emitted = new Set(); @@ -360,8 +367,13 @@ export function rewriteRoutedNamespaceToolsForUpstream( let input = body.input; if (Array.isArray(body.input)) { let inputChanged = false; - const rewrittenInput = body.input.map(item => { - const next = rewriteInputItem(item, plan, emitted); + const historicalEmitted = new Set(); + const rewrittenInput = body.input.map((item, index) => { + const isHistorical = hasProvenance ? isHistoricalItem(item) : (index < currentTurnStart); + const itemEmitted = (isHistorical && isPlainObject(item) && item.type === "additional_tools") + ? historicalEmitted + : emitted; + const next = rewriteInputItem(item, plan, itemEmitted); if (next !== item) inputChanged = true; return next; }); diff --git a/tests/providers/xai/xai-additional-tools.test.ts b/tests/providers/xai/xai-additional-tools.test.ts index 3821bcbc49b..3d248d88cbe 100644 --- a/tests/providers/xai/xai-additional-tools.test.ts +++ b/tests/providers/xai/xai-additional-tools.test.ts @@ -247,4 +247,83 @@ describe("xAI Responses additional_tools promotion", () => { expect(body.tools).toBeArray(); expect((body.tools as any[]).some(t => t.name === "lookup")).toBe(true); }); +test("Finding 1: historical declarations do not deduplicate current declarations before promotion", () => { + const xaiProvider: OcxProviderConfig = { + adapter: "openai-responses", + baseUrl: "https://api.x.ai/v1", + headers: { authorization: "Bearer xai-key" }, + }; + const raw = { + model: "grok-4.7", + input: [ + { type: "additional_tools", tools: [lookup] }, + { type: "message", role: "user", content: "hello" }, + { type: "additional_tools", tools: [lookup] }, + ], + }; + const { body } = build(xaiProvider, raw, 2); + expect(body.tools).toBeArray(); + expect((body.tools as any[]).some(t => t.name === "lookup")).toBe(true); + }); + + test("Finding 1: historical namespaced declarations do not deduplicate current namespaced declarations before promotion", () => { + const xaiProvider: OcxProviderConfig = { + adapter: "openai-responses", + baseUrl: "https://api.x.ai/v1", + headers: { authorization: "Bearer xai-key" }, + }; + const namespacedLookup = { + type: "namespace", + name: "functions", + tools: [lookup], + }; + const raw = { + model: "grok-4.7", + input: [ + { type: "additional_tools", tools: [namespacedLookup] }, + { type: "message", role: "user", content: "hello" }, + { type: "additional_tools", tools: [namespacedLookup] }, + ], + }; + const { body } = build(xaiProvider, raw, 2); + expect(body.tools).toBeArray(); + expect((body.tools as any[]).some(t => t.name === "lookup")).toBe(true); + }); + + test("Finding 2: newly loaded search tools survive when first additional_tools wrapper is historical", () => { + const xaiProvider: OcxProviderConfig = { + adapter: "openai-responses", + baseUrl: "https://api.x.ai/v1", + headers: { authorization: "Bearer xai-key" }, + }; + const raw = { + model: "grok-4.7", + input: [ + { type: "additional_tools", tools: [{ type: "function", name: "old_tool" }] }, + { type: "tool_search_output", tools: [lookup] }, + ], + }; + const { body } = build(xaiProvider, raw, 1); + expect(body.tools).toBeArray(); + expect((body.tools as any[]).some(t => t.name === "lookup")).toBe(true); + }); + + test("Finding 3: synthesizing missing historical tool output does not reactivate retired tools", () => { + const pWithPairing: OcxProviderConfig = { + adapter: "openai-responses", + baseUrl: "https://api.x.ai/v1", + requiresPairedResponsesToolResults: true, + headers: { authorization: "Bearer xai-key" }, + }; + const raw = { + model: "grok-4.7", + input: [ + { type: "function_call", call_id: "c1", name: "some_call" }, + { type: "additional_tools", tools: [{ type: "function", name: "retired_tool" }] }, + ], + }; + const { body } = build(pWithPairing, raw, 2); + const retired = (body.tools as any[] ?? []).find(t => t.name === "retired_tool"); + expect(retired).toBeUndefined(); + }); }); From 6f7c117cf236b040473544858fd8f6f82b5471ba Mon Sep 17 00:00:00 2001 From: JUN <243035832+lidge-jun@users.noreply.github.com> Date: Thu, 29 Oct 2026 06:15:19 +0900 Subject: [PATCH 08/14] fix(responses): preserve live catalogs across replay rewrites Keep explicit live provenance when every historical item is removed and separate historical namespace plans from current declaration authority. Add three regressions and move xAI test registration into the providers shard. Co-authored-by: Omar <13569609+omar391@users.noreply.github.com> --- scripts/test-layout/layout-providers.json | 1 + scripts/test-layout/layout.json | 1 - src/adapters/openai-responses/tool-schema.ts | 4 ++-- src/adapters/opencode-go-additional-tools.ts | 4 ++-- src/responses/item-provenance.ts | 15 ++++++++---- src/responses/namespace-tool-compat.ts | 22 ++++++++++------- structure/transports/responses.md | 4 +++- .../xai/xai-additional-tools.test.ts | 24 ++++++++++++++++++- 8 files changed, 56 insertions(+), 19 deletions(-) diff --git a/scripts/test-layout/layout-providers.json b/scripts/test-layout/layout-providers.json index cabfce58fb6..47e13007f9e 100644 --- a/scripts/test-layout/layout-providers.json +++ b/scripts/test-layout/layout-providers.json @@ -1,4 +1,5 @@ { + "xai-additional-tools.test.ts": "providers/xai", "free-directory-runcrate.test.ts": "providers", "kiro-completion-with-real-tools.test.ts": "providers/kiro" } diff --git a/scripts/test-layout/layout.json b/scripts/test-layout/layout.json index ed0260374b6..f32c120378d 100644 --- a/scripts/test-layout/layout.json +++ b/scripts/test-layout/layout.json @@ -1964,7 +1964,6 @@ "codex-ws-handshake.test.ts": "responses", "codex-ws-handshake-validation.test.ts": "responses", "ws-upstream-socks5.test.ts": "responses", "ws-upstream.test.ts": "responses", - "xai-additional-tools.test.ts": "providers/xai", "xai-client.test.ts": "images", "xai-empty-catalog-tool-choice.test.ts": "providers/xai", "xai-no-stop.test.ts": "providers/xai", diff --git a/src/adapters/openai-responses/tool-schema.ts b/src/adapters/openai-responses/tool-schema.ts index a1123c12e86..0836ff90dea 100644 --- a/src/adapters/openai-responses/tool-schema.ts +++ b/src/adapters/openai-responses/tool-schema.ts @@ -8,7 +8,7 @@ import { XaiToolSchemaCompatibilityError, } from "../xai-tool-schema"; import { isPlainObject } from "./internal"; -import { isHistoricalItem } from "../../responses/item-provenance"; +import { hasItemProvenance, isHistoricalItem } from "../../responses/item-provenance"; function normalizeFunctionToolSchema(tool: unknown, xaiTarget: boolean): unknown | undefined { if (!isPlainObject(tool) || tool.type !== "function") return tool; @@ -214,7 +214,7 @@ export function promoteClientLoadedTools(body: unknown, replayPrefixLength?: num const currentTurnStart = Number.isFinite(replayPrefixLength) ? Math.max(0, Math.trunc(replayPrefixLength!)) : 0; - const hasProvenance = body.input.some(isHistoricalItem); + const hasProvenance = body.input.some(hasItemProvenance); const currentAdditionalToolsIndex = body.input.findIndex((item, index) => { const isHistorical = hasProvenance ? isHistoricalItem(item) : (index < currentTurnStart); diff --git a/src/adapters/opencode-go-additional-tools.ts b/src/adapters/opencode-go-additional-tools.ts index 64c3081f200..c76b0ad8a00 100644 --- a/src/adapters/opencode-go-additional-tools.ts +++ b/src/adapters/opencode-go-additional-tools.ts @@ -1,4 +1,4 @@ -import { isHistoricalItem } from "../responses/item-provenance"; +import { hasItemProvenance, isHistoricalItem } from "../responses/item-provenance"; function isRecord(value: unknown): value is Record { return !!value && typeof value === "object" && !Array.isArray(value); @@ -57,7 +57,7 @@ export function promoteResponsesAdditionalTools( const currentTurnStart = Number.isFinite(replayPrefixLength) ? Math.min(body.input.length, Math.max(0, Math.trunc(replayPrefixLength))) : 0; - const hasProvenance = body.input.some(isHistoricalItem); + const hasProvenance = body.input.some(hasItemProvenance); let changed = false; for (const [index, item] of body.input.entries()) { if (isRecord(item) && item.type === "additional_tools" && Array.isArray(item.tools)) { diff --git a/src/responses/item-provenance.ts b/src/responses/item-provenance.ts index 6ecb9e10611..0125c4a4f5f 100644 --- a/src/responses/item-provenance.ts +++ b/src/responses/item-provenance.ts @@ -30,16 +30,23 @@ export function isHistoricalItem(item: unknown): boolean { ); } +/** A live marker survives even when a rewrite removes every historical item. */ +export function hasItemProvenance(item: unknown): boolean { + return typeof item === "object" && item !== null + && typeof (item as Record)[REPLAY_HISTORICAL_ITEM] === "boolean"; +} + /** - * Tag the initial slice of items [0 ... prefixLength) as historical. + * Tag both historical and live items so an empty replay prefix remains distinguishable + * from an input that has never been tagged. */ export function tagHistoricalInputItems(input: unknown[], prefixLength: number): unknown[] { if (!Array.isArray(input)) return input; const prefix = Math.min(input.length, Math.max(0, Math.trunc(prefixLength))); if (prefix <= 0) return input; return input.map((item, index) => { - if (index < prefix && isPlainObject(item)) { - return markHistoricalItem({ ...item }); + if (isPlainObject(item)) { + return { ...item, [REPLAY_HISTORICAL_ITEM]: index < prefix }; } return item; }); @@ -52,7 +59,7 @@ export function tagHistoricalInputItems(input: unknown[], prefixLength: number): */ export function computeReplayPrefixLength(input: unknown[], fallback = 0): number { if (!Array.isArray(input)) return fallback; - const hasProvenance = input.some(isHistoricalItem); + const hasProvenance = input.some(hasItemProvenance); if (!hasProvenance) { return Math.min(input.length, Math.max(0, Math.trunc(fallback))); } diff --git a/src/responses/namespace-tool-compat.ts b/src/responses/namespace-tool-compat.ts index 5161b38aed1..0c24ac3e87e 100644 --- a/src/responses/namespace-tool-compat.ts +++ b/src/responses/namespace-tool-compat.ts @@ -1,7 +1,7 @@ import { dottedToolName, namespacedToolName } from "../types"; import { collectResponsesToolGroups } from "./tool-groups"; import { collectAmbiguousDottedAliases, dottedAliasIsUnambiguous } from "./tool-name-aliases"; -import { isHistoricalItem } from "./item-provenance"; +import { hasItemProvenance, isHistoricalItem } from "./item-provenance"; export interface RoutedNamespaceToolIdentity { namespace: string; @@ -351,13 +351,19 @@ export function rewriteRoutedNamespaceToolsForUpstream( aliases: Map; } { if (!isPlainObject(body)) return { body, aliases: new Map() }; - const groups = collectResponsesToolGroups(body); - const plan = buildRewritePlan(groups); - const currentTurnStart = Number.isFinite(replayPrefixLength) ? Math.max(0, Math.trunc(replayPrefixLength!)) : 0; - const hasProvenance = Array.isArray(body.input) && body.input.some(isHistoricalItem); + const hasProvenance = Array.isArray(body.input) && body.input.some(hasItemProvenance); + const isHistorical = (item: unknown, index: number) => + hasProvenance ? isHistoricalItem(item) : index < currentTurnStart; + const groups = collectResponsesToolGroups(Array.isArray(body.input) + ? { ...body, input: body.input.filter((item, index) => !isHistorical(item, index)) } + : body); + const plan = buildRewritePlan(groups); + const historicalPlan = buildRewritePlan(collectResponsesToolGroups({ + input: Array.isArray(body.input) ? body.input.filter(isHistorical) : [], + })); // Deliberately not gated on the plan being non-empty: a turn whose catalog is absent can still // replay call items carrying a private `namespace`. @@ -369,11 +375,11 @@ export function rewriteRoutedNamespaceToolsForUpstream( let inputChanged = false; const historicalEmitted = new Set(); const rewrittenInput = body.input.map((item, index) => { - const isHistorical = hasProvenance ? isHistoricalItem(item) : (index < currentTurnStart); - const itemEmitted = (isHistorical && isPlainObject(item) && item.type === "additional_tools") + const historicalCatalog = isHistorical(item, index) && isPlainObject(item) && item.type === "additional_tools"; + const itemEmitted = historicalCatalog ? historicalEmitted : emitted; - const next = rewriteInputItem(item, plan, itemEmitted); + const next = rewriteInputItem(item, historicalCatalog ? historicalPlan : plan, itemEmitted); if (next !== item) inputChanged = true; return next; }); diff --git a/structure/transports/responses.md b/structure/transports/responses.md index e33af39428f..ceb5eb164ae 100644 --- a/structure/transports/responses.md +++ b/structure/transports/responses.md @@ -207,7 +207,9 @@ Codex Desktop packages client tools inside an `additional_tools` input item; str rejecting non-ResponseItem input types—including official xAI endpoints (`api.x.ai`, `cli-chat-proxy.grok.com`), OpenCode Go, and custom providers opting in with `promoteAdditionalTools: true`—promote declarations to top-level `tools` and strip the wrapper -from `input` while preserving turn boundaries via `_replayPrefixLen`. +from `input`. `src/responses/item-provenance.ts` tags historical and live input objects with JSON-invisible symbols before replay repair; +boundaries survive removals, insertions and adjacency rewrites, including removal of every historical item. +Historical wrappers retain call-rewriting context with separate rewrite plans and declaration sets; loaded search tools join the live catalog or top-level tools. Only the ROOT rejects a union, so exclusivity is preserved by moving it down rather than widening it: a root `oneOf` whose branches differ in one property becomes that property's `oneOf`, or its `anyOf` diff --git a/tests/providers/xai/xai-additional-tools.test.ts b/tests/providers/xai/xai-additional-tools.test.ts index 3d248d88cbe..3ecce2c2e61 100644 --- a/tests/providers/xai/xai-additional-tools.test.ts +++ b/tests/providers/xai/xai-additional-tools.test.ts @@ -247,7 +247,19 @@ describe("xAI Responses additional_tools promotion", () => { expect(body.tools).toBeArray(); expect((body.tools as any[]).some(t => t.name === "lookup")).toBe(true); }); -test("Finding 1: historical declarations do not deduplicate current declarations before promotion", () => { + test.each(["configuration_update", "reasoning"])("Finding 3: removing the entire %s replay prefix preserves current tools", type => { + const provider: OcxProviderConfig = { + adapter: "openai-responses", + baseUrl: "https://api.x.ai/v1", + dropResponsesReasoningItems: true, + }; + const { body } = build(provider, { + input: [{ type, content: "historical" }, { type: "additional_tools", tools: [lookup] }], + }, 1); + expect(body.tools).toEqual([lookup]); + }); + + test("Finding 1: historical declarations do not deduplicate current declarations before promotion", () => { const xaiProvider: OcxProviderConfig = { adapter: "openai-responses", baseUrl: "https://api.x.ai/v1", @@ -308,6 +320,16 @@ test("Finding 1: historical declarations do not deduplicate current declarations expect((body.tools as any[]).some(t => t.name === "lookup")).toBe(true); }); + test("Finding 1: a historical bare declaration does not suppress the live functions namespace", () => { + const { body } = build({ adapter: "openai-responses", baseUrl: "https://api.x.ai/v1" }, { + input: [ + { type: "additional_tools", tools: [lookup] }, + { type: "additional_tools", tools: [{ type: "namespace", name: "functions", tools: [lookup] }] }, + ], + }, 1); + expect(body.tools).toEqual([lookup]); + }); + test("Finding 3: synthesizing missing historical tool output does not reactivate retired tools", () => { const pWithPairing: OcxProviderConfig = { adapter: "openai-responses", From 8bdb199a189ecc31d56f1d316db9456e7a64fd2d Mon Sep 17 00:00:00 2001 From: JUN <243035832+lidge-jun@users.noreply.github.com> Date: Thu, 29 Oct 2026 06:57:15 +0900 Subject: [PATCH 09/14] fix(responses): preserve replay provenance before input rewrites Tag cloned input before identity repair and preserve historical/live markers through agent-message replacement, prompt-cache cleanup, and recursive custom-tool lowering. Lock in canonical-forward live tool promotion, destination/credential admission, and reconstruction regressions. Co-authored-by: Omar <13569609+omar391@users.noreply.github.com> --- src/adapters/openai-responses/passthrough.ts | 30 ++++---- src/adapters/openai-responses/prompt-cache.ts | 4 +- src/adapters/routed-agent-messages.ts | 3 + src/responses/custom-tool-compat.ts | 3 +- structure/transports/responses-wire-shapes.md | 2 +- tests/adapters/routed-agent-messages.test.ts | 12 ++++ .../xai/xai-additional-tools.test.ts | 70 +++++++++++++++++++ 7 files changed, 107 insertions(+), 17 deletions(-) diff --git a/src/adapters/openai-responses/passthrough.ts b/src/adapters/openai-responses/passthrough.ts index 4a866ebfdda..256f70c13ab 100644 --- a/src/adapters/openai-responses/passthrough.ts +++ b/src/adapters/openai-responses/passthrough.ts @@ -175,8 +175,22 @@ export function createResponsesPassthroughAdapter(provider: OcxProviderConfig): let plaintextV2AgentMessageAliasedToolNames: ReadonlySet | undefined; let convertedMuseToolNameAliases: Map | undefined; const unexpandedMiss = !!parsed.previousResponseId && parsed._previousResponseInputExpanded !== true; - let outBody = stripPreviousResponseId( - parsed._rawBody, + let replayPrefixLen = parsed._replayPrefixLen; + let outBody = parsed._rawBody; + // Clone and tag before any input rewrite can remove, replace, or reorder replay items. + if ( + typeof replayPrefixLen === "number" + && replayPrefixLen > 0 + && isPlainObject(outBody) + && Array.isArray(outBody.input) + ) { + outBody = { + ...outBody, + input: tagHistoricalInputItems(outBody.input, replayPrefixLen), + }; + } + outBody = stripPreviousResponseId( + outBody, forward || parsed._previousResponseInputExpanded === true, ); if (!forward) outBody = normalizeRoutedAgentMessages(outBody, { @@ -215,18 +229,6 @@ export function createResponsesPassthroughAdapter(provider: OcxProviderConfig): // 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. - let replayPrefixLen = parsed._replayPrefixLen; - if ( - typeof replayPrefixLen === "number" - && replayPrefixLen > 0 - && isPlainObject(outBody) - && Array.isArray(outBody.input) - ) { - outBody = { - ...outBody, - input: tagHistoricalInputItems(outBody.input, replayPrefixLen), - }; - } if (provider.annotateEmptyToolOutputs === true) { outBody = annotateEmptyResponsesToolOutputs(outBody, true); } diff --git a/src/adapters/openai-responses/prompt-cache.ts b/src/adapters/openai-responses/prompt-cache.ts index dff927dd17f..a9b4b2a6e63 100644 --- a/src/adapters/openai-responses/prompt-cache.ts +++ b/src/adapters/openai-responses/prompt-cache.ts @@ -71,7 +71,9 @@ export function stripPromptCacheBreakpoints( if (!isPlainObject(value)) return { value, changed: false, complete: true }; let changed = Object.hasOwn(value, "prompt_cache_breakpoint"); - const next: Record = {}; + // Keep symbol provenance when rebuilding an input item or one of its nested fields. + const next: Record = { ...value }; + delete next.prompt_cache_breakpoint; for (const [key, entry] of Object.entries(value)) { if (key === "prompt_cache_breakpoint") continue; const rewritten = stripPromptCacheBreakpoints(entry, state, depth + 1); diff --git a/src/adapters/routed-agent-messages.ts b/src/adapters/routed-agent-messages.ts index 2ca67162a72..e9c57d17cc6 100644 --- a/src/adapters/routed-agent-messages.ts +++ b/src/adapters/routed-agent-messages.ts @@ -1,3 +1,5 @@ +import { hasItemProvenance, isHistoricalItem, REPLAY_HISTORICAL_ITEM } from "../responses/item-provenance"; + /** * `agent_message` is Codex's private multi-agent input item: it exists only in the ChatGPT * Codex backend's schema. Codex replays every sub-agent reply in the history it sends, so @@ -36,6 +38,7 @@ export function normalizeRoutedAgentMessages( changed = true; return { type: "message", role: "user", + ...(hasItemProvenance(message) ? { [REPLAY_HISTORICAL_ITEM]: isHistoricalItem(message) } : {}), content: [ ...(Object.keys(identities).length ? [{ type: "input_text", text: `Agent message ${JSON.stringify(identities)}` }] : []), ...content, diff --git a/src/responses/custom-tool-compat.ts b/src/responses/custom-tool-compat.ts index 4b15bc9f9ff..30a894f06c2 100644 --- a/src/responses/custom-tool-compat.ts +++ b/src/responses/custom-tool-compat.ts @@ -232,7 +232,8 @@ function rewriteForUpstream( } let changed = false; - const next: Record = {}; + // Recursive tool lowering must retain the input item's historical/live symbol marker. + const next: Record = { ...value }; for (const [key, entry] of Object.entries(value)) { const rewritten = rewriteForUpstream(entry, names, callIds); next[key] = rewritten; diff --git a/structure/transports/responses-wire-shapes.md b/structure/transports/responses-wire-shapes.md index df91331cbb5..77ebbcc8b12 100644 --- a/structure/transports/responses-wire-shapes.md +++ b/structure/transports/responses-wire-shapes.md @@ -163,7 +163,7 @@ and exact `/zen/go/v1/responses`. Normal and endpoint-inclusive bases or split ` configurations agree; a custom path resolving to Zen or elsewhere does not acquire Go placement. Credentials, query, fragment, foreign hosts and other resource paths are excluded. The existing URL constructor canonicalizes trailing base slashes before this check. Malformed wrappers remain unchanged and -the shared mixed-ciphertext agent-message gate remains fail-closed. +the shared mixed-ciphertext agent-message gate remains fail-closed. Responses passthrough clones and tags historical and current input items before any rewrite, including identity repair; symbol provenance survives message replacement, prompt-cache cleanup, filtering and tool lowering without entering serialized requests. Promotion uses provenance rather than positions in shortened input. The canonical `opencode-go` registry entry defaults to `statelessResponses: true` because Go rejects reasoning ciphertext combined with `previous_response_id` (#3838). Existing derive diff --git a/tests/adapters/routed-agent-messages.test.ts b/tests/adapters/routed-agent-messages.test.ts index 2a44d118622..4e247cd1615 100644 --- a/tests/adapters/routed-agent-messages.test.ts +++ b/tests/adapters/routed-agent-messages.test.ts @@ -1,12 +1,24 @@ import { expect, test } from "bun:test"; import { createResponsesPassthroughAdapter } from "../../src/adapters/openai-responses"; import { normalizeRoutedAgentMessages } from "../../src/adapters/routed-agent-messages"; +import { hasItemProvenance, isHistoricalItem, tagHistoricalInputItems } from "../../src/responses/item-provenance"; import { parseRequest } from "../../src/responses/parser"; import { routeModel } from "../../src/router"; import { createTranslatorBudget } from "../../src/lib/translator-budget"; import type { OcxProviderConfig } from "../../src/types"; const base: OcxProviderConfig = { adapter: "openai-responses", baseUrl: "https://opencode.ai/zen/go/v1", authMode: "key", apiKey: "synthetic-key" }; + +test("agent-message replacement carries historical and live provenance", () => { + const input = tagHistoricalInputItems([ + { type: "agent_message", content: [{ type: "input_text", text: "past" }] }, + { type: "agent_message", content: [{ type: "input_text", text: "current" }] }, + ], 1); + const result = normalizeRoutedAgentMessages({ input }) as { input: unknown[] }; + expect(result.input.map(hasItemProvenance)).toEqual([true, true]); + expect(result.input.map(isHistoricalItem)).toEqual([true, false]); + expect(JSON.stringify(result)).not.toContain("replayHistoricalItem"); +}); const body = () => ({ model: "muse-spark-1.3-contributor", input: [{ type: "agent_message", id: "amsg_test", author: "/root/reader", recipient: "/root/checker", content: [{ type: "input_text", text: "Exact assignment\nwith lines." }] }], stream: true }); test("Responses converts plaintext task and peer messages without mutating replay or losing routing identities", async () => { diff --git a/tests/providers/xai/xai-additional-tools.test.ts b/tests/providers/xai/xai-additional-tools.test.ts index 3ecce2c2e61..26f93c6d581 100644 --- a/tests/providers/xai/xai-additional-tools.test.ts +++ b/tests/providers/xai/xai-additional-tools.test.ts @@ -6,6 +6,9 @@ import { promoteResponsesAdditionalTools, } from "../../../src/adapters/opencode-go-additional-tools"; import { withTestTranslatorBudget } from "../../helpers/translator-budget"; +import { CODEX_GPT5_IDENTITY_LINE, identifyRoutedModel } from "../../../src/adapters/identity"; +import { tagHistoricalInputItems } from "../../../src/responses/item-provenance"; +import { rewriteRoutedCustomToolsForUpstream } from "../../../src/responses/custom-tool-compat"; import type { OcxProviderConfig } from "../../../src/types"; const lookup = { @@ -29,6 +32,73 @@ function build(providerConfig: OcxProviderConfig, rawBody: Record { + test("custom-tool lowering carries historical and live provenance into promotion", () => { + const input = tagHistoricalInputItems([ + { type: "additional_tools", tools: [{ type: "custom", name: "retired", description: "past tool" }] }, + { type: "additional_tools", tools: [lookup] }, + ], 1); + const lowered = rewriteRoutedCustomToolsForUpstream({ input }, false); + const promoted = promoteResponsesAdditionalTools(lowered.body) as { tools: unknown[] }; + expect(promoted.tools).toEqual([lookup]); + }); + + test("custom-tool lowering cannot reactivate a historical wrapper", () => { + const { body } = build({ + adapter: "openai-responses", baseUrl: "https://api.x.ai/v1", supportsResponsesCustomTools: false, + }, { input: [ + { type: "additional_tools", tools: [{ type: "custom", name: "retired", description: "past tool" }] }, + { type: "additional_tools", tools: [lookup] }, + ] }, 1); + expect(body.tools).toEqual([lookup]); + expect(body.input).toEqual([]); + }); + + test("forward cache-marker cleanup cannot promote historical declarations", () => { + const retired = { ...lookup, name: "retired", prompt_cache_breakpoint: true }; + const raw = { input: [ + { type: "additional_tools", tools: [retired] }, + { type: "additional_tools", tools: [lookup] }, + ] }; + const before = structuredClone(raw); + const { body } = build({ + adapter: "openai-responses", baseUrl: "https://chatgpt.com/backend-api/codex", + authMode: "forward", promoteAdditionalTools: true, + }, raw, 1); + expect(body.tools).toEqual([lookup]); + expect(body.input).toEqual([]); + expect(raw).toEqual(before); + }); + + test("forward opt-in preserves live tools after removing the historical identity message", () => { + const provider: OcxProviderConfig = { + adapter: "openai-responses", + baseUrl: "https://chatgpt.com/backend-api/codex", + authMode: "forward", + promoteAdditionalTools: true, + }; + const live = { ...lookup, name: "live" }; + const raw = { + input: [ + { type: "message", role: "developer", content: identifyRoutedModel(CODEX_GPT5_IDENTITY_LINE, "old-model") }, + { type: "additional_tools", tools: [live] }, + ], + }; + const before = structuredClone(raw); + const adapter = withTestTranslatorBudget(createProductionAdapter(provider)); + const request = adapter.buildRequest({ + modelId: "gpt-5", context: { messages: [] }, stream: false, options: {}, + _rawBody: raw, _replayPrefixLen: 1, + }, { headers: new Headers({ authorization: "Bearer caller-fixture", "chatgpt-account-id": "caller-account" }) }); + const body = JSON.parse(request.body); + expect(body.tools).toEqual([live]); + expect(body.input).toEqual([]); + expect(request.url).toBe("https://chatgpt.com/backend-api/codex/responses"); + expect(new Headers(request.headers).get("authorization")).toBe("Bearer caller-fixture"); + expect(new Headers(request.headers).get("chatgpt-account-id")).toBe("caller-account"); + expect(raw).toEqual(before); + for (const item of raw.input) expect(Object.getOwnPropertySymbols(item)).toEqual([]); + }); + test("isXaiResponsesUrl matches official xAI endpoints and rejects lookalikes", () => { expect(isXaiResponsesUrl("https://api.x.ai/v1/responses")).toBe(true); expect(isXaiResponsesUrl("https://api.x.ai:443/v1/responses")).toBe(true); From 48a8e31fdcbb79b9d654cca6c90855ac1ce3000c Mon Sep 17 00:00:00 2001 From: JUN <243035832+lidge-jun@users.noreply.github.com> Date: Thu, 29 Oct 2026 08:35:54 +0900 Subject: [PATCH 10/14] fix(responses): keep replay provenance authoritative after rewrites Preserve historical and live tags through task-input replacement, orphan repair, synthesized outputs and bridge restoration. Select xAI search and all transformed catalogs using item tags. Remove numeric-prefix reuse after serialization in Zed promotion and hosted-call authorization. Regression: round-2 reviewer probe 1 pass/1 fail at 8bdb199a18 -> 2 pass/0 fail. Permanent regression initially 1 pass/8 fail; final 10 pass/0 fail including bridge restoration. Focused batches: 219 pass/0 fail and 317 pass/0 fail. Typecheck, structure, privacy, file-size and test-layout checks pass. Co-authored-by: Omar <13569609+omar391@users.noreply.github.com> --- scripts/test-layout/layout-providers.json | 1 + src/adapters/openai-responses/passthrough.ts | 24 ++--- .../openai-responses/tool-output-recovery.ts | 18 ++-- src/adapters/xai-web-search.ts | 9 +- src/adapters/zed-additional-tools.ts | 14 +-- src/adapters/zed.ts | 7 +- src/responses/item-provenance.ts | 8 ++ src/server/responses-undeclared-tool-guard.ts | 4 + src/server/responses/passthrough-dispatch.ts | 7 +- structure/transports/responses-wire-shapes.md | 2 +- structure/transports/responses.md | 2 +- tests/fixtures/test-layout-expected.json | 1 + .../xai/xai-replay-provenance.test.ts | 94 +++++++++++++++++++ 13 files changed, 139 insertions(+), 52 deletions(-) create mode 100644 tests/providers/xai/xai-replay-provenance.test.ts diff --git a/scripts/test-layout/layout-providers.json b/scripts/test-layout/layout-providers.json index 47e13007f9e..df4ee8e5e09 100644 --- a/scripts/test-layout/layout-providers.json +++ b/scripts/test-layout/layout-providers.json @@ -1,5 +1,6 @@ { "xai-additional-tools.test.ts": "providers/xai", + "xai-replay-provenance.test.ts": "providers/xai", "free-directory-runcrate.test.ts": "providers", "kiro-completion-with-real-tools.test.ts": "providers/kiro" } diff --git a/src/adapters/openai-responses/passthrough.ts b/src/adapters/openai-responses/passthrough.ts index 256f70c13ab..e4eb907d5fc 100644 --- a/src/adapters/openai-responses/passthrough.ts +++ b/src/adapters/openai-responses/passthrough.ts @@ -48,7 +48,7 @@ import { usageFromResponsesPayload } from "./usage"; import { normalizeToolSchemas, promoteClientLoadedTools, stripUnsupportedHostedTools } from "./tool-schema"; import { annotateEmptyResponsesToolOutputs, backfillWebSearchQueries, normalizeResponsesToolResultAdjacency, repairOrphanedInputItems, repairOversizedReplayCallIds, repairUnidentifiedToolOutputItems, restoreBridgedWebSearchCalls } from "./tool-output-recovery"; import { bridgeSearchReplayScope } from "../../responses/bridge-search-replay-cache"; -import { computeReplayPrefixLength, tagHistoricalInputItems } from "../../responses/item-provenance"; +import { tagHistoricalInputItems } from "../../responses/item-provenance"; import { applyTierDecisionToResponsesBody, normalizeCanonicalForwardContinuationEnvelope, normalizeCanonicalForwardPromptEnvelope, stripCanonicalForwardSamplingParams, stripPreviousResponseId, stripStatefulResponsesParams, stripUnsupportedForwardParams } from "./canonical-forward"; import { normalizeImageGenClientTools, preferConfiguredHostedTools } from "./image-gen"; import { stripMuseSparkUnsupportedWebSearchFields, stripOpenAiOnlyWebSearchFields } from "./web-search"; @@ -175,7 +175,7 @@ export function createResponsesPassthroughAdapter(provider: OcxProviderConfig): let plaintextV2AgentMessageAliasedToolNames: ReadonlySet | undefined; let convertedMuseToolNameAliases: Map | undefined; const unexpandedMiss = !!parsed.previousResponseId && parsed._previousResponseInputExpanded !== true; - let replayPrefixLen = parsed._replayPrefixLen; + const replayPrefixLen = parsed._replayPrefixLen; let outBody = parsed._rawBody; // Clone and tag before any input rewrite can remove, replace, or reorder replay items. if ( @@ -243,21 +243,12 @@ export function createResponsesPassthroughAdapter(provider: OcxProviderConfig): synthesizeMissingCallOutputs, repairOrphanOutputs, ); - if (isPlainObject(outBody) && Array.isArray(outBody.input)) { - replayPrefixLen = computeReplayPrefixLength(outBody.input, replayPrefixLen); - } } if (provider.dropResponsesReasoningItems === true) { outBody = dropResponsesReasoningInputItems(outBody); - if (isPlainObject(outBody) && Array.isArray(outBody.input)) { - replayPrefixLen = computeReplayPrefixLength(outBody.input, replayPrefixLen); - } } if (adjacentToolResults) { outBody = normalizeResponsesToolResultAdjacency(outBody); - if (isPlainObject(outBody) && Array.isArray(outBody.input)) { - replayPrefixLen = computeReplayPrefixLength(outBody.input, replayPrefixLen); - } } const hostedToolSubstitutions: NonNullable[number][] = []; if (forward) { @@ -331,11 +322,8 @@ export function createResponsesPassthroughAdapter(provider: OcxProviderConfig): if (parsed._compactionRequest !== true && isPlainObject(outBody)) outBody = frameCompactionHistory(outBody); outBody = stripCanonicalOnlyTopLevelFields(outBody); outBody = stripCanonicalOnlyInputItems(outBody); - if (isPlainObject(outBody) && Array.isArray(outBody.input)) { - replayPrefixLen = computeReplayPrefixLength(outBody.input, replayPrefixLen); - } } - outBody = promoteClientLoadedTools(outBody, replayPrefixLen); + outBody = promoteClientLoadedTools(outBody); } if (!isCanonicalOpenAiForwardProvider(provider)) { const rewritten = rewriteRoutedCustomToolsForUpstream( @@ -357,14 +345,14 @@ export function createResponsesPassthroughAdapter(provider: OcxProviderConfig): // Codex 0.147 emits private namespace tool groups, while public/third-party Responses // gateways accept only flat tool variants. Run after custom/tool-search lowering so // namespace children already carry their final public kind before they are promoted. - const rewritten = rewriteRoutedNamespaceToolsForUpstream(outBody, convertedRoutedCustomToolNames, replayPrefixLen); + const rewritten = rewriteRoutedNamespaceToolsForUpstream(outBody, convertedRoutedCustomToolNames); outBody = rewritten.body; convertedRoutedNamespaceToolAliases = rewritten.aliases; // Preserve xAI's cached-only fail-closed semantics and image-search mapping before the // generic capability fallback removes the private OpenAI fields. Grok relayed through // OpenCode Go speaks the same dialect, which the final model id and URL identify. outBody = normalizeXaiResponsesWebSearch(outBody, provider, { modelId: parsed.modelId, responseUrl: url }); - outBody = injectXaiResponsesXSearch(outBody, provider, replayPrefixLen); + outBody = injectXaiResponsesXSearch(outBody, provider); // xAI and explicitly classified compatible gateways reject these OpenAI web_search // extensions. Keep them for OpenAI API-key traffic and unclassified gateways. if (provider.supportsOpenAiWebSearchToolFields === false) { @@ -390,7 +378,7 @@ export function createResponsesPassthroughAdapter(provider: OcxProviderConfig): || isXaiResponsesUrl(url) )) ) { - outBody = promoteResponsesAdditionalTools(outBody, replayPrefixLen); + outBody = promoteResponsesAdditionalTools(outBody); } // Same native-v2/portable predicate as the routedCompaction gate in handleResponses(): // neither forward auth nor native v1 endpoint support implies trigger support. The diff --git a/src/adapters/openai-responses/tool-output-recovery.ts b/src/adapters/openai-responses/tool-output-recovery.ts index ef196405981..2605e1fdab7 100644 --- a/src/adapters/openai-responses/tool-output-recovery.ts +++ b/src/adapters/openai-responses/tool-output-recovery.ts @@ -3,7 +3,7 @@ import { EMPTY_TOOL_OUTPUT_ANNOTATION, isWhitespaceOnlyTextPartArray } from "../ import { isPlainObject } from "./internal"; import { peekBridgeSearchReplay } from "../../responses/bridge-search-replay-cache"; import { externalTaskInputResponsesContent } from "../../responses/task-input"; -import { isHistoricalItem, markHistoricalItem } from "../../responses/item-provenance"; +import { copyItemProvenance } from "../../responses/item-provenance"; const MAX_RESPONSES_CALL_ID_LENGTH = 64; @@ -351,10 +351,8 @@ export function restoreBridgedWebSearchCalls(body: unknown, destinationScope: st call_id: memo.callId, output: memo.output, }; - if (isHistoricalItem(item)) { - markHistoricalItem(fnCall); - markHistoricalItem(fnOutput); - } + copyItemProvenance(item, fnCall); + copyItemProvenance(item, fnOutput); restored.push(fnCall); restored.push(fnOutput); continue; @@ -408,7 +406,7 @@ export function repairOrphanedInputItems( const taskInput = isFnOutput && !hasStringCallId ? externalTaskInputResponsesContent(item) : undefined; if (taskInput) { changed = true; - repaired.push({ type: "message", role: "user", content: taskInput }); + repaired.push(copyItemProvenance(item, { type: "message", role: "user", content: taskInput })); continue; } const callId = typeof item.call_id === "string" ? item.call_id : ""; @@ -425,9 +423,7 @@ export function repairOrphanedInputItems( role: "user", content: orphanedToolOutputContent(item.output, callId), }; - if (isHistoricalItem(item)) { - markHistoricalItem(msg); - } + copyItemProvenance(item, msg); repaired.push(msg); continue; } @@ -447,9 +443,7 @@ export function repairOrphanedInputItems( const synthetic: Record = isFnCall ? { type: "function_call_output", call_id: callId, output: text } : { type: "custom_tool_call_output", call_id: callId, output: text }; - if (isHistoricalItem(item)) { - markHistoricalItem(synthetic); - } + copyItemProvenance(item, synthetic); pendingSyntheticOutputs.push(synthetic); } } diff --git a/src/adapters/xai-web-search.ts b/src/adapters/xai-web-search.ts index 836eb0ca6df..d871234ae73 100644 --- a/src/adapters/xai-web-search.ts +++ b/src/adapters/xai-web-search.ts @@ -2,6 +2,7 @@ import type { OcxProviderConfig } from "../types"; import { debugProviderDiagnostic } from "../lib/debug"; import { isXaiResponsesDestination } from "../providers/xai-transport"; import { isOpenCodeGoResponsesUrl } from "./opencode-go-additional-tools"; +import { hasItemProvenance, isHistoricalItem } from "../responses/item-provenance"; const CODEX_WEB_SEARCH_TOOL = "web_search"; const CODEX_WEB_SEARCH_PREVIEW_TOOL = "web_search_preview"; @@ -190,8 +191,8 @@ export function normalizeXaiResponsesWebSearch( const xaiDestination = isXaiResponsesDestination(provider); if (!xaiDestination && !isOpenCodeGoGrokResponses(target?.modelId, target?.responseUrl)) return body; // On Go, a wrapper emptied here stays in place: Go promotion (opencode-go-additional-tools.ts) - // removes every wrapper before the wire and splits them into history and current turn by - // index against _replayPrefixLen, so deleting one would shift the current turn into history. + // removes every wrapper before the wire. Retain positions for callers with untagged input; + // transformed adapter input carries per-item historical/live provenance instead. const keepEmptiedWrappers = !xaiDestination; let next: Record = body; @@ -264,7 +265,9 @@ export function injectXaiResponsesXSearch( const input = Array.isArray(body.input) ? body.input : undefined; const inputStart = input ? currentInputStart(input.length, replayPrefixLength) : 0; - const currentInput = input?.slice(inputStart) ?? []; + const currentInput = input?.some(hasItemProvenance) + ? input.filter(item => !isHistoricalItem(item)) + : input?.slice(inputStart) ?? []; const currentXSearchDeclared = hasToolType(body.tools, XAI_SEARCH_TOOL) || currentInput.some(item => isPlainObject(item) diff --git a/src/adapters/zed-additional-tools.ts b/src/adapters/zed-additional-tools.ts index 73a18ca4533..10b52677e52 100644 --- a/src/adapters/zed-additional-tools.ts +++ b/src/adapters/zed-additional-tools.ts @@ -1,4 +1,5 @@ import { isPlainObject as isRecord } from "./openai-responses/internal"; +import { hasItemProvenance, isHistoricalItem, tagHistoricalInputItems } from "../responses/item-provenance"; function toolKey(tool: Record): string | undefined { if (typeof tool.type !== "string" || !tool.type) return undefined; @@ -8,8 +9,8 @@ function toolKey(tool: Record): string | undefined { /** * Zed's OpenAI Responses schema accepts message and call items, not Codex's private - * additional_tools wrapper. Drop every wrapper. Promote only wrappers at or after the - * replayed prefix: earlier ones are history, not the current turn's tool catalog. + * additional_tools wrapper. Drop every wrapper. Item provenance owns current-turn + * membership; a numeric prefix is accepted only for input not yet tagged. * A non-array tools field is left untouched so a malformed catalog is not replaced * by a partial promotion. */ @@ -17,16 +18,15 @@ export function liftZedAdditionalTools(providerRequest: Record, if (!Array.isArray(providerRequest.input)) return; if (providerRequest.tools !== undefined && !Array.isArray(providerRequest.tools)) return; - const currentTurnStart = Number.isFinite(replayPrefixLength) - ? Math.min(providerRequest.input.length, Math.max(0, Math.trunc(replayPrefixLength))) - : 0; + const taggedInput = providerRequest.input.some(hasItemProvenance) ? providerRequest.input + : tagHistoricalInputItems(providerRequest.input, Number.isFinite(replayPrefixLength) ? replayPrefixLength : 0); const input: unknown[] = []; const promoted: unknown[] = []; let changed = false; - for (const [index, item] of providerRequest.input.entries()) { + for (const item of taggedInput) { if (isRecord(item) && item.type === "additional_tools" && Array.isArray(item.tools)) { changed = true; - if (index >= currentTurnStart) promoted.push(...item.tools); + if (!isHistoricalItem(item)) promoted.push(...item.tools); continue; } input.push(item); diff --git a/src/adapters/zed.ts b/src/adapters/zed.ts index a9bb6104df4..e156a59748c 100644 --- a/src/adapters/zed.ts +++ b/src/adapters/zed.ts @@ -354,12 +354,7 @@ export function createZedAdapter(provider: OcxProviderConfig, cacheRetention?: A : item, ); } - if (zedProvider === "open_ai") { - // Codex carries this turn's client tools in a private additional_tools input item. - // Zed rejects that variant, so promote the current turn and drop every wrapper - // before the built-in tool filter runs. - liftZedAdditionalTools(providerRequest, parsed._replayPrefixLen ?? 0); - } + // OpenAI wrappers were promoted before delegation; serialized input has no provenance tags. if (zedProvider === "open_ai" && Array.isArray(providerRequest.tools)) { // Zed's Responses decoder supports function/custom tools, not Codex's built-in web_search. const supported = (tool: unknown) => isRecord(tool) && (tool.type === "function" || tool.type === "custom"); diff --git a/src/responses/item-provenance.ts b/src/responses/item-provenance.ts index 0125c4a4f5f..85935e3adf9 100644 --- a/src/responses/item-provenance.ts +++ b/src/responses/item-provenance.ts @@ -36,6 +36,14 @@ export function hasItemProvenance(item: unknown): boolean { && typeof (item as Record)[REPLAY_HISTORICAL_ITEM] === "boolean"; } +/** Replacement and synthesized items inherit the source's historical or live tag. */ +export function copyItemProvenance(source: unknown, target: T): T { + if (hasItemProvenance(source)) { + Object.assign(target, { [REPLAY_HISTORICAL_ITEM]: isHistoricalItem(source) }); + } + return target; +} + /** * Tag both historical and live items so an empty replay prefix remains distinguishable * from an input that has never been tagged. diff --git a/src/server/responses-undeclared-tool-guard.ts b/src/server/responses-undeclared-tool-guard.ts index d64a802b2ab..6a2784c7c68 100644 --- a/src/server/responses-undeclared-tool-guard.ts +++ b/src/server/responses-undeclared-tool-guard.ts @@ -1,4 +1,5 @@ import { UNDECLARED_TOOL_CALL_ERROR_CODE } from "../lib/errors"; +import { hasItemProvenance, isHistoricalItem } from "../responses/item-provenance"; import { collectAmbiguousDottedAliases, dottedAliasIsUnambiguous, @@ -136,6 +137,9 @@ export function currentTurnWireToolCatalogBody( replayPrefixLength: number | undefined, ): unknown { if (!isPlainObject(body) || !Array.isArray(body.input)) return body; + if (body.input.some(hasItemProvenance)) { + return { ...body, input: body.input.filter(item => !isHistoricalItem(item)) }; + } if (typeof replayPrefixLength !== "number" || !Number.isFinite(replayPrefixLength)) return body; const start = Math.min(body.input.length, Math.max(0, Math.trunc(replayPrefixLength))); if (start === 0) return body; diff --git a/src/server/responses/passthrough-dispatch.ts b/src/server/responses/passthrough-dispatch.ts index e253d5c9460..f45029e4de5 100644 --- a/src/server/responses/passthrough-dispatch.ts +++ b/src/server/responses/passthrough-dispatch.ts @@ -480,10 +480,9 @@ export async function preparePassthroughExchange( for (const callType of collectProviderExecutedCallTypes(clientToolAuthorizationBody)) { providerExecutedCallTypes.add(callType); } - const currentOutboundCatalog = currentTurnWireToolCatalogBody( - outboundRequestBody, - replayedInputPrefixLength, - ); + // Serialization drops provenance, so never slice transformed input by the raw prefix. + // Current wrappers were captured above; adapter-injected hosted tools are top-level. + const currentOutboundCatalog = { tools: outboundRequestBody?.tools }; for (const callType of collectProviderExecutedCallTypes(currentOutboundCatalog)) { providerExecutedCallTypes.add(callType); } diff --git a/structure/transports/responses-wire-shapes.md b/structure/transports/responses-wire-shapes.md index 77ebbcc8b12..ada4bc36b82 100644 --- a/structure/transports/responses-wire-shapes.md +++ b/structure/transports/responses-wire-shapes.md @@ -157,7 +157,7 @@ policy. Explicitly selected Codex Lite ordinary unreferenced hosted-history lowering follows [the hosted-search continuation boundary](../providers-and-adapters.md#hosted-search-continuation-binding); non-Lite/unknown requests, native compact and public API retain structural history; portable compaction keeps its existing trigger and default projection. Go's non-forward Responses request path moves valid `additional_tools` wrappers into top-level `tools` through `src/adapters/opencode-go-additional-tools.ts`. Placement runs after existing custom/search/namespace lowering and before code-mode, compaction and final hosted-tool pruning. -It does not recalculate wire identities or response aliases. The matcher reads the constructed +It reads per-item replay provenance without recalculating wire identities or response aliases. The matcher reads the constructed send URL, resolving it with URL semantics, and requires HTTPS `opencode.ai`, the standard port and exact `/zen/go/v1/responses`. Normal and endpoint-inclusive bases or split `responsesPath` configurations agree; a custom path resolving to Zen or elsewhere does not acquire Go placement. diff --git a/structure/transports/responses.md b/structure/transports/responses.md index ceb5eb164ae..bd46f94a0b5 100644 --- a/structure/transports/responses.md +++ b/structure/transports/responses.md @@ -208,7 +208,7 @@ rejecting non-ResponseItem input types—including official xAI endpoints (`api. `cli-chat-proxy.grok.com`), OpenCode Go, and custom providers opting in with `promoteAdditionalTools: true`—promote declarations to top-level `tools` and strip the wrapper from `input`. `src/responses/item-provenance.ts` tags historical and live input objects with JSON-invisible symbols before replay repair; -boundaries survive removals, insertions and adjacency rewrites, including removal of every historical item. +Historical/live tags survive replacements, synthesized outputs and bridge restoration; transformed-input consumers read each tag, never a recalculated prefix. Historical wrappers retain call-rewriting context with separate rewrite plans and declaration sets; loaded search tools join the live catalog or top-level tools. Only the ROOT rejects a union, so exclusivity is preserved by moving it down rather than widening it: diff --git a/tests/fixtures/test-layout-expected.json b/tests/fixtures/test-layout-expected.json index 6f8b476404b..d484c55957c 100644 --- a/tests/fixtures/test-layout-expected.json +++ b/tests/fixtures/test-layout-expected.json @@ -1466,6 +1466,7 @@ "combo-replay-refusal-attribution.test.ts": "responses", "ws-upstream.test.ts": "responses", "xai-additional-tools.test.ts": "providers/xai", + "xai-replay-provenance.test.ts": "providers/xai", "xai-client.test.ts": "images", "xai-empty-catalog-tool-choice.test.ts": "providers/xai", "xai-no-stop.test.ts": "providers/xai", diff --git a/tests/providers/xai/xai-replay-provenance.test.ts b/tests/providers/xai/xai-replay-provenance.test.ts new file mode 100644 index 00000000000..fdcc0b619af --- /dev/null +++ b/tests/providers/xai/xai-replay-provenance.test.ts @@ -0,0 +1,94 @@ +import { expect, test } from "bun:test"; +import { createResponsesPassthroughAdapter } from "../../../src/adapters/openai-responses"; +import { repairOrphanedInputItems, restoreBridgedWebSearchCalls } from "../../../src/adapters/openai-responses/tool-output-recovery"; +import { injectXaiResponsesXSearch } from "../../../src/adapters/xai-web-search"; +import { liftZedAdditionalTools } from "../../../src/adapters/zed-additional-tools"; +import { currentTurnWireToolCatalogBody } from "../../../src/server/responses-undeclared-tool-guard"; +import { hasItemProvenance, isHistoricalItem, tagHistoricalInputItems } from "../../../src/responses/item-provenance"; +import { withTestTranslatorBudget } from "../../helpers/translator-budget"; +import { rememberBridgeSearchReplay } from "../../../src/responses/bridge-search-replay-cache"; + +const live = { type: "function", name: "live", parameters: { type: "object", properties: {} } }; +const wrap = (tools: unknown[]) => ({ type: "additional_tools", tools }); +const xai = { adapter: "openai-responses", baseUrl: "https://api.x.ai/v1", xaiResponsesXSearch: true }; +const task = { type: "function_call_output", id: "seed", name: "task", namespace: "agent", output: "old task" }; + +function build(seed: unknown) { + return JSON.parse(withTestTranslatorBudget(createResponsesPassthroughAdapter({ + ...xai, requiresPairedResponsesToolResults: true, + })).buildRequest({ + modelId: "grok-4.7", context: { messages: [] }, stream: false, options: {}, + _rawBody: { input: [seed, wrap([{ type: "web_search" }]), wrap([live])] }, _replayPrefixLen: 2, + }, { headers: new Headers() }).body); +} + +test("control: historical user seed does not reactivate historical hosted search", () => { + expect(build({ type: "message", role: "user", content: "old task" }).tools).toEqual([live]); +}); + +test("external task-input replacement must not reactivate historical hosted search", () => { + expect(build(task).tools).toEqual([live]); +}); + +for (const [name, seed, synthesize] of [ + ["external task", task, false], + ["orphan output", { type: "function_call_output", call_id: "old", output: "result" }, false], + ["synthetic output", { type: "function_call", call_id: "old", name: "lookup", arguments: "{}" }, true], +] as const) { + test(`${name} replacement retains both historical and live provenance`, () => { + for (const historical of [true, false]) { + const tagged = tagHistoricalInputItems([{ type: "message" }, seed], historical ? 2 : 1); + const body = repairOrphanedInputItems({ input: tagged }, false, synthesize) as { input: unknown[] }; + const replacement = body.input.at(-1); + expect(hasItemProvenance(replacement)).toBe(true); + expect(isHistoricalItem(replacement)).toBe(historical); + expect(Object.getOwnPropertySymbols(seed)).toEqual([]); + } + }); +} + +test("xAI injection ignores historical web search after a live item moves ahead of it", () => { + const [historical, current] = tagHistoricalInputItems([wrap([{ type: "web_search" }]), wrap([live])], 1); + const body = { input: [current, historical] }; + expect(injectXaiResponsesXSearch(body, xai, 0)).toBe(body); +}); + +test("bridge restoration retains historical and live provenance on both restored items", () => { + const scope = "xai-replay-provenance-fixture"; + rememberBridgeSearchReplay(scope, "ws_provenance", { + callId: "search_provenance", name: "web_search", argumentsText: "{}", output: "fixture result", + }); + for (const historical of [true, false]) { + const input = tagHistoricalInputItems([ + { type: "message" }, { type: "web_search_call", id: "ws_provenance" }, + ], historical ? 2 : 1); + const restored = restoreBridgedWebSearchCalls({ input }, scope) as { input: unknown[] }; + expect(restored.input).toHaveLength(3); + for (const item of restored.input.slice(1)) { + expect(hasItemProvenance(item)).toBe(true); + expect(isHistoricalItem(item)).toBe(historical); + } + } +}); + +test("historical x_search cannot suppress injection for a current web-search wrapper", () => { + const [historical, current] = tagHistoricalInputItems([ + wrap([{ type: "x_search" }]), wrap([{ type: "web_search" }]), + ], 1); + expect(injectXaiResponsesXSearch({ input: [current, historical] }, xai, 0)) + .toMatchObject({ tools: [{ type: "x_search" }] }); +}); + +test("Zed promotion reads provenance after input reordering", () => { + const [historical, current] = tagHistoricalInputItems([wrap([{ ...live, name: "retired" }]), wrap([live])], 1); + const body: Record = { input: [current, historical] }; + liftZedAdditionalTools(body, 0); + expect(body.tools).toEqual([live]); + expect(body.input).toEqual([]); +}); + +test("current-turn authorization reads provenance after input reordering", () => { + const [historical, current] = tagHistoricalInputItems([wrap([{ type: "x_search" }]), wrap([live])], 1); + expect(currentTurnWireToolCatalogBody({ input: [current, historical] }, 0)) + .toEqual({ input: [current] }); +}); From 71c91fd8fc28b4648bef6aa0526b02e9d96fcbec Mon Sep 17 00:00:00 2001 From: JUN <243035832+lidge-jun@users.noreply.github.com> Date: Thu, 29 Oct 2026 09:06:16 +0900 Subject: [PATCH 11/14] fix(responses): bound loaded-tool promotion to current discoveries Filter tool-search source items before declarations enter a live wrapper, top-level catalog or fallback. Preserve current discovery, per-item tags and the untagged replay-prefix fallback through the existing merge paths. Add 21 permanent cases including stored two-turn hosted-call authorization. Regression on 48a8e31fdc: 9 pass / 12 fail; fixed: 21 pass / 0 fail. Co-authored-by: Omar <13569609+omar391@users.noreply.github.com> --- scripts/test-layout/layout-providers.json | 1 + src/adapters/openai-responses/tool-schema.ts | 24 ++-- structure/transports/responses-wire-shapes.md | 2 +- structure/transports/responses.md | 2 +- tests/fixtures/test-layout-expected.json | 1 + .../xai/xai-loaded-tool-provenance.test.ts | 128 ++++++++++++++++++ 6 files changed, 145 insertions(+), 13 deletions(-) create mode 100644 tests/providers/xai/xai-loaded-tool-provenance.test.ts diff --git a/scripts/test-layout/layout-providers.json b/scripts/test-layout/layout-providers.json index df4ee8e5e09..bbaf2303867 100644 --- a/scripts/test-layout/layout-providers.json +++ b/scripts/test-layout/layout-providers.json @@ -1,6 +1,7 @@ { "xai-additional-tools.test.ts": "providers/xai", "xai-replay-provenance.test.ts": "providers/xai", + "xai-loaded-tool-provenance.test.ts": "providers/xai", "free-directory-runcrate.test.ts": "providers", "kiro-completion-with-real-tools.test.ts": "providers/kiro" } diff --git a/src/adapters/openai-responses/tool-schema.ts b/src/adapters/openai-responses/tool-schema.ts index 0836ff90dea..eb986999fb8 100644 --- a/src/adapters/openai-responses/tool-schema.ts +++ b/src/adapters/openai-responses/tool-schema.ts @@ -199,8 +199,16 @@ function mergeLoadedTools(declaredTools: unknown[], loadedTools: unknown[]): unk export function promoteClientLoadedTools(body: unknown, replayPrefixLength?: number): unknown { if (!isPlainObject(body) || !Array.isArray(body.input)) return body; - const loadedTools = body.input.flatMap(item => - isPlainObject(item) && item.type === "tool_search_output" && Array.isArray(item.tools) + const currentTurnStart = Number.isFinite(replayPrefixLength) + ? Math.max(0, Math.trunc(replayPrefixLength!)) + : 0; + const hasProvenance = body.input.some(hasItemProvenance); + const isCurrentItem = (item: unknown, index: number): boolean => + hasProvenance ? !isHistoricalItem(item) : index >= currentTurnStart; + + // Filter the source before declarations lose their item's provenance in a live catalog. + const loadedTools = body.input.flatMap((item, index) => + isCurrentItem(item, index) && isPlainObject(item) && item.type === "tool_search_output" && Array.isArray(item.tools) ? item.tools : [] ); @@ -211,15 +219,9 @@ export function promoteClientLoadedTools(body: unknown, replayPrefixLength?: num return tools === body.tools ? body : { ...body, tools }; } - const currentTurnStart = Number.isFinite(replayPrefixLength) - ? Math.max(0, Math.trunc(replayPrefixLength!)) - : 0; - const hasProvenance = body.input.some(hasItemProvenance); - - const currentAdditionalToolsIndex = body.input.findIndex((item, index) => { - const isHistorical = hasProvenance ? isHistoricalItem(item) : (index < currentTurnStart); - return !isHistorical && isPlainObject(item) && item.type === "additional_tools" && Array.isArray(item.tools); - }); + const currentAdditionalToolsIndex = body.input.findIndex((item, index) => + isCurrentItem(item, index) && isPlainObject(item) && item.type === "additional_tools" && Array.isArray(item.tools) + ); if (currentAdditionalToolsIndex < 0) { return { ...body, tools: mergeLoadedTools([], loadedTools) }; diff --git a/structure/transports/responses-wire-shapes.md b/structure/transports/responses-wire-shapes.md index ada4bc36b82..b666247bba5 100644 --- a/structure/transports/responses-wire-shapes.md +++ b/structure/transports/responses-wire-shapes.md @@ -163,7 +163,7 @@ and exact `/zen/go/v1/responses`. Normal and endpoint-inclusive bases or split ` configurations agree; a custom path resolving to Zen or elsewhere does not acquire Go placement. Credentials, query, fragment, foreign hosts and other resource paths are excluded. The existing URL constructor canonicalizes trailing base slashes before this check. Malformed wrappers remain unchanged and -the shared mixed-ciphertext agent-message gate remains fail-closed. Responses passthrough clones and tags historical and current input items before any rewrite, including identity repair; symbol provenance survives message replacement, prompt-cache cleanup, filtering and tool lowering without entering serialized requests. Promotion uses provenance rather than positions in shortened input. +the shared mixed-ciphertext agent-message gate remains fail-closed. Responses passthrough clones and tags historical and current input items before any rewrite, including identity repair; symbol provenance survives message replacement, prompt-cache cleanup, filtering and tool lowering without entering serialized requests. Promotion uses provenance rather than positions in shortened input. Discovery-source filtering follows the [current-turn catalog contract](responses.md#responses-httpsse) before nested tool definitions enter a live catalog. The canonical `opencode-go` registry entry defaults to `statelessResponses: true` because Go rejects reasoning ciphertext combined with `previous_response_id` (#3838). Existing derive diff --git a/structure/transports/responses.md b/structure/transports/responses.md index bd46f94a0b5..bb272c28433 100644 --- a/structure/transports/responses.md +++ b/structure/transports/responses.md @@ -209,7 +209,7 @@ rejecting non-ResponseItem input types—including official xAI endpoints (`api. `promoteAdditionalTools: true`—promote declarations to top-level `tools` and strip the wrapper from `input`. `src/responses/item-provenance.ts` tags historical and live input objects with JSON-invisible symbols before replay repair; Historical/live tags survive replacements, synthesized outputs and bridge restoration; transformed-input consumers read each tag, never a recalculated prefix. -Historical wrappers retain call-rewriting context with separate rewrite plans and declaration sets; loaded search tools join the live catalog or top-level tools. +Historical wrappers retain call-rewriting context with separate rewrite plans and declaration sets. Only current-turn `tool_search_output` discoveries join a live wrapper, top-level tools or the top-level fallback; source provenance is checked before merging declarations. Historical discoveries remain replay context and grant no current hosted-tool authority. `tests/providers/xai/xai-loaded-tool-provenance.test.ts` covers these paths and stored two-turn continuations. Only the ROOT rejects a union, so exclusivity is preserved by moving it down rather than widening it: a root `oneOf` whose branches differ in one property becomes that property's `oneOf`, or its `anyOf` diff --git a/tests/fixtures/test-layout-expected.json b/tests/fixtures/test-layout-expected.json index d484c55957c..48e71c13677 100644 --- a/tests/fixtures/test-layout-expected.json +++ b/tests/fixtures/test-layout-expected.json @@ -1467,6 +1467,7 @@ "ws-upstream.test.ts": "responses", "xai-additional-tools.test.ts": "providers/xai", "xai-replay-provenance.test.ts": "providers/xai", + "xai-loaded-tool-provenance.test.ts": "providers/xai", "xai-client.test.ts": "images", "xai-empty-catalog-tool-choice.test.ts": "providers/xai", "xai-no-stop.test.ts": "providers/xai", diff --git a/tests/providers/xai/xai-loaded-tool-provenance.test.ts b/tests/providers/xai/xai-loaded-tool-provenance.test.ts new file mode 100644 index 00000000000..70586cacfc2 --- /dev/null +++ b/tests/providers/xai/xai-loaded-tool-provenance.test.ts @@ -0,0 +1,128 @@ +import { expect, test } from "bun:test"; +import { createResponsesPassthroughAdapter } from "../../../src/adapters/openai-responses"; +import { promoteClientLoadedTools } from "../../../src/adapters/openai-responses/tool-schema"; +import { tagHistoricalInputItems } from "../../../src/responses/item-provenance"; +import { handleResponses } from "../../../src/server/responses"; +import type { OcxConfig } from "../../../src/types"; +import { acquireOwnedSpendHome } from "../../helpers/owned-spend-home"; +import { withTestTranslatorBudget } from "../../helpers/translator-budget"; + +const live = { type: "function", name: "live", parameters: { type: "object", properties: {} } }; +const retired = { type: "web_search", name: "retired_search" }; +const current = { type: "web_search", name: "current_search" }; +const wrap = (tools: unknown[]) => ({ type: "additional_tools", tools }); +const discovery = (tools: unknown[]) => ({ type: "tool_search_output", call_id: "search", tools }); +const provider = { + adapter: "openai-responses", baseUrl: "https://api.x.ai/v1", authMode: "key", + apiKey: "fixture-key", xaiResponsesXSearch: true, +}; + +for (const catalog of ["wrapper", "top-level", "fallback"] as const) { + for (const historical of [true, false]) { + test(`${catalog}: ${historical ? "historical" : "current"} discoveries keep their authority boundary`, () => { + const input = [discovery([retired]), ...(catalog === "wrapper" ? [wrap([live])] : [])]; + const raw = { input, ...(catalog === "top-level" ? { tools: [live] } : {}) }; + const before = JSON.stringify(raw); + const body = JSON.parse(withTestTranslatorBudget(createResponsesPassthroughAdapter(provider)).buildRequest({ + modelId: "grok-4.7", context: { messages: [] }, stream: false, options: {}, + _rawBody: raw, _replayPrefixLen: historical ? 1 : 0, + }, { headers: new Headers() }).body); + const declared = catalog === "fallback" ? [] : [live]; + const active = catalog === "wrapper" ? [{ type: "x_search" }, ...declared, retired] + : [...declared, retired, { type: "x_search" }]; + expect(body.tools ?? []).toEqual(historical ? declared : active); + expect(JSON.stringify(raw)).toBe(before); + expect(input.every(item => Object.getOwnPropertySymbols(item).length === 0)).toBe(true); + }); + } + + test(`${catalog}: source tags override stale positions after reordering`, () => { + const [oldResult, liveResult, wrapper] = tagHistoricalInputItems([ + discovery([retired]), discovery([current]), wrap([live]), + ], 1); + const body = { + input: [liveResult, oldResult, ...(catalog === "wrapper" ? [wrapper] : [])], + ...(catalog === "top-level" ? { tools: [live] } : {}), + }; + const loaded = promoteClientLoadedTools(body, body.input.length) as { + tools?: unknown[]; input: Array<{ type: string; tools?: unknown[] }>; + }; + const declared = catalog === "fallback" ? [] : [live]; + expect(catalog === "wrapper" ? loaded.input.at(-1)?.tools : loaded.tools).toEqual([...declared, current]); + expect(body.input).toEqual([liveResult, oldResult, ...(catalog === "wrapper" ? [wrapper] : [])]); + }); + + test(`${catalog}: untagged callers use the original replay prefix for discoveries`, () => { + const body = { + input: [discovery([retired]), discovery([current]), ...(catalog === "wrapper" ? [wrap([live])] : [])], + ...(catalog === "top-level" ? { tools: [live] } : {}), + }; + const loaded = promoteClientLoadedTools(body, 1) as { + tools?: unknown[]; input: Array<{ type: string; tools?: unknown[] }>; + }; + expect(catalog === "wrapper" ? loaded.input.at(-1)?.tools : loaded.tools) + .toEqual([...(catalog === "fallback" ? [] : [live]), current]); + }); +} + +for (const catalog of ["wrapper", "top-level", "fallback"] as const) { + for (const source of ["historical", "current", "absent"] as const) { + test(`two-turn ${catalog}: ${source} discovery retains only current hosted declarations`, async () => { + const savedFetch = globalThis.fetch; + const releaseHome = acquireOwnedSpendHome(); + const responseId = `resp_loaded_provenance_${catalog}_${source}`; + const outbound: Array<{ tools?: Array> }> = []; + const hostedCall = { + type: "custom_tool_call", id: "ctc_provenance", call_id: "xs_call-round3", + name: "x_keyword_search", input: "{}", status: "completed", + }; + globalThis.fetch = (async (_input, init) => { + outbound.push(JSON.parse(String(init?.body))); + return Response.json({ + id: outbound.length === 1 ? responseId : `${responseId}_next`, status: "completed", + output: outbound.length === 1 + ? [{ type: "message", role: "assistant", content: [{ type: "output_text", text: "done" }] }] + : [hostedCall], + }); + }) as typeof fetch; + const config: OcxConfig = { port: 0, defaultProvider: "fixture", providers: { fixture: provider } }; + const post = (body: Record) => handleResponses(new Request("http://localhost/v1/responses", { + method: "POST", headers: { "content-type": "application/json" }, + body: JSON.stringify({ model: "fixture/grok-4.7", stream: false, ...body }), + }), config, { model: "", provider: "" }); + try { + const first = await post({ input: [ + wrap([{ type: "web_search" }]), ...(source === "historical" ? [discovery([retired])] : []), + { role: "user", content: "old search" }, + ] }); + expect(first.status).toBe(200); + await first.text(); + const second = await post({ + previous_response_id: responseId, + input: [ + ...(catalog === "wrapper" ? [wrap([live])] : []), + ...(source === "current" ? [discovery([current])] : []), + { role: "user", content: "continue" }, + ], + ...(catalog === "top-level" ? { tools: [live] } : {}), + }); + const result = await second.json() as { output?: unknown[]; error?: { code?: string } }; + expect(outbound).toHaveLength(2); + // An omitted catalog leaves call handling to the client; an explicit live catalog + // activates the server guard. Both paths must exclude historical outbound authority. + const accepted = source === "current" || catalog === "fallback"; + expect(second.status).toBe(accepted ? 200 : 502); + if (accepted) expect(result.output).toEqual([hostedCall]); + else expect(result.error?.code).toBe("undeclared_tool_call"); + const declared = catalog === "fallback" ? [] : [live]; + const active = catalog === "wrapper" ? [{ type: "x_search" }, ...declared, current] + : [...declared, current, { type: "x_search" }]; + expect(outbound[1]?.tools ?? []) + .toEqual(source === "current" ? active : declared); + } finally { + globalThis.fetch = savedFetch; + releaseHome(); + } + }); + } +} From 31be65e805d921262f3e8602bb089d2357aee29f Mon Sep 17 00:00:00 2001 From: Omar <13569609+omar391@users.noreply.github.com> Date: Thu, 29 Oct 2026 21:24:41 +0600 Subject: [PATCH 12/14] test(responses): update pre-promotion xAI tests for additional_tools promotion Reconcile legacy pre-PR-7637 xAI test assertions with official xAI endpoint additional_tools promotion. Verify outbound body strips additional_tools from input and promotes declarations to top-level tools on xAI destinations (api.x.ai and cli-chat-proxy.grok.com), and verify unpromoted routed gateways continue preserving additional_tools in input. Co-authored-by: Omar <13569609+omar391@users.noreply.github.com> --- .../openai-responses-passthrough.test.ts | 47 +++++++++++++++++-- .../server-xai-responses-streaming.test.ts | 5 +- 2 files changed, 46 insertions(+), 6 deletions(-) diff --git a/tests/responses/openai-responses-passthrough.test.ts b/tests/responses/openai-responses-passthrough.test.ts index feee0328653..b0a453000b1 100644 --- a/tests/responses/openai-responses-passthrough.test.ts +++ b/tests/responses/openai-responses-passthrough.test.ts @@ -1579,7 +1579,10 @@ describe("OpenAI Responses passthrough sanitization", () => { for (const lite of [false, true]) { const body = build(lite); - const tools = lite ? body.input[0]?.tools : body.tools; + const tools = body.tools; + if (lite) { + expect(body.input).toEqual([]); + } expect(tools?.map(tool => tool.name)).toEqual([ "mcp__codex_app__safe_union", "mcp__codex_app__plain", @@ -2478,8 +2481,8 @@ describe("OpenAI Responses passthrough sanitization", () => { tool_choice: Record; }; - expect(body.tools).toBeUndefined(); - expect(body.input[0]?.tools).toEqual([{ type: "web_search" }]); + expect(body.tools).toEqual([{ type: "web_search" }]); + expect(body.input).toEqual([]); expect(body.tool_choice).toEqual({ type: "web_search" }); }); @@ -2655,7 +2658,43 @@ describe("OpenAI Responses passthrough sanitization", () => { expect(body.tools[0]).toEqual({ type: "web_search" }); expect(body.tools[1]).toMatchObject({ type: "function", name: "workspace__read" }); expect(body.tools[1]).not.toHaveProperty("defer_loading"); - expect(body.input[0].tools[0]).not.toHaveProperty("defer_loading"); + expect(body.tools[2]).toMatchObject({ type: "function", name: "loose" }); + expect(body.tools[2]).not.toHaveProperty("defer_loading"); + expect(body.input).toEqual([]); + + const unpromotedAdapter = createResponsesPassthroughAdapter({ + adapter: "openai-responses", + baseUrl: "https://router.example.com/v1", + authMode: "key" as const, + apiKey: "test", + supportsOpenAiWebSearchToolFields: false, + }); + const unpromotedRequest = unpromotedAdapter.buildRequest({ + modelId: "custom-model", + context: { messages: [] }, + stream: true, + options: {}, + _rawBody: { + model: "custom-model", + tools: [ + { + type: "namespace", + name: "workspace", + tools: [{ type: "function", name: "read", defer_loading: true, parameters: {} }], + }, + ], + input: [{ + type: "additional_tools", + tools: [{ type: "function", name: "loose", defer_loading: true, parameters: {} }], + }], + }, + }, { headers: new Headers() }); + const unpromotedBody = JSON.parse(unpromotedRequest.body) as { + tools: Record[]; + input: Array<{ tools: Record[] }>; + }; + expect(unpromotedBody.tools[0]).not.toHaveProperty("defer_loading"); + expect(unpromotedBody.input[0].tools[0]).not.toHaveProperty("defer_loading"); }); test("preserves prompt_cache_key in the raw Responses passthrough body", () => { diff --git a/tests/server/server-xai-responses-streaming.test.ts b/tests/server/server-xai-responses-streaming.test.ts index 8a2298972c9..03adfc36dac 100644 --- a/tests/server/server-xai-responses-streaming.test.ts +++ b/tests/server/server-xai-responses-streaming.test.ts @@ -477,11 +477,12 @@ describe("xAI OAuth Responses streaming opt-in", () => { type: string; tools?: Array<{ type: string; name?: string }>; }> | undefined; - const outboundTools = outboundInput?.find(item => item.type === "additional_tools")?.tools; + expect(outboundInput?.find(item => item.type === "additional_tools")).toBeUndefined(); + const outboundTools = outboundBody?.tools as Array<{ type: string; name?: string }> | undefined; expect(outboundTools?.some(tool => tool.type === "namespace")).toBe(false); expect(outboundTools?.find(tool => tool.name === "exec")?.type).toBe("function"); expect(outboundTools?.find(tool => tool.name === "collaboration__spawn_agent")?.type).toBe("function"); - expect(outboundBody?.tools).toEqual([{ type: "web_search" }]); + expect(outboundTools?.find(tool => tool.type === "web_search")).toEqual({ type: "web_search" }); const payloads = clientText .split(/\r?\n/) From c62e494075aa1d3040384f5388488c2aeac29933 Mon Sep 17 00:00:00 2001 From: JUN Date: Fri, 30 Oct 2026 20:15:52 +0900 Subject: [PATCH 13/14] fix(responses): keep existing item provenance when tagging the replay prefix Zed lifts replayed additional_tools wrappers out of the tagged input before the Responses delegate runs. The delegate then re-tagged the shortened input by the original numeric prefix, so a current-turn tool_search_output slid under the boundary and its loaded tools were dropped. Input that already carries provenance now keeps it. Co-authored-by: Omar <13569609+omar391@users.noreply.github.com> --- src/responses/item-provenance.ts | 5 ++++- structure/transports/responses.md | 2 +- tests/providers/zed-hosted-provider.test.ts | 22 +++++++++++++++++++++ 3 files changed, 27 insertions(+), 2 deletions(-) diff --git a/src/responses/item-provenance.ts b/src/responses/item-provenance.ts index 85935e3adf9..4cf5b7c365f 100644 --- a/src/responses/item-provenance.ts +++ b/src/responses/item-provenance.ts @@ -46,10 +46,13 @@ export function copyItemProvenance(source: unknown, target: T) /** * Tag both historical and live items so an empty replay prefix remains distinguishable - * from an input that has never been tagged. + * from an input that has never been tagged. Input that already carries provenance keeps it: + * an earlier rewrite (Zed lifting a replayed wrapper) may have shifted items past the + * numeric prefix, so re-tagging by index would mislabel current-turn items as history. */ export function tagHistoricalInputItems(input: unknown[], prefixLength: number): unknown[] { if (!Array.isArray(input)) return input; + if (input.some(hasItemProvenance)) return input; const prefix = Math.min(input.length, Math.max(0, Math.trunc(prefixLength))); if (prefix <= 0) return input; return input.map((item, index) => { diff --git a/structure/transports/responses.md b/structure/transports/responses.md index a474c85c5e1..d67380d7ec7 100644 --- a/structure/transports/responses.md +++ b/structure/transports/responses.md @@ -203,7 +203,7 @@ when the shared xAI normalizer can preserve its meaning; an unsafe function is o letting one incompatible declaration reject the entire request before inference. This is scoped to `cli-chat-proxy.grok.com`: public `api.x.ai` keeps native root unions, as do unrelated Responses gateways. Both top-level `tools` and Responses Lite `additional_tools` pass through this policy. -Codex Desktop packages client tools inside an `additional_tools` input item; strict gateways rejecting non-ResponseItem input types—including official xAI endpoints (`api.x.ai`, `cli-chat-proxy.grok.com`), OpenCode Go, and custom providers opting in with `promoteAdditionalTools: true`—promote declarations to top-level `tools` and strip the wrapper from `input`. `src/responses/item-provenance.ts` tags historical and live input objects with JSON-invisible symbols before replay repair; historical/live tags survive replacements, synthesized outputs and bridge restoration; transformed-input consumers read each tag, never a recalculated prefix. +Codex Desktop packages client tools inside an `additional_tools` input item; strict gateways rejecting non-ResponseItem input types—including official xAI endpoints (`api.x.ai`, `cli-chat-proxy.grok.com`), OpenCode Go, and custom providers opting in with `promoteAdditionalTools: true`—promote declarations to top-level `tools` and strip the wrapper from `input`. `src/responses/item-provenance.ts` tags historical and live input objects with JSON-invisible symbols before replay repair; historical/live tags survive replacements, synthesized outputs and bridge restoration; transformed-input consumers read each tag, never a recalculated prefix. The numeric replay prefix tags only input that carries no tag yet, so a rewrite that already tagged and then shortened the input (Zed lifting a replayed wrapper) keeps a current-turn `tool_search_output` current. Historical wrappers retain call-rewriting context with separate rewrite plans and declaration sets. Only current-turn `tool_search_output` discoveries join a live wrapper, top-level tools or the top-level fallback; source provenance is checked before merging declarations. Historical discoveries remain replay context and grant no current hosted-tool authority. `tests/providers/xai/xai-loaded-tool-provenance.test.ts` covers these paths and stored two-turn continuations. Only the ROOT rejects a union, so exclusivity is preserved by moving it down rather than widening it: diff --git a/tests/providers/zed-hosted-provider.test.ts b/tests/providers/zed-hosted-provider.test.ts index 2046c1d6670..a3763b2a8d4 100644 --- a/tests/providers/zed-hosted-provider.test.ts +++ b/tests/providers/zed-hosted-provider.test.ts @@ -446,6 +446,28 @@ describe("Zed OpenAI tool relay", () => { expect(rawBefore).toContain("reasoning"); }); + test("a current tool_search_output stays current after a replayed wrapper is lifted", async () => { + catalogFetch(); + const lookup = { type: "function", name: "lookup", parameters: { type: "object", properties: {} } }; + const parsed = parseRequest({ + model: "gpt-6.1-sol", + input: [ + { type: "additional_tools", tools: [{ type: "function", name: "old_tool", parameters: { type: "object", properties: {} } }] }, + { type: "tool_search_output", call_id: "search", status: "completed", execution: "client", tools: [lookup] }, + { type: "message", role: "user", content: [{ type: "input_text", text: "now" }] }, + ], + tools: [], + stream: true, + }); + parsed._zedAuthContext = { userId: "user-1" }; + parsed._replayPrefixLen = 1; + const adapter = withTestTranslatorBudget(createZedAdapter(provider)); + const request = await adapter.buildRequest(parsed); + const body = JSON.parse(request.body).provider_request; + expect(JSON.stringify(body.input)).not.toContain("old_tool"); + expect(body.tools.map((tool: { name?: string }) => tool.name)).toEqual(["lookup"]); + }); + async function relay(frames: unknown[]): Promise { catalogFetch(); const parsed = parseRequest({ model: "gpt-6.1-sol", input: "hello", stream: true }); From 4319468ee2f5d16bdb031d3942f554a7f46d3207 Mon Sep 17 00:00:00 2001 From: JUN Date: Fri, 30 Oct 2026 20:23:38 +0900 Subject: [PATCH 14/14] refactor(responses): drop an unused OpenCode Go import Co-authored-by: Omar <13569609+omar391@users.noreply.github.com> --- src/adapters/openai-responses/passthrough.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/adapters/openai-responses/passthrough.ts b/src/adapters/openai-responses/passthrough.ts index b5a6313ecdb..c5525133ecf 100644 --- a/src/adapters/openai-responses/passthrough.ts +++ b/src/adapters/openai-responses/passthrough.ts @@ -3,7 +3,7 @@ import { normalizeRoutedAgentMessages } from "../routed-agent-messages"; import { frameCompactionHistory } from "../../responses/compaction-history-framing"; import { nameRoutedIdentity, repairIdentityInResponsesBody, stripRoutedIdentity } from "../identity"; import { stripBracketedModelSuffix } from "../openai-chat"; -import { isOpenCodeGoResponsesUrl, isXaiResponsesUrl, promoteResponsesAdditionalTools, normalizeOpenCodeGoAdditionalTools } from "../opencode-go-additional-tools"; +import { isOpenCodeGoResponsesUrl, isXaiResponsesUrl, promoteResponsesAdditionalTools } from "../opencode-go-additional-tools"; import { isXaiResponsesDestination } from "../../providers/xai-transport"; import { applyGithubCopilotContextTier } from "../../providers/github-copilot-context"; import { Buffer } from "node:buffer";