cartethyia 2.7.0 → 2.7.2
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-XaUmiBrC.js} +1 -1
- package/dist/dashboard/assets/BannedPage-XaUmiBrC.js.gz +0 -0
- package/dist/dashboard/assets/{CliToolDetailPage-DdBggJS9.js → CliToolDetailPage-CqD7QiLM.js} +1 -1
- package/dist/dashboard/assets/CliToolDetailPage-CqD7QiLM.js.gz +0 -0
- package/dist/dashboard/assets/{CliToolsPage-aN1iXhhP.js → CliToolsPage-0XUo4UMn.js} +1 -1
- package/dist/dashboard/assets/CliToolsPage-0XUo4UMn.js.gz +0 -0
- package/dist/dashboard/assets/{CombosPage-DE-F1nxZ.js → CombosPage-CXGS_Ct-.js} +1 -1
- package/dist/dashboard/assets/CombosPage-CXGS_Ct-.js.gz +0 -0
- package/dist/dashboard/assets/ConsoleLogPage-DGnGNYnM.js +1 -0
- package/dist/dashboard/assets/ConsoleLogPage-DGnGNYnM.js.gz +0 -0
- package/dist/dashboard/assets/{CustomizationPage-3CUxMy2X.js → CustomizationPage-DAG5J9EY.js} +1 -1
- package/dist/dashboard/assets/CustomizationPage-DAG5J9EY.js.gz +0 -0
- package/dist/dashboard/assets/HealthEventsModal-BKtAPYVF.js +1 -0
- package/dist/dashboard/assets/HealthEventsModal-BKtAPYVF.js.gz +0 -0
- package/dist/dashboard/assets/LoginPage-BdmRLnq2.js +1 -0
- package/dist/dashboard/assets/LoginPage-BdmRLnq2.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-CqMxibJB.js +1 -0
- package/dist/dashboard/assets/OverviewPage-CqMxibJB.js.gz +0 -0
- package/dist/dashboard/assets/ProviderDetailPage-CqMh2NRX.js +4 -0
- package/dist/dashboard/assets/ProviderDetailPage-CqMh2NRX.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-B9uWu4Nz.js +1 -0
- package/dist/dashboard/assets/ProvidersPage-B9uWu4Nz.js.gz +0 -0
- package/dist/dashboard/assets/ProxyPage-BoWOhpDO.js +9 -0
- package/dist/dashboard/assets/ProxyPage-BoWOhpDO.js.gz +0 -0
- package/dist/dashboard/assets/{QuotaPage-BjldQwIU.js → QuotaPage-7P2n--Fv.js} +1 -1
- package/dist/dashboard/assets/QuotaPage-7P2n--Fv.js.gz +0 -0
- package/dist/dashboard/assets/{SettingsPage-DUIoLRxk.js → SettingsPage-CxZ1fTkN.js} +1 -1
- package/dist/dashboard/assets/SettingsPage-CxZ1fTkN.js.gz +0 -0
- package/dist/dashboard/assets/SetupPage-Co12Zzaw.js +1 -0
- package/dist/dashboard/assets/SetupPage-Co12Zzaw.js.gz +0 -0
- package/dist/dashboard/assets/{SortableList-fI7JkdIB.js → SortableList-Dqf4zpS-.js} +1 -1
- package/dist/dashboard/assets/SortableList-Dqf4zpS-.js.gz +0 -0
- package/dist/dashboard/assets/{StudioPage-CHVZqskZ.js → StudioPage-Byj2lAEm.js} +3 -3
- package/dist/dashboard/assets/StudioPage-Byj2lAEm.js.gz +0 -0
- package/dist/dashboard/assets/UsagePage-CTkLAuRc.js +56 -0
- package/dist/dashboard/assets/UsagePage-CTkLAuRc.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-BJfsVMvA.js → entry-Bqy8RFvM.js} +1 -1
- package/dist/dashboard/assets/entry-Bqy8RFvM.js.gz +0 -0
- package/dist/dashboard/assets/{entry-CWBY5A0f.js → entry-ClCMCWWQ.js} +3 -3
- package/dist/dashboard/assets/entry-ClCMCWWQ.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-CWWZ-uCZ.js +1 -0
- package/dist/dashboard/assets/formatters-CWWZ-uCZ.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-DFVFrnWW.js} +2 -2
- package/dist/dashboard/assets/index-DFVFrnWW.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-DBNVLdUu.js} +1 -1
- package/dist/dashboard/assets/select-DBNVLdUu.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/cli-tools/contracts.ts +10 -4
- 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 +124 -208
- package/src/network/pool/agent.ts +15 -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 +22 -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/ollamacloud-models.ts +50 -0
- 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-oauth.ts +12 -1
- package/src/providers/integrations/qoder/qoder-realm.ts +20 -0
- package/src/providers/integrations/qoder/qoder-text-tools.ts +277 -0
- package/src/providers/integrations/qoder/qoder.ts +315 -400
- 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 +1684 -0
- package/src/providers/operations/account-health-service.ts +27 -1503
- package/src/providers/operations/account-reset-service.ts +2 -0
- package/src/providers/operations/provider-catalog-service.ts +11 -1
- package/src/providers/provider-contracts.ts +7 -4
- 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/security/cli-client-fingerprint.ts +9 -2
- 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 +9 -2
- 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 +92 -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 +75 -43
- package/src/transport/routing/router.ts +30 -2
- package/src/transport/surface/messages/parse.ts +1 -1
- package/src/transport/surface/responses/encode.ts +2 -3
- package/src/transport/surface/responses/parse.ts +49 -7
- package/src/types/undici.d.ts +33 -0
- 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,27 @@
|
|
|
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
|
-
|
|
5
|
-
|
|
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
|
-
}
|
|
4
|
+
import { parseUpstreamBackoff } from "../protocol/upstream-parsing";
|
|
5
|
+
import { resolveAccountQuotaCooldownMs } from "../config/cooldowns";
|
|
19
6
|
|
|
20
7
|
/**
|
|
21
|
-
*
|
|
8
|
+
* How much of the upstream's error body is kept and surfaced.
|
|
22
9
|
*
|
|
23
|
-
*
|
|
24
|
-
*
|
|
25
|
-
*
|
|
26
|
-
*
|
|
27
|
-
*
|
|
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.
|
|
10
|
+
* 500 bytes cut the CodeBuddy envelope mid-`extError`: the operator saw
|
|
11
|
+
* "request illegal" with none of the `displayMsg` prose that names the actual
|
|
12
|
+
* condition — "prompt is too long: 1048788 tokens > 1048576 maximum" — so a
|
|
13
|
+
* context overflow was indistinguishable from a policy refusal. The envelope
|
|
14
|
+
* runs a few KB; 4000 keeps the whole thing while bounding a hostile body.
|
|
57
15
|
*/
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
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
|
-
/**
|
|
86
|
-
* One `amount unit` pair of a duration phrase: `4h`, `13m`, `2 hours`, `30s`.
|
|
87
|
-
*
|
|
88
|
-
* The unit ends on `(?![a-z])`, not `\b`: a compact compound like `2h30m` puts
|
|
89
|
-
* a digit straight after `h`, which is a word character, so a `\b` would refuse
|
|
90
|
-
* to end the pair and the whole phrase would parse as null. `m(?!s|o)` is what
|
|
91
|
-
* stops a bare `m` from swallowing `ms` (milliseconds) or `mo` (months),
|
|
92
|
-
* neither of which is a reset duration.
|
|
93
|
-
*/
|
|
94
|
-
const DURATION_PAIR_SOURCE = String.raw`(\d+(?:\.\d+)?)[\s-]*(hours?|hrs?|h|minutes?|mins?|m(?!s|o)|seconds?|secs?|s)(?![a-z])`;
|
|
95
|
-
|
|
96
|
-
function durationUnitMs(unit: string): number {
|
|
97
|
-
const first = unit.toLowerCase()[0];
|
|
98
|
-
return first === "h" ? 3600_000 : first === "m" ? 60_000 : 1_000;
|
|
99
|
-
}
|
|
100
|
-
|
|
101
|
-
/**
|
|
102
|
-
* Sums the `amount unit` pairs at the head of a duration phrase.
|
|
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
|
-
}
|
|
16
|
+
const MAX_UPSTREAM_ERROR_BYTES = 4000;
|
|
17
|
+
export {
|
|
18
|
+
parseRetryAfter,
|
|
19
|
+
parseRateLimitReset,
|
|
20
|
+
parseResetAfterMs,
|
|
21
|
+
parseUpstreamBackoff,
|
|
22
|
+
parseProviderResetDuration,
|
|
23
|
+
parseAbsoluteResetTimestamp,
|
|
24
|
+
} from "../protocol/upstream-parsing";
|
|
182
25
|
/** Extracts a bounded, newline-free provider message from any error envelope. */
|
|
183
26
|
export function extractUpstreamMessage(body: unknown): string {
|
|
184
27
|
let raw = "";
|
|
@@ -390,6 +233,31 @@ export function classifyUpstreamFailure(error: unknown): UpstreamFailurePolicy {
|
|
|
390
233
|
// DNS failure. Failing the client request instead of failing over is the wrong
|
|
391
234
|
// outcome for a whole class the table already names as platform-wide.
|
|
392
235
|
const statusCode = error.status;
|
|
236
|
+
// Qoder answers an exhausted quota by closing the stream with an upstream 499
|
|
237
|
+
// (nginx client-close style). That is a refusal from the upstream — the
|
|
238
|
+
// gateway's own aborts are `transport_closed`, produced locally, and never
|
|
239
|
+
// carry a qoder providerId — so a 499 scoped to qoder is quota, not a
|
|
240
|
+
// malformed request. Without this it fell through to `invalid_request`
|
|
241
|
+
// (non-retryable, no cooldown) and the account was re-dialled on every
|
|
242
|
+
// request inside the exhausted window until a late 429 finally classified it.
|
|
243
|
+
if (
|
|
244
|
+
error.origin === "upstream" &&
|
|
245
|
+
statusCode === 499 &&
|
|
246
|
+
(providerId === "qoder" || providerId === "qodercn")
|
|
247
|
+
) {
|
|
248
|
+
const cooldownMs = resolveAccountQuotaCooldownMs();
|
|
249
|
+
return {
|
|
250
|
+
retryable: true,
|
|
251
|
+
mutatesAccount: true,
|
|
252
|
+
category: "quota_exceeded",
|
|
253
|
+
scope: "account",
|
|
254
|
+
origin: error.origin,
|
|
255
|
+
...(providerCode ? { providerCode } : {}),
|
|
256
|
+
...(providerId ? { providerId } : {}),
|
|
257
|
+
cooldownMs,
|
|
258
|
+
statusCode,
|
|
259
|
+
};
|
|
260
|
+
}
|
|
393
261
|
const retryableByStatus =
|
|
394
262
|
statusCode === 401 ||
|
|
395
263
|
statusCode === 403 ||
|
|
@@ -544,7 +412,27 @@ export interface TerminalOutcome {
|
|
|
544
412
|
readonly status: "cancelled" | "failed";
|
|
545
413
|
readonly errorCategory: GatewayErrorCode | "unknown_error";
|
|
546
414
|
readonly errorOrigin: GatewayErrorOrigin;
|
|
547
|
-
|
|
415
|
+
/**
|
|
416
|
+
* The HTTP status the row should carry. `undefined` means the caller has a
|
|
417
|
+
* better one (the wire status of an actual response) and should keep it;
|
|
418
|
+
* a raw error has none, and the caller's own fallback was 500 — which
|
|
419
|
+
* read as a gateway fault for what was an upstream transport drop.
|
|
420
|
+
*/
|
|
421
|
+
readonly httpStatus?: number;
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
/** The HTTP status each classifier-derived category carries when no wire response named one. */
|
|
425
|
+
const CATEGORY_HTTP_STATUS: Partial<Record<GatewayErrorCode | "unknown_error", number>> = {
|
|
426
|
+
// The upstream's transport died mid-response: a bad gateway, not a fault
|
|
427
|
+
// in this process.
|
|
428
|
+
transport_unavailable: 502,
|
|
429
|
+
platform_unavailable: 502,
|
|
430
|
+
deadline_exceeded: 504,
|
|
431
|
+
transport_closed: 499,
|
|
432
|
+
shutting_down: 503,
|
|
433
|
+
overloaded: 529,
|
|
434
|
+
unknown_error: 500,
|
|
435
|
+
};
|
|
548
436
|
|
|
549
437
|
/**
|
|
550
438
|
* Derives a failed attempt's status, error category, and error origin **from
|
|
@@ -559,11 +447,24 @@ export interface TerminalOutcome {
|
|
|
559
447
|
* outcome carries the real code and origin.
|
|
560
448
|
*/
|
|
561
449
|
export function classifyTerminalOutcome(error: unknown, signal: AbortSignal): TerminalOutcome {
|
|
450
|
+
const category = classifyTerminalCategory(error, signal);
|
|
451
|
+
const gateway = error instanceof GatewayError ? error : signal.reason instanceof GatewayError ? signal.reason : undefined;
|
|
562
452
|
return {
|
|
563
453
|
status: isClientCancellation(error, signal) ? "cancelled" : "failed",
|
|
564
|
-
errorCategory:
|
|
565
|
-
//
|
|
566
|
-
|
|
454
|
+
errorCategory: category,
|
|
455
|
+
// The classifier owns the blame. An upstream socket drop arrives as a
|
|
456
|
+
// raw runtime error — no `GatewayError` wraps it — and calling that ours
|
|
457
|
+
// made the row accuse the gateway for an upstream failure.
|
|
458
|
+
errorOrigin: gateway
|
|
459
|
+
? gateway.origin
|
|
460
|
+
: category === "transport_unavailable"
|
|
461
|
+
? "upstream"
|
|
462
|
+
: "cartethyia",
|
|
463
|
+
// A `GatewayError` carries its own status; a raw error takes the
|
|
464
|
+
// category's, so an upstream drop is 502 rather than the 500 fallback.
|
|
465
|
+
...(gateway || CATEGORY_HTTP_STATUS[category] !== undefined
|
|
466
|
+
? { httpStatus: gateway ? gateway.status : CATEGORY_HTTP_STATUS[category] }
|
|
467
|
+
: {}),
|
|
567
468
|
};
|
|
568
469
|
}
|
|
569
470
|
|
|
@@ -584,6 +485,13 @@ const CONTEXT_LENGTH_CODES: ReadonlySet<string> = new Set([
|
|
|
584
485
|
"model_context_window_exceeded",
|
|
585
486
|
// InferHub's spelling for the same condition.
|
|
586
487
|
"context_too_long",
|
|
488
|
+
// Trae answers an overflow on its streaming surface with the bare numeric
|
|
489
|
+
// code `4026` and the prose "We're sorry, your context length has exceeded
|
|
490
|
+
// the maximum limit." — no HTTP status, no symbolic code (verified live
|
|
491
|
+
// 2026-10: `event:error data:{"code":4026,"message":"..."}`). Without it
|
|
492
|
+
// the frame fell to the 502 `platform_unavailable` fallback and the client
|
|
493
|
+
// never saw the 413 it compacts on.
|
|
494
|
+
"4026",
|
|
587
495
|
]);
|
|
588
496
|
|
|
589
497
|
/**
|
|
@@ -735,6 +643,12 @@ const QUOTA_ERROR_IDENTIFIERS: ReadonlySet<string> = new Set([
|
|
|
735
643
|
"usage_not_included",
|
|
736
644
|
"subscription:free-usage-exhausted",
|
|
737
645
|
"resource_exhausted",
|
|
646
|
+
// Qoder answers an exhausted daily billing allowance with a bare numeric
|
|
647
|
+
// code: `{"code":"110","message":"Billing daily count exceeded"}`. The 403
|
|
648
|
+
// wrapper made the old status-only fallback read it as a credential fault;
|
|
649
|
+
// it is a spend/limit verdict, never an auth one, so it must classify as
|
|
650
|
+
// quota (a cooldown) instead of `auth_invalidated`.
|
|
651
|
+
"110",
|
|
738
652
|
// InferHub publishes its own vocabulary in `error.code`. The two 403s below
|
|
739
653
|
// are deliberate absences: `account_blocked` means the account was blocked
|
|
740
654
|
// (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";
|