@bitkyc08/opencodex 2.48.0 → 2.50.0

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 (141) hide show
  1. package/AGENTS_INSTALL.md +9 -1
  2. package/README.md +11 -5
  3. package/SPONSORS.md +1 -1
  4. package/assets/sponsors/orcarouter.png +0 -0
  5. package/assets/sponsors/packycode.png +0 -0
  6. package/gui/dist/assets/index-BoBRSehJ.css +1 -0
  7. package/gui/dist/assets/index-C39tnjXO.js +115 -0
  8. package/gui/dist/index.html +2 -2
  9. package/gui/dist/provider-icons/packycode.svg +19 -0
  10. package/gui/dist/provider-icons/qoder.svg +5 -0
  11. package/package.json +5 -3
  12. package/src/adapters/anthropic.ts +31 -16
  13. package/src/adapters/codebuddy/adapter.ts +85 -0
  14. package/src/adapters/codebuddy/profiles.ts +52 -0
  15. package/src/adapters/coding-agent/profile.ts +100 -0
  16. package/src/adapters/coding-agent/protocol.ts +463 -0
  17. package/src/adapters/coding-agent/turn.ts +353 -0
  18. package/src/adapters/google.ts +15 -11
  19. package/src/adapters/mimo-free.ts +3 -0
  20. package/src/adapters/openai-chat.ts +2 -2
  21. package/src/adapters/openai-responses.ts +18 -11
  22. package/src/adapters/qoder/adapter.ts +70 -0
  23. package/src/adapters/qoder/live-models.ts +89 -0
  24. package/src/adapters/qoder/profiles.ts +36 -0
  25. package/src/adapters/registry.ts +12 -0
  26. package/src/adapters/responses-tool-schema.ts +113 -8
  27. package/src/claude/inbound.ts +17 -5
  28. package/src/cli/account-api.ts +18 -3
  29. package/src/cli/account-auth.ts +8 -1
  30. package/src/cli/account-extended.ts +2 -1
  31. package/src/cli/account.ts +1 -0
  32. package/src/cli/capabilities.ts +15 -1
  33. package/src/cli/dispatch.ts +2 -0
  34. package/src/cli/doctor.ts +40 -0
  35. package/src/cli/effort.ts +24 -8
  36. package/src/cli/help.ts +2 -0
  37. package/src/cli/index.ts +29 -2
  38. package/src/cli/models-runtime.ts +8 -3
  39. package/src/cli/observe.ts +13 -3
  40. package/src/cli/provider-runtime.ts +2 -1
  41. package/src/cli/registry.ts +2 -2
  42. package/src/cli/system-command.ts +10 -3
  43. package/src/cli/usage-report.ts +9 -5
  44. package/src/clients/config-export/zcode.ts +24 -0
  45. package/src/codex/account-lifecycle.ts +35 -2
  46. package/src/codex/account-runtime-state.ts +6 -1
  47. package/src/codex/account-store.ts +72 -9
  48. package/src/codex/account-usability.ts +3 -2
  49. package/src/codex/auth-api.ts +113 -26
  50. package/src/codex/auth-collision.ts +12 -2
  51. package/src/codex/auth-context.ts +96 -7
  52. package/src/codex/catalog/parsing.ts +23 -0
  53. package/src/codex/catalog/provider-fetch.ts +144 -11
  54. package/src/codex/catalog/sync.ts +14 -0
  55. package/src/codex/inject.ts +128 -30
  56. package/src/codex/internal/catalog-writer.ts +3 -0
  57. package/src/codex/journal.ts +61 -12
  58. package/src/codex/model-cache.ts +11 -4
  59. package/src/codex/native-profile-startup.ts +72 -5
  60. package/src/codex/native-profile-store.ts +2 -2
  61. package/src/codex/ocx-compaction-history.ts +226 -0
  62. package/src/codex/project-config-warnings.ts +3 -1
  63. package/src/codex/quota-auto-refresh.ts +6 -1
  64. package/src/codex/quota.ts +71 -15
  65. package/src/codex/reserve-availability.ts +21 -5
  66. package/src/codex/runtime.ts +45 -1
  67. package/src/codex/sync.ts +5 -0
  68. package/src/combos/index.ts +2 -0
  69. package/src/combos/resolve.ts +52 -0
  70. package/src/config.ts +59 -0
  71. package/src/generated/compatibility-version.json +178 -114
  72. package/src/images/loop.ts +1 -0
  73. package/src/images/xai-video-client.ts +2 -0
  74. package/src/integrations/registry.ts +1 -0
  75. package/src/lib/errors.ts +8 -0
  76. package/src/lib/privacy.ts +25 -0
  77. package/src/lib/process-control.ts +52 -8
  78. package/src/lib/upstream-retry.ts +1 -0
  79. package/src/oauth/chatgpt.ts +83 -0
  80. package/src/oauth/health.ts +47 -12
  81. package/src/oauth/index.ts +46 -8
  82. package/src/oauth/token-guardian.ts +32 -6
  83. package/src/oauth/xai.ts +151 -8
  84. package/src/providers/api-key-selection-capture.ts +10 -0
  85. package/src/providers/api-key-selection.ts +2 -7
  86. package/src/providers/caller-authorization.ts +36 -0
  87. package/src/providers/codebuddy-models.ts +184 -0
  88. package/src/providers/derive.ts +5 -0
  89. package/src/providers/free-directory.ts +26 -2
  90. package/src/providers/google-ai-studio-model-discovery.ts +74 -0
  91. package/src/providers/openai-sidecar.ts +35 -11
  92. package/src/providers/opencode-zen-rate-limit.ts +75 -0
  93. package/src/providers/qoder-models.ts +25 -0
  94. package/src/providers/quota.ts +15 -0
  95. package/src/providers/registry.ts +140 -1
  96. package/src/responses/compaction.ts +4 -0
  97. package/src/responses/task-input.ts +21 -1
  98. package/src/router.ts +1 -1
  99. package/src/server/auth-cors.ts +6 -0
  100. package/src/server/chat-completions.ts +30 -13
  101. package/src/server/chat-native.ts +10 -1
  102. package/src/server/claude-messages.ts +17 -7
  103. package/src/server/images.ts +3 -2
  104. package/src/server/index.ts +25 -2
  105. package/src/server/management/account-selection-stream.ts +13 -4
  106. package/src/server/management/config-routes.ts +24 -5
  107. package/src/server/management/logs-usage-routes.ts +5 -1
  108. package/src/server/management/model-rows.ts +16 -1
  109. package/src/server/management/native-integration-routes.ts +2 -1
  110. package/src/server/management/oauth-account-routes.ts +6 -2
  111. package/src/server/management/provider-routes.ts +33 -2
  112. package/src/server/management/request-history-routes.ts +4 -2
  113. package/src/server/management/route-registry.ts +5 -4
  114. package/src/server/management/shared.ts +66 -3
  115. package/src/server/management-api.ts +15 -1
  116. package/src/server/port-reclaim.ts +11 -26
  117. package/src/server/request-decompress.ts +91 -3
  118. package/src/server/request-log.ts +16 -0
  119. package/src/server/responses/codex-ws-wire.ts +1 -1
  120. package/src/server/responses/collaboration.ts +4 -9
  121. package/src/server/responses/compact.ts +8 -2
  122. package/src/server/responses/context-overflow.ts +11 -0
  123. package/src/server/responses/core.ts +285 -57
  124. package/src/server/responses/fetch-helpers.ts +18 -7
  125. package/src/server/responses/policy-fallback.ts +18 -2
  126. package/src/server/search.ts +2 -2
  127. package/src/service.ts +128 -9
  128. package/src/storage/cleanup.ts +77 -45
  129. package/src/types/accounts.ts +18 -0
  130. package/src/types/config.ts +43 -1
  131. package/src/types/provider.ts +56 -0
  132. package/src/types.ts +4 -0
  133. package/src/usage/log.ts +24 -0
  134. package/src/vision/anthropic-describe.ts +1 -0
  135. package/src/web-search/anthropic-executor.ts +1 -0
  136. package/src/web-search/loop.ts +1 -0
  137. package/src/web-search/ollama-executor.ts +127 -0
  138. package/src/web-search/passthrough-bridge.ts +761 -0
  139. package/src/web-search/progress-stream.ts +4 -0
  140. package/gui/dist/assets/index-B5r7LNHN.js +0 -115
  141. package/gui/dist/assets/index-D5SiRo8X.css +0 -1
