cartethyia 2.4.6 → 2.5.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (303) hide show
  1. package/README.md +45 -13
  2. package/dist/dashboard/assets/BannedPage-ChqnTRpZ.js +1 -0
  3. package/dist/dashboard/assets/BannedPage-ChqnTRpZ.js.gz +0 -0
  4. package/dist/dashboard/assets/{CliToolDetailPage-Bcy150Jo.js → CliToolDetailPage-Bb9CNuoh.js} +1 -1
  5. package/dist/dashboard/assets/CliToolDetailPage-Bb9CNuoh.js.gz +0 -0
  6. package/dist/dashboard/assets/{CliToolsPage-DyNXefph.js → CliToolsPage-BXUaRCQQ.js} +1 -1
  7. package/dist/dashboard/assets/CliToolsPage-BXUaRCQQ.js.gz +0 -0
  8. package/dist/dashboard/assets/CombosPage-CisNZOUt.js +1 -0
  9. package/dist/dashboard/assets/CombosPage-CisNZOUt.js.gz +0 -0
  10. package/dist/dashboard/assets/{ConsoleLogPage-CATpAEOz.js → ConsoleLogPage-BOpI_LOH.js} +1 -1
  11. package/dist/dashboard/assets/ConsoleLogPage-BOpI_LOH.js.gz +0 -0
  12. package/dist/dashboard/assets/{CustomizationPage-D6jEU7nL.js → CustomizationPage-DAkdvJ4k.js} +1 -1
  13. package/dist/dashboard/assets/CustomizationPage-DAkdvJ4k.js.gz +0 -0
  14. package/dist/dashboard/assets/{HealthEventsModal-CZkpn0a7.js → HealthEventsModal-DeT-VXHd.js} +1 -1
  15. package/dist/dashboard/assets/HealthEventsModal-DeT-VXHd.js.gz +0 -0
  16. package/dist/dashboard/assets/{LoginPage-BJlMVN4U.js → LoginPage-CytxSeGP.js} +1 -1
  17. package/dist/dashboard/assets/LoginPage-CytxSeGP.js.gz +0 -0
  18. package/dist/dashboard/assets/{ModelPicker-Br3CXEau.js → ModelPicker-oQ-SDXp8.js} +1 -1
  19. package/dist/dashboard/assets/ModelPicker-oQ-SDXp8.js.gz +0 -0
  20. package/dist/dashboard/assets/OverviewPage-CU7S1wJs.js +1 -0
  21. package/dist/dashboard/assets/OverviewPage-CU7S1wJs.js.gz +0 -0
  22. package/dist/dashboard/assets/ProviderDetailPage-DLm2baot.js +3 -0
  23. package/dist/dashboard/assets/ProviderDetailPage-DLm2baot.js.gz +0 -0
  24. package/dist/dashboard/assets/ProviderIcon-CmfzxpVM.js +1 -0
  25. package/dist/dashboard/assets/ProviderIcon-CmfzxpVM.js.gz +0 -0
  26. package/dist/dashboard/assets/ProvidersPage-DOXfuzfL.js +1 -0
  27. package/dist/dashboard/assets/ProvidersPage-DOXfuzfL.js.gz +0 -0
  28. package/dist/dashboard/assets/ProxyPage-wvk0ciQN.js +7 -0
  29. package/dist/dashboard/assets/ProxyPage-wvk0ciQN.js.gz +0 -0
  30. package/dist/dashboard/assets/QuotaPage-Cn_fcUHE.js +1 -0
  31. package/dist/dashboard/assets/QuotaPage-Cn_fcUHE.js.gz +0 -0
  32. package/dist/dashboard/assets/{SettingsPage-BHW4Zt06.js → SettingsPage-8-eVadhc.js} +1 -1
  33. package/dist/dashboard/assets/SettingsPage-8-eVadhc.js.gz +0 -0
  34. package/dist/dashboard/assets/{SetupPage-VOYC_lIa.js → SetupPage-uEIXPyGO.js} +1 -1
  35. package/dist/dashboard/assets/SetupPage-uEIXPyGO.js.gz +0 -0
  36. package/dist/dashboard/assets/SortableList-DCY0TPTZ.js +1 -0
  37. package/dist/dashboard/assets/SortableList-DCY0TPTZ.js.gz +0 -0
  38. package/dist/dashboard/assets/{StudioPage-BNIDLiU6.js → StudioPage-DqK5E26W.js} +4 -4
  39. package/dist/dashboard/assets/StudioPage-DqK5E26W.js.gz +0 -0
  40. package/dist/dashboard/assets/UsagePage-38JfiZ0H.js +56 -0
  41. package/dist/dashboard/assets/UsagePage-38JfiZ0H.js.gz +0 -0
  42. package/dist/dashboard/assets/{api-keys-BETfaMYZ.js → api-keys-BmDbsior.js} +1 -1
  43. package/dist/dashboard/assets/api-keys-BmDbsior.js.gz +0 -0
  44. package/dist/dashboard/assets/app-BbpSarVV.css +2 -0
  45. package/dist/dashboard/assets/app-BbpSarVV.css.gz +0 -0
  46. package/dist/dashboard/assets/assert-BHfaei-X.js +1 -0
  47. package/dist/dashboard/assets/assert-BHfaei-X.js.gz +0 -0
  48. package/dist/dashboard/assets/{badge-BTyGk_DL.js → badge-DpXT4Ixt.js} +1 -1
  49. package/dist/dashboard/assets/badge-DpXT4Ixt.js.gz +0 -0
  50. package/dist/dashboard/assets/{banner-bujTWYM4.js → banner-CSAAWLJv.js} +1 -1
  51. package/dist/dashboard/assets/banner-CSAAWLJv.js.gz +0 -0
  52. package/dist/dashboard/assets/{card-B0Bnx5CA.js → card-DEhRXXw4.js} +1 -1
  53. package/dist/dashboard/assets/card-DEhRXXw4.js.gz +0 -0
  54. package/dist/dashboard/assets/{cli-tools-DE2ug812.js → cli-tools-B0wBq-RL.js} +1 -1
  55. package/dist/dashboard/assets/cli-tools-B0wBq-RL.js.gz +0 -0
  56. package/dist/dashboard/assets/{clipboard-button-R_rArH8S.js → clipboard-button-DOo6EU8O.js} +1 -1
  57. package/dist/dashboard/assets/clipboard-button-DOo6EU8O.js.gz +0 -0
  58. package/dist/dashboard/assets/{dialog-BWIP_EvW.js → dialog-BvQeBMfP.js} +1 -1
  59. package/dist/dashboard/assets/dialog-BvQeBMfP.js.gz +0 -0
  60. package/dist/dashboard/assets/{drawer-BKcgVugF.js → drawer-CMwPXwQB.js} +1 -1
  61. package/dist/dashboard/assets/drawer-CMwPXwQB.js.gz +0 -0
  62. package/dist/dashboard/assets/entry--yj84zk3.js +4 -0
  63. package/dist/dashboard/assets/entry--yj84zk3.js.gz +0 -0
  64. package/dist/dashboard/assets/{entry-CcWkGR4a.js → entry-D4O2229X.js} +1 -1
  65. package/dist/dashboard/assets/{entry-CcWkGR4a.js.gz → entry-D4O2229X.js.gz} +0 -0
  66. package/dist/dashboard/assets/{entry-kTiTFrvh.js → entry-DKvr5Q9K.js} +1 -1
  67. package/dist/dashboard/assets/entry-DKvr5Q9K.js.gz +0 -0
  68. package/dist/dashboard/assets/{github-badge-Booc5mNe.js → github-badge-zliLvBwr.js} +1 -1
  69. package/dist/dashboard/assets/github-badge-zliLvBwr.js.gz +0 -0
  70. package/dist/dashboard/assets/{index-D70pChfn.js → index-CcD1eSJ6.js} +2 -2
  71. package/dist/dashboard/assets/index-CcD1eSJ6.js.gz +0 -0
  72. package/dist/dashboard/assets/{inline-D1TCqM2D.js → inline-D1IlZZ8H.js} +1 -1
  73. package/dist/dashboard/assets/inline-D1IlZZ8H.js.gz +0 -0
  74. package/dist/dashboard/assets/{input-B9oyU93y.js → input-BpVZdrbc.js} +1 -1
  75. package/dist/dashboard/assets/input-BpVZdrbc.js.gz +0 -0
  76. package/dist/dashboard/assets/{layout-BArzXirQ.js → layout-BioLW7gN.js} +1 -1
  77. package/dist/dashboard/assets/layout-BioLW7gN.js.gz +0 -0
  78. package/dist/dashboard/assets/{list-row-BVZzaoHF.js → list-row-CwVcGAIc.js} +1 -1
  79. package/dist/dashboard/assets/list-row-CwVcGAIc.js.gz +0 -0
  80. package/dist/dashboard/assets/{live-EmuIayqp.js → live-yfEJFF9T.js} +1 -1
  81. package/dist/dashboard/assets/live-yfEJFF9T.js.gz +0 -0
  82. package/dist/dashboard/assets/{model-bans-CSPQ9xB0.js → model-bans-CT-TxSNX.js} +1 -1
  83. package/dist/dashboard/assets/model-bans-CT-TxSNX.js.gz +0 -0
  84. package/dist/dashboard/assets/providers/cursor.webp +0 -0
  85. package/dist/dashboard/assets/providers/trae.webp +0 -0
  86. package/dist/dashboard/assets/{routing-BExgxxBU.js → routing-Cs6jl49L.js} +1 -1
  87. package/dist/dashboard/assets/routing-Cs6jl49L.js.gz +0 -0
  88. package/dist/dashboard/assets/select-XPJ-dIw3.js +41 -0
  89. package/dist/dashboard/assets/select-XPJ-dIw3.js.gz +0 -0
  90. package/dist/dashboard/assets/settings-CfcTcOyU.js +1 -0
  91. package/dist/dashboard/assets/settings-CfcTcOyU.js.gz +0 -0
  92. package/dist/dashboard/assets/{stack-Ca5HxI5i.js → stack-C9_uUjaA.js} +1 -1
  93. package/dist/dashboard/assets/stack-C9_uUjaA.js.gz +0 -0
  94. package/dist/dashboard/assets/{state-CzUPfLXo.js → state-DA0B95UQ.js} +1 -1
  95. package/dist/dashboard/assets/state-DA0B95UQ.js.gz +0 -0
  96. package/dist/dashboard/assets/{switch-DOUIky_i.js → switch-Cittxy-x.js} +1 -1
  97. package/dist/dashboard/assets/switch-Cittxy-x.js.gz +0 -0
  98. package/dist/dashboard/assets/{toast-C76WrMhc.js → toast-CNt_jzO4.js} +1 -1
  99. package/dist/dashboard/assets/toast-CNt_jzO4.js.gz +0 -0
  100. package/dist/dashboard/assets/{useMutation-D87FZwH0.js → useMutation-BQKMcJ1H.js} +1 -1
  101. package/dist/dashboard/assets/useMutation-BQKMcJ1H.js.gz +0 -0
  102. package/dist/dashboard/index.html +1 -1
  103. package/dist/dashboard/index.html.gz +0 -0
  104. package/dist/dashboard/precompressed-assets.json +51 -52
  105. package/migrations/full-postgres/0015_add_payload_stages.sql +18 -0
  106. package/migrations/full-postgres/0016_add_share_link_slug.sql +20 -0
  107. package/migrations/full-postgres/0017_add_models_pro_only.sql +18 -0
  108. package/migrations/full-postgres/0018_add_accounts_quota_plan.sql +18 -0
  109. package/migrations/full-postgres/0019_add_models_free_tier.sql +15 -0
  110. package/migrations/lite-sqlite3/0015_add_payload_stages.sql +18 -0
  111. package/migrations/lite-sqlite3/0016_add_share_link_slug.sql +20 -0
  112. package/migrations/lite-sqlite3/0017_add_models_pro_only.sql +18 -0
  113. package/migrations/lite-sqlite3/0018_add_accounts_quota_plan.sql +18 -0
  114. package/migrations/lite-sqlite3/0019_add_models_free_tier.sql +15 -0
  115. package/package.json +3 -2
  116. package/scripts/cli/command/console-menu.ts +30 -11
  117. package/scripts/cli/command/start.ts +77 -13
  118. package/scripts/cli/command/tray.ts +47 -8
  119. package/scripts/cli/command/update.ts +1 -1
  120. package/scripts/cli/gateway/instance-lock.ts +105 -0
  121. package/scripts/cli/gateway/spawn.ts +173 -101
  122. package/scripts/cli/ui/background.ts +110 -49
  123. package/src/boot/app.ts +20 -6
  124. package/src/boot/dependencies.ts +19 -0
  125. package/src/boot/elysia-typebox-bridge.ts +37 -0
  126. package/src/boot/lifecycle.ts +2 -0
  127. package/src/config/cooldowns.ts +138 -138
  128. package/src/config/providers.ts +5 -5
  129. package/src/config/runtime.ts +19 -3
  130. package/src/config/spec.ts +9 -0
  131. package/src/console/api-keys/contracts.ts +414 -391
  132. package/src/console/api-keys/routes.ts +880 -870
  133. package/src/console/auth/account-reset-store.ts +14 -0
  134. package/src/console/auth/account-reset.ts +3 -2
  135. package/src/console/auth/setup-service.ts +6 -3
  136. package/src/console/domain-registration.ts +9 -2
  137. package/src/console/observability/contracts.ts +475 -455
  138. package/src/console/observability/store.ts +1015 -952
  139. package/src/console/providers/catalog/contracts.ts +31 -0
  140. package/src/console/providers/catalog/provider-operations.ts +8 -0
  141. package/src/console/providers/catalog/store.ts +1532 -1525
  142. package/src/console/providers/oauth/callback-listener.ts +18 -3
  143. package/src/console/providers/oauth/routes.ts +38 -15
  144. package/src/console/quota/refresh.ts +19 -0
  145. package/src/console/updates/routes.ts +162 -4
  146. package/src/main.ts +3 -0
  147. package/src/network/http2-bidi.ts +85 -0
  148. package/src/network/outbound-fetch.ts +1 -26
  149. package/src/network/pool/agent.ts +478 -460
  150. package/src/network/pool/resolver.ts +1104 -544
  151. package/src/network/pool-health-machine.ts +12 -5
  152. package/src/observability/payload-capture.ts +344 -324
  153. package/src/persistence/db-pg.ts +9 -3
  154. package/src/persistence/db-sqlite.ts +497 -495
  155. package/src/persistence/health-event-flush.ts +137 -0
  156. package/src/persistence/migrate.ts +181 -129
  157. package/src/persistence/schema.ts +1360 -1295
  158. package/src/persistence/settings-revision.ts +25 -0
  159. package/src/persistence/shared-cache.ts +217 -0
  160. package/src/persistence/stores/share-store.ts +556 -528
  161. package/src/persistence/usage-flush.ts +61 -1
  162. package/src/protocol/canonical-model.ts +23 -1
  163. package/src/protocol/response/chat.ts +143 -10
  164. package/src/protocol/response/omp-tool-call-markup.ts +221 -0
  165. package/src/providers/default-registry.ts +173 -1
  166. package/src/providers/discovery/openai-model-discovery.ts +39 -3
  167. package/src/providers/discovery/openrouter-free-roster.ts +98 -0
  168. package/src/providers/discovery/probing-service.ts +1022 -1001
  169. package/src/providers/integrations/buddy/buddy-catalog-shared.ts +39 -0
  170. package/src/providers/integrations/buddy/buddy-chat-shared.ts +913 -914
  171. package/src/providers/integrations/buddy/buddy-conversation-headers.ts +18 -5
  172. package/src/providers/integrations/buddy/codebuddy/cn-provider.ts +4 -0
  173. package/src/providers/integrations/buddy/codebuddy/provider.ts +180 -175
  174. package/src/providers/integrations/buddy/codebuddy/shared.ts +15 -6
  175. package/src/providers/integrations/buddy/workbuddy/provider.ts +206 -204
  176. package/src/providers/integrations/buddy/workbuddy/shared.ts +3 -3
  177. package/src/providers/integrations/claude/claude.ts +606 -582
  178. package/src/providers/integrations/cline/cline-quota.ts +18 -16
  179. package/src/providers/integrations/cline/cline.ts +539 -468
  180. package/src/providers/integrations/commandcode/commandcode.ts +675 -541
  181. package/src/providers/integrations/commandcode/discovery.ts +116 -0
  182. package/src/providers/integrations/commandcode/quota.ts +160 -0
  183. package/src/providers/integrations/cursor/cursor-auth.ts +418 -0
  184. package/src/providers/integrations/cursor/cursor-catalog.ts +368 -0
  185. package/src/providers/integrations/cursor/cursor-http2.ts +300 -0
  186. package/src/providers/integrations/cursor/cursor-identity.ts +92 -0
  187. package/src/providers/integrations/cursor/cursor-request.ts +471 -0
  188. package/src/providers/integrations/cursor/cursor-session-manager.ts +281 -0
  189. package/src/providers/integrations/cursor/cursor-shared.ts +108 -0
  190. package/src/providers/integrations/cursor/cursor.ts +960 -0
  191. package/src/providers/integrations/cursor/protocol/framing.ts +129 -0
  192. package/src/providers/integrations/cursor/protocol/messages.ts +2355 -0
  193. package/src/providers/integrations/cursor/protocol/struct.ts +305 -0
  194. package/src/providers/integrations/github/github.ts +4 -4
  195. package/src/providers/integrations/hermes.ts +79 -8
  196. package/src/providers/integrations/kilo/kilo-discovery.ts +10 -1
  197. package/src/providers/integrations/kilo/kilo.ts +153 -146
  198. package/src/providers/integrations/opencode/provider.ts +532 -538
  199. package/src/providers/integrations/openrouter/provider.ts +139 -281
  200. package/src/providers/integrations/qoder/qoder-catalog.ts +450 -0
  201. package/src/providers/integrations/qoder/qoder-oauth.ts +310 -257
  202. package/src/providers/integrations/qoder/qoder-realm.ts +106 -0
  203. package/src/providers/integrations/qoder/qoder-virtual.ts +77 -0
  204. package/src/providers/integrations/qoder/qoder.ts +865 -730
  205. package/src/providers/integrations/trae/trae-catalog.ts +190 -0
  206. package/src/providers/integrations/trae/trae-headers.ts +149 -0
  207. package/src/providers/integrations/trae/trae-oauth.ts +773 -0
  208. package/src/providers/integrations/trae/trae-protocol.ts +400 -0
  209. package/src/providers/integrations/trae/trae-realm.ts +220 -0
  210. package/src/providers/integrations/trae/trae.ts +458 -0
  211. package/src/providers/operations/account-health-service.ts +107 -10
  212. package/src/providers/operations/client-versions.ts +133 -6
  213. package/src/providers/operations/provider-catalog-seeder.ts +95 -86
  214. package/src/providers/operations/provider-credential-service.ts +46 -7
  215. package/src/providers/provider-contracts.ts +76 -0
  216. package/src/providers/provider-metadata.ts +78 -6
  217. package/src/providers/provider-model-definition.ts +373 -349
  218. package/src/security/api-key-auth.ts +126 -8
  219. package/src/transport/contracts.ts +3 -0
  220. package/src/transport/dispatch/attempt-finalize.ts +32 -1
  221. package/src/transport/dispatch/attempt-loop.ts +530 -515
  222. package/src/transport/dispatch/generation.ts +88 -21
  223. package/src/transport/dispatch/leases.ts +99 -2
  224. package/src/transport/dispatch/proxy-request.ts +60 -3
  225. package/src/transport/dispatch/streaming/finalize.ts +16 -1
  226. package/src/transport/dispatch/streaming/stream.ts +22 -2
  227. package/src/transport/dispatch/upstream.ts +19 -1
  228. package/src/transport/guards/auth-guard.ts +26 -1
  229. package/src/transport/guards/body-policy.ts +5 -4
  230. package/src/transport/request/preparer.ts +103 -0
  231. package/src/transport/request/state.ts +14 -0
  232. package/src/transport/routing/plan-gate.ts +45 -0
  233. package/src/transport/routing/route-catalog.ts +799 -777
  234. package/src/transport/routing/route-model.ts +52 -1
  235. package/src/transport/routing/router.ts +51 -0
  236. package/dist/dashboard/assets/BannedPage-QhvbJB9o.js +0 -1
  237. package/dist/dashboard/assets/BannedPage-QhvbJB9o.js.gz +0 -0
  238. package/dist/dashboard/assets/CliToolDetailPage-Bcy150Jo.js.gz +0 -0
  239. package/dist/dashboard/assets/CliToolsPage-DyNXefph.js.gz +0 -0
  240. package/dist/dashboard/assets/CombosPage-Cak0ljnO.js +0 -1
  241. package/dist/dashboard/assets/CombosPage-Cak0ljnO.js.gz +0 -0
  242. package/dist/dashboard/assets/ConsoleLogPage-CATpAEOz.js.gz +0 -0
  243. package/dist/dashboard/assets/CustomizationPage-D6jEU7nL.js.gz +0 -0
  244. package/dist/dashboard/assets/HealthEventsModal-CZkpn0a7.js.gz +0 -0
  245. package/dist/dashboard/assets/LoginPage-BJlMVN4U.js.gz +0 -0
  246. package/dist/dashboard/assets/ModelPicker-Br3CXEau.js.gz +0 -0
  247. package/dist/dashboard/assets/OverviewPage-CQxXbo2g.js +0 -1
  248. package/dist/dashboard/assets/OverviewPage-CQxXbo2g.js.gz +0 -0
  249. package/dist/dashboard/assets/ProviderDetailPage--0RBNh99.js +0 -3
  250. package/dist/dashboard/assets/ProviderDetailPage--0RBNh99.js.gz +0 -0
  251. package/dist/dashboard/assets/ProviderIcon-paOCq8I0.js +0 -1
  252. package/dist/dashboard/assets/ProviderIcon-paOCq8I0.js.gz +0 -0
  253. package/dist/dashboard/assets/ProvidersPage-1M-C3mbu.js +0 -1
  254. package/dist/dashboard/assets/ProvidersPage-1M-C3mbu.js.gz +0 -0
  255. package/dist/dashboard/assets/ProxyPage-CXiumA53.js +0 -7
  256. package/dist/dashboard/assets/ProxyPage-CXiumA53.js.gz +0 -0
  257. package/dist/dashboard/assets/QuotaPage-BGDIfIYP.js +0 -1
  258. package/dist/dashboard/assets/QuotaPage-BGDIfIYP.js.gz +0 -0
  259. package/dist/dashboard/assets/SettingsPage-BHW4Zt06.js.gz +0 -0
  260. package/dist/dashboard/assets/SetupPage-VOYC_lIa.js.gz +0 -0
  261. package/dist/dashboard/assets/SortableList-DpOTnGuH.js +0 -1
  262. package/dist/dashboard/assets/SortableList-DpOTnGuH.js.gz +0 -0
  263. package/dist/dashboard/assets/StudioPage-BNIDLiU6.js.gz +0 -0
  264. package/dist/dashboard/assets/UsagePage-BAFaSDQV.js +0 -56
  265. package/dist/dashboard/assets/UsagePage-BAFaSDQV.js.gz +0 -0
  266. package/dist/dashboard/assets/api-keys-BETfaMYZ.js.gz +0 -0
  267. package/dist/dashboard/assets/app-BLCvGdzb.css +0 -2
  268. package/dist/dashboard/assets/app-BLCvGdzb.css.gz +0 -0
  269. package/dist/dashboard/assets/assert-7J6_LVD3.js +0 -1
  270. package/dist/dashboard/assets/assert-7J6_LVD3.js.gz +0 -0
  271. package/dist/dashboard/assets/badge-BTyGk_DL.js.gz +0 -0
  272. package/dist/dashboard/assets/banner-bujTWYM4.js.gz +0 -0
  273. package/dist/dashboard/assets/card-B0Bnx5CA.js.gz +0 -0
  274. package/dist/dashboard/assets/cli-tools-DE2ug812.js.gz +0 -0
  275. package/dist/dashboard/assets/clipboard-button-R_rArH8S.js.gz +0 -0
  276. package/dist/dashboard/assets/dialog-BWIP_EvW.js.gz +0 -0
  277. package/dist/dashboard/assets/drawer-BKcgVugF.js.gz +0 -0
  278. package/dist/dashboard/assets/entry-7dpgvpJ3.js +0 -4
  279. package/dist/dashboard/assets/entry-7dpgvpJ3.js.gz +0 -0
  280. package/dist/dashboard/assets/entry-kTiTFrvh.js.gz +0 -0
  281. package/dist/dashboard/assets/github-badge-Booc5mNe.js.gz +0 -0
  282. package/dist/dashboard/assets/index-D70pChfn.js.gz +0 -0
  283. package/dist/dashboard/assets/inline-D1TCqM2D.js.gz +0 -0
  284. package/dist/dashboard/assets/input-B9oyU93y.js.gz +0 -0
  285. package/dist/dashboard/assets/layout-BArzXirQ.js.gz +0 -0
  286. package/dist/dashboard/assets/list-row-BVZzaoHF.js.gz +0 -0
  287. package/dist/dashboard/assets/live-EmuIayqp.js.gz +0 -0
  288. package/dist/dashboard/assets/model-bans-CSPQ9xB0.js.gz +0 -0
  289. package/dist/dashboard/assets/routing-BExgxxBU.js.gz +0 -0
  290. package/dist/dashboard/assets/select-C9mMoEpJ.js +0 -41
  291. package/dist/dashboard/assets/select-C9mMoEpJ.js.gz +0 -0
  292. package/dist/dashboard/assets/settings-C4_Gn_wq.js +0 -1
  293. package/dist/dashboard/assets/settings-C4_Gn_wq.js.gz +0 -0
  294. package/dist/dashboard/assets/sliders-horizontal-ClG7dBeJ.js +0 -1
  295. package/dist/dashboard/assets/sliders-horizontal-ClG7dBeJ.js.gz +0 -0
  296. package/dist/dashboard/assets/stack-Ca5HxI5i.js.gz +0 -0
  297. package/dist/dashboard/assets/state-CzUPfLXo.js.gz +0 -0
  298. package/dist/dashboard/assets/switch-DOUIky_i.js.gz +0 -0
  299. package/dist/dashboard/assets/toast-C76WrMhc.js.gz +0 -0
  300. package/dist/dashboard/assets/useMutation-D87FZwH0.js.gz +0 -0
  301. package/src/network/tls/bun-client-hello.ts +0 -112
  302. /package/dist/dashboard/assets/{app-Cjq7nrwu.js → app-C-Y4oZT0.js} +0 -0
  303. /package/dist/dashboard/assets/{app-Cjq7nrwu.js.gz → app-C-Y4oZT0.js.gz} +0 -0
