cartethyia 2.5.9 → 2.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (386) hide show
  1. package/.env.example +15 -42
  2. package/README.md +11 -10
  3. package/dist/dashboard/assets/{BannedPage-Brvuj_Eb.js → BannedPage-l6XMnfr9.js} +1 -1
  4. package/dist/dashboard/assets/BannedPage-l6XMnfr9.js.gz +0 -0
  5. package/dist/dashboard/assets/{CliToolDetailPage-CqfXtI3y.js → CliToolDetailPage-DwOzAW45.js} +1 -1
  6. package/dist/dashboard/assets/CliToolDetailPage-DwOzAW45.js.gz +0 -0
  7. package/dist/dashboard/assets/{CliToolsPage-GQeS91bg.js → CliToolsPage-BT3Up3QA.js} +1 -1
  8. package/dist/dashboard/assets/CliToolsPage-BT3Up3QA.js.gz +0 -0
  9. package/dist/dashboard/assets/{CombosPage-BDRsTXBB.js → CombosPage-6kYIUpzR.js} +1 -1
  10. package/dist/dashboard/assets/CombosPage-6kYIUpzR.js.gz +0 -0
  11. package/dist/dashboard/assets/{ConsoleLogPage-zP8AFHX0.js → ConsoleLogPage-zRnj1mlR.js} +1 -1
  12. package/dist/dashboard/assets/ConsoleLogPage-zRnj1mlR.js.gz +0 -0
  13. package/dist/dashboard/assets/{CustomizationPage-BmfrNZ14.js → CustomizationPage-C7m-H1Rz.js} +1 -1
  14. package/dist/dashboard/assets/CustomizationPage-C7m-H1Rz.js.gz +0 -0
  15. package/dist/dashboard/assets/HealthEventsModal-C8MR_BdB.js +1 -0
  16. package/dist/dashboard/assets/HealthEventsModal-C8MR_BdB.js.gz +0 -0
  17. package/dist/dashboard/assets/{LoginPage-DoreIgm8.js → LoginPage-CbphLvjg.js} +1 -1
  18. package/dist/dashboard/assets/LoginPage-CbphLvjg.js.gz +0 -0
  19. package/dist/dashboard/assets/{ModelPicker-C7pDb41R.js → ModelPicker-DVny4S5_.js} +1 -1
  20. package/dist/dashboard/assets/ModelPicker-DVny4S5_.js.gz +0 -0
  21. package/dist/dashboard/assets/OverviewPage-DABu2Wgt.js +1 -0
  22. package/dist/dashboard/assets/OverviewPage-DABu2Wgt.js.gz +0 -0
  23. package/dist/dashboard/assets/ProviderDetailPage-C15x9q13.js +3 -0
  24. package/dist/dashboard/assets/ProviderDetailPage-C15x9q13.js.gz +0 -0
  25. package/dist/dashboard/assets/{ProvidersPage-BnoPgA7w.js → ProvidersPage-Dfb2896L.js} +1 -1
  26. package/dist/dashboard/assets/ProvidersPage-Dfb2896L.js.gz +0 -0
  27. package/dist/dashboard/assets/{ProxyPage-Bq_TGgdb.js → ProxyPage-BaCIdAUt.js} +1 -1
  28. package/dist/dashboard/assets/ProxyPage-BaCIdAUt.js.gz +0 -0
  29. package/dist/dashboard/assets/QuotaPage-BsdoCIw1.js +1 -0
  30. package/dist/dashboard/assets/QuotaPage-BsdoCIw1.js.gz +0 -0
  31. package/dist/dashboard/assets/SettingsPage-KVJFh3Ip.js +1 -0
  32. package/dist/dashboard/assets/SettingsPage-KVJFh3Ip.js.gz +0 -0
  33. package/dist/dashboard/assets/{SetupPage-Day3mCOn.js → SetupPage-D4g6vOGc.js} +1 -1
  34. package/dist/dashboard/assets/SetupPage-D4g6vOGc.js.gz +0 -0
  35. package/dist/dashboard/assets/{SortableList-DQxllnB5.js → SortableList-C93i14BD.js} +1 -1
  36. package/dist/dashboard/assets/SortableList-C93i14BD.js.gz +0 -0
  37. package/dist/dashboard/assets/{StudioPage-uMExoWu1.js → StudioPage-SgQpymjm.js} +1 -1
  38. package/dist/dashboard/assets/{StudioPage-uMExoWu1.js.gz → StudioPage-SgQpymjm.js.gz} +0 -0
  39. package/dist/dashboard/assets/{UsagePage-DXa1oR-4.js → UsagePage-BFME-eba.js} +2 -2
  40. package/dist/dashboard/assets/UsagePage-BFME-eba.js.gz +0 -0
  41. package/dist/dashboard/assets/{api-keys-DQYLQxpW.js → api-keys-C23S9sgp.js} +1 -1
  42. package/dist/dashboard/assets/api-keys-C23S9sgp.js.gz +0 -0
  43. package/dist/dashboard/assets/{app-BIt446yE.js → app-2KhtHCv9.js} +1 -1
  44. package/dist/dashboard/assets/{app-BIt446yE.js.gz → app-2KhtHCv9.js.gz} +0 -0
  45. package/dist/dashboard/assets/app-CEeaMHOy.css +2 -0
  46. package/dist/dashboard/assets/app-CEeaMHOy.css.gz +0 -0
  47. package/dist/dashboard/assets/{assert-C9FD_JwJ.js → assert-DU6qrMA_.js} +1 -1
  48. package/dist/dashboard/assets/assert-DU6qrMA_.js.gz +0 -0
  49. package/dist/dashboard/assets/{badge-CR72dasT.js → badge-CKsF123d.js} +1 -1
  50. package/dist/dashboard/assets/badge-CKsF123d.js.gz +0 -0
  51. package/dist/dashboard/assets/{banner-CI9_eSfO.js → banner-8j7uChA9.js} +1 -1
  52. package/dist/dashboard/assets/banner-8j7uChA9.js.gz +0 -0
  53. package/dist/dashboard/assets/{card-DS7CQEsO.js → card-CdsSlWA3.js} +1 -1
  54. package/dist/dashboard/assets/card-CdsSlWA3.js.gz +0 -0
  55. package/dist/dashboard/assets/{cli-tools-CNqzrfdb.js → cli-tools-BaFUuFFF.js} +1 -1
  56. package/dist/dashboard/assets/cli-tools-BaFUuFFF.js.gz +0 -0
  57. package/dist/dashboard/assets/{clipboard-button-611pSsaX.js → clipboard-button-uL5aYLfW.js} +1 -1
  58. package/dist/dashboard/assets/clipboard-button-uL5aYLfW.js.gz +0 -0
  59. package/dist/dashboard/assets/{dialog-k37exjNt.js → dialog-C07hr5_G.js} +1 -1
  60. package/dist/dashboard/assets/dialog-C07hr5_G.js.gz +0 -0
  61. package/dist/dashboard/assets/{drawer-CX7N8ucR.js → drawer-BPO1TcKH.js} +1 -1
  62. package/dist/dashboard/assets/drawer-BPO1TcKH.js.gz +0 -0
  63. package/dist/dashboard/assets/{entry-CTP8AyiS.js → entry-Bz_CtDfC.js} +1 -1
  64. package/dist/dashboard/assets/entry-Bz_CtDfC.js.gz +0 -0
  65. package/dist/dashboard/assets/entry-CwPRKk0d.js +4 -0
  66. package/dist/dashboard/assets/entry-CwPRKk0d.js.gz +0 -0
  67. package/dist/dashboard/assets/entry-GJRbmrpA.js +1 -0
  68. package/dist/dashboard/assets/entry-GJRbmrpA.js.gz +0 -0
  69. package/dist/dashboard/assets/{github-badge-sltk1qUi.js → github-badge-DmQreSDd.js} +1 -1
  70. package/dist/dashboard/assets/github-badge-DmQreSDd.js.gz +0 -0
  71. package/dist/dashboard/assets/{index-z3zDOSMM.js → index-Bblu6erR.js} +2 -2
  72. package/dist/dashboard/assets/index-Bblu6erR.js.gz +0 -0
  73. package/dist/dashboard/assets/{inline-BhY2sSnO.js → inline-C9dCYaBZ.js} +1 -1
  74. package/dist/dashboard/assets/inline-C9dCYaBZ.js.gz +0 -0
  75. package/dist/dashboard/assets/{input-V1umFiBk.js → input-CjCnXweJ.js} +1 -1
  76. package/dist/dashboard/assets/input-CjCnXweJ.js.gz +0 -0
  77. package/dist/dashboard/assets/{layout-BBOYycyS.js → layout-V6y8DAkl.js} +1 -1
  78. package/dist/dashboard/assets/layout-V6y8DAkl.js.gz +0 -0
  79. package/dist/dashboard/assets/{list-row-1zzZYM0B.js → list-row-CM1AaG_y.js} +1 -1
  80. package/dist/dashboard/assets/list-row-CM1AaG_y.js.gz +0 -0
  81. package/dist/dashboard/assets/{live-BivGfuUs.js → live-ta6GMb-8.js} +1 -1
  82. package/dist/dashboard/assets/live-ta6GMb-8.js.gz +0 -0
  83. package/dist/dashboard/assets/{model-bans-CmffjLuG.js → model-bans-BmYTmbAk.js} +1 -1
  84. package/dist/dashboard/assets/model-bans-BmYTmbAk.js.gz +0 -0
  85. package/dist/dashboard/assets/{mutation-46P0oWMe.js → mutation-g_fduiNw.js} +1 -1
  86. package/dist/dashboard/assets/mutation-g_fduiNw.js.gz +0 -0
  87. package/dist/dashboard/assets/{numbers-DkfMR5Qs.js → numbers-BOevJzdz.js} +1 -1
  88. package/dist/dashboard/assets/numbers-BOevJzdz.js.gz +0 -0
  89. package/dist/dashboard/assets/{query-keys-BFhGpwR1.js → query-keys-UETqfytp.js} +1 -1
  90. package/dist/dashboard/assets/query-keys-UETqfytp.js.gz +0 -0
  91. package/dist/dashboard/assets/{routing-CAb4F37R.js → routing-BPUo-W8d.js} +1 -1
  92. package/dist/dashboard/assets/routing-BPUo-W8d.js.gz +0 -0
  93. package/dist/dashboard/assets/{select-C6MMiaio.js → select-C980c4cz.js} +1 -1
  94. package/dist/dashboard/assets/select-C980c4cz.js.gz +0 -0
  95. package/dist/dashboard/assets/{settings-DNX9sydi.js → settings--3hxSyry.js} +1 -1
  96. package/dist/dashboard/assets/settings--3hxSyry.js.gz +0 -0
  97. package/dist/dashboard/assets/{stack-BHTfXdz_.js → stack-_gfTdLVB.js} +1 -1
  98. package/dist/dashboard/assets/stack-_gfTdLVB.js.gz +0 -0
  99. package/dist/dashboard/assets/{state-mPDRlQkl.js → state-2c5V4zo7.js} +1 -1
  100. package/dist/dashboard/assets/state-2c5V4zo7.js.gz +0 -0
  101. package/dist/dashboard/assets/{switch-DOmJxg7H.js → switch-Dkke7Naf.js} +1 -1
  102. package/dist/dashboard/assets/switch-Dkke7Naf.js.gz +0 -0
  103. package/dist/dashboard/assets/{useMutation-Dem0lXQj.js → useMutation-EDFRkn1i.js} +1 -1
  104. package/dist/dashboard/assets/useMutation-EDFRkn1i.js.gz +0 -0
  105. package/dist/dashboard/index.html +1 -1
  106. package/dist/dashboard/index.html.gz +0 -0
  107. package/dist/dashboard/precompressed-assets.json +51 -51
  108. package/migrations/README.md +52 -52
  109. package/migrations/full-postgres/0020_add_telemetry_event_analytics.sql +77 -0
  110. package/migrations/full-postgres/0021_add_api_key_concurrency_tolerance.sql +13 -0
  111. package/migrations/full-postgres/0022_add_inferhub_config_key.sql +17 -0
  112. package/migrations/full-postgres/0023_drop_api_key_concurrency_tolerance.sql +13 -0
  113. package/migrations/lite-sqlite3/0020_add_telemetry_event_analytics.sql +69 -0
  114. package/migrations/lite-sqlite3/0021_add_api_key_concurrency_tolerance.sql +13 -0
  115. package/migrations/lite-sqlite3/0022_add_inferhub_config_key.sql +17 -0
  116. package/migrations/lite-sqlite3/0023_drop_api_key_concurrency_tolerance.sql +13 -0
  117. package/package.json +96 -97
  118. package/scripts/cli/admin.ts +126 -125
  119. package/scripts/cli/autostart.ts +237 -237
  120. package/scripts/cli/background.ts +120 -110
  121. package/scripts/cli/bin/carte.cjs +8 -8
  122. package/scripts/cli/bin/cartethyia.cjs +167 -167
  123. package/scripts/cli/console-menu.ts +308 -275
  124. package/scripts/cli/controller.ts +145 -0
  125. package/scripts/cli/doctor.ts +1 -1
  126. package/scripts/cli/env.ts +99 -99
  127. package/scripts/cli/gateway.ts +264 -261
  128. package/scripts/{health.ts → cli/health.ts} +4 -4
  129. package/scripts/cli/instance.ts +234 -224
  130. package/scripts/cli/main.ts +188 -164
  131. package/scripts/cli/platform.ts +64 -64
  132. package/scripts/cli/prompt.ts +275 -107
  133. package/scripts/cli/services.ts +88 -88
  134. package/scripts/cli/setup.ts +445 -445
  135. package/scripts/cli/start.ts +85 -82
  136. package/scripts/cli/startup-signal.ts +23 -23
  137. package/scripts/cli/telemetry.ts +70 -0
  138. package/scripts/cli/tray/index.ts +263 -263
  139. package/scripts/cli/tray/windows.ps1 +107 -107
  140. package/scripts/cli/tray.ts +192 -208
  141. package/scripts/cli/update.ts +39 -39
  142. package/scripts/cli/version.ts +67 -67
  143. package/scripts/cli/wordmark.ts +58 -58
  144. package/src/boot/app.ts +16 -1
  145. package/src/boot/dependencies.ts +128 -25
  146. package/src/config/package-paths.ts +24 -0
  147. package/src/config/resources.ts +137 -0
  148. package/src/config/runtime.ts +133 -76
  149. package/src/config/spec.ts +30 -29
  150. package/src/config/telemetry.ts +0 -25
  151. package/src/console/api-keys/bucket-spend.ts +14 -17
  152. package/src/console/api-keys/contracts.ts +410 -410
  153. package/src/console/api-keys/routes.ts +12 -0
  154. package/src/console/audit/store.ts +2 -3
  155. package/src/console/auth/setup-service.ts +8 -1
  156. package/src/console/backup/routes.ts +7 -0
  157. package/src/console/backup/service.ts +463 -463
  158. package/src/console/backup/store.ts +16 -11
  159. package/src/console/backup/validate.ts +1 -4
  160. package/src/console/cli-tools/fs-ops.ts +18 -2
  161. package/src/console/data-management/contracts.ts +1 -8
  162. package/src/console/data-management/operations.ts +61 -48
  163. package/src/console/data-management/routes.ts +4 -2
  164. package/src/console/domain-registration.ts +25 -3
  165. package/src/console/inferhub/client.ts +139 -0
  166. package/src/console/inferhub/key-store.ts +129 -0
  167. package/src/console/inferhub/normalize.ts +347 -0
  168. package/src/console/inferhub/routes.ts +411 -0
  169. package/src/console/inferhub/store.ts +109 -0
  170. package/src/console/inferhub/types.ts +183 -0
  171. package/src/console/observability/contracts.ts +2 -41
  172. package/src/console/observability/store.ts +176 -371
  173. package/src/console/providers/catalog/public-model-store.ts +0 -0
  174. package/src/console/providers/catalog/store.ts +23 -36
  175. package/src/console/quota/account-quota-shared.ts +52 -52
  176. package/src/console/quota/account-quota-tenant.ts +16 -24
  177. package/src/console/quota/refresh.ts +441 -441
  178. package/src/console/remote-support/service.ts +270 -114
  179. package/src/console/routing/pools/import-parse.ts +206 -206
  180. package/src/console/routing/pools/operations.ts +543 -543
  181. package/src/console/routing/pools/probe-verdict.ts +132 -132
  182. package/src/console/routing/pools/routes.ts +181 -181
  183. package/src/console/routing/pools/store.ts +1 -1
  184. package/src/console/settings/contracts.ts +42 -40
  185. package/src/console/settings/store.ts +25 -25
  186. package/src/console/share/share-stats.ts +22 -4
  187. package/src/console/share/share-usage.ts +30 -4
  188. package/src/console/shared/cursor.ts +15 -0
  189. package/src/console/updates/repository-updates.ts +40 -39
  190. package/src/console/updates/routes.ts +12 -0
  191. package/src/main.ts +13 -5
  192. package/src/network/pool/resolver.ts +1104 -1104
  193. package/src/observability/client-name.ts +22 -0
  194. package/src/observability/event-log.ts +5 -29
  195. package/src/observability/event-query.ts +690 -0
  196. package/src/observability/log-ring.ts +26 -2
  197. package/src/observability/log-sanitize.ts +118 -0
  198. package/src/observability/logger.ts +17 -13
  199. package/src/observability/metrics.ts +35 -0
  200. package/src/observability/payload-capture.ts +114 -41
  201. package/src/observability/payload-store.ts +249 -24
  202. package/src/observability/raw-event-writer.ts +118 -0
  203. package/src/observability/runtime-metrics.ts +71 -1
  204. package/src/observability/telemetry-buffer.ts +100 -17
  205. package/src/observability/telemetry-reconciliation.ts +417 -0
  206. package/src/persistence/boot.ts +20 -9
  207. package/src/persistence/db-pg.ts +13 -0
  208. package/src/persistence/db-sqlite.ts +569 -497
  209. package/src/persistence/dialect.ts +3 -2
  210. package/src/persistence/migrate.ts +181 -181
  211. package/src/persistence/schema.ts +1496 -1406
  212. package/src/persistence/shared-cache.ts +5 -1
  213. package/src/persistence/stores/api-key-store.ts +581 -581
  214. package/src/persistence/stores/telemetry-store.ts +124 -63
  215. package/src/protocol/reasoning-text.ts +30 -0
  216. package/src/protocol/registry.ts +2 -0
  217. package/src/protocol/response/chat.ts +33 -7
  218. package/src/protocol/response/codex.ts +14 -2
  219. package/src/protocol/response/messages.ts +5 -2
  220. package/src/providers/authentication/oauth-flow-store.ts +3 -10
  221. package/src/providers/bundled-discovery-providers.ts +122 -122
  222. package/src/providers/default-registry.ts +14 -0
  223. package/src/providers/discovery/catalog/catalog-blob.ts +18 -18
  224. package/src/providers/discovery/models-dev-catalog.ts +354 -354
  225. package/src/providers/discovery/probe-phases.ts +43 -7
  226. package/src/providers/discovery/probing-service.ts +70 -32
  227. package/src/providers/integrations/antigravity/antigravity.ts +9 -2
  228. package/src/providers/integrations/buddy/buddy-catalog-shared.ts +85 -17
  229. package/src/providers/integrations/buddy/buddy-chat-shared.ts +934 -934
  230. package/src/providers/integrations/buddy/buddy-checkin.ts +5 -9
  231. package/src/providers/integrations/buddy/buddy-discovery-shared.ts +3 -7
  232. package/src/providers/integrations/buddy/buddy-quota-shared.ts +3 -1
  233. package/src/providers/integrations/buddy/capabilities.ts +4 -0
  234. package/src/providers/integrations/buddy/codebuddy/cn-provider.ts +57 -49
  235. package/src/providers/integrations/buddy/codebuddy/provider.ts +180 -180
  236. package/src/providers/integrations/buddy/workbuddy/provider.ts +206 -206
  237. package/src/providers/integrations/buddy/workbuddy/quota.ts +161 -161
  238. package/src/providers/integrations/claude/claude-quota.ts +2 -2
  239. package/src/providers/integrations/claude/claude-user-profiles.ts +3 -2
  240. package/src/providers/integrations/claude/claude.ts +3 -4
  241. package/src/providers/integrations/cline/cline.ts +94 -8
  242. package/src/providers/integrations/codex/codex.ts +61 -37
  243. package/src/providers/integrations/cursor/cursor-catalog.ts +368 -368
  244. package/src/providers/integrations/cursor/cursor-http2.ts +300 -300
  245. package/src/providers/integrations/cursor/cursor-identity.ts +2 -4
  246. package/src/providers/integrations/cursor/cursor-request.ts +11 -0
  247. package/src/providers/integrations/cursor/cursor.ts +29 -17
  248. package/src/providers/integrations/cursor/protocol/framing.ts +129 -129
  249. package/src/providers/integrations/cursor/protocol/messages.ts +2380 -2380
  250. package/src/providers/integrations/cursor/protocol/struct.ts +305 -305
  251. package/src/providers/integrations/cursor/protocol/tool-canonical.ts +275 -0
  252. package/src/providers/integrations/github/github.ts +3 -1
  253. package/src/providers/integrations/grok/grok-quota.ts +279 -279
  254. package/src/providers/integrations/grok/grok-ratelimit-store.ts +73 -73
  255. package/src/providers/integrations/grok/grok-ratelimit.ts +218 -218
  256. package/src/providers/integrations/hermes.ts +7 -1
  257. package/src/providers/integrations/inferhub.ts +201 -73
  258. package/src/providers/integrations/kilo/kilo.ts +152 -152
  259. package/src/providers/integrations/kiro/kiro-machine-id.ts +2 -4
  260. package/src/providers/integrations/opencode/provider.ts +2 -0
  261. package/src/providers/integrations/openrouter/provider.ts +129 -129
  262. package/src/providers/integrations/qoder/qoder-quota.ts +193 -192
  263. package/src/providers/integrations/qoder/qoder-realm.ts +106 -106
  264. package/src/providers/integrations/trae/trae-headers.ts +17 -48
  265. package/src/providers/integrations/trae/trae-oauth.ts +4 -26
  266. package/src/providers/integrations/trae/trae-quota.ts +389 -384
  267. package/src/providers/integrations/trae/trae-realm.ts +15 -2
  268. package/src/providers/integrations/trae/trae.ts +22 -24
  269. package/src/providers/integrations/xiaomi-mimo/think-stream.ts +100 -24
  270. package/src/providers/integrations/zcode/oauth.ts +1 -1
  271. package/src/providers/operations/account-health-service.ts +605 -279
  272. package/src/providers/provider-metadata.ts +12 -1
  273. package/src/providers/provider-model-definition.ts +373 -373
  274. package/src/providers/provider-probe.ts +3 -0
  275. package/src/providers/quota/quota-contracts.ts +3 -7
  276. package/src/providers/search/search-provider.ts +2 -6
  277. package/src/security/admission/contracts.ts +10 -0
  278. package/src/security/admission/in-memory-store.ts +89 -14
  279. package/src/security/admission/lua.ts +191 -182
  280. package/src/security/admission/redis-store.ts +30 -0
  281. package/src/security/admission/service.ts +18 -0
  282. package/src/security/api-key-auth-cache.ts +96 -0
  283. package/src/security/api-key-auth.ts +11 -100
  284. package/src/security/client-router-fingerprint.ts +3 -7
  285. package/src/security/ip-boundary.ts +7 -3
  286. package/src/shared/coerce.ts +67 -0
  287. package/src/shared/hash.ts +6 -0
  288. package/src/shared/time.ts +7 -0
  289. package/src/transport/dispatch/attempt-finalize.ts +26 -138
  290. package/src/transport/dispatch/attempt-loop.ts +103 -81
  291. package/src/transport/dispatch/capture-policy.ts +78 -0
  292. package/src/transport/dispatch/proxy-request.ts +4 -2
  293. package/src/transport/dispatch/streaming/encoder.ts +111 -5
  294. package/src/transport/dispatch/streaming/stream.ts +19 -2
  295. package/src/transport/dispatch/streaming/thinking-loop.ts +300 -0
  296. package/src/transport/failure-policy.ts +27 -0
  297. package/src/transport/guards/abuse-guard.ts +2 -2
  298. package/src/transport/guards/auth-guard.ts +17 -0
  299. package/src/transport/guards/body-policy.ts +3 -2
  300. package/src/transport/guards/concurrency-guard.ts +89 -13
  301. package/src/transport/guards/early-rejection-capture.ts +43 -3
  302. package/src/transport/guards/error-lifecycle.ts +10 -2
  303. package/src/transport/guards/guard-errors.ts +23 -0
  304. package/src/transport/guards/headroom-guard.ts +41 -7
  305. package/src/transport/guards/pipeline.ts +24 -0
  306. package/src/transport/guards/request-context.ts +2 -1
  307. package/src/transport/routing/admission.ts +51 -9
  308. package/src/transport/routing/bounded-lru.ts +49 -0
  309. package/src/transport/routing/candidate-index.ts +88 -0
  310. package/src/transport/routing/reservations.ts +121 -7
  311. package/src/transport/routing/round-robin.ts +10 -22
  312. package/src/transport/routing/route-catalog.ts +26 -13
  313. package/src/transport/routing/route-model.ts +22 -2
  314. package/src/transport/routing/router.ts +53 -31
  315. package/src/transport/surface/chat/encode.ts +2 -3
  316. package/src/transport/surface/chat/parse.ts +2 -1
  317. package/src/transport/surface/dialects.ts +11 -3
  318. package/src/transport/surface/messages/encode.ts +2 -1
  319. package/src/transport/surface/messages/parse.ts +3 -1
  320. package/src/transport/surface/messages/stream.ts +2 -6
  321. package/src/workers/maintenance-lease.ts +200 -0
  322. package/src/workers/scheduler.ts +101 -1
  323. package/dist/dashboard/assets/BannedPage-Brvuj_Eb.js.gz +0 -0
  324. package/dist/dashboard/assets/CliToolDetailPage-CqfXtI3y.js.gz +0 -0
  325. package/dist/dashboard/assets/CliToolsPage-GQeS91bg.js.gz +0 -0
  326. package/dist/dashboard/assets/CombosPage-BDRsTXBB.js.gz +0 -0
  327. package/dist/dashboard/assets/ConsoleLogPage-zP8AFHX0.js.gz +0 -0
  328. package/dist/dashboard/assets/CustomizationPage-BmfrNZ14.js.gz +0 -0
  329. package/dist/dashboard/assets/HealthEventsModal-Dn3M6WZc.js +0 -1
  330. package/dist/dashboard/assets/HealthEventsModal-Dn3M6WZc.js.gz +0 -0
  331. package/dist/dashboard/assets/LoginPage-DoreIgm8.js.gz +0 -0
  332. package/dist/dashboard/assets/ModelPicker-C7pDb41R.js.gz +0 -0
  333. package/dist/dashboard/assets/OverviewPage-CvvD1Ugk.js +0 -1
  334. package/dist/dashboard/assets/OverviewPage-CvvD1Ugk.js.gz +0 -0
  335. package/dist/dashboard/assets/ProviderDetailPage-Clbn1gsW.js +0 -3
  336. package/dist/dashboard/assets/ProviderDetailPage-Clbn1gsW.js.gz +0 -0
  337. package/dist/dashboard/assets/ProvidersPage-BnoPgA7w.js.gz +0 -0
  338. package/dist/dashboard/assets/ProxyPage-Bq_TGgdb.js.gz +0 -0
  339. package/dist/dashboard/assets/QuotaPage-D9DHXvom.js +0 -1
  340. package/dist/dashboard/assets/QuotaPage-D9DHXvom.js.gz +0 -0
  341. package/dist/dashboard/assets/SettingsPage-7xV8WkWL.js +0 -1
  342. package/dist/dashboard/assets/SettingsPage-7xV8WkWL.js.gz +0 -0
  343. package/dist/dashboard/assets/SetupPage-Day3mCOn.js.gz +0 -0
  344. package/dist/dashboard/assets/SortableList-DQxllnB5.js.gz +0 -0
  345. package/dist/dashboard/assets/UsagePage-DXa1oR-4.js.gz +0 -0
  346. package/dist/dashboard/assets/api-keys-DQYLQxpW.js.gz +0 -0
  347. package/dist/dashboard/assets/app-CX_Z8cMs.css +0 -2
  348. package/dist/dashboard/assets/app-CX_Z8cMs.css.gz +0 -0
  349. package/dist/dashboard/assets/assert-C9FD_JwJ.js.gz +0 -0
  350. package/dist/dashboard/assets/badge-CR72dasT.js.gz +0 -0
  351. package/dist/dashboard/assets/banner-CI9_eSfO.js.gz +0 -0
  352. package/dist/dashboard/assets/card-DS7CQEsO.js.gz +0 -0
  353. package/dist/dashboard/assets/cli-tools-CNqzrfdb.js.gz +0 -0
  354. package/dist/dashboard/assets/clipboard-button-611pSsaX.js.gz +0 -0
  355. package/dist/dashboard/assets/dialog-k37exjNt.js.gz +0 -0
  356. package/dist/dashboard/assets/drawer-CX7N8ucR.js.gz +0 -0
  357. package/dist/dashboard/assets/entry-B9Xvtnfj.js +0 -1
  358. package/dist/dashboard/assets/entry-B9Xvtnfj.js.gz +0 -0
  359. package/dist/dashboard/assets/entry-ByMGkR3C.js +0 -4
  360. package/dist/dashboard/assets/entry-ByMGkR3C.js.gz +0 -0
  361. package/dist/dashboard/assets/entry-CTP8AyiS.js.gz +0 -0
  362. package/dist/dashboard/assets/github-badge-sltk1qUi.js.gz +0 -0
  363. package/dist/dashboard/assets/index-z3zDOSMM.js.gz +0 -0
  364. package/dist/dashboard/assets/inline-BhY2sSnO.js.gz +0 -0
  365. package/dist/dashboard/assets/input-V1umFiBk.js.gz +0 -0
  366. package/dist/dashboard/assets/layout-BBOYycyS.js.gz +0 -0
  367. package/dist/dashboard/assets/list-row-1zzZYM0B.js.gz +0 -0
  368. package/dist/dashboard/assets/live-BivGfuUs.js.gz +0 -0
  369. package/dist/dashboard/assets/model-bans-CmffjLuG.js.gz +0 -0
  370. package/dist/dashboard/assets/mutation-46P0oWMe.js.gz +0 -0
  371. package/dist/dashboard/assets/numbers-DkfMR5Qs.js.gz +0 -0
  372. package/dist/dashboard/assets/query-keys-BFhGpwR1.js.gz +0 -0
  373. package/dist/dashboard/assets/routing-CAb4F37R.js.gz +0 -0
  374. package/dist/dashboard/assets/select-C6MMiaio.js.gz +0 -0
  375. package/dist/dashboard/assets/settings-DNX9sydi.js.gz +0 -0
  376. package/dist/dashboard/assets/stack-BHTfXdz_.js.gz +0 -0
  377. package/dist/dashboard/assets/state-mPDRlQkl.js.gz +0 -0
  378. package/dist/dashboard/assets/switch-DOmJxg7H.js.gz +0 -0
  379. package/dist/dashboard/assets/useMutation-Dem0lXQj.js.gz +0 -0
  380. package/scripts/build/aot.ts +0 -143
  381. package/scripts/build/build.ts +0 -24
  382. package/scripts/build/compile.ts +0 -88
  383. package/scripts/build/restart.ts +0 -120
  384. package/scripts/build/start.ts +0 -25
  385. package/src/observability/event-compaction.ts +0 -317
  386. package/src/observability/event-day-index.ts +0 -440
