@f5-sales-demo/pi-ai 19.51.2

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 (128) hide show
  1. package/CHANGELOG.md +1997 -0
  2. package/README.md +1160 -0
  3. package/package.json +135 -0
  4. package/src/api-registry.ts +95 -0
  5. package/src/auth-storage.ts +2694 -0
  6. package/src/cli.ts +493 -0
  7. package/src/index.ts +42 -0
  8. package/src/model-cache.ts +97 -0
  9. package/src/model-manager.ts +349 -0
  10. package/src/model-thinking.ts +561 -0
  11. package/src/models.json +49439 -0
  12. package/src/models.json.d.ts +9 -0
  13. package/src/models.ts +56 -0
  14. package/src/prompts/turn-aborted-guidance.md +4 -0
  15. package/src/provider-details.ts +81 -0
  16. package/src/provider-models/descriptors.ts +285 -0
  17. package/src/provider-models/google.ts +90 -0
  18. package/src/provider-models/index.ts +4 -0
  19. package/src/provider-models/openai-compat.ts +2074 -0
  20. package/src/provider-models/special.ts +106 -0
  21. package/src/providers/amazon-bedrock.ts +706 -0
  22. package/src/providers/anthropic.ts +1682 -0
  23. package/src/providers/azure-openai-responses.ts +391 -0
  24. package/src/providers/cursor/gen/agent_pb.ts +15274 -0
  25. package/src/providers/cursor/proto/agent.proto +3526 -0
  26. package/src/providers/cursor/proto/buf.gen.yaml +6 -0
  27. package/src/providers/cursor/proto/buf.yaml +17 -0
  28. package/src/providers/cursor.ts +2218 -0
  29. package/src/providers/github-copilot-headers.ts +140 -0
  30. package/src/providers/gitlab-duo.ts +381 -0
  31. package/src/providers/google-gemini-cli.ts +1133 -0
  32. package/src/providers/google-shared.ts +354 -0
  33. package/src/providers/google-vertex.ts +436 -0
  34. package/src/providers/google.ts +381 -0
  35. package/src/providers/kimi.ts +151 -0
  36. package/src/providers/openai-codex/constants.ts +43 -0
  37. package/src/providers/openai-codex/request-transformer.ts +158 -0
  38. package/src/providers/openai-codex/response-handler.ts +81 -0
  39. package/src/providers/openai-codex-responses.ts +2345 -0
  40. package/src/providers/openai-completions-compat.ts +159 -0
  41. package/src/providers/openai-completions.ts +1290 -0
  42. package/src/providers/openai-responses-shared.ts +452 -0
  43. package/src/providers/openai-responses.ts +519 -0
  44. package/src/providers/register-builtins.ts +329 -0
  45. package/src/providers/synthetic.ts +154 -0
  46. package/src/providers/transform-messages.ts +234 -0
  47. package/src/rate-limit-utils.ts +84 -0
  48. package/src/stream.ts +728 -0
  49. package/src/types.ts +546 -0
  50. package/src/usage/claude.ts +337 -0
  51. package/src/usage/gemini.ts +248 -0
  52. package/src/usage/github-copilot.ts +421 -0
  53. package/src/usage/google-antigravity.ts +200 -0
  54. package/src/usage/kimi.ts +286 -0
  55. package/src/usage/minimax-code.ts +31 -0
  56. package/src/usage/openai-codex.ts +387 -0
  57. package/src/usage/zai.ts +247 -0
  58. package/src/usage.ts +130 -0
  59. package/src/utils/abort.ts +36 -0
  60. package/src/utils/anthropic-auth.ts +293 -0
  61. package/src/utils/discovery/antigravity.ts +261 -0
  62. package/src/utils/discovery/codex.ts +371 -0
  63. package/src/utils/discovery/cursor.ts +306 -0
  64. package/src/utils/discovery/gemini.ts +248 -0
  65. package/src/utils/discovery/index.ts +5 -0
  66. package/src/utils/discovery/openai-compatible.ts +224 -0
  67. package/src/utils/event-stream.ts +209 -0
  68. package/src/utils/http-inspector.ts +165 -0
  69. package/src/utils/idle-iterator.ts +176 -0
  70. package/src/utils/json-parse.ts +28 -0
  71. package/src/utils/oauth/alibaba-coding-plan.ts +59 -0
  72. package/src/utils/oauth/anthropic.ts +134 -0
  73. package/src/utils/oauth/api-key-validation.ts +92 -0
  74. package/src/utils/oauth/callback-server.ts +276 -0
  75. package/src/utils/oauth/cerebras.ts +59 -0
  76. package/src/utils/oauth/cloudflare-ai-gateway.ts +48 -0
  77. package/src/utils/oauth/cursor.ts +157 -0
  78. package/src/utils/oauth/github-copilot.ts +358 -0
  79. package/src/utils/oauth/gitlab-duo.ts +123 -0
  80. package/src/utils/oauth/google-antigravity.ts +275 -0
  81. package/src/utils/oauth/google-gemini-cli.ts +334 -0
  82. package/src/utils/oauth/huggingface.ts +62 -0
  83. package/src/utils/oauth/index.ts +512 -0
  84. package/src/utils/oauth/kagi.ts +47 -0
  85. package/src/utils/oauth/kilo.ts +87 -0
  86. package/src/utils/oauth/kimi.ts +251 -0
  87. package/src/utils/oauth/litellm.ts +81 -0
  88. package/src/utils/oauth/lm-studio.ts +40 -0
  89. package/src/utils/oauth/minimax-code.ts +78 -0
  90. package/src/utils/oauth/moonshot.ts +59 -0
  91. package/src/utils/oauth/nanogpt.ts +51 -0
  92. package/src/utils/oauth/nvidia.ts +70 -0
  93. package/src/utils/oauth/oauth.html +199 -0
  94. package/src/utils/oauth/ollama.ts +47 -0
  95. package/src/utils/oauth/openai-codex.ts +190 -0
  96. package/src/utils/oauth/opencode.ts +49 -0
  97. package/src/utils/oauth/parallel.ts +46 -0
  98. package/src/utils/oauth/perplexity.ts +200 -0
  99. package/src/utils/oauth/pkce.ts +18 -0
  100. package/src/utils/oauth/qianfan.ts +58 -0
  101. package/src/utils/oauth/qwen-portal.ts +60 -0
  102. package/src/utils/oauth/synthetic.ts +60 -0
  103. package/src/utils/oauth/tavily.ts +46 -0
  104. package/src/utils/oauth/together.ts +59 -0
  105. package/src/utils/oauth/types.ts +89 -0
  106. package/src/utils/oauth/venice.ts +59 -0
  107. package/src/utils/oauth/vercel-ai-gateway.ts +47 -0
  108. package/src/utils/oauth/vllm.ts +40 -0
  109. package/src/utils/oauth/xiaomi.ts +88 -0
  110. package/src/utils/oauth/zai.ts +60 -0
  111. package/src/utils/oauth/zenmux.ts +51 -0
  112. package/src/utils/overflow.ts +134 -0
  113. package/src/utils/retry-after.ts +110 -0
  114. package/src/utils/retry.ts +93 -0
  115. package/src/utils/schema/CONSTRAINTS.md +160 -0
  116. package/src/utils/schema/adapt.ts +20 -0
  117. package/src/utils/schema/compatibility.ts +397 -0
  118. package/src/utils/schema/dereference.ts +93 -0
  119. package/src/utils/schema/equality.ts +93 -0
  120. package/src/utils/schema/fields.ts +147 -0
  121. package/src/utils/schema/index.ts +9 -0
  122. package/src/utils/schema/normalize-cca.ts +479 -0
  123. package/src/utils/schema/sanitize-google.ts +212 -0
  124. package/src/utils/schema/strict-mode.ts +385 -0
  125. package/src/utils/schema/types.ts +5 -0
  126. package/src/utils/tool-choice.ts +81 -0
  127. package/src/utils/validation.ts +664 -0
  128. package/src/utils.ts +147 -0
