cartethyia 2.7.0 → 2.7.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.env.example +107 -106
- package/README.md +25 -2
- package/dist/dashboard/assets/{BannedPage-P76QLGJA.js → BannedPage-D-J75hBM.js} +1 -1
- package/dist/dashboard/assets/BannedPage-D-J75hBM.js.gz +0 -0
- package/dist/dashboard/assets/{CliToolDetailPage-DdBggJS9.js → CliToolDetailPage-CVe6RaBH.js} +1 -1
- package/dist/dashboard/assets/CliToolDetailPage-CVe6RaBH.js.gz +0 -0
- package/dist/dashboard/assets/{CliToolsPage-aN1iXhhP.js → CliToolsPage-Dd8ZSamp.js} +1 -1
- package/dist/dashboard/assets/CliToolsPage-Dd8ZSamp.js.gz +0 -0
- package/dist/dashboard/assets/{CombosPage-DE-F1nxZ.js → CombosPage-QoJ6foxo.js} +1 -1
- package/dist/dashboard/assets/CombosPage-QoJ6foxo.js.gz +0 -0
- package/dist/dashboard/assets/ConsoleLogPage-DpNRay4C.js +1 -0
- package/dist/dashboard/assets/ConsoleLogPage-DpNRay4C.js.gz +0 -0
- package/dist/dashboard/assets/{CustomizationPage-3CUxMy2X.js → CustomizationPage-CKUFTcW4.js} +1 -1
- package/dist/dashboard/assets/CustomizationPage-CKUFTcW4.js.gz +0 -0
- package/dist/dashboard/assets/HealthEventsModal-CzSLiDzr.js +1 -0
- package/dist/dashboard/assets/HealthEventsModal-CzSLiDzr.js.gz +0 -0
- package/dist/dashboard/assets/LoginPage-DubdQcvX.js +1 -0
- package/dist/dashboard/assets/LoginPage-DubdQcvX.js.gz +0 -0
- package/dist/dashboard/assets/{ModelPicker-DKVslqG3.js → ModelPicker-BH8Tehdi.js} +1 -1
- package/dist/dashboard/assets/ModelPicker-BH8Tehdi.js.gz +0 -0
- package/dist/dashboard/assets/OverviewPage-DuWzAQFL.js +1 -0
- package/dist/dashboard/assets/OverviewPage-DuWzAQFL.js.gz +0 -0
- package/dist/dashboard/assets/ProviderDetailPage-BKEKe2Fm.js +4 -0
- package/dist/dashboard/assets/ProviderDetailPage-BKEKe2Fm.js.gz +0 -0
- package/dist/dashboard/assets/ProviderIcon-FB6yS4v1.js +1 -0
- package/dist/dashboard/assets/ProviderIcon-FB6yS4v1.js.gz +0 -0
- package/dist/dashboard/assets/ProvidersPage-Bwotu8UJ.js +1 -0
- package/dist/dashboard/assets/ProvidersPage-Bwotu8UJ.js.gz +0 -0
- package/dist/dashboard/assets/ProxyPage-D7hpWbg9.js +9 -0
- package/dist/dashboard/assets/ProxyPage-D7hpWbg9.js.gz +0 -0
- package/dist/dashboard/assets/{QuotaPage-BjldQwIU.js → QuotaPage-ATpaxrbl.js} +1 -1
- package/dist/dashboard/assets/QuotaPage-ATpaxrbl.js.gz +0 -0
- package/dist/dashboard/assets/{SettingsPage-DUIoLRxk.js → SettingsPage-CETophpx.js} +1 -1
- package/dist/dashboard/assets/SettingsPage-CETophpx.js.gz +0 -0
- package/dist/dashboard/assets/SetupPage-BdgECjqq.js +1 -0
- package/dist/dashboard/assets/SetupPage-BdgECjqq.js.gz +0 -0
- package/dist/dashboard/assets/{SortableList-fI7JkdIB.js → SortableList-CAK-EN9z.js} +1 -1
- package/dist/dashboard/assets/SortableList-CAK-EN9z.js.gz +0 -0
- package/dist/dashboard/assets/{StudioPage-CHVZqskZ.js → StudioPage-B7W2_RhT.js} +3 -3
- package/dist/dashboard/assets/StudioPage-B7W2_RhT.js.gz +0 -0
- package/dist/dashboard/assets/UsagePage-6T4EFDjW.js +56 -0
- package/dist/dashboard/assets/UsagePage-6T4EFDjW.js.gz +0 -0
- package/dist/dashboard/assets/{api-keys-DFY_fZU4.js → api-keys-CUi6UXsR.js} +1 -1
- package/dist/dashboard/assets/api-keys-CUi6UXsR.js.gz +0 -0
- package/dist/dashboard/assets/app-KvsUJUMF.css +2 -0
- package/dist/dashboard/assets/app-KvsUJUMF.css.gz +0 -0
- package/dist/dashboard/assets/{assert-CwdnZARt.js → assert-67xXH5rf.js} +1 -1
- package/dist/dashboard/assets/assert-67xXH5rf.js.gz +0 -0
- package/dist/dashboard/assets/{badge-Dr-uf0Au.js → badge-DC2L43Mj.js} +1 -1
- package/dist/dashboard/assets/badge-DC2L43Mj.js.gz +0 -0
- package/dist/dashboard/assets/{banner-BddmMdoS.js → banner-BBVcb0J3.js} +1 -1
- package/dist/dashboard/assets/banner-BBVcb0J3.js.gz +0 -0
- package/dist/dashboard/assets/{card-CK0PXWlX.js → card-Cca1XC6G.js} +1 -1
- package/dist/dashboard/assets/card-Cca1XC6G.js.gz +0 -0
- package/dist/dashboard/assets/{cli-tools-CcB6mGh2.js → cli-tools-KvsWa1Jl.js} +1 -1
- package/dist/dashboard/assets/cli-tools-KvsWa1Jl.js.gz +0 -0
- package/dist/dashboard/assets/{clipboard-button-DGb2P9Yx.js → clipboard-button-DUzEbynq.js} +1 -1
- package/dist/dashboard/assets/clipboard-button-DUzEbynq.js.gz +0 -0
- package/dist/dashboard/assets/{dialog-LkfuL50u.js → dialog-DFvWhwTa.js} +1 -1
- package/dist/dashboard/assets/dialog-DFvWhwTa.js.gz +0 -0
- package/dist/dashboard/assets/{drawer-ZbEZ_84c.js → drawer-CFM2sMX1.js} +1 -1
- package/dist/dashboard/assets/drawer-CFM2sMX1.js.gz +0 -0
- package/dist/dashboard/assets/{entry-CWBY5A0f.js → entry-BLBLxX0h.js} +3 -3
- package/dist/dashboard/assets/entry-BLBLxX0h.js.gz +0 -0
- package/dist/dashboard/assets/{entry-BJfsVMvA.js → entry-Bqy8RFvM.js} +1 -1
- package/dist/dashboard/assets/entry-Bqy8RFvM.js.gz +0 -0
- package/dist/dashboard/assets/{entry-CvC1-Zqx.js → entry-_xHo5x6r.js} +1 -1
- package/dist/dashboard/assets/entry-_xHo5x6r.js.gz +0 -0
- package/dist/dashboard/assets/formatters-mpBPqAQ8.js +1 -0
- package/dist/dashboard/assets/formatters-mpBPqAQ8.js.gz +0 -0
- package/dist/dashboard/assets/{github-badge-BxpznFLt.js → github-badge-C9e8Ts7e.js} +1 -1
- package/dist/dashboard/assets/github-badge-C9e8Ts7e.js.gz +0 -0
- package/dist/dashboard/assets/{index-CDzP3ovJ.js → index-D6QzEC_n.js} +2 -2
- package/dist/dashboard/assets/index-D6QzEC_n.js.gz +0 -0
- package/dist/dashboard/assets/{inline-Ae13MQ5w.js → inline-BFUcvllj.js} +1 -1
- package/dist/dashboard/assets/inline-BFUcvllj.js.gz +0 -0
- package/dist/dashboard/assets/{input-D23fo7cp.js → input-C_ILAvBb.js} +1 -1
- package/dist/dashboard/assets/input-C_ILAvBb.js.gz +0 -0
- package/dist/dashboard/assets/{layout-CSsxjj5P.js → layout-CBIyT6Rt.js} +1 -1
- package/dist/dashboard/assets/layout-CBIyT6Rt.js.gz +0 -0
- package/dist/dashboard/assets/{list-row-dmbwZMDY.js → list-row-ebJ_-Nsb.js} +1 -1
- package/dist/dashboard/assets/list-row-ebJ_-Nsb.js.gz +0 -0
- package/dist/dashboard/assets/{live-BG1oUsZZ.js → live-D0mm--ME.js} +1 -1
- package/dist/dashboard/assets/live-D0mm--ME.js.gz +0 -0
- package/dist/dashboard/assets/{mutation-DSF-e4ho.js → mutation-sMD965oO.js} +1 -1
- package/dist/dashboard/assets/mutation-sMD965oO.js.gz +0 -0
- package/dist/dashboard/assets/{numbers-Bm_aECY7.js → numbers-BO9dKaFr.js} +1 -1
- package/dist/dashboard/assets/numbers-BO9dKaFr.js.gz +0 -0
- package/dist/dashboard/assets/providers/inception.svg +5 -0
- package/dist/dashboard/assets/providers/inception.svg.gz +0 -0
- package/dist/dashboard/assets/{query-keys-CF56nYIV.js → query-keys-CfL_Zdoh.js} +1 -1
- package/dist/dashboard/assets/query-keys-CfL_Zdoh.js.gz +0 -0
- package/dist/dashboard/assets/{routing-CeA71n7G.js → routing-BnIpi1IJ.js} +1 -1
- package/dist/dashboard/assets/routing-BnIpi1IJ.js.gz +0 -0
- package/dist/dashboard/assets/{select-KIT0rC6G.js → select-Cj5Nw43g.js} +1 -1
- package/dist/dashboard/assets/select-Cj5Nw43g.js.gz +0 -0
- package/dist/dashboard/assets/{settings-B_2gkePf.js → settings-BAFhDgX8.js} +1 -1
- package/dist/dashboard/assets/settings-BAFhDgX8.js.gz +0 -0
- package/dist/dashboard/assets/{stack-D7WOSzcX.js → stack-EMrOMt4I.js} +1 -1
- package/dist/dashboard/assets/stack-EMrOMt4I.js.gz +0 -0
- package/dist/dashboard/assets/{state-BBxtKZOQ.js → state-CWoo7ty5.js} +1 -1
- package/dist/dashboard/assets/state-CWoo7ty5.js.gz +0 -0
- package/dist/dashboard/assets/{switch-DNS_D5Ea.js → switch-CCjT6IkF.js} +1 -1
- package/dist/dashboard/assets/switch-CCjT6IkF.js.gz +0 -0
- package/dist/dashboard/assets/{useMutation-BgoxdoMk.js → useMutation-DjkLHUcE.js} +1 -1
- package/dist/dashboard/assets/useMutation-DjkLHUcE.js.gz +0 -0
- package/dist/dashboard/index.html +1 -1
- package/dist/dashboard/index.html.gz +0 -0
- package/dist/dashboard/precompressed-assets.json +54 -53
- package/migrations/full-postgres/0024_add_pool_provider_cooldowns.sql +12 -0
- package/migrations/full-postgres/0025_add_pool_random_strategy.sql +3 -0
- package/migrations/lite-sqlite3/0024_add_pool_provider_cooldowns.sql +12 -0
- package/migrations/lite-sqlite3/0025_add_pool_random_strategy.sql +20 -0
- package/package.json +96 -96
- package/scripts/cli/console-menu.ts +5 -2
- package/scripts/cli/tray.ts +21 -6
- package/src/boot/app.ts +684 -714
- package/src/boot/dependencies.ts +65 -62
- package/src/config/cooldowns.ts +16 -0
- package/src/config/providers.ts +13 -1
- package/src/config/resources.ts +70 -70
- package/src/config/security.ts +37 -37
- package/src/config/spec.ts +371 -358
- package/src/console/console-router.ts +233 -225
- package/src/console/domain-registration.ts +398 -381
- package/src/console/providers/catalog/catalog-projections.ts +2 -6
- package/src/console/providers/catalog/contracts.ts +19 -0
- package/src/console/providers/catalog/provider-operations.ts +38 -0
- package/src/console/providers/catalog/routes.ts +14 -0
- package/src/console/providers/catalog/store-helpers.ts +326 -0
- package/src/console/providers/catalog/store.ts +1292 -1563
- package/src/console/quota/account-quota-global.ts +2 -2
- package/src/console/quota/account-quota-shared.ts +1 -1
- package/src/console/quota/account-quota-tenant.ts +35 -12
- package/src/console/quota/quota-view.ts +2 -2
- package/src/console/routing/pools/contracts.ts +41 -1
- package/src/console/routing/pools/operations.ts +183 -4
- package/src/console/routing/pools/relay-deploy.ts +17 -4
- package/src/console/routing/pools/routes.ts +30 -0
- package/src/console/routing/pools/store.ts +98 -8
- package/src/main.ts +183 -167
- package/src/network/outbound-fetch.ts +226 -365
- package/src/network/pool/agent.ts +8 -0
- package/src/network/pool/pool-state.ts +7 -0
- package/src/network/pool/selector.ts +110 -2
- package/src/network/pool-cooldown-store.ts +252 -0
- package/src/network/pool-health-machine.ts +5 -3
- package/src/network/pool-health.ts +36 -9
- package/src/observability/event-query.ts +57 -0
- package/src/observability/metrics.ts +467 -467
- package/src/observability/runtime-metrics.ts +269 -269
- package/src/persistence/health-event-flush.ts +2 -0
- package/src/persistence/schema.ts +19 -0
- package/src/persistence/usage-flush.ts +8 -3
- package/src/protocol/canonical-model.ts +1 -1
- package/src/protocol/dsml.ts +377 -0
- package/src/protocol/gateway-error.ts +232 -232
- package/src/{providers/provider-reasoning.ts → protocol/reasoning.ts} +10 -9
- package/src/protocol/request/chat.ts +7 -7
- package/src/protocol/request/codex.ts +2 -2
- package/src/protocol/request/gemini.ts +1 -1
- package/src/protocol/request/messages.ts +6 -6
- package/src/protocol/request/responses.ts +7 -7
- package/src/protocol/response/dsml.ts +12 -133
- package/src/{transport → protocol}/translation/attachment-integrity.ts +2 -2
- package/src/{transport → protocol}/translation/cache-controls.ts +1 -1
- package/src/{transport → protocol}/translation/capabilities.ts +2 -2
- package/src/{transport → protocol}/translation/normalize.ts +1 -1
- package/src/{transport → protocol}/translation/quirks.ts +1 -1
- package/src/{transport → protocol}/translation/search.ts +1 -1
- package/src/{transport → protocol}/translation/thinking.ts +23 -1
- package/src/{transport → protocol}/translation/tool-id.ts +1 -1
- package/src/{transport → protocol}/translation/tool-repair.ts +2 -2
- package/src/protocol/upstream-parsing.ts +176 -0
- package/src/providers/adapters/compatible-adapter.ts +11 -2
- package/src/providers/authentication/oauth-refresh-service.ts +9 -5
- package/src/providers/default-registry.ts +21 -5
- package/src/providers/discovery/probe-phases.ts +16 -3
- package/src/providers/discovery/probing-service.ts +4 -1
- package/src/providers/integrations/anthropic.ts +1 -1
- package/src/providers/integrations/buddy/buddy-catalog-shared.ts +1 -4
- package/src/providers/integrations/buddy/buddy-chat-shared.ts +141 -8
- package/src/providers/integrations/buddy/buddy-checkin.ts +5 -1
- package/src/providers/integrations/buddy/buddy-growth-shared.ts +0 -82
- package/src/providers/integrations/buddy/buddy-identity.ts +25 -0
- package/src/providers/integrations/buddy/buddy-server-tools.ts +240 -0
- package/src/providers/integrations/buddy/codebuddy/cn-catalog.ts +36 -0
- package/src/providers/integrations/buddy/codebuddy/cn-provider.ts +6 -31
- package/src/providers/integrations/buddy/codebuddy/provider.ts +2 -0
- package/src/providers/integrations/buddy/codebuddy/shared.ts +19 -4
- package/src/providers/integrations/buddy/turing-device-token.ts +262 -0
- package/src/providers/integrations/buddy/workbuddy/provider.ts +2 -0
- package/src/providers/integrations/buddy/workbuddy/shared.ts +56 -3
- package/src/providers/integrations/claude/claude.ts +1 -1
- package/src/providers/integrations/cloudflareai/cloudflareai.ts +1 -1
- package/src/providers/integrations/codex/codex-errors.ts +1 -1
- package/src/providers/integrations/codex/codex.ts +1 -1
- package/src/providers/integrations/factory/factory.ts +1 -1
- package/src/providers/integrations/github/github.ts +1 -1
- package/src/providers/integrations/grok/grok.ts +1 -1
- package/src/providers/integrations/inception/provider.ts +91 -0
- package/src/providers/integrations/inferhub.ts +1 -1
- package/src/providers/integrations/kiro/kiro-request.ts +1 -1
- package/src/providers/integrations/mistral.ts +193 -19
- package/src/providers/integrations/opencode/fingerprint.ts +19 -2
- package/src/providers/integrations/opencode/prompt.ts +111 -0
- package/src/providers/integrations/opencode/provider.ts +101 -23
- package/src/providers/integrations/opencode/tool-definitions.ts +4 -4
- package/src/providers/integrations/qoder/qoder-checkin.ts +303 -0
- package/src/providers/integrations/qoder/qoder.ts +1641 -1458
- package/src/providers/integrations/shared/install-id.ts +2 -1
- package/src/providers/integrations/trae/trae-cache.ts +110 -0
- package/src/providers/integrations/trae/trae-oauth.ts +29 -4
- package/src/providers/integrations/trae/trae-protocol.ts +2 -1
- package/src/providers/integrations/trae/trae.ts +106 -23
- package/src/providers/operations/account-error-classification.ts +1660 -0
- package/src/providers/operations/account-health-service.ts +621 -2097
- package/src/providers/operations/account-reset-service.ts +2 -0
- package/src/providers/provider-contracts.ts +0 -2
- package/src/providers/provider-metadata.ts +25 -4
- package/src/providers/provider-probe.ts +2 -2
- package/src/{console → providers}/quota/cache.ts +1 -1
- package/src/{console → providers}/quota/refresh.ts +5 -5
- package/src/transport/dispatch/attempt-finalize.ts +114 -12
- package/src/transport/dispatch/attempt-loop.ts +3 -0
- package/src/transport/dispatch/empty-stream.ts +224 -217
- package/src/transport/dispatch/generation.ts +37 -55
- package/src/transport/dispatch/handler-deps.ts +32 -0
- package/src/transport/dispatch/leases.ts +9 -4
- package/src/transport/dispatch/proxy-request.ts +6 -30
- package/src/transport/dispatch/responses-compact.ts +22 -43
- package/src/transport/dispatch/streaming/finalize.ts +3 -0
- package/src/transport/dispatch/streaming/stream.ts +0 -8
- package/src/transport/dispatch/systemone.ts +22 -42
- package/src/transport/dispatch/token-count.ts +23 -39
- package/src/transport/dispatch/upstream.ts +1 -3
- package/src/transport/dispatch/websearch.ts +22 -41
- package/src/transport/failure-policy.ts +59 -178
- package/src/transport/guards/abuse-guard.ts +10 -0
- package/src/transport/guards/auth-guard.ts +109 -109
- package/src/transport/guards/error-lifecycle.ts +1 -1
- package/src/transport/guards/pipeline.ts +220 -220
- package/src/transport/guards/request-context.ts +245 -238
- package/src/transport/request/degradation.ts +20 -1
- package/src/transport/request/inflight.ts +4 -114
- package/src/transport/request/preparer.ts +6 -6
- package/src/transport/request/state.ts +66 -38
- package/src/transport/request/tenant-preferences.ts +1 -1
- package/src/transport/routing/route-catalog.ts +41 -31
- package/src/transport/routing/router.ts +2 -2
- package/src/transport/surface/messages/parse.ts +1 -1
- package/src/workers/buddy-growth-tasks.ts +346 -425
- package/src/workers/daily-checkin.ts +17 -6
- package/src/workers/quota-refresh-sweep.ts +2 -2
- package/src/workers/scheduler.ts +17 -1
- package/dist/dashboard/assets/BannedPage-P76QLGJA.js.gz +0 -0
- package/dist/dashboard/assets/CliToolDetailPage-DdBggJS9.js.gz +0 -0
- package/dist/dashboard/assets/CliToolsPage-aN1iXhhP.js.gz +0 -0
- package/dist/dashboard/assets/CombosPage-DE-F1nxZ.js.gz +0 -0
- package/dist/dashboard/assets/ConsoleLogPage-5SpmfLyw.js +0 -1
- package/dist/dashboard/assets/ConsoleLogPage-5SpmfLyw.js.gz +0 -0
- package/dist/dashboard/assets/CustomizationPage-3CUxMy2X.js.gz +0 -0
- package/dist/dashboard/assets/HealthEventsModal-BkmJQKC0.js +0 -1
- package/dist/dashboard/assets/HealthEventsModal-BkmJQKC0.js.gz +0 -0
- package/dist/dashboard/assets/LoginPage-B_0FUKWc.js +0 -1
- package/dist/dashboard/assets/LoginPage-B_0FUKWc.js.gz +0 -0
- package/dist/dashboard/assets/ModelPicker-DKVslqG3.js.gz +0 -0
- package/dist/dashboard/assets/OverviewPage-D-H4MEiT.js +0 -1
- package/dist/dashboard/assets/OverviewPage-D-H4MEiT.js.gz +0 -0
- package/dist/dashboard/assets/ProviderDetailPage-CEJw6Guo.js +0 -3
- package/dist/dashboard/assets/ProviderDetailPage-CEJw6Guo.js.gz +0 -0
- package/dist/dashboard/assets/ProviderIcon-CmfzxpVM.js +0 -1
- package/dist/dashboard/assets/ProviderIcon-CmfzxpVM.js.gz +0 -0
- package/dist/dashboard/assets/ProvidersPage-CEplAR-i.js +0 -1
- package/dist/dashboard/assets/ProvidersPage-CEplAR-i.js.gz +0 -0
- package/dist/dashboard/assets/ProxyPage-CAN7hy0f.js +0 -7
- package/dist/dashboard/assets/ProxyPage-CAN7hy0f.js.gz +0 -0
- package/dist/dashboard/assets/QuotaPage-BjldQwIU.js.gz +0 -0
- package/dist/dashboard/assets/SettingsPage-DUIoLRxk.js.gz +0 -0
- package/dist/dashboard/assets/SetupPage-_bCaTM0v.js +0 -1
- package/dist/dashboard/assets/SetupPage-_bCaTM0v.js.gz +0 -0
- package/dist/dashboard/assets/SortableList-fI7JkdIB.js.gz +0 -0
- package/dist/dashboard/assets/StudioPage-CHVZqskZ.js.gz +0 -0
- package/dist/dashboard/assets/UsagePage-DiFBymHj.js +0 -56
- package/dist/dashboard/assets/UsagePage-DiFBymHj.js.gz +0 -0
- package/dist/dashboard/assets/api-keys-DFY_fZU4.js.gz +0 -0
- package/dist/dashboard/assets/app-BOJ1_xUB.css +0 -2
- package/dist/dashboard/assets/app-BOJ1_xUB.css.gz +0 -0
- package/dist/dashboard/assets/assert-CwdnZARt.js.gz +0 -0
- package/dist/dashboard/assets/badge-Dr-uf0Au.js.gz +0 -0
- package/dist/dashboard/assets/banner-BddmMdoS.js.gz +0 -0
- package/dist/dashboard/assets/card-CK0PXWlX.js.gz +0 -0
- package/dist/dashboard/assets/cli-tools-CcB6mGh2.js.gz +0 -0
- package/dist/dashboard/assets/clipboard-button-DGb2P9Yx.js.gz +0 -0
- package/dist/dashboard/assets/cog-CdWhsajs.js +0 -1
- package/dist/dashboard/assets/cog-CdWhsajs.js.gz +0 -0
- package/dist/dashboard/assets/dialog-LkfuL50u.js.gz +0 -0
- package/dist/dashboard/assets/drawer-ZbEZ_84c.js.gz +0 -0
- package/dist/dashboard/assets/entry-BJfsVMvA.js.gz +0 -0
- package/dist/dashboard/assets/entry-CWBY5A0f.js.gz +0 -0
- package/dist/dashboard/assets/entry-CvC1-Zqx.js.gz +0 -0
- package/dist/dashboard/assets/github-badge-BxpznFLt.js.gz +0 -0
- package/dist/dashboard/assets/index-CDzP3ovJ.js.gz +0 -0
- package/dist/dashboard/assets/inline-Ae13MQ5w.js.gz +0 -0
- package/dist/dashboard/assets/input-D23fo7cp.js.gz +0 -0
- package/dist/dashboard/assets/layout-CSsxjj5P.js.gz +0 -0
- package/dist/dashboard/assets/list-row-dmbwZMDY.js.gz +0 -0
- package/dist/dashboard/assets/live-BG1oUsZZ.js.gz +0 -0
- package/dist/dashboard/assets/mutation-DSF-e4ho.js.gz +0 -0
- package/dist/dashboard/assets/numbers-Bm_aECY7.js.gz +0 -0
- package/dist/dashboard/assets/query-keys-CF56nYIV.js.gz +0 -0
- package/dist/dashboard/assets/routing-CeA71n7G.js.gz +0 -0
- package/dist/dashboard/assets/select-KIT0rC6G.js.gz +0 -0
- package/dist/dashboard/assets/settings-B_2gkePf.js.gz +0 -0
- package/dist/dashboard/assets/stack-D7WOSzcX.js.gz +0 -0
- package/dist/dashboard/assets/state-BBxtKZOQ.js.gz +0 -0
- package/dist/dashboard/assets/switch-DNS_D5Ea.js.gz +0 -0
- package/dist/dashboard/assets/useMutation-BgoxdoMk.js.gz +0 -0
- /package/dist/dashboard/assets/{app-CaXZ9SCn.js → app-BaWd0puM.js} +0 -0
- /package/dist/dashboard/assets/{app-CaXZ9SCn.js.gz → app-BaWd0puM.js.gz} +0 -0
|
@@ -1,184 +1,26 @@
|
|
|
1
1
|
import { GatewayError, type GatewayErrorCode, type GatewayErrorOrigin } from "../protocol/gateway-error";
|
|
2
2
|
import { isRecord } from "../protocol/codecs";
|
|
3
3
|
import { resolveFallbackRetryBaseMs, resolveFallbackRetryCapMs } from "../config/timeouts";
|
|
4
|
+
import { parseUpstreamBackoff } from "../protocol/upstream-parsing";
|
|
4
5
|
|
|
5
|
-
const MAX_UPSTREAM_ERROR_BYTES = 500;
|
|
6
|
-
const MAX_COOLDOWN_MS = 24 * 3600 * 1000;
|
|
7
|
-
|
|
8
|
-
/** Parses Retry-After seconds or an HTTP date, bounded to one day. */
|
|
9
|
-
export function parseRetryAfter(value: string | null | undefined): number | null {
|
|
10
|
-
if (!value) return null;
|
|
11
|
-
const trimmed = value.trim();
|
|
12
|
-
const numeric = Number(trimmed);
|
|
13
|
-
if (Number.isFinite(numeric) && numeric > 0) return Math.min(MAX_COOLDOWN_MS, numeric * 1000);
|
|
14
|
-
const parsed = Date.parse(trimmed);
|
|
15
|
-
if (!Number.isFinite(parsed)) return null;
|
|
16
|
-
const delay = parsed - Date.now();
|
|
17
|
-
return delay > 0 ? Math.min(MAX_COOLDOWN_MS, delay) : null;
|
|
18
|
-
}
|
|
19
|
-
|
|
20
|
-
/**
|
|
21
|
-
* Parses x-ratelimit-reset as epoch seconds or a relative delay.
|
|
22
|
-
*
|
|
23
|
-
* Returns `null` — not `0` — when the instant has already passed. This matters
|
|
24
|
-
* because `parseUpstreamBackoff` chains these parsers with `??`, which falls
|
|
25
|
-
* through only on null: a `0` from an elapsed `x-ratelimit-reset` (a cached error
|
|
26
|
-
* body, clock skew, a stale edge response) stopped the chain before the
|
|
27
|
-
* lower-priority headers that carried the real wait. The `0` was then written to
|
|
28
|
-
* `details.retryAfterMs` and read by `classifyAccountError` as `headerCooldown`,
|
|
29
|
-
* which outranks the provider's own message text — so both were discarded and the
|
|
30
|
-
* account fell back to the generic default cooldown while the provider had parked
|
|
31
|
-
* it for the rest of the window.
|
|
32
|
-
*
|
|
33
|
-
* `parseRetryAfter` above already returns `null` for an elapsed date; matching it
|
|
34
|
-
* is what removes the asymmetry.
|
|
35
|
-
*/
|
|
36
|
-
export function parseRateLimitReset(value: string | null | undefined): number | null {
|
|
37
|
-
if (!value) return null;
|
|
38
|
-
const numeric = Number(value.trim());
|
|
39
|
-
if (!Number.isFinite(numeric) || numeric <= 0) return null;
|
|
40
|
-
const target = numeric >= 1_000_000_000 ? numeric * 1000 : Date.now() + numeric * 1000;
|
|
41
|
-
const remaining = target - Date.now();
|
|
42
|
-
return remaining > 0 ? Math.min(MAX_COOLDOWN_MS, remaining) : null;
|
|
43
|
-
}
|
|
44
|
-
|
|
45
|
-
/** Parses a millisecond-denominated reset header (`retry-after-ms`, `x-ratelimit-reset-ms`). */
|
|
46
|
-
export function parseResetAfterMs(value: string | null | undefined): number | null {
|
|
47
|
-
if (!value) return null;
|
|
48
|
-
const numeric = Number(value.trim());
|
|
49
|
-
if (!Number.isFinite(numeric) || numeric <= 0) return null;
|
|
50
|
-
return Math.min(MAX_COOLDOWN_MS, Math.round(numeric));
|
|
51
|
-
}
|
|
52
|
-
|
|
53
|
-
/**
|
|
54
|
-
* Reads upstream backoff evidence in reference priority order: millisecond
|
|
55
|
-
* variants first (`retry-after-ms`, then `x-ratelimit-reset-ms`), then the
|
|
56
|
-
* classic `retry-after` / `x-ratelimit-reset` pair.
|
|
57
|
-
*/
|
|
58
|
-
export function parseUpstreamBackoff(headers: {
|
|
59
|
-
get(name: string): string | null;
|
|
60
|
-
}): number | null {
|
|
61
|
-
return (
|
|
62
|
-
parseResetAfterMs(headers.get("retry-after-ms")) ??
|
|
63
|
-
parseRetryAfter(headers.get("retry-after")) ??
|
|
64
|
-
parseResetAfterMs(headers.get("x-ratelimit-reset-ms")) ??
|
|
65
|
-
parseRateLimitReset(headers.get("x-ratelimit-reset")) ??
|
|
66
|
-
parseRateLimitReset(headers.get("x-ratelimit-reset-requests")) ??
|
|
67
|
-
parseRateLimitReset(headers.get("retry-after"))
|
|
68
|
-
);
|
|
69
|
-
}
|
|
70
|
-
|
|
71
|
-
/**
|
|
72
|
-
* Extracts bounded reset durations from provider messages when headers omit
|
|
73
|
-
* them.
|
|
74
|
-
*
|
|
75
|
-
* Two shapes are recognized, because providers state resets both ways:
|
|
76
|
-
*
|
|
77
|
-
* - **Relative** — "quota will reset in 3 hours", "try again in 30s".
|
|
78
|
-
* - **Absolute** — "your usage will reset at 2026-09-24 02:12:51 UTC+8"
|
|
79
|
-
* (WorkBuddy/CodeBuddy `6004`). An absolute stamp is *not* a duration, and
|
|
80
|
-
* the relative-only pattern silently missed it, so the account fell back to
|
|
81
|
-
* the generic 15-minute default while the provider had actually parked it
|
|
82
|
-
* for the rest of the window — the account kept re-entering rotation and
|
|
83
|
-
* failing every request inside the stated reset window.
|
|
84
|
-
*/
|
|
85
6
|
/**
|
|
86
|
-
*
|
|
7
|
+
* How much of the upstream's error body is kept and surfaced.
|
|
87
8
|
*
|
|
88
|
-
*
|
|
89
|
-
*
|
|
90
|
-
*
|
|
91
|
-
*
|
|
92
|
-
*
|
|
9
|
+
* 500 bytes cut the CodeBuddy envelope mid-`extError`: the operator saw
|
|
10
|
+
* "request illegal" with none of the `displayMsg` prose that names the actual
|
|
11
|
+
* condition — "prompt is too long: 1048788 tokens > 1048576 maximum" — so a
|
|
12
|
+
* context overflow was indistinguishable from a policy refusal. The envelope
|
|
13
|
+
* runs a few KB; 4000 keeps the whole thing while bounding a hostile body.
|
|
93
14
|
*/
|
|
94
|
-
const
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
*
|
|
104
|
-
* Providers state a reset as one compound duration at least as often as a
|
|
105
|
-
* single unit: a daily-limit 429 reads "Try again in 4h 13m". A single-pair
|
|
106
|
-
* parser read only `4h` and dropped the 13 minutes, so the stored retry
|
|
107
|
-
* deadline was earlier than the window the provider had actually stated and the
|
|
108
|
-
* account re-entered rotation before it. Pairs are accepted only while they
|
|
109
|
-
* continue the phrase — separated by whitespace, a comma, or `and` — so a
|
|
110
|
-
* number that merely follows later in the sentence cannot be absorbed.
|
|
111
|
-
*/
|
|
112
|
-
function parseCompoundDuration(text: string): number | null {
|
|
113
|
-
const pairs = new RegExp(DURATION_PAIR_SOURCE, "gi");
|
|
114
|
-
let totalMs = 0;
|
|
115
|
-
let lastEnd = 0;
|
|
116
|
-
let matched = false;
|
|
117
|
-
for (let pair = pairs.exec(text); pair !== null; pair = pairs.exec(text)) {
|
|
118
|
-
const gap = text.slice(lastEnd, pair.index);
|
|
119
|
-
const continues = matched ? /^[\s,]*(?:and\s+)?$/i.test(gap) : /^\s*$/.test(gap);
|
|
120
|
-
if (!continues) break;
|
|
121
|
-
const amount = Number(pair[1]);
|
|
122
|
-
if (!Number.isFinite(amount) || amount <= 0) break;
|
|
123
|
-
totalMs += amount * durationUnitMs(pair[2] ?? "");
|
|
124
|
-
matched = true;
|
|
125
|
-
lastEnd = pair.index + pair[0].length;
|
|
126
|
-
}
|
|
127
|
-
return matched ? Math.round(totalMs) : null;
|
|
128
|
-
}
|
|
129
|
-
|
|
130
|
-
export function parseProviderResetDuration(message: string): number | null {
|
|
131
|
-
const trigger =
|
|
132
|
-
/(?:quota will reset|resets?|try again|retry(?:ing)?)\s+(?:in|after|over)\s+(?:a\s+)?(?:rolling\s+)?/i.exec(
|
|
133
|
-
message,
|
|
134
|
-
);
|
|
135
|
-
if (trigger) {
|
|
136
|
-
const parsed = parseCompoundDuration(message.slice(trigger.index + trigger[0].length));
|
|
137
|
-
if (parsed !== null) return Math.min(MAX_COOLDOWN_MS, parsed);
|
|
138
|
-
}
|
|
139
|
-
return parseAbsoluteResetTimestamp(message);
|
|
140
|
-
}
|
|
141
|
-
|
|
142
|
-
/**
|
|
143
|
-
* Reads an absolute reset instant out of a provider message, e.g.
|
|
144
|
-
* `will reset at 2026-09-24 02:12:51 UTC+8`. Returns the delay from now,
|
|
145
|
-
* bounded to one day, or null when no future timestamp is present.
|
|
146
|
-
*
|
|
147
|
-
* `Date.parse` cannot read the space-separated `YYYY-MM-DD HH:MM:SS` form with
|
|
148
|
-
* a bare `UTC±H` offset, so the parts are normalized explicitly rather than
|
|
149
|
-
* relying on engine-specific leniency.
|
|
150
|
-
*/
|
|
151
|
-
export function parseAbsoluteResetTimestamp(message: string): number | null {
|
|
152
|
-
const match = /(\d{4})-(\d{2})-(\d{2})[ T](\d{2}):(\d{2}):(\d{2})\s*(?:UTC|GMT)?\s*([+-]\d{1,2})?(?::?(\d{2}))?/i.exec(
|
|
153
|
-
message,
|
|
154
|
-
);
|
|
155
|
-
if (!match) return null;
|
|
156
|
-
const [, year, month, day, hour, minute, second, offsetHours, offsetMinutes] = match;
|
|
157
|
-
if (!year || !month || !day || !hour || !minute || !second) return null;
|
|
158
|
-
// A bare `UTC+8` means a whole-hour offset; `+08:30`-style halves are rare
|
|
159
|
-
// but cheap to honor. No offset at all is read as UTC (providers that omit
|
|
160
|
-
// it always label the stamp "UTC" in practice).
|
|
161
|
-
const sign = offsetHours?.startsWith("-") ? -1 : 1;
|
|
162
|
-
const offsetTotalMinutes =
|
|
163
|
-
offsetHours === undefined
|
|
164
|
-
? 0
|
|
165
|
-
: sign * (Math.abs(Number(offsetHours)) * 60 + Number(offsetMinutes ?? 0));
|
|
166
|
-
const utcMs = Date.UTC(
|
|
167
|
-
Number(year),
|
|
168
|
-
Number(month) - 1,
|
|
169
|
-
Number(day),
|
|
170
|
-
Number(hour),
|
|
171
|
-
Number(minute),
|
|
172
|
-
Number(second),
|
|
173
|
-
);
|
|
174
|
-
if (!Number.isFinite(utcMs)) return null;
|
|
175
|
-
const targetMs = utcMs - offsetTotalMinutes * 60_000;
|
|
176
|
-
const delay = targetMs - Date.now();
|
|
177
|
-
// A past stamp is not a usable reset (clock skew, stale cached error); fall
|
|
178
|
-
// through to the caller's default instead of returning a negative delay.
|
|
179
|
-
if (!Number.isFinite(delay) || delay <= 0) return null;
|
|
180
|
-
return Math.min(MAX_COOLDOWN_MS, Math.round(delay));
|
|
181
|
-
}
|
|
15
|
+
const MAX_UPSTREAM_ERROR_BYTES = 4000;
|
|
16
|
+
export {
|
|
17
|
+
parseRetryAfter,
|
|
18
|
+
parseRateLimitReset,
|
|
19
|
+
parseResetAfterMs,
|
|
20
|
+
parseUpstreamBackoff,
|
|
21
|
+
parseProviderResetDuration,
|
|
22
|
+
parseAbsoluteResetTimestamp,
|
|
23
|
+
} from "../protocol/upstream-parsing";
|
|
182
24
|
/** Extracts a bounded, newline-free provider message from any error envelope. */
|
|
183
25
|
export function extractUpstreamMessage(body: unknown): string {
|
|
184
26
|
let raw = "";
|
|
@@ -544,7 +386,27 @@ export interface TerminalOutcome {
|
|
|
544
386
|
readonly status: "cancelled" | "failed";
|
|
545
387
|
readonly errorCategory: GatewayErrorCode | "unknown_error";
|
|
546
388
|
readonly errorOrigin: GatewayErrorOrigin;
|
|
547
|
-
|
|
389
|
+
/**
|
|
390
|
+
* The HTTP status the row should carry. `undefined` means the caller has a
|
|
391
|
+
* better one (the wire status of an actual response) and should keep it;
|
|
392
|
+
* a raw error has none, and the caller's own fallback was 500 — which
|
|
393
|
+
* read as a gateway fault for what was an upstream transport drop.
|
|
394
|
+
*/
|
|
395
|
+
readonly httpStatus?: number;
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
/** The HTTP status each classifier-derived category carries when no wire response named one. */
|
|
399
|
+
const CATEGORY_HTTP_STATUS: Partial<Record<GatewayErrorCode | "unknown_error", number>> = {
|
|
400
|
+
// The upstream's transport died mid-response: a bad gateway, not a fault
|
|
401
|
+
// in this process.
|
|
402
|
+
transport_unavailable: 502,
|
|
403
|
+
platform_unavailable: 502,
|
|
404
|
+
deadline_exceeded: 504,
|
|
405
|
+
transport_closed: 499,
|
|
406
|
+
shutting_down: 503,
|
|
407
|
+
overloaded: 529,
|
|
408
|
+
unknown_error: 500,
|
|
409
|
+
};
|
|
548
410
|
|
|
549
411
|
/**
|
|
550
412
|
* Derives a failed attempt's status, error category, and error origin **from
|
|
@@ -559,11 +421,24 @@ export interface TerminalOutcome {
|
|
|
559
421
|
* outcome carries the real code and origin.
|
|
560
422
|
*/
|
|
561
423
|
export function classifyTerminalOutcome(error: unknown, signal: AbortSignal): TerminalOutcome {
|
|
424
|
+
const category = classifyTerminalCategory(error, signal);
|
|
425
|
+
const gateway = error instanceof GatewayError ? error : signal.reason instanceof GatewayError ? signal.reason : undefined;
|
|
562
426
|
return {
|
|
563
427
|
status: isClientCancellation(error, signal) ? "cancelled" : "failed",
|
|
564
|
-
errorCategory:
|
|
565
|
-
//
|
|
566
|
-
|
|
428
|
+
errorCategory: category,
|
|
429
|
+
// The classifier owns the blame. An upstream socket drop arrives as a
|
|
430
|
+
// raw runtime error — no `GatewayError` wraps it — and calling that ours
|
|
431
|
+
// made the row accuse the gateway for an upstream failure.
|
|
432
|
+
errorOrigin: gateway
|
|
433
|
+
? gateway.origin
|
|
434
|
+
: category === "transport_unavailable"
|
|
435
|
+
? "upstream"
|
|
436
|
+
: "cartethyia",
|
|
437
|
+
// A `GatewayError` carries its own status; a raw error takes the
|
|
438
|
+
// category's, so an upstream drop is 502 rather than the 500 fallback.
|
|
439
|
+
...(gateway || CATEGORY_HTTP_STATUS[category] !== undefined
|
|
440
|
+
? { httpStatus: gateway ? gateway.status : CATEGORY_HTTP_STATUS[category] }
|
|
441
|
+
: {}),
|
|
567
442
|
};
|
|
568
443
|
}
|
|
569
444
|
|
|
@@ -735,6 +610,12 @@ const QUOTA_ERROR_IDENTIFIERS: ReadonlySet<string> = new Set([
|
|
|
735
610
|
"usage_not_included",
|
|
736
611
|
"subscription:free-usage-exhausted",
|
|
737
612
|
"resource_exhausted",
|
|
613
|
+
// Qoder answers an exhausted daily billing allowance with a bare numeric
|
|
614
|
+
// code: `{"code":"110","message":"Billing daily count exceeded"}`. The 403
|
|
615
|
+
// wrapper made the old status-only fallback read it as a credential fault;
|
|
616
|
+
// it is a spend/limit verdict, never an auth one, so it must classify as
|
|
617
|
+
// quota (a cooldown) instead of `auth_invalidated`.
|
|
618
|
+
"110",
|
|
738
619
|
// InferHub publishes its own vocabulary in `error.code`. The two 403s below
|
|
739
620
|
// are deliberate absences: `account_blocked` means the account was blocked
|
|
740
621
|
// (an auth/entitlement verdict, not a spend limit) and
|
|
@@ -69,6 +69,16 @@ export function createIpAbuseProtectionMiddleware(deps: {
|
|
|
69
69
|
throw peerAddressUnavailableError();
|
|
70
70
|
const address = resolveClientIdentity(request, deps.trustedProxyBoundary, peer);
|
|
71
71
|
const state = deps.stateStore.get(request);
|
|
72
|
+
// The identity middleware runs later in the plugin chain and resolves the
|
|
73
|
+
// same value from the same boundary; stashing it here means the request
|
|
74
|
+
// pays for one resolveClientIdentity instead of two, and the middleware can
|
|
75
|
+
// skip its own when it finds the stash.
|
|
76
|
+
if (state !== undefined) {
|
|
77
|
+
state.clientIdentity ??= {
|
|
78
|
+
address,
|
|
79
|
+
source: address === peer ? "tcp-peer" : "trusted-forwarded-header",
|
|
80
|
+
};
|
|
81
|
+
}
|
|
72
82
|
try {
|
|
73
83
|
await deps.ipAbuseProtection.checkBeforeAccess({
|
|
74
84
|
identity: {
|
|
@@ -1,109 +1,109 @@
|
|
|
1
|
-
import { log } from "../../observability/logger";
|
|
2
|
-
// API-key authentication guard for `/v1/*`: token resolution, scope check,
|
|
3
|
-
// client-router denylist, and the model-abuse ban gate.
|
|
4
|
-
import { Elysia } from "elysia";
|
|
5
|
-
import { GatewayError } from "../../protocol/gateway-error";
|
|
6
|
-
import type { ProxyRequestStateStore } from "../request/state";
|
|
7
|
-
import { fastPathname } from "../pathname";
|
|
8
|
-
import type { CartethyiaDatabase } from "../../persistence/db-handle";
|
|
9
|
-
import { requestToken, resolveApiKeyAuthorization, unknownApiKeyError } from "../../security/api-key-auth";
|
|
10
|
-
import {
|
|
11
|
-
deniedClientRouter,
|
|
12
|
-
detectClientRouter,
|
|
13
|
-
} from "../../security/client-router-fingerprint";
|
|
14
|
-
import { createAccessDecision } from "../../security/access-control";
|
|
15
|
-
|
|
16
|
-
/**
|
|
17
|
-
* Stateless Elysia `beforeHandle` gateway check: API-key authentication,
|
|
18
|
-
* wired into the middleware chain in `app.ts`.
|
|
19
|
-
*/
|
|
20
|
-
export function createApiKeyAuthenticationMiddleware(deps: {
|
|
21
|
-
readonly db: CartethyiaDatabase;
|
|
22
|
-
readonly stateStore: ProxyRequestStateStore;
|
|
23
|
-
/**
|
|
24
|
-
* Model-abuse ban gate. A banned IP or key is refused here, before the
|
|
25
|
-
* canonical parse and route preparation, and before `state.authorization` is
|
|
26
|
-
* assigned — so a banned caller's attempts produce no telemetry row and no
|
|
27
|
-
* console error, which is exactly the noise this layer exists to stop.
|
|
28
|
-
*/
|
|
29
|
-
/**
|
|
30
|
-
* Tenant invoke kill switch. Checked after the key resolves, because the
|
|
31
|
-
* tenant is a property of the key: the same gateway serves several tenants
|
|
32
|
-
* and one switching itself off must not stop the others.
|
|
33
|
-
*/
|
|
34
|
-
readonly isInvokeDisabled?: (tenantId: string) => Promise<boolean | undefined>;
|
|
35
|
-
}): Elysia {
|
|
36
|
-
// Single policy for every `/v1/*` gateway route: authenticated, scoped to
|
|
37
|
-
// `routing:invoke`, and tenant-bound (the snapshot's tenant is the only
|
|
38
|
-
// tenant this key may address). The check is inline rather than routed
|
|
39
|
-
// through a per-route policy table: Elysia's router already owns which
|
|
40
|
-
// paths exist, so a per-route table only added a second place to keep in
|
|
41
|
-
// sync.
|
|
42
|
-
return new Elysia()
|
|
43
|
-
.beforeHandle(async ({ request }) => {
|
|
44
|
-
const pathname = fastPathname(request.url);
|
|
45
|
-
if (!pathname.startsWith("/v1/")) return;
|
|
46
|
-
const token = requestToken(request.headers);
|
|
47
|
-
// A database failure here is not "the key is bad". Left to propagate it
|
|
48
|
-
// surfaced as a 500 whose log line carried the whole statement and its
|
|
49
|
-
// bound parameters — including the key hash — once per request. Under an
|
|
50
|
-
// overloaded database that is a credential leak written at request rate.
|
|
51
|
-
//
|
|
52
|
-
// It is also the wrong answer: the caller cannot fix it by sending a
|
|
53
|
-
// different key, so it is a capacity signal, not an auth one.
|
|
54
|
-
const authorization = await resolveApiKeyAuthorization(deps.db, token).catch((error: unknown) => {
|
|
55
|
-
// Only the error's identity is logged, never its message: a drizzle
|
|
56
|
-
// query error carries the full statement and its bound parameters, and
|
|
57
|
-
// on this path one of those parameters is the key hash.
|
|
58
|
-
// A sanitized stand-in, not the original: a drizzle query error's
|
|
59
|
-
// message is the statement plus its bound parameters, and one of those
|
|
60
|
-
// parameters is the key hash.
|
|
61
|
-
const safe = new Error("[redacted: credential-store query failed]");
|
|
62
|
-
safe.name = error instanceof Error ? error.name : "Error";
|
|
63
|
-
log.error("[auth] key lookup failed; treating as unavailable", safe);
|
|
64
|
-
throw new GatewayError(
|
|
65
|
-
"accounts_unavailable",
|
|
66
|
-
503,
|
|
67
|
-
"The gateway could not verify this API key because its credential store is unavailable. Retry shortly.",
|
|
68
|
-
{},
|
|
69
|
-
"cartethyia",
|
|
70
|
-
);
|
|
71
|
-
});
|
|
72
|
-
if (!authorization) throw unknownApiKeyError();
|
|
73
|
-
const decision = createAccessDecision({
|
|
74
|
-
id: authorization.id,
|
|
75
|
-
tenantId: authorization.tenantId,
|
|
76
|
-
scopes: authorization.scopes,
|
|
77
|
-
});
|
|
78
|
-
if (!decision.scopes.includes("routing:invoke"))
|
|
79
|
-
throw new GatewayError("invalid_request", 403, "This API key is not allowed to invoke routing. Grant it the routing:invoke scope, or use a key that has it.");
|
|
80
|
-
// Kill switch: refuse before any upstream is touched. Distinct from the
|
|
81
|
-
// scope check above, which is a property of the key — this is the tenant
|
|
82
|
-
// having stopped all traffic, and it applies to every key the tenant owns.
|
|
83
|
-
// 503 rather than 403: nothing about the request is invalid, and the
|
|
84
|
-
// caller should retry later instead of "fixing" a request that is fine.
|
|
85
|
-
if ((await deps.isInvokeDisabled?.(authorization.tenantId)) === true)
|
|
86
|
-
throw new GatewayError(
|
|
87
|
-
"platform_unavailable",
|
|
88
|
-
503,
|
|
89
|
-
"This gateway has invoke switched off. Nothing upstream is being dialed while the switch is off; turn it back on from the dashboard overview to resume routing.",
|
|
90
|
-
);
|
|
91
|
-
// A key may refuse a downstream router it has been resold to. This is a
|
|
92
|
-
// policy check on the resolved key, so it belongs with the other
|
|
93
|
-
// authorization decisions rather than in routing: the request is refused
|
|
94
|
-
// before a route is planned or an upstream is touched.
|
|
95
|
-
const denied = deniedClientRouter(
|
|
96
|
-
authorization.snapshot.client_router_denylist,
|
|
97
|
-
detectClientRouter({ headers: request.headers }),
|
|
98
|
-
);
|
|
99
|
-
if (denied !== null)
|
|
100
|
-
throw new GatewayError(
|
|
101
|
-
"client_router_denied",
|
|
102
|
-
403,
|
|
103
|
-
"No API invocation access for this client.",
|
|
104
|
-
{ reason: "client_router_denied", clientRouter: denied },
|
|
105
|
-
);
|
|
106
|
-
deps.stateStore.require(request).authorization = authorization;
|
|
107
|
-
})
|
|
108
|
-
.as("plugin");
|
|
109
|
-
}
|
|
1
|
+
import { log } from "../../observability/logger";
|
|
2
|
+
// API-key authentication guard for `/v1/*`: token resolution, scope check,
|
|
3
|
+
// client-router denylist, and the model-abuse ban gate.
|
|
4
|
+
import { Elysia } from "elysia";
|
|
5
|
+
import { GatewayError } from "../../protocol/gateway-error";
|
|
6
|
+
import type { ProxyRequestStateStore } from "../request/state";
|
|
7
|
+
import { fastPathname } from "../pathname";
|
|
8
|
+
import type { CartethyiaDatabase } from "../../persistence/db-handle";
|
|
9
|
+
import { requestToken, resolveApiKeyAuthorization, unknownApiKeyError } from "../../security/api-key-auth";
|
|
10
|
+
import {
|
|
11
|
+
deniedClientRouter,
|
|
12
|
+
detectClientRouter,
|
|
13
|
+
} from "../../security/client-router-fingerprint";
|
|
14
|
+
import { createAccessDecision } from "../../security/access-control";
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Stateless Elysia `beforeHandle` gateway check: API-key authentication,
|
|
18
|
+
* wired into the middleware chain in `app.ts`.
|
|
19
|
+
*/
|
|
20
|
+
export function createApiKeyAuthenticationMiddleware(deps: {
|
|
21
|
+
readonly db: CartethyiaDatabase;
|
|
22
|
+
readonly stateStore: ProxyRequestStateStore;
|
|
23
|
+
/**
|
|
24
|
+
* Model-abuse ban gate. A banned IP or key is refused here, before the
|
|
25
|
+
* canonical parse and route preparation, and before `state.authorization` is
|
|
26
|
+
* assigned — so a banned caller's attempts produce no telemetry row and no
|
|
27
|
+
* console error, which is exactly the noise this layer exists to stop.
|
|
28
|
+
*/
|
|
29
|
+
/**
|
|
30
|
+
* Tenant invoke kill switch. Checked after the key resolves, because the
|
|
31
|
+
* tenant is a property of the key: the same gateway serves several tenants
|
|
32
|
+
* and one switching itself off must not stop the others.
|
|
33
|
+
*/
|
|
34
|
+
readonly isInvokeDisabled?: (tenantId: string) => Promise<boolean | undefined>;
|
|
35
|
+
}): Elysia {
|
|
36
|
+
// Single policy for every `/v1/*` gateway route: authenticated, scoped to
|
|
37
|
+
// `routing:invoke`, and tenant-bound (the snapshot's tenant is the only
|
|
38
|
+
// tenant this key may address). The check is inline rather than routed
|
|
39
|
+
// through a per-route policy table: Elysia's router already owns which
|
|
40
|
+
// paths exist, so a per-route table only added a second place to keep in
|
|
41
|
+
// sync.
|
|
42
|
+
return new Elysia()
|
|
43
|
+
.beforeHandle(async ({ request }) => {
|
|
44
|
+
const pathname = fastPathname(request.url);
|
|
45
|
+
if (!pathname.startsWith("/v1/")) return;
|
|
46
|
+
const token = requestToken(request.headers);
|
|
47
|
+
// A database failure here is not "the key is bad". Left to propagate it
|
|
48
|
+
// surfaced as a 500 whose log line carried the whole statement and its
|
|
49
|
+
// bound parameters — including the key hash — once per request. Under an
|
|
50
|
+
// overloaded database that is a credential leak written at request rate.
|
|
51
|
+
//
|
|
52
|
+
// It is also the wrong answer: the caller cannot fix it by sending a
|
|
53
|
+
// different key, so it is a capacity signal, not an auth one.
|
|
54
|
+
const authorization = await resolveApiKeyAuthorization(deps.db, token).catch((error: unknown) => {
|
|
55
|
+
// Only the error's identity is logged, never its message: a drizzle
|
|
56
|
+
// query error carries the full statement and its bound parameters, and
|
|
57
|
+
// on this path one of those parameters is the key hash.
|
|
58
|
+
// A sanitized stand-in, not the original: a drizzle query error's
|
|
59
|
+
// message is the statement plus its bound parameters, and one of those
|
|
60
|
+
// parameters is the key hash.
|
|
61
|
+
const safe = new Error("[redacted: credential-store query failed]");
|
|
62
|
+
safe.name = error instanceof Error ? error.name : "Error";
|
|
63
|
+
log.error("[auth] key lookup failed; treating as unavailable", safe);
|
|
64
|
+
throw new GatewayError(
|
|
65
|
+
"accounts_unavailable",
|
|
66
|
+
503,
|
|
67
|
+
"The gateway could not verify this API key because its credential store is unavailable. Retry shortly.",
|
|
68
|
+
{},
|
|
69
|
+
"cartethyia",
|
|
70
|
+
);
|
|
71
|
+
});
|
|
72
|
+
if (!authorization) throw unknownApiKeyError();
|
|
73
|
+
const decision = createAccessDecision({
|
|
74
|
+
id: authorization.id,
|
|
75
|
+
tenantId: authorization.tenantId,
|
|
76
|
+
scopes: authorization.scopes,
|
|
77
|
+
});
|
|
78
|
+
if (!decision.scopes.includes("routing:invoke"))
|
|
79
|
+
throw new GatewayError("invalid_request", 403, "This API key is not allowed to invoke routing. Grant it the routing:invoke scope, or use a key that has it.");
|
|
80
|
+
// Kill switch: refuse before any upstream is touched. Distinct from the
|
|
81
|
+
// scope check above, which is a property of the key — this is the tenant
|
|
82
|
+
// having stopped all traffic, and it applies to every key the tenant owns.
|
|
83
|
+
// 503 rather than 403: nothing about the request is invalid, and the
|
|
84
|
+
// caller should retry later instead of "fixing" a request that is fine.
|
|
85
|
+
if ((await deps.isInvokeDisabled?.(authorization.tenantId)) === true)
|
|
86
|
+
throw new GatewayError(
|
|
87
|
+
"platform_unavailable",
|
|
88
|
+
503,
|
|
89
|
+
"This gateway has invoke switched off. Nothing upstream is being dialed while the switch is off; turn it back on from the dashboard overview to resume routing.",
|
|
90
|
+
);
|
|
91
|
+
// A key may refuse a downstream router it has been resold to. This is a
|
|
92
|
+
// policy check on the resolved key, so it belongs with the other
|
|
93
|
+
// authorization decisions rather than in routing: the request is refused
|
|
94
|
+
// before a route is planned or an upstream is touched.
|
|
95
|
+
const denied = deniedClientRouter(
|
|
96
|
+
authorization.snapshot.client_router_denylist,
|
|
97
|
+
detectClientRouter({ headers: request.headers }),
|
|
98
|
+
);
|
|
99
|
+
if (denied !== null)
|
|
100
|
+
throw new GatewayError(
|
|
101
|
+
"client_router_denied",
|
|
102
|
+
403,
|
|
103
|
+
"No API invocation access for this client.",
|
|
104
|
+
{ reason: "client_router_denied", clientRouter: denied },
|
|
105
|
+
);
|
|
106
|
+
deps.stateStore.require(request).authorization = authorization;
|
|
107
|
+
})
|
|
108
|
+
.as("plugin");
|
|
109
|
+
}
|
|
@@ -7,7 +7,7 @@ import type { TelemetryBatchBuffer, TelemetryEventInput } from "../../observabil
|
|
|
7
7
|
import type { TelemetryPayloadCapture } from "../../observability/payload-capture";
|
|
8
8
|
import { captureEarlyRejectionPayload } from "./early-rejection-capture";
|
|
9
9
|
import type { CartethyiaDatabase } from "../../persistence/db-handle";
|
|
10
|
-
import { reasoningEffortFromIntent } from "../../
|
|
10
|
+
import { reasoningEffortFromIntent } from "../../protocol/reasoning";
|
|
11
11
|
import { metrics } from "../../observability/metrics";
|
|
12
12
|
import { computeTokensPerSec } from "../../observability/token-speed";
|
|
13
13
|
import { pushStructuredConsoleLog } from "../../observability/log-ring";
|