@bitkyc08/opencodex 2.7.6 → 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.
@@ -16,8 +16,8 @@
16
16
  } catch (e) {}
17
17
  })();
18
18
  </script>
19
- <script type="module" crossorigin src="/assets/index-mEjIne-M.js"></script>
20
- <link rel="stylesheet" crossorigin href="/assets/index-D7o1qwy-.css">
19
+ <script type="module" crossorigin src="/assets/index-DzEDGLZh.js"></script>
20
+ <link rel="stylesheet" crossorigin href="/assets/index-C0xVu72_.css">
21
21
  </head>
22
22
  <body>
23
23
  <div id="root"></div>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bitkyc08/opencodex",
3
- "version": "2.7.6",
3
+ "version": "2.7.7",
4
4
  "description": "Universal provider proxy for OpenAI Codex — use any LLM with Codex CLI/App/SDK",
5
5
  "type": "module",
6
6
  "main": "./bin/package-main.mjs",
@@ -21,6 +21,7 @@ export const FORWARD_HEADERS = [
21
21
  "x-codex-turn-state",
22
22
  "x-codex-window-id",
23
23
  "x-oai-attestation",
24
+ "x-openai-subagent",
24
25
  "x-responsesapi-include-timing-metrics",
25
26
  ];
26
27
 
@@ -174,6 +175,46 @@ function stripPreviousResponseId(body: unknown, strip: boolean): unknown {
174
175
  return rest;
175
176
  }
176
177
 
