Skip to content

Commit 45bebbe

Browse files
committed
Merge origin/dev (PR #87 images endpoint) with encrypted-marker fixes (#85)
2 parents b918873 + 75d62e6 commit 45bebbe

14 files changed

Lines changed: 960 additions & 23 deletions

File tree

Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
# 260710 — /v1/images relay for codex's built-in image_gen (issue #83)
2+
3+
## Problem
4+
5+
GitHub issue #83: the built-in `image_gen` tool fails instantly with
6+
`http 404 Not Found: {"error":{"message":"Unknown endpoint: POST /v1/images/generations",...}}`.
7+
8+
The 404 is ours: codex-rs's standalone image-generation extension executes CLIENT-SIDE —
9+
it POSTs `{base_url}/images/generations` (edits variant: `/images/edits`) with the same
10+
ChatGPT bearer auth it uses for chat (`ext/image-generation`, request body
11+
`{prompt, background, model:"gpt-image-2", quality, size}`, expected response
12+
`{created, data:[{b64_json}]}`, both strict serde). Under Design B injection `base_url`
13+
IS the proxy, and the request died on the `/v1/*` JSON-404 guard (previously even
14+
test-pinned as an intentional 404 in server-auth.test.ts).
15+
16+
Why now: codex 0.144.0 graduated `image_generation` from the disabled-by-default
17+
`imagegenext` experiment to Stage::Stable / default-enabled, so ChatGPT-signed-in users
18+
started hitting the tool organically. The hosted `image_generation` tools[] entry on
19+
`/v1/responses` was never affected (passthrough already forwards it); only the
20+
extension's direct REST call 404'd.
21+
22+
## Fix
23+
24+
New relay route `POST /v1/images/{generations,edits}` (src/server/images.ts), inserted
25+
before the /v1/* guard with the standard data-plane preamble (drain 503, requireApiAuth,
26+
origin check, withCors, request log with `client_cancel` meta on 499).
27+
28+
Upstream selection (`findImagesUpstreams` collects BOTH candidates; selection is
29+
per-request):
30+
31+
1. **ChatGPT forward provider** — preferred, same precedence as the vision/web-search
32+
sidecars; it is the backend codex itself would have called absent the base_url
33+
override, so request/response bodies relay verbatim (no mapping). Auth via
34+
`resolveCodexAuthContext`/`headersForCodexAuthContext`: forwarded caller bearer, or
35+
the routed multi-account pool token. Guards:
36+
- `startServer` auto-upserts a `chatgpt` forward entry into every config, so the
37+
forward candidate is only used when the resolved headers actually carry an
38+
authorization value — otherwise an unauthenticated request would bounce off
39+
chatgpt.com as an opaque `{"detail":"Unauthorized"}`.
40+
- A bearer equal to the proxy's own admission secret (non-loopback binds accept
41+
`Authorization: Bearer <OPENCODEX_API_AUTH_TOKEN>`) is stripped before selection —
42+
the proxy secret must never be relayed to chatgpt.com
43+
(`isProxyAdmissionSecret`, extracted from `hasValidApiAuth`).
44+
- Forward-auth FAILURES (pool cooldown 429, reauth 401, affinity 409) are captured,
45+
not returned: a configured keyed provider still serves the request, and the
46+
captured error only surfaces when no keyed candidate exists. Without this, "all
47+
pool accounts cooling down" would 429 image_gen while api.openai.com sat idle.
48+
- Pool upstream outcomes are recorded via `sidecarOutcomeRecorder` (status /
49+
timeout / connect_error), keeping rotation failover signals alive.
50+
2. **Keyed openai-responses provider** (e.g. api.openai.com) — fallback when no
51+
relayable ChatGPT auth; its `/v1/images/*` is the real platform Images API. URL is
52+
normalized like the adapter (`baseUrl.replace(/\/v1\/?$/,"")` + `/v1/images/…`), so
53+
baseUrl with or without `/v1` both work; forward URLs stay bare
54+
(`${baseUrl}/images/…`, the ChatGPT-backend convention).
55+
3. **Neither** — honest 400 (`invalid_request_error`) naming the fix
56+
(`codex features disable image_generation` or add an OpenAI provider). 400, not 5xx:
57+
codex retries every 5xx up to 5 total attempts and this is a permanent config state.
58+
59+
Relay details: `readJsonRequestBody` (zstd/gzip decode + 256MB cap) → JSON re-serialize →
60+
`fetchWithResetRetry` (stale keep-alive resets) with a `config.images.timeoutMs`
61+
(default 300s) timeout linked to the client abort; response buffered (single JSON
62+
document, few MB) and passed through status+content-type+body verbatim so upstream
63+
plan-gating errors stay legible. Client cancel returns 499 (`client_closed_request`),
64+
never a fake 502; genuine timeout returns 504 (retriable by codex, acceptable for a
65+
transient hang).
66+
67+
Deliberately NOT done:
68+
69+
- No auto-injection of `[features] image_generation = false` — it would remove a
70+
capability the relay makes work, and openai/codex#21952 suggests app-server ignores
71+
the flag anyway. Documented as a user opt-out instead (docs-site codex-integration,
72+
en/ko/zh-cn).
73+
- No image generation via routed providers (Gemini image models etc.) — the adapter
74+
event pipeline has no image-output event; separate feature, not this bug fix.
75+
76+
## Tests
77+
78+
tests/server-images.test.ts (16): forward relay (auth + path + body), edits path,
79+
pool-token override (caller bearer must not leak), zstd body decode, keyed fallback +
80+
`/v1` URL normalization (with and without suffix), unauthenticated-request gate (keyed
81+
fallback / 401), forward-auth-failure fallback (keyed / surfaced 401), no-upstream 400,
82+
upstream error passthrough, 504 timeout via `config.images.timeoutMs`, GET falls to 404
83+
guard, non-loopback auth/origin, admission-secret never relayed.
84+
server-auth.test.ts's 404-guard list swaps `/v1/images/generations` for
85+
`/v1/realtime/sessions`.

docs-site/src/content/docs/guides/codex-integration.md

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,24 @@ fast_mode = true
2323
```
2424

2525
The proxy listens on port `10100` by default and serves `POST /v1/responses`,
26-
`POST /v1/responses/compact`, `GET /v1/models`, `GET /healthz`, and the `/api/*` management surface.
26+
`POST /v1/responses/compact`, `POST /v1/images/generations`, `POST /v1/images/edits`,
27+
`GET /v1/models`, `GET /healthz`, and the `/api/*` management surface.
28+
29+
### Built-in image generation (`image_gen`)
30+
31+
Codex's built-in `image_gen` tool does not go through `/v1/responses` — the codex-rs extension
32+
POSTs `{base_url}/images/generations` (or `/images/edits` when reference images are attached)
33+
directly, with the same ChatGPT bearer auth it uses for chat. Because the injected `base_url`
34+
points at opencodex, the proxy relays those calls to the OpenAI upstream:
35+
36+
- **ChatGPT login (default):** the request is forwarded to `chatgpt.com/backend-api/codex` with
37+
the caller's OAuth token (or the routed multi-account pool token). No API key is needed.
38+
- **OpenAI API-key provider:** if no ChatGPT auth is available on the request, the relay falls
39+
back to a configured `openai-responses` API-key provider (e.g. `api.openai.com`).
40+
- **Neither:** the proxy returns a clear error instead of a generic 404. Routed providers
41+
(Cursor, Gemini, Kiro, …) cannot serve image generation; if you don't want the tool offered at
42+
all, disable it in Codex with `codex features disable image_generation`
43+
(`[features] image_generation = false` in `config.toml`).
2744

2845
For a non-loopback `hostname`, Codex must send the generated API auth header. The injector therefore
2946
uses a dedicated provider instead:

docs-site/src/content/docs/ko/guides/codex-integration.md

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,24 @@ fast_mode = true
2222
```
2323

2424
프록시의 기본 포트는 `10100`입니다. `POST /v1/responses`, `POST /v1/responses/compact`,
25-
`GET /v1/models`, `GET /healthz`, `/api/*` 관리 API를 제공합니다.
25+
`POST /v1/images/generations`, `POST /v1/images/edits`, `GET /v1/models`, `GET /healthz`,
26+
`/api/*` 관리 API를 제공합니다.
27+
28+
### 내장 이미지 생성 (`image_gen`)
29+
30+
Codex의 내장 `image_gen` 도구는 `/v1/responses`를 거치지 않습니다. codex-rs 확장이
31+
`{base_url}/images/generations`(참조 이미지가 있으면 `/images/edits`)를 채팅과 동일한
32+
ChatGPT bearer 인증으로 직접 POST합니다. 주입된 `base_url`이 opencodex를 가리키므로,
33+
프록시가 이 호출을 OpenAI 업스트림으로 중계합니다.
34+
35+
- **ChatGPT 로그인(기본):** 호출자의 OAuth 토큰(또는 멀티 계정 풀에서 선택된 계정의 토큰)으로
36+
`chatgpt.com/backend-api/codex`에 전달합니다. API 키가 필요 없습니다.
37+
- **OpenAI API 키 프로바이더:** 요청에 사용할 ChatGPT 인증이 없으면, 설정된 `openai-responses`
38+
API 키 프로바이더(예: `api.openai.com`)로 대신 전달합니다.
39+
- **둘 다 없음:** 모호한 404 대신 명확한 오류를 반환합니다. 라우팅되는 다른 프로바이더(Cursor,
40+
Gemini, Kiro 등)는 이미지 생성을 제공할 수 없습니다. 도구 자체를 끄고 싶다면 Codex에서
41+
`codex features disable image_generation`(`config.toml``[features] image_generation = false`)을
42+
사용하세요.
2643

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

docs-site/src/content/docs/ko/reference/architecture.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,9 @@ HTTP 경계는 `server/index.ts`가 맡고, Responses 데이터 플레인은 `se
4040

4141
1. `server/index.ts`에서 CORS와 API 인증을 확인하고, 종료 대기 중이면 새 요청을 거부한 뒤 요청 수명
4242
주기를 기록합니다. 여기서 `GET /v1/models`, `POST /v1/responses`,
43-
`POST /v1/responses/compact`, `/v1/responses`의 선택적 WebSocket 업그레이드를 제공합니다.
43+
`POST /v1/responses/compact`, `POST /v1/images/generations` / `POST /v1/images/edits`
44+
(Codex 내장 `image_gen` 도구용 — `server/images.ts`가 OpenAI 계열 업스트림으로 중계),
45+
`/v1/responses`의 선택적 WebSocket 업그레이드를 제공합니다.
4446
2. `server/responses.ts`가 압축을 풀고 JSON을 읽습니다. 기억해 둔 `previous_response_id` 입력이 있으면
4547
펼친 다음 `responses/parser.ts`로 넘깁니다.
4648
3. `router.ts`가 일반 모델 id 또는 `provider/model` id를 해석합니다. 이어서 Codex 계정 affinity를

docs-site/src/content/docs/reference/architecture.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,9 @@ src/
4141

4242
1. `server/index.ts` applies CORS and API authentication, rejects new work while draining, and
4343
records request lifecycle metadata. It serves `GET /v1/models`, `POST /v1/responses`,
44-
`POST /v1/responses/compact`, and the optional WebSocket upgrade on `/v1/responses`.
44+
`POST /v1/responses/compact`, `POST /v1/images/generations` / `POST /v1/images/edits`
45+
(relayed to an OpenAI-family upstream by `server/images.ts` for codex's built-in `image_gen`
46+
tool), and the optional WebSocket upgrade on `/v1/responses`.
4547
2. `server/responses.ts` decompresses and parses JSON, expands locally remembered
4648
`previous_response_id` input when available, then calls `responses/parser.ts`.
4749
3. `router.ts` resolves a bare or `provider/model` id. The server then resolves Codex account

docs-site/src/content/docs/zh-cn/guides/codex-integration.md

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,24 @@ fast_mode = true
2121
```
2222

2323
proxy 的默认端口为 `10100`,提供 `POST /v1/responses``POST /v1/responses/compact`
24-
`GET /v1/models``GET /healthz` 以及 `/api/*` 管理 API。
24+
`POST /v1/images/generations``POST /v1/images/edits``GET /v1/models``GET /healthz`
25+
以及 `/api/*` 管理 API。
26+
27+
### 内置图像生成(`image_gen`
28+
29+
Codex 的内置 `image_gen` 工具不经过 `/v1/responses`——codex-rs 扩展直接 POST
30+
`{base_url}/images/generations`(附带参考图像时为 `/images/edits`),使用与聊天相同的
31+
ChatGPT bearer 认证。由于注入的 `base_url` 指向 opencodex,proxy 会把这些调用中继到
32+
OpenAI 上游:
33+
34+
- **ChatGPT 登录(默认):** 请求会携带调用方的 OAuth token(或多账户池选中账户的 token)
35+
转发到 `chatgpt.com/backend-api/codex`,无需 API key。
36+
- **OpenAI API key 提供商:** 如果请求中没有可用的 ChatGPT 认证,中继会退回到已配置的
37+
`openai-responses` API key 提供商(例如 `api.openai.com`)。
38+
- **两者都没有:** proxy 返回明确的错误而不是含糊的 404。其他路由提供商(Cursor、Gemini、
39+
Kiro 等)无法提供图像生成;如果想完全关闭该工具,可在 Codex 中执行
40+
`codex features disable image_generation`(即 `config.toml`
41+
`[features] image_generation = false`)。
2542

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

docs-site/src/content/docs/zh-cn/reference/architecture.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,9 @@ src/
4040

4141
1. `server/index.ts` 应用 CORS 和 API 认证,在 drain 期间拒绝新请求,并记录请求生命周期
4242
metadata。它提供 `GET /v1/models``POST /v1/responses`
43-
`POST /v1/responses/compact`,以及 `/v1/responses` 上可选的 WebSocket upgrade。
43+
`POST /v1/responses/compact``POST /v1/images/generations` / `POST /v1/images/edits`
44+
(供 Codex 内置 `image_gen` 工具使用——由 `server/images.ts` 中继到 OpenAI 系上游),
45+
以及 `/v1/responses` 上可选的 WebSocket upgrade。
4446
2. `server/responses.ts` 解压并解析 JSON;如果本地记住了对应输入,则展开
4547
`previous_response_id`,随后调用 `responses/parser.ts`
4648
3. `router.ts` 解析 bare id 或 `provider/model` id。server 随后确定 Codex account affinity,

src/server/auth-cors.ts

Lines changed: 16 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -117,31 +117,34 @@ export function assertServerAuthConfig(config: OcxConfig): void {
117117
}
118118
}
119119

120-
export function hasValidApiAuth(req: Request, config: OcxConfig): boolean {
121-
if (!isApiAuthRequired(config)) return true;
122-
const actual = req.headers.get("x-opencodex-api-key")?.trim()
123-
|| req.headers.get("authorization")?.replace(/^Bearer\s+/i, "").trim();
120+
/** Whether `token` is one of the proxy's own admission secrets (env token or config API keys). */
121+
export function isProxyAdmissionSecret(token: string, config: OcxConfig): boolean {
122+
const actual = token.trim();
124123
if (!actual) return false;
124+
const enc = new TextEncoder();
125+
const actualBytes = enc.encode(actual);
125126
// Check env-based token
126127
const expected = configuredApiAuthToken(config);
127128
if (expected) {
128-
const enc = new TextEncoder();
129129
const expectedBytes = enc.encode(expected);
130-
const actualBytes = enc.encode(actual);
131130
if (expectedBytes.length === actualBytes.length && timingSafeEqual(actualBytes, expectedBytes)) return true;
132131
}
133132
// Check config-based API keys
134-
if (config.apiKeys?.length) {
135-
const enc = new TextEncoder();
136-
const actualBytes = enc.encode(actual);
137-
for (const k of config.apiKeys) {
138-
const keyBytes = enc.encode(k.key);
139-
if (keyBytes.length === actualBytes.length && timingSafeEqual(actualBytes, keyBytes)) return true;
140-
}
133+
for (const k of config.apiKeys ?? []) {
134+
const keyBytes = enc.encode(k.key);
135+
if (keyBytes.length === actualBytes.length && timingSafeEqual(actualBytes, keyBytes)) return true;
141136
}
142137
return false;
143138
}
144139

140+
export function hasValidApiAuth(req: Request, config: OcxConfig): boolean {
141+
if (!isApiAuthRequired(config)) return true;
142+
const actual = req.headers.get("x-opencodex-api-key")?.trim()
143+
|| req.headers.get("authorization")?.replace(/^Bearer\s+/i, "").trim();
144+
if (!actual) return false;
145+
return isProxyAdmissionSecret(actual, config);
146+
}
147+
145148
export function requireApiAuth(req: Request, config: OcxConfig, kind: "management" | "data-plane"): Response | null {
146149
if (hasValidApiAuth(req, config)) return null;
147150
if (kind === "management") return jsonResponse({ error: "opencodex API key required" }, 401);

0 commit comments

Comments
 (0)