diff --git a/src/responses/parser-content.ts b/src/responses/parser-content.ts new file mode 100644 index 0000000000..e96d3b2017 --- /dev/null +++ b/src/responses/parser-content.ts @@ -0,0 +1,127 @@ +import type { OcxContentPart, OcxTextContent } from "../types"; + +export function isObj(v: unknown): v is Record { + return typeof v === "object" && v !== null && !Array.isArray(v); +} + +type InputBlock = + | { type: "input_text"; text: string } + | { type: "text"; text: string } + | { type: "input_image"; image_url?: string; file_id?: string; detail?: string } + | { type: "input_video"; video_url?: string } + | { type: "input_file"; file_id?: string; filename?: string; file_data?: string }; + +/** A usable reference string, or undefined. Empty strings and non-strings are not references. */ +function nonEmptyString(value: unknown): string | undefined { + return typeof value === "string" && value.length > 0 ? value : undefined; +} + +export function inputContentParts(blocks: unknown): string | OcxContentPart[] { + if (typeof blocks === "string") return blocks; + // The catch-all can also hand back a non-array `content` (an object, a number), which would + // throw at the loop below before any per-block guard runs. + if (!Array.isArray(blocks)) return []; + const parts: OcxContentPart[] = []; + for (const raw of blocks) { + // A malformed message item fails its strict schema and falls through to inputItemSchema's + // permissive catch-all, so blocks reaching here are NOT guaranteed to match the declared + // shape. Validate each field before use, as outputToToolResultContent already does. + if (!isObj(raw)) continue; + const block = raw as InputBlock; + if (block.type === "input_text" || block.type === "text") { + if (typeof raw.text === "string") parts.push({ type: "text", text: raw.text }); + } else if (block.type === "input_image") { + const b = block as { image_url?: string; file_id?: string; detail?: string }; + const imageUrl = nonEmptyString(b.image_url); + const fileId = nonEmptyString(b.file_id); + const detail = nonEmptyString(b.detail); + if (imageUrl) { + // Preserve the image as a structured part — adapters send it as a native image block. + // NEVER inline the (often base64 data-URL) image_url as text: that explodes the token count. + parts.push({ type: "image", imageUrl, ...(detail ? { detail: normalizeImageDetail(detail) } : {}) }); + } else if (fileId) { + parts.push({ type: "text", text: `[image: ${fileId}]` }); // file_id ref → no inline data + } + // No usable reference: omit the block. A "[image: ?]" marker would claim an attachment + // the request never carried, which is worse than dropping malformed input. + } else if (block.type === "input_video") { + const videoUrl = nonEmptyString(block.video_url); + if (videoUrl) parts.push({ type: "video", videoUrl }); + } else if (block.type === "input_file") { + const b = block as { file_id?: string; filename?: string; file_data?: string }; + const fileId = nonEmptyString(b.file_id); + const fileData = nonEmptyString(b.file_data); + const filename = nonEmptyString(b.filename); + if (fileId) { + parts.push({ type: "text", text: `[file: ${fileId}]` }); + } else if (fileData) { + // Inline file_data is often large base64. Preserve only its presence and name, never bytes. + parts.push({ type: "text", text: filename ? `[file: ${filename}]` : "[file: inline data]" }); + } + // A bare filename is not a file resource in the Responses schema, so omit it rather than + // fabricating a "[file: ...]" marker for an attachment that was never sent. + } + } + // Collapse to a plain string only for a single TEXT part; images must stay structured. + if (parts.length === 1 && parts[0].type === "text") return parts[0].text; + return parts; +} + +type OutputBlock = { type: "output_text"; text: string } | { type: "text"; text: string } | { type: "refusal"; refusal: string }; + +export function outputTextOf(blocks: unknown): OcxTextContent[] { + if (typeof blocks === "string") return blocks.length > 0 ? [{ type: "text", text: blocks }] : []; + if (!Array.isArray(blocks)) return []; + const out: OcxTextContent[] = []; + for (const raw of blocks) { + // Same catch-all caveat as inputContentParts: validate before use. + if (!isObj(raw)) continue; + const b = raw as OutputBlock; + if (b.type === "output_text" || b.type === "text") { + if (typeof raw.text === "string") out.push({ type: "text", text: raw.text }); + } else if (b.type === "refusal") { + if (typeof raw.refusal === "string") out.push({ type: "text", text: `[refusal: ${raw.refusal}]` }); + } + } + return out; +} + +/** + * Tool-call output content. Preserves images (e.g. Codex `view_image` returns + * `input_image` items): returns content parts when any image is present, else a plain joined string. + * Never inlines an image_url as text (that would explode the token count). + */ +export function outputToToolResultContent(output: string | unknown[] | undefined): string | OcxContentPart[] { + if (typeof output === "string") return output; + if (!Array.isArray(output)) return ""; + const parts: OcxContentPart[] = []; + let hasImage = false; + for (const raw of output) { + if (!isObj(raw)) continue; + if (raw.type === "output_text" || raw.type === "text" || raw.type === "input_text") { + if (typeof raw.text === "string") parts.push({ type: "text", text: raw.text }); + } else if (raw.type === "refusal" && typeof raw.refusal === "string") { + parts.push({ type: "text", text: `[refusal: ${raw.refusal}]` }); + } else if (raw.type === "input_image" && typeof raw.image_url === "string") { + parts.push({ type: "image", imageUrl: raw.image_url, ...(typeof raw.detail === "string" ? { detail: normalizeImageDetail(raw.detail) } : {}) }); + hasImage = true; + } else if (raw.type === "encrypted_content") { + // codex-rs FunctionCallOutputContentItem::EncryptedContent — opaque to routed models. + parts.push({ type: "text", text: "[encrypted content omitted]" }); + } + } + if (!hasImage) return parts.map(p => (p.type === "text" ? p.text : "")).join(""); + return parts; +} + +export function toolOutputContainsEncryptedContent(output: string | unknown[] | undefined): boolean { + return Array.isArray(output) && output.some(raw => isObj(raw) && raw.type === "encrypted_content"); +} + +/** + * codex-rs ImageDetail allows "original", but chat-completions providers only accept + * auto|low|high on image_url.detail — degrade "original" to "high" (the codex default). + */ +function normalizeImageDetail(detail: string): string { + return detail === "original" ? "high" : detail; +} diff --git a/src/responses/parser-text-format.ts b/src/responses/parser-text-format.ts new file mode 100644 index 0000000000..08a27cdc36 --- /dev/null +++ b/src/responses/parser-text-format.ts @@ -0,0 +1,24 @@ +import type { OcxRequestOptions } from "../types"; +import { isObj } from "./parser-content"; + +/** + * The Responses `text.format` object when it requests structured output (json_schema or + * json_object), undefined otherwise. Acceptance is identical to the boolean detector this + * replaces; unknown or malformed formats are ignored, never rejected, so the native + * passthrough keeps forwarding whatever the caller sent via `_rawBody`. + */ +export function parseTextFormat(text: unknown): OcxRequestOptions["textFormat"] { + if (!isObj(text)) return undefined; + const format = (text as { format?: unknown }).format; + if (!isObj(format)) return undefined; + const f = format as { type?: unknown; name?: unknown; description?: unknown; schema?: unknown; strict?: unknown }; + if (f.type === "json_object") return { type: "json_object" }; + if (f.type !== "json_schema") return undefined; + return { + type: "json_schema", + ...(typeof f.name === "string" ? { name: f.name } : {}), + ...(typeof f.description === "string" ? { description: f.description } : {}), + ...(isObj(f.schema) ? { schema: f.schema as Record } : {}), + ...(typeof f.strict === "boolean" ? { strict: f.strict } : {}), + }; +} diff --git a/src/responses/parser-tools.ts b/src/responses/parser-tools.ts new file mode 100644 index 0000000000..8812b0bbe6 --- /dev/null +++ b/src/responses/parser-tools.ts @@ -0,0 +1,188 @@ +import type { OcxRequestOptions, OcxTool } from "../types"; +import { isObj } from "./parser-content"; +import { WEB_SEARCH_TOOL_NAME } from "../web-search/synthetic-tool"; +import { buildImageTool, IMAGE_GEN_TOOL_NAME } from "../images/synthetic-tool"; +import { toolSearchDescription, toolSearchParameters } from "./tool-search-compat"; + +export function mapToolChoice(value: unknown): OcxRequestOptions["toolChoice"] { + if (value === undefined || value === null) return undefined; + if (value === "auto" || value === "none" || value === "required") return value; + if (isObj(value) && "type" in value) { + const t = (value as { type: string }).type; + if ((t === "function" || t === "custom") && "name" in value) { + return { name: (value as { name: string }).name }; + } + // Hosted image tool types (with or without a name) map to the synthetic image_gen wire name. + if (t === "image_generation" || t === "image_gen") { + return { name: IMAGE_GEN_TOOL_NAME }; + } + if (t === "allowed_tools" && Array.isArray(value.tools)) { + const names = value.tools + .map(allowedToolName) + .filter((name): name is string => Boolean(name)); + return names.length > 0 + ? { allowedTools: [...new Set(names)], mode: value.mode === "required" ? "required" : "auto" } + : "none"; + } + return "auto"; + } + return undefined; +} + +function allowedToolName(tool: unknown): string | undefined { + if (!isObj(tool)) return undefined; + if (typeof tool.name === "string" && tool.name.length > 0) return tool.name; + if (tool.type === "web_search" || tool.type === "web_search_preview") return WEB_SEARCH_TOOL_NAME; + if (tool.type === "image_generation" || tool.type === "image_gen") return IMAGE_GEN_TOOL_NAME; + if (tool.type === "tool_search") return "tool_search"; + return undefined; +} + +export function buildTools(tools: unknown[] | undefined): OcxTool[] | undefined { + if (!tools) return undefined; + const out: OcxTool[] = []; + const normalizeParameters = (raw: unknown): Record => { + if (isObj(raw) && raw.type === "object") return raw; + return { ...(isObj(raw) ? raw : {}), type: "object" }; + }; + const pushFn = (t: Record, namespace?: string) => { + // Hosted image_generation already installed the synthetic root tool. A later + // ordinary root `image_gen` must not create a second un-namespaced identity. + if ( + !namespace + && t.name === IMAGE_GEN_TOOL_NAME + && out.some(tool => tool.name === IMAGE_GEN_TOOL_NAME && !tool.namespace && tool.imageGeneration) + ) { + return; + } + const tool: OcxTool = { + name: t.name as string, + description: (t.description as string) ?? "", + parameters: normalizeParameters(t.parameters), + }; + if (t.strict !== undefined) tool.strict = t.strict as boolean; + if (namespace) tool.namespace = namespace; + out.push(tool); + }; + const pushCustom = (t: Record, namespace?: string) => { + // Hosted image_generation already installed the synthetic root tool. A later + // root custom `image_gen` would collide on the same wire name with a different + // `freeform` flag and throw `ambiguous tool catalog`. + if ( + !namespace + && t.name === IMAGE_GEN_TOOL_NAME + && out.some(tool => tool.name === IMAGE_GEN_TOOL_NAME && !tool.namespace && tool.imageGeneration) + ) { + return; + } + // Freeform custom tools are lowered to a single string `input` because chat models cannot + // emit Responses grammar payloads directly. Keep tool-specific input guidance scoped to the + // tool that owns it: leaking apply_patch syntax into `exec` or another freeform tool teaches + // routed models that the nested helper name is itself a callable top-level tool. + const inputDescription = t.name === "apply_patch" + ? "Raw tool input. For apply_patch, begin exactly with `*** Begin Patch` (no trailing `***`), then use its standard patch envelope." + : "Raw freeform input for this tool."; + const tool: OcxTool = { + name: t.name as string, + description: (t.description as string) ?? "", + parameters: { type: "object", properties: { input: { type: "string", description: inputDescription } }, required: ["input"] }, + freeform: true, + }; + if (namespace) tool.namespace = namespace; + out.push(tool); + }; + for (const t of tools) { + if (!isObj(t)) continue; + if (t.type === "function" && isObj(t.function) && typeof t.function.name === "string" && t.function.name.length > 0) { + pushFn(t.function as Record); + continue; + } + if (t.type === "function" && typeof t.name === "string") { + pushFn(t); + } else if (t.type === "namespace" && Array.isArray(t.tools)) { + // Codex 0.147 groups its ordinary client tools under the reserved `functions` namespace, + // including freeform custom tools such as code-mode `exec`. Those children are still + // top-level Responses tools, so flatten them without a namespace. Other namespace groups + // are MCP-style and keep their namespace for round-trip routing. + const builtinFunctions = t.name === "functions"; + const ns = typeof t.name === "string" && !builtinFunctions ? t.name : undefined; + for (const inner of t.tools as unknown[]) { + if (isObj(inner) && inner.type === "function" && typeof inner.name === "string") pushFn(inner, ns); + else if (isObj(inner) && inner.type === "custom" && typeof inner.name === "string") pushCustom(inner, ns); + } + } + else if (t.type === "custom" && typeof t.name === "string") { + pushCustom(t); + } + else if (t.type === "tool_search") { + // Client-executed tool discovery — the gateway to deferred tools (subagents, extra MCP tools). + // Expose as a function so chat models can call it; the bridge relays it as a tool_search_call. + out.push({ + name: "tool_search", + description: toolSearchDescription(t), + parameters: normalizeParameters(toolSearchParameters(t)), + toolSearch: true, + }); + } + else if (t.type === "image_generation" || t.type === "image_gen") { + // Keep Codex's image_gen visible to routed chat models. The hosted OpenAI tool + // cannot execute on Grok; the model still has to see a callable image_gen so + // Codex's client-side /v1/images request can fire and be relayed to xAI. + // Identity is the un-namespaced synthetic root (`imageGeneration: true`), not + // the bare name: a namespaced ordinary `image_gen` must not suppress it. + const synthetic = buildImageTool(); + // Every un-namespaced `image_gen` collides on one wire name, so removing only + // the first leaves a second root behind and the catalog stays ambiguous. + // Drop all root collisions, keep namespaced entries, then insert exactly one + // synthetic root — at the earliest colliding position so declaration order is + // preserved for models that read the catalog positionally. + let insertAt = -1; + for (let i = out.length - 1; i >= 0; i -= 1) { + const tool = out[i]!; + if (tool.name !== IMAGE_GEN_TOOL_NAME || tool.namespace) continue; + out.splice(i, 1); + insertAt = i; + } + if (insertAt >= 0) out.splice(insertAt, 0, synthetic); + else out.push(synthetic); + } + else if (typeof t.name === "string" && t.type !== "web_search" && t.type !== "image_generation") { + // Any OTHER named tool (e.g. a native/computer-use tool type opencodex doesn't explicitly + // model) is client-executed — pass it through as a function so the routed model can read and + // call it naturally; the bridge relays its call as a function_call. Previously such tools were + // silently dropped, so the model never saw them. + pushFn(t); + } + // Hosted web_search is still dropped here — the web-search sidecar re-injects it. + } + return out.length > 0 ? out : undefined; +} + +/** + * Namespace a custom tool was declared under, by its bare name. + * + * A `custom_tool_call` echoed back by the client carries only the bare name — the bridge + * emits `{"type":"custom_tool_call","name":"exec"}` even when the tool was declared as + * `mcp__functions__exec`. Without this lookup the namespace is lost on the return trip, + * and the adapters replay history through `namespacedToolName(namespace, name)`, which + * then produces a bare `exec` the provider may not have. Ordinary `function_call` items + * do not need this: they carry `namespace` on the wire. + */ +export function customToolNamespaces(tools: unknown): Map { + const out = new Map(); + if (!Array.isArray(tools)) return out; + for (const spec of tools) { + if (!isObj(spec) || spec.type !== "namespace" || !Array.isArray(spec.tools)) continue; + const namespace = typeof spec.name === "string" ? spec.name : undefined; + // Codex 0.147 groups ordinary client tools under the reserved `functions` namespace and + // buildTools deliberately flattens those without a namespace. Mirror that here, or the + // reconstruction would invent a namespace the request never advertised. + if (!namespace || namespace === "functions") continue; + for (const inner of spec.tools) { + if (!isObj(inner) || inner.type !== "custom" || typeof inner.name !== "string") continue; + // Ambiguous bare names are already rejected upstream, so first declaration wins. + if (!out.has(inner.name)) out.set(inner.name, namespace); + } + } + return out; +} diff --git a/src/responses/parser.ts b/src/responses/parser.ts index f26539945a..c60f441406 100644 --- a/src/responses/parser.ts +++ b/src/responses/parser.ts @@ -22,9 +22,9 @@ import { extractHostedWebSearch, WEB_SEARCH_TOOL_NAME } from "../web-search/synt import { buildImageTool, extractHostedImageGeneration, IMAGE_GEN_TOOL_NAME } from "../images/synthetic-tool"; import { toolSearchDescription, toolSearchParameters } from "./tool-search-compat"; -function isObj(v: unknown): v is Record { - return typeof v === "object" && v !== null && !Array.isArray(v); -} +import { isObj, inputContentParts, outputTextOf, outputToToolResultContent, toolOutputContainsEncryptedContent } from "./parser-content"; +import { mapToolChoice, buildTools, customToolNamespaces } from "./parser-tools"; +import { parseTextFormat } from "./parser-text-format"; /** * Wrap a remembered proxy-side signature as provider metadata for a replayed tool call. @@ -41,241 +41,7 @@ function replayThoughtSignatureMetadata( return signature ? { google: { thoughtSignature: signature } } : undefined; } -type InputBlock = - | { type: "input_text"; text: string } - | { type: "text"; text: string } - | { type: "input_image"; image_url?: string; file_id?: string; detail?: string } - | { type: "input_video"; video_url?: string } - | { type: "input_file"; file_id?: string; filename?: string; file_data?: string }; - -/** A usable reference string, or undefined. Empty strings and non-strings are not references. */ -function nonEmptyString(value: unknown): string | undefined { - return typeof value === "string" && value.length > 0 ? value : undefined; -} - -function inputContentParts(blocks: unknown): string | OcxContentPart[] { - if (typeof blocks === "string") return blocks; - // The catch-all can also hand back a non-array `content` (an object, a number), which would - // throw at the loop below before any per-block guard runs. - if (!Array.isArray(blocks)) return []; - const parts: OcxContentPart[] = []; - for (const raw of blocks) { - // A malformed message item fails its strict schema and falls through to inputItemSchema's - // permissive catch-all, so blocks reaching here are NOT guaranteed to match the declared - // shape. Validate each field before use, as outputToToolResultContent already does. - if (!isObj(raw)) continue; - const block = raw as InputBlock; - if (block.type === "input_text" || block.type === "text") { - if (typeof raw.text === "string") parts.push({ type: "text", text: raw.text }); - } else if (block.type === "input_image") { - const b = block as { image_url?: string; file_id?: string; detail?: string }; - const imageUrl = nonEmptyString(b.image_url); - const fileId = nonEmptyString(b.file_id); - const detail = nonEmptyString(b.detail); - if (imageUrl) { - // Preserve the image as a structured part — adapters send it as a native image block. - // NEVER inline the (often base64 data-URL) image_url as text: that explodes the token count. - parts.push({ type: "image", imageUrl, ...(detail ? { detail: normalizeImageDetail(detail) } : {}) }); - } else if (fileId) { - parts.push({ type: "text", text: `[image: ${fileId}]` }); // file_id ref → no inline data - } - // No usable reference: omit the block. A "[image: ?]" marker would claim an attachment - // the request never carried, which is worse than dropping malformed input. - } else if (block.type === "input_video") { - const videoUrl = nonEmptyString(block.video_url); - if (videoUrl) parts.push({ type: "video", videoUrl }); - } else if (block.type === "input_file") { - const b = block as { file_id?: string; filename?: string; file_data?: string }; - const fileId = nonEmptyString(b.file_id); - const fileData = nonEmptyString(b.file_data); - const filename = nonEmptyString(b.filename); - if (fileId) { - parts.push({ type: "text", text: `[file: ${fileId}]` }); - } else if (fileData) { - // Inline file_data is often large base64. Preserve only its presence and name, never bytes. - parts.push({ type: "text", text: filename ? `[file: ${filename}]` : "[file: inline data]" }); - } - // A bare filename is not a file resource in the Responses schema, so omit it rather than - // fabricating a "[file: ...]" marker for an attachment that was never sent. - } - } - // Collapse to a plain string only for a single TEXT part; images must stay structured. - if (parts.length === 1 && parts[0].type === "text") return parts[0].text; - return parts; -} - -type OutputBlock = { type: "output_text"; text: string } | { type: "text"; text: string } | { type: "refusal"; refusal: string }; - -function outputTextOf(blocks: unknown): OcxTextContent[] { - if (typeof blocks === "string") return blocks.length > 0 ? [{ type: "text", text: blocks }] : []; - if (!Array.isArray(blocks)) return []; - const out: OcxTextContent[] = []; - for (const raw of blocks) { - // Same catch-all caveat as inputContentParts: validate before use. - if (!isObj(raw)) continue; - const b = raw as OutputBlock; - if (b.type === "output_text" || b.type === "text") { - if (typeof raw.text === "string") out.push({ type: "text", text: raw.text }); - } else if (b.type === "refusal") { - if (typeof raw.refusal === "string") out.push({ type: "text", text: `[refusal: ${raw.refusal}]` }); - } - } - return out; -} - -function mapToolChoice(value: unknown): OcxRequestOptions["toolChoice"] { - if (value === undefined || value === null) return undefined; - if (value === "auto" || value === "none" || value === "required") return value; - if (isObj(value) && "type" in value) { - const t = (value as { type: string }).type; - if ((t === "function" || t === "custom") && "name" in value) { - return { name: (value as { name: string }).name }; - } - // Hosted image tool types (with or without a name) map to the synthetic image_gen wire name. - if (t === "image_generation" || t === "image_gen") { - return { name: IMAGE_GEN_TOOL_NAME }; - } - if (t === "allowed_tools" && Array.isArray(value.tools)) { - const names = value.tools - .map(allowedToolName) - .filter((name): name is string => Boolean(name)); - return names.length > 0 - ? { allowedTools: [...new Set(names)], mode: value.mode === "required" ? "required" : "auto" } - : "none"; - } - return "auto"; - } - return undefined; -} -function allowedToolName(tool: unknown): string | undefined { - if (!isObj(tool)) return undefined; - if (typeof tool.name === "string" && tool.name.length > 0) return tool.name; - if (tool.type === "web_search" || tool.type === "web_search_preview") return WEB_SEARCH_TOOL_NAME; - if (tool.type === "image_generation" || tool.type === "image_gen") return IMAGE_GEN_TOOL_NAME; - if (tool.type === "tool_search") return "tool_search"; - return undefined; -} - -function buildTools(tools: unknown[] | undefined): OcxTool[] | undefined { - if (!tools) return undefined; - const out: OcxTool[] = []; - const normalizeParameters = (raw: unknown): Record => { - if (isObj(raw) && raw.type === "object") return raw; - return { ...(isObj(raw) ? raw : {}), type: "object" }; - }; - const pushFn = (t: Record, namespace?: string) => { - // Hosted image_generation already installed the synthetic root tool. A later - // ordinary root `image_gen` must not create a second un-namespaced identity. - if ( - !namespace - && t.name === IMAGE_GEN_TOOL_NAME - && out.some(tool => tool.name === IMAGE_GEN_TOOL_NAME && !tool.namespace && tool.imageGeneration) - ) { - return; - } - const tool: OcxTool = { - name: t.name as string, - description: (t.description as string) ?? "", - parameters: normalizeParameters(t.parameters), - }; - if (t.strict !== undefined) tool.strict = t.strict as boolean; - if (namespace) tool.namespace = namespace; - out.push(tool); - }; - const pushCustom = (t: Record, namespace?: string) => { - // Hosted image_generation already installed the synthetic root tool. A later - // root custom `image_gen` would collide on the same wire name with a different - // `freeform` flag and throw `ambiguous tool catalog`. - if ( - !namespace - && t.name === IMAGE_GEN_TOOL_NAME - && out.some(tool => tool.name === IMAGE_GEN_TOOL_NAME && !tool.namespace && tool.imageGeneration) - ) { - return; - } - // Freeform custom tools are lowered to a single string `input` because chat models cannot - // emit Responses grammar payloads directly. Keep tool-specific input guidance scoped to the - // tool that owns it: leaking apply_patch syntax into `exec` or another freeform tool teaches - // routed models that the nested helper name is itself a callable top-level tool. - const inputDescription = t.name === "apply_patch" - ? "Raw tool input. For apply_patch, begin exactly with `*** Begin Patch` (no trailing `***`), then use its standard patch envelope." - : "Raw freeform input for this tool."; - const tool: OcxTool = { - name: t.name as string, - description: (t.description as string) ?? "", - parameters: { type: "object", properties: { input: { type: "string", description: inputDescription } }, required: ["input"] }, - freeform: true, - }; - if (namespace) tool.namespace = namespace; - out.push(tool); - }; - for (const t of tools) { - if (!isObj(t)) continue; - if (t.type === "function" && isObj(t.function) && typeof t.function.name === "string" && t.function.name.length > 0) { - pushFn(t.function as Record); - continue; - } - if (t.type === "function" && typeof t.name === "string") { - pushFn(t); - } else if (t.type === "namespace" && Array.isArray(t.tools)) { - // Codex 0.147 groups its ordinary client tools under the reserved `functions` namespace, - // including freeform custom tools such as code-mode `exec`. Those children are still - // top-level Responses tools, so flatten them without a namespace. Other namespace groups - // are MCP-style and keep their namespace for round-trip routing. - const builtinFunctions = t.name === "functions"; - const ns = typeof t.name === "string" && !builtinFunctions ? t.name : undefined; - for (const inner of t.tools as unknown[]) { - if (isObj(inner) && inner.type === "function" && typeof inner.name === "string") pushFn(inner, ns); - else if (isObj(inner) && inner.type === "custom" && typeof inner.name === "string") pushCustom(inner, ns); - } - } - else if (t.type === "custom" && typeof t.name === "string") { - pushCustom(t); - } - else if (t.type === "tool_search") { - // Client-executed tool discovery — the gateway to deferred tools (subagents, extra MCP tools). - // Expose as a function so chat models can call it; the bridge relays it as a tool_search_call. - out.push({ - name: "tool_search", - description: toolSearchDescription(t), - parameters: normalizeParameters(toolSearchParameters(t)), - toolSearch: true, - }); - } - else if (t.type === "image_generation" || t.type === "image_gen") { - // Keep Codex's image_gen visible to routed chat models. The hosted OpenAI tool - // cannot execute on Grok; the model still has to see a callable image_gen so - // Codex's client-side /v1/images request can fire and be relayed to xAI. - // Identity is the un-namespaced synthetic root (`imageGeneration: true`), not - // the bare name: a namespaced ordinary `image_gen` must not suppress it. - const synthetic = buildImageTool(); - // Every un-namespaced `image_gen` collides on one wire name, so removing only - // the first leaves a second root behind and the catalog stays ambiguous. - // Drop all root collisions, keep namespaced entries, then insert exactly one - // synthetic root — at the earliest colliding position so declaration order is - // preserved for models that read the catalog positionally. - let insertAt = -1; - for (let i = out.length - 1; i >= 0; i -= 1) { - const tool = out[i]!; - if (tool.name !== IMAGE_GEN_TOOL_NAME || tool.namespace) continue; - out.splice(i, 1); - insertAt = i; - } - if (insertAt >= 0) out.splice(insertAt, 0, synthetic); - else out.push(synthetic); - } - else if (typeof t.name === "string" && t.type !== "web_search" && t.type !== "image_generation") { - // Any OTHER named tool (e.g. a native/computer-use tool type opencodex doesn't explicitly - // model) is client-executed — pass it through as a function so the routed model can read and - // call it naturally; the bridge relays its call as a function_call. Previously such tools were - // silently dropped, so the model never saw them. - pushFn(t); - } - // Hosted web_search is still dropped here — the web-search sidecar re-injects it. - } - return out.length > 0 ? out : undefined; -} function ensureAssistantPlaceholder(messages: OcxMessage[], modelId: string, now: number): OcxAssistantMessage { const last = messages[messages.length - 1]; @@ -285,45 +51,6 @@ function ensureAssistantPlaceholder(messages: OcxMessage[], modelId: string, now return placeholder; } -/** - * Tool-call output content. Preserves images (e.g. Codex `view_image` returns - * `input_image` items): returns content parts when any image is present, else a plain joined string. - * Never inlines an image_url as text (that would explode the token count). - */ -function outputToToolResultContent(output: string | unknown[] | undefined): string | OcxContentPart[] { - if (typeof output === "string") return output; - if (!Array.isArray(output)) return ""; - const parts: OcxContentPart[] = []; - let hasImage = false; - for (const raw of output) { - if (!isObj(raw)) continue; - if (raw.type === "output_text" || raw.type === "text" || raw.type === "input_text") { - if (typeof raw.text === "string") parts.push({ type: "text", text: raw.text }); - } else if (raw.type === "refusal" && typeof raw.refusal === "string") { - parts.push({ type: "text", text: `[refusal: ${raw.refusal}]` }); - } else if (raw.type === "input_image" && typeof raw.image_url === "string") { - parts.push({ type: "image", imageUrl: raw.image_url, ...(typeof raw.detail === "string" ? { detail: normalizeImageDetail(raw.detail) } : {}) }); - hasImage = true; - } else if (raw.type === "encrypted_content") { - // codex-rs FunctionCallOutputContentItem::EncryptedContent — opaque to routed models. - parts.push({ type: "text", text: "[encrypted content omitted]" }); - } - } - if (!hasImage) return parts.map(p => (p.type === "text" ? p.text : "")).join(""); - return parts; -} - -function toolOutputContainsEncryptedContent(output: string | unknown[] | undefined): boolean { - return Array.isArray(output) && output.some(raw => isObj(raw) && raw.type === "encrypted_content"); -} - -/** - * codex-rs ImageDetail allows "original", but chat-completions providers only accept - * auto|low|high on image_url.detail — degrade "original" to "high" (the codex default). - */ -function normalizeImageDetail(detail: string): string { - return detail === "original" ? "high" : detail; -} function findToolById(messages: OcxMessage[], callId: string): { name: string; namespace?: string } { for (let i = messages.length - 1; i >= 0; i--) { @@ -366,34 +93,6 @@ function attachPendingReasoningToCallOwner( const REASONING_EFFORTS = new Set(["none", "minimal", "low", "medium", "high", "xhigh", "max"]); -/** - * Namespace a custom tool was declared under, by its bare name. - * - * A `custom_tool_call` echoed back by the client carries only the bare name — the bridge - * emits `{"type":"custom_tool_call","name":"exec"}` even when the tool was declared as - * `mcp__functions__exec`. Without this lookup the namespace is lost on the return trip, - * and the adapters replay history through `namespacedToolName(namespace, name)`, which - * then produces a bare `exec` the provider may not have. Ordinary `function_call` items - * do not need this: they carry `namespace` on the wire. - */ -function customToolNamespaces(tools: unknown): Map { - const out = new Map(); - if (!Array.isArray(tools)) return out; - for (const spec of tools) { - if (!isObj(spec) || spec.type !== "namespace" || !Array.isArray(spec.tools)) continue; - const namespace = typeof spec.name === "string" ? spec.name : undefined; - // Codex 0.147 groups ordinary client tools under the reserved `functions` namespace and - // buildTools deliberately flattens those without a namespace. Mirror that here, or the - // reconstruction would invent a namespace the request never advertised. - if (!namespace || namespace === "functions") continue; - for (const inner of spec.tools) { - if (!isObj(inner) || inner.type !== "custom" || typeof inner.name !== "string") continue; - // Ambiguous bare names are already rejected upstream, so first declaration wins. - if (!out.has(inner.name)) out.set(inner.name, namespace); - } - } - return out; -} export function parseRequest( body: unknown, @@ -859,25 +558,3 @@ export function parseRequest( ...(contextCompactionBoundary ? { _contextCompactionBoundary: true } : {}), }; } - -/** - * The Responses `text.format` object when it requests structured output (json_schema or - * json_object), undefined otherwise. Acceptance is identical to the boolean detector this - * replaces; unknown or malformed formats are ignored, never rejected, so the native - * passthrough keeps forwarding whatever the caller sent via `_rawBody`. - */ -function parseTextFormat(text: unknown): OcxRequestOptions["textFormat"] { - if (!isObj(text)) return undefined; - const format = (text as { format?: unknown }).format; - if (!isObj(format)) return undefined; - const f = format as { type?: unknown; name?: unknown; description?: unknown; schema?: unknown; strict?: unknown }; - if (f.type === "json_object") return { type: "json_object" }; - if (f.type !== "json_schema") return undefined; - return { - type: "json_schema", - ...(typeof f.name === "string" ? { name: f.name } : {}), - ...(typeof f.description === "string" ? { description: f.description } : {}), - ...(isObj(f.schema) ? { schema: f.schema as Record } : {}), - ...(typeof f.strict === "boolean" ? { strict: f.strict } : {}), - }; -} diff --git a/tests/responses/responses-parser.test.ts b/tests/responses/responses-parser.test.ts index 1d0c46a05c..55ca5ead61 100644 --- a/tests/responses/responses-parser.test.ts +++ b/tests/responses/responses-parser.test.ts @@ -1,7 +1,11 @@ import { describe, expect, test } from "bun:test"; +import { readFileSync } from "node:fs"; import { buildResponseJSON } from "../../src/bridge"; import { parseRequest } from "../../src/responses/parser"; +import { buildTools } from "../../src/responses/parser-tools"; +import { parseTextFormat } from "../../src/responses/parser-text-format"; import { buildToolBridgeMaps } from "../../src/server/responses"; +import { repoPath } from "../helpers/repo-root"; describe("Responses parser", () => { test("normalizes function tool schemas to an object root without corrupting valid schemas (#745)", () => { @@ -851,3 +855,12 @@ describe("unpaired tool result boundary (#3259)", () => { }))).not.toThrow(); }); }); + +test("parser leaf seams preserve tool and format contracts without importing the request parser", () => { + const tools = buildTools([{ type: "function", name: "missing_parameters" }]); + expect(tools?.[0]?.name).toBe("missing_parameters"); + expect(parseTextFormat(undefined)).toBeUndefined(); + for (const leaf of ["parser-content.ts", "parser-tools.ts", "parser-text-format.ts"]) { + expect(readFileSync(repoPath("src", "responses", leaf), "utf8")).not.toMatch(/from\s+["\x27]\.\/parser["\x27]/); + } +});