@bitkyc08/opencodex 2.7.4 → 2.7.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,202 @@
1
+ /** Maximum number of response-body bytes that may be retained for an error. */
2
+ export const BOUNDED_BODY_MAX_BYTES = 65_536;
3
+
4
+ /** Default wall-clock and continuous-silence deadlines. */
5
+ export const BOUNDED_BODY_TIMEOUT_MS = 5_000;
6
+
7
+ export interface BoundedBodyOptions {
8
+ /** Abort the read with this signal. Its reason is rethrown by identity. */
9
+ signal?: AbortSignal;
10
+ /** Total wall-clock deadline. Exposed for focused tests. */
11
+ totalTimeoutMs?: number;
12
+ /** Deadline between non-empty raw chunks. Exposed for focused tests. */
13
+ inactivityTimeoutMs?: number;
14
+ }
15
+
16
+ export interface BoundedBodyResult {
17
+ /** UTF-8 text retained from the response. Empty when the size limit was exceeded. */
18
+ text: string;
19
+ /** True when EOF was not observed. */
20
+ truncated: boolean;
21
+ /** True for either total-deadline or inactivity-deadline expiry. */
22
+ timedOut: boolean;
23
+ /** Distinguishes the wall-clock deadline from an inactivity deadline. */
24
+ totalTimedOut: boolean;
25
+ /** True only when continuous inactivity caused the timeout. */
26
+ inactivityTimedOut: boolean;
27
+ /** True when the body was observed to exceed the byte cap. */
28
+ oversized: boolean;
29
+ /** False means callers should use a status-only fallback, not `text`. */
30
+ displaySafe: boolean;
31
+ }
32
+
33
+ const TOTAL_TIMEOUT = Symbol("bounded body total timeout");
34
+ const INACTIVITY_TIMEOUT = Symbol("bounded body inactivity timeout");
35
+
36
+ function timeoutPromise(ms: number, value: symbol): { promise: Promise<symbol>; clear: () => void } {
37
+ let timer: ReturnType<typeof setTimeout> | undefined;
38
+ const promise = new Promise<symbol>((resolve) => {
39
+ timer = setTimeout(() => resolve(value), Math.max(0, ms));
40
+ });
41
+ return {
42
+ promise,
43
+ clear: () => {
44
+ if (timer !== undefined) clearTimeout(timer);
45
+ },
46
+ };
47
+ }
48
+
49
+ function cancelWithoutWaiting(reader: ReadableStreamDefaultReader<Uint8Array>, reason?: unknown): void {
50
+ // A hostile/broken stream may reject or never settle cancel(). Neither should
51
+ // escape as an unhandled rejection or extend this primitive's own deadline.
52
+ try {
53
+ void reader.cancel(reason).catch(() => undefined);
54
+ } catch {
55
+ // Some stream implementations throw synchronously from cancel().
56
+ }
57
+ }
58
+
59
+ function decodeUtf8(chunks: readonly Uint8Array[]): string {
60
+ const decoder = new TextDecoder();
61
+ let text = "";
62
+ for (const chunk of chunks) text += decoder.decode(chunk, { stream: true });
63
+ // Flush an incomplete trailing UTF-8 sequence deterministically.
64
+ text += decoder.decode();
65
+ return text;
66
+ }
67
+
68
+ /**
69
+ * Consume the original response body under strict memory and time bounds.
70
+ *
71
+ * This deliberately calls `getReader()` on `response.body`: it never clones or
72
+ * tees the response. Once an over-limit byte is observed, all retained raw data
73
+ * is discarded so an untrusted prefix can never become a client-facing error.
74
+ */
75
+ export async function readBoundedResponseBody(
76
+ response: Response,
77
+ options: BoundedBodyOptions = {},
78
+ ): Promise<BoundedBodyResult> {
79
+ const signal = options.signal;
80
+ if (signal?.aborted) throw signal.reason;
81
+
82
+ const body = response.body;
83
+ if (!body) {
84
+ return {
85
+ text: "",
86
+ truncated: false,
87
+ timedOut: false,
88
+ totalTimedOut: false,
89
+ inactivityTimedOut: false,
90
+ oversized: false,
91
+ displaySafe: true,
92
+ };
93
+ }
94
+
95
+ const reader = body.getReader();
96
+ const chunks: Uint8Array[] = [];
97
+ let retainedBytes = 0;
98
+ let mustCancel = false;
99
+ let cancelReason: unknown;
100
+ const total = timeoutPromise(options.totalTimeoutMs ?? BOUNDED_BODY_TIMEOUT_MS, TOTAL_TIMEOUT);
101
+ let inactivity = timeoutPromise(
102
+ options.inactivityTimeoutMs ?? BOUNDED_BODY_TIMEOUT_MS,
103
+ INACTIVITY_TIMEOUT,
104
+ );
105
+
106
+ let rejectForAbort: ((reason: unknown) => void) | undefined;
107
+ const aborted = new Promise<never>((_resolve, reject) => {
108
+ rejectForAbort = reject;
109
+ });
110
+ const onAbort = () => rejectForAbort?.(signal?.reason);
111
+ signal?.addEventListener("abort", onAbort, { once: true });
112
+ // Close the narrow race between the preflight check and listener install.
113
+ if (signal?.aborted) onAbort();
114
+
115
+ try {
116
+ while (true) {
117
+ // Attach a rejection handler before racing. If a deadline wins and
118
+ // cancellation later rejects this read, it remains observed.
119
+ const read = reader.read();
120
+ void read.catch(() => undefined);
121
+ const outcome = await Promise.race([read, total.promise, inactivity.promise, aborted]);
122
+ // Cancellation owns the body lifetime even when EOF/readability settles in
123
+ // the same turn. Promise.race otherwise lets array order hide the abort.
124
+ if (signal?.aborted) {
125
+ mustCancel = true;
126
+ cancelReason = signal.reason;
127
+ throw signal.reason;
128
+ }
129
+
130
+ if (outcome === TOTAL_TIMEOUT || outcome === INACTIVITY_TIMEOUT) {
131
+ mustCancel = true;
132
+ cancelReason = new DOMException(
133
+ outcome === TOTAL_TIMEOUT ? "Error body total timeout" : "Error body inactivity timeout",
134
+ "TimeoutError",
135
+ );
136
+ return {
137
+ text: decodeUtf8(chunks),
138
+ truncated: true,
139
+ timedOut: true,
140
+ totalTimedOut: outcome === TOTAL_TIMEOUT,
141
+ inactivityTimedOut: outcome === INACTIVITY_TIMEOUT,
142
+ oversized: false,
143
+ displaySafe: false,
144
+ };
145
+ }
146
+
147
+ const { value, done } = outcome as ReadableStreamReadResult<Uint8Array>;
148
+ if (done) {
149
+ return {
150
+ text: decodeUtf8(chunks),
151
+ truncated: false,
152
+ timedOut: false,
153
+ totalTimedOut: false,
154
+ inactivityTimedOut: false,
155
+ oversized: false,
156
+ displaySafe: true,
157
+ };
158
+ }
159
+
160
+ if (!value || value.byteLength === 0) continue;
161
+
162
+ inactivity.clear();
163
+ inactivity = timeoutPromise(
164
+ options.inactivityTimeoutMs ?? BOUNDED_BODY_TIMEOUT_MS,
165
+ INACTIVITY_TIMEOUT,
166
+ );
167
+
168
+ if (value.byteLength > BOUNDED_BODY_MAX_BYTES - retainedBytes) {
169
+ mustCancel = true;
170
+ cancelReason = new DOMException("Error body size limit reached", "QuotaExceededError");
171
+ chunks.length = 0;
172
+ retainedBytes = 0;
173
+ return {
174
+ text: "",
175
+ truncated: true,
176
+ timedOut: false,
177
+ totalTimedOut: false,
178
+ inactivityTimedOut: false,
179
+ oversized: true,
180
+ displaySafe: false,
181
+ };
182
+ }
183
+
184
+ chunks.push(value);
185
+ retainedBytes += value.byteLength;
186
+ }
187
+ } catch (error) {
188
+ mustCancel = true;
189
+ cancelReason = error;
190
+ throw error;
191
+ } finally {
192
+ total.clear();
193
+ inactivity.clear();
194
+ signal?.removeEventListener("abort", onAbort);
195
+ if (mustCancel) cancelWithoutWaiting(reader, cancelReason);
196
+ try {
197
+ reader.releaseLock();
198
+ } catch {
199
+ // A pending read can keep the lock briefly while cancel settles.
200
+ }
201
+ }
202
+ }
@@ -348,11 +348,13 @@ export const PROVIDER_REGISTRY: readonly ProviderRegistryEntry[] = [
348
348
  "kimi-k2.7-code-highspeed": [],
349
349
  ...Object.fromEntries(OPENCODE_GO_THINKING_TOGGLE_MODELS.map(id => [id, THINKING_TOGGLE_EFFORTS])),
350
350
  ...Object.fromEntries(OPENCODE_GO_THINKING_BUDGET_MODELS.map(id => [id, THINKING_BUDGET_EFFORTS])),
351
+ ...Object.fromEntries(DEEPSEEK_THINKING_MODELS.map(id => [id, DEEPSEEK_THINKING_EFFORTS])),
351
352
  },
