cartethyia 2.4.4 → 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 (304) 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-TDue2X0p.js → BannedPage-QhvbJB9o.js} +1 -1
  5. package/dist/dashboard/assets/BannedPage-QhvbJB9o.js.gz +0 -0
  6. package/dist/dashboard/assets/{CliToolDetailPage-qaHXuiI5.js → CliToolDetailPage-Bcy150Jo.js} +1 -1
  7. package/dist/dashboard/assets/CliToolDetailPage-Bcy150Jo.js.gz +0 -0
  8. package/dist/dashboard/assets/{CliToolsPage-BFuQBKez.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-BhglcqyR.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-CeqROJrT.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-CRXSAITY.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-B6LA4ewF.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-BA_ni9Ix.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/support/version.ts +37 -0
  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-vkZBxMVo.js +0 -1
  274. package/dist/dashboard/assets/HealthEventsModal-DDkHfwpV.js +0 -1
  275. package/dist/dashboard/assets/ModelPicker-CAiPR5Vd.js +0 -1
  276. package/dist/dashboard/assets/ProviderDetailPage-cNAD_6uN.js +0 -3
  277. package/dist/dashboard/assets/ProvidersPage-Cv5tdxlx.js +0 -1
  278. package/dist/dashboard/assets/ProxyPage-CWvMfzjj.js +0 -7
  279. package/dist/dashboard/assets/QuotaPage-IYQOOHR4.js +0 -1
  280. package/dist/dashboard/assets/SetupPage-BPDNo3Ek.js +0 -1
  281. package/dist/dashboard/assets/SortableList-Bol1ASmI.js +0 -1
  282. package/dist/dashboard/assets/StudioPage-BE5DJONZ.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-DTiWhIY-.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 -766
  297. package/scripts/cli/version.ts +0 -66
  298. package/scripts/operations/probe-egress.ts +0 -335
  299. package/scripts/operations/restart.ts +0 -120
  300. package/scripts/shared/env.ts +0 -269
  301. /package/{tray-assets → scripts/cli/tray/assets}/cartethyia.ico +0 -0
  302. /package/{tray-assets → scripts/cli/tray/assets}/cartethyia.png +0 -0
  303. /package/scripts/cli/{menu.ts → ui/prompt.ts} +0 -0
  304. /package/scripts/cli/{wordmark.ts → ui/wordmark.ts} +0 -0
@@ -7,6 +7,7 @@ import type { RedisClient } from "../persistence/redis";
7
7
  import {
8
8
  bundledModelCatalog,
9
9
  liveProviderUpstreamHosts,
10
+ markCatalogSeeded,
10
11
  refreshActiveProviderCatalogs,
11
12
  registerByokProviders,
12
13
  retireUnbundledProviders,
@@ -61,6 +62,7 @@ import {
61
62
  } from "../workers/buddy-growth-tasks";
62
63
  import { createAccountSecretResolver } from "../providers/operations/provider-credential-service";
63
64
  import { quotaCacheSize } from "../console/quota/cache";
65
+ import { modelCatalogCacheSize } from "../providers/operations/model-catalog-cache";
64
66
  import { preferencesReaderFor } from "../persistence/tenant-preferences";
65
67
  import { sweepExpiredCooldowns } from "../providers/operations/account-health-service";
66
68
  import { DrizzleTelemetryStore } from "../persistence/stores/telemetry-store";
@@ -130,7 +132,9 @@ const OAUTH_REFRESH_INTER_ITEM_DELAY_MS = 1_500;
130
132
  * Client identities are refreshed out of band so provider dispatch never waits
131
133
  * on npm/provider version endpoints. Resolver TTLs deduplicate the sources.
132
134
  */
133
- const CLIENT_VERSION_MONITOR_INTERVAL_MS = 15 * 60_000;
135
+ // Matched to the resolver's own 30 min cache TTL: at 15 min every second pass
136
+ // was a pure cache read that could not discover anything new.
137
+ const CLIENT_VERSION_MONITOR_INTERVAL_MS = 30 * 60_000;
134
138
  const CLIENT_VERSION_INITIAL_DELAY_MS = 10_000;
135
139
 
136
140
  export async function buildProductionDeps(): Promise<ProductionDeps> {
@@ -166,6 +170,9 @@ export async function buildProductionDeps(): Promise<ProductionDeps> {
166
170
  await measureStartupPhase("bundled-model-seed", () =>
167
171
  seedBundledModels(db, bundledCatalog.modelsByProvider),
168
172
  );
173
+ // The boot seed above is unconditional, so record it: the snapshot builder's
174
+ // first rebuild would otherwise re-seed an identical catalog.
175
+ markCatalogSeeded();
169
176
  const snapshotService = new InMemoryRouteSnapshotService(
170
177
  createDatabaseSnapshotBuilder(db, () => refreshActiveProviderCatalogs(db, registry)),
171
178
  );
@@ -280,7 +287,11 @@ export async function buildProductionDeps(): Promise<ProductionDeps> {
280
287
  });
281
288
  scheduledTasks.register({
282
289
  name: "console-session-sweep",
283
- intervalMs: 60 * 60_000,
290
+ // Sessions live 48 h, so an hourly sweep deleted nothing 47 times out of
291
+ // 48 — each pass a full scan of the session table for rows that cannot yet
292
+ // be expired. Half the session lifetime is the point where a pass can
293
+ // actually find work, and still leaves a full grace period after it.
294
+ intervalMs: 12 * 60 * 60_000,
284
295
  run: () => deleteExpiredSessions(db),
285
296
  });
286
297
  scheduledTasks.register({
@@ -309,6 +320,7 @@ export async function buildProductionDeps(): Promise<ProductionDeps> {
309
320
  routingRoundRobinEntries: () => routingEngine.roundRobinEntries(),
310
321
  ipAbuseKeys: () => (ipStore instanceof InMemoryIpAbuseStore ? ipStore.keyCount() : 0),
311
322
  quotaCacheEntries: () => quotaCacheSize(),
323
+ modelCatalogEntries: () => modelCatalogCacheSize(),
312
324
  // Late-bound via bindInFlight once the request state store exists.
313
325
  inFlight: () => 0,
314
326
  });
@@ -322,7 +334,11 @@ export async function buildProductionDeps(): Promise<ProductionDeps> {
322
334
  });
323
335
  scheduledTasks.register({
324
336
  name: "pool-agent-sweep",
325
- intervalMs: 60_000,
337
+ // `resolveAgent` already self-sweeps every 30 s, so a 1 min timer added a
338
+ // second pass that almost always found nothing to release. 5 min is well
339
+ // inside the idle timeout this exists to enforce, and outside the path
340
+ // that already runs.
341
+ intervalMs: 5 * 60_000,
326
342
  run: () => poolAgentResolver.sweepIdleAgents(),
327
343
  });
328
344
  const oauthRefreshService = new OAuthRefreshService(db);
@@ -338,7 +354,12 @@ export async function buildProductionDeps(): Promise<ProductionDeps> {
338
354
  // credential — no upstream call — so it runs often and cheaply.
339
355
  scheduledTasks.register({
340
356
  name: "account-enrichment-sweep",
341
- intervalMs: 30_000,
357
+ // Converges via `identity_checked_at`: once every stored credential has
358
+ // been parsed, the listing query returns nothing — and a 30 s timer kept
359
+ // running that empty query forever, two million times a year. 10 min is
360
+ // still instant for the case that matters (a batch import, where the label
361
+ // and email show up on the next tick) and costs nothing when idle.
362
+ intervalMs: 10 * 60_000,
342
363
  run: () => accountIdentitySweep({ db }),
343
364
  });
344
365
  scheduledTasks.register({
@@ -434,9 +455,11 @@ export async function buildProductionDeps(): Promise<ProductionDeps> {
434
455
  // an account unhealthy — a growth endpoint that 404s is a display miss, not a
435
456
  // dispatch failure.
436
457
  //
437
- // Hourly, not minute-cadenced: each claim is once per account per day, and a
438
- // task that ticks every minute would spend its whole budget re-reading the
439
- // day marker.
458
+ // Daily, not hourly: each claim is once per account per day and is
459
+ // ledger-guarded, so at an hourly cadence 23 of every 24 passes were a full
460
+ // scan of active accounts plus a ledger read per account that resolved to
461
+ // nothing. Once a day is the shortest interval where a pass can still find
462
+ // work, which is the only reason to run at all.
440
463
  const buddyTaskDeps = {
441
464
  db,
442
465
  resolveCredential: quotaResolveCredential,
@@ -447,37 +470,37 @@ export async function buildProductionDeps(): Promise<ProductionDeps> {
447
470
  };
448
471
  scheduledTasks.register({
449
472
  name: "buddy-streak",
450
- intervalMs: 60 * 60_000,
473
+ intervalMs: 24 * 60 * 60_000,
451
474
  initialDelayMs: 5 * 60_000,
452
475
  run: () => buddyStreakTask(buddyTaskDeps),
453
476
  });
454
477
  scheduledTasks.register({
455
478
  name: "buddy-redeem",
456
- intervalMs: 60 * 60_000,
479
+ intervalMs: 24 * 60 * 60_000,
457
480
  initialDelayMs: 8 * 60_000,
458
481
  run: () => buddyRedeemTask(buddyTaskDeps),
459
482
  });
460
483
  scheduledTasks.register({
461
484
  name: "buddy-lottery",
462
- intervalMs: 60 * 60_000,
485
+ intervalMs: 24 * 60 * 60_000,
463
486
  initialDelayMs: 11 * 60_000,
464
487
  run: () => buddyLotteryTask(buddyTaskDeps),
465
488
  });
466
489
  scheduledTasks.register({
467
490
  name: "buddy-travel",
468
- intervalMs: 60 * 60_000,
491
+ intervalMs: 24 * 60 * 60_000,
469
492
  initialDelayMs: 14 * 60_000,
470
493
  run: () => buddyTravelTask(buddyTaskDeps),
471
494
  });
472
495
  scheduledTasks.register({
473
496
  name: "buddy-adopt",
474
- intervalMs: 60 * 60_000,
497
+ intervalMs: 24 * 60 * 60_000,
475
498
  initialDelayMs: 17 * 60_000,
476
499
  run: () => buddyAdoptTask(buddyTaskDeps),
477
500
  });
478
501
  scheduledTasks.register({
479
502
  name: "buddy-trial",
480
- intervalMs: 6 * 60 * 60_000,
503
+ intervalMs: 24 * 60 * 60_000,
481
504
  initialDelayMs: 20 * 60_000,
482
505
  run: () => buddyTrialTask(buddyTaskDeps),
483
506
  });
@@ -4,7 +4,7 @@
4
4
  * callback URL builders built on the public origin.
5
5
  */
6
6
 
7
- import { CONFIG_SPEC, readRequired, readText } from "./spec";
7
+ import { CONFIG_SPEC, readList, readRequired, readText } from "./spec";
8
8
 
9
9
  /** Reads and normalizes the public OAuth origin. */
10
10
  export function requirePublicOrigin(): string {
@@ -48,6 +48,32 @@ export function resolveKimiQuotaBaseUrl(): string {
48
48
  );
49
49
  }
50
50
 
51
+ /**
52
+ * Operator override for the OpenCode free-tier ids upstream refuses, or
53
+ * `undefined` when unset — meaning the bundled, measured list stands.
54
+ *
55
+ * The single token `none` is distinct from unset and from an empty string: it
56
+ * means "trust the listing completely", which is the reference gateway's whole
57
+ * approach. Collapsing it into "no override" would silently re-block models
58
+ * the operator just asked to unblock.
59
+ */
60
+ export function resolveOpenCodeUnavailableFree():
61
+ | readonly string[]
62
+ | "none"
63
+ | undefined {
64
+ const raw = process.env.CARTETHYIA_OPENCODE_UNAVAILABLE_FREE?.trim();
65
+ if (raw === undefined || raw === "") return undefined;
66
+ if (raw.toLowerCase() === "none") return "none";
67
+ const parsed = readList(
68
+ "CARTETHYIA_OPENCODE_UNAVAILABLE_FREE",
69
+ CONFIG_SPEC.CARTETHYIA_OPENCODE_UNAVAILABLE_FREE,
70
+ );
71
+ // Separators only: readList drops the empties and yields nothing. Treating
72
+ // that as "trust everything" would un-block every refused model on a typo,
73
+ // so it falls back to the bundled list instead.
74
+ return parsed === undefined || parsed.length === 0 ? undefined : parsed;
75
+ }
76
+
51
77
  /**
52
78
  * GitHub Enterprise domain for the Copilot device login, or `""` for github.com.
53
79
  *
@@ -225,6 +225,17 @@ export const CONFIG_SPEC = {
225
225
  * an operator on one has no other way to point the login at it.
226
226
  */
227
227
  CARTETHYIA_GITHUB_ENTERPRISE_DOMAIN: { kind: "text", default: "" },
228
+ /**
229
+ * Free-tier OpenCode ids the listing still advertises but upstream refuses.
230
+ * Replaces the bundled list outright; the single token `none` trusts the
231
+ * listing completely.
232
+ *
233
+ * The bundled ids were measured, not declared — upstream can start serving
234
+ * a refused id or start refusing a served one at any time, and either way
235
+ * the correct answer stopped being the one compiled in. This is the escape
236
+ * hatch that needs no deploy.
237
+ */
238
+ CARTETHYIA_OPENCODE_UNAVAILABLE_FREE: { kind: "list" },
228
239
  } as const satisfies Readonly<Record<string, ConfigEntry>>;
229
240
 
230
241
  // ─── Spec readers ───────────────────────────────────────────────────────────
@@ -18,6 +18,7 @@ import { Elysia, t } from "elysia";
18
18
  import type { ConsoleAccessResolver } from "../auth/access";
19
19
  import { invalidateApiKeyCache } from "../../security/api-key-auth";
20
20
  import { ConsoleDomainError, errorResponse, requireAnyScope } from "../shared/errors";
21
+ import { NO_COMPRESS_HEADER } from "../response-compression";
21
22
  import { MAX_BACKUP_BYTES } from "./contracts";
22
23
  import type { BackupSection } from "./contracts";
23
24
  import { DELETE_ALL_SCOPES, compressBackup } from "./store";
@@ -124,6 +125,13 @@ export function createBackupRoutes(config: BackupRoutesConfig): Elysia {
124
125
  }
125
126
  const bytes = compressBackup(payload);
126
127
  set.headers["content-type"] = "application/json; charset=utf-8";
128
+ // Opt out of the response wrapper's gzip rather than declaring
129
+ // `content-encoding: gzip`: the bytes here are base64 of a gzip
130
+ // stream, and a client told "this is gzip" tries to inflate the
131
+ // base64 text and fails. The payload inside is already compressed,
132
+ // and a second pass over base64 would find nothing to gain while
133
+ // paying a synchronous `gzipSync` over the whole multi-MB body.
134
+ set.headers[NO_COMPRESS_HEADER] = "1";
127
135
  return { compressed: true, filename: `cartethyia_backup_${stamp}.json.gz`, bytes: bytes.toString("base64") };
128
136
  } catch (error) {
129
137
  return errorResponse(error, set, "Backup export failed");
@@ -486,9 +486,9 @@ export const TOOL_REGISTRY = {
486
486
  roleKind: "primary",
487
487
  },
488
488
  {
489
- id: "gpt-5.1",
490
- name: "GPT-5.1",
491
- alias: "gpt-5.1",
489
+ id: "gpt-5.6-sol",
490
+ name: "GPT-5.6 Sol",
491
+ alias: "gpt-5.6-sol",
492
492
  roleLabel: "Fallback Agent",
493
493
  roleKind: "secondary",
494
494
  },
@@ -505,7 +505,7 @@ export const TOOL_REGISTRY = {
505
505
  settingsFile: "~/.factory/settings.json",
506
506
  defaultModels: [
507
507
  { id: "claude/claude-sonnet-5", name: "Claude Sonnet 5", alias: "sonnet" },
508
- { id: "gpt-5.1", name: "GPT-5.1", alias: "gpt-5.1" },
508
+ { id: "gpt-5.6-sol", name: "GPT-5.6 Sol", alias: "gpt-5.6-sol" },
509
509
  ],
510
510
  },
511
511
 
@@ -540,7 +540,7 @@ export const TOOL_REGISTRY = {
540
540
  surface: "chat" as const,
541
541
  settingsFile: "Code/User/chatLanguageModels.json",
542
542
  defaultModels: [
543
- { id: "gpt-5.1", name: "GPT-5.1", alias: "gpt-5.1" },
543
+ { id: "gpt-5.6-sol", name: "GPT-5.6 Sol", alias: "gpt-5.6-sol" },
544
544
  { id: "claude/claude-sonnet-5", name: "Claude Sonnet 5", alias: "sonnet" },
545
545
  ],
546
546
  },
@@ -703,7 +703,7 @@ amp --model "{{model}}"`,
703
703
  docsUrl: "https://qwenlm.github.io/qwen-code-docs/en/users/configuration/model-providers/",
704
704
  defaultModels: [
705
705
  { id: "claude/claude-sonnet-5", name: "Claude Sonnet 5", alias: "sonnet" },
706
- { id: "gpt-5.1", name: "GPT-5.1", alias: "gpt-5.1" },
706
+ { id: "gpt-5.6-sol", name: "GPT-5.6 Sol", alias: "gpt-5.6-sol" },
707
707
  ],
708
708
  notes: [
709
709
  {
@@ -209,9 +209,11 @@ export function createConsoleRouter(deps: ConsoleApiCompositionDeps): Elysia {
209
209
  // nothing to compress and no size to judge. `wrap` sits outside the router
210
210
  // and sees the finished `Response`, which is what this needs.
211
211
  //
212
- // Scoped to the console instance: `/v1/*` is a separate Elysia and is
213
- // deliberately not covered, because a streaming completion must not be
214
- // buffered and proxied bodies are the provider's own bytes. See
212
+ // Scoped by path *inside* `gzipResponse`, not by this instance: Elysia's
213
+ // `use()` merges a child's `hoc` into the root app's ext, so this wrap is
214
+ // reached by `/v1/*` as well. The allowlist is `/console/api/` + `/metrics`.
215
+ // `/v1/*` is deliberately excluded, because a streaming completion must not
216
+ // be buffered and proxied bodies are the provider's own bytes. See
215
217
  // `response-compression.ts` for the full reasoning and the measured win.
216
218
  console.wrap(
217
219
  (fetch) => (request: Request, ...rest: unknown[]) =>
@@ -4,6 +4,7 @@ import {
4
4
  X_FRAME_OPTIONS,
5
5
  dashboardContentSecurityPolicy,
6
6
  } from "../security/outbound-headers";
7
+ import { acceptsGzip } from "./response-compression";
7
8
 
8
9
  /**
9
10
  * Represents the result of serving a static file.
@@ -148,9 +149,39 @@ function entryDocumentFor(pathname: string, entries: Readonly<Record<string, str
148
149
  * - Serves hashed assets with immutable cache headers and documents with no-cache
149
150
  * - Returns 404 for missing asset-like files
150
151
  */
152
+ /**
153
+ * The `.gz` sibling of an asset, when one exists and the client offered gzip.
154
+ *
155
+ * Restricted to hashed assets — see the check below for why — and best-effort:
156
+ * a build that emitted no `.gz` simply serves the plain file.
157
+ */
158
+ async function readPrecompressed(
159
+ fullPath: string,
160
+ acceptEncoding: string | undefined | null,
161
+ ): Promise<Uint8Array | undefined> {
162
+ if (acceptEncoding === undefined || acceptEncoding === null) return undefined;
163
+ if (!acceptsGzip(acceptEncoding)) return undefined;
164
+ // Only hashed assets: those are the immutable ones, and they are what the
165
+ // Vite build precompresses. An unhashed asset is served with a revalidating
166
+ // cache header precisely because it may be replaced in place, and a `.gz`
167
+ // left beside a file that was swapped afterwards would not match it.
168
+ if (!hasHashInFilename(fullPath)) return undefined;
169
+ const file = Bun.file(`${fullPath}.gz`);
170
+ if (!(await file.exists())) return undefined;
171
+ return await file.bytes();
172
+ }
173
+
174
+ /**
175
+ * Builds the static handler.
176
+ *
177
+ * The returned function takes an optional `acceptEncoding` so the caller can
178
+ * pass the request's header: a precompressed `.gz` sibling is only usable when
179
+ * the client asking for it offered gzip. Omitting it serves the plain file,
180
+ * which is what the tests and any non-HTTP caller want.
181
+ */
151
182
  export function createStaticHandler(
152
183
  config: StaticHandlerConfig,
153
- ): (pathname: string) => Promise<StaticServeResult> {
184
+ ): (pathname: string, acceptEncoding?: string | null) => Promise<StaticServeResult> {
154
185
  const buildDirResolved = resolve(config.buildDir);
155
186
  const buildDirNorm = normalizePathSeparators(buildDirResolved);
156
187
  const entries = config.entries ?? DEFAULT_ENTRY_DOCUMENTS;
@@ -173,7 +204,10 @@ export function createStaticHandler(
173
204
  };
174
205
  };
175
206
 
176
- const serve = async (pathname: string): Promise<StaticServeResult> => {
207
+ const serve = async (
208
+ pathname: string,
209
+ acceptEncoding?: string | null,
210
+ ): Promise<StaticServeResult> => {
177
211
  // Reject the reserved `/console/api` namespace, including its bare prefix.
178
212
  if (pathname === "/console/api" || pathname.startsWith("/console/api/")) {
179
213
  return {
@@ -241,6 +275,28 @@ export function createStaticHandler(
241
275
  cacheControl = "public, max-age=3600";
242
276
  }
243
277
 
278
+ // A precompressed sibling is served when the client asked for gzip
279
+ // and the build emitted one. The bytes were compressed at build time
280
+ // (level 9), so this is a disk read rather than a per-request
281
+ // compress — and a build with no `.gz` falls through to the plain
282
+ // file, which is why there is no error path here.
283
+ const precompressed = await readPrecompressed(fullPath, acceptEncoding);
284
+ if (precompressed !== undefined) {
285
+ return {
286
+ status: 200,
287
+ body: precompressed,
288
+ headers: {
289
+ "content-type": mimeType,
290
+ "cache-control": cacheControl,
291
+ "content-encoding": "gzip",
292
+ // Without this a shared cache would hand the gzipped bytes to a
293
+ // client that never offered gzip: the URL is identical either
294
+ // way, so `accept-encoding` is the only thing that differs.
295
+ vary: "accept-encoding",
296
+ },
297
+ };
298
+ }
299
+
244
300
  return {
245
301
  status: 200,
246
302
  body: data,
@@ -260,8 +316,8 @@ export function createStaticHandler(
260
316
  return { status: 404, body: undefined, headers: {} };
261
317
  };
262
318
 
263
- return async (pathname: string): Promise<StaticServeResult> =>
264
- applyStaticSecurityHeaders(await serve(pathname));
319
+ return async (pathname: string, acceptEncoding?: string | null): Promise<StaticServeResult> =>
320
+ applyStaticSecurityHeaders(await serve(pathname, acceptEncoding));
265
321
  }
266
322
 
267
323
  /**
@@ -328,13 +384,27 @@ function getMimeType(filePath: string): string {
328
384
  }
329
385
 
330
386
  /**
331
- * Checks if a filename contains a hash (common pattern: name.hash.ext).
332
- * This is a heuristic that looks for hex-like strings in the filename.
387
+ * True when a filename carries a build hash, so its bytes are immutable.
388
+ *
389
+ * Matched on the two separator forms a bundler actually emits — `app.a1b2c3d4.js`
390
+ * and the modern Vite spelling `app-BCnqn3N3.js` — plus `_`, and then required
391
+ * to *look generated*: the segment must contain a digit or mix upper and lower
392
+ * case. The second half is what keeps a plain word out of the set, since
393
+ * `console-backgrounds.jpg` and `openai-light.svg` both match the separator
394
+ * alone and are emphatically not immutable.
395
+ *
396
+ * Getting this wrong in either direction costs real money: a hashed asset
397
+ * served with a revalidating header is re-downloaded after every deploy, and an
398
+ * unhashed one served `immutable` is pinned in the browser cache until it
399
+ * expires. Measured against a real build, this matches 198 of 288 files — all
400
+ * of them hashed bundles — and rejects every icon, photo, and document.
333
401
  */
334
402
  function hasHashInFilename(filePath: string): boolean {
335
- const filename = filePath.split(/[/\\]/).pop() || "";
336
- // Match patterns like: app.abc123def.js, script.12345678.mjs, etc.
337
- // Look for segments that are at least 8 characters of hex digits
338
- const hashPattern = /\.[a-f0-9]{6,}\./i;
339
- return hashPattern.test(filename);
403
+ const filename = filePath.split(/[/\\]/).pop() ?? "";
404
+ const match = /[._-]([A-Za-z0-9_-]{8,})\./.exec(filename);
405
+ if (match === null) return false;
406
+ const segment = match[1] ?? "";
407
+ const hasDigit = /[0-9]/.test(segment);
408
+ const hasMixedCase = /[a-z]/.test(segment) && /[A-Z]/.test(segment);
409
+ return hasDigit || hasMixedCase;
340
410
  }
@@ -2,6 +2,7 @@ import type { ConsoleAccessResolver } from "../auth/access";
2
2
  // Telemetry domain: system health, usage, and telemetry-event read API.
3
3
 
4
4
  import { ConsoleDomainError, requireTenantScope } from "../shared/errors";
5
+ import { conditionalResponse, type CachedResult } from "../shared/read-through-cache";
5
6
  import { parseQueryLimit } from "../shared/query";
6
7
  import type { AccessDecision } from "../../security/access-control";
7
8
  import { USAGE_DIMENSIONS, type UsageDimension } from "./usage-dimensions";
@@ -262,8 +263,15 @@ export interface TelemetryEventListPage {
262
263
  nextCursor?: string;
263
264
  }
264
265
  export interface ObservabilityStore {
265
- health(tenantId: string): Promise<SystemHealthResponse>;
266
- usage(tenantId: string, period: string): Promise<UsageResponse>;
266
+ /**
267
+ * Every member below that is served from the read-through cache returns a
268
+ * {@link CachedResult}, carrying the strong validator for the bytes the
269
+ * client would otherwise receive. A repeated poll is then a `304` instead of
270
+ * a full payload. The reads that are per-row and already bounded — the event
271
+ * list and the detail views — have no cache entry and no validator.
272
+ */
273
+ health(tenantId: string): Promise<CachedResult<SystemHealthResponse>>;
274
+ usage(tenantId: string, period: string): Promise<CachedResult<UsageResponse>>;
267
275
  listEvents(tenantId: string, limit: number, cursor?: string): Promise<TelemetryEventListPage>;
268
276
  getEvent(
269
277
  tenantId: string,
@@ -271,20 +279,20 @@ export interface ObservabilityStore {
271
279
  includePayload: boolean,
272
280
  createdAt?: string,
273
281
  ): Promise<TelemetryEventDetail | undefined>;
274
- usageSummary(tenantId: string, period: string): Promise<UsageSummaryResponse>;
275
- usageChart(tenantId: string, period: string): Promise<UsageChartResponse>;
282
+ usageSummary(tenantId: string, period: string): Promise<CachedResult<UsageSummaryResponse>>;
283
+ usageChart(tenantId: string, period: string): Promise<CachedResult<UsageChartResponse>>;
276
284
  usageBy(
277
285
  tenantId: string,
278
286
  dimension: UsageDimension,
279
287
  period: string,
280
- ): Promise<UsageByResponse>;
281
- usageCache(tenantId: string, period: string): Promise<UsageCacheResponse>;
288
+ ): Promise<CachedResult<UsageByResponse>>;
289
+ usageCache(tenantId: string, period: string): Promise<CachedResult<UsageCacheResponse>>;
282
290
  usageRequests(
283
291
  tenantId: string,
284
292
  period: string,
285
293
  limit: number,
286
294
  httpStatus?: number,
287
- ): Promise<UsageRequestsResponse>;
295
+ ): Promise<CachedResult<UsageRequestsResponse>>;
288
296
  usageRequestDetail(tenantId: string, requestId: string): Promise<UsageRequestDetail | undefined>;
289
297
  }
290
298
  export interface ObservabilityConfig {
@@ -294,12 +302,25 @@ export interface ObservabilityConfig {
294
302
 
295
303
  export function createObservabilityOperations(config: ObservabilityConfig) {
296
304
  const operations = {
297
- async getSystemHealth(access: AccessDecision | undefined): Promise<SystemHealthResponse> {
305
+ /**
306
+ * The cached reads below answer with a `Response` rather than a plain
307
+ * object, because a repeated poll is a `304` and a `304` has no body to
308
+ * serialize. `ifNoneMatch` is the client's `If-None-Match`; a match costs
309
+ * ~200 bytes instead of the whole payload.
310
+ */
311
+ async getSystemHealth(
312
+ access: AccessDecision | undefined,
313
+ ifNoneMatch: string | null,
314
+ ): Promise<Response> {
298
315
  const a = requireTenantScope(access, "dashboard:read");
299
- const h = await config.store.health(a.tenantId);
300
- return h;
316
+ const result = await config.store.health(a.tenantId);
317
+ return conditionalResponse(ifNoneMatch, result, result.value);
301
318
  },
302
- async getTenantUsage(access: AccessDecision | undefined, period = "7d"): Promise<UsageResponse> {
319
+ async getTenantUsage(
320
+ access: AccessDecision | undefined,
321
+ period = "7d",
322
+ ifNoneMatch: string | null = null,
323
+ ): Promise<Response> {
303
324
  const a = requireTenantScope(access, "dashboard:read");
304
325
  if (!isSupportedUsagePeriod(period)) {
305
326
  throw new ConsoleDomainError(
@@ -308,7 +329,8 @@ export function createObservabilityOperations(config: ObservabilityConfig) {
308
329
  `Unsupported usage period: ${period}. Use "24h", "7d", "30d", or "all".`,
309
330
  );
310
331
  }
311
- return config.store.usage(a.tenantId, period);
332
+ const result = await config.store.usage(a.tenantId, period);
333
+ return conditionalResponse(ifNoneMatch, result, result.value);
312
334
  },
313
335
  async listEvents(
314
336
  access: AccessDecision | undefined,
@@ -354,22 +376,27 @@ export function createObservabilityOperations(config: ObservabilityConfig) {
354
376
  async getUsageSummary(
355
377
  access: AccessDecision | undefined,
356
378
  period = "24h",
357
- ): Promise<UsageSummaryResponse> {
379
+ ifNoneMatch: string | null = null,
380
+ ): Promise<Response> {
358
381
  const tenantId = operations.requireTenant(access);
359
- return config.store.usageSummary(tenantId, operations.requirePeriod(period));
382
+ const result = await config.store.usageSummary(tenantId, operations.requirePeriod(period));
383
+ return conditionalResponse(ifNoneMatch, result, result.value);
360
384
  },
361
385
  async getUsageChart(
362
386
  access: AccessDecision | undefined,
363
387
  period = "24h",
364
- ): Promise<UsageChartResponse> {
388
+ ifNoneMatch: string | null = null,
389
+ ): Promise<Response> {
365
390
  const tenantId = operations.requireTenant(access);
366
- return config.store.usageChart(tenantId, operations.requirePeriod(period));
391
+ const result = await config.store.usageChart(tenantId, operations.requirePeriod(period));
392
+ return conditionalResponse(ifNoneMatch, result, result.value);
367
393
  },
368
394
  async getUsageBy(
369
395
  access: AccessDecision | undefined,
370
396
  dimension: UsageDimension,
371
397
  period = "24h",
372
- ): Promise<UsageByResponse> {
398
+ ifNoneMatch: string | null = null,
399
+ ): Promise<Response> {
373
400
  const tenantId = operations.requireTenant(access);
374
401
  if (!(USAGE_DIMENSIONS as readonly string[]).includes(dimension)) {
375
402
  throw new ConsoleDomainError(
@@ -378,21 +405,25 @@ export function createObservabilityOperations(config: ObservabilityConfig) {
378
405
  `Unsupported usage dimension: ${dimension}. Use ${USAGE_DIMENSIONS.map((d) => `"${d}"`).join(", ")}.`,
379
406
  );
380
407
  }
381
- return config.store.usageBy(tenantId, dimension, operations.requirePeriod(period));
408
+ const result = await config.store.usageBy(tenantId, dimension, operations.requirePeriod(period));
409
+ return conditionalResponse(ifNoneMatch, result, result.value);
382
410
  },
383
411
  async getUsageCache(
384
412
  access: AccessDecision | undefined,
385
413
  period = "24h",
386
- ): Promise<UsageCacheResponse> {
414
+ ifNoneMatch: string | null = null,
415
+ ): Promise<Response> {
387
416
  const tenantId = operations.requireTenant(access);
388
- return config.store.usageCache(tenantId, operations.requirePeriod(period));
417
+ const result = await config.store.usageCache(tenantId, operations.requirePeriod(period));
418
+ return conditionalResponse(ifNoneMatch, result, result.value);
389
419
  },
390
420
  async listUsageRequests(
391
421
  access: AccessDecision | undefined,
392
422
  period = "24h",
393
423
  limit?: string,
394
424
  httpStatus?: string,
395
- ): Promise<UsageRequestsResponse> {
425
+ ifNoneMatch: string | null = null,
426
+ ): Promise<Response> {
396
427
  const tenantId = operations.requireTenant(access);
397
428
  let parsedStatus: number | undefined;
398
429
  if (httpStatus !== undefined) {
@@ -401,12 +432,13 @@ export function createObservabilityOperations(config: ObservabilityConfig) {
401
432
  throw new ConsoleDomainError("invalid_request", 400, "Invalid HTTP status filter");
402
433
  }
403
434
  }
404
- return config.store.usageRequests(
435
+ const result = await config.store.usageRequests(
405
436
  tenantId,
406
437
  operations.requirePeriod(period),
407
438
  parseQueryLimit(limit, 50, 500),
408
439
  parsedStatus,
409
440
  );
441
+ return conditionalResponse(ifNoneMatch, result, result.value);
410
442
  },
411
443
  async getUsageRequestDetail(
412
444
  access: AccessDecision | undefined,
@@ -23,16 +23,31 @@ export function createObservabilityRoutes(config: ObservabilityConfig): Elysia {
23
23
  return new Elysia()
24
24
  .error(consoleErrorHandler("Observability operation failed"))
25
25
  .get("/system/health", async ({ request }) => {
26
- return await factory.getSystemHealth(config.accessResolver(request));
26
+ return await factory.getSystemHealth(
27
+ config.accessResolver(request),
28
+ request.headers.get("if-none-match"),
29
+ );
27
30
  })
28
31
  .get("/system/usage", { query: usageQuery }, async ({ request, query }) => {
29
- return await factory.getTenantUsage(config.accessResolver(request), query.period);
32
+ return await factory.getTenantUsage(
33
+ config.accessResolver(request),
34
+ query.period,
35
+ request.headers.get("if-none-match"),
36
+ );
30
37
  })
31
38
  .get("/system/usage/summary", { query: usageQuery }, async ({ request, query }) => {
32
- return await factory.getUsageSummary(config.accessResolver(request), query.period);
39
+ return await factory.getUsageSummary(
40
+ config.accessResolver(request),
41
+ query.period,
42
+ request.headers.get("if-none-match"),
43
+ );
33
44
  })
34
45
  .get("/system/usage/chart", { query: usageQuery }, async ({ request, query }) => {
35
- return await factory.getUsageChart(config.accessResolver(request), query.period);
46
+ return await factory.getUsageChart(
47
+ config.accessResolver(request),
48
+ query.period,
49
+ request.headers.get("if-none-match"),
50
+ );
36
51
  })
37
52
  // One `by-<dimension>` route per member of `USAGE_DIMENSIONS`, so a new
38
53
  // dimension cannot be added to the validator without its route existing.
@@ -48,11 +63,20 @@ export function createObservabilityRoutes(config: ObservabilityConfig): Elysia {
48
63
  `Unknown usage dimension: ${dimension}`,
49
64
  );
50
65
  }
51
- return await factory.getUsageBy(config.accessResolver(request), dimension, query.period);
66
+ return await factory.getUsageBy(
67
+ config.accessResolver(request),
68
+ dimension,
69
+ query.period,
70
+ request.headers.get("if-none-match"),
71
+ );
52
72
  },
53
73
  )
54
74
  .get("/system/usage/cache", { query: usageQuery }, async ({ request, query }) => {
55
- return await factory.getUsageCache(config.accessResolver(request), query.period);
75
+ return await factory.getUsageCache(
76
+ config.accessResolver(request),
77
+ query.period,
78
+ request.headers.get("if-none-match"),
79
+ );
56
80
  })
57
81
  .get("/system/usage/requests", { query: usageRequestsQuery }, async ({ request, query }) => {
58
82
  return await factory.listUsageRequests(
@@ -60,6 +84,7 @@ export function createObservabilityRoutes(config: ObservabilityConfig): Elysia {
60
84
  query.period,
61
85
  query.limit,
62
86
  query.httpStatus,
87
+ request.headers.get("if-none-match"),
63
88
  );
64
89
  })
65
90
  .get("/system/usage/requests/:requestId", async ({ request, params }) => {