cartethyia 2.6.7 → 2.7.1

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 (335) hide show
  1. package/.env.example +1 -7
  2. package/README.md +25 -2
  3. package/dist/dashboard/assets/{BannedPage-Dskhyp0l.js → BannedPage-D-J75hBM.js} +1 -1
  4. package/dist/dashboard/assets/BannedPage-D-J75hBM.js.gz +0 -0
  5. package/dist/dashboard/assets/{CliToolDetailPage--SJhk7qQ.js → CliToolDetailPage-CVe6RaBH.js} +1 -1
  6. package/dist/dashboard/assets/CliToolDetailPage-CVe6RaBH.js.gz +0 -0
  7. package/dist/dashboard/assets/{CliToolsPage-C203VT-c.js → CliToolsPage-Dd8ZSamp.js} +1 -1
  8. package/dist/dashboard/assets/CliToolsPage-Dd8ZSamp.js.gz +0 -0
  9. package/dist/dashboard/assets/{CombosPage-BcS7kGG1.js → CombosPage-QoJ6foxo.js} +1 -1
  10. package/dist/dashboard/assets/CombosPage-QoJ6foxo.js.gz +0 -0
  11. package/dist/dashboard/assets/ConsoleLogPage-DpNRay4C.js +1 -0
  12. package/dist/dashboard/assets/ConsoleLogPage-DpNRay4C.js.gz +0 -0
  13. package/dist/dashboard/assets/{CustomizationPage-uujbBYwC.js → CustomizationPage-CKUFTcW4.js} +1 -1
  14. package/dist/dashboard/assets/CustomizationPage-CKUFTcW4.js.gz +0 -0
  15. package/dist/dashboard/assets/HealthEventsModal-CzSLiDzr.js +1 -0
  16. package/dist/dashboard/assets/HealthEventsModal-CzSLiDzr.js.gz +0 -0
  17. package/dist/dashboard/assets/LoginPage-DubdQcvX.js +1 -0
  18. package/dist/dashboard/assets/LoginPage-DubdQcvX.js.gz +0 -0
  19. package/dist/dashboard/assets/{ModelPicker-CA1kLs45.js → ModelPicker-BH8Tehdi.js} +1 -1
  20. package/dist/dashboard/assets/ModelPicker-BH8Tehdi.js.gz +0 -0
  21. package/dist/dashboard/assets/OverviewPage-DuWzAQFL.js +1 -0
  22. package/dist/dashboard/assets/OverviewPage-DuWzAQFL.js.gz +0 -0
  23. package/dist/dashboard/assets/ProviderDetailPage-BKEKe2Fm.js +4 -0
  24. package/dist/dashboard/assets/ProviderDetailPage-BKEKe2Fm.js.gz +0 -0
  25. package/dist/dashboard/assets/ProviderIcon-FB6yS4v1.js +1 -0
  26. package/dist/dashboard/assets/ProviderIcon-FB6yS4v1.js.gz +0 -0
  27. package/dist/dashboard/assets/ProvidersPage-Bwotu8UJ.js +1 -0
  28. package/dist/dashboard/assets/ProvidersPage-Bwotu8UJ.js.gz +0 -0
  29. package/dist/dashboard/assets/ProxyPage-D7hpWbg9.js +9 -0
  30. package/dist/dashboard/assets/ProxyPage-D7hpWbg9.js.gz +0 -0
  31. package/dist/dashboard/assets/{QuotaPage-BueSWX5D.js → QuotaPage-ATpaxrbl.js} +1 -1
  32. package/dist/dashboard/assets/QuotaPage-ATpaxrbl.js.gz +0 -0
  33. package/dist/dashboard/assets/SettingsPage-CETophpx.js +1 -0
  34. package/dist/dashboard/assets/SettingsPage-CETophpx.js.gz +0 -0
  35. package/dist/dashboard/assets/SetupPage-BdgECjqq.js +1 -0
  36. package/dist/dashboard/assets/SetupPage-BdgECjqq.js.gz +0 -0
  37. package/dist/dashboard/assets/{SortableList-DrKeMcGN.js → SortableList-CAK-EN9z.js} +1 -1
  38. package/dist/dashboard/assets/SortableList-CAK-EN9z.js.gz +0 -0
  39. package/dist/dashboard/assets/{StudioPage-qjuYtccc.js → StudioPage-B7W2_RhT.js} +3 -3
  40. package/dist/dashboard/assets/StudioPage-B7W2_RhT.js.gz +0 -0
  41. package/dist/dashboard/assets/UsagePage-6T4EFDjW.js +56 -0
  42. package/dist/dashboard/assets/UsagePage-6T4EFDjW.js.gz +0 -0
  43. package/dist/dashboard/assets/{api-keys-C23S9sgp.js → api-keys-CUi6UXsR.js} +1 -1
  44. package/dist/dashboard/assets/api-keys-CUi6UXsR.js.gz +0 -0
  45. package/dist/dashboard/assets/app-KvsUJUMF.css +2 -0
  46. package/dist/dashboard/assets/app-KvsUJUMF.css.gz +0 -0
  47. package/dist/dashboard/assets/{assert-DU6qrMA_.js → assert-67xXH5rf.js} +1 -1
  48. package/dist/dashboard/assets/assert-67xXH5rf.js.gz +0 -0
  49. package/dist/dashboard/assets/{badge-Bkz_-RrM.js → badge-DC2L43Mj.js} +1 -1
  50. package/dist/dashboard/assets/badge-DC2L43Mj.js.gz +0 -0
  51. package/dist/dashboard/assets/{banner-C-7y3VKV.js → banner-BBVcb0J3.js} +1 -1
  52. package/dist/dashboard/assets/banner-BBVcb0J3.js.gz +0 -0
  53. package/dist/dashboard/assets/{card-BOPBuBzD.js → card-Cca1XC6G.js} +1 -1
  54. package/dist/dashboard/assets/card-Cca1XC6G.js.gz +0 -0
  55. package/dist/dashboard/assets/{cli-tools-BaFUuFFF.js → cli-tools-KvsWa1Jl.js} +1 -1
  56. package/dist/dashboard/assets/cli-tools-KvsWa1Jl.js.gz +0 -0
  57. package/dist/dashboard/assets/{clipboard-button-e-Mlk0FH.js → clipboard-button-DUzEbynq.js} +1 -1
  58. package/dist/dashboard/assets/clipboard-button-DUzEbynq.js.gz +0 -0
  59. package/dist/dashboard/assets/{dialog-Iec_ywbD.js → dialog-DFvWhwTa.js} +1 -1
  60. package/dist/dashboard/assets/dialog-DFvWhwTa.js.gz +0 -0
  61. package/dist/dashboard/assets/{drawer-Bs8MIIHc.js → drawer-CFM2sMX1.js} +1 -1
  62. package/dist/dashboard/assets/drawer-CFM2sMX1.js.gz +0 -0
  63. package/dist/dashboard/assets/{entry-DOddwoc0.js → entry-BLBLxX0h.js} +3 -3
  64. package/dist/dashboard/assets/entry-BLBLxX0h.js.gz +0 -0
  65. package/dist/dashboard/assets/{entry-DcobaKFu.js → entry-Bqy8RFvM.js} +1 -1
  66. package/dist/dashboard/assets/entry-Bqy8RFvM.js.gz +0 -0
  67. package/dist/dashboard/assets/{entry-BRszm4_Z.js → entry-_xHo5x6r.js} +1 -1
  68. package/dist/dashboard/assets/entry-_xHo5x6r.js.gz +0 -0
  69. package/dist/dashboard/assets/formatters-mpBPqAQ8.js +1 -0
  70. package/dist/dashboard/assets/formatters-mpBPqAQ8.js.gz +0 -0
  71. package/dist/dashboard/assets/{github-badge-BuDsrsPn.js → github-badge-C9e8Ts7e.js} +1 -1
  72. package/dist/dashboard/assets/github-badge-C9e8Ts7e.js.gz +0 -0
  73. package/dist/dashboard/assets/{index-BeLRhsmt.js → index-D6QzEC_n.js} +2 -2
  74. package/dist/dashboard/assets/index-D6QzEC_n.js.gz +0 -0
  75. package/dist/dashboard/assets/{inline-DToMICwl.js → inline-BFUcvllj.js} +1 -1
  76. package/dist/dashboard/assets/inline-BFUcvllj.js.gz +0 -0
  77. package/dist/dashboard/assets/{input-WlyHWeG2.js → input-C_ILAvBb.js} +1 -1
  78. package/dist/dashboard/assets/input-C_ILAvBb.js.gz +0 -0
  79. package/dist/dashboard/assets/{layout-DaJ5baJr.js → layout-CBIyT6Rt.js} +1 -1
  80. package/dist/dashboard/assets/layout-CBIyT6Rt.js.gz +0 -0
  81. package/dist/dashboard/assets/{list-row-B3oSK13z.js → list-row-ebJ_-Nsb.js} +1 -1
  82. package/dist/dashboard/assets/list-row-ebJ_-Nsb.js.gz +0 -0
  83. package/dist/dashboard/assets/{live-ta6GMb-8.js → live-D0mm--ME.js} +1 -1
  84. package/dist/dashboard/assets/live-D0mm--ME.js.gz +0 -0
  85. package/dist/dashboard/assets/minimize-2-D-MN8W8a.js +1 -0
  86. package/dist/dashboard/assets/minimize-2-D-MN8W8a.js.gz +0 -0
  87. package/dist/dashboard/assets/{mutation-g_fduiNw.js → mutation-sMD965oO.js} +1 -1
  88. package/dist/dashboard/assets/mutation-sMD965oO.js.gz +0 -0
  89. package/dist/dashboard/assets/{numbers-Dulqgys5.js → numbers-BO9dKaFr.js} +1 -1
  90. package/dist/dashboard/assets/numbers-BO9dKaFr.js.gz +0 -0
  91. package/dist/dashboard/assets/providers/inception.svg +5 -0
  92. package/dist/dashboard/assets/providers/inception.svg.gz +0 -0
  93. package/dist/dashboard/assets/{query-keys-UETqfytp.js → query-keys-CfL_Zdoh.js} +1 -1
  94. package/dist/dashboard/assets/query-keys-CfL_Zdoh.js.gz +0 -0
  95. package/dist/dashboard/assets/{routing-BPUo-W8d.js → routing-BnIpi1IJ.js} +1 -1
  96. package/dist/dashboard/assets/routing-BnIpi1IJ.js.gz +0 -0
  97. package/dist/dashboard/assets/{select-BfZ3SCTM.js → select-Cj5Nw43g.js} +1 -1
  98. package/dist/dashboard/assets/select-Cj5Nw43g.js.gz +0 -0
  99. package/dist/dashboard/assets/settings-BAFhDgX8.js +1 -0
  100. package/dist/dashboard/assets/settings-BAFhDgX8.js.gz +0 -0
  101. package/dist/dashboard/assets/{stack-B1w5BM0u.js → stack-EMrOMt4I.js} +1 -1
  102. package/dist/dashboard/assets/stack-EMrOMt4I.js.gz +0 -0
  103. package/dist/dashboard/assets/{state-DhZBfWgx.js → state-CWoo7ty5.js} +1 -1
  104. package/dist/dashboard/assets/state-CWoo7ty5.js.gz +0 -0
  105. package/dist/dashboard/assets/{switch-yD7D99Lg.js → switch-CCjT6IkF.js} +1 -1
  106. package/dist/dashboard/assets/switch-CCjT6IkF.js.gz +0 -0
  107. package/dist/dashboard/assets/{useMutation-EDFRkn1i.js → useMutation-DjkLHUcE.js} +1 -1
  108. package/dist/dashboard/assets/useMutation-DjkLHUcE.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 +55 -54
  112. package/migrations/full-postgres/0024_add_pool_provider_cooldowns.sql +12 -0
  113. package/migrations/full-postgres/0025_add_pool_random_strategy.sql +3 -0
  114. package/migrations/lite-sqlite3/0024_add_pool_provider_cooldowns.sql +12 -0
  115. package/migrations/lite-sqlite3/0025_add_pool_random_strategy.sql +20 -0
  116. package/package.json +1 -1
  117. package/scripts/cli/console-menu.ts +5 -2
  118. package/scripts/cli/tray.ts +21 -6
  119. package/src/boot/app.ts +24 -62
  120. package/src/boot/dependencies.ts +732 -750
  121. package/src/config/cooldowns.ts +16 -0
  122. package/src/config/providers.ts +13 -1
  123. package/src/config/resources.ts +13 -80
  124. package/src/config/security.ts +2 -38
  125. package/src/config/spec.ts +16 -23
  126. package/src/console/console-router.ts +7 -7
  127. package/src/console/domain-registration.ts +19 -12
  128. package/src/console/providers/catalog/catalog-projections.ts +2 -6
  129. package/src/console/providers/catalog/contracts.ts +19 -0
  130. package/src/console/providers/catalog/provider-operations.ts +38 -0
  131. package/src/console/providers/catalog/routes.ts +14 -0
  132. package/src/console/providers/catalog/store-helpers.ts +326 -0
  133. package/src/console/providers/catalog/store.ts +1292 -1563
  134. package/src/console/quota/account-quota-global.ts +2 -2
  135. package/src/console/quota/account-quota-shared.ts +1 -1
  136. package/src/console/quota/account-quota-tenant.ts +35 -12
  137. package/src/console/quota/quota-view.ts +2 -2
  138. package/src/console/remote-support/contracts.ts +3 -1
  139. package/src/console/remote-support/service.ts +115 -6
  140. package/src/console/routing/pools/contracts.ts +41 -1
  141. package/src/console/routing/pools/operations.ts +183 -4
  142. package/src/console/routing/pools/relay-deploy.ts +17 -4
  143. package/src/console/routing/pools/routes.ts +30 -0
  144. package/src/console/routing/pools/store.ts +98 -8
  145. package/src/main.ts +16 -2
  146. package/src/network/outbound-fetch.ts +226 -365
  147. package/src/network/pool/agent.ts +8 -0
  148. package/src/network/pool/pool-state.ts +7 -0
  149. package/src/network/pool/selector.ts +110 -2
  150. package/src/network/pool-cooldown-store.ts +252 -0
  151. package/src/network/pool-health-machine.ts +5 -3
  152. package/src/network/pool-health.ts +36 -9
  153. package/src/observability/event-query.ts +57 -0
  154. package/src/observability/metrics.ts +15 -5
  155. package/src/observability/runtime-metrics.ts +30 -15
  156. package/src/persistence/health-event-flush.ts +2 -0
  157. package/src/persistence/schema.ts +19 -0
  158. package/src/persistence/usage-flush.ts +8 -3
  159. package/src/protocol/canonical-model.ts +1 -1
  160. package/src/protocol/dsml.ts +377 -0
  161. package/src/protocol/gateway-error.ts +0 -1
  162. package/src/{providers/provider-reasoning.ts → protocol/reasoning.ts} +10 -9
  163. package/src/protocol/request/chat.ts +7 -7
  164. package/src/protocol/request/codex.ts +2 -2
  165. package/src/protocol/request/gemini.ts +1 -1
  166. package/src/protocol/request/messages.ts +6 -6
  167. package/src/protocol/request/responses.ts +7 -7
  168. package/src/protocol/response/dsml.ts +12 -133
  169. package/src/{transport → protocol}/translation/attachment-integrity.ts +2 -2
  170. package/src/{transport → protocol}/translation/cache-controls.ts +1 -1
  171. package/src/{transport → protocol}/translation/capabilities.ts +2 -2
  172. package/src/{transport → protocol}/translation/normalize.ts +1 -1
  173. package/src/{transport → protocol}/translation/quirks.ts +1 -1
  174. package/src/{transport → protocol}/translation/search.ts +1 -1
  175. package/src/{transport → protocol}/translation/thinking.ts +23 -1
  176. package/src/{transport → protocol}/translation/tool-id.ts +1 -1
  177. package/src/{transport → protocol}/translation/tool-repair.ts +2 -2
  178. package/src/protocol/upstream-parsing.ts +176 -0
  179. package/src/providers/adapters/compatible-adapter.ts +11 -2
  180. package/src/providers/authentication/oauth-refresh-service.ts +9 -5
  181. package/src/providers/default-registry.ts +21 -5
  182. package/src/providers/discovery/probe-phases.ts +16 -3
  183. package/src/providers/discovery/probing-service.ts +4 -1
  184. package/src/providers/integrations/anthropic.ts +1 -1
  185. package/src/providers/integrations/buddy/buddy-catalog-shared.ts +1 -4
  186. package/src/providers/integrations/buddy/buddy-chat-shared.ts +141 -8
  187. package/src/providers/integrations/buddy/buddy-checkin.ts +5 -1
  188. package/src/providers/integrations/buddy/buddy-growth-shared.ts +0 -82
  189. package/src/providers/integrations/buddy/buddy-identity.ts +25 -0
  190. package/src/providers/integrations/buddy/buddy-server-tools.ts +240 -0
  191. package/src/providers/integrations/buddy/codebuddy/cn-catalog.ts +36 -0
  192. package/src/providers/integrations/buddy/codebuddy/cn-provider.ts +6 -31
  193. package/src/providers/integrations/buddy/codebuddy/provider.ts +2 -0
  194. package/src/providers/integrations/buddy/codebuddy/shared.ts +19 -4
  195. package/src/providers/integrations/buddy/turing-device-token.ts +262 -0
  196. package/src/providers/integrations/buddy/workbuddy/provider.ts +2 -0
  197. package/src/providers/integrations/buddy/workbuddy/shared.ts +56 -3
  198. package/src/providers/integrations/claude/claude.ts +1 -1
  199. package/src/providers/integrations/cloudflareai/cloudflareai.ts +1 -1
  200. package/src/providers/integrations/codex/codex-errors.ts +1 -1
  201. package/src/providers/integrations/codex/codex.ts +1 -1
  202. package/src/providers/integrations/commandcode/commandcode.ts +134 -104
  203. package/src/providers/integrations/commandcode/discovery.ts +6 -5
  204. package/src/providers/integrations/factory/factory.ts +1 -1
  205. package/src/providers/integrations/github/github.ts +1 -1
  206. package/src/providers/integrations/grok/grok.ts +1 -1
  207. package/src/providers/integrations/inception/provider.ts +91 -0
  208. package/src/providers/integrations/inferhub.ts +1 -1
  209. package/src/providers/integrations/kiro/kiro-request.ts +1 -1
  210. package/src/providers/integrations/mistral.ts +193 -19
  211. package/src/providers/integrations/opencode/fingerprint.ts +19 -2
  212. package/src/providers/integrations/opencode/prompt.ts +111 -0
  213. package/src/providers/integrations/opencode/provider.ts +101 -23
  214. package/src/providers/integrations/opencode/tool-definitions.ts +4 -4
  215. package/src/providers/integrations/qoder/qoder-checkin.ts +303 -0
  216. package/src/providers/integrations/qoder/qoder.ts +1641 -884
  217. package/src/providers/integrations/shared/install-id.ts +2 -1
  218. package/src/providers/integrations/trae/trae-cache.ts +110 -0
  219. package/src/providers/integrations/trae/trae-oauth.ts +29 -4
  220. package/src/providers/integrations/trae/trae-protocol.ts +2 -1
  221. package/src/providers/integrations/trae/trae.ts +106 -23
  222. package/src/providers/operations/account-error-classification.ts +1660 -0
  223. package/src/providers/operations/account-health-service.ts +621 -2097
  224. package/src/providers/operations/account-reset-service.ts +2 -0
  225. package/src/providers/provider-contracts.ts +0 -2
  226. package/src/providers/provider-metadata.ts +25 -4
  227. package/src/providers/provider-probe.ts +2 -2
  228. package/src/{console → providers}/quota/cache.ts +1 -1
  229. package/src/{console → providers}/quota/refresh.ts +5 -5
  230. package/src/security/abuse.ts +17 -0
  231. package/src/transport/dispatch/attempt-finalize.ts +114 -12
  232. package/src/transport/dispatch/attempt-loop.ts +3 -0
  233. package/src/transport/dispatch/empty-stream.ts +224 -217
  234. package/src/transport/dispatch/generation.ts +37 -55
  235. package/src/transport/dispatch/handler-deps.ts +32 -0
  236. package/src/transport/dispatch/leases.ts +9 -4
  237. package/src/transport/dispatch/proxy-request.ts +6 -30
  238. package/src/transport/dispatch/responses-compact.ts +22 -43
  239. package/src/transport/dispatch/streaming/finalize.ts +3 -0
  240. package/src/transport/dispatch/streaming/stream.ts +0 -8
  241. package/src/transport/dispatch/systemone.ts +22 -42
  242. package/src/transport/dispatch/token-count.ts +23 -39
  243. package/src/transport/dispatch/upstream.ts +1 -3
  244. package/src/transport/dispatch/websearch.ts +22 -41
  245. package/src/transport/failure-policy.ts +59 -178
  246. package/src/transport/guards/abuse-guard.ts +10 -0
  247. package/src/transport/guards/auth-guard.ts +0 -18
  248. package/src/transport/guards/error-lifecycle.ts +1 -1
  249. package/src/transport/guards/pipeline.ts +0 -6
  250. package/src/transport/guards/request-context.ts +18 -91
  251. package/src/transport/request/degradation.ts +20 -1
  252. package/src/transport/request/inflight.ts +4 -95
  253. package/src/transport/request/preparer.ts +6 -6
  254. package/src/transport/request/state.ts +66 -38
  255. package/src/transport/request/tenant-preferences.ts +1 -1
  256. package/src/transport/routing/route-catalog.ts +41 -31
  257. package/src/transport/routing/router.ts +2 -2
  258. package/src/transport/surface/messages/parse.ts +1 -1
  259. package/src/workers/buddy-growth-tasks.ts +346 -425
  260. package/src/workers/daily-checkin.ts +17 -6
  261. package/src/workers/quota-refresh-sweep.ts +2 -2
  262. package/src/workers/scheduler.ts +17 -1
  263. package/dist/dashboard/assets/BannedPage-Dskhyp0l.js.gz +0 -0
  264. package/dist/dashboard/assets/CliToolDetailPage--SJhk7qQ.js.gz +0 -0
  265. package/dist/dashboard/assets/CliToolsPage-C203VT-c.js.gz +0 -0
  266. package/dist/dashboard/assets/CombosPage-BcS7kGG1.js.gz +0 -0
  267. package/dist/dashboard/assets/ConsoleLogPage-ChmiSLwO.js +0 -1
  268. package/dist/dashboard/assets/ConsoleLogPage-ChmiSLwO.js.gz +0 -0
  269. package/dist/dashboard/assets/CustomizationPage-uujbBYwC.js.gz +0 -0
  270. package/dist/dashboard/assets/HealthEventsModal-fKAWd-OQ.js +0 -1
  271. package/dist/dashboard/assets/HealthEventsModal-fKAWd-OQ.js.gz +0 -0
  272. package/dist/dashboard/assets/LoginPage-DoOUthbG.js +0 -1
  273. package/dist/dashboard/assets/LoginPage-DoOUthbG.js.gz +0 -0
  274. package/dist/dashboard/assets/ModelPicker-CA1kLs45.js.gz +0 -0
  275. package/dist/dashboard/assets/OverviewPage-C5Z-UBz1.js +0 -1
  276. package/dist/dashboard/assets/OverviewPage-C5Z-UBz1.js.gz +0 -0
  277. package/dist/dashboard/assets/ProviderDetailPage-CFu1qGcT.js +0 -3
  278. package/dist/dashboard/assets/ProviderDetailPage-CFu1qGcT.js.gz +0 -0
  279. package/dist/dashboard/assets/ProviderIcon-CmfzxpVM.js +0 -1
  280. package/dist/dashboard/assets/ProviderIcon-CmfzxpVM.js.gz +0 -0
  281. package/dist/dashboard/assets/ProvidersPage-CnlJZbxF.js +0 -1
  282. package/dist/dashboard/assets/ProvidersPage-CnlJZbxF.js.gz +0 -0
  283. package/dist/dashboard/assets/ProxyPage-B9mBTweh.js +0 -7
  284. package/dist/dashboard/assets/ProxyPage-B9mBTweh.js.gz +0 -0
  285. package/dist/dashboard/assets/QuotaPage-BueSWX5D.js.gz +0 -0
  286. package/dist/dashboard/assets/SettingsPage-B0x00MVY.js +0 -1
  287. package/dist/dashboard/assets/SettingsPage-B0x00MVY.js.gz +0 -0
  288. package/dist/dashboard/assets/SetupPage-DQ9oDRpi.js +0 -1
  289. package/dist/dashboard/assets/SetupPage-DQ9oDRpi.js.gz +0 -0
  290. package/dist/dashboard/assets/SortableList-DrKeMcGN.js.gz +0 -0
  291. package/dist/dashboard/assets/StudioPage-qjuYtccc.js.gz +0 -0
  292. package/dist/dashboard/assets/UsagePage-Be7aNviQ.js +0 -56
  293. package/dist/dashboard/assets/UsagePage-Be7aNviQ.js.gz +0 -0
  294. package/dist/dashboard/assets/api-keys-C23S9sgp.js.gz +0 -0
  295. package/dist/dashboard/assets/app-BUSiYL6F.css +0 -2
  296. package/dist/dashboard/assets/app-BUSiYL6F.css.gz +0 -0
  297. package/dist/dashboard/assets/assert-DU6qrMA_.js.gz +0 -0
  298. package/dist/dashboard/assets/badge-Bkz_-RrM.js.gz +0 -0
  299. package/dist/dashboard/assets/banner-C-7y3VKV.js.gz +0 -0
  300. package/dist/dashboard/assets/card-BOPBuBzD.js.gz +0 -0
  301. package/dist/dashboard/assets/cli-tools-BaFUuFFF.js.gz +0 -0
  302. package/dist/dashboard/assets/clipboard-button-e-Mlk0FH.js.gz +0 -0
  303. package/dist/dashboard/assets/cog-CdWhsajs.js +0 -1
  304. package/dist/dashboard/assets/cog-CdWhsajs.js.gz +0 -0
  305. package/dist/dashboard/assets/dialog-Iec_ywbD.js.gz +0 -0
  306. package/dist/dashboard/assets/drawer-Bs8MIIHc.js.gz +0 -0
  307. package/dist/dashboard/assets/entry-BRszm4_Z.js.gz +0 -0
  308. package/dist/dashboard/assets/entry-DOddwoc0.js.gz +0 -0
  309. package/dist/dashboard/assets/entry-DcobaKFu.js.gz +0 -0
  310. package/dist/dashboard/assets/github-badge-BuDsrsPn.js.gz +0 -0
  311. package/dist/dashboard/assets/index-BeLRhsmt.js.gz +0 -0
  312. package/dist/dashboard/assets/inline-DToMICwl.js.gz +0 -0
  313. package/dist/dashboard/assets/input-WlyHWeG2.js.gz +0 -0
  314. package/dist/dashboard/assets/layout-DaJ5baJr.js.gz +0 -0
  315. package/dist/dashboard/assets/list-row-B3oSK13z.js.gz +0 -0
  316. package/dist/dashboard/assets/live-ta6GMb-8.js.gz +0 -0
  317. package/dist/dashboard/assets/model-bans-BmYTmbAk.js +0 -1
  318. package/dist/dashboard/assets/model-bans-BmYTmbAk.js.gz +0 -0
  319. package/dist/dashboard/assets/mutation-g_fduiNw.js.gz +0 -0
  320. package/dist/dashboard/assets/numbers-Dulqgys5.js.gz +0 -0
  321. package/dist/dashboard/assets/query-keys-UETqfytp.js.gz +0 -0
  322. package/dist/dashboard/assets/routing-BPUo-W8d.js.gz +0 -0
  323. package/dist/dashboard/assets/select-BfZ3SCTM.js.gz +0 -0
  324. package/dist/dashboard/assets/settings--3hxSyry.js +0 -1
  325. package/dist/dashboard/assets/settings--3hxSyry.js.gz +0 -0
  326. package/dist/dashboard/assets/stack-B1w5BM0u.js.gz +0 -0
  327. package/dist/dashboard/assets/state-DhZBfWgx.js.gz +0 -0
  328. package/dist/dashboard/assets/switch-yD7D99Lg.js.gz +0 -0
  329. package/dist/dashboard/assets/useMutation-EDFRkn1i.js.gz +0 -0
  330. package/src/console/admin/model-abuse/contracts.ts +0 -38
  331. package/src/console/admin/model-abuse/routes.ts +0 -97
  332. package/src/security/model-abuse.ts +0 -704
  333. package/src/transport/guards/concurrency-guard.ts +0 -125
  334. /package/dist/dashboard/assets/{app-MdVfwraB.js → app-BaWd0puM.js} +0 -0
  335. /package/dist/dashboard/assets/{app-MdVfwraB.js.gz → app-BaWd0puM.js.gz} +0 -0
