cartethyia 2.5.9 → 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 (386) hide show
  1. package/.env.example +15 -42
  2. package/README.md +11 -10
  3. package/dist/dashboard/assets/{BannedPage-Brvuj_Eb.js → BannedPage-l6XMnfr9.js} +1 -1
  4. package/dist/dashboard/assets/BannedPage-l6XMnfr9.js.gz +0 -0
  5. package/dist/dashboard/assets/{CliToolDetailPage-CqfXtI3y.js → CliToolDetailPage-DwOzAW45.js} +1 -1
  6. package/dist/dashboard/assets/CliToolDetailPage-DwOzAW45.js.gz +0 -0
  7. package/dist/dashboard/assets/{CliToolsPage-GQeS91bg.js → CliToolsPage-BT3Up3QA.js} +1 -1
  8. package/dist/dashboard/assets/CliToolsPage-BT3Up3QA.js.gz +0 -0
  9. package/dist/dashboard/assets/{CombosPage-BDRsTXBB.js → CombosPage-6kYIUpzR.js} +1 -1
  10. package/dist/dashboard/assets/CombosPage-6kYIUpzR.js.gz +0 -0
  11. package/dist/dashboard/assets/{ConsoleLogPage-zP8AFHX0.js → ConsoleLogPage-zRnj1mlR.js} +1 -1
  12. package/dist/dashboard/assets/ConsoleLogPage-zRnj1mlR.js.gz +0 -0
  13. package/dist/dashboard/assets/{CustomizationPage-BmfrNZ14.js → CustomizationPage-C7m-H1Rz.js} +1 -1
  14. package/dist/dashboard/assets/CustomizationPage-C7m-H1Rz.js.gz +0 -0
  15. package/dist/dashboard/assets/HealthEventsModal-C8MR_BdB.js +1 -0
  16. package/dist/dashboard/assets/HealthEventsModal-C8MR_BdB.js.gz +0 -0
  17. package/dist/dashboard/assets/{LoginPage-DoreIgm8.js → LoginPage-CbphLvjg.js} +1 -1
  18. package/dist/dashboard/assets/LoginPage-CbphLvjg.js.gz +0 -0
  19. package/dist/dashboard/assets/{ModelPicker-C7pDb41R.js → ModelPicker-DVny4S5_.js} +1 -1
  20. package/dist/dashboard/assets/ModelPicker-DVny4S5_.js.gz +0 -0
  21. package/dist/dashboard/assets/OverviewPage-DABu2Wgt.js +1 -0
  22. package/dist/dashboard/assets/OverviewPage-DABu2Wgt.js.gz +0 -0
  23. package/dist/dashboard/assets/ProviderDetailPage-C15x9q13.js +3 -0
  24. package/dist/dashboard/assets/ProviderDetailPage-C15x9q13.js.gz +0 -0
  25. package/dist/dashboard/assets/{ProvidersPage-BnoPgA7w.js → ProvidersPage-Dfb2896L.js} +1 -1
  26. package/dist/dashboard/assets/ProvidersPage-Dfb2896L.js.gz +0 -0
  27. package/dist/dashboard/assets/{ProxyPage-Bq_TGgdb.js → ProxyPage-BaCIdAUt.js} +1 -1
  28. package/dist/dashboard/assets/ProxyPage-BaCIdAUt.js.gz +0 -0
  29. package/dist/dashboard/assets/QuotaPage-BsdoCIw1.js +1 -0
  30. package/dist/dashboard/assets/QuotaPage-BsdoCIw1.js.gz +0 -0
  31. package/dist/dashboard/assets/SettingsPage-KVJFh3Ip.js +1 -0
  32. package/dist/dashboard/assets/SettingsPage-KVJFh3Ip.js.gz +0 -0
  33. package/dist/dashboard/assets/{SetupPage-Day3mCOn.js → SetupPage-D4g6vOGc.js} +1 -1
  34. package/dist/dashboard/assets/SetupPage-D4g6vOGc.js.gz +0 -0
  35. package/dist/dashboard/assets/{SortableList-DQxllnB5.js → SortableList-C93i14BD.js} +1 -1
  36. package/dist/dashboard/assets/SortableList-C93i14BD.js.gz +0 -0
  37. package/dist/dashboard/assets/{StudioPage-uMExoWu1.js → StudioPage-SgQpymjm.js} +1 -1
  38. package/dist/dashboard/assets/{StudioPage-uMExoWu1.js.gz → StudioPage-SgQpymjm.js.gz} +0 -0
  39. package/dist/dashboard/assets/{UsagePage-DXa1oR-4.js → UsagePage-BFME-eba.js} +2 -2
  40. package/dist/dashboard/assets/UsagePage-BFME-eba.js.gz +0 -0
  41. package/dist/dashboard/assets/{api-keys-DQYLQxpW.js → api-keys-C23S9sgp.js} +1 -1
  42. package/dist/dashboard/assets/api-keys-C23S9sgp.js.gz +0 -0
  43. package/dist/dashboard/assets/{app-BIt446yE.js → app-2KhtHCv9.js} +1 -1
  44. package/dist/dashboard/assets/{app-BIt446yE.js.gz → app-2KhtHCv9.js.gz} +0 -0
  45. package/dist/dashboard/assets/app-CEeaMHOy.css +2 -0
  46. package/dist/dashboard/assets/app-CEeaMHOy.css.gz +0 -0
  47. package/dist/dashboard/assets/{assert-C9FD_JwJ.js → assert-DU6qrMA_.js} +1 -1
  48. package/dist/dashboard/assets/assert-DU6qrMA_.js.gz +0 -0
  49. package/dist/dashboard/assets/{badge-CR72dasT.js → badge-CKsF123d.js} +1 -1
  50. package/dist/dashboard/assets/badge-CKsF123d.js.gz +0 -0
  51. package/dist/dashboard/assets/{banner-CI9_eSfO.js → banner-8j7uChA9.js} +1 -1
  52. package/dist/dashboard/assets/banner-8j7uChA9.js.gz +0 -0
  53. package/dist/dashboard/assets/{card-DS7CQEsO.js → card-CdsSlWA3.js} +1 -1
  54. package/dist/dashboard/assets/card-CdsSlWA3.js.gz +0 -0
  55. package/dist/dashboard/assets/{cli-tools-CNqzrfdb.js → cli-tools-BaFUuFFF.js} +1 -1
  56. package/dist/dashboard/assets/cli-tools-BaFUuFFF.js.gz +0 -0
  57. package/dist/dashboard/assets/{clipboard-button-611pSsaX.js → clipboard-button-uL5aYLfW.js} +1 -1
  58. package/dist/dashboard/assets/clipboard-button-uL5aYLfW.js.gz +0 -0
  59. package/dist/dashboard/assets/{dialog-k37exjNt.js → dialog-C07hr5_G.js} +1 -1
  60. package/dist/dashboard/assets/dialog-C07hr5_G.js.gz +0 -0
  61. package/dist/dashboard/assets/{drawer-CX7N8ucR.js → drawer-BPO1TcKH.js} +1 -1
  62. package/dist/dashboard/assets/drawer-BPO1TcKH.js.gz +0 -0
  63. package/dist/dashboard/assets/{entry-CTP8AyiS.js → entry-Bz_CtDfC.js} +1 -1
  64. package/dist/dashboard/assets/entry-Bz_CtDfC.js.gz +0 -0
  65. package/dist/dashboard/assets/entry-CwPRKk0d.js +4 -0
  66. package/dist/dashboard/assets/entry-CwPRKk0d.js.gz +0 -0
  67. package/dist/dashboard/assets/entry-GJRbmrpA.js +1 -0
  68. package/dist/dashboard/assets/entry-GJRbmrpA.js.gz +0 -0
  69. package/dist/dashboard/assets/{github-badge-sltk1qUi.js → github-badge-DmQreSDd.js} +1 -1
  70. package/dist/dashboard/assets/github-badge-DmQreSDd.js.gz +0 -0
  71. package/dist/dashboard/assets/{index-z3zDOSMM.js → index-Bblu6erR.js} +2 -2
  72. package/dist/dashboard/assets/index-Bblu6erR.js.gz +0 -0
  73. package/dist/dashboard/assets/{inline-BhY2sSnO.js → inline-C9dCYaBZ.js} +1 -1
  74. package/dist/dashboard/assets/inline-C9dCYaBZ.js.gz +0 -0
  75. package/dist/dashboard/assets/{input-V1umFiBk.js → input-CjCnXweJ.js} +1 -1
  76. package/dist/dashboard/assets/input-CjCnXweJ.js.gz +0 -0
  77. package/dist/dashboard/assets/{layout-BBOYycyS.js → layout-V6y8DAkl.js} +1 -1
  78. package/dist/dashboard/assets/layout-V6y8DAkl.js.gz +0 -0
  79. package/dist/dashboard/assets/{list-row-1zzZYM0B.js → list-row-CM1AaG_y.js} +1 -1
  80. package/dist/dashboard/assets/list-row-CM1AaG_y.js.gz +0 -0
  81. package/dist/dashboard/assets/{live-BivGfuUs.js → live-ta6GMb-8.js} +1 -1
  82. package/dist/dashboard/assets/live-ta6GMb-8.js.gz +0 -0
  83. package/dist/dashboard/assets/{model-bans-CmffjLuG.js → model-bans-BmYTmbAk.js} +1 -1
  84. package/dist/dashboard/assets/model-bans-BmYTmbAk.js.gz +0 -0
  85. package/dist/dashboard/assets/{mutation-46P0oWMe.js → mutation-g_fduiNw.js} +1 -1
  86. package/dist/dashboard/assets/mutation-g_fduiNw.js.gz +0 -0
  87. package/dist/dashboard/assets/{numbers-DkfMR5Qs.js → numbers-BOevJzdz.js} +1 -1
  88. package/dist/dashboard/assets/numbers-BOevJzdz.js.gz +0 -0
  89. package/dist/dashboard/assets/{query-keys-BFhGpwR1.js → query-keys-UETqfytp.js} +1 -1
  90. package/dist/dashboard/assets/query-keys-UETqfytp.js.gz +0 -0
  91. package/dist/dashboard/assets/{routing-CAb4F37R.js → routing-BPUo-W8d.js} +1 -1
  92. package/dist/dashboard/assets/routing-BPUo-W8d.js.gz +0 -0
  93. package/dist/dashboard/assets/{select-C6MMiaio.js → select-C980c4cz.js} +1 -1
  94. package/dist/dashboard/assets/select-C980c4cz.js.gz +0 -0
  95. package/dist/dashboard/assets/{settings-DNX9sydi.js → settings--3hxSyry.js} +1 -1
  96. package/dist/dashboard/assets/settings--3hxSyry.js.gz +0 -0
  97. package/dist/dashboard/assets/{stack-BHTfXdz_.js → stack-_gfTdLVB.js} +1 -1
  98. package/dist/dashboard/assets/stack-_gfTdLVB.js.gz +0 -0
  99. package/dist/dashboard/assets/{state-mPDRlQkl.js → state-2c5V4zo7.js} +1 -1
  100. package/dist/dashboard/assets/state-2c5V4zo7.js.gz +0 -0
  101. package/dist/dashboard/assets/{switch-DOmJxg7H.js → switch-Dkke7Naf.js} +1 -1
  102. package/dist/dashboard/assets/switch-Dkke7Naf.js.gz +0 -0
  103. package/dist/dashboard/assets/{useMutation-Dem0lXQj.js → useMutation-EDFRkn1i.js} +1 -1
  104. package/dist/dashboard/assets/useMutation-EDFRkn1i.js.gz +0 -0
  105. package/dist/dashboard/index.html +1 -1
  106. package/dist/dashboard/index.html.gz +0 -0
  107. package/dist/dashboard/precompressed-assets.json +51 -51
  108. package/migrations/README.md +52 -52
  109. package/migrations/full-postgres/0020_add_telemetry_event_analytics.sql +77 -0
  110. package/migrations/full-postgres/0021_add_api_key_concurrency_tolerance.sql +13 -0
  111. package/migrations/full-postgres/0022_add_inferhub_config_key.sql +17 -0
  112. package/migrations/full-postgres/0023_drop_api_key_concurrency_tolerance.sql +13 -0
  113. package/migrations/lite-sqlite3/0020_add_telemetry_event_analytics.sql +69 -0
  114. package/migrations/lite-sqlite3/0021_add_api_key_concurrency_tolerance.sql +13 -0
  115. package/migrations/lite-sqlite3/0022_add_inferhub_config_key.sql +17 -0
  116. package/migrations/lite-sqlite3/0023_drop_api_key_concurrency_tolerance.sql +13 -0
  117. package/package.json +96 -97
  118. package/scripts/cli/admin.ts +126 -125
  119. package/scripts/cli/autostart.ts +237 -237
  120. package/scripts/cli/background.ts +120 -110
  121. package/scripts/cli/bin/carte.cjs +8 -8
  122. package/scripts/cli/bin/cartethyia.cjs +167 -167
  123. package/scripts/cli/console-menu.ts +308 -275
  124. package/scripts/cli/controller.ts +145 -0
  125. package/scripts/cli/doctor.ts +1 -1
  126. package/scripts/cli/env.ts +99 -99
  127. package/scripts/cli/gateway.ts +264 -261
  128. package/scripts/{health.ts → cli/health.ts} +4 -4
  129. package/scripts/cli/instance.ts +234 -224
  130. package/scripts/cli/main.ts +188 -164
  131. package/scripts/cli/platform.ts +64 -64
  132. package/scripts/cli/prompt.ts +275 -107
  133. package/scripts/cli/services.ts +88 -88
  134. package/scripts/cli/setup.ts +445 -445
  135. package/scripts/cli/start.ts +85 -82
  136. package/scripts/cli/startup-signal.ts +23 -23
  137. package/scripts/cli/telemetry.ts +70 -0
  138. package/scripts/cli/tray/index.ts +263 -263
  139. package/scripts/cli/tray/windows.ps1 +107 -107
  140. package/scripts/cli/tray.ts +192 -208
  141. package/scripts/cli/update.ts +39 -39
  142. package/scripts/cli/version.ts +67 -67
  143. package/scripts/cli/wordmark.ts +58 -58
  144. package/src/boot/app.ts +16 -1
  145. package/src/boot/dependencies.ts +128 -25
  146. package/src/config/package-paths.ts +24 -0
  147. package/src/config/resources.ts +137 -0
  148. package/src/config/runtime.ts +133 -76
  149. package/src/config/spec.ts +30 -29
  150. package/src/config/telemetry.ts +0 -25
  151. package/src/console/api-keys/bucket-spend.ts +14 -17
  152. package/src/console/api-keys/contracts.ts +410 -410
  153. package/src/console/api-keys/routes.ts +12 -0
  154. package/src/console/audit/store.ts +2 -3
  155. package/src/console/auth/setup-service.ts +8 -1
  156. package/src/console/backup/routes.ts +7 -0
  157. package/src/console/backup/service.ts +463 -463
  158. package/src/console/backup/store.ts +16 -11
  159. package/src/console/backup/validate.ts +1 -4
  160. package/src/console/cli-tools/fs-ops.ts +18 -2
  161. package/src/console/data-management/contracts.ts +1 -8
  162. package/src/console/data-management/operations.ts +61 -48
  163. package/src/console/data-management/routes.ts +4 -2
  164. package/src/console/domain-registration.ts +25 -3
  165. package/src/console/inferhub/client.ts +139 -0
  166. package/src/console/inferhub/key-store.ts +129 -0
  167. package/src/console/inferhub/normalize.ts +347 -0
  168. package/src/console/inferhub/routes.ts +411 -0
  169. package/src/console/inferhub/store.ts +109 -0
  170. package/src/console/inferhub/types.ts +183 -0
  171. package/src/console/observability/contracts.ts +2 -41
  172. package/src/console/observability/store.ts +176 -371
  173. package/src/console/providers/catalog/public-model-store.ts +0 -0
  174. package/src/console/providers/catalog/store.ts +23 -36
  175. package/src/console/quota/account-quota-shared.ts +52 -52
  176. package/src/console/quota/account-quota-tenant.ts +16 -24
  177. package/src/console/quota/refresh.ts +441 -441
  178. package/src/console/remote-support/service.ts +270 -114
  179. package/src/console/routing/pools/import-parse.ts +206 -206
  180. package/src/console/routing/pools/operations.ts +543 -543
  181. package/src/console/routing/pools/probe-verdict.ts +132 -132
  182. package/src/console/routing/pools/routes.ts +181 -181
  183. package/src/console/routing/pools/store.ts +1 -1
  184. package/src/console/settings/contracts.ts +42 -40
  185. package/src/console/settings/store.ts +25 -25
  186. package/src/console/share/share-stats.ts +22 -4
  187. package/src/console/share/share-usage.ts +30 -4
  188. package/src/console/shared/cursor.ts +15 -0
  189. package/src/console/updates/repository-updates.ts +40 -39
  190. package/src/console/updates/routes.ts +12 -0
  191. package/src/main.ts +13 -5
  192. package/src/network/pool/resolver.ts +1104 -1104
  193. package/src/observability/client-name.ts +22 -0
  194. package/src/observability/event-log.ts +5 -29
  195. package/src/observability/event-query.ts +690 -0
  196. package/src/observability/log-ring.ts +26 -2
  197. package/src/observability/log-sanitize.ts +118 -0
  198. package/src/observability/logger.ts +17 -13
  199. package/src/observability/metrics.ts +35 -0
  200. package/src/observability/payload-capture.ts +114 -41
  201. package/src/observability/payload-store.ts +249 -24
  202. package/src/observability/raw-event-writer.ts +118 -0
  203. package/src/observability/runtime-metrics.ts +71 -1
  204. package/src/observability/telemetry-buffer.ts +100 -17
  205. package/src/observability/telemetry-reconciliation.ts +417 -0
  206. package/src/persistence/boot.ts +20 -9
  207. package/src/persistence/db-pg.ts +13 -0
  208. package/src/persistence/db-sqlite.ts +569 -497
  209. package/src/persistence/dialect.ts +3 -2
  210. package/src/persistence/migrate.ts +181 -181
  211. package/src/persistence/schema.ts +1496 -1406
  212. package/src/persistence/shared-cache.ts +5 -1
  213. package/src/persistence/stores/api-key-store.ts +581 -581
  214. package/src/persistence/stores/telemetry-store.ts +124 -63
  215. package/src/protocol/reasoning-text.ts +30 -0
  216. package/src/protocol/registry.ts +2 -0
  217. package/src/protocol/response/chat.ts +33 -7
  218. package/src/protocol/response/codex.ts +14 -2
  219. package/src/protocol/response/messages.ts +5 -2
  220. package/src/providers/authentication/oauth-flow-store.ts +3 -10
  221. package/src/providers/bundled-discovery-providers.ts +122 -122
  222. package/src/providers/default-registry.ts +14 -0
  223. package/src/providers/discovery/catalog/catalog-blob.ts +18 -18
  224. package/src/providers/discovery/models-dev-catalog.ts +354 -354
  225. package/src/providers/discovery/probe-phases.ts +43 -7
  226. package/src/providers/discovery/probing-service.ts +70 -32
  227. package/src/providers/integrations/antigravity/antigravity.ts +9 -2
  228. package/src/providers/integrations/buddy/buddy-catalog-shared.ts +85 -17
  229. package/src/providers/integrations/buddy/buddy-chat-shared.ts +934 -934
  230. package/src/providers/integrations/buddy/buddy-checkin.ts +5 -9
  231. package/src/providers/integrations/buddy/buddy-discovery-shared.ts +3 -7
  232. package/src/providers/integrations/buddy/buddy-quota-shared.ts +3 -1
  233. package/src/providers/integrations/buddy/capabilities.ts +4 -0
  234. package/src/providers/integrations/buddy/codebuddy/cn-provider.ts +57 -49
  235. package/src/providers/integrations/buddy/codebuddy/provider.ts +180 -180
  236. package/src/providers/integrations/buddy/workbuddy/provider.ts +206 -206
  237. package/src/providers/integrations/buddy/workbuddy/quota.ts +161 -161
  238. package/src/providers/integrations/claude/claude-quota.ts +2 -2
  239. package/src/providers/integrations/claude/claude-user-profiles.ts +3 -2
  240. package/src/providers/integrations/claude/claude.ts +3 -4
  241. package/src/providers/integrations/cline/cline.ts +94 -8
  242. package/src/providers/integrations/codex/codex.ts +61 -37
  243. package/src/providers/integrations/cursor/cursor-catalog.ts +368 -368
  244. package/src/providers/integrations/cursor/cursor-http2.ts +300 -300
  245. package/src/providers/integrations/cursor/cursor-identity.ts +2 -4
  246. package/src/providers/integrations/cursor/cursor-request.ts +11 -0
  247. package/src/providers/integrations/cursor/cursor.ts +29 -17
  248. package/src/providers/integrations/cursor/protocol/framing.ts +129 -129
  249. package/src/providers/integrations/cursor/protocol/messages.ts +2380 -2380
  250. package/src/providers/integrations/cursor/protocol/struct.ts +305 -305
  251. package/src/providers/integrations/cursor/protocol/tool-canonical.ts +275 -0
  252. package/src/providers/integrations/github/github.ts +3 -1
  253. package/src/providers/integrations/grok/grok-quota.ts +279 -279
  254. package/src/providers/integrations/grok/grok-ratelimit-store.ts +73 -73
  255. package/src/providers/integrations/grok/grok-ratelimit.ts +218 -218
  256. package/src/providers/integrations/hermes.ts +7 -1
  257. package/src/providers/integrations/inferhub.ts +201 -73
  258. package/src/providers/integrations/kilo/kilo.ts +152 -152
  259. package/src/providers/integrations/kiro/kiro-machine-id.ts +2 -4
  260. package/src/providers/integrations/opencode/provider.ts +2 -0
  261. package/src/providers/integrations/openrouter/provider.ts +129 -129
  262. package/src/providers/integrations/qoder/qoder-quota.ts +193 -192
  263. package/src/providers/integrations/qoder/qoder-realm.ts +106 -106
  264. package/src/providers/integrations/trae/trae-headers.ts +17 -48
  265. package/src/providers/integrations/trae/trae-oauth.ts +4 -26
  266. package/src/providers/integrations/trae/trae-quota.ts +389 -384
  267. package/src/providers/integrations/trae/trae-realm.ts +15 -2
  268. package/src/providers/integrations/trae/trae.ts +22 -24
  269. package/src/providers/integrations/xiaomi-mimo/think-stream.ts +100 -24
  270. package/src/providers/integrations/zcode/oauth.ts +1 -1
  271. package/src/providers/operations/account-health-service.ts +605 -279
  272. package/src/providers/provider-metadata.ts +12 -1
  273. package/src/providers/provider-model-definition.ts +373 -373
  274. package/src/providers/provider-probe.ts +3 -0
  275. package/src/providers/quota/quota-contracts.ts +3 -7
  276. package/src/providers/search/search-provider.ts +2 -6
  277. package/src/security/admission/contracts.ts +10 -0
  278. package/src/security/admission/in-memory-store.ts +89 -14
  279. package/src/security/admission/lua.ts +191 -182
  280. package/src/security/admission/redis-store.ts +30 -0
  281. package/src/security/admission/service.ts +18 -0
  282. package/src/security/api-key-auth-cache.ts +96 -0
  283. package/src/security/api-key-auth.ts +11 -100
  284. package/src/security/client-router-fingerprint.ts +3 -7
  285. package/src/security/ip-boundary.ts +7 -3
  286. package/src/shared/coerce.ts +67 -0
  287. package/src/shared/hash.ts +6 -0
  288. package/src/shared/time.ts +7 -0
  289. package/src/transport/dispatch/attempt-finalize.ts +26 -138
  290. package/src/transport/dispatch/attempt-loop.ts +103 -81
  291. package/src/transport/dispatch/capture-policy.ts +78 -0
  292. package/src/transport/dispatch/proxy-request.ts +4 -2
  293. package/src/transport/dispatch/streaming/encoder.ts +111 -5
  294. package/src/transport/dispatch/streaming/stream.ts +19 -2
  295. package/src/transport/dispatch/streaming/thinking-loop.ts +300 -0
  296. package/src/transport/failure-policy.ts +27 -0
  297. package/src/transport/guards/abuse-guard.ts +2 -2
  298. package/src/transport/guards/auth-guard.ts +17 -0
  299. package/src/transport/guards/body-policy.ts +3 -2
  300. package/src/transport/guards/concurrency-guard.ts +89 -13
  301. package/src/transport/guards/early-rejection-capture.ts +43 -3
  302. package/src/transport/guards/error-lifecycle.ts +10 -2
  303. package/src/transport/guards/guard-errors.ts +23 -0
  304. package/src/transport/guards/headroom-guard.ts +41 -7
  305. package/src/transport/guards/pipeline.ts +24 -0
  306. package/src/transport/guards/request-context.ts +2 -1
  307. package/src/transport/routing/admission.ts +51 -9
  308. package/src/transport/routing/bounded-lru.ts +49 -0
  309. package/src/transport/routing/candidate-index.ts +88 -0
  310. package/src/transport/routing/reservations.ts +121 -7
  311. package/src/transport/routing/round-robin.ts +10 -22
  312. package/src/transport/routing/route-catalog.ts +26 -13
  313. package/src/transport/routing/route-model.ts +22 -2
  314. package/src/transport/routing/router.ts +53 -31
  315. package/src/transport/surface/chat/encode.ts +2 -3
  316. package/src/transport/surface/chat/parse.ts +2 -1
  317. package/src/transport/surface/dialects.ts +11 -3
  318. package/src/transport/surface/messages/encode.ts +2 -1
  319. package/src/transport/surface/messages/parse.ts +3 -1
  320. package/src/transport/surface/messages/stream.ts +2 -6
  321. package/src/workers/maintenance-lease.ts +200 -0
  322. package/src/workers/scheduler.ts +101 -1
  323. package/dist/dashboard/assets/BannedPage-Brvuj_Eb.js.gz +0 -0
  324. package/dist/dashboard/assets/CliToolDetailPage-CqfXtI3y.js.gz +0 -0
  325. package/dist/dashboard/assets/CliToolsPage-GQeS91bg.js.gz +0 -0
  326. package/dist/dashboard/assets/CombosPage-BDRsTXBB.js.gz +0 -0
  327. package/dist/dashboard/assets/ConsoleLogPage-zP8AFHX0.js.gz +0 -0
  328. package/dist/dashboard/assets/CustomizationPage-BmfrNZ14.js.gz +0 -0
  329. package/dist/dashboard/assets/HealthEventsModal-Dn3M6WZc.js +0 -1
  330. package/dist/dashboard/assets/HealthEventsModal-Dn3M6WZc.js.gz +0 -0
  331. package/dist/dashboard/assets/LoginPage-DoreIgm8.js.gz +0 -0
  332. package/dist/dashboard/assets/ModelPicker-C7pDb41R.js.gz +0 -0
  333. package/dist/dashboard/assets/OverviewPage-CvvD1Ugk.js +0 -1
  334. package/dist/dashboard/assets/OverviewPage-CvvD1Ugk.js.gz +0 -0
  335. package/dist/dashboard/assets/ProviderDetailPage-Clbn1gsW.js +0 -3
  336. package/dist/dashboard/assets/ProviderDetailPage-Clbn1gsW.js.gz +0 -0
  337. package/dist/dashboard/assets/ProvidersPage-BnoPgA7w.js.gz +0 -0
  338. package/dist/dashboard/assets/ProxyPage-Bq_TGgdb.js.gz +0 -0
  339. package/dist/dashboard/assets/QuotaPage-D9DHXvom.js +0 -1
  340. package/dist/dashboard/assets/QuotaPage-D9DHXvom.js.gz +0 -0
  341. package/dist/dashboard/assets/SettingsPage-7xV8WkWL.js +0 -1
  342. package/dist/dashboard/assets/SettingsPage-7xV8WkWL.js.gz +0 -0
  343. package/dist/dashboard/assets/SetupPage-Day3mCOn.js.gz +0 -0
  344. package/dist/dashboard/assets/SortableList-DQxllnB5.js.gz +0 -0
  345. package/dist/dashboard/assets/UsagePage-DXa1oR-4.js.gz +0 -0
  346. package/dist/dashboard/assets/api-keys-DQYLQxpW.js.gz +0 -0
  347. package/dist/dashboard/assets/app-CX_Z8cMs.css +0 -2
  348. package/dist/dashboard/assets/app-CX_Z8cMs.css.gz +0 -0
  349. package/dist/dashboard/assets/assert-C9FD_JwJ.js.gz +0 -0
  350. package/dist/dashboard/assets/badge-CR72dasT.js.gz +0 -0
  351. package/dist/dashboard/assets/banner-CI9_eSfO.js.gz +0 -0
  352. package/dist/dashboard/assets/card-DS7CQEsO.js.gz +0 -0
  353. package/dist/dashboard/assets/cli-tools-CNqzrfdb.js.gz +0 -0
  354. package/dist/dashboard/assets/clipboard-button-611pSsaX.js.gz +0 -0
  355. package/dist/dashboard/assets/dialog-k37exjNt.js.gz +0 -0
  356. package/dist/dashboard/assets/drawer-CX7N8ucR.js.gz +0 -0
  357. package/dist/dashboard/assets/entry-B9Xvtnfj.js +0 -1
  358. package/dist/dashboard/assets/entry-B9Xvtnfj.js.gz +0 -0
  359. package/dist/dashboard/assets/entry-ByMGkR3C.js +0 -4
  360. package/dist/dashboard/assets/entry-ByMGkR3C.js.gz +0 -0
  361. package/dist/dashboard/assets/entry-CTP8AyiS.js.gz +0 -0
  362. package/dist/dashboard/assets/github-badge-sltk1qUi.js.gz +0 -0
  363. package/dist/dashboard/assets/index-z3zDOSMM.js.gz +0 -0
  364. package/dist/dashboard/assets/inline-BhY2sSnO.js.gz +0 -0
  365. package/dist/dashboard/assets/input-V1umFiBk.js.gz +0 -0
  366. package/dist/dashboard/assets/layout-BBOYycyS.js.gz +0 -0
  367. package/dist/dashboard/assets/list-row-1zzZYM0B.js.gz +0 -0
  368. package/dist/dashboard/assets/live-BivGfuUs.js.gz +0 -0
  369. package/dist/dashboard/assets/model-bans-CmffjLuG.js.gz +0 -0
  370. package/dist/dashboard/assets/mutation-46P0oWMe.js.gz +0 -0
  371. package/dist/dashboard/assets/numbers-DkfMR5Qs.js.gz +0 -0
  372. package/dist/dashboard/assets/query-keys-BFhGpwR1.js.gz +0 -0
  373. package/dist/dashboard/assets/routing-CAb4F37R.js.gz +0 -0
  374. package/dist/dashboard/assets/select-C6MMiaio.js.gz +0 -0
  375. package/dist/dashboard/assets/settings-DNX9sydi.js.gz +0 -0
  376. package/dist/dashboard/assets/stack-BHTfXdz_.js.gz +0 -0
  377. package/dist/dashboard/assets/state-mPDRlQkl.js.gz +0 -0
  378. package/dist/dashboard/assets/switch-DOmJxg7H.js.gz +0 -0
  379. package/dist/dashboard/assets/useMutation-Dem0lXQj.js.gz +0 -0
  380. package/scripts/build/aot.ts +0 -143
  381. package/scripts/build/build.ts +0 -24
  382. package/scripts/build/compile.ts +0 -88
  383. package/scripts/build/restart.ts +0 -120
  384. package/scripts/build/start.ts +0 -25
  385. package/src/observability/event-compaction.ts +0 -317
  386. package/src/observability/event-day-index.ts +0 -440
