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
@@ -32,7 +32,8 @@
32
32
  import { and, eq, sql } from "drizzle-orm";
33
33
  import { gzipSync, gunzipSync } from "node:zlib";
34
34
  import { differsFrom } from "../../persistence/dialect";
35
- import { readTelemetryEvents, TELEMETRY_EVENT_DIR } from "../../observability/event-log";
35
+ import { TELEMETRY_EVENT_DIR } from "../../observability/event-log";
36
+ import { queryTelemetryEvents as querySqlEvents } from "../../observability/event-query";
36
37
  import { appendFile, mkdir } from "node:fs/promises";
37
38
  import { getTableColumns, type Column, type SQL, type Table } from "drizzle-orm";
38
39
  import type { CartethyiaDatabase } from "../../persistence/db-handle";
@@ -294,7 +295,7 @@ export async function exportBackup(
294
295
  const reads = tables.map(async (table) =>
295
296
  [
296
297
  table,
297
- table === telemetryEvents ? await exportEventFile(tenantId) : await exportTable(db, table, tenantId),
298
+ table === telemetryEvents ? await exportEventFile(db, tenantId) : await exportTable(db, table, tenantId),
298
299
  ] as const,
299
300
  );
300
301
  // Preserve the caller's `tables` order when writing into the output maps so
@@ -373,10 +374,12 @@ export function decompressBackup(bytes: Uint8Array): string {
373
374
  * backup contract still names. The table is no longer written, so reading it
374
375
  * would export nothing.
375
376
  */
376
- async function exportEventFile(tenantId: string): Promise<readonly BackupRow[]> {
377
- const events = await readTelemetryEvents();
378
- return events
379
- .filter((event) => event.tenantId === tenantId)
377
+ async function exportEventFile(db: CartethyiaDatabase, tenantId: string): Promise<readonly BackupRow[]> {
378
+ // The SQL table is the durable record now, and it is tenant-scoped and
379
+ // retained by the same rule as the raw files — so the export reads it
380
+ // instead of scanning every raw file and filtering by tenant afterwards.
381
+ const page = await querySqlEvents(db, { tenantId, limit: MAX_ROWS_PER_TABLE });
382
+ return page.events
380
383
  .slice(0, MAX_ROWS_PER_TABLE)
381
384
  .map((event) => {
382
385
  const row: BackupRow = {};
@@ -465,13 +468,15 @@ function createdAtIso(value: unknown): string {
465
468
  * so the two sides are comparable regardless of how the driver types them.
466
469
  */
467
470
  /** Natural keys already present in the event file, so a re-import skips them. */
468
- async function existingEventFileKeys(rows: readonly BackupRow[]): Promise<Set<string>> {
471
+ async function existingEventFileKeys(db: CartethyiaDatabase, tenantId: string, rows: readonly BackupRow[]): Promise<Set<string>> {
469
472
  const wanted = new Set(rows.map((row) => row.request_id).filter((id): id is string => typeof id === "string"));
470
473
  if (wanted.size === 0) return new Set();
471
- const events = await readTelemetryEvents();
474
+ // One indexed query for the ids being restored, instead of reading every
475
+ // raw file to test membership. `id = request_id`, so the unique index serves
476
+ // this directly.
477
+ const page = await querySqlEvents(db, { tenantId, requestIds: [...wanted] });
472
478
  return new Set(
473
- events
474
- .filter((event) => wanted.has(event.requestId))
479
+ page.events
475
480
  .map((event) => `${event.tenantId ?? ""}\u0000${event.requestId}\u0000${event.createdAt}`),
476
481
  );
477
482
  }
@@ -832,7 +837,7 @@ export async function applyRestore(
832
837
  const entry = validation.tables.get(table);
833
838
  if (entry === undefined) continue;
834
839
  if (table === telemetryEvents) {
835
- const existing = await existingEventFileKeys(entry.rows);
840
+ const existing = await existingEventFileKeys(db, tenantId, entry.rows);
836
841
  const fresh = entry.rows.filter((row) => !existing.has(telemetryKey(row)));
837
842
  await appendRestoredEvents(fresh);
838
843
  restored[tableName(table)] = fresh.length;
@@ -13,6 +13,7 @@
13
13
  */
14
14
  import type { Table } from "drizzle-orm";
15
15
  import { getTableColumns } from "drizzle-orm";
16
+ import { isRecord as isPlainObject } from "../../protocol/codecs";
16
17
  import { decryptCredentialToString } from "../../security/crypto";
17
18
  import {
18
19
  BACKUP_APP,
@@ -100,10 +101,6 @@ function normalizeLegacyCiphertext(table: Table, row: Record<string, unknown>):
100
101
  return normalized;
101
102
  }
102
103
 
103
- function isPlainObject(value: unknown): value is Record<string, unknown> {
104
- return typeof value === "object" && value !== null && !Array.isArray(value);
105
- }
106
-
107
104
  /** Column name → its Drizzle data type, so validation knows what may nest. */
108
105
  function columnTypes(table: Table): ReadonlyMap<string, string> {
109
106
  const out = new Map<string, string>();
@@ -11,8 +11,24 @@ import { homedir, platform } from "node:os";
11
11
  import { join } from "node:path";
12
12
  import { mkdir, rm } from "node:fs/promises";
13
13
 
14
- /** Home directory — shared contract used by all injectors. */
15
- export { homedir as homeDir };
14
+ /**
15
+ * Home directory — shared contract used by all injectors.
16
+ *
17
+ * Reads the environment on every call rather than exporting `os.homedir()`,
18
+ * whose value is cached at first use. That cache silently broke the redirect
19
+ * this module documents: a test that pointed `HOME` at a temp dir had its
20
+ * first path resolved correctly and every later one resolved to the home that
21
+ * was live when the process started — and on Linux that is the only value
22
+ * `os.homedir()` ever returns, so injectors wrote to the real home while the
23
+ * assertions read a temp dir that `afterEach` had already deleted.
24
+ *
25
+ * Production behaviour is unchanged: `HOME`/`USERPROFILE` are still what
26
+ * decide the directory, so an operator's environment is honoured as before.
27
+ */
28
+ export function homeDir(): string {
29
+ const fromEnv = process.env.HOME ?? process.env.USERPROFILE;
30
+ return fromEnv && fromEnv.length > 0 ? fromEnv : homedir();
31
+ }
16
32
  export { join };
17
33
 
18
34
  const IS_WIN: boolean = platform() === "win32";
@@ -19,7 +19,7 @@
19
19
  /** One measured category of on-disk data. */
20
20
  export interface DiskUsageBucket {
21
21
  /** Stable id, so the dashboard can key rows without matching on copy. */
22
- readonly id: "telemetry_events" | "telemetry_index" | "payload_frames";
22
+ readonly id: "telemetry_events" | "payload_frames";
23
23
  readonly label: string;
24
24
  readonly bytes: number;
25
25
  readonly files: number;
@@ -37,13 +37,6 @@ export interface DiskUsageResponse {
37
37
  readonly retentionDays: number;
38
38
  /** Payload frames live this long by default before they are reclaimable. */
39
39
  readonly payloadRetentionMs: number;
40
- /**
41
- * The longest retention a tenant may have chosen. A frame is only counted as
42
- * reclaimable once it is older than *this*, because a tenant who raised the
43
- * window has frames the default would call dead while their rows still
44
- * address them.
45
- */
46
- readonly payloadRetentionMaxMs: number;
47
40
  /**
48
41
  * Stored health & error rows, for the purge row's label. Absent when the
49
42
  * composition does not wire the health-event store (a reduced console),
@@ -1,6 +1,7 @@
1
1
  import { readdir, stat, unlink } from "node:fs/promises";
2
2
  import { join } from "node:path";
3
3
  import { payloadDirectory, prunePayloadFrames } from "../../observability/payload-store";
4
+ import { payloadPruneBound } from "../../observability/payload-capture";
4
5
  import type {
5
6
  DiskUsageBucket,
6
7
  DiskUsageResponse,
@@ -9,6 +10,7 @@ import type {
9
10
  ReclaimResponse,
10
11
  } from "./contracts";
11
12
  import { PurgeNotWiredError, ReclaimNotAllowedError } from "./contracts";
13
+ import { hourStamp } from "../../shared/time";
12
14
 
13
15
  /**
14
16
  * Reads and reclaims the on-disk telemetry and payload directories, and
@@ -33,14 +35,16 @@ import { PurgeNotWiredError, ReclaimNotAllowedError } from "./contracts";
33
35
  export interface DataManagementConfig {
34
36
  readonly telemetryDir: string;
35
37
  readonly retentionDays: number;
36
- readonly payloadRetentionMs: number;
37
38
  /**
38
- * The longest payload retention a tenant may have chosen, and therefore the
39
- * only cutoff a frame file can be judged dead against without reading it.
40
- * Defaults to `payloadRetentionMs` for callers that predate per-tenant
41
- * retention, which is then the true ceiling in that deployment.
39
+ * How long a payload frame lives, for one tenant.
40
+ *
41
+ * A function of the tenant, not a number: the Request detail window in
42
+ * Settings is per tenant, and a single deployment-level number made this
43
+ * panel report the deployment default while the gateway applied the tenant's
44
+ * own window. The same panel then offered to reclaim frames the gateway
45
+ * still considered live, and left alone ones it had already expired.
42
46
  */
43
- readonly payloadRetentionMaxMs?: number;
47
+ readonly payloadRetentionMs: (tenantId: string | null) => Promise<number>;
44
48
  /**
45
49
  * The health & error log store, backing the console's only row-level purge.
46
50
  * Absent (a reduced console composition) means the purge is not offered
@@ -78,13 +82,37 @@ async function scan(
78
82
  return { bytes, files };
79
83
  }
80
84
 
81
- const isIndex = (name: string): boolean => name.endsWith(".index.json");
82
- const isHourly = (name: string): boolean => name.endsWith(".events.jsonl");
85
+ const isEventLog = (name: string): boolean =>
86
+ name.endsWith(".jsonl") && !name.endsWith(".compacted.jsonl");
83
87
  const isFrame = (name: string): boolean =>
84
88
  name.endsWith(".jsonl");
85
89
 
90
+ /**
91
+ * A duration in minutes as the operator's own units, not raw milliseconds.
92
+ *
93
+ * An hour is "1 hour" rather than "60 min" because that is how the window was
94
+ * chosen — the dropdown they picked it from says "1 hour", and a note that
95
+ * renders the same choice as "60 min" makes the two screens disagree about
96
+ * what was set.
97
+ */
98
+ function formatMinutes(ms: number): string {
99
+ const minutes = Math.round(ms / 60_000);
100
+ if (minutes % 1440 === 0) {
101
+ const days = minutes / 1440;
102
+ return `${days} day${days === 1 ? "" : "s"}`;
103
+ }
104
+ if (minutes % 60 === 0) {
105
+ const hours = minutes / 60;
106
+ return `${hours} hour${hours === 1 ? "" : "s"}`;
107
+ }
108
+ return `${minutes} min`;
109
+ }
110
+
86
111
  /** The UTC day a telemetry file name starts with, compared to a cutoff. */
87
- function dayBefore(name: string, cutoff: number): boolean {
112
+ function eventBefore(name: string, cutoff: number): boolean {
113
+ if (!name.endsWith(".events.jsonl")) {
114
+ return name.slice(0, 13) < hourStamp(new Date(cutoff));
115
+ }
88
116
  const at = Date.parse(`${name.slice(0, 10)}T00:00:00Z`);
89
117
  return Number.isFinite(at) && at < cutoff;
90
118
  }
@@ -92,55 +120,47 @@ function dayBefore(name: string, cutoff: number): boolean {
92
120
  export function createDataManagementOperations(config: DataManagementConfig) {
93
121
  const payloadDir = (): string => payloadDirectory();
94
122
  /**
95
- * The only cutoff a frame file can be judged dead against without parsing it.
123
+ * Disk usage for one tenant.
96
124
  *
97
- * A file's mtime says when it was last written, not what its frames' tenants
98
- * chose — so a file written an hour ago can hold a frame with a week to live.
99
- * The ceiling is therefore the bound, and the default retention is only used
100
- * when no ceiling was configured.
125
+ * `tenantId` is required because the payload window is a per-tenant setting:
126
+ * counting "stale" frames against the deployment default declared frames
127
+ * stale while the gateway still held them as live, and under-counted them
128
+ * for a tenant that had shortened its window.
101
129
  */
102
- const payloadMax = (): number => config.payloadRetentionMaxMs ?? config.payloadRetentionMs;
103
-
104
- async function usage(): Promise<DiskUsageResponse> {
105
- const retentionCutoff = Date.now() - config.retentionDays * 24 * 60 * 60_000;
106
- const payloadCutoff = Date.now() - payloadMax();
130
+ async function usage(tenantId: string | null): Promise<DiskUsageResponse> {
131
+ const retentionMs = await config.payloadRetentionMs(tenantId);
132
+ const payloadCutoff = Date.now() - retentionMs;
107
133
 
108
- const [hourly, compacted, staleCompacted, frames, staleFrames, healthEventCount] = await Promise.all([
109
- scan(config.telemetryDir, isHourly),
110
- scan(config.telemetryDir, isIndex),
111
- scan(config.telemetryDir, (name) => isIndex(name) && dayBefore(name, retentionCutoff)),
134
+ const [events, frames, staleFrames, healthEventCount] = await Promise.all([
135
+ scan(config.telemetryDir, isEventLog),
112
136
  scan(payloadDir(), isFrame),
113
137
  scan(payloadDir(), isFrame, payloadCutoff),
114
138
  config.healthEvents?.count(),
115
139
  ]);
116
140
 
141
+ /*
142
+ * The notes name the same knobs Operations exposes, read at request time
143
+ * rather than written into the copy. A fixed "15 min" was wrong the moment
144
+ * an operator changed the tenant's window: the panel kept quoting the
145
+ * deployment default while the gateway applied their number, so the one
146
+ * place that could correct the assumption repeated it.
147
+ */
117
148
  const buckets: DiskUsageBucket[] = [
118
149
  {
119
150
  id: "telemetry_events",
120
151
  label: "Request event files",
121
- bytes: hourly.bytes,
122
- files: hourly.files,
123
- note: "Feeds the request list and detail drawer.",
152
+ bytes: events.bytes,
153
+ files: events.files,
154
+ note: `Feeds the request list and detail drawer. Kept ${config.retentionDays} days.`,
124
155
  reclaimableBytes: null,
125
156
  reclaimableFiles: null,
126
157
  },
127
- {
128
- id: "telemetry_index",
129
- label: "Day indexes",
130
- bytes: compacted.bytes,
131
- files: compacted.files,
132
- note: "Feeds usage totals, breakdowns and the chart.",
133
- reclaimableBytes: staleCompacted.bytes,
134
- reclaimableFiles: staleCompacted.files,
135
- },
136
158
  {
137
159
  id: "payload_frames",
138
160
  label: "Payload frames",
139
161
  bytes: frames.bytes,
140
162
  files: frames.files,
141
- note: `Captured bodies, expiring ${Math.round(
142
- config.payloadRetentionMs / 60_000,
143
- )} min after write by default.`,
163
+ note: `Captured bodies, expiring ${formatMinutes(retentionMs)} after write.`,
144
164
  reclaimableBytes: staleFrames.bytes,
145
165
  reclaimableFiles: staleFrames.files,
146
166
  },
@@ -150,8 +170,7 @@ export function createDataManagementOperations(config: DataManagementConfig) {
150
170
  buckets,
151
171
  totalBytes: buckets.reduce((sum, bucket) => sum + bucket.bytes, 0),
152
172
  retentionDays: config.retentionDays,
153
- payloadRetentionMs: config.payloadRetentionMs,
154
- payloadRetentionMaxMs: payloadMax(),
173
+ payloadRetentionMs: retentionMs,
155
174
  ...(healthEventCount === undefined ? {} : { healthEventCount }),
156
175
  };
157
176
  }
@@ -168,10 +187,7 @@ export function createDataManagementOperations(config: DataManagementConfig) {
168
187
  // written after that bound might still hold a live frame in the part
169
188
  // that failed to parse, so it is left for a later pass.
170
189
  const now = new Date();
171
- const deletedFiles = await prunePayloadFrames(
172
- now,
173
- new Date(now.getTime() - payloadMax()),
174
- );
190
+ const deletedFiles = await prunePayloadFrames(now, payloadPruneBound(now));
175
191
  const after = await scan(payloadDir(), isFrame);
176
192
  return {
177
193
  action,
@@ -180,12 +196,9 @@ export function createDataManagementOperations(config: DataManagementConfig) {
180
196
  };
181
197
  }
182
198
  if (action === "telemetry_retention") {
183
- // Only day indexes past retention, and only together with their events.
184
- // Deleting one without the other is the failure this avoids: an index
185
- // with no events reports totals for requests that are gone.
186
199
  const cutoff = Date.now() - config.retentionDays * 24 * 60 * 60_000;
187
200
  return unlinkWhere(action, config.telemetryDir, (name) =>
188
- (isIndex(name) || isHourly(name)) && dayBefore(name, cutoff),
201
+ isEventLog(name) && eventBefore(name, cutoff),
189
202
  );
190
203
  }
191
204
  throw new ReclaimNotAllowedError(`Unknown reclaim action: ${action}`);
@@ -46,8 +46,10 @@ export function createDataManagementRoutes(
46
46
  .get("/usage", async ({ request, set }) => {
47
47
  try {
48
48
  const access = requireAnyScope(config.accessResolver(request), WRITE_SCOPES);
49
- void access;
50
- return await operations.usage();
49
+ // The tenant decides the payload window, so the scan needs it: the
50
+ // count of reclaimable frames is measured against that tenant's own
51
+ // retention, not the deployment's.
52
+ return await operations.usage(access.tenantId ?? null);
51
53
  } catch (error) {
52
54
  return errorResponse(error, set, "Could not read disk usage");
53
55
  }
@@ -5,6 +5,8 @@ import { createAccountQuotaRoutes } from "./quota/account-quota";
5
5
  import { createApiKeyRoutes } from "./api-keys/routes";
6
6
  import { familyBucketSpend } from "./api-keys/bucket-spend";
7
7
  import { createProviderCatalogRoutes } from "./providers/catalog/routes";
8
+ import { createInferhubRoutes } from "./inferhub/routes";
9
+ import { createInferhubKeyStore } from "./inferhub/key-store";
8
10
  import {
9
11
  createProviderDetailRoutes,
10
12
  } from "./providers/detail/routes";
@@ -43,7 +45,7 @@ import { createBackupRoutes } from "./backup/routes";
43
45
  import { BackupService } from "./backup/service";
44
46
  import type { ConsoleCredentialService } from "./auth/credentials";
45
47
  import { resolveTelemetryRetentionDays } from "../config/telemetry";
46
- import { MAX_PAYLOAD_RETENTION_MS, payloadRetentionMs } from "../observability/payload-capture";
48
+ import { payloadRetentionMs, resolvePayloadCapture } from "../observability/payload-capture";
47
49
  import { TELEMETRY_EVENT_DIR } from "../observability/event-log";
48
50
 
49
51
  import { InMemoryOAuthFlowStore, OAuthFlowStore } from "../providers/authentication/oauth-flow-store";
@@ -249,6 +251,14 @@ export function registerConsoleDomains(
249
251
  service: createRemoteSupportService({ port: resolvePort() }),
250
252
  }));
251
253
  console.use(createAuditRoutes({ store: auditReadStore, accessResolver: ctx.accessResolver }));
254
+ // InferHub market data. On demand only: a route fetches when its snapshot is
255
+ // past its freshness window, so a page nobody opens causes no upstream calls.
256
+ const inferhubKeys = createInferhubKeyStore(ctx.db);
257
+ console.use(createInferhubRoutes({
258
+ accessResolver: ctx.accessResolver,
259
+ resolveConfigKey: () => inferhubKeys.read(),
260
+ keyStore: inferhubKeys,
261
+ }));
252
262
  if (ctx.modelStrikes) {
253
263
  console.use(createModelAbuseRoutes({
254
264
  strikes: ctx.modelStrikes,
@@ -293,8 +303,20 @@ export function registerConsoleDomains(
293
303
  createDataManagementRoutes({
294
304
  telemetryDir: TELEMETRY_EVENT_DIR,
295
305
  retentionDays: resolveTelemetryRetentionDays(),
296
- payloadRetentionMs: payloadRetentionMs(),
297
- payloadRetentionMaxMs: MAX_PAYLOAD_RETENTION_MS,
306
+ /*
307
+ * Resolved per tenant, not once at boot: the Request detail window is a
308
+ * per-tenant setting, and this panel is the only place that reports the
309
+ * number back. A deployment-level value made it show the default while
310
+ * the gateway applied the tenant's own window.
311
+ *
312
+ * It goes through the same `resolvePayloadCapture` the dispatch path
313
+ * uses, so the panel and the gateway cannot disagree about how long a
314
+ * frame lives.
315
+ */
316
+ payloadRetentionMs: async (tenantId) => {
317
+ const resolved = await resolvePayloadCapture(ctx.db, tenantId);
318
+ return resolved.ok ? resolved.retentionMs : payloadRetentionMs();
319
+ },
298
320
  accessResolver: ctx.accessResolver,
299
321
  auditSink: ctx.auditRecorder,
300
322
  flusher: ctx.flushBuffers,
@@ -0,0 +1,139 @@
1
+ /**
2
+ * InferHub management-API client.
3
+ *
4
+ * Called only from route handlers — there is no scheduler, no poller, and no
5
+ * background loop anywhere in this design. A page that nobody opens issues no
6
+ * requests.
7
+ *
8
+ * Two constraints the API imposes:
9
+ * - The documented budget is 30 req/min per account for Management + MCP. All
10
+ * calls share one bucket capped at 20/min to leave headroom, and the public
11
+ * endpoints are assumed to share it because the docs do not prove otherwise.
12
+ * - Every value here is untrusted remote data. A response is parsed defensively
13
+ * and a failure returns `undefined` — never a half-parsed object that a route
14
+ * would render as if it were good.
15
+ */
16
+ import { createValidatedFetch, type ValidatedFetch } from "../../network/outbound-fetch";
17
+
18
+ export const INFERHUB_API_BASE = "https://inferhub.dev/api";
19
+
20
+ /** Leave headroom under the documented 30/min Management budget. */
21
+ const RATE_LIMIT_PER_MINUTE = 20;
22
+ const RATE_LIMIT_WINDOW_MS = 60_000;
23
+ const REQUEST_TIMEOUT_MS = 10_000;
24
+ /** Bounded: one retry, and only when the server asked us to wait. */
25
+ const MAX_ATTEMPTS = 2;
26
+
27
+ /** One shared bucket for every management call this process makes. */
28
+ interface Bucket {
29
+ windowStart: number;
30
+ used: number;
31
+ }
32
+ const bucket: Bucket = { windowStart: Date.now(), used: 0 };
33
+
34
+ function takeToken(now: number): boolean {
35
+ if (now - bucket.windowStart >= RATE_LIMIT_WINDOW_MS) {
36
+ bucket.windowStart = now;
37
+ bucket.used = 0;
38
+ }
39
+ if (bucket.used >= RATE_LIMIT_PER_MINUTE) return false;
40
+ bucket.used += 1;
41
+ return true;
42
+ }
43
+
44
+ /** Test seam: lets a test drive the bucket without waiting a minute. */
45
+ export function __resetRateLimit(now: number = Date.now()): void {
46
+ bucket.windowStart = now;
47
+ bucket.used = 0;
48
+ }
49
+
50
+ export interface InferhubClientOptions {
51
+ /** `config_read` key; required for `/catalog`, ignored by the public endpoints. */
52
+ readonly configKey?: string | undefined;
53
+ readonly fetchFn?: ValidatedFetch | typeof fetch;
54
+ readonly now?: () => number;
55
+ }
56
+
57
+ export class InferhubRateLimited extends Error {
58
+ constructor() {
59
+ super("inferhub management rate limit reached");
60
+ this.name = "InferhubRateLimited";
61
+ }
62
+ }
63
+
64
+ /**
65
+ * One management GET.
66
+ *
67
+ * Returns `undefined` for any non-2xx, timeout, rate-limit exhaustion, or parse
68
+ * failure. A caller that gets `undefined` keeps its last good snapshot, so a
69
+ * transient upstream problem never blanks an operator's view.
70
+ */
71
+ async function get(
72
+ path: string,
73
+ options: InferhubClientOptions,
74
+ ): Promise<unknown> {
75
+ const now = options.now?.() ?? Date.now();
76
+ if (!takeToken(now)) return undefined;
77
+
78
+ const fetchFn = (options.fetchFn ?? createValidatedFetch()) as ValidatedFetch;
79
+ const headers: Record<string, string> = { accept: "application/json" };
80
+ if (options.configKey !== undefined && options.configKey.length > 0)
81
+ headers.authorization = `Bearer ${options.configKey}`;
82
+
83
+ for (let attempt = 1; attempt <= MAX_ATTEMPTS; attempt++) {
84
+ try {
85
+ const response = await fetchFn(`${INFERHUB_API_BASE}${path}`, {
86
+ method: "GET",
87
+ headers,
88
+ signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
89
+ });
90
+ if (response.status === 429) {
91
+ // Honor `Retry-After` rather than retrying immediately, but only once:
92
+ // the bounded loop is the guard against a retry storm.
93
+ const retryAfter = Number(response.headers.get("retry-after"));
94
+ if (attempt < MAX_ATTEMPTS && Number.isFinite(retryAfter) && retryAfter > 0) {
95
+ await new Promise((resolve) => setTimeout(resolve, Math.min(retryAfter * 1000, 5_000)));
96
+ continue;
97
+ }
98
+ return undefined;
99
+ }
100
+ if (!response.ok) return undefined;
101
+ return await response.json();
102
+ } catch {
103
+ // Timeout, network error, or a body that is not JSON: treat as no data.
104
+ return undefined;
105
+ }
106
+ }
107
+ return undefined;
108
+ }
109
+
110
+ /** `GET /api/market` — public. Money is numeric. */
111
+ export function fetchMarket(options: InferhubClientOptions = {}): Promise<unknown> {
112
+ return get("/market", options);
113
+ }
114
+
115
+ /** `GET /api/status` — public. Availability and per-model health. */
116
+ export function fetchStatus(options: InferhubClientOptions = {}): Promise<unknown> {
117
+ return get("/status", options);
118
+ }
119
+
120
+ /** `GET /api/catalog` — needs `config_read`. Money is a decimal string. */
121
+ export function fetchCatalog(options: InferhubClientOptions = {}): Promise<unknown> {
122
+ return get("/catalog", options);
123
+ }
124
+
125
+ /** `GET /api/pricing/config` — public platform fee configuration. */
126
+ export function fetchPricingConfig(options: InferhubClientOptions = {}): Promise<unknown> {
127
+ return get("/pricing/config", options);
128
+ }
129
+
130
+ export function objectArray(payload: unknown, key: string): readonly Record<string, unknown>[] {
131
+ const value =
132
+ payload !== null && typeof payload === "object" && !Array.isArray(payload)
133
+ ? (payload as Record<string, unknown>)[key]
134
+ : payload;
135
+ if (!Array.isArray(value)) return [];
136
+ return value.filter(
137
+ (item): item is Record<string, unknown> => item !== null && typeof item === "object",
138
+ );
139
+ }
@@ -0,0 +1,129 @@
1
+ /**
2
+ * The InferHub `config_read` key, stored on its own row.
3
+ *
4
+ * Deliberately **not** in `provider_accounts.auth_state`: `auth_state` is
5
+ * documented as the per-account *non-secret* upstream config field and travels
6
+ * with the account through backup/restore, so a credential there is copied
7
+ * around and readable by anything that reads accounts. This key is a secret, so
8
+ * it gets the same treatment as any other credential — encrypted at rest with
9
+ * `encryptCredential`, in its own table, reachable only through the InferHub
10
+ * routes.
11
+ *
12
+ * It is also never the inference key. InferHub issues two kinds and a
13
+ * `config_read` key can read no tenant inference data and move no money; an
14
+ * inference key here would be able to do both. The write path rejects a value
15
+ * already stored as an account credential so the two cannot be conflated.
16
+ */
17
+ import { and, eq } from "drizzle-orm";
18
+ import { inferhubConfigKeys, providerAccounts } from "../../persistence/schema";
19
+ import { decryptCredentialToString, encryptCredential, hashSecret } from "../../security/crypto";
20
+ import type { CartethyiaDatabase } from "../../persistence/db-handle";
21
+
22
+ /** Shape returned to the console: whether a key is set, and never the key. */
23
+ export interface InferhubKeyStatus {
24
+ readonly configured: boolean;
25
+ /** Last four characters, so an operator can tell two keys apart. */
26
+ readonly last4: string | null;
27
+ readonly updatedAt: string | null;
28
+ }
29
+
30
+ /**
31
+ * Reads the stored key. Returns undefined when none is configured.
32
+ *
33
+ * A missing key is not an error — the routes report `no_config_key` and the
34
+ * operator is told to add one, rather than an optional feature failing closed.
35
+ */
36
+ export async function readConfigKey(db: CartethyiaDatabase): Promise<string | undefined> {
37
+ try {
38
+ const rows = await db
39
+ .select({ ciphertext: inferhubConfigKeys.ciphertext })
40
+ .from(inferhubConfigKeys)
41
+ .where(eq(inferhubConfigKeys.id, SINGLETON_ID))
42
+ .limit(1);
43
+ const stored = rows[0]?.ciphertext;
44
+ if (stored === undefined || stored === null) return undefined;
45
+ const plaintext = decryptCredentialToString(stored).trim();
46
+ return plaintext.length > 0 ? plaintext : undefined;
47
+ } catch {
48
+ // A decrypt failure (rotated encryption key, corrupt row) must not look
49
+ // like "no key configured" in a way that silently hides data loss, but it
50
+ // must not throw into a page render either. Returning undefined reports
51
+ // `no_config_key`, which tells the operator to re-enter it.
52
+ return undefined;
53
+ }
54
+ }
55
+
56
+ /** Writes the key, replacing any previous value. */
57
+ export async function writeConfigKey(db: CartethyiaDatabase, value: string): Promise<void> {
58
+ const trimmed = value.trim();
59
+ const now = new Date();
60
+ await db
61
+ .insert(inferhubConfigKeys)
62
+ .values({
63
+ id: SINGLETON_ID,
64
+ ciphertext: encryptCredential(trimmed),
65
+ fingerprint: hashSecret(trimmed),
66
+ updatedAt: now,
67
+ })
68
+ .onConflictDoUpdate({
69
+ target: inferhubConfigKeys.id,
70
+ set: { ciphertext: encryptCredential(trimmed), fingerprint: hashSecret(trimmed), updatedAt: now },
71
+ });
72
+ }
73
+
74
+ /** Removes the key entirely. */
75
+ export async function clearConfigKey(db: CartethyiaDatabase): Promise<void> {
76
+ await db.delete(inferhubConfigKeys).where(eq(inferhubConfigKeys.id, SINGLETON_ID));
77
+ }
78
+
79
+ /** Whether a key is set, without decrypting it into a response. */
80
+ export async function keyStatus(db: CartethyiaDatabase): Promise<InferhubKeyStatus> {
81
+ const rows = await db
82
+ .select({ ciphertext: inferhubConfigKeys.ciphertext, updatedAt: inferhubConfigKeys.updatedAt })
83
+ .from(inferhubConfigKeys)
84
+ .where(eq(inferhubConfigKeys.id, SINGLETON_ID))
85
+ .limit(1);
86
+ const row = rows[0];
87
+ if (row === undefined) return { configured: false, last4: null, updatedAt: null };
88
+ let last4: string | null = null;
89
+ try {
90
+ const plaintext = decryptCredentialToString(row.ciphertext).trim();
91
+ last4 = plaintext.length >= 4 ? plaintext.slice(-4) : null;
92
+ } catch {
93
+ last4 = null;
94
+ }
95
+ return {
96
+ configured: true,
97
+ last4,
98
+ updatedAt: row.updatedAt?.toISOString() ?? null,
99
+ };
100
+ }
101
+
102
+ /**
103
+ * One row for the whole deployment: the `config_read` key is a single operator
104
+ * credential, not a per-account one.
105
+ */
106
+ const SINGLETON_ID = "default";
107
+
108
+ /** Re-exported so routes can assert a key is not also an account credential. */
109
+ export async function isAlsoAccountCredential(
110
+ db: CartethyiaDatabase,
111
+ providerId: string,
112
+ ): Promise<boolean> {
113
+ const rows = await db
114
+ .select({ id: providerAccounts.id })
115
+ .from(providerAccounts)
116
+ .where(and(eq(providerAccounts.providerId, providerId)))
117
+ .limit(1);
118
+ return rows.length > 0;
119
+ }
120
+
121
+ /** Builds the key store the InferHub routes take. */
122
+ export function createInferhubKeyStore(db: CartethyiaDatabase) {
123
+ return {
124
+ read: () => readConfigKey(db),
125
+ write: (value: string) => writeConfigKey(db, value),
126
+ clear: () => clearConfigKey(db),
127
+ status: () => keyStatus(db),
128
+ };
129
+ }