@bitkyc08/opencodex 2.77.0 → 2.78.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 (222) hide show
  1. package/bin/ocx.mjs +11 -10
  2. package/gui/dist/assets/App-BtfG5id-.js +54 -0
  3. package/gui/dist/assets/App-lCIslZ1L.css +1 -0
  4. package/gui/dist/assets/{Tray-gloIYNaW.js → Tray-DiJpSub0.js} +1 -1
  5. package/gui/dist/assets/index-Btc3Ogw4.js +94 -0
  6. package/gui/dist/assets/index-DmQB8D_R.css +1 -0
  7. package/gui/dist/assets/quota-summary-B_a96nXj.js +1 -0
  8. package/gui/dist/index.html +2 -2
  9. package/package.json +1 -1
  10. package/src/adapters/anthropic/beta-allowlist.ts +19 -2
  11. package/src/adapters/anthropic/client-identity.ts +13 -4
  12. package/src/adapters/anthropic/native-client-preamble.ts +31 -0
  13. package/src/adapters/anthropic/passthrough.ts +103 -25
  14. package/src/adapters/devin/cloud-direct/chat.ts +7 -2
  15. package/src/adapters/devin/trajectory.ts +49 -0
  16. package/src/adapters/devin.ts +13 -0
  17. package/src/adapters/ollama-native.ts +35 -19
  18. package/src/claude/intercept/client-hello.ts +103 -0
  19. package/src/claude/intercept/model-bindings.ts +3 -2
  20. package/src/claude/intercept/picker-listener.ts +279 -66
  21. package/src/cli/access-audio-input.ts +84 -0
  22. package/src/cli/access-audio-live.ts +99 -0
  23. package/src/cli/access-audio.ts +123 -0
  24. package/src/cli/access-data-client.ts +127 -0
  25. package/src/cli/access-data-plane.ts +104 -0
  26. package/src/cli/access-data-response.ts +76 -0
  27. package/src/cli/access.ts +65 -2
  28. package/src/cli/account-api.ts +30 -6
  29. package/src/cli/account-auth.ts +111 -42
  30. package/src/cli/account-key-quota.ts +118 -0
  31. package/src/cli/account-main.ts +11 -4
  32. package/src/cli/account-next-actions.ts +63 -0
  33. package/src/cli/account-policy-dto.ts +68 -0
  34. package/src/cli/account-policy.ts +167 -0
  35. package/src/cli/account-target.ts +8 -0
  36. package/src/cli/account.ts +34 -8
  37. package/src/cli/agent-runtime-settings.ts +130 -0
  38. package/src/cli/agent-settings.ts +101 -0
  39. package/src/cli/agent.ts +19 -2
  40. package/src/cli/alias.ts +7 -2
  41. package/src/cli/aside-profiles.ts +2 -2
  42. package/src/cli/capabilities-access-remote.ts +147 -0
  43. package/src/cli/capabilities-accounts.ts +195 -0
  44. package/src/cli/capabilities-agents-routing.ts +414 -0
  45. package/src/cli/capabilities-base.ts +783 -0
  46. package/src/cli/capabilities-command.ts +1 -0
  47. package/src/cli/capabilities-integrations.ts +264 -0
  48. package/src/cli/capabilities-lab.ts +214 -0
  49. package/src/cli/capabilities-observe-system.ts +662 -0
  50. package/src/cli/capabilities-provider-models.ts +302 -0
  51. package/src/cli/capabilities.ts +30 -1199
  52. package/src/cli/capability-types.ts +54 -0
  53. package/src/cli/catalog-command-result.ts +57 -0
  54. package/src/cli/chatgpt-command.ts +25 -2
  55. package/src/cli/claude-agent-startup-sync.ts +17 -0
  56. package/src/cli/claude-desktop-profile.ts +100 -0
  57. package/src/cli/claude-desktop.ts +39 -6
  58. package/src/cli/codex-shim-autorestore.ts +2 -1
  59. package/src/cli/codex-shim-readiness.ts +23 -3
  60. package/src/cli/combo-input.ts +88 -0
  61. package/src/cli/combo-result.ts +52 -0
  62. package/src/cli/combo-stats.ts +123 -0
  63. package/src/cli/combo.ts +37 -14
  64. package/src/cli/companion-timeline.ts +142 -0
  65. package/src/cli/companion-usage.ts +146 -0
  66. package/src/cli/companion.ts +12 -3
  67. package/src/cli/config-command.ts +24 -8
  68. package/src/cli/dispatch.ts +52 -79
  69. package/src/cli/doctor.ts +22 -4
  70. package/src/cli/help-catalog.ts +31 -14
  71. package/src/cli/help-recovery.ts +2 -2
  72. package/src/cli/help.ts +25 -11
  73. package/src/cli/index.ts +38 -27
  74. package/src/cli/injection-follow.ts +35 -0
  75. package/src/cli/inspect.ts +5 -0
  76. package/src/cli/integration-aside-sync.ts +69 -0
  77. package/src/cli/integration-cursor.ts +106 -0
  78. package/src/cli/integration-input.ts +60 -0
  79. package/src/cli/integration-journal.ts +49 -0
  80. package/src/cli/integration-plan-dto.ts +156 -0
  81. package/src/cli/integration-preview.ts +215 -0
  82. package/src/cli/integrations.ts +24 -18
  83. package/src/cli/json-input.ts +134 -0
  84. package/src/cli/link.ts +32 -4
  85. package/src/cli/local-sync-result.ts +41 -0
  86. package/src/cli/log-follow.ts +84 -0
  87. package/src/cli/log-view-filter.ts +146 -0
  88. package/src/cli/logout-command.ts +44 -0
  89. package/src/cli/model-picker-ordering.ts +137 -0
  90. package/src/cli/models-custom-input.ts +107 -0
  91. package/src/cli/models-custom-runtime.ts +112 -0
  92. package/src/cli/models-order.ts +194 -0
  93. package/src/cli/models-runtime-subcommands.ts +2 -0
  94. package/src/cli/models-runtime.ts +7 -0
  95. package/src/cli/models.ts +66 -81
  96. package/src/cli/observe-stream.ts +142 -0
  97. package/src/cli/observe.ts +75 -34
  98. package/src/cli/provider-batch.ts +77 -0
  99. package/src/cli/provider-lifecycle-runtime.ts +170 -0
  100. package/src/cli/provider-result.ts +124 -0
  101. package/src/cli/provider-runtime.ts +15 -12
  102. package/src/cli/provider-settings.ts +143 -0
  103. package/src/cli/provider.ts +112 -34
  104. package/src/cli/registry.ts +62 -20
  105. package/src/cli/remote-workspace-hub.ts +83 -0
  106. package/src/cli/remote-workspace.ts +6 -1
  107. package/src/cli/restart-failure.ts +27 -0
  108. package/src/cli/root.ts +12 -0
  109. package/src/cli/route-policy-write.ts +137 -0
  110. package/src/cli/route-policy.ts +8 -0
  111. package/src/cli/runtime-api.ts +56 -79
  112. package/src/cli/secret-args.ts +149 -0
  113. package/src/cli/settings-result.ts +30 -0
  114. package/src/cli/storage.ts +77 -7
  115. package/src/cli/system-command.ts +60 -2
  116. package/src/cli/system-restart-client.ts +20 -3
  117. package/src/cli/system-settings-parity.ts +83 -0
  118. package/src/cli/update-restart-candidate.ts +21 -0
  119. package/src/cli/update-restart-child.ts +81 -0
  120. package/src/cli/update-restart-home.ts +38 -0
  121. package/src/cli/update-restart-transport.ts +193 -0
  122. package/src/cli/update-restart.ts +260 -0
  123. package/src/cli/usage-model-search.ts +58 -0
  124. package/src/cli/usage-report.ts +13 -5
  125. package/src/cli/v2-input.ts +66 -0
  126. package/src/cli/v2-local-output.ts +37 -0
  127. package/src/cli/v2-runtime.ts +90 -0
  128. package/src/cli/v2.ts +102 -144
  129. package/src/clients/config-export/dsh.ts +21 -0
  130. package/src/clients/config-export.ts +7 -1
  131. package/src/codex/catalog/build-entries.ts +1 -1
  132. package/src/codex/catalog/bundled.ts +9 -2
  133. package/src/codex/catalog/heal-observation.ts +66 -0
  134. package/src/codex/catalog/publication-observer.ts +19 -0
  135. package/src/codex/catalog/remote.ts +13 -15
  136. package/src/codex/catalog/restore.ts +19 -6
  137. package/src/codex/catalog/retained-sync.ts +58 -10
  138. package/src/codex/catalog/routed-removal.ts +116 -0
  139. package/src/codex/catalog/write-audit-contract.ts +21 -0
  140. package/src/codex/catalog/write-audit.ts +171 -0
  141. package/src/codex/catalog-self-heal.ts +284 -0
  142. package/src/codex/catalog-write-serialization.ts +71 -7
  143. package/src/codex/codex-home-owner.ts +124 -0
  144. package/src/codex/convergence.ts +55 -15
  145. package/src/codex/features.ts +8 -1
  146. package/src/codex/inject/restore.ts +46 -4
  147. package/src/codex/inject.ts +11 -1
  148. package/src/codex/internal/catalog-writer.ts +120 -13
  149. package/src/codex/journal.ts +49 -13
  150. package/src/codex/management-convergence.ts +3 -0
  151. package/src/codex/quota-types.ts +3 -0
  152. package/src/codex/quota.ts +13 -3
  153. package/src/codex/refresh.ts +20 -9
  154. package/src/codex/runtime.ts +7 -1
  155. package/src/codex/shim-diagnostics.ts +80 -0
  156. package/src/codex/shim-fingerprint.ts +7 -2
  157. package/src/codex/shim-migration.ts +153 -0
  158. package/src/codex/shim-overlay.ts +509 -0
  159. package/src/codex/shim-path-resolution.ts +14 -2
  160. package/src/codex/shim-probe.ts +4 -3
  161. package/src/codex/shim-state-file.ts +93 -7
  162. package/src/codex/shim.ts +15 -68
  163. package/src/codex/sync.ts +24 -4
  164. package/src/combos/failover.ts +19 -0
  165. package/src/combos/index.ts +1 -0
  166. package/src/combos/resolve.ts +12 -0
  167. package/src/config/atomic-write.ts +13 -8
  168. package/src/config/diagnostics.ts +19 -1
  169. package/src/config/load-degrade.ts +3 -0
  170. package/src/config/schema/chatgpt-desktop.ts +22 -0
  171. package/src/config/schema/config-schema.ts +32 -14
  172. package/src/config/schema/leaf-validators.ts +3 -2
  173. package/src/generated/compatibility-version.json +445 -129
  174. package/src/integrations/merge.ts +13 -5
  175. package/src/integrations/mutation-plan.ts +46 -3
  176. package/src/integrations/omp-yaml-source.ts +181 -1
  177. package/src/integrations/ownership-policy.ts +66 -0
  178. package/src/integrations/registry.ts +50 -1
  179. package/src/integrations/state.ts +46 -21
  180. package/src/integrations/target.ts +25 -2
  181. package/src/integrations/writer.ts +89 -31
  182. package/src/lib/package-tree-integrity.ts +42 -1
  183. package/src/lib/proxy-env.ts +3 -0
  184. package/src/lib/test-home-guard.ts +2 -1
  185. package/src/oauth/anthropic-account-refusal.ts +19 -8
  186. package/src/oauth/anthropic-routing.ts +17 -12
  187. package/src/oauth/anthropic.ts +27 -2
  188. package/src/oauth/index.ts +6 -8
  189. package/src/oauth/pool-settings-capability.ts +9 -2
  190. package/src/protocols/plan-snapshot.ts +1 -1
  191. package/src/protocols/settings.ts +28 -4
  192. package/src/server/caller-session-identity.ts +32 -0
  193. package/src/server/claude-messages.ts +11 -4
  194. package/src/server/index/serve-options.ts +12 -7
  195. package/src/server/index.ts +1 -1
  196. package/src/server/management/agent-settings-routes.ts +6 -5
  197. package/src/server/management/context.ts +8 -1
  198. package/src/server/management/cursor-integration-routes.ts +3 -3
  199. package/src/server/management/integration-routes.ts +5 -5
  200. package/src/server/management/model-routes.ts +3 -3
  201. package/src/server/management/native-integration-routes.ts +2 -2
  202. package/src/server/management/oauth-account-routes.ts +96 -30
  203. package/src/server/management/route-registry.ts +25 -24
  204. package/src/server/messages-native-eligibility.ts +3 -6
  205. package/src/server/messages-native-oauth.ts +82 -35
  206. package/src/server/messages-native.ts +78 -16
  207. package/src/server/responses/adapter-dispatch.ts +53 -28
  208. package/src/server/responses/core-auth.ts +10 -4
  209. package/src/server/responses/core-combo.ts +46 -6
  210. package/src/service/cli.ts +6 -1
  211. package/src/service/ownership-mutation-lease.d.mts +26 -0
  212. package/src/service/ownership-mutation-lease.mjs +76 -1
  213. package/src/types/config.ts +2 -0
  214. package/src/types/provider.ts +10 -10
  215. package/src/update/index.ts +2 -1
  216. package/src/update/update-failure-guidance.d.mts +6 -0
  217. package/src/update/update-failure-guidance.mjs +30 -5
  218. package/gui/dist/assets/App-COdEeqSj.css +0 -1
  219. package/gui/dist/assets/App-DEpCJEP_.js +0 -54
  220. package/gui/dist/assets/index-Cd7ittMv.css +0 -1
  221. package/gui/dist/assets/index-DwJKFJm5.js +0 -94
  222. package/gui/dist/assets/quota-summary-DGcEJU3h.js +0 -1
