cartethyia 2.4.6 → 2.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (302) hide show
  1. package/README.md +45 -13
  2. package/dist/dashboard/assets/BannedPage-9igfctfZ.js +1 -0
  3. package/dist/dashboard/assets/BannedPage-9igfctfZ.js.gz +0 -0
  4. package/dist/dashboard/assets/{CliToolDetailPage-Bcy150Jo.js → CliToolDetailPage-ZRr2K2sF.js} +1 -1
  5. package/dist/dashboard/assets/CliToolDetailPage-ZRr2K2sF.js.gz +0 -0
  6. package/dist/dashboard/assets/{CliToolsPage-DyNXefph.js → CliToolsPage-D3xqZbN-.js} +1 -1
  7. package/dist/dashboard/assets/CliToolsPage-D3xqZbN-.js.gz +0 -0
  8. package/dist/dashboard/assets/CombosPage-CVnQPBBz.js +1 -0
  9. package/dist/dashboard/assets/CombosPage-CVnQPBBz.js.gz +0 -0
  10. package/dist/dashboard/assets/{ConsoleLogPage-CATpAEOz.js → ConsoleLogPage-u6W1UXSc.js} +1 -1
  11. package/dist/dashboard/assets/ConsoleLogPage-u6W1UXSc.js.gz +0 -0
  12. package/dist/dashboard/assets/{CustomizationPage-D6jEU7nL.js → CustomizationPage-CTTpCb25.js} +1 -1
  13. package/dist/dashboard/assets/CustomizationPage-CTTpCb25.js.gz +0 -0
  14. package/dist/dashboard/assets/{HealthEventsModal-CZkpn0a7.js → HealthEventsModal-C5XpWlvy.js} +1 -1
  15. package/dist/dashboard/assets/HealthEventsModal-C5XpWlvy.js.gz +0 -0
  16. package/dist/dashboard/assets/{LoginPage-BJlMVN4U.js → LoginPage-DHoB3Agn.js} +1 -1
  17. package/dist/dashboard/assets/LoginPage-DHoB3Agn.js.gz +0 -0
  18. package/dist/dashboard/assets/{ModelPicker-Br3CXEau.js → ModelPicker-oQ-SDXp8.js} +1 -1
  19. package/dist/dashboard/assets/ModelPicker-oQ-SDXp8.js.gz +0 -0
  20. package/dist/dashboard/assets/OverviewPage-BrY4SgIt.js +1 -0
  21. package/dist/dashboard/assets/OverviewPage-BrY4SgIt.js.gz +0 -0
  22. package/dist/dashboard/assets/ProviderDetailPage-D9VSt7T_.js +3 -0
  23. package/dist/dashboard/assets/ProviderDetailPage-D9VSt7T_.js.gz +0 -0
  24. package/dist/dashboard/assets/ProviderIcon-CmfzxpVM.js +1 -0
  25. package/dist/dashboard/assets/ProviderIcon-CmfzxpVM.js.gz +0 -0
  26. package/dist/dashboard/assets/ProvidersPage-Df77V5I5.js +1 -0
  27. package/dist/dashboard/assets/ProvidersPage-Df77V5I5.js.gz +0 -0
  28. package/dist/dashboard/assets/ProxyPage-CDfxP7AC.js +7 -0
  29. package/dist/dashboard/assets/ProxyPage-CDfxP7AC.js.gz +0 -0
  30. package/dist/dashboard/assets/QuotaPage-CY4o2t4p.js +1 -0
  31. package/dist/dashboard/assets/QuotaPage-CY4o2t4p.js.gz +0 -0
  32. package/dist/dashboard/assets/{SettingsPage-BHW4Zt06.js → SettingsPage-CvjZxHE4.js} +1 -1
  33. package/dist/dashboard/assets/SettingsPage-CvjZxHE4.js.gz +0 -0
  34. package/dist/dashboard/assets/{SetupPage-VOYC_lIa.js → SetupPage-D3nza7LH.js} +1 -1
  35. package/dist/dashboard/assets/SetupPage-D3nza7LH.js.gz +0 -0
  36. package/dist/dashboard/assets/SortableList-Dtt12WT1.js +1 -0
  37. package/dist/dashboard/assets/SortableList-Dtt12WT1.js.gz +0 -0
  38. package/dist/dashboard/assets/{StudioPage-BNIDLiU6.js → StudioPage-CbK3hXvU.js} +4 -4
  39. package/dist/dashboard/assets/StudioPage-CbK3hXvU.js.gz +0 -0
  40. package/dist/dashboard/assets/UsagePage-BQlWbWg2.js +56 -0
  41. package/dist/dashboard/assets/UsagePage-BQlWbWg2.js.gz +0 -0
  42. package/dist/dashboard/assets/{api-keys-BETfaMYZ.js → api-keys-BmDbsior.js} +1 -1
  43. package/dist/dashboard/assets/api-keys-BmDbsior.js.gz +0 -0
  44. package/dist/dashboard/assets/app-BbpSarVV.css +2 -0
  45. package/dist/dashboard/assets/app-BbpSarVV.css.gz +0 -0
  46. package/dist/dashboard/assets/assert-BHfaei-X.js +1 -0
  47. package/dist/dashboard/assets/assert-BHfaei-X.js.gz +0 -0
  48. package/dist/dashboard/assets/{badge-BTyGk_DL.js → badge-DpXT4Ixt.js} +1 -1
  49. package/dist/dashboard/assets/badge-DpXT4Ixt.js.gz +0 -0
  50. package/dist/dashboard/assets/{banner-bujTWYM4.js → banner-CSAAWLJv.js} +1 -1
  51. package/dist/dashboard/assets/banner-CSAAWLJv.js.gz +0 -0
  52. package/dist/dashboard/assets/{card-B0Bnx5CA.js → card-DEhRXXw4.js} +1 -1
  53. package/dist/dashboard/assets/card-DEhRXXw4.js.gz +0 -0
  54. package/dist/dashboard/assets/{cli-tools-DE2ug812.js → cli-tools-B0wBq-RL.js} +1 -1
  55. package/dist/dashboard/assets/cli-tools-B0wBq-RL.js.gz +0 -0
  56. package/dist/dashboard/assets/{clipboard-button-R_rArH8S.js → clipboard-button-DOo6EU8O.js} +1 -1
  57. package/dist/dashboard/assets/clipboard-button-DOo6EU8O.js.gz +0 -0
  58. package/dist/dashboard/assets/{dialog-BWIP_EvW.js → dialog-BvQeBMfP.js} +1 -1
  59. package/dist/dashboard/assets/dialog-BvQeBMfP.js.gz +0 -0
  60. package/dist/dashboard/assets/{drawer-BKcgVugF.js → drawer-CMwPXwQB.js} +1 -1
  61. package/dist/dashboard/assets/drawer-CMwPXwQB.js.gz +0 -0
  62. package/dist/dashboard/assets/{entry-CcWkGR4a.js → entry-D4O2229X.js} +1 -1
  63. package/dist/dashboard/assets/{entry-CcWkGR4a.js.gz → entry-D4O2229X.js.gz} +0 -0
  64. package/dist/dashboard/assets/{entry-kTiTFrvh.js → entry-DKvr5Q9K.js} +1 -1
  65. package/dist/dashboard/assets/entry-DKvr5Q9K.js.gz +0 -0
  66. package/dist/dashboard/assets/entry-FQCejoDY.js +4 -0
  67. package/dist/dashboard/assets/entry-FQCejoDY.js.gz +0 -0
  68. package/dist/dashboard/assets/{github-badge-Booc5mNe.js → github-badge-zliLvBwr.js} +1 -1
  69. package/dist/dashboard/assets/github-badge-zliLvBwr.js.gz +0 -0
  70. package/dist/dashboard/assets/{index-D70pChfn.js → index-CXm3FIyt.js} +2 -2
  71. package/dist/dashboard/assets/index-CXm3FIyt.js.gz +0 -0
  72. package/dist/dashboard/assets/{inline-D1TCqM2D.js → inline-D1IlZZ8H.js} +1 -1
  73. package/dist/dashboard/assets/inline-D1IlZZ8H.js.gz +0 -0
  74. package/dist/dashboard/assets/{input-B9oyU93y.js → input-BpVZdrbc.js} +1 -1
  75. package/dist/dashboard/assets/input-BpVZdrbc.js.gz +0 -0
  76. package/dist/dashboard/assets/{layout-BArzXirQ.js → layout-BioLW7gN.js} +1 -1
  77. package/dist/dashboard/assets/layout-BioLW7gN.js.gz +0 -0
  78. package/dist/dashboard/assets/{list-row-BVZzaoHF.js → list-row-CwVcGAIc.js} +1 -1
  79. package/dist/dashboard/assets/list-row-CwVcGAIc.js.gz +0 -0
  80. package/dist/dashboard/assets/{live-EmuIayqp.js → live-yfEJFF9T.js} +1 -1
  81. package/dist/dashboard/assets/live-yfEJFF9T.js.gz +0 -0
  82. package/dist/dashboard/assets/{model-bans-CSPQ9xB0.js → model-bans-CT-TxSNX.js} +1 -1
  83. package/dist/dashboard/assets/model-bans-CT-TxSNX.js.gz +0 -0
  84. package/dist/dashboard/assets/providers/cursor.webp +0 -0
  85. package/dist/dashboard/assets/providers/trae.webp +0 -0
  86. package/dist/dashboard/assets/{routing-BExgxxBU.js → routing-Cs6jl49L.js} +1 -1
  87. package/dist/dashboard/assets/routing-Cs6jl49L.js.gz +0 -0
  88. package/dist/dashboard/assets/select-C0Rer3gF.js +41 -0
  89. package/dist/dashboard/assets/select-C0Rer3gF.js.gz +0 -0
  90. package/dist/dashboard/assets/settings-CfcTcOyU.js +1 -0
  91. package/dist/dashboard/assets/settings-CfcTcOyU.js.gz +0 -0
  92. package/dist/dashboard/assets/{stack-Ca5HxI5i.js → stack-C9_uUjaA.js} +1 -1
  93. package/dist/dashboard/assets/stack-C9_uUjaA.js.gz +0 -0
  94. package/dist/dashboard/assets/{state-CzUPfLXo.js → state-DA0B95UQ.js} +1 -1
  95. package/dist/dashboard/assets/state-DA0B95UQ.js.gz +0 -0
  96. package/dist/dashboard/assets/{switch-DOUIky_i.js → switch-Cittxy-x.js} +1 -1
  97. package/dist/dashboard/assets/switch-Cittxy-x.js.gz +0 -0
  98. package/dist/dashboard/assets/{toast-C76WrMhc.js → toast-CNt_jzO4.js} +1 -1
  99. package/dist/dashboard/assets/toast-CNt_jzO4.js.gz +0 -0
  100. package/dist/dashboard/assets/{useMutation-D87FZwH0.js → useMutation-BQKMcJ1H.js} +1 -1
  101. package/dist/dashboard/assets/useMutation-BQKMcJ1H.js.gz +0 -0
  102. package/dist/dashboard/index.html +1 -1
  103. package/dist/dashboard/index.html.gz +0 -0
  104. package/dist/dashboard/precompressed-assets.json +51 -52
  105. package/migrations/full-postgres/0015_add_payload_stages.sql +18 -0
  106. package/migrations/full-postgres/0016_add_share_link_slug.sql +20 -0
  107. package/migrations/full-postgres/0017_add_models_pro_only.sql +18 -0
  108. package/migrations/full-postgres/0018_add_accounts_quota_plan.sql +18 -0
  109. package/migrations/full-postgres/0019_add_models_free_tier.sql +15 -0
  110. package/migrations/lite-sqlite3/0015_add_payload_stages.sql +18 -0
  111. package/migrations/lite-sqlite3/0016_add_share_link_slug.sql +20 -0
  112. package/migrations/lite-sqlite3/0017_add_models_pro_only.sql +18 -0
  113. package/migrations/lite-sqlite3/0018_add_accounts_quota_plan.sql +18 -0
  114. package/migrations/lite-sqlite3/0019_add_models_free_tier.sql +15 -0
  115. package/package.json +3 -2
  116. package/scripts/cli/command/console-menu.ts +30 -11
  117. package/scripts/cli/command/start.ts +77 -13
  118. package/scripts/cli/command/update.ts +1 -1
  119. package/scripts/cli/gateway/instance-lock.ts +105 -0
  120. package/scripts/cli/gateway/spawn.ts +173 -101
  121. package/scripts/cli/ui/background.ts +110 -49
  122. package/src/boot/app.ts +20 -6
  123. package/src/boot/dependencies.ts +19 -0
  124. package/src/boot/elysia-typebox-bridge.ts +37 -0
  125. package/src/boot/lifecycle.ts +2 -0
  126. package/src/config/cooldowns.ts +138 -138
  127. package/src/config/providers.ts +5 -5
  128. package/src/config/runtime.ts +19 -3
  129. package/src/config/spec.ts +9 -0
  130. package/src/console/api-keys/contracts.ts +414 -391
  131. package/src/console/api-keys/routes.ts +880 -870
  132. package/src/console/auth/account-reset-store.ts +14 -0
  133. package/src/console/auth/account-reset.ts +3 -2
  134. package/src/console/auth/setup-service.ts +6 -3
  135. package/src/console/domain-registration.ts +9 -2
  136. package/src/console/observability/contracts.ts +475 -455
  137. package/src/console/observability/store.ts +1015 -952
  138. package/src/console/providers/catalog/contracts.ts +31 -0
  139. package/src/console/providers/catalog/provider-operations.ts +8 -0
  140. package/src/console/providers/catalog/store.ts +1532 -1525
  141. package/src/console/providers/oauth/callback-listener.ts +18 -3
  142. package/src/console/providers/oauth/routes.ts +38 -15
  143. package/src/console/quota/refresh.ts +19 -0
  144. package/src/console/updates/routes.ts +162 -4
  145. package/src/main.ts +3 -0
  146. package/src/network/http2-bidi.ts +85 -0
  147. package/src/network/outbound-fetch.ts +1 -26
  148. package/src/network/pool/agent.ts +478 -460
  149. package/src/network/pool/resolver.ts +1104 -544
  150. package/src/network/pool-health-machine.ts +12 -5
  151. package/src/observability/payload-capture.ts +344 -324
  152. package/src/persistence/db-pg.ts +9 -3
  153. package/src/persistence/db-sqlite.ts +497 -495
  154. package/src/persistence/health-event-flush.ts +137 -0
  155. package/src/persistence/migrate.ts +181 -129
  156. package/src/persistence/schema.ts +1360 -1295
  157. package/src/persistence/settings-revision.ts +25 -0
  158. package/src/persistence/shared-cache.ts +217 -0
  159. package/src/persistence/stores/share-store.ts +556 -528
  160. package/src/persistence/usage-flush.ts +61 -1
  161. package/src/protocol/canonical-model.ts +23 -1
  162. package/src/protocol/response/chat.ts +143 -10
  163. package/src/protocol/response/omp-tool-call-markup.ts +221 -0
  164. package/src/providers/default-registry.ts +173 -1
  165. package/src/providers/discovery/openai-model-discovery.ts +39 -3
  166. package/src/providers/discovery/openrouter-free-roster.ts +98 -0
  167. package/src/providers/discovery/probing-service.ts +1022 -1001
  168. package/src/providers/integrations/buddy/buddy-catalog-shared.ts +39 -0
  169. package/src/providers/integrations/buddy/buddy-chat-shared.ts +913 -914
  170. package/src/providers/integrations/buddy/buddy-conversation-headers.ts +18 -5
  171. package/src/providers/integrations/buddy/codebuddy/cn-provider.ts +4 -0
  172. package/src/providers/integrations/buddy/codebuddy/provider.ts +180 -175
  173. package/src/providers/integrations/buddy/codebuddy/shared.ts +15 -6
  174. package/src/providers/integrations/buddy/workbuddy/provider.ts +206 -204
  175. package/src/providers/integrations/buddy/workbuddy/shared.ts +3 -3
  176. package/src/providers/integrations/claude/claude.ts +606 -582
  177. package/src/providers/integrations/cline/cline-quota.ts +18 -16
  178. package/src/providers/integrations/cline/cline.ts +539 -468
  179. package/src/providers/integrations/commandcode/commandcode.ts +675 -541
  180. package/src/providers/integrations/commandcode/discovery.ts +116 -0
  181. package/src/providers/integrations/commandcode/quota.ts +160 -0
  182. package/src/providers/integrations/cursor/cursor-auth.ts +418 -0
  183. package/src/providers/integrations/cursor/cursor-catalog.ts +368 -0
  184. package/src/providers/integrations/cursor/cursor-http2.ts +300 -0
  185. package/src/providers/integrations/cursor/cursor-identity.ts +92 -0
  186. package/src/providers/integrations/cursor/cursor-request.ts +471 -0
  187. package/src/providers/integrations/cursor/cursor-session-manager.ts +281 -0
  188. package/src/providers/integrations/cursor/cursor-shared.ts +108 -0
  189. package/src/providers/integrations/cursor/cursor.ts +960 -0
  190. package/src/providers/integrations/cursor/protocol/framing.ts +129 -0
  191. package/src/providers/integrations/cursor/protocol/messages.ts +2355 -0
  192. package/src/providers/integrations/cursor/protocol/struct.ts +305 -0
  193. package/src/providers/integrations/github/github.ts +4 -4
  194. package/src/providers/integrations/hermes.ts +79 -8
  195. package/src/providers/integrations/kilo/kilo-discovery.ts +10 -1
  196. package/src/providers/integrations/kilo/kilo.ts +153 -146
  197. package/src/providers/integrations/opencode/provider.ts +532 -538
  198. package/src/providers/integrations/openrouter/provider.ts +139 -281
  199. package/src/providers/integrations/qoder/qoder-catalog.ts +450 -0
  200. package/src/providers/integrations/qoder/qoder-oauth.ts +310 -257
  201. package/src/providers/integrations/qoder/qoder-realm.ts +106 -0
  202. package/src/providers/integrations/qoder/qoder-virtual.ts +77 -0
  203. package/src/providers/integrations/qoder/qoder.ts +865 -730
  204. package/src/providers/integrations/trae/trae-catalog.ts +190 -0
  205. package/src/providers/integrations/trae/trae-headers.ts +149 -0
  206. package/src/providers/integrations/trae/trae-oauth.ts +773 -0
  207. package/src/providers/integrations/trae/trae-protocol.ts +400 -0
  208. package/src/providers/integrations/trae/trae-realm.ts +220 -0
  209. package/src/providers/integrations/trae/trae.ts +458 -0
  210. package/src/providers/operations/account-health-service.ts +107 -10
  211. package/src/providers/operations/client-versions.ts +133 -6
  212. package/src/providers/operations/provider-catalog-seeder.ts +95 -86
  213. package/src/providers/operations/provider-credential-service.ts +46 -7
  214. package/src/providers/provider-contracts.ts +76 -0
  215. package/src/providers/provider-metadata.ts +78 -6
  216. package/src/providers/provider-model-definition.ts +373 -349
  217. package/src/security/api-key-auth.ts +126 -8
  218. package/src/transport/contracts.ts +3 -0
  219. package/src/transport/dispatch/attempt-finalize.ts +32 -1
  220. package/src/transport/dispatch/attempt-loop.ts +530 -515
  221. package/src/transport/dispatch/generation.ts +88 -21
  222. package/src/transport/dispatch/leases.ts +99 -2
  223. package/src/transport/dispatch/proxy-request.ts +60 -3
  224. package/src/transport/dispatch/streaming/finalize.ts +16 -1
  225. package/src/transport/dispatch/streaming/stream.ts +22 -2
  226. package/src/transport/dispatch/upstream.ts +19 -1
  227. package/src/transport/guards/auth-guard.ts +26 -1
  228. package/src/transport/guards/body-policy.ts +5 -4
  229. package/src/transport/request/preparer.ts +103 -0
  230. package/src/transport/request/state.ts +14 -0
  231. package/src/transport/routing/plan-gate.ts +45 -0
  232. package/src/transport/routing/route-catalog.ts +799 -777
  233. package/src/transport/routing/route-model.ts +52 -1
  234. package/src/transport/routing/router.ts +51 -0
  235. package/dist/dashboard/assets/BannedPage-QhvbJB9o.js +0 -1
  236. package/dist/dashboard/assets/BannedPage-QhvbJB9o.js.gz +0 -0
  237. package/dist/dashboard/assets/CliToolDetailPage-Bcy150Jo.js.gz +0 -0
  238. package/dist/dashboard/assets/CliToolsPage-DyNXefph.js.gz +0 -0
  239. package/dist/dashboard/assets/CombosPage-Cak0ljnO.js +0 -1
  240. package/dist/dashboard/assets/CombosPage-Cak0ljnO.js.gz +0 -0
  241. package/dist/dashboard/assets/ConsoleLogPage-CATpAEOz.js.gz +0 -0
  242. package/dist/dashboard/assets/CustomizationPage-D6jEU7nL.js.gz +0 -0
  243. package/dist/dashboard/assets/HealthEventsModal-CZkpn0a7.js.gz +0 -0
  244. package/dist/dashboard/assets/LoginPage-BJlMVN4U.js.gz +0 -0
  245. package/dist/dashboard/assets/ModelPicker-Br3CXEau.js.gz +0 -0
  246. package/dist/dashboard/assets/OverviewPage-CQxXbo2g.js +0 -1
  247. package/dist/dashboard/assets/OverviewPage-CQxXbo2g.js.gz +0 -0
  248. package/dist/dashboard/assets/ProviderDetailPage--0RBNh99.js +0 -3
  249. package/dist/dashboard/assets/ProviderDetailPage--0RBNh99.js.gz +0 -0
  250. package/dist/dashboard/assets/ProviderIcon-paOCq8I0.js +0 -1
  251. package/dist/dashboard/assets/ProviderIcon-paOCq8I0.js.gz +0 -0
  252. package/dist/dashboard/assets/ProvidersPage-1M-C3mbu.js +0 -1
  253. package/dist/dashboard/assets/ProvidersPage-1M-C3mbu.js.gz +0 -0
  254. package/dist/dashboard/assets/ProxyPage-CXiumA53.js +0 -7
  255. package/dist/dashboard/assets/ProxyPage-CXiumA53.js.gz +0 -0
  256. package/dist/dashboard/assets/QuotaPage-BGDIfIYP.js +0 -1
  257. package/dist/dashboard/assets/QuotaPage-BGDIfIYP.js.gz +0 -0
  258. package/dist/dashboard/assets/SettingsPage-BHW4Zt06.js.gz +0 -0
  259. package/dist/dashboard/assets/SetupPage-VOYC_lIa.js.gz +0 -0
  260. package/dist/dashboard/assets/SortableList-DpOTnGuH.js +0 -1
  261. package/dist/dashboard/assets/SortableList-DpOTnGuH.js.gz +0 -0
  262. package/dist/dashboard/assets/StudioPage-BNIDLiU6.js.gz +0 -0
  263. package/dist/dashboard/assets/UsagePage-BAFaSDQV.js +0 -56
  264. package/dist/dashboard/assets/UsagePage-BAFaSDQV.js.gz +0 -0
  265. package/dist/dashboard/assets/api-keys-BETfaMYZ.js.gz +0 -0
  266. package/dist/dashboard/assets/app-BLCvGdzb.css +0 -2
  267. package/dist/dashboard/assets/app-BLCvGdzb.css.gz +0 -0
  268. package/dist/dashboard/assets/assert-7J6_LVD3.js +0 -1
  269. package/dist/dashboard/assets/assert-7J6_LVD3.js.gz +0 -0
  270. package/dist/dashboard/assets/badge-BTyGk_DL.js.gz +0 -0
  271. package/dist/dashboard/assets/banner-bujTWYM4.js.gz +0 -0
  272. package/dist/dashboard/assets/card-B0Bnx5CA.js.gz +0 -0
  273. package/dist/dashboard/assets/cli-tools-DE2ug812.js.gz +0 -0
  274. package/dist/dashboard/assets/clipboard-button-R_rArH8S.js.gz +0 -0
  275. package/dist/dashboard/assets/dialog-BWIP_EvW.js.gz +0 -0
  276. package/dist/dashboard/assets/drawer-BKcgVugF.js.gz +0 -0
  277. package/dist/dashboard/assets/entry-7dpgvpJ3.js +0 -4
  278. package/dist/dashboard/assets/entry-7dpgvpJ3.js.gz +0 -0
  279. package/dist/dashboard/assets/entry-kTiTFrvh.js.gz +0 -0
  280. package/dist/dashboard/assets/github-badge-Booc5mNe.js.gz +0 -0
  281. package/dist/dashboard/assets/index-D70pChfn.js.gz +0 -0
  282. package/dist/dashboard/assets/inline-D1TCqM2D.js.gz +0 -0
  283. package/dist/dashboard/assets/input-B9oyU93y.js.gz +0 -0
  284. package/dist/dashboard/assets/layout-BArzXirQ.js.gz +0 -0
  285. package/dist/dashboard/assets/list-row-BVZzaoHF.js.gz +0 -0
  286. package/dist/dashboard/assets/live-EmuIayqp.js.gz +0 -0
  287. package/dist/dashboard/assets/model-bans-CSPQ9xB0.js.gz +0 -0
  288. package/dist/dashboard/assets/routing-BExgxxBU.js.gz +0 -0
  289. package/dist/dashboard/assets/select-C9mMoEpJ.js +0 -41
  290. package/dist/dashboard/assets/select-C9mMoEpJ.js.gz +0 -0
  291. package/dist/dashboard/assets/settings-C4_Gn_wq.js +0 -1
  292. package/dist/dashboard/assets/settings-C4_Gn_wq.js.gz +0 -0
  293. package/dist/dashboard/assets/sliders-horizontal-ClG7dBeJ.js +0 -1
  294. package/dist/dashboard/assets/sliders-horizontal-ClG7dBeJ.js.gz +0 -0
  295. package/dist/dashboard/assets/stack-Ca5HxI5i.js.gz +0 -0
  296. package/dist/dashboard/assets/state-CzUPfLXo.js.gz +0 -0
  297. package/dist/dashboard/assets/switch-DOUIky_i.js.gz +0 -0
  298. package/dist/dashboard/assets/toast-C76WrMhc.js.gz +0 -0
  299. package/dist/dashboard/assets/useMutation-D87FZwH0.js.gz +0 -0
  300. package/src/network/tls/bun-client-hello.ts +0 -112
  301. /package/dist/dashboard/assets/{app-Cjq7nrwu.js → app-C-Y4oZT0.js} +0 -0
  302. /package/dist/dashboard/assets/{app-Cjq7nrwu.js.gz → app-C-Y4oZT0.js.gz} +0 -0
