@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.
- package/gui/dist/assets/index-C0xVu72_.css +1 -0
- package/gui/dist/assets/index-DzEDGLZh.js +40 -0
- package/gui/dist/index.html +2 -2
- package/package.json +1 -1
- package/src/adapters/openai-responses.ts +42 -0
- package/src/codex/catalog.ts +13 -1
- package/src/providers/registry.ts +4 -0
- package/src/reasoning-effort.ts +5 -0
- package/src/server/effort-policy.ts +172 -0
- package/src/server/index.ts +29 -1
- package/src/server/management-api.ts +28 -0
- package/src/server/responses.ts +27 -0
- package/src/server/search.ts +150 -0
- package/src/types.ts +25 -0
- package/gui/dist/assets/index-D7o1qwy-.css +0 -1
- package/gui/dist/assets/index-mEjIne-M.js +0 -40
package/gui/dist/index.html
CHANGED
|
@@ -16,8 +16,8 @@
|
|
|
16
16
|
} catch (e) {}
|
|
17
17
|
})();
|
|
18
18
|
</script>
|
|
19
|
-
<script type="module" crossorigin src="/assets/index-
|
|
20
|
-
<link rel="stylesheet" crossorigin href="/assets/index-
|
|
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
|
@@ -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",
|
package/src/codex/catalog.ts
CHANGED
|
@@ -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 (
|
|
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" },
|
package/src/reasoning-effort.ts
CHANGED
|
@@ -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
|
+
}
|
package/src/server/index.ts
CHANGED
|
@@ -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 —
|
|
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") {
|
package/src/server/responses.ts
CHANGED
|
@@ -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;
|