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
127 changes: 127 additions & 0 deletions src/responses/parser-content.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
import type { OcxContentPart, OcxTextContent } from "../types";

export function isObj(v: unknown): v is Record<string, unknown> {
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;
}
24 changes: 24 additions & 0 deletions src/responses/parser-text-format.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown> } : {}),
...(typeof f.strict === "boolean" ? { strict: f.strict } : {}),
};
}
188 changes: 188 additions & 0 deletions src/responses/parser-tools.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown> => {
if (isObj(raw) && raw.type === "object") return raw;
return { ...(isObj(raw) ? raw : {}), type: "object" };
};
const pushFn = (t: Record<string, unknown>, 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<string, unknown>, 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<string, unknown>);
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<string, string> {
const out = new Map<string, string>();
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;
}
Loading
Loading