Skip to content
9 changes: 9 additions & 0 deletions docs-site/src/content/docs/guides/codex-integration.md
Original file line number Diff line number Diff line change
Expand Up @@ -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__<inner-name>` 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:

Expand Down
9 changes: 9 additions & 0 deletions docs-site/src/content/docs/ja/guides/codex-integration.md
Original file line number Diff line number Diff line change
Expand Up @@ -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__<inner-name>` エイリアス(例: `image_gen__imagegen`)に変換します。その利用可能な
エイリアスがクライアント宣言を置き換える場合にのみ、重複する hosted `image_generation` 宣言を
削除します。関数呼び出しは Codex に届く前に明示的な `image_gen` 名前空間へ戻され、後続の履歴を
上流へ再送するときは再びエンコードされます。これにより、名前空間を予約している、またはドットを
含む関数名を拒否する OpenAI 互換の上流でも、クライアント側の画像生成を呼び出せます。ChatGPT
forward モードは変更されず、ネイティブな Responses Lite 形式を維持します。

`hostname` がループバックアドレスでない場合、Codex が自動生成した API 認証ヘッダーを送る必要があります。このとき専用
プロバイダーを注入します。

Expand Down
9 changes: 9 additions & 0 deletions docs-site/src/content/docs/ko/guides/codex-integration.md
Original file line number Diff line number Diff line change
Expand Up @@ -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__<inner-name>` alias(예: `image_gen__imagegen`)로 변환합니다. 이 사용 가능한 alias가
클라이언트 선언을 대체할 때만 중복된 hosted `image_generation` 선언을 제거합니다. 함수 호출은
Codex에 도달하기 전에 명시적인 `image_gen` namespace로 복원되고, 이후 기록을 업스트림으로
replay할 때 다시 인코딩됩니다. 따라서 namespace를 예약하거나 점이 포함된 함수 이름을 거부하는
OpenAI 호환 업스트림에서도 클라이언트 측 이미지 생성을 호출할 수 있습니다. ChatGPT forward
모드는 변경되지 않으며 네이티브 Responses Lite 형식을 유지합니다.

`hostname`이 loopback 주소가 아니면 Codex가 자동 생성된 API 인증 헤더를 보내야 합니다. 이때는 전용
프로바이더를 주입합니다.

Expand Down
11 changes: 11 additions & 0 deletions docs-site/src/content/docs/ru/guides/codex-integration.md
Original file line number Diff line number Diff line change
Expand Up @@ -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__<inner-name>` (например,
`image_gen__imagegen`). Дублирующее объявление hosted-инструмента `image_generation` удаляется
только тогда, когда такой рабочий псевдоним заменяет клиентское объявление. Перед передачей в
Codex вызов функции восстанавливается с явным пространством имён `image_gen`, а при последующем
воспроизведении истории во вышестоящий сервис снова кодируется. Это сохраняет клиентскую
генерацию изображений у OpenAI-совместимых провайдеров, которые резервируют это пространство
имён или отклоняют имена функций с точками. Режим ChatGPT forward не изменяется и сохраняет
свой нативный формат Responses Lite.

Если `hostname` не является loopback-адресом, Codex должен отправлять сгенерированный заголовок
API-аутентификации. Поэтому инжектор в этом случае использует выделенного провайдера:

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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__<inner-name>` alias(例如 `image_gen__imagegen`)。只有当这个可用 alias 替代了
客户端声明时,才会移除重复的 hosted `image_generation` 声明。函数调用会在到达 Codex 前恢复为
显式的 `image_gen` namespace,后续将历史记录重放到上游时再重新编码。因此,即使 OpenAI 兼容
上游保留了该 namespace,或拒绝包含点号的函数名,客户端图像生成仍可正常调用。ChatGPT forward
模式保持不变,并继续使用其原生 Responses Lite 格式。

如果 `hostname` 不是 loopback 地址,Codex 必须发送自动生成的 API 认证请求头。此时注入器会改用
专用提供商:

Expand Down
230 changes: 180 additions & 50 deletions src/adapters/openai-responses.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -500,31 +500,143 @@ 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__<tool>` is therefore an upstream-only alias; client-facing responses are
* restored to explicit `{ namespace: "image_gen", name: "<tool>" }` 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<string, unknown>[] | 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<string, unknown> & { 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;
}

/** 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.
*
* A complete `image_gen` namespace is flattened to safe `image_gen__<tool>` 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.
*
* 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.
* 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
Expand All @@ -538,48 +650,66 @@ 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 hasUsableImageGenAlias = toolGroups.some(group => group.some(declaresUsableImageGenAlias));

const seenFunctionNames = new Set<string>();
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);
}
}

return groupChanged ? normalized : tools;
};
Comment thread
RailyW marked this conversation as resolved.

let changed = false;
let tools = body.tools;
if (Array.isArray(body.tools)) {
tools = stripGroup(body.tools);
tools = normalizeGroup(body.tools);
changed ||= tools !== body.tools;
}

let input = body.input;
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;
Expand Down Expand Up @@ -740,7 +870,7 @@ export function createResponsesPassthroughAdapter(provider: OcxProviderConfig):
outBody = repairOrphanedInputItems(outBody, unexpandedMiss);
outBody = stripUnsupportedForwardParams(outBody);
}
else outBody = stripConflictingHostedTools(outBody);
else outBody = normalizeImageGenClientTools(outBody);
Comment thread
RailyW marked this conversation as resolved.
if (forward || parsed._previousResponseInputExpanded === true) {
outBody = repairOversizedReplayCallIds(outBody);
}
Expand Down
Loading
Loading