From e5d1dd1c134570d5f2bb02330e9b3064785b3b85 Mon Sep 17 00:00:00 2001 From: 977 Date: Tue, 28 Jul 2026 10:36:03 +0800 Subject: [PATCH 01/10] fix(openai-responses): round-trip image-gen namespace Lower Codex's private image_gen namespace to safe API-key aliases, restore function calls in JSON and SSE responses, and encode replayed history without changing ChatGPT forward mode. --- .../content/docs/guides/codex-integration.md | 8 + src/adapters/openai-responses.ts | 203 ++++++++++---- src/server/index.ts | 5 +- src/server/responses-image-gen-repair.ts | 169 +++++++++++ src/server/responses/core.ts | 25 +- structure/04_transports-and-sidecars.md | 22 +- tests/azure-adapter.test.ts | 24 ++ tests/openai-responses-passthrough.test.ts | 188 +++++++++++-- tests/passthrough-abort.test.ts | 7 +- tests/responses-image-gen-repair.test.ts | 262 ++++++++++++++++++ 10 files changed, 825 insertions(+), 88 deletions(-) create mode 100644 src/server/responses-image-gen-repair.ts create mode 100644 tests/responses-image-gen-repair.test.ts diff --git a/docs-site/src/content/docs/guides/codex-integration.md b/docs-site/src/content/docs/guides/codex-integration.md index 08cd908385..a97fc03218 100644 --- a/docs-site/src/content/docs/guides/codex-integration.md +++ b/docs-site/src/content/docs/guides/codex-integration.md @@ -63,6 +63,14 @@ Standalone `/images/generations` calls never enter that bridge. tool offered at all, disable it in Codex with `codex features disable image_generation` (`[features] image_generation = false` in `config.toml`). +The tool declaration still travels with the model's Responses request. For API-key Responses +providers, opencodex lowers Codex's private `image_gen` namespace to an upstream-safe +`image_gen__imagegen` alias and removes a duplicate hosted `image_generation` declaration. It maps +the returned function call back to the explicit `image_gen` namespace before Codex sees it, and +encodes the native call again when later history is replayed upstream. This keeps client-side image +generation callable on public-compatible upstreams that reserve the namespace or reject dotted +function names. ChatGPT forward mode remains untouched and keeps its native Responses Lite shape. + For an OpenAI-compatible custom gateway, configure a dedicated provider and select it only for standalone Images requests: diff --git a/src/adapters/openai-responses.ts b/src/adapters/openai-responses.ts index d7e9ef5be2..0f7bda4fc3 100644 --- a/src/adapters/openai-responses.ts +++ b/src/adapters/openai-responses.ts @@ -1,6 +1,6 @@ import { createHash } from "node:crypto"; import type { IncomingMeta, ProviderAdapter } from "./base"; -import type { AdapterEvent, OcxParsedRequest, OcxProviderConfig, OcxUsage } from "../types"; +import { namespacedToolName, type AdapterEvent, type OcxParsedRequest, type OcxProviderConfig, type OcxUsage } from "../types"; import { catalogModelSupportsReasoningSummaries } from "../codex/catalog"; import { COMPACT_PROMPT, decodeCompactionSummary, SUMMARY_PREFIX } from "../responses/compaction"; import { decodeServerSentEvents } from "../lib/sse-decoder"; @@ -500,31 +500,121 @@ function stripUnsupportedForwardParams(body: unknown): unknown { return rest; } +const IMAGE_GEN_NAMESPACE = "image_gen"; +const HOSTED_IMAGE_GENERATION_TOOL = "image_generation"; +const IMAGE_GEN_DOTTED_PREFIX = `${IMAGE_GEN_NAMESPACE}.`; +const IMAGE_GEN_WIRE_PREFIX = `${IMAGE_GEN_NAMESPACE}__`; + +function imageGenLocalName(name: string): string { + if (name.startsWith(IMAGE_GEN_DOTTED_PREFIX)) return name.slice(IMAGE_GEN_DOTTED_PREFIX.length); + if (name.startsWith(IMAGE_GEN_WIRE_PREFIX)) return name.slice(IMAGE_GEN_WIRE_PREFIX.length); + return name; +} + +function imageGenWireName(name: string): string { + return namespacedToolName(IMAGE_GEN_NAMESPACE, imageGenLocalName(name)); +} + +function isImageGenClientName(name: string): boolean { + return name === IMAGE_GEN_NAMESPACE + || name.startsWith(IMAGE_GEN_DOTTED_PREFIX) + || name.startsWith(IMAGE_GEN_WIRE_PREFIX); +} + +function declaresImageGenClientTool(tool: unknown): boolean { + if (!isPlainObject(tool) || typeof tool.name !== "string") return false; + if (tool.type === "namespace") return tool.name === IMAGE_GEN_NAMESPACE; + return isImageGenClientName(tool.name); +} + /** - * Hosted tool types whose server-side function names collide with the client tools Codex - * declares for the matching app skill. Codex sends BOTH (e.g. hosted `image_generation` plus a - * declared `image_gen.imagegen` function/namespace tool for the imagegen skill). The ChatGPT - * backend tolerates the pair, but the platform `/v1/responses` rejects it: - * `Invalid Value: 'tools'. Function 'image_gen.imagegen' conflicts with a hosted tool in the - * same request.` Keyed hosted-type → conflicting client tool-name prefix; the hosted entry is - * dropped (the declared tool wins — Codex executes the skill client-side either way). + * Lower one complete Codex image-gen namespace to public Responses function tools. + * + * The public API reserves the `image_gen` namespace and restricts function names to a flat safe + * alphabet. `image_gen__` is therefore an upstream-only alias; client-facing responses are + * restored to explicit `{ namespace: "image_gen", name: "" }` calls by the server. Only a + * non-empty namespace containing named function tools is safe to lower. Malformed, empty, and + * future namespace shapes stay untouched instead of silently losing client capabilities. */ -const HOSTED_TOOL_NAME_CONFLICTS: ReadonlyArray<{ hostedType: string; namePrefix: string }> = [ - { hostedType: "image_generation", namePrefix: "image_gen" }, -]; +function flattenImageGenNamespace(tool: unknown): Record[] | undefined { + if ( + !isPlainObject(tool) + || tool.type !== "namespace" + || tool.name !== IMAGE_GEN_NAMESPACE + || !Array.isArray(tool.tools) + || tool.tools.length === 0 + ) return undefined; + + for (const innerTool of tool.tools) { + if ( + !isPlainObject(innerTool) + || innerTool.type !== "function" + || typeof innerTool.name !== "string" + || innerTool.name.length === 0 + ) return undefined; + } + + return tool.tools.map(innerTool => { + const functionTool = innerTool as Record & { name: string }; + return { + ...functionTool, + name: imageGenWireName(functionTool.name), + }; + }); +} + +function normalizeFlatImageGenFunction(tool: unknown): unknown { + if ( + !isPlainObject(tool) + || tool.type !== "function" + || typeof tool.name !== "string" + || !tool.name.startsWith(IMAGE_GEN_DOTTED_PREFIX) + ) return tool; + return { ...tool, name: imageGenWireName(tool.name) }; +} + +function imageGenFunctionName(tool: unknown): string | undefined { + if (!isPlainObject(tool) || tool.type !== "function" || typeof tool.name !== "string") { + return undefined; + } + return isImageGenClientName(tool.name) ? tool.name : undefined; +} + +function declaresImageGenFunctionCall(item: unknown): boolean { + if (!isPlainObject(item) || item.type !== "function_call" || typeof item.name !== "string") { + return false; + } + return item.namespace === IMAGE_GEN_NAMESPACE || isImageGenClientName(item.name); +} + +function normalizeImageGenFunctionCall(item: unknown): unknown { + if (!declaresImageGenFunctionCall(item) || !isPlainObject(item) || typeof item.name !== "string") { + return item; + } + if (item.namespace === IMAGE_GEN_NAMESPACE) { + const { namespace: _namespace, ...rest } = item; + return { ...rest, name: imageGenWireName(item.name) }; + } + if (item.name.startsWith(IMAGE_GEN_DOTTED_PREFIX)) { + return { ...item, name: imageGenWireName(item.name) }; + } + return item; +} /** - * Drop hosted tools whose names collide with declared function/namespace tools (see - * HOSTED_TOOL_NAME_CONFLICTS). Only applies on the API-key platform path: the ChatGPT backend - * ("forward" mode) accepts the pair, and stripping there would disable native imagegen. No-op - * (returns the original reference) when nothing matches. + * Normalize Codex's private image-gen tool declaration for API-key Responses providers. * - * Codex Desktop Responses Lite requests do not always use top-level `body.tools`; they may place - * the same declarations in `body.input[]` entries of type `additional_tools`. The platform validates - * these containers as one tool namespace, so conflict detection must span all of them. The function - * uses copy-on-write semantics and preserves the original request reference when nothing is removed. + * A complete `image_gen` namespace is flattened to safe `image_gen__` aliases even when it is + * the only image tool in the request. Replayed client calls are encoded to the same alias, including + * legacy dotted calls from older compatibility attempts. When any client image-gen tool is present, + * the duplicate hosted `image_generation` entry is removed. Duplicate aliases are resolved in stable + * container order: top-level tools first, then Responses Lite `additional_tools` entries. + * + * This function is called only on the API-key path. ChatGPT forward mode understands the private + * namespace and must keep it. Copy-on-write preserves the original request reference when no + * namespace is flattened, hosted tool removed, or duplicate function discarded. */ -function stripConflictingHostedTools(body: unknown): unknown { +function normalizeImageGenClientTools(body: unknown): unknown { if (!isPlainObject(body)) return body; // Collect every tool container in the request. Traditional HTTP/SSE requests use top-level @@ -538,34 +628,45 @@ function stripConflictingHostedTools(body: unknown): unknown { } } } - if (toolGroups.length === 0) return body; - - // Detect conflicts across containers because the client namespace and the hosted tool may be - // declared in different groups while still sharing one platform-validated namespace. - const conflicting = HOSTED_TOOL_NAME_CONFLICTS.filter(c => toolGroups.some(group => - group.some(t => { - if (!isPlainObject(t) || typeof t.name !== "string") return false; - if (t.type === "namespace") return t.name === c.namePrefix; - return t.name === c.namePrefix || t.name.startsWith(`${c.namePrefix}.`); - }), - )); - if (conflicting.length === 0) return body; - - // Allocate a replacement only when a hosted tool is removed. Preserve malformed entries, - // unrelated tools, and future tool types so this compatibility rule remains narrowly scoped. - const stripGroup = (tools: unknown[]): unknown[] => { - const filtered = tools.filter(t => { - const type = isPlainObject(t) && typeof t.type === "string" ? t.type : undefined; - if (!type) return true; - return !conflicting.some(c => c.hostedType === type); - }); - return filtered.length === tools.length ? tools : filtered; + const hasImageGenClientTool = toolGroups.some(group => group.some(declaresImageGenClientTool)) + || (Array.isArray(body.input) && body.input.some(declaresImageGenFunctionCall)); + if (!hasImageGenClientTool) return body; + + const seenFunctionNames = new Set(); + const normalizeGroup = (tools: unknown[]): unknown[] => { + const normalized: unknown[] = []; + let groupChanged = false; + + for (const tool of tools) { + if (isPlainObject(tool) && tool.type === HOSTED_IMAGE_GENERATION_TOOL) { + groupChanged = true; + continue; + } + + const flattened = flattenImageGenNamespace(tool); + const candidates = flattened ?? [tool]; + if (flattened) groupChanged = true; + + for (const candidate of candidates) { + const normalizedCandidate = normalizeFlatImageGenFunction(candidate); + if (normalizedCandidate !== candidate) groupChanged = true; + const functionName = imageGenFunctionName(normalizedCandidate); + if (functionName && seenFunctionNames.has(functionName)) { + groupChanged = true; + continue; + } + if (functionName) seenFunctionNames.add(functionName); + normalized.push(normalizedCandidate); + } + } + + return groupChanged ? normalized : tools; }; let changed = false; let tools = body.tools; if (Array.isArray(body.tools)) { - tools = stripGroup(body.tools); + tools = normalizeGroup(body.tools); changed ||= tools !== body.tools; } @@ -573,13 +674,15 @@ function stripConflictingHostedTools(body: unknown): unknown { if (Array.isArray(body.input)) { let nestedChanged = false; const mappedInput = body.input.map(item => { - if (!isPlainObject(item) || item.type !== "additional_tools" || !Array.isArray(item.tools)) { - return item; + if (isPlainObject(item) && item.type === "additional_tools" && Array.isArray(item.tools)) { + const nestedTools = normalizeGroup(item.tools); + if (nestedTools === item.tools) return item; + nestedChanged = true; + return { ...item, tools: nestedTools }; } - const nestedTools = stripGroup(item.tools); - if (nestedTools === item.tools) return item; - nestedChanged = true; - return { ...item, tools: nestedTools }; + const normalizedCall = normalizeImageGenFunctionCall(item); + if (normalizedCall !== item) nestedChanged = true; + return normalizedCall; }); if (nestedChanged) { input = mappedInput; @@ -740,7 +843,7 @@ export function createResponsesPassthroughAdapter(provider: OcxProviderConfig): outBody = repairOrphanedInputItems(outBody, unexpandedMiss); outBody = stripUnsupportedForwardParams(outBody); } - else outBody = stripConflictingHostedTools(outBody); + else outBody = normalizeImageGenClientTools(outBody); if (forward || parsed._previousResponseInputExpanded === true) { outBody = repairOversizedReplayCallIds(outBody); } diff --git a/src/server/index.ts b/src/server/index.ts index 1ea9723104..8de20175f0 100644 --- a/src/server/index.ts +++ b/src/server/index.ts @@ -226,14 +226,15 @@ function attachLiveSidebandUpstream(ws: ServerWebSocket): void { // Source invariant for tests/passthrough-abort.test.ts after the pure module split: // if (isEventStream && upstreamResponse.body) { // const repairConfig = route.provider.responsesItemIdRepair; -// #314 gated shape (win32-no-repair only; default OFF on the bundled known-bad runtime): +// const needsClientRewrite = imageGenCallAliases.size > 0 +// #314 gated shape (win32-no-client-rewrite only; default OFF on the bundled known-bad runtime): // decideEagerRelay(config.streamMode ?? "auto") // relaySseEagerBounded(upstreamResponse.body, turnAc, // Default shape (tee + background inspection): // upstreamResponse.body.tee() // const repairedBody = hasResponsesItemIdRepair(repairConfig) // process.platform === "win32" -// && !hasResponsesItemIdRepair(repairConfig) +// && !needsClientRewrite // ? nativeBody // relaySseWithFailedTail(repairedBody, upstream) // new Response(clientBody diff --git a/src/server/responses-image-gen-repair.ts b/src/server/responses-image-gen-repair.ts new file mode 100644 index 0000000000..feef1872a3 --- /dev/null +++ b/src/server/responses-image-gen-repair.ts @@ -0,0 +1,169 @@ +interface NamespacedTool { + namespace: string; + name: string; +} + +function isPlainObject(value: unknown): value is Record { + return !!value && typeof value === "object" && !Array.isArray(value); +} + +/** + * Restrict the generic bridge map to Codex's standalone image namespace and accept the dotted + * alias emitted by earlier compatibility attempts. Exact map lookups avoid splitting arbitrary + * double-underscore function names that belong to unrelated client tools. + */ +export function imageGenToolCallAliases( + toolNsMap: ReadonlyMap, +): Map { + const aliases = new Map(); + for (const [wireName, tool] of toolNsMap) { + if (tool.namespace !== "image_gen") continue; + aliases.set(wireName, tool); + aliases.set(`${tool.namespace}.${tool.name}`, tool); + } + return aliases; +} + +function restoreImageGenCalls( + value: unknown, + aliases: ReadonlyMap, +): { value: unknown; changed: boolean } { + if (Array.isArray(value)) { + let changed = false; + const restored = value.map(entry => { + const result = restoreImageGenCalls(entry, aliases); + changed ||= result.changed; + return result.value; + }); + return changed ? { value: restored, changed: true } : { value, changed: false }; + } + if (!isPlainObject(value)) return { value, changed: false }; + + let changed = false; + const restored: Record = {}; + for (const [key, entry] of Object.entries(value)) { + const result = restoreImageGenCalls(entry, aliases); + restored[key] = result.value; + changed ||= result.changed; + } + + const name = typeof value.name === "string" ? value.name : undefined; + const target = value.type === "function_call" && name ? aliases.get(name) : undefined; + if (target) { + restored.name = target.name; + restored.namespace = target.namespace; + changed = true; + } + return changed ? { value: restored, changed: true } : { value, changed: false }; +} + +/** Restore image-gen function calls in a non-streaming Responses JSON document. */ +export function restoreImageGenCallsInJson( + text: string, + aliases: ReadonlyMap, +): string { + if (aliases.size === 0) return text; + let payload: unknown; + try { + payload = JSON.parse(text); + } catch { + return text; + } + const restored = restoreImageGenCalls(payload, aliases); + return restored.changed ? JSON.stringify(restored.value) : text; +} + +function nextSseBlock(buffer: string): { block: string; delimiter: string; rest: string } | null { + const match = buffer.match(/\r?\n\r?\n/); + if (!match || match.index === undefined) return null; + return { + block: buffer.slice(0, match.index), + delimiter: match[0], + rest: buffer.slice(match.index + match[0].length), + }; +} + +function sseDataPayload(block: string): string | null { + const data: string[] = []; + for (const line of block.split(/\r?\n/)) { + if (!line.startsWith("data:")) continue; + const value = line.slice(5); + data.push(value.startsWith(" ") ? value.slice(1) : value); + } + return data.length > 0 ? data.join("\n") : null; +} + +function replaceSseDataPayload(block: string, payload: string): string { + const newline = block.includes("\r\n") ? "\r\n" : "\n"; + const lines = block.split(/\r?\n/); + const rewritten: string[] = []; + let replaced = false; + for (const line of lines) { + if (!line.startsWith("data:")) { + rewritten.push(line); + continue; + } + if (!replaced) { + rewritten.push(`data: ${payload}`); + replaced = true; + } + } + return replaced ? rewritten.join(newline) : block; +} + +/** + * Restore upstream-safe image-gen aliases only on the client-facing SSE branch. The inspection and + * continuation-cache branch must retain raw upstream names so a replay can be sent back unchanged. + */ +export function relaySseWithImageGenCallRestore( + body: ReadableStream, + aliases: ReadonlyMap, +): ReadableStream { + if (aliases.size === 0) return body; + const reader = body.getReader(); + const decoder = new TextDecoder(); + const encoder = new TextEncoder(); + let buffer = ""; + + const emitProcessedBlocks = ( + controller: ReadableStreamDefaultController, + flushFinal = false, + ): void => { + let next: { block: string; delimiter: string; rest: string } | null; + while ((next = nextSseBlock(buffer))) { + buffer = next.rest; + const payload = sseDataPayload(next.block); + const restoredPayload = payload ? restoreImageGenCallsInJson(payload, aliases) : undefined; + const block = payload && restoredPayload !== undefined && restoredPayload !== payload + ? replaceSseDataPayload(next.block, restoredPayload) + : next.block; + controller.enqueue(encoder.encode(block + next.delimiter)); + } + if (flushFinal && buffer.length > 0) { + const payload = sseDataPayload(buffer); + const restoredPayload = payload ? restoreImageGenCallsInJson(payload, aliases) : undefined; + const block = payload && restoredPayload !== undefined && restoredPayload !== payload + ? replaceSseDataPayload(buffer, restoredPayload) + : buffer; + controller.enqueue(encoder.encode(block)); + buffer = ""; + } + }; + + return new ReadableStream({ + async pull(controller) { + const { done, value } = await reader.read(); + if (done) { + buffer += decoder.decode(); + emitProcessedBlocks(controller, true); + controller.close(); + return; + } + buffer += decoder.decode(value, { stream: true }); + emitProcessedBlocks(controller); + }, + cancel(reason) { + reader.cancel(reason).catch(() => {}); + }, + }); +} diff --git a/src/server/responses/core.ts b/src/server/responses/core.ts index dc96fa01d0..1019caf4a3 100644 --- a/src/server/responses/core.ts +++ b/src/server/responses/core.ts @@ -133,6 +133,11 @@ import { relaySseEagerBounded } from "../relay-eager"; import { decideEagerRelay } from "../../lib/bun-stream-caps"; import { cancelBodyOnAbort } from "../../lib/abort"; import { hasResponsesItemIdRepair, relaySseWithResponsesItemIdRepair } from "../responses-item-id-repair"; +import { + imageGenToolCallAliases, + relaySseWithImageGenCallRestore, + restoreImageGenCallsInJson, +} from "../responses-image-gen-repair"; import type { EffectiveSubagentRoster, SpawnAgentSurface } from "../../codex/catalog"; import { buildToolBridgeMaps, collabSurface, injectDeveloperMessage, multiAgentGuidanceText } from "./collaboration"; @@ -1337,6 +1342,9 @@ export async function handleResponses( } if ("passthrough" in adapter && adapter.passthrough && !routedCompaction) { + const imageGenCallAliases = route.provider.authMode === "forward" + ? new Map() + : imageGenToolCallAliases(buildToolBridgeMaps(parsed).toolNsMap); // Local continuation cache for the ChatGPT passthrough. Codex WS turns chain with // previous_response_id, ocx converts them to internal HTTP requests, and the ChatGPT Codex // REST backend rejects the parameter — the adapter strips it in forward mode, so the ONLY @@ -1541,8 +1549,9 @@ export async function handleResponses( // known-bad runtime remains the tee path below. if (isEventStream && upstreamResponse.body) { const repairConfig = route.provider.responsesItemIdRepair; - const winNoRepair = process.platform === "win32" && !hasResponsesItemIdRepair(repairConfig); - const eagerDecision = winNoRepair ? decideEagerRelay(config.streamMode ?? "auto") : null; + const needsClientRewrite = imageGenCallAliases.size > 0 || hasResponsesItemIdRepair(repairConfig); + const winNoClientRewrite = process.platform === "win32" && !needsClientRewrite; + const eagerDecision = winNoClientRewrite ? decideEagerRelay(config.streamMode ?? "auto") : null; if (eagerDecision?.useEagerRelay) { const turnAc = new AbortController(); linkAbortSignal(upstream, turnAc.signal); @@ -1593,8 +1602,9 @@ export async function handleResponses( onClientCancel: () => options.onNativePassthroughCancel?.(), onDone: () => unregisterTurn(turnAc), }); + const clientBody = relaySseWithImageGenCallRestore(eagerBody, imageGenCallAliases); if (!headers.has("content-type")) headers.set("content-type", "text/event-stream"); - return markNativePassthroughSseResponse(new Response(eagerBody, { + return markNativePassthroughSseResponse(new Response(clientBody, { status: upstreamResponse.status, headers, })); @@ -1652,10 +1662,11 @@ export async function handleResponses( // win32 must keep the pure native relay (Bun#32111 JS-sink segfault); elsewhere a JS pull // relay is established practice (relayWithAbort, relaySseWithHeartbeat) and lets a // mid-stream reset end with a clean response.failed terminal instead of a raw socket error. + const restoredBody = relaySseWithImageGenCallRestore(nativeBody, imageGenCallAliases); const repairedBody = hasResponsesItemIdRepair(repairConfig) - ? relaySseWithResponsesItemIdRepair(nativeBody, repairConfig!) - : nativeBody; - const clientBody = process.platform === "win32" && !hasResponsesItemIdRepair(repairConfig) + ? relaySseWithResponsesItemIdRepair(restoredBody, repairConfig!) + : restoredBody; + const clientBody = process.platform === "win32" && !needsClientRewrite ? nativeBody : relaySseWithFailedTail(repairedBody, upstream); return markNativePassthroughSseResponse(new Response(clientBody, { @@ -1671,7 +1682,7 @@ export async function handleResponses( rememberPassthroughResponse(JSON.parse(text) as { id?: unknown; output?: unknown; status?: unknown }); } catch { /* non-JSON despite content-type; recording is best-effort */ } } - return new Response(text, { + return new Response(restoreImageGenCallsInJson(text, imageGenCallAliases), { status: upstreamResponse.status, statusText: upstreamResponse.statusText, headers, diff --git a/structure/04_transports-and-sidecars.md b/structure/04_transports-and-sidecars.md index 6f39490d13..bee9b81abf 100644 --- a/structure/04_transports-and-sidecars.md +++ b/structure/04_transports-and-sidecars.md @@ -60,13 +60,21 @@ explicit keyed Images provider accepts the proxy admission secret as either an O or `x-opencodex-api-key` because the provider key replaces caller authorization before fetch. The ChatGPT forward path still requires the dedicated header so its upstream bearer remains distinct. -The API-key `openai-responses` path also prevents the standalone client tool from colliding with the -hosted Responses tool. When a request declares `image_gen.imagegen` (as a flat function or an -`image_gen` namespace), the adapter drops hosted `image_generation` while preserving unrelated -tools. Conflict discovery spans both top-level `body.tools` and Codex Desktop Responses Lite -`input[].type = "additional_tools"` containers because the platform validates their merged tool -namespace. ChatGPT forward mode preserves the pair because that backend accepts it and owns native -image generation. +The API-key `openai-responses` path also adapts Codex's private standalone image tool to the public +Responses tool surface. A complete `image_gen` namespace is lowered to safe +`image_gen__` function aliases even when no hosted image tool is present, because public +Responses runtimes may reserve the namespace itself and reject dotted function names. Native and +legacy dotted calls replayed in `body.input` are encoded to the same aliases. When any client +image-gen tool is declared, the adapter also drops hosted `image_generation` and deduplicates aliases +in stable container order. Discovery and normalization span both top-level `body.tools` and Codex +Desktop Responses Lite `input[].type = "additional_tools"` containers. + +Client-facing API-key responses perform the inverse mapping: JSON output and SSE function-call +items restore `{ namespace: "image_gen", name: "" }` so Codex can dispatch the local +extension. Inspection and continuation-cache branches keep the raw upstream alias, allowing stored +replays to return upstream without leaking a client-only namespace shape. Malformed, empty, and +unrelated namespaces remain untouched. ChatGPT forward mode preserves the private namespace and +hosted tool because that backend understands their native semantics. Per-model `modelReasoningSummaryDelivery` is a narrow compatibility layer for `openai-responses` gateways whose summary capability is real but whose accepted delivery enum diff --git a/tests/azure-adapter.test.ts b/tests/azure-adapter.test.ts index 833ebf5d2d..b20767edfa 100644 --- a/tests/azure-adapter.test.ts +++ b/tests/azure-adapter.test.ts @@ -33,6 +33,30 @@ describe("Azure OpenAI adapter hardening", () => { expect(request.headers.Authorization).toBeUndefined(); }); + test("lowers the private image_gen namespace on the inherited API-key path", async () => { + const request = await createAzureAdapter(provider()).buildRequest({ + ...parsed, + _rawBody: { + model: "gpt-5.5", + input: [{ + type: "additional_tools", + tools: [{ + type: "namespace", + name: "image_gen", + tools: [{ type: "function", name: "imagegen", parameters: {} }], + }], + }], + }, + }); + const body = JSON.parse(request.body) as { + input: Array<{ tools?: Array<{ type: string; name?: string }> }>; + }; + + expect(body.input[0]?.tools).toEqual([ + { type: "function", name: "image_gen__imagegen", parameters: {} }, + ]); + }); + test("rejects missing and blank API keys", async () => { for (const apiKey of [undefined, "", " "]) { await expect(createAzureAdapter(provider({ apiKey })).buildRequest(parsed)) diff --git a/tests/openai-responses-passthrough.test.ts b/tests/openai-responses-passthrough.test.ts index f5928652f9..75d6cd26f3 100644 --- a/tests/openai-responses-passthrough.test.ts +++ b/tests/openai-responses-passthrough.test.ts @@ -628,7 +628,7 @@ describe("OpenAI Responses hosted-tool name conflicts", () => { }; const meta = { headers: new Headers({ authorization: "Bearer token" }) }; - test("keyed platform strips hosted image_generation that collides with a declared image_gen.imagegen tool", () => { + test("keyed platform replaces a dotted image_gen function with a safe alias", () => { const adapter = createResponsesPassthroughAdapter(keyedProvider); const request = adapter.buildRequest({ modelId: "gpt-5.6-sol", @@ -650,11 +650,11 @@ describe("OpenAI Responses hosted-tool name conflicts", () => { // Hosted image_generation dropped; the declared client tool wins and unrelated hosted tools stay. expect(body.tools).toHaveLength(2); expect(body.tools.some(t => t.type === "image_generation")).toBe(false); - expect(body.tools.some(t => t.type === "function" && t.name === "image_gen.imagegen")).toBe(true); + expect(body.tools.some(t => t.type === "function" && t.name === "image_gen__imagegen")).toBe(true); expect(body.tools.some(t => t.type === "web_search")).toBe(true); }); - test("keyed platform strips hosted image_generation when the skill is declared as an image_gen namespace tool", () => { + test("keyed platform flattens an image_gen namespace and removes the hosted duplicate", () => { const adapter = createResponsesPassthroughAdapter(keyedProvider); const request = adapter.buildRequest({ modelId: "gpt-5.6-sol", @@ -665,19 +665,35 @@ describe("OpenAI Responses hosted-tool name conflicts", () => { model: "gpt-5.6-sol", input: [], tools: [ - { type: "namespace", name: "image_gen" }, + { + type: "namespace", + name: "image_gen", + description: "Client image tools", + tools: [{ + type: "function", + name: "imagegen", + description: "Generate or edit an image", + parameters: { type: "object", properties: { prompt: { type: "string" } } }, + strict: true, + }], + }, { type: "image_generation" }, ], }, }, meta); - const body = JSON.parse(request.body) as { tools: { type: string; name?: string }[] }; + const body = JSON.parse(request.body) as { tools: Array> }; expect(body.tools).toHaveLength(1); - expect(body.tools[0]).toMatchObject({ type: "namespace", name: "image_gen" }); - expect(body.tools.some(t => t.type === "image_generation")).toBe(false); + expect(body.tools[0]).toEqual({ + type: "function", + name: "image_gen__imagegen", + description: "Generate or edit an image", + parameters: { type: "object", properties: { prompt: { type: "string" } } }, + strict: true, + }); }); - test("keyed responses-lite strips hosted image_generation from nested additional_tools", () => { + test("keyed responses-lite flattens a nested namespace without requiring a hosted tool", () => { const adapter = createResponsesPassthroughAdapter(keyedProvider); const request = adapter.buildRequest({ modelId: "gpt-5.6-sol", @@ -691,8 +707,11 @@ describe("OpenAI Responses hosted-tool name conflicts", () => { type: "additional_tools", role: "developer", tools: [ - { type: "namespace", name: "image_gen", tools: [] }, - { type: "image_generation" }, + { + type: "namespace", + name: "image_gen", + tools: [{ type: "function", name: "imagegen", parameters: { type: "object" } }], + }, { type: "web_search" }, ], }, @@ -705,11 +724,13 @@ describe("OpenAI Responses hosted-tool name conflicts", () => { }; const additionalTools = body.input.find(item => item.type === "additional_tools"); - // Preserve the input entry and unrelated tools while removing only the hosted conflict. + // Preserve the input entry and unrelated tools while lowering the private namespace. expect(additionalTools).toBeDefined(); expect(additionalTools?.role).toBe("developer"); - expect(additionalTools?.tools?.some(t => t.type === "image_generation")).toBe(false); - expect(additionalTools?.tools?.some(t => t.type === "namespace" && t.name === "image_gen")).toBe(true); + expect(additionalTools?.tools?.some(t => t.type === "namespace")).toBe(false); + expect(additionalTools?.tools?.some(t => + t.type === "function" && t.name === "image_gen__imagegen" + )).toBe(true); expect(additionalTools?.tools?.some(t => t.type === "web_search")).toBe(true); expect(body.input.some(item => item.type === "message")).toBe(true); }); @@ -745,7 +766,126 @@ describe("OpenAI Responses hosted-tool name conflicts", () => { // The platform validates one merged namespace even when declarations use different groups. expect(body.tools.some(t => t.type === "image_generation")).toBe(false); expect(body.tools.some(t => t.type === "web_search")).toBe(true); - expect(additionalTools?.tools?.some(t => t.name === "image_gen.imagegen")).toBe(true); + expect(additionalTools?.tools?.some(t => t.name === "image_gen__imagegen")).toBe(true); + }); + + test("keyed platform encodes native and legacy image-gen calls for upstream replay", () => { + const adapter = createResponsesPassthroughAdapter(keyedProvider); + const request = adapter.buildRequest({ + modelId: "gpt-5.6-sol", + context: { messages: [] }, + stream: true, + options: {}, + _rawBody: { + model: "gpt-5.6-sol", + tools: [{ + type: "namespace", + name: "image_gen", + tools: [{ type: "function", name: "imagegen", parameters: {} }], + }], + input: [ + { + type: "function_call", + namespace: "image_gen", + name: "imagegen", + call_id: "call_native", + arguments: "{}", + }, + { + type: "function_call", + name: "image_gen.imagegen", + call_id: "call_legacy", + arguments: "{}", + }, + { type: "function_call", name: "exec_command", call_id: "call_other", arguments: "{}" }, + ], + }, + }, meta); + const body = JSON.parse(request.body) as { + input: Array<{ name?: string; namespace?: string }>; + }; + + expect(body.input[0]).toMatchObject({ name: "image_gen__imagegen", call_id: "call_native" }); + expect(body.input[0]).not.toHaveProperty("namespace"); + expect(body.input[1]).toMatchObject({ name: "image_gen__imagegen", call_id: "call_legacy" }); + expect(body.input[2]).toMatchObject({ name: "exec_command", call_id: "call_other" }); + }); + + test("keyed responses normalization is idempotent and deduplicates image-gen aliases", () => { + const adapter = createResponsesPassthroughAdapter(keyedProvider); + const firstRequest = adapter.buildRequest({ + modelId: "gpt-5.6-sol", + context: { messages: [] }, + stream: true, + options: {}, + _rawBody: { + model: "gpt-5.6-sol", + tools: [{ + type: "namespace", + name: "image_gen", + tools: [{ type: "function", name: "imagegen", parameters: { type: "object" } }], + }], + input: [{ + type: "additional_tools", + role: "developer", + tools: [ + { type: "function", name: "image_gen.imagegen", parameters: { type: "object" } }, + { type: "web_search" }, + ], + }], + }, + }, meta); + const firstBody = JSON.parse(firstRequest.body) as { + tools: Array<{ type: string; name?: string }>; + input: Array<{ type: string; tools?: Array<{ type: string; name?: string }> }>; + }; + + expect(firstBody.tools).toEqual([ + { type: "function", name: "image_gen__imagegen", parameters: { type: "object" } }, + ]); + expect(firstBody.input[0]?.tools).toEqual([{ type: "web_search" }]); + + const secondRequest = adapter.buildRequest({ + modelId: "gpt-5.6-sol", + context: { messages: [] }, + stream: true, + options: {}, + _rawBody: firstBody, + }, meta); + expect(JSON.parse(secondRequest.body)).toEqual(firstBody); + }); + + test("keyed platform preserves unrelated and malformed namespaces", () => { + const adapter = createResponsesPassthroughAdapter(keyedProvider); + const request = adapter.buildRequest({ + modelId: "gpt-5.6-sol", + context: { messages: [] }, + stream: true, + options: {}, + _rawBody: { + model: "gpt-5.6-sol", + input: [], + tools: [ + { type: "namespace", name: "image_gen", tools: [] }, + { + type: "namespace", + name: "web", + tools: [{ type: "function", name: "run", parameters: {} }], + }, + { type: "image_generation" }, + ], + }, + }, meta); + const body = JSON.parse(request.body) as { tools: Array> }; + + expect(body.tools).toEqual([ + { type: "namespace", name: "image_gen", tools: [] }, + { + type: "namespace", + name: "web", + tools: [{ type: "function", name: "run", parameters: {} }], + }, + ]); }); test("keyed platform keeps hosted image_generation when no conflicting tool is declared", () => { @@ -770,8 +910,8 @@ describe("OpenAI Responses hosted-tool name conflicts", () => { expect(body.tools.some(t => t.type === "image_generation")).toBe(true); }); - test("forward backend preserves the image_generation + image_gen.imagegen pair", () => { - // The ChatGPT backend tolerates the pair; stripping there would disable native imagegen. + test("forward backend preserves the private image_gen namespace and hosted tool", () => { + // The ChatGPT backend understands the private namespace; lowering it would change native behavior. const adapter = createResponsesPassthroughAdapter(provider); const request = adapter.buildRequest({ modelId: "gpt-5.5", @@ -782,16 +922,26 @@ describe("OpenAI Responses hosted-tool name conflicts", () => { model: "gpt-5.5", input: [], tools: [ - { type: "function", name: "image_gen.imagegen", parameters: {} }, + { + type: "namespace", + name: "image_gen", + tools: [{ type: "function", name: "imagegen", parameters: {} }], + }, { type: "image_generation" }, ], }, }, meta); - const body = JSON.parse(request.body) as { tools: { type: string; name?: string }[] }; + const body = JSON.parse(request.body) as { + tools: Array<{ type: string; name?: string; tools?: Array<{ name?: string }> }>; + }; expect(body.tools).toHaveLength(2); expect(body.tools.some(t => t.type === "image_generation")).toBe(true); - expect(body.tools.some(t => t.type === "function" && t.name === "image_gen.imagegen")).toBe(true); + expect(body.tools.some(t => + t.type === "namespace" + && t.name === "image_gen" + && t.tools?.some(inner => inner.name === "imagegen") + )).toBe(true); }); }); diff --git a/tests/passthrough-abort.test.ts b/tests/passthrough-abort.test.ts index 4f536fedfb..0e3bf56489 100644 --- a/tests/passthrough-abort.test.ts +++ b/tests/passthrough-abort.test.ts @@ -42,12 +42,13 @@ describe("passthrough relayWithAbort (RC2, passthrough path)", () => { ); expect(sseBranch).toContain("upstreamResponse.body.tee()"); - // win32 must receive the tee'd body untouched when repair is disabled — no JS pull wrapper - // on the default path (Bun#32111 segfault). + // win32 must receive the tee'd body untouched when no client rewrite is required — no JS pull + // wrapper on the default path (Bun#32111 segfault). expect(sseBranch).toContain("const repairConfig = route.provider.responsesItemIdRepair;"); + expect(sseBranch).toContain("const needsClientRewrite = imageGenCallAliases.size > 0"); expect(sseBranch).toContain("const repairedBody = hasResponsesItemIdRepair(repairConfig)"); expect(sseBranch).toContain('process.platform === "win32"'); - expect(sseBranch).toContain("&& !hasResponsesItemIdRepair(repairConfig)"); + expect(sseBranch).toContain("&& !needsClientRewrite"); expect(sseBranch).toContain("? nativeBody"); // Elsewhere the failed-tail relay converts mid-stream resets into a clean response.failed. expect(sseBranch).toContain("relaySseWithFailedTail(repairedBody, upstream)"); diff --git a/tests/responses-image-gen-repair.test.ts b/tests/responses-image-gen-repair.test.ts new file mode 100644 index 0000000000..f2501d88f2 --- /dev/null +++ b/tests/responses-image-gen-repair.test.ts @@ -0,0 +1,262 @@ +import { describe, expect, test } from "bun:test"; +import { + imageGenToolCallAliases, + relaySseWithImageGenCallRestore, + restoreImageGenCallsInJson, +} from "../src/server/responses-image-gen-repair"; +import { handleResponses } from "../src/server/responses"; +import type { OcxConfig } from "../src/types"; + +function streamFromChunks(chunks: string[]): ReadableStream { + const encoder = new TextEncoder(); + return new ReadableStream({ + start(controller) { + for (const chunk of chunks) controller.enqueue(encoder.encode(chunk)); + controller.close(); + }, + }); +} + +async function readAll(stream: ReadableStream): Promise { + return new Response(stream).text(); +} + +const aliases = imageGenToolCallAliases(new Map([ + ["image_gen__imagegen", { namespace: "image_gen", name: "imagegen" }], + ["mcp__files__read", { namespace: "mcp__files", name: "read" }], +])); + +describe("Responses image-gen call restoration", () => { + test("keeps only image-gen aliases and accepts the legacy dotted name", () => { + expect([...aliases.entries()]).toEqual([ + ["image_gen__imagegen", { namespace: "image_gen", name: "imagegen" }], + ["image_gen.imagegen", { namespace: "image_gen", name: "imagegen" }], + ]); + }); + + test("restores non-streaming function calls without changing unrelated tools", () => { + const upstream = JSON.stringify({ + id: "resp_1", + output: [ + { + type: "function_call", + id: "fc_1", + call_id: "call_1", + name: "image_gen__imagegen", + arguments: "{}", + }, + { + type: "function_call", + id: "fc_2", + call_id: "call_2", + name: "exec_command", + arguments: "{}", + }, + ], + }); + + expect(JSON.parse(restoreImageGenCallsInJson(upstream, aliases))).toEqual({ + id: "resp_1", + output: [ + { + type: "function_call", + id: "fc_1", + call_id: "call_1", + namespace: "image_gen", + name: "imagegen", + arguments: "{}", + }, + { + type: "function_call", + id: "fc_2", + call_id: "call_2", + name: "exec_command", + arguments: "{}", + }, + ], + }); + }); + + test("restores added, done, and completed SSE snapshots across chunk boundaries", async () => { + const item = { + type: "function_call", + id: "fc_1", + call_id: "call_1", + name: "image_gen__imagegen", + arguments: "{}", + }; + const added = `event: response.output_item.added\ndata: ${JSON.stringify({ + type: "response.output_item.added", + output_index: 0, + item, + })}\n\n`; + const done = `event: response.output_item.done\ndata: ${JSON.stringify({ + type: "response.output_item.done", + output_index: 0, + item: { ...item, status: "completed" }, + })}\n\n`; + const completed = `event: response.completed\ndata: ${JSON.stringify({ + type: "response.completed", + response: { id: "resp_1", status: "completed", output: [{ ...item, status: "completed" }] }, + })}\n\ndata: [DONE]\n\n`; + const upstream = added + done + completed; + const splitAt = Math.floor(upstream.length / 2); + const restored = await readAll(relaySseWithImageGenCallRestore( + streamFromChunks([upstream.slice(0, splitAt), upstream.slice(splitAt)]), + aliases, + )); + + expect(restored).not.toContain("image_gen__imagegen"); + expect(restored.match(/\"namespace\":\"image_gen\"/g)).toHaveLength(3); + expect(restored.match(/\"name\":\"imagegen\"/g)).toHaveLength(3); + expect(restored).toContain("data: [DONE]"); + }); + + test("restores legacy dotted calls and preserves invalid JSON byte-for-byte", () => { + const legacy = JSON.stringify({ + type: "function_call", + name: "image_gen.imagegen", + call_id: "call_legacy", + arguments: "{}", + }); + expect(JSON.parse(restoreImageGenCallsInJson(legacy, aliases))).toMatchObject({ + namespace: "image_gen", + name: "imagegen", + call_id: "call_legacy", + }); + expect(restoreImageGenCallsInJson("not-json", aliases)).toBe("not-json"); + }); + + test("handleResponses encodes the request and restores the client-facing JSON call", async () => { + const savedFetch = globalThis.fetch; + let outboundBody: Record | undefined; + globalThis.fetch = (async (_input, init) => { + outboundBody = JSON.parse(String(init?.body)) as Record; + return Response.json({ + status: "completed", + output: [{ + type: "function_call", + id: "fc_1", + call_id: "call_1", + name: "image_gen__imagegen", + arguments: "{}", + }], + }); + }) as typeof fetch; + + const config = { + port: 0, + defaultProvider: "fixture", + providers: { + fixture: { + adapter: "openai-responses", + baseUrl: "https://fixture.test/v1", + authMode: "key", + apiKey: "fixture-key", + }, + }, + } as OcxConfig; + + try { + const response = await handleResponses(new Request("http://localhost/v1/responses", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + model: "fixture/gpt-5.6-sol", + stream: false, + input: [{ role: "user", content: [{ type: "input_text", text: "generate an image" }] }], + tools: [{ + type: "namespace", + name: "image_gen", + tools: [{ type: "function", name: "imagegen", parameters: {} }], + }], + }), + }), config, { model: "", provider: "" }); + const clientBody = await response.json() as { + output: Array<{ namespace?: string; name?: string; call_id?: string }>; + }; + const outboundTools = outboundBody?.tools as Array<{ name?: string }> | undefined; + + expect(outboundTools?.[0]?.name).toBe("image_gen__imagegen"); + expect(clientBody.output[0]).toMatchObject({ + namespace: "image_gen", + name: "imagegen", + call_id: "call_1", + }); + } finally { + globalThis.fetch = savedFetch; + } + }); + + test("handleResponses restores client-facing SSE calls on the Windows rewrite path", async () => { + const savedFetch = globalThis.fetch; + let outboundBody: Record | undefined; + const item = { + type: "function_call", + id: "fc_1", + call_id: "call_1", + name: "image_gen__imagegen", + arguments: "{}", + status: "completed", + }; + const upstream = [ + `event: response.output_item.added\ndata: ${JSON.stringify({ + type: "response.output_item.added", + output_index: 0, + item: { ...item, status: "in_progress" }, + })}\n\n`, + `event: response.output_item.done\ndata: ${JSON.stringify({ + type: "response.output_item.done", + output_index: 0, + item, + })}\n\n`, + `event: response.completed\ndata: ${JSON.stringify({ + type: "response.completed", + response: { status: "completed", output: [item] }, + })}\n\n`, + "data: [DONE]\n\n", + ].join(""); + globalThis.fetch = (async (_input, init) => { + outboundBody = JSON.parse(String(init?.body)) as Record; + return new Response(upstream, { headers: { "content-type": "text/event-stream" } }); + }) as typeof fetch; + const config = { + port: 0, + defaultProvider: "fixture", + providers: { + fixture: { + adapter: "openai-responses", + baseUrl: "https://fixture.test/v1", + authMode: "key", + apiKey: "fixture-key", + }, + }, + } as OcxConfig; + + try { + const response = await handleResponses(new Request("http://localhost/v1/responses", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + model: "fixture/gpt-5.6-sol", + stream: true, + input: [{ role: "user", content: [{ type: "input_text", text: "generate an image" }] }], + tools: [{ + type: "namespace", + name: "image_gen", + tools: [{ type: "function", name: "imagegen", parameters: {} }], + }], + }), + }), config, { model: "", provider: "" }); + const clientBody = await response.text(); + const outboundTools = outboundBody?.tools as Array<{ name?: string }> | undefined; + + expect(outboundTools?.[0]?.name).toBe("image_gen__imagegen"); + expect(clientBody).not.toContain("image_gen__imagegen"); + expect(clientBody.match(/\"namespace\":\"image_gen\"/g)).toHaveLength(3); + expect(clientBody).toContain("data: [DONE]"); + } finally { + globalThis.fetch = savedFetch; + } + }); +}); From 5cabd7058cb19ed82dadb4e86fd44eeca849c571 Mon Sep 17 00:00:00 2001 From: 977 Date: Tue, 28 Jul 2026 11:21:12 +0800 Subject: [PATCH 02/10] fix(openai-responses): preserve hosted image fallback Remove hosted image_generation only when a usable client alias replaces it, while retaining the hosted tool for malformed namespaces and replay-only calls. --- src/adapters/openai-responses.ts | 26 ++++++++-- structure/04_transports-and-sidecars.md | 7 +-- tests/openai-responses-passthrough.test.ts | 58 ++++++++++++++++++++++ 3 files changed, 84 insertions(+), 7 deletions(-) diff --git a/src/adapters/openai-responses.ts b/src/adapters/openai-responses.ts index 0f7bda4fc3..a9befafc26 100644 --- a/src/adapters/openai-responses.ts +++ b/src/adapters/openai-responses.ts @@ -580,6 +580,18 @@ function imageGenFunctionName(tool: unknown): string | undefined { return isImageGenClientName(tool.name) ? tool.name : undefined; } +function declaresUsableImageGenAlias(tool: unknown): boolean { + if (flattenImageGenNamespace(tool)) return true; + if (!isPlainObject(tool) || tool.type !== "function" || typeof tool.name !== "string") { + return false; + } + if (tool.name.startsWith(IMAGE_GEN_DOTTED_PREFIX)) { + return tool.name.length > IMAGE_GEN_DOTTED_PREFIX.length; + } + return tool.name.startsWith(IMAGE_GEN_WIRE_PREFIX) + && tool.name.length > IMAGE_GEN_WIRE_PREFIX.length; +} + function declaresImageGenFunctionCall(item: unknown): boolean { if (!isPlainObject(item) || item.type !== "function_call" || typeof item.name !== "string") { return false; @@ -606,9 +618,10 @@ function normalizeImageGenFunctionCall(item: unknown): unknown { * * A complete `image_gen` namespace is flattened to safe `image_gen__` aliases even when it is * the only image tool in the request. Replayed client calls are encoded to the same alias, including - * legacy dotted calls from older compatibility attempts. When any client image-gen tool is present, - * the duplicate hosted `image_generation` entry is removed. Duplicate aliases are resolved in stable - * container order: top-level tools first, then Responses Lite `additional_tools` entries. + * legacy dotted calls from older compatibility attempts. When a usable alias replaces a client + * image-gen declaration, the duplicate hosted `image_generation` entry is removed. Duplicate aliases + * are resolved in stable container order: top-level tools first, then Responses Lite + * `additional_tools` entries. * * This function is called only on the API-key path. ChatGPT forward mode understands the private * namespace and must keep it. Copy-on-write preserves the original request reference when no @@ -631,6 +644,7 @@ function normalizeImageGenClientTools(body: unknown): unknown { const hasImageGenClientTool = toolGroups.some(group => group.some(declaresImageGenClientTool)) || (Array.isArray(body.input) && body.input.some(declaresImageGenFunctionCall)); if (!hasImageGenClientTool) return body; + const hasUsableImageGenAlias = toolGroups.some(group => group.some(declaresUsableImageGenAlias)); const seenFunctionNames = new Set(); const normalizeGroup = (tools: unknown[]): unknown[] => { @@ -638,7 +652,11 @@ function normalizeImageGenClientTools(body: unknown): unknown { let groupChanged = false; for (const tool of tools) { - if (isPlainObject(tool) && tool.type === HOSTED_IMAGE_GENERATION_TOOL) { + if ( + hasUsableImageGenAlias + && isPlainObject(tool) + && tool.type === HOSTED_IMAGE_GENERATION_TOOL + ) { groupChanged = true; continue; } diff --git a/structure/04_transports-and-sidecars.md b/structure/04_transports-and-sidecars.md index bee9b81abf..84c89168c9 100644 --- a/structure/04_transports-and-sidecars.md +++ b/structure/04_transports-and-sidecars.md @@ -65,9 +65,10 @@ Responses tool surface. A complete `image_gen` namespace is lowered to safe `image_gen__` function aliases even when no hosted image tool is present, because public Responses runtimes may reserve the namespace itself and reject dotted function names. Native and legacy dotted calls replayed in `body.input` are encoded to the same aliases. When any client -image-gen tool is declared, the adapter also drops hosted `image_generation` and deduplicates aliases -in stable container order. Discovery and normalization span both top-level `body.tools` and Codex -Desktop Responses Lite `input[].type = "additional_tools"` containers. +image-gen declaration is replaced by a usable `image_gen__` alias, the adapter also drops +hosted `image_generation` and deduplicates aliases in stable container order. Empty or malformed +namespaces do not remove the hosted fallback. Discovery and normalization span both top-level +`body.tools` and Codex Desktop Responses Lite `input[].type = "additional_tools"` containers. Client-facing API-key responses perform the inverse mapping: JSON output and SSE function-call items restore `{ namespace: "image_gen", name: "" }` so Codex can dispatch the local diff --git a/tests/openai-responses-passthrough.test.ts b/tests/openai-responses-passthrough.test.ts index 75d6cd26f3..804f1b1678 100644 --- a/tests/openai-responses-passthrough.test.ts +++ b/tests/openai-responses-passthrough.test.ts @@ -885,6 +885,64 @@ describe("OpenAI Responses hosted-tool name conflicts", () => { name: "web", tools: [{ type: "function", name: "run", parameters: {} }], }, + { type: "image_generation" }, + ]); + }); + + test("keyed platform preserves hosted image_generation for replay-only image-gen calls", () => { + const adapter = createResponsesPassthroughAdapter(keyedProvider); + const request = adapter.buildRequest({ + modelId: "gpt-5.6-sol", + context: { messages: [] }, + stream: true, + options: {}, + _rawBody: { + model: "gpt-5.6-sol", + tools: [{ type: "image_generation" }], + input: [{ + type: "function_call", + namespace: "image_gen", + name: "imagegen", + call_id: "call_replay", + arguments: "{}", + }], + }, + }, meta); + const body = JSON.parse(request.body) as { + tools: Array<{ type: string }>; + input: Array<{ name?: string; namespace?: string }>; + }; + + expect(body.tools).toEqual([{ type: "image_generation" }]); + expect(body.input[0]).toMatchObject({ + type: "function_call", + name: "image_gen__imagegen", + call_id: "call_replay", + }); + expect(body.input[0]).not.toHaveProperty("namespace"); + }); + + test("keyed platform preserves hosted image_generation for a bare image_gen function", () => { + const adapter = createResponsesPassthroughAdapter(keyedProvider); + const request = adapter.buildRequest({ + modelId: "gpt-5.6-sol", + context: { messages: [] }, + stream: true, + options: {}, + _rawBody: { + model: "gpt-5.6-sol", + input: [], + tools: [ + { type: "function", name: "image_gen", parameters: {} }, + { type: "image_generation" }, + ], + }, + }, meta); + const body = JSON.parse(request.body) as { tools: Array> }; + + expect(body.tools).toEqual([ + { type: "function", name: "image_gen", parameters: {} }, + { type: "image_generation" }, ]); }); From 21754d7ac61a5337d365c445a22a8fdd6bd828b8 Mon Sep 17 00:00:00 2001 From: 977 Date: Tue, 28 Jul 2026 11:23:04 +0800 Subject: [PATCH 03/10] fix(openai-responses): restore legacy image aliases Build the reverse map from explicitly declared dotted image-gen functions so safe upstream aliases return to Codex with an explicit namespace without guessing unrelated double-underscore names. --- src/server/responses-image-gen-repair.ts | 40 +++++++++++++- src/server/responses/core.ts | 2 +- tests/responses-image-gen-repair.test.ts | 70 ++++++++++++++++++++++++ 3 files changed, 108 insertions(+), 4 deletions(-) diff --git a/src/server/responses-image-gen-repair.ts b/src/server/responses-image-gen-repair.ts index feef1872a3..ce0845b048 100644 --- a/src/server/responses-image-gen-repair.ts +++ b/src/server/responses-image-gen-repair.ts @@ -3,24 +3,58 @@ interface NamespacedTool { name: string; } +const IMAGE_GEN_NAMESPACE = "image_gen"; +const IMAGE_GEN_DOTTED_PREFIX = `${IMAGE_GEN_NAMESPACE}.`; + function isPlainObject(value: unknown): value is Record { return !!value && typeof value === "object" && !Array.isArray(value); } +function declaredToolGroups(body: unknown): unknown[][] { + if (!isPlainObject(body)) return []; + const groups: unknown[][] = []; + if (Array.isArray(body.tools)) groups.push(body.tools); + if (Array.isArray(body.input)) { + for (const item of body.input) { + if (isPlainObject(item) && item.type === "additional_tools" && Array.isArray(item.tools)) { + groups.push(item.tools); + } + } + } + return groups; +} + /** * Restrict the generic bridge map to Codex's standalone image namespace and accept the dotted - * alias emitted by earlier compatibility attempts. Exact map lookups avoid splitting arbitrary - * double-underscore function names that belong to unrelated client tools. + * alias emitted by earlier compatibility attempts. Legacy flat dotted declarations are recovered + * from the raw request because the generic parser does not assign them a namespace. Exact declared + * names avoid splitting arbitrary double-underscore function names that belong to unrelated tools. */ export function imageGenToolCallAliases( toolNsMap: ReadonlyMap, + requestBody?: unknown, ): Map { const aliases = new Map(); for (const [wireName, tool] of toolNsMap) { - if (tool.namespace !== "image_gen") continue; + if (tool.namespace !== IMAGE_GEN_NAMESPACE) continue; aliases.set(wireName, tool); aliases.set(`${tool.namespace}.${tool.name}`, tool); } + for (const group of declaredToolGroups(requestBody)) { + for (const tool of group) { + if ( + !isPlainObject(tool) + || tool.type !== "function" + || typeof tool.name !== "string" + || !tool.name.startsWith(IMAGE_GEN_DOTTED_PREFIX) + || tool.name.length === IMAGE_GEN_DOTTED_PREFIX.length + ) continue; + const localName = tool.name.slice(IMAGE_GEN_DOTTED_PREFIX.length); + const target = { namespace: IMAGE_GEN_NAMESPACE, name: localName }; + aliases.set(`${IMAGE_GEN_NAMESPACE}__${localName}`, target); + aliases.set(tool.name, target); + } + } return aliases; } diff --git a/src/server/responses/core.ts b/src/server/responses/core.ts index 1019caf4a3..b4965fbc67 100644 --- a/src/server/responses/core.ts +++ b/src/server/responses/core.ts @@ -1344,7 +1344,7 @@ export async function handleResponses( if ("passthrough" in adapter && adapter.passthrough && !routedCompaction) { const imageGenCallAliases = route.provider.authMode === "forward" ? new Map() - : imageGenToolCallAliases(buildToolBridgeMaps(parsed).toolNsMap); + : imageGenToolCallAliases(buildToolBridgeMaps(parsed).toolNsMap, parsed._rawBody); // Local continuation cache for the ChatGPT passthrough. Codex WS turns chain with // previous_response_id, ocx converts them to internal HTTP requests, and the ChatGPT Codex // REST backend rejects the parameter — the adapter strips it in forward mode, so the ONLY diff --git a/tests/responses-image-gen-repair.test.ts b/tests/responses-image-gen-repair.test.ts index f2501d88f2..881d4f8137 100644 --- a/tests/responses-image-gen-repair.test.ts +++ b/tests/responses-image-gen-repair.test.ts @@ -34,6 +34,20 @@ describe("Responses image-gen call restoration", () => { ]); }); + test("recovers declared dotted aliases without guessing double-underscore function names", () => { + const recovered = imageGenToolCallAliases(new Map(), { + tools: [ + { type: "function", name: "image_gen.imagegen", parameters: {} }, + { type: "function", name: "image_gen__unrelated", parameters: {} }, + ], + }); + + expect([...recovered.entries()]).toEqual([ + ["image_gen__imagegen", { namespace: "image_gen", name: "imagegen" }], + ["image_gen.imagegen", { namespace: "image_gen", name: "imagegen" }], + ]); + }); + test("restores non-streaming function calls without changing unrelated tools", () => { const upstream = JSON.stringify({ id: "resp_1", @@ -188,6 +202,62 @@ describe("Responses image-gen call restoration", () => { } }); + test("handleResponses round-trips a legacy dotted declaration", async () => { + const savedFetch = globalThis.fetch; + let outboundBody: Record | undefined; + globalThis.fetch = (async (_input, init) => { + outboundBody = JSON.parse(String(init?.body)) as Record; + return Response.json({ + status: "completed", + output: [{ + type: "function_call", + id: "fc_legacy", + call_id: "call_legacy", + name: "image_gen__imagegen", + arguments: "{}", + }], + }); + }) as typeof fetch; + const config = { + port: 0, + defaultProvider: "fixture", + providers: { + fixture: { + adapter: "openai-responses", + baseUrl: "https://fixture.test/v1", + authMode: "key", + apiKey: "fixture-key", + }, + }, + } as OcxConfig; + + try { + const response = await handleResponses(new Request("http://localhost/v1/responses", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + model: "fixture/gpt-5.6-sol", + stream: false, + input: [{ role: "user", content: [{ type: "input_text", text: "generate an image" }] }], + tools: [{ type: "function", name: "image_gen.imagegen", parameters: {} }], + }), + }), config, { model: "", provider: "" }); + const clientBody = await response.json() as { + output: Array<{ namespace?: string; name?: string; call_id?: string }>; + }; + const outboundTools = outboundBody?.tools as Array<{ name?: string }> | undefined; + + expect(outboundTools?.[0]?.name).toBe("image_gen__imagegen"); + expect(clientBody.output[0]).toMatchObject({ + namespace: "image_gen", + name: "imagegen", + call_id: "call_legacy", + }); + } finally { + globalThis.fetch = savedFetch; + } + }); + test("handleResponses restores client-facing SSE calls on the Windows rewrite path", async () => { const savedFetch = globalThis.fetch; let outboundBody: Record | undefined; From fed53289565edd6bf6e70f6de97d68f70b9d5fba Mon Sep 17 00:00:00 2001 From: 977 Date: Tue, 28 Jul 2026 11:23:51 +0800 Subject: [PATCH 04/10] fix(responses): preserve eager relay invariant Keep the win32 eager branch free of client rewrite wrappers and align the source invariant and transport documentation with the no-client-rewrite gate. --- src/server/index.ts | 1 + src/server/responses/core.ts | 5 +++-- structure/04_transports-and-sidecars.md | 16 +++++++++------- tests/passthrough-abort.test.ts | 1 + 4 files changed, 14 insertions(+), 9 deletions(-) diff --git a/src/server/index.ts b/src/server/index.ts index 8de20175f0..e12dc32c3a 100644 --- a/src/server/index.ts +++ b/src/server/index.ts @@ -230,6 +230,7 @@ function attachLiveSidebandUpstream(ws: ServerWebSocket): void { // #314 gated shape (win32-no-client-rewrite only; default OFF on the bundled known-bad runtime): // decideEagerRelay(config.streamMode ?? "auto") // relaySseEagerBounded(upstreamResponse.body, turnAc, +// new Response(eagerBody, // Default shape (tee + background inspection): // upstreamResponse.body.tee() // const repairedBody = hasResponsesItemIdRepair(repairConfig) diff --git a/src/server/responses/core.ts b/src/server/responses/core.ts index b4965fbc67..f38b0e057d 100644 --- a/src/server/responses/core.ts +++ b/src/server/responses/core.ts @@ -1602,9 +1602,10 @@ export async function handleResponses( onClientCancel: () => options.onNativePassthroughCancel?.(), onDone: () => unregisterTurn(turnAc), }); - const clientBody = relaySseWithImageGenCallRestore(eagerBody, imageGenCallAliases); + // The eager branch is reachable only through winNoClientRewrite, so it must stay a pure + // native relay without an image-gen or item-id JS pull wrapper on win32 (Bun#32111). if (!headers.has("content-type")) headers.set("content-type", "text/event-stream"); - return markNativePassthroughSseResponse(new Response(clientBody, { + return markNativePassthroughSseResponse(new Response(eagerBody, { status: upstreamResponse.status, headers, })); diff --git a/structure/04_transports-and-sidecars.md b/structure/04_transports-and-sidecars.md index 84c89168c9..f582923920 100644 --- a/structure/04_transports-and-sidecars.md +++ b/structure/04_transports-and-sidecars.md @@ -23,19 +23,21 @@ Native passthrough SSE has TWO shapes, selected per request in `src/server/responses/core.ts`: - **Default: tee + background inspection.** `upstreamResponse.body.tee()` sends - branch[0] to the client (pure native relay on win32 without item-id repair — - the Bun#32111 crash workaround; a JS relay elsewhere) while branch[1] is + branch[0] to the client (pure native relay on win32 without any client-facing + rewrite — the Bun#32111 crash workaround; a JS relay elsewhere) while branch[1] is drained eagerly by `consumeForInspection`/`consumeForResponseLogMetadata` for terminal-outcome recording, quota, the passthrough continuation cache, and request logs. This is the only shape on the bundled Bun 1.3.14. -- **Gated: eager bounded relay** (`src/server/relay-eager.ts`). win32-no-repair - only, armed by `decideEagerRelay(config.streamMode)` from +- **Gated: eager bounded relay** (`src/server/relay-eager.ts`). win32 with no + client-facing rewrite only (neither image-gen aliases nor item-id repair), armed by + `decideEagerRelay(config.streamMode)` from `src/lib/bun-stream-caps.ts` — default-on only for runtimes proven to carry the Bun#32111 fix (`MIN_FIXED_BUN_VERSION`, null until a bundle bump), or by explicit `streamMode: "eager-relay"` opt-in. One eager reader + byte-bounded - client queue + post-cancel bounded discard-drain replaces the tee, preserving - the full inspection side-effect set (shared `createSseInspector` factory in - `relay.ts`) including the #44 late-terminal semantics. + client queue + post-cancel bounded discard-drain replaces the tee and goes + directly to the response without a JS rewrite wrapper, preserving the full + inspection side-effect set (shared `createSseInspector` factory in `relay.ts`) + including the #44 late-terminal semantics. The two-shape contract is mirror-commented in `src/server/index.ts` and source-invariant-tested by `tests/passthrough-abort.test.ts`; keep both in diff --git a/tests/passthrough-abort.test.ts b/tests/passthrough-abort.test.ts index 0e3bf56489..3f2fc46996 100644 --- a/tests/passthrough-abort.test.ts +++ b/tests/passthrough-abort.test.ts @@ -46,6 +46,7 @@ describe("passthrough relayWithAbort (RC2, passthrough path)", () => { // wrapper on the default path (Bun#32111 segfault). expect(sseBranch).toContain("const repairConfig = route.provider.responsesItemIdRepair;"); expect(sseBranch).toContain("const needsClientRewrite = imageGenCallAliases.size > 0"); + expect(sseBranch).toContain("new Response(eagerBody"); expect(sseBranch).toContain("const repairedBody = hasResponsesItemIdRepair(repairConfig)"); expect(sseBranch).toContain('process.platform === "win32"'); expect(sseBranch).toContain("&& !needsClientRewrite"); From f8eb638159fe915af4c704502c9b32f5b975a5bc Mon Sep 17 00:00:00 2001 From: 977 Date: Tue, 28 Jul 2026 11:24:19 +0800 Subject: [PATCH 05/10] test(responses): name SSE rewrite coverage accurately Describe the integration test as the platform-neutral passthrough rewrite path while the separate source invariant continues to guard the win32 native relay branch. --- tests/responses-image-gen-repair.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/responses-image-gen-repair.test.ts b/tests/responses-image-gen-repair.test.ts index 881d4f8137..2c06599ddd 100644 --- a/tests/responses-image-gen-repair.test.ts +++ b/tests/responses-image-gen-repair.test.ts @@ -258,7 +258,7 @@ describe("Responses image-gen call restoration", () => { } }); - test("handleResponses restores client-facing SSE calls on the Windows rewrite path", async () => { + test("handleResponses restores client-facing SSE calls on the passthrough rewrite path", async () => { const savedFetch = globalThis.fetch; let outboundBody: Record | undefined; const item = { From d45858603e827e0f7a349f0d324fcdebdf6e9424 Mon Sep 17 00:00:00 2001 From: 977 Date: Tue, 28 Jul 2026 11:25:00 +0800 Subject: [PATCH 06/10] docs(codex): describe generic image aliases Document the generic image_gen__ contract and clarify that hosted image generation is removed only after a usable client alias replaces it. --- .../src/content/docs/guides/codex-integration.md | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/docs-site/src/content/docs/guides/codex-integration.md b/docs-site/src/content/docs/guides/codex-integration.md index a97fc03218..7c70ba1e6a 100644 --- a/docs-site/src/content/docs/guides/codex-integration.md +++ b/docs-site/src/content/docs/guides/codex-integration.md @@ -65,11 +65,12 @@ Standalone `/images/generations` calls never enter that bridge. The tool declaration still travels with the model's Responses request. For API-key Responses providers, opencodex lowers Codex's private `image_gen` namespace to an upstream-safe -`image_gen__imagegen` alias and removes a duplicate hosted `image_generation` declaration. It maps -the returned function call back to the explicit `image_gen` namespace before Codex sees it, and -encodes the native call again when later history is replayed upstream. This keeps client-side image -generation callable on public-compatible upstreams that reserve the namespace or reject dotted -function names. ChatGPT forward mode remains untouched and keeps its native Responses Lite shape. +`image_gen__` alias (for example `image_gen__imagegen`). When that usable alias replaces +the client declaration, opencodex removes a duplicate hosted `image_generation` declaration. It maps +the function call to the explicit `image_gen` namespace before Codex sees it, and encodes the native +call again when later history is replayed upstream. This keeps client-side image generation callable +on public-compatible upstreams that reserve the namespace or reject dotted function names. ChatGPT +forward mode remains untouched and keeps its native Responses Lite shape. For an OpenAI-compatible custom gateway, configure a dedicated provider and select it only for standalone Images requests: From 06b35ecc793c2c2f08bd2ca7c3ad0290fae3c734 Mon Sep 17 00:00:00 2001 From: 977 Date: Tue, 28 Jul 2026 11:28:38 +0800 Subject: [PATCH 07/10] docs(i18n): sync image namespace behavior Update the Japanese, Korean, Russian, and Simplified Chinese Codex integration guides with the API-key alias, response restoration, replay, and forward-mode compatibility contract. --- .../src/content/docs/ja/guides/codex-integration.md | 9 +++++++++ .../src/content/docs/ko/guides/codex-integration.md | 9 +++++++++ .../src/content/docs/ru/guides/codex-integration.md | 11 +++++++++++ .../content/docs/zh-cn/guides/codex-integration.md | 8 ++++++++ 4 files changed, 37 insertions(+) diff --git a/docs-site/src/content/docs/ja/guides/codex-integration.md b/docs-site/src/content/docs/ja/guides/codex-integration.md index 4d1a7da499..4530561a64 100644 --- a/docs-site/src/content/docs/ja/guides/codex-integration.md +++ b/docs-site/src/content/docs/ja/guides/codex-integration.md @@ -57,6 +57,15 @@ ChatGPT bearer 認証で直接 POST します。注入された `base_url` が o `codex features disable image_generation`(`config.toml` の `[features] image_generation = false`)を 使ってください。 +ツール宣言はモデルへの Responses リクエストにも引き続き含まれます。API キー方式の Responses +プロバイダーでは、opencodex は Codex のプライベートな `image_gen` 名前空間を、上流で安全な +`image_gen__` エイリアス(例: `image_gen__imagegen`)に変換します。その利用可能な +エイリアスがクライアント宣言を置き換える場合にのみ、重複する hosted `image_generation` 宣言を +削除します。関数呼び出しは Codex に届く前に明示的な `image_gen` 名前空間へ戻され、後続の履歴を +上流へ再送するときは再びエンコードされます。これにより、名前空間を予約している、またはドットを +含む関数名を拒否する OpenAI 互換の上流でも、クライアント側の画像生成を呼び出せます。ChatGPT +forward モードは変更されず、ネイティブな Responses Lite 形式を維持します。 + `hostname` がループバックアドレスでない場合、Codex が自動生成した API 認証ヘッダーを送る必要があります。このとき専用 プロバイダーを注入します。 diff --git a/docs-site/src/content/docs/ko/guides/codex-integration.md b/docs-site/src/content/docs/ko/guides/codex-integration.md index 63182b91a2..3dd928e49d 100644 --- a/docs-site/src/content/docs/ko/guides/codex-integration.md +++ b/docs-site/src/content/docs/ko/guides/codex-integration.md @@ -56,6 +56,15 @@ ChatGPT bearer 인증으로 직접 POST합니다. 주입된 `base_url`이 openco `codex features disable image_generation`(`config.toml`의 `[features] image_generation = false`)을 사용하세요. +도구 선언은 모델의 Responses 요청에도 계속 포함됩니다. API key 방식 Responses 프로바이더에서는 +opencodex가 Codex의 비공개 `image_gen` namespace를 업스트림에서 안전한 +`image_gen__` alias(예: `image_gen__imagegen`)로 변환합니다. 이 사용 가능한 alias가 +클라이언트 선언을 대체할 때만 중복된 hosted `image_generation` 선언을 제거합니다. 함수 호출은 +Codex에 도달하기 전에 명시적인 `image_gen` namespace로 복원되고, 이후 기록을 업스트림으로 +replay할 때 다시 인코딩됩니다. 따라서 namespace를 예약하거나 점이 포함된 함수 이름을 거부하는 +OpenAI 호환 업스트림에서도 클라이언트 측 이미지 생성을 호출할 수 있습니다. ChatGPT forward +모드는 변경되지 않으며 네이티브 Responses Lite 형식을 유지합니다. + `hostname`이 loopback 주소가 아니면 Codex가 자동 생성된 API 인증 헤더를 보내야 합니다. 이때는 전용 프로바이더를 주입합니다. diff --git a/docs-site/src/content/docs/ru/guides/codex-integration.md b/docs-site/src/content/docs/ru/guides/codex-integration.md index e92939c77b..e77970afd4 100644 --- a/docs-site/src/content/docs/ru/guides/codex-integration.md +++ b/docs-site/src/content/docs/ru/guides/codex-integration.md @@ -64,6 +64,17 @@ fast_mode = true командой `codex features disable image_generation` (`[features] image_generation = false` в `config.toml`). +Объявление инструмента по-прежнему передаётся в Responses-запросе к модели. Для Responses- +провайдеров с API-ключом opencodex преобразует приватное пространство имён Codex `image_gen` +в безопасный для вышестоящей стороны псевдоним `image_gen__` (например, +`image_gen__imagegen`). Дублирующее объявление hosted-инструмента `image_generation` удаляется +только тогда, когда такой рабочий псевдоним заменяет клиентское объявление. Перед передачей в +Codex вызов функции восстанавливается с явным пространством имён `image_gen`, а при последующем +воспроизведении истории во вышестоящий сервис снова кодируется. Это сохраняет клиентскую +генерацию изображений у OpenAI-совместимых провайдеров, которые резервируют это пространство +имён или отклоняют имена функций с точками. Режим ChatGPT forward не изменяется и сохраняет +свой нативный формат Responses Lite. + Если `hostname` не является loopback-адресом, Codex должен отправлять сгенерированный заголовок API-аутентификации. Поэтому инжектор в этом случае использует выделенного провайдера: diff --git a/docs-site/src/content/docs/zh-cn/guides/codex-integration.md b/docs-site/src/content/docs/zh-cn/guides/codex-integration.md index ba5b31951d..53fceb9c97 100644 --- a/docs-site/src/content/docs/zh-cn/guides/codex-integration.md +++ b/docs-site/src/content/docs/zh-cn/guides/codex-integration.md @@ -54,6 +54,14 @@ OpenAI 上游: `codex features disable image_generation`(即 `config.toml` 的 `[features] image_generation = false`)。 +工具声明仍会随模型的 Responses 请求一同发送。对于 API key 方式的 Responses 提供商, +opencodex 会把 Codex 私有的 `image_gen` namespace 转换为上游安全的 +`image_gen__` alias(例如 `image_gen__imagegen`)。只有当这个可用 alias 替代了 +客户端声明时,才会移除重复的 hosted `image_generation` 声明。函数调用会在到达 Codex 前恢复为 +显式的 `image_gen` namespace,后续将历史记录重放到上游时再重新编码。因此,即使 OpenAI 兼容 +上游保留了该 namespace,或拒绝包含点号的函数名,客户端图像生成仍可正常调用。ChatGPT forward +模式保持不变,并继续使用其原生 Responses Lite 格式。 + 如果 `hostname` 不是 loopback 地址,Codex 必须发送自动生成的 API 认证请求头。此时注入器会改用 专用提供商: From ad020ca32988a7df18f0093e29472ee3583997b2 Mon Sep 17 00:00:00 2001 From: 977 Date: Tue, 28 Jul 2026 11:29:36 +0800 Subject: [PATCH 08/10] docs(code): clarify image alias helpers Document the request-normalization and response-restoration helper contracts without changing the deferred SSE relay structure. --- src/adapters/openai-responses.ts | 9 +++++++++ src/server/responses-image-gen-repair.ts | 5 +++++ 2 files changed, 14 insertions(+) diff --git a/src/adapters/openai-responses.ts b/src/adapters/openai-responses.ts index a9befafc26..8764e3f24c 100644 --- a/src/adapters/openai-responses.ts +++ b/src/adapters/openai-responses.ts @@ -505,22 +505,26 @@ const HOSTED_IMAGE_GENERATION_TOOL = "image_generation"; const IMAGE_GEN_DOTTED_PREFIX = `${IMAGE_GEN_NAMESPACE}.`; const IMAGE_GEN_WIRE_PREFIX = `${IMAGE_GEN_NAMESPACE}__`; +/** Remove a supported client prefix before constructing the canonical image-gen wire alias. */ function imageGenLocalName(name: string): string { if (name.startsWith(IMAGE_GEN_DOTTED_PREFIX)) return name.slice(IMAGE_GEN_DOTTED_PREFIX.length); if (name.startsWith(IMAGE_GEN_WIRE_PREFIX)) return name.slice(IMAGE_GEN_WIRE_PREFIX.length); return name; } +/** Build the flat public-Responses name used only on the upstream wire. */ function imageGenWireName(name: string): string { return namespacedToolName(IMAGE_GEN_NAMESPACE, imageGenLocalName(name)); } +/** Match client image-gen declarations across namespace, legacy dotted, and canonical wire forms. */ function isImageGenClientName(name: string): boolean { return name === IMAGE_GEN_NAMESPACE || name.startsWith(IMAGE_GEN_DOTTED_PREFIX) || name.startsWith(IMAGE_GEN_WIRE_PREFIX); } +/** Identify declarations that should activate image-gen request normalization. */ function declaresImageGenClientTool(tool: unknown): boolean { if (!isPlainObject(tool) || typeof tool.name !== "string") return false; if (tool.type === "namespace") return tool.name === IMAGE_GEN_NAMESPACE; @@ -563,6 +567,7 @@ function flattenImageGenNamespace(tool: unknown): Record[] | un }); } +/** Convert a legacy dotted function declaration while preserving all other function metadata. */ function normalizeFlatImageGenFunction(tool: unknown): unknown { if ( !isPlainObject(tool) @@ -573,6 +578,7 @@ function normalizeFlatImageGenFunction(tool: unknown): unknown { return { ...tool, name: imageGenWireName(tool.name) }; } +/** Return the image-gen function name used for stable cross-container deduplication. */ function imageGenFunctionName(tool: unknown): string | undefined { if (!isPlainObject(tool) || tool.type !== "function" || typeof tool.name !== "string") { return undefined; @@ -580,6 +586,7 @@ function imageGenFunctionName(tool: unknown): string | undefined { return isImageGenClientName(tool.name) ? tool.name : undefined; } +/** True only when a declaration can yield a callable upstream-safe image-gen function alias. */ function declaresUsableImageGenAlias(tool: unknown): boolean { if (flattenImageGenNamespace(tool)) return true; if (!isPlainObject(tool) || tool.type !== "function" || typeof tool.name !== "string") { @@ -592,6 +599,7 @@ function declaresUsableImageGenAlias(tool: unknown): boolean { && tool.name.length > IMAGE_GEN_WIRE_PREFIX.length; } +/** Identify replayed image-gen calls that require upstream wire encoding. */ function declaresImageGenFunctionCall(item: unknown): boolean { if (!isPlainObject(item) || item.type !== "function_call" || typeof item.name !== "string") { return false; @@ -599,6 +607,7 @@ function declaresImageGenFunctionCall(item: unknown): boolean { return item.namespace === IMAGE_GEN_NAMESPACE || isImageGenClientName(item.name); } +/** Encode native or legacy replay calls to the same flat name used by tool declarations. */ function normalizeImageGenFunctionCall(item: unknown): unknown { if (!declaresImageGenFunctionCall(item) || !isPlainObject(item) || typeof item.name !== "string") { return item; diff --git a/src/server/responses-image-gen-repair.ts b/src/server/responses-image-gen-repair.ts index ce0845b048..d55aaeb6fe 100644 --- a/src/server/responses-image-gen-repair.ts +++ b/src/server/responses-image-gen-repair.ts @@ -10,6 +10,7 @@ function isPlainObject(value: unknown): value is Record { return !!value && typeof value === "object" && !Array.isArray(value); } +/** Collect top-level and Responses Lite tool containers without altering their order. */ function declaredToolGroups(body: unknown): unknown[][] { if (!isPlainObject(body)) return []; const groups: unknown[][] = []; @@ -58,6 +59,7 @@ export function imageGenToolCallAliases( return aliases; } +/** Recursively restore only exact image-gen function-call aliases in a parsed Responses payload. */ function restoreImageGenCalls( value: unknown, aliases: ReadonlyMap, @@ -107,6 +109,7 @@ export function restoreImageGenCallsInJson( return restored.changed ? JSON.stringify(restored.value) : text; } +/** Split one complete SSE event block while retaining its original blank-line delimiter. */ function nextSseBlock(buffer: string): { block: string; delimiter: string; rest: string } | null { const match = buffer.match(/\r?\n\r?\n/); if (!match || match.index === undefined) return null; @@ -117,6 +120,7 @@ function nextSseBlock(buffer: string): { block: string; delimiter: string; rest: }; } +/** Join all data lines from one SSE event according to the event-stream field rules. */ function sseDataPayload(block: string): string | null { const data: string[] = []; for (const line of block.split(/\r?\n/)) { @@ -127,6 +131,7 @@ function sseDataPayload(block: string): string | null { return data.length > 0 ? data.join("\n") : null; } +/** Replace an SSE event's data field while preserving non-data fields and newline style. */ function replaceSseDataPayload(block: string, payload: string): string { const newline = block.includes("\r\n") ? "\r\n" : "\n"; const lines = block.split(/\r?\n/); From 0eb79aea6893ff7668f1c5ab16aea131e85b0706 Mon Sep 17 00:00:00 2001 From: 977 Date: Tue, 28 Jul 2026 12:22:57 +0800 Subject: [PATCH 09/10] fix(openai-responses): normalize image tool choices --- src/adapters/openai-responses.ts | 66 ++++++++++++++++++++ tests/openai-responses-passthrough.test.ts | 72 +++++++++++++++++++++- 2 files changed, 137 insertions(+), 1 deletion(-) diff --git a/src/adapters/openai-responses.ts b/src/adapters/openai-responses.ts index 8764e3f24c..c5a1d1d632 100644 --- a/src/adapters/openai-responses.ts +++ b/src/adapters/openai-responses.ts @@ -599,6 +599,67 @@ function declaresUsableImageGenAlias(tool: unknown): boolean { && tool.name.length > IMAGE_GEN_WIRE_PREFIX.length; } +/** Collect client tool-choice names and the exact upstream aliases declared for them. */ +function imageGenToolChoiceAliases(toolGroups: unknown[][]): Map { + const aliases = new Map(); + + for (const group of toolGroups) { + for (const tool of group) { + const flattened = flattenImageGenNamespace(tool); + if (flattened) { + for (const candidate of flattened) { + const wireName = candidate.name as string; + aliases.set(`${IMAGE_GEN_DOTTED_PREFIX}${imageGenLocalName(wireName)}`, wireName); + aliases.set(wireName, wireName); + } + continue; + } + if (!isPlainObject(tool) || tool.type !== "function" || typeof tool.name !== "string") { + continue; + } + if ( + tool.name.startsWith(IMAGE_GEN_DOTTED_PREFIX) + && tool.name.length > IMAGE_GEN_DOTTED_PREFIX.length + ) { + aliases.set(tool.name, imageGenWireName(tool.name)); + } else if ( + tool.name.startsWith(IMAGE_GEN_WIRE_PREFIX) + && tool.name.length > IMAGE_GEN_WIRE_PREFIX.length + ) { + aliases.set(tool.name, tool.name); + } + } + } + + return aliases; +} + +/** Rewrite function selectors only when their corresponding declaration receives a wire alias. */ +function normalizeImageGenToolChoice( + toolChoice: unknown, + aliases: ReadonlyMap, +): unknown { + if (!isPlainObject(toolChoice)) return toolChoice; + + if (toolChoice.type === "function" && typeof toolChoice.name === "string") { + const alias = aliases.get(toolChoice.name); + return alias && alias !== toolChoice.name ? { ...toolChoice, name: alias } : toolChoice; + } + + if (toolChoice.type !== "allowed_tools" || !Array.isArray(toolChoice.tools)) return toolChoice; + let changed = false; + const tools = toolChoice.tools.map(tool => { + if (!isPlainObject(tool) || tool.type !== "function" || typeof tool.name !== "string") { + return tool; + } + const alias = aliases.get(tool.name); + if (!alias || alias === tool.name) return tool; + changed = true; + return { ...tool, name: alias }; + }); + return changed ? { ...toolChoice, tools } : toolChoice; +} + /** Identify replayed image-gen calls that require upstream wire encoding. */ function declaresImageGenFunctionCall(item: unknown): boolean { if (!isPlainObject(item) || item.type !== "function_call" || typeof item.name !== "string") { @@ -654,6 +715,7 @@ function normalizeImageGenClientTools(body: unknown): unknown { || (Array.isArray(body.input) && body.input.some(declaresImageGenFunctionCall)); if (!hasImageGenClientTool) return body; const hasUsableImageGenAlias = toolGroups.some(group => group.some(declaresUsableImageGenAlias)); + const toolChoiceAliases = imageGenToolChoiceAliases(toolGroups); const seenFunctionNames = new Set(); const normalizeGroup = (tools: unknown[]): unknown[] => { @@ -717,11 +779,15 @@ function normalizeImageGenClientTools(body: unknown): unknown { } } + const toolChoice = normalizeImageGenToolChoice(body.tool_choice, toolChoiceAliases); + changed ||= toolChoice !== body.tool_choice; + if (!changed) return body; return { ...body, ...(Array.isArray(body.tools) ? { tools } : {}), ...(Array.isArray(body.input) ? { input } : {}), + ...(Object.prototype.hasOwnProperty.call(body, "tool_choice") ? { tool_choice: toolChoice } : {}), }; } diff --git a/tests/openai-responses-passthrough.test.ts b/tests/openai-responses-passthrough.test.ts index 804f1b1678..31e5a014d5 100644 --- a/tests/openai-responses-passthrough.test.ts +++ b/tests/openai-responses-passthrough.test.ts @@ -693,6 +693,68 @@ describe("OpenAI Responses hosted-tool name conflicts", () => { }); }); + test("keyed platform rewrites a forced image-gen tool choice with its declared alias", () => { + const adapter = createResponsesPassthroughAdapter(keyedProvider); + const request = adapter.buildRequest({ + modelId: "gpt-5.6-sol", + context: { messages: [] }, + stream: true, + options: {}, + _rawBody: { + model: "gpt-5.6-sol", + input: [], + tools: [{ + type: "namespace", + name: "image_gen", + tools: [{ type: "function", name: "imagegen", parameters: {} }], + }], + tool_choice: { type: "function", name: "image_gen.imagegen" }, + }, + }, meta); + const body = JSON.parse(request.body) as { + tool_choice: { type: string; name: string }; + }; + + expect(body.tool_choice).toEqual({ type: "function", name: "image_gen__imagegen" }); + }); + + test("keyed platform rewrites image-gen entries in an allowed-tools choice", () => { + const adapter = createResponsesPassthroughAdapter(keyedProvider); + const request = adapter.buildRequest({ + modelId: "gpt-5.6-sol", + context: { messages: [] }, + stream: true, + options: {}, + _rawBody: { + model: "gpt-5.6-sol", + input: [{ + type: "additional_tools", + tools: [{ type: "function", name: "image_gen.imagegen", parameters: {} }], + }], + tool_choice: { + type: "allowed_tools", + mode: "required", + tools: [ + { type: "function", name: "image_gen.imagegen" }, + { type: "function", name: "exec_command" }, + ], + }, + }, + }, meta); + const body = JSON.parse(request.body) as { + tool_choice: { type: string; mode: string; tools: Array<{ type: string; name: string }> }; + }; + + expect(body.tool_choice).toEqual({ + type: "allowed_tools", + mode: "required", + tools: [ + { type: "function", name: "image_gen__imagegen" }, + { type: "function", name: "exec_command" }, + ], + }); + }); + test("keyed responses-lite flattens a nested namespace without requiring a hosted tool", () => { const adapter = createResponsesPassthroughAdapter(keyedProvider); const request = adapter.buildRequest({ @@ -874,9 +936,13 @@ describe("OpenAI Responses hosted-tool name conflicts", () => { }, { type: "image_generation" }, ], + tool_choice: { type: "function", name: "image_gen.imagegen" }, }, }, meta); - const body = JSON.parse(request.body) as { tools: Array> }; + const body = JSON.parse(request.body) as { + tools: Array>; + tool_choice: { type: string; name: string }; + }; expect(body.tools).toEqual([ { type: "namespace", name: "image_gen", tools: [] }, @@ -887,6 +953,7 @@ describe("OpenAI Responses hosted-tool name conflicts", () => { }, { type: "image_generation" }, ]); + expect(body.tool_choice).toEqual({ type: "function", name: "image_gen.imagegen" }); }); test("keyed platform preserves hosted image_generation for replay-only image-gen calls", () => { @@ -987,10 +1054,12 @@ describe("OpenAI Responses hosted-tool name conflicts", () => { }, { type: "image_generation" }, ], + tool_choice: { type: "function", name: "image_gen.imagegen" }, }, }, meta); const body = JSON.parse(request.body) as { tools: Array<{ type: string; name?: string; tools?: Array<{ name?: string }> }>; + tool_choice: { type: string; name: string }; }; expect(body.tools).toHaveLength(2); @@ -1000,6 +1069,7 @@ describe("OpenAI Responses hosted-tool name conflicts", () => { && t.name === "image_gen" && t.tools?.some(inner => inner.name === "imagegen") )).toBe(true); + expect(body.tool_choice).toEqual({ type: "function", name: "image_gen.imagegen" }); }); }); From 74fee9c9bde96e50411271869fdbd8aedae57430 Mon Sep 17 00:00:00 2001 From: 977 Date: Tue, 28 Jul 2026 12:24:13 +0800 Subject: [PATCH 10/10] refactor(responses): share tool-group collection --- src/adapters/openai-responses.ts | 13 ++-------- src/responses/tool-groups.ts | 19 +++++++++++++++ src/server/responses-image-gen-repair.ts | 19 +++------------ tests/responses-tool-groups.test.ts | 30 ++++++++++++++++++++++++ 4 files changed, 54 insertions(+), 27 deletions(-) create mode 100644 src/responses/tool-groups.ts create mode 100644 tests/responses-tool-groups.test.ts diff --git a/src/adapters/openai-responses.ts b/src/adapters/openai-responses.ts index c5a1d1d632..b03561c2d5 100644 --- a/src/adapters/openai-responses.ts +++ b/src/adapters/openai-responses.ts @@ -3,6 +3,7 @@ import type { IncomingMeta, ProviderAdapter } from "./base"; import { namespacedToolName, type AdapterEvent, type OcxParsedRequest, type OcxProviderConfig, type OcxUsage } from "../types"; import { catalogModelSupportsReasoningSummaries } from "../codex/catalog"; import { COMPACT_PROMPT, decodeCompactionSummary, SUMMARY_PREFIX } from "../responses/compaction"; +import { collectResponsesToolGroups } from "../responses/tool-groups"; import { decodeServerSentEvents } from "../lib/sse-decoder"; import { isCanonicalOpenAiForwardProvider } from "../providers/openai-tiers"; import { OCX_REASONING_PREFIX } from "../responses/reasoning-envelope"; @@ -700,17 +701,7 @@ function normalizeImageGenFunctionCall(item: unknown): unknown { function normalizeImageGenClientTools(body: unknown): unknown { if (!isPlainObject(body)) return body; - // Collect every tool container in the request. Traditional HTTP/SSE requests use top-level - // `tools`, while Responses Lite may carry the same declarations in `additional_tools` entries. - const toolGroups: unknown[][] = []; - if (Array.isArray(body.tools)) toolGroups.push(body.tools); - if (Array.isArray(body.input)) { - for (const item of body.input) { - if (isPlainObject(item) && item.type === "additional_tools" && Array.isArray(item.tools)) { - toolGroups.push(item.tools); - } - } - } + const toolGroups = collectResponsesToolGroups(body); const hasImageGenClientTool = toolGroups.some(group => group.some(declaresImageGenClientTool)) || (Array.isArray(body.input) && body.input.some(declaresImageGenFunctionCall)); if (!hasImageGenClientTool) return body; diff --git a/src/responses/tool-groups.ts b/src/responses/tool-groups.ts new file mode 100644 index 0000000000..4a4af73fa3 --- /dev/null +++ b/src/responses/tool-groups.ts @@ -0,0 +1,19 @@ +function isPlainObject(value: unknown): value is Record { + return !!value && typeof value === "object" && !Array.isArray(value); +} + +/** Collect top-level and Responses Lite tool containers without altering their order. */ +export function collectResponsesToolGroups(body: unknown): unknown[][] { + if (!isPlainObject(body)) return []; + + const groups: unknown[][] = []; + if (Array.isArray(body.tools)) groups.push(body.tools); + if (!Array.isArray(body.input)) return groups; + + for (const item of body.input) { + if (isPlainObject(item) && item.type === "additional_tools" && Array.isArray(item.tools)) { + groups.push(item.tools); + } + } + return groups; +} diff --git a/src/server/responses-image-gen-repair.ts b/src/server/responses-image-gen-repair.ts index d55aaeb6fe..5ebee945eb 100644 --- a/src/server/responses-image-gen-repair.ts +++ b/src/server/responses-image-gen-repair.ts @@ -1,3 +1,5 @@ +import { collectResponsesToolGroups } from "../responses/tool-groups"; + interface NamespacedTool { namespace: string; name: string; @@ -10,21 +12,6 @@ function isPlainObject(value: unknown): value is Record { return !!value && typeof value === "object" && !Array.isArray(value); } -/** Collect top-level and Responses Lite tool containers without altering their order. */ -function declaredToolGroups(body: unknown): unknown[][] { - if (!isPlainObject(body)) return []; - const groups: unknown[][] = []; - if (Array.isArray(body.tools)) groups.push(body.tools); - if (Array.isArray(body.input)) { - for (const item of body.input) { - if (isPlainObject(item) && item.type === "additional_tools" && Array.isArray(item.tools)) { - groups.push(item.tools); - } - } - } - return groups; -} - /** * Restrict the generic bridge map to Codex's standalone image namespace and accept the dotted * alias emitted by earlier compatibility attempts. Legacy flat dotted declarations are recovered @@ -41,7 +28,7 @@ export function imageGenToolCallAliases( aliases.set(wireName, tool); aliases.set(`${tool.namespace}.${tool.name}`, tool); } - for (const group of declaredToolGroups(requestBody)) { + for (const group of collectResponsesToolGroups(requestBody)) { for (const tool of group) { if ( !isPlainObject(tool) diff --git a/tests/responses-tool-groups.test.ts b/tests/responses-tool-groups.test.ts new file mode 100644 index 0000000000..a7a88e4db7 --- /dev/null +++ b/tests/responses-tool-groups.test.ts @@ -0,0 +1,30 @@ +import { describe, expect, test } from "bun:test"; +import { collectResponsesToolGroups } from "../src/responses/tool-groups"; + +describe("Responses tool-group collection", () => { + test("collects top-level and Responses Lite tools in wire order", () => { + const topLevel = [{ type: "web_search" }]; + const firstNested = [{ type: "function", name: "image_gen.imagegen" }]; + const secondNested = [{ type: "function", name: "exec_command" }]; + const groups = collectResponsesToolGroups({ + tools: topLevel, + input: [ + { type: "message", role: "user", content: "hello" }, + { type: "additional_tools", tools: firstNested }, + { type: "additional_tools", tools: "invalid" }, + { type: "additional_tools", tools: secondNested }, + ], + }); + + expect(groups).toHaveLength(3); + expect(groups[0]).toBe(topLevel); + expect(groups[1]).toBe(firstNested); + expect(groups[2]).toBe(secondNested); + }); + + test("ignores malformed request containers", () => { + expect(collectResponsesToolGroups(undefined)).toEqual([]); + expect(collectResponsesToolGroups([])).toEqual([]); + expect(collectResponsesToolGroups({ tools: {}, input: "invalid" })).toEqual([]); + }); +});