Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions scripts/test-layout/layout.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
44 changes: 24 additions & 20 deletions src/adapters/openai-responses/passthrough.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -462,28 +464,30 @@ 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(
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,
),
{
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),
);
Expand Down
14 changes: 10 additions & 4 deletions src/adapters/openai-responses/request-strips.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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;
Expand Down
2 changes: 2 additions & 0 deletions src/config/schema/leaf-validators.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand Down
2 changes: 2 additions & 0 deletions src/providers/derive.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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));
Expand Down
2 changes: 2 additions & 0 deletions src/providers/model-rename-fields.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
2 changes: 2 additions & 0 deletions src/providers/registry/model-ids.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
4 changes: 4 additions & 0 deletions src/providers/registry/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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. */
Expand Down
6 changes: 6 additions & 0 deletions src/router.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 }
: {}),
Expand Down
2 changes: 2 additions & 0 deletions src/server/auth-cors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
16 changes: 16 additions & 0 deletions src/types/provider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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`)
Expand Down
8 changes: 8 additions & 0 deletions structure/transports/responses-wire-shapes.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions tests/fixtures/test-layout-expected.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
140 changes: 140 additions & 0 deletions tests/responses/responses-chatgpt-web-replay-metadata.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,140 @@
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<typeof createResponsesPassthroughAdapterProduction>
) => 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<string, unknown>) {
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<string, unknown>;
}

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<string, unknown>[])[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<string, unknown>[])[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<string, unknown>[])[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: "dummy-key",
preserveResponsesMessageMetadata: true,
};
const sent = sentBody(provider, sampleCodexClientBody());
const firstItem = (sent.input as Record<string, unknown>[])[0]!;
expect(firstItem).not.toHaveProperty("id");
expect(firstItem.internal_chat_message_metadata_passthrough).toEqual({
turn_id: "turn_abc_456",
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<string, unknown>[];
expect(items[0]!.type).toBe("custom_tool_call");
expect(String(items[0]!.id)).toStartWith("ctc_");
expect(items[1]!.id).toBe("msg_user_kept");
});
Loading