352
353
  // glm-5.2 uses identity labels now that `max` is a native Codex level (no alias map);
353
354
  // the thinking-toggle map is a REAL wire alias (effort -> enabled/disabled) and stays.
354
355
  modelReasoningEffortMap: {
355
356
  ...Object.fromEntries(OPENCODE_GO_THINKING_TOGGLE_MODELS.map(id => [id, THINKING_TOGGLE_MAP])),
357
+ ...Object.fromEntries(DEEPSEEK_THINKING_MODELS.map(id => [id, DEEPSEEK_THINKING_REASONING_MAP])),
356
358
  },
357
359
  thinkingToggleModels: OPENCODE_GO_THINKING_TOGGLE_MODELS,
358
360
  thinkingBudgetModels: THINKING_BUDGET_MODELS,
@@ -371,7 +373,8 @@ export const PROVIDER_REGISTRY: readonly ProviderRegistryEntry[] = [
371
373
  noTopPModels: ["kimi-k2.7-code", "kimi-k2.7-code-highspeed"],
372
374
  noPenaltyModels: ["kimi-k2.7-code", "kimi-k2.7-code-highspeed"],
373
375
  autoToolChoiceOnlyModels: ["kimi-k2.7-code", "kimi-k2.7-code-highspeed"],
374
- preserveReasoningContentModels: ["glm-5.2", "kimi-k2.7-code", "kimi-k2.7-code-highspeed"],
376
+ // Issue #78: DeepSeek V4 thinking mode requires reasoning_content replay on tool-call turns.
377
+ preserveReasoningContentModels: ["glm-5.2", "kimi-k2.7-code", "kimi-k2.7-code-highspeed", ...DEEPSEEK_THINKING_MODELS],
375
378
  },
