@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,1332 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Client-side {@link AuthCredentialStore} that mirrors a remote broker's
|
|
3
|
+
* snapshot. Refresh tokens never leave the broker; mutating methods (`replace*`,
|
|
4
|
+
* `upsert*`, `delete*ForProvider`) throw because login flows are server-side.
|
|
5
|
+
*
|
|
6
|
+
* Cache (`getCache`/`setCache`/`cleanExpiredCache`) is in-memory and ephemeral —
|
|
7
|
+
* usage reports cache TTL is 5 minutes per credential, so durability across
|
|
8
|
+
* runs isn't required.
|
|
9
|
+
*/
|
|
10
|
+
import * as os from "node:os";
|
|
11
|
+
import { scheduler } from "node:timers/promises";
|
|
12
|
+
import { getInstallId, logger } from "@linxiraos/pi-utils";
|
|
13
|
+
import {
|
|
14
|
+
type AuthCredential,
|
|
15
|
+
type AuthCredentialSnapshotEntry,
|
|
16
|
+
type AuthCredentialStore,
|
|
17
|
+
type DisabledCredentialSummary,
|
|
18
|
+
type OAuthCredential,
|
|
19
|
+
REMOTE_REFRESH_SENTINEL,
|
|
20
|
+
type StoredAuthCredential,
|
|
21
|
+
type StoredCredentialBlock,
|
|
22
|
+
} from "../auth-storage";
|
|
23
|
+
import * as AIError from "../error";
|
|
24
|
+
import type { OAuthCredentials } from "../registry/oauth/types";
|
|
25
|
+
import type { Provider } from "../types";
|
|
26
|
+
import type { ObservedUsageEntry, UsageReport } from "../usage";
|
|
27
|
+
import { type AuthBrokerClient, AuthBrokerError, AuthBrokerStreamUnsupportedError } from "./client";
|
|
28
|
+
import type {
|
|
29
|
+
CredentialBlockSnapshot,
|
|
30
|
+
RefresherSchedule,
|
|
31
|
+
SnapshotEntry,
|
|
32
|
+
SnapshotResponse,
|
|
33
|
+
SnapshotStreamEvent,
|
|
34
|
+
} from "./types";
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Per-provider OAuth identities visible to this trusted broker client.
|
|
38
|
+
* Missing providers are unrestricted; an empty set excludes that provider's
|
|
39
|
+
* OAuth credentials. API keys are never filtered.
|
|
40
|
+
*/
|
|
41
|
+
export type AuthBrokerAccountPool = ReadonlyMap<string, ReadonlySet<string>>;
|
|
42
|
+
|
|
43
|
+
function isCredentialInAccountPool(
|
|
44
|
+
entry: Pick<SnapshotEntry, "provider" | "credential" | "identityKey">,
|
|
45
|
+
accountPool: AuthBrokerAccountPool | undefined,
|
|
46
|
+
): boolean {
|
|
47
|
+
if (entry.credential.type !== "oauth") return true;
|
|
48
|
+
const identities = accountPool?.get(entry.provider);
|
|
49
|
+
if (identities === undefined) return true;
|
|
50
|
+
return entry.identityKey !== null && identities.has(entry.identityKey);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Client-side TTL for the aggregate `/v1/usage` response. The broker dedups
|
|
55
|
+
* upstream `/usage` hits via AuthStorage's 5-minute per-credential cache plus
|
|
56
|
+
* single-flight, so this short client TTL mainly folds the parallel fan-out
|
|
57
|
+
* from `#rankOAuthSelections` into a single round-trip — a ranking pass issues
|
|
58
|
+
* one broker call instead of N.
|
|
59
|
+
*/
|
|
60
|
+
const USAGE_CACHE_TTL_MS = 15_000;
|
|
61
|
+
const CREDENTIAL_BLOCK_RECONCILE_DELAY_MS = 5 * 60_000;
|
|
62
|
+
const WAIT_THRESHOLD_MS = 1_000;
|
|
63
|
+
const MAX_WAIT_MS = 5_000;
|
|
64
|
+
const BACKGROUND_WAIT_MS = 30_000;
|
|
65
|
+
const BACKGROUND_BACKOFF_INITIAL_MS = 500;
|
|
66
|
+
const BACKGROUND_BACKOFF_MAX_MS = 30_000;
|
|
67
|
+
|
|
68
|
+
function compareCredentialBlockSnapshots(a: CredentialBlockSnapshot, b: CredentialBlockSnapshot): number {
|
|
69
|
+
const provider = a.providerKey.localeCompare(b.providerKey);
|
|
70
|
+
if (provider !== 0) return provider;
|
|
71
|
+
const scope = a.blockScope.localeCompare(b.blockScope);
|
|
72
|
+
if (scope !== 0) return scope;
|
|
73
|
+
const blockedUntil = a.blockedUntilMs - b.blockedUntilMs;
|
|
74
|
+
if (blockedUntil !== 0) return blockedUntil;
|
|
75
|
+
return (a.updatedAtMs ?? 0) - (b.updatedAtMs ?? 0);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function toCredentialBlockSnapshot(block: StoredCredentialBlock): CredentialBlockSnapshot {
|
|
79
|
+
return {
|
|
80
|
+
providerKey: block.providerKey,
|
|
81
|
+
blockScope: block.blockScope,
|
|
82
|
+
blockedUntilMs: block.blockedUntilMs,
|
|
83
|
+
...(block.updatedAtMs !== undefined ? { updatedAtMs: block.updatedAtMs } : {}),
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function credentialBlockSnapshotsEqual(
|
|
88
|
+
left: readonly CredentialBlockSnapshot[] | undefined,
|
|
89
|
+
right: readonly CredentialBlockSnapshot[] | undefined,
|
|
90
|
+
): boolean {
|
|
91
|
+
const leftBlocks = left ?? [];
|
|
92
|
+
const rightBlocks = right ?? [];
|
|
93
|
+
if (leftBlocks.length !== rightBlocks.length) return false;
|
|
94
|
+
for (let index = 0; index < leftBlocks.length; index += 1) {
|
|
95
|
+
const leftBlock = leftBlocks[index]!;
|
|
96
|
+
const rightBlock = rightBlocks[index]!;
|
|
97
|
+
if (
|
|
98
|
+
leftBlock.providerKey !== rightBlock.providerKey ||
|
|
99
|
+
leftBlock.blockScope !== rightBlock.blockScope ||
|
|
100
|
+
leftBlock.blockedUntilMs !== rightBlock.blockedUntilMs ||
|
|
101
|
+
leftBlock.updatedAtMs !== rightBlock.updatedAtMs
|
|
102
|
+
) {
|
|
103
|
+
return false;
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
return true;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function snapshotBlocksChanged(previous: readonly SnapshotEntry[], next: readonly SnapshotEntry[]): boolean {
|
|
110
|
+
const previousBlocksById = new Map<number, readonly CredentialBlockSnapshot[] | undefined>();
|
|
111
|
+
for (const entry of previous) previousBlocksById.set(entry.id, entry.blocks);
|
|
112
|
+
for (const entry of next) {
|
|
113
|
+
const previousBlocks = previousBlocksById.get(entry.id);
|
|
114
|
+
if (!credentialBlockSnapshotsEqual(previousBlocks, entry.blocks)) return true;
|
|
115
|
+
previousBlocksById.delete(entry.id);
|
|
116
|
+
}
|
|
117
|
+
for (const previousBlocks of previousBlocksById.values()) {
|
|
118
|
+
if (previousBlocks && previousBlocks.length > 0) return true;
|
|
119
|
+
}
|
|
120
|
+
return false;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function credentialEntryWithBlocks(
|
|
124
|
+
entry: AuthCredentialSnapshotEntry,
|
|
125
|
+
blocks: readonly CredentialBlockSnapshot[] | undefined,
|
|
126
|
+
): SnapshotEntry {
|
|
127
|
+
const incoming: SnapshotEntry = { ...entry, rotatesInMs: null };
|
|
128
|
+
if (blocks && blocks.length > 0) incoming.blocks = [...blocks].sort(compareCredentialBlockSnapshots);
|
|
129
|
+
return incoming;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function emptySnapshot(): SnapshotResponse {
|
|
133
|
+
return {
|
|
134
|
+
generation: 0,
|
|
135
|
+
generatedAt: 0,
|
|
136
|
+
serverNowMs: 0,
|
|
137
|
+
refresher: {
|
|
138
|
+
enabled: false,
|
|
139
|
+
intervalMs: 0,
|
|
140
|
+
skewMs: 0,
|
|
141
|
+
nextSweepInMs: Number.MAX_SAFE_INTEGER,
|
|
142
|
+
},
|
|
143
|
+
credentials: [],
|
|
144
|
+
};
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
interface CacheEntry {
|
|
148
|
+
value: string;
|
|
149
|
+
expiresAtSec: number;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
interface UsageCacheEntry {
|
|
153
|
+
/**
|
|
154
|
+
* `null` means the last aggregate `/v1/usage` fetch failed. Callers treat
|
|
155
|
+
* this the same as a successful empty-report response ("no usage signal
|
|
156
|
+
* for this cycle"), and the same 15s TTL applies so transient broker
|
|
157
|
+
* outages don't turn every ranking pass into a broker retry storm.
|
|
158
|
+
*/
|
|
159
|
+
reports: UsageReport[] | null;
|
|
160
|
+
fetchedAt: number;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function usageOverlayKey(
|
|
164
|
+
provider: Provider,
|
|
165
|
+
ids: { accountId?: string; email?: string; projectId?: string; orgId?: string },
|
|
166
|
+
): string | undefined {
|
|
167
|
+
// Org first: one account email can hold several organizations (Anthropic
|
|
168
|
+
// Team seat + personal Max), each with its own limit pools. Keying the
|
|
169
|
+
// overlay by account/email would merge the two pools' header ingests.
|
|
170
|
+
// But the org alone is not enough either: two Team members share the org
|
|
171
|
+
// id while drawing on per-user pools, so the key stays qualified by the
|
|
172
|
+
// member's own base identity whenever one is known.
|
|
173
|
+
let base: string | undefined;
|
|
174
|
+
const accountId = ids.accountId?.trim().toLowerCase();
|
|
175
|
+
const email = ids.email?.trim().toLowerCase();
|
|
176
|
+
const projectId = ids.projectId?.trim().toLowerCase();
|
|
177
|
+
if (accountId) base = `account:${accountId}`;
|
|
178
|
+
else if (email) base = `email:${email}`;
|
|
179
|
+
else if (projectId) base = `project:${projectId}`;
|
|
180
|
+
const orgId = ids.orgId?.trim().toLowerCase();
|
|
181
|
+
if (orgId) return base ? `${provider}\0org:${orgId}|${base}` : `${provider}\0org:${orgId}`;
|
|
182
|
+
if (base) return `${provider}\0${base}`;
|
|
183
|
+
return undefined;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
function mergeUsageReports(base: UsageReport, overlay: UsageReport): UsageReport {
|
|
187
|
+
const overlayLimitsById = new Map(overlay.limits.map(limit => [limit.id, limit]));
|
|
188
|
+
const limits = [];
|
|
189
|
+
for (const limit of base.limits) {
|
|
190
|
+
const replacement = overlayLimitsById.get(limit.id);
|
|
191
|
+
if (replacement) {
|
|
192
|
+
limits.push(replacement);
|
|
193
|
+
overlayLimitsById.delete(limit.id);
|
|
194
|
+
} else {
|
|
195
|
+
limits.push(limit);
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
for (const limit of overlayLimitsById.values()) limits.push(limit);
|
|
199
|
+
const overlayMetadata = (overlay.metadata ?? {}) as Record<string, unknown>;
|
|
200
|
+
return {
|
|
201
|
+
...base,
|
|
202
|
+
fetchedAt: Math.max(base.fetchedAt, overlay.fetchedAt),
|
|
203
|
+
limits,
|
|
204
|
+
metadata: {
|
|
205
|
+
...overlayMetadata,
|
|
206
|
+
...(base.metadata ?? {}),
|
|
207
|
+
...(overlayMetadata.headersUpdatedAt !== undefined
|
|
208
|
+
? { headersUpdatedAt: overlayMetadata.headersUpdatedAt }
|
|
209
|
+
: {}),
|
|
210
|
+
},
|
|
211
|
+
};
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
export interface RemoteAuthCredentialStoreOptions {
|
|
215
|
+
client: AuthBrokerClient;
|
|
216
|
+
/**
|
|
217
|
+
* Initial snapshot. When omitted, callers must call
|
|
218
|
+
* {@link RemoteAuthCredentialStore.refreshSnapshot} before the first read.
|
|
219
|
+
*/
|
|
220
|
+
initialSnapshot?: SnapshotResponse;
|
|
221
|
+
/**
|
|
222
|
+
* Subscribe to the broker's SSE snapshot stream when available. Falls back
|
|
223
|
+
* to long-poll permanently when the broker returns 404. Default `true`.
|
|
224
|
+
*/
|
|
225
|
+
streamSnapshots?: boolean;
|
|
226
|
+
/**
|
|
227
|
+
* Called with each broker-sourced raw full snapshot after the filtered
|
|
228
|
+
* public view is applied. The constructor's initial snapshot intentionally
|
|
229
|
+
* does not trigger this hook.
|
|
230
|
+
*/
|
|
231
|
+
onSnapshot?: (snapshot: SnapshotResponse, generation: number) => void;
|
|
232
|
+
/**
|
|
233
|
+
* OAuth identities visible through this store. This is a trusted-client
|
|
234
|
+
* routing policy, not broker authorization.
|
|
235
|
+
*/
|
|
236
|
+
accountPool?: AuthBrokerAccountPool;
|
|
237
|
+
/** Flush cadence for batched observed-usage reports. Default 10s. */
|
|
238
|
+
observedUsageFlushMs?: number;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
export class RemoteAuthCredentialStore implements AuthCredentialStore {
|
|
242
|
+
readonly #client: AuthBrokerClient;
|
|
243
|
+
readonly #streamSnapshots: boolean;
|
|
244
|
+
readonly #onSnapshot?: (snapshot: SnapshotResponse, generation: number) => void;
|
|
245
|
+
readonly #accountPool?: AuthBrokerAccountPool;
|
|
246
|
+
#snapshot: SnapshotResponse = emptySnapshot();
|
|
247
|
+
#snapshotReceivedAt = Date.now();
|
|
248
|
+
#generation = 0;
|
|
249
|
+
#usageOverlays: Map<string, UsageReport> = new Map();
|
|
250
|
+
#backgroundAbort = new AbortController();
|
|
251
|
+
#cache: Map<string, CacheEntry> = new Map();
|
|
252
|
+
#usageCache?: UsageCacheEntry;
|
|
253
|
+
#usageInflight?: Promise<UsageReport[] | null>;
|
|
254
|
+
#credentialBlockReconcileAfter: Map<string, number> = new Map();
|
|
255
|
+
#usageCacheEpoch = 0;
|
|
256
|
+
/** Per-snapshot lookup of oauth credentials by provider; rebuilt when `#snapshot` is replaced. */
|
|
257
|
+
#usageFilterLookup?: { snapshot: SnapshotResponse; byProvider: Map<Provider, OAuthCredential[]> };
|
|
258
|
+
/** Memoized `#filterUsageReports` output, keyed on (input identity, lookup identity). */
|
|
259
|
+
#usageFilterResult?: { input: UsageReport[]; byProvider: Map<Provider, OAuthCredential[]>; output: UsageReport[] };
|
|
260
|
+
#closed = false;
|
|
261
|
+
/**
|
|
262
|
+
* `true` once the SSE consumer received its first frame and hasn't dropped
|
|
263
|
+
* since. Writes consult this to suppress the otherwise-mandatory
|
|
264
|
+
* `refreshSnapshot()` follow-up — the stream will deliver the new
|
|
265
|
+
* generation without an extra GET.
|
|
266
|
+
*/
|
|
267
|
+
#streamingActive = false;
|
|
268
|
+
/** Latched once the broker has answered 404 — never try the stream again. */
|
|
269
|
+
#streamingUnsupported = false;
|
|
270
|
+
/** Pending observed usage keyed by `provider\u0000model`, merged until flush. */
|
|
271
|
+
#observedUsage = new Map<string, ObservedUsageEntry>();
|
|
272
|
+
#observedUsageTimer: Timer | undefined;
|
|
273
|
+
readonly #observedUsageFlushMs: number;
|
|
274
|
+
/** Latched once the broker answered 404 — old broker, never report again. */
|
|
275
|
+
#observedUsageUnsupported = false;
|
|
276
|
+
|
|
277
|
+
constructor(opts: RemoteAuthCredentialStoreOptions) {
|
|
278
|
+
this.#client = opts.client;
|
|
279
|
+
this.#streamSnapshots = opts.streamSnapshots ?? true;
|
|
280
|
+
this.#observedUsageFlushMs = opts.observedUsageFlushMs ?? 10_000;
|
|
281
|
+
this.#accountPool = opts.accountPool
|
|
282
|
+
? new Map([...opts.accountPool].map(([provider, identities]) => [provider, new Set(identities)]))
|
|
283
|
+
: undefined;
|
|
284
|
+
this.#applySnapshot(opts.initialSnapshot ?? emptySnapshot(), opts.initialSnapshot?.generation ?? 0);
|
|
285
|
+
this.#onSnapshot = opts.onSnapshot;
|
|
286
|
+
void this.#runBackground();
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
get client(): AuthBrokerClient {
|
|
290
|
+
return this.#client;
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
get snapshot(): SnapshotResponse {
|
|
294
|
+
return this.#snapshot;
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
#applySnapshot(snapshot: SnapshotResponse, generation: number, protectNewBlocks = true): void {
|
|
298
|
+
const nowMs = Date.now();
|
|
299
|
+
const previousCredentials = this.#snapshot.credentials;
|
|
300
|
+
const credentials = snapshot.credentials
|
|
301
|
+
.filter(entry => isCredentialInAccountPool(entry, this.#accountPool))
|
|
302
|
+
.map(entry => this.#normalizeSnapshotEntryBlocks(entry, nowMs));
|
|
303
|
+
if (snapshotBlocksChanged(previousCredentials, credentials)) this.#invalidateUsageCache();
|
|
304
|
+
if (protectNewBlocks) this.#protectNewSnapshotBlocks(previousCredentials, credentials, nowMs);
|
|
305
|
+
this.#snapshot = { ...snapshot, credentials };
|
|
306
|
+
this.#generation = generation;
|
|
307
|
+
this.#snapshotReceivedAt = nowMs;
|
|
308
|
+
const onSnapshot = this.#onSnapshot;
|
|
309
|
+
if (!onSnapshot) return;
|
|
310
|
+
try {
|
|
311
|
+
onSnapshot(snapshot, generation);
|
|
312
|
+
} catch (error) {
|
|
313
|
+
logger.debug("auth-broker snapshot callback failed", { error: String(error) });
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
#protectNewSnapshotBlocks(previous: readonly SnapshotEntry[], next: readonly SnapshotEntry[], nowMs: number): void {
|
|
317
|
+
const previousBlocksByKey = new Map<string, string>();
|
|
318
|
+
for (const entry of previous) {
|
|
319
|
+
for (const block of entry.blocks ?? []) {
|
|
320
|
+
previousBlocksByKey.set(
|
|
321
|
+
`${entry.id}\0${block.providerKey}\0${block.blockScope}`,
|
|
322
|
+
`${block.blockedUntilMs}\0${block.updatedAtMs ?? ""}`,
|
|
323
|
+
);
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
const activeKeys = new Set<string>();
|
|
327
|
+
for (const entry of next) {
|
|
328
|
+
for (const block of entry.blocks ?? []) {
|
|
329
|
+
const key = `${entry.id}\0${block.providerKey}\0${block.blockScope}`;
|
|
330
|
+
activeKeys.add(key);
|
|
331
|
+
const signature = `${block.blockedUntilMs}\0${block.updatedAtMs ?? ""}`;
|
|
332
|
+
if (previousBlocksByKey.get(key) === signature) continue;
|
|
333
|
+
const updatedAtMs = block.updatedAtMs ?? nowMs;
|
|
334
|
+
this.#credentialBlockReconcileAfter.set(
|
|
335
|
+
key,
|
|
336
|
+
Math.min(block.blockedUntilMs, updatedAtMs + CREDENTIAL_BLOCK_RECONCILE_DELAY_MS),
|
|
337
|
+
);
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
for (const key of this.#credentialBlockReconcileAfter.keys()) {
|
|
341
|
+
if (!activeKeys.has(key)) this.#credentialBlockReconcileAfter.delete(key);
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
async #runBackground(): Promise<void> {
|
|
346
|
+
let backoffMs = BACKGROUND_BACKOFF_INITIAL_MS;
|
|
347
|
+
while (!this.#closed && !this.#backgroundAbort.signal.aborted) {
|
|
348
|
+
if (this.#streamSnapshots && !this.#streamingUnsupported) {
|
|
349
|
+
try {
|
|
350
|
+
await this.#consumeSnapshotStream();
|
|
351
|
+
backoffMs = BACKGROUND_BACKOFF_INITIAL_MS;
|
|
352
|
+
continue;
|
|
353
|
+
} catch (error) {
|
|
354
|
+
if (this.#closed || this.#backgroundAbort.signal.aborted) break;
|
|
355
|
+
if (error instanceof AuthBrokerStreamUnsupportedError) {
|
|
356
|
+
this.#streamingUnsupported = true;
|
|
357
|
+
logger.debug("auth-broker snapshot stream unsupported; falling back to long-poll");
|
|
358
|
+
continue;
|
|
359
|
+
}
|
|
360
|
+
logger.debug("auth-broker snapshot stream failed; backing off", { error: String(error) });
|
|
361
|
+
await scheduler.wait(backoffMs, { signal: this.#backgroundAbort.signal }).catch(() => {});
|
|
362
|
+
backoffMs = Math.min(BACKGROUND_BACKOFF_MAX_MS, backoffMs * 2);
|
|
363
|
+
continue;
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
try {
|
|
367
|
+
const result = await this.#client.fetchSnapshot({
|
|
368
|
+
ifGenerationGt: this.#generation,
|
|
369
|
+
waitMs: BACKGROUND_WAIT_MS,
|
|
370
|
+
signal: this.#backgroundAbort.signal,
|
|
371
|
+
});
|
|
372
|
+
if (result.status === 200) this.#applySnapshot(result.snapshot, result.generation);
|
|
373
|
+
backoffMs = BACKGROUND_BACKOFF_INITIAL_MS;
|
|
374
|
+
} catch (error) {
|
|
375
|
+
if (this.#closed || this.#backgroundAbort.signal.aborted) break;
|
|
376
|
+
logger.debug("auth-broker background snapshot sync failed", { error: String(error) });
|
|
377
|
+
await scheduler.wait(backoffMs, { signal: this.#backgroundAbort.signal }).catch(() => {});
|
|
378
|
+
backoffMs = Math.min(BACKGROUND_BACKOFF_MAX_MS, backoffMs * 2);
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
async #consumeSnapshotStream(): Promise<void> {
|
|
384
|
+
const iterator = this.#client.openSnapshotStream({ signal: this.#backgroundAbort.signal });
|
|
385
|
+
try {
|
|
386
|
+
for await (const event of iterator) {
|
|
387
|
+
if (this.#closed || this.#backgroundAbort.signal.aborted) break;
|
|
388
|
+
this.#streamingActive = true;
|
|
389
|
+
this.#applyStreamEvent(event);
|
|
390
|
+
}
|
|
391
|
+
} finally {
|
|
392
|
+
this.#streamingActive = false;
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
#applyStreamEvent(event: SnapshotStreamEvent): void {
|
|
397
|
+
switch (event.kind) {
|
|
398
|
+
case "snapshot": {
|
|
399
|
+
// Strip the discriminator so we store the wire-shape SnapshotResponse.
|
|
400
|
+
const { kind: _kind, ...snapshot } = event;
|
|
401
|
+
if (snapshot.generation < this.#generation) {
|
|
402
|
+
logger.debug("auth-broker stream snapshot older than local; ignoring", {
|
|
403
|
+
local: this.#generation,
|
|
404
|
+
incoming: snapshot.generation,
|
|
405
|
+
});
|
|
406
|
+
return;
|
|
407
|
+
}
|
|
408
|
+
this.#applySnapshot(snapshot, snapshot.generation);
|
|
409
|
+
return;
|
|
410
|
+
}
|
|
411
|
+
case "entry": {
|
|
412
|
+
if (event.generation < this.#generation) return;
|
|
413
|
+
this.#applyStreamEntry(event.entry, event.refresher, event.generation, event.serverNowMs);
|
|
414
|
+
return;
|
|
415
|
+
}
|
|
416
|
+
case "removed": {
|
|
417
|
+
if (event.generation < this.#generation) return;
|
|
418
|
+
this.#removeStreamCredential(event.id, event.refresher, event.generation, event.serverNowMs);
|
|
419
|
+
return;
|
|
420
|
+
}
|
|
421
|
+
}
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
#applyStreamEntry(
|
|
425
|
+
entry: SnapshotEntry,
|
|
426
|
+
refresher: RefresherSchedule,
|
|
427
|
+
generation: number,
|
|
428
|
+
serverNowMs: number,
|
|
429
|
+
): void {
|
|
430
|
+
if (!isCredentialInAccountPool(entry, this.#accountPool)) {
|
|
431
|
+
this.#removeStreamCredential(entry.id, refresher, generation, serverNowMs);
|
|
432
|
+
return;
|
|
433
|
+
}
|
|
434
|
+
const incoming = this.#normalizeSnapshotEntryBlocks(entry, Date.now());
|
|
435
|
+
const index = this.#snapshot.credentials.findIndex(candidate => candidate.id === incoming.id);
|
|
436
|
+
const previousBlocks = index === -1 ? undefined : this.#snapshot.credentials[index]?.blocks;
|
|
437
|
+
const blocksChanged = !credentialBlockSnapshotsEqual(previousBlocks, incoming.blocks);
|
|
438
|
+
if (blocksChanged) this.#invalidateUsageCache();
|
|
439
|
+
const credentials =
|
|
440
|
+
index === -1
|
|
441
|
+
? [...this.#snapshot.credentials, incoming]
|
|
442
|
+
: this.#snapshot.credentials.map((candidate, i) => (i === index ? incoming : candidate));
|
|
443
|
+
if (blocksChanged) this.#protectNewSnapshotBlocks(this.#snapshot.credentials, credentials, Date.now());
|
|
444
|
+
this.#snapshot = { ...this.#snapshot, generation, serverNowMs, refresher, credentials };
|
|
445
|
+
this.#generation = generation;
|
|
446
|
+
this.#snapshotReceivedAt = Date.now();
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
#removeStreamCredential(id: number, refresher: RefresherSchedule, generation: number, serverNowMs: number): void {
|
|
450
|
+
const removed = this.#snapshot.credentials.find(entry => entry.id === id);
|
|
451
|
+
if (removed?.blocks && removed.blocks.length > 0) this.#invalidateUsageCache();
|
|
452
|
+
const credentials = this.#snapshot.credentials.filter(entry => entry.id !== id);
|
|
453
|
+
this.#snapshot = { ...this.#snapshot, generation, serverNowMs, refresher, credentials };
|
|
454
|
+
this.#generation = generation;
|
|
455
|
+
this.#snapshotReceivedAt = Date.now();
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
/** Re-hydrate the in-memory snapshot from the broker. */
|
|
459
|
+
async refreshSnapshot(): Promise<SnapshotResponse> {
|
|
460
|
+
const result = await this.#client.fetchSnapshot();
|
|
461
|
+
if (result.status === 200) this.#applySnapshot(result.snapshot, result.generation);
|
|
462
|
+
return this.#snapshot;
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
listAuthCredentials(provider?: string): StoredAuthCredential[] {
|
|
466
|
+
const out: StoredAuthCredential[] = [];
|
|
467
|
+
for (const entry of this.#snapshot.credentials) {
|
|
468
|
+
if (provider !== undefined && entry.provider !== provider) continue;
|
|
469
|
+
out.push({
|
|
470
|
+
id: entry.id,
|
|
471
|
+
provider: entry.provider,
|
|
472
|
+
credential: entry.credential as AuthCredential,
|
|
473
|
+
disabledCause: null,
|
|
474
|
+
});
|
|
475
|
+
}
|
|
476
|
+
return out;
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
/** Broker-backed disabled tombstones; empty against brokers predating the endpoint. */
|
|
480
|
+
listDisabledCredentials(provider?: string, signal?: AbortSignal): Promise<DisabledCredentialSummary[]> {
|
|
481
|
+
return this.#client.listDisabledCredentials(provider, signal);
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
getCredentialBlock(credentialId: number, providerKey: string, blockScope: string): number | undefined {
|
|
485
|
+
const nowMs = Date.now();
|
|
486
|
+
this.cleanExpiredCredentialBlocks(nowMs);
|
|
487
|
+
const entry = this.#snapshot.credentials.find(candidate => candidate.id === credentialId);
|
|
488
|
+
if (!entry?.blocks) return undefined;
|
|
489
|
+
const block = entry.blocks.find(
|
|
490
|
+
candidate => candidate.providerKey === providerKey && candidate.blockScope === blockScope,
|
|
491
|
+
);
|
|
492
|
+
if (!block || block.blockedUntilMs <= nowMs) return undefined;
|
|
493
|
+
return block.blockedUntilMs;
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
getCredentialBlockReconcileAfter(credentialId: number, providerKey: string, blockScope: string): number | undefined {
|
|
497
|
+
if (this.getCredentialBlock(credentialId, providerKey, blockScope) === undefined) return undefined;
|
|
498
|
+
return this.#credentialBlockReconcileAfter.get(`${credentialId}\0${providerKey}\0${blockScope}`);
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
listCredentialBlocks(credentialIds: readonly number[]): StoredCredentialBlock[] {
|
|
502
|
+
const nowMs = Date.now();
|
|
503
|
+
this.cleanExpiredCredentialBlocks(nowMs);
|
|
504
|
+
const ids = new Set(credentialIds);
|
|
505
|
+
const blocks: StoredCredentialBlock[] = [];
|
|
506
|
+
for (const entry of this.#snapshot.credentials) {
|
|
507
|
+
if (!ids.has(entry.id) || !entry.blocks) continue;
|
|
508
|
+
for (const block of entry.blocks) {
|
|
509
|
+
if (block.blockedUntilMs <= nowMs) continue;
|
|
510
|
+
blocks.push({
|
|
511
|
+
credentialId: entry.id,
|
|
512
|
+
providerKey: block.providerKey,
|
|
513
|
+
blockScope: block.blockScope,
|
|
514
|
+
blockedUntilMs: block.blockedUntilMs,
|
|
515
|
+
updatedAtMs: block.updatedAtMs,
|
|
516
|
+
});
|
|
517
|
+
}
|
|
518
|
+
}
|
|
519
|
+
blocks.sort((a, b) => a.credentialId - b.credentialId || compareCredentialBlockSnapshots(a, b));
|
|
520
|
+
return blocks;
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
upsertCredentialBlock(block: StoredCredentialBlock): void {
|
|
524
|
+
this.#upsertSnapshotBlock(block);
|
|
525
|
+
this.#invalidateUsageCache();
|
|
526
|
+
this.#credentialBlockReconcileAfter.set(
|
|
527
|
+
`${block.credentialId}\0${block.providerKey}\0${block.blockScope}`,
|
|
528
|
+
Math.min(block.blockedUntilMs, Date.now() + CREDENTIAL_BLOCK_RECONCILE_DELAY_MS),
|
|
529
|
+
);
|
|
530
|
+
const body = toCredentialBlockSnapshot(block);
|
|
531
|
+
void this.#client
|
|
532
|
+
.upsertCredentialBlock(block.credentialId, body)
|
|
533
|
+
.then(() => {
|
|
534
|
+
this.#maybeRefreshSnapshot("credential block");
|
|
535
|
+
})
|
|
536
|
+
.catch(error => {
|
|
537
|
+
logger.warn("auth-broker credential block propagation failed", {
|
|
538
|
+
id: block.credentialId,
|
|
539
|
+
providerKey: block.providerKey,
|
|
540
|
+
blockScope: block.blockScope,
|
|
541
|
+
error: String(error),
|
|
542
|
+
});
|
|
543
|
+
});
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
deleteCredentialBlock(_credentialId: number, _providerKey: string, _blockScope: string): void {
|
|
547
|
+
// The broker protocol only supports deleting every block for a credential.
|
|
548
|
+
// Keep scoped blocks until expiry rather than risk deleting unrelated or
|
|
549
|
+
// newer broker state through that broader operation.
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
deleteCredentialBlocks(credentialId: number): void {
|
|
553
|
+
this.#deleteSnapshotBlocks(credentialId);
|
|
554
|
+
for (const key of this.#credentialBlockReconcileAfter.keys()) {
|
|
555
|
+
if (key.startsWith(`${credentialId}\0`)) this.#credentialBlockReconcileAfter.delete(key);
|
|
556
|
+
}
|
|
557
|
+
this.#invalidateUsageCache();
|
|
558
|
+
void this.#client
|
|
559
|
+
.deleteCredentialBlocks(credentialId)
|
|
560
|
+
.then(() => {
|
|
561
|
+
this.#maybeRefreshSnapshot("credential blocks delete");
|
|
562
|
+
})
|
|
563
|
+
.catch(error => {
|
|
564
|
+
logger.warn("auth-broker credential blocks delete propagation failed", {
|
|
565
|
+
id: credentialId,
|
|
566
|
+
error: String(error),
|
|
567
|
+
});
|
|
568
|
+
});
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
cleanExpiredCredentialBlocks(nowMs: number): void {
|
|
572
|
+
this.#pruneExpiredCredentialBlocks(nowMs);
|
|
573
|
+
for (const [key, reconcileAfterMs] of this.#credentialBlockReconcileAfter) {
|
|
574
|
+
if (reconcileAfterMs <= nowMs) this.#credentialBlockReconcileAfter.delete(key);
|
|
575
|
+
}
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
/**
|
|
579
|
+
* In-memory update from a successful refresh through the broker. AuthStorage
|
|
580
|
+
* calls this after `#replaceCredentialAt`; the broker already persisted the
|
|
581
|
+
* authoritative row, so we just mirror it.
|
|
582
|
+
*/
|
|
583
|
+
updateAuthCredential(id: number, credential: AuthCredential): void {
|
|
584
|
+
for (const entry of this.#snapshot.credentials) {
|
|
585
|
+
if (entry.id !== id) continue;
|
|
586
|
+
entry.credential = credential as typeof entry.credential;
|
|
587
|
+
return;
|
|
588
|
+
}
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
deleteAuthCredential(id: number, disabledCause: string): void {
|
|
592
|
+
this.#removeCredentialById(id);
|
|
593
|
+
// Fire-and-forget: tell the broker to persist the disable.
|
|
594
|
+
this.#client.disableCredential(id, disabledCause).catch(error => {
|
|
595
|
+
logger.warn("auth-broker disable propagation failed", { id, error: String(error) });
|
|
596
|
+
});
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
async deleteAuthCredentialRemote(id: number, disabledCause: string): Promise<boolean> {
|
|
600
|
+
const found = this.#snapshot.credentials.some(entry => entry.id === id);
|
|
601
|
+
if (!found) return false;
|
|
602
|
+
await this.#client.disableCredential(id, disabledCause);
|
|
603
|
+
this.#removeCredentialById(id);
|
|
604
|
+
this.#maybeRefreshSnapshot("delete credential");
|
|
605
|
+
return true;
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
tryDisableAuthCredentialIfMatches(id: number, _expectedData: string, disabledCause: string): boolean {
|
|
609
|
+
const found = this.#snapshot.credentials.find(entry => entry.id === id);
|
|
610
|
+
if (!found) return false;
|
|
611
|
+
this.deleteAuthCredential(id, disabledCause);
|
|
612
|
+
return true;
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
async waitForFreshSnapshot(maxWaitMs: number, opts: { signal?: AbortSignal } = {}): Promise<boolean> {
|
|
616
|
+
const previousGeneration = this.#generation;
|
|
617
|
+
const result = await this.#client.fetchSnapshot({
|
|
618
|
+
ifGenerationGt: this.#generation,
|
|
619
|
+
waitMs: maxWaitMs,
|
|
620
|
+
signal: opts.signal,
|
|
621
|
+
});
|
|
622
|
+
if (result.status === 200) this.#applySnapshot(result.snapshot, result.generation);
|
|
623
|
+
return this.#generation !== previousGeneration;
|
|
624
|
+
}
|
|
625
|
+
|
|
626
|
+
async prepareForRequest(credentialId: number, opts: { signal?: AbortSignal } = {}): Promise<boolean> {
|
|
627
|
+
const entry = this.#snapshot.credentials.find(candidate => candidate.id === credentialId);
|
|
628
|
+
if (entry?.credential.type !== "oauth" || entry.rotatesInMs === null) return false;
|
|
629
|
+
const remainingMs = this.#snapshotReceivedAt + entry.rotatesInMs - Date.now();
|
|
630
|
+
if (remainingMs > WAIT_THRESHOLD_MS) return false;
|
|
631
|
+
return this.waitForFreshSnapshot(MAX_WAIT_MS, opts);
|
|
632
|
+
}
|
|
633
|
+
|
|
634
|
+
async markCredentialSuspect(credentialId: number, opts: { signal?: AbortSignal } = {}): Promise<void> {
|
|
635
|
+
const { entry } = await this.#client.refreshCredential(credentialId, opts.signal);
|
|
636
|
+
if (entry.credential.type !== "oauth") {
|
|
637
|
+
throw new AIError.AuthBrokerError(`Broker returned non-OAuth credential for id=${credentialId}`);
|
|
638
|
+
}
|
|
639
|
+
if (!this.#applyCredentialEntry(entry)) {
|
|
640
|
+
throw new AIError.AuthBrokerError(
|
|
641
|
+
`Broker refreshed credential id=${credentialId} outside the configured account pool`,
|
|
642
|
+
);
|
|
643
|
+
}
|
|
644
|
+
this.#maybeRefreshSnapshot("suspect credential refresh");
|
|
645
|
+
}
|
|
646
|
+
|
|
647
|
+
replaceAuthCredentialsForProvider(_provider: string, _credentials: AuthCredential[]): StoredAuthCredential[] {
|
|
648
|
+
throw new AIError.AuthBrokerError(
|
|
649
|
+
"RemoteAuthCredentialStore is read-only on the client. Use `omp auth-broker login <provider>` to mutate credentials.",
|
|
650
|
+
);
|
|
651
|
+
}
|
|
652
|
+
|
|
653
|
+
upsertAuthCredentialForProvider(_provider: string, _credential: AuthCredential): StoredAuthCredential[] {
|
|
654
|
+
throw new AIError.AuthBrokerError(
|
|
655
|
+
"RemoteAuthCredentialStore is read-only on the client. Use `omp auth-broker login <provider>` to mutate credentials.",
|
|
656
|
+
);
|
|
657
|
+
}
|
|
658
|
+
|
|
659
|
+
deleteAuthCredentialsForProvider(_provider: string, _disabledCause: string): void {
|
|
660
|
+
throw new AIError.AuthBrokerError(
|
|
661
|
+
"RemoteAuthCredentialStore is read-only on the client. Use `omp auth-broker logout <provider>` to mutate credentials.",
|
|
662
|
+
);
|
|
663
|
+
}
|
|
664
|
+
|
|
665
|
+
/**
|
|
666
|
+
* Upsert a single credential through the broker. The broker server is the
|
|
667
|
+
* canonical writer — see `POST /v1/credential`. The redacted snapshot
|
|
668
|
+
* entries returned by the server replace the provider's rows in our local
|
|
669
|
+
* snapshot, and the global snapshot is then refreshed in the background so
|
|
670
|
+
* any concurrent peer (refresh, generation bump) stays in sync.
|
|
671
|
+
*/
|
|
672
|
+
async upsertAuthCredentialRemote(provider: string, credential: AuthCredential): Promise<StoredAuthCredential[]> {
|
|
673
|
+
const { entries } = await this.#client.uploadCredential(provider, credential);
|
|
674
|
+
this.#applyProviderEntries(provider, entries);
|
|
675
|
+
this.#maybeRefreshSnapshot("upload");
|
|
676
|
+
return this.listAuthCredentials(provider);
|
|
677
|
+
}
|
|
678
|
+
|
|
679
|
+
/**
|
|
680
|
+
* Replace-all semantics: disable every active credential for the provider,
|
|
681
|
+
* then upload each of the new credentials. Used by API-key login so a new
|
|
682
|
+
* key clobbers any previously stored key for the same provider.
|
|
683
|
+
*/
|
|
684
|
+
async replaceAuthCredentialsRemote(
|
|
685
|
+
provider: string,
|
|
686
|
+
credentials: AuthCredential[],
|
|
687
|
+
): Promise<StoredAuthCredential[]> {
|
|
688
|
+
const existing = this.listAuthCredentials(provider);
|
|
689
|
+
for (const entry of existing) {
|
|
690
|
+
try {
|
|
691
|
+
await this.#client.disableCredential(entry.id, "replaced by newer credential");
|
|
692
|
+
} catch (error) {
|
|
693
|
+
logger.warn("auth-broker disable during replace failed", {
|
|
694
|
+
provider,
|
|
695
|
+
id: entry.id,
|
|
696
|
+
error: String(error),
|
|
697
|
+
});
|
|
698
|
+
}
|
|
699
|
+
}
|
|
700
|
+
// Snapshot reflects the disables before we add the new rows so a concurrent
|
|
701
|
+
// reader cannot momentarily see old + new together for the same provider.
|
|
702
|
+
this.#removeProviderEntries(provider);
|
|
703
|
+
for (const credential of credentials) {
|
|
704
|
+
const { entries } = await this.#client.uploadCredential(provider, credential);
|
|
705
|
+
this.#applyProviderEntries(provider, entries);
|
|
706
|
+
}
|
|
707
|
+
this.#maybeRefreshSnapshot("replace");
|
|
708
|
+
return this.listAuthCredentials(provider);
|
|
709
|
+
}
|
|
710
|
+
|
|
711
|
+
/**
|
|
712
|
+
* Logout: disable every active credential for the provider on the broker,
|
|
713
|
+
* then drop them from the local snapshot. Refresh fetches the authoritative
|
|
714
|
+
* post-state in the background.
|
|
715
|
+
*/
|
|
716
|
+
async deleteAuthCredentialsRemote(provider: string, disabledCause: string): Promise<void> {
|
|
717
|
+
const existing = this.listAuthCredentials(provider);
|
|
718
|
+
for (const entry of existing) {
|
|
719
|
+
try {
|
|
720
|
+
await this.#client.disableCredential(entry.id, disabledCause);
|
|
721
|
+
} catch (error) {
|
|
722
|
+
logger.warn("auth-broker disable during delete failed", {
|
|
723
|
+
provider,
|
|
724
|
+
id: entry.id,
|
|
725
|
+
error: String(error),
|
|
726
|
+
});
|
|
727
|
+
}
|
|
728
|
+
}
|
|
729
|
+
this.#removeProviderEntries(provider);
|
|
730
|
+
this.#maybeRefreshSnapshot("delete");
|
|
731
|
+
}
|
|
732
|
+
|
|
733
|
+
#applyProviderEntries(provider: string, entries: AuthCredentialSnapshotEntry[]): void {
|
|
734
|
+
// `entries` is the broker's authoritative post-upsert list of rows for
|
|
735
|
+
// `provider`. Drop our existing rows for the same provider and splice in
|
|
736
|
+
// the fresh set — preserving every other provider's rows in place.
|
|
737
|
+
const existingBlocks = new Map(
|
|
738
|
+
this.#snapshot.credentials
|
|
739
|
+
.filter(entry => entry.provider === provider && entry.blocks !== undefined)
|
|
740
|
+
.map(entry => [entry.id, entry.blocks] as const),
|
|
741
|
+
);
|
|
742
|
+
const others = this.#snapshot.credentials.filter(entry => entry.provider !== provider);
|
|
743
|
+
const incoming = entries
|
|
744
|
+
.filter(entry => isCredentialInAccountPool(entry, this.#accountPool))
|
|
745
|
+
.map(entry => credentialEntryWithBlocks(entry, existingBlocks.get(entry.id)));
|
|
746
|
+
this.#snapshot = { ...this.#snapshot, credentials: [...others, ...incoming] };
|
|
747
|
+
}
|
|
748
|
+
#applyCredentialEntry(entry: AuthCredentialSnapshotEntry): boolean {
|
|
749
|
+
if (!isCredentialInAccountPool(entry, this.#accountPool)) {
|
|
750
|
+
this.#removeCredentialById(entry.id);
|
|
751
|
+
return false;
|
|
752
|
+
}
|
|
753
|
+
const index = this.#snapshot.credentials.findIndex(candidate => candidate.id === entry.id);
|
|
754
|
+
const existingBlocks = index === -1 ? undefined : this.#snapshot.credentials[index]?.blocks;
|
|
755
|
+
const incoming = credentialEntryWithBlocks(entry, existingBlocks);
|
|
756
|
+
if (index === -1) {
|
|
757
|
+
this.#snapshot = { ...this.#snapshot, credentials: [...this.#snapshot.credentials, incoming] };
|
|
758
|
+
return true;
|
|
759
|
+
}
|
|
760
|
+
const credentials = [...this.#snapshot.credentials];
|
|
761
|
+
credentials[index] = incoming;
|
|
762
|
+
this.#snapshot = { ...this.#snapshot, credentials };
|
|
763
|
+
return true;
|
|
764
|
+
}
|
|
765
|
+
|
|
766
|
+
#removeProviderEntries(provider: string): void {
|
|
767
|
+
const next = this.#snapshot.credentials.filter(entry => entry.provider !== provider);
|
|
768
|
+
this.#snapshot = { ...this.#snapshot, credentials: next };
|
|
769
|
+
}
|
|
770
|
+
|
|
771
|
+
#removeCredentialById(id: number): void {
|
|
772
|
+
const next = this.#snapshot.credentials.filter(entry => entry.id !== id);
|
|
773
|
+
this.#snapshot = { ...this.#snapshot, credentials: next };
|
|
774
|
+
}
|
|
775
|
+
|
|
776
|
+
#normalizeSnapshotEntryBlocks(entry: SnapshotEntry, nowMs: number): SnapshotEntry {
|
|
777
|
+
if (!entry.blocks || entry.blocks.length === 0) return entry;
|
|
778
|
+
const blocks = entry.blocks
|
|
779
|
+
.filter(block => block.blockedUntilMs > nowMs)
|
|
780
|
+
.map(block => ({
|
|
781
|
+
providerKey: block.providerKey,
|
|
782
|
+
blockScope: block.blockScope,
|
|
783
|
+
blockedUntilMs: block.blockedUntilMs,
|
|
784
|
+
...(block.updatedAtMs !== undefined ? { updatedAtMs: block.updatedAtMs } : {}),
|
|
785
|
+
}))
|
|
786
|
+
.sort(compareCredentialBlockSnapshots);
|
|
787
|
+
if (blocks.length > 0) return { ...entry, blocks };
|
|
788
|
+
const next: SnapshotEntry = { ...entry };
|
|
789
|
+
delete next.blocks;
|
|
790
|
+
return next;
|
|
791
|
+
}
|
|
792
|
+
|
|
793
|
+
#upsertSnapshotBlock(block: StoredCredentialBlock): void {
|
|
794
|
+
const index = this.#snapshot.credentials.findIndex(entry => entry.id === block.credentialId);
|
|
795
|
+
if (index === -1) return;
|
|
796
|
+
const entry = this.#snapshot.credentials[index]!;
|
|
797
|
+
const incoming = toCredentialBlockSnapshot(block);
|
|
798
|
+
const blocks = entry.blocks ? [...entry.blocks] : [];
|
|
799
|
+
const blockIndex = blocks.findIndex(
|
|
800
|
+
candidate => candidate.providerKey === incoming.providerKey && candidate.blockScope === incoming.blockScope,
|
|
801
|
+
);
|
|
802
|
+
if (blockIndex === -1) {
|
|
803
|
+
blocks.push(incoming);
|
|
804
|
+
} else {
|
|
805
|
+
const existing = blocks[blockIndex]!;
|
|
806
|
+
blocks[blockIndex] = {
|
|
807
|
+
...existing,
|
|
808
|
+
blockedUntilMs: Math.max(existing.blockedUntilMs, incoming.blockedUntilMs),
|
|
809
|
+
};
|
|
810
|
+
}
|
|
811
|
+
blocks.sort(compareCredentialBlockSnapshots);
|
|
812
|
+
const credentials = [...this.#snapshot.credentials];
|
|
813
|
+
credentials[index] = { ...entry, blocks };
|
|
814
|
+
this.#snapshot = { ...this.#snapshot, credentials };
|
|
815
|
+
}
|
|
816
|
+
|
|
817
|
+
#deleteSnapshotBlocks(credentialId: number): void {
|
|
818
|
+
const index = this.#snapshot.credentials.findIndex(entry => entry.id === credentialId);
|
|
819
|
+
if (index === -1) return;
|
|
820
|
+
const entry = this.#snapshot.credentials[index]!;
|
|
821
|
+
if (!entry.blocks || entry.blocks.length === 0) return;
|
|
822
|
+
const next: SnapshotEntry = { ...entry };
|
|
823
|
+
delete next.blocks;
|
|
824
|
+
const credentials = [...this.#snapshot.credentials];
|
|
825
|
+
credentials[index] = next;
|
|
826
|
+
this.#snapshot = { ...this.#snapshot, credentials };
|
|
827
|
+
}
|
|
828
|
+
|
|
829
|
+
#pruneExpiredCredentialBlocks(nowMs: number): void {
|
|
830
|
+
let changed = false;
|
|
831
|
+
const credentials = this.#snapshot.credentials.map(entry => {
|
|
832
|
+
if (!entry.blocks || entry.blocks.length === 0) return entry;
|
|
833
|
+
const blocks = entry.blocks.filter(block => block.blockedUntilMs > nowMs);
|
|
834
|
+
if (blocks.length === entry.blocks.length) return entry;
|
|
835
|
+
changed = true;
|
|
836
|
+
if (blocks.length > 0) return { ...entry, blocks };
|
|
837
|
+
const next: SnapshotEntry = { ...entry };
|
|
838
|
+
delete next.blocks;
|
|
839
|
+
return next;
|
|
840
|
+
});
|
|
841
|
+
if (changed) this.#snapshot = { ...this.#snapshot, credentials };
|
|
842
|
+
}
|
|
843
|
+
|
|
844
|
+
/**
|
|
845
|
+
* Fire-and-forget `refreshSnapshot()` after a write. When the SSE stream is
|
|
846
|
+
* active the broker will deliver the new generation push, so the extra GET
|
|
847
|
+
* is wasted bandwidth and we skip it.
|
|
848
|
+
*/
|
|
849
|
+
#maybeRefreshSnapshot(reason: string): void {
|
|
850
|
+
if (this.#streamingActive) return;
|
|
851
|
+
void this.refreshSnapshot().catch(error => {
|
|
852
|
+
logger.debug("auth-broker snapshot refresh after write failed", { reason, error: String(error) });
|
|
853
|
+
});
|
|
854
|
+
}
|
|
855
|
+
|
|
856
|
+
getCache(key: string): string | null {
|
|
857
|
+
const entry = this.#cache.get(key);
|
|
858
|
+
if (!entry) return null;
|
|
859
|
+
if (entry.expiresAtSec * 1000 <= Date.now()) {
|
|
860
|
+
this.#cache.delete(key);
|
|
861
|
+
return null;
|
|
862
|
+
}
|
|
863
|
+
return entry.value;
|
|
864
|
+
}
|
|
865
|
+
|
|
866
|
+
setCache(key: string, value: string, expiresAtSec: number): void {
|
|
867
|
+
this.#cache.set(key, { value, expiresAtSec });
|
|
868
|
+
}
|
|
869
|
+
|
|
870
|
+
/** Drop all cache rows whose keys start with the supplied prefix. */
|
|
871
|
+
deleteCachePrefix(prefix: string): void {
|
|
872
|
+
for (const key of this.#cache.keys()) {
|
|
873
|
+
if (key.startsWith(prefix)) this.#cache.delete(key);
|
|
874
|
+
}
|
|
875
|
+
}
|
|
876
|
+
|
|
877
|
+
cleanExpiredCache(): void {
|
|
878
|
+
const nowSec = Math.floor(Date.now() / 1000);
|
|
879
|
+
for (const [key, entry] of this.#cache) {
|
|
880
|
+
if (entry.expiresAtSec <= nowSec) this.#cache.delete(key);
|
|
881
|
+
}
|
|
882
|
+
}
|
|
883
|
+
|
|
884
|
+
async invalidateUsageCache(signal?: AbortSignal): Promise<void> {
|
|
885
|
+
this.#invalidateUsageCache();
|
|
886
|
+
await this.#client.notifyUsageStale(signal).catch(err => {
|
|
887
|
+
logger.warn("auth-broker notification of stale usage failed", { error: String(err) });
|
|
888
|
+
});
|
|
889
|
+
}
|
|
890
|
+
|
|
891
|
+
#invalidateUsageCache(): void {
|
|
892
|
+
this.#usageCache = undefined;
|
|
893
|
+
this.#usageInflight = undefined;
|
|
894
|
+
this.#usageCacheEpoch += 1;
|
|
895
|
+
}
|
|
896
|
+
|
|
897
|
+
/**
|
|
898
|
+
* Store-level hook consumed by `AuthStorage` — routes refresh through the
|
|
899
|
+
* broker so the actual refresh token never leaves the broker host. Returns
|
|
900
|
+
* the broker-redacted credential with {@link REMOTE_REFRESH_SENTINEL} in
|
|
901
|
+
* the `refresh` slot.
|
|
902
|
+
*/
|
|
903
|
+
async refreshOAuthCredential(
|
|
904
|
+
_provider: Provider,
|
|
905
|
+
credentialId: number,
|
|
906
|
+
_credential: OAuthCredential,
|
|
907
|
+
signal?: AbortSignal,
|
|
908
|
+
): Promise<OAuthCredentials> {
|
|
909
|
+
const { entry } = await this.#client.refreshCredential(credentialId, signal);
|
|
910
|
+
if (entry.credential.type !== "oauth") {
|
|
911
|
+
throw new AIError.AuthBrokerError(`Broker returned non-OAuth credential for id=${credentialId}`);
|
|
912
|
+
}
|
|
913
|
+
if (!this.#applyCredentialEntry(entry)) {
|
|
914
|
+
throw new AIError.AuthBrokerError(
|
|
915
|
+
`Broker refreshed credential id=${credentialId} outside the configured account pool`,
|
|
916
|
+
);
|
|
917
|
+
}
|
|
918
|
+
if (!this.#streamingActive) {
|
|
919
|
+
await this.refreshSnapshot().catch(error => {
|
|
920
|
+
logger.debug("auth-broker snapshot refresh after credential refresh failed", { error: String(error) });
|
|
921
|
+
});
|
|
922
|
+
}
|
|
923
|
+
const refreshed = entry.credential;
|
|
924
|
+
return {
|
|
925
|
+
access: refreshed.access,
|
|
926
|
+
refresh: REMOTE_REFRESH_SENTINEL,
|
|
927
|
+
expires: refreshed.expires,
|
|
928
|
+
accountId: refreshed.accountId,
|
|
929
|
+
email: refreshed.email,
|
|
930
|
+
projectId: refreshed.projectId,
|
|
931
|
+
enterpriseUrl: refreshed.enterpriseUrl,
|
|
932
|
+
};
|
|
933
|
+
}
|
|
934
|
+
|
|
935
|
+
/**
|
|
936
|
+
* Store-level hook consumed by `AuthStorage.fetchUsageReports()` — proxies
|
|
937
|
+
* to the broker's `/v1/usage` endpoint. The broker's egress IP isn't
|
|
938
|
+
* rate-limited by Anthropic's per-IP `/usage` cap the way a heavy
|
|
939
|
+
* residential laptop is, so all credentials surface every cycle.
|
|
940
|
+
*/
|
|
941
|
+
async fetchUsageReports(signal?: AbortSignal): Promise<UsageReport[] | null> {
|
|
942
|
+
const reports = await this.#raceWithSignal(this.#loadUsageReports(), signal);
|
|
943
|
+
if (!reports) return null;
|
|
944
|
+
return this.#filterUsageReports(this.#applyUsageOverlays(reports));
|
|
945
|
+
}
|
|
946
|
+
|
|
947
|
+
/**
|
|
948
|
+
* Per-credential usage hook consumed by `AuthStorage.#getUsageReport`. Pulls
|
|
949
|
+
* the aggregate broker `/v1/usage` once and serves all callers from the
|
|
950
|
+
* same response (coalesced + cached), then overlays any client-observed
|
|
951
|
+
* header hints for the matching credential.
|
|
952
|
+
*
|
|
953
|
+
* The broker already aggregates with its own 30s TTL on the server side; our
|
|
954
|
+
* 15s client TTL is below that so we usually re-use the broker's cache too.
|
|
955
|
+
*/
|
|
956
|
+
async getUsageReport(
|
|
957
|
+
provider: Provider,
|
|
958
|
+
credential: OAuthCredential,
|
|
959
|
+
signal?: AbortSignal,
|
|
960
|
+
): Promise<UsageReport | null> {
|
|
961
|
+
const reports = await this.#raceWithSignal(this.#loadUsageReports(), signal);
|
|
962
|
+
const visibleReports = reports ? this.#filterUsageReports(reports) : null;
|
|
963
|
+
const matched = visibleReports ? matchUsageReport(visibleReports, provider, credential) : null;
|
|
964
|
+
const overlay = this.#getActiveUsageOverlay(provider, credential);
|
|
965
|
+
if (matched && overlay) return mergeUsageReports(matched, overlay);
|
|
966
|
+
return overlay ?? matched;
|
|
967
|
+
}
|
|
968
|
+
|
|
969
|
+
/**
|
|
970
|
+
* Hot path — called per `getUsageReport()`/`fetchUsageReports()` (status-line
|
|
971
|
+
* refresh cadence). The oauth-credential lookup is memoized on `#snapshot`
|
|
972
|
+
* identity (every update site replaces the reference), and the filtered
|
|
973
|
+
* output on (reports identity, lookup identity) — `#loadUsageReports`
|
|
974
|
+
* serves the same array for 15s, so steady-state calls are O(1).
|
|
975
|
+
*/
|
|
976
|
+
#filterUsageReports(reports: UsageReport[]): UsageReport[] {
|
|
977
|
+
const accountPool = this.#accountPool;
|
|
978
|
+
if (!accountPool) return reports;
|
|
979
|
+
let lookup = this.#usageFilterLookup;
|
|
980
|
+
if (!lookup || lookup.snapshot !== this.#snapshot) {
|
|
981
|
+
const byProvider = new Map<Provider, OAuthCredential[]>();
|
|
982
|
+
for (const entry of this.#snapshot.credentials) {
|
|
983
|
+
if (entry.credential.type !== "oauth") continue;
|
|
984
|
+
const list = byProvider.get(entry.provider);
|
|
985
|
+
if (list) list.push(entry.credential);
|
|
986
|
+
else byProvider.set(entry.provider, [entry.credential]);
|
|
987
|
+
}
|
|
988
|
+
lookup = { snapshot: this.#snapshot, byProvider };
|
|
989
|
+
this.#usageFilterLookup = lookup;
|
|
990
|
+
}
|
|
991
|
+
const memo = this.#usageFilterResult;
|
|
992
|
+
if (memo && memo.input === reports && memo.byProvider === lookup.byProvider) return memo.output;
|
|
993
|
+
const byProvider = lookup.byProvider;
|
|
994
|
+
const output = reports.filter(report => {
|
|
995
|
+
if (!accountPool.has(report.provider)) return true;
|
|
996
|
+
const credentials = byProvider.get(report.provider);
|
|
997
|
+
if (!credentials) return false;
|
|
998
|
+
return credentials.some(credential => usageReportMatchesCredential(report, credential));
|
|
999
|
+
});
|
|
1000
|
+
this.#usageFilterResult = { input: reports, byProvider, output };
|
|
1001
|
+
return output;
|
|
1002
|
+
}
|
|
1003
|
+
|
|
1004
|
+
ingestUsageReport(provider: Provider, credential: OAuthCredential, report: UsageReport): boolean {
|
|
1005
|
+
const key = usageOverlayKey(provider, credential);
|
|
1006
|
+
if (!key) return false;
|
|
1007
|
+
const activeOverlay = this.#getActiveUsageOverlay(provider, credential);
|
|
1008
|
+
this.#usageOverlays.set(key, activeOverlay ? mergeUsageReports(activeOverlay, report) : report);
|
|
1009
|
+
return true;
|
|
1010
|
+
}
|
|
1011
|
+
|
|
1012
|
+
#getActiveUsageOverlay(provider: Provider, credential: OAuthCredential): UsageReport | undefined {
|
|
1013
|
+
const key = usageOverlayKey(provider, credential);
|
|
1014
|
+
if (!key) return undefined;
|
|
1015
|
+
const overlay = this.#usageOverlays.get(key);
|
|
1016
|
+
if (!overlay) return undefined;
|
|
1017
|
+
if (Date.now() - overlay.fetchedAt >= USAGE_CACHE_TTL_MS) {
|
|
1018
|
+
this.#usageOverlays.delete(key);
|
|
1019
|
+
return undefined;
|
|
1020
|
+
}
|
|
1021
|
+
return overlay;
|
|
1022
|
+
}
|
|
1023
|
+
|
|
1024
|
+
#applyUsageOverlays(reports: UsageReport[]): UsageReport[] {
|
|
1025
|
+
const overlays = [...this.#usageOverlays.values()].filter(
|
|
1026
|
+
overlay => Date.now() - overlay.fetchedAt < USAGE_CACHE_TTL_MS,
|
|
1027
|
+
);
|
|
1028
|
+
if (overlays.length === 0) return reports;
|
|
1029
|
+
const merged = [...reports];
|
|
1030
|
+
for (const overlay of overlays) {
|
|
1031
|
+
const matchIndex = findMatchingReportIndex(merged, overlay);
|
|
1032
|
+
if (matchIndex === -1) {
|
|
1033
|
+
merged.push(overlay);
|
|
1034
|
+
} else {
|
|
1035
|
+
merged[matchIndex] = mergeUsageReports(merged[matchIndex]!, overlay);
|
|
1036
|
+
}
|
|
1037
|
+
}
|
|
1038
|
+
return merged;
|
|
1039
|
+
}
|
|
1040
|
+
|
|
1041
|
+
/**
|
|
1042
|
+
* Reject the awaited promise when the caller's signal aborts, without
|
|
1043
|
+
* affecting the shared upstream fetch. Used to give each caller their
|
|
1044
|
+
* own cancel without one caller's abort cascading into a peer's in-flight
|
|
1045
|
+
* request through the single-flight `#usageInflight`.
|
|
1046
|
+
*/
|
|
1047
|
+
#raceWithSignal<T>(promise: Promise<T>, signal?: AbortSignal): Promise<T> {
|
|
1048
|
+
if (!signal) return promise;
|
|
1049
|
+
if (signal.aborted) return Promise.reject(new AIError.AbortError("auth-broker request aborted"));
|
|
1050
|
+
return new Promise<T>((resolve, reject) => {
|
|
1051
|
+
const onAbort = (): void => {
|
|
1052
|
+
signal.removeEventListener("abort", onAbort);
|
|
1053
|
+
reject(new AIError.AbortError("auth-broker request aborted"));
|
|
1054
|
+
};
|
|
1055
|
+
signal.addEventListener("abort", onAbort, { once: true });
|
|
1056
|
+
promise.then(
|
|
1057
|
+
value => {
|
|
1058
|
+
signal.removeEventListener("abort", onAbort);
|
|
1059
|
+
resolve(value);
|
|
1060
|
+
},
|
|
1061
|
+
err => {
|
|
1062
|
+
signal.removeEventListener("abort", onAbort);
|
|
1063
|
+
reject(err);
|
|
1064
|
+
},
|
|
1065
|
+
);
|
|
1066
|
+
});
|
|
1067
|
+
}
|
|
1068
|
+
|
|
1069
|
+
#loadUsageReports(): Promise<UsageReport[] | null> {
|
|
1070
|
+
const cached = this.#usageCache;
|
|
1071
|
+
if (cached && Date.now() - cached.fetchedAt < USAGE_CACHE_TTL_MS) {
|
|
1072
|
+
return Promise.resolve(cached.reports);
|
|
1073
|
+
}
|
|
1074
|
+
if (this.#usageInflight) return this.#usageInflight;
|
|
1075
|
+
const epoch = this.#usageCacheEpoch;
|
|
1076
|
+
const inflight = this.#client
|
|
1077
|
+
.fetchUsage()
|
|
1078
|
+
.then(body => {
|
|
1079
|
+
if (epoch !== this.#usageCacheEpoch) return this.#loadUsageReports();
|
|
1080
|
+
this.#usageCache = { reports: body.reports, fetchedAt: Date.now() };
|
|
1081
|
+
return body.reports;
|
|
1082
|
+
})
|
|
1083
|
+
.catch(error => {
|
|
1084
|
+
logger.warn("auth-broker usage fetch failed", { error: String(error) });
|
|
1085
|
+
// Documented 15s TTL fallback: cache the null so sequential callers
|
|
1086
|
+
// don't re-hit the broker while it's still down. See
|
|
1087
|
+
// docs/auth-broker-gateway.md § "Client-side single-flight".
|
|
1088
|
+
if (epoch !== this.#usageCacheEpoch) return this.#loadUsageReports();
|
|
1089
|
+
this.#usageCache = { reports: null, fetchedAt: Date.now() };
|
|
1090
|
+
return null;
|
|
1091
|
+
})
|
|
1092
|
+
.finally(() => {
|
|
1093
|
+
if (this.#usageInflight === inflight) this.#usageInflight = undefined;
|
|
1094
|
+
});
|
|
1095
|
+
this.#usageInflight = inflight;
|
|
1096
|
+
return inflight;
|
|
1097
|
+
}
|
|
1098
|
+
|
|
1099
|
+
/**
|
|
1100
|
+
* Fold locally observed request usage into the pending report and schedule
|
|
1101
|
+
* a flush. One `POST /v1/usage/observed` at most per flush interval; on
|
|
1102
|
+
* failure the batch is retained and retried with the next flush. A 404
|
|
1103
|
+
* (pre-endpoint broker) disables reporting for the life of this store.
|
|
1104
|
+
*/
|
|
1105
|
+
recordObservedUsage(entries: ObservedUsageEntry[]): void {
|
|
1106
|
+
if (this.#closed || this.#observedUsageUnsupported) return;
|
|
1107
|
+
for (const entry of entries) {
|
|
1108
|
+
const key = `${entry.provider}\u0000${entry.model}`;
|
|
1109
|
+
const pending = this.#observedUsage.get(key);
|
|
1110
|
+
if (pending) {
|
|
1111
|
+
pending.at = Math.max(pending.at, entry.at);
|
|
1112
|
+
pending.requests += entry.requests;
|
|
1113
|
+
pending.inputTokens += entry.inputTokens;
|
|
1114
|
+
pending.outputTokens += entry.outputTokens;
|
|
1115
|
+
pending.cacheReadTokens += entry.cacheReadTokens;
|
|
1116
|
+
pending.cacheWriteTokens += entry.cacheWriteTokens;
|
|
1117
|
+
pending.costUsd += entry.costUsd;
|
|
1118
|
+
} else {
|
|
1119
|
+
this.#observedUsage.set(key, { ...entry });
|
|
1120
|
+
}
|
|
1121
|
+
}
|
|
1122
|
+
if (this.#observedUsage.size > 0 && this.#observedUsageTimer === undefined) {
|
|
1123
|
+
this.#observedUsageTimer = setTimeout(() => {
|
|
1124
|
+
this.#observedUsageTimer = undefined;
|
|
1125
|
+
void this.#flushObservedUsage();
|
|
1126
|
+
}, this.#observedUsageFlushMs);
|
|
1127
|
+
this.#observedUsageTimer.unref?.();
|
|
1128
|
+
}
|
|
1129
|
+
}
|
|
1130
|
+
|
|
1131
|
+
async #flushObservedUsage(): Promise<void> {
|
|
1132
|
+
if (this.#observedUsage.size === 0 || this.#observedUsageUnsupported) return;
|
|
1133
|
+
const batch = [...this.#observedUsage.values()];
|
|
1134
|
+
this.#observedUsage.clear();
|
|
1135
|
+
try {
|
|
1136
|
+
await this.#client.reportClientUsage({
|
|
1137
|
+
installId: getInstallId(),
|
|
1138
|
+
hostname: os.hostname(),
|
|
1139
|
+
entries: batch,
|
|
1140
|
+
});
|
|
1141
|
+
} catch (error) {
|
|
1142
|
+
const status = error instanceof AuthBrokerError ? error.status : undefined;
|
|
1143
|
+
if (status === 404 || status === 501) {
|
|
1144
|
+
// Broker predates the endpoint (or store can't persist) — stop trying.
|
|
1145
|
+
this.#observedUsageUnsupported = true;
|
|
1146
|
+
logger.debug("auth-broker does not accept observed usage; reporting disabled", { status });
|
|
1147
|
+
return;
|
|
1148
|
+
}
|
|
1149
|
+
logger.debug("auth-broker observed usage flush failed; retrying next flush", { error: String(error) });
|
|
1150
|
+
// Merge the failed batch back under the (possibly refilled) buffer so
|
|
1151
|
+
// nothing is lost; bounded because entries are keyed per (provider, model).
|
|
1152
|
+
if (!this.#closed) this.recordObservedUsage(batch);
|
|
1153
|
+
}
|
|
1154
|
+
}
|
|
1155
|
+
|
|
1156
|
+
close(): void {
|
|
1157
|
+
if (this.#closed) return;
|
|
1158
|
+
this.#closed = true;
|
|
1159
|
+
this.#backgroundAbort.abort();
|
|
1160
|
+
if (this.#observedUsageTimer !== undefined) {
|
|
1161
|
+
clearTimeout(this.#observedUsageTimer);
|
|
1162
|
+
this.#observedUsageTimer = undefined;
|
|
1163
|
+
}
|
|
1164
|
+
// Best-effort final flush; failures are dropped (the process is exiting).
|
|
1165
|
+
if (this.#observedUsage.size > 0) void this.#flushObservedUsage();
|
|
1166
|
+
this.#cache.clear();
|
|
1167
|
+
this.#usageOverlays.clear();
|
|
1168
|
+
}
|
|
1169
|
+
}
|
|
1170
|
+
|
|
1171
|
+
/**
|
|
1172
|
+
* Match a broker-supplied usage report to a specific OAuth credential. The
|
|
1173
|
+
* broker returns aggregate reports across all credentials it manages, so we
|
|
1174
|
+
* pick the one whose identity (accountId / email / projectId) lines up with
|
|
1175
|
+
* the credential the caller is asking about.
|
|
1176
|
+
*
|
|
1177
|
+
* Falls back to the lone candidate when only one matches the provider; falls
|
|
1178
|
+
* through to `null` when nothing matches, which `AuthStorage` treats as "no
|
|
1179
|
+
* usage data" (ranking proceeds without a usage signal for this credential).
|
|
1180
|
+
*/
|
|
1181
|
+
function matchUsageReport(reports: UsageReport[], provider: Provider, credential: OAuthCredential): UsageReport | null {
|
|
1182
|
+
const all = reports.filter(report => report.provider === provider);
|
|
1183
|
+
if (all.length === 0) return null;
|
|
1184
|
+
// Org precedence, decisive on EITHER side: an org-scoped credential may
|
|
1185
|
+
// only take its own org's report, and an org-less (legacy) credential may
|
|
1186
|
+
// only take org-less reports — the shared email/account would otherwise
|
|
1187
|
+
// hand one subscription the OTHER subscription's pool (e.g. mark healthy
|
|
1188
|
+
// Max exhausted via Team's report, or rank a legacy row on a sibling's
|
|
1189
|
+
// numbers).
|
|
1190
|
+
const orgId = credential.orgId?.trim().toLowerCase();
|
|
1191
|
+
const accountId = credential.accountId?.trim().toLowerCase();
|
|
1192
|
+
const email = credential.email?.trim().toLowerCase();
|
|
1193
|
+
const projectId = credential.projectId?.trim().toLowerCase();
|
|
1194
|
+
if (orgId) {
|
|
1195
|
+
const sameOrg: UsageReport[] = [];
|
|
1196
|
+
let sawReportOrg = false;
|
|
1197
|
+
for (const report of all) {
|
|
1198
|
+
const metaOrg = readMetadataString((report.metadata ?? {}) as Record<string, unknown>, "orgId");
|
|
1199
|
+
if (metaOrg) {
|
|
1200
|
+
sawReportOrg = true;
|
|
1201
|
+
if (metaOrg.toLowerCase() === orgId) sameOrg.push(report);
|
|
1202
|
+
}
|
|
1203
|
+
}
|
|
1204
|
+
// Org-attributed reports exist: the shared org is a GATE, not a match.
|
|
1205
|
+
// Two Team members share the org id while drawing on per-user pools,
|
|
1206
|
+
// so the credential's own base identity must still line up inside the
|
|
1207
|
+
// same-org subset — a lone sibling report is NOT ours. An org-only
|
|
1208
|
+
// credential (no base identifiers) takes the lone same-org report and
|
|
1209
|
+
// treats several as ambiguous. None in our org → "no usage data"
|
|
1210
|
+
// rather than mis-attributing another org's pool.
|
|
1211
|
+
if (sawReportOrg) {
|
|
1212
|
+
if (accountId || email || projectId) {
|
|
1213
|
+
for (const report of sameOrg) {
|
|
1214
|
+
if (reportMatchesIdentity(report, accountId, email, projectId)) return report;
|
|
1215
|
+
}
|
|
1216
|
+
return null;
|
|
1217
|
+
}
|
|
1218
|
+
return sameOrg.length === 1 ? sameOrg[0]! : null;
|
|
1219
|
+
}
|
|
1220
|
+
// No surviving report carries an org at all: presence mismatch is a
|
|
1221
|
+
// non-match too — the sole org-less report may be a legacy sibling
|
|
1222
|
+
// row's pool, and handing it to a scoped credential would rank/block
|
|
1223
|
+
// on the wrong quota. "No usage data" degrades gracefully instead.
|
|
1224
|
+
return null;
|
|
1225
|
+
}
|
|
1226
|
+
const candidates = all.filter(
|
|
1227
|
+
report => !readMetadataString((report.metadata ?? {}) as Record<string, unknown>, "orgId"),
|
|
1228
|
+
);
|
|
1229
|
+
if (candidates.length === 0) return null;
|
|
1230
|
+
if (all.length === 1 && candidates.length === 1) return candidates[0];
|
|
1231
|
+
for (const report of candidates) {
|
|
1232
|
+
if (reportMatchesIdentity(report, accountId, email, projectId)) return report;
|
|
1233
|
+
}
|
|
1234
|
+
return null;
|
|
1235
|
+
}
|
|
1236
|
+
|
|
1237
|
+
function usageReportMatchesCredential(report: UsageReport, credential: OAuthCredential): boolean {
|
|
1238
|
+
const metadata = (report.metadata ?? {}) as Record<string, unknown>;
|
|
1239
|
+
const credentialOrg = credential.orgId?.trim().toLowerCase();
|
|
1240
|
+
const reportOrg = readMetadataString(metadata, "orgId")?.toLowerCase();
|
|
1241
|
+
if (credentialOrg !== reportOrg) return false;
|
|
1242
|
+
|
|
1243
|
+
const accountId = credential.accountId?.trim().toLowerCase();
|
|
1244
|
+
const email = credential.email?.trim().toLowerCase();
|
|
1245
|
+
const projectId = credential.projectId?.trim().toLowerCase();
|
|
1246
|
+
if (accountId || email || projectId) {
|
|
1247
|
+
return reportMatchesIdentity(report, accountId, email, projectId);
|
|
1248
|
+
}
|
|
1249
|
+
return credentialOrg !== undefined;
|
|
1250
|
+
}
|
|
1251
|
+
|
|
1252
|
+
function findMatchingReportIndex(reports: UsageReport[], overlay: UsageReport): number {
|
|
1253
|
+
const all = reports
|
|
1254
|
+
.map((report, index) => ({ report, index }))
|
|
1255
|
+
.filter(candidate => candidate.report.provider === overlay.provider);
|
|
1256
|
+
if (all.length === 0) return -1;
|
|
1257
|
+
const metadata = (overlay.metadata ?? {}) as Record<string, unknown>;
|
|
1258
|
+
// Org precedence — mirror matchUsageReport: an org-attributed overlay may
|
|
1259
|
+
// only merge into a report of the SAME org, and an org-less overlay may
|
|
1260
|
+
// only merge into an org-less report. Within the same org the overlay's
|
|
1261
|
+
// base identity must still match — two Team members' reports share the
|
|
1262
|
+
// org id but must not swallow each other's header ingests.
|
|
1263
|
+
const overlayOrg = readMetadataString(metadata, "orgId")?.toLowerCase();
|
|
1264
|
+
const accountId = readMetadataString(metadata, "accountId")?.toLowerCase();
|
|
1265
|
+
const email = readMetadataString(metadata, "email")?.toLowerCase();
|
|
1266
|
+
const projectId = readMetadataString(metadata, "projectId")?.toLowerCase();
|
|
1267
|
+
if (overlayOrg) {
|
|
1268
|
+
const sameOrg: { report: UsageReport; index: number }[] = [];
|
|
1269
|
+
let sawReportOrg = false;
|
|
1270
|
+
for (const candidate of all) {
|
|
1271
|
+
const candidateOrg = readMetadataString((candidate.report.metadata ?? {}) as Record<string, unknown>, "orgId");
|
|
1272
|
+
if (candidateOrg) {
|
|
1273
|
+
sawReportOrg = true;
|
|
1274
|
+
if (candidateOrg.toLowerCase() === overlayOrg) sameOrg.push(candidate);
|
|
1275
|
+
}
|
|
1276
|
+
}
|
|
1277
|
+
if (sawReportOrg) {
|
|
1278
|
+
if (accountId || email || projectId) {
|
|
1279
|
+
for (const candidate of sameOrg) {
|
|
1280
|
+
if (reportMatchesIdentity(candidate.report, accountId, email, projectId)) return candidate.index;
|
|
1281
|
+
}
|
|
1282
|
+
return -1;
|
|
1283
|
+
}
|
|
1284
|
+
return sameOrg.length === 1 ? sameOrg[0]!.index : -1;
|
|
1285
|
+
}
|
|
1286
|
+
// Presence mismatch — mirror matchUsageReport: an org-scoped overlay
|
|
1287
|
+
// never merges into an org-less report; it becomes its own report row.
|
|
1288
|
+
return -1;
|
|
1289
|
+
}
|
|
1290
|
+
const candidates = all.filter(
|
|
1291
|
+
candidate => !readMetadataString((candidate.report.metadata ?? {}) as Record<string, unknown>, "orgId"),
|
|
1292
|
+
);
|
|
1293
|
+
if (candidates.length === 0) return -1;
|
|
1294
|
+
if (all.length === 1 && candidates.length === 1) return candidates[0]!.index;
|
|
1295
|
+
for (const candidate of candidates) {
|
|
1296
|
+
if (reportMatchesIdentity(candidate.report, accountId, email, projectId)) return candidate.index;
|
|
1297
|
+
}
|
|
1298
|
+
return -1;
|
|
1299
|
+
}
|
|
1300
|
+
|
|
1301
|
+
function reportMatchesIdentity(
|
|
1302
|
+
report: UsageReport,
|
|
1303
|
+
accountId: string | undefined,
|
|
1304
|
+
email: string | undefined,
|
|
1305
|
+
projectId: string | undefined,
|
|
1306
|
+
): boolean {
|
|
1307
|
+
const metadata = (report.metadata ?? {}) as Record<string, unknown>;
|
|
1308
|
+
if (accountId) {
|
|
1309
|
+
const metaAccount = readMetadataString(metadata, "accountId") ?? readMetadataString(metadata, "account_id");
|
|
1310
|
+
if (metaAccount && metaAccount.toLowerCase() === accountId) return true;
|
|
1311
|
+
for (const limit of report.limits) {
|
|
1312
|
+
if (limit.scope.accountId?.toLowerCase() === accountId) return true;
|
|
1313
|
+
}
|
|
1314
|
+
}
|
|
1315
|
+
if (email) {
|
|
1316
|
+
const metaEmail = readMetadataString(metadata, "email");
|
|
1317
|
+
if (metaEmail && metaEmail.toLowerCase() === email) return true;
|
|
1318
|
+
}
|
|
1319
|
+
if (projectId) {
|
|
1320
|
+
const metaProject = readMetadataString(metadata, "projectId") ?? readMetadataString(metadata, "project_id");
|
|
1321
|
+
if (metaProject && metaProject.toLowerCase() === projectId) return true;
|
|
1322
|
+
for (const limit of report.limits) {
|
|
1323
|
+
if (limit.scope.projectId?.toLowerCase() === projectId) return true;
|
|
1324
|
+
}
|
|
1325
|
+
}
|
|
1326
|
+
return false;
|
|
1327
|
+
}
|
|
1328
|
+
|
|
1329
|
+
function readMetadataString(metadata: Record<string, unknown>, key: string): string | undefined {
|
|
1330
|
+
const value = metadata[key];
|
|
1331
|
+
return typeof value === "string" && value.trim().length > 0 ? value.trim() : undefined;
|
|
1332
|
+
}
|