@@ -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
+ }
@@ -7,10 +7,9 @@ import type { UsageRecord } from "../../protocol/canonical-model";
7
7
  import { classifyUpstreamFailure } from "../failure-policy";
8
8
  import type { AdmissionLease } from "../../security/admission/contracts";
9
9
  import type { CartethyiaDatabase } from "../../persistence/db-handle";
10
- import { TelemetryPayloadCapture } from "../../observability/payload-capture";
10
+ import { resolvePayloadCapture, TelemetryPayloadCapture, truncateUtf8 } from "../../observability/payload-capture";
11
11
  import { payloadDirectory } from "../../observability/payload-store";
12
12
  import type { TelemetryBatchBuffer } from "../../observability/telemetry-buffer";
13
- import type { TelemetryPayloadDepth } from "../../console/settings/contracts";
14
13
  import type { ProxyRequestOutcome, ProxyRequestState } from "../request/state";
15
14
  import type { PayloadCaptureState } from "../../observability/payload-capture";
16
15
  import { finalizeRequestTelemetry } from "../guards/error-lifecycle";
@@ -22,126 +21,7 @@ import {
22
21
  recordPoolDispatchOutcome,
23
22
  } from "../../network/pool-health-machine";
24
23
  import { ProxyConnectRefusalError } from "../../network/pool/agent";