package/src/cli/access.ts CHANGED
@@ -4,15 +4,20 @@ import {
4
4
  rejectArgs,
5
5
  runCliAction,
6
6
  runtimeRequest,
7
+ runtimeBaseUrl,
7
8
  takeFlag,
8
9
  takeOption,
9
10
  type RuntimeApiDeps,
10
11
  } from "./runtime-api";
11
12
 
13
+ import { handleSelectedKeyTest, type SelectedKeyTestDeps } from "./access-data-plane";
14
+ import { handleAccessAudioCommand } from "./access-audio";
15
+
12
16
  const USAGE = `Usage:
13
17
  ocx access key [list] [--json]
14
18
  ocx access key create [name] [--json]
15
19
  ocx access key get <id-or-name> [--json]
20
+ ocx access key rename <id-or-name> <name> [--json]
16
21
  ocx access key set <id-or-name> [--allow-provider <name>]... [--allow-model <id>]... [--clear] [--json]
17
22
  ocx access key rotate <id> [--json]
18
23
  ocx access key rotate commit <id> <rotation-id> [--json]
@@ -20,7 +25,9 @@ const USAGE = `Usage:
20
25
  ocx access key remove <id> --yes [--json]
21
26
  ocx access endpoints [--json]
22
27
  ocx access models [--json]
