cartethyia 2.6.7 → 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 +1 -7
- package/README.md +25 -2
- package/dist/dashboard/assets/{BannedPage-Dskhyp0l.js → BannedPage-D-J75hBM.js} +1 -1
- package/dist/dashboard/assets/BannedPage-D-J75hBM.js.gz +0 -0
- package/dist/dashboard/assets/{CliToolDetailPage--SJhk7qQ.js → CliToolDetailPage-CVe6RaBH.js} +1 -1
- package/dist/dashboard/assets/CliToolDetailPage-CVe6RaBH.js.gz +0 -0
- package/dist/dashboard/assets/{CliToolsPage-C203VT-c.js → CliToolsPage-Dd8ZSamp.js} +1 -1
- package/dist/dashboard/assets/CliToolsPage-Dd8ZSamp.js.gz +0 -0
- package/dist/dashboard/assets/{CombosPage-BcS7kGG1.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-uujbBYwC.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-CA1kLs45.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-BueSWX5D.js → QuotaPage-ATpaxrbl.js} +1 -1
- package/dist/dashboard/assets/QuotaPage-ATpaxrbl.js.gz +0 -0
- package/dist/dashboard/assets/SettingsPage-CETophpx.js +1 -0
- 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-DrKeMcGN.js → SortableList-CAK-EN9z.js} +1 -1
- package/dist/dashboard/assets/SortableList-CAK-EN9z.js.gz +0 -0
- package/dist/dashboard/assets/{StudioPage-qjuYtccc.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-C23S9sgp.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-DU6qrMA_.js → assert-67xXH5rf.js} +1 -1
- package/dist/dashboard/assets/assert-67xXH5rf.js.gz +0 -0
- package/dist/dashboard/assets/{badge-Bkz_-RrM.js → badge-DC2L43Mj.js} +1 -1
- package/dist/dashboard/assets/badge-DC2L43Mj.js.gz +0 -0
- package/dist/dashboard/assets/{banner-C-7y3VKV.js → banner-BBVcb0J3.js} +1 -1
- package/dist/dashboard/assets/banner-BBVcb0J3.js.gz +0 -0
- package/dist/dashboard/assets/{card-BOPBuBzD.js → card-Cca1XC6G.js} +1 -1
- package/dist/dashboard/assets/card-Cca1XC6G.js.gz +0 -0
- package/dist/dashboard/assets/{cli-tools-BaFUuFFF.js → cli-tools-KvsWa1Jl.js} +1 -1
- package/dist/dashboard/assets/cli-tools-KvsWa1Jl.js.gz +0 -0
- package/dist/dashboard/assets/{clipboard-button-e-Mlk0FH.js → clipboard-button-DUzEbynq.js} +1 -1
- package/dist/dashboard/assets/clipboard-button-DUzEbynq.js.gz +0 -0
- package/dist/dashboard/assets/{dialog-Iec_ywbD.js → dialog-DFvWhwTa.js} +1 -1
- package/dist/dashboard/assets/dialog-DFvWhwTa.js.gz +0 -0
- package/dist/dashboard/assets/{drawer-Bs8MIIHc.js → drawer-CFM2sMX1.js} +1 -1
- package/dist/dashboard/assets/drawer-CFM2sMX1.js.gz +0 -0
- package/dist/dashboard/assets/{entry-DOddwoc0.js → entry-BLBLxX0h.js} +3 -3
- package/dist/dashboard/assets/entry-BLBLxX0h.js.gz +0 -0
- package/dist/dashboard/assets/{entry-DcobaKFu.js → entry-Bqy8RFvM.js} +1 -1
- package/dist/dashboard/assets/entry-Bqy8RFvM.js.gz +0 -0
- package/dist/dashboard/assets/{entry-BRszm4_Z.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-BuDsrsPn.js → github-badge-C9e8Ts7e.js} +1 -1
- package/dist/dashboard/assets/github-badge-C9e8Ts7e.js.gz +0 -0
- package/dist/dashboard/assets/{index-BeLRhsmt.js → index-D6QzEC_n.js} +2 -2
- package/dist/dashboard/assets/index-D6QzEC_n.js.gz +0 -0
- package/dist/dashboard/assets/{inline-DToMICwl.js → inline-BFUcvllj.js} +1 -1
- package/dist/dashboard/assets/inline-BFUcvllj.js.gz +0 -0
- package/dist/dashboard/assets/{input-WlyHWeG2.js → input-C_ILAvBb.js} +1 -1
- package/dist/dashboard/assets/input-C_ILAvBb.js.gz +0 -0
- package/dist/dashboard/assets/{layout-DaJ5baJr.js → layout-CBIyT6Rt.js} +1 -1
- package/dist/dashboard/assets/layout-CBIyT6Rt.js.gz +0 -0
- package/dist/dashboard/assets/{list-row-B3oSK13z.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-ta6GMb-8.js → live-D0mm--ME.js} +1 -1
- package/dist/dashboard/assets/live-D0mm--ME.js.gz +0 -0
- package/dist/dashboard/assets/minimize-2-D-MN8W8a.js +1 -0
- package/dist/dashboard/assets/minimize-2-D-MN8W8a.js.gz +0 -0
- package/dist/dashboard/assets/{mutation-g_fduiNw.js → mutation-sMD965oO.js} +1 -1
- package/dist/dashboard/assets/mutation-sMD965oO.js.gz +0 -0
- package/dist/dashboard/assets/{numbers-Dulqgys5.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-UETqfytp.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-BPUo-W8d.js → routing-BnIpi1IJ.js} +1 -1
- package/dist/dashboard/assets/routing-BnIpi1IJ.js.gz +0 -0
- package/dist/dashboard/assets/{select-BfZ3SCTM.js → select-Cj5Nw43g.js} +1 -1
- package/dist/dashboard/assets/select-Cj5Nw43g.js.gz +0 -0
- package/dist/dashboard/assets/settings-BAFhDgX8.js +1 -0
- package/dist/dashboard/assets/settings-BAFhDgX8.js.gz +0 -0
- package/dist/dashboard/assets/{stack-B1w5BM0u.js → stack-EMrOMt4I.js} +1 -1
- package/dist/dashboard/assets/stack-EMrOMt4I.js.gz +0 -0
- package/dist/dashboard/assets/{state-DhZBfWgx.js → state-CWoo7ty5.js} +1 -1
- package/dist/dashboard/assets/state-CWoo7ty5.js.gz +0 -0
- package/dist/dashboard/assets/{switch-yD7D99Lg.js → switch-CCjT6IkF.js} +1 -1
- package/dist/dashboard/assets/switch-CCjT6IkF.js.gz +0 -0
- package/dist/dashboard/assets/{useMutation-EDFRkn1i.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 +55 -54
- 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 +1 -1
- package/scripts/cli/console-menu.ts +5 -2
- package/scripts/cli/tray.ts +21 -6
- package/src/boot/app.ts +24 -62
- package/src/boot/dependencies.ts +732 -750
- package/src/config/cooldowns.ts +16 -0
- package/src/config/providers.ts +13 -1
- package/src/config/resources.ts +13 -80
- package/src/config/security.ts +2 -38
- package/src/config/spec.ts +16 -23
- package/src/console/console-router.ts +7 -7
- package/src/console/domain-registration.ts +19 -12
- 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/remote-support/contracts.ts +3 -1
- package/src/console/remote-support/service.ts +115 -6
- 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 +16 -2
- 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 +15 -5
- package/src/observability/runtime-metrics.ts +30 -15
- 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 +0 -1
- 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/commandcode/commandcode.ts +134 -104
- package/src/providers/integrations/commandcode/discovery.ts +6 -5
- 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 -884
- 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/security/abuse.ts +17 -0
- 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 +0 -18
- package/src/transport/guards/error-lifecycle.ts +1 -1
- package/src/transport/guards/pipeline.ts +0 -6
- package/src/transport/guards/request-context.ts +18 -91
- package/src/transport/request/degradation.ts +20 -1
- package/src/transport/request/inflight.ts +4 -95
- 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-Dskhyp0l.js.gz +0 -0
- package/dist/dashboard/assets/CliToolDetailPage--SJhk7qQ.js.gz +0 -0
- package/dist/dashboard/assets/CliToolsPage-C203VT-c.js.gz +0 -0
- package/dist/dashboard/assets/CombosPage-BcS7kGG1.js.gz +0 -0
- package/dist/dashboard/assets/ConsoleLogPage-ChmiSLwO.js +0 -1
- package/dist/dashboard/assets/ConsoleLogPage-ChmiSLwO.js.gz +0 -0
- package/dist/dashboard/assets/CustomizationPage-uujbBYwC.js.gz +0 -0
- package/dist/dashboard/assets/HealthEventsModal-fKAWd-OQ.js +0 -1
- package/dist/dashboard/assets/HealthEventsModal-fKAWd-OQ.js.gz +0 -0
- package/dist/dashboard/assets/LoginPage-DoOUthbG.js +0 -1
- package/dist/dashboard/assets/LoginPage-DoOUthbG.js.gz +0 -0
- package/dist/dashboard/assets/ModelPicker-CA1kLs45.js.gz +0 -0
- package/dist/dashboard/assets/OverviewPage-C5Z-UBz1.js +0 -1
- package/dist/dashboard/assets/OverviewPage-C5Z-UBz1.js.gz +0 -0
- package/dist/dashboard/assets/ProviderDetailPage-CFu1qGcT.js +0 -3
- package/dist/dashboard/assets/ProviderDetailPage-CFu1qGcT.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-CnlJZbxF.js +0 -1
- package/dist/dashboard/assets/ProvidersPage-CnlJZbxF.js.gz +0 -0
- package/dist/dashboard/assets/ProxyPage-B9mBTweh.js +0 -7
- package/dist/dashboard/assets/ProxyPage-B9mBTweh.js.gz +0 -0
- package/dist/dashboard/assets/QuotaPage-BueSWX5D.js.gz +0 -0
- package/dist/dashboard/assets/SettingsPage-B0x00MVY.js +0 -1
- package/dist/dashboard/assets/SettingsPage-B0x00MVY.js.gz +0 -0
- package/dist/dashboard/assets/SetupPage-DQ9oDRpi.js +0 -1
- package/dist/dashboard/assets/SetupPage-DQ9oDRpi.js.gz +0 -0
- package/dist/dashboard/assets/SortableList-DrKeMcGN.js.gz +0 -0
- package/dist/dashboard/assets/StudioPage-qjuYtccc.js.gz +0 -0
- package/dist/dashboard/assets/UsagePage-Be7aNviQ.js +0 -56
- package/dist/dashboard/assets/UsagePage-Be7aNviQ.js.gz +0 -0
- package/dist/dashboard/assets/api-keys-C23S9sgp.js.gz +0 -0
- package/dist/dashboard/assets/app-BUSiYL6F.css +0 -2
- package/dist/dashboard/assets/app-BUSiYL6F.css.gz +0 -0
- package/dist/dashboard/assets/assert-DU6qrMA_.js.gz +0 -0
- package/dist/dashboard/assets/badge-Bkz_-RrM.js.gz +0 -0
- package/dist/dashboard/assets/banner-C-7y3VKV.js.gz +0 -0
- package/dist/dashboard/assets/card-BOPBuBzD.js.gz +0 -0
- package/dist/dashboard/assets/cli-tools-BaFUuFFF.js.gz +0 -0
- package/dist/dashboard/assets/clipboard-button-e-Mlk0FH.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-Iec_ywbD.js.gz +0 -0
- package/dist/dashboard/assets/drawer-Bs8MIIHc.js.gz +0 -0
- package/dist/dashboard/assets/entry-BRszm4_Z.js.gz +0 -0
- package/dist/dashboard/assets/entry-DOddwoc0.js.gz +0 -0
- package/dist/dashboard/assets/entry-DcobaKFu.js.gz +0 -0
- package/dist/dashboard/assets/github-badge-BuDsrsPn.js.gz +0 -0
- package/dist/dashboard/assets/index-BeLRhsmt.js.gz +0 -0
- package/dist/dashboard/assets/inline-DToMICwl.js.gz +0 -0
- package/dist/dashboard/assets/input-WlyHWeG2.js.gz +0 -0
- package/dist/dashboard/assets/layout-DaJ5baJr.js.gz +0 -0
- package/dist/dashboard/assets/list-row-B3oSK13z.js.gz +0 -0
- package/dist/dashboard/assets/live-ta6GMb-8.js.gz +0 -0
- package/dist/dashboard/assets/model-bans-BmYTmbAk.js +0 -1
- package/dist/dashboard/assets/model-bans-BmYTmbAk.js.gz +0 -0
- package/dist/dashboard/assets/mutation-g_fduiNw.js.gz +0 -0
- package/dist/dashboard/assets/numbers-Dulqgys5.js.gz +0 -0
- package/dist/dashboard/assets/query-keys-UETqfytp.js.gz +0 -0
- package/dist/dashboard/assets/routing-BPUo-W8d.js.gz +0 -0
- package/dist/dashboard/assets/select-BfZ3SCTM.js.gz +0 -0
- package/dist/dashboard/assets/settings--3hxSyry.js +0 -1
- package/dist/dashboard/assets/settings--3hxSyry.js.gz +0 -0
- package/dist/dashboard/assets/stack-B1w5BM0u.js.gz +0 -0
- package/dist/dashboard/assets/state-DhZBfWgx.js.gz +0 -0
- package/dist/dashboard/assets/switch-yD7D99Lg.js.gz +0 -0
- package/dist/dashboard/assets/useMutation-EDFRkn1i.js.gz +0 -0
- package/src/console/admin/model-abuse/contracts.ts +0 -38
- package/src/console/admin/model-abuse/routes.ts +0 -97
- package/src/security/model-abuse.ts +0 -704
- package/src/transport/guards/concurrency-guard.ts +0 -125
- /package/dist/dashboard/assets/{app-MdVfwraB.js → app-BaWd0puM.js} +0 -0
- /package/dist/dashboard/assets/{app-MdVfwraB.js.gz → app-BaWd0puM.js.gz} +0 -0
|
@@ -28,6 +28,13 @@ export interface ProxyCooldownEntry {
|
|
|
28
28
|
export interface PoolRotation {
|
|
29
29
|
readonly key: string;
|
|
30
30
|
readonly rotateCount: number;
|
|
31
|
+
/**
|
|
32
|
+
* The strategy that produced this rotation request. `round_robin` serves
|
|
33
|
+
* `rotateCount` requests per pool before advancing; `random` scatters each
|
|
34
|
+
* selection across the eligible pools in a fresh random order. Absent means
|
|
35
|
+
* the default weighted least-loaded scan.
|
|
36
|
+
*/
|
|
37
|
+
readonly strategy?: "round_robin" | "random";
|
|
31
38
|
}
|
|
32
39
|
|
|
33
40
|
export interface PoolUsageSnapshot {
|
|
@@ -10,6 +10,7 @@
|
|
|
10
10
|
import { redisEvalNumber, type RedisClient } from "../../persistence/redis";
|
|
11
11
|
import { resolveInflightTtlSeconds } from "../../config/timeouts";
|
|
12
12
|
import { log } from "../../observability/logger";
|
|
13
|
+
import { providerRateLimitIsIpScoped } from "../../providers/provider-metadata";
|
|
13
14
|
import {
|
|
14
15
|
COOLDOWN_CLEAR_SCRIPT,
|
|
15
16
|
COOLDOWN_FLAG_SCRIPT,
|
|
@@ -67,6 +68,21 @@ export function effectiveConcurrency(limit: number): number {
|
|
|
67
68
|
return limit;
|
|
68
69
|
}
|
|
69
70
|
|
|
71
|
+
/**
|
|
72
|
+
* In-place Fisher–Yates shuffle. The `random` pool strategy uses it to scatter
|
|
73
|
+
* each selection across the eligible pools instead of always scanning from a
|
|
74
|
+
* fixed (or cursor-driven) order, so a burst does not clump onto the first
|
|
75
|
+
* pool and burn it while siblings sit idle.
|
|
76
|
+
*/
|
|
77
|
+
function shuffleArray<T>(items: T[]): void {
|
|
78
|
+
for (let i = items.length - 1; i > 0; i -= 1) {
|
|
79
|
+
const j = Math.floor(Math.random() * (i + 1));
|
|
80
|
+
const swap = items[i]!;
|
|
81
|
+
items[i] = items[j]!;
|
|
82
|
+
items[j] = swap;
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
70
86
|
function normalizedWeight(weight: number | undefined): number {
|
|
71
87
|
if (weight === undefined) return DEFAULT_PROXY_WEIGHT;
|
|
72
88
|
if (!Number.isInteger(weight) || weight < 1 || weight > MAX_PROXY_WEIGHT) return 0;
|
|
@@ -257,6 +273,20 @@ export class NetworkPoolSelector {
|
|
|
257
273
|
return `${poolId}::${providerId.toLowerCase()}`;
|
|
258
274
|
}
|
|
259
275
|
|
|
276
|
+
/**
|
|
277
|
+
* Seeds one cooldown entry into the local map without touching Redis.
|
|
278
|
+
*
|
|
279
|
+
* Boot restore is the only writer: it replays the durable markers the
|
|
280
|
+
* previous process recorded (`network_pools.provider_cooldowns`, see
|
|
281
|
+
* `pool-cooldown-store.ts`) so dispatch starts remembering the egresses the
|
|
282
|
+
* upstream is still refusing. Anything whose window already passed is
|
|
283
|
+
* dropped by `restore…` before this is reached.
|
|
284
|
+
*/
|
|
285
|
+
hydrateProviderCooldown(entry: ProxyCooldownEntry): void {
|
|
286
|
+
enforceCooldownLimit(this.state.cooldowns);
|
|
287
|
+
this.state.cooldowns.set(this.cooldownKey(entry.poolId, entry.providerId), entry);
|
|
288
|
+
}
|
|
289
|
+
|
|
260
290
|
async flagProviderCooldown(
|
|
261
291
|
poolId: string,
|
|
262
292
|
providerId: string,
|
|
@@ -308,12 +338,32 @@ export class NetworkPoolSelector {
|
|
|
308
338
|
* The entry uses the same storage as a cooldown so every reader that already
|
|
309
339
|
* skips a cooling pool skips this one too; `reason` is what the console
|
|
310
340
|
* renders to tell an operator-blocked proxy from a throttled one.
|
|
341
|
+
*
|
|
342
|
+
* Refuses to retire an egress for a provider whose limits belong to a
|
|
343
|
+
* credential. An address-scoped verdict only exists for providers that count
|
|
344
|
+
* requests by egress (`ipScopedRateLimit` — opencode); for everyone else a
|
|
345
|
+
* refusal belongs to the account that was dialed, so retiring the pool would
|
|
346
|
+
* sideline an address that is still serving every sibling account. Callers
|
|
347
|
+
* keep their own guard, and this one is the backstop that makes the rule
|
|
348
|
+
* impossible to route around from a new call site.
|
|
311
349
|
*/
|
|
312
350
|
async flagProviderBlocked(
|
|
313
351
|
poolId: string,
|
|
314
352
|
providerId: string,
|
|
315
353
|
reason = "Upstream refused this egress address",
|
|
316
354
|
): Promise<ProxyCooldownEntry> {
|
|
355
|
+
if (!providerRateLimitIsIpScoped(providerId)) {
|
|
356
|
+
log.warn("[pool-selector] refused to block an egress for a non-IP-scoped provider", {
|
|
357
|
+
networkPoolId: poolId,
|
|
358
|
+
providerId,
|
|
359
|
+
});
|
|
360
|
+
return {
|
|
361
|
+
poolId,
|
|
362
|
+
providerId: providerId.toLowerCase(),
|
|
363
|
+
until: Date.now(),
|
|
364
|
+
reason,
|
|
365
|
+
};
|
|
366
|
+
}
|
|
317
367
|
const entry = await this.flagProviderCooldown(poolId, providerId, PROVIDER_BLOCK_MS, reason);
|
|
318
368
|
const blocked: ProxyCooldownEntry = { ...entry, blocked: true };
|
|
319
369
|
// Rewrite both copies with the flag: the cooldown write above is shared
|
|
@@ -455,6 +505,38 @@ export class NetworkPoolSelector {
|
|
|
455
505
|
await this.clearRedisCooldown(poolId, providerId);
|
|
456
506
|
}
|
|
457
507
|
|
|
508
|
+
/**
|
|
509
|
+
* Releases a *retired* egress only, leaving a cooldown intact.
|
|
510
|
+
*
|
|
511
|
+
* The two are one key, so `clearProviderCooldown` cannot be reused: it would
|
|
512
|
+
* also drop a live 429 cooldown and hand a spent address back to dispatch.
|
|
513
|
+
* This is the correction a 429 probe is entitled to make — the address is
|
|
514
|
+
* answering its own rate limiter, so it was never retired — and nothing more.
|
|
515
|
+
*
|
|
516
|
+
* No-op when the marker is a cooldown, and no-op when there is no marker.
|
|
517
|
+
*/
|
|
518
|
+
async clearProviderBlockedOnly(poolId: string, providerId: string): Promise<void> {
|
|
519
|
+
const key = this.cooldownKey(poolId, providerId);
|
|
520
|
+
const local = this.state.cooldowns.get(key);
|
|
521
|
+
if (local !== undefined) {
|
|
522
|
+
if (local.blocked !== true) return;
|
|
523
|
+
this.state.cooldowns.delete(key);
|
|
524
|
+
}
|
|
525
|
+
if (!this.redis) return;
|
|
526
|
+
try {
|
|
527
|
+
const raw = await this.redis.get(proxyCooldownKey(poolId, providerId));
|
|
528
|
+
const entry = raw ? parseCooldownEntry(raw) : undefined;
|
|
529
|
+
// A missing or unparseable marker says nothing about the address, and
|
|
530
|
+
// deleting a key we cannot read would drop a cooldown this is forbidden
|
|
531
|
+
// from touching. Only a marker that names itself a block is released.
|
|
532
|
+
if (entry?.blocked !== true) return;
|
|
533
|
+
await this.clearRedisCooldown(poolId, providerId);
|
|
534
|
+
} catch {
|
|
535
|
+
// The local copy is already corrected; the Redis mirror stays until its
|
|
536
|
+
// TTL or the next write.
|
|
537
|
+
}
|
|
538
|
+
}
|
|
539
|
+
|
|
458
540
|
/**
|
|
459
541
|
* Removes one cooldown marker and its index entry in one atomic step.
|
|
460
542
|
*
|
|
@@ -564,6 +646,26 @@ export class NetworkPoolSelector {
|
|
|
564
646
|
if (scored.length === 0) return undefined;
|
|
565
647
|
|
|
566
648
|
if (!this.redis) {
|
|
649
|
+
if (rotation?.strategy === "random") {
|
|
650
|
+
// Random scatter: shuffle the eligible pools and take the first one
|
|
651
|
+
// with a free slot, falling through like the ordered scans do.
|
|
652
|
+
shuffleArray(scored);
|
|
653
|
+
for (const candidate of scored) {
|
|
654
|
+
if (this.getInflight(candidate.poolId) >= candidate.capacity) continue;
|
|
655
|
+
const slot =
|
|
656
|
+
providerCapacity === undefined
|
|
657
|
+
? this.acquire(candidate.poolId, candidate.capacity)
|
|
658
|
+
: this.acquireProvider(
|
|
659
|
+
candidate.poolId,
|
|
660
|
+
providerId,
|
|
661
|
+
candidate.capacity,
|
|
662
|
+
providerCapacity,
|
|
663
|
+
);
|
|
664
|
+
if (!slot.acquired) continue;
|
|
665
|
+
return { poolId: candidate.poolId, release: slot.release };
|
|
666
|
+
}
|
|
667
|
+
return undefined;
|
|
668
|
+
}
|
|
567
669
|
if (rotation) {
|
|
568
670
|
// Round robin: scan from the tenant cursor; a pool that is full falls
|
|
569
671
|
// through to the next offset (failover). Only a successful admission
|
|
@@ -662,7 +764,13 @@ export class NetworkPoolSelector {
|
|
|
662
764
|
// Original (pre-sort) index = absolute rotation position and
|
|
663
765
|
// deterministic tie-breaking.
|
|
664
766
|
const absoluteIndex = new Map(withCounts.map((candidate, index) => [candidate.poolId, index]));
|
|
665
|
-
if (rotation) {
|
|
767
|
+
if (rotation?.strategy === "random") {
|
|
768
|
+
// Random scatter: shuffle before the admit loop, which still enforces
|
|
769
|
+
// capacity and fails over to the next pool exactly like the ordered
|
|
770
|
+
// scans — but the order each request tries is fresh, so a burst does
|
|
771
|
+
// not gang up on the first pools in a fixed order.
|
|
772
|
+
shuffleArray(withCounts);
|
|
773
|
+
} else if (rotation) {
|
|
666
774
|
// Round robin orders strictly by the tenant cursor; the CAS admit below
|
|
667
775
|
// still enforces capacity, so a full pool falls through to the next
|
|
668
776
|
// offset exactly like account failover.
|
|
@@ -715,7 +823,7 @@ export class NetworkPoolSelector {
|
|
|
715
823
|
// to the next pool, which is the whole point of the ceiling: the request
|
|
716
824
|
// moves to an egress that still has room for this provider.
|
|
717
825
|
if (result !== 1) continue;
|
|
718
|
-
if (rotation) {
|
|
826
|
+
if (rotation && rotation.strategy !== "random") {
|
|
719
827
|
advanceRotation(
|
|
720
828
|
this.state.rotationCursors,
|
|
721
829
|
rotation.key,
|
|
@@ -0,0 +1,252 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The durable mirror of the pool selector's provider cooldown state.
|
|
3
|
+
*
|
|
4
|
+
* The selector's own map is process-local, mirrored to Redis only when Redis
|
|
5
|
+
* exists — so the usual installation ran with the flags surviving just a
|
|
6
|
+
* restart, while the upstream's refusal of the egress did not. The result was
|
|
7
|
+
* the report that motivated this: no cooldown shown on the proxy page, and
|
|
8
|
+
* still-429 requests, because dispatch re-picked a spent egress it no longer
|
|
9
|
+
* remembered. `network_pools.provider_cooldowns` is that state's home between
|
|
10
|
+
* boots: dispatch flaggers write each marker, boot reads them all back.
|
|
11
|
+
*
|
|
12
|
+
* One entry per cooling provider, keyed by the lowercased provider id — the
|
|
13
|
+
* same normalization the selector applies to its in-memory key.
|
|
14
|
+
*/
|
|
15
|
+
import { randomUUID } from "node:crypto";
|
|
16
|
+
import { eq } from "drizzle-orm";
|
|
17
|
+
import type { CartethyiaDatabase } from "../persistence/db-handle";
|
|
18
|
+
import { networkPools, healthEvents } from "../persistence/schema";
|
|
19
|
+
import { log } from "../observability/logger";
|
|
20
|
+
import { providerRateLimitIsIpScoped } from "../providers/provider-metadata";
|
|
21
|
+
import { PROVIDER_BLOCK_MS, type ProxyCooldownEntry } from "./pool/selector";
|
|
22
|
+
|
|
23
|
+
type PoolProviderCooldownMap = NonNullable<
|
|
24
|
+
(typeof networkPools.$inferSelect)["providerCooldowns"]
|
|
25
|
+
>;
|
|
26
|
+
|
|
27
|
+
function readMarkerMap(
|
|
28
|
+
db: CartethyiaDatabase,
|
|
29
|
+
poolId: string,
|
|
30
|
+
): Promise<PoolProviderCooldownMap> {
|
|
31
|
+
return db
|
|
32
|
+
.select({ providerCooldowns: networkPools.providerCooldowns })
|
|
33
|
+
.from(networkPools)
|
|
34
|
+
.where(eq(networkPools.id, poolId))
|
|
35
|
+
.limit(1)
|
|
36
|
+
.then((rows) => rows[0]?.providerCooldowns ?? {});
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function writeMarkerMap(
|
|
40
|
+
db: CartethyiaDatabase,
|
|
41
|
+
poolId: string,
|
|
42
|
+
map: PoolProviderCooldownMap,
|
|
43
|
+
): Promise<unknown> {
|
|
44
|
+
return db.update(networkPools).set({ providerCooldowns: map }).where(eq(networkPools.id, poolId));
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function entryOf(marker: Omit<ProxyCooldownEntry, "poolId" | "providerId">): {
|
|
48
|
+
until: number;
|
|
49
|
+
reason: string;
|
|
50
|
+
blocked?: boolean;
|
|
51
|
+
} {
|
|
52
|
+
return marker.blocked === true
|
|
53
|
+
? { until: marker.until, reason: marker.reason, blocked: true }
|
|
54
|
+
: { until: marker.until, reason: marker.reason };
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Writes up (or replaces) one provider's marker on the pool row.
|
|
59
|
+
*
|
|
60
|
+
* Fill-in, not replace: an entry for another cooling provider must survive.
|
|
61
|
+
* Failures are logged and swallowed — the in-memory/Redis state stays
|
|
62
|
+
* conservative for the running process, and only the *next* restart would miss
|
|
63
|
+
* a flag this write failed to land.
|
|
64
|
+
*/
|
|
65
|
+
export async function upsertProviderCooldownMarker(
|
|
66
|
+
db: CartethyiaDatabase,
|
|
67
|
+
poolId: string,
|
|
68
|
+
providerId: string,
|
|
69
|
+
marker: { until: number; reason: string; blocked?: boolean },
|
|
70
|
+
): Promise<void> {
|
|
71
|
+
try {
|
|
72
|
+
const key = providerId.toLowerCase();
|
|
73
|
+
const map = await readMarkerMap(db, poolId);
|
|
74
|
+
map[key] = entryOf(marker);
|
|
75
|
+
await writeMarkerMap(db, poolId, map);
|
|
76
|
+
} catch (error) {
|
|
77
|
+
log.warn("pool provider cooldown mirror write failed", { networkPoolId: poolId, providerId, error: String(error) });
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Removes one provider's marker, optionally only when it is a *block*.
|
|
83
|
+
*
|
|
84
|
+
* `onlyBlocked` mirrors `clearProviderBlockedOnly`: a probe that answered 429
|
|
85
|
+
* may correct a retired claim but must not release a live cooldown, and the
|
|
86
|
+
* same rule applies to the durable copy.
|
|
87
|
+
*/
|
|
88
|
+
export async function dropProviderCooldownMarker(
|
|
89
|
+
db: CartethyiaDatabase,
|
|
90
|
+
poolId: string,
|
|
91
|
+
providerId: string,
|
|
92
|
+
onlyBlocked = false,
|
|
93
|
+
): Promise<void> {
|
|
94
|
+
try {
|
|
95
|
+
const key = providerId.toLowerCase();
|
|
96
|
+
const map = await readMarkerMap(db, poolId);
|
|
97
|
+
const current = map[key];
|
|
98
|
+
if (current === undefined) return;
|
|
99
|
+
if (onlyBlocked && current.blocked !== true) return;
|
|
100
|
+
delete map[key];
|
|
101
|
+
await writeMarkerMap(db, poolId, map);
|
|
102
|
+
} catch (error) {
|
|
103
|
+
log.warn("pool provider cooldown mirror clear failed", { networkPoolId: poolId, providerId, error: String(error) });
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Removes every provider's marker from one pool row (disable/replace paths).
|
|
109
|
+
*/
|
|
110
|
+
export async function dropAllProviderCooldownMarkers(
|
|
111
|
+
db: CartethyiaDatabase,
|
|
112
|
+
poolId: string,
|
|
113
|
+
): Promise<void> {
|
|
114
|
+
try {
|
|
115
|
+
const map = await readMarkerMap(db, poolId);
|
|
116
|
+
if (Object.keys(map).length === 0) return;
|
|
117
|
+
await writeMarkerMap(db, poolId, {});
|
|
118
|
+
} catch (error) {
|
|
119
|
+
log.warn("pool provider cooldown mirror purge failed", { networkPoolId: poolId, error: String(error) });
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Writes a probe-measured verdict the same way a dispatch-side event would be
|
|
125
|
+
* recorded: one mirror marker and one audit row.
|
|
126
|
+
*
|
|
127
|
+
* The probe is the only writer that can flag a cooldown from the control
|
|
128
|
+
* plane — its 429 is a fresh measurement of "this egress is out of allowance"
|
|
129
|
+
* (a cooldown marker), and its 403 is a fresh measurement of "this egress was
|
|
130
|
+
* retired" (a block marker). Without this, an operator who tested a
|
|
131
|
+
* rate-limited or retired pool saw the verdict once, refreshing lost it, and
|
|
132
|
+
* the activity panel showed nothing: the fact existed only in the remembered
|
|
133
|
+
* HTTP response.
|
|
134
|
+
*
|
|
135
|
+
* Not coalesced: a probe is operator-initiated and one row per click is the
|
|
136
|
+
* visible history the panel exists for.
|
|
137
|
+
*/
|
|
138
|
+
export async function recordProbeVerdict(
|
|
139
|
+
db: CartethyiaDatabase,
|
|
140
|
+
poolId: string,
|
|
141
|
+
providerId: string,
|
|
142
|
+
marker: { until: number; reason: string; blocked?: boolean },
|
|
143
|
+
): Promise<void> {
|
|
144
|
+
await upsertProviderCooldownMarker(db, poolId, providerId, marker);
|
|
145
|
+
try {
|
|
146
|
+
await db.insert(healthEvents).values({
|
|
147
|
+
id: randomUUID(),
|
|
148
|
+
entityKind: "pool",
|
|
149
|
+
networkPoolId: poolId,
|
|
150
|
+
fromStatus: null,
|
|
151
|
+
toStatus: "cooldown",
|
|
152
|
+
reason: `provider ${providerId}: ${marker.reason}`,
|
|
153
|
+
errorCategory: marker.blocked === true ? "egress_blocked" : "rate_limit_transient",
|
|
154
|
+
createdAt: new Date(),
|
|
155
|
+
});
|
|
156
|
+
} catch (error) {
|
|
157
|
+
log.warn("pool probe event failed", { networkPoolId: poolId, providerId, error: String(error) });
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* Reads every live marker back into the selector at boot.
|
|
163
|
+
*
|
|
164
|
+
* Only future-dated entries are restored: an expired marker describes an
|
|
165
|
+
* allowance that refilled while the process was down, and resurrecting it
|
|
166
|
+
* would sideline a working address. Repeat 429s re-insert markers, so nothing
|
|
167
|
+
* is lost by dropping the expired ones.
|
|
168
|
+
*/
|
|
169
|
+
export async function restoreProviderCooldownMarkers(
|
|
170
|
+
selector: { hydrateProviderCooldown(entry: ProxyCooldownEntry): void },
|
|
171
|
+
db: CartethyiaDatabase,
|
|
172
|
+
): Promise<number> {
|
|
173
|
+
const rows = await db
|
|
174
|
+
.select({ id: networkPools.id, providerCooldowns: networkPools.providerCooldowns })
|
|
175
|
+
.from(networkPools);
|
|
176
|
+
let restored = 0;
|
|
177
|
+
for (const row of rows) {
|
|
178
|
+
const map = row.providerCooldowns;
|
|
179
|
+
if (map === null || map === undefined) continue;
|
|
180
|
+
for (const [providerId, marker] of Object.entries(map)) {
|
|
181
|
+
if (Date.now() >= marker.until) continue;
|
|
182
|
+
selector.hydrateProviderCooldown({
|
|
183
|
+
poolId: row.id,
|
|
184
|
+
providerId,
|
|
185
|
+
until: marker.until,
|
|
186
|
+
reason: marker.reason,
|
|
187
|
+
...(marker.blocked === true ? { blocked: true } : {}),
|
|
188
|
+
});
|
|
189
|
+
restored += 1;
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
return restored;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/** The columns the probe-verdict reconciliation reads, mirroring the row's own read path. */
|
|
196
|
+
function probeEndpointOf(endpointConfig: unknown): string {
|
|
197
|
+
const raw = (endpointConfig ?? {}) as Record<string, unknown>;
|
|
198
|
+
return typeof raw["endpoint"] === "string" ? raw["endpoint"] : "";
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/**
|
|
202
|
+
* Re-enforces the stored probe verdicts at boot (new: replay, read-ahead).
|
|
203
|
+
*
|
|
204
|
+
* A stored `blocked` verdict is a *state*, not a decoration. If the marker the
|
|
205
|
+
* probe flagged failed to persist, or the verdict predated the marker column,
|
|
206
|
+
* the egress would return to rotation after restart and get refused again —
|
|
207
|
+
* dispatch spending a real request to rediscover a refusal the row already
|
|
208
|
+
* knew. So the boot read reconciles: a fresh (endpoint still matches) `blocked`
|
|
209
|
+
* verdict for an address-keyed provider becomes a block flag, even when the
|
|
210
|
+
* mirror has nothing (or has an older, cooler entry — the retirement claim is
|
|
211
|
+
* the sharper fact and wins).
|
|
212
|
+
*
|
|
213
|
+
* `throttled` is deliberately **not** replayed into a cooldown: the cooldown
|
|
214
|
+
* the probe flagged already carries the measured window, and blind-replaying
|
|
215
|
+
* one from a verdict that predates the restart could outlive the upstream's
|
|
216
|
+
* actual window.
|
|
217
|
+
*
|
|
218
|
+
* Returns how many verdicts were replayed.
|
|
219
|
+
*/
|
|
220
|
+
export async function restorePoolProbeVerdicts(
|
|
221
|
+
selector: { hydrateProviderCooldown(entry: ProxyCooldownEntry): void },
|
|
222
|
+
db: CartethyiaDatabase,
|
|
223
|
+
): Promise<number> {
|
|
224
|
+
const rows = await db
|
|
225
|
+
.select({
|
|
226
|
+
id: networkPools.id,
|
|
227
|
+
status: networkPools.status,
|
|
228
|
+
endpointConfig: networkPools.endpointConfig,
|
|
229
|
+
providerProbeProviderId: networkPools.providerProbeProviderId,
|
|
230
|
+
providerProbeStatus: networkPools.providerProbeStatus,
|
|
231
|
+
providerProbeReason: networkPools.providerProbeReason,
|
|
232
|
+
providerProbeEndpoint: networkPools.providerProbeEndpoint,
|
|
233
|
+
})
|
|
234
|
+
.from(networkPools);
|
|
235
|
+
let replayed = 0;
|
|
236
|
+
for (const row of rows) {
|
|
237
|
+
if (row.status !== "active") continue;
|
|
238
|
+
if (row.providerProbeStatus !== "blocked") continue;
|
|
239
|
+
if (row.providerProbeEndpoint !== probeEndpointOf(row.endpointConfig)) continue;
|
|
240
|
+
const providerId = row.providerProbeProviderId;
|
|
241
|
+
if (typeof providerId !== "string" || !providerRateLimitIsIpScoped(providerId)) continue;
|
|
242
|
+
selector.hydrateProviderCooldown({
|
|
243
|
+
poolId: row.id,
|
|
244
|
+
providerId: providerId.toLowerCase(),
|
|
245
|
+
until: Date.now() + PROVIDER_BLOCK_MS,
|
|
246
|
+
reason: row.providerProbeReason ?? "Upstream refused this egress address",
|
|
247
|
+
blocked: true,
|
|
248
|
+
});
|
|
249
|
+
replayed += 1;
|
|
250
|
+
}
|
|
251
|
+
return replayed;
|
|
252
|
+
}
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
1
2
|
import { healthEventBuffer } from "../persistence/health-event-flush";
|
|
2
3
|
import { and, desc, eq, inArray, isNotNull, lte, sql } from "drizzle-orm";
|
|
3
4
|
import type { CartethyiaDatabase } from "../persistence/db-handle";
|
|
@@ -140,7 +141,7 @@ export async function recordPoolDispatchOutcome(
|
|
|
140
141
|
createdAt: now,
|
|
141
142
|
};
|
|
142
143
|
if (buffer !== undefined) buffer.enqueue(row);
|
|
143
|
-
else await db.insert(healthEvents).values(row as never);
|
|
144
|
+
else await db.insert(healthEvents).values({ id: randomUUID(), ...row } as never);
|
|
144
145
|
}
|
|
145
146
|
if (healthChanged) {
|
|
146
147
|
publishPoolHealth({
|
|
@@ -198,6 +199,7 @@ export async function disablePoolForProxyHttpStatus(
|
|
|
198
199
|
if (!updated[0]) return false;
|
|
199
200
|
|
|
200
201
|
await db.insert(healthEvents).values({
|
|
202
|
+
id: randomUUID(),
|
|
201
203
|
entityKind: "pool",
|
|
202
204
|
networkPoolId: poolId,
|
|
203
205
|
fromStatus: pool.status,
|
|
@@ -226,7 +228,7 @@ export async function recoverNetworkPool(db: CartethyiaDatabase, tenantId: strin
|
|
|
226
228
|
if (!pool) return false;
|
|
227
229
|
const now = new Date();
|
|
228
230
|
await db.update(networkPools).set({ status: "active", consecutiveFailures: 0, cooldownUntil: null, lastRecoveredAt: now }).where(and(eq(networkPools.tenantId, tenantId), eq(networkPools.id, poolId)));
|
|
229
|
-
await db.insert(healthEvents).values({ entityKind: "pool", networkPoolId: poolId, fromStatus: pool.status, toStatus: "active", reason: "Manual operator recovery", errorCategory: null, createdAt: now });
|
|
231
|
+
await db.insert(healthEvents).values({ id: randomUUID(), entityKind: "pool", networkPoolId: poolId, fromStatus: pool.status, toStatus: "active", reason: "Manual operator recovery", errorCategory: null, createdAt: now });
|
|
230
232
|
publishPoolHealth({ poolId, tenantId: pool.tenantId, status: "active", consecutiveFailures: 0 });
|
|
231
233
|
return true;
|
|
232
234
|
}
|
|
@@ -248,7 +250,7 @@ export async function sweepExpiredPoolCooldowns(db: CartethyiaDatabase): Promise
|
|
|
248
250
|
const expired = await db.select({ id: networkPools.id, tenantId: networkPools.tenantId, status: networkPools.status }).from(networkPools).where(and(inArray(networkPools.status, ["cooldown"]), isNotNull(networkPools.cooldownUntil), lte(networkPools.cooldownUntil, now))).limit(POOL_COOLDOWN_RECOVERY_BATCH);
|
|
249
251
|
if (expired.length === 0) return 0;
|
|
250
252
|
await db.update(networkPools).set({ status: "active", consecutiveFailures: 0, cooldownUntil: null, lastRecoveredAt: now }).where(inArray(networkPools.id, expired.map((row) => row.id)));
|
|
251
|
-
await db.insert(healthEvents).values(expired.map((row) => ({ entityKind: "pool" as const, networkPoolId: row.id, fromStatus: row.status, toStatus: "active" as const, reason: "Cooldown elapsed — auto-recovered", errorCategory: null, createdAt: now })));
|
|
253
|
+
await db.insert(healthEvents).values(expired.map((row) => ({ id: randomUUID(), entityKind: "pool" as const, networkPoolId: row.id, fromStatus: row.status, toStatus: "active" as const, reason: "Cooldown elapsed — auto-recovered", errorCategory: null, createdAt: now })));
|
|
252
254
|
for (const row of expired) publishPoolHealth({ poolId: row.id, tenantId: row.tenantId, status: "active", consecutiveFailures: 0 });
|
|
253
255
|
return expired.length;
|
|
254
256
|
}
|
|
@@ -1,21 +1,26 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
1
2
|
import type { CartethyiaDatabase } from "../persistence/db-handle";
|
|
2
3
|
import { healthEvents } from "../persistence/schema";
|
|
3
4
|
import type { NetworkPoolSelector } from "./pool/selector";
|
|
4
5
|
import type { GatewayError } from "../protocol/gateway-error";
|
|
5
6
|
import { metrics } from "../observability/metrics";
|
|
6
7
|
import { log } from "../observability/logger";
|
|
8
|
+
import { upsertProviderCooldownMarker } from "./pool-cooldown-store";
|
|
9
|
+
import { resolvePoolProviderRateLimitCooldownMs } from "../config/cooldowns";
|
|
7
10
|
|
|
8
11
|
/**
|
|
9
12
|
* Durable pool rate-limit history.
|
|
10
13
|
*
|
|
11
14
|
* Per-(pool, provider) 429 cooldowns live in `NetworkPoolSelector` (fast,
|
|
12
|
-
* volatile, consulted on every dispatch). This recorder mirrors each flag
|
|
13
|
-
*
|
|
14
|
-
* same merged log the account recorder writes to
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
15
|
+
* volatile, consulted on every dispatch). This recorder mirrors each flag into
|
|
16
|
+
* two durable homes: the append-only `health_events` table
|
|
17
|
+
* (`entity_kind: "pool"`, the same merged log the account recorder writes to
|
|
18
|
+
* for accounts) for audit, and the `provider_cooldowns` column on the pool row
|
|
19
|
+
* as the state boot restores — a restart must not make dispatch forget the
|
|
20
|
+
* egresses the upstream is still refusing. It never touches
|
|
21
|
+
* `network_pools.status`: a single provider's 429 must not flip the whole pool
|
|
22
|
+
* to `cooldown` (that would head-of-line-block every other provider on the
|
|
23
|
+
* pool).
|
|
19
24
|
*/
|
|
20
25
|
|
|
21
26
|
/**
|
|
@@ -41,12 +46,14 @@ async function recordPoolCooldownEvent(
|
|
|
41
46
|
if (last !== undefined && now - last < POOL_COOLDOWN_EVENT_WINDOW_MS) return;
|
|
42
47
|
try {
|
|
43
48
|
await db.insert(healthEvents).values({
|
|
49
|
+
id: randomUUID(),
|
|
44
50
|
entityKind: "pool",
|
|
45
51
|
networkPoolId: poolId,
|
|
46
52
|
fromStatus: null,
|
|
47
53
|
toStatus: "cooldown",
|
|
48
54
|
reason: `provider ${providerId}: ${reason}`,
|
|
49
55
|
errorCategory: "rate_limit_transient",
|
|
56
|
+
createdAt: new Date(),
|
|
50
57
|
});
|
|
51
58
|
} catch {
|
|
52
59
|
// Non-fatal: observability writes must never fail dispatch. The
|
|
@@ -85,6 +92,12 @@ export function flagPoolBlocked(
|
|
|
85
92
|
const reason = `Provider ${providerId} refused this egress`;
|
|
86
93
|
void poolSelector
|
|
87
94
|
.flagProviderBlocked(poolId, providerId, reason)
|
|
95
|
+
.then((entry) =>
|
|
96
|
+
// Persisted even when the flag was refused (a credential-keyed
|
|
97
|
+
// provider): the selector returned an inert entry, so the mirror
|
|
98
|
+
// records nothing a reader would act on.
|
|
99
|
+
upsertProviderCooldownMarker(db, poolId, providerId, entry),
|
|
100
|
+
)
|
|
88
101
|
.catch((cause: unknown) => {
|
|
89
102
|
metrics.pool_cooldown_record_failed.inc(1);
|
|
90
103
|
log.warn("pool block flag failed", {
|
|
@@ -119,12 +132,14 @@ async function recordPoolBlockEvent(
|
|
|
119
132
|
error: GatewayError,
|
|
120
133
|
): Promise<void> {
|
|
121
134
|
await db.insert(healthEvents).values({
|
|
135
|
+
id: randomUUID(),
|
|
122
136
|
entityKind: "pool",
|
|
123
137
|
networkPoolId: poolId,
|
|
124
138
|
fromStatus: null,
|
|
125
139
|
toStatus: "cooldown",
|
|
126
140
|
reason: `provider ${providerId}: ${reason}${providerCodeSuffix(error)}`,
|
|
127
141
|
errorCategory: "egress_blocked",
|
|
142
|
+
createdAt: new Date(),
|
|
128
143
|
});
|
|
129
144
|
}
|
|
130
145
|
|
|
@@ -137,9 +152,20 @@ function providerCodeSuffix(error: GatewayError): string {
|
|
|
137
152
|
/** Cooldown applied when an upstream 429 carries no `retryAfterMs`. */
|
|
138
153
|
const DEFAULT_PROVIDER_COOLDOWN_MS = 15 * 60 * 1000;
|
|
139
154
|
|
|
155
|
+
/**
|
|
156
|
+
* The fallback window for a provider-scoped egress cooldown. Upstream `Retry-After`
|
|
157
|
+
* always wins; the configured default exists because a request-counted free
|
|
158
|
+
* tier — the one provider that flags at all — stays refused for hours after
|
|
159
|
+
* the 15-minute guess this function used before.
|
|
160
|
+
*/
|
|
161
|
+
function providerCooldownFallbackMs(): number {
|
|
162
|
+
return Math.max(resolvePoolProviderRateLimitCooldownMs(), DEFAULT_PROVIDER_COOLDOWN_MS);
|
|
163
|
+
}
|
|
164
|
+
|
|
140
165
|
/**
|
|
141
166
|
* Records a provider-scoped 429 cooldown: the volatile per-(pool, provider)
|
|
142
|
-
* flag consulted on every dispatch,
|
|
167
|
+
* flag consulted on every dispatch, its durable audit row, and the durable
|
|
168
|
+
* state boot restores from.
|
|
143
169
|
*
|
|
144
170
|
* Both paths that reach this decision — the non-streaming attempt loop and
|
|
145
171
|
* the streaming failure path — call this one function, because each write
|
|
@@ -161,9 +187,10 @@ export function flagPoolCooldown(
|
|
|
161
187
|
const retryAfterMs =
|
|
162
188
|
typeof error.details.retryAfterMs === "number"
|
|
163
189
|
? error.details.retryAfterMs
|
|
164
|
-
:
|
|
190
|
+
: providerCooldownFallbackMs();
|
|
165
191
|
void poolSelector
|
|
166
192
|
.flagProviderCooldown(poolId, providerId, retryAfterMs, reason)
|
|
193
|
+
.then((entry) => upsertProviderCooldownMarker(db, poolId, providerId, entry))
|
|
167
194
|
.catch((cause: unknown) => {
|
|
168
195
|
metrics.pool_cooldown_record_failed.inc(1);
|
|
169
196
|
log.warn("pool cooldown flag failed", {
|
|
@@ -537,6 +537,63 @@ export async function chartTelemetry(
|
|
|
537
537
|
}));
|
|
538
538
|
}
|
|
539
539
|
|
|
540
|
+
/** One day × model usage cell for a single provider — the Activity Matrix feed. */
|
|
541
|
+
export interface ProviderUsageDayRow {
|
|
542
|
+
/** UTC day, `YYYY-MM-DD` (epoch-day buckets, so the same key on both backends). */
|
|
543
|
+
readonly day: string;
|
|
544
|
+
/** What the client asked for; `null` when the event carried no model. */
|
|
545
|
+
readonly model: string | null;
|
|
546
|
+
readonly requests: number;
|
|
547
|
+
readonly inputTokens: number;
|
|
548
|
+
readonly cachedInputTokens: number;
|
|
549
|
+
readonly outputTokens: number;
|
|
550
|
+
readonly costUsd: number;
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
/**
|
|
554
|
+
* Per-provider daily usage grouped by `requested_model`, day-bucketed like
|
|
555
|
+
* {@link chartTelemetry} but with a second grouping dimension (requested
|
|
556
|
+
* model) and the totals the tooltip/summary strip need. Provider rows carry
|
|
557
|
+
* no catalog foreign keys, so an unknown or deleted provider id returns empty
|
|
558
|
+
* rather than erroring.
|
|
559
|
+
*/
|
|
560
|
+
export async function providerUsageMatrix(
|
|
561
|
+
db: CartethyiaDatabase,
|
|
562
|
+
tenantId: string,
|
|
563
|
+
providerId: string,
|
|
564
|
+
since: Date,
|
|
565
|
+
): Promise<ProviderUsageDayRow[]> {
|
|
566
|
+
// Same inlining rule as chartTelemetry: SELECT and GROUP BY must spell the
|
|
567
|
+
// identical expression or Postgres 42803-rejects the grouped reference.
|
|
568
|
+
const w = sql.raw(String(86400));
|
|
569
|
+
const dayExpression = isPostgres(db)
|
|
570
|
+
? sql<number>`(cast(extract(epoch from ${telemetryEvents.createdAt}) as bigint) / ${w}) * ${w}`
|
|
571
|
+
: sql<number>`(cast(strftime('%s', ${telemetryEvents.createdAt}) as integer) / ${w}) * ${w}`;
|
|
572
|
+
const rows = await db
|
|
573
|
+
.select({
|
|
574
|
+
day: dayExpression,
|
|
575
|
+
model: telemetryEvents.requestedModel,
|
|
576
|
+
requests: sql<number>`count(*)`,
|
|
577
|
+
inputTokens: sql<number>`coalesce(sum(${telemetryEvents.inputTokens}), 0)`,
|
|
578
|
+
cachedInputTokens: sql<number>`coalesce(sum(${telemetryEvents.cachedInputTokens}), 0)`,
|
|
579
|
+
outputTokens: sql<number>`coalesce(sum(${telemetryEvents.outputTokens}), 0)`,
|
|
580
|
+
costUsd: sql<number>`coalesce(sum(${telemetryEvents.estimatedCostUsd}), 0)`,
|
|
581
|
+
})
|
|
582
|
+
.from(telemetryEvents)
|
|
583
|
+
.where(and(scoped(tenantId, since), eq(telemetryEvents.providerId, providerId)))
|
|
584
|
+
.groupBy(dayExpression, telemetryEvents.requestedModel)
|
|
585
|
+
.orderBy(dayExpression);
|
|
586
|
+
return rows.map((row) => ({
|
|
587
|
+
day: new Date(numeric(row.day) * 1000).toISOString().slice(0, 10),
|
|
588
|
+
model: row.model,
|
|
589
|
+
requests: numeric(row.requests),
|
|
590
|
+
inputTokens: numeric(row.inputTokens),
|
|
591
|
+
cachedInputTokens: numeric(row.cachedInputTokens),
|
|
592
|
+
outputTokens: numeric(row.outputTokens),
|
|
593
|
+
costUsd: numeric(row.costUsd),
|
|
594
|
+
}));
|
|
595
|
+
}
|
|
596
|
+
|
|
540
597
|
/** Overview/health totals for one tenant window. */
|
|
541
598
|
export async function healthTelemetry(
|
|
542
599
|
db: CartethyiaDatabase,
|