@@ -1,704 +0,0 @@
1
- /**
2
- * Model-abuse strikes: a graduated response to a client that repeatedly asks
3
- * for a model it may not use.
4
- *
5
- * A gateway request names a model; when that name is outside the key's
6
- * allowlist, denylisted, or resolves to nothing, the request is rejected 404
7
- * `model_not_found`. That rejection is a normal answer to one mistake — but a
8
- * client that keeps hitting it is not making a mistake, it is probing, and it
9
- * costs the operator real work: every attempt lands as a failed row in the
10
- * console and the share page, burying the traffic that matters.
11
- *
12
- * So the rejection escalates. Each invalid-model attempt inside the sliding
13
- * window records a timestamp against the caller's client IP; crossing the
14
- * threshold bans that IP. A valid request does not reset the window. Attempts
15
- * older than the window stop counting, so old probes do not accumulate forever.
16
- *
17
- * **The ban is keyed on the client address and the API key that was used.**
18
- *
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.
37
- *
38
- * Fail-open on store outage: unlike admission (where admitting an unauthorized
39
- * request is worse than refusing a valid one), a strike layer that cannot read
40
- * its counter must not refuse a legitimate request. A store error is swallowed
41
- * and the request proceeds; the worst case is a missed strike, never a blocked
42
- * client.
43
- */
44
- import { GatewayError } from "../protocol/gateway-error";
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
- }
114
-
115
- /** A ban row, for the console. */
116
- export interface ModelAbuseBan {
117
- /** The client address the ban is keyed on. */
118
- readonly ip: string;
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. */
127
- readonly reason: "repeated_invalid_model_requests";
128
- /** Epoch ms the ban lapses on its own. */
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;
134
- }
135
-
136
- /** One strike decision, as the store sees it. */
137
- export interface ModelAbuseAttempt {
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;
145
- /** Whether this attempt named a model the key may use. */
146
- readonly valid: boolean;
147
- /** Invalid attempts inside the window that trigger a ban. */
148
- readonly threshold: number;
149
- /** Sliding window (ms) that keeps an invalid attempt countable. */
150
- readonly windowMs: number;
151
- /** How long (ms) a ban lasts before it lapses on its own. */
152
- readonly banTtlMs: number;
153
- }
154
-
155
- /** What one strike decision decided. */
156
- export interface ModelAbuseOutcome {
157
- /** The IP is banned, now or already. */
158
- readonly banned: boolean;
159
- /** Invalid attempts still inside the window, after this one. `0` when valid. */
160
- readonly strikes: number;
161
- /** This attempt crossed the threshold and recorded the ban. */
162
- readonly bannedNow: boolean;
163
- }
164
-
165
- /**
166
- * The `{banned, strikes, bannedNow}` tuple the script returns. A value of the
167
- * wrong length or with a non-finite member means the script and its caller
168
- * drifted; reading it as a decision would ban or admit on garbage, so it
169
- * throws — the service maps that to a swallowed fail-open, never a rejection.
170
- */
171
- function decodeOutcome(raw: readonly unknown[]): ModelAbuseOutcome {
172
- if (raw.length !== 3) {
173
- throw new Error(`[model-abuse] script returned ${raw.length} elements, expected 3`);
174
- }
175
- const [bannedRaw, strikesRaw, bannedNowRaw] = raw.map((value) => Number(value));
176
- const banned = bannedRaw ?? NaN;
177
- const strikes = strikesRaw ?? NaN;
178
- const bannedNow = bannedNowRaw ?? NaN;
179
- if (!Number.isFinite(banned) || !Number.isFinite(strikes) || !Number.isFinite(bannedNow)) {
180
- throw new Error(`[model-abuse] script returned a non-finite value: ${String(raw)}`);
181
- }
182
- return { banned: banned === 1 || bannedNow === 1, strikes, bannedNow: bannedNow === 1 };
183
- }
184
-
185
- export interface ModelAbuseStore {
186
- /**
187
- * One atomic strike decision: reads the ban and records an invalid attempt
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.
193
- */
194
- record(attempt: ModelAbuseAttempt): Promise<ModelAbuseOutcome>;
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>;
206
- /** Every active ban, for the console. */
207
- listBans(): Promise<readonly ModelAbuseBan[]>;
208
- /** Lifts one ban, by the opaque member the listing reports. */
209
- unban(identity: string): Promise<boolean>;
210
- /**
211
- * Bans one member immediately for `ttlMs`, regardless of strike history.
212
- * Returns the ban's expiry (epoch ms). The operator's instant-ban action
213
- * and the graduated strike path share one ban set, so `unban` lifts both.
214
- */
215
- ban(identity: string, ttlMs: number): Promise<number>;
216
- }
217
-
218
- interface InMemoryOptions {
219
- readonly maxKeys?: number;
220
- }
221
-
222
- /**
223
- * In-memory strike store: one timestamp list and one ban map.
224
- *
225
- * Each invalid attempt keeps its own instant. The count is the number of
226
- * instants still inside the window, so a valid request cannot reset it and an
227
- * old attempt leaves on its own. A ban carries the instant it lapses and is
228
- * swept lazily on the next read of that address. Bounded by `maxKeys` (oldest
229
- * evicted) so an adversarial fan-out over unique addresses cannot pin memory;
230
- * the ban map is bounded by the abuse itself, since each entry cost a full
231
- * threshold of attempts, and entries leave when their TTL lapses.
232
- */
233
- export class InMemoryModelAbuseStore implements ModelAbuseStore {
234
- private readonly strikes = new Map<string, number[]>();
235
- private readonly bans = new Map<string, number>();
236
- private readonly maxKeys: number;
237
-
238
- constructor(private readonly clock: () => number = () => Date.now(), opts: InMemoryOptions = {}) {
239
- this.maxKeys = opts.maxKeys ?? 10_000;
240
- }
241
-
242
- private evictOldest(): void {
243
- if (this.strikes.size < this.maxKeys) return;
244
- let toEvict = this.strikes.size - this.maxKeys + 1;
245
- for (const key of this.strikes.keys()) {
246
- if (toEvict-- <= 0) break;
247
- this.strikes.delete(key);
248
- }
249
- }
250
-
251
- /** The live ban's expiry, or `undefined`; an elapsed ban is dropped. */
252
- private banExpiry(identity: string, now: number): number | undefined {
253
- const expiresAt = this.bans.get(identity);
254
- if (expiresAt === undefined) return undefined;
255
- if (expiresAt <= now) {
256
- this.bans.delete(identity);
257
- return undefined;
258
- }
259
- return expiresAt;
260
- }
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
-
277
- /** Drops attempts older than the window and returns the rest. */
278
- private liveAttempts(identity: string, now: number, windowMs: number): number[] {
279
- const cutoff = now - windowMs;
280
- const existing = this.strikes.get(identity) ?? [];
281
- // In-place compaction: `.filter` allocated a new array per invalid attempt.
282
- let write = 0;
283
- for (let read = 0; read < existing.length; read += 1) {
284
- if (existing[read]! > cutoff) existing[write++] = existing[read]!;
285
- }
286
- existing.length = write;
287
- if (existing.length === 0) this.strikes.delete(identity);
288
- else this.strikes.set(identity, existing);
289
- return existing;
290
- }
291
-
292
- async record(attempt: ModelAbuseAttempt): Promise<ModelAbuseOutcome> {
293
- const now = this.clock();
294
- const { ip, apiKeyId, valid, threshold, windowMs, banTtlMs } = attempt;
295
- const identity = banIdentity(ip, apiKeyId);
296
-
297
- if (this.bannedNow(identity, now)) return { banned: true, strikes: 0, bannedNow: false };
298
-
299
- if (valid) return { banned: false, strikes: 0, bannedNow: false };
300
-
301
- const live = this.liveAttempts(identity, now, windowMs);
302
- live.push(now);
303
- this.evictOldest();
304
- this.strikes.set(identity, live);
305
- if (live.length >= threshold) {
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);
311
- return { banned: true, strikes: live.length, bannedNow: true };
312
- }
313
- return { banned: false, strikes: live.length, bannedNow: false };
314
- }
315
-
316
-
317
- async listBans(): Promise<readonly ModelAbuseBan[]> {
318
- const now = this.clock();
319
- const bans: ModelAbuseBan[] = [];
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
- });
334
- }
335
- return bans;
336
- }
337
-
338
- async isBanned(identity: string): Promise<boolean> {
339
- return this.bannedNow(identity, this.clock());
340
- }
341
-
342
- async unban(identity: string): Promise<boolean> {
343
- return this.bans.delete(identity);
344
- }
345
-
346
- async ban(identity: string, ttlMs: number): Promise<number> {
347
- const expiresAt = this.clock() + Math.max(1, ttlMs);
348
- this.bans.set(identity, expiresAt);
349
- return expiresAt;
350
- }
351
- }
352
-
353
- /**
354
- * The one strike decision, as a static Lua script.
355
- *
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.
360
- *
361
- * Returns `{banned, strikes, bannedNow}`. The ban is read first and
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.
375
- */
376
- const MODEL_ABUSE_SCRIPT = `
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}
387
- end
388
- if ARGV[1] == '1' then
389
- return {0, 0, 0}
390
- end
391
- local windowMs = tonumber(ARGV[2])
392
- redis.call('ZREMRANGEBYSCORE', KEYS[1], '-inf', now - windowMs)
393
- local seq = redis.call('INCR', KEYS[1] .. ':seq')
394
- redis.call('ZADD', KEYS[1], now, now .. ':' .. seq)
395
- local count = redis.call('ZCARD', KEYS[1])
396
- redis.call('PEXPIRE', KEYS[1], windowMs)
397
- redis.call('PEXPIRE', KEYS[1] .. ':seq', windowMs)
398
- if count >= tonumber(ARGV[5]) then
399
- redis.call('ZADD', KEYS[2], now + tonumber(ARGV[6]), ARGV[3])
400
- redis.call('DEL', KEYS[1], KEYS[1] .. ':seq')
401
- return {0, count, 1}
402
- end
403
- return {0, count, 0}
404
- `;
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
-
431
- export class RedisModelAbuseStore implements ModelAbuseStore {
432
- constructor(
433
- private readonly redis: RedisClient,
434
- private readonly clock: () => number = () => Date.now(),
435
- ) {}
436
-
437
- private strikeKey(identity: string): string {
438
- return `cartethyia:model-abuse:strike:${identity}`;
439
- }
440
- /**
441
- * The ban index. It is a sorted set scored with each ban's expiry instant,
442
- * under a name of its own: the previous permanent-ban *set* cannot be read as
443
- * a sorted set, and reading it anyway would raise WRONGTYPE on every check.
444
- * A member from before that change is therefore simply gone — the right
445
- * outcome, since those bans were recorded without an expiry this store cannot
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.
451
- */
452
- private banKey(): string {
453
- return "cartethyia:model-abuse:bans";
454
- }
455
-
456
- async record(attempt: ModelAbuseAttempt): Promise<ModelAbuseOutcome> {
457
- const { ip, apiKeyId, valid, threshold, windowMs, banTtlMs } = attempt;
458
- const identity = banIdentity(ip, apiKeyId);
459
- const raw = await redisEvalTuple(
460
- this.redis,
461
- MODEL_ABUSE_SCRIPT,
462
- 2,
463
- this.strikeKey(identity),
464
- this.banKey(),
465
- valid ? "1" : "0",
466
- String(Math.max(1, windowMs)),
467
- identity,
468
- String(this.clock()),
469
- String(threshold),
470
- String(Math.max(1, banTtlMs)),
471
- addressBanIdentity(ip),
472
- );
473
- return decodeOutcome(raw);
474
- }
475
-
476
- async listBans(): Promise<readonly ModelAbuseBan[]> {
477
- const now = this.clock();
478
- await this.redis.zremrangebyscore(this.banKey(), "-inf", String(now));
479
- const entries = await this.redis.zrange(this.banKey(), "0", "-1", "WITHSCORES");
480
- const bans: ModelAbuseBan[] = [];
481
- for (let index = 0; index + 1 < entries.length; index += 2) {
482
- const identity = entries[index];
483
- const score = entries[index + 1];
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
- });
496
- }
497
- return bans;
498
- }
499
-
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;
514
- }
515
-
516
- async unban(identity: string): Promise<boolean> {
517
- const removed = await this.redis.zrem(this.banKey(), identity);
518
- return Number(removed) > 0;
519
- }
520
-
521
- async ban(identity: string, ttlMs: number): Promise<number> {
522
- const expiresAt = this.clock() + Math.max(1, ttlMs);
523
- await this.redis.zadd(this.banKey(), String(expiresAt), identity);
524
- return expiresAt;
525
- }
526
- }
527
-
528
- export interface ModelStrikeOptions {
529
- /** Invalid-model attempts inside the window that trigger a ban. Default 10. */
530
- readonly threshold?: number;
531
- /** Sliding window (ms) that keeps an invalid attempt countable. Default 30 minutes. */
532
- readonly windowMs?: number;
533
- /** How long (ms) a graduated ban lasts before it lapses. Default 6 hours. */
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;
542
- }
543
-
544
- /**
545
- * Graduated model-abuse strikes over a store.
546
- *
547
- * `noteInvalid` records one invalid attempt in the sliding window. `noteValid`
548
- * does not reset that window. `listBans`/`unban` are the console's escape hatch.
549
- */
550
- export class ModelStrikeService {
551
- private readonly threshold: number;
552
- private readonly windowMs: number;
553
- private readonly _banTtlMs: number;
554
- private readonly _maxBanTtlMs: number;
555
-
556
- constructor(private readonly store: ModelAbuseStore, opts: ModelStrikeOptions = {}) {
557
- this.threshold = Math.max(1, opts.threshold ?? 10);
558
- this.windowMs = Math.max(1, opts.windowMs ?? 30 * 60_000);
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
- );
567
- }
568
-
569
- /** The ban threshold, for an operator-facing warning message. */
570
- get limit(): number {
571
- return this.threshold;
572
- }
573
-
574
- get banTtlMs(): number {
575
- return this._banTtlMs;
576
- }
577
-
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));
593
- }
594
-
595
- /** Records one invalid-model attempt and returns the escalation decision. */
596
- async noteInvalid(input: {
597
- readonly ip: string;
598
- readonly apiKeyId?: string | null;
599
- }): Promise<ModelAbuseOutcome> {
600
- return this.store.record({
601
- ip: input.ip,
602
- ...(input.apiKeyId === undefined ? {} : { apiKeyId: input.apiKeyId }),
603
- valid: false,
604
- threshold: this.threshold,
605
- windowMs: this.windowMs,
606
- banTtlMs: this.banTtlMs,
607
- });
608
- }
609
-
610
- /** A valid model request does not reset the invalid-attempt window. */
611
- async noteValid(_input: { readonly ip: string }): Promise<void> {}
612
-
613
- async listBans(): Promise<readonly ModelAbuseBan[]> {
614
- return this.store.listBans();
615
- }
616
-
617
- async unban(identity: string): Promise<boolean> {
618
- return this.store.unban(identity);
619
- }
620
-
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
- };
661
- }
662
- }
663
-
664
- /**
665
- * The typed rejection a banned caller receives. 403 with its own code so the
666
- * console and the client can tell an abuse ban from a per-request rejection.
667
- * The message names the reason without the ban duration.
668
- */
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.
676
- return new GatewayError(
677
- "model_abuse_banned",
678
- 403,
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.",
680
- );
681
- }
682
-
683
- /**
684
- * The 404 a rejected model returns, carrying the escalating warning. The
685
- * message names the strike count without naming how long a ban lasts.
686
- */
687
- export function modelWarningMessage(
688
- model: string,
689
- outcome: ModelAbuseOutcome,
690
- limit: number,
691
- ): string {
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.
697
- const tail =
698
- remaining === 0
699
- ? "The next invalid request will temporarily ban this IP for this API key. Use /v1/models to see allowed models."
700
- : remaining === 1
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.`;
703
- return `Model '${model}' is not available to this API key. Warning ${outcome.strikes}/${limit} — ${tail}`;
704
- }