@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.
Files changed (45) hide show
  1. package/README.ko.md +2 -0
  2. package/README.md +2 -0
  3. package/README.zh-CN.md +2 -0
  4. package/gui/dist/assets/index-BcaDQD3i.js +40 -0
  5. package/gui/dist/assets/index-Cq8maiJf.css +1 -0
  6. package/gui/dist/index.html +2 -2
  7. package/package.json +1 -1
  8. package/src/adapters/anthropic.ts +11 -9
  9. package/src/adapters/cursor/exec-policy.ts +38 -0
  10. package/src/adapters/cursor/live-transport.ts +4 -3
  11. package/src/adapters/cursor/protobuf-request.ts +20 -0
  12. package/src/adapters/cursor/transport.ts +5 -0
  13. package/src/adapters/cursor.ts +2 -2
  14. package/src/bridge.ts +4 -2
  15. package/src/claude/agents-inject.ts +198 -0
  16. package/src/claude/alias.ts +69 -0
  17. package/src/claude/context-windows.ts +189 -0
  18. package/src/claude/desktop-3p.ts +254 -0
  19. package/src/claude/gateway-cache.ts +70 -0
  20. package/src/claude/inbound-debug.ts +114 -0
  21. package/src/claude/inbound.ts +481 -0
  22. package/src/claude/model-info.ts +145 -0
  23. package/src/claude/outbound.ts +487 -0
  24. package/src/cli/claude.ts +157 -0
  25. package/src/cli/help.ts +12 -0
  26. package/src/cli/index.ts +86 -7
  27. package/src/cli/v2.ts +23 -18
  28. package/src/codex/features.ts +288 -16
  29. package/src/lib/crash-guard.ts +11 -1
  30. package/src/lib/debug-settings.ts +14 -2
  31. package/src/lib/token-estimate.ts +27 -1
  32. package/src/providers/registry.ts +1 -1
  33. package/src/server/auth-cors.ts +4 -2
  34. package/src/server/claude-messages.ts +494 -0
  35. package/src/server/index.ts +72 -0
  36. package/src/server/management-api.ts +226 -34
  37. package/src/server/request-log.ts +19 -4
  38. package/src/server/responses.ts +13 -1
  39. package/src/server/system-env.ts +314 -0
  40. package/src/types.ts +108 -0
  41. package/src/usage/log.ts +8 -2
  42. package/src/usage/summary.ts +18 -1
  43. package/src/usage/totals.ts +7 -18
  44. package/gui/dist/assets/index-Bp8dDrs5.js +0 -40
  45. package/gui/dist/assets/index-C0xVu72_.css +0 -1
@@ -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, getMaxConcurrentThreads } = await import("../codex/features");
461
+ const { isMultiAgentV2Enabled, hasAgentsMaxThreads, getLogicalMaxThreads } = await import("../codex/features");
462
+ const enabled = isMultiAgentV2Enabled();
442
463
  return jsonResponse({
443
- enabled: isMultiAgentV2Enabled(),
444
- agentsMaxThreadsConflict: hasAgentsMaxThreads(),
445
- maxConcurrentThreadsPerSession: getMaxConcurrentThreads(),
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 { isMultiAgentV2Enabled, hasAgentsMaxThreads, getMaxConcurrentThreads, setMaxConcurrentThreads } = await import("../codex/features");
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
- if (wantsFlag && isMultiAgentV2Enabled() !== body.enabled) {
466
- const { execFileSync } = await import("node:child_process");
467
- const command = process.env.CODEX_CLI_PATH?.trim() || "codex";
468
- try {
469
- execFileSync(command, ["features", body.enabled ? "enable" : "disable", "multi_agent_v2"],
470
- { stdio: ["ignore", "pipe", "pipe"], timeout: 15_000, windowsHide: true });
471
- } catch (err) {
472
- return jsonResponse({ error: `codex features ${body.enabled ? "enable" : "disable"} failed: ${err instanceof Error ? err.message : String(err)}` }, 502);
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
- await refreshCodexCatalogBestEffort();
475
- }
476
- if (wantsThreads) {
477
- // setMaxConcurrentThreads is idempotent (equal value -> no write) and refuses
478
- // when the [features.multi_agent_v2] table is missing, so a threads-only PUT
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
- if ((wantsFlag ? body.enabled === true : isMultiAgentV2Enabled()) && hasAgentsMaxThreads()) {
493
- 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).");
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: isMultiAgentV2Enabled(),
499
- maxConcurrentThreadsPerSession: getMaxConcurrentThreads(),
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
- ? { cachedInputTokens: raw.input_tokens_details.cached_tokens }
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
- ? { cachedInputTokens: raw.prompt_tokens_details.cached_tokens }
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
- const finalUsage = usageForFinalLog(logCtx.provider, logCtx.usage);
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 } : {}),
@@ -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),