@bitkyc08/opencodex 2.34.0 → 2.35.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 (71) hide show
  1. package/gui/dist/assets/{index-C4TMRloX.js → index-DNdRKXK9.js} +11 -11
  2. package/gui/dist/index.html +1 -1
  3. package/package.json +3 -1
  4. package/src/adapters/base.ts +26 -0
  5. package/src/adapters/cursor/catalog.ts +541 -0
  6. package/src/adapters/cursor/cursor-errors.ts +15 -0
  7. package/src/adapters/cursor/discovery.ts +34 -41
  8. package/src/adapters/cursor/envelope-echo.ts +128 -0
  9. package/src/adapters/cursor/request-builder.ts +19 -12
  10. package/src/adapters/cursor/tool-definitions.ts +2 -1
  11. package/src/adapters/cursor/tool-result-normalize.ts +23 -31
  12. package/src/adapters/cursor.ts +21 -2
  13. package/src/adapters/exec-tool-result-normalize.ts +99 -0
  14. package/src/adapters/google-antigravity-replay.ts +71 -2
  15. package/src/adapters/google-antigravity-wire.ts +5 -0
  16. package/src/adapters/google.ts +15 -1
  17. package/src/adapters/kiro-constants.ts +12 -0
  18. package/src/adapters/kiro.ts +128 -11
  19. package/src/adapters/openai-chat.ts +16 -2
  20. package/src/adapters/openai-responses.ts +15 -2
  21. package/src/adapters/run-turn-queue.ts +36 -1
  22. package/src/adapters/tool-catalog-nudge.ts +2 -1
  23. package/src/adapters/xai-web-search.ts +10 -14
  24. package/src/claude/outbound.ts +14 -3
  25. package/src/cli/access.ts +46 -3
  26. package/src/cli/account-api.ts +84 -15
  27. package/src/cli/account-extended.ts +261 -28
  28. package/src/cli/account-main.ts +12 -12
  29. package/src/cli/account.ts +40 -10
  30. package/src/cli/agent.ts +8 -1
  31. package/src/cli/capabilities-command.ts +94 -0
  32. package/src/cli/capabilities.ts +496 -0
  33. package/src/cli/claude-desktop.ts +31 -11
  34. package/src/cli/dispatch.ts +195 -27
  35. package/src/cli/doctor.ts +100 -1
  36. package/src/cli/help.ts +11 -2
  37. package/src/cli/index.ts +19 -3
  38. package/src/cli/inspect.ts +230 -0
  39. package/src/cli/observe.ts +11 -3
  40. package/src/cli/registry.ts +34 -2
  41. package/src/cli/runtime-api.ts +51 -7
  42. package/src/cli/status.ts +16 -0
  43. package/src/cli/storage.ts +234 -0
  44. package/src/cli/system-command.ts +16 -0
  45. package/src/cli/usage-report.ts +52 -2
  46. package/src/cli/version-skew.ts +46 -0
  47. package/src/codex/account-label.ts +21 -0
  48. package/src/codex/catalog/provider-fetch.ts +4 -0
  49. package/src/codex/transition-state.ts +12 -3
  50. package/src/compatibility/openai-responses.ts +9 -1
  51. package/src/generated/compatibility-version.json +95 -59
  52. package/src/integrations/ownership-policy.ts +24 -5
  53. package/src/integrations/ownership.ts +36 -2
  54. package/src/integrations/state.ts +40 -7
  55. package/src/integrations/writer.ts +21 -3
  56. package/src/lib/admin-secrets.ts +24 -0
  57. package/src/lib/errors.ts +25 -1
  58. package/src/lib/service-secrets.ts +15 -0
  59. package/src/oauth/store.ts +14 -5
  60. package/src/providers/label.ts +34 -1
  61. package/src/responses/turn-termination.ts +107 -0
  62. package/src/server/management/logs-usage-routes.ts +0 -16
  63. package/src/server/management/route-registry.ts +311 -0
  64. package/src/server/proxy-liveness.ts +27 -4
  65. package/src/server/request-log.ts +29 -1
  66. package/src/server/responses/core.ts +80 -0
  67. package/src/service.ts +34 -0
  68. package/src/storage/policy-job.ts +14 -4
  69. package/src/storage/policy.ts +88 -23
  70. package/src/usage/log.ts +44 -4
  71. package/src/usage/summary.ts +10 -0
package/src/cli/access.ts CHANGED
@@ -17,6 +17,51 @@ const USAGE = `Usage:
17
17
  ocx access models [--json]
18
18
  ocx access test <model> [--protocol <chat|responses|messages>] [--json]`;
19
19
 
