@bitkyc08/opencodex 2.49.0 → 2.51.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (141) hide show
  1. package/AGENTS_INSTALL.md +9 -1
  2. package/README.md +3 -0
  3. package/bin/ocx.mjs +222 -71
  4. package/gui/dist/assets/index-D7BdZpZm.js +115 -0
  5. package/gui/dist/index.html +1 -1
  6. package/package.json +1 -1
  7. package/src/adapters/qoder/adapter.ts +69 -1
  8. package/src/adapters/qoder/scaffold-guard.ts +233 -0
  9. package/src/claude/agents-inject.ts +29 -5
  10. package/src/claude/desktop-3p.ts +31 -3
  11. package/src/claude/gateway-cache.ts +12 -21
  12. package/src/claude/inbound.ts +17 -5
  13. package/src/cli/account-api.ts +18 -3
  14. package/src/cli/account-auth.ts +8 -1
  15. package/src/cli/account-extended.ts +2 -1
  16. package/src/cli/account.ts +1 -0
  17. package/src/cli/capabilities.ts +43 -1
  18. package/src/cli/claude-agent-startup-sync.ts +26 -1
  19. package/src/cli/claude.ts +138 -20
  20. package/src/cli/config-command.ts +67 -1
  21. package/src/cli/connect.ts +181 -14
  22. package/src/cli/dispatch.ts +53 -9
  23. package/src/cli/doctor.ts +9 -2
  24. package/src/cli/ensure-desired-integrations.ts +10 -0
  25. package/src/cli/gui-pair-client.ts +1 -12
  26. package/src/cli/help.ts +4 -1
  27. package/src/cli/hub.ts +367 -0
  28. package/src/cli/index.ts +99 -31
  29. package/src/cli/launcher-context.ts +1 -1
  30. package/src/cli/models-runtime.ts +8 -3
  31. package/src/cli/observe.ts +13 -3
  32. package/src/cli/registry.ts +43 -3
  33. package/src/cli/status.ts +325 -5
  34. package/src/cli/version-skew.ts +4 -1
  35. package/src/cli.ts +2 -2
  36. package/src/client/catalog-compatibility.ts +192 -0
  37. package/src/client/connect.ts +31 -0
  38. package/src/client/hub-client.ts +52 -0
  39. package/src/client/hub-state.ts +214 -0
  40. package/src/clients/config-export/zcode.ts +24 -0
  41. package/src/codex/account-runtime-state.ts +6 -1
  42. package/src/codex/account-store.ts +72 -9
  43. package/src/codex/account-usability.ts +50 -13
  44. package/src/codex/auth-api.ts +156 -28
  45. package/src/codex/auth-context.ts +21 -0
  46. package/src/codex/catalog/effort.ts +67 -8
  47. package/src/codex/catalog/parsing.ts +23 -0
  48. package/src/codex/catalog/provider-fetch.ts +71 -2
  49. package/src/codex/catalog/sync.ts +99 -0
  50. package/src/codex/codex-write-lock.ts +11 -2
  51. package/src/codex/desired-state.ts +47 -1
  52. package/src/codex/inject-coordination.ts +10 -5
  53. package/src/codex/inject.ts +29 -12
  54. package/src/codex/loopback-target.ts +45 -0
  55. package/src/codex/quota-auto-refresh.ts +6 -1
  56. package/src/codex/quota.ts +54 -8
  57. package/src/codex/routing.ts +48 -1
  58. package/src/codex/runtime.ts +37 -3
  59. package/src/codex/sync.ts +29 -9
  60. package/src/codex/warmup.ts +21 -4
  61. package/src/combos/index.ts +2 -0
  62. package/src/combos/resolve.ts +52 -0
  63. package/src/config/pending-teardown.ts +1 -1
  64. package/src/config.ts +184 -12
  65. package/src/generated/compatibility-version.json +188 -116
  66. package/src/grok/status.ts +9 -1
  67. package/src/integrations/config-io.ts +54 -1
  68. package/src/lib/bun-runtime.ts +1 -1
  69. package/src/lib/errors.ts +8 -0
  70. package/src/lib/gui-pair-capability.ts +27 -0
  71. package/src/lib/local-destinations.ts +162 -0
  72. package/src/lib/package-tree-integrity.ts +1 -1
  73. package/src/lib/privacy.ts +25 -0
  74. package/src/lib/process-control.ts +130 -20
  75. package/src/lib/service-secrets.ts +28 -0
  76. package/src/lib/test-home-guard.ts +49 -0
  77. package/src/oauth/health.ts +47 -12
  78. package/src/oauth/index.ts +46 -8
  79. package/src/oauth/token-guardian.ts +32 -6
  80. package/src/providers/google-ai-studio-model-discovery.ts +74 -0
  81. package/src/providers/opencode-go-transport.ts +9 -1
  82. package/src/providers/opencode-zen-rate-limit.ts +75 -0
  83. package/src/providers/quota.ts +20 -1
  84. package/src/providers/registry.ts +35 -6
  85. package/src/remote/hub-state.ts +182 -0
  86. package/src/server/auth-cors.ts +11 -0
  87. package/src/server/chat-completions.ts +10 -7
  88. package/src/server/chat-native.ts +10 -1
  89. package/src/server/claude-messages.ts +12 -6
  90. package/src/server/hub-state.ts +98 -0
  91. package/src/server/images.ts +2 -2
  92. package/src/server/index.ts +149 -8
  93. package/src/server/management/api-access.ts +14 -3
  94. package/src/server/management/config-routes.ts +2 -2
  95. package/src/server/management/cursor-integration-routes.ts +13 -4
  96. package/src/server/management/logs-usage-routes.ts +4 -1
  97. package/src/server/management/model-rows.ts +16 -1
  98. package/src/server/management/oauth-account-routes.ts +6 -2
  99. package/src/server/management/provider-routes.ts +9 -2
  100. package/src/server/management/request-history-routes.ts +4 -2
  101. package/src/server/management/route-registry.ts +5 -4
  102. package/src/server/management/shared.ts +66 -3
  103. package/src/server/management-api.ts +1 -1
  104. package/src/server/proxy-liveness.ts +7 -1
  105. package/src/server/request-decompress.ts +91 -3
  106. package/src/server/request-log-conversation.ts +41 -1
  107. package/src/server/request-log.ts +10 -0
  108. package/src/server/responses/codex-auth-error.ts +18 -1
  109. package/src/server/responses/codex-ws-exchange.ts +36 -4
  110. package/src/server/responses/codex-ws-wire.ts +76 -5
  111. package/src/server/responses/compact.ts +28 -11
  112. package/src/server/responses/context-overflow.ts +11 -0
  113. package/src/server/responses/core.ts +201 -48
  114. package/src/server/responses/policy-fallback.ts +13 -3
  115. package/src/server/search.ts +2 -2
  116. package/src/server/system-env-shell.ts +14 -2
  117. package/src/server/system-env.ts +106 -14
  118. package/src/service.ts +965 -68
  119. package/src/types/accounts.ts +18 -0
  120. package/src/types/config.ts +93 -4
  121. package/src/types/provider.ts +56 -0
  122. package/src/types.ts +4 -0
  123. package/src/update/badge.ts +3 -2
  124. package/src/update/index.ts +317 -64
  125. package/src/update/install-detection.d.mts +6 -0
  126. package/src/update/install-detection.mjs +73 -0
  127. package/src/update/job.ts +101 -49
  128. package/src/update/pnpm-global-install.d.mts +144 -0
  129. package/src/update/pnpm-global-install.mjs +591 -0
  130. package/src/update/pnpm-invocation.d.mts +43 -0
  131. package/src/update/pnpm-invocation.mjs +141 -0
  132. package/src/update/registry-integrity.d.mts +16 -0
  133. package/src/update/registry-integrity.mjs +37 -0
  134. package/src/update/transactional-install.d.mts +1 -1
  135. package/src/update/transactional-install.mjs +101 -7
  136. package/src/update/tray-update-plan.mjs +1 -1
  137. package/src/vision/plan.ts +13 -3
  138. package/src/vision/routed-describe.ts +51 -20
  139. package/src/web-search/ollama-executor.ts +127 -0
  140. package/src/web-search/passthrough-bridge.ts +761 -0
  141. package/gui/dist/assets/index-BtyONQrZ.js +0 -115
