Skip to content

[Provider compatibility] Google extra_content from tool history leaks into native Codex response.create and causes 400 #7111

Open

Description

@nordz0r

Client or integration

Other — omp (OpenAI-compatible Responses client), CLI 18.10.4 at the time of investigation. The error occurs intermittently when falling back from ocx/jev-auto to ocx/gpt-6-sol:auto with tool-call history.

Provider or upstream service

Native OpenAI/ChatGPT Codex forward route. The replayed metadata originates from Google/Gemini tool-call history through the OpenCodex Responses bridge. The user provided an actual rejection from Codex; this report makes no claims about Grok or Claude/Opus behaviour.

OpenCodex version

Investigated main 2.86.0, commit 409026b1bcb184e523ccc4f22930dc0a5a6ffa01. Also checked dev 2.87.0 (7640a942b35044c974e534b84d5a8049f4a8ae9a): the main...dev comparison only changes version/package/desktop files, not the adapter/metadata paths investigated here. The running server version at the time of the original 400 was not established; this report does not identify it as 2.86.0.

Endpoint or capability

Client-facing /v1/responses; outbound https://chatgpt.com/backend-api/codex/responses / WS response.create. Cross-provider history replay and fallback, function-call metadata.

Current behaviour

OpenCodex serializes a Google thought signature on a Responses function_call as extra_content.google.thought_signature. A client can legitimately preserve this item and replay it in the next request's history. If the next destination is native Codex, the canonical passthrough adapter retains this Google-only field in the outbound input[]. The user receives 400 Unknown parameter: 'input[48].extra_content'.

Local execution of the production createResponsesPassthroughAdapter(...).buildRequest(...) and prepareCodexWsRequest(...) on the main commit above confirmed that the field survives in both the serialized HTTP body and the serialized WS frame. The original caller-owned history remains unchanged.

Existing filters — stripInternalChatMessageMetadataPassthrough, stripCanonicalOnlyTopLevelFields, reasoning sanitization, and canonical forward envelope normalization — do not remove extra_content. No supported setting for this removal was found in src/types or docs-site. This report does not propose stripping all unknown fields from arbitrary gateways: a gateway may support its own extensions.

Expected behaviour

Tool-call history returned to a client by OpenCodex itself should be replayable to native Codex without a 400 caused by Google-only metadata. Remove incompatible extra_content from the outbound projection only for destinations confirmed to reject it; preserve call_id/name/arguments/output, caller-owned history, and Google metadata/replay for switching back to Gemini. HTTP and WS should behave consistently, including direct native requests and concrete combo/fallback children.

Minimal redacted request or reproduction

Reported sequence: a Google tool call through jev-auto → the client preserves the Responses item with extra_content → fallback/replay of the same tool history to native gpt-6-sol → the actual 400 below.

The following deterministic local probe runs on a clean snapshot of the main commit above without calling upstream or requiring credentials. With Bun installed, save it as check-extra-content.ts at the snapshot root and run bun check-extra-content.ts:

import { createResponsesPassthroughAdapter } from './src/adapters/openai-responses/passthrough';
import { prepareCodexWsRequest } from './src/server/responses/codex-ws-request';
import { responsesExtraContentFromProviderMetadata } from './src/responses/provider-opaque-metadata';
import { createTranslatorBudget } from './src/lib/translator-budget';
import { deepStrictEqual, ok } from 'node:assert';

const extra = responsesExtraContentFromProviderMetadata({
  google: { thoughtSignature: 'synthetic-signature-for-local-probe-only' },
})!;
const body = {
  model: 'gpt-6-sol', stream: true, store: false,
  input: [
    { role: 'user', content: 'Continue after the completed tool call.' },
    { type: 'function_call', call_id: 'call_probe', name: 'probe', arguments: '{}', ...extra },
    { type: 'function_call_output', call_id: 'call_probe', output: 'ok' },
  ],
  tools: [{ type: 'function', name: 'probe', parameters: { type: 'object', properties: {} } }],
};
const before = JSON.stringify(body);
const budget = createTranslatorBudget();
try {
  const adapter = createResponsesPassthroughAdapter({
    adapter: 'openai-responses',
    baseUrl: 'https://chatgpt.com/backend-api/codex', authMode: 'forward',
  });
  const request = adapter.buildRequest({
    modelId: body.model, context: { messages: [] }, stream: true, options: {}, _rawBody: body,
  }, { headers: new Headers(), translatorBudget: budget });
  const httpBody = JSON.parse(request.body);
  const ws = prepareCodexWsRequest(request.url, {
    method: request.method, headers: request.headers, body: request.body,
  });
  ok(ws);
  const frame = JSON.parse(ws.frameText);
  const isCall = (item: unknown) => item !== null && typeof item === 'object'
    && 'type' in item && item.type === 'function_call';
  const httpCall = httpBody.input.find(isCall);
  const wsCall = frame.input.find(isCall);
  // Current behaviour: Google-only extension survives both outbound serializers.
  deepStrictEqual(httpCall.extra_content, extra.extra_content);
  deepStrictEqual(wsCall.extra_content, extra.extra_content);
  deepStrictEqual(httpCall.arguments, '{}');
  deepStrictEqual(JSON.stringify(body), before);
  console.log(JSON.stringify({
    url: request.url, frameType: frame.type,
    httpExtraContent: httpCall.extra_content, wsExtraContent: wsCall.extra_content,
    callerHistoryUnchanged: true,
  }, null, 2));
  request.releaseBodyObservation?.();
} finally { budget.dispose(); }

