cartethyia 2.4.5 → 2.4.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 (303) hide show
  1. package/.env.example +4 -1
  2. package/README.md +18 -0
  3. package/dist/dashboard/assets/AccountCooldown-ChpdzxRH.js.gz +0 -0
  4. package/dist/dashboard/assets/{BannedPage-DUrXpp-X.js → BannedPage-QhvbJB9o.js} +1 -1
  5. package/dist/dashboard/assets/BannedPage-QhvbJB9o.js.gz +0 -0
  6. package/dist/dashboard/assets/{CliToolDetailPage-BzGevuql.js → CliToolDetailPage-Bcy150Jo.js} +1 -1
  7. package/dist/dashboard/assets/CliToolDetailPage-Bcy150Jo.js.gz +0 -0
  8. package/dist/dashboard/assets/{CliToolsPage-BaRM_7We.js → CliToolsPage-DyNXefph.js} +1 -1
  9. package/dist/dashboard/assets/CliToolsPage-DyNXefph.js.gz +0 -0
  10. package/dist/dashboard/assets/CombosPage-Cak0ljnO.js +1 -0
  11. package/dist/dashboard/assets/CombosPage-Cak0ljnO.js.gz +0 -0
  12. package/dist/dashboard/assets/ConsoleLogPage-CATpAEOz.js +1 -0
  13. package/dist/dashboard/assets/ConsoleLogPage-CATpAEOz.js.gz +0 -0
  14. package/dist/dashboard/assets/{CustomizationPage-DqUGcoH0.js → CustomizationPage-D6jEU7nL.js} +1 -1
  15. package/dist/dashboard/assets/CustomizationPage-D6jEU7nL.js.gz +0 -0
  16. package/dist/dashboard/assets/HealthEventsModal-CZkpn0a7.js +1 -0
  17. package/dist/dashboard/assets/HealthEventsModal-CZkpn0a7.js.gz +0 -0
  18. package/dist/dashboard/assets/{LoginPage-DbhTGfhb.js → LoginPage-BJlMVN4U.js} +1 -1
  19. package/dist/dashboard/assets/LoginPage-BJlMVN4U.js.gz +0 -0
  20. package/dist/dashboard/assets/ModelPicker-Br3CXEau.js +1 -0
  21. package/dist/dashboard/assets/ModelPicker-Br3CXEau.js.gz +0 -0
  22. package/dist/dashboard/assets/{OverviewPage-C3NIzsDx.js → OverviewPage-CQxXbo2g.js} +1 -1
  23. package/dist/dashboard/assets/OverviewPage-CQxXbo2g.js.gz +0 -0
  24. package/dist/dashboard/assets/ProviderDetailPage--0RBNh99.js +3 -0
  25. package/dist/dashboard/assets/ProviderDetailPage--0RBNh99.js.gz +0 -0
  26. package/dist/dashboard/assets/ProviderIcon-paOCq8I0.js.gz +0 -0
  27. package/dist/dashboard/assets/ProvidersPage-1M-C3mbu.js +1 -0
  28. package/dist/dashboard/assets/ProvidersPage-1M-C3mbu.js.gz +0 -0
  29. package/dist/dashboard/assets/ProxyPage-CXiumA53.js +7 -0
  30. package/dist/dashboard/assets/ProxyPage-CXiumA53.js.gz +0 -0
  31. package/dist/dashboard/assets/QuotaPage-BGDIfIYP.js +1 -0
  32. package/dist/dashboard/assets/QuotaPage-BGDIfIYP.js.gz +0 -0
  33. package/dist/dashboard/assets/{SettingsPage-YxRml8AT.js → SettingsPage-BHW4Zt06.js} +1 -1
  34. package/dist/dashboard/assets/SettingsPage-BHW4Zt06.js.gz +0 -0
  35. package/dist/dashboard/assets/SetupPage-VOYC_lIa.js +1 -0
  36. package/dist/dashboard/assets/SetupPage-VOYC_lIa.js.gz +0 -0
  37. package/dist/dashboard/assets/SortableList-DpOTnGuH.js +1 -0
  38. package/dist/dashboard/assets/SortableList-DpOTnGuH.js.gz +0 -0
  39. package/dist/dashboard/assets/StudioPage-BNIDLiU6.js +31 -0
  40. package/dist/dashboard/assets/StudioPage-BNIDLiU6.js.gz +0 -0
  41. package/dist/dashboard/assets/ToolIcon-K1ckcDKg.js.gz +0 -0
  42. package/dist/dashboard/assets/{UsagePage-Djes-Epy.js → UsagePage-BAFaSDQV.js} +14 -14
  43. package/dist/dashboard/assets/UsagePage-BAFaSDQV.js.gz +0 -0
  44. package/dist/dashboard/assets/api-keys-BETfaMYZ.js +1 -0
  45. package/dist/dashboard/assets/api-keys-BETfaMYZ.js.gz +0 -0
  46. package/dist/dashboard/assets/app-BLCvGdzb.css +2 -0
  47. package/dist/dashboard/assets/app-BLCvGdzb.css.gz +0 -0
  48. package/dist/dashboard/assets/{app-BDJWMThu.js → app-Cjq7nrwu.js} +3 -3
  49. package/dist/dashboard/assets/app-Cjq7nrwu.js.gz +0 -0
  50. package/dist/dashboard/assets/arrow-left-CpBxkoxI.js.gz +0 -0
  51. package/dist/dashboard/assets/arrow-right-Dl5yKnp8.js.gz +0 -0
  52. package/dist/dashboard/assets/{assert-Bwh6DJTK.js → assert-7J6_LVD3.js} +1 -1
  53. package/dist/dashboard/assets/assert-7J6_LVD3.js.gz +0 -0
  54. package/dist/dashboard/assets/backup-file-gdKIlg8U.js.gz +0 -0
  55. package/dist/dashboard/assets/{badge-BwmwEOgA.js → badge-BTyGk_DL.js} +1 -1
  56. package/dist/dashboard/assets/badge-BTyGk_DL.js.gz +0 -0
  57. package/dist/dashboard/assets/{banner-BKBfKTqn.js → banner-bujTWYM4.js} +1 -1
  58. package/dist/dashboard/assets/banner-bujTWYM4.js.gz +0 -0
  59. package/dist/dashboard/assets/brain-Cwed_72z.js.gz +0 -0
  60. package/dist/dashboard/assets/{card-BTdbRFFg.js → card-B0Bnx5CA.js} +1 -1
  61. package/dist/dashboard/assets/card-B0Bnx5CA.js.gz +0 -0
  62. package/dist/dashboard/assets/check-D69FzdvC.js.gz +0 -0
  63. package/dist/dashboard/assets/chevron-right-BXIy5l3h.js.gz +0 -0
  64. package/dist/dashboard/assets/circle-alert-CntYHV5v.js.gz +0 -0
  65. package/dist/dashboard/assets/circle-check-BU3v_mFW.js.gz +0 -0
  66. package/dist/dashboard/assets/cli-tools-DE2ug812.js +1 -0
  67. package/dist/dashboard/assets/cli-tools-DE2ug812.js.gz +0 -0
  68. package/dist/dashboard/assets/clipboard-button-R_rArH8S.js +1 -0
  69. package/dist/dashboard/assets/clipboard-button-R_rArH8S.js.gz +0 -0
  70. package/dist/dashboard/assets/cog-CdWhsajs.js.gz +0 -0
  71. package/dist/dashboard/assets/copy-ngmQ9zcZ.js.gz +0 -0
  72. package/dist/dashboard/assets/{dialog-iocKA3jr.js → dialog-BWIP_EvW.js} +1 -1
  73. package/dist/dashboard/assets/dialog-BWIP_EvW.js.gz +0 -0
  74. package/dist/dashboard/assets/dialog-actions-B1axpEg7.js.gz +0 -0
  75. package/dist/dashboard/assets/download-A0SW9OCW.js.gz +0 -0
  76. package/dist/dashboard/assets/download-dRFo5_Wu.js.gz +0 -0
  77. package/dist/dashboard/assets/{drawer-CDKOTZdY.js → drawer-BKcgVugF.js} +1 -1
  78. package/dist/dashboard/assets/drawer-BKcgVugF.js.gz +0 -0
  79. package/dist/dashboard/assets/entry-7dpgvpJ3.js +4 -0
  80. package/dist/dashboard/assets/entry-7dpgvpJ3.js.gz +0 -0
  81. package/dist/dashboard/assets/entry-CcWkGR4a.js +1 -0
  82. package/dist/dashboard/assets/entry-CcWkGR4a.js.gz +0 -0
  83. package/dist/dashboard/assets/entry-kTiTFrvh.js +1 -0
  84. package/dist/dashboard/assets/entry-kTiTFrvh.js.gz +0 -0
  85. package/dist/dashboard/assets/eye-C_hawfH4.js.gz +0 -0
  86. package/dist/dashboard/assets/eye-off-Bk5g2luF.js.gz +0 -0
  87. package/dist/dashboard/assets/fingerprint-pattern-BWuHtbW4.js.gz +0 -0
  88. package/dist/dashboard/assets/gauge-BbIMy7MB.js.gz +0 -0
  89. package/dist/dashboard/assets/github-badge-Booc5mNe.js +1 -0
  90. package/dist/dashboard/assets/github-badge-Booc5mNe.js.gz +0 -0
  91. package/dist/dashboard/assets/image-BPaouViG.js.gz +0 -0
  92. package/dist/dashboard/assets/{index-C4hXfAMs.js → index-D70pChfn.js} +2 -2
  93. package/dist/dashboard/assets/index-D70pChfn.js.gz +0 -0
  94. package/dist/dashboard/assets/{inline-DCejSLlA.js → inline-D1TCqM2D.js} +1 -1
  95. package/dist/dashboard/assets/inline-D1TCqM2D.js.gz +0 -0
  96. package/dist/dashboard/assets/{input-Bn4_kNav.js → input-B9oyU93y.js} +1 -1
  97. package/dist/dashboard/assets/input-B9oyU93y.js.gz +0 -0
  98. package/dist/dashboard/assets/jsx-runtime-CXUMngI_.js.gz +0 -0
  99. package/dist/dashboard/assets/key-round-UkavsGxp.js.gz +0 -0
  100. package/dist/dashboard/assets/layers-2-glBbjl.js.gz +0 -0
  101. package/dist/dashboard/assets/{layout-Bv-NxqZ5.js → layout-BArzXirQ.js} +1 -1
  102. package/dist/dashboard/assets/layout-BArzXirQ.js.gz +0 -0
  103. package/dist/dashboard/assets/{list-row-BLA0sldD.js → list-row-BVZzaoHF.js} +1 -1
  104. package/dist/dashboard/assets/list-row-BVZzaoHF.js.gz +0 -0
  105. package/dist/dashboard/assets/{live-YlifPYox.js → live-EmuIayqp.js} +1 -1
  106. package/dist/dashboard/assets/live-EmuIayqp.js.gz +0 -0
  107. package/dist/dashboard/assets/log-in-BmRjy60c.js.gz +0 -0
  108. package/dist/dashboard/assets/message-circle-CCxGwRvv.js.gz +0 -0
  109. package/dist/dashboard/assets/{model-bans-CVvHZ-qz.js → model-bans-CSPQ9xB0.js} +1 -1
  110. package/dist/dashboard/assets/model-bans-CSPQ9xB0.js.gz +0 -0
  111. package/dist/dashboard/assets/mutation-BkoLM5Ba.js +1 -0
  112. package/dist/dashboard/assets/mutation-BkoLM5Ba.js.gz +0 -0
  113. package/dist/dashboard/assets/numbers-CFL4_Dqc.js.gz +0 -0
  114. package/dist/dashboard/assets/pencil-768ysRAr.js.gz +0 -0
  115. package/dist/dashboard/assets/plus-DIdX_g-x.js.gz +0 -0
  116. package/dist/dashboard/assets/power-off-Yjw9GMJh.js +1 -0
  117. package/dist/dashboard/assets/power-off-Yjw9GMJh.js.gz +0 -0
  118. package/dist/dashboard/assets/privacy-mode-GM8bej85.js +1 -0
  119. package/dist/dashboard/assets/privacy-mode-GM8bej85.js.gz +0 -0
  120. package/dist/dashboard/assets/providers/agentrouter.svg.gz +0 -0
  121. package/dist/dashboard/assets/providers/aihubmix.svg.gz +0 -0
  122. package/dist/dashboard/assets/providers/alibaba-coding-plan.svg.gz +0 -0
  123. package/dist/dashboard/assets/providers/alibaba.svg.gz +0 -0
  124. package/dist/dashboard/assets/providers/antigravity.svg.gz +0 -0
  125. package/dist/dashboard/assets/providers/bai.svg.gz +0 -0
  126. package/dist/dashboard/assets/providers/blackbox.svg.gz +0 -0
  127. package/dist/dashboard/assets/providers/claude-code.svg.gz +0 -0
  128. package/dist/dashboard/assets/providers/exa.svg.gz +0 -0
  129. package/dist/dashboard/assets/providers/gmi-cloud.svg.gz +0 -0
  130. package/dist/dashboard/assets/providers/inferhub.svg.gz +0 -0
  131. package/dist/dashboard/assets/providers/muse.svg.gz +0 -0
  132. package/dist/dashboard/assets/providers/nous-research.svg.gz +0 -0
  133. package/dist/dashboard/assets/providers/openai-light.svg.gz +0 -0
  134. package/dist/dashboard/assets/providers/parallel.svg.gz +0 -0
  135. package/dist/dashboard/assets/providers/tokenharbor.svg.gz +0 -0
  136. package/dist/dashboard/assets/providers/zai-coding-plan.svg.gz +0 -0
  137. package/dist/dashboard/assets/{query-keys-ROf-g1Wi.js → query-keys-D7wX2vM5.js} +1 -1
  138. package/dist/dashboard/assets/query-keys-D7wX2vM5.js.gz +0 -0
  139. package/dist/dashboard/assets/quota-DZGyp0N5.js.gz +0 -0
  140. package/dist/dashboard/assets/refresh-cw-BqJFk4sU.js.gz +0 -0
  141. package/dist/dashboard/assets/rotate-ccw-CufcqITi.js.gz +0 -0
  142. package/dist/dashboard/assets/route-CNSNj9Zm.js.gz +0 -0
  143. package/dist/dashboard/assets/{routing-jntw9-Ui.js → routing-BExgxxBU.js} +1 -1
  144. package/dist/dashboard/assets/routing-BExgxxBU.js.gz +0 -0
  145. package/dist/dashboard/assets/scaling-nWE4Cx8f.js.gz +0 -0
  146. package/dist/dashboard/assets/search-JOKus9rb.js.gz +0 -0
  147. package/dist/dashboard/assets/{select-CGUuTQmj.js → select-C9mMoEpJ.js} +1 -1
  148. package/dist/dashboard/assets/select-C9mMoEpJ.js.gz +0 -0
  149. package/dist/dashboard/assets/settings-C4_Gn_wq.js +1 -0
  150. package/dist/dashboard/assets/settings-C4_Gn_wq.js.gz +0 -0
  151. package/dist/dashboard/assets/shield-check-DVS0Foqh.js.gz +0 -0
  152. package/dist/dashboard/assets/sliders-horizontal-ClG7dBeJ.js.gz +0 -0
  153. package/dist/dashboard/assets/sparkles-BRaRUxAU.js.gz +0 -0
  154. package/dist/dashboard/assets/{stack-C1kTcOmP.js → stack-Ca5HxI5i.js} +1 -1
  155. package/dist/dashboard/assets/stack-Ca5HxI5i.js.gz +0 -0
  156. package/dist/dashboard/assets/{state-DHKdg-pm.js → state-CzUPfLXo.js} +1 -1
  157. package/dist/dashboard/assets/state-CzUPfLXo.js.gz +0 -0
  158. package/dist/dashboard/assets/stream-retry-5QnVuSSo.js.gz +0 -0
  159. package/dist/dashboard/assets/{switch-DIDeAiz9.js → switch-DOUIky_i.js} +1 -1
  160. package/dist/dashboard/assets/switch-DOUIky_i.js.gz +0 -0
  161. package/dist/dashboard/assets/terminal-Bd4ee8TA.js +1 -0
  162. package/dist/dashboard/assets/terminal-Bd4ee8TA.js.gz +0 -0
  163. package/dist/dashboard/assets/toast-C76WrMhc.js +2 -0
  164. package/dist/dashboard/assets/toast-C76WrMhc.js.gz +0 -0
  165. package/dist/dashboard/assets/tokens-DDxMbSRL.js.gz +0 -0
  166. package/dist/dashboard/assets/trash-Cppn5c3R.js.gz +0 -0
  167. package/dist/dashboard/assets/use-clipboard-B0Sy03fE.js.gz +0 -0
  168. package/dist/dashboard/assets/use-presence-BSZEBx7U.js.gz +0 -0
  169. package/dist/dashboard/assets/use-reduced-motion-C4B0R6Sz.js.gz +0 -0
  170. package/dist/dashboard/assets/use-timeout-BwcBBRrT.js.gz +0 -0
  171. package/dist/dashboard/assets/{useMutation-DVmwOKU-.js → useMutation-D87FZwH0.js} +1 -1
  172. package/dist/dashboard/assets/useMutation-D87FZwH0.js.gz +0 -0
  173. package/dist/dashboard/assets/wrench-B6E1oEuY.js.gz +0 -0
  174. package/dist/dashboard/index.html +1 -1
  175. package/dist/dashboard/index.html.gz +0 -0
  176. package/dist/dashboard/precompressed-assets.json +123 -0
  177. package/migrations/full-postgres/0013_add_providers_tenant_id_index.sql +22 -0
  178. package/migrations/full-postgres/0014_add_provider_accounts_tenant_id_index.sql +21 -0
  179. package/migrations/lite-sqlite3/0000_baseline.sql +4 -2
  180. package/migrations/lite-sqlite3/0013_add_providers_tenant_id_index.sql +4 -0
  181. package/migrations/lite-sqlite3/0014_add_provider_accounts_tenant_id_index.sql +4 -0
  182. package/package.json +25 -30
  183. package/scripts/cli/{carte-launcher.cjs → bin/carte.cjs} +8 -8
  184. package/scripts/cli/{cli-launcher.cjs → bin/cartethyia.cjs} +167 -134
  185. package/scripts/cli/command/admin.ts +65 -0
  186. package/scripts/cli/command/console-menu.ts +227 -0
  187. package/scripts/cli/{doctor.ts → command/doctor.ts} +257 -258
  188. package/scripts/cli/{setup.ts → command/setup.ts} +8 -12
  189. package/scripts/cli/command/start.ts +13 -0
  190. package/scripts/cli/command/tray.ts +165 -0
  191. package/scripts/cli/command/update.ts +23 -0
  192. package/scripts/cli/{runtime-state.ts → gateway/drain-token.ts} +78 -78
  193. package/scripts/cli/gateway/spawn.ts +101 -0
  194. package/scripts/cli/gateway/stop.ts +51 -0
  195. package/scripts/cli/main.ts +94 -0
  196. package/scripts/cli/support/bun.ts +27 -0
  197. package/scripts/cli/support/env.ts +99 -0
  198. package/scripts/cli/support/platform.ts +64 -0
  199. package/scripts/cli/support/services.ts +88 -0
  200. package/scripts/cli/support/startup-signal.ts +23 -0
  201. package/scripts/cli/{version.ts → support/version.ts} +1 -1
  202. package/scripts/cli/tray/hidden-window.vbs +28 -0
  203. package/scripts/cli/{tray.ts → tray/index.ts} +3 -3
  204. package/scripts/cli/{tray.ps1 → tray/windows.ps1} +107 -98
  205. package/scripts/cli/ui/background.ts +49 -0
  206. package/src/boot/app.ts +3 -1
  207. package/src/boot/dependencies.ts +36 -13
  208. package/src/config/providers.ts +27 -1
  209. package/src/config/spec.ts +11 -0
  210. package/src/console/backup/routes.ts +8 -0
  211. package/src/console/cli-tools/contracts.ts +6 -6
  212. package/src/console/console-router.ts +5 -3
  213. package/src/console/dashboard-assets.ts +81 -11
  214. package/src/console/observability/contracts.ts +54 -22
  215. package/src/console/observability/routes.ts +31 -6
  216. package/src/console/observability/store.ts +68 -28
  217. package/src/console/providers/catalog/public-model-store.ts +91 -60
  218. package/src/console/providers/catalog/store.ts +5 -1
  219. package/src/console/quota/account-quota-tenant.ts +9 -6
  220. package/src/console/response-compression.ts +69 -2
  221. package/src/console/routing/pools/probe-verdict.ts +72 -0
  222. package/src/console/share/share-router.ts +7 -4
  223. package/src/console/share/share-stats.ts +12 -5
  224. package/src/console/shared/read-through-cache.ts +133 -7
  225. package/src/console/updates/repository-updates.ts +39 -34
  226. package/src/network/outbound-fetch.ts +113 -25
  227. package/src/network/tls/bun-client-hello.ts +112 -0
  228. package/src/observability/event-log.ts +29 -8
  229. package/src/observability/metrics.ts +40 -13
  230. package/src/observability/runtime-metrics.ts +7 -0
  231. package/src/persistence/schema.ts +40 -2
  232. package/src/protocol/response/codex.ts +9 -75
  233. package/src/protocol/tool-identity.ts +43 -9
  234. package/src/providers/authentication/jwt-validator.ts +130 -187
  235. package/src/providers/authentication/oauth-flow-store.ts +16 -9
  236. package/src/providers/default-registry.ts +4 -0
  237. package/src/providers/discovery/catalog/catalog-format.ts +20 -0
  238. package/src/providers/discovery/models-dev-catalog.ts +39 -22
  239. package/src/providers/discovery/probe-wire.ts +29 -5
  240. package/src/providers/integrations/buddy/buddy-catalog-shared.ts +3 -2
  241. package/src/providers/integrations/buddy/buddy-cli-headers.ts +88 -0
  242. package/src/providers/integrations/buddy/buddy-conversation-headers.ts +4 -0
  243. package/src/providers/integrations/buddy/buddy-discovery-shared.ts +33 -1
  244. package/src/providers/integrations/buddy/buddy-identity.ts +46 -0
  245. package/src/providers/integrations/buddy/buddy-oauth-shared.ts +37 -1
  246. package/src/providers/integrations/buddy/buddy-usage-store.ts +40 -5
  247. package/src/providers/integrations/buddy/codebuddy/provider.ts +2 -2
  248. package/src/providers/integrations/buddy/codebuddy/shared.ts +106 -73
  249. package/src/providers/integrations/buddy/workbuddy/cn-provider.ts +5 -2
  250. package/src/providers/integrations/buddy/workbuddy/quota.ts +172 -159
  251. package/src/providers/integrations/buddy/workbuddy/shared.ts +200 -171
  252. package/src/providers/integrations/codex/codex.ts +5 -8
  253. package/src/providers/integrations/factory/factory-oauth.ts +7 -11
  254. package/src/providers/integrations/opencode/opencode-go-quota.ts +130 -0
  255. package/src/providers/integrations/opencode/provider.ts +42 -3
  256. package/src/providers/integrations/qoder/qoder-oauth.ts +3 -9
  257. package/src/providers/integrations/shared/install-id.ts +29 -2
  258. package/src/providers/operations/client-versions.ts +11 -1
  259. package/src/providers/operations/model-catalog-cache.ts +18 -3
  260. package/src/providers/operations/provider-catalog-service.ts +76 -0
  261. package/src/transport/dispatch/attempt-finalize.ts +7 -1
  262. package/src/transport/dispatch/attempt-loop.ts +9 -7
  263. package/src/transport/dispatch/empty-stream.ts +17 -26
  264. package/src/transport/failure-policy.ts +20 -0
  265. package/src/transport/routing/route-catalog.ts +42 -3
  266. package/src/transport/surface/chat/encode.ts +18 -31
  267. package/src/transport/surface/messages/stream.ts +3 -2
  268. package/src/transport/translation/tool-repair.ts +37 -16
  269. package/src/workers/account-identity-sweep.ts +14 -12
  270. package/src/workers/daily-checkin.ts +62 -0
  271. package/src/workers/quota-refresh-sweep.ts +27 -2
  272. package/dist/dashboard/assets/CombosPage-B7ETkfVH.js +0 -1
  273. package/dist/dashboard/assets/ConsoleLogPage-AisQzCI1.js +0 -1
  274. package/dist/dashboard/assets/HealthEventsModal-BNflDm_f.js +0 -1
  275. package/dist/dashboard/assets/ModelPicker-CAiPR5Vd.js +0 -1
  276. package/dist/dashboard/assets/ProviderDetailPage-BK8i5B5X.js +0 -3
  277. package/dist/dashboard/assets/ProvidersPage-CvCVgm1e.js +0 -1
  278. package/dist/dashboard/assets/ProxyPage-DjabkuzP.js +0 -7
  279. package/dist/dashboard/assets/QuotaPage-Dxe0B0iu.js +0 -1
  280. package/dist/dashboard/assets/SetupPage-BaD4P_jW.js +0 -1
  281. package/dist/dashboard/assets/SortableList-Bol1ASmI.js +0 -1
  282. package/dist/dashboard/assets/StudioPage-CLP4le3U.js +0 -31
  283. package/dist/dashboard/assets/api-keys-CjIGQhe7.js +0 -1
  284. package/dist/dashboard/assets/app-Cjgo4-Lf.css +0 -2
  285. package/dist/dashboard/assets/cli-tools-DccaOfhX.js +0 -1
  286. package/dist/dashboard/assets/clipboard-button-DQmMikNQ.js +0 -1
  287. package/dist/dashboard/assets/entry-Cpi0rph1.js +0 -4
  288. package/dist/dashboard/assets/entry-Dm3PpDCO.js +0 -1
  289. package/dist/dashboard/assets/entry-keNDZ_Wz.js +0 -1
  290. package/dist/dashboard/assets/github-badge-BuAJr8oU.js +0 -1
  291. package/dist/dashboard/assets/mutation-BIizFZav.js +0 -1
  292. package/dist/dashboard/assets/privacy-mode-CQjbZ3xk.js +0 -1
  293. package/dist/dashboard/assets/settings-BAS6QbmJ.js +0 -1
  294. package/dist/dashboard/assets/terminal-C9mZ4Ugm.js +0 -1
  295. package/dist/dashboard/assets/toast-DKAdP2mo.js +0 -2
  296. package/scripts/cli/cli.ts +0 -771
  297. package/scripts/operations/probe-egress.ts +0 -335
  298. package/scripts/operations/restart.ts +0 -120
  299. package/scripts/shared/env.ts +0 -269
  300. /package/{tray-assets → scripts/cli/tray/assets}/cartethyia.ico +0 -0
  301. /package/{tray-assets → scripts/cli/tray/assets}/cartethyia.png +0 -0
  302. /package/scripts/cli/{menu.ts → ui/prompt.ts} +0 -0
  303. /package/scripts/cli/{wordmark.ts → ui/wordmark.ts} +0 -0
