From a15b5f130a3415ff1685e78ff202e4505190ef48 Mon Sep 17 00:00:00 2001 From: codingbo Date: Wed, 30 Sep 2026 02:22:39 +0800 Subject: [PATCH 1/2] fix(responses): preserve input IDs and private metadata for opted-in launcher replays (#6220) --- scripts/test-layout/layout.json | 1 + src/adapters/openai-responses/passthrough.ts | 32 +++++- src/config/schema/leaf-validators.ts | 2 + src/providers/derive.ts | 2 + src/providers/model-rename-fields.ts | 2 + src/providers/registry/model-ids.ts | 2 + src/providers/registry/types.ts | 4 + src/router.ts | 6 + src/server/auth-cors.ts | 2 + src/types/provider.ts | 16 +++ structure/transports/responses-wire-shapes.md | 8 ++ tests/fixtures/test-layout-expected.json | 1 + ...ponses-chatgpt-web-replay-metadata.test.ts | 107 ++++++++++++++++++ 13 files changed, 180 insertions(+), 5 deletions(-) create mode 100644 tests/responses/responses-chatgpt-web-replay-metadata.test.ts diff --git a/scripts/test-layout/layout.json b/scripts/test-layout/layout.json index fd19e2040b0..31ed5b29b79 100644 --- a/scripts/test-layout/layout.json +++ b/scripts/test-layout/layout.json @@ -1409,6 +1409,7 @@ "responses-azure-opaque-blob-recovery.test.ts": "responses", "responses-bare-echo-helper-fence.test.ts": "responses", "responses-canonical-only-top-level-fields.test.ts": "responses", + "responses-chatgpt-web-replay-metadata.test.ts": "responses", "responses-code-mode-goal-helpers.test.ts": "responses", "responses-code-mode-patch-compile.test.ts": "responses", "responses-code-mode-shell-compile.test.ts": "responses", diff --git a/src/adapters/openai-responses/passthrough.ts b/src/adapters/openai-responses/passthrough.ts index a3e5a85e1ca..10a1f493539 100644 --- a/src/adapters/openai-responses/passthrough.ts +++ b/src/adapters/openai-responses/passthrough.ts @@ -372,7 +372,9 @@ export function createResponsesPassthroughAdapter(provider: OcxProviderConfig): // OpenAI model inheriting history a routed provider had damaged. outBody = repairLegacyDottedToolCallNames(outBody); if (!isCanonicalOpenAiForwardProvider(provider)) { - outBody = stripInternalChatMessageMetadataPassthrough(outBody); + if (provider.preserveResponsesMessageMetadata !== true) { + outBody = stripInternalChatMessageMetadataPassthrough(outBody); + } // The same class of private field, one level up, but keyed on the DESTINATION rather than // on the canonical surface alone. `src/server/responses/compact.ts` spreads the caller's // raw body into the native `/responses/compact` request without passing through this @@ -463,8 +465,8 @@ export function createResponsesPassthroughAdapter(provider: OcxProviderConfig): // separately refuses a proven cross-route replay when no plaintext exists; this final // serializer guard ensures the foreign opaque state is never forwarded regardless. const sanitizedBody = normalizeToolSchemas( - stripItemIdsWhenUnstored( - stripInvalidItemIds( + provider.preserveResponsesInputItemIds === true + ? stripInvalidItemIds( stripUnsupportedHostedTools( sanitizeReasoningInputContent( scrubOcxCompactionItems( @@ -482,9 +484,29 @@ export function createResponsesPassthroughAdapter(provider: OcxProviderConfig): ), provider, ), + ) + : stripItemIdsWhenUnstored( + stripInvalidItemIds( + stripUnsupportedHostedTools( + sanitizeReasoningInputContent( + scrubOcxCompactionItems( + outBody, + destinationDecodesNativeCompactionBlob(provider), + threadServingIdentityChanged, + ), + { + preserveRawReasoningContent: provider.preserveResponsesReasoningContent === true, + dropNullContentChannel: !isOpenAiOperatedResponsesDestination(provider), + stripEncryptedContent: threadServingIdentityChanged || requiresPlaintextReasoningReplay(provider), + dropForeignItemId: parsed._dropForeignReasoningItemIds === true, + requirePlaintextReasoning: requiresPlaintextReasoningReplay(provider), + }, + ), + provider, + ), + ), + isXaiResponsesDestination(provider), ), - isXaiResponsesDestination(provider), - ), isXaiSchemaTarget(provider), ); const unnormalizedBody = stripDisabledVerbosity( diff --git a/src/config/schema/leaf-validators.ts b/src/config/schema/leaf-validators.ts index 16c8e0a23c7..8bd203d232c 100644 --- a/src/config/schema/leaf-validators.ts +++ b/src/config/schema/leaf-validators.ts @@ -323,6 +323,8 @@ export const providerConfigSchema = z.object({ modelSupportsServiceTier: z.record(z.string().min(1), z.boolean()).optional(), modelSuppressSyntheticMax: z.record(z.string().min(1), z.boolean()).optional(), preserveResponsesReasoningContent: z.boolean().optional(), + preserveResponsesInputItemIds: z.boolean().optional(), + preserveResponsesMessageMetadata: z.boolean().optional(), dropResponsesReasoningItems: z.boolean().optional(), modelReasoningEffortsAuthoritative: z.boolean().optional(), decodesNativeCompactionBlobs: z.boolean().optional(), diff --git a/src/providers/derive.ts b/src/providers/derive.ts index b14078b4ec7..2708830b9ff 100644 --- a/src/providers/derive.ts +++ b/src/providers/derive.ts @@ -590,6 +590,8 @@ export function enrichProviderFromRegistry(name: string, prov: OcxProviderConfig prov.supportsResponsesCustomTools = entry.supportsResponsesCustomTools; } if (prov.preserveResponsesReasoningContent === undefined && entry.preserveResponsesReasoningContent !== undefined) prov.preserveResponsesReasoningContent = entry.preserveResponsesReasoningContent; + if (prov.preserveResponsesInputItemIds === undefined && entry.preserveResponsesInputItemIds !== undefined) prov.preserveResponsesInputItemIds = entry.preserveResponsesInputItemIds; + if (prov.preserveResponsesMessageMetadata === undefined && entry.preserveResponsesMessageMetadata !== undefined) prov.preserveResponsesMessageMetadata = entry.preserveResponsesMessageMetadata; if (prov.dropResponsesReasoningItems === undefined && entry.dropResponsesReasoningItems !== undefined) prov.dropResponsesReasoningItems = entry.dropResponsesReasoningItems; applyReasoningSummaryDefaults(prov, entry.modelSupportsReasoningSummaries); applyServiceTierModelDefaults(prov, serviceTierModelDefaultsFor(entry, prov)); diff --git a/src/providers/model-rename-fields.ts b/src/providers/model-rename-fields.ts index a12fbf88083..b4b433773bd 100644 --- a/src/providers/model-rename-fields.ts +++ b/src/providers/model-rename-fields.ts @@ -30,6 +30,8 @@ export const PROVIDER_MODEL_RENAME_ROLES = { fastEnabled: "none", modelSupportsServiceTier: "record", preserveResponsesReasoningContent: "none", + preserveResponsesInputItemIds: "none", + preserveResponsesMessageMetadata: "none", modelReasoningEffortsAuthoritative: "none", dropResponsesReasoningItems: "none", decodesNativeCompactionBlobs: "none", diff --git a/src/providers/registry/model-ids.ts b/src/providers/registry/model-ids.ts index 2e70c9d21be..9dc799eee5c 100644 --- a/src/providers/registry/model-ids.ts +++ b/src/providers/registry/model-ids.ts @@ -87,6 +87,8 @@ export const REGISTRY_FIELD_MODEL_ID_ROLES = { fastOptIn: NONE, modelServiceTierCapabilityBaseUrlGuard: NONE, preserveResponsesReasoningContent: NONE, + preserveResponsesInputItemIds: NONE, + preserveResponsesMessageMetadata: NONE, dropResponsesReasoningItems: NONE, modelSupportsReasoningSummaries: RECORD_KEYS, modelSupportsVerbosity: RECORD_KEYS, diff --git a/src/providers/registry/types.ts b/src/providers/registry/types.ts index ff2e46577f2..763bbbd5ccf 100644 --- a/src/providers/registry/types.ts +++ b/src/providers/registry/types.ts @@ -275,6 +275,10 @@ export interface ProviderRegistryEntry { modelServiceTierCapabilityBaseUrlGuard?: (baseUrl: string) => boolean; /** Registry default for plaintext reasoning replay; see `OcxProviderConfig.preserveResponsesReasoningContent`. Registry-only like `supportsServiceTier`. */ preserveResponsesReasoningContent?: boolean; + /** Registry default for preserving input item IDs; see `OcxProviderConfig.preserveResponsesInputItemIds`. */ + preserveResponsesInputItemIds?: boolean; + /** Registry default for preserving per-item chat metadata; see `OcxProviderConfig.preserveResponsesMessageMetadata`. */ + preserveResponsesMessageMetadata?: boolean; /** Registry default for dropping replayed reasoning items for Responses upstreams that reject them. */ dropResponsesReasoningItems?: boolean; /** Registry defaults for per-model Codex reasoning propagation; explicit user keys win during enrichment. */ diff --git a/src/router.ts b/src/router.ts index 4a682c78e27..9ae232d29d6 100644 --- a/src/router.ts +++ b/src/router.ts @@ -420,6 +420,12 @@ export function routedProviderConfig(providerName: string, provider: OcxProvider ...(provider.preserveResponsesReasoningContent === undefined && registryEntry.preserveResponsesReasoningContent !== undefined ? { preserveResponsesReasoningContent: registryEntry.preserveResponsesReasoningContent } : {}), + ...(provider.preserveResponsesInputItemIds === undefined && registryEntry.preserveResponsesInputItemIds !== undefined + ? { preserveResponsesInputItemIds: registryEntry.preserveResponsesInputItemIds } + : {}), + ...(provider.preserveResponsesMessageMetadata === undefined && registryEntry.preserveResponsesMessageMetadata !== undefined + ? { preserveResponsesMessageMetadata: registryEntry.preserveResponsesMessageMetadata } + : {}), ...(provider.dropResponsesReasoningItems === undefined && registryEntry.dropResponsesReasoningItems !== undefined ? { dropResponsesReasoningItems: registryEntry.dropResponsesReasoningItems } : {}), diff --git a/src/server/auth-cors.ts b/src/server/auth-cors.ts index c05d14038b9..ef0fa0f7fc5 100644 --- a/src/server/auth-cors.ts +++ b/src/server/auth-cors.ts @@ -992,6 +992,8 @@ const PROVIDER_CONFIG_FIELD_POLICY = { supportsServiceTier: "editor", modelSupportsServiceTier: "editor", preserveResponsesReasoningContent: "editor", + preserveResponsesInputItemIds: "editor", + preserveResponsesMessageMetadata: "editor", dropResponsesReasoningItems: "editor", modelReasoningEffortsAuthoritative: "editor", decodesNativeCompactionBlobs: "editor", diff --git a/src/types/provider.ts b/src/types/provider.ts index 08930881b23..15c952392ba 100644 --- a/src/types/provider.ts +++ b/src/types/provider.ts @@ -407,6 +407,22 @@ export interface OcxProviderConfig { * `ocxr1` envelopes are still stripped because no upstream can decrypt them. */ preserveResponsesReasoningContent?: boolean; + /** + * Whether to preserve Codex-private `input[].id` on `store: false` requests. + * Disabled by default: ordinary Responses upstreams interpret input IDs as references + * to nonexistent stored items and return 404. Upstream launcher relays such as + * Codex Web GPT (`chatgpt-web/*`) require the current-turn user message ID for + * browser-session replay (#6220). + */ + preserveResponsesInputItemIds?: boolean; + /** + * Whether to preserve ChatGPT-internal `internal_chat_message_metadata_passthrough` + * on outgoing input messages for noncanonical destinations. Disabled by default: + * public Responses gateways reject the private field as an unknown parameter. + * Upstream launcher relays such as Codex Web GPT require this metadata (or the item ID) + * to extract turn provenance (#6220). + */ + preserveResponsesMessageMetadata?: boolean; /** * Treat this provider's `modelReasoningEfforts` as authoritative at the wire, not only in the * catalog. Adapters that ship their own per-model effort table (currently `command-code`) diff --git a/structure/transports/responses-wire-shapes.md b/structure/transports/responses-wire-shapes.md index 7944955f31b..f2d4cd6e33d 100644 --- a/structure/transports/responses-wire-shapes.md +++ b/structure/transports/responses-wire-shapes.md @@ -51,6 +51,14 @@ string on this wire instead of the numeric `thinking_budget` the Chat wire appli stays unpinned. `tests/providers/alibaba-token-plan-wire-defaults.test.ts` covers the pins and the replay flag. +Responses relays and launcher proxies such as Codex Web GPT (`chatgpt-web/*`) can opt into +`preserveResponsesInputItemIds` and `preserveResponsesMessageMetadata` on custom `openai-responses` +providers. By default, ordinary third-party Responses destinations have input item IDs stripped when +`store: false` to avoid nonexistent item reference 404s, and have ChatGPT-internal +`internal_chat_message_metadata_passthrough` removed to avoid unknown parameter errors. Opting in +preserves the current-turn user message ID and private turn metadata required for browser-session +replay without relaxing sanitization for standard upstreams (#6220). + xAI keeps `openai-chat` as its provider-wide compatibility wire, but Grok 4.5/4.6/4.7 subscription Responses requests default to native `openai-responses`. Existing namespace, hosted-search and reasoning-replay normalization remains in force. The reserved `xai` OAuth transport is name-pinned diff --git a/tests/fixtures/test-layout-expected.json b/tests/fixtures/test-layout-expected.json index 459506dab5b..e0c4dbf5039 100644 --- a/tests/fixtures/test-layout-expected.json +++ b/tests/fixtures/test-layout-expected.json @@ -1421,6 +1421,7 @@ "responses-azure-opaque-blob-recovery.test.ts": "responses", "responses-bare-echo-helper-fence.test.ts": "responses", "responses-canonical-only-top-level-fields.test.ts": "responses", + "responses-chatgpt-web-replay-metadata.test.ts": "responses", "responses-code-mode-goal-helpers.test.ts": "responses", "responses-code-mode-mcp-direct.test.ts": "responses", "local-read-response-proof.test.ts": "server", diff --git a/tests/responses/responses-chatgpt-web-replay-metadata.test.ts b/tests/responses/responses-chatgpt-web-replay-metadata.test.ts new file mode 100644 index 00000000000..a62c946f999 --- /dev/null +++ b/tests/responses/responses-chatgpt-web-replay-metadata.test.ts @@ -0,0 +1,107 @@ +import { expect, test } from "bun:test"; +import { createResponsesPassthroughAdapter as createResponsesPassthroughAdapterProduction } from "../../src/adapters/openai-responses"; +import type { OcxProviderConfig } from "../../src/types"; +import { withTestTranslatorBudget } from "../helpers/translator-budget"; + +const createResponsesPassthroughAdapter = ( + ...args: Parameters +) => withTestTranslatorBudget(createResponsesPassthroughAdapterProduction(...args)); + +/** A custom Responses relay like Codex Web GPT (chatgpt-web) reached with forward or key auth. */ +const CHATGPT_WEB_RELAY_KEY: OcxProviderConfig = { + adapter: "openai-responses", + baseUrl: "http://127.0.0.1:17841/v1", + authMode: "key", + apiKey: "secret-key", + preserveResponsesInputItemIds: true, + preserveResponsesMessageMetadata: true, +}; + +const ORDINARY_NONCANONICAL_KEY: OcxProviderConfig = { + adapter: "openai-responses", + baseUrl: "https://gateway.example/v1", + authMode: "key", + apiKey: "secret-key", +}; + +function sentBody(provider: OcxProviderConfig, rawBody: Record) { + const request = createResponsesPassthroughAdapter(provider).buildRequest({ + modelId: String(rawBody.model), + context: { messages: [] }, + stream: true, + options: {}, + _rawBody: rawBody, + }, { headers: new Headers({ authorization: "Bearer token" }) }); + return JSON.parse(request.body) as Record; +} + +function sampleCodexClientBody() { + return { + model: "chatgpt-web/gpt-5.6-sol-instant", + store: false, + input: [ + { + id: "msg_user_123", + type: "message", + role: "user", + content: [{ type: "input_text", text: "Reply with exactly: WEB READY" }], + internal_chat_message_metadata_passthrough: { + turn_id: "turn_abc_456", + conversation_id: "conv_xyz", + }, + }, + ], + }; +} + +test("ordinary noncanonical Responses destinations strip input IDs when store: false and strip private metadata", () => { + const sent = sentBody(ORDINARY_NONCANONICAL_KEY, sampleCodexClientBody()); + expect(sent.input).toBeArray(); + const firstItem = (sent.input as Record[])[0]!; + expect(firstItem).not.toHaveProperty("id"); + expect(firstItem).not.toHaveProperty("internal_chat_message_metadata_passthrough"); + expect(firstItem.role).toBe("user"); +}); + +test("opted-in provider preserves input IDs when store: false for launcher session replay (#6220)", () => { + const sent = sentBody(CHATGPT_WEB_RELAY_KEY, sampleCodexClientBody()); + expect(sent.input).toBeArray(); + const firstItem = (sent.input as Record[])[0]!; + expect(firstItem.id).toBe("msg_user_123"); + expect(firstItem.internal_chat_message_metadata_passthrough).toEqual({ + turn_id: "turn_abc_456", + conversation_id: "conv_xyz", + }); + expect(firstItem.role).toBe("user"); +}); + +test("opted-in provider preserveResponsesInputItemIds alone keeps input IDs while stripping metadata", () => { + const provider: OcxProviderConfig = { + adapter: "openai-responses", + baseUrl: "http://127.0.0.1:17841/v1", + authMode: "key", + apiKey: "secret-key", + preserveResponsesInputItemIds: true, + }; + const sent = sentBody(provider, sampleCodexClientBody()); + const firstItem = (sent.input as Record[])[0]!; + expect(firstItem.id).toBe("msg_user_123"); + expect(firstItem).not.toHaveProperty("internal_chat_message_metadata_passthrough"); +}); + +test("opted-in provider preserveResponsesMessageMetadata alone keeps metadata while stripping input IDs", () => { + const provider: OcxProviderConfig = { + adapter: "openai-responses", + baseUrl: "http://127.0.0.1:17841/v1", + authMode: "key", + apiKey: "secret-key", + preserveResponsesMessageMetadata: true, + }; + const sent = sentBody(provider, sampleCodexClientBody()); + const firstItem = (sent.input as Record[])[0]!; + expect(firstItem).not.toHaveProperty("id"); + expect(firstItem.internal_chat_message_metadata_passthrough).toEqual({ + turn_id: "turn_abc_456", + conversation_id: "conv_xyz", + }); +}); From c673bdd41bcbaf8c877fdc46ec35ac84220845c7 Mon Sep 17 00:00:00 2001 From: codingbo Date: Wed, 30 Sep 2026 08:58:56 +0800 Subject: [PATCH 2/2] fix(responses): retain xai custom-tool-call repair and deduplicate sanitize in passthrough --- src/adapters/openai-responses/passthrough.ts | 64 +++++++------------ .../openai-responses/request-strips.ts | 14 ++-- ...ponses-chatgpt-web-replay-metadata.test.ts | 35 +++++++++- 3 files changed, 67 insertions(+), 46 deletions(-) diff --git a/src/adapters/openai-responses/passthrough.ts b/src/adapters/openai-responses/passthrough.ts index 10a1f493539..8aa84032b6a 100644 --- a/src/adapters/openai-responses/passthrough.ts +++ b/src/adapters/openai-responses/passthrough.ts @@ -464,49 +464,31 @@ export function createResponsesPassthroughAdapter(provider: OcxProviderConfig): // encrypted reasoning blob, including one whose provenance is unknown. Combo routing // separately refuses a proven cross-route replay when no plaintext exists; this final // serializer guard ensures the foreign opaque state is never forwarded regardless. - const sanitizedBody = normalizeToolSchemas( - provider.preserveResponsesInputItemIds === true - ? stripInvalidItemIds( - stripUnsupportedHostedTools( - sanitizeReasoningInputContent( - scrubOcxCompactionItems( - outBody, - destinationDecodesNativeCompactionBlob(provider), - threadServingIdentityChanged, - ), - { - preserveRawReasoningContent: provider.preserveResponsesReasoningContent === true, - dropNullContentChannel: !isOpenAiOperatedResponsesDestination(provider), - stripEncryptedContent: threadServingIdentityChanged || requiresPlaintextReasoningReplay(provider), - dropForeignItemId: parsed._dropForeignReasoningItemIds === true, - requirePlaintextReasoning: requiresPlaintextReasoningReplay(provider), - }, - ), - provider, - ), - ) - : stripItemIdsWhenUnstored( - stripInvalidItemIds( - stripUnsupportedHostedTools( - sanitizeReasoningInputContent( - scrubOcxCompactionItems( - outBody, - destinationDecodesNativeCompactionBlob(provider), - threadServingIdentityChanged, - ), - { - preserveRawReasoningContent: provider.preserveResponsesReasoningContent === true, - dropNullContentChannel: !isOpenAiOperatedResponsesDestination(provider), - stripEncryptedContent: threadServingIdentityChanged || requiresPlaintextReasoningReplay(provider), - dropForeignItemId: parsed._dropForeignReasoningItemIds === true, - requirePlaintextReasoning: requiresPlaintextReasoningReplay(provider), - }, - ), - provider, - ), + const baseSanitized = stripInvalidItemIds( + stripUnsupportedHostedTools( + sanitizeReasoningInputContent( + scrubOcxCompactionItems( + outBody, + destinationDecodesNativeCompactionBlob(provider), + threadServingIdentityChanged, ), - isXaiResponsesDestination(provider), + { + preserveRawReasoningContent: provider.preserveResponsesReasoningContent === true, + dropNullContentChannel: !isOpenAiOperatedResponsesDestination(provider), + stripEncryptedContent: threadServingIdentityChanged || requiresPlaintextReasoningReplay(provider), + dropForeignItemId: parsed._dropForeignReasoningItemIds === true, + requirePlaintextReasoning: requiresPlaintextReasoningReplay(provider), + }, ), + provider, + ), + ); + const sanitizedBody = normalizeToolSchemas( + stripItemIdsWhenUnstored( + baseSanitized, + isXaiResponsesDestination(provider), + provider.preserveResponsesInputItemIds === true, + ), isXaiSchemaTarget(provider), ); const unnormalizedBody = stripDisabledVerbosity( diff --git a/src/adapters/openai-responses/request-strips.ts b/src/adapters/openai-responses/request-strips.ts index f74fa0f54b5..619d1433fc8 100644 --- a/src/adapters/openai-responses/request-strips.ts +++ b/src/adapters/openai-responses/request-strips.ts @@ -191,12 +191,18 @@ export function stripRejectedSamplingParams( } /** - * When `store` is false, the upstream API does not persist response items. Any item ID + * When `store: false`, the upstream cannot retrieve stored turns by ID. An ID * forwarded in `input` is then interpreted as a reference to a stored item that does not - * exist, producing a 404. Strip all item IDs in this case — `call_id` pairing is unaffected. + * exist, producing a 404. Strip all item IDs in this case unless `preserveIds` is true + * — `call_id` pairing is unaffected. Also repairs missing or broken xAI custom_tool_call IDs + * when `requireCustomCallIds` is true, regardless of `preserveIds`. * Matches codex-rs behavior (core/src/client.rs:918-925). */ -export function stripItemIdsWhenUnstored(body: unknown, requireCustomCallIds = false): unknown { +export function stripItemIdsWhenUnstored( + body: unknown, + requireCustomCallIds = false, + preserveIds = false, +): unknown { const repairCustomCallIds = requireCustomCallIds === true; if (!isPlainObject(body) || (body.store !== false && !repairCustomCallIds)) return body; if (!Array.isArray(body.input)) return body; @@ -226,7 +232,7 @@ export function stripItemIdsWhenUnstored(body: unknown, requireCustomCallIds = f return item; } } - if (body.store !== false || !("id" in item)) return item; + if (preserveIds || body.store !== false || !("id" in item)) return item; changed = true; const next = { ...item }; delete next.id; diff --git a/tests/responses/responses-chatgpt-web-replay-metadata.test.ts b/tests/responses/responses-chatgpt-web-replay-metadata.test.ts index a62c946f999..5123ad21a47 100644 --- a/tests/responses/responses-chatgpt-web-replay-metadata.test.ts +++ b/tests/responses/responses-chatgpt-web-replay-metadata.test.ts @@ -94,7 +94,7 @@ test("opted-in provider preserveResponsesMessageMetadata alone keeps metadata wh adapter: "openai-responses", baseUrl: "http://127.0.0.1:17841/v1", authMode: "key", - apiKey: "secret-key", + apiKey: "dummy-key", preserveResponsesMessageMetadata: true, }; const sent = sentBody(provider, sampleCodexClientBody()); @@ -105,3 +105,36 @@ test("opted-in provider preserveResponsesMessageMetadata alone keeps metadata wh conversation_id: "conv_xyz", }); }); + +test("opted-in preserveResponsesInputItemIds on xAI destination still repairs custom_tool_call IDs", () => { + const provider: OcxProviderConfig = { + adapter: "openai-responses", + baseUrl: "https://api.x.ai/v1", + authMode: "key", + apiKey: "dummy-key", + preserveResponsesInputItemIds: true, + }; + const bodyWithCustomToolCall = { + model: "grok-beta", + store: false, + input: [ + { + type: "custom_tool_call", + call_id: "call_123", + name: "test_tool", + input: "{}", + }, + { + type: "message", + role: "user", + id: "msg_user_kept", + content: "hello", + }, + ], + }; + const sent = sentBody(provider, bodyWithCustomToolCall); + const items = sent.input as Record[]; + expect(items[0]!.type).toBe("custom_tool_call"); + expect(String(items[0]!.id)).toStartWith("ctc_"); + expect(items[1]!.id).toBe("msg_user_kept"); +});