cartethyia 2.7.0 → 2.7.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.env.example +107 -106
- package/README.md +25 -2
- package/dist/dashboard/assets/{BannedPage-P76QLGJA.js → BannedPage-D-J75hBM.js} +1 -1
- package/dist/dashboard/assets/BannedPage-D-J75hBM.js.gz +0 -0
- package/dist/dashboard/assets/{CliToolDetailPage-DdBggJS9.js → CliToolDetailPage-CVe6RaBH.js} +1 -1
- package/dist/dashboard/assets/CliToolDetailPage-CVe6RaBH.js.gz +0 -0
- package/dist/dashboard/assets/{CliToolsPage-aN1iXhhP.js → CliToolsPage-Dd8ZSamp.js} +1 -1
- package/dist/dashboard/assets/CliToolsPage-Dd8ZSamp.js.gz +0 -0
- package/dist/dashboard/assets/{CombosPage-DE-F1nxZ.js → CombosPage-QoJ6foxo.js} +1 -1
- package/dist/dashboard/assets/CombosPage-QoJ6foxo.js.gz +0 -0
- package/dist/dashboard/assets/ConsoleLogPage-DpNRay4C.js +1 -0
- package/dist/dashboard/assets/ConsoleLogPage-DpNRay4C.js.gz +0 -0
- package/dist/dashboard/assets/{CustomizationPage-3CUxMy2X.js → CustomizationPage-CKUFTcW4.js} +1 -1
- package/dist/dashboard/assets/CustomizationPage-CKUFTcW4.js.gz +0 -0
- package/dist/dashboard/assets/HealthEventsModal-CzSLiDzr.js +1 -0
- package/dist/dashboard/assets/HealthEventsModal-CzSLiDzr.js.gz +0 -0
- package/dist/dashboard/assets/LoginPage-DubdQcvX.js +1 -0
- package/dist/dashboard/assets/LoginPage-DubdQcvX.js.gz +0 -0
- package/dist/dashboard/assets/{ModelPicker-DKVslqG3.js → ModelPicker-BH8Tehdi.js} +1 -1
- package/dist/dashboard/assets/ModelPicker-BH8Tehdi.js.gz +0 -0
- package/dist/dashboard/assets/OverviewPage-DuWzAQFL.js +1 -0
- package/dist/dashboard/assets/OverviewPage-DuWzAQFL.js.gz +0 -0
- package/dist/dashboard/assets/ProviderDetailPage-BKEKe2Fm.js +4 -0
- package/dist/dashboard/assets/ProviderDetailPage-BKEKe2Fm.js.gz +0 -0
- package/dist/dashboard/assets/ProviderIcon-FB6yS4v1.js +1 -0
- package/dist/dashboard/assets/ProviderIcon-FB6yS4v1.js.gz +0 -0
- package/dist/dashboard/assets/ProvidersPage-Bwotu8UJ.js +1 -0
- package/dist/dashboard/assets/ProvidersPage-Bwotu8UJ.js.gz +0 -0
- package/dist/dashboard/assets/ProxyPage-D7hpWbg9.js +9 -0
- package/dist/dashboard/assets/ProxyPage-D7hpWbg9.js.gz +0 -0
- package/dist/dashboard/assets/{QuotaPage-BjldQwIU.js → QuotaPage-ATpaxrbl.js} +1 -1
- package/dist/dashboard/assets/QuotaPage-ATpaxrbl.js.gz +0 -0
- package/dist/dashboard/assets/{SettingsPage-DUIoLRxk.js → SettingsPage-CETophpx.js} +1 -1
- package/dist/dashboard/assets/SettingsPage-CETophpx.js.gz +0 -0
- package/dist/dashboard/assets/SetupPage-BdgECjqq.js +1 -0
- package/dist/dashboard/assets/SetupPage-BdgECjqq.js.gz +0 -0
- package/dist/dashboard/assets/{SortableList-fI7JkdIB.js → SortableList-CAK-EN9z.js} +1 -1
- package/dist/dashboard/assets/SortableList-CAK-EN9z.js.gz +0 -0
- package/dist/dashboard/assets/{StudioPage-CHVZqskZ.js → StudioPage-B7W2_RhT.js} +3 -3
- package/dist/dashboard/assets/StudioPage-B7W2_RhT.js.gz +0 -0
- package/dist/dashboard/assets/UsagePage-6T4EFDjW.js +56 -0
- package/dist/dashboard/assets/UsagePage-6T4EFDjW.js.gz +0 -0
- package/dist/dashboard/assets/{api-keys-DFY_fZU4.js → api-keys-CUi6UXsR.js} +1 -1
- package/dist/dashboard/assets/api-keys-CUi6UXsR.js.gz +0 -0
- package/dist/dashboard/assets/app-KvsUJUMF.css +2 -0
- package/dist/dashboard/assets/app-KvsUJUMF.css.gz +0 -0
- package/dist/dashboard/assets/{assert-CwdnZARt.js → assert-67xXH5rf.js} +1 -1
- package/dist/dashboard/assets/assert-67xXH5rf.js.gz +0 -0
- package/dist/dashboard/assets/{badge-Dr-uf0Au.js → badge-DC2L43Mj.js} +1 -1
- package/dist/dashboard/assets/badge-DC2L43Mj.js.gz +0 -0
- package/dist/dashboard/assets/{banner-BddmMdoS.js → banner-BBVcb0J3.js} +1 -1
- package/dist/dashboard/assets/banner-BBVcb0J3.js.gz +0 -0
- package/dist/dashboard/assets/{card-CK0PXWlX.js → card-Cca1XC6G.js} +1 -1
- package/dist/dashboard/assets/card-Cca1XC6G.js.gz +0 -0
- package/dist/dashboard/assets/{cli-tools-CcB6mGh2.js → cli-tools-KvsWa1Jl.js} +1 -1
- package/dist/dashboard/assets/cli-tools-KvsWa1Jl.js.gz +0 -0
- package/dist/dashboard/assets/{clipboard-button-DGb2P9Yx.js → clipboard-button-DUzEbynq.js} +1 -1
- package/dist/dashboard/assets/clipboard-button-DUzEbynq.js.gz +0 -0
- package/dist/dashboard/assets/{dialog-LkfuL50u.js → dialog-DFvWhwTa.js} +1 -1
- package/dist/dashboard/assets/dialog-DFvWhwTa.js.gz +0 -0
- package/dist/dashboard/assets/{drawer-ZbEZ_84c.js → drawer-CFM2sMX1.js} +1 -1
- package/dist/dashboard/assets/drawer-CFM2sMX1.js.gz +0 -0
- package/dist/dashboard/assets/{entry-CWBY5A0f.js → entry-BLBLxX0h.js} +3 -3
- package/dist/dashboard/assets/entry-BLBLxX0h.js.gz +0 -0
- package/dist/dashboard/assets/{entry-BJfsVMvA.js → entry-Bqy8RFvM.js} +1 -1
- package/dist/dashboard/assets/entry-Bqy8RFvM.js.gz +0 -0
- package/dist/dashboard/assets/{entry-CvC1-Zqx.js → entry-_xHo5x6r.js} +1 -1
- package/dist/dashboard/assets/entry-_xHo5x6r.js.gz +0 -0
- package/dist/dashboard/assets/formatters-mpBPqAQ8.js +1 -0
- package/dist/dashboard/assets/formatters-mpBPqAQ8.js.gz +0 -0
- package/dist/dashboard/assets/{github-badge-BxpznFLt.js → github-badge-C9e8Ts7e.js} +1 -1
- package/dist/dashboard/assets/github-badge-C9e8Ts7e.js.gz +0 -0
- package/dist/dashboard/assets/{index-CDzP3ovJ.js → index-D6QzEC_n.js} +2 -2
- package/dist/dashboard/assets/index-D6QzEC_n.js.gz +0 -0
- package/dist/dashboard/assets/{inline-Ae13MQ5w.js → inline-BFUcvllj.js} +1 -1
- package/dist/dashboard/assets/inline-BFUcvllj.js.gz +0 -0
- package/dist/dashboard/assets/{input-D23fo7cp.js → input-C_ILAvBb.js} +1 -1
- package/dist/dashboard/assets/input-C_ILAvBb.js.gz +0 -0
- package/dist/dashboard/assets/{layout-CSsxjj5P.js → layout-CBIyT6Rt.js} +1 -1
- package/dist/dashboard/assets/layout-CBIyT6Rt.js.gz +0 -0
- package/dist/dashboard/assets/{list-row-dmbwZMDY.js → list-row-ebJ_-Nsb.js} +1 -1
- package/dist/dashboard/assets/list-row-ebJ_-Nsb.js.gz +0 -0
- package/dist/dashboard/assets/{live-BG1oUsZZ.js → live-D0mm--ME.js} +1 -1
- package/dist/dashboard/assets/live-D0mm--ME.js.gz +0 -0
- package/dist/dashboard/assets/{mutation-DSF-e4ho.js → mutation-sMD965oO.js} +1 -1
- package/dist/dashboard/assets/mutation-sMD965oO.js.gz +0 -0
- package/dist/dashboard/assets/{numbers-Bm_aECY7.js → numbers-BO9dKaFr.js} +1 -1
- package/dist/dashboard/assets/numbers-BO9dKaFr.js.gz +0 -0
- package/dist/dashboard/assets/providers/inception.svg +5 -0
- package/dist/dashboard/assets/providers/inception.svg.gz +0 -0
- package/dist/dashboard/assets/{query-keys-CF56nYIV.js → query-keys-CfL_Zdoh.js} +1 -1
- package/dist/dashboard/assets/query-keys-CfL_Zdoh.js.gz +0 -0
- package/dist/dashboard/assets/{routing-CeA71n7G.js → routing-BnIpi1IJ.js} +1 -1
- package/dist/dashboard/assets/routing-BnIpi1IJ.js.gz +0 -0
- package/dist/dashboard/assets/{select-KIT0rC6G.js → select-Cj5Nw43g.js} +1 -1
- package/dist/dashboard/assets/select-Cj5Nw43g.js.gz +0 -0
- package/dist/dashboard/assets/{settings-B_2gkePf.js → settings-BAFhDgX8.js} +1 -1
- package/dist/dashboard/assets/settings-BAFhDgX8.js.gz +0 -0
- package/dist/dashboard/assets/{stack-D7WOSzcX.js → stack-EMrOMt4I.js} +1 -1
- package/dist/dashboard/assets/stack-EMrOMt4I.js.gz +0 -0
- package/dist/dashboard/assets/{state-BBxtKZOQ.js → state-CWoo7ty5.js} +1 -1
- package/dist/dashboard/assets/state-CWoo7ty5.js.gz +0 -0
- package/dist/dashboard/assets/{switch-DNS_D5Ea.js → switch-CCjT6IkF.js} +1 -1
- package/dist/dashboard/assets/switch-CCjT6IkF.js.gz +0 -0
- package/dist/dashboard/assets/{useMutation-BgoxdoMk.js → useMutation-DjkLHUcE.js} +1 -1
- package/dist/dashboard/assets/useMutation-DjkLHUcE.js.gz +0 -0
- package/dist/dashboard/index.html +1 -1
- package/dist/dashboard/index.html.gz +0 -0
- package/dist/dashboard/precompressed-assets.json +54 -53
- package/migrations/full-postgres/0024_add_pool_provider_cooldowns.sql +12 -0
- package/migrations/full-postgres/0025_add_pool_random_strategy.sql +3 -0
- package/migrations/lite-sqlite3/0024_add_pool_provider_cooldowns.sql +12 -0
- package/migrations/lite-sqlite3/0025_add_pool_random_strategy.sql +20 -0
- package/package.json +96 -96
- package/scripts/cli/console-menu.ts +5 -2
- package/scripts/cli/tray.ts +21 -6
- package/src/boot/app.ts +684 -714
- package/src/boot/dependencies.ts +65 -62
- package/src/config/cooldowns.ts +16 -0
- package/src/config/providers.ts +13 -1
- package/src/config/resources.ts +70 -70
- package/src/config/security.ts +37 -37
- package/src/config/spec.ts +371 -358
- package/src/console/console-router.ts +233 -225
- package/src/console/domain-registration.ts +398 -381
- package/src/console/providers/catalog/catalog-projections.ts +2 -6
- package/src/console/providers/catalog/contracts.ts +19 -0
- package/src/console/providers/catalog/provider-operations.ts +38 -0
- package/src/console/providers/catalog/routes.ts +14 -0
- package/src/console/providers/catalog/store-helpers.ts +326 -0
- package/src/console/providers/catalog/store.ts +1292 -1563
- package/src/console/quota/account-quota-global.ts +2 -2
- package/src/console/quota/account-quota-shared.ts +1 -1
- package/src/console/quota/account-quota-tenant.ts +35 -12
- package/src/console/quota/quota-view.ts +2 -2
- package/src/console/routing/pools/contracts.ts +41 -1
- package/src/console/routing/pools/operations.ts +183 -4
- package/src/console/routing/pools/relay-deploy.ts +17 -4
- package/src/console/routing/pools/routes.ts +30 -0
- package/src/console/routing/pools/store.ts +98 -8
- package/src/main.ts +183 -167
- package/src/network/outbound-fetch.ts +226 -365
- package/src/network/pool/agent.ts +8 -0
- package/src/network/pool/pool-state.ts +7 -0
- package/src/network/pool/selector.ts +110 -2
- package/src/network/pool-cooldown-store.ts +252 -0
- package/src/network/pool-health-machine.ts +5 -3
- package/src/network/pool-health.ts +36 -9
- package/src/observability/event-query.ts +57 -0
- package/src/observability/metrics.ts +467 -467
- package/src/observability/runtime-metrics.ts +269 -269
- package/src/persistence/health-event-flush.ts +2 -0
- package/src/persistence/schema.ts +19 -0
- package/src/persistence/usage-flush.ts +8 -3
- package/src/protocol/canonical-model.ts +1 -1
- package/src/protocol/dsml.ts +377 -0
- package/src/protocol/gateway-error.ts +232 -232
- package/src/{providers/provider-reasoning.ts → protocol/reasoning.ts} +10 -9
- package/src/protocol/request/chat.ts +7 -7
- package/src/protocol/request/codex.ts +2 -2
- package/src/protocol/request/gemini.ts +1 -1
- package/src/protocol/request/messages.ts +6 -6
- package/src/protocol/request/responses.ts +7 -7
- package/src/protocol/response/dsml.ts +12 -133
- package/src/{transport → protocol}/translation/attachment-integrity.ts +2 -2
- package/src/{transport → protocol}/translation/cache-controls.ts +1 -1
- package/src/{transport → protocol}/translation/capabilities.ts +2 -2
- package/src/{transport → protocol}/translation/normalize.ts +1 -1
- package/src/{transport → protocol}/translation/quirks.ts +1 -1
- package/src/{transport → protocol}/translation/search.ts +1 -1
- package/src/{transport → protocol}/translation/thinking.ts +23 -1
- package/src/{transport → protocol}/translation/tool-id.ts +1 -1
- package/src/{transport → protocol}/translation/tool-repair.ts +2 -2
- package/src/protocol/upstream-parsing.ts +176 -0
- package/src/providers/adapters/compatible-adapter.ts +11 -2
- package/src/providers/authentication/oauth-refresh-service.ts +9 -5
- package/src/providers/default-registry.ts +21 -5
- package/src/providers/discovery/probe-phases.ts +16 -3
- package/src/providers/discovery/probing-service.ts +4 -1
- package/src/providers/integrations/anthropic.ts +1 -1
- package/src/providers/integrations/buddy/buddy-catalog-shared.ts +1 -4
- package/src/providers/integrations/buddy/buddy-chat-shared.ts +141 -8
- package/src/providers/integrations/buddy/buddy-checkin.ts +5 -1
- package/src/providers/integrations/buddy/buddy-growth-shared.ts +0 -82
- package/src/providers/integrations/buddy/buddy-identity.ts +25 -0
- package/src/providers/integrations/buddy/buddy-server-tools.ts +240 -0
- package/src/providers/integrations/buddy/codebuddy/cn-catalog.ts +36 -0
- package/src/providers/integrations/buddy/codebuddy/cn-provider.ts +6 -31
- package/src/providers/integrations/buddy/codebuddy/provider.ts +2 -0
- package/src/providers/integrations/buddy/codebuddy/shared.ts +19 -4
- package/src/providers/integrations/buddy/turing-device-token.ts +262 -0
- package/src/providers/integrations/buddy/workbuddy/provider.ts +2 -0
- package/src/providers/integrations/buddy/workbuddy/shared.ts +56 -3
- package/src/providers/integrations/claude/claude.ts +1 -1
- package/src/providers/integrations/cloudflareai/cloudflareai.ts +1 -1
- package/src/providers/integrations/codex/codex-errors.ts +1 -1
- package/src/providers/integrations/codex/codex.ts +1 -1
- package/src/providers/integrations/factory/factory.ts +1 -1
- package/src/providers/integrations/github/github.ts +1 -1
- package/src/providers/integrations/grok/grok.ts +1 -1
- package/src/providers/integrations/inception/provider.ts +91 -0
- package/src/providers/integrations/inferhub.ts +1 -1
- package/src/providers/integrations/kiro/kiro-request.ts +1 -1
- package/src/providers/integrations/mistral.ts +193 -19
- package/src/providers/integrations/opencode/fingerprint.ts +19 -2
- package/src/providers/integrations/opencode/prompt.ts +111 -0
- package/src/providers/integrations/opencode/provider.ts +101 -23
- package/src/providers/integrations/opencode/tool-definitions.ts +4 -4
- package/src/providers/integrations/qoder/qoder-checkin.ts +303 -0
- package/src/providers/integrations/qoder/qoder.ts +1641 -1458
- package/src/providers/integrations/shared/install-id.ts +2 -1
- package/src/providers/integrations/trae/trae-cache.ts +110 -0
- package/src/providers/integrations/trae/trae-oauth.ts +29 -4
- package/src/providers/integrations/trae/trae-protocol.ts +2 -1
- package/src/providers/integrations/trae/trae.ts +106 -23
- package/src/providers/operations/account-error-classification.ts +1660 -0
- package/src/providers/operations/account-health-service.ts +621 -2097
- package/src/providers/operations/account-reset-service.ts +2 -0
- package/src/providers/provider-contracts.ts +0 -2
- package/src/providers/provider-metadata.ts +25 -4
- package/src/providers/provider-probe.ts +2 -2
- package/src/{console → providers}/quota/cache.ts +1 -1
- package/src/{console → providers}/quota/refresh.ts +5 -5
- package/src/transport/dispatch/attempt-finalize.ts +114 -12
- package/src/transport/dispatch/attempt-loop.ts +3 -0
- package/src/transport/dispatch/empty-stream.ts +224 -217
- package/src/transport/dispatch/generation.ts +37 -55
- package/src/transport/dispatch/handler-deps.ts +32 -0
- package/src/transport/dispatch/leases.ts +9 -4
- package/src/transport/dispatch/proxy-request.ts +6 -30
- package/src/transport/dispatch/responses-compact.ts +22 -43
- package/src/transport/dispatch/streaming/finalize.ts +3 -0
- package/src/transport/dispatch/streaming/stream.ts +0 -8
- package/src/transport/dispatch/systemone.ts +22 -42
- package/src/transport/dispatch/token-count.ts +23 -39
- package/src/transport/dispatch/upstream.ts +1 -3
- package/src/transport/dispatch/websearch.ts +22 -41
- package/src/transport/failure-policy.ts +59 -178
- package/src/transport/guards/abuse-guard.ts +10 -0
- package/src/transport/guards/auth-guard.ts +109 -109
- package/src/transport/guards/error-lifecycle.ts +1 -1
- package/src/transport/guards/pipeline.ts +220 -220
- package/src/transport/guards/request-context.ts +245 -238
- package/src/transport/request/degradation.ts +20 -1
- package/src/transport/request/inflight.ts +4 -114
- package/src/transport/request/preparer.ts +6 -6
- package/src/transport/request/state.ts +66 -38
- package/src/transport/request/tenant-preferences.ts +1 -1
- package/src/transport/routing/route-catalog.ts +41 -31
- package/src/transport/routing/router.ts +2 -2
- package/src/transport/surface/messages/parse.ts +1 -1
- package/src/workers/buddy-growth-tasks.ts +346 -425
- package/src/workers/daily-checkin.ts +17 -6
- package/src/workers/quota-refresh-sweep.ts +2 -2
- package/src/workers/scheduler.ts +17 -1
- package/dist/dashboard/assets/BannedPage-P76QLGJA.js.gz +0 -0
- package/dist/dashboard/assets/CliToolDetailPage-DdBggJS9.js.gz +0 -0
- package/dist/dashboard/assets/CliToolsPage-aN1iXhhP.js.gz +0 -0
- package/dist/dashboard/assets/CombosPage-DE-F1nxZ.js.gz +0 -0
- package/dist/dashboard/assets/ConsoleLogPage-5SpmfLyw.js +0 -1
- package/dist/dashboard/assets/ConsoleLogPage-5SpmfLyw.js.gz +0 -0
- package/dist/dashboard/assets/CustomizationPage-3CUxMy2X.js.gz +0 -0
- package/dist/dashboard/assets/HealthEventsModal-BkmJQKC0.js +0 -1
- package/dist/dashboard/assets/HealthEventsModal-BkmJQKC0.js.gz +0 -0
- package/dist/dashboard/assets/LoginPage-B_0FUKWc.js +0 -1
- package/dist/dashboard/assets/LoginPage-B_0FUKWc.js.gz +0 -0
- package/dist/dashboard/assets/ModelPicker-DKVslqG3.js.gz +0 -0
- package/dist/dashboard/assets/OverviewPage-D-H4MEiT.js +0 -1
- package/dist/dashboard/assets/OverviewPage-D-H4MEiT.js.gz +0 -0
- package/dist/dashboard/assets/ProviderDetailPage-CEJw6Guo.js +0 -3
- package/dist/dashboard/assets/ProviderDetailPage-CEJw6Guo.js.gz +0 -0
- package/dist/dashboard/assets/ProviderIcon-CmfzxpVM.js +0 -1
- package/dist/dashboard/assets/ProviderIcon-CmfzxpVM.js.gz +0 -0
- package/dist/dashboard/assets/ProvidersPage-CEplAR-i.js +0 -1
- package/dist/dashboard/assets/ProvidersPage-CEplAR-i.js.gz +0 -0
- package/dist/dashboard/assets/ProxyPage-CAN7hy0f.js +0 -7
- package/dist/dashboard/assets/ProxyPage-CAN7hy0f.js.gz +0 -0
- package/dist/dashboard/assets/QuotaPage-BjldQwIU.js.gz +0 -0
- package/dist/dashboard/assets/SettingsPage-DUIoLRxk.js.gz +0 -0
- package/dist/dashboard/assets/SetupPage-_bCaTM0v.js +0 -1
- package/dist/dashboard/assets/SetupPage-_bCaTM0v.js.gz +0 -0
- package/dist/dashboard/assets/SortableList-fI7JkdIB.js.gz +0 -0
- package/dist/dashboard/assets/StudioPage-CHVZqskZ.js.gz +0 -0
- package/dist/dashboard/assets/UsagePage-DiFBymHj.js +0 -56
- package/dist/dashboard/assets/UsagePage-DiFBymHj.js.gz +0 -0
- package/dist/dashboard/assets/api-keys-DFY_fZU4.js.gz +0 -0
- package/dist/dashboard/assets/app-BOJ1_xUB.css +0 -2
- package/dist/dashboard/assets/app-BOJ1_xUB.css.gz +0 -0
- package/dist/dashboard/assets/assert-CwdnZARt.js.gz +0 -0
- package/dist/dashboard/assets/badge-Dr-uf0Au.js.gz +0 -0
- package/dist/dashboard/assets/banner-BddmMdoS.js.gz +0 -0
- package/dist/dashboard/assets/card-CK0PXWlX.js.gz +0 -0
- package/dist/dashboard/assets/cli-tools-CcB6mGh2.js.gz +0 -0
- package/dist/dashboard/assets/clipboard-button-DGb2P9Yx.js.gz +0 -0
- package/dist/dashboard/assets/cog-CdWhsajs.js +0 -1
- package/dist/dashboard/assets/cog-CdWhsajs.js.gz +0 -0
- package/dist/dashboard/assets/dialog-LkfuL50u.js.gz +0 -0
- package/dist/dashboard/assets/drawer-ZbEZ_84c.js.gz +0 -0
- package/dist/dashboard/assets/entry-BJfsVMvA.js.gz +0 -0
- package/dist/dashboard/assets/entry-CWBY5A0f.js.gz +0 -0
- package/dist/dashboard/assets/entry-CvC1-Zqx.js.gz +0 -0
- package/dist/dashboard/assets/github-badge-BxpznFLt.js.gz +0 -0
- package/dist/dashboard/assets/index-CDzP3ovJ.js.gz +0 -0
- package/dist/dashboard/assets/inline-Ae13MQ5w.js.gz +0 -0
- package/dist/dashboard/assets/input-D23fo7cp.js.gz +0 -0
- package/dist/dashboard/assets/layout-CSsxjj5P.js.gz +0 -0
- package/dist/dashboard/assets/list-row-dmbwZMDY.js.gz +0 -0
- package/dist/dashboard/assets/live-BG1oUsZZ.js.gz +0 -0
- package/dist/dashboard/assets/mutation-DSF-e4ho.js.gz +0 -0
- package/dist/dashboard/assets/numbers-Bm_aECY7.js.gz +0 -0
- package/dist/dashboard/assets/query-keys-CF56nYIV.js.gz +0 -0
- package/dist/dashboard/assets/routing-CeA71n7G.js.gz +0 -0
- package/dist/dashboard/assets/select-KIT0rC6G.js.gz +0 -0
- package/dist/dashboard/assets/settings-B_2gkePf.js.gz +0 -0
- package/dist/dashboard/assets/stack-D7WOSzcX.js.gz +0 -0
- package/dist/dashboard/assets/state-BBxtKZOQ.js.gz +0 -0
- package/dist/dashboard/assets/switch-DNS_D5Ea.js.gz +0 -0
- package/dist/dashboard/assets/useMutation-BgoxdoMk.js.gz +0 -0
- /package/dist/dashboard/assets/{app-CaXZ9SCn.js → app-BaWd0puM.js} +0 -0
- /package/dist/dashboard/assets/{app-CaXZ9SCn.js.gz → app-BaWd0puM.js.gz} +0 -0
|
@@ -1,269 +1,269 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Periodic runtime gauges: process memory plus bounded-collection sizes.
|
|
3
|
-
*
|
|
4
|
-
* Sampling is driven by `ScheduledTaskRegistry` (unref'd interval) rather than
|
|
5
|
-
* a dedicated timer so it shares the process's existing maintenance lifecycle
|
|
6
|
-
* and stops with it. Map/collection sizes are read through closures supplied at
|
|
7
|
-
* composition time; this module owns no references to the routing or pool
|
|
8
|
-
* layers, keeping the observability boundary one-directional.
|
|
9
|
-
*/
|
|
10
|
-
import { readFileSync } from "node:fs";
|
|
11
|
-
import os from "node:os";
|
|
12
|
-
import { metrics } from "./metrics";
|
|
13
|
-
import { trackMemoryUsage } from "./performance-metrics";
|
|
14
|
-
import { log } from "./logger";
|
|
15
|
-
import { resolveMemoryLimitOverrideBytes } from "../config/timeouts";
|
|
16
|
-
import { CONFIG_SPEC, readFlag } from "../config/spec";
|
|
17
|
-
import type { TelemetryBacklogSnapshot } from "./telemetry-buffer";
|
|
18
|
-
/**
|
|
19
|
-
* The memory figures a shed decision needs.
|
|
20
|
-
*
|
|
21
|
-
* Published once per sample instead of re-measured per request:
|
|
22
|
-
* `process.memoryUsage()` is not free, and the headroom guard runs on every
|
|
23
|
-
* request. A snapshot up to one sample interval old is the right trade — the
|
|
24
|
-
* guard is protecting against a spike that arrives over seconds, not one
|
|
25
|
-
* request's worth of allocation.
|
|
26
|
-
*/
|
|
27
|
-
export interface RuntimeMemorySnapshot {
|
|
28
|
-
readonly rssBytes: number;
|
|
29
|
-
/** The resolved limit; `undefined` when none could be discovered. */
|
|
30
|
-
readonly limitBytes: number | undefined;
|
|
31
|
-
readonly sampledAtMs: number;
|
|
32
|
-
}
|
|
33
|
-
|
|
34
|
-
/** Live collection sizes sampled alongside process memory. */
|
|
35
|
-
export interface RuntimeMetricSources {
|
|
36
|
-
readonly routingRoundRobinEntries: () => { readonly combo: number; readonly provider: number };
|
|
37
|
-
/**
|
|
38
|
-
* Tracked keys in the in-memory IP abuse store, or 0 when the store is
|
|
39
|
-
* Redis-backed. Unlike the routing maps this one is fed by client-supplied
|
|
40
|
-
* identities, so its size is the leak signal to watch.
|
|
41
|
-
*/
|
|
42
|
-
readonly ipAbuseKeys: () => number;
|
|
43
|
-
/**
|
|
44
|
-
* The telemetry backlog's current shape, or `undefined` when no buffer is
|
|
45
|
-
* wired (tests, and the console-only shell).
|
|
46
|
-
*/
|
|
47
|
-
readonly telemetryBacklog?: () => TelemetryBacklogSnapshot | undefined;
|
|
48
|
-
/** Tracked keys in the in-process quota cache tier (0 when Redis-only paths are idle). */
|
|
49
|
-
readonly quotaCacheEntries: () => number;
|
|
50
|
-
|
|
51
|
-
/**
|
|
52
|
-
* Entries in the per-provider model-catalog cache. Bounded, but a count that
|
|
53
|
-
* sits at the cap means every rebuild is evicting something it needs.
|
|
54
|
-
*/
|
|
55
|
-
readonly modelCatalogEntries: () => number;
|
|
56
|
-
/**
|
|
57
|
-
* Live in-flight request count. Zero means the gateway is idle, so a
|
|
58
|
-
* synchronous full collection is safe (no request to stall).
|
|
59
|
-
*/
|
|
60
|
-
readonly inFlight: () => number;
|
|
61
|
-
}
|
|
62
|
-
|
|
63
|
-
/** Cooldown between memory-pressure warnings so a sustained high-water mark does not flood logs. */
|
|
64
|
-
const PRESSURE_LOG_WINDOW_MS = 60_000;
|
|
65
|
-
/** Cooldown between proactive garbage collections while pressure persists. */
|
|
66
|
-
const PRESSURE_GC_WINDOW_MS = 60_000;
|
|
67
|
-
/** Fraction of the resolved limit at which memory pressure is reported. */
|
|
68
|
-
const PRESSURE_THRESHOLD = 0.8;
|
|
69
|
-
/**
|
|
70
|
-
* Fallback limit when neither an override nor a cgroup limit is discoverable
|
|
71
|
-
* (native development, macOS, Windows).
|
|
72
|
-
*
|
|
73
|
-
* A fixed 512 MiB made every machine without a cgroup look like a 512 MiB
|
|
74
|
-
* container. On a desktop running the gateway natively that is far below what
|
|
75
|
-
* the machine has, so the pressure check fired against a number that described
|
|
76
|
-
* nothing — "RSS 430 MiB exceeds 80% of the 512 MiB limit" on a machine with
|
|
77
|
-
* gigabytes free — and the concurrency cap derived from it admitted a handful
|
|
78
|
-
* of requests while every tenant-level setting sat unreachable above it.
|
|
79
|
-
*
|
|
80
|
-
* The host's real memory is the honest stand-in. It is what the process can
|
|
81
|
-
* actually grow into here, and it is what "no container limit" means.
|
|
82
|
-
*/
|
|
83
|
-
function defaultMemoryLimitBytes(): number {
|
|
84
|
-
try {
|
|
85
|
-
const total = os.totalmem();
|
|
86
|
-
if (Number.isFinite(total) && total > 0) return total;
|
|
87
|
-
} catch {
|
|
88
|
-
// Platform without totalmem; fall through to the last-resort constant.
|
|
89
|
-
}
|
|
90
|
-
return LAST_RESORT_MEMORY_LIMIT_BYTES;
|
|
91
|
-
}
|
|
92
|
-
|
|
93
|
-
/**
|
|
94
|
-
* Used only when the host's memory cannot be read at all. Deliberately large
|
|
95
|
-
* so an unmeasurable host is not treated as a small container.
|
|
96
|
-
*/
|
|
97
|
-
const LAST_RESORT_MEMORY_LIMIT_BYTES = 4 * 1024 * 1024 * 1024;
|
|
98
|
-
/** Cooldown between idle full collections. */
|
|
99
|
-
const IDLE_GC_WINDOW_MS = 5 * 60_000;
|
|
100
|
-
/** RSS retained above the post-collection baseline before another idle collection runs. */
|
|
101
|
-
const IDLE_GC_MIN_GROWTH_BYTES = 32 * 1024 * 1024;
|
|
102
|
-
|
|
103
|
-
/**
|
|
104
|
-
* Resolves the effective memory limit for the process.
|
|
105
|
-
*
|
|
106
|
-
* Precedence: an explicit `CARTETHYIA_MEMORY_LIMIT_BYTES` override, then the
|
|
107
|
-
* cgroup v2/v1 limit when running in a container. Falls back to
|
|
108
|
-
* {@link DEFAULT_MEMORY_LIMIT_BYTES} when no limit is discoverable (native
|
|
109
|
-
* development, macOS, Windows) so pressure-based reclaim still runs.
|
|
110
|
-
*/
|
|
111
|
-
export function resolveMemoryLimitBytes(): number | undefined {
|
|
112
|
-
const override = resolveMemoryLimitOverrideBytes();
|
|
113
|
-
if (override !== undefined) return override;
|
|
114
|
-
if (process.platform !== "linux") return defaultMemoryLimitBytes();
|
|
115
|
-
for (const path of [
|
|
116
|
-
"/sys/fs/cgroup/memory.max",
|
|
117
|
-
"/sys/fs/cgroup/memory/memory.limit_in_bytes",
|
|
118
|
-
]) {
|
|
119
|
-
try {
|
|
120
|
-
const content = readFileSync(path, "utf8").trim();
|
|
121
|
-
if (content === "max") continue;
|
|
122
|
-
const value = Number(content);
|
|
123
|
-
// The v1 unlimited sentinel is a huge finite number; treat it as unknown.
|
|
124
|
-
if (Number.isInteger(value) && value > 0 && value < 2 ** 53) return value;
|
|
125
|
-
} catch {
|
|
126
|
-
// File absent (cgroup v1 vs v2) or unreadable — try the next source.
|
|
127
|
-
}
|
|
128
|
-
}
|
|
129
|
-
return defaultMemoryLimitBytes();
|
|
130
|
-
}
|
|
131
|
-
|
|
132
|
-
/** Samples process memory and bounded-collection sizes into the metric registry. */
|
|
133
|
-
export class RuntimeMetricsSampler {
|
|
134
|
-
private readonly limitBytes: number | undefined;
|
|
135
|
-
private readonly gcOnPressure: boolean;
|
|
136
|
-
private lastPressureLogAt = 0;
|
|
137
|
-
private lastGcAt = 0;
|
|
138
|
-
private lastIdleGcAt = 0;
|
|
139
|
-
private rssAtLastGc = 0;
|
|
140
|
-
private inFlightSource: () => number = () => 0;
|
|
141
|
-
/**
|
|
142
|
-
* The latest snapshot, published once per sample. Stays undefined until the
|
|
143
|
-
* first sample runs; consumers must treat that as "unknown", not as zero.
|
|
144
|
-
*/
|
|
145
|
-
private snapshot: RuntimeMemorySnapshot | undefined;
|
|
146
|
-
|
|
147
|
-
/**
|
|
148
|
-
* Binds the live in-flight count after the request state store exists. The
|
|
149
|
-
* sampler is constructed before that store, so the source is late-bound.
|
|
150
|
-
*/
|
|
151
|
-
bindInFlight(source: () => number): void {
|
|
152
|
-
this.inFlightSource = source;
|
|
153
|
-
}
|
|
154
|
-
|
|
155
|
-
/**
|
|
156
|
-
* The last sampled memory state, for consumers on a hot path.
|
|
157
|
-
*
|
|
158
|
-
* Undefined before the first sample: a guard that read zero as "plenty of
|
|
159
|
-
* room" would disable itself exactly when it matters, so callers must fall
|
|
160
|
-
* back to their own judgement when this is absent.
|
|
161
|
-
*/
|
|
162
|
-
memorySnapshot(): RuntimeMemorySnapshot | undefined {
|
|
163
|
-
return this.snapshot;
|
|
164
|
-
}
|
|
165
|
-
|
|
166
|
-
/**
|
|
167
|
-
* Publishes the effective concurrency ceiling.
|
|
168
|
-
*
|
|
169
|
-
* Late-bound like `bindInFlight`: the ceiling is derived from the memory
|
|
170
|
-
* limit, which is known here, but the guard that owns it is constructed in
|
|
171
|
-
* the transport layer.
|
|
172
|
-
*/
|
|
173
|
-
|
|
174
|
-
constructor(
|
|
175
|
-
private readonly sources: RuntimeMetricSources,
|
|
176
|
-
limitBytes: number | undefined = resolveMemoryLimitBytes(),
|
|
177
|
-
// Off by default: a forced collection is not how memory is meant to come
|
|
178
|
-
// back. The synchronous form (`Bun.gc(true)`) stops the shared event loop
|
|
179
|
-
// outright, so every concurrent request stalls inside it — which reads as
|
|
180
|
-
// a latency spike with no upstream cause. And it hides the actual defect:
|
|
181
|
-
// a process that only returns memory when something forces it to is a
|
|
182
|
-
// process that is still holding it. Bounded caches, released streams, and
|
|
183
|
-
// batched writers reclaim naturally; this is opt-in for an operator who
|
|
184
|
-
// has measured that they need it.
|
|
185
|
-
gcOnPressure = readFlag("CARTETHYIA_FORCED_GC", CONFIG_SPEC.CARTETHYIA_FORCED_GC),
|
|
186
|
-
) {
|
|
187
|
-
this.limitBytes = limitBytes;
|
|
188
|
-
this.gcOnPressure = gcOnPressure;
|
|
189
|
-
}
|
|
190
|
-
|
|
191
|
-
sample(): void {
|
|
192
|
-
const memory = process.memoryUsage();
|
|
193
|
-
metrics.cartethyia_memory_rss_bytes.set(memory.rss);
|
|
194
|
-
metrics.cartethyia_memory_heap_used_bytes.set(memory.heapUsed);
|
|
195
|
-
metrics.cartethyia_memory_heap_total_bytes.set(memory.heapTotal);
|
|
196
|
-
metrics.cartethyia_memory_limit_bytes.set(this.limitBytes ?? 0);
|
|
197
|
-
this.snapshot = {
|
|
198
|
-
rssBytes: memory.rss,
|
|
199
|
-
limitBytes: this.limitBytes,
|
|
200
|
-
sampledAtMs: Date.now(),
|
|
201
|
-
};
|
|
202
|
-
trackMemoryUsage("rss", memory.rss);
|
|
203
|
-
trackMemoryUsage("heap_used", memory.heapUsed);
|
|
204
|
-
trackMemoryUsage("heap_total", memory.heapTotal);
|
|
205
|
-
trackMemoryUsage("external", memory.external);
|
|
206
|
-
|
|
207
|
-
const roundRobin = this.sources.routingRoundRobinEntries();
|
|
208
|
-
metrics.cartethyia_routing_roundrobin_entries.set(roundRobin.combo, { scope: "combo" });
|
|
209
|
-
metrics.cartethyia_routing_roundrobin_entries.set(roundRobin.provider, { scope: "provider" });
|
|
210
|
-
metrics.cartethyia_ip_abuse_keys.set(this.sources.ipAbuseKeys());
|
|
211
|
-
metrics.cartethyia_quota_cache_entries.set(this.sources.quotaCacheEntries());
|
|
212
|
-
metrics.cartethyia_model_catalog_entries.set(this.sources.modelCatalogEntries());
|
|
213
|
-
metrics.cartethyia_process_in_flight.set(this.inFlightSource());
|
|
214
|
-
const backlog = this.sources.telemetryBacklog?.();
|
|
215
|
-
if (backlog !== undefined) {
|
|
216
|
-
metrics.cartethyia_telemetry_backlog_items.set(backlog.items);
|
|
217
|
-
metrics.cartethyia_telemetry_backlog_bytes.set(backlog.bytes);
|
|
218
|
-
}
|
|
219
|
-
|
|
220
|
-
this.checkPressure(memory.rss);
|
|
221
|
-
this.checkIdleReclaim(memory.rss);
|
|
222
|
-
}
|
|
223
|
-
|
|
224
|
-
private checkPressure(rss: number): void {
|
|
225
|
-
if (this.limitBytes === undefined || rss <= this.limitBytes * PRESSURE_THRESHOLD) return;
|
|
226
|
-
const now = Date.now();
|
|
227
|
-
if (this.gcOnPressure && now - this.lastGcAt >= PRESSURE_GC_WINDOW_MS) {
|
|
228
|
-
this.lastGcAt = now;
|
|
229
|
-
// Asynchronous GC: reclaims before the limit is reached without stalling
|
|
230
|
-
// in-flight requests on the shared event loop. A synchronous full
|
|
231
|
-
// collection here would add a latency spike to every concurrent request.
|
|
232
|
-
Bun.gc(false);
|
|
233
|
-
}
|
|
234
|
-
if (now - this.lastPressureLogAt < PRESSURE_LOG_WINDOW_MS) return;
|
|
235
|
-
this.lastPressureLogAt = now;
|
|
236
|
-
log.warn(
|
|
237
|
-
`[memory] RSS ${(rss / 1024 / 1024).toFixed(1)}MiB exceeds 80% of the ` +
|
|
238
|
-
`${(this.limitBytes / 1024 / 1024).toFixed(1)}MiB limit`,
|
|
239
|
-
);
|
|
240
|
-
}
|
|
241
|
-
|
|
242
|
-
/**
|
|
243
|
-
* Reclaims memory while the gateway is idle.
|
|
244
|
-
*
|
|
245
|
-
* Triggered on RSS growth rather than heap growth: this process holds most of
|
|
246
|
-
* its footprint off-heap (adapters, TLS contexts, buffers), and `heapUsed`
|
|
247
|
-
* barely moves when those are allocated — a heap-based gate never opens, which
|
|
248
|
-
* is why an idle gateway held its startup peak indefinitely.
|
|
249
|
-
*
|
|
250
|
-
* A synchronous full collection is safe here because no request is in flight to
|
|
251
|
-
* stall, and it is the only form that actually returns pages to the OS; the
|
|
252
|
-
* async form used under pressure does not.
|
|
253
|
-
*
|
|
254
|
-
* Not gated on `gcOnPressure` the way `checkPressure` is. The opt-in exists
|
|
255
|
-
* because a synchronous collection stalls concurrent requests, and this path
|
|
256
|
-
* only runs when there is nothing in flight to stall — so the reason the flag
|
|
257
|
-
* is off does not apply to it. Gating it meant a gateway with no traffic at
|
|
258
|
-
* all never reclaimed anything and held its startup peak for hours.
|
|
259
|
-
*/
|
|
260
|
-
private checkIdleReclaim(rss: number): void {
|
|
261
|
-
if (this.inFlightSource() > 0) return;
|
|
262
|
-
const now = Date.now();
|
|
263
|
-
if (now - this.lastIdleGcAt < IDLE_GC_WINDOW_MS) return;
|
|
264
|
-
if (this.rssAtLastGc !== 0 && rss - this.rssAtLastGc < IDLE_GC_MIN_GROWTH_BYTES) return;
|
|
265
|
-
this.lastIdleGcAt = now;
|
|
266
|
-
Bun.gc(true);
|
|
267
|
-
this.rssAtLastGc = process.memoryUsage().rss;
|
|
268
|
-
}
|
|
269
|
-
}
|
|
1
|
+
/**
|
|
2
|
+
* Periodic runtime gauges: process memory plus bounded-collection sizes.
|
|
3
|
+
*
|
|
4
|
+
* Sampling is driven by `ScheduledTaskRegistry` (unref'd interval) rather than
|
|
5
|
+
* a dedicated timer so it shares the process's existing maintenance lifecycle
|
|
6
|
+
* and stops with it. Map/collection sizes are read through closures supplied at
|
|
7
|
+
* composition time; this module owns no references to the routing or pool
|
|
8
|
+
* layers, keeping the observability boundary one-directional.
|
|
9
|
+
*/
|
|
10
|
+
import { readFileSync } from "node:fs";
|
|
11
|
+
import os from "node:os";
|
|
12
|
+
import { metrics } from "./metrics";
|
|
13
|
+
import { trackMemoryUsage } from "./performance-metrics";
|
|
14
|
+
import { log } from "./logger";
|
|
15
|
+
import { resolveMemoryLimitOverrideBytes } from "../config/timeouts";
|
|
16
|
+
import { CONFIG_SPEC, readFlag } from "../config/spec";
|
|
17
|
+
import type { TelemetryBacklogSnapshot } from "./telemetry-buffer";
|
|
18
|
+
/**
|
|
19
|
+
* The memory figures a shed decision needs.
|
|
20
|
+
*
|
|
21
|
+
* Published once per sample instead of re-measured per request:
|
|
22
|
+
* `process.memoryUsage()` is not free, and the headroom guard runs on every
|
|
23
|
+
* request. A snapshot up to one sample interval old is the right trade — the
|
|
24
|
+
* guard is protecting against a spike that arrives over seconds, not one
|
|
25
|
+
* request's worth of allocation.
|
|
26
|
+
*/
|
|
27
|
+
export interface RuntimeMemorySnapshot {
|
|
28
|
+
readonly rssBytes: number;
|
|
29
|
+
/** The resolved limit; `undefined` when none could be discovered. */
|
|
30
|
+
readonly limitBytes: number | undefined;
|
|
31
|
+
readonly sampledAtMs: number;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** Live collection sizes sampled alongside process memory. */
|
|
35
|
+
export interface RuntimeMetricSources {
|
|
36
|
+
readonly routingRoundRobinEntries: () => { readonly combo: number; readonly provider: number };
|
|
37
|
+
/**
|
|
38
|
+
* Tracked keys in the in-memory IP abuse store, or 0 when the store is
|
|
39
|
+
* Redis-backed. Unlike the routing maps this one is fed by client-supplied
|
|
40
|
+
* identities, so its size is the leak signal to watch.
|
|
41
|
+
*/
|
|
42
|
+
readonly ipAbuseKeys: () => number;
|
|
43
|
+
/**
|
|
44
|
+
* The telemetry backlog's current shape, or `undefined` when no buffer is
|
|
45
|
+
* wired (tests, and the console-only shell).
|
|
46
|
+
*/
|
|
47
|
+
readonly telemetryBacklog?: () => TelemetryBacklogSnapshot | undefined;
|
|
48
|
+
/** Tracked keys in the in-process quota cache tier (0 when Redis-only paths are idle). */
|
|
49
|
+
readonly quotaCacheEntries: () => number;
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Entries in the per-provider model-catalog cache. Bounded, but a count that
|
|
53
|
+
* sits at the cap means every rebuild is evicting something it needs.
|
|
54
|
+
*/
|
|
55
|
+
readonly modelCatalogEntries: () => number;
|
|
56
|
+
/**
|
|
57
|
+
* Live in-flight request count. Zero means the gateway is idle, so a
|
|
58
|
+
* synchronous full collection is safe (no request to stall).
|
|
59
|
+
*/
|
|
60
|
+
readonly inFlight: () => number;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** Cooldown between memory-pressure warnings so a sustained high-water mark does not flood logs. */
|
|
64
|
+
const PRESSURE_LOG_WINDOW_MS = 60_000;
|
|
65
|
+
/** Cooldown between proactive garbage collections while pressure persists. */
|
|
66
|
+
const PRESSURE_GC_WINDOW_MS = 60_000;
|
|
67
|
+
/** Fraction of the resolved limit at which memory pressure is reported. */
|
|
68
|
+
const PRESSURE_THRESHOLD = 0.8;
|
|
69
|
+
/**
|
|
70
|
+
* Fallback limit when neither an override nor a cgroup limit is discoverable
|
|
71
|
+
* (native development, macOS, Windows).
|
|
72
|
+
*
|
|
73
|
+
* A fixed 512 MiB made every machine without a cgroup look like a 512 MiB
|
|
74
|
+
* container. On a desktop running the gateway natively that is far below what
|
|
75
|
+
* the machine has, so the pressure check fired against a number that described
|
|
76
|
+
* nothing — "RSS 430 MiB exceeds 80% of the 512 MiB limit" on a machine with
|
|
77
|
+
* gigabytes free — and the concurrency cap derived from it admitted a handful
|
|
78
|
+
* of requests while every tenant-level setting sat unreachable above it.
|
|
79
|
+
*
|
|
80
|
+
* The host's real memory is the honest stand-in. It is what the process can
|
|
81
|
+
* actually grow into here, and it is what "no container limit" means.
|
|
82
|
+
*/
|
|
83
|
+
function defaultMemoryLimitBytes(): number {
|
|
84
|
+
try {
|
|
85
|
+
const total = os.totalmem();
|
|
86
|
+
if (Number.isFinite(total) && total > 0) return total;
|
|
87
|
+
} catch {
|
|
88
|
+
// Platform without totalmem; fall through to the last-resort constant.
|
|
89
|
+
}
|
|
90
|
+
return LAST_RESORT_MEMORY_LIMIT_BYTES;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Used only when the host's memory cannot be read at all. Deliberately large
|
|
95
|
+
* so an unmeasurable host is not treated as a small container.
|
|
96
|
+
*/
|
|
97
|
+
const LAST_RESORT_MEMORY_LIMIT_BYTES = 4 * 1024 * 1024 * 1024;
|
|
98
|
+
/** Cooldown between idle full collections. */
|
|
99
|
+
const IDLE_GC_WINDOW_MS = 5 * 60_000;
|
|
100
|
+
/** RSS retained above the post-collection baseline before another idle collection runs. */
|
|
101
|
+
const IDLE_GC_MIN_GROWTH_BYTES = 32 * 1024 * 1024;
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Resolves the effective memory limit for the process.
|
|
105
|
+
*
|
|
106
|
+
* Precedence: an explicit `CARTETHYIA_MEMORY_LIMIT_BYTES` override, then the
|
|
107
|
+
* cgroup v2/v1 limit when running in a container. Falls back to
|
|
108
|
+
* {@link DEFAULT_MEMORY_LIMIT_BYTES} when no limit is discoverable (native
|
|
109
|
+
* development, macOS, Windows) so pressure-based reclaim still runs.
|
|
110
|
+
*/
|
|
111
|
+
export function resolveMemoryLimitBytes(): number | undefined {
|
|
112
|
+
const override = resolveMemoryLimitOverrideBytes();
|
|
113
|
+
if (override !== undefined) return override;
|
|
114
|
+
if (process.platform !== "linux") return defaultMemoryLimitBytes();
|
|
115
|
+
for (const path of [
|
|
116
|
+
"/sys/fs/cgroup/memory.max",
|
|
117
|
+
"/sys/fs/cgroup/memory/memory.limit_in_bytes",
|
|
118
|
+
]) {
|
|
119
|
+
try {
|
|
120
|
+
const content = readFileSync(path, "utf8").trim();
|
|
121
|
+
if (content === "max") continue;
|
|
122
|
+
const value = Number(content);
|
|
123
|
+
// The v1 unlimited sentinel is a huge finite number; treat it as unknown.
|
|
124
|
+
if (Number.isInteger(value) && value > 0 && value < 2 ** 53) return value;
|
|
125
|
+
} catch {
|
|
126
|
+
// File absent (cgroup v1 vs v2) or unreadable — try the next source.
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
return defaultMemoryLimitBytes();
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/** Samples process memory and bounded-collection sizes into the metric registry. */
|
|
133
|
+
export class RuntimeMetricsSampler {
|
|
134
|
+
private readonly limitBytes: number | undefined;
|
|
135
|
+
private readonly gcOnPressure: boolean;
|
|
136
|
+
private lastPressureLogAt = 0;
|
|
137
|
+
private lastGcAt = 0;
|
|
138
|
+
private lastIdleGcAt = 0;
|
|
139
|
+
private rssAtLastGc = 0;
|
|
140
|
+
private inFlightSource: () => number = () => 0;
|
|
141
|
+
/**
|
|
142
|
+
* The latest snapshot, published once per sample. Stays undefined until the
|
|
143
|
+
* first sample runs; consumers must treat that as "unknown", not as zero.
|
|
144
|
+
*/
|
|
145
|
+
private snapshot: RuntimeMemorySnapshot | undefined;
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* Binds the live in-flight count after the request state store exists. The
|
|
149
|
+
* sampler is constructed before that store, so the source is late-bound.
|
|
150
|
+
*/
|
|
151
|
+
bindInFlight(source: () => number): void {
|
|
152
|
+
this.inFlightSource = source;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* The last sampled memory state, for consumers on a hot path.
|
|
157
|
+
*
|
|
158
|
+
* Undefined before the first sample: a guard that read zero as "plenty of
|
|
159
|
+
* room" would disable itself exactly when it matters, so callers must fall
|
|
160
|
+
* back to their own judgement when this is absent.
|
|
161
|
+
*/
|
|
162
|
+
memorySnapshot(): RuntimeMemorySnapshot | undefined {
|
|
163
|
+
return this.snapshot;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* Publishes the effective concurrency ceiling.
|
|
168
|
+
*
|
|
169
|
+
* Late-bound like `bindInFlight`: the ceiling is derived from the memory
|
|
170
|
+
* limit, which is known here, but the guard that owns it is constructed in
|
|
171
|
+
* the transport layer.
|
|
172
|
+
*/
|
|
173
|
+
|
|
174
|
+
constructor(
|
|
175
|
+
private readonly sources: RuntimeMetricSources,
|
|
176
|
+
limitBytes: number | undefined = resolveMemoryLimitBytes(),
|
|
177
|
+
// Off by default: a forced collection is not how memory is meant to come
|
|
178
|
+
// back. The synchronous form (`Bun.gc(true)`) stops the shared event loop
|
|
179
|
+
// outright, so every concurrent request stalls inside it — which reads as
|
|
180
|
+
// a latency spike with no upstream cause. And it hides the actual defect:
|
|
181
|
+
// a process that only returns memory when something forces it to is a
|
|
182
|
+
// process that is still holding it. Bounded caches, released streams, and
|
|
183
|
+
// batched writers reclaim naturally; this is opt-in for an operator who
|
|
184
|
+
// has measured that they need it.
|
|
185
|
+
gcOnPressure = readFlag("CARTETHYIA_FORCED_GC", CONFIG_SPEC.CARTETHYIA_FORCED_GC),
|
|
186
|
+
) {
|
|
187
|
+
this.limitBytes = limitBytes;
|
|
188
|
+
this.gcOnPressure = gcOnPressure;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
sample(): void {
|
|
192
|
+
const memory = process.memoryUsage();
|
|
193
|
+
metrics.cartethyia_memory_rss_bytes.set(memory.rss);
|
|
194
|
+
metrics.cartethyia_memory_heap_used_bytes.set(memory.heapUsed);
|
|
195
|
+
metrics.cartethyia_memory_heap_total_bytes.set(memory.heapTotal);
|
|
196
|
+
metrics.cartethyia_memory_limit_bytes.set(this.limitBytes ?? 0);
|
|
197
|
+
this.snapshot = {
|
|
198
|
+
rssBytes: memory.rss,
|
|
199
|
+
limitBytes: this.limitBytes,
|
|
200
|
+
sampledAtMs: Date.now(),
|
|
201
|
+
};
|
|
202
|
+
trackMemoryUsage("rss", memory.rss);
|
|
203
|
+
trackMemoryUsage("heap_used", memory.heapUsed);
|
|
204
|
+
trackMemoryUsage("heap_total", memory.heapTotal);
|
|
205
|
+
trackMemoryUsage("external", memory.external);
|
|
206
|
+
|
|
207
|
+
const roundRobin = this.sources.routingRoundRobinEntries();
|
|
208
|
+
metrics.cartethyia_routing_roundrobin_entries.set(roundRobin.combo, { scope: "combo" });
|
|
209
|
+
metrics.cartethyia_routing_roundrobin_entries.set(roundRobin.provider, { scope: "provider" });
|
|
210
|
+
metrics.cartethyia_ip_abuse_keys.set(this.sources.ipAbuseKeys());
|
|
211
|
+
metrics.cartethyia_quota_cache_entries.set(this.sources.quotaCacheEntries());
|
|
212
|
+
metrics.cartethyia_model_catalog_entries.set(this.sources.modelCatalogEntries());
|
|
213
|
+
metrics.cartethyia_process_in_flight.set(this.inFlightSource());
|
|
214
|
+
const backlog = this.sources.telemetryBacklog?.();
|
|
215
|
+
if (backlog !== undefined) {
|
|
216
|
+
metrics.cartethyia_telemetry_backlog_items.set(backlog.items);
|
|
217
|
+
metrics.cartethyia_telemetry_backlog_bytes.set(backlog.bytes);
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
this.checkPressure(memory.rss);
|
|
221
|
+
this.checkIdleReclaim(memory.rss);
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
private checkPressure(rss: number): void {
|
|
225
|
+
if (this.limitBytes === undefined || rss <= this.limitBytes * PRESSURE_THRESHOLD) return;
|
|
226
|
+
const now = Date.now();
|
|
227
|
+
if (this.gcOnPressure && now - this.lastGcAt >= PRESSURE_GC_WINDOW_MS) {
|
|
228
|
+
this.lastGcAt = now;
|
|
229
|
+
// Asynchronous GC: reclaims before the limit is reached without stalling
|
|
230
|
+
// in-flight requests on the shared event loop. A synchronous full
|
|
231
|
+
// collection here would add a latency spike to every concurrent request.
|
|
232
|
+
Bun.gc(false);
|
|
233
|
+
}
|
|
234
|
+
if (now - this.lastPressureLogAt < PRESSURE_LOG_WINDOW_MS) return;
|
|
235
|
+
this.lastPressureLogAt = now;
|
|
236
|
+
log.warn(
|
|
237
|
+
`[memory] RSS ${(rss / 1024 / 1024).toFixed(1)}MiB exceeds 80% of the ` +
|
|
238
|
+
`${(this.limitBytes / 1024 / 1024).toFixed(1)}MiB limit`,
|
|
239
|
+
);
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/**
|
|
243
|
+
* Reclaims memory while the gateway is idle.
|
|
244
|
+
*
|
|
245
|
+
* Triggered on RSS growth rather than heap growth: this process holds most of
|
|
246
|
+
* its footprint off-heap (adapters, TLS contexts, buffers), and `heapUsed`
|
|
247
|
+
* barely moves when those are allocated — a heap-based gate never opens, which
|
|
248
|
+
* is why an idle gateway held its startup peak indefinitely.
|
|
249
|
+
*
|
|
250
|
+
* A synchronous full collection is safe here because no request is in flight to
|
|
251
|
+
* stall, and it is the only form that actually returns pages to the OS; the
|
|
252
|
+
* async form used under pressure does not.
|
|
253
|
+
*
|
|
254
|
+
* Not gated on `gcOnPressure` the way `checkPressure` is. The opt-in exists
|
|
255
|
+
* because a synchronous collection stalls concurrent requests, and this path
|
|
256
|
+
* only runs when there is nothing in flight to stall — so the reason the flag
|
|
257
|
+
* is off does not apply to it. Gating it meant a gateway with no traffic at
|
|
258
|
+
* all never reclaimed anything and held its startup peak for hours.
|
|
259
|
+
*/
|
|
260
|
+
private checkIdleReclaim(rss: number): void {
|
|
261
|
+
if (this.inFlightSource() > 0) return;
|
|
262
|
+
const now = Date.now();
|
|
263
|
+
if (now - this.lastIdleGcAt < IDLE_GC_WINDOW_MS) return;
|
|
264
|
+
if (this.rssAtLastGc !== 0 && rss - this.rssAtLastGc < IDLE_GC_MIN_GROWTH_BYTES) return;
|
|
265
|
+
this.lastIdleGcAt = now;
|
|
266
|
+
Bun.gc(true);
|
|
267
|
+
this.rssAtLastGc = process.memoryUsage().rss;
|
|
268
|
+
}
|
|
269
|
+
}
|
|
@@ -15,6 +15,7 @@
|
|
|
15
15
|
// Same shape as `persistence/usage-flush.ts`: enqueue and return, a timer
|
|
16
16
|
// flushes, shutdown flushes what remains. A crash loses the unflushed rows and
|
|
17
17
|
// nothing else — the durable state routing depends on was never in here.
|
|
18
|
+
import { randomUUID } from "node:crypto";
|
|
18
19
|
import type { CartethyiaDatabase } from "./db-handle";
|
|
19
20
|
import { healthEvents } from "./schema";
|
|
20
21
|
|
|
@@ -141,6 +142,7 @@ export class HealthEventFlushBuffer {
|
|
|
141
142
|
try {
|
|
142
143
|
await this.db.insert(healthEvents).values(
|
|
143
144
|
batch.map((row) => ({
|
|
145
|
+
id: randomUUID(),
|
|
144
146
|
entityKind: row.entityKind,
|
|
145
147
|
accountId: row.accountId ?? null,
|
|
146
148
|
networkPoolId: row.networkPoolId ?? null,
|
|
@@ -490,6 +490,24 @@ export const networkPools = pgTable("network_pools", {
|
|
|
490
490
|
/** The endpoint the verdict was measured against. Compared on read, so an
|
|
491
491
|
* edited endpoint reports "never probed" instead of a stale verdict. */
|
|
492
492
|
providerProbeEndpoint: text("provider_probe_endpoint"),
|
|
493
|
+
/**
|
|
494
|
+
* The database mirror of the pool selector's provider cooldown state, one
|
|
495
|
+
* entry per cooling provider keyed by its lowercased id:
|
|
496
|
+
* `{ "opencode": { "until": <epoch ms>, "reason": "...", "blocked": false } }`.
|
|
497
|
+
*
|
|
498
|
+
* The selector's own map is process-local (mirrored to Redis when present),
|
|
499
|
+
* so a restart dropped every flag while the upstream kept refusing the
|
|
500
|
+
* egress — dispatch re-picked a spent pool and the console showed nothing.
|
|
501
|
+
* The dispatch-side flaggers write each marker here, and boot reads this
|
|
502
|
+
* column back into the selector, so the state survives a restart the way the
|
|
503
|
+
* upstream's memory of the address does.
|
|
504
|
+
*
|
|
505
|
+
* `null` means no provider was ever flagged. Expired entries are left inert
|
|
506
|
+
* until the next write for the pool rather than swept eagerly: a marker with
|
|
507
|
+
* `until` in the past is skipped by every reader.
|
|
508
|
+
*/
|
|
509
|
+
providerCooldowns: jsonb("provider_cooldowns")
|
|
510
|
+
.$type<Record<string, { until: number; reason: string; blocked?: boolean }>>(),
|
|
493
511
|
/** Metered egress totals, flushed from the in-memory socket counters on
|
|
494
512
|
* every pool-touching write so the quota bar survives a restart. */
|
|
495
513
|
bytesSentTotal: bigint("bytes_sent_total", { mode: "number" }).notNull().default(0),
|
|
@@ -676,6 +694,7 @@ export const providerRoutingSettings = pgTable(
|
|
|
676
694
|
export const poolRoutingStrategy = pgEnum("pool_routing_strategy", [
|
|
677
695
|
"least_loaded",
|
|
678
696
|
"round_robin",
|
|
697
|
+
"random",
|
|
679
698
|
]);
|
|
680
699
|
export type PoolRoutingStrategy = (typeof poolRoutingStrategy.enumValues)[number];
|
|
681
700
|
|
|
@@ -145,13 +145,18 @@ export class UsageFlushBuffer {
|
|
|
145
145
|
* cost the others their delta. A key whose write fails goes back into the
|
|
146
146
|
* pending map, added to anything enqueued while the flush ran, so a
|
|
147
147
|
* transient failure retries instead of dropping.
|
|
148
|
+
*
|
|
149
|
+
* `force` bypasses the outage backoff. The scheduled timer never passes it —
|
|
150
|
+
* a failing database should not be probed on every tick — but `stop({flush:
|
|
151
|
+
* true})` must, or a shutdown during a backoff window would return without
|
|
152
|
+
* even attempting to persist the pending deltas.
|
|
148
153
|
*/
|
|
149
|
-
async flush(): Promise<void> {
|
|
154
|
+
async flush(options?: { force?: boolean }): Promise<void> {
|
|
150
155
|
if (this.flushing || this.pending.size === 0) return;
|
|
151
156
|
// Backing off during a sustained outage: without this the writer probed a
|
|
152
157
|
// failing database on every tick, adding write load to the very overload
|
|
153
158
|
// that caused the failure.
|
|
154
|
-
if (Date.now() < this.backoffUntil) return;
|
|
159
|
+
if (Date.now() < this.backoffUntil && options?.force !== true) return;
|
|
155
160
|
this.flushing = true;
|
|
156
161
|
const batch = new Map(this.pending);
|
|
157
162
|
this.pending.clear();
|
|
@@ -210,7 +215,7 @@ export class UsageFlushBuffer {
|
|
|
210
215
|
clearTimeout(this.timer);
|
|
211
216
|
this.timer = undefined;
|
|
212
217
|
}
|
|
213
|
-
if (options?.flush) await this.flush();
|
|
218
|
+
if (options?.flush) await this.flush({ force: true });
|
|
214
219
|
}
|
|
215
220
|
|
|
216
221
|
private arm(): void {
|
|
@@ -52,7 +52,7 @@ export type ServiceKind = (typeof SERVICE_KINDS)[number];
|
|
|
52
52
|
* their membership set from it, and the probe route's accepted subset is
|
|
53
53
|
* declared separately because it is deliberately narrower.
|
|
54
54
|
*/
|
|
55
|
-
export const REASONING_EFFORTS = ["none", "minimal", "low", "medium", "high", "xhigh", "max"] as const;
|
|
55
|
+
export const REASONING_EFFORTS = ["none", "instant", "minimal", "low", "medium", "high", "xhigh", "max"] as const;
|
|
56
56
|
|
|
57
57
|
/** One canonical reasoning effort. */
|
|
58
58
|
export type ReasoningEffort = (typeof REASONING_EFFORTS)[number];
|