376
379
  {
377
380
  id: "neuralwatt",
@@ -117,31 +117,34 @@ export function assertServerAuthConfig(config: OcxConfig): void {
117
117
  }
118
118
  }
119
119
 
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();
124
123
  if (!actual) return false;
124
+ const enc = new TextEncoder();
125
+ const actualBytes = enc.encode(actual);
125
126
  // Check env-based token
126
127
  const expected = configuredApiAuthToken(config);
127
128
  if (expected) {
128
- const enc = new TextEncoder();
129
129
  const expectedBytes = enc.encode(expected);
130
- const actualBytes = enc.encode(actual);
131
130
  if (expectedBytes.length === actualBytes.length && timingSafeEqual(actualBytes, expectedBytes)) return true;
132
131
  }
133
132
  // 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;
141
136
  }
142
137
  return false;
143
138
  }
144
139
 
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
+
145
148
  export function requireApiAuth(req: Request, config: OcxConfig, kind: "management" | "data-plane"): Response | null {
146
149
  if (hasValidApiAuth(req, config)) return null;
147
150
  if (kind === "management") return jsonResponse({ error: "opencodex API key required" }, 401);
@@ -0,0 +1,218 @@
1
+ /**
2
+ * /v1/images/{generations,edits} relay (issue #83).
3
+ *
4
+ * codex-rs's standalone image_gen extension executes CLIENT-SIDE: it POSTs
5
+ * `{base_url}/images/generations` (edits when reference images are attached) with the same
6
+ * ChatGPT bearer auth it uses for chat. Under Design B injection base_url IS this proxy, so
7
+ * without a route the tool died on the /v1/* JSON-404 guard. Only an OpenAI-family upstream
8
+ * can serve these endpoints — routed providers (Cursor, Kiro, Gemini, …) have no image
9
+ * generation surface — so the handler relays the body verbatim to the ChatGPT forward
10
+ * provider (or an OpenAI API-key provider) and passes the response through untouched:
11
+ * codex's images client parses `{created, data:[{b64_json}]}` strictly and Debug-prints
12
+ * error bodies into the model-visible failure, so upstream errors must stay legible.
13
+ */
14
+ import { formatErrorResponse } from "../bridge";
15
+ import {
16
+ CodexAccountCooldownError,
17
+ CodexAuthContextError,
18
+ CodexThreadAffinityExpiredError,
19
+ headersForCodexAuthContext,
20
+ isCodexAuthContextUsable,
21
+ resolveCodexAuthContext,
22
+ } from "../codex/auth-context";
23
+ import { formatCodexProviderForLog } from "../codex/routing";
24
+ import { resolveEnvValue } from "../config";
25
+ import { signalWithTimeout } from "../lib/abort";
26
+ import { sidecarEnter } from "../lib/sidecar-tracker";
27
+ import type { OcxConfig, OcxProviderConfig } from "../types";
28
+ import { isProxyAdmissionSecret } from "./auth-cors";
29
+ import { readJsonRequestBody } from "./request-decompress";
30
+ import type { RequestLogContext } from "./request-log";
31
+ import { codexLogAccountId, decodeRequestErrorResponse, sidecarOutcomeRecorder } from "./responses";
32
+
33
+ export type ImagesEndpoint = "generations" | "edits";
34
+
35
+ /** Image generation is slow (tens of seconds); bound a hung upstream, not a working one. */
36
+ const IMAGES_UPSTREAM_TIMEOUT_MS = 300_000;
37
+
38
+ /**
39
+ * Cap for the buffered upstream response body (100 MiB). Images responses are JSON documents
40
+ * containing base64-encoded images — typically a few MB. This prevents an oversized or malicious
41
+ * response from exhausting process memory.
42
+ */
43
+ const IMAGES_RESPONSE_MAX_BYTES = 100 * 1024 * 1024;
44
+
45
+ interface NamedProvider {
46
+ name: string;
47
+ provider: OcxProviderConfig;
48
+ }
49
+
50
+ interface ImagesUpstreamCandidates {
51
+ /** ChatGPT passthrough — the backend codex itself would have called absent the base_url override. */
52
+ forward?: NamedProvider;
53
+ /** Keyed openai-responses provider (e.g. api.openai.com), whose /v1/images/* is the platform Images API. */
54
+ keyed?: NamedProvider & { apiKey: string };
55
+ }
56
+
57
+ /**
58
+ * Collect the upstreams that can serve /images/*. The forward provider is preferred (same
59
+ * precedence as the vision/web-search sidecars) but only usable when the request actually
60
+ * carries relayable ChatGPT auth — startServer auto-upserts a `chatgpt` forward entry into
61
+ * every config, so its mere presence proves nothing about credentials.
62
+ */
63
+ function findImagesUpstreams(config: OcxConfig): ImagesUpstreamCandidates {
64
+ const candidates: ImagesUpstreamCandidates = {};
65
+ for (const [name, provider] of Object.entries(config.providers)) {
66
+ if (provider.disabled === true) continue;
67
+ if (provider.authMode === "forward") {
68
+ candidates.forward ??= { name, provider };
69
+ continue;
70
+ }
71
+ if (candidates.keyed || provider.adapter !== "openai-responses" || provider.authMode === "oauth") continue;
72
+ const apiKey = resolveEnvValue(provider.apiKey);
73
+ if (apiKey) candidates.keyed = { name, provider, apiKey };
74
+ }
75
+ return candidates;
76
+ }
77
+
78
+ export async function handleImages(
79
+ req: Request,
80
+ config: OcxConfig,
81
+ endpoint: ImagesEndpoint,
82
+ logCtx: RequestLogContext,
83
+ ): Promise<Response> {
84
+ let body: unknown;
85
+ try {
86
+ body = await readJsonRequestBody(req);
87
+ } catch (err) {
88
+ return decodeRequestErrorResponse(err, "images");
89
+ }
90
+ const model = (body as { model?: unknown } | null)?.model;
91
+ if (typeof model === "string" && model) logCtx.model = model;
92
+
93
+ const candidates = findImagesUpstreams(config);
94
+ if (!candidates.forward && !candidates.keyed) {
95
+ // 400, not 5xx: codex retries every 5xx up to 5 total attempts, and this is a permanent
96
+ // configuration state that must surface on the first attempt.
97
+ return formatErrorResponse(
98
+ 400,
99
+ "invalid_request_error",
100
+ "Built-in image generation needs an OpenAI upstream (ChatGPT login or an OpenAI API-key provider), "
101
+ + "but none is configured in opencodex. Routed providers cannot serve /v1/images/* — "
102
+ + "add an OpenAI provider or disable the tool with `codex features disable image_generation`.",
103
+ );
104
+ }
105
+
106
+ // Resolve forward auth first; failures are captured, not returned, so a configured keyed
107
+ // provider can still serve the request (e.g. every pool account cooling down must not
108
+ // 429 image_gen while api.openai.com sits idle).
109
+ let forwardAuthHeaders: Headers | undefined;
110
+ let forwardAuthError: Response | undefined;
111
+ let recordOutcome: ReturnType<typeof sidecarOutcomeRecorder>;
112
+ if (candidates.forward) {
113
+ try {
114
+ const authCtx = await resolveCodexAuthContext(req.headers, config);
115
+ if (!isCodexAuthContextUsable(authCtx, config)) {
116
+ forwardAuthError = formatErrorResponse(401, "authentication_error", "Selected Codex account needs reauthentication");
117
+ } else {
118
+ // Forwarded caller auth, overridden by the routed pool account's token when one is selected.
119
+ const authHeaders = headersForCodexAuthContext(req.headers, authCtx);
120
+ const bearer = authHeaders.get("authorization")?.replace(/^Bearer\s+/i, "") ?? "";
121
+ // A caller may authenticate to the proxy itself with `Authorization: Bearer <admission
122
+ // token>` (non-loopback binds); that secret must never be relayed to chatgpt.com.
123
+ if (bearer && isProxyAdmissionSecret(bearer, config)) authHeaders.delete("authorization");
124
+ // Only relay through the ChatGPT backend when there is a bearer to relay: startServer
125
+ // auto-upserts the `chatgpt` provider, so an unauthenticated request must not be bounced
126
+ // off chatgpt.com when a keyed OpenAI provider (or an honest error) serves it better.
127
+ if (authHeaders.get("authorization")) {
128
+ forwardAuthHeaders = authHeaders;
129
+ recordOutcome = sidecarOutcomeRecorder(config, authCtx);
130
+ logCtx.provider = formatCodexProviderForLog(candidates.forward.name, codexLogAccountId(authCtx), config);
131
+ }
132
+ }
133
+ } catch (err) {
134
+ if (err instanceof CodexAccountCooldownError) {
135
+ forwardAuthError = formatErrorResponse(429, "rate_limit_error", "Selected Codex account is cooling down");
136
+ } else if (err instanceof CodexThreadAffinityExpiredError) {
137
+ forwardAuthError = formatErrorResponse(409, "invalid_request_error", "Codex thread account affinity expired; start a new session");
138
+ } else if (err instanceof CodexAuthContextError) {
139
+ const safeAccountLabel = formatCodexProviderForLog(candidates.forward.name, err.accountId, config);
140
+ console.error(`[images] Pool account ${safeAccountLabel} token failed; reauthentication required`);
141
+ forwardAuthError = formatErrorResponse(401, "authentication_error", "Selected Codex account needs reauthentication");
142
+ } else {
143
+ throw err;
144
+ }
145
+ }
146
+ }
147
+
148
+ const headers: Record<string, string> = { "content-type": "application/json" };
149
+ let url: string;
150
+ if (forwardAuthHeaders && candidates.forward) {
151
+ const { provider } = candidates.forward;
152
+ if (provider.headers) Object.assign(headers, provider.headers);
153
+ for (const [name, value] of forwardAuthHeaders) headers[name] = value;
154
+ // The ChatGPT codex backend takes bare paths (matches the adapter's `${baseUrl}/responses`).
155
+ url = `${provider.baseUrl}/images/${endpoint}`;
156
+ } else if (candidates.keyed) {
157
+ const { provider, apiKey, name } = candidates.keyed;
158
+ if (provider.headers) Object.assign(headers, provider.headers);
159
+ headers["authorization"] = `Bearer ${apiKey}`;
160
+ logCtx.provider = name;
161
+ // Keyed providers tolerate baseUrl with or without /v1 (mirrors openai-responses.ts).
162
+ url = `${provider.baseUrl.replace(/\/v1\/?$/, "")}/v1/images/${endpoint}`;
163
+ } else if (forwardAuthError) {
164
+ return forwardAuthError;
165
+ } else {
166
+ return formatErrorResponse(
167
+ 401,
168
+ "authentication_error",
169
+ "image generation relay needs ChatGPT auth (Authorization header) or an OpenAI API-key provider",
170
+ );
171
+ }
172
+
173
+ const timeoutMs = config.images?.timeoutMs ?? IMAGES_UPSTREAM_TIMEOUT_MS;
174
+ const linkedSignal = signalWithTimeout(timeoutMs, req.signal);
175
+ const sidecarExit = sidecarEnter("images");
176
+ try {
177
+ // Images POSTs create paid, non-idempotent work. One fetch only: no reset retry without a
178
+ // source-proven idempotency contract.
179
+ const upstreamResponse = await fetch(url, {
180
+ method: "POST",
181
+ headers,
182
+ body: JSON.stringify(body),
183
+ signal: linkedSignal.signal,
184
+ });
185
+ // Buffer rather than stream: the payload is one JSON document (base64 image, typically a few
186
+ // MB), and buffering keeps the timeout window covering the whole exchange. Cap the size to
187
+ // prevent an oversized response from exhausting process memory.
188
+ const payload = await upstreamResponse.arrayBuffer();
189
+ if (payload.byteLength > IMAGES_RESPONSE_MAX_BYTES) {
190
+ return formatErrorResponse(502, "upstream_error", `image ${endpoint} response too large (${payload.byteLength} bytes)`);
191
+ }
192
+ recordOutcome?.(upstreamResponse.status);
193
+ const relayHeaders: Record<string, string> = {};
194
+ const contentType = upstreamResponse.headers.get("content-type");
195
+ if (contentType) relayHeaders["content-type"] = contentType;
196
+ return new Response(payload, { status: upstreamResponse.status, headers: relayHeaders });
197
+ } catch (err) {
198
+ // Client cancel first: it aborts the linked signal too, and must not be logged as an
199
+ // upstream failure (499 maps to client_closed_request in the request log).
200
+ if (req.signal.aborted) {
201
+ return formatErrorResponse(499, "client_closed_request", `image ${endpoint} request canceled by client`);
202
+ }
203
+ if (err instanceof Error && err.name === "TimeoutError") {
204
+ recordOutcome?.("timeout");
205
+ // codex retries 5xx up to 4 more times; a retried 504 is acceptable for a transient hang.
206
+ return formatErrorResponse(504, "upstream_error", `image ${endpoint} upstream timed out`);
207
+ }
208
+ recordOutcome?.("connect_error");
209
+ return formatErrorResponse(
210
+ 502,
211
+ "upstream_error",
212
+ `image ${endpoint} relay failed: ${err instanceof Error ? err.message : String(err)}`,
213
+ );
214
+ } finally {
215
+ sidecarExit();
216
+ linkedSignal.cleanup();
217
+ }
218
+ }
@@ -59,6 +59,7 @@ export {
59
59
  } from "./lifecycle";
60
60
  import {
61
61
  addFinalRequestLog,
62
+ httpStatusForRequestLogTerminal,
62
63
  httpStatusForTerminalStatus,
63
64
  inspectResponseLogSsePayload,
64
65
  nextRequestLogId,
@@ -116,6 +117,7 @@ export {
116
117
  } from "./auth-cors";
117
118
  import { disableResponsesRequestTimeout, handleResponses, handleResponsesCompact } from "./responses";
118
119
  export { disableResponsesRequestTimeout, linkAbortSignal } from "./responses";
120
+ import { handleImages } from "./images";
119
121
  import { fetchAllModels, handleManagementAPI, VERSION } from "./management-api";
120
122
 
121
123
  const MAX_WS_FRAME_BYTES = 50 * 1024 * 1024;
@@ -304,6 +306,31 @@ export function startServer(port?: number) {
304
306
  return withCors(await handleResponsesCompact(req, config), req, config);
305
307
  }
306
308
 
309
+ if (
310
+ req.method === "POST"
311
+ && (url.pathname === "/v1/images/generations" || url.pathname === "/v1/images/edits")
312
+ ) {
313
+ disableResponsesRequestTimeout(req, requestServer);
314
+ if (isDraining()) {
315
+ return new Response("Service shutting down", {
316
+ status: 503,
317
+ headers: { ...corsHeaders(req, config), "Retry-After": "5" },
318
+ });
319
+ }
320
+ const apiAuthError = requireApiAuth(req, config, "data-plane");
321
+ if (apiAuthError) return withCors(apiAuthError, req, config);
322
+ if (!isAllowedRequestOrigin(req, config)) {
323
+ return withCors(formatErrorResponse(403, "origin_rejected", "cross-origin data-plane request blocked"), req, config);
324
+ }
325
+ const start = Date.now();
326
+ const requestId = nextRequestLogId(start);
327
+ const logCtx: RequestLogContext = { model: "image_gen", provider: "unknown" };
328
+ const endpoint = url.pathname.endsWith("/edits") ? "edits" as const : "generations" as const;
329
+ const response = await handleImages(req, config, endpoint, logCtx);
330
+ addFinalRequestLog(requestId, start, logCtx, response.status, response.status === 499 ? { closeReason: "client_cancel" } : undefined);
331
+ return withCors(response, req, config);
332
+ }
333
+
307
334
  if (url.pathname === "/v1/responses" && req.method === "POST") {
308
335
  disableResponsesRequestTimeout(req, requestServer);
309
336
  if (isDraining()) {
@@ -343,7 +370,7 @@ export function startServer(port?: number) {
343
370
 
344
371
  // Data-plane guard: unknown /v1/* paths must fail with JSON 404, never fall through to the
345
372
  // GUI static handler (extensionless paths would get index.html with HTTP 200 and codex-rs
346
- // endpoint clients — alpha/search, images/*, memories/*, realtime/* — would surface confusing
373
+ // endpoint clients — alpha/search, memories/*, realtime/* — would surface confusing
347
374
  // serde decode errors instead of a clean not-found).
348
375
  if (url.pathname.startsWith("/v1/")) {
349
376
  return withCors(formatErrorResponse(404, "not_found", `Unknown endpoint: ${req.method} ${url.pathname}`), req, config);
@@ -485,7 +512,7 @@ export function startServer(port?: number) {
485
512
  onSsePayload: payload => inspectResponseLogSsePayload(logCtx, payload),
486
513
  onTerminal: status => {
487
514
  terminalRecorder?.(status);
488
- finalizeLog(httpStatusForTerminalStatus(status), {
515
+ finalizeLog(httpStatusForRequestLogTerminal(status, logCtx), {
489
516
  terminalStatus: status,
490
517
  closeReason: "terminal",
491
518
  });
@@ -374,7 +374,7 @@ export function codexForwardTerminalOutcomeRecorder(
374
374
  * zstd-compressed screenshot history exceeds the limit), or a genuine JSON syntax error (400). The
375
375
  * real decode error was previously swallowed, so log it before returning the generic 400.
376
376
  */
377
- function decodeRequestErrorResponse(err: unknown, label: string): Response {
377
+ export function decodeRequestErrorResponse(err: unknown, label: string): Response {
378
378
  if (err instanceof UnsupportedContentEncodingError) {
379
379
  return formatErrorResponse(415, "invalid_request_error", err.message);
380
380
  }
@@ -810,6 +810,7 @@ export async function handleResponses(
810
810
  abortSignal: options.abortSignal,
811
811
  recordSidecarOutcome,
812
812
  connectTimeoutMs: config.connectTimeoutMs ?? 200_000,
813
+ routedModelStallTimeoutMs: wsPlan.routedModelStallTimeoutMs,
813
814
  stallTimeoutSec: wsPlan.stallTimeoutSec,
814
815
  on429: retryAfter => {
815
816
  const rotated = rotateKeyOn429(config, route.providerName, retryAfter, Date.now(), route.provider.apiKey);
package/src/types.ts CHANGED
@@ -312,6 +312,8 @@ export interface OcxConfig {
312
312
  webSearchSidecar?: OcxWebSearchSidecarConfig;
313
313
  /** Vision sidecar: describe images via a gpt vision model so text-only models can "see" them. */
314
314
  visionSidecar?: OcxVisionSidecarConfig;
315
+ /** /v1/images relay for codex's built-in image_gen tool. */
316
+ images?: OcxImagesConfig;
315
317
  /** Codex multi-account pool. */
316
318
  codexAccounts?: CodexAccount[];
317
319
  /** Active pool account id for next session. undefined = main (passthrough as-is). */
@@ -357,6 +359,11 @@ export interface OcxTokenGuardianConfig {
357
359
  codexWarmupModel?: string;
358
360
  }
359
361
 
362
+ export interface OcxImagesConfig {
363
+ /** Upstream timeout (ms) for one /v1/images relay. Default 300000 — generation is slow. */
364
+ timeoutMs?: number;
365
+ }
366
+
360
367
  export interface OcxVisionSidecarConfig {
361
368
  /** Master switch. Default: enabled when a forward (ChatGPT) provider exists and the caller is logged in. */
362
369
  enabled?: boolean;
@@ -377,6 +384,11 @@ export interface OcxWebSearchSidecarConfig {
377
384
  maxSearchesPerTurn?: number;
378
385
  /** Sidecar fetch timeout (ms). */
379
386
  timeoutMs?: number;
387
+ /**
388
+ * Config-file-only deadline (ms) for continuous routed-model response-body raw-byte inactivity
389
+ * during a web-search turn. Default 200000. Must be an integer from 1 through 2147483647.
390
+ */
391
+ routedModelStallTimeoutMs?: number;
380
392
  }
381
393
 
382
394
  export interface OcxProviderConfig {