cartethyia 2.5.4 → 2.5.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.env.example +13 -4
- package/README.md +2 -2
- package/dist/dashboard/assets/{BannedPage-C2orBsI2.js → BannedPage-CADDq4IU.js} +1 -1
- package/dist/dashboard/assets/BannedPage-CADDq4IU.js.gz +0 -0
- package/dist/dashboard/assets/CliToolDetailPage-_z1hID7E.js +1 -0
- package/dist/dashboard/assets/CliToolDetailPage-_z1hID7E.js.gz +0 -0
- package/dist/dashboard/assets/{CliToolsPage-BV6pxVQo.js → CliToolsPage-DuO1Z7dq.js} +1 -1
- package/dist/dashboard/assets/CliToolsPage-DuO1Z7dq.js.gz +0 -0
- package/dist/dashboard/assets/CombosPage-DNEPi3xU.js +1 -0
- package/dist/dashboard/assets/CombosPage-DNEPi3xU.js.gz +0 -0
- package/dist/dashboard/assets/{ConsoleLogPage-CW-wPmJ6.js → ConsoleLogPage-BVdpfiz1.js} +1 -1
- package/dist/dashboard/assets/ConsoleLogPage-BVdpfiz1.js.gz +0 -0
- package/dist/dashboard/assets/{CustomizationPage-0p8uX7vB.js → CustomizationPage-BR5HHvbi.js} +1 -1
- package/dist/dashboard/assets/CustomizationPage-BR5HHvbi.js.gz +0 -0
- package/dist/dashboard/assets/{HealthEventsModal-BjqSMfko.js → HealthEventsModal-mUfcknqm.js} +1 -1
- package/dist/dashboard/assets/HealthEventsModal-mUfcknqm.js.gz +0 -0
- package/dist/dashboard/assets/{LoginPage-BvzCx8Kr.js → LoginPage-V5faV9V2.js} +1 -1
- package/dist/dashboard/assets/LoginPage-V5faV9V2.js.gz +0 -0
- package/dist/dashboard/assets/ModelPicker-C7pDb41R.js +1 -0
- package/dist/dashboard/assets/ModelPicker-C7pDb41R.js.gz +0 -0
- package/dist/dashboard/assets/OverviewPage-BDu20Jdx.js +1 -0
- package/dist/dashboard/assets/OverviewPage-BDu20Jdx.js.gz +0 -0
- package/dist/dashboard/assets/ProviderDetailPage-Dllr_aFO.js +3 -0
- package/dist/dashboard/assets/ProviderDetailPage-Dllr_aFO.js.gz +0 -0
- package/dist/dashboard/assets/{ProvidersPage-Js9jUPSb.js → ProvidersPage-uPiGVTTK.js} +1 -1
- package/dist/dashboard/assets/ProvidersPage-uPiGVTTK.js.gz +0 -0
- package/dist/dashboard/assets/ProxyPage-BaUkO-4O.js +7 -0
- package/dist/dashboard/assets/ProxyPage-BaUkO-4O.js.gz +0 -0
- package/dist/dashboard/assets/QuotaPage-o0IFAsFb.js +1 -0
- package/dist/dashboard/assets/QuotaPage-o0IFAsFb.js.gz +0 -0
- package/dist/dashboard/assets/SettingsPage-CYWqbXjp.js +1 -0
- package/dist/dashboard/assets/SettingsPage-CYWqbXjp.js.gz +0 -0
- package/dist/dashboard/assets/{SetupPage-DDbMQsoo.js → SetupPage-UB690SE2.js} +1 -1
- package/dist/dashboard/assets/SetupPage-UB690SE2.js.gz +0 -0
- package/dist/dashboard/assets/{SortableList-CB3Z7VTf.js → SortableList-BLujdX7N.js} +1 -1
- package/dist/dashboard/assets/SortableList-BLujdX7N.js.gz +0 -0
- package/dist/dashboard/assets/{StudioPage-D95UCPHI.js → StudioPage-D5C2raAc.js} +3 -3
- package/dist/dashboard/assets/StudioPage-D5C2raAc.js.gz +0 -0
- package/dist/dashboard/assets/UsagePage-8uIcxa1W.js +56 -0
- package/dist/dashboard/assets/UsagePage-8uIcxa1W.js.gz +0 -0
- package/dist/dashboard/assets/api-keys-DQYLQxpW.js +1 -0
- package/dist/dashboard/assets/api-keys-DQYLQxpW.js.gz +0 -0
- package/dist/dashboard/assets/app-CX_Z8cMs.css +2 -0
- package/dist/dashboard/assets/app-CX_Z8cMs.css.gz +0 -0
- package/dist/dashboard/assets/{assert-Chi7pCnt.js → assert-C9FD_JwJ.js} +1 -1
- package/dist/dashboard/assets/assert-C9FD_JwJ.js.gz +0 -0
- package/dist/dashboard/assets/{badge-D4pQapKj.js → badge-CR72dasT.js} +1 -1
- package/dist/dashboard/assets/badge-CR72dasT.js.gz +0 -0
- package/dist/dashboard/assets/{banner-DH6cg-_B.js → banner-CI9_eSfO.js} +1 -1
- package/dist/dashboard/assets/banner-CI9_eSfO.js.gz +0 -0
- package/dist/dashboard/assets/{card-BKqlUOe4.js → card-DS7CQEsO.js} +1 -1
- package/dist/dashboard/assets/card-DS7CQEsO.js.gz +0 -0
- package/dist/dashboard/assets/{cli-tools-fTgljTGA.js → cli-tools-CNqzrfdb.js} +1 -1
- package/dist/dashboard/assets/cli-tools-CNqzrfdb.js.gz +0 -0
- package/dist/dashboard/assets/{clipboard-button-D5Ys_sM0.js → clipboard-button-611pSsaX.js} +1 -1
- package/dist/dashboard/assets/clipboard-button-611pSsaX.js.gz +0 -0
- package/dist/dashboard/assets/{fingerprint-pattern-BWuHtbW4.js → concurrency-BDF0ok7y.js} +1 -1
- package/dist/dashboard/assets/concurrency-BDF0ok7y.js.gz +0 -0
- package/dist/dashboard/assets/dialog-k37exjNt.js +1 -0
- package/dist/dashboard/assets/dialog-k37exjNt.js.gz +0 -0
- package/dist/dashboard/assets/drawer-CX7N8ucR.js +1 -0
- package/dist/dashboard/assets/drawer-CX7N8ucR.js.gz +0 -0
- package/dist/dashboard/assets/{entry-CQBr6U9c.js → entry-B9Xvtnfj.js} +1 -1
- package/dist/dashboard/assets/entry-B9Xvtnfj.js.gz +0 -0
- package/dist/dashboard/assets/entry-CTP8AyiS.js +1 -0
- package/dist/dashboard/assets/entry-CTP8AyiS.js.gz +0 -0
- package/dist/dashboard/assets/entry-DxBHnauy.js +4 -0
- package/dist/dashboard/assets/entry-DxBHnauy.js.gz +0 -0
- package/dist/dashboard/assets/{github-badge-BlptL8Uf.js → github-badge-sltk1qUi.js} +1 -1
- package/dist/dashboard/assets/github-badge-sltk1qUi.js.gz +0 -0
- package/dist/dashboard/assets/{index-js6TJvdr.js → index-CmxZr14F.js} +2 -2
- package/dist/dashboard/assets/index-CmxZr14F.js.gz +0 -0
- package/dist/dashboard/assets/{inline-DUWDSmDV.js → inline-BhY2sSnO.js} +1 -1
- package/dist/dashboard/assets/inline-BhY2sSnO.js.gz +0 -0
- package/dist/dashboard/assets/{input-LVjgZq4f.js → input-V1umFiBk.js} +1 -1
- package/dist/dashboard/assets/input-V1umFiBk.js.gz +0 -0
- package/dist/dashboard/assets/layout-BBOYycyS.js +1 -0
- package/dist/dashboard/assets/layout-BBOYycyS.js.gz +0 -0
- package/dist/dashboard/assets/{list-row-DupvLfDx.js → list-row-1zzZYM0B.js} +1 -1
- package/dist/dashboard/assets/list-row-1zzZYM0B.js.gz +0 -0
- package/dist/dashboard/assets/{live-C4pEJUqI.js → live-BivGfuUs.js} +1 -1
- package/dist/dashboard/assets/live-BivGfuUs.js.gz +0 -0
- package/dist/dashboard/assets/{model-bans-BRiuufkB.js → model-bans-CmffjLuG.js} +1 -1
- package/dist/dashboard/assets/model-bans-CmffjLuG.js.gz +0 -0
- package/dist/dashboard/assets/{mutation-B4ROwk8L.js → mutation-46P0oWMe.js} +1 -1
- package/dist/dashboard/assets/mutation-46P0oWMe.js.gz +0 -0
- package/dist/dashboard/assets/numbers-DkfMR5Qs.js +2 -0
- package/dist/dashboard/assets/numbers-DkfMR5Qs.js.gz +0 -0
- package/dist/dashboard/assets/{query-keys-gmjzC3RS.js → query-keys-BFhGpwR1.js} +1 -1
- package/dist/dashboard/assets/query-keys-BFhGpwR1.js.gz +0 -0
- package/dist/dashboard/assets/{routing-C7tBT5o-.js → routing-CAb4F37R.js} +1 -1
- package/dist/dashboard/assets/routing-CAb4F37R.js.gz +0 -0
- package/dist/dashboard/assets/{select-WUCXorXw.js → select-BYR_5K2c.js} +1 -1
- package/dist/dashboard/assets/select-BYR_5K2c.js.gz +0 -0
- package/dist/dashboard/assets/{settings-C_zKl1GS.js → settings-DNX9sydi.js} +1 -1
- package/dist/dashboard/assets/settings-DNX9sydi.js.gz +0 -0
- package/dist/dashboard/assets/{stack-_3O0F8Xn.js → stack-BHTfXdz_.js} +1 -1
- package/dist/dashboard/assets/stack-BHTfXdz_.js.gz +0 -0
- package/dist/dashboard/assets/{state-BgTxHcJS.js → state-mPDRlQkl.js} +1 -1
- package/dist/dashboard/assets/state-mPDRlQkl.js.gz +0 -0
- package/dist/dashboard/assets/{switch-DEjTtOWf.js → switch-DOmJxg7H.js} +1 -1
- package/dist/dashboard/assets/switch-DOmJxg7H.js.gz +0 -0
- package/dist/dashboard/assets/tokens-COzario9.js +1 -0
- package/dist/dashboard/assets/tokens-COzario9.js.gz +0 -0
- package/dist/dashboard/assets/use-modal-focus-By7oJoGd.js +1 -0
- package/dist/dashboard/assets/use-modal-focus-By7oJoGd.js.gz +0 -0
- package/dist/dashboard/assets/{useMutation-BWSC0UAS.js → useMutation-Dem0lXQj.js} +1 -1
- package/dist/dashboard/assets/useMutation-Dem0lXQj.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 +54 -54
- package/migrations/README.md +52 -52
- package/migrations/lite-sqlite3/0000_baseline.sql +2 -2
- package/package.json +96 -95
- package/scripts/cli/admin.ts +125 -0
- package/scripts/cli/{ui/background.ts → background.ts} +1 -1
- package/scripts/cli/{command/console-menu.ts → console-menu.ts} +275 -244
- package/scripts/cli/doctor.ts +37 -0
- package/scripts/cli/{support/env.ts → env.ts} +99 -99
- package/scripts/cli/{gateway/spawn.ts → gateway.ts} +112 -24
- package/scripts/cli/instance.ts +224 -0
- package/scripts/cli/main.ts +61 -18
- package/scripts/cli/{support/platform.ts → platform.ts} +64 -64
- package/scripts/cli/port.ts +100 -0
- package/scripts/cli/{support/services.ts → services.ts} +88 -88
- package/scripts/cli/{command/setup.ts → setup.ts} +35 -34
- package/scripts/cli/{command/start.ts → start.ts} +10 -5
- package/scripts/cli/{support/startup-signal.ts → startup-signal.ts} +23 -23
- package/scripts/cli/tray/index.ts +62 -12
- package/scripts/cli/{command/tray.ts → tray.ts} +208 -208
- package/scripts/cli/{command/update.ts → update.ts} +39 -39
- package/scripts/cli/version.ts +67 -0
- package/scripts/cli/{ui/wordmark.ts → wordmark.ts} +58 -58
- package/src/boot/app.ts +697 -693
- package/src/boot/dependencies.ts +647 -646
- package/src/boot/timeout.ts +2 -13
- package/src/config/cooldowns.ts +186 -138
- package/src/config/network.ts +8 -14
- package/src/config/providers.ts +7 -10
- package/src/config/runtime.ts +76 -76
- package/src/config/security.ts +15 -0
- package/src/config/spec.ts +20 -30
- package/src/config/telemetry.ts +0 -20
- package/src/config/timeouts.ts +0 -6
- package/src/console/admin/model-abuse/contracts.ts +28 -6
- package/src/console/admin/model-abuse/routes.ts +56 -3
- package/src/console/api-keys/contracts.ts +410 -414
- package/src/console/api-keys/routes.ts +881 -880
- package/src/console/auth/credentials.ts +3 -3
- package/src/console/auth/setup-service.ts +2 -2
- package/src/console/backup/export-endpoint.ts +6 -2
- package/src/console/backup/nine-router.ts +2 -5
- package/src/console/backup/service.ts +4 -0
- package/src/console/backup/store.ts +192 -29
- package/src/console/backup/validate.ts +3 -45
- package/src/console/cli-tools/fs-ops.ts +487 -487
- package/src/console/console-router-guards.ts +1 -1
- package/src/console/data-management/contracts.ts +13 -6
- package/src/console/data-management/operations.ts +23 -18
- package/src/console/data-management/routes.ts +0 -1
- package/src/console/domain-registration.ts +10 -5
- package/src/console/observability/live.ts +22 -0
- package/src/console/observability/store.ts +39 -16
- package/src/console/observability/usage-dimensions.ts +4 -4
- package/src/console/providers/catalog/catalog-projections.ts +6 -7
- package/src/console/providers/catalog/model-operations.ts +5 -2
- package/src/console/providers/catalog/provider-operations.ts +4 -4
- package/src/console/providers/catalog/public-model-store.ts +8 -11
- package/src/console/providers/catalog/store.ts +1553 -1553
- package/src/console/providers/oauth/routes.ts +8 -12
- package/src/console/quota/account-quota-tenant.ts +6 -6
- package/src/console/quota/quota-view.ts +1 -1
- package/src/console/remote-support/contracts.ts +61 -0
- package/src/console/remote-support/routes.ts +65 -0
- package/src/console/remote-support/service.ts +207 -0
- package/src/console/response-compression.ts +0 -3
- package/src/console/routing/pools/probe-result.ts +3 -2
- package/src/console/routing/pools/probe-verdict.ts +132 -132
- package/src/console/routing/pools/store.ts +8 -8
- package/src/console/settings/contracts.ts +58 -2
- package/src/console/settings/store.ts +30 -3
- package/src/console/shared/cursor.ts +8 -8
- package/src/console/shared/errors.ts +1 -3
- package/src/network/http2-bidi.ts +2 -4
- package/src/network/pool/agent.ts +478 -478
- package/src/network/pool/selector.ts +5 -4
- package/src/network/retry.ts +1 -2
- package/src/network/ssrf.ts +5 -4
- package/src/observability/event-compaction.ts +80 -78
- package/src/observability/event-day-index.ts +13 -15
- package/src/observability/event-log.ts +4 -4
- package/src/observability/logger.ts +6 -6
- package/src/observability/payload-capture.ts +151 -33
- package/src/observability/payload-store.ts +397 -440
- package/src/observability/redaction.ts +5 -6
- package/src/observability/request-notify.ts +43 -0
- package/src/observability/telemetry-status.ts +8 -9
- package/src/observability/token-speed.ts +5 -4
- package/src/persistence/db-mode.ts +7 -7
- package/src/persistence/readiness.ts +1 -1
- package/src/persistence/schema.ts +1406 -1402
- package/src/persistence/shared-cache.ts +1 -2
- package/src/persistence/stores/api-key-store.ts +18 -6
- package/src/persistence/stores/share-store.ts +4 -3
- package/src/persistence/stores/telemetry-store.ts +236 -237
- package/src/protocol/canonical-model.ts +11 -3
- package/src/protocol/codecs.ts +138 -4
- package/src/protocol/gateway-error.ts +6 -4
- package/src/protocol/request/chat.ts +21 -1
- package/src/protocol/request/codex.ts +10 -1
- package/src/protocol/request/messages.ts +4 -5
- package/src/protocol/request/responses.ts +9 -1
- package/src/protocol/response/chat.ts +34 -4
- package/src/protocol/response/codex.ts +7 -4
- package/src/protocol/response/dsml.ts +140 -28
- package/src/protocol/response/responses.ts +11 -5
- package/src/protocol/stream-error-frames.ts +4 -5
- package/src/providers/authentication/jwt-validator.ts +9 -10
- package/src/providers/authentication/oauth-refresh-service.ts +9 -11
- package/src/providers/configured-openai-providers.ts +134 -136
- package/src/providers/discovery/catalog/catalog-blob.ts +18 -18
- package/src/providers/discovery/catalog/catalog-format.ts +3 -6
- package/src/providers/discovery/models-dev-catalog.ts +354 -354
- package/src/providers/discovery/probe-phases.ts +4 -4
- package/src/providers/discovery/probe-wire.ts +6 -13
- package/src/providers/discovery/probing-service.ts +1117 -1045
- package/src/providers/integrations/alibabacp/alibabacp.ts +1 -1
- package/src/providers/integrations/antigravity/antigravity-quota.ts +5 -7
- package/src/providers/integrations/buddy/buddy-chat-shared.ts +23 -2
- package/src/providers/integrations/buddy/buddy-checkin.ts +1 -4
- package/src/providers/integrations/buddy/buddy-cli-headers.ts +23 -6
- package/src/providers/integrations/buddy/buddy-conversation-headers.ts +0 -1
- package/src/providers/integrations/buddy/buddy-identity.ts +52 -0
- package/src/providers/integrations/buddy/buddy-media.ts +2 -2
- package/src/providers/integrations/buddy/buddy-usage-store.ts +3 -4
- package/src/providers/integrations/buddy/codebuddy/quota.ts +2 -13
- package/src/providers/integrations/buddy/codebuddy/shared.ts +118 -115
- package/src/providers/integrations/buddy/workbuddy/quota.ts +0 -11
- package/src/providers/integrations/buddy/workbuddy/shared.ts +5 -2
- package/src/providers/integrations/claude/claude.ts +608 -608
- package/src/providers/integrations/cline/cline-quota.ts +132 -132
- package/src/providers/integrations/cline/cline.ts +537 -539
- package/src/providers/integrations/codex/codex-oauth.ts +1 -1
- package/src/providers/integrations/commandcode/commandcode.ts +674 -675
- package/src/providers/integrations/commandcode/discovery.ts +117 -116
- package/src/providers/integrations/cursor/cursor-auth.ts +418 -418
- package/src/providers/integrations/cursor/cursor-quota.ts +309 -310
- package/src/providers/integrations/cursor/cursor.ts +11 -163
- package/src/providers/integrations/factory/factory-models.ts +3 -5
- package/src/providers/integrations/grok/grok-install-id.ts +2 -3
- package/src/providers/integrations/grok/grok.ts +6 -7
- package/src/providers/integrations/hermes.ts +14 -18
- package/src/providers/integrations/kiro/kiro-discovery.ts +1 -1
- package/src/providers/integrations/kiro/kiro.ts +1 -1
- package/src/providers/integrations/mistral.ts +6 -6
- package/src/providers/integrations/opencode/fingerprint.ts +3 -3
- package/src/providers/integrations/opencode/provider.ts +8 -8
- package/src/providers/integrations/qoder/qoder-catalog.ts +450 -450
- package/src/providers/integrations/qoder/qoder-oauth.ts +388 -310
- package/src/providers/integrations/qoder/qoder.ts +884 -865
- package/src/providers/integrations/shared/connect.ts +6 -6
- package/src/providers/integrations/trae/trae-catalog.ts +1 -2
- package/src/providers/integrations/trae/trae.ts +2 -2
- package/src/providers/integrations/zcode/oauth.ts +6 -1
- package/src/providers/operations/account-health-service.ts +1528 -1523
- package/src/providers/operations/account-reset-service.ts +1 -1
- package/src/providers/operations/client-versions.ts +6 -6
- package/src/providers/operations/custom-cli-headers.ts +3 -4
- package/src/providers/operations/provider-catalog-seeder.ts +95 -95
- package/src/providers/operations/provider-catalog-service.ts +3 -4
- package/src/providers/provider-metadata.ts +4 -8
- package/src/providers/provider-registry.ts +6 -7
- package/src/providers/provider-usage.ts +1 -1
- package/src/security/abuse.ts +15 -10
- package/src/security/access-control.ts +144 -146
- package/src/security/admission/contracts.ts +11 -0
- package/src/security/admission/in-memory-store.ts +4 -3
- package/src/security/admission/lease-sweep.ts +6 -6
- package/src/security/admission/reasons.ts +5 -1
- package/src/security/admission/redis-store.ts +211 -210
- package/src/security/admission/service.ts +4 -0
- package/src/security/api-key-auth.ts +504 -504
- package/src/security/ip-boundary.ts +155 -15
- package/src/security/model-abuse.ts +366 -94
- package/src/security/outbound-headers.ts +3 -4
- package/src/shared/sleep.ts +4 -0
- package/src/transport/dispatch/attempt-finalize.ts +47 -5
- package/src/transport/dispatch/attempt-loop.ts +531 -530
- package/src/transport/dispatch/stream-bridge.ts +1 -3
- package/src/transport/dispatch/streaming/finalize.ts +1 -1
- package/src/transport/dispatch/streaming/stream.ts +5 -6
- package/src/transport/failure-policy.ts +162 -8
- package/src/transport/guards/auth-guard.ts +7 -4
- package/src/transport/guards/concurrency-guard.ts +0 -5
- package/src/transport/guards/error-lifecycle.ts +13 -6
- package/src/transport/guards/pipeline.ts +15 -3
- package/src/transport/guards/request-context.ts +8 -2
- package/src/transport/request/inflight.ts +5 -6
- package/src/transport/request/preparer.ts +2 -2
- package/src/transport/request/state.ts +12 -8
- package/src/transport/routing/admission.ts +4 -0
- package/src/transport/routing/route-catalog.ts +8 -1
- package/src/transport/routing/route-model.ts +46 -14
- package/src/transport/routing/router.ts +21 -14
- package/src/transport/surface/completion.ts +2 -3
- package/src/transport/surface/dialects.ts +5 -6
- package/src/transport/surface/responses/encode.ts +38 -0
- package/src/transport/surface/responses/parse.ts +4 -0
- package/src/transport/translation/capabilities.ts +2 -3
- package/src/transport/translation/quirks.ts +3 -3
- package/src/workers/buddy-growth-tasks.ts +5 -10
- package/src/workers/daily-checkin.ts +17 -23
- package/src/workers/quota-refresh-sweep.ts +26 -26
- package/dist/dashboard/assets/BannedPage-C2orBsI2.js.gz +0 -0
- package/dist/dashboard/assets/CliToolDetailPage-DUcZ6pMB.js +0 -1
- package/dist/dashboard/assets/CliToolDetailPage-DUcZ6pMB.js.gz +0 -0
- package/dist/dashboard/assets/CliToolsPage-BV6pxVQo.js.gz +0 -0
- package/dist/dashboard/assets/CombosPage-Bsan4679.js +0 -1
- package/dist/dashboard/assets/CombosPage-Bsan4679.js.gz +0 -0
- package/dist/dashboard/assets/ConsoleLogPage-CW-wPmJ6.js.gz +0 -0
- package/dist/dashboard/assets/CustomizationPage-0p8uX7vB.js.gz +0 -0
- package/dist/dashboard/assets/HealthEventsModal-BjqSMfko.js.gz +0 -0
- package/dist/dashboard/assets/LoginPage-BvzCx8Kr.js.gz +0 -0
- package/dist/dashboard/assets/ModelPicker-BY1aKEkP.js +0 -1
- package/dist/dashboard/assets/ModelPicker-BY1aKEkP.js.gz +0 -0
- package/dist/dashboard/assets/OverviewPage-DOQpy1y0.js +0 -1
- package/dist/dashboard/assets/OverviewPage-DOQpy1y0.js.gz +0 -0
- package/dist/dashboard/assets/ProviderDetailPage-ecxyUMzt.js +0 -3
- package/dist/dashboard/assets/ProviderDetailPage-ecxyUMzt.js.gz +0 -0
- package/dist/dashboard/assets/ProvidersPage-Js9jUPSb.js.gz +0 -0
- package/dist/dashboard/assets/ProxyPage-J3TBi3Kx.js +0 -7
- package/dist/dashboard/assets/ProxyPage-J3TBi3Kx.js.gz +0 -0
- package/dist/dashboard/assets/QuotaPage-C1gZS6xr.js +0 -1
- package/dist/dashboard/assets/QuotaPage-C1gZS6xr.js.gz +0 -0
- package/dist/dashboard/assets/SettingsPage-CS86ho5X.js +0 -1
- package/dist/dashboard/assets/SettingsPage-CS86ho5X.js.gz +0 -0
- package/dist/dashboard/assets/SetupPage-DDbMQsoo.js.gz +0 -0
- package/dist/dashboard/assets/SortableList-CB3Z7VTf.js.gz +0 -0
- package/dist/dashboard/assets/StudioPage-D95UCPHI.js.gz +0 -0
- package/dist/dashboard/assets/UsagePage-Cnae9SWA.js +0 -56
- package/dist/dashboard/assets/UsagePage-Cnae9SWA.js.gz +0 -0
- package/dist/dashboard/assets/api-keys-VY68Mhd1.js +0 -1
- package/dist/dashboard/assets/api-keys-VY68Mhd1.js.gz +0 -0
- package/dist/dashboard/assets/app-COlK95oi.css +0 -2
- package/dist/dashboard/assets/app-COlK95oi.css.gz +0 -0
- package/dist/dashboard/assets/assert-Chi7pCnt.js.gz +0 -0
- package/dist/dashboard/assets/badge-D4pQapKj.js.gz +0 -0
- package/dist/dashboard/assets/banner-DH6cg-_B.js.gz +0 -0
- package/dist/dashboard/assets/card-BKqlUOe4.js.gz +0 -0
- package/dist/dashboard/assets/cli-tools-fTgljTGA.js.gz +0 -0
- package/dist/dashboard/assets/clipboard-button-D5Ys_sM0.js.gz +0 -0
- package/dist/dashboard/assets/dialog-BYj-8WuK.js +0 -1
- package/dist/dashboard/assets/dialog-BYj-8WuK.js.gz +0 -0
- package/dist/dashboard/assets/drawer-CmXIZ6H1.js +0 -1
- package/dist/dashboard/assets/drawer-CmXIZ6H1.js.gz +0 -0
- package/dist/dashboard/assets/entry-CBg5zeu8.js +0 -1
- package/dist/dashboard/assets/entry-CBg5zeu8.js.gz +0 -0
- package/dist/dashboard/assets/entry-CQBr6U9c.js.gz +0 -0
- package/dist/dashboard/assets/entry-CgdSAzEV.js +0 -4
- package/dist/dashboard/assets/entry-CgdSAzEV.js.gz +0 -0
- package/dist/dashboard/assets/fingerprint-pattern-BWuHtbW4.js.gz +0 -0
- package/dist/dashboard/assets/github-badge-BlptL8Uf.js.gz +0 -0
- package/dist/dashboard/assets/index-js6TJvdr.js.gz +0 -0
- package/dist/dashboard/assets/inline-DUWDSmDV.js.gz +0 -0
- package/dist/dashboard/assets/input-LVjgZq4f.js.gz +0 -0
- package/dist/dashboard/assets/layout-CPfxpsfy.js +0 -1
- package/dist/dashboard/assets/layout-CPfxpsfy.js.gz +0 -0
- package/dist/dashboard/assets/list-row-DupvLfDx.js.gz +0 -0
- package/dist/dashboard/assets/live-C4pEJUqI.js.gz +0 -0
- package/dist/dashboard/assets/model-bans-BRiuufkB.js.gz +0 -0
- package/dist/dashboard/assets/mutation-B4ROwk8L.js.gz +0 -0
- package/dist/dashboard/assets/numbers-YNcHvRY7.js +0 -2
- package/dist/dashboard/assets/numbers-YNcHvRY7.js.gz +0 -0
- package/dist/dashboard/assets/query-keys-gmjzC3RS.js.gz +0 -0
- package/dist/dashboard/assets/routing-C7tBT5o-.js.gz +0 -0
- package/dist/dashboard/assets/select-WUCXorXw.js.gz +0 -0
- package/dist/dashboard/assets/settings-C_zKl1GS.js.gz +0 -0
- package/dist/dashboard/assets/stack-_3O0F8Xn.js.gz +0 -0
- package/dist/dashboard/assets/state-BgTxHcJS.js.gz +0 -0
- package/dist/dashboard/assets/switch-DEjTtOWf.js.gz +0 -0
- package/dist/dashboard/assets/tokens-DDxMbSRL.js +0 -1
- package/dist/dashboard/assets/tokens-DDxMbSRL.js.gz +0 -0
- package/dist/dashboard/assets/use-presence-BSZEBx7U.js +0 -1
- package/dist/dashboard/assets/use-presence-BSZEBx7U.js.gz +0 -0
- package/dist/dashboard/assets/useMutation-BWSC0UAS.js.gz +0 -0
- package/scripts/cli/command/admin.ts +0 -65
- package/scripts/cli/command/doctor.ts +0 -257
- package/scripts/cli/gateway/drain-token.ts +0 -78
- package/scripts/cli/gateway/instance-lock.ts +0 -105
- package/scripts/cli/gateway/stop.ts +0 -51
- package/scripts/cli/support/bun.ts +0 -27
- package/scripts/cli/support/version.ts +0 -37
- /package/dist/dashboard/assets/{app-DCQZRmHY.js → app-BIt446yE.js} +0 -0
- /package/dist/dashboard/assets/{app-DCQZRmHY.js.gz → app-BIt446yE.js.gz} +0 -0
- /package/scripts/cli/{ui/prompt.ts → prompt.ts} +0 -0
|
@@ -14,16 +14,26 @@
|
|
|
14
14
|
* threshold bans that IP. A valid request does not reset the window. Attempts
|
|
15
15
|
* older than the window stop counting, so old probes do not accumulate forever.
|
|
16
16
|
*
|
|
17
|
-
* **The ban is keyed on the client address
|
|
18
|
-
* too was the wrong trade: a key is shared by everyone behind a template (every
|
|
19
|
-
* recipient of a share link, every member of a team), so banning the key
|
|
20
|
-
* punishes callers that did nothing while the address that actually probed
|
|
21
|
-
* walks away and mints a fresh key. The address is the identity that cannot be
|
|
22
|
-
* re-enrolled, which is the whole point of the escalation.
|
|
17
|
+
* **The ban is keyed on the client address and the API key that was used.**
|
|
23
18
|
*
|
|
24
|
-
*
|
|
25
|
-
*
|
|
26
|
-
*
|
|
19
|
+
* Each half alone was the wrong trade. The address alone: a key is shared by
|
|
20
|
+
* everyone behind a template (every recipient of a share link, every member of
|
|
21
|
+
* a team), so banning the address punishes callers that did nothing. The key
|
|
22
|
+
* alone: the address that actually probed mints a fresh key and carries on.
|
|
23
|
+
* Together they block the caller that earned it and nobody else.
|
|
24
|
+
*
|
|
25
|
+
* **A known gap, deliberately left open.** A share key's children are issued per
|
|
26
|
+
* recipient, so banning `address|child` leaves that recipient able to enroll a
|
|
27
|
+
* *new* child key from the same address and continue. Closing it by banning the
|
|
28
|
+
* address (or the share template) would take down every other recipient behind
|
|
29
|
+
* the same NAT — exactly the collateral this scoping exists to avoid. The
|
|
30
|
+
* console's "ban every key from this IP" is the operator's opt-in for the case
|
|
31
|
+
* where the address, not the credential, is the problem.
|
|
32
|
+
*
|
|
33
|
+
* **A ban lapses after its TTL** (`CARTETHYIA_MODEL_BAN_TTL_MS` for the
|
|
34
|
+
* graduated path, or the duration an operator chose) rather than staying until
|
|
35
|
+
* someone lifts it, so a false positive heals without human intervention. The
|
|
36
|
+
* console can still lift one early.
|
|
27
37
|
*
|
|
28
38
|
* Fail-open on store outage: unlike admission (where admitting an unauthorized
|
|
29
39
|
* request is worse than refusing a valid one), a strike layer that cannot read
|
|
@@ -32,21 +42,106 @@
|
|
|
32
42
|
* client.
|
|
33
43
|
*/
|
|
34
44
|
import { GatewayError } from "../protocol/gateway-error";
|
|
35
|
-
import { redisEvalTuple, type RedisClient } from "../persistence/redis";
|
|
45
|
+
import { redisEvalNumber, redisEvalTuple, type RedisClient } from "../persistence/redis";
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* A ban is keyed on the address **and** the API key that was used, so banning
|
|
49
|
+
* one caller behind a shared address does not take down everyone else on it.
|
|
50
|
+
*
|
|
51
|
+
* The pair is stored as one opaque string rather than two fields because both
|
|
52
|
+
* stores address a ban by a single member: Redis keeps bans in one sorted set
|
|
53
|
+
* scored by expiry, and a composite key is the only way to keep that a single
|
|
54
|
+
* O(1) lookup. `|` is the separator because neither an IP nor a uuid can
|
|
55
|
+
* contain it, so the split is unambiguous.
|
|
56
|
+
*/
|
|
57
|
+
export function banIdentity(ip: string, apiKeyId?: string | null): string {
|
|
58
|
+
const key = apiKeyId ?? "";
|
|
59
|
+
// No key is the *bare address*, not `address|` with an empty tail: a ban
|
|
60
|
+
// written before scoping existed is exactly this string, and a rolling
|
|
61
|
+
// deploy has to read those back as what they always were — a ban on every
|
|
62
|
+
// key from that address. Giving the two shapes different spellings would
|
|
63
|
+
// silently un-ban every caller the running deployment is blocking.
|
|
64
|
+
return key.length > 0 ? `${ip}|${key}` : ip;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* The address-wide member for `ip`: the same shape, with no key.
|
|
69
|
+
*
|
|
70
|
+
* A ban recorded under it blocks every key used from that address. It is what
|
|
71
|
+
* an operator picks when the address itself is the problem — and, because it
|
|
72
|
+
* is the bare address, it is also the member every pre-scoping ban already
|
|
73
|
+
* uses.
|
|
74
|
+
*/
|
|
75
|
+
export function addressBanIdentity(ip: string): string {
|
|
76
|
+
return ip;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** Splits a ban member back into the address and the key it names. */
|
|
80
|
+
export function parseBanIdentity(
|
|
81
|
+
identity: string,
|
|
82
|
+
): { readonly ip: string; readonly apiKeyId: string | null } {
|
|
83
|
+
const at = identity.indexOf("|");
|
|
84
|
+
// A member with no separator is the bare address: a ban on every key from it,
|
|
85
|
+
// whether an operator asked for that or the ban predates scoping.
|
|
86
|
+
if (at < 0) return { ip: identity, apiKeyId: null };
|
|
87
|
+
const key = identity.slice(at + 1);
|
|
88
|
+
return { ip: identity.slice(0, at), apiKeyId: key.length > 0 ? key : null };
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* The TTL behind the "Permanent" choice.
|
|
93
|
+
*
|
|
94
|
+
* Not literally infinite, because both stores lapse a ban by comparing its
|
|
95
|
+
* expiry to now — an unbounded ban would need a second, different mechanism
|
|
96
|
+
* and a second way to get it wrong. Fifty years is past the point where
|
|
97
|
+
* "lapses on its own" means anything, and the ban is still liftable from the
|
|
98
|
+
* console, which is the property that actually matters: a ban an operator
|
|
99
|
+
* cannot undo is a trap, not a deterrent.
|
|
100
|
+
*/
|
|
101
|
+
export const PERMANENT_BAN_TTL_MS = 50 * 365 * 24 * 60 * 60_000;
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Whether a ban's remaining time can only be the permanent choice.
|
|
105
|
+
*
|
|
106
|
+
* Derived rather than stored, because the stores carry a ban as one scored
|
|
107
|
+
* member with no room for a flag, and a second parallel structure would be a
|
|
108
|
+
* second thing to keep in sync. The longest chooseable non-permanent ban is a
|
|
109
|
+
* week, so a remaining time measured in years has exactly one origin.
|
|
110
|
+
*/
|
|
111
|
+
export function isPermanentBan(expiresAt: number, now: number): boolean {
|
|
112
|
+
return expiresAt - now > 30 * 24 * 60 * 60_000;
|
|
113
|
+
}
|
|
36
114
|
|
|
37
115
|
/** A ban row, for the console. */
|
|
38
116
|
export interface ModelAbuseBan {
|
|
39
117
|
/** The client address the ban is keyed on. */
|
|
40
118
|
readonly ip: string;
|
|
41
|
-
/**
|
|
119
|
+
/**
|
|
120
|
+
* The API key the ban is scoped to, or `null` for every key from that
|
|
121
|
+
* address. `null` is the blunt instrument: it is what the graduated strike
|
|
122
|
+
* path used to always do, and what an operator still picks when the address
|
|
123
|
+
* itself is the problem.
|
|
124
|
+
*/
|
|
125
|
+
readonly apiKeyId: string | null;
|
|
126
|
+
/** Model-abuse policy that triggered this ban. */
|
|
42
127
|
readonly reason: "repeated_invalid_model_requests";
|
|
43
128
|
/** Epoch ms the ban lapses on its own. */
|
|
44
129
|
readonly expiresAt: number;
|
|
130
|
+
/** Whether this is the "Permanent" choice rather than a timed one. */
|
|
131
|
+
readonly permanent: boolean;
|
|
132
|
+
/** The opaque member the store addresses this ban by, for `unban`. */
|
|
133
|
+
readonly identity: string;
|
|
45
134
|
}
|
|
46
135
|
|
|
47
136
|
/** One strike decision, as the store sees it. */
|
|
48
137
|
export interface ModelAbuseAttempt {
|
|
49
138
|
readonly ip: string;
|
|
139
|
+
/**
|
|
140
|
+
* The key this attempt authenticated with, if one was resolved. Scopes both
|
|
141
|
+
* the strike window and the ban it escalates to, so a key shared by several
|
|
142
|
+
* callers does not let one of them spend another's budget.
|
|
143
|
+
*/
|
|
144
|
+
readonly apiKeyId?: string | null;
|
|
50
145
|
/** Whether this attempt named a model the key may use. */
|
|
51
146
|
readonly valid: boolean;
|
|
52
147
|
/** Invalid attempts inside the window that trigger a ban. */
|
|
@@ -90,21 +185,34 @@ function decodeOutcome(raw: readonly unknown[]): ModelAbuseOutcome {
|
|
|
90
185
|
export interface ModelAbuseStore {
|
|
91
186
|
/**
|
|
92
187
|
* One atomic strike decision: reads the ban and records an invalid attempt
|
|
93
|
-
* in the
|
|
188
|
+
* in the caller's sliding window. A valid attempt changes nothing.
|
|
189
|
+
*
|
|
190
|
+
* The window is keyed on the address **and** the key, so two callers sharing
|
|
191
|
+
* one address each get their own budget: a strike is something one caller
|
|
192
|
+
* did, and merging them let a busy caller escalate a ban onto a quiet one.
|
|
94
193
|
*/
|
|
95
194
|
record(attempt: ModelAbuseAttempt): Promise<ModelAbuseOutcome>;
|
|
96
|
-
/**
|
|
97
|
-
|
|
195
|
+
/**
|
|
196
|
+
* O(1) ban lookup for the pre-parse gate, by the opaque member
|
|
197
|
+
* {@link banIdentity} builds.
|
|
198
|
+
*
|
|
199
|
+
* True when that member is banned **or** the address-wide member is — a ban
|
|
200
|
+
* scoped to no key blocks every key from the address, and a ban written
|
|
201
|
+
* before scoping existed is such a member. Both are read here rather than at
|
|
202
|
+
* the call site because the store owns where a ban lives, and a second
|
|
203
|
+
* lookup outside it would be a second round trip per request on Redis.
|
|
204
|
+
*/
|
|
205
|
+
isBanned(identity: string): Promise<boolean>;
|
|
98
206
|
/** Every active ban, for the console. */
|
|
99
207
|
listBans(): Promise<readonly ModelAbuseBan[]>;
|
|
100
|
-
/** Lifts one ban
|
|
101
|
-
unban(
|
|
208
|
+
/** Lifts one ban, by the opaque member the listing reports. */
|
|
209
|
+
unban(identity: string): Promise<boolean>;
|
|
102
210
|
/**
|
|
103
|
-
* Bans one
|
|
211
|
+
* Bans one member immediately for `ttlMs`, regardless of strike history.
|
|
104
212
|
* Returns the ban's expiry (epoch ms). The operator's instant-ban action
|
|
105
213
|
* and the graduated strike path share one ban set, so `unban` lifts both.
|
|
106
214
|
*/
|
|
107
|
-
ban(
|
|
215
|
+
ban(identity: string, ttlMs: number): Promise<number>;
|
|
108
216
|
}
|
|
109
217
|
|
|
110
218
|
interface InMemoryOptions {
|
|
@@ -141,47 +249,65 @@ export class InMemoryModelAbuseStore implements ModelAbuseStore {
|
|
|
141
249
|
}
|
|
142
250
|
|
|
143
251
|
/** The live ban's expiry, or `undefined`; an elapsed ban is dropped. */
|
|
144
|
-
private banExpiry(
|
|
145
|
-
const expiresAt = this.bans.get(
|
|
252
|
+
private banExpiry(identity: string, now: number): number | undefined {
|
|
253
|
+
const expiresAt = this.bans.get(identity);
|
|
146
254
|
if (expiresAt === undefined) return undefined;
|
|
147
255
|
if (expiresAt <= now) {
|
|
148
|
-
this.bans.delete(
|
|
256
|
+
this.bans.delete(identity);
|
|
149
257
|
return undefined;
|
|
150
258
|
}
|
|
151
259
|
return expiresAt;
|
|
152
260
|
}
|
|
153
261
|
|
|
262
|
+
/**
|
|
263
|
+
* Whether `identity` is banned, reading both members that can block it.
|
|
264
|
+
*
|
|
265
|
+
* The entry for this exact address+key, and the address-wide entry. The
|
|
266
|
+
* second is what makes a ban scoped to every key from an address still
|
|
267
|
+
* enforced, and what keeps a ban written before scoping existed — whose
|
|
268
|
+
* member is the bare address — from silently lapsing on deploy.
|
|
269
|
+
*/
|
|
270
|
+
private bannedNow(identity: string, now: number): boolean {
|
|
271
|
+
const { ip, apiKeyId } = parseBanIdentity(identity);
|
|
272
|
+
if (this.banExpiry(identity, now) !== undefined) return true;
|
|
273
|
+
if (apiKeyId === null) return false;
|
|
274
|
+
return this.banExpiry(addressBanIdentity(ip), now) !== undefined;
|
|
275
|
+
}
|
|
276
|
+
|
|
154
277
|
/** Drops attempts older than the window and returns the rest. */
|
|
155
|
-
private liveAttempts(
|
|
278
|
+
private liveAttempts(identity: string, now: number, windowMs: number): number[] {
|
|
156
279
|
const cutoff = now - windowMs;
|
|
157
|
-
const existing = this.strikes.get(
|
|
280
|
+
const existing = this.strikes.get(identity) ?? [];
|
|
158
281
|
// In-place compaction: `.filter` allocated a new array per invalid attempt.
|
|
159
282
|
let write = 0;
|
|
160
283
|
for (let read = 0; read < existing.length; read += 1) {
|
|
161
284
|
if (existing[read]! > cutoff) existing[write++] = existing[read]!;
|
|
162
285
|
}
|
|
163
286
|
existing.length = write;
|
|
164
|
-
if (existing.length === 0) this.strikes.delete(
|
|
165
|
-
else this.strikes.set(
|
|
287
|
+
if (existing.length === 0) this.strikes.delete(identity);
|
|
288
|
+
else this.strikes.set(identity, existing);
|
|
166
289
|
return existing;
|
|
167
290
|
}
|
|
168
291
|
|
|
169
292
|
async record(attempt: ModelAbuseAttempt): Promise<ModelAbuseOutcome> {
|
|
170
293
|
const now = this.clock();
|
|
171
|
-
const { ip, valid, threshold, windowMs, banTtlMs } = attempt;
|
|
294
|
+
const { ip, apiKeyId, valid, threshold, windowMs, banTtlMs } = attempt;
|
|
295
|
+
const identity = banIdentity(ip, apiKeyId);
|
|
172
296
|
|
|
173
|
-
if (this.
|
|
174
|
-
return { banned: true, strikes: 0, bannedNow: false };
|
|
297
|
+
if (this.bannedNow(identity, now)) return { banned: true, strikes: 0, bannedNow: false };
|
|
175
298
|
|
|
176
299
|
if (valid) return { banned: false, strikes: 0, bannedNow: false };
|
|
177
300
|
|
|
178
|
-
const live = this.liveAttempts(
|
|
301
|
+
const live = this.liveAttempts(identity, now, windowMs);
|
|
179
302
|
live.push(now);
|
|
180
303
|
this.evictOldest();
|
|
181
|
-
this.strikes.set(
|
|
304
|
+
this.strikes.set(identity, live);
|
|
182
305
|
if (live.length >= threshold) {
|
|
183
|
-
|
|
184
|
-
|
|
306
|
+
// Scoped to the key that earned it. Banning the address alone would take
|
|
307
|
+
// down every other caller behind the same NAT or share link, none of
|
|
308
|
+
// whom made the invalid requests.
|
|
309
|
+
this.bans.set(identity, now + banTtlMs);
|
|
310
|
+
this.strikes.delete(identity);
|
|
185
311
|
return { banned: true, strikes: live.length, bannedNow: true };
|
|
186
312
|
}
|
|
187
313
|
return { banned: false, strikes: live.length, bannedNow: false };
|
|
@@ -191,24 +317,35 @@ export class InMemoryModelAbuseStore implements ModelAbuseStore {
|
|
|
191
317
|
async listBans(): Promise<readonly ModelAbuseBan[]> {
|
|
192
318
|
const now = this.clock();
|
|
193
319
|
const bans: ModelAbuseBan[] = [];
|
|
194
|
-
for (const [
|
|
195
|
-
if (expiresAt <= now)
|
|
196
|
-
|
|
320
|
+
for (const [identity, expiresAt] of [...this.bans]) {
|
|
321
|
+
if (expiresAt <= now) {
|
|
322
|
+
this.bans.delete(identity);
|
|
323
|
+
continue;
|
|
324
|
+
}
|
|
325
|
+
const { ip, apiKeyId } = parseBanIdentity(identity);
|
|
326
|
+
bans.push({
|
|
327
|
+
ip,
|
|
328
|
+
apiKeyId,
|
|
329
|
+
reason: "repeated_invalid_model_requests",
|
|
330
|
+
expiresAt,
|
|
331
|
+
permanent: isPermanentBan(expiresAt, now),
|
|
332
|
+
identity,
|
|
333
|
+
});
|
|
197
334
|
}
|
|
198
335
|
return bans;
|
|
199
336
|
}
|
|
200
337
|
|
|
201
|
-
async isBanned(
|
|
202
|
-
return this.
|
|
338
|
+
async isBanned(identity: string): Promise<boolean> {
|
|
339
|
+
return this.bannedNow(identity, this.clock());
|
|
203
340
|
}
|
|
204
341
|
|
|
205
|
-
async unban(
|
|
206
|
-
return this.bans.delete(
|
|
342
|
+
async unban(identity: string): Promise<boolean> {
|
|
343
|
+
return this.bans.delete(identity);
|
|
207
344
|
}
|
|
208
345
|
|
|
209
|
-
async ban(
|
|
346
|
+
async ban(identity: string, ttlMs: number): Promise<number> {
|
|
210
347
|
const expiresAt = this.clock() + Math.max(1, ttlMs);
|
|
211
|
-
this.bans.set(
|
|
348
|
+
this.bans.set(identity, expiresAt);
|
|
212
349
|
return expiresAt;
|
|
213
350
|
}
|
|
214
351
|
}
|
|
@@ -216,31 +353,41 @@ export class InMemoryModelAbuseStore implements ModelAbuseStore {
|
|
|
216
353
|
/**
|
|
217
354
|
* The one strike decision, as a static Lua script.
|
|
218
355
|
*
|
|
219
|
-
* `KEYS[1]` the
|
|
220
|
-
* 1 valid(`1`/`0`), 2 windowMs, 3
|
|
221
|
-
* banTtlMs
|
|
356
|
+
* `KEYS[1]` the attempt sorted set for this address+key, `KEYS[2]` the ban
|
|
357
|
+
* sorted set. `ARGV`: 1 valid(`1`/`0`), 2 windowMs, 3 this caller's ban
|
|
358
|
+
* member, 4 now (epoch ms), 5 threshold, 6 banTtlMs, 7 the address-wide ban
|
|
359
|
+
* member.
|
|
222
360
|
*
|
|
223
361
|
* Returns `{banned, strikes, bannedNow}`. The ban is read first and
|
|
224
|
-
* short-circuits without writing, so a banned
|
|
225
|
-
* window
|
|
226
|
-
*
|
|
227
|
-
*
|
|
228
|
-
*
|
|
229
|
-
*
|
|
230
|
-
*
|
|
362
|
+
* short-circuits without writing, so a banned caller cannot extend its own
|
|
363
|
+
* window — and it is read for *both* members: the one naming this key, and the
|
|
364
|
+
* address-wide one. The second is what keeps a ban scoped to "every key from
|
|
365
|
+
* this address" enforced, and what keeps a ban written before scoping existed
|
|
366
|
+
* (whose member is the bare address) from lapsing on deploy. Both reads happen
|
|
367
|
+
* inside the script because the gate runs on every `/v1/*` request: two
|
|
368
|
+
* `ZSCORE` calls from the caller would be two round trips per request.
|
|
369
|
+
*
|
|
370
|
+
* Each invalid attempt is one member scored by its instant. Members older than
|
|
371
|
+
* the window are removed before the count, so a valid request does not reset
|
|
372
|
+
* it and an old attempt leaves on its own. A ban is a member of the ban set
|
|
373
|
+
* scored with the instant it lapses. `now` is the caller's clock rather than
|
|
374
|
+
* the server's so the in-memory and Redis stores age a ban identically.
|
|
231
375
|
*/
|
|
232
376
|
const MODEL_ABUSE_SCRIPT = `
|
|
233
|
-
local
|
|
234
|
-
|
|
235
|
-
if
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
377
|
+
local function liveBan(member, now)
|
|
378
|
+
local score = redis.call('ZSCORE', KEYS[2], member)
|
|
379
|
+
if not score then return false end
|
|
380
|
+
if tonumber(score) > now then return true end
|
|
381
|
+
redis.call('ZREM', KEYS[2], member)
|
|
382
|
+
return false
|
|
383
|
+
end
|
|
384
|
+
local now = tonumber(ARGV[4])
|
|
385
|
+
if liveBan(ARGV[3], now) or liveBan(ARGV[7], now) then
|
|
386
|
+
return {1, 0, 0}
|
|
239
387
|
end
|
|
240
388
|
if ARGV[1] == '1' then
|
|
241
389
|
return {0, 0, 0}
|
|
242
390
|
end
|
|
243
|
-
local now = tonumber(ARGV[4])
|
|
244
391
|
local windowMs = tonumber(ARGV[2])
|
|
245
392
|
redis.call('ZREMRANGEBYSCORE', KEYS[1], '-inf', now - windowMs)
|
|
246
393
|
local seq = redis.call('INCR', KEYS[1] .. ':seq')
|
|
@@ -256,41 +403,72 @@ const MODEL_ABUSE_SCRIPT = `
|
|
|
256
403
|
return {0, count, 0}
|
|
257
404
|
`;
|
|
258
405
|
|
|
406
|
+
/**
|
|
407
|
+
* The pre-parse ban check, as a static Lua script.
|
|
408
|
+
*
|
|
409
|
+
* Reads both members that can block a caller — this address+key, and the
|
|
410
|
+
* address-wide one — and returns `1` or `0`. One script rather than two
|
|
411
|
+
* `ZSCORE` calls from the caller because this runs on every `/v1/*` request,
|
|
412
|
+
* where a second round trip is a second hop on the hot path. It also drops a
|
|
413
|
+
* member it finds elapsed, which is what keeps the ban set from accumulating
|
|
414
|
+
* dead entries no request will ever look up again.
|
|
415
|
+
*/
|
|
416
|
+
const MODEL_ABUSE_BAN_CHECK_SCRIPT = `
|
|
417
|
+
local function liveBan(member, now)
|
|
418
|
+
local score = redis.call('ZSCORE', KEYS[1], member)
|
|
419
|
+
if not score then return false end
|
|
420
|
+
if tonumber(score) > now then return true end
|
|
421
|
+
redis.call('ZREM', KEYS[1], member)
|
|
422
|
+
return false
|
|
423
|
+
end
|
|
424
|
+
local now = tonumber(ARGV[3])
|
|
425
|
+
if liveBan(ARGV[1], now) or liveBan(ARGV[2], now) then
|
|
426
|
+
return 1
|
|
427
|
+
end
|
|
428
|
+
return 0
|
|
429
|
+
`;
|
|
430
|
+
|
|
259
431
|
export class RedisModelAbuseStore implements ModelAbuseStore {
|
|
260
432
|
constructor(
|
|
261
433
|
private readonly redis: RedisClient,
|
|
262
434
|
private readonly clock: () => number = () => Date.now(),
|
|
263
435
|
) {}
|
|
264
436
|
|
|
265
|
-
private strikeKey(
|
|
266
|
-
return `cartethyia:model-abuse:strike
|
|
437
|
+
private strikeKey(identity: string): string {
|
|
438
|
+
return `cartethyia:model-abuse:strike:${identity}`;
|
|
267
439
|
}
|
|
268
440
|
/**
|
|
269
441
|
* The ban index. It is a sorted set scored with each ban's expiry instant,
|
|
270
442
|
* under a name of its own: the previous permanent-ban *set* cannot be read as
|
|
271
443
|
* a sorted set, and reading it anyway would raise WRONGTYPE on every check.
|
|
272
|
-
* A member from before
|
|
444
|
+
* A member from before that change is therefore simply gone — the right
|
|
273
445
|
* outcome, since those bans were recorded without an expiry this store cannot
|
|
274
446
|
* invent.
|
|
447
|
+
*
|
|
448
|
+
* The members are now `address|key` composites, and a member with no key
|
|
449
|
+
* blocks every key from its address — which is also how a pre-scoping member
|
|
450
|
+
* reads, since those were the bare address.
|
|
275
451
|
*/
|
|
276
452
|
private banKey(): string {
|
|
277
|
-
return "cartethyia:model-abuse:bans
|
|
453
|
+
return "cartethyia:model-abuse:bans";
|
|
278
454
|
}
|
|
279
455
|
|
|
280
456
|
async record(attempt: ModelAbuseAttempt): Promise<ModelAbuseOutcome> {
|
|
281
|
-
const { ip, valid, threshold, windowMs, banTtlMs } = attempt;
|
|
457
|
+
const { ip, apiKeyId, valid, threshold, windowMs, banTtlMs } = attempt;
|
|
458
|
+
const identity = banIdentity(ip, apiKeyId);
|
|
282
459
|
const raw = await redisEvalTuple(
|
|
283
460
|
this.redis,
|
|
284
461
|
MODEL_ABUSE_SCRIPT,
|
|
285
462
|
2,
|
|
286
|
-
this.strikeKey(
|
|
463
|
+
this.strikeKey(identity),
|
|
287
464
|
this.banKey(),
|
|
288
465
|
valid ? "1" : "0",
|
|
289
466
|
String(Math.max(1, windowMs)),
|
|
290
|
-
|
|
467
|
+
identity,
|
|
291
468
|
String(this.clock()),
|
|
292
469
|
String(threshold),
|
|
293
470
|
String(Math.max(1, banTtlMs)),
|
|
471
|
+
addressBanIdentity(ip),
|
|
294
472
|
);
|
|
295
473
|
return decodeOutcome(raw);
|
|
296
474
|
}
|
|
@@ -301,31 +479,48 @@ export class RedisModelAbuseStore implements ModelAbuseStore {
|
|
|
301
479
|
const entries = await this.redis.zrange(this.banKey(), "0", "-1", "WITHSCORES");
|
|
302
480
|
const bans: ModelAbuseBan[] = [];
|
|
303
481
|
for (let index = 0; index + 1 < entries.length; index += 2) {
|
|
304
|
-
const
|
|
482
|
+
const identity = entries[index];
|
|
305
483
|
const score = entries[index + 1];
|
|
306
|
-
if (
|
|
307
|
-
|
|
484
|
+
if (identity === undefined || score === undefined) break;
|
|
485
|
+
const expiresAt = Number(score);
|
|
486
|
+
if (!Number.isFinite(expiresAt)) continue;
|
|
487
|
+
const { ip, apiKeyId } = parseBanIdentity(identity);
|
|
488
|
+
bans.push({
|
|
489
|
+
ip,
|
|
490
|
+
apiKeyId,
|
|
491
|
+
reason: "repeated_invalid_model_requests",
|
|
492
|
+
expiresAt,
|
|
493
|
+
permanent: isPermanentBan(expiresAt, now),
|
|
494
|
+
identity,
|
|
495
|
+
});
|
|
308
496
|
}
|
|
309
497
|
return bans;
|
|
310
498
|
}
|
|
311
499
|
|
|
312
|
-
async isBanned(
|
|
313
|
-
const
|
|
314
|
-
const
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
500
|
+
async isBanned(identity: string): Promise<boolean> {
|
|
501
|
+
const { ip, apiKeyId } = parseBanIdentity(identity);
|
|
502
|
+
const value = await redisEvalNumber(
|
|
503
|
+
this.redis,
|
|
504
|
+
MODEL_ABUSE_BAN_CHECK_SCRIPT,
|
|
505
|
+
1,
|
|
506
|
+
this.banKey(),
|
|
507
|
+
identity,
|
|
508
|
+
// A ban scoped to no key is its own address-wide member, so the second
|
|
509
|
+
// read is the same member — harmless, and it keeps the script one shape.
|
|
510
|
+
apiKeyId === null ? identity : addressBanIdentity(ip),
|
|
511
|
+
String(this.clock()),
|
|
512
|
+
);
|
|
513
|
+
return value === 1;
|
|
319
514
|
}
|
|
320
515
|
|
|
321
|
-
async unban(
|
|
322
|
-
const removed = await this.redis.zrem(this.banKey(),
|
|
516
|
+
async unban(identity: string): Promise<boolean> {
|
|
517
|
+
const removed = await this.redis.zrem(this.banKey(), identity);
|
|
323
518
|
return Number(removed) > 0;
|
|
324
519
|
}
|
|
325
520
|
|
|
326
|
-
async ban(
|
|
521
|
+
async ban(identity: string, ttlMs: number): Promise<number> {
|
|
327
522
|
const expiresAt = this.clock() + Math.max(1, ttlMs);
|
|
328
|
-
await this.redis.zadd(this.banKey(), String(expiresAt),
|
|
523
|
+
await this.redis.zadd(this.banKey(), String(expiresAt), identity);
|
|
329
524
|
return expiresAt;
|
|
330
525
|
}
|
|
331
526
|
}
|
|
@@ -335,8 +530,15 @@ export interface ModelStrikeOptions {
|
|
|
335
530
|
readonly threshold?: number;
|
|
336
531
|
/** Sliding window (ms) that keeps an invalid attempt countable. Default 30 minutes. */
|
|
337
532
|
readonly windowMs?: number;
|
|
338
|
-
/** How long (ms) a ban lasts before it lapses. Default 6 hours. */
|
|
533
|
+
/** How long (ms) a graduated ban lasts before it lapses. Default 6 hours. */
|
|
339
534
|
readonly banTtlMs?: number;
|
|
535
|
+
/**
|
|
536
|
+
* The longest ban this service will record, however long a caller asks for.
|
|
537
|
+
* Default one week. The "Permanent" choice is not exempt: it is a very long
|
|
538
|
+
* TTL, not an unbounded one, and this is the bound that keeps a typo in a
|
|
539
|
+
* duration from parking a caller for a decade.
|
|
540
|
+
*/
|
|
541
|
+
readonly maxBanTtlMs?: number;
|
|
340
542
|
}
|
|
341
543
|
|
|
342
544
|
/**
|
|
@@ -349,11 +551,19 @@ export class ModelStrikeService {
|
|
|
349
551
|
private readonly threshold: number;
|
|
350
552
|
private readonly windowMs: number;
|
|
351
553
|
private readonly _banTtlMs: number;
|
|
554
|
+
private readonly _maxBanTtlMs: number;
|
|
352
555
|
|
|
353
556
|
constructor(private readonly store: ModelAbuseStore, opts: ModelStrikeOptions = {}) {
|
|
354
557
|
this.threshold = Math.max(1, opts.threshold ?? 10);
|
|
355
558
|
this.windowMs = Math.max(1, opts.windowMs ?? 30 * 60_000);
|
|
356
559
|
this._banTtlMs = Math.max(1, opts.banTtlMs ?? 6 * 60 * 60_000);
|
|
560
|
+
// The operator-facing ceiling defaults to a week — the same upper bound the
|
|
561
|
+
// deployment env var accepts — so a request cannot ask for a ban longer
|
|
562
|
+
// than the longest one an operator can configure by hand.
|
|
563
|
+
this._maxBanTtlMs = Math.max(
|
|
564
|
+
this._banTtlMs,
|
|
565
|
+
Math.max(1, opts.maxBanTtlMs ?? 7 * 24 * 60 * 60_000),
|
|
566
|
+
);
|
|
357
567
|
}
|
|
358
568
|
|
|
359
569
|
/** The ban threshold, for an operator-facing warning message. */
|
|
@@ -365,15 +575,31 @@ export class ModelStrikeService {
|
|
|
365
575
|
return this._banTtlMs;
|
|
366
576
|
}
|
|
367
577
|
|
|
368
|
-
/**
|
|
369
|
-
|
|
370
|
-
return this.
|
|
578
|
+
/** The longest ban this service will record, however long one is asked for. */
|
|
579
|
+
get maxBanTtlMs(): number {
|
|
580
|
+
return this._maxBanTtlMs;
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
/**
|
|
584
|
+
* Read-only ban lookup for the pre-parse gate. Throws only on store outage.
|
|
585
|
+
*
|
|
586
|
+
* `apiKeyId` decides how narrow the answer is. Absent (an unauthenticated
|
|
587
|
+
* attempt, or a composition that does not carry the key) asks the blunt
|
|
588
|
+
* question — is this address banned outright — which is also the only
|
|
589
|
+
* question a caller with no key can be asked.
|
|
590
|
+
*/
|
|
591
|
+
async check(input: { readonly ip: string; readonly apiKeyId?: string | null }): Promise<boolean> {
|
|
592
|
+
return this.store.isBanned(banIdentity(input.ip, input.apiKeyId));
|
|
371
593
|
}
|
|
372
594
|
|
|
373
595
|
/** Records one invalid-model attempt and returns the escalation decision. */
|
|
374
|
-
async noteInvalid(input: {
|
|
596
|
+
async noteInvalid(input: {
|
|
597
|
+
readonly ip: string;
|
|
598
|
+
readonly apiKeyId?: string | null;
|
|
599
|
+
}): Promise<ModelAbuseOutcome> {
|
|
375
600
|
return this.store.record({
|
|
376
601
|
ip: input.ip,
|
|
602
|
+
...(input.apiKeyId === undefined ? {} : { apiKeyId: input.apiKeyId }),
|
|
377
603
|
valid: false,
|
|
378
604
|
threshold: this.threshold,
|
|
379
605
|
windowMs: this.windowMs,
|
|
@@ -388,14 +614,50 @@ export class ModelStrikeService {
|
|
|
388
614
|
return this.store.listBans();
|
|
389
615
|
}
|
|
390
616
|
|
|
391
|
-
async unban(
|
|
392
|
-
return this.store.unban(
|
|
617
|
+
async unban(identity: string): Promise<boolean> {
|
|
618
|
+
return this.store.unban(identity);
|
|
393
619
|
}
|
|
394
620
|
|
|
395
|
-
/**
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
621
|
+
/**
|
|
622
|
+
* Operator instant-ban: one caller for one chosen duration, no strike
|
|
623
|
+
* history needed.
|
|
624
|
+
*
|
|
625
|
+
* `apiKeyId: null` is the deliberate blunt instrument — it bans every key
|
|
626
|
+
* used from that address, which is what an operator wants when the address
|
|
627
|
+
* is the problem rather than the credential. `addressOnly` is therefore not
|
|
628
|
+
* a fallback for a missing key; it is a choice the console makes visible.
|
|
629
|
+
*
|
|
630
|
+
* `ttlMs` is clamped to `maxBanTtlMs` here rather than only at the route:
|
|
631
|
+
* the service is the last layer before the store, and a bound that only
|
|
632
|
+
* exists in HTTP is one a caller in-process can walk past. The one value
|
|
633
|
+
* that escapes the clamp is {@link PERMANENT_BAN_TTL_MS} — it *is* the
|
|
634
|
+
* clamp's own ceiling in the other direction, so a request asking for it is
|
|
635
|
+
* asking for "permanent" and gets exactly that, no more.
|
|
636
|
+
*/
|
|
637
|
+
async ban(input: {
|
|
638
|
+
readonly ip: string;
|
|
639
|
+
readonly apiKeyId?: string | null;
|
|
640
|
+
readonly ttlMs?: number;
|
|
641
|
+
readonly addressOnly?: boolean;
|
|
642
|
+
}): Promise<ModelAbuseBan> {
|
|
643
|
+
const identity = input.addressOnly
|
|
644
|
+
? addressBanIdentity(input.ip)
|
|
645
|
+
: banIdentity(input.ip, input.apiKeyId);
|
|
646
|
+
const requested = input.ttlMs ?? this.banTtlMs;
|
|
647
|
+
const ttlMs =
|
|
648
|
+
requested >= PERMANENT_BAN_TTL_MS
|
|
649
|
+
? PERMANENT_BAN_TTL_MS
|
|
650
|
+
: Math.min(Math.max(1, Math.floor(requested)), this.maxBanTtlMs);
|
|
651
|
+
const expiresAt = await this.store.ban(identity, ttlMs);
|
|
652
|
+
const { ip, apiKeyId } = parseBanIdentity(identity);
|
|
653
|
+
return {
|
|
654
|
+
ip,
|
|
655
|
+
apiKeyId,
|
|
656
|
+
reason: "repeated_invalid_model_requests",
|
|
657
|
+
expiresAt,
|
|
658
|
+
permanent: isPermanentBan(expiresAt, Date.now()),
|
|
659
|
+
identity,
|
|
660
|
+
};
|
|
399
661
|
}
|
|
400
662
|
}
|
|
401
663
|
|
|
@@ -405,10 +667,16 @@ export class ModelStrikeService {
|
|
|
405
667
|
* The message names the reason without the ban duration.
|
|
406
668
|
*/
|
|
407
669
|
export function modelAbuseBannedError(): GatewayError {
|
|
670
|
+
// English, because this is a client-facing wire message: the gateway's other
|
|
671
|
+
// public errors are English, and a client that pattern-matches on the text
|
|
672
|
+
// (or an operator reading a foreign-language response) cannot act on one that
|
|
673
|
+
// is not. It also names the scope — the ban is this address *with this key*,
|
|
674
|
+
// not the address outright — so a caller behind a shared address knows a
|
|
675
|
+
// different key from the same address may still work.
|
|
408
676
|
return new GatewayError(
|
|
409
677
|
"model_abuse_banned",
|
|
410
678
|
403,
|
|
411
|
-
"
|
|
679
|
+
"This address is temporarily banned for this API key after too many requests for models this key cannot use. Wait for the ban to lift, then list models to see which ones this key may call.",
|
|
412
680
|
);
|
|
413
681
|
}
|
|
414
682
|
|
|
@@ -422,11 +690,15 @@ export function modelWarningMessage(
|
|
|
422
690
|
limit: number,
|
|
423
691
|
): string {
|
|
424
692
|
const remaining = Math.max(0, limit - outcome.strikes);
|
|
693
|
+
// "this IP" is now a lie when the ban is scoped to the key: the escalation
|
|
694
|
+
// blocks this address *with this key*, and a caller behind a shared address
|
|
695
|
+
// is entitled to know that switching keys will not help but a different
|
|
696
|
+
// caller on the same address is not affected.
|
|
425
697
|
const tail =
|
|
426
698
|
remaining === 0
|
|
427
|
-
? "The next invalid request will temporarily ban this IP. Use /v1/models to see allowed models."
|
|
699
|
+
? "The next invalid request will temporarily ban this IP for this API key. Use /v1/models to see allowed models."
|
|
428
700
|
: remaining === 1
|
|
429
|
-
? "1 more invalid request will temporarily ban this IP. Use /v1/models to see allowed models."
|
|
430
|
-
: `${remaining} more invalid requests will temporarily ban this IP. Use /v1/models to see allowed models.`;
|
|
701
|
+
? "1 more invalid request will temporarily ban this IP for this API key. Use /v1/models to see allowed models."
|
|
702
|
+
: `${remaining} more invalid requests will temporarily ban this IP for this API key. Use /v1/models to see allowed models.`;
|
|
431
703
|
return `Model '${model}' is not available to this API key. Warning ${outcome.strikes}/${limit} — ${tail}`;
|
|
432
704
|
}
|