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
|
@@ -5,6 +5,7 @@ import { deleteShared, publishInvalidation, readShared, writeShared } from "../p
|
|
|
5
5
|
import { DrizzleApiKeyStore } from "../persistence/stores/api-key-store";
|
|
6
6
|
import { GatewayError } from "../protocol/gateway-error";
|
|
7
7
|
import { hashSecret } from "./crypto";
|
|
8
|
+
import { AUTH_CACHE_TTL_MS, createApiKeyAuthorizationCache } from "./api-key-auth-cache";
|
|
8
9
|
import type { AccessScope } from "./access-control";
|
|
9
10
|
import type { ApiKeyModelAccessMode } from "../persistence/schema";
|
|
10
11
|
|
|
@@ -23,37 +24,7 @@ import type { ApiKeyModelAccessMode } from "../persistence/schema";
|
|
|
23
24
|
* Negative results (unknown/revoked tokens) are deliberately NOT cached, so
|
|
24
25
|
* a newly minted key is usable immediately.
|
|
25
26
|
*/
|
|
26
|
-
const
|
|
27
|
-
const AUTH_CACHE_MAX_ENTRIES = 10_000;
|
|
28
|
-
/** Negative-result TTL: unknown/revoked tokens miss the DB at most this often. */
|
|
29
|
-
const AUTH_NEGATIVE_CACHE_TTL_MS = 1_500;
|
|
30
|
-
|
|
31
|
-
interface AuthCacheEntry {
|
|
32
|
-
readonly at: number;
|
|
33
|
-
readonly value: ResolvedApiKey;
|
|
34
|
-
/** Owner of `value`; the reverse-index key this entry is registered under. */
|
|
35
|
-
readonly keyId: string;
|
|
36
|
-
}
|
|
37
|
-
|
|
38
|
-
const authCache = new Map<string, AuthCacheEntry>();
|
|
39
|
-
/** Reverse index: keyId → set of token hashes that resolved to it. */
|
|
40
|
-
const authCacheByKeyId = new Map<string, Set<string>>();
|
|
41
|
-
/** Unknown/revoked token HMACs with their miss time; cleared on any key mutation. */
|
|
42
|
-
const authNegativeCache = new Map<string, number>();
|
|
43
|
-
|
|
44
|
-
/**
|
|
45
|
-
* Short circuit after a credential-store failure.
|
|
46
|
-
*
|
|
47
|
-
* An overloaded or down database fails every lookup, and each failure costs a
|
|
48
|
-
* connection attempt. Without this, a saturated backend received one query per
|
|
49
|
-
* inbound request — load the operator sees as traffic, on top of the load that
|
|
50
|
-
* caused the outage. For the window, lookups answer "unavailable" instead.
|
|
51
|
-
*
|
|
52
|
-
* Deliberately short: this is an auth path, and a brief outage must not lock
|
|
53
|
-
* out valid keys for long.
|
|
54
|
-
*/
|
|
55
|
-
const AUTH_FAILURE_COOLDOWN_MS = 1_000;
|
|
56
|
-
let authFailureUntil = 0;
|
|
27
|
+
const authorizationCache = createApiKeyAuthorizationCache<ResolvedApiKey>();
|
|
57
28
|
|
|
58
29
|
/** Thrown when the credential store could not be reached at all. */
|
|
59
30
|
export class ApiKeyStoreUnavailableError extends Error {
|
|
@@ -84,52 +55,6 @@ let pendingUsage: (apiKeyId: string) => number = () => 0;
|
|
|
84
55
|
export function setPendingUsageSource(source: (apiKeyId: string) => number): void {
|
|
85
56
|
pendingUsage = source;
|
|
86
57
|
}
|
|
87
|
-
/**
|
|
88
|
-
* Negative-result fast path. A repeat miss for an unknown/revoked token within
|
|
89
|
-
* 1.5 s costs one HMAC + one map lookup and no query. Keyed by the full HMAC: a
|
|
90
|
-
* cheap pre-hash would let a crafted collision reject someone else's valid key,
|
|
91
|
-
* while the HMAC key can only match its own token. Bound below beside the
|
|
92
|
-
* positive cache.
|
|
93
|
-
*/
|
|
94
|
-
|
|
95
|
-
/**
|
|
96
|
-
* Drops the oldest entries until `authCache` is within budget. Every removal
|
|
97
|
-
* must also unregister the hash from `authCacheByKeyId`, otherwise the reverse
|
|
98
|
-
* index keeps referencing hashes that no longer exist and grows without bound
|
|
99
|
-
* (one dead hash per eviction, forever).
|
|
100
|
-
*/
|
|
101
|
-
function evictAuthCacheOldest(): void {
|
|
102
|
-
while (authCache.size > AUTH_CACHE_MAX_ENTRIES) {
|
|
103
|
-
const oldest = authCache.keys().next().value;
|
|
104
|
-
if (oldest === undefined) return;
|
|
105
|
-
const entry = authCache.get(oldest);
|
|
106
|
-
authCache.delete(oldest);
|
|
107
|
-
if (entry === undefined) continue;
|
|
108
|
-
const hashes = authCacheByKeyId.get(entry.keyId);
|
|
109
|
-
if (hashes === undefined) continue;
|
|
110
|
-
hashes.delete(oldest);
|
|
111
|
-
if (hashes.size === 0) authCacheByKeyId.delete(entry.keyId);
|
|
112
|
-
}
|
|
113
|
-
}
|
|
114
|
-
|
|
115
|
-
/** Registers a resolved hash in both the cache and the keyId reverse index. */
|
|
116
|
-
function indexAuthCache(hash: string, entry: AuthCacheEntry): void {
|
|
117
|
-
authCache.set(hash, entry);
|
|
118
|
-
authNegativeCache.delete(hash);
|
|
119
|
-
const hashes = authCacheByKeyId.get(entry.keyId) ?? new Set<string>();
|
|
120
|
-
hashes.add(hash);
|
|
121
|
-
authCacheByKeyId.set(entry.keyId, hashes);
|
|
122
|
-
}
|
|
123
|
-
|
|
124
|
-
/** Bounds the negative cache; same oldest-first rule as the positive cache. */
|
|
125
|
-
function evictAuthNegativeOldest(): void {
|
|
126
|
-
while (authNegativeCache.size > AUTH_CACHE_MAX_ENTRIES) {
|
|
127
|
-
const oldest = authNegativeCache.keys().next().value;
|
|
128
|
-
if (oldest === undefined) return;
|
|
129
|
-
authNegativeCache.delete(oldest);
|
|
130
|
-
}
|
|
131
|
-
}
|
|
132
|
-
|
|
133
58
|
/**
|
|
134
59
|
* Invalidates every cached authorization entry for one API key (or the entire
|
|
135
60
|
* cache when `keyId` is omitted). Called by the API-key domain after any
|
|
@@ -138,19 +63,12 @@ function evictAuthNegativeOldest(): void {
|
|
|
138
63
|
export function invalidateApiKeyCache(keyId?: string): void {
|
|
139
64
|
// A mutation may mint a token the negative cache has already rejected, so
|
|
140
65
|
// negative entries must go with the positive ones.
|
|
141
|
-
|
|
66
|
+
authorizationCache.invalidate(keyId);
|
|
142
67
|
if (keyId === undefined) {
|
|
143
|
-
authCache.clear();
|
|
144
|
-
authCacheByKeyId.clear();
|
|
145
68
|
void deleteShared("auth");
|
|
146
69
|
publishInvalidation({ namespace: "auth" });
|
|
147
70
|
return;
|
|
148
71
|
}
|
|
149
|
-
const hashes = authCacheByKeyId.get(keyId);
|
|
150
|
-
if (hashes) {
|
|
151
|
-
for (const hash of hashes) authCache.delete(hash);
|
|
152
|
-
authCacheByKeyId.delete(keyId);
|
|
153
|
-
}
|
|
154
72
|
// Peer processes hold their own copy, and `reusePort` means the console
|
|
155
73
|
// write may have landed on a different one entirely. Dropping only locally
|
|
156
74
|
// leaves a revoked key valid for up to a full TTL everywhere else.
|
|
@@ -388,13 +306,9 @@ export async function resolveApiKeyAuthorization(
|
|
|
388
306
|
): Promise<ResolvedApiKey | undefined> {
|
|
389
307
|
const now = Date.now();
|
|
390
308
|
const hash = hashSecret(token);
|
|
391
|
-
const
|
|
392
|
-
if (
|
|
393
|
-
|
|
394
|
-
authNegativeCache.delete(hash);
|
|
395
|
-
}
|
|
396
|
-
const cached = authCache.get(hash);
|
|
397
|
-
if (cached && now - cached.at < AUTH_CACHE_TTL_MS) return cached.value;
|
|
309
|
+
const cached = authorizationCache.lookup(hash, now);
|
|
310
|
+
if (cached.kind === "negative") return undefined;
|
|
311
|
+
if (cached.kind === "positive") return cached.value;
|
|
398
312
|
// L2: a peer process may have resolved this token already. Reachable only
|
|
399
313
|
// after the L1 entry lapsed, so Redis is never consulted on a warm hit.
|
|
400
314
|
//
|
|
@@ -405,8 +319,7 @@ export async function resolveApiKeyAuthorization(
|
|
|
405
319
|
try {
|
|
406
320
|
const parsed = JSON.parse(shared) as ResolvedApiKey;
|
|
407
321
|
if (typeof parsed?.id === "string") {
|
|
408
|
-
|
|
409
|
-
evictAuthCacheOldest();
|
|
322
|
+
authorizationCache.rememberPositive(hash, parsed.id, parsed, now);
|
|
410
323
|
return parsed;
|
|
411
324
|
}
|
|
412
325
|
} catch {
|
|
@@ -415,7 +328,7 @@ export async function resolveApiKeyAuthorization(
|
|
|
415
328
|
}
|
|
416
329
|
// Answered from the cooldown before any I/O: during a store outage the
|
|
417
330
|
// query is the expensive part, and it is also the part that fails.
|
|
418
|
-
if (now
|
|
331
|
+
if (authorizationCache.isStoreUnavailable(now)) throw new ApiKeyStoreUnavailableError();
|
|
419
332
|
const store = new DrizzleApiKeyStore(db);
|
|
420
333
|
let row: Awaited<ReturnType<typeof store.findActiveByHash>>;
|
|
421
334
|
try {
|
|
@@ -423,12 +336,11 @@ export async function resolveApiKeyAuthorization(
|
|
|
423
336
|
} catch (error) {
|
|
424
337
|
// Any failure here is the store's, not the key's — a revoked key is a
|
|
425
338
|
// normal empty result, so this only runs on a real fault.
|
|
426
|
-
|
|
339
|
+
authorizationCache.markStoreUnavailable(Date.now());
|
|
427
340
|
throw error;
|
|
428
341
|
}
|
|
429
342
|
if (!row) {
|
|
430
|
-
|
|
431
|
-
evictAuthNegativeOldest();
|
|
343
|
+
authorizationCache.rememberNegative(hash, now);
|
|
432
344
|
return undefined;
|
|
433
345
|
}
|
|
434
346
|
// A child (shared) key consumes against its parent's quota, not just its
|
|
@@ -487,8 +399,7 @@ export async function resolveApiKeyAuthorization(
|
|
|
487
399
|
...(policy.modelPrefix ? { modelPrefix: policy.modelPrefix } : {}),
|
|
488
400
|
...(parent?.keyMode === "share" ? { cliMappingOwnerId: parent.id } : {}),
|
|
489
401
|
};
|
|
490
|
-
|
|
491
|
-
evictAuthCacheOldest();
|
|
402
|
+
authorizationCache.rememberPositive(hash, value.id, value, Date.now());
|
|
492
403
|
// Shared copy for peer processes, keyed by the token hash so a lookup can
|
|
493
404
|
// find it without resolving the key first. Invalidation travels by broadcast,
|
|
494
405
|
// since a console mutation names a key id and cannot enumerate the hashes
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { getHeader as readRecordHeader } from "../protocol/canonical-model";
|
|
2
|
+
|
|
1
3
|
// Client-router fingerprinting for inbound `/v1/*` traffic.
|
|
2
4
|
//
|
|
3
5
|
// Several AI-gateway products can themselves be pointed at this gateway. An
|
|
@@ -133,13 +135,7 @@ export interface ClientRouterProbe {
|
|
|
133
135
|
function readHeader(probe: ClientRouterProbe, name: string): string | undefined {
|
|
134
136
|
const headers = probe.headers;
|
|
135
137
|
if (headers instanceof Headers) return headers.get(name) ?? undefined;
|
|
136
|
-
|
|
137
|
-
if (direct !== undefined) return direct;
|
|
138
|
-
const lowered = name.toLowerCase();
|
|
139
|
-
for (const [key, value] of Object.entries(headers)) {
|
|
140
|
-
if (key.toLowerCase() === lowered) return value;
|
|
141
|
-
}
|
|
142
|
-
return undefined;
|
|
138
|
+
return readRecordHeader(headers, name);
|
|
143
139
|
}
|
|
144
140
|
|
|
145
141
|
function signalMatches(signal: ClientRouterSignal, value: string): boolean {
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
import { isIP } from "node:net";
|
|
4
4
|
import type { TrustedProxyBoundary } from "../config/network";
|
|
5
|
+
import { log } from "../observability/logger";
|
|
5
6
|
|
|
6
7
|
function ipv4ToNumber(address: string): number | undefined {
|
|
7
8
|
const parts = address.split(".");
|
|
@@ -319,9 +320,12 @@ function maybeWarnIgnoredForwardedHeaders(
|
|
|
319
320
|
if (collapseWarned) return;
|
|
320
321
|
if (!isSuspectedProxyCollapse(request, boundary, peer)) return;
|
|
321
322
|
collapseWarned = true;
|
|
322
|
-
//
|
|
323
|
-
//
|
|
324
|
-
console
|
|
323
|
+
// Via `log`, so this ships through the same transport as everything else and
|
|
324
|
+
// is level-filtered. It was `console.warn` to keep the module importable
|
|
325
|
+
// without the logger, but the logger pulls in only pino, the console log ring
|
|
326
|
+
// and its own sanitizer — none of which import this module — so there is no
|
|
327
|
+
// cycle and the boundary tests import it the same way production does.
|
|
328
|
+
log.warn(
|
|
325
329
|
`[ip-boundary] A forwarded client-address header arrived from a loopback peer ` +
|
|
326
330
|
`(${unwrapMappedIpv4(peer)}) but the trusted-proxy boundary does not trust it, so ` +
|
|
327
331
|
`the client address is the loopback peer for every caller. Every caller behind this ` +
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared value-coercion primitives for untyped upstream JSON.
|
|
3
|
+
*
|
|
4
|
+
* These used to exist as private copies in a dozen modules, spelled
|
|
5
|
+
* `record`/`asRecord`/`recordOf`/`isPlainObject` with two different nullish
|
|
6
|
+
* sentinels. One implementation, one sentinel (`undefined`), one place to fix.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
/** Narrow an unknown value to a plain (non-array) object, else `undefined`. */
|
|
10
|
+
export function asRecord(value: unknown): Record<string, unknown> | undefined {
|
|
11
|
+
return value !== null && typeof value === "object" && !Array.isArray(value)
|
|
12
|
+
? (value as Record<string, unknown>)
|
|
13
|
+
: undefined;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/** Narrow to a finite number, else `undefined`. `NaN`/`Infinity` are not numbers here. */
|
|
17
|
+
export function asNumber(value: unknown): number | undefined {
|
|
18
|
+
return typeof value === "number" && Number.isFinite(value) ? value : undefined;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Finite number accepting numeric strings (`"42"` → 42). Whitespace-only or
|
|
23
|
+
* non-finite results are `undefined`. This is the "upstream JSON may quote a
|
|
24
|
+
* number" contract; use `asNumber` when strings must stay rejected.
|
|
25
|
+
*/
|
|
26
|
+
export function asCoercedNumber(value: unknown): number | undefined {
|
|
27
|
+
if (typeof value === "number") return asNumber(value);
|
|
28
|
+
if (typeof value === "string" && value.trim().length > 0) {
|
|
29
|
+
const parsed = Number(value);
|
|
30
|
+
return Number.isFinite(parsed) ? parsed : undefined;
|
|
31
|
+
}
|
|
32
|
+
return undefined;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** Trim a string-ish value; empty/whitespace-only becomes `undefined`. */
|
|
36
|
+
export function asTrimmedString(value: unknown): string | undefined {
|
|
37
|
+
if (typeof value !== "string") return undefined;
|
|
38
|
+
const trimmed = value.trim();
|
|
39
|
+
return trimmed.length > 0 ? trimmed : undefined;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** First non-empty trimmed string among `keys` of `record`. */
|
|
43
|
+
export function firstNonEmptyString(
|
|
44
|
+
record: Record<string, unknown>,
|
|
45
|
+
keys: readonly string[],
|
|
46
|
+
): string | undefined {
|
|
47
|
+
for (const key of keys) {
|
|
48
|
+
const value = asTrimmedString(record[key]);
|
|
49
|
+
if (value !== undefined) return value;
|
|
50
|
+
}
|
|
51
|
+
return undefined;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* `JSON.parse` that yields `undefined` instead of throwing, for untyped
|
|
56
|
+
* upstream/console JSON that may be malformed. Non-string/empty input is
|
|
57
|
+
* `undefined` without attempting a parse.
|
|
58
|
+
*/
|
|
59
|
+
export function tryParseJson(value: string | null | undefined): unknown {
|
|
60
|
+
if (typeof value !== "string" || value.length === 0) return undefined;
|
|
61
|
+
try {
|
|
62
|
+
return JSON.parse(value) as unknown;
|
|
63
|
+
} catch {
|
|
64
|
+
return undefined;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
@@ -7,10 +7,9 @@ import type { UsageRecord } from "../../protocol/canonical-model";
|
|
|
7
7
|
import { classifyUpstreamFailure } from "../failure-policy";
|
|
8
8
|
import type { AdmissionLease } from "../../security/admission/contracts";
|
|
9
9
|
import type { CartethyiaDatabase } from "../../persistence/db-handle";
|
|
10
|
-
import { TelemetryPayloadCapture } from "../../observability/payload-capture";
|
|
10
|
+
import { resolvePayloadCapture, TelemetryPayloadCapture, truncateUtf8 } from "../../observability/payload-capture";
|
|
11
11
|
import { payloadDirectory } from "../../observability/payload-store";
|
|
12
12
|
import type { TelemetryBatchBuffer } from "../../observability/telemetry-buffer";
|
|
13
|
-
import type { TelemetryPayloadDepth } from "../../console/settings/contracts";
|
|
14
13
|
import type { ProxyRequestOutcome, ProxyRequestState } from "../request/state";
|
|
15
14
|
import type { PayloadCaptureState } from "../../observability/payload-capture";
|
|
16
15
|
import { finalizeRequestTelemetry } from "../guards/error-lifecycle";
|
|
@@ -22,126 +21,7 @@ import {
|
|
|
22
21
|
recordPoolDispatchOutcome,
|
|
23
22
|
} from "../../network/pool-health-machine";
|
|
24
23
|
import { ProxyConnectRefusalError } from "../../network/pool/agent";
|
|
25
|
-
import {
|
|
26
|
-
payloadCaptureKeepsBodies,
|
|
27
|
-
payloadRetentionMs,
|
|
28
|
-
resolvePayloadCapture,
|
|
29
|
-
} from "../../observability/payload-capture";
|
|
30
|
-
|
|
31
|
-
/**
|
|
32
|
-
* True when the tenant keeps response bodies (mode `full`). The dispatch
|
|
33
|
-
* path calls this once per attempt to decide whether the provider-response
|
|
34
|
-
* tee is worth its clone + bounded read; `none`/`metadata` skip the body.
|
|
35
|
-
*/
|
|
36
|
-
export async function shouldCaptureProviderBody(
|
|
37
|
-
db: CartethyiaDatabase,
|
|
38
|
-
tenantId: string | null,
|
|
39
|
-
): Promise<boolean> {
|
|
40
|
-
return payloadCaptureKeepsBodies(db, tenantId);
|
|
41
|
-
}
|
|
42
|
-
|
|
43
|
-
/**
|
|
44
|
-
* Per-stage cap for the provider request/response tee at one capture depth.
|
|
45
|
-
*
|
|
46
|
-
* The tee is what the drawer's "Provider Request" and "Provider Response"
|
|
47
|
-
* panels read, so its cap is the most a provider panel can ever show. It used
|
|
48
|
-
* to be a flat 256 KiB at every depth: on a large request the translated
|
|
49
|
-
* provider body was clipped to 256 KiB before the shared record budget ever
|
|
50
|
-
* ran, so a tenant who raised the depth to Maximum — 16 MiB — still saw a
|
|
51
|
-
* quarter-megabyte provider request and an empty provider response. Derived
|
|
52
|
-
* from the same depth tiers as the record budget, so raising the depth now
|
|
53
|
-
* raises both.
|
|
54
|
-
*
|
|
55
|
-
* Capped at a quarter of the depth's total budget: four panels share it, and
|
|
56
|
-
* one of them must never be able to spend the whole thing.
|
|
57
|
-
*/
|
|
58
|
-
export function providerCaptureMaxBytes(depth?: TelemetryPayloadDepth): number {
|
|
59
|
-
if (depth === "moderate") return 2 * 1024 * 1024;
|
|
60
|
-
if (depth === "maximum") return 4 * 1024 * 1024;
|
|
61
|
-
return 256 * 1024;
|
|
62
|
-
}
|
|
63
|
-
|
|
64
|
-
/**
|
|
65
|
-
* Same answer as {@link shouldCaptureProviderBody}, memoized on the request.
|
|
66
|
-
*
|
|
67
|
-
* The preference is a tenant setting, so it cannot change between attempts of
|
|
68
|
-
* one request. Resolving it per attempt meant a request with several attempts
|
|
69
|
-
* paid for it repeatedly, and the per-attempt answer could in principle differ
|
|
70
|
-
* from the one the terminal capture then acted on. Passing `state` lets every
|
|
71
|
-
* attempt of a request share one resolution.
|
|
72
|
-
*/
|
|
73
|
-
export async function shouldCaptureProviderBodyForRequest(
|
|
74
|
-
db: CartethyiaDatabase,
|
|
75
|
-
tenantId: string | null,
|
|
76
|
-
state: { payloadCapturePreference?: unknown },
|
|
77
|
-
): Promise<boolean> {
|
|
78
|
-
if (state.payloadCapturePreference === undefined) {
|
|
79
|
-
state.payloadCapturePreference = await resolvePayloadCapture(db, tenantId);
|
|
80
|
-
}
|
|
81
|
-
const resolved = state.payloadCapturePreference as
|
|
82
|
-
| { readonly ok: true; readonly mode: string }
|
|
83
|
-
| { readonly ok: false };
|
|
84
|
-
return resolved.ok === true && resolved.mode === "full";
|
|
85
|
-
}
|
|
86
|
-
|
|
87
|
-
/**
|
|
88
|
-
* The tenant's capture depth, memoized on the request like the mode above.
|
|
89
|
-
*
|
|
90
|
-
* Read *after* {@link shouldCaptureProviderBodyForRequest} has populated the
|
|
91
|
-
* memo, so the two answers come from one preference read. It sizes the
|
|
92
|
-
* provider tee: a tenant who raised the depth is asking for more of every
|
|
93
|
-
* panel, and a flat cap would answer with the same clipped body regardless.
|
|
94
|
-
*/
|
|
95
|
-
export async function resolveCaptureDepthForRequest(
|
|
96
|
-
db: CartethyiaDatabase,
|
|
97
|
-
tenantId: string | null,
|
|
98
|
-
state: { payloadCapturePreference?: unknown },
|
|
99
|
-
): Promise<TelemetryPayloadDepth> {
|
|
100
|
-
if (state.payloadCapturePreference === undefined) {
|
|
101
|
-
state.payloadCapturePreference = await resolvePayloadCapture(db, tenantId);
|
|
102
|
-
}
|
|
103
|
-
const resolved = state.payloadCapturePreference as
|
|
104
|
-
| { readonly ok: true; readonly depth: TelemetryPayloadDepth }
|
|
105
|
-
| { readonly ok: false };
|
|
106
|
-
return resolved.ok === true ? resolved.depth : "minimum";
|
|
107
|
-
}
|
|
108
|
-
|
|
109
|
-
/**
|
|
110
|
-
* The tenant's chosen payload retention, memoized on the request like the mode
|
|
111
|
-
* and depth above — and resolved *after* one of them has populated the memo, so
|
|
112
|
-
* all three come from one preference read.
|
|
113
|
-
*
|
|
114
|
-
* Falls back to the deployment default when the read failed, which is the same
|
|
115
|
-
* answer the capture path reaches on its own; returning it here keeps the
|
|
116
|
-
* caller from having to know the fallback exists.
|
|
117
|
-
*/
|
|
118
|
-
export async function resolveCaptureRetentionForRequest(
|
|
119
|
-
db: CartethyiaDatabase,
|
|
120
|
-
tenantId: string | null,
|
|
121
|
-
state: { payloadCapturePreference?: unknown },
|
|
122
|
-
): Promise<number> {
|
|
123
|
-
if (state.payloadCapturePreference === undefined) {
|
|
124
|
-
state.payloadCapturePreference = await resolvePayloadCapture(db, tenantId);
|
|
125
|
-
}
|
|
126
|
-
const resolved = state.payloadCapturePreference as
|
|
127
|
-
| { readonly ok: true; readonly retentionMs: number }
|
|
128
|
-
| { readonly ok: false };
|
|
129
|
-
return resolved.ok === true ? resolved.retentionMs : payloadRetentionMs();
|
|
130
|
-
}
|
|
131
|
-
|
|
132
|
-
/**
|
|
133
|
-
* Combined body cap for one capture depth. Fixed size tiers: every depth
|
|
134
|
-
* stores all four drawer panels (client request, translated provider
|
|
135
|
-
* request, raw provider response, final client response) — only the cap
|
|
136
|
-
* changes, so raising the depth never changes *what* is visible, only how
|
|
137
|
-
* much of large bodies survives. Moderate and maximum spike CPU/memory while
|
|
138
|
-
* active; minimum is light enough to leave on.
|
|
139
|
-
*/
|
|
140
|
-
function captureDepthMaxBytes(depth: TelemetryPayloadDepth): number {
|
|
141
|
-
if (depth === "moderate") return 8 * 1024 * 1024;
|
|
142
|
-
if (depth === "maximum") return 16 * 1024 * 1024;
|
|
143
|
-
return 1 * 1024 * 1024;
|
|
144
|
-
}
|
|
24
|
+
import { captureDepthMaxBytes, providerCaptureMaxBytes } from "./capture-policy";
|
|
145
25
|
|
|
146
26
|
/**
|
|
147
27
|
* What the client received for a failed terminal attempt, in the same public
|
|
@@ -186,21 +66,24 @@ async function readBoundedBody(response: Response, maxBytes: number): Promise<st
|
|
|
186
66
|
const reader = response.body.getReader();
|
|
187
67
|
const decoder = new TextDecoder();
|
|
188
68
|
// Collect decoded chunks and join once: `+=` per chunk reallocates the
|
|
189
|
-
// prefix (O(n^2)).
|
|
69
|
+
// prefix (O(n^2)). The budget is UTF-8 **bytes** — the same basis the record
|
|
70
|
+
// budget and the request-line slice use — measured per decoded chunk, with
|
|
71
|
+
// the final chunk trimmed by truncateUtf8 so a code point is never split.
|
|
190
72
|
const parts: string[] = [];
|
|
191
|
-
let
|
|
73
|
+
let totalBytes = 0;
|
|
192
74
|
try {
|
|
193
|
-
while (
|
|
75
|
+
while (totalBytes < maxBytes) {
|
|
194
76
|
const { done, value } = await reader.read();
|
|
195
77
|
if (done) break;
|
|
196
78
|
const chunk = decoder.decode(value, { stream: true });
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
79
|
+
const chunkBytes = Buffer.byteLength(chunk, "utf8");
|
|
80
|
+
if (totalBytes + chunkBytes > maxBytes) {
|
|
81
|
+
parts.push(truncateUtf8(chunk, maxBytes - totalBytes));
|
|
82
|
+
totalBytes = maxBytes;
|
|
200
83
|
break;
|
|
201
84
|
}
|
|
202
85
|
parts.push(chunk);
|
|
203
|
-
|
|
86
|
+
totalBytes += chunkBytes;
|
|
204
87
|
}
|
|
205
88
|
} catch {
|
|
206
89
|
// Best-effort capture must never fail the request path.
|
|
@@ -339,11 +222,16 @@ export function captureProviderExchange(
|
|
|
339
222
|
method: init?.method ?? "GET",
|
|
340
223
|
url,
|
|
341
224
|
...(headers === undefined ? {} : { headers }),
|
|
342
|
-
//
|
|
343
|
-
//
|
|
344
|
-
//
|
|
345
|
-
//
|
|
346
|
-
|
|
225
|
+
// `captureBody=false` is the `none`/`metadata` path: record that a body
|
|
226
|
+
// was sent and how big it was, without slicing or parsing it. The old
|
|
227
|
+
// shape sliced up to `maxBytes` of the translated body and ran it through
|
|
228
|
+
// `JSON.parse` for every attempt — content a metadata-mode tenant had
|
|
229
|
+
// explicitly declined to retain, paid on every dispatch.
|
|
230
|
+
...(rawBody === null
|
|
231
|
+
? {}
|
|
232
|
+
: captureBody
|
|
233
|
+
? { body: parseCapturedBody(truncateUtf8(rawBody, maxBytes)) }
|
|
234
|
+
: { body_present: true, body_bytes: Buffer.byteLength(rawBody, "utf8") }),
|
|
347
235
|
};
|
|
348
236
|
const response = await inner(input, init);
|
|
349
237
|
sink.response =
|
|
@@ -426,7 +314,7 @@ async function captureTerminalPayload(
|
|
|
426
314
|
note("captured_metadata");
|
|
427
315
|
return;
|
|
428
316
|
}
|
|
429
|
-
await new TelemetryPayloadCapture(db).capture({
|
|
317
|
+
const metadataCapture = await new TelemetryPayloadCapture(db).capture({
|
|
430
318
|
tenantId,
|
|
431
319
|
requestId,
|
|
432
320
|
requestBody: null,
|
|
@@ -436,13 +324,13 @@ async function captureTerminalPayload(
|
|
|
436
324
|
scope: "tenant",
|
|
437
325
|
tenantOptIn: true,
|
|
438
326
|
});
|
|
439
|
-
note("captured_metadata");
|
|
327
|
+
note(metadataCapture.payloadCaptureState ?? "captured_metadata");
|
|
440
328
|
return;
|
|
441
329
|
}
|
|
442
330
|
const maxBytes = captureDepthMaxBytes(depth);
|
|
443
331
|
const providerRequest = providerCapture?.request ?? null;
|
|
444
332
|
const providerResponse = await resolvedProviderResponse(providerCapture);
|
|
445
|
-
await new TelemetryPayloadCapture(db).capture({
|
|
333
|
+
const terminalCapture = await new TelemetryPayloadCapture(db).capture({
|
|
446
334
|
tenantId,
|
|
447
335
|
requestId,
|
|
448
336
|
requestBody: requestBody ?? null,
|
|
@@ -455,7 +343,7 @@ async function captureTerminalPayload(
|
|
|
455
343
|
scope: "tenant",
|
|
456
344
|
tenantOptIn: true,
|
|
457
345
|
});
|
|
458
|
-
note("captured");
|
|
346
|
+
note(terminalCapture.payloadCaptureState ?? "captured");
|
|
459
347
|
} catch (error) {
|
|
460
348
|
note("failed_write");
|
|
461
349
|
reportCaptureFailure(error);
|