cartethyia 2.5.10 → 2.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.env.example +8 -0
- package/dist/dashboard/assets/{BannedPage-Cd30Z0b9.js → BannedPage-l6XMnfr9.js} +1 -1
- package/dist/dashboard/assets/BannedPage-l6XMnfr9.js.gz +0 -0
- package/dist/dashboard/assets/{CliToolDetailPage-wf5u-ww6.js → CliToolDetailPage-DwOzAW45.js} +1 -1
- package/dist/dashboard/assets/CliToolDetailPage-DwOzAW45.js.gz +0 -0
- package/dist/dashboard/assets/{CliToolsPage-DykPvuck.js → CliToolsPage-BT3Up3QA.js} +1 -1
- package/dist/dashboard/assets/CliToolsPage-BT3Up3QA.js.gz +0 -0
- package/dist/dashboard/assets/{CombosPage-DwvSD3vs.js → CombosPage-6kYIUpzR.js} +1 -1
- package/dist/dashboard/assets/CombosPage-6kYIUpzR.js.gz +0 -0
- package/dist/dashboard/assets/{ConsoleLogPage-CIr7KDex.js → ConsoleLogPage-zRnj1mlR.js} +1 -1
- package/dist/dashboard/assets/ConsoleLogPage-zRnj1mlR.js.gz +0 -0
- package/dist/dashboard/assets/{CustomizationPage-82X0DDdH.js → CustomizationPage-C7m-H1Rz.js} +1 -1
- package/dist/dashboard/assets/CustomizationPage-C7m-H1Rz.js.gz +0 -0
- package/dist/dashboard/assets/HealthEventsModal-C8MR_BdB.js +1 -0
- package/dist/dashboard/assets/HealthEventsModal-C8MR_BdB.js.gz +0 -0
- package/dist/dashboard/assets/{LoginPage-CS5mPuVF.js → LoginPage-CbphLvjg.js} +1 -1
- package/dist/dashboard/assets/LoginPage-CbphLvjg.js.gz +0 -0
- package/dist/dashboard/assets/{ModelPicker-C0UCUsVJ.js → ModelPicker-DVny4S5_.js} +1 -1
- package/dist/dashboard/assets/ModelPicker-DVny4S5_.js.gz +0 -0
- package/dist/dashboard/assets/OverviewPage-DABu2Wgt.js +1 -0
- package/dist/dashboard/assets/OverviewPage-DABu2Wgt.js.gz +0 -0
- package/dist/dashboard/assets/ProviderDetailPage-C15x9q13.js +3 -0
- package/dist/dashboard/assets/ProviderDetailPage-C15x9q13.js.gz +0 -0
- package/dist/dashboard/assets/{ProvidersPage-vdja5ZPK.js → ProvidersPage-Dfb2896L.js} +1 -1
- package/dist/dashboard/assets/ProvidersPage-Dfb2896L.js.gz +0 -0
- package/dist/dashboard/assets/{ProxyPage-BB4CNm4-.js → ProxyPage-BaCIdAUt.js} +1 -1
- package/dist/dashboard/assets/ProxyPage-BaCIdAUt.js.gz +0 -0
- package/dist/dashboard/assets/QuotaPage-BsdoCIw1.js +1 -0
- package/dist/dashboard/assets/QuotaPage-BsdoCIw1.js.gz +0 -0
- package/dist/dashboard/assets/SettingsPage-KVJFh3Ip.js +1 -0
- package/dist/dashboard/assets/SettingsPage-KVJFh3Ip.js.gz +0 -0
- package/dist/dashboard/assets/{SetupPage-FadUOl9S.js → SetupPage-D4g6vOGc.js} +1 -1
- package/dist/dashboard/assets/SetupPage-D4g6vOGc.js.gz +0 -0
- package/dist/dashboard/assets/{SortableList-Cfy6_Khv.js → SortableList-C93i14BD.js} +1 -1
- package/dist/dashboard/assets/SortableList-C93i14BD.js.gz +0 -0
- package/dist/dashboard/assets/{StudioPage-CM92fgJf.js → StudioPage-SgQpymjm.js} +1 -1
- package/dist/dashboard/assets/{StudioPage-CM92fgJf.js.gz → StudioPage-SgQpymjm.js.gz} +0 -0
- package/dist/dashboard/assets/{UsagePage-BT4MyQeT.js → UsagePage-BFME-eba.js} +2 -2
- package/dist/dashboard/assets/UsagePage-BFME-eba.js.gz +0 -0
- package/dist/dashboard/assets/{api-keys-DQYLQxpW.js → api-keys-C23S9sgp.js} +1 -1
- package/dist/dashboard/assets/api-keys-C23S9sgp.js.gz +0 -0
- package/dist/dashboard/assets/{app-B7pVRhJ1.js → app-2KhtHCv9.js} +1 -1
- package/dist/dashboard/assets/{app-B7pVRhJ1.js.gz → app-2KhtHCv9.js.gz} +0 -0
- package/dist/dashboard/assets/app-CEeaMHOy.css +2 -0
- package/dist/dashboard/assets/app-CEeaMHOy.css.gz +0 -0
- package/dist/dashboard/assets/{assert-C9FD_JwJ.js → assert-DU6qrMA_.js} +1 -1
- package/dist/dashboard/assets/assert-DU6qrMA_.js.gz +0 -0
- package/dist/dashboard/assets/{badge-Bvyr7D0u.js → badge-CKsF123d.js} +1 -1
- package/dist/dashboard/assets/badge-CKsF123d.js.gz +0 -0
- package/dist/dashboard/assets/{banner-DIvKAZih.js → banner-8j7uChA9.js} +1 -1
- package/dist/dashboard/assets/banner-8j7uChA9.js.gz +0 -0
- package/dist/dashboard/assets/{card-D5_gYIS_.js → card-CdsSlWA3.js} +1 -1
- package/dist/dashboard/assets/card-CdsSlWA3.js.gz +0 -0
- package/dist/dashboard/assets/{cli-tools-CNqzrfdb.js → cli-tools-BaFUuFFF.js} +1 -1
- package/dist/dashboard/assets/cli-tools-BaFUuFFF.js.gz +0 -0
- package/dist/dashboard/assets/{clipboard-button-B6vDBtbm.js → clipboard-button-uL5aYLfW.js} +1 -1
- package/dist/dashboard/assets/clipboard-button-uL5aYLfW.js.gz +0 -0
- package/dist/dashboard/assets/{dialog-CpjrgdwJ.js → dialog-C07hr5_G.js} +1 -1
- package/dist/dashboard/assets/dialog-C07hr5_G.js.gz +0 -0
- package/dist/dashboard/assets/{drawer-BWmt3b5y.js → drawer-BPO1TcKH.js} +1 -1
- package/dist/dashboard/assets/drawer-BPO1TcKH.js.gz +0 -0
- package/dist/dashboard/assets/{entry-C_oakV3z.js → entry-Bz_CtDfC.js} +1 -1
- package/dist/dashboard/assets/entry-Bz_CtDfC.js.gz +0 -0
- package/dist/dashboard/assets/entry-CwPRKk0d.js +4 -0
- package/dist/dashboard/assets/entry-CwPRKk0d.js.gz +0 -0
- package/dist/dashboard/assets/entry-GJRbmrpA.js +1 -0
- package/dist/dashboard/assets/entry-GJRbmrpA.js.gz +0 -0
- package/dist/dashboard/assets/{github-badge-RRHHWGxh.js → github-badge-DmQreSDd.js} +1 -1
- package/dist/dashboard/assets/github-badge-DmQreSDd.js.gz +0 -0
- package/dist/dashboard/assets/{index-Bhey_rg_.js → index-Bblu6erR.js} +2 -2
- package/dist/dashboard/assets/index-Bblu6erR.js.gz +0 -0
- package/dist/dashboard/assets/{inline-C2_EQ5Uf.js → inline-C9dCYaBZ.js} +1 -1
- package/dist/dashboard/assets/inline-C9dCYaBZ.js.gz +0 -0
- package/dist/dashboard/assets/{input-BMRlQ450.js → input-CjCnXweJ.js} +1 -1
- package/dist/dashboard/assets/input-CjCnXweJ.js.gz +0 -0
- package/dist/dashboard/assets/{layout-XR5xP8ew.js → layout-V6y8DAkl.js} +1 -1
- package/dist/dashboard/assets/layout-V6y8DAkl.js.gz +0 -0
- package/dist/dashboard/assets/{list-row-Cw07dioc.js → list-row-CM1AaG_y.js} +1 -1
- package/dist/dashboard/assets/list-row-CM1AaG_y.js.gz +0 -0
- package/dist/dashboard/assets/{live-BivGfuUs.js → live-ta6GMb-8.js} +1 -1
- package/dist/dashboard/assets/live-ta6GMb-8.js.gz +0 -0
- package/dist/dashboard/assets/{model-bans-CmffjLuG.js → model-bans-BmYTmbAk.js} +1 -1
- package/dist/dashboard/assets/model-bans-BmYTmbAk.js.gz +0 -0
- package/dist/dashboard/assets/{mutation-46P0oWMe.js → mutation-g_fduiNw.js} +1 -1
- package/dist/dashboard/assets/mutation-g_fduiNw.js.gz +0 -0
- package/dist/dashboard/assets/{numbers-DrdA9oSM.js → numbers-BOevJzdz.js} +1 -1
- package/dist/dashboard/assets/numbers-BOevJzdz.js.gz +0 -0
- package/dist/dashboard/assets/{query-keys-BFhGpwR1.js → query-keys-UETqfytp.js} +1 -1
- package/dist/dashboard/assets/query-keys-UETqfytp.js.gz +0 -0
- package/dist/dashboard/assets/{routing-CAb4F37R.js → routing-BPUo-W8d.js} +1 -1
- package/dist/dashboard/assets/routing-BPUo-W8d.js.gz +0 -0
- package/dist/dashboard/assets/{select-CxVVB__N.js → select-C980c4cz.js} +1 -1
- package/dist/dashboard/assets/select-C980c4cz.js.gz +0 -0
- package/dist/dashboard/assets/{settings-DNX9sydi.js → settings--3hxSyry.js} +1 -1
- package/dist/dashboard/assets/settings--3hxSyry.js.gz +0 -0
- package/dist/dashboard/assets/{stack-uPOwM9Hs.js → stack-_gfTdLVB.js} +1 -1
- package/dist/dashboard/assets/stack-_gfTdLVB.js.gz +0 -0
- package/dist/dashboard/assets/{state-D1Yz79iL.js → state-2c5V4zo7.js} +1 -1
- package/dist/dashboard/assets/state-2c5V4zo7.js.gz +0 -0
- package/dist/dashboard/assets/{switch-sevsRrRo.js → switch-Dkke7Naf.js} +1 -1
- package/dist/dashboard/assets/switch-Dkke7Naf.js.gz +0 -0
- package/dist/dashboard/assets/{useMutation-Dem0lXQj.js → useMutation-EDFRkn1i.js} +1 -1
- package/dist/dashboard/assets/useMutation-EDFRkn1i.js.gz +0 -0
- package/dist/dashboard/index.html +1 -1
- package/dist/dashboard/index.html.gz +0 -0
- package/dist/dashboard/precompressed-assets.json +51 -51
- package/migrations/README.md +52 -52
- package/migrations/full-postgres/0020_add_telemetry_event_analytics.sql +77 -0
- package/migrations/full-postgres/0021_add_api_key_concurrency_tolerance.sql +13 -0
- package/migrations/full-postgres/0022_add_inferhub_config_key.sql +17 -0
- package/migrations/full-postgres/0023_drop_api_key_concurrency_tolerance.sql +13 -0
- package/migrations/lite-sqlite3/0020_add_telemetry_event_analytics.sql +69 -0
- package/migrations/lite-sqlite3/0021_add_api_key_concurrency_tolerance.sql +13 -0
- package/migrations/lite-sqlite3/0022_add_inferhub_config_key.sql +17 -0
- package/migrations/lite-sqlite3/0023_drop_api_key_concurrency_tolerance.sql +13 -0
- package/package.json +96 -95
- package/scripts/cli/admin.ts +126 -128
- package/scripts/cli/autostart.ts +237 -237
- package/scripts/cli/background.ts +120 -110
- package/scripts/cli/bin/carte.cjs +8 -8
- package/scripts/cli/bin/cartethyia.cjs +167 -167
- package/scripts/cli/console-menu.ts +39 -30
- package/scripts/cli/controller.ts +145 -0
- package/scripts/cli/env.ts +99 -99
- package/scripts/cli/gateway.ts +264 -260
- package/scripts/cli/instance.ts +234 -224
- package/scripts/cli/main.ts +188 -167
- package/scripts/cli/platform.ts +64 -64
- package/scripts/cli/prompt.ts +217 -79
- package/scripts/cli/services.ts +88 -88
- package/scripts/cli/setup.ts +445 -445
- package/scripts/cli/start.ts +85 -85
- package/scripts/cli/startup-signal.ts +23 -23
- package/scripts/cli/telemetry.ts +70 -0
- package/scripts/cli/tray/index.ts +263 -263
- package/scripts/cli/tray/windows.ps1 +107 -107
- package/scripts/cli/tray.ts +29 -4
- package/scripts/cli/update.ts +39 -39
- package/scripts/cli/version.ts +67 -67
- package/scripts/cli/wordmark.ts +58 -58
- package/src/boot/app.ts +16 -1
- package/src/boot/dependencies.ts +128 -25
- package/src/config/resources.ts +137 -0
- package/src/config/runtime.ts +133 -76
- package/src/config/spec.ts +30 -29
- package/src/config/telemetry.ts +0 -25
- package/src/console/api-keys/bucket-spend.ts +14 -17
- package/src/console/api-keys/contracts.ts +410 -410
- package/src/console/api-keys/routes.ts +12 -0
- package/src/console/audit/store.ts +2 -3
- package/src/console/auth/setup-service.ts +8 -1
- package/src/console/backup/routes.ts +7 -0
- package/src/console/backup/service.ts +463 -463
- package/src/console/backup/store.ts +16 -11
- package/src/console/backup/validate.ts +1 -4
- package/src/console/cli-tools/fs-ops.ts +18 -2
- package/src/console/data-management/contracts.ts +1 -8
- package/src/console/data-management/operations.ts +61 -48
- package/src/console/data-management/routes.ts +4 -2
- package/src/console/domain-registration.ts +25 -3
- package/src/console/inferhub/client.ts +139 -0
- package/src/console/inferhub/key-store.ts +129 -0
- package/src/console/inferhub/normalize.ts +347 -0
- package/src/console/inferhub/routes.ts +411 -0
- package/src/console/inferhub/store.ts +109 -0
- package/src/console/inferhub/types.ts +183 -0
- package/src/console/observability/contracts.ts +2 -41
- package/src/console/observability/store.ts +176 -371
- package/src/console/providers/catalog/public-model-store.ts +0 -0
- package/src/console/providers/catalog/store.ts +23 -36
- package/src/console/quota/account-quota-shared.ts +52 -52
- package/src/console/quota/account-quota-tenant.ts +16 -24
- package/src/console/quota/refresh.ts +441 -441
- package/src/console/routing/pools/import-parse.ts +206 -206
- package/src/console/routing/pools/operations.ts +543 -543
- package/src/console/routing/pools/probe-verdict.ts +132 -132
- package/src/console/routing/pools/routes.ts +181 -181
- package/src/console/settings/contracts.ts +42 -40
- package/src/console/settings/store.ts +25 -25
- package/src/console/share/share-stats.ts +22 -4
- package/src/console/share/share-usage.ts +30 -4
- package/src/console/shared/cursor.ts +15 -0
- package/src/console/updates/routes.ts +12 -0
- package/src/main.ts +13 -5
- package/src/network/pool/resolver.ts +1104 -1104
- package/src/observability/client-name.ts +22 -0
- package/src/observability/event-log.ts +5 -29
- package/src/observability/event-query.ts +690 -0
- package/src/observability/log-ring.ts +26 -2
- package/src/observability/log-sanitize.ts +118 -0
- package/src/observability/logger.ts +17 -13
- package/src/observability/metrics.ts +35 -0
- package/src/observability/payload-capture.ts +114 -41
- package/src/observability/payload-store.ts +249 -24
- package/src/observability/raw-event-writer.ts +118 -0
- package/src/observability/runtime-metrics.ts +71 -1
- package/src/observability/telemetry-buffer.ts +100 -17
- package/src/observability/telemetry-reconciliation.ts +417 -0
- package/src/persistence/boot.ts +20 -9
- package/src/persistence/db-pg.ts +13 -0
- package/src/persistence/db-sqlite.ts +569 -497
- package/src/persistence/dialect.ts +3 -2
- package/src/persistence/migrate.ts +181 -181
- package/src/persistence/schema.ts +1496 -1406
- package/src/persistence/shared-cache.ts +5 -1
- package/src/persistence/stores/api-key-store.ts +581 -581
- package/src/persistence/stores/telemetry-store.ts +124 -63
- package/src/protocol/reasoning-text.ts +30 -0
- package/src/protocol/response/chat.ts +33 -7
- package/src/protocol/response/codex.ts +14 -2
- package/src/protocol/response/messages.ts +5 -2
- package/src/providers/authentication/oauth-flow-store.ts +3 -10
- package/src/providers/bundled-discovery-providers.ts +122 -122
- package/src/providers/default-registry.ts +10 -0
- package/src/providers/discovery/catalog/catalog-blob.ts +18 -18
- package/src/providers/discovery/models-dev-catalog.ts +354 -354
- package/src/providers/discovery/probe-phases.ts +43 -7
- package/src/providers/discovery/probing-service.ts +70 -32
- package/src/providers/integrations/antigravity/antigravity.ts +9 -2
- package/src/providers/integrations/buddy/buddy-catalog-shared.ts +85 -17
- package/src/providers/integrations/buddy/buddy-chat-shared.ts +934 -934
- package/src/providers/integrations/buddy/buddy-checkin.ts +5 -9
- package/src/providers/integrations/buddy/buddy-discovery-shared.ts +3 -7
- package/src/providers/integrations/buddy/buddy-quota-shared.ts +3 -1
- package/src/providers/integrations/buddy/codebuddy/cn-provider.ts +57 -49
- package/src/providers/integrations/buddy/codebuddy/provider.ts +180 -180
- package/src/providers/integrations/buddy/workbuddy/provider.ts +206 -206
- package/src/providers/integrations/buddy/workbuddy/quota.ts +161 -161
- package/src/providers/integrations/claude/claude-quota.ts +2 -2
- package/src/providers/integrations/claude/claude-user-profiles.ts +3 -2
- package/src/providers/integrations/claude/claude.ts +3 -4
- package/src/providers/integrations/cline/cline.ts +94 -8
- package/src/providers/integrations/codex/codex.ts +61 -37
- package/src/providers/integrations/cursor/cursor-catalog.ts +368 -368
- package/src/providers/integrations/cursor/cursor-http2.ts +300 -300
- package/src/providers/integrations/cursor/cursor-identity.ts +2 -4
- package/src/providers/integrations/cursor/cursor-request.ts +11 -0
- package/src/providers/integrations/cursor/cursor.ts +29 -17
- package/src/providers/integrations/cursor/protocol/framing.ts +129 -129
- package/src/providers/integrations/cursor/protocol/messages.ts +2380 -2380
- package/src/providers/integrations/cursor/protocol/struct.ts +305 -305
- package/src/providers/integrations/cursor/protocol/tool-canonical.ts +275 -0
- package/src/providers/integrations/grok/grok-quota.ts +279 -279
- package/src/providers/integrations/grok/grok-ratelimit-store.ts +73 -73
- package/src/providers/integrations/grok/grok-ratelimit.ts +218 -218
- package/src/providers/integrations/hermes.ts +7 -1
- package/src/providers/integrations/inferhub.ts +201 -73
- package/src/providers/integrations/kilo/kilo.ts +152 -152
- package/src/providers/integrations/kiro/kiro-machine-id.ts +2 -4
- package/src/providers/integrations/openrouter/provider.ts +129 -129
- package/src/providers/integrations/qoder/qoder-quota.ts +193 -192
- package/src/providers/integrations/qoder/qoder-realm.ts +106 -106
- package/src/providers/integrations/trae/trae-oauth.ts +4 -26
- package/src/providers/integrations/trae/trae-quota.ts +389 -384
- package/src/providers/integrations/trae/trae-realm.ts +15 -2
- package/src/providers/integrations/xiaomi-mimo/think-stream.ts +100 -24
- package/src/providers/integrations/zcode/oauth.ts +1 -1
- package/src/providers/operations/account-health-service.ts +605 -279
- package/src/providers/provider-metadata.ts +12 -1
- package/src/providers/provider-model-definition.ts +373 -373
- package/src/providers/quota/quota-contracts.ts +3 -7
- package/src/providers/search/search-provider.ts +2 -6
- package/src/security/admission/contracts.ts +10 -0
- package/src/security/admission/in-memory-store.ts +89 -14
- package/src/security/admission/lua.ts +191 -182
- package/src/security/admission/redis-store.ts +30 -0
- package/src/security/admission/service.ts +18 -0
- package/src/security/api-key-auth-cache.ts +96 -0
- package/src/security/api-key-auth.ts +11 -100
- package/src/security/client-router-fingerprint.ts +3 -7
- package/src/security/ip-boundary.ts +7 -3
- package/src/shared/coerce.ts +67 -0
- package/src/shared/hash.ts +6 -0
- package/src/shared/time.ts +7 -0
- package/src/transport/dispatch/attempt-finalize.ts +26 -138
- package/src/transport/dispatch/attempt-loop.ts +103 -81
- package/src/transport/dispatch/capture-policy.ts +78 -0
- package/src/transport/dispatch/proxy-request.ts +4 -2
- package/src/transport/dispatch/streaming/encoder.ts +111 -5
- package/src/transport/dispatch/streaming/stream.ts +19 -2
- package/src/transport/dispatch/streaming/thinking-loop.ts +300 -0
- package/src/transport/failure-policy.ts +27 -0
- package/src/transport/guards/abuse-guard.ts +2 -2
- package/src/transport/guards/auth-guard.ts +17 -0
- package/src/transport/guards/body-policy.ts +3 -2
- package/src/transport/guards/concurrency-guard.ts +89 -13
- package/src/transport/guards/early-rejection-capture.ts +43 -3
- package/src/transport/guards/error-lifecycle.ts +10 -2
- package/src/transport/guards/guard-errors.ts +23 -0
- package/src/transport/guards/headroom-guard.ts +41 -7
- package/src/transport/guards/pipeline.ts +24 -0
- package/src/transport/guards/request-context.ts +2 -1
- package/src/transport/routing/admission.ts +51 -9
- package/src/transport/routing/bounded-lru.ts +49 -0
- package/src/transport/routing/candidate-index.ts +88 -0
- package/src/transport/routing/reservations.ts +121 -7
- package/src/transport/routing/round-robin.ts +10 -22
- package/src/transport/routing/route-catalog.ts +26 -13
- package/src/transport/routing/route-model.ts +22 -2
- package/src/transport/routing/router.ts +53 -31
- package/src/transport/surface/chat/encode.ts +2 -3
- package/src/transport/surface/chat/parse.ts +2 -1
- package/src/transport/surface/dialects.ts +11 -3
- package/src/transport/surface/messages/encode.ts +2 -1
- package/src/transport/surface/messages/parse.ts +3 -1
- package/src/transport/surface/messages/stream.ts +2 -6
- package/src/workers/maintenance-lease.ts +200 -0
- package/src/workers/scheduler.ts +101 -1
- package/dist/dashboard/assets/BannedPage-Cd30Z0b9.js.gz +0 -0
- package/dist/dashboard/assets/CliToolDetailPage-wf5u-ww6.js.gz +0 -0
- package/dist/dashboard/assets/CliToolsPage-DykPvuck.js.gz +0 -0
- package/dist/dashboard/assets/CombosPage-DwvSD3vs.js.gz +0 -0
- package/dist/dashboard/assets/ConsoleLogPage-CIr7KDex.js.gz +0 -0
- package/dist/dashboard/assets/CustomizationPage-82X0DDdH.js.gz +0 -0
- package/dist/dashboard/assets/HealthEventsModal-BYPucTTn.js +0 -1
- package/dist/dashboard/assets/HealthEventsModal-BYPucTTn.js.gz +0 -0
- package/dist/dashboard/assets/LoginPage-CS5mPuVF.js.gz +0 -0
- package/dist/dashboard/assets/ModelPicker-C0UCUsVJ.js.gz +0 -0
- package/dist/dashboard/assets/OverviewPage-Dk_ew3YU.js +0 -1
- package/dist/dashboard/assets/OverviewPage-Dk_ew3YU.js.gz +0 -0
- package/dist/dashboard/assets/ProviderDetailPage-CAiRn2Q0.js +0 -3
- package/dist/dashboard/assets/ProviderDetailPage-CAiRn2Q0.js.gz +0 -0
- package/dist/dashboard/assets/ProvidersPage-vdja5ZPK.js.gz +0 -0
- package/dist/dashboard/assets/ProxyPage-BB4CNm4-.js.gz +0 -0
- package/dist/dashboard/assets/QuotaPage-FEc4iNyq.js +0 -1
- package/dist/dashboard/assets/QuotaPage-FEc4iNyq.js.gz +0 -0
- package/dist/dashboard/assets/SettingsPage-Dlfi5L4N.js +0 -1
- package/dist/dashboard/assets/SettingsPage-Dlfi5L4N.js.gz +0 -0
- package/dist/dashboard/assets/SetupPage-FadUOl9S.js.gz +0 -0
- package/dist/dashboard/assets/SortableList-Cfy6_Khv.js.gz +0 -0
- package/dist/dashboard/assets/UsagePage-BT4MyQeT.js.gz +0 -0
- package/dist/dashboard/assets/api-keys-DQYLQxpW.js.gz +0 -0
- package/dist/dashboard/assets/app-CX_Z8cMs.css +0 -2
- package/dist/dashboard/assets/app-CX_Z8cMs.css.gz +0 -0
- package/dist/dashboard/assets/assert-C9FD_JwJ.js.gz +0 -0
- package/dist/dashboard/assets/badge-Bvyr7D0u.js.gz +0 -0
- package/dist/dashboard/assets/banner-DIvKAZih.js.gz +0 -0
- package/dist/dashboard/assets/card-D5_gYIS_.js.gz +0 -0
- package/dist/dashboard/assets/cli-tools-CNqzrfdb.js.gz +0 -0
- package/dist/dashboard/assets/clipboard-button-B6vDBtbm.js.gz +0 -0
- package/dist/dashboard/assets/dialog-CpjrgdwJ.js.gz +0 -0
- package/dist/dashboard/assets/drawer-BWmt3b5y.js.gz +0 -0
- package/dist/dashboard/assets/entry-B-gvimrg.js +0 -1
- package/dist/dashboard/assets/entry-B-gvimrg.js.gz +0 -0
- package/dist/dashboard/assets/entry-C_oakV3z.js.gz +0 -0
- package/dist/dashboard/assets/entry-Dj0cHfEc.js +0 -4
- package/dist/dashboard/assets/entry-Dj0cHfEc.js.gz +0 -0
- package/dist/dashboard/assets/github-badge-RRHHWGxh.js.gz +0 -0
- package/dist/dashboard/assets/index-Bhey_rg_.js.gz +0 -0
- package/dist/dashboard/assets/inline-C2_EQ5Uf.js.gz +0 -0
- package/dist/dashboard/assets/input-BMRlQ450.js.gz +0 -0
- package/dist/dashboard/assets/layout-XR5xP8ew.js.gz +0 -0
- package/dist/dashboard/assets/list-row-Cw07dioc.js.gz +0 -0
- package/dist/dashboard/assets/live-BivGfuUs.js.gz +0 -0
- package/dist/dashboard/assets/model-bans-CmffjLuG.js.gz +0 -0
- package/dist/dashboard/assets/mutation-46P0oWMe.js.gz +0 -0
- package/dist/dashboard/assets/numbers-DrdA9oSM.js.gz +0 -0
- package/dist/dashboard/assets/query-keys-BFhGpwR1.js.gz +0 -0
- package/dist/dashboard/assets/routing-CAb4F37R.js.gz +0 -0
- package/dist/dashboard/assets/select-CxVVB__N.js.gz +0 -0
- package/dist/dashboard/assets/settings-DNX9sydi.js.gz +0 -0
- package/dist/dashboard/assets/stack-uPOwM9Hs.js.gz +0 -0
- package/dist/dashboard/assets/state-D1Yz79iL.js.gz +0 -0
- package/dist/dashboard/assets/switch-sevsRrRo.js.gz +0 -0
- package/dist/dashboard/assets/useMutation-Dem0lXQj.js.gz +0 -0
- package/src/observability/event-compaction.ts +0 -317
- package/src/observability/event-day-index.ts +0 -439
|
@@ -32,7 +32,8 @@
|
|
|
32
32
|
import { and, eq, sql } from "drizzle-orm";
|
|
33
33
|
import { gzipSync, gunzipSync } from "node:zlib";
|
|
34
34
|
import { differsFrom } from "../../persistence/dialect";
|
|
35
|
-
import {
|
|
35
|
+
import { TELEMETRY_EVENT_DIR } from "../../observability/event-log";
|
|
36
|
+
import { queryTelemetryEvents as querySqlEvents } from "../../observability/event-query";
|
|
36
37
|
import { appendFile, mkdir } from "node:fs/promises";
|
|
37
38
|
import { getTableColumns, type Column, type SQL, type Table } from "drizzle-orm";
|
|
38
39
|
import type { CartethyiaDatabase } from "../../persistence/db-handle";
|
|
@@ -294,7 +295,7 @@ export async function exportBackup(
|
|
|
294
295
|
const reads = tables.map(async (table) =>
|
|
295
296
|
[
|
|
296
297
|
table,
|
|
297
|
-
table === telemetryEvents ? await exportEventFile(tenantId) : await exportTable(db, table, tenantId),
|
|
298
|
+
table === telemetryEvents ? await exportEventFile(db, tenantId) : await exportTable(db, table, tenantId),
|
|
298
299
|
] as const,
|
|
299
300
|
);
|
|
300
301
|
// Preserve the caller's `tables` order when writing into the output maps so
|
|
@@ -373,10 +374,12 @@ export function decompressBackup(bytes: Uint8Array): string {
|
|
|
373
374
|
* backup contract still names. The table is no longer written, so reading it
|
|
374
375
|
* would export nothing.
|
|
375
376
|
*/
|
|
376
|
-
async function exportEventFile(tenantId: string): Promise<readonly BackupRow[]> {
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
377
|
+
async function exportEventFile(db: CartethyiaDatabase, tenantId: string): Promise<readonly BackupRow[]> {
|
|
378
|
+
// The SQL table is the durable record now, and it is tenant-scoped and
|
|
379
|
+
// retained by the same rule as the raw files — so the export reads it
|
|
380
|
+
// instead of scanning every raw file and filtering by tenant afterwards.
|
|
381
|
+
const page = await querySqlEvents(db, { tenantId, limit: MAX_ROWS_PER_TABLE });
|
|
382
|
+
return page.events
|
|
380
383
|
.slice(0, MAX_ROWS_PER_TABLE)
|
|
381
384
|
.map((event) => {
|
|
382
385
|
const row: BackupRow = {};
|
|
@@ -465,13 +468,15 @@ function createdAtIso(value: unknown): string {
|
|
|
465
468
|
* so the two sides are comparable regardless of how the driver types them.
|
|
466
469
|
*/
|
|
467
470
|
/** Natural keys already present in the event file, so a re-import skips them. */
|
|
468
|
-
async function existingEventFileKeys(rows: readonly BackupRow[]): Promise<Set<string>> {
|
|
471
|
+
async function existingEventFileKeys(db: CartethyiaDatabase, tenantId: string, rows: readonly BackupRow[]): Promise<Set<string>> {
|
|
469
472
|
const wanted = new Set(rows.map((row) => row.request_id).filter((id): id is string => typeof id === "string"));
|
|
470
473
|
if (wanted.size === 0) return new Set();
|
|
471
|
-
|
|
474
|
+
// One indexed query for the ids being restored, instead of reading every
|
|
475
|
+
// raw file to test membership. `id = request_id`, so the unique index serves
|
|
476
|
+
// this directly.
|
|
477
|
+
const page = await querySqlEvents(db, { tenantId, requestIds: [...wanted] });
|
|
472
478
|
return new Set(
|
|
473
|
-
events
|
|
474
|
-
.filter((event) => wanted.has(event.requestId))
|
|
479
|
+
page.events
|
|
475
480
|
.map((event) => `${event.tenantId ?? ""}\u0000${event.requestId}\u0000${event.createdAt}`),
|
|
476
481
|
);
|
|
477
482
|
}
|
|
@@ -832,7 +837,7 @@ export async function applyRestore(
|
|
|
832
837
|
const entry = validation.tables.get(table);
|
|
833
838
|
if (entry === undefined) continue;
|
|
834
839
|
if (table === telemetryEvents) {
|
|
835
|
-
const existing = await existingEventFileKeys(entry.rows);
|
|
840
|
+
const existing = await existingEventFileKeys(db, tenantId, entry.rows);
|
|
836
841
|
const fresh = entry.rows.filter((row) => !existing.has(telemetryKey(row)));
|
|
837
842
|
await appendRestoredEvents(fresh);
|
|
838
843
|
restored[tableName(table)] = fresh.length;
|
|
@@ -13,6 +13,7 @@
|
|
|
13
13
|
*/
|
|
14
14
|
import type { Table } from "drizzle-orm";
|
|
15
15
|
import { getTableColumns } from "drizzle-orm";
|
|
16
|
+
import { isRecord as isPlainObject } from "../../protocol/codecs";
|
|
16
17
|
import { decryptCredentialToString } from "../../security/crypto";
|
|
17
18
|
import {
|
|
18
19
|
BACKUP_APP,
|
|
@@ -100,10 +101,6 @@ function normalizeLegacyCiphertext(table: Table, row: Record<string, unknown>):
|
|
|
100
101
|
return normalized;
|
|
101
102
|
}
|
|
102
103
|
|
|
103
|
-
function isPlainObject(value: unknown): value is Record<string, unknown> {
|
|
104
|
-
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
105
|
-
}
|
|
106
|
-
|
|
107
104
|
/** Column name → its Drizzle data type, so validation knows what may nest. */
|
|
108
105
|
function columnTypes(table: Table): ReadonlyMap<string, string> {
|
|
109
106
|
const out = new Map<string, string>();
|
|
@@ -11,8 +11,24 @@ import { homedir, platform } from "node:os";
|
|
|
11
11
|
import { join } from "node:path";
|
|
12
12
|
import { mkdir, rm } from "node:fs/promises";
|
|
13
13
|
|
|
14
|
-
/**
|
|
15
|
-
|
|
14
|
+
/**
|
|
15
|
+
* Home directory — shared contract used by all injectors.
|
|
16
|
+
*
|
|
17
|
+
* Reads the environment on every call rather than exporting `os.homedir()`,
|
|
18
|
+
* whose value is cached at first use. That cache silently broke the redirect
|
|
19
|
+
* this module documents: a test that pointed `HOME` at a temp dir had its
|
|
20
|
+
* first path resolved correctly and every later one resolved to the home that
|
|
21
|
+
* was live when the process started — and on Linux that is the only value
|
|
22
|
+
* `os.homedir()` ever returns, so injectors wrote to the real home while the
|
|
23
|
+
* assertions read a temp dir that `afterEach` had already deleted.
|
|
24
|
+
*
|
|
25
|
+
* Production behaviour is unchanged: `HOME`/`USERPROFILE` are still what
|
|
26
|
+
* decide the directory, so an operator's environment is honoured as before.
|
|
27
|
+
*/
|
|
28
|
+
export function homeDir(): string {
|
|
29
|
+
const fromEnv = process.env.HOME ?? process.env.USERPROFILE;
|
|
30
|
+
return fromEnv && fromEnv.length > 0 ? fromEnv : homedir();
|
|
31
|
+
}
|
|
16
32
|
export { join };
|
|
17
33
|
|
|
18
34
|
const IS_WIN: boolean = platform() === "win32";
|
|
@@ -19,7 +19,7 @@
|
|
|
19
19
|
/** One measured category of on-disk data. */
|
|
20
20
|
export interface DiskUsageBucket {
|
|
21
21
|
/** Stable id, so the dashboard can key rows without matching on copy. */
|
|
22
|
-
readonly id: "telemetry_events" | "
|
|
22
|
+
readonly id: "telemetry_events" | "payload_frames";
|
|
23
23
|
readonly label: string;
|
|
24
24
|
readonly bytes: number;
|
|
25
25
|
readonly files: number;
|
|
@@ -37,13 +37,6 @@ export interface DiskUsageResponse {
|
|
|
37
37
|
readonly retentionDays: number;
|
|
38
38
|
/** Payload frames live this long by default before they are reclaimable. */
|
|
39
39
|
readonly payloadRetentionMs: number;
|
|
40
|
-
/**
|
|
41
|
-
* The longest retention a tenant may have chosen. A frame is only counted as
|
|
42
|
-
* reclaimable once it is older than *this*, because a tenant who raised the
|
|
43
|
-
* window has frames the default would call dead while their rows still
|
|
44
|
-
* address them.
|
|
45
|
-
*/
|
|
46
|
-
readonly payloadRetentionMaxMs: number;
|
|
47
40
|
/**
|
|
48
41
|
* Stored health & error rows, for the purge row's label. Absent when the
|
|
49
42
|
* composition does not wire the health-event store (a reduced console),
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { readdir, stat, unlink } from "node:fs/promises";
|
|
2
2
|
import { join } from "node:path";
|
|
3
3
|
import { payloadDirectory, prunePayloadFrames } from "../../observability/payload-store";
|
|
4
|
+
import { payloadPruneBound } from "../../observability/payload-capture";
|
|
4
5
|
import type {
|
|
5
6
|
DiskUsageBucket,
|
|
6
7
|
DiskUsageResponse,
|
|
@@ -9,6 +10,7 @@ import type {
|
|
|
9
10
|
ReclaimResponse,
|
|
10
11
|
} from "./contracts";
|
|
11
12
|
import { PurgeNotWiredError, ReclaimNotAllowedError } from "./contracts";
|
|
13
|
+
import { hourStamp } from "../../shared/time";
|
|
12
14
|
|
|
13
15
|
/**
|
|
14
16
|
* Reads and reclaims the on-disk telemetry and payload directories, and
|
|
@@ -33,14 +35,16 @@ import { PurgeNotWiredError, ReclaimNotAllowedError } from "./contracts";
|
|
|
33
35
|
export interface DataManagementConfig {
|
|
34
36
|
readonly telemetryDir: string;
|
|
35
37
|
readonly retentionDays: number;
|
|
36
|
-
readonly payloadRetentionMs: number;
|
|
37
38
|
/**
|
|
38
|
-
*
|
|
39
|
-
*
|
|
40
|
-
*
|
|
41
|
-
*
|
|
39
|
+
* How long a payload frame lives, for one tenant.
|
|
40
|
+
*
|
|
41
|
+
* A function of the tenant, not a number: the Request detail window in
|
|
42
|
+
* Settings is per tenant, and a single deployment-level number made this
|
|
43
|
+
* panel report the deployment default while the gateway applied the tenant's
|
|
44
|
+
* own window. The same panel then offered to reclaim frames the gateway
|
|
45
|
+
* still considered live, and left alone ones it had already expired.
|
|
42
46
|
*/
|
|
43
|
-
readonly
|
|
47
|
+
readonly payloadRetentionMs: (tenantId: string | null) => Promise<number>;
|
|
44
48
|
/**
|
|
45
49
|
* The health & error log store, backing the console's only row-level purge.
|
|
46
50
|
* Absent (a reduced console composition) means the purge is not offered
|
|
@@ -78,13 +82,37 @@ async function scan(
|
|
|
78
82
|
return { bytes, files };
|
|
79
83
|
}
|
|
80
84
|
|
|
81
|
-
const
|
|
82
|
-
|
|
85
|
+
const isEventLog = (name: string): boolean =>
|
|
86
|
+
name.endsWith(".jsonl") && !name.endsWith(".compacted.jsonl");
|
|
83
87
|
const isFrame = (name: string): boolean =>
|
|
84
88
|
name.endsWith(".jsonl");
|
|
85
89
|
|
|
90
|
+
/**
|
|
91
|
+
* A duration in minutes as the operator's own units, not raw milliseconds.
|
|
92
|
+
*
|
|
93
|
+
* An hour is "1 hour" rather than "60 min" because that is how the window was
|
|
94
|
+
* chosen — the dropdown they picked it from says "1 hour", and a note that
|
|
95
|
+
* renders the same choice as "60 min" makes the two screens disagree about
|
|
96
|
+
* what was set.
|
|
97
|
+
*/
|
|
98
|
+
function formatMinutes(ms: number): string {
|
|
99
|
+
const minutes = Math.round(ms / 60_000);
|
|
100
|
+
if (minutes % 1440 === 0) {
|
|
101
|
+
const days = minutes / 1440;
|
|
102
|
+
return `${days} day${days === 1 ? "" : "s"}`;
|
|
103
|
+
}
|
|
104
|
+
if (minutes % 60 === 0) {
|
|
105
|
+
const hours = minutes / 60;
|
|
106
|
+
return `${hours} hour${hours === 1 ? "" : "s"}`;
|
|
107
|
+
}
|
|
108
|
+
return `${minutes} min`;
|
|
109
|
+
}
|
|
110
|
+
|
|
86
111
|
/** The UTC day a telemetry file name starts with, compared to a cutoff. */
|
|
87
|
-
function
|
|
112
|
+
function eventBefore(name: string, cutoff: number): boolean {
|
|
113
|
+
if (!name.endsWith(".events.jsonl")) {
|
|
114
|
+
return name.slice(0, 13) < hourStamp(new Date(cutoff));
|
|
115
|
+
}
|
|
88
116
|
const at = Date.parse(`${name.slice(0, 10)}T00:00:00Z`);
|
|
89
117
|
return Number.isFinite(at) && at < cutoff;
|
|
90
118
|
}
|
|
@@ -92,55 +120,47 @@ function dayBefore(name: string, cutoff: number): boolean {
|
|
|
92
120
|
export function createDataManagementOperations(config: DataManagementConfig) {
|
|
93
121
|
const payloadDir = (): string => payloadDirectory();
|
|
94
122
|
/**
|
|
95
|
-
*
|
|
123
|
+
* Disk usage for one tenant.
|
|
96
124
|
*
|
|
97
|
-
*
|
|
98
|
-
*
|
|
99
|
-
*
|
|
100
|
-
*
|
|
125
|
+
* `tenantId` is required because the payload window is a per-tenant setting:
|
|
126
|
+
* counting "stale" frames against the deployment default declared frames
|
|
127
|
+
* stale while the gateway still held them as live, and under-counted them
|
|
128
|
+
* for a tenant that had shortened its window.
|
|
101
129
|
*/
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
const retentionCutoff = Date.now() - config.retentionDays * 24 * 60 * 60_000;
|
|
106
|
-
const payloadCutoff = Date.now() - payloadMax();
|
|
130
|
+
async function usage(tenantId: string | null): Promise<DiskUsageResponse> {
|
|
131
|
+
const retentionMs = await config.payloadRetentionMs(tenantId);
|
|
132
|
+
const payloadCutoff = Date.now() - retentionMs;
|
|
107
133
|
|
|
108
|
-
const [
|
|
109
|
-
scan(config.telemetryDir,
|
|
110
|
-
scan(config.telemetryDir, isIndex),
|
|
111
|
-
scan(config.telemetryDir, (name) => isIndex(name) && dayBefore(name, retentionCutoff)),
|
|
134
|
+
const [events, frames, staleFrames, healthEventCount] = await Promise.all([
|
|
135
|
+
scan(config.telemetryDir, isEventLog),
|
|
112
136
|
scan(payloadDir(), isFrame),
|
|
113
137
|
scan(payloadDir(), isFrame, payloadCutoff),
|
|
114
138
|
config.healthEvents?.count(),
|
|
115
139
|
]);
|
|
116
140
|
|
|
141
|
+
/*
|
|
142
|
+
* The notes name the same knobs Operations exposes, read at request time
|
|
143
|
+
* rather than written into the copy. A fixed "15 min" was wrong the moment
|
|
144
|
+
* an operator changed the tenant's window: the panel kept quoting the
|
|
145
|
+
* deployment default while the gateway applied their number, so the one
|
|
146
|
+
* place that could correct the assumption repeated it.
|
|
147
|
+
*/
|
|
117
148
|
const buckets: DiskUsageBucket[] = [
|
|
118
149
|
{
|
|
119
150
|
id: "telemetry_events",
|
|
120
151
|
label: "Request event files",
|
|
121
|
-
bytes:
|
|
122
|
-
files:
|
|
123
|
-
note:
|
|
152
|
+
bytes: events.bytes,
|
|
153
|
+
files: events.files,
|
|
154
|
+
note: `Feeds the request list and detail drawer. Kept ${config.retentionDays} days.`,
|
|
124
155
|
reclaimableBytes: null,
|
|
125
156
|
reclaimableFiles: null,
|
|
126
157
|
},
|
|
127
|
-
{
|
|
128
|
-
id: "telemetry_index",
|
|
129
|
-
label: "Day indexes",
|
|
130
|
-
bytes: compacted.bytes,
|
|
131
|
-
files: compacted.files,
|
|
132
|
-
note: "Feeds usage totals, breakdowns and the chart.",
|
|
133
|
-
reclaimableBytes: staleCompacted.bytes,
|
|
134
|
-
reclaimableFiles: staleCompacted.files,
|
|
135
|
-
},
|
|
136
158
|
{
|
|
137
159
|
id: "payload_frames",
|
|
138
160
|
label: "Payload frames",
|
|
139
161
|
bytes: frames.bytes,
|
|
140
162
|
files: frames.files,
|
|
141
|
-
note: `Captured bodies, expiring ${
|
|
142
|
-
config.payloadRetentionMs / 60_000,
|
|
143
|
-
)} min after write by default.`,
|
|
163
|
+
note: `Captured bodies, expiring ${formatMinutes(retentionMs)} after write.`,
|
|
144
164
|
reclaimableBytes: staleFrames.bytes,
|
|
145
165
|
reclaimableFiles: staleFrames.files,
|
|
146
166
|
},
|
|
@@ -150,8 +170,7 @@ export function createDataManagementOperations(config: DataManagementConfig) {
|
|
|
150
170
|
buckets,
|
|
151
171
|
totalBytes: buckets.reduce((sum, bucket) => sum + bucket.bytes, 0),
|
|
152
172
|
retentionDays: config.retentionDays,
|
|
153
|
-
payloadRetentionMs:
|
|
154
|
-
payloadRetentionMaxMs: payloadMax(),
|
|
173
|
+
payloadRetentionMs: retentionMs,
|
|
155
174
|
...(healthEventCount === undefined ? {} : { healthEventCount }),
|
|
156
175
|
};
|
|
157
176
|
}
|
|
@@ -168,10 +187,7 @@ export function createDataManagementOperations(config: DataManagementConfig) {
|
|
|
168
187
|
// written after that bound might still hold a live frame in the part
|
|
169
188
|
// that failed to parse, so it is left for a later pass.
|
|
170
189
|
const now = new Date();
|
|
171
|
-
const deletedFiles = await prunePayloadFrames(
|
|
172
|
-
now,
|
|
173
|
-
new Date(now.getTime() - payloadMax()),
|
|
174
|
-
);
|
|
190
|
+
const deletedFiles = await prunePayloadFrames(now, payloadPruneBound(now));
|
|
175
191
|
const after = await scan(payloadDir(), isFrame);
|
|
176
192
|
return {
|
|
177
193
|
action,
|
|
@@ -180,12 +196,9 @@ export function createDataManagementOperations(config: DataManagementConfig) {
|
|
|
180
196
|
};
|
|
181
197
|
}
|
|
182
198
|
if (action === "telemetry_retention") {
|
|
183
|
-
// Only day indexes past retention, and only together with their events.
|
|
184
|
-
// Deleting one without the other is the failure this avoids: an index
|
|
185
|
-
// with no events reports totals for requests that are gone.
|
|
186
199
|
const cutoff = Date.now() - config.retentionDays * 24 * 60 * 60_000;
|
|
187
200
|
return unlinkWhere(action, config.telemetryDir, (name) =>
|
|
188
|
-
(
|
|
201
|
+
isEventLog(name) && eventBefore(name, cutoff),
|
|
189
202
|
);
|
|
190
203
|
}
|
|
191
204
|
throw new ReclaimNotAllowedError(`Unknown reclaim action: ${action}`);
|
|
@@ -46,8 +46,10 @@ export function createDataManagementRoutes(
|
|
|
46
46
|
.get("/usage", async ({ request, set }) => {
|
|
47
47
|
try {
|
|
48
48
|
const access = requireAnyScope(config.accessResolver(request), WRITE_SCOPES);
|
|
49
|
-
|
|
50
|
-
|
|
49
|
+
// The tenant decides the payload window, so the scan needs it: the
|
|
50
|
+
// count of reclaimable frames is measured against that tenant's own
|
|
51
|
+
// retention, not the deployment's.
|
|
52
|
+
return await operations.usage(access.tenantId ?? null);
|
|
51
53
|
} catch (error) {
|
|
52
54
|
return errorResponse(error, set, "Could not read disk usage");
|
|
53
55
|
}
|
|
@@ -5,6 +5,8 @@ import { createAccountQuotaRoutes } from "./quota/account-quota";
|
|
|
5
5
|
import { createApiKeyRoutes } from "./api-keys/routes";
|
|
6
6
|
import { familyBucketSpend } from "./api-keys/bucket-spend";
|
|
7
7
|
import { createProviderCatalogRoutes } from "./providers/catalog/routes";
|
|
8
|
+
import { createInferhubRoutes } from "./inferhub/routes";
|
|
9
|
+
import { createInferhubKeyStore } from "./inferhub/key-store";
|
|
8
10
|
import {
|
|
9
11
|
createProviderDetailRoutes,
|
|
10
12
|
} from "./providers/detail/routes";
|
|
@@ -43,7 +45,7 @@ import { createBackupRoutes } from "./backup/routes";
|
|
|
43
45
|
import { BackupService } from "./backup/service";
|
|
44
46
|
import type { ConsoleCredentialService } from "./auth/credentials";
|
|
45
47
|
import { resolveTelemetryRetentionDays } from "../config/telemetry";
|
|
46
|
-
import {
|
|
48
|
+
import { payloadRetentionMs, resolvePayloadCapture } from "../observability/payload-capture";
|
|
47
49
|
import { TELEMETRY_EVENT_DIR } from "../observability/event-log";
|
|
48
50
|
|
|
49
51
|
import { InMemoryOAuthFlowStore, OAuthFlowStore } from "../providers/authentication/oauth-flow-store";
|
|
@@ -249,6 +251,14 @@ export function registerConsoleDomains(
|
|
|
249
251
|
service: createRemoteSupportService({ port: resolvePort() }),
|
|
250
252
|
}));
|
|
251
253
|
console.use(createAuditRoutes({ store: auditReadStore, accessResolver: ctx.accessResolver }));
|
|
254
|
+
// InferHub market data. On demand only: a route fetches when its snapshot is
|
|
255
|
+
// past its freshness window, so a page nobody opens causes no upstream calls.
|
|
256
|
+
const inferhubKeys = createInferhubKeyStore(ctx.db);
|
|
257
|
+
console.use(createInferhubRoutes({
|
|
258
|
+
accessResolver: ctx.accessResolver,
|
|
259
|
+
resolveConfigKey: () => inferhubKeys.read(),
|
|
260
|
+
keyStore: inferhubKeys,
|
|
261
|
+
}));
|
|
252
262
|
if (ctx.modelStrikes) {
|
|
253
263
|
console.use(createModelAbuseRoutes({
|
|
254
264
|
strikes: ctx.modelStrikes,
|
|
@@ -293,8 +303,20 @@ export function registerConsoleDomains(
|
|
|
293
303
|
createDataManagementRoutes({
|
|
294
304
|
telemetryDir: TELEMETRY_EVENT_DIR,
|
|
295
305
|
retentionDays: resolveTelemetryRetentionDays(),
|
|
296
|
-
|
|
297
|
-
|
|
306
|
+
/*
|
|
307
|
+
* Resolved per tenant, not once at boot: the Request detail window is a
|
|
308
|
+
* per-tenant setting, and this panel is the only place that reports the
|
|
309
|
+
* number back. A deployment-level value made it show the default while
|
|
310
|
+
* the gateway applied the tenant's own window.
|
|
311
|
+
*
|
|
312
|
+
* It goes through the same `resolvePayloadCapture` the dispatch path
|
|
313
|
+
* uses, so the panel and the gateway cannot disagree about how long a
|
|
314
|
+
* frame lives.
|
|
315
|
+
*/
|
|
316
|
+
payloadRetentionMs: async (tenantId) => {
|
|
317
|
+
const resolved = await resolvePayloadCapture(ctx.db, tenantId);
|
|
318
|
+
return resolved.ok ? resolved.retentionMs : payloadRetentionMs();
|
|
319
|
+
},
|
|
298
320
|
accessResolver: ctx.accessResolver,
|
|
299
321
|
auditSink: ctx.auditRecorder,
|
|
300
322
|
flusher: ctx.flushBuffers,
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* InferHub management-API client.
|
|
3
|
+
*
|
|
4
|
+
* Called only from route handlers — there is no scheduler, no poller, and no
|
|
5
|
+
* background loop anywhere in this design. A page that nobody opens issues no
|
|
6
|
+
* requests.
|
|
7
|
+
*
|
|
8
|
+
* Two constraints the API imposes:
|
|
9
|
+
* - The documented budget is 30 req/min per account for Management + MCP. All
|
|
10
|
+
* calls share one bucket capped at 20/min to leave headroom, and the public
|
|
11
|
+
* endpoints are assumed to share it because the docs do not prove otherwise.
|
|
12
|
+
* - Every value here is untrusted remote data. A response is parsed defensively
|
|
13
|
+
* and a failure returns `undefined` — never a half-parsed object that a route
|
|
14
|
+
* would render as if it were good.
|
|
15
|
+
*/
|
|
16
|
+
import { createValidatedFetch, type ValidatedFetch } from "../../network/outbound-fetch";
|
|
17
|
+
|
|
18
|
+
export const INFERHUB_API_BASE = "https://inferhub.dev/api";
|
|
19
|
+
|
|
20
|
+
/** Leave headroom under the documented 30/min Management budget. */
|
|
21
|
+
const RATE_LIMIT_PER_MINUTE = 20;
|
|
22
|
+
const RATE_LIMIT_WINDOW_MS = 60_000;
|
|
23
|
+
const REQUEST_TIMEOUT_MS = 10_000;
|
|
24
|
+
/** Bounded: one retry, and only when the server asked us to wait. */
|
|
25
|
+
const MAX_ATTEMPTS = 2;
|
|
26
|
+
|
|
27
|
+
/** One shared bucket for every management call this process makes. */
|
|
28
|
+
interface Bucket {
|
|
29
|
+
windowStart: number;
|
|
30
|
+
used: number;
|
|
31
|
+
}
|
|
32
|
+
const bucket: Bucket = { windowStart: Date.now(), used: 0 };
|
|
33
|
+
|
|
34
|
+
function takeToken(now: number): boolean {
|
|
35
|
+
if (now - bucket.windowStart >= RATE_LIMIT_WINDOW_MS) {
|
|
36
|
+
bucket.windowStart = now;
|
|
37
|
+
bucket.used = 0;
|
|
38
|
+
}
|
|
39
|
+
if (bucket.used >= RATE_LIMIT_PER_MINUTE) return false;
|
|
40
|
+
bucket.used += 1;
|
|
41
|
+
return true;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** Test seam: lets a test drive the bucket without waiting a minute. */
|
|
45
|
+
export function __resetRateLimit(now: number = Date.now()): void {
|
|
46
|
+
bucket.windowStart = now;
|
|
47
|
+
bucket.used = 0;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export interface InferhubClientOptions {
|
|
51
|
+
/** `config_read` key; required for `/catalog`, ignored by the public endpoints. */
|
|
52
|
+
readonly configKey?: string | undefined;
|
|
53
|
+
readonly fetchFn?: ValidatedFetch | typeof fetch;
|
|
54
|
+
readonly now?: () => number;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export class InferhubRateLimited extends Error {
|
|
58
|
+
constructor() {
|
|
59
|
+
super("inferhub management rate limit reached");
|
|
60
|
+
this.name = "InferhubRateLimited";
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* One management GET.
|
|
66
|
+
*
|
|
67
|
+
* Returns `undefined` for any non-2xx, timeout, rate-limit exhaustion, or parse
|
|
68
|
+
* failure. A caller that gets `undefined` keeps its last good snapshot, so a
|
|
69
|
+
* transient upstream problem never blanks an operator's view.
|
|
70
|
+
*/
|
|
71
|
+
async function get(
|
|
72
|
+
path: string,
|
|
73
|
+
options: InferhubClientOptions,
|
|
74
|
+
): Promise<unknown> {
|
|
75
|
+
const now = options.now?.() ?? Date.now();
|
|
76
|
+
if (!takeToken(now)) return undefined;
|
|
77
|
+
|
|
78
|
+
const fetchFn = (options.fetchFn ?? createValidatedFetch()) as ValidatedFetch;
|
|
79
|
+
const headers: Record<string, string> = { accept: "application/json" };
|
|
80
|
+
if (options.configKey !== undefined && options.configKey.length > 0)
|
|
81
|
+
headers.authorization = `Bearer ${options.configKey}`;
|
|
82
|
+
|
|
83
|
+
for (let attempt = 1; attempt <= MAX_ATTEMPTS; attempt++) {
|
|
84
|
+
try {
|
|
85
|
+
const response = await fetchFn(`${INFERHUB_API_BASE}${path}`, {
|
|
86
|
+
method: "GET",
|
|
87
|
+
headers,
|
|
88
|
+
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
|
|
89
|
+
});
|
|
90
|
+
if (response.status === 429) {
|
|
91
|
+
// Honor `Retry-After` rather than retrying immediately, but only once:
|
|
92
|
+
// the bounded loop is the guard against a retry storm.
|
|
93
|
+
const retryAfter = Number(response.headers.get("retry-after"));
|
|
94
|
+
if (attempt < MAX_ATTEMPTS && Number.isFinite(retryAfter) && retryAfter > 0) {
|
|
95
|
+
await new Promise((resolve) => setTimeout(resolve, Math.min(retryAfter * 1000, 5_000)));
|
|
96
|
+
continue;
|
|
97
|
+
}
|
|
98
|
+
return undefined;
|
|
99
|
+
}
|
|
100
|
+
if (!response.ok) return undefined;
|
|
101
|
+
return await response.json();
|
|
102
|
+
} catch {
|
|
103
|
+
// Timeout, network error, or a body that is not JSON: treat as no data.
|
|
104
|
+
return undefined;
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
return undefined;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** `GET /api/market` — public. Money is numeric. */
|
|
111
|
+
export function fetchMarket(options: InferhubClientOptions = {}): Promise<unknown> {
|
|
112
|
+
return get("/market", options);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** `GET /api/status` — public. Availability and per-model health. */
|
|
116
|
+
export function fetchStatus(options: InferhubClientOptions = {}): Promise<unknown> {
|
|
117
|
+
return get("/status", options);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/** `GET /api/catalog` — needs `config_read`. Money is a decimal string. */
|
|
121
|
+
export function fetchCatalog(options: InferhubClientOptions = {}): Promise<unknown> {
|
|
122
|
+
return get("/catalog", options);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/** `GET /api/pricing/config` — public platform fee configuration. */
|
|
126
|
+
export function fetchPricingConfig(options: InferhubClientOptions = {}): Promise<unknown> {
|
|
127
|
+
return get("/pricing/config", options);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
export function objectArray(payload: unknown, key: string): readonly Record<string, unknown>[] {
|
|
131
|
+
const value =
|
|
132
|
+
payload !== null && typeof payload === "object" && !Array.isArray(payload)
|
|
133
|
+
? (payload as Record<string, unknown>)[key]
|
|
134
|
+
: payload;
|
|
135
|
+
if (!Array.isArray(value)) return [];
|
|
136
|
+
return value.filter(
|
|
137
|
+
(item): item is Record<string, unknown> => item !== null && typeof item === "object",
|
|
138
|
+
);
|
|
139
|
+
}
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The InferHub `config_read` key, stored on its own row.
|
|
3
|
+
*
|
|
4
|
+
* Deliberately **not** in `provider_accounts.auth_state`: `auth_state` is
|
|
5
|
+
* documented as the per-account *non-secret* upstream config field and travels
|
|
6
|
+
* with the account through backup/restore, so a credential there is copied
|
|
7
|
+
* around and readable by anything that reads accounts. This key is a secret, so
|
|
8
|
+
* it gets the same treatment as any other credential — encrypted at rest with
|
|
9
|
+
* `encryptCredential`, in its own table, reachable only through the InferHub
|
|
10
|
+
* routes.
|
|
11
|
+
*
|
|
12
|
+
* It is also never the inference key. InferHub issues two kinds and a
|
|
13
|
+
* `config_read` key can read no tenant inference data and move no money; an
|
|
14
|
+
* inference key here would be able to do both. The write path rejects a value
|
|
15
|
+
* already stored as an account credential so the two cannot be conflated.
|
|
16
|
+
*/
|
|
17
|
+
import { and, eq } from "drizzle-orm";
|
|
18
|
+
import { inferhubConfigKeys, providerAccounts } from "../../persistence/schema";
|
|
19
|
+
import { decryptCredentialToString, encryptCredential, hashSecret } from "../../security/crypto";
|
|
20
|
+
import type { CartethyiaDatabase } from "../../persistence/db-handle";
|
|
21
|
+
|
|
22
|
+
/** Shape returned to the console: whether a key is set, and never the key. */
|
|
23
|
+
export interface InferhubKeyStatus {
|
|
24
|
+
readonly configured: boolean;
|
|
25
|
+
/** Last four characters, so an operator can tell two keys apart. */
|
|
26
|
+
readonly last4: string | null;
|
|
27
|
+
readonly updatedAt: string | null;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Reads the stored key. Returns undefined when none is configured.
|
|
32
|
+
*
|
|
33
|
+
* A missing key is not an error — the routes report `no_config_key` and the
|
|
34
|
+
* operator is told to add one, rather than an optional feature failing closed.
|
|
35
|
+
*/
|
|
36
|
+
export async function readConfigKey(db: CartethyiaDatabase): Promise<string | undefined> {
|
|
37
|
+
try {
|
|
38
|
+
const rows = await db
|
|
39
|
+
.select({ ciphertext: inferhubConfigKeys.ciphertext })
|
|
40
|
+
.from(inferhubConfigKeys)
|
|
41
|
+
.where(eq(inferhubConfigKeys.id, SINGLETON_ID))
|
|
42
|
+
.limit(1);
|
|
43
|
+
const stored = rows[0]?.ciphertext;
|
|
44
|
+
if (stored === undefined || stored === null) return undefined;
|
|
45
|
+
const plaintext = decryptCredentialToString(stored).trim();
|
|
46
|
+
return plaintext.length > 0 ? plaintext : undefined;
|
|
47
|
+
} catch {
|
|
48
|
+
// A decrypt failure (rotated encryption key, corrupt row) must not look
|
|
49
|
+
// like "no key configured" in a way that silently hides data loss, but it
|
|
50
|
+
// must not throw into a page render either. Returning undefined reports
|
|
51
|
+
// `no_config_key`, which tells the operator to re-enter it.
|
|
52
|
+
return undefined;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** Writes the key, replacing any previous value. */
|
|
57
|
+
export async function writeConfigKey(db: CartethyiaDatabase, value: string): Promise<void> {
|
|
58
|
+
const trimmed = value.trim();
|
|
59
|
+
const now = new Date();
|
|
60
|
+
await db
|
|
61
|
+
.insert(inferhubConfigKeys)
|
|
62
|
+
.values({
|
|
63
|
+
id: SINGLETON_ID,
|
|
64
|
+
ciphertext: encryptCredential(trimmed),
|
|
65
|
+
fingerprint: hashSecret(trimmed),
|
|
66
|
+
updatedAt: now,
|
|
67
|
+
})
|
|
68
|
+
.onConflictDoUpdate({
|
|
69
|
+
target: inferhubConfigKeys.id,
|
|
70
|
+
set: { ciphertext: encryptCredential(trimmed), fingerprint: hashSecret(trimmed), updatedAt: now },
|
|
71
|
+
});
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** Removes the key entirely. */
|
|
75
|
+
export async function clearConfigKey(db: CartethyiaDatabase): Promise<void> {
|
|
76
|
+
await db.delete(inferhubConfigKeys).where(eq(inferhubConfigKeys.id, SINGLETON_ID));
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** Whether a key is set, without decrypting it into a response. */
|
|
80
|
+
export async function keyStatus(db: CartethyiaDatabase): Promise<InferhubKeyStatus> {
|
|
81
|
+
const rows = await db
|
|
82
|
+
.select({ ciphertext: inferhubConfigKeys.ciphertext, updatedAt: inferhubConfigKeys.updatedAt })
|
|
83
|
+
.from(inferhubConfigKeys)
|
|
84
|
+
.where(eq(inferhubConfigKeys.id, SINGLETON_ID))
|
|
85
|
+
.limit(1);
|
|
86
|
+
const row = rows[0];
|
|
87
|
+
if (row === undefined) return { configured: false, last4: null, updatedAt: null };
|
|
88
|
+
let last4: string | null = null;
|
|
89
|
+
try {
|
|
90
|
+
const plaintext = decryptCredentialToString(row.ciphertext).trim();
|
|
91
|
+
last4 = plaintext.length >= 4 ? plaintext.slice(-4) : null;
|
|
92
|
+
} catch {
|
|
93
|
+
last4 = null;
|
|
94
|
+
}
|
|
95
|
+
return {
|
|
96
|
+
configured: true,
|
|
97
|
+
last4,
|
|
98
|
+
updatedAt: row.updatedAt?.toISOString() ?? null,
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* One row for the whole deployment: the `config_read` key is a single operator
|
|
104
|
+
* credential, not a per-account one.
|
|
105
|
+
*/
|
|
106
|
+
const SINGLETON_ID = "default";
|
|
107
|
+
|
|
108
|
+
/** Re-exported so routes can assert a key is not also an account credential. */
|
|
109
|
+
export async function isAlsoAccountCredential(
|
|
110
|
+
db: CartethyiaDatabase,
|
|
111
|
+
providerId: string,
|
|
112
|
+
): Promise<boolean> {
|
|
113
|
+
const rows = await db
|
|
114
|
+
.select({ id: providerAccounts.id })
|
|
115
|
+
.from(providerAccounts)
|
|
116
|
+
.where(and(eq(providerAccounts.providerId, providerId)))
|
|
117
|
+
.limit(1);
|
|
118
|
+
return rows.length > 0;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/** Builds the key store the InferHub routes take. */
|
|
122
|
+
export function createInferhubKeyStore(db: CartethyiaDatabase) {
|
|
123
|
+
return {
|
|
124
|
+
read: () => readConfigKey(db),
|
|
125
|
+
write: (value: string) => writeConfigKey(db, value),
|
|
126
|
+
clear: () => clearConfigKey(db),
|
|
127
|
+
status: () => keyStatus(db),
|
|
128
|
+
};
|
|
129
|
+
}
|