diff --git a/docs-site/src/content/docs/guides/codex-integration.md b/docs-site/src/content/docs/guides/codex-integration.md index 08cd90838..7c70ba1e6 100644 --- a/docs-site/src/content/docs/guides/codex-integration.md +++ b/docs-site/src/content/docs/guides/codex-integration.md @@ -63,6 +63,15 @@ 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__` 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: 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 4d1a7da49..4530561a6 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 63182b91a..3dd928e49 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 e92939c77..e77970afd 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 ba5b31951..53fceb9c9 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 认证请求头。此时注入器会改用 专用提供商: diff --git a/src/adapters/openai-responses.ts b/src/adapters/openai-responses.ts index d7e9ef5be..b03561c2d 100644 --- a/src/adapters/openai-responses.ts +++ b/src/adapters/openai-responses.ts @@ -1,8 +1,9 @@ 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 { 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"; @@ -500,72 +501,252 @@ 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}__`; + +/** 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; + 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), + }; + }); +} + +/** Convert a legacy dotted function declaration while preserving all other function metadata. */ +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) }; +} + +/** 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; + } + 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") { + 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; +} + +/** 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") { + return false; + } + 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; + } + 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 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 + * 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 - // `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; + const hasUsableImageGenAlias = toolGroups.some(group => group.some(declaresUsableImageGenAlias)); + const toolChoiceAliases = imageGenToolChoiceAliases(toolGroups); + + const seenFunctionNames = new Set(); + const normalizeGroup = (tools: unknown[]): unknown[] => { + const normalized: unknown[] = []; + let groupChanged = false; + + for (const tool of tools) { + if ( + hasUsableImageGenAlias + && 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); } } - } - 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; + + 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 +754,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; @@ -587,11 +770,15 @@ function stripConflictingHostedTools(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 } : {}), }; } @@ -740,7 +927,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/responses/tool-groups.ts b/src/responses/tool-groups.ts new file mode 100644 index 000000000..4a4af73fa --- /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/index.ts b/src/server/index.ts index 1ea972310..e12dc32c3 100644 --- a/src/server/index.ts +++ b/src/server/index.ts @@ -226,14 +226,16 @@ 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, +// new Response(eagerBody, // 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 000000000..5ebee945e --- /dev/null +++ b/src/server/responses-image-gen-repair.ts @@ -0,0 +1,195 @@ +import { collectResponsesToolGroups } from "../responses/tool-groups"; + +interface NamespacedTool { + namespace: string; + 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); +} + +/** + * 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 + * 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_NAMESPACE) continue; + aliases.set(wireName, tool); + aliases.set(`${tool.namespace}.${tool.name}`, tool); + } + for (const group of collectResponsesToolGroups(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; +} + +/** Recursively restore only exact image-gen function-call aliases in a parsed Responses payload. */ +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; +} + +/** 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; + return { + block: buffer.slice(0, match.index), + delimiter: match[0], + rest: buffer.slice(match.index + match[0].length), + }; +} + +/** 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/)) { + 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; +} + +/** 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/); + 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 dc96fa01d..f38b0e057 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, 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 @@ -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,6 +1602,8 @@ export async function handleResponses( onClientCancel: () => options.onNativePassthroughCancel?.(), onDone: () => unregisterTurn(turnAc), }); + // 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(eagerBody, { status: upstreamResponse.status, @@ -1652,10 +1663,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 +1683,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 6f39490d1..f58292392 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 @@ -60,13 +62,22 @@ 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 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 +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 833ebf5d2..b20767edf 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 f5928652f..31e5a014d 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,97 @@ 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 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 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 +769,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 +786,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 +828,189 @@ 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" }, + ], + tool_choice: { type: "function", name: "image_gen.imagegen" }, + }, + }, meta); + 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: [] }, + { + type: "namespace", + name: "web", + tools: [{ type: "function", name: "run", parameters: {} }], + }, + { 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", () => { + 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" }, + ]); }); test("keyed platform keeps hosted image_generation when no conflicting tool is declared", () => { @@ -770,8 +1035,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 +1047,29 @@ 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" }, ], + tool_choice: { type: "function", name: "image_gen.imagegen" }, }, }, 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 }> }>; + tool_choice: { type: string; 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); + expect(body.tool_choice).toEqual({ type: "function", name: "image_gen.imagegen" }); }); }); diff --git a/tests/passthrough-abort.test.ts b/tests/passthrough-abort.test.ts index 4f536fedf..3f2fc4699 100644 --- a/tests/passthrough-abort.test.ts +++ b/tests/passthrough-abort.test.ts @@ -42,12 +42,14 @@ 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("new Response(eagerBody"); 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 000000000..2c06599dd --- /dev/null +++ b/tests/responses-image-gen-repair.test.ts @@ -0,0 +1,332 @@ +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("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", + 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 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 passthrough 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; + } + }); +}); diff --git a/tests/responses-tool-groups.test.ts b/tests/responses-tool-groups.test.ts new file mode 100644 index 000000000..a7a88e4db --- /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([]); + }); +});