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
42 changes: 29 additions & 13 deletions src/adapters/openai-responses.ts
Original file line number Diff line number Diff line change
Expand Up @@ -156,34 +156,49 @@ function stripInvalidItemIds(body: unknown): unknown {
}

/**
* Codex-private tool fields that only the ChatGPT backend understands.
* Tool fields with destination-specific support.
*
* A third-party Responses gateway validates its schema and rejects the whole request before
* inference — xAI answers `Argument not supported: external_web_access` — so these are removed at
* the noncanonical boundary while the tool and every public option stay.
* A third-party Responses gateway validates its schema and may reject the whole request before
* inference, so each entry declares which destinations understand it while the tool and every
* public option stay intact elsewhere.
*
* Keep this a table. Each private bit Codex attaches has so far arrived as its own bespoke strip
* with its own traversal, and the traversals disagreed about which containers they covered; a new
* one should be a row here instead. `toolTypes` omitted means the field is private on any tool.
* one should be a row here instead. `toolTypes` omitted means the field is scoped on any tool.
*
* `external_web_access` is understood by both OpenAI-operated Responses surfaces. Routed web-search
* incompatibility is also handled by the `supportsOpenAiWebSearchToolFields` capability flag, which
* strips the broader OpenAI-only web-search field set for explicit denials; do not collapse that
* layer into this table. `defer_loading` differs because it is private to the canonical ChatGPT
* surface and the official OpenAI API rejects it.
*/
const CANONICAL_ONLY_TOOL_FIELDS: readonly { field: string; toolTypes?: ReadonlySet<string> }[] = [
// ChatGPT's browsing policy bit. The public hosted tool is enabled by its presence alone.
{ field: "external_web_access", toolTypes: new Set(["web_search", "web_search_preview"]) },
const CANONICAL_ONLY_TOOL_FIELDS: readonly {
field: string;
toolTypes?: ReadonlySet<string>;
isSupportedDestination: (provider: OcxProviderConfig) => boolean;
}[] = [
// OpenAI's browsing policy bit. The public hosted tool is enabled by its presence alone.
{
field: "external_web_access",
toolTypes: new Set(["web_search", "web_search_preview"]),
isSupportedDestination: isOpenAiOperatedResponsesDestination,
},
// Deferred-discovery marker. `activateDeferredTool` clears it only for tools a `tool_search_output`
// already loaded, so a still-deferred declaration — including one promoted out of a namespace
// group — otherwise reaches the wire carrying it.
{ field: "defer_loading" },
{ field: "defer_loading", isSupportedDestination: isCanonicalOpenAiForwardProvider },
];

function stripCanonicalOnlyToolFields(body: unknown): unknown {
function stripCanonicalOnlyToolFields(body: unknown, provider: OcxProviderConfig): unknown {
if (!isPlainObject(body)) return body;

const rewriteTools = (tools: unknown[]): unknown[] => {
let changed = false;
const rewritten = tools.map(tool => {
if (!isPlainObject(tool)) return tool;
let next = tool;
for (const { field, toolTypes } of CANONICAL_ONLY_TOOL_FIELDS) {
for (const { field, toolTypes, isSupportedDestination } of CANONICAL_ONLY_TOOL_FIELDS) {
if (isSupportedDestination(provider)) continue;
if (!Object.hasOwn(next, field)) continue;
if (toolTypes && (typeof next.type !== "string" || !toolTypes.has(next.type))) continue;
const { [field]: _private, ...rest } = next;
Expand Down Expand Up @@ -1721,9 +1736,10 @@ export function createResponsesPassthroughAdapter(provider: OcxProviderConfig):
const rewritten = rewriteRoutedNamespaceToolsForUpstream(outBody);
outBody = rewritten.body;
convertedRoutedNamespaceToolAliases = rewritten.aliases;
// Last, so promoted namespace children are also cleared of Codex-private fields.
outBody = stripCanonicalOnlyToolFields(outBody);
}
// Last, so promoted namespace children are also cleared of destination-unsupported fields.
// This still runs for OpenAI API-key traffic, where only the canonical-only entries apply.
outBody = stripCanonicalOnlyToolFields(outBody, provider);
const threadServingIdentityChanged = parsed._stripReasoningEncryptedContent === true;
const sanitizedBody = normalizeToolSchemas(stripSparkCompatibility(stripUnsupportedReasoningParams(stripItemIdsWhenUnstored(stripInvalidItemIds(stripUnsupportedHostedTools(sanitizeReasoningInputContent(scrubOcxCompactionItems(
outBody,
Expand Down
49 changes: 49 additions & 0 deletions tests/responses-routed-web-search-fields.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,25 @@ function buildWebSearchBody(provider: OcxProviderConfig): Record<string, unknown
return JSON.parse(request.body) as Record<string, unknown>;
}

function buildScopedToolFieldsBody(provider: OcxProviderConfig): Record<string, unknown> {
const request = createResponsesPassthroughAdapter(provider).buildRequest({
modelId: "test-model",
context: { messages: [] },
stream: true,
options: {},
_rawBody: {
model: "test-model",
input: "ping",
tools: [{
type: "web_search",
external_web_access: true,
defer_loading: true,
}],
},
}, { headers: new Headers() });
return JSON.parse(request.body) as Record<string, unknown>;
}

// #2188 follow-up: routed Responses upstreams (xAI api.x.ai) 400 the WHOLE request on
// OpenAI-only web_search config fields (probe 2026-08-21: external_web_access and
// search_context_size each 400 individually; user_location and filters are accepted).
Expand Down Expand Up @@ -81,3 +100,33 @@ describe("Responses buildRequest web_search capability", () => {
}]);
});
});

describe("Responses buildRequest destination-scoped tool fields", () => {
test("official OpenAI API-key provider keeps external_web_access and drops defer_loading", () => {
const body = buildScopedToolFieldsBody({
adapter: "openai-responses",
baseUrl: "https://api.openai.com/v1",
authMode: "key",
apiKey: "test-openai-key",
});

expect(body.tools).toEqual([{
type: "web_search",
external_web_access: true,
}]);
});

test("canonical ChatGPT forward provider keeps both fields", () => {
const body = buildScopedToolFieldsBody({
adapter: "openai-responses",
baseUrl: "https://chatgpt.com/backend-api/codex",
authMode: "forward",
});

expect(body.tools).toEqual([{
type: "web_search",
external_web_access: true,
defer_loading: true,
}]);
});
});
Loading