25
- import {
26
- payloadCaptureKeepsBodies,
27
- payloadRetentionMs,
28
- resolvePayloadCapture,
29
- } from "../../observability/payload-capture";
30
-
31
- /**
32
- * True when the tenant keeps response bodies (mode `full`). The dispatch
33
- * path calls this once per attempt to decide whether the provider-response
34
- * tee is worth its clone + bounded read; `none`/`metadata` skip the body.
35
- */
36
- export async function shouldCaptureProviderBody(
37
- db: CartethyiaDatabase,
38
- tenantId: string | null,
39
- ): Promise<boolean> {
40
- return payloadCaptureKeepsBodies(db, tenantId);
41
- }
42
-
43
- /**
44
- * Per-stage cap for the provider request/response tee at one capture depth.
45
- *
46
- * The tee is what the drawer's "Provider Request" and "Provider Response"
47
- * panels read, so its cap is the most a provider panel can ever show. It used
48
- * to be a flat 256 KiB at every depth: on a large request the translated
49
- * provider body was clipped to 256 KiB before the shared record budget ever
50
- * ran, so a tenant who raised the depth to Maximum — 16 MiB — still saw a
51
- * quarter-megabyte provider request and an empty provider response. Derived
52
- * from the same depth tiers as the record budget, so raising the depth now
53
- * raises both.
54
- *
55
- * Capped at a quarter of the depth's total budget: four panels share it, and
56
- * one of them must never be able to spend the whole thing.
57
- */
58
- export function providerCaptureMaxBytes(depth?: TelemetryPayloadDepth): number {
59
- if (depth === "moderate") return 2 * 1024 * 1024;
60
- if (depth === "maximum") return 4 * 1024 * 1024;
61
- return 256 * 1024;
62
- }
63
-
64
- /**
65
- * Same answer as {@link shouldCaptureProviderBody}, memoized on the request.
66
- *
67
- * The preference is a tenant setting, so it cannot change between attempts of
68
- * one request. Resolving it per attempt meant a request with several attempts
69
- * paid for it repeatedly, and the per-attempt answer could in principle differ
70
- * from the one the terminal capture then acted on. Passing `state` lets every
71
- * attempt of a request share one resolution.
72
- */
73
- export async function shouldCaptureProviderBodyForRequest(
74
- db: CartethyiaDatabase,
75
- tenantId: string | null,
76
- state: { payloadCapturePreference?: unknown },
77
- ): Promise<boolean> {
78
- if (state.payloadCapturePreference === undefined) {
79
- state.payloadCapturePreference = await resolvePayloadCapture(db, tenantId);
80
- }
81
- const resolved = state.payloadCapturePreference as
82
- | { readonly ok: true; readonly mode: string }
83
- | { readonly ok: false };
84
- return resolved.ok === true && resolved.mode === "full";
85
- }
86
-
87
- /**
88
- * The tenant's capture depth, memoized on the request like the mode above.
89
- *
90
- * Read *after* {@link shouldCaptureProviderBodyForRequest} has populated the
91
- * memo, so the two answers come from one preference read. It sizes the
92
- * provider tee: a tenant who raised the depth is asking for more of every
93
- * panel, and a flat cap would answer with the same clipped body regardless.
94
- */
95
- export async function resolveCaptureDepthForRequest(
96
- db: CartethyiaDatabase,
97
- tenantId: string | null,
98
- state: { payloadCapturePreference?: unknown },
99
- ): Promise<TelemetryPayloadDepth> {
100
- if (state.payloadCapturePreference === undefined) {
101
- state.payloadCapturePreference = await resolvePayloadCapture(db, tenantId);
102
- }
103
- const resolved = state.payloadCapturePreference as
104
- | { readonly ok: true; readonly depth: TelemetryPayloadDepth }
105
- | { readonly ok: false };
106
- return resolved.ok === true ? resolved.depth : "minimum";
107
- }
108
-
109
- /**
110
- * The tenant's chosen payload retention, memoized on the request like the mode
111
- * and depth above — and resolved *after* one of them has populated the memo, so
112
- * all three come from one preference read.
113
- *
114
- * Falls back to the deployment default when the read failed, which is the same
115
- * answer the capture path reaches on its own; returning it here keeps the
116
- * caller from having to know the fallback exists.
117
- */
118
- export async function resolveCaptureRetentionForRequest(
119
- db: CartethyiaDatabase,
120
- tenantId: string | null,
121
- state: { payloadCapturePreference?: unknown },
122
- ): Promise<number> {
123
- if (state.payloadCapturePreference === undefined) {
124
- state.payloadCapturePreference = await resolvePayloadCapture(db, tenantId);
125
- }
126
- const resolved = state.payloadCapturePreference as
127
- | { readonly ok: true; readonly retentionMs: number }
128
- | { readonly ok: false };
129
- return resolved.ok === true ? resolved.retentionMs : payloadRetentionMs();
130
- }
131
-
132
- /**
133
- * Combined body cap for one capture depth. Fixed size tiers: every depth
134
- * stores all four drawer panels (client request, translated provider
135
- * request, raw provider response, final client response) — only the cap
136
- * changes, so raising the depth never changes *what* is visible, only how
137
- * much of large bodies survives. Moderate and maximum spike CPU/memory while
138
- * active; minimum is light enough to leave on.
139
- */
140
- function captureDepthMaxBytes(depth: TelemetryPayloadDepth): number {
141
- if (depth === "moderate") return 8 * 1024 * 1024;
142
- if (depth === "maximum") return 16 * 1024 * 1024;
143
- return 1 * 1024 * 1024;
144
- }
24
+ import { captureDepthMaxBytes, providerCaptureMaxBytes } from "./capture-policy";
145
25
 
