@gajae-code/ai 0.1.1
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.
- package/CHANGELOG.md +2644 -0
- package/README.md +1181 -0
- package/dist/types/api-registry.d.ts +30 -0
- package/dist/types/auth-broker/client.d.ts +66 -0
- package/dist/types/auth-broker/index.d.ts +5 -0
- package/dist/types/auth-broker/refresher.d.ts +25 -0
- package/dist/types/auth-broker/remote-store.d.ts +96 -0
- package/dist/types/auth-broker/server.d.ts +32 -0
- package/dist/types/auth-broker/types.d.ts +105 -0
- package/dist/types/auth-broker/wire-schemas.d.ts +412 -0
- package/dist/types/auth-gateway/http.d.ts +39 -0
- package/dist/types/auth-gateway/index.d.ts +3 -0
- package/dist/types/auth-gateway/server.d.ts +17 -0
- package/dist/types/auth-gateway/types.d.ts +115 -0
- package/dist/types/auth-storage.d.ts +641 -0
- package/dist/types/cli.d.ts +2 -0
- package/dist/types/index.d.ts +49 -0
- package/dist/types/model-cache.d.ts +17 -0
- package/dist/types/model-manager.d.ts +62 -0
- package/dist/types/model-thinking.d.ts +71 -0
- package/dist/types/models.d.ts +12 -0
- package/dist/types/provider-details.d.ts +24 -0
- package/dist/types/provider-models/bundled-references.d.ts +4 -0
- package/dist/types/provider-models/descriptors.d.ts +48 -0
- package/dist/types/provider-models/google.d.ts +20 -0
- package/dist/types/provider-models/index.d.ts +5 -0
- package/dist/types/provider-models/ollama.d.ts +7 -0
- package/dist/types/provider-models/openai-compat.d.ts +237 -0
- package/dist/types/provider-models/special.d.ts +16 -0
- package/dist/types/providers/amazon-bedrock.d.ts +36 -0
- package/dist/types/providers/anthropic-messages-server-schema.d.ts +450 -0
- package/dist/types/providers/anthropic-messages-server.d.ts +17 -0
- package/dist/types/providers/anthropic.d.ts +188 -0
- package/dist/types/providers/aws-credentials.d.ts +43 -0
- package/dist/types/providers/aws-eventstream.d.ts +38 -0
- package/dist/types/providers/aws-sigv4.d.ts +55 -0
- package/dist/types/providers/azure-openai-responses.d.ts +15 -0
- package/dist/types/providers/cursor/gen/agent_pb.d.ts +13022 -0
- package/dist/types/providers/cursor.d.ts +42 -0
- package/dist/types/providers/error-message.d.ts +27 -0
- package/dist/types/providers/github-copilot-headers.d.ts +40 -0
- package/dist/types/providers/gitlab-duo.d.ts +27 -0
- package/dist/types/providers/google-auth.d.ts +24 -0
- package/dist/types/providers/google-gemini-cli.d.ts +72 -0
- package/dist/types/providers/google-gemini-headers.d.ts +18 -0
- package/dist/types/providers/google-shared.d.ts +163 -0
- package/dist/types/providers/google-types.d.ts +138 -0
- package/dist/types/providers/google-vertex.d.ts +7 -0
- package/dist/types/providers/google.d.ts +4 -0
- package/dist/types/providers/grammar.d.ts +1 -0
- package/dist/types/providers/kimi.d.ts +27 -0
- package/dist/types/providers/mock.d.ts +175 -0
- package/dist/types/providers/ollama.d.ts +6 -0
- package/dist/types/providers/openai-anthropic-shim.d.ts +31 -0
- package/dist/types/providers/openai-chat-server-schema.d.ts +814 -0
- package/dist/types/providers/openai-chat-server.d.ts +16 -0
- package/dist/types/providers/openai-codex/constants.d.ts +26 -0
- package/dist/types/providers/openai-codex/request-transformer.d.ts +49 -0
- package/dist/types/providers/openai-codex/response-handler.d.ts +17 -0
- package/dist/types/providers/openai-codex-responses.d.ts +67 -0
- package/dist/types/providers/openai-completions-compat.d.ts +25 -0
- package/dist/types/providers/openai-completions.d.ts +33 -0
- package/dist/types/providers/openai-responses-server-schema.d.ts +392 -0
- package/dist/types/providers/openai-responses-server.d.ts +17 -0
- package/dist/types/providers/openai-responses-shared.d.ts +89 -0
- package/dist/types/providers/openai-responses.d.ts +32 -0
- package/dist/types/providers/pi-native-client.d.ts +13 -0
- package/dist/types/providers/pi-native-server.d.ts +68 -0
- package/dist/types/providers/register-builtins.d.ts +31 -0
- package/dist/types/providers/synthetic.d.ts +26 -0
- package/dist/types/providers/transform-messages.d.ts +12 -0
- package/dist/types/providers/vision-guard.d.ts +8 -0
- package/dist/types/rate-limit-utils.d.ts +19 -0
- package/dist/types/stream.d.ts +24 -0
- package/dist/types/types.d.ts +746 -0
- package/dist/types/usage/claude.d.ts +3 -0
- package/dist/types/usage/gemini.d.ts +2 -0
- package/dist/types/usage/github-copilot.d.ts +7 -0
- package/dist/types/usage/google-antigravity.d.ts +2 -0
- package/dist/types/usage/kimi.d.ts +2 -0
- package/dist/types/usage/minimax-code.d.ts +2 -0
- package/dist/types/usage/openai-codex.d.ts +3 -0
- package/dist/types/usage/shared.d.ts +1 -0
- package/dist/types/usage/zai.d.ts +2 -0
- package/dist/types/usage.d.ts +258 -0
- package/dist/types/utils/abort.d.ts +19 -0
- package/dist/types/utils/anthropic-auth.d.ts +31 -0
- package/dist/types/utils/discovery/antigravity.d.ts +61 -0
- package/dist/types/utils/discovery/codex.d.ts +38 -0
- package/dist/types/utils/discovery/cursor.d.ts +23 -0
- package/dist/types/utils/discovery/gemini.d.ts +25 -0
- package/dist/types/utils/discovery/index.d.ts +4 -0
- package/dist/types/utils/discovery/openai-compatible.d.ts +72 -0
- package/dist/types/utils/event-stream.d.ts +28 -0
- package/dist/types/utils/fireworks-model-id.d.ts +10 -0
- package/dist/types/utils/foundry.d.ts +1 -0
- package/dist/types/utils/h2-fetch.d.ts +22 -0
- package/dist/types/utils/http-inspector.d.ts +31 -0
- package/dist/types/utils/idle-iterator.d.ts +67 -0
- package/dist/types/utils/json-parse.d.ts +10 -0
- package/dist/types/utils/oauth/alibaba-coding-plan.d.ts +18 -0
- package/dist/types/utils/oauth/anthropic.d.ts +22 -0
- package/dist/types/utils/oauth/api-key-login.d.ts +35 -0
- package/dist/types/utils/oauth/api-key-validation.d.ts +27 -0
- package/dist/types/utils/oauth/callback-server.d.ts +57 -0
- package/dist/types/utils/oauth/cerebras.d.ts +1 -0
- package/dist/types/utils/oauth/cloudflare-ai-gateway.d.ts +18 -0
- package/dist/types/utils/oauth/cursor.d.ts +15 -0
- package/dist/types/utils/oauth/deepseek.d.ts +10 -0
- package/dist/types/utils/oauth/firepass.d.ts +1 -0
- package/dist/types/utils/oauth/fireworks.d.ts +1 -0
- package/dist/types/utils/oauth/github-copilot.d.ts +38 -0
- package/dist/types/utils/oauth/gitlab-duo.d.ts +3 -0
- package/dist/types/utils/oauth/google-antigravity.d.ts +11 -0
- package/dist/types/utils/oauth/google-gemini-cli.d.ts +10 -0
- package/dist/types/utils/oauth/google-oauth-shared.d.ts +28 -0
- package/dist/types/utils/oauth/huggingface.d.ts +19 -0
- package/dist/types/utils/oauth/index.d.ts +38 -0
- package/dist/types/utils/oauth/kagi.d.ts +17 -0
- package/dist/types/utils/oauth/kilo.d.ts +5 -0
- package/dist/types/utils/oauth/kimi.d.ts +21 -0
- package/dist/types/utils/oauth/litellm.d.ts +18 -0
- package/dist/types/utils/oauth/lm-studio.d.ts +17 -0
- package/dist/types/utils/oauth/minimax-code.d.ts +28 -0
- package/dist/types/utils/oauth/moonshot.d.ts +1 -0
- package/dist/types/utils/oauth/nanogpt.d.ts +1 -0
- package/dist/types/utils/oauth/nvidia.d.ts +18 -0
- package/dist/types/utils/oauth/ollama-cloud.d.ts +2 -0
- package/dist/types/utils/oauth/ollama.d.ts +18 -0
- package/dist/types/utils/oauth/openai-codex.d.ts +21 -0
- package/dist/types/utils/oauth/opencode.d.ts +18 -0
- package/dist/types/utils/oauth/parallel.d.ts +17 -0
- package/dist/types/utils/oauth/perplexity.d.ts +9 -0
- package/dist/types/utils/oauth/pkce.d.ts +8 -0
- package/dist/types/utils/oauth/qianfan.d.ts +17 -0
- package/dist/types/utils/oauth/qwen-portal.d.ts +19 -0
- package/dist/types/utils/oauth/synthetic.d.ts +1 -0
- package/dist/types/utils/oauth/tavily.d.ts +17 -0
- package/dist/types/utils/oauth/together.d.ts +1 -0
- package/dist/types/utils/oauth/types.d.ts +44 -0
- package/dist/types/utils/oauth/venice.d.ts +18 -0
- package/dist/types/utils/oauth/vercel-ai-gateway.d.ts +18 -0
- package/dist/types/utils/oauth/vllm.d.ts +16 -0
- package/dist/types/utils/oauth/xiaomi.d.ts +19 -0
- package/dist/types/utils/oauth/zai.d.ts +18 -0
- package/dist/types/utils/oauth/zenmux.d.ts +1 -0
- package/dist/types/utils/overflow.d.ts +54 -0
- package/dist/types/utils/parse-bind.d.ts +23 -0
- package/dist/types/utils/provider-response.d.ts +3 -0
- package/dist/types/utils/retry-after.d.ts +3 -0
- package/dist/types/utils/retry.d.ts +26 -0
- package/dist/types/utils/schema/adapt.d.ts +24 -0
- package/dist/types/utils/schema/compatibility.d.ts +30 -0
- package/dist/types/utils/schema/dereference.d.ts +11 -0
- package/dist/types/utils/schema/draft.d.ts +10 -0
- package/dist/types/utils/schema/equality.d.ts +4 -0
- package/dist/types/utils/schema/fields.d.ts +49 -0
- package/dist/types/utils/schema/index.d.ts +13 -0
- package/dist/types/utils/schema/json-schema-validator.d.ts +12 -0
- package/dist/types/utils/schema/meta-validator.d.ts +2 -0
- package/dist/types/utils/schema/normalize.d.ts +93 -0
- package/dist/types/utils/schema/spill.d.ts +8 -0
- package/dist/types/utils/schema/stamps.d.ts +25 -0
- package/dist/types/utils/schema/types.d.ts +4 -0
- package/dist/types/utils/schema/wire.d.ts +54 -0
- package/dist/types/utils/schema/zod-decontaminate.d.ts +31 -0
- package/dist/types/utils/sse-debug.d.ts +10 -0
- package/dist/types/utils/tool-call-healing.d.ts +71 -0
- package/dist/types/utils/tool-choice.d.ts +50 -0
- package/dist/types/utils/validation.d.ts +17 -0
- package/dist/types/utils.d.ts +28 -0
- package/package.json +146 -0
- package/src/api-registry.ts +96 -0
- package/src/auth-broker/client.ts +358 -0
- package/src/auth-broker/index.ts +5 -0
- package/src/auth-broker/refresher.ts +127 -0
- package/src/auth-broker/remote-store.ts +623 -0
- package/src/auth-broker/server.ts +644 -0
- package/src/auth-broker/types.ts +127 -0
- package/src/auth-broker/wire-schemas.ts +200 -0
- package/src/auth-gateway/http.ts +194 -0
- package/src/auth-gateway/index.ts +3 -0
- package/src/auth-gateway/server.ts +717 -0
- package/src/auth-gateway/types.ts +134 -0
- package/src/auth-storage.ts +4104 -0
- package/src/cli.ts +262 -0
- package/src/index.ts +54 -0
- package/src/model-cache.ts +129 -0
- package/src/model-manager.ts +450 -0
- package/src/model-thinking.ts +691 -0
- package/src/models.json +73853 -0
- package/src/models.json.d.ts +9 -0
- package/src/models.ts +56 -0
- package/src/prompts/turn-aborted-guidance.md +4 -0
- package/src/provider-details.ts +90 -0
- package/src/provider-models/bundled-references.ts +38 -0
- package/src/provider-models/descriptors.ts +308 -0
- package/src/provider-models/google.ts +91 -0
- package/src/provider-models/index.ts +5 -0
- package/src/provider-models/ollama.ts +153 -0
- package/src/provider-models/openai-compat.ts +2275 -0
- package/src/provider-models/special.ts +67 -0
- package/src/providers/amazon-bedrock.ts +849 -0
- package/src/providers/anthropic-messages-server-schema.ts +229 -0
- package/src/providers/anthropic-messages-server.ts +677 -0
- package/src/providers/anthropic.ts +2696 -0
- package/src/providers/aws-credentials.ts +501 -0
- package/src/providers/aws-eventstream.ts +185 -0
- package/src/providers/aws-sigv4.ts +218 -0
- package/src/providers/azure-openai-responses.ts +337 -0
- package/src/providers/cursor/gen/agent_pb.ts +15274 -0
- package/src/providers/cursor/proto/agent.proto +3526 -0
- package/src/providers/cursor/proto/buf.gen.yaml +6 -0
- package/src/providers/cursor/proto/buf.yaml +17 -0
- package/src/providers/cursor.ts +2561 -0
- package/src/providers/error-message.ts +21 -0
- package/src/providers/github-copilot-headers.ts +140 -0
- package/src/providers/gitlab-duo.ts +372 -0
- package/src/providers/google-auth.ts +252 -0
- package/src/providers/google-gemini-cli.ts +795 -0
- package/src/providers/google-gemini-headers.ts +41 -0
- package/src/providers/google-shared.ts +902 -0
- package/src/providers/google-types.ts +167 -0
- package/src/providers/google-vertex.ts +88 -0
- package/src/providers/google.ts +41 -0
- package/src/providers/grammar.ts +70 -0
- package/src/providers/kimi.ts +52 -0
- package/src/providers/mock.ts +500 -0
- package/src/providers/ollama.ts +544 -0
- package/src/providers/openai-anthropic-shim.ts +138 -0
- package/src/providers/openai-chat-server-schema.ts +243 -0
- package/src/providers/openai-chat-server.ts +628 -0
- package/src/providers/openai-codex/constants.ts +43 -0
- package/src/providers/openai-codex/request-transformer.ts +161 -0
- package/src/providers/openai-codex/response-handler.ts +81 -0
- package/src/providers/openai-codex-responses.ts +2598 -0
- package/src/providers/openai-completions-compat.ts +279 -0
- package/src/providers/openai-completions.ts +1853 -0
- package/src/providers/openai-responses-server-schema.ts +290 -0
- package/src/providers/openai-responses-server.ts +1183 -0
- package/src/providers/openai-responses-shared.ts +800 -0
- package/src/providers/openai-responses.ts +621 -0
- package/src/providers/pi-native-client.ts +228 -0
- package/src/providers/pi-native-server.ts +210 -0
- package/src/providers/register-builtins.ts +412 -0
- package/src/providers/synthetic.ts +50 -0
- package/src/providers/transform-messages.ts +309 -0
- package/src/providers/vision-guard.ts +31 -0
- package/src/rate-limit-utils.ts +84 -0
- package/src/stream.ts +895 -0
- package/src/types.ts +884 -0
- package/src/usage/claude.ts +431 -0
- package/src/usage/gemini.ts +250 -0
- package/src/usage/github-copilot.ts +421 -0
- package/src/usage/google-antigravity.ts +201 -0
- package/src/usage/kimi.ts +271 -0
- package/src/usage/minimax-code.ts +31 -0
- package/src/usage/openai-codex.ts +503 -0
- package/src/usage/shared.ts +10 -0
- package/src/usage/zai.ts +247 -0
- package/src/usage.ts +183 -0
- package/src/utils/abort.ts +51 -0
- package/src/utils/anthropic-auth.ts +87 -0
- package/src/utils/discovery/antigravity.ts +261 -0
- package/src/utils/discovery/codex.ts +371 -0
- package/src/utils/discovery/cursor.ts +306 -0
- package/src/utils/discovery/gemini.ts +248 -0
- package/src/utils/discovery/index.ts +4 -0
- package/src/utils/discovery/openai-compatible.ts +224 -0
- package/src/utils/event-stream.ts +142 -0
- package/src/utils/fireworks-model-id.ts +30 -0
- package/src/utils/foundry.ts +8 -0
- package/src/utils/h2-fetch.ts +60 -0
- package/src/utils/http-inspector.ts +176 -0
- package/src/utils/idle-iterator.ts +250 -0
- package/src/utils/json-parse.ts +148 -0
- package/src/utils/oauth/alibaba-coding-plan.ts +59 -0
- package/src/utils/oauth/anthropic.ts +200 -0
- package/src/utils/oauth/api-key-login.ts +87 -0
- package/src/utils/oauth/api-key-validation.ts +92 -0
- package/src/utils/oauth/callback-server.ts +276 -0
- package/src/utils/oauth/cerebras.ts +16 -0
- package/src/utils/oauth/cloudflare-ai-gateway.ts +48 -0
- package/src/utils/oauth/cursor.ts +157 -0
- package/src/utils/oauth/deepseek.ts +53 -0
- package/src/utils/oauth/firepass.ts +24 -0
- package/src/utils/oauth/fireworks.ts +15 -0
- package/src/utils/oauth/github-copilot.ts +362 -0
- package/src/utils/oauth/gitlab-duo.ts +123 -0
- package/src/utils/oauth/google-antigravity.ts +200 -0
- package/src/utils/oauth/google-gemini-cli.ts +256 -0
- package/src/utils/oauth/google-oauth-shared.ts +110 -0
- package/src/utils/oauth/huggingface.ts +62 -0
- package/src/utils/oauth/index.ts +444 -0
- package/src/utils/oauth/kagi.ts +47 -0
- package/src/utils/oauth/kilo.ts +87 -0
- package/src/utils/oauth/kimi.ts +254 -0
- package/src/utils/oauth/litellm.ts +47 -0
- package/src/utils/oauth/lm-studio.ts +38 -0
- package/src/utils/oauth/minimax-code.ts +78 -0
- package/src/utils/oauth/moonshot.ts +16 -0
- package/src/utils/oauth/nanogpt.ts +15 -0
- package/src/utils/oauth/nvidia.ts +70 -0
- package/src/utils/oauth/oauth.html +199 -0
- package/src/utils/oauth/ollama-cloud.ts +28 -0
- package/src/utils/oauth/ollama.ts +47 -0
- package/src/utils/oauth/openai-codex.ts +299 -0
- package/src/utils/oauth/opencode.ts +49 -0
- package/src/utils/oauth/parallel.ts +46 -0
- package/src/utils/oauth/perplexity.ts +206 -0
- package/src/utils/oauth/pkce.ts +18 -0
- package/src/utils/oauth/qianfan.ts +58 -0
- package/src/utils/oauth/qwen-portal.ts +60 -0
- package/src/utils/oauth/synthetic.ts +16 -0
- package/src/utils/oauth/tavily.ts +46 -0
- package/src/utils/oauth/together.ts +16 -0
- package/src/utils/oauth/types.ts +94 -0
- package/src/utils/oauth/venice.ts +59 -0
- package/src/utils/oauth/vercel-ai-gateway.ts +47 -0
- package/src/utils/oauth/vllm.ts +40 -0
- package/src/utils/oauth/xiaomi.ts +137 -0
- package/src/utils/oauth/zai.ts +60 -0
- package/src/utils/oauth/zenmux.ts +15 -0
- package/src/utils/overflow.ts +137 -0
- package/src/utils/parse-bind.ts +54 -0
- package/src/utils/provider-response.ts +30 -0
- package/src/utils/retry-after.ts +110 -0
- package/src/utils/retry.ts +54 -0
- package/src/utils/schema/CONSTRAINTS.md +164 -0
- package/src/utils/schema/adapt.ts +36 -0
- package/src/utils/schema/compatibility.ts +435 -0
- package/src/utils/schema/dereference.ts +98 -0
- package/src/utils/schema/draft.ts +341 -0
- package/src/utils/schema/equality.ts +97 -0
- package/src/utils/schema/fields.ts +190 -0
- package/src/utils/schema/index.ts +13 -0
- package/src/utils/schema/json-schema-validator.ts +577 -0
- package/src/utils/schema/meta-validator.ts +167 -0
- package/src/utils/schema/normalize.ts +1588 -0
- package/src/utils/schema/spill.ts +43 -0
- package/src/utils/schema/stamps.ts +97 -0
- package/src/utils/schema/types.ts +11 -0
- package/src/utils/schema/wire.ts +213 -0
- package/src/utils/schema/zod-decontaminate.ts +331 -0
- package/src/utils/sse-debug.ts +289 -0
- package/src/utils/tool-call-healing.ts +271 -0
- package/src/utils/tool-choice.ts +99 -0
- package/src/utils/validation.ts +1019 -0
- package/src/utils.ts +166 -0
|
@@ -0,0 +1,4104 @@
|
|
|
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: persistence abstraction (SQLite, remote vault, …)
|
|
7
|
+
* - `AuthStorage` class: credential management with round-robin, usage limits, OAuth refresh
|
|
8
|
+
* - `SqliteAuthCredentialStore`: 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 "@gajae-code/utils";
|
|
14
|
+
import { getEnvApiKey } from "./stream";
|
|
15
|
+
import type { Provider } from "./types";
|
|
16
|
+
import type {
|
|
17
|
+
CredentialRankingStrategy,
|
|
18
|
+
UsageCredential,
|
|
19
|
+
UsageFetchContext,
|
|
20
|
+
UsageFetchParams,
|
|
21
|
+
UsageLimit,
|
|
22
|
+
UsageLogger,
|
|
23
|
+
UsageProvider,
|
|
24
|
+
UsageReport,
|
|
25
|
+
} from "./usage";
|
|
26
|
+
import { claudeRankingStrategy, claudeUsageProvider } from "./usage/claude";
|
|
27
|
+
import { googleGeminiCliUsageProvider } from "./usage/gemini";
|
|
28
|
+
import { githubCopilotUsageProvider } from "./usage/github-copilot";
|
|
29
|
+
import { antigravityUsageProvider } from "./usage/google-antigravity";
|
|
30
|
+
import { kimiUsageProvider } from "./usage/kimi";
|
|
31
|
+
import { codexRankingStrategy, openaiCodexUsageProvider } from "./usage/openai-codex";
|
|
32
|
+
import { zaiUsageProvider } from "./usage/zai";
|
|
33
|
+
import { getOAuthApiKey, getOAuthProvider, refreshOAuthToken } from "./utils/oauth";
|
|
34
|
+
import { loginDeepSeek } from "./utils/oauth/deepseek";
|
|
35
|
+
import { loginOpenAICodexDevice } from "./utils/oauth/openai-codex";
|
|
36
|
+
import type { OAuthController, OAuthCredentials, OAuthProvider, OAuthProviderId } from "./utils/oauth/types";
|
|
37
|
+
|
|
38
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
39
|
+
// Credential Types
|
|
40
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
41
|
+
|
|
42
|
+
export type ApiKeyCredential = {
|
|
43
|
+
type: "api_key";
|
|
44
|
+
key: string;
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
export type OAuthCredential = {
|
|
48
|
+
type: "oauth";
|
|
49
|
+
} & OAuthCredentials;
|
|
50
|
+
|
|
51
|
+
export type AuthCredential = ApiKeyCredential | OAuthCredential;
|
|
52
|
+
|
|
53
|
+
export type AuthCredentialEntry = AuthCredential | AuthCredential[];
|
|
54
|
+
|
|
55
|
+
export type AuthStorageData = Record<string, AuthCredentialEntry>;
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Serialized representation of AuthStorage for passing to subagent workers.
|
|
59
|
+
* Contains only the essential credential data, not runtime state.
|
|
60
|
+
*/
|
|
61
|
+
export interface SerializedAuthStorage {
|
|
62
|
+
credentials: Record<
|
|
63
|
+
string,
|
|
64
|
+
Array<{
|
|
65
|
+
id: number;
|
|
66
|
+
type: "api_key" | "oauth";
|
|
67
|
+
data: Record<string, unknown>;
|
|
68
|
+
}>
|
|
69
|
+
>;
|
|
70
|
+
runtimeOverrides?: Record<string, string>;
|
|
71
|
+
dbPath?: string;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Auth credential with database row ID for updates/deletes.
|
|
76
|
+
* Wraps AuthCredential with storage metadata.
|
|
77
|
+
*/
|
|
78
|
+
export interface StoredAuthCredential {
|
|
79
|
+
id: number;
|
|
80
|
+
provider: string;
|
|
81
|
+
credential: AuthCredential;
|
|
82
|
+
disabledCause: string | null;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Per-credential health record returned by {@link AuthStorage.checkCredentials}.
|
|
87
|
+
*
|
|
88
|
+
* Use this to identify which credential in a multi-account pool is causing
|
|
89
|
+
* auth errors. `ok` is tri-state:
|
|
90
|
+
*
|
|
91
|
+
* - `true` — credential authenticated against the provider's auth-verifying
|
|
92
|
+
* probe (today: the usage endpoint). For OAuth this also exercises refresh
|
|
93
|
+
* when the access token was expired.
|
|
94
|
+
* - `false` — the probe rejected the credential (401/403/refresh failure/etc).
|
|
95
|
+
* `reason` carries the upstream error string.
|
|
96
|
+
* - `null` — no probe is configured for this provider (or the configured
|
|
97
|
+
* probe doesn't support this credential type). The credential's auth
|
|
98
|
+
* status is unverifiable from here.
|
|
99
|
+
*/
|
|
100
|
+
export interface CredentialHealthResult {
|
|
101
|
+
/** Database row id (matches {@link StoredAuthCredential.id}). */
|
|
102
|
+
id: number;
|
|
103
|
+
provider: string;
|
|
104
|
+
type: AuthCredential["type"];
|
|
105
|
+
/** OAuth email if known on the stored credential or surfaced by the probe. */
|
|
106
|
+
email?: string;
|
|
107
|
+
/** OAuth account id / org id if known. */
|
|
108
|
+
accountId?: string;
|
|
109
|
+
/** `true` when the refresh token lives on a remote broker (sentinel was present). */
|
|
110
|
+
remoteRefresh?: true;
|
|
111
|
+
ok: boolean | null;
|
|
112
|
+
/** Failure / unverifiable reason; absent when `ok === true`. */
|
|
113
|
+
reason?: string;
|
|
114
|
+
/** Probe usage report (raw payload stripped) when `ok === true`. */
|
|
115
|
+
report?: Omit<UsageReport, "raw">;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
export interface CheckCredentialsOptions {
|
|
119
|
+
signal?: AbortSignal;
|
|
120
|
+
/** Per-credential probe timeout (ms). Defaults to the configured usage request timeout. */
|
|
121
|
+
timeoutMs?: number;
|
|
122
|
+
/** Provider → base URL override, same shape as {@link AuthStorage.fetchUsageReports}. */
|
|
123
|
+
baseUrlResolver?: (provider: Provider) => string | undefined;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
127
|
+
// Auth Broker Snapshot Types
|
|
128
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Sentinel value placed in OAuth `refresh` fields when a credential is shared
|
|
132
|
+
* via {@link AuthStorage.exportSnapshot}. Refresh tokens never leave the broker;
|
|
133
|
+
* clients must call back to refresh.
|
|
134
|
+
*/
|
|
135
|
+
export const REMOTE_REFRESH_SENTINEL = "__remote__" as const;
|
|
136
|
+
export type RemoteRefreshSentinel = typeof REMOTE_REFRESH_SENTINEL;
|
|
137
|
+
|
|
138
|
+
/** OAuth credential with refresh token replaced by the broker sentinel. */
|
|
139
|
+
export type RemoteOAuthCredential = Omit<OAuthCredential, "refresh"> & {
|
|
140
|
+
refresh: RemoteRefreshSentinel;
|
|
141
|
+
};
|
|
142
|
+
|
|
143
|
+
/** Discriminated credential payload as published by the broker. */
|
|
144
|
+
export type SnapshotCredential = ApiKeyCredential | RemoteOAuthCredential;
|
|
145
|
+
|
|
146
|
+
export interface AuthCredentialSnapshotEntry {
|
|
147
|
+
id: number;
|
|
148
|
+
provider: string;
|
|
149
|
+
credential: SnapshotCredential;
|
|
150
|
+
identityKey: string | null;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* Wire-shaped snapshot exported by {@link AuthStorage.exportSnapshot} and
|
|
155
|
+
* served by the auth-broker server on `GET /v1/snapshot`.
|
|
156
|
+
*/
|
|
157
|
+
export interface AuthCredentialSnapshot {
|
|
158
|
+
generation: number;
|
|
159
|
+
generatedAt: number;
|
|
160
|
+
credentials: AuthCredentialSnapshotEntry[];
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
164
|
+
// AuthCredentialStore interface
|
|
165
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* Persistence abstraction consumed by {@link AuthStorage}.
|
|
169
|
+
*
|
|
170
|
+
* Concrete implementations:
|
|
171
|
+
* - {@link SqliteAuthCredentialStore} — local SQLite-backed store (default).
|
|
172
|
+
* - `RemoteAuthCredentialStore` from `./auth-broker` — client-side snapshot of
|
|
173
|
+
* a remote broker; mutating methods (`replace*`, `upsert*`, `delete*ForProvider`)
|
|
174
|
+
* throw because login flows route through the broker, not the client.
|
|
175
|
+
*/
|
|
176
|
+
export interface AuthCredentialStore {
|
|
177
|
+
close(): void;
|
|
178
|
+
listAuthCredentials(provider?: string): StoredAuthCredential[];
|
|
179
|
+
updateAuthCredential(id: number, credential: AuthCredential): void;
|
|
180
|
+
deleteAuthCredential(id: number, disabledCause: string): void;
|
|
181
|
+
tryDisableAuthCredentialIfMatches(id: number, expectedData: string, disabledCause: string): boolean;
|
|
182
|
+
replaceAuthCredentialsForProvider(provider: string, credentials: AuthCredential[]): StoredAuthCredential[];
|
|
183
|
+
upsertAuthCredentialForProvider(provider: string, credential: AuthCredential): StoredAuthCredential[];
|
|
184
|
+
deleteAuthCredentialsForProvider(provider: string, disabledCause: string): void;
|
|
185
|
+
getCache(key: string, options?: { includeExpired?: boolean }): string | null;
|
|
186
|
+
setCache(key: string, value: string, expiresAtSec: number): void;
|
|
187
|
+
cleanExpiredCache(): void;
|
|
188
|
+
/**
|
|
189
|
+
* Optional store-supplied OAuth refresh. When present, `AuthStorage` uses
|
|
190
|
+
* it before the per-provider local refresh path. `RemoteAuthCredentialStore`
|
|
191
|
+
* implements this against the broker; SQLite stores leave it undefined.
|
|
192
|
+
*
|
|
193
|
+
* Precedence: `AuthStorageOptions.refreshOAuthCredential` > this hook > local.
|
|
194
|
+
*
|
|
195
|
+
* `signal` propagates the agent's cancel (ESC, request abort, …) all the
|
|
196
|
+
* way to the broker fetch so a hung connection can't strand the caller
|
|
197
|
+
* for `timeoutMs * (maxRetries + 1)`.
|
|
198
|
+
*/
|
|
199
|
+
refreshOAuthCredential?(
|
|
200
|
+
provider: Provider,
|
|
201
|
+
credentialId: number,
|
|
202
|
+
credential: OAuthCredential,
|
|
203
|
+
signal?: AbortSignal,
|
|
204
|
+
): Promise<OAuthCredentials>;
|
|
205
|
+
/**
|
|
206
|
+
* Optional async pre-read hook invoked after AuthStorage selects a stored
|
|
207
|
+
* credential but before it returns that credential for an outbound request.
|
|
208
|
+
* Remote broker stores use this to wait out imminent rotations and refresh
|
|
209
|
+
* their local snapshot before the caller sees a stale access token.
|
|
210
|
+
*/
|
|
211
|
+
prepareForRequest?(credentialId: number, opts?: { signal?: AbortSignal }): Promise<boolean | undefined>;
|
|
212
|
+
/**
|
|
213
|
+
* Optional store-supplied aggregate usage fetch. When present, `AuthStorage`
|
|
214
|
+
* routes `fetchUsageReports()` here instead of fanning out per-credential.
|
|
215
|
+
* `RemoteAuthCredentialStore` proxies to the broker (whose datacenter IP
|
|
216
|
+
* isn't rate-limited like a heavy residential client).
|
|
217
|
+
*
|
|
218
|
+
* Precedence: `AuthStorageOptions.fetchUsageReports` > this hook > local fan-out.
|
|
219
|
+
*
|
|
220
|
+
* `signal` propagates the agent's cancel down to the broker fetch.
|
|
221
|
+
*/
|
|
222
|
+
fetchUsageReports?(signal?: AbortSignal): Promise<UsageReport[] | null>;
|
|
223
|
+
/**
|
|
224
|
+
* Optional store-supplied per-credential usage report lookup. When present,
|
|
225
|
+
* `AuthStorage` consults this before its own per-credential upstream fetch
|
|
226
|
+
* (`#getUsageReport`). `RemoteAuthCredentialStore` implements this against
|
|
227
|
+
* the broker's aggregate `/v1/usage` (one coalesced round-trip shared across
|
|
228
|
+
* all callers) so multi-credential ranking on the client never hits the
|
|
229
|
+
* upstream provider's rate-limited usage endpoint from the laptop IP.
|
|
230
|
+
*
|
|
231
|
+
* Returning `null` is authoritative — `AuthStorage` does NOT fall back to
|
|
232
|
+
* the local fetch path. The store hook owns the decision, since falling
|
|
233
|
+
* back would re-introduce the per-IP rate-limit problem the broker exists
|
|
234
|
+
* to avoid.
|
|
235
|
+
*
|
|
236
|
+
* `signal` propagates the agent's cancel down to the broker fetch.
|
|
237
|
+
*/
|
|
238
|
+
getUsageReport?(provider: Provider, credential: OAuthCredential, signal?: AbortSignal): Promise<UsageReport | null>;
|
|
239
|
+
/**
|
|
240
|
+
* Optional store hook to invalidate a specific credential after the upstream
|
|
241
|
+
* provider returned 401 on a supposedly-fresh key. Remote stores force the
|
|
242
|
+
* broker to re-issue the row; local stores can leave it undefined and let
|
|
243
|
+
* {@link AuthStorage.invalidateCredentialMatching} fall back to `reload()`.
|
|
244
|
+
*/
|
|
245
|
+
markCredentialSuspect?(credentialId: number, opts?: { signal?: AbortSignal }): Promise<void>;
|
|
246
|
+
/**
|
|
247
|
+
* Optional async write hook for upserting a single credential. When present,
|
|
248
|
+
* `AuthStorage.#upsertOAuthCredential` routes through this instead of the
|
|
249
|
+
* sync `upsertAuthCredentialForProvider`. `RemoteAuthCredentialStore` uses
|
|
250
|
+
* it to send the upsert to the broker via `POST /v1/credential`.
|
|
251
|
+
*
|
|
252
|
+
* Implementations MUST update the in-memory snapshot before returning so the
|
|
253
|
+
* post-write read path is consistent.
|
|
254
|
+
*/
|
|
255
|
+
upsertAuthCredentialRemote?(provider: string, credential: AuthCredential): Promise<StoredAuthCredential[]>;
|
|
256
|
+
/**
|
|
257
|
+
* Optional async write hook for replace-all semantics (e.g. API-key login
|
|
258
|
+
* overwriting any previous keys for the same provider). When present,
|
|
259
|
+
* `AuthStorage.set` routes through this instead of the sync
|
|
260
|
+
* `replaceAuthCredentialsForProvider`.
|
|
261
|
+
*/
|
|
262
|
+
replaceAuthCredentialsRemote?(provider: string, credentials: AuthCredential[]): Promise<StoredAuthCredential[]>;
|
|
263
|
+
/**
|
|
264
|
+
* Optional async write hook for clearing every credential for a provider
|
|
265
|
+
* (logout). When present, `AuthStorage.remove` routes through this instead
|
|
266
|
+
* of the sync `deleteAuthCredentialsForProvider`.
|
|
267
|
+
*/
|
|
268
|
+
deleteAuthCredentialsRemote?(provider: string, disabledCause: string): Promise<void>;
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
272
|
+
// AuthStorage Options
|
|
273
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
274
|
+
|
|
275
|
+
/**
|
|
276
|
+
* Event payload describing a credential that was just soft-disabled.
|
|
277
|
+
*
|
|
278
|
+
* Today the only call site is OAuth refresh failures with a definitive cause
|
|
279
|
+
* (`invalid_grant`, `401/403` not from a network blip, etc.) — the
|
|
280
|
+
* disabled_cause string is the verbatim error captured for forensics.
|
|
281
|
+
*
|
|
282
|
+
* Subscribers can use this to surface a notification, banner, or auto-launch
|
|
283
|
+
* a re-login flow instead of letting the credential silently disappear.
|
|
284
|
+
*/
|
|
285
|
+
export interface CredentialDisabledEvent {
|
|
286
|
+
provider: string;
|
|
287
|
+
disabledCause: string;
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
export type AuthStorageOptions = {
|
|
291
|
+
usageProviderResolver?: (provider: Provider) => UsageProvider | undefined;
|
|
292
|
+
rankingStrategyResolver?: (provider: Provider) => CredentialRankingStrategy | undefined;
|
|
293
|
+
usageFetch?: typeof fetch;
|
|
294
|
+
usageRequestTimeoutMs?: number;
|
|
295
|
+
usageLogger?: UsageLogger;
|
|
296
|
+
/**
|
|
297
|
+
* Resolve a config value (API key, header value, etc.) to an actual value.
|
|
298
|
+
* - coding-agent injects its resolveConfigValue (supports "!command" syntax via pi-natives)
|
|
299
|
+
* - Default: checks environment variable first, then treats as literal
|
|
300
|
+
*/
|
|
301
|
+
configValueResolver?: (config: string) => Promise<string | undefined>;
|
|
302
|
+
/**
|
|
303
|
+
* Optional callback fired when AuthStorage automatically disables a
|
|
304
|
+
* credential because something detected it as no longer usable — today
|
|
305
|
+
* that's the OAuth refresh-failure path in `getApiKey`. NOT fired for
|
|
306
|
+
* user-initiated `remove()` (the user already knows) or dedup of
|
|
307
|
+
* duplicate credentials (uninteresting hygiene).
|
|
308
|
+
*/
|
|
309
|
+
onCredentialDisabled?: (event: CredentialDisabledEvent) => void | Promise<void>;
|
|
310
|
+
/**
|
|
311
|
+
* Override OAuth refresh. When set, `AuthStorage` calls this instead of the
|
|
312
|
+
* per-provider local refresh function. Receives the credential id so the
|
|
313
|
+
* implementation can address remote credentials.
|
|
314
|
+
*
|
|
315
|
+
* Must return updated {@link OAuthCredentials} with at least `access` and
|
|
316
|
+
* `expires`. `refresh` may be an opaque sentinel (e.g. `"__remote__"`) when
|
|
317
|
+
* the actual refresh token never leaves the broker.
|
|
318
|
+
*/
|
|
319
|
+
refreshOAuthCredential?: (
|
|
320
|
+
provider: Provider,
|
|
321
|
+
credentialId: number,
|
|
322
|
+
credential: OAuthCredential,
|
|
323
|
+
signal?: AbortSignal,
|
|
324
|
+
) => Promise<OAuthCredentials>;
|
|
325
|
+
/**
|
|
326
|
+
* Human-readable description of the credential store backing this
|
|
327
|
+
* AuthStorage instance. Surfaced through {@link AuthStorage.describeCredentialSource}
|
|
328
|
+
* so the TUI can show where a token came from (broker URL or local SQLite path).
|
|
329
|
+
*
|
|
330
|
+
* Examples:
|
|
331
|
+
* - `"local ~/.gjc/agent/agent.db"`
|
|
332
|
+
* - `"broker http://can.internal:8765"`
|
|
333
|
+
*/
|
|
334
|
+
sourceLabel?: string;
|
|
335
|
+
/**
|
|
336
|
+
* Override `fetchUsageReports`. When set, `AuthStorage.fetchUsageReports`
|
|
337
|
+
* calls this instead of fanning out per-credential. The primary use case is
|
|
338
|
+
* routing through a broker that egresses from a less-throttled IP — e.g. a
|
|
339
|
+
* residential laptop trips Anthropic's per-IP rate limit on the usage
|
|
340
|
+
* endpoint and drops 2-of-5 credentials, while the VPS broker gets all 5.
|
|
341
|
+
*
|
|
342
|
+
* Implementations may return null when no usage data is available; the
|
|
343
|
+
* AuthStorage caller surfaces that to its own consumer unchanged.
|
|
344
|
+
*/
|
|
345
|
+
fetchUsageReports?: (signal?: AbortSignal) => Promise<UsageReport[] | null>;
|
|
346
|
+
};
|
|
347
|
+
|
|
348
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
349
|
+
// Default Config Value Resolver
|
|
350
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
351
|
+
|
|
352
|
+
/**
|
|
353
|
+
* Default config value resolver that checks env vars and treats as literal.
|
|
354
|
+
* Does NOT support "!command" syntax (that requires pi-natives).
|
|
355
|
+
*/
|
|
356
|
+
async function defaultConfigValueResolver(config: string): Promise<string | undefined> {
|
|
357
|
+
const envValue = process.env[config];
|
|
358
|
+
return envValue || config;
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
362
|
+
// Usage Providers (defaults)
|
|
363
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
364
|
+
|
|
365
|
+
const DEFAULT_USAGE_PROVIDERS: UsageProvider[] = [
|
|
366
|
+
openaiCodexUsageProvider,
|
|
367
|
+
kimiUsageProvider,
|
|
368
|
+
antigravityUsageProvider,
|
|
369
|
+
googleGeminiCliUsageProvider,
|
|
370
|
+
claudeUsageProvider,
|
|
371
|
+
zaiUsageProvider,
|
|
372
|
+
githubCopilotUsageProvider,
|
|
373
|
+
];
|
|
374
|
+
|
|
375
|
+
const DEFAULT_USAGE_PROVIDER_MAP = new Map<Provider, UsageProvider>(
|
|
376
|
+
DEFAULT_USAGE_PROVIDERS.map(provider => [provider.id, provider]),
|
|
377
|
+
);
|
|
378
|
+
|
|
379
|
+
const USAGE_CACHE_PREFIX = "usage_cache:";
|
|
380
|
+
// 5 min stale tolerance. Anthropic / OpenAI rate-limit /usage hard at the IP
|
|
381
|
+
// level so we can't fetch all N credentials every cycle; with a long cache
|
|
382
|
+
// each credential's last-known value sticks visible while peers retry. UI
|
|
383
|
+
// data (5h / 7d / monthly limits) is fine being a few minutes stale.
|
|
384
|
+
const USAGE_REPORT_TTL_MS = 5 * 60_000;
|
|
385
|
+
const USAGE_LAST_GOOD_RETENTION_MS = 24 * 60 * 60_000;
|
|
386
|
+
/**
|
|
387
|
+
* Per-credential cool-down after a usage fetch fails. While this window is
|
|
388
|
+
* active we serve the last successful value to avoid dropping the credential
|
|
389
|
+
* from the report; without a previous value we just return null and retry
|
|
390
|
+
* on the next poll.
|
|
391
|
+
*/
|
|
392
|
+
const USAGE_FAILURE_BACKOFF_MS = 10_000;
|
|
393
|
+
// Bumped from 3s — Anthropic model usage retries up to 3 times with exponential backoff
|
|
394
|
+
// (~3.5s total worst case); a tight per-request budget aborts retries mid-cycle.
|
|
395
|
+
const DEFAULT_USAGE_REQUEST_TIMEOUT_MS = 10_000;
|
|
396
|
+
const DEFAULT_OAUTH_REFRESH_TIMEOUT_MS = 10_000;
|
|
397
|
+
/**
|
|
398
|
+
* Refresh OAuth access tokens this many ms before their stated expiry. The
|
|
399
|
+
* skew exists so callers downstream of {@link AuthStorage} (stream providers,
|
|
400
|
+
* usage probes, web_search) never observe a credential that is expired or
|
|
401
|
+
* about to expire mid-request — there's a single rotation point and everyone
|
|
402
|
+
* downstream trusts the token they receive.
|
|
403
|
+
*
|
|
404
|
+
* Set to 60s: comfortably absorbs request RTT + a clock-skew window without
|
|
405
|
+
* triggering a refresh on every request. Provider token endpoints typically
|
|
406
|
+
* mint access tokens with 30-60min lifetimes, so refreshing 60s early changes
|
|
407
|
+
* the rotation cadence by <4%.
|
|
408
|
+
*/
|
|
409
|
+
const OAUTH_REFRESH_SKEW_MS = 60_000;
|
|
410
|
+
/**
|
|
411
|
+
* Cap on the buffered credential_disabled backlog held while no handler is attached.
|
|
412
|
+
* In practice the backlog is 0–N where N ≈ active providers (≤ ~20). The cap exists so
|
|
413
|
+
* pathological detach-without-reattach loops can't grow memory unboundedly.
|
|
414
|
+
*/
|
|
415
|
+
const MAX_PENDING_DISABLED_EVENTS = 32;
|
|
416
|
+
|
|
417
|
+
type UsageCacheEntry<T> = {
|
|
418
|
+
value: T;
|
|
419
|
+
expiresAt: number;
|
|
420
|
+
};
|
|
421
|
+
|
|
422
|
+
interface UsageCache {
|
|
423
|
+
get<T>(key: string): UsageCacheEntry<T> | undefined;
|
|
424
|
+
getStale<T>(key: string): UsageCacheEntry<T> | undefined;
|
|
425
|
+
set<T>(key: string, entry: UsageCacheEntry<T>): void;
|
|
426
|
+
cleanup?(): void;
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
type UsageRequestDescriptor = {
|
|
430
|
+
provider: Provider;
|
|
431
|
+
credential: UsageCredential;
|
|
432
|
+
baseUrl?: string;
|
|
433
|
+
};
|
|
434
|
+
|
|
435
|
+
type AuthApiKeyOptions = {
|
|
436
|
+
baseUrl?: string;
|
|
437
|
+
modelId?: string;
|
|
438
|
+
/**
|
|
439
|
+
* Caller's cancel signal. Threaded into any broker-bound OAuth refresh so
|
|
440
|
+
* `ESC` / request abort actually kills a hung broker fetch instead of
|
|
441
|
+
* stranding the caller for `timeoutMs * (maxRetries + 1)`.
|
|
442
|
+
*/
|
|
443
|
+
signal?: AbortSignal;
|
|
444
|
+
};
|
|
445
|
+
type OAuthResolutionResult = { apiKey: string; credential: OAuthCredential };
|
|
446
|
+
|
|
447
|
+
/**
|
|
448
|
+
* Refreshed OAuth access plus identity metadata returned by
|
|
449
|
+
* {@link AuthStorage.getOAuthAccess}. Callers that authenticate via a bearer
|
|
450
|
+
* AND need the credential's identity (OpenAI code backend `chatgpt-account-id`, Google
|
|
451
|
+
* `projectId`, GitHub `enterpriseUrl`) consume this shape directly; the
|
|
452
|
+
* refresh slot is deliberately omitted because rotating refresh tokens never
|
|
453
|
+
* leave {@link AuthStorage}.
|
|
454
|
+
*/
|
|
455
|
+
export interface OAuthAccess {
|
|
456
|
+
accessToken: string;
|
|
457
|
+
accountId?: string;
|
|
458
|
+
email?: string;
|
|
459
|
+
projectId?: string;
|
|
460
|
+
enterpriseUrl?: string;
|
|
461
|
+
}
|
|
462
|
+
export interface InvalidateCredentialMatchingOptions {
|
|
463
|
+
signal?: AbortSignal;
|
|
464
|
+
sessionId?: string;
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
function isAbortSignalOption(
|
|
468
|
+
value: InvalidateCredentialMatchingOptions | AbortSignal | undefined,
|
|
469
|
+
): value is AbortSignal {
|
|
470
|
+
return typeof value === "object" && value !== null && "aborted" in value && "addEventListener" in value;
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
function requiresOpenAICodexProModel(provider: string, modelId: string | undefined): boolean {
|
|
474
|
+
return provider === "openai-codex" && typeof modelId === "string" && modelId.includes("-spark");
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
function getUsagePlanType(report: UsageReport | null): string | undefined {
|
|
478
|
+
const metadata = report?.metadata;
|
|
479
|
+
if (!metadata || typeof metadata !== "object" || Array.isArray(metadata)) return undefined;
|
|
480
|
+
const planType = (metadata as { planType?: unknown }).planType;
|
|
481
|
+
return typeof planType === "string" ? planType.toLowerCase() : undefined;
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
function getOpenAICodexPlanPriority(report: UsageReport | null): number {
|
|
485
|
+
const planType = getUsagePlanType(report);
|
|
486
|
+
if (!planType) return 1;
|
|
487
|
+
return planType.includes("pro") ? 0 : 2;
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
function hasOpenAICodexProPlan(report: UsageReport | null): boolean {
|
|
491
|
+
return getUsagePlanType(report)?.includes("pro") === true;
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
function resolveDefaultUsageProvider(provider: Provider): UsageProvider | undefined {
|
|
495
|
+
return DEFAULT_USAGE_PROVIDER_MAP.get(provider);
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
const DEFAULT_RANKING_STRATEGIES = new Map<Provider, CredentialRankingStrategy>([
|
|
499
|
+
["openai-codex", codexRankingStrategy],
|
|
500
|
+
["anthropic", claudeRankingStrategy],
|
|
501
|
+
]);
|
|
502
|
+
|
|
503
|
+
function resolveDefaultRankingStrategy(provider: Provider): CredentialRankingStrategy | undefined {
|
|
504
|
+
return DEFAULT_RANKING_STRATEGIES.get(provider);
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
function parseUsageCacheEntry<T>(raw: string): UsageCacheEntry<T> | undefined {
|
|
508
|
+
try {
|
|
509
|
+
const parsed = JSON.parse(raw) as { value?: T; expiresAt?: unknown };
|
|
510
|
+
const expiresAt = typeof parsed.expiresAt === "number" ? parsed.expiresAt : undefined;
|
|
511
|
+
if (!expiresAt || !Number.isFinite(expiresAt)) return undefined;
|
|
512
|
+
return { value: parsed.value as T, expiresAt };
|
|
513
|
+
} catch {
|
|
514
|
+
return undefined;
|
|
515
|
+
}
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
/**
|
|
519
|
+
* Race `promise` against `signal`, rejecting only this caller when the signal
|
|
520
|
+
* fires. The underlying promise keeps running so other awaiters on the same
|
|
521
|
+
* single-flight fetch aren't punished by a peer's cancel.
|
|
522
|
+
*/
|
|
523
|
+
function raceUsageWithSignal<T>(promise: Promise<T>, signal: AbortSignal | undefined): Promise<T> {
|
|
524
|
+
if (!signal) return promise;
|
|
525
|
+
if (signal.aborted) return Promise.reject(new Error("usage fetch aborted"));
|
|
526
|
+
return new Promise<T>((resolve, reject) => {
|
|
527
|
+
const onAbort = (): void => {
|
|
528
|
+
signal.removeEventListener("abort", onAbort);
|
|
529
|
+
reject(new Error("usage fetch aborted"));
|
|
530
|
+
};
|
|
531
|
+
signal.addEventListener("abort", onAbort, { once: true });
|
|
532
|
+
promise.then(
|
|
533
|
+
value => {
|
|
534
|
+
signal.removeEventListener("abort", onAbort);
|
|
535
|
+
resolve(value);
|
|
536
|
+
},
|
|
537
|
+
err => {
|
|
538
|
+
signal.removeEventListener("abort", onAbort);
|
|
539
|
+
reject(err);
|
|
540
|
+
},
|
|
541
|
+
);
|
|
542
|
+
});
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
function raceCredentialRefreshWithSignal<T>(
|
|
546
|
+
promise: Promise<T>,
|
|
547
|
+
signal: AbortSignal | undefined,
|
|
548
|
+
message = "credential refresh aborted",
|
|
549
|
+
): Promise<T> {
|
|
550
|
+
if (!signal) return promise;
|
|
551
|
+
if (signal.aborted) return Promise.reject(new Error(message));
|
|
552
|
+
const abort = Promise.withResolvers<never>();
|
|
553
|
+
const onAbort = (): void => abort.reject(new Error(message));
|
|
554
|
+
signal.addEventListener("abort", onAbort, { once: true });
|
|
555
|
+
return Promise.race([promise, abort.promise]).finally(() => {
|
|
556
|
+
signal.removeEventListener("abort", onAbort);
|
|
557
|
+
});
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
function authCredentialEquals(left: AuthCredential, right: AuthCredential): boolean {
|
|
561
|
+
if (left.type !== right.type) return false;
|
|
562
|
+
if (left.type === "api_key") {
|
|
563
|
+
return right.type === "api_key" && left.key === right.key;
|
|
564
|
+
}
|
|
565
|
+
if (right.type !== "oauth") return false;
|
|
566
|
+
return (
|
|
567
|
+
left.access === right.access &&
|
|
568
|
+
left.refresh === right.refresh &&
|
|
569
|
+
left.expires === right.expires &&
|
|
570
|
+
left.accountId === right.accountId &&
|
|
571
|
+
left.email === right.email &&
|
|
572
|
+
left.projectId === right.projectId &&
|
|
573
|
+
left.enterpriseUrl === right.enterpriseUrl
|
|
574
|
+
);
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
function storedCredentialArraysEqual(left: StoredCredential[], right: StoredCredential[]): boolean {
|
|
578
|
+
if (left.length !== right.length) return false;
|
|
579
|
+
for (let index = 0; index < left.length; index += 1) {
|
|
580
|
+
const leftEntry = left[index];
|
|
581
|
+
const rightEntry = right[index];
|
|
582
|
+
if (!leftEntry || !rightEntry) return false;
|
|
583
|
+
if (leftEntry.id !== rightEntry.id) return false;
|
|
584
|
+
if (!authCredentialEquals(leftEntry.credential, rightEntry.credential)) return false;
|
|
585
|
+
}
|
|
586
|
+
return true;
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
590
|
+
// Usage Cache (backed by AuthCredentialStore)
|
|
591
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
592
|
+
|
|
593
|
+
class AuthStorageUsageCache implements UsageCache {
|
|
594
|
+
constructor(private store: AuthCredentialStore) {}
|
|
595
|
+
|
|
596
|
+
get<T>(key: string): UsageCacheEntry<T> | undefined {
|
|
597
|
+
const raw = this.store.getCache(`${USAGE_CACHE_PREFIX}${key}`);
|
|
598
|
+
if (!raw) return undefined;
|
|
599
|
+
return parseUsageCacheEntry<T>(raw);
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
getStale<T>(key: string): UsageCacheEntry<T> | undefined {
|
|
603
|
+
const raw = this.store.getCache(`${USAGE_CACHE_PREFIX}${key}`, { includeExpired: true });
|
|
604
|
+
if (!raw) return undefined;
|
|
605
|
+
return parseUsageCacheEntry<T>(raw);
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
set<T>(key: string, entry: UsageCacheEntry<T>): void {
|
|
609
|
+
const payload = JSON.stringify({ value: entry.value, expiresAt: entry.expiresAt });
|
|
610
|
+
const durableExpiresAt =
|
|
611
|
+
entry.value === null ? entry.expiresAt : Math.max(entry.expiresAt, Date.now() + USAGE_LAST_GOOD_RETENTION_MS);
|
|
612
|
+
this.store.setCache(`${USAGE_CACHE_PREFIX}${key}`, payload, Math.floor(durableExpiresAt / 1000));
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
cleanup(): void {
|
|
616
|
+
this.store.cleanExpiredCache();
|
|
617
|
+
}
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
621
|
+
// In-memory representation
|
|
622
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
623
|
+
|
|
624
|
+
type StoredCredential = { id: number; credential: AuthCredential };
|
|
625
|
+
|
|
626
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
627
|
+
// AuthStorage Class
|
|
628
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
629
|
+
|
|
630
|
+
/**
|
|
631
|
+
* Credential storage backed by an AuthCredentialStore.
|
|
632
|
+
* Reads from storage on reload(), manages round-robin credential selection,
|
|
633
|
+
* usage limit tracking, and OAuth token refresh.
|
|
634
|
+
*/
|
|
635
|
+
export class AuthStorage {
|
|
636
|
+
static readonly #defaultBackoffMs = 60_000; // Default backoff when no reset time available
|
|
637
|
+
|
|
638
|
+
/** Provider -> credentials cache, populated from store on reload(). */
|
|
639
|
+
#data: Map<string, StoredCredential[]> = new Map();
|
|
640
|
+
#runtimeOverrides: Map<string, string> = new Map();
|
|
641
|
+
#configOverrides: Map<string, string> = new Map();
|
|
642
|
+
/** Tracks next credential index per provider:type key for round-robin distribution (non-session use). */
|
|
643
|
+
#providerRoundRobinIndex: Map<string, number> = new Map();
|
|
644
|
+
/** Tracks the last used credential per provider for a session (used for rate-limit switching). */
|
|
645
|
+
#sessionLastCredential: Map<string, Map<string, { type: AuthCredential["type"]; index: number }>> = new Map();
|
|
646
|
+
/** Maps provider:type -> credentialIndex -> blockedUntilMs for temporary backoff. */
|
|
647
|
+
#credentialBackoff: Map<string, Map<number, number>> = new Map();
|
|
648
|
+
#usageProviderResolver?: (provider: Provider) => UsageProvider | undefined;
|
|
649
|
+
#rankingStrategyResolver?: (provider: Provider) => CredentialRankingStrategy | undefined;
|
|
650
|
+
#usageCache: UsageCache;
|
|
651
|
+
#usageRequestInFlight: Map<string, Promise<UsageReport | null>> = new Map();
|
|
652
|
+
#usageReportsInFlight: Map<string, Promise<UsageReport[] | null>> = new Map();
|
|
653
|
+
#usageFetch: typeof fetch;
|
|
654
|
+
#usageRequestTimeoutMs: number;
|
|
655
|
+
#usageLogger?: UsageLogger;
|
|
656
|
+
#fallbackResolver?: (provider: string) => string | undefined;
|
|
657
|
+
#store: AuthCredentialStore;
|
|
658
|
+
#configValueResolver: (config: string) => Promise<string | undefined>;
|
|
659
|
+
#refreshOAuthCredentialOverride?: AuthStorageOptions["refreshOAuthCredential"];
|
|
660
|
+
#fetchUsageReportsOverride?: AuthStorageOptions["fetchUsageReports"];
|
|
661
|
+
#sourceLabel?: string;
|
|
662
|
+
#credentialDisabledListeners: Set<(event: CredentialDisabledEvent) => void | Promise<void>> = new Set();
|
|
663
|
+
/**
|
|
664
|
+
* Buffer for credential_disabled events fired while no listener is subscribed.
|
|
665
|
+
* Drained (in insertion order) to the first listener that triggers the empty→non-empty
|
|
666
|
+
* transition via {@link AuthStorage.onCredentialDisabled}. Bounded at
|
|
667
|
+
* {@link MAX_PENDING_DISABLED_EVENTS}; oldest entries are dropped to keep memory predictable
|
|
668
|
+
* if a long-lived AuthStorage somehow accumulates a backlog (provider count is naturally small,
|
|
669
|
+
* but a process that runs without subscribers for a long time shouldn't grow this unboundedly).
|
|
670
|
+
*/
|
|
671
|
+
#pendingDisabledEvents: CredentialDisabledEvent[] = [];
|
|
672
|
+
#generation = 1;
|
|
673
|
+
#generationListeners: Set<(generation: number) => void> = new Set();
|
|
674
|
+
#oauthRefreshInFlight: Map<number, Promise<AuthCredentialSnapshotEntry>> = new Map();
|
|
675
|
+
#oauthCredentialRefreshInFlight: Map<number, Promise<OAuthCredentials>> = new Map();
|
|
676
|
+
#closed = false;
|
|
677
|
+
|
|
678
|
+
constructor(store: AuthCredentialStore, options: AuthStorageOptions = {}) {
|
|
679
|
+
this.#store = store;
|
|
680
|
+
this.#configValueResolver = options.configValueResolver ?? defaultConfigValueResolver;
|
|
681
|
+
this.#usageProviderResolver = options.usageProviderResolver ?? resolveDefaultUsageProvider;
|
|
682
|
+
this.#rankingStrategyResolver = options.rankingStrategyResolver ?? resolveDefaultRankingStrategy;
|
|
683
|
+
this.#usageCache = new AuthStorageUsageCache(this.#store);
|
|
684
|
+
this.#usageFetch = options.usageFetch ?? fetch;
|
|
685
|
+
this.#usageRequestTimeoutMs = options.usageRequestTimeoutMs ?? DEFAULT_USAGE_REQUEST_TIMEOUT_MS;
|
|
686
|
+
this.#refreshOAuthCredentialOverride = options.refreshOAuthCredential;
|
|
687
|
+
this.#fetchUsageReportsOverride = options.fetchUsageReports;
|
|
688
|
+
this.#sourceLabel = options.sourceLabel;
|
|
689
|
+
if (options.onCredentialDisabled) {
|
|
690
|
+
// Constructor-registered subscribers are permanent for this AuthStorage's lifetime;
|
|
691
|
+
// the unsubscribe handle is intentionally discarded.
|
|
692
|
+
this.onCredentialDisabled(options.onCredentialDisabled);
|
|
693
|
+
}
|
|
694
|
+
this.#usageLogger =
|
|
695
|
+
options.usageLogger ??
|
|
696
|
+
({
|
|
697
|
+
debug: (message, meta) => logger.debug(message, meta),
|
|
698
|
+
warn: (message, meta) => logger.warn(message, meta),
|
|
699
|
+
} satisfies UsageLogger);
|
|
700
|
+
}
|
|
701
|
+
|
|
702
|
+
/**
|
|
703
|
+
* Create an AuthStorage instance backed by a AuthCredentialStore.
|
|
704
|
+
* Convenience factory for standalone use (e.g., pi-ai CLI).
|
|
705
|
+
* @param dbPath - Path to SQLite database
|
|
706
|
+
*/
|
|
707
|
+
static async create(dbPath: string, options: AuthStorageOptions = {}): Promise<AuthStorage> {
|
|
708
|
+
const store = await SqliteAuthCredentialStore.open(dbPath);
|
|
709
|
+
return new AuthStorage(store, options);
|
|
710
|
+
}
|
|
711
|
+
|
|
712
|
+
/**
|
|
713
|
+
* Close the underlying credential store.
|
|
714
|
+
*
|
|
715
|
+
* After calling this, the instance must not be reused.
|
|
716
|
+
*/
|
|
717
|
+
close(): void {
|
|
718
|
+
if (this.#closed) return;
|
|
719
|
+
this.#closed = true;
|
|
720
|
+
this.#store.close();
|
|
721
|
+
}
|
|
722
|
+
|
|
723
|
+
getGeneration(): number {
|
|
724
|
+
return this.#generation;
|
|
725
|
+
}
|
|
726
|
+
|
|
727
|
+
onGenerationChanged(listener: (generation: number) => void): () => void {
|
|
728
|
+
this.#generationListeners.add(listener);
|
|
729
|
+
return () => {
|
|
730
|
+
this.#generationListeners.delete(listener);
|
|
731
|
+
};
|
|
732
|
+
}
|
|
733
|
+
|
|
734
|
+
offGenerationChanged(listener: (generation: number) => void): void {
|
|
735
|
+
this.#generationListeners.delete(listener);
|
|
736
|
+
}
|
|
737
|
+
|
|
738
|
+
#bumpGeneration(reason: string): void {
|
|
739
|
+
this.#generation += 1;
|
|
740
|
+
for (const listener of [...this.#generationListeners]) {
|
|
741
|
+
try {
|
|
742
|
+
listener(this.#generation);
|
|
743
|
+
} catch (error) {
|
|
744
|
+
logger.debug("AuthStorage generation listener failed", { reason, error: String(error) });
|
|
745
|
+
}
|
|
746
|
+
}
|
|
747
|
+
}
|
|
748
|
+
|
|
749
|
+
/**
|
|
750
|
+
* Subscribe to {@link CredentialDisabledEvent}s. Multiple subscribers are supported and
|
|
751
|
+
* each fires for every disable event; subscribers are invoked in registration order with
|
|
752
|
+
* exceptions and async rejections isolated per-listener so a misbehaving subscriber
|
|
753
|
+
* cannot break the disable path or starve the rest of the chain.
|
|
754
|
+
*
|
|
755
|
+
* If `credential_disabled` events were emitted while no listener was subscribed, they are
|
|
756
|
+
* replayed (in insertion order) to the listener that triggers the empty→non-empty
|
|
757
|
+
* transition. The drain is one-shot — listeners that subscribe after that no longer see
|
|
758
|
+
* past events.
|
|
759
|
+
*
|
|
760
|
+
* Returns an unsubscribe function. The function is idempotent: calling it more than once
|
|
761
|
+
* is a no-op. After every subscriber has unsubscribed, subsequent disable events buffer
|
|
762
|
+
* again until the next subscribe.
|
|
763
|
+
*
|
|
764
|
+
* @param listener Callback invoked with each disable event. May be sync or async.
|
|
765
|
+
* @returns A function that removes this listener from the subscriber set.
|
|
766
|
+
*/
|
|
767
|
+
onCredentialDisabled(listener: (event: CredentialDisabledEvent) => void | Promise<void>): () => void {
|
|
768
|
+
const wasEmpty = this.#credentialDisabledListeners.size === 0;
|
|
769
|
+
this.#credentialDisabledListeners.add(listener);
|
|
770
|
+
if (wasEmpty && this.#pendingDisabledEvents.length > 0) {
|
|
771
|
+
const drained = this.#pendingDisabledEvents;
|
|
772
|
+
this.#pendingDisabledEvents = [];
|
|
773
|
+
for (const event of drained) {
|
|
774
|
+
this.#invokeListener(listener, event);
|
|
775
|
+
}
|
|
776
|
+
}
|
|
777
|
+
return () => {
|
|
778
|
+
this.#credentialDisabledListeners.delete(listener);
|
|
779
|
+
};
|
|
780
|
+
}
|
|
781
|
+
|
|
782
|
+
/**
|
|
783
|
+
* Set a runtime API key override (not persisted to disk).
|
|
784
|
+
* Used for CLI --api-key flag.
|
|
785
|
+
*/
|
|
786
|
+
setRuntimeApiKey(provider: string, apiKey: string): void {
|
|
787
|
+
this.#runtimeOverrides.set(provider, apiKey);
|
|
788
|
+
}
|
|
789
|
+
|
|
790
|
+
/**
|
|
791
|
+
* Remove a runtime API key override.
|
|
792
|
+
*/
|
|
793
|
+
removeRuntimeApiKey(provider: string): void {
|
|
794
|
+
this.#runtimeOverrides.delete(provider);
|
|
795
|
+
}
|
|
796
|
+
|
|
797
|
+
/**
|
|
798
|
+
* Register a per-provider API key sourced from user configuration
|
|
799
|
+
* (e.g. `models.yml` `providers.<name>.apiKey`). Higher priority than
|
|
800
|
+
* stored credentials and OAuth tokens — when the user pins a key in
|
|
801
|
+
* config, that key is what authenticates outbound requests, regardless
|
|
802
|
+
* of whatever the broker happens to have loaded for that provider.
|
|
803
|
+
*
|
|
804
|
+
* Lower priority than {@link setRuntimeApiKey} so a CLI `--api-key`
|
|
805
|
+
* still wins for the duration of a single invocation.
|
|
806
|
+
*/
|
|
807
|
+
setConfigApiKey(provider: string, apiKey: string): void {
|
|
808
|
+
this.#configOverrides.set(provider, apiKey);
|
|
809
|
+
}
|
|
810
|
+
|
|
811
|
+
/**
|
|
812
|
+
* Remove a single config-sourced API key override.
|
|
813
|
+
*/
|
|
814
|
+
removeConfigApiKey(provider: string): void {
|
|
815
|
+
this.#configOverrides.delete(provider);
|
|
816
|
+
}
|
|
817
|
+
|
|
818
|
+
/**
|
|
819
|
+
* Drop every config-sourced API key. Called by `ModelRegistry` before
|
|
820
|
+
* re-parsing `models.yml` so removed entries actually disappear.
|
|
821
|
+
*/
|
|
822
|
+
clearConfigApiKeys(): void {
|
|
823
|
+
this.#configOverrides.clear();
|
|
824
|
+
}
|
|
825
|
+
|
|
826
|
+
/**
|
|
827
|
+
* Set a fallback resolver for API keys not found in storage or env vars.
|
|
828
|
+
* Used for custom provider keys from models.json.
|
|
829
|
+
*/
|
|
830
|
+
setFallbackResolver(resolver: (provider: string) => string | undefined): void {
|
|
831
|
+
this.#fallbackResolver = resolver;
|
|
832
|
+
}
|
|
833
|
+
|
|
834
|
+
/**
|
|
835
|
+
* Reload credentials from storage.
|
|
836
|
+
*/
|
|
837
|
+
async reload(): Promise<void> {
|
|
838
|
+
const records = this.#store.listAuthCredentials();
|
|
839
|
+
const grouped = new Map<string, StoredCredential[]>();
|
|
840
|
+
for (const record of records) {
|
|
841
|
+
const list = grouped.get(record.provider) ?? [];
|
|
842
|
+
list.push({ id: record.id, credential: record.credential });
|
|
843
|
+
grouped.set(record.provider, list);
|
|
844
|
+
}
|
|
845
|
+
|
|
846
|
+
const dedupedGrouped = new Map<string, StoredCredential[]>();
|
|
847
|
+
for (const [provider, entries] of grouped.entries()) {
|
|
848
|
+
const deduped = this.#pruneDuplicateStoredCredentials(provider, entries);
|
|
849
|
+
if (deduped.length > 0) {
|
|
850
|
+
dedupedGrouped.set(provider, deduped);
|
|
851
|
+
}
|
|
852
|
+
}
|
|
853
|
+
|
|
854
|
+
const removedProviders = new Set(this.#data.keys());
|
|
855
|
+
for (const [provider, entries] of dedupedGrouped) {
|
|
856
|
+
this.#setStoredCredentials(provider, entries);
|
|
857
|
+
removedProviders.delete(provider);
|
|
858
|
+
}
|
|
859
|
+
for (const provider of removedProviders) {
|
|
860
|
+
this.#setStoredCredentials(provider, []);
|
|
861
|
+
}
|
|
862
|
+
}
|
|
863
|
+
|
|
864
|
+
/**
|
|
865
|
+
* Gets cached credentials for a provider.
|
|
866
|
+
* @param provider - Provider name (e.g., "anthropic", "openai")
|
|
867
|
+
* @returns Array of stored credentials, empty if none exist
|
|
868
|
+
*/
|
|
869
|
+
#getStoredCredentials(provider: string): StoredCredential[] {
|
|
870
|
+
return this.#data.get(provider) ?? [];
|
|
871
|
+
}
|
|
872
|
+
|
|
873
|
+
/**
|
|
874
|
+
* Updates in-memory credential cache for a provider.
|
|
875
|
+
* Removes the provider entry entirely if credentials array is empty.
|
|
876
|
+
* @param provider - Provider name (e.g., "anthropic", "openai")
|
|
877
|
+
* @param credentials - Array of stored credentials to cache
|
|
878
|
+
*/
|
|
879
|
+
#setStoredCredentials(provider: string, credentials: StoredCredential[]): void {
|
|
880
|
+
const current = this.#data.get(provider) ?? [];
|
|
881
|
+
if (storedCredentialArraysEqual(current, credentials)) return;
|
|
882
|
+
if (credentials.length === 0) {
|
|
883
|
+
this.#data.delete(provider);
|
|
884
|
+
} else {
|
|
885
|
+
this.#data.set(provider, credentials);
|
|
886
|
+
}
|
|
887
|
+
this.#bumpGeneration("credentials");
|
|
888
|
+
}
|
|
889
|
+
|
|
890
|
+
#resolveOAuthDedupeIdentityKey(provider: string, credential: OAuthCredential): string | null {
|
|
891
|
+
return resolveCredentialIdentityKey(provider, credential);
|
|
892
|
+
}
|
|
893
|
+
|
|
894
|
+
#dedupeOAuthCredentials(provider: string, credentials: AuthCredential[]): AuthCredential[] {
|
|
895
|
+
const seen = new Set<string>();
|
|
896
|
+
const deduped: AuthCredential[] = [];
|
|
897
|
+
for (let index = credentials.length - 1; index >= 0; index -= 1) {
|
|
898
|
+
const credential = credentials[index];
|
|
899
|
+
if (credential.type !== "oauth") {
|
|
900
|
+
deduped.push(credential);
|
|
901
|
+
continue;
|
|
902
|
+
}
|
|
903
|
+
const identityKey = this.#resolveOAuthDedupeIdentityKey(provider, credential);
|
|
904
|
+
if (!identityKey) {
|
|
905
|
+
deduped.push(credential);
|
|
906
|
+
continue;
|
|
907
|
+
}
|
|
908
|
+
if (seen.has(identityKey)) {
|
|
909
|
+
continue;
|
|
910
|
+
}
|
|
911
|
+
seen.add(identityKey);
|
|
912
|
+
deduped.push(credential);
|
|
913
|
+
}
|
|
914
|
+
return deduped.reverse();
|
|
915
|
+
}
|
|
916
|
+
|
|
917
|
+
#pruneDuplicateStoredCredentials(provider: string, entries: StoredCredential[]): StoredCredential[] {
|
|
918
|
+
const seen = new Set<string>();
|
|
919
|
+
const kept: StoredCredential[] = [];
|
|
920
|
+
const removed: StoredCredential[] = [];
|
|
921
|
+
for (let index = entries.length - 1; index >= 0; index -= 1) {
|
|
922
|
+
const entry = entries[index];
|
|
923
|
+
const credential = entry.credential;
|
|
924
|
+
if (credential.type !== "oauth") {
|
|
925
|
+
kept.push(entry);
|
|
926
|
+
continue;
|
|
927
|
+
}
|
|
928
|
+
const identityKey = this.#resolveOAuthDedupeIdentityKey(provider, credential);
|
|
929
|
+
if (!identityKey) {
|
|
930
|
+
kept.push(entry);
|
|
931
|
+
continue;
|
|
932
|
+
}
|
|
933
|
+
if (seen.has(identityKey)) {
|
|
934
|
+
removed.push(entry);
|
|
935
|
+
continue;
|
|
936
|
+
}
|
|
937
|
+
seen.add(identityKey);
|
|
938
|
+
kept.push(entry);
|
|
939
|
+
}
|
|
940
|
+
if (removed.length > 0) {
|
|
941
|
+
for (const entry of removed) {
|
|
942
|
+
this.#store.deleteAuthCredential(entry.id, "deduplicated duplicate credential");
|
|
943
|
+
}
|
|
944
|
+
this.#resetProviderAssignments(provider);
|
|
945
|
+
}
|
|
946
|
+
return kept.reverse();
|
|
947
|
+
}
|
|
948
|
+
|
|
949
|
+
/** Returns all credentials for a provider as an array */
|
|
950
|
+
#getCredentialsForProvider(provider: string): AuthCredential[] {
|
|
951
|
+
return this.#getStoredCredentials(provider).map(entry => entry.credential);
|
|
952
|
+
}
|
|
953
|
+
|
|
954
|
+
/** Composite key for round-robin tracking: "anthropic:oauth" or "openai:api_key" */
|
|
955
|
+
#getProviderTypeKey(provider: string, type: AuthCredential["type"]): string {
|
|
956
|
+
return `${provider}:${type}`;
|
|
957
|
+
}
|
|
958
|
+
|
|
959
|
+
/**
|
|
960
|
+
* Returns next index in round-robin sequence for load distribution.
|
|
961
|
+
* Increments stored counter and wraps at total.
|
|
962
|
+
*/
|
|
963
|
+
#getNextRoundRobinIndex(providerKey: string, total: number): number {
|
|
964
|
+
if (total <= 1) return 0;
|
|
965
|
+
const current = this.#providerRoundRobinIndex.get(providerKey) ?? -1;
|
|
966
|
+
const next = (current + 1) % total;
|
|
967
|
+
this.#providerRoundRobinIndex.set(providerKey, next);
|
|
968
|
+
return next;
|
|
969
|
+
}
|
|
970
|
+
|
|
971
|
+
/**
|
|
972
|
+
* FNV-1a hash for deterministic session-to-credential mapping.
|
|
973
|
+
* Ensures the same session always starts with the same credential.
|
|
974
|
+
*/
|
|
975
|
+
#getHashedIndex(sessionId: string, total: number): number {
|
|
976
|
+
if (total <= 1) return 0;
|
|
977
|
+
return Bun.hash.xxHash32(sessionId) % total;
|
|
978
|
+
}
|
|
979
|
+
|
|
980
|
+
/**
|
|
981
|
+
* Returns credential indices in priority order for selection.
|
|
982
|
+
* With sessionId: starts from hashed index (consistent per session).
|
|
983
|
+
* Without sessionId: starts from round-robin index (load balancing).
|
|
984
|
+
* Order wraps around so all credentials are tried if earlier ones are blocked.
|
|
985
|
+
*/
|
|
986
|
+
#getCredentialOrder(providerKey: string, sessionId: string | undefined, total: number): number[] {
|
|
987
|
+
if (total <= 1) return [0];
|
|
988
|
+
const start = sessionId
|
|
989
|
+
? this.#getHashedIndex(sessionId, total)
|
|
990
|
+
: this.#getNextRoundRobinIndex(providerKey, total);
|
|
991
|
+
const order: number[] = [];
|
|
992
|
+
for (let i = 0; i < total; i++) {
|
|
993
|
+
order.push((start + i) % total);
|
|
994
|
+
}
|
|
995
|
+
return order;
|
|
996
|
+
}
|
|
997
|
+
|
|
998
|
+
/** Returns block expiry timestamp for a credential, cleaning up expired entries. */
|
|
999
|
+
#getCredentialBlockedUntil(providerKey: string, credentialIndex: number): number | undefined {
|
|
1000
|
+
const backoffMap = this.#credentialBackoff.get(providerKey);
|
|
1001
|
+
if (!backoffMap) return undefined;
|
|
1002
|
+
const blockedUntil = backoffMap.get(credentialIndex);
|
|
1003
|
+
if (!blockedUntil) return undefined;
|
|
1004
|
+
if (blockedUntil <= Date.now()) {
|
|
1005
|
+
backoffMap.delete(credentialIndex);
|
|
1006
|
+
if (backoffMap.size === 0) {
|
|
1007
|
+
this.#credentialBackoff.delete(providerKey);
|
|
1008
|
+
}
|
|
1009
|
+
return undefined;
|
|
1010
|
+
}
|
|
1011
|
+
return blockedUntil;
|
|
1012
|
+
}
|
|
1013
|
+
|
|
1014
|
+
/** Checks if a credential is temporarily blocked due to usage limits. */
|
|
1015
|
+
#isCredentialBlocked(providerKey: string, credentialIndex: number): boolean {
|
|
1016
|
+
return this.#getCredentialBlockedUntil(providerKey, credentialIndex) !== undefined;
|
|
1017
|
+
}
|
|
1018
|
+
|
|
1019
|
+
/** Marks a credential as blocked until the specified time. */
|
|
1020
|
+
#markCredentialBlocked(providerKey: string, credentialIndex: number, blockedUntilMs: number): void {
|
|
1021
|
+
const backoffMap = this.#credentialBackoff.get(providerKey) ?? new Map<number, number>();
|
|
1022
|
+
const existing = backoffMap.get(credentialIndex) ?? 0;
|
|
1023
|
+
backoffMap.set(credentialIndex, Math.max(existing, blockedUntilMs));
|
|
1024
|
+
this.#credentialBackoff.set(providerKey, backoffMap);
|
|
1025
|
+
}
|
|
1026
|
+
|
|
1027
|
+
/** Records which credential was used for a session (for rate-limit switching). */
|
|
1028
|
+
#recordSessionCredential(
|
|
1029
|
+
provider: string,
|
|
1030
|
+
sessionId: string | undefined,
|
|
1031
|
+
type: AuthCredential["type"],
|
|
1032
|
+
index: number,
|
|
1033
|
+
): void {
|
|
1034
|
+
if (!sessionId) return;
|
|
1035
|
+
const sessionMap = this.#sessionLastCredential.get(provider) ?? new Map();
|
|
1036
|
+
sessionMap.set(sessionId, { type, index });
|
|
1037
|
+
this.#sessionLastCredential.set(provider, sessionMap);
|
|
1038
|
+
}
|
|
1039
|
+
|
|
1040
|
+
/** Retrieves the last credential used by a session. */
|
|
1041
|
+
#getSessionCredential(
|
|
1042
|
+
provider: string,
|
|
1043
|
+
sessionId: string | undefined,
|
|
1044
|
+
): { type: AuthCredential["type"]; index: number } | undefined {
|
|
1045
|
+
if (!sessionId) return undefined;
|
|
1046
|
+
return this.#sessionLastCredential.get(provider)?.get(sessionId);
|
|
1047
|
+
}
|
|
1048
|
+
|
|
1049
|
+
/** Clears the last credential used by a session for a provider. */
|
|
1050
|
+
#clearSessionCredential(provider: string, sessionId: string | undefined): void {
|
|
1051
|
+
if (!sessionId) return;
|
|
1052
|
+
const sessionMap = this.#sessionLastCredential.get(provider);
|
|
1053
|
+
if (!sessionMap) return;
|
|
1054
|
+
sessionMap.delete(sessionId);
|
|
1055
|
+
if (sessionMap.size === 0) {
|
|
1056
|
+
this.#sessionLastCredential.delete(provider);
|
|
1057
|
+
}
|
|
1058
|
+
}
|
|
1059
|
+
|
|
1060
|
+
/**
|
|
1061
|
+
* Selects a credential of the specified type for a provider.
|
|
1062
|
+
* Returns both the credential and its index in the original array (for updates/removal).
|
|
1063
|
+
* Uses deterministic hashing for session stickiness and skips blocked credentials when possible.
|
|
1064
|
+
*/
|
|
1065
|
+
#selectCredentialByType<T extends AuthCredential["type"]>(
|
|
1066
|
+
provider: string,
|
|
1067
|
+
type: T,
|
|
1068
|
+
sessionId?: string,
|
|
1069
|
+
): { credential: Extract<AuthCredential, { type: T }>; index: number } | undefined {
|
|
1070
|
+
const credentials = this.#getCredentialsForProvider(provider)
|
|
1071
|
+
.map((credential, index) => ({ credential, index }))
|
|
1072
|
+
.filter(
|
|
1073
|
+
(entry): entry is { credential: Extract<AuthCredential, { type: T }>; index: number } =>
|
|
1074
|
+
entry.credential.type === type,
|
|
1075
|
+
);
|
|
1076
|
+
|
|
1077
|
+
if (credentials.length === 0) return undefined;
|
|
1078
|
+
if (credentials.length === 1) return credentials[0];
|
|
1079
|
+
|
|
1080
|
+
const providerKey = this.#getProviderTypeKey(provider, type);
|
|
1081
|
+
const order = this.#getCredentialOrder(providerKey, sessionId, credentials.length);
|
|
1082
|
+
const fallback = credentials[order[0]];
|
|
1083
|
+
|
|
1084
|
+
for (const idx of order) {
|
|
1085
|
+
const candidate = credentials[idx];
|
|
1086
|
+
if (!this.#isCredentialBlocked(providerKey, candidate.index)) {
|
|
1087
|
+
return candidate;
|
|
1088
|
+
}
|
|
1089
|
+
}
|
|
1090
|
+
|
|
1091
|
+
return fallback;
|
|
1092
|
+
}
|
|
1093
|
+
|
|
1094
|
+
/**
|
|
1095
|
+
* Clears round-robin and session assignment state for a provider.
|
|
1096
|
+
* Called when credentials are added/removed to prevent stale index references.
|
|
1097
|
+
*/
|
|
1098
|
+
#resetProviderAssignments(provider: string): void {
|
|
1099
|
+
for (const key of this.#providerRoundRobinIndex.keys()) {
|
|
1100
|
+
if (key.startsWith(`${provider}:`)) {
|
|
1101
|
+
this.#providerRoundRobinIndex.delete(key);
|
|
1102
|
+
}
|
|
1103
|
+
}
|
|
1104
|
+
this.#sessionLastCredential.delete(provider);
|
|
1105
|
+
for (const key of this.#credentialBackoff.keys()) {
|
|
1106
|
+
if (key.startsWith(`${provider}:`)) {
|
|
1107
|
+
this.#credentialBackoff.delete(key);
|
|
1108
|
+
}
|
|
1109
|
+
}
|
|
1110
|
+
}
|
|
1111
|
+
|
|
1112
|
+
/** Updates credential at index in-place (used for OAuth token refresh) */
|
|
1113
|
+
#replaceCredentialAt(provider: string, index: number, credential: AuthCredential): void {
|
|
1114
|
+
const entries = this.#getStoredCredentials(provider);
|
|
1115
|
+
if (index < 0 || index >= entries.length) return;
|
|
1116
|
+
const target = entries[index];
|
|
1117
|
+
this.#store.updateAuthCredential(target.id, credential);
|
|
1118
|
+
const updated = [...entries];
|
|
1119
|
+
updated[index] = { id: target.id, credential };
|
|
1120
|
+
this.#setStoredCredentials(provider, updated);
|
|
1121
|
+
}
|
|
1122
|
+
|
|
1123
|
+
/**
|
|
1124
|
+
* CAS-style disable used when OAuth refresh definitively fails: only disables
|
|
1125
|
+
* persisted `data` still matches the credential we attempted to refresh.
|
|
1126
|
+
* Returns `false` when a peer rotated the row between our pre-check and the
|
|
1127
|
+
* disable, so the caller can reload and retry instead of clobbering the
|
|
1128
|
+
* freshly-rotated credential.
|
|
1129
|
+
*/
|
|
1130
|
+
#tryDisableCredentialAtIfMatches(
|
|
1131
|
+
provider: string,
|
|
1132
|
+
index: number,
|
|
1133
|
+
expectedCredential: AuthCredential,
|
|
1134
|
+
disabledCause: string,
|
|
1135
|
+
): boolean {
|
|
1136
|
+
const entries = this.#getStoredCredentials(provider);
|
|
1137
|
+
if (index < 0 || index >= entries.length) return false;
|
|
1138
|
+
const target = entries[index];
|
|
1139
|
+
const serialized = serializeCredential(provider, expectedCredential);
|
|
1140
|
+
if (!serialized) return false;
|
|
1141
|
+
const disabled = this.#store.tryDisableAuthCredentialIfMatches(target.id, serialized.data, disabledCause);
|
|
1142
|
+
if (!disabled) return false;
|
|
1143
|
+
const updated = entries.filter((_value, idx) => idx !== index);
|
|
1144
|
+
this.#setStoredCredentials(provider, updated);
|
|
1145
|
+
this.#resetProviderAssignments(provider);
|
|
1146
|
+
this.#emitCredentialDisabled({ provider, disabledCause });
|
|
1147
|
+
return true;
|
|
1148
|
+
}
|
|
1149
|
+
|
|
1150
|
+
#emitCredentialDisabled(event: CredentialDisabledEvent): void {
|
|
1151
|
+
if (this.#credentialDisabledListeners.size === 0) {
|
|
1152
|
+
// No subscribers — buffer for later replay. Cap the backlog so a process that runs
|
|
1153
|
+
// without subscribers for a long time can't grow memory unboundedly; drop oldest
|
|
1154
|
+
// under pressure.
|
|
1155
|
+
if (this.#pendingDisabledEvents.length >= MAX_PENDING_DISABLED_EVENTS) {
|
|
1156
|
+
this.#pendingDisabledEvents.shift();
|
|
1157
|
+
}
|
|
1158
|
+
this.#pendingDisabledEvents.push(event);
|
|
1159
|
+
return;
|
|
1160
|
+
}
|
|
1161
|
+
// Snapshot before iteration so a listener that subscribes/unsubscribes during fan-out
|
|
1162
|
+
// can't observe a partially-mutated set or receive an event it just registered for.
|
|
1163
|
+
const listeners = [...this.#credentialDisabledListeners];
|
|
1164
|
+
for (const listener of listeners) {
|
|
1165
|
+
this.#invokeListener(listener, event);
|
|
1166
|
+
}
|
|
1167
|
+
}
|
|
1168
|
+
|
|
1169
|
+
#invokeListener(
|
|
1170
|
+
listener: (event: CredentialDisabledEvent) => void | Promise<void>,
|
|
1171
|
+
event: CredentialDisabledEvent,
|
|
1172
|
+
): void {
|
|
1173
|
+
const logListenerError = (error: unknown): void => {
|
|
1174
|
+
logger.warn("onCredentialDisabled listener threw", { provider: event.provider, error: String(error) });
|
|
1175
|
+
};
|
|
1176
|
+
try {
|
|
1177
|
+
const result = listener(event);
|
|
1178
|
+
if (result && typeof (result as PromiseLike<void>).then === "function") {
|
|
1179
|
+
(result as Promise<void>).catch(logListenerError);
|
|
1180
|
+
}
|
|
1181
|
+
} catch (error) {
|
|
1182
|
+
logListenerError(error);
|
|
1183
|
+
}
|
|
1184
|
+
}
|
|
1185
|
+
|
|
1186
|
+
/**
|
|
1187
|
+
* Get credential for a provider (first entry if multiple).
|
|
1188
|
+
*/
|
|
1189
|
+
get(provider: string): AuthCredential | undefined {
|
|
1190
|
+
return this.#getCredentialsForProvider(provider)[0];
|
|
1191
|
+
}
|
|
1192
|
+
|
|
1193
|
+
/**
|
|
1194
|
+
* Set credential for a provider.
|
|
1195
|
+
*/
|
|
1196
|
+
async set(provider: string, credential: AuthCredentialEntry): Promise<void> {
|
|
1197
|
+
const normalized = Array.isArray(credential) ? credential : [credential];
|
|
1198
|
+
const deduped = this.#dedupeOAuthCredentials(provider, normalized);
|
|
1199
|
+
const stored = this.#store.replaceAuthCredentialsRemote
|
|
1200
|
+
? await this.#store.replaceAuthCredentialsRemote(provider, deduped)
|
|
1201
|
+
: this.#store.replaceAuthCredentialsForProvider(provider, deduped);
|
|
1202
|
+
this.#setStoredCredentials(
|
|
1203
|
+
provider,
|
|
1204
|
+
stored.map(record => ({ id: record.id, credential: record.credential })),
|
|
1205
|
+
);
|
|
1206
|
+
this.#resetProviderAssignments(provider);
|
|
1207
|
+
}
|
|
1208
|
+
|
|
1209
|
+
async #upsertOAuthCredential(provider: string, credential: OAuthCredential): Promise<void> {
|
|
1210
|
+
const stored = this.#store.upsertAuthCredentialRemote
|
|
1211
|
+
? await this.#store.upsertAuthCredentialRemote(provider, credential)
|
|
1212
|
+
: this.#store.upsertAuthCredentialForProvider(provider, credential);
|
|
1213
|
+
this.#setStoredCredentials(
|
|
1214
|
+
provider,
|
|
1215
|
+
stored.map(record => ({ id: record.id, credential: record.credential })),
|
|
1216
|
+
);
|
|
1217
|
+
this.#resetProviderAssignments(provider);
|
|
1218
|
+
}
|
|
1219
|
+
|
|
1220
|
+
/**
|
|
1221
|
+
* Remove credential for a provider.
|
|
1222
|
+
*/
|
|
1223
|
+
async remove(provider: string): Promise<void> {
|
|
1224
|
+
if (this.#store.deleteAuthCredentialsRemote) {
|
|
1225
|
+
await this.#store.deleteAuthCredentialsRemote(provider, "deleted by user");
|
|
1226
|
+
} else {
|
|
1227
|
+
this.#store.deleteAuthCredentialsForProvider(provider, "deleted by user");
|
|
1228
|
+
}
|
|
1229
|
+
this.#setStoredCredentials(provider, []);
|
|
1230
|
+
this.#resetProviderAssignments(provider);
|
|
1231
|
+
}
|
|
1232
|
+
|
|
1233
|
+
/**
|
|
1234
|
+
* List all providers with credentials.
|
|
1235
|
+
*/
|
|
1236
|
+
list(): string[] {
|
|
1237
|
+
return [...this.#data.keys()];
|
|
1238
|
+
}
|
|
1239
|
+
|
|
1240
|
+
/**
|
|
1241
|
+
* Check if credentials exist for a provider in storage.
|
|
1242
|
+
*/
|
|
1243
|
+
has(provider: string): boolean {
|
|
1244
|
+
return this.#getCredentialsForProvider(provider).length > 0;
|
|
1245
|
+
}
|
|
1246
|
+
|
|
1247
|
+
/**
|
|
1248
|
+
* Check if any form of auth is configured for a provider.
|
|
1249
|
+
* Unlike getApiKey(), this doesn't refresh OAuth tokens.
|
|
1250
|
+
*/
|
|
1251
|
+
hasAuth(provider: string): boolean {
|
|
1252
|
+
if (this.#runtimeOverrides.has(provider)) return true;
|
|
1253
|
+
if (this.#configOverrides.has(provider)) return true;
|
|
1254
|
+
if (this.#getCredentialsForProvider(provider).length > 0) return true;
|
|
1255
|
+
if (getEnvApiKey(provider)) return true;
|
|
1256
|
+
if (this.#fallbackResolver?.(provider)) return true;
|
|
1257
|
+
return false;
|
|
1258
|
+
}
|
|
1259
|
+
|
|
1260
|
+
/**
|
|
1261
|
+
* Check if OAuth credentials are configured for a provider.
|
|
1262
|
+
*/
|
|
1263
|
+
hasOAuth(provider: string): boolean {
|
|
1264
|
+
return this.#getCredentialsForProvider(provider).some(credential => credential.type === "oauth");
|
|
1265
|
+
}
|
|
1266
|
+
|
|
1267
|
+
/**
|
|
1268
|
+
* Get OAuth credentials for a provider.
|
|
1269
|
+
*/
|
|
1270
|
+
getOAuthCredential(provider: string): OAuthCredential | undefined {
|
|
1271
|
+
return this.#getCredentialsForProvider(provider).find(
|
|
1272
|
+
(credential): credential is OAuthCredential => credential.type === "oauth",
|
|
1273
|
+
);
|
|
1274
|
+
}
|
|
1275
|
+
|
|
1276
|
+
/**
|
|
1277
|
+
* Get the OAuth `accountId` for a provider, preferring the credential that is
|
|
1278
|
+
* session-sticky for `sessionId` when multiple OAuth credentials are configured.
|
|
1279
|
+
* Falls back to the first OAuth credential when no session preference exists (e.g.
|
|
1280
|
+
* first call before any `getApiKey` has been issued, or single-credential setups).
|
|
1281
|
+
* Returns `undefined` when no OAuth credential carries an `accountId`.
|
|
1282
|
+
*/
|
|
1283
|
+
getOAuthAccountId(provider: string, sessionId?: string): string | undefined {
|
|
1284
|
+
const allCredentials = this.#getCredentialsForProvider(provider);
|
|
1285
|
+
const oauthCredentials = allCredentials.filter((c): c is OAuthCredential => c.type === "oauth");
|
|
1286
|
+
if (oauthCredentials.length === 0) return undefined;
|
|
1287
|
+
|
|
1288
|
+
// Runtime / config overrides bypass OAuth account_uuid attribution — the
|
|
1289
|
+
// caller is authenticating with an explicit key, not the broker's OAuth.
|
|
1290
|
+
if (this.#runtimeOverrides.has(provider) || this.#configOverrides.has(provider)) return undefined;
|
|
1291
|
+
|
|
1292
|
+
// Prefer the session-sticky credential when available.
|
|
1293
|
+
const sessionPref = this.#getSessionCredential(provider, sessionId);
|
|
1294
|
+
// If the session has been routed to a stored API key, do not inject OAuth account_uuid.
|
|
1295
|
+
if (sessionPref !== undefined && sessionPref.type !== "oauth") return undefined;
|
|
1296
|
+
|
|
1297
|
+
// When no session-sticky credential is recorded yet (first call before any getApiKey,
|
|
1298
|
+
// or all stored credentials are unavailable), the request falls through to the env-key
|
|
1299
|
+
// or fallback-resolver path in getApiKey() — neither is OAuth-authenticated, so
|
|
1300
|
+
// account_uuid injection would misattribute traffic. Only apply this guard when
|
|
1301
|
+
// sessionPref is absent; a recorded OAuth sticky (sessionPref.type === "oauth") must
|
|
1302
|
+
// NOT be blocked even if an env key also happens to exist.
|
|
1303
|
+
if (!sessionPref && (getEnvApiKey(provider) || this.#fallbackResolver?.(provider))) return undefined;
|
|
1304
|
+
// Resolve the sticky index against the full credential list — the index is
|
|
1305
|
+
// recorded against the unfiltered provider array (by #recordSessionCredential /
|
|
1306
|
+
// #tryOAuthCredential), not the OAuth-only subset, so dereferencing it into the
|
|
1307
|
+
// filtered array would be off-by-N when any non-OAuth credential precedes the
|
|
1308
|
+
// OAuth ones (e.g. [api_key, oauth_A, oauth_B] stored order).
|
|
1309
|
+
const stickyCredential = sessionPref?.type === "oauth" ? allCredentials[sessionPref.index] : undefined;
|
|
1310
|
+
const preferred = stickyCredential?.type === "oauth" ? stickyCredential : oauthCredentials[0];
|
|
1311
|
+
const accountId = preferred?.accountId;
|
|
1312
|
+
return typeof accountId === "string" && accountId.length > 0 ? accountId : undefined;
|
|
1313
|
+
}
|
|
1314
|
+
|
|
1315
|
+
/**
|
|
1316
|
+
* Get all credentials.
|
|
1317
|
+
*/
|
|
1318
|
+
getAll(): AuthStorageData {
|
|
1319
|
+
const result: AuthStorageData = {};
|
|
1320
|
+
for (const [provider, entries] of this.#data.entries()) {
|
|
1321
|
+
const credentials = entries.map(entry => entry.credential);
|
|
1322
|
+
if (credentials.length === 1) {
|
|
1323
|
+
result[provider] = credentials[0];
|
|
1324
|
+
} else if (credentials.length > 1) {
|
|
1325
|
+
result[provider] = credentials;
|
|
1326
|
+
}
|
|
1327
|
+
}
|
|
1328
|
+
return result;
|
|
1329
|
+
}
|
|
1330
|
+
|
|
1331
|
+
/**
|
|
1332
|
+
* Login to an OAuth provider.
|
|
1333
|
+
*/
|
|
1334
|
+
async login(
|
|
1335
|
+
provider: OAuthProviderId,
|
|
1336
|
+
ctrl: OAuthController & {
|
|
1337
|
+
/** onAuth is required by auth-storage but optional in OAuthController */
|
|
1338
|
+
onAuth: (info: { url: string; instructions?: string }) => void;
|
|
1339
|
+
/** onPrompt is required for some providers (github-copilot, OpenAI code provider) */
|
|
1340
|
+
onPrompt: (prompt: { message: string; placeholder?: string }) => Promise<string>;
|
|
1341
|
+
},
|
|
1342
|
+
): Promise<void> {
|
|
1343
|
+
let credentials: OAuthCredentials;
|
|
1344
|
+
const saveApiKeyCredential = async (apiKey: string): Promise<void> => {
|
|
1345
|
+
const newCredential: ApiKeyCredential = { type: "api_key", key: apiKey };
|
|
1346
|
+
await this.set(provider, newCredential);
|
|
1347
|
+
};
|
|
1348
|
+
const manualCodeInput = () => ctrl.onPrompt({ message: "Paste the authorization code (or full redirect URL):" });
|
|
1349
|
+
switch (provider) {
|
|
1350
|
+
case "anthropic": {
|
|
1351
|
+
const { loginAnthropic } = await import("./utils/oauth/anthropic");
|
|
1352
|
+
credentials = await loginAnthropic({
|
|
1353
|
+
...ctrl,
|
|
1354
|
+
onManualCodeInput: ctrl.onManualCodeInput ?? manualCodeInput,
|
|
1355
|
+
});
|
|
1356
|
+
break;
|
|
1357
|
+
}
|
|
1358
|
+
case "alibaba-coding-plan": {
|
|
1359
|
+
const { loginAlibabaCodingPlan } = await import("./utils/oauth/alibaba-coding-plan");
|
|
1360
|
+
const apiKey = await loginAlibabaCodingPlan(ctrl);
|
|
1361
|
+
await saveApiKeyCredential(apiKey);
|
|
1362
|
+
return;
|
|
1363
|
+
}
|
|
1364
|
+
case "github-copilot": {
|
|
1365
|
+
const { loginGitHubCopilot } = await import("./utils/oauth/github-copilot");
|
|
1366
|
+
credentials = await loginGitHubCopilot({
|
|
1367
|
+
onAuth: (url, instructions) => ctrl.onAuth({ url, instructions }),
|
|
1368
|
+
onPrompt: ctrl.onPrompt,
|
|
1369
|
+
onProgress: ctrl.onProgress,
|
|
1370
|
+
signal: ctrl.signal,
|
|
1371
|
+
});
|
|
1372
|
+
break;
|
|
1373
|
+
}
|
|
1374
|
+
case "google-gemini-cli": {
|
|
1375
|
+
const { loginGeminiCli } = await import("./utils/oauth/google-gemini-cli");
|
|
1376
|
+
credentials = await loginGeminiCli({
|
|
1377
|
+
...ctrl,
|
|
1378
|
+
onManualCodeInput: ctrl.onManualCodeInput ?? manualCodeInput,
|
|
1379
|
+
});
|
|
1380
|
+
break;
|
|
1381
|
+
}
|
|
1382
|
+
case "google-antigravity": {
|
|
1383
|
+
const { loginAntigravity } = await import("./utils/oauth/google-antigravity");
|
|
1384
|
+
credentials = await loginAntigravity({
|
|
1385
|
+
...ctrl,
|
|
1386
|
+
onManualCodeInput: ctrl.onManualCodeInput ?? manualCodeInput,
|
|
1387
|
+
});
|
|
1388
|
+
break;
|
|
1389
|
+
}
|
|
1390
|
+
case "openai-codex": {
|
|
1391
|
+
const { loginOpenAICodex } = await import("./utils/oauth/openai-codex");
|
|
1392
|
+
credentials = await loginOpenAICodex({
|
|
1393
|
+
...ctrl,
|
|
1394
|
+
onManualCodeInput: ctrl.onManualCodeInput ?? manualCodeInput,
|
|
1395
|
+
});
|
|
1396
|
+
break;
|
|
1397
|
+
}
|
|
1398
|
+
case "openai-codex-device": {
|
|
1399
|
+
// Device/headless flow — stores credentials under "OpenAI code provider" so the
|
|
1400
|
+
// provider can pick them up without a separate provider configuration.
|
|
1401
|
+
const deviceCredentials = await loginOpenAICodexDevice(ctrl);
|
|
1402
|
+
const newCredential: OAuthCredential = { type: "oauth", ...deviceCredentials };
|
|
1403
|
+
await this.#upsertOAuthCredential("openai-codex", newCredential);
|
|
1404
|
+
return;
|
|
1405
|
+
}
|
|
1406
|
+
case "gitlab-duo": {
|
|
1407
|
+
const { loginGitLabDuo } = await import("./utils/oauth/gitlab-duo");
|
|
1408
|
+
credentials = await loginGitLabDuo({
|
|
1409
|
+
...ctrl,
|
|
1410
|
+
onManualCodeInput: ctrl.onManualCodeInput ?? manualCodeInput,
|
|
1411
|
+
});
|
|
1412
|
+
break;
|
|
1413
|
+
}
|
|
1414
|
+
case "kimi-code": {
|
|
1415
|
+
const { loginKimi } = await import("./utils/oauth/kimi");
|
|
1416
|
+
credentials = await loginKimi(ctrl);
|
|
1417
|
+
break;
|
|
1418
|
+
}
|
|
1419
|
+
case "kilo": {
|
|
1420
|
+
const { loginKilo } = await import("./utils/oauth/kilo");
|
|
1421
|
+
credentials = await loginKilo(ctrl);
|
|
1422
|
+
break;
|
|
1423
|
+
}
|
|
1424
|
+
case "cursor": {
|
|
1425
|
+
const { loginCursor } = await import("./utils/oauth/cursor");
|
|
1426
|
+
credentials = await loginCursor(
|
|
1427
|
+
url => ctrl.onAuth({ url }),
|
|
1428
|
+
ctrl.onProgress ? () => ctrl.onProgress?.("Waiting for browser authentication...") : undefined,
|
|
1429
|
+
);
|
|
1430
|
+
break;
|
|
1431
|
+
}
|
|
1432
|
+
case "perplexity": {
|
|
1433
|
+
const { loginPerplexity } = await import("./utils/oauth/perplexity");
|
|
1434
|
+
credentials = await loginPerplexity(ctrl);
|
|
1435
|
+
break;
|
|
1436
|
+
}
|
|
1437
|
+
case "huggingface": {
|
|
1438
|
+
const { loginHuggingface } = await import("./utils/oauth/huggingface");
|
|
1439
|
+
const apiKey = await loginHuggingface(ctrl);
|
|
1440
|
+
await saveApiKeyCredential(apiKey);
|
|
1441
|
+
return;
|
|
1442
|
+
}
|
|
1443
|
+
case "opencode-zen":
|
|
1444
|
+
case "opencode-go": {
|
|
1445
|
+
const { loginOpenCode } = await import("./utils/oauth/opencode");
|
|
1446
|
+
const apiKey = await loginOpenCode(ctrl);
|
|
1447
|
+
await saveApiKeyCredential(apiKey);
|
|
1448
|
+
return;
|
|
1449
|
+
}
|
|
1450
|
+
case "lm-studio": {
|
|
1451
|
+
const { loginLmStudio } = await import("./utils/oauth/lm-studio");
|
|
1452
|
+
const apiKey = await loginLmStudio(ctrl);
|
|
1453
|
+
await saveApiKeyCredential(apiKey);
|
|
1454
|
+
return;
|
|
1455
|
+
}
|
|
1456
|
+
case "ollama": {
|
|
1457
|
+
const { loginOllama } = await import("./utils/oauth/ollama");
|
|
1458
|
+
const apiKey = await loginOllama(ctrl);
|
|
1459
|
+
if (!apiKey) {
|
|
1460
|
+
return;
|
|
1461
|
+
}
|
|
1462
|
+
await saveApiKeyCredential(apiKey);
|
|
1463
|
+
return;
|
|
1464
|
+
}
|
|
1465
|
+
case "ollama-cloud": {
|
|
1466
|
+
const { loginOllamaCloud } = await import("./utils/oauth/ollama-cloud");
|
|
1467
|
+
const apiKey = await loginOllamaCloud(ctrl);
|
|
1468
|
+
await saveApiKeyCredential(apiKey);
|
|
1469
|
+
return;
|
|
1470
|
+
}
|
|
1471
|
+
case "cerebras": {
|
|
1472
|
+
const { loginCerebras } = await import("./utils/oauth/cerebras");
|
|
1473
|
+
const apiKey = await loginCerebras(ctrl);
|
|
1474
|
+
await saveApiKeyCredential(apiKey);
|
|
1475
|
+
return;
|
|
1476
|
+
}
|
|
1477
|
+
case "deepseek": {
|
|
1478
|
+
const apiKey = await loginDeepSeek(ctrl);
|
|
1479
|
+
await saveApiKeyCredential(apiKey);
|
|
1480
|
+
return;
|
|
1481
|
+
}
|
|
1482
|
+
case "fireworks": {
|
|
1483
|
+
const { loginFireworks } = await import("./utils/oauth/fireworks");
|
|
1484
|
+
const apiKey = await loginFireworks(ctrl);
|
|
1485
|
+
await saveApiKeyCredential(apiKey);
|
|
1486
|
+
return;
|
|
1487
|
+
}
|
|
1488
|
+
case "firepass": {
|
|
1489
|
+
const { loginFirepass } = await import("./utils/oauth/firepass");
|
|
1490
|
+
const apiKey = await loginFirepass(ctrl);
|
|
1491
|
+
await saveApiKeyCredential(apiKey);
|
|
1492
|
+
return;
|
|
1493
|
+
}
|
|
1494
|
+
case "zai": {
|
|
1495
|
+
const { loginZai } = await import("./utils/oauth/zai");
|
|
1496
|
+
const apiKey = await loginZai(ctrl);
|
|
1497
|
+
await saveApiKeyCredential(apiKey);
|
|
1498
|
+
return;
|
|
1499
|
+
}
|
|
1500
|
+
case "qianfan": {
|
|
1501
|
+
const { loginQianfan } = await import("./utils/oauth/qianfan");
|
|
1502
|
+
const apiKey = await loginQianfan(ctrl);
|
|
1503
|
+
await saveApiKeyCredential(apiKey);
|
|
1504
|
+
return;
|
|
1505
|
+
}
|
|
1506
|
+
case "minimax-code": {
|
|
1507
|
+
const { loginMiniMaxCode } = await import("./utils/oauth/minimax-code");
|
|
1508
|
+
const apiKey = await loginMiniMaxCode(ctrl);
|
|
1509
|
+
await saveApiKeyCredential(apiKey);
|
|
1510
|
+
return;
|
|
1511
|
+
}
|
|
1512
|
+
case "minimax-code-cn": {
|
|
1513
|
+
const { loginMiniMaxCodeCn } = await import("./utils/oauth/minimax-code");
|
|
1514
|
+
const apiKey = await loginMiniMaxCodeCn(ctrl);
|
|
1515
|
+
await saveApiKeyCredential(apiKey);
|
|
1516
|
+
return;
|
|
1517
|
+
}
|
|
1518
|
+
case "synthetic": {
|
|
1519
|
+
const { loginSynthetic } = await import("./utils/oauth/synthetic");
|
|
1520
|
+
const apiKey = await loginSynthetic(ctrl);
|
|
1521
|
+
await saveApiKeyCredential(apiKey);
|
|
1522
|
+
return;
|
|
1523
|
+
}
|
|
1524
|
+
case "tavily": {
|
|
1525
|
+
const { loginTavily } = await import("./utils/oauth/tavily");
|
|
1526
|
+
const apiKey = await loginTavily(ctrl);
|
|
1527
|
+
await saveApiKeyCredential(apiKey);
|
|
1528
|
+
return;
|
|
1529
|
+
}
|
|
1530
|
+
case "venice": {
|
|
1531
|
+
const { loginVenice } = await import("./utils/oauth/venice");
|
|
1532
|
+
const apiKey = await loginVenice(ctrl);
|
|
1533
|
+
await saveApiKeyCredential(apiKey);
|
|
1534
|
+
return;
|
|
1535
|
+
}
|
|
1536
|
+
case "litellm": {
|
|
1537
|
+
const { loginLiteLLM } = await import("./utils/oauth/litellm");
|
|
1538
|
+
const apiKey = await loginLiteLLM(ctrl);
|
|
1539
|
+
await saveApiKeyCredential(apiKey);
|
|
1540
|
+
return;
|
|
1541
|
+
}
|
|
1542
|
+
case "moonshot": {
|
|
1543
|
+
const { loginMoonshot } = await import("./utils/oauth/moonshot");
|
|
1544
|
+
const apiKey = await loginMoonshot(ctrl);
|
|
1545
|
+
await saveApiKeyCredential(apiKey);
|
|
1546
|
+
return;
|
|
1547
|
+
}
|
|
1548
|
+
case "kagi": {
|
|
1549
|
+
const { loginKagi } = await import("./utils/oauth/kagi");
|
|
1550
|
+
const apiKey = await loginKagi(ctrl);
|
|
1551
|
+
await saveApiKeyCredential(apiKey);
|
|
1552
|
+
return;
|
|
1553
|
+
}
|
|
1554
|
+
case "nanogpt": {
|
|
1555
|
+
const { loginNanoGPT } = await import("./utils/oauth/nanogpt");
|
|
1556
|
+
const apiKey = await loginNanoGPT(ctrl);
|
|
1557
|
+
await saveApiKeyCredential(apiKey);
|
|
1558
|
+
return;
|
|
1559
|
+
}
|
|
1560
|
+
case "together": {
|
|
1561
|
+
const { loginTogether } = await import("./utils/oauth/together");
|
|
1562
|
+
const apiKey = await loginTogether(ctrl);
|
|
1563
|
+
await saveApiKeyCredential(apiKey);
|
|
1564
|
+
return;
|
|
1565
|
+
}
|
|
1566
|
+
case "cloudflare-ai-gateway": {
|
|
1567
|
+
const { loginCloudflareAiGateway } = await import("./utils/oauth/cloudflare-ai-gateway");
|
|
1568
|
+
const apiKey = await loginCloudflareAiGateway(ctrl);
|
|
1569
|
+
await saveApiKeyCredential(apiKey);
|
|
1570
|
+
return;
|
|
1571
|
+
}
|
|
1572
|
+
case "vercel-ai-gateway": {
|
|
1573
|
+
const { loginVercelAiGateway } = await import("./utils/oauth/vercel-ai-gateway");
|
|
1574
|
+
const apiKey = await loginVercelAiGateway(ctrl);
|
|
1575
|
+
await saveApiKeyCredential(apiKey);
|
|
1576
|
+
return;
|
|
1577
|
+
}
|
|
1578
|
+
case "vllm": {
|
|
1579
|
+
const { loginVllm } = await import("./utils/oauth/vllm");
|
|
1580
|
+
const apiKey = await loginVllm(ctrl);
|
|
1581
|
+
await saveApiKeyCredential(apiKey);
|
|
1582
|
+
return;
|
|
1583
|
+
}
|
|
1584
|
+
case "parallel": {
|
|
1585
|
+
const { loginParallel } = await import("./utils/oauth/parallel");
|
|
1586
|
+
const apiKey = await loginParallel(ctrl);
|
|
1587
|
+
await saveApiKeyCredential(apiKey);
|
|
1588
|
+
return;
|
|
1589
|
+
}
|
|
1590
|
+
case "qwen-portal": {
|
|
1591
|
+
const { loginQwenPortal } = await import("./utils/oauth/qwen-portal");
|
|
1592
|
+
const apiKey = await loginQwenPortal(ctrl);
|
|
1593
|
+
await saveApiKeyCredential(apiKey);
|
|
1594
|
+
return;
|
|
1595
|
+
}
|
|
1596
|
+
case "nvidia": {
|
|
1597
|
+
const { loginNvidia } = await import("./utils/oauth/nvidia");
|
|
1598
|
+
const apiKey = await loginNvidia(ctrl);
|
|
1599
|
+
await saveApiKeyCredential(apiKey);
|
|
1600
|
+
return;
|
|
1601
|
+
}
|
|
1602
|
+
case "xiaomi": {
|
|
1603
|
+
const { loginXiaomi } = await import("./utils/oauth/xiaomi");
|
|
1604
|
+
const apiKey = await loginXiaomi(ctrl);
|
|
1605
|
+
await saveApiKeyCredential(apiKey);
|
|
1606
|
+
return;
|
|
1607
|
+
}
|
|
1608
|
+
case "zenmux": {
|
|
1609
|
+
const { loginZenMux } = await import("./utils/oauth/zenmux");
|
|
1610
|
+
const apiKey = await loginZenMux(ctrl);
|
|
1611
|
+
await saveApiKeyCredential(apiKey);
|
|
1612
|
+
return;
|
|
1613
|
+
}
|
|
1614
|
+
default: {
|
|
1615
|
+
const customProvider = getOAuthProvider(provider);
|
|
1616
|
+
if (!customProvider) {
|
|
1617
|
+
throw new Error(`Unknown OAuth provider: ${provider}`);
|
|
1618
|
+
}
|
|
1619
|
+
const customLoginResult = await customProvider.login({
|
|
1620
|
+
onAuth: info => ctrl.onAuth(info),
|
|
1621
|
+
onProgress: ctrl.onProgress,
|
|
1622
|
+
onPrompt: ctrl.onPrompt,
|
|
1623
|
+
onManualCodeInput: ctrl.onManualCodeInput ?? manualCodeInput,
|
|
1624
|
+
signal: ctrl.signal,
|
|
1625
|
+
});
|
|
1626
|
+
if (typeof customLoginResult === "string") {
|
|
1627
|
+
await saveApiKeyCredential(customLoginResult);
|
|
1628
|
+
return;
|
|
1629
|
+
}
|
|
1630
|
+
credentials = customLoginResult;
|
|
1631
|
+
break;
|
|
1632
|
+
}
|
|
1633
|
+
}
|
|
1634
|
+
const newCredential: OAuthCredential = { type: "oauth", ...credentials };
|
|
1635
|
+
await this.#upsertOAuthCredential(provider, newCredential);
|
|
1636
|
+
}
|
|
1637
|
+
|
|
1638
|
+
/**
|
|
1639
|
+
* Logout from a provider.
|
|
1640
|
+
*/
|
|
1641
|
+
async logout(provider: string): Promise<void> {
|
|
1642
|
+
await this.remove(provider);
|
|
1643
|
+
}
|
|
1644
|
+
|
|
1645
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
1646
|
+
// Usage API Integration
|
|
1647
|
+
// Queries provider usage endpoints to detect rate limits before they occur.
|
|
1648
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
1649
|
+
|
|
1650
|
+
#buildUsageCredential(credential: OAuthCredential): UsageCredential {
|
|
1651
|
+
return {
|
|
1652
|
+
type: "oauth",
|
|
1653
|
+
accessToken: credential.access,
|
|
1654
|
+
refreshToken: credential.refresh,
|
|
1655
|
+
expiresAt: credential.expires,
|
|
1656
|
+
accountId: credential.accountId,
|
|
1657
|
+
projectId: credential.projectId,
|
|
1658
|
+
email: credential.email,
|
|
1659
|
+
enterpriseUrl: credential.enterpriseUrl,
|
|
1660
|
+
};
|
|
1661
|
+
}
|
|
1662
|
+
|
|
1663
|
+
#buildUsageCacheIdentity(credential: UsageCredential): string {
|
|
1664
|
+
const parts: string[] = [credential.type];
|
|
1665
|
+
const accountId = credential.accountId?.trim();
|
|
1666
|
+
if (accountId) parts.push(`account:${accountId}`);
|
|
1667
|
+
const email = credential.email?.trim().toLowerCase();
|
|
1668
|
+
if (email) parts.push(`email:${email}`);
|
|
1669
|
+
const projectId = credential.projectId?.trim();
|
|
1670
|
+
if (projectId) parts.push(`project:${projectId}`);
|
|
1671
|
+
const enterpriseUrl = credential.enterpriseUrl?.trim().toLowerCase();
|
|
1672
|
+
if (enterpriseUrl) parts.push(`enterprise:${enterpriseUrl}`);
|
|
1673
|
+
// Only fall back to a secret-derived key when a stable account identifier is unavailable.
|
|
1674
|
+
// Including the token hash when accountId/email are present causes cache misses on
|
|
1675
|
+
// every OAuth refresh — usage data is per-account, not per-token.
|
|
1676
|
+
const hasStableIdentifier = Boolean(accountId || email);
|
|
1677
|
+
if (!hasStableIdentifier) {
|
|
1678
|
+
const secret = credential.apiKey?.trim() || credential.refreshToken?.trim() || credential.accessToken?.trim();
|
|
1679
|
+
if (secret) {
|
|
1680
|
+
parts.push(`secret:${Bun.hash(secret).toString(16)}`);
|
|
1681
|
+
} else {
|
|
1682
|
+
parts.push("anonymous");
|
|
1683
|
+
}
|
|
1684
|
+
}
|
|
1685
|
+
return parts.join("|");
|
|
1686
|
+
}
|
|
1687
|
+
|
|
1688
|
+
#normalizeUsageBaseUrl(baseUrl?: string): string {
|
|
1689
|
+
return baseUrl?.trim().replace(/\/+$/, "") ?? "";
|
|
1690
|
+
}
|
|
1691
|
+
|
|
1692
|
+
#buildUsageReportCacheKey(request: UsageRequestDescriptor): string {
|
|
1693
|
+
const baseUrl = this.#normalizeUsageBaseUrl(request.baseUrl) || "default";
|
|
1694
|
+
const identity = this.#buildUsageCacheIdentity(request.credential);
|
|
1695
|
+
return `report:${request.provider}:${baseUrl}:${identity}`;
|
|
1696
|
+
}
|
|
1697
|
+
|
|
1698
|
+
#buildUsageReportsCacheKey(requests: ReadonlyArray<UsageRequestDescriptor>): string {
|
|
1699
|
+
const snapshot = requests
|
|
1700
|
+
.map(
|
|
1701
|
+
request =>
|
|
1702
|
+
`${request.provider}:${this.#normalizeUsageBaseUrl(request.baseUrl) || "default"}:${this.#buildUsageCacheIdentity(request.credential)}`,
|
|
1703
|
+
)
|
|
1704
|
+
.sort()
|
|
1705
|
+
.join("\n");
|
|
1706
|
+
return `reports:${Bun.hash(snapshot).toString(16)}`;
|
|
1707
|
+
}
|
|
1708
|
+
|
|
1709
|
+
#buildUsageRequest(provider: Provider, credential: UsageCredential, baseUrl?: string): UsageRequestDescriptor {
|
|
1710
|
+
return { provider, credential, baseUrl };
|
|
1711
|
+
}
|
|
1712
|
+
|
|
1713
|
+
#buildUsageRequestForOauth(
|
|
1714
|
+
provider: Provider,
|
|
1715
|
+
credential: OAuthCredential,
|
|
1716
|
+
baseUrl?: string,
|
|
1717
|
+
): UsageRequestDescriptor {
|
|
1718
|
+
return this.#buildUsageRequest(provider, this.#buildUsageCredential(credential), baseUrl);
|
|
1719
|
+
}
|
|
1720
|
+
|
|
1721
|
+
#buildRefreshableOauthCredential(credential: UsageCredential): OAuthCredential | null {
|
|
1722
|
+
if (!credential.accessToken || !credential.refreshToken || credential.expiresAt === undefined) {
|
|
1723
|
+
return null;
|
|
1724
|
+
}
|
|
1725
|
+
return {
|
|
1726
|
+
type: "oauth",
|
|
1727
|
+
access: credential.accessToken,
|
|
1728
|
+
refresh: credential.refreshToken,
|
|
1729
|
+
expires: credential.expiresAt,
|
|
1730
|
+
accountId: credential.accountId,
|
|
1731
|
+
projectId: credential.projectId,
|
|
1732
|
+
email: credential.email,
|
|
1733
|
+
enterpriseUrl: credential.enterpriseUrl,
|
|
1734
|
+
};
|
|
1735
|
+
}
|
|
1736
|
+
|
|
1737
|
+
#mergeRefreshedUsageCredential(credential: UsageCredential, refreshed: OAuthCredentials): UsageCredential {
|
|
1738
|
+
return {
|
|
1739
|
+
...credential,
|
|
1740
|
+
accessToken: refreshed.access,
|
|
1741
|
+
refreshToken: refreshed.refresh,
|
|
1742
|
+
expiresAt: refreshed.expires,
|
|
1743
|
+
accountId: refreshed.accountId ?? credential.accountId,
|
|
1744
|
+
projectId: refreshed.projectId ?? credential.projectId,
|
|
1745
|
+
email: refreshed.email ?? credential.email,
|
|
1746
|
+
enterpriseUrl: refreshed.enterpriseUrl ?? credential.enterpriseUrl,
|
|
1747
|
+
};
|
|
1748
|
+
}
|
|
1749
|
+
|
|
1750
|
+
/**
|
|
1751
|
+
* Find the stored credential id matching a {@link UsageCredential} so the
|
|
1752
|
+
* refresh override can address the row. Mirrors the matching logic in
|
|
1753
|
+
* {@link AuthStorage.#persistRefreshedUsageCredential}.
|
|
1754
|
+
*/
|
|
1755
|
+
#findStoredCredentialIdForUsageCredential(provider: Provider, previous: UsageCredential): number | undefined {
|
|
1756
|
+
const entries = this.#getStoredCredentials(provider);
|
|
1757
|
+
const match = entries.find(entry => {
|
|
1758
|
+
if (entry.credential.type !== "oauth") return false;
|
|
1759
|
+
if (previous.refreshToken && entry.credential.refresh === previous.refreshToken) return true;
|
|
1760
|
+
if (previous.accessToken && entry.credential.access === previous.accessToken) return true;
|
|
1761
|
+
return (
|
|
1762
|
+
entry.credential.accountId === previous.accountId &&
|
|
1763
|
+
entry.credential.email === previous.email &&
|
|
1764
|
+
entry.credential.projectId === previous.projectId
|
|
1765
|
+
);
|
|
1766
|
+
});
|
|
1767
|
+
return match?.id;
|
|
1768
|
+
}
|
|
1769
|
+
|
|
1770
|
+
#persistRefreshedUsageCredential(provider: Provider, previous: UsageCredential, next: UsageCredential): void {
|
|
1771
|
+
const entries = this.#getStoredCredentials(provider);
|
|
1772
|
+
const index = entries.findIndex(entry => {
|
|
1773
|
+
if (entry.credential.type !== "oauth") return false;
|
|
1774
|
+
if (previous.refreshToken && entry.credential.refresh === previous.refreshToken) return true;
|
|
1775
|
+
if (previous.accessToken && entry.credential.access === previous.accessToken) return true;
|
|
1776
|
+
return (
|
|
1777
|
+
entry.credential.accountId === previous.accountId &&
|
|
1778
|
+
entry.credential.email === previous.email &&
|
|
1779
|
+
entry.credential.projectId === previous.projectId
|
|
1780
|
+
);
|
|
1781
|
+
});
|
|
1782
|
+
if (index === -1) return;
|
|
1783
|
+
const existing = entries[index]!.credential;
|
|
1784
|
+
if (existing.type !== "oauth") return;
|
|
1785
|
+
this.#replaceCredentialAt(provider, index, {
|
|
1786
|
+
type: "oauth",
|
|
1787
|
+
access: next.accessToken ?? existing.access,
|
|
1788
|
+
refresh: next.refreshToken ?? existing.refresh,
|
|
1789
|
+
expires: next.expiresAt ?? existing.expires,
|
|
1790
|
+
accountId: next.accountId,
|
|
1791
|
+
projectId: next.projectId,
|
|
1792
|
+
email: next.email,
|
|
1793
|
+
enterpriseUrl: next.enterpriseUrl,
|
|
1794
|
+
});
|
|
1795
|
+
}
|
|
1796
|
+
|
|
1797
|
+
async #fetchUsageUncached(request: UsageRequestDescriptor, timeoutMs?: number): Promise<UsageReport | null> {
|
|
1798
|
+
const resolver = this.#usageProviderResolver;
|
|
1799
|
+
if (!resolver) return null;
|
|
1800
|
+
|
|
1801
|
+
const providerImpl = resolver(request.provider);
|
|
1802
|
+
if (!providerImpl) return null;
|
|
1803
|
+
|
|
1804
|
+
const timeoutSignal =
|
|
1805
|
+
typeof timeoutMs === "number" && Number.isFinite(timeoutMs) && timeoutMs > 0
|
|
1806
|
+
? AbortSignal.timeout(timeoutMs)
|
|
1807
|
+
: undefined;
|
|
1808
|
+
let params: UsageRequestDescriptor & { signal?: AbortSignal } = { ...request, signal: timeoutSignal };
|
|
1809
|
+
|
|
1810
|
+
if (
|
|
1811
|
+
request.credential.type === "oauth" &&
|
|
1812
|
+
request.credential.expiresAt !== undefined &&
|
|
1813
|
+
Date.now() + OAUTH_REFRESH_SKEW_MS >= request.credential.expiresAt
|
|
1814
|
+
) {
|
|
1815
|
+
const refreshableCredential = this.#buildRefreshableOauthCredential(request.credential);
|
|
1816
|
+
if (refreshableCredential) {
|
|
1817
|
+
try {
|
|
1818
|
+
const refreshableCredentialId = this.#findStoredCredentialIdForUsageCredential(
|
|
1819
|
+
request.provider,
|
|
1820
|
+
request.credential,
|
|
1821
|
+
);
|
|
1822
|
+
const refreshed = await this.#refreshOAuthCredential(
|
|
1823
|
+
request.provider,
|
|
1824
|
+
refreshableCredential,
|
|
1825
|
+
refreshableCredentialId,
|
|
1826
|
+
timeoutSignal,
|
|
1827
|
+
);
|
|
1828
|
+
const refreshedCredential = this.#mergeRefreshedUsageCredential(request.credential, refreshed);
|
|
1829
|
+
this.#persistRefreshedUsageCredential(request.provider, request.credential, refreshedCredential);
|
|
1830
|
+
params = {
|
|
1831
|
+
...params,
|
|
1832
|
+
credential: refreshedCredential,
|
|
1833
|
+
};
|
|
1834
|
+
} catch (error) {
|
|
1835
|
+
this.#usageLogger?.debug("Usage credential refresh failed, using original credential", {
|
|
1836
|
+
provider: request.provider,
|
|
1837
|
+
error: String(error),
|
|
1838
|
+
});
|
|
1839
|
+
}
|
|
1840
|
+
}
|
|
1841
|
+
}
|
|
1842
|
+
|
|
1843
|
+
if (providerImpl.supports && !providerImpl.supports(params)) return null;
|
|
1844
|
+
|
|
1845
|
+
try {
|
|
1846
|
+
return await providerImpl.fetchUsage(params, {
|
|
1847
|
+
fetch: this.#usageFetch,
|
|
1848
|
+
logger: this.#usageLogger,
|
|
1849
|
+
});
|
|
1850
|
+
} catch (error) {
|
|
1851
|
+
logger.debug("AuthStorage usage fetch failed", {
|
|
1852
|
+
provider: request.provider,
|
|
1853
|
+
error: String(error),
|
|
1854
|
+
});
|
|
1855
|
+
return null;
|
|
1856
|
+
}
|
|
1857
|
+
}
|
|
1858
|
+
|
|
1859
|
+
async #fetchUsageCached(request: UsageRequestDescriptor, timeoutMs?: number): Promise<UsageReport | null> {
|
|
1860
|
+
const cacheKey = this.#buildUsageReportCacheKey(request);
|
|
1861
|
+
const now = Date.now();
|
|
1862
|
+
const cached = this.#usageCache.get<UsageReport | null>(cacheKey);
|
|
1863
|
+
// Fresh cache hit: return whatever's there (success or null fallback).
|
|
1864
|
+
if (cached && cached.expiresAt > now) {
|
|
1865
|
+
return cached.value;
|
|
1866
|
+
}
|
|
1867
|
+
|
|
1868
|
+
const inFlight = this.#usageRequestInFlight.get(cacheKey);
|
|
1869
|
+
if (inFlight) return inFlight;
|
|
1870
|
+
|
|
1871
|
+
const promise = (async () => {
|
|
1872
|
+
const report = await this.#fetchUsageUncached(request, timeoutMs);
|
|
1873
|
+
const ttlJitter = USAGE_REPORT_TTL_MS * (Math.random() * 0.5 - 0.25);
|
|
1874
|
+
if (report !== null) {
|
|
1875
|
+
// Success: stagger per-credential cache expiry so all accounts don't
|
|
1876
|
+
// refresh in the same window — Anthropic / OpenAI rate-limit `/usage`
|
|
1877
|
+
// per source IP regardless of account, and synchronized 5-credential
|
|
1878
|
+
// fan-out trips 429s every cycle. With ±25% jitter on TTL the refresh
|
|
1879
|
+
// times decorrelate within a few cycles.
|
|
1880
|
+
this.#usageCache.set(cacheKey, { value: report, expiresAt: Date.now() + USAGE_REPORT_TTL_MS + ttlJitter });
|
|
1881
|
+
return report;
|
|
1882
|
+
}
|
|
1883
|
+
// Failure: cache the LAST GOOD value (if any) with a short jittered TTL
|
|
1884
|
+
// so the credential cools down briefly without dropping out of the
|
|
1885
|
+
// report. If we never had a good value, return null this cycle and
|
|
1886
|
+
// don't write — let the next poll retry.
|
|
1887
|
+
const lastGood = this.#usageCache.getStale<UsageReport | null>(cacheKey)?.value ?? null;
|
|
1888
|
+
if (lastGood !== null) {
|
|
1889
|
+
const backoffJitter = USAGE_FAILURE_BACKOFF_MS * (Math.random() * 0.5 - 0.25);
|
|
1890
|
+
const coolDown = Date.now() + USAGE_FAILURE_BACKOFF_MS + backoffJitter;
|
|
1891
|
+
this.#usageCache.set(cacheKey, { value: lastGood, expiresAt: coolDown });
|
|
1892
|
+
}
|
|
1893
|
+
return lastGood;
|
|
1894
|
+
})().finally(() => {
|
|
1895
|
+
this.#usageRequestInFlight.delete(cacheKey);
|
|
1896
|
+
});
|
|
1897
|
+
|
|
1898
|
+
this.#usageRequestInFlight.set(cacheKey, promise);
|
|
1899
|
+
return promise;
|
|
1900
|
+
}
|
|
1901
|
+
|
|
1902
|
+
#collectUsageRequests(options?: {
|
|
1903
|
+
baseUrlResolver?: (provider: Provider) => string | undefined;
|
|
1904
|
+
}): UsageRequestDescriptor[] {
|
|
1905
|
+
const resolver = this.#usageProviderResolver;
|
|
1906
|
+
if (!resolver) return [];
|
|
1907
|
+
|
|
1908
|
+
const requests: UsageRequestDescriptor[] = [];
|
|
1909
|
+
const providers = new Set<string>([
|
|
1910
|
+
...this.#data.keys(),
|
|
1911
|
+
...DEFAULT_USAGE_PROVIDERS.map(provider => provider.id),
|
|
1912
|
+
]);
|
|
1913
|
+
|
|
1914
|
+
for (const providerId of providers) {
|
|
1915
|
+
const provider = providerId as Provider;
|
|
1916
|
+
const providerImpl = resolver(provider);
|
|
1917
|
+
if (!providerImpl) continue;
|
|
1918
|
+
const baseUrl = options?.baseUrlResolver?.(provider);
|
|
1919
|
+
let entries = this.#getStoredCredentials(providerId);
|
|
1920
|
+
if (entries.length > 0) {
|
|
1921
|
+
const dedupedEntries = this.#pruneDuplicateStoredCredentials(providerId, entries);
|
|
1922
|
+
if (dedupedEntries.length !== entries.length) {
|
|
1923
|
+
this.#setStoredCredentials(providerId, dedupedEntries);
|
|
1924
|
+
}
|
|
1925
|
+
entries = dedupedEntries;
|
|
1926
|
+
}
|
|
1927
|
+
|
|
1928
|
+
if (entries.length === 0) {
|
|
1929
|
+
const runtimeKey = this.#runtimeOverrides.get(providerId);
|
|
1930
|
+
const envKey = getEnvApiKey(providerId);
|
|
1931
|
+
const apiKey = runtimeKey ?? envKey;
|
|
1932
|
+
if (!apiKey) continue;
|
|
1933
|
+
const request = this.#buildUsageRequest(provider, { type: "api_key", apiKey }, baseUrl);
|
|
1934
|
+
if (providerImpl.supports && !providerImpl.supports(request)) continue;
|
|
1935
|
+
requests.push(request);
|
|
1936
|
+
continue;
|
|
1937
|
+
}
|
|
1938
|
+
|
|
1939
|
+
for (const entry of entries) {
|
|
1940
|
+
const credential = entry.credential;
|
|
1941
|
+
const request =
|
|
1942
|
+
credential.type === "api_key"
|
|
1943
|
+
? this.#buildUsageRequest(provider, { type: "api_key", apiKey: credential.key }, baseUrl)
|
|
1944
|
+
: this.#buildUsageRequestForOauth(provider, credential, baseUrl);
|
|
1945
|
+
if (providerImpl.supports && !providerImpl.supports(request)) continue;
|
|
1946
|
+
requests.push(request);
|
|
1947
|
+
}
|
|
1948
|
+
}
|
|
1949
|
+
|
|
1950
|
+
return requests;
|
|
1951
|
+
}
|
|
1952
|
+
|
|
1953
|
+
#getUsageReportMetadataValue(report: UsageReport, key: string): string | undefined {
|
|
1954
|
+
const metadata = report.metadata;
|
|
1955
|
+
if (!metadata || typeof metadata !== "object") return undefined;
|
|
1956
|
+
const value = metadata[key];
|
|
1957
|
+
return typeof value === "string" ? value.trim() : undefined;
|
|
1958
|
+
}
|
|
1959
|
+
|
|
1960
|
+
#getUsageReportScopeAccountId(report: UsageReport): string | undefined {
|
|
1961
|
+
const ids = new Set<string>();
|
|
1962
|
+
for (const limit of report.limits) {
|
|
1963
|
+
const accountId = limit.scope.accountId?.trim();
|
|
1964
|
+
if (accountId) ids.add(accountId);
|
|
1965
|
+
}
|
|
1966
|
+
if (ids.size === 1) return [...ids][0];
|
|
1967
|
+
return undefined;
|
|
1968
|
+
}
|
|
1969
|
+
|
|
1970
|
+
#getUsageReportIdentifiers(report: UsageReport): string[] {
|
|
1971
|
+
const identifiers: string[] = [];
|
|
1972
|
+
const email = this.#getUsageReportMetadataValue(report, "email");
|
|
1973
|
+
if (email) identifiers.push(`email:${email.toLowerCase()}`);
|
|
1974
|
+
if (report.provider === "openai-codex" || report.provider === "anthropic") {
|
|
1975
|
+
return identifiers.map(identifier => `${report.provider}:${identifier.toLowerCase()}`);
|
|
1976
|
+
}
|
|
1977
|
+
const accountId = this.#getUsageReportMetadataValue(report, "accountId");
|
|
1978
|
+
if (accountId) identifiers.push(`account:${accountId}`);
|
|
1979
|
+
const account = this.#getUsageReportMetadataValue(report, "account");
|
|
1980
|
+
if (account) identifiers.push(`account:${account}`);
|
|
1981
|
+
const user = this.#getUsageReportMetadataValue(report, "user");
|
|
1982
|
+
if (user) identifiers.push(`account:${user}`);
|
|
1983
|
+
const username = this.#getUsageReportMetadataValue(report, "username");
|
|
1984
|
+
if (username) identifiers.push(`account:${username}`);
|
|
1985
|
+
const scopeAccountId = this.#getUsageReportScopeAccountId(report);
|
|
1986
|
+
if (scopeAccountId) identifiers.push(`account:${scopeAccountId}`);
|
|
1987
|
+
return identifiers.map(identifier => `${report.provider}:${identifier.toLowerCase()}`);
|
|
1988
|
+
}
|
|
1989
|
+
|
|
1990
|
+
#mergeUsageReportGroup(reports: UsageReport[]): UsageReport {
|
|
1991
|
+
if (reports.length === 1) return reports[0];
|
|
1992
|
+
const sorted = [...reports].sort((a, b) => {
|
|
1993
|
+
const limitDiff = b.limits.length - a.limits.length;
|
|
1994
|
+
if (limitDiff !== 0) return limitDiff;
|
|
1995
|
+
return (b.fetchedAt ?? 0) - (a.fetchedAt ?? 0);
|
|
1996
|
+
});
|
|
1997
|
+
const base = sorted[0];
|
|
1998
|
+
const mergedLimits = [...base.limits];
|
|
1999
|
+
const limitIds = new Set(mergedLimits.map(limit => limit.id));
|
|
2000
|
+
const mergedMetadata: Record<string, unknown> = { ...(base.metadata ?? {}) };
|
|
2001
|
+
let fetchedAt = base.fetchedAt;
|
|
2002
|
+
|
|
2003
|
+
for (const report of sorted.slice(1)) {
|
|
2004
|
+
fetchedAt = Math.max(fetchedAt, report.fetchedAt);
|
|
2005
|
+
for (const limit of report.limits) {
|
|
2006
|
+
if (!limitIds.has(limit.id)) {
|
|
2007
|
+
limitIds.add(limit.id);
|
|
2008
|
+
mergedLimits.push(limit);
|
|
2009
|
+
}
|
|
2010
|
+
}
|
|
2011
|
+
if (report.metadata) {
|
|
2012
|
+
for (const [key, value] of Object.entries(report.metadata)) {
|
|
2013
|
+
if (mergedMetadata[key] === undefined) {
|
|
2014
|
+
mergedMetadata[key] = value;
|
|
2015
|
+
}
|
|
2016
|
+
}
|
|
2017
|
+
}
|
|
2018
|
+
}
|
|
2019
|
+
|
|
2020
|
+
return {
|
|
2021
|
+
...base,
|
|
2022
|
+
fetchedAt,
|
|
2023
|
+
limits: mergedLimits,
|
|
2024
|
+
metadata: Object.keys(mergedMetadata).length > 0 ? mergedMetadata : undefined,
|
|
2025
|
+
};
|
|
2026
|
+
}
|
|
2027
|
+
|
|
2028
|
+
#dedupeUsageReports(reports: UsageReport[]): UsageReport[] {
|
|
2029
|
+
const groups: UsageReport[][] = [];
|
|
2030
|
+
const idToGroup = new Map<string, number>();
|
|
2031
|
+
|
|
2032
|
+
for (const report of reports) {
|
|
2033
|
+
const identifiers = this.#getUsageReportIdentifiers(report);
|
|
2034
|
+
let groupIndex: number | undefined;
|
|
2035
|
+
for (const identifier of identifiers) {
|
|
2036
|
+
const existing = idToGroup.get(identifier);
|
|
2037
|
+
if (existing !== undefined) {
|
|
2038
|
+
groupIndex = existing;
|
|
2039
|
+
break;
|
|
2040
|
+
}
|
|
2041
|
+
}
|
|
2042
|
+
if (groupIndex === undefined) {
|
|
2043
|
+
groupIndex = groups.length;
|
|
2044
|
+
groups.push([]);
|
|
2045
|
+
}
|
|
2046
|
+
groups[groupIndex].push(report);
|
|
2047
|
+
for (const identifier of identifiers) {
|
|
2048
|
+
idToGroup.set(identifier, groupIndex);
|
|
2049
|
+
}
|
|
2050
|
+
}
|
|
2051
|
+
|
|
2052
|
+
const deduped = groups.map(group => this.#mergeUsageReportGroup(group));
|
|
2053
|
+
if (deduped.length !== reports.length) {
|
|
2054
|
+
this.#usageLogger?.debug("Usage reports deduped", {
|
|
2055
|
+
before: reports.length,
|
|
2056
|
+
after: deduped.length,
|
|
2057
|
+
});
|
|
2058
|
+
}
|
|
2059
|
+
return deduped;
|
|
2060
|
+
}
|
|
2061
|
+
|
|
2062
|
+
#isUsageLimitExhausted(limit: UsageLimit): boolean {
|
|
2063
|
+
if (limit.status === "exhausted") return true;
|
|
2064
|
+
const amount = limit.amount;
|
|
2065
|
+
if (amount.usedFraction !== undefined && amount.usedFraction >= 1) return true;
|
|
2066
|
+
if (amount.remainingFraction !== undefined && amount.remainingFraction <= 0) return true;
|
|
2067
|
+
if (amount.used !== undefined && amount.limit !== undefined && amount.used >= amount.limit) return true;
|
|
2068
|
+
if (amount.remaining !== undefined && amount.remaining <= 0) return true;
|
|
2069
|
+
if (amount.unit === "percent" && amount.used !== undefined && amount.used >= 100) return true;
|
|
2070
|
+
return false;
|
|
2071
|
+
}
|
|
2072
|
+
|
|
2073
|
+
/** Returns true if usage indicates rate limit has been reached. */
|
|
2074
|
+
#isUsageLimitReached(report: UsageReport): boolean {
|
|
2075
|
+
return report.limits.some(limit => this.#isUsageLimitExhausted(limit));
|
|
2076
|
+
}
|
|
2077
|
+
|
|
2078
|
+
/** Extracts the earliest reset timestamp from exhausted windows (in ms). */
|
|
2079
|
+
#getUsageResetAtMs(report: UsageReport, nowMs: number): number | undefined {
|
|
2080
|
+
const candidates: number[] = [];
|
|
2081
|
+
for (const limit of report.limits) {
|
|
2082
|
+
if (!this.#isUsageLimitExhausted(limit)) continue;
|
|
2083
|
+
const window = limit.window;
|
|
2084
|
+
if (window?.resetsAt && window.resetsAt > nowMs) {
|
|
2085
|
+
candidates.push(window.resetsAt);
|
|
2086
|
+
}
|
|
2087
|
+
}
|
|
2088
|
+
if (candidates.length === 0) return undefined;
|
|
2089
|
+
return Math.min(...candidates);
|
|
2090
|
+
}
|
|
2091
|
+
|
|
2092
|
+
async #getUsageReport(
|
|
2093
|
+
provider: Provider,
|
|
2094
|
+
credential: OAuthCredential,
|
|
2095
|
+
options?: { baseUrl?: string; timeoutMs?: number; signal?: AbortSignal },
|
|
2096
|
+
): Promise<UsageReport | null> {
|
|
2097
|
+
// Store-level hook (e.g. `RemoteAuthCredentialStore`) is authoritative
|
|
2098
|
+
// when present: the broker already aggregates usage from a less-throttled
|
|
2099
|
+
// IP, and falling back to the local per-credential fetch would defeat the
|
|
2100
|
+
// whole point of routing through it.
|
|
2101
|
+
const storeHook = this.#store.getUsageReport?.bind(this.#store);
|
|
2102
|
+
if (storeHook) {
|
|
2103
|
+
return storeHook(provider, credential, options?.signal);
|
|
2104
|
+
}
|
|
2105
|
+
return this.#fetchUsageCached(
|
|
2106
|
+
this.#buildUsageRequestForOauth(provider, credential, options?.baseUrl),
|
|
2107
|
+
options?.timeoutMs ?? this.#usageRequestTimeoutMs,
|
|
2108
|
+
);
|
|
2109
|
+
}
|
|
2110
|
+
|
|
2111
|
+
async fetchUsageReports(options?: {
|
|
2112
|
+
baseUrlResolver?: (provider: Provider) => string | undefined;
|
|
2113
|
+
/** Caller's cancel signal; only rejects this caller, never the shared upstream fetch. */
|
|
2114
|
+
signal?: AbortSignal;
|
|
2115
|
+
}): Promise<UsageReport[] | null> {
|
|
2116
|
+
// Caller override > store-level hook > local per-credential fan-out.
|
|
2117
|
+
// `RemoteAuthCredentialStore` implements the store hook so a gateway
|
|
2118
|
+
// backed by a broker automatically routes usage to the broker without
|
|
2119
|
+
// needing the caller to wire it explicitly.
|
|
2120
|
+
const override = this.#fetchUsageReportsOverride ?? this.#store.fetchUsageReports?.bind(this.#store);
|
|
2121
|
+
if (override) {
|
|
2122
|
+
// Reuse the in-flight map so concurrent callers (widget poll + format
|
|
2123
|
+
// dispatch + credential selection) coalesce into one upstream call.
|
|
2124
|
+
// Each caller's `signal` only cancels THAT caller's await; the
|
|
2125
|
+
// shared upstream fetch runs to completion so peers aren't punished.
|
|
2126
|
+
const OVERRIDE_KEY = "__override__";
|
|
2127
|
+
let shared = this.#usageReportsInFlight.get(OVERRIDE_KEY);
|
|
2128
|
+
if (!shared) {
|
|
2129
|
+
// Don't forward the caller signal into the shared fetch — first caller's
|
|
2130
|
+
// abort would otherwise cancel the upstream for every peer.
|
|
2131
|
+
shared = override().finally(() => {
|
|
2132
|
+
this.#usageReportsInFlight.delete(OVERRIDE_KEY);
|
|
2133
|
+
});
|
|
2134
|
+
this.#usageReportsInFlight.set(OVERRIDE_KEY, shared);
|
|
2135
|
+
}
|
|
2136
|
+
return raceUsageWithSignal(shared, options?.signal);
|
|
2137
|
+
}
|
|
2138
|
+
if (!this.#usageProviderResolver) return null;
|
|
2139
|
+
|
|
2140
|
+
const requests = this.#collectUsageRequests(options);
|
|
2141
|
+
if (requests.length === 0) return [];
|
|
2142
|
+
|
|
2143
|
+
this.#usageLogger?.debug("Usage fetch requested", {
|
|
2144
|
+
providers: [...new Set(requests.map(request => request.provider))].sort(),
|
|
2145
|
+
});
|
|
2146
|
+
|
|
2147
|
+
// Per-credential caching with jitter lives in #fetchUsageCached, so we
|
|
2148
|
+
// don't store the aggregated result here — doing so locks the widget to
|
|
2149
|
+
// a single decorrelation snapshot for 30s, defeating the jitter (some
|
|
2150
|
+
// accounts can be missing from one fetch and present in the next; the
|
|
2151
|
+
// aggregate cache freezes whichever set landed first).
|
|
2152
|
+
const cacheKey = this.#buildUsageReportsCacheKey(requests);
|
|
2153
|
+
|
|
2154
|
+
const inFlight = this.#usageReportsInFlight.get(cacheKey);
|
|
2155
|
+
if (inFlight) return inFlight;
|
|
2156
|
+
|
|
2157
|
+
const promise = (async () => {
|
|
2158
|
+
for (const request of requests) {
|
|
2159
|
+
this.#usageLogger?.debug("Usage fetch queued", {
|
|
2160
|
+
provider: request.provider,
|
|
2161
|
+
credentialType: request.credential.type,
|
|
2162
|
+
baseUrl: request.baseUrl,
|
|
2163
|
+
accountId: request.credential.accountId,
|
|
2164
|
+
email: request.credential.email,
|
|
2165
|
+
});
|
|
2166
|
+
}
|
|
2167
|
+
|
|
2168
|
+
const results = await Promise.all(
|
|
2169
|
+
requests.map(request => this.#fetchUsageCached(request, this.#usageRequestTimeoutMs)),
|
|
2170
|
+
);
|
|
2171
|
+
const reports = results.filter((report): report is UsageReport => report !== null);
|
|
2172
|
+
const deduped = this.#dedupeUsageReports(reports);
|
|
2173
|
+
// no outer cache write — see comment above.
|
|
2174
|
+
const resolved = deduped;
|
|
2175
|
+
this.#usageLogger?.debug("Usage fetch resolved", {
|
|
2176
|
+
reports: resolved.map(report => {
|
|
2177
|
+
const accountLabel =
|
|
2178
|
+
this.#getUsageReportMetadataValue(report, "email") ??
|
|
2179
|
+
this.#getUsageReportMetadataValue(report, "accountId") ??
|
|
2180
|
+
this.#getUsageReportMetadataValue(report, "account") ??
|
|
2181
|
+
this.#getUsageReportMetadataValue(report, "user") ??
|
|
2182
|
+
this.#getUsageReportMetadataValue(report, "username") ??
|
|
2183
|
+
this.#getUsageReportScopeAccountId(report);
|
|
2184
|
+
return {
|
|
2185
|
+
provider: report.provider,
|
|
2186
|
+
limits: report.limits.length,
|
|
2187
|
+
account: accountLabel,
|
|
2188
|
+
};
|
|
2189
|
+
}),
|
|
2190
|
+
});
|
|
2191
|
+
return resolved;
|
|
2192
|
+
})().finally(() => {
|
|
2193
|
+
this.#usageReportsInFlight.delete(cacheKey);
|
|
2194
|
+
});
|
|
2195
|
+
|
|
2196
|
+
this.#usageReportsInFlight.set(cacheKey, promise);
|
|
2197
|
+
return promise;
|
|
2198
|
+
}
|
|
2199
|
+
|
|
2200
|
+
/**
|
|
2201
|
+
* Probe each stored credential against its provider's auth-verifying usage
|
|
2202
|
+
* endpoint and report per-credential auth health.
|
|
2203
|
+
*
|
|
2204
|
+
* Surfaces the identity of failing credentials so callers running a
|
|
2205
|
+
* multi-account pool (e.g. a broker-backed auth-gateway) can tell which
|
|
2206
|
+
* row is producing 401s. The probe mirrors the per-credential fan-out
|
|
2207
|
+
* inside {@link AuthStorage.fetchUsageReports} (OAuth refresh-on-expiry,
|
|
2208
|
+
* then `UsageProvider.fetchUsage`) but does NOT swallow errors — every
|
|
2209
|
+
* credential gets either `ok: true`, `ok: false` with `reason`, or
|
|
2210
|
+
* `ok: null` when no probe is configured for the provider.
|
|
2211
|
+
*
|
|
2212
|
+
* Iterates sequentially to avoid synchronized N-account fan-out that
|
|
2213
|
+
* upstream `/usage` rate limiters (per source IP) treat as a burst.
|
|
2214
|
+
*
|
|
2215
|
+
* Only inspects active rows from {@link AuthCredentialStore.listAuthCredentials};
|
|
2216
|
+
* soft-disabled rows are already known-bad and don't need a network probe.
|
|
2217
|
+
* Environment-variable API keys are not enumerated — the caller's intent
|
|
2218
|
+
* here is "which of my stored credentials is broken".
|
|
2219
|
+
*/
|
|
2220
|
+
async checkCredentials(options?: CheckCredentialsOptions): Promise<CredentialHealthResult[]> {
|
|
2221
|
+
options?.signal?.throwIfAborted();
|
|
2222
|
+
const stored = this.#store.listAuthCredentials();
|
|
2223
|
+
const resolver = this.#usageProviderResolver;
|
|
2224
|
+
const timeoutMs = options?.timeoutMs ?? this.#usageRequestTimeoutMs;
|
|
2225
|
+
const ctx: UsageFetchContext = { fetch: this.#usageFetch, logger: this.#usageLogger };
|
|
2226
|
+
|
|
2227
|
+
const results: CredentialHealthResult[] = [];
|
|
2228
|
+
for (const row of stored) {
|
|
2229
|
+
options?.signal?.throwIfAborted();
|
|
2230
|
+
const base: CredentialHealthResult = {
|
|
2231
|
+
id: row.id,
|
|
2232
|
+
provider: row.provider,
|
|
2233
|
+
type: row.credential.type,
|
|
2234
|
+
ok: null,
|
|
2235
|
+
};
|
|
2236
|
+
if (row.credential.type === "oauth") {
|
|
2237
|
+
if (row.credential.email) base.email = row.credential.email;
|
|
2238
|
+
if (row.credential.accountId) base.accountId = row.credential.accountId;
|
|
2239
|
+
if (row.credential.refresh === REMOTE_REFRESH_SENTINEL) base.remoteRefresh = true;
|
|
2240
|
+
}
|
|
2241
|
+
|
|
2242
|
+
const providerImpl = resolver?.(row.provider as Provider);
|
|
2243
|
+
if (!providerImpl) {
|
|
2244
|
+
base.reason = `no usage probe configured for provider ${row.provider}`;
|
|
2245
|
+
results.push(base);
|
|
2246
|
+
continue;
|
|
2247
|
+
}
|
|
2248
|
+
|
|
2249
|
+
const baseUrl = options?.baseUrlResolver?.(row.provider as Provider);
|
|
2250
|
+
const cred = row.credential;
|
|
2251
|
+
const initialRequest: UsageRequestDescriptor =
|
|
2252
|
+
cred.type === "api_key"
|
|
2253
|
+
? this.#buildUsageRequest(row.provider as Provider, { type: "api_key", apiKey: cred.key }, baseUrl)
|
|
2254
|
+
: this.#buildUsageRequestForOauth(row.provider as Provider, cred, baseUrl);
|
|
2255
|
+
|
|
2256
|
+
if (providerImpl.supports && !providerImpl.supports(initialRequest)) {
|
|
2257
|
+
base.reason = `usage probe does not support ${cred.type} credentials for ${row.provider}`;
|
|
2258
|
+
results.push(base);
|
|
2259
|
+
continue;
|
|
2260
|
+
}
|
|
2261
|
+
|
|
2262
|
+
const timeoutSignal = AbortSignal.timeout(timeoutMs);
|
|
2263
|
+
const probeSignal = options?.signal ? AbortSignal.any([options.signal, timeoutSignal]) : timeoutSignal;
|
|
2264
|
+
let params: UsageFetchParams & { signal: AbortSignal } = { ...initialRequest, signal: probeSignal };
|
|
2265
|
+
|
|
2266
|
+
// Refresh expired OAuth before probing — without this an expired access
|
|
2267
|
+
// token reports as `false` when the credential is actually healthy
|
|
2268
|
+
// (broker would happily refresh it on the next real request).
|
|
2269
|
+
if (
|
|
2270
|
+
cred.type === "oauth" &&
|
|
2271
|
+
initialRequest.credential.type === "oauth" &&
|
|
2272
|
+
initialRequest.credential.expiresAt !== undefined &&
|
|
2273
|
+
Date.now() >= initialRequest.credential.expiresAt
|
|
2274
|
+
) {
|
|
2275
|
+
const refreshable = this.#buildRefreshableOauthCredential(initialRequest.credential);
|
|
2276
|
+
if (refreshable) {
|
|
2277
|
+
try {
|
|
2278
|
+
const refreshed = await this.#refreshOAuthCredential(
|
|
2279
|
+
row.provider as Provider,
|
|
2280
|
+
refreshable,
|
|
2281
|
+
row.id,
|
|
2282
|
+
probeSignal,
|
|
2283
|
+
);
|
|
2284
|
+
const refreshedCredential = this.#mergeRefreshedUsageCredential(initialRequest.credential, refreshed);
|
|
2285
|
+
this.#persistRefreshedUsageCredential(
|
|
2286
|
+
row.provider as Provider,
|
|
2287
|
+
initialRequest.credential,
|
|
2288
|
+
refreshedCredential,
|
|
2289
|
+
);
|
|
2290
|
+
params = { ...params, credential: refreshedCredential };
|
|
2291
|
+
} catch (error) {
|
|
2292
|
+
base.ok = false;
|
|
2293
|
+
base.reason = `oauth refresh failed: ${error instanceof Error ? error.message : String(error)}`;
|
|
2294
|
+
results.push(base);
|
|
2295
|
+
continue;
|
|
2296
|
+
}
|
|
2297
|
+
}
|
|
2298
|
+
}
|
|
2299
|
+
|
|
2300
|
+
try {
|
|
2301
|
+
const report = await providerImpl.fetchUsage(params, ctx);
|
|
2302
|
+
if (report === null) {
|
|
2303
|
+
base.reason = "usage probe returned no data for this credential";
|
|
2304
|
+
} else {
|
|
2305
|
+
base.ok = true;
|
|
2306
|
+
const accountId = this.#getUsageReportMetadataValue(report, "accountId");
|
|
2307
|
+
const email = this.#getUsageReportMetadataValue(report, "email");
|
|
2308
|
+
if (accountId) base.accountId = accountId;
|
|
2309
|
+
if (email) base.email = email;
|
|
2310
|
+
const { raw: _raw, ...trimmed } = report;
|
|
2311
|
+
base.report = trimmed;
|
|
2312
|
+
}
|
|
2313
|
+
} catch (error) {
|
|
2314
|
+
base.ok = false;
|
|
2315
|
+
base.reason = error instanceof Error ? error.message : String(error);
|
|
2316
|
+
}
|
|
2317
|
+
|
|
2318
|
+
results.push(base);
|
|
2319
|
+
}
|
|
2320
|
+
|
|
2321
|
+
return results;
|
|
2322
|
+
}
|
|
2323
|
+
|
|
2324
|
+
/**
|
|
2325
|
+
* Marks the current session's credential as temporarily blocked due to usage limits.
|
|
2326
|
+
* Uses usage reports to determine accurate reset time when available.
|
|
2327
|
+
* Returns true if a credential was blocked, enabling automatic fallback to the next credential.
|
|
2328
|
+
*/
|
|
2329
|
+
async markUsageLimitReached(
|
|
2330
|
+
provider: string,
|
|
2331
|
+
sessionId: string | undefined,
|
|
2332
|
+
options?: { retryAfterMs?: number; baseUrl?: string; signal?: AbortSignal },
|
|
2333
|
+
): Promise<boolean> {
|
|
2334
|
+
const sessionCredential = this.#getSessionCredential(provider, sessionId);
|
|
2335
|
+
if (!sessionCredential) return false;
|
|
2336
|
+
|
|
2337
|
+
const providerKey = this.#getProviderTypeKey(provider, sessionCredential.type);
|
|
2338
|
+
const now = Date.now();
|
|
2339
|
+
let blockedUntil = now + (options?.retryAfterMs ?? AuthStorage.#defaultBackoffMs);
|
|
2340
|
+
|
|
2341
|
+
if (sessionCredential.type === "oauth" && this.#rankingStrategyResolver?.(provider)) {
|
|
2342
|
+
const credential = this.#getCredentialsForProvider(provider)[sessionCredential.index];
|
|
2343
|
+
if (credential?.type === "oauth") {
|
|
2344
|
+
const report = await this.#getUsageReport(provider, credential, options);
|
|
2345
|
+
if (report && this.#isUsageLimitReached(report)) {
|
|
2346
|
+
const resetAtMs = this.#getUsageResetAtMs(report, Date.now());
|
|
2347
|
+
if (resetAtMs && resetAtMs > blockedUntil) {
|
|
2348
|
+
blockedUntil = resetAtMs;
|
|
2349
|
+
}
|
|
2350
|
+
}
|
|
2351
|
+
}
|
|
2352
|
+
}
|
|
2353
|
+
|
|
2354
|
+
this.#markCredentialBlocked(providerKey, sessionCredential.index, blockedUntil);
|
|
2355
|
+
|
|
2356
|
+
const remainingCredentials = this.#getCredentialsForProvider(provider)
|
|
2357
|
+
.map((credential, index) => ({ credential, index }))
|
|
2358
|
+
.filter(
|
|
2359
|
+
(entry): entry is { credential: AuthCredential; index: number } =>
|
|
2360
|
+
entry.credential.type === sessionCredential.type && entry.index !== sessionCredential.index,
|
|
2361
|
+
);
|
|
2362
|
+
|
|
2363
|
+
return remainingCredentials.some(candidate => !this.#isCredentialBlocked(providerKey, candidate.index));
|
|
2364
|
+
}
|
|
2365
|
+
|
|
2366
|
+
#resolveWindowResetAt(window: UsageLimit["window"]): number | undefined {
|
|
2367
|
+
if (!window) return undefined;
|
|
2368
|
+
if (typeof window.resetsAt === "number" && Number.isFinite(window.resetsAt)) {
|
|
2369
|
+
return window.resetsAt;
|
|
2370
|
+
}
|
|
2371
|
+
return undefined;
|
|
2372
|
+
}
|
|
2373
|
+
|
|
2374
|
+
#normalizeUsageFraction(limit: UsageLimit | undefined): number {
|
|
2375
|
+
const usedFraction = limit?.amount.usedFraction;
|
|
2376
|
+
if (typeof usedFraction !== "number" || !Number.isFinite(usedFraction)) {
|
|
2377
|
+
return 0.5;
|
|
2378
|
+
}
|
|
2379
|
+
return Math.min(Math.max(usedFraction, 0), 1);
|
|
2380
|
+
}
|
|
2381
|
+
|
|
2382
|
+
/** Computes `usedFraction / elapsedHours` — consumption rate per hour within the current window. Lower drain rate = less pressure = preferred. */
|
|
2383
|
+
#computeWindowDrainRate(limit: UsageLimit | undefined, nowMs: number, fallbackDurationMs: number): number {
|
|
2384
|
+
const usedFraction = this.#normalizeUsageFraction(limit);
|
|
2385
|
+
const durationMs = limit?.window?.durationMs ?? fallbackDurationMs;
|
|
2386
|
+
if (!Number.isFinite(durationMs) || durationMs <= 0) {
|
|
2387
|
+
return usedFraction;
|
|
2388
|
+
}
|
|
2389
|
+
const resetAt = this.#resolveWindowResetAt(limit?.window);
|
|
2390
|
+
if (!Number.isFinite(resetAt)) {
|
|
2391
|
+
return usedFraction;
|
|
2392
|
+
}
|
|
2393
|
+
const remainingWindowMs = (resetAt as number) - nowMs;
|
|
2394
|
+
const clampedRemainingWindowMs = Math.min(Math.max(remainingWindowMs, 0), durationMs);
|
|
2395
|
+
const elapsedMs = durationMs - clampedRemainingWindowMs;
|
|
2396
|
+
if (elapsedMs <= 0) {
|
|
2397
|
+
return usedFraction;
|
|
2398
|
+
}
|
|
2399
|
+
const elapsedHours = elapsedMs / (60 * 60 * 1000);
|
|
2400
|
+
if (!Number.isFinite(elapsedHours) || elapsedHours <= 0) {
|
|
2401
|
+
return usedFraction;
|
|
2402
|
+
}
|
|
2403
|
+
return usedFraction / elapsedHours;
|
|
2404
|
+
}
|
|
2405
|
+
|
|
2406
|
+
async #rankOAuthSelections(args: {
|
|
2407
|
+
providerKey: string;
|
|
2408
|
+
provider: string;
|
|
2409
|
+
order: number[];
|
|
2410
|
+
credentials: Array<{ credential: OAuthCredential; index: number }>;
|
|
2411
|
+
options?: AuthApiKeyOptions;
|
|
2412
|
+
strategy: CredentialRankingStrategy;
|
|
2413
|
+
}): Promise<
|
|
2414
|
+
Array<{
|
|
2415
|
+
selection: { credential: OAuthCredential; index: number };
|
|
2416
|
+
usage: UsageReport | null;
|
|
2417
|
+
usageChecked: boolean;
|
|
2418
|
+
}>
|
|
2419
|
+
> {
|
|
2420
|
+
const nowMs = Date.now();
|
|
2421
|
+
const { strategy } = args;
|
|
2422
|
+
const ranked: Array<{
|
|
2423
|
+
selection: { credential: OAuthCredential; index: number };
|
|
2424
|
+
usage: UsageReport | null;
|
|
2425
|
+
usageChecked: boolean;
|
|
2426
|
+
blocked: boolean;
|
|
2427
|
+
blockedUntil?: number;
|
|
2428
|
+
hasPriorityBoost: boolean;
|
|
2429
|
+
secondaryUsed: number;
|
|
2430
|
+
secondaryDrainRate: number;
|
|
2431
|
+
primaryUsed: number;
|
|
2432
|
+
primaryDrainRate: number;
|
|
2433
|
+
orderPos: number;
|
|
2434
|
+
}> = [];
|
|
2435
|
+
// Pre-fetch usage reports in parallel for non-blocked credentials.
|
|
2436
|
+
// Wrap with a timeout so slow/429'd fetches don't indefinitely block
|
|
2437
|
+
// credential selection — better to pick a credential without usage data
|
|
2438
|
+
// than to hang the agent waiting for rate-limited usage endpoints.
|
|
2439
|
+
const usageTimeout = Math.max(5000, this.#usageRequestTimeoutMs * 1.5);
|
|
2440
|
+
const usagePromise = Promise.all(
|
|
2441
|
+
args.order.map(async idx => {
|
|
2442
|
+
const selection = args.credentials[idx];
|
|
2443
|
+
if (!selection) return null;
|
|
2444
|
+
const blockedUntil = this.#getCredentialBlockedUntil(args.providerKey, selection.index);
|
|
2445
|
+
if (blockedUntil !== undefined) return { selection, usage: null, usageChecked: false, blockedUntil };
|
|
2446
|
+
const usage = await this.#getUsageReport(args.provider, selection.credential, {
|
|
2447
|
+
...args.options,
|
|
2448
|
+
timeoutMs: this.#usageRequestTimeoutMs,
|
|
2449
|
+
});
|
|
2450
|
+
return { selection, usage, usageChecked: true, blockedUntil: undefined as number | undefined };
|
|
2451
|
+
}),
|
|
2452
|
+
);
|
|
2453
|
+
const timeoutSignal = Promise.withResolvers<null>();
|
|
2454
|
+
// `Bun.sleep` keeps the event loop alive even after Promise.race resolves,
|
|
2455
|
+
// which leaks a 7.5–15s timer per credential-selection call. Use an unref'd
|
|
2456
|
+
// timer so the timeout doesn't pin the process and clear it on the happy
|
|
2457
|
+
// path so memory drops immediately.
|
|
2458
|
+
const timer = setTimeout(() => timeoutSignal.resolve(null), usageTimeout);
|
|
2459
|
+
timer.unref?.();
|
|
2460
|
+
const usageResults = await Promise.race([usagePromise, timeoutSignal.promise]).then(result => {
|
|
2461
|
+
clearTimeout(timer);
|
|
2462
|
+
return (
|
|
2463
|
+
result ??
|
|
2464
|
+
args.order.map(idx => {
|
|
2465
|
+
const selection = args.credentials[idx];
|
|
2466
|
+
return selection ? { selection, usage: null, usageChecked: false, blockedUntil: undefined } : null;
|
|
2467
|
+
})
|
|
2468
|
+
);
|
|
2469
|
+
});
|
|
2470
|
+
|
|
2471
|
+
for (let orderPos = 0; orderPos < usageResults.length; orderPos += 1) {
|
|
2472
|
+
const result = usageResults[orderPos];
|
|
2473
|
+
if (!result) continue;
|
|
2474
|
+
const { selection, usage, usageChecked } = result;
|
|
2475
|
+
let { blockedUntil } = result;
|
|
2476
|
+
let blocked = blockedUntil !== undefined;
|
|
2477
|
+
if (!blocked && usage && this.#isUsageLimitReached(usage)) {
|
|
2478
|
+
const resetAtMs = this.#getUsageResetAtMs(usage, nowMs);
|
|
2479
|
+
blockedUntil = resetAtMs ?? Date.now() + AuthStorage.#defaultBackoffMs;
|
|
2480
|
+
this.#markCredentialBlocked(args.providerKey, selection.index, blockedUntil);
|
|
2481
|
+
blocked = true;
|
|
2482
|
+
}
|
|
2483
|
+
const windows = usage ? strategy.findWindowLimits(usage) : undefined;
|
|
2484
|
+
const primary = windows?.primary;
|
|
2485
|
+
const secondary = windows?.secondary;
|
|
2486
|
+
const secondaryTarget = secondary ?? primary;
|
|
2487
|
+
ranked.push({
|
|
2488
|
+
selection,
|
|
2489
|
+
usage,
|
|
2490
|
+
usageChecked,
|
|
2491
|
+
blocked,
|
|
2492
|
+
blockedUntil,
|
|
2493
|
+
hasPriorityBoost: strategy.hasPriorityBoost?.(primary) ?? false,
|
|
2494
|
+
secondaryUsed: this.#normalizeUsageFraction(secondaryTarget),
|
|
2495
|
+
secondaryDrainRate: this.#computeWindowDrainRate(
|
|
2496
|
+
secondaryTarget,
|
|
2497
|
+
nowMs,
|
|
2498
|
+
strategy.windowDefaults.secondaryMs,
|
|
2499
|
+
),
|
|
2500
|
+
primaryUsed: this.#normalizeUsageFraction(primary),
|
|
2501
|
+
primaryDrainRate: this.#computeWindowDrainRate(primary, nowMs, strategy.windowDefaults.primaryMs),
|
|
2502
|
+
orderPos,
|
|
2503
|
+
});
|
|
2504
|
+
}
|
|
2505
|
+
ranked.sort((left, right) => {
|
|
2506
|
+
if (left.blocked !== right.blocked) return left.blocked ? 1 : -1;
|
|
2507
|
+
if (left.blocked && right.blocked) {
|
|
2508
|
+
const leftBlockedUntil = left.blockedUntil ?? Number.POSITIVE_INFINITY;
|
|
2509
|
+
const rightBlockedUntil = right.blockedUntil ?? Number.POSITIVE_INFINITY;
|
|
2510
|
+
if (leftBlockedUntil !== rightBlockedUntil) return leftBlockedUntil - rightBlockedUntil;
|
|
2511
|
+
return left.orderPos - right.orderPos;
|
|
2512
|
+
}
|
|
2513
|
+
if (requiresOpenAICodexProModel(args.provider, args.options?.modelId)) {
|
|
2514
|
+
const leftPlanPriority = getOpenAICodexPlanPriority(left.usage);
|
|
2515
|
+
const rightPlanPriority = getOpenAICodexPlanPriority(right.usage);
|
|
2516
|
+
if (leftPlanPriority !== rightPlanPriority) return leftPlanPriority - rightPlanPriority;
|
|
2517
|
+
}
|
|
2518
|
+
if (left.hasPriorityBoost !== right.hasPriorityBoost) return left.hasPriorityBoost ? -1 : 1;
|
|
2519
|
+
if (left.secondaryDrainRate !== right.secondaryDrainRate)
|
|
2520
|
+
return left.secondaryDrainRate - right.secondaryDrainRate;
|
|
2521
|
+
if (left.secondaryUsed !== right.secondaryUsed) return left.secondaryUsed - right.secondaryUsed;
|
|
2522
|
+
if (left.primaryDrainRate !== right.primaryDrainRate) return left.primaryDrainRate - right.primaryDrainRate;
|
|
2523
|
+
if (left.primaryUsed !== right.primaryUsed) return left.primaryUsed - right.primaryUsed;
|
|
2524
|
+
return left.orderPos - right.orderPos;
|
|
2525
|
+
});
|
|
2526
|
+
return ranked.map(candidate => ({
|
|
2527
|
+
selection: candidate.selection,
|
|
2528
|
+
usage: candidate.usage,
|
|
2529
|
+
usageChecked: candidate.usageChecked,
|
|
2530
|
+
}));
|
|
2531
|
+
}
|
|
2532
|
+
|
|
2533
|
+
/**
|
|
2534
|
+
* Resolves an OAuth credential, trying credentials in priority order.
|
|
2535
|
+
* Skips blocked credentials and checks usage limits for providers with usage data.
|
|
2536
|
+
* Falls back to earliest-unblocking credential if all are blocked.
|
|
2537
|
+
*
|
|
2538
|
+
* Returns both the API key bytes for outbound requests AND the refreshed
|
|
2539
|
+
* {@link OAuthCredential} so callers needing identity metadata (account id,
|
|
2540
|
+
* project id, etc.) do not have to dereference the snapshot themselves.
|
|
2541
|
+
*/
|
|
2542
|
+
async #resolveOAuthSelection(
|
|
2543
|
+
provider: string,
|
|
2544
|
+
sessionId?: string,
|
|
2545
|
+
options?: AuthApiKeyOptions,
|
|
2546
|
+
): Promise<OAuthResolutionResult | undefined> {
|
|
2547
|
+
const credentials = this.#getCredentialsForProvider(provider)
|
|
2548
|
+
.map((credential, index) => ({ credential, index }))
|
|
2549
|
+
.filter((entry): entry is { credential: OAuthCredential; index: number } => entry.credential.type === "oauth");
|
|
2550
|
+
|
|
2551
|
+
if (credentials.length === 0) return undefined;
|
|
2552
|
+
|
|
2553
|
+
const providerKey = this.#getProviderTypeKey(provider, "oauth");
|
|
2554
|
+
const order = this.#getCredentialOrder(providerKey, sessionId, credentials.length);
|
|
2555
|
+
const strategy = this.#rankingStrategyResolver?.(provider);
|
|
2556
|
+
const requiresProModel = requiresOpenAICodexProModel(provider, options?.modelId);
|
|
2557
|
+
const checkUsage = strategy !== undefined && (credentials.length > 1 || requiresProModel);
|
|
2558
|
+
const sessionCredential = this.#getSessionCredential(provider, sessionId);
|
|
2559
|
+
const sessionPreferredIndex = sessionCredential?.type === "oauth" ? sessionCredential.index : undefined;
|
|
2560
|
+
// Skip ranking only when the session already has a working preferred credential — re-ranking
|
|
2561
|
+
// mid-session causes account switches that cold-start the server-side prompt cache. New sessions
|
|
2562
|
+
// (no preference) and sessions whose preferred is blocked still rank, so we pick the account
|
|
2563
|
+
// with the most headroom proactively and fall back intelligently when rate-limited.
|
|
2564
|
+
const sessionPreferredIsAvailable =
|
|
2565
|
+
sessionPreferredIndex !== undefined && !this.#isCredentialBlocked(providerKey, sessionPreferredIndex);
|
|
2566
|
+
const shouldRank = checkUsage && (!sessionPreferredIsAvailable || requiresProModel);
|
|
2567
|
+
const candidates = shouldRank
|
|
2568
|
+
? await this.#rankOAuthSelections({ providerKey, provider, order, credentials, options, strategy: strategy! })
|
|
2569
|
+
: order
|
|
2570
|
+
.map(idx => credentials[idx])
|
|
2571
|
+
.filter((selection): selection is { credential: OAuthCredential; index: number } => Boolean(selection))
|
|
2572
|
+
.map(selection => ({ selection, usage: null, usageChecked: false }));
|
|
2573
|
+
|
|
2574
|
+
if (sessionPreferredIndex !== undefined && !requiresProModel) {
|
|
2575
|
+
const sessionPreferredCandidate = candidates.findIndex(
|
|
2576
|
+
candidate =>
|
|
2577
|
+
!this.#isCredentialBlocked(providerKey, candidate.selection.index) &&
|
|
2578
|
+
candidate.selection.index === sessionPreferredIndex,
|
|
2579
|
+
);
|
|
2580
|
+
if (sessionPreferredCandidate > 0) {
|
|
2581
|
+
const [preferred] = candidates.splice(sessionPreferredCandidate, 1);
|
|
2582
|
+
candidates.unshift(preferred);
|
|
2583
|
+
}
|
|
2584
|
+
}
|
|
2585
|
+
await Promise.all(
|
|
2586
|
+
candidates.map(async candidate => {
|
|
2587
|
+
if (Date.now() + OAUTH_REFRESH_SKEW_MS < candidate.selection.credential.expires) return;
|
|
2588
|
+
const latestCredential = this.#getCredentialsForProvider(provider)[candidate.selection.index];
|
|
2589
|
+
if (latestCredential?.type === "oauth" && Date.now() + OAUTH_REFRESH_SKEW_MS < latestCredential.expires) {
|
|
2590
|
+
candidate.selection.credential = latestCredential;
|
|
2591
|
+
return;
|
|
2592
|
+
}
|
|
2593
|
+
try {
|
|
2594
|
+
const credentialId = this.#getStoredCredentials(provider)[candidate.selection.index]?.id;
|
|
2595
|
+
const refreshedCredentials = await this.#refreshOAuthCredential(
|
|
2596
|
+
provider,
|
|
2597
|
+
candidate.selection.credential,
|
|
2598
|
+
credentialId,
|
|
2599
|
+
options?.signal,
|
|
2600
|
+
);
|
|
2601
|
+
const updated: OAuthCredential = {
|
|
2602
|
+
...candidate.selection.credential,
|
|
2603
|
+
...refreshedCredentials,
|
|
2604
|
+
type: "oauth",
|
|
2605
|
+
};
|
|
2606
|
+
candidate.selection.credential = updated;
|
|
2607
|
+
this.#replaceCredentialAt(provider, candidate.selection.index, updated);
|
|
2608
|
+
} catch {}
|
|
2609
|
+
}),
|
|
2610
|
+
);
|
|
2611
|
+
|
|
2612
|
+
// Skip the Pro-plan filter when no candidate is confirmed Pro, so users with only
|
|
2613
|
+
// non-Pro accounts can still attempt Spark requests (e.g. trial/grandfathered access).
|
|
2614
|
+
const enforceProRequirement =
|
|
2615
|
+
requiresProModel && candidates.some(candidate => hasOpenAICodexProPlan(candidate.usage));
|
|
2616
|
+
|
|
2617
|
+
const fallback = candidates[0];
|
|
2618
|
+
|
|
2619
|
+
for (const candidate of candidates) {
|
|
2620
|
+
const resolved = await this.#tryOAuthCredential(
|
|
2621
|
+
provider,
|
|
2622
|
+
candidate.selection,
|
|
2623
|
+
providerKey,
|
|
2624
|
+
sessionId,
|
|
2625
|
+
options,
|
|
2626
|
+
{
|
|
2627
|
+
checkUsage,
|
|
2628
|
+
allowBlocked: false,
|
|
2629
|
+
prefetchedUsage: candidate.usage,
|
|
2630
|
+
usagePrechecked: candidate.usageChecked,
|
|
2631
|
+
enforceProRequirement,
|
|
2632
|
+
},
|
|
2633
|
+
);
|
|
2634
|
+
if (resolved) return resolved;
|
|
2635
|
+
}
|
|
2636
|
+
|
|
2637
|
+
if (fallback && this.#isCredentialBlocked(providerKey, fallback.selection.index)) {
|
|
2638
|
+
return this.#tryOAuthCredential(provider, fallback.selection, providerKey, sessionId, options, {
|
|
2639
|
+
checkUsage,
|
|
2640
|
+
allowBlocked: true,
|
|
2641
|
+
prefetchedUsage: fallback.usage,
|
|
2642
|
+
usagePrechecked: fallback.usageChecked,
|
|
2643
|
+
enforceProRequirement,
|
|
2644
|
+
});
|
|
2645
|
+
}
|
|
2646
|
+
|
|
2647
|
+
return undefined;
|
|
2648
|
+
}
|
|
2649
|
+
|
|
2650
|
+
async #refreshOAuthCredential(
|
|
2651
|
+
provider: Provider,
|
|
2652
|
+
credential: OAuthCredential,
|
|
2653
|
+
credentialId: number | undefined,
|
|
2654
|
+
signal?: AbortSignal,
|
|
2655
|
+
): Promise<OAuthCredentials> {
|
|
2656
|
+
if (credentialId !== undefined) {
|
|
2657
|
+
const existing = this.#oauthCredentialRefreshInFlight.get(credentialId);
|
|
2658
|
+
if (existing) return raceCredentialRefreshWithSignal(existing, signal);
|
|
2659
|
+
}
|
|
2660
|
+
if (Date.now() + OAUTH_REFRESH_SKEW_MS < credential.expires) return credential;
|
|
2661
|
+
if (credentialId === undefined) {
|
|
2662
|
+
return this.#refreshOAuthCredentialUnshared(provider, credential, undefined, signal);
|
|
2663
|
+
}
|
|
2664
|
+
const promise = this.#refreshOAuthCredentialUnshared(provider, credential, credentialId).finally(() => {
|
|
2665
|
+
this.#oauthCredentialRefreshInFlight.delete(credentialId);
|
|
2666
|
+
});
|
|
2667
|
+
this.#oauthCredentialRefreshInFlight.set(credentialId, promise);
|
|
2668
|
+
return raceCredentialRefreshWithSignal(promise, signal);
|
|
2669
|
+
}
|
|
2670
|
+
|
|
2671
|
+
async #refreshOAuthCredentialUnshared(
|
|
2672
|
+
provider: Provider,
|
|
2673
|
+
credential: OAuthCredential,
|
|
2674
|
+
credentialId: number | undefined,
|
|
2675
|
+
signal?: AbortSignal,
|
|
2676
|
+
): Promise<OAuthCredentials> {
|
|
2677
|
+
let refreshPromise: Promise<OAuthCredentials>;
|
|
2678
|
+
// Caller override > store-level hook > local per-provider refresh.
|
|
2679
|
+
// `RemoteAuthCredentialStore` exposes the hook so a broker-backed gateway
|
|
2680
|
+
// routes refresh through the broker without explicit wiring.
|
|
2681
|
+
const storeRefresh = this.#store.refreshOAuthCredential?.bind(this.#store);
|
|
2682
|
+
const overrideRefresh = this.#refreshOAuthCredentialOverride ?? storeRefresh;
|
|
2683
|
+
if (overrideRefresh && credentialId !== undefined) {
|
|
2684
|
+
refreshPromise = overrideRefresh(provider, credentialId, credential, signal);
|
|
2685
|
+
} else {
|
|
2686
|
+
const customProvider = getOAuthProvider(provider);
|
|
2687
|
+
if (customProvider) {
|
|
2688
|
+
if (!customProvider.refreshToken) {
|
|
2689
|
+
throw new Error(`OAuth provider "${provider}" does not support token refresh`);
|
|
2690
|
+
}
|
|
2691
|
+
refreshPromise = customProvider.refreshToken(credential);
|
|
2692
|
+
} else {
|
|
2693
|
+
refreshPromise = refreshOAuthToken(provider as OAuthProvider, credential);
|
|
2694
|
+
}
|
|
2695
|
+
}
|
|
2696
|
+
// Bound the refresh so a slow/hanging token endpoint cannot stall credential selection.
|
|
2697
|
+
// Caller-driven abort jumps the gun on the timeout — the agent's ESC must
|
|
2698
|
+
// take priority over the floor timeout.
|
|
2699
|
+
let timeout: NodeJS.Timeout | undefined;
|
|
2700
|
+
let onAbort: (() => void) | undefined;
|
|
2701
|
+
const cancellation = Promise.withResolvers<never>();
|
|
2702
|
+
timeout = setTimeout(
|
|
2703
|
+
() => cancellation.reject(new Error(`OAuth token refresh timed out for provider: ${provider}`)),
|
|
2704
|
+
DEFAULT_OAUTH_REFRESH_TIMEOUT_MS,
|
|
2705
|
+
);
|
|
2706
|
+
if (signal) {
|
|
2707
|
+
if (signal.aborted) {
|
|
2708
|
+
cancellation.reject(new Error("OAuth token refresh aborted by caller"));
|
|
2709
|
+
} else {
|
|
2710
|
+
onAbort = () => cancellation.reject(new Error("OAuth token refresh aborted by caller"));
|
|
2711
|
+
signal.addEventListener("abort", onAbort, { once: true });
|
|
2712
|
+
}
|
|
2713
|
+
}
|
|
2714
|
+
try {
|
|
2715
|
+
return await Promise.race([refreshPromise, cancellation.promise]);
|
|
2716
|
+
} finally {
|
|
2717
|
+
if (timeout) clearTimeout(timeout);
|
|
2718
|
+
if (signal && onAbort) signal.removeEventListener("abort", onAbort);
|
|
2719
|
+
}
|
|
2720
|
+
}
|
|
2721
|
+
|
|
2722
|
+
async #prepareOAuthCredentialForRequest(
|
|
2723
|
+
provider: string,
|
|
2724
|
+
selection: { credential: OAuthCredential; index: number },
|
|
2725
|
+
options: AuthApiKeyOptions | undefined,
|
|
2726
|
+
): Promise<boolean> {
|
|
2727
|
+
const prepare = this.#store.prepareForRequest?.bind(this.#store);
|
|
2728
|
+
if (!prepare) return true;
|
|
2729
|
+
const stored = this.#getStoredCredentials(provider);
|
|
2730
|
+
const selected = stored[selection.index];
|
|
2731
|
+
if (!selected || selected.credential.type !== "oauth") return false;
|
|
2732
|
+
|
|
2733
|
+
const prepared = await prepare(selected.id, { signal: options?.signal });
|
|
2734
|
+
if (!prepared) return true;
|
|
2735
|
+
const latestRows = this.#store.listAuthCredentials(provider);
|
|
2736
|
+
this.#setStoredCredentials(
|
|
2737
|
+
provider,
|
|
2738
|
+
latestRows.map(row => ({ id: row.id, credential: row.credential })),
|
|
2739
|
+
);
|
|
2740
|
+
const latestIndex = latestRows.findIndex(row => row.id === selected.id);
|
|
2741
|
+
if (latestIndex === -1) return false;
|
|
2742
|
+
const latest = latestRows[latestIndex];
|
|
2743
|
+
if (!latest || latest.credential.type !== "oauth") return false;
|
|
2744
|
+
selection.index = latestIndex;
|
|
2745
|
+
selection.credential = latest.credential;
|
|
2746
|
+
return true;
|
|
2747
|
+
}
|
|
2748
|
+
|
|
2749
|
+
/** Attempts to use a single OAuth credential, checking usage and refreshing token. */
|
|
2750
|
+
async #tryOAuthCredential(
|
|
2751
|
+
provider: Provider,
|
|
2752
|
+
selection: { credential: OAuthCredential; index: number },
|
|
2753
|
+
providerKey: string,
|
|
2754
|
+
sessionId: string | undefined,
|
|
2755
|
+
options: AuthApiKeyOptions | undefined,
|
|
2756
|
+
usageOptions: {
|
|
2757
|
+
checkUsage: boolean;
|
|
2758
|
+
allowBlocked: boolean;
|
|
2759
|
+
prefetchedUsage?: UsageReport | null;
|
|
2760
|
+
usagePrechecked?: boolean;
|
|
2761
|
+
enforceProRequirement?: boolean;
|
|
2762
|
+
},
|
|
2763
|
+
): Promise<OAuthResolutionResult | undefined> {
|
|
2764
|
+
const {
|
|
2765
|
+
checkUsage,
|
|
2766
|
+
allowBlocked,
|
|
2767
|
+
prefetchedUsage = null,
|
|
2768
|
+
usagePrechecked = false,
|
|
2769
|
+
enforceProRequirement,
|
|
2770
|
+
} = usageOptions;
|
|
2771
|
+
if (!allowBlocked && this.#isCredentialBlocked(providerKey, selection.index)) {
|
|
2772
|
+
return undefined;
|
|
2773
|
+
}
|
|
2774
|
+
|
|
2775
|
+
if (!(await this.#prepareOAuthCredentialForRequest(provider, selection, options))) {
|
|
2776
|
+
return undefined;
|
|
2777
|
+
}
|
|
2778
|
+
|
|
2779
|
+
const requiresProModel = requiresOpenAICodexProModel(provider, options?.modelId);
|
|
2780
|
+
const applyProFilter = enforceProRequirement ?? requiresProModel;
|
|
2781
|
+
let usage: UsageReport | null = null;
|
|
2782
|
+
let usageChecked = false;
|
|
2783
|
+
|
|
2784
|
+
if ((checkUsage && !allowBlocked) || requiresProModel) {
|
|
2785
|
+
if (usagePrechecked) {
|
|
2786
|
+
usage = prefetchedUsage;
|
|
2787
|
+
usageChecked = true;
|
|
2788
|
+
} else {
|
|
2789
|
+
usage = await this.#getUsageReport(provider, selection.credential, {
|
|
2790
|
+
...options,
|
|
2791
|
+
timeoutMs: this.#usageRequestTimeoutMs,
|
|
2792
|
+
});
|
|
2793
|
+
usageChecked = true;
|
|
2794
|
+
}
|
|
2795
|
+
if (applyProFilter && !hasOpenAICodexProPlan(usage)) {
|
|
2796
|
+
return undefined;
|
|
2797
|
+
}
|
|
2798
|
+
if (checkUsage && !allowBlocked && usage && this.#isUsageLimitReached(usage)) {
|
|
2799
|
+
const resetAtMs = this.#getUsageResetAtMs(usage, Date.now());
|
|
2800
|
+
this.#markCredentialBlocked(
|
|
2801
|
+
providerKey,
|
|
2802
|
+
selection.index,
|
|
2803
|
+
resetAtMs ?? Date.now() + AuthStorage.#defaultBackoffMs,
|
|
2804
|
+
);
|
|
2805
|
+
return undefined;
|
|
2806
|
+
}
|
|
2807
|
+
}
|
|
2808
|
+
|
|
2809
|
+
try {
|
|
2810
|
+
let result: { newCredentials: OAuthCredentials; apiKey: string } | null;
|
|
2811
|
+
const customProvider = getOAuthProvider(provider);
|
|
2812
|
+
if (customProvider) {
|
|
2813
|
+
const refreshedCredentials = await this.#refreshOAuthCredential(
|
|
2814
|
+
provider,
|
|
2815
|
+
selection.credential,
|
|
2816
|
+
this.#getStoredCredentials(provider)[selection.index]?.id,
|
|
2817
|
+
options?.signal,
|
|
2818
|
+
);
|
|
2819
|
+
const apiKey = customProvider.getApiKey
|
|
2820
|
+
? customProvider.getApiKey(refreshedCredentials)
|
|
2821
|
+
: refreshedCredentials.access;
|
|
2822
|
+
result = { newCredentials: refreshedCredentials, apiKey };
|
|
2823
|
+
} else {
|
|
2824
|
+
// Refresh first through the broker-aware single-flighted machinery
|
|
2825
|
+
// so transient failures surface as network errors (5-min temp block)
|
|
2826
|
+
// instead of `getOAuthApiKey`'s "expired" precondition error, which
|
|
2827
|
+
// the definitive-failure regex below would otherwise classify as
|
|
2828
|
+
// auth failure and soft-disable a still-valid credential.
|
|
2829
|
+
const refreshedCredentials = await this.#refreshOAuthCredential(
|
|
2830
|
+
provider,
|
|
2831
|
+
selection.credential,
|
|
2832
|
+
this.#getStoredCredentials(provider)[selection.index]?.id,
|
|
2833
|
+
options?.signal,
|
|
2834
|
+
);
|
|
2835
|
+
const oauthCreds: Record<string, OAuthCredentials> = {
|
|
2836
|
+
[provider]: refreshedCredentials,
|
|
2837
|
+
};
|
|
2838
|
+
result = await getOAuthApiKey(provider as OAuthProvider, oauthCreds);
|
|
2839
|
+
}
|
|
2840
|
+
if (!result) return undefined;
|
|
2841
|
+
const updated: OAuthCredential = {
|
|
2842
|
+
type: "oauth",
|
|
2843
|
+
access: result.newCredentials.access,
|
|
2844
|
+
refresh: result.newCredentials.refresh,
|
|
2845
|
+
expires: result.newCredentials.expires,
|
|
2846
|
+
accountId: result.newCredentials.accountId ?? selection.credential.accountId,
|
|
2847
|
+
email: result.newCredentials.email ?? selection.credential.email,
|
|
2848
|
+
projectId: result.newCredentials.projectId ?? selection.credential.projectId,
|
|
2849
|
+
enterpriseUrl: result.newCredentials.enterpriseUrl ?? selection.credential.enterpriseUrl,
|
|
2850
|
+
};
|
|
2851
|
+
this.#replaceCredentialAt(provider, selection.index, updated);
|
|
2852
|
+
if ((checkUsage && !allowBlocked) || requiresProModel) {
|
|
2853
|
+
const sameAccount = selection.credential.accountId === updated.accountId;
|
|
2854
|
+
if (!usageChecked || !sameAccount) {
|
|
2855
|
+
usage = await this.#getUsageReport(provider, updated, {
|
|
2856
|
+
...options,
|
|
2857
|
+
timeoutMs: this.#usageRequestTimeoutMs,
|
|
2858
|
+
});
|
|
2859
|
+
usageChecked = true;
|
|
2860
|
+
}
|
|
2861
|
+
if (applyProFilter && !hasOpenAICodexProPlan(usage)) {
|
|
2862
|
+
return undefined;
|
|
2863
|
+
}
|
|
2864
|
+
if (checkUsage && !allowBlocked && usage && this.#isUsageLimitReached(usage)) {
|
|
2865
|
+
const resetAtMs = this.#getUsageResetAtMs(usage, Date.now());
|
|
2866
|
+
this.#markCredentialBlocked(
|
|
2867
|
+
providerKey,
|
|
2868
|
+
selection.index,
|
|
2869
|
+
resetAtMs ?? Date.now() + AuthStorage.#defaultBackoffMs,
|
|
2870
|
+
);
|
|
2871
|
+
return undefined;
|
|
2872
|
+
}
|
|
2873
|
+
}
|
|
2874
|
+
this.#recordSessionCredential(provider, sessionId, "oauth", selection.index);
|
|
2875
|
+
return { apiKey: result.apiKey, credential: updated };
|
|
2876
|
+
} catch (error) {
|
|
2877
|
+
const errorMsg = String(error);
|
|
2878
|
+
// Only remove credentials for definitive auth failures
|
|
2879
|
+
// Keep credentials for transient errors (network, 5xx) and block temporarily
|
|
2880
|
+
const isDefinitiveFailure =
|
|
2881
|
+
/invalid_grant|invalid_token|revoked|unauthorized|expired.*refresh|refresh.*expired/i.test(errorMsg) ||
|
|
2882
|
+
(/\b(401|403)\b/.test(errorMsg) && !/timeout|network|fetch failed|ECONNREFUSED/i.test(errorMsg));
|
|
2883
|
+
|
|
2884
|
+
logger.warn("OAuth token refresh failed", {
|
|
2885
|
+
provider,
|
|
2886
|
+
index: selection.index,
|
|
2887
|
+
error: errorMsg,
|
|
2888
|
+
isDefinitiveFailure,
|
|
2889
|
+
});
|
|
2890
|
+
|
|
2891
|
+
if (isDefinitiveFailure) {
|
|
2892
|
+
// The credential at this index may have been rotated by another process between
|
|
2893
|
+
// our in-memory snapshot and the refresh attempt: Anthropic rotates refresh
|
|
2894
|
+
// tokens on every use, so the peer's success leaves our stored token invalid.
|
|
2895
|
+
// Re-read the row from disk before marking it disabled — if the persisted
|
|
2896
|
+
// refresh token has changed, the peer rotation succeeded and we should pick
|
|
2897
|
+
// up the new credential instead of soft-deleting the row that the peer just
|
|
2898
|
+
// updated.
|
|
2899
|
+
const credentialId = this.#getStoredCredentials(provider)[selection.index]?.id;
|
|
2900
|
+
if (credentialId !== undefined) {
|
|
2901
|
+
const latestRow = this.#store.listAuthCredentials(provider).find(row => row.id === credentialId);
|
|
2902
|
+
const latestCredential = latestRow?.credential;
|
|
2903
|
+
if (latestCredential?.type === "oauth" && latestCredential.refresh !== selection.credential.refresh) {
|
|
2904
|
+
logger.debug("OAuth refresh race detected; another process rotated token first", {
|
|
2905
|
+
provider,
|
|
2906
|
+
index: selection.index,
|
|
2907
|
+
credentialId,
|
|
2908
|
+
});
|
|
2909
|
+
await this.reload();
|
|
2910
|
+
return this.#resolveOAuthSelection(provider, sessionId, options);
|
|
2911
|
+
}
|
|
2912
|
+
}
|
|
2913
|
+
// Permanently disable invalid credentials with an explicit cause for inspection/debugging.
|
|
2914
|
+
// Use a CAS-style disable conditioned on the row still containing the stale credential
|
|
2915
|
+
// we tried to refresh, so a peer rotation that lands between the pre-check above and
|
|
2916
|
+
// this disable doesn't soft-delete the freshly-rotated row.
|
|
2917
|
+
const disabled = this.#tryDisableCredentialAtIfMatches(
|
|
2918
|
+
provider,
|
|
2919
|
+
selection.index,
|
|
2920
|
+
selection.credential,
|
|
2921
|
+
`oauth refresh failed: ${errorMsg}`,
|
|
2922
|
+
);
|
|
2923
|
+
if (!disabled) {
|
|
2924
|
+
logger.debug("OAuth refresh disable lost CAS; reloading after peer rotation", {
|
|
2925
|
+
provider,
|
|
2926
|
+
index: selection.index,
|
|
2927
|
+
});
|
|
2928
|
+
await this.reload();
|
|
2929
|
+
return this.#resolveOAuthSelection(provider, sessionId, options);
|
|
2930
|
+
}
|
|
2931
|
+
if (this.#getCredentialsForProvider(provider).some(credential => credential.type === "oauth")) {
|
|
2932
|
+
return this.#resolveOAuthSelection(provider, sessionId, options);
|
|
2933
|
+
}
|
|
2934
|
+
} else {
|
|
2935
|
+
// Block temporarily for transient failures (5 minutes)
|
|
2936
|
+
this.#markCredentialBlocked(providerKey, selection.index, Date.now() + 5 * 60 * 1000);
|
|
2937
|
+
}
|
|
2938
|
+
}
|
|
2939
|
+
|
|
2940
|
+
return undefined;
|
|
2941
|
+
}
|
|
2942
|
+
|
|
2943
|
+
/**
|
|
2944
|
+
* Peek at API key for a provider without refreshing OAuth tokens.
|
|
2945
|
+
* Used for model discovery where we only need to know if credentials exist
|
|
2946
|
+
* and get a best-effort token. For GitHub Copilot we preserve enterprise
|
|
2947
|
+
* routing metadata so discovery can hit the correct host.
|
|
2948
|
+
*/
|
|
2949
|
+
async peekApiKey(provider: string): Promise<string | undefined> {
|
|
2950
|
+
const runtimeKey = this.#runtimeOverrides.get(provider);
|
|
2951
|
+
if (runtimeKey) {
|
|
2952
|
+
return runtimeKey;
|
|
2953
|
+
}
|
|
2954
|
+
|
|
2955
|
+
const configKey = this.#configOverrides.get(provider);
|
|
2956
|
+
if (configKey) {
|
|
2957
|
+
return configKey;
|
|
2958
|
+
}
|
|
2959
|
+
|
|
2960
|
+
const apiKeySelection = this.#selectCredentialByType(provider, "api_key");
|
|
2961
|
+
if (apiKeySelection) {
|
|
2962
|
+
return this.#configValueResolver(apiKeySelection.credential.key);
|
|
2963
|
+
}
|
|
2964
|
+
|
|
2965
|
+
// Return current OAuth access token only if it is not already expired.
|
|
2966
|
+
const oauthSelection = this.#selectCredentialByType(provider, "oauth");
|
|
2967
|
+
if (oauthSelection) {
|
|
2968
|
+
const expiresAt = oauthSelection.credential.expires;
|
|
2969
|
+
if (Number.isFinite(expiresAt) && expiresAt > Date.now()) {
|
|
2970
|
+
if (provider === "github-copilot") {
|
|
2971
|
+
return JSON.stringify({
|
|
2972
|
+
token: oauthSelection.credential.access,
|
|
2973
|
+
enterpriseUrl: oauthSelection.credential.enterpriseUrl,
|
|
2974
|
+
});
|
|
2975
|
+
}
|
|
2976
|
+
return oauthSelection.credential.access;
|
|
2977
|
+
}
|
|
2978
|
+
}
|
|
2979
|
+
|
|
2980
|
+
const envKey = getEnvApiKey(provider);
|
|
2981
|
+
if (envKey) return envKey;
|
|
2982
|
+
|
|
2983
|
+
return this.#fallbackResolver?.(provider) ?? undefined;
|
|
2984
|
+
}
|
|
2985
|
+
|
|
2986
|
+
/**
|
|
2987
|
+
* Get API key for a provider.
|
|
2988
|
+
* Priority:
|
|
2989
|
+
* 1. Runtime override (CLI --api-key)
|
|
2990
|
+
* 2. Config override (models.yml `providers.<name>.apiKey`)
|
|
2991
|
+
* 3. API key from storage
|
|
2992
|
+
* 4. OAuth token from storage (auto-refreshed)
|
|
2993
|
+
* 5. Environment variable
|
|
2994
|
+
* 6. Fallback resolver (models.yml custom providers, last-resort)
|
|
2995
|
+
*/
|
|
2996
|
+
async getApiKey(provider: string, sessionId?: string, options?: AuthApiKeyOptions): Promise<string | undefined> {
|
|
2997
|
+
// Runtime override takes highest priority
|
|
2998
|
+
const runtimeKey = this.#runtimeOverrides.get(provider);
|
|
2999
|
+
if (runtimeKey) {
|
|
3000
|
+
return runtimeKey;
|
|
3001
|
+
}
|
|
3002
|
+
|
|
3003
|
+
// Config override: explicit apiKey pinned in models.yml beats the broker's
|
|
3004
|
+
// OAuth credentials. The user redirected a provider at a custom baseUrl
|
|
3005
|
+
// (e.g. an auth-gateway) and supplied the bearer for that endpoint —
|
|
3006
|
+
// honor it instead of forwarding an upstream OAuth token that the proxy
|
|
3007
|
+
// won't accept.
|
|
3008
|
+
const configKey = this.#configOverrides.get(provider);
|
|
3009
|
+
if (configKey) {
|
|
3010
|
+
return configKey;
|
|
3011
|
+
}
|
|
3012
|
+
|
|
3013
|
+
const apiKeySelection = this.#selectCredentialByType(provider, "api_key", sessionId);
|
|
3014
|
+
if (apiKeySelection) {
|
|
3015
|
+
this.#recordSessionCredential(provider, sessionId, "api_key", apiKeySelection.index);
|
|
3016
|
+
return this.#configValueResolver(apiKeySelection.credential.key);
|
|
3017
|
+
}
|
|
3018
|
+
|
|
3019
|
+
const oauthResolved = await this.#resolveOAuthSelection(provider, sessionId, options);
|
|
3020
|
+
if (oauthResolved) {
|
|
3021
|
+
return oauthResolved.apiKey;
|
|
3022
|
+
}
|
|
3023
|
+
|
|
3024
|
+
// Fall back to environment variable or custom resolver. If we reach here after
|
|
3025
|
+
// an OAuth miss, the session sticky (if any) is stale — the request will
|
|
3026
|
+
// authenticate via env/fallback, not OAuth, so clear the sticky now so that
|
|
3027
|
+
// getOAuthAccountId() correctly suppresses account_uuid for this session.
|
|
3028
|
+
if (sessionId) this.#sessionLastCredential.get(provider)?.delete(sessionId);
|
|
3029
|
+
const envKey = getEnvApiKey(provider);
|
|
3030
|
+
if (envKey) return envKey;
|
|
3031
|
+
|
|
3032
|
+
// Fall back to custom resolver (e.g., models.json custom providers)
|
|
3033
|
+
return this.#fallbackResolver?.(provider) ?? undefined;
|
|
3034
|
+
}
|
|
3035
|
+
|
|
3036
|
+
/**
|
|
3037
|
+
* Resolve the OAuth credential for `provider`, refreshing through the same
|
|
3038
|
+
* pipeline as {@link AuthStorage.getApiKey} but returning the refreshed
|
|
3039
|
+
* {@link OAuthAccess} (raw access token + identity metadata) instead of
|
|
3040
|
+
* the API-key bytes.
|
|
3041
|
+
*
|
|
3042
|
+
* Use this when the caller needs to inject identity headers alongside the
|
|
3043
|
+
* bearer (OpenAI code backend `chatgpt-account-id`, Google `project`, GitHub
|
|
3044
|
+
* `enterpriseUrl`). For pure "give me the bytes for `Authorization`"
|
|
3045
|
+
* scenarios, prefer {@link AuthStorage.getApiKey}.
|
|
3046
|
+
*
|
|
3047
|
+
* Returns `undefined` when no OAuth credential is available, the
|
|
3048
|
+
* credential fails to refresh, or runtime/config overrides have replaced
|
|
3049
|
+
* OAuth with an explicit API key.
|
|
3050
|
+
*/
|
|
3051
|
+
async getOAuthAccess(
|
|
3052
|
+
provider: string,
|
|
3053
|
+
sessionId?: string,
|
|
3054
|
+
options?: AuthApiKeyOptions,
|
|
3055
|
+
): Promise<OAuthAccess | undefined> {
|
|
3056
|
+
// Runtime / config overrides intentionally short-circuit OAuth: when the
|
|
3057
|
+
// user has pinned an API key, they expect the OAuth identity to be
|
|
3058
|
+
// suppressed (same contract as `getOAuthAccountId`).
|
|
3059
|
+
if (this.#runtimeOverrides.has(provider) || this.#configOverrides.has(provider)) {
|
|
3060
|
+
return undefined;
|
|
3061
|
+
}
|
|
3062
|
+
const resolved = await this.#resolveOAuthSelection(provider, sessionId, options);
|
|
3063
|
+
if (!resolved) return undefined;
|
|
3064
|
+
const { credential } = resolved;
|
|
3065
|
+
return {
|
|
3066
|
+
accessToken: credential.access,
|
|
3067
|
+
accountId: credential.accountId,
|
|
3068
|
+
email: credential.email,
|
|
3069
|
+
projectId: credential.projectId,
|
|
3070
|
+
enterpriseUrl: credential.enterpriseUrl,
|
|
3071
|
+
};
|
|
3072
|
+
}
|
|
3073
|
+
|
|
3074
|
+
#extractStructuredApiKeyToken(apiKey: string): string | undefined {
|
|
3075
|
+
if (!apiKey.startsWith("{")) return undefined;
|
|
3076
|
+
try {
|
|
3077
|
+
const parsed = JSON.parse(apiKey) as { token?: unknown };
|
|
3078
|
+
return typeof parsed.token === "string" ? parsed.token : undefined;
|
|
3079
|
+
} catch {
|
|
3080
|
+
return undefined;
|
|
3081
|
+
}
|
|
3082
|
+
}
|
|
3083
|
+
|
|
3084
|
+
async #credentialMatchesApiKey(credential: AuthCredential, apiKey: string): Promise<boolean> {
|
|
3085
|
+
if (credential.type === "api_key") {
|
|
3086
|
+
return (await this.#configValueResolver(credential.key)) === apiKey;
|
|
3087
|
+
}
|
|
3088
|
+
if (credential.access === apiKey) return true;
|
|
3089
|
+
return this.#extractStructuredApiKeyToken(apiKey) === credential.access;
|
|
3090
|
+
}
|
|
3091
|
+
|
|
3092
|
+
async invalidateCredentialMatching(
|
|
3093
|
+
provider: string,
|
|
3094
|
+
apiKey: string,
|
|
3095
|
+
options?: InvalidateCredentialMatchingOptions,
|
|
3096
|
+
): Promise<boolean>;
|
|
3097
|
+
async invalidateCredentialMatching(provider: string, apiKey: string, signal?: AbortSignal): Promise<boolean>;
|
|
3098
|
+
async invalidateCredentialMatching(
|
|
3099
|
+
provider: string,
|
|
3100
|
+
apiKey: string,
|
|
3101
|
+
optionsOrSignal?: InvalidateCredentialMatchingOptions | AbortSignal,
|
|
3102
|
+
): Promise<boolean> {
|
|
3103
|
+
const signal = isAbortSignalOption(optionsOrSignal) ? optionsOrSignal : optionsOrSignal?.signal;
|
|
3104
|
+
const sessionId = isAbortSignalOption(optionsOrSignal) ? undefined : optionsOrSignal?.sessionId;
|
|
3105
|
+
const stored = this.#getStoredCredentials(provider);
|
|
3106
|
+
let matched: { id: number; type: AuthCredential["type"]; index: number } | undefined;
|
|
3107
|
+
for (let index = 0; index < stored.length; index++) {
|
|
3108
|
+
const entry = stored[index];
|
|
3109
|
+
if (entry && (await this.#credentialMatchesApiKey(entry.credential, apiKey))) {
|
|
3110
|
+
matched = { id: entry.id, type: entry.credential.type, index };
|
|
3111
|
+
break;
|
|
3112
|
+
}
|
|
3113
|
+
}
|
|
3114
|
+
|
|
3115
|
+
if (!matched) {
|
|
3116
|
+
await this.reload();
|
|
3117
|
+
return false;
|
|
3118
|
+
}
|
|
3119
|
+
|
|
3120
|
+
this.#clearSessionCredential(provider, sessionId);
|
|
3121
|
+
this.#markCredentialBlocked(
|
|
3122
|
+
this.#getProviderTypeKey(provider, matched.type),
|
|
3123
|
+
matched.index,
|
|
3124
|
+
Date.now() + AuthStorage.#defaultBackoffMs,
|
|
3125
|
+
);
|
|
3126
|
+
|
|
3127
|
+
const markSuspect = this.#store.markCredentialSuspect?.bind(this.#store);
|
|
3128
|
+
if (markSuspect) {
|
|
3129
|
+
await markSuspect(matched.id, { signal });
|
|
3130
|
+
} else {
|
|
3131
|
+
await this.reload();
|
|
3132
|
+
}
|
|
3133
|
+
|
|
3134
|
+
const latestRows = this.#store.listAuthCredentials(provider);
|
|
3135
|
+
this.#setStoredCredentials(
|
|
3136
|
+
provider,
|
|
3137
|
+
latestRows.map(row => ({ id: row.id, credential: row.credential })),
|
|
3138
|
+
);
|
|
3139
|
+
return true;
|
|
3140
|
+
}
|
|
3141
|
+
|
|
3142
|
+
// ─── Auth Broker integration ────────────────────────────────────────────
|
|
3143
|
+
|
|
3144
|
+
/**
|
|
3145
|
+
* Build a redacted snapshot of all loaded credentials for the auth-broker
|
|
3146
|
+
* wire. OAuth refresh tokens are replaced with {@link REMOTE_REFRESH_SENTINEL}
|
|
3147
|
+
* so clients never see the actual refresh token.
|
|
3148
|
+
*
|
|
3149
|
+
* Callers must {@link AuthStorage.reload} first when serving a stale snapshot
|
|
3150
|
+
* (the broker server's HTTP handler does this).
|
|
3151
|
+
*/
|
|
3152
|
+
exportSnapshot(): AuthCredentialSnapshot {
|
|
3153
|
+
const entries: AuthCredentialSnapshotEntry[] = [];
|
|
3154
|
+
for (const [provider, stored] of this.#data) {
|
|
3155
|
+
for (const entry of stored) {
|
|
3156
|
+
const credential = entry.credential;
|
|
3157
|
+
const redacted: SnapshotCredential =
|
|
3158
|
+
credential.type === "api_key" ? credential : { ...credential, refresh: REMOTE_REFRESH_SENTINEL };
|
|
3159
|
+
entries.push({
|
|
3160
|
+
id: entry.id,
|
|
3161
|
+
provider,
|
|
3162
|
+
credential: redacted,
|
|
3163
|
+
identityKey: resolveCredentialIdentityKey(provider, credential),
|
|
3164
|
+
});
|
|
3165
|
+
}
|
|
3166
|
+
}
|
|
3167
|
+
return { generation: this.#generation, generatedAt: Date.now(), credentials: entries };
|
|
3168
|
+
}
|
|
3169
|
+
|
|
3170
|
+
/**
|
|
3171
|
+
* Refresh the OAuth credential with the given id through a per-credential
|
|
3172
|
+
* single-flight. Concurrent callers for the same row await the same upstream
|
|
3173
|
+
* refresh attempt, which is required for providers that rotate refresh tokens
|
|
3174
|
+
* on every successful refresh.
|
|
3175
|
+
*/
|
|
3176
|
+
async refreshCredentialById(id: number, signal?: AbortSignal): Promise<AuthCredentialSnapshotEntry> {
|
|
3177
|
+
const existing = this.#oauthRefreshInFlight.get(id);
|
|
3178
|
+
if (existing) return raceCredentialRefreshWithSignal(existing, signal);
|
|
3179
|
+
|
|
3180
|
+
const promise = (async () => {
|
|
3181
|
+
this.#bumpGeneration("credential-refresh-start");
|
|
3182
|
+
try {
|
|
3183
|
+
return await this.#forceRefreshCredentialByIdUnshared(id, signal);
|
|
3184
|
+
} catch (error) {
|
|
3185
|
+
this.#bumpGeneration("credential-refresh-failure");
|
|
3186
|
+
throw error;
|
|
3187
|
+
} finally {
|
|
3188
|
+
this.#oauthRefreshInFlight.delete(id);
|
|
3189
|
+
}
|
|
3190
|
+
})();
|
|
3191
|
+
this.#oauthRefreshInFlight.set(id, promise);
|
|
3192
|
+
return raceCredentialRefreshWithSignal(promise, signal);
|
|
3193
|
+
}
|
|
3194
|
+
|
|
3195
|
+
/**
|
|
3196
|
+
* Force-refresh the OAuth credential with the given id, bypassing the
|
|
3197
|
+
* not-yet-expired guard. Used by the auth-broker server to honour
|
|
3198
|
+
* `POST /v1/credential/:id/refresh`.
|
|
3199
|
+
*
|
|
3200
|
+
* Returns the redacted snapshot entry for the refreshed row.
|
|
3201
|
+
* Throws when no OAuth credential with that id is loaded.
|
|
3202
|
+
*/
|
|
3203
|
+
async forceRefreshCredentialById(id: number, signal?: AbortSignal): Promise<AuthCredentialSnapshotEntry> {
|
|
3204
|
+
return this.refreshCredentialById(id, signal);
|
|
3205
|
+
}
|
|
3206
|
+
|
|
3207
|
+
async #forceRefreshCredentialByIdUnshared(id: number, signal?: AbortSignal): Promise<AuthCredentialSnapshotEntry> {
|
|
3208
|
+
for (const [provider, entries] of this.#data) {
|
|
3209
|
+
const index = entries.findIndex(entry => entry.id === id);
|
|
3210
|
+
if (index === -1) continue;
|
|
3211
|
+
const target = entries[index];
|
|
3212
|
+
if (target.credential.type !== "oauth") {
|
|
3213
|
+
throw new Error(`Credential ${id} is not OAuth (provider=${provider}, type=${target.credential.type})`);
|
|
3214
|
+
}
|
|
3215
|
+
// Pass a clone with expires=0 so the cached not-yet-expired short-circuit
|
|
3216
|
+
// in #refreshOAuthCredential doesn't suppress the requested refresh.
|
|
3217
|
+
const stale: OAuthCredential = { ...target.credential, expires: 0 };
|
|
3218
|
+
const refreshed = await this.#refreshOAuthCredential(provider as Provider, stale, id, signal);
|
|
3219
|
+
const updated: OAuthCredential = {
|
|
3220
|
+
type: "oauth",
|
|
3221
|
+
access: refreshed.access,
|
|
3222
|
+
refresh: refreshed.refresh,
|
|
3223
|
+
expires: refreshed.expires,
|
|
3224
|
+
accountId: refreshed.accountId ?? target.credential.accountId,
|
|
3225
|
+
email: refreshed.email ?? target.credential.email,
|
|
3226
|
+
projectId: refreshed.projectId ?? target.credential.projectId,
|
|
3227
|
+
enterpriseUrl: refreshed.enterpriseUrl ?? target.credential.enterpriseUrl,
|
|
3228
|
+
};
|
|
3229
|
+
this.#replaceCredentialAt(provider, index, updated);
|
|
3230
|
+
return {
|
|
3231
|
+
id,
|
|
3232
|
+
provider,
|
|
3233
|
+
credential: { ...updated, refresh: REMOTE_REFRESH_SENTINEL },
|
|
3234
|
+
identityKey: resolveCredentialIdentityKey(provider, updated),
|
|
3235
|
+
};
|
|
3236
|
+
}
|
|
3237
|
+
throw new Error(`No credential with id=${id}`);
|
|
3238
|
+
}
|
|
3239
|
+
|
|
3240
|
+
/**
|
|
3241
|
+
* Disable the credential with the given id and emit a
|
|
3242
|
+
* {@link CredentialDisabledEvent}. Used by the auth-broker server to honour
|
|
3243
|
+
* `POST /v1/credential/:id/disable`. Returns `false` when no such row exists.
|
|
3244
|
+
*/
|
|
3245
|
+
disableCredentialById(id: number, disabledCause: string): boolean {
|
|
3246
|
+
for (const [provider, entries] of this.#data) {
|
|
3247
|
+
const index = entries.findIndex(entry => entry.id === id);
|
|
3248
|
+
if (index === -1) continue;
|
|
3249
|
+
this.#store.deleteAuthCredential(id, disabledCause);
|
|
3250
|
+
const next = entries.filter((_value, idx) => idx !== index);
|
|
3251
|
+
this.#setStoredCredentials(provider, next);
|
|
3252
|
+
this.#resetProviderAssignments(provider);
|
|
3253
|
+
this.#emitCredentialDisabled({ provider, disabledCause });
|
|
3254
|
+
return true;
|
|
3255
|
+
}
|
|
3256
|
+
return false;
|
|
3257
|
+
}
|
|
3258
|
+
|
|
3259
|
+
/**
|
|
3260
|
+
* Upsert a credential into the underlying store, refresh the in-memory
|
|
3261
|
+
* snapshot, and return the redacted snapshot entries for the provider.
|
|
3262
|
+
*
|
|
3263
|
+
* Used by the auth-broker server to honour `POST /v1/credential`. The
|
|
3264
|
+
* persistence layer (`SqliteAuthCredentialStore.upsertAuthCredentialForProvider`)
|
|
3265
|
+
* does identity-key matching, so re-uploading the same email/account replaces
|
|
3266
|
+
* the existing row instead of inserting a duplicate.
|
|
3267
|
+
*/
|
|
3268
|
+
upsertCredential(provider: string, credential: AuthCredential): AuthCredentialSnapshotEntry[] {
|
|
3269
|
+
const stored = this.#store.upsertAuthCredentialForProvider(provider, credential);
|
|
3270
|
+
this.#setStoredCredentials(
|
|
3271
|
+
provider,
|
|
3272
|
+
stored.map(entry => ({ id: entry.id, credential: entry.credential })),
|
|
3273
|
+
);
|
|
3274
|
+
this.#resetProviderAssignments(provider);
|
|
3275
|
+
return stored.map(entry => {
|
|
3276
|
+
const persisted = entry.credential;
|
|
3277
|
+
const redacted: SnapshotCredential =
|
|
3278
|
+
persisted.type === "api_key" ? persisted : { ...persisted, refresh: REMOTE_REFRESH_SENTINEL };
|
|
3279
|
+
return {
|
|
3280
|
+
id: entry.id,
|
|
3281
|
+
provider: entry.provider,
|
|
3282
|
+
credential: redacted,
|
|
3283
|
+
identityKey: resolveCredentialIdentityKey(provider, persisted),
|
|
3284
|
+
};
|
|
3285
|
+
});
|
|
3286
|
+
}
|
|
3287
|
+
|
|
3288
|
+
/**
|
|
3289
|
+
* Describe where the active credential for a provider came from.
|
|
3290
|
+
*
|
|
3291
|
+
* Surfaces four layers, highest precedence first:
|
|
3292
|
+
* 1. Runtime override (`--api-key`).
|
|
3293
|
+
* 2. Config override (`models.yml` `providers.<name>.apiKey`).
|
|
3294
|
+
* 3. Stored credential (the one this session is currently sticky to, or the
|
|
3295
|
+
* one round-robin would pick next when no session id is supplied).
|
|
3296
|
+
* 4. Env var / fallback resolver — when no stored credential exists.
|
|
3297
|
+
*
|
|
3298
|
+
* The string is purely informational; consumers must not parse it.
|
|
3299
|
+
*/
|
|
3300
|
+
describeCredentialSource(provider: string, sessionId?: string): string | undefined {
|
|
3301
|
+
if (this.#runtimeOverrides.has(provider)) {
|
|
3302
|
+
return "runtime override (--api-key)";
|
|
3303
|
+
}
|
|
3304
|
+
if (this.#configOverrides.has(provider)) {
|
|
3305
|
+
return "config override (models.yml)";
|
|
3306
|
+
}
|
|
3307
|
+
|
|
3308
|
+
const baseLabel = this.#sourceLabel ?? "local store";
|
|
3309
|
+
const stored = this.#getStoredCredentials(provider);
|
|
3310
|
+
if (stored.length === 0) {
|
|
3311
|
+
if (getEnvApiKey(provider)) return `env ${baseLabel ? `(fallback over ${baseLabel})` : ""}`.trim();
|
|
3312
|
+
if (this.#fallbackResolver?.(provider) !== undefined) return `fallback resolver`;
|
|
3313
|
+
return undefined;
|
|
3314
|
+
}
|
|
3315
|
+
|
|
3316
|
+
const session = sessionId ? this.#sessionLastCredential.get(provider)?.get(sessionId) : undefined;
|
|
3317
|
+
// Same selection logic as #selectCredentialByType for "no session" lookups: prefer
|
|
3318
|
+
// the type with stored credentials, lean OAuth before api_key. We don't run the
|
|
3319
|
+
// full round-robin here because describing the source shouldn't advance the index.
|
|
3320
|
+
const preferredType: AuthCredential["type"] =
|
|
3321
|
+
session?.type ?? (stored.some(entry => entry.credential.type === "oauth") ? "oauth" : "api_key");
|
|
3322
|
+
const typed = stored
|
|
3323
|
+
.map((entry, index) => ({ entry, index }))
|
|
3324
|
+
.filter(({ entry }) => entry.credential.type === preferredType);
|
|
3325
|
+
if (typed.length === 0) return baseLabel;
|
|
3326
|
+
const index = session?.index ?? typed[0].index;
|
|
3327
|
+
const chosen = stored[index] ?? typed[0].entry;
|
|
3328
|
+
const credential = chosen.credential;
|
|
3329
|
+
const identity =
|
|
3330
|
+
credential.type === "oauth"
|
|
3331
|
+
? (credential.email ?? credential.accountId ?? credential.projectId ?? `cred ${chosen.id}`)
|
|
3332
|
+
: `cred ${chosen.id}`;
|
|
3333
|
+
return `${baseLabel} · ${preferredType} #${chosen.id} (${identity})`;
|
|
3334
|
+
}
|
|
3335
|
+
}
|
|
3336
|
+
|
|
3337
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
3338
|
+
// SqliteAuthCredentialStore
|
|
3339
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
3340
|
+
|
|
3341
|
+
/** Row shape for auth_credentials table queries */
|
|
3342
|
+
type AuthRow = {
|
|
3343
|
+
id: number;
|
|
3344
|
+
provider: string;
|
|
3345
|
+
credential_type: string;
|
|
3346
|
+
data: string;
|
|
3347
|
+
disabled_cause: string | null;
|
|
3348
|
+
identity_key: string | null;
|
|
3349
|
+
};
|
|
3350
|
+
|
|
3351
|
+
type SerializedCredentialRecord = {
|
|
3352
|
+
credentialType: AuthCredential["type"];
|
|
3353
|
+
data: string;
|
|
3354
|
+
identityKey: string | null;
|
|
3355
|
+
};
|
|
3356
|
+
|
|
3357
|
+
const AUTH_SCHEMA_VERSION = 4;
|
|
3358
|
+
const SQLITE_NOW_EPOCH = "CAST(strftime('%s','now') AS INTEGER)";
|
|
3359
|
+
|
|
3360
|
+
function normalizeStoredAccountId(accountId: string | null | undefined): string | null {
|
|
3361
|
+
const normalized = accountId?.trim();
|
|
3362
|
+
return normalized && normalized.length > 0 ? normalized : null;
|
|
3363
|
+
}
|
|
3364
|
+
|
|
3365
|
+
function normalizeStoredEmail(email: string | null | undefined): string | null {
|
|
3366
|
+
const normalized = email?.trim().toLowerCase();
|
|
3367
|
+
return normalized && normalized.length > 0 ? normalized : null;
|
|
3368
|
+
}
|
|
3369
|
+
|
|
3370
|
+
function normalizeStoredIdentityKey(identityKey: string | null | undefined): string | null {
|
|
3371
|
+
const normalized = identityKey?.trim();
|
|
3372
|
+
return normalized && normalized.length > 0 ? normalized : null;
|
|
3373
|
+
}
|
|
3374
|
+
|
|
3375
|
+
function serializeCredential(provider: string, credential: AuthCredential): SerializedCredentialRecord | null {
|
|
3376
|
+
if (credential.type === "api_key") {
|
|
3377
|
+
return {
|
|
3378
|
+
credentialType: "api_key",
|
|
3379
|
+
data: JSON.stringify({ key: credential.key }),
|
|
3380
|
+
identityKey: null,
|
|
3381
|
+
};
|
|
3382
|
+
}
|
|
3383
|
+
if (credential.type === "oauth") {
|
|
3384
|
+
const { type: _type, ...rest } = credential;
|
|
3385
|
+
return {
|
|
3386
|
+
credentialType: "oauth",
|
|
3387
|
+
data: JSON.stringify(rest),
|
|
3388
|
+
identityKey: resolveCredentialIdentityKey(provider, credential),
|
|
3389
|
+
};
|
|
3390
|
+
}
|
|
3391
|
+
return null;
|
|
3392
|
+
}
|
|
3393
|
+
|
|
3394
|
+
function deserializeCredential(row: AuthRow): AuthCredential | null {
|
|
3395
|
+
let parsed: unknown;
|
|
3396
|
+
try {
|
|
3397
|
+
parsed = JSON.parse(row.data);
|
|
3398
|
+
} catch {
|
|
3399
|
+
return null;
|
|
3400
|
+
}
|
|
3401
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
|
|
3402
|
+
return null;
|
|
3403
|
+
}
|
|
3404
|
+
if (row.credential_type === "api_key") {
|
|
3405
|
+
const data = parsed as Record<string, unknown>;
|
|
3406
|
+
if (typeof data.key === "string") {
|
|
3407
|
+
return { type: "api_key", key: data.key };
|
|
3408
|
+
}
|
|
3409
|
+
}
|
|
3410
|
+
if (row.credential_type === "oauth") {
|
|
3411
|
+
return { type: "oauth", ...(parsed as Record<string, unknown>) } as AuthCredential;
|
|
3412
|
+
}
|
|
3413
|
+
return null;
|
|
3414
|
+
}
|
|
3415
|
+
|
|
3416
|
+
function normalizeDisabledCause(disabledCause: string): string {
|
|
3417
|
+
const normalized = disabledCause.trim();
|
|
3418
|
+
return normalized.length > 0 ? normalized : "disabled";
|
|
3419
|
+
}
|
|
3420
|
+
|
|
3421
|
+
function toStoredAuthCredential(row: AuthRow, credential: AuthCredential): StoredAuthCredential {
|
|
3422
|
+
return { id: row.id, provider: row.provider, credential, disabledCause: row.disabled_cause };
|
|
3423
|
+
}
|
|
3424
|
+
|
|
3425
|
+
function resolveProviderCredentialIdentityKey(provider: string, identifiers: string[]): string | null {
|
|
3426
|
+
const emailIdentifier = identifiers.find(identifier => identifier.startsWith("email:"));
|
|
3427
|
+
if ((provider === "openai-codex" || provider === "anthropic") && emailIdentifier) return emailIdentifier;
|
|
3428
|
+
const accountIdentifier = identifiers.find(identifier => identifier.startsWith("account:"));
|
|
3429
|
+
if (accountIdentifier) return accountIdentifier;
|
|
3430
|
+
if (emailIdentifier) return emailIdentifier;
|
|
3431
|
+
return null;
|
|
3432
|
+
}
|
|
3433
|
+
|
|
3434
|
+
function resolveCredentialIdentityKey(provider: string, credential: AuthCredential): string | null {
|
|
3435
|
+
if (credential.type === "api_key") return null;
|
|
3436
|
+
return resolveProviderCredentialIdentityKey(provider, extractOAuthCredentialIdentifiers(credential));
|
|
3437
|
+
}
|
|
3438
|
+
|
|
3439
|
+
function resolveRowCredentialIdentityKey(provider: string, row: AuthRow): string | null {
|
|
3440
|
+
const identityKey = normalizeStoredIdentityKey(row.identity_key);
|
|
3441
|
+
if (identityKey) return identityKey;
|
|
3442
|
+
const credential = deserializeCredential(row);
|
|
3443
|
+
return credential?.type === "oauth" ? resolveCredentialIdentityKey(provider, credential) : null;
|
|
3444
|
+
}
|
|
3445
|
+
|
|
3446
|
+
function matchesReplacementCredential(
|
|
3447
|
+
provider: string,
|
|
3448
|
+
existing: AuthCredential | null,
|
|
3449
|
+
existingIdentityKey: string | null,
|
|
3450
|
+
incoming: AuthCredential,
|
|
3451
|
+
): boolean {
|
|
3452
|
+
if (!existing || existing.type !== incoming.type) return false;
|
|
3453
|
+
if (incoming.type === "api_key") {
|
|
3454
|
+
return existing.type === "api_key" && existing.key === incoming.key;
|
|
3455
|
+
}
|
|
3456
|
+
const incomingIdentityKey = resolveCredentialIdentityKey(provider, incoming);
|
|
3457
|
+
return incomingIdentityKey !== null && incomingIdentityKey === existingIdentityKey;
|
|
3458
|
+
}
|
|
3459
|
+
|
|
3460
|
+
function extractOAuthCredentialIdentifiers(credential: OAuthCredential): string[] {
|
|
3461
|
+
const identifiers = new Set<string>();
|
|
3462
|
+
const accountId = normalizeStoredAccountId(credential.accountId);
|
|
3463
|
+
if (accountId) identifiers.add(`account:${accountId}`);
|
|
3464
|
+
const email = normalizeStoredEmail(credential.email);
|
|
3465
|
+
if (email) identifiers.add(`email:${email}`);
|
|
3466
|
+
const accessIdentifiers = extractOAuthTokenIdentifiers(credential.access) ?? [];
|
|
3467
|
+
for (const identifier of accessIdentifiers) {
|
|
3468
|
+
identifiers.add(identifier);
|
|
3469
|
+
}
|
|
3470
|
+
const refreshIdentifiers = extractOAuthTokenIdentifiers(credential.refresh) ?? [];
|
|
3471
|
+
for (const identifier of refreshIdentifiers) {
|
|
3472
|
+
identifiers.add(identifier);
|
|
3473
|
+
}
|
|
3474
|
+
return [...identifiers];
|
|
3475
|
+
}
|
|
3476
|
+
|
|
3477
|
+
function extractOAuthTokenIdentifiers(token: string | undefined): string[] | undefined {
|
|
3478
|
+
if (!token) return undefined;
|
|
3479
|
+
const parts = token.split(".");
|
|
3480
|
+
if (parts.length !== 3) return undefined;
|
|
3481
|
+
try {
|
|
3482
|
+
const payload = JSON.parse(
|
|
3483
|
+
new TextDecoder("utf-8").decode(Uint8Array.fromBase64(parts[1], { alphabet: "base64url" })),
|
|
3484
|
+
) as Record<string, unknown>;
|
|
3485
|
+
const identifiers = new Set<string>();
|
|
3486
|
+
const directEmail = normalizeStoredEmail(typeof payload.email === "string" ? payload.email : undefined);
|
|
3487
|
+
if (directEmail) identifiers.add(`email:${directEmail}`);
|
|
3488
|
+
const openAiProfile = payload["https://api.openai.com/profile"];
|
|
3489
|
+
if (typeof openAiProfile === "object" && openAiProfile !== null && !Array.isArray(openAiProfile)) {
|
|
3490
|
+
const claimEmail = normalizeStoredEmail(
|
|
3491
|
+
(openAiProfile as Record<string, unknown>).email as string | undefined,
|
|
3492
|
+
);
|
|
3493
|
+
if (claimEmail) identifiers.add(`email:${claimEmail}`);
|
|
3494
|
+
}
|
|
3495
|
+
const openAiAuth = payload["https://api.openai.com/auth"];
|
|
3496
|
+
const authClaims =
|
|
3497
|
+
typeof openAiAuth === "object" && openAiAuth !== null && !Array.isArray(openAiAuth)
|
|
3498
|
+
? (openAiAuth as Record<string, unknown>)
|
|
3499
|
+
: undefined;
|
|
3500
|
+
const accountId = normalizeStoredAccountId(
|
|
3501
|
+
typeof payload.account_id === "string"
|
|
3502
|
+
? payload.account_id
|
|
3503
|
+
: typeof payload.accountId === "string"
|
|
3504
|
+
? payload.accountId
|
|
3505
|
+
: typeof payload.user_id === "string"
|
|
3506
|
+
? payload.user_id
|
|
3507
|
+
: typeof payload.sub === "string"
|
|
3508
|
+
? payload.sub
|
|
3509
|
+
: typeof authClaims?.chatgpt_account_id === "string"
|
|
3510
|
+
? authClaims.chatgpt_account_id
|
|
3511
|
+
: undefined,
|
|
3512
|
+
);
|
|
3513
|
+
if (accountId) identifiers.add(`account:${accountId}`);
|
|
3514
|
+
return identifiers.size > 0 ? [...identifiers] : undefined;
|
|
3515
|
+
} catch {
|
|
3516
|
+
return undefined;
|
|
3517
|
+
}
|
|
3518
|
+
}
|
|
3519
|
+
/**
|
|
3520
|
+
* Default SQLite-backed implementation of {@link AuthCredentialStore}.
|
|
3521
|
+
*
|
|
3522
|
+
* Used by the pi-ai CLI and as the default store for `AuthStorage.create()`.
|
|
3523
|
+
* Also exposes convenience methods (`saveOAuth`, `getOAuth`, `saveApiKey`,
|
|
3524
|
+
* `getApiKey`, `listProviders`, `deleteProvider`) that callers can use directly
|
|
3525
|
+
* without going through `AuthStorage`.
|
|
3526
|
+
*/
|
|
3527
|
+
export class SqliteAuthCredentialStore implements AuthCredentialStore {
|
|
3528
|
+
#db: Database;
|
|
3529
|
+
#listActiveStmt: Statement;
|
|
3530
|
+
#listActiveByProviderStmt: Statement;
|
|
3531
|
+
#listDisabledByProviderStmt: Statement;
|
|
3532
|
+
#insertStmt: Statement;
|
|
3533
|
+
#updateStmt: Statement;
|
|
3534
|
+
#deleteStmt: Statement;
|
|
3535
|
+
#deleteIfMatchesStmt: Statement;
|
|
3536
|
+
#deleteByProviderStmt: Statement;
|
|
3537
|
+
#hardDeleteStmt: Statement;
|
|
3538
|
+
#getCacheStmt: Statement;
|
|
3539
|
+
#getCacheIncludingExpiredStmt: Statement;
|
|
3540
|
+
#upsertCacheStmt: Statement;
|
|
3541
|
+
#deleteExpiredCacheStmt: Statement;
|
|
3542
|
+
#closed = false;
|
|
3543
|
+
|
|
3544
|
+
constructor(db: Database) {
|
|
3545
|
+
this.#db = db;
|
|
3546
|
+
this.#initializeSchema();
|
|
3547
|
+
|
|
3548
|
+
this.#listActiveStmt = this.#db.prepare(
|
|
3549
|
+
"SELECT id, provider, credential_type, data, disabled_cause, identity_key FROM auth_credentials WHERE disabled_cause IS NULL ORDER BY id ASC",
|
|
3550
|
+
);
|
|
3551
|
+
this.#listActiveByProviderStmt = this.#db.prepare(
|
|
3552
|
+
"SELECT id, provider, credential_type, data, disabled_cause, identity_key FROM auth_credentials WHERE provider = ? AND disabled_cause IS NULL ORDER BY id ASC",
|
|
3553
|
+
);
|
|
3554
|
+
this.#listDisabledByProviderStmt = this.#db.prepare(
|
|
3555
|
+
"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",
|
|
3556
|
+
);
|
|
3557
|
+
this.#insertStmt = this.#db.prepare(
|
|
3558
|
+
`INSERT INTO auth_credentials (provider, credential_type, data, identity_key, created_at, updated_at) VALUES (?, ?, ?, ?, ${SQLITE_NOW_EPOCH}, ${SQLITE_NOW_EPOCH}) RETURNING id`,
|
|
3559
|
+
);
|
|
3560
|
+
this.#updateStmt = this.#db.prepare(
|
|
3561
|
+
`UPDATE auth_credentials SET credential_type = ?, data = ?, identity_key = ?, updated_at = ${SQLITE_NOW_EPOCH} WHERE id = ?`,
|
|
3562
|
+
);
|
|
3563
|
+
this.#deleteStmt = this.#db.prepare(
|
|
3564
|
+
`UPDATE auth_credentials SET disabled_cause = ?, updated_at = ${SQLITE_NOW_EPOCH} WHERE id = ?`,
|
|
3565
|
+
);
|
|
3566
|
+
this.#deleteIfMatchesStmt = this.#db.prepare(
|
|
3567
|
+
`UPDATE auth_credentials SET disabled_cause = ?, updated_at = ${SQLITE_NOW_EPOCH} WHERE id = ? AND data = ? AND disabled_cause IS NULL`,
|
|
3568
|
+
);
|
|
3569
|
+
this.#deleteByProviderStmt = this.#db.prepare(
|
|
3570
|
+
`UPDATE auth_credentials SET disabled_cause = ?, updated_at = ${SQLITE_NOW_EPOCH} WHERE provider = ? AND disabled_cause IS NULL`,
|
|
3571
|
+
);
|
|
3572
|
+
this.#hardDeleteStmt = this.#db.prepare("DELETE FROM auth_credentials WHERE id = ?");
|
|
3573
|
+
this.#getCacheStmt = this.#db.prepare(
|
|
3574
|
+
`SELECT value FROM cache WHERE key = ? AND expires_at > ${SQLITE_NOW_EPOCH}`,
|
|
3575
|
+
);
|
|
3576
|
+
this.#getCacheIncludingExpiredStmt = this.#db.prepare("SELECT value FROM cache WHERE key = ?");
|
|
3577
|
+
this.#upsertCacheStmt = this.#db.prepare(
|
|
3578
|
+
"INSERT INTO cache (key, value, expires_at) VALUES (?, ?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value, expires_at = excluded.expires_at",
|
|
3579
|
+
);
|
|
3580
|
+
this.#deleteExpiredCacheStmt = this.#db.prepare(`DELETE FROM cache WHERE expires_at <= ${SQLITE_NOW_EPOCH}`);
|
|
3581
|
+
}
|
|
3582
|
+
|
|
3583
|
+
static async open(dbPath: string = getAgentDbPath()): Promise<SqliteAuthCredentialStore> {
|
|
3584
|
+
const dir = path.dirname(dbPath);
|
|
3585
|
+
const dirExists = await fs
|
|
3586
|
+
.stat(dir)
|
|
3587
|
+
.then(s => s.isDirectory())
|
|
3588
|
+
.catch(() => false);
|
|
3589
|
+
if (!dirExists) {
|
|
3590
|
+
await fs.mkdir(dir, { recursive: true, mode: 0o700 });
|
|
3591
|
+
}
|
|
3592
|
+
|
|
3593
|
+
const db = new Database(dbPath);
|
|
3594
|
+
try {
|
|
3595
|
+
await fs.chmod(dbPath, 0o600);
|
|
3596
|
+
} catch {
|
|
3597
|
+
// Ignore chmod failures (e.g., Windows)
|
|
3598
|
+
}
|
|
3599
|
+
|
|
3600
|
+
return new SqliteAuthCredentialStore(db);
|
|
3601
|
+
}
|
|
3602
|
+
|
|
3603
|
+
#initializeSchema(): void {
|
|
3604
|
+
this.#db.run(`
|
|
3605
|
+
PRAGMA journal_mode=WAL;
|
|
3606
|
+
PRAGMA synchronous=NORMAL;
|
|
3607
|
+
PRAGMA busy_timeout=5000;
|
|
3608
|
+
CREATE TABLE IF NOT EXISTS auth_schema_version (
|
|
3609
|
+
id INTEGER PRIMARY KEY CHECK (id = 1),
|
|
3610
|
+
version INTEGER NOT NULL
|
|
3611
|
+
);
|
|
3612
|
+
CREATE TABLE IF NOT EXISTS cache (
|
|
3613
|
+
key TEXT PRIMARY KEY,
|
|
3614
|
+
value TEXT NOT NULL,
|
|
3615
|
+
expires_at INTEGER NOT NULL
|
|
3616
|
+
);
|
|
3617
|
+
CREATE INDEX IF NOT EXISTS idx_cache_expires ON cache(expires_at);
|
|
3618
|
+
`);
|
|
3619
|
+
|
|
3620
|
+
if (!this.#authCredentialsTableExists()) {
|
|
3621
|
+
this.#createAuthCredentialsTable();
|
|
3622
|
+
this.#writeAuthSchemaVersion(AUTH_SCHEMA_VERSION);
|
|
3623
|
+
return;
|
|
3624
|
+
}
|
|
3625
|
+
|
|
3626
|
+
const schemaVersion = this.#readAuthSchemaVersion() ?? this.#inferAuthSchemaVersion();
|
|
3627
|
+
const shouldWriteSchemaVersion = schemaVersion <= AUTH_SCHEMA_VERSION;
|
|
3628
|
+
if (schemaVersion > AUTH_SCHEMA_VERSION) {
|
|
3629
|
+
logger.warn("SqliteAuthCredentialStore schema version mismatch", {
|
|
3630
|
+
current: schemaVersion,
|
|
3631
|
+
expected: AUTH_SCHEMA_VERSION,
|
|
3632
|
+
});
|
|
3633
|
+
} else if (schemaVersion < AUTH_SCHEMA_VERSION) {
|
|
3634
|
+
this.#migrateAuthSchema(schemaVersion);
|
|
3635
|
+
}
|
|
3636
|
+
|
|
3637
|
+
this.#createAuthCredentialIndexes();
|
|
3638
|
+
this.#backfillCredentialIdentityKeys();
|
|
3639
|
+
if (shouldWriteSchemaVersion) {
|
|
3640
|
+
this.#writeAuthSchemaVersion(AUTH_SCHEMA_VERSION);
|
|
3641
|
+
}
|
|
3642
|
+
}
|
|
3643
|
+
|
|
3644
|
+
#authCredentialsTableExists(): boolean {
|
|
3645
|
+
const row = this.#db
|
|
3646
|
+
.prepare("SELECT 1 AS present FROM sqlite_master WHERE type = 'table' AND name = 'auth_credentials'")
|
|
3647
|
+
.get() as { present?: number } | undefined;
|
|
3648
|
+
return row?.present === 1;
|
|
3649
|
+
}
|
|
3650
|
+
|
|
3651
|
+
#readAuthSchemaVersion(): number | null {
|
|
3652
|
+
const row = this.#db.prepare("SELECT version FROM auth_schema_version WHERE id = 1").get() as
|
|
3653
|
+
| { version?: number }
|
|
3654
|
+
| undefined;
|
|
3655
|
+
return typeof row?.version === "number" ? row.version : null;
|
|
3656
|
+
}
|
|
3657
|
+
|
|
3658
|
+
#writeAuthSchemaVersion(version: number): void {
|
|
3659
|
+
this.#db.prepare("INSERT OR REPLACE INTO auth_schema_version(id, version) VALUES (1, ?)").run(version);
|
|
3660
|
+
}
|
|
3661
|
+
|
|
3662
|
+
#inferAuthSchemaVersion(): number {
|
|
3663
|
+
const cols = this.#db.prepare("PRAGMA table_info(auth_credentials)").all() as Array<{ name?: string }>;
|
|
3664
|
+
const hasDisabledCause = cols.some(column => column.name === "disabled_cause");
|
|
3665
|
+
const hasIdentityKey = cols.some(column => column.name === "identity_key");
|
|
3666
|
+
const hasAccountId = cols.some(column => column.name === "account_id");
|
|
3667
|
+
const hasEmail = cols.some(column => column.name === "email");
|
|
3668
|
+
if (hasIdentityKey) return 3;
|
|
3669
|
+
if (hasAccountId || hasEmail) return 2;
|
|
3670
|
+
if (hasDisabledCause) return 1;
|
|
3671
|
+
return 0;
|
|
3672
|
+
}
|
|
3673
|
+
|
|
3674
|
+
#createAuthCredentialsTable(): void {
|
|
3675
|
+
this.#db.run(`
|
|
3676
|
+
CREATE TABLE IF NOT EXISTS auth_credentials (
|
|
3677
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
3678
|
+
provider TEXT NOT NULL,
|
|
3679
|
+
credential_type TEXT NOT NULL,
|
|
3680
|
+
data TEXT NOT NULL,
|
|
3681
|
+
disabled_cause TEXT DEFAULT NULL,
|
|
3682
|
+
identity_key TEXT DEFAULT NULL,
|
|
3683
|
+
created_at INTEGER NOT NULL DEFAULT (${SQLITE_NOW_EPOCH}),
|
|
3684
|
+
updated_at INTEGER NOT NULL DEFAULT (${SQLITE_NOW_EPOCH})
|
|
3685
|
+
);
|
|
3686
|
+
`);
|
|
3687
|
+
this.#createAuthCredentialIndexes();
|
|
3688
|
+
}
|
|
3689
|
+
|
|
3690
|
+
#createAuthCredentialIndexes(): void {
|
|
3691
|
+
this.#db.run(`
|
|
3692
|
+
CREATE INDEX IF NOT EXISTS idx_auth_provider ON auth_credentials(provider);
|
|
3693
|
+
CREATE INDEX IF NOT EXISTS idx_auth_provider_identity ON auth_credentials(provider, identity_key) WHERE identity_key IS NOT NULL;
|
|
3694
|
+
`);
|
|
3695
|
+
}
|
|
3696
|
+
|
|
3697
|
+
#migrateAuthSchema(fromVersion: number): void {
|
|
3698
|
+
if (fromVersion < 1) {
|
|
3699
|
+
this.#migrateAuthSchemaV0ToV1();
|
|
3700
|
+
}
|
|
3701
|
+
if (fromVersion < 3) {
|
|
3702
|
+
this.#migrateAuthSchemaV1OrV2ToV3();
|
|
3703
|
+
}
|
|
3704
|
+
if (fromVersion < 4) {
|
|
3705
|
+
this.#migrateAuthSchemaV3ToV4();
|
|
3706
|
+
}
|
|
3707
|
+
}
|
|
3708
|
+
|
|
3709
|
+
#migrateAuthSchemaV0ToV1(): void {
|
|
3710
|
+
const migrate = this.#db.transaction(() => {
|
|
3711
|
+
const v0Cols = this.#db.prepare("PRAGMA table_info(auth_credentials)").all() as Array<{ name?: string }>;
|
|
3712
|
+
const hasDisabled = v0Cols.some(col => col.name === "disabled");
|
|
3713
|
+
|
|
3714
|
+
this.#db.run("ALTER TABLE auth_credentials RENAME TO auth_credentials_v0");
|
|
3715
|
+
this.#db.run(`
|
|
3716
|
+
CREATE TABLE auth_credentials (
|
|
3717
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
3718
|
+
provider TEXT NOT NULL,
|
|
3719
|
+
credential_type TEXT NOT NULL,
|
|
3720
|
+
data TEXT NOT NULL,
|
|
3721
|
+
disabled_cause TEXT DEFAULT NULL,
|
|
3722
|
+
created_at INTEGER NOT NULL DEFAULT (${SQLITE_NOW_EPOCH}),
|
|
3723
|
+
updated_at INTEGER NOT NULL DEFAULT (${SQLITE_NOW_EPOCH})
|
|
3724
|
+
);
|
|
3725
|
+
`);
|
|
3726
|
+
this.#db.run(`
|
|
3727
|
+
INSERT INTO auth_credentials (id, provider, credential_type, data, disabled_cause, created_at, updated_at)
|
|
3728
|
+
SELECT
|
|
3729
|
+
id,
|
|
3730
|
+
provider,
|
|
3731
|
+
credential_type,
|
|
3732
|
+
data,
|
|
3733
|
+
${hasDisabled ? "CASE WHEN disabled = 1 THEN 'disabled' ELSE NULL END" : "NULL"},
|
|
3734
|
+
created_at,
|
|
3735
|
+
updated_at
|
|
3736
|
+
FROM auth_credentials_v0
|
|
3737
|
+
`);
|
|
3738
|
+
this.#db.run("DROP TABLE auth_credentials_v0");
|
|
3739
|
+
});
|
|
3740
|
+
migrate();
|
|
3741
|
+
}
|
|
3742
|
+
|
|
3743
|
+
#migrateAuthSchemaV1OrV2ToV3(): void {
|
|
3744
|
+
const migrate = this.#db.transaction(() => {
|
|
3745
|
+
this.#db.run("ALTER TABLE auth_credentials RENAME TO auth_credentials_legacy");
|
|
3746
|
+
this.#createAuthCredentialsTable();
|
|
3747
|
+
this.#db.run(`
|
|
3748
|
+
INSERT INTO auth_credentials (id, provider, credential_type, data, disabled_cause, identity_key, created_at, updated_at)
|
|
3749
|
+
SELECT
|
|
3750
|
+
id,
|
|
3751
|
+
provider,
|
|
3752
|
+
credential_type,
|
|
3753
|
+
data,
|
|
3754
|
+
disabled_cause,
|
|
3755
|
+
NULL,
|
|
3756
|
+
created_at,
|
|
3757
|
+
updated_at
|
|
3758
|
+
FROM auth_credentials_legacy
|
|
3759
|
+
`);
|
|
3760
|
+
this.#db.run("DROP TABLE auth_credentials_legacy");
|
|
3761
|
+
});
|
|
3762
|
+
migrate();
|
|
3763
|
+
}
|
|
3764
|
+
|
|
3765
|
+
#migrateAuthSchemaV3ToV4(): void {
|
|
3766
|
+
const migrate = this.#db.transaction(() => {
|
|
3767
|
+
this.#db.run("ALTER TABLE auth_credentials RENAME TO auth_credentials_v3");
|
|
3768
|
+
this.#createAuthCredentialsTable();
|
|
3769
|
+
this.#db.run(`
|
|
3770
|
+
INSERT INTO auth_credentials (id, provider, credential_type, data, disabled_cause, identity_key, created_at, updated_at)
|
|
3771
|
+
SELECT
|
|
3772
|
+
id,
|
|
3773
|
+
provider,
|
|
3774
|
+
credential_type,
|
|
3775
|
+
data,
|
|
3776
|
+
disabled_cause,
|
|
3777
|
+
identity_key,
|
|
3778
|
+
created_at,
|
|
3779
|
+
updated_at
|
|
3780
|
+
FROM auth_credentials_v3
|
|
3781
|
+
`);
|
|
3782
|
+
this.#db.run("DROP TABLE auth_credentials_v3");
|
|
3783
|
+
});
|
|
3784
|
+
migrate();
|
|
3785
|
+
}
|
|
3786
|
+
|
|
3787
|
+
#backfillCredentialIdentityKeys(): void {
|
|
3788
|
+
const rows = this.#db
|
|
3789
|
+
.prepare(
|
|
3790
|
+
"SELECT id, provider, credential_type, data, disabled_cause, identity_key FROM auth_credentials WHERE identity_key IS NULL ORDER BY id ASC",
|
|
3791
|
+
)
|
|
3792
|
+
.all() as AuthRow[];
|
|
3793
|
+
if (rows.length === 0) return;
|
|
3794
|
+
|
|
3795
|
+
const updateIdentity = this.#db.prepare("UPDATE auth_credentials SET identity_key = ? WHERE id = ?");
|
|
3796
|
+
for (const row of rows) {
|
|
3797
|
+
const identityKey = resolveRowCredentialIdentityKey(row.provider, row);
|
|
3798
|
+
updateIdentity.run(identityKey, row.id);
|
|
3799
|
+
}
|
|
3800
|
+
}
|
|
3801
|
+
|
|
3802
|
+
// ─── AuthCredentialStore interface ──────────────────────────────────────
|
|
3803
|
+
|
|
3804
|
+
listAuthCredentials(provider?: string): StoredAuthCredential[] {
|
|
3805
|
+
const rows =
|
|
3806
|
+
(provider
|
|
3807
|
+
? (this.#listActiveByProviderStmt.all(provider) as AuthRow[])
|
|
3808
|
+
: (this.#listActiveStmt.all() as AuthRow[])) ?? [];
|
|
3809
|
+
|
|
3810
|
+
const results: StoredAuthCredential[] = [];
|
|
3811
|
+
for (const row of rows) {
|
|
3812
|
+
const credential = deserializeCredential(row);
|
|
3813
|
+
if (!credential) continue;
|
|
3814
|
+
results.push(toStoredAuthCredential(row, credential));
|
|
3815
|
+
}
|
|
3816
|
+
return results;
|
|
3817
|
+
}
|
|
3818
|
+
|
|
3819
|
+
replaceAuthCredentialsForProvider(provider: string, credentials: AuthCredential[]): StoredAuthCredential[] {
|
|
3820
|
+
const replace = this.#db.transaction((providerName: string, items: AuthCredential[]) => {
|
|
3821
|
+
const existingRows = this.#listActiveByProviderStmt.all(providerName) as AuthRow[];
|
|
3822
|
+
const existing = existingRows.map(row => ({
|
|
3823
|
+
id: row.id,
|
|
3824
|
+
credential: deserializeCredential(row),
|
|
3825
|
+
identityKey: resolveRowCredentialIdentityKey(providerName, row),
|
|
3826
|
+
}));
|
|
3827
|
+
|
|
3828
|
+
const result: StoredAuthCredential[] = [];
|
|
3829
|
+
const matchedExistingIds = new Set<number>();
|
|
3830
|
+
|
|
3831
|
+
for (const credential of items) {
|
|
3832
|
+
const serialized = serializeCredential(providerName, credential);
|
|
3833
|
+
if (!serialized) continue;
|
|
3834
|
+
const match = existing.find(
|
|
3835
|
+
entry =>
|
|
3836
|
+
!matchedExistingIds.has(entry.id) &&
|
|
3837
|
+
matchesReplacementCredential(providerName, entry.credential, entry.identityKey, credential),
|
|
3838
|
+
);
|
|
3839
|
+
if (match) {
|
|
3840
|
+
matchedExistingIds.add(match.id);
|
|
3841
|
+
this.#updateStmt.run(serialized.credentialType, serialized.data, serialized.identityKey, match.id);
|
|
3842
|
+
result.push({ id: match.id, provider: providerName, credential, disabledCause: null });
|
|
3843
|
+
} else {
|
|
3844
|
+
const row = this.#insertStmt.get(
|
|
3845
|
+
providerName,
|
|
3846
|
+
serialized.credentialType,
|
|
3847
|
+
serialized.data,
|
|
3848
|
+
serialized.identityKey,
|
|
3849
|
+
) as { id?: number } | undefined;
|
|
3850
|
+
if (row?.id) {
|
|
3851
|
+
result.push({ id: row.id, provider: providerName, credential, disabledCause: null });
|
|
3852
|
+
}
|
|
3853
|
+
}
|
|
3854
|
+
}
|
|
3855
|
+
|
|
3856
|
+
for (const row of existing) {
|
|
3857
|
+
if (!matchedExistingIds.has(row.id)) {
|
|
3858
|
+
this.#deleteStmt.run("replaced by newer credential", row.id);
|
|
3859
|
+
}
|
|
3860
|
+
}
|
|
3861
|
+
|
|
3862
|
+
return result;
|
|
3863
|
+
});
|
|
3864
|
+
|
|
3865
|
+
const result = replace(provider, credentials);
|
|
3866
|
+
this.#purgeSupersededDisabledRows(provider, result);
|
|
3867
|
+
return result;
|
|
3868
|
+
}
|
|
3869
|
+
|
|
3870
|
+
upsertAuthCredentialForProvider(provider: string, credential: AuthCredential): StoredAuthCredential[] {
|
|
3871
|
+
const upsert = this.#db.transaction((providerName: string, item: AuthCredential) => {
|
|
3872
|
+
const serialized = serializeCredential(providerName, item);
|
|
3873
|
+
if (!serialized) return this.listAuthCredentials(providerName);
|
|
3874
|
+
const existingRows = this.#listActiveByProviderStmt.all(providerName) as AuthRow[];
|
|
3875
|
+
const existing = existingRows.map(row => ({
|
|
3876
|
+
id: row.id,
|
|
3877
|
+
credential: deserializeCredential(row),
|
|
3878
|
+
identityKey: resolveRowCredentialIdentityKey(providerName, row),
|
|
3879
|
+
}));
|
|
3880
|
+
|
|
3881
|
+
let targetId: number | null = null;
|
|
3882
|
+
for (const row of existing) {
|
|
3883
|
+
if (!matchesReplacementCredential(providerName, row.credential, row.identityKey, item)) continue;
|
|
3884
|
+
if (targetId === null) {
|
|
3885
|
+
targetId = row.id;
|
|
3886
|
+
this.#updateStmt.run(serialized.credentialType, serialized.data, serialized.identityKey, row.id);
|
|
3887
|
+
continue;
|
|
3888
|
+
}
|
|
3889
|
+
this.#deleteStmt.run("replaced by newer credential", row.id);
|
|
3890
|
+
}
|
|
3891
|
+
|
|
3892
|
+
if (targetId === null) {
|
|
3893
|
+
const row = this.#insertStmt.get(
|
|
3894
|
+
providerName,
|
|
3895
|
+
serialized.credentialType,
|
|
3896
|
+
serialized.data,
|
|
3897
|
+
serialized.identityKey,
|
|
3898
|
+
) as { id?: number } | undefined;
|
|
3899
|
+
targetId = row?.id ?? null;
|
|
3900
|
+
}
|
|
3901
|
+
|
|
3902
|
+
const activeRows = this.#listActiveByProviderStmt.all(providerName) as AuthRow[];
|
|
3903
|
+
const result: StoredAuthCredential[] = [];
|
|
3904
|
+
for (const row of activeRows) {
|
|
3905
|
+
const activeCredential = deserializeCredential(row);
|
|
3906
|
+
if (!activeCredential) continue;
|
|
3907
|
+
result.push(toStoredAuthCredential(row, activeCredential));
|
|
3908
|
+
}
|
|
3909
|
+
return result;
|
|
3910
|
+
});
|
|
3911
|
+
|
|
3912
|
+
const result = upsert(provider, credential);
|
|
3913
|
+
this.#purgeSupersededDisabledRows(provider, result);
|
|
3914
|
+
return result;
|
|
3915
|
+
}
|
|
3916
|
+
|
|
3917
|
+
/**
|
|
3918
|
+
* Hard-deletes disabled rows for a provider when an active row with the same identity exists.
|
|
3919
|
+
* This prevents unbounded accumulation of soft-deleted credentials while preserving
|
|
3920
|
+
* disabled rows that have no active replacement (safety net for recovery).
|
|
3921
|
+
*/
|
|
3922
|
+
#purgeSupersededDisabledRows(provider: string, activeRows: StoredAuthCredential[]): void {
|
|
3923
|
+
try {
|
|
3924
|
+
const activeIdentityKeys = new Set<string>();
|
|
3925
|
+
for (const row of activeRows) {
|
|
3926
|
+
const identityKey = resolveCredentialIdentityKey(provider, row.credential);
|
|
3927
|
+
if (identityKey) activeIdentityKeys.add(identityKey);
|
|
3928
|
+
}
|
|
3929
|
+
if (activeIdentityKeys.size === 0) return;
|
|
3930
|
+
|
|
3931
|
+
const disabledRows = this.#listDisabledByProviderStmt.all(provider) as AuthRow[];
|
|
3932
|
+
for (const row of disabledRows) {
|
|
3933
|
+
const identityKey = resolveRowCredentialIdentityKey(provider, row);
|
|
3934
|
+
if (identityKey && activeIdentityKeys.has(identityKey)) {
|
|
3935
|
+
this.#hardDeleteStmt.run(row.id);
|
|
3936
|
+
}
|
|
3937
|
+
}
|
|
3938
|
+
} catch {
|
|
3939
|
+
// Best-effort cleanup; don't let it break the main operation
|
|
3940
|
+
}
|
|
3941
|
+
}
|
|
3942
|
+
|
|
3943
|
+
updateAuthCredential(id: number, credential: AuthCredential): void {
|
|
3944
|
+
try {
|
|
3945
|
+
const providerRow = this.#db.prepare("SELECT provider FROM auth_credentials WHERE id = ?").get(id) as
|
|
3946
|
+
| { provider?: string }
|
|
3947
|
+
| undefined;
|
|
3948
|
+
const provider = providerRow?.provider ?? "";
|
|
3949
|
+
const serialized = serializeCredential(provider, credential);
|
|
3950
|
+
if (!serialized) return;
|
|
3951
|
+
this.#updateStmt.run(serialized.credentialType, serialized.data, serialized.identityKey, id);
|
|
3952
|
+
if (provider) {
|
|
3953
|
+
this.#purgeSupersededDisabledRows(provider, this.listAuthCredentials(provider));
|
|
3954
|
+
}
|
|
3955
|
+
} catch {
|
|
3956
|
+
// Ignore update failures
|
|
3957
|
+
}
|
|
3958
|
+
}
|
|
3959
|
+
|
|
3960
|
+
deleteAuthCredential(id: number, disabledCause: string): void {
|
|
3961
|
+
try {
|
|
3962
|
+
this.#deleteStmt.run(normalizeDisabledCause(disabledCause), id);
|
|
3963
|
+
} catch {
|
|
3964
|
+
// Ignore delete failures
|
|
3965
|
+
}
|
|
3966
|
+
}
|
|
3967
|
+
|
|
3968
|
+
/**
|
|
3969
|
+
* CAS-style disable: only soft-deletes the row when its `data` column still
|
|
3970
|
+
* matches `expectedData` and the row has not already been disabled. Used by
|
|
3971
|
+
* the OAuth refresh-failure path to avoid clobbering a peer that rotated the
|
|
3972
|
+
* row between our pre-check and the disable.
|
|
3973
|
+
*/
|
|
3974
|
+
tryDisableAuthCredentialIfMatches(id: number, expectedData: string, disabledCause: string): boolean {
|
|
3975
|
+
try {
|
|
3976
|
+
const result = this.#deleteIfMatchesStmt.run(normalizeDisabledCause(disabledCause), id, expectedData) as {
|
|
3977
|
+
changes: number;
|
|
3978
|
+
};
|
|
3979
|
+
return result.changes === 1;
|
|
3980
|
+
} catch {
|
|
3981
|
+
return false;
|
|
3982
|
+
}
|
|
3983
|
+
}
|
|
3984
|
+
|
|
3985
|
+
deleteAuthCredentialsForProvider(provider: string, disabledCause: string): void {
|
|
3986
|
+
try {
|
|
3987
|
+
this.#deleteByProviderStmt.run(normalizeDisabledCause(disabledCause), provider);
|
|
3988
|
+
} catch {
|
|
3989
|
+
// Ignore delete failures
|
|
3990
|
+
}
|
|
3991
|
+
}
|
|
3992
|
+
|
|
3993
|
+
getCache(key: string, options?: { includeExpired?: boolean }): string | null {
|
|
3994
|
+
try {
|
|
3995
|
+
const stmt = options?.includeExpired === true ? this.#getCacheIncludingExpiredStmt : this.#getCacheStmt;
|
|
3996
|
+
const row = stmt.get(key) as { value?: string } | undefined;
|
|
3997
|
+
return row?.value ?? null;
|
|
3998
|
+
} catch {
|
|
3999
|
+
return null;
|
|
4000
|
+
}
|
|
4001
|
+
}
|
|
4002
|
+
|
|
4003
|
+
setCache(key: string, value: string, expiresAtSec: number): void {
|
|
4004
|
+
try {
|
|
4005
|
+
this.#upsertCacheStmt.run(key, value, expiresAtSec);
|
|
4006
|
+
} catch {
|
|
4007
|
+
// Ignore cache set failures
|
|
4008
|
+
}
|
|
4009
|
+
}
|
|
4010
|
+
|
|
4011
|
+
cleanExpiredCache(): void {
|
|
4012
|
+
try {
|
|
4013
|
+
this.#deleteExpiredCacheStmt.run();
|
|
4014
|
+
} catch {
|
|
4015
|
+
// Ignore cleanup errors
|
|
4016
|
+
}
|
|
4017
|
+
}
|
|
4018
|
+
|
|
4019
|
+
// ─── Convenience methods for CLI ────────────────────────────────────────
|
|
4020
|
+
|
|
4021
|
+
/**
|
|
4022
|
+
* Save OAuth credentials for a provider.
|
|
4023
|
+
* Preserves unrelated identities and replaces only the matching credential.
|
|
4024
|
+
*/
|
|
4025
|
+
saveOAuth(provider: string, credentials: OAuthCredentials): void {
|
|
4026
|
+
const credential: AuthCredential = { type: "oauth", ...credentials };
|
|
4027
|
+
this.upsertAuthCredentialForProvider(provider, credential);
|
|
4028
|
+
}
|
|
4029
|
+
|
|
4030
|
+
/**
|
|
4031
|
+
* Get OAuth credentials for a provider.
|
|
4032
|
+
*/
|
|
4033
|
+
getOAuth(provider: string): OAuthCredentials | null {
|
|
4034
|
+
const rows = this.#listActiveByProviderStmt.all(provider) as AuthRow[];
|
|
4035
|
+
for (const row of rows) {
|
|
4036
|
+
const credential = deserializeCredential(row);
|
|
4037
|
+
if (credential && credential.type === "oauth") {
|
|
4038
|
+
const { type: _type, ...oauth } = credential;
|
|
4039
|
+
return oauth as OAuthCredentials;
|
|
4040
|
+
}
|
|
4041
|
+
}
|
|
4042
|
+
return null;
|
|
4043
|
+
}
|
|
4044
|
+
|
|
4045
|
+
/**
|
|
4046
|
+
* Save API key for a provider (replaces existing).
|
|
4047
|
+
*/
|
|
4048
|
+
saveApiKey(provider: string, apiKey: string): void {
|
|
4049
|
+
const credential: AuthCredential = { type: "api_key", key: apiKey };
|
|
4050
|
+
this.replaceAuthCredentialsForProvider(provider, [credential]);
|
|
4051
|
+
}
|
|
4052
|
+
|
|
4053
|
+
/**
|
|
4054
|
+
* Get API key for a provider.
|
|
4055
|
+
*/
|
|
4056
|
+
getApiKey(provider: string): string | null {
|
|
4057
|
+
const rows = this.#listActiveByProviderStmt.all(provider) as AuthRow[];
|
|
4058
|
+
for (const row of rows) {
|
|
4059
|
+
const credential = deserializeCredential(row);
|
|
4060
|
+
if (credential && credential.type === "api_key") {
|
|
4061
|
+
return credential.key;
|
|
4062
|
+
}
|
|
4063
|
+
}
|
|
4064
|
+
return null;
|
|
4065
|
+
}
|
|
4066
|
+
|
|
4067
|
+
/**
|
|
4068
|
+
* List all providers with credentials.
|
|
4069
|
+
*/
|
|
4070
|
+
listProviders(): string[] {
|
|
4071
|
+
const rows = this.#listActiveStmt.all() as AuthRow[];
|
|
4072
|
+
const providers = new Set<string>();
|
|
4073
|
+
for (const row of rows) {
|
|
4074
|
+
providers.add(row.provider);
|
|
4075
|
+
}
|
|
4076
|
+
return Array.from(providers);
|
|
4077
|
+
}
|
|
4078
|
+
|
|
4079
|
+
/**
|
|
4080
|
+
* Delete all credentials for a provider.
|
|
4081
|
+
*/
|
|
4082
|
+
deleteProvider(provider: string): void {
|
|
4083
|
+
this.deleteAuthCredentialsForProvider(provider, "deleted by user");
|
|
4084
|
+
}
|
|
4085
|
+
|
|
4086
|
+
close(): void {
|
|
4087
|
+
if (this.#closed) return;
|
|
4088
|
+
this.#closed = true;
|
|
4089
|
+
this.#listActiveStmt.finalize();
|
|
4090
|
+
this.#listActiveByProviderStmt.finalize();
|
|
4091
|
+
this.#listDisabledByProviderStmt.finalize();
|
|
4092
|
+
this.#insertStmt.finalize();
|
|
4093
|
+
this.#updateStmt.finalize();
|
|
4094
|
+
this.#deleteStmt.finalize();
|
|
4095
|
+
this.#deleteIfMatchesStmt.finalize();
|
|
4096
|
+
this.#deleteByProviderStmt.finalize();
|
|
4097
|
+
this.#hardDeleteStmt.finalize();
|
|
4098
|
+
this.#getCacheStmt.finalize();
|
|
4099
|
+
this.#getCacheIncludingExpiredStmt.finalize();
|
|
4100
|
+
this.#upsertCacheStmt.finalize();
|
|
4101
|
+
this.#deleteExpiredCacheStmt.finalize();
|
|
4102
|
+
this.#db.close();
|
|
4103
|
+
}
|
|
4104
|
+
}
|