@bitkyc08/opencodex 2.7.40 → 2.7.41

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 (110) hide show
  1. package/README.md +63 -12
  2. package/assets/claude-code-models.gif +0 -0
  3. package/gui/dist/assets/index-B2J4t3te.css +1 -0
  4. package/gui/dist/assets/index-BmvM6wRb.js +65 -0
  5. package/gui/dist/index.html +2 -2
  6. package/package.json +3 -2
  7. package/src/adapters/google.ts +48 -9
  8. package/src/adapters/kiro-events.ts +15 -3
  9. package/src/adapters/kiro.ts +292 -28
  10. package/src/adapters/openai-chat.ts +63 -15
  11. package/src/adapters/openai-responses.ts +18 -0
  12. package/src/bridge.ts +76 -21
  13. package/src/chat/outbound.ts +66 -34
  14. package/src/claude/auth-detect.ts +229 -0
  15. package/src/claude/auth-mode-migration.ts +32 -0
  16. package/src/claude/auth-mode.ts +62 -0
  17. package/src/claude/desktop-3p-guard.ts +35 -0
  18. package/src/claude/desktop-3p.ts +121 -21
  19. package/src/claude/desktop-health.ts +26 -0
  20. package/src/claude/desktop-profile.ts +263 -0
  21. package/src/claude/inbound-debug.ts +4 -0
  22. package/src/claude/model-info.ts +9 -3
  23. package/src/cli/account-extended.ts +34 -0
  24. package/src/cli/account.ts +3 -1
  25. package/src/cli/claude-desktop.ts +152 -0
  26. package/src/cli/claude.ts +37 -3
  27. package/src/cli/doctor.ts +187 -6
  28. package/src/cli/help.ts +13 -1
  29. package/src/cli/index.ts +151 -57
  30. package/src/cli/status-oauth.ts +68 -0
  31. package/src/cli/status.ts +4 -0
  32. package/src/codex/account-lifecycle.ts +31 -0
  33. package/src/codex/auth-api.ts +133 -26
  34. package/src/codex/auth-collision.ts +55 -11
  35. package/src/codex/auth-context.ts +50 -6
  36. package/src/codex/catalog/provider-fetch.ts +6 -2
  37. package/src/codex/home.ts +61 -1
  38. package/src/codex/inject.ts +28 -66
  39. package/src/codex/injected-marker.ts +72 -0
  40. package/src/codex/journal.ts +39 -3
  41. package/src/codex/main-account-cache.ts +25 -0
  42. package/src/codex/model-cache.ts +20 -1
  43. package/src/codex/paths.ts +5 -0
  44. package/src/codex/routing.ts +138 -11
  45. package/src/codex/subagent-model-fallback.ts +455 -0
  46. package/src/codex/sync.ts +17 -0
  47. package/src/combos/failover.ts +10 -2
  48. package/src/combos/index.ts +1 -0
  49. package/src/combos/types.ts +9 -0
  50. package/src/config.ts +117 -0
  51. package/src/grok/inject.ts +339 -0
  52. package/src/grok/status.ts +88 -0
  53. package/src/grok/sync.ts +66 -0
  54. package/src/lib/destination-policy.ts +13 -0
  55. package/src/lib/errors.ts +59 -0
  56. package/src/lib/privacy.ts +9 -0
  57. package/src/lib/process-control.ts +57 -4
  58. package/src/oauth/health.ts +375 -0
  59. package/src/oauth/index.ts +69 -22
  60. package/src/oauth/kiro.ts +5 -3
  61. package/src/oauth/log.ts +48 -0
  62. package/src/oauth/store.ts +55 -6
  63. package/src/providers/alibaba-region-backup.ts +75 -0
  64. package/src/providers/alibaba-region-migration.ts +143 -0
  65. package/src/providers/alibaba-region-startup.ts +36 -0
  66. package/src/providers/api-keys.ts +5 -5
  67. package/src/providers/derive.ts +22 -1
  68. package/src/providers/free-directory.ts +181 -0
  69. package/src/providers/key-failover.ts +2 -2
  70. package/src/providers/kiro-models.ts +3 -2
  71. package/src/providers/openai-tiers.ts +1 -1
  72. package/src/providers/provider-id-rewrite.ts +150 -0
  73. package/src/providers/registry.ts +49 -2
  74. package/src/responses/parser.ts +49 -20
  75. package/src/responses/state.ts +156 -2
  76. package/src/router.ts +73 -5
  77. package/src/server/chat-completions.ts +78 -22
  78. package/src/server/claude-messages.ts +8 -0
  79. package/src/server/images.ts +2 -1
  80. package/src/server/index.ts +50 -30
  81. package/src/server/live.ts +2 -1
  82. package/src/server/management/agent-settings-routes.ts +337 -18
  83. package/src/server/management/api-access.ts +141 -0
  84. package/src/server/management/combo-routes.ts +3 -3
  85. package/src/server/management/config-routes.ts +4 -4
  86. package/src/server/management/logs-usage-routes.ts +64 -4
  87. package/src/server/management/model-routes.ts +114 -8
  88. package/src/server/management/oauth-account-routes.ts +31 -5
  89. package/src/server/management/provider-routes.ts +42 -11
  90. package/src/server/management/shared.ts +81 -2
  91. package/src/server/management/system-routes.ts +6 -1
  92. package/src/server/management-api.ts +24 -7
  93. package/src/server/port-reclaim.ts +261 -0
  94. package/src/server/request-log.ts +11 -5
  95. package/src/server/responses/collaboration.ts +11 -6
  96. package/src/server/responses/compact.ts +2 -1
  97. package/src/server/responses/core.ts +358 -155
  98. package/src/server/responses/passthrough-error.ts +53 -0
  99. package/src/server/search.ts +2 -1
  100. package/src/server/system-env.ts +23 -6
  101. package/src/server/windows-tcp-drop.ts +174 -0
  102. package/src/service.ts +43 -3
  103. package/src/types.ts +74 -6
  104. package/src/update/index.ts +30 -19
  105. package/src/update/job.ts +30 -15
  106. package/src/usage/log.ts +158 -2
  107. package/src/usage/summary.ts +8 -4
  108. package/src/web-search/loop.ts +4 -1
  109. package/gui/dist/assets/index-CMip1DzF.css +0 -1
  110. package/gui/dist/assets/index-cydcmbzC.js +0 -52
