@bitkyc08/opencodex 2.6.32 → 2.7.1-preview.20260710
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 +9 -5
- package/README.md +7 -4
- package/README.zh-CN.md +8 -4
- package/gui/dist/assets/index-BUAMcKFd.css +1 -0
- package/gui/dist/assets/index-KorpEKW8.js +34 -0
- package/gui/dist/index.html +2 -2
- package/package.json +1 -1
- package/src/adapters/anthropic.ts +62 -1
- package/src/adapters/cursor/cursor-errors.ts +28 -1
- package/src/adapters/cursor/discovery.ts +60 -10
- package/src/adapters/cursor/effort-map.ts +38 -7
- package/src/adapters/cursor/live-models.ts +3 -0
- package/src/adapters/cursor/live-transport.ts +136 -7
- package/src/adapters/cursor/protobuf-request.ts +24 -1
- package/src/adapters/cursor/request-builder.ts +6 -5
- package/src/adapters/cursor/transport-retry.ts +22 -3
- package/src/adapters/cursor.ts +2 -1
- package/src/adapters/openai-chat.ts +75 -26
- package/src/bridge.ts +42 -3
- package/src/cli/debug.ts +203 -0
- package/src/cli/doctor.ts +11 -0
- package/src/cli/help.ts +11 -0
- package/src/cli/index.ts +10 -0
- package/src/cli/v2.ts +131 -0
- package/src/codex/auth-api.ts +7 -3
- package/src/codex/catalog.ts +334 -31
- package/src/codex/data/upstream-models.json +830 -0
- package/src/codex/features.ts +178 -0
- package/src/codex/project-config-warnings.ts +388 -0
- package/src/codex/sync.ts +8 -0
- package/src/codex/warmup.ts +62 -7
- package/src/config.ts +7 -5
- package/src/lib/debug-log-buffer.ts +42 -0
- package/src/lib/debug-settings.ts +84 -0
- package/src/lib/debug.ts +18 -9
- package/src/lib/errors.ts +104 -1
- package/src/oauth/cursor.ts +35 -12
- package/src/oauth/store.ts +4 -3
- package/src/providers/derive.ts +8 -0
- package/src/providers/registry.ts +56 -21
- package/src/reasoning-effort.ts +37 -9
- package/src/responses/parser.ts +7 -2
- package/src/router.ts +5 -0
- package/src/server/adapter-resolve.ts +1 -1
- package/src/server/index.ts +27 -3
- package/src/server/management-api.ts +189 -7
- package/src/server/relay.ts +2 -2
- package/src/server/request-decompress.ts +8 -2
- package/src/server/request-log.ts +78 -0
- package/src/server/responses.ts +241 -9
- package/src/types.ts +34 -1
- package/src/usage/debug.ts +32 -5
- package/src/usage/summary.ts +6 -6
- package/src/vision/describe.ts +4 -0
- package/src/web-search/executor.ts +4 -0
- package/src/web-search/format-result.ts +11 -3
- package/src/web-search/index.ts +31 -2
- package/src/web-search/loop.ts +112 -61
- package/src/web-search/parse.ts +4 -1
- package/gui/dist/assets/index-ByGC8-Bm.css +0 -1
- package/gui/dist/assets/index-D_JZzI0r.js +0 -15
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { readFileSync } from "node:fs";
|
|
2
2
|
import type { CatalogModel } from "../codex/catalog";
|
|
3
|
-
import { invalidateCodexModelsCache } from "../codex/catalog";
|
|
3
|
+
import { invalidateCodexModelsCache, nativeModelRows } from "../codex/catalog";
|
|
4
4
|
import {
|
|
5
5
|
DEFAULT_SUBAGENT_MODELS,
|
|
6
6
|
codexAutoStartEnabled,
|
|
@@ -24,8 +24,17 @@ import { deriveProviderPresets } from "../providers/derive";
|
|
|
24
24
|
import { fetchProviderQuotaReports } from "../providers/quota";
|
|
25
25
|
import { DEFAULT_PROVIDER_CONTEXT_CAP, globalContextCapValue, providerContextCap, providerContextCaps, setAllProviderContextCaps, setGlobalContextCapValue, setProviderContextCap } from "../providers/context-cap";
|
|
26
26
|
import { readUsageEntries } from "../usage/log";
|
|
27
|
+
import { getUsageDebugLogEntries } from "../usage/debug";
|
|
27
28
|
import { parseRange, summarizeUsage } from "../usage/summary";
|
|
28
29
|
import { stripCodexRuntimeProviderFields } from "../codex/auth-context";
|
|
30
|
+
import { getDebugLogEntries } from "../lib/debug-log-buffer";
|
|
31
|
+
import {
|
|
32
|
+
clearDebugSettings,
|
|
33
|
+
clearDebugSetting,
|
|
34
|
+
getDebugSettings,
|
|
35
|
+
setDebugSettings,
|
|
36
|
+
type DebugFlag,
|
|
37
|
+
} from "../lib/debug-settings";
|
|
29
38
|
import type { OcxConfig, OcxProviderConfig } from "../types";
|
|
30
39
|
import { drainAndShutdown } from "./lifecycle";
|
|
31
40
|
import { filterRequestLogs, getRequestLogEntries } from "./request-log";
|
|
@@ -41,6 +50,15 @@ export const VERSION = (() => {
|
|
|
41
50
|
}
|
|
42
51
|
})();
|
|
43
52
|
|
|
53
|
+
function parseDebugLogQuery(url: URL): { after: number; limit: number } {
|
|
54
|
+
const after = Number(url.searchParams.get("after") ?? url.searchParams.get("since") ?? "0");
|
|
55
|
+
const limit = Number(url.searchParams.get("limit") ?? "500");
|
|
56
|
+
return {
|
|
57
|
+
after: Number.isFinite(after) && after > 0 ? after : 0,
|
|
58
|
+
limit: Number.isFinite(limit) && limit > 0 ? Math.min(limit, 2000) : 500,
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
|
|
44
62
|
export async function handleManagementAPI(req: Request, url: URL, config: OcxConfig): Promise<Response | null> {
|
|
45
63
|
if (!isAllowedRequestOrigin(req, config)) {
|
|
46
64
|
return jsonResponse({ error: "cross-origin request blocked" }, 403, req, config);
|
|
@@ -89,6 +107,12 @@ export async function handleManagementAPI(req: Request, url: URL, config: OcxCon
|
|
|
89
107
|
return jsonResponse({ ok: true, codexAutoStart: codexAutoStartEnabled(config) });
|
|
90
108
|
}
|
|
91
109
|
|
|
110
|
+
if (url.pathname === "/api/diagnostics/project-config" && req.method === "GET") {
|
|
111
|
+
const { getCachedProjectConfigDiagnostics } = await import("../codex/project-config-warnings");
|
|
112
|
+
const { warnings, grouped } = getCachedProjectConfigDiagnostics();
|
|
113
|
+
return jsonResponse({ warnings, grouped });
|
|
114
|
+
}
|
|
115
|
+
|
|
92
116
|
if (url.pathname === "/api/sync" && req.method === "POST") {
|
|
93
117
|
const { syncModelsToCodex } = await import("../codex/sync");
|
|
94
118
|
const result = await syncModelsToCodex(undefined, config, null);
|
|
@@ -138,8 +162,8 @@ export async function handleManagementAPI(req: Request, url: URL, config: OcxCon
|
|
|
138
162
|
const ws = config.webSearchSidecar ?? {};
|
|
139
163
|
const vs = config.visionSidecar ?? {};
|
|
140
164
|
return jsonResponse({
|
|
141
|
-
webSearch: { model: ws.model ?? "gpt-5.
|
|
142
|
-
vision: { model: vs.model ?? "gpt-5.
|
|
165
|
+
webSearch: { model: ws.model ?? "gpt-5.6-luna", reasoning: ws.reasoning ?? "low" },
|
|
166
|
+
vision: { model: vs.model ?? "gpt-5.6-luna" },
|
|
143
167
|
});
|
|
144
168
|
}
|
|
145
169
|
|
|
@@ -160,8 +184,8 @@ export async function handleManagementAPI(req: Request, url: URL, config: OcxCon
|
|
|
160
184
|
const vs = config.visionSidecar ?? {};
|
|
161
185
|
return jsonResponse({
|
|
162
186
|
ok: true,
|
|
163
|
-
webSearch: { model: ws.model ?? "gpt-5.
|
|
164
|
-
vision: { model: vs.model ?? "gpt-5.
|
|
187
|
+
webSearch: { model: ws.model ?? "gpt-5.6-luna", reasoning: ws.reasoning ?? "low" },
|
|
188
|
+
vision: { model: vs.model ?? "gpt-5.6-luna" },
|
|
165
189
|
});
|
|
166
190
|
}
|
|
167
191
|
|
|
@@ -169,6 +193,38 @@ export async function handleManagementAPI(req: Request, url: URL, config: OcxCon
|
|
|
169
193
|
return jsonResponse(filterRequestLogs(getRequestLogEntries(), url.searchParams));
|
|
170
194
|
}
|
|
171
195
|
|
|
196
|
+
if (url.pathname === "/api/debug" && req.method === "GET") {
|
|
197
|
+
return jsonResponse(getDebugSettings());
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
if (url.pathname === "/api/debug/logs" && req.method === "GET") {
|
|
201
|
+
const { after, limit } = parseDebugLogQuery(url);
|
|
202
|
+
return jsonResponse(getDebugLogEntries({ after, limit }));
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
if (url.pathname === "/api/debug/usage-logs" && req.method === "GET") {
|
|
206
|
+
const { after, limit } = parseDebugLogQuery(url);
|
|
207
|
+
return jsonResponse(getUsageDebugLogEntries({ after, limit }));
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
if (url.pathname === "/api/debug" && req.method === "PUT") {
|
|
211
|
+
let body: { debug?: unknown; usage?: unknown; reset?: unknown };
|
|
212
|
+
try { body = await req.json(); } catch { return jsonResponse({ error: "invalid JSON body" }, 400); }
|
|
213
|
+
if (body.reset === true) return jsonResponse(clearDebugSettings());
|
|
214
|
+
if (body.reset === "debug" || body.reset === "provider") return jsonResponse(clearDebugSetting("debug"));
|
|
215
|
+
if (body.reset === "usage") return jsonResponse(clearDebugSetting("usage"));
|
|
216
|
+
const partial: Partial<Record<DebugFlag, boolean>> = {};
|
|
217
|
+
for (const key of ["debug", "usage"] as const) {
|
|
218
|
+
if (body[key] === undefined) continue;
|
|
219
|
+
if (typeof body[key] !== "boolean") return jsonResponse({ error: `${key} must be a boolean` }, 400);
|
|
220
|
+
partial[key] = body[key];
|
|
221
|
+
}
|
|
222
|
+
if (Object.keys(partial).length === 0) {
|
|
223
|
+
return jsonResponse({ error: "provide debug/usage booleans or reset:true" }, 400);
|
|
224
|
+
}
|
|
225
|
+
return jsonResponse(setDebugSettings(partial));
|
|
226
|
+
}
|
|
227
|
+
|
|
172
228
|
if (url.pathname === "/api/usage" && req.method === "GET") {
|
|
173
229
|
const range = parseRange(url.searchParams.get("range"));
|
|
174
230
|
const now = Date.now();
|
|
@@ -284,7 +340,17 @@ export async function handleManagementAPI(req: Request, url: URL, config: OcxCon
|
|
|
284
340
|
if (url.pathname === "/api/models" && req.method === "GET") {
|
|
285
341
|
const models = await fetchAllModels(config);
|
|
286
342
|
const disabled = new Set(config.disabledModels ?? []);
|
|
287
|
-
|
|
343
|
+
// Native GPT passthrough rows lead (provider "openai", bare-slug namespaced ids): sourced
|
|
344
|
+
// from the static supported set so a disabled model stays listed and re-enableable.
|
|
345
|
+
const native = nativeModelRows(config).map(row => ({
|
|
346
|
+
provider: "openai",
|
|
347
|
+
id: row.slug,
|
|
348
|
+
namespaced: row.slug,
|
|
349
|
+
disabled: row.disabled,
|
|
350
|
+
native: true,
|
|
351
|
+
...(row.contextWindow !== undefined ? { contextWindow: row.contextWindow } : {}),
|
|
352
|
+
}));
|
|
353
|
+
return jsonResponse([...native, ...models.map(m => {
|
|
288
354
|
const namespaced = `${m.provider}/${m.id}`;
|
|
289
355
|
const contextCap = providerContextCap(config, m.provider);
|
|
290
356
|
return {
|
|
@@ -293,7 +359,7 @@ export async function handleManagementAPI(req: Request, url: URL, config: OcxCon
|
|
|
293
359
|
disabled: disabled.has(namespaced),
|
|
294
360
|
...(contextCap !== undefined ? { contextCap, contextCapped: m.contextCapped === true } : {}),
|
|
295
361
|
};
|
|
296
|
-
}));
|
|
362
|
+
})]);
|
|
297
363
|
}
|
|
298
364
|
|
|
299
365
|
if (url.pathname === "/api/provider-context-caps" && req.method === "GET") {
|
|
@@ -365,6 +431,76 @@ export async function handleManagementAPI(req: Request, url: URL, config: OcxCon
|
|
|
365
431
|
return jsonResponse({ ok: true, disabled });
|
|
366
432
|
}
|
|
367
433
|
|
|
434
|
+
// multi_agent_v2 surface toggle. GET reports the flag + the agents.max_threads
|
|
435
|
+
// boot conflict; PUT flips it via the official `codex features` CLI and RESYNCS
|
|
436
|
+
// the catalog so multi-agent surface metadata stays fresh. The catalog build
|
|
437
|
+
// itself never writes config — this endpoint is the only server-side mutation
|
|
438
|
+
// surface for the flag.
|
|
439
|
+
if (url.pathname === "/api/v2" && req.method === "GET") {
|
|
440
|
+
const { isMultiAgentV2Enabled, hasAgentsMaxThreads, getMaxConcurrentThreads } = await import("../codex/features");
|
|
441
|
+
return jsonResponse({
|
|
442
|
+
enabled: isMultiAgentV2Enabled(),
|
|
443
|
+
agentsMaxThreadsConflict: hasAgentsMaxThreads(),
|
|
444
|
+
maxConcurrentThreadsPerSession: getMaxConcurrentThreads(),
|
|
445
|
+
multiAgentMode: config.multiAgentMode ?? "default",
|
|
446
|
+
});
|
|
447
|
+
}
|
|
448
|
+
if (url.pathname === "/api/v2" && req.method === "PUT") {
|
|
449
|
+
let body: { enabled?: unknown; maxConcurrentThreadsPerSession?: unknown; multiAgentMode?: unknown };
|
|
450
|
+
try { body = await req.json(); } catch { return jsonResponse({ error: "invalid JSON body" }, 400); }
|
|
451
|
+
const wantsFlag = body.enabled !== undefined;
|
|
452
|
+
const wantsThreads = body.maxConcurrentThreadsPerSession !== undefined;
|
|
453
|
+
const wantsMode = body.multiAgentMode !== undefined;
|
|
454
|
+
if (!wantsFlag && !wantsThreads && !wantsMode) return jsonResponse({ error: "body must set enabled, multiAgentMode, and/or maxConcurrentThreadsPerSession" }, 400);
|
|
455
|
+
if (wantsFlag && typeof body.enabled !== "boolean") return jsonResponse({ error: "body.enabled must be a boolean" }, 400);
|
|
456
|
+
if (wantsMode && body.multiAgentMode !== "v1" && body.multiAgentMode !== "default" && body.multiAgentMode !== "v2") {
|
|
457
|
+
return jsonResponse({ error: "body.multiAgentMode must be 'v1', 'default', or 'v2'" }, 400);
|
|
458
|
+
}
|
|
459
|
+
if (wantsThreads && (typeof body.maxConcurrentThreadsPerSession !== "number" || !Number.isInteger(body.maxConcurrentThreadsPerSession) || body.maxConcurrentThreadsPerSession < 1)) {
|
|
460
|
+
return jsonResponse({ error: "body.maxConcurrentThreadsPerSession must be an integer >= 1" }, 400);
|
|
461
|
+
}
|
|
462
|
+
const { isMultiAgentV2Enabled, hasAgentsMaxThreads, getMaxConcurrentThreads, setMaxConcurrentThreads } = await import("../codex/features");
|
|
463
|
+
const warnings: string[] = [];
|
|
464
|
+
if (wantsFlag && isMultiAgentV2Enabled() !== body.enabled) {
|
|
465
|
+
const { execFileSync } = await import("node:child_process");
|
|
466
|
+
const command = process.env.CODEX_CLI_PATH?.trim() || "codex";
|
|
467
|
+
try {
|
|
468
|
+
execFileSync(command, ["features", body.enabled ? "enable" : "disable", "multi_agent_v2"],
|
|
469
|
+
{ stdio: ["ignore", "pipe", "pipe"], timeout: 15_000, windowsHide: true });
|
|
470
|
+
} catch (err) {
|
|
471
|
+
return jsonResponse({ error: `codex features ${body.enabled ? "enable" : "disable"} failed: ${err instanceof Error ? err.message : String(err)}` }, 502);
|
|
472
|
+
}
|
|
473
|
+
await refreshCodexCatalogBestEffort();
|
|
474
|
+
}
|
|
475
|
+
if (wantsThreads) {
|
|
476
|
+
// setMaxConcurrentThreads is idempotent (equal value -> no write) and refuses
|
|
477
|
+
// when the [features.multi_agent_v2] table is missing, so a threads-only PUT
|
|
478
|
+
// against a never-enabled config fails loudly instead of inventing state.
|
|
479
|
+
const result = setMaxConcurrentThreads(body.maxConcurrentThreadsPerSession as number);
|
|
480
|
+
if (!result.ok) return jsonResponse({ error: result.error }, 409);
|
|
481
|
+
if (result.changed) warnings.push("Thread limit applies to new sessions.");
|
|
482
|
+
}
|
|
483
|
+
if (wantsMode) {
|
|
484
|
+
const mode = body.multiAgentMode as "v1" | "default" | "v2";
|
|
485
|
+
if (mode === "default") delete config.multiAgentMode;
|
|
486
|
+
else config.multiAgentMode = mode;
|
|
487
|
+
saveConfig(config);
|
|
488
|
+
await refreshCodexCatalogBestEffort();
|
|
489
|
+
warnings.push(`Multi-agent mode set to '${mode}'. Applies to new sessions.`);
|
|
490
|
+
}
|
|
491
|
+
if ((wantsFlag ? body.enabled === true : isMultiAgentV2Enabled()) && hasAgentsMaxThreads()) {
|
|
492
|
+
warnings.push("[agents] max_threads is set — codex refuses to start while multi_agent_v2 is enabled; remove it (features.multi_agent_v2.max_concurrent_threads_per_session replaces it).");
|
|
493
|
+
}
|
|
494
|
+
if (wantsFlag) warnings.push("Applies to new sessions; restart the Codex app or wait out its picker cache to see the ladder change.");
|
|
495
|
+
return jsonResponse({
|
|
496
|
+
ok: true,
|
|
497
|
+
enabled: isMultiAgentV2Enabled(),
|
|
498
|
+
maxConcurrentThreadsPerSession: getMaxConcurrentThreads(),
|
|
499
|
+
multiAgentMode: config.multiAgentMode ?? "default",
|
|
500
|
+
warnings,
|
|
501
|
+
});
|
|
502
|
+
}
|
|
503
|
+
|
|
368
504
|
// Which providers support real OAuth login (drives the GUI's "Log in with …" buttons).
|
|
369
505
|
if (url.pathname === "/api/oauth/providers" && req.method === "GET") {
|
|
370
506
|
return jsonResponse({ providers: listOAuthProviders() });
|
|
@@ -381,6 +517,52 @@ export async function handleManagementAPI(req: Request, url: URL, config: OcxCon
|
|
|
381
517
|
return jsonResponse({ providers: deriveProviderPresets() });
|
|
382
518
|
}
|
|
383
519
|
|
|
520
|
+
// Subagent prompt injection model: single native or routed model whose info is
|
|
521
|
+
// dynamically injected into the v1 proactive prompt, plus an optional reasoning
|
|
522
|
+
// effort the prompt tells the agent to pass to spawn_agent. GET returns the current
|
|
523
|
+
// picks + available models/efforts; PUT sets or clears them.
|
|
524
|
+
if (url.pathname === "/api/injection-model" && req.method === "GET") {
|
|
525
|
+
const models = await fetchAllModels(config);
|
|
526
|
+
const disabled = new Set(config.disabledModels ?? []);
|
|
527
|
+
const { listCatalogNativeSlugs } = await import("../codex/catalog");
|
|
528
|
+
const { CODEX_REASONING_LEVELS } = await import("../reasoning-effort");
|
|
529
|
+
const nativeModels = listCatalogNativeSlugs()
|
|
530
|
+
.filter(slug => !disabled.has(slug))
|
|
531
|
+
.map(slug => ({ provider: "openai", model: slug, namespaced: slug }));
|
|
532
|
+
const routedModels = models
|
|
533
|
+
.map(m => ({ provider: m.provider, model: m.id, namespaced: `${m.provider}/${m.id}` }))
|
|
534
|
+
.filter(m => !disabled.has(m.namespaced));
|
|
535
|
+
return jsonResponse({
|
|
536
|
+
model: config.injectionModel ?? null,
|
|
537
|
+
effort: config.injectionEffort ?? null,
|
|
538
|
+
efforts: CODEX_REASONING_LEVELS.map(l => l.effort),
|
|
539
|
+
available: [...nativeModels, ...routedModels],
|
|
540
|
+
});
|
|
541
|
+
}
|
|
542
|
+
if (url.pathname === "/api/injection-model" && req.method === "PUT") {
|
|
543
|
+
let body: { model?: unknown; effort?: unknown };
|
|
544
|
+
try { body = await req.json(); } catch { return jsonResponse({ error: "invalid JSON body" }, 400); }
|
|
545
|
+
const { isCodexReasoningEffort } = await import("../reasoning-effort");
|
|
546
|
+
const model = typeof body.model === "string" && body.model.length > 0 ? body.model : undefined;
|
|
547
|
+
let effort = config.injectionEffort;
|
|
548
|
+
// `effort` key semantics: absent -> unchanged; null/"" -> clear; ladder value -> set;
|
|
549
|
+
// anything else -> 400. Clearing the model always clears the effort (it is meaningless alone).
|
|
550
|
+
if ("effort" in body) {
|
|
551
|
+
const requestedEffort = typeof body.effort === "string" && body.effort.length > 0 ? body.effort : undefined;
|
|
552
|
+
if (requestedEffort !== undefined && !isCodexReasoningEffort(requestedEffort)) {
|
|
553
|
+
return jsonResponse({ error: `unknown reasoning effort "${requestedEffort}"` }, 400);
|
|
554
|
+
}
|
|
555
|
+
effort = requestedEffort;
|
|
556
|
+
}
|
|
557
|
+
if (!model) effort = undefined;
|
|
558
|
+
if (model) config.injectionModel = model;
|
|
559
|
+
else delete config.injectionModel;
|
|
560
|
+
if (effort) config.injectionEffort = effort;
|
|
561
|
+
else delete config.injectionEffort;
|
|
562
|
+
saveConfig(config);
|
|
563
|
+
return jsonResponse({ ok: true, model: config.injectionModel ?? null, effort: config.injectionEffort ?? null });
|
|
564
|
+
}
|
|
565
|
+
|
|
384
566
|
// Subagent model picker: which ≤5 routed models Codex's spawn_agent advertises (it shows the
|
|
385
567
|
// first 5 routed catalog entries). PUT reorders the injected catalog so the chosen ones lead.
|
|
386
568
|
if (url.pathname === "/api/subagent-models" && req.method === "GET") {
|
package/src/server/relay.ts
CHANGED
|
@@ -3,7 +3,7 @@ import { isUsageDebugEnabled } from "../usage/debug";
|
|
|
3
3
|
import {
|
|
4
4
|
addRequestLog,
|
|
5
5
|
addFinalRequestLog,
|
|
6
|
-
|
|
6
|
+
httpStatusForRequestLogTerminal,
|
|
7
7
|
inspectResponseLogJson,
|
|
8
8
|
inspectResponseLogSsePayload,
|
|
9
9
|
type RequestLogContext,
|
|
@@ -249,7 +249,7 @@ export function responseWithDeferredRequestLog(
|
|
|
249
249
|
status => {
|
|
250
250
|
if (logged) return;
|
|
251
251
|
logged = true;
|
|
252
|
-
addFinalRequestLog(requestId, start, logCtx,
|
|
252
|
+
addFinalRequestLog(requestId, start, logCtx, httpStatusForRequestLogTerminal(status, logCtx), {
|
|
253
253
|
terminalStatus: status,
|
|
254
254
|
closeReason: "terminal",
|
|
255
255
|
}, addLog);
|
|
@@ -9,8 +9,14 @@
|
|
|
9
9
|
* `content-encoding: zstd` bodies that `req.json()` cannot parse.
|
|
10
10
|
*/
|
|
11
11
|
|
|
12
|
-
/**
|
|
13
|
-
|
|
12
|
+
/**
|
|
13
|
+
* Cap decompressed request bodies (a compressed bomb must not inflate unbounded). Codex compresses
|
|
14
|
+
* EVERY responses request with zstd (no size threshold), and image-heavy histories inflate fast:
|
|
15
|
+
* ~12 full-res screenshots as base64 already cross 64MB decompressed. The proxy is fed by the user's
|
|
16
|
+
* own local Codex over loopback, so the bomb threat is weak; this cap is really an OOM guard. Keep it
|
|
17
|
+
* generous enough that ordinary multi-image sessions decode, while still bounding a runaway body.
|
|
18
|
+
*/
|
|
19
|
+
export const MAX_DECOMPRESSED_BODY_BYTES = 256 * 1024 * 1024;
|
|
14
20
|
|
|
15
21
|
export class UnsupportedContentEncodingError extends Error {
|
|
16
22
|
constructor(readonly encoding: string) {
|
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
import { existsSync, readFileSync } from "node:fs";
|
|
2
2
|
import type { ResponsesTerminalStatus } from "../bridge";
|
|
3
|
+
import { httpStatusFromTerminalError as httpStatusFromClassifiedTerminalError } from "../lib/errors";
|
|
3
4
|
import { CODEX_CONFIG_PATH, readRootTomlString } from "../codex/paths";
|
|
4
5
|
import { readCodexCatalogPath } from "../codex/catalog";
|
|
5
6
|
import type { OcxUsage } from "../types";
|
|
7
|
+
import { redactSecretString } from "../lib/redact";
|
|
6
8
|
import {
|
|
7
9
|
appendUsageEntry,
|
|
8
10
|
usageForFinalLog,
|
|
@@ -35,6 +37,12 @@ export interface RequestLogContext {
|
|
|
35
37
|
usageDebugBodyKind?: UsageDebugBodyKind;
|
|
36
38
|
usageDebugBodySample?: string;
|
|
37
39
|
usageDebugContentType?: string;
|
|
40
|
+
/** Secret-redacted upstream error reason (e.g. the granular Cursor "rate limit exceeded…"
|
|
41
|
+
* message) extracted from a `response.failed` SSE payload or non-streaming error body, so the
|
|
42
|
+
* request log / GUI shows the actual upstream failure rather than only the HTTP-mapped code. */
|
|
43
|
+
upstreamError?: string;
|
|
44
|
+
/** HTTP status derived from a terminal `response.failed` SSE payload (429/401/503/etc.). */
|
|
45
|
+
terminalHttpStatus?: number;
|
|
38
46
|
}
|
|
39
47
|
|
|
40
48
|
export interface RequestLogEntry {
|
|
@@ -56,6 +64,8 @@ export interface RequestLogEntry {
|
|
|
56
64
|
errorCode?: string;
|
|
57
65
|
terminalStatus?: ResponsesTerminalStatus;
|
|
58
66
|
closeReason?: "terminal" | "client_cancel" | "non_stream";
|
|
67
|
+
/** Secret-redacted upstream error reason, surfaced in /api/logs and the GUI detail modal. */
|
|
68
|
+
upstreamError?: string;
|
|
59
69
|
usageStatus: UsageStatus;
|
|
60
70
|
usage?: OcxUsage;
|
|
61
71
|
totalTokens?: number;
|
|
@@ -208,6 +218,7 @@ export function inspectResponseLogJson(logCtx: RequestLogContext, text: string):
|
|
|
208
218
|
} catch {
|
|
209
219
|
/* body may not be JSON; request log metadata is best-effort only */
|
|
210
220
|
}
|
|
221
|
+
captureUpstreamError(logCtx, text);
|
|
211
222
|
if (isUsageDebugEnabled() && logCtx.usageDebugBodyKind === undefined) {
|
|
212
223
|
logCtx.usageDebugBodyKind = "json";
|
|
213
224
|
logCtx.usageDebugBodySample = truncateForDebug(text);
|
|
@@ -223,6 +234,7 @@ export function inspectResponseLogSsePayload(logCtx: RequestLogContext, payload:
|
|
|
223
234
|
} catch {
|
|
224
235
|
/* SSE block payload may not be JSON; metadata inspection is best-effort */
|
|
225
236
|
}
|
|
237
|
+
captureUpstreamError(logCtx, payload);
|
|
226
238
|
if (debugEnabled) {
|
|
227
239
|
if (!sseAlreadyMarked) {
|
|
228
240
|
logCtx.usageDebugBodyKind = "sse";
|
|
@@ -235,10 +247,75 @@ export function inspectResponseLogSsePayload(logCtx: RequestLogContext, payload:
|
|
|
235
247
|
}
|
|
236
248
|
}
|
|
237
249
|
|
|
250
|
+
/**
|
|
251
|
+
* Capture the upstream error reason into the request log context. Codex/consumer surfaces only
|
|
252
|
+
* see an HTTP-mapped error code (502 → upstream_server_error); the granular reason lives inside
|
|
253
|
+
* a `response.failed` SSE payload's `error.message` (the adapter's redacted upstream message) or
|
|
254
|
+
* a non-streaming JSON error body. We keep the FIRST non-empty reason (the original failure) and
|
|
255
|
+
* run it through redactSecretString so secrets never reach /api/logs. Pure; safe on any text.
|
|
256
|
+
*/
|
|
257
|
+
function captureUpstreamError(logCtx: RequestLogContext, text: string | null): void {
|
|
258
|
+
if (!text || logCtx.upstreamError) return;
|
|
259
|
+
try {
|
|
260
|
+
const json = JSON.parse(text) as {
|
|
261
|
+
type?: unknown;
|
|
262
|
+
error?: { message?: unknown };
|
|
263
|
+
last_error?: { message?: unknown };
|
|
264
|
+
response?: { error?: { type?: unknown; code?: unknown; message?: unknown } };
|
|
265
|
+
};
|
|
266
|
+
captureTerminalHttpStatus(logCtx, json);
|
|
267
|
+
const message = json?.error?.message
|
|
268
|
+
?? json?.last_error?.message
|
|
269
|
+
?? json?.response?.error?.message;
|
|
270
|
+
if (typeof message === "string" && message.trim()) {
|
|
271
|
+
logCtx.upstreamError = redactSecretString(message).slice(0, 500);
|
|
272
|
+
}
|
|
273
|
+
} catch {
|
|
274
|
+
/* not JSON; nothing to capture */
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
function captureTerminalHttpStatus(
|
|
279
|
+
logCtx: RequestLogContext,
|
|
280
|
+
json: {
|
|
281
|
+
type?: unknown;
|
|
282
|
+
response?: { error?: { type?: unknown; code?: unknown; message?: unknown } };
|
|
283
|
+
},
|
|
284
|
+
): void {
|
|
285
|
+
if (logCtx.terminalHttpStatus !== undefined) return;
|
|
286
|
+
if (json.type !== "response.failed") return;
|
|
287
|
+
const error = json.response?.error;
|
|
288
|
+
if (!error || typeof error !== "object") return;
|
|
289
|
+
logCtx.terminalHttpStatus = httpStatusFromTerminalError({
|
|
290
|
+
type: typeof error.type === "string" ? error.type : undefined,
|
|
291
|
+
code: error.code === null || typeof error.code === "string" ? error.code : undefined,
|
|
292
|
+
message: typeof error.message === "string" ? error.message : undefined,
|
|
293
|
+
});
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
/** Map a terminal Responses error object to the HTTP status we record in /api/logs. */
|
|
297
|
+
export function httpStatusFromTerminalError(error: {
|
|
298
|
+
type?: string;
|
|
299
|
+
code?: string | null;
|
|
300
|
+
message?: string;
|
|
301
|
+
} | undefined): number {
|
|
302
|
+
return httpStatusFromClassifiedTerminalError(error);
|
|
303
|
+
}
|
|
304
|
+
|
|
238
305
|
export function httpStatusForTerminalStatus(status: ResponsesTerminalStatus): number {
|
|
239
306
|
return status === "completed" ? 200 : 502;
|
|
240
307
|
}
|
|
241
308
|
|
|
309
|
+
export function httpStatusForRequestLogTerminal(
|
|
310
|
+
status: ResponsesTerminalStatus,
|
|
311
|
+
logCtx?: RequestLogContext,
|
|
312
|
+
): number {
|
|
313
|
+
if (status === "failed" && logCtx?.terminalHttpStatus !== undefined) {
|
|
314
|
+
return logCtx.terminalHttpStatus;
|
|
315
|
+
}
|
|
316
|
+
return httpStatusForTerminalStatus(status);
|
|
317
|
+
}
|
|
318
|
+
|
|
242
319
|
export function addFinalRequestLog(
|
|
243
320
|
requestId: string,
|
|
244
321
|
start: number,
|
|
@@ -276,6 +353,7 @@ export function addFinalRequestLog(
|
|
|
276
353
|
...(errorCode ? { errorCode } : {}),
|
|
277
354
|
...(meta?.terminalStatus ? { terminalStatus: meta.terminalStatus } : {}),
|
|
278
355
|
...(meta?.closeReason ? { closeReason: meta.closeReason } : {}),
|
|
356
|
+
...(logCtx.upstreamError ? { upstreamError: logCtx.upstreamError } : {}),
|
|
279
357
|
usageStatus,
|
|
280
358
|
...(loggedUsage ? { usage: loggedUsage } : {}),
|
|
281
359
|
...(totalTokens !== undefined ? { totalTokens } : {}),
|