The signature is synthetic and intended only for the local serialization probe; do not send it to Gemini. This probe demonstrates the extension leaking into the outbound payload, not a live upstream rejection.

Actual response or error

User-provided error, with no credentials or history content:

Warning: Fallback: ocx/jev-auto -> ocx/gpt-6-sol:auto
Request failed: 400 Provider error 400:
{"error":{"message":"Invalid response.create payload: Unknown parameter: 'input[48].extra_content'.","type":"invalid_request_error","param":null,"code":null}}

Observed output from bun check-extra-content.ts (Bun 1.4.2, Linux 6.1.0-52-amd64):

{
  "url": "https://chatgpt.com/backend-api/codex/responses",
  "frameType": "response.create",
  "httpExtraContent": {"google": {"thought_signature": "synthetic-signature-for-local-probe-only"}},
  "wsExtraContent": {"google": {"thought_signature": "synthetic-signature-for-local-probe-only"}},
  "callerHistoryUnchanged": true
}

The full bun test tests/responses/openai-responses-passthrough.test.ts file was invoked but could not load: Cannot find module 'zod/v4' ... src/config/schema/leaf-validators.ts (0 pass, 1 error). This is not a passing test suite; the production serialization probe above ran successfully on its own. Upstream code was not modified.

Upstream documentation

No public specification for this field on the private ChatGPT Codex response.create backend was established; the concrete evidence of incompatibility is the supplied upstream validation error. The client requirement is to replay proxy-generated history across route changes without a protocol rejection. For the origin and necessity of Google signatures, see https://ai.google.dev/gemini-api/docs/thought-signatures and OpenCodex #1735 / #1781 / #1823.

Suggested mapping or implementation notes

A likely location is destination-aware canonical outbound normalization before serialization in src/adapters/openai-responses/passthrough.ts, rather than only the WS wrapper or a global cleanup of client history. Verification should cover HTTP/WS, direct native routes and combo children, preservation of original args/results, and no mutation of raw history; Google signature replay must remain functional. Other Responses destinations should be classified based on evidence, not a blanket non-Google rule.

