cartethyia 2.4.5 → 2.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (415) hide show
  1. package/.env.example +4 -1
  2. package/README.md +63 -13
  3. package/dist/dashboard/assets/AccountCooldown-ChpdzxRH.js.gz +0 -0
  4. package/dist/dashboard/assets/BannedPage-9igfctfZ.js +1 -0
  5. package/dist/dashboard/assets/BannedPage-9igfctfZ.js.gz +0 -0
  6. package/dist/dashboard/assets/{CliToolDetailPage-BzGevuql.js → CliToolDetailPage-ZRr2K2sF.js} +1 -1
  7. package/dist/dashboard/assets/CliToolDetailPage-ZRr2K2sF.js.gz +0 -0
  8. package/dist/dashboard/assets/{CliToolsPage-BaRM_7We.js → CliToolsPage-D3xqZbN-.js} +1 -1
  9. package/dist/dashboard/assets/CliToolsPage-D3xqZbN-.js.gz +0 -0
  10. package/dist/dashboard/assets/CombosPage-CVnQPBBz.js +1 -0
  11. package/dist/dashboard/assets/CombosPage-CVnQPBBz.js.gz +0 -0
  12. package/dist/dashboard/assets/ConsoleLogPage-u6W1UXSc.js +1 -0
  13. package/dist/dashboard/assets/ConsoleLogPage-u6W1UXSc.js.gz +0 -0
  14. package/dist/dashboard/assets/{CustomizationPage-DqUGcoH0.js → CustomizationPage-CTTpCb25.js} +1 -1
  15. package/dist/dashboard/assets/CustomizationPage-CTTpCb25.js.gz +0 -0
  16. package/dist/dashboard/assets/HealthEventsModal-C5XpWlvy.js +1 -0
  17. package/dist/dashboard/assets/HealthEventsModal-C5XpWlvy.js.gz +0 -0
  18. package/dist/dashboard/assets/{LoginPage-DbhTGfhb.js → LoginPage-DHoB3Agn.js} +1 -1
  19. package/dist/dashboard/assets/LoginPage-DHoB3Agn.js.gz +0 -0
  20. package/dist/dashboard/assets/ModelPicker-oQ-SDXp8.js +1 -0
  21. package/dist/dashboard/assets/ModelPicker-oQ-SDXp8.js.gz +0 -0
  22. package/dist/dashboard/assets/OverviewPage-BrY4SgIt.js +1 -0
  23. package/dist/dashboard/assets/OverviewPage-BrY4SgIt.js.gz +0 -0
  24. package/dist/dashboard/assets/ProviderDetailPage-D9VSt7T_.js +3 -0
  25. package/dist/dashboard/assets/ProviderDetailPage-D9VSt7T_.js.gz +0 -0
  26. package/dist/dashboard/assets/ProviderIcon-CmfzxpVM.js +1 -0
  27. package/dist/dashboard/assets/ProviderIcon-CmfzxpVM.js.gz +0 -0
  28. package/dist/dashboard/assets/ProvidersPage-Df77V5I5.js +1 -0
  29. package/dist/dashboard/assets/ProvidersPage-Df77V5I5.js.gz +0 -0
  30. package/dist/dashboard/assets/ProxyPage-CDfxP7AC.js +7 -0
  31. package/dist/dashboard/assets/ProxyPage-CDfxP7AC.js.gz +0 -0
  32. package/dist/dashboard/assets/QuotaPage-CY4o2t4p.js +1 -0
  33. package/dist/dashboard/assets/QuotaPage-CY4o2t4p.js.gz +0 -0
  34. package/dist/dashboard/assets/{SettingsPage-YxRml8AT.js → SettingsPage-CvjZxHE4.js} +1 -1
  35. package/dist/dashboard/assets/SettingsPage-CvjZxHE4.js.gz +0 -0
  36. package/dist/dashboard/assets/SetupPage-D3nza7LH.js +1 -0
  37. package/dist/dashboard/assets/SetupPage-D3nza7LH.js.gz +0 -0
  38. package/dist/dashboard/assets/{SortableList-Bol1ASmI.js → SortableList-Dtt12WT1.js} +1 -1
  39. package/dist/dashboard/assets/SortableList-Dtt12WT1.js.gz +0 -0
  40. package/dist/dashboard/assets/StudioPage-CbK3hXvU.js +31 -0
  41. package/dist/dashboard/assets/StudioPage-CbK3hXvU.js.gz +0 -0
  42. package/dist/dashboard/assets/ToolIcon-K1ckcDKg.js.gz +0 -0
  43. package/dist/dashboard/assets/UsagePage-BQlWbWg2.js +56 -0
  44. package/dist/dashboard/assets/UsagePage-BQlWbWg2.js.gz +0 -0
  45. package/dist/dashboard/assets/api-keys-BmDbsior.js +1 -0
  46. package/dist/dashboard/assets/api-keys-BmDbsior.js.gz +0 -0
  47. package/dist/dashboard/assets/app-BbpSarVV.css +2 -0
  48. package/dist/dashboard/assets/app-BbpSarVV.css.gz +0 -0
  49. package/dist/dashboard/assets/{app-BDJWMThu.js → app-C-Y4oZT0.js} +3 -3
  50. package/dist/dashboard/assets/app-C-Y4oZT0.js.gz +0 -0
  51. package/dist/dashboard/assets/arrow-left-CpBxkoxI.js.gz +0 -0
  52. package/dist/dashboard/assets/arrow-right-Dl5yKnp8.js.gz +0 -0
  53. package/dist/dashboard/assets/assert-BHfaei-X.js +1 -0
  54. package/dist/dashboard/assets/assert-BHfaei-X.js.gz +0 -0
  55. package/dist/dashboard/assets/backup-file-gdKIlg8U.js.gz +0 -0
  56. package/dist/dashboard/assets/{badge-BwmwEOgA.js → badge-DpXT4Ixt.js} +1 -1
  57. package/dist/dashboard/assets/badge-DpXT4Ixt.js.gz +0 -0
  58. package/dist/dashboard/assets/{banner-BKBfKTqn.js → banner-CSAAWLJv.js} +1 -1
  59. package/dist/dashboard/assets/banner-CSAAWLJv.js.gz +0 -0
  60. package/dist/dashboard/assets/brain-Cwed_72z.js.gz +0 -0
  61. package/dist/dashboard/assets/{card-BTdbRFFg.js → card-DEhRXXw4.js} +1 -1
  62. package/dist/dashboard/assets/card-DEhRXXw4.js.gz +0 -0
  63. package/dist/dashboard/assets/check-D69FzdvC.js.gz +0 -0
  64. package/dist/dashboard/assets/chevron-right-BXIy5l3h.js.gz +0 -0
  65. package/dist/dashboard/assets/circle-alert-CntYHV5v.js.gz +0 -0
  66. package/dist/dashboard/assets/circle-check-BU3v_mFW.js.gz +0 -0
  67. package/dist/dashboard/assets/cli-tools-B0wBq-RL.js +1 -0
  68. package/dist/dashboard/assets/cli-tools-B0wBq-RL.js.gz +0 -0
  69. package/dist/dashboard/assets/clipboard-button-DOo6EU8O.js +1 -0
  70. package/dist/dashboard/assets/clipboard-button-DOo6EU8O.js.gz +0 -0
  71. package/dist/dashboard/assets/cog-CdWhsajs.js.gz +0 -0
  72. package/dist/dashboard/assets/copy-ngmQ9zcZ.js.gz +0 -0
  73. package/dist/dashboard/assets/{dialog-iocKA3jr.js → dialog-BvQeBMfP.js} +1 -1
  74. package/dist/dashboard/assets/dialog-BvQeBMfP.js.gz +0 -0
  75. package/dist/dashboard/assets/dialog-actions-B1axpEg7.js.gz +0 -0
  76. package/dist/dashboard/assets/download-A0SW9OCW.js.gz +0 -0
  77. package/dist/dashboard/assets/download-dRFo5_Wu.js.gz +0 -0
  78. package/dist/dashboard/assets/{drawer-CDKOTZdY.js → drawer-CMwPXwQB.js} +1 -1
  79. package/dist/dashboard/assets/drawer-CMwPXwQB.js.gz +0 -0
  80. package/dist/dashboard/assets/entry-D4O2229X.js +1 -0
  81. package/dist/dashboard/assets/entry-D4O2229X.js.gz +0 -0
  82. package/dist/dashboard/assets/entry-DKvr5Q9K.js +1 -0
  83. package/dist/dashboard/assets/entry-DKvr5Q9K.js.gz +0 -0
  84. package/dist/dashboard/assets/entry-FQCejoDY.js +4 -0
  85. package/dist/dashboard/assets/entry-FQCejoDY.js.gz +0 -0
  86. package/dist/dashboard/assets/eye-C_hawfH4.js.gz +0 -0
  87. package/dist/dashboard/assets/eye-off-Bk5g2luF.js.gz +0 -0
  88. package/dist/dashboard/assets/fingerprint-pattern-BWuHtbW4.js.gz +0 -0
  89. package/dist/dashboard/assets/gauge-BbIMy7MB.js.gz +0 -0
  90. package/dist/dashboard/assets/github-badge-zliLvBwr.js +1 -0
  91. package/dist/dashboard/assets/github-badge-zliLvBwr.js.gz +0 -0
  92. package/dist/dashboard/assets/image-BPaouViG.js.gz +0 -0
  93. package/dist/dashboard/assets/{index-C4hXfAMs.js → index-CXm3FIyt.js} +2 -2
  94. package/dist/dashboard/assets/index-CXm3FIyt.js.gz +0 -0
  95. package/dist/dashboard/assets/{inline-DCejSLlA.js → inline-D1IlZZ8H.js} +1 -1
  96. package/dist/dashboard/assets/inline-D1IlZZ8H.js.gz +0 -0
  97. package/dist/dashboard/assets/{input-Bn4_kNav.js → input-BpVZdrbc.js} +1 -1
  98. package/dist/dashboard/assets/input-BpVZdrbc.js.gz +0 -0
  99. package/dist/dashboard/assets/jsx-runtime-CXUMngI_.js.gz +0 -0
  100. package/dist/dashboard/assets/key-round-UkavsGxp.js.gz +0 -0
  101. package/dist/dashboard/assets/layers-2-glBbjl.js.gz +0 -0
  102. package/dist/dashboard/assets/{layout-Bv-NxqZ5.js → layout-BioLW7gN.js} +1 -1
  103. package/dist/dashboard/assets/layout-BioLW7gN.js.gz +0 -0
  104. package/dist/dashboard/assets/{list-row-BLA0sldD.js → list-row-CwVcGAIc.js} +1 -1
  105. package/dist/dashboard/assets/list-row-CwVcGAIc.js.gz +0 -0
  106. package/dist/dashboard/assets/{live-YlifPYox.js → live-yfEJFF9T.js} +1 -1
  107. package/dist/dashboard/assets/live-yfEJFF9T.js.gz +0 -0
  108. package/dist/dashboard/assets/log-in-BmRjy60c.js.gz +0 -0
  109. package/dist/dashboard/assets/message-circle-CCxGwRvv.js.gz +0 -0
  110. package/dist/dashboard/assets/model-bans-CT-TxSNX.js +1 -0
  111. package/dist/dashboard/assets/model-bans-CT-TxSNX.js.gz +0 -0
  112. package/dist/dashboard/assets/mutation-BkoLM5Ba.js +1 -0
  113. package/dist/dashboard/assets/mutation-BkoLM5Ba.js.gz +0 -0
  114. package/dist/dashboard/assets/numbers-CFL4_Dqc.js.gz +0 -0
  115. package/dist/dashboard/assets/pencil-768ysRAr.js.gz +0 -0
  116. package/dist/dashboard/assets/plus-DIdX_g-x.js.gz +0 -0
  117. package/dist/dashboard/assets/power-off-Yjw9GMJh.js +1 -0
  118. package/dist/dashboard/assets/power-off-Yjw9GMJh.js.gz +0 -0
  119. package/dist/dashboard/assets/privacy-mode-GM8bej85.js +1 -0
  120. package/dist/dashboard/assets/privacy-mode-GM8bej85.js.gz +0 -0
  121. package/dist/dashboard/assets/providers/agentrouter.svg.gz +0 -0
  122. package/dist/dashboard/assets/providers/aihubmix.svg.gz +0 -0
  123. package/dist/dashboard/assets/providers/alibaba-coding-plan.svg.gz +0 -0
  124. package/dist/dashboard/assets/providers/alibaba.svg.gz +0 -0
  125. package/dist/dashboard/assets/providers/antigravity.svg.gz +0 -0
  126. package/dist/dashboard/assets/providers/bai.svg.gz +0 -0
  127. package/dist/dashboard/assets/providers/blackbox.svg.gz +0 -0
  128. package/dist/dashboard/assets/providers/claude-code.svg.gz +0 -0
  129. package/dist/dashboard/assets/providers/cursor.webp +0 -0
  130. package/dist/dashboard/assets/providers/exa.svg.gz +0 -0
  131. package/dist/dashboard/assets/providers/gmi-cloud.svg.gz +0 -0
  132. package/dist/dashboard/assets/providers/inferhub.svg.gz +0 -0
  133. package/dist/dashboard/assets/providers/muse.svg.gz +0 -0
  134. package/dist/dashboard/assets/providers/nous-research.svg.gz +0 -0
  135. package/dist/dashboard/assets/providers/openai-light.svg.gz +0 -0
  136. package/dist/dashboard/assets/providers/parallel.svg.gz +0 -0
  137. package/dist/dashboard/assets/providers/tokenharbor.svg.gz +0 -0
  138. package/dist/dashboard/assets/providers/trae.webp +0 -0
  139. package/dist/dashboard/assets/providers/zai-coding-plan.svg.gz +0 -0
  140. package/dist/dashboard/assets/{query-keys-ROf-g1Wi.js → query-keys-D7wX2vM5.js} +1 -1
  141. package/dist/dashboard/assets/query-keys-D7wX2vM5.js.gz +0 -0
  142. package/dist/dashboard/assets/quota-DZGyp0N5.js.gz +0 -0
  143. package/dist/dashboard/assets/refresh-cw-BqJFk4sU.js.gz +0 -0
  144. package/dist/dashboard/assets/rotate-ccw-CufcqITi.js.gz +0 -0
  145. package/dist/dashboard/assets/route-CNSNj9Zm.js.gz +0 -0
  146. package/dist/dashboard/assets/routing-Cs6jl49L.js +1 -0
  147. package/dist/dashboard/assets/routing-Cs6jl49L.js.gz +0 -0
  148. package/dist/dashboard/assets/scaling-nWE4Cx8f.js.gz +0 -0
  149. package/dist/dashboard/assets/search-JOKus9rb.js.gz +0 -0
  150. package/dist/dashboard/assets/select-C0Rer3gF.js +41 -0
  151. package/dist/dashboard/assets/select-C0Rer3gF.js.gz +0 -0
  152. package/dist/dashboard/assets/settings-CfcTcOyU.js +1 -0
  153. package/dist/dashboard/assets/settings-CfcTcOyU.js.gz +0 -0
  154. package/dist/dashboard/assets/shield-check-DVS0Foqh.js.gz +0 -0
  155. package/dist/dashboard/assets/sparkles-BRaRUxAU.js.gz +0 -0
  156. package/dist/dashboard/assets/{stack-C1kTcOmP.js → stack-C9_uUjaA.js} +1 -1
  157. package/dist/dashboard/assets/stack-C9_uUjaA.js.gz +0 -0
  158. package/dist/dashboard/assets/{state-DHKdg-pm.js → state-DA0B95UQ.js} +1 -1
  159. package/dist/dashboard/assets/state-DA0B95UQ.js.gz +0 -0
  160. package/dist/dashboard/assets/stream-retry-5QnVuSSo.js.gz +0 -0
  161. package/dist/dashboard/assets/{switch-DIDeAiz9.js → switch-Cittxy-x.js} +1 -1
  162. package/dist/dashboard/assets/switch-Cittxy-x.js.gz +0 -0
  163. package/dist/dashboard/assets/terminal-Bd4ee8TA.js +1 -0
  164. package/dist/dashboard/assets/terminal-Bd4ee8TA.js.gz +0 -0
  165. package/dist/dashboard/assets/toast-CNt_jzO4.js +2 -0
  166. package/dist/dashboard/assets/toast-CNt_jzO4.js.gz +0 -0
  167. package/dist/dashboard/assets/tokens-DDxMbSRL.js.gz +0 -0
  168. package/dist/dashboard/assets/trash-Cppn5c3R.js.gz +0 -0
  169. package/dist/dashboard/assets/use-clipboard-B0Sy03fE.js.gz +0 -0
  170. package/dist/dashboard/assets/use-presence-BSZEBx7U.js.gz +0 -0
  171. package/dist/dashboard/assets/use-reduced-motion-C4B0R6Sz.js.gz +0 -0
  172. package/dist/dashboard/assets/use-timeout-BwcBBRrT.js.gz +0 -0
  173. package/dist/dashboard/assets/{useMutation-DVmwOKU-.js → useMutation-BQKMcJ1H.js} +1 -1
  174. package/dist/dashboard/assets/useMutation-BQKMcJ1H.js.gz +0 -0
  175. package/dist/dashboard/assets/wrench-B6E1oEuY.js.gz +0 -0
  176. package/dist/dashboard/index.html +1 -1
  177. package/dist/dashboard/index.html.gz +0 -0
  178. package/dist/dashboard/precompressed-assets.json +122 -0
  179. package/migrations/full-postgres/0013_add_providers_tenant_id_index.sql +22 -0
  180. package/migrations/full-postgres/0014_add_provider_accounts_tenant_id_index.sql +21 -0
  181. package/migrations/full-postgres/0015_add_payload_stages.sql +18 -0
  182. package/migrations/full-postgres/0016_add_share_link_slug.sql +20 -0
  183. package/migrations/full-postgres/0017_add_models_pro_only.sql +18 -0
  184. package/migrations/full-postgres/0018_add_accounts_quota_plan.sql +18 -0
  185. package/migrations/full-postgres/0019_add_models_free_tier.sql +15 -0
  186. package/migrations/lite-sqlite3/0000_baseline.sql +4 -2
  187. package/migrations/lite-sqlite3/0013_add_providers_tenant_id_index.sql +4 -0
  188. package/migrations/lite-sqlite3/0014_add_provider_accounts_tenant_id_index.sql +4 -0
  189. package/migrations/lite-sqlite3/0015_add_payload_stages.sql +18 -0
  190. package/migrations/lite-sqlite3/0016_add_share_link_slug.sql +20 -0
  191. package/migrations/lite-sqlite3/0017_add_models_pro_only.sql +18 -0
  192. package/migrations/lite-sqlite3/0018_add_accounts_quota_plan.sql +18 -0
  193. package/migrations/lite-sqlite3/0019_add_models_free_tier.sql +15 -0
  194. package/package.json +26 -30
  195. package/scripts/cli/{carte-launcher.cjs → bin/carte.cjs} +8 -8
  196. package/scripts/cli/{cli-launcher.cjs → bin/cartethyia.cjs} +167 -134
  197. package/scripts/cli/command/admin.ts +65 -0
  198. package/scripts/cli/command/console-menu.ts +246 -0
  199. package/scripts/cli/{doctor.ts → command/doctor.ts} +257 -258
  200. package/scripts/cli/{setup.ts → command/setup.ts} +8 -12
  201. package/scripts/cli/command/start.ts +77 -0
  202. package/scripts/cli/command/tray.ts +165 -0
  203. package/scripts/cli/command/update.ts +23 -0
  204. package/scripts/cli/{runtime-state.ts → gateway/drain-token.ts} +78 -78
  205. package/scripts/cli/gateway/instance-lock.ts +105 -0
  206. package/scripts/cli/gateway/spawn.ts +173 -0
  207. package/scripts/cli/gateway/stop.ts +51 -0
  208. package/scripts/cli/main.ts +94 -0
  209. package/scripts/cli/support/bun.ts +27 -0
  210. package/scripts/cli/support/env.ts +99 -0
  211. package/scripts/cli/support/platform.ts +64 -0
  212. package/scripts/cli/support/services.ts +88 -0
  213. package/scripts/cli/support/startup-signal.ts +23 -0
  214. package/scripts/cli/{version.ts → support/version.ts} +1 -1
  215. package/scripts/cli/tray/hidden-window.vbs +28 -0
  216. package/scripts/cli/{tray.ts → tray/index.ts} +3 -3
  217. package/scripts/cli/{tray.ps1 → tray/windows.ps1} +107 -98
  218. package/scripts/cli/ui/background.ts +110 -0
  219. package/src/boot/app.ts +23 -7
  220. package/src/boot/dependencies.ts +55 -13
  221. package/src/boot/elysia-typebox-bridge.ts +37 -0
  222. package/src/boot/lifecycle.ts +2 -0
  223. package/src/config/cooldowns.ts +138 -138
  224. package/src/config/providers.ts +31 -5
  225. package/src/config/runtime.ts +19 -3
  226. package/src/config/spec.ts +20 -0
  227. package/src/console/api-keys/contracts.ts +414 -391
  228. package/src/console/api-keys/routes.ts +880 -870
  229. package/src/console/auth/account-reset-store.ts +14 -0
  230. package/src/console/auth/account-reset.ts +3 -2
  231. package/src/console/auth/setup-service.ts +6 -3
  232. package/src/console/backup/routes.ts +8 -0
  233. package/src/console/cli-tools/contracts.ts +6 -6
  234. package/src/console/console-router.ts +5 -3
  235. package/src/console/dashboard-assets.ts +81 -11
  236. package/src/console/domain-registration.ts +9 -2
  237. package/src/console/observability/contracts.ts +475 -423
  238. package/src/console/observability/routes.ts +31 -6
  239. package/src/console/observability/store.ts +1015 -912
  240. package/src/console/providers/catalog/contracts.ts +31 -0
  241. package/src/console/providers/catalog/provider-operations.ts +8 -0
  242. package/src/console/providers/catalog/public-model-store.ts +91 -60
  243. package/src/console/providers/catalog/store.ts +1532 -1521
  244. package/src/console/providers/oauth/callback-listener.ts +18 -3
  245. package/src/console/providers/oauth/routes.ts +38 -15
  246. package/src/console/quota/account-quota-tenant.ts +9 -6
  247. package/src/console/quota/refresh.ts +19 -0
  248. package/src/console/response-compression.ts +69 -2
  249. package/src/console/routing/pools/probe-verdict.ts +72 -0
  250. package/src/console/share/share-router.ts +7 -4
  251. package/src/console/share/share-stats.ts +12 -5
  252. package/src/console/shared/read-through-cache.ts +133 -7
  253. package/src/console/updates/repository-updates.ts +39 -34
  254. package/src/console/updates/routes.ts +162 -4
  255. package/src/main.ts +3 -0
  256. package/src/network/http2-bidi.ts +85 -0
  257. package/src/network/outbound-fetch.ts +87 -24
  258. package/src/network/pool/agent.ts +478 -460
  259. package/src/network/pool/resolver.ts +1104 -544
  260. package/src/network/pool-health-machine.ts +12 -5
  261. package/src/observability/event-log.ts +29 -8
  262. package/src/observability/metrics.ts +40 -13
  263. package/src/observability/payload-capture.ts +344 -324
  264. package/src/observability/runtime-metrics.ts +7 -0
  265. package/src/persistence/db-pg.ts +9 -3
  266. package/src/persistence/db-sqlite.ts +497 -495
  267. package/src/persistence/health-event-flush.ts +137 -0
  268. package/src/persistence/migrate.ts +181 -129
  269. package/src/persistence/schema.ts +1360 -1257
  270. package/src/persistence/settings-revision.ts +25 -0
  271. package/src/persistence/shared-cache.ts +217 -0
  272. package/src/persistence/stores/share-store.ts +556 -528
  273. package/src/persistence/usage-flush.ts +61 -1
  274. package/src/protocol/canonical-model.ts +23 -1
  275. package/src/protocol/response/chat.ts +143 -10
  276. package/src/protocol/response/codex.ts +9 -75
  277. package/src/protocol/response/omp-tool-call-markup.ts +221 -0
  278. package/src/protocol/tool-identity.ts +43 -9
  279. package/src/providers/authentication/jwt-validator.ts +130 -187
  280. package/src/providers/authentication/oauth-flow-store.ts +16 -9
  281. package/src/providers/default-registry.ts +177 -1
  282. package/src/providers/discovery/catalog/catalog-format.ts +20 -0
  283. package/src/providers/discovery/models-dev-catalog.ts +39 -22
  284. package/src/providers/discovery/openai-model-discovery.ts +39 -3
  285. package/src/providers/discovery/openrouter-free-roster.ts +98 -0
  286. package/src/providers/discovery/probe-wire.ts +29 -5
  287. package/src/providers/discovery/probing-service.ts +1022 -1001
  288. package/src/providers/integrations/buddy/buddy-catalog-shared.ts +42 -2
  289. package/src/providers/integrations/buddy/buddy-chat-shared.ts +913 -914
  290. package/src/providers/integrations/buddy/buddy-cli-headers.ts +88 -0
  291. package/src/providers/integrations/buddy/buddy-conversation-headers.ts +22 -5
  292. package/src/providers/integrations/buddy/buddy-discovery-shared.ts +33 -1
  293. package/src/providers/integrations/buddy/buddy-identity.ts +46 -0
  294. package/src/providers/integrations/buddy/buddy-oauth-shared.ts +37 -1
  295. package/src/providers/integrations/buddy/buddy-usage-store.ts +40 -5
  296. package/src/providers/integrations/buddy/codebuddy/cn-provider.ts +4 -0
  297. package/src/providers/integrations/buddy/codebuddy/provider.ts +180 -175
  298. package/src/providers/integrations/buddy/codebuddy/shared.ts +115 -73
  299. package/src/providers/integrations/buddy/workbuddy/cn-provider.ts +5 -2
  300. package/src/providers/integrations/buddy/workbuddy/provider.ts +206 -204
  301. package/src/providers/integrations/buddy/workbuddy/quota.ts +172 -159
  302. package/src/providers/integrations/buddy/workbuddy/shared.ts +200 -171
  303. package/src/providers/integrations/claude/claude.ts +606 -582
  304. package/src/providers/integrations/cline/cline-quota.ts +18 -16
  305. package/src/providers/integrations/cline/cline.ts +539 -468
  306. package/src/providers/integrations/codex/codex.ts +5 -8
  307. package/src/providers/integrations/commandcode/commandcode.ts +675 -541
  308. package/src/providers/integrations/commandcode/discovery.ts +116 -0
  309. package/src/providers/integrations/commandcode/quota.ts +160 -0
  310. package/src/providers/integrations/cursor/cursor-auth.ts +418 -0
  311. package/src/providers/integrations/cursor/cursor-catalog.ts +368 -0
  312. package/src/providers/integrations/cursor/cursor-http2.ts +300 -0
  313. package/src/providers/integrations/cursor/cursor-identity.ts +92 -0
  314. package/src/providers/integrations/cursor/cursor-request.ts +471 -0
  315. package/src/providers/integrations/cursor/cursor-session-manager.ts +281 -0
  316. package/src/providers/integrations/cursor/cursor-shared.ts +108 -0
  317. package/src/providers/integrations/cursor/cursor.ts +960 -0
  318. package/src/providers/integrations/cursor/protocol/framing.ts +129 -0
  319. package/src/providers/integrations/cursor/protocol/messages.ts +2355 -0
  320. package/src/providers/integrations/cursor/protocol/struct.ts +305 -0
  321. package/src/providers/integrations/factory/factory-oauth.ts +7 -11
  322. package/src/providers/integrations/github/github.ts +4 -4
  323. package/src/providers/integrations/hermes.ts +79 -8
  324. package/src/providers/integrations/kilo/kilo-discovery.ts +10 -1
  325. package/src/providers/integrations/kilo/kilo.ts +153 -146
  326. package/src/providers/integrations/opencode/opencode-go-quota.ts +130 -0
  327. package/src/providers/integrations/opencode/provider.ts +532 -499
  328. package/src/providers/integrations/openrouter/provider.ts +139 -281
  329. package/src/providers/integrations/qoder/qoder-catalog.ts +450 -0
  330. package/src/providers/integrations/qoder/qoder-oauth.ts +310 -263
  331. package/src/providers/integrations/qoder/qoder-realm.ts +106 -0
  332. package/src/providers/integrations/qoder/qoder-virtual.ts +77 -0
  333. package/src/providers/integrations/qoder/qoder.ts +865 -730
  334. package/src/providers/integrations/shared/install-id.ts +29 -2
  335. package/src/providers/integrations/trae/trae-catalog.ts +190 -0
  336. package/src/providers/integrations/trae/trae-headers.ts +149 -0
  337. package/src/providers/integrations/trae/trae-oauth.ts +773 -0
  338. package/src/providers/integrations/trae/trae-protocol.ts +400 -0
  339. package/src/providers/integrations/trae/trae-realm.ts +220 -0
  340. package/src/providers/integrations/trae/trae.ts +458 -0
  341. package/src/providers/operations/account-health-service.ts +107 -10
  342. package/src/providers/operations/client-versions.ts +143 -6
  343. package/src/providers/operations/model-catalog-cache.ts +18 -3
  344. package/src/providers/operations/provider-catalog-seeder.ts +95 -86
  345. package/src/providers/operations/provider-catalog-service.ts +76 -0
  346. package/src/providers/operations/provider-credential-service.ts +46 -7
  347. package/src/providers/provider-contracts.ts +76 -0
  348. package/src/providers/provider-metadata.ts +78 -6
  349. package/src/providers/provider-model-definition.ts +373 -349
  350. package/src/security/api-key-auth.ts +126 -8
  351. package/src/transport/contracts.ts +3 -0
  352. package/src/transport/dispatch/attempt-finalize.ts +39 -2
  353. package/src/transport/dispatch/attempt-loop.ts +530 -513
  354. package/src/transport/dispatch/empty-stream.ts +17 -26
  355. package/src/transport/dispatch/generation.ts +88 -21
  356. package/src/transport/dispatch/leases.ts +99 -2
  357. package/src/transport/dispatch/proxy-request.ts +60 -3
  358. package/src/transport/dispatch/streaming/finalize.ts +16 -1
  359. package/src/transport/dispatch/streaming/stream.ts +22 -2
  360. package/src/transport/dispatch/upstream.ts +19 -1
  361. package/src/transport/failure-policy.ts +20 -0
  362. package/src/transport/guards/auth-guard.ts +26 -1
  363. package/src/transport/guards/body-policy.ts +5 -4
  364. package/src/transport/request/preparer.ts +103 -0
  365. package/src/transport/request/state.ts +14 -0
  366. package/src/transport/routing/plan-gate.ts +45 -0
  367. package/src/transport/routing/route-catalog.ts +799 -738
  368. package/src/transport/routing/route-model.ts +52 -1
  369. package/src/transport/routing/router.ts +51 -0
  370. package/src/transport/surface/chat/encode.ts +18 -31
  371. package/src/transport/surface/messages/stream.ts +3 -2
  372. package/src/transport/translation/tool-repair.ts +37 -16
  373. package/src/workers/account-identity-sweep.ts +14 -12
  374. package/src/workers/daily-checkin.ts +62 -0
  375. package/src/workers/quota-refresh-sweep.ts +27 -2
  376. package/dist/dashboard/assets/BannedPage-DUrXpp-X.js +0 -1
  377. package/dist/dashboard/assets/CombosPage-B7ETkfVH.js +0 -1
  378. package/dist/dashboard/assets/ConsoleLogPage-AisQzCI1.js +0 -1
  379. package/dist/dashboard/assets/HealthEventsModal-BNflDm_f.js +0 -1
  380. package/dist/dashboard/assets/ModelPicker-CAiPR5Vd.js +0 -1
  381. package/dist/dashboard/assets/OverviewPage-C3NIzsDx.js +0 -1
  382. package/dist/dashboard/assets/ProviderDetailPage-BK8i5B5X.js +0 -3
  383. package/dist/dashboard/assets/ProviderIcon-paOCq8I0.js +0 -1
  384. package/dist/dashboard/assets/ProvidersPage-CvCVgm1e.js +0 -1
  385. package/dist/dashboard/assets/ProxyPage-DjabkuzP.js +0 -7
  386. package/dist/dashboard/assets/QuotaPage-Dxe0B0iu.js +0 -1
  387. package/dist/dashboard/assets/SetupPage-BaD4P_jW.js +0 -1
  388. package/dist/dashboard/assets/StudioPage-CLP4le3U.js +0 -31
  389. package/dist/dashboard/assets/UsagePage-Djes-Epy.js +0 -56
  390. package/dist/dashboard/assets/api-keys-CjIGQhe7.js +0 -1
  391. package/dist/dashboard/assets/app-Cjgo4-Lf.css +0 -2
  392. package/dist/dashboard/assets/assert-Bwh6DJTK.js +0 -1
  393. package/dist/dashboard/assets/cli-tools-DccaOfhX.js +0 -1
  394. package/dist/dashboard/assets/clipboard-button-DQmMikNQ.js +0 -1
  395. package/dist/dashboard/assets/entry-Cpi0rph1.js +0 -4
  396. package/dist/dashboard/assets/entry-Dm3PpDCO.js +0 -1
  397. package/dist/dashboard/assets/entry-keNDZ_Wz.js +0 -1
  398. package/dist/dashboard/assets/github-badge-BuAJr8oU.js +0 -1
  399. package/dist/dashboard/assets/model-bans-CVvHZ-qz.js +0 -1
  400. package/dist/dashboard/assets/mutation-BIizFZav.js +0 -1
  401. package/dist/dashboard/assets/privacy-mode-CQjbZ3xk.js +0 -1
  402. package/dist/dashboard/assets/routing-jntw9-Ui.js +0 -1
  403. package/dist/dashboard/assets/select-CGUuTQmj.js +0 -41
  404. package/dist/dashboard/assets/settings-BAS6QbmJ.js +0 -1
  405. package/dist/dashboard/assets/sliders-horizontal-ClG7dBeJ.js +0 -1
  406. package/dist/dashboard/assets/terminal-C9mZ4Ugm.js +0 -1
  407. package/dist/dashboard/assets/toast-DKAdP2mo.js +0 -2
  408. package/scripts/cli/cli.ts +0 -771
  409. package/scripts/operations/probe-egress.ts +0 -335
  410. package/scripts/operations/restart.ts +0 -120
  411. package/scripts/shared/env.ts +0 -269
  412. /package/{tray-assets → scripts/cli/tray/assets}/cartethyia.ico +0 -0
  413. /package/{tray-assets → scripts/cli/tray/assets}/cartethyia.png +0 -0
  414. /package/scripts/cli/{menu.ts → ui/prompt.ts} +0 -0
  415. /package/scripts/cli/{wordmark.ts → ui/wordmark.ts} +0 -0