@@ -29,7 +29,7 @@ import { sidecarEnter } from "../lib/sidecar-tracker";
29
29
  import type { OcxConfig } from "../types";
30
30
  import { resolveFirstUsableOpenAiSidecar, selectImagesProvider } from "../providers/openai-sidecar";
31
31
  import { getProviderRegistryEntry } from "../providers/registry";
32
- import { readJsonRequestBody } from "./request-decompress";
32
+ import { readJsonRequestBody, resolveInboundBodyLimitBytes } from "./request-decompress";
33
33
  import { ForwardAdmissionCredentialError, validateForwardAdmissionCredential } from "./auth-cors";
34
34
  import type { RequestLogContext } from "./request-log";
35
35
  import { codexLogAccountId, decodeRequestErrorResponse } from "./responses";
@@ -288,6 +288,7 @@ async function tryCcaImageGeneration(
288
288
  try {
289
289
  upstream = await fetch(`${baseUrl}/v1internal:generateContent`, {
290
290
  method: "POST",
291
+ redirect: "manual",
291
292
  headers: {
292
293
  "Content-Type": "application/json",
293
294
  "Authorization": `Bearer ${token}`,
@@ -601,7 +602,7 @@ export async function handleImages(
601
602
  ): Promise<Response> {
602
603
  let body: unknown;
603
604
  try {
604
- body = await readJsonRequestBody(req);
605
+ body = await readJsonRequestBody(req, undefined, resolveInboundBodyLimitBytes(config.maxInboundBodyBytes));
605
606
  } catch (err) {
606
607
  return decodeRequestErrorResponse(err, "images");
607
608
  }
@@ -63,7 +63,11 @@ import { runModelRenameStartupMigration } from "../providers/model-rename-startu
63
63
  import { isCanonicalOpenAiForwardProvider, OPENAI_CODEX_PROVIDER_ID } from "../providers/openai-tiers";
64
64
  import { providerCodexAccountMode } from "../providers/registry";
65
65
  import type { StorageCleanupPolicy } from "../types";
66
- import { MAX_DECOMPRESSED_BODY_BYTES } from "./request-decompress";
66
+ import {
67
+ MAX_CONFIGURABLE_INBOUND_BODY_BYTES,
68
+ MIN_CONFIGURABLE_INBOUND_BODY_BYTES,
69
+ resolveInboundBodyLimitBytes,
70
+ } from "./request-decompress";
67
71
  import {
68
72
  CodexAccountCooldownError,
69
73
  cooldownErrorMessage,
@@ -1023,6 +1027,22 @@ export function startServer(port?: number, deps: StartServerDeps = {}): Server<W
1023
1027
  let loopbackServer: Server<WsData> | null = null;
1024
1028
  let managementIngressServer: Server<WsData> | null = null;
1025
1029
 
1030
+ // Resolved once, before any listener binds. The clamp is silent inside the resolver so it
1031
+ // stays pure and per-request cheap; the operator is told here instead, once, because a
1032
+ // config value that was quietly reduced is exactly the thing they would otherwise debug
1033
+ // against the wrong limit.
1034
+ const inboundBodyLimitBytes = resolveInboundBodyLimitBytes(config.maxInboundBodyBytes);
1035
+ const requestedInboundBodyLimit = config.maxInboundBodyBytes;
1036
+ if (requestedInboundBodyLimit !== undefined
1037
+ && requestedInboundBodyLimit > 0
1038
+ && requestedInboundBodyLimit !== inboundBodyLimitBytes) {
1039
+ console.warn(
1040
+ `[server] maxInboundBodyBytes=${requestedInboundBodyLimit} is outside the supported range `
1041
+ + `[${MIN_CONFIGURABLE_INBOUND_BODY_BYTES}, ${MAX_CONFIGURABLE_INBOUND_BODY_BYTES}]; `
1042
+ + `using ${inboundBodyLimitBytes} bytes.`,
1043
+ );
1044
+ }
1045
+
1026
1046
  type ServerIngress = "public" | "unauthenticated-loopback" | "hub-management";
1027
1047
  function ingressForServer(requestServer: Server<WsData>): ServerIngress {
1028
1048
  if (requestServer === loopbackServer) return "unauthenticated-loopback";
@@ -1042,7 +1062,10 @@ export function startServer(port?: number, deps: StartServerDeps = {}): Server<W
1042
1062
  userCostOverlayReconciler = startUserCostOverlayReconciler({ liveConfig: config });
1043
1063
  const serveOptions = {
1044
1064
  idleTimeout: 255,
1045
- maxRequestBodySize: MAX_DECOMPRESSED_BODY_BYTES,
1065
+ // Bun rejects an oversized body before `fetch` runs, so the listener has to be raised
1066
+ // with the admission limit or the opt-in would do nothing. Fixed at bind time: a live
1067
+ // `maxInboundBodyBytes` edit needs a restart, which the config doc states.
1068
+ maxRequestBodySize: inboundBodyLimitBytes,
1046
1069
  async fetch(req: Request, requestServer: Server<WsData>): Promise<Response> {
1047
1070
  const ingress = ingressForServer(requestServer);
1048
1071
  // The unauthenticated loopback listener (#1102) serves a fixed allowlist and nothing
@@ -3,6 +3,7 @@ import { registerOptionalShutdownHook } from "../../lib/optional-shutdown-hooks"
3
3
 
4
4
  const MAX_SELECTION_STREAMS = 64;
5
5
  const HEARTBEAT_MS = 15_000;
6
+ const STREAM_QUEUE_HIGH_WATER_MARK = 16;
6
7
  const encoder = new TextEncoder();
7
8
  const connections = new Set<() => void>();
8
9
 
@@ -36,9 +37,17 @@ export function accountSelectionStream(request: Request, validate: () => boolean
36
37
  const send = (frame: string) => {
37
38
  if (closed) return;
38
39
  if (!authorized()) {
39
- // Error clears queued frames as well, so a revoked consumer cannot drain them.
40
- try { controller.error(new DOMException("Management session is no longer authorized", "NotAllowedError")); }
41
- finally { close(); }
40
+ // A revoked consumer must not drain frames queued before revocation: error() is
41
+ // what discards a non-empty queue. When nothing is queued — the common expired-session
42
+ // path, including the heartbeat — close() alone terminates quietly, so an expired
43
+ // dashboard session does not dump an expected DOMException into the server console.
44
+ // desiredSize equals the high water mark exactly when the queue is empty.
45
+ if (controller.desiredSize !== null && controller.desiredSize < STREAM_QUEUE_HIGH_WATER_MARK) {
46
+ try { controller.error(new DOMException("Management session is no longer authorized", "NotAllowedError")); }
47
+ finally { close(); }
48
+ } else {
49
+ close();
50
+ }
42
51
  return;
43
52
  }
44
53
  // Reconnection sends a ready event, so a slow reader can reconcile without an
@@ -61,7 +70,7 @@ export function accountSelectionStream(request: Request, validate: () => boolean
61
70
  heartbeat.unref?.();
62
71
  },
63
72
  cancel() { cleanup(); },
64
- }, { highWaterMark: 16 });
73
+ }, { highWaterMark: STREAM_QUEUE_HIGH_WATER_MARK });
65
74
  return new Response(body, { headers: {
66
75
  "Content-Type": "text/event-stream; charset=utf-8",
67
76
  "Cache-Control": "no-cache, no-transform",
@@ -329,6 +329,8 @@ export async function handleConfigRoutes(ctx: ManagementContext): Promise<Respon
329
329
  oauthOpenBrowser: config.oauthOpenBrowser !== false,
330
330
  // Absent means off (today's Design B injection), so the GUI/CLI render a plain switch.
331
331
  codexDesktopAuthless: config.codexDesktopAuthless === true,
332
+ // Absent keeps Design B remote compaction; true selects the dedicated provider identity.
333
+ codexClientCompaction: config.codexClientCompaction === true,
332
334
  startupHealth: await readStartupHealth(config),
333
335
  codexRuntime: {
334
336
  path: displayCodexRuntimePath(resolved.runtime.command),
@@ -419,6 +421,7 @@ export async function handleConfigRoutes(ctx: ManagementContext): Promise<Respon
419
421
  ultraFastTier?: unknown;
420
422
  codexMainAccountHardLock?: unknown;
421
423
  codexDesktopAuthless?: unknown;
424
+ codexClientCompaction?: unknown;
422
425
  };
423
426
  if (body.codexAutoStart === undefined
424
427
  && body.streamMode === undefined
@@ -429,8 +432,9 @@ export async function handleConfigRoutes(ctx: ManagementContext): Promise<Respon
429
432
  && body.showCodexSparkQuota === undefined
430
433
  && body.ultraFastTier === undefined
431
434
  && body.codexMainAccountHardLock === undefined
432
- && body.codexDesktopAuthless === undefined) {
433
- return jsonResponse({ error: "provide codexAutoStart, streamMode, appOwnedMemoryBudgetMb, codexAccountPickerEnabled, codexQuotaAutoRefresh, oauthOpenBrowser, showCodexSparkQuota, ultraFastTier, codexMainAccountHardLock, or codexDesktopAuthless" }, 400);
435
+ && body.codexDesktopAuthless === undefined
436
+ && body.codexClientCompaction === undefined) {
437
+ return jsonResponse({ error: "provide codexAutoStart, streamMode, appOwnedMemoryBudgetMb, codexAccountPickerEnabled, codexQuotaAutoRefresh, oauthOpenBrowser, showCodexSparkQuota, ultraFastTier, codexMainAccountHardLock, codexDesktopAuthless, or codexClientCompaction" }, 400);
434
438
  }
435
439
  if (body.codexAutoStart !== undefined && typeof body.codexAutoStart !== "boolean") {
436
440
  return jsonResponse({ error: "codexAutoStart boolean is required" }, 400);
@@ -457,6 +461,9 @@ export async function handleConfigRoutes(ctx: ManagementContext): Promise<Respon
457
461
  if (body.codexDesktopAuthless !== undefined && typeof body.codexDesktopAuthless !== "boolean") {
458
462
  return jsonResponse({ error: "codexDesktopAuthless boolean is required" }, 400);
459
463
  }
464
+ if (body.codexClientCompaction !== undefined && typeof body.codexClientCompaction !== "boolean") {
465
+ return jsonResponse({ error: "codexClientCompaction boolean is required" }, 400);
466
+ }
460
467
  let quotaAutoRefreshChange: { id: string; window: "fiveHour" | "weekly"; enabled: boolean } | undefined;
461
468
  if (body.codexQuotaAutoRefresh !== undefined) {
462
469
  if (!isPlainRecord(body.codexQuotaAutoRefresh)) {
@@ -509,10 +516,13 @@ export async function handleConfigRoutes(ctx: ManagementContext): Promise<Respon
509
516
  hasCodexMainAccountHardLock: Object.hasOwn(config, "codexMainAccountHardLock"),
510
517
  codexDesktopAuthless: config.codexDesktopAuthless,
511
518
  hasCodexDesktopAuthless: Object.hasOwn(config, "codexDesktopAuthless"),
519
+ codexClientCompaction: config.codexClientCompaction,
520
+ hasCodexClientCompaction: Object.hasOwn(config, "codexClientCompaction"),
512
521
  };
513
522
  const pickerWasEnabled = codexAccountPickerEnabled(config);
514
523
  let pickerIsEnabled = pickerWasEnabled;
515
524
  const authlessWasEnabled = config.codexDesktopAuthless === true;
525
+ const clientCompactionWasEnabled = config.codexClientCompaction === true;
516
526
  try {
517
527
  if (typeof body.codexAutoStart === "boolean") {
518
528
  config.codexAutoStart = body.codexAutoStart;
@@ -547,6 +557,8 @@ export async function handleConfigRoutes(ctx: ManagementContext): Promise<Respon
547
557
  else if (body.codexMainAccountHardLock === false) deleteConfigTopLevelKey(config, "codexMainAccountHardLock");
548
558
  if (body.codexDesktopAuthless === true) config.codexDesktopAuthless = true;
549
559
  else if (body.codexDesktopAuthless === false) deleteConfigTopLevelKey(config, "codexDesktopAuthless");
560
+ if (body.codexClientCompaction === true) config.codexClientCompaction = true;
561
+ else if (body.codexClientCompaction === false) deleteConfigTopLevelKey(config, "codexClientCompaction");
550
562
  if (quotaAutoRefreshChange) {
551
563
  const { id, window, enabled } = quotaAutoRefreshChange;
552
564
  const setting = { ...(config.codexQuotaAutoRefresh?.[id] ?? {}) };
@@ -592,16 +604,22 @@ export async function handleConfigRoutes(ctx: ManagementContext): Promise<Respon
592
604
  if (previousSettings.hasCodexDesktopAuthless) {
593
605
  config.codexDesktopAuthless = previousSettings.codexDesktopAuthless;
594
606
  } else deleteConfigTopLevelKey(config, "codexDesktopAuthless");
607
+ if (previousSettings.hasCodexClientCompaction) {
608
+ config.codexClientCompaction = previousSettings.codexClientCompaction;
609
+ } else deleteConfigTopLevelKey(config, "codexClientCompaction");
595
610
  throw error;
596
611
  }
597
612
  if (typeof body.appOwnedMemoryBudgetMb === "number") {
598
613
  configureAppOwnedMemoryBudget(resolveAppOwnedMemoryBudgetBytes(body.appOwnedMemoryBudgetMb));
599
614
  enforceAppOwnedMemoryBudget();
600
615
  }
601
- // The authless switch changes the injected config.toml shape, so converge now rather than
602
- // waiting for the next start; the injector re-reads config and rewrites the form.
616
+ // Both Desktop compatibility switches change the injected config.toml shape, so converge now
617
+ // rather than waiting for the next start; the injector re-reads config and rewrites the form.
603
618
  const authlessIsEnabled = config.codexDesktopAuthless === true;
604
- const catalogRefresh = pickerWasEnabled !== pickerIsEnabled || authlessWasEnabled !== authlessIsEnabled
619
+ const clientCompactionIsEnabled = config.codexClientCompaction === true;
620
+ const catalogRefresh = pickerWasEnabled !== pickerIsEnabled
621
+ || authlessWasEnabled !== authlessIsEnabled
622
+ || clientCompactionWasEnabled !== clientCompactionIsEnabled
605
623
  ? await convergeCodexCatalog()
606
624
  : undefined;
607
625
  const catalogRefreshPending = catalogRefresh
@@ -620,6 +638,7 @@ export async function handleConfigRoutes(ctx: ManagementContext): Promise<Respon
620
638
  catalogRefreshPending,
621
639
  showCodexSparkQuota: config.showCodexSparkQuota === true,
622
640
  codexDesktopAuthless: authlessIsEnabled,
641
+ codexClientCompaction: clientCompactionIsEnabled,
623
642
  codexMainAccountHardLock: config.codexMainAccountHardLock === true,
624
643
  mainAccountHardLock: getMainAccountHardLockStatus(config),
625
644
  startupHealth: await readStartupHealth(config),
@@ -115,7 +115,10 @@ export async function handleLogsUsageRoutes(ctx: ManagementContext): Promise<Res
115
115
  }
116
116
  const all = getRequestLogEntries();
117
117
  const total = filteredRequestLogCount(all, url.searchParams);
118
- const logs = filterRequestLogs(all, url.searchParams).map(requestLogDto);
118
+ // Not point-free: requestLogDto takes an options object second, and Array.map would pass the
119
+ // element INDEX into it. An explicit arrow keeps the default (decode rate included) and is
120
+ // what /api/logs wants; /api/request-history opts out at its own call sites.
121
+ const logs = filterRequestLogs(all, url.searchParams).map(entry => requestLogDto(entry));
119
122
  const poll = selectRequestLogPoll(logs, url.searchParams, cursor);
120
123
  return jsonResponse({
121
124
  timeZone: Intl.DateTimeFormat().resolvedOptions().timeZone,
@@ -425,6 +428,7 @@ export async function handleLogsUsageRoutes(ctx: ManagementContext): Promise<Res
425
428
  bytes: result.bytes,
426
429
  ...(result.trashDir ? { trashDir: result.trashDir } : {}),
427
430
  removedPaths: result.removedPaths,
431
+ ...(result.skippedReferencedPaths?.length ? { skippedReferencedPaths: result.skippedReferencedPaths } : {}),
428
432
  });
429
433
  } catch {
430
434
  return jsonResponse({
@@ -147,10 +147,25 @@ export async function listManagementModelRows(
147
147
  };
148
148
  });
149
149
  const publicModels = uniqueCatalogModelsForPublicList(models);
150
+ // Custom rows below are REBUILT from config.customModels rather than spread from a
151
+ // CatalogModel, so every field gather computed for the same slug has to be carried across by
152
+ // hand. Without this a custom model whose provider is out of credit would be the one row on
153
+ // the page that never shows as inactive (#1711), because the gather-derived row it replaces
154
+ // is dropped by the slug dedup below.
155
+ const quotaInactiveByNamespaced = new Map(
156
+ publicModels
157
+ .filter(model => model.quotaInactiveReason !== undefined)
158
+ .map(model => [catalogModelSlug(model), model.quotaInactiveReason!] as const),
159
+ );
150
160
  const comboNamespaced = new Set(
151
161
  publicModels.filter(model => model.provider === "combo").map(catalogModelSlug),
152
162
  );
153
- const visibleCustomModels = customModels.filter(model => !comboNamespaced.has(model.namespaced));
163
+ const visibleCustomModels = customModels
164
+ .filter(model => !comboNamespaced.has(model.namespaced))
165
+ .map(model => {
166
+ const quotaInactiveReason = quotaInactiveByNamespaced.get(model.namespaced);
167
+ return quotaInactiveReason ? { ...model, quotaInactiveReason } : model;
168
+ });
154
169
  // Custom metadata wins when a physical live/static row resolves to the same Codex-facing
155
170
  // slug, while a combo keeps the same precedence it has in routing and /v1/models.
156
171
  const customNamespaced = new Set(visibleCustomModels.map(c => c.namespaced));
@@ -366,13 +366,14 @@ async function handleCodexToggle(ctx: ManagementContext): Promise<Response> {
366
366
  }
367
367
  }
368
368
  const { restoreNativeCodexAsync } = await import("../../codex/inject");
369
+ const { OCX_NATIVE_REPLAY_RECOVERY_NOTE } = await import("../../responses/compaction");
369
370
  const restored = await restoreNativeCodexAsync({ revalidateDesiredState: true });
370
371
  return jsonResponse({
371
372
  ok: true, clientId: "codex", changed: durable && persisted.status === "committed",
372
373
  state: restored.success ? "absent" : "unsafe",
373
374
  desiredEnabled: enabled,
374
375
  message: restored.success
375
- ? "Codex restored to its native path; the proxy is still serving other clients"
376
+ ? `Codex restored to its native path; the proxy is still serving other clients. ${OCX_NATIVE_REPLAY_RECOVERY_NOTE}`
376
377
  : `Codex intent saved, but restoring the native path did not complete: ${restored.message}`,
377
378
  ...(restored.success
378
379
  ? (durable ? {} : { reason: "not_durable" })
@@ -25,6 +25,7 @@ import {
25
25
  } from "../../oauth";
26
26
  import { OAuthMutationBusyError, removeCredential } from "../../oauth/store";
27
27
  import { providerDestinationResolvedError } from "../../lib/destination-policy";
28
+ import { emailMaskingEnabled } from "../../lib/privacy";
28
29
  import { reconcileLiveStateStores } from "../../lib/state-store-registrations";
29
30
  import { enrichProviderFromCatalog, listKeyLoginProviders } from "../../oauth/key-providers";
30
31
  import { deriveProviderPresets } from "../../providers/derive";
@@ -233,7 +234,10 @@ export async function handleOauthAccountRoutes(ctx: ManagementContext): Promise<
233
234
  if (url.pathname === "/api/oauth/status" && req.method === "GET") {
234
235
  const provider = (url.searchParams.get("provider") ?? "").trim().toLowerCase();
235
236
  if (!isPublicOAuthProvider(provider)) return jsonResponse({ error: "unknown oauth provider" }, 400);
236
- const status = getLoginStatus(provider);
237
+ // Resolved here, at the request boundary that already holds the config, and passed down.
238
+ // getLoginStatus stays free of config I/O. This route does not re-mask afterwards: it
239
+ // consumes the already-projected status rather than redacting a second time.
240
+ const status = getLoginStatus(provider, emailMaskingEnabled(config));
237
241
  return jsonResponse(status);
238
242
  }
239
243
 
@@ -269,7 +273,7 @@ export async function handleOauthAccountRoutes(ctx: ManagementContext): Promise<
269
273
  } = await import("../../oauth/health");
270
274
  const projectAccounts = () => {
271
275
  const set = getAccountSet(provider);
272
- const current = getLoginStatus(provider);
276
+ const current = getLoginStatus(provider, emailMaskingEnabled(config));
273
277
  return {
274
278
  activeAccountId: current.activeAccountId ?? null,
275
279
  accounts: (current.accounts ?? []).map(summary => {
@@ -40,6 +40,8 @@ import { providerDestinationResolvedError } from "../../lib/destination-policy";
40
40
  import { reconcileLiveStateStores } from "../../lib/state-store-registrations";
41
41
  import { ProviderOutboundPolicyError, providerOutboundGet, providerOutboundPost, providerRedirectError } from "../../lib/provider-outbound";
42
42
  import { fetchCursorUsableModels } from "../../adapters/cursor/live-models";
43
+ import { fetchQoderModels } from "../../adapters/qoder/live-models";
44
+ import { resolveQoderProfile } from "../../adapters/qoder/profiles";
43
45
  import { parseAntigravityAvailableModels } from "../../providers/antigravity-models";
44
46
  import { enrichProviderFromCatalog, listKeyLoginProviders } from "../../oauth/key-providers";
45
47
  import { deriveProviderPresets, providerConfigSeed } from "../../providers/derive";
@@ -52,6 +54,7 @@ import {
52
54
  readBoundedDiscoveryJson,
53
55
  resolveProviderModelDiscovery,
54
56
  } from "../../providers/model-discovery";
57
+ import { extractGoogleAiStudioModelItems } from "../../providers/google-ai-studio-model-discovery";
55
58
  import { routedSlug, slugEquals } from "../../providers/slug-codec";
56
59
  import { clearAccountQuotaCache, clearProviderQuotaCache, fetchProviderQuotaReports } from "../../providers/quota";
57
60
  import { clearKeyCooldowns } from "../../providers/key-failover";
@@ -1350,6 +1353,28 @@ export async function handleProviderRoutes(ctx: ManagementContext): Promise<Resp
1350
1353
  message: `Connected. ${live.models.length} models.`,
1351
1354
  });
1352
1355
  }
1356
+ if (prov.adapter === "qoder") {
1357
+ const started = Date.now();
1358
+ const profile = resolveQoderProfile(prov.baseUrl);
1359
+ if (!profile) {
1360
+ return jsonResponse({ ok: false, latencyMs: 0, error: "qoder discovery refused a non-canonical destination" });
1361
+ }
1362
+ const live = await fetchQoderModels(profile, apiKey ?? "");
1363
+ const latencyMs = Date.now() - started;
1364
+ if (!live.ok) {
1365
+ return jsonResponse({
1366
+ ok: false,
1367
+ latencyMs,
1368
+ error: `qoder discovery ${live.error}${live.detail ? `: ${live.detail}` : ""}`,
1369
+ });
1370
+ }
1371
+ return jsonResponse({
1372
+ ok: true,
1373
+ latencyMs,
1374
+ models: live.models.length,
1375
+ message: `Connected. ${live.models.length} models.`,
1376
+ });
1377
+ }
1353
1378
  const project = prov.project ?? snapshot?.projectId;
1354
1379
  if (antigravity && !project) {
1355
1380
  return jsonResponse({ ok: false, latencyMs: 0, error: "Antigravity project unavailable — re-run `ocx login google-antigravity`" });
@@ -1404,13 +1429,19 @@ export async function handleProviderRoutes(ctx: ManagementContext): Promise<Resp
1404
1429
  return jsonResponse({ ok: false, latencyMs, error: "upstream CCA model discovery returned an unexpected shape" });
1405
1430
  }
1406
1431
  // OpenAI-style lists (and Together top-level arrays) use the same validation/dedupe/filter
1407
- // as catalog discovery. Google's /v1beta/models uses `models[].name` and remains a
1408
- // connectivity-only count because it is not an authoritative catalog source.
1432
+ // as catalog discovery. Google AI Studio parses the native `models[]` envelope and filters to
1433
+ // `generateContent`, while other providers fall back to generic envelope rows if they return `models[]`.
1409
1434
  const record = bounded.value !== null && typeof bounded.value === "object" && !Array.isArray(bounded.value)
1410
1435
  ? bounded.value as Record<string, unknown>
1411
1436
  : undefined;
1437
+ const isAiStudio = effectiveGoogleMode(name, prov) === "ai-studio";
1438
+ const googleAiStudio = !ccaModels && isAiStudio
1439
+ ? extractGoogleAiStudioModelItems(bounded.value, discovery.maxModels)
1440
+ : undefined;
1412
1441
  const extracted = ccaModels
1413
1442
  ? undefined
1443
+ : googleAiStudio?.ok
1444
+ ? googleAiStudio
1414
1445
  : Array.isArray(bounded.value) || Array.isArray(record?.data)
1415
1446
  ? extractProviderModelItems(bounded.value, discovery)
1416
1447
  : extractModelEnvelopeRows(bounded.value, discovery.maxModels, ["models"]);
@@ -106,7 +106,9 @@ export async function handleRequestHistoryRoutes(ctx: ManagementContext): Promis
106
106
  to,
107
107
  }, cursor, limit);
108
108
  return jsonResponse({
109
- entries: page.rows.map(row => requestLogDto(requestLogEntryFromPersistedUsage(row))),
109
+ // The decode rate is a Logs-page metric; this endpoint shares the DTO but not its
110
+ // contract, so it opts out rather than silently widening its own response shape (#4038).
111
+ entries: page.rows.map(row => requestLogDto(requestLogEntryFromPersistedUsage(row), { includeDecodeRate: false })),
110
112
  ...(page.nextCursor ? { nextCursor: page.nextCursor } : {}),
111
113
  hasMore: page.hasMore,
112
114
  index: {
@@ -184,7 +186,7 @@ export async function handleRequestHistoryRoutes(ctx: ManagementContext): Promis
184
186
  if (!entry) {
185
187
  return jsonResponse({ error: { code: "not_found", message: "unknown request" } }, 404, req, config);
186
188
  }
187
- return jsonResponse(requestLogDto(requestLogEntryFromPersistedUsage(entry)), 200, req, config);
189
+ return jsonResponse(requestLogDto(requestLogEntryFromPersistedUsage(entry), { includeDecodeRate: false }), 200, req, config);
188
190
  }
189
191
 
190
192
  return null;
@@ -95,6 +95,7 @@ export const MANAGEMENT_ROUTES: readonly ManagementRoute[] = [
95
95
  { method: "PATCH", path: "/api/codex-auth/pool-strategy", module: "codex/auth-api", mutates: true },
96
96
  { method: "POST", path: "/api/codex-auth/accounts", module: "codex/auth-api", mutates: true },
97
97
  { method: "POST", path: "/api/codex-auth/accounts/clear-cooldown", module: "codex/auth-api", mutates: true },
98
+ { method: "POST", path: "/api/codex-auth/accounts/refresh", module: "codex/auth-api", mutates: true },
98
99
  { method: "POST", path: "/api/codex-auth/login", module: "codex/auth-api", mutates: true },
99
100
  { method: "POST", path: "/api/codex-auth/login/cancel", module: "codex/auth-api", mutates: true },
100
101
  { method: "POST", path: "/api/codex-auth/login/code", module: "codex/auth-api", mutates: true },
@@ -148,9 +149,9 @@ export const MANAGEMENT_ROUTES: readonly ManagementRoute[] = [
148
149
  { method: "GET", path: "/api/client-integrations/aside/profiles/{profileId}", module: "server/management/aside-profile-routes", mutates: false, mechanism: "prefix-decode" },
149
150
  { method: "PUT", path: "/api/client-integrations/aside/profiles/{profileId}", module: "server/management/aside-profile-routes", mutates: true, mechanism: "prefix-decode" },
150
151
  { method: "GET", path: "/api/client-integrations/aside/profiles/journal", module: "server/management/aside-profile-routes", mutates: false, mechanism: "prefix-decode" },
151
- { method: "DELETE", path: "/api/client-integrations/aside/profiles/journal", module: "server/management/aside-profile-routes", mutates: true, mechanism: "prefix-decode", exempt: { reason: "deferred-verb", why: "Aside history deletion uses the dashboard journal cleanup; the CLI has history and restore but no deletion verb yet.", owner: "260904_priority65_closeout WP7", ownerDoc: "devlog/_plan/260904_priority65_closeout/060_wp7_rollback_journal_crud.md" } },
152
+ { method: "DELETE", path: "/api/client-integrations/aside/profiles/journal", module: "server/management/aside-profile-routes", mutates: true, mechanism: "prefix-decode", exempt: { reason: "deferred-verb", why: "Aside history deletion uses the dashboard journal cleanup; the CLI has history and restore but no deletion verb yet.", owner: "260904_priority65_closeout WP7", ownerDoc: "devlog/_fin/260904_priority65_closeout/060_wp7_rollback_journal_crud.md" } },
152
153
  { method: "GET", path: "/api/client-integrations/aside/profiles/{profileId}/journal", module: "server/management/aside-profile-routes", mutates: false, mechanism: "prefix-decode" },
153
- { method: "DELETE", path: "/api/client-integrations/aside/profiles/{profileId}/journal", module: "server/management/aside-profile-routes", mutates: true, mechanism: "prefix-decode", exempt: { reason: "deferred-verb", why: "Aside profile history deletion uses the dashboard journal cleanup; the CLI has scoped history and restore but no deletion verb yet.", owner: "260904_priority65_closeout WP7", ownerDoc: "devlog/_plan/260904_priority65_closeout/060_wp7_rollback_journal_crud.md" } },
154
+ { method: "DELETE", path: "/api/client-integrations/aside/profiles/{profileId}/journal", module: "server/management/aside-profile-routes", mutates: true, mechanism: "prefix-decode", exempt: { reason: "deferred-verb", why: "Aside profile history deletion uses the dashboard journal cleanup; the CLI has scoped history and restore but no deletion verb yet.", owner: "260904_priority65_closeout WP7", ownerDoc: "devlog/_fin/260904_priority65_closeout/060_wp7_rollback_journal_crud.md" } },
154
155
  { method: "POST", path: "/api/client-integrations/aside/profiles/{profileId}/restore", module: "server/management/aside-profile-routes", mutates: true, mechanism: "prefix-decode" },
155
156
  // server/management/codex-prompt-routes
156
157
  { method: "GET", path: "/api/codex-prompt", module: "server/management/codex-prompt-routes", mutates: false },
@@ -186,7 +187,7 @@ export const MANAGEMENT_ROUTES: readonly ManagementRoute[] = [
186
187
  // server/management/integration-routes
187
188
  { method: "GET", path: "/api/client-integrations", module: "server/management/integration-routes", mutates: false },
188
189
  { method: "GET", path: "/api/client-integrations/journal", module: "server/management/integration-routes", mutates: false },
189
- { method: "DELETE", path: "/api/client-integrations/journal", module: "server/management/integration-routes", mutates: true, exempt: { reason: "deferred-verb", why: "Retiring one rollback row is a dashboard-local cleanup; the CLI verb that would drive it is owed by a later work-phase and is not implemented here.", owner: "260904_priority65_closeout WP7", ownerDoc: "devlog/_plan/260904_priority65_closeout/060_wp7_rollback_journal_crud.md" } },
190
+ { method: "DELETE", path: "/api/client-integrations/journal", module: "server/management/integration-routes", mutates: true, exempt: { reason: "deferred-verb", why: "Retiring one rollback row is a dashboard-local cleanup; the CLI verb that would drive it is owed by a later work-phase and is not implemented here.", owner: "260904_priority65_closeout WP7", ownerDoc: "devlog/_fin/260904_priority65_closeout/060_wp7_rollback_journal_crud.md" } },
190
191
  { method: "POST", path: "/api/client-integrations/restore", module: "server/management/integration-routes", mutates: true },
191
192
  // server/management/lab-automation-routes
192
193
  { method: "GET", path: "/api/lab/automation", module: "server/management/lab-automation-routes", mutates: false, exempt: { reason: "local-transport", why: "ocx lab reads the same rows from the local SQLite projection; src/cli/lab.ts imports ../lab/query directly and never fetches /api/lab." } },
@@ -292,7 +293,7 @@ export const MANAGEMENT_ROUTES: readonly ManagementRoute[] = [
292
293
  { method: "PATCH", path: "/api/providers", module: "server/management/provider-routes", mutates: true },
293
294
  { method: "POST", path: "/api/providers", module: "server/management/provider-routes", mutates: true },
294
295
  { method: "POST", path: "/api/providers/test", module: "server/management/provider-routes", mutates: true },
295
- { method: "PUT", path: "/api/providers", module: "server/management/provider-routes", mutates: true, exempt: { reason: "deferred-verb", why: "Issue #3280 scopes this atomic batch endpoint to the GUI JSON editor; a matching CLI verb is outside wp5 and remains owed.", owner: "wp5-followup", ownerDoc: "devlog/_plan/260903_bug_drawdown_bcda/050_phase5.md" } },
296
+ { method: "PUT", path: "/api/providers", module: "server/management/provider-routes", mutates: true, exempt: { reason: "deferred-verb", why: "Issue #3280 scopes this atomic batch endpoint to the GUI JSON editor; a matching CLI verb is outside wp5 and remains owed.", owner: "wp5-followup", ownerDoc: "devlog/_fin/260903_bug_drawdown_bcda/050_phase5.md" } },
296
297
  { method: "PUT", path: "/api/provider-context-caps", module: "server/management/provider-routes", mutates: true },
297
298
  // server/management/quota-reset-routes
298
299
  { method: "GET", path: "/api/quota-resets", module: "server/management/quota-reset-routes", mutates: false, mechanism: "negated-guard" },
@@ -79,7 +79,8 @@ export function parseDebugLogQuery(url: URL): { after: number; limit: number } {
79
79
  export type MetricUnavailableReason =
80
80
  | "usage_missing" | "usage_unsupported" | "output_missing" | "invalid_duration"
81
81
  | "price_unmatched" | "invalid_cache_breakdown"
82
- | "invalid_usage" | "combo_attempt_unavailable";
82
+ | "invalid_usage" | "combo_attempt_unavailable"
83
+ | "ttft_missing" | "decode_window_too_short";
83
84
 
84
85
  export type TokPerSecondResult =
85
86
  | { kind: "value"; value: number; estimated: boolean }
@@ -96,7 +97,7 @@ export type CostResult =
96
97
  | { kind: "value"; estimate: NonNullable<ReturnType<typeof estimateRequestCost>>; estimateReasons: CostEstimateReason[] }
97
98
  | { kind: "unavailable"; reason: MetricUnavailableReason };
98
99
 
99
- export type MetricSource = Pick<RequestLogEntry, "provider" | "model" | "durationMs" | "usageStatus" | "usage" | "requestedServiceTier" | "configuredServiceTier" | "responseServiceTier" | "tierOutcome" | "routeDecision"> & {
100
+ export type MetricSource = Pick<RequestLogEntry, "provider" | "model" | "durationMs" | "firstOutputMs" | "usageStatus" | "usage" | "requestedServiceTier" | "configuredServiceTier" | "responseServiceTier" | "tierOutcome" | "routeDecision"> & {
100
101
  attempts?: readonly PersistedUsageAttempt[];
101
102
  };
102
103
 
@@ -113,6 +114,51 @@ export function tokPerSecondResult(entry: Pick<MetricSource, "durationMs" | "usa
113
114
  return { kind: "value", value, estimated: entry.usageStatus === "estimated" || entry.usage.estimated === true };
114
115
  }
115
116
 
117
+ /**
118
+ * Shortest post-TTFT window that can carry a decode-rate estimate (#4038).
119
+ *
120
+ * Below one second the window is dominated by things that are not decoding: TTFT jitter, the
121
+ * proxy's own buffering, and the granularity of the timestamps themselves. A 240-token response
122
+ * whose first token arrived 50 ms before the last one is not a 4800 tok/s model, and printing
123
+ * that number is worse than printing nothing — which is precisely why the earlier attempt at
124
+ * this metric (#4040) was closed as an unreliable estimate.
125
+ */
126
+ export const MIN_DECODE_WINDOW_MS = 1_000;
127
+
128
+ /**
129
+ * Estimated DECODE throughput: output tokens over the window after the first token (#4038).
130
+ *
131
+ * Strictly additive. `tokensPerSecond`, `tokPerSecondResult`, `RequestLogEntry` and
132
+ * `usage.jsonl` are untouched, and the end-to-end rate beside it keeps meaning exactly what it
133
+ * has always meant — it is documented as end-to-end, so this is a missing metric rather than a
134
+ * miscalculated one.
135
+ *
136
+ * Always `estimated: true`. The proxy's TTFT is when the FIRST BYTE reached the proxy, which is
137
+ * not the provider's own generation start, so this can never be more than an estimate no matter
138
+ * how long the window is. Saying so in the payload is the honest half of the answer to #4040;
139
+ * MIN_DECODE_WINDOW_MS is the other half.
140
+ */
141
+ export function decodeTokPerSecondResult(
142
+ entry: Pick<MetricSource, "durationMs" | "firstOutputMs" | "usageStatus" | "usage">,
143
+ ): TokPerSecondResult {
144
+ if (!entry.usage) return { kind: "unavailable", reason: "usage_missing" };
145
+ if (entry.usageStatus === "unsupported") return { kind: "unavailable", reason: "usage_unsupported" };
146
+ if (entry.usage.outputTokens <= 0) return { kind: "unavailable", reason: "output_missing" };
147
+ // A row that predates TTFT capture, or a non-streaming turn that never recorded one, has no
148
+ // window to measure. That is a different fact from a bad duration, so it gets its own reason.
149
+ if (entry.firstOutputMs === undefined) return { kind: "unavailable", reason: "ttft_missing" };
150
+ if (!Number.isFinite(entry.firstOutputMs) || entry.firstOutputMs < 0 || !Number.isFinite(entry.durationMs)) {
151
+ return { kind: "unavailable", reason: "invalid_duration" };
152
+ }
153
+ const windowMs = entry.durationMs - entry.firstOutputMs;
154
+ // TTFT at or past the total duration means the two clocks disagree; there is no window.
155
+ if (windowMs <= 0) return { kind: "unavailable", reason: "invalid_duration" };
156
+ if (windowMs < MIN_DECODE_WINDOW_MS) return { kind: "unavailable", reason: "decode_window_too_short" };
157
+ const value = tokensPerSecond(entry.usage.outputTokens, windowMs);
158
+ if (value === null) return { kind: "unavailable", reason: "invalid_duration" };
159
+ return { kind: "value", value, estimated: true };
160
+ }
161
+
116
162
  export function unavailableCostReason(entry: MetricSource): MetricUnavailableReason {
117
163
  // Normalizer-first classification: the landed normalizer recovers legacy
118
164
  // cachedInputTokens=read+write rows via retry, so a raw read+write>input
@@ -152,11 +198,26 @@ export function costResult(entry: MetricSource): CostResult {
152
198
  return { kind: "value", estimate, estimateReasons };
153
199
  }
154
200
 
155
- export function requestLogDto(entry: RequestLogEntry): Record<string, unknown> {
201
+ /**
202
+ * `/api/logs` row projection.
203
+ *
204
+ * `includeDecodeRate` exists because `/api/request-history` shares this DTO but not its
205
+ * contract (#4038). The value would be meaningful there — `firstOutputMs` does survive into a
206
+ * persisted-usage row — so this is a scope decision, not a correctness one: the decode rate is
207
+ * a Logs-page metric, and widening a separate endpoint's response shape is not this change's
208
+ * business. Flipping it on later is one argument.
209
+ */
210
+ export function requestLogDto(
211
+ entry: RequestLogEntry,
212
+ { includeDecodeRate = true }: { includeDecodeRate?: boolean } = {},
213
+ ): Record<string, unknown> {
156
214
  return {
157
215
  ...entry,
158
216
  displayMetrics: {
159
217
  tokPerSecond: tokPerSecondResult(entry),
218
+ // The parent uses the REQUEST's own TTFT. A combo parent must not borrow an attempt's,
219
+ // which would measure a window the parent never had.
220
+ ...(includeDecodeRate ? { decodeTokPerSecond: decodeTokPerSecondResult(entry) } : {}),
160
221
  cost: costResult(entry),
161
222
  },
162
223
  ...(entry.attempts?.length
@@ -165,6 +226,8 @@ export function requestLogDto(entry: RequestLogEntry): Record<string, unknown> {
165
226
  ...attempt,
166
227
  displayMetrics: {
167
228
  tokPerSecond: tokPerSecondResult(attempt),
229
+ // Each attempt measures its own attempt-relative TTFT.
230
+ ...(includeDecodeRate ? { decodeTokPerSecond: decodeTokPerSecondResult(attempt) } : {}),
168
231
  cost: costResult({ ...attempt, attempts: undefined, routeDecision: entry.routeDecision, requestedServiceTier: entry.requestedServiceTier, configuredServiceTier: entry.configuredServiceTier, responseServiceTier: entry.responseServiceTier }),
169
232
  },
170
233
  })),
@@ -300,6 +300,20 @@ export async function handleManagementAPI(
300
300
  message: "This proxy is managed by a Task Scheduler wrapper that can respawn it, so the stop must be run by `ocx stop`, which verifies the respawn window. Nothing was changed.",
301
301
  }, 409, req, config);
302
302
  }
303
+ if (respawnRisk === "self-unload") {
304
+ // This proxy IS the launchd/systemd job, so stopping the manager below would
305
+ // terminate the handler before the shared teardown at the end of this route restores
306
+ // the native Codex keys — the dashboard Stop button left `openai_base_url`,
307
+ // `experimental_realtime_ws_base_url` and `model_catalog_json` pointed at a dead
308
+ // proxy (#4023). Refuse before touching anything, like the Windows branch above.
309
+ // `ocx stop` is safe because it runs outside this process and owns the teardown
310
+ // through its receipt, which is why the receipt-backed caller never reaches here.
311
+ return jsonResponse({
312
+ success: false,
313
+ code: "self_unload_service",
314
+ message: "This proxy is running as the installed service, so stopping the manager from inside it would end this process before native Codex is restored. Run `ocx stop`, which stops the service from outside and completes the restore. Nothing was changed.",
315
+ }, 409, req, config);
316
+ }
303
317
  if (respawnRisk === "unknown") {
304
318
  // Do NOT send them to `ocx stop`: it maps the same unanswerable probe to a stop
305
319
  // failure, so that advice would be a loop. The scheduler query itself is what needs
@@ -371,7 +385,7 @@ export async function handleManagementAPI(
371
385
  const { ConfigMutationLockError } = await import("../config");
372
386
  const { CodexCredentialRefreshLockTimeoutError } = await import("../codex/account-store");
373
387
  try {
374
- return await handleCodexAuthAPI(req, url, config, convergeCodexCatalog);
388
+ return await handleCodexAuthAPI(req, url, config, convergeCodexCatalog, principal);
375
389
  } catch (error) {
376
390
  // Credential writers remap ConfigMutationLockError to CodexCredentialRefreshLockTimeoutError;
377
391
  // treat both as the same retryable busy response.