178
+ /**
179
+ * Hosted tool types whose server-side function names collide with the client tools Codex
180
+ * declares for the matching app skill. Codex sends BOTH (e.g. hosted `image_generation` plus a
181
+ * declared `image_gen.imagegen` function/namespace tool for the imagegen skill). The ChatGPT
182
+ * backend tolerates the pair, but the platform `/v1/responses` rejects it:
183
+ * `Invalid Value: 'tools'. Function 'image_gen.imagegen' conflicts with a hosted tool in the
184
+ * same request.` Keyed hosted-type → conflicting client tool-name prefix; the hosted entry is
185
+ * dropped (the declared tool wins — Codex executes the skill client-side either way).
186
+ */
187
+ const HOSTED_TOOL_NAME_CONFLICTS: ReadonlyArray<{ hostedType: string; namePrefix: string }> = [
188
+ { hostedType: "image_generation", namePrefix: "image_gen" },
189
+ ];
190
+
191
+ /**
192
+ * Drop hosted tools whose names collide with declared function/namespace tools (see
193
+ * HOSTED_TOOL_NAME_CONFLICTS). Only applies on the API-key platform path: the ChatGPT backend
194
+ * ("forward" mode) accepts the pair, and stripping there would disable native imagegen. No-op
195
+ * (returns the original reference) when nothing matches.
196
+ */
197
+ function stripConflictingHostedTools(body: unknown): unknown {
198
+ if (!isPlainObject(body) || !Array.isArray(body.tools)) return body;
199
+ const allTools = body.tools;
200
+
201
+ const conflicting = HOSTED_TOOL_NAME_CONFLICTS.filter(c =>
202
+ allTools.some(t => {
203
+ if (!isPlainObject(t) || typeof t.name !== "string") return false;
204
+ if (t.type === "namespace") return t.name === c.namePrefix;
205
+ return t.name === c.namePrefix || t.name.startsWith(`${c.namePrefix}.`);
206
+ }),
207
+ );
208
+ if (conflicting.length === 0) return body;
209
+
210
+ const tools = allTools.filter(t => {
211
+ const type = isPlainObject(t) && typeof t.type === "string" ? t.type : undefined;
212
+ if (!type) return true;
213
+ return !conflicting.some(c => c.hostedType === type);
214
+ });
215
+ return tools.length === allTools.length ? body : { ...body, tools };
216
+ }
217
+
177
218
  /**
178
219
  * Remove hosted tool entries the target native slug rejects, so the OAuth-passthrough body never
179
220
  * carries a tool the upstream model 400s on. No-op (returns the original reference) when nothing
@@ -236,6 +277,7 @@ export function createResponsesPassthroughAdapter(provider: OcxProviderConfig):
236
277
  forward || parsed._previousResponseInputExpanded === true,
237
278
  );
238
279
  if (forward) outBody = repairOrphanedInputItems(outBody, unexpandedMiss);
280
+ else outBody = stripConflictingHostedTools(outBody);
239
281
  return {
240
282
  url,
241
283
  method: "POST",
@@ -569,6 +569,18 @@ function codexCommandCandidates(): string[] {
569
569
  return unique(candidates);
570
570
  }
571
571
 
572
+ /**
573
+ * Windows probe guard: only PE/batch launchers can be spawned as processes. Anything
574
+ * else pulled from the shim state (the extensionless Git-Bash sh backup
575
+ * `codex.opencodex-real`, `.ps1` scripts) risks falling through to the cmd/ShellExecute
576
+ * document-association path — Windows then OPENS the file in the user's editor
577
+ * (e.g. VS Code) on every `codex` launch instead of executing it.
578
+ */
579
+ export function isSpawnableCodexCandidate(path: string, platform: NodeJS.Platform = process.platform): boolean {
580
+ if (platform !== "win32") return true;
581
+ return /\.(cmd|bat|exe|com)$/i.test(path);
582
+ }
583
+
572
584
  function codexShimCommandCandidates(): string[] {
573
585
  try {
574
586
  const state = JSON.parse(readFileSync(join(getConfigDir(), "codex-shim.json"), "utf8")) as {
@@ -582,7 +594,7 @@ function codexShimCommandCandidates(): string[] {
582
594
  for (const file of files) {
583
595
  for (const value of [file.backupPath, file.originalPath, file.wrapperPath]) {
584
596
  if (typeof value !== "string" || value.length === 0) continue;
585
- if (process.platform === "win32" && value.toLowerCase().endsWith(".ps1")) continue;
597
+ if (!isSpawnableCodexCandidate(value)) continue;
586
598
  out.push(value);
587
599
  }
588
600
  }
@@ -459,6 +459,10 @@ export const PROVIDER_REGISTRY: readonly ProviderRegistryEntry[] = [
459
459
  modelReasoningEfforts: Object.fromEntries(DEEPSEEK_THINKING_MODELS.map(id => [id, DEEPSEEK_THINKING_EFFORTS])),
460
460
  modelReasoningEffortMap: Object.fromEntries(DEEPSEEK_THINKING_MODELS.map(id => [id, DEEPSEEK_THINKING_REASONING_MAP])),
461
461
  preserveReasoningContentModels: DEEPSEEK_THINKING_MODELS,
462
+ // Issue #88: every DeepSeek API model is text-only input (no image support upstream) — the
463
+ // vision sidecar describes attached images for them, and the catalog advertises image input
464
+ // on their behalf (same treatment as opencode-go's DeepSeek V4 entries above).
465
+ noVisionModels: ["deepseek-chat", "deepseek-reasoner", ...DEEPSEEK_THINKING_MODELS],
462
466
  },
463
467
  // llama-3.3-70b was deprecated by Cerebras on 2026-02-16. Evidence: devlog/_plan/260710_provider_hardening/003_research_aggregators.md.
464
468
  { id: "cerebras", label: "Cerebras", baseUrl: "https://api.cerebras.ai/v1", adapter: "openai-chat", authKind: "key", dashboardUrl: "https://cloud.cerebras.ai/platform/apikeys", defaultModel: "gpt-oss-120b" },
@@ -19,6 +19,11 @@ export function isCodexReasoningEffort(effort: string): boolean {
19
19
  return CODEX_REASONING_SET.has(effort);
20
20
  }
21
21
 
22
+ /** Position of `effort` in the Codex ladder (low=0 .. ultra=5), or -1 when not a ladder member. */
23
+ export function codexEffortRank(effort: string): number {
24
+ return CODEX_REASONING_ORDER.indexOf(effort);
25
+ }
26
+
22
27
  export function modelRecordValue<T>(record: Record<string, T> | undefined, modelId: string): T | undefined {
23
28
  if (!record) return undefined;
24
29
  if (Object.prototype.hasOwnProperty.call(record, modelId)) return record[modelId];
@@ -0,0 +1,172 @@
1
+ /**
2
+ * Hard reasoning-effort caps (devlog/260710_subagent_effort_intercept).
3
+ *
4
+ * Prompt-side effort designation (injectionEffort) is advisory only: codex-rs inherits the
5
+ * parent's effective effort when spawn_agent carries no model/effort args
6
+ * (multi_agents_common.rs resolve defaults), rejects overrides on full-history forks, and a
7
+ * non-empty agent-role file rebuilds the child Config and silently drops spawn-time
8
+ * model/effort. So a session whose config default is ultra leaks max-tier children whenever
9
+ * the parent model spawns bare. This module is the enforcement path: it rewrites the effort
10
+ * of proxied turns at the single choke point every HTTP/WS turn passes through
11
+ * (handleResponses), using the same dual-shape rewrite contract as nativeEffortClamp —
12
+ * parsed.options.reasoning feeds routed adapters, _rawBody.reasoning.effort feeds the
13
+ * ChatGPT passthrough serializer.
14
+ */
15
+ import type { OcxConfig, OcxParsedRequest, OcxProviderConfig } from "../types";
16
+ import { modelInList } from "../types";
17
+ import { codexEffortRank, configuredReasoningEfforts, isCodexReasoningEffort, modelRecordValue } from "../reasoning-effort";
18
+ import { catalogModelEfforts } from "../codex/catalog";
19
+
20
+ /**
21
+ * True when the request carries codex-rs's spawned-child markers, matched EXACTLY.
22
+ * Source of truth (openai/codex @ 6138909d): every collab-spawned child turn sends
23
+ * `x-openai-subagent: collab_spawn` (core/src/responses_metadata.rs) and embeds
24
+ * `"subagent_kind":"thread_spawn"` in the JSON `x-codex-turn-metadata` compatibility
25
+ * header. Both are checked: the WS bridge rebuilds internal requests from the
26
+ * FORWARD_HEADERS allowlist, so either header alone is sufficient evidence.
27
+ *
28
+ * Exact matching matters: upstream emits `x-openai-subagent` for OTHER internal
29
+ * turn categories too (review, compact, memory_consolidation, arbitrary "other"
30
+ * sources — responses_metadata.rs subagent_source). Those are maintenance turns,
31
+ * not spawned children, and must never trip subagentEffortCap.
32
+ */
33
+ export function isThreadSpawnRequest(headers: Headers): boolean {
34
+ if (headers.get("x-openai-subagent") === "collab_spawn") return true;
35
+ const turnMeta = headers.get("x-codex-turn-metadata");
36
+ if (!turnMeta) return false;
37
+ try {
38
+ const parsed = JSON.parse(turnMeta) as { subagent_kind?: unknown };
39
+ return parsed.subagent_kind === "thread_spawn";
40
+ } catch {
41
+ return false;
42
+ }
43
+ }
44
+
45
+ /** The effective ceiling for this turn, or undefined when no configured cap applies. */
46
+ export function effortCapFor(config: OcxConfig, subagent: boolean): string | undefined {
47
+ const caps: string[] = [];
48
+ if (config.effortCap && isCodexReasoningEffort(config.effortCap)) caps.push(config.effortCap);
49
+ if (subagent && config.subagentEffortCap && isCodexReasoningEffort(config.subagentEffortCap)) {
50
+ caps.push(config.subagentEffortCap);
51
+ }
52
+ if (caps.length === 0) return undefined;
53
+ return caps.reduce((low, cap) => (codexEffortRank(cap) < codexEffortRank(low) ? cap : low));
54
+ }
55
+
56
+ /**
57
+ * Whether the effort caps apply to this turn at all. Caps are a V2-surface feature
58
+ * (v1 sub-agents are pinned via explicit spawn args + injectionEffort prompting, so
59
+ * the ultra-default leak this module intercepts is v2-specific):
60
+ * - compaction turns are maintenance, not agent turns: they bypass caps entirely so
61
+ * native /v1/responses/compact (forwarded, never enters handleResponses) and routed
62
+ * compaction (synthesized internal request) get identical cap semantics.
63
+ * - multiAgentMode "v1" disables caps entirely (mirrors the GUI hiding the panel).
64
+ * - a main turn qualifies when its own tool list carries the v2 collab surface.
65
+ * - a CHILD turn is admitted by its spawned-child markers (isThreadSpawnRequest)
66
+ * REGARDLESS of tool surface: depth-limited leaves carry no collab tools (surface
67
+ * null) while children below the spawn-depth limit retain collab tools (spec_plan.rs
68
+ * leaf guard), so tool sniffing alone would cap siblings inconsistently.
69
+ * - a v1-surface MAIN turn (no child markers) never qualifies.
70
+ */
71
+ export function effortCapAppliesTo(
72
+ surface: "v1" | "v2" | null,
73
+ headers: Headers,
74
+ config: OcxConfig,
75
+ compaction = false,
76
+ ): boolean {
77
+ if (compaction) return false;
78
+ if (config.multiAgentMode === "v1") return false;
79
+ return surface === "v2" || isThreadSpawnRequest(headers);
80
+ }
81
+
82
+ /**
83
+ * The routed model's supported effort ladder for cap resolution, from the ROUTE's
84
+ * registry-merged provider (router.ts routedProviderConfig) — the persisted
85
+ * config.providers entry misses registry seeds, and bare ids can route via
86
+ * defaultModel/model-list/default-provider, so no "/" heuristic anywhere.
87
+ *
88
+ * - `[]` -> the model intentionally exposes no effort control (noReasoningModels or
89
+ * an explicitly empty configured ladder): cap resolution strips.
90
+ * - list -> sanitized + healed ladder (configuredReasoningEfforts).
91
+ * - undefined -> unknown. Includes the raw-nonempty-but-non-rankable case (e.g. a
92
+ * thinking-toggle ladder of ["enabled"]): sanitizing would flatten it to []
93
+ * and mis-classify it as "no effort control", so it stays unknown.
94
+ *
95
+ * Catalog fallback fires only for the ChatGPT-backend native passthrough IDENTITY
96
+ * (adapter "openai-responses" + authMode "forward", the fresh-install `openai`
97
+ * provider shape): the injected catalog is authoritative exactly for models Codex
98
+ * validates against that backend. A custom responses provider (key mode) serving a
99
+ * native-looking bare id must NOT inherit the unrelated native ladder.
100
+ */
101
+ export function supportedLadderFor(route: { provider: OcxProviderConfig; modelId: string }): string[] | undefined {
102
+ const { provider, modelId } = route;
103
+ if (modelInList(provider.noReasoningModels, modelId)) return [];
104
+ const raw = modelRecordValue(provider.modelReasoningEfforts, modelId) ?? provider.reasoningEfforts;
105
+ if (raw !== undefined) {
106
+ const sanitized = configuredReasoningEfforts(provider, modelId) ?? [];
107
+ if (sanitized.length === 0 && raw.length > 0) return undefined;
108
+ return sanitized;
109
+ }
110
+ if (provider.adapter === "openai-responses" && provider.authMode === "forward") {
111
+ const efforts = catalogModelEfforts([modelId]).get(modelId);
112
+ if (efforts && efforts.length > 0) return efforts;
113
+ }
114
+ return undefined;
115
+ }
116
+
117
+ /**
118
+ * Resolve the configured cap against the model's supported ladder. Returns the effective
119
+ * ceiling rung, or null when the turn must be STRIPPED of its effort entirely. The cap
120
+ * NEVER raises: when rankable rungs exist but none sits at or below the cap, the model
121
+ * cannot run within the ceiling, so the effort is stripped and the provider default
122
+ * applies (never a rung above the cap).
123
+ */
124
+ export function resolveCappedEffort(cap: string, supported: readonly string[] | undefined): string | null {
125
+ if (supported === undefined) return cap;
126
+ const rankable = supported.filter(isCodexReasoningEffort);
127
+ if (rankable.length === 0) {
128
+ // Nonempty but non-rankable (e.g. ["enabled"]) -> unknown ladder, cap as-is.
129
+ // Genuinely empty -> no effort control at all -> strip.
130
+ return supported.length > 0 ? cap : null;
131
+ }
132
+ const capRank = codexEffortRank(cap);
133
+ let best: string | null = null;
134
+ for (const rung of rankable) {
135
+ const rank = codexEffortRank(rung);
136
+ if (rank <= capRank && (best === null || rank > codexEffortRank(best))) best = rung;
137
+ }
138
+ return best;
139
+ }
140
+
141
+ /**
142
+ * Cap the turn's reasoning effort in BOTH request shapes. Non-strip resolution only
143
+ * lowers: efforts at or below the resolved ceiling (and non-ladder/absent efforts) pass
144
+ * untouched. Strip resolution (model exposes no effort control, or no supported rung
145
+ * fits under the cap) removes whatever effort is present — regardless of its rank —
146
+ * from both shapes while preserving `reasoning.summary`. Returns the applied rewrite
147
+ * for request-log annotation (`to: "none"` on strip), or null when nothing changed.
148
+ */
149
+ export function applyEffortCap(
150
+ parsed: OcxParsedRequest,
151
+ headers: Headers,
152
+ config: OcxConfig,
153
+ supported?: readonly string[] | undefined,
154
+ ): { from: string; to: string; subagent: boolean } | null {
155
+ const subagent = isThreadSpawnRequest(headers);
156
+ const cap = effortCapFor(config, subagent);
157
+ if (!cap) return null;
158
+ const resolved = resolveCappedEffort(cap, supported);
159
+ const requested = parsed.options.reasoning;
160
+ const raw = parsed._rawBody as { reasoning?: { effort?: string } } | undefined;
161
+ if (resolved === null) {
162
+ if (!requested) return null;
163
+ parsed.options.reasoning = undefined;
164
+ if (raw?.reasoning && typeof raw.reasoning === "object") delete raw.reasoning.effort;
165
+ return { from: requested, to: "none", subagent };
166
+ }
167
+ if (!requested || !isCodexReasoningEffort(requested)) return null;
168
+ if (codexEffortRank(requested) <= codexEffortRank(resolved)) return null;
169
+ parsed.options.reasoning = resolved;
170
+ if (raw?.reasoning && typeof raw.reasoning === "object") raw.reasoning.effort = resolved;
171
+ return { from: requested, to: resolved, subagent };
172
+ }
@@ -118,6 +118,7 @@ export {
118
118
  import { disableResponsesRequestTimeout, handleResponses, handleResponsesCompact } from "./responses";
119
119
  export { disableResponsesRequestTimeout, linkAbortSignal } from "./responses";
120
120
  import { handleImages } from "./images";
121
+ import { handleSearch } from "./search";
121
122
  import { fetchAllModels, handleManagementAPI, VERSION } from "./management-api";
122
123
 
123
124
  const MAX_WS_FRAME_BYTES = 50 * 1024 * 1024;
@@ -331,6 +332,33 @@ export function startServer(port?: number) {
331
332
  return withCors(response, req, config);
332
333
  }
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
+
334
362
  if (url.pathname === "/v1/responses" && req.method === "POST") {
335
363
  disableResponsesRequestTimeout(req, requestServer);
336
364
  if (isDraining()) {
@@ -370,7 +398,7 @@ export function startServer(port?: number) {
370
398
 
371
399
  // Data-plane guard: unknown /v1/* paths must fail with JSON 404, never fall through to the
372
400
  // GUI static handler (extensionless paths would get index.html with HTTP 200 and codex-rs
373
- // endpoint clients — alpha/search, memories/*, realtime/* — would surface confusing
401
+ // endpoint clients — memories/*, realtime/* — would surface confusing
374
402
  // serde decode errors instead of a clean not-found).
375
403
  if (url.pathname.startsWith("/v1/")) {
376
404
  return withCors(formatErrorResponse(404, "not_found", `Unknown endpoint: ${req.method} ${url.pathname}`), req, config);
@@ -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") {
@@ -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
@@ -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:
@@ -314,6 +328,8 @@ export interface OcxConfig {
314
328
  visionSidecar?: OcxVisionSidecarConfig;
315
329
  /** /v1/images relay for codex's built-in image_gen tool. */
316
330
  images?: OcxImagesConfig;
331
+ /** /v1/alpha/search relay for codex's built-in web search client. */
332
+ search?: OcxSearchConfig;
317
333
  /** Codex multi-account pool. */
318
334
  codexAccounts?: CodexAccount[];
319
335
  /** Active pool account id for next session. undefined = main (passthrough as-is). */
@@ -364,6 +380,15 @@ export interface OcxImagesConfig {
364
380
  timeoutMs?: number;
365
381
  }
366
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
+
367
392
  export interface OcxVisionSidecarConfig {
368
393
  /** Master switch. Default: enabled when a forward (ChatGPT) provider exists and the caller is logged in. */
369
394
  enabled?: boolean;