@@ -1,777 +1,799 @@
1
- // Routing snapshot: builds the RouteSnapshot from the persisted model catalog.
2
-
3
- import { asc, eq, ne } from "drizzle-orm";
4
- import type { CartethyiaDatabase } from "../../persistence/db-handle";
5
- import {
6
- models,
7
- modelAliases,
8
- modelCombos,
9
- networkPools,
10
- poolRoutingSettings,
11
- providerAccounts,
12
- providerRoutingSettings,
13
- providers,
14
- tenantDisabledModels,
15
- cliToolMappings,
16
- cliToolSettings,
17
- } from "../../persistence/schema";
18
- import { DEFAULT_PROVIDER_MAX_INFLIGHT } from "../../persistence/schema";
19
- import { resolveTenantOverride } from "../../persistence/tenant-scope";
20
- import {
21
- type RouteCandidate,
22
- type ComboDefinition,
23
- type PoolRoutingSetting,
24
- type ProviderRoutingMap,
25
- type RouteSnapshot,
26
- type SnapshotBuilder,
27
- } from "./route-model";
28
- import { DEFAULT_PROXY_SKIP_PROVIDER_IDS } from "../../providers/provider-metadata";
29
- import { buildClaudeCliUserAgent } from "../../providers/operations/custom-cli-headers";
30
- import { isBundledProviderId } from "../../providers/provider-contracts";
31
- import type { ServiceKind, WireFamily } from "../../protocol/canonical-model";
32
- import { providerHasAdapterUserAgent, providerUsesBespokeWire } from "../../providers/provider-metadata";
33
-
34
- const CLAUDE_MODEL_FAMILIES = new Set(["opus", "sonnet", "haiku", "fable", "mythos"]);
35
-
36
- export function cliMappingSourceKeys(toolId: string, sourceModel: string): readonly string[] {
37
- if (toolId !== "claude") return [sourceModel];
38
- const normalized = sourceModel.trim().toLowerCase();
39
- const family =
40
- CLAUDE_MODEL_FAMILIES.has(normalized)
41
- ? normalized
42
- : /^claude-(opus|sonnet|haiku|fable|mythos)(?:-|$)/.exec(normalized)?.[1];
43
- if (normalized.includes("/") || family === undefined) return [sourceModel];
44
- return [...new Set([
45
- sourceModel,
46
- family,
47
- `claude-${family}-5`,
48
- `claude-${family}-5-1`,
49
- `claude-${family}-5-5`,
50
- `claude-${family}-4-6`,
51
- `claude-${family}-4-5`,
52
- ])];
53
- }
54
- /**
55
- * Builds the routing `RouteSnapshot` from the persisted catalog
56
- * (`providers`, `models`, `provider_accounts`). Consumed by
57
- * `InMemoryRouteSnapshotService` as its `SnapshotBuilder`.
58
- *
59
- * Built-in definitions are materialized before snapshots are read. The
60
- * `models` DB table is authoritative for routing; compiled definitions are
61
- * only startup seed data. Custom/BYOK and live-discovered models are stored
62
- * in the same table.
63
- *
64
- * Tenant-aware: a provider with `providers.tenant_id = NULL` is global
65
- * (every built-in, plus any pool-wide custom provider) and its candidates
66
- * carry `tenant_id: null`, matching every request regardless of tenant. A
67
- * provider with `providers.tenant_id` set (BYOK) only pairs with accounts
68
- * owned by that exact same tenant, and its candidates carry that tenant id
69
- * — `RoutingEngine.plan()` filters candidates by the requesting tenant.
70
- */
71
-
72
- type RouteCandidateWithHealth = RouteCandidate & {
73
- health_status?: "cooldown" | "model_cooldown" | "disabled";
74
- cooldown_kind?: "hard" | "soft";
75
- /**
76
- * Epoch milliseconds at which this candidate's cooling window ends, when one
77
- * is in force. Routing-internal: it is what lets `plan()` report a real reset
78
- * on the all-accounts-cooling answer, and it never reaches a client verbatim —
79
- * only the derived `retry-after` does.
80
- */
81
- cooldown_until?: number;
82
- };
83
-
84
- const HARD_COOLDOWN_CATEGORIES: Readonly<Record<string, true>> = {
85
- quota_exhausted: true,
86
- policy_blocked: true,
87
- auth_invalidated: true,
88
- };
89
-
90
- function hardCooldownCategory(category: string | null): boolean {
91
- return category !== null && HARD_COOLDOWN_CATEGORIES[category] === true;
92
- }
93
-
94
- /**
95
- * Projects one model row's metadata into the capability gates the router
96
- * filters candidates by. Exported so the projection contract is unit-testable
97
- * without a database.
98
- */
99
- export function buildCapabilityProfile(row: {
100
- modalities: unknown;
101
- reasoning: boolean;
102
- toolCall: boolean;
103
- providerId: string;
104
- }): Record<string, boolean> {
105
- const mods =
106
- row.modalities != null && typeof row.modalities === "object"
107
- ? (row.modalities as { input?: unknown[]; output?: unknown[] })
108
- : {};
109
- const inputMods = Array.isArray(mods.input) ? mods.input : [];
110
- const outputMods = Array.isArray(mods.output) ? mods.output : [];
111
- // The canonical chat and responses codecs encode `image`, `document`/`file`,
112
- // and `audio` parts; the messages codec encodes the first two but has no
113
- // audio block (see `AUDIO_CAPABLE_WIRE_FAMILIES`). A bespoke adapter (Cursor)
114
- // frames its own protocol and has no generic rich-content path.
115
- // Treating the codec routes as capable by default keeps pass-through behavior
116
- // instead of degrading a caller's attachment to text on the strength of a
117
- // metadata guess. An explicit modality still wins, so a catalog can declare
118
- // support for a bespoke adapter too. Whether a given upstream model accepts
119
- // the part is the upstream's call — a capability declared here only decides
120
- // whether the router strips the part before it ever sees it, and the audio
121
- // grant is narrowed per wire family at projection time.
122
- const codecEncodesRichContent = !providerUsesBespokeWire(row.providerId);
123
- // Reasoning and tools are never stripped here. A `false` flag — whether a
124
- // discovered row recorded it for lack of metadata, or a catalog row set it
125
- // explicitly — must not become a silent rewrite of a request the caller
126
- // asked for. The upstream decides whether it can serve them and returns its
127
- // own error if it cannot.
128
- return {
129
- // The route is not served by a canonical codec. Carried in the profile
130
- // because the capability predicates downstream see only this snapshot, not
131
- // the provider registry.
132
- bespokeWire: !codecEncodesRichContent,
133
- image: inputMods.includes("image") || codecEncodesRichContent,
134
- document: inputMods.includes("document") || codecEncodesRichContent,
135
- audio: inputMods.includes("audio") || codecEncodesRichContent,
136
- mediaGeneration: outputMods.includes("image"),
137
- tools: true,
138
- parallelToolCalls: true,
139
- reasoning: true,
140
- reasoningEncryptedContent: true,
141
- // Search is its own route: a `websearch` row answers `POST /v1/search`.
142
- // A chat row never claims it; a hosted declaration rides through `tools`.
143
- webSearch: false,
144
- responseJsonObject: true,
145
- responseJsonSchema: true,
146
- promptCaching: true,
147
- };
148
- }
149
-
150
- /**
151
- * The capability profile for one model row, memoized across rebuilds.
152
- *
153
- * Bounded LRU keyed by `providerId/modelId`: the value is a pure function of
154
- * the row's metadata, so two rebuilds that see the same row get the same object
155
- * instead of two identical ones. The bound keeps a long-lived process from
156
- * pinning one profile per row it has ever seen — a model deleted from the
157
- * catalog must not leave its profile behind forever.
158
- */
159
- const CAPABILITY_PROFILE_CACHE_MAX_ENTRIES = 4096;
160
- const capabilityProfileCache = new Map<string, Record<string, boolean>>();
161
-
162
- function capabilityProfileFor(row: {
163
- modalities: unknown;
164
- reasoning: boolean;
165
- toolCall: boolean;
166
- providerId: string;
167
- modelId: string;
168
- }): Record<string, boolean> {
169
- const key = `${row.providerId}/${row.modelId}`;
170
- const cached = capabilityProfileCache.get(key);
171
- if (cached !== undefined) return cached;
172
- const profile = buildCapabilityProfile(row);
173
- if (capabilityProfileCache.size >= CAPABILITY_PROFILE_CACHE_MAX_ENTRIES) {
174
- const oldest = capabilityProfileCache.keys().next().value;
175
- if (oldest !== undefined) capabilityProfileCache.delete(oldest);
176
- }
177
- capabilityProfileCache.set(key, profile);
178
- return profile;
179
- }
180
-
181
- /** Live memo size, for tests and the metrics gauge. */
182
- export function capabilityProfileCacheSize(): number {
183
- return capabilityProfileCache.size;
184
- }
185
-
186
- /** Resolves route identity only for bundled adapters without a native User-Agent builder. */
187
- export function resolveRouteUserAgent(
188
- providerId: string,
189
- tenantUserAgent: string | undefined,
190
- globalUserAgent: string | undefined,
191
- ): string | undefined {
192
- if (!isBundledProviderId(providerId) || providerHasAdapterUserAgent(providerId)) return undefined;
193
- // A provider whose operator set no identity of their own gets the Claude Code
194
- // CLI identity, resolved live so the stamped version tracks discovery. This
195
- // used to be a frozen `codex_cli_rs` literal, which named a different vendor's
196
- // client on requests that mostly reached neither.
197
- const selected = resolveTenantOverride(
198
- tenantUserAgent,
199
- globalUserAgent,
200
- buildClaudeCliUserAgent(),
201
- );
202
- return selected.trim() === "" ? undefined : selected;
203
- }
204
-
205
- /** One persisted model row ready for candidate construction. */
206
- interface MergedModelRow {
207
- providerId: string;
208
- modelId: string;
209
- wireFamily: WireFamily;
210
- serviceKind: ServiceKind;
211
- endpointPath: string;
212
- modalities: unknown;
213
- outputLimit: number | null;
214
- reasoning: boolean;
215
- toolCall: boolean;
216
- enabled: boolean;
217
- }
218
- /**
219
- * Converts persisted model rows into candidate input. Seed materialization is
220
- * performed before this function runs, so no static-vs-DB merge is needed.
221
- */
222
- function mergeModelCatalog(
223
- dbRows: readonly ModelRouteRow[],
224
- ): MergedModelRow[] {
225
- return dbRows
226
- .filter((row) => row.enabled)
227
- .map((row) => ({
228
- providerId: row.providerId,
229
- modelId: row.modelId,
230
- wireFamily: row.wireFamily as WireFamily,
231
- serviceKind: row.serviceKind as ServiceKind,
232
- endpointPath: row.endpointPath,
233
- modalities: row.modalities,
234
- outputLimit: row.outputLimit,
235
- reasoning: row.reasoning,
236
- toolCall: row.toolCall,
237
- enabled: row.enabled,
238
- }));
239
- }
240
-
241
- /**
242
- * Column projections for the snapshot read.
243
- *
244
- * The builder reads a handful of fields from each table, but the read used to
245
- * be a bare `select()` — every column of all ten tables, including
246
- * `provider_accounts.credential_ciphertext` and `network_pools.
247
- * endpoint_config`, which the snapshot never touches. That ciphertext is the
248
- * largest thing the gateway stores per account, and the snapshot is rebuilt on
249
- * every catalog change, so the bytes were transferred and then discarded on
250
- * every rebuild. The projection keeps the query count at ten and drops the
251
- * payload to what is actually read.
252
- *
253
- * Each object doubles as the row type (`ModelRouteRow` and friends are derived
254
- * from it), so a column added here is automatically typed, and a column the
255
- * builder starts reading but is missing here fails to compile rather than
256
- * silently reading `undefined`.
257
- */
258
- const PROVIDER_COLUMNS = {
259
- id: providers.id,
260
- tenantId: providers.tenantId,
261
- requiresAccount: providers.requiresAccount,
262
- } as const;
263
-
264
- const MODEL_COLUMNS = {
265
- providerId: models.providerId,
266
- modelId: models.modelId,
267
- wireFamily: models.wireFamily,
268
- serviceKind: models.serviceKind,
269
- endpointPath: models.endpointPath,
270
- modalities: models.modalities,
271
- outputLimit: models.outputLimit,
272
- reasoning: models.reasoning,
273
- toolCall: models.toolCall,
274
- enabled: models.enabled,
275
- } as const;
276
- const ACCOUNT_COLUMNS = {
277
- id: providerAccounts.id,
278
- providerId: providerAccounts.providerId,
279
- tenantId: providerAccounts.tenantId,
280
- label: providerAccounts.label,
281
- status: providerAccounts.status,
282
- cooldownUntil: providerAccounts.cooldownUntil,
283
- modelCooldowns: providerAccounts.modelCooldowns,
284
- lastErrorCategory: providerAccounts.lastErrorCategory,
285
- } as const;
286
-
287
- const ALIAS_COLUMNS = {
288
- tenantId: modelAliases.tenantId,
289
- alias: modelAliases.alias,
290
- targetModel: modelAliases.targetModel,
291
- } as const;
292
-
293
- const COMBO_COLUMNS = {
294
- tenantId: modelCombos.tenantId,
295
- name: modelCombos.name,
296
- members: modelCombos.members,
297
- strategy: modelCombos.strategy,
298
- } as const;
299
-
300
- const ROUTING_COLUMNS = {
301
- providerId: providerRoutingSettings.providerId,
302
- tenantId: providerRoutingSettings.tenantId,
303
- strategy: providerRoutingSettings.strategy,
304
- rotateCount: providerRoutingSettings.rotateCount,
305
- maxInflight: providerRoutingSettings.maxInflight,
306
- maxInflightPerProxy: providerRoutingSettings.maxInflightPerProxy,
307
- usageFloor: providerRoutingSettings.usageFloor,
308
- usageFloorUnit: providerRoutingSettings.usageFloorUnit,
309
- enabled: providerRoutingSettings.enabled,
310
- skipProxy: providerRoutingSettings.skipProxy,
311
- userAgent: providerRoutingSettings.userAgent,
312
- } as const;
313
-
314
- const POOL_COLUMNS = {
315
- id: networkPools.id,
316
- tenantId: networkPools.tenantId,
317
- status: networkPools.status,
318
- maxInflight: networkPools.maxInflight,
319
- weight: networkPools.weight,
320
- } as const;
321
-
322
- const DISABLED_MODEL_COLUMNS = {
323
- tenantId: tenantDisabledModels.tenantId,
324
- providerId: tenantDisabledModels.providerId,
325
- modelId: tenantDisabledModels.modelId,
326
- endpointPath: tenantDisabledModels.endpointPath,
327
- } as const;
328
-
329
- const CLI_MAPPING_COLUMNS = {
330
- tenantId: cliToolMappings.tenantId,
331
- apiKeyId: cliToolMappings.apiKeyId,
332
- toolId: cliToolMappings.toolId,
333
- sourceModel: cliToolMappings.sourceModel,
334
- targetModel: cliToolMappings.targetModel,
335
- enabled: cliToolMappings.enabled,
336
- } as const;
337
-
338
- /** The per-(tenant, tool, key) Remote Routing switch. A mapping row only
339
- * routes when its bucket's settings row has `mappings_enabled` set. */
340
- const CLI_MAPPING_SETTING_COLUMNS = {
341
- tenantId: cliToolSettings.tenantId,
342
- apiKeyId: cliToolSettings.apiKeyId,
343
- toolId: cliToolSettings.toolId,
344
- mappingsEnabled: cliToolSettings.mappingsEnabled,
345
- } as const;
346
-
347
- const POOL_SETTING_COLUMNS = {
348
- tenantId: poolRoutingSettings.tenantId,
349
- strategy: poolRoutingSettings.strategy,
350
- rotateCount: poolRoutingSettings.rotateCount,
351
- } as const;
352
-
353
- /** A row of `models` as this module reads it. */
354
- type ModelRouteRow = { [K in keyof typeof MODEL_COLUMNS]: (typeof models.$inferSelect)[K] };
355
-
356
- /** Snapshot load result from the 10-table catalog read. */
357
- type RouteCatalogSnapshotResult = Omit<RouteSnapshot, "revision" | "created_at"> & { created_at?: number };
358
-
359
- /** Repository abstracting database queries for the route catalog snapshot. */
360
- class RouteCatalogRepository {
361
- constructor(private readonly db: CartethyiaDatabase) {}
362
-
363
- async loadRouteCatalogSnapshot(tenantId?: string): Promise<RouteCatalogSnapshotResult> {
364
- const [providerRows, modelRows, accountRows, aliasRows, comboRows, routingRows, poolRows, disabledModelRows, cliMappingRows, cliMappingSettingRows, poolSettingRows] =
365
- await Promise.all([
366
- this.db.select(PROVIDER_COLUMNS).from(providers).where(eq(providers.enabled, true)),
367
- this.db.select(MODEL_COLUMNS).from(models),
368
- this.db
369
- .select(ACCOUNT_COLUMNS)
370
- .from(providerAccounts)
371
- .where(ne(providerAccounts.status, "disabled"))
372
- .orderBy(asc(providerAccounts.status), asc(providerAccounts.createdAt), asc(providerAccounts.id)),
373
- this.db.select(ALIAS_COLUMNS).from(modelAliases),
374
- this.db.select(COMBO_COLUMNS).from(modelCombos),
375
- this.db.select(ROUTING_COLUMNS).from(providerRoutingSettings),
376
- this.db.select(POOL_COLUMNS).from(networkPools),
377
- this.db.select(DISABLED_MODEL_COLUMNS).from(tenantDisabledModels),
378
- this.db.select(CLI_MAPPING_COLUMNS).from(cliToolMappings),
379
- this.db.select(CLI_MAPPING_SETTING_COLUMNS).from(cliToolSettings),
380
- this.db.select(POOL_SETTING_COLUMNS).from(poolRoutingSettings),
381
- ]);
382
- const mergedModelRows = mergeModelCatalog(modelRows);
383
-
384
- // Compile tenant-scoped disables into a lookup keyed by the same
385
- // composite identity the `models` table uses. A disabled key suppresses
386
- // the candidate whose account belongs to that tenant.
387
- const disabledModelKeys = new Set<string>(
388
- disabledModelRows.map(
389
- (row) => `${row.tenantId}:${row.providerId}:${row.modelId}:${row.endpointPath}`,
390
- ),
391
- );
392
-
393
- const providerTenantById = new Map<string, string | null>(
394
- providerRows.map((p) => [p.id, p.tenantId]),
395
- );
396
- const requiresAccountById = new Map<string, boolean>(
397
- providerRows.map((p) => [p.id, p.requiresAccount]),
398
- );
399
-
400
- // Every active (non-disabled) pool a tenant owns, available for
401
- // automatic per-request selection — see `resolveNetworkPools` below.
402
- const activePoolsByTenant = new Map<
403
- string,
404
- Array<{ id: string; maxInflight: number; weight: number }>
405
- >();
406
- const configuredPoolTenants = new Set<string>();
407
- for (const pool of poolRows) {
408
- configuredPoolTenants.add(pool.tenantId);
409
- if (pool.status !== "active") continue;
410
- if (!pool.tenantId) continue;
411
- const list = activePoolsByTenant.get(pool.tenantId) ?? [];
412
- list.push({ id: pool.id, maxInflight: pool.maxInflight ?? 10, weight: pool.weight ?? 100 });
413
- activePoolsByTenant.set(pool.tenantId, list);
414
- }
415
-
416
- // Per-tenant pool selection strategy; an absent row reads as the
417
- // `least_loaded` default at selection time.
418
- const poolRouting: Record<string, PoolRoutingSetting> = {};
419
- for (const row of poolSettingRows) {
420
- if (tenantId === undefined || row.tenantId === tenantId) {
421
- poolRouting[row.tenantId] = { strategy: row.strategy, rotateCount: row.rotateCount };
422
- }
423
- }
424
-
425
- // Build per-tenant per-provider routing preferences before the
426
- // candidates loop below, since skipProxy has to be known while
427
- // deciding each candidate's network pool set. Global settings
428
- // (tenant_id IS NULL) are stored under sentinel "__global__".
429
- const providerRouting: Record<string, Record<string, ProviderRoutingMap[string][string]>> = {};
430
- for (const row of routingRows) {
431
- const tenantKey = row.tenantId ?? "__global__";
432
- const bucket = (providerRouting[tenantKey] ??= {});
433
- bucket[row.providerId] = {
434
- strategy: row.strategy as ProviderRoutingMap[string][string]["strategy"],
435
- rotateCount: row.rotateCount ?? 1,
436
- maxInflight: row.maxInflight,
437
- maxInflightPerProxy: row.maxInflightPerProxy,
438
- usageFloor: row.usageFloor,
439
- usageFloorUnit: row.usageFloorUnit === "credit" ? "credit" : "percent",
440
- enabled: row.enabled,
441
- skipProxy: row.skipProxy,
442
- userAgent: row.userAgent,
443
- };
444
- }
445
- /** Tenant-specific setting wins over global; an unconfigured provider
446
- * falls back to `DEFAULT_PROXY_SKIP_PROVIDER_IDS`, mirroring the same
447
- * default `DrizzleProviderDetailStore.getRouting` reports to the API —
448
- * so the dashboard's displayed default and the real dispatch decision
449
- * never disagree. */
450
- function resolveSkipProxy(providerId: string, rowTenantId: string | null): boolean {
451
- const effectiveTenantId = rowTenantId;
452
- const tenantSetting = effectiveTenantId ? providerRouting[effectiveTenantId]?.[providerId] : undefined;
453
- const globalSetting = providerRouting.__global__?.[providerId];
454
- return resolveTenantOverride(
455
- tenantSetting?.skipProxy,
456
- globalSetting?.skipProxy,
457
- DEFAULT_PROXY_SKIP_PROVIDER_IDS.has(providerId),
458
- );
459
- }
460
- function resolveUserAgent(providerId: string, rowTenantId: string | null): string | undefined {
461
- const tenantSetting = rowTenantId ? providerRouting[rowTenantId]?.[providerId]?.userAgent : undefined;
462
- const globalSetting = providerRouting.__global__?.[providerId]?.userAgent;
463
- return resolveRouteUserAgent(providerId, tenantSetting, globalSetting);
464
- }
465
-
466
- /** Provider-wide concurrency ceiling shared by every account of the provider.
467
- * Tenant setting wins over global, mirroring skipProxy. A stored `null`
468
- * is the explicit unlimited choice; so is the default — a provider with no
469
- * routing row at all is unlimited too, because an operator who never
470
- * configured a ceiling asked for none. */
471
- function resolveMaxInflight(
472
- providerId: string,
473
- rowTenantId: string | null,
474
- ): number | undefined {
475
- const tenantSetting = rowTenantId
476
- ? providerRouting[rowTenantId]?.[providerId]?.maxInflight
477
- : undefined;
478
- const globalSetting = providerRouting.__global__?.[providerId]?.maxInflight;
479
- const resolved = tenantSetting ?? globalSetting;
480
- if (resolved === null) return undefined;
481
- return resolved ?? DEFAULT_PROVIDER_MAX_INFLIGHT ?? undefined;
482
- }
483
-
484
- /**
485
- * This provider's per-pool inflight ceiling, from Routing Strategy.
486
- * `undefined` means unlimited — the default, and the state of every
487
- * provider nobody has configured. A stored `null` is the same choice made
488
- * explicitly; the two are deliberately indistinguishable downstream so the
489
- * hot path never branches on which one it read.
490
- */
491
- function resolveMaxInflightPerProxy(
492
- providerId: string,
493
- rowTenantId: string | null,
494
- ): number | undefined {
495
- const tenantSetting = rowTenantId
496
- ? providerRouting[rowTenantId]?.[providerId]?.maxInflightPerProxy
497
- : undefined;
498
- const globalSetting = providerRouting.__global__?.[providerId]?.maxInflightPerProxy;
499
- return tenantSetting ?? globalSetting ?? undefined;
500
- }
501
-
502
- /** Every active pool the account's tenant owns — dispatch picks the
503
- * least-loaded/non-cooldown one per request (`tryAcquireAvailablePool`),
504
- * never an admin-pinned single pool. */
505
- function resolveNetworkPools(
506
- providerId: string,
507
- rowTenantId: string | null,
508
- ):
509
- | {
510
- ids: readonly string[];
511
- limits: Record<string, number>;
512
- weights: Record<string, number>;
513
- routing?: PoolRoutingSetting & { tenantId: string };
514
- }
515
- | undefined {
516
- if (!rowTenantId || resolveSkipProxy(providerId, rowTenantId)) return undefined;
517
- if (!configuredPoolTenants.has(rowTenantId)) return undefined;
518
- const pools = activePoolsByTenant.get(rowTenantId) ?? [];
519
- const limits: Record<string, number> = {};
520
- const weights: Record<string, number> = {};
521
- for (const pool of pools) {
522
- limits[pool.id] = pool.maxInflight;
523
- weights[pool.id] = pool.weight;
524
- }
525
- const routing = poolRouting[rowTenantId];
526
- return {
527
- ids: pools.map((pool) => pool.id),
528
- limits,
529
- weights,
530
- ...(routing ? { routing: { tenantId: rowTenantId, ...routing } } : {}),
531
- };
532
- }
533
-
534
- /** The candidate fragment for a provider ceiling, empty when unlimited —
535
- * so an unconfigured provider's candidate is byte-for-byte what it was. */
536
- function providerLimitFor(
537
- providerId: string,
538
- rowTenantId: string | null,
539
- ): { network_pool_provider_limit?: number } {
540
- const limit = resolveMaxInflightPerProxy(providerId, rowTenantId);
541
- return limit === undefined ? {} : { network_pool_provider_limit: limit };
542
- }
543
-
544
- // Group usable accounts per provider (not per provider-tenant). Built-in
545
- // providers are `tenant_id IS NULL` but their accounts are per-tenant, so
546
- // grouping by `providerTenantId` would make every global model look
547
- // disabled. Instead keep all accounts for a provider together and fan out
548
- // candidates per account tenant below — e.g. `opencodeze/muse-spark`
549
- // hits with `model: "opencodeze/muse-spark"` must resolve to the tenant's
550
- // own account even though the provider row is global.
551
- const accountsByProvider = new Map<string, typeof accountRows>();
552
- for (const account of accountRows) {
553
- if (account.status === "disabled") continue;
554
- const list = accountsByProvider.get(account.providerId) ?? [];
555
- list.push(account);
556
- accountsByProvider.set(account.providerId, list);
557
- }
558
-
559
- const candidates: RouteCandidateWithHealth[] = [];
560
- for (const model of mergedModelRows) {
561
- const providerTenantId = providerTenantById.get(model.providerId);
562
- if (providerTenantId === undefined) continue;
563
- const accounts = accountsByProvider.get(model.providerId) ?? [];
564
- // One profile per model row, memoized across snapshot rebuilds. The
565
- // profile is derived purely from the row's metadata, is never mutated
566
- // (grep finds no write to `capability_profile`), and is shared by every
567
- // candidate this row fans out to — so a rebuild no longer constructs a
568
- // fresh object per model on top of the fan-out it already had.
569
- const capabilityProfile = capabilityProfileFor(model);
570
- if (accounts.length === 0) {
571
- const providerRequiresAccount = requiresAccountById.get(model.providerId) ?? true;
572
- if (!providerRequiresAccount) {
573
- // Public models inherit active pools for each tenant that has active pools
574
- for (const tId of configuredPoolTenants) {
575
- const tenantPools = resolveNetworkPools(model.providerId, tId);
576
- if (tenantPools) {
577
- const tenantCandidate: RouteCandidateWithHealth = {
578
- provider_id: model.providerId,
579
- model_id: model.modelId,
580
- wire_family: model.wireFamily as WireFamily,
581
- service_kind: model.serviceKind,
582
- endpoint: model.endpointPath,
583
- ...(model.outputLimit == null ? {} : { output_limit: model.outputLimit }),
584
- capability_profile: capabilityProfile,
585
- tenant_id: tId,
586
- requires_account: false as const,
587
- network_pool_ids: tenantPools.ids,
588
- network_pool_required: true,
589
- network_pool_limits: tenantPools.limits,
590
- network_pool_weights: tenantPools.weights,
591
- ...(tenantPools.routing ? { network_pool_routing: tenantPools.routing } : {}),
592
- ...providerLimitFor(model.providerId, tId),
593
- };
594
- if (tenantId === undefined || tenantId === tId) {
595
- candidates.push(tenantCandidate);
596
- }
597
- }
598
- }
599
- }
600
- const networkPools = providerRequiresAccount
601
- ? undefined
602
- : resolveNetworkPools(model.providerId, providerTenantId);
603
- const candidate: RouteCandidateWithHealth = {
604
- provider_id: model.providerId,
605
- model_id: model.modelId,
606
- wire_family: model.wireFamily as WireFamily,
607
- service_kind: model.serviceKind,
608
- endpoint: model.endpointPath,
609
- ...(model.outputLimit == null ? {} : { output_limit: model.outputLimit }),
610
- capability_profile: capabilityProfile,
611
- tenant_id: providerTenantId,
612
- ...(providerRequiresAccount
613
- ? { health_status: "disabled" as const }
614
- : { requires_account: false as const }),
615
- ...(networkPools
616
- ? {
617
- network_pool_ids: networkPools.ids,
618
- network_pool_required: true,
619
- network_pool_limits: networkPools.limits,
620
- network_pool_weights: networkPools.weights,
621
- ...(networkPools.routing ? { network_pool_routing: networkPools.routing } : {}),
622
- ...providerLimitFor(model.providerId, providerTenantId),
623
- }
624
- : {}),
625
- };
626
- if (tenantId === undefined || candidate.tenant_id === null || candidate.tenant_id === tenantId) {
627
- candidates.push(candidate);
628
- }
629
- continue;
630
- }
631
- for (const account of accounts) {
632
- const rowTenantId = account.tenantId ?? providerTenantId;
633
- if (
634
- rowTenantId !== null &&
635
- disabledModelKeys.has(
636
- `${rowTenantId}:${model.providerId}:${model.modelId}:${model.endpointPath}`,
637
- )
638
- ) {
639
- continue;
640
- }
641
- const networkPools = resolveNetworkPools(model.providerId, rowTenantId);
642
- const routeUserAgent = resolveUserAgent(model.providerId, rowTenantId);
643
- const candidate: RouteCandidateWithHealth = {
644
- provider_id: model.providerId,
645
- model_id: model.modelId,
646
- wire_family: model.wireFamily as WireFamily,
647
- service_kind: model.serviceKind,
648
- endpoint: model.endpointPath,
649
- ...(model.outputLimit == null ? {} : { output_limit: model.outputLimit }),
650
- capability_profile: capabilityProfile,
651
- ...(routeUserAgent === undefined ? {} : { user_agent: routeUserAgent }),
652
- tenant_id: rowTenantId,
653
- provider_account_id: account.id,
654
- // Provider-wide concurrency ceiling from Routing Strategy; legacy
655
- // account overrides are never read here.
656
- ...(() => {
657
- const resolved = resolveMaxInflight(model.providerId, rowTenantId);
658
- return resolved === undefined ? {} : { max_inflight: resolved };
659
- })(),
660
- ...(networkPools
661
- ? {
662
- network_pool_required: true,
663
- network_pool_ids: networkPools.ids,
664
- network_pool_limits: networkPools.limits,
665
- network_pool_weights: networkPools.weights,
666
- ...(networkPools.routing ? { network_pool_routing: networkPools.routing } : {}),
667
- ...providerLimitFor(model.providerId, rowTenantId),
668
- }
669
- : {}),
670
- };
671
- if (account.status === "cooldown") {
672
- // Expired cooldowns read as healthy here without a fire-and-forget
673
- // write: AccountHealthSweeper materializes the recovery every 30s,
674
- // so the builder stays a pure read (no UPDATE racing the request).
675
- if (account.cooldownUntil && account.cooldownUntil.getTime() > Date.now()) {
676
- candidate.health_status = "cooldown";
677
- candidate.cooldown_kind = hardCooldownCategory(account.lastErrorCategory)
678
- ? "hard"
679
- : "soft";
680
- // The deadline rides with the exclusion it explains. `plan()` reads
681
- // it to answer the all-cooling case with the real reset instead of
682
- // a bare 429, so a client backs off for the window that is actually
683
- // open rather than inventing one.
684
- candidate.cooldown_until = account.cooldownUntil.getTime();
685
- }
686
- }
687
-
688
- // Per-model cooldown: an account may be healthy globally but cooling down
689
- // for a specific model. A non-expired entry for the model THIS candidate
690
- // serves is a hard exclusion, not a deprioritization: the upstream said
691
- // this exact (account, model) pair is exhausted until a stated reset
692
- // ("Daily free limit reached … Try again in 11h"), so dialing it can only
693
- // reproduce the same refusal. Deprioritizing instead made failover spend
694
- // a full round trip on it after every healthy sibling had failed, and
695
- // recorded another `active → cooldown` row each time — the operator's
696
- // report of one account churning while ten siblings sat healthy.
697
- //
698
- // `model_cooldown` (distinct from `cooldown`) is what carries that
699
- // distinction to the evaluator: an account-wide `cooldown` stays
700
- // deprioritized so a single-account deployment still routes, while a
701
- // model-scoped one is dropped for this model only and the account keeps
702
- // serving every other model it holds.
703
- const modelCooldowns = account.modelCooldowns as Record<string, string> | null;
704
- const modelCooldownUntil = modelCooldowns?.[model.modelId];
705
- if (modelCooldownUntil && new Date(modelCooldownUntil).getTime() > Date.now()) {
706
- candidate.health_status = "model_cooldown";
707
- candidate.cooldown_until = new Date(modelCooldownUntil).getTime();
708
- }
709
- if (tenantId === undefined || candidate.tenant_id === null || candidate.tenant_id === tenantId) {
710
- candidates.push(candidate);
711
- }
712
- }
713
- }
714
-
715
- const aliases: Record<string, Record<string, string>> = {};
716
- const cliAliases: Record<string, Record<string, string>> = {};
717
- for (const row of aliasRows) {
718
- if (tenantId === undefined || row.tenantId === tenantId) {
719
- (aliases[row.tenantId] ??= {})[row.alias] = row.targetModel;
720
- }
721
- }
722
- // CLI-tool mappings are per-(tenant, key): each API key can route the same
723
- // CLI slot to a different target. The snapshot keys the alias bucket by
724
- // `${tenantId}:${apiKeyId}` so the preparer can look up the exact key's
725
- // routes without merging across keys.
726
- // Remote Routing is opt-in per (tenant, tool, key): a mapping row routes
727
- // only when its bucket's settings row explicitly enabled it. The CLI tool
728
- // page and the API-key edit form both write that one flag, so either place
729
- // can turn the routes on or off.
730
- const enabledMappingBuckets = new Set<string>();
731
- for (const row of cliMappingSettingRows) {
732
- if (row.mappingsEnabled) enabledMappingBuckets.add(`${row.tenantId}:${row.apiKeyId}`);
733
- }
734
- for (const row of cliMappingRows) {
735
- if (!row.enabled) continue;
736
- if (!enabledMappingBuckets.has(`${row.tenantId}:${row.apiKeyId}`)) continue;
737
- if (tenantId === undefined || row.tenantId === tenantId) {
738
- const bucketKey = `${row.tenantId}:${row.apiKeyId}`;
739
- const aliasBucket = (cliAliases[bucketKey] ??= {});
740
- for (const sourceKey of cliMappingSourceKeys(row.toolId, row.sourceModel)) {
741
- aliasBucket[sourceKey] = row.targetModel;
742
- }
743
- }
744
- }
745
- const combos: Record<string, Record<string, ComboDefinition>> = {};
746
- for (const row of comboRows) {
747
- if (tenantId === undefined || row.tenantId === tenantId) {
748
- (combos[row.tenantId] ??= {})[row.name] = { members: row.members, strategy: row.strategy };
749
- }
750
- }
751
-
752
- const routingMap: ProviderRoutingMap | undefined =
753
- Object.keys(providerRouting).length > 0 ? (providerRouting as ProviderRoutingMap) : undefined;
754
-
755
- return {
756
- candidates,
757
- aliases,
758
- cli_aliases: cliAliases,
759
- combos,
760
- ...(routingMap ? { providerRouting: routingMap } : {}),
761
- ...(Object.keys(poolRouting).length > 0 ? { poolRouting } : {}),
762
- };
763
- }
764
- }
765
-
766
- /** Builds a `SnapshotBuilder` reading the live catalog from `db`. */
767
- export function createDatabaseSnapshotBuilder(
768
- db: CartethyiaDatabase,
769
- refreshActiveCatalogs?: () => Promise<void>,
770
- ): SnapshotBuilder {
771
- const repository = new RouteCatalogRepository(db);
772
- return async () => {
773
- await refreshActiveCatalogs?.();
774
- return repository.loadRouteCatalogSnapshot(undefined);
775
- };
776
- }
777
-
1
+ // Routing snapshot: builds the RouteSnapshot from the persisted model catalog.
2
+
3
+ import { asc, eq, ne } from "drizzle-orm";
4
+ import type { CartethyiaDatabase } from "../../persistence/db-handle";
5
+ import {
6
+ models,
7
+ modelAliases,
8
+ modelCombos,
9
+ networkPools,
10
+ poolRoutingSettings,
11
+ providerAccounts,
12
+ providerRoutingSettings,
13
+ providers,
14
+ tenantDisabledModels,
15
+ cliToolMappings,
16
+ cliToolSettings,
17
+ } from "../../persistence/schema";
18
+ import { DEFAULT_PROVIDER_MAX_INFLIGHT } from "../../persistence/schema";
19
+ import { resolveTenantOverride } from "../../persistence/tenant-scope";
20
+ import {
21
+ type RouteCandidate,
22
+ type ComboDefinition,
23
+ type PoolRoutingSetting,
24
+ type ProviderRoutingMap,
25
+ type RouteSnapshot,
26
+ type SnapshotBuilder,
27
+ } from "./route-model";
28
+ import { DEFAULT_PROXY_SKIP_PROVIDER_IDS } from "../../providers/provider-metadata";
29
+ import { buildClaudeCliUserAgent } from "../../providers/operations/custom-cli-headers";
30
+ import { isBundledProviderId } from "../../providers/provider-contracts";
31
+ import type { ServiceKind, WireFamily } from "../../protocol/canonical-model";
32
+ import { providerHasAdapterUserAgent, providerUsesBespokeWire } from "../../providers/provider-metadata";
33
+
34
+ const CLAUDE_MODEL_FAMILIES = new Set(["opus", "sonnet", "haiku", "fable", "mythos"]);
35
+
36
+ export function cliMappingSourceKeys(toolId: string, sourceModel: string): readonly string[] {
37
+ if (toolId !== "claude") return [sourceModel];
38
+ const normalized = sourceModel.trim().toLowerCase();
39
+ const family =
40
+ CLAUDE_MODEL_FAMILIES.has(normalized)
41
+ ? normalized
42
+ : /^claude-(opus|sonnet|haiku|fable|mythos)(?:-|$)/.exec(normalized)?.[1];
43
+ if (normalized.includes("/") || family === undefined) return [sourceModel];
44
+ return [...new Set([
45
+ sourceModel,
46
+ family,
47
+ `claude-${family}-5`,
48
+ `claude-${family}-5-1`,
49
+ `claude-${family}-5-5`,
50
+ `claude-${family}-4-6`,
51
+ `claude-${family}-4-5`,
52
+ ])];
53
+ }
54
+ /**
55
+ * Builds the routing `RouteSnapshot` from the persisted catalog
56
+ * (`providers`, `models`, `provider_accounts`). Consumed by
57
+ * `InMemoryRouteSnapshotService` as its `SnapshotBuilder`.
58
+ *
59
+ * Built-in definitions are materialized before snapshots are read. The
60
+ * `models` DB table is authoritative for routing; compiled definitions are
61
+ * only startup seed data. Custom/BYOK and live-discovered models are stored
62
+ * in the same table.
63
+ *
64
+ * Tenant-aware: a provider with `providers.tenant_id = NULL` is global
65
+ * (every built-in, plus any pool-wide custom provider) and its candidates
66
+ * carry `tenant_id: null`, matching every request regardless of tenant. A
67
+ * provider with `providers.tenant_id` set (BYOK) only pairs with accounts
68
+ * owned by that exact same tenant, and its candidates carry that tenant id
69
+ * — `RoutingEngine.plan()` filters candidates by the requesting tenant.
70
+ */
71
+
72
+ type RouteCandidateWithHealth = RouteCandidate & {
73
+ health_status?: "cooldown" | "model_cooldown" | "disabled";
74
+ cooldown_kind?: "hard" | "soft";
75
+ /**
76
+ * Epoch milliseconds at which this candidate's cooling window ends, when one
77
+ * is in force. Routing-internal: it is what lets `plan()` report a real reset
78
+ * on the all-accounts-cooling answer, and it never reaches a client verbatim —
79
+ * only the derived `retry-after` does.
80
+ */
81
+ cooldown_until?: number;
82
+ };
83
+
84
+ const HARD_COOLDOWN_CATEGORIES: Readonly<Record<string, true>> = {
85
+ quota_exhausted: true,
86
+ policy_blocked: true,
87
+ auth_invalidated: true,
88
+ };
89
+
90
+ function hardCooldownCategory(category: string | null): boolean {
91
+ return category !== null && HARD_COOLDOWN_CATEGORIES[category] === true;
92
+ }
93
+
94
+ /**
95
+ * Projects one model row's metadata into the capability gates the router
96
+ * filters candidates by. Exported so the projection contract is unit-testable
97
+ * without a database.
98
+ */
99
+ export function buildCapabilityProfile(row: {
100
+ modalities: unknown;
101
+ reasoning: boolean;
102
+ toolCall: boolean;
103
+ providerId: string;
104
+ }): Record<string, boolean> {
105
+ const mods =
106
+ row.modalities != null && typeof row.modalities === "object"
107
+ ? (row.modalities as { input?: unknown[]; output?: unknown[] })
108
+ : {};
109
+ const inputMods = Array.isArray(mods.input) ? mods.input : [];
110
+ const outputMods = Array.isArray(mods.output) ? mods.output : [];
111
+ // The canonical chat and responses codecs encode `image`, `document`/`file`,
112
+ // and `audio` parts; the messages codec encodes the first two but has no
113
+ // audio block (see `AUDIO_CAPABLE_WIRE_FAMILIES`). A bespoke adapter (Cursor)
114
+ // frames its own protocol and has no generic rich-content path.
115
+ // Treating the codec routes as capable by default keeps pass-through behavior
116
+ // instead of degrading a caller's attachment to text on the strength of a
117
+ // metadata guess. An explicit modality still wins, so a catalog can declare
118
+ // support for a bespoke adapter too. Whether a given upstream model accepts
119
+ // the part is the upstream's call — a capability declared here only decides
120
+ // whether the router strips the part before it ever sees it, and the audio
121
+ // grant is narrowed per wire family at projection time.
122
+ const codecEncodesRichContent = !providerUsesBespokeWire(row.providerId);
123
+ // Reasoning and tools are never stripped here. A `false` flag — whether a
124
+ // discovered row recorded it for lack of metadata, or a catalog row set it
125
+ // explicitly — must not become a silent rewrite of a request the caller
126
+ // asked for. The upstream decides whether it can serve them and returns its
127
+ // own error if it cannot.
128
+ return {
129
+ // The route is not served by a canonical codec. Carried in the profile
130
+ // because the capability predicates downstream see only this snapshot, not
131
+ // the provider registry.
132
+ bespokeWire: !codecEncodesRichContent,
133
+ image: inputMods.includes("image") || codecEncodesRichContent,
134
+ document: inputMods.includes("document") || codecEncodesRichContent,
135
+ audio: inputMods.includes("audio") || codecEncodesRichContent,
136
+ mediaGeneration: outputMods.includes("image"),
137
+ tools: true,
138
+ parallelToolCalls: true,
139
+ reasoning: true,
140
+ reasoningEncryptedContent: true,
141
+ // Search is its own route: a `websearch` row answers `POST /v1/search`.
142
+ // A chat row never claims it; a hosted declaration rides through `tools`.
143
+ webSearch: false,
144
+ responseJsonObject: true,
145
+ responseJsonSchema: true,
146
+ promptCaching: true,
147
+ };
148
+ }
149
+
150
+ /**
151
+ * The capability profile for one model row, memoized across rebuilds.
152
+ *
153
+ * Bounded LRU keyed by `providerId/modelId`: the value is a pure function of
154
+ * the row's metadata, so two rebuilds that see the same row get the same object
155
+ * instead of two identical ones. The bound keeps a long-lived process from
156
+ * pinning one profile per row it has ever seen — a model deleted from the
157
+ * catalog must not leave its profile behind forever.
158
+ */
159
+ const CAPABILITY_PROFILE_CACHE_MAX_ENTRIES = 4096;
160
+ const capabilityProfileCache = new Map<string, Record<string, boolean>>();
161
+
162
+ function capabilityProfileFor(row: {
163
+ modalities: unknown;
164
+ reasoning: boolean;
165
+ toolCall: boolean;
166
+ providerId: string;
167
+ modelId: string;
168
+ }): Record<string, boolean> {
169
+ const key = `${row.providerId}/${row.modelId}`;
170
+ const cached = capabilityProfileCache.get(key);
171
+ if (cached !== undefined) return cached;
172
+ const profile = buildCapabilityProfile(row);
173
+ if (capabilityProfileCache.size >= CAPABILITY_PROFILE_CACHE_MAX_ENTRIES) {
174
+ const oldest = capabilityProfileCache.keys().next().value;
175
+ if (oldest !== undefined) capabilityProfileCache.delete(oldest);
176
+ }
177
+ capabilityProfileCache.set(key, profile);
178
+ return profile;
179
+ }
180
+
181
+ /** Live memo size, for tests and the metrics gauge. */
182
+ export function capabilityProfileCacheSize(): number {
183
+ return capabilityProfileCache.size;
184
+ }
185
+
186
+ /** Resolves route identity only for bundled adapters without a native User-Agent builder. */
187
+ export function resolveRouteUserAgent(
188
+ providerId: string,
189
+ tenantUserAgent: string | undefined,
190
+ globalUserAgent: string | undefined,
191
+ ): string | undefined {
192
+ if (!isBundledProviderId(providerId) || providerHasAdapterUserAgent(providerId)) return undefined;
193
+ // A provider whose operator set no identity of their own gets the Claude Code
194
+ // CLI identity, resolved live so the stamped version tracks discovery. This
195
+ // used to be a frozen `codex_cli_rs` literal, which named a different vendor's
196
+ // client on requests that mostly reached neither.
197
+ const selected = resolveTenantOverride(
198
+ tenantUserAgent,
199
+ globalUserAgent,
200
+ buildClaudeCliUserAgent(),
201
+ );
202
+ return selected.trim() === "" ? undefined : selected;
203
+ }
204
+
205
+ /** One persisted model row ready for candidate construction. */
206
+ interface MergedModelRow {
207
+ providerId: string;
208
+ modelId: string;
209
+ wireFamily: WireFamily;
210
+ serviceKind: ServiceKind;
211
+ endpointPath: string;
212
+ modalities: unknown;
213
+ outputLimit: number | null;
214
+ reasoning: boolean;
215
+ toolCall: boolean;
216
+ enabled: boolean;
217
+ proOnly: boolean | null;
218
+ /** Whether the provider publishes the row as free. Read for plan logging. */
219
+ freeTier: boolean | null;
220
+ }
221
+ /**
222
+ * Converts persisted model rows into candidate input. Seed materialization is
223
+ * performed before this function runs, so no static-vs-DB merge is needed.
224
+ */
225
+ function mergeModelCatalog(
226
+ dbRows: readonly ModelRouteRow[],
227
+ ): MergedModelRow[] {
228
+ return dbRows
229
+ .filter((row) => row.enabled)
230
+ .map((row) => ({
231
+ providerId: row.providerId,
232
+ modelId: row.modelId,
233
+ wireFamily: row.wireFamily as WireFamily,
234
+ serviceKind: row.serviceKind as ServiceKind,
235
+ endpointPath: row.endpointPath,
236
+ modalities: row.modalities,
237
+ outputLimit: row.outputLimit,
238
+ reasoning: row.reasoning,
239
+ toolCall: row.toolCall,
240
+ enabled: row.enabled,
241
+ freeTier: row.freeTier ?? null,
242
+ proOnly: row.proOnly ?? null,
243
+ }));
244
+ }
245
+
246
+ /**
247
+ * Column projections for the snapshot read.
248
+ *
249
+ * The builder reads a handful of fields from each table, but the read used to
250
+ * be a bare `select()` — every column of all ten tables, including
251
+ * `provider_accounts.credential_ciphertext` and `network_pools.
252
+ * endpoint_config`, which the snapshot never touches. That ciphertext is the
253
+ * largest thing the gateway stores per account, and the snapshot is rebuilt on
254
+ * every catalog change, so the bytes were transferred and then discarded on
255
+ * every rebuild. The projection keeps the query count at ten and drops the
256
+ * payload to what is actually read.
257
+ *
258
+ * Each object doubles as the row type (`ModelRouteRow` and friends are derived
259
+ * from it), so a column added here is automatically typed, and a column the
260
+ * builder starts reading but is missing here fails to compile rather than
261
+ * silently reading `undefined`.
262
+ */
263
+ const PROVIDER_COLUMNS = {
264
+ id: providers.id,
265
+ tenantId: providers.tenantId,
266
+ requiresAccount: providers.requiresAccount,
267
+ } as const;
268
+
269
+ const MODEL_COLUMNS = {
270
+ providerId: models.providerId,
271
+ modelId: models.modelId,
272
+ wireFamily: models.wireFamily,
273
+ serviceKind: models.serviceKind,
274
+ endpointPath: models.endpointPath,
275
+ modalities: models.modalities,
276
+ outputLimit: models.outputLimit,
277
+ reasoning: models.reasoning,
278
+ toolCall: models.toolCall,
279
+ enabled: models.enabled,
280
+ // Carried for the plan gate: an upstream may refuse a model on a free plan,
281
+ // and only the row says which. See `planAllowsModel`.
282
+ proOnly: models.proOnly,
283
+ // Carried alongside it: the provider's own statement that the row is free,
284
+ // which is the other half of the same plan question.
285
+ freeTier: models.freeTier,
286
+ } as const;
287
+ const ACCOUNT_COLUMNS = {
288
+ id: providerAccounts.id,
289
+ providerId: providerAccounts.providerId,
290
+ tenantId: providerAccounts.tenantId,
291
+ label: providerAccounts.label,
292
+ status: providerAccounts.status,
293
+ cooldownUntil: providerAccounts.cooldownUntil,
294
+ modelCooldowns: providerAccounts.modelCooldowns,
295
+ lastErrorCategory: providerAccounts.lastErrorCategory,
296
+ // The account's last reported plan. Read here rather than from the quota
297
+ // cache, which is empty on a cold start — exactly when routing must not be
298
+ // guessing whether an account is free.
299
+ quotaPlan: providerAccounts.quotaPlan,
300
+ } as const;
301
+
302
+ const ALIAS_COLUMNS = {
303
+ tenantId: modelAliases.tenantId,
304
+ alias: modelAliases.alias,
305
+ targetModel: modelAliases.targetModel,
306
+ } as const;
307
+
308
+ const COMBO_COLUMNS = {
309
+ tenantId: modelCombos.tenantId,
310
+ name: modelCombos.name,
311
+ members: modelCombos.members,
312
+ strategy: modelCombos.strategy,
313
+ } as const;
314
+
315
+ const ROUTING_COLUMNS = {
316
+ providerId: providerRoutingSettings.providerId,
317
+ tenantId: providerRoutingSettings.tenantId,
318
+ strategy: providerRoutingSettings.strategy,
319
+ rotateCount: providerRoutingSettings.rotateCount,
320
+ maxInflight: providerRoutingSettings.maxInflight,
321
+ maxInflightPerProxy: providerRoutingSettings.maxInflightPerProxy,
322
+ usageFloor: providerRoutingSettings.usageFloor,
323
+ usageFloorUnit: providerRoutingSettings.usageFloorUnit,
324
+ enabled: providerRoutingSettings.enabled,
325
+ skipProxy: providerRoutingSettings.skipProxy,
326
+ userAgent: providerRoutingSettings.userAgent,
327
+ } as const;
328
+
329
+ const POOL_COLUMNS = {
330
+ id: networkPools.id,
331
+ tenantId: networkPools.tenantId,
332
+ status: networkPools.status,
333
+ maxInflight: networkPools.maxInflight,
334
+ weight: networkPools.weight,
335
+ } as const;
336
+
337
+ const DISABLED_MODEL_COLUMNS = {
338
+ tenantId: tenantDisabledModels.tenantId,
339
+ providerId: tenantDisabledModels.providerId,
340
+ modelId: tenantDisabledModels.modelId,
341
+ endpointPath: tenantDisabledModels.endpointPath,
342
+ } as const;
343
+
344
+ const CLI_MAPPING_COLUMNS = {
345
+ tenantId: cliToolMappings.tenantId,
346
+ apiKeyId: cliToolMappings.apiKeyId,
347
+ toolId: cliToolMappings.toolId,
348
+ sourceModel: cliToolMappings.sourceModel,
349
+ targetModel: cliToolMappings.targetModel,
350
+ enabled: cliToolMappings.enabled,
351
+ } as const;
352
+
353
+ /** The per-(tenant, tool, key) Remote Routing switch. A mapping row only
354
+ * routes when its bucket's settings row has `mappings_enabled` set. */
355
+ const CLI_MAPPING_SETTING_COLUMNS = {
356
+ tenantId: cliToolSettings.tenantId,
357
+ apiKeyId: cliToolSettings.apiKeyId,
358
+ toolId: cliToolSettings.toolId,
359
+ mappingsEnabled: cliToolSettings.mappingsEnabled,
360
+ } as const;
361
+
362
+ const POOL_SETTING_COLUMNS = {
363
+ tenantId: poolRoutingSettings.tenantId,
364
+ strategy: poolRoutingSettings.strategy,
365
+ rotateCount: poolRoutingSettings.rotateCount,
366
+ } as const;
367
+
368
+ /** A row of `models` as this module reads it. */
369
+ type ModelRouteRow = { [K in keyof typeof MODEL_COLUMNS]: (typeof models.$inferSelect)[K] };
370
+
371
+ /** Snapshot load result from the 10-table catalog read. */
372
+ type RouteCatalogSnapshotResult = Omit<RouteSnapshot, "revision" | "created_at"> & { created_at?: number };
373
+
374
+ /** Repository abstracting database queries for the route catalog snapshot. */
375
+ class RouteCatalogRepository {
376
+ constructor(private readonly db: CartethyiaDatabase) {}
377
+
378
+ async loadRouteCatalogSnapshot(tenantId?: string): Promise<RouteCatalogSnapshotResult> {
379
+ const [providerRows, modelRows, accountRows, aliasRows, comboRows, routingRows, poolRows, disabledModelRows, cliMappingRows, cliMappingSettingRows, poolSettingRows] =
380
+ await Promise.all([
381
+ this.db.select(PROVIDER_COLUMNS).from(providers).where(eq(providers.enabled, true)),
382
+ this.db.select(MODEL_COLUMNS).from(models),
383
+ this.db
384
+ .select(ACCOUNT_COLUMNS)
385
+ .from(providerAccounts)
386
+ .where(ne(providerAccounts.status, "disabled"))
387
+ .orderBy(asc(providerAccounts.status), asc(providerAccounts.createdAt), asc(providerAccounts.id)),
388
+ this.db.select(ALIAS_COLUMNS).from(modelAliases),
389
+ this.db.select(COMBO_COLUMNS).from(modelCombos),
390
+ this.db.select(ROUTING_COLUMNS).from(providerRoutingSettings),
391
+ this.db.select(POOL_COLUMNS).from(networkPools),
392
+ this.db.select(DISABLED_MODEL_COLUMNS).from(tenantDisabledModels),
393
+ this.db.select(CLI_MAPPING_COLUMNS).from(cliToolMappings),
394
+ this.db.select(CLI_MAPPING_SETTING_COLUMNS).from(cliToolSettings),
395
+ this.db.select(POOL_SETTING_COLUMNS).from(poolRoutingSettings),
396
+ ]);
397
+ const mergedModelRows = mergeModelCatalog(modelRows);
398
+
399
+ // Compile tenant-scoped disables into a lookup keyed by the same
400
+ // composite identity the `models` table uses. A disabled key suppresses
401
+ // the candidate whose account belongs to that tenant.
402
+ const disabledModelKeys = new Set<string>(
403
+ disabledModelRows.map(
404
+ (row) => `${row.tenantId}:${row.providerId}:${row.modelId}:${row.endpointPath}`,
405
+ ),
406
+ );
407
+
408
+ const providerTenantById = new Map<string, string | null>(
409
+ providerRows.map((p) => [p.id, p.tenantId]),
410
+ );
411
+ const requiresAccountById = new Map<string, boolean>(
412
+ providerRows.map((p) => [p.id, p.requiresAccount]),
413
+ );
414
+
415
+ // Every active (non-disabled) pool a tenant owns, available for
416
+ // automatic per-request selection — see `resolveNetworkPools` below.
417
+ const activePoolsByTenant = new Map<
418
+ string,
419
+ Array<{ id: string; maxInflight: number; weight: number }>
420
+ >();
421
+ const configuredPoolTenants = new Set<string>();
422
+ for (const pool of poolRows) {
423
+ configuredPoolTenants.add(pool.tenantId);
424
+ if (pool.status !== "active") continue;
425
+ if (!pool.tenantId) continue;
426
+ const list = activePoolsByTenant.get(pool.tenantId) ?? [];
427
+ list.push({ id: pool.id, maxInflight: pool.maxInflight ?? 10, weight: pool.weight ?? 100 });
428
+ activePoolsByTenant.set(pool.tenantId, list);
429
+ }
430
+
431
+ // Per-tenant pool selection strategy; an absent row reads as the
432
+ // `least_loaded` default at selection time.
433
+ const poolRouting: Record<string, PoolRoutingSetting> = {};
434
+ for (const row of poolSettingRows) {
435
+ if (tenantId === undefined || row.tenantId === tenantId) {
436
+ poolRouting[row.tenantId] = { strategy: row.strategy, rotateCount: row.rotateCount };
437
+ }
438
+ }
439
+
440
+ // Build per-tenant per-provider routing preferences before the
441
+ // candidates loop below, since skipProxy has to be known while
442
+ // deciding each candidate's network pool set. Global settings
443
+ // (tenant_id IS NULL) are stored under sentinel "__global__".
444
+ const providerRouting: Record<string, Record<string, ProviderRoutingMap[string][string]>> = {};
445
+ for (const row of routingRows) {
446
+ const tenantKey = row.tenantId ?? "__global__";
447
+ const bucket = (providerRouting[tenantKey] ??= {});
448
+ bucket[row.providerId] = {
449
+ strategy: row.strategy as ProviderRoutingMap[string][string]["strategy"],
450
+ rotateCount: row.rotateCount ?? 1,
451
+ maxInflight: row.maxInflight,
452
+ maxInflightPerProxy: row.maxInflightPerProxy,
453
+ usageFloor: row.usageFloor,
454
+ usageFloorUnit: row.usageFloorUnit === "credit" ? "credit" : "percent",
455
+ enabled: row.enabled,
456
+ skipProxy: row.skipProxy,
457
+ userAgent: row.userAgent,
458
+ };
459
+ }
460
+ /** Tenant-specific setting wins over global; an unconfigured provider
461
+ * falls back to `DEFAULT_PROXY_SKIP_PROVIDER_IDS`, mirroring the same
462
+ * default `DrizzleProviderDetailStore.getRouting` reports to the API —
463
+ * so the dashboard's displayed default and the real dispatch decision
464
+ * never disagree. */
465
+ function resolveSkipProxy(providerId: string, rowTenantId: string | null): boolean {
466
+ const effectiveTenantId = rowTenantId;
467
+ const tenantSetting = effectiveTenantId ? providerRouting[effectiveTenantId]?.[providerId] : undefined;
468
+ const globalSetting = providerRouting.__global__?.[providerId];
469
+ return resolveTenantOverride(
470
+ tenantSetting?.skipProxy,
471
+ globalSetting?.skipProxy,
472
+ DEFAULT_PROXY_SKIP_PROVIDER_IDS.has(providerId),
473
+ );
474
+ }
475
+ function resolveUserAgent(providerId: string, rowTenantId: string | null): string | undefined {
476
+ const tenantSetting = rowTenantId ? providerRouting[rowTenantId]?.[providerId]?.userAgent : undefined;
477
+ const globalSetting = providerRouting.__global__?.[providerId]?.userAgent;
478
+ return resolveRouteUserAgent(providerId, tenantSetting, globalSetting);
479
+ }
480
+
481
+ /** Provider-wide concurrency ceiling shared by every account of the provider.
482
+ * Tenant setting wins over global, mirroring skipProxy. A stored `null`
483
+ * is the explicit unlimited choice; so is the default — a provider with no
484
+ * routing row at all is unlimited too, because an operator who never
485
+ * configured a ceiling asked for none. */
486
+ function resolveMaxInflight(
487
+ providerId: string,
488
+ rowTenantId: string | null,
489
+ ): number | undefined {
490
+ const tenantSetting = rowTenantId
491
+ ? providerRouting[rowTenantId]?.[providerId]?.maxInflight
492
+ : undefined;
493
+ const globalSetting = providerRouting.__global__?.[providerId]?.maxInflight;
494
+ const resolved = tenantSetting ?? globalSetting;
495
+ if (resolved === null) return undefined;
496
+ return resolved ?? DEFAULT_PROVIDER_MAX_INFLIGHT ?? undefined;
497
+ }
498
+
499
+ /**
500
+ * This provider's per-pool inflight ceiling, from Routing Strategy.
501
+ * `undefined` means unlimited — the default, and the state of every
502
+ * provider nobody has configured. A stored `null` is the same choice made
503
+ * explicitly; the two are deliberately indistinguishable downstream so the
504
+ * hot path never branches on which one it read.
505
+ */
506
+ function resolveMaxInflightPerProxy(
507
+ providerId: string,
508
+ rowTenantId: string | null,
509
+ ): number | undefined {
510
+ const tenantSetting = rowTenantId
511
+ ? providerRouting[rowTenantId]?.[providerId]?.maxInflightPerProxy
512
+ : undefined;
513
+ const globalSetting = providerRouting.__global__?.[providerId]?.maxInflightPerProxy;
514
+ return tenantSetting ?? globalSetting ?? undefined;
515
+ }
516
+
517
+ /** Every active pool the account's tenant owns — dispatch picks the
518
+ * least-loaded/non-cooldown one per request (`tryAcquireAvailablePool`),
519
+ * never an admin-pinned single pool. */
520
+ function resolveNetworkPools(
521
+ providerId: string,
522
+ rowTenantId: string | null,
523
+ ):
524
+ | {
525
+ ids: readonly string[];
526
+ limits: Record<string, number>;
527
+ weights: Record<string, number>;
528
+ routing?: PoolRoutingSetting & { tenantId: string };
529
+ }
530
+ | undefined {
531
+ if (!rowTenantId || resolveSkipProxy(providerId, rowTenantId)) return undefined;
532
+ if (!configuredPoolTenants.has(rowTenantId)) return undefined;
533
+ const pools = activePoolsByTenant.get(rowTenantId) ?? [];
534
+ const limits: Record<string, number> = {};
535
+ const weights: Record<string, number> = {};
536
+ for (const pool of pools) {
537
+ limits[pool.id] = pool.maxInflight;
538
+ weights[pool.id] = pool.weight;
539
+ }
540
+ const routing = poolRouting[rowTenantId];
541
+ return {
542
+ ids: pools.map((pool) => pool.id),
543
+ limits,
544
+ weights,
545
+ ...(routing ? { routing: { tenantId: rowTenantId, ...routing } } : {}),
546
+ };
547
+ }
548
+
549
+ /** The candidate fragment for a provider ceiling, empty when unlimited —
550
+ * so an unconfigured provider's candidate is byte-for-byte what it was. */
551
+ function providerLimitFor(
552
+ providerId: string,
553
+ rowTenantId: string | null,
554
+ ): { network_pool_provider_limit?: number } {
555
+ const limit = resolveMaxInflightPerProxy(providerId, rowTenantId);
556
+ return limit === undefined ? {} : { network_pool_provider_limit: limit };
557
+ }
558
+
559
+ // Group usable accounts per provider (not per provider-tenant). Built-in
560
+ // providers are `tenant_id IS NULL` but their accounts are per-tenant, so
561
+ // grouping by `providerTenantId` would make every global model look
562
+ // disabled. Instead keep all accounts for a provider together and fan out
563
+ // candidates per account tenant below — e.g. `opencodeze/muse-spark`
564
+ // hits with `model: "opencodeze/muse-spark"` must resolve to the tenant's
565
+ // own account even though the provider row is global.
566
+ const accountsByProvider = new Map<string, typeof accountRows>();
567
+ for (const account of accountRows) {
568
+ if (account.status === "disabled") continue;
569
+ const list = accountsByProvider.get(account.providerId) ?? [];
570
+ list.push(account);
571
+ accountsByProvider.set(account.providerId, list);
572
+ }
573
+
574
+ const candidates: RouteCandidateWithHealth[] = [];
575
+ for (const model of mergedModelRows) {
576
+ const providerTenantId = providerTenantById.get(model.providerId);
577
+ if (providerTenantId === undefined) continue;
578
+ const accounts = accountsByProvider.get(model.providerId) ?? [];
579
+ // One profile per model row, memoized across snapshot rebuilds. The
580
+ // profile is derived purely from the row's metadata, is never mutated
581
+ // (grep finds no write to `capability_profile`), and is shared by every
582
+ // candidate this row fans out to — so a rebuild no longer constructs a
583
+ // fresh object per model on top of the fan-out it already had.
584
+ const capabilityProfile = capabilityProfileFor(model);
585
+ if (accounts.length === 0) {
586
+ const providerRequiresAccount = requiresAccountById.get(model.providerId) ?? true;
587
+ if (!providerRequiresAccount) {
588
+ // Public models inherit active pools for each tenant that has active pools
589
+ for (const tId of configuredPoolTenants) {
590
+ const tenantPools = resolveNetworkPools(model.providerId, tId);
591
+ if (tenantPools) {
592
+ const tenantCandidate: RouteCandidateWithHealth = {
593
+ provider_id: model.providerId,
594
+ model_id: model.modelId,
595
+ wire_family: model.wireFamily as WireFamily,
596
+ service_kind: model.serviceKind,
597
+ endpoint: model.endpointPath,
598
+ ...(model.outputLimit == null ? {} : { output_limit: model.outputLimit }),
599
+ capability_profile: capabilityProfile,
600
+ tenant_id: tId,
601
+ requires_account: false as const,
602
+ network_pool_ids: tenantPools.ids,
603
+ network_pool_required: true,
604
+ network_pool_limits: tenantPools.limits,
605
+ network_pool_weights: tenantPools.weights,
606
+ ...(tenantPools.routing ? { network_pool_routing: tenantPools.routing } : {}),
607
+ ...providerLimitFor(model.providerId, tId),
608
+ };
609
+ if (tenantId === undefined || tenantId === tId) {
610
+ candidates.push(tenantCandidate);
611
+ }
612
+ }
613
+ }
614
+ }
615
+ const networkPools = providerRequiresAccount
616
+ ? undefined
617
+ : resolveNetworkPools(model.providerId, providerTenantId);
618
+ const candidate: RouteCandidateWithHealth = {
619
+ provider_id: model.providerId,
620
+ model_id: model.modelId,
621
+ wire_family: model.wireFamily as WireFamily,
622
+ service_kind: model.serviceKind,
623
+ endpoint: model.endpointPath,
624
+ ...(model.outputLimit == null ? {} : { output_limit: model.outputLimit }),
625
+ capability_profile: capabilityProfile,
626
+ tenant_id: providerTenantId,
627
+ ...(providerRequiresAccount
628
+ ? { health_status: "disabled" as const }
629
+ : { requires_account: false as const }),
630
+ ...(networkPools
631
+ ? {
632
+ network_pool_ids: networkPools.ids,
633
+ network_pool_required: true,
634
+ network_pool_limits: networkPools.limits,
635
+ network_pool_weights: networkPools.weights,
636
+ ...(networkPools.routing ? { network_pool_routing: networkPools.routing } : {}),
637
+ ...providerLimitFor(model.providerId, providerTenantId),
638
+ }
639
+ : {}),
640
+ };
641
+ if (tenantId === undefined || candidate.tenant_id === null || candidate.tenant_id === tenantId) {
642
+ candidates.push(candidate);
643
+ }
644
+ continue;
645
+ }
646
+ for (const account of accounts) {
647
+ const rowTenantId = account.tenantId ?? providerTenantId;
648
+ if (
649
+ rowTenantId !== null &&
650
+ disabledModelKeys.has(
651
+ `${rowTenantId}:${model.providerId}:${model.modelId}:${model.endpointPath}`,
652
+ )
653
+ ) {
654
+ continue;
655
+ }
656
+ const networkPools = resolveNetworkPools(model.providerId, rowTenantId);
657
+ const routeUserAgent = resolveUserAgent(model.providerId, rowTenantId);
658
+ const candidate: RouteCandidateWithHealth = {
659
+ provider_id: model.providerId,
660
+ model_id: model.modelId,
661
+ wire_family: model.wireFamily as WireFamily,
662
+ service_kind: model.serviceKind,
663
+ endpoint: model.endpointPath,
664
+ ...(model.outputLimit == null ? {} : { output_limit: model.outputLimit }),
665
+ capability_profile: capabilityProfile,
666
+ ...(routeUserAgent === undefined ? {} : { user_agent: routeUserAgent }),
667
+ tenant_id: rowTenantId,
668
+ provider_account_id: account.id,
669
+ // The plan gate is per (account, model): the model may be refused on
670
+ // a free plan and the account may be on one. Both ride here because
671
+ // `plan()` sees the candidate and nothing else.
672
+ ...(model.proOnly === true ? { pro_only: true } : {}),
673
+ ...(account.quotaPlan === null || account.quotaPlan === undefined
674
+ ? {}
675
+ : { account_plan: account.quotaPlan }),
676
+ // Provider-wide concurrency ceiling from Routing Strategy; legacy
677
+ // account overrides are never read here.
678
+ ...(() => {
679
+ const resolved = resolveMaxInflight(model.providerId, rowTenantId);
680
+ return resolved === undefined ? {} : { max_inflight: resolved };
681
+ })(),
682
+ ...(networkPools
683
+ ? {
684
+ network_pool_required: true,
685
+ network_pool_ids: networkPools.ids,
686
+ network_pool_limits: networkPools.limits,
687
+ network_pool_weights: networkPools.weights,
688
+ ...(networkPools.routing ? { network_pool_routing: networkPools.routing } : {}),
689
+ ...providerLimitFor(model.providerId, rowTenantId),
690
+ }
691
+ : {}),
692
+ };
693
+ if (account.status === "cooldown") {
694
+ // Expired cooldowns read as healthy here without a fire-and-forget
695
+ // write: AccountHealthSweeper materializes the recovery every 30s,
696
+ // so the builder stays a pure read (no UPDATE racing the request).
697
+ if (account.cooldownUntil && account.cooldownUntil.getTime() > Date.now()) {
698
+ candidate.health_status = "cooldown";
699
+ candidate.cooldown_kind = hardCooldownCategory(account.lastErrorCategory)
700
+ ? "hard"
701
+ : "soft";
702
+ // The deadline rides with the exclusion it explains. `plan()` reads
703
+ // it to answer the all-cooling case with the real reset instead of
704
+ // a bare 429, so a client backs off for the window that is actually
705
+ // open rather than inventing one.
706
+ candidate.cooldown_until = account.cooldownUntil.getTime();
707
+ }
708
+ }
709
+
710
+ // Per-model cooldown: an account may be healthy globally but cooling down
711
+ // for a specific model. A non-expired entry for the model THIS candidate
712
+ // serves is a hard exclusion, not a deprioritization: the upstream said
713
+ // this exact (account, model) pair is exhausted until a stated reset
714
+ // ("Daily free limit reached … Try again in 11h"), so dialing it can only
715
+ // reproduce the same refusal. Deprioritizing instead made failover spend
716
+ // a full round trip on it after every healthy sibling had failed, and
717
+ // recorded another `active → cooldown` row each time — the operator's
718
+ // report of one account churning while ten siblings sat healthy.
719
+ //
720
+ // `model_cooldown` (distinct from `cooldown`) is what carries that
721
+ // distinction to the evaluator: an account-wide `cooldown` stays
722
+ // deprioritized so a single-account deployment still routes, while a
723
+ // model-scoped one is dropped for this model only and the account keeps
724
+ // serving every other model it holds.
725
+ const modelCooldowns = account.modelCooldowns as Record<string, string> | null;
726
+ const modelCooldownUntil = modelCooldowns?.[model.modelId];
727
+ if (modelCooldownUntil && new Date(modelCooldownUntil).getTime() > Date.now()) {
728
+ candidate.health_status = "model_cooldown";
729
+ candidate.cooldown_until = new Date(modelCooldownUntil).getTime();
730
+ }
731
+ if (tenantId === undefined || candidate.tenant_id === null || candidate.tenant_id === tenantId) {
732
+ candidates.push(candidate);
733
+ }
734
+ }
735
+ }
736
+
737
+ const aliases: Record<string, Record<string, string>> = {};
738
+ const cliAliases: Record<string, Record<string, string>> = {};
739
+ for (const row of aliasRows) {
740
+ if (tenantId === undefined || row.tenantId === tenantId) {
741
+ (aliases[row.tenantId] ??= {})[row.alias] = row.targetModel;
742
+ }
743
+ }
744
+ // CLI-tool mappings are per-(tenant, key): each API key can route the same
745
+ // CLI slot to a different target. The snapshot keys the alias bucket by
746
+ // `${tenantId}:${apiKeyId}` so the preparer can look up the exact key's
747
+ // routes without merging across keys.
748
+ // Remote Routing is opt-in per (tenant, tool, key): a mapping row routes
749
+ // only when its bucket's settings row explicitly enabled it. The CLI tool
750
+ // page and the API-key edit form both write that one flag, so either place
751
+ // can turn the routes on or off.
752
+ const enabledMappingBuckets = new Set<string>();
753
+ for (const row of cliMappingSettingRows) {
754
+ if (row.mappingsEnabled) enabledMappingBuckets.add(`${row.tenantId}:${row.apiKeyId}`);
755
+ }
756
+ for (const row of cliMappingRows) {
757
+ if (!row.enabled) continue;
758
+ if (!enabledMappingBuckets.has(`${row.tenantId}:${row.apiKeyId}`)) continue;
759
+ if (tenantId === undefined || row.tenantId === tenantId) {
760
+ const bucketKey = `${row.tenantId}:${row.apiKeyId}`;
761
+ const aliasBucket = (cliAliases[bucketKey] ??= {});
762
+ for (const sourceKey of cliMappingSourceKeys(row.toolId, row.sourceModel)) {
763
+ aliasBucket[sourceKey] = row.targetModel;
764
+ }
765
+ }
766
+ }
767
+ const combos: Record<string, Record<string, ComboDefinition>> = {};
768
+ for (const row of comboRows) {
769
+ if (tenantId === undefined || row.tenantId === tenantId) {
770
+ (combos[row.tenantId] ??= {})[row.name] = { members: row.members, strategy: row.strategy };
771
+ }
772
+ }
773
+
774
+ const routingMap: ProviderRoutingMap | undefined =
775
+ Object.keys(providerRouting).length > 0 ? (providerRouting as ProviderRoutingMap) : undefined;
776
+
777
+ return {
778
+ candidates,
779
+ aliases,
780
+ cli_aliases: cliAliases,
781
+ combos,
782
+ ...(routingMap ? { providerRouting: routingMap } : {}),
783
+ ...(Object.keys(poolRouting).length > 0 ? { poolRouting } : {}),
784
+ };
785
+ }
786
+ }
787
+
788
+ /** Builds a `SnapshotBuilder` reading the live catalog from `db`. */
789
+ export function createDatabaseSnapshotBuilder(
790
+ db: CartethyiaDatabase,
791
+ refreshActiveCatalogs?: () => Promise<void>,
792
+ ): SnapshotBuilder {
793
+ const repository = new RouteCatalogRepository(db);
794
+ return async () => {
795
+ await refreshActiveCatalogs?.();
796
+ return repository.loadRouteCatalogSnapshot(undefined);
797
+ };
798
+ }
799
+