20
+ /**
21
+ * Render the key table with the usage fields the API already returns (#2705).
22
+ *
23
+ * `usage` is a DISCRIMINATED UNION server-side (`api-key-usage.ts`): the `{ambiguous:true}`
24
+ * variant carries no numbers at all, because when two config entries share an id there IS no
25
+ * per-key total. The union exists specifically so a consumer cannot print a number beside an
26
+ * ambiguity marker, so this renders the word `ambiguous` across the numeric columns rather
27
+ * than a fabricated 0 -- reporting 0 requests for a key that may be in heavy use is the
28
+ * dangerous answer to hand someone deciding what to delete.
29
+ *
30
+ * `attributionSince` and `historyTruncated` describe the DATA SET, not a key, so they print
31
+ * once as a footer. Without `attributionSince`, an absent `lastUsedAt` is unreadable: it
32
+ * could mean "never used" or "nothing is attributable yet".
33
+ */
34
+ function formatKeyRows(payload: Record<string, unknown>, keys: Array<Record<string, unknown>>): string[] {
35
+ const cells: string[][] = [["ID", "NAME", "PREFIX", "REQ 7D", "TOTAL", "LAST USED"]];
36
+ for (const entry of keys) {
37
+ const usage = (entry.usage ?? {}) as Record<string, unknown>;
38
+ const ambiguous = usage.ambiguous === true;
39
+ const num = (value: unknown): string => (typeof value === "number" ? value.toLocaleString("en-US") : "-");
40
+ cells.push([
41
+ String(entry.id ?? ""),
42
+ String(entry.name ?? ""),
43
+ String(entry.prefix ?? ""),
44
+ // One marker spanning both numeric columns: the union guarantees neither exists.
45
+ ambiguous ? "ambiguous" : num(usage.requests7d),
46
+ ambiguous ? "" : num(usage.totalRequests),
47
+ ambiguous ? "" : (typeof usage.lastUsedAt === "string" ? usage.lastUsedAt : "never"),
48
+ ]);
49
+ }
50
+ const widths = cells[0]!.map((_, column) => Math.max(...cells.map(row => (row[column] ?? "").length)));
51
+ const lines = cells.map(row => row.map((cell, i) => (cell ?? "").padEnd(widths[i]!)).join(" ").trimEnd());
52
+ const footer: string[] = [];
53
+ if (typeof payload.attributionSince === "string") {
54
+ footer.push(`attribution since ${payload.attributionSince}`);
55
+ }
56
+ if (payload.historyTruncated === true) {
57
+ footer.push("older history truncated");
58
+ }
59
+ if (keys.some(entry => (entry.usage as Record<string, unknown> | undefined)?.ambiguous === true)) {
60
+ footer.push("ambiguous: two configured keys share an id, so per-key totals do not exist");
61
+ }
62
+ return footer.length > 0 ? [...lines, "", ...footer] : lines;
63
+ }
64
+
20
65
  async function key(argv: string[], deps: RuntimeApiDeps): Promise<void> {
21
66
  const args = [...argv];
22
67
  const action = (args.shift() ?? "list").toLowerCase();
@@ -25,9 +70,7 @@ async function key(argv: string[], deps: RuntimeApiDeps): Promise<void> {
25
70
  rejectArgs(args, USAGE);
26
71
  const result = await runtimeRequest<Record<string, unknown>>("/api/keys", {}, deps);
27
72
  const keys = Array.isArray(result.keys) ? result.keys as Array<Record<string, unknown>> : [];
28
- printData(result, wantsJson, keys.length
29
- ? keys.map(entry => `${String(entry.id)} ${String(entry.name)} ${String(entry.prefix ?? "")}`)
30
- : ["No API access keys configured."]);
73
+ printData(result, wantsJson, keys.length ? formatKeyRows(result, keys) : ["No API access keys configured."]);
31
74
  return;
32
75
  }
33
76
  if (action === "create") {
@@ -24,6 +24,14 @@ export interface AccountRow {
24
24
  /** Codex pool selection order, higher used earlier. Absent where ordering does not apply. */
25
25
  priority?: number;
26
26
  quota?: CodexQuotaDto | null;
27
+ /**
28
+ * Whether the pool is holding this account out of rotation.
29
+ *
30
+ * The server has always sent it (auth-api.ts:286 for pool accounts, :1315 for main) and the
31
+ * CLI dropped it, so a paused account was indistinguishable from an available one in every
32
+ * human listing (#2703).
33
+ */
34
+ paused?: boolean;
27
35
  }
28
36
 
29
37
  export type ClassifyResult = { type: AccountType } | { error: string };
@@ -83,6 +91,12 @@ export interface ApiResult {
83
91
  /** 0 = network-level failure (proxy unreachable). */
84
92
  status: number;
85
93
  json: Record<string, unknown>;
94
+ /**
95
+ * Message from the thrown transport error when `status` is 0. Previously the
96
+ * error was swallowed by a catch block with an empty body, so an unreachable proxy, a DNS
97
+ * failure and a TLS error were indistinguishable (#2698).
98
+ */
99
+ transportError?: string;
86
100
  }
87
101
 
88
102
  export async function apiJson(
@@ -103,8 +117,14 @@ export async function apiJson(
103
117
  });
104
118
  const json = (await res.json().catch(() => ({}))) as Record<string, unknown>;
105
119
  return { status: res.status, json };
106
- } catch {
107
- return { status: 0, json: {} };
120
+ } catch (error) {
121
+ // status 0 stays the transport sentinel, but keep the cause: callers can now
122
+ // tell the operator why the request never reached the proxy (#2698).
123
+ return {
124
+ status: 0,
125
+ json: {},
126
+ transportError: error instanceof Error ? error.message : String(error),
127
+ };
108
128
  }
109
129
  }
110
130
 
@@ -115,18 +135,43 @@ export async function resolveBaseUrl(deps: AccountDeps): Promise<string | null>
115
135
  return `http://${probeHostname(live.hostname)}:${live.port}`;
116
136
  }
117
137
 