@@ -1,354 +1,354 @@
1
- /**
2
- * Offline model catalog: limits, capabilities, and pricing for every model the
3
- * bundled providers can route to.
4
- *
5
- * The data is a packed, brotli-compressed binary blob (`catalog-blob.ts`, built
6
- * by `scripts/tools/generate-models.ts`) read through {@link PackedCatalog}.
7
- * There is no JSON parse and no per-row object: a lookup is a binary search
8
- * over a typed-array column, and the metadata object for a row is built only
9
- * when something asks for that row.
10
- *
11
- * Sources, both fetched by the generator:
12
- * - **models.dev** — the broad catalog and the only per-model pricing.
13
- * - **models.opencode.ai** — the authoritative limits for the OpenCode family,
14
- * whose dispatch listing (`/zen/v1/models`) publishes ids with no limits at
15
- * all. Without it every fetched OpenCode model published an invented
16
- * 200k/64k.
17
- *
18
- * Runtime network access is intentionally absent: a deployed instance uses the
19
- * exact catalog selected at build time.
20
- */
21
- import { brotliDecompressSync } from "node:zlib";
22
- import {
23
- lowerAscii,
24
- PackedCatalog,
25
- type ModelsDevModelCost,
26
- type ModelsDevModelMetadata,
27
- } from "./catalog/catalog-format";
28
- import {
29
- CATALOG_BLOB_BROTLI_BASE64,
30
- CATALOG_BLOB_BYTES,
31
- } from "./catalog/catalog-blob";
32
-
33
- export type { ModelsDevModelCost, ModelsDevModelMetadata };
34
-
35
- /**
36
- * Cartethyia provider id → the provider key the catalog files it under.
37
- *
38
- * Only ids that genuinely differ belong here, and each entry is backed by the
39
- * deployment's own upstream: `opencode` serves `opencode.ai`, and its model
40
- * ids also appear under the catalog's `opencode`. Without this the exact
41
- * lookup missed for every one of those models, so each fell through to the bare
42
- * lookup — which fails closed when providers disagree — and the entry was
43
- * published with invented defaults.
44
- *
45
- * Deliberately absent: providers with no catalog counterpart (a private
46
- * gateway, a BYOK endpoint) and any id whose match rests on model ids alone.
47
- * A shared model id is not proof of identity — the same generic id appears
48
- * under hundreds of resellers — so a guess here would attribute a stranger's
49
- * limits to this gateway's serving, which is the exact failure this catalog's
50
- * disagreement rule exists to prevent.
51
- *
52
- * This table is for **metadata** (limits, modalities), where a wrong row is
53
- * harmful and failing closed is right. Pricing does not need it: see
54
- * {@link ModelsDevCatalog.costFor}, which falls back to the model's own rate.
55
- */
56
- const CATALOG_PROVIDER_IDS: Readonly<Record<string, string>> = {
57
- // `opencode` is both a Cartethyia provider id and a catalog key, so it needs
58
- // no entry: `exactMetadata` tries the id directly first. Only the tiers whose
59
- // id differs from the catalog key belong here.
60
- "opencode-zen": "opencode",
61
- "opencode-go": "opencode-go",
62
- ollamacloud: "ollama-cloud",
63
- xiaomipg: "xiaomi-token-plan-sgp",
64
- xiaomitp: "xiaomi-token-plan-cn",
65
- // Cartethyia ids that are shorter than the name the catalog files the
66
- // provider under. Without these the exact lookup misses for every model, and
67
- // each row falls through to the bare lookup — which fails closed when
68
- // providers disagree about the id — so the catalog would publish invented
69
- // defaults.
70
- github: "github-copilot",
71
- // Discovery-backed aggregators whose catalog key is spelled differently.
72
- // `qianfan` and `helmcode` are deliberately absent: the catalog files no rows
73
- // for either, and an entry pointing at a provider it does not carry would
74
- // make every lookup miss and publish invented limits.
75
- siliconflowcn: "siliconflow-cn",
76
- nanogpt: "nano-gpt",
77
- novita: "novita-ai",
78
- together: "togetherai",
79
- wafer: "wafer.ai",
80
- // The Token Plan and the mainland DashScope host. Each points at the key the
81
- // catalog files the *same* service under, so without these every one of their
82
- // rows missed the exact lookup and fell through to the bare one, which fails
83
- // closed when providers disagree about an id that resellers share.
84
- //
85
- // `cloudflareai` is deliberately absent: the gateway files its rows as
86
- // `anthropic/claude-opus-5` while the provider dispatches the bare
87
- // `claude-opus-5`, so the exact lookup misses under either key. An entry here
88
- // would only restate the miss.
89
- alibabacp: "alibaba-token-plan",
90
- dashscope: "alibaba-coding-plan-cn",
91
- };
92
-
93
- /**
94
- * Removes a trailing release date from a model id, in either spelling the
95
- * catalog uses: `-YYYY-MM-DD` (`gpt-4o-2024-11-20`) or `-YYYYMMDD`
96
- * (`claude-haiku-4-5-20251001`). A dated snapshot bills the same rate as the
97
- * model it snapshots, so both forms must reach the same row.
98
- */
99
- function stripDateSuffix(modelId: string): string {
100
- return modelId.replace(/-\d{4}-\d{2}-\d{2}$/, "").replace(/-\d{8}$/, "");
101
- }
102
-
103
- /**
104
- * Suffixes a reseller appends to a model id it republishes unchanged.
105
- *
106
- * OpenRouter lists `deepseek/deepseek-v4.1-flash:free` — the `:free` marks its
107
- * own billing tier, not a different model, so the underlying row is the one
108
- * carrying the real context and output limits. `@variant` is the same idea (a
109
- * pinned quantization or provider route).
110
- *
111
- * Stripped for *lookup only* — the id sent upstream is always the caller's.
112
- */
113
- function stripVariantSuffix(modelId: string): string {
114
- return modelId.replace(/[:@][^/@:]*$/, "");
115
- }
116
-
117
- /**
118
- * The bare id: what follows a reseller's `provider/` prefix, if any.
119
- *
120
- * Returned as bytes because every lookup compares against the packed blob
121
- * without allocating a string.
122
- */
123
- function bareBytes(idLower: Uint8Array): Uint8Array {
124
- for (let i = 0; i < idLower.length; i += 1) {
125
- if (idLower[i] === 0x2f /* "/" */) return idLower.subarray(i + 1);
126
- }
127
- return idLower;
128
- }
129
-
130
- const UNKNOWN_COST: ModelsDevModelCost = {
131
- input: null,
132
- output: null,
133
- pricing_model: "unknown",
134
- };
135
-
136
- /**
137
- * The packed catalog, decompressed on first use and memoized.
138
- *
139
- * Brotli + base64, not `JSON.parse`: the blob is columnar, so this allocates
140
- * one buffer and takes typed-array views over it rather than materialising
141
- * 8900 objects. `CATALOG_BLOB_BYTES` is the decompressed length, checked here
142
- * so a consumer paired with a half-regenerated blob fails on first access
143
- * rather than resolving a truncated catalog.
144
- *
145
- * The decode is lazy on purpose. It is ~105 KB of base64 that expands to a
146
- * much larger buffer, and running it at module load would bill every process,
147
- * including the ones that never resolve a model (a CLI subcommand, a migration
148
- * run, a test that only touches the console), on the boot path. Deferring it
149
- * moves that cost to the first catalog lookup and pays it once.
150
- */
151
- let catalogCache: PackedCatalog | undefined;
152
-
153
- function catalog(): PackedCatalog {
154
- const cached = catalogCache;
155
- if (cached !== undefined) return cached;
156
- const bytes = brotliDecompressSync(Buffer.from(CATALOG_BLOB_BROTLI_BASE64, "base64"));
157
- if (bytes.byteLength !== CATALOG_BLOB_BYTES) {
158
- throw new Error(
159
- `The packed model catalog is ${bytes.byteLength} B, expected ` +
160
- `${CATALOG_BLOB_BYTES} B — regenerate it.`,
161
- );
162
- }
163
- // `PackedCatalog` copies into a 4-byte-aligned buffer of its own, so the
164
- // decompressed buffer is dropped here rather than held beside the copy.
165
- const packed = new PackedCatalog(bytes);
166
- catalogCache = packed;
167
- return packed;
168
- }
169
-
170
- /**
171
- * Resolves model metadata and pricing exclusively from the packed base catalog
172
- * shipped with Cartethyia.
173
- */
174
- export class ModelsDevCatalog {
175
- /** When the generator fetched the upstream catalogs. */
176
- get fetchedAt(): string {
177
- return catalog().fetchedAt;
178
- }
179
- /** Every provider key the catalog files. */
180
- get providers(): readonly string[] {
181
- return catalog().providers;
182
- }
183
-
184
- /**
185
- * Metadata for an exact `(providerIndex, idLower)` pair, or `undefined`.
186
- *
187
- * The provider is tried as given first and then under its mapped key: the
188
- * mapped name is a second chance, not a replacement, so an id that already
189
- * matches exactly keeps winning.
190
- */
191
- private exactMetadata(providerLower: string, idLower: Uint8Array): ModelsDevModelMetadata | undefined {
192
- const direct = catalog().exactIndex(catalog().providerIndex(providerLower), idLower);
193
- if (direct >= 0) return catalog().metadataAt(direct);
194
- const mapped = CATALOG_PROVIDER_IDS[providerLower];
195
- if (mapped === undefined) return undefined;
196
- return catalog().metadataAt(catalog().exactIndex(catalog().providerIndex(mapped), idLower));
197
- }
198
-
199
- /** Metadata rows whose *bare* id equals `bare`, in catalog order. */
200
- private metadataForBare(bare: Uint8Array): ModelsDevModelMetadata[] {
201
- const [start, end] = catalog().bareRange(bare);
202
- const out: ModelsDevModelMetadata[] = [];
203
- for (let position = start; position < end; position += 1) {
204
- const metadata = catalog().metadataAt(catalog().bareOrderAt(position));
205
- if (metadata !== undefined) out.push(metadata);
206
- }
207
- return out;
208
- }
209
-
210
- /**
211
- * Resolves metadata for one model id.
212
- *
213
- * An exact `provider:model` hit always wins — it is the only lookup that
214
- * names the provider actually serving the request. The bare-id fallback is
215
- * used only when it is **unambiguous**: if the catalog recorded the id under
216
- * several providers with different facts, no row can be called the model's
217
- * own, so the answer is `undefined` and the caller keeps its own declared
218
- * limits instead of inheriting a stranger's.
219
- *
220
- * Callers that know the provider should pass it; passing `""` opts into the
221
- * bare lookup, which is why it fails closed on disagreement.
222
- */
223
- resolve(providerId: string, modelId: string): ModelsDevModelMetadata | undefined {
224
- const provider = providerId.trim().toLowerCase();
225
- const idLower = lowerAscii(modelId);
226
- if (idLower.length === 0) return undefined;
227
- const exact = this.exactMetadata(provider, idLower);
228
- if (exact !== undefined) return exact;
229
- // A reseller suffix is the reseller's, not the model's: retry the exact
230
- // lookup without it before falling through to the bare id. Doing this here
231
- // rather than stripping up front keeps the provider-scoped match.
232
- const unsuffixedText = stripVariantSuffix(modelId.trim().toLowerCase());
233
- if (unsuffixedText !== modelId.trim().toLowerCase()) {
234
- const unsuffixed = this.exactMetadata(provider, lowerAscii(unsuffixedText));
235
- if (unsuffixed !== undefined) return unsuffixed;
236
- }
237
- const bare = bareBytes(idLower);
238
- const unambiguous = this.unambiguousBare(bare);
239
- if (unambiguous !== undefined) return unambiguous;
240
- const strippedText = stripDateSuffix(modelId.trim().toLowerCase());
241
- const bareText = new TextDecoder().decode(bare);
242
- if (strippedText !== bareText) {
243
- const dated = this.unambiguousBare(lowerAscii(strippedText));
244
- if (dated !== undefined) return dated;
245
- }
246
- // Last chance: the bare id may carry a reseller suffix of its own
247
- // (`provider/model:free` → bare `model:free`).
248
- const unsuffixedBareText = stripVariantSuffix(bareText);
249
- if (unsuffixedBareText !== bareText && unsuffixedBareText !== strippedText) {
250
- return this.unambiguousBare(lowerAscii(unsuffixedBareText));
251
- }
252
- return undefined;
253
- }
254
-
255
- /**
256
- * The single row for a bare id, or `undefined` when the catalog disagrees.
257
- *
258
- * Two rows that agree on every reported fact are interchangeable, so the
259
- * first is returned rather than treating agreement as ambiguity.
260
- */
261
- private unambiguousBare(bare: Uint8Array): ModelsDevModelMetadata | undefined {
262
- const rows = this.metadataForBare(bare);
263
- const first = rows[0];
264
- if (first === undefined) return undefined;
265
- if (rows.length === 1) return first;
266
- const signature = (metadata: ModelsDevModelMetadata): string =>
267
- JSON.stringify([metadata.contextLimit, metadata.outputLimit, metadata.cost, metadata.modalities]);
268
- const expected = signature(first);
269
- for (const row of rows) {
270
- if (signature(row) !== expected) return undefined;
271
- }
272
- return first;
273
- }
274
-
275
- /**
276
- * The most widely agreed row for a bare id, or `undefined` when nothing
277
- * records it.
278
- *
279
- * `resolve`/`unambiguousBare` fail closed on any disagreement, which is right
280
- * when the caller can leave a limit unstated. A caller that must publish a
281
- * number for a model the catalog knows about but disagrees on — a roster
282
- * whose own upstream reports nothing, where the alternative is a fixed
283
- * default — is better served by the majority answer: 18 of 19 rows file
284
- * `claude-opus-5` as 1000000/128000, and the single dissenter (a reseller's
285
- * 64k output cap) is the outlier, not the model.
286
- *
287
- * The vote is per-field and only among rows that state the field, so a row
288
- * omitting `output` cannot win that field by abstaining. Ties resolve to the
289
- * larger value: a limit stated too low truncates a caller's work, while one
290
- * stated too high is caught by the provider.
291
- */
292
- majorityFor(modelId: string): { contextLimit: number; outputLimit: number } | undefined {
293
- const idLower = lowerAscii(modelId);
294
- const bare = bareBytes(idLower);
295
- // The bare index is keyed by the id as filed, so a dated spelling needs its
296
- // undated form tried too. Merge rather than pick: both name one model.
297
- const bareText = new TextDecoder().decode(bare);
298
- const rows = [
299
- ...this.metadataForBare(bare),
300
- ...(stripDateSuffix(bareText) === bareText
301
- ? []
302
- : this.metadataForBare(lowerAscii(stripDateSuffix(bareText)))),
303
- ];
304
- if (rows.length === 0) return undefined;
305
- const vote = (values: ReadonlyArray<number | null>): number | undefined => {
306
- const counts = new Map<number, number>();
307
- for (const value of values) {
308
- if (typeof value !== "number" || !Number.isFinite(value) || value <= 0) continue;
309
- counts.set(value, (counts.get(value) ?? 0) + 1);
310
- }
311
- let best: number | undefined;
312
- let bestCount = 0;
313
- for (const [value, count] of counts) {
314
- if (count > bestCount || (count === bestCount && best !== undefined && value > best)) {
315
- best = value;
316
- bestCount = count;
317
- }
318
- }
319
- return best;
320
- };
321
- const contextLimit = vote(rows.map((row) => row.contextLimit));
322
- const outputLimit = vote(rows.map((row) => row.outputLimit));
323
- if (contextLimit === undefined && outputLimit === undefined) return undefined;
324
- return {
325
- contextLimit: contextLimit ?? outputLimit ?? 0,
326
- outputLimit: outputLimit ?? contextLimit ?? 0,
327
- };
328
- }
329
-
330
- /**
331
- * The price to bill for `modelId`, preferring the routed provider's own row.
332
- *
333
- * Unlike {@link resolve}, this does **not** fail closed when the provider has
334
- * no row: a gateway that republishes a model without publishing its own rate
335
- * still owes a cost, and the model's own global rate is the honest answer.
336
- * `resolve` keeps its fail-closed metadata rule — a wrong context limit is
337
- * harmful, while a missing price makes every request on that route read
338
- * `$0.00` as if it were measured.
339
- */
340
- costFor(providerId: string, modelId: string): ModelsDevModelCost {
341
- if (modelId.trim().length === 0 && providerId.trim().length === 0) return UNKNOWN_COST;
342
- const own = this.resolve(providerId, modelId)?.cost;
343
- if (own !== undefined && (typeof own.input === "number" || typeof own.output === "number")) {
344
- return own;
345
- }
346
- const idLower = lowerAscii(modelId);
347
- const row = catalog().globalCostRow(bareBytes(idLower));
348
- const global = row >= 0 ? catalog().rowCost(row) : undefined;
349
- if (global !== undefined && global.pricing_model !== "unknown") return global;
350
- return own ?? global ?? UNKNOWN_COST;
351
- }
352
- }
353
-
354
- export const modelsDevCatalog = new ModelsDevCatalog();
1
+ /**
2
+ * Offline model catalog: limits, capabilities, and pricing for every model the
3
+ * bundled providers can route to.
4
+ *
5
+ * The data is a packed, brotli-compressed binary blob (`catalog-blob.ts`, built
6
+ * by `scripts/tools/generate-models.ts`) read through {@link PackedCatalog}.
7
+ * There is no JSON parse and no per-row object: a lookup is a binary search
8
+ * over a typed-array column, and the metadata object for a row is built only
9
+ * when something asks for that row.
10
+ *
11
+ * Sources, both fetched by the generator:
12
+ * - **models.dev** — the broad catalog and the only per-model pricing.
13
+ * - **models.opencode.ai** — the authoritative limits for the OpenCode family,
14
+ * whose dispatch listing (`/zen/v1/models`) publishes ids with no limits at
15
+ * all. Without it every fetched OpenCode model published an invented
16
+ * 200k/64k.
17
+ *
18
+ * Runtime network access is intentionally absent: a deployed instance uses the
19
+ * exact catalog selected at build time.
20
+ */
21
+ import { brotliDecompressSync } from "node:zlib";
22
+ import {
23
+ lowerAscii,
24
+ PackedCatalog,
25
+ type ModelsDevModelCost,
26
+ type ModelsDevModelMetadata,
27
+ } from "./catalog/catalog-format";
28
+ import {
29
+ CATALOG_BLOB_BROTLI_BASE64,
30
+ CATALOG_BLOB_BYTES,
31
+ } from "./catalog/catalog-blob";
32
+
33
+ export type { ModelsDevModelCost, ModelsDevModelMetadata };
34
+
35
+ /**
36
+ * Cartethyia provider id → the provider key the catalog files it under.
37
+ *
38
+ * Only ids that genuinely differ belong here, and each entry is backed by the
39
+ * deployment's own upstream: `opencode` serves `opencode.ai`, and its model
40
+ * ids also appear under the catalog's `opencode`. Without this the exact
41
+ * lookup missed for every one of those models, so each fell through to the bare
42
+ * lookup — which fails closed when providers disagree — and the entry was
43
+ * published with invented defaults.
44
+ *
45
+ * Deliberately absent: providers with no catalog counterpart (a private
46
+ * gateway, a BYOK endpoint) and any id whose match rests on model ids alone.
47
+ * A shared model id is not proof of identity — the same generic id appears
48
+ * under hundreds of resellers — so a guess here would attribute a stranger's
49
+ * limits to this gateway's serving, which is the exact failure this catalog's
50
+ * disagreement rule exists to prevent.
51
+ *
52
+ * This table is for **metadata** (limits, modalities), where a wrong row is
53
+ * harmful and failing closed is right. Pricing does not need it: see
54
+ * {@link ModelsDevCatalog.costFor}, which falls back to the model's own rate.
55
+ */
56
+ const CATALOG_PROVIDER_IDS: Readonly<Record<string, string>> = {
57
+ // `opencode` is both a Cartethyia provider id and a catalog key, so it needs
58
+ // no entry: `exactMetadata` tries the id directly first. Only the tiers whose
59
+ // id differs from the catalog key belong here.
60
+ "opencode-zen": "opencode",
61
+ "opencode-go": "opencode-go",
62
+ ollamacloud: "ollama-cloud",
63
+ xiaomipg: "xiaomi-token-plan-sgp",
64
+ xiaomitp: "xiaomi-token-plan-cn",
65
+ // Cartethyia ids that are shorter than the name the catalog files the
66
+ // provider under. Without these the exact lookup misses for every model, and
67
+ // each row falls through to the bare lookup — which fails closed when
68
+ // providers disagree about the id — so the catalog would publish invented
69
+ // defaults.
70
+ github: "github-copilot",
71
+ // Discovery-backed aggregators whose catalog key is spelled differently.
72
+ // `qianfan` and `helmcode` are deliberately absent: the catalog files no rows
73
+ // for either, and an entry pointing at a provider it does not carry would
74
+ // make every lookup miss and publish invented limits.
75
+ siliconflowcn: "siliconflow-cn",
76
+ nanogpt: "nano-gpt",
77
+ novita: "novita-ai",
78
+ together: "togetherai",
79
+ wafer: "wafer.ai",
80
+ // The Token Plan and the mainland DashScope host. Each points at the key the
81
+ // catalog files the *same* service under, so without these every one of their
82
+ // rows missed the exact lookup and fell through to the bare one, which fails
83
+ // closed when providers disagree about an id that resellers share.
84
+ //
85
+ // `cloudflareai` is deliberately absent: the gateway files its rows as
86
+ // `anthropic/claude-opus-5` while the provider dispatches the bare
87
+ // `claude-opus-5`, so the exact lookup misses under either key. An entry here
88
+ // would only restate the miss.
89
+ alibabacp: "alibaba-token-plan",
90
+ dashscope: "alibaba-coding-plan-cn",
91
+ };
92
+
93
+ /**
94
+ * Removes a trailing release date from a model id, in either spelling the
95
+ * catalog uses: `-YYYY-MM-DD` (`gpt-4o-2024-11-20`) or `-YYYYMMDD`
96
+ * (`claude-haiku-4-5-20251001`). A dated snapshot bills the same rate as the
97
+ * model it snapshots, so both forms must reach the same row.
98
+ */
99
+ function stripDateSuffix(modelId: string): string {
100
+ return modelId.replace(/-\d{4}-\d{2}-\d{2}$/, "").replace(/-\d{8}$/, "");
101
+ }
102
+
103
+ /**
104
+ * Suffixes a reseller appends to a model id it republishes unchanged.
105
+ *
106
+ * OpenRouter lists `deepseek/deepseek-v4.1-flash:free` — the `:free` marks its
107
+ * own billing tier, not a different model, so the underlying row is the one
108
+ * carrying the real context and output limits. `@variant` is the same idea (a
109
+ * pinned quantization or provider route).
110
+ *
111
+ * Stripped for *lookup only* — the id sent upstream is always the caller's.
112
+ */
113
+ function stripVariantSuffix(modelId: string): string {
114
+ return modelId.replace(/[:@][^/@:]*$/, "");
115
+ }
116
+
117
+ /**
118
+ * The bare id: what follows a reseller's `provider/` prefix, if any.
119
+ *
120
+ * Returned as bytes because every lookup compares against the packed blob
121
+ * without allocating a string.
122
+ */
123
+ function bareBytes(idLower: Uint8Array): Uint8Array {
124
+ for (let i = 0; i < idLower.length; i += 1) {
125
+ if (idLower[i] === 0x2f /* "/" */) return idLower.subarray(i + 1);
126
+ }
127
+ return idLower;
128
+ }
129
+
130
+ const UNKNOWN_COST: ModelsDevModelCost = {
131
+ input: null,
132
+ output: null,
133
+ pricing_model: "unknown",
134
+ };
135
+
136
+ /**
137
+ * The packed catalog, decompressed on first use and memoized.
138
+ *
139
+ * Brotli + base64, not `JSON.parse`: the blob is columnar, so this allocates
140
+ * one buffer and takes typed-array views over it rather than materialising
141
+ * 8900 objects. `CATALOG_BLOB_BYTES` is the decompressed length, checked here
142
+ * so a consumer paired with a half-regenerated blob fails on first access
143
+ * rather than resolving a truncated catalog.
144
+ *
145
+ * The decode is lazy on purpose. It is ~105 KB of base64 that expands to a
146
+ * much larger buffer, and running it at module load would bill every process,
147
+ * including the ones that never resolve a model (a CLI subcommand, a migration
148
+ * run, a test that only touches the console), on the boot path. Deferring it
149
+ * moves that cost to the first catalog lookup and pays it once.
150
+ */
151
+ let catalogCache: PackedCatalog | undefined;
152
+
153
+ function catalog(): PackedCatalog {
154
+ const cached = catalogCache;
155
+ if (cached !== undefined) return cached;
156
+ const bytes = brotliDecompressSync(Buffer.from(CATALOG_BLOB_BROTLI_BASE64, "base64"));
157
+ if (bytes.byteLength !== CATALOG_BLOB_BYTES) {
158
+ throw new Error(
159
+ `The packed model catalog is ${bytes.byteLength} B, expected ` +
160
+ `${CATALOG_BLOB_BYTES} B — regenerate it.`,
161
+ );
162
+ }
163
+ // `PackedCatalog` copies into a 4-byte-aligned buffer of its own, so the
164
+ // decompressed buffer is dropped here rather than held beside the copy.
165
+ const packed = new PackedCatalog(bytes);
166
+ catalogCache = packed;
167
+ return packed;
168
+ }
169
+
170
+ /**
171
+ * Resolves model metadata and pricing exclusively from the packed base catalog
172
+ * shipped with Cartethyia.
173
+ */
174
+ export class ModelsDevCatalog {
175
+ /** When the generator fetched the upstream catalogs. */
176
+ get fetchedAt(): string {
177
+ return catalog().fetchedAt;
178
+ }
179
+ /** Every provider key the catalog files. */
180
+ get providers(): readonly string[] {
181
+ return catalog().providers;
182
+ }
183
+
184
+ /**
185
+ * Metadata for an exact `(providerIndex, idLower)` pair, or `undefined`.
186
+ *
187
+ * The provider is tried as given first and then under its mapped key: the
188
+ * mapped name is a second chance, not a replacement, so an id that already
189
+ * matches exactly keeps winning.
190
+ */
191
+ private exactMetadata(providerLower: string, idLower: Uint8Array): ModelsDevModelMetadata | undefined {
192
+ const direct = catalog().exactIndex(catalog().providerIndex(providerLower), idLower);
193
+ if (direct >= 0) return catalog().metadataAt(direct);
194
+ const mapped = CATALOG_PROVIDER_IDS[providerLower];
195
+ if (mapped === undefined) return undefined;
196
+ return catalog().metadataAt(catalog().exactIndex(catalog().providerIndex(mapped), idLower));
197
+ }
198
+
199
+ /** Metadata rows whose *bare* id equals `bare`, in catalog order. */
200
+ private metadataForBare(bare: Uint8Array): ModelsDevModelMetadata[] {
201
+ const [start, end] = catalog().bareRange(bare);
202
+ const out: ModelsDevModelMetadata[] = [];
203
+ for (let position = start; position < end; position += 1) {
204
+ const metadata = catalog().metadataAt(catalog().bareOrderAt(position));
205
+ if (metadata !== undefined) out.push(metadata);
206
+ }
207
+ return out;
208
+ }
209
+
210
+ /**
211
+ * Resolves metadata for one model id.
212
+ *
213
+ * An exact `provider:model` hit always wins — it is the only lookup that
214
+ * names the provider actually serving the request. The bare-id fallback is
215
+ * used only when it is **unambiguous**: if the catalog recorded the id under
216
+ * several providers with different facts, no row can be called the model's
217
+ * own, so the answer is `undefined` and the caller keeps its own declared
218
+ * limits instead of inheriting a stranger's.
219
+ *
220
+ * Callers that know the provider should pass it; passing `""` opts into the
221
+ * bare lookup, which is why it fails closed on disagreement.
222
+ */
223
+ resolve(providerId: string, modelId: string): ModelsDevModelMetadata | undefined {
224
+ const provider = providerId.trim().toLowerCase();
225
+ const idLower = lowerAscii(modelId);
226
+ if (idLower.length === 0) return undefined;
227
+ const exact = this.exactMetadata(provider, idLower);
228
+ if (exact !== undefined) return exact;
229
+ // A reseller suffix is the reseller's, not the model's: retry the exact
230
+ // lookup without it before falling through to the bare id. Doing this here
231
+ // rather than stripping up front keeps the provider-scoped match.
232
+ const unsuffixedText = stripVariantSuffix(modelId.trim().toLowerCase());
233
+ if (unsuffixedText !== modelId.trim().toLowerCase()) {
234
+ const unsuffixed = this.exactMetadata(provider, lowerAscii(unsuffixedText));
235
+ if (unsuffixed !== undefined) return unsuffixed;
236
+ }
237
+ const bare = bareBytes(idLower);
238
+ const unambiguous = this.unambiguousBare(bare);
239
+ if (unambiguous !== undefined) return unambiguous;
240
+ const strippedText = stripDateSuffix(modelId.trim().toLowerCase());
241
+ const bareText = new TextDecoder().decode(bare);
242
+ if (strippedText !== bareText) {
243
+ const dated = this.unambiguousBare(lowerAscii(strippedText));
244
+ if (dated !== undefined) return dated;
245
+ }
246
+ // Last chance: the bare id may carry a reseller suffix of its own
247
+ // (`provider/model:free` → bare `model:free`).
248
+ const unsuffixedBareText = stripVariantSuffix(bareText);
249
+ if (unsuffixedBareText !== bareText && unsuffixedBareText !== strippedText) {
250
+ return this.unambiguousBare(lowerAscii(unsuffixedBareText));
251
+ }
252
+ return undefined;
253
+ }
254
+
255
+ /**
256
+ * The single row for a bare id, or `undefined` when the catalog disagrees.
257
+ *
258
+ * Two rows that agree on every reported fact are interchangeable, so the
259
+ * first is returned rather than treating agreement as ambiguity.
260
+ */
261
+ private unambiguousBare(bare: Uint8Array): ModelsDevModelMetadata | undefined {
262
+ const rows = this.metadataForBare(bare);
263
+ const first = rows[0];
264
+ if (first === undefined) return undefined;
265
+ if (rows.length === 1) return first;
266
+ const signature = (metadata: ModelsDevModelMetadata): string =>
267
+ JSON.stringify([metadata.contextLimit, metadata.outputLimit, metadata.cost, metadata.modalities]);
268
+ const expected = signature(first);
269
+ for (const row of rows) {
270
+ if (signature(row) !== expected) return undefined;
271
+ }
272
+ return first;
273
+ }
274
+
275
+ /**
276
+ * The most widely agreed row for a bare id, or `undefined` when nothing
277
+ * records it.
278
+ *
279
+ * `resolve`/`unambiguousBare` fail closed on any disagreement, which is right
280
+ * when the caller can leave a limit unstated. A caller that must publish a
281
+ * number for a model the catalog knows about but disagrees on — a roster
282
+ * whose own upstream reports nothing, where the alternative is a fixed
283
+ * default — is better served by the majority answer: 18 of 19 rows file
284
+ * `claude-opus-5` as 1000000/128000, and the single dissenter (a reseller's
285
+ * 64k output cap) is the outlier, not the model.
286
+ *
287
+ * The vote is per-field and only among rows that state the field, so a row
288
+ * omitting `output` cannot win that field by abstaining. Ties resolve to the
289
+ * larger value: a limit stated too low truncates a caller's work, while one
290
+ * stated too high is caught by the provider.
291
+ */
292
+ majorityFor(modelId: string): { contextLimit: number; outputLimit: number } | undefined {
293
+ const idLower = lowerAscii(modelId);
294
+ const bare = bareBytes(idLower);
295
+ // The bare index is keyed by the id as filed, so a dated spelling needs its
296
+ // undated form tried too. Merge rather than pick: both name one model.
297
+ const bareText = new TextDecoder().decode(bare);
298
+ const rows = [
299
+ ...this.metadataForBare(bare),
300
+ ...(stripDateSuffix(bareText) === bareText
301
+ ? []
302
+ : this.metadataForBare(lowerAscii(stripDateSuffix(bareText)))),
303
+ ];
304
+ if (rows.length === 0) return undefined;
305
+ const vote = (values: ReadonlyArray<number | null>): number | undefined => {
306
+ const counts = new Map<number, number>();
307
+ for (const value of values) {
308
+ if (typeof value !== "number" || !Number.isFinite(value) || value <= 0) continue;
309
+ counts.set(value, (counts.get(value) ?? 0) + 1);
310
+ }
311
+ let best: number | undefined;
312
+ let bestCount = 0;
313
+ for (const [value, count] of counts) {
314
+ if (count > bestCount || (count === bestCount && best !== undefined && value > best)) {
315
+ best = value;
316
+ bestCount = count;
317
+ }
318
+ }
319
+ return best;
320
+ };
321
+ const contextLimit = vote(rows.map((row) => row.contextLimit));
322
+ const outputLimit = vote(rows.map((row) => row.outputLimit));
323
+ if (contextLimit === undefined && outputLimit === undefined) return undefined;
324
+ return {
325
+ contextLimit: contextLimit ?? outputLimit ?? 0,
326
+ outputLimit: outputLimit ?? contextLimit ?? 0,
327
+ };
328
+ }
329
+
330
+ /**
331
+ * The price to bill for `modelId`, preferring the routed provider's own row.
332
+ *
333
+ * Unlike {@link resolve}, this does **not** fail closed when the provider has
334
+ * no row: a gateway that republishes a model without publishing its own rate
335
+ * still owes a cost, and the model's own global rate is the honest answer.
336
+ * `resolve` keeps its fail-closed metadata rule — a wrong context limit is
337
+ * harmful, while a missing price makes every request on that route read
338
+ * `$0.00` as if it were measured.
339
+ */
340
+ costFor(providerId: string, modelId: string): ModelsDevModelCost {
341
+ if (modelId.trim().length === 0 && providerId.trim().length === 0) return UNKNOWN_COST;
342
+ const own = this.resolve(providerId, modelId)?.cost;
343
+ if (own !== undefined && (typeof own.input === "number" || typeof own.output === "number")) {
344
+ return own;
345
+ }
346
+ const idLower = lowerAscii(modelId);
347
+ const row = catalog().globalCostRow(bareBytes(idLower));
348
+ const global = row >= 0 ? catalog().rowCost(row) : undefined;
349
+ if (global !== undefined && global.pricing_model !== "unknown") return global;
350
+ return own ?? global ?? UNKNOWN_COST;
351
+ }
352
+ }
353
+
354
+ export const modelsDevCatalog = new ModelsDevCatalog();