@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.
- package/README.ko.md +6 -3
- package/README.md +6 -4
- package/README.zh-CN.md +4 -3
- 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/anthropic.ts +30 -2
- package/src/adapters/base.ts +10 -0
- package/src/adapters/google-http.ts +37 -13
- package/src/adapters/google-tool-schema.ts +5 -0
- package/src/adapters/google.ts +3 -0
- package/src/adapters/kiro-retry.ts +33 -13
- package/src/adapters/kiro-tools.ts +4 -0
- package/src/adapters/kiro.ts +5 -1
- package/src/adapters/openai-responses.ts +42 -0
- package/src/codex/catalog.ts +77 -7
- package/src/lib/abort.ts +40 -0
- package/src/lib/bounded-body.ts +202 -0
- package/src/providers/registry.ts +8 -1
- package/src/reasoning-effort.ts +5 -0
- package/src/server/auth-cors.ts +16 -13
- package/src/server/effort-policy.ts +172 -0
- package/src/server/images.ts +218 -0
- package/src/server/index.ts +57 -2
- package/src/server/management-api.ts +28 -0
- package/src/server/responses.ts +29 -1
- package/src/server/search.ts +150 -0
- package/src/types.ts +37 -0
- package/src/web-search/index.ts +44 -10
- package/src/web-search/loop.ts +165 -112
- package/src/web-search/progress-stream.ts +329 -0
- package/gui/dist/assets/index-66dPs6l_.js +0 -40
- package/gui/dist/assets/index-D7o1qwy-.css +0 -1
|
@@ -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
|
+
}
|
package/src/server/index.ts
CHANGED
|
@@ -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 —
|
|
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(
|
|
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") {
|
package/src/server/responses.ts
CHANGED
|
@@ -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 {
|