cartethyia 2.5.3 → 2.5.6
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 +13 -4
- package/README.md +2 -2
- package/dist/dashboard/assets/{BannedPage-hfCzi-Ux.js → BannedPage-CADDq4IU.js} +1 -1
- package/dist/dashboard/assets/BannedPage-CADDq4IU.js.gz +0 -0
- package/dist/dashboard/assets/CliToolDetailPage-_z1hID7E.js +1 -0
- package/dist/dashboard/assets/CliToolDetailPage-_z1hID7E.js.gz +0 -0
- package/dist/dashboard/assets/{CliToolsPage-DEDyKBlC.js → CliToolsPage-DuO1Z7dq.js} +1 -1
- package/dist/dashboard/assets/CliToolsPage-DuO1Z7dq.js.gz +0 -0
- package/dist/dashboard/assets/CombosPage-DNEPi3xU.js +1 -0
- package/dist/dashboard/assets/CombosPage-DNEPi3xU.js.gz +0 -0
- package/dist/dashboard/assets/{ConsoleLogPage-bsLuZmYH.js → ConsoleLogPage-BVdpfiz1.js} +1 -1
- package/dist/dashboard/assets/ConsoleLogPage-BVdpfiz1.js.gz +0 -0
- package/dist/dashboard/assets/{CustomizationPage-_UWhwziJ.js → CustomizationPage-BR5HHvbi.js} +1 -1
- package/dist/dashboard/assets/CustomizationPage-BR5HHvbi.js.gz +0 -0
- package/dist/dashboard/assets/HealthEventsModal-mUfcknqm.js +1 -0
- package/dist/dashboard/assets/HealthEventsModal-mUfcknqm.js.gz +0 -0
- package/dist/dashboard/assets/{LoginPage-DDVIfZGW.js → LoginPage-V5faV9V2.js} +1 -1
- package/dist/dashboard/assets/LoginPage-V5faV9V2.js.gz +0 -0
- package/dist/dashboard/assets/ModelPicker-C7pDb41R.js +1 -0
- package/dist/dashboard/assets/ModelPicker-C7pDb41R.js.gz +0 -0
- package/dist/dashboard/assets/OverviewPage-BDu20Jdx.js +1 -0
- package/dist/dashboard/assets/OverviewPage-BDu20Jdx.js.gz +0 -0
- package/dist/dashboard/assets/ProviderDetailPage-Dllr_aFO.js +3 -0
- package/dist/dashboard/assets/ProviderDetailPage-Dllr_aFO.js.gz +0 -0
- package/dist/dashboard/assets/ProvidersPage-uPiGVTTK.js +1 -0
- package/dist/dashboard/assets/ProvidersPage-uPiGVTTK.js.gz +0 -0
- package/dist/dashboard/assets/ProxyPage-BaUkO-4O.js +7 -0
- package/dist/dashboard/assets/ProxyPage-BaUkO-4O.js.gz +0 -0
- package/dist/dashboard/assets/QuotaPage-o0IFAsFb.js +1 -0
- package/dist/dashboard/assets/QuotaPage-o0IFAsFb.js.gz +0 -0
- package/dist/dashboard/assets/SettingsPage-CYWqbXjp.js +1 -0
- package/dist/dashboard/assets/SettingsPage-CYWqbXjp.js.gz +0 -0
- package/dist/dashboard/assets/{SetupPage-D6pmQ86L.js → SetupPage-UB690SE2.js} +1 -1
- package/dist/dashboard/assets/SetupPage-UB690SE2.js.gz +0 -0
- package/dist/dashboard/assets/{SortableList-DWvKiuSl.js → SortableList-BLujdX7N.js} +1 -1
- package/dist/dashboard/assets/SortableList-BLujdX7N.js.gz +0 -0
- package/dist/dashboard/assets/{StudioPage-CmkUrgdj.js → StudioPage-D5C2raAc.js} +3 -3
- package/dist/dashboard/assets/StudioPage-D5C2raAc.js.gz +0 -0
- package/dist/dashboard/assets/UsagePage-8uIcxa1W.js +56 -0
- package/dist/dashboard/assets/UsagePage-8uIcxa1W.js.gz +0 -0
- package/dist/dashboard/assets/api-keys-DQYLQxpW.js +1 -0
- package/dist/dashboard/assets/api-keys-DQYLQxpW.js.gz +0 -0
- package/dist/dashboard/assets/app-CX_Z8cMs.css +2 -0
- package/dist/dashboard/assets/app-CX_Z8cMs.css.gz +0 -0
- package/dist/dashboard/assets/{assert-NHwYq95t.js → assert-C9FD_JwJ.js} +1 -1
- package/dist/dashboard/assets/assert-C9FD_JwJ.js.gz +0 -0
- package/dist/dashboard/assets/{badge-6uzDDvqs.js → badge-CR72dasT.js} +1 -1
- package/dist/dashboard/assets/badge-CR72dasT.js.gz +0 -0
- package/dist/dashboard/assets/{banner-4_WNg2WZ.js → banner-CI9_eSfO.js} +1 -1
- package/dist/dashboard/assets/banner-CI9_eSfO.js.gz +0 -0
- package/dist/dashboard/assets/{card--AtFedAq.js → card-DS7CQEsO.js} +1 -1
- package/dist/dashboard/assets/card-DS7CQEsO.js.gz +0 -0
- package/dist/dashboard/assets/{cli-tools-B3e7dx3N.js → cli-tools-CNqzrfdb.js} +1 -1
- package/dist/dashboard/assets/cli-tools-CNqzrfdb.js.gz +0 -0
- package/dist/dashboard/assets/{clipboard-button-NreUx6c8.js → clipboard-button-611pSsaX.js} +1 -1
- package/dist/dashboard/assets/clipboard-button-611pSsaX.js.gz +0 -0
- package/dist/dashboard/assets/{fingerprint-pattern-BWuHtbW4.js → concurrency-BDF0ok7y.js} +1 -1
- package/dist/dashboard/assets/concurrency-BDF0ok7y.js.gz +0 -0
- package/dist/dashboard/assets/database-Dz9N3zGd.js +1 -0
- package/dist/dashboard/assets/database-Dz9N3zGd.js.gz +0 -0
- package/dist/dashboard/assets/dialog-k37exjNt.js +1 -0
- package/dist/dashboard/assets/dialog-k37exjNt.js.gz +0 -0
- package/dist/dashboard/assets/drawer-CX7N8ucR.js +1 -0
- package/dist/dashboard/assets/drawer-CX7N8ucR.js.gz +0 -0
- package/dist/dashboard/assets/entry-B9Xvtnfj.js +1 -0
- package/dist/dashboard/assets/entry-B9Xvtnfj.js.gz +0 -0
- package/dist/dashboard/assets/entry-CTP8AyiS.js +1 -0
- package/dist/dashboard/assets/entry-CTP8AyiS.js.gz +0 -0
- package/dist/dashboard/assets/entry-DxBHnauy.js +4 -0
- package/dist/dashboard/assets/entry-DxBHnauy.js.gz +0 -0
- package/dist/dashboard/assets/{github-badge-D_eM3IgG.js → github-badge-sltk1qUi.js} +1 -1
- package/dist/dashboard/assets/github-badge-sltk1qUi.js.gz +0 -0
- package/dist/dashboard/assets/{index-BydyKaDz.js → index-CmxZr14F.js} +2 -2
- package/dist/dashboard/assets/index-CmxZr14F.js.gz +0 -0
- package/dist/dashboard/assets/{inline-BcNGF8Gr.js → inline-BhY2sSnO.js} +1 -1
- package/dist/dashboard/assets/inline-BhY2sSnO.js.gz +0 -0
- package/dist/dashboard/assets/{input-BG41et9G.js → input-V1umFiBk.js} +1 -1
- package/dist/dashboard/assets/input-V1umFiBk.js.gz +0 -0
- package/dist/dashboard/assets/layout-BBOYycyS.js +1 -0
- package/dist/dashboard/assets/layout-BBOYycyS.js.gz +0 -0
- package/dist/dashboard/assets/{list-row-Gh_W4OR_.js → list-row-1zzZYM0B.js} +1 -1
- package/dist/dashboard/assets/list-row-1zzZYM0B.js.gz +0 -0
- package/dist/dashboard/assets/{live-BJGYRBnR.js → live-BivGfuUs.js} +1 -1
- package/dist/dashboard/assets/live-BivGfuUs.js.gz +0 -0
- package/dist/dashboard/assets/model-bans-CmffjLuG.js +1 -0
- package/dist/dashboard/assets/model-bans-CmffjLuG.js.gz +0 -0
- package/dist/dashboard/assets/{mutation-BkoLM5Ba.js → mutation-46P0oWMe.js} +1 -1
- package/dist/dashboard/assets/mutation-46P0oWMe.js.gz +0 -0
- package/dist/dashboard/assets/numbers-DkfMR5Qs.js +2 -0
- package/dist/dashboard/assets/numbers-DkfMR5Qs.js.gz +0 -0
- package/dist/dashboard/assets/{query-keys-D7wX2vM5.js → query-keys-BFhGpwR1.js} +1 -1
- package/dist/dashboard/assets/query-keys-BFhGpwR1.js.gz +0 -0
- package/dist/dashboard/assets/{routing-CcusqXA7.js → routing-CAb4F37R.js} +1 -1
- package/dist/dashboard/assets/routing-CAb4F37R.js.gz +0 -0
- package/dist/dashboard/assets/{select-BO6h8yNv.js → select-BYR_5K2c.js} +2 -2
- package/dist/dashboard/assets/select-BYR_5K2c.js.gz +0 -0
- package/dist/dashboard/assets/{settings-mpmXTgUa.js → settings-DNX9sydi.js} +1 -1
- package/dist/dashboard/assets/settings-DNX9sydi.js.gz +0 -0
- package/dist/dashboard/assets/{stack-DwLmjUFK.js → stack-BHTfXdz_.js} +1 -1
- package/dist/dashboard/assets/stack-BHTfXdz_.js.gz +0 -0
- package/dist/dashboard/assets/{state-DYdQmdAl.js → state-mPDRlQkl.js} +1 -1
- package/dist/dashboard/assets/state-mPDRlQkl.js.gz +0 -0
- package/dist/dashboard/assets/switch-DOmJxg7H.js +1 -0
- package/dist/dashboard/assets/switch-DOmJxg7H.js.gz +0 -0
- package/dist/dashboard/assets/terminal-0_IjPWTk.js +1 -0
- package/dist/dashboard/assets/terminal-0_IjPWTk.js.gz +0 -0
- package/dist/dashboard/assets/tokens-COzario9.js +1 -0
- package/dist/dashboard/assets/tokens-COzario9.js.gz +0 -0
- package/dist/dashboard/assets/use-modal-focus-By7oJoGd.js +1 -0
- package/dist/dashboard/assets/use-modal-focus-By7oJoGd.js.gz +0 -0
- package/dist/dashboard/assets/{useMutation-w4u4CVue.js → useMutation-Dem0lXQj.js} +1 -1
- package/dist/dashboard/assets/useMutation-Dem0lXQj.js.gz +0 -0
- package/dist/dashboard/assets/zap-C4EeVW4q.js +1 -0
- package/dist/dashboard/assets/zap-C4EeVW4q.js.gz +0 -0
- package/dist/dashboard/donation/qris-risun-marke.png +0 -0
- package/dist/dashboard/index.html +1 -1
- package/dist/dashboard/index.html.gz +0 -0
- package/dist/dashboard/precompressed-assets.json +57 -57
- package/migrations/README.md +52 -52
- package/migrations/lite-sqlite3/0000_baseline.sql +2 -2
- package/package.json +96 -95
- package/scripts/cli/admin.ts +125 -0
- package/scripts/cli/autostart.ts +237 -0
- package/scripts/cli/{ui/background.ts → background.ts} +1 -1
- package/scripts/cli/{command/console-menu.ts → console-menu.ts} +275 -248
- package/scripts/cli/doctor.ts +37 -0
- package/scripts/cli/{support/env.ts → env.ts} +99 -99
- package/scripts/cli/{gateway/spawn.ts → gateway.ts} +112 -24
- package/scripts/cli/instance.ts +224 -0
- package/scripts/cli/main.ts +88 -18
- package/scripts/cli/{support/platform.ts → platform.ts} +64 -64
- package/scripts/cli/port.ts +100 -0
- package/scripts/cli/{support/services.ts → services.ts} +88 -88
- package/scripts/cli/{command/setup.ts → setup.ts} +35 -34
- package/scripts/cli/{command/start.ts → start.ts} +10 -5
- package/scripts/cli/{support/startup-signal.ts → startup-signal.ts} +23 -23
- package/scripts/cli/tray/index.ts +62 -12
- package/scripts/cli/{command/tray.ts → tray.ts} +208 -208
- package/scripts/cli/update.ts +40 -0
- package/scripts/cli/version.ts +67 -0
- package/scripts/cli/{ui/wordmark.ts → wordmark.ts} +58 -58
- package/src/boot/app.ts +11 -7
- package/src/boot/dependencies.ts +50 -19
- package/src/boot/timeout.ts +2 -13
- package/src/config/cooldowns.ts +186 -138
- package/src/config/network.ts +8 -14
- package/src/config/providers.ts +7 -10
- package/src/config/runtime.ts +76 -76
- package/src/config/security.ts +15 -0
- package/src/config/spec.ts +52 -7
- package/src/config/telemetry.ts +26 -0
- package/src/config/timeouts.ts +0 -6
- package/src/console/admin/model-abuse/contracts.ts +28 -6
- package/src/console/admin/model-abuse/routes.ts +56 -3
- package/src/console/api-keys/bucket-spend.ts +15 -4
- package/src/console/api-keys/contracts.ts +410 -414
- package/src/console/api-keys/routes.ts +881 -880
- package/src/console/auth/credentials.ts +3 -3
- package/src/console/auth/setup-service.ts +2 -2
- package/src/console/backup/contracts.ts +15 -2
- package/src/console/backup/export-endpoint.ts +6 -2
- package/src/console/backup/nine-router.ts +2 -5
- package/src/console/backup/service.ts +4 -0
- package/src/console/backup/store.ts +192 -29
- package/src/console/backup/validate.ts +3 -45
- package/src/console/cli-tools/fs-ops.ts +487 -487
- package/src/console/console-router-guards.ts +1 -1
- package/src/console/console-router.ts +8 -0
- package/src/console/data-management/contracts.ts +100 -0
- package/src/console/data-management/operations.ts +234 -0
- package/src/console/data-management/routes.ts +103 -0
- package/src/console/domain-registration.ts +45 -1
- package/src/console/observability/contracts.ts +524 -485
- package/src/console/observability/live.ts +22 -0
- package/src/console/observability/store.ts +1138 -1018
- package/src/console/observability/usage-dimensions.ts +4 -4
- package/src/console/providers/catalog/catalog-projections.ts +6 -7
- package/src/console/providers/catalog/contracts.ts +6 -0
- package/src/console/providers/catalog/model-operations.ts +5 -2
- package/src/console/providers/catalog/provider-operations.ts +24 -4
- package/src/console/providers/catalog/public-model-store.ts +8 -11
- package/src/console/providers/catalog/routes.ts +7 -0
- package/src/console/providers/catalog/store.ts +1553 -1532
- package/src/console/providers/oauth/routes.ts +8 -12
- package/src/console/quota/account-quota-shared.ts +52 -39
- package/src/console/quota/account-quota-tenant.ts +6 -6
- package/src/console/quota/quota-view.ts +1 -1
- package/src/console/quota/refresh.ts +441 -429
- package/src/console/remote-support/contracts.ts +61 -0
- package/src/console/remote-support/routes.ts +65 -0
- package/src/console/remote-support/service.ts +207 -0
- package/src/console/response-compression.ts +0 -3
- package/src/console/routing/pools/import-parse.ts +206 -0
- package/src/console/routing/pools/operations.ts +543 -501
- package/src/console/routing/pools/probe-result.ts +3 -2
- package/src/console/routing/pools/probe-verdict.ts +132 -132
- package/src/console/routing/pools/routes.ts +181 -164
- package/src/console/routing/pools/store.ts +8 -8
- package/src/console/settings/contracts.ts +95 -2
- package/src/console/settings/store.ts +81 -3
- package/src/console/shared/cursor.ts +8 -8
- package/src/console/shared/errors.ts +1 -3
- package/src/console/updates/routes.ts +16 -0
- package/src/main.ts +10 -0
- package/src/network/http2-bidi.ts +2 -4
- package/src/network/pool/agent.ts +478 -478
- package/src/network/pool/selector.ts +5 -4
- package/src/network/retry.ts +1 -2
- package/src/network/ssrf.ts +5 -4
- package/src/observability/event-compaction.ts +317 -0
- package/src/observability/event-day-index.ts +440 -0
- package/src/observability/event-log.ts +155 -6
- package/src/observability/logger.ts +6 -6
- package/src/observability/payload-capture.ts +158 -33
- package/src/observability/payload-store.ts +82 -61
- package/src/observability/redaction.ts +5 -6
- package/src/observability/request-notify.ts +43 -0
- package/src/observability/telemetry-status.ts +8 -9
- package/src/observability/token-speed.ts +5 -4
- package/src/persistence/db-mode.ts +7 -7
- package/src/persistence/health-event-flush.ts +36 -0
- package/src/persistence/readiness.ts +1 -1
- package/src/persistence/schema.ts +75 -29
- package/src/persistence/shared-cache.ts +1 -2
- package/src/persistence/stores/api-key-store.ts +18 -6
- package/src/persistence/stores/share-store.ts +4 -3
- package/src/persistence/stores/telemetry-store.ts +33 -11
- package/src/protocol/canonical-model.ts +11 -3
- package/src/protocol/codecs.ts +139 -6
- package/src/protocol/gateway-error.ts +6 -4
- package/src/protocol/image-clamp.ts +19 -1
- package/src/protocol/registry.ts +8 -0
- package/src/protocol/request/chat.ts +21 -1
- package/src/protocol/request/codex.ts +10 -1
- package/src/protocol/request/gemini.ts +9 -2
- package/src/protocol/request/messages.ts +18 -7
- package/src/protocol/request/responses.ts +9 -1
- package/src/protocol/response/chat.ts +34 -4
- package/src/protocol/response/codex.ts +7 -4
- package/src/protocol/response/dsml.ts +140 -28
- package/src/protocol/response/responses.ts +11 -5
- package/src/protocol/stream-error-frames.ts +4 -5
- package/src/providers/adapters/compatible-adapter.ts +3 -0
- package/src/providers/authentication/jwt-validator.ts +9 -10
- package/src/providers/authentication/oauth-refresh-service.ts +9 -11
- package/src/providers/bundled-discovery-providers.ts +122 -119
- package/src/providers/configured-openai-providers.ts +4 -4
- package/src/providers/default-registry.ts +5 -4
- package/src/providers/discovery/catalog/catalog-blob.ts +18 -18
- package/src/providers/discovery/catalog/catalog-format.ts +3 -6
- package/src/providers/discovery/models-dev-catalog.ts +354 -354
- package/src/providers/discovery/openai-model-discovery.ts +5 -0
- package/src/providers/discovery/openrouter-free-roster.ts +15 -17
- package/src/providers/discovery/probe-phases.ts +4 -4
- package/src/providers/discovery/probe-wire.ts +6 -13
- package/src/providers/discovery/probing-service.ts +1117 -1045
- package/src/providers/integrations/alibabacp/alibabacp.ts +1 -1
- package/src/providers/integrations/antigravity/antigravity-quota.ts +5 -7
- package/src/providers/integrations/buddy/buddy-chat-shared.ts +23 -2
- package/src/providers/integrations/buddy/buddy-checkin.ts +1 -4
- package/src/providers/integrations/buddy/buddy-cli-headers.ts +23 -6
- package/src/providers/integrations/buddy/buddy-conversation-headers.ts +0 -1
- package/src/providers/integrations/buddy/buddy-identity.ts +52 -0
- package/src/providers/integrations/buddy/buddy-media.ts +2 -2
- package/src/providers/integrations/buddy/buddy-usage-store.ts +3 -4
- package/src/providers/integrations/buddy/codebuddy/quota.ts +2 -13
- package/src/providers/integrations/buddy/codebuddy/shared.ts +118 -115
- package/src/providers/integrations/buddy/workbuddy/quota.ts +0 -11
- package/src/providers/integrations/buddy/workbuddy/shared.ts +5 -2
- package/src/providers/integrations/claude/claude.ts +608 -608
- package/src/providers/integrations/cline/cline-quota.ts +27 -20
- package/src/providers/integrations/cline/cline.ts +537 -539
- package/src/providers/integrations/codex/codex-headers.ts +25 -39
- package/src/providers/integrations/codex/codex-oauth.ts +1 -1
- package/src/providers/integrations/codex/codex.ts +2 -29
- package/src/providers/integrations/commandcode/commandcode.ts +674 -675
- package/src/providers/integrations/commandcode/discovery.ts +117 -116
- package/src/providers/integrations/cursor/cursor-auth.ts +418 -418
- package/src/providers/integrations/cursor/cursor-quota.ts +309 -0
- package/src/providers/integrations/cursor/cursor-request.ts +616 -471
- package/src/providers/integrations/cursor/cursor-session-manager.ts +319 -281
- package/src/providers/integrations/cursor/cursor.ts +1333 -960
- package/src/providers/integrations/cursor/protocol/messages.ts +25 -0
- package/src/providers/integrations/factory/factory-models.ts +3 -5
- package/src/providers/integrations/factory/factory.ts +8 -1
- package/src/providers/integrations/gemini.ts +6 -1
- package/src/providers/integrations/grok/grok-install-id.ts +2 -3
- package/src/providers/integrations/grok/grok-quota.ts +279 -177
- package/src/providers/integrations/grok/grok-ratelimit-store.ts +73 -0
- package/src/providers/integrations/grok/grok-ratelimit.ts +218 -0
- package/src/providers/integrations/grok/grok.ts +6 -7
- package/src/providers/integrations/hermes.ts +14 -18
- package/src/providers/integrations/kiro/kiro-discovery.ts +1 -1
- package/src/providers/integrations/kiro/kiro.ts +1 -1
- package/src/providers/integrations/mistral.ts +6 -6
- package/src/providers/integrations/opencode/fingerprint.ts +3 -3
- package/src/providers/integrations/opencode/provider.ts +19 -20
- package/src/providers/integrations/qoder/qoder-catalog.ts +450 -450
- package/src/providers/integrations/qoder/qoder-oauth.ts +388 -310
- package/src/providers/integrations/qoder/qoder-quota.ts +192 -0
- package/src/providers/integrations/qoder/qoder.ts +884 -865
- package/src/providers/integrations/shared/connect.ts +6 -6
- package/src/providers/integrations/trae/trae-catalog.ts +1 -2
- package/src/providers/integrations/trae/trae-quota.ts +384 -0
- package/src/providers/integrations/trae/trae.ts +2 -2
- package/src/providers/integrations/zcode/oauth.ts +6 -1
- package/src/providers/operations/account-health-service.ts +75 -27
- package/src/providers/operations/account-reset-service.ts +1 -1
- package/src/providers/operations/client-versions.ts +6 -6
- package/src/providers/operations/custom-cli-headers.ts +3 -4
- package/src/providers/operations/provider-catalog-seeder.ts +95 -95
- package/src/providers/operations/provider-catalog-service.ts +3 -4
- package/src/providers/provider-contracts.ts +7 -0
- package/src/providers/provider-metadata.ts +10 -8
- package/src/providers/provider-registry.ts +6 -7
- package/src/providers/provider-usage.ts +1 -1
- package/src/providers/quota/quota-support.ts +8 -0
- package/src/security/abuse.ts +15 -10
- package/src/security/access-control.ts +144 -146
- package/src/security/admission/contracts.ts +11 -0
- package/src/security/admission/in-memory-store.ts +4 -3
- package/src/security/admission/lease-sweep.ts +6 -6
- package/src/security/admission/reasons.ts +5 -1
- package/src/security/admission/redis-store.ts +211 -210
- package/src/security/admission/service.ts +4 -0
- package/src/security/api-key-auth.ts +27 -17
- package/src/security/ip-boundary.ts +155 -15
- package/src/security/model-abuse.ts +366 -94
- package/src/security/outbound-headers.ts +3 -4
- package/src/shared/sleep.ts +4 -0
- package/src/transport/dispatch/attempt-finalize.ts +47 -5
- package/src/transport/dispatch/attempt-loop.ts +531 -530
- package/src/transport/dispatch/generation.ts +6 -0
- package/src/transport/dispatch/proxy-request.ts +24 -1
- package/src/transport/dispatch/ratelimit-capture.ts +37 -0
- package/src/transport/dispatch/stream-bridge.ts +1 -3
- package/src/transport/dispatch/streaming/finalize.ts +1 -1
- package/src/transport/dispatch/streaming/stream.ts +19 -6
- package/src/transport/dispatch/upstream.ts +9 -0
- package/src/transport/failure-policy.ts +162 -8
- package/src/transport/guards/auth-guard.ts +7 -4
- package/src/transport/guards/concurrency-guard.ts +0 -5
- package/src/transport/guards/error-lifecycle.ts +13 -6
- package/src/transport/guards/pipeline.ts +15 -3
- package/src/transport/guards/request-context.ts +8 -2
- package/src/transport/request/inflight.ts +5 -6
- package/src/transport/request/preparer.ts +9 -8
- package/src/transport/request/state.ts +21 -8
- package/src/transport/request/tenant-preferences.ts +60 -0
- package/src/transport/routing/admission.ts +4 -0
- package/src/transport/routing/route-catalog.ts +65 -10
- package/src/transport/routing/route-model.ts +46 -14
- package/src/transport/routing/router.ts +21 -14
- package/src/transport/surface/completion.ts +2 -3
- package/src/transport/surface/dialects.ts +5 -6
- package/src/transport/surface/responses/encode.ts +38 -0
- package/src/transport/surface/responses/parse.ts +4 -0
- package/src/transport/translation/capabilities.ts +2 -3
- package/src/transport/translation/quirks.ts +3 -3
- package/src/workers/buddy-growth-tasks.ts +5 -10
- package/src/workers/daily-checkin.ts +17 -23
- package/src/workers/quota-refresh-sweep.ts +26 -26
- package/dist/dashboard/assets/BannedPage-hfCzi-Ux.js.gz +0 -0
- package/dist/dashboard/assets/CliToolDetailPage-DLGqYEwI.js +0 -1
- package/dist/dashboard/assets/CliToolDetailPage-DLGqYEwI.js.gz +0 -0
- package/dist/dashboard/assets/CliToolsPage-DEDyKBlC.js.gz +0 -0
- package/dist/dashboard/assets/CombosPage-CfuYiQ8Z.js +0 -1
- package/dist/dashboard/assets/CombosPage-CfuYiQ8Z.js.gz +0 -0
- package/dist/dashboard/assets/ConsoleLogPage-bsLuZmYH.js.gz +0 -0
- package/dist/dashboard/assets/CustomizationPage-_UWhwziJ.js.gz +0 -0
- package/dist/dashboard/assets/HealthEventsModal-Cjfc5WFS.js +0 -1
- package/dist/dashboard/assets/HealthEventsModal-Cjfc5WFS.js.gz +0 -0
- package/dist/dashboard/assets/LoginPage-DDVIfZGW.js.gz +0 -0
- package/dist/dashboard/assets/ModelPicker-CWkTUtpX.js +0 -1
- package/dist/dashboard/assets/ModelPicker-CWkTUtpX.js.gz +0 -0
- package/dist/dashboard/assets/OverviewPage-B53YYMng.js +0 -1
- package/dist/dashboard/assets/OverviewPage-B53YYMng.js.gz +0 -0
- package/dist/dashboard/assets/ProviderDetailPage-B0KaAQf0.js +0 -3
- package/dist/dashboard/assets/ProviderDetailPage-B0KaAQf0.js.gz +0 -0
- package/dist/dashboard/assets/ProvidersPage-Dx31DgIO.js +0 -1
- package/dist/dashboard/assets/ProvidersPage-Dx31DgIO.js.gz +0 -0
- package/dist/dashboard/assets/ProxyPage-BpnR4kXT.js +0 -7
- package/dist/dashboard/assets/ProxyPage-BpnR4kXT.js.gz +0 -0
- package/dist/dashboard/assets/QuotaPage-BpOQCnun.js +0 -1
- package/dist/dashboard/assets/QuotaPage-BpOQCnun.js.gz +0 -0
- package/dist/dashboard/assets/SettingsPage-DV1eo2J_.js +0 -1
- package/dist/dashboard/assets/SettingsPage-DV1eo2J_.js.gz +0 -0
- package/dist/dashboard/assets/SetupPage-D6pmQ86L.js.gz +0 -0
- package/dist/dashboard/assets/SortableList-DWvKiuSl.js.gz +0 -0
- package/dist/dashboard/assets/StudioPage-CmkUrgdj.js.gz +0 -0
- package/dist/dashboard/assets/UsagePage-B47uOydr.js +0 -56
- package/dist/dashboard/assets/UsagePage-B47uOydr.js.gz +0 -0
- package/dist/dashboard/assets/api-keys-uNywYlfv.js +0 -1
- package/dist/dashboard/assets/api-keys-uNywYlfv.js.gz +0 -0
- package/dist/dashboard/assets/app-DxivpfYG.css +0 -2
- package/dist/dashboard/assets/app-DxivpfYG.css.gz +0 -0
- package/dist/dashboard/assets/assert-NHwYq95t.js.gz +0 -0
- package/dist/dashboard/assets/badge-6uzDDvqs.js.gz +0 -0
- package/dist/dashboard/assets/banner-4_WNg2WZ.js.gz +0 -0
- package/dist/dashboard/assets/card--AtFedAq.js.gz +0 -0
- package/dist/dashboard/assets/cli-tools-B3e7dx3N.js.gz +0 -0
- package/dist/dashboard/assets/clipboard-button-NreUx6c8.js.gz +0 -0
- package/dist/dashboard/assets/dialog-BtpsJU98.js +0 -1
- package/dist/dashboard/assets/dialog-BtpsJU98.js.gz +0 -0
- package/dist/dashboard/assets/download-A0SW9OCW.js +0 -1
- package/dist/dashboard/assets/download-A0SW9OCW.js.gz +0 -0
- package/dist/dashboard/assets/drawer-WwwtzihF.js +0 -1
- package/dist/dashboard/assets/drawer-WwwtzihF.js.gz +0 -0
- package/dist/dashboard/assets/entry-CkryrIea.js +0 -4
- package/dist/dashboard/assets/entry-CkryrIea.js.gz +0 -0
- package/dist/dashboard/assets/entry-TJIye_jT.js +0 -1
- package/dist/dashboard/assets/entry-TJIye_jT.js.gz +0 -0
- package/dist/dashboard/assets/entry-jy9_4-Ke.js +0 -1
- package/dist/dashboard/assets/entry-jy9_4-Ke.js.gz +0 -0
- package/dist/dashboard/assets/fingerprint-pattern-BWuHtbW4.js.gz +0 -0
- package/dist/dashboard/assets/github-badge-D_eM3IgG.js.gz +0 -0
- package/dist/dashboard/assets/index-BydyKaDz.js.gz +0 -0
- package/dist/dashboard/assets/inline-BcNGF8Gr.js.gz +0 -0
- package/dist/dashboard/assets/input-BG41et9G.js.gz +0 -0
- package/dist/dashboard/assets/layout-C8mer_Ak.js +0 -1
- package/dist/dashboard/assets/layout-C8mer_Ak.js.gz +0 -0
- package/dist/dashboard/assets/list-row-Gh_W4OR_.js.gz +0 -0
- package/dist/dashboard/assets/live-BJGYRBnR.js.gz +0 -0
- package/dist/dashboard/assets/model-bans-DeVLDA7I.js +0 -1
- package/dist/dashboard/assets/model-bans-DeVLDA7I.js.gz +0 -0
- package/dist/dashboard/assets/mutation-BkoLM5Ba.js.gz +0 -0
- package/dist/dashboard/assets/numbers-Bs4LOOuf.js +0 -2
- package/dist/dashboard/assets/numbers-Bs4LOOuf.js.gz +0 -0
- package/dist/dashboard/assets/query-keys-D7wX2vM5.js.gz +0 -0
- package/dist/dashboard/assets/routing-CcusqXA7.js.gz +0 -0
- package/dist/dashboard/assets/select-BO6h8yNv.js.gz +0 -0
- package/dist/dashboard/assets/settings-mpmXTgUa.js.gz +0 -0
- package/dist/dashboard/assets/sparkles-BRaRUxAU.js +0 -1
- package/dist/dashboard/assets/sparkles-BRaRUxAU.js.gz +0 -0
- package/dist/dashboard/assets/stack-DwLmjUFK.js.gz +0 -0
- package/dist/dashboard/assets/state-DYdQmdAl.js.gz +0 -0
- package/dist/dashboard/assets/switch-CidSfbG7.js +0 -1
- package/dist/dashboard/assets/switch-CidSfbG7.js.gz +0 -0
- package/dist/dashboard/assets/terminal-Bd4ee8TA.js +0 -1
- package/dist/dashboard/assets/terminal-Bd4ee8TA.js.gz +0 -0
- package/dist/dashboard/assets/tokens-DDxMbSRL.js +0 -1
- package/dist/dashboard/assets/tokens-DDxMbSRL.js.gz +0 -0
- package/dist/dashboard/assets/use-presence-BSZEBx7U.js +0 -1
- package/dist/dashboard/assets/use-presence-BSZEBx7U.js.gz +0 -0
- package/dist/dashboard/assets/useMutation-w4u4CVue.js.gz +0 -0
- package/scripts/cli/command/admin.ts +0 -65
- package/scripts/cli/command/doctor.ts +0 -257
- package/scripts/cli/command/update.ts +0 -23
- package/scripts/cli/gateway/drain-token.ts +0 -78
- package/scripts/cli/gateway/instance-lock.ts +0 -105
- package/scripts/cli/gateway/stop.ts +0 -51
- package/scripts/cli/support/bun.ts +0 -27
- package/scripts/cli/support/version.ts +0 -37
- /package/dist/dashboard/assets/{app-CafWtVXA.js → app-BIt446yE.js} +0 -0
- /package/dist/dashboard/assets/{app-CafWtVXA.js.gz → app-BIt446yE.js.gz} +0 -0
- /package/scripts/cli/{ui/prompt.ts → prompt.ts} +0 -0
|
@@ -3,20 +3,68 @@ import { DrizzleTelemetryStore } from "../persistence/stores/telemetry-store";
|
|
|
3
3
|
import { prunePayloadFrames, writePayloadFrame } from "./payload-store";
|
|
4
4
|
import { resolveTelemetryPayloadMaxBytes } from "../config/telemetry";
|
|
5
5
|
import { preferencesReaderFor } from "../persistence/tenant-preferences";
|
|
6
|
-
import type {
|
|
6
|
+
import type {
|
|
7
|
+
TelemetryPayloadDepth,
|
|
8
|
+
TelemetryPayloadMode,
|
|
9
|
+
TelemetryPayloadRetentionMinutes,
|
|
10
|
+
} from "../console/settings/contracts";
|
|
7
11
|
export type CaptureScope = "tenant" | "debug_session" | "operator_flag";
|
|
8
12
|
|
|
13
|
+
/**
|
|
14
|
+
* How long a captured payload frame lives when the deployment has not chosen.
|
|
15
|
+
*
|
|
16
|
+
* Exported so surfaces that report on disk usage quote the same window the
|
|
17
|
+
* capture path enforces, rather than a second copy of the default that would
|
|
18
|
+
* drift the first time this one changes.
|
|
19
|
+
*/
|
|
20
|
+
export function payloadRetentionMs(): number {
|
|
21
|
+
const raw = Number(process.env.CARTETHYIA_TELEMETRY_PAYLOAD_RETENTION_MS ?? 15 * 60_000);
|
|
22
|
+
if (!Number.isFinite(raw) || raw < 1_000 || raw > MAX_PAYLOAD_RETENTION_MS) return 15 * 60_000;
|
|
23
|
+
return Math.floor(raw);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* The longest payload retention a tenant may ask for: seven days.
|
|
28
|
+
*
|
|
29
|
+
* Two things depend on this being a hard ceiling rather than a suggestion.
|
|
30
|
+
*
|
|
31
|
+
* The prune pass deletes a frame file it cannot parse once the file's last
|
|
32
|
+
* write is older than the retention window — that bound is the only thing that
|
|
33
|
+
* makes a damaged file reclaimable at all. A tenant allowed to keep frames for
|
|
34
|
+
* a year while the prune assumed an hour would have live frames deleted out
|
|
35
|
+
* from under them.
|
|
36
|
+
*
|
|
37
|
+
* The disk-usage panel counts a frame as reclaimable once its file is older
|
|
38
|
+
* than the same window. Without a ceiling it would offer a reclaim button for
|
|
39
|
+
* frames that are still live, and the operator would press it and see
|
|
40
|
+
* "nothing to reclaim".
|
|
41
|
+
*/
|
|
42
|
+
export const MAX_PAYLOAD_RETENTION_MS = 7 * 24 * 60 * 60_000;
|
|
43
|
+
|
|
44
|
+
/** Milliseconds one tenant's chosen retention resolves to. */
|
|
45
|
+
export function retentionMsFor(minutes: number | null | undefined): number {
|
|
46
|
+
if (minutes === null || minutes === undefined) return payloadRetentionMs();
|
|
47
|
+
if (!Number.isFinite(minutes)) return payloadRetentionMs();
|
|
48
|
+
return Math.min(Math.max(1_000, Math.floor(minutes * 60_000)), MAX_PAYLOAD_RETENTION_MS);
|
|
49
|
+
}
|
|
50
|
+
|
|
9
51
|
/**
|
|
10
52
|
* Settings-gated payload capture. Request-event metadata is always retained;
|
|
11
53
|
* tenants opt into drawer capture through Settings → Privacy: `metadata`
|
|
12
54
|
* keeps only the Proxy→Provider request line, `full` keeps redacted bodies
|
|
13
|
-
* at the tenant's chosen depth
|
|
55
|
+
* at the tenant's chosen depth, and both live for the tenant's chosen
|
|
56
|
+
* retention. Fail-closed on error.
|
|
14
57
|
*/
|
|
15
58
|
export async function resolvePayloadCapture(
|
|
16
59
|
db: CartethyiaDatabase,
|
|
17
60
|
tenantId: string | null,
|
|
18
61
|
): Promise<
|
|
19
|
-
| {
|
|
62
|
+
| {
|
|
63
|
+
readonly ok: true;
|
|
64
|
+
readonly mode: TelemetryPayloadMode;
|
|
65
|
+
readonly depth: TelemetryPayloadDepth;
|
|
66
|
+
readonly retentionMs: number;
|
|
67
|
+
}
|
|
20
68
|
| { readonly ok: false }
|
|
21
69
|
> {
|
|
22
70
|
if (!tenantId) return { ok: false };
|
|
@@ -24,6 +72,7 @@ export async function resolvePayloadCapture(
|
|
|
24
72
|
const prefs = await preferencesReaderFor(db).readPreferences(tenantId);
|
|
25
73
|
const mode = prefs?.telemetryPayloads;
|
|
26
74
|
const depth = prefs?.telemetryPayloadDepth;
|
|
75
|
+
const retention = prefs?.telemetryPayloadRetentionMinutes;
|
|
27
76
|
return {
|
|
28
77
|
ok: true,
|
|
29
78
|
mode: mode === "full" || mode === "metadata" || mode === "none" ? mode : "none",
|
|
@@ -37,12 +86,13 @@ export async function resolvePayloadCapture(
|
|
|
37
86
|
: depth === "full"
|
|
38
87
|
? "maximum"
|
|
39
88
|
: "minimum",
|
|
89
|
+
retentionMs: retentionMsFor(retention as TelemetryPayloadRetentionMinutes | undefined),
|
|
40
90
|
};
|
|
41
91
|
} catch {
|
|
42
92
|
// A preference read that fails must not invent capture. Off keeps nothing.
|
|
43
93
|
// Reported as its own state rather than silently as `none`: an operator
|
|
44
94
|
// whose capture is off by choice and one whose preference read is failing
|
|
45
|
-
// need different fixes, and both
|
|
95
|
+
// need different fixes, and both would otherwise look like "not configured".
|
|
46
96
|
return { ok: false };
|
|
47
97
|
}
|
|
48
98
|
}
|
|
@@ -99,6 +149,12 @@ export interface PayloadCaptureInput {
|
|
|
99
149
|
* Defaults to the configured capture limit.
|
|
100
150
|
*/
|
|
101
151
|
maxBytes?: number;
|
|
152
|
+
/**
|
|
153
|
+
* How long this frame lives, in ms. Defaults to the tenant's chosen
|
|
154
|
+
* retention, then the deployment default. Bounded by
|
|
155
|
+
* {@link MAX_PAYLOAD_RETENTION_MS} either way.
|
|
156
|
+
*/
|
|
157
|
+
retentionMs?: number;
|
|
102
158
|
scope: CaptureScope;
|
|
103
159
|
/** Links the row to `telemetry_events.request_id`. */
|
|
104
160
|
requestId?: string;
|
|
@@ -140,11 +196,13 @@ export interface StoredPayload {
|
|
|
140
196
|
signals: PayloadSignals;
|
|
141
197
|
}
|
|
142
198
|
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
199
|
+
/**
|
|
200
|
+
* How long a captured payload frame lives.
|
|
201
|
+
*
|
|
202
|
+
* Exported so surfaces that report on disk usage quote the same window the
|
|
203
|
+
* capture path enforces, rather than a second copy of the default that would
|
|
204
|
+
* drift the first time this one changes.
|
|
205
|
+
*/
|
|
148
206
|
function isCaptureAllowed(input: PayloadCaptureInput): boolean {
|
|
149
207
|
// Exactly one opt-in must be set, and it must be the one `scope` names:
|
|
150
208
|
// two flags at once would silently widen capture beyond the declared scope.
|
|
@@ -202,28 +260,29 @@ function payloadSignals(values: readonly unknown[]): PayloadSignals {
|
|
|
202
260
|
* `maxBytes` (post-redaction, defaulting to the configured capture limit): a
|
|
203
261
|
* body that no longer fits is replaced with a truncation marker — no object
|
|
204
262
|
* store exists, so an oversized body is never referenced, only accounted.
|
|
205
|
-
* Payloads remain redacted and bounded during
|
|
263
|
+
* Payloads remain redacted and bounded during their retention window.
|
|
206
264
|
*
|
|
207
265
|
* Drawer order is the allocation order, and that is deliberate: the cap is a
|
|
208
266
|
* single shared budget, so whatever is accounted first is what survives a
|
|
209
267
|
* large request. `response_body` is the legacy alias of the client response
|
|
210
|
-
* and names no panel of its own, so it is accounted **last** —
|
|
211
|
-
*
|
|
212
|
-
* budget before the Provider Request and Provider Response panels
|
|
213
|
-
*
|
|
268
|
+
* and names no panel of its own, so it is accounted **last** — accounting it
|
|
269
|
+
* earlier lets a large client request plus its own duplicate consume the whole
|
|
270
|
+
* budget before the Provider Request and Provider Response panels are reached,
|
|
271
|
+
* and those two then read empty for a request that did reach an upstream.
|
|
214
272
|
*/
|
|
215
273
|
export function buildPayloadRecord(
|
|
216
274
|
input: PayloadCaptureInput,
|
|
217
275
|
now = new Date(),
|
|
218
276
|
maxBytes = resolveTelemetryPayloadMaxBytes(),
|
|
277
|
+
retentionMs = payloadRetentionMs(),
|
|
219
278
|
): Omit<StoredPayload, "id"> {
|
|
220
|
-
const expiresAt = new Date(now.getTime() +
|
|
279
|
+
const expiresAt = new Date(now.getTime() + retentionMs);
|
|
221
280
|
// Each body is serialized once, in drawer order, and only while the running
|
|
222
|
-
// total still fits the cap.
|
|
223
|
-
//
|
|
224
|
-
//
|
|
225
|
-
//
|
|
226
|
-
//
|
|
281
|
+
// total still fits the cap. Serializing every body up front to measure them
|
|
282
|
+
// and then serializing the whole record again to store it holds two copies of
|
|
283
|
+
// every body at once — measured at 23 MB of heap for 8 MB of bodies. A body
|
|
284
|
+
// that would cross the cap is never touched: its text is not built and its
|
|
285
|
+
// object is not walked for signals.
|
|
227
286
|
const surfaces: ReadonlyArray<readonly [keyof StoredPayload, unknown]> = [
|
|
228
287
|
["request_body", input.requestBody],
|
|
229
288
|
["provider_request_body", input.providerRequestBody],
|
|
@@ -263,11 +322,11 @@ export function buildPayloadRecord(
|
|
|
263
322
|
serialized.push(text);
|
|
264
323
|
kept.push(body);
|
|
265
324
|
}
|
|
266
|
-
// Only a surface that *had* a body and lost it to the cap is marked.
|
|
267
|
-
//
|
|
268
|
-
//
|
|
269
|
-
//
|
|
270
|
-
//
|
|
325
|
+
// Only a surface that *had* a body and lost it to the cap is marked.
|
|
326
|
+
// Stamping the marker onto every null surface would also mark surfaces that
|
|
327
|
+
// never carried anything — a request that never reached an upstream then
|
|
328
|
+
// reports "truncated" for a provider panel that was never captured, and the
|
|
329
|
+
// stage bitmask counts a marker as captured content.
|
|
271
330
|
const marker = (originalBytes: number): unknown => ({
|
|
272
331
|
_truncated: true,
|
|
273
332
|
_original_bytes: originalBytes,
|
|
@@ -303,14 +362,26 @@ export function buildPayloadRecord(
|
|
|
303
362
|
}
|
|
304
363
|
|
|
305
364
|
/**
|
|
306
|
-
* Postgres-backed redacted request/response payload capture. Payloads
|
|
307
|
-
*
|
|
365
|
+
* Postgres-backed redacted request/response payload capture. Payloads live for
|
|
366
|
+
* the tenant's chosen retention — 15 minutes by default — and are pruned by the
|
|
367
|
+
* sweep.
|
|
308
368
|
|
|
309
369
|
*/
|
|
310
370
|
export class TelemetryPayloadCapture {
|
|
311
371
|
private readonly store: DrizzleTelemetryStore;
|
|
312
372
|
|
|
313
|
-
constructor(
|
|
373
|
+
constructor(
|
|
374
|
+
db: CartethyiaDatabase,
|
|
375
|
+
/**
|
|
376
|
+
* Reads the tenant's chosen retention for captures that arrive without one.
|
|
377
|
+
*
|
|
378
|
+
* Injected rather than imported so the capture path does not depend on the
|
|
379
|
+
* console's settings module, and so a test can pin a retention without
|
|
380
|
+
* touching the database. Absent means every capture uses the deployment
|
|
381
|
+
* default, which is what a probe or a debug session wants anyway.
|
|
382
|
+
*/
|
|
383
|
+
private readonly retentionFor?: (tenantId: string) => Promise<number> | number,
|
|
384
|
+
) {
|
|
314
385
|
this.store = new DrizzleTelemetryStore(db);
|
|
315
386
|
}
|
|
316
387
|
|
|
@@ -320,7 +391,17 @@ export class TelemetryPayloadCapture {
|
|
|
320
391
|
code: "capture_not_opted_in",
|
|
321
392
|
});
|
|
322
393
|
}
|
|
323
|
-
|
|
394
|
+
// A caller that knows the answer passes it; otherwise the tenant's own
|
|
395
|
+
// preference is resolved, and failing that the deployment default. The
|
|
396
|
+
// early-rejection path has no preference to hand and lands here, which is
|
|
397
|
+
// why the default must be the deployment one rather than the shortest.
|
|
398
|
+
const retentionMs = await resolveRetention(input, this.retentionFor);
|
|
399
|
+
const record = buildPayloadRecord(
|
|
400
|
+
input,
|
|
401
|
+
new Date(),
|
|
402
|
+
input.maxBytes ?? resolveTelemetryPayloadMaxBytes(),
|
|
403
|
+
retentionMs,
|
|
404
|
+
);
|
|
324
405
|
const reference = await writePayloadFrame(record, record.expires_at, record._serialized
|
|
325
406
|
? {
|
|
326
407
|
requestId: record.request_id,
|
|
@@ -357,10 +438,54 @@ export class TelemetryPayloadCapture {
|
|
|
357
438
|
deleted += batch;
|
|
358
439
|
if (batch < batchSize) break;
|
|
359
440
|
}
|
|
360
|
-
//
|
|
361
|
-
//
|
|
362
|
-
//
|
|
363
|
-
|
|
441
|
+
// A file last written before *the longest retention a tenant can choose*
|
|
442
|
+
// closed cannot still hold a live frame — whatever its tenant picked, that
|
|
443
|
+
// frame has expired. Using the deployment default here instead was correct
|
|
444
|
+
// only while every frame shared one window: with a tenant allowed to keep
|
|
445
|
+
// frames for a week, a 15-minute bound would delete files still holding
|
|
446
|
+
// frames their rows address, and the drawer would silently read empty.
|
|
447
|
+
deleted += await prunePayloadFrames(now, payloadPruneBound(now));
|
|
364
448
|
return { deleted };
|
|
365
449
|
}
|
|
366
450
|
}
|
|
451
|
+
|
|
452
|
+
/**
|
|
453
|
+
* The cutoff before which a frame file can be deleted wholesale.
|
|
454
|
+
*
|
|
455
|
+
* Every frame expires at most {@link MAX_PAYLOAD_RETENTION_MS} after it is
|
|
456
|
+
* written, so a file last touched before that instant cannot still hold a live
|
|
457
|
+
* frame — and that is the only thing that makes a damaged, unparsable file
|
|
458
|
+
* reclaimable at all. Exported so the bound is pinned without a database: the
|
|
459
|
+
* alternative is a test that deletes a live frame and notices nothing.
|
|
460
|
+
*/
|
|
461
|
+
export function payloadPruneBound(now: Date): Date {
|
|
462
|
+
return new Date(now.getTime() - MAX_PAYLOAD_RETENTION_MS);
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
/**
|
|
466
|
+
* How long one capture's frame should live.
|
|
467
|
+
*
|
|
468
|
+
* Order of authority: the caller's own value, then the tenant's preference,
|
|
469
|
+
* then the deployment default. A failure resolving the tenant's preference
|
|
470
|
+
* falls back rather than throwing — an unknown retention must not turn into a
|
|
471
|
+
* lost capture, and the deployment default is always a valid answer.
|
|
472
|
+
*/
|
|
473
|
+
export async function resolveRetention(
|
|
474
|
+
input: PayloadCaptureInput,
|
|
475
|
+
retentionFor?: (tenantId: string) => Promise<number> | number,
|
|
476
|
+
): Promise<number> {
|
|
477
|
+
if (typeof input.retentionMs === "number" && Number.isFinite(input.retentionMs)) {
|
|
478
|
+
return Math.min(Math.max(1_000, Math.floor(input.retentionMs)), MAX_PAYLOAD_RETENTION_MS);
|
|
479
|
+
}
|
|
480
|
+
if (retentionFor) {
|
|
481
|
+
try {
|
|
482
|
+
const value = await retentionFor(input.tenantId);
|
|
483
|
+
if (Number.isFinite(value)) {
|
|
484
|
+
return Math.min(Math.max(1_000, Math.floor(value)), MAX_PAYLOAD_RETENTION_MS);
|
|
485
|
+
}
|
|
486
|
+
} catch {
|
|
487
|
+
// Falls through to the deployment default below.
|
|
488
|
+
}
|
|
489
|
+
}
|
|
490
|
+
return payloadRetentionMs();
|
|
491
|
+
}
|
|
@@ -4,12 +4,7 @@ import { randomUUID } from "node:crypto";
|
|
|
4
4
|
import { gunzipSync, gzipSync } from "node:zlib";
|
|
5
5
|
|
|
6
6
|
export interface PayloadFileReference {
|
|
7
|
-
|
|
8
|
-
* `jsonb-file` is a frame written before compression existed: plain JSON.
|
|
9
|
-
* New frames are `jsonl-file`: the same JSON, gzipped. The row names its
|
|
10
|
-
* encoding, so a reader never guesses and an old row still resolves.
|
|
11
|
-
*/
|
|
12
|
-
readonly storage: "jsonb-file" | "jsonl-file";
|
|
7
|
+
readonly storage: "jsonl-file";
|
|
13
8
|
readonly file: string;
|
|
14
9
|
readonly offset: number;
|
|
15
10
|
readonly length: number;
|
|
@@ -28,7 +23,7 @@ export function isPayloadFileReference(value: unknown): value is PayloadFileRefe
|
|
|
28
23
|
if (typeof value !== "object" || value === null) return false;
|
|
29
24
|
const record = value as Record<string, unknown>;
|
|
30
25
|
return (
|
|
31
|
-
|
|
26
|
+
record["storage"] === "jsonl-file" &&
|
|
32
27
|
typeof record["file"] === "string" &&
|
|
33
28
|
typeof record["offset"] === "number" &&
|
|
34
29
|
typeof record["length"] === "number" &&
|
|
@@ -62,7 +57,13 @@ export function payloadReferenceFromRow(row: {
|
|
|
62
57
|
return isPayloadFileReference(candidate) ? candidate : undefined;
|
|
63
58
|
}
|
|
64
59
|
|
|
65
|
-
|
|
60
|
+
/** Extension for frame files. */
|
|
61
|
+
const FRAME_FILE_SUFFIX = ".jsonl";
|
|
62
|
+
|
|
63
|
+
function isFrameFileName(name: string): boolean {
|
|
64
|
+
return name.endsWith(FRAME_FILE_SUFFIX);
|
|
65
|
+
}
|
|
66
|
+
|
|
66
67
|
const MAX_FRAME_BYTES = 256 * 1024 * 1024;
|
|
67
68
|
const DEFAULT_MAX_FILE_BYTES = 64 * 1024 * 1024;
|
|
68
69
|
const DEFAULT_DIRECTORY = "./data/telemetry-payloads";
|
|
@@ -96,7 +97,7 @@ function checksum(value: Buffer | readonly Buffer[]): string {
|
|
|
96
97
|
}
|
|
97
98
|
|
|
98
99
|
function fileName(now = new Date()): string {
|
|
99
|
-
return `${now.toISOString().slice(0, 13).replaceAll(":", "-")}-${process.pid}
|
|
100
|
+
return `${now.toISOString().slice(0, 13).replaceAll(":", "-")}-${process.pid}${FRAME_FILE_SUFFIX}`;
|
|
100
101
|
}
|
|
101
102
|
|
|
102
103
|
/**
|
|
@@ -115,34 +116,50 @@ function compressFrame(parts: readonly Buffer[]): Buffer {
|
|
|
115
116
|
async function appendFrame(frame: PayloadFrame, parts?: readonly Buffer[]): Promise<PayloadFileReference> {
|
|
116
117
|
const directory = payloadDirectory();
|
|
117
118
|
await mkdir(directory, { recursive: true });
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
119
|
+
// Encoded as a line here rather than at the write, so the cap, the checksum,
|
|
120
|
+
// and the reference all describe the same bytes that land on disk — the
|
|
121
|
+
// prebuilt-parts path and the plain one cannot disagree about the shape.
|
|
122
|
+
const json = parts === undefined
|
|
123
|
+
? Buffer.from(JSON.stringify(frame), "utf8")
|
|
124
|
+
: Buffer.concat(parts);
|
|
125
|
+
const compressed = compressFrame([json]);
|
|
126
|
+
if (compressed.byteLength > MAX_FRAME_BYTES) throw new Error("telemetry payload frame exceeds limit");
|
|
127
|
+
const encoded = toLine(compressed);
|
|
121
128
|
const file = fileName();
|
|
122
129
|
const path = join(directory, file);
|
|
123
130
|
const currentSize = await stat(path).then((value) => value.size).catch(() => 0);
|
|
124
131
|
const offset = currentSize;
|
|
125
|
-
if (offset +
|
|
126
|
-
const rotated = `${Date.now()}-${randomUUID()}
|
|
127
|
-
return appendFrameToPath(
|
|
132
|
+
if (offset + encoded.byteLength > maxFileBytes()) {
|
|
133
|
+
const rotated = `${Date.now()}-${randomUUID()}${FRAME_FILE_SUFFIX}`;
|
|
134
|
+
return appendFrameToPath(join(directory, rotated), encoded, basename(rotated));
|
|
128
135
|
}
|
|
129
|
-
return appendFrameToPath(
|
|
136
|
+
return appendFrameToPath(path, encoded, file, offset);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* One frame as a stored line: base64 of the gzipped JSON, newline-terminated.
|
|
141
|
+
*
|
|
142
|
+
* A newline is the frame delimiter, so the file is a text file of base64 lines
|
|
143
|
+
* and a frame's extent is found by reading to the next `\n` — a binary length
|
|
144
|
+
* header would make every frame opaque, unreadable without first decoding a
|
|
145
|
+
* big-endian length and then gunzipping exactly that many bytes.
|
|
146
|
+
*
|
|
147
|
+
* base64 rather than raw gzip bytes because gzip contains newlines, which
|
|
148
|
+
* would make the delimiter ambiguous.
|
|
149
|
+
*/
|
|
150
|
+
function toLine(compressed: Buffer): Buffer {
|
|
151
|
+
return Buffer.from(`${compressed.toString("base64")}\n`, "utf8");
|
|
130
152
|
}
|
|
131
153
|
|
|
132
154
|
async function appendFrameToPath(
|
|
133
|
-
frame: PayloadFrame,
|
|
134
155
|
path: string,
|
|
156
|
+
encoded: Buffer,
|
|
135
157
|
knownFile = basename(path),
|
|
136
158
|
knownOffset?: number,
|
|
137
|
-
knownEncoded?: Buffer,
|
|
138
159
|
): Promise<PayloadFileReference> {
|
|
139
|
-
const encoded = knownEncoded ?? compressFrame([Buffer.from(JSON.stringify(frame), "utf8")]);
|
|
140
160
|
const offset = knownOffset ?? await stat(path).then((value) => value.size).catch(() => 0);
|
|
141
|
-
const header = Buffer.alloc(FRAME_HEADER_BYTES);
|
|
142
|
-
header.writeUInt32BE(encoded.byteLength, 0);
|
|
143
161
|
const handle = await open(path, "a");
|
|
144
162
|
try {
|
|
145
|
-
await handle.write(header);
|
|
146
163
|
await handle.write(encoded);
|
|
147
164
|
} finally {
|
|
148
165
|
await handle.close();
|
|
@@ -151,7 +168,7 @@ async function appendFrameToPath(
|
|
|
151
168
|
storage: "jsonl-file",
|
|
152
169
|
file: knownFile,
|
|
153
170
|
offset,
|
|
154
|
-
length:
|
|
171
|
+
length: encoded.byteLength,
|
|
155
172
|
checksum: checksum(encoded),
|
|
156
173
|
version: 1,
|
|
157
174
|
};
|
|
@@ -166,6 +183,11 @@ async function appendFrameToPath(
|
|
|
166
183
|
* drawer order with the legacy `response_body` alias last, for the same reason
|
|
167
184
|
* the record accounts it last: it names no panel of its own and must not
|
|
168
185
|
* spend the shared byte budget ahead of the provider stages.
|
|
186
|
+
*
|
|
187
|
+
* There are five, not four. `buildPayloadRecord` builds one string per surface
|
|
188
|
+
* and `response_body` is the fifth — dropping it here wrote the frame with the
|
|
189
|
+
* first four bodies and silently discarded the last, so a capture whose client
|
|
190
|
+
* response was in the legacy slot came back with that panel empty.
|
|
169
191
|
*/
|
|
170
192
|
const FRAME_BODY_KEYS = [
|
|
171
193
|
"request_body",
|
|
@@ -240,14 +262,7 @@ export async function readPayloadFrame(reference: PayloadFileReference): Promise
|
|
|
240
262
|
await handle.close();
|
|
241
263
|
}
|
|
242
264
|
const frame = data;
|
|
243
|
-
const
|
|
244
|
-
if (length <= 0 || length + FRAME_HEADER_BYTES !== frame.byteLength) return undefined;
|
|
245
|
-
const stored = frame.subarray(FRAME_HEADER_BYTES);
|
|
246
|
-
if (checksum(stored) !== reference.checksum) return undefined;
|
|
247
|
-
// The row says which encoding it wrote. A compressed row that does not
|
|
248
|
-
// decompress is a damaged frame, reported as absent like any other
|
|
249
|
-
// unreadable frame, never thrown into the request that asked for it.
|
|
250
|
-
const json = reference.storage === "jsonl-file" ? gunzipFrame(stored) : stored;
|
|
265
|
+
const json = frameJsonFromSlice(frame);
|
|
251
266
|
if (json === undefined) return undefined;
|
|
252
267
|
const parsed = JSON.parse(json.toString("utf8")) as PayloadFrame;
|
|
253
268
|
return parsed.version === 1 ? parsed.payload : undefined;
|
|
@@ -262,6 +277,24 @@ function gunzipFrame(stored: Buffer): Buffer | undefined {
|
|
|
262
277
|
}
|
|
263
278
|
}
|
|
264
279
|
|
|
280
|
+
/**
|
|
281
|
+
* The JSON of one frame read out of a file.
|
|
282
|
+
*
|
|
283
|
+
* A frame is a base64 line — base64 because gzip contains newlines, which would
|
|
284
|
+
* make the newline delimiter ambiguous. The bytes are gunzipped only if they
|
|
285
|
+
* are gzip; `gunzipFrame` returning `undefined` is what marks a slice as not a
|
|
286
|
+
* frame.
|
|
287
|
+
*/
|
|
288
|
+
function frameJsonFromSlice(
|
|
289
|
+
slice: Buffer,
|
|
290
|
+
): Buffer | undefined {
|
|
291
|
+
// Frames are a base64 line. The trailing newline is not part of the payload.
|
|
292
|
+
const text = slice.toString("utf8").trim();
|
|
293
|
+
if (text.length === 0) return undefined;
|
|
294
|
+
const gzipped = Buffer.from(text, "base64");
|
|
295
|
+
return gunzipFrame(gzipped);
|
|
296
|
+
}
|
|
297
|
+
|
|
265
298
|
/**
|
|
266
299
|
* What a whole file's parseable frames say about reclaiming it.
|
|
267
300
|
*
|
|
@@ -272,40 +305,25 @@ function gunzipFrame(stored: Buffer): Buffer | undefined {
|
|
|
272
305
|
*/
|
|
273
306
|
type FrameLiveness = "live" | "none" | "unknown";
|
|
274
307
|
|
|
275
|
-
/**
|
|
276
|
-
* The JSON of one stored frame, whichever encoding wrote it.
|
|
277
|
-
*
|
|
278
|
-
* A frame names no encoding of its own — the row does — and this pass reads
|
|
279
|
-
* files, not rows. gzip bytes start with the 0x1f 0x8b magic and plain JSON
|
|
280
|
-
* starts with `{`, so the encoding is read off the bytes. Anything else is
|
|
281
|
-
* corrupt and falls through to the caller's parse, which reports the file
|
|
282
|
-
* unreadable rather than empty.
|
|
283
|
-
*/
|
|
284
|
-
function frameJson(stored: Buffer): Buffer {
|
|
285
|
-
if (stored.byteLength >= 2 && stored[0] === 0x1f && stored[1] === 0x8b) {
|
|
286
|
-
return gunzipSync(stored);
|
|
287
|
-
}
|
|
288
|
-
return stored;
|
|
289
|
-
}
|
|
290
|
-
|
|
291
|
-
|
|
292
308
|
function frameLiveness(data: Buffer, before: Date): FrameLiveness {
|
|
293
309
|
let offset = 0;
|
|
294
|
-
while (offset
|
|
295
|
-
const
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
310
|
+
while (offset < data.byteLength) {
|
|
311
|
+
const newline = data.indexOf(0x0a, offset);
|
|
312
|
+
if (newline <= 0) return "unknown";
|
|
313
|
+
const end = newline + 1;
|
|
314
|
+
const slice = data.subarray(offset, end);
|
|
315
|
+
const json = frameJsonFromSlice(slice);
|
|
316
|
+
if (json === undefined) return "unknown";
|
|
299
317
|
let frame: PayloadFrame;
|
|
300
318
|
try {
|
|
301
|
-
frame = JSON.parse(
|
|
319
|
+
frame = JSON.parse(json.toString("utf8")) as PayloadFrame;
|
|
302
320
|
} catch {
|
|
303
321
|
return "unknown";
|
|
304
322
|
}
|
|
305
323
|
if (new Date(frame.expiresAt).getTime() >= before.getTime()) return "live";
|
|
306
324
|
offset = end;
|
|
307
325
|
}
|
|
308
|
-
// Bytes left over that are too short to hold a
|
|
326
|
+
// Bytes left over that are too short to hold a frame are an unreadable tail.
|
|
309
327
|
return offset === data.byteLength ? "none" : "unknown";
|
|
310
328
|
}
|
|
311
329
|
|
|
@@ -327,10 +345,11 @@ function frameLiveness(data: Buffer, before: Date): FrameLiveness {
|
|
|
327
345
|
* supply that bound leaves the default, which keeps damaged files rather than
|
|
328
346
|
* risk dropping a frame a row still addresses.
|
|
329
347
|
*
|
|
330
|
-
* One damaged file must not abort the pass.
|
|
331
|
-
*
|
|
332
|
-
* forever and the volume only
|
|
333
|
-
* usual way a half-written tail appears, the failure
|
|
348
|
+
* One damaged file must not abort the pass. If a single corrupt frame threw
|
|
349
|
+
* straight out of the loop, every other expired file would stay on disk
|
|
350
|
+
* forever and the volume would only grow — and because a container restart is
|
|
351
|
+
* the usual way a half-written tail appears, the failure would look
|
|
352
|
+
* Docker-specific.
|
|
334
353
|
*/
|
|
335
354
|
export async function prunePayloadFrames(
|
|
336
355
|
before: Date,
|
|
@@ -340,7 +359,7 @@ export async function prunePayloadFrames(
|
|
|
340
359
|
const entries = await readdir(directory, { withFileTypes: true }).catch(() => []);
|
|
341
360
|
let deleted = 0;
|
|
342
361
|
for (const entry of entries) {
|
|
343
|
-
if (!entry.isFile() || !entry.name
|
|
362
|
+
if (!entry.isFile() || !isFrameFileName(entry.name)) continue;
|
|
344
363
|
const path = join(directory, entry.name);
|
|
345
364
|
try {
|
|
346
365
|
if (await fileReclaimable(path, before, unparseableWrittenBefore)) {
|
|
@@ -367,7 +386,9 @@ async function fileReclaimable(
|
|
|
367
386
|
unparseableWrittenBefore: Date,
|
|
368
387
|
): Promise<boolean> {
|
|
369
388
|
const data = await readFile(path);
|
|
370
|
-
|
|
389
|
+
// Nothing written yet, so nothing to lose. A real frame is far longer than
|
|
390
|
+
// any header this format could carry.
|
|
391
|
+
if (data.byteLength === 0) return true;
|
|
371
392
|
const liveness = frameLiveness(data, before);
|
|
372
393
|
if (liveness === "live") return false;
|
|
373
394
|
if (liveness === "none") return true;
|
|
@@ -1,13 +1,12 @@
|
|
|
1
1
|
// Client-IP presentation masking.
|
|
2
2
|
//
|
|
3
|
-
// This module
|
|
4
|
-
//
|
|
5
|
-
// display, but `***REDACTED***` in a captured body is indistinguishable from
|
|
3
|
+
// This module carries no string-sniffing payload redactor, and must not grow
|
|
4
|
+
// one: `***REDACTED***` in a captured body is indistinguishable from
|
|
6
5
|
// `***REDACTED***` in a payload that was actually sent — so a memory-review
|
|
7
|
-
// turn quoting one JWT
|
|
8
|
-
// the provider
|
|
6
|
+
// turn quoting one JWT would render as a fully redacted message, and a 400 from
|
|
7
|
+
// the provider would look like the gateway had mangled the request.
|
|
9
8
|
//
|
|
10
|
-
// Payload bodies are stored verbatim
|
|
9
|
+
// Payload bodies are stored verbatim. The upstream already refuses and
|
|
11
10
|
// reports credentials on its own; a second, string-sniffing layer only
|
|
12
11
|
// destroys the evidence needed to read a failure.
|
|
13
12
|
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Lightweight fan-out for completed gateway requests.
|
|
3
|
+
*
|
|
4
|
+
* `notifyRequestCompleted` is called once per terminal request (same site as
|
|
5
|
+
* `telemetryBuffer.enqueue`). Subscribers receive a minimal summary — just
|
|
6
|
+
* enough for the dashboard SSE stream to trigger a targeted refetch rather
|
|
7
|
+
* than polling.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
export interface RequestCompletedNotice {
|
|
11
|
+
readonly tenantId: string;
|
|
12
|
+
readonly requestId: string;
|
|
13
|
+
readonly status: string;
|
|
14
|
+
readonly httpStatus: number;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
type Listener = (notice: RequestCompletedNotice) => void;
|
|
18
|
+
|
|
19
|
+
const listeners = new Set<Listener>();
|
|
20
|
+
|
|
21
|
+
/** Notify all subscribers that a request has reached a terminal state. */
|
|
22
|
+
export function notifyRequestCompleted(notice: RequestCompletedNotice): void {
|
|
23
|
+
for (const listener of listeners) {
|
|
24
|
+
try {
|
|
25
|
+
listener(notice);
|
|
26
|
+
} catch {
|
|
27
|
+
// Listener failures must not affect the request path.
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** Subscribe to per-request terminal notifications; returns unsubscribe. */
|
|
33
|
+
export function subscribeRequestCompleted(listener: Listener): () => void {
|
|
34
|
+
listeners.add(listener);
|
|
35
|
+
return () => {
|
|
36
|
+
listeners.delete(listener);
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** Test-only: clear all subscribers. */
|
|
41
|
+
export function resetRequestNotifyForTests(): void {
|
|
42
|
+
listeners.clear();
|
|
43
|
+
}
|
|
@@ -1,13 +1,12 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* The single definition of which requests count as *gateway* errors.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
* the
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
* `telemetry_usage_totals.errors` it stays counted after the raw row is pruned.
|
|
4
|
+
* One definition keeps the six sites that ask this — the usage summary, the
|
|
5
|
+
* health window, the usage breakdown, the retained-usage rollup
|
|
6
|
+
* (`telemetry_usage_totals`), the public share page, and the client-IP
|
|
7
|
+
* breakdown — from drifting apart, which matters because the rollup makes any
|
|
8
|
+
* drift durable: once a request is counted into `telemetry_usage_totals.errors`
|
|
9
|
+
* it stays counted after the raw row is pruned.
|
|
11
10
|
*
|
|
12
11
|
* The rule, stated as a principle rather than a list of codes:
|
|
13
12
|
*
|
|
@@ -23,8 +22,8 @@
|
|
|
23
22
|
* an unregistered `/v1/*` path, a `429` from the caller's own quota, and a
|
|
24
23
|
* `413` from an oversized body all cost the sender nothing and reach no
|
|
25
24
|
* provider, so counting any of them hands an abuser a free way to inflate
|
|
26
|
-
* the error rate. Enumerating "the bad ones"
|
|
27
|
-
*
|
|
25
|
+
* the error rate. Enumerating "the bad ones" misses `401`/`429`; excluding the
|
|
26
|
+
* class cannot miss one.
|
|
28
27
|
* - **`499`** is a client abort (`transport_closed`). The client leaving is
|
|
29
28
|
* expected behavior — the request may even have been served — and it is
|
|
30
29
|
* tracked as its own count. Being below `500`, it is excluded by the class
|
|
@@ -51,10 +51,11 @@ export interface TokenSpeedInput {
|
|
|
51
51
|
export function computeTokensPerSec(input: TokenSpeedInput): number | undefined {
|
|
52
52
|
const outputTokens = input.outputTokens;
|
|
53
53
|
// `!Number.isFinite` rather than `<= 0`: a NaN token count fails `NaN <= 0`
|
|
54
|
-
// (`false`), so it
|
|
55
|
-
// which reaches the telemetry row and renders as a literal
|
|
56
|
-
// Requests table. That is the case where a wrong number is
|
|
57
|
-
// the row that produced it is the one an operator is
|
|
54
|
+
// (`false`), so it would pass through and the division would produce a NaN
|
|
55
|
+
// speed — which reaches the telemetry row and renders as a literal
|
|
56
|
+
// "NaN tok/s" in the Requests table. That is the case where a wrong number is
|
|
57
|
+
// least welcome, since the row that produced it is the one an operator is
|
|
58
|
+
// investigating.
|
|
58
59
|
if (outputTokens === undefined || !Number.isFinite(outputTokens) || outputTokens <= 0) {
|
|
59
60
|
return undefined;
|
|
60
61
|
}
|