cartethyia 2.5.9 → 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 +15 -42
- package/README.md +11 -10
- package/dist/dashboard/assets/{BannedPage-Brvuj_Eb.js → BannedPage-l6XMnfr9.js} +1 -1
- package/dist/dashboard/assets/BannedPage-l6XMnfr9.js.gz +0 -0
- package/dist/dashboard/assets/{CliToolDetailPage-CqfXtI3y.js → CliToolDetailPage-DwOzAW45.js} +1 -1
- package/dist/dashboard/assets/CliToolDetailPage-DwOzAW45.js.gz +0 -0
- package/dist/dashboard/assets/{CliToolsPage-GQeS91bg.js → CliToolsPage-BT3Up3QA.js} +1 -1
- package/dist/dashboard/assets/CliToolsPage-BT3Up3QA.js.gz +0 -0
- package/dist/dashboard/assets/{CombosPage-BDRsTXBB.js → CombosPage-6kYIUpzR.js} +1 -1
- package/dist/dashboard/assets/CombosPage-6kYIUpzR.js.gz +0 -0
- package/dist/dashboard/assets/{ConsoleLogPage-zP8AFHX0.js → ConsoleLogPage-zRnj1mlR.js} +1 -1
- package/dist/dashboard/assets/ConsoleLogPage-zRnj1mlR.js.gz +0 -0
- package/dist/dashboard/assets/{CustomizationPage-BmfrNZ14.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-DoreIgm8.js → LoginPage-CbphLvjg.js} +1 -1
- package/dist/dashboard/assets/LoginPage-CbphLvjg.js.gz +0 -0
- package/dist/dashboard/assets/{ModelPicker-C7pDb41R.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-BnoPgA7w.js → ProvidersPage-Dfb2896L.js} +1 -1
- package/dist/dashboard/assets/ProvidersPage-Dfb2896L.js.gz +0 -0
- package/dist/dashboard/assets/{ProxyPage-Bq_TGgdb.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-Day3mCOn.js → SetupPage-D4g6vOGc.js} +1 -1
- package/dist/dashboard/assets/SetupPage-D4g6vOGc.js.gz +0 -0
- package/dist/dashboard/assets/{SortableList-DQxllnB5.js → SortableList-C93i14BD.js} +1 -1
- package/dist/dashboard/assets/SortableList-C93i14BD.js.gz +0 -0
- package/dist/dashboard/assets/{StudioPage-uMExoWu1.js → StudioPage-SgQpymjm.js} +1 -1
- package/dist/dashboard/assets/{StudioPage-uMExoWu1.js.gz → StudioPage-SgQpymjm.js.gz} +0 -0
- package/dist/dashboard/assets/{UsagePage-DXa1oR-4.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-BIt446yE.js → app-2KhtHCv9.js} +1 -1
- package/dist/dashboard/assets/{app-BIt446yE.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-CR72dasT.js → badge-CKsF123d.js} +1 -1
- package/dist/dashboard/assets/badge-CKsF123d.js.gz +0 -0
- package/dist/dashboard/assets/{banner-CI9_eSfO.js → banner-8j7uChA9.js} +1 -1
- package/dist/dashboard/assets/banner-8j7uChA9.js.gz +0 -0
- package/dist/dashboard/assets/{card-DS7CQEsO.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-611pSsaX.js → clipboard-button-uL5aYLfW.js} +1 -1
- package/dist/dashboard/assets/clipboard-button-uL5aYLfW.js.gz +0 -0
- package/dist/dashboard/assets/{dialog-k37exjNt.js → dialog-C07hr5_G.js} +1 -1
- package/dist/dashboard/assets/dialog-C07hr5_G.js.gz +0 -0
- package/dist/dashboard/assets/{drawer-CX7N8ucR.js → drawer-BPO1TcKH.js} +1 -1
- package/dist/dashboard/assets/drawer-BPO1TcKH.js.gz +0 -0
- package/dist/dashboard/assets/{entry-CTP8AyiS.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-sltk1qUi.js → github-badge-DmQreSDd.js} +1 -1
- package/dist/dashboard/assets/github-badge-DmQreSDd.js.gz +0 -0
- package/dist/dashboard/assets/{index-z3zDOSMM.js → index-Bblu6erR.js} +2 -2
- package/dist/dashboard/assets/index-Bblu6erR.js.gz +0 -0
- package/dist/dashboard/assets/{inline-BhY2sSnO.js → inline-C9dCYaBZ.js} +1 -1
- package/dist/dashboard/assets/inline-C9dCYaBZ.js.gz +0 -0
- package/dist/dashboard/assets/{input-V1umFiBk.js → input-CjCnXweJ.js} +1 -1
- package/dist/dashboard/assets/input-CjCnXweJ.js.gz +0 -0
- package/dist/dashboard/assets/{layout-BBOYycyS.js → layout-V6y8DAkl.js} +1 -1
- package/dist/dashboard/assets/layout-V6y8DAkl.js.gz +0 -0
- package/dist/dashboard/assets/{list-row-1zzZYM0B.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-DkfMR5Qs.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-C6MMiaio.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-BHTfXdz_.js → stack-_gfTdLVB.js} +1 -1
- package/dist/dashboard/assets/stack-_gfTdLVB.js.gz +0 -0
- package/dist/dashboard/assets/{state-mPDRlQkl.js → state-2c5V4zo7.js} +1 -1
- package/dist/dashboard/assets/state-2c5V4zo7.js.gz +0 -0
- package/dist/dashboard/assets/{switch-DOmJxg7H.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 -97
- package/scripts/cli/admin.ts +126 -125
- 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 +308 -275
- package/scripts/cli/controller.ts +145 -0
- package/scripts/cli/doctor.ts +1 -1
- package/scripts/cli/env.ts +99 -99
- package/scripts/cli/gateway.ts +264 -261
- package/scripts/{health.ts → cli/health.ts} +4 -4
- package/scripts/cli/instance.ts +234 -224
- package/scripts/cli/main.ts +188 -164
- package/scripts/cli/platform.ts +64 -64
- package/scripts/cli/prompt.ts +275 -107
- package/scripts/cli/services.ts +88 -88
- package/scripts/cli/setup.ts +445 -445
- package/scripts/cli/start.ts +85 -82
- 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 +192 -208
- 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/package-paths.ts +24 -0
- 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/remote-support/service.ts +270 -114
- 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/routing/pools/store.ts +1 -1
- 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/repository-updates.ts +40 -39
- 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/registry.ts +2 -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 +14 -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/capabilities.ts +4 -0
- 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/github/github.ts +3 -1
- 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/opencode/provider.ts +2 -0
- 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-headers.ts +17 -48
- 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/trae/trae.ts +22 -24
- 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/provider-probe.ts +3 -0
- 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-Brvuj_Eb.js.gz +0 -0
- package/dist/dashboard/assets/CliToolDetailPage-CqfXtI3y.js.gz +0 -0
- package/dist/dashboard/assets/CliToolsPage-GQeS91bg.js.gz +0 -0
- package/dist/dashboard/assets/CombosPage-BDRsTXBB.js.gz +0 -0
- package/dist/dashboard/assets/ConsoleLogPage-zP8AFHX0.js.gz +0 -0
- package/dist/dashboard/assets/CustomizationPage-BmfrNZ14.js.gz +0 -0
- package/dist/dashboard/assets/HealthEventsModal-Dn3M6WZc.js +0 -1
- package/dist/dashboard/assets/HealthEventsModal-Dn3M6WZc.js.gz +0 -0
- package/dist/dashboard/assets/LoginPage-DoreIgm8.js.gz +0 -0
- package/dist/dashboard/assets/ModelPicker-C7pDb41R.js.gz +0 -0
- package/dist/dashboard/assets/OverviewPage-CvvD1Ugk.js +0 -1
- package/dist/dashboard/assets/OverviewPage-CvvD1Ugk.js.gz +0 -0
- package/dist/dashboard/assets/ProviderDetailPage-Clbn1gsW.js +0 -3
- package/dist/dashboard/assets/ProviderDetailPage-Clbn1gsW.js.gz +0 -0
- package/dist/dashboard/assets/ProvidersPage-BnoPgA7w.js.gz +0 -0
- package/dist/dashboard/assets/ProxyPage-Bq_TGgdb.js.gz +0 -0
- package/dist/dashboard/assets/QuotaPage-D9DHXvom.js +0 -1
- package/dist/dashboard/assets/QuotaPage-D9DHXvom.js.gz +0 -0
- package/dist/dashboard/assets/SettingsPage-7xV8WkWL.js +0 -1
- package/dist/dashboard/assets/SettingsPage-7xV8WkWL.js.gz +0 -0
- package/dist/dashboard/assets/SetupPage-Day3mCOn.js.gz +0 -0
- package/dist/dashboard/assets/SortableList-DQxllnB5.js.gz +0 -0
- package/dist/dashboard/assets/UsagePage-DXa1oR-4.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-CR72dasT.js.gz +0 -0
- package/dist/dashboard/assets/banner-CI9_eSfO.js.gz +0 -0
- package/dist/dashboard/assets/card-DS7CQEsO.js.gz +0 -0
- package/dist/dashboard/assets/cli-tools-CNqzrfdb.js.gz +0 -0
- package/dist/dashboard/assets/clipboard-button-611pSsaX.js.gz +0 -0
- package/dist/dashboard/assets/dialog-k37exjNt.js.gz +0 -0
- package/dist/dashboard/assets/drawer-CX7N8ucR.js.gz +0 -0
- package/dist/dashboard/assets/entry-B9Xvtnfj.js +0 -1
- package/dist/dashboard/assets/entry-B9Xvtnfj.js.gz +0 -0
- package/dist/dashboard/assets/entry-ByMGkR3C.js +0 -4
- package/dist/dashboard/assets/entry-ByMGkR3C.js.gz +0 -0
- package/dist/dashboard/assets/entry-CTP8AyiS.js.gz +0 -0
- package/dist/dashboard/assets/github-badge-sltk1qUi.js.gz +0 -0
- package/dist/dashboard/assets/index-z3zDOSMM.js.gz +0 -0
- package/dist/dashboard/assets/inline-BhY2sSnO.js.gz +0 -0
- package/dist/dashboard/assets/input-V1umFiBk.js.gz +0 -0
- package/dist/dashboard/assets/layout-BBOYycyS.js.gz +0 -0
- package/dist/dashboard/assets/list-row-1zzZYM0B.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-DkfMR5Qs.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-C6MMiaio.js.gz +0 -0
- package/dist/dashboard/assets/settings-DNX9sydi.js.gz +0 -0
- package/dist/dashboard/assets/stack-BHTfXdz_.js.gz +0 -0
- package/dist/dashboard/assets/state-mPDRlQkl.js.gz +0 -0
- package/dist/dashboard/assets/switch-DOmJxg7H.js.gz +0 -0
- package/dist/dashboard/assets/useMutation-Dem0lXQj.js.gz +0 -0
- package/scripts/build/aot.ts +0 -143
- package/scripts/build/build.ts +0 -24
- package/scripts/build/compile.ts +0 -88
- package/scripts/build/restart.ts +0 -120
- package/scripts/build/start.ts +0 -25
- package/src/observability/event-compaction.ts +0 -317
- package/src/observability/event-day-index.ts +0 -440
|
@@ -6,6 +6,7 @@ import { GATEWAY_SECURITY_HEADERS } from "../../security/outbound-headers";
|
|
|
6
6
|
import type { TelemetryBatchBuffer, TelemetryEventInput } from "../../observability/telemetry-buffer";
|
|
7
7
|
import type { TelemetryPayloadCapture } from "../../observability/payload-capture";
|
|
8
8
|
import { captureEarlyRejectionPayload } from "./early-rejection-capture";
|
|
9
|
+
import type { CartethyiaDatabase } from "../../persistence/db-handle";
|
|
9
10
|
import { reasoningEffortFromIntent } from "../../providers/provider-reasoning";
|
|
10
11
|
import { metrics } from "../../observability/metrics";
|
|
11
12
|
import { computeTokensPerSec } from "../../observability/token-speed";
|
|
@@ -168,6 +169,7 @@ export interface AfterResponseApp {
|
|
|
168
169
|
export function finalizeRequestTelemetry(
|
|
169
170
|
state: ProxyRequestState,
|
|
170
171
|
telemetryBuffer: TelemetryBatchBuffer,
|
|
172
|
+
db?: CartethyiaDatabase,
|
|
171
173
|
): void {
|
|
172
174
|
// Discovery/surface routes under `/v1` (e.g. `/v1/models`) are authenticated
|
|
173
175
|
// gateway routes that never dispatch upstream. They hold request state but
|
|
@@ -225,7 +227,11 @@ export function finalizeRequestTelemetry(
|
|
|
225
227
|
// would have nothing to read. Capture the ingress body and the error the
|
|
226
228
|
// client saw, keyed to the same request id as the row.
|
|
227
229
|
if (requestData === undefined && state.ingressBody !== undefined) {
|
|
228
|
-
|
|
230
|
+
// `deps.db` so the helper resolves the tenant's capture mode through the
|
|
231
|
+
// same preference path every dispatched request uses — without it, an
|
|
232
|
+
// early rejection captured bodies with the deployment default regardless
|
|
233
|
+
// of the tenant's `none`/`metadata` choice.
|
|
234
|
+
void captureEarlyRejectionPayload(state, authorization, db);
|
|
229
235
|
}
|
|
230
236
|
|
|
231
237
|
telemetryBuffer.enqueue(
|
|
@@ -385,6 +391,8 @@ export function registerTelemetryLifecycle(
|
|
|
385
391
|
* telemetry row without a payload frame rather than failing.
|
|
386
392
|
*/
|
|
387
393
|
readonly payloadCapture?: TelemetryPayloadCapture;
|
|
394
|
+
/** Database handle for early-rejection capture-mode resolution. */
|
|
395
|
+
readonly db?: CartethyiaDatabase;
|
|
388
396
|
},
|
|
389
397
|
): void {
|
|
390
398
|
app.afterResponse(async ({ request }) => {
|
|
@@ -404,7 +412,7 @@ export function registerTelemetryLifecycle(
|
|
|
404
412
|
// finalizer for requests that never reached completion (early
|
|
405
413
|
// rejections). Cleanup below must still run for completed requests:
|
|
406
414
|
// skipping it leaks one in-flight count per request, forever.
|
|
407
|
-
if (!state.completed) finalizeRequestTelemetry(state, deps.telemetryBuffer);
|
|
415
|
+
if (!state.completed) finalizeRequestTelemetry(state, deps.telemetryBuffer, deps.db);
|
|
408
416
|
} finally {
|
|
409
417
|
state.cleanup();
|
|
410
418
|
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared construction points for the guard errors that more than one guard
|
|
3
|
+
* raises. Normalisation downstream is single-seam (`error-lifecycle.ts`); the
|
|
4
|
+
* duplication this module removes was on the construction side, where four
|
|
5
|
+
* guards each hand-rolled their own code + message for the same conditions.
|
|
6
|
+
*/
|
|
7
|
+
import { GatewayError } from "../../protocol/gateway-error";
|
|
8
|
+
|
|
9
|
+
/** Thrown when the request's client peer address cannot be established. */
|
|
10
|
+
export function peerAddressUnavailableError(): GatewayError {
|
|
11
|
+
return new GatewayError("admission_unavailable", 503, "client peer address unavailable");
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/** Thrown when the request body exceeds the endpoint's byte budget. */
|
|
15
|
+
export function bodyTooLargeError(maxBytes: number): GatewayError {
|
|
16
|
+
return new GatewayError(
|
|
17
|
+
"invalid_request",
|
|
18
|
+
413,
|
|
19
|
+
`The request body is larger than this endpoint accepts (${maxBytes} bytes). Shorten the prompt or split the request.`,
|
|
20
|
+
);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
|
|
@@ -17,15 +17,14 @@
|
|
|
17
17
|
* metrics are exempt so an operator can always reach the console that explains
|
|
18
18
|
* the shedding.
|
|
19
19
|
*/
|
|
20
|
-
import { totalmem } from "node:os";
|
|
21
20
|
import { Elysia } from "elysia";
|
|
22
21
|
import { GatewayError } from "../../protocol/gateway-error";
|
|
23
22
|
import type { RedisClient } from "../../persistence/redis";
|
|
23
|
+
import { deriveMemoryHeadroomBytes } from "../../config/resources";
|
|
24
|
+
import type { RuntimeMemorySnapshot } from "../../observability/runtime-metrics";
|
|
24
25
|
import type { ProxyRequestStateStore } from "../request/state";
|
|
25
26
|
import { isProxyRequest } from "./body-policy";
|
|
26
27
|
|
|
27
|
-
/** Free bytes the process must keep below its RSS before it stops accepting. */
|
|
28
|
-
const DEFAULT_MIN_FREE_BYTES = 64 * 1024 * 1024;
|
|
29
28
|
/** How long the shared shed marker outlives the last shed, so peers can see it. */
|
|
30
29
|
const SHED_MARKER_TTL_SECONDS = 5;
|
|
31
30
|
/** Fleet-visible marker: set on the first shed so every instance reports the same state. */
|
|
@@ -34,7 +33,22 @@ const SHED_RETRY_MS = 1_000;
|
|
|
34
33
|
|
|
35
34
|
export interface HeadroomGuardDeps {
|
|
36
35
|
readonly store: ProxyRequestStateStore;
|
|
37
|
-
/**
|
|
36
|
+
/**
|
|
37
|
+
* The last sampled memory state, published once per sample by
|
|
38
|
+
* `RuntimeMetricsSampler`.
|
|
39
|
+
*
|
|
40
|
+
* Taken from the sampler rather than measured here: this runs on EVERY
|
|
41
|
+
* request, and `process.memoryUsage()` on the request path is a cost paid
|
|
42
|
+
* per request to answer a question that changes over seconds. When absent
|
|
43
|
+
* (before the first sample) the guard falls back to measuring once — an
|
|
44
|
+
* unsampled process is still a process that can be OOM-killed.
|
|
45
|
+
*/
|
|
46
|
+
readonly memorySnapshot?: () => RuntimeMemorySnapshot | undefined;
|
|
47
|
+
/**
|
|
48
|
+
* Free bytes to keep below the resolved limit. Defaults to
|
|
49
|
+
* `MEMORY_HEADROOM_FRACTION` of it, so a 512 MiB container keeps ~102 MiB
|
|
50
|
+
* free rather than the flat 64 MiB a 2 GiB one would keep.
|
|
51
|
+
*/
|
|
38
52
|
readonly minFreeBytes?: number;
|
|
39
53
|
/**
|
|
40
54
|
* Optional shared client. Used only to publish a shed marker for visibility;
|
|
@@ -45,11 +59,27 @@ export interface HeadroomGuardDeps {
|
|
|
45
59
|
}
|
|
46
60
|
|
|
47
61
|
export function createHeadroomGuard(deps: HeadroomGuardDeps): Elysia {
|
|
48
|
-
const
|
|
62
|
+
const fixedHeadroom = deps.minFreeBytes;
|
|
49
63
|
return new Elysia()
|
|
50
64
|
.beforeHandle(({ request }) => {
|
|
51
65
|
if (!isProxyRequest(request)) return;
|
|
52
|
-
|
|
66
|
+
// Shed when RSS plus the headroom would exceed this process's LIMIT —
|
|
67
|
+
// not the host's total memory. A container capped at 512 MiB on a 16 GiB
|
|
68
|
+
// host never approached the old `os.totalmem()` threshold, so the guard
|
|
69
|
+
// never fired and the process was OOM-killed instead of shedding.
|
|
70
|
+
//
|
|
71
|
+
// The snapshot is up to one sample interval old. That is deliberate:
|
|
72
|
+
// this is the hottest path in the process, and the condition it guards
|
|
73
|
+
// develops over seconds.
|
|
74
|
+
const snapshot = deps.memorySnapshot?.();
|
|
75
|
+
const limit = snapshot?.limitBytes;
|
|
76
|
+
if (limit === undefined) {
|
|
77
|
+
// No limit discoverable and no sample yet: nothing to shed against.
|
|
78
|
+
return;
|
|
79
|
+
}
|
|
80
|
+
const rss = snapshot?.rssBytes ?? process.memoryUsage().rss;
|
|
81
|
+
const headroom = fixedHeadroom ?? deriveMemoryHeadroomBytes(limit);
|
|
82
|
+
if (rss + headroom <= limit) return;
|
|
53
83
|
if (deps.redis) {
|
|
54
84
|
// Fire-and-forget: a failed marker write must not change this request's
|
|
55
85
|
// outcome, and awaiting it would put Redis on the shed path.
|
|
@@ -61,7 +91,11 @@ export function createHeadroomGuard(deps: HeadroomGuardDeps): Elysia {
|
|
|
61
91
|
"overloaded",
|
|
62
92
|
503,
|
|
63
93
|
"The gateway is at capacity; retry shortly.",
|
|
64
|
-
{
|
|
94
|
+
{
|
|
95
|
+
retryAfterMs: SHED_RETRY_MS,
|
|
96
|
+
minFreeBytes: headroom,
|
|
97
|
+
activeRequests: deps.store.activeCount(),
|
|
98
|
+
},
|
|
65
99
|
);
|
|
66
100
|
})
|
|
67
101
|
.as("plugin");
|
|
@@ -28,6 +28,7 @@ import type { CartethyiaDatabase } from "../../persistence/db-handle";
|
|
|
28
28
|
import type { RedisClient } from "../../persistence/redis";
|
|
29
29
|
import type { TelemetryBatchBuffer } from "../../observability/telemetry-buffer";
|
|
30
30
|
import type { ModelStrikeService } from "../../security/model-abuse";
|
|
31
|
+
import type { RuntimeMemorySnapshot } from "../../observability/runtime-metrics";
|
|
31
32
|
import {
|
|
32
33
|
TelemetryPayloadCapture,
|
|
33
34
|
payloadCaptureKeepsBodies,
|
|
@@ -58,6 +59,20 @@ export interface TransportPipelineContext {
|
|
|
58
59
|
* decision is local, and Redis is only written for fleet visibility.
|
|
59
60
|
*/
|
|
60
61
|
readonly redis?: RedisClient | undefined;
|
|
62
|
+
/**
|
|
63
|
+
* The sampled memory state the headroom guard sheds against.
|
|
64
|
+
*
|
|
65
|
+
* Supplied by the runtime sampler. When absent the guard cannot compare RSS
|
|
66
|
+
* against a limit and does not shed — so this must be wired in production,
|
|
67
|
+
* and the sampler must be initialized at boot even before its first tick.
|
|
68
|
+
*/
|
|
69
|
+
readonly memorySnapshot?: () => RuntimeMemorySnapshot | undefined;
|
|
70
|
+
/**
|
|
71
|
+
* Whether invoke is switched off for a tenant. Optional: without it the
|
|
72
|
+
* gateway never refuses on the kill switch, which is the pre-change
|
|
73
|
+
* behaviour rather than a failure.
|
|
74
|
+
*/
|
|
75
|
+
readonly isInvokeDisabled?: (tenantId: string) => Promise<boolean | undefined>;
|
|
61
76
|
}
|
|
62
77
|
|
|
63
78
|
/**
|
|
@@ -84,6 +99,7 @@ export function createTransportPipeline(context: TransportPipelineContext): Tran
|
|
|
84
99
|
createProcessConcurrencyMiddleware(),
|
|
85
100
|
createHeadroomGuard({
|
|
86
101
|
store: context.stateStore,
|
|
102
|
+
...(context.memorySnapshot === undefined ? {} : { memorySnapshot: context.memorySnapshot }),
|
|
87
103
|
...(context.redis === undefined ? {} : { redis: context.redis }),
|
|
88
104
|
}),
|
|
89
105
|
createDependencyReadinessMiddleware({
|
|
@@ -103,6 +119,9 @@ export function createTransportPipeline(context: TransportPipelineContext): Tran
|
|
|
103
119
|
db: context.db,
|
|
104
120
|
stateStore: context.stateStore,
|
|
105
121
|
...(context.modelStrikes ? { modelStrikes: context.modelStrikes } : {}),
|
|
122
|
+
...(context.isInvokeDisabled
|
|
123
|
+
? { isInvokeDisabled: context.isInvokeDisabled }
|
|
124
|
+
: {}),
|
|
106
125
|
}),
|
|
107
126
|
createCanonicalRequestMiddleware({
|
|
108
127
|
stateStore: context.stateStore,
|
|
@@ -181,6 +200,11 @@ export function createTransportPipeline(context: TransportPipelineContext): Tran
|
|
|
181
200
|
(resolved) => (resolved.ok ? resolved.retentionMs : payloadRetentionMs()),
|
|
182
201
|
),
|
|
183
202
|
),
|
|
203
|
+
// The early-rejection capture resolves the tenant's capture mode
|
|
204
|
+
// through the same preference path dispatched requests use, instead of
|
|
205
|
+
// capturing bodies under the deployment default regardless of the
|
|
206
|
+
// tenant's none/metadata choice.
|
|
207
|
+
db: context.db,
|
|
184
208
|
});
|
|
185
209
|
} else {
|
|
186
210
|
registerRequestCleanup(rootApp, { stateStore: context.stateStore });
|
|
@@ -20,6 +20,7 @@ import {
|
|
|
20
20
|
type ModelAbuseOutcome,
|
|
21
21
|
type ModelStrikeService,
|
|
22
22
|
} from "../../security/model-abuse";
|
|
23
|
+
import { peerAddressUnavailableError } from "./guard-errors";
|
|
23
24
|
|
|
24
25
|
/**
|
|
25
26
|
* Whether a thrown error is a *model* rejection the strike layer counts: the
|
|
@@ -123,7 +124,7 @@ export function createClientIdentityMiddleware(deps: {
|
|
|
123
124
|
? deps.resolvePeerAddress(request)
|
|
124
125
|
: (server?.requestIP(request)?.address ?? null);
|
|
125
126
|
if (!peer)
|
|
126
|
-
throw
|
|
127
|
+
throw peerAddressUnavailableError();
|
|
127
128
|
const address = resolveClientIdentity(request, deps.trustedProxyBoundary, peer);
|
|
128
129
|
const state = deps.stateStore.require(request);
|
|
129
130
|
state.clientIdentity = {
|
|
@@ -69,8 +69,20 @@ function capacityRejected(): AdmissionDecision {
|
|
|
69
69
|
|
|
70
70
|
export class InMemoryAdmissionController implements AdmissionController {
|
|
71
71
|
private inflight = new Map<string, number>();
|
|
72
|
-
|
|
73
|
-
|
|
72
|
+
readAccountInflight(
|
|
73
|
+
candidates: readonly RouteCandidate[],
|
|
74
|
+
): ReadonlyMap<string, number> {
|
|
75
|
+
// Dedupe buckets: one candidate list often repeats a bucket (combo members
|
|
76
|
+
// fan out to several candidates on one account), and the caller only needs
|
|
77
|
+
// one count per bucket to make its ordering decision.
|
|
78
|
+
const snapshot = new Map<string, number>();
|
|
79
|
+
for (const candidate of candidates) {
|
|
80
|
+
const key = admissionKey(candidate);
|
|
81
|
+
if (snapshot.has(key)) continue;
|
|
82
|
+
const value = this.inflight.get(key) ?? 0;
|
|
83
|
+
if (value > 0) snapshot.set(key, value);
|
|
84
|
+
}
|
|
85
|
+
return snapshot;
|
|
74
86
|
}
|
|
75
87
|
async admit(candidate: RouteCandidate): Promise<AdmissionDecision> {
|
|
76
88
|
// Bucket per ACCOUNT, not per provider:model. A provider with N accounts
|
|
@@ -166,17 +178,47 @@ export class RedisAdmissionController implements AdmissionController {
|
|
|
166
178
|
await redisEvalNumber(this.redis, RELEASE_SCRIPT, 1, this.key(reservation.candidate));
|
|
167
179
|
}
|
|
168
180
|
|
|
169
|
-
async
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
181
|
+
async readAccountInflight(
|
|
182
|
+
candidates: readonly RouteCandidate[],
|
|
183
|
+
): Promise<ReadonlyMap<string, number>> {
|
|
184
|
+
// Candidate-scoped exact reads replace the fleet-wide `KEYS` + unbounded
|
|
185
|
+
// `MGET` this method used to run on every `plan()` that had any bounded
|
|
186
|
+
// candidate. `KEYS` is O(keyspace) and blocks the single-threaded Redis
|
|
187
|
+
// server; on a shared box the scan was measured as the dominant planning
|
|
188
|
+
// cost. The keys here are exactly the buckets the caller is about to order
|
|
189
|
+
// candidates by, read in bounded sequential chunks.
|
|
190
|
+
//
|
|
191
|
+
// `KEYS` and `SCAN` stay banned on this path by construction: neither can
|
|
192
|
+
// answer "what are the values of these N known keys" in fewer than
|
|
193
|
+
// O(keyspace) server work, and a fleet-wide inflight snapshot has no
|
|
194
|
+
// request-path consumer left. (If one ever appears, it belongs in a
|
|
195
|
+
// maintenance task using `SCAN` with `COUNT`, never here.)
|
|
196
|
+
const buckets = [...new Set(candidates.map(admissionKey))];
|
|
197
|
+
if (buckets.length === 0) return new Map();
|
|
198
|
+
const keys = buckets.map((bucket) => `admission:inflight:${bucket}`);
|
|
199
|
+
const values: number[] = [];
|
|
200
|
+
// Bounded sequential chunks: one huge MGET would move the unbounded
|
|
201
|
+
// payload problem from the KEYS call to the MGET call. 256 keys keeps a
|
|
202
|
+
// single command payload small while the round trips stay proportional to
|
|
203
|
+
// the (deduped, candidate-bounded) request size.
|
|
204
|
+
for (let offset = 0; offset < keys.length; offset += MGET_CHUNK_SIZE) {
|
|
205
|
+
const chunk = keys.slice(offset, offset + MGET_CHUNK_SIZE);
|
|
206
|
+
const raw = (await this.redis.mget(...chunk)) as (string | null)[];
|
|
207
|
+
for (const value of raw) values.push(Number(value ?? 0));
|
|
208
|
+
}
|
|
173
209
|
const snapshot = new Map<string, number>();
|
|
174
|
-
for (const [index,
|
|
175
|
-
const value =
|
|
210
|
+
for (const [index, bucket] of buckets.entries()) {
|
|
211
|
+
const value = values[index] ?? 0;
|
|
176
212
|
if (Number.isFinite(value) && value > 0) {
|
|
177
|
-
snapshot.set(
|
|
213
|
+
snapshot.set(bucket, value);
|
|
178
214
|
}
|
|
215
|
+
// The Redis controller's counter keys are TTL self-healing, so a bucket
|
|
216
|
+
// absent from the reply reads as zero and is omitted — the projection
|
|
217
|
+
// both controllers share: positive-finite counts only, unprefixed names.
|
|
179
218
|
}
|
|
180
219
|
return snapshot;
|
|
181
220
|
}
|
|
182
221
|
}
|
|
222
|
+
|
|
223
|
+
/** Sequential MGET chunk bound for {@link RedisAdmissionController.readAccountInflight}. */
|
|
224
|
+
const MGET_CHUNK_SIZE = 256;
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
// Bounded LRU map: the one shared implementation for routing-layer caches.
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* A capacity-bounded LRU `Map`.
|
|
5
|
+
*
|
|
6
|
+
* `get()` refreshes recency; `set()` evicts exactly the least-recently-used
|
|
7
|
+
* entry on overflow. Insertion order is the recency order (head = LRU,
|
|
8
|
+
* tail = MRU), which `Map` maintains for free — so eviction is one
|
|
9
|
+
* `keys().next()` and no bookkeeping arrays.
|
|
10
|
+
*
|
|
11
|
+
* The routing layer previously hand-rolled this shape twice (round-robin
|
|
12
|
+
* state, capability profiles) with subtly different code. One implementation
|
|
13
|
+
* means the bound is provable once: the map's size can never exceed
|
|
14
|
+
* `capacity`, and every entry is reachable through the normal `Map` API the
|
|
15
|
+
* callers already use.
|
|
16
|
+
*/
|
|
17
|
+
export class BoundedLruMap<K, V> extends Map<K, V> {
|
|
18
|
+
private readonly capacity: number;
|
|
19
|
+
|
|
20
|
+
constructor(capacity: number) {
|
|
21
|
+
if (!Number.isFinite(capacity) || capacity < 1 || Math.trunc(capacity) !== capacity) {
|
|
22
|
+
throw new Error(`BoundedLruMap capacity must be a positive integer, got ${capacity}`);
|
|
23
|
+
}
|
|
24
|
+
super();
|
|
25
|
+
this.capacity = Math.trunc(capacity);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
override get(key: K): V | undefined {
|
|
29
|
+
if (!super.has(key)) return undefined;
|
|
30
|
+
// Refresh recency: delete + re-insert moves the entry to the tail.
|
|
31
|
+
const value = super.get(key)!;
|
|
32
|
+
super.delete(key);
|
|
33
|
+
super.set(key, value);
|
|
34
|
+
return value;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
override set(key: K, value: V): this {
|
|
38
|
+
// An existing key is re-inserted at the tail even when the value is the
|
|
39
|
+
// same object, so a read-modify-write cycle cannot leave a hot entry
|
|
40
|
+
// parked at the head one eviction away from disappearing.
|
|
41
|
+
if (super.has(key)) super.delete(key);
|
|
42
|
+
else if (super.size >= this.capacity) {
|
|
43
|
+
const oldest = super.keys().next().value;
|
|
44
|
+
if (oldest !== undefined) super.delete(oldest);
|
|
45
|
+
}
|
|
46
|
+
super.set(key, value);
|
|
47
|
+
return this;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
// Immutable per-snapshot candidate index for model resolution.
|
|
2
|
+
import type { RouteCandidate, RouteSnapshot } from "./route-model";
|
|
3
|
+
|
|
4
|
+
/** One parsed model reference: `provider/model` or a bare `model`. */
|
|
5
|
+
export interface ParsedModel {
|
|
6
|
+
readonly providerId: string | null;
|
|
7
|
+
readonly modelId: string;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
/** Parse one model reference into its provider and bare model id. */
|
|
11
|
+
export function parseModelReference(model: string): ParsedModel {
|
|
12
|
+
const slash = model.indexOf("/");
|
|
13
|
+
if (slash === -1) return { providerId: null, modelId: model };
|
|
14
|
+
return { providerId: model.slice(0, slash), modelId: model.slice(slash + 1) };
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* An immutable, per-snapshot index of route candidates.
|
|
19
|
+
*
|
|
20
|
+
* Snapshots are immutable by contract (rebuilt wholesale on catalog change),
|
|
21
|
+
* so the index is built once per snapshot — O(C) — and reused by every plan,
|
|
22
|
+
* instead of the previous behavior of rescanning the full candidate list per
|
|
23
|
+
* resolved model inside one plan: a combo with K unique members over C
|
|
24
|
+
* candidates cost K×C `candidateMatches` calls (each re-parsing the model
|
|
25
|
+
* string), repeated on every request.
|
|
26
|
+
*/
|
|
27
|
+
export interface RouteCandidateIndex {
|
|
28
|
+
/** Candidates keyed by `provider_id/model_id`, snapshot order preserved. */
|
|
29
|
+
readonly qualified: ReadonlyMap<string, readonly RouteCandidate[]>;
|
|
30
|
+
/** Candidates keyed by bare `model_id`, snapshot order preserved. */
|
|
31
|
+
readonly bare: ReadonlyMap<string, readonly RouteCandidate[]>;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** WeakMap cache: an evicted/rebuilt snapshot releases its index automatically. */
|
|
35
|
+
const indexBySnapshot = new WeakMap<RouteSnapshot, RouteCandidateIndex>();
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Builds (once per snapshot) the lookup index for `resolveMatchingCandidates`.
|
|
39
|
+
*
|
|
40
|
+
* Each candidate is visited exactly once and filed under both its qualified and
|
|
41
|
+
* bare key, so a lookup later is one `Map.get` regardless of catalog size.
|
|
42
|
+
* Snapshot order is preserved within each bucket because `Map` iterates in
|
|
43
|
+
* insertion order and candidates are appended in one pass.
|
|
44
|
+
*/
|
|
45
|
+
export function routeCandidateIndex(snapshot: RouteSnapshot): RouteCandidateIndex {
|
|
46
|
+
const cached = indexBySnapshot.get(snapshot);
|
|
47
|
+
if (cached) return cached;
|
|
48
|
+
const qualified = new Map<string, RouteCandidate[]>();
|
|
49
|
+
const bare = new Map<string, RouteCandidate[]>();
|
|
50
|
+
for (const candidate of snapshot.candidates) {
|
|
51
|
+
const qualifiedKey = `${candidate.provider_id}/${candidate.model_id}`;
|
|
52
|
+
const qualifiedList = qualified.get(qualifiedKey);
|
|
53
|
+
if (qualifiedList) qualifiedList.push(candidate);
|
|
54
|
+
else qualified.set(qualifiedKey, [candidate]);
|
|
55
|
+
|
|
56
|
+
const bareList = bare.get(candidate.model_id);
|
|
57
|
+
if (bareList) bareList.push(candidate);
|
|
58
|
+
else bare.set(candidate.model_id, [candidate]);
|
|
59
|
+
}
|
|
60
|
+
const index: RouteCandidateIndex = { qualified, bare };
|
|
61
|
+
indexBySnapshot.set(snapshot, index);
|
|
62
|
+
return index;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Looks up the candidates a parsed model reference resolves to, filtered by
|
|
67
|
+
* tenant.
|
|
68
|
+
*
|
|
69
|
+
* A qualified reference (`provider/model`) reads the exact bucket and requires
|
|
70
|
+
* the provider to match; a bare reference reads every provider's bucket for
|
|
71
|
+
* that model. The tenant rule is the resolver's existing one, unchanged:
|
|
72
|
+
* a candidate is visible only when it is global (`tenant_id == null`) or
|
|
73
|
+
* belongs to the requesting tenant.
|
|
74
|
+
*/
|
|
75
|
+
export function lookupRouteCandidates(
|
|
76
|
+
index: RouteCandidateIndex,
|
|
77
|
+
parsedModel: ParsedModel,
|
|
78
|
+
tenantId: string | null,
|
|
79
|
+
): readonly RouteCandidate[] {
|
|
80
|
+
const group =
|
|
81
|
+
parsedModel.providerId === null
|
|
82
|
+
? index.bare.get(parsedModel.modelId)
|
|
83
|
+
: index.qualified.get(`${parsedModel.providerId}/${parsedModel.modelId}`);
|
|
84
|
+
if (group === undefined || group.length === 0) return [];
|
|
85
|
+
return group.filter(
|
|
86
|
+
(candidate) => candidate.tenant_id == null || candidate.tenant_id === tenantId,
|
|
87
|
+
);
|
|
88
|
+
}
|
|
@@ -1,25 +1,136 @@
|
|
|
1
|
-
// In-memory reservation lease store with
|
|
1
|
+
// In-memory reservation lease store with expiry-ordered cleanup.
|
|
2
2
|
import type { Reservation, RouteCandidate, RoutingRevision } from "./route-model";
|
|
3
3
|
|
|
4
|
+
/** One heap node: when a lease expires, and which lease it names. */
|
|
5
|
+
interface ExpiryNode {
|
|
6
|
+
readonly expiresAt: number;
|
|
7
|
+
readonly leaseId: string;
|
|
8
|
+
}
|
|
9
|
+
|
|
4
10
|
/**
|
|
5
|
-
*
|
|
6
|
-
*
|
|
11
|
+
* Binary min-heap on `expiresAt`.
|
|
12
|
+
*
|
|
13
|
+
* The sweep only ever needs the *earliest* expiring reservations, so a heap
|
|
14
|
+
* answers "which nodes are expired right now" by examining heads only — the
|
|
15
|
+
* previous implementation walked the entire lease map per sweep interval.
|
|
16
|
+
* Stale nodes (a lease released before its expiry) are harmless: the sweep
|
|
17
|
+
* verifies against the map before deleting and drops the node.
|
|
18
|
+
*/
|
|
19
|
+
class ExpiryHeap {
|
|
20
|
+
private nodes: ExpiryNode[] = [];
|
|
21
|
+
|
|
22
|
+
get size(): number {
|
|
23
|
+
return this.nodes.length;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
insert(node: ExpiryNode): void {
|
|
27
|
+
this.nodes.push(node);
|
|
28
|
+
this.bubbleUp(this.nodes.length - 1);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** The earliest-expiring node, without removing it. */
|
|
32
|
+
peek(): ExpiryNode | undefined {
|
|
33
|
+
return this.nodes[0];
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
pop(): ExpiryNode | undefined {
|
|
37
|
+
const top = this.nodes[0];
|
|
38
|
+
const last = this.nodes.pop();
|
|
39
|
+
if (this.nodes.length > 0 && last !== undefined) {
|
|
40
|
+
this.nodes[0] = last;
|
|
41
|
+
this.bubbleDown(0);
|
|
42
|
+
}
|
|
43
|
+
return top;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
private bubbleUp(index: number): void {
|
|
47
|
+
let current = index;
|
|
48
|
+
while (current > 0) {
|
|
49
|
+
const parent = (current - 1) >> 1;
|
|
50
|
+
if (this.nodes[current]!.expiresAt >= this.nodes[parent]!.expiresAt) break;
|
|
51
|
+
[this.nodes[current], this.nodes[parent]] = [this.nodes[parent]!, this.nodes[current]!];
|
|
52
|
+
current = parent;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
private bubbleDown(index: number): void {
|
|
57
|
+
let current = index;
|
|
58
|
+
for (;;) {
|
|
59
|
+
const left = current * 2 + 1;
|
|
60
|
+
const right = left + 1;
|
|
61
|
+
let smallest = current;
|
|
62
|
+
if (left < this.nodes.length && this.nodes[left]!.expiresAt < this.nodes[smallest]!.expiresAt) {
|
|
63
|
+
smallest = left;
|
|
64
|
+
}
|
|
65
|
+
if (
|
|
66
|
+
right < this.nodes.length &&
|
|
67
|
+
this.nodes[right]!.expiresAt < this.nodes[smallest]!.expiresAt
|
|
68
|
+
) {
|
|
69
|
+
smallest = right;
|
|
70
|
+
}
|
|
71
|
+
if (smallest === current) break;
|
|
72
|
+
[this.nodes[current], this.nodes[smallest]] = [this.nodes[smallest]!, this.nodes[current]!];
|
|
73
|
+
current = smallest;
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* In-memory reservation lease store with expiry-ordered cleanup.
|
|
80
|
+
*
|
|
81
|
+
* Acquisitions insert into an expiry min-heap; the bounded sweep examines only
|
|
82
|
+
* expired heads and verifies the map still holds that exact reservation before
|
|
83
|
+
* deleting (a released entry leaves a harmless stale node behind, dropped on
|
|
84
|
+
* the next sweep that reaches it or by periodic compaction). Stale nodes are
|
|
85
|
+
* compacted when they dominate the heap, so a long stream of short-lived
|
|
86
|
+
* released reservations cannot grow the heap without bound.
|
|
87
|
+
*
|
|
88
|
+
* The sweep interval stays 30 s and the lease TTL stays 30 s by default. Live
|
|
89
|
+
* reservations are not capped — capacity is admission's job, not the lease
|
|
90
|
+
* bookkeeping's. `expires_at` is NOT used to skip admission release: the
|
|
91
|
+
* admission counters protect the real limits and carry their own TTL
|
|
92
|
+
* (`INFLIGHT_TTL_SECONDS`); this map is local bookkeeping only.
|
|
7
93
|
*/
|
|
8
94
|
export class ReservationManager {
|
|
9
95
|
private leases = new Map<string, Reservation>();
|
|
96
|
+
private expiry = new ExpiryHeap();
|
|
10
97
|
private lastSweepAt = 0;
|
|
11
98
|
private static readonly SWEEP_INTERVAL_MS = 30_000;
|
|
12
99
|
|
|
100
|
+
constructor(private readonly now: () => number = Date.now) {}
|
|
101
|
+
|
|
13
102
|
private sweepExpired(now: number): void {
|
|
14
103
|
if (now - this.lastSweepAt < ReservationManager.SWEEP_INTERVAL_MS) return;
|
|
15
104
|
this.lastSweepAt = now;
|
|
16
|
-
|
|
17
|
-
|
|
105
|
+
// Only expired heads are examined: once the head expires later than `now`,
|
|
106
|
+
// every remaining node does too, so the loop stops without touching them.
|
|
107
|
+
while (this.expiry.size > 0) {
|
|
108
|
+
const head = this.expiry.peek()!;
|
|
109
|
+
if (head.expiresAt >= now) break;
|
|
110
|
+
this.expiry.pop();
|
|
111
|
+
const lease = this.leases.get(head.leaseId);
|
|
112
|
+
// A released reservation is a harmless stale node: nothing to delete.
|
|
113
|
+
// (Lease identity is the unique lease id, so a reused id cannot alias —
|
|
114
|
+
// the id embeds acquisition time plus randomness.)
|
|
115
|
+
if (lease && lease.expires_at === head.expiresAt && lease.expires_at < now) {
|
|
116
|
+
this.leases.delete(head.leaseId);
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
// Periodic compaction of stale nodes (entries whose lease was released or
|
|
120
|
+
// re-keyed before expiry): rebuild the heap only when stale nodes make up
|
|
121
|
+
// the bulk of it, so the common all-live case pays nothing.
|
|
122
|
+
if (this.expiry.size > this.leases.size * 2) {
|
|
123
|
+
const live: ExpiryNode[] = [];
|
|
124
|
+
for (const [leaseId, lease] of this.leases) {
|
|
125
|
+
live.push({ expiresAt: lease.expires_at, leaseId });
|
|
126
|
+
}
|
|
127
|
+
this.expiry = new ExpiryHeap();
|
|
128
|
+
for (const node of live) this.expiry.insert(node);
|
|
18
129
|
}
|
|
19
130
|
}
|
|
20
131
|
|
|
21
132
|
acquire(candidate: RouteCandidate, _revision: RoutingRevision, ttlMs = 30_000): Reservation {
|
|
22
|
-
const now =
|
|
133
|
+
const now = this.now();
|
|
23
134
|
this.sweepExpired(now);
|
|
24
135
|
const lease_id = `${candidate.provider_id}:${candidate.model_id}:${now}:${Math.random().toString(16).slice(2)}`;
|
|
25
136
|
const r: Reservation = {
|
|
@@ -29,17 +140,20 @@ export class ReservationManager {
|
|
|
29
140
|
expires_at: now + ttlMs,
|
|
30
141
|
};
|
|
31
142
|
this.leases.set(lease_id, r);
|
|
143
|
+
this.expiry.insert({ expiresAt: r.expires_at, leaseId: lease_id });
|
|
32
144
|
return r;
|
|
33
145
|
}
|
|
34
146
|
|
|
35
147
|
release(lease_id: string): void {
|
|
148
|
+
// The heap node stays until its expiry passes — the sweep treats it as
|
|
149
|
+
// stale and drops it. Cheaper than a heap search per release.
|
|
36
150
|
this.leases.delete(lease_id);
|
|
37
151
|
}
|
|
38
152
|
|
|
39
153
|
get(lease_id: string): Reservation | undefined {
|
|
40
154
|
const r = this.leases.get(lease_id);
|
|
41
155
|
if (!r) return undefined;
|
|
42
|
-
if (r.expires_at <
|
|
156
|
+
if (r.expires_at < this.now()) {
|
|
43
157
|
this.leases.delete(lease_id);
|
|
44
158
|
return undefined;
|
|
45
159
|
}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
// Round-robin rotation state and its bounded per-key LRU.
|
|
2
2
|
import type { RouteCandidate } from "./route-model";
|
|
3
|
+
import { BoundedLruMap } from "./bounded-lru";
|
|
3
4
|
|
|
4
5
|
export class RoundRobinState {
|
|
5
6
|
private idx = 0;
|
|
@@ -22,29 +23,16 @@ export class RoundRobinState {
|
|
|
22
23
|
* combinations would retain one entry forever. 1000 is far above the working
|
|
23
24
|
* set of any real catalog, so eviction only ever targets genuinely cold keys.
|
|
24
25
|
*/
|
|
25
|
-
const MAX_ROUND_ROBIN_ENTRIES = 1000;
|
|
26
|
+
export const MAX_ROUND_ROBIN_ENTRIES = 1000;
|
|
26
27
|
|
|
27
28
|
/**
|
|
28
|
-
*
|
|
29
|
-
*
|
|
30
|
-
*
|
|
31
|
-
* the
|
|
29
|
+
* Creates the bounded LRU map the router keeps round-robin state in.
|
|
30
|
+
*
|
|
31
|
+
* The bound is the same 1000-entry cap the hand-rolled version enforced;
|
|
32
|
+
* `BoundedLruMap` is now the single implementation of that behavior, so the
|
|
33
|
+
* eviction rule (get refreshes recency, overflow evicts the LRU head) cannot
|
|
34
|
+
* drift between this cache and the capability-profile cache.
|
|
32
35
|
*/
|
|
33
|
-
export function
|
|
34
|
-
|
|
35
|
-
key: string,
|
|
36
|
-
): RoundRobinState {
|
|
37
|
-
const existing = map.get(key);
|
|
38
|
-
if (existing) {
|
|
39
|
-
map.delete(key);
|
|
40
|
-
map.set(key, existing);
|
|
41
|
-
return existing;
|
|
42
|
-
}
|
|
43
|
-
if (map.size >= MAX_ROUND_ROBIN_ENTRIES) {
|
|
44
|
-
const oldest = map.keys().next().value;
|
|
45
|
-
if (oldest !== undefined) map.delete(oldest);
|
|
46
|
-
}
|
|
47
|
-
const created = new RoundRobinState();
|
|
48
|
-
map.set(key, created);
|
|
49
|
-
return created;
|
|
36
|
+
export function createRoundRobinMap(): BoundedLruMap<string, RoundRobinState> {
|
|
37
|
+
return new BoundedLruMap<string, RoundRobinState>(MAX_ROUND_ROBIN_ENTRIES);
|
|
50
38
|
}
|