@@ -0,0 +1,450 @@
1
+ // Qoder model catalogs, one per realm, plus the live listing that supersedes them.
2
+ //
3
+ // The two realms publish different rosters: the donor's snapshot files
4
+ // (`qoder_catalog_cn.json`, `qoder_catalog_intl.json`) disagree on 5 keys and on
5
+ // the context window of the keys they share. A single merged list therefore
6
+ // advertises models a given account cannot reach, and hides the ones only the
7
+ // other realm serves — so each realm keeps its own static floor.
8
+ //
9
+ // The static rows are the *offline floor*, not the truth: the authoritative list
10
+ // is `/algo/api/v2/model/list` for the account's own realm (`discoverQoderModels`).
11
+ import { GatewayError } from "../../../protocol/gateway-error";
12
+ import { defineModel } from "../../provider-model-definition";
13
+ import type { ModelDefinition } from "../../provider-model-definition";
14
+ import { QODER_CHAT_PATH, QODER_MODELS_PATH, qoderGatewayCandidates, type QoderRealm } from "./qoder-realm";
15
+ import { qoderVirtualIdFor, QODER_VIRTUAL_MODELS } from "./qoder-virtual";
16
+
17
+ /** One upstream catalog row, as `/algo/api/v2/model/list` reports it. */
18
+ export interface QoderCatalogEntry {
19
+ readonly key: string;
20
+ readonly displayName: string;
21
+ readonly isVl: boolean;
22
+ readonly isReasoning: boolean;
23
+ /**
24
+ * Largest window this model can be switched to.
25
+ *
26
+ * Upstream states two different numbers: `max_input_tokens` is the window
27
+ * *currently selected* (often 180k), while `context_config` lists the
28
+ * selectable windows — `{"200K":…, "400K":…, "1M":…}` — which for the same
29
+ * row go up to 1M. Taking `max_input_tokens` alone published and dispatched
30
+ * the smaller figure, so Qoder was told the conversation was 180k when the
31
+ * account could use 1M.
32
+ */
33
+ readonly maxInputTokens: number | undefined;
34
+ readonly enable: boolean;
35
+ /**
36
+ * Whether upstream publishes this row as free.
37
+ *
38
+ * Qoder states it per row (`is_free`) and it is not inferable: `price_factor`
39
+ * is 0 on both free and some billed rows, and the lite-plan flag describes a
40
+ * plan tier rather than a model. So the free tier is read from this field
41
+ * instead of being hardcoded as a list, which is what lets a plan change
42
+ * move a model between tiers without a code change.
43
+ */
44
+ readonly isFree: boolean;
45
+ }
46
+
47
+ /**
48
+ * The widest selectable window on one catalog row.
49
+ *
50
+ * `context_config` is the authority when present: it names every window the
51
+ * account can switch to, whereas `max_input_tokens` only names the one active
52
+ * now. Absent both, `undefined` — an unknown window is not zero.
53
+ */
54
+ export function qoderMaxContextWindow(raw: unknown): number | undefined {
55
+ if (raw === null || typeof raw !== "object" || Array.isArray(raw)) return undefined;
56
+ const record = raw as Record<string, unknown>;
57
+ const candidates: number[] = [];
58
+ const config = record["context_config"];
59
+ if (config !== null && typeof config === "object" && !Array.isArray(config)) {
60
+ for (const option of Object.values(config as Record<string, unknown>)) {
61
+ if (option === null || typeof option !== "object" || Array.isArray(option)) continue;
62
+ const tokenCount = (option as Record<string, unknown>)["token_count"];
63
+ if (typeof tokenCount === "number" && Number.isFinite(tokenCount) && tokenCount > 0) {
64
+ candidates.push(Math.floor(tokenCount));
65
+ }
66
+ }
67
+ }
68
+ const stated = record["max_input_tokens"];
69
+ if (typeof stated === "number" && Number.isFinite(stated) && stated > 0) {
70
+ candidates.push(Math.floor(stated));
71
+ }
72
+ return candidates.length === 0 ? undefined : Math.max(...candidates);
73
+ }
74
+
75
+ /** One static row's shape, mirrored on `QODER_MODEL_CONFIGS` in `qoder.ts`. */
76
+ interface QoderModelConfig {
77
+ readonly id: string;
78
+ readonly display_name: string;
79
+ readonly max_input_tokens: number;
80
+ readonly max_output_tokens?: number;
81
+ readonly is_vl: boolean;
82
+ readonly is_reasoning: boolean;
83
+ /**
84
+ * Whether upstream publishes this row as free. Absent means upstream did not
85
+ * state it for the row, which is not the same as false — only an explicit
86
+ * `false` marks the model as billed.
87
+ */
88
+ readonly is_free?: boolean;
89
+ }
90
+
91
+ /**
92
+ * China roster (14 keys).
93
+ *
94
+ * Captured from the donor's `qoder_catalog_cn.json`, which is the decoded
95
+ * `chat` array of a real CN account's model list.
96
+ */
97
+ export const QODER_CN_MODEL_CONFIGS: Readonly<Record<string, QoderModelConfig>> = {
98
+ auto: { id: "auto", display_name: "Auto", max_output_tokens: 64000, max_input_tokens: 200000, is_vl: true, is_reasoning: true },
99
+ qmodel_38max: { id: "qmodel_38max", display_name: "Qwen3.8-Max", max_output_tokens: 131072, max_input_tokens: 1000000, is_vl: true, is_reasoning: true },
100
+ qfmodel: { id: "qfmodel", display_name: "Qwen3.8-Flash", max_output_tokens: 131072, max_input_tokens: 1000000, is_vl: true, is_reasoning: true },
101
+ qmodel_latest: { id: "qmodel_latest", display_name: "Qwen3.7-Max", max_output_tokens: 131072, max_input_tokens: 1000000, is_vl: true, is_reasoning: true },
102
+ qmodel: { id: "qmodel", display_name: "Qwen3.7-Plus", max_output_tokens: 131072, max_input_tokens: 1000000, is_vl: true, is_reasoning: true },
103
+ q37fmodel: { id: "q37fmodel", display_name: "Qwen3.7-Flash", max_output_tokens: 131072, max_input_tokens: 1000000, is_vl: true, is_reasoning: true },
104
+ dmodel: { id: "dmodel", display_name: "DeepSeek-V4-Pro", max_output_tokens: 384000, max_input_tokens: 1000000, is_vl: true, is_reasoning: true },
105
+ dfmodel: { id: "dfmodel", display_name: "DeepSeek-Flash", max_output_tokens: 384000, max_input_tokens: 1000000, is_vl: true, is_reasoning: false },
106
+ gmodel: { id: "gmodel", display_name: "GLM-5.3", max_output_tokens: 131072, max_input_tokens: 1000000, is_vl: true, is_reasoning: true },
107
+ gfmodel: { id: "gfmodel", display_name: "GLM-5.3-Flash", max_output_tokens: 131072, max_input_tokens: 1000000, is_vl: true, is_reasoning: true },
108
+ gm51model: { id: "gm51model", display_name: "GLM-5.2", max_output_tokens: 131072, max_input_tokens: 1000000, is_vl: true, is_reasoning: true },
109
+ kmodel_latest: { id: "kmodel_latest", display_name: "Kimi-K3", max_output_tokens: 131072, max_input_tokens: 1000000, is_vl: true, is_reasoning: false },
110
+ kmodel: { id: "kmodel", display_name: "Kimi-K2.8-Preview", max_output_tokens: 65536, max_input_tokens: 1000000, is_vl: true, is_reasoning: true },
111
+ mmodel: { id: "mmodel", display_name: "MiniMax-M2.7", max_output_tokens: 131072, max_input_tokens: 200000, is_vl: false, is_reasoning: false },
112
+ };
113
+
114
+ /**
115
+ * Global/intl roster (17 keys), from the donor's `qoder_catalog_intl.json`.
116
+ *
117
+ * Note how the shared keys differ from CN — `auto` is non-reasoning here and
118
+ * `qmodel` carries a 1M window. That is why the rosters are not merged.
119
+ */
120
+ export const QODER_INTL_MODEL_CONFIGS: Readonly<Record<string, QoderModelConfig>> = {
121
+ auto: { id: "auto", display_name: "Auto", max_input_tokens: 200000, max_output_tokens: 64_000, is_vl: true, is_reasoning: false },
122
+ ultimate: { id: "ultimate", display_name: "Ultimate", max_input_tokens: 1000000, max_output_tokens: 128_000, is_vl: true, is_free: false, is_reasoning: true },
123
+ // `fast` is what the live roster reports today; `performance` is the older
124
+ // key for the same slot. Both are kept so a hand-added model id written
125
+ // against either name still resolves — the roster is the offline floor, and
126
+ // discovery supersedes it on the first successful fetch.
127
+ fast: { id: "fast", display_name: "Fast", max_input_tokens: 1000000, max_output_tokens: 64_000, is_vl: true, is_reasoning: false },
128
+ performance: { id: "performance", display_name: "Performance", max_input_tokens: 1000000, max_output_tokens: 64_000, is_vl: true, is_reasoning: false },
129
+ efficient: { id: "efficient", display_name: "Efficient", max_input_tokens: 200000, max_output_tokens: 64_000, is_vl: true, is_reasoning: false },
130
+ smodel: { id: "smodel", display_name: "Sonus", max_input_tokens: 1000000, max_output_tokens: 128_000, is_vl: true, is_free: false, is_reasoning: true },
131
+ cmodel: { id: "cmodel", display_name: "Cantus", max_input_tokens: 1000000, max_output_tokens: 128_000, is_vl: true, is_free: false, is_reasoning: true },
132
+ qmodel_38max: { id: "qmodel_38max", display_name: "Qwen3.8-Max", max_input_tokens: 1000000, max_output_tokens: 131_072, is_vl: true, is_reasoning: true },
133
+ qfmodel: { id: "qfmodel", display_name: "Qwen3.8-Flash", max_input_tokens: 1000000, max_output_tokens: 131_072, is_vl: true, is_free: true, is_reasoning: true },
134
+ qmodel_latest: { id: "qmodel_latest", display_name: "Qwen3.7-Max", max_input_tokens: 1000000, max_output_tokens: 131_072, is_vl: true, is_reasoning: false },
135
+ qmodel: { id: "qmodel", display_name: "Qwen3.7-Plus", max_input_tokens: 1000000, max_output_tokens: 131_072, is_vl: true, is_reasoning: false },
136
+ kmodel_latest: { id: "kmodel_latest", display_name: "Kimi-K3", max_input_tokens: 1000000, max_output_tokens: 131_072, is_vl: true, is_free: false, is_reasoning: false },
137
+ kmodel: { id: "kmodel", display_name: "Kimi-K2.8-Preview", max_input_tokens: 1000000, max_output_tokens: 65_536, is_vl: true, is_free: false, is_reasoning: false },
138
+ gmodel: { id: "gmodel", display_name: "GLM-5.3", max_input_tokens: 1000000, max_output_tokens: 131_072, is_vl: true, is_free: false, is_reasoning: true },
139
+ gfmodel: { id: "gfmodel", display_name: "GLM-5.3-Flash", max_input_tokens: 1000000, max_output_tokens: 131_072, is_vl: true, is_reasoning: true },
140
+ dmodel: { id: "dmodel", display_name: "DeepSeek-V4-Pro", max_input_tokens: 1000000, max_output_tokens: 384_000, is_vl: true, is_free: false, is_reasoning: true },
141
+ dfmodel: { id: "dfmodel", display_name: "DeepSeek-Flash", max_input_tokens: 1000000, max_output_tokens: 384_000, is_vl: true, is_free: true, is_reasoning: true },
142
+ mmodel: { id: "mmodel", display_name: "MiniMax-M3", max_input_tokens: 1000000, max_output_tokens: 512_000, is_vl: true, is_free: false, is_reasoning: false },
143
+ };
144
+
145
+ /**
146
+ * Output ceilings are stated per row, not derived.
147
+ *
148
+ * Qoder's `/model/list` reports no output limit at all — none of the 21 fields
149
+ * it returns for any row mentions output — so these come from the static
150
+ * roster, set to what the same model is filed at elsewhere in the catalog
151
+ * (DeepSeek-V4 at 384k, GLM/Qwen/Kimi/MiniMax at 131k, the GPT-class slots at
152
+ * 128k). The 32k that used to be hardcoded here applied to every row alike,
153
+ * which is what made the whole catalog look capped at one number.
154
+ *
155
+ * A row with no stated ceiling leaves `out` unset so `defineModel` can still
156
+ * consult the base catalog rather than publishing a guess.
157
+ */
158
+
159
+ function qoderModels(
160
+ configs: Readonly<Record<string, QoderModelConfig>>,
161
+ providerId: string,
162
+ ): readonly ModelDefinition[] {
163
+ return Object.entries(configs).map(([id, cfg]) =>
164
+ defineModel({
165
+ id,
166
+ providerId,
167
+ wireFamily: "chat",
168
+ endpoint: QODER_CHAT_PATH,
169
+ ctx: cfg.max_input_tokens,
170
+ ...(cfg.max_output_tokens === undefined ? {} : { out: cfg.max_output_tokens }),
171
+ vision: cfg.is_vl,
172
+ reasoning: cfg.is_reasoning,
173
+ // Only an explicit `true` marks the row free; an unstated flag is left
174
+ // unset so nothing claims a tier the roster did not state.
175
+ ...(cfg.is_free === undefined ? {} : { free: cfg.is_free, freeTier: cfg.is_free }),
176
+ }),
177
+ );
178
+ }
179
+
180
+ /** Static offline catalog for the China realm. */
181
+ export const QODER_CN_MODELS: readonly ModelDefinition[] = qoderModels(QODER_CN_MODEL_CONFIGS, "qodercn");
182
+
183
+ /** Static offline catalog for the global/intl realm. */
184
+ const QODER_INTL_ROSTER: readonly ModelDefinition[] = qoderModels(QODER_INTL_MODEL_CONFIGS, "qoder");
185
+
186
+ /**
187
+ * Virtual rows, one per roster key that has a caller-facing name.
188
+ *
189
+ * A virtual row inherits its target's limits and capabilities rather than
190
+ * restating them: the two ids are the same upstream model, so a window or a
191
+ * vision flag that disagreed between them would be a lie in one of the two
192
+ * places. A target this realm does not serve contributes no row, which is what
193
+ * keeps the CN and intl catalogs honest about their own rosters.
194
+ */
195
+ export function qoderVirtualModels(
196
+ roster: readonly ModelDefinition[],
197
+ providerId: string,
198
+ ): readonly ModelDefinition[] {
199
+ const byId = new Map(roster.map((row) => [row.modelId, row]));
200
+ const rows: ModelDefinition[] = [];
201
+ for (const virtual of QODER_VIRTUAL_MODELS) {
202
+ const target = byId.get(virtual.target);
203
+ if (target === undefined) continue;
204
+ rows.push(
205
+ defineModel({
206
+ id: virtual.id,
207
+ providerId,
208
+ wireFamily: "chat",
209
+ endpoint: QODER_CHAT_PATH,
210
+ // `null`, not a number: an unknown window is unknown, and `defineModel`
211
+ // substitutes the shared unfiled fallback for null — the same marker a
212
+ // hand-added row gets.
213
+ ctx: target.contextLimit ?? null,
214
+ ...(target.outputLimit === null ? {} : { out: target.outputLimit }),
215
+ vision: target.modalities.input.includes("image"),
216
+ reasoning: target.reasoning ?? false,
217
+ toolCall: target.toolCall ?? false,
218
+ // The same model under its caller-facing name belongs to the same tier,
219
+ // so the pill follows the roster row rather than being restated.
220
+ ...(target.freeTier === undefined ? {} : { free: target.freeTier, freeTier: target.freeTier }),
221
+ }),
222
+ );
223
+ }
224
+ return rows;
225
+ }
226
+
227
+ /**
228
+ * Static offline catalog for the global/intl realm.
229
+ *
230
+ * A roster key that has a caller-facing name is published under that name
231
+ * only: upstream's keys are opaque slots (`dfmodel`, `cmodel`), and offering
232
+ * both the slot and its name shows one model twice under two ids nobody can
233
+ * tell apart. A key with no known name is kept as-is — an unnamed slot is
234
+ * still reachable, and inventing a name for it would assert what upstream
235
+ * never stated.
236
+ *
237
+ * The key remains what dispatch sends; see `qoder-virtual.ts`.
238
+ */
239
+ export const QODER_INTL_MODELS: readonly ModelDefinition[] = [
240
+ ...QODER_INTL_ROSTER.filter((row) => qoderVirtualIdFor(row.modelId) === undefined),
241
+ ...qoderVirtualModels(QODER_INTL_ROSTER, "qoder"),
242
+ ];
243
+
244
+ /**
245
+ * Reads upstream's `{"chat": [...]}` envelope.
246
+ *
247
+ * Rows without a usable `key` are dropped rather than coerced: a model id the
248
+ * upstream did not state would become an unroutable catalog row.
249
+ */
250
+ export function mapQoderCatalogResponse(raw: unknown): QoderCatalogEntry[] {
251
+ if (raw === null || typeof raw !== "object" || Array.isArray(raw)) return [];
252
+ const chat = (raw as Record<string, unknown>)["chat"];
253
+ if (!Array.isArray(chat)) return [];
254
+ const entries: QoderCatalogEntry[] = [];
255
+ for (const row of chat) {
256
+ if (row === null || typeof row !== "object" || Array.isArray(row)) continue;
257
+ const record = row as Record<string, unknown>;
258
+ const key = record["key"];
259
+ if (typeof key !== "string" || key.trim().length === 0) continue;
260
+ const displayName = record["display_name"];
261
+ entries.push({
262
+ key: key.trim(),
263
+ displayName: typeof displayName === "string" && displayName.length > 0 ? displayName : key.trim(),
264
+ isVl: record["is_vl"] === true,
265
+ isReasoning: record["is_reasoning"] === true,
266
+ maxInputTokens: qoderMaxContextWindow(record),
267
+ isFree: record["is_free"] === true,
268
+ // Absent means "not stated", which is not the same as disabled.
269
+ enable: record["enable"] !== false,
270
+ });
271
+ }
272
+ return entries;
273
+ }
274
+
275
+ /** Projects one live catalog row onto a Cartethyia catalog row. */
276
+ export function qoderCatalogModel(
277
+ entry: QoderCatalogEntry,
278
+ providerId: string,
279
+ ): ModelDefinition {
280
+ return defineModel({
281
+ id: entry.key,
282
+ providerId,
283
+ wireFamily: "chat",
284
+ endpoint: QODER_CHAT_PATH,
285
+ // `null`, not omitted: an upstream row that stated no window is unknown,
286
+ // not unlimited, and `defineModel` substitutes its own "unfiled" estimate
287
+ // for null — the same marker a hand-added row gets.
288
+ ctx: entry.maxInputTokens ?? null,
289
+ vision: entry.isVl,
290
+ reasoning: entry.isReasoning,
291
+ // Free tier straight from upstream's own flag, so the pill on a model card
292
+ // tracks the plan rather than a hardcoded id list. Both statements hold:
293
+ // a free row bills zero per token and belongs to the provider's free tier.
294
+ free: entry.isFree,
295
+ freeTier: entry.isFree,
296
+ });
297
+ }
298
+
299
+ /**
300
+ * Header factory for one signed request.
301
+ *
302
+ * Injected rather than imported: the COSY signer lives in `qoder.ts`, which
303
+ * imports this module's rosters — importing it back would be a cycle. The
304
+ * callback receives the URL and the exact body that will be sent, because the
305
+ * signature covers both.
306
+ */
307
+ export type QoderSigner = (url: string, body: string) => Promise<Record<string, string>>;
308
+
309
+ /**
310
+ * The account's own live model list.
311
+ *
312
+ * Two details are load-bearing and non-obvious:
313
+ *
314
+ * - the request is a **GET that carries the signed body**. A bare GET answers
315
+ * 403, because the server validates signature against body; the donor
316
+ * (`read_dynamic_models`) signs `{}` and sends it as the GET's data.
317
+ * - the signature covers only the path, so switching gateway hosts between
318
+ * attempts does not invalidate it.
319
+ *
320
+ * Hosts are tried primary → fallbacks (intl has three, CN one) and the first
321
+ * host that answers with a usable list wins.
322
+ *
323
+ * Signing is injected, never reimplemented: `headers(url, body)` is the COSY
324
+ * signer from `qoder.ts`, passed in because that module imports this one and
325
+ * importing it back would be a cycle. `encode` is the same arrangement for
326
+ * Qoder's scrambled body alphabet — omitted, the literal `{}` is both signed
327
+ * and sent, which is what a caller whose signer already binds the credential
328
+ * wants.
329
+ *
330
+ * `fetcher` is optional in the type but required at runtime, and there is
331
+ * deliberately no global-`fetch` default: a test cannot reach the network by
332
+ * accident, and production is forced through the SSRF-validated egress
333
+ * (`network/outbound-fetch.ts`). That egress is a `node:http` path, which is
334
+ * also the only way a body reaches the wire on a GET — `fetch` itself rejects
335
+ * `GET` + `body` with "request with GET/HEAD method cannot have body".
336
+ */
337
+ /**
338
+ * How long one realm's live list is reused, in milliseconds.
339
+ *
340
+ * The roster is account-wide and changes rarely — upstream adds or retires a
341
+ * key, it does not reshuffle per request — so every catalog refresh inside the
342
+ * window would re-sign and re-fetch an identical list. The donor caches the
343
+ * same way (`fetch_models`, 300 s per realm).
344
+ */
345
+ const DISCOVERY_CACHE_TTL_MS = 300_000;
346
+
347
+ /** One realm's cached roster, with the instant it was read. */
348
+ interface CachedRoster {
349
+ readonly at: number;
350
+ readonly models: readonly ModelDefinition[];
351
+ }
352
+
353
+ /** Per-realm live-list cache; keyed by realm, not by account. */
354
+ const discoveryCache = new Map<QoderRealm, CachedRoster>();
355
+
356
+ /**
357
+ * Drops one realm's cached list.
358
+ *
359
+ * Exported for tests: the cache is module state, so a test that builds a
360
+ * roster and then asserts a fresh fetch would otherwise read the entry the
361
+ * previous assertion left behind.
362
+ */
363
+ export function resetQoderDiscoveryCache(realm?: QoderRealm): void {
364
+ if (realm === undefined) discoveryCache.clear();
365
+ else discoveryCache.delete(realm);
366
+ }
367
+
368
+ export async function discoverQoderModels(input: {
369
+ readonly realm: QoderRealm;
370
+ /** Account secret the injected signer signs with; blank fails fast. */
371
+ readonly credential: string;
372
+ readonly headers: QoderSigner;
373
+ readonly fetcher?: typeof fetch;
374
+ readonly signal?: AbortSignal;
375
+ /** Defaults to the realm's bundled provider id (`qodercn` | `qoder`). */
376
+ readonly providerId?: string;
377
+ /** Body encoder; Qoder uses its own scrambled base64 alphabet. */
378
+ readonly encode?: (body: Uint8Array) => string;
379
+ }): Promise<readonly ModelDefinition[]> {
380
+ const providerId = input.providerId ?? (input.realm === "cn" ? "qodercn" : "qoder");
381
+ const cached = discoveryCache.get(input.realm);
382
+ if (cached !== undefined && Date.now() - cached.at < DISCOVERY_CACHE_TTL_MS) {
383
+ return cached.models;
384
+ }
385
+ // The credential belongs to the caller and is never echoed into `details`;
386
+ // this only refuses the case where discovery was asked for without one,
387
+ // which would otherwise surface as an opaque 403 from upstream.
388
+ if (typeof input.credential !== "string" || input.credential.trim().length === 0) {
389
+ throw new GatewayError(
390
+ "authentication_failed",
391
+ 401,
392
+ "Qoder model discovery requires an account credential.",
393
+ { providerId, realm: input.realm },
394
+ );
395
+ }
396
+ const fetcher = input.fetcher;
397
+ if (fetcher === undefined) {
398
+ throw new GatewayError(
399
+ "invalid_request",
400
+ 400,
401
+ "Qoder model discovery requires a validated outbound fetch",
402
+ { providerId, realm: input.realm },
403
+ );
404
+ }
405
+ const signedBody =
406
+ input.encode === undefined ? "{}" : input.encode(new TextEncoder().encode("{}"));
407
+ let lastError: unknown;
408
+ for (const host of qoderGatewayCandidates(input.realm)) {
409
+ const url = `${host}${QODER_MODELS_PATH}`;
410
+ try {
411
+ const headers = await input.headers(url, signedBody);
412
+ const response = await fetcher(url, {
413
+ method: "GET",
414
+ headers: headers as unknown as HeadersInit,
415
+ // The signed body must accompany the GET or upstream refuses it.
416
+ body: signedBody,
417
+ ...(input.signal === undefined ? {} : { signal: input.signal }),
418
+ });
419
+ if (!response.ok) {
420
+ lastError = new Error(`Qoder model list answered ${response.status}`);
421
+ continue;
422
+ }
423
+ const entries = mapQoderCatalogResponse((await response.json()) as unknown);
424
+ // Emptiness is judged after `enable` filtering, not before: a roster
425
+ // whose every row is disabled has no usable model and must fall through
426
+ // to the next host rather than return an empty catalog.
427
+ const enabled = entries.filter((entry) => entry.enable);
428
+ if (enabled.length === 0) {
429
+ lastError = new Error("Qoder model list returned no usable models");
430
+ continue;
431
+ }
432
+ const models = enabled.map((entry) => qoderCatalogModel(entry, providerId));
433
+ // Cached only after a host actually answered: a failure must stay
434
+ // retryable on the next call rather than being frozen for five minutes.
435
+ discoveryCache.set(input.realm, { at: Date.now(), models });
436
+ return models;
437
+ } catch (error) {
438
+ lastError = error;
439
+ }
440
+ }
441
+ throw new GatewayError(
442
+ "transport_unavailable",
443
+ 502,
444
+ `Qoder model discovery failed for ${input.realm}: ${
445
+ lastError instanceof Error ? lastError.message : "unknown error"
446
+ }`,
447
+ { providerId, realm: input.realm },
448
+ "upstream",
449
+ );
450
+ }