@bitkyc08/opencodex 2.7.4 → 2.7.7

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,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,8 @@ 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";
121
+ import { handleSearch } from "./search";
119
122
  import { fetchAllModels, handleManagementAPI, VERSION } from "./management-api";
120
123
 
121
124
  const MAX_WS_FRAME_BYTES = 50 * 1024 * 1024;
@@ -304,6 +307,58 @@ export function startServer(port?: number) {
304
307
  return withCors(await handleResponsesCompact(req, config), req, config);
305
308
  }
306
309
 
310
+ if (
311
+ req.method === "POST"
312
+ && (url.pathname === "/v1/images/generations" || url.pathname === "/v1/images/edits")
313
+ ) {
314
+ disableResponsesRequestTimeout(req, requestServer);
315
+ if (isDraining()) {
316
+ return new Response("Service shutting down", {
317
+ status: 503,
318
+ headers: { ...corsHeaders(req, config), "Retry-After": "5" },
319
+ });
320
+ }
321
+ const apiAuthError = requireApiAuth(req, config, "data-plane");
322
+ if (apiAuthError) return withCors(apiAuthError, req, config);
323
+ if (!isAllowedRequestOrigin(req, config)) {
324
+ return withCors(formatErrorResponse(403, "origin_rejected", "cross-origin data-plane request blocked"), req, config);
325
+ }
326
+ const start = Date.now();
327
+ const requestId = nextRequestLogId(start);
328
+ const logCtx: RequestLogContext = { model: "image_gen", provider: "unknown" };
329
+ const endpoint = url.pathname.endsWith("/edits") ? "edits" as const : "generations" as const;
330
+ const response = await handleImages(req, config, endpoint, logCtx);
331
+ addFinalRequestLog(requestId, start, logCtx, response.status, response.status === 499 ? { closeReason: "client_cancel" } : undefined);
332
+ return withCors(response, req, config);
333
+ }
334
+
335
+ if (url.pathname === "/v1/alpha/search" && req.method === "POST") {
336
+ disableResponsesRequestTimeout(req, requestServer);
337
+ if (isDraining()) {
338
+ return new Response("Service shutting down", {
339
+ status: 503,
340
+ headers: { ...corsHeaders(req, config), "Retry-After": "5" },
341
+ });
342
+ }
343
+ const apiAuthError = requireApiAuth(req, config, "data-plane");
344
+ if (apiAuthError) return withCors(apiAuthError, req, config);
345
+ if (!isAllowedRequestOrigin(req, config)) {
346
+ return withCors(formatErrorResponse(403, "origin_rejected", "cross-origin data-plane request blocked"), req, config);
347
+ }
348
+ const start = Date.now();
349
+ const requestId = nextRequestLogId(start);
350
+ const logCtx: RequestLogContext = { model: "web_search", provider: "unknown" };
351
+ const response = await handleSearch(req, config, logCtx);
352
+ addFinalRequestLog(
353
+ requestId,
354
+ start,
355
+ logCtx,
356
+ response.status,
357
+ response.status === 499 ? { closeReason: "client_cancel" } : undefined,
358
+ );
359
+ return withCors(response, req, config);
360
+ }
361
+
307
362
  if (url.pathname === "/v1/responses" && req.method === "POST") {
308
363
  disableResponsesRequestTimeout(req, requestServer);
309
364
  if (isDraining()) {
@@ -343,7 +398,7 @@ export function startServer(port?: number) {
343
398
 
344
399
  // Data-plane guard: unknown /v1/* paths must fail with JSON 404, never fall through to the
345
400
  // 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
401
+ // endpoint clients — memories/*, realtime/* — would surface confusing
347
402
  // serde decode errors instead of a clean not-found).
348
403
  if (url.pathname.startsWith("/v1/")) {
349
404
  return withCors(formatErrorResponse(404, "not_found", `Unknown endpoint: ${req.method} ${url.pathname}`), req, config);
@@ -485,7 +540,7 @@ export function startServer(port?: number) {
485
540
  onSsePayload: payload => inspectResponseLogSsePayload(logCtx, payload),
486
541
  onTerminal: status => {
487
542
  terminalRecorder?.(status);
488
- finalizeLog(httpStatusForTerminalStatus(status), {
543
+ finalizeLog(httpStatusForRequestLogTerminal(status, logCtx), {
489
544
  terminalStatus: status,
490
545
  closeReason: "terminal",
491
546
  });
@@ -572,6 +572,34 @@ export async function handleManagementAPI(req: Request, url: URL, config: OcxCon
572
572
  return jsonResponse({ ok: true, model: config.injectionModel ?? null, effort: config.injectionEffort ?? null, prompt: config.injectionPrompt ?? null });
573
573
  }
574
574
 
575
+ // Hard reasoning-effort caps (devlog/260710_subagent_effort_intercept): a global ceiling and a
576
+ // sub-agent-only ceiling, enforced per-request in handleResponses (src/server/effort-policy.ts).
577
+ // Key semantics per field: absent -> unchanged; null/"" -> clear; ladder value -> set; else 400.
578
+ if (url.pathname === "/api/effort-caps" && req.method === "GET") {
579
+ const { CODEX_REASONING_LEVELS } = await import("../reasoning-effort");
580
+ return jsonResponse({
581
+ effortCap: config.effortCap ?? null,
582
+ subagentEffortCap: config.subagentEffortCap ?? null,
583
+ efforts: CODEX_REASONING_LEVELS.map(l => l.effort),
584
+ });
585
+ }
586
+ if (url.pathname === "/api/effort-caps" && req.method === "PUT") {
587
+ let body: { effortCap?: unknown; subagentEffortCap?: unknown };
588
+ try { body = await req.json(); } catch { return jsonResponse({ error: "invalid JSON body" }, 400); }
589
+ const { isCodexReasoningEffort } = await import("../reasoning-effort");
590
+ for (const key of ["effortCap", "subagentEffortCap"] as const) {
591
+ if (!(key in body)) continue;
592
+ const value = body[key];
593
+ if (value === null || value === "") { delete config[key]; continue; }
594
+ if (typeof value !== "string" || !isCodexReasoningEffort(value)) {
595
+ return jsonResponse({ error: `unknown reasoning effort "${String(value)}"` }, 400);
596
+ }
597
+ config[key] = value;
598
+ }
599
+ saveConfig(config);
600
+ return jsonResponse({ ok: true, effortCap: config.effortCap ?? null, subagentEffortCap: config.subagentEffortCap ?? null });
601
+ }
602
+
575
603
  // Subagent model picker: which ≤5 routed models Codex's spawn_agent advertises (it shows the
576
604
  // first 5 routed catalog entries). PUT reorders the injected catalog so the chosen ones lead.
577
605
  if (url.pathname === "/api/subagent-models" && req.method === "GET") {
@@ -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
  }
@@ -470,6 +470,33 @@ export async function handleResponses(
470
470
  }
471
471
  }
472
472
 
473
+ // Hard effort caps (effortCap / subagentEffortCap): enforcement companion to the advisory
474
+ // injection above — spawn-arg prompting cannot stop codex-rs from inheriting the parent's
475
+ // ultra-tier default on bare spawns (see src/server/effort-policy.ts). Runs BEFORE the
476
+ // mock-max clamp so a capped effort is what nativeness clamping then validates; rewrites
477
+ // both request shapes (same dual-write contract as the clamp below).
478
+ // GATE: v2 feature only (effortCapAppliesTo) — v2-surface main turns plus header-marked
479
+ // child turns admitted regardless of tool surface (depth-limited leaves carry no collab
480
+ // tools while shallower children do, so tool sniffing alone would cap siblings
481
+ // inconsistently); multiAgentMode "v1" disables caps entirely; compaction turns bypass
482
+ // caps so routed compaction matches native /v1/responses/compact (which never enters
483
+ // handleResponses).
484
+ {
485
+ const { applyEffortCap, effortCapAppliesTo, supportedLadderFor } = await import("./effort-policy");
486
+ const surface = collabSurface(parsed);
487
+ if (effortCapAppliesTo(surface, req.headers, config, parsed._compactionRequest === true)) {
488
+ const capped = applyEffortCap(parsed, req.headers, config, supportedLadderFor(route));
489
+ if (capped) {
490
+ logCtx.requestedEffort = `${capped.from}->${capped.to}`;
491
+ if (isInjectionDebugEnabled()) {
492
+ console.log(`[opencodex] ${route.modelId}: effort cap applied (${capped.from} -> ${capped.to}, ${capped.subagent ? "sub-agent" : "main"} turn)`);
493
+ }
494
+ }
495
+ } else if (isInjectionDebugEnabled() && (config.effortCap || config.subagentEffortCap)) {
496
+ console.log(`[opencodex] ${route.modelId}: effort cap skipped (surface=${surface ?? "none"}, v2 feature only)`);
497
+ }
498
+ }
499
+
473
500
  // Mock-max clamp: native models whose real ladder stops below max (gpt-5.5/5.4/…)
474
501
  // receive `max` when the user picks Ultra (codex converts ultra->max client-side).
475
502
  // Clamp to the model's highest real effort BEFORE any adapter — the ChatGPT
@@ -810,6 +837,7 @@ export async function handleResponses(
810
837
  abortSignal: options.abortSignal,
811
838
  recordSidecarOutcome,
812
839
  connectTimeoutMs: config.connectTimeoutMs ?? 200_000,
840
+ routedModelStallTimeoutMs: wsPlan.routedModelStallTimeoutMs,
813
841
  stallTimeoutSec: wsPlan.stallTimeoutSec,
814
842
  on429: retryAfter => {
815
843
  const rotated = rotateKeyOn429(config, route.providerName, retryAfter, Date.now(), route.provider.apiKey);
@@ -0,0 +1,150 @@
1
+ /**
2
+ * /v1/alpha/search relay.
3
+ *
4
+ * codex-rs's built-in search client executes CLIENT-SIDE: it POSTs `alpha/search` against the
5
+ * configured base_url with the same ChatGPT bearer auth used for model requests. Under Design B
6
+ * injection base_url is this proxy, so the request otherwise dies on the /v1/* JSON-404 guard.
7
+ * The endpoint is private to the ChatGPT Codex backend, so routed providers and OpenAI API-key
8
+ * providers cannot serve it. Relay the JSON request and response verbatim through the configured
9
+ * ChatGPT forward provider.
10
+ */
11
+ import { formatErrorResponse } from "../bridge";
12
+ import {
13
+ CodexAccountCooldownError,
14
+ CodexAuthContextError,
15
+ CodexThreadAffinityExpiredError,
16
+ headersForCodexAuthContext,
17
+ isCodexAuthContextUsable,
18
+ resolveCodexAuthContext,
19
+ } from "../codex/auth-context";
20
+ import { formatCodexProviderForLog } from "../codex/routing";
21
+ import { signalWithTimeout } from "../lib/abort";
22
+ import { sidecarEnter } from "../lib/sidecar-tracker";
23
+ import type { OcxConfig, OcxProviderConfig } from "../types";
24
+ import { isProxyAdmissionSecret } from "./auth-cors";
25
+ import { readJsonRequestBody } from "./request-decompress";
26
+ import type { RequestLogContext } from "./request-log";
27
+ import { codexLogAccountId, decodeRequestErrorResponse, sidecarOutcomeRecorder } from "./responses";
28
+
29
+ /**
30
+ * Default TOTAL deadline for one search relay. alpha/search is non-streaming JSON — response
31
+ * headers arrive only when the search finishes — so the budget must cover the whole request.
32
+ * Overridable via config.search.timeoutMs; never config.connectTimeoutMs, whose documented
33
+ * contract is the DNS/TCP/TLS/header-arrival budget (a 10s connect budget would kill every
34
+ * long-running search).
35
+ */
36
+ const SEARCH_UPSTREAM_TIMEOUT_MS = 200_000;
37
+ const SEARCH_RESPONSE_MAX_BYTES = 16 * 1024 * 1024;
38
+
39
+ interface NamedProvider {
40
+ name: string;
41
+ provider: OcxProviderConfig;
42
+ }
43
+
44
+ function findSearchUpstream(config: OcxConfig): NamedProvider | undefined {
45
+ for (const [name, provider] of Object.entries(config.providers)) {
46
+ if (provider.disabled !== true && provider.authMode === "forward") return { name, provider };
47
+ }
48
+ return undefined;
49
+ }
50
+
51
+ export async function handleSearch(
52
+ req: Request,
53
+ config: OcxConfig,
54
+ logCtx: RequestLogContext,
55
+ ): Promise<Response> {
56
+ let body: unknown;
57
+ try {
58
+ body = await readJsonRequestBody(req);
59
+ } catch (err) {
60
+ return decodeRequestErrorResponse(err, "search");
61
+ }
62
+ const model = (body as { model?: unknown } | null)?.model;
63
+ if (typeof model === "string" && model) logCtx.model = model;
64
+
65
+ const upstream = findSearchUpstream(config);
66
+ if (!upstream) {
67
+ return formatErrorResponse(
68
+ 400,
69
+ "invalid_request_error",
70
+ "Built-in web search needs a ChatGPT forward provider, but none is configured in opencodex. "
71
+ + "Routed and OpenAI API-key providers cannot serve /v1/alpha/search.",
72
+ );
73
+ }
74
+
75
+ let authHeaders: Headers;
76
+ let recordOutcome: ReturnType<typeof sidecarOutcomeRecorder>;
77
+ try {
78
+ const authCtx = await resolveCodexAuthContext(req.headers, config);
79
+ if (!isCodexAuthContextUsable(authCtx, config)) {
80
+ return formatErrorResponse(401, "authentication_error", "Selected Codex account needs reauthentication");
81
+ }
82
+ authHeaders = headersForCodexAuthContext(req.headers, authCtx);
83
+ const bearer = authHeaders.get("authorization")?.replace(/^Bearer\s+/i, "") ?? "";
84
+ if (bearer && isProxyAdmissionSecret(bearer, config)) authHeaders.delete("authorization");
85
+ if (!authHeaders.get("authorization")) {
86
+ return formatErrorResponse(
87
+ 401,
88
+ "authentication_error",
89
+ "web search relay needs ChatGPT auth (Authorization header)",
90
+ );
91
+ }
92
+ recordOutcome = sidecarOutcomeRecorder(config, authCtx);
93
+ logCtx.provider = formatCodexProviderForLog(upstream.name, codexLogAccountId(authCtx), config);
94
+ } catch (err) {
95
+ if (err instanceof CodexAccountCooldownError) {
96
+ return formatErrorResponse(429, "rate_limit_error", "Selected Codex account is cooling down");
97
+ }
98
+ if (err instanceof CodexThreadAffinityExpiredError) {
99
+ return formatErrorResponse(409, "invalid_request_error", "Codex thread account affinity expired; start a new session");
100
+ }
101
+ if (err instanceof CodexAuthContextError) {
102
+ const safeAccountLabel = formatCodexProviderForLog(upstream.name, err.accountId, config);
103
+ console.error(`[search] Pool account ${safeAccountLabel} token failed; reauthentication required`);
104
+ return formatErrorResponse(401, "authentication_error", "Selected Codex account needs reauthentication");
105
+ }
106
+ throw err;
107
+ }
108
+
109
+ const headers: Record<string, string> = { "content-type": "application/json" };
110
+ if (upstream.provider.headers) Object.assign(headers, upstream.provider.headers);
111
+ for (const [name, value] of authHeaders) headers[name] = value;
112
+ const url = `${upstream.provider.baseUrl}/alpha/search`;
113
+ const timeoutMs = config.search?.timeoutMs ?? SEARCH_UPSTREAM_TIMEOUT_MS;
114
+ const linkedSignal = signalWithTimeout(timeoutMs, req.signal);
115
+ const sidecarExit = sidecarEnter("search");
116
+ try {
117
+ const upstreamResponse = await fetch(url, {
118
+ method: "POST",
119
+ headers,
120
+ body: JSON.stringify(body),
121
+ signal: linkedSignal.signal,
122
+ });
123
+ const payload = await upstreamResponse.arrayBuffer();
124
+ if (payload.byteLength > SEARCH_RESPONSE_MAX_BYTES) {
125
+ return formatErrorResponse(502, "upstream_error", `search response too large (${payload.byteLength} bytes)`);
126
+ }
127
+ recordOutcome?.(upstreamResponse.status);
128
+ const relayHeaders: Record<string, string> = {};
129
+ const contentType = upstreamResponse.headers.get("content-type");
130
+ if (contentType) relayHeaders["content-type"] = contentType;
131
+ return new Response(payload, { status: upstreamResponse.status, headers: relayHeaders });
132
+ } catch (err) {
133
+ if (req.signal.aborted) {
134
+ return formatErrorResponse(499, "client_closed_request", "search request canceled by client");
135
+ }
136
+ if (err instanceof Error && err.name === "TimeoutError") {
137
+ recordOutcome?.("timeout");
138
+ return formatErrorResponse(504, "upstream_error", "search upstream timed out");
139
+ }
140
+ recordOutcome?.("connect_error");
141
+ return formatErrorResponse(
142
+ 502,
143
+ "upstream_error",
144
+ `search relay failed: ${err instanceof Error ? err.message : String(err)}`,
145
+ );
146
+ } finally {
147
+ sidecarExit();
148
+ linkedSignal.cleanup();
149
+ }
150
+ }
package/src/types.ts CHANGED
@@ -259,6 +259,20 @@ export interface OcxConfig {
259
259
  * the resolved sub-agent roster block ("" when nothing resolves).
260
260
  */
261
261
  injectionPrompt?: string;
262
+ /**
263
+ * Global hard ceiling for the reasoning effort of EVERY proxied turn (main agent AND
264
+ * sub-agents). Ladder value "low".."max"; incoming efforts ranking above it are rewritten
265
+ * in both request shapes before any adapter or clamp. Unset = no cap. codex-rs converts
266
+ * ultra -> max client-side, so e.g. a "high" cap sends ultra/max-tier turns as high.
267
+ */
268
+ effortCap?: string;
269
+ /**
270
+ * Hard ceiling applied ONLY to sub-agent turns — requests carrying codex-rs's spawned-child
271
+ * markers (`x-openai-subagent` header, or `subagent_kind` inside `x-codex-turn-metadata`).
272
+ * Lets the main agent keep its tier while delegated children are capped. When both caps are
273
+ * set, the lower one wins for sub-agents. See src/server/effort-policy.ts.
274
+ */
275
+ subagentEffortCap?: string;
262
276
  /**
263
277
  * Models hidden from Codex. Routed ids are namespaced ("<provider>/<model>") and are excluded
264
278
  * from the catalog + /v1/models entirely. BARE ids (no "/") are native GPT passthrough slugs:
@@ -312,6 +326,10 @@ export interface OcxConfig {
312
326
  webSearchSidecar?: OcxWebSearchSidecarConfig;
313
327
  /** Vision sidecar: describe images via a gpt vision model so text-only models can "see" them. */
314
328
  visionSidecar?: OcxVisionSidecarConfig;
329
+ /** /v1/images relay for codex's built-in image_gen tool. */
330
+ images?: OcxImagesConfig;
331
+ /** /v1/alpha/search relay for codex's built-in web search client. */
332
+ search?: OcxSearchConfig;
315
333
  /** Codex multi-account pool. */
316
334
  codexAccounts?: CodexAccount[];
317
335
  /** Active pool account id for next session. undefined = main (passthrough as-is). */
@@ -357,6 +375,20 @@ export interface OcxTokenGuardianConfig {
357
375
  codexWarmupModel?: string;
358
376
  }
359
377
 
378
+ export interface OcxImagesConfig {
379
+ /** Upstream timeout (ms) for one /v1/images relay. Default 300000 — generation is slow. */
380
+ timeoutMs?: number;
381
+ }
382
+
383
+ export interface OcxSearchConfig {
384
+ /**
385
+ * Total upstream deadline (ms) for one /v1/alpha/search relay. Default 200000. The endpoint
386
+ * is non-streaming JSON (headers arrive only when the search completes), so this is a whole-
387
+ * request budget — deliberately NOT connectTimeoutMs, which is a header-arrival budget.
388
+ */
389
+ timeoutMs?: number;
390
+ }
391
+
360
392
  export interface OcxVisionSidecarConfig {
361
393
  /** Master switch. Default: enabled when a forward (ChatGPT) provider exists and the caller is logged in. */
362
394
  enabled?: boolean;
@@ -377,6 +409,11 @@ export interface OcxWebSearchSidecarConfig {
377
409
  maxSearchesPerTurn?: number;
378
410
  /** Sidecar fetch timeout (ms). */
379
411
  timeoutMs?: number;
412
+ /**
413
+ * Config-file-only deadline (ms) for continuous routed-model response-body raw-byte inactivity
414
+ * during a web-search turn. Default 200000. Must be an integer from 1 through 2147483647.
415
+ */
416
+ routedModelStallTimeoutMs?: number;
380
417
  }
381
418
 
382
419
  export interface OcxProviderConfig {