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
45 changes: 42 additions & 3 deletions src/adapters/openai-responses.ts
Original file line number Diff line number Diff line change
Expand Up @@ -467,7 +467,12 @@ function normalizeConfiguredReasoningSummaryDelivery(
* namespace, tool_search, web_search, custom) plus extensions (defer_loading,
* parallel_tool_calls, tool_search_call/output items). Spark's serving path only
* supports flat function tools and hosted web_search. This function:
* - Flattens namespace tools → promotes inner functions to top level
* - Flattens MCP-style namespace tools → promotes inner functions to top level. The reserved
* `functions` group is kept as a group (#3217): Codex 0.147+ sends every ordinary client tool
* inside it on Responses Lite, the backend accepts the group as-is, and flattening it changes
* what the backend answers with — a `custom_tool_call` carrying `namespace: "exec"`, which
* codex-rs concatenates into the unroutable `execexec`. Traced on a live proxy: with the
* group intact the same backend returns the bare `exec` call and the turn completes.
* - Drops unsupported tool types (tool_search, custom)
* - Strips defer_loading from function tools
* - Strips namespace from input items
Expand All @@ -482,12 +487,39 @@ function stripSparkCompatibility(body: unknown): unknown {
let changed = false;

const SPARK_SAFE_TOOL_TYPES = new Set(["function", "web_search", "web_search_preview"]);
// Inside the reserved group Codex sends freeform `custom` tools (code-mode `exec`) and the
// backend accepts them there; the top-level "drop custom" rule stays for flattened groups.
const SPARK_SAFE_FUNCTIONS_GROUP_CHILD_TYPES = new Set(["function", "custom"]);
const filterSparkFunctionsGroup = (group: Record<string, unknown>): Record<string, unknown> | undefined => {
if (!Array.isArray(group.tools)) return undefined;
let groupChanged = false;
const children: unknown[] = [];
for (const child of group.tools) {
if (!isPlainObject(child) || typeof child.type !== "string" || !SPARK_SAFE_FUNCTIONS_GROUP_CHILD_TYPES.has(child.type)) {
groupChanged = true;
continue;
}
if (child.type === "function" && "defer_loading" in child) {
const { defer_loading: _, ...rest } = child;
groupChanged = true;
children.push(rest);
continue;
}
children.push(child);
}
if (children.length === 0) return undefined;
return groupChanged ? { ...group, tools: children } : group;
};

let tools = body.tools;
if (Array.isArray(tools)) {
const flattened: unknown[] = [];
for (const t of tools) {
if (isPlainObject(t) && t.type === "namespace") {
if (isPlainObject(t) && t.type === "namespace" && t.name === SPARK_RESERVED_FUNCTIONS_NAMESPACE) {
const kept = filterSparkFunctionsGroup(t);
if (kept !== t) changed = true;
if (kept) flattened.push(kept);
Comment on lines +518 to +521

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Normalize function schemas inside the preserved group

When a functions group contains a function whose parameters is absent or not an object schema, preserving the group here prevents the later normalizeToolSchemas pass from repairing it because that pass only examines direct entries in body.tools and additional_tools.tools. Before this change the group was flattened, so the child received the required { type: "object" } normalization; now the malformed nested declaration reaches Spark and can make the upstream reject the entire request. Apply the same schema normalization recursively to retained group children.

AGENTS.md reference: src/AGENTS.md:L19-L19

Useful? React with 👍 / 👎.

} else if (isPlainObject(t) && t.type === "namespace") {
changed = true;
if (Array.isArray(t.tools)) {
for (const inner of t.tools) flattened.push(inner);
Expand Down Expand Up @@ -527,7 +559,11 @@ function stripSparkCompatibility(body: unknown): unknown {
const innerTools = item.tools as unknown[];
const filteredInner: unknown[] = [];
for (const t of innerTools) {
if (isPlainObject(t) && t.type === "namespace") {
if (isPlainObject(t) && t.type === "namespace" && t.name === SPARK_RESERVED_FUNCTIONS_NAMESPACE) {
const kept = filterSparkFunctionsGroup(t);
if (kept !== t) changed = true;
if (kept) filteredInner.push(kept);
} else if (isPlainObject(t) && t.type === "namespace") {
changed = true;
if (Array.isArray(t.tools)) {
for (const fn of t.tools) filteredInner.push(fn);
Expand Down Expand Up @@ -574,6 +610,9 @@ function isPlainObject(v: unknown): v is Record<string, unknown> {
return !!v && typeof v === "object" && !Array.isArray(v);
}

/** Codex's reserved client-tool group on Responses Lite; carries no wire prefix. */
const SPARK_RESERVED_FUNCTIONS_NAMESPACE = "functions";

/**
* Apply the routed provider's real effort ladder to an existing Responses reasoning field.
* Native forward requests keep the server-owned native clamp; unknown third-party ladders stay
Expand Down
63 changes: 63 additions & 0 deletions src/server/responses-self-named-namespace-scrub.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
import type { SsePayloadRewrite } from "./sse-payload-rewrite";

function isPlainObject(value: unknown): value is Record<string, unknown> {
return !!value && typeof value === "object" && !Array.isArray(value);
}

/**
* Drop a tool-call `namespace` that merely repeats the call's own `name` (#3217).
*
* codex-rs resolves a client tool call as `ToolName::new(namespace, name)` and treats only
* `None | "" | "functions"` as the default namespace; anything else is concatenated into a flat
* name before routing. A backend answer of `{ name: "exec", namespace: "exec" }` therefore
* becomes `execexec`, which no client tool matches, and Codex re-issues the same call forever.
* That shape is never a legitimate identity — an MCP namespace is a server name, not the tool —
* so it is safe to scrub without consulting the declared catalog. The adapter fix that stops
* provoking the answer lives in `stripSparkCompatibility`; this is the belt to that suspender.
*/
export function scrubSelfNamedToolCallNamespace(value: unknown): { value: unknown; changed: boolean } {
if (Array.isArray(value)) {
let changed = false;
const out = value.map(entry => {
const result = scrubSelfNamedToolCallNamespace(entry);
changed ||= result.changed;
return result.value;
});
return changed ? { value: out, changed: true } : { value, changed: false };
}
if (!isPlainObject(value)) return { value, changed: false };
let changed = false;
const out: Record<string, unknown> = {};
for (const [key, entry] of Object.entries(value)) {
const result = scrubSelfNamedToolCallNamespace(entry);
out[key] = result.value;
changed ||= result.changed;
}
if (
(value.type === "custom_tool_call" || value.type === "function_call")
&& typeof value.name === "string"
&& value.name.length > 0
&& value.namespace === value.name
Comment on lines +36 to +40

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Restrict scrubbing to malformed Spark calls

When a caller legitimately declares a namespace and child with the same name (for example, namespace mcp__worker containing function mcp__worker), this unconditional equality check removes the namespace from every provider/model's streamed and bounded passthrough response. The client then receives a different bare-tool identity; depending on the catalog, the undeclared-tool guard either rejects the response or permits dispatch to the wrong bare tool. Gate this repair on the affected Spark route and verify that the bare tool was actually declared instead of assuming every self-named pair is malformed.

AGENTS.md reference: src/AGENTS.md:L19-L19

Useful? React with 👍 / 👎.

) {
delete out.namespace;
changed = true;
}
return changed ? { value: out, changed: true } : { value, changed: false };
}

export function scrubSelfNamedToolCallNamespaceInJson(text: string): string {
if (!text.includes("\"namespace\"")) return text;
let payload: unknown;
try {
payload = JSON.parse(text);
} catch {
return text;
}
const result = scrubSelfNamedToolCallNamespace(payload);
return result.changed ? JSON.stringify(result.value) : text;
}

export function createSelfNamedToolCallNamespaceScrubRewrite(): SsePayloadRewrite {
return payload => scrubSelfNamedToolCallNamespaceInJson(payload);
}

8 changes: 7 additions & 1 deletion src/server/responses/core.ts
Original file line number Diff line number Diff line change
Expand Up @@ -326,6 +326,10 @@ import {
restoreImageGenCallsInJson,
} from "../responses-image-gen-repair";
import { createResponsesModelPayloadRewrite, rewriteResponsesModelJson } from "../responses-model-rewrite";
import {
createSelfNamedToolCallNamespaceScrubRewrite,
scrubSelfNamedToolCallNamespaceInJson,
} from "../responses-self-named-namespace-scrub";
import type { EffectiveSubagentRoster, SpawnAgentSurface } from "../../codex/catalog";

import { buildToolBridgeMaps, collabSurface, injectDeveloperMessage, multiAgentGuidanceText } from "./collaboration";
Expand Down Expand Up @@ -4641,6 +4645,8 @@ async function handleResponsesInner(
// Compose opt-in payload rewrites into one parse/stringify pass (image-gen restore first).
const payloadRewrites = [
createImageGenCallRestoreRewrite(imageGenCallAliases),
// #3217: a call whose namespace repeats its own name is unroutable in codex-rs.
createSelfNamedToolCallNamespaceScrubRewrite(),
routedNamespaceToolAliases.size > 0
? createRoutedNamespaceCallRestoreRewrite(routedNamespaceToolAliases)
: undefined,
Expand Down Expand Up @@ -4873,7 +4879,7 @@ async function handleResponsesInner(
inspectResponseLogJson(logCtx, text);
const clientJson = (() => {
const restoredNamespace = restoreRoutedNamespaceCallsInJson(
restoreImageGenCallsInJson(text, imageGenCallAliases),
scrubSelfNamedToolCallNamespaceInJson(restoreImageGenCallsInJson(text, imageGenCallAliases)),
routedNamespaceToolAliases,
);
const restoredAuthorizedBareNamespace = restoreRoutedNamespaceCallsInJson(
Expand Down
55 changes: 55 additions & 0 deletions tests/openai-responses-passthrough.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1717,6 +1717,61 @@ describe("OpenAI Responses passthrough sanitization", () => {
});
});

test("keeps the reserved functions group intact for codex-spark, flattens MCP groups (#3217)", () => {
// Codex 0.147+ on Responses Lite ships every ordinary client tool inside the reserved
// `functions` namespace group, carried in an `additional_tools` input item. Flattening that
// group made the backend answer `custom_tool_call { name: "exec", namespace: "exec" }`,
// which codex-rs concatenates into the unroutable `execexec` and loops on.
const adapter = createResponsesPassthroughAdapter(provider);
const functionsGroup = {
type: "namespace",
name: "functions",
description: "client tools",
tools: [
{ type: "custom", name: "exec", description: "shell" },
{ type: "function", name: "wait", parameters: { type: "object", properties: {} }, defer_loading: true },
{ type: "tool_search", name: "tool_search" },
],
};
const mcpGroup = {
type: "namespace",
name: "mcp__docs",
tools: [{ type: "function", name: "search", parameters: { type: "object", properties: {} } }],
};
const request = adapter.buildRequest({
modelId: "gpt-5.3-codex-spark",
context: { messages: [] },
stream: true,
options: {},
_rawBody: {
model: "gpt-5.3-codex-spark",
input: [
{ type: "additional_tools", role: "developer", tools: [functionsGroup, mcpGroup] },
{ type: "message", role: "user", content: [{ type: "input_text", text: "run pwd" }] },
],
tools: [functionsGroup, mcpGroup],
},
}, { headers: new Headers({ authorization: "Bearer token" }) });
const body = JSON.parse(request.body) as {
tools: Array<Record<string, unknown>>;
input: Array<{ type: string; tools?: Array<Record<string, unknown>> }>;
};
const expectedGroup = {
type: "namespace",
name: "functions",
description: "client tools",
tools: [
{ type: "custom", name: "exec", description: "shell" },
{ type: "function", name: "wait", parameters: { type: "object", properties: {} } },
],
};
// The reserved group survives as a group with its custom child; tool_search is still dropped
// and defer_loading still stripped inside it. The MCP group is still flattened.
expect(body.tools).toEqual([expectedGroup, { type: "function", name: "search", parameters: { type: "object", properties: {} } }]);
const additional = body.input.find(item => item.type === "additional_tools");
expect(additional?.tools).toEqual([expectedGroup, { type: "function", name: "search", parameters: { type: "object", properties: {} } }]);
});

test("strips image_generation hosted tool for codex-spark passthrough", () => {
const adapter = createResponsesPassthroughAdapter(provider);
const request = adapter.buildRequest({
Expand Down
132 changes: 132 additions & 0 deletions tests/responses-self-named-namespace-scrub.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,132 @@
/**
* #3217 — a custom_tool_call whose `namespace` repeats its own `name` must not reach Codex.
*
* codex-rs resolves `ToolName::new(namespace, name)` and only treats None/""/"functions" as the
* default namespace; `{ name: "exec", namespace: "exec" }` becomes the flat name `execexec`,
* which no client tool matches, and Codex re-issues the call every turn. The adapter fix keeps
* the reserved `functions` group intact so the backend stops answering that way; this scrub is
* the belt to that suspender on the client-facing passthrough (SSE and bounded JSON).
*/
import { afterEach, expect, test } from "bun:test";
import { handleResponses } from "../src/server/responses";
import { scrubSelfNamedToolCallNamespace } from "../src/server/responses-self-named-namespace-scrub";
import type { OcxConfig } from "../src/types";

const originalFetch = globalThis.fetch;
afterEach(() => { globalThis.fetch = originalFetch; });

function forwardConfig(): OcxConfig {
return {
port: 0,
defaultProvider: "openai",
providers: {
openai: {
adapter: "openai-responses",
baseUrl: "https://chatgpt.com/backend-api/codex",
authMode: "forward",
codexAccountMode: "direct",
},
},
} as unknown as OcxConfig;
}

const requestBody = {
model: "gpt-5.3-codex-spark",
stream: true,
store: false,
instructions: "x",
input: [
{
type: "additional_tools",
role: "developer",
tools: [{
type: "namespace",
name: "functions",
tools: [
{ type: "custom", name: "exec", description: "shell" },
{ type: "function", name: "wait", parameters: { type: "object", properties: {} } },
],
}],
},
{ type: "message", role: "user", content: [{ type: "input_text", text: "run pwd" }] },
],
};

function sseFrom(items: Array<Record<string, unknown>>): string {
const events = [
{ type: "response.output_item.added", output_index: 0, item: { ...items[0], input: "", status: "in_progress" } },
{ type: "response.output_item.done", output_index: 0, item: items[0] },
{ type: "response.completed", response: { id: "r1", status: "completed", output: items } },
];
return events.map(e => `event: ${e.type}\ndata: ${JSON.stringify(e)}\n\n`).join("");
}

function request(): Request {
return new Request("http://localhost/v1/responses", {
method: "POST",
headers: { "content-type": "application/json", authorization: "Bearer test", "chatgpt-account-id": "acct" },
body: JSON.stringify(requestBody),
});
}

test("a self-named namespace on a passthrough custom_tool_call is scrubbed before the client (#3217)", async () => {
const call = { type: "custom_tool_call", id: "ctc_1", call_id: "call_1", name: "exec", namespace: "exec", input: "pwd", status: "completed" };
globalThis.fetch = (async () => new Response(sseFrom([call]), {
status: 200, headers: { "content-type": "text/event-stream" },
})) as typeof fetch;

const res = await handleResponses(request(), forwardConfig(), { model: "", provider: "" });
expect(res.status).toBe(200);
const text = await res.text();
const payloads = text.split("\n").filter(l => l.startsWith("data: ") && l !== "data: [DONE]").map(l => JSON.parse(l.slice(6)) as Record<string, unknown>);
const callItems = payloads.flatMap(p => {
const item = p.item as Record<string, unknown> | undefined;
const output = (p.response as { output?: Array<Record<string, unknown>> } | undefined)?.output ?? [];
return [...(item ? [item] : []), ...output];
}).filter(i => i.type === "custom_tool_call");
expect(callItems.length).toBeGreaterThanOrEqual(3);
for (const item of callItems) {
expect(item.name).toBe("exec");
expect("namespace" in item).toBe(false);
}
});

test("a genuine MCP namespace on a passthrough call is left alone", async () => {
const call = { type: "function_call", id: "fc_1", call_id: "call_1", name: "search", namespace: "mcp__docs", arguments: "{}", status: "completed" };
globalThis.fetch = (async () => new Response(sseFrom([call]), {
status: 200, headers: { "content-type": "text/event-stream" },
})) as typeof fetch;

const res = await handleResponses(request(), forwardConfig(), { model: "", provider: "" });
const text = await res.text();
expect(text).toContain('"namespace":"mcp__docs"');
});

test("the bounded JSON (stream:false) passthrough path scrubs the same shape (#3217)", async () => {
const call = { type: "custom_tool_call", id: "ctc_1", call_id: "call_1", name: "exec", namespace: "exec", input: "pwd", status: "completed" };
globalThis.fetch = (async () => new Response(JSON.stringify({ id: "r1", object: "response", status: "completed", output: [call] }), {
status: 200, headers: { "content-type": "application/json" },
})) as typeof fetch;

const req = new Request("http://localhost/v1/responses", {
method: "POST",
headers: { "content-type": "application/json", authorization: "Bearer test", "chatgpt-account-id": "acct" },
body: JSON.stringify({ ...requestBody, stream: false }),
});
const res = await handleResponses(req, forwardConfig(), { model: "", provider: "" });
expect(res.status).toBe(200);
const body = await res.json() as { output: Array<Record<string, unknown>> };
expect(body.output[0]).toMatchObject({ type: "custom_tool_call", name: "exec" });
expect("namespace" in body.output[0]).toBe(false);
});

test("scrub is recursive, shape-preserving, and a no-op on clean payloads", () => {
const clean = { type: "response.completed", response: { output: [{ type: "custom_tool_call", name: "exec", input: "" }] } };
expect(scrubSelfNamedToolCallNamespace(clean)).toEqual({ value: clean, changed: false });
const dirty = { response: { output: [{ type: "function_call", name: "wait", namespace: "wait", arguments: "{}" }, { type: "message" }] } };
const result = scrubSelfNamedToolCallNamespace(dirty);
expect(result.changed).toBe(true);
expect(result.value).toEqual({ response: { output: [{ type: "function_call", name: "wait", arguments: "{}" }, { type: "message" }] } });
// An empty name never matches: a namespace equal to "" is not the self-named shape.
expect(scrubSelfNamedToolCallNamespace({ type: "custom_tool_call", name: "", namespace: "" }).changed).toBe(false);
});
Loading