@@ -0,0 +1,68 @@
1
+ import { maskAccountId } from "../lib/privacy";
2
+ import {
3
+ CODEX_HEALTH_UNAVAILABLE_NOTE,
4
+ MASKED_ACCOUNT_FALLBACK,
5
+ type OAuthAccountHealth,
6
+ type OAuthCliHealthReport,
7
+ type OAuthHealthEntry,
8
+ } from "../oauth/health";
9
+
10
+ function describeHealth(health: OAuthAccountHealth): string {
11
+ switch (health.status) {
12
+ case "healthy":
13
+ return "healthy";
14
+ case "reauth_required":
15
+ return "reauthentication required";
16
+ case "cooldown":
17
+ return health.reason === "rate_limit"
18
+ ? `rate limited until ${health.until}`
19
+ : `quota limited until ${health.until}`;
20
+ case "warning":
21
+ switch (health.reason) {
22
+ case "refresh_conflict":
23
+ return "refresh conflict";
24
+ case "metadata_mismatch":
25
+ return "metadata mismatch";
26
+ case "stale_credentials":
27
+ return "stale credentials";
28
+ default:
29
+ return "warning";
30
+ }
31
+ default:
32
+ return "unknown";
33
+ }
34
+ }
35
+
36
+ function formatEntryBlock(entries: OAuthHealthEntry[]): string {
37
+ if (entries.length === 0) return "";
38
+
39
+ const notable = entries.filter((entry) => entry.health.status !== "healthy");
40
+ if (notable.length === 0) return "OAuth health: ok";
41
+
42
+ const lines = ["OAuth health: warning"];
43
+ for (const entry of notable) {
44
+ const masked = maskAccountId(entry.accountId) ?? MASKED_ACCOUNT_FALLBACK;
45
+ lines.push(` ${entry.provider} ${masked} ${describeHealth(entry.health)}`);
46
+ if (entry.action) {
47
+ lines.push(` Action: ${entry.action}`);
48
+ }
49
+ }
50
+ return lines.join("\n");
51
+ }
52
+
53
+ /** Human-readable OAuth health block for `ocx status` (redacted account ids, no tokens). */
54
+ export function formatOAuthHealthForStatus(
55
+ input: OAuthHealthEntry[] | OAuthCliHealthReport,
56
+ ): string {
57
+ const report: OAuthCliHealthReport = Array.isArray(input)
58
+ ? { entries: input, codexHealthSource: "management-api" }
59
+ : input;
60
+
61
+ const parts: string[] = [];
62
+ if (report.codexHealthSource === "unavailable") {
63
+ parts.push(CODEX_HEALTH_UNAVAILABLE_NOTE);
64
+ }
65
+ const oauthBlock = formatEntryBlock(report.entries);
66
+ if (oauthBlock) parts.push(oauthBlock);
67
+ return parts.join("\n");
68
+ }
package/src/cli/status.ts CHANGED
@@ -9,6 +9,7 @@ import { getCodexRoutingKind } from "../codex/inject";
9
9
  import { diagnoseCodexShim } from "../codex/shim";
10
10
  import { displayCodexRuntimePath, effortClampAppliesToRuntime, loadLastEffortClamp, resolveCodexRuntime } from "../codex/runtime";
11
11
  import { redactSecretString, redactUserPath } from "../lib/redact";
12
+ import { collectOrcaCodexHomeDiagnostic, type OrcaCodexHomeDiagnostic } from "../codex/home";
12
13
 
13
14
  type HealthCheck = {
14
15
  ok: boolean;
@@ -65,6 +66,7 @@ export type CliStatusJson = {
65
66
  runtimeVersion: string | null;
66
67
  };
67
68
  };
69
+ codexHome: OrcaCodexHomeDiagnostic;
68
70
  };
69
71
 
70
72
  export type CliStatusView = {
@@ -163,6 +165,7 @@ export async function collectStatus(): Promise<CliStatusView> {
163
165
  })();
164
166
  const lastClamp = loadLastEffortClamp();
165
167
  const clampActive = effortClampAppliesToRuntime(lastClamp, resolvedRuntime.runtime);
168
+ const codexHome = collectOrcaCodexHomeDiagnostic();
166
169
  const warningParts: string[] = [];
167
170
  if (
168
171
  resolvedRuntime.replacedConfigured
@@ -256,6 +259,7 @@ export async function collectStatus(): Promise<CliStatusView> {
256
259
  codexShim: { summary: codexShimSummary },
257
260
  codexPlugins,
258
261
  codexRuntime,
262
+ codexHome,
259
263
  },
260
264
  };
261
265
  }
@@ -1,15 +1,46 @@
1
1
  import { removeCodexAccountCredential } from "./account-store";
2
2
  import { clearAccountNeedsReauth } from "./account-runtime-state";
3
+ import { getMainChatgptAccountId } from "./auth-collision";
4
+ import { MAIN_CODEX_ACCOUNT_ID, setMainAccountPlan } from "./main-account";
3
5
  import { clearAccountQuota } from "./quota";
