@bitkyc08/opencodex 2.7.42 → 2.7.43

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 (143) hide show
  1. package/README.md +8 -1
  2. package/bin/ocx.mjs +13 -14
  3. package/gui/dist/assets/index-Czw-jpTU.css +1 -0
  4. package/gui/dist/assets/index-cmds12BG.js +67 -0
  5. package/gui/dist/index.html +2 -2
  6. package/package.json +1 -1
  7. package/src/AGENTS.md +28 -0
  8. package/src/adapters/anthropic.ts +15 -6
  9. package/src/adapters/cursor/native-exec-shell.ts +18 -6
  10. package/src/adapters/cursor/protobuf-events.ts +24 -2
  11. package/src/adapters/cursor/protobuf-request.ts +1 -2
  12. package/src/adapters/cursor/tool-definitions.ts +68 -29
  13. package/src/adapters/google-wire-compiler.ts +4 -0
  14. package/src/adapters/google.ts +128 -2
  15. package/src/adapters/identity.ts +12 -2
  16. package/src/adapters/kiro.ts +49 -6
  17. package/src/adapters/mimo-free.ts +2 -0
  18. package/src/adapters/openai-responses.ts +246 -59
  19. package/src/claude/agents-inject.ts +5 -0
  20. package/src/claude/inbound.ts +26 -9
  21. package/src/cli/account-auth.ts +1 -1
  22. package/src/cli/agent-driven.ts +37 -0
  23. package/src/cli/claude.ts +3 -3
  24. package/src/cli/doctor.ts +23 -18
  25. package/src/cli/help.ts +42 -6
  26. package/src/cli/index.ts +88 -19
  27. package/src/cli/interactive-confirm.ts +129 -0
  28. package/src/cli/opencode.ts +701 -0
  29. package/src/cli/provider-runtime.ts +3 -0
  30. package/src/cli/provider.ts +31 -10
  31. package/src/cli/star-prompt.ts +55 -16
  32. package/src/cli/status.ts +47 -13
  33. package/src/codex/account-id.ts +34 -0
  34. package/src/codex/account-lifecycle.ts +4 -1
  35. package/src/codex/account-namespace-match.ts +63 -0
  36. package/src/codex/account-namespaces.ts +149 -0
  37. package/src/codex/account-pause.ts +20 -0
  38. package/src/codex/account-usability.ts +6 -1
  39. package/src/codex/app-server-processes.ts +511 -0
  40. package/src/codex/auth-api.ts +293 -34
  41. package/src/codex/auth-collision.ts +2 -1
  42. package/src/codex/auth-context.ts +60 -17
  43. package/src/codex/catalog/parsing.ts +16 -1
  44. package/src/codex/catalog/provider-fetch.ts +48 -12
  45. package/src/codex/catalog/sync.ts +18 -3
  46. package/src/codex/catalog.ts +1 -1
  47. package/src/codex/history-provider.ts +145 -1
  48. package/src/codex/inject.ts +114 -14
  49. package/src/codex/main-account.ts +2 -8
  50. package/src/codex/pool-rotation.ts +186 -0
  51. package/src/codex/quota.ts +15 -0
  52. package/src/codex/routing.ts +695 -106
  53. package/src/codex/shim.ts +4 -1
  54. package/src/codex/subagent-defaults.ts +550 -0
  55. package/src/codex/subagent-model-fallback.ts +2 -0
  56. package/src/codex/sync.ts +3 -0
  57. package/src/config.ts +566 -25
  58. package/src/images/artifacts.ts +516 -0
  59. package/src/images/fulfill-video.ts +163 -0
  60. package/src/images/fulfill.ts +111 -0
  61. package/src/images/index.ts +4 -0
  62. package/src/images/loop.ts +789 -0
  63. package/src/images/plan.ts +133 -0
  64. package/src/images/synthetic-tool.ts +133 -0
  65. package/src/images/types.ts +41 -0
  66. package/src/images/xai-client.ts +141 -0
  67. package/src/images/xai-video-client.ts +163 -0
  68. package/src/lib/admin-secrets.ts +25 -0
  69. package/src/lib/config-ownership.ts +327 -0
  70. package/src/lib/crash-guard.ts +2 -0
  71. package/src/lib/destination-policy.ts +122 -4
  72. package/src/lib/pinned-http.ts +151 -0
  73. package/src/lib/process-control.ts +2 -2
  74. package/src/lib/provider-outbound.ts +164 -0
  75. package/src/lib/provider-url.ts +14 -0
  76. package/src/lib/proxy-env.ts +18 -0
  77. package/src/lib/windows-elevation.ts +81 -3
  78. package/src/lib/windows-secret-acl.ts +189 -12
  79. package/src/lib/winsw.ts +2 -0
  80. package/src/oauth/anthropic-routing.ts +570 -0
  81. package/src/oauth/health.ts +6 -0
  82. package/src/oauth/index.ts +283 -72
  83. package/src/oauth/key-providers.ts +17 -6
  84. package/src/oauth/kimi.ts +2 -0
  85. package/src/oauth/kiro-credentials.ts +322 -10
  86. package/src/oauth/kiro.ts +423 -43
  87. package/src/oauth/login-cli.ts +32 -5
  88. package/src/oauth/store.ts +54 -4
  89. package/src/oauth/types.ts +11 -0
  90. package/src/providers/alibaba-region-migration.ts +16 -3
  91. package/src/providers/antigravity-models.ts +3 -0
  92. package/src/providers/api-keys.ts +13 -6
  93. package/src/providers/derive.ts +6 -0
  94. package/src/providers/key-failover.ts +24 -4
  95. package/src/providers/quota.ts +233 -29
  96. package/src/providers/registry.ts +11 -3
  97. package/src/responses/parser.ts +11 -0
  98. package/src/responses/state.ts +22 -8
  99. package/src/responses/tool-groups.ts +19 -0
  100. package/src/router.ts +14 -4
  101. package/src/server/auth-cors.ts +110 -22
  102. package/src/server/claude-messages.ts +8 -1
  103. package/src/server/gui-static.ts +30 -6
  104. package/src/server/images.ts +303 -9
  105. package/src/server/index.ts +77 -9
  106. package/src/server/lifecycle.ts +25 -1
  107. package/src/server/management/agent-settings-routes.ts +28 -4
  108. package/src/server/management/combo-routes.ts +7 -0
  109. package/src/server/management/config-routes.ts +5 -2
  110. package/src/server/management/logs-usage-routes.ts +167 -3
  111. package/src/server/management/oauth-account-routes.ts +163 -17
  112. package/src/server/management/provider-routes.ts +35 -7
  113. package/src/server/management/system-restart.ts +172 -0
  114. package/src/server/management/system-routes.ts +33 -10
  115. package/src/server/management-api.ts +2 -2
  116. package/src/server/management-auth.ts +216 -0
  117. package/src/server/proxy-liveness.ts +14 -3
  118. package/src/server/responses/compact.ts +21 -13
  119. package/src/server/responses/core.ts +583 -152
  120. package/src/server/responses-image-gen-repair.ts +118 -0
  121. package/src/server/responses-item-id-repair.ts +10 -85
  122. package/src/server/sse-payload-rewrite.ts +116 -0
  123. package/src/server/system-env.ts +28 -10
  124. package/src/service.ts +49 -2
  125. package/src/storage/cleanup-job.ts +57 -0
  126. package/src/storage/cleanup.ts +1504 -28
  127. package/src/storage/policy-job.ts +366 -0
  128. package/src/storage/policy-scheduler.ts +40 -0
  129. package/src/storage/policy-worker.ts +53 -0
  130. package/src/storage/policy.ts +522 -0
  131. package/src/storage/restore-job.ts +242 -0
  132. package/src/storage/restore-worker.ts +52 -0
  133. package/src/storage/storage-mutation-coordinator.ts +109 -0
  134. package/src/tray/windows.ts +2 -0
  135. package/src/types.ts +96 -1
  136. package/src/update/index.ts +36 -18
  137. package/src/update/job.ts +103 -12
  138. package/src/update/npm-invocation.d.mts +23 -0
  139. package/src/update/npm-invocation.mjs +94 -0
  140. package/src/usage/debug.ts +2 -0
  141. package/src/usage/log.ts +4 -0
  142. package/gui/dist/assets/index-Bl_VBGoI.js +0 -65
  143. package/gui/dist/assets/index-DfVGuN88.css +0 -1