@@ -0,0 +1,2694 @@
1
+ /**
2
+ * Credential storage for API keys and OAuth tokens.
3
+ * Handles loading, saving, refreshing credentials, and usage tracking.
4
+ *
5
+ * This module defines:
6
+ * - `AuthCredentialStore` interface: abstracting persistence (SQLite, memory, etc.)
7
+ * - `AuthStorage` class: credential management with round-robin, usage limits, OAuth refresh
8
+ * - `AuthCredentialStore`: concrete SQLite-backed implementation
9
+ */
10
+ import { Database, type Statement } from "bun:sqlite";
11
+ import * as fs from "node:fs/promises";
12
+ import * as path from "node:path";
13
+ import { getAgentDbPath, logger } from "@f5xc-salesdemos/pi-utils";
14
+ import { getEnvApiKey } from "./stream";
15
+ import type { Provider } from "./types";
16
+ import type {
17
+ CredentialRankingStrategy,
18
+ UsageCredential,
19
+ UsageLimit,
20
+ UsageLogger,
21
+ UsageProvider,
22
+ UsageReport,
23
+ } from "./usage";
24
+ import { claudeRankingStrategy, claudeUsageProvider } from "./usage/claude";
25
+ import { googleGeminiCliUsageProvider } from "./usage/gemini";
26
+ import { githubCopilotUsageProvider } from "./usage/github-copilot";
27
+ import { antigravityUsageProvider } from "./usage/google-antigravity";
28
+ import { kimiUsageProvider } from "./usage/kimi";
29
+ import { codexRankingStrategy, openaiCodexUsageProvider } from "./usage/openai-codex";
30
+ import { zaiUsageProvider } from "./usage/zai";
31
+ import { getOAuthApiKey, getOAuthProvider, refreshOAuthToken } from "./utils/oauth";
32
+ // Re-export login functions so consumers of AuthStorage.login() have access
33
+ // (these are used inside the login() switch-case)
34
+ import { loginAlibabaCodingPlan } from "./utils/oauth/alibaba-coding-plan";
35
+ import { loginAnthropic } from "./utils/oauth/anthropic";
36
+ import { loginCerebras } from "./utils/oauth/cerebras";
37
+ import { loginCloudflareAiGateway } from "./utils/oauth/cloudflare-ai-gateway";
38
+ import { loginCursor } from "./utils/oauth/cursor";
39
+ import { loginGitHubCopilot } from "./utils/oauth/github-copilot";
40
+ import { loginGitLabDuo } from "./utils/oauth/gitlab-duo";
41
+ import { loginAntigravity } from "./utils/oauth/google-antigravity";
42
+ import { loginGeminiCli } from "./utils/oauth/google-gemini-cli";
43
+ import { loginHuggingface } from "./utils/oauth/huggingface";
44
+ import { loginKagi } from "./utils/oauth/kagi";
45
+ import { loginKilo } from "./utils/oauth/kilo";
46
+ import { loginKimi } from "./utils/oauth/kimi";
47
+ import { loginLiteLLM } from "./utils/oauth/litellm";
48
+ import { loginLmStudio } from "./utils/oauth/lm-studio";
49
+ import { loginMiniMaxCode, loginMiniMaxCodeCn } from "./utils/oauth/minimax-code";
50
+ import { loginMoonshot } from "./utils/oauth/moonshot";
51
+ import { loginNanoGPT } from "./utils/oauth/nanogpt";
52
+ import { loginNvidia } from "./utils/oauth/nvidia";
53
+ import { loginOllama } from "./utils/oauth/ollama";
54
+ import { loginOpenAICodex } from "./utils/oauth/openai-codex";
55
+ import { loginOpenCode } from "./utils/oauth/opencode";
56
+ import { loginParallel } from "./utils/oauth/parallel";
57
+ import { loginPerplexity } from "./utils/oauth/perplexity";
58
+ import { loginQianfan } from "./utils/oauth/qianfan";
59
+ import { loginQwenPortal } from "./utils/oauth/qwen-portal";
60
+ import { loginSynthetic } from "./utils/oauth/synthetic";
61
+ import { loginTavily } from "./utils/oauth/tavily";
62
+ import { loginTogether } from "./utils/oauth/together";
63
+ import type { OAuthController, OAuthCredentials, OAuthProvider, OAuthProviderId } from "./utils/oauth/types";
64
+ import { loginVenice } from "./utils/oauth/venice";
65
+ import { loginVercelAiGateway } from "./utils/oauth/vercel-ai-gateway";
66
+ import { loginVllm } from "./utils/oauth/vllm";
67
+ import { loginXiaomi } from "./utils/oauth/xiaomi";
68
+ import { loginZai } from "./utils/oauth/zai";
69
+ import { loginZenMux } from "./utils/oauth/zenmux";
70
+
71
+ // ─────────────────────────────────────────────────────────────────────────────
72
+ // Credential Types
73
+ // ─────────────────────────────────────────────────────────────────────────────
74
+
75
+ export type ApiKeyCredential = {
76
+ type: "api_key";
77
+ key: string;
78
+ };
79
+
80
+ export type OAuthCredential = {
81
+ type: "oauth";
82
+ } & OAuthCredentials;
83
+
84
+ export type AuthCredential = ApiKeyCredential | OAuthCredential;
85
+
86
+ export type AuthCredentialEntry = AuthCredential | AuthCredential[];
87
+
88
+ export type AuthStorageData = Record<string, AuthCredentialEntry>;
89
+
90
+ /**
91
+ * Serialized representation of AuthStorage for passing to subagent workers.
92
+ * Contains only the essential credential data, not runtime state.
93
+ */
94
+ export interface SerializedAuthStorage {
95
+ credentials: Record<
96
+ string,
97
+ Array<{
98
+ id: number;
99
+ type: "api_key" | "oauth";
100
+ data: Record<string, unknown>;
101
+ }>
102
+ >;
103
+ runtimeOverrides?: Record<string, string>;
104
+ dbPath?: string;
105
+ }
106
+
107
+ /**
108
+ * Auth credential with database row ID for updates/deletes.
109
+ * Wraps AuthCredential with storage metadata.
110
+ */
111
+ export interface StoredAuthCredential {
112
+ id: number;
113
+ provider: string;
114
+ credential: AuthCredential;
115
+ disabledCause: string | null;
116
+ }
117
+
118
+ // ─────────────────────────────────────────────────────────────────────────────
119
+ // AuthStorage Options
120
+ // ─────────────────────────────────────────────────────────────────────────────
121
+
122
+ export type AuthStorageOptions = {
123
+ usageProviderResolver?: (provider: Provider) => UsageProvider | undefined;
124
+ rankingStrategyResolver?: (provider: Provider) => CredentialRankingStrategy | undefined;
125
+ usageFetch?: typeof fetch;
126
+ usageRequestTimeoutMs?: number;
127
+ usageLogger?: UsageLogger;
128
+ /**
129
+ * Resolve a config value (API key, header value, etc.) to an actual value.
130
+ * - coding-agent injects its resolveConfigValue (supports "!command" syntax via pi-natives)
131
+ * - Default: checks environment variable first, then treats as literal
132
+ */
133
+ configValueResolver?: (config: string) => Promise<string | undefined>;
134
+ };
135
+
136
+ // ─────────────────────────────────────────────────────────────────────────────
137
+ // Default Config Value Resolver
138
+ // ─────────────────────────────────────────────────────────────────────────────
139
+
140
+ /**
141
+ * Default config value resolver that checks env vars and treats as literal.
142
+ * Does NOT support "!command" syntax (that requires pi-natives).
143
+ */
144
+ async function defaultConfigValueResolver(config: string): Promise<string | undefined> {
145
+ const envValue = process.env[config];
146
+ return envValue || config;
147
+ }
148
+
149
+ // ─────────────────────────────────────────────────────────────────────────────
150
+ // Usage Providers (defaults)
151
+ // ─────────────────────────────────────────────────────────────────────────────
152
+
153
+ const DEFAULT_USAGE_PROVIDERS: UsageProvider[] = [
154
+ openaiCodexUsageProvider,
155
+ kimiUsageProvider,
156
+ antigravityUsageProvider,
157
+ googleGeminiCliUsageProvider,
158
+ claudeUsageProvider,
159
+ zaiUsageProvider,
160
+ githubCopilotUsageProvider,
161
+ ];
162
+
163
+ const DEFAULT_USAGE_PROVIDER_MAP = new Map<Provider, UsageProvider>(
164
+ DEFAULT_USAGE_PROVIDERS.map(provider => [provider.id, provider]),
165
+ );
166
+
167
+ const USAGE_CACHE_PREFIX = "usage_cache:";
168
+ const USAGE_REPORT_TTL_MS = 30_000;
169
+ const DEFAULT_USAGE_REQUEST_TIMEOUT_MS = 3_000;
170
+ const DEFAULT_OAUTH_REFRESH_TIMEOUT_MS = 10_000;
171
+
172
+ type UsageCacheEntry<T> = {
173
+ value: T;
174
+ expiresAt: number;
175
+ };
176
+
177
+ interface UsageCache {
178
+ get<T>(key: string): UsageCacheEntry<T> | undefined;
179
+ set<T>(key: string, entry: UsageCacheEntry<T>): void;
180
+ cleanup?(): void;
181
+ }
182
+
183
+ type UsageRequestDescriptor = {
184
+ provider: Provider;
185
+ credential: UsageCredential;
186
+ baseUrl?: string;
187
+ };
188
+
189
+ type AuthApiKeyOptions = {
190
+ baseUrl?: string;
191
+ modelId?: string;
192
+ };
193
+
194
+ function requiresOpenAICodexProModel(provider: string, modelId: string | undefined): boolean {
195
+ return provider === "openai-codex" && typeof modelId === "string" && modelId.includes("-spark");
196
+ }
197
+
198
+ function getUsagePlanType(report: UsageReport | null): string | undefined {
199
+ const metadata = report?.metadata;
200
+ if (!metadata || typeof metadata !== "object" || Array.isArray(metadata)) return undefined;
201
+ const planType = (metadata as { planType?: unknown }).planType;
202
+ return typeof planType === "string" ? planType.toLowerCase() : undefined;
203
+ }
204
+
205
+ function getOpenAICodexPlanPriority(report: UsageReport | null): number {
206
+ const planType = getUsagePlanType(report);
207
+ if (!planType) return 1;
208
+ return planType.includes("pro") ? 0 : 2;
209
+ }
210
+
211
+ function resolveDefaultUsageProvider(provider: Provider): UsageProvider | undefined {
212
+ return DEFAULT_USAGE_PROVIDER_MAP.get(provider);
213
+ }
214
+
215
+ const DEFAULT_RANKING_STRATEGIES = new Map<Provider, CredentialRankingStrategy>([
216
+ ["openai-codex", codexRankingStrategy],
217
+ ["anthropic", claudeRankingStrategy],
218
+ ]);
219
+
220
+ function resolveDefaultRankingStrategy(provider: Provider): CredentialRankingStrategy | undefined {
221
+ return DEFAULT_RANKING_STRATEGIES.get(provider);
222
+ }
223
+
224
+ function parseUsageCacheEntry<T>(raw: string): UsageCacheEntry<T> | undefined {
225
+ try {
226
+ const parsed = JSON.parse(raw) as { value?: T; expiresAt?: unknown };
227
+ const expiresAt = typeof parsed.expiresAt === "number" ? parsed.expiresAt : undefined;
228
+ if (!expiresAt || !Number.isFinite(expiresAt)) return undefined;
229
+ return { value: parsed.value as T, expiresAt };
230
+ } catch {
231
+ return undefined;
232
+ }
233
+ }
234
+
235
+ // ─────────────────────────────────────────────────────────────────────────────
236
+ // Usage Cache (backed by AuthCredentialStore)
237
+ // ─────────────────────────────────────────────────────────────────────────────
238
+
239
+ class AuthStorageUsageCache implements UsageCache {
240
+ constructor(private store: AuthCredentialStore) {}
241
+
242
+ get<T>(key: string): UsageCacheEntry<T> | undefined {
243
+ const raw = this.store.getCache(`${USAGE_CACHE_PREFIX}${key}`);
244
+ if (!raw) return undefined;
245
+ return parseUsageCacheEntry<T>(raw);
246
+ }
247
+
248
+ set<T>(key: string, entry: UsageCacheEntry<T>): void {
249
+ const payload = JSON.stringify({ value: entry.value, expiresAt: entry.expiresAt });
250
+ this.store.setCache(`${USAGE_CACHE_PREFIX}${key}`, payload, Math.floor(entry.expiresAt / 1000));
251
+ }
252
+
253
+ cleanup(): void {
254
+ this.store.cleanExpiredCache();
255
+ }
256
+ }
257
+
258
+ // ─────────────────────────────────────────────────────────────────────────────
259
+ // In-memory representation
260
+ // ─────────────────────────────────────────────────────────────────────────────
261
+
262
+ type StoredCredential = { id: number; credential: AuthCredential };
263
+
264
+ // ─────────────────────────────────────────────────────────────────────────────
265
+ // AuthStorage Class
266
+ // ─────────────────────────────────────────────────────────────────────────────
267
+
268
+ /**
269
+ * Credential storage backed by an AuthCredentialStore.
270
+ * Reads from storage on reload(), manages round-robin credential selection,
271
+ * usage limit tracking, and OAuth token refresh.
272
+ */
273
+ export class AuthStorage {
274
+ static readonly #defaultBackoffMs = 60_000; // Default backoff when no reset time available
275
+
276
+ /** Provider -> credentials cache, populated from store on reload(). */
277
+ #data: Map<string, StoredCredential[]> = new Map();
278
+ #runtimeOverrides: Map<string, string> = new Map();
279
+ /** Tracks next credential index per provider:type key for round-robin distribution (non-session use). */
280
+ #providerRoundRobinIndex: Map<string, number> = new Map();
281
+ /** Tracks the last used credential per provider for a session (used for rate-limit switching). */
282
+ #sessionLastCredential: Map<string, Map<string, { type: AuthCredential["type"]; index: number }>> = new Map();
283
+ /** Maps provider:type -> credentialIndex -> blockedUntilMs for temporary backoff. */
284
+ #credentialBackoff: Map<string, Map<number, number>> = new Map();
285
+ #usageProviderResolver?: (provider: Provider) => UsageProvider | undefined;
286
+ #rankingStrategyResolver?: (provider: Provider) => CredentialRankingStrategy | undefined;
287
+ #usageCache: UsageCache;
288
+ #usageRequestInFlight: Map<string, Promise<UsageReport | null>> = new Map();
289
+ #usageReportsInFlight: Map<string, Promise<UsageReport[]>> = new Map();
290
+ #usageFetch: typeof fetch;
291
+ #usageRequestTimeoutMs: number;
292
+ #usageLogger?: UsageLogger;
293
+ #fallbackResolver?: (provider: string) => string | undefined;
294
+ #store: AuthCredentialStore;
295
+ #configValueResolver: (config: string) => Promise<string | undefined>;
296
+ #closed = false;
297
+
298
+ constructor(store: AuthCredentialStore, options: AuthStorageOptions = {}) {
299
+ this.#store = store;
300
+ this.#configValueResolver = options.configValueResolver ?? defaultConfigValueResolver;
301
+ this.#usageProviderResolver = options.usageProviderResolver ?? resolveDefaultUsageProvider;
302
+ this.#rankingStrategyResolver = options.rankingStrategyResolver ?? resolveDefaultRankingStrategy;
303
+ this.#usageCache = new AuthStorageUsageCache(this.#store);
304
+ this.#usageFetch = options.usageFetch ?? fetch;
305
+ this.#usageRequestTimeoutMs = options.usageRequestTimeoutMs ?? DEFAULT_USAGE_REQUEST_TIMEOUT_MS;
306
+ this.#usageLogger =
307
+ options.usageLogger ??
308
+ ({
309
+ debug: (message, meta) => logger.debug(message, meta),
310
+ warn: (message, meta) => logger.warn(message, meta),
311
+ } satisfies UsageLogger);
312
+ }
313
+
314
+ /**
315
+ * Create an AuthStorage instance backed by a AuthCredentialStore.
316
+ * Convenience factory for standalone use (e.g., pi-ai CLI).
317
+ * @param dbPath - Path to SQLite database
318
+ */
319
+ static async create(dbPath: string, options: AuthStorageOptions = {}): Promise<AuthStorage> {
320
+ const store = await AuthCredentialStore.open(dbPath);
321
+ return new AuthStorage(store, options);
322
+ }
323
+
324
+ /**
325
+ * Close the underlying credential store.
326
+ *
327
+ * After calling this, the instance must not be reused.
328
+ */
329
+ close(): void {
330
+ if (this.#closed) return;
331
+ this.#closed = true;
332
+ this.#store.close();
333
+ }
334
+
335
+ /**
336
+ * Set a runtime API key override (not persisted to disk).
337
+ * Used for CLI --api-key flag.
338
+ */
339
+ setRuntimeApiKey(provider: string, apiKey: string): void {
340
+ this.#runtimeOverrides.set(provider, apiKey);
341
+ }
342
+
343
+ /**
344
+ * Remove a runtime API key override.
345
+ */
346
+ removeRuntimeApiKey(provider: string): void {
347
+ this.#runtimeOverrides.delete(provider);
348
+ }
349
+
350
+ /**
351
+ * Set a fallback resolver for API keys not found in storage or env vars.
352
+ * Used for custom provider keys from models.json.
353
+ */
354
+ setFallbackResolver(resolver: (provider: string) => string | undefined): void {
355
+ this.#fallbackResolver = resolver;
356
+ }
357
+
358
+ /**
359
+ * Reload credentials from storage.
360
+ */
361
+ async reload(): Promise<void> {
362
+ const records = this.#store.listAuthCredentials();
363
+ const grouped = new Map<string, StoredCredential[]>();
364
+ for (const record of records) {
365
+ const list = grouped.get(record.provider) ?? [];
366
+ list.push({ id: record.id, credential: record.credential });
367
+ grouped.set(record.provider, list);
368
+ }
369
+
370
+ const dedupedGrouped = new Map<string, StoredCredential[]>();
371
+ for (const [provider, entries] of grouped.entries()) {
372
+ const deduped = this.#pruneDuplicateStoredCredentials(provider, entries);
373
+ if (deduped.length > 0) {
374
+ dedupedGrouped.set(provider, deduped);
375
+ }
376
+ }
377
+ this.#data = dedupedGrouped;
378
+ }
379
+
380
+ /**
381
+ * Gets cached credentials for a provider.
382
+ * @param provider - Provider name (e.g., "anthropic", "openai")
383
+ * @returns Array of stored credentials, empty if none exist
384
+ */
385
+ #getStoredCredentials(provider: string): StoredCredential[] {
386
+ return this.#data.get(provider) ?? [];
387
+ }
388
+
389
+ /**
390
+ * Updates in-memory credential cache for a provider.
391
+ * Removes the provider entry entirely if credentials array is empty.
392
+ * @param provider - Provider name (e.g., "anthropic", "openai")
393
+ * @param credentials - Array of stored credentials to cache
394
+ */
395
+ #setStoredCredentials(provider: string, credentials: StoredCredential[]): void {
396
+ if (credentials.length === 0) {
397
+ this.#data.delete(provider);
398
+ } else {
399
+ this.#data.set(provider, credentials);
400
+ }
401
+ }
402
+
403
+ #resolveOAuthDedupeIdentityKey(provider: string, credential: OAuthCredential): string | null {
404
+ return resolveCredentialIdentityKey(provider, credential);
405
+ }
406
+
407
+ #dedupeOAuthCredentials(provider: string, credentials: AuthCredential[]): AuthCredential[] {
408
+ const seen = new Set<string>();
409
+ const deduped: AuthCredential[] = [];
410
+ for (let index = credentials.length - 1; index >= 0; index -= 1) {
411
+ const credential = credentials[index];
412
+ if (credential.type !== "oauth") {
413
+ deduped.push(credential);
414
+ continue;
415
+ }
416
+ const identityKey = this.#resolveOAuthDedupeIdentityKey(provider, credential);
417
+ if (!identityKey) {
418
+ deduped.push(credential);
419
+ continue;
420
+ }
421
+ if (seen.has(identityKey)) {
422
+ continue;
423
+ }
424
+ seen.add(identityKey);
425
+ deduped.push(credential);
426
+ }
427
+ return deduped.reverse();
428
+ }
429
+
430
+ #pruneDuplicateStoredCredentials(provider: string, entries: StoredCredential[]): StoredCredential[] {
431
+ const seen = new Set<string>();
432
+ const kept: StoredCredential[] = [];
433
+ const removed: StoredCredential[] = [];
434
+ for (let index = entries.length - 1; index >= 0; index -= 1) {
435
+ const entry = entries[index];
436
+ const credential = entry.credential;
437
+ if (credential.type !== "oauth") {
438
+ kept.push(entry);
439
+ continue;
440
+ }
441
+ const identityKey = this.#resolveOAuthDedupeIdentityKey(provider, credential);
442
+ if (!identityKey) {
443
+ kept.push(entry);
444
+ continue;
445
+ }
446
+ if (seen.has(identityKey)) {
447
+ removed.push(entry);
448
+ continue;
449
+ }
450
+ seen.add(identityKey);
451
+ kept.push(entry);
452
+ }
453
+ if (removed.length > 0) {
454
+ for (const entry of removed) {
455
+ this.#store.deleteAuthCredential(entry.id, "deduplicated duplicate credential");
456
+ }
457
+ this.#resetProviderAssignments(provider);
458
+ }
459
+ return kept.reverse();
460
+ }
461
+
462
+ /** Returns all credentials for a provider as an array */
463
+ #getCredentialsForProvider(provider: string): AuthCredential[] {
464
+ return this.#getStoredCredentials(provider).map(entry => entry.credential);
465
+ }
466
+
467
+ /** Composite key for round-robin tracking: "anthropic:oauth" or "openai:api_key" */
468
+ #getProviderTypeKey(provider: string, type: AuthCredential["type"]): string {
469
+ return `${provider}:${type}`;
470
+ }
471
+
472
+ /**
473
+ * Returns next index in round-robin sequence for load distribution.
474
+ * Increments stored counter and wraps at total.
475
+ */
476
+ #getNextRoundRobinIndex(providerKey: string, total: number): number {
477
+ if (total <= 1) return 0;
478
+ const current = this.#providerRoundRobinIndex.get(providerKey) ?? -1;
479
+ const next = (current + 1) % total;
480
+ this.#providerRoundRobinIndex.set(providerKey, next);
481
+ return next;
482
+ }
483
+
484
+ /**
485
+ * FNV-1a hash for deterministic session-to-credential mapping.
486
+ * Ensures the same session always starts with the same credential.
487
+ */
488
+ #getHashedIndex(sessionId: string, total: number): number {
489
+ if (total <= 1) return 0;
490
+ return Bun.hash.xxHash32(sessionId) % total;
491
+ }
492
+
493
+ /**
494
+ * Returns credential indices in priority order for selection.
495
+ * With sessionId: starts from hashed index (consistent per session).
496
+ * Without sessionId: starts from round-robin index (load balancing).
497
+ * Order wraps around so all credentials are tried if earlier ones are blocked.
498
+ */
499
+ #getCredentialOrder(providerKey: string, sessionId: string | undefined, total: number): number[] {
500
+ if (total <= 1) return [0];
501
+ const start = sessionId
502
+ ? this.#getHashedIndex(sessionId, total)
503
+ : this.#getNextRoundRobinIndex(providerKey, total);
504
+ const order: number[] = [];
505
+ for (let i = 0; i < total; i++) {
506
+ order.push((start + i) % total);
507
+ }
508
+ return order;
509
+ }
510
+
511
+ /** Returns block expiry timestamp for a credential, cleaning up expired entries. */
512
+ #getCredentialBlockedUntil(providerKey: string, credentialIndex: number): number | undefined {
513
+ const backoffMap = this.#credentialBackoff.get(providerKey);
514
+ if (!backoffMap) return undefined;
515
+ const blockedUntil = backoffMap.get(credentialIndex);
516
+ if (!blockedUntil) return undefined;
517
+ if (blockedUntil <= Date.now()) {
518
+ backoffMap.delete(credentialIndex);
519
+ if (backoffMap.size === 0) {
520
+ this.#credentialBackoff.delete(providerKey);
521
+ }
522
+ return undefined;
523
+ }
524
+ return blockedUntil;
525
+ }
526
+
527
+ /** Checks if a credential is temporarily blocked due to usage limits. */
528
+ #isCredentialBlocked(providerKey: string, credentialIndex: number): boolean {
529
+ return this.#getCredentialBlockedUntil(providerKey, credentialIndex) !== undefined;
530
+ }
531
+
532
+ /** Marks a credential as blocked until the specified time. */
533
+ #markCredentialBlocked(providerKey: string, credentialIndex: number, blockedUntilMs: number): void {
534
+ const backoffMap = this.#credentialBackoff.get(providerKey) ?? new Map<number, number>();
535
+ const existing = backoffMap.get(credentialIndex) ?? 0;
536
+ backoffMap.set(credentialIndex, Math.max(existing, blockedUntilMs));
537
+ this.#credentialBackoff.set(providerKey, backoffMap);
538
+ }
539
+
540
+ /** Records which credential was used for a session (for rate-limit switching). */
541
+ #recordSessionCredential(
542
+ provider: string,
543
+ sessionId: string | undefined,
544
+ type: AuthCredential["type"],
545
+ index: number,
546
+ ): void {
547
+ if (!sessionId) return;
548
+ const sessionMap = this.#sessionLastCredential.get(provider) ?? new Map();
549
+ sessionMap.set(sessionId, { type, index });
550
+ this.#sessionLastCredential.set(provider, sessionMap);
551
+ }
552
+
553
+ /** Retrieves the last credential used by a session. */
554
+ #getSessionCredential(
555
+ provider: string,
556
+ sessionId: string | undefined,
557
+ ): { type: AuthCredential["type"]; index: number } | undefined {
558
+ if (!sessionId) return undefined;
559
+ return this.#sessionLastCredential.get(provider)?.get(sessionId);
560
+ }
561
+
562
+ /**
563
+ * Selects a credential of the specified type for a provider.
564
+ * Returns both the credential and its index in the original array (for updates/removal).
565
+ * Uses deterministic hashing for session stickiness and skips blocked credentials when possible.
566
+ */
567
+ #selectCredentialByType<T extends AuthCredential["type"]>(
568
+ provider: string,
569
+ type: T,
570
+ sessionId?: string,
571
+ ): { credential: Extract<AuthCredential, { type: T }>; index: number } | undefined {
572
+ const credentials = this.#getCredentialsForProvider(provider)
573
+ .map((credential, index) => ({ credential, index }))
574
+ .filter(
575
+ (entry): entry is { credential: Extract<AuthCredential, { type: T }>; index: number } =>
576
+ entry.credential.type === type,
577
+ );
578
+
579
+ if (credentials.length === 0) return undefined;
580
+ if (credentials.length === 1) return credentials[0];
581
+
582
+ const providerKey = this.#getProviderTypeKey(provider, type);
583
+ const order = this.#getCredentialOrder(providerKey, sessionId, credentials.length);
584
+ const fallback = credentials[order[0]];
585
+
586
+ for (const idx of order) {
587
+ const candidate = credentials[idx];
588
+ if (!this.#isCredentialBlocked(providerKey, candidate.index)) {
589
+ return candidate;
590
+ }
591
+ }
592
+
593
+ return fallback;
594
+ }
595
+
596
+ /**
597
+ * Clears round-robin and session assignment state for a provider.
598
+ * Called when credentials are added/removed to prevent stale index references.
599
+ */
600
+ #resetProviderAssignments(provider: string): void {
601
+ for (const key of this.#providerRoundRobinIndex.keys()) {
602
+ if (key.startsWith(`${provider}:`)) {
603
+ this.#providerRoundRobinIndex.delete(key);
604
+ }
605
+ }
606
+ this.#sessionLastCredential.delete(provider);
607
+ for (const key of this.#credentialBackoff.keys()) {
608
+ if (key.startsWith(`${provider}:`)) {
609
+ this.#credentialBackoff.delete(key);
610
+ }
611
+ }
612
+ }
613
+
614
+ /** Updates credential at index in-place (used for OAuth token refresh) */
615
+ #replaceCredentialAt(provider: string, index: number, credential: AuthCredential): void {
616
+ const entries = this.#getStoredCredentials(provider);
617
+ if (index < 0 || index >= entries.length) return;
618
+ const target = entries[index];
619
+ this.#store.updateAuthCredential(target.id, credential);
620
+ const updated = [...entries];
621
+ updated[index] = { id: target.id, credential };
622
+ this.#setStoredCredentials(provider, updated);
623
+ }
624
+
625
+ /**
626
+ * Disables credential at index (used when OAuth refresh fails).
627
+ * The credential remains in the database but is excluded from active queries.
628
+ * Cleans up provider entry if last credential disabled.
629
+ */
630
+ #disableCredentialAt(provider: string, index: number, disabledCause: string): void {
631
+ const entries = this.#getStoredCredentials(provider);
632
+ if (index < 0 || index >= entries.length) return;
633
+ this.#store.deleteAuthCredential(entries[index].id, disabledCause);
634
+ const updated = entries.filter((_value, idx) => idx !== index);
635
+ this.#setStoredCredentials(provider, updated);
636
+ this.#resetProviderAssignments(provider);
637
+ }
638
+
639
+ /**
640
+ * Get credential for a provider (first entry if multiple).
641
+ */
642
+ get(provider: string): AuthCredential | undefined {
643
+ return this.#getCredentialsForProvider(provider)[0];
644
+ }
645
+
646
+ /**
647
+ * Set credential for a provider.
648
+ */
649
+ async set(provider: string, credential: AuthCredentialEntry): Promise<void> {
650
+ const normalized = Array.isArray(credential) ? credential : [credential];
651
+ const deduped = this.#dedupeOAuthCredentials(provider, normalized);
652
+ const stored = this.#store.replaceAuthCredentialsForProvider(provider, deduped);
653
+ this.#setStoredCredentials(
654
+ provider,
655
+ stored.map(record => ({ id: record.id, credential: record.credential })),
656
+ );
657
+ this.#resetProviderAssignments(provider);
658
+ }
659
+
660
+ async #upsertOAuthCredential(provider: string, credential: OAuthCredential): Promise<void> {
661
+ const stored = this.#store.upsertAuthCredentialForProvider(provider, credential);
662
+ this.#setStoredCredentials(
663
+ provider,
664
+ stored.map(record => ({ id: record.id, credential: record.credential })),
665
+ );
666
+ this.#resetProviderAssignments(provider);
667
+ }
668
+
669
+ /**
670
+ * Remove credential for a provider.
671
+ */
672
+ async remove(provider: string): Promise<void> {
673
+ this.#store.deleteAuthCredentialsForProvider(provider, "deleted by user");
674
+ this.#data.delete(provider);
675
+ this.#resetProviderAssignments(provider);
676
+ }
677
+
678
+ /**
679
+ * List all providers with credentials.
680
+ */
681
+ list(): string[] {
682
+ return [...this.#data.keys()];
683
+ }
684
+
685
+ /**
686
+ * Check if credentials exist for a provider in storage.
687
+ */
688
+ has(provider: string): boolean {
689
+ return this.#getCredentialsForProvider(provider).length > 0;
690
+ }
691
+
692
+ /**
693
+ * Check if any form of auth is configured for a provider.
694
+ * Unlike getApiKey(), this doesn't refresh OAuth tokens.
695
+ */
696
+ hasAuth(provider: string): boolean {
697
+ if (this.#runtimeOverrides.has(provider)) return true;
698
+ if (this.#getCredentialsForProvider(provider).length > 0) return true;
699
+ if (getEnvApiKey(provider)) return true;
700
+ if (this.#fallbackResolver?.(provider)) return true;
701
+ return false;
702
+ }
703
+
704
+ /**
705
+ * Check if OAuth credentials are configured for a provider.
706
+ */
707
+ hasOAuth(provider: string): boolean {
708
+ return this.#getCredentialsForProvider(provider).some(credential => credential.type === "oauth");
709
+ }
710
+
711
+ /**
712
+ * Get OAuth credentials for a provider.
713
+ */
714
+ getOAuthCredential(provider: string): OAuthCredential | undefined {
715
+ return this.#getCredentialsForProvider(provider).find(
716
+ (credential): credential is OAuthCredential => credential.type === "oauth",
717
+ );
718
+ }
719
+
720
+ /**
721
+ * Get all credentials.
722
+ */
723
+ getAll(): AuthStorageData {
724
+ const result: AuthStorageData = {};
725
+ for (const [provider, entries] of this.#data.entries()) {
726
+ const credentials = entries.map(entry => entry.credential);
727
+ if (credentials.length === 1) {
728
+ result[provider] = credentials[0];
729
+ } else if (credentials.length > 1) {
730
+ result[provider] = credentials;
731
+ }
732
+ }
733
+ return result;
734
+ }
735
+
736
+ /**
737
+ * Login to an OAuth provider.
738
+ */
739
+ async login(
740
+ provider: OAuthProviderId,
741
+ ctrl: OAuthController & {
742
+ /** onAuth is required by auth-storage but optional in OAuthController */
743
+ onAuth: (info: { url: string; instructions?: string }) => void;
744
+ /** onPrompt is required for some providers (github-copilot, openai-codex) */
745
+ onPrompt: (prompt: { message: string; placeholder?: string }) => Promise<string>;
746
+ },
747
+ ): Promise<void> {
748
+ let credentials: OAuthCredentials;
749
+ const saveApiKeyCredential = async (apiKey: string): Promise<void> => {
750
+ const newCredential: ApiKeyCredential = { type: "api_key", key: apiKey };
751
+ await this.set(provider, newCredential);
752
+ };
753
+ const manualCodeInput = () => ctrl.onPrompt({ message: "Paste the authorization code (or full redirect URL):" });
754
+ switch (provider) {
755
+ case "anthropic":
756
+ credentials = await loginAnthropic({
757
+ ...ctrl,
758
+ onManualCodeInput: ctrl.onManualCodeInput ?? manualCodeInput,
759
+ });
760
+ break;
761
+ case "alibaba-coding-plan": {
762
+ const apiKey = await loginAlibabaCodingPlan(ctrl);
763
+ await saveApiKeyCredential(apiKey);
764
+ return;
765
+ }
766
+ case "github-copilot":
767
+ credentials = await loginGitHubCopilot({
768
+ onAuth: (url, instructions) => ctrl.onAuth({ url, instructions }),
769
+ onPrompt: ctrl.onPrompt,
770
+ onProgress: ctrl.onProgress,
771
+ signal: ctrl.signal,
772
+ });
773
+ break;
774
+ case "google-gemini-cli":
775
+ credentials = await loginGeminiCli({
776
+ ...ctrl,
777
+ onManualCodeInput: ctrl.onManualCodeInput ?? manualCodeInput,
778
+ });
779
+ break;
780
+ case "google-antigravity":
781
+ credentials = await loginAntigravity({
782
+ ...ctrl,
783
+ onManualCodeInput: ctrl.onManualCodeInput ?? manualCodeInput,
784
+ });
785
+ break;
786
+ case "openai-codex":
787
+ credentials = await loginOpenAICodex({
788
+ ...ctrl,
789
+ onManualCodeInput: ctrl.onManualCodeInput ?? manualCodeInput,
790
+ });
791
+ break;
792
+ case "gitlab-duo":
793
+ credentials = await loginGitLabDuo({
794
+ ...ctrl,
795
+ onManualCodeInput: ctrl.onManualCodeInput ?? manualCodeInput,
796
+ });
797
+ break;
798
+ case "kimi-code":
799
+ credentials = await loginKimi(ctrl);
800
+ break;
801
+ case "kilo":
802
+ credentials = await loginKilo(ctrl);
803
+ break;
804
+ case "cursor":
805
+ credentials = await loginCursor(
806
+ url => ctrl.onAuth({ url }),
807
+ ctrl.onProgress ? () => ctrl.onProgress?.("Waiting for browser authentication...") : undefined,
808
+ );
809
+ break;
810
+ case "perplexity":
811
+ credentials = await loginPerplexity(ctrl);
812
+ break;
813
+ case "huggingface": {
814
+ const apiKey = await loginHuggingface(ctrl);
815
+ await saveApiKeyCredential(apiKey);
816
+ return;
817
+ }
818
+ case "opencode-zen":
819
+ case "opencode-go": {
820
+ const apiKey = await loginOpenCode(ctrl);
821
+ await saveApiKeyCredential(apiKey);
822
+ return;
823
+ }
824
+ case "lm-studio": {
825
+ const apiKey = await loginLmStudio(ctrl);
826
+ await saveApiKeyCredential(apiKey);
827
+ return;
828
+ }
829
+ case "ollama": {
830
+ const apiKey = await loginOllama(ctrl);
831
+ if (!apiKey) {
832
+ return;
833
+ }
834
+ await saveApiKeyCredential(apiKey);
835
+ return;
836
+ }
837
+ case "cerebras": {
838
+ const apiKey = await loginCerebras(ctrl);
839
+ await saveApiKeyCredential(apiKey);
840
+ return;
841
+ }
842
+ case "zai": {
843
+ const apiKey = await loginZai(ctrl);
844
+ await saveApiKeyCredential(apiKey);
845
+ return;
846
+ }
847
+ case "qianfan": {
848
+ const apiKey = await loginQianfan(ctrl);
849
+ await saveApiKeyCredential(apiKey);
850
+ return;
851
+ }
852
+ case "minimax-code": {
853
+ const apiKey = await loginMiniMaxCode(ctrl);
854
+ await saveApiKeyCredential(apiKey);
855
+ return;
856
+ }
857
+ case "minimax-code-cn": {
858
+ const apiKey = await loginMiniMaxCodeCn(ctrl);
859
+ await saveApiKeyCredential(apiKey);
860
+ return;
861
+ }
862
+ case "synthetic": {
863
+ const apiKey = await loginSynthetic(ctrl);
864
+ await saveApiKeyCredential(apiKey);
865
+ return;
866
+ }
867
+ case "tavily": {
868
+ const apiKey = await loginTavily(ctrl);
869
+ await saveApiKeyCredential(apiKey);
870
+ return;
871
+ }
872
+ case "venice": {
873
+ const apiKey = await loginVenice(ctrl);
874
+ await saveApiKeyCredential(apiKey);
875
+ return;
876
+ }
877
+ case "litellm": {
878
+ const result = await loginLiteLLM(ctrl);
879
+ await saveApiKeyCredential(result.apiKey);
880
+ return;
881
+ }
882
+ case "moonshot": {
883
+ const apiKey = await loginMoonshot(ctrl);
884
+ await saveApiKeyCredential(apiKey);
885
+ return;
886
+ }
887
+ case "kagi": {
888
+ const apiKey = await loginKagi(ctrl);
889
+ await saveApiKeyCredential(apiKey);
890
+ return;
891
+ }
892
+ case "nanogpt": {
893
+ const apiKey = await loginNanoGPT(ctrl);
894
+ await saveApiKeyCredential(apiKey);
895
+ return;
896
+ }
897
+ case "together": {
898
+ const apiKey = await loginTogether(ctrl);
899
+ await saveApiKeyCredential(apiKey);
900
+ return;
901
+ }
902
+ case "cloudflare-ai-gateway": {
903
+ const apiKey = await loginCloudflareAiGateway(ctrl);
904
+ await saveApiKeyCredential(apiKey);
905
+ return;
906
+ }
907
+ case "vercel-ai-gateway": {
908
+ const apiKey = await loginVercelAiGateway(ctrl);
909
+ await saveApiKeyCredential(apiKey);
910
+ return;
911
+ }
912
+ case "vllm": {
913
+ const apiKey = await loginVllm(ctrl);
914
+ await saveApiKeyCredential(apiKey);
915
+ return;
916
+ }
917
+ case "parallel": {
918
+ const apiKey = await loginParallel(ctrl);
919
+ await saveApiKeyCredential(apiKey);
920
+ return;
921
+ }
922
+ case "qwen-portal": {
923
+ const apiKey = await loginQwenPortal(ctrl);
924
+ await saveApiKeyCredential(apiKey);
925
+ return;
926
+ }
927
+ case "nvidia": {
928
+ const apiKey = await loginNvidia(ctrl);
929
+ await saveApiKeyCredential(apiKey);
930
+ return;
931
+ }
932
+ case "xiaomi": {
933
+ const apiKey = await loginXiaomi(ctrl);
934
+ await saveApiKeyCredential(apiKey);
935
+ return;
936
+ }
937
+ case "zenmux": {
938
+ const apiKey = await loginZenMux(ctrl);
939
+ await saveApiKeyCredential(apiKey);
940
+ return;
941
+ }
942
+ default: {
943
+ const customProvider = getOAuthProvider(provider);
944
+ if (!customProvider) {
945
+ throw new Error(`Unknown OAuth provider: ${provider}`);
946
+ }
947
+ const customLoginResult = await customProvider.login({
948
+ onAuth: info => ctrl.onAuth(info),
949
+ onProgress: ctrl.onProgress,
950
+ onPrompt: ctrl.onPrompt,
951
+ onManualCodeInput: ctrl.onManualCodeInput ?? manualCodeInput,
952
+ signal: ctrl.signal,
953
+ });
954
+ if (typeof customLoginResult === "string") {
955
+ await saveApiKeyCredential(customLoginResult);
956
+ return;
957
+ }
958
+ credentials = customLoginResult;
959
+ break;
960
+ }
961
+ }
962
+ const newCredential: OAuthCredential = { type: "oauth", ...credentials };
963
+ await this.#upsertOAuthCredential(provider, newCredential);
964
+ }
965
+
966
+ /**
967
+ * Logout from a provider.
968
+ */
969
+ async logout(provider: string): Promise<void> {
970
+ await this.remove(provider);
971
+ }
972
+
973
+ // ─────────────────────────────────────────────────────────────────────────────
974
+ // Usage API Integration
975
+ // Queries provider usage endpoints to detect rate limits before they occur.
976
+ // ─────────────────────────────────────────────────────────────────────────────
977
+
978
+ #buildUsageCredential(credential: OAuthCredential): UsageCredential {
979
+ return {
980
+ type: "oauth",
981
+ accessToken: credential.access,
982
+ refreshToken: credential.refresh,
983
+ expiresAt: credential.expires,
984
+ accountId: credential.accountId,
985
+ projectId: credential.projectId,
986
+ email: credential.email,
987
+ enterpriseUrl: credential.enterpriseUrl,
988
+ };
989
+ }
990
+
991
+ #buildUsageCacheIdentity(credential: UsageCredential): string {
992
+ const parts: string[] = [credential.type];
993
+ const accountId = credential.accountId?.trim();
994
+ if (accountId) parts.push(`account:${accountId}`);
995
+ const email = credential.email?.trim().toLowerCase();
996
+ if (email) parts.push(`email:${email}`);
997
+ const projectId = credential.projectId?.trim();
998
+ if (projectId) parts.push(`project:${projectId}`);
999
+ const enterpriseUrl = credential.enterpriseUrl?.trim().toLowerCase();
1000
+ if (enterpriseUrl) parts.push(`enterprise:${enterpriseUrl}`);
1001
+ // Only fall back to a secret-derived key when a stable account identifier is unavailable.
1002
+ // Including the token hash when accountId/email are present causes cache misses on
1003
+ // every OAuth refresh — usage data is per-account, not per-token.
1004
+ const hasStableIdentifier = Boolean(accountId || email);
1005
+ if (!hasStableIdentifier) {
1006
+ const secret = credential.apiKey?.trim() || credential.refreshToken?.trim() || credential.accessToken?.trim();
1007
+ if (secret) {
1008
+ parts.push(`secret:${Bun.hash(secret).toString(16)}`);
1009
+ } else {
1010
+ parts.push("anonymous");
1011
+ }
1012
+ }
1013
+ return parts.join("|");
1014
+ }
1015
+
1016
+ #normalizeUsageBaseUrl(baseUrl?: string): string {
1017
+ return baseUrl?.trim().replace(/\/+$/, "") ?? "";
1018
+ }
1019
+
1020
+ #buildUsageReportCacheKey(request: UsageRequestDescriptor): string {
1021
+ const baseUrl = this.#normalizeUsageBaseUrl(request.baseUrl) || "default";
1022
+ const identity = this.#buildUsageCacheIdentity(request.credential);
1023
+ return `report:${request.provider}:${baseUrl}:${identity}`;
1024
+ }
1025
+
1026
+ #buildUsageReportsCacheKey(requests: ReadonlyArray<UsageRequestDescriptor>): string {
1027
+ const snapshot = requests
1028
+ .map(
1029
+ request =>
1030
+ `${request.provider}:${this.#normalizeUsageBaseUrl(request.baseUrl) || "default"}:${this.#buildUsageCacheIdentity(request.credential)}`,
1031
+ )
1032
+ .sort()
1033
+ .join("\n");
1034
+ return `reports:${Bun.hash(snapshot).toString(16)}`;
1035
+ }
1036
+
1037
+ #buildUsageRequest(provider: Provider, credential: UsageCredential, baseUrl?: string): UsageRequestDescriptor {
1038
+ return { provider, credential, baseUrl };
1039
+ }
1040
+
1041
+ #buildUsageRequestForOauth(
1042
+ provider: Provider,
1043
+ credential: OAuthCredential,
1044
+ baseUrl?: string,
1045
+ ): UsageRequestDescriptor {
1046
+ return this.#buildUsageRequest(provider, this.#buildUsageCredential(credential), baseUrl);
1047
+ }
1048
+
1049
+ #buildRefreshableOauthCredential(credential: UsageCredential): OAuthCredential | null {
1050
+ if (!credential.accessToken || !credential.refreshToken || credential.expiresAt === undefined) {
1051
+ return null;
1052
+ }
1053
+ return {
1054
+ type: "oauth",
1055
+ access: credential.accessToken,
1056
+ refresh: credential.refreshToken,
1057
+ expires: credential.expiresAt,
1058
+ accountId: credential.accountId,
1059
+ projectId: credential.projectId,
1060
+ email: credential.email,
1061
+ enterpriseUrl: credential.enterpriseUrl,
1062
+ };
1063
+ }
1064
+
1065
+ #mergeRefreshedUsageCredential(credential: UsageCredential, refreshed: OAuthCredentials): UsageCredential {
1066
+ return {
1067
+ ...credential,
1068
+ accessToken: refreshed.access,
1069
+ refreshToken: refreshed.refresh,
1070
+ expiresAt: refreshed.expires,
1071
+ accountId: refreshed.accountId ?? credential.accountId,
1072
+ projectId: refreshed.projectId ?? credential.projectId,
1073
+ email: refreshed.email ?? credential.email,
1074
+ enterpriseUrl: refreshed.enterpriseUrl ?? credential.enterpriseUrl,
1075
+ };
1076
+ }
1077
+
1078
+ #persistRefreshedUsageCredential(provider: Provider, previous: UsageCredential, next: UsageCredential): void {
1079
+ const entries = this.#getStoredCredentials(provider);
1080
+ const index = entries.findIndex(entry => {
1081
+ if (entry.credential.type !== "oauth") return false;
1082
+ if (previous.refreshToken && entry.credential.refresh === previous.refreshToken) return true;
1083
+ if (previous.accessToken && entry.credential.access === previous.accessToken) return true;
1084
+ return (
1085
+ entry.credential.accountId === previous.accountId &&
1086
+ entry.credential.email === previous.email &&
1087
+ entry.credential.projectId === previous.projectId
1088
+ );
1089
+ });
1090
+ if (index === -1) return;
1091
+ const existing = entries[index]!.credential;
1092
+ if (existing.type !== "oauth") return;
1093
+ this.#replaceCredentialAt(provider, index, {
1094
+ type: "oauth",
1095
+ access: next.accessToken ?? existing.access,
1096
+ refresh: next.refreshToken ?? existing.refresh,
1097
+ expires: next.expiresAt ?? existing.expires,
1098
+ accountId: next.accountId,
1099
+ projectId: next.projectId,
1100
+ email: next.email,
1101
+ enterpriseUrl: next.enterpriseUrl,
1102
+ });
1103
+ }
1104
+
1105
+ async #fetchUsageUncached(request: UsageRequestDescriptor, timeoutMs?: number): Promise<UsageReport | null> {
1106
+ const resolver = this.#usageProviderResolver;
1107
+ if (!resolver) return null;
1108
+
1109
+ const providerImpl = resolver(request.provider);
1110
+ if (!providerImpl) return null;
1111
+
1112
+ const timeoutSignal =
1113
+ typeof timeoutMs === "number" && Number.isFinite(timeoutMs) && timeoutMs > 0
1114
+ ? AbortSignal.timeout(timeoutMs)
1115
+ : undefined;
1116
+ let params: UsageRequestDescriptor & { signal?: AbortSignal } = { ...request, signal: timeoutSignal };
1117
+
1118
+ if (
1119
+ request.credential.type === "oauth" &&
1120
+ request.credential.expiresAt !== undefined &&
1121
+ Date.now() >= request.credential.expiresAt
1122
+ ) {
1123
+ const refreshableCredential = this.#buildRefreshableOauthCredential(request.credential);
1124
+ if (refreshableCredential) {
1125
+ try {
1126
+ const refreshed = await this.#refreshOAuthCredential(request.provider, refreshableCredential);
1127
+ const refreshedCredential = this.#mergeRefreshedUsageCredential(request.credential, refreshed);
1128
+ this.#persistRefreshedUsageCredential(request.provider, request.credential, refreshedCredential);
1129
+ params = {
1130
+ ...params,
1131
+ credential: refreshedCredential,
1132
+ };
1133
+ } catch (error) {
1134
+ this.#usageLogger?.debug("Usage credential refresh failed, using original credential", {
1135
+ provider: request.provider,
1136
+ error: String(error),
1137
+ });
1138
+ }
1139
+ }
1140
+ }
1141
+
1142
+ if (providerImpl.supports && !providerImpl.supports(params)) return null;
1143
+
1144
+ try {
1145
+ return await providerImpl.fetchUsage(params, {
1146
+ fetch: this.#usageFetch,
1147
+ logger: this.#usageLogger,
1148
+ });
1149
+ } catch (error) {
1150
+ logger.debug("AuthStorage usage fetch failed", {
1151
+ provider: request.provider,
1152
+ error: String(error),
1153
+ });
1154
+ return null;
1155
+ }
1156
+ }
1157
+
1158
+ async #fetchUsageCached(request: UsageRequestDescriptor, timeoutMs?: number): Promise<UsageReport | null> {
1159
+ const cacheKey = this.#buildUsageReportCacheKey(request);
1160
+ const now = Date.now();
1161
+ const cached = this.#usageCache.get<UsageReport | null>(cacheKey);
1162
+ if (cached && cached.expiresAt > now) {
1163
+ return cached.value;
1164
+ }
1165
+
1166
+ const inFlight = this.#usageRequestInFlight.get(cacheKey);
1167
+ if (inFlight) return inFlight;
1168
+
1169
+ const promise = (async () => {
1170
+ const report = await this.#fetchUsageUncached(request, timeoutMs);
1171
+ if (report !== null) {
1172
+ this.#usageCache.set(cacheKey, { value: report, expiresAt: Date.now() + USAGE_REPORT_TTL_MS });
1173
+ return report;
1174
+ }
1175
+ return cached?.value ?? null;
1176
+ })().finally(() => {
1177
+ this.#usageRequestInFlight.delete(cacheKey);
1178
+ });
1179
+
1180
+ this.#usageRequestInFlight.set(cacheKey, promise);
1181
+ return promise;
1182
+ }
1183
+
1184
+ #collectUsageRequests(options?: {
1185
+ baseUrlResolver?: (provider: Provider) => string | undefined;
1186
+ }): UsageRequestDescriptor[] {
1187
+ const resolver = this.#usageProviderResolver;
1188
+ if (!resolver) return [];
1189
+
1190
+ const requests: UsageRequestDescriptor[] = [];
1191
+ const providers = new Set<string>([
1192
+ ...this.#data.keys(),
1193
+ ...DEFAULT_USAGE_PROVIDERS.map(provider => provider.id),
1194
+ ]);
1195
+
1196
+ for (const providerId of providers) {
1197
+ const provider = providerId as Provider;
1198
+ const providerImpl = resolver(provider);
1199
+ if (!providerImpl) continue;
1200
+ const baseUrl = options?.baseUrlResolver?.(provider);
1201
+ let entries = this.#getStoredCredentials(providerId);
1202
+ if (entries.length > 0) {
1203
+ const dedupedEntries = this.#pruneDuplicateStoredCredentials(providerId, entries);
1204
+ if (dedupedEntries.length !== entries.length) {
1205
+ this.#setStoredCredentials(providerId, dedupedEntries);
1206
+ }
1207
+ entries = dedupedEntries;
1208
+ }
1209
+
1210
+ if (entries.length === 0) {
1211
+ const runtimeKey = this.#runtimeOverrides.get(providerId);
1212
+ const envKey = getEnvApiKey(providerId);
1213
+ const apiKey = runtimeKey ?? envKey;
1214
+ if (!apiKey) continue;
1215
+ const request = this.#buildUsageRequest(provider, { type: "api_key", apiKey }, baseUrl);
1216
+ if (providerImpl.supports && !providerImpl.supports(request)) continue;
1217
+ requests.push(request);
1218
+ continue;
1219
+ }
1220
+
1221
+ for (const entry of entries) {
1222
+ const credential = entry.credential;
1223
+ const request =
1224
+ credential.type === "api_key"
1225
+ ? this.#buildUsageRequest(provider, { type: "api_key", apiKey: credential.key }, baseUrl)
1226
+ : this.#buildUsageRequestForOauth(provider, credential, baseUrl);
1227
+ if (providerImpl.supports && !providerImpl.supports(request)) continue;
1228
+ requests.push(request);
1229
+ }
1230
+ }
1231
+
1232
+ return requests;
1233
+ }
1234
+
1235
+ #getUsageReportMetadataValue(report: UsageReport, key: string): string | undefined {
1236
+ const metadata = report.metadata;
1237
+ if (!metadata || typeof metadata !== "object") return undefined;
1238
+ const value = metadata[key];
1239
+ return typeof value === "string" ? value.trim() : undefined;
1240
+ }
1241
+
1242
+ #getUsageReportScopeAccountId(report: UsageReport): string | undefined {
1243
+ const ids = new Set<string>();
1244
+ for (const limit of report.limits) {
1245
+ const accountId = limit.scope.accountId?.trim();
1246
+ if (accountId) ids.add(accountId);
1247
+ }
1248
+ if (ids.size === 1) return [...ids][0];
1249
+ return undefined;
1250
+ }
1251
+
1252
+ #getUsageReportIdentifiers(report: UsageReport): string[] {
1253
+ const identifiers: string[] = [];
1254
+ const email = this.#getUsageReportMetadataValue(report, "email");
1255
+ if (email) identifiers.push(`email:${email.toLowerCase()}`);
1256
+ if (report.provider === "openai-codex" || report.provider === "anthropic") {
1257
+ return identifiers.map(identifier => `${report.provider}:${identifier.toLowerCase()}`);
1258
+ }
1259
+ const accountId = this.#getUsageReportMetadataValue(report, "accountId");
1260
+ if (accountId) identifiers.push(`account:${accountId}`);
1261
+ const account = this.#getUsageReportMetadataValue(report, "account");
1262
+ if (account) identifiers.push(`account:${account}`);
1263
+ const user = this.#getUsageReportMetadataValue(report, "user");
1264
+ if (user) identifiers.push(`account:${user}`);
1265
+ const username = this.#getUsageReportMetadataValue(report, "username");
1266
+ if (username) identifiers.push(`account:${username}`);
1267
+ const scopeAccountId = this.#getUsageReportScopeAccountId(report);
1268
+ if (scopeAccountId) identifiers.push(`account:${scopeAccountId}`);
1269
+ return identifiers.map(identifier => `${report.provider}:${identifier.toLowerCase()}`);
1270
+ }
1271
+
1272
+ #mergeUsageReportGroup(reports: UsageReport[]): UsageReport {
1273
+ if (reports.length === 1) return reports[0];
1274
+ const sorted = [...reports].sort((a, b) => {
1275
+ const limitDiff = b.limits.length - a.limits.length;
1276
+ if (limitDiff !== 0) return limitDiff;
1277
+ return (b.fetchedAt ?? 0) - (a.fetchedAt ?? 0);
1278
+ });
1279
+ const base = sorted[0];
1280
+ const mergedLimits = [...base.limits];
1281
+ const limitIds = new Set(mergedLimits.map(limit => limit.id));
1282
+ const mergedMetadata: Record<string, unknown> = { ...(base.metadata ?? {}) };
1283
+ let fetchedAt = base.fetchedAt;
1284
+
1285
+ for (const report of sorted.slice(1)) {
1286
+ fetchedAt = Math.max(fetchedAt, report.fetchedAt);
1287
+ for (const limit of report.limits) {
1288
+ if (!limitIds.has(limit.id)) {
1289
+ limitIds.add(limit.id);
1290
+ mergedLimits.push(limit);
1291
+ }
1292
+ }
1293
+ if (report.metadata) {
1294
+ for (const [key, value] of Object.entries(report.metadata)) {
1295
+ if (mergedMetadata[key] === undefined) {
1296
+ mergedMetadata[key] = value;
1297
+ }
1298
+ }
1299
+ }
1300
+ }
1301
+
1302
+ return {
1303
+ ...base,
1304
+ fetchedAt,
1305
+ limits: mergedLimits,
1306
+ metadata: Object.keys(mergedMetadata).length > 0 ? mergedMetadata : undefined,
1307
+ };
1308
+ }
1309
+
1310
+ #dedupeUsageReports(reports: UsageReport[]): UsageReport[] {
1311
+ const groups: UsageReport[][] = [];
1312
+ const idToGroup = new Map<string, number>();
1313
+
1314
+ for (const report of reports) {
1315
+ const identifiers = this.#getUsageReportIdentifiers(report);
1316
+ let groupIndex: number | undefined;
1317
+ for (const identifier of identifiers) {
1318
+ const existing = idToGroup.get(identifier);
1319
+ if (existing !== undefined) {
1320
+ groupIndex = existing;
1321
+ break;
1322
+ }
1323
+ }
1324
+ if (groupIndex === undefined) {
1325
+ groupIndex = groups.length;
1326
+ groups.push([]);
1327
+ }
1328
+ groups[groupIndex].push(report);
1329
+ for (const identifier of identifiers) {
1330
+ idToGroup.set(identifier, groupIndex);
1331
+ }
1332
+ }
1333
+
1334
+ const deduped = groups.map(group => this.#mergeUsageReportGroup(group));
1335
+ if (deduped.length !== reports.length) {
1336
+ this.#usageLogger?.debug("Usage reports deduped", {
1337
+ before: reports.length,
1338
+ after: deduped.length,
1339
+ });
1340
+ }
1341
+ return deduped;
1342
+ }
1343
+
1344
+ #isUsageLimitExhausted(limit: UsageLimit): boolean {
1345
+ if (limit.status === "exhausted") return true;
1346
+ const amount = limit.amount;
1347
+ if (amount.usedFraction !== undefined && amount.usedFraction >= 1) return true;
1348
+ if (amount.remainingFraction !== undefined && amount.remainingFraction <= 0) return true;
1349
+ if (amount.used !== undefined && amount.limit !== undefined && amount.used >= amount.limit) return true;
1350
+ if (amount.remaining !== undefined && amount.remaining <= 0) return true;
1351
+ if (amount.unit === "percent" && amount.used !== undefined && amount.used >= 100) return true;
1352
+ return false;
1353
+ }
1354
+
1355
+ /** Returns true if usage indicates rate limit has been reached. */
1356
+ #isUsageLimitReached(report: UsageReport): boolean {
1357
+ return report.limits.some(limit => this.#isUsageLimitExhausted(limit));
1358
+ }
1359
+
1360
+ /** Extracts the earliest reset timestamp from exhausted windows (in ms). */
1361
+ #getUsageResetAtMs(report: UsageReport, nowMs: number): number | undefined {
1362
+ const candidates: number[] = [];
1363
+ for (const limit of report.limits) {
1364
+ if (!this.#isUsageLimitExhausted(limit)) continue;
1365
+ const window = limit.window;
1366
+ if (window?.resetsAt && window.resetsAt > nowMs) {
1367
+ candidates.push(window.resetsAt);
1368
+ }
1369
+ }
1370
+ if (candidates.length === 0) return undefined;
1371
+ return Math.min(...candidates);
1372
+ }
1373
+
1374
+ async #getUsageReport(
1375
+ provider: Provider,
1376
+ credential: OAuthCredential,
1377
+ options?: { baseUrl?: string; timeoutMs?: number },
1378
+ ): Promise<UsageReport | null> {
1379
+ return this.#fetchUsageCached(
1380
+ this.#buildUsageRequestForOauth(provider, credential, options?.baseUrl),
1381
+ options?.timeoutMs ?? this.#usageRequestTimeoutMs,
1382
+ );
1383
+ }
1384
+
1385
+ async fetchUsageReports(options?: {
1386
+ baseUrlResolver?: (provider: Provider) => string | undefined;
1387
+ }): Promise<UsageReport[] | null> {
1388
+ if (!this.#usageProviderResolver) return null;
1389
+
1390
+ const requests = this.#collectUsageRequests(options);
1391
+ if (requests.length === 0) return [];
1392
+
1393
+ this.#usageLogger?.debug("Usage fetch requested", {
1394
+ providers: [...new Set(requests.map(request => request.provider))].sort(),
1395
+ });
1396
+
1397
+ const cacheKey = this.#buildUsageReportsCacheKey(requests);
1398
+ const now = Date.now();
1399
+ const cached = this.#usageCache.get<UsageReport[]>(cacheKey);
1400
+ if (cached && cached.expiresAt > now) {
1401
+ return cached.value;
1402
+ }
1403
+
1404
+ const inFlight = this.#usageReportsInFlight.get(cacheKey);
1405
+ if (inFlight) return inFlight;
1406
+
1407
+ const promise = (async () => {
1408
+ for (const request of requests) {
1409
+ this.#usageLogger?.debug("Usage fetch queued", {
1410
+ provider: request.provider,
1411
+ credentialType: request.credential.type,
1412
+ baseUrl: request.baseUrl,
1413
+ accountId: request.credential.accountId,
1414
+ email: request.credential.email,
1415
+ });
1416
+ }
1417
+
1418
+ const results = await Promise.all(
1419
+ requests.map(request => this.#fetchUsageCached(request, this.#usageRequestTimeoutMs)),
1420
+ );
1421
+ const reports = results.filter((report): report is UsageReport => report !== null);
1422
+ const deduped = this.#dedupeUsageReports(reports);
1423
+ if (deduped.length > 0) {
1424
+ this.#usageCache.set(cacheKey, { value: deduped, expiresAt: Date.now() + USAGE_REPORT_TTL_MS });
1425
+ }
1426
+ const resolved = deduped.length > 0 ? deduped : (cached?.value ?? []);
1427
+ this.#usageLogger?.debug("Usage fetch resolved", {
1428
+ reports: resolved.map(report => {
1429
+ const accountLabel =
1430
+ this.#getUsageReportMetadataValue(report, "email") ??
1431
+ this.#getUsageReportMetadataValue(report, "accountId") ??
1432
+ this.#getUsageReportMetadataValue(report, "account") ??
1433
+ this.#getUsageReportMetadataValue(report, "user") ??
1434
+ this.#getUsageReportMetadataValue(report, "username") ??
1435
+ this.#getUsageReportScopeAccountId(report);
1436
+ return {
1437
+ provider: report.provider,
1438
+ limits: report.limits.length,
1439
+ account: accountLabel,
1440
+ };
1441
+ }),
1442
+ });
1443
+ return resolved;
1444
+ })().finally(() => {
1445
+ this.#usageReportsInFlight.delete(cacheKey);
1446
+ });
1447
+
1448
+ this.#usageReportsInFlight.set(cacheKey, promise);
1449
+ return promise;
1450
+ }
1451
+
1452
+ /**
1453
+ * Marks the current session's credential as temporarily blocked due to usage limits.
1454
+ * Uses usage reports to determine accurate reset time when available.
1455
+ * Returns true if a credential was blocked, enabling automatic fallback to the next credential.
1456
+ */
1457
+ async markUsageLimitReached(
1458
+ provider: string,
1459
+ sessionId: string | undefined,
1460
+ options?: { retryAfterMs?: number; baseUrl?: string },
1461
+ ): Promise<boolean> {
1462
+ const sessionCredential = this.#getSessionCredential(provider, sessionId);
1463
+ if (!sessionCredential) return false;
1464
+
1465
+ const providerKey = this.#getProviderTypeKey(provider, sessionCredential.type);
1466
+ const now = Date.now();
1467
+ let blockedUntil = now + (options?.retryAfterMs ?? AuthStorage.#defaultBackoffMs);
1468
+
1469
+ if (sessionCredential.type === "oauth" && this.#rankingStrategyResolver?.(provider)) {
1470
+ const credential = this.#getCredentialsForProvider(provider)[sessionCredential.index];
1471
+ if (credential?.type === "oauth") {
1472
+ const report = await this.#getUsageReport(provider, credential, options);
1473
+ if (report && this.#isUsageLimitReached(report)) {
1474
+ const resetAtMs = this.#getUsageResetAtMs(report, Date.now());
1475
+ if (resetAtMs && resetAtMs > blockedUntil) {
1476
+ blockedUntil = resetAtMs;
1477
+ }
1478
+ }
1479
+ }
1480
+ }
1481
+
1482
+ this.#markCredentialBlocked(providerKey, sessionCredential.index, blockedUntil);
1483
+
1484
+ const remainingCredentials = this.#getCredentialsForProvider(provider)
1485
+ .map((credential, index) => ({ credential, index }))
1486
+ .filter(
1487
+ (entry): entry is { credential: AuthCredential; index: number } =>
1488
+ entry.credential.type === sessionCredential.type && entry.index !== sessionCredential.index,
1489
+ );
1490
+
1491
+ return remainingCredentials.some(candidate => !this.#isCredentialBlocked(providerKey, candidate.index));
1492
+ }
1493
+
1494
+ #resolveWindowResetAt(window: UsageLimit["window"]): number | undefined {
1495
+ if (!window) return undefined;
1496
+ if (typeof window.resetsAt === "number" && Number.isFinite(window.resetsAt)) {
1497
+ return window.resetsAt;
1498
+ }
1499
+ return undefined;
1500
+ }
1501
+
1502
+ #normalizeUsageFraction(limit: UsageLimit | undefined): number {
1503
+ const usedFraction = limit?.amount.usedFraction;
1504
+ if (typeof usedFraction !== "number" || !Number.isFinite(usedFraction)) {
1505
+ return 0.5;
1506
+ }
1507
+ return Math.min(Math.max(usedFraction, 0), 1);
1508
+ }
1509
+
1510
+ /** Computes `usedFraction / elapsedHours` — consumption rate per hour within the current window. Lower drain rate = less pressure = preferred. */
1511
+ #computeWindowDrainRate(limit: UsageLimit | undefined, nowMs: number, fallbackDurationMs: number): number {
1512
+ const usedFraction = this.#normalizeUsageFraction(limit);
1513
+ const durationMs = limit?.window?.durationMs ?? fallbackDurationMs;
1514
+ if (!Number.isFinite(durationMs) || durationMs <= 0) {
1515
+ return usedFraction;
1516
+ }
1517
+ const resetAt = this.#resolveWindowResetAt(limit?.window);
1518
+ if (!Number.isFinite(resetAt)) {
1519
+ return usedFraction;
1520
+ }
1521
+ const remainingWindowMs = (resetAt as number) - nowMs;
1522
+ const clampedRemainingWindowMs = Math.min(Math.max(remainingWindowMs, 0), durationMs);
1523
+ const elapsedMs = durationMs - clampedRemainingWindowMs;
1524
+ if (elapsedMs <= 0) {
1525
+ return usedFraction;
1526
+ }
1527
+ const elapsedHours = elapsedMs / (60 * 60 * 1000);
1528
+ if (!Number.isFinite(elapsedHours) || elapsedHours <= 0) {
1529
+ return usedFraction;
1530
+ }
1531
+ return usedFraction / elapsedHours;
1532
+ }
1533
+
1534
+ async #rankOAuthSelections(args: {
1535
+ providerKey: string;
1536
+ provider: string;
1537
+ order: number[];
1538
+ credentials: Array<{ credential: OAuthCredential; index: number }>;
1539
+ options?: AuthApiKeyOptions;
1540
+ strategy: CredentialRankingStrategy;
1541
+ }): Promise<
1542
+ Array<{
1543
+ selection: { credential: OAuthCredential; index: number };
1544
+ usage: UsageReport | null;
1545
+ usageChecked: boolean;
1546
+ }>
1547
+ > {
1548
+ const nowMs = Date.now();
1549
+ const { strategy } = args;
1550
+ const ranked: Array<{
1551
+ selection: { credential: OAuthCredential; index: number };
1552
+ usage: UsageReport | null;
1553
+ usageChecked: boolean;
1554
+ blocked: boolean;
1555
+ blockedUntil?: number;
1556
+ hasPriorityBoost: boolean;
1557
+ secondaryUsed: number;
1558
+ secondaryDrainRate: number;
1559
+ primaryUsed: number;
1560
+ primaryDrainRate: number;
1561
+ orderPos: number;
1562
+ }> = [];
1563
+ // Pre-fetch usage reports in parallel for non-blocked credentials
1564
+ const usageResults = await Promise.all(
1565
+ args.order.map(async idx => {
1566
+ const selection = args.credentials[idx];
1567
+ if (!selection) return null;
1568
+ const blockedUntil = this.#getCredentialBlockedUntil(args.providerKey, selection.index);
1569
+ if (blockedUntil !== undefined) return { selection, usage: null, usageChecked: false, blockedUntil };
1570
+ const usage = await this.#getUsageReport(args.provider, selection.credential, {
1571
+ ...args.options,
1572
+ timeoutMs: this.#usageRequestTimeoutMs,
1573
+ });
1574
+ return { selection, usage, usageChecked: true, blockedUntil: undefined as number | undefined };
1575
+ }),
1576
+ );
1577
+
1578
+ for (let orderPos = 0; orderPos < usageResults.length; orderPos += 1) {
1579
+ const result = usageResults[orderPos];
1580
+ if (!result) continue;
1581
+ const { selection, usage, usageChecked } = result;
1582
+ let { blockedUntil } = result;
1583
+ let blocked = blockedUntil !== undefined;
1584
+ if (!blocked && usage && this.#isUsageLimitReached(usage)) {
1585
+ const resetAtMs = this.#getUsageResetAtMs(usage, nowMs);
1586
+ blockedUntil = resetAtMs ?? Date.now() + AuthStorage.#defaultBackoffMs;
1587
+ this.#markCredentialBlocked(args.providerKey, selection.index, blockedUntil);
1588
+ blocked = true;
1589
+ }
1590
+ const windows = usage ? strategy.findWindowLimits(usage) : undefined;
1591
+ const primary = windows?.primary;
1592
+ const secondary = windows?.secondary;
1593
+ const secondaryTarget = secondary ?? primary;
1594
+ ranked.push({
1595
+ selection,
1596
+ usage,
1597
+ usageChecked,
1598
+ blocked,
1599
+ blockedUntil,
1600
+ hasPriorityBoost: strategy.hasPriorityBoost?.(primary) ?? false,
1601
+ secondaryUsed: this.#normalizeUsageFraction(secondaryTarget),
1602
+ secondaryDrainRate: this.#computeWindowDrainRate(
1603
+ secondaryTarget,
1604
+ nowMs,
1605
+ strategy.windowDefaults.secondaryMs,
1606
+ ),
1607
+ primaryUsed: this.#normalizeUsageFraction(primary),
1608
+ primaryDrainRate: this.#computeWindowDrainRate(primary, nowMs, strategy.windowDefaults.primaryMs),
1609
+ orderPos,
1610
+ });
1611
+ }
1612
+ ranked.sort((left, right) => {
1613
+ if (left.blocked !== right.blocked) return left.blocked ? 1 : -1;
1614
+ if (left.blocked && right.blocked) {
1615
+ const leftBlockedUntil = left.blockedUntil ?? Number.POSITIVE_INFINITY;
1616
+ const rightBlockedUntil = right.blockedUntil ?? Number.POSITIVE_INFINITY;
1617
+ if (leftBlockedUntil !== rightBlockedUntil) return leftBlockedUntil - rightBlockedUntil;
1618
+ return left.orderPos - right.orderPos;
1619
+ }
1620
+ if (requiresOpenAICodexProModel(args.provider, args.options?.modelId)) {
1621
+ const leftPlanPriority = getOpenAICodexPlanPriority(left.usage);
1622
+ const rightPlanPriority = getOpenAICodexPlanPriority(right.usage);
1623
+ if (leftPlanPriority !== rightPlanPriority) return leftPlanPriority - rightPlanPriority;
1624
+ }
1625
+ if (left.hasPriorityBoost !== right.hasPriorityBoost) return left.hasPriorityBoost ? -1 : 1;
1626
+ if (left.secondaryDrainRate !== right.secondaryDrainRate)
1627
+ return left.secondaryDrainRate - right.secondaryDrainRate;
1628
+ if (left.secondaryUsed !== right.secondaryUsed) return left.secondaryUsed - right.secondaryUsed;
1629
+ if (left.primaryDrainRate !== right.primaryDrainRate) return left.primaryDrainRate - right.primaryDrainRate;
1630
+ if (left.primaryUsed !== right.primaryUsed) return left.primaryUsed - right.primaryUsed;
1631
+ return left.orderPos - right.orderPos;
1632
+ });
1633
+ return ranked.map(candidate => ({
1634
+ selection: candidate.selection,
1635
+ usage: candidate.usage,
1636
+ usageChecked: candidate.usageChecked,
1637
+ }));
1638
+ }
1639
+
1640
+ /**
1641
+ * Resolves an OAuth API key, trying credentials in priority order.
1642
+ * Skips blocked credentials and checks usage limits for providers with usage data.
1643
+ * Falls back to earliest-unblocking credential if all are blocked.
1644
+ */
1645
+ async #resolveOAuthApiKey(
1646
+ provider: string,
1647
+ sessionId?: string,
1648
+ options?: AuthApiKeyOptions,
1649
+ ): Promise<string | undefined> {
1650
+ const credentials = this.#getCredentialsForProvider(provider)
1651
+ .map((credential, index) => ({ credential, index }))
1652
+ .filter((entry): entry is { credential: OAuthCredential; index: number } => entry.credential.type === "oauth");
1653
+
1654
+ if (credentials.length === 0) return undefined;
1655
+
1656
+ const providerKey = this.#getProviderTypeKey(provider, "oauth");
1657
+ const order = this.#getCredentialOrder(providerKey, sessionId, credentials.length);
1658
+ const strategy = this.#rankingStrategyResolver?.(provider);
1659
+ const checkUsage = strategy !== undefined && credentials.length > 1;
1660
+ const sessionCredential = this.#getSessionCredential(provider, sessionId);
1661
+ const sessionPreferredIndex = sessionCredential?.type === "oauth" ? sessionCredential.index : undefined;
1662
+ // Skip ranking only when the session already has a working preferred credential — re-ranking
1663
+ // mid-session causes account switches that cold-start the server-side prompt cache. New sessions
1664
+ // (no preference) and sessions whose preferred is blocked still rank, so we pick the account
1665
+ // with the most headroom proactively and fall back intelligently when rate-limited.
1666
+ const requiresProModel = requiresOpenAICodexProModel(provider, options?.modelId);
1667
+ const sessionPreferredIsAvailable =
1668
+ sessionPreferredIndex !== undefined && !this.#isCredentialBlocked(providerKey, sessionPreferredIndex);
1669
+ const shouldRank = checkUsage && (!sessionPreferredIsAvailable || requiresProModel);
1670
+ const candidates = shouldRank
1671
+ ? await this.#rankOAuthSelections({ providerKey, provider, order, credentials, options, strategy: strategy! })
1672
+ : order
1673
+ .map(idx => credentials[idx])
1674
+ .filter((selection): selection is { credential: OAuthCredential; index: number } => Boolean(selection))
1675
+ .map(selection => ({ selection, usage: null, usageChecked: false }));
1676
+
1677
+ if (sessionPreferredIndex !== undefined && !requiresProModel) {
1678
+ const sessionPreferredCandidate = candidates.findIndex(
1679
+ candidate =>
1680
+ !this.#isCredentialBlocked(providerKey, candidate.selection.index) &&
1681
+ candidate.selection.index === sessionPreferredIndex,
1682
+ );
1683
+ if (sessionPreferredCandidate > 0) {
1684
+ const [preferred] = candidates.splice(sessionPreferredCandidate, 1);
1685
+ candidates.unshift(preferred);
1686
+ }
1687
+ }
1688
+ await Promise.all(
1689
+ candidates.map(async candidate => {
1690
+ if (Date.now() < candidate.selection.credential.expires) return;
1691
+ const latestCredential = this.#getCredentialsForProvider(provider)[candidate.selection.index];
1692
+ if (latestCredential?.type === "oauth" && Date.now() < latestCredential.expires) {
1693
+ candidate.selection.credential = latestCredential;
1694
+ return;
1695
+ }
1696
+ try {
1697
+ const refreshedCredentials = await this.#refreshOAuthCredential(
1698
+ provider,
1699
+ candidate.selection.credential,
1700
+ );
1701
+ candidate.selection.credential = {
1702
+ ...candidate.selection.credential,
1703
+ ...refreshedCredentials,
1704
+ type: "oauth",
1705
+ };
1706
+ } catch {}
1707
+ }),
1708
+ );
1709
+
1710
+ const fallback = candidates[0];
1711
+
1712
+ for (const candidate of candidates) {
1713
+ const apiKey = await this.#tryOAuthCredential(provider, candidate.selection, providerKey, sessionId, options, {
1714
+ checkUsage,
1715
+ allowBlocked: false,
1716
+ prefetchedUsage: candidate.usage,
1717
+ usagePrechecked: candidate.usageChecked,
1718
+ });
1719
+ if (apiKey) return apiKey;
1720
+ }
1721
+
1722
+ if (fallback && this.#isCredentialBlocked(providerKey, fallback.selection.index)) {
1723
+ return this.#tryOAuthCredential(provider, fallback.selection, providerKey, sessionId, options, {
1724
+ checkUsage,
1725
+ allowBlocked: true,
1726
+ prefetchedUsage: fallback.usage,
1727
+ usagePrechecked: fallback.usageChecked,
1728
+ });
1729
+ }
1730
+
1731
+ return undefined;
1732
+ }
1733
+
1734
+ async #refreshOAuthCredential(provider: Provider, credential: OAuthCredential): Promise<OAuthCredentials> {
1735
+ if (Date.now() < credential.expires) return credential;
1736
+ const customProvider = getOAuthProvider(provider);
1737
+ let refreshPromise: Promise<OAuthCredentials>;
1738
+ if (customProvider) {
1739
+ if (!customProvider.refreshToken) {
1740
+ throw new Error(`OAuth provider "${provider}" does not support token refresh`);
1741
+ }
1742
+ refreshPromise = customProvider.refreshToken(credential);
1743
+ } else {
1744
+ refreshPromise = refreshOAuthToken(provider as OAuthProvider, credential);
1745
+ }
1746
+ // Bound the refresh so a slow/hanging token endpoint cannot stall credential selection.
1747
+ let timeout: NodeJS.Timeout | undefined;
1748
+ const timeoutPromise = new Promise<never>((_, reject) => {
1749
+ timeout = setTimeout(
1750
+ () => reject(new Error(`OAuth token refresh timed out for provider: ${provider}`)),
1751
+ DEFAULT_OAUTH_REFRESH_TIMEOUT_MS,
1752
+ );
1753
+ });
1754
+ try {
1755
+ return await Promise.race([refreshPromise, timeoutPromise]);
1756
+ } finally {
1757
+ if (timeout) clearTimeout(timeout);
1758
+ }
1759
+ }
1760
+
1761
+ /** Attempts to use a single OAuth credential, checking usage and refreshing token. */
1762
+ async #tryOAuthCredential(
1763
+ provider: Provider,
1764
+ selection: { credential: OAuthCredential; index: number },
1765
+ providerKey: string,
1766
+ sessionId: string | undefined,
1767
+ options: AuthApiKeyOptions | undefined,
1768
+ usageOptions: {
1769
+ checkUsage: boolean;
1770
+ allowBlocked: boolean;
1771
+ prefetchedUsage?: UsageReport | null;
1772
+ usagePrechecked?: boolean;
1773
+ },
1774
+ ): Promise<string | undefined> {
1775
+ const { checkUsage, allowBlocked, prefetchedUsage = null, usagePrechecked = false } = usageOptions;
1776
+ if (!allowBlocked && this.#isCredentialBlocked(providerKey, selection.index)) {
1777
+ return undefined;
1778
+ }
1779
+
1780
+ let usage: UsageReport | null = null;
1781
+ let usageChecked = false;
1782
+
1783
+ if (checkUsage && !allowBlocked) {
1784
+ if (usagePrechecked) {
1785
+ usage = prefetchedUsage;
1786
+ usageChecked = true;
1787
+ } else {
1788
+ usage = await this.#getUsageReport(provider, selection.credential, {
1789
+ ...options,
1790
+ timeoutMs: this.#usageRequestTimeoutMs,
1791
+ });
1792
+ usageChecked = true;
1793
+ }
1794
+ if (usage && this.#isUsageLimitReached(usage)) {
1795
+ const resetAtMs = this.#getUsageResetAtMs(usage, Date.now());
1796
+ this.#markCredentialBlocked(
1797
+ providerKey,
1798
+ selection.index,
1799
+ resetAtMs ?? Date.now() + AuthStorage.#defaultBackoffMs,
1800
+ );
1801
+ return undefined;
1802
+ }
1803
+ }
1804
+
1805
+ try {
1806
+ let result: { newCredentials: OAuthCredentials; apiKey: string } | null;
1807
+ const customProvider = getOAuthProvider(provider);
1808
+ if (customProvider) {
1809
+ const refreshedCredentials = await this.#refreshOAuthCredential(provider, selection.credential);
1810
+ const apiKey = customProvider.getApiKey
1811
+ ? customProvider.getApiKey(refreshedCredentials)
1812
+ : refreshedCredentials.access;
1813
+ result = { newCredentials: refreshedCredentials, apiKey };
1814
+ } else {
1815
+ const oauthCreds: Record<string, OAuthCredentials> = {
1816
+ [provider]: selection.credential,
1817
+ };
1818
+ result = await getOAuthApiKey(provider as OAuthProvider, oauthCreds);
1819
+ }
1820
+ if (!result) return undefined;
1821
+ const updated: OAuthCredential = {
1822
+ type: "oauth",
1823
+ access: result.newCredentials.access,
1824
+ refresh: result.newCredentials.refresh,
1825
+ expires: result.newCredentials.expires,
1826
+ accountId: result.newCredentials.accountId ?? selection.credential.accountId,
1827
+ email: result.newCredentials.email ?? selection.credential.email,
1828
+ projectId: result.newCredentials.projectId ?? selection.credential.projectId,
1829
+ enterpriseUrl: result.newCredentials.enterpriseUrl ?? selection.credential.enterpriseUrl,
1830
+ };
1831
+ this.#replaceCredentialAt(provider, selection.index, updated);
1832
+ if (checkUsage && !allowBlocked) {
1833
+ const sameAccount = selection.credential.accountId === updated.accountId;
1834
+ if (!usageChecked || !sameAccount) {
1835
+ usage = await this.#getUsageReport(provider, updated, {
1836
+ ...options,
1837
+ timeoutMs: this.#usageRequestTimeoutMs,
1838
+ });
1839
+ }
1840
+ if (usage && this.#isUsageLimitReached(usage)) {
1841
+ const resetAtMs = this.#getUsageResetAtMs(usage, Date.now());
1842
+ this.#markCredentialBlocked(
1843
+ providerKey,
1844
+ selection.index,
1845
+ resetAtMs ?? Date.now() + AuthStorage.#defaultBackoffMs,
1846
+ );
1847
+ return undefined;
1848
+ }
1849
+ }
1850
+ this.#recordSessionCredential(provider, sessionId, "oauth", selection.index);
1851
+ return result.apiKey;
1852
+ } catch (error) {
1853
+ const errorMsg = String(error);
1854
+ // Only remove credentials for definitive auth failures
1855
+ // Keep credentials for transient errors (network, 5xx) and block temporarily
1856
+ const isDefinitiveFailure =
1857
+ /invalid_grant|invalid_token|revoked|unauthorized|expired.*refresh|refresh.*expired/i.test(errorMsg) ||
1858
+ (/\b(401|403)\b/.test(errorMsg) && !/timeout|network|fetch failed|ECONNREFUSED/i.test(errorMsg));
1859
+
1860
+ logger.warn("OAuth token refresh failed", {
1861
+ provider,
1862
+ index: selection.index,
1863
+ error: errorMsg,
1864
+ isDefinitiveFailure,
1865
+ });
1866
+
1867
+ if (isDefinitiveFailure) {
1868
+ // Permanently disable invalid credentials with an explicit cause for inspection/debugging
1869
+ this.#disableCredentialAt(provider, selection.index, `oauth refresh failed: ${errorMsg}`);
1870
+ if (this.#getCredentialsForProvider(provider).some(credential => credential.type === "oauth")) {
1871
+ return this.getApiKey(provider, sessionId, options);
1872
+ }
1873
+ } else {
1874
+ // Block temporarily for transient failures (5 minutes)
1875
+ this.#markCredentialBlocked(providerKey, selection.index, Date.now() + 5 * 60 * 1000);
1876
+ }
1877
+ }
1878
+
1879
+ return undefined;
1880
+ }
1881
+
1882
+ /**
1883
+ * Peek at API key for a provider without refreshing OAuth tokens.
1884
+ * Used for model discovery where we only need to know if credentials exist
1885
+ * and get a best-effort token. For GitHub Copilot we preserve enterprise
1886
+ * routing metadata so discovery can hit the correct host.
1887
+ */
1888
+ async peekApiKey(provider: string): Promise<string | undefined> {
1889
+ const runtimeKey = this.#runtimeOverrides.get(provider);
1890
+ if (runtimeKey) {
1891
+ return runtimeKey;
1892
+ }
1893
+
1894
+ const apiKeySelection = this.#selectCredentialByType(provider, "api_key");
1895
+ if (apiKeySelection) {
1896
+ return this.#configValueResolver(apiKeySelection.credential.key);
1897
+ }
1898
+
1899
+ // Return current OAuth access token only if it is not already expired.
1900
+ const oauthSelection = this.#selectCredentialByType(provider, "oauth");
1901
+ if (oauthSelection) {
1902
+ const expiresAt = oauthSelection.credential.expires;
1903
+ if (Number.isFinite(expiresAt) && expiresAt > Date.now()) {
1904
+ if (provider === "github-copilot") {
1905
+ return JSON.stringify({
1906
+ token: oauthSelection.credential.access,
1907
+ enterpriseUrl: oauthSelection.credential.enterpriseUrl,
1908
+ });
1909
+ }
1910
+ return oauthSelection.credential.access;
1911
+ }
1912
+ }
1913
+
1914
+ const envKey = getEnvApiKey(provider);
1915
+ if (envKey) return envKey;
1916
+
1917
+ return this.#fallbackResolver?.(provider) ?? undefined;
1918
+ }
1919
+
1920
+ /**
1921
+ * Get API key for a provider.
1922
+ * Priority:
1923
+ * 1. Runtime override (CLI --api-key)
1924
+ * 2. API key from storage
1925
+ * 3. OAuth token from storage (auto-refreshed)
1926
+ * 4. Environment variable
1927
+ * 5. Fallback resolver (models.json custom providers)
1928
+ */
1929
+ async getApiKey(provider: string, sessionId?: string, options?: AuthApiKeyOptions): Promise<string | undefined> {
1930
+ // Runtime override takes highest priority
1931
+ const runtimeKey = this.#runtimeOverrides.get(provider);
1932
+ if (runtimeKey) {
1933
+ return runtimeKey;
1934
+ }
1935
+
1936
+ const apiKeySelection = this.#selectCredentialByType(provider, "api_key", sessionId);
1937
+ if (apiKeySelection) {
1938
+ this.#recordSessionCredential(provider, sessionId, "api_key", apiKeySelection.index);
1939
+ return this.#configValueResolver(apiKeySelection.credential.key);
1940
+ }
1941
+
1942
+ const oauthKey = await this.#resolveOAuthApiKey(provider, sessionId, options);
1943
+ if (oauthKey) {
1944
+ return oauthKey;
1945
+ }
1946
+
1947
+ // Fall back to environment variable
1948
+ const envKey = getEnvApiKey(provider);
1949
+ if (envKey) return envKey;
1950
+
1951
+ // Fall back to custom resolver (e.g., models.json custom providers)
1952
+ return this.#fallbackResolver?.(provider) ?? undefined;
1953
+ }
1954
+ }
1955
+
1956
+ // ─────────────────────────────────────────────────────────────────────────────
1957
+ // AuthCredentialStore
1958
+ // ─────────────────────────────────────────────────────────────────────────────
1959
+
1960
+ /** Row shape for auth_credentials table queries */
1961
+ type AuthRow = {
1962
+ id: number;
1963
+ provider: string;
1964
+ credential_type: string;
1965
+ data: string;
1966
+ disabled_cause: string | null;
1967
+ identity_key: string | null;
1968
+ };
1969
+
1970
+ type SerializedCredentialRecord = {
1971
+ credentialType: AuthCredential["type"];
1972
+ data: string;
1973
+ identityKey: string | null;
1974
+ };
1975
+
1976
+ const AUTH_SCHEMA_VERSION = 4;
1977
+ const SQLITE_NOW_EPOCH = "CAST(strftime('%s','now') AS INTEGER)";
1978
+
1979
+ function normalizeStoredAccountId(accountId: string | null | undefined): string | null {
1980
+ const normalized = accountId?.trim();
1981
+ return normalized && normalized.length > 0 ? normalized : null;
1982
+ }
1983
+
1984
+ function normalizeStoredEmail(email: string | null | undefined): string | null {
1985
+ const normalized = email?.trim().toLowerCase();
1986
+ return normalized && normalized.length > 0 ? normalized : null;
1987
+ }
1988
+
1989
+ function normalizeStoredIdentityKey(identityKey: string | null | undefined): string | null {
1990
+ const normalized = identityKey?.trim();
1991
+ return normalized && normalized.length > 0 ? normalized : null;
1992
+ }
1993
+
1994
+ function serializeCredential(provider: string, credential: AuthCredential): SerializedCredentialRecord | null {
1995
+ if (credential.type === "api_key") {
1996
+ return {
1997
+ credentialType: "api_key",
1998
+ data: JSON.stringify({ key: credential.key }),
1999
+ identityKey: null,
2000
+ };
2001
+ }
2002
+ if (credential.type === "oauth") {
2003
+ const { type: _type, ...rest } = credential;
2004
+ return {
2005
+ credentialType: "oauth",
2006
+ data: JSON.stringify(rest),
2007
+ identityKey: resolveCredentialIdentityKey(provider, credential),
2008
+ };
2009
+ }
2010
+ return null;
2011
+ }
2012
+
2013
+ function deserializeCredential(row: AuthRow): AuthCredential | null {
2014
+ let parsed: unknown;
2015
+ try {
2016
+ parsed = JSON.parse(row.data);
2017
+ } catch {
2018
+ return null;
2019
+ }
2020
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
2021
+ return null;
2022
+ }
2023
+ if (row.credential_type === "api_key") {
2024
+ const data = parsed as Record<string, unknown>;
2025
+ if (typeof data.key === "string") {
2026
+ return { type: "api_key", key: data.key };
2027
+ }
2028
+ }
2029
+ if (row.credential_type === "oauth") {
2030
+ return { type: "oauth", ...(parsed as Record<string, unknown>) } as AuthCredential;
2031
+ }
2032
+ return null;
2033
+ }
2034
+
2035
+ function normalizeDisabledCause(disabledCause: string): string {
2036
+ const normalized = disabledCause.trim();
2037
+ return normalized.length > 0 ? normalized : "disabled";
2038
+ }
2039
+
2040
+ function toStoredAuthCredential(row: AuthRow, credential: AuthCredential): StoredAuthCredential {
2041
+ return { id: row.id, provider: row.provider, credential, disabledCause: row.disabled_cause };
2042
+ }
2043
+
2044
+ function resolveProviderCredentialIdentityKey(provider: string, identifiers: string[]): string | null {
2045
+ const emailIdentifier = identifiers.find(identifier => identifier.startsWith("email:"));
2046
+ if ((provider === "openai-codex" || provider === "anthropic") && emailIdentifier) return emailIdentifier;
2047
+ const accountIdentifier = identifiers.find(identifier => identifier.startsWith("account:"));
2048
+ if (accountIdentifier) return accountIdentifier;
2049
+ if (emailIdentifier) return emailIdentifier;
2050
+ return null;
2051
+ }
2052
+
2053
+ function resolveCredentialIdentityKey(provider: string, credential: AuthCredential): string | null {
2054
+ if (credential.type === "api_key") return null;
2055
+ return resolveProviderCredentialIdentityKey(provider, extractOAuthCredentialIdentifiers(credential));
2056
+ }
2057
+
2058
+ function resolveRowCredentialIdentityKey(provider: string, row: AuthRow): string | null {
2059
+ const identityKey = normalizeStoredIdentityKey(row.identity_key);
2060
+ if (identityKey) return identityKey;
2061
+ const credential = deserializeCredential(row);
2062
+ return credential?.type === "oauth" ? resolveCredentialIdentityKey(provider, credential) : null;
2063
+ }
2064
+
2065
+ function matchesReplacementCredential(
2066
+ provider: string,
2067
+ existing: AuthCredential | null,
2068
+ existingIdentityKey: string | null,
2069
+ incoming: AuthCredential,
2070
+ ): boolean {
2071
+ if (!existing || existing.type !== incoming.type) return false;
2072
+ if (incoming.type === "api_key") {
2073
+ return existing.type === "api_key" && existing.key === incoming.key;
2074
+ }
2075
+ const incomingIdentityKey = resolveCredentialIdentityKey(provider, incoming);
2076
+ return incomingIdentityKey !== null && incomingIdentityKey === existingIdentityKey;
2077
+ }
2078
+
2079
+ function extractOAuthCredentialIdentifiers(credential: OAuthCredential): string[] {
2080
+ const identifiers = new Set<string>();
2081
+ const accountId = normalizeStoredAccountId(credential.accountId);
2082
+ if (accountId) identifiers.add(`account:${accountId}`);
2083
+ const email = normalizeStoredEmail(credential.email);
2084
+ if (email) identifiers.add(`email:${email}`);
2085
+ const accessIdentifiers = extractOAuthTokenIdentifiers(credential.access) ?? [];
2086
+ for (const identifier of accessIdentifiers) {
2087
+ identifiers.add(identifier);
2088
+ }
2089
+ const refreshIdentifiers = extractOAuthTokenIdentifiers(credential.refresh) ?? [];
2090
+ for (const identifier of refreshIdentifiers) {
2091
+ identifiers.add(identifier);
2092
+ }
2093
+ return [...identifiers];
2094
+ }
2095
+
2096
+ function extractOAuthTokenIdentifiers(token: string | undefined): string[] | undefined {
2097
+ if (!token) return undefined;
2098
+ const parts = token.split(".");
2099
+ if (parts.length !== 3) return undefined;
2100
+ try {
2101
+ const payload = JSON.parse(
2102
+ new TextDecoder("utf-8").decode(Uint8Array.fromBase64(parts[1], { alphabet: "base64url" })),
2103
+ ) as Record<string, unknown>;
2104
+ const identifiers = new Set<string>();
2105
+ const directEmail = normalizeStoredEmail(typeof payload.email === "string" ? payload.email : undefined);
2106
+ if (directEmail) identifiers.add(`email:${directEmail}`);
2107
+ const openAiProfile = payload["https://api.openai.com/profile"];
2108
+ if (typeof openAiProfile === "object" && openAiProfile !== null && !Array.isArray(openAiProfile)) {
2109
+ const claimEmail = normalizeStoredEmail(
2110
+ (openAiProfile as Record<string, unknown>).email as string | undefined,
2111
+ );
2112
+ if (claimEmail) identifiers.add(`email:${claimEmail}`);
2113
+ }
2114
+ const openAiAuth = payload["https://api.openai.com/auth"];
2115
+ const authClaims =
2116
+ typeof openAiAuth === "object" && openAiAuth !== null && !Array.isArray(openAiAuth)
2117
+ ? (openAiAuth as Record<string, unknown>)
2118
+ : undefined;
2119
+ const accountId = normalizeStoredAccountId(
2120
+ typeof payload.account_id === "string"
2121
+ ? payload.account_id
2122
+ : typeof payload.accountId === "string"
2123
+ ? payload.accountId
2124
+ : typeof payload.user_id === "string"
2125
+ ? payload.user_id
2126
+ : typeof payload.sub === "string"
2127
+ ? payload.sub
2128
+ : typeof authClaims?.chatgpt_account_id === "string"
2129
+ ? authClaims.chatgpt_account_id
2130
+ : undefined,
2131
+ );
2132
+ if (accountId) identifiers.add(`account:${accountId}`);
2133
+ return identifiers.size > 0 ? [...identifiers] : undefined;
2134
+ } catch {
2135
+ return undefined;
2136
+ }
2137
+ }
2138
+ /**
2139
+ * Standalone SQLite-backed implementation of AuthCredentialStore interface.
2140
+ * Used by the pi-ai CLI and as the default store for AuthStorage.create().
2141
+ * Also has convenience methods for simple CRUD (saveOAuth, getOAuth, etc.).
2142
+ */
2143
+ export class AuthCredentialStore {
2144
+ #db: Database;
2145
+ #listActiveStmt: Statement;
2146
+ #listActiveByProviderStmt: Statement;
2147
+ #listDisabledByProviderStmt: Statement;
2148
+ #insertStmt: Statement;
2149
+ #updateStmt: Statement;
2150
+ #deleteStmt: Statement;
2151
+ #deleteByProviderStmt: Statement;
2152
+ #hardDeleteStmt: Statement;
2153
+ #getCacheStmt: Statement;
2154
+ #upsertCacheStmt: Statement;
2155
+ #deleteExpiredCacheStmt: Statement;
2156
+ #closed = false;
2157
+
2158
+ constructor(db: Database) {
2159
+ this.#db = db;
2160
+ this.#initializeSchema();
2161
+
2162
+ this.#listActiveStmt = this.#db.prepare(
2163
+ "SELECT id, provider, credential_type, data, disabled_cause, identity_key FROM auth_credentials WHERE disabled_cause IS NULL ORDER BY id ASC",
2164
+ );
2165
+ this.#listActiveByProviderStmt = this.#db.prepare(
2166
+ "SELECT id, provider, credential_type, data, disabled_cause, identity_key FROM auth_credentials WHERE provider = ? AND disabled_cause IS NULL ORDER BY id ASC",
2167
+ );
2168
+ this.#listDisabledByProviderStmt = this.#db.prepare(
2169
+ "SELECT id, provider, credential_type, data, disabled_cause, identity_key FROM auth_credentials WHERE provider = ? AND disabled_cause IS NOT NULL ORDER BY id ASC",
2170
+ );
2171
+ this.#insertStmt = this.#db.prepare(
2172
+ `INSERT INTO auth_credentials (provider, credential_type, data, identity_key, created_at, updated_at) VALUES (?, ?, ?, ?, ${SQLITE_NOW_EPOCH}, ${SQLITE_NOW_EPOCH}) RETURNING id`,
2173
+ );
2174
+ this.#updateStmt = this.#db.prepare(
2175
+ `UPDATE auth_credentials SET credential_type = ?, data = ?, identity_key = ?, updated_at = ${SQLITE_NOW_EPOCH} WHERE id = ?`,
2176
+ );
2177
+ this.#deleteStmt = this.#db.prepare(
2178
+ `UPDATE auth_credentials SET disabled_cause = ?, updated_at = ${SQLITE_NOW_EPOCH} WHERE id = ?`,
2179
+ );
2180
+ this.#deleteByProviderStmt = this.#db.prepare(
2181
+ `UPDATE auth_credentials SET disabled_cause = ?, updated_at = ${SQLITE_NOW_EPOCH} WHERE provider = ? AND disabled_cause IS NULL`,
2182
+ );
2183
+ this.#hardDeleteStmt = this.#db.prepare("DELETE FROM auth_credentials WHERE id = ?");
2184
+ this.#getCacheStmt = this.#db.prepare(
2185
+ `SELECT value FROM cache WHERE key = ? AND expires_at > ${SQLITE_NOW_EPOCH}`,
2186
+ );
2187
+ this.#upsertCacheStmt = this.#db.prepare(
2188
+ "INSERT INTO cache (key, value, expires_at) VALUES (?, ?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value, expires_at = excluded.expires_at",
2189
+ );
2190
+ this.#deleteExpiredCacheStmt = this.#db.prepare(`DELETE FROM cache WHERE expires_at <= ${SQLITE_NOW_EPOCH}`);
2191
+ }
2192
+
2193
+ static async open(dbPath: string = getAgentDbPath()): Promise<AuthCredentialStore> {
2194
+ const dir = path.dirname(dbPath);
2195
+ const dirExists = await fs
2196
+ .stat(dir)
2197
+ .then(s => s.isDirectory())
2198
+ .catch(() => false);
2199
+ if (!dirExists) {
2200
+ await fs.mkdir(dir, { recursive: true, mode: 0o700 });
2201
+ }
2202
+
2203
+ const db = new Database(dbPath);
2204
+ try {
2205
+ await fs.chmod(dbPath, 0o600);
2206
+ } catch {
2207
+ // Ignore chmod failures (e.g., Windows)
2208
+ }
2209
+
2210
+ return new AuthCredentialStore(db);
2211
+ }
2212
+
2213
+ #initializeSchema(): void {
2214
+ this.#db.run(`
2215
+ PRAGMA journal_mode=WAL;
2216
+ PRAGMA synchronous=NORMAL;
2217
+ PRAGMA busy_timeout=5000;
2218
+ CREATE TABLE IF NOT EXISTS auth_schema_version (
2219
+ id INTEGER PRIMARY KEY CHECK (id = 1),
2220
+ version INTEGER NOT NULL
2221
+ );
2222
+ CREATE TABLE IF NOT EXISTS cache (
2223
+ key TEXT PRIMARY KEY,
2224
+ value TEXT NOT NULL,
2225
+ expires_at INTEGER NOT NULL
2226
+ );
2227
+ CREATE INDEX IF NOT EXISTS idx_cache_expires ON cache(expires_at);
2228
+ `);
2229
+
2230
+ if (!this.#authCredentialsTableExists()) {
2231
+ this.#createAuthCredentialsTable();
2232
+ this.#writeAuthSchemaVersion(AUTH_SCHEMA_VERSION);
2233
+ return;
2234
+ }
2235
+
2236
+ const schemaVersion = this.#readAuthSchemaVersion() ?? this.#inferAuthSchemaVersion();
2237
+ const shouldWriteSchemaVersion = schemaVersion <= AUTH_SCHEMA_VERSION;
2238
+ if (schemaVersion > AUTH_SCHEMA_VERSION) {
2239
+ logger.warn("AuthCredentialStore schema version mismatch", {
2240
+ current: schemaVersion,
2241
+ expected: AUTH_SCHEMA_VERSION,
2242
+ });
2243
+ } else if (schemaVersion < AUTH_SCHEMA_VERSION) {
2244
+ this.#migrateAuthSchema(schemaVersion);
2245
+ }
2246
+
2247
+ this.#createAuthCredentialIndexes();
2248
+ this.#backfillCredentialIdentityKeys();
2249
+ if (shouldWriteSchemaVersion) {
2250
+ this.#writeAuthSchemaVersion(AUTH_SCHEMA_VERSION);
2251
+ }
2252
+ }
2253
+
2254
+ #authCredentialsTableExists(): boolean {
2255
+ const row = this.#db
2256
+ .prepare("SELECT 1 AS present FROM sqlite_master WHERE type = 'table' AND name = 'auth_credentials'")
2257
+ .get() as { present?: number } | undefined;
2258
+ return row?.present === 1;
2259
+ }
2260
+
2261
+ #readAuthSchemaVersion(): number | null {
2262
+ const row = this.#db.prepare("SELECT version FROM auth_schema_version WHERE id = 1").get() as
2263
+ | { version?: number }
2264
+ | undefined;
2265
+ return typeof row?.version === "number" ? row.version : null;
2266
+ }
2267
+
2268
+ #writeAuthSchemaVersion(version: number): void {
2269
+ this.#db.prepare("INSERT OR REPLACE INTO auth_schema_version(id, version) VALUES (1, ?)").run(version);
2270
+ }
2271
+
2272
+ #inferAuthSchemaVersion(): number {
2273
+ const cols = this.#db.prepare("PRAGMA table_info(auth_credentials)").all() as Array<{ name?: string }>;
2274
+ const hasDisabledCause = cols.some(column => column.name === "disabled_cause");
2275
+ const hasIdentityKey = cols.some(column => column.name === "identity_key");
2276
+ const hasAccountId = cols.some(column => column.name === "account_id");
2277
+ const hasEmail = cols.some(column => column.name === "email");
2278
+ if (hasIdentityKey) return 3;
2279
+ if (hasAccountId || hasEmail) return 2;
2280
+ if (hasDisabledCause) return 1;
2281
+ return 0;
2282
+ }
2283
+
2284
+ #createAuthCredentialsTable(): void {
2285
+ this.#db.run(`
2286
+ CREATE TABLE IF NOT EXISTS auth_credentials (
2287
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
2288
+ provider TEXT NOT NULL,
2289
+ credential_type TEXT NOT NULL,
2290
+ data TEXT NOT NULL,
2291
+ disabled_cause TEXT DEFAULT NULL,
2292
+ identity_key TEXT DEFAULT NULL,
2293
+ created_at INTEGER NOT NULL DEFAULT (${SQLITE_NOW_EPOCH}),
2294
+ updated_at INTEGER NOT NULL DEFAULT (${SQLITE_NOW_EPOCH})
2295
+ );
2296
+ `);
2297
+ this.#createAuthCredentialIndexes();
2298
+ }
2299
+
2300
+ #createAuthCredentialIndexes(): void {
2301
+ this.#db.run(`
2302
+ CREATE INDEX IF NOT EXISTS idx_auth_provider ON auth_credentials(provider);
2303
+ CREATE INDEX IF NOT EXISTS idx_auth_provider_identity ON auth_credentials(provider, identity_key) WHERE identity_key IS NOT NULL;
2304
+ `);
2305
+ }
2306
+
2307
+ #migrateAuthSchema(fromVersion: number): void {
2308
+ if (fromVersion < 1) {
2309
+ this.#migrateAuthSchemaV0ToV1();
2310
+ }
2311
+ if (fromVersion < 3) {
2312
+ this.#migrateAuthSchemaV1OrV2ToV3();
2313
+ }
2314
+ if (fromVersion < 4) {
2315
+ this.#migrateAuthSchemaV3ToV4();
2316
+ }
2317
+ }
2318
+
2319
+ #migrateAuthSchemaV0ToV1(): void {
2320
+ const migrate = this.#db.transaction(() => {
2321
+ const v0Cols = this.#db.prepare("PRAGMA table_info(auth_credentials)").all() as Array<{ name?: string }>;
2322
+ const hasDisabled = v0Cols.some(col => col.name === "disabled");
2323
+
2324
+ this.#db.run("ALTER TABLE auth_credentials RENAME TO auth_credentials_v0");
2325
+ this.#db.run(`
2326
+ CREATE TABLE auth_credentials (
2327
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
2328
+ provider TEXT NOT NULL,
2329
+ credential_type TEXT NOT NULL,
2330
+ data TEXT NOT NULL,
2331
+ disabled_cause TEXT DEFAULT NULL,
2332
+ created_at INTEGER NOT NULL DEFAULT (${SQLITE_NOW_EPOCH}),
2333
+ updated_at INTEGER NOT NULL DEFAULT (${SQLITE_NOW_EPOCH})
2334
+ );
2335
+ `);
2336
+ this.#db.run(`
2337
+ INSERT INTO auth_credentials (id, provider, credential_type, data, disabled_cause, created_at, updated_at)
2338
+ SELECT
2339
+ id,
2340
+ provider,
2341
+ credential_type,
2342
+ data,
2343
+ ${hasDisabled ? "CASE WHEN disabled = 1 THEN 'disabled' ELSE NULL END" : "NULL"},
2344
+ created_at,
2345
+ updated_at
2346
+ FROM auth_credentials_v0
2347
+ `);
2348
+ this.#db.run("DROP TABLE auth_credentials_v0");
2349
+ });
2350
+ migrate();
2351
+ }
2352
+
2353
+ #migrateAuthSchemaV1OrV2ToV3(): void {
2354
+ const migrate = this.#db.transaction(() => {
2355
+ this.#db.run("ALTER TABLE auth_credentials RENAME TO auth_credentials_legacy");
2356
+ this.#createAuthCredentialsTable();
2357
+ this.#db.run(`
2358
+ INSERT INTO auth_credentials (id, provider, credential_type, data, disabled_cause, identity_key, created_at, updated_at)
2359
+ SELECT
2360
+ id,
2361
+ provider,
2362
+ credential_type,
2363
+ data,
2364
+ disabled_cause,
2365
+ NULL,
2366
+ created_at,
2367
+ updated_at
2368
+ FROM auth_credentials_legacy
2369
+ `);
2370
+ this.#db.run("DROP TABLE auth_credentials_legacy");
2371
+ });
2372
+ migrate();
2373
+ }
2374
+
2375
+ #migrateAuthSchemaV3ToV4(): void {
2376
+ const migrate = this.#db.transaction(() => {
2377
+ this.#db.run("ALTER TABLE auth_credentials RENAME TO auth_credentials_v3");
2378
+ this.#createAuthCredentialsTable();
2379
+ this.#db.run(`
2380
+ INSERT INTO auth_credentials (id, provider, credential_type, data, disabled_cause, identity_key, created_at, updated_at)
2381
+ SELECT
2382
+ id,
2383
+ provider,
2384
+ credential_type,
2385
+ data,
2386
+ disabled_cause,
2387
+ identity_key,
2388
+ created_at,
2389
+ updated_at
2390
+ FROM auth_credentials_v3
2391
+ `);
2392
+ this.#db.run("DROP TABLE auth_credentials_v3");
2393
+ });
2394
+ migrate();
2395
+ }
2396
+
2397
+ #backfillCredentialIdentityKeys(): void {
2398
+ const rows = this.#db
2399
+ .prepare(
2400
+ "SELECT id, provider, credential_type, data, disabled_cause, identity_key FROM auth_credentials WHERE identity_key IS NULL ORDER BY id ASC",
2401
+ )
2402
+ .all() as AuthRow[];
2403
+ if (rows.length === 0) return;
2404
+
2405
+ const updateIdentity = this.#db.prepare("UPDATE auth_credentials SET identity_key = ? WHERE id = ?");
2406
+ for (const row of rows) {
2407
+ const identityKey = resolveRowCredentialIdentityKey(row.provider, row);
2408
+ updateIdentity.run(identityKey, row.id);
2409
+ }
2410
+ }
2411
+
2412
+ // ─── AuthCredentialStore interface ──────────────────────────────────────
2413
+
2414
+ listAuthCredentials(provider?: string): StoredAuthCredential[] {
2415
+ const rows =
2416
+ (provider
2417
+ ? (this.#listActiveByProviderStmt.all(provider) as AuthRow[])
2418
+ : (this.#listActiveStmt.all() as AuthRow[])) ?? [];
2419
+
2420
+ const results: StoredAuthCredential[] = [];
2421
+ for (const row of rows) {
2422
+ const credential = deserializeCredential(row);
2423
+ if (!credential) continue;
2424
+ results.push(toStoredAuthCredential(row, credential));
2425
+ }
2426
+ return results;
2427
+ }
2428
+
2429
+ replaceAuthCredentialsForProvider(provider: string, credentials: AuthCredential[]): StoredAuthCredential[] {
2430
+ const replace = this.#db.transaction((providerName: string, items: AuthCredential[]) => {
2431
+ const existingRows = this.#listActiveByProviderStmt.all(providerName) as AuthRow[];
2432
+ const existing = existingRows.map(row => ({
2433
+ id: row.id,
2434
+ credential: deserializeCredential(row),
2435
+ identityKey: resolveRowCredentialIdentityKey(providerName, row),
2436
+ }));
2437
+
2438
+ const result: StoredAuthCredential[] = [];
2439
+ const matchedExistingIds = new Set<number>();
2440
+
2441
+ for (const credential of items) {
2442
+ const serialized = serializeCredential(providerName, credential);
2443
+ if (!serialized) continue;
2444
+ const match = existing.find(
2445
+ entry =>
2446
+ !matchedExistingIds.has(entry.id) &&
2447
+ matchesReplacementCredential(providerName, entry.credential, entry.identityKey, credential),
2448
+ );
2449
+ if (match) {
2450
+ matchedExistingIds.add(match.id);
2451
+ this.#updateStmt.run(serialized.credentialType, serialized.data, serialized.identityKey, match.id);
2452
+ result.push({ id: match.id, provider: providerName, credential, disabledCause: null });
2453
+ } else {
2454
+ const row = this.#insertStmt.get(
2455
+ providerName,
2456
+ serialized.credentialType,
2457
+ serialized.data,
2458
+ serialized.identityKey,
2459
+ ) as { id?: number } | undefined;
2460
+ if (row?.id) {
2461
+ result.push({ id: row.id, provider: providerName, credential, disabledCause: null });
2462
+ }
2463
+ }
2464
+ }
2465
+
2466
+ for (const row of existing) {
2467
+ if (!matchedExistingIds.has(row.id)) {
2468
+ this.#deleteStmt.run("replaced by newer credential", row.id);
2469
+ }
2470
+ }
2471
+
2472
+ return result;
2473
+ });
2474
+
2475
+ const result = replace(provider, credentials);
2476
+ this.#purgeSupersededDisabledRows(provider, result);
2477
+ return result;
2478
+ }
2479
+
2480
+ upsertAuthCredentialForProvider(provider: string, credential: AuthCredential): StoredAuthCredential[] {
2481
+ const upsert = this.#db.transaction((providerName: string, item: AuthCredential) => {
2482
+ const serialized = serializeCredential(providerName, item);
2483
+ if (!serialized) return this.listAuthCredentials(providerName);
2484
+ const existingRows = this.#listActiveByProviderStmt.all(providerName) as AuthRow[];
2485
+ const existing = existingRows.map(row => ({
2486
+ id: row.id,
2487
+ credential: deserializeCredential(row),
2488
+ identityKey: resolveRowCredentialIdentityKey(providerName, row),
2489
+ }));
2490
+
2491
+ let targetId: number | null = null;
2492
+ for (const row of existing) {
2493
+ if (!matchesReplacementCredential(providerName, row.credential, row.identityKey, item)) continue;
2494
+ if (targetId === null) {
2495
+ targetId = row.id;
2496
+ this.#updateStmt.run(serialized.credentialType, serialized.data, serialized.identityKey, row.id);
2497
+ continue;
2498
+ }
2499
+ this.#deleteStmt.run("replaced by newer credential", row.id);
2500
+ }
2501
+
2502
+ if (targetId === null) {
2503
+ const row = this.#insertStmt.get(
2504
+ providerName,
2505
+ serialized.credentialType,
2506
+ serialized.data,
2507
+ serialized.identityKey,
2508
+ ) as { id?: number } | undefined;
2509
+ targetId = row?.id ?? null;
2510
+ }
2511
+
2512
+ const activeRows = this.#listActiveByProviderStmt.all(providerName) as AuthRow[];
2513
+ const result: StoredAuthCredential[] = [];
2514
+ for (const row of activeRows) {
2515
+ const activeCredential = deserializeCredential(row);
2516
+ if (!activeCredential) continue;
2517
+ result.push(toStoredAuthCredential(row, activeCredential));
2518
+ }
2519
+ return result;
2520
+ });
2521
+
2522
+ const result = upsert(provider, credential);
2523
+ this.#purgeSupersededDisabledRows(provider, result);
2524
+ return result;
2525
+ }
2526
+
2527
+ /**
2528
+ * Hard-deletes disabled rows for a provider when an active row with the same identity exists.
2529
+ * This prevents unbounded accumulation of soft-deleted credentials while preserving
2530
+ * disabled rows that have no active replacement (safety net for recovery).
2531
+ */
2532
+ #purgeSupersededDisabledRows(provider: string, activeRows: StoredAuthCredential[]): void {
2533
+ try {
2534
+ const activeIdentityKeys = new Set<string>();
2535
+ for (const row of activeRows) {
2536
+ const identityKey = resolveCredentialIdentityKey(provider, row.credential);
2537
+ if (identityKey) activeIdentityKeys.add(identityKey);
2538
+ }
2539
+ if (activeIdentityKeys.size === 0) return;
2540
+
2541
+ const disabledRows = this.#listDisabledByProviderStmt.all(provider) as AuthRow[];
2542
+ for (const row of disabledRows) {
2543
+ const identityKey = resolveRowCredentialIdentityKey(provider, row);
2544
+ if (identityKey && activeIdentityKeys.has(identityKey)) {
2545
+ this.#hardDeleteStmt.run(row.id);
2546
+ }
2547
+ }
2548
+ } catch {
2549
+ // Best-effort cleanup; don't let it break the main operation
2550
+ }
2551
+ }
2552
+
2553
+ updateAuthCredential(id: number, credential: AuthCredential): void {
2554
+ try {
2555
+ const providerRow = this.#db.prepare("SELECT provider FROM auth_credentials WHERE id = ?").get(id) as
2556
+ | { provider?: string }
2557
+ | undefined;
2558
+ const provider = providerRow?.provider ?? "";
2559
+ const serialized = serializeCredential(provider, credential);
2560
+ if (!serialized) return;
2561
+ this.#updateStmt.run(serialized.credentialType, serialized.data, serialized.identityKey, id);
2562
+ if (provider) {
2563
+ this.#purgeSupersededDisabledRows(provider, this.listAuthCredentials(provider));
2564
+ }
2565
+ } catch {
2566
+ // Ignore update failures
2567
+ }
2568
+ }
2569
+
2570
+ deleteAuthCredential(id: number, disabledCause: string): void {
2571
+ try {
2572
+ this.#deleteStmt.run(normalizeDisabledCause(disabledCause), id);
2573
+ } catch {
2574
+ // Ignore delete failures
2575
+ }
2576
+ }
2577
+
2578
+ deleteAuthCredentialsForProvider(provider: string, disabledCause: string): void {
2579
+ try {
2580
+ this.#deleteByProviderStmt.run(normalizeDisabledCause(disabledCause), provider);
2581
+ } catch {
2582
+ // Ignore delete failures
2583
+ }
2584
+ }
2585
+
2586
+ getCache(key: string): string | null {
2587
+ try {
2588
+ const row = this.#getCacheStmt.get(key) as { value?: string } | undefined;
2589
+ return row?.value ?? null;
2590
+ } catch {
2591
+ return null;
2592
+ }
2593
+ }
2594
+
2595
+ setCache(key: string, value: string, expiresAtSec: number): void {
2596
+ try {
2597
+ this.#upsertCacheStmt.run(key, value, expiresAtSec);
2598
+ } catch {
2599
+ // Ignore cache set failures
2600
+ }
2601
+ }
2602
+
2603
+ cleanExpiredCache(): void {
2604
+ try {
2605
+ this.#deleteExpiredCacheStmt.run();
2606
+ } catch {
2607
+ // Ignore cleanup errors
2608
+ }
2609
+ }
2610
+
2611
+ // ─── Convenience methods for CLI ────────────────────────────────────────
2612
+
2613
+ /**
2614
+ * Save OAuth credentials for a provider.
2615
+ * Preserves unrelated identities and replaces only the matching credential.
2616
+ */
2617
+ saveOAuth(provider: string, credentials: OAuthCredentials): void {
2618
+ const credential: AuthCredential = { type: "oauth", ...credentials };
2619
+ this.upsertAuthCredentialForProvider(provider, credential);
2620
+ }
2621
+
2622
+ /**
2623
+ * Get OAuth credentials for a provider.
2624
+ */
2625
+ getOAuth(provider: string): OAuthCredentials | null {
2626
+ const rows = this.#listActiveByProviderStmt.all(provider) as AuthRow[];
2627
+ for (const row of rows) {
2628
+ const credential = deserializeCredential(row);
2629
+ if (credential && credential.type === "oauth") {
2630
+ const { type: _type, ...oauth } = credential;
2631
+ return oauth as OAuthCredentials;
2632
+ }
2633
+ }
2634
+ return null;
2635
+ }
2636
+
2637
+ /**
2638
+ * Save API key for a provider (replaces existing).
2639
+ */
2640
+ saveApiKey(provider: string, apiKey: string): void {
2641
+ const credential: AuthCredential = { type: "api_key", key: apiKey };
2642
+ this.replaceAuthCredentialsForProvider(provider, [credential]);
2643
+ }
2644
+
2645
+ /**
2646
+ * Get API key for a provider.
2647
+ */
2648
+ getApiKey(provider: string): string | null {
2649
+ const rows = this.#listActiveByProviderStmt.all(provider) as AuthRow[];
2650
+ for (const row of rows) {
2651
+ const credential = deserializeCredential(row);
2652
+ if (credential && credential.type === "api_key") {
2653
+ return credential.key;
2654
+ }
2655
+ }
2656
+ return null;
2657
+ }
2658
+
2659
+ /**
2660
+ * List all providers with credentials.
2661
+ */
2662
+ listProviders(): string[] {
2663
+ const rows = this.#listActiveStmt.all() as AuthRow[];
2664
+ const providers = new Set<string>();
2665
+ for (const row of rows) {
2666
+ providers.add(row.provider);
2667
+ }
2668
+ return Array.from(providers);
2669
+ }
2670
+
2671
+ /**
2672
+ * Delete all credentials for a provider.
2673
+ */
2674
+ deleteProvider(provider: string): void {
2675
+ this.deleteAuthCredentialsForProvider(provider, "deleted by user");
2676
+ }
2677
+
2678
+ close(): void {
2679
+ if (this.#closed) return;
2680
+ this.#closed = true;
2681
+ this.#listActiveStmt.finalize();
2682
+ this.#listActiveByProviderStmt.finalize();
2683
+ this.#listDisabledByProviderStmt.finalize();
2684
+ this.#insertStmt.finalize();
2685
+ this.#updateStmt.finalize();
2686
+ this.#deleteStmt.finalize();
2687
+ this.#deleteByProviderStmt.finalize();
2688
+ this.#hardDeleteStmt.finalize();
2689
+ this.#getCacheStmt.finalize();
2690
+ this.#upsertCacheStmt.finalize();
2691
+ this.#deleteExpiredCacheStmt.finalize();
2692
+ this.#db.close();
2693
+ }
2694
+ }