cartethyia 2.5.4 → 2.5.7

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.
Files changed (395) hide show
  1. package/.env.example +13 -4
  2. package/README.md +2 -2
  3. package/dist/dashboard/assets/{BannedPage-C2orBsI2.js → BannedPage-DE2607Tb.js} +1 -1
  4. package/dist/dashboard/assets/BannedPage-DE2607Tb.js.gz +0 -0
  5. package/dist/dashboard/assets/CliToolDetailPage-Duv6mfrl.js +1 -0
  6. package/dist/dashboard/assets/CliToolDetailPage-Duv6mfrl.js.gz +0 -0
  7. package/dist/dashboard/assets/{CliToolsPage-BV6pxVQo.js → CliToolsPage-C2PNYWzk.js} +1 -1
  8. package/dist/dashboard/assets/CliToolsPage-C2PNYWzk.js.gz +0 -0
  9. package/dist/dashboard/assets/CombosPage-TXP9OfAM.js +1 -0
  10. package/dist/dashboard/assets/CombosPage-TXP9OfAM.js.gz +0 -0
  11. package/dist/dashboard/assets/{ConsoleLogPage-CW-wPmJ6.js → ConsoleLogPage-B-9xnAQN.js} +1 -1
  12. package/dist/dashboard/assets/ConsoleLogPage-B-9xnAQN.js.gz +0 -0
  13. package/dist/dashboard/assets/{CustomizationPage-0p8uX7vB.js → CustomizationPage-xeax9nJt.js} +1 -1
  14. package/dist/dashboard/assets/CustomizationPage-xeax9nJt.js.gz +0 -0
  15. package/dist/dashboard/assets/{HealthEventsModal-BjqSMfko.js → HealthEventsModal-Df4jjaHJ.js} +1 -1
  16. package/dist/dashboard/assets/HealthEventsModal-Df4jjaHJ.js.gz +0 -0
  17. package/dist/dashboard/assets/{LoginPage-BvzCx8Kr.js → LoginPage-Dyojsm84.js} +1 -1
  18. package/dist/dashboard/assets/LoginPage-Dyojsm84.js.gz +0 -0
  19. package/dist/dashboard/assets/ModelPicker-C7pDb41R.js +1 -0
  20. package/dist/dashboard/assets/ModelPicker-C7pDb41R.js.gz +0 -0
  21. package/dist/dashboard/assets/OverviewPage-BJfg4ow6.js +1 -0
  22. package/dist/dashboard/assets/OverviewPage-BJfg4ow6.js.gz +0 -0
  23. package/dist/dashboard/assets/ProviderDetailPage-ChCX1gmT.js +3 -0
  24. package/dist/dashboard/assets/ProviderDetailPage-ChCX1gmT.js.gz +0 -0
  25. package/dist/dashboard/assets/{ProvidersPage-Js9jUPSb.js → ProvidersPage-DeWSOEUO.js} +1 -1
  26. package/dist/dashboard/assets/ProvidersPage-DeWSOEUO.js.gz +0 -0
  27. package/dist/dashboard/assets/ProxyPage-CkpWln2S.js +7 -0
  28. package/dist/dashboard/assets/ProxyPage-CkpWln2S.js.gz +0 -0
  29. package/dist/dashboard/assets/QuotaPage-B3VOgQmt.js +1 -0
  30. package/dist/dashboard/assets/QuotaPage-B3VOgQmt.js.gz +0 -0
  31. package/dist/dashboard/assets/SettingsPage-C_klIZom.js +1 -0
  32. package/dist/dashboard/assets/SettingsPage-C_klIZom.js.gz +0 -0
  33. package/dist/dashboard/assets/{SetupPage-DDbMQsoo.js → SetupPage-BYph-InK.js} +1 -1
  34. package/dist/dashboard/assets/SetupPage-BYph-InK.js.gz +0 -0
  35. package/dist/dashboard/assets/{SortableList-CB3Z7VTf.js → SortableList-CvyQCYWJ.js} +1 -1
  36. package/dist/dashboard/assets/SortableList-CvyQCYWJ.js.gz +0 -0
  37. package/dist/dashboard/assets/{StudioPage-D95UCPHI.js → StudioPage-Cm1JYilI.js} +3 -3
  38. package/dist/dashboard/assets/StudioPage-Cm1JYilI.js.gz +0 -0
  39. package/dist/dashboard/assets/UsagePage-DlFnc3_S.js +56 -0
  40. package/dist/dashboard/assets/UsagePage-DlFnc3_S.js.gz +0 -0
  41. package/dist/dashboard/assets/api-keys-DQYLQxpW.js +1 -0
  42. package/dist/dashboard/assets/api-keys-DQYLQxpW.js.gz +0 -0
  43. package/dist/dashboard/assets/app-CX_Z8cMs.css +2 -0
  44. package/dist/dashboard/assets/app-CX_Z8cMs.css.gz +0 -0
  45. package/dist/dashboard/assets/{assert-Chi7pCnt.js → assert-C9FD_JwJ.js} +1 -1
  46. package/dist/dashboard/assets/assert-C9FD_JwJ.js.gz +0 -0
  47. package/dist/dashboard/assets/{badge-D4pQapKj.js → badge-CR72dasT.js} +1 -1
  48. package/dist/dashboard/assets/badge-CR72dasT.js.gz +0 -0
  49. package/dist/dashboard/assets/{banner-DH6cg-_B.js → banner-CI9_eSfO.js} +1 -1
  50. package/dist/dashboard/assets/banner-CI9_eSfO.js.gz +0 -0
  51. package/dist/dashboard/assets/{card-BKqlUOe4.js → card-DS7CQEsO.js} +1 -1
  52. package/dist/dashboard/assets/card-DS7CQEsO.js.gz +0 -0
  53. package/dist/dashboard/assets/{cli-tools-fTgljTGA.js → cli-tools-CNqzrfdb.js} +1 -1
  54. package/dist/dashboard/assets/cli-tools-CNqzrfdb.js.gz +0 -0
  55. package/dist/dashboard/assets/{clipboard-button-D5Ys_sM0.js → clipboard-button-611pSsaX.js} +1 -1
  56. package/dist/dashboard/assets/clipboard-button-611pSsaX.js.gz +0 -0
  57. package/dist/dashboard/assets/{fingerprint-pattern-BWuHtbW4.js → concurrency-BDF0ok7y.js} +1 -1
  58. package/dist/dashboard/assets/concurrency-BDF0ok7y.js.gz +0 -0
  59. package/dist/dashboard/assets/dialog-k37exjNt.js +1 -0
  60. package/dist/dashboard/assets/dialog-k37exjNt.js.gz +0 -0
  61. package/dist/dashboard/assets/drawer-CX7N8ucR.js +1 -0
  62. package/dist/dashboard/assets/drawer-CX7N8ucR.js.gz +0 -0
  63. package/dist/dashboard/assets/{entry-CQBr6U9c.js → entry-B9Xvtnfj.js} +1 -1
  64. package/dist/dashboard/assets/entry-B9Xvtnfj.js.gz +0 -0
  65. package/dist/dashboard/assets/entry-Bog0p925.js +4 -0
  66. package/dist/dashboard/assets/entry-Bog0p925.js.gz +0 -0
  67. package/dist/dashboard/assets/entry-CTP8AyiS.js +1 -0
  68. package/dist/dashboard/assets/entry-CTP8AyiS.js.gz +0 -0
  69. package/dist/dashboard/assets/{github-badge-BlptL8Uf.js → github-badge-sltk1qUi.js} +1 -1
  70. package/dist/dashboard/assets/github-badge-sltk1qUi.js.gz +0 -0
  71. package/dist/dashboard/assets/{index-js6TJvdr.js → index-CZQIrdoX.js} +2 -2
  72. package/dist/dashboard/assets/index-CZQIrdoX.js.gz +0 -0
  73. package/dist/dashboard/assets/{inline-DUWDSmDV.js → inline-BhY2sSnO.js} +1 -1
  74. package/dist/dashboard/assets/inline-BhY2sSnO.js.gz +0 -0
  75. package/dist/dashboard/assets/{input-LVjgZq4f.js → input-V1umFiBk.js} +1 -1
  76. package/dist/dashboard/assets/input-V1umFiBk.js.gz +0 -0
  77. package/dist/dashboard/assets/layout-BBOYycyS.js +1 -0
  78. package/dist/dashboard/assets/layout-BBOYycyS.js.gz +0 -0
  79. package/dist/dashboard/assets/{list-row-DupvLfDx.js → list-row-1zzZYM0B.js} +1 -1
  80. package/dist/dashboard/assets/list-row-1zzZYM0B.js.gz +0 -0
  81. package/dist/dashboard/assets/{live-C4pEJUqI.js → live-BivGfuUs.js} +1 -1
  82. package/dist/dashboard/assets/live-BivGfuUs.js.gz +0 -0
  83. package/dist/dashboard/assets/{model-bans-BRiuufkB.js → model-bans-CmffjLuG.js} +1 -1
  84. package/dist/dashboard/assets/model-bans-CmffjLuG.js.gz +0 -0
  85. package/dist/dashboard/assets/{mutation-B4ROwk8L.js → mutation-46P0oWMe.js} +1 -1
  86. package/dist/dashboard/assets/mutation-46P0oWMe.js.gz +0 -0
  87. package/dist/dashboard/assets/numbers-DkfMR5Qs.js +2 -0
  88. package/dist/dashboard/assets/numbers-DkfMR5Qs.js.gz +0 -0
  89. package/dist/dashboard/assets/{query-keys-gmjzC3RS.js → query-keys-BFhGpwR1.js} +1 -1
  90. package/dist/dashboard/assets/query-keys-BFhGpwR1.js.gz +0 -0
  91. package/dist/dashboard/assets/{routing-C7tBT5o-.js → routing-CAb4F37R.js} +1 -1
  92. package/dist/dashboard/assets/routing-CAb4F37R.js.gz +0 -0
  93. package/dist/dashboard/assets/{select-WUCXorXw.js → select-BPpSsBgL.js} +1 -1
  94. package/dist/dashboard/assets/select-BPpSsBgL.js.gz +0 -0
  95. package/dist/dashboard/assets/{settings-C_zKl1GS.js → settings-DNX9sydi.js} +1 -1
  96. package/dist/dashboard/assets/settings-DNX9sydi.js.gz +0 -0
  97. package/dist/dashboard/assets/{stack-_3O0F8Xn.js → stack-BHTfXdz_.js} +1 -1
  98. package/dist/dashboard/assets/stack-BHTfXdz_.js.gz +0 -0
  99. package/dist/dashboard/assets/{state-BgTxHcJS.js → state-mPDRlQkl.js} +1 -1
  100. package/dist/dashboard/assets/state-mPDRlQkl.js.gz +0 -0
  101. package/dist/dashboard/assets/{switch-DEjTtOWf.js → switch-DOmJxg7H.js} +1 -1
  102. package/dist/dashboard/assets/switch-DOmJxg7H.js.gz +0 -0
  103. package/dist/dashboard/assets/tokens-COzario9.js +1 -0
  104. package/dist/dashboard/assets/tokens-COzario9.js.gz +0 -0
  105. package/dist/dashboard/assets/use-modal-focus-By7oJoGd.js +1 -0
  106. package/dist/dashboard/assets/use-modal-focus-By7oJoGd.js.gz +0 -0
  107. package/dist/dashboard/assets/{useMutation-BWSC0UAS.js → useMutation-Dem0lXQj.js} +1 -1
  108. package/dist/dashboard/assets/useMutation-Dem0lXQj.js.gz +0 -0
  109. package/dist/dashboard/index.html +1 -1
  110. package/dist/dashboard/index.html.gz +0 -0
  111. package/dist/dashboard/precompressed-assets.json +54 -54
  112. package/migrations/README.md +52 -52
  113. package/migrations/lite-sqlite3/0000_baseline.sql +2 -2
  114. package/package.json +95 -95
  115. package/scripts/cli/admin.ts +125 -0
  116. package/scripts/cli/{ui/background.ts → background.ts} +1 -1
  117. package/scripts/cli/{command/console-menu.ts → console-menu.ts} +275 -244
  118. package/scripts/cli/doctor.ts +37 -0
  119. package/scripts/cli/{support/env.ts → env.ts} +99 -99
  120. package/scripts/cli/{gateway/spawn.ts → gateway.ts} +112 -24
  121. package/scripts/cli/instance.ts +224 -0
  122. package/scripts/cli/main.ts +61 -18
  123. package/scripts/cli/{support/platform.ts → platform.ts} +64 -64
  124. package/scripts/cli/port.ts +100 -0
  125. package/scripts/cli/{support/services.ts → services.ts} +88 -88
  126. package/scripts/cli/{command/setup.ts → setup.ts} +35 -34
  127. package/scripts/cli/{command/start.ts → start.ts} +10 -5
  128. package/scripts/cli/{support/startup-signal.ts → startup-signal.ts} +23 -23
  129. package/scripts/cli/tray/index.ts +62 -12
  130. package/scripts/cli/{command/tray.ts → tray.ts} +208 -208
  131. package/scripts/cli/{command/update.ts → update.ts} +39 -39
  132. package/scripts/cli/version.ts +67 -0
  133. package/scripts/cli/{ui/wordmark.ts → wordmark.ts} +58 -58
  134. package/src/boot/app.ts +697 -693
  135. package/src/boot/dependencies.ts +647 -646
  136. package/src/boot/timeout.ts +2 -13
  137. package/src/config/cooldowns.ts +186 -138
  138. package/src/config/network.ts +8 -14
  139. package/src/config/providers.ts +7 -10
  140. package/src/config/runtime.ts +76 -76
  141. package/src/config/security.ts +15 -0
  142. package/src/config/spec.ts +20 -30
  143. package/src/config/telemetry.ts +0 -20
  144. package/src/config/timeouts.ts +0 -6
  145. package/src/console/admin/model-abuse/contracts.ts +28 -6
  146. package/src/console/admin/model-abuse/routes.ts +56 -3
  147. package/src/console/api-keys/contracts.ts +410 -414
  148. package/src/console/api-keys/routes.ts +881 -880
  149. package/src/console/auth/credentials.ts +3 -3
  150. package/src/console/auth/setup-service.ts +2 -2
  151. package/src/console/backup/export-endpoint.ts +6 -2
  152. package/src/console/backup/nine-router.ts +2 -5
  153. package/src/console/backup/service.ts +4 -0
  154. package/src/console/backup/store.ts +192 -29
  155. package/src/console/backup/validate.ts +3 -45
  156. package/src/console/cli-tools/fs-ops.ts +487 -487
  157. package/src/console/console-router-guards.ts +1 -1
  158. package/src/console/data-management/contracts.ts +13 -6
  159. package/src/console/data-management/operations.ts +23 -18
  160. package/src/console/data-management/routes.ts +0 -1
  161. package/src/console/domain-registration.ts +10 -5
  162. package/src/console/observability/live.ts +22 -0
  163. package/src/console/observability/store.ts +39 -16
  164. package/src/console/observability/usage-dimensions.ts +4 -4
  165. package/src/console/providers/catalog/catalog-projections.ts +6 -7
  166. package/src/console/providers/catalog/model-operations.ts +5 -2
  167. package/src/console/providers/catalog/provider-operations.ts +4 -4
  168. package/src/console/providers/catalog/public-model-store.ts +8 -11
  169. package/src/console/providers/catalog/store.ts +1553 -1553
  170. package/src/console/providers/oauth/routes.ts +8 -12
  171. package/src/console/quota/account-quota-tenant.ts +6 -6
  172. package/src/console/quota/quota-view.ts +1 -1
  173. package/src/console/remote-support/contracts.ts +61 -0
  174. package/src/console/remote-support/routes.ts +65 -0
  175. package/src/console/remote-support/service.ts +207 -0
  176. package/src/console/response-compression.ts +0 -3
  177. package/src/console/routing/pools/probe-result.ts +3 -2
  178. package/src/console/routing/pools/probe-verdict.ts +132 -132
  179. package/src/console/routing/pools/store.ts +8 -8
  180. package/src/console/settings/contracts.ts +58 -2
  181. package/src/console/settings/store.ts +30 -3
  182. package/src/console/shared/cursor.ts +8 -8
  183. package/src/console/shared/errors.ts +1 -3
  184. package/src/network/http2-bidi.ts +2 -4
  185. package/src/network/pool/agent.ts +478 -478
  186. package/src/network/pool/selector.ts +5 -4
  187. package/src/network/retry.ts +1 -2
  188. package/src/network/ssrf.ts +5 -4
  189. package/src/observability/event-compaction.ts +80 -78
  190. package/src/observability/event-day-index.ts +13 -15
  191. package/src/observability/event-log.ts +4 -4
  192. package/src/observability/logger.ts +6 -6
  193. package/src/observability/payload-capture.ts +151 -33
  194. package/src/observability/payload-store.ts +397 -440
  195. package/src/observability/redaction.ts +5 -6
  196. package/src/observability/request-notify.ts +43 -0
  197. package/src/observability/telemetry-status.ts +8 -9
  198. package/src/observability/token-speed.ts +5 -4
  199. package/src/persistence/db-mode.ts +7 -7
  200. package/src/persistence/readiness.ts +1 -1
  201. package/src/persistence/schema.ts +1406 -1402
  202. package/src/persistence/shared-cache.ts +1 -2
  203. package/src/persistence/stores/api-key-store.ts +18 -6
  204. package/src/persistence/stores/share-store.ts +4 -3
  205. package/src/persistence/stores/telemetry-store.ts +236 -237
  206. package/src/protocol/canonical-model.ts +11 -3
  207. package/src/protocol/codecs.ts +138 -4
  208. package/src/protocol/gateway-error.ts +6 -4
  209. package/src/protocol/request/chat.ts +21 -1
  210. package/src/protocol/request/codex.ts +10 -1
  211. package/src/protocol/request/messages.ts +4 -5
  212. package/src/protocol/request/responses.ts +9 -1
  213. package/src/protocol/response/chat.ts +34 -4
  214. package/src/protocol/response/codex.ts +7 -4
  215. package/src/protocol/response/dsml.ts +140 -28
  216. package/src/protocol/response/responses.ts +11 -5
  217. package/src/protocol/stream-error-frames.ts +4 -5
  218. package/src/providers/authentication/jwt-validator.ts +9 -10
  219. package/src/providers/authentication/oauth-refresh-service.ts +9 -11
  220. package/src/providers/configured-openai-providers.ts +134 -136
  221. package/src/providers/discovery/catalog/catalog-blob.ts +18 -18
  222. package/src/providers/discovery/catalog/catalog-format.ts +3 -6
  223. package/src/providers/discovery/models-dev-catalog.ts +354 -354
  224. package/src/providers/discovery/probe-phases.ts +4 -4
  225. package/src/providers/discovery/probe-wire.ts +6 -13
  226. package/src/providers/discovery/probing-service.ts +1117 -1045
  227. package/src/providers/integrations/alibabacp/alibabacp.ts +1 -1
  228. package/src/providers/integrations/antigravity/antigravity-quota.ts +5 -7
  229. package/src/providers/integrations/buddy/buddy-chat-shared.ts +23 -2
  230. package/src/providers/integrations/buddy/buddy-checkin.ts +1 -4
  231. package/src/providers/integrations/buddy/buddy-cli-headers.ts +23 -6
  232. package/src/providers/integrations/buddy/buddy-conversation-headers.ts +0 -1
  233. package/src/providers/integrations/buddy/buddy-identity.ts +52 -0
  234. package/src/providers/integrations/buddy/buddy-media.ts +2 -2
  235. package/src/providers/integrations/buddy/buddy-usage-store.ts +3 -4
  236. package/src/providers/integrations/buddy/codebuddy/quota.ts +2 -13
  237. package/src/providers/integrations/buddy/codebuddy/shared.ts +118 -115
  238. package/src/providers/integrations/buddy/workbuddy/quota.ts +0 -11
  239. package/src/providers/integrations/buddy/workbuddy/shared.ts +5 -2
  240. package/src/providers/integrations/claude/claude.ts +608 -608
  241. package/src/providers/integrations/cline/cline-quota.ts +132 -132
  242. package/src/providers/integrations/cline/cline.ts +537 -539
  243. package/src/providers/integrations/codex/codex-oauth.ts +1 -1
  244. package/src/providers/integrations/commandcode/commandcode.ts +674 -675
  245. package/src/providers/integrations/commandcode/discovery.ts +117 -116
  246. package/src/providers/integrations/cursor/cursor-auth.ts +418 -418
  247. package/src/providers/integrations/cursor/cursor-quota.ts +309 -310
  248. package/src/providers/integrations/cursor/cursor.ts +11 -163
  249. package/src/providers/integrations/factory/factory-models.ts +3 -5
  250. package/src/providers/integrations/grok/grok-install-id.ts +2 -3
  251. package/src/providers/integrations/grok/grok.ts +6 -7
  252. package/src/providers/integrations/hermes.ts +14 -18
  253. package/src/providers/integrations/kiro/kiro-discovery.ts +1 -1
  254. package/src/providers/integrations/kiro/kiro.ts +1 -1
  255. package/src/providers/integrations/mistral.ts +6 -6
  256. package/src/providers/integrations/opencode/fingerprint.ts +3 -3
  257. package/src/providers/integrations/opencode/provider.ts +8 -8
  258. package/src/providers/integrations/qoder/qoder-catalog.ts +450 -450
  259. package/src/providers/integrations/qoder/qoder-oauth.ts +388 -310
  260. package/src/providers/integrations/qoder/qoder.ts +884 -865
  261. package/src/providers/integrations/shared/connect.ts +6 -6
  262. package/src/providers/integrations/trae/trae-catalog.ts +1 -2
  263. package/src/providers/integrations/trae/trae.ts +2 -2
  264. package/src/providers/integrations/zcode/oauth.ts +6 -1
  265. package/src/providers/operations/account-health-service.ts +1528 -1523
  266. package/src/providers/operations/account-reset-service.ts +1 -1
  267. package/src/providers/operations/client-versions.ts +6 -6
  268. package/src/providers/operations/custom-cli-headers.ts +3 -4
  269. package/src/providers/operations/provider-catalog-seeder.ts +95 -95
  270. package/src/providers/operations/provider-catalog-service.ts +3 -4
  271. package/src/providers/provider-metadata.ts +4 -8
  272. package/src/providers/provider-registry.ts +6 -7
  273. package/src/providers/provider-usage.ts +1 -1
  274. package/src/security/abuse.ts +15 -10
  275. package/src/security/access-control.ts +144 -146
  276. package/src/security/admission/contracts.ts +11 -0
  277. package/src/security/admission/in-memory-store.ts +4 -3
  278. package/src/security/admission/lease-sweep.ts +6 -6
  279. package/src/security/admission/reasons.ts +5 -1
  280. package/src/security/admission/redis-store.ts +211 -210
  281. package/src/security/admission/service.ts +4 -0
  282. package/src/security/api-key-auth.ts +504 -504
  283. package/src/security/ip-boundary.ts +155 -15
  284. package/src/security/model-abuse.ts +366 -94
  285. package/src/security/outbound-headers.ts +3 -4
  286. package/src/shared/sleep.ts +4 -0
  287. package/src/transport/dispatch/attempt-finalize.ts +47 -5
  288. package/src/transport/dispatch/attempt-loop.ts +531 -530
  289. package/src/transport/dispatch/stream-bridge.ts +1 -3
  290. package/src/transport/dispatch/streaming/finalize.ts +1 -1
  291. package/src/transport/dispatch/streaming/stream.ts +5 -6
  292. package/src/transport/failure-policy.ts +162 -8
  293. package/src/transport/guards/auth-guard.ts +7 -4
  294. package/src/transport/guards/concurrency-guard.ts +0 -5
  295. package/src/transport/guards/error-lifecycle.ts +13 -6
  296. package/src/transport/guards/pipeline.ts +15 -3
  297. package/src/transport/guards/request-context.ts +8 -2
  298. package/src/transport/request/inflight.ts +5 -6
  299. package/src/transport/request/preparer.ts +2 -2
  300. package/src/transport/request/state.ts +12 -8
  301. package/src/transport/routing/admission.ts +4 -0
  302. package/src/transport/routing/route-catalog.ts +8 -1
  303. package/src/transport/routing/route-model.ts +46 -14
  304. package/src/transport/routing/router.ts +21 -14
  305. package/src/transport/surface/completion.ts +2 -3
  306. package/src/transport/surface/dialects.ts +5 -6
  307. package/src/transport/surface/responses/encode.ts +38 -0
  308. package/src/transport/surface/responses/parse.ts +4 -0
  309. package/src/transport/translation/capabilities.ts +2 -3
  310. package/src/transport/translation/quirks.ts +3 -3
  311. package/src/workers/buddy-growth-tasks.ts +5 -10
  312. package/src/workers/daily-checkin.ts +17 -23
  313. package/src/workers/quota-refresh-sweep.ts +26 -26
  314. package/dist/dashboard/assets/BannedPage-C2orBsI2.js.gz +0 -0
  315. package/dist/dashboard/assets/CliToolDetailPage-DUcZ6pMB.js +0 -1
  316. package/dist/dashboard/assets/CliToolDetailPage-DUcZ6pMB.js.gz +0 -0
  317. package/dist/dashboard/assets/CliToolsPage-BV6pxVQo.js.gz +0 -0
  318. package/dist/dashboard/assets/CombosPage-Bsan4679.js +0 -1
  319. package/dist/dashboard/assets/CombosPage-Bsan4679.js.gz +0 -0
  320. package/dist/dashboard/assets/ConsoleLogPage-CW-wPmJ6.js.gz +0 -0
  321. package/dist/dashboard/assets/CustomizationPage-0p8uX7vB.js.gz +0 -0
  322. package/dist/dashboard/assets/HealthEventsModal-BjqSMfko.js.gz +0 -0
  323. package/dist/dashboard/assets/LoginPage-BvzCx8Kr.js.gz +0 -0
  324. package/dist/dashboard/assets/ModelPicker-BY1aKEkP.js +0 -1
  325. package/dist/dashboard/assets/ModelPicker-BY1aKEkP.js.gz +0 -0
  326. package/dist/dashboard/assets/OverviewPage-DOQpy1y0.js +0 -1
  327. package/dist/dashboard/assets/OverviewPage-DOQpy1y0.js.gz +0 -0
  328. package/dist/dashboard/assets/ProviderDetailPage-ecxyUMzt.js +0 -3
  329. package/dist/dashboard/assets/ProviderDetailPage-ecxyUMzt.js.gz +0 -0
  330. package/dist/dashboard/assets/ProvidersPage-Js9jUPSb.js.gz +0 -0
  331. package/dist/dashboard/assets/ProxyPage-J3TBi3Kx.js +0 -7
  332. package/dist/dashboard/assets/ProxyPage-J3TBi3Kx.js.gz +0 -0
  333. package/dist/dashboard/assets/QuotaPage-C1gZS6xr.js +0 -1
  334. package/dist/dashboard/assets/QuotaPage-C1gZS6xr.js.gz +0 -0
  335. package/dist/dashboard/assets/SettingsPage-CS86ho5X.js +0 -1
  336. package/dist/dashboard/assets/SettingsPage-CS86ho5X.js.gz +0 -0
  337. package/dist/dashboard/assets/SetupPage-DDbMQsoo.js.gz +0 -0
  338. package/dist/dashboard/assets/SortableList-CB3Z7VTf.js.gz +0 -0
  339. package/dist/dashboard/assets/StudioPage-D95UCPHI.js.gz +0 -0
  340. package/dist/dashboard/assets/UsagePage-Cnae9SWA.js +0 -56
  341. package/dist/dashboard/assets/UsagePage-Cnae9SWA.js.gz +0 -0
  342. package/dist/dashboard/assets/api-keys-VY68Mhd1.js +0 -1
  343. package/dist/dashboard/assets/api-keys-VY68Mhd1.js.gz +0 -0
  344. package/dist/dashboard/assets/app-COlK95oi.css +0 -2
  345. package/dist/dashboard/assets/app-COlK95oi.css.gz +0 -0
  346. package/dist/dashboard/assets/assert-Chi7pCnt.js.gz +0 -0
  347. package/dist/dashboard/assets/badge-D4pQapKj.js.gz +0 -0
  348. package/dist/dashboard/assets/banner-DH6cg-_B.js.gz +0 -0
  349. package/dist/dashboard/assets/card-BKqlUOe4.js.gz +0 -0
  350. package/dist/dashboard/assets/cli-tools-fTgljTGA.js.gz +0 -0
  351. package/dist/dashboard/assets/clipboard-button-D5Ys_sM0.js.gz +0 -0
  352. package/dist/dashboard/assets/dialog-BYj-8WuK.js +0 -1
  353. package/dist/dashboard/assets/dialog-BYj-8WuK.js.gz +0 -0
  354. package/dist/dashboard/assets/drawer-CmXIZ6H1.js +0 -1
  355. package/dist/dashboard/assets/drawer-CmXIZ6H1.js.gz +0 -0
  356. package/dist/dashboard/assets/entry-CBg5zeu8.js +0 -1
  357. package/dist/dashboard/assets/entry-CBg5zeu8.js.gz +0 -0
  358. package/dist/dashboard/assets/entry-CQBr6U9c.js.gz +0 -0
  359. package/dist/dashboard/assets/entry-CgdSAzEV.js +0 -4
  360. package/dist/dashboard/assets/entry-CgdSAzEV.js.gz +0 -0
  361. package/dist/dashboard/assets/fingerprint-pattern-BWuHtbW4.js.gz +0 -0
  362. package/dist/dashboard/assets/github-badge-BlptL8Uf.js.gz +0 -0
  363. package/dist/dashboard/assets/index-js6TJvdr.js.gz +0 -0
  364. package/dist/dashboard/assets/inline-DUWDSmDV.js.gz +0 -0
  365. package/dist/dashboard/assets/input-LVjgZq4f.js.gz +0 -0
  366. package/dist/dashboard/assets/layout-CPfxpsfy.js +0 -1
  367. package/dist/dashboard/assets/layout-CPfxpsfy.js.gz +0 -0
  368. package/dist/dashboard/assets/list-row-DupvLfDx.js.gz +0 -0
  369. package/dist/dashboard/assets/live-C4pEJUqI.js.gz +0 -0
  370. package/dist/dashboard/assets/model-bans-BRiuufkB.js.gz +0 -0
  371. package/dist/dashboard/assets/mutation-B4ROwk8L.js.gz +0 -0
  372. package/dist/dashboard/assets/numbers-YNcHvRY7.js +0 -2
  373. package/dist/dashboard/assets/numbers-YNcHvRY7.js.gz +0 -0
  374. package/dist/dashboard/assets/query-keys-gmjzC3RS.js.gz +0 -0
  375. package/dist/dashboard/assets/routing-C7tBT5o-.js.gz +0 -0
  376. package/dist/dashboard/assets/select-WUCXorXw.js.gz +0 -0
  377. package/dist/dashboard/assets/settings-C_zKl1GS.js.gz +0 -0
  378. package/dist/dashboard/assets/stack-_3O0F8Xn.js.gz +0 -0
  379. package/dist/dashboard/assets/state-BgTxHcJS.js.gz +0 -0
  380. package/dist/dashboard/assets/switch-DEjTtOWf.js.gz +0 -0
  381. package/dist/dashboard/assets/tokens-DDxMbSRL.js +0 -1
  382. package/dist/dashboard/assets/tokens-DDxMbSRL.js.gz +0 -0
  383. package/dist/dashboard/assets/use-presence-BSZEBx7U.js +0 -1
  384. package/dist/dashboard/assets/use-presence-BSZEBx7U.js.gz +0 -0
  385. package/dist/dashboard/assets/useMutation-BWSC0UAS.js.gz +0 -0
  386. package/scripts/cli/command/admin.ts +0 -65
  387. package/scripts/cli/command/doctor.ts +0 -257
  388. package/scripts/cli/gateway/drain-token.ts +0 -78
  389. package/scripts/cli/gateway/instance-lock.ts +0 -105
  390. package/scripts/cli/gateway/stop.ts +0 -51
  391. package/scripts/cli/support/bun.ts +0 -27
  392. package/scripts/cli/support/version.ts +0 -37
  393. /package/dist/dashboard/assets/{app-DCQZRmHY.js → app-BIt446yE.js} +0 -0
  394. /package/dist/dashboard/assets/{app-DCQZRmHY.js.gz → app-BIt446yE.js.gz} +0 -0
  395. /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 alone.** Keying it on the API key
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
- * **A ban lapses after its configured TTL** (`CARTETHYIA_MODEL_BAN_TTL_MS`)
25
- * rather than staying until an operator lifts it, so a false positive heals
26
- * without human intervention. The console can still lift one early.
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
- /** Model-abuse policy that triggered this address-wide ban. */
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 IP's sliding window. A valid attempt changes nothing.
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
- /** O(1) ban lookup for the pre-parse gate. */
97
- isBanned(ip: string): Promise<boolean>;
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. Returns whether a ban existed. */
101
- unban(ip: string): Promise<boolean>;
208
+ /** Lifts one ban, by the opaque member the listing reports. */
209
+ unban(identity: string): Promise<boolean>;
102
210
  /**
103
- * Bans one address immediately for `ttlMs`, regardless of strike history.
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(ip: string, ttlMs: number): Promise<number>;
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(ip: string, now: number): number | undefined {
145
- const expiresAt = this.bans.get(ip);
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(ip);
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(ip: string, now: number, windowMs: number): number[] {
278
+ private liveAttempts(identity: string, now: number, windowMs: number): number[] {
156
279
  const cutoff = now - windowMs;
157
- const existing = this.strikes.get(ip) ?? [];
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(ip);
165
- else this.strikes.set(ip, existing);
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.banExpiry(ip, now) !== undefined)
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(ip, now, windowMs);
301
+ const live = this.liveAttempts(identity, now, windowMs);
179
302
  live.push(now);
180
303
  this.evictOldest();
181
- this.strikes.set(ip, live);
304
+ this.strikes.set(identity, live);
182
305
  if (live.length >= threshold) {
183
- this.bans.set(ip, now + banTtlMs);
184
- this.strikes.delete(ip);
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 [ip, expiresAt] of [...this.bans]) {
195
- if (expiresAt <= now) this.bans.delete(ip);
196
- else bans.push({ ip, reason: "repeated_invalid_model_requests", expiresAt });
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(ip: string): Promise<boolean> {
202
- return this.banExpiry(ip, this.clock()) !== undefined;
338
+ async isBanned(identity: string): Promise<boolean> {
339
+ return this.bannedNow(identity, this.clock());
203
340
  }
204
341
 
205
- async unban(ip: string): Promise<boolean> {
206
- return this.bans.delete(ip);
342
+ async unban(identity: string): Promise<boolean> {
343
+ return this.bans.delete(identity);
207
344
  }
208
345
 
209
- async ban(ip: string, ttlMs: number): Promise<number> {
346
+ async ban(identity: string, ttlMs: number): Promise<number> {
210
347
  const expiresAt = this.clock() + Math.max(1, ttlMs);
211
- this.bans.set(ip, expiresAt);
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 IP attempt sorted set, `KEYS[2]` the ban sorted set. `ARGV`:
220
- * 1 valid(`1`/`0`), 2 windowMs, 3 ip, 4 now (epoch ms), 5 threshold, 6
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 address cannot extend its own
225
- * window. Each invalid attempt is one member scored by its instant. Members
226
- * older than the window are removed before the count, so a valid request does
227
- * not reset it and an old attempt leaves on its own. A ban is a member of the
228
- * ban set scored with the instant it lapses. `now` is the caller's clock
229
- * rather than the server's so the in-memory and Redis stores age a ban
230
- * identically.
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 score = redis.call('ZSCORE', KEYS[2], ARGV[3])
234
- if score then
235
- if tonumber(score) > tonumber(ARGV[4]) then
236
- return {1, 0, 0}
237
- end
238
- redis.call('ZREM', KEYS[2], ARGV[3])
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(ip: string): string {
266
- return `cartethyia:model-abuse:strike:ip:${ip}`;
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 this change is therefore simply gone — the right
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:ip";
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(ip),
463
+ this.strikeKey(identity),
287
464
  this.banKey(),
288
465
  valid ? "1" : "0",
289
466
  String(Math.max(1, windowMs)),
290
- ip,
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 ip = entries[index];
482
+ const identity = entries[index];
305
483
  const score = entries[index + 1];
306
- if (ip === undefined || score === undefined) break;
307
- bans.push({ ip, reason: "repeated_invalid_model_requests", expiresAt: Number(score) });
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(ip: string): Promise<boolean> {
313
- const now = this.clock();
314
- const score = await this.redis.zscore(this.banKey(), ip);
315
- if (score === null) return false;
316
- if (Number(score) > now) return true;
317
- await this.redis.zrem(this.banKey(), ip);
318
- return false;
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(ip: string): Promise<boolean> {
322
- const removed = await this.redis.zrem(this.banKey(), ip);
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(ip: string, ttlMs: number): Promise<number> {
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), ip);
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
- /** Read-only ban lookup for the pre-parse gate. Throws only on store outage. */
369
- async check(input: { readonly ip: string }): Promise<boolean> {
370
- return this.store.isBanned(input.ip);
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: { readonly ip: string }): Promise<ModelAbuseOutcome> {
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(ip: string): Promise<boolean> {
392
- return this.store.unban(ip);
617
+ async unban(identity: string): Promise<boolean> {
618
+ return this.store.unban(identity);
393
619
  }
394
620
 
395
- /** Operator instant-ban: one address, full TTL, no strike history needed. */
396
- async ban(ip: string): Promise<ModelAbuseBan> {
397
- const expiresAt = await this.store.ban(ip, this.banTtlMs);
398
- return { ip, reason: "repeated_invalid_model_requests", expiresAt };
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
- "IP kamu diblokir karena terlalu banyak request model yang tidak valid.",
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
  }