cartethyia 2.5.3 → 2.5.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (456) hide show
  1. package/.env.example +13 -4
  2. package/README.md +2 -2
  3. package/dist/dashboard/assets/{BannedPage-hfCzi-Ux.js → BannedPage-CADDq4IU.js} +1 -1
  4. package/dist/dashboard/assets/BannedPage-CADDq4IU.js.gz +0 -0
  5. package/dist/dashboard/assets/CliToolDetailPage-_z1hID7E.js +1 -0
  6. package/dist/dashboard/assets/CliToolDetailPage-_z1hID7E.js.gz +0 -0
  7. package/dist/dashboard/assets/{CliToolsPage-DEDyKBlC.js → CliToolsPage-DuO1Z7dq.js} +1 -1
  8. package/dist/dashboard/assets/CliToolsPage-DuO1Z7dq.js.gz +0 -0
  9. package/dist/dashboard/assets/CombosPage-DNEPi3xU.js +1 -0
  10. package/dist/dashboard/assets/CombosPage-DNEPi3xU.js.gz +0 -0
  11. package/dist/dashboard/assets/{ConsoleLogPage-bsLuZmYH.js → ConsoleLogPage-BVdpfiz1.js} +1 -1
  12. package/dist/dashboard/assets/ConsoleLogPage-BVdpfiz1.js.gz +0 -0
  13. package/dist/dashboard/assets/{CustomizationPage-_UWhwziJ.js → CustomizationPage-BR5HHvbi.js} +1 -1
  14. package/dist/dashboard/assets/CustomizationPage-BR5HHvbi.js.gz +0 -0
  15. package/dist/dashboard/assets/HealthEventsModal-mUfcknqm.js +1 -0
  16. package/dist/dashboard/assets/HealthEventsModal-mUfcknqm.js.gz +0 -0
  17. package/dist/dashboard/assets/{LoginPage-DDVIfZGW.js → LoginPage-V5faV9V2.js} +1 -1
  18. package/dist/dashboard/assets/LoginPage-V5faV9V2.js.gz +0 -0
  19. package/dist/dashboard/assets/ModelPicker-C7pDb41R.js +1 -0
  20. package/dist/dashboard/assets/ModelPicker-C7pDb41R.js.gz +0 -0
  21. package/dist/dashboard/assets/OverviewPage-BDu20Jdx.js +1 -0
  22. package/dist/dashboard/assets/OverviewPage-BDu20Jdx.js.gz +0 -0
  23. package/dist/dashboard/assets/ProviderDetailPage-Dllr_aFO.js +3 -0
  24. package/dist/dashboard/assets/ProviderDetailPage-Dllr_aFO.js.gz +0 -0
  25. package/dist/dashboard/assets/ProvidersPage-uPiGVTTK.js +1 -0
  26. package/dist/dashboard/assets/ProvidersPage-uPiGVTTK.js.gz +0 -0
  27. package/dist/dashboard/assets/ProxyPage-BaUkO-4O.js +7 -0
  28. package/dist/dashboard/assets/ProxyPage-BaUkO-4O.js.gz +0 -0
  29. package/dist/dashboard/assets/QuotaPage-o0IFAsFb.js +1 -0
  30. package/dist/dashboard/assets/QuotaPage-o0IFAsFb.js.gz +0 -0
  31. package/dist/dashboard/assets/SettingsPage-CYWqbXjp.js +1 -0
  32. package/dist/dashboard/assets/SettingsPage-CYWqbXjp.js.gz +0 -0
  33. package/dist/dashboard/assets/{SetupPage-D6pmQ86L.js → SetupPage-UB690SE2.js} +1 -1
  34. package/dist/dashboard/assets/SetupPage-UB690SE2.js.gz +0 -0
  35. package/dist/dashboard/assets/{SortableList-DWvKiuSl.js → SortableList-BLujdX7N.js} +1 -1
  36. package/dist/dashboard/assets/SortableList-BLujdX7N.js.gz +0 -0
  37. package/dist/dashboard/assets/{StudioPage-CmkUrgdj.js → StudioPage-D5C2raAc.js} +3 -3
  38. package/dist/dashboard/assets/StudioPage-D5C2raAc.js.gz +0 -0
  39. package/dist/dashboard/assets/UsagePage-8uIcxa1W.js +56 -0
  40. package/dist/dashboard/assets/UsagePage-8uIcxa1W.js.gz +0 -0
  41. package/dist/dashboard/assets/api-keys-DQYLQxpW.js +1 -0
  42. package/dist/dashboard/assets/api-keys-DQYLQxpW.js.gz +0 -0
  43. package/dist/dashboard/assets/app-CX_Z8cMs.css +2 -0
  44. package/dist/dashboard/assets/app-CX_Z8cMs.css.gz +0 -0
  45. package/dist/dashboard/assets/{assert-NHwYq95t.js → assert-C9FD_JwJ.js} +1 -1
  46. package/dist/dashboard/assets/assert-C9FD_JwJ.js.gz +0 -0
  47. package/dist/dashboard/assets/{badge-6uzDDvqs.js → badge-CR72dasT.js} +1 -1
  48. package/dist/dashboard/assets/badge-CR72dasT.js.gz +0 -0
  49. package/dist/dashboard/assets/{banner-4_WNg2WZ.js → banner-CI9_eSfO.js} +1 -1
  50. package/dist/dashboard/assets/banner-CI9_eSfO.js.gz +0 -0
  51. package/dist/dashboard/assets/{card--AtFedAq.js → card-DS7CQEsO.js} +1 -1
  52. package/dist/dashboard/assets/card-DS7CQEsO.js.gz +0 -0
  53. package/dist/dashboard/assets/{cli-tools-B3e7dx3N.js → cli-tools-CNqzrfdb.js} +1 -1
  54. package/dist/dashboard/assets/cli-tools-CNqzrfdb.js.gz +0 -0
  55. package/dist/dashboard/assets/{clipboard-button-NreUx6c8.js → clipboard-button-611pSsaX.js} +1 -1
  56. package/dist/dashboard/assets/clipboard-button-611pSsaX.js.gz +0 -0
  57. package/dist/dashboard/assets/{fingerprint-pattern-BWuHtbW4.js → concurrency-BDF0ok7y.js} +1 -1
  58. package/dist/dashboard/assets/concurrency-BDF0ok7y.js.gz +0 -0
  59. package/dist/dashboard/assets/database-Dz9N3zGd.js +1 -0
  60. package/dist/dashboard/assets/database-Dz9N3zGd.js.gz +0 -0
  61. package/dist/dashboard/assets/dialog-k37exjNt.js +1 -0
  62. package/dist/dashboard/assets/dialog-k37exjNt.js.gz +0 -0
  63. package/dist/dashboard/assets/drawer-CX7N8ucR.js +1 -0
  64. package/dist/dashboard/assets/drawer-CX7N8ucR.js.gz +0 -0
  65. package/dist/dashboard/assets/entry-B9Xvtnfj.js +1 -0
  66. package/dist/dashboard/assets/entry-B9Xvtnfj.js.gz +0 -0
  67. package/dist/dashboard/assets/entry-CTP8AyiS.js +1 -0
  68. package/dist/dashboard/assets/entry-CTP8AyiS.js.gz +0 -0
  69. package/dist/dashboard/assets/entry-DxBHnauy.js +4 -0
  70. package/dist/dashboard/assets/entry-DxBHnauy.js.gz +0 -0
  71. package/dist/dashboard/assets/{github-badge-D_eM3IgG.js → github-badge-sltk1qUi.js} +1 -1
  72. package/dist/dashboard/assets/github-badge-sltk1qUi.js.gz +0 -0
  73. package/dist/dashboard/assets/{index-BydyKaDz.js → index-CmxZr14F.js} +2 -2
  74. package/dist/dashboard/assets/index-CmxZr14F.js.gz +0 -0
  75. package/dist/dashboard/assets/{inline-BcNGF8Gr.js → inline-BhY2sSnO.js} +1 -1
  76. package/dist/dashboard/assets/inline-BhY2sSnO.js.gz +0 -0
  77. package/dist/dashboard/assets/{input-BG41et9G.js → input-V1umFiBk.js} +1 -1
  78. package/dist/dashboard/assets/input-V1umFiBk.js.gz +0 -0
  79. package/dist/dashboard/assets/layout-BBOYycyS.js +1 -0
  80. package/dist/dashboard/assets/layout-BBOYycyS.js.gz +0 -0
  81. package/dist/dashboard/assets/{list-row-Gh_W4OR_.js → list-row-1zzZYM0B.js} +1 -1
  82. package/dist/dashboard/assets/list-row-1zzZYM0B.js.gz +0 -0
  83. package/dist/dashboard/assets/{live-BJGYRBnR.js → live-BivGfuUs.js} +1 -1
  84. package/dist/dashboard/assets/live-BivGfuUs.js.gz +0 -0
  85. package/dist/dashboard/assets/model-bans-CmffjLuG.js +1 -0
  86. package/dist/dashboard/assets/model-bans-CmffjLuG.js.gz +0 -0
  87. package/dist/dashboard/assets/{mutation-BkoLM5Ba.js → mutation-46P0oWMe.js} +1 -1
  88. package/dist/dashboard/assets/mutation-46P0oWMe.js.gz +0 -0
  89. package/dist/dashboard/assets/numbers-DkfMR5Qs.js +2 -0
  90. package/dist/dashboard/assets/numbers-DkfMR5Qs.js.gz +0 -0
  91. package/dist/dashboard/assets/{query-keys-D7wX2vM5.js → query-keys-BFhGpwR1.js} +1 -1
  92. package/dist/dashboard/assets/query-keys-BFhGpwR1.js.gz +0 -0
  93. package/dist/dashboard/assets/{routing-CcusqXA7.js → routing-CAb4F37R.js} +1 -1
  94. package/dist/dashboard/assets/routing-CAb4F37R.js.gz +0 -0
  95. package/dist/dashboard/assets/{select-BO6h8yNv.js → select-BYR_5K2c.js} +2 -2
  96. package/dist/dashboard/assets/select-BYR_5K2c.js.gz +0 -0
  97. package/dist/dashboard/assets/{settings-mpmXTgUa.js → settings-DNX9sydi.js} +1 -1
  98. package/dist/dashboard/assets/settings-DNX9sydi.js.gz +0 -0
  99. package/dist/dashboard/assets/{stack-DwLmjUFK.js → stack-BHTfXdz_.js} +1 -1
  100. package/dist/dashboard/assets/stack-BHTfXdz_.js.gz +0 -0
  101. package/dist/dashboard/assets/{state-DYdQmdAl.js → state-mPDRlQkl.js} +1 -1
  102. package/dist/dashboard/assets/state-mPDRlQkl.js.gz +0 -0
  103. package/dist/dashboard/assets/switch-DOmJxg7H.js +1 -0
  104. package/dist/dashboard/assets/switch-DOmJxg7H.js.gz +0 -0
  105. package/dist/dashboard/assets/terminal-0_IjPWTk.js +1 -0
  106. package/dist/dashboard/assets/terminal-0_IjPWTk.js.gz +0 -0
  107. package/dist/dashboard/assets/tokens-COzario9.js +1 -0
  108. package/dist/dashboard/assets/tokens-COzario9.js.gz +0 -0
  109. package/dist/dashboard/assets/use-modal-focus-By7oJoGd.js +1 -0
  110. package/dist/dashboard/assets/use-modal-focus-By7oJoGd.js.gz +0 -0
  111. package/dist/dashboard/assets/{useMutation-w4u4CVue.js → useMutation-Dem0lXQj.js} +1 -1
  112. package/dist/dashboard/assets/useMutation-Dem0lXQj.js.gz +0 -0
  113. package/dist/dashboard/assets/zap-C4EeVW4q.js +1 -0
  114. package/dist/dashboard/assets/zap-C4EeVW4q.js.gz +0 -0
  115. package/dist/dashboard/donation/qris-risun-marke.png +0 -0
  116. package/dist/dashboard/index.html +1 -1
  117. package/dist/dashboard/index.html.gz +0 -0
  118. package/dist/dashboard/precompressed-assets.json +57 -57
  119. package/migrations/README.md +52 -52
  120. package/migrations/lite-sqlite3/0000_baseline.sql +2 -2
  121. package/package.json +96 -95
  122. package/scripts/cli/admin.ts +125 -0
  123. package/scripts/cli/autostart.ts +237 -0
  124. package/scripts/cli/{ui/background.ts → background.ts} +1 -1
  125. package/scripts/cli/{command/console-menu.ts → console-menu.ts} +275 -248
  126. package/scripts/cli/doctor.ts +37 -0
  127. package/scripts/cli/{support/env.ts → env.ts} +99 -99
  128. package/scripts/cli/{gateway/spawn.ts → gateway.ts} +112 -24
  129. package/scripts/cli/instance.ts +224 -0
  130. package/scripts/cli/main.ts +88 -18
  131. package/scripts/cli/{support/platform.ts → platform.ts} +64 -64
  132. package/scripts/cli/port.ts +100 -0
  133. package/scripts/cli/{support/services.ts → services.ts} +88 -88
  134. package/scripts/cli/{command/setup.ts → setup.ts} +35 -34
  135. package/scripts/cli/{command/start.ts → start.ts} +10 -5
  136. package/scripts/cli/{support/startup-signal.ts → startup-signal.ts} +23 -23
  137. package/scripts/cli/tray/index.ts +62 -12
  138. package/scripts/cli/{command/tray.ts → tray.ts} +208 -208
  139. package/scripts/cli/update.ts +40 -0
  140. package/scripts/cli/version.ts +67 -0
  141. package/scripts/cli/{ui/wordmark.ts → wordmark.ts} +58 -58
  142. package/src/boot/app.ts +11 -7
  143. package/src/boot/dependencies.ts +50 -19
  144. package/src/boot/timeout.ts +2 -13
  145. package/src/config/cooldowns.ts +186 -138
  146. package/src/config/network.ts +8 -14
  147. package/src/config/providers.ts +7 -10
  148. package/src/config/runtime.ts +76 -76
  149. package/src/config/security.ts +15 -0
  150. package/src/config/spec.ts +52 -7
  151. package/src/config/telemetry.ts +26 -0
  152. package/src/config/timeouts.ts +0 -6
  153. package/src/console/admin/model-abuse/contracts.ts +28 -6
  154. package/src/console/admin/model-abuse/routes.ts +56 -3
  155. package/src/console/api-keys/bucket-spend.ts +15 -4
  156. package/src/console/api-keys/contracts.ts +410 -414
  157. package/src/console/api-keys/routes.ts +881 -880
  158. package/src/console/auth/credentials.ts +3 -3
  159. package/src/console/auth/setup-service.ts +2 -2
  160. package/src/console/backup/contracts.ts +15 -2
  161. package/src/console/backup/export-endpoint.ts +6 -2
  162. package/src/console/backup/nine-router.ts +2 -5
  163. package/src/console/backup/service.ts +4 -0
  164. package/src/console/backup/store.ts +192 -29
  165. package/src/console/backup/validate.ts +3 -45
  166. package/src/console/cli-tools/fs-ops.ts +487 -487
  167. package/src/console/console-router-guards.ts +1 -1
  168. package/src/console/console-router.ts +8 -0
  169. package/src/console/data-management/contracts.ts +100 -0
  170. package/src/console/data-management/operations.ts +234 -0
  171. package/src/console/data-management/routes.ts +103 -0
  172. package/src/console/domain-registration.ts +45 -1
  173. package/src/console/observability/contracts.ts +524 -485
  174. package/src/console/observability/live.ts +22 -0
  175. package/src/console/observability/store.ts +1138 -1018
  176. package/src/console/observability/usage-dimensions.ts +4 -4
  177. package/src/console/providers/catalog/catalog-projections.ts +6 -7
  178. package/src/console/providers/catalog/contracts.ts +6 -0
  179. package/src/console/providers/catalog/model-operations.ts +5 -2
  180. package/src/console/providers/catalog/provider-operations.ts +24 -4
  181. package/src/console/providers/catalog/public-model-store.ts +8 -11
  182. package/src/console/providers/catalog/routes.ts +7 -0
  183. package/src/console/providers/catalog/store.ts +1553 -1532
  184. package/src/console/providers/oauth/routes.ts +8 -12
  185. package/src/console/quota/account-quota-shared.ts +52 -39
  186. package/src/console/quota/account-quota-tenant.ts +6 -6
  187. package/src/console/quota/quota-view.ts +1 -1
  188. package/src/console/quota/refresh.ts +441 -429
  189. package/src/console/remote-support/contracts.ts +61 -0
  190. package/src/console/remote-support/routes.ts +65 -0
  191. package/src/console/remote-support/service.ts +207 -0
  192. package/src/console/response-compression.ts +0 -3
  193. package/src/console/routing/pools/import-parse.ts +206 -0
  194. package/src/console/routing/pools/operations.ts +543 -501
  195. package/src/console/routing/pools/probe-result.ts +3 -2
  196. package/src/console/routing/pools/probe-verdict.ts +132 -132
  197. package/src/console/routing/pools/routes.ts +181 -164
  198. package/src/console/routing/pools/store.ts +8 -8
  199. package/src/console/settings/contracts.ts +95 -2
  200. package/src/console/settings/store.ts +81 -3
  201. package/src/console/shared/cursor.ts +8 -8
  202. package/src/console/shared/errors.ts +1 -3
  203. package/src/console/updates/routes.ts +16 -0
  204. package/src/main.ts +10 -0
  205. package/src/network/http2-bidi.ts +2 -4
  206. package/src/network/pool/agent.ts +478 -478
  207. package/src/network/pool/selector.ts +5 -4
  208. package/src/network/retry.ts +1 -2
  209. package/src/network/ssrf.ts +5 -4
  210. package/src/observability/event-compaction.ts +317 -0
  211. package/src/observability/event-day-index.ts +440 -0
  212. package/src/observability/event-log.ts +155 -6
  213. package/src/observability/logger.ts +6 -6
  214. package/src/observability/payload-capture.ts +158 -33
  215. package/src/observability/payload-store.ts +82 -61
  216. package/src/observability/redaction.ts +5 -6
  217. package/src/observability/request-notify.ts +43 -0
  218. package/src/observability/telemetry-status.ts +8 -9
  219. package/src/observability/token-speed.ts +5 -4
  220. package/src/persistence/db-mode.ts +7 -7
  221. package/src/persistence/health-event-flush.ts +36 -0
  222. package/src/persistence/readiness.ts +1 -1
  223. package/src/persistence/schema.ts +75 -29
  224. package/src/persistence/shared-cache.ts +1 -2
  225. package/src/persistence/stores/api-key-store.ts +18 -6
  226. package/src/persistence/stores/share-store.ts +4 -3
  227. package/src/persistence/stores/telemetry-store.ts +33 -11
  228. package/src/protocol/canonical-model.ts +11 -3
  229. package/src/protocol/codecs.ts +139 -6
  230. package/src/protocol/gateway-error.ts +6 -4
  231. package/src/protocol/image-clamp.ts +19 -1
  232. package/src/protocol/registry.ts +8 -0
  233. package/src/protocol/request/chat.ts +21 -1
  234. package/src/protocol/request/codex.ts +10 -1
  235. package/src/protocol/request/gemini.ts +9 -2
  236. package/src/protocol/request/messages.ts +18 -7
  237. package/src/protocol/request/responses.ts +9 -1
  238. package/src/protocol/response/chat.ts +34 -4
  239. package/src/protocol/response/codex.ts +7 -4
  240. package/src/protocol/response/dsml.ts +140 -28
  241. package/src/protocol/response/responses.ts +11 -5
  242. package/src/protocol/stream-error-frames.ts +4 -5
  243. package/src/providers/adapters/compatible-adapter.ts +3 -0
  244. package/src/providers/authentication/jwt-validator.ts +9 -10
  245. package/src/providers/authentication/oauth-refresh-service.ts +9 -11
  246. package/src/providers/bundled-discovery-providers.ts +122 -119
  247. package/src/providers/configured-openai-providers.ts +4 -4
  248. package/src/providers/default-registry.ts +5 -4
  249. package/src/providers/discovery/catalog/catalog-blob.ts +18 -18
  250. package/src/providers/discovery/catalog/catalog-format.ts +3 -6
  251. package/src/providers/discovery/models-dev-catalog.ts +354 -354
  252. package/src/providers/discovery/openai-model-discovery.ts +5 -0
  253. package/src/providers/discovery/openrouter-free-roster.ts +15 -17
  254. package/src/providers/discovery/probe-phases.ts +4 -4
  255. package/src/providers/discovery/probe-wire.ts +6 -13
  256. package/src/providers/discovery/probing-service.ts +1117 -1045
  257. package/src/providers/integrations/alibabacp/alibabacp.ts +1 -1
  258. package/src/providers/integrations/antigravity/antigravity-quota.ts +5 -7
  259. package/src/providers/integrations/buddy/buddy-chat-shared.ts +23 -2
  260. package/src/providers/integrations/buddy/buddy-checkin.ts +1 -4
  261. package/src/providers/integrations/buddy/buddy-cli-headers.ts +23 -6
  262. package/src/providers/integrations/buddy/buddy-conversation-headers.ts +0 -1
  263. package/src/providers/integrations/buddy/buddy-identity.ts +52 -0
  264. package/src/providers/integrations/buddy/buddy-media.ts +2 -2
  265. package/src/providers/integrations/buddy/buddy-usage-store.ts +3 -4
  266. package/src/providers/integrations/buddy/codebuddy/quota.ts +2 -13
  267. package/src/providers/integrations/buddy/codebuddy/shared.ts +118 -115
  268. package/src/providers/integrations/buddy/workbuddy/quota.ts +0 -11
  269. package/src/providers/integrations/buddy/workbuddy/shared.ts +5 -2
  270. package/src/providers/integrations/claude/claude.ts +608 -608
  271. package/src/providers/integrations/cline/cline-quota.ts +27 -20
  272. package/src/providers/integrations/cline/cline.ts +537 -539
  273. package/src/providers/integrations/codex/codex-headers.ts +25 -39
  274. package/src/providers/integrations/codex/codex-oauth.ts +1 -1
  275. package/src/providers/integrations/codex/codex.ts +2 -29
  276. package/src/providers/integrations/commandcode/commandcode.ts +674 -675
  277. package/src/providers/integrations/commandcode/discovery.ts +117 -116
  278. package/src/providers/integrations/cursor/cursor-auth.ts +418 -418
  279. package/src/providers/integrations/cursor/cursor-quota.ts +309 -0
  280. package/src/providers/integrations/cursor/cursor-request.ts +616 -471
  281. package/src/providers/integrations/cursor/cursor-session-manager.ts +319 -281
  282. package/src/providers/integrations/cursor/cursor.ts +1333 -960
  283. package/src/providers/integrations/cursor/protocol/messages.ts +25 -0
  284. package/src/providers/integrations/factory/factory-models.ts +3 -5
  285. package/src/providers/integrations/factory/factory.ts +8 -1
  286. package/src/providers/integrations/gemini.ts +6 -1
  287. package/src/providers/integrations/grok/grok-install-id.ts +2 -3
  288. package/src/providers/integrations/grok/grok-quota.ts +279 -177
  289. package/src/providers/integrations/grok/grok-ratelimit-store.ts +73 -0
  290. package/src/providers/integrations/grok/grok-ratelimit.ts +218 -0
  291. package/src/providers/integrations/grok/grok.ts +6 -7
  292. package/src/providers/integrations/hermes.ts +14 -18
  293. package/src/providers/integrations/kiro/kiro-discovery.ts +1 -1
  294. package/src/providers/integrations/kiro/kiro.ts +1 -1
  295. package/src/providers/integrations/mistral.ts +6 -6
  296. package/src/providers/integrations/opencode/fingerprint.ts +3 -3
  297. package/src/providers/integrations/opencode/provider.ts +19 -20
  298. package/src/providers/integrations/qoder/qoder-catalog.ts +450 -450
  299. package/src/providers/integrations/qoder/qoder-oauth.ts +388 -310
  300. package/src/providers/integrations/qoder/qoder-quota.ts +192 -0
  301. package/src/providers/integrations/qoder/qoder.ts +884 -865
  302. package/src/providers/integrations/shared/connect.ts +6 -6
  303. package/src/providers/integrations/trae/trae-catalog.ts +1 -2
  304. package/src/providers/integrations/trae/trae-quota.ts +384 -0
  305. package/src/providers/integrations/trae/trae.ts +2 -2
  306. package/src/providers/integrations/zcode/oauth.ts +6 -1
  307. package/src/providers/operations/account-health-service.ts +75 -27
  308. package/src/providers/operations/account-reset-service.ts +1 -1
  309. package/src/providers/operations/client-versions.ts +6 -6
  310. package/src/providers/operations/custom-cli-headers.ts +3 -4
  311. package/src/providers/operations/provider-catalog-seeder.ts +95 -95
  312. package/src/providers/operations/provider-catalog-service.ts +3 -4
  313. package/src/providers/provider-contracts.ts +7 -0
  314. package/src/providers/provider-metadata.ts +10 -8
  315. package/src/providers/provider-registry.ts +6 -7
  316. package/src/providers/provider-usage.ts +1 -1
  317. package/src/providers/quota/quota-support.ts +8 -0
  318. package/src/security/abuse.ts +15 -10
  319. package/src/security/access-control.ts +144 -146
  320. package/src/security/admission/contracts.ts +11 -0
  321. package/src/security/admission/in-memory-store.ts +4 -3
  322. package/src/security/admission/lease-sweep.ts +6 -6
  323. package/src/security/admission/reasons.ts +5 -1
  324. package/src/security/admission/redis-store.ts +211 -210
  325. package/src/security/admission/service.ts +4 -0
  326. package/src/security/api-key-auth.ts +27 -17
  327. package/src/security/ip-boundary.ts +155 -15
  328. package/src/security/model-abuse.ts +366 -94
  329. package/src/security/outbound-headers.ts +3 -4
  330. package/src/shared/sleep.ts +4 -0
  331. package/src/transport/dispatch/attempt-finalize.ts +47 -5
  332. package/src/transport/dispatch/attempt-loop.ts +531 -530
  333. package/src/transport/dispatch/generation.ts +6 -0
  334. package/src/transport/dispatch/proxy-request.ts +24 -1
  335. package/src/transport/dispatch/ratelimit-capture.ts +37 -0
  336. package/src/transport/dispatch/stream-bridge.ts +1 -3
  337. package/src/transport/dispatch/streaming/finalize.ts +1 -1
  338. package/src/transport/dispatch/streaming/stream.ts +19 -6
  339. package/src/transport/dispatch/upstream.ts +9 -0
  340. package/src/transport/failure-policy.ts +162 -8
  341. package/src/transport/guards/auth-guard.ts +7 -4
  342. package/src/transport/guards/concurrency-guard.ts +0 -5
  343. package/src/transport/guards/error-lifecycle.ts +13 -6
  344. package/src/transport/guards/pipeline.ts +15 -3
  345. package/src/transport/guards/request-context.ts +8 -2
  346. package/src/transport/request/inflight.ts +5 -6
  347. package/src/transport/request/preparer.ts +9 -8
  348. package/src/transport/request/state.ts +21 -8
  349. package/src/transport/request/tenant-preferences.ts +60 -0
  350. package/src/transport/routing/admission.ts +4 -0
  351. package/src/transport/routing/route-catalog.ts +65 -10
  352. package/src/transport/routing/route-model.ts +46 -14
  353. package/src/transport/routing/router.ts +21 -14
  354. package/src/transport/surface/completion.ts +2 -3
  355. package/src/transport/surface/dialects.ts +5 -6
  356. package/src/transport/surface/responses/encode.ts +38 -0
  357. package/src/transport/surface/responses/parse.ts +4 -0
  358. package/src/transport/translation/capabilities.ts +2 -3
  359. package/src/transport/translation/quirks.ts +3 -3
  360. package/src/workers/buddy-growth-tasks.ts +5 -10
  361. package/src/workers/daily-checkin.ts +17 -23
  362. package/src/workers/quota-refresh-sweep.ts +26 -26
  363. package/dist/dashboard/assets/BannedPage-hfCzi-Ux.js.gz +0 -0
  364. package/dist/dashboard/assets/CliToolDetailPage-DLGqYEwI.js +0 -1
  365. package/dist/dashboard/assets/CliToolDetailPage-DLGqYEwI.js.gz +0 -0
  366. package/dist/dashboard/assets/CliToolsPage-DEDyKBlC.js.gz +0 -0
  367. package/dist/dashboard/assets/CombosPage-CfuYiQ8Z.js +0 -1
  368. package/dist/dashboard/assets/CombosPage-CfuYiQ8Z.js.gz +0 -0
  369. package/dist/dashboard/assets/ConsoleLogPage-bsLuZmYH.js.gz +0 -0
  370. package/dist/dashboard/assets/CustomizationPage-_UWhwziJ.js.gz +0 -0
  371. package/dist/dashboard/assets/HealthEventsModal-Cjfc5WFS.js +0 -1
  372. package/dist/dashboard/assets/HealthEventsModal-Cjfc5WFS.js.gz +0 -0
  373. package/dist/dashboard/assets/LoginPage-DDVIfZGW.js.gz +0 -0
  374. package/dist/dashboard/assets/ModelPicker-CWkTUtpX.js +0 -1
  375. package/dist/dashboard/assets/ModelPicker-CWkTUtpX.js.gz +0 -0
  376. package/dist/dashboard/assets/OverviewPage-B53YYMng.js +0 -1
  377. package/dist/dashboard/assets/OverviewPage-B53YYMng.js.gz +0 -0
  378. package/dist/dashboard/assets/ProviderDetailPage-B0KaAQf0.js +0 -3
  379. package/dist/dashboard/assets/ProviderDetailPage-B0KaAQf0.js.gz +0 -0
  380. package/dist/dashboard/assets/ProvidersPage-Dx31DgIO.js +0 -1
  381. package/dist/dashboard/assets/ProvidersPage-Dx31DgIO.js.gz +0 -0
  382. package/dist/dashboard/assets/ProxyPage-BpnR4kXT.js +0 -7
  383. package/dist/dashboard/assets/ProxyPage-BpnR4kXT.js.gz +0 -0
  384. package/dist/dashboard/assets/QuotaPage-BpOQCnun.js +0 -1
  385. package/dist/dashboard/assets/QuotaPage-BpOQCnun.js.gz +0 -0
  386. package/dist/dashboard/assets/SettingsPage-DV1eo2J_.js +0 -1
  387. package/dist/dashboard/assets/SettingsPage-DV1eo2J_.js.gz +0 -0
  388. package/dist/dashboard/assets/SetupPage-D6pmQ86L.js.gz +0 -0
  389. package/dist/dashboard/assets/SortableList-DWvKiuSl.js.gz +0 -0
  390. package/dist/dashboard/assets/StudioPage-CmkUrgdj.js.gz +0 -0
  391. package/dist/dashboard/assets/UsagePage-B47uOydr.js +0 -56
  392. package/dist/dashboard/assets/UsagePage-B47uOydr.js.gz +0 -0
  393. package/dist/dashboard/assets/api-keys-uNywYlfv.js +0 -1
  394. package/dist/dashboard/assets/api-keys-uNywYlfv.js.gz +0 -0
  395. package/dist/dashboard/assets/app-DxivpfYG.css +0 -2
  396. package/dist/dashboard/assets/app-DxivpfYG.css.gz +0 -0
  397. package/dist/dashboard/assets/assert-NHwYq95t.js.gz +0 -0
  398. package/dist/dashboard/assets/badge-6uzDDvqs.js.gz +0 -0
  399. package/dist/dashboard/assets/banner-4_WNg2WZ.js.gz +0 -0
  400. package/dist/dashboard/assets/card--AtFedAq.js.gz +0 -0
  401. package/dist/dashboard/assets/cli-tools-B3e7dx3N.js.gz +0 -0
  402. package/dist/dashboard/assets/clipboard-button-NreUx6c8.js.gz +0 -0
  403. package/dist/dashboard/assets/dialog-BtpsJU98.js +0 -1
  404. package/dist/dashboard/assets/dialog-BtpsJU98.js.gz +0 -0
  405. package/dist/dashboard/assets/download-A0SW9OCW.js +0 -1
  406. package/dist/dashboard/assets/download-A0SW9OCW.js.gz +0 -0
  407. package/dist/dashboard/assets/drawer-WwwtzihF.js +0 -1
  408. package/dist/dashboard/assets/drawer-WwwtzihF.js.gz +0 -0
  409. package/dist/dashboard/assets/entry-CkryrIea.js +0 -4
  410. package/dist/dashboard/assets/entry-CkryrIea.js.gz +0 -0
  411. package/dist/dashboard/assets/entry-TJIye_jT.js +0 -1
  412. package/dist/dashboard/assets/entry-TJIye_jT.js.gz +0 -0
  413. package/dist/dashboard/assets/entry-jy9_4-Ke.js +0 -1
  414. package/dist/dashboard/assets/entry-jy9_4-Ke.js.gz +0 -0
  415. package/dist/dashboard/assets/fingerprint-pattern-BWuHtbW4.js.gz +0 -0
  416. package/dist/dashboard/assets/github-badge-D_eM3IgG.js.gz +0 -0
  417. package/dist/dashboard/assets/index-BydyKaDz.js.gz +0 -0
  418. package/dist/dashboard/assets/inline-BcNGF8Gr.js.gz +0 -0
  419. package/dist/dashboard/assets/input-BG41et9G.js.gz +0 -0
  420. package/dist/dashboard/assets/layout-C8mer_Ak.js +0 -1
  421. package/dist/dashboard/assets/layout-C8mer_Ak.js.gz +0 -0
  422. package/dist/dashboard/assets/list-row-Gh_W4OR_.js.gz +0 -0
  423. package/dist/dashboard/assets/live-BJGYRBnR.js.gz +0 -0
  424. package/dist/dashboard/assets/model-bans-DeVLDA7I.js +0 -1
  425. package/dist/dashboard/assets/model-bans-DeVLDA7I.js.gz +0 -0
  426. package/dist/dashboard/assets/mutation-BkoLM5Ba.js.gz +0 -0
  427. package/dist/dashboard/assets/numbers-Bs4LOOuf.js +0 -2
  428. package/dist/dashboard/assets/numbers-Bs4LOOuf.js.gz +0 -0
  429. package/dist/dashboard/assets/query-keys-D7wX2vM5.js.gz +0 -0
  430. package/dist/dashboard/assets/routing-CcusqXA7.js.gz +0 -0
  431. package/dist/dashboard/assets/select-BO6h8yNv.js.gz +0 -0
  432. package/dist/dashboard/assets/settings-mpmXTgUa.js.gz +0 -0
  433. package/dist/dashboard/assets/sparkles-BRaRUxAU.js +0 -1
  434. package/dist/dashboard/assets/sparkles-BRaRUxAU.js.gz +0 -0
  435. package/dist/dashboard/assets/stack-DwLmjUFK.js.gz +0 -0
  436. package/dist/dashboard/assets/state-DYdQmdAl.js.gz +0 -0
  437. package/dist/dashboard/assets/switch-CidSfbG7.js +0 -1
  438. package/dist/dashboard/assets/switch-CidSfbG7.js.gz +0 -0
  439. package/dist/dashboard/assets/terminal-Bd4ee8TA.js +0 -1
  440. package/dist/dashboard/assets/terminal-Bd4ee8TA.js.gz +0 -0
  441. package/dist/dashboard/assets/tokens-DDxMbSRL.js +0 -1
  442. package/dist/dashboard/assets/tokens-DDxMbSRL.js.gz +0 -0
  443. package/dist/dashboard/assets/use-presence-BSZEBx7U.js +0 -1
  444. package/dist/dashboard/assets/use-presence-BSZEBx7U.js.gz +0 -0
  445. package/dist/dashboard/assets/useMutation-w4u4CVue.js.gz +0 -0
  446. package/scripts/cli/command/admin.ts +0 -65
  447. package/scripts/cli/command/doctor.ts +0 -257
  448. package/scripts/cli/command/update.ts +0 -23
  449. package/scripts/cli/gateway/drain-token.ts +0 -78
  450. package/scripts/cli/gateway/instance-lock.ts +0 -105
  451. package/scripts/cli/gateway/stop.ts +0 -51
  452. package/scripts/cli/support/bun.ts +0 -27
  453. package/scripts/cli/support/version.ts +0 -37
  454. /package/dist/dashboard/assets/{app-CafWtVXA.js → app-BIt446yE.js} +0 -0
  455. /package/dist/dashboard/assets/{app-CafWtVXA.js.gz → app-BIt446yE.js.gz} +0 -0
  456. /package/scripts/cli/{ui/prompt.ts → prompt.ts} +0 -0