118
- export function proxyUnreachable(): number {
138
+ export function proxyUnreachable(transportError?: string): number {
119
139
  console.error("Proxy not reachable. Start it with 'ocx start' or 'ocx ensure'.");
140
+ // Naming the transport cause distinguishes "nothing is listening" from a refused
141
+ // or reset connection, which is what made #2696-class breakage undiagnosable.
142
+ if (transportError) console.error(`reason: ${transportError}`);
120
143
  return 1;
121
144
  }
122
145
 
123
- export function apiError(json: Record<string, unknown>, fallback: string): number {
124
- const message = typeof json.error === "string" ? json.error : fallback;
125
- console.error(`Error: ${message}`);
146
+ function accountStringField(json: Record<string, unknown>, key: string): string | undefined {
147
+ const value = json[key];
148
+ return typeof value === "string" && value.trim() ? value.trim() : undefined;
149
+ }
150
+
151
+ /**
152
+ * Report a failed management call from the account family.
153
+ *
154
+ * `reason` and `hint` are the actionable fields on a refusal — the management plane
155
+ * sets both on a 503, and several routes return `reason` with no `error` key at all,
156
+ * which used to print only the generic fallback (#2698).
157
+ *
158
+ * `status` selects the exit code so the account client speaks the same vocabulary as
159
+ * runtime-api.ts: 4 for not-found, 5 for conflict, 1 otherwise. Previously every
160
+ * failure exited 1, so a script could not distinguish a missing account from a
161
+ * concurrent mutation.
162
+ */
163
+ export function apiError(json: Record<string, unknown>, fallback: string, status: number): number {
164
+ const primary = accountStringField(json, "error") ?? fallback;
165
+ const lines = [`Error: ${primary}`];
166
+ const reason = accountStringField(json, "reason");
167
+ if (reason && reason !== primary) lines.push(`reason: ${reason}`);
168
+ const hint = accountStringField(json, "hint");
169
+ if (hint && hint !== primary) lines.push(`hint: ${hint}`);
170
+ for (const line of lines) console.error(line);
126
171
  if (json.cleanupRequired === true) {
127
172
  console.error("Warning: native-login staging cleanup is still required; run 'ocx account main doctor'.");
128
173
  }
129
- return 1;
174
+ return status === 404 ? 4 : status === 409 ? 5 : 1;
130
175
  }
131
176
 
132
177
  export interface FamilyRows {
@@ -134,10 +179,12 @@ export interface FamilyRows {
134
179
  activeId: string | null;
135
180
  autoSwitchThreshold?: number;
136
181
  /** HTTP status for a completed family read, including failures. */
137
- status?: number;
182
+ status: number;
138
183
  /** Set when the family endpoint returned an error. */
139
184
  errorJson?: Record<string, unknown>;
140
185
  networkDown?: boolean;
186
+ /** Transport cause when `networkDown` is set. Callers must forward this to `proxyUnreachable`. */
187
+ transportError?: string;
141
188
  }
142
189
 
143
190
  export interface CodexQuotaDto {
@@ -190,12 +237,19 @@ interface CodexAccountDto {
190
237
  needsReauth?: boolean;
191
238
  priority?: number;
192
239
  quota?: CodexQuotaDto | null;
240
+ paused?: boolean;
193
241
  }
194
242
 
195
243
  function projectQuota(quota: CodexQuotaDto | null | undefined): CodexQuotaDto | null {
196
244
  if (!quota) return null;
197
245
  const projected: CodexQuotaDto = {};
198
- for (const key of ["weeklyPercent", "monthlyPercent", "weeklyResetAt", "monthlyResetAt", "shortPercent", "shortResetAt", "shortWindowSeconds"] as const) {
246
+ // `fiveHourPercent`/`fiveHourResetAt` were declared on the DTO and read by two renderers
247
+ // -- `quotaText`'s `quota.fiveHourPercent ?? quota.shortPercent` (account.ts:89) and
248
+ // `quotaParts` (account-extended.ts:275) -- but omitted from this whitelist, so the first
249
+ // operand was unreachable and a 5h-only account rendered as unknown (#2703). A projection
250
+ // that silently drops a field its own type declares is worse than one that never had it:
251
+ // the type checks, the renderer looks correct, and only the output is wrong.
252
+ for (const key of ["fiveHourPercent", "fiveHourResetAt", "weeklyPercent", "monthlyPercent", "weeklyResetAt", "monthlyResetAt", "shortPercent", "shortResetAt", "shortWindowSeconds"] as const) {
199
253
  if (typeof quota[key] === "number" && Number.isFinite(quota[key])) projected[key] = quota[key];
200
254
  }
201
255
  return projected;
@@ -205,6 +259,7 @@ export async function fetchCodexRows(
205
259
  deps: AccountDeps,
206
260
  baseUrl: string,
207
261
  forceRefresh = false,
262
+ includeQuota = forceRefresh,
208
263
  ): Promise<FamilyRows> {
209
264
  const accountsPath = `/api/codex-auth/accounts${forceRefresh ? "?refresh=1" : ""}`;
210
265
  const [accountsRes, activeRes] = await Promise.all([
@@ -218,7 +273,13 @@ export async function fetchCodexRows(
218
273
  return { rows: [], activeId: null, status: activeRes.status, errorJson: activeRes.json };
219
274
  }
220
275
  if (accountsRes.status === 0 || activeRes.status === 0) {
221
- return { rows: [], activeId: null, status: 0, networkDown: true };
276
+ return {
277
+ rows: [],
278
+ activeId: null,
279
+ status: 0,
280
+ networkDown: true,
281
+ transportError: accountsRes.transportError ?? activeRes.transportError,
282
+ };
222
283
  }
223
284
  const activeId = typeof activeRes.json.activeCodexAccountId === "string"
224
285
  ? activeRes.json.activeCodexAccountId
@@ -237,7 +298,8 @@ export async function fetchCodexRows(
237
298
  active: a.id === activeId,
238
299
  needsReauth: a.needsReauth,
239
300
  priority: typeof a.priority === "number" ? a.priority : 0,
240
- ...(forceRefresh ? { quota: projectQuota(a.quota) } : {}),
301
+ paused: a.paused === true,
302
+ ...(includeQuota ? { quota: projectQuota(a.quota) } : {}),
241
303
  }));
242
304
  return { rows, activeId, autoSwitchThreshold, status: 200 };
243
305
  }
@@ -264,7 +326,9 @@ async function fetchOAuthRows(
264
326
  ? `?provider=${encodeURIComponent(name)}&quota=1${quota.refresh ? "&refresh=1" : ""}`
265
327
  : `?provider=${encodeURIComponent(name)}`;
266
328
  const res = await apiJson(deps, baseUrl, "GET", `/api/oauth/accounts${query}`);
267
- if (res.status === 0) return { rows: [], activeId: null, status: 0, networkDown: true };
329
+ if (res.status === 0) {
330
+ return { rows: [], activeId: null, status: 0, networkDown: true, transportError: res.transportError };
331
+ }
268
332
  if (res.status !== 200) return { rows: [], activeId: null, status: res.status, errorJson: res.json };
269
333
  const activeId = typeof res.json.activeAccountId === "string" ? res.json.activeAccountId : null;
270
334
  const accounts = Array.isArray(res.json.accounts) ? res.json.accounts as OAuthAccountDto[] : [];
@@ -291,7 +355,9 @@ interface ApiKeyDto {
291
355
 
292
356
  async function fetchKeyRows(deps: AccountDeps, baseUrl: string, name: string): Promise<FamilyRows> {
293
357
  const res = await apiJson(deps, baseUrl, "GET", `/api/providers/keys?name=${encodeURIComponent(name)}`);
294
- if (res.status === 0) return { rows: [], activeId: null, status: 0, networkDown: true };
358
+ if (res.status === 0) {
359
+ return { rows: [], activeId: null, status: 0, networkDown: true, transportError: res.transportError };
360
+ }
295
361
  if (res.status !== 200) return { rows: [], activeId: null, status: res.status, errorJson: res.json };
296
362
  const activeId = typeof res.json.activeId === "string" ? res.json.activeId : null;
297
363
  const keys = Array.isArray(res.json.keys) ? res.json.keys as ApiKeyDto[] : [];
@@ -313,7 +379,7 @@ export function fetchRows(
313
379
  type: AccountType,
314
380
  quota?: { refresh?: boolean },
315
381
  ): Promise<FamilyRows> {
316
- if (type === "codex") return fetchCodexRows(deps, baseUrl);
382
+ if (type === "codex") return fetchCodexRows(deps, baseUrl, Boolean(quota?.refresh), quota !== undefined);
317
383
  if (type === "oauth") return fetchOAuthRows(deps, baseUrl, name, quota);
318
384
  return fetchKeyRows(deps, baseUrl, name);
319
385
  }
@@ -322,8 +388,11 @@ export async function fetchProviderQuotaReport(
322
388
  deps: AccountDeps,
323
389
  baseUrl: string,
324
390
  name: string,
325
- ): Promise<{ status: number; report: ProviderQuotaReportDto | null; errorJson?: Record<string, unknown> }> {
391
+ ): Promise<{ status: number; report: ProviderQuotaReportDto | null; errorJson?: Record<string, unknown>; transportError?: string }> {
326
392
  const res = await apiJson(deps, baseUrl, "GET", "/api/provider-quotas?refresh=1");
393
+ if (res.status === 0) {
394
+ return { status: 0, report: null, errorJson: res.json, transportError: res.transportError };
395
+ }
327
396
  if (res.status !== 200) return { status: res.status, report: null, errorJson: res.json };
328
397
  const reports = Array.isArray(res.json.reports) ? res.json.reports as ProviderQuotaReportDto[] : [];
329
398
  return { status: 200, report: reports.find(report => report?.provider === name) ?? null };
@@ -40,6 +40,11 @@ const EXTENDED_USAGE = `Usage:
40
40
  ocx account auto-switch <provider> <on|off|status|threshold <0-100>> [--json]
41
41
  ocx account alias <provider> <id|main> <display-name|-> [--json]
42
42
  ocx account priority <provider> <id|main> [<-100..100|first|earlier|normal|later|last|reset>] [--json]
43
+ ocx account pause <provider> <id|main> [--json]
44
+ ocx account resume <provider> <id|main> [--json]
45
+ ocx account pause-exhausted <provider> [--json]
46
+ ocx account strategy <provider> [<quota|round-robin|fill-first>] [--json]
47
+ ocx account sticky <provider> [<1-100>] [--json]
43
48
  ocx account remove <provider> <id|main> --yes [--json]
44
49
  ocx account clear-cooldown <provider> <id|main> [--json]
45
50
  ocx account add-key <provider> [--label <label>] [--json]
@@ -232,8 +237,8 @@ function configAndType(deps: AccountDeps, name: string) {
232
237
  }
233
238
 
234
239
  function familyFailure(result: FamilyRows, fallback: string): number | null {
235
- if (result.networkDown) return proxyUnreachable();
236
- if (result.errorJson) return apiError(result.errorJson, fallback);
240
+ if (result.networkDown) return proxyUnreachable(result.transportError);
241
+ if (result.errorJson) return apiError(result.errorJson, fallback, result.status);
237
242
  return null;
238
243
  }
239
244
 
@@ -249,17 +254,14 @@ function resetIso(value: number | undefined): string | null {
249
254
 
250
255
  function refreshLine(row: FamilyRows["rows"][number]): string {
251
256
  const parts = [row.id === MAIN_ID ? "main" : row.id, row.email, row.plan];
252
- const quota = row.quota;
253
- if (!quota || (quota.weeklyPercent === undefined && quota.monthlyPercent === undefined)) {
254
- parts.push("quota: unknown");
255
- } else {
256
- if (quota.weeklyPercent !== undefined) parts.push(`weekly ${quota.weeklyPercent}%`);
257
- const weeklyReset = resetIso(quota.weeklyResetAt);
258
- if (weeklyReset) parts.push(`resets ${weeklyReset}`);
259
- if (quota.monthlyPercent !== undefined) parts.push(`monthly ${quota.monthlyPercent}%`);
260
- const monthlyReset = resetIso(quota.monthlyResetAt);
261
- if (monthlyReset) parts.push(`resets ${monthlyReset}`);
262
- }
257
+ if (row.paused) parts.push("paused");
258
+ // Was a second quota dialect: it gated the whole block on weekly/monthly, so an account
259
+ // reporting only a 5h window printed `quota: unknown` while `quotaParts` five lines below
260
+ // rendered the same data correctly for the provider path (#2703). Two halves of one file
261
+ // disagreeing about how to read one DTO is the defect; delegating removes it rather than
262
+ // teaching the second dialect a third window.
263
+ const quotaText = row.quota ? quotaParts(row.quota).join(" ") : "";
264
+ parts.push(quotaText.length > 0 ? quotaText : "quota: unknown");
263
265
  if (row.needsReauth) parts.push("needs-reauth");
264
266
  return parts.filter(Boolean).join(" ");
265
267
  }
@@ -325,8 +327,8 @@ export async function cmdRefresh(args: string[], deps: AccountDeps): Promise<num
325
327
  if (!baseUrl) return proxyUnreachable();
326
328
  if (classified.type !== "codex") {
327
329
  const result = await fetchProviderQuotaReport(deps, baseUrl, name);
328
- if (result.status === 0) return proxyUnreachable();
329
- if (result.status !== 200) return apiError(result.errorJson ?? {}, `failed to refresh ${name}`);
330
+ if (result.status === 0) return proxyUnreachable(result.transportError);
331
+ if (result.status !== 200) return apiError(result.errorJson ?? {}, `failed to refresh ${name}`, result.status);
330
332
  if (wantsJson) console.log(JSON.stringify({ provider: name, report: result.report }, null, 2));
331
333
  else console.log(result.report ? providerQuotaLine(name, result.report) : `no quota report available for ${name}`);
332
334
  return 0;
@@ -360,15 +362,15 @@ export async function cmdAutoSwitch(args: string[], deps: AccountDeps): Promise<
360
362
  if (!baseUrl) return proxyUnreachable();
361
363
  if (action === "status") {
362
364
  const response = await apiJson(deps, baseUrl, "GET", "/api/codex-auth/active");
363
- if (response.status === 0) return proxyUnreachable();
365
+ if (response.status === 0) return proxyUnreachable(response.transportError);
364
366
  if (response.status !== 200 || typeof response.json.autoSwitchThreshold !== "number") {
365
- return apiError(response.json, "failed to read auto-switch status");
367
+ return apiError(response.json, "failed to read auto-switch status", response.status);
366
368
  }
367
369
  threshold = response.json.autoSwitchThreshold;
368
370
  } else {
369
371
  const response = await apiJson(deps, baseUrl, "PUT", "/api/codex-auth/auto-switch", { threshold });
370
- if (response.status === 0) return proxyUnreachable();
371
- if (response.status !== 200) return apiError(response.json, "failed to update auto-switch");
372
+ if (response.status === 0) return proxyUnreachable(response.transportError);
373
+ if (response.status !== 200) return apiError(response.json, "failed to update auto-switch", response.status);
372
374
  }
373
375
  const enabled = threshold! > 0;
374
376
  if (wantsJson) console.log(JSON.stringify({ provider: name, autoSwitchThreshold: threshold, enabled }, null, 2));
@@ -459,8 +461,8 @@ export async function cmdAddKey(args: string[], deps: AccountDeps): Promise<numb
459
461
  const baseUrl = await resolveBaseUrl(deps);
460
462
  if (!baseUrl) return proxyUnreachable();
461
463
  const response = await apiJson(deps, baseUrl, "POST", "/api/providers/keys", { name, key, ...(label ? { label } : {}) });
462
- if (response.status === 0) return proxyUnreachable();
463
- if (response.status !== 201) return apiError(response.json, `failed to add a key for ${name}`);
464
+ if (response.status === 0) return proxyUnreachable(response.transportError);
465
+ if (response.status !== 201) return apiError(response.json, `failed to add a key for ${name}`, response.status);
464
466
  const id = typeof response.json.id === "string" ? response.json.id : null;
465
467
  // Redact the key inside the label BEFORE serialization — a key containing
466
468
  // JSON-escaped characters (" or \) would otherwise survive the whole-output
@@ -536,7 +538,7 @@ export async function cmdImport(args: string[], deps: AccountDeps): Promise<numb
536
538
  clearTimeout(timer);
537
539
  }
538
540
  if (response.status === 0) {
539
- if (!timedOut) return proxyUnreachable();
541
+ if (!timedOut) return proxyUnreachable(response.transportError);
540
542
  console.error(`Error: import_timeout after ${importTimeoutMs}ms`);
541
543
  return 1;
542
544
  }
@@ -589,8 +591,8 @@ export async function cmdClearCooldown(args: string[], deps: AccountDeps): Promi
589
591
  const baseUrl = await resolveBaseUrl(deps);
590
592
  if (!baseUrl) return proxyUnreachable();
591
593
  const response = await apiJson(deps, baseUrl, "POST", "/api/codex-auth/accounts/clear-cooldown", { id });
592
- if (response.status === 0) return proxyUnreachable();
593
- if (response.status !== 200) return apiError(response.json, `failed to clear cooldown for ${requestedId}`);
594
+ if (response.status === 0) return proxyUnreachable(response.transportError);
595
+ if (response.status !== 200) return apiError(response.json, `failed to clear cooldown for ${requestedId}`, response.status);
594
596
  const cleared = response.json?.cleared === true;
595
597
  if (wantsJson) console.log(JSON.stringify({ ok: true, provider: name, id, cleared }, null, 2));
596
598
  else if (cleared) console.log(`${name}: cooldown lifted for ${requestedId}`);
@@ -680,8 +682,8 @@ export async function cmdPriority(args: string[], deps: AccountDeps): Promise<nu
680
682
  }
681
683
 
682
684
  const response = await apiJson(deps, baseUrl, "PUT", "/api/codex-auth/accounts/priority", { id, priority });
683
- if (response.status === 0) return proxyUnreachable();
684
- if (response.status !== 200) return apiError(response.json, `failed to set selection order for ${requestedId}`);
685
+ if (response.status === 0) return proxyUnreachable(response.transportError);
686
+ if (response.status !== 200) return apiError(response.json, `failed to set selection order for ${requestedId}`, response.status);
685
687
  const applied = typeof response.json.priority === "number" ? response.json.priority : (priority ?? 0);
686
688
  if (wantsJson) {
687
689
  console.log(JSON.stringify(
@@ -703,6 +705,237 @@ export async function cmdPriority(args: string[], deps: AccountDeps): Promise<nu
703
705
  return 0;
704
706
  }
705
707
 
708
+ /**
709
+ * `ocx account pause|resume <provider> <id>` (#2702).
710
+ *
711
+ * The server routes have always existed; only the CLI caller was missing, so pausing an
712
+ * account was dashboard-only. The issue reports these as POST; the code is PUT
713
+ * (`auth-api.ts:1494`), and the route is shared by both directions with a `paused` boolean
714
+ * rather than being two endpoints.
715
+ */
716
+ export async function cmdPause(args: string[], deps: AccountDeps, paused: boolean): Promise<number> {
717
+ const wantsJson = flag(args, "--json");
718
+ const name = args.shift();
719
+ const requestedId = args.shift();
720
+ const verb = paused ? "pause" : "resume";
721
+ if (!name || !requestedId || args.length) return usage();
722
+ const classified = configAndType(deps, name);
723
+ if ("error" in classified) return usage(`Error: ${classified.error}`);
724
+ if (classified.type !== "codex") {
725
+ return usage(`Error: ${verb} applies to the openai Codex account pool`);
726
+ }
727
+ const id = requestedId === "main" ? MAIN_ID : requestedId;
728
+
729
+ const baseUrl = await resolveBaseUrl(deps);
730
+ if (!baseUrl) return proxyUnreachable();
731
+
732
+ const response = await apiJson(deps, baseUrl, "PUT", "/api/codex-auth/accounts/pause", { id, paused });
733
+ // Transport sentinel first: status 0 means the proxy never answered, and comparing it to
734
+ // 200 would report an unreachable proxy as a management error.
735
+ if (response.status === 0) return proxyUnreachable(response.transportError);
736
+ if (response.status !== 200) return apiError(response.json, `failed to ${verb} ${requestedId}`, response.status);
737
+
738
+ if (wantsJson) {
739
+ console.log(JSON.stringify({ ok: true, provider: name, id, paused }, null, 2));
740
+ } else {
741
+ console.log(`${name}: ${requestedId} ${paused ? "paused" : "resumed"}`);
742
+ }
743
+ if (paused) {
744
+ // Both are server-side effects of this route (auth-api.ts:1508-1510), not consequences
745
+ // the operator would infer from the word "pause".
746
+ console.error("Threads bound to this account are unbound, and a fallback account is selected if this one was active.");
747
+ }
748
+ return 0;
749
+ }
750
+
751
+ /**
752
+ * `ocx account pause-exhausted [--off]` (#2702).
753
+ *
754
+ * Pauses every account whose quota is spent. The route refreshes quota for each account, so
755
+ * it can partially fail; the response distinguishes "checked none and some failed" from
756
+ * "checked some", and that distinction is reported rather than flattened into ok/not-ok.
757
+ */
758
+ export async function cmdPauseExhausted(args: string[], deps: AccountDeps): Promise<number> {
759
+ const wantsJson = flag(args, "--json");
760
+ const name = args.shift();
761
+ if (!name || args.length) return usage();
762
+ const classified = configAndType(deps, name);
763
+ if ("error" in classified) return usage(`Error: ${classified.error}`);
764
+ if (classified.type !== "codex") {
765
+ return usage("Error: pause-exhausted applies to the openai Codex account pool");
766
+ }
767
+
768
+ const baseUrl = await resolveBaseUrl(deps);
769
+ if (!baseUrl) return proxyUnreachable();
770
+
771
+ const response = await apiJson(deps, baseUrl, "PUT", "/api/codex-auth/accounts/pause-exhausted", {});
772
+ if (response.status === 0) return proxyUnreachable(response.transportError);
773
+ if (response.status !== 200) return apiError(response.json, "failed to pause exhausted accounts", response.status);
774
+
775
+ const pausedIds = Array.isArray(response.json.pausedAccountIds)
776
+ ? (response.json.pausedAccountIds as unknown[]).filter((value): value is string => typeof value === "string")
777
+ : [];
778
+ const checked = typeof response.json.checkedAccountCount === "number" ? response.json.checkedAccountCount : null;
779
+ const failed = typeof response.json.failedAccountCount === "number" ? response.json.failedAccountCount : null;
780
+
781
+ const complete = failed === null || failed === 0;
782
+ const ok = complete;
783
+ if (failed !== null && failed > 0) {
784
+ console.error(`Quota refresh failed for ${failed} account(s); those were not evaluated.`);
785
+ }
786
+ if (wantsJson) {
787
+ console.log(JSON.stringify({
788
+ ok,
789
+ complete,
790
+ provider: name,
791
+ pausedAccountIds: pausedIds,
792
+ checkedAccountCount: checked,
793
+ failedAccountCount: failed,
794
+ }, null, 2));
795
+ return ok ? 0 : 1;
796
+ }
797
+ console.log(pausedIds.length > 0
798
+ ? `${name}: paused ${pausedIds.length} exhausted account(s): ${pausedIds.join(", ")}`
799
+ : `${name}: no exhausted accounts to pause`);
800
+ return ok ? 0 : 1;
801
+ }
802
+
803
+ /**
804
+ * Two pools expose strategy and sticky, and they are NOT reached the same way:
805
+ *
806
+ * | | Codex pool | Anthropic pool |
807
+ * |---|---|---|
808
+ * | read | `GET /api/codex-auth/active` | `GET /api/oauth/accounts/pool?provider=` |
809
+ * | write | `PUT /api/codex-auth/pool-strategy` | `PUT /api/oauth/accounts/pool` |
810
+ * | keys | `accountPoolStrategy`/`accountPoolStickyLimit` | `strategy`/`stickyLimit` |
811
+ * | body | bare field | field **plus** a mandatory `provider` |
812
+ *
813
+ * Omitting `provider` from the Anthropic write body earns a 400
814
+ * (`oauth-account-routes.ts:344`), so the asymmetry has to be encoded somewhere. Encoding it
815
+ * here keeps ONE verb pair working on both pools. The alternative the plan left open -- a second
816
+ * `provider-strategy`/`provider-sticky` pair -- would double the surface an operator must learn
817
+ * to express one idea, and a CLI that can steer one pool and not the other is exactly the trap
818
+ * this unit exists to remove.
819
+ */
820
+ interface PoolTransport {
821
+ readPath: string;
822
+ writePath: string;
823
+ /** Response key carrying the applied strategy. */
824
+ strategyKey: string;
825
+ /** Response key carrying the applied sticky limit. */
826
+ stickyKey: string;
827
+ writeBody: (field: "strategy" | "stickyLimit", value: unknown) => Record<string, unknown>;
828
+ }
829
+
830
+ const CODEX_POOL_TRANSPORT: PoolTransport = {
831
+ readPath: "/api/codex-auth/active",
832
+ writePath: "/api/codex-auth/pool-strategy",
833
+ strategyKey: "accountPoolStrategy",
834
+ stickyKey: "accountPoolStickyLimit",
835
+ writeBody: (field, value) => ({ [field]: value }),
836
+ };
837
+
838
+ function anthropicPoolTransport(provider: string): PoolTransport {
839
+ return {
840
+ readPath: `/api/oauth/accounts/pool?provider=${encodeURIComponent(provider)}`,
841
+ writePath: "/api/oauth/accounts/pool",
842
+ strategyKey: "strategy",
843
+ stickyKey: "stickyLimit",
844
+ writeBody: (field, value) => ({ provider, [field]: value }),
845
+ };
846
+ }
847
+
848
+ /**
849
+ * The pool-config route supports `anthropic` only and says so with a 400. Any other OAuth
850
+ * provider is refused here with the same wording rather than spending a round-trip to learn it.
851
+ */
852
+ function poolTransportFor(
853
+ classified: { type: "codex" | "oauth" | "api-key" },
854
+ name: string,
855
+ ): PoolTransport | string {
856
+ if (classified.type === "codex") return CODEX_POOL_TRANSPORT;
857
+ if (classified.type === "oauth" && name === "anthropic") return anthropicPoolTransport(name);
858
+ return `pool settings apply to the openai Codex pool and the anthropic pool, not "${name}"`;
859
+ }
860
+
861
+ /**
862
+ * `ocx account strategy <provider> [<name>]` and `ocx account sticky <provider> [<n>]` (#2702).
863
+ *
864
+ * One implementation rather than two near-duplicates, because strategy and sticky are two
865
+ * fields of one setting on every pool that has them.
866
+ *
867
+ * Values are NOT re-validated here. The server owns both contracts -- three strategy names,
868
+ * a 1-100 sticky bound -- and a duplicated bound is a second thing to keep in sync. Its 400
869
+ * is actionable now that the CLI prints `reason`.
870
+ */
871
+ async function poolSetting(
872
+ args: string[],
873
+ deps: AccountDeps,
874
+ field: "strategy" | "stickyLimit",
875
+ ): Promise<number> {
876
+ const wantsJson = flag(args, "--json");
877
+ const name = args.shift();
878
+ const requested = args.shift();
879
+ const label = field === "strategy" ? "pool strategy" : "sticky limit";
880
+ if (!name || args.length) return usage();
881
+ const classified = configAndType(deps, name);
882
+ if ("error" in classified) return usage(`Error: ${classified.error}`);
883
+ const transport = poolTransportFor(classified, name);
884
+ if (typeof transport === "string") return usage(`Error: ${transport}`);
885
+
886
+ const baseUrl = await resolveBaseUrl(deps);
887
+ if (!baseUrl) return proxyUnreachable();
888
+
889
+ // No value means show. A read must not rewrite what it is reporting.
890
+ if (requested === undefined) {
891
+ const response = await apiJson(deps, baseUrl, "GET", transport.readPath);
892
+ if (response.status === 0) return proxyUnreachable(response.transportError);
893
+ if (response.status !== 200) return apiError(response.json, `failed to read ${label}`, response.status);
894
+ const strategy = response.json[transport.strategyKey];
895
+ const sticky = response.json[transport.stickyKey];
896
+ if (wantsJson) {
897
+ // Pool-neutral key names: the two routes spell the same two settings differently, and a
898
+ // `--json` consumer should not have to branch on which pool answered.
899
+ console.log(JSON.stringify({ ok: true, provider: name, strategy, stickyLimit: sticky }, null, 2));
900
+ } else {
901
+ console.log(`${name}: ${label} is ${String(field === "strategy" ? strategy : sticky)}`);
902
+ }
903
+ return 0;
904
+ }
905
+
906
+ // Sent as a number when it parses as one so the server sees the type it validates;
907
+ // a non-numeric string still goes through and earns the server's own 400.
908
+ const value = field === "strategy"
909
+ ? requested
910
+ : (Number.isNaN(Number(requested)) ? requested : Number(requested));
911
+ const response = await apiJson(deps, baseUrl, "PUT", transport.writePath, transport.writeBody(field, value));
912
+ if (response.status === 0) return proxyUnreachable(response.transportError);
913
+ if (response.status !== 200) return apiError(response.json, `failed to set ${label}`, response.status);
914
+
915
+ if (wantsJson) {
916
+ console.log(JSON.stringify({
917
+ ok: true,
918
+ provider: name,
919
+ strategy: response.json[transport.strategyKey],
920
+ stickyLimit: response.json[transport.stickyKey],
921
+ }, null, 2));
922
+ } else {
923
+ // Echo the APPLIED value from the response, not the requested one: the server normalizes,
924
+ // and printing the request would hide a normalization the operator should see.
925
+ const applied = field === "strategy" ? response.json[transport.strategyKey] : response.json[transport.stickyKey];
926
+ console.log(`${name}: ${label} is now ${String(applied)}`);
927
+ }
928
+ return 0;
929
+ }
930
+
931
+ export function cmdStrategy(args: string[], deps: AccountDeps): Promise<number> {
932
+ return poolSetting(args, deps, "strategy");
933
+ }
934
+
935
+ export function cmdSticky(args: string[], deps: AccountDeps): Promise<number> {
936
+ return poolSetting(args, deps, "stickyLimit");
937
+ }
938
+
706
939
  export async function cmdAlias(args: string[], deps: AccountDeps): Promise<number> {
707
940
  const wantsJson = flag(args, "--json");
708
941
  const name = args.shift();
@@ -728,8 +961,8 @@ export async function cmdAlias(args: string[], deps: AccountDeps): Promise<numbe
728
961
  ? { provider: name, accountId: id, alias }
729
962
  : { name, id, alias };
730
963
  const response = await apiJson(deps, baseUrl, "PUT", path, body);
731
- if (response.status === 0) return proxyUnreachable();
732
- if (response.status !== 200) return apiError(response.json, `failed to rename ${requestedId}`);
964
+ if (response.status === 0) return proxyUnreachable(response.transportError);
965
+ if (response.status !== 200) return apiError(response.json, `failed to rename ${requestedId}`, response.status);
733
966
  const result = { ok: true, provider: name, id, alias: alias || null };
734
967
  if (wantsJson) console.log(JSON.stringify(result, null, 2));
735
968
  else console.log(alias ? `${name}: ${requestedId} is now “${alias}”` : `${name}: cleared alias for ${requestedId}`);