cartethyia 2.5.10 → 2.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.env.example +8 -0
- package/dist/dashboard/assets/{BannedPage-Cd30Z0b9.js → BannedPage-l6XMnfr9.js} +1 -1
- package/dist/dashboard/assets/BannedPage-l6XMnfr9.js.gz +0 -0
- package/dist/dashboard/assets/{CliToolDetailPage-wf5u-ww6.js → CliToolDetailPage-DwOzAW45.js} +1 -1
- package/dist/dashboard/assets/CliToolDetailPage-DwOzAW45.js.gz +0 -0
- package/dist/dashboard/assets/{CliToolsPage-DykPvuck.js → CliToolsPage-BT3Up3QA.js} +1 -1
- package/dist/dashboard/assets/CliToolsPage-BT3Up3QA.js.gz +0 -0
- package/dist/dashboard/assets/{CombosPage-DwvSD3vs.js → CombosPage-6kYIUpzR.js} +1 -1
- package/dist/dashboard/assets/CombosPage-6kYIUpzR.js.gz +0 -0
- package/dist/dashboard/assets/{ConsoleLogPage-CIr7KDex.js → ConsoleLogPage-zRnj1mlR.js} +1 -1
- package/dist/dashboard/assets/ConsoleLogPage-zRnj1mlR.js.gz +0 -0
- package/dist/dashboard/assets/{CustomizationPage-82X0DDdH.js → CustomizationPage-C7m-H1Rz.js} +1 -1
- package/dist/dashboard/assets/CustomizationPage-C7m-H1Rz.js.gz +0 -0
- package/dist/dashboard/assets/HealthEventsModal-C8MR_BdB.js +1 -0
- package/dist/dashboard/assets/HealthEventsModal-C8MR_BdB.js.gz +0 -0
- package/dist/dashboard/assets/{LoginPage-CS5mPuVF.js → LoginPage-CbphLvjg.js} +1 -1
- package/dist/dashboard/assets/LoginPage-CbphLvjg.js.gz +0 -0
- package/dist/dashboard/assets/{ModelPicker-C0UCUsVJ.js → ModelPicker-DVny4S5_.js} +1 -1
- package/dist/dashboard/assets/ModelPicker-DVny4S5_.js.gz +0 -0
- package/dist/dashboard/assets/OverviewPage-DABu2Wgt.js +1 -0
- package/dist/dashboard/assets/OverviewPage-DABu2Wgt.js.gz +0 -0
- package/dist/dashboard/assets/ProviderDetailPage-C15x9q13.js +3 -0
- package/dist/dashboard/assets/ProviderDetailPage-C15x9q13.js.gz +0 -0
- package/dist/dashboard/assets/{ProvidersPage-vdja5ZPK.js → ProvidersPage-Dfb2896L.js} +1 -1
- package/dist/dashboard/assets/ProvidersPage-Dfb2896L.js.gz +0 -0
- package/dist/dashboard/assets/{ProxyPage-BB4CNm4-.js → ProxyPage-BaCIdAUt.js} +1 -1
- package/dist/dashboard/assets/ProxyPage-BaCIdAUt.js.gz +0 -0
- package/dist/dashboard/assets/QuotaPage-BsdoCIw1.js +1 -0
- package/dist/dashboard/assets/QuotaPage-BsdoCIw1.js.gz +0 -0
- package/dist/dashboard/assets/SettingsPage-KVJFh3Ip.js +1 -0
- package/dist/dashboard/assets/SettingsPage-KVJFh3Ip.js.gz +0 -0
- package/dist/dashboard/assets/{SetupPage-FadUOl9S.js → SetupPage-D4g6vOGc.js} +1 -1
- package/dist/dashboard/assets/SetupPage-D4g6vOGc.js.gz +0 -0
- package/dist/dashboard/assets/{SortableList-Cfy6_Khv.js → SortableList-C93i14BD.js} +1 -1
- package/dist/dashboard/assets/SortableList-C93i14BD.js.gz +0 -0
- package/dist/dashboard/assets/{StudioPage-CM92fgJf.js → StudioPage-SgQpymjm.js} +1 -1
- package/dist/dashboard/assets/{StudioPage-CM92fgJf.js.gz → StudioPage-SgQpymjm.js.gz} +0 -0
- package/dist/dashboard/assets/{UsagePage-BT4MyQeT.js → UsagePage-BFME-eba.js} +2 -2
- package/dist/dashboard/assets/UsagePage-BFME-eba.js.gz +0 -0
- package/dist/dashboard/assets/{api-keys-DQYLQxpW.js → api-keys-C23S9sgp.js} +1 -1
- package/dist/dashboard/assets/api-keys-C23S9sgp.js.gz +0 -0
- package/dist/dashboard/assets/{app-B7pVRhJ1.js → app-2KhtHCv9.js} +1 -1
- package/dist/dashboard/assets/{app-B7pVRhJ1.js.gz → app-2KhtHCv9.js.gz} +0 -0
- package/dist/dashboard/assets/app-CEeaMHOy.css +2 -0
- package/dist/dashboard/assets/app-CEeaMHOy.css.gz +0 -0
- package/dist/dashboard/assets/{assert-C9FD_JwJ.js → assert-DU6qrMA_.js} +1 -1
- package/dist/dashboard/assets/assert-DU6qrMA_.js.gz +0 -0
- package/dist/dashboard/assets/{badge-Bvyr7D0u.js → badge-CKsF123d.js} +1 -1
- package/dist/dashboard/assets/badge-CKsF123d.js.gz +0 -0
- package/dist/dashboard/assets/{banner-DIvKAZih.js → banner-8j7uChA9.js} +1 -1
- package/dist/dashboard/assets/banner-8j7uChA9.js.gz +0 -0
- package/dist/dashboard/assets/{card-D5_gYIS_.js → card-CdsSlWA3.js} +1 -1
- package/dist/dashboard/assets/card-CdsSlWA3.js.gz +0 -0
- package/dist/dashboard/assets/{cli-tools-CNqzrfdb.js → cli-tools-BaFUuFFF.js} +1 -1
- package/dist/dashboard/assets/cli-tools-BaFUuFFF.js.gz +0 -0
- package/dist/dashboard/assets/{clipboard-button-B6vDBtbm.js → clipboard-button-uL5aYLfW.js} +1 -1
- package/dist/dashboard/assets/clipboard-button-uL5aYLfW.js.gz +0 -0
- package/dist/dashboard/assets/{dialog-CpjrgdwJ.js → dialog-C07hr5_G.js} +1 -1
- package/dist/dashboard/assets/dialog-C07hr5_G.js.gz +0 -0
- package/dist/dashboard/assets/{drawer-BWmt3b5y.js → drawer-BPO1TcKH.js} +1 -1
- package/dist/dashboard/assets/drawer-BPO1TcKH.js.gz +0 -0
- package/dist/dashboard/assets/{entry-C_oakV3z.js → entry-Bz_CtDfC.js} +1 -1
- package/dist/dashboard/assets/entry-Bz_CtDfC.js.gz +0 -0
- package/dist/dashboard/assets/entry-CwPRKk0d.js +4 -0
- package/dist/dashboard/assets/entry-CwPRKk0d.js.gz +0 -0
- package/dist/dashboard/assets/entry-GJRbmrpA.js +1 -0
- package/dist/dashboard/assets/entry-GJRbmrpA.js.gz +0 -0
- package/dist/dashboard/assets/{github-badge-RRHHWGxh.js → github-badge-DmQreSDd.js} +1 -1
- package/dist/dashboard/assets/github-badge-DmQreSDd.js.gz +0 -0
- package/dist/dashboard/assets/{index-Bhey_rg_.js → index-Bblu6erR.js} +2 -2
- package/dist/dashboard/assets/index-Bblu6erR.js.gz +0 -0
- package/dist/dashboard/assets/{inline-C2_EQ5Uf.js → inline-C9dCYaBZ.js} +1 -1
- package/dist/dashboard/assets/inline-C9dCYaBZ.js.gz +0 -0
- package/dist/dashboard/assets/{input-BMRlQ450.js → input-CjCnXweJ.js} +1 -1
- package/dist/dashboard/assets/input-CjCnXweJ.js.gz +0 -0
- package/dist/dashboard/assets/{layout-XR5xP8ew.js → layout-V6y8DAkl.js} +1 -1
- package/dist/dashboard/assets/layout-V6y8DAkl.js.gz +0 -0
- package/dist/dashboard/assets/{list-row-Cw07dioc.js → list-row-CM1AaG_y.js} +1 -1
- package/dist/dashboard/assets/list-row-CM1AaG_y.js.gz +0 -0
- package/dist/dashboard/assets/{live-BivGfuUs.js → live-ta6GMb-8.js} +1 -1
- package/dist/dashboard/assets/live-ta6GMb-8.js.gz +0 -0
- package/dist/dashboard/assets/{model-bans-CmffjLuG.js → model-bans-BmYTmbAk.js} +1 -1
- package/dist/dashboard/assets/model-bans-BmYTmbAk.js.gz +0 -0
- package/dist/dashboard/assets/{mutation-46P0oWMe.js → mutation-g_fduiNw.js} +1 -1
- package/dist/dashboard/assets/mutation-g_fduiNw.js.gz +0 -0
- package/dist/dashboard/assets/{numbers-DrdA9oSM.js → numbers-BOevJzdz.js} +1 -1
- package/dist/dashboard/assets/numbers-BOevJzdz.js.gz +0 -0
- package/dist/dashboard/assets/{query-keys-BFhGpwR1.js → query-keys-UETqfytp.js} +1 -1
- package/dist/dashboard/assets/query-keys-UETqfytp.js.gz +0 -0
- package/dist/dashboard/assets/{routing-CAb4F37R.js → routing-BPUo-W8d.js} +1 -1
- package/dist/dashboard/assets/routing-BPUo-W8d.js.gz +0 -0
- package/dist/dashboard/assets/{select-CxVVB__N.js → select-C980c4cz.js} +1 -1
- package/dist/dashboard/assets/select-C980c4cz.js.gz +0 -0
- package/dist/dashboard/assets/{settings-DNX9sydi.js → settings--3hxSyry.js} +1 -1
- package/dist/dashboard/assets/settings--3hxSyry.js.gz +0 -0
- package/dist/dashboard/assets/{stack-uPOwM9Hs.js → stack-_gfTdLVB.js} +1 -1
- package/dist/dashboard/assets/stack-_gfTdLVB.js.gz +0 -0
- package/dist/dashboard/assets/{state-D1Yz79iL.js → state-2c5V4zo7.js} +1 -1
- package/dist/dashboard/assets/state-2c5V4zo7.js.gz +0 -0
- package/dist/dashboard/assets/{switch-sevsRrRo.js → switch-Dkke7Naf.js} +1 -1
- package/dist/dashboard/assets/switch-Dkke7Naf.js.gz +0 -0
- package/dist/dashboard/assets/{useMutation-Dem0lXQj.js → useMutation-EDFRkn1i.js} +1 -1
- package/dist/dashboard/assets/useMutation-EDFRkn1i.js.gz +0 -0
- package/dist/dashboard/index.html +1 -1
- package/dist/dashboard/index.html.gz +0 -0
- package/dist/dashboard/precompressed-assets.json +51 -51
- package/migrations/README.md +52 -52
- package/migrations/full-postgres/0020_add_telemetry_event_analytics.sql +77 -0
- package/migrations/full-postgres/0021_add_api_key_concurrency_tolerance.sql +13 -0
- package/migrations/full-postgres/0022_add_inferhub_config_key.sql +17 -0
- package/migrations/full-postgres/0023_drop_api_key_concurrency_tolerance.sql +13 -0
- package/migrations/lite-sqlite3/0020_add_telemetry_event_analytics.sql +69 -0
- package/migrations/lite-sqlite3/0021_add_api_key_concurrency_tolerance.sql +13 -0
- package/migrations/lite-sqlite3/0022_add_inferhub_config_key.sql +17 -0
- package/migrations/lite-sqlite3/0023_drop_api_key_concurrency_tolerance.sql +13 -0
- package/package.json +96 -95
- package/scripts/cli/admin.ts +126 -128
- package/scripts/cli/autostart.ts +237 -237
- package/scripts/cli/background.ts +120 -110
- package/scripts/cli/bin/carte.cjs +8 -8
- package/scripts/cli/bin/cartethyia.cjs +167 -167
- package/scripts/cli/console-menu.ts +39 -30
- package/scripts/cli/controller.ts +145 -0
- package/scripts/cli/env.ts +99 -99
- package/scripts/cli/gateway.ts +264 -260
- package/scripts/cli/instance.ts +234 -224
- package/scripts/cli/main.ts +188 -167
- package/scripts/cli/platform.ts +64 -64
- package/scripts/cli/prompt.ts +217 -79
- package/scripts/cli/services.ts +88 -88
- package/scripts/cli/setup.ts +445 -445
- package/scripts/cli/start.ts +85 -85
- package/scripts/cli/startup-signal.ts +23 -23
- package/scripts/cli/telemetry.ts +70 -0
- package/scripts/cli/tray/index.ts +263 -263
- package/scripts/cli/tray/windows.ps1 +107 -107
- package/scripts/cli/tray.ts +29 -4
- package/scripts/cli/update.ts +39 -39
- package/scripts/cli/version.ts +67 -67
- package/scripts/cli/wordmark.ts +58 -58
- package/src/boot/app.ts +16 -1
- package/src/boot/dependencies.ts +128 -25
- package/src/config/resources.ts +137 -0
- package/src/config/runtime.ts +133 -76
- package/src/config/spec.ts +30 -29
- package/src/config/telemetry.ts +0 -25
- package/src/console/api-keys/bucket-spend.ts +14 -17
- package/src/console/api-keys/contracts.ts +410 -410
- package/src/console/api-keys/routes.ts +12 -0
- package/src/console/audit/store.ts +2 -3
- package/src/console/auth/setup-service.ts +8 -1
- package/src/console/backup/routes.ts +7 -0
- package/src/console/backup/service.ts +463 -463
- package/src/console/backup/store.ts +16 -11
- package/src/console/backup/validate.ts +1 -4
- package/src/console/cli-tools/fs-ops.ts +18 -2
- package/src/console/data-management/contracts.ts +1 -8
- package/src/console/data-management/operations.ts +61 -48
- package/src/console/data-management/routes.ts +4 -2
- package/src/console/domain-registration.ts +25 -3
- package/src/console/inferhub/client.ts +139 -0
- package/src/console/inferhub/key-store.ts +129 -0
- package/src/console/inferhub/normalize.ts +347 -0
- package/src/console/inferhub/routes.ts +411 -0
- package/src/console/inferhub/store.ts +109 -0
- package/src/console/inferhub/types.ts +183 -0
- package/src/console/observability/contracts.ts +2 -41
- package/src/console/observability/store.ts +176 -371
- package/src/console/providers/catalog/public-model-store.ts +0 -0
- package/src/console/providers/catalog/store.ts +23 -36
- package/src/console/quota/account-quota-shared.ts +52 -52
- package/src/console/quota/account-quota-tenant.ts +16 -24
- package/src/console/quota/refresh.ts +441 -441
- package/src/console/routing/pools/import-parse.ts +206 -206
- package/src/console/routing/pools/operations.ts +543 -543
- package/src/console/routing/pools/probe-verdict.ts +132 -132
- package/src/console/routing/pools/routes.ts +181 -181
- package/src/console/settings/contracts.ts +42 -40
- package/src/console/settings/store.ts +25 -25
- package/src/console/share/share-stats.ts +22 -4
- package/src/console/share/share-usage.ts +30 -4
- package/src/console/shared/cursor.ts +15 -0
- package/src/console/updates/routes.ts +12 -0
- package/src/main.ts +13 -5
- package/src/network/pool/resolver.ts +1104 -1104
- package/src/observability/client-name.ts +22 -0
- package/src/observability/event-log.ts +5 -29
- package/src/observability/event-query.ts +690 -0
- package/src/observability/log-ring.ts +26 -2
- package/src/observability/log-sanitize.ts +118 -0
- package/src/observability/logger.ts +17 -13
- package/src/observability/metrics.ts +35 -0
- package/src/observability/payload-capture.ts +114 -41
- package/src/observability/payload-store.ts +249 -24
- package/src/observability/raw-event-writer.ts +118 -0
- package/src/observability/runtime-metrics.ts +71 -1
- package/src/observability/telemetry-buffer.ts +100 -17
- package/src/observability/telemetry-reconciliation.ts +417 -0
- package/src/persistence/boot.ts +20 -9
- package/src/persistence/db-pg.ts +13 -0
- package/src/persistence/db-sqlite.ts +569 -497
- package/src/persistence/dialect.ts +3 -2
- package/src/persistence/migrate.ts +181 -181
- package/src/persistence/schema.ts +1496 -1406
- package/src/persistence/shared-cache.ts +5 -1
- package/src/persistence/stores/api-key-store.ts +581 -581
- package/src/persistence/stores/telemetry-store.ts +124 -63
- package/src/protocol/reasoning-text.ts +30 -0
- package/src/protocol/response/chat.ts +33 -7
- package/src/protocol/response/codex.ts +14 -2
- package/src/protocol/response/messages.ts +5 -2
- package/src/providers/authentication/oauth-flow-store.ts +3 -10
- package/src/providers/bundled-discovery-providers.ts +122 -122
- package/src/providers/default-registry.ts +10 -0
- package/src/providers/discovery/catalog/catalog-blob.ts +18 -18
- package/src/providers/discovery/models-dev-catalog.ts +354 -354
- package/src/providers/discovery/probe-phases.ts +43 -7
- package/src/providers/discovery/probing-service.ts +70 -32
- package/src/providers/integrations/antigravity/antigravity.ts +9 -2
- package/src/providers/integrations/buddy/buddy-catalog-shared.ts +85 -17
- package/src/providers/integrations/buddy/buddy-chat-shared.ts +934 -934
- package/src/providers/integrations/buddy/buddy-checkin.ts +5 -9
- package/src/providers/integrations/buddy/buddy-discovery-shared.ts +3 -7
- package/src/providers/integrations/buddy/buddy-quota-shared.ts +3 -1
- package/src/providers/integrations/buddy/codebuddy/cn-provider.ts +57 -49
- package/src/providers/integrations/buddy/codebuddy/provider.ts +180 -180
- package/src/providers/integrations/buddy/workbuddy/provider.ts +206 -206
- package/src/providers/integrations/buddy/workbuddy/quota.ts +161 -161
- package/src/providers/integrations/claude/claude-quota.ts +2 -2
- package/src/providers/integrations/claude/claude-user-profiles.ts +3 -2
- package/src/providers/integrations/claude/claude.ts +3 -4
- package/src/providers/integrations/cline/cline.ts +94 -8
- package/src/providers/integrations/codex/codex.ts +61 -37
- package/src/providers/integrations/cursor/cursor-catalog.ts +368 -368
- package/src/providers/integrations/cursor/cursor-http2.ts +300 -300
- package/src/providers/integrations/cursor/cursor-identity.ts +2 -4
- package/src/providers/integrations/cursor/cursor-request.ts +11 -0
- package/src/providers/integrations/cursor/cursor.ts +29 -17
- package/src/providers/integrations/cursor/protocol/framing.ts +129 -129
- package/src/providers/integrations/cursor/protocol/messages.ts +2380 -2380
- package/src/providers/integrations/cursor/protocol/struct.ts +305 -305
- package/src/providers/integrations/cursor/protocol/tool-canonical.ts +275 -0
- package/src/providers/integrations/grok/grok-quota.ts +279 -279
- package/src/providers/integrations/grok/grok-ratelimit-store.ts +73 -73
- package/src/providers/integrations/grok/grok-ratelimit.ts +218 -218
- package/src/providers/integrations/hermes.ts +7 -1
- package/src/providers/integrations/inferhub.ts +201 -73
- package/src/providers/integrations/kilo/kilo.ts +152 -152
- package/src/providers/integrations/kiro/kiro-machine-id.ts +2 -4
- package/src/providers/integrations/openrouter/provider.ts +129 -129
- package/src/providers/integrations/qoder/qoder-quota.ts +193 -192
- package/src/providers/integrations/qoder/qoder-realm.ts +106 -106
- package/src/providers/integrations/trae/trae-oauth.ts +4 -26
- package/src/providers/integrations/trae/trae-quota.ts +389 -384
- package/src/providers/integrations/trae/trae-realm.ts +15 -2
- package/src/providers/integrations/xiaomi-mimo/think-stream.ts +100 -24
- package/src/providers/integrations/zcode/oauth.ts +1 -1
- package/src/providers/operations/account-health-service.ts +605 -279
- package/src/providers/provider-metadata.ts +12 -1
- package/src/providers/provider-model-definition.ts +373 -373
- package/src/providers/quota/quota-contracts.ts +3 -7
- package/src/providers/search/search-provider.ts +2 -6
- package/src/security/admission/contracts.ts +10 -0
- package/src/security/admission/in-memory-store.ts +89 -14
- package/src/security/admission/lua.ts +191 -182
- package/src/security/admission/redis-store.ts +30 -0
- package/src/security/admission/service.ts +18 -0
- package/src/security/api-key-auth-cache.ts +96 -0
- package/src/security/api-key-auth.ts +11 -100
- package/src/security/client-router-fingerprint.ts +3 -7
- package/src/security/ip-boundary.ts +7 -3
- package/src/shared/coerce.ts +67 -0
- package/src/shared/hash.ts +6 -0
- package/src/shared/time.ts +7 -0
- package/src/transport/dispatch/attempt-finalize.ts +26 -138
- package/src/transport/dispatch/attempt-loop.ts +103 -81
- package/src/transport/dispatch/capture-policy.ts +78 -0
- package/src/transport/dispatch/proxy-request.ts +4 -2
- package/src/transport/dispatch/streaming/encoder.ts +111 -5
- package/src/transport/dispatch/streaming/stream.ts +19 -2
- package/src/transport/dispatch/streaming/thinking-loop.ts +300 -0
- package/src/transport/failure-policy.ts +27 -0
- package/src/transport/guards/abuse-guard.ts +2 -2
- package/src/transport/guards/auth-guard.ts +17 -0
- package/src/transport/guards/body-policy.ts +3 -2
- package/src/transport/guards/concurrency-guard.ts +89 -13
- package/src/transport/guards/early-rejection-capture.ts +43 -3
- package/src/transport/guards/error-lifecycle.ts +10 -2
- package/src/transport/guards/guard-errors.ts +23 -0
- package/src/transport/guards/headroom-guard.ts +41 -7
- package/src/transport/guards/pipeline.ts +24 -0
- package/src/transport/guards/request-context.ts +2 -1
- package/src/transport/routing/admission.ts +51 -9
- package/src/transport/routing/bounded-lru.ts +49 -0
- package/src/transport/routing/candidate-index.ts +88 -0
- package/src/transport/routing/reservations.ts +121 -7
- package/src/transport/routing/round-robin.ts +10 -22
- package/src/transport/routing/route-catalog.ts +26 -13
- package/src/transport/routing/route-model.ts +22 -2
- package/src/transport/routing/router.ts +53 -31
- package/src/transport/surface/chat/encode.ts +2 -3
- package/src/transport/surface/chat/parse.ts +2 -1
- package/src/transport/surface/dialects.ts +11 -3
- package/src/transport/surface/messages/encode.ts +2 -1
- package/src/transport/surface/messages/parse.ts +3 -1
- package/src/transport/surface/messages/stream.ts +2 -6
- package/src/workers/maintenance-lease.ts +200 -0
- package/src/workers/scheduler.ts +101 -1
- package/dist/dashboard/assets/BannedPage-Cd30Z0b9.js.gz +0 -0
- package/dist/dashboard/assets/CliToolDetailPage-wf5u-ww6.js.gz +0 -0
- package/dist/dashboard/assets/CliToolsPage-DykPvuck.js.gz +0 -0
- package/dist/dashboard/assets/CombosPage-DwvSD3vs.js.gz +0 -0
- package/dist/dashboard/assets/ConsoleLogPage-CIr7KDex.js.gz +0 -0
- package/dist/dashboard/assets/CustomizationPage-82X0DDdH.js.gz +0 -0
- package/dist/dashboard/assets/HealthEventsModal-BYPucTTn.js +0 -1
- package/dist/dashboard/assets/HealthEventsModal-BYPucTTn.js.gz +0 -0
- package/dist/dashboard/assets/LoginPage-CS5mPuVF.js.gz +0 -0
- package/dist/dashboard/assets/ModelPicker-C0UCUsVJ.js.gz +0 -0
- package/dist/dashboard/assets/OverviewPage-Dk_ew3YU.js +0 -1
- package/dist/dashboard/assets/OverviewPage-Dk_ew3YU.js.gz +0 -0
- package/dist/dashboard/assets/ProviderDetailPage-CAiRn2Q0.js +0 -3
- package/dist/dashboard/assets/ProviderDetailPage-CAiRn2Q0.js.gz +0 -0
- package/dist/dashboard/assets/ProvidersPage-vdja5ZPK.js.gz +0 -0
- package/dist/dashboard/assets/ProxyPage-BB4CNm4-.js.gz +0 -0
- package/dist/dashboard/assets/QuotaPage-FEc4iNyq.js +0 -1
- package/dist/dashboard/assets/QuotaPage-FEc4iNyq.js.gz +0 -0
- package/dist/dashboard/assets/SettingsPage-Dlfi5L4N.js +0 -1
- package/dist/dashboard/assets/SettingsPage-Dlfi5L4N.js.gz +0 -0
- package/dist/dashboard/assets/SetupPage-FadUOl9S.js.gz +0 -0
- package/dist/dashboard/assets/SortableList-Cfy6_Khv.js.gz +0 -0
- package/dist/dashboard/assets/UsagePage-BT4MyQeT.js.gz +0 -0
- package/dist/dashboard/assets/api-keys-DQYLQxpW.js.gz +0 -0
- package/dist/dashboard/assets/app-CX_Z8cMs.css +0 -2
- package/dist/dashboard/assets/app-CX_Z8cMs.css.gz +0 -0
- package/dist/dashboard/assets/assert-C9FD_JwJ.js.gz +0 -0
- package/dist/dashboard/assets/badge-Bvyr7D0u.js.gz +0 -0
- package/dist/dashboard/assets/banner-DIvKAZih.js.gz +0 -0
- package/dist/dashboard/assets/card-D5_gYIS_.js.gz +0 -0
- package/dist/dashboard/assets/cli-tools-CNqzrfdb.js.gz +0 -0
- package/dist/dashboard/assets/clipboard-button-B6vDBtbm.js.gz +0 -0
- package/dist/dashboard/assets/dialog-CpjrgdwJ.js.gz +0 -0
- package/dist/dashboard/assets/drawer-BWmt3b5y.js.gz +0 -0
- package/dist/dashboard/assets/entry-B-gvimrg.js +0 -1
- package/dist/dashboard/assets/entry-B-gvimrg.js.gz +0 -0
- package/dist/dashboard/assets/entry-C_oakV3z.js.gz +0 -0
- package/dist/dashboard/assets/entry-Dj0cHfEc.js +0 -4
- package/dist/dashboard/assets/entry-Dj0cHfEc.js.gz +0 -0
- package/dist/dashboard/assets/github-badge-RRHHWGxh.js.gz +0 -0
- package/dist/dashboard/assets/index-Bhey_rg_.js.gz +0 -0
- package/dist/dashboard/assets/inline-C2_EQ5Uf.js.gz +0 -0
- package/dist/dashboard/assets/input-BMRlQ450.js.gz +0 -0
- package/dist/dashboard/assets/layout-XR5xP8ew.js.gz +0 -0
- package/dist/dashboard/assets/list-row-Cw07dioc.js.gz +0 -0
- package/dist/dashboard/assets/live-BivGfuUs.js.gz +0 -0
- package/dist/dashboard/assets/model-bans-CmffjLuG.js.gz +0 -0
- package/dist/dashboard/assets/mutation-46P0oWMe.js.gz +0 -0
- package/dist/dashboard/assets/numbers-DrdA9oSM.js.gz +0 -0
- package/dist/dashboard/assets/query-keys-BFhGpwR1.js.gz +0 -0
- package/dist/dashboard/assets/routing-CAb4F37R.js.gz +0 -0
- package/dist/dashboard/assets/select-CxVVB__N.js.gz +0 -0
- package/dist/dashboard/assets/settings-DNX9sydi.js.gz +0 -0
- package/dist/dashboard/assets/stack-uPOwM9Hs.js.gz +0 -0
- package/dist/dashboard/assets/state-D1Yz79iL.js.gz +0 -0
- package/dist/dashboard/assets/switch-sevsRrRo.js.gz +0 -0
- package/dist/dashboard/assets/useMutation-Dem0lXQj.js.gz +0 -0
- package/src/observability/event-compaction.ts +0 -317
- package/src/observability/event-day-index.ts +0 -439
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { decodeJwtPayload } from "../authentication/oauth-flow-store";
|
|
2
|
+
import { asCoercedNumber, asRecord as sharedAsRecord } from "../../shared/coerce";
|
|
2
3
|
|
|
3
4
|
export interface ProviderQuotaWindow {
|
|
4
5
|
readonly kind: string;
|
|
@@ -112,9 +113,7 @@ function creditWindowBalance(
|
|
|
112
113
|
const TIMEOUT_MS = 15_000;
|
|
113
114
|
|
|
114
115
|
export function record(value: unknown): Record<string, unknown> | null {
|
|
115
|
-
return
|
|
116
|
-
? (value as Record<string, unknown>)
|
|
117
|
-
: null;
|
|
116
|
+
return sharedAsRecord(value) ?? null;
|
|
118
117
|
}
|
|
119
118
|
|
|
120
119
|
export function text(value: unknown): string | null {
|
|
@@ -122,10 +121,7 @@ export function text(value: unknown): string | null {
|
|
|
122
121
|
}
|
|
123
122
|
|
|
124
123
|
export function number(value: unknown): number | null {
|
|
125
|
-
|
|
126
|
-
if (typeof value === "string" && value.trim() && Number.isFinite(Number(value)))
|
|
127
|
-
return Number(value);
|
|
128
|
-
return null;
|
|
124
|
+
return asCoercedNumber(value) ?? null;
|
|
129
125
|
}
|
|
130
126
|
|
|
131
127
|
export function isoDate(value: unknown): string | null {
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { asRecord } from "../../shared/coerce";
|
|
2
|
+
|
|
1
3
|
/**
|
|
2
4
|
* Declarative web-search provider adapters.
|
|
3
5
|
*
|
|
@@ -42,12 +44,6 @@ export interface SearchProviderSpec {
|
|
|
42
44
|
readonly normalize: (data: unknown) => readonly WebSearchResult[];
|
|
43
45
|
}
|
|
44
46
|
|
|
45
|
-
function asRecord(value: unknown): Record<string, unknown> | undefined {
|
|
46
|
-
return typeof value === "object" && value !== null && !Array.isArray(value)
|
|
47
|
-
? (value as Record<string, unknown>)
|
|
48
|
-
: undefined;
|
|
49
|
-
}
|
|
50
|
-
|
|
51
47
|
function stringField(record: Record<string, unknown> | undefined, key: string): string {
|
|
52
48
|
const value = record?.[key];
|
|
53
49
|
return typeof value === "string" ? value : "";
|
|
@@ -44,6 +44,16 @@ export interface AdmissionReserveRequest {
|
|
|
44
44
|
readonly lifetimeBudget: number | null;
|
|
45
45
|
readonly lifetimeConsumed: number;
|
|
46
46
|
readonly concurrencyLimit: number | null;
|
|
47
|
+
/**
|
|
48
|
+
* Hidden allowance added on top of `concurrencyLimit`.
|
|
49
|
+
*
|
|
50
|
+
* The configured limit is what an operator tunes and what the error names;
|
|
51
|
+
* this is deliberate slack so a run that lands a few requests over does not
|
|
52
|
+
* get refused at the number it was told. Effective max is
|
|
53
|
+
* `concurrencyLimit + concurrencyTolerance`. Only requests *beyond* that are
|
|
54
|
+
* refused — never ones already admitted.
|
|
55
|
+
*/
|
|
56
|
+
readonly concurrencyTolerance?: number | null | undefined;
|
|
47
57
|
readonly tenantId: string;
|
|
48
58
|
readonly tenantConcurrencyLimit: number | null;
|
|
49
59
|
/**
|
|
@@ -20,6 +20,16 @@ interface InMemoryReservation {
|
|
|
20
20
|
readonly monthlyKey: string;
|
|
21
21
|
readonly tracksLifetime: boolean;
|
|
22
22
|
readonly tracksConcurrency: boolean;
|
|
23
|
+
/**
|
|
24
|
+
* The key the concurrency counter was incremented under.
|
|
25
|
+
*
|
|
26
|
+
* Not `apiKeyId`: a share child reserves under its own id while the
|
|
27
|
+
* reservation is recorded against its parent, so charging and refunding
|
|
28
|
+
* through `apiKeyId` incremented one counter and decremented another. The
|
|
29
|
+
* child's count then never fell — after `limit` requests it was permanently
|
|
30
|
+
* full and refused everything, which is the "never invoked again" symptom.
|
|
31
|
+
*/
|
|
32
|
+
readonly concurrencyKey: string;
|
|
23
33
|
readonly tenantId: string;
|
|
24
34
|
readonly tracksTenantConcurrency: boolean;
|
|
25
35
|
state: "active" | "committed" | "released";
|
|
@@ -36,9 +46,57 @@ const TERMINAL_RESERVATION_CAP = 10_000;
|
|
|
36
46
|
const COUNTER_MAP_CAP = 20_000;
|
|
37
47
|
|
|
38
48
|
|
|
49
|
+
/**
|
|
50
|
+
* Per-key RPM timestamp queue.
|
|
51
|
+
*
|
|
52
|
+
* Entries are appended in nondecreasing `now` order (requests are admitted
|
|
53
|
+
* under the store's exclusive gate, each stamped with its own `now`), so the
|
|
54
|
+
* window prefix is monotone: pruning advances a head index past every entry
|
|
55
|
+
* `<= cutoff` without copying the surviving suffix, and the array is compacted
|
|
56
|
+
* only when the consumed prefix exceeds half of it. Per request this is O(1)
|
|
57
|
+
* amortized instead of the previous per-request full-array rewrite.
|
|
58
|
+
*
|
|
59
|
+
* Exposed surface mirrors what the reserve path needs: `length` (window count
|
|
60
|
+
* for the limit check), `push` (append on admit), and `prune` (advance the
|
|
61
|
+
* window). `purge()` drops the whole key from the outer map.
|
|
62
|
+
*/
|
|
63
|
+
class RpmTimestampQueue {
|
|
64
|
+
private entries: number[] = [];
|
|
65
|
+
private head = 0;
|
|
66
|
+
|
|
67
|
+
get length(): number {
|
|
68
|
+
return this.entries.length - this.head;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
push(now: number): void {
|
|
72
|
+
this.entries.push(now);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Drops every entry `<= cutoff`. An entry exactly one window old is expired
|
|
77
|
+
* (the existing exact-boundary semantics: `cutoff = now - window`, so an
|
|
78
|
+
* entry at `now - window` equals the cutoff and is dropped).
|
|
79
|
+
*/
|
|
80
|
+
prune(now: number, windowMs: number): void {
|
|
81
|
+
const cutoff = now - windowMs;
|
|
82
|
+
while (this.head < this.entries.length && this.entries[this.head]! <= cutoff) {
|
|
83
|
+
this.head += 1;
|
|
84
|
+
}
|
|
85
|
+
if (this.head > this.entries.length / 2) {
|
|
86
|
+
this.entries = this.entries.slice(this.head);
|
|
87
|
+
this.head = 0;
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** True when no live entries remain — the caller deletes the bucket. */
|
|
92
|
+
isEmpty(): boolean {
|
|
93
|
+
return this.length === 0;
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
39
97
|
/** Atomic single-process admission store used by tests and single-process development. */
|
|
40
98
|
export class InMemoryAdmissionCounterStore implements AdmissionCounterStore {
|
|
41
|
-
private readonly rpmTimestamps = new Map<string,
|
|
99
|
+
private readonly rpmTimestamps = new Map<string, RpmTimestampQueue>();
|
|
42
100
|
private readonly daily = new Map<string, number>();
|
|
43
101
|
private readonly monthly = new Map<string, number>();
|
|
44
102
|
private readonly lifetime = new Map<string, number>();
|
|
@@ -145,8 +203,19 @@ export class InMemoryAdmissionCounterStore implements AdmissionCounterStore {
|
|
|
145
203
|
lifetime + request.estimatedTokens > request.lifetimeBudget
|
|
146
204
|
)
|
|
147
205
|
throw reasonToGatewayError("lifetime-token-budget", { limit: request.lifetimeBudget });
|
|
148
|
-
|
|
149
|
-
|
|
206
|
+
// The tolerance is slack above the configured limit, so the ceiling is
|
|
207
|
+
// the sum. It widens admission only: requests already inside are never
|
|
208
|
+
// revisited, and the refusal still names the configured limit.
|
|
209
|
+
const concurrencyCeiling =
|
|
210
|
+
request.concurrencyLimit !== null
|
|
211
|
+
? request.concurrencyLimit + Math.max(0, request.concurrencyTolerance ?? 0)
|
|
212
|
+
: null;
|
|
213
|
+
if (concurrencyCeiling !== null && concurrent >= concurrencyCeiling)
|
|
214
|
+
throw reasonToGatewayError("concurrency-limit", {
|
|
215
|
+
limit: request.concurrencyLimit,
|
|
216
|
+
tolerance: request.concurrencyTolerance ?? 0,
|
|
217
|
+
effectiveLimit: concurrencyCeiling,
|
|
218
|
+
});
|
|
150
219
|
if (
|
|
151
220
|
request.tenantConcurrencyLimit != null &&
|
|
152
221
|
tenantConcurrent >= request.tenantConcurrencyLimit
|
|
@@ -182,6 +251,7 @@ export class InMemoryAdmissionCounterStore implements AdmissionCounterStore {
|
|
|
182
251
|
monthlyKey,
|
|
183
252
|
tracksLifetime: request.lifetimeBudget != null,
|
|
184
253
|
tracksConcurrency: request.concurrencyLimit != null,
|
|
254
|
+
concurrencyKey: rpmConcId,
|
|
185
255
|
tenantId: request.tenantId,
|
|
186
256
|
tracksTenantConcurrency: request.tenantConcurrencyLimit != null,
|
|
187
257
|
state: "active",
|
|
@@ -205,7 +275,7 @@ export class InMemoryAdmissionCounterStore implements AdmissionCounterStore {
|
|
|
205
275
|
this.adjust(this.daily, reservation.dailyKey, delta, reservation.tracksDaily);
|
|
206
276
|
this.adjust(this.monthly, reservation.monthlyKey, delta, reservation.tracksMonthly);
|
|
207
277
|
this.adjust(this.lifetime, apiKeyId, delta, reservation.tracksLifetime);
|
|
208
|
-
if (reservation.tracksConcurrency) this.decrement(this.concurrent,
|
|
278
|
+
if (reservation.tracksConcurrency) this.decrement(this.concurrent, reservation.concurrencyKey);
|
|
209
279
|
if (reservation.tracksTenantConcurrency)
|
|
210
280
|
this.decrement(this.tenantConcurrent, reservation.tenantId);
|
|
211
281
|
reservation.state = "committed";
|
|
@@ -276,7 +346,7 @@ export class InMemoryAdmissionCounterStore implements AdmissionCounterStore {
|
|
|
276
346
|
}
|
|
277
347
|
|
|
278
348
|
/** Caps a bucketed counter map; oldest-inserted buckets evicted first. */
|
|
279
|
-
private evictCounter(store: Map<string, number> | Map<string,
|
|
349
|
+
private evictCounter(store: Map<string, number> | Map<string, RpmTimestampQueue>): void {
|
|
280
350
|
if (store.size < COUNTER_MAP_CAP) return;
|
|
281
351
|
let toEvict = store.size - COUNTER_MAP_CAP + 1;
|
|
282
352
|
for (const key of store.keys()) {
|
|
@@ -290,7 +360,7 @@ export class InMemoryAdmissionCounterStore implements AdmissionCounterStore {
|
|
|
290
360
|
this.adjust(this.daily, reservation.dailyKey, -reservation.reserved, reservation.tracksDaily);
|
|
291
361
|
this.adjust(this.monthly, reservation.monthlyKey, -reservation.reserved, reservation.tracksMonthly);
|
|
292
362
|
this.adjust(this.lifetime, reservation.apiKeyId, -reservation.reserved, reservation.tracksLifetime);
|
|
293
|
-
if (reservation.tracksConcurrency) this.decrement(this.concurrent, reservation.
|
|
363
|
+
if (reservation.tracksConcurrency) this.decrement(this.concurrent, reservation.concurrencyKey);
|
|
294
364
|
if (reservation.tracksTenantConcurrency)
|
|
295
365
|
this.decrement(this.tenantConcurrent, reservation.tenantId);
|
|
296
366
|
reservation.state = "released";
|
|
@@ -316,21 +386,26 @@ export class InMemoryAdmissionCounterStore implements AdmissionCounterStore {
|
|
|
316
386
|
private value(store: ReadonlyMap<string, number>, key: string): number {
|
|
317
387
|
return store.get(key) ?? 0;
|
|
318
388
|
}
|
|
319
|
-
private pruneRpm(apiKeyId: string, now: number):
|
|
320
|
-
const cutoff = now - (this.options.windowMs ?? 60_000);
|
|
389
|
+
private pruneRpm(apiKeyId: string, now: number): RpmTimestampQueue {
|
|
321
390
|
const existing = this.rpmTimestamps.get(apiKeyId);
|
|
322
391
|
if (!existing) {
|
|
323
|
-
const fresh
|
|
392
|
+
const fresh = new RpmTimestampQueue();
|
|
324
393
|
this.rpmTimestamps.set(apiKeyId, fresh);
|
|
325
394
|
this.evictCounter(this.rpmTimestamps);
|
|
326
395
|
return fresh;
|
|
327
396
|
}
|
|
328
|
-
//
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
397
|
+
// Amortized: advances the head past expired entries; compaction of the
|
|
398
|
+
// consumed prefix happens only when it exceeds half the array.
|
|
399
|
+
existing.prune(now, this.options.windowMs ?? 60_000);
|
|
400
|
+
if (existing.isEmpty()) {
|
|
401
|
+
// Drop the emptied bucket so the map cannot pin one dead queue per key
|
|
402
|
+
// the process ever served, then hand the caller a fresh queue — the
|
|
403
|
+
// caller pushes this request's timestamp into whatever it gets back, and
|
|
404
|
+
// pushing into the detached object would silently drop the admission.
|
|
405
|
+
const fresh = new RpmTimestampQueue();
|
|
406
|
+
this.rpmTimestamps.set(apiKeyId, fresh);
|
|
407
|
+
return fresh;
|
|
332
408
|
}
|
|
333
|
-
existing.length = write;
|
|
334
409
|
return existing;
|
|
335
410
|
}
|
|
336
411
|
private requireReservation(
|
|
@@ -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
|
+
}
|