@@ -3,20 +3,68 @@ import { DrizzleTelemetryStore } from "../persistence/stores/telemetry-store";
3
3
  import { prunePayloadFrames, writePayloadFrame } from "./payload-store";
4
4
  import { resolveTelemetryPayloadMaxBytes } from "../config/telemetry";
5
5
  import { preferencesReaderFor } from "../persistence/tenant-preferences";
6
- import type { TelemetryPayloadDepth, TelemetryPayloadMode } from "../console/settings/contracts";
6
+ import type {
7
+ TelemetryPayloadDepth,
8
+ TelemetryPayloadMode,
9
+ TelemetryPayloadRetentionMinutes,
10
+ } from "../console/settings/contracts";
7
11
  export type CaptureScope = "tenant" | "debug_session" | "operator_flag";
8
12
 
13
+ /**
14
+ * How long a captured payload frame lives when the deployment has not chosen.
15
+ *
16
+ * Exported so surfaces that report on disk usage quote the same window the
17
+ * capture path enforces, rather than a second copy of the default that would
18
+ * drift the first time this one changes.
19
+ */
20
+ export function payloadRetentionMs(): number {
21
+ const raw = Number(process.env.CARTETHYIA_TELEMETRY_PAYLOAD_RETENTION_MS ?? 15 * 60_000);
22
+ if (!Number.isFinite(raw) || raw < 1_000 || raw > MAX_PAYLOAD_RETENTION_MS) return 15 * 60_000;
23
+ return Math.floor(raw);
24
+ }
25
+
26
+ /**
27
+ * The longest payload retention a tenant may ask for: seven days.
28
+ *
29
+ * Two things depend on this being a hard ceiling rather than a suggestion.
30
+ *
31
+ * The prune pass deletes a frame file it cannot parse once the file's last
32
+ * write is older than the retention window — that bound is the only thing that
33
+ * makes a damaged file reclaimable at all. A tenant allowed to keep frames for
34
+ * a year while the prune assumed an hour would have live frames deleted out
35
+ * from under them.
36
+ *
37
+ * The disk-usage panel counts a frame as reclaimable once its file is older
38
+ * than the same window. Without a ceiling it would offer a reclaim button for
39
+ * frames that are still live, and the operator would press it and see
40
+ * "nothing to reclaim".
41
+ */
42
+ export const MAX_PAYLOAD_RETENTION_MS = 7 * 24 * 60 * 60_000;
43
+
44
+ /** Milliseconds one tenant's chosen retention resolves to. */
45
+ export function retentionMsFor(minutes: number | null | undefined): number {
46
+ if (minutes === null || minutes === undefined) return payloadRetentionMs();
47
+ if (!Number.isFinite(minutes)) return payloadRetentionMs();
48
+ return Math.min(Math.max(1_000, Math.floor(minutes * 60_000)), MAX_PAYLOAD_RETENTION_MS);
49
+ }
50
+
9
51
  /**
10
52
  * Settings-gated payload capture. Request-event metadata is always retained;
11
53
  * tenants opt into drawer capture through Settings → Privacy: `metadata`
12
54
  * keeps only the Proxy→Provider request line, `full` keeps redacted bodies
13
- * at the tenant's chosen depth. Fail-closed on error.
55
+ * at the tenant's chosen depth, and both live for the tenant's chosen
56
+ * retention. Fail-closed on error.
14
57
  */
