Skip to content
Merged
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
15 changes: 10 additions & 5 deletions src/adapters/openai-responses.ts
Original file line number Diff line number Diff line change
Expand Up @@ -166,24 +166,29 @@ function stripInvalidItemIds(body: unknown): unknown {
* 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.
*/
const CANONICAL_ONLY_TOOL_FIELDS: readonly { field: string; toolTypes?: ReadonlySet<string> }[] = [
const CANONICAL_ONLY_TOOL_FIELDS: readonly { field: string; toolTypes?: ReadonlySet<string>; capabilityGated?: boolean }[] = [
// 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"]) },
// OWNERSHIP: official OpenAI API-key traffic and unclassified gateways ACCEPT this field, so
// it is only stripped when the provider capability denies it (supportsOpenAiWebSearchToolFields
// === false), matching stripOpenAiOnlyWebSearchFields; see
// tests/responses-routed-web-search-fields.test.ts.
{ field: "external_web_access", toolTypes: new Set(["web_search", "web_search_preview"]), capabilityGated: true },
// 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" },
];

function stripCanonicalOnlyToolFields(body: unknown): unknown {
function stripCanonicalOnlyToolFields(body: unknown, includeCapabilityGated: boolean): 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, capabilityGated } of CANONICAL_ONLY_TOOL_FIELDS) {
if (capabilityGated && !includeCapabilityGated) 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 @@ -1722,7 +1727,7 @@ export function createResponsesPassthroughAdapter(provider: OcxProviderConfig):
outBody = rewritten.body;
convertedRoutedNamespaceToolAliases = rewritten.aliases;
// Last, so promoted namespace children are also cleared of Codex-private fields.
outBody = stripCanonicalOnlyToolFields(outBody);
outBody = stripCanonicalOnlyToolFields(outBody, provider.supportsOpenAiWebSearchToolFields === false);
}
const threadServingIdentityChanged = parsed._stripReasoningEncryptedContent === true;
const sanitizedBody = normalizeToolSchemas(stripSparkCompatibility(stripUnsupportedReasoningParams(stripItemIdsWhenUnstored(stripInvalidItemIds(stripUnsupportedHostedTools(sanitizeReasoningInputContent(scrubOcxCompactionItems(
Expand Down
32 changes: 18 additions & 14 deletions tests/openai-responses-passthrough.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -976,13 +976,16 @@ describe("OpenAI Responses passthrough sanitization", () => {
expect(body.tools[0]).toMatchObject({ type: "image_generation" });
});

test("drops ChatGPT's external_web_access hint but keeps routed web search", () => {
const adapter = createResponsesPassthroughAdapter({
adapter: "openai-responses",
baseUrl: "https://api.x.ai/v1",
authMode: "key" as const,
apiKey: "xai-test",
});
test("drops ChatGPT's external_web_access hint but keeps routed web search", () => {
const adapter = createResponsesPassthroughAdapter({
adapter: "openai-responses",
baseUrl: "https://api.x.ai/v1",
authMode: "key" as const,
apiKey: "xai-test",
// The registry declares this denial for xAI; the strip is capability-driven, not
// hostname-driven (official OpenAI API-key traffic keeps the fields).
supportsOpenAiWebSearchToolFields: false,
});
const request = adapter.buildRequest({
modelId: "grok-4.6",
context: { messages: [] },
Expand Down Expand Up @@ -1033,13 +1036,14 @@ describe("OpenAI Responses passthrough sanitization", () => {
// `activateDeferredTool` clears `defer_loading` only for tools a `tool_search_output` already
// loaded, so the first turn of a deferred catalog — and any child promoted out of a namespace
// group — otherwise carries the private field to a gateway that rejects unknown arguments.
test("drops Codex-private tool fields from routed declarations", () => {
const adapter = createResponsesPassthroughAdapter({
adapter: "openai-responses",
baseUrl: "https://api.x.ai/v1",
authMode: "key" as const,
apiKey: "xai-test",
});
test("drops Codex-private tool fields from routed declarations", () => {
const adapter = createResponsesPassthroughAdapter({
adapter: "openai-responses",
baseUrl: "https://api.x.ai/v1",
authMode: "key" as const,
apiKey: "xai-test",
supportsOpenAiWebSearchToolFields: false,
});
const request = adapter.buildRequest({
modelId: "grok-4.6",
context: { messages: [] },
Expand Down
Loading