4
6
  import { clearCodexUpstreamHealthForAccount, clearThreadAccountMapForAccount } from "./routing";
5
7
  import { invalidateCodexWebSocketsForAccount } from "./websocket-registry";
8
+ import { clearMainAccountInfoCache } from "./main-account-cache";
6
9
  import type { OcxConfig } from "../types";
7
10
 
11
+ let observedMainChatgptAccountId: string | undefined;
12
+
8
13
  export function purgeCodexAccountRuntimeState(accountId: string): void {
9
14
  clearAccountNeedsReauth(accountId);
10
15
  clearAccountQuota(accountId);
11
16
  clearThreadAccountMapForAccount(accountId);
12
17
  clearCodexUpstreamHealthForAccount(accountId);
18
+ if (accountId === MAIN_CODEX_ACCOUNT_ID) clearMainAccountInfoCache();
19
+ }
20
+
21
+ /**
22
+ * The main Codex login is stored under the stable `__main__` alias, while
23
+ * `~/.codex/auth.json` can be replaced with credentials for another physical
24
+ * ChatGPT account. Drop alias-keyed runtime state when that identity changes so
25
+ * cooldown, quota, reauth, and thread affinity do not leak across accounts.
26
+ */
27
+ export function reconcileMainCodexAccountRuntimeState(): boolean {
28
+ const currentAccountId = getMainChatgptAccountId();
29
+ // A missing/malformed auth.json is an unknown identity, not a confirmed account switch. Keep the
30
+ // prior observation and its safety state until a real account id can be read again.
31
+ if (currentAccountId === null) return false;
32
+ const previousAccountId = observedMainChatgptAccountId;
33
+ observedMainChatgptAccountId = currentAccountId;
34
+ if (previousAccountId === undefined || previousAccountId === currentAccountId) return false;
35
+
36
+ purgeCodexAccountRuntimeState(MAIN_CODEX_ACCOUNT_ID);
37
+ setMainAccountPlan(null);
38
+ invalidateCodexWebSocketsForAccount(MAIN_CODEX_ACCOUNT_ID);
39
+ return true;
40
+ }
41
+
42
+ export function resetMainCodexAccountIdentityTrackingForTests(): void {
43
+ observedMainChatgptAccountId = undefined;
13
44
  }
14
45
 