15
58
  export async function resolvePayloadCapture(
16
59
  db: CartethyiaDatabase,
17
60
  tenantId: string | null,
18
61
  ): Promise<
19
- | { readonly ok: true; readonly mode: TelemetryPayloadMode; readonly depth: TelemetryPayloadDepth }
62
+ | {
63
+ readonly ok: true;
64
+ readonly mode: TelemetryPayloadMode;
65
+ readonly depth: TelemetryPayloadDepth;
66
+ readonly retentionMs: number;
67
+ }
20
68
  | { readonly ok: false }
21
69
  > {
22
70
  if (!tenantId) return { ok: false };
@@ -24,6 +72,7 @@ export async function resolvePayloadCapture(
24
72
  const prefs = await preferencesReaderFor(db).readPreferences(tenantId);
25
73
  const mode = prefs?.telemetryPayloads;
26
74
  const depth = prefs?.telemetryPayloadDepth;
75
+ const retention = prefs?.telemetryPayloadRetentionMinutes;
27
76
  return {
28
77
  ok: true,
29
78
  mode: mode === "full" || mode === "metadata" || mode === "none" ? mode : "none",
@@ -37,12 +86,13 @@ export async function resolvePayloadCapture(
37
86
  : depth === "full"
38
87
  ? "maximum"
39
88
  : "minimum",
89
+ retentionMs: retentionMsFor(retention as TelemetryPayloadRetentionMinutes | undefined),
40
90
  };
41
91
  } catch {
42
92
  // A preference read that fails must not invent capture. Off keeps nothing.
43
93
  // Reported as its own state rather than silently as `none`: an operator
44
94
  // whose capture is off by choice and one whose preference read is failing
45
- // need different fixes, and both used to look like "not configured".
95
+ // need different fixes, and both would otherwise look like "not configured".
46
96
  return { ok: false };
47
97
  }
48
98
  }
@@ -99,6 +149,12 @@ export interface PayloadCaptureInput {
99
149
  * Defaults to the configured capture limit.
100
150
  */
101
151
  maxBytes?: number;
152
+ /**
153
+ * How long this frame lives, in ms. Defaults to the tenant's chosen
154
+ * retention, then the deployment default. Bounded by
155
+ * {@link MAX_PAYLOAD_RETENTION_MS} either way.
156
+ */
157
+ retentionMs?: number;
102
158
  scope: CaptureScope;
103
159
  /** Links the row to `telemetry_events.request_id`. */
104
160
  requestId?: string;
@@ -140,11 +196,13 @@ export interface StoredPayload {
140
196
  signals: PayloadSignals;
141
197
  }
142
198
 
143
- function payloadRetentionMs(): number {
144
- const raw = Number(process.env.CARTETHYIA_TELEMETRY_PAYLOAD_RETENTION_MS ?? 15 * 60_000);
145
- if (!Number.isFinite(raw) || raw < 1_000 || raw > 7 * 24 * 60 * 60_000) return 15 * 60_000;
146
- return Math.floor(raw);
147
- }
199
+ /**
200
+ * How long a captured payload frame lives.
201
+ *
202
+ * Exported so surfaces that report on disk usage quote the same window the
203
+ * capture path enforces, rather than a second copy of the default that would
204
+ * drift the first time this one changes.
205
+ */
148
206
  function isCaptureAllowed(input: PayloadCaptureInput): boolean {
149
207
  // Exactly one opt-in must be set, and it must be the one `scope` names:
150
208
  // two flags at once would silently widen capture beyond the declared scope.
@@ -202,28 +260,29 @@ function payloadSignals(values: readonly unknown[]): PayloadSignals {
202
260
  * `maxBytes` (post-redaction, defaulting to the configured capture limit): a
203
261
  * body that no longer fits is replaced with a truncation marker — no object
204
262
  * store exists, so an oversized body is never referenced, only accounted.
205
- * Payloads remain redacted and bounded during the 15-minute retention window.
263
+ * Payloads remain redacted and bounded during their retention window.
206
264
  *
207
265
  * Drawer order is the allocation order, and that is deliberate: the cap is a
208
266
  * single shared budget, so whatever is accounted first is what survives a
209
267
  * large request. `response_body` is the legacy alias of the client response
210
- * and names no panel of its own, so it is accounted **last** — while it was
211
- * second, a large client request plus its own duplicate consumed the whole
212
- * budget before the Provider Request and Provider Response panels were
213
- * reached, and those two read empty for a request that did reach an upstream.
268
+ * and names no panel of its own, so it is accounted **last** — accounting it
269
+ * earlier lets a large client request plus its own duplicate consume the whole
270
+ * budget before the Provider Request and Provider Response panels are reached,
271
+ * and those two then read empty for a request that did reach an upstream.
214
272
  */
215
273
  export function buildPayloadRecord(
216
274
  input: PayloadCaptureInput,
217
275
  now = new Date(),
218
276
  maxBytes = resolveTelemetryPayloadMaxBytes(),
277
+ retentionMs = payloadRetentionMs(),
219
278
  ): Omit<StoredPayload, "id"> {
220
- const expiresAt = new Date(now.getTime() + payloadRetentionMs());
279
+ const expiresAt = new Date(now.getTime() + retentionMs);
221
280
  // Each body is serialized once, in drawer order, and only while the running
222
- // total still fits the cap. The old path serialized every body up front to
223
- // measure them and then serialized the whole record again to store it, so a
224
- // capture held two copies of every body at once — measured at 23 MB of heap
225
- // for 8 MB of bodies. A body that would cross the cap is never touched: its
226
- // text is not built and its object is not walked for signals.
281
+ // total still fits the cap. Serializing every body up front to measure them
282
+ // and then serializing the whole record again to store it holds two copies of
283
+ // every body at once — measured at 23 MB of heap for 8 MB of bodies. A body
284
+ // that would cross the cap is never touched: its text is not built and its
285
+ // object is not walked for signals.
227
286
  const surfaces: ReadonlyArray<readonly [keyof StoredPayload, unknown]> = [
228
287
  ["request_body", input.requestBody],
229
288
  ["provider_request_body", input.providerRequestBody],
@@ -263,11 +322,11 @@ export function buildPayloadRecord(
263
322
  serialized.push(text);
264
323
  kept.push(body);
265
324
  }
266
- // Only a surface that *had* a body and lost it to the cap is marked. The
267
- // previous form stamped the marker onto every null surface, including ones
268
- // that never carried anything — a request that never reached an upstream
269
- // then reported "truncated" for a provider panel that was never captured,
270
- // and the stage bitmask counted a marker as captured content.
325
+ // Only a surface that *had* a body and lost it to the cap is marked.
326
+ // Stamping the marker onto every null surface would also mark surfaces that
327
+ // never carried anything — a request that never reached an upstream then
328
+ // reports "truncated" for a provider panel that was never captured, and the
329
+ // stage bitmask counts a marker as captured content.
271
330
  const marker = (originalBytes: number): unknown => ({
272
331
  _truncated: true,
273
332
  _original_bytes: originalBytes,
@@ -303,14 +362,26 @@ export function buildPayloadRecord(
303
362
  }
304
363
 
305
364
  /**
306
- * Postgres-backed redacted request/response payload capture. Payloads have a
307
- * 15-minute retention window and are automatically pruned.
365
+ * Postgres-backed redacted request/response payload capture. Payloads live for
366
+ * the tenant's chosen retention — 15 minutes by default — and are pruned by the
367
+ * sweep.
308
368
 
309
369
  */
310
370
  export class TelemetryPayloadCapture {
311
371
  private readonly store: DrizzleTelemetryStore;
312
372
 
313
- constructor(db: CartethyiaDatabase) {
373
+ constructor(
374
+ db: CartethyiaDatabase,
375
+ /**
376
+ * Reads the tenant's chosen retention for captures that arrive without one.
377
+ *
378
+ * Injected rather than imported so the capture path does not depend on the
379
+ * console's settings module, and so a test can pin a retention without
380
+ * touching the database. Absent means every capture uses the deployment
381
+ * default, which is what a probe or a debug session wants anyway.
382
+ */
383
+ private readonly retentionFor?: (tenantId: string) => Promise<number> | number,
384
+ ) {
314
385
  this.store = new DrizzleTelemetryStore(db);
315
386
  }
316
387
 
@@ -320,7 +391,17 @@ export class TelemetryPayloadCapture {
320
391
  code: "capture_not_opted_in",
321
392
  });
322
393
  }
323
- const record = buildPayloadRecord(input, new Date(), input.maxBytes ?? resolveTelemetryPayloadMaxBytes());
394
+ // A caller that knows the answer passes it; otherwise the tenant's own
395
+ // preference is resolved, and failing that the deployment default. The
396
+ // early-rejection path has no preference to hand and lands here, which is
397
+ // why the default must be the deployment one rather than the shortest.
398
+ const retentionMs = await resolveRetention(input, this.retentionFor);
399
+ const record = buildPayloadRecord(
400
+ input,
401
+ new Date(),
402
+ input.maxBytes ?? resolveTelemetryPayloadMaxBytes(),
403
+ retentionMs,
404
+ );
324
405
  const reference = await writePayloadFrame(record, record.expires_at, record._serialized
325
406
  ? {
326
407
  requestId: record.request_id,
@@ -357,10 +438,54 @@ export class TelemetryPayloadCapture {
357
438
  deleted += batch;
358
439
  if (batch < batchSize) break;
359
440
  }
360
- // Every frame expires `payloadRetentionMs()` after it is written, so a file
361
- // last written before that window closed cannot still hold a live frame.
362
- // That bound is what makes a damaged, unparsable file reclaimable at all.
363
- deleted += await prunePayloadFrames(now, new Date(now.getTime() - payloadRetentionMs()));
441
+ // A file last written before *the longest retention a tenant can choose*
442
+ // closed cannot still hold a live frame — whatever its tenant picked, that
443
+ // frame has expired. Using the deployment default here instead was correct
444
+ // only while every frame shared one window: with a tenant allowed to keep
445
+ // frames for a week, a 15-minute bound would delete files still holding
446
+ // frames their rows address, and the drawer would silently read empty.
447
+ deleted += await prunePayloadFrames(now, payloadPruneBound(now));
364
448
  return { deleted };
365
449
  }
366
450
  }
451
+
452
+ /**
453
+ * The cutoff before which a frame file can be deleted wholesale.
454
+ *
455
+ * Every frame expires at most {@link MAX_PAYLOAD_RETENTION_MS} after it is
456
+ * written, so a file last touched before that instant cannot still hold a live
457
+ * frame — and that is the only thing that makes a damaged, unparsable file
458
+ * reclaimable at all. Exported so the bound is pinned without a database: the
459
+ * alternative is a test that deletes a live frame and notices nothing.
460
+ */
461
+ export function payloadPruneBound(now: Date): Date {
462
+ return new Date(now.getTime() - MAX_PAYLOAD_RETENTION_MS);
463
+ }
464
+
465
+ /**
466
+ * How long one capture's frame should live.
467
+ *
468
+ * Order of authority: the caller's own value, then the tenant's preference,
469
+ * then the deployment default. A failure resolving the tenant's preference
470
+ * falls back rather than throwing — an unknown retention must not turn into a
471
+ * lost capture, and the deployment default is always a valid answer.
472
+ */
473
+ export async function resolveRetention(
474
+ input: PayloadCaptureInput,
475
+ retentionFor?: (tenantId: string) => Promise<number> | number,
476
+ ): Promise<number> {
477
+ if (typeof input.retentionMs === "number" && Number.isFinite(input.retentionMs)) {
478
+ return Math.min(Math.max(1_000, Math.floor(input.retentionMs)), MAX_PAYLOAD_RETENTION_MS);
479
+ }
480
+ if (retentionFor) {
481
+ try {
482
+ const value = await retentionFor(input.tenantId);
483
+ if (Number.isFinite(value)) {
484
+ return Math.min(Math.max(1_000, Math.floor(value)), MAX_PAYLOAD_RETENTION_MS);
485
+ }
486
+ } catch {
487
+ // Falls through to the deployment default below.
488
+ }
489
+ }
490
+ return payloadRetentionMs();
491
+ }
@@ -4,12 +4,7 @@ import { randomUUID } from "node:crypto";
4
4
  import { gunzipSync, gzipSync } from "node:zlib";
5
5
 
6
6
  export interface PayloadFileReference {
7
- /**
8
- * `jsonb-file` is a frame written before compression existed: plain JSON.
9
- * New frames are `jsonl-file`: the same JSON, gzipped. The row names its
10
- * encoding, so a reader never guesses and an old row still resolves.
11
- */
12
- readonly storage: "jsonb-file" | "jsonl-file";
7
+ readonly storage: "jsonl-file";
13
8
  readonly file: string;
14
9
  readonly offset: number;
15
10
  readonly length: number;
@@ -28,7 +23,7 @@ export function isPayloadFileReference(value: unknown): value is PayloadFileRefe
28
23
  if (typeof value !== "object" || value === null) return false;
29
24
  const record = value as Record<string, unknown>;
30
25
  return (
31
- (record["storage"] === "jsonb-file" || record["storage"] === "jsonl-file") &&
26
+ record["storage"] === "jsonl-file" &&
32
27
  typeof record["file"] === "string" &&
33
28
  typeof record["offset"] === "number" &&
34
29
  typeof record["length"] === "number" &&
@@ -62,7 +57,13 @@ export function payloadReferenceFromRow(row: {
62
57
  return isPayloadFileReference(candidate) ? candidate : undefined;
63
58
  }
64
59
 
65
- const FRAME_HEADER_BYTES = 4;
60
+ /** Extension for frame files. */
61
+ const FRAME_FILE_SUFFIX = ".jsonl";
62
+
63
+ function isFrameFileName(name: string): boolean {
64
+ return name.endsWith(FRAME_FILE_SUFFIX);
65
+ }
66
+
66
67
  const MAX_FRAME_BYTES = 256 * 1024 * 1024;
67
68
  const DEFAULT_MAX_FILE_BYTES = 64 * 1024 * 1024;
68
69
  const DEFAULT_DIRECTORY = "./data/telemetry-payloads";
@@ -96,7 +97,7 @@ function checksum(value: Buffer | readonly Buffer[]): string {
96
97
  }
97
98
 
98
99
  function fileName(now = new Date()): string {
99
- return `${now.toISOString().slice(0, 13).replaceAll(":", "-")}-${process.pid}.jsonb`;
100
+ return `${now.toISOString().slice(0, 13).replaceAll(":", "-")}-${process.pid}${FRAME_FILE_SUFFIX}`;
100
101
  }
101
102
 
102
103
  /**
@@ -115,34 +116,50 @@ function compressFrame(parts: readonly Buffer[]): Buffer {
115
116
  async function appendFrame(frame: PayloadFrame, parts?: readonly Buffer[]): Promise<PayloadFileReference> {
116
117
  const directory = payloadDirectory();
117
118
  await mkdir(directory, { recursive: true });
118
- parts ??= [Buffer.from(JSON.stringify(frame), "utf8")];
119
- const encoded = compressFrame(parts);
120
- if (encoded.byteLength > MAX_FRAME_BYTES) throw new Error("telemetry payload frame exceeds limit");
119
+ // Encoded as a line here rather than at the write, so the cap, the checksum,
120
+ // and the reference all describe the same bytes that land on disk — the
121
+ // prebuilt-parts path and the plain one cannot disagree about the shape.
122
+ const json = parts === undefined
123
+ ? Buffer.from(JSON.stringify(frame), "utf8")
124
+ : Buffer.concat(parts);
125
+ const compressed = compressFrame([json]);
126
+ if (compressed.byteLength > MAX_FRAME_BYTES) throw new Error("telemetry payload frame exceeds limit");
127
+ const encoded = toLine(compressed);
121
128
  const file = fileName();
122
129
  const path = join(directory, file);
123
130
  const currentSize = await stat(path).then((value) => value.size).catch(() => 0);
124
131
  const offset = currentSize;
125
- if (offset + FRAME_HEADER_BYTES + encoded.byteLength > maxFileBytes()) {
126
- const rotated = `${Date.now()}-${randomUUID()}.jsonb`;
127
- return appendFrameToPath(frame, join(directory, rotated), basename(rotated), undefined, encoded);
132
+ if (offset + encoded.byteLength > maxFileBytes()) {
133
+ const rotated = `${Date.now()}-${randomUUID()}${FRAME_FILE_SUFFIX}`;
134
+ return appendFrameToPath(join(directory, rotated), encoded, basename(rotated));
128
135
  }
129
- return appendFrameToPath(frame, path, file, offset, encoded);
136
+ return appendFrameToPath(path, encoded, file, offset);
137
+ }
138
+
139
+ /**
140
+ * One frame as a stored line: base64 of the gzipped JSON, newline-terminated.
141
+ *
142
+ * A newline is the frame delimiter, so the file is a text file of base64 lines
143
+ * and a frame's extent is found by reading to the next `\n` — a binary length
144
+ * header would make every frame opaque, unreadable without first decoding a
145
+ * big-endian length and then gunzipping exactly that many bytes.
146
+ *
147
+ * base64 rather than raw gzip bytes because gzip contains newlines, which
148
+ * would make the delimiter ambiguous.
149
+ */
150
+ function toLine(compressed: Buffer): Buffer {
151
+ return Buffer.from(`${compressed.toString("base64")}\n`, "utf8");
130
152
  }
131
153
 
132
154
  async function appendFrameToPath(
133
- frame: PayloadFrame,
134
155
  path: string,
156
+ encoded: Buffer,
135
157
  knownFile = basename(path),
136
158
  knownOffset?: number,
137
- knownEncoded?: Buffer,
138
159
  ): Promise<PayloadFileReference> {
139
- const encoded = knownEncoded ?? compressFrame([Buffer.from(JSON.stringify(frame), "utf8")]);
140
160
  const offset = knownOffset ?? await stat(path).then((value) => value.size).catch(() => 0);
141
- const header = Buffer.alloc(FRAME_HEADER_BYTES);
142
- header.writeUInt32BE(encoded.byteLength, 0);
143
161
  const handle = await open(path, "a");
144
162
  try {
145
- await handle.write(header);
146
163
  await handle.write(encoded);
147
164
  } finally {
148
165
  await handle.close();
@@ -151,7 +168,7 @@ async function appendFrameToPath(
151
168
  storage: "jsonl-file",
152
169
  file: knownFile,
153
170
  offset,
154
- length: FRAME_HEADER_BYTES + encoded.byteLength,
171
+ length: encoded.byteLength,
155
172
  checksum: checksum(encoded),
156
173
  version: 1,
157
174
  };
@@ -166,6 +183,11 @@ async function appendFrameToPath(
166
183
  * drawer order with the legacy `response_body` alias last, for the same reason
167
184
  * the record accounts it last: it names no panel of its own and must not
168
185
  * spend the shared byte budget ahead of the provider stages.
186
+ *
187
+ * There are five, not four. `buildPayloadRecord` builds one string per surface
188
+ * and `response_body` is the fifth — dropping it here wrote the frame with the
189
+ * first four bodies and silently discarded the last, so a capture whose client
190
+ * response was in the legacy slot came back with that panel empty.
169
191
  */
170
192
  const FRAME_BODY_KEYS = [
171
193
  "request_body",
@@ -240,14 +262,7 @@ export async function readPayloadFrame(reference: PayloadFileReference): Promise
240
262
  await handle.close();
241
263
  }
242
264
  const frame = data;
243
- const length = frame.readUInt32BE(0);
244
- if (length <= 0 || length + FRAME_HEADER_BYTES !== frame.byteLength) return undefined;
245
- const stored = frame.subarray(FRAME_HEADER_BYTES);
246
- if (checksum(stored) !== reference.checksum) return undefined;
247
- // The row says which encoding it wrote. A compressed row that does not
248
- // decompress is a damaged frame, reported as absent like any other
249
- // unreadable frame, never thrown into the request that asked for it.
250
- const json = reference.storage === "jsonl-file" ? gunzipFrame(stored) : stored;
265
+ const json = frameJsonFromSlice(frame);
251
266
  if (json === undefined) return undefined;
252
267
  const parsed = JSON.parse(json.toString("utf8")) as PayloadFrame;
253
268
  return parsed.version === 1 ? parsed.payload : undefined;
@@ -262,6 +277,24 @@ function gunzipFrame(stored: Buffer): Buffer | undefined {
262
277
  }
263
278
  }
264
279
 
280
+ /**
281
+ * The JSON of one frame read out of a file.
282
+ *
283
+ * A frame is a base64 line — base64 because gzip contains newlines, which would
284
+ * make the newline delimiter ambiguous. The bytes are gunzipped only if they
285
+ * are gzip; `gunzipFrame` returning `undefined` is what marks a slice as not a
286
+ * frame.
287
+ */
288
+ function frameJsonFromSlice(
289
+ slice: Buffer,
290
+ ): Buffer | undefined {
291
+ // Frames are a base64 line. The trailing newline is not part of the payload.
292
+ const text = slice.toString("utf8").trim();
293
+ if (text.length === 0) return undefined;
294
+ const gzipped = Buffer.from(text, "base64");
295
+ return gunzipFrame(gzipped);
296
+ }
297
+
265
298
  /**
266
299
  * What a whole file's parseable frames say about reclaiming it.
267
300
  *
@@ -272,40 +305,25 @@ function gunzipFrame(stored: Buffer): Buffer | undefined {
272
305
  */
273
306
  type FrameLiveness = "live" | "none" | "unknown";
274
307
 
275
- /**
276
- * The JSON of one stored frame, whichever encoding wrote it.
277
- *
278
- * A frame names no encoding of its own — the row does — and this pass reads
279
- * files, not rows. gzip bytes start with the 0x1f 0x8b magic and plain JSON
280
- * starts with `{`, so the encoding is read off the bytes. Anything else is
281
- * corrupt and falls through to the caller's parse, which reports the file
282
- * unreadable rather than empty.
283
- */
284
- function frameJson(stored: Buffer): Buffer {
285
- if (stored.byteLength >= 2 && stored[0] === 0x1f && stored[1] === 0x8b) {
286
- return gunzipSync(stored);
287
- }
288
- return stored;
289
- }
290
-
291
-
292
308
  function frameLiveness(data: Buffer, before: Date): FrameLiveness {
293
309
  let offset = 0;
294
- while (offset + FRAME_HEADER_BYTES <= data.byteLength) {
295
- const length = data.readUInt32BE(offset);
296
- const end = offset + FRAME_HEADER_BYTES + length;
297
- // A length that runs past the file is a half-written tail, not a frame.
298
- if (length <= 0 || end > data.byteLength) return "unknown";
310
+ while (offset < data.byteLength) {
311
+ const newline = data.indexOf(0x0a, offset);
312
+ if (newline <= 0) return "unknown";
313
+ const end = newline + 1;
314
+ const slice = data.subarray(offset, end);
315
+ const json = frameJsonFromSlice(slice);
316
+ if (json === undefined) return "unknown";
299
317
  let frame: PayloadFrame;
300
318
  try {
301
- frame = JSON.parse(frameJson(data.subarray(offset + FRAME_HEADER_BYTES, end)).toString("utf8")) as PayloadFrame;
319
+ frame = JSON.parse(json.toString("utf8")) as PayloadFrame;
302
320
  } catch {
303
321
  return "unknown";
304
322
  }
305
323
  if (new Date(frame.expiresAt).getTime() >= before.getTime()) return "live";
306
324
  offset = end;
307
325
  }
308
- // Bytes left over that are too short to hold a header are an unreadable tail.
326
+ // Bytes left over that are too short to hold a frame are an unreadable tail.
309
327
  return offset === data.byteLength ? "none" : "unknown";
310
328
  }
311
329
 
@@ -327,10 +345,11 @@ function frameLiveness(data: Buffer, before: Date): FrameLiveness {
327
345
  * supply that bound leaves the default, which keeps damaged files rather than
328
346
  * risk dropping a frame a row still addresses.
329
347
  *
330
- * One damaged file must not abort the pass. A single corrupt frame used to
331
- * throw straight out of the loop, so every other expired file stayed on disk
332
- * forever and the volume only grew — and because a container restart is the
333
- * usual way a half-written tail appears, the failure looked Docker-specific.
348
+ * One damaged file must not abort the pass. If a single corrupt frame threw
349
+ * straight out of the loop, every other expired file would stay on disk
350
+ * forever and the volume would only grow — and because a container restart is
351
+ * the usual way a half-written tail appears, the failure would look
352
+ * Docker-specific.
334
353
  */
335
354
  export async function prunePayloadFrames(
336
355
  before: Date,
@@ -340,7 +359,7 @@ export async function prunePayloadFrames(
340
359
  const entries = await readdir(directory, { withFileTypes: true }).catch(() => []);
341
360
  let deleted = 0;
342
361
  for (const entry of entries) {
343
- if (!entry.isFile() || !entry.name.endsWith(".jsonb")) continue;
362
+ if (!entry.isFile() || !isFrameFileName(entry.name)) continue;
344
363
  const path = join(directory, entry.name);
345
364
  try {
346
365
  if (await fileReclaimable(path, before, unparseableWrittenBefore)) {
@@ -367,7 +386,9 @@ async function fileReclaimable(
367
386
  unparseableWrittenBefore: Date,
368
387
  ): Promise<boolean> {
369
388
  const data = await readFile(path);
370
- if (data.byteLength < FRAME_HEADER_BYTES) return true;
389
+ // Nothing written yet, so nothing to lose. A real frame is far longer than
390
+ // any header this format could carry.
391
+ if (data.byteLength === 0) return true;
371
392
  const liveness = frameLiveness(data, before);
372
393
  if (liveness === "live") return false;
373
394
  if (liveness === "none") return true;
@@ -1,13 +1,12 @@
1
1
  // Client-IP presentation masking.
2
2
  //
3
- // This module used to also carry a telemetry redactor that rewrote any string
4
- // containing a credential-shaped token into a placeholder. It was meant for
5
- // display, but `***REDACTED***` in a captured body is indistinguishable from
3
+ // This module carries no string-sniffing payload redactor, and must not grow
4
+ // one: `***REDACTED***` in a captured body is indistinguishable from
6
5
  // `***REDACTED***` in a payload that was actually sent — so a memory-review
7
- // turn quoting one JWT rendered as a fully redacted message, and a 400 from
8
- // the provider looked like the gateway had mangled the request.
6
+ // turn quoting one JWT would render as a fully redacted message, and a 400 from
7
+ // the provider would look like the gateway had mangled the request.
9
8
  //
10
- // Payload bodies are stored verbatim now. The upstream already refuses and
9
+ // Payload bodies are stored verbatim. The upstream already refuses and
11
10
  // reports credentials on its own; a second, string-sniffing layer only
12
11
  // destroys the evidence needed to read a failure.
13
12
 
@@ -0,0 +1,43 @@
1
+ /**
2
+ * Lightweight fan-out for completed gateway requests.
3
+ *
4
+ * `notifyRequestCompleted` is called once per terminal request (same site as
5
+ * `telemetryBuffer.enqueue`). Subscribers receive a minimal summary — just
6
+ * enough for the dashboard SSE stream to trigger a targeted refetch rather
7
+ * than polling.
8
+ */
9
+
10
+ export interface RequestCompletedNotice {
11
+ readonly tenantId: string;
12
+ readonly requestId: string;
13
+ readonly status: string;
14
+ readonly httpStatus: number;
15
+ }
16
+
17
+ type Listener = (notice: RequestCompletedNotice) => void;
18
+
19
+ const listeners = new Set<Listener>();
20
+
21
+ /** Notify all subscribers that a request has reached a terminal state. */
22
+ export function notifyRequestCompleted(notice: RequestCompletedNotice): void {
23
+ for (const listener of listeners) {
24
+ try {
25
+ listener(notice);
26
+ } catch {
27
+ // Listener failures must not affect the request path.
28
+ }
29
+ }
30
+ }
31
+
32
+ /** Subscribe to per-request terminal notifications; returns unsubscribe. */
33
+ export function subscribeRequestCompleted(listener: Listener): () => void {
34
+ listeners.add(listener);
35
+ return () => {
36
+ listeners.delete(listener);
37
+ };
38
+ }
39
+
40
+ /** Test-only: clear all subscribers. */
41
+ export function resetRequestNotifyForTests(): void {
42
+ listeners.clear();
43
+ }
@@ -1,13 +1,12 @@
1
1
  /**
2
2
  * The single definition of which requests count as *gateway* errors.
3
3
  *
4
- * Six independent sites used to answer this question with their own copy of
5
- * `status in ('failed', 'truncated')`: the usage summary, the health window,
6
- * the usage breakdown, the retained-usage rollup (`telemetry_usage_totals`),
7
- * the public share page, and the client-IP breakdown. A definition spread over
8
- * six predicates is not a definition — it is six chances to drift, and the
9
- * rollup made the drift durable: once a request is counted into
10
- * `telemetry_usage_totals.errors` it stays counted after the raw row is pruned.
4
+ * One definition keeps the six sites that ask this — the usage summary, the
5
+ * health window, the usage breakdown, the retained-usage rollup
6
+ * (`telemetry_usage_totals`), the public share page, and the client-IP
7
+ * breakdown — from drifting apart, which matters because the rollup makes any
8
+ * drift durable: once a request is counted into `telemetry_usage_totals.errors`
9
+ * it stays counted after the raw row is pruned.
11
10
  *
12
11
  * The rule, stated as a principle rather than a list of codes:
13
12
  *
@@ -23,8 +22,8 @@
23
22
  * an unregistered `/v1/*` path, a `429` from the caller's own quota, and a
24
23
  * `413` from an oversized body all cost the sender nothing and reach no
25
24
  * provider, so counting any of them hands an abuser a free way to inflate
26
- * the error rate. Enumerating "the bad ones" was the previous approach and it
27
- * missed `401`/`429`; excluding the class cannot miss one.
25
+ * the error rate. Enumerating "the bad ones" misses `401`/`429`; excluding the
26
+ * class cannot miss one.
28
27
  * - **`499`** is a client abort (`transport_closed`). The client leaving is
29
28
  * expected behavior — the request may even have been served — and it is
30
29
  * tracked as its own count. Being below `500`, it is excluded by the class
@@ -51,10 +51,11 @@ export interface TokenSpeedInput {
51
51
  export function computeTokensPerSec(input: TokenSpeedInput): number | undefined {
52
52
  const outputTokens = input.outputTokens;
53
53
  // `!Number.isFinite` rather than `<= 0`: a NaN token count fails `NaN <= 0`
54
- // (`false`), so it used to pass through and the division produced a NaN speed —
55
- // which reaches the telemetry row and renders as a literal "NaN tok/s" in the
56
- // Requests table. That is the case where a wrong number is least welcome, since
57
- // the row that produced it is the one an operator is investigating.
54
+ // (`false`), so it would pass through and the division would produce a NaN
55
+ // speed — which reaches the telemetry row and renders as a literal
56
+ // "NaN tok/s" in the Requests table. That is the case where a wrong number is
57
+ // least welcome, since the row that produced it is the one an operator is
58
+ // investigating.
58
59
  if (outputTokens === undefined || !Number.isFinite(outputTokens) || outputTokens <= 0) {
59
60
  return undefined;
60
61
  }