cartethyia 2.5.9 → 2.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.env.example +15 -42
- package/README.md +11 -10
- package/dist/dashboard/assets/{BannedPage-Brvuj_Eb.js → BannedPage-l6XMnfr9.js} +1 -1
- package/dist/dashboard/assets/BannedPage-l6XMnfr9.js.gz +0 -0
- package/dist/dashboard/assets/{CliToolDetailPage-CqfXtI3y.js → CliToolDetailPage-DwOzAW45.js} +1 -1
- package/dist/dashboard/assets/CliToolDetailPage-DwOzAW45.js.gz +0 -0
- package/dist/dashboard/assets/{CliToolsPage-GQeS91bg.js → CliToolsPage-BT3Up3QA.js} +1 -1
- package/dist/dashboard/assets/CliToolsPage-BT3Up3QA.js.gz +0 -0
- package/dist/dashboard/assets/{CombosPage-BDRsTXBB.js → CombosPage-6kYIUpzR.js} +1 -1
- package/dist/dashboard/assets/CombosPage-6kYIUpzR.js.gz +0 -0
- package/dist/dashboard/assets/{ConsoleLogPage-zP8AFHX0.js → ConsoleLogPage-zRnj1mlR.js} +1 -1
- package/dist/dashboard/assets/ConsoleLogPage-zRnj1mlR.js.gz +0 -0
- package/dist/dashboard/assets/{CustomizationPage-BmfrNZ14.js → CustomizationPage-C7m-H1Rz.js} +1 -1
- package/dist/dashboard/assets/CustomizationPage-C7m-H1Rz.js.gz +0 -0
- package/dist/dashboard/assets/HealthEventsModal-C8MR_BdB.js +1 -0
- package/dist/dashboard/assets/HealthEventsModal-C8MR_BdB.js.gz +0 -0
- package/dist/dashboard/assets/{LoginPage-DoreIgm8.js → LoginPage-CbphLvjg.js} +1 -1
- package/dist/dashboard/assets/LoginPage-CbphLvjg.js.gz +0 -0
- package/dist/dashboard/assets/{ModelPicker-C7pDb41R.js → ModelPicker-DVny4S5_.js} +1 -1
- package/dist/dashboard/assets/ModelPicker-DVny4S5_.js.gz +0 -0
- package/dist/dashboard/assets/OverviewPage-DABu2Wgt.js +1 -0
- package/dist/dashboard/assets/OverviewPage-DABu2Wgt.js.gz +0 -0
- package/dist/dashboard/assets/ProviderDetailPage-C15x9q13.js +3 -0
- package/dist/dashboard/assets/ProviderDetailPage-C15x9q13.js.gz +0 -0
- package/dist/dashboard/assets/{ProvidersPage-BnoPgA7w.js → ProvidersPage-Dfb2896L.js} +1 -1
- package/dist/dashboard/assets/ProvidersPage-Dfb2896L.js.gz +0 -0
- package/dist/dashboard/assets/{ProxyPage-Bq_TGgdb.js → ProxyPage-BaCIdAUt.js} +1 -1
- package/dist/dashboard/assets/ProxyPage-BaCIdAUt.js.gz +0 -0
- package/dist/dashboard/assets/QuotaPage-BsdoCIw1.js +1 -0
- package/dist/dashboard/assets/QuotaPage-BsdoCIw1.js.gz +0 -0
- package/dist/dashboard/assets/SettingsPage-KVJFh3Ip.js +1 -0
- package/dist/dashboard/assets/SettingsPage-KVJFh3Ip.js.gz +0 -0
- package/dist/dashboard/assets/{SetupPage-Day3mCOn.js → SetupPage-D4g6vOGc.js} +1 -1
- package/dist/dashboard/assets/SetupPage-D4g6vOGc.js.gz +0 -0
- package/dist/dashboard/assets/{SortableList-DQxllnB5.js → SortableList-C93i14BD.js} +1 -1
- package/dist/dashboard/assets/SortableList-C93i14BD.js.gz +0 -0
- package/dist/dashboard/assets/{StudioPage-uMExoWu1.js → StudioPage-SgQpymjm.js} +1 -1
- package/dist/dashboard/assets/{StudioPage-uMExoWu1.js.gz → StudioPage-SgQpymjm.js.gz} +0 -0
- package/dist/dashboard/assets/{UsagePage-DXa1oR-4.js → UsagePage-BFME-eba.js} +2 -2
- package/dist/dashboard/assets/UsagePage-BFME-eba.js.gz +0 -0
- package/dist/dashboard/assets/{api-keys-DQYLQxpW.js → api-keys-C23S9sgp.js} +1 -1
- package/dist/dashboard/assets/api-keys-C23S9sgp.js.gz +0 -0
- package/dist/dashboard/assets/{app-BIt446yE.js → app-2KhtHCv9.js} +1 -1
- package/dist/dashboard/assets/{app-BIt446yE.js.gz → app-2KhtHCv9.js.gz} +0 -0
- package/dist/dashboard/assets/app-CEeaMHOy.css +2 -0
- package/dist/dashboard/assets/app-CEeaMHOy.css.gz +0 -0
- package/dist/dashboard/assets/{assert-C9FD_JwJ.js → assert-DU6qrMA_.js} +1 -1
- package/dist/dashboard/assets/assert-DU6qrMA_.js.gz +0 -0
- package/dist/dashboard/assets/{badge-CR72dasT.js → badge-CKsF123d.js} +1 -1
- package/dist/dashboard/assets/badge-CKsF123d.js.gz +0 -0
- package/dist/dashboard/assets/{banner-CI9_eSfO.js → banner-8j7uChA9.js} +1 -1
- package/dist/dashboard/assets/banner-8j7uChA9.js.gz +0 -0
- package/dist/dashboard/assets/{card-DS7CQEsO.js → card-CdsSlWA3.js} +1 -1
- package/dist/dashboard/assets/card-CdsSlWA3.js.gz +0 -0
- package/dist/dashboard/assets/{cli-tools-CNqzrfdb.js → cli-tools-BaFUuFFF.js} +1 -1
- package/dist/dashboard/assets/cli-tools-BaFUuFFF.js.gz +0 -0
- package/dist/dashboard/assets/{clipboard-button-611pSsaX.js → clipboard-button-uL5aYLfW.js} +1 -1
- package/dist/dashboard/assets/clipboard-button-uL5aYLfW.js.gz +0 -0
- package/dist/dashboard/assets/{dialog-k37exjNt.js → dialog-C07hr5_G.js} +1 -1
- package/dist/dashboard/assets/dialog-C07hr5_G.js.gz +0 -0
- package/dist/dashboard/assets/{drawer-CX7N8ucR.js → drawer-BPO1TcKH.js} +1 -1
- package/dist/dashboard/assets/drawer-BPO1TcKH.js.gz +0 -0
- package/dist/dashboard/assets/{entry-CTP8AyiS.js → entry-Bz_CtDfC.js} +1 -1
- package/dist/dashboard/assets/entry-Bz_CtDfC.js.gz +0 -0
- package/dist/dashboard/assets/entry-CwPRKk0d.js +4 -0
- package/dist/dashboard/assets/entry-CwPRKk0d.js.gz +0 -0
- package/dist/dashboard/assets/entry-GJRbmrpA.js +1 -0
- package/dist/dashboard/assets/entry-GJRbmrpA.js.gz +0 -0
- package/dist/dashboard/assets/{github-badge-sltk1qUi.js → github-badge-DmQreSDd.js} +1 -1
- package/dist/dashboard/assets/github-badge-DmQreSDd.js.gz +0 -0
- package/dist/dashboard/assets/{index-z3zDOSMM.js → index-Bblu6erR.js} +2 -2
- package/dist/dashboard/assets/index-Bblu6erR.js.gz +0 -0
- package/dist/dashboard/assets/{inline-BhY2sSnO.js → inline-C9dCYaBZ.js} +1 -1
- package/dist/dashboard/assets/inline-C9dCYaBZ.js.gz +0 -0
- package/dist/dashboard/assets/{input-V1umFiBk.js → input-CjCnXweJ.js} +1 -1
- package/dist/dashboard/assets/input-CjCnXweJ.js.gz +0 -0
- package/dist/dashboard/assets/{layout-BBOYycyS.js → layout-V6y8DAkl.js} +1 -1
- package/dist/dashboard/assets/layout-V6y8DAkl.js.gz +0 -0
- package/dist/dashboard/assets/{list-row-1zzZYM0B.js → list-row-CM1AaG_y.js} +1 -1
- package/dist/dashboard/assets/list-row-CM1AaG_y.js.gz +0 -0
- package/dist/dashboard/assets/{live-BivGfuUs.js → live-ta6GMb-8.js} +1 -1
- package/dist/dashboard/assets/live-ta6GMb-8.js.gz +0 -0
- package/dist/dashboard/assets/{model-bans-CmffjLuG.js → model-bans-BmYTmbAk.js} +1 -1
- package/dist/dashboard/assets/model-bans-BmYTmbAk.js.gz +0 -0
- package/dist/dashboard/assets/{mutation-46P0oWMe.js → mutation-g_fduiNw.js} +1 -1
- package/dist/dashboard/assets/mutation-g_fduiNw.js.gz +0 -0
- package/dist/dashboard/assets/{numbers-DkfMR5Qs.js → numbers-BOevJzdz.js} +1 -1
- package/dist/dashboard/assets/numbers-BOevJzdz.js.gz +0 -0
- package/dist/dashboard/assets/{query-keys-BFhGpwR1.js → query-keys-UETqfytp.js} +1 -1
- package/dist/dashboard/assets/query-keys-UETqfytp.js.gz +0 -0
- package/dist/dashboard/assets/{routing-CAb4F37R.js → routing-BPUo-W8d.js} +1 -1
- package/dist/dashboard/assets/routing-BPUo-W8d.js.gz +0 -0
- package/dist/dashboard/assets/{select-C6MMiaio.js → select-C980c4cz.js} +1 -1
- package/dist/dashboard/assets/select-C980c4cz.js.gz +0 -0
- package/dist/dashboard/assets/{settings-DNX9sydi.js → settings--3hxSyry.js} +1 -1
- package/dist/dashboard/assets/settings--3hxSyry.js.gz +0 -0
- package/dist/dashboard/assets/{stack-BHTfXdz_.js → stack-_gfTdLVB.js} +1 -1
- package/dist/dashboard/assets/stack-_gfTdLVB.js.gz +0 -0
- package/dist/dashboard/assets/{state-mPDRlQkl.js → state-2c5V4zo7.js} +1 -1
- package/dist/dashboard/assets/state-2c5V4zo7.js.gz +0 -0
- package/dist/dashboard/assets/{switch-DOmJxg7H.js → switch-Dkke7Naf.js} +1 -1
- package/dist/dashboard/assets/switch-Dkke7Naf.js.gz +0 -0
- package/dist/dashboard/assets/{useMutation-Dem0lXQj.js → useMutation-EDFRkn1i.js} +1 -1
- package/dist/dashboard/assets/useMutation-EDFRkn1i.js.gz +0 -0
- package/dist/dashboard/index.html +1 -1
- package/dist/dashboard/index.html.gz +0 -0
- package/dist/dashboard/precompressed-assets.json +51 -51
- package/migrations/README.md +52 -52
- package/migrations/full-postgres/0020_add_telemetry_event_analytics.sql +77 -0
- package/migrations/full-postgres/0021_add_api_key_concurrency_tolerance.sql +13 -0
- package/migrations/full-postgres/0022_add_inferhub_config_key.sql +17 -0
- package/migrations/full-postgres/0023_drop_api_key_concurrency_tolerance.sql +13 -0
- package/migrations/lite-sqlite3/0020_add_telemetry_event_analytics.sql +69 -0
- package/migrations/lite-sqlite3/0021_add_api_key_concurrency_tolerance.sql +13 -0
- package/migrations/lite-sqlite3/0022_add_inferhub_config_key.sql +17 -0
- package/migrations/lite-sqlite3/0023_drop_api_key_concurrency_tolerance.sql +13 -0
- package/package.json +96 -97
- package/scripts/cli/admin.ts +126 -125
- package/scripts/cli/autostart.ts +237 -237
- package/scripts/cli/background.ts +120 -110
- package/scripts/cli/bin/carte.cjs +8 -8
- package/scripts/cli/bin/cartethyia.cjs +167 -167
- package/scripts/cli/console-menu.ts +308 -275
- package/scripts/cli/controller.ts +145 -0
- package/scripts/cli/doctor.ts +1 -1
- package/scripts/cli/env.ts +99 -99
- package/scripts/cli/gateway.ts +264 -261
- package/scripts/{health.ts → cli/health.ts} +4 -4
- package/scripts/cli/instance.ts +234 -224
- package/scripts/cli/main.ts +188 -164
- package/scripts/cli/platform.ts +64 -64
- package/scripts/cli/prompt.ts +275 -107
- package/scripts/cli/services.ts +88 -88
- package/scripts/cli/setup.ts +445 -445
- package/scripts/cli/start.ts +85 -82
- package/scripts/cli/startup-signal.ts +23 -23
- package/scripts/cli/telemetry.ts +70 -0
- package/scripts/cli/tray/index.ts +263 -263
- package/scripts/cli/tray/windows.ps1 +107 -107
- package/scripts/cli/tray.ts +192 -208
- package/scripts/cli/update.ts +39 -39
- package/scripts/cli/version.ts +67 -67
- package/scripts/cli/wordmark.ts +58 -58
- package/src/boot/app.ts +16 -1
- package/src/boot/dependencies.ts +128 -25
- package/src/config/package-paths.ts +24 -0
- package/src/config/resources.ts +137 -0
- package/src/config/runtime.ts +133 -76
- package/src/config/spec.ts +30 -29
- package/src/config/telemetry.ts +0 -25
- package/src/console/api-keys/bucket-spend.ts +14 -17
- package/src/console/api-keys/contracts.ts +410 -410
- package/src/console/api-keys/routes.ts +12 -0
- package/src/console/audit/store.ts +2 -3
- package/src/console/auth/setup-service.ts +8 -1
- package/src/console/backup/routes.ts +7 -0
- package/src/console/backup/service.ts +463 -463
- package/src/console/backup/store.ts +16 -11
- package/src/console/backup/validate.ts +1 -4
- package/src/console/cli-tools/fs-ops.ts +18 -2
- package/src/console/data-management/contracts.ts +1 -8
- package/src/console/data-management/operations.ts +61 -48
- package/src/console/data-management/routes.ts +4 -2
- package/src/console/domain-registration.ts +25 -3
- package/src/console/inferhub/client.ts +139 -0
- package/src/console/inferhub/key-store.ts +129 -0
- package/src/console/inferhub/normalize.ts +347 -0
- package/src/console/inferhub/routes.ts +411 -0
- package/src/console/inferhub/store.ts +109 -0
- package/src/console/inferhub/types.ts +183 -0
- package/src/console/observability/contracts.ts +2 -41
- package/src/console/observability/store.ts +176 -371
- package/src/console/providers/catalog/public-model-store.ts +0 -0
- package/src/console/providers/catalog/store.ts +23 -36
- package/src/console/quota/account-quota-shared.ts +52 -52
- package/src/console/quota/account-quota-tenant.ts +16 -24
- package/src/console/quota/refresh.ts +441 -441
- package/src/console/remote-support/service.ts +270 -114
- package/src/console/routing/pools/import-parse.ts +206 -206
- package/src/console/routing/pools/operations.ts +543 -543
- package/src/console/routing/pools/probe-verdict.ts +132 -132
- package/src/console/routing/pools/routes.ts +181 -181
- package/src/console/routing/pools/store.ts +1 -1
- package/src/console/settings/contracts.ts +42 -40
- package/src/console/settings/store.ts +25 -25
- package/src/console/share/share-stats.ts +22 -4
- package/src/console/share/share-usage.ts +30 -4
- package/src/console/shared/cursor.ts +15 -0
- package/src/console/updates/repository-updates.ts +40 -39
- package/src/console/updates/routes.ts +12 -0
- package/src/main.ts +13 -5
- package/src/network/pool/resolver.ts +1104 -1104
- package/src/observability/client-name.ts +22 -0
- package/src/observability/event-log.ts +5 -29
- package/src/observability/event-query.ts +690 -0
- package/src/observability/log-ring.ts +26 -2
- package/src/observability/log-sanitize.ts +118 -0
- package/src/observability/logger.ts +17 -13
- package/src/observability/metrics.ts +35 -0
- package/src/observability/payload-capture.ts +114 -41
- package/src/observability/payload-store.ts +249 -24
- package/src/observability/raw-event-writer.ts +118 -0
- package/src/observability/runtime-metrics.ts +71 -1
- package/src/observability/telemetry-buffer.ts +100 -17
- package/src/observability/telemetry-reconciliation.ts +417 -0
- package/src/persistence/boot.ts +20 -9
- package/src/persistence/db-pg.ts +13 -0
- package/src/persistence/db-sqlite.ts +569 -497
- package/src/persistence/dialect.ts +3 -2
- package/src/persistence/migrate.ts +181 -181
- package/src/persistence/schema.ts +1496 -1406
- package/src/persistence/shared-cache.ts +5 -1
- package/src/persistence/stores/api-key-store.ts +581 -581
- package/src/persistence/stores/telemetry-store.ts +124 -63
- package/src/protocol/reasoning-text.ts +30 -0
- package/src/protocol/registry.ts +2 -0
- package/src/protocol/response/chat.ts +33 -7
- package/src/protocol/response/codex.ts +14 -2
- package/src/protocol/response/messages.ts +5 -2
- package/src/providers/authentication/oauth-flow-store.ts +3 -10
- package/src/providers/bundled-discovery-providers.ts +122 -122
- package/src/providers/default-registry.ts +14 -0
- package/src/providers/discovery/catalog/catalog-blob.ts +18 -18
- package/src/providers/discovery/models-dev-catalog.ts +354 -354
- package/src/providers/discovery/probe-phases.ts +43 -7
- package/src/providers/discovery/probing-service.ts +70 -32
- package/src/providers/integrations/antigravity/antigravity.ts +9 -2
- package/src/providers/integrations/buddy/buddy-catalog-shared.ts +85 -17
- package/src/providers/integrations/buddy/buddy-chat-shared.ts +934 -934
- package/src/providers/integrations/buddy/buddy-checkin.ts +5 -9
- package/src/providers/integrations/buddy/buddy-discovery-shared.ts +3 -7
- package/src/providers/integrations/buddy/buddy-quota-shared.ts +3 -1
- package/src/providers/integrations/buddy/capabilities.ts +4 -0
- package/src/providers/integrations/buddy/codebuddy/cn-provider.ts +57 -49
- package/src/providers/integrations/buddy/codebuddy/provider.ts +180 -180
- package/src/providers/integrations/buddy/workbuddy/provider.ts +206 -206
- package/src/providers/integrations/buddy/workbuddy/quota.ts +161 -161
- package/src/providers/integrations/claude/claude-quota.ts +2 -2
- package/src/providers/integrations/claude/claude-user-profiles.ts +3 -2
- package/src/providers/integrations/claude/claude.ts +3 -4
- package/src/providers/integrations/cline/cline.ts +94 -8
- package/src/providers/integrations/codex/codex.ts +61 -37
- package/src/providers/integrations/cursor/cursor-catalog.ts +368 -368
- package/src/providers/integrations/cursor/cursor-http2.ts +300 -300
- package/src/providers/integrations/cursor/cursor-identity.ts +2 -4
- package/src/providers/integrations/cursor/cursor-request.ts +11 -0
- package/src/providers/integrations/cursor/cursor.ts +29 -17
- package/src/providers/integrations/cursor/protocol/framing.ts +129 -129
- package/src/providers/integrations/cursor/protocol/messages.ts +2380 -2380
- package/src/providers/integrations/cursor/protocol/struct.ts +305 -305
- package/src/providers/integrations/cursor/protocol/tool-canonical.ts +275 -0
- package/src/providers/integrations/github/github.ts +3 -1
- package/src/providers/integrations/grok/grok-quota.ts +279 -279
- package/src/providers/integrations/grok/grok-ratelimit-store.ts +73 -73
- package/src/providers/integrations/grok/grok-ratelimit.ts +218 -218
- package/src/providers/integrations/hermes.ts +7 -1
- package/src/providers/integrations/inferhub.ts +201 -73
- package/src/providers/integrations/kilo/kilo.ts +152 -152
- package/src/providers/integrations/kiro/kiro-machine-id.ts +2 -4
- package/src/providers/integrations/opencode/provider.ts +2 -0
- package/src/providers/integrations/openrouter/provider.ts +129 -129
- package/src/providers/integrations/qoder/qoder-quota.ts +193 -192
- package/src/providers/integrations/qoder/qoder-realm.ts +106 -106
- package/src/providers/integrations/trae/trae-headers.ts +17 -48
- package/src/providers/integrations/trae/trae-oauth.ts +4 -26
- package/src/providers/integrations/trae/trae-quota.ts +389 -384
- package/src/providers/integrations/trae/trae-realm.ts +15 -2
- package/src/providers/integrations/trae/trae.ts +22 -24
- package/src/providers/integrations/xiaomi-mimo/think-stream.ts +100 -24
- package/src/providers/integrations/zcode/oauth.ts +1 -1
- package/src/providers/operations/account-health-service.ts +605 -279
- package/src/providers/provider-metadata.ts +12 -1
- package/src/providers/provider-model-definition.ts +373 -373
- package/src/providers/provider-probe.ts +3 -0
- package/src/providers/quota/quota-contracts.ts +3 -7
- package/src/providers/search/search-provider.ts +2 -6
- package/src/security/admission/contracts.ts +10 -0
- package/src/security/admission/in-memory-store.ts +89 -14
- package/src/security/admission/lua.ts +191 -182
- package/src/security/admission/redis-store.ts +30 -0
- package/src/security/admission/service.ts +18 -0
- package/src/security/api-key-auth-cache.ts +96 -0
- package/src/security/api-key-auth.ts +11 -100
- package/src/security/client-router-fingerprint.ts +3 -7
- package/src/security/ip-boundary.ts +7 -3
- package/src/shared/coerce.ts +67 -0
- package/src/shared/hash.ts +6 -0
- package/src/shared/time.ts +7 -0
- package/src/transport/dispatch/attempt-finalize.ts +26 -138
- package/src/transport/dispatch/attempt-loop.ts +103 -81
- package/src/transport/dispatch/capture-policy.ts +78 -0
- package/src/transport/dispatch/proxy-request.ts +4 -2
- package/src/transport/dispatch/streaming/encoder.ts +111 -5
- package/src/transport/dispatch/streaming/stream.ts +19 -2
- package/src/transport/dispatch/streaming/thinking-loop.ts +300 -0
- package/src/transport/failure-policy.ts +27 -0
- package/src/transport/guards/abuse-guard.ts +2 -2
- package/src/transport/guards/auth-guard.ts +17 -0
- package/src/transport/guards/body-policy.ts +3 -2
- package/src/transport/guards/concurrency-guard.ts +89 -13
- package/src/transport/guards/early-rejection-capture.ts +43 -3
- package/src/transport/guards/error-lifecycle.ts +10 -2
- package/src/transport/guards/guard-errors.ts +23 -0
- package/src/transport/guards/headroom-guard.ts +41 -7
- package/src/transport/guards/pipeline.ts +24 -0
- package/src/transport/guards/request-context.ts +2 -1
- package/src/transport/routing/admission.ts +51 -9
- package/src/transport/routing/bounded-lru.ts +49 -0
- package/src/transport/routing/candidate-index.ts +88 -0
- package/src/transport/routing/reservations.ts +121 -7
- package/src/transport/routing/round-robin.ts +10 -22
- package/src/transport/routing/route-catalog.ts +26 -13
- package/src/transport/routing/route-model.ts +22 -2
- package/src/transport/routing/router.ts +53 -31
- package/src/transport/surface/chat/encode.ts +2 -3
- package/src/transport/surface/chat/parse.ts +2 -1
- package/src/transport/surface/dialects.ts +11 -3
- package/src/transport/surface/messages/encode.ts +2 -1
- package/src/transport/surface/messages/parse.ts +3 -1
- package/src/transport/surface/messages/stream.ts +2 -6
- package/src/workers/maintenance-lease.ts +200 -0
- package/src/workers/scheduler.ts +101 -1
- package/dist/dashboard/assets/BannedPage-Brvuj_Eb.js.gz +0 -0
- package/dist/dashboard/assets/CliToolDetailPage-CqfXtI3y.js.gz +0 -0
- package/dist/dashboard/assets/CliToolsPage-GQeS91bg.js.gz +0 -0
- package/dist/dashboard/assets/CombosPage-BDRsTXBB.js.gz +0 -0
- package/dist/dashboard/assets/ConsoleLogPage-zP8AFHX0.js.gz +0 -0
- package/dist/dashboard/assets/CustomizationPage-BmfrNZ14.js.gz +0 -0
- package/dist/dashboard/assets/HealthEventsModal-Dn3M6WZc.js +0 -1
- package/dist/dashboard/assets/HealthEventsModal-Dn3M6WZc.js.gz +0 -0
- package/dist/dashboard/assets/LoginPage-DoreIgm8.js.gz +0 -0
- package/dist/dashboard/assets/ModelPicker-C7pDb41R.js.gz +0 -0
- package/dist/dashboard/assets/OverviewPage-CvvD1Ugk.js +0 -1
- package/dist/dashboard/assets/OverviewPage-CvvD1Ugk.js.gz +0 -0
- package/dist/dashboard/assets/ProviderDetailPage-Clbn1gsW.js +0 -3
- package/dist/dashboard/assets/ProviderDetailPage-Clbn1gsW.js.gz +0 -0
- package/dist/dashboard/assets/ProvidersPage-BnoPgA7w.js.gz +0 -0
- package/dist/dashboard/assets/ProxyPage-Bq_TGgdb.js.gz +0 -0
- package/dist/dashboard/assets/QuotaPage-D9DHXvom.js +0 -1
- package/dist/dashboard/assets/QuotaPage-D9DHXvom.js.gz +0 -0
- package/dist/dashboard/assets/SettingsPage-7xV8WkWL.js +0 -1
- package/dist/dashboard/assets/SettingsPage-7xV8WkWL.js.gz +0 -0
- package/dist/dashboard/assets/SetupPage-Day3mCOn.js.gz +0 -0
- package/dist/dashboard/assets/SortableList-DQxllnB5.js.gz +0 -0
- package/dist/dashboard/assets/UsagePage-DXa1oR-4.js.gz +0 -0
- package/dist/dashboard/assets/api-keys-DQYLQxpW.js.gz +0 -0
- package/dist/dashboard/assets/app-CX_Z8cMs.css +0 -2
- package/dist/dashboard/assets/app-CX_Z8cMs.css.gz +0 -0
- package/dist/dashboard/assets/assert-C9FD_JwJ.js.gz +0 -0
- package/dist/dashboard/assets/badge-CR72dasT.js.gz +0 -0
- package/dist/dashboard/assets/banner-CI9_eSfO.js.gz +0 -0
- package/dist/dashboard/assets/card-DS7CQEsO.js.gz +0 -0
- package/dist/dashboard/assets/cli-tools-CNqzrfdb.js.gz +0 -0
- package/dist/dashboard/assets/clipboard-button-611pSsaX.js.gz +0 -0
- package/dist/dashboard/assets/dialog-k37exjNt.js.gz +0 -0
- package/dist/dashboard/assets/drawer-CX7N8ucR.js.gz +0 -0
- package/dist/dashboard/assets/entry-B9Xvtnfj.js +0 -1
- package/dist/dashboard/assets/entry-B9Xvtnfj.js.gz +0 -0
- package/dist/dashboard/assets/entry-ByMGkR3C.js +0 -4
- package/dist/dashboard/assets/entry-ByMGkR3C.js.gz +0 -0
- package/dist/dashboard/assets/entry-CTP8AyiS.js.gz +0 -0
- package/dist/dashboard/assets/github-badge-sltk1qUi.js.gz +0 -0
- package/dist/dashboard/assets/index-z3zDOSMM.js.gz +0 -0
- package/dist/dashboard/assets/inline-BhY2sSnO.js.gz +0 -0
- package/dist/dashboard/assets/input-V1umFiBk.js.gz +0 -0
- package/dist/dashboard/assets/layout-BBOYycyS.js.gz +0 -0
- package/dist/dashboard/assets/list-row-1zzZYM0B.js.gz +0 -0
- package/dist/dashboard/assets/live-BivGfuUs.js.gz +0 -0
- package/dist/dashboard/assets/model-bans-CmffjLuG.js.gz +0 -0
- package/dist/dashboard/assets/mutation-46P0oWMe.js.gz +0 -0
- package/dist/dashboard/assets/numbers-DkfMR5Qs.js.gz +0 -0
- package/dist/dashboard/assets/query-keys-BFhGpwR1.js.gz +0 -0
- package/dist/dashboard/assets/routing-CAb4F37R.js.gz +0 -0
- package/dist/dashboard/assets/select-C6MMiaio.js.gz +0 -0
- package/dist/dashboard/assets/settings-DNX9sydi.js.gz +0 -0
- package/dist/dashboard/assets/stack-BHTfXdz_.js.gz +0 -0
- package/dist/dashboard/assets/state-mPDRlQkl.js.gz +0 -0
- package/dist/dashboard/assets/switch-DOmJxg7H.js.gz +0 -0
- package/dist/dashboard/assets/useMutation-Dem0lXQj.js.gz +0 -0
- package/scripts/build/aot.ts +0 -143
- package/scripts/build/build.ts +0 -24
- package/scripts/build/compile.ts +0 -88
- package/scripts/build/restart.ts +0 -120
- package/scripts/build/start.ts +0 -25
- package/src/observability/event-compaction.ts +0 -317
- package/src/observability/event-day-index.ts +0 -440
|
@@ -1,182 +1,191 @@
|
|
|
1
|
-
import type { AdmissionRejectionReason } from "./contracts";
|
|
2
|
-
import { reasonToGatewayError } from "./reasons";
|
|
3
|
-
import {
|
|
4
|
-
CONCURRENCY_TTL_SECONDS,
|
|
5
|
-
DAILY_COUNTER_TTL_SECONDS,
|
|
6
|
-
LIFETIME_COUNTER_TTL_SECONDS,
|
|
7
|
-
MONTHLY_COUNTER_TTL_SECONDS,
|
|
8
|
-
RPM_WINDOW_TTL_SECONDS,
|
|
9
|
-
} from "./ttl";
|
|
10
|
-
|
|
11
|
-
/**
|
|
12
|
-
* The three atomic scripts every Redis admission operation runs.
|
|
13
|
-
*
|
|
14
|
-
* They live apart from the store that calls them because they are the
|
|
15
|
-
* enforcement itself: a limit check and its counter write must be one
|
|
16
|
-
* indivisible step, which is why the logic is Lua rather than TypeScript.
|
|
17
|
-
* Reading them next to each other is the only way to see that reserve,
|
|
18
|
-
* reconcile, and release agree on the lease hash fields.
|
|
19
|
-
*/
|
|
20
|
-
/**
|
|
21
|
-
* RESERVE — the atomic admission algorithm. Runs as one Redis Lua script so
|
|
22
|
-
* every limit is validated and the reservation created in a single atomic
|
|
23
|
-
* step: no read-modify-write race can let two requests through the last
|
|
24
|
-
* remaining slot, and no partially applied reservation can survive (all
|
|
25
|
-
* counter writes + the lease hash commit together or not at all).
|
|
26
|
-
*
|
|
27
|
-
* Idempotency: an existing lease hash in any state short-circuits to `1`
|
|
28
|
-
* (already reserved) so a retried admission cannot double-charge counters.
|
|
29
|
-
*
|
|
30
|
-
* Limit model — rate limits vs concurrency:
|
|
31
|
-
* - RPM (KEYS[1], a ZSET of timestamps): sliding 60s window, pruned with
|
|
32
|
-
* ZREMRANGEBYSCORE before counting. Admission is count-based, not
|
|
33
|
-
* token-based.
|
|
34
|
-
* - Daily / monthly / lifetime token budgets (KEYS[2]/[3]/[5]): the
|
|
35
|
-
* *estimated* token count is pre-credited at reserve time and reconciled
|
|
36
|
-
* to actual usage later (RECONCILE_SCRIPT), so a request that would exceed
|
|
37
|
-
* its budget is rejected before dispatch, never after.
|
|
38
|
-
* - Concurrency (KEYS[4], per API key) and tenant concurrency (KEYS[7]) are
|
|
39
|
-
* classic INCR/DECR slots held for the request duration; the lease hash
|
|
40
|
-
* (KEYS[6]) records which counters each reservation tracks so release
|
|
41
|
-
* reverses exactly those.
|
|
42
|
-
*
|
|
43
|
-
* Key naming: `admission:<kind>[:<apiKeyId>|<tenantId>][:<bucket>]` —
|
|
44
|
-
* `admission:rpm:<key>`, `admission:daily:<key>:<YYYY-MM-DD>`,
|
|
45
|
-
* `admission:monthly:<key>:<YYYY-MM>`, `admission:lifetime:<key>`,
|
|
46
|
-
* `admission:concurrent:<key>`, `admission:tenant_concurrent:<tenant>`,
|
|
47
|
-
* `admission:lease:<reservationId>`. All counters carry TTLs (roughly one
|
|
48
|
-
* bucket + slack) so a process death cannot leak counters forever.
|
|
49
|
-
*
|
|
50
|
-
* Return codes: `0` reserved; `1` idempotent replay; `-1..-6` limit
|
|
51
|
-
* rejections (mapped to GatewayErrors by `assertResult`); `-99` corrupt
|
|
52
|
-
* counter state (never bypassed — admission fails closed).
|
|
53
|
-
*/
|
|
54
|
-
export const RESERVE_SCRIPT = `
|
|
55
|
-
local state = redis.call('HGET', KEYS[6], 'state')
|
|
56
|
-
if state == 'active' or state == 'committed' or state == 'released' then return 1 end
|
|
57
|
-
redis.call('ZREMRANGEBYSCORE', KEYS[1], '-inf', tonumber(ARGV[1]) - 60000)
|
|
58
|
-
local rpm = redis.call('ZCARD', KEYS[1])
|
|
59
|
-
local daily = tonumber(redis.call('GET', KEYS[2]) or '0')
|
|
60
|
-
local monthly = tonumber(redis.call('GET', KEYS[3]) or '0')
|
|
61
|
-
local concurrent = tonumber(redis.call('GET', KEYS[4]) or '0')
|
|
62
|
-
local tenantConcurrent = tonumber(redis.call('GET', KEYS[7]) or '0')
|
|
63
|
-
local lifetime = 0
|
|
64
|
-
if tonumber(ARGV[6]) then
|
|
65
|
-
local rawLifetime = redis.call('GET', KEYS[5])
|
|
66
|
-
lifetime = rawLifetime and tonumber(rawLifetime) or tonumber(ARGV[9])
|
|
67
|
-
if not lifetime then return -99 end
|
|
68
|
-
if not rawLifetime then redis.call('SET', KEYS[5], lifetime, 'EX', ${LIFETIME_COUNTER_TTL_SECONDS}) end
|
|
69
|
-
end
|
|
70
|
-
local estimated = tonumber(ARGV[2])
|
|
71
|
-
if not estimated or estimated < 0 then return -99 end
|
|
72
|
-
if tonumber(ARGV[3]) and rpm >= tonumber(ARGV[3]) then return -1 end
|
|
73
|
-
if tonumber(ARGV[4]) and daily + estimated > tonumber(ARGV[4]) then return -2 end
|
|
74
|
-
if tonumber(ARGV[5]) and monthly + estimated > tonumber(ARGV[5]) then return -3 end
|
|
75
|
-
if tonumber(ARGV[6]) and lifetime + estimated > tonumber(ARGV[6]) then return -4 end
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
if tonumber(ARGV[
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
if tonumber(ARGV[
|
|
84
|
-
if tonumber(ARGV[
|
|
85
|
-
|
|
86
|
-
redis.call('
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
*
|
|
95
|
-
*
|
|
96
|
-
*
|
|
97
|
-
*
|
|
98
|
-
*
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
local
|
|
105
|
-
|
|
106
|
-
if
|
|
107
|
-
local
|
|
108
|
-
local
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
end
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
local value = tonumber(redis.call('GET',
|
|
130
|
-
if not value or value < 1 or value ~= math.floor(value) then return -99 end
|
|
131
|
-
end
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
if
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
}
|
|
1
|
+
import type { AdmissionRejectionReason } from "./contracts";
|
|
2
|
+
import { reasonToGatewayError } from "./reasons";
|
|
3
|
+
import {
|
|
4
|
+
CONCURRENCY_TTL_SECONDS,
|
|
5
|
+
DAILY_COUNTER_TTL_SECONDS,
|
|
6
|
+
LIFETIME_COUNTER_TTL_SECONDS,
|
|
7
|
+
MONTHLY_COUNTER_TTL_SECONDS,
|
|
8
|
+
RPM_WINDOW_TTL_SECONDS,
|
|
9
|
+
} from "./ttl";
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* The three atomic scripts every Redis admission operation runs.
|
|
13
|
+
*
|
|
14
|
+
* They live apart from the store that calls them because they are the
|
|
15
|
+
* enforcement itself: a limit check and its counter write must be one
|
|
16
|
+
* indivisible step, which is why the logic is Lua rather than TypeScript.
|
|
17
|
+
* Reading them next to each other is the only way to see that reserve,
|
|
18
|
+
* reconcile, and release agree on the lease hash fields.
|
|
19
|
+
*/
|
|
20
|
+
/**
|
|
21
|
+
* RESERVE — the atomic admission algorithm. Runs as one Redis Lua script so
|
|
22
|
+
* every limit is validated and the reservation created in a single atomic
|
|
23
|
+
* step: no read-modify-write race can let two requests through the last
|
|
24
|
+
* remaining slot, and no partially applied reservation can survive (all
|
|
25
|
+
* counter writes + the lease hash commit together or not at all).
|
|
26
|
+
*
|
|
27
|
+
* Idempotency: an existing lease hash in any state short-circuits to `1`
|
|
28
|
+
* (already reserved) so a retried admission cannot double-charge counters.
|
|
29
|
+
*
|
|
30
|
+
* Limit model — rate limits vs concurrency:
|
|
31
|
+
* - RPM (KEYS[1], a ZSET of timestamps): sliding 60s window, pruned with
|
|
32
|
+
* ZREMRANGEBYSCORE before counting. Admission is count-based, not
|
|
33
|
+
* token-based.
|
|
34
|
+
* - Daily / monthly / lifetime token budgets (KEYS[2]/[3]/[5]): the
|
|
35
|
+
* *estimated* token count is pre-credited at reserve time and reconciled
|
|
36
|
+
* to actual usage later (RECONCILE_SCRIPT), so a request that would exceed
|
|
37
|
+
* its budget is rejected before dispatch, never after.
|
|
38
|
+
* - Concurrency (KEYS[4], per API key) and tenant concurrency (KEYS[7]) are
|
|
39
|
+
* classic INCR/DECR slots held for the request duration; the lease hash
|
|
40
|
+
* (KEYS[6]) records which counters each reservation tracks so release
|
|
41
|
+
* reverses exactly those.
|
|
42
|
+
*
|
|
43
|
+
* Key naming: `admission:<kind>[:<apiKeyId>|<tenantId>][:<bucket>]` —
|
|
44
|
+
* `admission:rpm:<key>`, `admission:daily:<key>:<YYYY-MM-DD>`,
|
|
45
|
+
* `admission:monthly:<key>:<YYYY-MM>`, `admission:lifetime:<key>`,
|
|
46
|
+
* `admission:concurrent:<key>`, `admission:tenant_concurrent:<tenant>`,
|
|
47
|
+
* `admission:lease:<reservationId>`. All counters carry TTLs (roughly one
|
|
48
|
+
* bucket + slack) so a process death cannot leak counters forever.
|
|
49
|
+
*
|
|
50
|
+
* Return codes: `0` reserved; `1` idempotent replay; `-1..-6` limit
|
|
51
|
+
* rejections (mapped to GatewayErrors by `assertResult`); `-99` corrupt
|
|
52
|
+
* counter state (never bypassed — admission fails closed).
|
|
53
|
+
*/
|
|
54
|
+
export const RESERVE_SCRIPT = `
|
|
55
|
+
local state = redis.call('HGET', KEYS[6], 'state')
|
|
56
|
+
if state == 'active' or state == 'committed' or state == 'released' then return 1 end
|
|
57
|
+
redis.call('ZREMRANGEBYSCORE', KEYS[1], '-inf', tonumber(ARGV[1]) - 60000)
|
|
58
|
+
local rpm = redis.call('ZCARD', KEYS[1])
|
|
59
|
+
local daily = tonumber(redis.call('GET', KEYS[2]) or '0')
|
|
60
|
+
local monthly = tonumber(redis.call('GET', KEYS[3]) or '0')
|
|
61
|
+
local concurrent = tonumber(redis.call('GET', KEYS[4]) or '0')
|
|
62
|
+
local tenantConcurrent = tonumber(redis.call('GET', KEYS[7]) or '0')
|
|
63
|
+
local lifetime = 0
|
|
64
|
+
if tonumber(ARGV[6]) then
|
|
65
|
+
local rawLifetime = redis.call('GET', KEYS[5])
|
|
66
|
+
lifetime = rawLifetime and tonumber(rawLifetime) or tonumber(ARGV[9])
|
|
67
|
+
if not lifetime then return -99 end
|
|
68
|
+
if not rawLifetime then redis.call('SET', KEYS[5], lifetime, 'EX', ${LIFETIME_COUNTER_TTL_SECONDS}) end
|
|
69
|
+
end
|
|
70
|
+
local estimated = tonumber(ARGV[2])
|
|
71
|
+
if not estimated or estimated < 0 then return -99 end
|
|
72
|
+
if tonumber(ARGV[3]) and rpm >= tonumber(ARGV[3]) then return -1 end
|
|
73
|
+
if tonumber(ARGV[4]) and daily + estimated > tonumber(ARGV[4]) then return -2 end
|
|
74
|
+
if tonumber(ARGV[5]) and monthly + estimated > tonumber(ARGV[5]) then return -3 end
|
|
75
|
+
if tonumber(ARGV[6]) and lifetime + estimated > tonumber(ARGV[6]) then return -4 end
|
|
76
|
+
-- ARGV[15] is the tolerance: slack above the configured limit, so the ceiling
|
|
77
|
+
-- is the sum. It widens admission only; already-admitted requests are never
|
|
78
|
+
-- revisited.
|
|
79
|
+
if tonumber(ARGV[7]) and concurrent >= tonumber(ARGV[7]) + (tonumber(ARGV[15]) or 0) then return -5 end
|
|
80
|
+
if tonumber(ARGV[10]) and tenantConcurrent >= tonumber(ARGV[10]) then return -6 end
|
|
81
|
+
redis.call('ZADD', KEYS[1], ARGV[1], ARGV[8])
|
|
82
|
+
redis.call('EXPIRE', KEYS[1], ${RPM_WINDOW_TTL_SECONDS})
|
|
83
|
+
if tonumber(ARGV[4]) then redis.call('INCRBY', KEYS[2], estimated); redis.call('EXPIRE', KEYS[2], ${DAILY_COUNTER_TTL_SECONDS}) end
|
|
84
|
+
if tonumber(ARGV[5]) then redis.call('INCRBY', KEYS[3], estimated); redis.call('EXPIRE', KEYS[3], ${MONTHLY_COUNTER_TTL_SECONDS}) end
|
|
85
|
+
if tonumber(ARGV[6]) then redis.call('INCRBY', KEYS[5], estimated); redis.call('EXPIRE', KEYS[5], ${LIFETIME_COUNTER_TTL_SECONDS}) end
|
|
86
|
+
if tonumber(ARGV[7]) then redis.call('INCR', KEYS[4]); redis.call('EXPIRE', KEYS[4], ${CONCURRENCY_TTL_SECONDS}) end
|
|
87
|
+
if tonumber(ARGV[10]) then redis.call('INCR', KEYS[7]); redis.call('EXPIRE', KEYS[7], ${CONCURRENCY_TTL_SECONDS}) end
|
|
88
|
+
redis.call('HSET', KEYS[6], 'state', 'active', 'reserved', estimated, 'daily', tonumber(ARGV[4]) and KEYS[2] or '', 'monthly', tonumber(ARGV[5]) and KEYS[3] or '', 'lifetime', tonumber(ARGV[6]) and KEYS[5] or '', 'concurrent', tonumber(ARGV[7]) and '1' or '0', 'concurrent_key', tonumber(ARGV[7]) and KEYS[4] or '', 'tenant_concurrent', tonumber(ARGV[10]) and '1' or '0', 'tenant_id', ARGV[11], 'api_key_id', ARGV[12], 'expires_at', tonumber(ARGV[1]) + tonumber(ARGV[13]))
|
|
89
|
+
redis.call('EXPIRE', KEYS[6], tonumber(ARGV[14]))
|
|
90
|
+
return 0
|
|
91
|
+
`;
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* RECONCILE — replaces the reservation's estimated token credit with actual
|
|
95
|
+
* provider usage, atomically. Runs only on an `active` lease; reads which
|
|
96
|
+
* counters the reservation credited from the lease hash, computes
|
|
97
|
+
* `delta = actual - reserved`, validates the resulting counter values (no
|
|
98
|
+
* negative drift), applies them, and decrements the held concurrency slots.
|
|
99
|
+
* `actual` may be less than `reserved` (over-estimate refunded) or more
|
|
100
|
+
* (under-estimate charged). Marks the lease `committed` — a second reconcile
|
|
101
|
+
* or release is an idempotent no-op (`1`).
|
|
102
|
+
*/
|
|
103
|
+
export const RECONCILE_SCRIPT = `
|
|
104
|
+
local state = redis.call('HGET', KEYS[1], 'state')
|
|
105
|
+
if state == 'committed' or state == 'released' then return 1 end
|
|
106
|
+
if state ~= 'active' then return -99 end
|
|
107
|
+
local reserved = tonumber(redis.call('HGET', KEYS[1], 'reserved'))
|
|
108
|
+
local actual = tonumber(ARGV[1])
|
|
109
|
+
if not reserved or not actual or reserved < 0 or actual < 0 or reserved ~= math.floor(reserved) or actual ~= math.floor(actual) then return -99 end
|
|
110
|
+
local delta = actual - reserved
|
|
111
|
+
local updates = {}
|
|
112
|
+
for _, field in ipairs({'daily', 'monthly', 'lifetime'}) do
|
|
113
|
+
local key = redis.call('HGET', KEYS[1], field)
|
|
114
|
+
if key and key ~= '' then
|
|
115
|
+
local value = tonumber(redis.call('GET', key))
|
|
116
|
+
if not value or value < 0 or value + delta < 0 or value + delta ~= math.floor(value + delta) then return -99 end
|
|
117
|
+
table.insert(updates, key)
|
|
118
|
+
end
|
|
119
|
+
end
|
|
120
|
+
-- The key that was CHARGED, not one rebuilt from the caller's identity: a share
|
|
121
|
+
-- child reserves under its own id while the lease is recorded against its
|
|
122
|
+
-- parent, so rebuilding from ARGV incremented one counter and decremented
|
|
123
|
+
-- another. The child's count never fell and the key went permanently over its
|
|
124
|
+
-- limit. Fall back to ARGV only for leases written before this field existed.
|
|
125
|
+
local concurrentKey = redis.call('HGET', KEYS[1], 'concurrent_key')
|
|
126
|
+
if not concurrentKey or concurrentKey == '' then concurrentKey = 'admission:concurrent:' .. ARGV[2] end
|
|
127
|
+
local hasConcurrency = redis.call('HGET', KEYS[1], 'concurrent') == '1'
|
|
128
|
+
if hasConcurrency then
|
|
129
|
+
local value = tonumber(redis.call('GET', concurrentKey))
|
|
130
|
+
if not value or value < 1 or value ~= math.floor(value) then return -99 end
|
|
131
|
+
end
|
|
132
|
+
local tenantKey = ''
|
|
133
|
+
local hasTenantConcurrency = redis.call('HGET', KEYS[1], 'tenant_concurrent') == '1'
|
|
134
|
+
if hasTenantConcurrency then
|
|
135
|
+
local tenantId = redis.call('HGET', KEYS[1], 'tenant_id')
|
|
136
|
+
if not tenantId or tenantId == '' then return -99 end
|
|
137
|
+
tenantKey = 'admission:tenant_concurrent:' .. tenantId
|
|
138
|
+
local value = tonumber(redis.call('GET', tenantKey))
|
|
139
|
+
if not value or value < 1 or value ~= math.floor(value) then return -99 end
|
|
140
|
+
end
|
|
141
|
+
-- INCRBY, never SET: a plain SET replaces the key and drops its TTL, so the
|
|
142
|
+
-- first reconcile after admission would strip the bucket's expiry and the
|
|
143
|
+
-- counter would outlive its window forever. INCRBY adjusts the value in place
|
|
144
|
+
-- and leaves the TTL intact. (KEEPTTL would also work but is unavailable on
|
|
145
|
+
-- the oldest Redis this is documented against.)
|
|
146
|
+
for _, key in ipairs(updates) do redis.call('INCRBY', key, delta) end
|
|
147
|
+
if hasConcurrency then redis.call('DECR', concurrentKey) end
|
|
148
|
+
if hasTenantConcurrency then redis.call('DECR', tenantKey) end
|
|
149
|
+
redis.call('HSET', KEYS[1], 'state', 'committed', 'actual', actual)
|
|
150
|
+
return 0
|
|
151
|
+
`;
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* RELEASE — reverses an uncommitted reservation exactly once: refunds the
|
|
155
|
+
* pre-credited token estimates, decrements the held concurrency and tenant
|
|
156
|
+
* slots, and marks the lease `released` (subsequent release/reconcile
|
|
157
|
+
* returns `1`). Unlike the normal release path, crash-recovery releases
|
|
158
|
+
* (`releaseAdmissionLease` / the sweeper) run this same script keyed only by
|
|
159
|
+
* the lease hash; concurrency keys are rebuilt from `admission:concurrent:`
|
|
160
|
+
* + the lease's `api_key_id` / `tenant_id` fields, so recovery never needs
|
|
161
|
+
* the caller to remember which counters were held.
|
|
162
|
+
*/
|
|
163
|
+
export const RELEASE_SCRIPT = `
|
|
164
|
+
local state = redis.call('HGET', KEYS[1], 'state')
|
|
165
|
+
if state == 'released' or state == 'committed' then return 1 end
|
|
166
|
+
if state ~= 'active' then return -99 end
|
|
167
|
+
local reserved = tonumber(redis.call('HGET', KEYS[1], 'reserved'))
|
|
168
|
+
if not reserved or reserved < 0 then return -99 end
|
|
169
|
+
for _, field in ipairs({'daily', 'monthly', 'lifetime'}) do local key = redis.call('HGET', KEYS[1], field); if key and key ~= '' then local value = tonumber(redis.call('GET', key) or '0'); if not value or value < reserved then return -99 end; redis.call('DECRBY', key, reserved) end end
|
|
170
|
+
if redis.call('HGET', KEYS[1], 'concurrent') == '1' then local key = redis.call('HGET', KEYS[1], 'concurrent_key'); if not key or key == '' then key = 'admission:concurrent:' .. ARGV[1] end; local value = tonumber(redis.call('GET', key) or '0'); if not value or value < 1 then return -99 end; redis.call('DECR', key) end
|
|
171
|
+
if redis.call('HGET', KEYS[1], 'tenant_concurrent') == '1' then local tenantId = redis.call('HGET', KEYS[1], 'tenant_id'); if not tenantId or tenantId == '' then return -99 end; local key = 'admission:tenant_concurrent:' .. tenantId; local value = tonumber(redis.call('GET', key) or '0'); if not value or value < 1 then return -99 end; redis.call('DECR', key) end
|
|
172
|
+
redis.call('HSET', KEYS[1], 'state', 'released')
|
|
173
|
+
return 0
|
|
174
|
+
`;
|
|
175
|
+
const RESULT_REASON: Record<number, AdmissionRejectionReason> = {
|
|
176
|
+
[-1]: "rpm-exhausted",
|
|
177
|
+
[-2]: "daily-token-limit",
|
|
178
|
+
[-3]: "monthly-token-limit",
|
|
179
|
+
[-4]: "lifetime-token-budget",
|
|
180
|
+
[-5]: "concurrency-limit",
|
|
181
|
+
[-6]: "tenant-capacity-exhausted",
|
|
182
|
+
};
|
|
183
|
+
|
|
184
|
+
export function assertResult(result: number, operation: string): void {
|
|
185
|
+
if (result === -99) throw new Error(`corrupt admission counter during ${operation}`);
|
|
186
|
+
const reason = RESULT_REASON[result];
|
|
187
|
+
if (reason) {
|
|
188
|
+
throw reasonToGatewayError(reason, { operation });
|
|
189
|
+
}
|
|
190
|
+
if (result < 0) throw new Error(`admission rejected during ${operation}: ${result}`);
|
|
191
|
+
}
|
|
@@ -61,6 +61,7 @@ export class RedisAdmissionCounterStore implements AdmissionCounterStore {
|
|
|
61
61
|
request.apiKeyId,
|
|
62
62
|
String(LEASE_TTL_MS),
|
|
63
63
|
String(LEASE_KEY_TTL_SECONDS),
|
|
64
|
+
String(Math.max(0, request.concurrencyTolerance ?? 0)),
|
|
64
65
|
);
|
|
65
66
|
assertResult(result, "reserve");
|
|
66
67
|
}
|
|
@@ -142,11 +143,20 @@ export class RedisAdmissionCounterStore implements AdmissionCounterStore {
|
|
|
142
143
|
* Drops every counter for one key (revocation). Active in-flight leases
|
|
143
144
|
* settle through the release script first so their concurrency slots do
|
|
144
145
|
* not leak; remaining lease rows expire via TTL. Best-effort by contract.
|
|
146
|
+
*
|
|
147
|
+
* Bounded per invocation: a keyspace that has grown for months cannot turn
|
|
148
|
+
* one revocation into an unbounded walk — lease-scan pages, leases released,
|
|
149
|
+
* and counter-scan pages each stop cleanly at their budget, and the rest
|
|
150
|
+
* ages out via TTL (the fallback this contract already relies on). A later
|
|
151
|
+
* revocation or the lease sweeper picks up what a budgeted pass left.
|
|
145
152
|
*/
|
|
146
153
|
async purge(apiKeyId: string): Promise<void> {
|
|
147
154
|
try {
|
|
155
|
+
let leaseScanPages = 0;
|
|
156
|
+
let leasesReleased = 0;
|
|
148
157
|
let cursor = "0";
|
|
149
158
|
do {
|
|
159
|
+
if (leaseScanPages >= MAX_PURGE_LEASE_SCAN_PAGES) break;
|
|
150
160
|
const [nextCursor, keys] = await this.redis.scan(
|
|
151
161
|
cursor,
|
|
152
162
|
"MATCH",
|
|
@@ -155,6 +165,7 @@ export class RedisAdmissionCounterStore implements AdmissionCounterStore {
|
|
|
155
165
|
100,
|
|
156
166
|
);
|
|
157
167
|
cursor = String(nextCursor);
|
|
168
|
+
leaseScanPages += 1;
|
|
158
169
|
const leaseKeys = keys as string[];
|
|
159
170
|
if (leaseKeys.length === 0) continue;
|
|
160
171
|
// One pipeline for the whole scan page instead of an HGET per lease:
|
|
@@ -172,15 +183,22 @@ export class RedisAdmissionCounterStore implements AdmissionCounterStore {
|
|
|
172
183
|
return Array.isArray(entry) && entry[1] === apiKeyId;
|
|
173
184
|
});
|
|
174
185
|
for (const leaseKey of owned) {
|
|
186
|
+
if (leasesReleased >= MAX_PURGE_LEASES_RELEASED) break;
|
|
175
187
|
await redisEvalNumber(this.redis, RELEASE_SCRIPT, 1, leaseKey, apiKeyId).catch(
|
|
176
188
|
() => -1,
|
|
177
189
|
);
|
|
190
|
+
leasesReleased += 1;
|
|
178
191
|
}
|
|
192
|
+
// Releasing is the point of the lease phase; once its budget is spent
|
|
193
|
+
// there is nothing more this pass can usefully do with further pages.
|
|
194
|
+
if (leasesReleased >= MAX_PURGE_LEASES_RELEASED) break;
|
|
179
195
|
} while (cursor !== "0");
|
|
196
|
+
let counterScanPages = 0;
|
|
180
197
|
let counterCursor = "0";
|
|
181
198
|
const patterns = [`admission:*:${apiKeyId}`, `admission:*:${apiKeyId}:*`];
|
|
182
199
|
for (const pattern of patterns) {
|
|
183
200
|
do {
|
|
201
|
+
if (counterScanPages >= MAX_PURGE_COUNTER_SCAN_PAGES) break;
|
|
184
202
|
const [nextCursor, keys] = await this.redis.scan(
|
|
185
203
|
counterCursor,
|
|
186
204
|
"MATCH",
|
|
@@ -189,8 +207,10 @@ export class RedisAdmissionCounterStore implements AdmissionCounterStore {
|
|
|
189
207
|
100,
|
|
190
208
|
);
|
|
191
209
|
counterCursor = String(nextCursor);
|
|
210
|
+
counterScanPages += 1;
|
|
192
211
|
if ((keys as string[]).length > 0) await this.redis.del(...(keys as string[]));
|
|
193
212
|
} while (counterCursor !== "0");
|
|
213
|
+
if (counterScanPages >= MAX_PURGE_COUNTER_SCAN_PAGES) break;
|
|
194
214
|
}
|
|
195
215
|
} catch {
|
|
196
216
|
// Best-effort: revocation already succeeded; stale counters expire via TTL.
|
|
@@ -198,6 +218,16 @@ export class RedisAdmissionCounterStore implements AdmissionCounterStore {
|
|
|
198
218
|
}
|
|
199
219
|
}
|
|
200
220
|
|
|
221
|
+
/**
|
|
222
|
+
* Per-invocation purge budgets. Chosen so one full-budgeted pass still costs
|
|
223
|
+
* well under a second of Redis time (hundreds of small commands), while a
|
|
224
|
+
* normal revocation — tens of leases, a handful of counters — never notices
|
|
225
|
+
* them. Mirrors the lease sweeper's own bounded-pass philosophy.
|
|
226
|
+
*/
|
|
227
|
+
export const MAX_PURGE_LEASE_SCAN_PAGES = 40;
|
|
228
|
+
export const MAX_PURGE_LEASES_RELEASED = 100;
|
|
229
|
+
export const MAX_PURGE_COUNTER_SCAN_PAGES = 40;
|
|
230
|
+
|
|
201
231
|
/**
|
|
202
232
|
* Releases a lease identified only by its Redis key (used by the sweeper).
|
|
203
233
|
* Reads the owning api key id from the lease hash and runs RELEASE_SCRIPT,
|
|
@@ -35,6 +35,14 @@ export class ApiKeyAdmissionService {
|
|
|
35
35
|
) => Promise<number | null> | number | null = () => null,
|
|
36
36
|
private readonly persistLifetimeUsage?: LifetimeUsagePersister,
|
|
37
37
|
private readonly freshLifetimeConsumed?: FreshLifetimeConsumedReader,
|
|
38
|
+
/**
|
|
39
|
+
* Hidden allowance above each key's concurrency limit. Injected rather
|
|
40
|
+
* than read from the snapshot so the number is operator configuration,
|
|
41
|
+
* not something a key can carry about itself.
|
|
42
|
+
*/
|
|
43
|
+
private readonly concurrencyTolerance?: (
|
|
44
|
+
tenantId: string,
|
|
45
|
+
) => Promise<number | null> | number | null,
|
|
38
46
|
) {
|
|
39
47
|
if (!store) throw new Error("ApiKeyAdmissionService requires an admission store");
|
|
40
48
|
}
|
|
@@ -96,6 +104,15 @@ export class ApiKeyAdmissionService {
|
|
|
96
104
|
const reservationId = `${apiKeyId}:${now}:${crypto.randomUUID()}`;
|
|
97
105
|
const rawTenantLimit = this.tenantConcurrencyProvider(snapshot.tenant_id);
|
|
98
106
|
const tenantLimit = rawTenantLimit instanceof Promise ? await rawTenantLimit : rawTenantLimit;
|
|
107
|
+
/*
|
|
108
|
+
* One number, one place. The allowance used to be overridable per key,
|
|
109
|
+
* so the tenant-wide value was only the answer for a key that had never
|
|
110
|
+
* been given one — an operator who raised it in Settings could not tell
|
|
111
|
+
* why one key still refused early, because the override lived in a modal
|
|
112
|
+
* the Settings page does not show. It is tenant-wide only now.
|
|
113
|
+
*/
|
|
114
|
+
const rawTolerance = this.concurrencyTolerance?.(snapshot.tenant_id);
|
|
115
|
+
const tolerance = rawTolerance instanceof Promise ? await rawTolerance : rawTolerance;
|
|
99
116
|
|
|
100
117
|
try {
|
|
101
118
|
await this.store.reserve({
|
|
@@ -113,6 +130,7 @@ export class ApiKeyAdmissionService {
|
|
|
113
130
|
lifetimeBudget: snapshot.lifetime_token_budget ?? null,
|
|
114
131
|
lifetimeConsumed: snapshot.lifetime_tokens_consumed ?? 0,
|
|
115
132
|
concurrencyLimit: snapshot.max_concurrent ?? null,
|
|
133
|
+
concurrencyTolerance: tolerance ?? null,
|
|
116
134
|
tenantId: snapshot.tenant_id,
|
|
117
135
|
tenantConcurrencyLimit: tenantLimit,
|
|
118
136
|
// Fresh lifetime reader is invoked by the store only when the counter
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
/** Bounded process-local authorization cache; shared-cache I/O stays in the auth service. */
|
|
2
|
+
export const AUTH_CACHE_TTL_MS = 3_000;
|
|
3
|
+
const AUTH_CACHE_MAX_ENTRIES = 10_000;
|
|
4
|
+
const AUTH_NEGATIVE_CACHE_TTL_MS = 1_500;
|
|
5
|
+
const AUTH_FAILURE_COOLDOWN_MS = 1_000;
|
|
6
|
+
|
|
7
|
+
interface CacheEntry<T> {
|
|
8
|
+
readonly at: number;
|
|
9
|
+
readonly value: T;
|
|
10
|
+
readonly keyId: string;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
type Lookup<T> =
|
|
14
|
+
| { readonly kind: "positive"; readonly value: T }
|
|
15
|
+
| { readonly kind: "negative" }
|
|
16
|
+
| { readonly kind: "miss" };
|
|
17
|
+
|
|
18
|
+
/** Create the process-local positive/negative authorization cache. */
|
|
19
|
+
export function createApiKeyAuthorizationCache<T>() {
|
|
20
|
+
const entries = new Map<string, CacheEntry<T>>();
|
|
21
|
+
const hashesByKeyId = new Map<string, Set<string>>();
|
|
22
|
+
const negativeEntries = new Map<string, number>();
|
|
23
|
+
let failureUntil = 0;
|
|
24
|
+
|
|
25
|
+
const evictOldest = (): void => {
|
|
26
|
+
while (entries.size > AUTH_CACHE_MAX_ENTRIES) {
|
|
27
|
+
const oldest = entries.keys().next().value;
|
|
28
|
+
if (oldest === undefined) return;
|
|
29
|
+
const entry = entries.get(oldest);
|
|
30
|
+
entries.delete(oldest);
|
|
31
|
+
if (entry === undefined) continue;
|
|
32
|
+
const hashes = hashesByKeyId.get(entry.keyId);
|
|
33
|
+
if (hashes === undefined) continue;
|
|
34
|
+
hashes.delete(oldest);
|
|
35
|
+
if (hashes.size === 0) hashesByKeyId.delete(entry.keyId);
|
|
36
|
+
}
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
const evictNegativeOldest = (): void => {
|
|
40
|
+
while (negativeEntries.size > AUTH_CACHE_MAX_ENTRIES) {
|
|
41
|
+
const oldest = negativeEntries.keys().next().value;
|
|
42
|
+
if (oldest === undefined) return;
|
|
43
|
+
negativeEntries.delete(oldest);
|
|
44
|
+
}
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
return {
|
|
48
|
+
lookup(hash: string, now: number): Lookup<T> {
|
|
49
|
+
const negativeAt = negativeEntries.get(hash);
|
|
50
|
+
if (negativeAt !== undefined) {
|
|
51
|
+
if (now - negativeAt < AUTH_NEGATIVE_CACHE_TTL_MS) return { kind: "negative" };
|
|
52
|
+
negativeEntries.delete(hash);
|
|
53
|
+
}
|
|
54
|
+
const cached = entries.get(hash);
|
|
55
|
+
if (cached === undefined || now - cached.at >= AUTH_CACHE_TTL_MS) return { kind: "miss" };
|
|
56
|
+
entries.delete(hash);
|
|
57
|
+
entries.set(hash, cached);
|
|
58
|
+
return { kind: "positive", value: cached.value };
|
|
59
|
+
},
|
|
60
|
+
|
|
61
|
+
rememberPositive(hash: string, keyId: string, value: T, now: number): void {
|
|
62
|
+
entries.set(hash, { at: now, value, keyId });
|
|
63
|
+
negativeEntries.delete(hash);
|
|
64
|
+
const hashes = hashesByKeyId.get(keyId) ?? new Set<string>();
|
|
65
|
+
hashes.add(hash);
|
|
66
|
+
hashesByKeyId.set(keyId, hashes);
|
|
67
|
+
evictOldest();
|
|
68
|
+
},
|
|
69
|
+
|
|
70
|
+
rememberNegative(hash: string, now: number): void {
|
|
71
|
+
negativeEntries.set(hash, now);
|
|
72
|
+
evictNegativeOldest();
|
|
73
|
+
},
|
|
74
|
+
|
|
75
|
+
invalidate(keyId?: string): void {
|
|
76
|
+
negativeEntries.clear();
|
|
77
|
+
if (keyId === undefined) {
|
|
78
|
+
entries.clear();
|
|
79
|
+
hashesByKeyId.clear();
|
|
80
|
+
return;
|
|
81
|
+
}
|
|
82
|
+
const hashes = hashesByKeyId.get(keyId);
|
|
83
|
+
if (hashes === undefined) return;
|
|
84
|
+
for (const hash of hashes) entries.delete(hash);
|
|
85
|
+
hashesByKeyId.delete(keyId);
|
|
86
|
+
},
|
|
87
|
+
|
|
88
|
+
markStoreUnavailable(now: number): void {
|
|
89
|
+
failureUntil = now + AUTH_FAILURE_COOLDOWN_MS;
|
|
90
|
+
},
|
|
91
|
+
|
|
92
|
+
isStoreUnavailable(now: number): boolean {
|
|
93
|
+
return now < failureUntil;
|
|
94
|
+
},
|
|
95
|
+
};
|
|
96
|
+
}
|