References to the investigated code:

  • /** Serialize provider metadata onto an outbound Responses function_call item. */
    export function responsesExtraContentFromProviderMetadata(
    metadata: OcxProviderOpaqueToolCallMetadata | undefined,
    ): { extra_content: { google: { thought_signature: string } } } | undefined {
    const signature = metadata?.google?.thoughtSignature;
    if (!isCarryableSignature(signature)) return undefined;
    return { extra_content: { google: { thought_signature: signature } } };
  • let outBody = stripPreviousResponseId(
    parsed._rawBody,
    forward || parsed._previousResponseInputExpanded === true,
    );
    if (!forward) outBody = normalizeRoutedAgentMessages(outBody, {
    allowStringContent: isXaiResponsesDestination(provider),
    });
    // #5217: a sub-agent inherits the parent session's instruction block, so the identity
    // sentence this proxy generated for the PARENT's model rides along to a worker running a
    // different one. On a routed destination it is renamed to that destination — including the
    // model-neutral catalog sentence, which this adapter never names itself; on a native/forward
    // destination our sentence is dropped, because Codex's own identity wording (sent in the
    // client's model_switch block) is the correct one there. Text the proxy did not generate —
    // user turns, tool output, fenced code, provider-native blocks — is untouched.
    outBody = repairIdentityInResponsesBody(
    outBody,
    forward ? stripRoutedIdentity : (text: string) => nameRoutedIdentity(text, parsed.modelId),
    );
    outBody = mapRoutedResponsesReasoningEffort(outBody, provider, parsed.modelId);
    // stripPreviousResponseId() intentionally returns its input on a no-op. Detach before the
    // tier write so a force-fast/default decision can never mutate parsed._rawBody.
    outBody = applyTierDecisionToResponsesBody(outBody, parsed.options?.tierDecision);
    const stateless = provider.statelessResponses === true;
    const adjacentToolResults = provider.requiresAdjacentResponsesToolResults === true;
    // Adjacency reorders items the upstream would accept in some order. Pairing synthesizes an
    // item the client never sent, which is a larger claim about the conversation, so it is its
    // own capability: Kimi carries the adjacency flag but accepts a dangling call (#4726) and
    // must not start receiving placeholders it never needed.
    const pairedToolResults = provider.requiresPairedResponsesToolResults === true;
    if (stateless) outBody = stripStatefulResponsesParams(outBody);
    // A replay miss can leave a function_call_output whose paired function_call sat
    // in the prefix that was never expanded. A stateless upstream cannot resolve the
    // pair from its own storage either, so it needs the same repair the forward
    // backend gets — dropping previous_response_id is not much use if the body that
    // reaches the wire is unparseable.
    // A parser can also 400 on a function_call with no matching output at all. DeepSeek gets
    // that repair through statelessResponses. xAI cannot be marked stateless: its Responses API
    // stores conversations for 30 days and documents previous_response_id. So it carries the
    // pairing capability instead, which reuses the orphan-call placeholder without touching
    // store or previous_response_id.
    if (provider.annotateEmptyToolOutputs === true) {
    outBody = annotateEmptyResponsesToolOutputs(outBody, true);
    }
    const synthesizeMissingCallOutputs = !forward && (stateless || pairedToolResults);
    if (forward || stateless || pairedToolResults) {
    // A stateful destination can resolve an output-only delta against the call stored behind
    // an unexpanded previous_response_id. All other shapes have no hidden call to preserve.
    const repairOrphanOutputs = forward || stateless || !unexpandedMiss;
    outBody = repairOrphanedInputItems(
    outBody,
    unexpandedMiss,
    synthesizeMissingCallOutputs,
    repairOrphanOutputs,
    );
    }
    if (provider.dropResponsesReasoningItems === true) {
    outBody = dropResponsesReasoningInputItems(outBody);
    }
    if (adjacentToolResults) {
    outBody = normalizeResponsesToolResultAdjacency(outBody);
    }
    if (forward) {
    // `metadata` is stripped on every forward route for compatibility with the canonical backend.
    // `max_output_tokens` is stripped only when this provider is the canonical backend.
    outBody = stripUnsupportedForwardParams(outBody, isCanonicalOpenAiForwardProvider(provider));
    // Only the canonical ChatGPT backend rejects the canonical-only fields below; a self-hosted
    // or third-party forward gateway may accept them, so this guard must not be widened.
    if (isCanonicalOpenAiForwardProvider(provider)) {
    outBody = stripCanonicalForwardSamplingParams(outBody);
    outBody = stripDeprecatedPromptCacheRetention(outBody, parsed.modelId);
    outBody = stripCanonicalForwardPromptCacheOptions(outBody);
    outBody = normalizeCanonicalForwardPromptEnvelope(outBody);
    outBody = normalizeCanonicalForwardContinuationEnvelope(outBody);
  • export function prepareCodexWsRequest(url: string, init: RequestInit): PreparedCodexWsRequest | null {
    if (typeof init.body !== "string") return null;
    try {
    const parsed: unknown = JSON.parse(init.body);
    if (!isRecord(parsed)) return null;
    const body = { ...parsed };
    const canonical = url === CODEX_RESPONSES_HTTP_URL;
    const httpHeaders = new Headers(init.headers);
    if (canonical) {
    if (!applyLiteMetadata(body, httpHeaders)) return null;
    applyCodexRoutingHint(httpHeaders, body);
    }
    const httpInit = { ...init, headers: httpHeaders };
    // WS is implicitly streaming; retain every other caller field except type.
    delete body.stream;
    const frameText = JSON.stringify({ ...body, type: "response.create" });
    const headers: Record<string, string> = {};
    httpHeaders.forEach((value, key) => {
    if (key === "content-type" || key === "content-length" || key === "accept" || key === "accept-encoding") return;
    headers[key] = value;
    });
    // Preserve the existing beta composition for both canonical and opted-in gateways.
    headers["openai-beta"] = headers["openai-beta"]
    ? headers["openai-beta"].includes("responses_websockets")
    ? headers["openai-beta"]
    : `${headers["openai-beta"]}, ${WS_BETA}`
    : WS_BETA;
    return { frameText, headers, httpInit, canonical };

Additional context and attachments

Searches of issues/PRs for extra_content, Unknown parameter, and thought/Codex/fallback, including comments, found no exact duplicate. #1735, #2545, #2440 and PRs #1781/#1823 address the reverse problem — lost signatures on Google replay — rather than an unknown Google-only field on Codex. This is a separate provider-compatibility bug, not a request for a new model or routing strategy.

Checks

  • I searched existing provider and compatibility issues.
  • The request and response were redacted.
  • The expected behaviour is based on an upstream specification or a concrete client requirement.

Activity

  1. added
    providerProvider adapters, OpenAI-compat presets, upstream API quirks
    streamingSSE, WebSocket, terminal stream frames
    toolstool_calls, MCP, web-search / sidecar tools
    on Oct 14, 2026
  2. changed the title [-][Provider compatibility] Google extra_content из tool history попадает в native Codex response.create и вызывает 400[/-] [+][Provider compatibility] Google extra_content from tool history leaks into native Codex response.create and causes 400[/+] on Oct 14, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    providerProvider adapters, OpenAI-compat presets, upstream API quirksprovider-compatibilityProvider compatibility reportsstreamingSSE, WebSocket, terminal stream framestoolstool_calls, MCP, web-search / sidecar tools

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions