cartethyia 2.5.10 → 2.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (367) hide show
  1. package/.env.example +8 -0
  2. package/dist/dashboard/assets/{BannedPage-Cd30Z0b9.js → BannedPage-l6XMnfr9.js} +1 -1
  3. package/dist/dashboard/assets/BannedPage-l6XMnfr9.js.gz +0 -0
  4. package/dist/dashboard/assets/{CliToolDetailPage-wf5u-ww6.js → CliToolDetailPage-DwOzAW45.js} +1 -1
  5. package/dist/dashboard/assets/CliToolDetailPage-DwOzAW45.js.gz +0 -0
  6. package/dist/dashboard/assets/{CliToolsPage-DykPvuck.js → CliToolsPage-BT3Up3QA.js} +1 -1
  7. package/dist/dashboard/assets/CliToolsPage-BT3Up3QA.js.gz +0 -0
  8. package/dist/dashboard/assets/{CombosPage-DwvSD3vs.js → CombosPage-6kYIUpzR.js} +1 -1
  9. package/dist/dashboard/assets/CombosPage-6kYIUpzR.js.gz +0 -0
  10. package/dist/dashboard/assets/{ConsoleLogPage-CIr7KDex.js → ConsoleLogPage-zRnj1mlR.js} +1 -1
  11. package/dist/dashboard/assets/ConsoleLogPage-zRnj1mlR.js.gz +0 -0
  12. package/dist/dashboard/assets/{CustomizationPage-82X0DDdH.js → CustomizationPage-C7m-H1Rz.js} +1 -1
  13. package/dist/dashboard/assets/CustomizationPage-C7m-H1Rz.js.gz +0 -0
  14. package/dist/dashboard/assets/HealthEventsModal-C8MR_BdB.js +1 -0
  15. package/dist/dashboard/assets/HealthEventsModal-C8MR_BdB.js.gz +0 -0
  16. package/dist/dashboard/assets/{LoginPage-CS5mPuVF.js → LoginPage-CbphLvjg.js} +1 -1
  17. package/dist/dashboard/assets/LoginPage-CbphLvjg.js.gz +0 -0
  18. package/dist/dashboard/assets/{ModelPicker-C0UCUsVJ.js → ModelPicker-DVny4S5_.js} +1 -1
  19. package/dist/dashboard/assets/ModelPicker-DVny4S5_.js.gz +0 -0
  20. package/dist/dashboard/assets/OverviewPage-DABu2Wgt.js +1 -0
  21. package/dist/dashboard/assets/OverviewPage-DABu2Wgt.js.gz +0 -0
  22. package/dist/dashboard/assets/ProviderDetailPage-C15x9q13.js +3 -0
  23. package/dist/dashboard/assets/ProviderDetailPage-C15x9q13.js.gz +0 -0
  24. package/dist/dashboard/assets/{ProvidersPage-vdja5ZPK.js → ProvidersPage-Dfb2896L.js} +1 -1
  25. package/dist/dashboard/assets/ProvidersPage-Dfb2896L.js.gz +0 -0
  26. package/dist/dashboard/assets/{ProxyPage-BB4CNm4-.js → ProxyPage-BaCIdAUt.js} +1 -1
  27. package/dist/dashboard/assets/ProxyPage-BaCIdAUt.js.gz +0 -0
  28. package/dist/dashboard/assets/QuotaPage-BsdoCIw1.js +1 -0
  29. package/dist/dashboard/assets/QuotaPage-BsdoCIw1.js.gz +0 -0
  30. package/dist/dashboard/assets/SettingsPage-KVJFh3Ip.js +1 -0
  31. package/dist/dashboard/assets/SettingsPage-KVJFh3Ip.js.gz +0 -0
  32. package/dist/dashboard/assets/{SetupPage-FadUOl9S.js → SetupPage-D4g6vOGc.js} +1 -1
  33. package/dist/dashboard/assets/SetupPage-D4g6vOGc.js.gz +0 -0
  34. package/dist/dashboard/assets/{SortableList-Cfy6_Khv.js → SortableList-C93i14BD.js} +1 -1
  35. package/dist/dashboard/assets/SortableList-C93i14BD.js.gz +0 -0
  36. package/dist/dashboard/assets/{StudioPage-CM92fgJf.js → StudioPage-SgQpymjm.js} +1 -1
  37. package/dist/dashboard/assets/{StudioPage-CM92fgJf.js.gz → StudioPage-SgQpymjm.js.gz} +0 -0
  38. package/dist/dashboard/assets/{UsagePage-BT4MyQeT.js → UsagePage-BFME-eba.js} +2 -2
  39. package/dist/dashboard/assets/UsagePage-BFME-eba.js.gz +0 -0
  40. package/dist/dashboard/assets/{api-keys-DQYLQxpW.js → api-keys-C23S9sgp.js} +1 -1
  41. package/dist/dashboard/assets/api-keys-C23S9sgp.js.gz +0 -0
  42. package/dist/dashboard/assets/{app-B7pVRhJ1.js → app-2KhtHCv9.js} +1 -1
  43. package/dist/dashboard/assets/{app-B7pVRhJ1.js.gz → app-2KhtHCv9.js.gz} +0 -0
  44. package/dist/dashboard/assets/app-CEeaMHOy.css +2 -0
  45. package/dist/dashboard/assets/app-CEeaMHOy.css.gz +0 -0
  46. package/dist/dashboard/assets/{assert-C9FD_JwJ.js → assert-DU6qrMA_.js} +1 -1
  47. package/dist/dashboard/assets/assert-DU6qrMA_.js.gz +0 -0
  48. package/dist/dashboard/assets/{badge-Bvyr7D0u.js → badge-CKsF123d.js} +1 -1
  49. package/dist/dashboard/assets/badge-CKsF123d.js.gz +0 -0
  50. package/dist/dashboard/assets/{banner-DIvKAZih.js → banner-8j7uChA9.js} +1 -1
  51. package/dist/dashboard/assets/banner-8j7uChA9.js.gz +0 -0
  52. package/dist/dashboard/assets/{card-D5_gYIS_.js → card-CdsSlWA3.js} +1 -1
  53. package/dist/dashboard/assets/card-CdsSlWA3.js.gz +0 -0
  54. package/dist/dashboard/assets/{cli-tools-CNqzrfdb.js → cli-tools-BaFUuFFF.js} +1 -1
  55. package/dist/dashboard/assets/cli-tools-BaFUuFFF.js.gz +0 -0
  56. package/dist/dashboard/assets/{clipboard-button-B6vDBtbm.js → clipboard-button-uL5aYLfW.js} +1 -1
  57. package/dist/dashboard/assets/clipboard-button-uL5aYLfW.js.gz +0 -0
  58. package/dist/dashboard/assets/{dialog-CpjrgdwJ.js → dialog-C07hr5_G.js} +1 -1
  59. package/dist/dashboard/assets/dialog-C07hr5_G.js.gz +0 -0
  60. package/dist/dashboard/assets/{drawer-BWmt3b5y.js → drawer-BPO1TcKH.js} +1 -1
  61. package/dist/dashboard/assets/drawer-BPO1TcKH.js.gz +0 -0
  62. package/dist/dashboard/assets/{entry-C_oakV3z.js → entry-Bz_CtDfC.js} +1 -1
  63. package/dist/dashboard/assets/entry-Bz_CtDfC.js.gz +0 -0
  64. package/dist/dashboard/assets/entry-CwPRKk0d.js +4 -0
  65. package/dist/dashboard/assets/entry-CwPRKk0d.js.gz +0 -0
  66. package/dist/dashboard/assets/entry-GJRbmrpA.js +1 -0
  67. package/dist/dashboard/assets/entry-GJRbmrpA.js.gz +0 -0
  68. package/dist/dashboard/assets/{github-badge-RRHHWGxh.js → github-badge-DmQreSDd.js} +1 -1
  69. package/dist/dashboard/assets/github-badge-DmQreSDd.js.gz +0 -0
  70. package/dist/dashboard/assets/{index-Bhey_rg_.js → index-Bblu6erR.js} +2 -2
  71. package/dist/dashboard/assets/index-Bblu6erR.js.gz +0 -0
  72. package/dist/dashboard/assets/{inline-C2_EQ5Uf.js → inline-C9dCYaBZ.js} +1 -1
  73. package/dist/dashboard/assets/inline-C9dCYaBZ.js.gz +0 -0
  74. package/dist/dashboard/assets/{input-BMRlQ450.js → input-CjCnXweJ.js} +1 -1
  75. package/dist/dashboard/assets/input-CjCnXweJ.js.gz +0 -0
  76. package/dist/dashboard/assets/{layout-XR5xP8ew.js → layout-V6y8DAkl.js} +1 -1
  77. package/dist/dashboard/assets/layout-V6y8DAkl.js.gz +0 -0
  78. package/dist/dashboard/assets/{list-row-Cw07dioc.js → list-row-CM1AaG_y.js} +1 -1
  79. package/dist/dashboard/assets/list-row-CM1AaG_y.js.gz +0 -0
  80. package/dist/dashboard/assets/{live-BivGfuUs.js → live-ta6GMb-8.js} +1 -1
  81. package/dist/dashboard/assets/live-ta6GMb-8.js.gz +0 -0
  82. package/dist/dashboard/assets/{model-bans-CmffjLuG.js → model-bans-BmYTmbAk.js} +1 -1
  83. package/dist/dashboard/assets/model-bans-BmYTmbAk.js.gz +0 -0
  84. package/dist/dashboard/assets/{mutation-46P0oWMe.js → mutation-g_fduiNw.js} +1 -1
  85. package/dist/dashboard/assets/mutation-g_fduiNw.js.gz +0 -0
  86. package/dist/dashboard/assets/{numbers-DrdA9oSM.js → numbers-BOevJzdz.js} +1 -1
  87. package/dist/dashboard/assets/numbers-BOevJzdz.js.gz +0 -0
  88. package/dist/dashboard/assets/{query-keys-BFhGpwR1.js → query-keys-UETqfytp.js} +1 -1
  89. package/dist/dashboard/assets/query-keys-UETqfytp.js.gz +0 -0
  90. package/dist/dashboard/assets/{routing-CAb4F37R.js → routing-BPUo-W8d.js} +1 -1
  91. package/dist/dashboard/assets/routing-BPUo-W8d.js.gz +0 -0
  92. package/dist/dashboard/assets/{select-CxVVB__N.js → select-C980c4cz.js} +1 -1
  93. package/dist/dashboard/assets/select-C980c4cz.js.gz +0 -0
  94. package/dist/dashboard/assets/{settings-DNX9sydi.js → settings--3hxSyry.js} +1 -1
  95. package/dist/dashboard/assets/settings--3hxSyry.js.gz +0 -0
  96. package/dist/dashboard/assets/{stack-uPOwM9Hs.js → stack-_gfTdLVB.js} +1 -1
  97. package/dist/dashboard/assets/stack-_gfTdLVB.js.gz +0 -0
  98. package/dist/dashboard/assets/{state-D1Yz79iL.js → state-2c5V4zo7.js} +1 -1
  99. package/dist/dashboard/assets/state-2c5V4zo7.js.gz +0 -0
  100. package/dist/dashboard/assets/{switch-sevsRrRo.js → switch-Dkke7Naf.js} +1 -1
  101. package/dist/dashboard/assets/switch-Dkke7Naf.js.gz +0 -0
  102. package/dist/dashboard/assets/{useMutation-Dem0lXQj.js → useMutation-EDFRkn1i.js} +1 -1
  103. package/dist/dashboard/assets/useMutation-EDFRkn1i.js.gz +0 -0
  104. package/dist/dashboard/index.html +1 -1
  105. package/dist/dashboard/index.html.gz +0 -0
  106. package/dist/dashboard/precompressed-assets.json +51 -51
  107. package/migrations/README.md +52 -52
  108. package/migrations/full-postgres/0020_add_telemetry_event_analytics.sql +77 -0
  109. package/migrations/full-postgres/0021_add_api_key_concurrency_tolerance.sql +13 -0
  110. package/migrations/full-postgres/0022_add_inferhub_config_key.sql +17 -0
  111. package/migrations/full-postgres/0023_drop_api_key_concurrency_tolerance.sql +13 -0
  112. package/migrations/lite-sqlite3/0020_add_telemetry_event_analytics.sql +69 -0
  113. package/migrations/lite-sqlite3/0021_add_api_key_concurrency_tolerance.sql +13 -0
  114. package/migrations/lite-sqlite3/0022_add_inferhub_config_key.sql +17 -0
  115. package/migrations/lite-sqlite3/0023_drop_api_key_concurrency_tolerance.sql +13 -0
  116. package/package.json +96 -95
  117. package/scripts/cli/admin.ts +126 -128
  118. package/scripts/cli/autostart.ts +237 -237
  119. package/scripts/cli/background.ts +120 -110
  120. package/scripts/cli/bin/carte.cjs +8 -8
  121. package/scripts/cli/bin/cartethyia.cjs +167 -167
  122. package/scripts/cli/console-menu.ts +39 -30
  123. package/scripts/cli/controller.ts +145 -0
  124. package/scripts/cli/env.ts +99 -99
  125. package/scripts/cli/gateway.ts +264 -260
  126. package/scripts/cli/instance.ts +234 -224
  127. package/scripts/cli/main.ts +188 -167
  128. package/scripts/cli/platform.ts +64 -64
  129. package/scripts/cli/prompt.ts +217 -79
  130. package/scripts/cli/services.ts +88 -88
  131. package/scripts/cli/setup.ts +445 -445
  132. package/scripts/cli/start.ts +85 -85
  133. package/scripts/cli/startup-signal.ts +23 -23
  134. package/scripts/cli/telemetry.ts +70 -0
  135. package/scripts/cli/tray/index.ts +263 -263
  136. package/scripts/cli/tray/windows.ps1 +107 -107
  137. package/scripts/cli/tray.ts +29 -4
  138. package/scripts/cli/update.ts +39 -39
  139. package/scripts/cli/version.ts +67 -67
  140. package/scripts/cli/wordmark.ts +58 -58
  141. package/src/boot/app.ts +16 -1
  142. package/src/boot/dependencies.ts +128 -25
  143. package/src/config/resources.ts +137 -0
  144. package/src/config/runtime.ts +133 -76
  145. package/src/config/spec.ts +30 -29
  146. package/src/config/telemetry.ts +0 -25
  147. package/src/console/api-keys/bucket-spend.ts +14 -17
  148. package/src/console/api-keys/contracts.ts +410 -410
  149. package/src/console/api-keys/routes.ts +12 -0
  150. package/src/console/audit/store.ts +2 -3
  151. package/src/console/auth/setup-service.ts +8 -1
  152. package/src/console/backup/routes.ts +7 -0
  153. package/src/console/backup/service.ts +463 -463
  154. package/src/console/backup/store.ts +16 -11
  155. package/src/console/backup/validate.ts +1 -4
  156. package/src/console/cli-tools/fs-ops.ts +18 -2
  157. package/src/console/data-management/contracts.ts +1 -8
  158. package/src/console/data-management/operations.ts +61 -48
  159. package/src/console/data-management/routes.ts +4 -2
  160. package/src/console/domain-registration.ts +25 -3
  161. package/src/console/inferhub/client.ts +139 -0
  162. package/src/console/inferhub/key-store.ts +129 -0
  163. package/src/console/inferhub/normalize.ts +347 -0
  164. package/src/console/inferhub/routes.ts +411 -0
  165. package/src/console/inferhub/store.ts +109 -0
  166. package/src/console/inferhub/types.ts +183 -0
  167. package/src/console/observability/contracts.ts +2 -41
  168. package/src/console/observability/store.ts +176 -371
  169. package/src/console/providers/catalog/public-model-store.ts +0 -0
  170. package/src/console/providers/catalog/store.ts +23 -36
  171. package/src/console/quota/account-quota-shared.ts +52 -52
  172. package/src/console/quota/account-quota-tenant.ts +16 -24
  173. package/src/console/quota/refresh.ts +441 -441
  174. package/src/console/routing/pools/import-parse.ts +206 -206
  175. package/src/console/routing/pools/operations.ts +543 -543
  176. package/src/console/routing/pools/probe-verdict.ts +132 -132
  177. package/src/console/routing/pools/routes.ts +181 -181
  178. package/src/console/settings/contracts.ts +42 -40
  179. package/src/console/settings/store.ts +25 -25
  180. package/src/console/share/share-stats.ts +22 -4
  181. package/src/console/share/share-usage.ts +30 -4
  182. package/src/console/shared/cursor.ts +15 -0
  183. package/src/console/updates/routes.ts +12 -0
  184. package/src/main.ts +13 -5
  185. package/src/network/pool/resolver.ts +1104 -1104
  186. package/src/observability/client-name.ts +22 -0
  187. package/src/observability/event-log.ts +5 -29
  188. package/src/observability/event-query.ts +690 -0
  189. package/src/observability/log-ring.ts +26 -2
  190. package/src/observability/log-sanitize.ts +118 -0
  191. package/src/observability/logger.ts +17 -13
  192. package/src/observability/metrics.ts +35 -0
  193. package/src/observability/payload-capture.ts +114 -41
  194. package/src/observability/payload-store.ts +249 -24
  195. package/src/observability/raw-event-writer.ts +118 -0
  196. package/src/observability/runtime-metrics.ts +71 -1
  197. package/src/observability/telemetry-buffer.ts +100 -17
  198. package/src/observability/telemetry-reconciliation.ts +417 -0
  199. package/src/persistence/boot.ts +20 -9
  200. package/src/persistence/db-pg.ts +13 -0
  201. package/src/persistence/db-sqlite.ts +569 -497
  202. package/src/persistence/dialect.ts +3 -2
  203. package/src/persistence/migrate.ts +181 -181
  204. package/src/persistence/schema.ts +1496 -1406
  205. package/src/persistence/shared-cache.ts +5 -1
  206. package/src/persistence/stores/api-key-store.ts +581 -581
  207. package/src/persistence/stores/telemetry-store.ts +124 -63
  208. package/src/protocol/reasoning-text.ts +30 -0
  209. package/src/protocol/response/chat.ts +33 -7
  210. package/src/protocol/response/codex.ts +14 -2
  211. package/src/protocol/response/messages.ts +5 -2
  212. package/src/providers/authentication/oauth-flow-store.ts +3 -10
  213. package/src/providers/bundled-discovery-providers.ts +122 -122
  214. package/src/providers/default-registry.ts +10 -0
  215. package/src/providers/discovery/catalog/catalog-blob.ts +18 -18
  216. package/src/providers/discovery/models-dev-catalog.ts +354 -354
  217. package/src/providers/discovery/probe-phases.ts +43 -7
  218. package/src/providers/discovery/probing-service.ts +70 -32
  219. package/src/providers/integrations/antigravity/antigravity.ts +9 -2
  220. package/src/providers/integrations/buddy/buddy-catalog-shared.ts +85 -17
  221. package/src/providers/integrations/buddy/buddy-chat-shared.ts +934 -934
  222. package/src/providers/integrations/buddy/buddy-checkin.ts +5 -9
  223. package/src/providers/integrations/buddy/buddy-discovery-shared.ts +3 -7
  224. package/src/providers/integrations/buddy/buddy-quota-shared.ts +3 -1
  225. package/src/providers/integrations/buddy/codebuddy/cn-provider.ts +57 -49
  226. package/src/providers/integrations/buddy/codebuddy/provider.ts +180 -180
  227. package/src/providers/integrations/buddy/workbuddy/provider.ts +206 -206
  228. package/src/providers/integrations/buddy/workbuddy/quota.ts +161 -161
  229. package/src/providers/integrations/claude/claude-quota.ts +2 -2
  230. package/src/providers/integrations/claude/claude-user-profiles.ts +3 -2
  231. package/src/providers/integrations/claude/claude.ts +3 -4
  232. package/src/providers/integrations/cline/cline.ts +94 -8
  233. package/src/providers/integrations/codex/codex.ts +61 -37
  234. package/src/providers/integrations/cursor/cursor-catalog.ts +368 -368
  235. package/src/providers/integrations/cursor/cursor-http2.ts +300 -300
  236. package/src/providers/integrations/cursor/cursor-identity.ts +2 -4
  237. package/src/providers/integrations/cursor/cursor-request.ts +11 -0
  238. package/src/providers/integrations/cursor/cursor.ts +29 -17
  239. package/src/providers/integrations/cursor/protocol/framing.ts +129 -129
  240. package/src/providers/integrations/cursor/protocol/messages.ts +2380 -2380
  241. package/src/providers/integrations/cursor/protocol/struct.ts +305 -305
  242. package/src/providers/integrations/cursor/protocol/tool-canonical.ts +275 -0
  243. package/src/providers/integrations/grok/grok-quota.ts +279 -279
  244. package/src/providers/integrations/grok/grok-ratelimit-store.ts +73 -73
  245. package/src/providers/integrations/grok/grok-ratelimit.ts +218 -218
  246. package/src/providers/integrations/hermes.ts +7 -1
  247. package/src/providers/integrations/inferhub.ts +201 -73
  248. package/src/providers/integrations/kilo/kilo.ts +152 -152
  249. package/src/providers/integrations/kiro/kiro-machine-id.ts +2 -4
  250. package/src/providers/integrations/openrouter/provider.ts +129 -129
  251. package/src/providers/integrations/qoder/qoder-quota.ts +193 -192
  252. package/src/providers/integrations/qoder/qoder-realm.ts +106 -106
  253. package/src/providers/integrations/trae/trae-oauth.ts +4 -26
  254. package/src/providers/integrations/trae/trae-quota.ts +389 -384
  255. package/src/providers/integrations/trae/trae-realm.ts +15 -2
  256. package/src/providers/integrations/xiaomi-mimo/think-stream.ts +100 -24
  257. package/src/providers/integrations/zcode/oauth.ts +1 -1
  258. package/src/providers/operations/account-health-service.ts +605 -279
  259. package/src/providers/provider-metadata.ts +12 -1
  260. package/src/providers/provider-model-definition.ts +373 -373
  261. package/src/providers/quota/quota-contracts.ts +3 -7
  262. package/src/providers/search/search-provider.ts +2 -6
  263. package/src/security/admission/contracts.ts +10 -0
  264. package/src/security/admission/in-memory-store.ts +89 -14
  265. package/src/security/admission/lua.ts +191 -182
  266. package/src/security/admission/redis-store.ts +30 -0
  267. package/src/security/admission/service.ts +18 -0
  268. package/src/security/api-key-auth-cache.ts +96 -0
  269. package/src/security/api-key-auth.ts +11 -100
  270. package/src/security/client-router-fingerprint.ts +3 -7
  271. package/src/security/ip-boundary.ts +7 -3
  272. package/src/shared/coerce.ts +67 -0
  273. package/src/shared/hash.ts +6 -0
  274. package/src/shared/time.ts +7 -0
  275. package/src/transport/dispatch/attempt-finalize.ts +26 -138
  276. package/src/transport/dispatch/attempt-loop.ts +103 -81
  277. package/src/transport/dispatch/capture-policy.ts +78 -0
  278. package/src/transport/dispatch/proxy-request.ts +4 -2
  279. package/src/transport/dispatch/streaming/encoder.ts +111 -5
  280. package/src/transport/dispatch/streaming/stream.ts +19 -2
  281. package/src/transport/dispatch/streaming/thinking-loop.ts +300 -0
  282. package/src/transport/failure-policy.ts +27 -0
  283. package/src/transport/guards/abuse-guard.ts +2 -2
  284. package/src/transport/guards/auth-guard.ts +17 -0
  285. package/src/transport/guards/body-policy.ts +3 -2
  286. package/src/transport/guards/concurrency-guard.ts +89 -13
  287. package/src/transport/guards/early-rejection-capture.ts +43 -3
  288. package/src/transport/guards/error-lifecycle.ts +10 -2
  289. package/src/transport/guards/guard-errors.ts +23 -0
  290. package/src/transport/guards/headroom-guard.ts +41 -7
  291. package/src/transport/guards/pipeline.ts +24 -0
  292. package/src/transport/guards/request-context.ts +2 -1
  293. package/src/transport/routing/admission.ts +51 -9
  294. package/src/transport/routing/bounded-lru.ts +49 -0
  295. package/src/transport/routing/candidate-index.ts +88 -0
  296. package/src/transport/routing/reservations.ts +121 -7
  297. package/src/transport/routing/round-robin.ts +10 -22
  298. package/src/transport/routing/route-catalog.ts +26 -13
  299. package/src/transport/routing/route-model.ts +22 -2
  300. package/src/transport/routing/router.ts +53 -31
  301. package/src/transport/surface/chat/encode.ts +2 -3
  302. package/src/transport/surface/chat/parse.ts +2 -1
  303. package/src/transport/surface/dialects.ts +11 -3
  304. package/src/transport/surface/messages/encode.ts +2 -1
  305. package/src/transport/surface/messages/parse.ts +3 -1
  306. package/src/transport/surface/messages/stream.ts +2 -6
  307. package/src/workers/maintenance-lease.ts +200 -0
  308. package/src/workers/scheduler.ts +101 -1
  309. package/dist/dashboard/assets/BannedPage-Cd30Z0b9.js.gz +0 -0
  310. package/dist/dashboard/assets/CliToolDetailPage-wf5u-ww6.js.gz +0 -0
  311. package/dist/dashboard/assets/CliToolsPage-DykPvuck.js.gz +0 -0
  312. package/dist/dashboard/assets/CombosPage-DwvSD3vs.js.gz +0 -0
  313. package/dist/dashboard/assets/ConsoleLogPage-CIr7KDex.js.gz +0 -0
  314. package/dist/dashboard/assets/CustomizationPage-82X0DDdH.js.gz +0 -0
  315. package/dist/dashboard/assets/HealthEventsModal-BYPucTTn.js +0 -1
  316. package/dist/dashboard/assets/HealthEventsModal-BYPucTTn.js.gz +0 -0
  317. package/dist/dashboard/assets/LoginPage-CS5mPuVF.js.gz +0 -0
  318. package/dist/dashboard/assets/ModelPicker-C0UCUsVJ.js.gz +0 -0
  319. package/dist/dashboard/assets/OverviewPage-Dk_ew3YU.js +0 -1
  320. package/dist/dashboard/assets/OverviewPage-Dk_ew3YU.js.gz +0 -0
  321. package/dist/dashboard/assets/ProviderDetailPage-CAiRn2Q0.js +0 -3
  322. package/dist/dashboard/assets/ProviderDetailPage-CAiRn2Q0.js.gz +0 -0
  323. package/dist/dashboard/assets/ProvidersPage-vdja5ZPK.js.gz +0 -0
  324. package/dist/dashboard/assets/ProxyPage-BB4CNm4-.js.gz +0 -0
  325. package/dist/dashboard/assets/QuotaPage-FEc4iNyq.js +0 -1
  326. package/dist/dashboard/assets/QuotaPage-FEc4iNyq.js.gz +0 -0
  327. package/dist/dashboard/assets/SettingsPage-Dlfi5L4N.js +0 -1
  328. package/dist/dashboard/assets/SettingsPage-Dlfi5L4N.js.gz +0 -0
  329. package/dist/dashboard/assets/SetupPage-FadUOl9S.js.gz +0 -0
  330. package/dist/dashboard/assets/SortableList-Cfy6_Khv.js.gz +0 -0
  331. package/dist/dashboard/assets/UsagePage-BT4MyQeT.js.gz +0 -0
  332. package/dist/dashboard/assets/api-keys-DQYLQxpW.js.gz +0 -0
  333. package/dist/dashboard/assets/app-CX_Z8cMs.css +0 -2
  334. package/dist/dashboard/assets/app-CX_Z8cMs.css.gz +0 -0
  335. package/dist/dashboard/assets/assert-C9FD_JwJ.js.gz +0 -0
  336. package/dist/dashboard/assets/badge-Bvyr7D0u.js.gz +0 -0
  337. package/dist/dashboard/assets/banner-DIvKAZih.js.gz +0 -0
  338. package/dist/dashboard/assets/card-D5_gYIS_.js.gz +0 -0
  339. package/dist/dashboard/assets/cli-tools-CNqzrfdb.js.gz +0 -0
  340. package/dist/dashboard/assets/clipboard-button-B6vDBtbm.js.gz +0 -0
  341. package/dist/dashboard/assets/dialog-CpjrgdwJ.js.gz +0 -0
  342. package/dist/dashboard/assets/drawer-BWmt3b5y.js.gz +0 -0
  343. package/dist/dashboard/assets/entry-B-gvimrg.js +0 -1
  344. package/dist/dashboard/assets/entry-B-gvimrg.js.gz +0 -0
  345. package/dist/dashboard/assets/entry-C_oakV3z.js.gz +0 -0
  346. package/dist/dashboard/assets/entry-Dj0cHfEc.js +0 -4
  347. package/dist/dashboard/assets/entry-Dj0cHfEc.js.gz +0 -0
  348. package/dist/dashboard/assets/github-badge-RRHHWGxh.js.gz +0 -0
  349. package/dist/dashboard/assets/index-Bhey_rg_.js.gz +0 -0
  350. package/dist/dashboard/assets/inline-C2_EQ5Uf.js.gz +0 -0
  351. package/dist/dashboard/assets/input-BMRlQ450.js.gz +0 -0
  352. package/dist/dashboard/assets/layout-XR5xP8ew.js.gz +0 -0
  353. package/dist/dashboard/assets/list-row-Cw07dioc.js.gz +0 -0
  354. package/dist/dashboard/assets/live-BivGfuUs.js.gz +0 -0
  355. package/dist/dashboard/assets/model-bans-CmffjLuG.js.gz +0 -0
  356. package/dist/dashboard/assets/mutation-46P0oWMe.js.gz +0 -0
  357. package/dist/dashboard/assets/numbers-DrdA9oSM.js.gz +0 -0
  358. package/dist/dashboard/assets/query-keys-BFhGpwR1.js.gz +0 -0
  359. package/dist/dashboard/assets/routing-CAb4F37R.js.gz +0 -0
  360. package/dist/dashboard/assets/select-CxVVB__N.js.gz +0 -0
  361. package/dist/dashboard/assets/settings-DNX9sydi.js.gz +0 -0
  362. package/dist/dashboard/assets/stack-uPOwM9Hs.js.gz +0 -0
  363. package/dist/dashboard/assets/state-D1Yz79iL.js.gz +0 -0
  364. package/dist/dashboard/assets/switch-sevsRrRo.js.gz +0 -0
  365. package/dist/dashboard/assets/useMutation-Dem0lXQj.js.gz +0 -0
  366. package/src/observability/event-compaction.ts +0 -317
  367. package/src/observability/event-day-index.ts +0 -439
@@ -61,6 +61,7 @@ export class RedisAdmissionCounterStore implements AdmissionCounterStore {
61
61
  request.apiKeyId,
62
62
  String(LEASE_TTL_MS),
63
63
  String(LEASE_KEY_TTL_SECONDS),
64
+ String(Math.max(0, request.concurrencyTolerance ?? 0)),
64
65
  );
65
66
  assertResult(result, "reserve");
66
67
  }
@@ -142,11 +143,20 @@ export class RedisAdmissionCounterStore implements AdmissionCounterStore {
142
143
  * Drops every counter for one key (revocation). Active in-flight leases
143
144
  * settle through the release script first so their concurrency slots do
144
145
  * not leak; remaining lease rows expire via TTL. Best-effort by contract.
146
+ *
147
+ * Bounded per invocation: a keyspace that has grown for months cannot turn
148
+ * one revocation into an unbounded walk — lease-scan pages, leases released,
149
+ * and counter-scan pages each stop cleanly at their budget, and the rest
150
+ * ages out via TTL (the fallback this contract already relies on). A later
151
+ * revocation or the lease sweeper picks up what a budgeted pass left.
145
152
  */
146
153
  async purge(apiKeyId: string): Promise<void> {
147
154
  try {
155
+ let leaseScanPages = 0;
156
+ let leasesReleased = 0;
148
157
  let cursor = "0";
149
158
  do {
159
+ if (leaseScanPages >= MAX_PURGE_LEASE_SCAN_PAGES) break;
150
160
  const [nextCursor, keys] = await this.redis.scan(
151
161
  cursor,
152
162
  "MATCH",
@@ -155,6 +165,7 @@ export class RedisAdmissionCounterStore implements AdmissionCounterStore {
155
165
  100,
156
166
  );
157
167
  cursor = String(nextCursor);
168
+ leaseScanPages += 1;
158
169
  const leaseKeys = keys as string[];
159
170
  if (leaseKeys.length === 0) continue;
160
171
  // One pipeline for the whole scan page instead of an HGET per lease:
@@ -172,15 +183,22 @@ export class RedisAdmissionCounterStore implements AdmissionCounterStore {
172
183
  return Array.isArray(entry) && entry[1] === apiKeyId;
173
184
  });
174
185
  for (const leaseKey of owned) {
186
+ if (leasesReleased >= MAX_PURGE_LEASES_RELEASED) break;
175
187
  await redisEvalNumber(this.redis, RELEASE_SCRIPT, 1, leaseKey, apiKeyId).catch(
176
188
  () => -1,
177
189
  );
190
+ leasesReleased += 1;
178
191
  }
192
+ // Releasing is the point of the lease phase; once its budget is spent
193
+ // there is nothing more this pass can usefully do with further pages.
194
+ if (leasesReleased >= MAX_PURGE_LEASES_RELEASED) break;
179
195
  } while (cursor !== "0");
196
+ let counterScanPages = 0;
180
197
  let counterCursor = "0";
181
198
  const patterns = [`admission:*:${apiKeyId}`, `admission:*:${apiKeyId}:*`];
182
199
  for (const pattern of patterns) {
183
200
  do {
201
+ if (counterScanPages >= MAX_PURGE_COUNTER_SCAN_PAGES) break;
184
202
  const [nextCursor, keys] = await this.redis.scan(
185
203
  counterCursor,
186
204
  "MATCH",
@@ -189,8 +207,10 @@ export class RedisAdmissionCounterStore implements AdmissionCounterStore {
189
207
  100,
190
208
  );
191
209
  counterCursor = String(nextCursor);
210
+ counterScanPages += 1;
192
211
  if ((keys as string[]).length > 0) await this.redis.del(...(keys as string[]));
193
212
  } while (counterCursor !== "0");
213
+ if (counterScanPages >= MAX_PURGE_COUNTER_SCAN_PAGES) break;
194
214
  }
195
215
  } catch {
196
216
  // Best-effort: revocation already succeeded; stale counters expire via TTL.
@@ -198,6 +218,16 @@ export class RedisAdmissionCounterStore implements AdmissionCounterStore {
198
218
  }
199
219
  }
200
220
 
221
+ /**
222
+ * Per-invocation purge budgets. Chosen so one full-budgeted pass still costs
223
+ * well under a second of Redis time (hundreds of small commands), while a
224
+ * normal revocation — tens of leases, a handful of counters — never notices
225
+ * them. Mirrors the lease sweeper's own bounded-pass philosophy.
226
+ */
227
+ export const MAX_PURGE_LEASE_SCAN_PAGES = 40;
228
+ export const MAX_PURGE_LEASES_RELEASED = 100;
229
+ export const MAX_PURGE_COUNTER_SCAN_PAGES = 40;
230
+
201
231
  /**
202
232
  * Releases a lease identified only by its Redis key (used by the sweeper).
203
233
  * Reads the owning api key id from the lease hash and runs RELEASE_SCRIPT,
@@ -35,6 +35,14 @@ export class ApiKeyAdmissionService {
35
35
  ) => Promise<number | null> | number | null = () => null,
36
36
  private readonly persistLifetimeUsage?: LifetimeUsagePersister,
37
37
  private readonly freshLifetimeConsumed?: FreshLifetimeConsumedReader,
38
+ /**
39
+ * Hidden allowance above each key's concurrency limit. Injected rather
40
+ * than read from the snapshot so the number is operator configuration,
41
+ * not something a key can carry about itself.
42
+ */
43
+ private readonly concurrencyTolerance?: (
44
+ tenantId: string,
45
+ ) => Promise<number | null> | number | null,
38
46
  ) {
39
47
  if (!store) throw new Error("ApiKeyAdmissionService requires an admission store");
40
48
  }
@@ -96,6 +104,15 @@ export class ApiKeyAdmissionService {
96
104
  const reservationId = `${apiKeyId}:${now}:${crypto.randomUUID()}`;
97
105
  const rawTenantLimit = this.tenantConcurrencyProvider(snapshot.tenant_id);
98
106
  const tenantLimit = rawTenantLimit instanceof Promise ? await rawTenantLimit : rawTenantLimit;
107
+ /*
108
+ * One number, one place. The allowance used to be overridable per key,
109
+ * so the tenant-wide value was only the answer for a key that had never
110
+ * been given one — an operator who raised it in Settings could not tell
111
+ * why one key still refused early, because the override lived in a modal
112
+ * the Settings page does not show. It is tenant-wide only now.
113
+ */
114
+ const rawTolerance = this.concurrencyTolerance?.(snapshot.tenant_id);
115
+ const tolerance = rawTolerance instanceof Promise ? await rawTolerance : rawTolerance;
99
116
 
100
117
  try {
101
118
  await this.store.reserve({
@@ -113,6 +130,7 @@ export class ApiKeyAdmissionService {
113
130
  lifetimeBudget: snapshot.lifetime_token_budget ?? null,
114
131
  lifetimeConsumed: snapshot.lifetime_tokens_consumed ?? 0,
115
132
  concurrencyLimit: snapshot.max_concurrent ?? null,
133
+ concurrencyTolerance: tolerance ?? null,
116
134
  tenantId: snapshot.tenant_id,
117
135
  tenantConcurrencyLimit: tenantLimit,
118
136
  // Fresh lifetime reader is invoked by the store only when the counter
@@ -0,0 +1,96 @@
1
+ /** Bounded process-local authorization cache; shared-cache I/O stays in the auth service. */
2
+ export const AUTH_CACHE_TTL_MS = 3_000;
3
+ const AUTH_CACHE_MAX_ENTRIES = 10_000;
4
+ const AUTH_NEGATIVE_CACHE_TTL_MS = 1_500;
5
+ const AUTH_FAILURE_COOLDOWN_MS = 1_000;
6
+
7
+ interface CacheEntry<T> {
8
+ readonly at: number;
9
+ readonly value: T;
10
+ readonly keyId: string;
11
+ }
12
+
13
+ type Lookup<T> =
14
+ | { readonly kind: "positive"; readonly value: T }
15
+ | { readonly kind: "negative" }
16
+ | { readonly kind: "miss" };
17
+
18
+ /** Create the process-local positive/negative authorization cache. */
19
+ export function createApiKeyAuthorizationCache<T>() {
20
+ const entries = new Map<string, CacheEntry<T>>();
21
+ const hashesByKeyId = new Map<string, Set<string>>();
22
+ const negativeEntries = new Map<string, number>();
23
+ let failureUntil = 0;
24
+
25
+ const evictOldest = (): void => {
26
+ while (entries.size > AUTH_CACHE_MAX_ENTRIES) {
27
+ const oldest = entries.keys().next().value;
28
+ if (oldest === undefined) return;
29
+ const entry = entries.get(oldest);
30
+ entries.delete(oldest);
31
+ if (entry === undefined) continue;
32
+ const hashes = hashesByKeyId.get(entry.keyId);
33
+ if (hashes === undefined) continue;
34
+ hashes.delete(oldest);
35
+ if (hashes.size === 0) hashesByKeyId.delete(entry.keyId);
36
+ }
37
+ };
38
+
39
+ const evictNegativeOldest = (): void => {
40
+ while (negativeEntries.size > AUTH_CACHE_MAX_ENTRIES) {
41
+ const oldest = negativeEntries.keys().next().value;
42
+ if (oldest === undefined) return;
43
+ negativeEntries.delete(oldest);
44
+ }
45
+ };
46
+
47
+ return {
48
+ lookup(hash: string, now: number): Lookup<T> {
49
+ const negativeAt = negativeEntries.get(hash);
50
+ if (negativeAt !== undefined) {
51
+ if (now - negativeAt < AUTH_NEGATIVE_CACHE_TTL_MS) return { kind: "negative" };
52
+ negativeEntries.delete(hash);
53
+ }
54
+ const cached = entries.get(hash);
55
+ if (cached === undefined || now - cached.at >= AUTH_CACHE_TTL_MS) return { kind: "miss" };
56
+ entries.delete(hash);
57
+ entries.set(hash, cached);
58
+ return { kind: "positive", value: cached.value };
59
+ },
60
+
61
+ rememberPositive(hash: string, keyId: string, value: T, now: number): void {
62
+ entries.set(hash, { at: now, value, keyId });
63
+ negativeEntries.delete(hash);
64
+ const hashes = hashesByKeyId.get(keyId) ?? new Set<string>();
65
+ hashes.add(hash);
66
+ hashesByKeyId.set(keyId, hashes);
67
+ evictOldest();
68
+ },
69
+
70
+ rememberNegative(hash: string, now: number): void {
71
+ negativeEntries.set(hash, now);
72
+ evictNegativeOldest();
73
+ },
74
+
75
+ invalidate(keyId?: string): void {
76
+ negativeEntries.clear();
77
+ if (keyId === undefined) {
78
+ entries.clear();
79
+ hashesByKeyId.clear();
80
+ return;
81
+ }
82
+ const hashes = hashesByKeyId.get(keyId);
83
+ if (hashes === undefined) return;
84
+ for (const hash of hashes) entries.delete(hash);
85
+ hashesByKeyId.delete(keyId);
86
+ },
87
+
88
+ markStoreUnavailable(now: number): void {
89
+ failureUntil = now + AUTH_FAILURE_COOLDOWN_MS;
90
+ },
91
+
92
+ isStoreUnavailable(now: number): boolean {
93
+ return now < failureUntil;
94
+ },
95
+ };
96
+ }
@@ -5,6 +5,7 @@ import { deleteShared, publishInvalidation, readShared, writeShared } from "../p
5
5
  import { DrizzleApiKeyStore } from "../persistence/stores/api-key-store";
6
6
  import { GatewayError } from "../protocol/gateway-error";
7
7
  import { hashSecret } from "./crypto";
8
+ import { AUTH_CACHE_TTL_MS, createApiKeyAuthorizationCache } from "./api-key-auth-cache";
8
9
  import type { AccessScope } from "./access-control";
9
10
  import type { ApiKeyModelAccessMode } from "../persistence/schema";
10
11
 
@@ -23,37 +24,7 @@ import type { ApiKeyModelAccessMode } from "../persistence/schema";
23
24
  * Negative results (unknown/revoked tokens) are deliberately NOT cached, so
24
25
  * a newly minted key is usable immediately.
25
26
  */
26
- const AUTH_CACHE_TTL_MS = 3_000;
27
- const AUTH_CACHE_MAX_ENTRIES = 10_000;
28
- /** Negative-result TTL: unknown/revoked tokens miss the DB at most this often. */
29
- const AUTH_NEGATIVE_CACHE_TTL_MS = 1_500;
30
-
31
- interface AuthCacheEntry {
32
- readonly at: number;
33
- readonly value: ResolvedApiKey;
34
- /** Owner of `value`; the reverse-index key this entry is registered under. */
35
- readonly keyId: string;
36
- }
37
-
38
- const authCache = new Map<string, AuthCacheEntry>();
39
- /** Reverse index: keyId → set of token hashes that resolved to it. */
40
- const authCacheByKeyId = new Map<string, Set<string>>();
41
- /** Unknown/revoked token HMACs with their miss time; cleared on any key mutation. */
42
- const authNegativeCache = new Map<string, number>();
43
-
44
- /**
45
- * Short circuit after a credential-store failure.
46
- *
47
- * An overloaded or down database fails every lookup, and each failure costs a
48
- * connection attempt. Without this, a saturated backend received one query per
49
- * inbound request — load the operator sees as traffic, on top of the load that
50
- * caused the outage. For the window, lookups answer "unavailable" instead.
51
- *
52
- * Deliberately short: this is an auth path, and a brief outage must not lock
53
- * out valid keys for long.
54
- */
55
- const AUTH_FAILURE_COOLDOWN_MS = 1_000;
56
- let authFailureUntil = 0;
27
+ const authorizationCache = createApiKeyAuthorizationCache<ResolvedApiKey>();
57
28
 
58
29
  /** Thrown when the credential store could not be reached at all. */
59
30
  export class ApiKeyStoreUnavailableError extends Error {
@@ -84,52 +55,6 @@ let pendingUsage: (apiKeyId: string) => number = () => 0;
84
55
  export function setPendingUsageSource(source: (apiKeyId: string) => number): void {
85
56
  pendingUsage = source;
86
57
  }
87
- /**
88
- * Negative-result fast path. A repeat miss for an unknown/revoked token within
89
- * 1.5 s costs one HMAC + one map lookup and no query. Keyed by the full HMAC: a
90
- * cheap pre-hash would let a crafted collision reject someone else's valid key,
91
- * while the HMAC key can only match its own token. Bound below beside the
92
- * positive cache.
93
- */
94
-
95
- /**
96
- * Drops the oldest entries until `authCache` is within budget. Every removal
97
- * must also unregister the hash from `authCacheByKeyId`, otherwise the reverse
98
- * index keeps referencing hashes that no longer exist and grows without bound
99
- * (one dead hash per eviction, forever).
100
- */
101
- function evictAuthCacheOldest(): void {
102
- while (authCache.size > AUTH_CACHE_MAX_ENTRIES) {
103
- const oldest = authCache.keys().next().value;
104
- if (oldest === undefined) return;
105
- const entry = authCache.get(oldest);
106
- authCache.delete(oldest);
107
- if (entry === undefined) continue;
108
- const hashes = authCacheByKeyId.get(entry.keyId);
109
- if (hashes === undefined) continue;
110
- hashes.delete(oldest);
111
- if (hashes.size === 0) authCacheByKeyId.delete(entry.keyId);
112
- }
113
- }
114
-
115
- /** Registers a resolved hash in both the cache and the keyId reverse index. */
116
- function indexAuthCache(hash: string, entry: AuthCacheEntry): void {
117
- authCache.set(hash, entry);
118
- authNegativeCache.delete(hash);
119
- const hashes = authCacheByKeyId.get(entry.keyId) ?? new Set<string>();
120
- hashes.add(hash);
121
- authCacheByKeyId.set(entry.keyId, hashes);
122
- }
123
-
124
- /** Bounds the negative cache; same oldest-first rule as the positive cache. */
125
- function evictAuthNegativeOldest(): void {
126
- while (authNegativeCache.size > AUTH_CACHE_MAX_ENTRIES) {
127
- const oldest = authNegativeCache.keys().next().value;
128
- if (oldest === undefined) return;
129
- authNegativeCache.delete(oldest);
130
- }
131
- }
132
-
133
58
  /**
134
59
  * Invalidates every cached authorization entry for one API key (or the entire
135
60
  * cache when `keyId` is omitted). Called by the API-key domain after any
@@ -138,19 +63,12 @@ function evictAuthNegativeOldest(): void {
138
63
  export function invalidateApiKeyCache(keyId?: string): void {
139
64
  // A mutation may mint a token the negative cache has already rejected, so
140
65
  // negative entries must go with the positive ones.
141
- authNegativeCache.clear();
66
+ authorizationCache.invalidate(keyId);
142
67
  if (keyId === undefined) {
143
- authCache.clear();
144
- authCacheByKeyId.clear();
145
68
  void deleteShared("auth");
146
69
  publishInvalidation({ namespace: "auth" });
147
70
  return;
148
71
  }
149
- const hashes = authCacheByKeyId.get(keyId);
150
- if (hashes) {
151
- for (const hash of hashes) authCache.delete(hash);
152
- authCacheByKeyId.delete(keyId);
153
- }
154
72
  // Peer processes hold their own copy, and `reusePort` means the console
155
73
  // write may have landed on a different one entirely. Dropping only locally
156
74
  // leaves a revoked key valid for up to a full TTL everywhere else.
@@ -388,13 +306,9 @@ export async function resolveApiKeyAuthorization(
388
306
  ): Promise<ResolvedApiKey | undefined> {
389
307
  const now = Date.now();
390
308
  const hash = hashSecret(token);
391
- const negativeAt = authNegativeCache.get(hash);
392
- if (negativeAt !== undefined) {
393
- if (now - negativeAt < AUTH_NEGATIVE_CACHE_TTL_MS) return undefined;
394
- authNegativeCache.delete(hash);
395
- }
396
- const cached = authCache.get(hash);
397
- if (cached && now - cached.at < AUTH_CACHE_TTL_MS) return cached.value;
309
+ const cached = authorizationCache.lookup(hash, now);
310
+ if (cached.kind === "negative") return undefined;
311
+ if (cached.kind === "positive") return cached.value;
398
312
  // L2: a peer process may have resolved this token already. Reachable only
399
313
  // after the L1 entry lapsed, so Redis is never consulted on a warm hit.
400
314
  //
@@ -405,8 +319,7 @@ export async function resolveApiKeyAuthorization(
405
319
  try {
406
320
  const parsed = JSON.parse(shared) as ResolvedApiKey;
407
321
  if (typeof parsed?.id === "string") {
408
- indexAuthCache(hash, { at: now, value: parsed, keyId: parsed.id });
409
- evictAuthCacheOldest();
322
+ authorizationCache.rememberPositive(hash, parsed.id, parsed, now);
410
323
  return parsed;
411
324
  }
412
325
  } catch {
@@ -415,7 +328,7 @@ export async function resolveApiKeyAuthorization(
415
328
  }
416
329
  // Answered from the cooldown before any I/O: during a store outage the
417
330
  // query is the expensive part, and it is also the part that fails.
418
- if (now < authFailureUntil) throw new ApiKeyStoreUnavailableError();
331
+ if (authorizationCache.isStoreUnavailable(now)) throw new ApiKeyStoreUnavailableError();
419
332
  const store = new DrizzleApiKeyStore(db);
420
333
  let row: Awaited<ReturnType<typeof store.findActiveByHash>>;
421
334
  try {
@@ -423,12 +336,11 @@ export async function resolveApiKeyAuthorization(
423
336
  } catch (error) {
424
337
  // Any failure here is the store's, not the key's — a revoked key is a
425
338
  // normal empty result, so this only runs on a real fault.
426
- authFailureUntil = Date.now() + AUTH_FAILURE_COOLDOWN_MS;
339
+ authorizationCache.markStoreUnavailable(Date.now());
427
340
  throw error;
428
341
  }
429
342
  if (!row) {
430
- authNegativeCache.set(hash, now);
431
- evictAuthNegativeOldest();
343
+ authorizationCache.rememberNegative(hash, now);
432
344
  return undefined;
433
345
  }
434
346
  // A child (shared) key consumes against its parent's quota, not just its
@@ -487,8 +399,7 @@ export async function resolveApiKeyAuthorization(
487
399
  ...(policy.modelPrefix ? { modelPrefix: policy.modelPrefix } : {}),
488
400
  ...(parent?.keyMode === "share" ? { cliMappingOwnerId: parent.id } : {}),
489
401
  };
490
- indexAuthCache(hash, { at: Date.now(), value, keyId: value.id });
491
- evictAuthCacheOldest();
402
+ authorizationCache.rememberPositive(hash, value.id, value, Date.now());
492
403
  // Shared copy for peer processes, keyed by the token hash so a lookup can
493
404
  // find it without resolving the key first. Invalidation travels by broadcast,
494
405
  // since a console mutation names a key id and cannot enumerate the hashes
@@ -1,3 +1,5 @@
1
+ import { getHeader as readRecordHeader } from "../protocol/canonical-model";
2
+
1
3
  // Client-router fingerprinting for inbound `/v1/*` traffic.
2
4
  //
3
5
  // Several AI-gateway products can themselves be pointed at this gateway. An
@@ -133,13 +135,7 @@ export interface ClientRouterProbe {
133
135
  function readHeader(probe: ClientRouterProbe, name: string): string | undefined {
134
136
  const headers = probe.headers;
135
137
  if (headers instanceof Headers) return headers.get(name) ?? undefined;
136
- const direct = headers[name];
137
- if (direct !== undefined) return direct;
138
- const lowered = name.toLowerCase();
139
- for (const [key, value] of Object.entries(headers)) {
140
- if (key.toLowerCase() === lowered) return value;
141
- }
142
- return undefined;
138
+ return readRecordHeader(headers, name);
143
139
  }
144
140
 
145
141
  function signalMatches(signal: ClientRouterSignal, value: string): boolean {
@@ -2,6 +2,7 @@
2
2
 
3
3
  import { isIP } from "node:net";
4
4
  import type { TrustedProxyBoundary } from "../config/network";
5
+ import { log } from "../observability/logger";
5
6
 
6
7
  function ipv4ToNumber(address: string): number | undefined {
7
8
  const parts = address.split(".");
@@ -319,9 +320,12 @@ function maybeWarnIgnoredForwardedHeaders(
319
320
  if (collapseWarned) return;
320
321
  if (!isSuspectedProxyCollapse(request, boundary, peer)) return;
321
322
  collapseWarned = true;
322
- // Not `log`, to keep this module importable without the logger's transport
323
- // (and usable from the boundary tests, which construct bare `Request`s).
324
- console.warn(
323
+ // Via `log`, so this ships through the same transport as everything else and
324
+ // is level-filtered. It was `console.warn` to keep the module importable
325
+ // without the logger, but the logger pulls in only pino, the console log ring
326
+ // and its own sanitizer — none of which import this module — so there is no
327
+ // cycle and the boundary tests import it the same way production does.
328
+ log.warn(
325
329
  `[ip-boundary] A forwarded client-address header arrived from a loopback peer ` +
326
330
  `(${unwrapMappedIpv4(peer)}) but the trusted-proxy boundary does not trust it, so ` +
327
331
  `the client address is the loopback peer for every caller. Every caller behind this ` +
@@ -0,0 +1,67 @@
1
+ /**
2
+ * Shared value-coercion primitives for untyped upstream JSON.
3
+ *
4
+ * These used to exist as private copies in a dozen modules, spelled
5
+ * `record`/`asRecord`/`recordOf`/`isPlainObject` with two different nullish
6
+ * sentinels. One implementation, one sentinel (`undefined`), one place to fix.
7
+ */
8
+
9
+ /** Narrow an unknown value to a plain (non-array) object, else `undefined`. */
10
+ export function asRecord(value: unknown): Record<string, unknown> | undefined {
11
+ return value !== null && typeof value === "object" && !Array.isArray(value)
12
+ ? (value as Record<string, unknown>)
13
+ : undefined;
14
+ }
15
+
16
+ /** Narrow to a finite number, else `undefined`. `NaN`/`Infinity` are not numbers here. */
17
+ export function asNumber(value: unknown): number | undefined {
18
+ return typeof value === "number" && Number.isFinite(value) ? value : undefined;
19
+ }
20
+
21
+ /**
22
+ * Finite number accepting numeric strings (`"42"` → 42). Whitespace-only or
23
+ * non-finite results are `undefined`. This is the "upstream JSON may quote a
24
+ * number" contract; use `asNumber` when strings must stay rejected.
25
+ */
26
+ export function asCoercedNumber(value: unknown): number | undefined {
27
+ if (typeof value === "number") return asNumber(value);
28
+ if (typeof value === "string" && value.trim().length > 0) {
29
+ const parsed = Number(value);
30
+ return Number.isFinite(parsed) ? parsed : undefined;
31
+ }
32
+ return undefined;
33
+ }
34
+
35
+ /** Trim a string-ish value; empty/whitespace-only becomes `undefined`. */
36
+ export function asTrimmedString(value: unknown): string | undefined {
37
+ if (typeof value !== "string") return undefined;
38
+ const trimmed = value.trim();
39
+ return trimmed.length > 0 ? trimmed : undefined;
40
+ }
41
+
42
+ /** First non-empty trimmed string among `keys` of `record`. */
43
+ export function firstNonEmptyString(
44
+ record: Record<string, unknown>,
45
+ keys: readonly string[],
46
+ ): string | undefined {
47
+ for (const key of keys) {
48
+ const value = asTrimmedString(record[key]);
49
+ if (value !== undefined) return value;
50
+ }
51
+ return undefined;
52
+ }
53
+
54
+ /**
55
+ * `JSON.parse` that yields `undefined` instead of throwing, for untyped
56
+ * upstream/console JSON that may be malformed. Non-string/empty input is
57
+ * `undefined` without attempting a parse.
58
+ */
59
+ export function tryParseJson(value: string | null | undefined): unknown {
60
+ if (typeof value !== "string" || value.length === 0) return undefined;
61
+ try {
62
+ return JSON.parse(value) as unknown;
63
+ } catch {
64
+ return undefined;
65
+ }
66
+ }
67
+
@@ -0,0 +1,6 @@
1
+ import { createHash } from "node:crypto";
2
+
3
+ /** Lowercase hex SHA-256 of a UTF-8 string, for cache-key/identity hashing. */
4
+ export function sha256Hex(value: string): string {
5
+ return createHash("sha256").update(value, "utf8").digest("hex");
6
+ }
@@ -0,0 +1,7 @@
1
+ /**
2
+ * `YYYY-MM-DDTHH` stamp used as the prefix of raw telemetry/payload files
3
+ * and as the hour comparison key on both sides of retention.
4
+ */
5
+ export function hourStamp(date: Date): string {
6
+ return date.toISOString().slice(0, 13).replaceAll(":", "-");
7
+ }