cartethyia 2.5.1 → 2.5.3

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