cartethyia 2.5.10 → 2.6.0
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 +8 -0
- package/dist/dashboard/assets/{BannedPage-Cd30Z0b9.js → BannedPage-l6XMnfr9.js} +1 -1
- package/dist/dashboard/assets/BannedPage-l6XMnfr9.js.gz +0 -0
- package/dist/dashboard/assets/{CliToolDetailPage-wf5u-ww6.js → CliToolDetailPage-DwOzAW45.js} +1 -1
- package/dist/dashboard/assets/CliToolDetailPage-DwOzAW45.js.gz +0 -0
- package/dist/dashboard/assets/{CliToolsPage-DykPvuck.js → CliToolsPage-BT3Up3QA.js} +1 -1
- package/dist/dashboard/assets/CliToolsPage-BT3Up3QA.js.gz +0 -0
- package/dist/dashboard/assets/{CombosPage-DwvSD3vs.js → CombosPage-6kYIUpzR.js} +1 -1
- package/dist/dashboard/assets/CombosPage-6kYIUpzR.js.gz +0 -0
- package/dist/dashboard/assets/{ConsoleLogPage-CIr7KDex.js → ConsoleLogPage-zRnj1mlR.js} +1 -1
- package/dist/dashboard/assets/ConsoleLogPage-zRnj1mlR.js.gz +0 -0
- package/dist/dashboard/assets/{CustomizationPage-82X0DDdH.js → CustomizationPage-C7m-H1Rz.js} +1 -1
- package/dist/dashboard/assets/CustomizationPage-C7m-H1Rz.js.gz +0 -0
- package/dist/dashboard/assets/HealthEventsModal-C8MR_BdB.js +1 -0
- package/dist/dashboard/assets/HealthEventsModal-C8MR_BdB.js.gz +0 -0
- package/dist/dashboard/assets/{LoginPage-CS5mPuVF.js → LoginPage-CbphLvjg.js} +1 -1
- package/dist/dashboard/assets/LoginPage-CbphLvjg.js.gz +0 -0
- package/dist/dashboard/assets/{ModelPicker-C0UCUsVJ.js → ModelPicker-DVny4S5_.js} +1 -1
- package/dist/dashboard/assets/ModelPicker-DVny4S5_.js.gz +0 -0
- package/dist/dashboard/assets/OverviewPage-DABu2Wgt.js +1 -0
- package/dist/dashboard/assets/OverviewPage-DABu2Wgt.js.gz +0 -0
- package/dist/dashboard/assets/ProviderDetailPage-C15x9q13.js +3 -0
- package/dist/dashboard/assets/ProviderDetailPage-C15x9q13.js.gz +0 -0
- package/dist/dashboard/assets/{ProvidersPage-vdja5ZPK.js → ProvidersPage-Dfb2896L.js} +1 -1
- package/dist/dashboard/assets/ProvidersPage-Dfb2896L.js.gz +0 -0
- package/dist/dashboard/assets/{ProxyPage-BB4CNm4-.js → ProxyPage-BaCIdAUt.js} +1 -1
- package/dist/dashboard/assets/ProxyPage-BaCIdAUt.js.gz +0 -0
- package/dist/dashboard/assets/QuotaPage-BsdoCIw1.js +1 -0
- package/dist/dashboard/assets/QuotaPage-BsdoCIw1.js.gz +0 -0
- package/dist/dashboard/assets/SettingsPage-KVJFh3Ip.js +1 -0
- package/dist/dashboard/assets/SettingsPage-KVJFh3Ip.js.gz +0 -0
- package/dist/dashboard/assets/{SetupPage-FadUOl9S.js → SetupPage-D4g6vOGc.js} +1 -1
- package/dist/dashboard/assets/SetupPage-D4g6vOGc.js.gz +0 -0
- package/dist/dashboard/assets/{SortableList-Cfy6_Khv.js → SortableList-C93i14BD.js} +1 -1
- package/dist/dashboard/assets/SortableList-C93i14BD.js.gz +0 -0
- package/dist/dashboard/assets/{StudioPage-CM92fgJf.js → StudioPage-SgQpymjm.js} +1 -1
- package/dist/dashboard/assets/{StudioPage-CM92fgJf.js.gz → StudioPage-SgQpymjm.js.gz} +0 -0
- package/dist/dashboard/assets/{UsagePage-BT4MyQeT.js → UsagePage-BFME-eba.js} +2 -2
- package/dist/dashboard/assets/UsagePage-BFME-eba.js.gz +0 -0
- package/dist/dashboard/assets/{api-keys-DQYLQxpW.js → api-keys-C23S9sgp.js} +1 -1
- package/dist/dashboard/assets/api-keys-C23S9sgp.js.gz +0 -0
- package/dist/dashboard/assets/{app-B7pVRhJ1.js → app-2KhtHCv9.js} +1 -1
- package/dist/dashboard/assets/{app-B7pVRhJ1.js.gz → app-2KhtHCv9.js.gz} +0 -0
- package/dist/dashboard/assets/app-CEeaMHOy.css +2 -0
- package/dist/dashboard/assets/app-CEeaMHOy.css.gz +0 -0
- package/dist/dashboard/assets/{assert-C9FD_JwJ.js → assert-DU6qrMA_.js} +1 -1
- package/dist/dashboard/assets/assert-DU6qrMA_.js.gz +0 -0
- package/dist/dashboard/assets/{badge-Bvyr7D0u.js → badge-CKsF123d.js} +1 -1
- package/dist/dashboard/assets/badge-CKsF123d.js.gz +0 -0
- package/dist/dashboard/assets/{banner-DIvKAZih.js → banner-8j7uChA9.js} +1 -1
- package/dist/dashboard/assets/banner-8j7uChA9.js.gz +0 -0
- package/dist/dashboard/assets/{card-D5_gYIS_.js → card-CdsSlWA3.js} +1 -1
- package/dist/dashboard/assets/card-CdsSlWA3.js.gz +0 -0
- package/dist/dashboard/assets/{cli-tools-CNqzrfdb.js → cli-tools-BaFUuFFF.js} +1 -1
- package/dist/dashboard/assets/cli-tools-BaFUuFFF.js.gz +0 -0
- package/dist/dashboard/assets/{clipboard-button-B6vDBtbm.js → clipboard-button-uL5aYLfW.js} +1 -1
- package/dist/dashboard/assets/clipboard-button-uL5aYLfW.js.gz +0 -0
- package/dist/dashboard/assets/{dialog-CpjrgdwJ.js → dialog-C07hr5_G.js} +1 -1
- package/dist/dashboard/assets/dialog-C07hr5_G.js.gz +0 -0
- package/dist/dashboard/assets/{drawer-BWmt3b5y.js → drawer-BPO1TcKH.js} +1 -1
- package/dist/dashboard/assets/drawer-BPO1TcKH.js.gz +0 -0
- package/dist/dashboard/assets/{entry-C_oakV3z.js → entry-Bz_CtDfC.js} +1 -1
- package/dist/dashboard/assets/entry-Bz_CtDfC.js.gz +0 -0
- package/dist/dashboard/assets/entry-CwPRKk0d.js +4 -0
- package/dist/dashboard/assets/entry-CwPRKk0d.js.gz +0 -0
- package/dist/dashboard/assets/entry-GJRbmrpA.js +1 -0
- package/dist/dashboard/assets/entry-GJRbmrpA.js.gz +0 -0
- package/dist/dashboard/assets/{github-badge-RRHHWGxh.js → github-badge-DmQreSDd.js} +1 -1
- package/dist/dashboard/assets/github-badge-DmQreSDd.js.gz +0 -0
- package/dist/dashboard/assets/{index-Bhey_rg_.js → index-Bblu6erR.js} +2 -2
- package/dist/dashboard/assets/index-Bblu6erR.js.gz +0 -0
- package/dist/dashboard/assets/{inline-C2_EQ5Uf.js → inline-C9dCYaBZ.js} +1 -1
- package/dist/dashboard/assets/inline-C9dCYaBZ.js.gz +0 -0
- package/dist/dashboard/assets/{input-BMRlQ450.js → input-CjCnXweJ.js} +1 -1
- package/dist/dashboard/assets/input-CjCnXweJ.js.gz +0 -0
- package/dist/dashboard/assets/{layout-XR5xP8ew.js → layout-V6y8DAkl.js} +1 -1
- package/dist/dashboard/assets/layout-V6y8DAkl.js.gz +0 -0
- package/dist/dashboard/assets/{list-row-Cw07dioc.js → list-row-CM1AaG_y.js} +1 -1
- package/dist/dashboard/assets/list-row-CM1AaG_y.js.gz +0 -0
- package/dist/dashboard/assets/{live-BivGfuUs.js → live-ta6GMb-8.js} +1 -1
- package/dist/dashboard/assets/live-ta6GMb-8.js.gz +0 -0
- package/dist/dashboard/assets/{model-bans-CmffjLuG.js → model-bans-BmYTmbAk.js} +1 -1
- package/dist/dashboard/assets/model-bans-BmYTmbAk.js.gz +0 -0
- package/dist/dashboard/assets/{mutation-46P0oWMe.js → mutation-g_fduiNw.js} +1 -1
- package/dist/dashboard/assets/mutation-g_fduiNw.js.gz +0 -0
- package/dist/dashboard/assets/{numbers-DrdA9oSM.js → numbers-BOevJzdz.js} +1 -1
- package/dist/dashboard/assets/numbers-BOevJzdz.js.gz +0 -0
- package/dist/dashboard/assets/{query-keys-BFhGpwR1.js → query-keys-UETqfytp.js} +1 -1
- package/dist/dashboard/assets/query-keys-UETqfytp.js.gz +0 -0
- package/dist/dashboard/assets/{routing-CAb4F37R.js → routing-BPUo-W8d.js} +1 -1
- package/dist/dashboard/assets/routing-BPUo-W8d.js.gz +0 -0
- package/dist/dashboard/assets/{select-CxVVB__N.js → select-C980c4cz.js} +1 -1
- package/dist/dashboard/assets/select-C980c4cz.js.gz +0 -0
- package/dist/dashboard/assets/{settings-DNX9sydi.js → settings--3hxSyry.js} +1 -1
- package/dist/dashboard/assets/settings--3hxSyry.js.gz +0 -0
- package/dist/dashboard/assets/{stack-uPOwM9Hs.js → stack-_gfTdLVB.js} +1 -1
- package/dist/dashboard/assets/stack-_gfTdLVB.js.gz +0 -0
- package/dist/dashboard/assets/{state-D1Yz79iL.js → state-2c5V4zo7.js} +1 -1
- package/dist/dashboard/assets/state-2c5V4zo7.js.gz +0 -0
- package/dist/dashboard/assets/{switch-sevsRrRo.js → switch-Dkke7Naf.js} +1 -1
- package/dist/dashboard/assets/switch-Dkke7Naf.js.gz +0 -0
- package/dist/dashboard/assets/{useMutation-Dem0lXQj.js → useMutation-EDFRkn1i.js} +1 -1
- package/dist/dashboard/assets/useMutation-EDFRkn1i.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 +51 -51
- package/migrations/README.md +52 -52
- package/migrations/full-postgres/0020_add_telemetry_event_analytics.sql +77 -0
- package/migrations/full-postgres/0021_add_api_key_concurrency_tolerance.sql +13 -0
- package/migrations/full-postgres/0022_add_inferhub_config_key.sql +17 -0
- package/migrations/full-postgres/0023_drop_api_key_concurrency_tolerance.sql +13 -0
- package/migrations/lite-sqlite3/0020_add_telemetry_event_analytics.sql +69 -0
- package/migrations/lite-sqlite3/0021_add_api_key_concurrency_tolerance.sql +13 -0
- package/migrations/lite-sqlite3/0022_add_inferhub_config_key.sql +17 -0
- package/migrations/lite-sqlite3/0023_drop_api_key_concurrency_tolerance.sql +13 -0
- package/package.json +96 -95
- package/scripts/cli/admin.ts +126 -128
- package/scripts/cli/autostart.ts +237 -237
- package/scripts/cli/background.ts +120 -110
- package/scripts/cli/bin/carte.cjs +8 -8
- package/scripts/cli/bin/cartethyia.cjs +167 -167
- package/scripts/cli/console-menu.ts +39 -30
- package/scripts/cli/controller.ts +145 -0
- package/scripts/cli/env.ts +99 -99
- package/scripts/cli/gateway.ts +264 -260
- package/scripts/cli/instance.ts +234 -224
- package/scripts/cli/main.ts +188 -167
- package/scripts/cli/platform.ts +64 -64
- package/scripts/cli/prompt.ts +217 -79
- package/scripts/cli/services.ts +88 -88
- package/scripts/cli/setup.ts +445 -445
- package/scripts/cli/start.ts +85 -85
- package/scripts/cli/startup-signal.ts +23 -23
- package/scripts/cli/telemetry.ts +70 -0
- package/scripts/cli/tray/index.ts +263 -263
- package/scripts/cli/tray/windows.ps1 +107 -107
- package/scripts/cli/tray.ts +29 -4
- package/scripts/cli/update.ts +39 -39
- package/scripts/cli/version.ts +67 -67
- package/scripts/cli/wordmark.ts +58 -58
- package/src/boot/app.ts +16 -1
- package/src/boot/dependencies.ts +128 -25
- package/src/config/resources.ts +137 -0
- package/src/config/runtime.ts +133 -76
- package/src/config/spec.ts +30 -29
- package/src/config/telemetry.ts +0 -25
- package/src/console/api-keys/bucket-spend.ts +14 -17
- package/src/console/api-keys/contracts.ts +410 -410
- package/src/console/api-keys/routes.ts +12 -0
- package/src/console/audit/store.ts +2 -3
- package/src/console/auth/setup-service.ts +8 -1
- package/src/console/backup/routes.ts +7 -0
- package/src/console/backup/service.ts +463 -463
- package/src/console/backup/store.ts +16 -11
- package/src/console/backup/validate.ts +1 -4
- package/src/console/cli-tools/fs-ops.ts +18 -2
- package/src/console/data-management/contracts.ts +1 -8
- package/src/console/data-management/operations.ts +61 -48
- package/src/console/data-management/routes.ts +4 -2
- package/src/console/domain-registration.ts +25 -3
- package/src/console/inferhub/client.ts +139 -0
- package/src/console/inferhub/key-store.ts +129 -0
- package/src/console/inferhub/normalize.ts +347 -0
- package/src/console/inferhub/routes.ts +411 -0
- package/src/console/inferhub/store.ts +109 -0
- package/src/console/inferhub/types.ts +183 -0
- package/src/console/observability/contracts.ts +2 -41
- package/src/console/observability/store.ts +176 -371
- package/src/console/providers/catalog/public-model-store.ts +0 -0
- package/src/console/providers/catalog/store.ts +23 -36
- package/src/console/quota/account-quota-shared.ts +52 -52
- package/src/console/quota/account-quota-tenant.ts +16 -24
- package/src/console/quota/refresh.ts +441 -441
- package/src/console/routing/pools/import-parse.ts +206 -206
- package/src/console/routing/pools/operations.ts +543 -543
- package/src/console/routing/pools/probe-verdict.ts +132 -132
- package/src/console/routing/pools/routes.ts +181 -181
- package/src/console/settings/contracts.ts +42 -40
- package/src/console/settings/store.ts +25 -25
- package/src/console/share/share-stats.ts +22 -4
- package/src/console/share/share-usage.ts +30 -4
- package/src/console/shared/cursor.ts +15 -0
- package/src/console/updates/routes.ts +12 -0
- package/src/main.ts +13 -5
- package/src/network/pool/resolver.ts +1104 -1104
- package/src/observability/client-name.ts +22 -0
- package/src/observability/event-log.ts +5 -29
- package/src/observability/event-query.ts +690 -0
- package/src/observability/log-ring.ts +26 -2
- package/src/observability/log-sanitize.ts +118 -0
- package/src/observability/logger.ts +17 -13
- package/src/observability/metrics.ts +35 -0
- package/src/observability/payload-capture.ts +114 -41
- package/src/observability/payload-store.ts +249 -24
- package/src/observability/raw-event-writer.ts +118 -0
- package/src/observability/runtime-metrics.ts +71 -1
- package/src/observability/telemetry-buffer.ts +100 -17
- package/src/observability/telemetry-reconciliation.ts +417 -0
- package/src/persistence/boot.ts +20 -9
- package/src/persistence/db-pg.ts +13 -0
- package/src/persistence/db-sqlite.ts +569 -497
- package/src/persistence/dialect.ts +3 -2
- package/src/persistence/migrate.ts +181 -181
- package/src/persistence/schema.ts +1496 -1406
- package/src/persistence/shared-cache.ts +5 -1
- package/src/persistence/stores/api-key-store.ts +581 -581
- package/src/persistence/stores/telemetry-store.ts +124 -63
- package/src/protocol/reasoning-text.ts +30 -0
- package/src/protocol/response/chat.ts +33 -7
- package/src/protocol/response/codex.ts +14 -2
- package/src/protocol/response/messages.ts +5 -2
- package/src/providers/authentication/oauth-flow-store.ts +3 -10
- package/src/providers/bundled-discovery-providers.ts +122 -122
- package/src/providers/default-registry.ts +10 -0
- package/src/providers/discovery/catalog/catalog-blob.ts +18 -18
- package/src/providers/discovery/models-dev-catalog.ts +354 -354
- package/src/providers/discovery/probe-phases.ts +43 -7
- package/src/providers/discovery/probing-service.ts +70 -32
- package/src/providers/integrations/antigravity/antigravity.ts +9 -2
- package/src/providers/integrations/buddy/buddy-catalog-shared.ts +85 -17
- package/src/providers/integrations/buddy/buddy-chat-shared.ts +934 -934
- package/src/providers/integrations/buddy/buddy-checkin.ts +5 -9
- package/src/providers/integrations/buddy/buddy-discovery-shared.ts +3 -7
- package/src/providers/integrations/buddy/buddy-quota-shared.ts +3 -1
- package/src/providers/integrations/buddy/codebuddy/cn-provider.ts +57 -49
- package/src/providers/integrations/buddy/codebuddy/provider.ts +180 -180
- package/src/providers/integrations/buddy/workbuddy/provider.ts +206 -206
- package/src/providers/integrations/buddy/workbuddy/quota.ts +161 -161
- package/src/providers/integrations/claude/claude-quota.ts +2 -2
- package/src/providers/integrations/claude/claude-user-profiles.ts +3 -2
- package/src/providers/integrations/claude/claude.ts +3 -4
- package/src/providers/integrations/cline/cline.ts +94 -8
- package/src/providers/integrations/codex/codex.ts +61 -37
- package/src/providers/integrations/cursor/cursor-catalog.ts +368 -368
- package/src/providers/integrations/cursor/cursor-http2.ts +300 -300
- package/src/providers/integrations/cursor/cursor-identity.ts +2 -4
- package/src/providers/integrations/cursor/cursor-request.ts +11 -0
- package/src/providers/integrations/cursor/cursor.ts +29 -17
- package/src/providers/integrations/cursor/protocol/framing.ts +129 -129
- package/src/providers/integrations/cursor/protocol/messages.ts +2380 -2380
- package/src/providers/integrations/cursor/protocol/struct.ts +305 -305
- package/src/providers/integrations/cursor/protocol/tool-canonical.ts +275 -0
- package/src/providers/integrations/grok/grok-quota.ts +279 -279
- package/src/providers/integrations/grok/grok-ratelimit-store.ts +73 -73
- package/src/providers/integrations/grok/grok-ratelimit.ts +218 -218
- package/src/providers/integrations/hermes.ts +7 -1
- package/src/providers/integrations/inferhub.ts +201 -73
- package/src/providers/integrations/kilo/kilo.ts +152 -152
- package/src/providers/integrations/kiro/kiro-machine-id.ts +2 -4
- package/src/providers/integrations/openrouter/provider.ts +129 -129
- package/src/providers/integrations/qoder/qoder-quota.ts +193 -192
- package/src/providers/integrations/qoder/qoder-realm.ts +106 -106
- package/src/providers/integrations/trae/trae-oauth.ts +4 -26
- package/src/providers/integrations/trae/trae-quota.ts +389 -384
- package/src/providers/integrations/trae/trae-realm.ts +15 -2
- package/src/providers/integrations/xiaomi-mimo/think-stream.ts +100 -24
- package/src/providers/integrations/zcode/oauth.ts +1 -1
- package/src/providers/operations/account-health-service.ts +605 -279
- package/src/providers/provider-metadata.ts +12 -1
- package/src/providers/provider-model-definition.ts +373 -373
- package/src/providers/quota/quota-contracts.ts +3 -7
- package/src/providers/search/search-provider.ts +2 -6
- package/src/security/admission/contracts.ts +10 -0
- package/src/security/admission/in-memory-store.ts +89 -14
- package/src/security/admission/lua.ts +191 -182
- package/src/security/admission/redis-store.ts +30 -0
- package/src/security/admission/service.ts +18 -0
- package/src/security/api-key-auth-cache.ts +96 -0
- package/src/security/api-key-auth.ts +11 -100
- package/src/security/client-router-fingerprint.ts +3 -7
- package/src/security/ip-boundary.ts +7 -3
- package/src/shared/coerce.ts +67 -0
- package/src/shared/hash.ts +6 -0
- package/src/shared/time.ts +7 -0
- package/src/transport/dispatch/attempt-finalize.ts +26 -138
- package/src/transport/dispatch/attempt-loop.ts +103 -81
- package/src/transport/dispatch/capture-policy.ts +78 -0
- package/src/transport/dispatch/proxy-request.ts +4 -2
- package/src/transport/dispatch/streaming/encoder.ts +111 -5
- package/src/transport/dispatch/streaming/stream.ts +19 -2
- package/src/transport/dispatch/streaming/thinking-loop.ts +300 -0
- package/src/transport/failure-policy.ts +27 -0
- package/src/transport/guards/abuse-guard.ts +2 -2
- package/src/transport/guards/auth-guard.ts +17 -0
- package/src/transport/guards/body-policy.ts +3 -2
- package/src/transport/guards/concurrency-guard.ts +89 -13
- package/src/transport/guards/early-rejection-capture.ts +43 -3
- package/src/transport/guards/error-lifecycle.ts +10 -2
- package/src/transport/guards/guard-errors.ts +23 -0
- package/src/transport/guards/headroom-guard.ts +41 -7
- package/src/transport/guards/pipeline.ts +24 -0
- package/src/transport/guards/request-context.ts +2 -1
- package/src/transport/routing/admission.ts +51 -9
- package/src/transport/routing/bounded-lru.ts +49 -0
- package/src/transport/routing/candidate-index.ts +88 -0
- package/src/transport/routing/reservations.ts +121 -7
- package/src/transport/routing/round-robin.ts +10 -22
- package/src/transport/routing/route-catalog.ts +26 -13
- package/src/transport/routing/route-model.ts +22 -2
- package/src/transport/routing/router.ts +53 -31
- package/src/transport/surface/chat/encode.ts +2 -3
- package/src/transport/surface/chat/parse.ts +2 -1
- package/src/transport/surface/dialects.ts +11 -3
- package/src/transport/surface/messages/encode.ts +2 -1
- package/src/transport/surface/messages/parse.ts +3 -1
- package/src/transport/surface/messages/stream.ts +2 -6
- package/src/workers/maintenance-lease.ts +200 -0
- package/src/workers/scheduler.ts +101 -1
- package/dist/dashboard/assets/BannedPage-Cd30Z0b9.js.gz +0 -0
- package/dist/dashboard/assets/CliToolDetailPage-wf5u-ww6.js.gz +0 -0
- package/dist/dashboard/assets/CliToolsPage-DykPvuck.js.gz +0 -0
- package/dist/dashboard/assets/CombosPage-DwvSD3vs.js.gz +0 -0
- package/dist/dashboard/assets/ConsoleLogPage-CIr7KDex.js.gz +0 -0
- package/dist/dashboard/assets/CustomizationPage-82X0DDdH.js.gz +0 -0
- package/dist/dashboard/assets/HealthEventsModal-BYPucTTn.js +0 -1
- package/dist/dashboard/assets/HealthEventsModal-BYPucTTn.js.gz +0 -0
- package/dist/dashboard/assets/LoginPage-CS5mPuVF.js.gz +0 -0
- package/dist/dashboard/assets/ModelPicker-C0UCUsVJ.js.gz +0 -0
- package/dist/dashboard/assets/OverviewPage-Dk_ew3YU.js +0 -1
- package/dist/dashboard/assets/OverviewPage-Dk_ew3YU.js.gz +0 -0
- package/dist/dashboard/assets/ProviderDetailPage-CAiRn2Q0.js +0 -3
- package/dist/dashboard/assets/ProviderDetailPage-CAiRn2Q0.js.gz +0 -0
- package/dist/dashboard/assets/ProvidersPage-vdja5ZPK.js.gz +0 -0
- package/dist/dashboard/assets/ProxyPage-BB4CNm4-.js.gz +0 -0
- package/dist/dashboard/assets/QuotaPage-FEc4iNyq.js +0 -1
- package/dist/dashboard/assets/QuotaPage-FEc4iNyq.js.gz +0 -0
- package/dist/dashboard/assets/SettingsPage-Dlfi5L4N.js +0 -1
- package/dist/dashboard/assets/SettingsPage-Dlfi5L4N.js.gz +0 -0
- package/dist/dashboard/assets/SetupPage-FadUOl9S.js.gz +0 -0
- package/dist/dashboard/assets/SortableList-Cfy6_Khv.js.gz +0 -0
- package/dist/dashboard/assets/UsagePage-BT4MyQeT.js.gz +0 -0
- package/dist/dashboard/assets/api-keys-DQYLQxpW.js.gz +0 -0
- package/dist/dashboard/assets/app-CX_Z8cMs.css +0 -2
- package/dist/dashboard/assets/app-CX_Z8cMs.css.gz +0 -0
- package/dist/dashboard/assets/assert-C9FD_JwJ.js.gz +0 -0
- package/dist/dashboard/assets/badge-Bvyr7D0u.js.gz +0 -0
- package/dist/dashboard/assets/banner-DIvKAZih.js.gz +0 -0
- package/dist/dashboard/assets/card-D5_gYIS_.js.gz +0 -0
- package/dist/dashboard/assets/cli-tools-CNqzrfdb.js.gz +0 -0
- package/dist/dashboard/assets/clipboard-button-B6vDBtbm.js.gz +0 -0
- package/dist/dashboard/assets/dialog-CpjrgdwJ.js.gz +0 -0
- package/dist/dashboard/assets/drawer-BWmt3b5y.js.gz +0 -0
- package/dist/dashboard/assets/entry-B-gvimrg.js +0 -1
- package/dist/dashboard/assets/entry-B-gvimrg.js.gz +0 -0
- package/dist/dashboard/assets/entry-C_oakV3z.js.gz +0 -0
- package/dist/dashboard/assets/entry-Dj0cHfEc.js +0 -4
- package/dist/dashboard/assets/entry-Dj0cHfEc.js.gz +0 -0
- package/dist/dashboard/assets/github-badge-RRHHWGxh.js.gz +0 -0
- package/dist/dashboard/assets/index-Bhey_rg_.js.gz +0 -0
- package/dist/dashboard/assets/inline-C2_EQ5Uf.js.gz +0 -0
- package/dist/dashboard/assets/input-BMRlQ450.js.gz +0 -0
- package/dist/dashboard/assets/layout-XR5xP8ew.js.gz +0 -0
- package/dist/dashboard/assets/list-row-Cw07dioc.js.gz +0 -0
- package/dist/dashboard/assets/live-BivGfuUs.js.gz +0 -0
- package/dist/dashboard/assets/model-bans-CmffjLuG.js.gz +0 -0
- package/dist/dashboard/assets/mutation-46P0oWMe.js.gz +0 -0
- package/dist/dashboard/assets/numbers-DrdA9oSM.js.gz +0 -0
- package/dist/dashboard/assets/query-keys-BFhGpwR1.js.gz +0 -0
- package/dist/dashboard/assets/routing-CAb4F37R.js.gz +0 -0
- package/dist/dashboard/assets/select-CxVVB__N.js.gz +0 -0
- package/dist/dashboard/assets/settings-DNX9sydi.js.gz +0 -0
- package/dist/dashboard/assets/stack-uPOwM9Hs.js.gz +0 -0
- package/dist/dashboard/assets/state-D1Yz79iL.js.gz +0 -0
- package/dist/dashboard/assets/switch-sevsRrRo.js.gz +0 -0
- package/dist/dashboard/assets/useMutation-Dem0lXQj.js.gz +0 -0
- package/src/observability/event-compaction.ts +0 -317
- package/src/observability/event-day-index.ts +0 -439
package/src/boot/app.ts
CHANGED
|
@@ -45,6 +45,7 @@ import {
|
|
|
45
45
|
import { createTokenCountHandler } from "../transport/dispatch/token-count";
|
|
46
46
|
|
|
47
47
|
import { createTransportPipeline } from "../transport/guards/pipeline";
|
|
48
|
+
import { defaultProcessConcurrencyController } from "../transport/guards/concurrency-guard";
|
|
48
49
|
import { createDrainHandler, isLoopbackPeer, tokenMatches } from "../transport/drain-endpoint";
|
|
49
50
|
import { createConsoleAccountResetHandler } from "../console/auth/account-reset";
|
|
50
51
|
import { resetConsoleAuthState } from "../console/auth/account-reset-store";
|
|
@@ -244,6 +245,10 @@ export function createGatewayApp(deps: GatewayAppDeps) {
|
|
|
244
245
|
deps.mode === "production" ? deps.shutdownCoordinator : undefined,
|
|
245
246
|
);
|
|
246
247
|
deps.runtimeMetricsSampler?.bindInFlight(() => requestStateStore.inFlightCount());
|
|
248
|
+
// The concurrency ceiling is derived (memory limit ÷ body budget), so it is
|
|
249
|
+
// published as a gauge: an operator reading "shedding at 16 in flight" needs
|
|
250
|
+
// to see that 16 IS the limit on this box, not that the process is broken.
|
|
251
|
+
deps.runtimeMetricsSampler?.bindConcurrencyLimit(defaultProcessConcurrencyController.limit());
|
|
247
252
|
// Shutdown ordering: abort in-flight proxy controllers first so the
|
|
248
253
|
// bounded drain observes cancellation and finalizers run before
|
|
249
254
|
// telemetry flush and pool close. The reason is threaded through so the
|
|
@@ -379,6 +384,16 @@ export function createGatewayApp(deps: GatewayAppDeps) {
|
|
|
379
384
|
telemetry: deps.telemetryBuffer,
|
|
380
385
|
...(deps.modelStrikes ? { modelStrikes: deps.modelStrikes } : {}),
|
|
381
386
|
...(deps.consoleApi?.redis ? { redis: deps.consoleApi.redis } : {}),
|
|
387
|
+
// Bound at construction so the guard holds the sampler's live snapshot
|
|
388
|
+
// rather than a value captured at boot.
|
|
389
|
+
...(deps.runtimeMetricsSampler
|
|
390
|
+
? { memorySnapshot: () => deps.runtimeMetricsSampler?.memorySnapshot() }
|
|
391
|
+
: {}),
|
|
392
|
+
// Reads the cached route snapshot, which the settings route invalidates
|
|
393
|
+
// when the switch is saved — so flipping it takes effect on the next
|
|
394
|
+
// request and adds no query to the proxy path.
|
|
395
|
+
isInvokeDisabled: async (tenantId) =>
|
|
396
|
+
(await deps.snapshotService.getSnapshot())?.invokeDisabledTenants?.has(tenantId),
|
|
382
397
|
});
|
|
383
398
|
transportPipeline.mountRoot(app);
|
|
384
399
|
const proxyDeps: ProviderProxyHandlerDeps = {
|
|
@@ -657,7 +672,7 @@ export function createGatewayApp(deps: GatewayAppDeps) {
|
|
|
657
672
|
createShareRouter({
|
|
658
673
|
db: deps.db,
|
|
659
674
|
shareStore: new DrizzleShareLinkStore(deps.db),
|
|
660
|
-
stats: createShareStatsPort({ redis: deps.consoleApi?.redis }),
|
|
675
|
+
stats: createShareStatsPort({ db: deps.db, redis: deps.consoleApi?.redis }),
|
|
661
676
|
resolveClientIp: (request) => {
|
|
662
677
|
const peer = peerAddresses.get(request);
|
|
663
678
|
return peer
|
package/src/boot/dependencies.ts
CHANGED
|
@@ -3,6 +3,9 @@ import { assertPoolFitsServerCapacity, bootDatabase, ensureMigrated } from "../p
|
|
|
3
3
|
import { poolMaxFromEnv } from "../persistence/db-pg";
|
|
4
4
|
import type { CartethyiaDatabase } from "../persistence/db-handle";
|
|
5
5
|
import { resolveRedisBackend, resolveRedisClient } from "../persistence/redis";
|
|
6
|
+
import { resolveProcessTopology, type ProcessTopology } from "../config/runtime";
|
|
7
|
+
import { createRedisMaintenanceLeaseStore } from "../workers/maintenance-lease";
|
|
8
|
+
import { resolveDbMode } from "../persistence/db-mode";
|
|
6
9
|
import type { RedisClient } from "../persistence/redis";
|
|
7
10
|
import {
|
|
8
11
|
bundledModelCatalog,
|
|
@@ -71,8 +74,6 @@ import { preferencesReaderFor } from "../persistence/tenant-preferences";
|
|
|
71
74
|
import { sweepExpiredCooldowns } from "../providers/operations/account-health-service";
|
|
72
75
|
import { DrizzleTelemetryStore } from "../persistence/stores/telemetry-store";
|
|
73
76
|
import { TelemetryPayloadCapture } from "../observability/payload-capture";
|
|
74
|
-
import { foldDayIndex, listEventDays } from "../observability/event-day-index";
|
|
75
|
-
import { resolveTelemetryCompactIntervalMs } from "../config/telemetry";
|
|
76
77
|
import { sweepExpiredPoolCooldowns } from "../network/pool-health-machine";
|
|
77
78
|
import { TelemetryBatchBuffer } from "../observability/telemetry-buffer";
|
|
78
79
|
import { RuntimeMetricsSampler } from "../observability/runtime-metrics";
|
|
@@ -86,6 +87,7 @@ import {
|
|
|
86
87
|
import { resolveSsrfPolicy, resolveTrustedProxyBoundary } from "../config/network";
|
|
87
88
|
import type { TrustedProxyBoundary } from "../config/network";
|
|
88
89
|
import { resolveTelemetryRetentionDays } from "../config/telemetry";
|
|
90
|
+
import { resolveConcurrencyTolerance } from "../config/resources";
|
|
89
91
|
import { CONFIG_SPEC, readInt, readList } from "../config/spec";
|
|
90
92
|
import type { ProviderAdapter } from "../providers/provider-contracts";
|
|
91
93
|
import type { ProviderRegistry } from "../providers/provider-registry";
|
|
@@ -145,9 +147,89 @@ const OAUTH_REFRESH_INTER_ITEM_DELAY_MS = 1_500;
|
|
|
145
147
|
const CLIENT_VERSION_MONITOR_INTERVAL_MS = 30 * 60_000;
|
|
146
148
|
const CLIENT_VERSION_INITIAL_DELAY_MS = 10_000;
|
|
147
149
|
|
|
150
|
+
/** Bound on the boot-time Redis probe: a start must fail, not hang. */
|
|
151
|
+
const REDIS_PROBE_TIMEOUT_MS = 2_000;
|
|
152
|
+
|
|
153
|
+
async function withTimeout<T>(promise: Promise<T>, ms: number): Promise<T> {
|
|
154
|
+
const { promise: timeout, reject } = Promise.withResolvers<never>();
|
|
155
|
+
const timer = setTimeout(() => reject(new Error("timeout")), ms);
|
|
156
|
+
try {
|
|
157
|
+
return await Promise.race([promise, timeout]);
|
|
158
|
+
} finally {
|
|
159
|
+
clearTimeout(timer);
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/** Bounded Redis reachability probe: fails rather than hangs. */
|
|
164
|
+
async function redisIsReachable(): Promise<boolean> {
|
|
165
|
+
const client = resolveRedisClient();
|
|
166
|
+
if (!client) return false;
|
|
167
|
+
try {
|
|
168
|
+
const result = await withTimeout(client.ping(), REDIS_PROBE_TIMEOUT_MS);
|
|
169
|
+
return result === "PONG";
|
|
170
|
+
} catch {
|
|
171
|
+
return false;
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* Refuses to start a multi-process deployment whose shared state is not
|
|
177
|
+
* actually shared.
|
|
178
|
+
*
|
|
179
|
+
* Every process shares one port via `SO_REUSEPORT`, so the kernel balances
|
|
180
|
+
* across them — and each process keeps its own in-memory admission counters,
|
|
181
|
+
* quota cache, and cooldown map. Those are only correct when they live in
|
|
182
|
+
* Redis. Starting anyway does not fail loudly; it enforces every per-key limit
|
|
183
|
+
* N times over, which then reads as "the limit did not work".
|
|
184
|
+
*
|
|
185
|
+
* Redis is checked by PING with a hard bound, not merely for being configured:
|
|
186
|
+
* a configured-but-unreachable Redis is the failure mode that matters, and an
|
|
187
|
+
* unbounded connect at boot would hang the start instead of failing it.
|
|
188
|
+
*/
|
|
189
|
+
async function assertMultiProcessPreconditions(
|
|
190
|
+
topology: ProcessTopology,
|
|
191
|
+
redisReachable: () => Promise<boolean>,
|
|
192
|
+
): Promise<void> {
|
|
193
|
+
if (!topology.multiProcess) return;
|
|
194
|
+
if (process.platform !== "linux") {
|
|
195
|
+
throw Object.assign(
|
|
196
|
+
new Error(
|
|
197
|
+
`CARTETHYIA_PROCESS_COUNT=${topology.count} requires Linux (SO_REUSEPORT); ` +
|
|
198
|
+
`this process is on ${process.platform}`,
|
|
199
|
+
),
|
|
200
|
+
{ code: "multi_process_unsupported_platform" },
|
|
201
|
+
);
|
|
202
|
+
}
|
|
203
|
+
if (resolveDbMode() !== "full") {
|
|
204
|
+
throw Object.assign(
|
|
205
|
+
new Error(
|
|
206
|
+
`CARTETHYIA_PROCESS_COUNT=${topology.count} requires CARTETHYIA_DB_MODE=full (PostgreSQL); ` +
|
|
207
|
+
"two processes on one SQLite file corrupt it — there is no cross-process lock in the driver",
|
|
208
|
+
),
|
|
209
|
+
{ code: "multi_process_requires_postgres" },
|
|
210
|
+
);
|
|
211
|
+
}
|
|
212
|
+
const reachable = await redisReachable();
|
|
213
|
+
if (!reachable) {
|
|
214
|
+
throw Object.assign(
|
|
215
|
+
new Error(
|
|
216
|
+
`CARTETHYIA_PROCESS_COUNT=${topology.count} requires a REACHABLE Redis: admission counters, ` +
|
|
217
|
+
"quotas, and cooldowns are per-process without one, so every per-key limit would be " +
|
|
218
|
+
`enforced ${topology.count} times over`,
|
|
219
|
+
),
|
|
220
|
+
{ code: "multi_process_requires_redis" },
|
|
221
|
+
);
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
|
|
148
225
|
export async function buildProductionDeps(): Promise<ProductionDeps> {
|
|
149
226
|
const startupStartedAt = performance.now();
|
|
150
227
|
const startupMemoryBefore = process.memoryUsage();
|
|
228
|
+
// Resolved first and validated before any connection is opened: a
|
|
229
|
+
// half-configured multi-process deploy that starts anyway looks healthy
|
|
230
|
+
// until a per-key limit is enforced N times over.
|
|
231
|
+
const topology = resolveProcessTopology();
|
|
232
|
+
await assertMultiProcessPreconditions(topology, redisIsReachable);
|
|
151
233
|
const handle = await measureStartupPhase("database-connect", bootDatabase);
|
|
152
234
|
const db = handle.db;
|
|
153
235
|
// One rule, no mode flag: REDIS_URL set → shared client, unset → memory.
|
|
@@ -157,7 +239,7 @@ export async function buildProductionDeps(): Promise<ProductionDeps> {
|
|
|
157
239
|
await ensureMigrated();
|
|
158
240
|
// Pool sizing is an external-server concern; the embedded backend has no
|
|
159
241
|
// connection budget to fit into.
|
|
160
|
-
if (handle.kind === "pg") await assertPoolFitsServerCapacity(poolMaxFromEnv());
|
|
242
|
+
if (handle.kind === "pg") await assertPoolFitsServerCapacity(poolMaxFromEnv(), topology.count);
|
|
161
243
|
});
|
|
162
244
|
await measureStartupPhase("provider-seed", () => seedBundledProviders(db));
|
|
163
245
|
// Boot-only: retire global rows for providers the bundle has dropped, so a
|
|
@@ -193,7 +275,6 @@ export async function buildProductionDeps(): Promise<ProductionDeps> {
|
|
|
193
275
|
tenantId: string | null,
|
|
194
276
|
): Promise<readonly { accountId: string; inflight: number }[]> => {
|
|
195
277
|
if (providerId === "" || tenantId === null) return [];
|
|
196
|
-
const snapshot = await routingEngine.accountInflightSnapshot();
|
|
197
278
|
const accounts = await new DrizzleProviderCatalogStore(db, {
|
|
198
279
|
telemetryBuffer,
|
|
199
280
|
bundledModelCatalog: bundledCatalog,
|
|
@@ -202,10 +283,20 @@ export async function buildProductionDeps(): Promise<ProductionDeps> {
|
|
|
202
283
|
snapshotInvalidator: snapshotService,
|
|
203
284
|
}).listAccounts(tenantId, providerId);
|
|
204
285
|
const owned = new Set(accounts.map((account) => account.id));
|
|
286
|
+
// Scoped read: only the provider's own candidates for this tenant are
|
|
287
|
+
// queried, so the console read costs one exact MGET over the accounts it
|
|
288
|
+
// is about to report on — not a fleet-wide `KEYS` scan whose cost is the
|
|
289
|
+
// size of every bucket every process ever wrote.
|
|
290
|
+
const routeSnapshot = await snapshotService.getSnapshot();
|
|
291
|
+
const ownedCandidates = routeSnapshot.candidates.filter(
|
|
292
|
+
(candidate) =>
|
|
293
|
+
candidate.provider_id === providerId &&
|
|
294
|
+
candidate.provider_account_id !== undefined &&
|
|
295
|
+
owned.has(candidate.provider_account_id),
|
|
296
|
+
);
|
|
297
|
+
const snapshot = await routingEngine.accountInflightSnapshot(ownedCandidates);
|
|
205
298
|
const totals = new Map<string, number>();
|
|
206
299
|
for (const [bucket, count] of snapshot) {
|
|
207
|
-
const prefix = `${providerId}:`;
|
|
208
|
-
if (!bucket.startsWith(prefix)) continue;
|
|
209
300
|
const accountId = bucket.slice(bucket.lastIndexOf(":") + 1);
|
|
210
301
|
if (!owned.has(accountId)) continue;
|
|
211
302
|
totals.set(accountId, (totals.get(accountId) ?? 0) + count);
|
|
@@ -260,6 +351,13 @@ export async function buildProductionDeps(): Promise<ProductionDeps> {
|
|
|
260
351
|
const children = await store.sumChildrenConsumed(apiKeyId);
|
|
261
352
|
return (row.lifetimeTokensConsumed ?? 0) + children;
|
|
262
353
|
},
|
|
354
|
+
// Slack above every key's concurrency limit. A tenant preference an
|
|
355
|
+
// operator sets in the runtime settings modal, falling back to the
|
|
356
|
+
// deployment default so an unset row does not silently lose the allowance.
|
|
357
|
+
(tenantId) =>
|
|
358
|
+
preferencesReaderFor(db)
|
|
359
|
+
.readPreferences(tenantId)
|
|
360
|
+
.then((prefs) => prefs?.concurrencyTolerance ?? resolveConcurrencyTolerance()),
|
|
263
361
|
);
|
|
264
362
|
const ipStore = redis
|
|
265
363
|
? new RedisIpAbuseStore(redis)
|
|
@@ -282,8 +380,14 @@ export async function buildProductionDeps(): Promise<ProductionDeps> {
|
|
|
282
380
|
// One list covers every periodic task: a small deployment can cut the
|
|
283
381
|
// heaviest sweeps (quota refresh, OAuth refresh, client-version polling)
|
|
284
382
|
// without the codebase growing a flag per task.
|
|
383
|
+
// Leases coordinate the SHARED sweeps across processes. Absent Redis there is
|
|
384
|
+
// nothing to coordinate with, and the registry's local one-at-a-time rule is
|
|
385
|
+
// the correct (and only) guard.
|
|
285
386
|
const scheduledTasks = new ScheduledTaskRegistry(
|
|
286
387
|
readList("CARTETHYIA_DISABLED_TASKS", CONFIG_SPEC.CARTETHYIA_DISABLED_TASKS) ?? [],
|
|
388
|
+
{
|
|
389
|
+
...(redis ? { lease: createRedisMaintenanceLeaseStore(redis) } : {}),
|
|
390
|
+
},
|
|
287
391
|
);
|
|
288
392
|
if (redis) {
|
|
289
393
|
scheduledTasks.register({
|
|
@@ -295,6 +399,8 @@ export async function buildProductionDeps(): Promise<ProductionDeps> {
|
|
|
295
399
|
scheduledTasks.register({
|
|
296
400
|
name: "account-health-sweep",
|
|
297
401
|
intervalMs: 30_000,
|
|
402
|
+
// Recovers cooldowns deployment-wide and invalidates the shared snapshot.
|
|
403
|
+
shared: true,
|
|
298
404
|
run: async () => {
|
|
299
405
|
const recovered = await sweepExpiredCooldowns(db);
|
|
300
406
|
const poolRecovered = await sweepExpiredPoolCooldowns(db);
|
|
@@ -306,27 +412,11 @@ export async function buildProductionDeps(): Promise<ProductionDeps> {
|
|
|
306
412
|
scheduledTasks.register({
|
|
307
413
|
name: "telemetry-payload-cleanup",
|
|
308
414
|
intervalMs: 15 * 60_000,
|
|
415
|
+
// Deletes payload frames for every tenant: two passes over the same
|
|
416
|
+
// batches is duplicated work, not speedup.
|
|
417
|
+
shared: true,
|
|
309
418
|
run: () => payloadCapture.cleanupExpired(),
|
|
310
419
|
});
|
|
311
|
-
scheduledTasks.register({
|
|
312
|
-
name: "telemetry-index-fold",
|
|
313
|
-
intervalMs: resolveTelemetryCompactIntervalMs(),
|
|
314
|
-
// Brings each day's index up to date with the bytes appended since the last
|
|
315
|
-
// pass, so a usage read is a small JSON read rather than a parse of the
|
|
316
|
-
// whole period. Folding is incremental and idempotent: a crash only leaves
|
|
317
|
-
// the watermark behind, and the next pass re-reads the tail.
|
|
318
|
-
run: async () => {
|
|
319
|
-
const days = await listEventDays();
|
|
320
|
-
let folded = 0;
|
|
321
|
-
for (const day of days) {
|
|
322
|
-
const result = await foldDayIndex(day).catch(() => ({ folded: 0 }));
|
|
323
|
-
folded += result.folded;
|
|
324
|
-
}
|
|
325
|
-
if (folded > 0) {
|
|
326
|
-
log.info(`[telemetry] indexed ${folded} event(s) across ${days.length} day(s)`);
|
|
327
|
-
}
|
|
328
|
-
},
|
|
329
|
-
});
|
|
330
420
|
scheduledTasks.register({
|
|
331
421
|
name: "console-session-sweep",
|
|
332
422
|
// Sessions live 48 h, so an hourly sweep deleted nothing 47 times out of
|
|
@@ -334,11 +424,15 @@ export async function buildProductionDeps(): Promise<ProductionDeps> {
|
|
|
334
424
|
// be expired. Half the session lifetime is the point where a pass can
|
|
335
425
|
// actually find work, and still leaves a full grace period after it.
|
|
336
426
|
intervalMs: 12 * 60 * 60_000,
|
|
427
|
+
shared: true,
|
|
337
428
|
run: () => deleteExpiredSessions(db),
|
|
338
429
|
});
|
|
339
430
|
scheduledTasks.register({
|
|
340
431
|
name: "telemetry-retention",
|
|
341
432
|
intervalMs: 6 * 60 * 60_000,
|
|
433
|
+
// Retention deletes rows and files for the whole deployment; a second
|
|
434
|
+
// concurrent pass would churn the same batches.
|
|
435
|
+
shared: true,
|
|
342
436
|
run: () =>
|
|
343
437
|
telemetryStore.pruneTelemetry(
|
|
344
438
|
new Date(Date.now() - resolveTelemetryRetentionDays() * 24 * 60 * 60_000),
|
|
@@ -365,7 +459,16 @@ export async function buildProductionDeps(): Promise<ProductionDeps> {
|
|
|
365
459
|
modelCatalogEntries: () => modelCatalogCacheSize(),
|
|
366
460
|
// Late-bound via bindInFlight once the request state store exists.
|
|
367
461
|
inFlight: () => 0,
|
|
462
|
+
// The buffer is built below; the closure keeps the sampler from holding a
|
|
463
|
+
// reference during construction.
|
|
464
|
+
telemetryBacklog: () => telemetryBuffer?.snapshot(),
|
|
368
465
|
});
|
|
466
|
+
// One sample NOW, not on the first tick. The headroom guard reads
|
|
467
|
+
// `memorySnapshot()` on every gateway request and treats `undefined` as
|
|
468
|
+
// "unknown" — so without this the guard would spend its first interval
|
|
469
|
+
// (seconds, at the configured cadence) unable to shed at all, which is
|
|
470
|
+
// exactly the window a cold start is most likely to be short of memory.
|
|
471
|
+
runtimeMetricsSampler.sample();
|
|
369
472
|
scheduledTasks.register({
|
|
370
473
|
name: "runtime-metrics",
|
|
371
474
|
intervalMs: readInt(
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Canonical resource policy: how much memory this process may use, and how
|
|
3
|
+
* much work it may hold at once.
|
|
4
|
+
*
|
|
5
|
+
* These two numbers were previously decided in three unrelated places — a
|
|
6
|
+
* hardcoded cap in `concurrency-guard.ts`, a host-wide `os.totalmem()` in the
|
|
7
|
+
* headroom guard, and a fallback constant in `runtime-metrics.ts` — which
|
|
8
|
+
* meant a container limited to 512 MiB admitted the same concurrency as a 4 GiB
|
|
9
|
+
* one and only started shedding once the *host* was nearly full. On a 1-core /
|
|
10
|
+
* 2 GiB VPS that is the difference between shedding early and being
|
|
11
|
+
* OOM-killed, and the OOM kills the dashboard too — the one surface that would
|
|
12
|
+
* have explained what happened.
|
|
13
|
+
*
|
|
14
|
+
* Everything here is derived from one limit and one body ceiling, so the
|
|
15
|
+
* numbers cannot disagree with each other:
|
|
16
|
+
*
|
|
17
|
+
* concurrency = floor(limit × BUDGET_FRACTION / (TRANSIENT_COPIES × body))
|
|
18
|
+
*
|
|
19
|
+
* The transient-copies factor is why this is not simply "limit / body": a
|
|
20
|
+
* request body is held more than once between the socket read, the JSON parse,
|
|
21
|
+
* and the upstream re-serialization, so budgeting one copy per request
|
|
22
|
+
* understates the real hold by a factor of about two.
|
|
23
|
+
*/
|
|
24
|
+
import { CONFIG_SPEC, readOptionalInt } from "./spec";
|
|
25
|
+
import { resolveMaxBodyBytes } from "./runtime";
|
|
26
|
+
|
|
27
|
+
/** Used when no limit is discoverable at all (native dev, macOS, Windows). */
|
|
28
|
+
export const FALLBACK_MEMORY_LIMIT_BYTES = 512 * 1024 * 1024;
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Ceiling on concurrent in-flight requests per process.
|
|
32
|
+
*
|
|
33
|
+
* A hard ceiling even when memory would allow more: this process is one core,
|
|
34
|
+
* and past this point queueing delay, not memory, is what degrades. It also
|
|
35
|
+
* bounds every per-request structure that is sized by concurrency.
|
|
36
|
+
*/
|
|
37
|
+
export const MAX_PROCESS_CONCURRENCY = 64;
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* How many copies of a request body are resident at once.
|
|
41
|
+
*
|
|
42
|
+
* Measured, not assumed: the socket read buffer, the parsed object graph, and
|
|
43
|
+
* the upstream re-serialization each hold a body-sized allocation, and garbage
|
|
44
|
+
* collection lags rather than keeping them to one. Budgeting one copy halves
|
|
45
|
+
* the real peak and lets the process admit twice the concurrency it can hold.
|
|
46
|
+
*/
|
|
47
|
+
export const REQUEST_BODY_TRANSIENT_COPIES = 2;
|
|
48
|
+
|
|
49
|
+
/** Share of the memory limit all in-flight request bodies together may hold. */
|
|
50
|
+
export const REQUEST_BODY_BUDGET_FRACTION = 0.25;
|
|
51
|
+
|
|
52
|
+
/** Share of the memory limit kept free for allocation spikes and GC slack. */
|
|
53
|
+
export const MEMORY_HEADROOM_FRACTION = 0.2;
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Concurrency this process should admit, derived from the memory limit and the
|
|
57
|
+
* body ceiling rather than guessed.
|
|
58
|
+
*
|
|
59
|
+
* Worked examples, which are the point of having one formula:
|
|
60
|
+
* - 2 GiB limit, 4 MiB bodies → 64 (the ceiling; memory is not the binder)
|
|
61
|
+
* - 512 MiB limit, 4 MiB bodies → 16
|
|
62
|
+
* - 256 MiB limit, 16 MiB bodies → 2
|
|
63
|
+
*
|
|
64
|
+
* An explicit override is honoured, clamped to 1..MAX_PROCESS_CONCURRENCY. It
|
|
65
|
+
* is the operator's call and is never silently raised back to the derived
|
|
66
|
+
* value; exceeding the derived figure is a deliberate choice, so it is
|
|
67
|
+
* reported at the call site rather than corrected here.
|
|
68
|
+
*/
|
|
69
|
+
export function deriveProcessConcurrency(options: {
|
|
70
|
+
readonly memoryLimitBytes: number;
|
|
71
|
+
readonly maxBodyBytes: number;
|
|
72
|
+
readonly override?: number | undefined;
|
|
73
|
+
}): number {
|
|
74
|
+
if (options.override !== undefined) {
|
|
75
|
+
if (!Number.isInteger(options.override)) return 1;
|
|
76
|
+
return Math.min(MAX_PROCESS_CONCURRENCY, Math.max(1, options.override));
|
|
77
|
+
}
|
|
78
|
+
const body = Math.max(1, options.maxBodyBytes);
|
|
79
|
+
const perRequest = REQUEST_BODY_TRANSIENT_COPIES * body;
|
|
80
|
+
const budget = Math.max(1, Math.floor(options.memoryLimitBytes * REQUEST_BODY_BUDGET_FRACTION));
|
|
81
|
+
const derived = Math.floor(budget / perRequest);
|
|
82
|
+
return Math.min(MAX_PROCESS_CONCURRENCY, Math.max(1, derived));
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Bytes kept free below the memory limit.
|
|
87
|
+
*
|
|
88
|
+
* A guard that only reacts once RSS equals the limit has already lost: the
|
|
89
|
+
* allocation that breaches it happens between two samples, and the spike that
|
|
90
|
+
* matters — an upstream response landing all at once — arrives in far less
|
|
91
|
+
* than one sample interval.
|
|
92
|
+
*/
|
|
93
|
+
export function deriveMemoryHeadroomBytes(memoryLimitBytes: number): number {
|
|
94
|
+
return Math.ceil(memoryLimitBytes * MEMORY_HEADROOM_FRACTION);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* The optional operator override, read through the config spec so "unset" and
|
|
99
|
+
* "out of range" are treated the way every other knob treats them.
|
|
100
|
+
*/
|
|
101
|
+
export function resolveProcessConcurrencyOverride(): number | undefined {
|
|
102
|
+
return readOptionalInt(
|
|
103
|
+
"CARTETHYIA_PROCESS_CONCURRENCY_LIMIT",
|
|
104
|
+
CONFIG_SPEC.CARTETHYIA_PROCESS_CONCURRENCY_LIMIT,
|
|
105
|
+
);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Slack added on top of every key's concurrency limit.
|
|
110
|
+
*
|
|
111
|
+
* Operator configuration, not something a key carries about itself: it is
|
|
112
|
+
* deliberate hidden allowance so a run that lands a few requests over the
|
|
113
|
+
* number an operator chose is not refused at that number. The effective
|
|
114
|
+
* ceiling is `limit + tolerance`, and only requests past it are refused.
|
|
115
|
+
*/
|
|
116
|
+
export function resolveConcurrencyTolerance(): number {
|
|
117
|
+
const value = readOptionalInt(
|
|
118
|
+
"CARTETHYIA_CONCURRENCY_TOLERANCE",
|
|
119
|
+
CONFIG_SPEC.CARTETHYIA_CONCURRENCY_TOLERANCE,
|
|
120
|
+
);
|
|
121
|
+
return value === undefined ? 0 : Math.max(0, value);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* The effective concurrency for this process.
|
|
126
|
+
*
|
|
127
|
+
* Takes the limit as an argument rather than resolving it here: the limit
|
|
128
|
+
* owner is `resolveMemoryLimitBytes()` in `observability/runtime-metrics.ts`,
|
|
129
|
+
* and duplicating that resolution would let the two drift.
|
|
130
|
+
*/
|
|
131
|
+
export function resolveProcessConcurrency(memoryLimitBytes: number): number {
|
|
132
|
+
return deriveProcessConcurrency({
|
|
133
|
+
memoryLimitBytes,
|
|
134
|
+
maxBodyBytes: resolveMaxBodyBytes(),
|
|
135
|
+
override: resolveProcessConcurrencyOverride(),
|
|
136
|
+
});
|
|
137
|
+
}
|
package/src/config/runtime.ts
CHANGED
|
@@ -1,76 +1,133 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* HTTP listener, dashboard runtime, and graceful-shutdown drain resolvers.
|
|
3
|
-
*/
|
|
4
|
-
|
|
5
|
-
import { CONFIG_SPEC, readInt, readOptionalText } from "./spec";
|
|
6
|
-
import { resolvePackagePath } from "./package-paths";
|
|
7
|
-
|
|
8
|
-
/** Resolves the HTTP listener port from the process environment. */
|
|
9
|
-
export function resolvePort(): number {
|
|
10
|
-
return readInt("PORT", CONFIG_SPEC.PORT);
|
|
11
|
-
}
|
|
12
|
-
|
|
13
|
-
/** Resolves the dashboard static asset directory. */
|
|
14
|
-
export function resolveDashboardDist(): string {
|
|
15
|
-
const raw = process.env.DASHBOARD_DIST?.trim();
|
|
16
|
-
if (!raw) return resolvePackagePath("dist", "dashboard");
|
|
17
|
-
return raw;
|
|
18
|
-
}
|
|
19
|
-
|
|
20
|
-
/**
|
|
21
|
-
* Resolves the configured default gateway API key (`CARTETHYIA_API_KEY`), if
|
|
22
|
-
* one is set. Consumers that need the raw secret — first-boot seeding and the
|
|
23
|
-
* console's key recovery from existing installations — read it here instead of
|
|
24
|
-
* probing `process.env` directly.
|
|
25
|
-
*/
|
|
26
|
-
export function resolveDefaultGatewayApiKey(): string | undefined {
|
|
27
|
-
return readOptionalText("CARTETHYIA_API_KEY", CONFIG_SPEC.CARTETHYIA_API_KEY);
|
|
28
|
-
}
|
|
29
|
-
|
|
30
|
-
/** Resolves the request-body ceiling shared by the Bun listener and ingress middleware. */
|
|
31
|
-
export function resolveMaxBodyBytes(): number {
|
|
32
|
-
return readInt("CARTETHYIA_SERVER_MAX_BODY_BYTES", CONFIG_SPEC.CARTETHYIA_SERVER_MAX_BODY_BYTES);
|
|
33
|
-
}
|
|
34
|
-
|
|
35
|
-
/** Resolves the listener idle-socket timeout (seconds). */
|
|
36
|
-
export function resolveIdleTimeout(): number {
|
|
37
|
-
return readInt("CARTETHYIA_SERVER_IDLE_TIMEOUT", CONFIG_SPEC.CARTETHYIA_SERVER_IDLE_TIMEOUT);
|
|
38
|
-
}
|
|
39
|
-
|
|
40
|
-
/** Resolves the graceful-shutdown drain window: time for in-flight requests to finish before abort. */
|
|
41
|
-
export function resolveShutdownDrainWindowMs(): number {
|
|
42
|
-
return readInt(
|
|
43
|
-
"CARTETHYIA_SHUTDOWN_DRAIN_WINDOW_MS",
|
|
44
|
-
CONFIG_SPEC.CARTETHYIA_SHUTDOWN_DRAIN_WINDOW_MS,
|
|
45
|
-
);
|
|
46
|
-
}
|
|
47
|
-
|
|
48
|
-
/** Resolves the operator drain token, or `undefined` when the drain route is disabled. */
|
|
49
|
-
export function resolveDrainToken(): string | undefined {
|
|
50
|
-
return readOptionalText("CARTETHYIA_DRAIN_TOKEN", CONFIG_SPEC.CARTETHYIA_DRAIN_TOKEN);
|
|
51
|
-
}
|
|
52
|
-
|
|
53
|
-
/**
|
|
54
|
-
* Whether Elysia precompiles route handlers at startup (AOT).
|
|
55
|
-
*
|
|
56
|
-
* Precompilation takes the eager validator branch, which calls TypeBox's
|
|
57
|
-
* `SchemaCompile(schema)` and then reads `.buildResult.external` off the
|
|
58
|
-
* result. In a source-run process that field is absent — the lazy
|
|
59
|
-
* `require("typebox/schema")` resolves to a namespace that does not carry it —
|
|
60
|
-
* and the process dies at the first schema-bearing route with
|
|
61
|
-
* `undefined is not an object (evaluating 'this.tb.buildResult.external')`.
|
|
62
|
-
* `scripts/build/aot.ts` avoids this by rewriting the call site and bundling
|
|
63
|
-
* the namespaces statically, so only the built artifact can actually take the
|
|
64
|
-
* eager path.
|
|
65
|
-
*
|
|
66
|
-
* So: precompilation is on for production *builds*, and off for a source run
|
|
67
|
-
* even when `NODE_ENV=production`. A deployment that runs
|
|
68
|
-
* `bun run src/main.ts` must not be pushed onto the eager branch.
|
|
69
|
-
*
|
|
70
|
-
* `CARTETHYIA_SOURCE_RUN` marks a source run explicitly. It is set by
|
|
71
|
-
* `scripts/deploy/*.sh`, which are the only supported way to run from source.
|
|
72
|
-
*/
|
|
73
|
-
export function resolveElysiaPrecompile(): boolean {
|
|
74
|
-
if (process.env.CARTETHYIA_SOURCE_RUN === "1") return false;
|
|
75
|
-
return process.env.NODE_ENV === "production";
|
|
76
|
-
}
|
|
1
|
+
/**
|
|
2
|
+
* HTTP listener, dashboard runtime, and graceful-shutdown drain resolvers.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { CONFIG_SPEC, readInt, readOptionalText } from "./spec";
|
|
6
|
+
import { resolvePackagePath } from "./package-paths";
|
|
7
|
+
|
|
8
|
+
/** Resolves the HTTP listener port from the process environment. */
|
|
9
|
+
export function resolvePort(): number {
|
|
10
|
+
return readInt("PORT", CONFIG_SPEC.PORT);
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/** Resolves the dashboard static asset directory. */
|
|
14
|
+
export function resolveDashboardDist(): string {
|
|
15
|
+
const raw = process.env.DASHBOARD_DIST?.trim();
|
|
16
|
+
if (!raw) return resolvePackagePath("dist", "dashboard");
|
|
17
|
+
return raw;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Resolves the configured default gateway API key (`CARTETHYIA_API_KEY`), if
|
|
22
|
+
* one is set. Consumers that need the raw secret — first-boot seeding and the
|
|
23
|
+
* console's key recovery from existing installations — read it here instead of
|
|
24
|
+
* probing `process.env` directly.
|
|
25
|
+
*/
|
|
26
|
+
export function resolveDefaultGatewayApiKey(): string | undefined {
|
|
27
|
+
return readOptionalText("CARTETHYIA_API_KEY", CONFIG_SPEC.CARTETHYIA_API_KEY);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** Resolves the request-body ceiling shared by the Bun listener and ingress middleware. */
|
|
31
|
+
export function resolveMaxBodyBytes(): number {
|
|
32
|
+
return readInt("CARTETHYIA_SERVER_MAX_BODY_BYTES", CONFIG_SPEC.CARTETHYIA_SERVER_MAX_BODY_BYTES);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** Resolves the listener idle-socket timeout (seconds). */
|
|
36
|
+
export function resolveIdleTimeout(): number {
|
|
37
|
+
return readInt("CARTETHYIA_SERVER_IDLE_TIMEOUT", CONFIG_SPEC.CARTETHYIA_SERVER_IDLE_TIMEOUT);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** Resolves the graceful-shutdown drain window: time for in-flight requests to finish before abort. */
|
|
41
|
+
export function resolveShutdownDrainWindowMs(): number {
|
|
42
|
+
return readInt(
|
|
43
|
+
"CARTETHYIA_SHUTDOWN_DRAIN_WINDOW_MS",
|
|
44
|
+
CONFIG_SPEC.CARTETHYIA_SHUTDOWN_DRAIN_WINDOW_MS,
|
|
45
|
+
);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** Resolves the operator drain token, or `undefined` when the drain route is disabled. */
|
|
49
|
+
export function resolveDrainToken(): string | undefined {
|
|
50
|
+
return readOptionalText("CARTETHYIA_DRAIN_TOKEN", CONFIG_SPEC.CARTETHYIA_DRAIN_TOKEN);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Whether Elysia precompiles route handlers at startup (AOT).
|
|
55
|
+
*
|
|
56
|
+
* Precompilation takes the eager validator branch, which calls TypeBox's
|
|
57
|
+
* `SchemaCompile(schema)` and then reads `.buildResult.external` off the
|
|
58
|
+
* result. In a source-run process that field is absent — the lazy
|
|
59
|
+
* `require("typebox/schema")` resolves to a namespace that does not carry it —
|
|
60
|
+
* and the process dies at the first schema-bearing route with
|
|
61
|
+
* `undefined is not an object (evaluating 'this.tb.buildResult.external')`.
|
|
62
|
+
* `scripts/build/aot.ts` avoids this by rewriting the call site and bundling
|
|
63
|
+
* the namespaces statically, so only the built artifact can actually take the
|
|
64
|
+
* eager path.
|
|
65
|
+
*
|
|
66
|
+
* So: precompilation is on for production *builds*, and off for a source run
|
|
67
|
+
* even when `NODE_ENV=production`. A deployment that runs
|
|
68
|
+
* `bun run src/main.ts` must not be pushed onto the eager branch.
|
|
69
|
+
*
|
|
70
|
+
* `CARTETHYIA_SOURCE_RUN` marks a source run explicitly. It is set by
|
|
71
|
+
* `scripts/deploy/*.sh`, which are the only supported way to run from source.
|
|
72
|
+
*/
|
|
73
|
+
export function resolveElysiaPrecompile(): boolean {
|
|
74
|
+
if (process.env.CARTETHYIA_SOURCE_RUN === "1") return false;
|
|
75
|
+
return process.env.NODE_ENV === "production";
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* How many gateway processes this deployment runs, and which one this is.
|
|
80
|
+
*
|
|
81
|
+
* Multi-process is opt-in and deliberately narrow. Every process shares the
|
|
82
|
+
* port through `SO_REUSEPORT`, so the kernel load-balances across them — which
|
|
83
|
+
* means state that used to be process-local is now per-process unless it lives
|
|
84
|
+
* somewhere shared. A second process without that sharing is not "twice the
|
|
85
|
+
* capacity", it is two processes disagreeing:
|
|
86
|
+
*
|
|
87
|
+
* - **PostgreSQL, not SQLite.** Two processes on one SQLite file corrupt it;
|
|
88
|
+
* there is no cross-process lock in the driver.
|
|
89
|
+
* - **Redis configured AND reachable.** Admission counters, quotas, and
|
|
90
|
+
* cooldowns are per-process without it, so a per-key RPM limit silently
|
|
91
|
+
* becomes N× the configured value.
|
|
92
|
+
* - **Linux.** `SO_REUSEPORT` is the mechanism; elsewhere it is a silent no-op
|
|
93
|
+
* and the second process simply fails to bind.
|
|
94
|
+
*
|
|
95
|
+
* These are checked at resolution time rather than left to fail at bind time,
|
|
96
|
+
* because a half-configured multi-process deploy that starts anyway looks fine
|
|
97
|
+
* until a limit is enforced twice.
|
|
98
|
+
*/
|
|
99
|
+
export interface ProcessTopology {
|
|
100
|
+
readonly count: number;
|
|
101
|
+
readonly index: number;
|
|
102
|
+
/** True when more than one process serves this deployment. */
|
|
103
|
+
readonly multiProcess: boolean;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Resolves the topology from the environment.
|
|
108
|
+
*
|
|
109
|
+
* `count > 1` requires an explicit index: a process that does not know which
|
|
110
|
+
* one it is cannot own a shard of anything, and a guessed index would have two
|
|
111
|
+
* processes claiming the same one.
|
|
112
|
+
*/
|
|
113
|
+
export function resolveProcessTopology(): ProcessTopology {
|
|
114
|
+
const count = readInt("CARTETHYIA_PROCESS_COUNT", CONFIG_SPEC.CARTETHYIA_PROCESS_COUNT);
|
|
115
|
+
if (count === 1) return { count: 1, index: 0, multiProcess: false };
|
|
116
|
+
// Mandatory when the count is above one: a process that does not know which
|
|
117
|
+
// one it is cannot own a shard of anything, and a defaulted index would have
|
|
118
|
+
// two processes claiming the same number. The spec's default of 0 cannot
|
|
119
|
+
// express "absent", so the raw value is checked here before it is read.
|
|
120
|
+
const rawIndex = process.env.CARTETHYIA_PROCESS_INDEX?.trim();
|
|
121
|
+
if (rawIndex === undefined || rawIndex.length === 0) {
|
|
122
|
+
throw new Error(
|
|
123
|
+
`CARTETHYIA_PROCESS_INDEX is required when CARTETHYIA_PROCESS_COUNT is ${count} (0 – ${count - 1})`,
|
|
124
|
+
);
|
|
125
|
+
}
|
|
126
|
+
const index = readInt("CARTETHYIA_PROCESS_INDEX", CONFIG_SPEC.CARTETHYIA_PROCESS_INDEX);
|
|
127
|
+
if (index >= count) {
|
|
128
|
+
throw new Error(
|
|
129
|
+
`CARTETHYIA_PROCESS_INDEX must be an integer between 0 and ${count - 1} (got "${rawIndex}")`,
|
|
130
|
+
);
|
|
131
|
+
}
|
|
132
|
+
return { count, index, multiProcess: true };
|
|
133
|
+
}
|