@@ -61,6 +61,16 @@ function canonicalize(path: string): string {
61
61
  const REAL_HOME = process.env[REAL_HOME_ENV]?.trim() || homedir();
62
62
  const PROTECTED_HOME = canonicalize(join(REAL_HOME, ".opencodex"));
63
63
  const PROTECTED_CODEX_HOME = canonicalize(join(REAL_HOME, ".codex"));
64
+ /**
65
+ * `~/Library/LaunchAgents` needs its own entry because HOME isolation does not reach it:
66
+ * `os.homedir()` reads the password database, not `$HOME`, so a macOS test that rewrites
67
+ * HOME still resolves `plistPath()` to the developer's real LaunchAgents directory. The
68
+ * launchd install tests were doing exactly that — replacing the live
69
+ * `com.opencodex.proxy.plist` with one whose token file, log path and Bun paths all point
70
+ * into a temp sandbox, for as long as the case ran. launchd holds its own parsed copy, so
71
+ * nothing broke until the job next restarted.
72
+ */
73
+ const PROTECTED_LAUNCH_AGENTS = canonicalize(join(REAL_HOME, "Library", "LaunchAgents"));
64
74
 
65
75
  /** The production home this process protects. Exported for the guard's own tests. */
66
76
  export function protectedHomeForTests(): string {
@@ -76,6 +86,23 @@ export function isTestHomeGuardArmed(): boolean {
76
86
  return process.env[GUARD_ENV] === "1";
77
87
  }
78
88
 
89
+ /**
90
+ * Whether `dir` IS the protected production home, decided with the SAME canonicalization as
91
+ * {@link assertNotRealHomeUnderTest}.
92
+ *
93
+ * For the caller that must FILTER the real home out of a candidate list instead of refusing
94
+ * one write: `serviceStatePaths()` in `src/service.ts` keeps a legacy
95
+ * `~/.opencodex/service-state.json` entry so an install made before OPENCODEX_HOME existed
96
+ * can still be found, and under an armed test process that entry is the developer's live
97
+ * record. Exported so that filter cannot drift onto a weaker comparison — `resolve()` alone
98
+ * calls `/var/folders/...` and `/private/var/folders/...` different paths, which is exactly
99
+ * how a macOS sandbox path slips past a string compare.
100
+ */
101
+ export function isProtectedHomeUnderTest(dir: string): boolean {
102
+ if (!isTestHomeGuardArmed()) return false;
103
+ return canonicalize(dir) === PROTECTED_HOME;
104
+ }
105
+
79
106
  /**
80
107
  * Throw when an armed test process is about to write the real OpenCodex home.
81
108
  *
@@ -94,6 +121,28 @@ export function assertNotRealHomeUnderTest(dir: string): void {
94
121
  );
95
122
  }
96
123
 
124
+ /** The production LaunchAgents directory this process protects. Exported for its tests. */
125
+ export function protectedLaunchAgentsDirForTests(): string {
126
+ return PROTECTED_LAUNCH_AGENTS;
127
+ }
128
+
129
+ /**
130
+ * Throw when an armed test process is about to write the real `~/Library/LaunchAgents`.
131
+ *
132
+ * Same contract as {@link assertNotRealHomeUnderTest}: call before any mkdir/write, and
133
+ * pass a DIRECTORY. A launchd test gives `installLaunchd` an explicit plist path inside its
134
+ * own fixture directory instead.
135
+ */
136
+ export function assertNotRealLaunchAgentsUnderTest(dir: string): void {
137
+ if (!isTestHomeGuardArmed()) return;
138
+ if (canonicalize(dir) !== PROTECTED_LAUNCH_AGENTS) return;
139
+ throw new Error(
140
+ `refusing to write the real LaunchAgents directory (${PROTECTED_LAUNCH_AGENTS}) from a test `
141
+ + "process: os.homedir() ignores HOME, so rewriting HOME does not move this path. Pass an "
142
+ + "explicit plist path inside the test's own fixture directory instead.",
143
+ );
144
+ }
145
+
97
146
  /** Throw when an armed test process is about to write the real native Codex home. */
98
147
  export function assertNotRealCodexHomeUnderTest(dir: string): void {
99
148
  if (!isTestHomeGuardArmed()) return;
@@ -1,7 +1,7 @@
1
1
  import { getCodexAccountHealthSnapshot, type CodexCooldownSource } from "../codex/routing";
2
2
  import { getAnthropicAccountHealthSnapshot } from "./anthropic-routing";
3
3
  import { isAccountNeedsReauth } from "../codex/account-runtime-state";
4
- import { getCodexAccountCredential, listCodexAccountIds } from "../codex/account-store";
4
+ import { getCodexAccountCredential, listCodexAccountIds, readCodexAccountRecord } from "../codex/account-store";
5
5
  import { MAIN_CODEX_ACCOUNT_ID } from "../codex/main-account";
6
6
  import { readRuntimePort } from "../config/process-state";
7
7
  import { LOCAL_MANAGEMENT_READ_PATHS } from "../lib/local-management-capability";
@@ -15,7 +15,7 @@ export type OAuthAccountHealth =
15
15
  | { status: "healthy" }
16
16
  | { status: "cooldown"; until: string; reason: "rate_limit" | "quota" }
17
17
  | { status: "reauth_required"; reason: "unauthorized" | "forbidden" | "refresh_failed" }
18
- | { status: "warning"; reason: "refresh_conflict" | "metadata_mismatch" | "stale_credentials" };
18
+ | { status: "warning"; reason: "refresh_conflict" | "metadata_mismatch" | "stale_credentials" | "validation_pending" };
19
19
 
20
20
  export type OAuthHealthLabel =
21
21
  | "Healthy"
@@ -24,7 +24,8 @@ export type OAuthHealthLabel =
24
24
  | "Reauthentication required"
25
25
  | "Refresh failed"
26
26
  | "Metadata mismatch"
27
- | "Credential conflict";
27
+ | "Credential conflict"
28
+ | "Validation pending";
28
29
 
29
30
  /** Shared masked-id fallback when `maskAccountId` returns nullish. */
30
31
  export const MASKED_ACCOUNT_FALLBACK = "account-…????";
@@ -88,6 +89,9 @@ export function projectOAuthAccountHealth(input: {
88
89
  export const CODEX_REAUTH_ACTION = "reauthenticate via the dashboard Codex account pool";
89
90
 
90
91
  function actionFor(provider: string, health: OAuthAccountHealth): string | undefined {
92
+ if (health.status === "warning" && health.reason === "validation_pending") {
93
+ return "wait for quota recovery, then click Refresh quotas in the dashboard Codex account pool to finish validation";
94
+ }
91
95
  if (health.status === "reauth_required") {
92
96
  if (provider === "codex") return CODEX_REAUTH_ACTION;
93
97
  return `run \`ocx login ${provider}\``;
@@ -112,6 +116,8 @@ export function oauthHealthLabel(health: OAuthAccountHealth): OAuthHealthLabel {
112
116
  return health.reason === "refresh_failed" ? "Refresh failed" : "Reauthentication required";
113
117
  case "warning":
114
118
  switch (health.reason) {
119
+ case "validation_pending":
120
+ return "Validation pending";
115
121
  case "refresh_conflict":
116
122
  return "Credential conflict";
117
123
  case "metadata_mismatch":
@@ -198,11 +204,42 @@ export function projectCodexAccountHealth(input: {
198
204
  needsReauth: boolean;
199
205
  now?: number;
200
206
  }): OAuthAccountHealth {
207
+ // One read serves every verdict below. Each lookup re-reads and re-hardens the whole store
208
+ // file, and the main account lives in the native Codex auth file rather than the pool store,
209
+ // so a lookup for it could only ever miss.
210
+ const stored = input.accountId !== MAIN_CODEX_ACCOUNT_ID ? readCodexAccountRecord(input.accountId) : null;
211
+ const record = stored?.deletedAt == null ? stored : null;
212
+
213
+ // A successful quota read is not evidence that model authorization recovered.
214
+ // Preserve this guidance until validation succeeds or reauthentication replaces it.
215
+ const validationAuthFailed = record !== null
216
+ && record.codexValidationPending === true
217
+ && record.lastCodexValidationStatus === "failed"
218
+ && (record.lastCodexValidationError === "http_status:401" || record.lastCodexValidationError === "http_status:403");
219
+
220
+ // A persisted terminal verdict outranks the in-memory reauth flag rather than duplicating it:
221
+ // the flag lives in this process and a revoked grant does not. Without it, an account whose
222
+ // grant was revoked upstream keeps its login-time `lastCodexValidationStatus: "ok"` and every
223
+ // surface reports it healthy until someone tries to use it (#4120). Only a re-login clears the
224
+ // marker, so `reauth_required` is the accurate projection — and it is deliberately checked
225
+ // ahead of any cooldown, because telling an operator to wait out a rate limit on a credential
226
+ // that will never work again is a false promise.
227
+ const terminalGrantFailure = record !== null
228
+ && record.lastCodexValidationTerminal === true
229
+ && record.lastCodexValidationStatus === "failed";
230
+
231
+ const needsReauth = input.needsReauth || validationAuthFailed || terminalGrantFailure;
232
+
233
+ // Deferred validation is only worth reporting while the credential itself is still viable. A
234
+ // revoked grant needs a re-login, not a "Refresh quotas" click, so reauth is resolved first.
235
+ if (!needsReauth && record?.codexValidationPending) {
236
+ return { status: "warning", reason: "validation_pending" };
237
+ }
201
238
  const now = input.now ?? Date.now();
202
239
  const snap = getCodexAccountHealthSnapshot(input.accountId, now);
203
240
  return projectOAuthAccountHealth({
204
- needsReauth: input.needsReauth,
205
- reauthReason: input.needsReauth ? "refresh_failed" : undefined,
241
+ needsReauth,
242
+ reauthReason: needsReauth ? "refresh_failed" : undefined,
206
243
  cooldownUntilMs: snap?.cooldownUntil,
207
244
  cooldownReason: cooldownReasonFromSource(snap?.cooldownSource),
208
245
  now,
@@ -272,13 +309,11 @@ function collectLocalCodexEntries(now: number): OAuthHealthEntry[] {
272
309
  const hasPoolCredential = accountId !== MAIN_CODEX_ACCOUNT_ID && getCodexAccountCredential(accountId) !== null;
273
310
  if (!hasPoolCredential && !needsReauth && !snap) continue;
274
311
 
275
- const health = projectOAuthAccountHealth({
276
- needsReauth,
277
- reauthReason: needsReauth ? "refresh_failed" : undefined,
278
- cooldownUntilMs: snap?.cooldownUntil,
279
- cooldownReason: cooldownReasonFromSource(snap?.cooldownSource),
280
- now,
281
- });
312
+ // Call the projector rather than inlining a second copy of it. This collector serves the CLI
313
+ // (`ocx status`, `ocx doctor`) while the dashboard DTO goes through projectCodexAccountHealth,
314
+ // and the duplicated body is exactly how the CLI would have kept reporting a revoked account
315
+ // as healthy after the dashboard stopped.
316
+ const health = projectCodexAccountHealth({ accountId, needsReauth, now });
282
317
  pushEntry(entries, "codex", accountId, health);
283
318
  }
284
319
  return entries;
@@ -4,7 +4,7 @@ import { parseCallbackInput } from "./callback-server";
4
4
  import type { OcxConfig, OcxProviderConfig, RefreshPolicy } from "../types";
5
5
  import { ConfigMutationLockError, loadConfig, mutatePersistedConfig, saveConfig } from "../config";
6
6
  import { resolveProviderApiKey } from "../providers/key-store";
7
- import { maskEmail } from "../lib/privacy";
7
+ import { projectEmail } from "../lib/privacy";
8
8
  import { KiroTokenRefreshError, environmentKiroRoutingMetadata, loginKiro, refreshKiroToken, settleKiroLoginTransaction } from "./kiro";
9
9
  import {
10
10
  OAuthMutationBusyError,
@@ -1781,19 +1781,54 @@ export function submitManualLoginCode(provider: string, input: string): { ok: tr
1781
1781
  return { ok: true };
1782
1782
  }
1783
1783
 
1784
- export interface OAuthAccountSummary { id: string; alias?: string; email?: string; active: boolean; needsReauth?: boolean; expiresAt?: number }
1784
+ export interface OAuthAccountSummary {
1785
+ id: string;
1786
+ alias?: string;
1787
+ email?: string;
1788
+ active: boolean;
1789
+ needsReauth?: boolean;
1790
+ expiresAt?: number;
1791
+ /**
1792
+ * Subscription tier, mirroring the field the OpenAI/Codex provider reports, so a consumer
1793
+ * weighting a multi-account pool by seat size needs no per-provider branching (#3777).
1794
+ *
1795
+ * Always present and explicitly `null` when the tier is unknown. The distinction matters:
1796
+ * an ABSENT key means the proxy is too old to report a tier at all, while `null` means this
1797
+ * version looked and upstream did not say. Omitting it would make those indistinguishable and
1798
+ * invite a consumer to assume a tier.
1799
+ *
1800
+ * Every OAuth provider reports `null` today. Anthropic's `/api/oauth/usage` returns quota
1801
+ * buckets only — `five_hour`, `seven_day`, the model-scoped weekly windows and `limits[]` —
1802
+ * and carries no subscription/tier field, and its token response carries none either. See
1803
+ * `fetchAnthropicUsageQuota` in `src/providers/quota.ts`.
1804
+ */
1805
+ plan: string | null;
1806
+ }
1785
1807
 
1786
- export function getLoginStatus(provider: string): { loggedIn: boolean; email?: string; source?: OAuthCredentials["source"]; error?: string; done: boolean; activeAccountId?: string; accounts?: OAuthAccountSummary[] } {
1808
+ /**
1809
+ * Token-safe login state for one provider.
1810
+ *
1811
+ * `maskEmails` is an explicit boolean rather than a config read (#3859). This module must not
1812
+ * acquire a dependency on config I/O to answer a redaction question: the caller already holds
1813
+ * the config at its request boundary and resolves the policy there with `emailMaskingEnabled`.
1814
+ * The default masks, so every existing caller keeps today's behaviour.
1815
+ */
1816
+ export function getLoginStatus(provider: string, maskEmails = true): { loggedIn: boolean; email?: string; source?: OAuthCredentials["source"]; error?: string; done: boolean; activeAccountId?: string; accounts?: OAuthAccountSummary[] } {
1787
1817
  const cred = getCredential(provider);
1788
1818
  const st = loginState.get(provider);
1789
1819
  const set = getAccountSet(provider);
1790
1820
  const accounts: OAuthAccountSummary[] | undefined = set?.accounts.map(a => ({
1791
1821
  id: a.id,
1792
1822
  ...(a.alias ? { alias: a.alias } : {}),
1793
- email: maskEmail(a.credential.email) ?? undefined,
1823
+ email: projectEmail(a.credential.email, maskEmails) ?? undefined,
1794
1824
  active: a.id === set.activeAccountId,
1795
1825
  ...(a.needsReauth ? { needsReauth: true } : {}),
1796
1826
  expiresAt: a.credential.expires,
1827
+ // Explicitly null rather than omitted — see OAuthAccountSummary.plan. No OAuth provider
1828
+ // exposes a subscription tier today, so there is nothing truthful to put here; deriving one
1829
+ // from quota percentages is not possible, because they are normalized per account and a
1830
+ // half-consumed small seat is indistinguishable from a half-consumed large one.
1831
+ plan: null,
1797
1832
  }));
1798
1833
 
1799
1834
  // A stored credential counts as "logged in" when it exists and is not marked for
@@ -1805,7 +1840,7 @@ export function getLoginStatus(provider: string): { loggedIn: boolean; email?: s
1805
1840
  .find(a => a.id === set.activeAccountId)?.needsReauth === true;
1806
1841
  return {
1807
1842
  loggedIn: !!cred && !activeNeedsReauth,
1808
- email: maskEmail(cred?.email) ?? undefined,
1843
+ email: projectEmail(cred?.email, maskEmails) ?? undefined,
1809
1844
  source: cred?.source,
1810
1845
  error: st?.error,
1811
1846
  done: st?.done ?? false,
@@ -1813,10 +1848,13 @@ export function getLoginStatus(provider: string): { loggedIn: boolean; email?: s
1813
1848
  };
1814
1849
  }
1815
1850
 
1816
- /** Token-safe per-provider login state for the CLI `ocx status` logins section (no tokens, masked email). */
1817
- export function oauthLoginSummary(): Array<{ provider: string; loggedIn: boolean; email?: string }> {
1851
+ /**
1852
+ * Token-safe per-provider login state for the CLI `ocx status` logins section. Never tokens; the
1853
+ * email follows the operator's `privacy.maskEmails` policy, masked by default (#3859).
1854
+ */
1855
+ export function oauthLoginSummary(maskEmails = true): Array<{ provider: string; loggedIn: boolean; email?: string }> {
1818
1856
  return listOAuthProviders().map(provider => {
1819
- const status = getLoginStatus(provider);
1857
+ const status = getLoginStatus(provider, maskEmails);
1820
1858
  return { provider, loggedIn: status.loggedIn, ...(status.email ? { email: status.email } : {}) };
1821
1859
  });
1822
1860
  }
@@ -211,21 +211,30 @@ export async function guardianSweep(nowMs: number = Date.now()): Promise<Guardia
211
211
  if (!cred) continue;
212
212
  const needsRefresh = cred.expiresAt <= nowMs + horizonMs;
213
213
  const needsWarmup = opts.codexWarmupEnabled
214
+ && !record.codexValidationPending
214
215
  && (record.lastCodexValidatedAt === undefined || nowMs - record.lastCodexValidatedAt > opts.codexWarmupMaxAgeSeconds * 1000);
215
216
  if (!needsRefresh && !needsWarmup) continue;
216
217
  const key = `codex:${id}`;
217
218
  if (inBackoff(key, nowMs)) { result.skippedBackoff.push(key); continue; }
219
+ // The generation this sweep is acting on. A successful refresh commits a new one, and a
220
+ // failure that follows belongs to THAT credential, so the fence has to move with it.
221
+ let observedGeneration = record.generation;
218
222
  tasks.push(async () => {
223
+ let warmupGeneration: number | undefined;
219
224
  try {
220
225
  const token = await getValidCodexToken(id);
226
+ observedGeneration = token.generation;
221
227
  if (needsRefresh) result.refreshed.push(key);
222
- if (needsWarmup) {
228
+ const current = readCodexAccountRecord(id);
229
+ if (needsWarmup && current?.credential && current.deletedAt == null
230
+ && !current.codexValidationPending && current.generation === token.generation) {
231
+ warmupGeneration = token.generation;
223
232
  await warmCodexAccount({
224
233
  accessToken: token.accessToken,
225
234
  chatgptAccountId: token.chatgptAccountId,
226
235
  model: opts.codexWarmupModel,
227
236
  });
228
- markCodexAccountValidated(id, Date.now());
237
+ markCodexAccountValidated(id, Date.now(), token.generation);
229
238
  result.warmed.push(key);
230
239
  }
231
240
  backoff.delete(key);
@@ -235,11 +244,28 @@ export async function guardianSweep(nowMs: number = Date.now()): Promise<Guardia
235
244
  result.skippedBackoff.push(key);
236
245
  return;
237
246
  }
238
- const permanent = err instanceof TokenRefreshError && (err.reason === "revoked" || err.reason === "expired");
239
- if (needsWarmup && !(err instanceof TokenRefreshError)) {
240
- markCodexAccountValidationFailed(id, codexWarmupFailureReason(err));
247
+ const terminal = err instanceof TokenRefreshError && (err.reason === "revoked" || err.reason === "expired")
248
+ ? err
249
+ : undefined;
250
+ if (terminal) {
251
+ // A revoked or expired refresh grant is the strongest terminal evidence there is, and
252
+ // it used to be the one class that never reached the record: the persisted-verdict
253
+ // branch below requires `needsWarmup`, which is false in the default configuration,
254
+ // and additionally excluded every TokenRefreshError. The verdict landed only in the
255
+ // in-memory backoff map, which no health surface reads and no restart survives, so the
256
+ // account kept its login-time "ok" while every request with it 401'd (#4120).
257
+ markCodexAccountValidationFailed(id, `refresh_${terminal.reason}`, {
258
+ expectedGeneration: observedGeneration,
259
+ terminal: true,
260
+ });
261
+ } else if (warmupGeneration !== undefined && !(err instanceof TokenRefreshError)) {
262
+ // warmupGeneration is set only once the warmup actually started against a record
263
+ // still at the token's generation, so it is a tighter fence than the pre-sweep read.
264
+ markCodexAccountValidationFailed(id, codexWarmupFailureReason(err), {
265
+ expectedGeneration: warmupGeneration,
266
+ });
241
267
  }
242
- recordFailure(key, nowMs, opts.backoffBaseSeconds, opts.backoffMaxSeconds, permanent, writerGeneration);
268
+ recordFailure(key, nowMs, opts.backoffBaseSeconds, opts.backoffMaxSeconds, terminal !== undefined, writerGeneration);
243
269
  result.failed.push(key);
244
270
  }
245
271
  });
@@ -0,0 +1,74 @@
1
+ import {
2
+ extractModelEnvelopeRows,
3
+ isValidModelDiscoveryModelId,
4
+ type ProviderModelItemsResult,
5
+ type ProviderModelsApiItem,
6
+ } from "./model-discovery";
7
+
8
+ const GOOGLE_MODEL_PREFIX = "models/";
9
+ const MAX_GENERATION_METHODS = 32;
10
+ const MAX_GENERATION_METHOD_LENGTH = 64;
11
+
12
+ /** Returns the value if it is a positive safe integer; otherwise undefined. */
13
+ function positiveSafeInteger(value: unknown): number | undefined {
14
+ return typeof value === "number" && Number.isSafeInteger(value) && value > 0
15
+ ? value
16
+ : undefined;
17
+ }
18
+
19
+ /**
20
+ * Extracts and normalizes supported model items from a Google AI Studio
21
+ * /v1beta/models response payload.
22
+ *
23
+ * Validates the native models[] envelope, strips the 'models/' prefix, filters
24
+ * to rows supporting 'generateContent', maps input/output token limits, and
25
+ * resiliently skips toxic or malformed individual rows.
26
+ */
27
+ export function extractGoogleAiStudioModelItems(
28
+ value: unknown,
29
+ maxModels: number,
30
+ ): ProviderModelItemsResult {
31
+ const envelope = extractModelEnvelopeRows(value, maxModels, ["models"]);
32
+ if (!envelope.ok) return envelope;
33
+
34
+ const items: ProviderModelsApiItem[] = [];
35
+ const seen = new Set<string>();
36
+ for (const raw of envelope.rows) {
37
+ if (raw === null || typeof raw !== "object" || Array.isArray(raw)) {
38
+ continue;
39
+ }
40
+ const name = Reflect.get(raw, "name");
41
+ const generationMethods = Reflect.get(raw, "supportedGenerationMethods");
42
+ if (!isValidModelDiscoveryModelId(name)) {
43
+ continue;
44
+ }
45
+ if (generationMethods === undefined) continue;
46
+ if (
47
+ !Array.isArray(generationMethods)
48
+ || generationMethods.length > MAX_GENERATION_METHODS
49
+ || generationMethods.some(method => typeof method !== "string" || method.length > MAX_GENERATION_METHOD_LENGTH)
50
+ ) {
51
+ continue;
52
+ }
53
+ if (!generationMethods.includes("generateContent")) continue;
54
+
55
+ const id = name.startsWith(GOOGLE_MODEL_PREFIX)
56
+ ? name.slice(GOOGLE_MODEL_PREFIX.length)
57
+ : name;
58
+ if (!isValidModelDiscoveryModelId(id) || seen.has(id)) continue;
59
+ seen.add(id);
60
+
61
+ const inputTokenLimit = positiveSafeInteger(Reflect.get(raw, "inputTokenLimit"));
62
+ const outputTokenLimit = positiveSafeInteger(Reflect.get(raw, "outputTokenLimit"));
63
+ items.push({
64
+ id,
65
+ owned_by: "google",
66
+ ...(inputTokenLimit !== undefined
67
+ ? { context_length: inputTokenLimit, max_input_tokens: inputTokenLimit }
68
+ : {}),
69
+ ...(outputTokenLimit !== undefined ? { max_output_tokens: outputTokenLimit } : {}),
70
+ });
71
+ }
72
+ return { ok: true, items, rawCount: envelope.rows.length };
73
+ }
74
+
@@ -22,7 +22,15 @@ export function deriveOpenCodeGoSessionId(sessionLane: string): string {
22
22
  return `ocx_${digest}`;
23
23
  }
24
24
 
25
- /** Add per-conversation Go affinity only to the canonical fixed-key destination. */
25
+ /**
26
+ * Add Go affinity only to the canonical fixed-key destination.
27
+ *
28
+ * Callers on the request path resolve the lane with `getOrAllocateRequestSessionLane`, which returns
29
+ * real conversation identity when the client supplied it and a per-request value otherwise, so a
30
+ * request reaching this helper from the proxy always carries a lane. The `!sessionLane` guard stays
31
+ * for direct callers that have no request context; it is not a per-request identity of its own, and
32
+ * minting one here would hand each retry a different value.
33
+ */
26
34
  export function resolveOpenCodeGoTransport<T extends OcxProviderConfig>(
27
35
  provider: T,
28
36
  sessionLane: string | undefined,
@@ -7,6 +7,11 @@
7
7
  * bodies and may omit `Retry-After` / `X-RateLimit-*`; when those headers are
8
8
  * present they still take precedence. Distinct from the keyless desktop
9
9
  * ~200 requests / 5h quota documented on `opencode-free`.
10
+ *
11
+ * The same module also owns the keyless free-tier admission explanation (#4121):
12
+ * Zen rejects a request that carries no `x-opencode-session` header with
13
+ * `MissingSessionID` / "OpenCode's free tier can only be used in OpenCode".
14
+ * opencodex does not synthesize that header — see {@link enrichOpenCodeZenFreeTierMessage}.
10
15
  */
11
16
  import { validateClientRetryAfterHeader } from "../lib/retry-after";
12
17
  import { registryEntryForProviderDestination } from "./registry";
@@ -100,3 +105,73 @@ export function enrichOpenCodeZenRateLimitMessage(
100
105
  + paceHint
101
106
  );
102
107
  }
108
+
109
+ /**
110
+ * Zen's keyless free tier admits only OpenCode's own client. A request without an
111
+ * `x-opencode-session` header is refused with error type `MissingSessionID` and the
112
+ * message "OpenCode's free tier can only be used in OpenCode" (#4121).
113
+ *
114
+ * Presence of the header is the whole gate — any value clears it — so opencodex could
115
+ * pass by minting one. It does not. Fabricating a session identifier and a versioned
116
+ * `opencode/<version>` User-Agent is a claim to *be* the OpenCode client, and no upstream
117
+ * contract authorizes a third-party agent to make it; an HTTP 200 obtained that way is a
118
+ * bypassed admission check, not permission. Until OpenCode publishes a third-party
119
+ * integration path for this exact keyless tier, the supported route is the keyed
120
+ * `opencode-zen` provider.
121
+ *
122
+ * Two markers are matched because the two request surfaces expose different parts of the
123
+ * upstream envelope: the Responses path forwards the bounded raw body (which carries the
124
+ * `MissingSessionID` type), while the native Chat path forwards only the parsed message.
125
+ */
126
+ const OPENCODE_ZEN_FREE_TIER_LOCK_IN = /MissingSessionID|free tier can only be used in OpenCode/i;
127
+
128
+ /** Idempotence marker — the appended guidance must not stack across enrichment layers. */
129
+ const FREE_TIER_ENRICHMENT_MARKER = "does not send a fabricated OpenCode session header";
130
+
131
+ /** True when an upstream error body is Zen's keyless free-tier admission refusal. */
132
+ export function isOpenCodeZenFreeTierLockIn(message: string, upstreamErrorType?: string | null): boolean {
133
+ if (upstreamErrorType && OPENCODE_ZEN_FREE_TIER_LOCK_IN.test(upstreamErrorType)) return true;
134
+ return OPENCODE_ZEN_FREE_TIER_LOCK_IN.test(message);
135
+ }
136
+
137
+ /**
138
+ * Replace a raw `MissingSessionID` passthrough with an explanation of the upstream
139
+ * restriction and the supported alternative. No-op for every other provider and every
140
+ * other error, and idempotent so layered enrichment cannot append it twice.
141
+ */
142
+ export function enrichOpenCodeZenFreeTierMessage(
143
+ message: string,
144
+ opts: {
145
+ providerName?: string;
146
+ baseUrl?: string;
147
+ adapter?: string;
148
+ /** Upstream `error.type`, when the caller parsed one out of the envelope. */
149
+ upstreamErrorType?: string | null;
150
+ },
151
+ ): string {
152
+ if (message.includes(FREE_TIER_ENRICHMENT_MARKER)) return message;
153
+ if (!isOpenCodeZenFreeTierLockIn(message, opts.upstreamErrorType)) return message;
154
+ if (!isOpenCodeZenRateLimitProvider(opts)) return message;
155
+ return (
156
+ `${message}`
157
+ + " OpenCode Zen's keyless free tier admits only OpenCode's own client: it refuses any"
158
+ + " request that arrives without an x-opencode-session header."
159
+ + ` opencodex ${FREE_TIER_ENRICHMENT_MARKER}, because presenting itself as the OpenCode`
160
+ + " client is a claim no upstream contract supports."
161
+ + " Use the keyed opencode-zen provider with an OpenCode Zen API key"
162
+ + " (https://opencode.ai/auth), or route this model through another provider."
163
+ + " Upstream terms: https://opencode.ai/docs/zen/."
164
+ );
165
+ }
166
+
167
+ /**
168
+ * Single entry point for Zen upstream-error guidance on the Responses wire: short-window
169
+ * rate limits first, then the keyless free-tier admission refusal. Each layer is a no-op
170
+ * outside its own case, so the composition is safe for every other upstream failure.
171
+ */
172
+ export function enrichOpenCodeZenUpstreamMessage(
173
+ message: string,
174
+ opts: Parameters<typeof enrichOpenCodeZenRateLimitMessage>[1] & { upstreamErrorType?: string | null },
175
+ ): string {
176
+ return enrichOpenCodeZenFreeTierMessage(enrichOpenCodeZenRateLimitMessage(message, opts), opts);
177
+ }
@@ -1398,6 +1398,21 @@ function parseClaudeLimit(value: unknown): { label: string; percent: number; res
1398
1398
  /** Claude's OAuth usage endpoint, probed with ONE account's own bearer token. */
1399
1399
  const anthropicUsageInflight = new Map<string, Promise<ProviderQuota | null>>();
1400
1400
 
1401
+ /**
1402
+ * Anthropic per-credential usage.
1403
+ *
1404
+ * This endpoint reports quota only. Its body carries `five_hour`, `seven_day`, the
1405
+ * model-scoped weekly buckets (`seven_day_fable`/`_opus`/`_sonnet`) and a `limits` array,
1406
+ * and **no subscription or tier field** — nor does the OAuth token response, which yields only
1407
+ * `account.uuid` and `account.email_address` (`src/oauth/anthropic.ts`). That is why
1408
+ * `OAuthAccountSummary.plan` is `null` for Anthropic rather than populated here (#3777); it is
1409
+ * a missing upstream field, not an unfinished mapping.
1410
+ *
1411
+ * A tier must not be inferred from what is here. Percentages are normalized per account, so a
1412
+ * Max x5 seat at 50% is byte-identical to a Max x20 seat at 50%, and the presence of a
1413
+ * model-scoped window tracks entitlement rather than seat size. Populate `plan` only when
1414
+ * upstream returns the tier itself.
1415
+ */
1401
1416
  async function fetchAnthropicUsageQuota(accessToken: string): Promise<ProviderQuota | null> {
1402
1417
  const joinable = anthropicUsageInflight.get(accessToken);
1403
1418
  if (joinable) return joinable;
@@ -2891,7 +2906,11 @@ function keyQuotaReaderForProvider(name: string, provider: OcxProviderConfig): K
2891
2906
  if (name === "deepseek" && isCanonicalDeepSeekBaseUrl(provider.baseUrl)) return fetchDeepSeekQuota;
2892
2907
  if (name === "cline-pass" && isCanonicalClineBaseUrl(provider.baseUrl)) return fetchClineQuota;
2893
2908
  if (isCanonicalOllamaCloudBaseUrl(provider.baseUrl ?? getProviderRegistryEntry(name)?.baseUrl)) return fetchOllamaCloudQuota;
2894
- if (["zai", "glm", "glm-cn", "zhipu-bigmodel-coding"].includes(name) && isCanonicalZaiBaseUrl(provider.baseUrl)) return fetchZaiQuota;
2909
+ // #4201: the Responses preset is the same domestic GLM Coding Plan subscription on the OpenAI
2910
+ // Responses wire, so it reads the same monitor endpoint. Eligibility stays a name list AND the
2911
+ // canonical-URL guard: the guard is what keeps BigModel's bare-key Authorization from reaching a
2912
+ // lookalike host, so a same-named custom destination still dispatches nothing.
2913
+ if (["zai", "glm", "glm-cn", "zhipu-bigmodel-coding", "zhipu-bigmodel-responses"].includes(name) && isCanonicalZaiBaseUrl(provider.baseUrl)) return fetchZaiQuota;
2895
2914
  if (["minimax", "minimax-cn"].includes(name) && isCanonicalMinimaxBaseUrl(provider.baseUrl)) return fetchMinimaxQuota;
2896
2915
  if (name === "moonshot" && isCanonicalMoonshotBaseUrl(provider.baseUrl)) return fetchMoonshotQuota;
2897
2916
  if (name === "venice" && isCanonicalVeniceBaseUrl(provider.baseUrl)) return fetchVeniceQuota;
@@ -2640,6 +2640,23 @@ export const PROVIDER_REGISTRY: readonly ProviderRegistryEntry[] = [
2640
2640
  // Narrowed carry of #3641: the official Codex example declares a local static catalog,
2641
2641
  // not an HTTP /models contract. Keep Responses separate from the Chat endpoint above.
2642
2642
  // Source: https://docs.bigmodel.cn/cn/coding-plan/tool/codex.md (checked 2026-09-07).
2643
+ //
2644
+ // #4201 completes the roster. The `models.json` example on that Codex page is a *starter
2645
+ // catalog*, not the set of models the endpoint serves, and reading it as the latter is what
2646
+ // left Flash off a subscription that sells it. Three upstream pages say so directly, all
2647
+ // checked 2026-09-11:
2648
+ // - coding-plan/latest-model.md pins Codex to THIS baseUrl
2649
+ // (`Codex:https://open.bigmodel.cn/api/v1`) and opens with GLM Coding Plan supporting
2650
+ // GLM-5.3 and GLM-5.3-Flash for every tier (Max & Pro & Lite), then treats
2651
+ // `glm-5.3-flash` as an already-callable id in that same tool.
2652
+ // - coding-plan/overview.md: every plan supports GLM-5.3 and GLM-5.3-Flash, and calls to
2653
+ // GLM-5-Turbo are auto-switched to GLM-5.3-Flash. Turbo below is therefore an alias of
2654
+ // the very model this row omitted, which is the clearest statement that the endpoint
2655
+ // serves Flash: it was already serving it under another name.
2656
+ // - guide/models/vlm/glm-5.3-flash.md: native multimodal input, 1M context, and text
2657
+ // parameters explicitly "consistent with GLM-5.3".
2658
+ // No authenticated /models probe is implied by any of this, so `liveModels` and
2659
+ // `apiKeyValidation` below are deliberately unchanged.
2643
2660
  {
2644
2661
  id: "zhipu-bigmodel-responses",
2645
2662
  label: "Zhipu AI — BigModel Coding Plan (Responses)",
@@ -2648,22 +2665,34 @@ export const PROVIDER_REGISTRY: readonly ProviderRegistryEntry[] = [
2648
2665
  authKind: "key",
2649
2666
  dashboardUrl: "https://bigmodel.cn/console/usercenter/apikeys",
2650
2667
  defaultModel: "glm-5.3",
2651
- models: ["glm-5.3", "glm-5-turbo"],
2668
+ models: ["glm-5.3", "glm-5.3-flash", "glm-5-turbo"],
2652
2669
  liveModels: false,
2653
2670
  // The local Codex catalog does not establish an authenticated HTTP /models contract.
2654
2671
  apiKeyValidation: "unknown",
2655
2672
  jawcodeBundle: "zai",
2656
2673
  // A pre-existing same-named custom provider must retain its destination and key boundary.
2657
2674
  preserveCustomDestination: true,
2658
- modelContextWindows: { "glm-5.3": 1_048_576, "glm-5-turbo": 204_800 },
2659
- modelInputModalities: { "glm-5.3": ["text"], "glm-5-turbo": ["text"] },
2675
+ // Flash tracks its 5.3 sibling on this row rather than the Chat row's 1_000_000. Both
2676
+ // models are documented as "1M", and this preset expresses that family's 1M the way
2677
+ // BigModel's own Codex declaration does. Splitting the two would leave one preset
2678
+ // claiming two different sizes for one documented window.
2679
+ modelContextWindows: { "glm-5.3": 1_048_576, "glm-5.3-flash": 1_048_576, "glm-5-turbo": 204_800 },
2680
+ // Flash is the only row here that can actually see an image. Its siblings are declared
2681
+ // text-only and get `image` back from the vision sidecar at catalog-build time; declaring
2682
+ // Flash text-only would route a native VLM's pictures through a describe-it-first detour
2683
+ // and hand the model prose about an image it could have read (same defect
2684
+ // ZAI_GLM_5X_SIDECAR_VISION_MODELS exists to prevent on the Chat rows).
2685
+ modelInputModalities: { "glm-5.3": ["text"], "glm-5.3-flash": ["text", "image"], "glm-5-turbo": ["text"] },
2660
2686
  modelReasoningEfforts: {
2661
2687
  "glm-5.3": ZAI_GLM_53_REASONING_EFFORTS,
2688
+ // Same three effective tiers: upstream documents Flash's text parameters as identical
2689
+ // to GLM-5.3, and the Codex effort table folds every inbound value into low/high/max.
2690
+ "glm-5.3-flash": ZAI_GLM_53_REASONING_EFFORTS,
2662
2691
  // Explicitly empty: Turbo must not inherit the generic selectable effort ladder.
2663
2692
  "glm-5-turbo": [],
2664
2693
  },
2665
- modelDefaultReasoningEfforts: { "glm-5.3": "max", "glm-5-turbo": "max" },
2666
- modelSupportsReasoningSummaries: { "glm-5.3": true, "glm-5-turbo": true },
2694
+ modelDefaultReasoningEfforts: { "glm-5.3": "max", "glm-5.3-flash": "max", "glm-5-turbo": "max" },
2695
+ modelSupportsReasoningSummaries: { "glm-5.3": true, "glm-5.3-flash": true, "glm-5-turbo": true },
2667
2696
  // Responses replay uses this provider-level flag, not the Chat-path model list.
2668
2697
  preserveResponsesReasoningContent: true,
2669
2698
  note: "Domestic BigModel Coding Plan Responses endpoint; static model roster",
@@ -3018,7 +3047,7 @@ export const PROVIDER_REGISTRY: readonly ProviderRegistryEntry[] = [
3018
3047
  keyOptional: true,
3019
3048
  featured: true,
3020
3049
  liveModels: true,
3021
- note: "No key needed — public desktop tier. OpenCode currently advertises about 200 Big Pickle/free-model requests per 5 hours. The same Zen gateway can also short-window rate-limit free models at roughly 15-20 requests/minute, and may return generic 429s without Retry-After (opencodex synthesizes backoff only when that header is omitted). Free models are discovered live from Zen. Data use: per OpenCode's Zen docs (https://opencode.ai/docs/zen/), prompts sent to free models may be retained and used for training/improvement — do not send confidential material through this provider.",
3050
+ note: "No key needed, but OpenCode now gates this tier to its own client: Zen refuses any request that arrives without an x-opencode-session header (error type MissingSessionID, \"OpenCode's free tier can only be used in OpenCode\"). opencodex does not mint that header or claim an OpenCode client identity, because no upstream contract authorizes a third-party agent to present itself as OpenCode. Until OpenCode publishes a third-party integration path for the keyless tier, use the keyed opencode-zen provider instead (https://opencode.ai/auth). Quota figures for when the tier admitted a request: OpenCode advertises about 200 Big Pickle/free-model requests per 5 hours, and the same Zen gateway can short-window rate-limit free models at roughly 15-20 requests/minute, and may return generic 429s without Retry-After (opencodex synthesizes backoff only when that header is omitted). Free models are discovered live from Zen. Data use: per OpenCode's Zen docs (https://opencode.ai/docs/zen/), prompts sent to free models may be retained and used for training/improvement — do not send confidential material through this provider.",
3022
3051
  dashboardUrl: "https://opencode.ai",
3023
3052
  staticHeaders: {
3024
3053
  // Zen answers a bare runtime User-Agent (Bun/x.y.z) more aggressively than a client