cartethyia 2.5.3 → 2.5.6
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/.env.example +13 -4
- package/README.md +2 -2
- package/dist/dashboard/assets/{BannedPage-hfCzi-Ux.js → BannedPage-CADDq4IU.js} +1 -1
- package/dist/dashboard/assets/BannedPage-CADDq4IU.js.gz +0 -0
- package/dist/dashboard/assets/CliToolDetailPage-_z1hID7E.js +1 -0
- package/dist/dashboard/assets/CliToolDetailPage-_z1hID7E.js.gz +0 -0
- package/dist/dashboard/assets/{CliToolsPage-DEDyKBlC.js → CliToolsPage-DuO1Z7dq.js} +1 -1
- package/dist/dashboard/assets/CliToolsPage-DuO1Z7dq.js.gz +0 -0
- package/dist/dashboard/assets/CombosPage-DNEPi3xU.js +1 -0
- package/dist/dashboard/assets/CombosPage-DNEPi3xU.js.gz +0 -0
- package/dist/dashboard/assets/{ConsoleLogPage-bsLuZmYH.js → ConsoleLogPage-BVdpfiz1.js} +1 -1
- package/dist/dashboard/assets/ConsoleLogPage-BVdpfiz1.js.gz +0 -0
- package/dist/dashboard/assets/{CustomizationPage-_UWhwziJ.js → CustomizationPage-BR5HHvbi.js} +1 -1
- package/dist/dashboard/assets/CustomizationPage-BR5HHvbi.js.gz +0 -0
- package/dist/dashboard/assets/HealthEventsModal-mUfcknqm.js +1 -0
- package/dist/dashboard/assets/HealthEventsModal-mUfcknqm.js.gz +0 -0
- package/dist/dashboard/assets/{LoginPage-DDVIfZGW.js → LoginPage-V5faV9V2.js} +1 -1
- package/dist/dashboard/assets/LoginPage-V5faV9V2.js.gz +0 -0
- package/dist/dashboard/assets/ModelPicker-C7pDb41R.js +1 -0
- package/dist/dashboard/assets/ModelPicker-C7pDb41R.js.gz +0 -0
- package/dist/dashboard/assets/OverviewPage-BDu20Jdx.js +1 -0
- package/dist/dashboard/assets/OverviewPage-BDu20Jdx.js.gz +0 -0
- package/dist/dashboard/assets/ProviderDetailPage-Dllr_aFO.js +3 -0
- package/dist/dashboard/assets/ProviderDetailPage-Dllr_aFO.js.gz +0 -0
- package/dist/dashboard/assets/ProvidersPage-uPiGVTTK.js +1 -0
- package/dist/dashboard/assets/ProvidersPage-uPiGVTTK.js.gz +0 -0
- package/dist/dashboard/assets/ProxyPage-BaUkO-4O.js +7 -0
- package/dist/dashboard/assets/ProxyPage-BaUkO-4O.js.gz +0 -0
- package/dist/dashboard/assets/QuotaPage-o0IFAsFb.js +1 -0
- package/dist/dashboard/assets/QuotaPage-o0IFAsFb.js.gz +0 -0
- package/dist/dashboard/assets/SettingsPage-CYWqbXjp.js +1 -0
- package/dist/dashboard/assets/SettingsPage-CYWqbXjp.js.gz +0 -0
- package/dist/dashboard/assets/{SetupPage-D6pmQ86L.js → SetupPage-UB690SE2.js} +1 -1
- package/dist/dashboard/assets/SetupPage-UB690SE2.js.gz +0 -0
- package/dist/dashboard/assets/{SortableList-DWvKiuSl.js → SortableList-BLujdX7N.js} +1 -1
- package/dist/dashboard/assets/SortableList-BLujdX7N.js.gz +0 -0
- package/dist/dashboard/assets/{StudioPage-CmkUrgdj.js → StudioPage-D5C2raAc.js} +3 -3
- package/dist/dashboard/assets/StudioPage-D5C2raAc.js.gz +0 -0
- package/dist/dashboard/assets/UsagePage-8uIcxa1W.js +56 -0
- package/dist/dashboard/assets/UsagePage-8uIcxa1W.js.gz +0 -0
- package/dist/dashboard/assets/api-keys-DQYLQxpW.js +1 -0
- package/dist/dashboard/assets/api-keys-DQYLQxpW.js.gz +0 -0
- package/dist/dashboard/assets/app-CX_Z8cMs.css +2 -0
- package/dist/dashboard/assets/app-CX_Z8cMs.css.gz +0 -0
- package/dist/dashboard/assets/{assert-NHwYq95t.js → assert-C9FD_JwJ.js} +1 -1
- package/dist/dashboard/assets/assert-C9FD_JwJ.js.gz +0 -0
- package/dist/dashboard/assets/{badge-6uzDDvqs.js → badge-CR72dasT.js} +1 -1
- package/dist/dashboard/assets/badge-CR72dasT.js.gz +0 -0
- package/dist/dashboard/assets/{banner-4_WNg2WZ.js → banner-CI9_eSfO.js} +1 -1
- package/dist/dashboard/assets/banner-CI9_eSfO.js.gz +0 -0
- package/dist/dashboard/assets/{card--AtFedAq.js → card-DS7CQEsO.js} +1 -1
- package/dist/dashboard/assets/card-DS7CQEsO.js.gz +0 -0
- package/dist/dashboard/assets/{cli-tools-B3e7dx3N.js → cli-tools-CNqzrfdb.js} +1 -1
- package/dist/dashboard/assets/cli-tools-CNqzrfdb.js.gz +0 -0
- package/dist/dashboard/assets/{clipboard-button-NreUx6c8.js → clipboard-button-611pSsaX.js} +1 -1
- package/dist/dashboard/assets/clipboard-button-611pSsaX.js.gz +0 -0
- package/dist/dashboard/assets/{fingerprint-pattern-BWuHtbW4.js → concurrency-BDF0ok7y.js} +1 -1
- package/dist/dashboard/assets/concurrency-BDF0ok7y.js.gz +0 -0
- package/dist/dashboard/assets/database-Dz9N3zGd.js +1 -0
- package/dist/dashboard/assets/database-Dz9N3zGd.js.gz +0 -0
- package/dist/dashboard/assets/dialog-k37exjNt.js +1 -0
- package/dist/dashboard/assets/dialog-k37exjNt.js.gz +0 -0
- package/dist/dashboard/assets/drawer-CX7N8ucR.js +1 -0
- package/dist/dashboard/assets/drawer-CX7N8ucR.js.gz +0 -0
- package/dist/dashboard/assets/entry-B9Xvtnfj.js +1 -0
- package/dist/dashboard/assets/entry-B9Xvtnfj.js.gz +0 -0
- package/dist/dashboard/assets/entry-CTP8AyiS.js +1 -0
- package/dist/dashboard/assets/entry-CTP8AyiS.js.gz +0 -0
- package/dist/dashboard/assets/entry-DxBHnauy.js +4 -0
- package/dist/dashboard/assets/entry-DxBHnauy.js.gz +0 -0
- package/dist/dashboard/assets/{github-badge-D_eM3IgG.js → github-badge-sltk1qUi.js} +1 -1
- package/dist/dashboard/assets/github-badge-sltk1qUi.js.gz +0 -0
- package/dist/dashboard/assets/{index-BydyKaDz.js → index-CmxZr14F.js} +2 -2
- package/dist/dashboard/assets/index-CmxZr14F.js.gz +0 -0
- package/dist/dashboard/assets/{inline-BcNGF8Gr.js → inline-BhY2sSnO.js} +1 -1
- package/dist/dashboard/assets/inline-BhY2sSnO.js.gz +0 -0
- package/dist/dashboard/assets/{input-BG41et9G.js → input-V1umFiBk.js} +1 -1
- package/dist/dashboard/assets/input-V1umFiBk.js.gz +0 -0
- package/dist/dashboard/assets/layout-BBOYycyS.js +1 -0
- package/dist/dashboard/assets/layout-BBOYycyS.js.gz +0 -0
- package/dist/dashboard/assets/{list-row-Gh_W4OR_.js → list-row-1zzZYM0B.js} +1 -1
- package/dist/dashboard/assets/list-row-1zzZYM0B.js.gz +0 -0
- package/dist/dashboard/assets/{live-BJGYRBnR.js → live-BivGfuUs.js} +1 -1
- package/dist/dashboard/assets/live-BivGfuUs.js.gz +0 -0
- package/dist/dashboard/assets/model-bans-CmffjLuG.js +1 -0
- package/dist/dashboard/assets/model-bans-CmffjLuG.js.gz +0 -0
- package/dist/dashboard/assets/{mutation-BkoLM5Ba.js → mutation-46P0oWMe.js} +1 -1
- package/dist/dashboard/assets/mutation-46P0oWMe.js.gz +0 -0
- package/dist/dashboard/assets/numbers-DkfMR5Qs.js +2 -0
- package/dist/dashboard/assets/numbers-DkfMR5Qs.js.gz +0 -0
- package/dist/dashboard/assets/{query-keys-D7wX2vM5.js → query-keys-BFhGpwR1.js} +1 -1
- package/dist/dashboard/assets/query-keys-BFhGpwR1.js.gz +0 -0
- package/dist/dashboard/assets/{routing-CcusqXA7.js → routing-CAb4F37R.js} +1 -1
- package/dist/dashboard/assets/routing-CAb4F37R.js.gz +0 -0
- package/dist/dashboard/assets/{select-BO6h8yNv.js → select-BYR_5K2c.js} +2 -2
- package/dist/dashboard/assets/select-BYR_5K2c.js.gz +0 -0
- package/dist/dashboard/assets/{settings-mpmXTgUa.js → settings-DNX9sydi.js} +1 -1
- package/dist/dashboard/assets/settings-DNX9sydi.js.gz +0 -0
- package/dist/dashboard/assets/{stack-DwLmjUFK.js → stack-BHTfXdz_.js} +1 -1
- package/dist/dashboard/assets/stack-BHTfXdz_.js.gz +0 -0
- package/dist/dashboard/assets/{state-DYdQmdAl.js → state-mPDRlQkl.js} +1 -1
- package/dist/dashboard/assets/state-mPDRlQkl.js.gz +0 -0
- package/dist/dashboard/assets/switch-DOmJxg7H.js +1 -0
- package/dist/dashboard/assets/switch-DOmJxg7H.js.gz +0 -0
- package/dist/dashboard/assets/terminal-0_IjPWTk.js +1 -0
- package/dist/dashboard/assets/terminal-0_IjPWTk.js.gz +0 -0
- package/dist/dashboard/assets/tokens-COzario9.js +1 -0
- package/dist/dashboard/assets/tokens-COzario9.js.gz +0 -0
- package/dist/dashboard/assets/use-modal-focus-By7oJoGd.js +1 -0
- package/dist/dashboard/assets/use-modal-focus-By7oJoGd.js.gz +0 -0
- package/dist/dashboard/assets/{useMutation-w4u4CVue.js → useMutation-Dem0lXQj.js} +1 -1
- package/dist/dashboard/assets/useMutation-Dem0lXQj.js.gz +0 -0
- package/dist/dashboard/assets/zap-C4EeVW4q.js +1 -0
- package/dist/dashboard/assets/zap-C4EeVW4q.js.gz +0 -0
- package/dist/dashboard/donation/qris-risun-marke.png +0 -0
- package/dist/dashboard/index.html +1 -1
- package/dist/dashboard/index.html.gz +0 -0
- package/dist/dashboard/precompressed-assets.json +57 -57
- package/migrations/README.md +52 -52
- package/migrations/lite-sqlite3/0000_baseline.sql +2 -2
- package/package.json +96 -95
- package/scripts/cli/admin.ts +125 -0
- package/scripts/cli/autostart.ts +237 -0
- package/scripts/cli/{ui/background.ts → background.ts} +1 -1
- package/scripts/cli/{command/console-menu.ts → console-menu.ts} +275 -248
- package/scripts/cli/doctor.ts +37 -0
- package/scripts/cli/{support/env.ts → env.ts} +99 -99
- package/scripts/cli/{gateway/spawn.ts → gateway.ts} +112 -24
- package/scripts/cli/instance.ts +224 -0
- package/scripts/cli/main.ts +88 -18
- package/scripts/cli/{support/platform.ts → platform.ts} +64 -64
- package/scripts/cli/port.ts +100 -0
- package/scripts/cli/{support/services.ts → services.ts} +88 -88
- package/scripts/cli/{command/setup.ts → setup.ts} +35 -34
- package/scripts/cli/{command/start.ts → start.ts} +10 -5
- package/scripts/cli/{support/startup-signal.ts → startup-signal.ts} +23 -23
- package/scripts/cli/tray/index.ts +62 -12
- package/scripts/cli/{command/tray.ts → tray.ts} +208 -208
- package/scripts/cli/update.ts +40 -0
- package/scripts/cli/version.ts +67 -0
- package/scripts/cli/{ui/wordmark.ts → wordmark.ts} +58 -58
- package/src/boot/app.ts +11 -7
- package/src/boot/dependencies.ts +50 -19
- package/src/boot/timeout.ts +2 -13
- package/src/config/cooldowns.ts +186 -138
- package/src/config/network.ts +8 -14
- package/src/config/providers.ts +7 -10
- package/src/config/runtime.ts +76 -76
- package/src/config/security.ts +15 -0
- package/src/config/spec.ts +52 -7
- package/src/config/telemetry.ts +26 -0
- package/src/config/timeouts.ts +0 -6
- package/src/console/admin/model-abuse/contracts.ts +28 -6
- package/src/console/admin/model-abuse/routes.ts +56 -3
- package/src/console/api-keys/bucket-spend.ts +15 -4
- package/src/console/api-keys/contracts.ts +410 -414
- package/src/console/api-keys/routes.ts +881 -880
- package/src/console/auth/credentials.ts +3 -3
- package/src/console/auth/setup-service.ts +2 -2
- package/src/console/backup/contracts.ts +15 -2
- package/src/console/backup/export-endpoint.ts +6 -2
- package/src/console/backup/nine-router.ts +2 -5
- package/src/console/backup/service.ts +4 -0
- package/src/console/backup/store.ts +192 -29
- package/src/console/backup/validate.ts +3 -45
- package/src/console/cli-tools/fs-ops.ts +487 -487
- package/src/console/console-router-guards.ts +1 -1
- package/src/console/console-router.ts +8 -0
- package/src/console/data-management/contracts.ts +100 -0
- package/src/console/data-management/operations.ts +234 -0
- package/src/console/data-management/routes.ts +103 -0
- package/src/console/domain-registration.ts +45 -1
- package/src/console/observability/contracts.ts +524 -485
- package/src/console/observability/live.ts +22 -0
- package/src/console/observability/store.ts +1138 -1018
- package/src/console/observability/usage-dimensions.ts +4 -4
- package/src/console/providers/catalog/catalog-projections.ts +6 -7
- package/src/console/providers/catalog/contracts.ts +6 -0
- package/src/console/providers/catalog/model-operations.ts +5 -2
- package/src/console/providers/catalog/provider-operations.ts +24 -4
- package/src/console/providers/catalog/public-model-store.ts +8 -11
- package/src/console/providers/catalog/routes.ts +7 -0
- package/src/console/providers/catalog/store.ts +1553 -1532
- package/src/console/providers/oauth/routes.ts +8 -12
- package/src/console/quota/account-quota-shared.ts +52 -39
- package/src/console/quota/account-quota-tenant.ts +6 -6
- package/src/console/quota/quota-view.ts +1 -1
- package/src/console/quota/refresh.ts +441 -429
- package/src/console/remote-support/contracts.ts +61 -0
- package/src/console/remote-support/routes.ts +65 -0
- package/src/console/remote-support/service.ts +207 -0
- package/src/console/response-compression.ts +0 -3
- package/src/console/routing/pools/import-parse.ts +206 -0
- package/src/console/routing/pools/operations.ts +543 -501
- package/src/console/routing/pools/probe-result.ts +3 -2
- package/src/console/routing/pools/probe-verdict.ts +132 -132
- package/src/console/routing/pools/routes.ts +181 -164
- package/src/console/routing/pools/store.ts +8 -8
- package/src/console/settings/contracts.ts +95 -2
- package/src/console/settings/store.ts +81 -3
- package/src/console/shared/cursor.ts +8 -8
- package/src/console/shared/errors.ts +1 -3
- package/src/console/updates/routes.ts +16 -0
- package/src/main.ts +10 -0
- package/src/network/http2-bidi.ts +2 -4
- package/src/network/pool/agent.ts +478 -478
- package/src/network/pool/selector.ts +5 -4
- package/src/network/retry.ts +1 -2
- package/src/network/ssrf.ts +5 -4
- package/src/observability/event-compaction.ts +317 -0
- package/src/observability/event-day-index.ts +440 -0
- package/src/observability/event-log.ts +155 -6
- package/src/observability/logger.ts +6 -6
- package/src/observability/payload-capture.ts +158 -33
- package/src/observability/payload-store.ts +82 -61
- package/src/observability/redaction.ts +5 -6
- package/src/observability/request-notify.ts +43 -0
- package/src/observability/telemetry-status.ts +8 -9
- package/src/observability/token-speed.ts +5 -4
- package/src/persistence/db-mode.ts +7 -7
- package/src/persistence/health-event-flush.ts +36 -0
- package/src/persistence/readiness.ts +1 -1
- package/src/persistence/schema.ts +75 -29
- package/src/persistence/shared-cache.ts +1 -2
- package/src/persistence/stores/api-key-store.ts +18 -6
- package/src/persistence/stores/share-store.ts +4 -3
- package/src/persistence/stores/telemetry-store.ts +33 -11
- package/src/protocol/canonical-model.ts +11 -3
- package/src/protocol/codecs.ts +139 -6
- package/src/protocol/gateway-error.ts +6 -4
- package/src/protocol/image-clamp.ts +19 -1
- package/src/protocol/registry.ts +8 -0
- package/src/protocol/request/chat.ts +21 -1
- package/src/protocol/request/codex.ts +10 -1
- package/src/protocol/request/gemini.ts +9 -2
- package/src/protocol/request/messages.ts +18 -7
- package/src/protocol/request/responses.ts +9 -1
- package/src/protocol/response/chat.ts +34 -4
- package/src/protocol/response/codex.ts +7 -4
- package/src/protocol/response/dsml.ts +140 -28
- package/src/protocol/response/responses.ts +11 -5
- package/src/protocol/stream-error-frames.ts +4 -5
- package/src/providers/adapters/compatible-adapter.ts +3 -0
- package/src/providers/authentication/jwt-validator.ts +9 -10
- package/src/providers/authentication/oauth-refresh-service.ts +9 -11
- package/src/providers/bundled-discovery-providers.ts +122 -119
- package/src/providers/configured-openai-providers.ts +4 -4
- package/src/providers/default-registry.ts +5 -4
- package/src/providers/discovery/catalog/catalog-blob.ts +18 -18
- package/src/providers/discovery/catalog/catalog-format.ts +3 -6
- package/src/providers/discovery/models-dev-catalog.ts +354 -354
- package/src/providers/discovery/openai-model-discovery.ts +5 -0
- package/src/providers/discovery/openrouter-free-roster.ts +15 -17
- package/src/providers/discovery/probe-phases.ts +4 -4
- package/src/providers/discovery/probe-wire.ts +6 -13
- package/src/providers/discovery/probing-service.ts +1117 -1045
- package/src/providers/integrations/alibabacp/alibabacp.ts +1 -1
- package/src/providers/integrations/antigravity/antigravity-quota.ts +5 -7
- package/src/providers/integrations/buddy/buddy-chat-shared.ts +23 -2
- package/src/providers/integrations/buddy/buddy-checkin.ts +1 -4
- package/src/providers/integrations/buddy/buddy-cli-headers.ts +23 -6
- package/src/providers/integrations/buddy/buddy-conversation-headers.ts +0 -1
- package/src/providers/integrations/buddy/buddy-identity.ts +52 -0
- package/src/providers/integrations/buddy/buddy-media.ts +2 -2
- package/src/providers/integrations/buddy/buddy-usage-store.ts +3 -4
- package/src/providers/integrations/buddy/codebuddy/quota.ts +2 -13
- package/src/providers/integrations/buddy/codebuddy/shared.ts +118 -115
- package/src/providers/integrations/buddy/workbuddy/quota.ts +0 -11
- package/src/providers/integrations/buddy/workbuddy/shared.ts +5 -2
- package/src/providers/integrations/claude/claude.ts +608 -608
- package/src/providers/integrations/cline/cline-quota.ts +27 -20
- package/src/providers/integrations/cline/cline.ts +537 -539
- package/src/providers/integrations/codex/codex-headers.ts +25 -39
- package/src/providers/integrations/codex/codex-oauth.ts +1 -1
- package/src/providers/integrations/codex/codex.ts +2 -29
- package/src/providers/integrations/commandcode/commandcode.ts +674 -675
- package/src/providers/integrations/commandcode/discovery.ts +117 -116
- package/src/providers/integrations/cursor/cursor-auth.ts +418 -418
- package/src/providers/integrations/cursor/cursor-quota.ts +309 -0
- package/src/providers/integrations/cursor/cursor-request.ts +616 -471
- package/src/providers/integrations/cursor/cursor-session-manager.ts +319 -281
- package/src/providers/integrations/cursor/cursor.ts +1333 -960
- package/src/providers/integrations/cursor/protocol/messages.ts +25 -0
- package/src/providers/integrations/factory/factory-models.ts +3 -5
- package/src/providers/integrations/factory/factory.ts +8 -1
- package/src/providers/integrations/gemini.ts +6 -1
- package/src/providers/integrations/grok/grok-install-id.ts +2 -3
- package/src/providers/integrations/grok/grok-quota.ts +279 -177
- package/src/providers/integrations/grok/grok-ratelimit-store.ts +73 -0
- package/src/providers/integrations/grok/grok-ratelimit.ts +218 -0
- package/src/providers/integrations/grok/grok.ts +6 -7
- package/src/providers/integrations/hermes.ts +14 -18
- package/src/providers/integrations/kiro/kiro-discovery.ts +1 -1
- package/src/providers/integrations/kiro/kiro.ts +1 -1
- package/src/providers/integrations/mistral.ts +6 -6
- package/src/providers/integrations/opencode/fingerprint.ts +3 -3
- package/src/providers/integrations/opencode/provider.ts +19 -20
- package/src/providers/integrations/qoder/qoder-catalog.ts +450 -450
- package/src/providers/integrations/qoder/qoder-oauth.ts +388 -310
- package/src/providers/integrations/qoder/qoder-quota.ts +192 -0
- package/src/providers/integrations/qoder/qoder.ts +884 -865
- package/src/providers/integrations/shared/connect.ts +6 -6
- package/src/providers/integrations/trae/trae-catalog.ts +1 -2
- package/src/providers/integrations/trae/trae-quota.ts +384 -0
- package/src/providers/integrations/trae/trae.ts +2 -2
- package/src/providers/integrations/zcode/oauth.ts +6 -1
- package/src/providers/operations/account-health-service.ts +75 -27
- package/src/providers/operations/account-reset-service.ts +1 -1
- package/src/providers/operations/client-versions.ts +6 -6
- package/src/providers/operations/custom-cli-headers.ts +3 -4
- package/src/providers/operations/provider-catalog-seeder.ts +95 -95
- package/src/providers/operations/provider-catalog-service.ts +3 -4
- package/src/providers/provider-contracts.ts +7 -0
- package/src/providers/provider-metadata.ts +10 -8
- package/src/providers/provider-registry.ts +6 -7
- package/src/providers/provider-usage.ts +1 -1
- package/src/providers/quota/quota-support.ts +8 -0
- package/src/security/abuse.ts +15 -10
- package/src/security/access-control.ts +144 -146
- package/src/security/admission/contracts.ts +11 -0
- package/src/security/admission/in-memory-store.ts +4 -3
- package/src/security/admission/lease-sweep.ts +6 -6
- package/src/security/admission/reasons.ts +5 -1
- package/src/security/admission/redis-store.ts +211 -210
- package/src/security/admission/service.ts +4 -0
- package/src/security/api-key-auth.ts +27 -17
- package/src/security/ip-boundary.ts +155 -15
- package/src/security/model-abuse.ts +366 -94
- package/src/security/outbound-headers.ts +3 -4
- package/src/shared/sleep.ts +4 -0
- package/src/transport/dispatch/attempt-finalize.ts +47 -5
- package/src/transport/dispatch/attempt-loop.ts +531 -530
- package/src/transport/dispatch/generation.ts +6 -0
- package/src/transport/dispatch/proxy-request.ts +24 -1
- package/src/transport/dispatch/ratelimit-capture.ts +37 -0
- package/src/transport/dispatch/stream-bridge.ts +1 -3
- package/src/transport/dispatch/streaming/finalize.ts +1 -1
- package/src/transport/dispatch/streaming/stream.ts +19 -6
- package/src/transport/dispatch/upstream.ts +9 -0
- package/src/transport/failure-policy.ts +162 -8
- package/src/transport/guards/auth-guard.ts +7 -4
- package/src/transport/guards/concurrency-guard.ts +0 -5
- package/src/transport/guards/error-lifecycle.ts +13 -6
- package/src/transport/guards/pipeline.ts +15 -3
- package/src/transport/guards/request-context.ts +8 -2
- package/src/transport/request/inflight.ts +5 -6
- package/src/transport/request/preparer.ts +9 -8
- package/src/transport/request/state.ts +21 -8
- package/src/transport/request/tenant-preferences.ts +60 -0
- package/src/transport/routing/admission.ts +4 -0
- package/src/transport/routing/route-catalog.ts +65 -10
- package/src/transport/routing/route-model.ts +46 -14
- package/src/transport/routing/router.ts +21 -14
- package/src/transport/surface/completion.ts +2 -3
- package/src/transport/surface/dialects.ts +5 -6
- package/src/transport/surface/responses/encode.ts +38 -0
- package/src/transport/surface/responses/parse.ts +4 -0
- package/src/transport/translation/capabilities.ts +2 -3
- package/src/transport/translation/quirks.ts +3 -3
- package/src/workers/buddy-growth-tasks.ts +5 -10
- package/src/workers/daily-checkin.ts +17 -23
- package/src/workers/quota-refresh-sweep.ts +26 -26
- package/dist/dashboard/assets/BannedPage-hfCzi-Ux.js.gz +0 -0
- package/dist/dashboard/assets/CliToolDetailPage-DLGqYEwI.js +0 -1
- package/dist/dashboard/assets/CliToolDetailPage-DLGqYEwI.js.gz +0 -0
- package/dist/dashboard/assets/CliToolsPage-DEDyKBlC.js.gz +0 -0
- package/dist/dashboard/assets/CombosPage-CfuYiQ8Z.js +0 -1
- package/dist/dashboard/assets/CombosPage-CfuYiQ8Z.js.gz +0 -0
- package/dist/dashboard/assets/ConsoleLogPage-bsLuZmYH.js.gz +0 -0
- package/dist/dashboard/assets/CustomizationPage-_UWhwziJ.js.gz +0 -0
- package/dist/dashboard/assets/HealthEventsModal-Cjfc5WFS.js +0 -1
- package/dist/dashboard/assets/HealthEventsModal-Cjfc5WFS.js.gz +0 -0
- package/dist/dashboard/assets/LoginPage-DDVIfZGW.js.gz +0 -0
- package/dist/dashboard/assets/ModelPicker-CWkTUtpX.js +0 -1
- package/dist/dashboard/assets/ModelPicker-CWkTUtpX.js.gz +0 -0
- package/dist/dashboard/assets/OverviewPage-B53YYMng.js +0 -1
- package/dist/dashboard/assets/OverviewPage-B53YYMng.js.gz +0 -0
- package/dist/dashboard/assets/ProviderDetailPage-B0KaAQf0.js +0 -3
- package/dist/dashboard/assets/ProviderDetailPage-B0KaAQf0.js.gz +0 -0
- package/dist/dashboard/assets/ProvidersPage-Dx31DgIO.js +0 -1
- package/dist/dashboard/assets/ProvidersPage-Dx31DgIO.js.gz +0 -0
- package/dist/dashboard/assets/ProxyPage-BpnR4kXT.js +0 -7
- package/dist/dashboard/assets/ProxyPage-BpnR4kXT.js.gz +0 -0
- package/dist/dashboard/assets/QuotaPage-BpOQCnun.js +0 -1
- package/dist/dashboard/assets/QuotaPage-BpOQCnun.js.gz +0 -0
- package/dist/dashboard/assets/SettingsPage-DV1eo2J_.js +0 -1
- package/dist/dashboard/assets/SettingsPage-DV1eo2J_.js.gz +0 -0
- package/dist/dashboard/assets/SetupPage-D6pmQ86L.js.gz +0 -0
- package/dist/dashboard/assets/SortableList-DWvKiuSl.js.gz +0 -0
- package/dist/dashboard/assets/StudioPage-CmkUrgdj.js.gz +0 -0
- package/dist/dashboard/assets/UsagePage-B47uOydr.js +0 -56
- package/dist/dashboard/assets/UsagePage-B47uOydr.js.gz +0 -0
- package/dist/dashboard/assets/api-keys-uNywYlfv.js +0 -1
- package/dist/dashboard/assets/api-keys-uNywYlfv.js.gz +0 -0
- package/dist/dashboard/assets/app-DxivpfYG.css +0 -2
- package/dist/dashboard/assets/app-DxivpfYG.css.gz +0 -0
- package/dist/dashboard/assets/assert-NHwYq95t.js.gz +0 -0
- package/dist/dashboard/assets/badge-6uzDDvqs.js.gz +0 -0
- package/dist/dashboard/assets/banner-4_WNg2WZ.js.gz +0 -0
- package/dist/dashboard/assets/card--AtFedAq.js.gz +0 -0
- package/dist/dashboard/assets/cli-tools-B3e7dx3N.js.gz +0 -0
- package/dist/dashboard/assets/clipboard-button-NreUx6c8.js.gz +0 -0
- package/dist/dashboard/assets/dialog-BtpsJU98.js +0 -1
- package/dist/dashboard/assets/dialog-BtpsJU98.js.gz +0 -0
- package/dist/dashboard/assets/download-A0SW9OCW.js +0 -1
- package/dist/dashboard/assets/download-A0SW9OCW.js.gz +0 -0
- package/dist/dashboard/assets/drawer-WwwtzihF.js +0 -1
- package/dist/dashboard/assets/drawer-WwwtzihF.js.gz +0 -0
- package/dist/dashboard/assets/entry-CkryrIea.js +0 -4
- package/dist/dashboard/assets/entry-CkryrIea.js.gz +0 -0
- package/dist/dashboard/assets/entry-TJIye_jT.js +0 -1
- package/dist/dashboard/assets/entry-TJIye_jT.js.gz +0 -0
- package/dist/dashboard/assets/entry-jy9_4-Ke.js +0 -1
- package/dist/dashboard/assets/entry-jy9_4-Ke.js.gz +0 -0
- package/dist/dashboard/assets/fingerprint-pattern-BWuHtbW4.js.gz +0 -0
- package/dist/dashboard/assets/github-badge-D_eM3IgG.js.gz +0 -0
- package/dist/dashboard/assets/index-BydyKaDz.js.gz +0 -0
- package/dist/dashboard/assets/inline-BcNGF8Gr.js.gz +0 -0
- package/dist/dashboard/assets/input-BG41et9G.js.gz +0 -0
- package/dist/dashboard/assets/layout-C8mer_Ak.js +0 -1
- package/dist/dashboard/assets/layout-C8mer_Ak.js.gz +0 -0
- package/dist/dashboard/assets/list-row-Gh_W4OR_.js.gz +0 -0
- package/dist/dashboard/assets/live-BJGYRBnR.js.gz +0 -0
- package/dist/dashboard/assets/model-bans-DeVLDA7I.js +0 -1
- package/dist/dashboard/assets/model-bans-DeVLDA7I.js.gz +0 -0
- package/dist/dashboard/assets/mutation-BkoLM5Ba.js.gz +0 -0
- package/dist/dashboard/assets/numbers-Bs4LOOuf.js +0 -2
- package/dist/dashboard/assets/numbers-Bs4LOOuf.js.gz +0 -0
- package/dist/dashboard/assets/query-keys-D7wX2vM5.js.gz +0 -0
- package/dist/dashboard/assets/routing-CcusqXA7.js.gz +0 -0
- package/dist/dashboard/assets/select-BO6h8yNv.js.gz +0 -0
- package/dist/dashboard/assets/settings-mpmXTgUa.js.gz +0 -0
- package/dist/dashboard/assets/sparkles-BRaRUxAU.js +0 -1
- package/dist/dashboard/assets/sparkles-BRaRUxAU.js.gz +0 -0
- package/dist/dashboard/assets/stack-DwLmjUFK.js.gz +0 -0
- package/dist/dashboard/assets/state-DYdQmdAl.js.gz +0 -0
- package/dist/dashboard/assets/switch-CidSfbG7.js +0 -1
- package/dist/dashboard/assets/switch-CidSfbG7.js.gz +0 -0
- package/dist/dashboard/assets/terminal-Bd4ee8TA.js +0 -1
- package/dist/dashboard/assets/terminal-Bd4ee8TA.js.gz +0 -0
- package/dist/dashboard/assets/tokens-DDxMbSRL.js +0 -1
- package/dist/dashboard/assets/tokens-DDxMbSRL.js.gz +0 -0
- package/dist/dashboard/assets/use-presence-BSZEBx7U.js +0 -1
- package/dist/dashboard/assets/use-presence-BSZEBx7U.js.gz +0 -0
- package/dist/dashboard/assets/useMutation-w4u4CVue.js.gz +0 -0
- package/scripts/cli/command/admin.ts +0 -65
- package/scripts/cli/command/doctor.ts +0 -257
- package/scripts/cli/command/update.ts +0 -23
- package/scripts/cli/gateway/drain-token.ts +0 -78
- package/scripts/cli/gateway/instance-lock.ts +0 -105
- package/scripts/cli/gateway/stop.ts +0 -51
- package/scripts/cli/support/bun.ts +0 -27
- package/scripts/cli/support/version.ts +0 -37
- /package/dist/dashboard/assets/{app-CafWtVXA.js → app-BIt446yE.js} +0 -0
- /package/dist/dashboard/assets/{app-CafWtVXA.js.gz → app-BIt446yE.js.gz} +0 -0
- /package/scripts/cli/{ui/prompt.ts → prompt.ts} +0 -0
|
@@ -1,429 +1,441 @@
|
|
|
1
|
-
// Shared account-quota refresh: one home for "resolve credential → fetch quota →
|
|
2
|
-
// cache under the right lens → stamp the account row".
|
|
3
|
-
//
|
|
4
|
-
// Four callers need exactly this sequence — the console's single/bulk refresh
|
|
5
|
-
// routes, the overview's background fill, the periodic sweep worker, and the
|
|
6
|
-
// global-admin refresh — so it lives here rather than inside the route module.
|
|
7
|
-
// A drift between them would mean the page and the worker disagree about what
|
|
8
|
-
// "refreshed" means.
|
|
9
|
-
import { and, eq, inArray, isNull, ne, or, sql } from "drizzle-orm";
|
|
10
|
-
import type { CartethyiaDatabase } from "../../persistence/db-handle";
|
|
11
|
-
import { providerAccounts } from "../../persistence/schema";
|
|
12
|
-
import type { RedisClient } from "../../persistence/redis";
|
|
13
|
-
import { log } from "../../observability/logger";
|
|
14
|
-
import { fetchProviderQuota } from "../../providers/quota/quota-support";
|
|
15
|
-
import { type FetchLike, type ProviderQuotaResult } from "../../providers/quota/quota-contracts";
|
|
16
|
-
import type { ProviderId } from "../../providers/provider-contracts";
|
|
17
|
-
import type { ProviderRegistry } from "../../providers/provider-registry";
|
|
18
|
-
import { GLOBAL_QUOTA_LENS, setCachedQuota } from "./cache";
|
|
19
|
-
import { record } from "../../providers/authentication/oauth-flow-store";
|
|
20
|
-
|
|
21
|
-
/** Why an account quota refresh failed; `null` means the fetch succeeded. */
|
|
22
|
-
export type QuotaRefreshFailureCode = "missing_credential" | "quota_fetch_failed";
|
|
23
|
-
|
|
24
|
-
export interface QuotaRefreshOutcome {
|
|
25
|
-
readonly quota: ProviderQuotaResult;
|
|
26
|
-
readonly failure: QuotaRefreshFailureCode | null;
|
|
27
|
-
/** Display identity the quota surface reported, when it reported one. */
|
|
28
|
-
readonly accountLabel?: string;
|
|
29
|
-
}
|
|
30
|
-
|
|
31
|
-
/** Abort budget for one upstream quota fetch. */
|
|
32
|
-
export const QUOTA_REFRESH_TIMEOUT_MS = 15_000;
|
|
33
|
-
|
|
34
|
-
/**
|
|
35
|
-
* Consecutive quota refusals before an account is parked. One refusal can be
|
|
36
|
-
* the provider's own hiccup; three in a row is the quota surface not coming
|
|
37
|
-
* back, and the account should stop being dialed until the cooldown elapses.
|
|
38
|
-
*/
|
|
39
|
-
export const QUOTA_FAILURE_PARK_THRESHOLD = 3;
|
|
40
|
-
/** How long a quota-parked account stays out of rotation. */
|
|
41
|
-
export const QUOTA_FAILURE_COOLDOWN_MS = 6 * 60 * 60 * 1000;
|
|
42
|
-
|
|
43
|
-
export interface QuotaRefreshDeps {
|
|
44
|
-
readonly db: CartethyiaDatabase;
|
|
45
|
-
readonly redis: RedisClient | undefined;
|
|
46
|
-
readonly providerRegistry: ProviderRegistry;
|
|
47
|
-
/**
|
|
48
|
-
* Resolves a stored credential through the refresh-aware path (OAuth tokens
|
|
49
|
-
* are refreshed before use), returning `""` when the account has none.
|
|
50
|
-
* API-key accounts are wrapped in the provider's key envelope when one
|
|
51
|
-
* exists, so a key-only credential never reaches an OAuth-only surface.
|
|
52
|
-
*/
|
|
53
|
-
readonly resolveCredential: (providerId: ProviderId, accountId: string) => Promise<string>;
|
|
54
|
-
/** Marks a stored api_key credential with the upstream-distinguishable envelope. */
|
|
55
|
-
readonly markApiKeyCredential?: (providerId: string, credential: string) => string;
|
|
56
|
-
/** Invalidates the route snapshot when a stamped figure may exclude an account. */
|
|
57
|
-
readonly snapshotInvalidator?: { invalidate(): unknown } | undefined;
|
|
58
|
-
}
|
|
59
|
-
|
|
60
|
-
/** Bound on concurrently tracked single-flight quota refreshes; keys are account ids, so the real bound is the account count. */
|
|
61
|
-
const MAX_INFLIGHT = 1_000;
|
|
62
|
-
|
|
63
|
-
/**
|
|
64
|
-
* In-flight promise deduplication map for account quota refreshes. Keyed by
|
|
65
|
-
* accountId so concurrent callers (a manual refresh plus the sweep, or two
|
|
66
|
-
* rapid clicks) share one upstream fetch instead of hammering the provider.
|
|
67
|
-
*/
|
|
68
|
-
export const inFlightAccountQuotaRefreshes = new Map<string, Promise<QuotaRefreshOutcome>>();
|
|
69
|
-
|
|
70
|
-
/**
|
|
71
|
-
* Binds an abort signal to outbound quota fetches while preserving Bun's
|
|
72
|
-
* `fetch.preconnect` static, so the wrapper satisfies `FetchLike`.
|
|
73
|
-
*/
|
|
74
|
-
export function signalFetch(signal: AbortSignal): typeof fetch {
|
|
75
|
-
const wrapped = (
|
|
76
|
-
url: Parameters<typeof fetch>[0],
|
|
77
|
-
init?: Parameters<typeof fetch>[1],
|
|
78
|
-
): Promise<Response> => fetch(url, { ...init, signal });
|
|
79
|
-
return Object.assign(wrapped, {
|
|
80
|
-
preconnect: (...args: Parameters<typeof fetch.preconnect>): void =>
|
|
81
|
-
fetch.preconnect(...args),
|
|
82
|
-
});
|
|
83
|
-
}
|
|
84
|
-
|
|
85
|
-
/** Combines the caller's abort signal with the fetch budget. */
|
|
86
|
-
export function timeoutSignal(requestSignal: AbortSignal, ms: number): AbortSignal {
|
|
87
|
-
return AbortSignal.any([requestSignal, AbortSignal.timeout(ms)]);
|
|
88
|
-
}
|
|
89
|
-
|
|
90
|
-
/**
|
|
91
|
-
* Records one quota-refresh outcome on the account row so the dashboard's
|
|
92
|
-
* "Last check" reflects what the fetch actually found. Success stamps
|
|
93
|
-
* `lastSuccessAt` and clears the error fields; failure stamps the error
|
|
94
|
-
* fields and keeps the last success.
|
|
95
|
-
*
|
|
96
|
-
* A *counted* failure also advances `consecutiveFailures`, and once an account
|
|
97
|
-
* has `QUOTA_FAILURE_PARK_THRESHOLD` of them in a row it is parked — this is
|
|
98
|
-
* the one place a quota result may move `status`, because a quota surface that
|
|
99
|
-
* refuses three times is an account problem rather than a transient one. A
|
|
100
|
-
* transient timeout is annotated but never counted, so it cannot park
|
|
101
|
-
* (see `isTransientTimeout`).
|
|
102
|
-
*
|
|
103
|
-
* The error fields are written only while the account is `active`, because for
|
|
104
|
-
* a parked account (cooldown/disabled) those fields carry the reason
|
|
105
|
-
* the health machine parked it. A periodic quota sweep runs against every OAuth
|
|
106
|
-
* account, and a failing quota endpoint reported "Invalid or expired
|
|
107
|
-
* credentials" for an entire provider — overwriting the `auth_invalidated`
|
|
108
|
-
* reason a dispatch 401 had just recorded with `quota_fetch_failed`, so the
|
|
109
|
-
* dashboard blamed the wrong thing. A check cannot reclassify a parked account;
|
|
110
|
-
* its own failure is still visible through the quota cache the page renders.
|
|
111
|
-
*/
|
|
112
|
-
/**
|
|
113
|
-
* A failure that says nothing about the account.
|
|
114
|
-
*
|
|
115
|
-
* A quota fetch that ran out of its own abort budget timed out against *our*
|
|
116
|
-
* egress, not against the credential: the upstream may never have seen the
|
|
117
|
-
* request. Counting it like a refusal meant three slow passes parked a healthy
|
|
118
|
-
* account for six hours — and the buddy family is the worst case, because its
|
|
119
|
-
* quota and check-in legs share one pass and rotate egress per account, so a
|
|
120
|
-
* single slow pool produced three timeouts in a row.
|
|
121
|
-
*
|
|
122
|
-
* Matched on the message the timeout paths produce. Deliberately narrow: a
|
|
123
|
-
* refusal that merely mentions a timeout is still a refusal.
|
|
124
|
-
*/
|
|
125
|
-
function isTransientTimeout(error: string | null | undefined): boolean {
|
|
126
|
-
if (!error) return false;
|
|
127
|
-
const lower = error.toLowerCase();
|
|
128
|
-
return (
|
|
129
|
-
lower.includes("the operation timed out") ||
|
|
130
|
-
lower.includes("operation timed out") ||
|
|
131
|
-
lower.includes("timeout") && lower.includes("abort")
|
|
132
|
-
);
|
|
133
|
-
}
|
|
134
|
-
|
|
135
|
-
export async function recordAccountCheck(
|
|
136
|
-
db: CartethyiaDatabase,
|
|
137
|
-
accountId: string,
|
|
138
|
-
outcome: { ok: boolean; error?: string | null; category?: string | null },
|
|
139
|
-
): Promise<void> {
|
|
140
|
-
const activeOnly = and(
|
|
141
|
-
eq(providerAccounts.id, accountId),
|
|
142
|
-
eq(providerAccounts.status, "active"),
|
|
143
|
-
);
|
|
144
|
-
if (outcome.ok) {
|
|
145
|
-
await db
|
|
146
|
-
.update(providerAccounts)
|
|
147
|
-
.set({
|
|
148
|
-
lastSuccessAt: new Date(),
|
|
149
|
-
lastError: null,
|
|
150
|
-
lastErrorCategory: null,
|
|
151
|
-
lastErrorAt: null,
|
|
152
|
-
consecutiveFailures: 0,
|
|
153
|
-
})
|
|
154
|
-
.where(activeOnly);
|
|
155
|
-
return;
|
|
156
|
-
}
|
|
157
|
-
const transient = isTransientTimeout(outcome.error);
|
|
158
|
-
// Count the failure in the same statement — unless it is a transient
|
|
159
|
-
// timeout (see below). A quota endpoint that keeps refusing is an account
|
|
160
|
-
// problem, and the row has to say so: the dashboard reads
|
|
161
|
-
// `consecutiveFailures` for the pill, and a check that only wrote the error
|
|
162
|
-
// text left the account looking healthy while every refresh failed.
|
|
163
|
-
//
|
|
164
|
-
// A timeout is annotated but NOT counted: it is evidence about the network,
|
|
165
|
-
// not the credential, and letting it advance the streak is what parked
|
|
166
|
-
// healthy accounts for six hours over three slow passes.
|
|
167
|
-
const failing = await db
|
|
168
|
-
.update(providerAccounts)
|
|
169
|
-
.set({
|
|
170
|
-
lastError: (outcome.error ?? "Account check failed").slice(0, 500),
|
|
171
|
-
lastErrorCategory: outcome.category ?? "check_failed",
|
|
172
|
-
lastErrorAt: new Date(),
|
|
173
|
-
...(transient ? {} : { consecutiveFailures: sql`${providerAccounts.consecutiveFailures} + 1` }),
|
|
174
|
-
})
|
|
175
|
-
.where(activeOnly)
|
|
176
|
-
.returning({ consecutiveFailures: providerAccounts.consecutiveFailures });
|
|
177
|
-
if (transient) return;
|
|
178
|
-
const failures = failing[0]?.consecutiveFailures ?? 0;
|
|
179
|
-
// Past the threshold the account is parked, not just annotated. Three
|
|
180
|
-
// consecutive refusals is a quota surface that is not coming back on its
|
|
181
|
-
// own, and leaving the account in rotation only repeats the failure every
|
|
182
|
-
// pass. The cooldown clears itself; a success before it elapses resets the
|
|
183
|
-
// count above.
|
|
184
|
-
if (failures >= QUOTA_FAILURE_PARK_THRESHOLD) {
|
|
185
|
-
await db
|
|
186
|
-
.update(providerAccounts)
|
|
187
|
-
.set({
|
|
188
|
-
status: "cooldown",
|
|
189
|
-
cooldownUntil: new Date(Date.now() + QUOTA_FAILURE_COOLDOWN_MS),
|
|
190
|
-
})
|
|
191
|
-
.where(activeOnly);
|
|
192
|
-
}
|
|
193
|
-
}
|
|
194
|
-
|
|
195
|
-
/** One account that a quota refresh can be attempted for. */
|
|
196
|
-
export interface QuotaRefreshTarget {
|
|
197
|
-
readonly accountId: string;
|
|
198
|
-
readonly providerId: string;
|
|
199
|
-
/** Owning tenant, or `null` for a global/shared account. */
|
|
200
|
-
readonly tenantId: string | null;
|
|
201
|
-
/**
|
|
202
|
-
* Stored credential kind. Key-only credentials never reach an OAuth-only
|
|
203
|
-
* quota surface: the collector routes `api_key` to the `/models`
|
|
204
|
-
* connectivity probe instead, while OAuth keeps `users/me`.
|
|
205
|
-
*/
|
|
206
|
-
readonly credentialKind?: "api_key" | "oauth" | "none" | null;
|
|
207
|
-
/**
|
|
208
|
-
* Human identity for logs. OAuth rows carry `Name <email@host>` because the
|
|
209
|
-
* account label is seeded from the token's own identity claims, so sweep
|
|
210
|
-
* lines can name the account instead of dumping a uuid.
|
|
211
|
-
*/
|
|
212
|
-
readonly label?: string | null;
|
|
213
|
-
/**
|
|
214
|
-
* Non-secret per-account auth configuration. A collector whose billing
|
|
215
|
-
* surface is region- or profile-scoped needs it; most ignore it.
|
|
216
|
-
*/
|
|
217
|
-
readonly authState?: unknown;
|
|
218
|
-
}
|
|
219
|
-
|
|
220
|
-
/** Cache lens an account's quota belongs under. */
|
|
221
|
-
export function targetLens(target: QuotaRefreshTarget): string {
|
|
222
|
-
return target.tenantId ?? GLOBAL_QUOTA_LENS;
|
|
223
|
-
}
|
|
224
|
-
|
|
225
|
-
const TARGET_COLUMNS = {
|
|
226
|
-
accountId: providerAccounts.id,
|
|
227
|
-
providerId: providerAccounts.providerId,
|
|
228
|
-
tenantId: providerAccounts.tenantId,
|
|
229
|
-
credentialKind: providerAccounts.credentialKind,
|
|
230
|
-
label: providerAccounts.label,
|
|
231
|
-
authState: providerAccounts.authState,
|
|
232
|
-
};
|
|
233
|
-
|
|
234
|
-
/**
|
|
235
|
-
* Credential-bearing accounts eligible for the periodic quota sweep.
|
|
236
|
-
*
|
|
237
|
-
* OAuth accounts use the refresh-aware resolver; api_key accounts (including
|
|
238
|
-
* pasted JWTs) use the stored bearer exactly as issued. Both need quota refresh
|
|
239
|
-
* and Buddy daily check-in, while `none` has no credential to probe.
|
|
240
|
-
*
|
|
241
|
-
* An account already marked `auth_invalidated` is excluded: its credential was
|
|
242
|
-
* rejected outright and does not repair itself, so every sweep is a guaranteed
|
|
243
|
-
* 401 that only re-confirms the row and floods the log — the operator needs a
|
|
244
|
-
* re-login, not another probe. It returns to the sweep once it is re-authed or
|
|
245
|
-
* recovered, which is when its credential is expected to work again.
|
|
246
|
-
*
|
|
247
|
-
* Every other credential-bearing account is swept, `disabled` included. A
|
|
248
|
-
* disabled account is not necessarily revoked — it may have been parked for a
|
|
249
|
-
* reason unrelated to its credential — and skipping it would let a credential
|
|
250
|
-
* die silently while the row still reads healthy.
|
|
251
|
-
*/
|
|
252
|
-
export async function listQuotaRefreshTargets(
|
|
253
|
-
db: CartethyiaDatabase,
|
|
254
|
-
): Promise<readonly QuotaRefreshTarget[]> {
|
|
255
|
-
return db
|
|
256
|
-
.select(TARGET_COLUMNS)
|
|
257
|
-
.from(providerAccounts)
|
|
258
|
-
.where(
|
|
259
|
-
and(
|
|
260
|
-
inArray(providerAccounts.credentialKind, ["oauth", "api_key"]),
|
|
261
|
-
or(
|
|
262
|
-
isNull(providerAccounts.lastErrorCategory),
|
|
263
|
-
ne(providerAccounts.lastErrorCategory, "auth_invalidated"),
|
|
264
|
-
),
|
|
265
|
-
),
|
|
266
|
-
);
|
|
267
|
-
}
|
|
268
|
-
|
|
269
|
-
/**
|
|
270
|
-
* Loads one account scoped to its owner.
|
|
271
|
-
*
|
|
272
|
-
* `tenantId` undefined means "any owner"; `null` means a shared/global row,
|
|
273
|
-
* and a supplied tenant id matches only that tenant's own rows. Explicitly
|
|
274
|
-
* selecting `null` keeps global-admin refreshes from addressing tenant-owned
|
|
275
|
-
* accounts by id.
|
|
276
|
-
*/
|
|
277
|
-
export async function loadQuotaTarget(
|
|
278
|
-
db: CartethyiaDatabase,
|
|
279
|
-
accountId: string,
|
|
280
|
-
tenantId?: string | null,
|
|
281
|
-
): Promise<QuotaRefreshTarget | undefined> {
|
|
282
|
-
const ownerPredicate =
|
|
283
|
-
tenantId === undefined
|
|
284
|
-
? undefined
|
|
285
|
-
: tenantId === null
|
|
286
|
-
? isNull(providerAccounts.tenantId)
|
|
287
|
-
: eq(providerAccounts.tenantId, tenantId);
|
|
288
|
-
const where = ownerPredicate
|
|
289
|
-
? and(eq(providerAccounts.id, accountId), ownerPredicate)
|
|
290
|
-
: eq(providerAccounts.id, accountId);
|
|
291
|
-
const rows = await db.select(TARGET_COLUMNS).from(providerAccounts).where(where).limit(1);
|
|
292
|
-
return rows[0];
|
|
293
|
-
}
|
|
294
|
-
|
|
295
|
-
/**
|
|
296
|
-
* Fetches one account's quota through the refresh-aware credential path, then
|
|
297
|
-
* persists the outcome: the result is cached under the account's lens and the
|
|
298
|
-
* account row is stamped with the check result. Concurrent callers for the same
|
|
299
|
-
* account share one upstream fetch via `inFlightAccountQuotaRefreshes`.
|
|
300
|
-
*/
|
|
301
|
-
export async function refreshAccountQuota(
|
|
302
|
-
deps: QuotaRefreshDeps,
|
|
303
|
-
target: QuotaRefreshTarget,
|
|
304
|
-
fetcher: FetchLike,
|
|
305
|
-
): Promise<QuotaRefreshOutcome> {
|
|
306
|
-
const existing = inFlightAccountQuotaRefreshes.get(target.accountId);
|
|
307
|
-
if (existing) return existing;
|
|
308
|
-
// Eviction guard: keys are account ids (bounded by the accounts table), but a
|
|
309
|
-
// runaway caller could still fan out unique keys; drop the oldest in-flight
|
|
310
|
-
// task rather than grow without bound. An evicted task's `.finally` delete is
|
|
311
|
-
// a no-op for a key no longer present, and the caller still awaits its own
|
|
312
|
-
// promise — eviction only costs it deduplication, never its result.
|
|
313
|
-
if (inFlightAccountQuotaRefreshes.size >= MAX_INFLIGHT) {
|
|
314
|
-
const oldestKey = inFlightAccountQuotaRefreshes.keys().next().value;
|
|
315
|
-
if (oldestKey !== undefined) inFlightAccountQuotaRefreshes.delete(oldestKey);
|
|
316
|
-
}
|
|
317
|
-
const task = runQuotaRefresh(deps, target, fetcher).finally(() => {
|
|
318
|
-
inFlightAccountQuotaRefreshes.delete(target.accountId);
|
|
319
|
-
});
|
|
320
|
-
inFlightAccountQuotaRefreshes.set(target.accountId, task);
|
|
321
|
-
return task;
|
|
322
|
-
}
|
|
323
|
-
|
|
324
|
-
async function runQuotaRefresh(
|
|
325
|
-
deps: QuotaRefreshDeps,
|
|
326
|
-
target: QuotaRefreshTarget,
|
|
327
|
-
fetcher: FetchLike,
|
|
328
|
-
): Promise<QuotaRefreshOutcome> {
|
|
329
|
-
let credential: string | undefined;
|
|
330
|
-
let resolveError: string | undefined;
|
|
331
|
-
try {
|
|
332
|
-
credential = await deps.resolveCredential(target.providerId as ProviderId, target.accountId);
|
|
333
|
-
} catch (error) {
|
|
334
|
-
resolveError = error instanceof Error ? error.message : "Failed to resolve credential";
|
|
335
|
-
}
|
|
336
|
-
|
|
337
|
-
let quota: ProviderQuotaResult;
|
|
338
|
-
let failure: QuotaRefreshFailureCode | null = null;
|
|
339
|
-
if (resolveError !== undefined) {
|
|
340
|
-
quota = { source: target.providerId, plan: null, windows: [], error: resolveError };
|
|
341
|
-
failure = "quota_fetch_failed";
|
|
342
|
-
} else if (!credential) {
|
|
343
|
-
quota = {
|
|
344
|
-
source: target.providerId,
|
|
345
|
-
plan: null,
|
|
346
|
-
windows: [],
|
|
347
|
-
error: "Account has no stored credential",
|
|
348
|
-
};
|
|
349
|
-
failure = "missing_credential";
|
|
350
|
-
} else {
|
|
351
|
-
// OAuth accounts must never see the api-key mark: the marker routes the
|
|
352
|
-
// collector to the key-only probe, and OAuth credentials carry no key.
|
|
353
|
-
const marked =
|
|
354
|
-
target.credentialKind === "api_key"
|
|
355
|
-
? (deps.markApiKeyCredential?.(target.providerId, credential) ?? credential)
|
|
356
|
-
: credential;
|
|
357
|
-
try {
|
|
358
|
-
const authState = record(target.authState);
|
|
359
|
-
quota = await fetchProviderQuota(deps.providerRegistry, target.providerId, marked, fetcher, {
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
//
|
|
381
|
-
//
|
|
382
|
-
|
|
383
|
-
//
|
|
384
|
-
//
|
|
385
|
-
//
|
|
386
|
-
// it
|
|
387
|
-
//
|
|
388
|
-
//
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
//
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
.
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
1
|
+
// Shared account-quota refresh: one home for "resolve credential → fetch quota →
|
|
2
|
+
// cache under the right lens → stamp the account row".
|
|
3
|
+
//
|
|
4
|
+
// Four callers need exactly this sequence — the console's single/bulk refresh
|
|
5
|
+
// routes, the overview's background fill, the periodic sweep worker, and the
|
|
6
|
+
// global-admin refresh — so it lives here rather than inside the route module.
|
|
7
|
+
// A drift between them would mean the page and the worker disagree about what
|
|
8
|
+
// "refreshed" means.
|
|
9
|
+
import { and, eq, inArray, isNull, ne, or, sql } from "drizzle-orm";
|
|
10
|
+
import type { CartethyiaDatabase } from "../../persistence/db-handle";
|
|
11
|
+
import { providerAccounts } from "../../persistence/schema";
|
|
12
|
+
import type { RedisClient } from "../../persistence/redis";
|
|
13
|
+
import { log } from "../../observability/logger";
|
|
14
|
+
import { fetchProviderQuota } from "../../providers/quota/quota-support";
|
|
15
|
+
import { type FetchLike, type ProviderQuotaResult } from "../../providers/quota/quota-contracts";
|
|
16
|
+
import type { ProviderId } from "../../providers/provider-contracts";
|
|
17
|
+
import type { ProviderRegistry } from "../../providers/provider-registry";
|
|
18
|
+
import { GLOBAL_QUOTA_LENS, setCachedQuota } from "./cache";
|
|
19
|
+
import { record } from "../../providers/authentication/oauth-flow-store";
|
|
20
|
+
|
|
21
|
+
/** Why an account quota refresh failed; `null` means the fetch succeeded. */
|
|
22
|
+
export type QuotaRefreshFailureCode = "missing_credential" | "quota_fetch_failed";
|
|
23
|
+
|
|
24
|
+
export interface QuotaRefreshOutcome {
|
|
25
|
+
readonly quota: ProviderQuotaResult;
|
|
26
|
+
readonly failure: QuotaRefreshFailureCode | null;
|
|
27
|
+
/** Display identity the quota surface reported, when it reported one. */
|
|
28
|
+
readonly accountLabel?: string;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** Abort budget for one upstream quota fetch. */
|
|
32
|
+
export const QUOTA_REFRESH_TIMEOUT_MS = 15_000;
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Consecutive quota refusals before an account is parked. One refusal can be
|
|
36
|
+
* the provider's own hiccup; three in a row is the quota surface not coming
|
|
37
|
+
* back, and the account should stop being dialed until the cooldown elapses.
|
|
38
|
+
*/
|
|
39
|
+
export const QUOTA_FAILURE_PARK_THRESHOLD = 3;
|
|
40
|
+
/** How long a quota-parked account stays out of rotation. */
|
|
41
|
+
export const QUOTA_FAILURE_COOLDOWN_MS = 6 * 60 * 60 * 1000;
|
|
42
|
+
|
|
43
|
+
export interface QuotaRefreshDeps {
|
|
44
|
+
readonly db: CartethyiaDatabase;
|
|
45
|
+
readonly redis: RedisClient | undefined;
|
|
46
|
+
readonly providerRegistry: ProviderRegistry;
|
|
47
|
+
/**
|
|
48
|
+
* Resolves a stored credential through the refresh-aware path (OAuth tokens
|
|
49
|
+
* are refreshed before use), returning `""` when the account has none.
|
|
50
|
+
* API-key accounts are wrapped in the provider's key envelope when one
|
|
51
|
+
* exists, so a key-only credential never reaches an OAuth-only surface.
|
|
52
|
+
*/
|
|
53
|
+
readonly resolveCredential: (providerId: ProviderId, accountId: string) => Promise<string>;
|
|
54
|
+
/** Marks a stored api_key credential with the upstream-distinguishable envelope. */
|
|
55
|
+
readonly markApiKeyCredential?: (providerId: string, credential: string) => string;
|
|
56
|
+
/** Invalidates the route snapshot when a stamped figure may exclude an account. */
|
|
57
|
+
readonly snapshotInvalidator?: { invalidate(): unknown } | undefined;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** Bound on concurrently tracked single-flight quota refreshes; keys are account ids, so the real bound is the account count. */
|
|
61
|
+
const MAX_INFLIGHT = 1_000;
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* In-flight promise deduplication map for account quota refreshes. Keyed by
|
|
65
|
+
* accountId so concurrent callers (a manual refresh plus the sweep, or two
|
|
66
|
+
* rapid clicks) share one upstream fetch instead of hammering the provider.
|
|
67
|
+
*/
|
|
68
|
+
export const inFlightAccountQuotaRefreshes = new Map<string, Promise<QuotaRefreshOutcome>>();
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Binds an abort signal to outbound quota fetches while preserving Bun's
|
|
72
|
+
* `fetch.preconnect` static, so the wrapper satisfies `FetchLike`.
|
|
73
|
+
*/
|
|
74
|
+
export function signalFetch(signal: AbortSignal): typeof fetch {
|
|
75
|
+
const wrapped = (
|
|
76
|
+
url: Parameters<typeof fetch>[0],
|
|
77
|
+
init?: Parameters<typeof fetch>[1],
|
|
78
|
+
): Promise<Response> => fetch(url, { ...init, signal });
|
|
79
|
+
return Object.assign(wrapped, {
|
|
80
|
+
preconnect: (...args: Parameters<typeof fetch.preconnect>): void =>
|
|
81
|
+
fetch.preconnect(...args),
|
|
82
|
+
});
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** Combines the caller's abort signal with the fetch budget. */
|
|
86
|
+
export function timeoutSignal(requestSignal: AbortSignal, ms: number): AbortSignal {
|
|
87
|
+
return AbortSignal.any([requestSignal, AbortSignal.timeout(ms)]);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Records one quota-refresh outcome on the account row so the dashboard's
|
|
92
|
+
* "Last check" reflects what the fetch actually found. Success stamps
|
|
93
|
+
* `lastSuccessAt` and clears the error fields; failure stamps the error
|
|
94
|
+
* fields and keeps the last success.
|
|
95
|
+
*
|
|
96
|
+
* A *counted* failure also advances `consecutiveFailures`, and once an account
|
|
97
|
+
* has `QUOTA_FAILURE_PARK_THRESHOLD` of them in a row it is parked — this is
|
|
98
|
+
* the one place a quota result may move `status`, because a quota surface that
|
|
99
|
+
* refuses three times is an account problem rather than a transient one. A
|
|
100
|
+
* transient timeout is annotated but never counted, so it cannot park
|
|
101
|
+
* (see `isTransientTimeout`).
|
|
102
|
+
*
|
|
103
|
+
* The error fields are written only while the account is `active`, because for
|
|
104
|
+
* a parked account (cooldown/disabled) those fields carry the reason
|
|
105
|
+
* the health machine parked it. A periodic quota sweep runs against every OAuth
|
|
106
|
+
* account, and a failing quota endpoint reported "Invalid or expired
|
|
107
|
+
* credentials" for an entire provider — overwriting the `auth_invalidated`
|
|
108
|
+
* reason a dispatch 401 had just recorded with `quota_fetch_failed`, so the
|
|
109
|
+
* dashboard blamed the wrong thing. A check cannot reclassify a parked account;
|
|
110
|
+
* its own failure is still visible through the quota cache the page renders.
|
|
111
|
+
*/
|
|
112
|
+
/**
|
|
113
|
+
* A failure that says nothing about the account.
|
|
114
|
+
*
|
|
115
|
+
* A quota fetch that ran out of its own abort budget timed out against *our*
|
|
116
|
+
* egress, not against the credential: the upstream may never have seen the
|
|
117
|
+
* request. Counting it like a refusal meant three slow passes parked a healthy
|
|
118
|
+
* account for six hours — and the buddy family is the worst case, because its
|
|
119
|
+
* quota and check-in legs share one pass and rotate egress per account, so a
|
|
120
|
+
* single slow pool produced three timeouts in a row.
|
|
121
|
+
*
|
|
122
|
+
* Matched on the message the timeout paths produce. Deliberately narrow: a
|
|
123
|
+
* refusal that merely mentions a timeout is still a refusal.
|
|
124
|
+
*/
|
|
125
|
+
function isTransientTimeout(error: string | null | undefined): boolean {
|
|
126
|
+
if (!error) return false;
|
|
127
|
+
const lower = error.toLowerCase();
|
|
128
|
+
return (
|
|
129
|
+
lower.includes("the operation timed out") ||
|
|
130
|
+
lower.includes("operation timed out") ||
|
|
131
|
+
lower.includes("timeout") && lower.includes("abort")
|
|
132
|
+
);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
export async function recordAccountCheck(
|
|
136
|
+
db: CartethyiaDatabase,
|
|
137
|
+
accountId: string,
|
|
138
|
+
outcome: { ok: boolean; error?: string | null; category?: string | null },
|
|
139
|
+
): Promise<void> {
|
|
140
|
+
const activeOnly = and(
|
|
141
|
+
eq(providerAccounts.id, accountId),
|
|
142
|
+
eq(providerAccounts.status, "active"),
|
|
143
|
+
);
|
|
144
|
+
if (outcome.ok) {
|
|
145
|
+
await db
|
|
146
|
+
.update(providerAccounts)
|
|
147
|
+
.set({
|
|
148
|
+
lastSuccessAt: new Date(),
|
|
149
|
+
lastError: null,
|
|
150
|
+
lastErrorCategory: null,
|
|
151
|
+
lastErrorAt: null,
|
|
152
|
+
consecutiveFailures: 0,
|
|
153
|
+
})
|
|
154
|
+
.where(activeOnly);
|
|
155
|
+
return;
|
|
156
|
+
}
|
|
157
|
+
const transient = isTransientTimeout(outcome.error);
|
|
158
|
+
// Count the failure in the same statement — unless it is a transient
|
|
159
|
+
// timeout (see below). A quota endpoint that keeps refusing is an account
|
|
160
|
+
// problem, and the row has to say so: the dashboard reads
|
|
161
|
+
// `consecutiveFailures` for the pill, and a check that only wrote the error
|
|
162
|
+
// text left the account looking healthy while every refresh failed.
|
|
163
|
+
//
|
|
164
|
+
// A timeout is annotated but NOT counted: it is evidence about the network,
|
|
165
|
+
// not the credential, and letting it advance the streak is what parked
|
|
166
|
+
// healthy accounts for six hours over three slow passes.
|
|
167
|
+
const failing = await db
|
|
168
|
+
.update(providerAccounts)
|
|
169
|
+
.set({
|
|
170
|
+
lastError: (outcome.error ?? "Account check failed").slice(0, 500),
|
|
171
|
+
lastErrorCategory: outcome.category ?? "check_failed",
|
|
172
|
+
lastErrorAt: new Date(),
|
|
173
|
+
...(transient ? {} : { consecutiveFailures: sql`${providerAccounts.consecutiveFailures} + 1` }),
|
|
174
|
+
})
|
|
175
|
+
.where(activeOnly)
|
|
176
|
+
.returning({ consecutiveFailures: providerAccounts.consecutiveFailures });
|
|
177
|
+
if (transient) return;
|
|
178
|
+
const failures = failing[0]?.consecutiveFailures ?? 0;
|
|
179
|
+
// Past the threshold the account is parked, not just annotated. Three
|
|
180
|
+
// consecutive refusals is a quota surface that is not coming back on its
|
|
181
|
+
// own, and leaving the account in rotation only repeats the failure every
|
|
182
|
+
// pass. The cooldown clears itself; a success before it elapses resets the
|
|
183
|
+
// count above.
|
|
184
|
+
if (failures >= QUOTA_FAILURE_PARK_THRESHOLD) {
|
|
185
|
+
await db
|
|
186
|
+
.update(providerAccounts)
|
|
187
|
+
.set({
|
|
188
|
+
status: "cooldown",
|
|
189
|
+
cooldownUntil: new Date(Date.now() + QUOTA_FAILURE_COOLDOWN_MS),
|
|
190
|
+
})
|
|
191
|
+
.where(activeOnly);
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/** One account that a quota refresh can be attempted for. */
|
|
196
|
+
export interface QuotaRefreshTarget {
|
|
197
|
+
readonly accountId: string;
|
|
198
|
+
readonly providerId: string;
|
|
199
|
+
/** Owning tenant, or `null` for a global/shared account. */
|
|
200
|
+
readonly tenantId: string | null;
|
|
201
|
+
/**
|
|
202
|
+
* Stored credential kind. Key-only credentials never reach an OAuth-only
|
|
203
|
+
* quota surface: the collector routes `api_key` to the `/models`
|
|
204
|
+
* connectivity probe instead, while OAuth keeps `users/me`.
|
|
205
|
+
*/
|
|
206
|
+
readonly credentialKind?: "api_key" | "oauth" | "none" | null;
|
|
207
|
+
/**
|
|
208
|
+
* Human identity for logs. OAuth rows carry `Name <email@host>` because the
|
|
209
|
+
* account label is seeded from the token's own identity claims, so sweep
|
|
210
|
+
* lines can name the account instead of dumping a uuid.
|
|
211
|
+
*/
|
|
212
|
+
readonly label?: string | null;
|
|
213
|
+
/**
|
|
214
|
+
* Non-secret per-account auth configuration. A collector whose billing
|
|
215
|
+
* surface is region- or profile-scoped needs it; most ignore it.
|
|
216
|
+
*/
|
|
217
|
+
readonly authState?: unknown;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/** Cache lens an account's quota belongs under. */
|
|
221
|
+
export function targetLens(target: QuotaRefreshTarget): string {
|
|
222
|
+
return target.tenantId ?? GLOBAL_QUOTA_LENS;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
const TARGET_COLUMNS = {
|
|
226
|
+
accountId: providerAccounts.id,
|
|
227
|
+
providerId: providerAccounts.providerId,
|
|
228
|
+
tenantId: providerAccounts.tenantId,
|
|
229
|
+
credentialKind: providerAccounts.credentialKind,
|
|
230
|
+
label: providerAccounts.label,
|
|
231
|
+
authState: providerAccounts.authState,
|
|
232
|
+
};
|
|
233
|
+
|
|
234
|
+
/**
|
|
235
|
+
* Credential-bearing accounts eligible for the periodic quota sweep.
|
|
236
|
+
*
|
|
237
|
+
* OAuth accounts use the refresh-aware resolver; api_key accounts (including
|
|
238
|
+
* pasted JWTs) use the stored bearer exactly as issued. Both need quota refresh
|
|
239
|
+
* and Buddy daily check-in, while `none` has no credential to probe.
|
|
240
|
+
*
|
|
241
|
+
* An account already marked `auth_invalidated` is excluded: its credential was
|
|
242
|
+
* rejected outright and does not repair itself, so every sweep is a guaranteed
|
|
243
|
+
* 401 that only re-confirms the row and floods the log — the operator needs a
|
|
244
|
+
* re-login, not another probe. It returns to the sweep once it is re-authed or
|
|
245
|
+
* recovered, which is when its credential is expected to work again.
|
|
246
|
+
*
|
|
247
|
+
* Every other credential-bearing account is swept, `disabled` included. A
|
|
248
|
+
* disabled account is not necessarily revoked — it may have been parked for a
|
|
249
|
+
* reason unrelated to its credential — and skipping it would let a credential
|
|
250
|
+
* die silently while the row still reads healthy.
|
|
251
|
+
*/
|
|
252
|
+
export async function listQuotaRefreshTargets(
|
|
253
|
+
db: CartethyiaDatabase,
|
|
254
|
+
): Promise<readonly QuotaRefreshTarget[]> {
|
|
255
|
+
return db
|
|
256
|
+
.select(TARGET_COLUMNS)
|
|
257
|
+
.from(providerAccounts)
|
|
258
|
+
.where(
|
|
259
|
+
and(
|
|
260
|
+
inArray(providerAccounts.credentialKind, ["oauth", "api_key"]),
|
|
261
|
+
or(
|
|
262
|
+
isNull(providerAccounts.lastErrorCategory),
|
|
263
|
+
ne(providerAccounts.lastErrorCategory, "auth_invalidated"),
|
|
264
|
+
),
|
|
265
|
+
),
|
|
266
|
+
);
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
/**
|
|
270
|
+
* Loads one account scoped to its owner.
|
|
271
|
+
*
|
|
272
|
+
* `tenantId` undefined means "any owner"; `null` means a shared/global row,
|
|
273
|
+
* and a supplied tenant id matches only that tenant's own rows. Explicitly
|
|
274
|
+
* selecting `null` keeps global-admin refreshes from addressing tenant-owned
|
|
275
|
+
* accounts by id.
|
|
276
|
+
*/
|
|
277
|
+
export async function loadQuotaTarget(
|
|
278
|
+
db: CartethyiaDatabase,
|
|
279
|
+
accountId: string,
|
|
280
|
+
tenantId?: string | null,
|
|
281
|
+
): Promise<QuotaRefreshTarget | undefined> {
|
|
282
|
+
const ownerPredicate =
|
|
283
|
+
tenantId === undefined
|
|
284
|
+
? undefined
|
|
285
|
+
: tenantId === null
|
|
286
|
+
? isNull(providerAccounts.tenantId)
|
|
287
|
+
: eq(providerAccounts.tenantId, tenantId);
|
|
288
|
+
const where = ownerPredicate
|
|
289
|
+
? and(eq(providerAccounts.id, accountId), ownerPredicate)
|
|
290
|
+
: eq(providerAccounts.id, accountId);
|
|
291
|
+
const rows = await db.select(TARGET_COLUMNS).from(providerAccounts).where(where).limit(1);
|
|
292
|
+
return rows[0];
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
/**
|
|
296
|
+
* Fetches one account's quota through the refresh-aware credential path, then
|
|
297
|
+
* persists the outcome: the result is cached under the account's lens and the
|
|
298
|
+
* account row is stamped with the check result. Concurrent callers for the same
|
|
299
|
+
* account share one upstream fetch via `inFlightAccountQuotaRefreshes`.
|
|
300
|
+
*/
|
|
301
|
+
export async function refreshAccountQuota(
|
|
302
|
+
deps: QuotaRefreshDeps,
|
|
303
|
+
target: QuotaRefreshTarget,
|
|
304
|
+
fetcher: FetchLike,
|
|
305
|
+
): Promise<QuotaRefreshOutcome> {
|
|
306
|
+
const existing = inFlightAccountQuotaRefreshes.get(target.accountId);
|
|
307
|
+
if (existing) return existing;
|
|
308
|
+
// Eviction guard: keys are account ids (bounded by the accounts table), but a
|
|
309
|
+
// runaway caller could still fan out unique keys; drop the oldest in-flight
|
|
310
|
+
// task rather than grow without bound. An evicted task's `.finally` delete is
|
|
311
|
+
// a no-op for a key no longer present, and the caller still awaits its own
|
|
312
|
+
// promise — eviction only costs it deduplication, never its result.
|
|
313
|
+
if (inFlightAccountQuotaRefreshes.size >= MAX_INFLIGHT) {
|
|
314
|
+
const oldestKey = inFlightAccountQuotaRefreshes.keys().next().value;
|
|
315
|
+
if (oldestKey !== undefined) inFlightAccountQuotaRefreshes.delete(oldestKey);
|
|
316
|
+
}
|
|
317
|
+
const task = runQuotaRefresh(deps, target, fetcher).finally(() => {
|
|
318
|
+
inFlightAccountQuotaRefreshes.delete(target.accountId);
|
|
319
|
+
});
|
|
320
|
+
inFlightAccountQuotaRefreshes.set(target.accountId, task);
|
|
321
|
+
return task;
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
async function runQuotaRefresh(
|
|
325
|
+
deps: QuotaRefreshDeps,
|
|
326
|
+
target: QuotaRefreshTarget,
|
|
327
|
+
fetcher: FetchLike,
|
|
328
|
+
): Promise<QuotaRefreshOutcome> {
|
|
329
|
+
let credential: string | undefined;
|
|
330
|
+
let resolveError: string | undefined;
|
|
331
|
+
try {
|
|
332
|
+
credential = await deps.resolveCredential(target.providerId as ProviderId, target.accountId);
|
|
333
|
+
} catch (error) {
|
|
334
|
+
resolveError = error instanceof Error ? error.message : "Failed to resolve credential";
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
let quota: ProviderQuotaResult;
|
|
338
|
+
let failure: QuotaRefreshFailureCode | null = null;
|
|
339
|
+
if (resolveError !== undefined) {
|
|
340
|
+
quota = { source: target.providerId, plan: null, windows: [], error: resolveError };
|
|
341
|
+
failure = "quota_fetch_failed";
|
|
342
|
+
} else if (!credential) {
|
|
343
|
+
quota = {
|
|
344
|
+
source: target.providerId,
|
|
345
|
+
plan: null,
|
|
346
|
+
windows: [],
|
|
347
|
+
error: "Account has no stored credential",
|
|
348
|
+
};
|
|
349
|
+
failure = "missing_credential";
|
|
350
|
+
} else {
|
|
351
|
+
// OAuth accounts must never see the api-key mark: the marker routes the
|
|
352
|
+
// collector to the key-only probe, and OAuth credentials carry no key.
|
|
353
|
+
const marked =
|
|
354
|
+
target.credentialKind === "api_key"
|
|
355
|
+
? (deps.markApiKeyCredential?.(target.providerId, credential) ?? credential)
|
|
356
|
+
: credential;
|
|
357
|
+
try {
|
|
358
|
+
const authState = record(target.authState);
|
|
359
|
+
quota = await fetchProviderQuota(deps.providerRegistry, target.providerId, marked, fetcher, {
|
|
360
|
+
// Carried so a collector can reuse a reading captured from real
|
|
361
|
+
// traffic rather than spending a request to rediscover it.
|
|
362
|
+
...(target.accountId === undefined ? {} : { accountId: target.accountId }),
|
|
363
|
+
...(authState === undefined ? {} : { auth_state: authState }),
|
|
364
|
+
...(target.credentialKind === null || target.credentialKind === undefined
|
|
365
|
+
? {}
|
|
366
|
+
: { credential_kind: target.credentialKind }),
|
|
367
|
+
});
|
|
368
|
+
} catch (error) {
|
|
369
|
+
quota = {
|
|
370
|
+
source: target.providerId,
|
|
371
|
+
plan: null,
|
|
372
|
+
windows: [],
|
|
373
|
+
error: error instanceof Error ? error.message : "Failed to refresh quota",
|
|
374
|
+
};
|
|
375
|
+
}
|
|
376
|
+
failure = quota.error === null ? null : "quota_fetch_failed";
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
// Cache even a failed fetch: the error view ("credential expired", "quota
|
|
380
|
+
// unsupported") is itself the answer the page must render, and caching it
|
|
381
|
+
// stops every page open from re-hitting a provider that just refused us.
|
|
382
|
+
await setCachedQuota(targetLens(target), target.accountId, quota, deps.redis);
|
|
383
|
+
// Persist the plan the provider reported, because routing gates on it: some
|
|
384
|
+
// upstreams refuse a model on a free plan and nothing but the account's own
|
|
385
|
+
// plan says which. The cache above is a short in-process TTL and empty after
|
|
386
|
+
// a restart, so it cannot be the only copy — the row is what survives one.
|
|
387
|
+
//
|
|
388
|
+
// A failed fetch leaves the last known plan in place rather than clearing
|
|
389
|
+
// it. The refresh failing is evidence about the network or the credential,
|
|
390
|
+
// not evidence that the account's plan changed, and clearing on failure
|
|
391
|
+
// would drop the router back to "unknown" on every transient error.
|
|
392
|
+
if (quota.plan !== null) {
|
|
393
|
+
try {
|
|
394
|
+
await deps.db
|
|
395
|
+
.update(providerAccounts)
|
|
396
|
+
.set({ quotaPlan: quota.plan })
|
|
397
|
+
.where(eq(providerAccounts.id, target.accountId));
|
|
398
|
+
} catch (error) {
|
|
399
|
+
log.warn(`[quota] failed to record plan for account=${target.accountId}`, error as Error);
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
// A quota surface that names the account adopts the identity — but only over
|
|
403
|
+
// a default label. An operator's custom rename is never overwritten, and the
|
|
404
|
+
// CASE keeps the read and the write in one statement so a concurrent rename
|
|
405
|
+
// cannot lose to the background sweep.
|
|
406
|
+
if (quota.accountLabel !== undefined && quota.accountLabel.trim().length > 0) {
|
|
407
|
+
try {
|
|
408
|
+
await deps.db
|
|
409
|
+
.update(providerAccounts)
|
|
410
|
+
.set({
|
|
411
|
+
// Adopted when the stored label is still the provider id *or* still
|
|
412
|
+
// the credential's own subject. A collector that reduces an identity
|
|
413
|
+
// to an opaque tag must be able to replace that identity, and the
|
|
414
|
+
// provider-id test alone left the real subject in place — which is
|
|
415
|
+
// the thing the tag exists to remove.
|
|
416
|
+
label: sql`CASE
|
|
417
|
+
WHEN lower(trim(${providerAccounts.label})) = lower(trim(${providerAccounts.providerId})) THEN ${quota.accountLabel}
|
|
418
|
+
WHEN ${providerAccounts.label} LIKE '%|%' THEN ${quota.accountLabel}
|
|
419
|
+
ELSE ${providerAccounts.label}
|
|
420
|
+
END`,
|
|
421
|
+
})
|
|
422
|
+
.where(eq(providerAccounts.id, target.accountId));
|
|
423
|
+
} catch (error) {
|
|
424
|
+
log.warn(`[quota] failed to adopt account label for account=${target.accountId}`, error as Error);
|
|
425
|
+
}
|
|
426
|
+
}
|
|
427
|
+
try {
|
|
428
|
+
await recordAccountCheck(deps.db, target.accountId, {
|
|
429
|
+
ok: quota.error === null,
|
|
430
|
+
...(quota.error === null ? {} : { error: quota.error, category: failure ?? "quota_fetch_failed" }),
|
|
431
|
+
});
|
|
432
|
+
} catch (error) {
|
|
433
|
+
// A stamp failure must not lose the freshly fetched quota.
|
|
434
|
+
log.warn(`[quota] failed to stamp check outcome for account=${target.accountId}`, error as Error);
|
|
435
|
+
}
|
|
436
|
+
return {
|
|
437
|
+
quota,
|
|
438
|
+
failure: quota.error === null ? null : (failure ?? "quota_fetch_failed"),
|
|
439
|
+
...(quota.accountLabel === undefined ? {} : { accountLabel: quota.accountLabel }),
|
|
440
|
+
};
|
|
441
|
+
}
|