@@ -25,6 +25,7 @@
25
25
  * already spent.
26
26
  */
27
27
  import { log } from "../../../observability/logger";
28
+ import { ConsoleDomainError } from "../../shared/errors";
28
29
 
29
30
  /** A loopback endpoint a callback can actually be delivered to. */
30
31
  export interface LoopbackCallbackEndpoint {
@@ -224,10 +225,15 @@ export class OAuthCallbackListener {
224
225
  try {
225
226
  servers.push(this.#serve({ hostname: "127.0.0.1", port: endpoint.port, fetch: handler }));
226
227
  } catch (cause) {
227
- throw new Error(
228
+ // A domain error, not a bare Error: the console route maps an untyped
229
+ // failure to a generic `internal_error`, which hid an actionable "free
230
+ // the port" message behind "Failed to start OAuth".
231
+ throw new ConsoleDomainError(
232
+ "oauth_callback_port_unavailable",
233
+ 409,
228
234
  `OAuth callback port ${endpoint.port} is in use, so the login redirect to ${endpoint.path} cannot be delivered. ` +
229
235
  `Free port ${endpoint.port} and retry.`,
230
- { cause },
236
+ { port: endpoint.port, path: endpoint.path },
231
237
  );
232
238
  }
233
239
  try {
@@ -253,7 +259,16 @@ export class OAuthCallbackListener {
253
259
  }
254
260
  const error = url.searchParams.get("error");
255
261
  const state = url.searchParams.get("state") ?? "";
256
- const code = url.searchParams.get("code") ?? "";
262
+ /**
263
+ * Not every authorization server hands back a `code`. Trae's page redirects
264
+ * with the refresh token already in the query (`?isRedirect=true&
265
+ * refreshToken=…`), and the client's parser reads the whole query. So an
266
+ * absent `code` falls back to the query itself — the client decides whether
267
+ * it can redeem it, and one that cannot still fails with its own message
268
+ * rather than a bare 400 here.
269
+ */
270
+ const codeParam = url.searchParams.get("code") ?? "";
271
+ const code = codeParam.length > 0 ? codeParam : url.search.slice(1);
257
272
  if (error !== null) {
258
273
  // A denial carries the state only when the server echoes one; clear it
259
274
  // when present so a retry starts a fresh flow.
@@ -28,14 +28,22 @@ import type { ConsoleAccessResolver } from "../../auth/access";
28
28
  * identity the provider itself reports (an email or org name), which is stable
29
29
  * across logins, so it is what a re-login replaces.
30
30
  *
31
- * A provider that reports no label falls back to the refresh-token fingerprint,
32
- * which is the old behaviour and still catches an exact replay. Renaming an
33
- * account in the console changes the stored label, so a later login is treated
34
- * as a new account rather than silently overwriting the rename — the same
35
- * tradeoff the unique index has always had, now stated where it is decided.
31
+ * A label the provider never resolved is *not* that identity. Callers fill the
32
+ * gap with the provider id (`cursor`, `github`), and hashing that made every
33
+ * login of every account of that provider collide: the second login matched the
34
+ * first row and replaced it, so adding an account silently consumed the one
35
+ * already there. Only a label the provider actually reported — or the refresh
36
+ * token, which still catches an exact replay — may be the identity.
37
+ *
38
+ * Test seam: exported so the merge rule can be pinned without a database.
36
39
  */
37
- function oauthIdentityFingerprint(input: { readonly label: string } & OAuthExchangeResult): string {
38
- return hashSecret(input.label.trim().length > 0 ? input.label.trim() : input.refresh);
40
+ export function oauthIdentityFingerprint(input: {
41
+ readonly label: string;
42
+ readonly providerId: string;
43
+ } & OAuthExchangeResult): string {
44
+ const label = input.label.trim();
45
+ const resolved = label.length > 0 && label.toLowerCase() !== input.providerId.trim().toLowerCase();
46
+ return hashSecret(resolved ? label : input.refresh);
39
47
  }
40
48
 
41
49
  export class DrizzleOAuthAccountStore implements OAuthAccountStore {
@@ -46,7 +54,7 @@ export class DrizzleOAuthAccountStore implements OAuthAccountStore {
46
54
  providerId: string,
47
55
  input: { readonly label: string } & OAuthExchangeResult,
48
56
  ): Promise<PersistedOAuthAccount> {
49
- const credentialFingerprint = oauthIdentityFingerprint(input);
57
+ const credentialFingerprint = oauthIdentityFingerprint({ ...input, providerId });
50
58
  const sameAccount = and(
51
59
  eq(providerAccounts.providerId, providerId),
52
60
  // The identity index coalesces a null tenant to a sentinel, so "same
@@ -375,12 +383,32 @@ export function createOAuthLoginOperations(config: OAuthLoginConfig) {
375
383
  }
376
384
  const state = generateOAuthState();
377
385
  const { codeVerifier, codeChallenge } = generatePkcePair();
386
+ /**
387
+ * A client whose authorize URL depends on an upstream discovery runs it
388
+ * here, before the URL is built: `buildAuthorizeUrl` is synchronous by
389
+ * contract, so a client cannot fetch inside it.
390
+ *
391
+ * Trae is the reason this is wired up: its login host comes from
392
+ * `GetLoginGuidance`, and without this step the URL was built with no
393
+ * host and the login failed at the first click. Kiro declares the hook
394
+ * as a no-op, so nothing that already worked changes.
395
+ */
396
+ const authorizeRequest = {
397
+ state,
398
+ codeChallenge,
399
+ redirectUri: client.browserRedirectUri ?? browserAuthorizeRedirectUri(),
400
+ ...(parameters === undefined ? {} : { parameters }),
401
+ };
402
+ const prepared =
403
+ typeof client.prepareAuthorize === "function"
404
+ ? await client.prepareAuthorize(authorizeRequest)
405
+ : authorizeRequest;
378
406
  // A client that names its own allowlisted redirect URI wins over the
379
407
  // gateway default: OpenAI registers exactly `http://localhost:1455/auth/callback`
380
408
  // for the Codex client and answers any other value with
381
409
  // `invalid_request` before consent, so the shared default cannot serve
382
410
  // every provider.
383
- const redirectUri = client.browserRedirectUri ?? browserAuthorizeRedirectUri();
411
+ const redirectUri = authorizeRequest.redirectUri;
384
412
  // Bind the redirect's loopback port before the flow is recorded. The
385
413
  // advertised URI names the operator's own machine, so a login that only
386
414
  // advertises it leaves the browser on a dead page and the code stranded
@@ -404,12 +432,7 @@ export function createOAuthLoginOperations(config: OAuthLoginConfig) {
404
432
  // deliver itself; a non-loopback redirect (a custom scheme, a remote
405
433
  // host) returns false and keeps the manual path.
406
434
  return {
407
- authorizeUrl: client.buildAuthorizeUrl({
408
- state,
409
- codeChallenge,
410
- redirectUri,
411
- ...(parameters === undefined ? {} : { parameters }),
412
- }),
435
+ authorizeUrl: client.buildAuthorizeUrl(prepared),
413
436
  state,
414
437
  };
415
438
  },
@@ -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
@@ -377,6 +377,25 @@ async function runQuotaRefresh(
377
377
  // unsupported") is itself the answer the page must render, and caching it
378
378
  // stops every page open from re-hitting a provider that just refused us.
379
379
  await setCachedQuota(targetLens(target), target.accountId, quota, deps.redis);
380
+ // Persist the plan the provider reported, because routing gates on it: some
381
+ // upstreams refuse a model on a free plan and nothing but the account's own
382
+ // plan says which. The cache above is a short in-process TTL and empty after
383
+ // a restart, so it cannot be the only copy — the row is what survives one.
384
+ //
385
+ // A failed fetch leaves the last known plan in place rather than clearing
386
+ // it. The refresh failing is evidence about the network or the credential,
387
+ // not evidence that the account's plan changed, and clearing on failure
388
+ // would drop the router back to "unknown" on every transient error.
389
+ if (quota.plan !== null) {
390
+ try {
391
+ await deps.db
392
+ .update(providerAccounts)
393
+ .set({ quotaPlan: quota.plan })
394
+ .where(eq(providerAccounts.id, target.accountId));
395
+ } catch (error) {
396
+ log.warn(`[quota] failed to record plan for account=${target.accountId}`, error as Error);
397
+ }
398
+ }
380
399
  // A quota surface that names the account adopts the identity — but only over
381
400
  // a default label. An operator's custom rename is never overwritten, and the
382
401
  // CASE keeps the read and the write in one statement so a concurrent rename
@@ -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
@@ -227,12 +227,19 @@ const TOP_IPS_LIMIT = 50;
227
227
  const HOURS_WINDOW = 24;
228
228
 
229
229
  /**
230
- * Cache lifetime for one family's rollup. Equal to the share page's 2 s SSE
231
- * tick: a viewer cannot observe staleness longer than the refresh it already
232
- * accepts, while every viewer of one link shares a single computation. Declared
233
- * here rather than imported from the router so this port does not depend on it.
230
+ * Cache lifetime for one family's rollup.
231
+ *
232
+ * Matched to the share page's SSE tick, which was the point of the old 2 s
233
+ * value — but at 2 s the cache expired on every tick, so every single push was
234
+ * a full parse of the retained telemetry window. A share page's numbers move
235
+ * when a request does, not every 2 s; 10 s still reads as live, and cuts the
236
+ * parse rate by five.
237
+ *
238
+ * Declared here rather than imported from the router so this port does not
239
+ * depend on it. `SHARE_STATS_STREAM_INTERVAL_MS` must be raised with it, or
240
+ * the two drift apart and the cache expires between ticks for no reason.
234
241
  */
235
- export const SHARE_STATS_CACHE_TTL_SECONDS = 2;
242
+ export const SHARE_STATS_CACHE_TTL_SECONDS = 10;
236
243
 
237
244
  /**
238
245
  * Cache key for one family's rollup. Both inputs change the payload — `keyIds`
@@ -10,8 +10,26 @@
10
10
  * A Redis that is slow or down must never fail a request: every cache step
11
11
  * falls through to computing the answer, which is exactly what the request
12
12
  * would have done before there was a cache.
13
+ *
14
+ * ## Validators
15
+ *
16
+ * The endpoints answer dashboard polls that repeat every few seconds while the
17
+ * underlying telemetry window changes every minute. Without a validator every
18
+ * poll ships the whole payload — 8.8 KiB to 150 KiB of it — even when nothing
19
+ * changed. An ETag turns that into a ~200-byte `304`.
20
+ *
21
+ * Strong, not weak: the hash covers the exact serialized bytes the client
22
+ * would otherwise have received, so a matching validator means byte-identical
23
+ * content. A weaker or unstable hash would answer `304` for changed data and
24
+ * leave the dashboard showing stale numbers, which is worse than the bytes it
25
+ * saves.
26
+ *
27
+ * The ETag is computed **once per write**, never per read: hashing a 150 KiB
28
+ * body on every poll would move the cost off the wire and onto the CPU.
13
29
  */
14
30
 
31
+ import { createHash } from "node:crypto";
32
+
15
33
  /** The slice of Redis this cache needs, so it can be tested without a server. */
16
34
  export interface ReadThroughCacheClient {
17
35
  get(key: string): Promise<string | null>;
@@ -25,25 +43,133 @@ export interface ReadThroughCacheClient {
25
43
  */
26
44
  export const READ_THROUGH_DEFAULT_TTL_SECONDS = 10;
27
45
 
28
- /** `undefined` when the row was never cached, so a computed `null` is cacheable. */
29
- export async function cachedRead<T>(
46
+ /** The cached value plus the strong validator for the bytes it serializes to. */
47
+ export interface CachedResult<T> {
48
+ readonly value: T;
49
+ readonly etag: string;
50
+ }
51
+
52
+ /** Quoted per RFC 9110: an entity-tag is always `"…"`. */
53
+ function etagFor(bytes: string): string {
54
+ // SHA-256 over the exact serialized bytes. Not the FNV-1a in
55
+ // `payload-store.ts` — that one is module-private, and adding a second hash
56
+ // implementation would risk two different collision profiles for one
57
+ // purpose. SHA-256 is already used in `share-stats.ts` for cache identity.
58
+ return `"${createHash("sha256").update(bytes, "utf8").digest("hex").slice(0, 32)}"`;
59
+ }
60
+
61
+ /** The sibling key holding the validator, so a cached body is never re-hashed. */
62
+ export function etagKey(key: string): string {
63
+ return `${key}:etag`;
64
+ }
65
+
66
+ /**
67
+ * Reads through the cache and returns a strong validator alongside the value.
68
+ *
69
+ * The validator is computed once, at the moment the body is written, and read
70
+ * back from its own key: a hit never hashes the payload. On a validator miss
71
+ * with a body hit — a partial eviction, or a row written by an older build —
72
+ * the body is re-hashed rather than discarded, so the pair cannot disagree.
73
+ *
74
+ * With no cache configured (`client === undefined`) the value is computed and
75
+ * the validator is still returned, so conditional requests keep working for
76
+ * the length of the client's own polling loop; only the cross-instance sharing
77
+ * is lost.
78
+ */
79
+ export async function cachedReadWithEtag<T>(
30
80
  client: ReadThroughCacheClient | undefined,
31
81
  key: string,
32
82
  compute: () => Promise<T>,
33
83
  ttlSeconds: number = READ_THROUGH_DEFAULT_TTL_SECONDS,
34
- ): Promise<T> {
35
- if (client === undefined) return compute();
84
+ ): Promise<CachedResult<T>> {
85
+ if (client === undefined) {
86
+ const fresh = await compute();
87
+ return { value: fresh, etag: etagFor(JSON.stringify(fresh) ?? "null") };
88
+ }
36
89
  try {
37
90
  const hit = await client.get(key);
38
- if (hit !== null) return JSON.parse(hit) as T;
91
+ if (hit !== null) {
92
+ const value = JSON.parse(hit) as T;
93
+ try {
94
+ const stored = await client.get(etagKey(key));
95
+ return { value, etag: stored ?? etagFor(hit) };
96
+ } catch {
97
+ return { value, etag: etagFor(hit) };
98
+ }
99
+ }
39
100
  } catch {
40
- return compute();
101
+ // A cache read must never fail a request; compute instead.
41
102
  }
42
103
  const value = await compute();
104
+ const bytes = JSON.stringify(value) ?? "null";
105
+ const etag = etagFor(bytes);
43
106
  try {
44
- await client.set(key, JSON.stringify(value), "EX", ttlSeconds);
107
+ await client.set(key, bytes, "EX", ttlSeconds);
108
+ await client.set(etagKey(key), etag, "EX", ttlSeconds);
45
109
  } catch {
46
110
  // A cache write must never fail a request that already has its answer.
47
111
  }
112
+ return { value, etag };
113
+ }
114
+
115
+ /** `undefined` when the row was never cached, so a computed `null` is cacheable. */
116
+ export async function cachedRead<T>(
117
+ client: ReadThroughCacheClient | undefined,
118
+ key: string,
119
+ compute: () => Promise<T>,
120
+ ttlSeconds: number = READ_THROUGH_DEFAULT_TTL_SECONDS,
121
+ ): Promise<T> {
122
+ const { value } = await cachedReadWithEtag(client, key, compute, ttlSeconds);
48
123
  return value;
49
124
  }
125
+
126
+ /**
127
+ * `cache-control` for a cached console read.
128
+ *
129
+ * `no-cache`, not `no-store`: the response may be stored, it simply must be
130
+ * revalidated, which is exactly what the ETag is for. `no-store` would forbid
131
+ * storing and so forbid the `304` this exists to enable.
132
+ */
133
+ const CACHE_CONTROL = "no-cache";
134
+
135
+ /**
136
+ * Answers a conditional GET from a {@link CachedResult}.
137
+ *
138
+ * A match returns a bodiless `304`. An `If-None-Match` that does not match, or
139
+ * a request that sent none, returns the body with the validator attached so
140
+ * the next poll can be the cheap one.
141
+ *
142
+ * `If-None-Match` may carry several validators or the `*` form, so the header
143
+ * is parsed rather than compared as one string.
144
+ */
145
+ export function conditionalResponse<T>(
146
+ ifNoneMatch: string | null,
147
+ result: CachedResult<T>,
148
+ body: unknown,
149
+ ): Response {
150
+ if (matchesEtag(ifNoneMatch, result.etag)) {
151
+ return new Response(null, {
152
+ status: 304,
153
+ headers: { etag: result.etag, "cache-control": CACHE_CONTROL },
154
+ });
155
+ }
156
+ const response = Response.json(body);
157
+ response.headers.set("etag", result.etag);
158
+ response.headers.set("cache-control", CACHE_CONTROL);
159
+ return response;
160
+ }
161
+
162
+ /** True when an `If-None-Match` header names this validator. */
163
+ export function matchesEtag(header: string | null, etag: string): boolean {
164
+ if (header === null) return false;
165
+ const trimmed = header.trim();
166
+ if (trimmed === "*") return true;
167
+ for (const part of trimmed.split(",")) {
168
+ // The weak prefix is stripped: a weak validator still names the same
169
+ // representation, and the client that sent it asked to be told if the
170
+ // current one matches.
171
+ const candidate = part.trim().replace(/^W\//, "");
172
+ if (candidate === etag) return true;
173
+ }
174
+ return false;
175
+ }