@@ -8,7 +8,7 @@ import {
8
8
  type TelemetryEventRecord,
9
9
  } from "../../observability/event-log";
10
10
  import { isGatewayError } from "../../observability/telemetry-status";
11
- import { cachedRead } from "../shared/read-through-cache";
11
+ import { cachedReadWithEtag, type CachedResult } from "../shared/read-through-cache";
12
12
  import { decodeDatedCursor, encodeCursor } from "../shared/cursor";
13
13
  import { CARTETHYIA_VERSION } from "../../version";
14
14
  import { CachedPreferencesReader, DrizzlePreferencesReader, type PreferencesReader } from "../../persistence/tenant-preferences";
@@ -298,8 +298,14 @@ export class DrizzleObservabilityStore implements ObservabilityStore {
298
298
  ),
299
299
  ) {}
300
300
 
301
- async health(tenantId: string): Promise<SystemHealthResponse> {
302
- return this.computeHealth(tenantId);
301
+ async health(tenantId: string): Promise<CachedResult<SystemHealthResponse>> {
302
+ // The only usage-family read that used to bypass both the 5 s in-process
303
+ // memo and the Redis read-through, so it re-parsed 24 hourly files every
304
+ // page load. Same 10 s TTL as `/system/usage`.
305
+ return this.readThroughWithEtag(
306
+ this.usageCacheKey(tenantId, "24h", "health"),
307
+ () => this.computeHealth(tenantId),
308
+ );
303
309
  }
304
310
 
305
311
  private async computeHealth(tenantId: string): Promise<SystemHealthResponse> {
@@ -401,8 +407,8 @@ export class DrizzleObservabilityStore implements ObservabilityStore {
401
407
  avg_tokens_per_sec: avgTokensPerSec,
402
408
  };
403
409
  }
404
- async usage(tenantId: string, period: string): Promise<UsageResponse> {
405
- return this.readThrough(this.usageCacheKey(tenantId, period, "usage"), () =>
410
+ async usage(tenantId: string, period: string): Promise<CachedResult<UsageResponse>> {
411
+ return this.readThroughWithEtag(this.usageCacheKey(tenantId, period, "usage"), () =>
406
412
  this.computeUsage(tenantId, period),
407
413
  );
408
414
  }
@@ -415,9 +421,13 @@ export class DrizzleObservabilityStore implements ObservabilityStore {
415
421
  return `cartethyia:usage:v1:${shape}:${tenantId}:${period}`;
416
422
  }
417
423
 
418
- /** Reads a usage response through Redis; computes when no cache is configured. */
419
- private readThrough<T>(key: string, compute: () => Promise<T>): Promise<T> {
420
- return cachedRead(this.redis, key, compute);
424
+ /**
425
+ * Reads a usage response through Redis — computing when no cache is
426
+ * configured — and returns the strong validator alongside it, so a repeated
427
+ * poll can be answered with a `304` instead of the whole payload.
428
+ */
429
+ private readThroughWithEtag<T>(key: string, compute: () => Promise<T>): Promise<CachedResult<T>> {
430
+ return cachedReadWithEtag(this.redis, key, compute);
421
431
  }
422
432
 
423
433
  private async computeUsage(tenantId: string, period: string): Promise<UsageResponse> {
@@ -467,8 +477,8 @@ export class DrizzleObservabilityStore implements ObservabilityStore {
467
477
  return promise;
468
478
  }
469
479
 
470
- async usageSummary(tenantId: string, period: string): Promise<UsageSummaryResponse> {
471
- return this.readThrough(this.usageCacheKey(tenantId, period, "summary"), () =>
480
+ async usageSummary(tenantId: string, period: string): Promise<CachedResult<UsageSummaryResponse>> {
481
+ return this.readThroughWithEtag(this.usageCacheKey(tenantId, period, "summary"), () =>
472
482
  this.computeSummary(tenantId, period),
473
483
  );
474
484
  }
@@ -527,8 +537,8 @@ export class DrizzleObservabilityStore implements ObservabilityStore {
527
537
  };
528
538
  }
529
539
 
530
- async usageChart(tenantId: string, period: string): Promise<UsageChartResponse> {
531
- return this.readThrough(this.usageCacheKey(tenantId, period, "chart"), () =>
540
+ async usageChart(tenantId: string, period: string): Promise<CachedResult<UsageChartResponse>> {
541
+ return this.readThroughWithEtag(this.usageCacheKey(tenantId, period, "chart"), () =>
532
542
  this.computeChart(tenantId, period),
533
543
  );
534
544
  }
@@ -568,8 +578,8 @@ export class DrizzleObservabilityStore implements ObservabilityStore {
568
578
  tenantId: string,
569
579
  dimension: UsageDimension,
570
580
  period: string,
571
- ): Promise<UsageByResponse> {
572
- return this.readThrough(this.usageCacheKey(tenantId, `${dimension}:${period}`, "by"), () =>
581
+ ): Promise<CachedResult<UsageByResponse>> {
582
+ return this.readThroughWithEtag(this.usageCacheKey(tenantId, `${dimension}:${period}`, "by"), () =>
573
583
  this.computeBy(tenantId, dimension, period),
574
584
  );
575
585
  }
@@ -676,8 +686,8 @@ export class DrizzleObservabilityStore implements ObservabilityStore {
676
686
  return new Map(keyRows.map((keyRow) => [keyRow.id, keyRow.label] as const));
677
687
  }
678
688
 
679
- async usageCache(tenantId: string, period: string): Promise<UsageCacheResponse> {
680
- return this.readThrough(this.usageCacheKey(tenantId, period, "cache"), () =>
689
+ async usageCache(tenantId: string, period: string): Promise<CachedResult<UsageCacheResponse>> {
690
+ return this.readThroughWithEtag(this.usageCacheKey(tenantId, period, "cache"), () =>
681
691
  this.computeCache(tenantId, period),
682
692
  );
683
693
  }
@@ -709,7 +719,7 @@ export class DrizzleObservabilityStore implements ObservabilityStore {
709
719
  period: string,
710
720
  limit: number,
711
721
  httpStatus?: number,
712
- ): Promise<UsageRequestsResponse> {
722
+ ): Promise<CachedResult<UsageRequestsResponse>> {
713
723
  // Ask the reader for `limit` rows instead of folding the whole window and
714
724
  // slicing it. It stops at the newest file that fills the page, so the
715
725
  // console's most-polled endpoint no longer parses the retention window just
@@ -723,15 +733,29 @@ export class DrizzleObservabilityStore implements ObservabilityStore {
723
733
  });
724
734
  const rows = events.slice(0, limit);
725
735
  const hideClientIp = await shouldMaskClientIp(this.preferences, tenantId);
726
- const keyIds = [...new Set(rows.map((row) => row.apiKeyId).filter((id): id is string => id !== null))];
727
- const labels = await this.apiKeyLabels(tenantId, keyIds);
728
- return {
729
- items: rows.map((row) => {
730
- const item = mapUsageRequestItem(row, hideClientIp);
731
- const label = row.apiKeyId ? labels.get(row.apiKeyId) : undefined;
732
- return label === undefined ? item : { ...item, apiKeyLabel: label };
733
- }),
734
- };
736
+ // Cached like its siblings, so a repeated poll costs a `304` instead of
737
+ // another read. The mask flag is part of the key: it is a privacy setting,
738
+ // so a page must never be served from an entry computed under the other
739
+ // value. The rows were already read above — this bounds the label lookup
740
+ // and the mapping, not the telemetry read.
741
+ return this.readThroughWithEtag(
742
+ this.usageCacheKey(
743
+ tenantId,
744
+ `${period}:${limit}:${httpStatus ?? "*"}:${hideClientIp}`,
745
+ "requests",
746
+ ),
747
+ async () => {
748
+ const keyIds = [...new Set(rows.map((row) => row.apiKeyId).filter((id): id is string => id !== null))];
749
+ const labels = await this.apiKeyLabels(tenantId, keyIds);
750
+ return {
751
+ items: rows.map((row) => {
752
+ const item = mapUsageRequestItem(row, hideClientIp);
753
+ const label = row.apiKeyId ? labels.get(row.apiKeyId) : undefined;
754
+ return label === undefined ? item : { ...item, apiKeyLabel: label };
755
+ }),
756
+ };
757
+ },
758
+ );
735
759
  }
736
760
  async usageRequestDetail(
737
761
  tenantId: string,
@@ -795,7 +819,19 @@ export class DrizzleObservabilityStore implements ObservabilityStore {
795
819
  cursor?: string,
796
820
  ): Promise<TelemetryEventListPage> {
797
821
  const before = decodeDatedCursor(cursor);
798
- const events = await queryTelemetryEvents({ tenantId, ...(before ? {} : { limit: limit + 1 }) });
822
+ // The cursor becomes an `until` bound inside the query, so `limit` applies
823
+ // to every page and not only the first. Filtering after the read forced the
824
+ // previous shape: a cursor page had to fetch the whole window to know where
825
+ // it ended, so `limit` was dropped the moment a cursor appeared.
826
+ //
827
+ // `until` is inclusive while the cursor is exclusive, so rows at the exact
828
+ // cursor timestamp are still compared by id here — the cheap part of the
829
+ // predicate, now over one timestamp's worth of rows instead of the window.
830
+ const events = await queryTelemetryEvents({
831
+ tenantId,
832
+ limit: limit + 1,
833
+ ...(before === undefined ? {} : { until: new Date(before.createdAt) }),
834
+ });
799
835
  const rows = (before
800
836
  ? events.filter((event) => event.createdAt < before.createdAt || (event.createdAt === before.createdAt && event.id < before.id))
801
837
  : events
@@ -817,7 +853,11 @@ export class DrizzleObservabilityStore implements ObservabilityStore {
817
853
  includePayload = false,
818
854
  createdAt?: string,
819
855
  ): Promise<TelemetryEventDetail | undefined> {
820
- const events = await queryTelemetryEvents({ tenantId });
856
+ // Matched by `id`, which is the field the list cursor encodes — not by
857
+ // `requestId`, which is a different column that merely defaults to the
858
+ // same value. Predicating on it lets the reader stop at the first hit
859
+ // instead of parsing the retention window to find one row.
860
+ const events = await queryTelemetryEvents({ tenantId, id: eventId });
821
861
  const event = events.find(
822
862
  (candidate) =>
823
863
  candidate.id === eventId && (createdAt === undefined || candidate.createdAt === createdAt),
@@ -104,16 +104,6 @@ function commonCost(entries: readonly unknown[]): unknown {
104
104
  return present.every((entry) => costSignature(entry) === signature) ? present[0] : undefined;
105
105
  }
106
106
 
107
- function matchesModelPrefix(
108
- modelPrefix: string | undefined,
109
- bareModelId: string,
110
- qualifiedModelId?: string,
111
- ): boolean {
112
- const prefix = modelPrefix?.trim();
113
- if (!prefix) return true;
114
- return bareModelId.startsWith(prefix) || qualifiedModelId?.startsWith(prefix) === true;
115
- }
116
-
117
107
  /** Catalog metadata mirrored onto an alias or combo entry. */
118
108
  export interface ModelMetadata {
119
109
  readonly contextLimit: number | null;
@@ -153,15 +143,42 @@ export function resolveTargetIds(
153
143
  name: string,
154
144
  aliasTargets: ReadonlyMap<string, string>,
155
145
  comboMembers: ReadonlyMap<string, readonly string[]>,
156
- seen: ReadonlySet<string> = new Set(),
146
+ seen: Set<string> = new Set(),
147
+ ): string[] {
148
+ return collectTargetIds(name, aliasTargets, comboMembers, seen);
149
+ }
150
+
151
+ /**
152
+ * Walks alias → combo → member with one mutable visited set.
153
+ *
154
+ * The previous form copied the whole set per edge (`new Set(seen).add(name)`),
155
+ * which is called per alias and per combo row over the whole catalog. Adding
156
+ * and removing on one set keeps the same cycle protection — each branch still
157
+ * sees its own ancestors — without an allocation per step.
158
+ */
159
+ function collectTargetIds(
160
+ name: string,
161
+ aliasTargets: ReadonlyMap<string, string>,
162
+ comboMembers: ReadonlyMap<string, readonly string[]>,
163
+ seen: Set<string>,
157
164
  ): string[] {
158
165
  if (seen.has(name) || seen.size >= MAX_TARGET_DEPTH) return [];
159
- const next = new Set(seen).add(name);
160
- const members = comboMembers.get(name);
161
- if (members) return members.flatMap((m) => resolveTargetIds(m, aliasTargets, comboMembers, next));
162
- const target = aliasTargets.get(name);
163
- if (target !== undefined) return resolveTargetIds(target, aliasTargets, comboMembers, next);
164
- return [name];
166
+ seen.add(name);
167
+ try {
168
+ const members = comboMembers.get(name);
169
+ if (members) {
170
+ return members.flatMap((m) => collectTargetIds(m, aliasTargets, comboMembers, seen));
171
+ }
172
+ const target = aliasTargets.get(name);
173
+ if (target !== undefined) {
174
+ return collectTargetIds(target, aliasTargets, comboMembers, seen);
175
+ }
176
+ return [name];
177
+ } finally {
178
+ // Removing on the way out keeps `seen` meaning "ancestors on this path",
179
+ // so a name reachable by two sibling branches still resolves in both.
180
+ seen.delete(name);
181
+ }
165
182
  }
166
183
 
167
184
  /**
@@ -339,10 +356,21 @@ export class PublicModelCatalogStore {
339
356
  return result;
340
357
  }
341
358
 
359
+ /**
360
+ * Lists the models this key may see.
361
+ *
362
+ * `modelPrefix` is accepted and currently has NO effect on the result. It
363
+ * used to feed a `matchesModelPrefix(...) || isModelAllowed(...)` disjunct
364
+ * that was immediately followed by `isModelAllowed(...)` on its own — so the
365
+ * prefix could never add a row, and dropping it changes nothing. The
366
+ * parameter is kept so the call sites and the auth shape stay stable; if a
367
+ * real prefix filter is wanted later it needs to be an AND, not the OR it
368
+ * was written as.
369
+ */
342
370
  async listPublicModels(
343
371
  tenantId: string | null,
344
372
  snapshot: ApiKeyAuthorizationSnapshot,
345
- modelPrefix?: string,
373
+ _modelPrefix?: string,
346
374
  ): Promise<AllowedModelEntry[]> {
347
375
  const providerScope =
348
376
  tenantId === null
@@ -407,40 +435,46 @@ export class PublicModelCatalogStore {
407
435
  if (typeof combo.name === "string") comboNames.add(combo.name);
408
436
  }
409
437
 
410
- const modelEntries: AllowedModelEntry[] = rows
411
- .filter((row) => {
412
- // A provider is listed only when the tenant has an active account for
413
- // it — the same rule the console model picker applies. A no-auth
414
- // provider with nothing configured is not "set", so it stays hidden
415
- // until the operator adds an account.
416
- if (tenantId === null) return row.providerRequiresAccount === false;
417
- return activeProviderIds.has(row.providerId);
418
- })
419
- .filter((row) =>
420
- matchesModelPrefix(modelPrefix, row.modelId, `${row.providerId}/${row.modelId}`) ||
421
- isModelAllowed(snapshot, row.modelId, row.providerId),
422
- )
423
- .filter((m) => isModelAllowed(snapshot, m.modelId, m.providerId))
424
- .filter((m) => !shadowsAliasOrCombo(m, aliasTargets, comboNames, snapshot))
425
- .map((m) => {
426
- const capabilities = normalizeModalities(m.modalities);
427
- return {
428
- id: `${m.providerId}/${m.modelId}`,
429
- object: "model" as const,
430
- created: Math.floor(Date.now() / 1000),
431
- owned_by: m.providerId,
432
- ...(m.contextLimit != null ? { context_length: m.contextLimit } : {}),
433
- ...(m.outputLimit != null ? { max_completion_tokens: m.outputLimit } : {}),
434
- ...(capabilities ? { capabilities } : {}),
435
- ...(m.reasoning ? { reasoning: true } : {}),
436
- ...(m.toolCall ? { tool_call: true } : {}),
437
- ...(m.cost != null ? { cost: m.cost } : {}),
438
- // Only a non-`llm` row is labelled: absence already means "chat wire".
439
- ...(m.serviceKind && m.serviceKind !== "llm"
440
- ? { service_kind: m.serviceKind }
441
- : {}),
442
- };
438
+ // One clock reading for the whole response: `created` is the same
439
+ // timestamp for every entry, and reading it per row made the field vary
440
+ // across a single listing.
441
+ const now = Math.floor(Date.now() / 1000);
442
+ const modelEntries: AllowedModelEntry[] = [];
443
+ for (const row of rows) {
444
+ // A provider is listed only when the tenant has an active account for
445
+ // it — the same rule the console model picker applies. A no-auth
446
+ // provider with nothing configured is not "set", so it stays hidden
447
+ // until the operator adds an account.
448
+ const listed =
449
+ tenantId === null ? row.providerRequiresAccount === false : activeProviderIds.has(row.providerId);
450
+ if (!listed) continue;
451
+ const qualified = `${row.providerId}/${row.modelId}`;
452
+ // The prefix test is deliberately gone from this path: the chain used to
453
+ // be `(prefixMatch || isAllowed)` followed by `isAllowed`, which reduces
454
+ // to `isAllowed` alone — the prefix disjunct could never change the
455
+ // outcome, only cost a second full pass over the catalog. Collapsing the
456
+ // four chained filters into one loop also drops the three intermediate
457
+ // arrays they materialized.
458
+ if (!isModelAllowed(snapshot, row.modelId, row.providerId)) continue;
459
+ if (shadowsAliasOrCombo(row, aliasTargets, comboNames, snapshot)) continue;
460
+ const capabilities = normalizeModalities(row.modalities);
461
+ modelEntries.push({
462
+ id: qualified,
463
+ object: "model" as const,
464
+ created: now,
465
+ owned_by: row.providerId,
466
+ ...(row.contextLimit != null ? { context_length: row.contextLimit } : {}),
467
+ ...(row.outputLimit != null ? { max_completion_tokens: row.outputLimit } : {}),
468
+ ...(capabilities ? { capabilities } : {}),
469
+ ...(row.reasoning ? { reasoning: true } : {}),
470
+ ...(row.toolCall ? { tool_call: true } : {}),
471
+ ...(row.cost != null ? { cost: row.cost } : {}),
472
+ // Only a non-`llm` row is labelled: absence already means "chat wire".
473
+ ...(row.serviceKind && row.serviceKind !== "llm"
474
+ ? { service_kind: row.serviceKind }
475
+ : {}),
443
476
  });
477
+ }
444
478
 
445
479
  // Expose tenant model aliases and combos as first-class public models.
446
480
  // `aliasRows`/`comboRows` were loaded above for the shadow filter.
@@ -465,9 +499,10 @@ export class PublicModelCatalogStore {
465
499
  ].flat();
466
500
  const targetMeta = await this.modelMetadataById(tenantId, targetIds);
467
501
 
468
- const now = Math.floor(Date.now() / 1000);
502
+
469
503
  for (const a of aliasRows) {
470
- if (!matchesModelPrefix(modelPrefix, a.alias) && !isModelAllowed(snapshot, a.alias)) continue;
504
+ // `!A && !B` followed by `!B` — the second subsumes the first, so the
505
+ // prefix test never changed the outcome. Collapsed to the real gate.
471
506
  if (!isModelAllowed(snapshot, a.alias)) continue;
472
507
  const target = advertisedMetadata(resolveTargetIds(a.targetModel, aliasTargets, comboMembers), targetMeta);
473
508
  const capabilities = normalizeModalities(target.modalities);
@@ -486,7 +521,6 @@ export class PublicModelCatalogStore {
486
521
  }
487
522
 
488
523
  for (const c of comboRows) {
489
- if (!matchesModelPrefix(modelPrefix, c.name) && !isModelAllowed(snapshot, c.name)) continue;
490
524
  if (!isModelAllowed(snapshot, c.name)) continue;
491
525
  const target = advertisedMetadata(resolveTargetIds(c.name, aliasTargets, comboMembers), targetMeta);
492
526
  const capabilities = normalizeModalities(target.modalities);
@@ -648,12 +682,9 @@ export class PublicModelCatalogStore {
648
682
  )
649
683
  .limit(1);
650
684
  const row = rows[0];
651
- if (
652
- row &&
653
- (matchesModelPrefix(modelPrefix, row.modelId, `${row.providerId}/${row.modelId}`) ||
654
- isModelAllowed(snapshot, row.modelId, row.providerId)) &&
655
- isModelAllowed(snapshot, row.modelId, row.providerId)
656
- ) {
685
+ // Same reduction as the listing path: `(prefix || allowed) && allowed`
686
+ // is just `allowed`, and the prefix disjunct cost a redundant call.
687
+ if (row && isModelAllowed(snapshot, row.modelId, row.providerId)) {
657
688
  const capabilities = normalizeModalities(row.modalities);
658
689
  return {
659
690
  id: `${row.providerId}/${row.modelId}`,
@@ -17,7 +17,7 @@ import { listAccountHealthEvents, recoverAccount, type AccountHealthEventRecord
17
17
  import { invalidateCredentialCache } from "../../../providers/operations/provider-credential-service";
18
18
  import { encryptCredential, hashSecret } from "../../../security/crypto";
19
19
  import type { TelemetryBatchBuffer } from "../../../observability/telemetry-buffer";
20
- import type { BundledProviderCatalog } from "../../../providers/operations/provider-catalog-service";
20
+ import { bumpCatalogRevision, type BundledProviderCatalog } from "../../../providers/operations/provider-catalog-service";
21
21
  import type { AccountInflightReading, BuddyGrowthTaskLedgerEntry, ByokConnectionTestRequest, ByokConnectionTestResult, CreateProviderAccountBatchItemResult, CreateProviderAccountBatchResult, CreateProviderAccountRequest, CredentialMode, ModelCatalogEntry, ProbeAllAccountsResult, ProbeAllModelsResult, ProbeModelRequest, ProbeModelResult, ProviderAccountResponse, ProviderAccountTokenUsage, ProviderCatalogStore, ProviderRecord, SetModelEnabledRequest, UpdateModelMetadataRequest, UpdateProviderAccountRequest, ModelMetadataOverride } from "./contracts";
22
22
  import { isServiceKind, validateCompatibilityProfile } from "./contracts";
23
23
  import { ProviderProbingService, type ProbeOutboundResolver } from "../../../providers/discovery/probing-service";
@@ -487,6 +487,10 @@ export class DrizzleProviderCatalogStore implements ProviderCatalogStore {
487
487
  .returning({ id: providers.id });
488
488
  if (deleted.length === 0) return false;
489
489
  this.providerRegistry.unregister(providerId);
490
+ // The provider left the registry, so the compiled catalog no longer
491
+ // contains it — the snapshot's re-seed is gated on that revision, and a
492
+ // delete that did not bump it would leave the provider's model rows behind.
493
+ bumpCatalogRevision();
490
494
  return true;
491
495
  }
492
496
  async updateGlobal(
@@ -29,6 +29,7 @@ import type { ProviderId } from "../../providers/provider-contracts";
29
29
  import {
30
30
  DAILY_CHECKIN_PROVIDER_IDS,
31
31
  checkinAttemptedToday,
32
+ checkinAttemptedTodayBatch,
32
33
  dailyCheckinEgressForPass,
33
34
  markCheckinAttempted,
34
35
  } from "../../workers/daily-checkin";
@@ -334,12 +335,14 @@ export function registerAccountQuotaTenantRoutes(
334
335
  // request; only check-in-capable accounts can carry one, so only they
335
336
  // are read.
336
337
  const checkinCandidates = rows.filter((row) => supportsAccountCheckin(row.providerId));
337
- const checkinAttempted = new Map<string, boolean>();
338
- await Promise.all(
339
- checkinCandidates.map(async (row) => {
340
- checkinAttempted.set(row.id, await todayCheckinAttempted(db, redis, row.id));
341
- }),
342
- );
338
+ // One batched read for the whole page. Per-account reads made opening
339
+ // this route scale with the account count — a round trip each, on the
340
+ // route the dashboard polls — for a marker that is one key per account.
341
+ const checkinAttempted = await checkinAttemptedTodayBatch({
342
+ db,
343
+ redis,
344
+ accountIds: checkinCandidates.map((row) => row.id),
345
+ });
343
346
  // Real credit deductions, read from the stored snapshots rather than from
344
347
  // the upstream: the page renders every account at once, and re-hitting the
345
348
  // billing endpoint per account would make opening it scale with the
@@ -9,6 +9,18 @@
9
9
  * what the browser spends its time receiving, and shaving microseconds off a
10
10
  * handler cannot compete with removing 94% of the bytes.
11
11
  *
12
+ * ## Why the gate is a path allowlist inside the function
13
+ *
14
+ * The wrap is mounted on the console Elysia instance (`console-router.ts`),
15
+ * but Elysia's `use()` merges the child's `hoc` into the **root** app's ext
16
+ * (`node_modules/elysia/dist/base.js:886-887`). Measured: the wrap therefore
17
+ * runs for `/v1/*` too, so instance-level scoping cannot express "console
18
+ * only". A path test can, and it lives inside `gzipResponse` — the one place
19
+ * that runs on every request.
20
+ *
21
+ * Consequence: `/v1/models` was previously compressed as a side effect of
22
+ * that `hoc` propagation. It deliberately is not now.
23
+ *
12
24
  * ## Why this is scoped to the console, not the proxy
13
25
  *
14
26
  * `/v1/*` is deliberately excluded, for two independent reasons:
@@ -26,9 +38,26 @@
26
38
  * Below `MIN_COMPRESSIBLE_BYTES` the envelope, the header, and the CPU are all
27
39
  * pure loss: a 200-byte reply is not worth a gzip stream plus a browser
28
40
  * decompression step. The threshold sits where the saving stops being noise.
41
+ *
42
+ * ## Opting a route out
43
+ *
44
+ * A route that knows its body is already compressed — or is an encoded
45
+ * envelope that would gain nothing — sets {@link NO_COMPRESS_HEADER}. The
46
+ * honest-sounding alternative, declaring `content-encoding: gzip`, is a lie
47
+ * when the bytes on the wire are not gzip, and the client that believes it
48
+ * fails trying to inflate them.
29
49
  */
30
50
  import { gzipSync } from "node:zlib";
31
51
 
52
+ /**
53
+ * Header a route sets to say "do not compress this", popped before the
54
+ * response leaves.
55
+ *
56
+ * Deleted rather than left on the response, so it stays an internal signal
57
+ * between two layers in this process and never reaches a client or a cache.
58
+ */
59
+ export const NO_COMPRESS_HEADER = "x-cartethyia-no-compress";
60
+
32
61
  /**
33
62
  * Smallest body worth compressing.
34
63
  *
@@ -37,6 +66,19 @@ import { gzipSync } from "node:zlib";
37
66
  */
38
67
  export const MIN_COMPRESSIBLE_BYTES = 1024;
39
68
 
69
+ /**
70
+ * Largest body this module will compress, as a ceiling on the event-loop
71
+ * stall.
72
+ *
73
+ * `gzipSync` is synchronous, so its cost is paid out of the request's own
74
+ * turn and is not interruptible. `/console/api/backup/export` answers with a
75
+ * multi-MB JSON document; at that size level 6 costs tens of milliseconds,
76
+ * which is longer than an entire dashboard poll. Those bytes go to disk
77
+ * rather than being polled, so the compression ratio is the wrong thing to
78
+ * optimise — above this size the response is returned as-is.
79
+ */
80
+ export const MAX_COMPRESSIBLE_BYTES = 1024 * 1024;
81
+
40
82
  /** Content types worth compressing, matched by prefix. */
41
83
  const COMPRESSIBLE_TYPES: readonly string[] = [
42
84
  "application/json",
@@ -99,20 +141,42 @@ export function isCompressible(contentType: string | null, bodyLength: number):
99
141
  * type: `text/event-stream` is not in {@link COMPRESSIBLE_TYPES}, so a stream
100
142
  * can never reach the buffering path.
101
143
  *
102
- * The size gate runs on the **actual** byte length, not a declared
144
+ * The size gate runs on the **actual** byte length, not only a declared
103
145
  * `content-length`: Elysia does not set that header for a serialized object
104
- * (measured), so gating on it rejected every route worth compressing. The
146
+ * (measured), so gating on it alone rejected every route worth compressing. A
147
+ * declared length is still used as a fast path — it can skip the read for a
148
+ * small reply — but silence from the header must never mean "too small". The
105
149
  * content-type gate is what keeps a stream from being read here, and it is
106
150
  * checked before the body is touched.
107
151
  */
108
152
  export async function gzipResponse(request: Request, response: Response): Promise<Response> {
153
+ // Scoped to the paths this module documents. Instance-level scoping is not
154
+ // available here: the wrap's `hoc` propagates to the root app, so every
155
+ // route in the process — `/v1/*` included — reaches this function.
156
+ const pathname = new URL(request.url).pathname;
157
+ if (!pathname.startsWith("/console/api/") && pathname !== "/metrics") return response;
109
158
  if (request.method === "HEAD") return response;
110
159
  if (response.status === 204 || response.status === 304) return response;
111
160
  // Already encoded — by a route or by an upstream. Never encode twice.
112
161
  if (response.headers.has("content-encoding")) return response;
162
+ // A route that knows better: the body is an encoded envelope, or already
163
+ // compressed in a form this wire layer cannot describe. Deleted here so the
164
+ // signal stays internal and never reaches a client or a cache.
165
+ if (response.headers.has(NO_COMPRESS_HEADER)) {
166
+ response.headers.delete(NO_COMPRESS_HEADER);
167
+ return response;
168
+ }
113
169
  if (!acceptsGzip(request.headers.get("accept-encoding"))) return response;
114
170
  // Type first, before any read: this is what keeps an event stream out.
115
171
  if (!isCompressibleType(response.headers.get("content-type"))) return response;
172
+ // A bodiless reply has nothing to compress, and `arrayBuffer()` on a null
173
+ // body yields a 0-byte buffer that would rebuild into a non-null body.
174
+ if (response.body === null) return response;
175
+ // A declared length below the threshold skips the read entirely. It cannot
176
+ // be the only gate — Elysia sets no `content-length` for a serialized
177
+ // object (measured) — so the byte-length read below still has to exist.
178
+ const declared = response.headers.get("content-length");
179
+ if (declared !== null && Number(declared) < MIN_COMPRESSIBLE_BYTES) return response;
116
180
 
117
181
  const body = await response.arrayBuffer();
118
182
  // Below the threshold the envelope, the header, and the CPU are all pure
@@ -120,6 +184,9 @@ export async function gzipResponse(request: Request, response: Response): Promis
120
184
  // than compressed. A response that was read must always be rebuilt, or the
121
185
  // client would receive an empty body.
122
186
  if (body.byteLength < MIN_COMPRESSIBLE_BYTES) return rebuild(response, body);
187
+ // Above the ceiling the synchronous `gzipSync` stall costs more than the
188
+ // bytes it would save. Same rebuild obligation as the threshold path.
189
+ if (body.byteLength > MAX_COMPRESSIBLE_BYTES) return rebuild(response, body);
123
190
 
124
191
  const compressed = gzipSync(new Uint8Array(body), { level: GZIP_LEVEL });
125
192
  const headers = new Headers(response.headers);
@@ -58,3 +58,75 @@ export function classifyProviderProbeAnswer(httpStatus: number, body: string): P
58
58
  reason: `Reachable; upstream answered ${httpStatus} for the probe body`,
59
59
  };
60
60
  }
61
+
62
+ /**
63
+ * Error codes that mean "this attempt never reached the upstream", not "the
64
+ * upstream refused this address".
65
+ *
66
+ * Every one of them is worth a second attempt, and the reason is the same in
67
+ * each case: the failure is a property of one socket, not of the egress. A
68
+ * proxy that has just been dialled for the first time has to resolve, open a
69
+ * tunnel, and complete a TLS handshake, and any of those steps can be refused
70
+ * or reset by a middlebox that accepts the very next attempt — which is exactly
71
+ * the "fails once, works on retry" report. Retiring the pool on the first one
72
+ * blames the address for a cold-start race.
73
+ */
74
+ const RETRYABLE_TRANSPORT_CODES = new Set([
75
+ "ECONNREFUSED",
76
+ "ECONNRESET",
77
+ "ECONNABORTED",
78
+ "EHOSTUNREACH",
79
+ "ENETUNREACH",
80
+ "ENETRESET",
81
+ "EPIPE",
82
+ "ETIMEDOUT",
83
+ "EAI_AGAIN",
84
+ "UND_ERR_SOCKET",
85
+ "UND_ERR_CONNECT_TIMEOUT",
86
+ "ECONNECT_TIMEOUT",
87
+ ]);
88
+ /** How many `cause` links to follow; wraps are shallow in practice. */
89
+ const PROBE_ERROR_CAUSE_DEPTH = 5;
90
+
91
+ /**
92
+ * Whether one probe error should be retried before it becomes a verdict.
93
+ *
94
+ * Deliberately narrow: an abort is *our* deadline firing, so retrying it would
95
+ * spend the whole probe budget on a slow egress, and an unknown error is left
96
+ * alone rather than guessed at. Only transport codes that describe a single
97
+ * dead connection qualify.
98
+ */
99
+ export function isRetryableProbeError(error: unknown): boolean {
100
+
101
+ // Walk the `cause` chain: a transport failure is routinely wrapped — the
102
+ // proxy layer raises `ProxyConnectHandshakeError` whose *message* carries the
103
+ // real code, with nothing on `code`. Checking only the top error would miss
104
+ // exactly the failure this exists for.
105
+ let current: unknown = error;
106
+ for (let depth = 0; depth < PROBE_ERROR_CAUSE_DEPTH && current !== undefined && current !== null; depth += 1) {
107
+ if (!(current instanceof Error)) break;
108
+ if (current.name === "AbortError" || current.name === "TimeoutError") return false;
109
+ // `code` is not on the base `Error`; narrow before reading it rather than
110
+ // asserting a shape the runtime may not have.
111
+ const code: string | undefined =
112
+ "code" in current && typeof current.code === "string" ? current.code : undefined;
113
+ if (code !== undefined && RETRYABLE_TRANSPORT_CODES.has(code.toUpperCase())) return true;
114
+ const message = String(current.message ?? "");
115
+ // The wrapped code is often only in the message, so match it there too.
116
+ for (const candidate of RETRYABLE_TRANSPORT_CODES) {
117
+ if (message.includes(candidate)) return true;
118
+ }
119
+ if (
120
+ message.includes("fetch failed") ||
121
+ message.includes("socket connection was closed unexpectedly") ||
122
+ message.includes("other side closed") ||
123
+ message.startsWith("terminated") ||
124
+ /connection (?:was|is) closed/i.test(message)
125
+ ) {
126
+ return true;
127
+ }
128
+ current = current.cause;
129
+ }
130
+ return false;
131
+ }
132
+
@@ -33,11 +33,14 @@ import {
33
33
  const MIN_TOKEN_LENGTH = 20;
34
34
 
35
35
  /**
36
- * Cadence of the share stats SSE snapshot. Short enough to read as live;
37
- * the rollup itself is a handful of indexed aggregates, so re-reading on this
38
- * interval is cheap next to the per-request telemetry insert it summarizes.
36
+ * Cadence of the share stats SSE snapshot.
37
+ *
38
+ * Raised with `SHARE_STATS_CACHE_TTL_SECONDS` (10 s). At 2 s the tick was
39
+ * shorter than the rollup's own cache TTL, so every push re-parsed the whole
40
+ * retained telemetry window — the interval was the thing making this read
41
+ * expensive, not the number of viewers. Short enough to still read as live.
39
42
  */
40
- const SHARE_STATS_STREAM_INTERVAL_MS = 2_000;
43
+ const SHARE_STATS_STREAM_INTERVAL_MS = 10_000;
41
44
 
42
45
  /**
43
46
  * Context and capabilities for one allowed model, sourced from the public