@@ -10,7 +10,7 @@
10
10
  * Exceptions:
11
11
  * - `chatgpt` stays single-slot (always replaced): codex-auth-api uses it as a scratch slot
12
12
  * for Codex pool logins, which have their own ledger (codex-accounts.json).
13
- * - Credentials without identity (no accountId/email — e.g. kiro) replace the active slot
13
+ * - Credentials without identity (no accountId/email) replace the active slot
14
14
  * instead of appending: their refresh tokens rotate, so a derived id would duplicate the
15
15
  * same human on every re-login. Kimi extracts JWT `user_id`/`sub` as accountId; Cursor
16
16
  * extracts JWT `sub` — both append distinct accounts under multiauth.
@@ -19,6 +19,7 @@ import { createHash, randomUUID } from "node:crypto";
19
19
  import { chmodSync, closeSync, copyFileSync, existsSync, fstatSync, mkdirSync, openSync, readFileSync, statSync, unlinkSync, writeFileSync } from "node:fs";
20
20
  import { join } from "node:path";
21
21
  import { getConfigDir, atomicWriteFile, backupInvalidConfig, hardenConfigDir, hardenExistingSecret } from "../config";
22
+ import { recordOwnedConfigPath } from "../lib/config-ownership";
22
23
  import { validateCopilotApiBaseUrl } from "./github-copilot";
23
24
  import type { OAuthCredentialSource, OAuthCredentials, ProviderAccount, ProviderAccountSet } from "./types";
24
25
 