15
46
  export function deleteCodexAccount(runtimeConfig: OcxConfig, accountId: string): void {
@@ -1,4 +1,4 @@
1
- import { loadConfig, saveConfig } from "../config";
1
+ import { loadConfig, saveConfigPreservingClaudeCode } from "../config";
2
2
  import { withCodexAccountLogLabel } from "./account-label";
3
3
  import {
4
4
  getCodexAccountCredential,
@@ -9,8 +9,9 @@ import {
9
9
  CodexCredentialRefreshLockTimeoutError,
10
10
  TokenRefreshError,
11
11
  } from "./account-store";
12
- import { deleteCodexAccount } from "./account-lifecycle";
13
- import { checkAccountIdCollision, readCodexTokens } from "./auth-collision";
12
+ import { deleteCodexAccount, reconcileMainCodexAccountRuntimeState } from "./account-lifecycle";
13
+ import { clearCodexAccountCooldown } from "./routing";
14
+ import { checkAccountIdCollision, getMainChatgptAccountId, readCodexTokens, readCodexTokensResult } from "./auth-collision";
14
15
  export { checkAccountIdCollision, getMainChatgptAccountId } from "./auth-collision";
15
16
  export { clearAccountNeedsReauth, isAccountNeedsReauth, markAccountNeedsReauth } from "./account-runtime-state";
16
17
  import { clearAccountNeedsReauth, isAccountNeedsReauth, markAccountNeedsReauth } from "./account-runtime-state";
@@ -34,6 +35,13 @@ export {
34
35
  } from "./quota";
35
36
  import { extractAccountId, decodeJwtPayload } from "../oauth/chatgpt";
36
37
  import { MAIN_CODEX_ACCOUNT_ID, setMainAccountPlan } from "./main-account";
38
+ import {
39
+ clearMainAccountInfoCache,
40
+ getMainAccountInfoCache,
41
+ setMainAccountInfoCache,
42
+ type MainAccountInfo,
43
+ } from "./main-account-cache";
44
+ export { clearMainAccountInfoCache } from "./main-account-cache";
37
45
  import { maskEmail } from "../lib/privacy";
38
46
  import { CodexWarmupError, codexWarmupFailureReason, warmCodexAccount } from "./warmup";
39
47
  export { maskEmail } from "../lib/privacy";
@@ -41,6 +49,12 @@ import type { CodexAccount, OcxConfig } from "../types";
41
49
  import { isCanonicalOpenAiForwardProvider, OPENAI_CODEX_PROVIDER_ID } from "../providers/openai-tiers";
42
50
  import { providerCodexAccountMode } from "../providers/registry";
43
51
  import { readBoundedResponseBody } from "../lib/bounded-body";
52
+ import {
53
+ oauthAccountHealthFields,
54
+ projectCodexAccountHealth,
55
+ type OAuthAccountHealth,
56
+ type OAuthHealthLabel,
57
+ } from "../oauth/health";
44
58
 
45
59
  function jsonResponse(data: unknown, status = 200): Response {
46
60
  return new Response(JSON.stringify(data), {
@@ -83,6 +97,8 @@ function poolAccountDto(
83
97
  hasCredential: boolean,
84
98
  ): CodexAuthAccountDto {
85
99
  const quota = quotaForPlan(quotaResult.quota, account.plan);
100
+ const needsReauth = !hasCredential || quotaResult.needsReauth || isAccountNeedsReauth(account.id);
101
+ const health = projectCodexAccountHealth({ accountId: account.id, needsReauth });
86
102
  return {
87
103
  id: account.id,
88
104
  email: maskEmail(account.email) ?? account.email,
@@ -91,8 +107,9 @@ function poolAccountDto(
91
107
  ...(account.logLabel !== undefined ? { logLabel: account.logLabel } : {}),
92
108
  isMain: false,
93
109
  quota: quota ? { ...quota } : null,
94
- needsReauth: !hasCredential || quotaResult.needsReauth || isAccountNeedsReauth(account.id),
110
+ needsReauth,
95
111
  hasCredential,
112
+ ...oauthAccountHealthFields("codex", account.id, health),
96
113
  };
97
114
  }
98
115
 
@@ -187,7 +204,6 @@ function expireCodexAuthFlow(flowId: string | null, error = "Login cancelled"):
187
204
  }
188
205
  }
189
206
 
190
- let mainAccountCache: { email: string | null; plan: string | null; quota: Omit<StoredAccountQuota, "updatedAt"> | null; ts: number } | null = null;
191
207
  const MAIN_CACHE_TTL = 5 * 60_000;
192
208
  const POOL_CACHE_TTL = 5 * 60_000;
193
209
  const POOL_QUOTA_REFRESH_CONCURRENCY = 4;
@@ -201,7 +217,7 @@ function getRuntimeConfig(config: OcxConfig): OcxConfig {
201
217
  }
202
218
 
203
219
  function saveRuntimeConfig(sourceConfig: OcxConfig, nextConfig: OcxConfig): void {
204
- saveConfig(nextConfig);
220
+ saveConfigPreservingClaudeCode(nextConfig);
205
221
  if (sourceConfig === nextConfig || !isRuntimeConfig(sourceConfig)) return;
206
222
  for (const key of Object.keys(sourceConfig) as Array<keyof OcxConfig>) {
207
223
  delete sourceConfig[key];
@@ -253,15 +269,49 @@ async function isTerminalMainAuthResponse(resp: Response): Promise<boolean> {
253
269
  }
254
270
  }
255
271
 
256
- export async function fetchMainAccountInfo(forceRefresh = false): Promise<{ email: string | null; plan: string | null; quota: Omit<StoredAccountQuota, "updatedAt"> | null }> {
257
- const tokens = readCodexTokens();
258
- if (!tokens) {
259
- mainAccountCache = null;
260
- markAccountNeedsReauth(MAIN_CODEX_ACCOUNT_ID);
261
- return { email: null, plan: null, quota: null };
272
+ interface MainAccountInfoFetchResult {
273
+ info: MainAccountInfo;
274
+ /** Present only when this call's WHAM response included `rate_limit_reset_credits.available_count`. */
275
+ freshResetCredits?: number;
276
+ }
277
+
278
+ export async function fetchMainAccountInfo(forceRefresh = false): Promise<MainAccountInfo> {
279
+ const { info } = await fetchMainAccountInfoAttempt(forceRefresh, 1);
280
+ return info;
281
+ }
282
+
283
+ const EMPTY_MAIN_ACCOUNT_INFO: MainAccountInfo = { email: null, plan: null, quota: null };
284
+
285
+ async function retryMainAccountInfoIfIdentityChanged(
286
+ requestAccountId: string | null,
287
+ retriesRemaining: number,
288
+ ): Promise<MainAccountInfoFetchResult | null> {
289
+ const currentAccountId = getMainChatgptAccountId();
290
+ if (currentAccountId === null || currentAccountId === requestAccountId) return null;
291
+ reconcileMainCodexAccountRuntimeState();
292
+ return retriesRemaining > 0
293
+ ? fetchMainAccountInfoAttempt(true, retriesRemaining - 1)
294
+ : { info: EMPTY_MAIN_ACCOUNT_INFO };
295
+ }
296
+
297
+ async function fetchMainAccountInfoAttempt(forceRefresh: boolean, retriesRemaining: number): Promise<MainAccountInfoFetchResult> {
298
+ reconcileMainCodexAccountRuntimeState();
299
+ const tokenRead = readCodexTokensResult();
300
+ if (tokenRead.status !== "ok") {
301
+ // A local read failure is NOT proof of sign-out: a missing file can be a non-atomic rewrite
302
+ // gap, and malformed JSON can be a half-written file. Clearing the cache and marking the
303
+ // account for reauth here destroyed healthy email/plan/quota state and pinned a working
304
+ // account as unusable. Preserve what we already know and let the caller retry; request
305
+ // routing stays fail-closed because getMainAccountToken() re-reads the file itself, and the
306
+ // account DTO still reports hasCredential=false while the file is unreadable.
307
+ const preserved = getMainAccountInfoCache();
308
+ return { info: preserved ?? EMPTY_MAIN_ACCOUNT_INFO };
262
309
  }
263
- if (!forceRefresh && mainAccountCache && Date.now() - mainAccountCache.ts < MAIN_CACHE_TTL) {
264
- return mainAccountCache;
310
+ const tokens = tokenRead.tokens;
311
+ const requestAccountId = extractAccountId(tokens.id_token, tokens.access_token) ?? (tokens.account_id || null);
312
+ const cached = getMainAccountInfoCache();
313
+ if (!forceRefresh && cached && Date.now() - cached.ts < MAIN_CACHE_TTL) {
314
+ return { info: cached };
265
315
  }
266
316
  try {
267
317
  const resp = await fetch("https://chatgpt.com/backend-api/wham/usage", {
@@ -269,20 +319,27 @@ export async function fetchMainAccountInfo(forceRefresh = false): Promise<{ emai
269
319
  signal: AbortSignal.timeout(8000),
270
320
  });
271
321
  if (!resp.ok) {
272
- if (await isTerminalMainAuthResponse(resp)) {
273
- mainAccountCache = null;
322
+ const terminalAuthFailure = await isTerminalMainAuthResponse(resp);
323
+ const retried = await retryMainAccountInfoIfIdentityChanged(requestAccountId, retriesRemaining);
324
+ if (retried) return retried;
325
+ if (terminalAuthFailure) {
326
+ clearMainAccountInfoCache();
274
327
  markAccountNeedsReauth(MAIN_CODEX_ACCOUNT_ID);
275
328
  }
276
- return { email: null, plan: null, quota: null };
329
+ return { info: EMPTY_MAIN_ACCOUNT_INFO };
277
330
  }
278
331
  const data = (await resp.json()) as WhamUsageResponse;
332
+ const retried = await retryMainAccountInfoIfIdentityChanged(requestAccountId, retriesRemaining);
333
+ if (retried) return retried;
334
+ const quota = parseUsageQuota(data);
335
+ const freshResetCredits = quota?.resetCredits;
279
336
  const result = {
280
337
  email: data.email ?? null,
281
338
  plan: data.plan_type ?? null,
282
- quota: parseUsageQuota(data),
339
+ quota,
283
340
  ts: Date.now(),
284
341
  };
285
- mainAccountCache = result;
342
+ setMainAccountInfoCache(result);
286
343
  clearAccountNeedsReauth(MAIN_CODEX_ACCOUNT_ID);
287
344
  // Mirror main quota + plan into the shared stores so the rotation engine can
288
345
  // score and auto-switch the main account exactly like a pool account (Option A).
@@ -290,15 +347,21 @@ export async function fetchMainAccountInfo(forceRefresh = false): Promise<{ emai
290
347
  if (result.quota) {
291
348
  setAccountQuotaFromParsed(MAIN_CODEX_ACCOUNT_ID, result.quota);
292
349
  }
293
- return result;
350
+ return {
351
+ info: result,
352
+ ...(freshResetCredits !== undefined ? { freshResetCredits } : {}),
353
+ };
294
354
  } catch {
295
- return { email: null, plan: null, quota: null };
355
+ const retried = await retryMainAccountInfoIfIdentityChanged(requestAccountId, retriesRemaining);
356
+ return retried ?? { info: EMPTY_MAIN_ACCOUNT_INFO };
296
357
  }
297
358
  }
298
359
 
299
360
  interface PoolQuotaResult {
300
361
  quota: StoredAccountQuota | null;
301
362
  needsReauth: boolean;
363
+ /** Present only when this call's WHAM response included `rate_limit_reset_credits.available_count`. */
364
+ freshResetCredits?: number;
302
365
  }
303
366
 
304
367
  export interface CodexAuthAccountDto {
@@ -311,6 +374,10 @@ export interface CodexAuthAccountDto {
311
374
  quota: (StoredAccountQuota | (Omit<StoredAccountQuota, "updatedAt"> & { updatedAt: number })) | null;
312
375
  needsReauth?: boolean;
313
376
  hasCredential: boolean;
377
+ health: OAuthAccountHealth;
378
+ healthLabel: OAuthHealthLabel;
379
+ healthSummary: string;
380
+ healthAction?: string;
314
381
  }
315
382
 
316
383
  async function fetchPoolAccountQuota(accountId: string, forceRefresh = false, configuredPlan?: string): Promise<PoolQuotaResult> {
@@ -327,9 +394,14 @@ async function fetchPoolAccountQuota(accountId: string, forceRefresh = false, co
327
394
  if (!resp.ok) return { quota: existing ?? null, needsReauth: resp.status === 401 };
328
395
  const data = (await resp.json()) as WhamUsageResponse;
329
396
  const quota = parseUsageQuota({ ...data, plan_type: data.plan_type ?? configuredPlan });
397
+ const freshResetCredits = quota?.resetCredits;
330
398
  if (!quota) return { quota: existing ?? null, needsReauth: false };
331
399
  setAccountQuotaFromParsed(accountId, quota);
332
- return { quota: getAccountQuota(accountId), needsReauth: false };
400
+ return {
401
+ quota: getAccountQuota(accountId),
402
+ needsReauth: false,
403
+ ...(freshResetCredits !== undefined ? { freshResetCredits } : {}),
404
+ };
333
405
  } catch (e) {
334
406
  if (e instanceof CodexCredentialGenerationConflictError || e instanceof CodexCredentialRefreshLockTimeoutError) return { quota: existing ?? null, needsReauth: false };
335
407
  if (e instanceof TokenRefreshError) return { quota: existing ?? null, needsReauth: true };
@@ -363,6 +435,10 @@ export async function primeCodexPoolQuotas(config: OcxConfig, reason: string): P
363
435
  || providerCodexAccountMode(OPENAI_CODEX_PROVIDER_ID, openai) !== "pool"
364
436
  ) return;
365
437
  if (primeInFlight) return primeInFlight;
438
+ // Seed the observed physical main identity before startup/lazy priming can populate quota or
439
+ // plan state. Otherwise the first post-startup account switch sees no previous identity and
440
+ // skips the purge that protects the stable __main__ alias.
441
+ reconcileMainCodexAccountRuntimeState();
366
442
  primeInFlight = (async () => {
367
443
  const runtimeConfig = getRuntimeConfig(config);
368
444
  const pool = (runtimeConfig.codexAccounts ?? []).filter(a => !a.isMain);
@@ -407,14 +483,20 @@ export async function listCodexAuthAccounts(config: OcxConfig, forceRefresh = fa
407
483
  return poolAccountDto(a, quotaResult, !!cred);
408
484
  });
409
485
  const hasMainCredential = readCodexTokens() !== null;
486
+ const mainNeedsReauth = !hasMainCredential || isAccountNeedsReauth(MAIN_CODEX_ACCOUNT_ID);
487
+ const mainHealth = projectCodexAccountHealth({
488
+ accountId: MAIN_CODEX_ACCOUNT_ID,
489
+ needsReauth: mainNeedsReauth,
490
+ });
410
491
  const main: CodexAuthAccountDto = {
411
492
  id: MAIN_CODEX_ACCOUNT_ID,
412
493
  email: maskEmail(mainInfo.email) ?? "Codex App login",
413
494
  plan: mainInfo.plan,
414
495
  isMain: true,
415
496
  hasCredential: hasMainCredential,
416
- needsReauth: !hasMainCredential || isAccountNeedsReauth(MAIN_CODEX_ACCOUNT_ID),
497
+ needsReauth: mainNeedsReauth,
417
498
  quota: mainInfo.quota ? { ...quotaForPlan({ ...mainInfo.quota, updatedAt: Date.now() }, mainInfo.plan) } : null,
499
+ ...oauthAccountHealthFields("codex", MAIN_CODEX_ACCOUNT_ID, mainHealth),
418
500
  };
419
501
  return [main, ...withQuota];
420
502
  }
@@ -501,6 +583,21 @@ export async function handleCodexAuthAPI(
501
583
  return jsonResponse({ ok: true, id, alias: alias || null });
502
584
  }
503
585
 
586
+ // Manual escape from a quota cooldown. Injected Codex routing makes this proxy the only
587
+ // model path for Codex Desktop, so a cooldown that outlives the real upstream limit
588
+ // otherwise leaves editing config.toml as the user's only recovery.
589
+ //
590
+ // Existence is deliberately NOT disclosed: an unknown id returns 200 with cleared:false
591
+ // exactly like an account that simply had no live cooldown, so this route cannot be used
592
+ // to enumerate configured accounts. Cooldown state is runtime-only and independent of the
593
+ // account list, so 404 would carry no useful meaning anyway.
594
+ if (url.pathname === "/api/codex-auth/accounts/clear-cooldown" && req.method === "POST") {
595
+ const body = await req.json().catch(() => ({})) as { id?: unknown };
596
+ const id = typeof body.id === "string" ? body.id.trim() : "";
597
+ if (!id || !ACCOUNT_ID_RE.test(id)) return jsonResponse({ error: "Invalid account id format" }, 400);
598
+ return jsonResponse({ ok: true, id, cleared: clearCodexAccountCooldown(id) });
599
+ }
600
+
504
601
  if (url.pathname === "/api/codex-auth/active" && req.method === "PUT") {
505
602
  let body: { accountId: string | null };
506
603
  try { body = (await req.json()) as typeof body; } catch { return jsonResponse({ error: "Invalid JSON" }, 400); }
@@ -608,13 +705,23 @@ export async function handleCodexAuthAPI(
608
705
  return jsonResponse({ error: `Upstream error ${resp.status}` }, resp.status);
609
706
  }
610
707
  const result = safeResetCreditConsumeDto(await resp.json());
611
- if (result.code === "reset") {
708
+ // After a successful redeem (or an idempotent already_redeemed), refresh WHAM usage
709
+ // and return remaining only when that refresh freshly parsed available_count.
710
+ // Do not fall back to a preserved cached resetCredits (failed/omitted refresh).
711
+ if (result.code === "reset" || result.code === "already_redeemed") {
712
+ let freshResetCredits: number | undefined;
612
713
  if (auth.isMain) {
613
- await fetchMainAccountInfo(true);
714
+ ({ freshResetCredits } = await fetchMainAccountInfoAttempt(true, 1));
614
715
  } else {
615
716
  const account = configuredPoolAccount(getRuntimeConfig(config), body.accountId);
616
- await fetchPoolAccountQuota(body.accountId, true, account?.plan);
717
+ ({ freshResetCredits } = await fetchPoolAccountQuota(body.accountId, true, account?.plan));
617
718
  }
719
+ return jsonResponse({
720
+ code: result.code,
721
+ ...(typeof freshResetCredits === "number" && Number.isFinite(freshResetCredits)
722
+ ? { remaining: freshResetCredits }
723
+ : {}),
724
+ });
618
725
  }
619
726
  return jsonResponse(result);
620
727
  } catch (e) {
@@ -1,25 +1,69 @@
1
- import { existsSync, readFileSync } from "node:fs";
1
+ import { readFileSync } from "node:fs";
2
2
  import { join } from "node:path";
3
3
  import { getCodexAccountCredential } from "./account-store";
4
4
  import { loadConfig } from "../config";
5
5
  import { resolveCodexHomeDir } from "./home";
6
6
  import { extractAccountId } from "../oauth/chatgpt";
7
7
 
8
- export function readCodexTokens(): { access_token: string; account_id: string; id_token?: string } | null {
8
+ export interface CodexTokens {
9
+ access_token: string;
10
+ account_id: string;
11
+ id_token?: string;
12
+ }
13
+
14
+ /**
15
+ * Why a read *outcome* and not just `Tokens | null`: callers need to tell a real sign-out apart
16
+ * from a transient read failure. A single null collapsed "file absent", "malformed JSON", and
17
+ * "read error" into one answer, so a half-written `auth.json` looked exactly like a logout and
18
+ * callers destroyed healthy cached account state because of it.
19
+ */
20
+ export type CodexTokenReadResult =
21
+ | { status: "ok"; tokens: CodexTokens }
22
+ | { status: "missing" | "invalid" | "unreadable" };
23
+
24
+ function hasErrnoCode(error: unknown, code: string): boolean {
25
+ return typeof error === "object" && error !== null && "code" in error
26
+ && (error as { code?: unknown }).code === code;
27
+ }
28
+
29
+ /**
30
+ * Reads the Codex CLI credential file and classifies the outcome. Reads once instead of doing an
31
+ * `existsSync` pre-check, so a file replaced between check and read cannot be misread as absent.
32
+ * Never returns or logs the raw error or any token material.
33
+ */
34
+ export function readCodexTokensResult(): CodexTokenReadResult {
35
+ let raw: string;
9
36
  try {
10
- const codexHome = resolveCodexHomeDir();
11
- const authPath = join(codexHome, "auth.json");
12
- if (!existsSync(authPath)) return null;
13
- const j = JSON.parse(readFileSync(authPath, "utf-8")) as {
37
+ raw = readFileSync(join(resolveCodexHomeDir(), "auth.json"), "utf-8");
38
+ } catch (error) {
39
+ return { status: hasErrnoCode(error, "ENOENT") ? "missing" : "unreadable" };
40
+ }
41
+ try {
42
+ const j = JSON.parse(raw) as {
14
43
  tokens?: { access_token?: string; account_id?: string; id_token?: string };
15
44
  };
16
- if (!j?.tokens?.access_token) return null;
45
+ if (!j?.tokens?.access_token) return { status: "invalid" };
17
46
  return {
18
- access_token: j.tokens.access_token,
19
- account_id: j.tokens.account_id ?? "",
20
- id_token: j.tokens.id_token,
47
+ status: "ok",
48
+ tokens: {
49
+ access_token: j.tokens.access_token,
50
+ account_id: j.tokens.account_id ?? "",
51
+ id_token: j.tokens.id_token,
52
+ },
21
53
  };
22
- } catch { return null; }
54
+ } catch {
55
+ return { status: "invalid" };
56
+ }
57
+ }
58
+
59
+ /**
60
+ * Compatibility wrapper: any usable-token check keeps the original null contract, which keeps
61
+ * request routing fail-closed. Only callers that must distinguish the failure reason should use
62
+ * `readCodexTokensResult()`.
63
+ */
64
+ export function readCodexTokens(): CodexTokens | null {
65
+ const result = readCodexTokensResult();
66
+ return result.status === "ok" ? result.tokens : null;
23
67
  }
24
68
 
25
69
  export function getMainChatgptAccountId(): string | null {
@@ -6,14 +6,18 @@ import {
6
6
  } from "./account-store";
7
7
  import { markAccountNeedsReauth } from "./account-runtime-state";
8
8
  import { isCodexAccountUsable } from "./account-usability";
9
+ import { reconcileMainCodexAccountRuntimeState } from "./account-lifecycle";
9
10
  import { MAIN_CODEX_ACCOUNT_ID, getMainAccountToken } from "./main-account";
10
11
  import {
11
- getCodexAccountCooldownUntil,
12
+ getCodexAccountHealthSnapshot,
12
13
  releaseCodexQuotaProbeLease,
13
14
  tryAcquireCodexQuotaProbeLease,
14
15
  pickLowestUsageCodexAccount,
15
16
  resolveCodexAccountForThreadDetailed,
16
17
  } from "./routing";
18
+ import type { CodexCooldownSource } from "./routing";
19
+ import { maskAccountId } from "../lib/privacy";
20
+ import { formatErrorResponse } from "../bridge";
17
21
  import { getAccountQuota } from "./quota";
18
22
  import type { CodexAccountMode, OcxConfig, OcxProviderConfig } from "../types";
19
23
  import { FORWARD_HEADERS } from "../adapters/openai-responses";
@@ -94,15 +98,49 @@ export function hasCallerCodexBearer(headers: Headers): boolean {
94
98
  export class CodexAccountCooldownError extends Error {
95
99
  accountId: string;
96
100
  cooldownUntil: number;
101
+ cooldownSource?: CodexCooldownSource;
97
102
 
98
- constructor(accountId: string, cooldownUntil: number) {
103
+ constructor(accountId: string, cooldownUntil: number, cooldownSource?: CodexCooldownSource) {
99
104
  super("Selected Codex account is cooling down");
100
105
  this.name = "CodexAccountCooldownError";
101
106
  this.accountId = accountId;
102
107
  this.cooldownUntil = cooldownUntil;
108
+ this.cooldownSource = cooldownSource;
103
109
  }
104
110
  }
105
111
 
112
+ /**
113
+ * Human-readable account label for a client-visible error. NEVER the raw id: the proxy
114
+ * supports non-loopback binds (auth-cors.ts `isApiAuthRequired` requires a token there
115
+ * rather than refusing), so data-plane bodies can reach remote authenticated clients.
116
+ * The main login has no secret id, so it renders as the literal alias users type.
117
+ */
118
+ export function cooldownAccountLabel(accountId: string): string {
119
+ return accountId === MAIN_CODEX_ACCOUNT_ID ? "main" : maskAccountId(accountId) ?? "account-…????";
120
+ }
121
+
122
+ /**
123
+ * Actionable message for a cooled-down account: until when, why, and how to escape.
124
+ * Shared by every transport so the WebSocket surface (Codex Desktop) says the same thing
125
+ * as HTTP. The bare "cooling down" string left users with no route but commenting out the
126
+ * injected `openai_base_url` in config.toml.
127
+ */
128
+ export function cooldownErrorMessage(err: CodexAccountCooldownError): string {
129
+ const until = new Date(err.cooldownUntil).toISOString();
130
+ return `Selected Codex account (${cooldownAccountLabel(err.accountId)}) is cooling down until ${until}`
131
+ + ` (source: ${err.cooldownSource ?? "default"}).`
132
+ + ` Run 'ocx account list openai' to find the id, then`
133
+ + ` 'ocx account clear-cooldown openai <id>' to lift it, or switch accounts with 'ocx account use openai <id>'.`;
134
+ }
135
+
136
+ /** HTTP form of {@link cooldownErrorMessage}, carrying Retry-After for well-behaved clients. */
137
+ export function cooldownErrorResponse(err: CodexAccountCooldownError, now = Date.now()): Response {
138
+ const res = formatErrorResponse(429, "rate_limit_error", cooldownErrorMessage(err));
139
+ const headers = new Headers(res.headers);
140
+ headers.set("Retry-After", String(Math.max(1, Math.ceil((err.cooldownUntil - now) / 1000))));
141
+ return new Response(res.body, { status: res.status, headers });
142
+ }
143
+
106
144
  export class CodexThreadAffinityExpiredError extends Error {
107
145
  accountId: string;
108
146
 
@@ -131,6 +169,7 @@ export async function resolveCodexAuthContext(
131
169
  if (!hasCallerCodexBearer(headers)) throw new CodexDirectAuthenticationError();
132
170
  return { kind: "main", accountId: null };
133
171
  }
172
+ reconcileMainCodexAccountRuntimeState();
134
173
  const threadId = headers.get("x-codex-parent-thread-id");
135
174
  const resolution = options.excludeAccountId
136
175
  ? (() => {
@@ -153,14 +192,17 @@ export async function resolveCodexAuthContext(
153
192
  .then(({ primeCodexPoolQuotas }) => primeCodexPoolQuotas(config, "pre-route"))
154
193
  .catch(() => {});
155
194
  }
156
- const cooldownUntil = getCodexAccountCooldownUntil(accountId);
195
+ // Snapshot (not just the deadline) so a refused request can report WHY it is cooled:
196
+ // a literal Retry-After reads very differently to a user than a reset-derived guess.
197
+ const cooldown = getCodexAccountHealthSnapshot(accountId);
198
+ const cooldownUntil = cooldown?.cooldownUntil;
157
199
  // A cooled-down account never sends traffic, so upstream recovery can never be
158
200
  // observed and the cooldown outlives the real limit. Admit one probe per
159
201
  // interval; its outcome decides whether the cooldown ends (#433).
160
202
  let probeLeaseId: string | undefined;
161
203
  if (cooldownUntil) {
162
204
  probeLeaseId = tryAcquireCodexQuotaProbeLease(accountId) ?? undefined;
163
- if (!probeLeaseId) throw new CodexAccountCooldownError(accountId, cooldownUntil);
205
+ if (!probeLeaseId) throw new CodexAccountCooldownError(accountId, cooldownUntil, cooldown?.cooldownSource);
164
206
  }
165
207
 
166
208
  if (accountId === MAIN_CODEX_ACCOUNT_ID) {
@@ -203,8 +245,10 @@ export function assertCodexAuthContextNotCooled(ctx: CodexAuthContext | undefine
203
245
  if (ctx?.kind !== "pool" && ctx?.kind !== "main-pool") return;
204
246
  // A context holding the probe lease was deliberately admitted through the cooldown.
205
247
  if (ctx.probeLeaseId) return;
206
- const cooldownUntil = getCodexAccountCooldownUntil(ctx.accountId);
207
- if (cooldownUntil) throw new CodexAccountCooldownError(ctx.accountId, cooldownUntil);
248
+ const cooldown = getCodexAccountHealthSnapshot(ctx.accountId);
249
+ if (cooldown?.cooldownUntil) {
250
+ throw new CodexAccountCooldownError(ctx.accountId, cooldown.cooldownUntil, cooldown.cooldownSource);
251
+ }
208
252
  }
209
253
 
210
254
  export function applyCodexAuthContextToProvider(
@@ -267,7 +267,8 @@ export async function fetchProviderModels(name: string, prov: OcxProviderConfig,
267
267
  if (liveResult.ok) {
268
268
  const available = filterCursorConfiguredModelsByLiveDiscovery(configured, liveResult.models);
269
269
  const result = available.length > 0 ? available : configured;
270
- markProviderDiscoveryOk(name);
270
+ // Count what discovery actually returned, not the configured rows we fall back to.
271
+ markProviderDiscoveryOk(name, liveResult.models.length);
271
272
  setCached(name, result);
272
273
  return result;
273
274
  }
@@ -384,6 +385,9 @@ export async function fetchProviderModels(name: string, prov: OcxProviderConfig,
384
385
  ...catalogHintsFromModelsApiItem(name, m),
385
386
  }, contextCap))
386
387
  .filter(m => shouldExposeProviderModel(name, m.id));
388
+ // Capture the count BEFORE the alias/configured augmentation below pushes extra rows into
389
+ // `live`; otherwise configured entries would be reported as discovered ones.
390
+ const liveModelCount = live.length;
387
391
  const liveIds = new Set(live.map(m => m.id));
388
392
  // Dated-release aliases (Anthropic pattern): older models may appear in the live catalog
389
393
  // ONLY under their dated id (claude-haiku-4-5-20251001) while the config names the
@@ -412,7 +416,7 @@ export async function fetchProviderModels(name: string, prov: OcxProviderConfig,
412
416
  && !QUIET_AUTHORITATIVE_CATALOG_PROVIDERS.has(name)) {
413
417
  warnDroppedConfiguredIdsOnce(name, droppedConfiguredIds);
414
418
  }
415
- markProviderDiscoveryOk(name);
419
+ markProviderDiscoveryOk(name, liveModelCount);
416
420
  setCached(name, live);
417
421
  return live;
418
422
  } catch (error) {