23
- ocx access test <model> [--protocol <chat|responses|messages>] [--json]`;
28
+ ocx access test <model> [--protocol <chat|responses|messages>] [--api-key-stdin] [--json]
29
+ ocx access audio transcribe <file> --model <id> --api-key-stdin [--json]
30
+ ocx access audio live-check --model <id> --api-key-stdin [--json]`;
24
31
 
25
32
  const UTC_ISO_INSTANT_RE = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d{1,3})?Z$/;
26
33
 
@@ -234,6 +241,56 @@ async function key(argv: string[], deps: RuntimeApiDeps): Promise<void> {
234
241
  throw new CliUsageError(`unknown key command ${action}`, USAGE);
235
242
  }
236
243
 
244
+ /** Rename never sends or prints a secret, and cannot silently choose duplicate ids. */
245
+ async function renameKey(argv: string[], deps: RuntimeApiDeps): Promise<number> {
246
+ const args = [...argv];
247
+ const wantsJson = takeFlag(args, "--json");
248
+ const [selector, rawName] = args;
249
+ if (args.length !== 2 || !selector?.trim() || selector.startsWith("-") || !rawName
250
+ || /[\u0000-\u001f\u007f]/.test(rawName) || !rawName.trim() || rawName.trim().length > 64) {
251
+ console.error("Error: Supply one key id or unambiguous name and a new name of 1–64 characters without controls.");
252
+ return 2;
253
+ }
254
+ const name = rawName.trim();
255
+ try {
256
+ const pinned = { ...deps, baseUrl: await runtimeBaseUrl(deps) };
257
+ const before = await runtimeRequest<unknown>("/api/keys", { redirect: "error", credentials: "omit" }, pinned);
258
+ if (!before || typeof before !== "object" || !("keys" in before) || !Array.isArray(before.keys)
259
+ || before.keys.some(row => !row || typeof row !== "object" || Array.isArray(row)
260
+ || typeof row.id !== "string" || !row.id || typeof row.name !== "string")) throw new Error();
261
+ const rows = before.keys as Array<Record<string, unknown>>;
262
+ const wanted = selector.trim().toLowerCase();
263
+ if (rows.filter(row => (row.id as string).toLowerCase() === wanted).length > 1) {
264
+ throw new CliUsageError("Ambiguous key identity.");
265
+ }
266
+ const target = findKeyRow(rows, selector);
267
+ if (rows.filter(row => (row.id as string).toLowerCase() === (target.id as string).toLowerCase()).length !== 1) {
268
+ throw new CliUsageError("Ambiguous key identity.");
269
+ }
270
+ const result = await runtimeRequest<Record<string, unknown>>("/api/keys", {
271
+ method: "PATCH", body: JSON.stringify({ id: target.id, name }), redirect: "error", credentials: "omit",
272
+ }, pinned);
273
+ if (!result || typeof result !== "object" || Array.isArray(result) || result.id !== target.id || result.name !== name
274
+ || typeof result.createdAt !== "string" || !isUtcIsoInstant(result.createdAt)) throw new Error();
275
+ const projected: Record<string, unknown> = { id: result.id, name: result.name, createdAt: result.createdAt };
276
+ for (const scope of ["allowedProviders", "allowedModels"] as const) {
277
+ if (result[scope] === undefined) continue;
278
+ const value = result[scope];
279
+ if (!Array.isArray(value) || value.some(item => typeof item !== "string" || !item.trim() || item.length > 256)) throw new Error();
280
+ projected[scope] = [...value];
281
+ }
282
+ printData(projected, wantsJson, ["API key renamed.", ...scopeLines(projected)]);
283
+ return 0;
284
+ } catch (error) {
285
+ if (error instanceof CliUsageError) {
286
+ console.error("Error: API key selector is missing or ambiguous. List keys and use a unique id.");
287
+ return 2;
288
+ }
289
+ console.error("Error: API key rename was not confirmed. List keys to check the current name before retrying.");
290
+ return 1;
291
+ }
292
+ }
293
+
237
294
  async function endpoints(argv: string[], deps: RuntimeApiDeps): Promise<void> {
238
295
  const args = [...argv];
239
296
  const wantsJson = takeFlag(args, "--json");
@@ -271,7 +328,13 @@ async function testModel(argv: string[], deps: RuntimeApiDeps): Promise<void> {
271
328
  printData(result, wantsJson, [`${model}: ${protocol} request succeeded.`]);
272
329
  }
273
330
 
274
- export async function handleAccessCommand(argv: string[], deps: RuntimeApiDeps = {}): Promise<number> {
331
+ export async function handleAccessCommand(argv: string[], deps: SelectedKeyTestDeps = {}): Promise<number> {
332
+ const [command, ...args] = argv;
333
+ if (command === "audio") return handleAccessAudioCommand(args, deps);
334
+ if (command === "test" && args.some(arg => arg === "--api-key-stdin" || arg.startsWith("--api-key-stdin="))) {
335
+ return handleSelectedKeyTest(args, deps);
336
+ }
337
+ if ((command === "key" || command === "keys") && args[0] === "rename") return renameKey(args.slice(1), deps);
275
338
  return runCliAction(async () => {
276
339
  const [sub = "key", ...rest] = argv;
277
340
  if (sub === "key" || sub === "keys") await key(rest, deps);
@@ -1,4 +1,4 @@
1
- import { parseQuotaFailureCode, type QuotaFailureCode } from "../providers/quota-types";
1
+ import { parseQuotaFailureCode, type QuotaFailureCode, type ProviderQuota, type AccountQuotaMode } from "../providers/quota-types";
2
2
  /**
3
3
  * Data-access layer for `ocx account` (issue #180) — live-proxy HTTP client and
4
4
  * per-family account readers. Kept separate from account.ts (command handlers)
@@ -11,6 +11,10 @@ import { getProviderRegistryEntry, providerCodexAccountMode } from "../providers
11
11
  import type { OcxConfig } from "../types";
12
12
  import { projectCodexQuotaRefreshOutcome, type CodexQuotaRefreshOutcome } from "../codex/quota-refresh-outcome";
13
13
 
14
+ import { projectApiKeyQuotaRows } from "./account-key-quota";
15
+ import { projectAccountHealth } from "./account-next-actions";
16
+ import type { OAuthHealthLabel } from "../oauth/health";
17
+
14
18
  export type AccountType = "codex" | "oauth" | "api-key";
15
19
 
16
20
  export interface AccountRow {
@@ -27,6 +31,11 @@ export interface AccountRow {
27
31
  masked?: string;
28
32
  active: boolean;
29
33
  needsReauth?: boolean;
34
+ /** Validated server health label and locally generated recovery guidance. */
35
+ health?: OAuthHealthLabel;
36
+ healthAction?: string;
37
+ /** Explicit paid-credit consent, reported only when supplied by the Codex API. */
38
+ creditsAfterLimit?: boolean;
30
39
  needsReauthReason?: "verify_account";
31
40
  autoSelectable?: boolean;
32
41
  skipReason?: "paused" | "needs_reauth" | "suspended" | "cooldown" | "quota_exhausted";
@@ -38,7 +47,8 @@ export interface AccountRow {
38
47
  priority?: number;
39
48
  /** Null means the account inherits the global usage-switch threshold. */
40
49
  autoSwitchThresholdOverride?: number | null;
41
- quota?: CodexQuotaDto | null;
50
+ quota?: (CodexQuotaDto & Partial<ProviderQuota>) | null;
51
+ quotaMode?: AccountQuotaMode;
42
52
  quotaRefresh?: CodexQuotaRefreshOutcome;
43
53
  quotaUnavailable?: boolean;
44
54
  quotaFailure?: QuotaFailureCode;
@@ -123,7 +133,7 @@ export async function apiJson(
123
133
  method: "GET" | "PUT" | "POST" | "DELETE",
124
134
  path: string,
125
135
  body?: unknown,
126
- options: { signal?: AbortSignal } = {},
136
+ options: { signal?: AbortSignal; redirect?: RequestRedirect } = {},
127
137
  ): Promise<ApiResult> {
128
138
  const fetchImpl = deps.fetchImpl ?? fetch;
129
139
  try {
@@ -132,6 +142,7 @@ export async function apiJson(
132
142
  headers: runningProxyUpdateHeaders(),
133
143
  body: body === undefined ? undefined : JSON.stringify(body),
134
144
  signal: options.signal,
145
+ ...(options.redirect ? { redirect: options.redirect } : {}),
135
146
  });
136
147
  const json = (await res.json().catch(() => ({}))) as Record<string, unknown>;
137
148
  return { status: res.status, json };
@@ -256,6 +267,8 @@ interface CodexAccountDto {
256
267
  selectionExcludedReason?: "plan_excluded";
257
268
  selectionExcludedPlan?: string;
258
269
  health?: { reason?: string };
270
+ healthLabel?: unknown;
271
+ creditsAfterLimit?: unknown;
259
272
  priority?: number;
260
273
  autoSwitchThresholdOverride?: number | null;
261
274
  quota?: CodexQuotaDto | null;
@@ -322,6 +335,8 @@ export async function fetchCodexRows(
322
335
  plan: a.plan,
323
336
  active: a.id === activeId,
324
337
  needsReauth: a.needsReauth,
338
+ ...projectAccountHealth(a, "openai", a.id),
339
+ ...(typeof a.creditsAfterLimit === "boolean" ? { creditsAfterLimit: a.creditsAfterLimit } : {}),
325
340
  ...(a.selectionExcludedReason === "plan_excluded" ? {
326
341
  selectionExcludedReason: "plan_excluded" as const,
327
342
  ...(typeof a.selectionExcludedPlan === "string" ? { selectionExcludedPlan: a.selectionExcludedPlan } : {}),
@@ -346,6 +361,7 @@ interface OAuthAccountDto {
346
361
  email?: string;
347
362
  active?: boolean;
348
363
  needsReauth?: boolean;
364
+ healthLabel?: unknown;
349
365
  /** Present only for providers that support operator pause (generic OAuth pools). */
350
366
  paused?: boolean;
351
367
  autoSwitchThresholdOverride?: number | null;
@@ -390,6 +406,7 @@ async function fetchOAuthRows(
390
406
  email: a.email,
391
407
  active: a.active ?? a.id === activeId,
392
408
  needsReauth: a.needsReauth,
409
+ ...projectAccountHealth(a, name, a.id),
393
410
  ...(a.paused === true ? { paused: true } : {}),
394
411
  ...(name === "anthropic" && Object.hasOwn(a, "autoSwitchThresholdOverride") ? { autoSwitchThresholdOverride: a.autoSwitchThresholdOverride } : {}),
395
412
  ...(a.needsReauthReason === "verify_account" ? { needsReauthReason: a.needsReauthReason } : {}),
@@ -416,12 +433,19 @@ interface ApiKeyDto {
416
433
  active?: boolean;
417
434
  }
418
435
 
419
- async function fetchKeyRows(deps: AccountDeps, baseUrl: string, name: string): Promise<FamilyRows> {
420
- const res = await apiJson(deps, baseUrl, "GET", `/api/providers/keys?name=${encodeURIComponent(name)}`);
436
+ async function fetchKeyRows(deps: AccountDeps, baseUrl: string, name: string, quota?: { refresh?: boolean }): Promise<FamilyRows> {
437
+ const query = `?name=${encodeURIComponent(name)}${quota ? `&quota=1${quota.refresh ? "&refresh=1" : ""}` : ""}`;
438
+ const res = await apiJson(deps, baseUrl, "GET", `/api/providers/keys${query}`, undefined, quota ? { redirect: "error" } : {});
421
439
  if (res.status === 0) {
422
440
  return { rows: [], activeId: null, status: 0, networkDown: true, transportError: res.transportError };
423
441
  }
424
442
  if (res.status !== 200) return { rows: [], activeId: null, status: res.status, errorJson: res.json };
443
+ if (quota) {
444
+ try { return projectApiKeyQuotaRows(res.json, name); }
445
+ catch (error) {
446
+ return { rows: [], activeId: null, status: 200, errorJson: { error: error instanceof Error ? error.message : "Malformed API-key quota response." } };
447
+ }
448
+ }
425
449
  const activeId = typeof res.json.activeId === "string" ? res.json.activeId : null;
426
450
  const keys = Array.isArray(res.json.keys) ? res.json.keys as ApiKeyDto[] : [];
427
451
  const rows = keys.map(k => ({
@@ -444,7 +468,7 @@ export function fetchRows(
444
468
  ): Promise<FamilyRows> {
445
469
  if (type === "codex") return fetchCodexRows(deps, baseUrl, Boolean(quota?.refresh), quota !== undefined);
446
470
  if (type === "oauth") return fetchOAuthRows(deps, baseUrl, name, quota);
447
- return fetchKeyRows(deps, baseUrl, name);
471
+ return fetchKeyRows(deps, baseUrl, name, quota);
448
472
  }
449
473
 
450
474
  export async function fetchProviderQuotaReport(
@@ -1,4 +1,6 @@
1
1
  import { writeSync } from "node:fs";
2
+ import { isValidProviderName } from "../config/provider-name";
3
+ import { runCatalogAction } from "./catalog-command-result";
2
4
  import { modelSelectionGuidance, modelSelectionNextSteps } from "./model-selection-guidance";
3
5
  import { warnIfCodexCatalogRefreshPending } from "./account-catalog-refresh";
4
6
  import { isCodexResetCreditOperationId } from "../codex/reset-credit-recovery";
@@ -10,6 +12,8 @@ import {
10
12
  rejectArgs,
11
13
  runCliAction,
12
14
  runtimeRequest,
15
+ runtimeBaseUrl,
16
+ terminalSafeText,
13
17
  takeFlag,
14
18
  takeOption,
15
19
  takeOptionWithSyntax,
@@ -34,7 +38,7 @@ function writeStdoutFully(text: string): void {
34
38
  }
35
39
 
36
40
  const USAGE = `Usage:
37
- ocx account login <provider> [--id <account-id>] [--reauth] [--device] [--method builder-id|google|github] [--code -] [--no-wait] [--json]
41
+ ocx account login <provider> [--id <account-id>] [--reauth] [--device] [--method builder-id|google|github] [--open-browser on|off] [--add-account on|off] [--code -] [--no-wait] [--json]
38
42
  ocx account code <provider> [--flow <flow-id>] [--json] (reads the code from stdin)
39
43
  ocx account cancel <provider> [--flow <flow-id>] [--json] (--flow required for codex)
40
44
  ocx account reset-credits <account-id|main> [--consume --yes [--operation-id <uuid>]] [--json]
@@ -81,6 +85,62 @@ interface LoginStart {
81
85
  warning?: string;
82
86
  }
83
87
 
88
+ /** Public handoff only. Never spread runtime bodies into terminal or JSON output. */
89
+ function loginObject(value: unknown): Record<string, unknown> {
90
+ if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error("Invalid login reply");
91
+ return value as Record<string, unknown>;
92
+ }
93
+ function publicLoginStart(value: unknown, kiro = false): LoginStart {
94
+ const data = loginObject(value);
95
+ const out: LoginStart = {};
96
+ const strings = kiro
97
+ ? ["flowId", "userCode", "verificationUri", "verificationUriComplete"] as const
98
+ : ["url", "flowId", "instructions", "deviceCode"] as const;
99
+ for (const key of strings) {
100
+ if (data[key] !== undefined && typeof data[key] !== "string") throw new Error("Invalid login handoff field");
101
+ if (typeof data[key] === "string") out[key] = data[key];
102
+ }
103
+ if (typeof data.browserLaunch === "string" && ["started", "failed", "skipped"].includes(data.browserLaunch)) out.browserLaunch = data.browserLaunch as LoginStart["browserLaunch"];
104
+ if (typeof data.method === "string" && ["builder-id", "google", "github"].includes(data.method)) out.method = data.method as LoginStart["method"];
105
+ if (typeof data.state === "string" && ["pending", "done", "failed", "expired", "cancelled"].includes(data.state)) out.state = data.state as string;
106
+ if (typeof data.warning === "string" && ["duplicate_profile_arn", "manual_review_required"].includes(data.warning)) out.warning = data.warning as string;
107
+ if (typeof data.expiresAt === "number" && Number.isFinite(data.expiresAt)) out.expiresAt = data.expiresAt;
108
+ return out;
109
+ }
110
+ function publicLoginStatus(value: unknown): Record<string, unknown> {
111
+ const data = loginObject(value);
112
+ const out: Record<string, unknown> = {};
113
+ for (const key of ["accountId", "activeAccountId", "email"]) if (typeof data[key] === "string") out[key] = data[key];
114
+ for (const key of ["loggedIn", "done", "needsReauth", "validationPending", "catalogRefreshPending"]) {
115
+ if (data[key] !== undefined && typeof data[key] !== "boolean") throw new Error("Invalid login completion field");
116
+ if (typeof data[key] === "boolean") out[key] = data[key];
117
+ }
118
+ if (typeof data.status === "string" && ["starting", "pending", "done", "error", "expired", "cancelled", "idle"].includes(data.status)) out.status = data.status;
119
+ if (typeof data.source === "string" && ["oauth", "local-cli", "credential-file", "environment", "manual"].includes(data.source)) out.source = data.source;
120
+ if (data.hint && typeof data.hint === "object" && !Array.isArray(data.hint)) out.hint = publicLoginStart(data.hint);
121
+ if (Array.isArray(data.accounts)) out.accounts = data.accounts.map(value => {
122
+ const row = loginObject(value);
123
+ const account: Record<string, unknown> = {};
124
+ for (const key of ["id", "alias", "email"]) if (typeof row[key] === "string") account[key] = row[key];
125
+ for (const key of ["active", "needsReauth"]) if (typeof row[key] === "boolean") account[key] = row[key];
126
+ if (row.needsReauthReason === "verify_account") account.needsReauthReason = "verify_account";
127
+ if (typeof row.expiresAt === "number" && Number.isFinite(row.expiresAt)) account.expiresAt = row.expiresAt;
128
+ if (row.plan === null) account.plan = null;
129
+ return account;
130
+ });
131
+ return out;
132
+ }
133
+ function loginBoolean(args: string[], flag: string): boolean | undefined {
134
+ const option = takeOptionWithSyntax(args, flag);
135
+ if (!option) return undefined;
136
+ if (option.value !== "on" && option.value !== "off") throw new CliUsageError(`${flag} must be on or off`, USAGE);
137
+ return option.value === "on";
138
+ }
139
+ function writeLoginInstructions(lines: readonly string[]): void {
140
+ const block = lines.filter(line => line !== "").flatMap(line => line.split("\n")).map(terminalSafeText).join("\n");
141
+ if (block) writeStdoutFully(`${block}\n`);
142
+ }
143
+
84
144
  /**
85
145
  * Said only when the host could not open a browser (#5261).
86
146
  *
@@ -136,7 +196,7 @@ async function resolveCode(
136
196
  return await readSecretLine(deps, "authorization code");
137
197
  }
138
198
 
139
- async function login(argv: string[], deps: RuntimeApiDeps): Promise<void> {
199
+ async function login(argv: string[], deps: RuntimeApiDeps): Promise<number | void> {
140
200
  const args = [...argv];
141
201
  const provider = args.shift()?.trim().toLowerCase();
142
202
  const wantsJson = takeFlag(args, "--json");
@@ -146,34 +206,39 @@ async function login(argv: string[], deps: RuntimeApiDeps): Promise<void> {
146
206
  const method = takeOption(args, "--method");
147
207
  const id = takeOption(args, "--id");
148
208
  const suppliedCode = takeOptionWithSyntax(args, "--code");
209
+ const openBrowser = loginBoolean(args, "--open-browser");
210
+ const addAccount = loginBoolean(args, "--add-account");
211
+ if (provider && !isValidProviderName(provider)) throw new CliUsageError("invalid provider name", USAGE);
149
212
  if (!provider) throw new CliUsageError("provider is required", USAGE);
150
213
  if (method !== undefined) {
151
214
  if (provider !== "kiro" || !["builder-id", "google", "github"].includes(method)) {
152
215
  throw new CliUsageError("--method requires kiro and builder-id, google, or github", USAGE);
153
216
  }
154
217
  if (reauth || id) throw new CliUsageError("native Kiro device login only adds accounts; remove and re-add to reauthenticate", USAGE);
218
+ if (openBrowser !== undefined || addAccount !== undefined) throw new CliUsageError("native Kiro --method does not accept --open-browser or --add-account", USAGE);
155
219
  if (device || suppliedCode) throw new CliUsageError("--method cannot be combined with --device or --code", USAGE);
156
220
  }
157
- // A bare leftover here is plausibly the authorization code itself: this flow takes one
158
- // through --code, and a user who pastes it as a positional would otherwise see it echoed
159
- // back in the usage error. `ocx login codex` reaches this parser too, so the paste lands
160
- // one word away from a command people run constantly. Flag-shaped leftovers stay visible,
161
- // because a mistyped flag is exactly what the message has to name.
162
- rejectArgs(args, USAGE, { redactValues: true });
221
+ // A malformed leftover may contain pasted authorization input, even inside an
222
+ // unknown flag. New login parsing reports only a fixed usage diagnostic.
223
+ if (args.length) throw new CliUsageError("Unexpected arguments or repeated options", USAGE);
163
224
  // kimi, nous, and github-copilot are already device flows, so --device is a
164
225
  // true statement about them and is accepted as a no-op rather than an error.
165
226
  // Anything else has no device grant at all and must fail loudly.
166
227
  if (device && !CODEX_NAMES.has(provider) && !DEVICE_NATIVE_PROVIDERS.has(provider)) {
167
- throw new CliUsageError(`--device is not supported for provider '${provider}'`, USAGE);
228
+ throw new CliUsageError("--device is not supported for this provider", USAGE);
168
229
  }
230
+ if (addAccount !== undefined && (reauth || CODEX_NAMES.has(provider))) throw new CliUsageError("--add-account is only valid for fresh provider OAuth login", USAGE);
231
+ if (openBrowser !== undefined && (device || DEVICE_NATIVE_PROVIDERS.has(provider))) throw new CliUsageError("--open-browser is not supported for device login", USAGE);
232
+ if (!CODEX_NAMES.has(provider) && id && !reauth) throw new CliUsageError("--id is only valid with --reauth for provider OAuth accounts", USAGE);
169
233
  // Only resolve when --code was actually given: a plain `ocx account login`
170
234
  // opens the browser flow and polls, and must not block on stdin.
171
235
  const code = await resolveCode(suppliedCode, deps, false);
236
+ const pinned = { ...deps, baseUrl: await runtimeBaseUrl(deps) };
172
237
 
173
238
  if (method) {
174
- const start = await runtimeRequest<LoginStart>("/api/oauth/login", {
175
- method: "POST", body: JSON.stringify({ provider: "kiro", method }),
176
- }, deps);
239
+ const start = publicLoginStart(await runtimeRequest<unknown>("/api/oauth/login", {
240
+ method: "POST", redirect: "error", body: JSON.stringify({ provider: "kiro", method }),
241
+ }, pinned), true);
177
242
  if (!wantsJson) {
178
243
  const block = formatKiroDeviceInstructions(start);
179
244
  if (block) writeStdoutFully(`${block}\n`);
@@ -182,7 +247,7 @@ async function login(argv: string[], deps: RuntimeApiDeps): Promise<void> {
182
247
  if (!start.flowId) throw new CliUsageError("Kiro device login did not return a flow id");
183
248
  for (let attempt = 0; attempt < 450; attempt++) {
184
249
  await Bun.sleep(2_000);
185
- const state = await runtimeRequest<LoginStart>(`/api/oauth/status?provider=kiro&flowId=${encodeURIComponent(start.flowId)}`, {}, deps);
250
+ const state = publicLoginStart(await runtimeRequest<unknown>(`/api/oauth/status?provider=kiro&flowId=${encodeURIComponent(start.flowId)}`, { redirect: "error" }, pinned), true);
186
251
  if (state.state === "done") {
187
252
  printData(state, wantsJson, ["Logged in to kiro.", ...(state.warning ? [`Warning: ${state.warning}`] : [])]);
188
253
  return;
@@ -195,31 +260,33 @@ async function login(argv: string[], deps: RuntimeApiDeps): Promise<void> {
195
260
  }
196
261
 
197
262
  if (CODEX_NAMES.has(provider)) {
198
- const start = await runtimeRequest<LoginStart>("/api/codex-auth/login", {
199
- method: "POST",
263
+ const start = publicLoginStart(await runtimeRequest<unknown>("/api/codex-auth/login", {
264
+ method: "POST", redirect: "error",
200
265
  body: JSON.stringify({
201
266
  ...(id ? { id } : {}),
202
267
  ...(reauth ? { reauth: true } : {}),
203
268
  ...(device ? { device: true } : {}),
269
+ ...(openBrowser === undefined ? {} : { openBrowser }),
204
270
  }),
205
- }, deps);
271
+ }, pinned));
206
272
  if (!wantsJson) {
207
273
  // One atomic pre-poll block, flushed synchronously so a piped parent
208
274
  // reads the URL before the polling window starts (#1007).
209
- const block = [
275
+ writeLoginInstructions([
210
276
  start.url ? `Open this URL to sign in:\n${start.url}` : "",
211
277
  start.deviceCode ? `Device code: ${start.deviceCode}` : "",
212
278
  start.instructions ?? "",
213
279
  start.browserLaunch === "failed" ? BROWSER_LAUNCH_FAILED_HINT : "",
214
280
  start.flowId ? `Flow: ${start.flowId}` : "",
215
- ].filter(line => line !== "").join("\n");
216
- if (block) writeStdoutFully(`${block}\n`);
281
+ ]);
217
282
  }
283
+ if (code && !start.flowId) throw new Error("Code submission requires a returned flow id");
218
284
  if (code && start.flowId) {
219
- await runtimeRequest("/api/codex-auth/login/code", {
220
- method: "POST",
285
+ const submitted = await runtimeRequest("/api/codex-auth/login/code", {
286
+ method: "POST", redirect: "error",
221
287
  body: JSON.stringify({ flowId: start.flowId, input: code }),
222
- }, deps);
288
+ }, pinned);
289
+ if (loginObject(submitted).ok !== true) throw new Error("Code submission was not accepted");
223
290
  }
224
291
  if (noWait) {
225
292
  printData({ ...start, modelSelection: modelSelectionNextSteps("openai", true) }, wantsJson, modelSelectionGuidance("openai", true));
@@ -233,10 +300,11 @@ async function login(argv: string[], deps: RuntimeApiDeps): Promise<void> {
233
300
  const maxAttempts = device ? 480 : 150;
234
301
  for (let attempt = 0; attempt < maxAttempts; attempt++) {
235
302
  await Bun.sleep(2_000);
236
- const state = await runtimeRequest<Record<string, unknown>>(
303
+ const state = publicLoginStatus(await runtimeRequest<unknown>(
237
304
  `/api/codex-auth/login-status?flowId=${encodeURIComponent(start.flowId)}${id ? `&accountId=${encodeURIComponent(id)}` : ""}${reauth ? "&reauth=1" : ""}`,
238
- {}, deps,
239
- );
305
+ { redirect: "error" }, pinned,
306
+ ));
307
+ if (start.flowId) state.flowId = start.flowId;
240
308
  if (state.status === "done") {
241
309
  if (state.validationPending === true) {
242
310
  printData({ ...state, recoveryCommand: "ocx gui", recoveryAction: "After quota recovers, click Refresh quotas in the dashboard Codex account pool." }, wantsJson, [
@@ -247,33 +315,32 @@ async function login(argv: string[], deps: RuntimeApiDeps): Promise<void> {
247
315
  printData({ ...state, modelSelection: modelSelectionNextSteps("openai") }, wantsJson, [`Logged in${state.email ? ` as ${String(state.email)}` : ""}.`, ...modelSelectionGuidance("openai")]);
248
316
  }
249
317
  if (!wantsJson) warnIfCodexCatalogRefreshPending(state);
250
- return;
318
+ return state.validationPending === true || state.catalogRefreshPending === true ? 1 : 0;
251
319
  }
252
- if (state.status === "error" || state.status === "expired") {
253
- throw new CliUsageError(String(state.error ?? `login ${state.status}`));
320
+ if (state.status === "error" || state.status === "expired" || state.status === "cancelled") {
321
+ throw new Error("Login failed or expired");
254
322
  }
255
323
  }
256
324
  throw new CliUsageError("login timed out");
257
325
  }
258
326
 
259
- if (id && !reauth) throw new CliUsageError("--id is only valid with --reauth for provider OAuth accounts", USAGE);
260
- const start = await runtimeRequest<LoginStart>("/api/oauth/login", {
261
- method: "POST",
262
- body: JSON.stringify({ provider, addAccount: !reauth, ...(reauth && id ? { accountId: id, reauth: true } : {}) }),
263
- }, deps);
327
+ const start = publicLoginStart(await runtimeRequest<unknown>("/api/oauth/login", {
328
+ method: "POST", redirect: "error",
329
+ body: JSON.stringify({ provider, addAccount: addAccount ?? !reauth, ...(openBrowser === undefined ? {} : { openBrowser }), ...(reauth && id ? { accountId: id, reauth: true } : {}) }),
330
+ }, pinned));
264
331
  if (!wantsJson) {
265
- const block = [
332
+ writeLoginInstructions([
266
333
  start.url ? `Open this URL to sign in:\n${start.url}` : "",
267
334
  start.instructions ?? "",
268
335
  start.deviceCode ? `Device code: ${start.deviceCode}` : "",
269
- ].filter(line => line !== "").join("\n");
270
- if (block) writeStdoutFully(`${block}\n`);
336
+ ]);
271
337
  }
272
338
  if (code) {
273
- await runtimeRequest("/api/oauth/login/code", {
274
- method: "POST",
339
+ const submitted = await runtimeRequest("/api/oauth/login/code", {
340
+ method: "POST", redirect: "error",
275
341
  body: JSON.stringify({ provider, input: code }),
276
- }, deps);
342
+ }, pinned);
343
+ if (loginObject(submitted).ok !== true) throw new Error("Code submission was not accepted");
277
344
  }
278
345
  if (noWait) {
279
346
  printData({ ...start, modelSelection: modelSelectionNextSteps(provider, true) }, wantsJson, modelSelectionGuidance(provider, true));
@@ -281,8 +348,10 @@ async function login(argv: string[], deps: RuntimeApiDeps): Promise<void> {
281
348
  }
282
349
  for (let attempt = 0; attempt < 100; attempt++) {
283
350
  await Bun.sleep(2_000);
284
- const state = await runtimeRequest<Record<string, unknown>>(`/api/oauth/status?provider=${encodeURIComponent(provider)}`, {}, deps);
285
- if (state.error) throw new CliUsageError(String(state.error));
351
+ const raw = await runtimeRequest<Record<string, unknown>>(`/api/oauth/status?provider=${encodeURIComponent(provider)}`, { redirect: "error" }, pinned);
352
+ if (loginObject(raw).error) throw new Error("Provider login failed");
353
+ const state = publicLoginStatus(raw);
354
+ if (state.done === true && state.loggedIn !== true) throw new Error("Provider login ended without authentication");
286
355
  if (state.loggedIn === true) {
287
356
  printData({ ...state, modelSelection: modelSelectionNextSteps(provider) }, wantsJson, [`Logged in to ${provider}.`, ...modelSelectionGuidance(provider)]);
288
357
  return;
@@ -413,7 +482,7 @@ async function grokResetCoupons(argv: string[], deps: RuntimeApiDeps): Promise<v
413
482
 
414
483
  export async function handleAccountAuthCommand(sub: string, argv: string[], deps: RuntimeApiDeps = {}): Promise<number | null> {
415
484
  let action: (() => Promise<void>) | undefined;
416
- if (sub === "login" || sub === "reauth") action = () => login(sub === "reauth" ? [...argv, "--reauth"] : argv, deps);
485
+ if (sub === "login" || sub === "reauth") return runCatalogAction(() => login(sub === "reauth" ? [...argv, "--reauth"] : argv, deps));
417
486
  else if (sub === "code") action = () => code(argv, deps);
418
487
  else if (sub === "cancel") action = () => cancel(argv, deps);
419
488
  else if (sub === "reset-credits") action = () => resetCredits(argv, deps);
@@ -0,0 +1,118 @@
1
+ /** Public API-key quota projection; private probe identity stays on the server. */
2
+ import { parseQuotaFailureCode, type AccountQuotaFields, type ProviderQuota, type ProviderQuotaWindow, type ProviderQuotaCreditsUsd } from "../providers/quota-types";
3
+ import { terminalSafeText } from "./runtime-api";
4
+ import type { AccountRow, FamilyRows } from "./account-api";
5
+
6
+ function record(value: unknown): Record<string, unknown> {
7
+ if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error("Malformed API-key quota response.");
8
+ return value as Record<string, unknown>;
9
+ }
10
+ function finite(value: unknown): number {
11
+ if (typeof value !== "number" || !Number.isFinite(value)) throw new Error("Malformed API-key quota response.");
12
+ return value;
13
+ }
14
+ function optionalString(row: Record<string, unknown>, key: string): string | undefined {
15
+ if (row[key] === undefined) return undefined;
16
+ if (typeof row[key] !== "string") throw new Error("Malformed API-key quota response.");
17
+ return row[key];
18
+ }
19
+ function optionalBoolean(row: Record<string, unknown>, key: string): boolean | undefined {
20
+ if (row[key] === undefined) return undefined;
21
+ if (typeof row[key] !== "boolean") throw new Error("Malformed API-key quota response.");
22
+ return row[key];
23
+ }
24
+ function windowProjection(value: unknown): ProviderQuotaWindow {
25
+ const row = record(value);
26
+ const label = optionalString(row, "label");
27
+ if (label === undefined) throw new Error("Malformed API-key quota response.");
28
+ const result: ProviderQuotaWindow = { label, percent: finite(row.percent) };
29
+ if (row.resetAt !== undefined) result.resetAt = finite(row.resetAt);
30
+ if (row.passiveObservedAt !== undefined) result.passiveObservedAt = finite(row.passiveObservedAt);
31
+ if (row.scope !== undefined) {
32
+ if (row.scope !== "model") throw new Error("Malformed API-key quota response.");
33
+ result.scope = row.scope;
34
+ }
35
+ if (row.rejected !== undefined) {
36
+ if (row.rejected !== true) throw new Error("Malformed API-key quota response.");
37
+ result.rejected = true;
38
+ }
39
+ return result;
40
+ }
41
+ function creditsProjection(value: unknown): ProviderQuotaCreditsUsd {
42
+ const row = record(value);
43
+ const result: ProviderQuotaCreditsUsd = {
44
+ used: finite(row.used), limit: finite(row.limit), remaining: finite(row.remaining), percent: finite(row.percent),
45
+ };
46
+ if (row.expiresAt !== undefined) result.expiresAt = finite(row.expiresAt);
47
+ const unlimited = optionalBoolean(row, "unlimited");
48
+ if (unlimited !== undefined) result.unlimited = unlimited;
49
+ return result;
50
+ }
51
+ function quotaProjection(value: unknown): ProviderQuota | null {
52
+ if (value === null) return null;
53
+ const row = record(value);
54
+ const result: ProviderQuota = { updatedAt: finite(row.updatedAt) };
55
+ for (const key of ["fiveHourPercent", "fiveHourResetAt", "weeklyPercent", "weeklyResetAt", "monthlyPercent", "monthlyResetAt", "kiroCreditsUsed", "kiroCreditsLimit"] as const) {
56
+ if (row[key] !== undefined) result[key] = finite(row[key]);
57
+ }
58
+ if (row.customWindows !== undefined) {
59
+ if (!Array.isArray(row.customWindows)) throw new Error("Malformed API-key quota response.");
60
+ result.customWindows = row.customWindows.map(windowProjection);
61
+ }
62
+ if (row.creditsUsd !== undefined) result.creditsUsd = creditsProjection(row.creditsUsd);
63
+ return result;
64
+ }
65
+
66
+ /** Only opt-in reads use the strict DTO boundary; legacy cheap lists retain their contract. */
67
+ export function projectApiKeyQuotaRows(value: unknown, provider: string): FamilyRows {
68
+ const body = record(value);
69
+ if (!Array.isArray(body.keys) || (body.activeId !== null && typeof body.activeId !== "string")) {
70
+ throw new Error("Malformed API-key quota response.");
71
+ }
72
+ const activeId = body.activeId;
73
+ const rows: AccountRow[] = body.keys.map(value => {
74
+ const row = record(value);
75
+ const id = optionalString(row, "id");
76
+ if (!id) throw new Error("Malformed API-key quota response.");
77
+ const quotaMode = row.quotaMode;
78
+ if (quotaMode === undefined) throw new Error("API-key quota is unverified: server did not report quotaMode. Update the proxy and retry.");
79
+ if (quotaMode !== "probe" && quotaMode !== "passive" && quotaMode !== "unsupported") throw new Error("Malformed API-key quota response.");
80
+ const fields: AccountQuotaFields = { quotaMode };
81
+ if (row.quota !== undefined) fields.quota = quotaProjection(row.quota);
82
+ const unavailable = optionalBoolean(row, "quotaUnavailable");
83
+ if (unavailable !== undefined) fields.quotaUnavailable = unavailable;
84
+ if (row.quotaFailure !== undefined) {
85
+ const failure = parseQuotaFailureCode(row.quotaFailure);
86
+ if (!failure) throw new Error("Malformed API-key quota response.");
87
+ if (unavailable === true) fields.quotaFailure = failure;
88
+ }
89
+ const masked = optionalString(row, "masked");
90
+ return { provider, type: "api-key", id, label: optionalString(row, "label") ?? masked,
91
+ masked, active: optionalBoolean(row, "active") ?? id === activeId, ...fields };
92
+ });
93
+ return { rows, activeId, status: 200 };
94
+ }
95
+
96
+ export function apiKeyQuotaText(row: AccountRow): string {
97
+ const mode = row.quotaMode;
98
+ if (!mode) return "unverified";
99
+ if (mode === "unsupported") return "unsupported";
100
+ if (row.quotaUnavailable) return `${mode}: unavailable${row.quotaFailure ? ` (${row.quotaFailure})` : ""}`;
101
+ const quota = row.quota;
102
+ if (!quota) return `${mode}: not measured`;
103
+ const parts: string[] = [];
104
+ for (const [label, percent] of [["5h", quota.fiveHourPercent], ["wk", quota.weeklyPercent], ["mo", quota.monthlyPercent]] as const) {
105
+ if (percent !== undefined) parts.push(`${label} ${percent}%`);
106
+ }
107
+ for (const window of quota.customWindows ?? []) {
108
+ // Labels are upstream text: keep terminal controls out of the human table.
109
+ const label = terminalSafeText(window.label);
110
+ parts.push(`${label} ${window.percent}%${window.rejected ? " (rejected)" : ""}`);
111
+ }
112
+ if (quota.creditsUsd) {
113
+ const c = quota.creditsUsd;
114
+ parts.push(`USD ${c.used}/${c.unlimited ? "unlimited" : c.limit} used; ${c.remaining} remaining (${c.percent}%)`);
115
+ }
116
+ if (quota.kiroCreditsUsed !== undefined || quota.kiroCreditsLimit !== undefined) parts.push(`credits ${quota.kiroCreditsUsed ?? "?"}/${quota.kiroCreditsLimit ?? "?"}`);
117
+ return `${mode}: ${parts.join("; ") || "not measured"}`;
118
+ }