146
26
  /**
147
27
  * What the client received for a failed terminal attempt, in the same public
@@ -186,21 +66,24 @@ async function readBoundedBody(response: Response, maxBytes: number): Promise<st
186
66
  const reader = response.body.getReader();
187
67
  const decoder = new TextDecoder();
188
68
  // Collect decoded chunks and join once: `+=` per chunk reallocates the
189
- // prefix (O(n^2)). Truncation stays at the same decoded-char boundary.
69
+ // prefix (O(n^2)). The budget is UTF-8 **bytes** — the same basis the record
70
+ // budget and the request-line slice use — measured per decoded chunk, with
71
+ // the final chunk trimmed by truncateUtf8 so a code point is never split.
190
72
  const parts: string[] = [];
191
- let total = 0;
73
+ let totalBytes = 0;
192
74
  try {
193
- while (total < maxBytes) {
75
+ while (totalBytes < maxBytes) {
194
76
  const { done, value } = await reader.read();
195
77
  if (done) break;
196
78
  const chunk = decoder.decode(value, { stream: true });
197
- if (total + chunk.length > maxBytes) {
198
- parts.push(chunk.slice(0, maxBytes - total));
199
- total = maxBytes;
79
+ const chunkBytes = Buffer.byteLength(chunk, "utf8");
80
+ if (totalBytes + chunkBytes > maxBytes) {
81
+ parts.push(truncateUtf8(chunk, maxBytes - totalBytes));
82
+ totalBytes = maxBytes;
200
83
  break;
201
84
  }
202
85
  parts.push(chunk);
203
- total += chunk.length;
86
+ totalBytes += chunkBytes;
204
87
  }
205
88
  } catch {
206
89
  // Best-effort capture must never fail the request path.
@@ -339,11 +222,16 @@ export function captureProviderExchange(
339
222
  method: init?.method ?? "GET",
340
223
  url,
341
224
  ...(headers === undefined ? {} : { headers }),
342
- // Capped the same way the response is. A capture is a diagnostic, and an
343
- // unbounded translated request body per attempt — up to `maxAttempts` of
344
- // them, all alive until the loop returns — costs more than it can tell
345
- // an operator.
346
- ...(rawBody === null ? {} : { body: parseCapturedBody(rawBody.slice(0, maxBytes)) }),
225
+ // `captureBody=false` is the `none`/`metadata` path: record that a body
226
+ // was sent and how big it was, without slicing or parsing it. The old
227
+ // shape sliced up to `maxBytes` of the translated body and ran it through
228
+ // `JSON.parse` for every attempt — content a metadata-mode tenant had
229
+ // explicitly declined to retain, paid on every dispatch.
230
+ ...(rawBody === null
231
+ ? {}
232
+ : captureBody
233
+ ? { body: parseCapturedBody(truncateUtf8(rawBody, maxBytes)) }
234
+ : { body_present: true, body_bytes: Buffer.byteLength(rawBody, "utf8") }),
347
235
  };
348
236
  const response = await inner(input, init);
349
237
  sink.response =
@@ -426,7 +314,7 @@ async function captureTerminalPayload(
426
314
  note("captured_metadata");
427
315
  return;
428
316
  }
429
- await new TelemetryPayloadCapture(db).capture({
317
+ const metadataCapture = await new TelemetryPayloadCapture(db).capture({
430
318
  tenantId,
431
319
  requestId,
432
320
  requestBody: null,
@@ -436,13 +324,13 @@ async function captureTerminalPayload(
436
324
  scope: "tenant",
437
325
  tenantOptIn: true,
438
326
  });
439
- note("captured_metadata");
327
+ note(metadataCapture.payloadCaptureState ?? "captured_metadata");
440
328
  return;
441
329
  }
442
330
  const maxBytes = captureDepthMaxBytes(depth);
443
331
  const providerRequest = providerCapture?.request ?? null;
444
332
  const providerResponse = await resolvedProviderResponse(providerCapture);
445
- await new TelemetryPayloadCapture(db).capture({
333
+ const terminalCapture = await new TelemetryPayloadCapture(db).capture({
446
334
  tenantId,
447
335
  requestId,
448
336
  requestBody: requestBody ?? null,
@@ -455,7 +343,7 @@ async function captureTerminalPayload(
455
343
  scope: "tenant",
456
344
  tenantOptIn: true,
457
345
  });
458
- note("captured");
346
+ note(terminalCapture.payloadCaptureState ?? "captured");
459
347
  } catch (error) {
460
348
  note("failed_write");
461
349
  reportCaptureFailure(error);