@@ -150,7 +151,7 @@ function sameFd(a: LockSnapshot,b: ReturnType<typeof fstatSync>): boolean { retu
150
151
  export function createOAuthFileLock(options: OAuthFileLockOptions): { acquire(): Promise<OAuthFileLockGuard> } {
151
152
  const wait=options.waitTimeoutMs??5000, stale=options.staleAfterMs??120000, min=options.pollMinMs??25,max=options.pollMaxMs??100,sleep=options.sleep??(ms=>Bun.sleep(ms)),now=options.now??Date.now,random=options.random??Math.random,write=options.writeMetadata??((fd,b)=>writeFileSync(fd,b,"utf8"));
152
153
  if(wait<0||stale<=0||min<0||max<min) throw new OAuthFileLockError("Invalid OAuth file-lock timing options");
153
- return { async acquire() { hardenConfigDir(); if(!existsSync(getConfigDir())) mkdirSync(getConfigDir(),{recursive:true,mode:0o700}); const ownerId=randomUUID(),started=now(); for(;;){ let fd:number|undefined; try { fd=openSync(options.path,"wx",0o600); const bytes=`${JSON.stringify({version:1,ownerId,pid:process.pid,createdAt:now()})}\n`; write(fd,bytes); const fs=fstatSync(fd); closeSync(fd); fd=undefined; const owned=snapshot(options.path); if(owned.bytes!==bytes||!sameFd(owned,fs)) throw new OAuthFileLockError("OAuth lock changed during creation"); let released=false; return {ownerId,release(){if(released)return;released=true;try{const a=snapshot(options.path);if(!sameSnapshot(owned,a))return;options.beforeReleaseUnlink?.();const b=snapshot(options.path);if(sameSnapshot(owned,b))unlinkSync(options.path);}catch(e){if(errorCode(e)!=="ENOENT")console.warn(`[oauth] lock release failed: ${e instanceof Error?e.message:String(e)}`);}}}; } catch(e) { if(fd!==undefined){let fs;try{fs=fstatSync(fd);}catch{}try{closeSync(fd);}catch{}if(fs)try{const a=snapshot(options.path);if(sameFd(a,fs)){options.beforeFailedCreateUnlink?.();const b=snapshot(options.path);if(sameSnapshot(a,b)&&sameFd(b,fs))unlinkSync(options.path);}}catch{}} if(errorCode(e)!=="EEXIST")throw e instanceof OAuthFileLockError?e:new OAuthFileLockError("Could not create OAuth file lock",{cause:e}); }
154
+ return { async acquire() { hardenConfigDir(); recordOwnedConfigPath(getConfigDir(),options.path); if(!existsSync(getConfigDir())) mkdirSync(getConfigDir(),{recursive:true,mode:0o700}); const ownerId=randomUUID(),started=now(); for(;;){ let fd:number|undefined; try { fd=openSync(options.path,"wx",0o600); const bytes=`${JSON.stringify({version:1,ownerId,pid:process.pid,createdAt:now()})}\n`; write(fd,bytes); const fs=fstatSync(fd); closeSync(fd); fd=undefined; const owned=snapshot(options.path); if(owned.bytes!==bytes||!sameFd(owned,fs)) throw new OAuthFileLockError("OAuth lock changed during creation"); let released=false; return {ownerId,release(){if(released)return;released=true;try{const a=snapshot(options.path);if(!sameSnapshot(owned,a))return;options.beforeReleaseUnlink?.();const b=snapshot(options.path);if(sameSnapshot(owned,b))unlinkSync(options.path);}catch(e){if(errorCode(e)!=="ENOENT")console.warn(`[oauth] lock release failed: ${e instanceof Error?e.message:String(e)}`);}}}; } catch(e) { if(fd!==undefined){let fs;try{fs=fstatSync(fd);}catch{}try{closeSync(fd);}catch{}if(fs)try{const a=snapshot(options.path);if(sameFd(a,fs)){options.beforeFailedCreateUnlink?.();const b=snapshot(options.path);if(sameSnapshot(a,b)&&sameFd(b,fs))unlinkSync(options.path);}}catch{}} if(errorCode(e)!=="EEXIST")throw e instanceof OAuthFileLockError?e:new OAuthFileLockError("Could not create OAuth file lock",{cause:e}); }
154
155
  try{const a=snapshot(options.path);let created=a.mtimeMs;try{const p=JSON.parse(a.bytes);if(typeof p.createdAt==="number")created=Math.max(created,p.createdAt);}catch{}if(now()-created>stale){options.beforeStaleUnlink?.();const b=snapshot(options.path);if(sameSnapshot(a,b))unlinkSync(options.path);continue;}}catch(e){if(errorCode(e)==="ENOENT")continue;throw new OAuthFileLockError("Could not inspect OAuth file lock",{cause:e});} const elapsed=now()-started;if(elapsed>=wait)throw new OAuthFileLockError(`Timed out after ${wait}ms waiting for OAuth file lock`);await sleep(Math.min(wait-elapsed,min+Math.floor(random()*(max-min+1)))); } } };
155
156
  }
156
157
  /** Wait long enough for slow IdP refreshes (e.g. Cursor 15s × 3 attempts) before timing out. */
@@ -206,6 +207,28 @@ function normalizeCredential(cred: unknown): OAuthCredentials | null {
206
207
  const validated = validateCopilotApiBaseUrl(candidate.apiBaseUrl);
207
208
  if (validated) normalized.apiBaseUrl = validated;
208
209
  }
210
+ if (candidate.kiro && typeof candidate.kiro === "object") {
211
+ const kiro = candidate.kiro;
212
+ const clean = (value: unknown, max: number): string | undefined => {
213
+ if (typeof value !== "string") return undefined;
214
+ const trimmed = value.trim();
215
+ return trimmed && trimmed.length <= max && !/[\x00-\x1f\x7f]/.test(trimmed) ? trimmed : undefined;
216
+ };
217
+ const profileArn = clean(kiro.profileArn, 1024);
218
+ const ssoRegion = clean(kiro.ssoRegion, 64);
219
+ const apiRegion = clean(kiro.apiRegion, 64);
220
+ const clientId = clean(kiro.clientId, 4096);
221
+ const clientSecret = clean(kiro.clientSecret, 4096);
222
+ if (profileArn || ssoRegion || apiRegion || clientId || clientSecret) {
223
+ normalized.kiro = {
224
+ ...(profileArn ? { profileArn } : {}),
225
+ ...(ssoRegion ? { ssoRegion } : {}),
226
+ ...(apiRegion ? { apiRegion } : {}),
227
+ ...(clientId ? { clientId } : {}),
228
+ ...(clientSecret ? { clientSecret } : {}),
229
+ };
230
+ }
231
+ }
209
232
  return normalized;
210
233
  }
211
234
 
@@ -293,7 +316,11 @@ export function getCredential(provider: string): OAuthCredentials | null {
293
316
  * (rotating refresh tokens would fabricate duplicates) and single-slot providers replace the
294
317
  * active slot / whole set instead.
295
318
  */
296
- export async function saveCredential(provider: string, cred: OAuthCredentials): Promise<void> {
319
+ export async function saveCredential(
320
+ provider: string,
321
+ cred: OAuthCredentials,
322
+ opts: { preserveIdentityless?: boolean } = {},
323
+ ): Promise<void> {
297
324
  const safe = normalizeCredential(cred);
298
325
  if (!safe) return;
299
326
  await mutateStore(store => {
@@ -317,7 +344,7 @@ export async function saveCredential(provider: string, cred: OAuthCredentials):
317
344
  // active identity-less row in place prevents a stale duplicate that stays selectable
318
345
  // and would re-refresh into a second row with the same identity.
319
346
  const active = set.accounts.find(a => a.id === set.activeAccountId);
320
- if (active && active.credential.accountId === undefined && active.credential.email === undefined) {
347
+ if (!opts.preserveIdentityless && active && active.credential.accountId === undefined && active.credential.email === undefined) {
321
348
  active.credential = safe;
322
349
  delete active.needsReauth;
323
350
  return;
@@ -418,6 +445,29 @@ export async function removeAccount(provider: string, accountId: string): Promis
418
445
  });
419
446
  }
420
447
 
448
+ /** Replace or clear a provider account set (used for transactional Kiro add-account rollback). */
449
+ export async function replaceProviderAccountSet(
450
+ provider: string,
451
+ set: ProviderAccountSet | null,
452
+ ): Promise<void> {
453
+ await mutateStore(store => {
454
+ if (!set || set.accounts.length === 0) {
455
+ delete store[provider];
456
+ return;
457
+ }
458
+ store[provider] = {
459
+ activeAccountId: set.activeAccountId,
460
+ accounts: set.accounts.map(account => ({
461
+ id: account.id,
462
+ credential: { ...account.credential, ...(account.credential.kiro ? { kiro: { ...account.credential.kiro } } : {}) },
463
+ ...(account.alias ? { alias: account.alias } : {}),
464
+ ...(account.needsReauth ? { needsReauth: true } : {}),
465
+ ...(account.addedAt !== undefined ? { addedAt: account.addedAt } : {}),
466
+ })),
467
+ };
468
+ });
469
+ }
470
+
421
471
  export async function markAccountNeedsReauth(provider: string, accountId: string, needsReauth: boolean): Promise<void> {
422
472
  await mutateStore(store => {
423
473
  const account = store[provider]?.accounts.find(a => a.id === accountId);
@@ -1,6 +1,15 @@
1
1
  /** Minimal OAuth types, ported from jawcode packages/ai/src/utils/oauth/types.ts. */
2
2
  export type OAuthCredentialSource = "oauth" | "local-cli" | "credential-file" | "environment" | "manual";
3
3
 
4
+ /** Account-scoped Kiro data required for refresh and request routing. */
5
+ export interface KiroOAuthMetadata {
6
+ profileArn?: string;
7
+ ssoRegion?: string;
8
+ apiRegion?: string;
9
+ clientId?: string;
10
+ clientSecret?: string;
11
+ }
12
+
4
13
  export type OAuthCredentials = {
5
14
  refresh: string;
6
15
  access: string;
@@ -15,6 +24,8 @@ export type OAuthCredentials = {
15
24
  * Never reuse for Antigravity projectId; validated on write and again at request time.
16
25
  */
17
26
  apiBaseUrl?: string;
27
+ /** Never returned by management APIs; persisted only inside the protected auth-store boundary. */
28
+ kiro?: KiroOAuthMetadata;
18
29
  };
19
30
 
20
31
  /** One logged-in account inside a provider's account set (multiauth). */
@@ -2,6 +2,7 @@ import { ALIBABA_INTL_BASE_URL_CHOICES } from "./base-url-choices";
2
2
  import { providerConfigSeed } from "./derive";
3
3
  import { rewriteProviderReferences } from "./provider-id-rewrite";
4
4
  import { PROVIDER_REGISTRY } from "./registry";
5
+ import { codexAccountNamespaceProviderCollisionError } from "../codex/account-namespace-match";
5
6
  import type { OcxConfig, OcxProviderConfig } from "../types";
6
7
 
7
8
  const BEIJING_ID = "alibaba-token-plan";
@@ -91,9 +92,10 @@ function buildIntlRow(source: OcxProviderConfig): OcxProviderConfig {
91
92
  * those contracts across. That override fix shipped once, was backed out twice,
92
93
  * and was refused again when PR #459 closed.
93
94
  *
94
- * Refuses to act when the destination already holds a value a provider row or a
95
- * key the reference rewrite would land on because merging two settings is a
96
- * user decision, not a migration's.
95
+ * Refuses to act when the destination is reserved by an account namespace or
96
+ * already holds a value a provider row or a key the reference rewrite would
97
+ * land on — because choosing which setting wins is a user decision, not a
98
+ * migration's.
97
99
  */
98
100
  export function projectAlibabaRegionMigration(config: OcxConfig): AlibabaRegionMigrationProjection {
99
101
  const beijing = config.providers[BEIJING_ID];
@@ -101,6 +103,17 @@ export function projectAlibabaRegionMigration(config: OcxConfig): AlibabaRegionM
101
103
  if (!beijing || !savedBaseUrl || !isInternationalEndpoint(savedBaseUrl)) {
102
104
  return { config, changed: false, warnings: [] };
103
105
  }
106
+ if (codexAccountNamespaceProviderCollisionError(config.codexAccountNamespaces, INTL_ID)) {
107
+ return {
108
+ config,
109
+ changed: false,
110
+ warnings: [
111
+ `provider "${BEIJING_ID}" needs to move to "${INTL_ID}", but that destination is reserved `
112
+ + `by a configured Codex account namespace. Nothing was changed. Rename the account `
113
+ + `selector or move the provider manually, then restart.`,
114
+ ],
115
+ };
116
+ }
104
117
  if (config.providers[INTL_ID]) {
105
118
  return {
106
119
  config,
@@ -13,6 +13,7 @@ const ANTIGRAVITY_WIRE_MODELS = [
13
13
  "gemini-3.6-flash-high",
14
14
  "gemini-3.1-pro-low",
15
15
  "gemini-pro-agent",
16
+ "gemini-3.1-flash-image",
16
17
  "claude-sonnet-4-6",
17
18
  "claude-opus-4-6-thinking",
18
19
  "gpt-oss-120b-medium",
@@ -85,6 +86,7 @@ export const ANTIGRAVITY_MODEL_ALIASES: Record<string, string> = {
85
86
  export const ANTIGRAVITY_MODELS = [
86
87
  "gemini-3.6-flash",
87
88
  "gemini-3.1-pro",
89
+ "gemini-3.1-flash-image",
88
90
  "claude-sonnet-4-6",
89
91
  "claude-opus-4-6-thinking",
90
92
  "gpt-oss-120b-medium",
@@ -97,6 +99,7 @@ const ANTIGRAVITY_WIRE_MODEL_CONTEXT_WINDOWS: Record<string, number> = {
97
99
  "gemini-3.6-flash-high": 1_048_576,
98
100
  "gemini-3.1-pro-low": 1_048_576,
99
101
  "gemini-pro-agent": 1_048_576,
102
+ "gemini-3.1-flash-image": 1_048_576,
100
103
  "claude-sonnet-4-6": 200_000,
101
104
  "claude-opus-4-6-thinking": 1_000_000,
102
105
  "gpt-oss-120b-medium": 131_072,
@@ -30,7 +30,7 @@ export function maskApiKey(value: string): string {
30
30
  }
31
31
 
32
32
  /** Content-derived id: re-adding the same key upserts instead of duplicating. */
33
- function keyId(key: string): string {
33
+ export function apiKeyPoolEntryId(key: string): string {
34
34
  return createHash("sha256").update(key).digest("hex").slice(0, 8);
35
35
  }
36
36
 
@@ -39,11 +39,18 @@ export function isKeyAuthProvider(provider: OcxProviderConfig): boolean {
39
39
  return provider.authMode !== "oauth" && provider.authMode !== "forward";
40
40
  }
41
41
 
42
+ /** Trim and reject blank / CRLF-bearing secrets. Shared by pool writes and OAuth upsert. */
43
+ export function sanitizeApiKeyValue(value: unknown): string | undefined {
44
+ if (typeof value !== "string") return undefined;
45
+ const trimmed = value.trim();
46
+ return trimmed && !/[\r\n]/.test(trimmed) ? trimmed : undefined;
47
+ }
48
+
42
49
  /** Seed the pool from a legacy bare `apiKey`, and keep `apiKey` mirrored to the active entry. */
43
50
  function ensurePool(provider: OcxProviderConfig): NonNullable<OcxProviderConfig["apiKeyPool"]> {
44
51
  if (!provider.apiKeyPool) provider.apiKeyPool = [];
45
52
  if (provider.apiKeyPool.length === 0 && provider.apiKey) {
46
- provider.apiKeyPool.push({ id: keyId(provider.apiKey), key: provider.apiKey });
53
+ provider.apiKeyPool.push({ id: apiKeyPoolEntryId(provider.apiKey), key: provider.apiKey });
47
54
  }
48
55
  return provider.apiKeyPool;
49
56
  }
@@ -75,11 +82,11 @@ export function listProviderApiKeys(config: OcxConfig, name: string): { activeId
75
82
  export function addProviderApiKey(config: OcxConfig, name: string, key: string, label?: string): { id: string } | { error: string } {
76
83
  const provider = config.providers[name];
77
84
  if (!provider || !isKeyAuthProvider(provider)) return { error: "provider does not use API-key auth" };
78
- const trimmed = key.trim();
79
- if (!trimmed) return { error: "key is required" };
80
- if (/[\r\n]/.test(trimmed)) return { error: "key must not include line breaks" };
85
+ if (typeof key !== "string" || !key.trim()) return { error: "key is required" };
86
+ const trimmed = sanitizeApiKeyValue(key);
87
+ if (!trimmed) return { error: "key must not include line breaks" };
81
88
  const pool = ensurePool(provider);
82
- const id = keyId(trimmed);
89
+ const id = apiKeyPoolEntryId(trimmed);
83
90
  const existing = pool.find(e => e.id === id);
84
91
  if (existing) {
85
92
  if (label?.trim()) existing.label = label.trim();
@@ -5,6 +5,7 @@ export interface DerivedKeyLoginProvider {
5
5
  label: string;
6
6
  baseUrl: string;
7
7
  adapter: string;
8
+ apiKeyTransport?: OcxProviderConfig["apiKeyTransport"];
8
9
  dashboardUrl: string;
9
10
  models?: string[];
10
11
  liveModels?: boolean;
@@ -103,6 +104,7 @@ export function providerConfigSeed(entry: ProviderRegistryEntry): OcxProviderCon
103
104
  return {
104
105
  adapter: entry.adapter,
105
106
  baseUrl: entry.baseUrl,
107
+ ...(entry.apiKeyTransport !== undefined ? { apiKeyTransport: entry.apiKeyTransport } : {}),
106
108
  authMode: entry.authKind === "local" ? undefined : entry.authKind,
107
109
  ...(entry.codexAccountMode ? { codexAccountMode: entry.codexAccountMode } : {}),
108
110
  ...(entry.keyOptional !== undefined ? { keyOptional: entry.keyOptional } : {}),
@@ -129,6 +131,7 @@ export function providerConfigSeed(entry: ProviderRegistryEntry): OcxProviderCon
129
131
  ...(entry.noTopPModels ? { noTopPModels: [...entry.noTopPModels] } : {}),
130
132
  ...(entry.noPenaltyModels ? { noPenaltyModels: [...entry.noPenaltyModels] } : {}),
131
133
  ...(entry.parallelToolCalls !== undefined ? { parallelToolCalls: entry.parallelToolCalls } : {}),
134
+ ...(entry.promptCacheKey !== undefined ? { promptCacheKey: entry.promptCacheKey } : {}),
132
135
  ...(entry.autoToolChoiceOnlyModels ? { autoToolChoiceOnlyModels: [...entry.autoToolChoiceOnlyModels] } : {}),
133
136
  ...(entry.preserveReasoningContentModels ? { preserveReasoningContentModels: [...entry.preserveReasoningContentModels] } : {}),
134
137
  ...(entry.reasoningSplitModels ? { reasoningSplitModels: [...entry.reasoningSplitModels] } : {}),
@@ -150,6 +153,7 @@ export function deriveKeyLoginMap(): Record<string, DerivedKeyLoginProvider> {
150
153
  label: entry.label,
151
154
  baseUrl: entry.baseUrl,
152
155
  adapter: entry.adapter,
156
+ ...(entry.apiKeyTransport !== undefined ? { apiKeyTransport: entry.apiKeyTransport } : {}),
153
157
  dashboardUrl: entry.dashboardUrl,
154
158
  ...(entry.models ? { models: [...entry.models] } : {}),
155
159
  ...(entry.liveModels !== undefined ? { liveModels: entry.liveModels } : {}),
@@ -221,6 +225,7 @@ export function enrichProviderFromRegistry(name: string, prov: OcxProviderConfig
221
225
  const entry = PROVIDER_REGISTRY.find(row => row.id === name);
222
226
  if (!entry) return;
223
227
  const seed = providerConfigSeed(entry);
228
+ if (prov.apiKeyTransport === undefined && seed.apiKeyTransport !== undefined) prov.apiKeyTransport = seed.apiKeyTransport;
224
229
  if (!prov.defaultModel && seed.defaultModel) prov.defaultModel = seed.defaultModel;
225
230
  // Fill mode only when absent: an explicit persisted `direct` must never be overwritten.
226
231
  if (prov.codexAccountMode === undefined && seed.codexAccountMode !== undefined) prov.codexAccountMode = seed.codexAccountMode;
@@ -242,6 +247,7 @@ export function enrichProviderFromRegistry(name: string, prov: OcxProviderConfig
242
247
  if (!prov.noTopPModels && seed.noTopPModels) prov.noTopPModels = [...seed.noTopPModels];
243
248
  if (!prov.noPenaltyModels && seed.noPenaltyModels) prov.noPenaltyModels = [...seed.noPenaltyModels];
244
249
  if (prov.parallelToolCalls === undefined && seed.parallelToolCalls !== undefined) prov.parallelToolCalls = seed.parallelToolCalls;
250
+ if (prov.promptCacheKey === undefined && seed.promptCacheKey !== undefined) prov.promptCacheKey = seed.promptCacheKey;
245
251
  if (!prov.autoToolChoiceOnlyModels && seed.autoToolChoiceOnlyModels) prov.autoToolChoiceOnlyModels = [...seed.autoToolChoiceOnlyModels];
246
252
  if (!prov.preserveReasoningContentModels && seed.preserveReasoningContentModels) prov.preserveReasoningContentModels = [...seed.preserveReasoningContentModels];
247
253
  if (!prov.reasoningSplitModels && seed.reasoningSplitModels) prov.reasoningSplitModels = [...seed.reasoningSplitModels];
@@ -10,7 +10,7 @@
10
10
  */
11
11
  import { saveConfigPreservingClaudeCode } from "../config";
12
12
  import type { OcxConfig, OcxProviderConfig } from "../types";
13
- import { resolveProviderTransport } from "./xai-transport";
13
+ import { resolveProviderTransport, type OcxProviderTransport } from "./xai-transport";
14
14
 
15
15
  // ---- cooldown state (in-memory, same as codex/routing.ts) ----
16
16
 
@@ -69,6 +69,11 @@ export function hasKeyPoolFailover(provider: OcxProviderConfig): boolean {
69
69
  *
70
70
  * @returns A new OcxProviderConfig with the swapped key (and mutated config on disk),
71
71
  * or `null` when no alternative key is available (all in cooldown or pool < 2).
72
+ *
73
+ * The returned object is a snapshot of the PERSISTED config — it carries none of the
74
+ * registry backfills `routedProviderConfig` merges in at request time. Request paths must
75
+ * not assign it to an active route wholesale; use `rotateProviderTransportOn429`, which
76
+ * takes only the swapped key and keeps the routed provider intact.
72
77
  */
73
78
  export function rotateKeyOn429(
74
79
  config: OcxConfig,
@@ -133,12 +138,23 @@ interface RotateProviderTransportOptions {
133
138
  promptCacheKey?: string;
134
139
  }
135
140
 
136
- /** Rotate a failed key and re-apply provider-specific transport metadata to the replacement. */
141
+ /**
142
+ * Rotate a failed key and re-apply provider-specific transport metadata to the replacement.
143
+ *
144
+ * `routedProvider` is the request's active provider (the `routedProviderConfig` output the
145
+ * route was built with). The result inherits it and swaps ONLY the API key: the persisted
146
+ * config that `rotateKeyOn429` snapshots predates registry backfill, so building the retry
147
+ * provider from that snapshot would silently drop every field the registry merged in at
148
+ * routing time (scalar flags like `promptCacheKey`/`parallelToolCalls`, merged model
149
+ * metadata such as `noTemperatureModels`, a pinned baseUrl). Mirrors the OAuth-401 replay
150
+ * path in src/server/responses/core.ts, which spreads `route.provider` for the same reason.
151
+ */
137
152
  export function rotateProviderTransportOn429(
138
153
  config: OcxConfig,
139
154
  providerName: string,
155
+ routedProvider: OcxProviderTransport,
140
156
  options: RotateProviderTransportOptions = {},
141
- ): OcxProviderConfig | null {
157
+ ): OcxProviderTransport | null {
142
158
  const rotated = rotateKeyOn429(
143
159
  config,
144
160
  providerName,
@@ -147,7 +163,11 @@ export function rotateProviderTransportOn429(
147
163
  options.attemptedKey,
148
164
  );
149
165
  return rotated
150
- ? resolveProviderTransport(providerName, rotated, options.promptCacheKey)
166
+ ? resolveProviderTransport(
167
+ providerName,
168
+ { ...routedProvider, apiKey: rotated.apiKey },
169
+ options.promptCacheKey,
170
+ )
151
171
  : null;
152
172
  }
153
173
 
@@ -1,13 +1,16 @@
1
1
  import { fetchMainAccountInfo, listCodexAuthAccounts } from "../codex/auth-api";
2
2
  import { MAIN_CODEX_ACCOUNT_ID } from "../codex/main-account";
3
3
  import { resolveEnvValue } from "../config";
4
- import { getValidAccessToken } from "../oauth";
5
- import { getCredential } from "../oauth/store";
4
+ import { getValidAccessToken, getValidAccessTokenForAccount } from "../oauth";
5
+ import { getAccountCredential, getAccountSet, getCredential } from "../oauth/store";
6
6
  import { antigravityUserAgent } from "../adapters/client-fingerprint";
7
7
  import { getProviderRegistryEntry, providerCodexAccountMode } from "./registry";
8
8
  import type { OcxConfig, OcxProviderConfig } from "../types";
9
9
  import { isCanonicalOpenAiForwardProvider, OPENAI_CODEX_PROVIDER_ID } from "./openai-tiers";
10
10
 
11
+ /** Match oauth/index REFRESH_SKEW_MS — use stored access without refresh when still fresh. */
12
+ const ACCOUNT_TOKEN_SKEW_MS = 60_000;
13
+
11
14
  const CACHE_TTL_MS = 5 * 60_000;
12
15
  const REQUEST_TIMEOUT_MS = 8_000;
13
16
  const KIMI_CODE_BASE_URL = "https://api.kimi.com/coding/v1";
@@ -188,43 +191,244 @@ function parseClaudeBucket(value: unknown): { percent?: number; resetAt?: number
188
191
  return { percent, resetAt };
189
192
  }
190
193
 
194
+ /** Claude's OAuth usage endpoint, probed with ONE account's own bearer token. */
195
+ const anthropicUsageInflight = new Map<string, Promise<ProviderQuota | null>>();
196
+
197
+ async function fetchAnthropicUsageQuota(accessToken: string): Promise<ProviderQuota | null> {
198
+ const joinable = anthropicUsageInflight.get(accessToken);
199
+ if (joinable) return joinable;
200
+
201
+ const probe = (async (): Promise<ProviderQuota | null> => {
202
+ const response = await fetch("https://api.anthropic.com/api/oauth/usage", {
203
+ headers: {
204
+ Accept: "application/json, text/plain, */*",
205
+ "Content-Type": "application/json",
206
+ "User-Agent": "claude-cli/2.1.63 (external, cli)",
207
+ "anthropic-beta": "claude-code-20250219,oauth-2025-04-20,interleaved-thinking-2025-05-14,context-management-2025-06-27,prompt-caching-scope-2026-01-05",
208
+ Authorization: `Bearer ${accessToken}`,
209
+ },
210
+ signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
211
+ });
212
+ if (!response.ok) return null;
213
+ const body = asRecord(await response.json().catch(() => null));
214
+ if (!body) return null;
215
+ const fiveHour = parseClaudeBucket(body.five_hour);
216
+ const sevenDay = parseClaudeBucket(body.seven_day);
217
+ const opus = parseClaudeBucket(body.seven_day_opus);
218
+ const sonnet = parseClaudeBucket(body.seven_day_sonnet);
219
+ const customWindows: ProviderQuotaWindow[] = [];
220
+ if (opus?.percent !== undefined) customWindows.push({ label: "Opus", percent: opus.percent, ...(opus.resetAt !== undefined ? { resetAt: opus.resetAt } : {}) });
221
+ if (sonnet?.percent !== undefined) customWindows.push({ label: "Sonnet", percent: sonnet.percent, ...(sonnet.resetAt !== undefined ? { resetAt: sonnet.resetAt } : {}) });
222
+ const quota: ProviderQuota = {
223
+ // Claude's 5-hour window is a first-class rate limit, same as the Codex login 5h/weekly
224
+ // rows: report it in the canonical fields so the dashboard renders it with the standard
225
+ // "5-hour limit" label and ordering instead of as a generic extra window.
226
+ ...(fiveHour?.percent !== undefined ? { fiveHourPercent: fiveHour.percent } : {}),
227
+ ...(fiveHour?.resetAt !== undefined ? { fiveHourResetAt: fiveHour.resetAt } : {}),
228
+ ...(sevenDay?.percent !== undefined ? { weeklyPercent: sevenDay.percent } : {}),
229
+ ...(sevenDay?.resetAt !== undefined ? { weeklyResetAt: sevenDay.resetAt } : {}),
230
+ ...(customWindows.length > 0 ? { customWindows } : {}),
231
+ updatedAt: Date.now(),
232
+ };
233
+ // Empty / schema-changed payloads must not cache as "success with no bars".
234
+ return hasQuotaRows(quota) ? quota : null;
235
+ })().finally(() => {
236
+ if (anthropicUsageInflight.get(accessToken) === probe) anthropicUsageInflight.delete(accessToken);
237
+ });
238
+ anthropicUsageInflight.set(accessToken, probe);
239
+ return probe;
240
+ }
241
+
191
242
  async function fetchAnthropicQuota(provider: string): Promise<ProviderQuotaReport | null> {
243
+ // Capture the account we intend to probe before awaiting — a mid-flight active
244
+ // switch must not seed the wrong account's cache with this response.
245
+ const probedAccountId = getAccountSet("anthropic")?.activeAccountId;
192
246
  let accessToken: string;
193
247
  try {
194
248
  accessToken = await getValidAccessToken("anthropic");
195
249
  } catch {
196
250
  return null;
197
251
  }
198
- const response = await fetch("https://api.anthropic.com/api/oauth/usage", {
199
- headers: {
200
- Accept: "application/json, text/plain, */*",
201
- "Content-Type": "application/json",
202
- "User-Agent": "claude-cli/2.1.63 (external, cli)",
203
- "anthropic-beta": "claude-code-20250219,oauth-2025-04-20,interleaved-thinking-2025-05-14,context-management-2025-06-27,prompt-caching-scope-2026-01-05",
204
- Authorization: `Bearer ${accessToken}`,
205
- },
206
- signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
207
- });
208
- if (!response.ok) return null;
209
- const body = asRecord(await response.json().catch(() => null));
210
- if (!body) return null;
211
- const fiveHour = parseClaudeBucket(body.five_hour);
212
- const sevenDay = parseClaudeBucket(body.seven_day);
213
- const opus = parseClaudeBucket(body.seven_day_opus);
214
- const sonnet = parseClaudeBucket(body.seven_day_sonnet);
215
- const customWindows: ProviderQuotaWindow[] = [];
216
- if (fiveHour?.percent !== undefined) customWindows.push({ label: "5h", percent: fiveHour.percent, ...(fiveHour.resetAt !== undefined ? { resetAt: fiveHour.resetAt } : {}) });
217
- if (opus?.percent !== undefined) customWindows.push({ label: "Opus", percent: opus.percent, ...(opus.resetAt !== undefined ? { resetAt: opus.resetAt } : {}) });
218
- if (sonnet?.percent !== undefined) customWindows.push({ label: "Sonnet", percent: sonnet.percent, ...(sonnet.resetAt !== undefined ? { resetAt: sonnet.resetAt } : {}) });
219
- const quota: ProviderQuota = {
220
- ...(sevenDay?.percent !== undefined ? { weeklyPercent: sevenDay.percent } : {}),
221
- ...(sevenDay?.resetAt !== undefined ? { weeklyResetAt: sevenDay.resetAt } : {}),
222
- ...(customWindows.length > 0 ? { customWindows } : {}),
223
- updatedAt: Date.now(),
224
- };
252
+ const quota = await fetchAnthropicUsageQuota(accessToken);
253
+ if (!quota) return null;
254
+ // Share the active-account probe with the per-account cache so Providers-page
255
+ // loads do not double-hit Anthropic's rate-limited usage endpoint.
256
+ if (probedAccountId) {
257
+ const stillOwnsToken = getAccountCredential("anthropic", probedAccountId)?.access === accessToken;
258
+ if (stillOwnsToken) {
259
+ accountQuotaCache.set(accountCacheKey("anthropic", probedAccountId), { ts: Date.now(), quota });
260
+ }
261
+ }
225
262
  return report(provider, "anthropic:oauth-usage", quota);
226
263
  }
227
264
 
265
+ // ---------------------------------------------------------------------------
266
+ // Per-account quota (multiauth)
267
+ // ---------------------------------------------------------------------------
268
+
269
+ /**
270
+ * Anthropic reports usage per CREDENTIAL, so every logged-in account can be probed with its
271
+ * own bearer token — the active-account selection and the local usage log are irrelevant here.
272
+ * Mirrors the Codex pool behaviour (codex/auth-api.ts:fetchPoolAccountQuota), including a
273
+ * per-account TTL so N accounts cost at most N upstream calls per window.
274
+ *
275
+ * The TTL is deliberately longer than the provider-level one: this path multiplies by account
276
+ * count, and Anthropic rate-limits the usage endpoint (observed 429 under repeated probing).
277
+ */
278
+ const ACCOUNT_QUOTA_TTL_MS = 10 * 60_000;
279
+ type AccountQuotaCacheEntry = {
280
+ ts: number;
281
+ quota: ProviderQuota | null;
282
+ /** Last probe failed (429 / network / expired login); still may hold last-good quota. */
283
+ unavailable?: true;
284
+ };
285
+ const accountQuotaCache = new Map<string, AccountQuotaCacheEntry>();
286
+ const accountQuotaInflight = new Map<string, Promise<AccountQuotaCacheEntry>>();
287
+
288
+ export interface ProviderAccountQuota {
289
+ accountId: string;
290
+ quota: ProviderQuota | null;
291
+ /** Set when the probe could not reach upstream (expired login, 429, network). */
292
+ unavailable?: true;
293
+ }
294
+
295
+ /** Providers whose per-account quota can be probed. Extend as other OAuth APIs are covered. */
296
+ export function supportsPerAccountQuota(provider: string): boolean {
297
+ return provider === "anthropic";
298
+ }
299
+
300
+ function accountCacheKey(provider: string, accountId: string): string {
301
+ return `${provider}\u0000${accountId}`;
302
+ }
303
+
304
+ /**
305
+ * Synchronous last-good per-account quota read for routing. Never probes the network.
306
+ * Returns null when nothing is cached (or the cached row has no bars).
307
+ */
308
+ export function getCachedProviderAccountQuota(provider: string, accountId: string): ProviderQuota | null {
309
+ const entry = accountQuotaCache.get(accountCacheKey(provider, accountId));
310
+ return entry?.quota ?? null;
311
+ }
312
+
313
+ /** Test-only: seed or clear the per-account quota cache without probing upstream. */
314
+ export function setCachedProviderAccountQuotaForTests(
315
+ provider: string,
316
+ accountId: string,
317
+ quota: ProviderQuota | null,
318
+ ): void {
319
+ const key = accountCacheKey(provider, accountId);
320
+ if (quota === null) {
321
+ accountQuotaCache.delete(key);
322
+ return;
323
+ }
324
+ accountQuotaCache.set(key, { ts: Date.now(), quota });
325
+ }
326
+
327
+ /** Drop cached per-account rows (all, or just one provider's). */
328
+ export function clearAccountQuotaCache(provider?: string): void {
329
+ if (!provider) {
330
+ accountQuotaCache.clear();
331
+ accountQuotaInflight.clear();
332
+ return;
333
+ }
334
+ const prefix = `${provider}\u0000`;
335
+ for (const key of [...accountQuotaCache.keys()]) {
336
+ if (key.startsWith(prefix)) accountQuotaCache.delete(key);
337
+ }
338
+ // Drop in-flight probes too so a late resolve cannot repopulate after logout/remove.
339
+ for (const key of [...accountQuotaInflight.keys()]) {
340
+ if (key.startsWith(prefix)) accountQuotaInflight.delete(key);
341
+ }
342
+ }
343
+
344
+ /**
345
+ * Resolve a bearer for quota probing without silently adopting a newer global
346
+ * Claude CLI credential into a background multiauth slot.
347
+ *
348
+ * - Fresh stored access → use as-is (no refresh).
349
+ * - Active account with expired access → normal refresh path.
350
+ * - Background `local-cli` with expired access → fail closed (unavailable):
351
+ * `getValidAccessTokenForAccount` can persist a mismatched Claude CLI identity.
352
+ * - Background ordinary OAuth (`source !== "local-cli"`) → safe to refresh;
353
+ * Anthropic's lock only adopts disk credentials for `local-cli` rows.
354
+ */
355
+ async function getTokenForAccountQuotaProbe(provider: string, accountId: string): Promise<string> {
356
+ const stored = getAccountCredential(provider, accountId);
357
+ if (!stored) throw new Error("account credential missing");
358
+ if (stored.expires > Date.now() + ACCOUNT_TOKEN_SKEW_MS) return stored.access;
359
+ const activeId = getAccountSet(provider)?.activeAccountId;
360
+ if (activeId !== accountId && stored.source === "local-cli") {
361
+ throw new Error("background local-cli token expired; skip CLI-adopting refresh for quota probe");
362
+ }
363
+ return getValidAccessTokenForAccount(provider, accountId);
364
+ }
365
+
366
+ async function fetchAccountQuota(
367
+ provider: string,
368
+ accountId: string,
369
+ forceRefresh: boolean,
370
+ ): Promise<AccountQuotaCacheEntry> {
371
+ const key = accountCacheKey(provider, accountId);
372
+ const cached = accountQuotaCache.get(key);
373
+ if (!forceRefresh && cached && Date.now() - cached.ts < ACCOUNT_QUOTA_TTL_MS) return cached;
374
+ const joinable = accountQuotaInflight.get(key);
375
+ if (joinable) return joinable;
376
+
377
+ const probe = (async (): Promise<AccountQuotaCacheEntry> => {
378
+ try {
379
+ const token = await getTokenForAccountQuotaProbe(provider, accountId);
380
+ const quota = await fetchAnthropicUsageQuota(token);
381
+ if (!quota) {
382
+ // Preserve last-good bars and mark unavailable; advance TTL so failures
383
+ // negative-cache instead of re-probing on every GUI poll.
384
+ const entry: AccountQuotaCacheEntry = {
385
+ ts: Date.now(),
386
+ quota: cached?.quota ?? null,
387
+ unavailable: true,
388
+ };
389
+ accountQuotaCache.set(key, entry);
390
+ return entry;
391
+ }
392
+ const entry: AccountQuotaCacheEntry = { ts: Date.now(), quota };
393
+ accountQuotaCache.set(key, entry);
394
+ return entry;
395
+ } catch {
396
+ const entry: AccountQuotaCacheEntry = {
397
+ ts: Date.now(),
398
+ quota: cached?.quota ?? null,
399
+ unavailable: true,
400
+ };
401
+ accountQuotaCache.set(key, entry);
402
+ return entry;
403
+ }
404
+ })().finally(() => {
405
+ if (accountQuotaInflight.get(key) === probe) accountQuotaInflight.delete(key);
406
+ });
407
+ accountQuotaInflight.set(key, probe);
408
+ return probe;
409
+ }
410
+
411
+ /**
412
+ * Per-account quota rows for a provider's logged-in accounts. Probes run in parallel; a
413
+ * single failing account never blocks the others.
414
+ */
415
+ export async function fetchProviderAccountQuotas(
416
+ provider: string,
417
+ forceRefresh = false,
418
+ ): Promise<ProviderAccountQuota[]> {
419
+ if (!supportsPerAccountQuota(provider)) return [];
420
+ const set = getAccountSet(provider);
421
+ if (!set) return [];
422
+ return await Promise.all(set.accounts.map(async account => {
423
+ const entry = await fetchAccountQuota(provider, account.id, forceRefresh);
424
+ return {
425
+ accountId: account.id,
426
+ quota: entry.quota,
427
+ ...(entry.unavailable ? { unavailable: true as const } : {}),
428
+ };
429
+ }));
430
+ }
431
+
228
432
  function normalizedBaseUrl(value: string): string | null {
229
433
  try {
230
434
  const url = new URL(value);