@bitkyc08/opencodex 2.7.8 → 2.7.9-preview.20260712
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 +2 -0
- package/README.md +2 -0
- package/README.zh-CN.md +2 -0
- package/gui/dist/assets/index-BcaDQD3i.js +40 -0
- package/gui/dist/assets/index-Cq8maiJf.css +1 -0
- package/gui/dist/index.html +2 -2
- package/package.json +1 -1
- package/src/adapters/anthropic.ts +11 -9
- package/src/adapters/cursor/exec-policy.ts +38 -0
- package/src/adapters/cursor/live-transport.ts +4 -3
- package/src/adapters/cursor/protobuf-request.ts +20 -0
- package/src/adapters/cursor/transport.ts +5 -0
- package/src/adapters/cursor.ts +2 -2
- package/src/bridge.ts +4 -2
- package/src/claude/agents-inject.ts +198 -0
- package/src/claude/alias.ts +69 -0
- package/src/claude/context-windows.ts +189 -0
- package/src/claude/desktop-3p.ts +254 -0
- package/src/claude/gateway-cache.ts +70 -0
- package/src/claude/inbound-debug.ts +114 -0
- package/src/claude/inbound.ts +481 -0
- package/src/claude/model-info.ts +145 -0
- package/src/claude/outbound.ts +487 -0
- package/src/cli/claude.ts +157 -0
- package/src/cli/help.ts +12 -0
- package/src/cli/index.ts +86 -7
- package/src/cli/v2.ts +23 -18
- package/src/codex/features.ts +288 -16
- package/src/lib/crash-guard.ts +11 -1
- package/src/lib/debug-settings.ts +14 -2
- package/src/lib/token-estimate.ts +27 -1
- package/src/providers/registry.ts +1 -1
- package/src/server/auth-cors.ts +4 -2
- package/src/server/claude-messages.ts +494 -0
- package/src/server/index.ts +72 -0
- package/src/server/management-api.ts +226 -34
- package/src/server/request-log.ts +19 -4
- package/src/server/responses.ts +13 -1
- package/src/server/system-env.ts +314 -0
- package/src/types.ts +108 -0
- package/src/usage/log.ts +8 -2
- package/src/usage/summary.ts +18 -1
- package/src/usage/totals.ts +7 -18
- package/gui/dist/assets/index-Bp8dDrs5.js +0 -40
- package/gui/dist/assets/index-C0xVu72_.css +0 -1
package/src/server/index.ts
CHANGED
|
@@ -117,6 +117,9 @@ export {
|
|
|
117
117
|
} from "./auth-cors";
|
|
118
118
|
import { disableResponsesRequestTimeout, handleResponses, handleResponsesCompact } from "./responses";
|
|
119
119
|
export { disableResponsesRequestTimeout, linkAbortSignal } from "./responses";
|
|
120
|
+
import { handleClaudeCountTokens, handleClaudeMessages } from "./claude-messages";
|
|
121
|
+
import { anthropicErrorResponse } from "../claude/outbound";
|
|
122
|
+
import { buildDesktop3pRegistry } from "../claude/desktop-3p";
|
|
120
123
|
import { handleImages } from "./images";
|
|
121
124
|
import { handleSearch } from "./search";
|
|
122
125
|
import { fetchAllModels, handleManagementAPI, VERSION } from "./management-api";
|
|
@@ -273,6 +276,38 @@ export function startServer(port?: number) {
|
|
|
273
276
|
const nativeSlugs = nativeOpenAiSlugs();
|
|
274
277
|
const goEnabled = filterCatalogVisibleModels(goModels, config);
|
|
275
278
|
const goOrdered = orderForSubagents(goEnabled, config.subagentModels);
|
|
279
|
+
// Claude Code / Claude Desktop gateway model discovery (GET /v1/models with
|
|
280
|
+
// Anthropic-style headers; 003 G1-G8 + devlog 131). Entries use the official
|
|
281
|
+
// ModelInfo shape incl. capabilities (effort ladder / thinking) — Desktop 3P can
|
|
282
|
+
// only learn capabilities through discovery, and Claude Code 2.1.207 strips the
|
|
283
|
+
// extra fields (backward-safe). Ids are the claude-opus-4-8-{code} Desktop
|
|
284
|
+
// aliases; legacy claude-ocx-* ids keep decoding via resolveAlias. Detection:
|
|
285
|
+
// anthropic-version header (Claude Code sends it) or explicit ?flavor=anthropic.
|
|
286
|
+
// Codex catalog (client_version) and the OpenAI list shape below stay byte-identical.
|
|
287
|
+
const wantsAnthropicList = req.headers.get("anthropic-version") !== null
|
|
288
|
+
|| url.searchParams.get("flavor") === "anthropic";
|
|
289
|
+
if (wantsAnthropicList && !url.searchParams.has("client_version")) {
|
|
290
|
+
if (config.claudeCode?.enabled === false) return jsonResponse({ data: [] }, 200, req, config);
|
|
291
|
+
const { buildAnthropicModelInfos } = await import("../claude/model-info");
|
|
292
|
+
const { resolveAutoContext } = await import("../claude/context-windows");
|
|
293
|
+
// Per-surface id family (devlog 050): explicit ?ids= wins; otherwise the
|
|
294
|
+
// Claude Code CLI discovery UA (`claude-code/<version>`, binary n_()) gets
|
|
295
|
+
// readable claude-ocx ids and every other client (Desktop 3P) keeps the
|
|
296
|
+
// hashed family its config was written with. Unknown UA -> hashed (safe).
|
|
297
|
+
const idsParam = url.searchParams.get("ids");
|
|
298
|
+
const idStyle = idsParam === "cli"
|
|
299
|
+
? "readable" as const
|
|
300
|
+
: idsParam === "desktop"
|
|
301
|
+
? "desktop3p" as const
|
|
302
|
+
: (/^claude-code\//i.test(req.headers.get("user-agent") ?? "") ? "readable" as const : "desktop3p" as const);
|
|
303
|
+
const data = buildAnthropicModelInfos([...visibleNativeSlugs(config)], goOrdered, resolveAutoContext(config.claudeCode), idStyle);
|
|
304
|
+
// Build Desktop 3P registry so inbound alias resolution works for subsequent requests.
|
|
305
|
+
buildDesktop3pRegistry(
|
|
306
|
+
[...visibleNativeSlugs(config)],
|
|
307
|
+
goOrdered.map(m => ({ provider: m.provider, id: m.id, contextWindow: m.contextWindow })),
|
|
308
|
+
);
|
|
309
|
+
return jsonResponse({ data }, 200, req, config);
|
|
310
|
+
}
|
|
276
311
|
if (url.searchParams.has("client_version")) {
|
|
277
312
|
// Codex client → Codex catalog shape: native gpt + namespaced routed models,
|
|
278
313
|
// cloned from a native template so required fields (base_instructions, etc.) are present.
|
|
@@ -396,6 +431,43 @@ export function startServer(port?: number) {
|
|
|
396
431
|
return withCors(responseWithDeferredRequestLog(response, requestId, start, logCtx), req, config);
|
|
397
432
|
}
|
|
398
433
|
|
|
434
|
+
// Anthropic Messages inbound (Claude Code). count_tokens FIRST (longer path).
|
|
435
|
+
// Claude Code posts `/v1/messages?beta=true` — pathname match ignores the query (003 G9).
|
|
436
|
+
if (url.pathname === "/v1/messages/count_tokens" && req.method === "POST") {
|
|
437
|
+
if (isDraining()) {
|
|
438
|
+
return new Response("Service shutting down", { status: 503, headers: { ...corsHeaders(req, config), "Retry-After": "5" } });
|
|
439
|
+
}
|
|
440
|
+
if (!hasValidApiAuth(req, config)) {
|
|
441
|
+
return withCors(anthropicErrorResponse(401, "opencodex API key required", "authentication_error"), req, config);
|
|
442
|
+
}
|
|
443
|
+
if (!isAllowedRequestOrigin(req, config)) {
|
|
444
|
+
return withCors(anthropicErrorResponse(403, "cross-origin data-plane request blocked", "permission_error"), req, config);
|
|
445
|
+
}
|
|
446
|
+
const response = await handleClaudeCountTokens(req, config);
|
|
447
|
+
return withCors(response, req, config);
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
if (url.pathname === "/v1/messages" && req.method === "POST") {
|
|
451
|
+
disableResponsesRequestTimeout(req, requestServer);
|
|
452
|
+
if (isDraining()) {
|
|
453
|
+
return new Response("Service shutting down", { status: 503, headers: { ...corsHeaders(req, config), "Retry-After": "5" } });
|
|
454
|
+
}
|
|
455
|
+
if (!hasValidApiAuth(req, config)) {
|
|
456
|
+
return withCors(anthropicErrorResponse(401, "opencodex API key required", "authentication_error"), req, config);
|
|
457
|
+
}
|
|
458
|
+
if (!isAllowedRequestOrigin(req, config)) {
|
|
459
|
+
return withCors(anthropicErrorResponse(403, "cross-origin data-plane request blocked", "permission_error"), req, config);
|
|
460
|
+
}
|
|
461
|
+
const start = Date.now();
|
|
462
|
+
const requestId = nextRequestLogId(start);
|
|
463
|
+
const logCtx: RequestLogContext = { model: "unknown", provider: "unknown" };
|
|
464
|
+
// Logging is finalized inside handleClaudeMessages (Responses-vocab tap on the
|
|
465
|
+
// pre-translation stream + native passthrough callbacks) — do not re-wrap the
|
|
466
|
+
// translated Anthropic stream here.
|
|
467
|
+
const response = await handleClaudeMessages(req, config, logCtx, { requestId, start });
|
|
468
|
+
return withCors(response, req, config);
|
|
469
|
+
}
|
|
470
|
+
|
|
399
471
|
// Data-plane guard: unknown /v1/* paths must fail with JSON 404, never fall through to the
|
|
400
472
|
// GUI static handler (extensionless paths would get index.html with HTTP 200 and codex-rs
|
|
401
473
|
// endpoint clients — memories/*, realtime/* — would surface confusing
|
|
@@ -50,6 +50,11 @@ export const VERSION = (() => {
|
|
|
50
50
|
}
|
|
51
51
|
})();
|
|
52
52
|
|
|
53
|
+
export interface ManagementApiDeps {
|
|
54
|
+
toggleCodexMultiAgentV2?: (enabled: boolean) => void;
|
|
55
|
+
refreshCodexCatalog?: () => Promise<void>;
|
|
56
|
+
}
|
|
57
|
+
|
|
53
58
|
function parseDebugLogQuery(url: URL): { after: number; limit: number } {
|
|
54
59
|
const after = Number(url.searchParams.get("after") ?? url.searchParams.get("since") ?? "0");
|
|
55
60
|
const limit = Number(url.searchParams.get("limit") ?? "500");
|
|
@@ -59,7 +64,7 @@ function parseDebugLogQuery(url: URL): { after: number; limit: number } {
|
|
|
59
64
|
};
|
|
60
65
|
}
|
|
61
66
|
|
|
62
|
-
export async function handleManagementAPI(req: Request, url: URL, config: OcxConfig): Promise<Response | null> {
|
|
67
|
+
export async function handleManagementAPI(req: Request, url: URL, config: OcxConfig, deps: ManagementApiDeps = {}): Promise<Response | null> {
|
|
63
68
|
if (!isAllowedRequestOrigin(req, config)) {
|
|
64
69
|
return jsonResponse({ error: "cross-origin request blocked" }, 403, req, config);
|
|
65
70
|
}
|
|
@@ -72,6 +77,7 @@ export async function handleManagementAPI(req: Request, url: URL, config: OcxCon
|
|
|
72
77
|
}
|
|
73
78
|
}
|
|
74
79
|
async function refreshCodexCatalogBestEffort(): Promise<void> {
|
|
80
|
+
if (deps.refreshCodexCatalog) return deps.refreshCodexCatalog();
|
|
75
81
|
try {
|
|
76
82
|
const { refreshCodexModelCatalog } = await import("../codex/refresh");
|
|
77
83
|
await refreshCodexModelCatalog(config);
|
|
@@ -207,21 +213,33 @@ export async function handleManagementAPI(req: Request, url: URL, config: OcxCon
|
|
|
207
213
|
return jsonResponse(getUsageDebugLogEntries({ after, limit }));
|
|
208
214
|
}
|
|
209
215
|
|
|
216
|
+
if (url.pathname === "/api/claude/inbound-debug" && req.method === "GET") {
|
|
217
|
+
const { getClaudeInboundDebugEntries } = await import("../claude/inbound-debug");
|
|
218
|
+
const { isClaudeDebugEnabled } = await import("../lib/debug-settings");
|
|
219
|
+
return jsonResponse({ enabled: isClaudeDebugEnabled(), entries: getClaudeInboundDebugEntries() });
|
|
220
|
+
}
|
|
221
|
+
|
|
210
222
|
if (url.pathname === "/api/debug" && req.method === "PUT") {
|
|
211
|
-
let body: { debug?: unknown; usage?: unknown; injection?: unknown; reset?: unknown };
|
|
223
|
+
let body: { debug?: unknown; usage?: unknown; injection?: unknown; claude?: unknown; reset?: unknown };
|
|
212
224
|
try { body = await req.json(); } catch { return jsonResponse({ error: "invalid JSON body" }, 400); }
|
|
213
225
|
if (body.reset === true) return jsonResponse(clearDebugSettings());
|
|
214
226
|
if (body.reset === "debug" || body.reset === "provider") return jsonResponse(clearDebugSetting("debug"));
|
|
215
227
|
if (body.reset === "usage") return jsonResponse(clearDebugSetting("usage"));
|
|
216
228
|
if (body.reset === "injection") return jsonResponse(clearDebugSetting("injection"));
|
|
229
|
+
if (body.reset === "claude") return jsonResponse(clearDebugSetting("claude"));
|
|
217
230
|
const partial: Partial<Record<DebugFlag, boolean>> = {};
|
|
218
|
-
for (const key of ["debug", "usage", "injection"] as const) {
|
|
231
|
+
for (const key of ["debug", "usage", "injection", "claude"] as const) {
|
|
219
232
|
if (body[key] === undefined) continue;
|
|
220
233
|
if (typeof body[key] !== "boolean") return jsonResponse({ error: `${key} must be a boolean` }, 400);
|
|
221
234
|
partial[key] = body[key];
|
|
222
235
|
}
|
|
223
236
|
if (Object.keys(partial).length === 0) {
|
|
224
|
-
return jsonResponse({ error: "provide debug/usage/injection booleans or reset:true" }, 400);
|
|
237
|
+
return jsonResponse({ error: "provide debug/usage/injection/claude booleans or reset:true" }, 400);
|
|
238
|
+
}
|
|
239
|
+
// Turning capture off should also flush already-captured entries (privacy contract).
|
|
240
|
+
if (partial.claude === false) {
|
|
241
|
+
const { clearClaudeInboundDebug } = await import("../claude/inbound-debug");
|
|
242
|
+
clearClaudeInboundDebug();
|
|
225
243
|
}
|
|
226
244
|
return jsonResponse(setDebugSettings(partial));
|
|
227
245
|
}
|
|
@@ -246,6 +264,8 @@ export async function handleManagementAPI(req: Request, url: URL, config: OcxCon
|
|
|
246
264
|
inputTokens: 0,
|
|
247
265
|
outputTokens: 0,
|
|
248
266
|
cachedInputTokens: 0,
|
|
267
|
+
cacheReadInputTokens: 0,
|
|
268
|
+
cacheCreationInputTokens: 0,
|
|
249
269
|
reasoningOutputTokens: 0,
|
|
250
270
|
totalTokens: 0,
|
|
251
271
|
coverageRatio: 0,
|
|
@@ -438,11 +458,12 @@ export async function handleManagementAPI(req: Request, url: URL, config: OcxCon
|
|
|
438
458
|
// itself never writes config — this endpoint is the only server-side mutation
|
|
439
459
|
// surface for the flag.
|
|
440
460
|
if (url.pathname === "/api/v2" && req.method === "GET") {
|
|
441
|
-
const { isMultiAgentV2Enabled, hasAgentsMaxThreads,
|
|
461
|
+
const { isMultiAgentV2Enabled, hasAgentsMaxThreads, getLogicalMaxThreads } = await import("../codex/features");
|
|
462
|
+
const enabled = isMultiAgentV2Enabled();
|
|
442
463
|
return jsonResponse({
|
|
443
|
-
enabled
|
|
444
|
-
agentsMaxThreadsConflict: hasAgentsMaxThreads(),
|
|
445
|
-
maxConcurrentThreadsPerSession:
|
|
464
|
+
enabled,
|
|
465
|
+
agentsMaxThreadsConflict: enabled && hasAgentsMaxThreads(),
|
|
466
|
+
maxConcurrentThreadsPerSession: getLogicalMaxThreads(),
|
|
446
467
|
multiAgentMode: config.multiAgentMode ?? "default",
|
|
447
468
|
});
|
|
448
469
|
}
|
|
@@ -460,43 +481,45 @@ export async function handleManagementAPI(req: Request, url: URL, config: OcxCon
|
|
|
460
481
|
if (wantsThreads && (typeof body.maxConcurrentThreadsPerSession !== "number" || !Number.isInteger(body.maxConcurrentThreadsPerSession) || body.maxConcurrentThreadsPerSession < 1)) {
|
|
461
482
|
return jsonResponse({ error: "body.maxConcurrentThreadsPerSession must be an integer >= 1" }, 400);
|
|
462
483
|
}
|
|
463
|
-
const
|
|
484
|
+
const mode = wantsMode ? body.multiAgentMode as "v1" | "default" | "v2" : undefined;
|
|
485
|
+
const modeFlag = mode === "v2" ? true : mode === "v1" ? false : undefined;
|
|
486
|
+
if (wantsFlag && modeFlag !== undefined && body.enabled !== modeFlag) {
|
|
487
|
+
return jsonResponse({ error: `body.enabled conflicts with multiAgentMode '${mode}'` }, 400);
|
|
488
|
+
}
|
|
489
|
+
const { isMultiAgentV2Enabled, hasAgentsMaxThreads, getLogicalMaxThreads, transitionMultiAgentV2 } = await import("../codex/features");
|
|
464
490
|
const warnings: string[] = [];
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
const
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
491
|
+
const requestedFlag = wantsFlag ? body.enabled as boolean : modeFlag;
|
|
492
|
+
if (requestedFlag !== undefined || wantsThreads) {
|
|
493
|
+
const targetFlag = requestedFlag ?? isMultiAgentV2Enabled();
|
|
494
|
+
let toggle = deps.toggleCodexMultiAgentV2;
|
|
495
|
+
if (!toggle) {
|
|
496
|
+
const { execFileSync } = await import("node:child_process");
|
|
497
|
+
toggle = (enabled: boolean) => {
|
|
498
|
+
const command = process.env.CODEX_CLI_PATH?.trim() || "codex";
|
|
499
|
+
execFileSync(command, ["features", enabled ? "enable" : "disable", "multi_agent_v2"],
|
|
500
|
+
{ stdio: ["ignore", "pipe", "pipe"], timeout: 15_000, windowsHide: true });
|
|
501
|
+
};
|
|
473
502
|
}
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
// against a never-enabled config fails loudly instead of inventing state.
|
|
480
|
-
const result = setMaxConcurrentThreads(body.maxConcurrentThreadsPerSession as number);
|
|
481
|
-
if (!result.ok) return jsonResponse({ error: result.error }, 409);
|
|
482
|
-
if (result.changed) warnings.push("Thread limit applies to new sessions.");
|
|
503
|
+
const result = transitionMultiAgentV2(targetFlag, toggle, {
|
|
504
|
+
...(wantsThreads ? { threadLimit: body.maxConcurrentThreadsPerSession as number } : {}),
|
|
505
|
+
});
|
|
506
|
+
if (!result.ok) return jsonResponse({ error: `multi_agent_v2 transition failed: ${result.error}` }, 502);
|
|
507
|
+
if (result.changed && result.threadLimit !== null) warnings.push(`Thread limit ${result.threadLimit} preserved for ${targetFlag ? "v2" : "v1"}.`);
|
|
483
508
|
}
|
|
484
509
|
if (wantsMode) {
|
|
485
|
-
const mode = body.multiAgentMode as "v1" | "default" | "v2";
|
|
486
510
|
if (mode === "default") delete config.multiAgentMode;
|
|
487
511
|
else config.multiAgentMode = mode;
|
|
488
512
|
saveConfig(config);
|
|
489
|
-
await refreshCodexCatalogBestEffort();
|
|
490
513
|
warnings.push(`Multi-agent mode set to '${mode}'. Applies to new sessions.`);
|
|
491
514
|
}
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
if (wantsFlag) warnings.push("Applies to new sessions; restart the Codex app or wait out its picker cache to see the ladder change.");
|
|
515
|
+
await refreshCodexCatalogBestEffort();
|
|
516
|
+
if (requestedFlag !== undefined) warnings.push("Applies to new sessions; restart the Codex app or wait out its picker cache to see the ladder change.");
|
|
517
|
+
const enabled = isMultiAgentV2Enabled();
|
|
496
518
|
return jsonResponse({
|
|
497
519
|
ok: true,
|
|
498
|
-
enabled
|
|
499
|
-
|
|
520
|
+
enabled,
|
|
521
|
+
agentsMaxThreadsConflict: enabled && hasAgentsMaxThreads(),
|
|
522
|
+
maxConcurrentThreadsPerSession: getLogicalMaxThreads(),
|
|
500
523
|
multiAgentMode: config.multiAgentMode ?? "default",
|
|
501
524
|
warnings,
|
|
502
525
|
});
|
|
@@ -625,6 +648,175 @@ export async function handleManagementAPI(req: Request, url: URL, config: OcxCon
|
|
|
625
648
|
return jsonResponse({ ok: true, applied: chosen });
|
|
626
649
|
}
|
|
627
650
|
|
|
651
|
+
// Claude Code inbound settings (GUI "Claude ON" toggle + Claude page).
|
|
652
|
+
if (url.pathname === "/api/claude-code" && req.method === "GET") {
|
|
653
|
+
const models = await fetchAllModels(config);
|
|
654
|
+
const { listCatalogNativeSlugs } = await import("../codex/catalog");
|
|
655
|
+
const { claudeCodeAlias, claudeCodeNativeAlias } = await import("../claude/alias");
|
|
656
|
+
const { buildClaudeContextWindows, effectiveModelEnv } = await import("../claude/context-windows");
|
|
657
|
+
const { visibleNativeSlugs } = await import("../codex/catalog");
|
|
658
|
+
const disabled = new Set(config.disabledModels ?? []);
|
|
659
|
+
const available = [
|
|
660
|
+
...listCatalogNativeSlugs(),
|
|
661
|
+
...models.map(m => `${m.provider}/${m.id}`),
|
|
662
|
+
].filter(ns => !disabled.has(ns));
|
|
663
|
+
const aliases: { id: string; display_name: string }[] = [];
|
|
664
|
+
for (const slug of listCatalogNativeSlugs()) {
|
|
665
|
+
// Readable CLI-surface alias with hash fallback (devlog 050 / audit 051 #2) —
|
|
666
|
+
// the same shared helper the /v1/models ?ids=cli path uses.
|
|
667
|
+
if (!disabled.has(slug)) aliases.push({ id: claudeCodeNativeAlias(slug), display_name: `${slug} (native)` });
|
|
668
|
+
}
|
|
669
|
+
for (const m of models) {
|
|
670
|
+
if (disabled.has(`${m.provider}/${m.id}`)) continue;
|
|
671
|
+
aliases.push({ id: claudeCodeAlias(m.provider, m.id), display_name: `${m.id} (${m.provider})` });
|
|
672
|
+
}
|
|
673
|
+
const contextWindows = buildClaudeContextWindows([...visibleNativeSlugs(config)], models);
|
|
674
|
+
return jsonResponse({
|
|
675
|
+
enabled: config.claudeCode?.enabled !== false,
|
|
676
|
+
model: config.claudeCode?.model ?? "",
|
|
677
|
+
smallFastModel: config.claudeCode?.smallFastModel ?? "",
|
|
678
|
+
tierModels: config.claudeCode?.tierModels ?? {},
|
|
679
|
+
modelMap: config.claudeCode?.modelMap ?? {},
|
|
680
|
+
systemEnv: config.claudeCode?.systemEnv === true,
|
|
681
|
+
maxContextTokens: config.claudeCode?.maxContextTokens ?? null,
|
|
682
|
+
alwaysEnableEffort: config.claudeCode?.alwaysEnableEffort === true,
|
|
683
|
+
autoContext: config.claudeCode?.autoContext !== false,
|
|
684
|
+
autoCompactWindow: config.claudeCode?.autoCompactWindow ?? null,
|
|
685
|
+
blockedSkills: config.claudeCode?.blockedSkills ?? null,
|
|
686
|
+
injectAgents: config.claudeCode?.injectAgents !== false,
|
|
687
|
+
fastMode: config.fastMode,
|
|
688
|
+
contextWindows,
|
|
689
|
+
effectiveModelEnv: effectiveModelEnv(config.claudeCode, contextWindows),
|
|
690
|
+
available,
|
|
691
|
+
aliases,
|
|
692
|
+
port: config.port,
|
|
693
|
+
});
|
|
694
|
+
}
|
|
695
|
+
if (url.pathname === "/api/claude-code" && req.method === "PUT") {
|
|
696
|
+
// NOTE: model / tierModels / maxContextTokens / alwaysEnableEffort are
|
|
697
|
+
// CONFIG-ONLY back-compat fields — the GUI no longer offers controls for them
|
|
698
|
+
// (default model is owned by Claude Code's /model picker; roster agents
|
|
699
|
+
// supersede tiers; auto-context supersedes the max-context pair; effort rides
|
|
700
|
+
// regardless on 2.1.207). PUT keeps validating them so hand-written configs
|
|
701
|
+
// and older GUIs stay safe; GUI saves omit them and the spread preserves them.
|
|
702
|
+
let body: { enabled?: unknown; model?: unknown; smallFastModel?: unknown; modelMap?: unknown; systemEnv?: unknown; fastMode?: unknown; maxContextTokens?: unknown; alwaysEnableEffort?: unknown; tierModels?: unknown; autoContext?: unknown; autoCompactWindow?: unknown; blockedSkills?: unknown; injectAgents?: unknown };
|
|
703
|
+
try { body = await req.json(); } catch { return jsonResponse({ error: "invalid JSON body" }, 400); }
|
|
704
|
+
const next = { ...(config.claudeCode ?? {}) };
|
|
705
|
+
if (body.enabled !== undefined) {
|
|
706
|
+
if (typeof body.enabled !== "boolean") return jsonResponse({ error: "enabled must be a boolean" }, 400);
|
|
707
|
+
next.enabled = body.enabled;
|
|
708
|
+
}
|
|
709
|
+
if (body.systemEnv !== undefined) {
|
|
710
|
+
if (typeof body.systemEnv !== "boolean") return jsonResponse({ error: "systemEnv must be a boolean" }, 400);
|
|
711
|
+
next.systemEnv = body.systemEnv;
|
|
712
|
+
}
|
|
713
|
+
if (body.alwaysEnableEffort !== undefined) {
|
|
714
|
+
if (typeof body.alwaysEnableEffort !== "boolean") return jsonResponse({ error: "alwaysEnableEffort must be a boolean" }, 400);
|
|
715
|
+
if (body.alwaysEnableEffort) next.alwaysEnableEffort = true;
|
|
716
|
+
else delete next.alwaysEnableEffort;
|
|
717
|
+
}
|
|
718
|
+
if (body.maxContextTokens !== undefined) {
|
|
719
|
+
// CONFIG-ONLY back-compat (GUI control removed — superseded by auto-context):
|
|
720
|
+
// null clears; otherwise a positive integer (devlog 136 B6).
|
|
721
|
+
if (body.maxContextTokens === null) {
|
|
722
|
+
delete next.maxContextTokens;
|
|
723
|
+
} else if (typeof body.maxContextTokens !== "number" || !Number.isInteger(body.maxContextTokens) || body.maxContextTokens <= 0) {
|
|
724
|
+
return jsonResponse({ error: "maxContextTokens must be a positive integer or null" }, 400);
|
|
725
|
+
} else {
|
|
726
|
+
next.maxContextTokens = body.maxContextTokens;
|
|
727
|
+
}
|
|
728
|
+
}
|
|
729
|
+
if (body.autoContext !== undefined) {
|
|
730
|
+
// Default-on boolean (devlog 260712 020): true = drop the key, false = store.
|
|
731
|
+
if (typeof body.autoContext !== "boolean") return jsonResponse({ error: "autoContext must be a boolean" }, 400);
|
|
732
|
+
if (body.autoContext) delete next.autoContext;
|
|
733
|
+
else next.autoContext = false;
|
|
734
|
+
}
|
|
735
|
+
if (body.injectAgents !== undefined) {
|
|
736
|
+
// Default-on boolean (devlog 260712 070): true = drop the key, false = store.
|
|
737
|
+
if (typeof body.injectAgents !== "boolean") return jsonResponse({ error: "injectAgents must be a boolean" }, 400);
|
|
738
|
+
if (body.injectAgents) delete next.injectAgents;
|
|
739
|
+
else next.injectAgents = false;
|
|
740
|
+
}
|
|
741
|
+
if (body.autoCompactWindow !== undefined) {
|
|
742
|
+
// null resets to the 350k default; otherwise the binary-accepted range
|
|
743
|
+
// 100_000..1_000_000 (2.1.207 pSo/yDs — audit 021 #1).
|
|
744
|
+
if (body.autoCompactWindow === null) {
|
|
745
|
+
delete next.autoCompactWindow;
|
|
746
|
+
} else if (typeof body.autoCompactWindow !== "number" || !Number.isInteger(body.autoCompactWindow) || body.autoCompactWindow < 100_000 || body.autoCompactWindow > 1_000_000) {
|
|
747
|
+
return jsonResponse({ error: "autoCompactWindow must be an integer between 100000 and 1000000, or null" }, 400);
|
|
748
|
+
} else {
|
|
749
|
+
next.autoCompactWindow = body.autoCompactWindow;
|
|
750
|
+
}
|
|
751
|
+
}
|
|
752
|
+
if (body.blockedSkills !== undefined) {
|
|
753
|
+
// null resets to the default (["claude-api"]); an array (possibly empty = off)
|
|
754
|
+
// must contain non-empty strings (devlog 060).
|
|
755
|
+
if (body.blockedSkills === null) {
|
|
756
|
+
delete next.blockedSkills;
|
|
757
|
+
} else if (!Array.isArray(body.blockedSkills) || body.blockedSkills.some(s => typeof s !== "string" || s.trim() === "")) {
|
|
758
|
+
return jsonResponse({ error: "blockedSkills must be an array of non-empty strings, or null" }, 400);
|
|
759
|
+
} else {
|
|
760
|
+
next.blockedSkills = (body.blockedSkills as string[]).map(s => s.trim());
|
|
761
|
+
}
|
|
762
|
+
}
|
|
763
|
+
if (body.tierModels !== undefined) {
|
|
764
|
+
// CONFIG-ONLY back-compat (GUI pickers removed — roster agents supersede tiers).
|
|
765
|
+
if (!body.tierModels || typeof body.tierModels !== "object" || Array.isArray(body.tierModels)) {
|
|
766
|
+
return jsonResponse({ error: "tierModels must be an object" }, 400);
|
|
767
|
+
}
|
|
768
|
+
const tiers: Record<string, string> = {};
|
|
769
|
+
for (const tier of ["opus", "sonnet", "haiku", "fable"] as const) {
|
|
770
|
+
const value = (body.tierModels as Record<string, unknown>)[tier];
|
|
771
|
+
if (value === undefined || value === null) continue;
|
|
772
|
+
if (typeof value !== "string") return jsonResponse({ error: `tierModels.${tier} must be a string` }, 400);
|
|
773
|
+
if (value.trim() !== "") tiers[tier] = value.trim();
|
|
774
|
+
}
|
|
775
|
+
if (Object.keys(tiers).length > 0) next.tierModels = tiers;
|
|
776
|
+
else delete next.tierModels;
|
|
777
|
+
}
|
|
778
|
+
if (body.fastMode !== undefined) {
|
|
779
|
+
if (body.fastMode !== true && body.fastMode !== false && body.fastMode !== null) {
|
|
780
|
+
return jsonResponse({ error: "fastMode must be true, false, or null" }, 400);
|
|
781
|
+
}
|
|
782
|
+
config.fastMode = body.fastMode === null ? undefined : body.fastMode;
|
|
783
|
+
}
|
|
784
|
+
for (const field of ["model", "smallFastModel"] as const) {
|
|
785
|
+
const value = body[field];
|
|
786
|
+
if (value === undefined) continue;
|
|
787
|
+
if (typeof value !== "string") return jsonResponse({ error: `${field} must be a string` }, 400);
|
|
788
|
+
if (value.trim() === "") delete next[field];
|
|
789
|
+
else next[field] = value.trim();
|
|
790
|
+
}
|
|
791
|
+
if (body.modelMap !== undefined) {
|
|
792
|
+
if (!body.modelMap || typeof body.modelMap !== "object" || Array.isArray(body.modelMap)) {
|
|
793
|
+
return jsonResponse({ error: "modelMap must be an object of string->string" }, 400);
|
|
794
|
+
}
|
|
795
|
+
const map: Record<string, string> = {};
|
|
796
|
+
for (const [k, v] of Object.entries(body.modelMap as Record<string, unknown>)) {
|
|
797
|
+
if (typeof v !== "string" || k.trim() === "" || v.trim() === "") {
|
|
798
|
+
return jsonResponse({ error: "modelMap entries must be non-empty strings" }, 400);
|
|
799
|
+
}
|
|
800
|
+
map[k.trim()] = v.trim();
|
|
801
|
+
}
|
|
802
|
+
if (Object.keys(map).length > 0) next.modelMap = map;
|
|
803
|
+
else delete next.modelMap;
|
|
804
|
+
}
|
|
805
|
+
config.claudeCode = next;
|
|
806
|
+
const { saveConfig: save } = await import("../config");
|
|
807
|
+
save(config);
|
|
808
|
+
// Immediate prune when injection turns off (audit 071 #3): stale ocx-* agent
|
|
809
|
+
// definitions must stop loading in future sessions without waiting for the
|
|
810
|
+
// next launch hook. Best-effort; the disabled gate inside prunes owned files.
|
|
811
|
+
if (next.injectAgents === false || next.enabled === false) {
|
|
812
|
+
try {
|
|
813
|
+
const { injectClaudeAgentDefs } = await import("../claude/agents-inject");
|
|
814
|
+
injectClaudeAgentDefs(config, {});
|
|
815
|
+
} catch { /* best-effort */ }
|
|
816
|
+
}
|
|
817
|
+
return jsonResponse({ ok: true, enabled: next.enabled !== false });
|
|
818
|
+
}
|
|
819
|
+
|
|
628
820
|
// Per-provider catalog allowlist (issue #52): when a provider has a non-empty selectedModels list,
|
|
629
821
|
// only those ids ship to Codex's catalog / /v1/models. GET returns the CURRENT selection plus the
|
|
630
822
|
// FULL available set per provider (unfiltered — the picker needs everything to choose from).
|
|
@@ -23,6 +23,7 @@ import {
|
|
|
23
23
|
export interface RequestLogContext {
|
|
24
24
|
model: string;
|
|
25
25
|
provider: string;
|
|
26
|
+
surface?: "claude";
|
|
26
27
|
requestedModel?: string;
|
|
27
28
|
requestedEffort?: string;
|
|
28
29
|
requestedServiceTier?: string;
|
|
@@ -37,6 +38,9 @@ export interface RequestLogContext {
|
|
|
37
38
|
usageDebugBodyKind?: UsageDebugBodyKind;
|
|
38
39
|
usageDebugBodySample?: string;
|
|
39
40
|
usageDebugContentType?: string;
|
|
41
|
+
/** Route adapter type ("cursor"/"kiro"/"anthropic"/…): drives estimated-usage detection
|
|
42
|
+
* independent of the user-chosen provider NAME (devlog 130 B2). */
|
|
43
|
+
providerAdapter?: string;
|
|
40
44
|
/** Secret-redacted upstream error reason (e.g. the granular Cursor "rate limit exceeded…"
|
|
41
45
|
* message) extracted from a `response.failed` SSE payload or non-streaming error body, so the
|
|
42
46
|
* request log / GUI shows the actual upstream failure rather than only the HTTP-mapped code. */
|
|
@@ -50,6 +54,7 @@ export interface RequestLogEntry {
|
|
|
50
54
|
timestamp: number;
|
|
51
55
|
model: string;
|
|
52
56
|
provider: string;
|
|
57
|
+
surface?: "claude";
|
|
53
58
|
requestedModel?: string;
|
|
54
59
|
requestedEffort?: string;
|
|
55
60
|
requestedServiceTier?: string;
|
|
@@ -183,7 +188,10 @@ export function usageFromResponsesPayload(usage: unknown): OcxUsage | undefined
|
|
|
183
188
|
outputTokens: raw.output_tokens,
|
|
184
189
|
...(typeof raw.total_tokens === "number" ? { totalTokens: raw.total_tokens } : {}),
|
|
185
190
|
...(typeof raw.input_tokens_details?.cached_tokens === "number"
|
|
186
|
-
? {
|
|
191
|
+
? {
|
|
192
|
+
cachedInputTokens: raw.input_tokens_details.cached_tokens,
|
|
193
|
+
cacheReadInputTokens: raw.input_tokens_details.cached_tokens,
|
|
194
|
+
}
|
|
187
195
|
: {}),
|
|
188
196
|
...(typeof raw.input_tokens_details?.cache_write_tokens === "number"
|
|
189
197
|
? { cacheCreationInputTokens: raw.input_tokens_details.cache_write_tokens }
|
|
@@ -199,7 +207,10 @@ export function usageFromResponsesPayload(usage: unknown): OcxUsage | undefined
|
|
|
199
207
|
outputTokens: raw.completion_tokens,
|
|
200
208
|
...(typeof raw.total_tokens === "number" ? { totalTokens: raw.total_tokens } : {}),
|
|
201
209
|
...(typeof raw.prompt_tokens_details?.cached_tokens === "number"
|
|
202
|
-
? {
|
|
210
|
+
? {
|
|
211
|
+
cachedInputTokens: raw.prompt_tokens_details.cached_tokens,
|
|
212
|
+
cacheReadInputTokens: raw.prompt_tokens_details.cached_tokens,
|
|
213
|
+
}
|
|
203
214
|
: {}),
|
|
204
215
|
...(typeof raw.prompt_tokens_details?.cache_write_tokens === "number"
|
|
205
216
|
? { cacheCreationInputTokens: raw.prompt_tokens_details.cache_write_tokens }
|
|
@@ -349,12 +360,15 @@ export function addFinalRequestLog(
|
|
|
349
360
|
addLog: (entry: RequestLogEntry) => void = addRequestLog,
|
|
350
361
|
): void {
|
|
351
362
|
const errorCode = requestLogErrorCode(status);
|
|
352
|
-
|
|
363
|
+
// Estimated-usage detection prefers the route ADAPTER: configured provider names
|
|
364
|
+
// ("cursor-mykey") broke the old exact-name match and cursor rows logged as
|
|
365
|
+
// accurately "reported" (devlog 130 B2).
|
|
366
|
+
const finalUsage = usageForFinalLog(logCtx.providerAdapter ?? logCtx.provider, logCtx.usage);
|
|
353
367
|
const usageFallback = !finalUsage && typeof logCtx.usageLogInputTokens === "number"
|
|
354
368
|
? { inputTokens: logCtx.usageLogInputTokens, outputTokens: 0, estimated: true }
|
|
355
369
|
: undefined;
|
|
356
370
|
const loggedUsage = finalUsage && typeof logCtx.usageLogInputTokens === "number"
|
|
357
|
-
? { ...finalUsage, inputTokens: Math.max(finalUsage.inputTokens, logCtx.usageLogInputTokens) }
|
|
371
|
+
? { ...finalUsage, inputTokens: Math.max(finalUsage.inputTokens, logCtx.usageLogInputTokens), estimated: true }
|
|
358
372
|
: (finalUsage ?? usageFallback);
|
|
359
373
|
const usageStatus = usageStatusForFinalLog(loggedUsage);
|
|
360
374
|
const totalTokens = usageTotalTokens(loggedUsage);
|
|
@@ -363,6 +377,7 @@ export function addFinalRequestLog(
|
|
|
363
377
|
timestamp: start,
|
|
364
378
|
model: logCtx.model,
|
|
365
379
|
provider: logCtx.provider,
|
|
380
|
+
...(logCtx.surface ? { surface: logCtx.surface } : {}),
|
|
366
381
|
...(logCtx.requestedModel ? { requestedModel: logCtx.requestedModel } : {}),
|
|
367
382
|
...(logCtx.requestedEffort ? { requestedEffort: logCtx.requestedEffort } : {}),
|
|
368
383
|
...(logCtx.requestedServiceTier ? { requestedServiceTier: logCtx.requestedServiceTier } : {}),
|
package/src/server/responses.ts
CHANGED
|
@@ -481,6 +481,18 @@ export async function handleResponses(
|
|
|
481
481
|
}
|
|
482
482
|
logCtx.model = route.modelId;
|
|
483
483
|
logCtx.provider = route.providerName;
|
|
484
|
+
logCtx.providerAdapter = route.provider.adapter;
|
|
485
|
+
|
|
486
|
+
// Fast mode override: when config.fastMode is explicitly set, inject or strip
|
|
487
|
+
// service_tier for OpenAI-routed models. Undefined = passthrough (client decides).
|
|
488
|
+
if (config.fastMode !== undefined && route.provider.adapter === "openai-responses") {
|
|
489
|
+
const tier = config.fastMode ? "priority" : undefined;
|
|
490
|
+
if (parsed._rawBody && typeof parsed._rawBody === "object") {
|
|
491
|
+
if (tier) (parsed._rawBody as Record<string, unknown>).service_tier = tier;
|
|
492
|
+
else delete (parsed._rawBody as Record<string, unknown>).service_tier;
|
|
493
|
+
}
|
|
494
|
+
parsed.options.serviceTier = tier;
|
|
495
|
+
}
|
|
484
496
|
|
|
485
497
|
// Multi-agent guidance shim: codex-rs emits its Proactive delegation developer
|
|
486
498
|
// message only on the v2 surface. The proxy fills both gaps: the Proactive text
|
|
@@ -926,7 +938,7 @@ export async function handleResponses(
|
|
|
926
938
|
if (!rotated) break;
|
|
927
939
|
// Release the failed response's socket before retrying; unread bodies otherwise linger
|
|
928
940
|
// until runtime cleanup (one per rotated key under a rate-limit storm).
|
|
929
|
-
try { void upstreamResponse.body?.cancel(); } catch { /* already consumed/closed */ }
|
|
941
|
+
try { void upstreamResponse.body?.cancel().catch(() => {}); } catch { /* already consumed/closed */ }
|
|
930
942
|
route.provider = rotated;
|
|
931
943
|
const retryAdapter = resolveAdapter(
|
|
932
944
|
resolveWireProtocolOverride(route.providerName, route.modelId, rotated),
|