@linxiraos/pi-ai 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +5066 -0
- package/README.md +1195 -0
- package/dist/types/api-registry.d.ts +30 -0
- package/dist/types/auth/sqlite-credential-store.d.ts +117 -0
- package/dist/types/auth-broker/client.d.ts +93 -0
- package/dist/types/auth-broker/discover.d.ts +39 -0
- package/dist/types/auth-broker/index.d.ts +7 -0
- package/dist/types/auth-broker/refresher.d.ts +25 -0
- package/dist/types/auth-broker/remote-store.d.ts +136 -0
- package/dist/types/auth-broker/server.d.ts +48 -0
- package/dist/types/auth-broker/snapshot-cache.d.ts +17 -0
- package/dist/types/auth-broker/types.d.ts +152 -0
- package/dist/types/auth-broker/wire-schema-resource.d.ts +53 -0
- package/dist/types/auth-broker/wire-schemas.d.ts +5 -0
- package/dist/types/auth-gateway/http.d.ts +56 -0
- package/dist/types/auth-gateway/index.d.ts +3 -0
- package/dist/types/auth-gateway/server.d.ts +36 -0
- package/dist/types/auth-gateway/types.d.ts +127 -0
- package/dist/types/auth-retry.d.ts +150 -0
- package/dist/types/auth-storage.d.ts +1258 -0
- package/dist/types/dialect/anthropic.d.ts +15 -0
- package/dist/types/dialect/catalog.d.ts +3 -0
- package/dist/types/dialect/coercion.d.ts +23 -0
- package/dist/types/dialect/deepseek.d.ts +14 -0
- package/dist/types/dialect/demotion.d.ts +30 -0
- package/dist/types/dialect/examples.d.ts +18 -0
- package/dist/types/dialect/factory.d.ts +3 -0
- package/dist/types/dialect/fenced-thinking.d.ts +53 -0
- package/dist/types/dialect/gemini.d.ts +17 -0
- package/dist/types/dialect/gemma.d.ts +15 -0
- package/dist/types/dialect/glm.d.ts +9 -0
- package/dist/types/dialect/harmony.d.ts +8 -0
- package/dist/types/dialect/hermes.d.ts +9 -0
- package/dist/types/dialect/history.d.ts +3 -0
- package/dist/types/dialect/index.d.ts +11 -0
- package/dist/types/dialect/inventory.d.ts +9 -0
- package/dist/types/dialect/kimi.d.ts +14 -0
- package/dist/types/dialect/minimax.d.ts +3 -0
- package/dist/types/dialect/owned-stream.d.ts +4 -0
- package/dist/types/dialect/qwen3.d.ts +9 -0
- package/dist/types/dialect/rendering.d.ts +54 -0
- package/dist/types/dialect/thinking.d.ts +6 -0
- package/dist/types/dialect/types.d.ts +68 -0
- package/dist/types/dialect/xml.d.ts +9 -0
- package/dist/types/error/abort.d.ts +14 -0
- package/dist/types/error/auth-classify.d.ts +20 -0
- package/dist/types/error/auth.d.ts +27 -0
- package/dist/types/error/aws.d.ts +27 -0
- package/dist/types/error/classes.d.ts +106 -0
- package/dist/types/error/finalize.d.ts +39 -0
- package/dist/types/error/flags.d.ts +94 -0
- package/dist/types/error/format.d.ts +20 -0
- package/dist/types/error/gateway.d.ts +20 -0
- package/dist/types/error/index.d.ts +14 -0
- package/dist/types/error/oauth.d.ts +43 -0
- package/dist/types/error/provider.d.ts +42 -0
- package/dist/types/error/rate-limit.d.ts +80 -0
- package/dist/types/error/retryable.d.ts +27 -0
- package/dist/types/error/validation.d.ts +32 -0
- package/dist/types/index.d.ts +51 -0
- package/dist/types/provider-details.d.ts +24 -0
- package/dist/types/providers/amazon-bedrock.d.ts +39 -0
- package/dist/types/providers/anthropic-client.d.ts +106 -0
- package/dist/types/providers/anthropic-messages-server-schema.d.ts +937 -0
- package/dist/types/providers/anthropic-messages-server.d.ts +17 -0
- package/dist/types/providers/anthropic-wire.d.ts +345 -0
- package/dist/types/providers/anthropic.d.ts +261 -0
- package/dist/types/providers/aws-credentials.d.ts +48 -0
- package/dist/types/providers/aws-eventstream.d.ts +39 -0
- package/dist/types/providers/aws-sigv4.d.ts +55 -0
- package/dist/types/providers/azure-openai-responses.d.ts +16 -0
- package/dist/types/providers/bedrock-mantle.d.ts +13 -0
- package/dist/types/providers/claude-code-fingerprint.d.ts +19 -0
- package/dist/types/providers/cowork-fetch.d.ts +3 -0
- package/dist/types/providers/cursor/exec-modern.d.ts +98 -0
- package/dist/types/providers/cursor-pi-args.d.ts +105 -0
- package/dist/types/providers/cursor.d.ts +221 -0
- package/dist/types/providers/devin.d.ts +12 -0
- package/dist/types/providers/error-message.d.ts +25 -0
- package/dist/types/providers/github-copilot-headers.d.ts +40 -0
- package/dist/types/providers/gitlab-duo-workflow.d.ts +254 -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 +120 -0
- package/dist/types/providers/google-shared.d.ts +203 -0
- package/dist/types/providers/google-types.d.ts +155 -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 +179 -0
- package/dist/types/providers/ollama.d.ts +8 -0
- package/dist/types/providers/openai-anthropic-shim.d.ts +35 -0
- package/dist/types/providers/openai-chat-server-schema.d.ts +1311 -0
- package/dist/types/providers/openai-chat-server.d.ts +16 -0
- package/dist/types/providers/openai-chat-wire.d.ts +669 -0
- package/dist/types/providers/openai-codex/request-transformer.d.ts +109 -0
- package/dist/types/providers/openai-codex/response-handler.d.ts +26 -0
- package/dist/types/providers/openai-codex-responses.d.ts +238 -0
- package/dist/types/providers/openai-completions.d.ts +48 -0
- package/dist/types/providers/openai-reasoning-fallback.d.ts +25 -0
- package/dist/types/providers/openai-responses-server-schema.d.ts +1314 -0
- package/dist/types/providers/openai-responses-server.d.ts +17 -0
- package/dist/types/providers/openai-responses-wire.d.ts +6099 -0
- package/dist/types/providers/openai-responses.d.ts +131 -0
- package/dist/types/providers/openai-shared.d.ts +624 -0
- package/dist/types/providers/pi-native-client.d.ts +13 -0
- package/dist/types/providers/pi-native-server.d.ts +69 -0
- package/dist/types/providers/register-builtins.d.ts +37 -0
- package/dist/types/providers/synthetic.d.ts +26 -0
- package/dist/types/providers/transform-messages.d.ts +32 -0
- package/dist/types/providers/vision-guard.d.ts +20 -0
- package/dist/types/registry/aiand.d.ts +7 -0
- package/dist/types/registry/aimlapi.d.ts +4 -0
- package/dist/types/registry/alibaba-coding-plan.d.ts +8 -0
- package/dist/types/registry/alibaba-token-plan.d.ts +18 -0
- package/dist/types/registry/amazon-bedrock.d.ts +5 -0
- package/dist/types/registry/anthropic.d.ts +10 -0
- package/dist/types/registry/api-key-login.d.ts +42 -0
- package/dist/types/registry/api-key-validation.d.ts +43 -0
- package/dist/types/registry/aws.d.ts +13 -0
- package/dist/types/registry/azure.d.ts +4 -0
- package/dist/types/registry/baseten.d.ts +7 -0
- package/dist/types/registry/bedrock-mantle.d.ts +22 -0
- package/dist/types/registry/cerebras.d.ts +7 -0
- package/dist/types/registry/cloudflare-ai-gateway.d.ts +13 -0
- package/dist/types/registry/coreweave.d.ts +7 -0
- package/dist/types/registry/cursor.d.ts +7 -0
- package/dist/types/registry/deepseek.d.ts +8 -0
- package/dist/types/registry/derived.d.ts +5 -0
- package/dist/types/registry/devin.d.ts +8 -0
- package/dist/types/registry/exa.d.ts +8 -0
- package/dist/types/registry/firepass.d.ts +16 -0
- package/dist/types/registry/fireworks.d.ts +7 -0
- package/dist/types/registry/github-copilot.d.ts +7 -0
- package/dist/types/registry/gitlab-duo-workflow.d.ts +10 -0
- package/dist/types/registry/gitlab-duo.d.ts +9 -0
- package/dist/types/registry/gmi-cloud.d.ts +7 -0
- package/dist/types/registry/google-antigravity.d.ts +9 -0
- package/dist/types/registry/google-gemini-cli.d.ts +9 -0
- package/dist/types/registry/google-vertex.d.ts +5 -0
- package/dist/types/registry/google.d.ts +4 -0
- package/dist/types/registry/groq.d.ts +4 -0
- package/dist/types/registry/huggingface.d.ts +7 -0
- package/dist/types/registry/index.d.ts +4 -0
- package/dist/types/registry/kagi.d.ts +14 -0
- package/dist/types/registry/kilo.d.ts +7 -0
- package/dist/types/registry/kimi-code.d.ts +7 -0
- package/dist/types/registry/litellm.d.ts +13 -0
- package/dist/types/registry/llama-cpp.d.ts +8 -0
- package/dist/types/registry/lm-studio.d.ts +8 -0
- package/dist/types/registry/meta.d.ts +7 -0
- package/dist/types/registry/minimax-code-cn.d.ts +6 -0
- package/dist/types/registry/minimax-code.d.ts +6 -0
- package/dist/types/registry/minimax.d.ts +4 -0
- package/dist/types/registry/mistral.d.ts +4 -0
- package/dist/types/registry/moonshot.d.ts +7 -0
- package/dist/types/registry/nanogpt.d.ts +7 -0
- package/dist/types/registry/novita.d.ts +6 -0
- package/dist/types/registry/nvidia.d.ts +7 -0
- package/dist/types/registry/oauth/anthropic-constants.d.ts +12 -0
- package/dist/types/registry/oauth/anthropic.d.ts +24 -0
- package/dist/types/registry/oauth/callback-server.d.ts +74 -0
- package/dist/types/registry/oauth/cursor.d.ts +16 -0
- package/dist/types/registry/oauth/device-code.d.ts +25 -0
- package/dist/types/registry/oauth/devin.d.ts +5 -0
- package/dist/types/registry/oauth/github-copilot.d.ts +30 -0
- package/dist/types/registry/oauth/gitlab-duo-workflow.d.ts +6 -0
- package/dist/types/registry/oauth/gitlab-duo.d.ts +3 -0
- package/dist/types/registry/oauth/google-antigravity.d.ts +11 -0
- package/dist/types/registry/oauth/google-gemini-cli.d.ts +22 -0
- package/dist/types/registry/oauth/google-oauth-shared.d.ts +56 -0
- package/dist/types/registry/oauth/index.d.ts +45 -0
- package/dist/types/registry/oauth/kimi.d.ts +21 -0
- package/dist/types/registry/oauth/minimax-code.d.ts +27 -0
- package/dist/types/registry/oauth/openai-codex.d.ts +33 -0
- package/dist/types/registry/oauth/opencode.d.ts +18 -0
- package/dist/types/registry/oauth/perplexity.d.ts +9 -0
- package/dist/types/registry/oauth/pkce.d.ts +8 -0
- package/dist/types/registry/oauth/types.d.ts +87 -0
- package/dist/types/registry/oauth/wafer.d.ts +1 -0
- package/dist/types/registry/oauth/xai-oauth.d.ts +46 -0
- package/dist/types/registry/oauth/xiaomi.d.ts +25 -0
- package/dist/types/registry/oauth/zai.d.ts +25 -0
- package/dist/types/registry/ollama-cloud.d.ts +7 -0
- package/dist/types/registry/ollama.d.ts +12 -0
- package/dist/types/registry/openai-codex-device.d.ts +8 -0
- package/dist/types/registry/openai-codex.d.ts +9 -0
- package/dist/types/registry/openai.d.ts +4 -0
- package/dist/types/registry/opencode-go.d.ts +6 -0
- package/dist/types/registry/opencode-zen.d.ts +6 -0
- package/dist/types/registry/openrouter.d.ts +13 -0
- package/dist/types/registry/parallel.d.ts +14 -0
- package/dist/types/registry/perplexity.d.ts +7 -0
- package/dist/types/registry/qianfan.d.ts +7 -0
- package/dist/types/registry/qwen-portal.d.ts +7 -0
- package/dist/types/registry/registry.d.ts +367 -0
- package/dist/types/registry/sakana.d.ts +7 -0
- package/dist/types/registry/siliconflow-cn.d.ts +7 -0
- package/dist/types/registry/siliconflow.d.ts +7 -0
- package/dist/types/registry/synthetic.d.ts +6 -0
- package/dist/types/registry/tavily.d.ts +14 -0
- package/dist/types/registry/together.d.ts +6 -0
- package/dist/types/registry/types.d.ts +75 -0
- package/dist/types/registry/umans.d.ts +7 -0
- package/dist/types/registry/venice.d.ts +13 -0
- package/dist/types/registry/vercel-ai-gateway.d.ts +7 -0
- package/dist/types/registry/vllm.d.ts +7 -0
- package/dist/types/registry/wafer-serverless.d.ts +6 -0
- package/dist/types/registry/xai-oauth.d.ts +7 -0
- package/dist/types/registry/xai.d.ts +7 -0
- package/dist/types/registry/xiaomi-token-plan-ams.d.ts +6 -0
- package/dist/types/registry/xiaomi-token-plan-cn.d.ts +6 -0
- package/dist/types/registry/xiaomi-token-plan-sgp.d.ts +6 -0
- package/dist/types/registry/xiaomi.d.ts +6 -0
- package/dist/types/registry/zai.d.ts +15 -0
- package/dist/types/registry/zenmux.d.ts +7 -0
- package/dist/types/registry/zhipu-coding-plan.d.ts +7 -0
- package/dist/types/stream.d.ts +46 -0
- package/dist/types/types.d.ts +1064 -0
- package/dist/types/usage/alibaba-token-plan.d.ts +3 -0
- package/dist/types/usage/claude.d.ts +4 -0
- package/dist/types/usage/cursor.d.ts +4 -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 +15 -0
- package/dist/types/usage/kimi.d.ts +2 -0
- package/dist/types/usage/minimax-code.d.ts +3 -0
- package/dist/types/usage/ollama.d.ts +5 -0
- package/dist/types/usage/openai-codex-base-url.d.ts +18 -0
- package/dist/types/usage/openai-codex-reset.d.ts +88 -0
- package/dist/types/usage/openai-codex.d.ts +10 -0
- package/dist/types/usage/opencode-go.d.ts +2 -0
- package/dist/types/usage/shared.d.ts +1 -0
- package/dist/types/usage/synthetic.d.ts +2 -0
- package/dist/types/usage/umans.d.ts +2 -0
- package/dist/types/usage/xai-oauth.d.ts +12 -0
- package/dist/types/usage/zai.d.ts +3 -0
- package/dist/types/usage.d.ts +527 -0
- package/dist/types/utils/abort.d.ts +25 -0
- package/dist/types/utils/anthropic-auth.d.ts +35 -0
- package/dist/types/utils/aws-profile.d.ts +17 -0
- package/dist/types/utils/block-symbols.d.ts +62 -0
- package/dist/types/utils/deterministic-id.d.ts +16 -0
- package/dist/types/utils/empty-completion-retry.d.ts +21 -0
- package/dist/types/utils/event-stream.d.ts +39 -0
- package/dist/types/utils/foundry.d.ts +1 -0
- package/dist/types/utils/google-validation.d.ts +2 -0
- package/dist/types/utils/harmony-leak.d.ts +135 -0
- package/dist/types/utils/http-inspector.d.ts +49 -0
- package/dist/types/utils/idle-iterator.d.ts +149 -0
- package/dist/types/utils/leaked-thinking-stream.d.ts +33 -0
- package/dist/types/utils/openai-http.d.ts +48 -0
- package/dist/types/utils/openrouter-headers.d.ts +1 -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/proxy.d.ts +39 -0
- package/dist/types/utils/request-debug.d.ts +29 -0
- package/dist/types/utils/retry-after.d.ts +4 -0
- package/dist/types/utils/retry.d.ts +14 -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 +54 -0
- package/dist/types/utils/schema/index.d.ts +14 -0
- package/dist/types/utils/schema/json-schema-validator.d.ts +20 -0
- package/dist/types/utils/schema/meta-validator.d.ts +2 -0
- package/dist/types/utils/schema/normalize.d.ts +153 -0
- package/dist/types/utils/schema/spill.d.ts +8 -0
- package/dist/types/utils/schema/stamps.d.ts +17 -0
- package/dist/types/utils/schema/strict-tool-validation.d.ts +16 -0
- package/dist/types/utils/schema/types.d.ts +4 -0
- package/dist/types/utils/schema/typescript.d.ts +24 -0
- package/dist/types/utils/schema/wire.d.ts +52 -0
- package/dist/types/utils/sdk-stream-timeout.d.ts +33 -0
- package/dist/types/utils/sse-debug.d.ts +5 -0
- package/dist/types/utils/stream-markup-healing.d.ts +87 -0
- package/dist/types/utils/thinking-loop.d.ts +102 -0
- package/dist/types/utils/tool-call-loop-guard.d.ts +26 -0
- package/dist/types/utils/tool-choice.d.ts +52 -0
- package/dist/types/utils/validation.d.ts +28 -0
- package/dist/types/utils.d.ts +57 -0
- package/package.json +138 -0
- package/src/api-registry.ts +109 -0
- package/src/auth/sqlite-credential-store.ts +2066 -0
- package/src/auth-broker/client.ts +471 -0
- package/src/auth-broker/discover.ts +310 -0
- package/src/auth-broker/index.ts +7 -0
- package/src/auth-broker/refresher.ts +117 -0
- package/src/auth-broker/remote-store.ts +1332 -0
- package/src/auth-broker/server.ts +898 -0
- package/src/auth-broker/snapshot-cache.ts +200 -0
- package/src/auth-broker/types.ts +193 -0
- package/src/auth-broker/wire-schema-resource.ts +487 -0
- package/src/auth-broker/wire-schemas.ts +43 -0
- package/src/auth-gateway/http.ts +227 -0
- package/src/auth-gateway/index.ts +3 -0
- package/src/auth-gateway/server.ts +836 -0
- package/src/auth-gateway/types.ts +153 -0
- package/src/auth-retry.ts +401 -0
- package/src/auth-storage.ts +6540 -0
- package/src/dialect/anthropic.md +31 -0
- package/src/dialect/anthropic.ts +608 -0
- package/src/dialect/catalog.ts +29 -0
- package/src/dialect/coercion.ts +136 -0
- package/src/dialect/deepseek.md +24 -0
- package/src/dialect/deepseek.ts +609 -0
- package/src/dialect/demotion.ts +40 -0
- package/src/dialect/examples.ts +71 -0
- package/src/dialect/factory.ts +34 -0
- package/src/dialect/fenced-thinking.ts +184 -0
- package/src/dialect/gemini.md +44 -0
- package/src/dialect/gemini.ts +583 -0
- package/src/dialect/gemma.md +33 -0
- package/src/dialect/gemma.ts +387 -0
- package/src/dialect/glm.md +32 -0
- package/src/dialect/glm.ts +579 -0
- package/src/dialect/harmony.md +31 -0
- package/src/dialect/harmony.ts +345 -0
- package/src/dialect/hermes.md +25 -0
- package/src/dialect/hermes.ts +206 -0
- package/src/dialect/history.ts +81 -0
- package/src/dialect/index.ts +15 -0
- package/src/dialect/inventory.ts +30 -0
- package/src/dialect/kimi.md +24 -0
- package/src/dialect/kimi.ts +340 -0
- package/src/dialect/minimax.md +31 -0
- package/src/dialect/minimax.ts +95 -0
- package/src/dialect/owned-stream.ts +481 -0
- package/src/dialect/prompt-template.md +12 -0
- package/src/dialect/qwen3.md +28 -0
- package/src/dialect/qwen3.ts +240 -0
- package/src/dialect/rendering.ts +304 -0
- package/src/dialect/thinking.ts +292 -0
- package/src/dialect/types.ts +56 -0
- package/src/dialect/xml.md +22 -0
- package/src/dialect/xml.ts +90 -0
- package/src/error/abort.ts +18 -0
- package/src/error/auth-classify.ts +47 -0
- package/src/error/auth.ts +48 -0
- package/src/error/aws.ts +35 -0
- package/src/error/classes.ts +281 -0
- package/src/error/finalize.ts +69 -0
- package/src/error/flags.ts +602 -0
- package/src/error/format.ts +45 -0
- package/src/error/gateway.ts +96 -0
- package/src/error/index.ts +14 -0
- package/src/error/oauth.ts +58 -0
- package/src/error/provider.ts +63 -0
- package/src/error/rate-limit.ts +303 -0
- package/src/error/retryable.ts +70 -0
- package/src/error/validation.ts +44 -0
- package/src/index.ts +51 -0
- package/src/provider-details.ts +90 -0
- package/src/providers/amazon-bedrock.ts +1064 -0
- package/src/providers/anthropic-client.ts +317 -0
- package/src/providers/anthropic-messages-server-schema.ts +252 -0
- package/src/providers/anthropic-messages-server.ts +818 -0
- package/src/providers/anthropic-wire.ts +359 -0
- package/src/providers/anthropic.ts +4539 -0
- package/src/providers/aws-credentials.ts +772 -0
- package/src/providers/aws-eventstream.ts +181 -0
- package/src/providers/aws-sigv4.ts +218 -0
- package/src/providers/azure-openai-responses.ts +438 -0
- package/src/providers/bedrock-mantle.ts +110 -0
- package/src/providers/claude-code-fingerprint.ts +20 -0
- package/src/providers/cowork-fetch.ts +201 -0
- package/src/providers/cursor/exec-modern.ts +496 -0
- package/src/providers/cursor/proto/agent.proto +4533 -0
- package/src/providers/cursor/proto/buf.gen.yaml +6 -0
- package/src/providers/cursor/proto/buf.yaml +17 -0
- package/src/providers/cursor-pi-args.ts +165 -0
- package/src/providers/cursor.ts +4689 -0
- package/src/providers/devin/proto/buf/validate/validate.proto +468 -0
- package/src/providers/devin/proto/buf.gen.yaml +33 -0
- package/src/providers/devin/proto/buf.yaml +17 -0
- package/src/providers/devin/proto/cel/expr/checked.proto +103 -0
- package/src/providers/devin/proto/cel/expr/eval.proto +38 -0
- package/src/providers/devin/proto/cel/expr/explain.proto +15 -0
- package/src/providers/devin/proto/cel/expr/syntax.proto +113 -0
- package/src/providers/devin/proto/cel/expr/value.proto +41 -0
- package/src/providers/devin/proto/connectext/grpc/status/v1/status.proto +11 -0
- package/src/providers/devin/proto/errorspb/errors.proto +56 -0
- package/src/providers/devin/proto/errorspb/hintdetail.proto +7 -0
- package/src/providers/devin/proto/errorspb/markers.proto +10 -0
- package/src/providers/devin/proto/errorspb/tags.proto +12 -0
- package/src/providers/devin/proto/errorspb/testing.proto +6 -0
- package/src/providers/devin/proto/exa/analytics_pb/analytics.proto +188 -0
- package/src/providers/devin/proto/exa/api_server_pb/api_server.proto +2461 -0
- package/src/providers/devin/proto/exa/auth_pb/auth.proto +19 -0
- package/src/providers/devin/proto/exa/auto_cascade_common_pb/auto_cascade_common.proto +79 -0
- package/src/providers/devin/proto/exa/browser_preview_pb/browser_preview.proto +32 -0
- package/src/providers/devin/proto/exa/bug_checker_pb/bug_checker.proto +22 -0
- package/src/providers/devin/proto/exa/cascade_plugins_pb/cascade_plugins.proto +262 -0
- package/src/providers/devin/proto/exa/chat_client_server_pb/chat_client_server.proto +57 -0
- package/src/providers/devin/proto/exa/chat_pb/chat.proto +449 -0
- package/src/providers/devin/proto/exa/code_edit/code_edit_pb/code_edit.proto +186 -0
- package/src/providers/devin/proto/exa/codeium_common_pb/codeium_common.proto +4157 -0
- package/src/providers/devin/proto/exa/context_module_pb/context_module.proto +175 -0
- package/src/providers/devin/proto/exa/cortex_pb/cortex.proto +3268 -0
- package/src/providers/devin/proto/exa/dev_pb/dev.proto +26 -0
- package/src/providers/devin/proto/exa/diff_action_pb/diff_action.proto +75 -0
- package/src/providers/devin/proto/exa/eval/pr_eval/datasets_pb/datasets.proto +103 -0
- package/src/providers/devin/proto/exa/eval_pb/eval.proto +1315 -0
- package/src/providers/devin/proto/exa/extension_server_pb/extension_server.proto +556 -0
- package/src/providers/devin/proto/exa/file_system_provider_pb/file_system_provider.proto +75 -0
- package/src/providers/devin/proto/exa/index_pb/index.proto +461 -0
- package/src/providers/devin/proto/exa/knowledge_base_pb/knowledge_base.proto +144 -0
- package/src/providers/devin/proto/exa/language_server_pb/language_server.proto +2385 -0
- package/src/providers/devin/proto/exa/model_management_pb/model_management.proto +186 -0
- package/src/providers/devin/proto/exa/opensearch_clients_pb/opensearch_clients.proto +503 -0
- package/src/providers/devin/proto/exa/product_analytics_pb/product_analytics.proto +37 -0
- package/src/providers/devin/proto/exa/prompt_pb/prompt.proto +92 -0
- package/src/providers/devin/proto/exa/reactive_component_pb/reactive_component.proto +96 -0
- package/src/providers/devin/proto/exa/seat_management_pb/seat_management.proto +2680 -0
- package/src/providers/devin/proto/exa/tokenizer_pb/tokenizer.proto +37 -0
- package/src/providers/devin/proto/exa/trainer_pb/config.proto +647 -0
- package/src/providers/devin/proto/exa/tree_sitter/language_data_pb/language_data.proto +14 -0
- package/src/providers/devin/proto/exa/trust_pb/trust.proto +157 -0
- package/src/providers/devin/proto/exa/user_analytics_pb/user_analytics.proto +519 -0
- package/src/providers/devin/proto/google.golang.org/appengine/internal/base/api_base.proto +28 -0
- package/src/providers/devin/proto/google.golang.org/appengine/internal/datastore/datastore_v3.proto +484 -0
- package/src/providers/devin/proto/google.golang.org/appengine/internal/log/log_service.proto +136 -0
- package/src/providers/devin/proto/google.golang.org/appengine/internal/remote_api/remote_api.proto +42 -0
- package/src/providers/devin/proto/google.golang.org/appengine/internal/urlfetch/urlfetch_service.proto +61 -0
- package/src/providers/devin/proto/grpc/binlog/v1/binarylog.proto +84 -0
- package/src/providers/devin/proto/io/prometheus/client/metrics.proto +98 -0
- package/src/providers/devin.ts +679 -0
- package/src/providers/error-message.ts +23 -0
- package/src/providers/github-copilot-headers.ts +141 -0
- package/src/providers/gitlab-duo-workflow-chatml-note.md +1 -0
- package/src/providers/gitlab-duo-workflow.ts +3135 -0
- package/src/providers/gitlab-duo.ts +399 -0
- package/src/providers/google-auth.ts +330 -0
- package/src/providers/google-gemini-cli.ts +1370 -0
- package/src/providers/google-shared.ts +1122 -0
- package/src/providers/google-types.ts +180 -0
- package/src/providers/google-vertex.ts +135 -0
- package/src/providers/google.ts +47 -0
- package/src/providers/grammar.ts +70 -0
- package/src/providers/kimi.ts +51 -0
- package/src/providers/mock.ts +514 -0
- package/src/providers/ollama.ts +776 -0
- package/src/providers/openai-anthropic-shim.ts +166 -0
- package/src/providers/openai-chat-server-schema.ts +243 -0
- package/src/providers/openai-chat-server.ts +752 -0
- package/src/providers/openai-chat-wire.ts +859 -0
- package/src/providers/openai-codex/request-transformer.ts +491 -0
- package/src/providers/openai-codex/response-handler.ts +102 -0
- package/src/providers/openai-codex-responses.ts +4716 -0
- package/src/providers/openai-completions.ts +2389 -0
- package/src/providers/openai-reasoning-fallback.ts +269 -0
- package/src/providers/openai-responses-server-schema.ts +397 -0
- package/src/providers/openai-responses-server.ts +1466 -0
- package/src/providers/openai-responses-wire.ts +6416 -0
- package/src/providers/openai-responses.ts +1393 -0
- package/src/providers/openai-shared.ts +3500 -0
- package/src/providers/pi-native-client.ts +275 -0
- package/src/providers/pi-native-server.ts +245 -0
- package/src/providers/register-builtins.ts +503 -0
- package/src/providers/synthetic.ts +50 -0
- package/src/providers/transform-messages.ts +1083 -0
- package/src/providers/vision-guard.ts +54 -0
- package/src/registry/aiand.ts +22 -0
- package/src/registry/aimlapi.ts +6 -0
- package/src/registry/alibaba-coding-plan.ts +104 -0
- package/src/registry/alibaba-token-plan.ts +125 -0
- package/src/registry/amazon-bedrock.ts +22 -0
- package/src/registry/anthropic.ts +26 -0
- package/src/registry/api-key-login.ts +115 -0
- package/src/registry/api-key-validation.ts +145 -0
- package/src/registry/aws.ts +57 -0
- package/src/registry/azure.ts +6 -0
- package/src/registry/baseten.ts +22 -0
- package/src/registry/bedrock-mantle.ts +34 -0
- package/src/registry/cerebras.ts +23 -0
- package/src/registry/cloudflare-ai-gateway.ts +45 -0
- package/src/registry/coreweave.ts +40 -0
- package/src/registry/cursor.ts +20 -0
- package/src/registry/deepseek.ts +46 -0
- package/src/registry/derived.ts +9 -0
- package/src/registry/devin.ts +15 -0
- package/src/registry/exa.ts +19 -0
- package/src/registry/firepass.ts +32 -0
- package/src/registry/fireworks.ts +28 -0
- package/src/registry/github-copilot.ts +22 -0
- package/src/registry/gitlab-duo-workflow.ts +20 -0
- package/src/registry/gitlab-duo.ts +19 -0
- package/src/registry/gmi-cloud.ts +22 -0
- package/src/registry/google-antigravity.ts +22 -0
- package/src/registry/google-gemini-cli.ts +22 -0
- package/src/registry/google-vertex.ts +38 -0
- package/src/registry/google.ts +6 -0
- package/src/registry/groq.ts +6 -0
- package/src/registry/huggingface.ts +29 -0
- package/src/registry/index.ts +4 -0
- package/src/registry/kagi.ts +46 -0
- package/src/registry/kilo.ts +114 -0
- package/src/registry/kimi-code.ts +17 -0
- package/src/registry/litellm.ts +45 -0
- package/src/registry/llama-cpp.ts +35 -0
- package/src/registry/lm-studio.ts +31 -0
- package/src/registry/meta.ts +22 -0
- package/src/registry/minimax-code-cn.ts +12 -0
- package/src/registry/minimax-code.ts +12 -0
- package/src/registry/minimax.ts +6 -0
- package/src/registry/mistral.ts +6 -0
- package/src/registry/moonshot.ts +28 -0
- package/src/registry/nanogpt.ts +22 -0
- package/src/registry/novita.ts +25 -0
- package/src/registry/nvidia.ts +61 -0
- package/src/registry/oauth/anthropic-constants.ts +12 -0
- package/src/registry/oauth/anthropic.ts +346 -0
- package/src/registry/oauth/callback-server.ts +438 -0
- package/src/registry/oauth/cursor.ts +187 -0
- package/src/registry/oauth/device-code.ts +92 -0
- package/src/registry/oauth/devin.ts +124 -0
- package/src/registry/oauth/github-copilot.ts +369 -0
- package/src/registry/oauth/gitlab-duo-workflow.ts +146 -0
- package/src/registry/oauth/gitlab-duo.ts +222 -0
- package/src/registry/oauth/google-antigravity.ts +225 -0
- package/src/registry/oauth/google-gemini-cli.ts +297 -0
- package/src/registry/oauth/google-oauth-shared.ts +211 -0
- package/src/registry/oauth/index.ts +187 -0
- package/src/registry/oauth/kimi.ts +297 -0
- package/src/registry/oauth/minimax-code.ts +53 -0
- package/src/registry/oauth/oauth.html +317 -0
- package/src/registry/oauth/openai-codex.ts +384 -0
- package/src/registry/oauth/opencode.ts +50 -0
- package/src/registry/oauth/perplexity.ts +228 -0
- package/src/registry/oauth/pkce.ts +18 -0
- package/src/registry/oauth/types.ts +96 -0
- package/src/registry/oauth/wafer.ts +24 -0
- package/src/registry/oauth/xai-oauth.ts +559 -0
- package/src/registry/oauth/xiaomi.ts +211 -0
- package/src/registry/oauth/zai.ts +285 -0
- package/src/registry/ollama-cloud.ts +36 -0
- package/src/registry/ollama.ts +43 -0
- package/src/registry/openai-codex-device.ts +18 -0
- package/src/registry/openai-codex.ts +19 -0
- package/src/registry/openai.ts +6 -0
- package/src/registry/opencode-go.ts +12 -0
- package/src/registry/opencode-zen.ts +12 -0
- package/src/registry/openrouter.ts +28 -0
- package/src/registry/parallel.ts +45 -0
- package/src/registry/perplexity.ts +13 -0
- package/src/registry/qianfan.ts +27 -0
- package/src/registry/qwen-portal.ts +50 -0
- package/src/registry/registry.ts +182 -0
- package/src/registry/sakana.ts +22 -0
- package/src/registry/siliconflow-cn.ts +22 -0
- package/src/registry/siliconflow.ts +22 -0
- package/src/registry/synthetic.ts +21 -0
- package/src/registry/tavily.ts +45 -0
- package/src/registry/together.ts +22 -0
- package/src/registry/types.ts +86 -0
- package/src/registry/umans.ts +23 -0
- package/src/registry/venice.ts +33 -0
- package/src/registry/vercel-ai-gateway.ts +38 -0
- package/src/registry/vllm.ts +34 -0
- package/src/registry/wafer-serverless.ts +12 -0
- package/src/registry/xai-oauth.ts +17 -0
- package/src/registry/xai.ts +22 -0
- package/src/registry/xiaomi-token-plan-ams.ts +12 -0
- package/src/registry/xiaomi-token-plan-cn.ts +12 -0
- package/src/registry/xiaomi-token-plan-sgp.ts +12 -0
- package/src/registry/xiaomi.ts +12 -0
- package/src/registry/zai.ts +41 -0
- package/src/registry/zenmux.ts +22 -0
- package/src/registry/zhipu-coding-plan.ts +27 -0
- package/src/stream.ts +1944 -0
- package/src/types.ts +1243 -0
- package/src/usage/alibaba-token-plan.ts +230 -0
- package/src/usage/claude.ts +830 -0
- package/src/usage/cursor.ts +335 -0
- package/src/usage/gemini.ts +258 -0
- package/src/usage/github-copilot.ts +424 -0
- package/src/usage/google-antigravity.ts +497 -0
- package/src/usage/kimi.ts +277 -0
- package/src/usage/minimax-code.ts +291 -0
- package/src/usage/ollama.ts +41 -0
- package/src/usage/openai-codex-base-url.ts +35 -0
- package/src/usage/openai-codex-reset.ts +205 -0
- package/src/usage/openai-codex.ts +627 -0
- package/src/usage/opencode-go.ts +89 -0
- package/src/usage/shared.ts +10 -0
- package/src/usage/synthetic.ts +180 -0
- package/src/usage/umans.ts +192 -0
- package/src/usage/xai-oauth.ts +414 -0
- package/src/usage/zai.ts +370 -0
- package/src/usage.ts +411 -0
- package/src/utils/abort.ts +67 -0
- package/src/utils/anthropic-auth.ts +93 -0
- package/src/utils/aws-profile.ts +88 -0
- package/src/utils/block-symbols.ts +78 -0
- package/src/utils/deterministic-id.ts +20 -0
- package/src/utils/empty-completion-retry.ts +161 -0
- package/src/utils/event-stream.ts +202 -0
- package/src/utils/foundry.ts +8 -0
- package/src/utils/google-validation.ts +25 -0
- package/src/utils/harmony-leak.ts +500 -0
- package/src/utils/http-inspector.ts +196 -0
- package/src/utils/idle-iterator.ts +531 -0
- package/src/utils/leaked-thinking-stream.ts +483 -0
- package/src/utils/openai-http.ts +119 -0
- package/src/utils/openrouter-headers.ts +12 -0
- package/src/utils/parse-bind.ts +56 -0
- package/src/utils/provider-response.ts +30 -0
- package/src/utils/proxy.ts +314 -0
- package/src/utils/request-debug.ts +351 -0
- package/src/utils/retry-after.ts +121 -0
- package/src/utils/retry.ts +77 -0
- package/src/utils/schema/CONSTRAINTS.md +168 -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 +210 -0
- package/src/utils/schema/index.ts +14 -0
- package/src/utils/schema/json-schema-validator.ts +595 -0
- package/src/utils/schema/meta-validator.ts +167 -0
- package/src/utils/schema/normalize.ts +2314 -0
- package/src/utils/schema/spill.ts +43 -0
- package/src/utils/schema/stamps.ts +109 -0
- package/src/utils/schema/strict-tool-validation.ts +117 -0
- package/src/utils/schema/types.ts +10 -0
- package/src/utils/schema/typescript.ts +212 -0
- package/src/utils/schema/wire.ts +662 -0
- package/src/utils/sdk-stream-timeout.ts +43 -0
- package/src/utils/sse-debug.ts +18 -0
- package/src/utils/stream-markup-healing.ts +247 -0
- package/src/utils/thinking-loop.ts +552 -0
- package/src/utils/tool-call-loop-guard.ts +107 -0
- package/src/utils/tool-choice.ts +101 -0
- package/src/utils/validation.ts +1932 -0
- package/src/utils.ts +492 -0
|
@@ -0,0 +1,2066 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SQLite-backed credential persistence for AuthStorage.
|
|
3
|
+
*
|
|
4
|
+
* The public AuthCredentialStore interface remains in ../auth-storage so local
|
|
5
|
+
* and remote stores share the same contract.
|
|
6
|
+
*/
|
|
7
|
+
import { Database, type Statement } from "bun:sqlite";
|
|
8
|
+
import * as fs from "node:fs/promises";
|
|
9
|
+
import * as path from "node:path";
|
|
10
|
+
import { parseAlibabaTokenPlanCredential } from "@linxiraos/pi-catalog/wire/alibaba-token-plan";
|
|
11
|
+
import { getAgentDbPath, getDbBusyTimeoutMs, logger } from "@linxiraos/pi-utils";
|
|
12
|
+
import type {
|
|
13
|
+
AuthCredential,
|
|
14
|
+
AuthCredentialStore,
|
|
15
|
+
CredentialRefreshLeaseFence,
|
|
16
|
+
DisabledCredentialSummary,
|
|
17
|
+
OAuthCredential,
|
|
18
|
+
StoredAuthCredential,
|
|
19
|
+
StoredCredentialBlock,
|
|
20
|
+
} from "../auth-storage";
|
|
21
|
+
import * as AIError from "../error";
|
|
22
|
+
import type { OAuthCredentials } from "../registry/oauth/types";
|
|
23
|
+
import type { Provider } from "../types";
|
|
24
|
+
import type {
|
|
25
|
+
ClientProviderUsage,
|
|
26
|
+
ClientUsageReport,
|
|
27
|
+
ClientUsageSummary,
|
|
28
|
+
UsageCostHistoryEntry,
|
|
29
|
+
UsageCostHistoryQuery,
|
|
30
|
+
UsageHistoryEntry,
|
|
31
|
+
UsageHistoryQuery,
|
|
32
|
+
} from "../usage";
|
|
33
|
+
|
|
34
|
+
// 5 min stale tolerance. Anthropic / OpenAI rate-limit /usage hard at the IP
|
|
35
|
+
// level so we can't fetch all N credentials every cycle; with a long cache
|
|
36
|
+
// each credential's last-known value sticks visible while peers retry. UI
|
|
37
|
+
// data (5h / 7d / monthly limits) is fine being a few minutes stale.
|
|
38
|
+
export const USAGE_REPORT_TTL_MS = 5 * 60_000;
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Downsample usage history to at most one row per hour per account window: a
|
|
42
|
+
* snapshot landing in the same hour bucket as the series' latest row
|
|
43
|
+
* overwrites it in place. That bound makes further retention pruning
|
|
44
|
+
* unnecessary — 1 row/hour is ~9k rows per account window per year.
|
|
45
|
+
*/
|
|
46
|
+
const USAGE_HISTORY_BUCKET_MS = 60 * 60_000;
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Merge client observed-usage flushes into at most one row per 5 minutes per
|
|
50
|
+
* (install, provider, model): ~300 rows/day per active model per client
|
|
51
|
+
* instead of one row per 10s flush.
|
|
52
|
+
*/
|
|
53
|
+
const CLIENT_USAGE_BUCKET_MS = 5 * 60_000;
|
|
54
|
+
|
|
55
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
56
|
+
// SqliteAuthCredentialStore
|
|
57
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
58
|
+
|
|
59
|
+
/** Row shape for auth_credentials table queries */
|
|
60
|
+
type AuthRow = {
|
|
61
|
+
id: number;
|
|
62
|
+
provider: string;
|
|
63
|
+
credential_type: string;
|
|
64
|
+
data: string;
|
|
65
|
+
disabled_cause: string | null;
|
|
66
|
+
identity_key: string | null;
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
/** {@link AuthRow} plus `updated_at` — disabled-tombstone queries surface when the row was torn down. */
|
|
70
|
+
type DisabledAuthRow = AuthRow & { updated_at: number | null };
|
|
71
|
+
|
|
72
|
+
type CredentialBlockRow = {
|
|
73
|
+
credential_id: number;
|
|
74
|
+
provider_key: string;
|
|
75
|
+
block_scope: string;
|
|
76
|
+
blocked_until_ms: number;
|
|
77
|
+
updated_at: number;
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
type SerializedCredentialRecord = {
|
|
81
|
+
credentialType: AuthCredential["type"];
|
|
82
|
+
data: string;
|
|
83
|
+
identityKey: string | null;
|
|
84
|
+
};
|
|
85
|
+
|
|
86
|
+
const AUTH_SCHEMA_VERSION = 7;
|
|
87
|
+
const SQLITE_NOW_EPOCH = "CAST(strftime('%s','now') AS INTEGER)";
|
|
88
|
+
const LEGACY_CODEX_BLOCK_PROVIDER_KEY = "openai-codex:oauth";
|
|
89
|
+
const LEGACY_CODEX_BLOCK_SCOPE = "shared";
|
|
90
|
+
const CODEX_METER_BLOCK_SCOPES = ["chat", "spark"] as const;
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* SQLite's busy result code family — base `SQLITE_BUSY` plus the extended
|
|
94
|
+
* variants `SQLITE_BUSY_RECOVERY` (concurrent WAL recovery), `SQLITE_BUSY_SNAPSHOT`,
|
|
95
|
+
* and `SQLITE_BUSY_TIMEOUT`. All warrant the same backoff-and-retry treatment.
|
|
96
|
+
*/
|
|
97
|
+
export function isSqliteBusyError(err: unknown): boolean {
|
|
98
|
+
if (err === null || typeof err !== "object") return false;
|
|
99
|
+
const code = (err as { code?: unknown }).code;
|
|
100
|
+
return typeof code === "string" && code.startsWith("SQLITE_BUSY");
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* SQLite's unrecoverable-corruption result codes — the `SQLITE_CORRUPT` family
|
|
105
|
+
* (base plus extended variants like `SQLITE_CORRUPT_VTAB` / `SQLITE_CORRUPT_INDEX`)
|
|
106
|
+
* and `SQLITE_NOTADB` (the file header is not a database). Unlike
|
|
107
|
+
* {@link isSqliteBusyError}, these never clear by retrying: the store must be
|
|
108
|
+
* repaired or replaced, so callers latch and stop touching it.
|
|
109
|
+
*/
|
|
110
|
+
export function isSqliteCorruptionError(err: unknown): boolean {
|
|
111
|
+
if (err === null || typeof err !== "object" || !("code" in err)) return false;
|
|
112
|
+
const code = err.code;
|
|
113
|
+
return typeof code === "string" && (code.startsWith("SQLITE_CORRUPT") || code === "SQLITE_NOTADB");
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function normalizeStoredAccountId(accountId: string | null | undefined): string | null {
|
|
117
|
+
const normalized = accountId?.trim();
|
|
118
|
+
return normalized && normalized.length > 0 ? normalized : null;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function normalizeStoredEmail(email: string | null | undefined): string | null {
|
|
122
|
+
const normalized = email?.trim().toLowerCase();
|
|
123
|
+
return normalized && normalized.length > 0 ? normalized : null;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function normalizeStoredIdentityKey(identityKey: string | null | undefined): string | null {
|
|
127
|
+
const normalized = identityKey?.trim();
|
|
128
|
+
return normalized && normalized.length > 0 ? normalized : null;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
export function serializeCredential(provider: string, credential: AuthCredential): SerializedCredentialRecord | null {
|
|
132
|
+
if (credential.type === "api_key") {
|
|
133
|
+
const data = credential.source === "login" ? { key: credential.key, source: "login" } : { key: credential.key };
|
|
134
|
+
return {
|
|
135
|
+
credentialType: "api_key",
|
|
136
|
+
data: JSON.stringify(data),
|
|
137
|
+
identityKey: null,
|
|
138
|
+
};
|
|
139
|
+
}
|
|
140
|
+
if (credential.type === "oauth") {
|
|
141
|
+
const { type: _type, ...rest } = credential;
|
|
142
|
+
return {
|
|
143
|
+
credentialType: "oauth",
|
|
144
|
+
data: JSON.stringify(rest),
|
|
145
|
+
identityKey: resolveCredentialIdentityKey(provider, credential),
|
|
146
|
+
};
|
|
147
|
+
}
|
|
148
|
+
return null;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function deserializeCredential(row: AuthRow): AuthCredential | null {
|
|
152
|
+
let parsed: unknown;
|
|
153
|
+
try {
|
|
154
|
+
parsed = JSON.parse(row.data);
|
|
155
|
+
} catch {
|
|
156
|
+
return null;
|
|
157
|
+
}
|
|
158
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
|
|
159
|
+
return null;
|
|
160
|
+
}
|
|
161
|
+
if (row.credential_type === "api_key") {
|
|
162
|
+
const data = parsed as Record<string, unknown>;
|
|
163
|
+
if (typeof data.key === "string") {
|
|
164
|
+
const source = data.source === "login" ? "login" : undefined;
|
|
165
|
+
return source ? { type: "api_key", key: data.key, source } : { type: "api_key", key: data.key };
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
if (row.credential_type === "oauth") {
|
|
169
|
+
return { type: "oauth", ...(parsed as Record<string, unknown>) } as AuthCredential;
|
|
170
|
+
}
|
|
171
|
+
return null;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
function normalizeDisabledCause(disabledCause: string): string {
|
|
175
|
+
const normalized = disabledCause.trim();
|
|
176
|
+
return normalized.length > 0 ? normalized : "disabled";
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
function toStoredAuthCredential(row: AuthRow, credential: AuthCredential): StoredAuthCredential {
|
|
180
|
+
return { id: row.id, provider: row.provider, credential, disabledCause: row.disabled_cause };
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
function resolveProviderCredentialIdentityKey(provider: string, identifiers: string[]): string | null {
|
|
184
|
+
const emailIdentifier = identifiers.find(identifier => identifier.startsWith("email:"));
|
|
185
|
+
if (provider === "anthropic" || provider === "openai-codex") {
|
|
186
|
+
// One account email can hold several organizations/workspaces (e.g. a
|
|
187
|
+
// Team seat plus a personal plan), each with its own org-scoped token
|
|
188
|
+
// and limit pools. Scope identity by org so both subscriptions can be
|
|
189
|
+
// stored side by side. The qualifier rides on whichever base identity
|
|
190
|
+
// is available, so an unqualified account/project fallback would
|
|
191
|
+
// still collapse two subscriptions whenever the email could not be
|
|
192
|
+
// recovered. Org-less credentials (rows written before org capture
|
|
193
|
+
// existed) keep their bare key.
|
|
194
|
+
const base =
|
|
195
|
+
emailIdentifier ??
|
|
196
|
+
identifiers.find(identifier => identifier.startsWith("account:")) ??
|
|
197
|
+
identifiers.find(identifier => identifier.startsWith("project:"));
|
|
198
|
+
const orgIdentifier = identifiers.find(identifier => identifier.startsWith("org:"));
|
|
199
|
+
if (base) return orgIdentifier ? `${base}|${orgIdentifier}` : base;
|
|
200
|
+
// No base identity at all: the org alone still distinguishes the row.
|
|
201
|
+
return orgIdentifier ?? null;
|
|
202
|
+
}
|
|
203
|
+
const accountIdentifier = identifiers.find(identifier => identifier.startsWith("account:"));
|
|
204
|
+
if (accountIdentifier) return accountIdentifier;
|
|
205
|
+
if (emailIdentifier) return emailIdentifier;
|
|
206
|
+
const projectIdentifier = identifiers.find(identifier => identifier.startsWith("project:"));
|
|
207
|
+
if (projectIdentifier) return projectIdentifier;
|
|
208
|
+
return null;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
export function resolveCredentialIdentityKey(provider: string, credential: AuthCredential): string | null {
|
|
212
|
+
if (credential.type === "api_key") return null;
|
|
213
|
+
return resolveProviderCredentialIdentityKey(provider, extractOAuthCredentialIdentifiers(credential));
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
function resolveRowCredentialIdentityKey(provider: string, row: AuthRow): string | null {
|
|
217
|
+
const identityKey = normalizeStoredIdentityKey(row.identity_key);
|
|
218
|
+
if (identityKey) return identityKey;
|
|
219
|
+
const credential = deserializeCredential(row);
|
|
220
|
+
return credential?.type === "oauth" ? resolveCredentialIdentityKey(provider, credential) : null;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
function matchesReplacementCredential(
|
|
224
|
+
provider: string,
|
|
225
|
+
existing: AuthCredential | null,
|
|
226
|
+
existingIdentityKey: string | null,
|
|
227
|
+
incoming: AuthCredential,
|
|
228
|
+
): boolean {
|
|
229
|
+
if (!existing || existing.type !== incoming.type) return false;
|
|
230
|
+
if (incoming.type === "api_key") {
|
|
231
|
+
if (existing.type !== "api_key") return false;
|
|
232
|
+
if (existing.key === incoming.key) return true;
|
|
233
|
+
if (provider !== "alibaba-token-plan") return false;
|
|
234
|
+
const existingToken = parseAlibabaTokenPlanCredential(existing.key)?.token;
|
|
235
|
+
const incomingToken = parseAlibabaTokenPlanCredential(incoming.key)?.token;
|
|
236
|
+
return existingToken !== undefined && existingToken === incomingToken;
|
|
237
|
+
}
|
|
238
|
+
const incomingIdentifiers = extractOAuthCredentialIdentifiers(incoming);
|
|
239
|
+
const incomingIdentityKey = resolveProviderCredentialIdentityKey(provider, incomingIdentifiers);
|
|
240
|
+
if (incomingIdentityKey === null) return false;
|
|
241
|
+
if (incomingIdentityKey === existingIdentityKey) return true;
|
|
242
|
+
if (existingIdentityKey === null) return false;
|
|
243
|
+
// One-way upgrade, applied only when the INCOMING identity key carries the
|
|
244
|
+
// org qualifier (only anthropic and openai-codex keys do, so other
|
|
245
|
+
// providers never reach the checks below). An org-scoped login `org:<o>`
|
|
246
|
+
// claims (and re-keys) any existing row that denotes the same subscription:
|
|
247
|
+
// - `org:<o>` — org-only row stored when identity recovery failed, claimed
|
|
248
|
+
// once a later same-org login recovers a base identity;
|
|
249
|
+
// - `<b>` for any base identity `<b>` (email/account/project) the incoming
|
|
250
|
+
// credential carries — a pre-org legacy row, mirroring the pre-org
|
|
251
|
+
// replace behavior;
|
|
252
|
+
// - `<b>|org:<o>` for any such base — the same subscription keyed by a
|
|
253
|
+
// different base, e.g. an account-keyed row stored while the email could
|
|
254
|
+
// not be recovered, claimed once a later login recovers the email;
|
|
255
|
+
// - any same-org row whose STORED credential shares a base identity with
|
|
256
|
+
// the incoming one — a stored credential can retain identifiers its key
|
|
257
|
+
// does not use (an email-keyed row also carries the account UUID), so a
|
|
258
|
+
// later login that loses the email but keeps the account still updates
|
|
259
|
+
// its row instead of duplicating the subscription.
|
|
260
|
+
// The reverse stays a non-match: an org-less credential only ever replaces
|
|
261
|
+
// via exact key equality above and must never clobber an org-scoped row.
|
|
262
|
+
const orgIdentifier = incomingIdentifiers.find(identifier => identifier.startsWith("org:"));
|
|
263
|
+
if (orgIdentifier === undefined) return false;
|
|
264
|
+
if (incomingIdentityKey !== orgIdentifier && !incomingIdentityKey.endsWith(`|${orgIdentifier}`)) return false;
|
|
265
|
+
if (existingIdentityKey === orgIdentifier) return true;
|
|
266
|
+
const existingIdentifiers =
|
|
267
|
+
existing.type === "oauth" && existingIdentityKey.endsWith(`|${orgIdentifier}`)
|
|
268
|
+
? extractOAuthCredentialIdentifiers(existing)
|
|
269
|
+
: null;
|
|
270
|
+
// A base identifier that merely repeats the org qualifier's id carries no
|
|
271
|
+
// per-user identity (openai-codex stores the ChatGPT workspace id as both
|
|
272
|
+
// accountId and orgId, shared by every member) — letting it act as a
|
|
273
|
+
// claimable base would re-key another member's same-org row.
|
|
274
|
+
const orgQualifierId = orgIdentifier.slice("org:".length);
|
|
275
|
+
for (const identifier of incomingIdentifiers) {
|
|
276
|
+
const isBase =
|
|
277
|
+
identifier.startsWith("email:") || identifier.startsWith("account:") || identifier.startsWith("project:");
|
|
278
|
+
if (!isBase) continue;
|
|
279
|
+
if (identifier.slice(identifier.indexOf(":") + 1) === orgQualifierId) continue;
|
|
280
|
+
if (existingIdentityKey === identifier) return true;
|
|
281
|
+
if (existingIdentityKey === `${identifier}|${orgIdentifier}`) return true;
|
|
282
|
+
if (existingIdentifiers?.includes(identifier)) return true;
|
|
283
|
+
}
|
|
284
|
+
return false;
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
function extractOAuthCredentialIdentifiers(credential: OAuthCredential): string[] {
|
|
288
|
+
const identifiers = new Set<string>();
|
|
289
|
+
const accountId = normalizeStoredAccountId(credential.accountId);
|
|
290
|
+
if (accountId) identifiers.add(`account:${accountId}`);
|
|
291
|
+
const email = normalizeStoredEmail(credential.email);
|
|
292
|
+
if (email) identifiers.add(`email:${email}`);
|
|
293
|
+
const projectId = normalizeStoredAccountId(credential.projectId);
|
|
294
|
+
if (projectId) identifiers.add(`project:${projectId}`);
|
|
295
|
+
const orgId = normalizeStoredAccountId(credential.orgId);
|
|
296
|
+
if (orgId) identifiers.add(`org:${orgId}`);
|
|
297
|
+
const accessIdentifiers = extractOAuthTokenIdentifiers(credential.access) ?? [];
|
|
298
|
+
for (const identifier of accessIdentifiers) {
|
|
299
|
+
identifiers.add(identifier);
|
|
300
|
+
}
|
|
301
|
+
const refreshIdentifiers = extractOAuthTokenIdentifiers(credential.refresh) ?? [];
|
|
302
|
+
for (const identifier of refreshIdentifiers) {
|
|
303
|
+
identifiers.add(identifier);
|
|
304
|
+
}
|
|
305
|
+
return [...identifiers];
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
function extractOAuthTokenIdentifiers(token: string | undefined): string[] | undefined {
|
|
309
|
+
if (!token) return undefined;
|
|
310
|
+
const parts = token.split(".");
|
|
311
|
+
if (parts.length !== 3) return undefined;
|
|
312
|
+
try {
|
|
313
|
+
const payload = JSON.parse(
|
|
314
|
+
new TextDecoder("utf-8").decode(Uint8Array.fromBase64(parts[1], { alphabet: "base64url" })),
|
|
315
|
+
) as Record<string, unknown>;
|
|
316
|
+
const identifiers = new Set<string>();
|
|
317
|
+
const directEmail = normalizeStoredEmail(typeof payload.email === "string" ? payload.email : undefined);
|
|
318
|
+
if (directEmail) identifiers.add(`email:${directEmail}`);
|
|
319
|
+
const openAiProfile = payload["https://api.openai.com/profile"];
|
|
320
|
+
if (typeof openAiProfile === "object" && openAiProfile !== null && !Array.isArray(openAiProfile)) {
|
|
321
|
+
const claimEmail = normalizeStoredEmail(
|
|
322
|
+
(openAiProfile as Record<string, unknown>).email as string | undefined,
|
|
323
|
+
);
|
|
324
|
+
if (claimEmail) identifiers.add(`email:${claimEmail}`);
|
|
325
|
+
}
|
|
326
|
+
const openAiAuth = payload["https://api.openai.com/auth"];
|
|
327
|
+
const authClaims =
|
|
328
|
+
typeof openAiAuth === "object" && openAiAuth !== null && !Array.isArray(openAiAuth)
|
|
329
|
+
? (openAiAuth as Record<string, unknown>)
|
|
330
|
+
: undefined;
|
|
331
|
+
const accountId = normalizeStoredAccountId(
|
|
332
|
+
typeof payload.account_id === "string"
|
|
333
|
+
? payload.account_id
|
|
334
|
+
: typeof payload.accountId === "string"
|
|
335
|
+
? payload.accountId
|
|
336
|
+
: typeof payload.user_id === "string"
|
|
337
|
+
? payload.user_id
|
|
338
|
+
: typeof payload.sub === "string"
|
|
339
|
+
? payload.sub
|
|
340
|
+
: typeof authClaims?.chatgpt_account_id === "string"
|
|
341
|
+
? authClaims.chatgpt_account_id
|
|
342
|
+
: undefined,
|
|
343
|
+
);
|
|
344
|
+
if (accountId) identifiers.add(`account:${accountId}`);
|
|
345
|
+
return identifiers.size > 0 ? [...identifiers] : undefined;
|
|
346
|
+
} catch {
|
|
347
|
+
return undefined;
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
/**
|
|
351
|
+
* Default SQLite-backed implementation of {@link AuthCredentialStore}.
|
|
352
|
+
*
|
|
353
|
+
* Used by the pi-ai CLI and as the default store for `AuthStorage.create()`.
|
|
354
|
+
* Also exposes convenience methods (`saveOAuth`, `getOAuth`, `saveApiKey`,
|
|
355
|
+
* `getApiKey`, `listProviders`, `deleteProvider`) that callers can use directly
|
|
356
|
+
* without going through `AuthStorage`.
|
|
357
|
+
*/
|
|
358
|
+
export class SqliteAuthCredentialStore implements AuthCredentialStore {
|
|
359
|
+
#db: Database;
|
|
360
|
+
#listActiveStmt: Statement;
|
|
361
|
+
#listActiveByProviderStmt: Statement;
|
|
362
|
+
#listDisabledStmt: Statement;
|
|
363
|
+
#listDisabledByProviderStmt: Statement;
|
|
364
|
+
#insertStmt: Statement;
|
|
365
|
+
#updateStmt: Statement;
|
|
366
|
+
#deleteStmt: Statement;
|
|
367
|
+
#deleteIfMatchesStmt: Statement;
|
|
368
|
+
#updateIfMatchesStmt: Statement;
|
|
369
|
+
#deleteByProviderStmt: Statement;
|
|
370
|
+
#hardDeleteStmt: Statement;
|
|
371
|
+
#getCacheStmt: Statement;
|
|
372
|
+
#getCacheIncludingExpiredStmt: Statement;
|
|
373
|
+
#upsertCacheStmt: Statement;
|
|
374
|
+
#deleteCachePrefixStmt: Statement;
|
|
375
|
+
#deleteExpiredCacheStmt: Statement;
|
|
376
|
+
#updateIfMatchesWithLeaseStmt: Statement;
|
|
377
|
+
#deleteIfMatchesWithLeaseStmt: Statement;
|
|
378
|
+
#getCredentialBlockStmt: Statement;
|
|
379
|
+
#listCredentialBlocksByCredentialStmt: Statement;
|
|
380
|
+
#upsertCredentialBlockStmt: Statement;
|
|
381
|
+
#deleteCredentialBlocksStmt: Statement;
|
|
382
|
+
#deleteCredentialBlockStmt: Statement;
|
|
383
|
+
#deleteExpiredCredentialBlocksStmt: Statement;
|
|
384
|
+
#acquireCredentialRefreshLeaseStmt: Statement;
|
|
385
|
+
#getCredentialRefreshLeaseStmt: Statement;
|
|
386
|
+
#renewCredentialRefreshLeaseStmt: Statement;
|
|
387
|
+
#releaseCredentialRefreshLeaseStmt: Statement;
|
|
388
|
+
#credentialBlockReconcileAfter: Map<string, number> = new Map();
|
|
389
|
+
#insertUsageHistoryStmt: Statement;
|
|
390
|
+
#insertUsageCostStmt: Statement;
|
|
391
|
+
#listUsageCostsStmt: Statement;
|
|
392
|
+
#lastUsageHistoryStmt: Statement;
|
|
393
|
+
#listUsageHistoryStmt: Statement;
|
|
394
|
+
#updateUsageHistoryStmt: Statement;
|
|
395
|
+
#dataVersion: number;
|
|
396
|
+
#authRevision: number;
|
|
397
|
+
#localAuthRevision: number;
|
|
398
|
+
#closed = false;
|
|
399
|
+
|
|
400
|
+
constructor(db: Database) {
|
|
401
|
+
this.#db = db;
|
|
402
|
+
this.#initializeSchema();
|
|
403
|
+
this.#dataVersion = this.#readDataVersion();
|
|
404
|
+
this.#authRevision = this.#readAuthRevision();
|
|
405
|
+
this.#localAuthRevision = this.#readLocalAuthRevision();
|
|
406
|
+
|
|
407
|
+
this.#listActiveStmt = this.#db.prepare(
|
|
408
|
+
"SELECT id, provider, credential_type, data, disabled_cause, identity_key FROM auth_credentials WHERE disabled_cause IS NULL ORDER BY id ASC",
|
|
409
|
+
);
|
|
410
|
+
this.#listActiveByProviderStmt = this.#db.prepare(
|
|
411
|
+
"SELECT id, provider, credential_type, data, disabled_cause, identity_key FROM auth_credentials WHERE provider = ? AND disabled_cause IS NULL ORDER BY id ASC",
|
|
412
|
+
);
|
|
413
|
+
this.#listDisabledStmt = this.#db.prepare(
|
|
414
|
+
"SELECT id, provider, credential_type, data, disabled_cause, identity_key, updated_at FROM auth_credentials WHERE disabled_cause IS NOT NULL ORDER BY id ASC",
|
|
415
|
+
);
|
|
416
|
+
this.#listDisabledByProviderStmt = this.#db.prepare(
|
|
417
|
+
"SELECT id, provider, credential_type, data, disabled_cause, identity_key, updated_at FROM auth_credentials WHERE provider = ? AND disabled_cause IS NOT NULL ORDER BY id ASC",
|
|
418
|
+
);
|
|
419
|
+
this.#insertStmt = this.#db.prepare(
|
|
420
|
+
`INSERT INTO auth_credentials (provider, credential_type, data, identity_key, created_at, updated_at) VALUES (?, ?, ?, ?, ${SQLITE_NOW_EPOCH}, ${SQLITE_NOW_EPOCH}) RETURNING id`,
|
|
421
|
+
);
|
|
422
|
+
this.#updateStmt = this.#db.prepare(
|
|
423
|
+
`UPDATE auth_credentials SET credential_type = ?, data = ?, identity_key = ?, updated_at = ${SQLITE_NOW_EPOCH} WHERE id = ?`,
|
|
424
|
+
);
|
|
425
|
+
this.#updateIfMatchesStmt = this.#db.prepare(
|
|
426
|
+
`UPDATE auth_credentials SET credential_type = ?, data = ?, identity_key = ?, updated_at = ${SQLITE_NOW_EPOCH} WHERE id = ? AND data = ? AND disabled_cause IS NULL`,
|
|
427
|
+
);
|
|
428
|
+
this.#updateIfMatchesWithLeaseStmt = this.#db.prepare(
|
|
429
|
+
`UPDATE auth_credentials
|
|
430
|
+
SET credential_type = ?, data = ?, identity_key = ?, updated_at = ${SQLITE_NOW_EPOCH}
|
|
431
|
+
WHERE id = ? AND data = ? AND disabled_cause IS NULL
|
|
432
|
+
AND EXISTS (
|
|
433
|
+
SELECT 1 FROM auth_credential_refresh_leases
|
|
434
|
+
WHERE credential_id = ? AND owner = ? AND expires_at_ms > ?
|
|
435
|
+
)`,
|
|
436
|
+
);
|
|
437
|
+
this.#deleteStmt = this.#db.prepare(
|
|
438
|
+
`UPDATE auth_credentials SET disabled_cause = ?, updated_at = ${SQLITE_NOW_EPOCH} WHERE id = ?`,
|
|
439
|
+
);
|
|
440
|
+
this.#deleteIfMatchesStmt = this.#db.prepare(
|
|
441
|
+
`UPDATE auth_credentials SET disabled_cause = ?, updated_at = ${SQLITE_NOW_EPOCH} WHERE id = ? AND data = ? AND disabled_cause IS NULL`,
|
|
442
|
+
);
|
|
443
|
+
this.#deleteIfMatchesWithLeaseStmt = this.#db.prepare(
|
|
444
|
+
`UPDATE auth_credentials
|
|
445
|
+
SET disabled_cause = ?, updated_at = ${SQLITE_NOW_EPOCH}
|
|
446
|
+
WHERE id = ? AND data = ? AND disabled_cause IS NULL
|
|
447
|
+
AND EXISTS (
|
|
448
|
+
SELECT 1 FROM auth_credential_refresh_leases
|
|
449
|
+
WHERE credential_id = ? AND owner = ? AND expires_at_ms > ?
|
|
450
|
+
)`,
|
|
451
|
+
);
|
|
452
|
+
this.#deleteByProviderStmt = this.#db.prepare(
|
|
453
|
+
`UPDATE auth_credentials SET disabled_cause = ?, updated_at = ${SQLITE_NOW_EPOCH} WHERE provider = ? AND disabled_cause IS NULL`,
|
|
454
|
+
);
|
|
455
|
+
this.#hardDeleteStmt = this.#db.prepare("DELETE FROM auth_credentials WHERE id = ?");
|
|
456
|
+
this.#getCacheStmt = this.#db.prepare(
|
|
457
|
+
`SELECT value FROM cache WHERE key = ? AND expires_at > ${SQLITE_NOW_EPOCH}`,
|
|
458
|
+
);
|
|
459
|
+
this.#getCacheIncludingExpiredStmt = this.#db.prepare("SELECT value FROM cache WHERE key = ?");
|
|
460
|
+
this.#upsertCacheStmt = this.#db.prepare(
|
|
461
|
+
"INSERT INTO cache (key, value, expires_at) VALUES (?, ?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value, expires_at = excluded.expires_at",
|
|
462
|
+
);
|
|
463
|
+
this.#deleteCachePrefixStmt = this.#db.prepare("DELETE FROM cache WHERE substr(key, 1, ?) = ?");
|
|
464
|
+
this.#deleteExpiredCacheStmt = this.#db.prepare(`DELETE FROM cache WHERE expires_at <= ${SQLITE_NOW_EPOCH}`);
|
|
465
|
+
this.#getCredentialBlockStmt = this.#db.prepare(
|
|
466
|
+
"SELECT blocked_until_ms, updated_at FROM auth_credential_blocks WHERE credential_id = ? AND provider_key = ? AND block_scope = ? AND blocked_until_ms > ?",
|
|
467
|
+
);
|
|
468
|
+
this.#listCredentialBlocksByCredentialStmt = this.#db.prepare(
|
|
469
|
+
`SELECT credential_id, provider_key, block_scope, blocked_until_ms, updated_at
|
|
470
|
+
FROM auth_credential_blocks
|
|
471
|
+
WHERE credential_id = ? AND blocked_until_ms > ?
|
|
472
|
+
AND NOT (provider_key = ? AND block_scope = ?)
|
|
473
|
+
ORDER BY provider_key ASC, block_scope ASC`,
|
|
474
|
+
);
|
|
475
|
+
this.#upsertCredentialBlockStmt = this.#db.prepare(
|
|
476
|
+
`INSERT INTO auth_credential_blocks (credential_id, provider_key, block_scope, blocked_until_ms, updated_at)
|
|
477
|
+
VALUES (?, ?, ?, ?, ${SQLITE_NOW_EPOCH})
|
|
478
|
+
ON CONFLICT(credential_id, provider_key, block_scope) DO UPDATE SET
|
|
479
|
+
blocked_until_ms = MAX(blocked_until_ms, excluded.blocked_until_ms),
|
|
480
|
+
updated_at = excluded.updated_at`,
|
|
481
|
+
);
|
|
482
|
+
this.#deleteCredentialBlocksStmt = this.#db.prepare("DELETE FROM auth_credential_blocks WHERE credential_id = ?");
|
|
483
|
+
this.#deleteCredentialBlockStmt = this.#db.prepare(
|
|
484
|
+
"DELETE FROM auth_credential_blocks WHERE credential_id = ? AND provider_key = ? AND block_scope = ?",
|
|
485
|
+
);
|
|
486
|
+
this.#deleteExpiredCredentialBlocksStmt = this.#db.prepare(
|
|
487
|
+
"DELETE FROM auth_credential_blocks WHERE blocked_until_ms <= ?",
|
|
488
|
+
);
|
|
489
|
+
this.#acquireCredentialRefreshLeaseStmt = this.#db.prepare(
|
|
490
|
+
`INSERT INTO auth_credential_refresh_leases (credential_id, owner, expires_at_ms, updated_at)
|
|
491
|
+
VALUES (?, ?, ?, ${SQLITE_NOW_EPOCH})
|
|
492
|
+
ON CONFLICT(credential_id) DO UPDATE SET
|
|
493
|
+
owner = excluded.owner,
|
|
494
|
+
expires_at_ms = excluded.expires_at_ms,
|
|
495
|
+
updated_at = excluded.updated_at
|
|
496
|
+
WHERE auth_credential_refresh_leases.expires_at_ms <= ?`,
|
|
497
|
+
);
|
|
498
|
+
this.#getCredentialRefreshLeaseStmt = this.#db.prepare(
|
|
499
|
+
"SELECT expires_at_ms FROM auth_credential_refresh_leases WHERE credential_id = ?",
|
|
500
|
+
);
|
|
501
|
+
this.#renewCredentialRefreshLeaseStmt = this.#db.prepare(
|
|
502
|
+
`UPDATE auth_credential_refresh_leases SET expires_at_ms = ?, updated_at = ${SQLITE_NOW_EPOCH} WHERE credential_id = ? AND owner = ?`,
|
|
503
|
+
);
|
|
504
|
+
this.#releaseCredentialRefreshLeaseStmt = this.#db.prepare(
|
|
505
|
+
"DELETE FROM auth_credential_refresh_leases WHERE credential_id = ? AND owner = ?",
|
|
506
|
+
);
|
|
507
|
+
this.#insertUsageHistoryStmt = this.#db.prepare(
|
|
508
|
+
"INSERT INTO usage_history (recorded_at, provider, account_key, email, account_id, limit_id, label, window_label, used_fraction, status, resets_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
|
|
509
|
+
);
|
|
510
|
+
this.#lastUsageHistoryStmt = this.#db.prepare(
|
|
511
|
+
"SELECT id, recorded_at FROM usage_history WHERE provider = ? AND account_key = ? AND limit_id = ? ORDER BY recorded_at DESC LIMIT 1",
|
|
512
|
+
);
|
|
513
|
+
this.#updateUsageHistoryStmt = this.#db.prepare(
|
|
514
|
+
"UPDATE usage_history SET recorded_at = ?, email = ?, account_id = ?, label = ?, window_label = ?, used_fraction = ?, status = ?, resets_at = ? WHERE id = ?",
|
|
515
|
+
);
|
|
516
|
+
this.#listUsageHistoryStmt = this.#db.prepare(
|
|
517
|
+
"SELECT recorded_at, provider, account_key, email, account_id, limit_id, label, window_label, used_fraction, status, resets_at FROM usage_history WHERE recorded_at >= ? AND (? IS NULL OR provider = ?) ORDER BY recorded_at ASC",
|
|
518
|
+
);
|
|
519
|
+
this.#insertUsageCostStmt = this.#db.prepare(
|
|
520
|
+
"INSERT INTO usage_cost_history (recorded_at, provider, account_key, cost_usd) VALUES (?, ?, ?, ?)",
|
|
521
|
+
);
|
|
522
|
+
this.#listUsageCostsStmt = this.#db.prepare(
|
|
523
|
+
"SELECT recorded_at, provider, account_key, cost_usd FROM usage_cost_history WHERE recorded_at >= ? AND (? IS NULL OR provider = ?) AND (? IS NULL OR account_key = ?) ORDER BY recorded_at ASC",
|
|
524
|
+
);
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
static async open(dbPath: string = getAgentDbPath()): Promise<SqliteAuthCredentialStore> {
|
|
528
|
+
const dir = path.dirname(dbPath);
|
|
529
|
+
const dirExists = await fs
|
|
530
|
+
.stat(dir)
|
|
531
|
+
.then(s => s.isDirectory())
|
|
532
|
+
.catch(() => false);
|
|
533
|
+
if (!dirExists) {
|
|
534
|
+
await fs.mkdir(dir, { recursive: true, mode: 0o700 });
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
// Concurrent omp startups can race against WAL recovery and the schema
|
|
538
|
+
// init's first lock-taking statement. Bun's default `busy_timeout` is 0,
|
|
539
|
+
// so retry the open on `SQLITE_BUSY` / `SQLITE_BUSY_RECOVERY` with bounded
|
|
540
|
+
// exponential backoff before surfacing the failure. See issue #2421.
|
|
541
|
+
const maxAttempts = 4;
|
|
542
|
+
const baseDelayMs = 100;
|
|
543
|
+
let lastBusyError: Error | undefined;
|
|
544
|
+
for (let attempt = 0; attempt < maxAttempts; attempt++) {
|
|
545
|
+
let db: Database | undefined;
|
|
546
|
+
try {
|
|
547
|
+
db = new Database(dbPath);
|
|
548
|
+
// Install the busy handler BEFORE the first lock-taking statement
|
|
549
|
+
// on this connection. The leases DDL below and the constructor's
|
|
550
|
+
// schema init both acquire locks during WAL recovery; without a
|
|
551
|
+
// non-zero `busy_timeout` they fail immediately with SQLITE_BUSY.
|
|
552
|
+
// See issue #2421.
|
|
553
|
+
SqliteAuthCredentialStore.#installBusyTimeout(db);
|
|
554
|
+
try {
|
|
555
|
+
await fs.chmod(dbPath, 0o600);
|
|
556
|
+
} catch {
|
|
557
|
+
// Ignore chmod failures (e.g., Windows)
|
|
558
|
+
}
|
|
559
|
+
SqliteAuthCredentialStore.#ensureAuthCredentialRefreshLeasesTable(db);
|
|
560
|
+
return new SqliteAuthCredentialStore(db);
|
|
561
|
+
} catch (err) {
|
|
562
|
+
db?.close();
|
|
563
|
+
if (!isSqliteBusyError(err)) {
|
|
564
|
+
throw err;
|
|
565
|
+
}
|
|
566
|
+
lastBusyError = err instanceof Error ? err : new Error(String(err));
|
|
567
|
+
if (attempt < maxAttempts - 1) {
|
|
568
|
+
await Bun.sleep(baseDelayMs * 2 ** attempt);
|
|
569
|
+
}
|
|
570
|
+
}
|
|
571
|
+
}
|
|
572
|
+
throw new AIError.ConfigurationError(
|
|
573
|
+
`Failed to open auth database at '${dbPath}' after ${maxAttempts} attempts: ${lastBusyError?.message}`,
|
|
574
|
+
{ cause: lastBusyError },
|
|
575
|
+
);
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
static #ensureAuthCredentialRefreshLeasesTable(db: Database): void {
|
|
579
|
+
db.run(`
|
|
580
|
+
CREATE TABLE IF NOT EXISTS auth_credential_refresh_leases (
|
|
581
|
+
credential_id INTEGER PRIMARY KEY,
|
|
582
|
+
owner TEXT NOT NULL,
|
|
583
|
+
expires_at_ms INTEGER NOT NULL,
|
|
584
|
+
updated_at INTEGER NOT NULL
|
|
585
|
+
);
|
|
586
|
+
CREATE INDEX IF NOT EXISTS idx_auth_credential_refresh_leases_expires ON auth_credential_refresh_leases(expires_at_ms);
|
|
587
|
+
`);
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
/**
|
|
591
|
+
* Install the per-connection busy handler so lock-taking statements wait for
|
|
592
|
+
* a contended writer instead of failing immediately (Bun defaults
|
|
593
|
+
* `busy_timeout` to 0). MUST run before the first lock-taking statement on
|
|
594
|
+
* the connection: concurrent omp startups race WAL recovery and the leases
|
|
595
|
+
* DDL. Uses the centralized timeout so headless hosts keep their bounded
|
|
596
|
+
* busy wait instead of the interactive 5s value. See issues #2421, #7298.
|
|
597
|
+
*/
|
|
598
|
+
static #installBusyTimeout(db: Database): void {
|
|
599
|
+
db.run(`PRAGMA busy_timeout = ${getDbBusyTimeoutMs()}`);
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
#initializeSchema(): void {
|
|
603
|
+
// Install the busy handler BEFORE any lock-taking statement (incl.
|
|
604
|
+
// `PRAGMA journal_mode=WAL`, which acquires an exclusive lock during WAL
|
|
605
|
+
// recovery). Without this, concurrent omp startups can crash here with
|
|
606
|
+
// `SQLITE_BUSY` / `SQLITE_BUSY_RECOVERY`. Re-setting when opened via
|
|
607
|
+
// `open()` (which already installed it) is idempotent. See issue #2421.
|
|
608
|
+
SqliteAuthCredentialStore.#installBusyTimeout(this.#db);
|
|
609
|
+
this.#db.run(`
|
|
610
|
+
PRAGMA journal_mode=WAL;
|
|
611
|
+
PRAGMA synchronous=NORMAL;
|
|
612
|
+
CREATE TABLE IF NOT EXISTS auth_schema_version (
|
|
613
|
+
id INTEGER PRIMARY KEY CHECK (id = 1),
|
|
614
|
+
version INTEGER NOT NULL
|
|
615
|
+
);
|
|
616
|
+
CREATE TABLE IF NOT EXISTS cache (
|
|
617
|
+
key TEXT PRIMARY KEY,
|
|
618
|
+
value TEXT NOT NULL,
|
|
619
|
+
expires_at INTEGER NOT NULL
|
|
620
|
+
);
|
|
621
|
+
CREATE INDEX IF NOT EXISTS idx_cache_expires ON cache(expires_at);
|
|
622
|
+
CREATE TABLE IF NOT EXISTS usage_history (
|
|
623
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
624
|
+
recorded_at INTEGER NOT NULL,
|
|
625
|
+
provider TEXT NOT NULL,
|
|
626
|
+
account_key TEXT NOT NULL,
|
|
627
|
+
email TEXT,
|
|
628
|
+
account_id TEXT,
|
|
629
|
+
limit_id TEXT NOT NULL,
|
|
630
|
+
label TEXT NOT NULL,
|
|
631
|
+
window_label TEXT,
|
|
632
|
+
used_fraction REAL,
|
|
633
|
+
status TEXT,
|
|
634
|
+
resets_at INTEGER
|
|
635
|
+
);
|
|
636
|
+
CREATE INDEX IF NOT EXISTS idx_usage_history_series ON usage_history(provider, account_key, limit_id, recorded_at);
|
|
637
|
+
CREATE TABLE IF NOT EXISTS usage_cost_history (
|
|
638
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
639
|
+
recorded_at INTEGER NOT NULL,
|
|
640
|
+
provider TEXT NOT NULL,
|
|
641
|
+
account_key TEXT NOT NULL,
|
|
642
|
+
cost_usd REAL NOT NULL
|
|
643
|
+
);
|
|
644
|
+
CREATE INDEX IF NOT EXISTS idx_usage_cost_history_lookup ON usage_cost_history(provider, account_key, recorded_at);
|
|
645
|
+
CREATE INDEX IF NOT EXISTS idx_usage_history_recorded ON usage_history(recorded_at);
|
|
646
|
+
CREATE TABLE IF NOT EXISTS clients (
|
|
647
|
+
install_id TEXT PRIMARY KEY,
|
|
648
|
+
hostname TEXT,
|
|
649
|
+
first_seen INTEGER NOT NULL,
|
|
650
|
+
last_seen INTEGER NOT NULL
|
|
651
|
+
);
|
|
652
|
+
CREATE TABLE IF NOT EXISTS client_usage (
|
|
653
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
654
|
+
recorded_at INTEGER NOT NULL,
|
|
655
|
+
install_id TEXT NOT NULL,
|
|
656
|
+
provider TEXT NOT NULL,
|
|
657
|
+
model TEXT NOT NULL,
|
|
658
|
+
requests INTEGER NOT NULL,
|
|
659
|
+
input_tokens INTEGER NOT NULL,
|
|
660
|
+
output_tokens INTEGER NOT NULL,
|
|
661
|
+
cache_read_tokens INTEGER NOT NULL,
|
|
662
|
+
cache_write_tokens INTEGER NOT NULL,
|
|
663
|
+
cost_usd REAL NOT NULL DEFAULT 0
|
|
664
|
+
);
|
|
665
|
+
CREATE INDEX IF NOT EXISTS idx_client_usage_series ON client_usage(install_id, provider, model, recorded_at);
|
|
666
|
+
CREATE INDEX IF NOT EXISTS idx_client_usage_recorded ON client_usage(recorded_at);
|
|
667
|
+
`);
|
|
668
|
+
|
|
669
|
+
if (!this.#authCredentialsTableExists()) {
|
|
670
|
+
this.#createAuthCredentialsTable();
|
|
671
|
+
this.#createAuthCredentialBlocksTable();
|
|
672
|
+
this.#createAuthCredentialRefreshLeasesTable();
|
|
673
|
+
this.#createAuthCredentialBlockCompatibilityObjects();
|
|
674
|
+
this.#createAuthChangeTrackingObjects();
|
|
675
|
+
this.#writeAuthSchemaVersion(AUTH_SCHEMA_VERSION);
|
|
676
|
+
return;
|
|
677
|
+
}
|
|
678
|
+
|
|
679
|
+
const recordedVersion = this.#readAuthSchemaVersion();
|
|
680
|
+
const schemaVersion = recordedVersion ?? this.#inferAuthSchemaVersion();
|
|
681
|
+
if (schemaVersion > AUTH_SCHEMA_VERSION) {
|
|
682
|
+
logger.warn("SqliteAuthCredentialStore schema version mismatch", {
|
|
683
|
+
current: schemaVersion,
|
|
684
|
+
expected: AUTH_SCHEMA_VERSION,
|
|
685
|
+
});
|
|
686
|
+
} else if (schemaVersion < AUTH_SCHEMA_VERSION) {
|
|
687
|
+
this.#migrateAuthSchema(schemaVersion);
|
|
688
|
+
}
|
|
689
|
+
|
|
690
|
+
this.#createAuthCredentialIndexes();
|
|
691
|
+
this.#createAuthCredentialBlocksTable();
|
|
692
|
+
this.#createAuthCredentialRefreshLeasesTable();
|
|
693
|
+
if (schemaVersion <= AUTH_SCHEMA_VERSION) {
|
|
694
|
+
this.#createAuthCredentialBlockCompatibilityObjects();
|
|
695
|
+
}
|
|
696
|
+
this.#createAuthChangeTrackingObjects();
|
|
697
|
+
this.#backfillCredentialIdentityKeys();
|
|
698
|
+
// Rewriting an already-current version row is a no-op write transaction
|
|
699
|
+
// on every boot; only persist when the recorded version actually changes.
|
|
700
|
+
if (recordedVersion !== AUTH_SCHEMA_VERSION && schemaVersion <= AUTH_SCHEMA_VERSION) {
|
|
701
|
+
this.#writeAuthSchemaVersion(AUTH_SCHEMA_VERSION);
|
|
702
|
+
}
|
|
703
|
+
}
|
|
704
|
+
|
|
705
|
+
#authCredentialsTableExists(): boolean {
|
|
706
|
+
const stmt = this.#db.prepare(
|
|
707
|
+
"SELECT 1 AS present FROM sqlite_master WHERE type = 'table' AND name = 'auth_credentials'",
|
|
708
|
+
);
|
|
709
|
+
try {
|
|
710
|
+
const row = stmt.get() as { present?: number } | undefined;
|
|
711
|
+
return row?.present === 1;
|
|
712
|
+
} finally {
|
|
713
|
+
stmt.finalize();
|
|
714
|
+
}
|
|
715
|
+
}
|
|
716
|
+
|
|
717
|
+
#readAuthSchemaVersion(): number | null {
|
|
718
|
+
const stmt = this.#db.prepare("SELECT version FROM auth_schema_version WHERE id = 1");
|
|
719
|
+
try {
|
|
720
|
+
const row = stmt.get() as { version?: number } | undefined;
|
|
721
|
+
return typeof row?.version === "number" ? row.version : null;
|
|
722
|
+
} finally {
|
|
723
|
+
stmt.finalize();
|
|
724
|
+
}
|
|
725
|
+
}
|
|
726
|
+
|
|
727
|
+
#writeAuthSchemaVersion(version: number): void {
|
|
728
|
+
const stmt = this.#db.prepare("INSERT OR REPLACE INTO auth_schema_version(id, version) VALUES (1, ?)");
|
|
729
|
+
try {
|
|
730
|
+
stmt.run(version);
|
|
731
|
+
} finally {
|
|
732
|
+
stmt.finalize();
|
|
733
|
+
}
|
|
734
|
+
}
|
|
735
|
+
|
|
736
|
+
#inferAuthSchemaVersion(): number {
|
|
737
|
+
const stmt = this.#db.prepare("PRAGMA table_info(auth_credentials)");
|
|
738
|
+
try {
|
|
739
|
+
const cols = stmt.all() as Array<{ name?: string }>;
|
|
740
|
+
return this.#inferAuthSchemaVersionFromColumns(cols);
|
|
741
|
+
} finally {
|
|
742
|
+
stmt.finalize();
|
|
743
|
+
}
|
|
744
|
+
}
|
|
745
|
+
|
|
746
|
+
#inferAuthSchemaVersionFromColumns(cols: Array<{ name?: string }>): number {
|
|
747
|
+
const hasDisabledCause = cols.some(column => column.name === "disabled_cause");
|
|
748
|
+
const hasIdentityKey = cols.some(column => column.name === "identity_key");
|
|
749
|
+
const hasAccountId = cols.some(column => column.name === "account_id");
|
|
750
|
+
const hasEmail = cols.some(column => column.name === "email");
|
|
751
|
+
if (hasIdentityKey) return 3;
|
|
752
|
+
if (hasAccountId || hasEmail) return 2;
|
|
753
|
+
if (hasDisabledCause) return 1;
|
|
754
|
+
return 0;
|
|
755
|
+
}
|
|
756
|
+
|
|
757
|
+
#createAuthCredentialsTable(): void {
|
|
758
|
+
this.#db.run(`
|
|
759
|
+
CREATE TABLE IF NOT EXISTS auth_credentials (
|
|
760
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
761
|
+
provider TEXT NOT NULL,
|
|
762
|
+
credential_type TEXT NOT NULL,
|
|
763
|
+
data TEXT NOT NULL,
|
|
764
|
+
disabled_cause TEXT DEFAULT NULL,
|
|
765
|
+
identity_key TEXT DEFAULT NULL,
|
|
766
|
+
created_at INTEGER NOT NULL DEFAULT (${SQLITE_NOW_EPOCH}),
|
|
767
|
+
updated_at INTEGER NOT NULL DEFAULT (${SQLITE_NOW_EPOCH})
|
|
768
|
+
);
|
|
769
|
+
`);
|
|
770
|
+
this.#createAuthCredentialIndexes();
|
|
771
|
+
}
|
|
772
|
+
|
|
773
|
+
#createAuthCredentialIndexes(): void {
|
|
774
|
+
this.#db.run(`
|
|
775
|
+
CREATE INDEX IF NOT EXISTS idx_auth_provider ON auth_credentials(provider);
|
|
776
|
+
CREATE INDEX IF NOT EXISTS idx_auth_provider_identity ON auth_credentials(provider, identity_key) WHERE identity_key IS NOT NULL;
|
|
777
|
+
`);
|
|
778
|
+
}
|
|
779
|
+
|
|
780
|
+
#createAuthCredentialBlocksTable(): void {
|
|
781
|
+
this.#db.run(`
|
|
782
|
+
CREATE TABLE IF NOT EXISTS auth_credential_blocks (
|
|
783
|
+
credential_id INTEGER NOT NULL,
|
|
784
|
+
provider_key TEXT NOT NULL,
|
|
785
|
+
block_scope TEXT NOT NULL DEFAULT '',
|
|
786
|
+
blocked_until_ms INTEGER NOT NULL,
|
|
787
|
+
updated_at INTEGER NOT NULL,
|
|
788
|
+
PRIMARY KEY (credential_id, provider_key, block_scope)
|
|
789
|
+
);
|
|
790
|
+
CREATE INDEX IF NOT EXISTS idx_auth_credential_blocks_expires ON auth_credential_blocks(blocked_until_ms);
|
|
791
|
+
`);
|
|
792
|
+
}
|
|
793
|
+
|
|
794
|
+
#createAuthChangeTrackingObjects(): void {
|
|
795
|
+
this.#db.run(`
|
|
796
|
+
CREATE TABLE IF NOT EXISTS auth_change_revision (
|
|
797
|
+
id INTEGER PRIMARY KEY CHECK (id = 1),
|
|
798
|
+
revision INTEGER NOT NULL
|
|
799
|
+
);
|
|
800
|
+
INSERT OR IGNORE INTO auth_change_revision (id, revision) VALUES (1, 0);
|
|
801
|
+
CREATE TEMP TABLE IF NOT EXISTS auth_local_change_revision (
|
|
802
|
+
id INTEGER PRIMARY KEY CHECK (id = 1),
|
|
803
|
+
revision INTEGER NOT NULL
|
|
804
|
+
);
|
|
805
|
+
INSERT OR IGNORE INTO auth_local_change_revision (id, revision) VALUES (1, 0);
|
|
806
|
+
`);
|
|
807
|
+
for (const table of ["auth_credentials", "auth_credential_blocks"] as const) {
|
|
808
|
+
for (const event of ["INSERT", "UPDATE", "DELETE"] as const) {
|
|
809
|
+
this.#db.run(`
|
|
810
|
+
CREATE TRIGGER IF NOT EXISTS auth_change_revision_${table}_${event.toLowerCase()}
|
|
811
|
+
AFTER ${event} ON ${table}
|
|
812
|
+
BEGIN
|
|
813
|
+
UPDATE auth_change_revision SET revision = revision + 1 WHERE id = 1;
|
|
814
|
+
END;
|
|
815
|
+
`);
|
|
816
|
+
this.#db.run(`
|
|
817
|
+
CREATE TEMP TRIGGER IF NOT EXISTS auth_local_change_revision_${table}_${event.toLowerCase()}
|
|
818
|
+
AFTER ${event} ON main.${table}
|
|
819
|
+
BEGIN
|
|
820
|
+
UPDATE auth_local_change_revision SET revision = revision + 1 WHERE id = 1;
|
|
821
|
+
END;
|
|
822
|
+
`);
|
|
823
|
+
}
|
|
824
|
+
}
|
|
825
|
+
}
|
|
826
|
+
|
|
827
|
+
#createAuthCredentialBlockMirrorGuardTable(): void {
|
|
828
|
+
this.#db.run(`
|
|
829
|
+
CREATE TABLE IF NOT EXISTS auth_credential_block_mirror_guard (
|
|
830
|
+
credential_id INTEGER PRIMARY KEY
|
|
831
|
+
) WITHOUT ROWID;
|
|
832
|
+
`);
|
|
833
|
+
}
|
|
834
|
+
|
|
835
|
+
/**
|
|
836
|
+
* Keep a physical Codex `shared` row for pre-meter binaries that read this
|
|
837
|
+
* database directly. Meter rows are canonical for current code. The guard
|
|
838
|
+
* suppresses feedback while triggers update the compatibility projection.
|
|
839
|
+
*/
|
|
840
|
+
#createAuthCredentialBlockCompatibilityTriggers(): void {
|
|
841
|
+
for (const event of ["INSERT", "UPDATE"] as const) {
|
|
842
|
+
const eventName = event.toLowerCase();
|
|
843
|
+
this.#db.run(`
|
|
844
|
+
CREATE TRIGGER IF NOT EXISTS auth_codex_shared_${eventName}_to_meters
|
|
845
|
+
AFTER ${event} ON auth_credential_blocks
|
|
846
|
+
WHEN NEW.provider_key = 'openai-codex:oauth'
|
|
847
|
+
AND NEW.block_scope = 'shared'
|
|
848
|
+
AND NOT EXISTS (
|
|
849
|
+
SELECT 1 FROM auth_credential_block_mirror_guard
|
|
850
|
+
WHERE credential_id = NEW.credential_id
|
|
851
|
+
)
|
|
852
|
+
BEGIN
|
|
853
|
+
INSERT OR IGNORE INTO auth_credential_block_mirror_guard (credential_id)
|
|
854
|
+
VALUES (NEW.credential_id);
|
|
855
|
+
INSERT INTO auth_credential_blocks (
|
|
856
|
+
credential_id,
|
|
857
|
+
provider_key,
|
|
858
|
+
block_scope,
|
|
859
|
+
blocked_until_ms,
|
|
860
|
+
updated_at
|
|
861
|
+
)
|
|
862
|
+
VALUES (
|
|
863
|
+
NEW.credential_id,
|
|
864
|
+
NEW.provider_key,
|
|
865
|
+
'chat',
|
|
866
|
+
NEW.blocked_until_ms,
|
|
867
|
+
NEW.updated_at
|
|
868
|
+
)
|
|
869
|
+
ON CONFLICT(credential_id, provider_key, block_scope) DO UPDATE SET
|
|
870
|
+
blocked_until_ms = MAX(auth_credential_blocks.blocked_until_ms, excluded.blocked_until_ms),
|
|
871
|
+
updated_at = MAX(auth_credential_blocks.updated_at, excluded.updated_at);
|
|
872
|
+
INSERT INTO auth_credential_blocks (
|
|
873
|
+
credential_id,
|
|
874
|
+
provider_key,
|
|
875
|
+
block_scope,
|
|
876
|
+
blocked_until_ms,
|
|
877
|
+
updated_at
|
|
878
|
+
)
|
|
879
|
+
VALUES (
|
|
880
|
+
NEW.credential_id,
|
|
881
|
+
NEW.provider_key,
|
|
882
|
+
'spark',
|
|
883
|
+
NEW.blocked_until_ms,
|
|
884
|
+
NEW.updated_at
|
|
885
|
+
)
|
|
886
|
+
ON CONFLICT(credential_id, provider_key, block_scope) DO UPDATE SET
|
|
887
|
+
blocked_until_ms = MAX(auth_credential_blocks.blocked_until_ms, excluded.blocked_until_ms),
|
|
888
|
+
updated_at = MAX(auth_credential_blocks.updated_at, excluded.updated_at);
|
|
889
|
+
DELETE FROM auth_credential_block_mirror_guard
|
|
890
|
+
WHERE credential_id = NEW.credential_id;
|
|
891
|
+
END;
|
|
892
|
+
|
|
893
|
+
CREATE TRIGGER IF NOT EXISTS auth_codex_meter_${eventName}_to_shared
|
|
894
|
+
AFTER ${event} ON auth_credential_blocks
|
|
895
|
+
WHEN NEW.provider_key = 'openai-codex:oauth'
|
|
896
|
+
AND NEW.block_scope IN ('chat', 'spark')
|
|
897
|
+
AND NOT EXISTS (
|
|
898
|
+
SELECT 1 FROM auth_credential_block_mirror_guard
|
|
899
|
+
WHERE credential_id = NEW.credential_id
|
|
900
|
+
)
|
|
901
|
+
BEGIN
|
|
902
|
+
INSERT OR IGNORE INTO auth_credential_block_mirror_guard (credential_id)
|
|
903
|
+
VALUES (NEW.credential_id);
|
|
904
|
+
DELETE FROM auth_credential_blocks
|
|
905
|
+
WHERE credential_id = NEW.credential_id
|
|
906
|
+
AND provider_key = NEW.provider_key
|
|
907
|
+
AND block_scope = 'shared';
|
|
908
|
+
INSERT INTO auth_credential_blocks (
|
|
909
|
+
credential_id,
|
|
910
|
+
provider_key,
|
|
911
|
+
block_scope,
|
|
912
|
+
blocked_until_ms,
|
|
913
|
+
updated_at
|
|
914
|
+
)
|
|
915
|
+
SELECT
|
|
916
|
+
NEW.credential_id,
|
|
917
|
+
NEW.provider_key,
|
|
918
|
+
'shared',
|
|
919
|
+
MAX(blocked_until_ms),
|
|
920
|
+
MAX(updated_at)
|
|
921
|
+
FROM auth_credential_blocks
|
|
922
|
+
WHERE credential_id = NEW.credential_id
|
|
923
|
+
AND provider_key = NEW.provider_key
|
|
924
|
+
AND block_scope IN ('chat', 'spark')
|
|
925
|
+
GROUP BY credential_id, provider_key;
|
|
926
|
+
DELETE FROM auth_credential_block_mirror_guard
|
|
927
|
+
WHERE credential_id = NEW.credential_id;
|
|
928
|
+
END;
|
|
929
|
+
`);
|
|
930
|
+
}
|
|
931
|
+
|
|
932
|
+
this.#db.run(`
|
|
933
|
+
CREATE TRIGGER IF NOT EXISTS auth_codex_shared_delete_to_meters
|
|
934
|
+
AFTER DELETE ON auth_credential_blocks
|
|
935
|
+
WHEN OLD.provider_key = 'openai-codex:oauth'
|
|
936
|
+
AND OLD.block_scope = 'shared'
|
|
937
|
+
AND NOT EXISTS (
|
|
938
|
+
SELECT 1 FROM auth_credential_block_mirror_guard
|
|
939
|
+
WHERE credential_id = OLD.credential_id
|
|
940
|
+
)
|
|
941
|
+
BEGIN
|
|
942
|
+
INSERT OR IGNORE INTO auth_credential_block_mirror_guard (credential_id)
|
|
943
|
+
VALUES (OLD.credential_id);
|
|
944
|
+
DELETE FROM auth_credential_blocks
|
|
945
|
+
WHERE credential_id = OLD.credential_id
|
|
946
|
+
AND provider_key = OLD.provider_key
|
|
947
|
+
AND block_scope IN ('chat', 'spark');
|
|
948
|
+
DELETE FROM auth_credential_block_mirror_guard
|
|
949
|
+
WHERE credential_id = OLD.credential_id;
|
|
950
|
+
END;
|
|
951
|
+
|
|
952
|
+
CREATE TRIGGER IF NOT EXISTS auth_codex_meter_delete_to_shared
|
|
953
|
+
AFTER DELETE ON auth_credential_blocks
|
|
954
|
+
WHEN OLD.provider_key = 'openai-codex:oauth'
|
|
955
|
+
AND OLD.block_scope IN ('chat', 'spark')
|
|
956
|
+
AND NOT EXISTS (
|
|
957
|
+
SELECT 1 FROM auth_credential_block_mirror_guard
|
|
958
|
+
WHERE credential_id = OLD.credential_id
|
|
959
|
+
)
|
|
960
|
+
BEGIN
|
|
961
|
+
INSERT OR IGNORE INTO auth_credential_block_mirror_guard (credential_id)
|
|
962
|
+
VALUES (OLD.credential_id);
|
|
963
|
+
DELETE FROM auth_credential_blocks
|
|
964
|
+
WHERE credential_id = OLD.credential_id
|
|
965
|
+
AND provider_key = OLD.provider_key
|
|
966
|
+
AND block_scope = 'shared';
|
|
967
|
+
INSERT INTO auth_credential_blocks (
|
|
968
|
+
credential_id,
|
|
969
|
+
provider_key,
|
|
970
|
+
block_scope,
|
|
971
|
+
blocked_until_ms,
|
|
972
|
+
updated_at
|
|
973
|
+
)
|
|
974
|
+
SELECT
|
|
975
|
+
OLD.credential_id,
|
|
976
|
+
OLD.provider_key,
|
|
977
|
+
'shared',
|
|
978
|
+
MAX(blocked_until_ms),
|
|
979
|
+
MAX(updated_at)
|
|
980
|
+
FROM auth_credential_blocks
|
|
981
|
+
WHERE credential_id = OLD.credential_id
|
|
982
|
+
AND provider_key = OLD.provider_key
|
|
983
|
+
AND block_scope IN ('chat', 'spark')
|
|
984
|
+
GROUP BY credential_id, provider_key;
|
|
985
|
+
DELETE FROM auth_credential_block_mirror_guard
|
|
986
|
+
WHERE credential_id = OLD.credential_id;
|
|
987
|
+
END;
|
|
988
|
+
`);
|
|
989
|
+
}
|
|
990
|
+
|
|
991
|
+
#createAuthCredentialBlockCompatibilityObjects(): void {
|
|
992
|
+
this.#createAuthCredentialBlockMirrorGuardTable();
|
|
993
|
+
this.#createAuthCredentialBlockCompatibilityTriggers();
|
|
994
|
+
}
|
|
995
|
+
|
|
996
|
+
#createAuthCredentialRefreshLeasesTable(): void {
|
|
997
|
+
SqliteAuthCredentialStore.#ensureAuthCredentialRefreshLeasesTable(this.#db);
|
|
998
|
+
}
|
|
999
|
+
|
|
1000
|
+
#migrateAuthSchema(fromVersion: number): void {
|
|
1001
|
+
if (fromVersion < 1) {
|
|
1002
|
+
this.#migrateAuthSchemaV0ToV1();
|
|
1003
|
+
}
|
|
1004
|
+
if (fromVersion < 3) {
|
|
1005
|
+
this.#migrateAuthSchemaV1OrV2ToV3();
|
|
1006
|
+
}
|
|
1007
|
+
if (fromVersion < 4) {
|
|
1008
|
+
this.#migrateAuthSchemaV3ToV4();
|
|
1009
|
+
}
|
|
1010
|
+
if (fromVersion < 5) {
|
|
1011
|
+
this.#migrateAuthSchemaV4ToV5();
|
|
1012
|
+
}
|
|
1013
|
+
if (fromVersion < 6) {
|
|
1014
|
+
this.#migrateAuthSchemaV5ToV6();
|
|
1015
|
+
}
|
|
1016
|
+
if (fromVersion < 7) {
|
|
1017
|
+
this.#migrateAuthSchemaV6ToV7();
|
|
1018
|
+
}
|
|
1019
|
+
}
|
|
1020
|
+
|
|
1021
|
+
#migrateAuthSchemaV0ToV1(): void {
|
|
1022
|
+
const migrate = this.#db.transaction(() => {
|
|
1023
|
+
const stmt = this.#db.prepare("PRAGMA table_info(auth_credentials)");
|
|
1024
|
+
let hasDisabled = false;
|
|
1025
|
+
try {
|
|
1026
|
+
const v0Cols = stmt.all() as Array<{ name?: string }>;
|
|
1027
|
+
hasDisabled = v0Cols.some(col => col.name === "disabled");
|
|
1028
|
+
} finally {
|
|
1029
|
+
stmt.finalize();
|
|
1030
|
+
}
|
|
1031
|
+
|
|
1032
|
+
this.#db.run("ALTER TABLE auth_credentials RENAME TO auth_credentials_v0");
|
|
1033
|
+
this.#db.run(`
|
|
1034
|
+
CREATE TABLE auth_credentials (
|
|
1035
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
1036
|
+
provider TEXT NOT NULL,
|
|
1037
|
+
credential_type TEXT NOT NULL,
|
|
1038
|
+
data TEXT NOT NULL,
|
|
1039
|
+
disabled_cause TEXT DEFAULT NULL,
|
|
1040
|
+
created_at INTEGER NOT NULL DEFAULT (${SQLITE_NOW_EPOCH}),
|
|
1041
|
+
updated_at INTEGER NOT NULL DEFAULT (${SQLITE_NOW_EPOCH})
|
|
1042
|
+
);
|
|
1043
|
+
`);
|
|
1044
|
+
this.#db.run(`
|
|
1045
|
+
INSERT INTO auth_credentials (id, provider, credential_type, data, disabled_cause, created_at, updated_at)
|
|
1046
|
+
SELECT
|
|
1047
|
+
id,
|
|
1048
|
+
provider,
|
|
1049
|
+
credential_type,
|
|
1050
|
+
data,
|
|
1051
|
+
${hasDisabled ? "CASE WHEN disabled = 1 THEN 'disabled' ELSE NULL END" : "NULL"},
|
|
1052
|
+
created_at,
|
|
1053
|
+
updated_at
|
|
1054
|
+
FROM auth_credentials_v0
|
|
1055
|
+
`);
|
|
1056
|
+
this.#db.run("DROP TABLE auth_credentials_v0");
|
|
1057
|
+
});
|
|
1058
|
+
migrate();
|
|
1059
|
+
}
|
|
1060
|
+
|
|
1061
|
+
#migrateAuthSchemaV1OrV2ToV3(): void {
|
|
1062
|
+
const migrate = this.#db.transaction(() => {
|
|
1063
|
+
this.#db.run("ALTER TABLE auth_credentials RENAME TO auth_credentials_legacy");
|
|
1064
|
+
this.#createAuthCredentialsTable();
|
|
1065
|
+
this.#db.run(`
|
|
1066
|
+
INSERT INTO auth_credentials (id, provider, credential_type, data, disabled_cause, identity_key, created_at, updated_at)
|
|
1067
|
+
SELECT
|
|
1068
|
+
id,
|
|
1069
|
+
provider,
|
|
1070
|
+
credential_type,
|
|
1071
|
+
data,
|
|
1072
|
+
disabled_cause,
|
|
1073
|
+
NULL,
|
|
1074
|
+
created_at,
|
|
1075
|
+
updated_at
|
|
1076
|
+
FROM auth_credentials_legacy
|
|
1077
|
+
`);
|
|
1078
|
+
this.#db.run("DROP TABLE auth_credentials_legacy");
|
|
1079
|
+
});
|
|
1080
|
+
migrate();
|
|
1081
|
+
}
|
|
1082
|
+
|
|
1083
|
+
#migrateAuthSchemaV3ToV4(): void {
|
|
1084
|
+
const migrate = this.#db.transaction(() => {
|
|
1085
|
+
this.#db.run("ALTER TABLE auth_credentials RENAME TO auth_credentials_v3");
|
|
1086
|
+
this.#createAuthCredentialsTable();
|
|
1087
|
+
this.#db.run(`
|
|
1088
|
+
INSERT INTO auth_credentials (id, provider, credential_type, data, disabled_cause, identity_key, created_at, updated_at)
|
|
1089
|
+
SELECT
|
|
1090
|
+
id,
|
|
1091
|
+
provider,
|
|
1092
|
+
credential_type,
|
|
1093
|
+
data,
|
|
1094
|
+
disabled_cause,
|
|
1095
|
+
identity_key,
|
|
1096
|
+
created_at,
|
|
1097
|
+
updated_at
|
|
1098
|
+
FROM auth_credentials_v3
|
|
1099
|
+
`);
|
|
1100
|
+
this.#db.run("DROP TABLE auth_credentials_v3");
|
|
1101
|
+
});
|
|
1102
|
+
migrate();
|
|
1103
|
+
}
|
|
1104
|
+
|
|
1105
|
+
#migrateAuthSchemaV4ToV5(): void {
|
|
1106
|
+
const migrate = this.#db.transaction(() => {
|
|
1107
|
+
this.#createAuthCredentialBlocksTable();
|
|
1108
|
+
});
|
|
1109
|
+
migrate();
|
|
1110
|
+
}
|
|
1111
|
+
|
|
1112
|
+
#migrateAuthSchemaV5ToV6(): void {
|
|
1113
|
+
const migrate = this.#db.transaction(() => {
|
|
1114
|
+
this.#createAuthCredentialRefreshLeasesTable();
|
|
1115
|
+
});
|
|
1116
|
+
migrate();
|
|
1117
|
+
}
|
|
1118
|
+
|
|
1119
|
+
#migrateAuthSchemaV6ToV7(): void {
|
|
1120
|
+
const migrate = this.#db.transaction(() => {
|
|
1121
|
+
this.#createAuthCredentialBlocksTable();
|
|
1122
|
+
this.#createAuthCredentialBlockMirrorGuardTable();
|
|
1123
|
+
this.#db.run(`
|
|
1124
|
+
DELETE FROM auth_credential_block_mirror_guard;
|
|
1125
|
+
INSERT OR IGNORE INTO auth_credential_block_mirror_guard (credential_id)
|
|
1126
|
+
SELECT DISTINCT credential_id
|
|
1127
|
+
FROM auth_credential_blocks
|
|
1128
|
+
WHERE provider_key = 'openai-codex:oauth'
|
|
1129
|
+
AND block_scope IN ('chat', 'spark', 'shared');
|
|
1130
|
+
|
|
1131
|
+
INSERT INTO auth_credential_blocks (
|
|
1132
|
+
credential_id,
|
|
1133
|
+
provider_key,
|
|
1134
|
+
block_scope,
|
|
1135
|
+
blocked_until_ms,
|
|
1136
|
+
updated_at
|
|
1137
|
+
)
|
|
1138
|
+
SELECT credential_id, provider_key, 'chat', blocked_until_ms, updated_at
|
|
1139
|
+
FROM auth_credential_blocks
|
|
1140
|
+
WHERE provider_key = 'openai-codex:oauth'
|
|
1141
|
+
AND block_scope = 'shared'
|
|
1142
|
+
ON CONFLICT(credential_id, provider_key, block_scope) DO UPDATE SET
|
|
1143
|
+
blocked_until_ms = MAX(auth_credential_blocks.blocked_until_ms, excluded.blocked_until_ms),
|
|
1144
|
+
updated_at = MAX(auth_credential_blocks.updated_at, excluded.updated_at);
|
|
1145
|
+
|
|
1146
|
+
INSERT INTO auth_credential_blocks (
|
|
1147
|
+
credential_id,
|
|
1148
|
+
provider_key,
|
|
1149
|
+
block_scope,
|
|
1150
|
+
blocked_until_ms,
|
|
1151
|
+
updated_at
|
|
1152
|
+
)
|
|
1153
|
+
SELECT credential_id, provider_key, 'spark', blocked_until_ms, updated_at
|
|
1154
|
+
FROM auth_credential_blocks
|
|
1155
|
+
WHERE provider_key = 'openai-codex:oauth'
|
|
1156
|
+
AND block_scope = 'shared'
|
|
1157
|
+
ON CONFLICT(credential_id, provider_key, block_scope) DO UPDATE SET
|
|
1158
|
+
blocked_until_ms = MAX(auth_credential_blocks.blocked_until_ms, excluded.blocked_until_ms),
|
|
1159
|
+
updated_at = MAX(auth_credential_blocks.updated_at, excluded.updated_at);
|
|
1160
|
+
|
|
1161
|
+
INSERT INTO auth_credential_blocks (
|
|
1162
|
+
credential_id,
|
|
1163
|
+
provider_key,
|
|
1164
|
+
block_scope,
|
|
1165
|
+
blocked_until_ms,
|
|
1166
|
+
updated_at
|
|
1167
|
+
)
|
|
1168
|
+
SELECT
|
|
1169
|
+
credential_id,
|
|
1170
|
+
provider_key,
|
|
1171
|
+
'shared',
|
|
1172
|
+
MAX(blocked_until_ms),
|
|
1173
|
+
MAX(updated_at)
|
|
1174
|
+
FROM auth_credential_blocks
|
|
1175
|
+
WHERE provider_key = 'openai-codex:oauth'
|
|
1176
|
+
AND block_scope IN ('chat', 'spark')
|
|
1177
|
+
GROUP BY credential_id, provider_key
|
|
1178
|
+
ON CONFLICT(credential_id, provider_key, block_scope) DO UPDATE SET
|
|
1179
|
+
blocked_until_ms = excluded.blocked_until_ms,
|
|
1180
|
+
updated_at = excluded.updated_at;
|
|
1181
|
+
|
|
1182
|
+
DELETE FROM auth_credential_block_mirror_guard;
|
|
1183
|
+
`);
|
|
1184
|
+
this.#createAuthCredentialBlockCompatibilityTriggers();
|
|
1185
|
+
this.#writeAuthSchemaVersion(7);
|
|
1186
|
+
});
|
|
1187
|
+
migrate.immediate();
|
|
1188
|
+
}
|
|
1189
|
+
|
|
1190
|
+
#backfillCredentialIdentityKeys(): void {
|
|
1191
|
+
const selectRowsStmt = this.#db.prepare(
|
|
1192
|
+
"SELECT id, provider, credential_type, data, disabled_cause, identity_key FROM auth_credentials WHERE identity_key IS NULL ORDER BY id ASC",
|
|
1193
|
+
);
|
|
1194
|
+
let rows: AuthRow[];
|
|
1195
|
+
try {
|
|
1196
|
+
rows = selectRowsStmt.all() as AuthRow[];
|
|
1197
|
+
} finally {
|
|
1198
|
+
selectRowsStmt.finalize();
|
|
1199
|
+
}
|
|
1200
|
+
if (rows.length === 0) return;
|
|
1201
|
+
|
|
1202
|
+
let updateIdentity: Statement | null = null;
|
|
1203
|
+
try {
|
|
1204
|
+
for (const row of rows) {
|
|
1205
|
+
const identityKey = resolveRowCredentialIdentityKey(row.provider, row);
|
|
1206
|
+
// Rows whose identity cannot be derived stay NULL; writing NULL over
|
|
1207
|
+
// NULL would just burn a write transaction on every boot.
|
|
1208
|
+
if (identityKey === null) continue;
|
|
1209
|
+
updateIdentity ??= this.#db.prepare("UPDATE auth_credentials SET identity_key = ? WHERE id = ?");
|
|
1210
|
+
updateIdentity.run(identityKey, row.id);
|
|
1211
|
+
}
|
|
1212
|
+
} finally {
|
|
1213
|
+
updateIdentity?.finalize();
|
|
1214
|
+
}
|
|
1215
|
+
}
|
|
1216
|
+
|
|
1217
|
+
// ─── AuthCredentialStore interface ──────────────────────────────────────
|
|
1218
|
+
|
|
1219
|
+
listAuthCredentials(provider?: string): StoredAuthCredential[] {
|
|
1220
|
+
const rows =
|
|
1221
|
+
(provider
|
|
1222
|
+
? (this.#listActiveByProviderStmt.all(provider) as AuthRow[])
|
|
1223
|
+
: (this.#listActiveStmt.all() as AuthRow[])) ?? [];
|
|
1224
|
+
|
|
1225
|
+
const results: StoredAuthCredential[] = [];
|
|
1226
|
+
for (const row of rows) {
|
|
1227
|
+
const credential = deserializeCredential(row);
|
|
1228
|
+
if (!credential) continue;
|
|
1229
|
+
results.push(toStoredAuthCredential(row, credential));
|
|
1230
|
+
}
|
|
1231
|
+
return results;
|
|
1232
|
+
}
|
|
1233
|
+
|
|
1234
|
+
async listDisabledCredentials(provider?: string): Promise<DisabledCredentialSummary[]> {
|
|
1235
|
+
const rows =
|
|
1236
|
+
(provider
|
|
1237
|
+
? (this.#listDisabledByProviderStmt.all(provider) as DisabledAuthRow[])
|
|
1238
|
+
: (this.#listDisabledStmt.all() as DisabledAuthRow[])) ?? [];
|
|
1239
|
+
const results: DisabledCredentialSummary[] = [];
|
|
1240
|
+
for (const row of rows) {
|
|
1241
|
+
const credential = deserializeCredential(row);
|
|
1242
|
+
const summary: DisabledCredentialSummary = {
|
|
1243
|
+
id: row.id,
|
|
1244
|
+
provider: row.provider,
|
|
1245
|
+
type: row.credential_type === "api_key" ? "api_key" : "oauth",
|
|
1246
|
+
cause: row.disabled_cause ?? "disabled",
|
|
1247
|
+
};
|
|
1248
|
+
if (credential?.type === "oauth") {
|
|
1249
|
+
if (credential.email) summary.email = credential.email;
|
|
1250
|
+
if (credential.accountId) summary.accountId = credential.accountId;
|
|
1251
|
+
if (credential.orgId) summary.orgId = credential.orgId;
|
|
1252
|
+
if (credential.orgName) summary.orgName = credential.orgName;
|
|
1253
|
+
}
|
|
1254
|
+
if (typeof row.updated_at === "number" && Number.isFinite(row.updated_at)) {
|
|
1255
|
+
summary.disabledAtMs = row.updated_at * 1000;
|
|
1256
|
+
}
|
|
1257
|
+
results.push(summary);
|
|
1258
|
+
}
|
|
1259
|
+
return results;
|
|
1260
|
+
}
|
|
1261
|
+
|
|
1262
|
+
replaceAuthCredentialsForProvider(provider: string, credentials: AuthCredential[]): StoredAuthCredential[] {
|
|
1263
|
+
const replace = this.#db.transaction((providerName: string, items: AuthCredential[]) => {
|
|
1264
|
+
const existingRows = this.#listActiveByProviderStmt.all(providerName) as AuthRow[];
|
|
1265
|
+
const existing = existingRows.map(row => ({
|
|
1266
|
+
id: row.id,
|
|
1267
|
+
credential: deserializeCredential(row),
|
|
1268
|
+
identityKey: resolveRowCredentialIdentityKey(providerName, row),
|
|
1269
|
+
}));
|
|
1270
|
+
|
|
1271
|
+
const result: StoredAuthCredential[] = [];
|
|
1272
|
+
const matchedExistingIds = new Set<number>();
|
|
1273
|
+
|
|
1274
|
+
for (const credential of items) {
|
|
1275
|
+
const serialized = serializeCredential(providerName, credential);
|
|
1276
|
+
if (!serialized) continue;
|
|
1277
|
+
const match = existing.find(
|
|
1278
|
+
entry =>
|
|
1279
|
+
!matchedExistingIds.has(entry.id) &&
|
|
1280
|
+
matchesReplacementCredential(providerName, entry.credential, entry.identityKey, credential),
|
|
1281
|
+
);
|
|
1282
|
+
if (match) {
|
|
1283
|
+
matchedExistingIds.add(match.id);
|
|
1284
|
+
this.#updateStmt.run(serialized.credentialType, serialized.data, serialized.identityKey, match.id);
|
|
1285
|
+
result.push({ id: match.id, provider: providerName, credential, disabledCause: null });
|
|
1286
|
+
} else {
|
|
1287
|
+
const row = this.#insertStmt.get(
|
|
1288
|
+
providerName,
|
|
1289
|
+
serialized.credentialType,
|
|
1290
|
+
serialized.data,
|
|
1291
|
+
serialized.identityKey,
|
|
1292
|
+
) as { id?: number } | undefined;
|
|
1293
|
+
if (row?.id) {
|
|
1294
|
+
result.push({ id: row.id, provider: providerName, credential, disabledCause: null });
|
|
1295
|
+
}
|
|
1296
|
+
}
|
|
1297
|
+
}
|
|
1298
|
+
|
|
1299
|
+
for (const row of existing) {
|
|
1300
|
+
if (!matchedExistingIds.has(row.id)) {
|
|
1301
|
+
this.#deleteStmt.run("replaced by newer credential", row.id);
|
|
1302
|
+
}
|
|
1303
|
+
}
|
|
1304
|
+
|
|
1305
|
+
return result;
|
|
1306
|
+
});
|
|
1307
|
+
|
|
1308
|
+
const result = replace(provider, credentials);
|
|
1309
|
+
this.#purgeSupersededDisabledRows(provider, result);
|
|
1310
|
+
return result;
|
|
1311
|
+
}
|
|
1312
|
+
|
|
1313
|
+
upsertAuthCredentialForProvider(provider: string, credential: AuthCredential): StoredAuthCredential[] {
|
|
1314
|
+
const upsert = this.#db.transaction((providerName: string, item: AuthCredential) => {
|
|
1315
|
+
const serialized = serializeCredential(providerName, item);
|
|
1316
|
+
if (!serialized) return this.listAuthCredentials(providerName);
|
|
1317
|
+
const existingRows = this.#listActiveByProviderStmt.all(providerName) as AuthRow[];
|
|
1318
|
+
const existing = existingRows.map(row => ({
|
|
1319
|
+
id: row.id,
|
|
1320
|
+
credential: deserializeCredential(row),
|
|
1321
|
+
identityKey: resolveRowCredentialIdentityKey(providerName, row),
|
|
1322
|
+
}));
|
|
1323
|
+
|
|
1324
|
+
if (item.type === "oauth") {
|
|
1325
|
+
for (const row of existing) {
|
|
1326
|
+
if (row.credential && row.credential.type === "api_key") {
|
|
1327
|
+
this.#deleteStmt.run("replaced by oauth login", row.id);
|
|
1328
|
+
}
|
|
1329
|
+
}
|
|
1330
|
+
}
|
|
1331
|
+
|
|
1332
|
+
let targetId: number | null = null;
|
|
1333
|
+
for (const row of existing) {
|
|
1334
|
+
if (!matchesReplacementCredential(providerName, row.credential, row.identityKey, item)) continue;
|
|
1335
|
+
if (targetId === null) {
|
|
1336
|
+
targetId = row.id;
|
|
1337
|
+
this.#updateStmt.run(serialized.credentialType, serialized.data, serialized.identityKey, row.id);
|
|
1338
|
+
continue;
|
|
1339
|
+
}
|
|
1340
|
+
this.#deleteStmt.run("replaced by newer credential", row.id);
|
|
1341
|
+
}
|
|
1342
|
+
|
|
1343
|
+
if (targetId === null) {
|
|
1344
|
+
const row = this.#insertStmt.get(
|
|
1345
|
+
providerName,
|
|
1346
|
+
serialized.credentialType,
|
|
1347
|
+
serialized.data,
|
|
1348
|
+
serialized.identityKey,
|
|
1349
|
+
) as { id?: number } | undefined;
|
|
1350
|
+
targetId = row?.id ?? null;
|
|
1351
|
+
}
|
|
1352
|
+
|
|
1353
|
+
const activeRows = this.#listActiveByProviderStmt.all(providerName) as AuthRow[];
|
|
1354
|
+
const result: StoredAuthCredential[] = [];
|
|
1355
|
+
for (const row of activeRows) {
|
|
1356
|
+
const activeCredential = deserializeCredential(row);
|
|
1357
|
+
if (!activeCredential) continue;
|
|
1358
|
+
result.push(toStoredAuthCredential(row, activeCredential));
|
|
1359
|
+
}
|
|
1360
|
+
return result;
|
|
1361
|
+
});
|
|
1362
|
+
|
|
1363
|
+
const result = upsert(provider, credential);
|
|
1364
|
+
this.#purgeSupersededDisabledRows(provider, result);
|
|
1365
|
+
return result;
|
|
1366
|
+
}
|
|
1367
|
+
|
|
1368
|
+
/**
|
|
1369
|
+
* Hard-deletes disabled rows for a provider when an active replacement exists.
|
|
1370
|
+
* OAuth credentials match by identity key; API keys match by provider and type.
|
|
1371
|
+
* Disabled rows without an active same-type replacement remain recoverable.
|
|
1372
|
+
*/
|
|
1373
|
+
#purgeSupersededDisabledRows(provider: string, activeRows: StoredAuthCredential[]): void {
|
|
1374
|
+
try {
|
|
1375
|
+
let hasActiveApiKey = false;
|
|
1376
|
+
const activeIdentityKeys = new Set<string>();
|
|
1377
|
+
const activeOAuthCredentials: AuthCredential[] = [];
|
|
1378
|
+
for (const row of activeRows) {
|
|
1379
|
+
if (row.credential.type === "api_key") {
|
|
1380
|
+
hasActiveApiKey = true;
|
|
1381
|
+
continue;
|
|
1382
|
+
}
|
|
1383
|
+
activeOAuthCredentials.push(row.credential);
|
|
1384
|
+
const identityKey = resolveCredentialIdentityKey(provider, row.credential);
|
|
1385
|
+
if (identityKey) activeIdentityKeys.add(identityKey);
|
|
1386
|
+
}
|
|
1387
|
+
if (!hasActiveApiKey && activeIdentityKeys.size === 0) return;
|
|
1388
|
+
|
|
1389
|
+
const disabledRows = this.#listDisabledByProviderStmt.all(provider) as AuthRow[];
|
|
1390
|
+
for (const row of disabledRows) {
|
|
1391
|
+
if (hasActiveApiKey && row.credential_type === "api_key") {
|
|
1392
|
+
this.#hardDeleteStmt.run(row.id);
|
|
1393
|
+
continue;
|
|
1394
|
+
}
|
|
1395
|
+
const identityKey = resolveRowCredentialIdentityKey(provider, row);
|
|
1396
|
+
if (identityKey && activeIdentityKeys.has(identityKey)) {
|
|
1397
|
+
this.#hardDeleteStmt.run(row.id);
|
|
1398
|
+
continue;
|
|
1399
|
+
}
|
|
1400
|
+
// Exact key equality misses a tombstone whose key predates a format
|
|
1401
|
+
// the active row now uses (pre-org `<b>` vs `<b>|org:<o>`). An active
|
|
1402
|
+
// credential that WOULD have replaced this row had it still been
|
|
1403
|
+
// active supersedes its tombstone too, so mirror the replacement
|
|
1404
|
+
// matcher rather than restating a weaker rule. The one-way upgrade
|
|
1405
|
+
// and shared-workspace guards in matchesReplacementCredential carry
|
|
1406
|
+
// over, so this never over-deletes another member's or subscription's
|
|
1407
|
+
// row.
|
|
1408
|
+
const disabledCredential = deserializeCredential(row);
|
|
1409
|
+
if (disabledCredential === null) continue;
|
|
1410
|
+
const superseded = activeOAuthCredentials.some(active =>
|
|
1411
|
+
matchesReplacementCredential(provider, disabledCredential, identityKey, active),
|
|
1412
|
+
);
|
|
1413
|
+
if (superseded) this.#hardDeleteStmt.run(row.id);
|
|
1414
|
+
}
|
|
1415
|
+
} catch {
|
|
1416
|
+
// Best-effort cleanup; don't let it break the main operation
|
|
1417
|
+
}
|
|
1418
|
+
}
|
|
1419
|
+
|
|
1420
|
+
updateAuthCredential(id: number, credential: AuthCredential): void {
|
|
1421
|
+
try {
|
|
1422
|
+
const providerStmt = this.#db.prepare("SELECT provider FROM auth_credentials WHERE id = ?");
|
|
1423
|
+
let providerRow: { provider?: string } | undefined;
|
|
1424
|
+
try {
|
|
1425
|
+
providerRow = providerStmt.get(id) as { provider?: string } | undefined;
|
|
1426
|
+
} finally {
|
|
1427
|
+
providerStmt.finalize();
|
|
1428
|
+
}
|
|
1429
|
+
const provider = providerRow?.provider ?? "";
|
|
1430
|
+
const serialized = serializeCredential(provider, credential);
|
|
1431
|
+
if (!serialized) return;
|
|
1432
|
+
this.#updateStmt.run(serialized.credentialType, serialized.data, serialized.identityKey, id);
|
|
1433
|
+
if (provider) {
|
|
1434
|
+
this.#purgeSupersededDisabledRows(provider, this.listAuthCredentials(provider));
|
|
1435
|
+
}
|
|
1436
|
+
} catch {
|
|
1437
|
+
// Ignore update failures
|
|
1438
|
+
}
|
|
1439
|
+
}
|
|
1440
|
+
|
|
1441
|
+
tryUpdateAuthCredentialIfMatches(
|
|
1442
|
+
id: number,
|
|
1443
|
+
expectedData: string,
|
|
1444
|
+
credential: AuthCredential,
|
|
1445
|
+
lease?: CredentialRefreshLeaseFence,
|
|
1446
|
+
): boolean {
|
|
1447
|
+
const providerStmt = this.#db.prepare("SELECT provider FROM auth_credentials WHERE id = ?");
|
|
1448
|
+
let providerRow: { provider?: string } | undefined;
|
|
1449
|
+
try {
|
|
1450
|
+
providerRow = providerStmt.get(id) as { provider?: string } | undefined;
|
|
1451
|
+
} finally {
|
|
1452
|
+
providerStmt.finalize();
|
|
1453
|
+
}
|
|
1454
|
+
const provider = providerRow?.provider ?? "";
|
|
1455
|
+
const serialized = serializeCredential(provider, credential);
|
|
1456
|
+
if (!serialized) return false;
|
|
1457
|
+
const result = lease
|
|
1458
|
+
? (this.#updateIfMatchesWithLeaseStmt.run(
|
|
1459
|
+
serialized.credentialType,
|
|
1460
|
+
serialized.data,
|
|
1461
|
+
serialized.identityKey,
|
|
1462
|
+
id,
|
|
1463
|
+
expectedData,
|
|
1464
|
+
id,
|
|
1465
|
+
lease.owner,
|
|
1466
|
+
lease.nowMs,
|
|
1467
|
+
) as { changes: number })
|
|
1468
|
+
: (this.#updateIfMatchesStmt.run(
|
|
1469
|
+
serialized.credentialType,
|
|
1470
|
+
serialized.data,
|
|
1471
|
+
serialized.identityKey,
|
|
1472
|
+
id,
|
|
1473
|
+
expectedData,
|
|
1474
|
+
) as { changes: number });
|
|
1475
|
+
if (result.changes === 0) return false;
|
|
1476
|
+
if (provider) {
|
|
1477
|
+
this.#purgeSupersededDisabledRows(provider, this.listAuthCredentials(provider));
|
|
1478
|
+
}
|
|
1479
|
+
return true;
|
|
1480
|
+
}
|
|
1481
|
+
|
|
1482
|
+
deleteAuthCredential(id: number, disabledCause: string): void {
|
|
1483
|
+
try {
|
|
1484
|
+
this.#deleteStmt.run(normalizeDisabledCause(disabledCause), id);
|
|
1485
|
+
} catch {
|
|
1486
|
+
// Ignore delete failures
|
|
1487
|
+
}
|
|
1488
|
+
}
|
|
1489
|
+
|
|
1490
|
+
/**
|
|
1491
|
+
* CAS-style disable: only soft-deletes the row when its `data` column still
|
|
1492
|
+
* matches `expectedData` and the row has not already been disabled. Used by
|
|
1493
|
+
* the OAuth refresh-failure path to avoid clobbering a peer that rotated the
|
|
1494
|
+
* row between our pre-check and the disable.
|
|
1495
|
+
*/
|
|
1496
|
+
tryDisableAuthCredentialIfMatches(
|
|
1497
|
+
id: number,
|
|
1498
|
+
expectedData: string,
|
|
1499
|
+
disabledCause: string,
|
|
1500
|
+
lease?: CredentialRefreshLeaseFence,
|
|
1501
|
+
): boolean {
|
|
1502
|
+
const result = lease
|
|
1503
|
+
? (this.#deleteIfMatchesWithLeaseStmt.run(
|
|
1504
|
+
normalizeDisabledCause(disabledCause),
|
|
1505
|
+
id,
|
|
1506
|
+
expectedData,
|
|
1507
|
+
id,
|
|
1508
|
+
lease.owner,
|
|
1509
|
+
lease.nowMs,
|
|
1510
|
+
) as { changes: number })
|
|
1511
|
+
: (this.#deleteIfMatchesStmt.run(normalizeDisabledCause(disabledCause), id, expectedData) as {
|
|
1512
|
+
changes: number;
|
|
1513
|
+
});
|
|
1514
|
+
return result.changes > 0;
|
|
1515
|
+
}
|
|
1516
|
+
deleteAuthCredentialsForProvider(provider: string, disabledCause: string): void {
|
|
1517
|
+
try {
|
|
1518
|
+
this.#deleteByProviderStmt.run(normalizeDisabledCause(disabledCause), provider);
|
|
1519
|
+
} catch {
|
|
1520
|
+
// Ignore delete failures
|
|
1521
|
+
}
|
|
1522
|
+
}
|
|
1523
|
+
|
|
1524
|
+
getCache(key: string, options?: { includeExpired?: boolean }): string | null {
|
|
1525
|
+
try {
|
|
1526
|
+
const stmt = options?.includeExpired === true ? this.#getCacheIncludingExpiredStmt : this.#getCacheStmt;
|
|
1527
|
+
const row = stmt.get(key) as { value?: string } | undefined;
|
|
1528
|
+
return row?.value ?? null;
|
|
1529
|
+
} catch {
|
|
1530
|
+
return null;
|
|
1531
|
+
}
|
|
1532
|
+
}
|
|
1533
|
+
|
|
1534
|
+
setCache(key: string, value: string, expiresAtSec: number): void {
|
|
1535
|
+
try {
|
|
1536
|
+
this.#upsertCacheStmt.run(key, value, expiresAtSec);
|
|
1537
|
+
} catch {
|
|
1538
|
+
// Ignore cache set failures
|
|
1539
|
+
}
|
|
1540
|
+
}
|
|
1541
|
+
|
|
1542
|
+
/** Drop all cache rows whose keys start with the supplied prefix. */
|
|
1543
|
+
deleteCachePrefix(prefix: string): void {
|
|
1544
|
+
try {
|
|
1545
|
+
this.#deleteCachePrefixStmt.run(prefix.length, prefix);
|
|
1546
|
+
} catch {
|
|
1547
|
+
// Ignore cache delete failures
|
|
1548
|
+
}
|
|
1549
|
+
}
|
|
1550
|
+
|
|
1551
|
+
cleanExpiredCache(): void {
|
|
1552
|
+
try {
|
|
1553
|
+
this.#deleteExpiredCacheStmt.run();
|
|
1554
|
+
} catch {
|
|
1555
|
+
// Ignore cleanup errors
|
|
1556
|
+
}
|
|
1557
|
+
}
|
|
1558
|
+
|
|
1559
|
+
getCredentialBlock(credentialId: number, providerKey: string, blockScope: string): number | undefined {
|
|
1560
|
+
const nowMs = Date.now();
|
|
1561
|
+
const isCodexBlock = providerKey === LEGACY_CODEX_BLOCK_PROVIDER_KEY;
|
|
1562
|
+
// Current callers use meter scopes. The physical shared row exists only
|
|
1563
|
+
// for direct SQLite readers from pre-meter releases.
|
|
1564
|
+
if (isCodexBlock && blockScope === LEGACY_CODEX_BLOCK_SCOPE) {
|
|
1565
|
+
return undefined;
|
|
1566
|
+
}
|
|
1567
|
+
if (!isCodexBlock) this.#deleteExpiredCredentialBlocksStmt.run(nowMs);
|
|
1568
|
+
const row = this.#getCredentialBlockStmt.get(credentialId, providerKey, blockScope, nowMs) as
|
|
1569
|
+
| { blocked_until_ms?: number; updated_at?: number }
|
|
1570
|
+
| undefined;
|
|
1571
|
+
return typeof row?.blocked_until_ms === "number" ? row.blocked_until_ms : undefined;
|
|
1572
|
+
}
|
|
1573
|
+
|
|
1574
|
+
getCredentialBlockReconcileAfter(credentialId: number, providerKey: string, blockScope: string): number | undefined {
|
|
1575
|
+
const nowMs = Date.now();
|
|
1576
|
+
const isCodexBlock = providerKey === LEGACY_CODEX_BLOCK_PROVIDER_KEY;
|
|
1577
|
+
if (isCodexBlock && blockScope === LEGACY_CODEX_BLOCK_SCOPE) {
|
|
1578
|
+
return undefined;
|
|
1579
|
+
}
|
|
1580
|
+
if (!isCodexBlock) this.#deleteExpiredCredentialBlocksStmt.run(nowMs);
|
|
1581
|
+
const row = this.#getCredentialBlockStmt.get(credentialId, providerKey, blockScope, nowMs) as
|
|
1582
|
+
| { blocked_until_ms?: number; updated_at?: number }
|
|
1583
|
+
| undefined;
|
|
1584
|
+
if (typeof row?.blocked_until_ms !== "number") return undefined;
|
|
1585
|
+
const memoryReconcileAfter =
|
|
1586
|
+
this.#credentialBlockReconcileAfter.get(`${credentialId}\0${providerKey}\0${blockScope}`) ?? 0;
|
|
1587
|
+
const persistedReconcileAfter =
|
|
1588
|
+
typeof row.updated_at === "number" ? row.updated_at * 1000 + USAGE_REPORT_TTL_MS : 0;
|
|
1589
|
+
const reconcileAfter = Math.max(memoryReconcileAfter, persistedReconcileAfter);
|
|
1590
|
+
return reconcileAfter > nowMs ? Math.min(row.blocked_until_ms, reconcileAfter) : undefined;
|
|
1591
|
+
}
|
|
1592
|
+
|
|
1593
|
+
upsertCredentialBlock(block: StoredCredentialBlock): void {
|
|
1594
|
+
const isLegacyCodexBlock =
|
|
1595
|
+
block.providerKey === LEGACY_CODEX_BLOCK_PROVIDER_KEY && block.blockScope === LEGACY_CODEX_BLOCK_SCOPE;
|
|
1596
|
+
const blockScopes = isLegacyCodexBlock ? CODEX_METER_BLOCK_SCOPES : [block.blockScope];
|
|
1597
|
+
const upsert = this.#db.transaction(() => {
|
|
1598
|
+
for (const blockScope of blockScopes) {
|
|
1599
|
+
this.#upsertCredentialBlockStmt.run(
|
|
1600
|
+
block.credentialId,
|
|
1601
|
+
block.providerKey,
|
|
1602
|
+
blockScope,
|
|
1603
|
+
block.blockedUntilMs,
|
|
1604
|
+
);
|
|
1605
|
+
}
|
|
1606
|
+
});
|
|
1607
|
+
upsert.immediate();
|
|
1608
|
+
|
|
1609
|
+
const reconcileAfterMs = Math.min(block.blockedUntilMs, Date.now() + USAGE_REPORT_TTL_MS);
|
|
1610
|
+
for (const blockScope of blockScopes) {
|
|
1611
|
+
this.#credentialBlockReconcileAfter.set(
|
|
1612
|
+
`${block.credentialId}\0${block.providerKey}\0${blockScope}`,
|
|
1613
|
+
reconcileAfterMs,
|
|
1614
|
+
);
|
|
1615
|
+
}
|
|
1616
|
+
if (isLegacyCodexBlock) {
|
|
1617
|
+
this.#credentialBlockReconcileAfter.delete(
|
|
1618
|
+
`${block.credentialId}\0${block.providerKey}\0${LEGACY_CODEX_BLOCK_SCOPE}`,
|
|
1619
|
+
);
|
|
1620
|
+
}
|
|
1621
|
+
}
|
|
1622
|
+
|
|
1623
|
+
deleteCredentialBlock(credentialId: number, providerKey: string, blockScope: string): void {
|
|
1624
|
+
this.#deleteCredentialBlockStmt.run(credentialId, providerKey, blockScope);
|
|
1625
|
+
this.#credentialBlockReconcileAfter.delete(`${credentialId}\0${providerKey}\0${blockScope}`);
|
|
1626
|
+
}
|
|
1627
|
+
|
|
1628
|
+
deleteCredentialBlocks(credentialId: number): void {
|
|
1629
|
+
this.#deleteCredentialBlocksStmt.run(credentialId);
|
|
1630
|
+
for (const key of this.#credentialBlockReconcileAfter.keys()) {
|
|
1631
|
+
if (key.startsWith(`${credentialId}\0`)) this.#credentialBlockReconcileAfter.delete(key);
|
|
1632
|
+
}
|
|
1633
|
+
}
|
|
1634
|
+
|
|
1635
|
+
cleanExpiredCredentialBlocks(nowMs: number): void {
|
|
1636
|
+
this.#deleteExpiredCredentialBlocksStmt.run(nowMs);
|
|
1637
|
+
for (const [key, reconcileAfterMs] of this.#credentialBlockReconcileAfter) {
|
|
1638
|
+
if (reconcileAfterMs <= nowMs) this.#credentialBlockReconcileAfter.delete(key);
|
|
1639
|
+
}
|
|
1640
|
+
}
|
|
1641
|
+
|
|
1642
|
+
listCredentialBlocks(credentialIds: readonly number[]): StoredCredentialBlock[] {
|
|
1643
|
+
if (credentialIds.length === 0) return [];
|
|
1644
|
+
const nowMs = Date.now();
|
|
1645
|
+
this.cleanExpiredCredentialBlocks(nowMs);
|
|
1646
|
+
const seenCredentialIds = new Set<number>();
|
|
1647
|
+
const blocks: StoredCredentialBlock[] = [];
|
|
1648
|
+
for (const credentialId of credentialIds) {
|
|
1649
|
+
if (seenCredentialIds.has(credentialId)) continue;
|
|
1650
|
+
seenCredentialIds.add(credentialId);
|
|
1651
|
+
const rows = this.#listCredentialBlocksByCredentialStmt.all(
|
|
1652
|
+
credentialId,
|
|
1653
|
+
nowMs,
|
|
1654
|
+
LEGACY_CODEX_BLOCK_PROVIDER_KEY,
|
|
1655
|
+
LEGACY_CODEX_BLOCK_SCOPE,
|
|
1656
|
+
) as CredentialBlockRow[];
|
|
1657
|
+
for (const row of rows) {
|
|
1658
|
+
blocks.push({
|
|
1659
|
+
credentialId: row.credential_id,
|
|
1660
|
+
providerKey: row.provider_key,
|
|
1661
|
+
blockScope: row.block_scope,
|
|
1662
|
+
blockedUntilMs: row.blocked_until_ms,
|
|
1663
|
+
updatedAtMs: row.updated_at * 1000,
|
|
1664
|
+
});
|
|
1665
|
+
}
|
|
1666
|
+
}
|
|
1667
|
+
return blocks;
|
|
1668
|
+
}
|
|
1669
|
+
|
|
1670
|
+
tryAcquireCredentialRefreshLease(credentialId: number, owner: string, expiresAtMs: number): boolean {
|
|
1671
|
+
const result = this.#acquireCredentialRefreshLeaseStmt.run(credentialId, owner, expiresAtMs, Date.now()) as {
|
|
1672
|
+
changes: number;
|
|
1673
|
+
};
|
|
1674
|
+
return result.changes === 1;
|
|
1675
|
+
}
|
|
1676
|
+
|
|
1677
|
+
getCredentialRefreshLeaseExpiresAt(credentialId: number): number | undefined {
|
|
1678
|
+
const row = this.#getCredentialRefreshLeaseStmt.get(credentialId) as { expires_at_ms?: number } | undefined;
|
|
1679
|
+
if (typeof row?.expires_at_ms !== "number") return undefined;
|
|
1680
|
+
if (row.expires_at_ms <= Date.now()) return undefined;
|
|
1681
|
+
return row.expires_at_ms;
|
|
1682
|
+
}
|
|
1683
|
+
|
|
1684
|
+
renewCredentialRefreshLease(credentialId: number, owner: string, expiresAtMs: number): boolean {
|
|
1685
|
+
const result = this.#renewCredentialRefreshLeaseStmt.run(expiresAtMs, credentialId, owner) as {
|
|
1686
|
+
changes: number;
|
|
1687
|
+
};
|
|
1688
|
+
return result.changes === 1;
|
|
1689
|
+
}
|
|
1690
|
+
|
|
1691
|
+
releaseCredentialRefreshLease(credentialId: number, owner: string): void {
|
|
1692
|
+
try {
|
|
1693
|
+
this.#releaseCredentialRefreshLeaseStmt.run(credentialId, owner);
|
|
1694
|
+
} catch {
|
|
1695
|
+
// Ignore lease release failures; expired leases are stealable.
|
|
1696
|
+
}
|
|
1697
|
+
}
|
|
1698
|
+
|
|
1699
|
+
recordUsageSnapshots(entries: UsageHistoryEntry[]): void {
|
|
1700
|
+
try {
|
|
1701
|
+
for (const entry of entries) {
|
|
1702
|
+
const bucket = Math.floor(entry.recordedAt / USAGE_HISTORY_BUCKET_MS);
|
|
1703
|
+
const last = this.#lastUsageHistoryStmt.get(entry.provider, entry.accountKey, entry.limitId) as
|
|
1704
|
+
| { id: number; recorded_at: number }
|
|
1705
|
+
| undefined;
|
|
1706
|
+
if (last && Math.floor(last.recorded_at / USAGE_HISTORY_BUCKET_MS) === bucket) {
|
|
1707
|
+
this.#updateUsageHistoryStmt.run(
|
|
1708
|
+
entry.recordedAt,
|
|
1709
|
+
entry.email ?? null,
|
|
1710
|
+
entry.accountId ?? null,
|
|
1711
|
+
entry.label,
|
|
1712
|
+
entry.windowLabel ?? null,
|
|
1713
|
+
entry.usedFraction ?? null,
|
|
1714
|
+
entry.status ?? null,
|
|
1715
|
+
entry.resetsAt ?? null,
|
|
1716
|
+
last.id,
|
|
1717
|
+
);
|
|
1718
|
+
continue;
|
|
1719
|
+
}
|
|
1720
|
+
this.#insertUsageHistoryStmt.run(
|
|
1721
|
+
entry.recordedAt,
|
|
1722
|
+
entry.provider,
|
|
1723
|
+
entry.accountKey,
|
|
1724
|
+
entry.email ?? null,
|
|
1725
|
+
entry.accountId ?? null,
|
|
1726
|
+
entry.limitId,
|
|
1727
|
+
entry.label,
|
|
1728
|
+
entry.windowLabel ?? null,
|
|
1729
|
+
entry.usedFraction ?? null,
|
|
1730
|
+
entry.status ?? null,
|
|
1731
|
+
entry.resetsAt ?? null,
|
|
1732
|
+
);
|
|
1733
|
+
}
|
|
1734
|
+
} catch {
|
|
1735
|
+
// History is best-effort; never break the usage fetch path.
|
|
1736
|
+
}
|
|
1737
|
+
}
|
|
1738
|
+
|
|
1739
|
+
listUsageHistory(query?: UsageHistoryQuery): UsageHistoryEntry[] {
|
|
1740
|
+
try {
|
|
1741
|
+
const provider = query?.provider ?? null;
|
|
1742
|
+
const rows = this.#listUsageHistoryStmt.all(query?.sinceMs ?? 0, provider, provider) as Array<{
|
|
1743
|
+
recorded_at: number;
|
|
1744
|
+
provider: string;
|
|
1745
|
+
account_key: string;
|
|
1746
|
+
email: string | null;
|
|
1747
|
+
account_id: string | null;
|
|
1748
|
+
limit_id: string;
|
|
1749
|
+
label: string;
|
|
1750
|
+
window_label: string | null;
|
|
1751
|
+
used_fraction: number | null;
|
|
1752
|
+
status: string | null;
|
|
1753
|
+
resets_at: number | null;
|
|
1754
|
+
}>;
|
|
1755
|
+
return rows.map(row => ({
|
|
1756
|
+
recordedAt: row.recorded_at,
|
|
1757
|
+
provider: row.provider as Provider,
|
|
1758
|
+
accountKey: row.account_key,
|
|
1759
|
+
email: row.email ?? undefined,
|
|
1760
|
+
accountId: row.account_id ?? undefined,
|
|
1761
|
+
limitId: row.limit_id,
|
|
1762
|
+
label: row.label,
|
|
1763
|
+
windowLabel: row.window_label ?? undefined,
|
|
1764
|
+
usedFraction: row.used_fraction ?? undefined,
|
|
1765
|
+
status: (row.status ?? undefined) as UsageHistoryEntry["status"],
|
|
1766
|
+
resetsAt: row.resets_at ?? undefined,
|
|
1767
|
+
}));
|
|
1768
|
+
} catch {
|
|
1769
|
+
return [];
|
|
1770
|
+
}
|
|
1771
|
+
}
|
|
1772
|
+
recordUsageCosts(entries: UsageCostHistoryEntry[]): void {
|
|
1773
|
+
try {
|
|
1774
|
+
for (const entry of entries) {
|
|
1775
|
+
this.#insertUsageCostStmt.run(entry.recordedAt, entry.provider, entry.accountKey, entry.costUsd);
|
|
1776
|
+
}
|
|
1777
|
+
} catch {
|
|
1778
|
+
// Cost history is best-effort; never break request persistence.
|
|
1779
|
+
}
|
|
1780
|
+
}
|
|
1781
|
+
|
|
1782
|
+
listUsageCosts(query?: UsageCostHistoryQuery): UsageCostHistoryEntry[] {
|
|
1783
|
+
try {
|
|
1784
|
+
const provider = query?.provider ?? null;
|
|
1785
|
+
const accountKey = query?.accountKey ?? null;
|
|
1786
|
+
const rows = this.#listUsageCostsStmt.all(
|
|
1787
|
+
query?.sinceMs ?? 0,
|
|
1788
|
+
provider,
|
|
1789
|
+
provider,
|
|
1790
|
+
accountKey,
|
|
1791
|
+
accountKey,
|
|
1792
|
+
) as Array<{
|
|
1793
|
+
recorded_at: number;
|
|
1794
|
+
provider: string;
|
|
1795
|
+
account_key: string;
|
|
1796
|
+
cost_usd: number;
|
|
1797
|
+
}>;
|
|
1798
|
+
return rows.map(row => ({
|
|
1799
|
+
recordedAt: row.recorded_at,
|
|
1800
|
+
provider: row.provider as Provider,
|
|
1801
|
+
accountKey: row.account_key,
|
|
1802
|
+
costUsd: row.cost_usd,
|
|
1803
|
+
}));
|
|
1804
|
+
} catch {
|
|
1805
|
+
return [];
|
|
1806
|
+
}
|
|
1807
|
+
}
|
|
1808
|
+
|
|
1809
|
+
recordClientUsage(report: ClientUsageReport): void {
|
|
1810
|
+
const now = Date.now();
|
|
1811
|
+
this.#db
|
|
1812
|
+
.query(
|
|
1813
|
+
`INSERT INTO clients (install_id, hostname, first_seen, last_seen) VALUES (?, ?, ?, ?)
|
|
1814
|
+
ON CONFLICT(install_id) DO UPDATE SET hostname = COALESCE(excluded.hostname, hostname), last_seen = excluded.last_seen`,
|
|
1815
|
+
)
|
|
1816
|
+
.run(report.installId, report.hostname ?? null, now, now);
|
|
1817
|
+
const findBucket = this.#db.query(
|
|
1818
|
+
`SELECT id FROM client_usage
|
|
1819
|
+
WHERE install_id = ? AND provider = ? AND model = ? AND recorded_at >= ?
|
|
1820
|
+
ORDER BY recorded_at DESC LIMIT 1`,
|
|
1821
|
+
);
|
|
1822
|
+
const merge = this.#db.query(
|
|
1823
|
+
`UPDATE client_usage SET recorded_at = ?, requests = requests + ?, input_tokens = input_tokens + ?,
|
|
1824
|
+
output_tokens = output_tokens + ?, cache_read_tokens = cache_read_tokens + ?,
|
|
1825
|
+
cache_write_tokens = cache_write_tokens + ?, cost_usd = cost_usd + ? WHERE id = ?`,
|
|
1826
|
+
);
|
|
1827
|
+
const insert = this.#db.query(
|
|
1828
|
+
`INSERT INTO client_usage (recorded_at, install_id, provider, model, requests, input_tokens, output_tokens, cache_read_tokens, cache_write_tokens, cost_usd)
|
|
1829
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
|
1830
|
+
);
|
|
1831
|
+
for (const entry of report.entries) {
|
|
1832
|
+
// Merge into the newest row of the same (install, provider, model)
|
|
1833
|
+
// bucket so 10s client flushes don't accrete one row apiece forever.
|
|
1834
|
+
const bucketFloor = entry.at - CLIENT_USAGE_BUCKET_MS;
|
|
1835
|
+
const existing = findBucket.get(report.installId, entry.provider, entry.model, bucketFloor) as {
|
|
1836
|
+
id: number;
|
|
1837
|
+
} | null;
|
|
1838
|
+
if (existing) {
|
|
1839
|
+
merge.run(
|
|
1840
|
+
entry.at,
|
|
1841
|
+
entry.requests,
|
|
1842
|
+
entry.inputTokens,
|
|
1843
|
+
entry.outputTokens,
|
|
1844
|
+
entry.cacheReadTokens,
|
|
1845
|
+
entry.cacheWriteTokens,
|
|
1846
|
+
entry.costUsd,
|
|
1847
|
+
existing.id,
|
|
1848
|
+
);
|
|
1849
|
+
continue;
|
|
1850
|
+
}
|
|
1851
|
+
insert.run(
|
|
1852
|
+
entry.at,
|
|
1853
|
+
report.installId,
|
|
1854
|
+
entry.provider,
|
|
1855
|
+
entry.model,
|
|
1856
|
+
entry.requests,
|
|
1857
|
+
entry.inputTokens,
|
|
1858
|
+
entry.outputTokens,
|
|
1859
|
+
entry.cacheReadTokens,
|
|
1860
|
+
entry.cacheWriteTokens,
|
|
1861
|
+
entry.costUsd,
|
|
1862
|
+
);
|
|
1863
|
+
}
|
|
1864
|
+
}
|
|
1865
|
+
|
|
1866
|
+
getClientUsageSummary(sinceMs: number): ClientUsageSummary {
|
|
1867
|
+
const clients = this.#db
|
|
1868
|
+
.query("SELECT install_id, hostname, first_seen, last_seen FROM clients ORDER BY last_seen DESC")
|
|
1869
|
+
.all() as Array<{ install_id: string; hostname: string | null; first_seen: number; last_seen: number }>;
|
|
1870
|
+
const aggregates = this.#db
|
|
1871
|
+
.query(
|
|
1872
|
+
`SELECT install_id, provider, SUM(requests) requests, SUM(input_tokens) input_tokens,
|
|
1873
|
+
SUM(output_tokens) output_tokens, SUM(cache_read_tokens) cache_read_tokens,
|
|
1874
|
+
SUM(cache_write_tokens) cache_write_tokens, SUM(cost_usd) cost_usd
|
|
1875
|
+
FROM client_usage WHERE recorded_at >= ? GROUP BY install_id, provider
|
|
1876
|
+
ORDER BY install_id, SUM(input_tokens + output_tokens + cache_read_tokens + cache_write_tokens) DESC`,
|
|
1877
|
+
)
|
|
1878
|
+
.all(sinceMs) as Array<{
|
|
1879
|
+
install_id: string;
|
|
1880
|
+
provider: string;
|
|
1881
|
+
requests: number;
|
|
1882
|
+
input_tokens: number;
|
|
1883
|
+
output_tokens: number;
|
|
1884
|
+
cache_read_tokens: number;
|
|
1885
|
+
cache_write_tokens: number;
|
|
1886
|
+
cost_usd: number;
|
|
1887
|
+
}>;
|
|
1888
|
+
const providersByInstall = new Map<string, ClientProviderUsage[]>();
|
|
1889
|
+
for (const row of aggregates) {
|
|
1890
|
+
let list = providersByInstall.get(row.install_id);
|
|
1891
|
+
if (!list) {
|
|
1892
|
+
list = [];
|
|
1893
|
+
providersByInstall.set(row.install_id, list);
|
|
1894
|
+
}
|
|
1895
|
+
list.push({
|
|
1896
|
+
provider: row.provider,
|
|
1897
|
+
requests: row.requests,
|
|
1898
|
+
inputTokens: row.input_tokens,
|
|
1899
|
+
outputTokens: row.output_tokens,
|
|
1900
|
+
cacheReadTokens: row.cache_read_tokens,
|
|
1901
|
+
cacheWriteTokens: row.cache_write_tokens,
|
|
1902
|
+
costUsd: row.cost_usd,
|
|
1903
|
+
});
|
|
1904
|
+
}
|
|
1905
|
+
return {
|
|
1906
|
+
clients: clients.map(client => ({
|
|
1907
|
+
installId: client.install_id,
|
|
1908
|
+
hostname: client.hostname ?? undefined,
|
|
1909
|
+
firstSeen: client.first_seen,
|
|
1910
|
+
lastSeen: client.last_seen,
|
|
1911
|
+
providers: providersByInstall.get(client.install_id) ?? [],
|
|
1912
|
+
})),
|
|
1913
|
+
};
|
|
1914
|
+
}
|
|
1915
|
+
|
|
1916
|
+
// ─── Convenience methods for CLI ────────────────────────────────────────
|
|
1917
|
+
|
|
1918
|
+
/**
|
|
1919
|
+
* Save OAuth credentials for a provider.
|
|
1920
|
+
* Preserves unrelated identities and replaces only the matching credential.
|
|
1921
|
+
*/
|
|
1922
|
+
saveOAuth(provider: string, credentials: OAuthCredentials): void {
|
|
1923
|
+
const credential: AuthCredential = { type: "oauth", ...credentials };
|
|
1924
|
+
this.upsertAuthCredentialForProvider(provider, credential);
|
|
1925
|
+
}
|
|
1926
|
+
|
|
1927
|
+
/**
|
|
1928
|
+
* Get OAuth credentials for a provider.
|
|
1929
|
+
*/
|
|
1930
|
+
getOAuth(provider: string): OAuthCredentials | null {
|
|
1931
|
+
const rows = this.#listActiveByProviderStmt.all(provider) as AuthRow[];
|
|
1932
|
+
for (const row of rows) {
|
|
1933
|
+
const credential = deserializeCredential(row);
|
|
1934
|
+
if (credential && credential.type === "oauth") {
|
|
1935
|
+
const { type: _type, ...oauth } = credential;
|
|
1936
|
+
return oauth as OAuthCredentials;
|
|
1937
|
+
}
|
|
1938
|
+
}
|
|
1939
|
+
return null;
|
|
1940
|
+
}
|
|
1941
|
+
|
|
1942
|
+
/**
|
|
1943
|
+
* Save API key for a provider (replaces existing).
|
|
1944
|
+
*/
|
|
1945
|
+
saveApiKey(provider: string, apiKey: string): void {
|
|
1946
|
+
const credential: AuthCredential = { type: "api_key", key: apiKey };
|
|
1947
|
+
this.replaceAuthCredentialsForProvider(provider, [credential]);
|
|
1948
|
+
}
|
|
1949
|
+
|
|
1950
|
+
/**
|
|
1951
|
+
* Get API key for a provider.
|
|
1952
|
+
*/
|
|
1953
|
+
getApiKey(provider: string): string | null {
|
|
1954
|
+
const rows = this.#listActiveByProviderStmt.all(provider) as AuthRow[];
|
|
1955
|
+
for (const row of rows) {
|
|
1956
|
+
const credential = deserializeCredential(row);
|
|
1957
|
+
if (credential && credential.type === "api_key") {
|
|
1958
|
+
return credential.key;
|
|
1959
|
+
}
|
|
1960
|
+
}
|
|
1961
|
+
return null;
|
|
1962
|
+
}
|
|
1963
|
+
|
|
1964
|
+
/**
|
|
1965
|
+
* List all providers with credentials.
|
|
1966
|
+
*/
|
|
1967
|
+
listProviders(): string[] {
|
|
1968
|
+
const rows = this.#listActiveStmt.all() as AuthRow[];
|
|
1969
|
+
const providers = new Set<string>();
|
|
1970
|
+
for (const row of rows) {
|
|
1971
|
+
providers.add(row.provider);
|
|
1972
|
+
}
|
|
1973
|
+
return Array.from(providers);
|
|
1974
|
+
}
|
|
1975
|
+
|
|
1976
|
+
/**
|
|
1977
|
+
* Delete all credentials for a provider.
|
|
1978
|
+
*/
|
|
1979
|
+
deleteProvider(provider: string): void {
|
|
1980
|
+
this.deleteAuthCredentialsForProvider(provider, "deleted by user");
|
|
1981
|
+
}
|
|
1982
|
+
|
|
1983
|
+
/**
|
|
1984
|
+
* SQLite increments `data_version` when another connection commits. Own
|
|
1985
|
+
* writes leave it unchanged and already notify AuthStorage directly.
|
|
1986
|
+
*/
|
|
1987
|
+
pollExternalChanges(): boolean {
|
|
1988
|
+
this.#acknowledgeLocalAuthChanges();
|
|
1989
|
+
const dataVersion = this.#readDataVersion();
|
|
1990
|
+
if (dataVersion === this.#dataVersion) return false;
|
|
1991
|
+
this.#dataVersion = dataVersion;
|
|
1992
|
+
const authRevision = this.#readAuthRevision();
|
|
1993
|
+
if (authRevision === this.#authRevision) return false;
|
|
1994
|
+
this.#authRevision = authRevision;
|
|
1995
|
+
return true;
|
|
1996
|
+
}
|
|
1997
|
+
|
|
1998
|
+
acknowledgeLocalChanges(): void {
|
|
1999
|
+
this.#acknowledgeLocalAuthChanges();
|
|
2000
|
+
}
|
|
2001
|
+
|
|
2002
|
+
#acknowledgeLocalAuthChanges(): void {
|
|
2003
|
+
const localAuthRevision = this.#readLocalAuthRevision();
|
|
2004
|
+
this.#authRevision += localAuthRevision - this.#localAuthRevision;
|
|
2005
|
+
this.#localAuthRevision = localAuthRevision;
|
|
2006
|
+
}
|
|
2007
|
+
|
|
2008
|
+
#readDataVersion(): number {
|
|
2009
|
+
const row = this.#db.query("PRAGMA data_version").get() as { data_version?: number } | null;
|
|
2010
|
+
return row?.data_version ?? 0;
|
|
2011
|
+
}
|
|
2012
|
+
|
|
2013
|
+
#readAuthRevision(): number {
|
|
2014
|
+
const row = this.#db.query("SELECT revision FROM auth_change_revision WHERE id = 1").get() as {
|
|
2015
|
+
revision?: number;
|
|
2016
|
+
} | null;
|
|
2017
|
+
return row?.revision ?? 0;
|
|
2018
|
+
}
|
|
2019
|
+
|
|
2020
|
+
#readLocalAuthRevision(): number {
|
|
2021
|
+
const row = this.#db.query("SELECT revision FROM auth_local_change_revision WHERE id = 1").get() as {
|
|
2022
|
+
revision?: number;
|
|
2023
|
+
} | null;
|
|
2024
|
+
return row?.revision ?? 0;
|
|
2025
|
+
}
|
|
2026
|
+
|
|
2027
|
+
close(): void {
|
|
2028
|
+
if (this.#closed) return;
|
|
2029
|
+
this.#closed = true;
|
|
2030
|
+
this.#listActiveStmt.finalize();
|
|
2031
|
+
this.#listActiveByProviderStmt.finalize();
|
|
2032
|
+
this.#listDisabledStmt.finalize();
|
|
2033
|
+
this.#listDisabledByProviderStmt.finalize();
|
|
2034
|
+
this.#insertStmt.finalize();
|
|
2035
|
+
this.#updateStmt.finalize();
|
|
2036
|
+
this.#deleteStmt.finalize();
|
|
2037
|
+
this.#deleteIfMatchesStmt.finalize();
|
|
2038
|
+
this.#deleteByProviderStmt.finalize();
|
|
2039
|
+
this.#hardDeleteStmt.finalize();
|
|
2040
|
+
this.#getCacheStmt.finalize();
|
|
2041
|
+
this.#getCacheIncludingExpiredStmt.finalize();
|
|
2042
|
+
this.#upsertCacheStmt.finalize();
|
|
2043
|
+
this.#deleteExpiredCacheStmt.finalize();
|
|
2044
|
+
this.#getCredentialBlockStmt.finalize();
|
|
2045
|
+
this.#listCredentialBlocksByCredentialStmt.finalize();
|
|
2046
|
+
this.#upsertCredentialBlockStmt.finalize();
|
|
2047
|
+
this.#deleteCredentialBlocksStmt.finalize();
|
|
2048
|
+
this.#deleteCredentialBlockStmt.finalize();
|
|
2049
|
+
this.#deleteExpiredCredentialBlocksStmt.finalize();
|
|
2050
|
+
this.#insertUsageHistoryStmt.finalize();
|
|
2051
|
+
this.#lastUsageHistoryStmt.finalize();
|
|
2052
|
+
this.#listUsageHistoryStmt.finalize();
|
|
2053
|
+
this.#updateUsageHistoryStmt.finalize();
|
|
2054
|
+
this.#insertUsageCostStmt.finalize();
|
|
2055
|
+
this.#listUsageCostsStmt.finalize();
|
|
2056
|
+
this.#updateIfMatchesStmt.finalize();
|
|
2057
|
+
this.#updateIfMatchesWithLeaseStmt.finalize();
|
|
2058
|
+
this.#deleteIfMatchesWithLeaseStmt.finalize();
|
|
2059
|
+
this.#deleteCachePrefixStmt.finalize();
|
|
2060
|
+
this.#acquireCredentialRefreshLeaseStmt.finalize();
|
|
2061
|
+
this.#getCredentialRefreshLeaseStmt.finalize();
|
|
2062
|
+
this.#renewCredentialRefreshLeaseStmt.finalize();
|
|
2063
|
+
this.#releaseCredentialRefreshLeaseStmt.finalize();
|
|
2064
|
+
this.#db.close();
|
|
2065
|
+
}
|
|
2066
|
+
}
|