cartethyia 2.4.5 → 2.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (415) hide show
  1. package/.env.example +4 -1
  2. package/README.md +63 -13
  3. package/dist/dashboard/assets/AccountCooldown-ChpdzxRH.js.gz +0 -0
  4. package/dist/dashboard/assets/BannedPage-9igfctfZ.js +1 -0
  5. package/dist/dashboard/assets/BannedPage-9igfctfZ.js.gz +0 -0
  6. package/dist/dashboard/assets/{CliToolDetailPage-BzGevuql.js → CliToolDetailPage-ZRr2K2sF.js} +1 -1
  7. package/dist/dashboard/assets/CliToolDetailPage-ZRr2K2sF.js.gz +0 -0
  8. package/dist/dashboard/assets/{CliToolsPage-BaRM_7We.js → CliToolsPage-D3xqZbN-.js} +1 -1
  9. package/dist/dashboard/assets/CliToolsPage-D3xqZbN-.js.gz +0 -0
  10. package/dist/dashboard/assets/CombosPage-CVnQPBBz.js +1 -0
  11. package/dist/dashboard/assets/CombosPage-CVnQPBBz.js.gz +0 -0
  12. package/dist/dashboard/assets/ConsoleLogPage-u6W1UXSc.js +1 -0
  13. package/dist/dashboard/assets/ConsoleLogPage-u6W1UXSc.js.gz +0 -0
  14. package/dist/dashboard/assets/{CustomizationPage-DqUGcoH0.js → CustomizationPage-CTTpCb25.js} +1 -1
  15. package/dist/dashboard/assets/CustomizationPage-CTTpCb25.js.gz +0 -0
  16. package/dist/dashboard/assets/HealthEventsModal-C5XpWlvy.js +1 -0
  17. package/dist/dashboard/assets/HealthEventsModal-C5XpWlvy.js.gz +0 -0
  18. package/dist/dashboard/assets/{LoginPage-DbhTGfhb.js → LoginPage-DHoB3Agn.js} +1 -1
  19. package/dist/dashboard/assets/LoginPage-DHoB3Agn.js.gz +0 -0
  20. package/dist/dashboard/assets/ModelPicker-oQ-SDXp8.js +1 -0
  21. package/dist/dashboard/assets/ModelPicker-oQ-SDXp8.js.gz +0 -0
  22. package/dist/dashboard/assets/OverviewPage-BrY4SgIt.js +1 -0
  23. package/dist/dashboard/assets/OverviewPage-BrY4SgIt.js.gz +0 -0
  24. package/dist/dashboard/assets/ProviderDetailPage-D9VSt7T_.js +3 -0
  25. package/dist/dashboard/assets/ProviderDetailPage-D9VSt7T_.js.gz +0 -0
  26. package/dist/dashboard/assets/ProviderIcon-CmfzxpVM.js +1 -0
  27. package/dist/dashboard/assets/ProviderIcon-CmfzxpVM.js.gz +0 -0
  28. package/dist/dashboard/assets/ProvidersPage-Df77V5I5.js +1 -0
  29. package/dist/dashboard/assets/ProvidersPage-Df77V5I5.js.gz +0 -0
  30. package/dist/dashboard/assets/ProxyPage-CDfxP7AC.js +7 -0
  31. package/dist/dashboard/assets/ProxyPage-CDfxP7AC.js.gz +0 -0
  32. package/dist/dashboard/assets/QuotaPage-CY4o2t4p.js +1 -0
  33. package/dist/dashboard/assets/QuotaPage-CY4o2t4p.js.gz +0 -0
  34. package/dist/dashboard/assets/{SettingsPage-YxRml8AT.js → SettingsPage-CvjZxHE4.js} +1 -1
  35. package/dist/dashboard/assets/SettingsPage-CvjZxHE4.js.gz +0 -0
  36. package/dist/dashboard/assets/SetupPage-D3nza7LH.js +1 -0
  37. package/dist/dashboard/assets/SetupPage-D3nza7LH.js.gz +0 -0
  38. package/dist/dashboard/assets/{SortableList-Bol1ASmI.js → SortableList-Dtt12WT1.js} +1 -1
  39. package/dist/dashboard/assets/SortableList-Dtt12WT1.js.gz +0 -0
  40. package/dist/dashboard/assets/StudioPage-CbK3hXvU.js +31 -0
  41. package/dist/dashboard/assets/StudioPage-CbK3hXvU.js.gz +0 -0
  42. package/dist/dashboard/assets/ToolIcon-K1ckcDKg.js.gz +0 -0
  43. package/dist/dashboard/assets/UsagePage-BQlWbWg2.js +56 -0
  44. package/dist/dashboard/assets/UsagePage-BQlWbWg2.js.gz +0 -0
  45. package/dist/dashboard/assets/api-keys-BmDbsior.js +1 -0
  46. package/dist/dashboard/assets/api-keys-BmDbsior.js.gz +0 -0
  47. package/dist/dashboard/assets/app-BbpSarVV.css +2 -0
  48. package/dist/dashboard/assets/app-BbpSarVV.css.gz +0 -0
  49. package/dist/dashboard/assets/{app-BDJWMThu.js → app-C-Y4oZT0.js} +3 -3
  50. package/dist/dashboard/assets/app-C-Y4oZT0.js.gz +0 -0
  51. package/dist/dashboard/assets/arrow-left-CpBxkoxI.js.gz +0 -0
  52. package/dist/dashboard/assets/arrow-right-Dl5yKnp8.js.gz +0 -0
  53. package/dist/dashboard/assets/assert-BHfaei-X.js +1 -0
  54. package/dist/dashboard/assets/assert-BHfaei-X.js.gz +0 -0
  55. package/dist/dashboard/assets/backup-file-gdKIlg8U.js.gz +0 -0
  56. package/dist/dashboard/assets/{badge-BwmwEOgA.js → badge-DpXT4Ixt.js} +1 -1
  57. package/dist/dashboard/assets/badge-DpXT4Ixt.js.gz +0 -0
  58. package/dist/dashboard/assets/{banner-BKBfKTqn.js → banner-CSAAWLJv.js} +1 -1
  59. package/dist/dashboard/assets/banner-CSAAWLJv.js.gz +0 -0
  60. package/dist/dashboard/assets/brain-Cwed_72z.js.gz +0 -0
  61. package/dist/dashboard/assets/{card-BTdbRFFg.js → card-DEhRXXw4.js} +1 -1
  62. package/dist/dashboard/assets/card-DEhRXXw4.js.gz +0 -0
  63. package/dist/dashboard/assets/check-D69FzdvC.js.gz +0 -0
  64. package/dist/dashboard/assets/chevron-right-BXIy5l3h.js.gz +0 -0
  65. package/dist/dashboard/assets/circle-alert-CntYHV5v.js.gz +0 -0
  66. package/dist/dashboard/assets/circle-check-BU3v_mFW.js.gz +0 -0
  67. package/dist/dashboard/assets/cli-tools-B0wBq-RL.js +1 -0
  68. package/dist/dashboard/assets/cli-tools-B0wBq-RL.js.gz +0 -0
  69. package/dist/dashboard/assets/clipboard-button-DOo6EU8O.js +1 -0
  70. package/dist/dashboard/assets/clipboard-button-DOo6EU8O.js.gz +0 -0
  71. package/dist/dashboard/assets/cog-CdWhsajs.js.gz +0 -0
  72. package/dist/dashboard/assets/copy-ngmQ9zcZ.js.gz +0 -0
  73. package/dist/dashboard/assets/{dialog-iocKA3jr.js → dialog-BvQeBMfP.js} +1 -1
  74. package/dist/dashboard/assets/dialog-BvQeBMfP.js.gz +0 -0
  75. package/dist/dashboard/assets/dialog-actions-B1axpEg7.js.gz +0 -0
  76. package/dist/dashboard/assets/download-A0SW9OCW.js.gz +0 -0
  77. package/dist/dashboard/assets/download-dRFo5_Wu.js.gz +0 -0
  78. package/dist/dashboard/assets/{drawer-CDKOTZdY.js → drawer-CMwPXwQB.js} +1 -1
  79. package/dist/dashboard/assets/drawer-CMwPXwQB.js.gz +0 -0
  80. package/dist/dashboard/assets/entry-D4O2229X.js +1 -0
  81. package/dist/dashboard/assets/entry-D4O2229X.js.gz +0 -0
  82. package/dist/dashboard/assets/entry-DKvr5Q9K.js +1 -0
  83. package/dist/dashboard/assets/entry-DKvr5Q9K.js.gz +0 -0
  84. package/dist/dashboard/assets/entry-FQCejoDY.js +4 -0
  85. package/dist/dashboard/assets/entry-FQCejoDY.js.gz +0 -0
  86. package/dist/dashboard/assets/eye-C_hawfH4.js.gz +0 -0
  87. package/dist/dashboard/assets/eye-off-Bk5g2luF.js.gz +0 -0
  88. package/dist/dashboard/assets/fingerprint-pattern-BWuHtbW4.js.gz +0 -0
  89. package/dist/dashboard/assets/gauge-BbIMy7MB.js.gz +0 -0
  90. package/dist/dashboard/assets/github-badge-zliLvBwr.js +1 -0
  91. package/dist/dashboard/assets/github-badge-zliLvBwr.js.gz +0 -0
  92. package/dist/dashboard/assets/image-BPaouViG.js.gz +0 -0
  93. package/dist/dashboard/assets/{index-C4hXfAMs.js → index-CXm3FIyt.js} +2 -2
  94. package/dist/dashboard/assets/index-CXm3FIyt.js.gz +0 -0
  95. package/dist/dashboard/assets/{inline-DCejSLlA.js → inline-D1IlZZ8H.js} +1 -1
  96. package/dist/dashboard/assets/inline-D1IlZZ8H.js.gz +0 -0
  97. package/dist/dashboard/assets/{input-Bn4_kNav.js → input-BpVZdrbc.js} +1 -1
  98. package/dist/dashboard/assets/input-BpVZdrbc.js.gz +0 -0
  99. package/dist/dashboard/assets/jsx-runtime-CXUMngI_.js.gz +0 -0
  100. package/dist/dashboard/assets/key-round-UkavsGxp.js.gz +0 -0
  101. package/dist/dashboard/assets/layers-2-glBbjl.js.gz +0 -0
  102. package/dist/dashboard/assets/{layout-Bv-NxqZ5.js → layout-BioLW7gN.js} +1 -1
  103. package/dist/dashboard/assets/layout-BioLW7gN.js.gz +0 -0
  104. package/dist/dashboard/assets/{list-row-BLA0sldD.js → list-row-CwVcGAIc.js} +1 -1
  105. package/dist/dashboard/assets/list-row-CwVcGAIc.js.gz +0 -0
  106. package/dist/dashboard/assets/{live-YlifPYox.js → live-yfEJFF9T.js} +1 -1
  107. package/dist/dashboard/assets/live-yfEJFF9T.js.gz +0 -0
  108. package/dist/dashboard/assets/log-in-BmRjy60c.js.gz +0 -0
  109. package/dist/dashboard/assets/message-circle-CCxGwRvv.js.gz +0 -0
  110. package/dist/dashboard/assets/model-bans-CT-TxSNX.js +1 -0
  111. package/dist/dashboard/assets/model-bans-CT-TxSNX.js.gz +0 -0
  112. package/dist/dashboard/assets/mutation-BkoLM5Ba.js +1 -0
  113. package/dist/dashboard/assets/mutation-BkoLM5Ba.js.gz +0 -0
  114. package/dist/dashboard/assets/numbers-CFL4_Dqc.js.gz +0 -0
  115. package/dist/dashboard/assets/pencil-768ysRAr.js.gz +0 -0
  116. package/dist/dashboard/assets/plus-DIdX_g-x.js.gz +0 -0
  117. package/dist/dashboard/assets/power-off-Yjw9GMJh.js +1 -0
  118. package/dist/dashboard/assets/power-off-Yjw9GMJh.js.gz +0 -0
  119. package/dist/dashboard/assets/privacy-mode-GM8bej85.js +1 -0
  120. package/dist/dashboard/assets/privacy-mode-GM8bej85.js.gz +0 -0
  121. package/dist/dashboard/assets/providers/agentrouter.svg.gz +0 -0
  122. package/dist/dashboard/assets/providers/aihubmix.svg.gz +0 -0
  123. package/dist/dashboard/assets/providers/alibaba-coding-plan.svg.gz +0 -0
  124. package/dist/dashboard/assets/providers/alibaba.svg.gz +0 -0
  125. package/dist/dashboard/assets/providers/antigravity.svg.gz +0 -0
  126. package/dist/dashboard/assets/providers/bai.svg.gz +0 -0
  127. package/dist/dashboard/assets/providers/blackbox.svg.gz +0 -0
  128. package/dist/dashboard/assets/providers/claude-code.svg.gz +0 -0
  129. package/dist/dashboard/assets/providers/cursor.webp +0 -0
  130. package/dist/dashboard/assets/providers/exa.svg.gz +0 -0
  131. package/dist/dashboard/assets/providers/gmi-cloud.svg.gz +0 -0
  132. package/dist/dashboard/assets/providers/inferhub.svg.gz +0 -0
  133. package/dist/dashboard/assets/providers/muse.svg.gz +0 -0
  134. package/dist/dashboard/assets/providers/nous-research.svg.gz +0 -0
  135. package/dist/dashboard/assets/providers/openai-light.svg.gz +0 -0
  136. package/dist/dashboard/assets/providers/parallel.svg.gz +0 -0
  137. package/dist/dashboard/assets/providers/tokenharbor.svg.gz +0 -0
  138. package/dist/dashboard/assets/providers/trae.webp +0 -0
  139. package/dist/dashboard/assets/providers/zai-coding-plan.svg.gz +0 -0
  140. package/dist/dashboard/assets/{query-keys-ROf-g1Wi.js → query-keys-D7wX2vM5.js} +1 -1
  141. package/dist/dashboard/assets/query-keys-D7wX2vM5.js.gz +0 -0
  142. package/dist/dashboard/assets/quota-DZGyp0N5.js.gz +0 -0
  143. package/dist/dashboard/assets/refresh-cw-BqJFk4sU.js.gz +0 -0
  144. package/dist/dashboard/assets/rotate-ccw-CufcqITi.js.gz +0 -0
  145. package/dist/dashboard/assets/route-CNSNj9Zm.js.gz +0 -0
  146. package/dist/dashboard/assets/routing-Cs6jl49L.js +1 -0
  147. package/dist/dashboard/assets/routing-Cs6jl49L.js.gz +0 -0
  148. package/dist/dashboard/assets/scaling-nWE4Cx8f.js.gz +0 -0
  149. package/dist/dashboard/assets/search-JOKus9rb.js.gz +0 -0
  150. package/dist/dashboard/assets/select-C0Rer3gF.js +41 -0
  151. package/dist/dashboard/assets/select-C0Rer3gF.js.gz +0 -0
  152. package/dist/dashboard/assets/settings-CfcTcOyU.js +1 -0
  153. package/dist/dashboard/assets/settings-CfcTcOyU.js.gz +0 -0
  154. package/dist/dashboard/assets/shield-check-DVS0Foqh.js.gz +0 -0
  155. package/dist/dashboard/assets/sparkles-BRaRUxAU.js.gz +0 -0
  156. package/dist/dashboard/assets/{stack-C1kTcOmP.js → stack-C9_uUjaA.js} +1 -1
  157. package/dist/dashboard/assets/stack-C9_uUjaA.js.gz +0 -0
  158. package/dist/dashboard/assets/{state-DHKdg-pm.js → state-DA0B95UQ.js} +1 -1
  159. package/dist/dashboard/assets/state-DA0B95UQ.js.gz +0 -0
  160. package/dist/dashboard/assets/stream-retry-5QnVuSSo.js.gz +0 -0
  161. package/dist/dashboard/assets/{switch-DIDeAiz9.js → switch-Cittxy-x.js} +1 -1
  162. package/dist/dashboard/assets/switch-Cittxy-x.js.gz +0 -0
  163. package/dist/dashboard/assets/terminal-Bd4ee8TA.js +1 -0
  164. package/dist/dashboard/assets/terminal-Bd4ee8TA.js.gz +0 -0
  165. package/dist/dashboard/assets/toast-CNt_jzO4.js +2 -0
  166. package/dist/dashboard/assets/toast-CNt_jzO4.js.gz +0 -0
  167. package/dist/dashboard/assets/tokens-DDxMbSRL.js.gz +0 -0
  168. package/dist/dashboard/assets/trash-Cppn5c3R.js.gz +0 -0
  169. package/dist/dashboard/assets/use-clipboard-B0Sy03fE.js.gz +0 -0
  170. package/dist/dashboard/assets/use-presence-BSZEBx7U.js.gz +0 -0
  171. package/dist/dashboard/assets/use-reduced-motion-C4B0R6Sz.js.gz +0 -0
  172. package/dist/dashboard/assets/use-timeout-BwcBBRrT.js.gz +0 -0
  173. package/dist/dashboard/assets/{useMutation-DVmwOKU-.js → useMutation-BQKMcJ1H.js} +1 -1
  174. package/dist/dashboard/assets/useMutation-BQKMcJ1H.js.gz +0 -0
  175. package/dist/dashboard/assets/wrench-B6E1oEuY.js.gz +0 -0
  176. package/dist/dashboard/index.html +1 -1
  177. package/dist/dashboard/index.html.gz +0 -0
  178. package/dist/dashboard/precompressed-assets.json +122 -0
  179. package/migrations/full-postgres/0013_add_providers_tenant_id_index.sql +22 -0
  180. package/migrations/full-postgres/0014_add_provider_accounts_tenant_id_index.sql +21 -0
  181. package/migrations/full-postgres/0015_add_payload_stages.sql +18 -0
  182. package/migrations/full-postgres/0016_add_share_link_slug.sql +20 -0
  183. package/migrations/full-postgres/0017_add_models_pro_only.sql +18 -0
  184. package/migrations/full-postgres/0018_add_accounts_quota_plan.sql +18 -0
  185. package/migrations/full-postgres/0019_add_models_free_tier.sql +15 -0
  186. package/migrations/lite-sqlite3/0000_baseline.sql +4 -2
  187. package/migrations/lite-sqlite3/0013_add_providers_tenant_id_index.sql +4 -0
  188. package/migrations/lite-sqlite3/0014_add_provider_accounts_tenant_id_index.sql +4 -0
  189. package/migrations/lite-sqlite3/0015_add_payload_stages.sql +18 -0
  190. package/migrations/lite-sqlite3/0016_add_share_link_slug.sql +20 -0
  191. package/migrations/lite-sqlite3/0017_add_models_pro_only.sql +18 -0
  192. package/migrations/lite-sqlite3/0018_add_accounts_quota_plan.sql +18 -0
  193. package/migrations/lite-sqlite3/0019_add_models_free_tier.sql +15 -0
  194. package/package.json +26 -30
  195. package/scripts/cli/{carte-launcher.cjs → bin/carte.cjs} +8 -8
  196. package/scripts/cli/{cli-launcher.cjs → bin/cartethyia.cjs} +167 -134
  197. package/scripts/cli/command/admin.ts +65 -0
  198. package/scripts/cli/command/console-menu.ts +246 -0
  199. package/scripts/cli/{doctor.ts → command/doctor.ts} +257 -258
  200. package/scripts/cli/{setup.ts → command/setup.ts} +8 -12
  201. package/scripts/cli/command/start.ts +77 -0
  202. package/scripts/cli/command/tray.ts +165 -0
  203. package/scripts/cli/command/update.ts +23 -0
  204. package/scripts/cli/{runtime-state.ts → gateway/drain-token.ts} +78 -78
  205. package/scripts/cli/gateway/instance-lock.ts +105 -0
  206. package/scripts/cli/gateway/spawn.ts +173 -0
  207. package/scripts/cli/gateway/stop.ts +51 -0
  208. package/scripts/cli/main.ts +94 -0
  209. package/scripts/cli/support/bun.ts +27 -0
  210. package/scripts/cli/support/env.ts +99 -0
  211. package/scripts/cli/support/platform.ts +64 -0
  212. package/scripts/cli/support/services.ts +88 -0
  213. package/scripts/cli/support/startup-signal.ts +23 -0
  214. package/scripts/cli/{version.ts → support/version.ts} +1 -1
  215. package/scripts/cli/tray/hidden-window.vbs +28 -0
  216. package/scripts/cli/{tray.ts → tray/index.ts} +3 -3
  217. package/scripts/cli/{tray.ps1 → tray/windows.ps1} +107 -98
  218. package/scripts/cli/ui/background.ts +110 -0
  219. package/src/boot/app.ts +23 -7
  220. package/src/boot/dependencies.ts +55 -13
  221. package/src/boot/elysia-typebox-bridge.ts +37 -0
  222. package/src/boot/lifecycle.ts +2 -0
  223. package/src/config/cooldowns.ts +138 -138
  224. package/src/config/providers.ts +31 -5
  225. package/src/config/runtime.ts +19 -3
  226. package/src/config/spec.ts +20 -0
  227. package/src/console/api-keys/contracts.ts +414 -391
  228. package/src/console/api-keys/routes.ts +880 -870
  229. package/src/console/auth/account-reset-store.ts +14 -0
  230. package/src/console/auth/account-reset.ts +3 -2
  231. package/src/console/auth/setup-service.ts +6 -3
  232. package/src/console/backup/routes.ts +8 -0
  233. package/src/console/cli-tools/contracts.ts +6 -6
  234. package/src/console/console-router.ts +5 -3
  235. package/src/console/dashboard-assets.ts +81 -11
  236. package/src/console/domain-registration.ts +9 -2
  237. package/src/console/observability/contracts.ts +475 -423
  238. package/src/console/observability/routes.ts +31 -6
  239. package/src/console/observability/store.ts +1015 -912
  240. package/src/console/providers/catalog/contracts.ts +31 -0
  241. package/src/console/providers/catalog/provider-operations.ts +8 -0
  242. package/src/console/providers/catalog/public-model-store.ts +91 -60
  243. package/src/console/providers/catalog/store.ts +1532 -1521
  244. package/src/console/providers/oauth/callback-listener.ts +18 -3
  245. package/src/console/providers/oauth/routes.ts +38 -15
  246. package/src/console/quota/account-quota-tenant.ts +9 -6
  247. package/src/console/quota/refresh.ts +19 -0
  248. package/src/console/response-compression.ts +69 -2
  249. package/src/console/routing/pools/probe-verdict.ts +72 -0
  250. package/src/console/share/share-router.ts +7 -4
  251. package/src/console/share/share-stats.ts +12 -5
  252. package/src/console/shared/read-through-cache.ts +133 -7
  253. package/src/console/updates/repository-updates.ts +39 -34
  254. package/src/console/updates/routes.ts +162 -4
  255. package/src/main.ts +3 -0
  256. package/src/network/http2-bidi.ts +85 -0
  257. package/src/network/outbound-fetch.ts +87 -24
  258. package/src/network/pool/agent.ts +478 -460
  259. package/src/network/pool/resolver.ts +1104 -544
  260. package/src/network/pool-health-machine.ts +12 -5
  261. package/src/observability/event-log.ts +29 -8
  262. package/src/observability/metrics.ts +40 -13
  263. package/src/observability/payload-capture.ts +344 -324
  264. package/src/observability/runtime-metrics.ts +7 -0
  265. package/src/persistence/db-pg.ts +9 -3
  266. package/src/persistence/db-sqlite.ts +497 -495
  267. package/src/persistence/health-event-flush.ts +137 -0
  268. package/src/persistence/migrate.ts +181 -129
  269. package/src/persistence/schema.ts +1360 -1257
  270. package/src/persistence/settings-revision.ts +25 -0
  271. package/src/persistence/shared-cache.ts +217 -0
  272. package/src/persistence/stores/share-store.ts +556 -528
  273. package/src/persistence/usage-flush.ts +61 -1
  274. package/src/protocol/canonical-model.ts +23 -1
  275. package/src/protocol/response/chat.ts +143 -10
  276. package/src/protocol/response/codex.ts +9 -75
  277. package/src/protocol/response/omp-tool-call-markup.ts +221 -0
  278. package/src/protocol/tool-identity.ts +43 -9
  279. package/src/providers/authentication/jwt-validator.ts +130 -187
  280. package/src/providers/authentication/oauth-flow-store.ts +16 -9
  281. package/src/providers/default-registry.ts +177 -1
  282. package/src/providers/discovery/catalog/catalog-format.ts +20 -0
  283. package/src/providers/discovery/models-dev-catalog.ts +39 -22
  284. package/src/providers/discovery/openai-model-discovery.ts +39 -3
  285. package/src/providers/discovery/openrouter-free-roster.ts +98 -0
  286. package/src/providers/discovery/probe-wire.ts +29 -5
  287. package/src/providers/discovery/probing-service.ts +1022 -1001
  288. package/src/providers/integrations/buddy/buddy-catalog-shared.ts +42 -2
  289. package/src/providers/integrations/buddy/buddy-chat-shared.ts +913 -914
  290. package/src/providers/integrations/buddy/buddy-cli-headers.ts +88 -0
  291. package/src/providers/integrations/buddy/buddy-conversation-headers.ts +22 -5
  292. package/src/providers/integrations/buddy/buddy-discovery-shared.ts +33 -1
  293. package/src/providers/integrations/buddy/buddy-identity.ts +46 -0
  294. package/src/providers/integrations/buddy/buddy-oauth-shared.ts +37 -1
  295. package/src/providers/integrations/buddy/buddy-usage-store.ts +40 -5
  296. package/src/providers/integrations/buddy/codebuddy/cn-provider.ts +4 -0
  297. package/src/providers/integrations/buddy/codebuddy/provider.ts +180 -175
  298. package/src/providers/integrations/buddy/codebuddy/shared.ts +115 -73
  299. package/src/providers/integrations/buddy/workbuddy/cn-provider.ts +5 -2
  300. package/src/providers/integrations/buddy/workbuddy/provider.ts +206 -204
  301. package/src/providers/integrations/buddy/workbuddy/quota.ts +172 -159
  302. package/src/providers/integrations/buddy/workbuddy/shared.ts +200 -171
  303. package/src/providers/integrations/claude/claude.ts +606 -582
  304. package/src/providers/integrations/cline/cline-quota.ts +18 -16
  305. package/src/providers/integrations/cline/cline.ts +539 -468
  306. package/src/providers/integrations/codex/codex.ts +5 -8
  307. package/src/providers/integrations/commandcode/commandcode.ts +675 -541
  308. package/src/providers/integrations/commandcode/discovery.ts +116 -0
  309. package/src/providers/integrations/commandcode/quota.ts +160 -0
  310. package/src/providers/integrations/cursor/cursor-auth.ts +418 -0
  311. package/src/providers/integrations/cursor/cursor-catalog.ts +368 -0
  312. package/src/providers/integrations/cursor/cursor-http2.ts +300 -0
  313. package/src/providers/integrations/cursor/cursor-identity.ts +92 -0
  314. package/src/providers/integrations/cursor/cursor-request.ts +471 -0
  315. package/src/providers/integrations/cursor/cursor-session-manager.ts +281 -0
  316. package/src/providers/integrations/cursor/cursor-shared.ts +108 -0
  317. package/src/providers/integrations/cursor/cursor.ts +960 -0
  318. package/src/providers/integrations/cursor/protocol/framing.ts +129 -0
  319. package/src/providers/integrations/cursor/protocol/messages.ts +2355 -0
  320. package/src/providers/integrations/cursor/protocol/struct.ts +305 -0
  321. package/src/providers/integrations/factory/factory-oauth.ts +7 -11
  322. package/src/providers/integrations/github/github.ts +4 -4
  323. package/src/providers/integrations/hermes.ts +79 -8
  324. package/src/providers/integrations/kilo/kilo-discovery.ts +10 -1
  325. package/src/providers/integrations/kilo/kilo.ts +153 -146
  326. package/src/providers/integrations/opencode/opencode-go-quota.ts +130 -0
  327. package/src/providers/integrations/opencode/provider.ts +532 -499
  328. package/src/providers/integrations/openrouter/provider.ts +139 -281
  329. package/src/providers/integrations/qoder/qoder-catalog.ts +450 -0
  330. package/src/providers/integrations/qoder/qoder-oauth.ts +310 -263
  331. package/src/providers/integrations/qoder/qoder-realm.ts +106 -0
  332. package/src/providers/integrations/qoder/qoder-virtual.ts +77 -0
  333. package/src/providers/integrations/qoder/qoder.ts +865 -730
  334. package/src/providers/integrations/shared/install-id.ts +29 -2
  335. package/src/providers/integrations/trae/trae-catalog.ts +190 -0
  336. package/src/providers/integrations/trae/trae-headers.ts +149 -0
  337. package/src/providers/integrations/trae/trae-oauth.ts +773 -0
  338. package/src/providers/integrations/trae/trae-protocol.ts +400 -0
  339. package/src/providers/integrations/trae/trae-realm.ts +220 -0
  340. package/src/providers/integrations/trae/trae.ts +458 -0
  341. package/src/providers/operations/account-health-service.ts +107 -10
  342. package/src/providers/operations/client-versions.ts +143 -6
  343. package/src/providers/operations/model-catalog-cache.ts +18 -3
  344. package/src/providers/operations/provider-catalog-seeder.ts +95 -86
  345. package/src/providers/operations/provider-catalog-service.ts +76 -0
  346. package/src/providers/operations/provider-credential-service.ts +46 -7
  347. package/src/providers/provider-contracts.ts +76 -0
  348. package/src/providers/provider-metadata.ts +78 -6
  349. package/src/providers/provider-model-definition.ts +373 -349
  350. package/src/security/api-key-auth.ts +126 -8
  351. package/src/transport/contracts.ts +3 -0
  352. package/src/transport/dispatch/attempt-finalize.ts +39 -2
  353. package/src/transport/dispatch/attempt-loop.ts +530 -513
  354. package/src/transport/dispatch/empty-stream.ts +17 -26
  355. package/src/transport/dispatch/generation.ts +88 -21
  356. package/src/transport/dispatch/leases.ts +99 -2
  357. package/src/transport/dispatch/proxy-request.ts +60 -3
  358. package/src/transport/dispatch/streaming/finalize.ts +16 -1
  359. package/src/transport/dispatch/streaming/stream.ts +22 -2
  360. package/src/transport/dispatch/upstream.ts +19 -1
  361. package/src/transport/failure-policy.ts +20 -0
  362. package/src/transport/guards/auth-guard.ts +26 -1
  363. package/src/transport/guards/body-policy.ts +5 -4
  364. package/src/transport/request/preparer.ts +103 -0
  365. package/src/transport/request/state.ts +14 -0
  366. package/src/transport/routing/plan-gate.ts +45 -0
  367. package/src/transport/routing/route-catalog.ts +799 -738
  368. package/src/transport/routing/route-model.ts +52 -1
  369. package/src/transport/routing/router.ts +51 -0
  370. package/src/transport/surface/chat/encode.ts +18 -31
  371. package/src/transport/surface/messages/stream.ts +3 -2
  372. package/src/transport/translation/tool-repair.ts +37 -16
  373. package/src/workers/account-identity-sweep.ts +14 -12
  374. package/src/workers/daily-checkin.ts +62 -0
  375. package/src/workers/quota-refresh-sweep.ts +27 -2
  376. package/dist/dashboard/assets/BannedPage-DUrXpp-X.js +0 -1
  377. package/dist/dashboard/assets/CombosPage-B7ETkfVH.js +0 -1
  378. package/dist/dashboard/assets/ConsoleLogPage-AisQzCI1.js +0 -1
  379. package/dist/dashboard/assets/HealthEventsModal-BNflDm_f.js +0 -1
  380. package/dist/dashboard/assets/ModelPicker-CAiPR5Vd.js +0 -1
  381. package/dist/dashboard/assets/OverviewPage-C3NIzsDx.js +0 -1
  382. package/dist/dashboard/assets/ProviderDetailPage-BK8i5B5X.js +0 -3
  383. package/dist/dashboard/assets/ProviderIcon-paOCq8I0.js +0 -1
  384. package/dist/dashboard/assets/ProvidersPage-CvCVgm1e.js +0 -1
  385. package/dist/dashboard/assets/ProxyPage-DjabkuzP.js +0 -7
  386. package/dist/dashboard/assets/QuotaPage-Dxe0B0iu.js +0 -1
  387. package/dist/dashboard/assets/SetupPage-BaD4P_jW.js +0 -1
  388. package/dist/dashboard/assets/StudioPage-CLP4le3U.js +0 -31
  389. package/dist/dashboard/assets/UsagePage-Djes-Epy.js +0 -56
  390. package/dist/dashboard/assets/api-keys-CjIGQhe7.js +0 -1
  391. package/dist/dashboard/assets/app-Cjgo4-Lf.css +0 -2
  392. package/dist/dashboard/assets/assert-Bwh6DJTK.js +0 -1
  393. package/dist/dashboard/assets/cli-tools-DccaOfhX.js +0 -1
  394. package/dist/dashboard/assets/clipboard-button-DQmMikNQ.js +0 -1
  395. package/dist/dashboard/assets/entry-Cpi0rph1.js +0 -4
  396. package/dist/dashboard/assets/entry-Dm3PpDCO.js +0 -1
  397. package/dist/dashboard/assets/entry-keNDZ_Wz.js +0 -1
  398. package/dist/dashboard/assets/github-badge-BuAJr8oU.js +0 -1
  399. package/dist/dashboard/assets/model-bans-CVvHZ-qz.js +0 -1
  400. package/dist/dashboard/assets/mutation-BIizFZav.js +0 -1
  401. package/dist/dashboard/assets/privacy-mode-CQjbZ3xk.js +0 -1
  402. package/dist/dashboard/assets/routing-jntw9-Ui.js +0 -1
  403. package/dist/dashboard/assets/select-CGUuTQmj.js +0 -41
  404. package/dist/dashboard/assets/settings-BAS6QbmJ.js +0 -1
  405. package/dist/dashboard/assets/sliders-horizontal-ClG7dBeJ.js +0 -1
  406. package/dist/dashboard/assets/terminal-C9mZ4Ugm.js +0 -1
  407. package/dist/dashboard/assets/toast-DKAdP2mo.js +0 -2
  408. package/scripts/cli/cli.ts +0 -771
  409. package/scripts/operations/probe-egress.ts +0 -335
  410. package/scripts/operations/restart.ts +0 -120
  411. package/scripts/shared/env.ts +0 -269
  412. /package/{tray-assets → scripts/cli/tray/assets}/cartethyia.ico +0 -0
  413. /package/{tray-assets → scripts/cli/tray/assets}/cartethyia.png +0 -0
  414. /package/scripts/cli/{menu.ts → ui/prompt.ts} +0 -0
  415. /package/scripts/cli/{wordmark.ts → ui/wordmark.ts} +0 -0
@@ -1,1001 +1,1022 @@
1
- import { captureProviderExchange, type ProviderExchangeCapture } from "../../transport/dispatch/attempt-finalize";
2
- import { globalOrOwnedBy } from "../../persistence/tenant-scope";
3
- /**
4
- * Provider probing and model-discovery orchestration.
5
- *
6
- * Network probes and `/v1/models` discovery stay separate from the Drizzle
7
- * catalog repository while sharing the provider catalog contract.
8
- */
9
- import { and, eq, inArray, sql } from "drizzle-orm";
10
- import type { CartethyiaDatabase } from "../../persistence/db-handle";
11
- import { models, providerAccounts, providers } from "../../persistence/schema";
12
- import type { CanonicalEvent, CanonicalRequest, WireFamily } from "../../protocol/canonical-model";
13
- import { WIRE_FAMILIES } from "../../protocol/canonical-model";
14
- import { isRecord } from "../../protocol/codecs";
15
- import type { FetchLike } from "../quota/quota-contracts";
16
- import type { CompatibilityProfile } from "../provider-metadata";
17
- import {
18
- type ByokConnectionTestRequest,
19
- type ByokConnectionTestResult,
20
- type ProbeAllAccountsResult,
21
- type ProbeAllModelsResult,
22
- type ProbeModelRequest,
23
- type ProbeModelResult,
24
- } from "./discovery-types";
25
- import { modelsDevCatalog } from "./models-dev-catalog";
26
- import { fetchOpenAICompatibleModels } from "./openai-model-discovery";
27
- import {
28
- applyDiscoveredWire,
29
- resolveDiscoveredWire,
30
- staticEndpointForWire,
31
- type DiscoveredModelWire,
32
- } from "./probe-wire";
33
- import {
34
- buildProbeCanonicalRequest,
35
- computeProbeVerdict,
36
- extractSample,
37
- GROK_407_PROBE_PROMPT,
38
- grok407ProbeFailure,
39
- hasMeaningfulOutput,
40
- loadProbePreferences,
41
- recordProbeHealth,
42
- resolveProbeAdapter,
43
- resolveProbeTarget,
44
- selectProbeAccount,
45
- wireContextFrom,
46
- type ProbeProviderRow,
47
- } from "./probe-phases";
48
- import { createProbeFetch } from "../operations/probe-fetch";
49
- import { authHeaders } from "../adapters/compatible-adapter";
50
- import {
51
- byokAuthHeaderShape,
52
- modelDiscoveryBaseUrl,
53
- modelListUrl,
54
- resolveByokWireProfile,
55
- splitEndpointBaseUrl,
56
- } from "../operations/byok-wire-profile";
57
- import { resolveCustomCliHeaders } from "../operations/custom-cli-headers";
58
- import { resolveCredentialForAccount } from "../operations/provider-credential-service";
59
- import { decryptCredentialToString } from "../../security/crypto";
60
- import { GatewayError } from "../../protocol/gateway-error";
61
- import type { TelemetryBatchBuffer } from "../../observability/telemetry-buffer";
62
- import { computeTokensPerSec } from "../../observability/token-speed";
63
- import { TelemetryPayloadCapture } from "../../observability/payload-capture";
64
- import { providerBaseUrl } from "../provider-metadata";
65
- import { isBundledProviderId, parseProviderId, type ProviderDispatchTarget as AdapterDispatchTarget, type ValidatedOutboundFetch } from "../provider-contracts";
66
- import type { ModelDefinition } from "../provider-model-definition";
67
- import type { ProviderRegistry } from "../provider-registry";
68
-
69
- /** Max models probed concurrently per batch (outside the sequential warm-up). */
70
- const PROBE_CONCURRENCY = 5;
71
- /** Max accounts probed concurrently; batches wait before starting the next ten. */
72
- const PROBE_ACCOUNT_CONCURRENCY = 10;
73
-
74
- export interface ProbeOutboundBinding {
75
- readonly fetch: ValidatedOutboundFetch;
76
- readonly networkPoolId?: string;
77
- readonly release?: () => void;
78
- }
79
-
80
- export type ProbeOutboundResolver = (
81
- tenantId: string,
82
- providerId?: string,
83
- ) => Promise<ValidatedOutboundFetch | ProbeOutboundBinding> | ValidatedOutboundFetch | ProbeOutboundBinding;
84
-
85
- interface ProviderProbingDeps {
86
- readonly db: CartethyiaDatabase;
87
- readonly telemetryBuffer: TelemetryBatchBuffer | undefined;
88
- readonly defaultEndpoints: Record<WireFamily, string>;
89
- readonly bundledModelCatalog: ReadonlyMap<string, readonly ModelDefinition[]>;
90
- readonly outboundFetchFor: ProbeOutboundResolver;
91
- readonly snapshotInvalidator: { invalidate(): unknown };
92
- readonly providerRegistry: ProviderRegistry;
93
- }
94
-
95
- /**
96
- * Adapts a validated outbound fetch to the `FetchLike` shape discovery
97
- * helpers expect. Only the call signature is consumed downstream.
98
- */
99
- function asFetchLike(fetchFn: ValidatedOutboundFetch): FetchLike {
100
- const wrapped = (input: RequestInfo | URL, init?: RequestInit): Promise<Response> =>
101
- fetchFn(input, init);
102
- return Object.assign(wrapped, { preconnect: () => undefined });
103
- }
104
-
105
- function asOutboundBinding(
106
- resolved: ValidatedOutboundFetch | ProbeOutboundBinding,
107
- ): ProbeOutboundBinding {
108
- return typeof resolved === "function" ? { fetch: resolved } : resolved;
109
- }
110
- export class ProviderProbingService {
111
- private readonly db: CartethyiaDatabase;
112
- private readonly telemetryBuffer: TelemetryBatchBuffer | undefined;
113
- private readonly payloadCapture: TelemetryPayloadCapture;
114
- private readonly defaultEndpoints: Record<WireFamily, string>;
115
- private readonly bundledModelCatalog: ReadonlyMap<string, readonly ModelDefinition[]>;
116
- private readonly outboundFetchFor: ProbeOutboundResolver;
117
- private readonly snapshotInvalidator: { invalidate(): unknown };
118
- private readonly providerRegistry: ProviderRegistry;
119
-
120
- constructor(deps: ProviderProbingDeps) {
121
- this.db = deps.db;
122
- this.telemetryBuffer = deps.telemetryBuffer;
123
- this.payloadCapture = new TelemetryPayloadCapture(this.db);
124
- this.defaultEndpoints = deps.defaultEndpoints;
125
- this.bundledModelCatalog = deps.bundledModelCatalog;
126
- this.outboundFetchFor = deps.outboundFetchFor;
127
- this.snapshotInvalidator = deps.snapshotInvalidator;
128
- this.providerRegistry = deps.providerRegistry;
129
- }
130
-
131
- /**
132
- * The provider row columns that decide wire-family/endpoint resolution.
133
- * `probeModel` loads this once and reuses it for the contract gate, the
134
- * custom-adapter fallback, and the wire context.
135
- */
136
- private async loadProviderWireRow(providerId: string): Promise<ProbeProviderRow | undefined> {
137
- const rows = await this.db
138
- .select({
139
- baseUrl: providers.baseUrl,
140
- wireFamilyDefault: providers.wireFamilyDefault,
141
- compatibilityProfile: providers.compatibilityProfile,
142
- requiresAccount: providers.requiresAccount,
143
- })
144
- .from(providers)
145
- .where(eq(providers.id, providerId))
146
- .limit(1);
147
- return rows[0];
148
- }
149
-
150
- /**
151
- * Static endpoint for one provider + wire family, if the bundled catalog
152
- * ships a static definition for it. Used by manual registration (via the
153
- * console store) so it resolves the same provider-truth endpoints as probes.
154
- */
155
- async resolveStaticEndpoint(
156
- providerId: string,
157
- wireFamily: WireFamily,
158
- ): Promise<string | undefined> {
159
- return staticEndpointForWire(this.bundledModelCatalog, providerId, wireFamily);
160
- }
161
-
162
- /** One-shot connectivity test: resolves a usable account + the real per-provider
163
- * adapter, dispatches a minimal canonical request through the model's own wire
164
- * family/endpoint (never a hardcoded `/v1/chat/completions`), and records the
165
- * outcome into `telemetry_events` via the same writer live traffic uses. */
166
- async probeModel(
167
- tenantId: string,
168
- providerId: string,
169
- request: ProbeModelRequest,
170
- ): Promise<ProbeModelResult> {
171
- const startedAt = Date.now();
172
- const modelId = request.modelId;
173
- // One provider snapshot per probe: the contract gate, the account gate, the
174
- // custom-adapter fallback, and the wire context all read this same row, so a
175
- // concurrent edit cannot make two reads disagree.
176
- const providerWireRow = await this.loadProviderWireRow(providerId);
177
- const requiresAccount = providerWireRow?.requiresAccount ?? true;
178
-
179
- const { wireFamily, serviceKind, endpointPath, sourceSurface, capabilityProfile } = await resolveProbeTarget({
180
- db: this.db,
181
- bundledModelCatalog: this.bundledModelCatalog,
182
- defaultEndpoints: this.defaultEndpoints,
183
- providerId,
184
- modelId,
185
- request,
186
- providerWireRow,
187
- });
188
-
189
- const account = await selectProbeAccount({
190
- db: this.db,
191
- tenantId,
192
- providerId,
193
- requestedAccountId: request.accountId,
194
- requiresAccount,
195
- allowCoolingAccount:
196
- providerId === "grok" && request.prompt === GROK_407_PROBE_PROMPT,
197
- });
198
- if (!account.ok) {
199
- return { ok: false, latencyMs: Date.now() - startedAt, error: account.error };
200
- }
201
- const accountId = account.accountId;
202
-
203
- const adapter = await resolveProbeAdapter({
204
- providerRegistry: this.providerRegistry,
205
- providerId,
206
- providerWireRow,
207
- });
208
- if (!adapter) {
209
- return {
210
- ok: false,
211
- latencyMs: Date.now() - startedAt,
212
- error: `No adapter registered for provider ${providerId}`,
213
- };
214
- }
215
-
216
- const { probeReasoning, payloadCaptureEnabled } = await loadProbePreferences({
217
- db: this.db,
218
- tenantId,
219
- wireFamily,
220
- request,
221
- });
222
- const canonicalRequest = buildProbeCanonicalRequest({
223
- modelId,
224
- request,
225
- probeReasoning,
226
- sourceSurface,
227
- });
228
- let capturedRequest = canonicalRequest;
229
- const parsedProviderId = parseProviderId(providerId);
230
- const candidate: AdapterDispatchTarget = {
231
- provider_id: parsedProviderId,
232
- model_id: modelId,
233
- wire_family: wireFamily,
234
- endpoint_path: endpointPath,
235
- // The route's real profile, not `{}`. A probe runs the production adapter,
236
- // which gates on capabilities — an empty profile made it reject the probe
237
- // over capabilities the route actually has (`prompt_caching`, `tools`,
238
- // `reasoning`), reporting a healthy provider as broken.
239
- capabilities: capabilityProfile,
240
- };
241
-
242
- const probeSignal = AbortSignal.timeout(30_000);
243
- const events: CanonicalEvent[] = [];
244
- /**
245
- * Populated when the probe dials. Declared here, not inside the try, so the
246
- * model the upstream named is still readable after the block unwinds — the
247
- * result is assembled outside it.
248
- */
249
- let probeCapture: ProviderExchangeCapture | undefined;
250
- let ttfbMs: number | undefined;
251
- let dispatchError: unknown;
252
- /** Hits a `websearch` probe returned; surfaced to the console verbatim. */
253
- let searchResults: ProbeModelResult["searchResults"];
254
- let networkPoolId: string | undefined;
255
- try {
256
- // Public providers (`requires_account: false`) probe credential-free;
257
- const credential = accountId
258
- ? await resolveCredentialForAccount(this.db, providerId, accountId)
259
- : { provider_id: parsedProviderId, credential_kind: "none" as const };
260
- const outbound = asOutboundBinding(await this.outboundFetchFor(tenantId, providerId));
261
- networkPoolId = outbound.networkPoolId;
262
- probeCapture = {
263
- request: null,
264
- response: Promise.resolve(null),
265
- };
266
- // The same wrapper a real dispatch uses, so the probe reads the model the
267
- // upstream named from the raw bytes rather than from a synthesized event.
268
- const probeFetch = createProbeFetch(
269
- captureProviderExchange(outbound.fetch, probeCapture, false),
270
- );
271
- try {
272
- if (serviceKind === "websearch") {
273
- if (typeof adapter.websearch !== "function") {
274
- throw new GatewayError(
275
- "capability_unsupported",
276
- 400,
277
- `Provider ${providerId} has no web search transport`,
278
- );
279
- }
280
- const searchProbeQuery = request.prompt?.trim() || "Cartethyia gateway probe";
281
- const response = await adapter.websearch(
282
- { model: modelId, query: searchProbeQuery, max_results: 3 },
283
- candidate,
284
- {
285
- credential,
286
- deadline: startedAt + 30_000,
287
- abort_signal: probeSignal,
288
- outbound_fetch: probeFetch,
289
- },
290
- );
291
- if (ttfbMs === undefined) ttfbMs = Date.now() - startedAt;
292
- searchResults = response.results.map((hit) => ({
293
- title: hit.title,
294
- url: hit.url,
295
- snippet: hit.snippet,
296
- }));
297
- if (!response.results || response.results.length === 0) {
298
- dispatchError = new GatewayError("platform_unavailable", 502, "web search returned no results");
299
- }
300
- } else if (serviceKind === "systemone") {
301
- if (typeof adapter.systemone !== "function") {
302
- throw new GatewayError(
303
- "capability_unsupported",
304
- 400,
305
- `Provider ${providerId} has no System One transport`,
306
- );
307
- }
308
- const response = await adapter.systemone(
309
- {
310
- model: modelId,
311
- state: "Customer: I was charged twice for my order this morning.",
312
- questions: {
313
- probe: {
314
- type: "noul",
315
- instructions: "Is the customer reporting a billing problem?",
316
- },
317
- },
318
- },
319
- candidate,
320
- {
321
- credential,
322
- deadline: startedAt + 30_000,
323
- abort_signal: probeSignal,
324
- outbound_fetch: probeFetch,
325
- },
326
- );
327
- if (ttfbMs === undefined) ttfbMs = Date.now() - startedAt;
328
- const raw = await response.text().catch(() => "");
329
- if (!response.ok) {
330
- dispatchError = new GatewayError(
331
- response.status >= 500 ? "platform_unavailable" : "invalid_request",
332
- response.status,
333
- `System One upstream error: ${raw.slice(0, 240)}`,
334
- );
335
- } else {
336
- let parsed: unknown;
337
- try {
338
- parsed = raw ? JSON.parse(raw) : null;
339
- } catch {
340
- dispatchError = new GatewayError("platform_unavailable", 502, "System One returned invalid JSON");
341
- }
342
- if (parsed !== null && parsed !== undefined) {
343
- const answers =
344
- typeof parsed === "object" ? (parsed as Record<string, unknown>).answers : undefined;
345
- const hasAnswers =
346
- answers !== undefined &&
347
- answers !== null &&
348
- typeof answers === "object" &&
349
- Object.keys(answers as Record<string, unknown>).length > 0;
350
- if (!hasAnswers) {
351
- dispatchError = new GatewayError(
352
- "platform_unavailable",
353
- 502,
354
- "System One returned no answers",
355
- );
356
- }
357
- }
358
- }
359
- } else {
360
- const dispatchProbe = async (request: CanonicalRequest): Promise<void> => {
361
- capturedRequest = request;
362
- for await (const event of adapter.dispatch(request, candidate, {
363
- credential,
364
- deadline: startedAt + 30_000,
365
- abort_signal: probeSignal,
366
- outbound_fetch: probeFetch,
367
- })) {
368
- if (ttfbMs === undefined) ttfbMs = Date.now() - startedAt;
369
- events.push(event);
370
- }
371
- };
372
- try {
373
- await dispatchProbe(canonicalRequest);
374
- if (!hasMeaningfulOutput(events)) throw new Error("probe response content empty");
375
- } catch (error) {
376
- const message = error instanceof Error ? error.message : String(error);
377
- if (!/empty\s+(?:response\s+)?content|response\s+content\s+empty/i.test(message)) {
378
- throw error;
379
- }
380
- // Some reasoning models spend the short probe budget before producing
381
- // visible text. Retry with a larger output allowance, and stream when
382
- // the first attempt did not — a streamed probe keeps the connection
383
- // open while the model reasons instead of waiting for a complete body.
384
- // Generic: no provider/model id special case is needed.
385
- events.length = 0;
386
- ttfbMs = undefined;
387
- await dispatchProbe({
388
- ...canonicalRequest,
389
- stream: true,
390
- generation_controls: {
391
- ...canonicalRequest.generation_controls,
392
- max_tokens: 1024,
393
- max_output_tokens: 1024,
394
- },
395
- });
396
- }
397
- }
398
- } finally {
399
- outbound.release?.();
400
- }
401
- } catch (error) {
402
- dispatchError = error;
403
- }
404
- const featureFailure = grok407ProbeFailure({
405
- providerId,
406
- prompt: request.prompt,
407
- events,
408
- dispatchError,
409
- });
410
- if (featureFailure !== undefined) dispatchError = featureFailure;
411
-
412
- const upstreamStatusCode =
413
- dispatchError instanceof GatewayError
414
- ? typeof dispatchError.details.upstreamStatus === "number"
415
- ? dispatchError.details.upstreamStatus
416
- : typeof dispatchError.details.providerStatus === "number"
417
- ? dispatchError.details.providerStatus
418
- : undefined
419
- : undefined;
420
- await recordProbeHealth({
421
- db: this.db,
422
- invalidator: this.snapshotInvalidator,
423
- accountId,
424
- dispatchError,
425
- modelId,
426
- upstreamStatusCode,
427
- });
428
-
429
- const latencyMs = Date.now() - startedAt;
430
- // A native-service probe carries no canonical events: its verdict is the
431
- // decision response itself (no dispatch error + answers present), which the
432
- // branch above already encoded into `dispatchError`. Web search is a
433
- // native service too — routing it through the LLM verdict demanded a
434
- // `terminal` event the search transport never emits, so a search that
435
- // returned hits was reported as a failure with an empty message.
436
- const { ok, errorMessage, usage } =
437
- serviceKind === "systemone" || serviceKind === "websearch"
438
- ? {
439
- ok: dispatchError === undefined,
440
- errorMessage:
441
- dispatchError === undefined
442
- ? undefined
443
- : dispatchError instanceof GatewayError
444
- ? `Search provider ${providerId}/${modelId} — ${dispatchError.message} (HTTP ${dispatchError.status})`
445
- : dispatchError instanceof Error
446
- ? `${providerId}/${modelId} — ${dispatchError.message}`
447
- : String(dispatchError),
448
- usage: undefined,
449
- }
450
- : computeProbeVerdict({
451
- events,
452
- dispatchError,
453
- wireFamily,
454
- });
455
-
456
- const requestId = crypto.randomUUID();
457
-
458
- // Token speed shared with live traffic (`computeTokensPerSec`): probes
459
- // record TTFT+latency only, so they report end-to-end effective speed.
460
- const tokensPerSec = computeTokensPerSec({
461
- outputTokens: usage?.output_tokens,
462
- latencyMs,
463
- });
464
-
465
- // Probe telemetry rides the same batch buffer as live traffic. Keep the
466
- // probe bodies linked by request_id so the console can inspect the exact
467
- // canonical request and provider event stream that produced the result.
468
- this.telemetryBuffer?.enqueue({
469
- tenantId,
470
- requestId,
471
- sourceSurface,
472
- requestedModel: modelId,
473
- endpoint: endpointPath,
474
- providerId,
475
- ...(accountId ? { accountId } : {}),
476
- ...(networkPoolId ? { networkPoolId } : {}),
477
- latencyMs,
478
- ...(ttfbMs !== undefined ? { ttfbMs } : {}),
479
- stream: capturedRequest.stream,
480
- status: ok ? "completed" : "failed",
481
- ...(ok ? {} : { errorCategory: errorMessage ?? "probe_failed" }),
482
- ...(usage ? { usage } : {}),
483
- ...(tokensPerSec !== undefined ? { tokensPerSec } : {}),
484
- });
485
- if (this.telemetryBuffer && payloadCaptureEnabled) {
486
- await this.payloadCapture
487
- .capture({
488
- tenantId,
489
- requestId,
490
- requestBody: capturedRequest,
491
- responseBody: events,
492
- scope: "tenant",
493
- tenantOptIn: true,
494
- })
495
- .catch(() => undefined);
496
- }
497
-
498
- const sample = extractSample(events);
499
- const reportedModel = await probeCapture?.reportedModel?.catch(() => undefined);
500
- return {
501
- ...(accountId ? { accountId } : {}),
502
- ok,
503
- latencyMs,
504
- ...(ttfbMs !== undefined ? { ttfbMs } : {}),
505
- ...(upstreamStatusCode === undefined ? {} : { statusCode: upstreamStatusCode }),
506
- ...(sample ? { sample } : {}),
507
- ...(reportedModel === undefined ? {} : { reportedModel }),
508
- ...(searchResults !== undefined && searchResults.length > 0 ? { searchResults } : {}),
509
- ...(errorMessage ? { error: errorMessage } : {}),
510
- };
511
- }
512
-
513
- /**
514
- * Ad-hoc connectivity test for a not-yet-persisted BYOK provider.
515
- *
516
- * Dispatches a real `GET <base>/models` through the same SSRF-validated
517
- * outbound fetch live traffic uses, stamping the credential with the auth
518
- * header the chosen wire family actually reads (`x-api-key` for Messages,
519
- * `Authorization: Bearer` otherwise) plus that wire's CLI identity headers.
520
- * Reachability is the contract: any HTTP response proves the operator's base
521
- * URL, credential, and egress path work — a 4xx is reported with its status
522
- * rather than disguised as a transport failure.
523
- */
524
- async testByokConnection(
525
- tenantId: string,
526
- request: ByokConnectionTestRequest,
527
- ): Promise<ByokConnectionTestResult> {
528
- const startedAt = Date.now();
529
- const requestedWire = request.wireFamily;
530
- const wireFamily: WireFamily = (WIRE_FAMILIES as readonly string[]).includes(requestedWire)
531
- ? (requestedWire as WireFamily)
532
- : "chat";
533
- // An operator who pasted a full endpoint (`…/anthropic/v1/messages`) gets it
534
- // stripped before the model list is derived, and told so. Probing the
535
- // corrected root is what makes the result actionable: the alternative is a
536
- // bare 404 against a path the gateway built from their own typo.
537
- const submitted = splitEndpointBaseUrl(request.baseUrl);
538
- const warning =
539
- submitted.stripped === ""
540
- ? undefined
541
- : `Base URL pointed at the endpoint "${submitted.stripped}". Cartethyia derives the model list from the root, so it tested "${submitted.baseUrl}" instead. Save that root as the Base URL.`;
542
- const url = modelListUrl(request.baseUrl);
543
- const outbound = asOutboundBinding(await this.outboundFetchFor(tenantId));
544
- const probeFetch = createProbeFetch(outbound.fetch);
545
- try {
546
- const headers: Record<string, string> = {
547
- accept: "application/json",
548
- ...authHeaders(
549
- byokAuthHeaderShape([wireFamily]),
550
- new TextEncoder().encode(request.apiKey),
551
- request.apiKey.trim().length > 0 ? "api_key" : "none",
552
- ),
553
- ...(request.cliIdentity === false ? {} : resolveCustomCliHeaders(wireFamily)),
554
- ...(request.extraHeaders ?? {}),
555
- };
556
- const response = await probeFetch(url, {
557
- method: "GET",
558
- headers,
559
- signal: AbortSignal.timeout(15_000),
560
- });
561
- const latencyMs = Date.now() - startedAt;
562
- if (!response.ok) {
563
- return {
564
- ok: false,
565
- latencyMs,
566
- statusCode: response.status,
567
- error: `Upstream rejected the connection test with HTTP ${response.status}`,
568
- ...(warning === undefined ? {} : { warning }),
569
- };
570
- }
571
- // A body is nice to have but not required: some gateways answer `/models`
572
- // with an empty envelope. Read it defensively so a non-JSON body cannot
573
- // turn a successful reachability check into a failure.
574
- let modelCount: number | undefined;
575
- try {
576
- const payload = (await response.json()) as unknown;
577
- const entries = Array.isArray(payload)
578
- ? payload
579
- : isRecord(payload) && Array.isArray(payload["data"])
580
- ? payload["data"]
581
- : isRecord(payload) && Array.isArray(payload["models"])
582
- ? payload["models"]
583
- : undefined;
584
- if (entries) modelCount = entries.length;
585
- } catch {
586
- // Non-JSON success body: reachability already proven by the 2xx.
587
- }
588
- return {
589
- ok: true,
590
- latencyMs,
591
- statusCode: response.status,
592
- ...(modelCount === undefined ? {} : { modelCount }),
593
- ...(warning === undefined ? {} : { warning }),
594
- };
595
- } catch (error) {
596
- return {
597
- ok: false,
598
- latencyMs: Date.now() - startedAt,
599
- error: error instanceof Error ? error.message : "Connection test failed",
600
- ...(warning === undefined ? {} : { warning }),
601
- };
602
- } finally {
603
- outbound.release?.();
604
- }
605
- }
606
-
607
- async syncModels(tenantId: string, providerId: string): Promise<{ synced: number }> {
608
- const outbound = asOutboundBinding(await this.outboundFetchFor(tenantId, providerId));
609
- try {
610
- const discoveryFetch = asFetchLike(outbound.fetch);
611
- const resolver = await this.providerRegistry.resolveModelDiscovery(providerId);
612
- if (resolver) {
613
- // An account is used when one is usable, and the fetch is attempted
614
- // either way. Gating on a credential only made the common case
615
- // unreachable: most providers list their models without auth, and the
616
- // ones that do not answer 401 themselves, which names the actual
617
- // problem where a local 409 named a missing account the operator may
618
- // not even need.
619
- const account = await this.usableAccount(tenantId, providerId);
620
- const secret = account?.secret;
621
- const providerRow = await this.loadProviderWireRow(providerId);
622
- const effectiveBaseUrl = providerRow?.baseUrl ?? providerBaseUrl(providerId) ?? "";
623
- let discovered: readonly ModelDefinition[] | null;
624
- try {
625
- discovered = await resolver({
626
- baseUrl: effectiveBaseUrl,
627
- credential: secret ?? "",
628
- ...(discoveryFetch ? { fetcher: discoveryFetch } : {}),
629
- ...(account?.authState === undefined ? {} : { authState: account.authState }),
630
- });
631
- } catch (error) {
632
- throw new GatewayError(
633
- "transport_unavailable",
634
- 502,
635
- `Model discovery failed for ${providerId}: ${error instanceof Error ? error.message : "unknown error"}`,
636
- {},
637
- "upstream",
638
- );
639
- }
640
- if (!discovered) {
641
- throw new GatewayError(
642
- "transport_unavailable",
643
- 502,
644
- `Model discovery failed for ${providerId}: endpoint returned no usable models`,
645
- {},
646
- "upstream",
647
- );
648
- }
649
- const ctx = wireContextFrom(providerRow);
650
- return this.persistDiscoveredModels(
651
- providerId,
652
- discovered,
653
- (modelId) => resolveDiscoveredWire(modelId, ctx, undefined, this.defaultEndpoints),
654
- ctx.endpointPathsByWireFamily,
655
- effectiveBaseUrl,
656
- );
657
- }
658
-
659
- const providerRow = await this.loadProviderWireRow(providerId);
660
- if (providerRow?.baseUrl) {
661
- const secret = await this.usableAccountSecret(tenantId, providerId);
662
- if (secret) {
663
- // An Anthropic-compatible root reads `x-api-key`, not bearer auth,
664
- // and a bare host needs its `/v1` segment before `/models` joins on.
665
- // Both are derived from the same wire profile dispatch uses, so
666
- // auto-fetch behaves identically for either flavor of custom provider.
667
- const wireProfile = resolveByokWireProfile(
668
- providerRow.wireFamilyDefault,
669
- providerRow.compatibilityProfile as CompatibilityProfile | null,
670
- );
671
- const modelsBaseUrl = modelDiscoveryBaseUrl(providerRow.baseUrl);
672
- let discovered: readonly ModelDefinition[] | null;
673
- try {
674
- discovered = await fetchOpenAICompatibleModels({
675
- baseUrl: modelsBaseUrl,
676
- providerId,
677
- headers: {
678
- ...authHeaders(
679
- wireProfile.authHeaderShape,
680
- new TextEncoder().encode(secret),
681
- "api_key",
682
- ),
683
- },
684
- ...(discoveryFetch ? { fetcher: discoveryFetch as typeof fetch } : {}),
685
- });
686
- } catch (error) {
687
- throw new GatewayError(
688
- "transport_unavailable",
689
- 502,
690
- `Model discovery failed for ${providerId}: ${error instanceof Error ? error.message : "unknown error"}`,
691
- {},
692
- "upstream",
693
- );
694
- }
695
- if (discovered && discovered.length > 0) {
696
- const ctx = wireContextFrom(providerRow);
697
- return this.persistDiscoveredModels(
698
- providerId,
699
- discovered,
700
- (modelId) => resolveDiscoveredWire(modelId, ctx, undefined, this.defaultEndpoints),
701
- ctx.endpointPathsByWireFamily,
702
- providerRow.baseUrl ?? undefined,
703
- // The generic `/models` fetch guesses a wire from the model id;
704
- // the row's own profile is what the adapter will enforce, so it
705
- // decides which guess is admissible.
706
- wireProfile.supportedWireFamilies,
707
- );
708
- }
709
- }
710
- }
711
-
712
- // Two reasons a sync reaches here with nothing dialed, and the operator
713
- // needs to be told which one. Returning `{synced: 0}` for both made an
714
- // unsupported provider indistinguishable from a successful fetch that
715
- // found nothing — the console showed a green "0 synced" for a provider
716
- // it can never sync.
717
- const bundled = isBundledProviderId(providerId);
718
- if (!bundled) {
719
- // A custom provider has a row and a base URL but no usable credential.
720
- // The fix is an account, and saying "no listing endpoint" would send the
721
- // operator looking for a capability the provider may well have.
722
- throw new GatewayError(
723
- "invalid_request",
724
- 400,
725
- `Add an account for ${providerId} first — model listing needs a credential.`,
726
- {},
727
- "cartethyia",
728
- );
729
- }
730
- if (!(await this.usableAccountSecret(tenantId, providerId))) {
731
- // A bundled provider with an account would have gone through the
732
- // resolver branch above, so reaching here means none is registered.
733
- // Add one first, then fetch.
734
- throw new GatewayError(
735
- "invalid_request",
736
- 400,
737
- `Add an account for ${providerId} before fetching its models.`,
738
- {},
739
- "cartethyia",
740
- );
741
- }
742
- // A credential exists and the provider still has no listing path: the
743
- // catalog is the compiled one, and new models arrive with an update.
744
- throw new GatewayError(
745
- "capability_unsupported",
746
- 400,
747
- `${providerId} publishes no model-listing endpoint. Its catalog ships with Cartethyia; new models appear with an update.`,
748
- {},
749
- "cartethyia",
750
- );
751
- } finally {
752
- outbound.release?.();
753
- }
754
- }
755
-
756
- private async persistDiscoveredModels(
757
- providerId: string,
758
- discoveredModels: readonly (string | ModelDefinition)[],
759
- resolve: (modelId: string) => DiscoveredModelWire,
760
- endpointOverrides?: Partial<Record<WireFamily, string>>,
761
- baseUrl?: string,
762
- supportedWireFamilies?: readonly WireFamily[],
763
- ): Promise<{ synced: number }> {
764
- // Static definitions are the injected bundled catalog; the probing service
765
- // and the console store are both handed the same startup-built map.
766
- const staticDefinitions: readonly ModelDefinition[] =
767
- this.bundledModelCatalog.get(providerId) ?? [];
768
- const staticEndpoints: Partial<Record<WireFamily, string>> = {};
769
- for (const definition of staticDefinitions) {
770
- staticEndpoints[definition.wireFamily] ??= definition.endpointPath;
771
- }
772
- // Fetch only adds genuinely new models: an id the builtin catalog
773
- // already owns stays exactly one (builtin) row instead of gaining a
774
- // discovered shadow that confuses pickers and deletes.
775
- const builtinIds = new Set(staticDefinitions.map((definition) => definition.modelId));
776
- // `(modelId, endpointPath)` is the row identity, so a corrected wire family
777
- // lands on a *new* row and would leave the old one behind as a dead route.
778
- // Collect this run's pairs and drop the provider's superseded discovered
779
- // rows for the same ids afterwards.
780
- const resolvedPairs: Array<{ modelId: string; endpointPath: string }> = [];
781
- const rows: Array<typeof models.$inferInsert> = [];
782
- // `(modelId, endpointPath)` is the row identity and the upsert's conflict
783
- // target, so a listing that repeats a pair (the bespoke discoveries return
784
- // raw upstream ids) would make a multi-row `ON CONFLICT DO UPDATE` fail with
785
- // "cannot affect row a second time". Keep the first row per pair, as the
786
- // builtin seeder does, so one statement can carry a whole chunk.
787
- const seenPairs = new Set<string>();
788
- for (const item of discoveredModels) {
789
- const modelId = typeof item === "string" ? item : item.modelId;
790
- if (builtinIds.has(modelId)) continue;
791
- const resolved = resolve(modelId);
792
- const discDef = typeof item === "string" ? undefined : item;
793
- const { wireFamily, endpointPath } = applyDiscoveredWire({
794
- resolvedWireFamily: resolved.wireFamily,
795
- resolvedEndpointPath: resolved.endpointPath,
796
- ...(discDef !== undefined
797
- ? { discoveredWireFamily: discDef.wireFamily, discoveredEndpointPath: discDef.endpointPath }
798
- : {}),
799
- staticEndpoints,
800
- ...(endpointOverrides !== undefined ? { endpointOverrides } : {}),
801
- ...(supportedWireFamilies !== undefined ? { supportedWireFamilies } : {}),
802
- ...(baseUrl !== undefined ? { baseUrl } : {}),
803
- });
804
- resolvedPairs.push({ modelId, endpointPath });
805
- const pairKey = `${modelId}\u0000${endpointPath}`;
806
- if (seenPairs.has(pairKey)) continue;
807
- seenPairs.add(pairKey);
808
- const knownDefinition = staticDefinitions.find((definition) => definition.modelId === modelId);
809
- const sourceUpdatedAt = new Date();
810
- const metadata = {
811
- contextLimit: knownDefinition?.contextLimit ?? discDef?.contextLimit ?? null,
812
- outputLimit: knownDefinition?.outputLimit ?? discDef?.outputLimit ?? null,
813
- modalities: knownDefinition?.modalities ?? discDef?.modalities ?? null,
814
- reasoning: knownDefinition?.reasoning ?? discDef?.reasoning ?? false,
815
- toolCall: knownDefinition?.toolCall ?? discDef?.toolCall ?? false,
816
- cost: modelsDevCatalog.costFor(providerId, modelId),
817
- };
818
- rows.push({
819
- providerId,
820
- modelId,
821
- wireFamily,
822
- // A discovery module that classifies an id as a non-chat service (the
823
- // System One decision API) states it on its own definition; otherwise the
824
- // static catalog's classification wins, and an unclassified id is `llm`.
825
- serviceKind: discDef?.serviceKind ?? knownDefinition?.serviceKind ?? "llm",
826
- endpointPath,
827
- ...metadata,
828
- // A discovery module that marks its rows as a free tier gets its own
829
- // source, because the model list groups on it: a free-tier row is not
830
- // an ordinary fetched one, and collapsing both into `discovered` lost
831
- // that distinction at the only point where it is still known.
832
- source: discDef?.freeTier === true ? "auto_free" : "discovered",
833
- sourceUpdatedAt,
834
- enabled: true,
835
- });
836
- }
837
- // One statement per chunk (500, the account-health batch step) instead of
838
- // one round-trip per row. `excluded.*` reads the proposed row, so the update
839
- // set is the same columns the per-row upsert wrote.
840
- for (let offset = 0; offset < rows.length; offset += 500) {
841
- await this.db
842
- .insert(models)
843
- .values(rows.slice(offset, offset + 500))
844
- .onConflictDoUpdate({
845
- target: [models.providerId, models.modelId, models.endpointPath],
846
- set: {
847
- serviceKind: sql`excluded.service_kind`,
848
- contextLimit: sql`excluded.context_limit`,
849
- outputLimit: sql`excluded.output_limit`,
850
- modalities: sql`excluded.modalities`,
851
- reasoning: sql`excluded.reasoning`,
852
- toolCall: sql`excluded.tool_call`,
853
- cost: sql`excluded.cost`,
854
- // The proposed row's own source, not a constant: a free-tier
855
- // discovery and an ordinary one write the same upsert, and pinning
856
- // this to `discovered` would relabel every free-tier row on the
857
- // next sync.
858
- source: sql`excluded.source`,
859
- sourceUpdatedAt: sql`excluded.source_updated_at`,
860
- },
861
- });
862
- }
863
- const synced = rows.length;
864
- // Drop this provider's discovered rows for the synced ids that are no
865
- // longer part of the resolved set — the stale `(model, endpoint)` pair left
866
- // behind when a corrected wire family moved a model to a new path. Rows the
867
- // operator added by hand (`manual`) and ids outside this run are untouched.
868
- // `auto_free` is included: it is written by this same sync, so leaving it
869
- // out would make a free-tier row the one kind of synced row that never gets
870
- // pruned when its id moves.
871
- if (resolvedPairs.length > 0) {
872
- const pairs = resolvedPairs.map(
873
- (pair) => sql`(${pair.modelId}, ${pair.endpointPath})`,
874
- );
875
- const ids = resolvedPairs.map((pair) => pair.modelId);
876
- await this.db
877
- .delete(models)
878
- .where(
879
- and(
880
- eq(models.providerId, providerId),
881
- inArray(models.source, ["discovered", "auto_free"]),
882
- sql`${models.modelId} IN (${sql.join(
883
- ids.map((id) => sql`${id}`),
884
- sql`, `,
885
- )})`,
886
- sql`(${models.modelId}, ${models.endpointPath}) NOT IN (${sql.join(pairs, sql`, `)})`,
887
- ),
888
- );
889
- }
890
- return { synced };
891
- }
892
-
893
- private async usableAccountSecret(
894
- tenantId: string,
895
- providerId: string,
896
- ): Promise<string | undefined> {
897
- return (await this.usableAccount(tenantId, providerId))?.secret;
898
- }
899
-
900
- /**
901
- * The account a credential-scoped discovery will use, with its non-secret
902
- * auth configuration. Selection is unchanged — first usable row, preferring a
903
- * tenant-owned one — so discovery and dispatch agree on which account answered.
904
- */
905
- private async usableAccount(
906
- tenantId: string,
907
- providerId: string,
908
- ): Promise<{ readonly secret: string; readonly authState?: Readonly<Record<string, unknown>> } | undefined> {
909
- const rows = await this.db
910
- .select()
911
- .from(providerAccounts)
912
- .where(
913
- and(
914
- eq(providerAccounts.providerId, providerId),
915
- globalOrOwnedBy(providerAccounts.tenantId, tenantId),
916
- ),
917
- );
918
- const now = Date.now();
919
- const usable = rows
920
- .filter(
921
- (row) =>
922
- row.status !== "disabled" &&
923
- (row.cooldownUntil === null || row.cooldownUntil.getTime() <= now),
924
- )
925
- .sort((left, right) => Number(right.tenantId !== null) - Number(left.tenantId !== null))[0];
926
- if (!usable?.credentialCiphertext) return undefined;
927
- const authState = isRecord(usable.authState) ? usable.authState as Readonly<Record<string, unknown>> : undefined;
928
- return {
929
- secret: decryptCredentialToString(usable.credentialCiphertext),
930
- ...(authState === undefined ? {} : { authState }),
931
- };
932
- }
933
-
934
- /**
935
- * Batched provider-wide probe: the first request runs sequentially (it warms
936
- * OAuth token caches and connection pools for the provider), the rest run
937
- * with `Promise.allSettled` bounded to `PROBE_CONCURRENCY`. Each request
938
- * carries its model route and service kind so native services never fall
939
- * through to an LLM probe.
940
- */
941
- async probeAllModels(
942
- tenantId: string,
943
- providerId: string,
944
- requests: readonly ProbeModelRequest[],
945
- ): Promise<ProbeAllModelsResult> {
946
- const results: Array<ProbeAllModelsResult["results"][number]> = [];
947
- const [firstRequest, ...restRequests] = requests;
948
- if (firstRequest === undefined) return { providerId, results };
949
-
950
- const runOne = async (probeRequest: ProbeModelRequest): Promise<void> => {
951
- const outcome = await this.probeModel(tenantId, providerId, probeRequest);
952
- results.push({
953
- modelId: probeRequest.modelId,
954
- ok: outcome.ok,
955
- latencyMs: outcome.latencyMs,
956
- ...(outcome.error ? { error: outcome.error } : {}),
957
- });
958
- };
959
-
960
- await runOne(firstRequest);
961
- for (let offset = 0; offset < restRequests.length; offset += PROBE_CONCURRENCY) {
962
- const batch = restRequests.slice(offset, offset + PROBE_CONCURRENCY);
963
- await Promise.allSettled(batch.map((request) => runOne(request)));
964
- }
965
- return { providerId, results };
966
- }
967
- async probeAllAccounts(
968
- tenantId: string,
969
- providerId: string,
970
- request: ProbeModelRequest,
971
- ): Promise<ProbeAllAccountsResult> {
972
- const accounts = await this.db
973
- .select({ id: providerAccounts.id })
974
- .from(providerAccounts)
975
- .where(
976
- and(
977
- eq(providerAccounts.providerId, providerId),
978
- globalOrOwnedBy(providerAccounts.tenantId, tenantId),
979
- ),
980
- );
981
- const results: ProbeAllAccountsResult["results"][number][] = [];
982
- for (let offset = 0; offset < accounts.length; offset += PROBE_ACCOUNT_CONCURRENCY) {
983
- const batch = accounts.slice(offset, offset + PROBE_ACCOUNT_CONCURRENCY);
984
- const batchResults = await Promise.all(
985
- batch.map(async (account) => {
986
- const result = await this.probeModel(tenantId, providerId, {
987
- ...request,
988
- accountId: account.id,
989
- });
990
- // No health write here: this used to regex the model's own answer text
991
- // for "202" and degrade the account on a match. A probe that genuinely
992
- // fails already reports through `recordAccountFailure`, which classifies
993
- // the real status; matching answer text is not a health signal.
994
- return { ...result, accountId: account.id };
995
- }),
996
- );
997
- results.push(...batchResults);
998
- }
999
- return { providerId, modelId: request.modelId, results };
1000
- }
1001
- }
1
+ import { captureProviderExchange, type ProviderExchangeCapture } from "../../transport/dispatch/attempt-finalize";
2
+ import { globalOrOwnedBy } from "../../persistence/tenant-scope";
3
+ /**
4
+ * Provider probing and model-discovery orchestration.
5
+ *
6
+ * Network probes and `/v1/models` discovery stay separate from the Drizzle
7
+ * catalog repository while sharing the provider catalog contract.
8
+ */
9
+ import { and, eq, inArray, sql } from "drizzle-orm";
10
+ import type { CartethyiaDatabase } from "../../persistence/db-handle";
11
+ import { models, providerAccounts, providers } from "../../persistence/schema";
12
+ import type { CanonicalEvent, CanonicalRequest, WireFamily } from "../../protocol/canonical-model";
13
+ import { WIRE_FAMILIES } from "../../protocol/canonical-model";
14
+ import { isRecord } from "../../protocol/codecs";
15
+ import type { FetchLike } from "../quota/quota-contracts";
16
+ import type { CompatibilityProfile } from "../provider-metadata";
17
+ import {
18
+ type ByokConnectionTestRequest,
19
+ type ByokConnectionTestResult,
20
+ type ProbeAllAccountsResult,
21
+ type ProbeAllModelsResult,
22
+ type ProbeModelRequest,
23
+ type ProbeModelResult,
24
+ } from "./discovery-types";
25
+ import { modelsDevCatalog } from "./models-dev-catalog";
26
+ import { fetchOpenAICompatibleModels } from "./openai-model-discovery";
27
+ import {
28
+ applyDiscoveredWire,
29
+ resolveDiscoveredWire,
30
+ staticEndpointForWire,
31
+ type DiscoveredModelWire,
32
+ } from "./probe-wire";
33
+ import {
34
+ buildProbeCanonicalRequest,
35
+ computeProbeVerdict,
36
+ extractSample,
37
+ GROK_407_PROBE_PROMPT,
38
+ grok407ProbeFailure,
39
+ hasMeaningfulOutput,
40
+ loadProbePreferences,
41
+ recordProbeHealth,
42
+ resolveProbeAdapter,
43
+ resolveProbeTarget,
44
+ selectProbeAccount,
45
+ wireContextFrom,
46
+ type ProbeProviderRow,
47
+ } from "./probe-phases";
48
+ import { createProbeFetch } from "../operations/probe-fetch";
49
+ import { authHeaders } from "../adapters/compatible-adapter";
50
+ import {
51
+ byokAuthHeaderShape,
52
+ modelDiscoveryBaseUrl,
53
+ modelListUrl,
54
+ resolveByokWireProfile,
55
+ splitEndpointBaseUrl,
56
+ } from "../operations/byok-wire-profile";
57
+ import { resolveCustomCliHeaders } from "../operations/custom-cli-headers";
58
+ import { resolveCredentialForAccount } from "../operations/provider-credential-service";
59
+ import { decryptCredentialToString } from "../../security/crypto";
60
+ import { GatewayError } from "../../protocol/gateway-error";
61
+ import type { TelemetryBatchBuffer } from "../../observability/telemetry-buffer";
62
+ import { computeTokensPerSec } from "../../observability/token-speed";
63
+ import { TelemetryPayloadCapture } from "../../observability/payload-capture";
64
+ import { providerBaseUrl } from "../provider-metadata";
65
+ import { isBundledProviderId, parseProviderId, type ProviderDispatchTarget as AdapterDispatchTarget, type ValidatedOutboundFetch } from "../provider-contracts";
66
+ import type { ValidatedOutboundHttp2Bidi } from "../provider-contracts";
67
+ import type { ModelDefinition } from "../provider-model-definition";
68
+ import type { ProviderRegistry } from "../provider-registry";
69
+
70
+ /** Max models probed concurrently per batch (outside the sequential warm-up). */
71
+ const PROBE_CONCURRENCY = 5;
72
+ /** Max accounts probed concurrently; batches wait before starting the next ten. */
73
+ const PROBE_ACCOUNT_CONCURRENCY = 10;
74
+
75
+ export interface ProbeOutboundBinding {
76
+ readonly fetch: ValidatedOutboundFetch;
77
+ /**
78
+ * Validated bidirectional HTTP/2 egress for the same pool/tenant. A provider
79
+ * whose only transport is a bidi stream (Cursor's Connect-RPC Run) has no
80
+ * `fetch` path to fall back to, so a probe without this binding reports a
81
+ * healthy account as broken.
82
+ */
83
+ readonly http2Bidi?: ValidatedOutboundHttp2Bidi;
84
+ readonly networkPoolId?: string;
85
+ readonly release?: () => void;
86
+ }
87
+
88
+ export type ProbeOutboundResolver = (
89
+ tenantId: string,
90
+ providerId?: string,
91
+ ) => Promise<ValidatedOutboundFetch | ProbeOutboundBinding> | ValidatedOutboundFetch | ProbeOutboundBinding;
92
+
93
+ interface ProviderProbingDeps {
94
+ readonly db: CartethyiaDatabase;
95
+ readonly telemetryBuffer: TelemetryBatchBuffer | undefined;
96
+ readonly defaultEndpoints: Record<WireFamily, string>;
97
+ readonly bundledModelCatalog: ReadonlyMap<string, readonly ModelDefinition[]>;
98
+ readonly outboundFetchFor: ProbeOutboundResolver;
99
+ readonly snapshotInvalidator: { invalidate(): unknown };
100
+ readonly providerRegistry: ProviderRegistry;
101
+ }
102
+
103
+ /**
104
+ * Adapts a validated outbound fetch to the `FetchLike` shape discovery
105
+ * helpers expect. Only the call signature is consumed downstream.
106
+ */
107
+ function asFetchLike(fetchFn: ValidatedOutboundFetch): FetchLike {
108
+ const wrapped = (input: RequestInfo | URL, init?: RequestInit): Promise<Response> =>
109
+ fetchFn(input, init);
110
+ return Object.assign(wrapped, { preconnect: () => undefined });
111
+ }
112
+
113
+ function asOutboundBinding(
114
+ resolved: ValidatedOutboundFetch | ProbeOutboundBinding,
115
+ ): ProbeOutboundBinding {
116
+ return typeof resolved === "function" ? { fetch: resolved } : resolved;
117
+ }
118
+ export class ProviderProbingService {
119
+ private readonly db: CartethyiaDatabase;
120
+ private readonly telemetryBuffer: TelemetryBatchBuffer | undefined;
121
+ private readonly payloadCapture: TelemetryPayloadCapture;
122
+ private readonly defaultEndpoints: Record<WireFamily, string>;
123
+ private readonly bundledModelCatalog: ReadonlyMap<string, readonly ModelDefinition[]>;
124
+ private readonly outboundFetchFor: ProbeOutboundResolver;
125
+ private readonly snapshotInvalidator: { invalidate(): unknown };
126
+ private readonly providerRegistry: ProviderRegistry;
127
+
128
+ constructor(deps: ProviderProbingDeps) {
129
+ this.db = deps.db;
130
+ this.telemetryBuffer = deps.telemetryBuffer;
131
+ this.payloadCapture = new TelemetryPayloadCapture(this.db);
132
+ this.defaultEndpoints = deps.defaultEndpoints;
133
+ this.bundledModelCatalog = deps.bundledModelCatalog;
134
+ this.outboundFetchFor = deps.outboundFetchFor;
135
+ this.snapshotInvalidator = deps.snapshotInvalidator;
136
+ this.providerRegistry = deps.providerRegistry;
137
+ }
138
+
139
+ /**
140
+ * The provider row columns that decide wire-family/endpoint resolution.
141
+ * `probeModel` loads this once and reuses it for the contract gate, the
142
+ * custom-adapter fallback, and the wire context.
143
+ */
144
+ private async loadProviderWireRow(providerId: string): Promise<ProbeProviderRow | undefined> {
145
+ const rows = await this.db
146
+ .select({
147
+ baseUrl: providers.baseUrl,
148
+ wireFamilyDefault: providers.wireFamilyDefault,
149
+ compatibilityProfile: providers.compatibilityProfile,
150
+ requiresAccount: providers.requiresAccount,
151
+ })
152
+ .from(providers)
153
+ .where(eq(providers.id, providerId))
154
+ .limit(1);
155
+ return rows[0];
156
+ }
157
+
158
+ /**
159
+ * Static endpoint for one provider + wire family, if the bundled catalog
160
+ * ships a static definition for it. Used by manual registration (via the
161
+ * console store) so it resolves the same provider-truth endpoints as probes.
162
+ */
163
+ async resolveStaticEndpoint(
164
+ providerId: string,
165
+ wireFamily: WireFamily,
166
+ ): Promise<string | undefined> {
167
+ return staticEndpointForWire(this.bundledModelCatalog, providerId, wireFamily);
168
+ }
169
+
170
+ /** One-shot connectivity test: resolves a usable account + the real per-provider
171
+ * adapter, dispatches a minimal canonical request through the model's own wire
172
+ * family/endpoint (never a hardcoded `/v1/chat/completions`), and records the
173
+ * outcome into `telemetry_events` via the same writer live traffic uses. */
174
+ async probeModel(
175
+ tenantId: string,
176
+ providerId: string,
177
+ request: ProbeModelRequest,
178
+ ): Promise<ProbeModelResult> {
179
+ const startedAt = Date.now();
180
+ const modelId = request.modelId;
181
+ // One provider snapshot per probe: the contract gate, the account gate, the
182
+ // custom-adapter fallback, and the wire context all read this same row, so a
183
+ // concurrent edit cannot make two reads disagree.
184
+ const providerWireRow = await this.loadProviderWireRow(providerId);
185
+ const requiresAccount = providerWireRow?.requiresAccount ?? true;
186
+
187
+ const { wireFamily, serviceKind, endpointPath, sourceSurface, capabilityProfile } = await resolveProbeTarget({
188
+ db: this.db,
189
+ bundledModelCatalog: this.bundledModelCatalog,
190
+ defaultEndpoints: this.defaultEndpoints,
191
+ providerId,
192
+ modelId,
193
+ request,
194
+ providerWireRow,
195
+ });
196
+
197
+ const account = await selectProbeAccount({
198
+ db: this.db,
199
+ tenantId,
200
+ providerId,
201
+ requestedAccountId: request.accountId,
202
+ requiresAccount,
203
+ allowCoolingAccount:
204
+ providerId === "grok" && request.prompt === GROK_407_PROBE_PROMPT,
205
+ });
206
+ if (!account.ok) {
207
+ return { ok: false, latencyMs: Date.now() - startedAt, error: account.error };
208
+ }
209
+ const accountId = account.accountId;
210
+
211
+ const adapter = await resolveProbeAdapter({
212
+ providerRegistry: this.providerRegistry,
213
+ providerId,
214
+ providerWireRow,
215
+ });
216
+ if (!adapter) {
217
+ return {
218
+ ok: false,
219
+ latencyMs: Date.now() - startedAt,
220
+ error: `No adapter registered for provider ${providerId}`,
221
+ };
222
+ }
223
+
224
+ const { probeReasoning, payloadCaptureEnabled } = await loadProbePreferences({
225
+ db: this.db,
226
+ tenantId,
227
+ wireFamily,
228
+ request,
229
+ });
230
+ const canonicalRequest = buildProbeCanonicalRequest({
231
+ modelId,
232
+ request,
233
+ probeReasoning,
234
+ sourceSurface,
235
+ });
236
+ let capturedRequest = canonicalRequest;
237
+ const parsedProviderId = parseProviderId(providerId);
238
+ const candidate: AdapterDispatchTarget = {
239
+ provider_id: parsedProviderId,
240
+ model_id: modelId,
241
+ wire_family: wireFamily,
242
+ endpoint_path: endpointPath,
243
+ // The route's real profile, not `{}`. A probe runs the production adapter,
244
+ // which gates on capabilities — an empty profile made it reject the probe
245
+ // over capabilities the route actually has (`prompt_caching`, `tools`,
246
+ // `reasoning`), reporting a healthy provider as broken.
247
+ capabilities: capabilityProfile,
248
+ };
249
+
250
+ const probeSignal = AbortSignal.timeout(30_000);
251
+ const events: CanonicalEvent[] = [];
252
+ /**
253
+ * Populated when the probe dials. Declared here, not inside the try, so the
254
+ * model the upstream named is still readable after the block unwinds — the
255
+ * result is assembled outside it.
256
+ */
257
+ let probeCapture: ProviderExchangeCapture | undefined;
258
+ let ttfbMs: number | undefined;
259
+ let dispatchError: unknown;
260
+ /** Hits a `websearch` probe returned; surfaced to the console verbatim. */
261
+ let searchResults: ProbeModelResult["searchResults"];
262
+ let networkPoolId: string | undefined;
263
+ try {
264
+ // Public providers (`requires_account: false`) probe credential-free;
265
+ const credential = accountId
266
+ ? await resolveCredentialForAccount(this.db, providerId, accountId)
267
+ : { provider_id: parsedProviderId, credential_kind: "none" as const };
268
+ const outbound = asOutboundBinding(await this.outboundFetchFor(tenantId, providerId));
269
+ networkPoolId = outbound.networkPoolId;
270
+ probeCapture = {
271
+ request: null,
272
+ response: Promise.resolve(null),
273
+ };
274
+ // The same wrapper a real dispatch uses, so the probe reads the model the
275
+ // upstream named from the raw bytes rather than from a synthesized event.
276
+ const probeFetch = createProbeFetch(
277
+ captureProviderExchange(outbound.fetch, probeCapture, false),
278
+ );
279
+ try {
280
+ if (serviceKind === "websearch") {
281
+ if (typeof adapter.websearch !== "function") {
282
+ throw new GatewayError(
283
+ "capability_unsupported",
284
+ 400,
285
+ `Provider ${providerId} has no web search transport`,
286
+ );
287
+ }
288
+ const searchProbeQuery = request.prompt?.trim() || "Cartethyia gateway probe";
289
+ const response = await adapter.websearch(
290
+ { model: modelId, query: searchProbeQuery, max_results: 3 },
291
+ candidate,
292
+ {
293
+ credential,
294
+ deadline: startedAt + 30_000,
295
+ abort_signal: probeSignal,
296
+ outbound_fetch: probeFetch,
297
+ },
298
+ );
299
+ if (ttfbMs === undefined) ttfbMs = Date.now() - startedAt;
300
+ searchResults = response.results.map((hit) => ({
301
+ title: hit.title,
302
+ url: hit.url,
303
+ snippet: hit.snippet,
304
+ }));
305
+ if (!response.results || response.results.length === 0) {
306
+ dispatchError = new GatewayError("platform_unavailable", 502, "web search returned no results");
307
+ }
308
+ } else if (serviceKind === "systemone") {
309
+ if (typeof adapter.systemone !== "function") {
310
+ throw new GatewayError(
311
+ "capability_unsupported",
312
+ 400,
313
+ `Provider ${providerId} has no System One transport`,
314
+ );
315
+ }
316
+ const response = await adapter.systemone(
317
+ {
318
+ model: modelId,
319
+ state: "Customer: I was charged twice for my order this morning.",
320
+ questions: {
321
+ probe: {
322
+ type: "noul",
323
+ instructions: "Is the customer reporting a billing problem?",
324
+ },
325
+ },
326
+ },
327
+ candidate,
328
+ {
329
+ credential,
330
+ deadline: startedAt + 30_000,
331
+ abort_signal: probeSignal,
332
+ outbound_fetch: probeFetch,
333
+ },
334
+ );
335
+ if (ttfbMs === undefined) ttfbMs = Date.now() - startedAt;
336
+ const raw = await response.text().catch(() => "");
337
+ if (!response.ok) {
338
+ dispatchError = new GatewayError(
339
+ response.status >= 500 ? "platform_unavailable" : "invalid_request",
340
+ response.status,
341
+ `System One upstream error: ${raw.slice(0, 240)}`,
342
+ );
343
+ } else {
344
+ let parsed: unknown;
345
+ try {
346
+ parsed = raw ? JSON.parse(raw) : null;
347
+ } catch {
348
+ dispatchError = new GatewayError("platform_unavailable", 502, "System One returned invalid JSON");
349
+ }
350
+ if (parsed !== null && parsed !== undefined) {
351
+ const answers =
352
+ typeof parsed === "object" ? (parsed as Record<string, unknown>).answers : undefined;
353
+ const hasAnswers =
354
+ answers !== undefined &&
355
+ answers !== null &&
356
+ typeof answers === "object" &&
357
+ Object.keys(answers as Record<string, unknown>).length > 0;
358
+ if (!hasAnswers) {
359
+ dispatchError = new GatewayError(
360
+ "platform_unavailable",
361
+ 502,
362
+ "System One returned no answers",
363
+ );
364
+ }
365
+ }
366
+ }
367
+ } else {
368
+ const dispatchProbe = async (request: CanonicalRequest): Promise<void> => {
369
+ capturedRequest = request;
370
+ for await (const event of adapter.dispatch(request, candidate, {
371
+ credential,
372
+ deadline: startedAt + 30_000,
373
+ abort_signal: probeSignal,
374
+ outbound_fetch: probeFetch,
375
+ // A bidi-only provider (Cursor's Connect-RPC Run) has no `fetch`
376
+ // transport at all: without the HTTP/2 binding the probe fails with
377
+ // "requires validated HTTP/2 egress" while live dispatch — which
378
+ // always supplies it — succeeds.
379
+ ...(outbound.http2Bidi === undefined ? {} : { outbound_http2: outbound.http2Bidi }),
380
+ })) {
381
+ if (ttfbMs === undefined) ttfbMs = Date.now() - startedAt;
382
+ events.push(event);
383
+ }
384
+ };
385
+ try {
386
+ await dispatchProbe(canonicalRequest);
387
+ if (!hasMeaningfulOutput(events)) throw new Error("probe response content empty");
388
+ } catch (error) {
389
+ const message = error instanceof Error ? error.message : String(error);
390
+ if (!/empty\s+(?:response\s+)?content|response\s+content\s+empty/i.test(message)) {
391
+ throw error;
392
+ }
393
+ // Some reasoning models spend the short probe budget before producing
394
+ // visible text. Retry with a larger output allowance, and stream when
395
+ // the first attempt did not — a streamed probe keeps the connection
396
+ // open while the model reasons instead of waiting for a complete body.
397
+ // Generic: no provider/model id special case is needed.
398
+ events.length = 0;
399
+ ttfbMs = undefined;
400
+ await dispatchProbe({
401
+ ...canonicalRequest,
402
+ stream: true,
403
+ generation_controls: {
404
+ ...canonicalRequest.generation_controls,
405
+ max_tokens: 1024,
406
+ max_output_tokens: 1024,
407
+ },
408
+ });
409
+ }
410
+ }
411
+ } finally {
412
+ outbound.release?.();
413
+ }
414
+ } catch (error) {
415
+ dispatchError = error;
416
+ }
417
+ const featureFailure = grok407ProbeFailure({
418
+ providerId,
419
+ prompt: request.prompt,
420
+ events,
421
+ dispatchError,
422
+ });
423
+ if (featureFailure !== undefined) dispatchError = featureFailure;
424
+
425
+ const upstreamStatusCode =
426
+ dispatchError instanceof GatewayError
427
+ ? typeof dispatchError.details.upstreamStatus === "number"
428
+ ? dispatchError.details.upstreamStatus
429
+ : typeof dispatchError.details.providerStatus === "number"
430
+ ? dispatchError.details.providerStatus
431
+ : undefined
432
+ : undefined;
433
+ await recordProbeHealth({
434
+ db: this.db,
435
+ invalidator: this.snapshotInvalidator,
436
+ accountId,
437
+ dispatchError,
438
+ modelId,
439
+ upstreamStatusCode,
440
+ });
441
+
442
+ const latencyMs = Date.now() - startedAt;
443
+ // A native-service probe carries no canonical events: its verdict is the
444
+ // decision response itself (no dispatch error + answers present), which the
445
+ // branch above already encoded into `dispatchError`. Web search is a
446
+ // native service too — routing it through the LLM verdict demanded a
447
+ // `terminal` event the search transport never emits, so a search that
448
+ // returned hits was reported as a failure with an empty message.
449
+ const { ok, errorMessage, usage } =
450
+ serviceKind === "systemone" || serviceKind === "websearch"
451
+ ? {
452
+ ok: dispatchError === undefined,
453
+ errorMessage:
454
+ dispatchError === undefined
455
+ ? undefined
456
+ : dispatchError instanceof GatewayError
457
+ ? `Search provider ${providerId}/${modelId} — ${dispatchError.message} (HTTP ${dispatchError.status})`
458
+ : dispatchError instanceof Error
459
+ ? `${providerId}/${modelId} — ${dispatchError.message}`
460
+ : String(dispatchError),
461
+ usage: undefined,
462
+ }
463
+ : computeProbeVerdict({
464
+ events,
465
+ dispatchError,
466
+ wireFamily,
467
+ });
468
+
469
+ const requestId = crypto.randomUUID();
470
+
471
+ // Token speed shared with live traffic (`computeTokensPerSec`): probes
472
+ // record TTFT+latency only, so they report end-to-end effective speed.
473
+ const tokensPerSec = computeTokensPerSec({
474
+ outputTokens: usage?.output_tokens,
475
+ latencyMs,
476
+ });
477
+
478
+ // Probe telemetry rides the same batch buffer as live traffic. Keep the
479
+ // probe bodies linked by request_id so the console can inspect the exact
480
+ // canonical request and provider event stream that produced the result.
481
+ this.telemetryBuffer?.enqueue({
482
+ tenantId,
483
+ requestId,
484
+ sourceSurface,
485
+ requestedModel: modelId,
486
+ endpoint: endpointPath,
487
+ providerId,
488
+ ...(accountId ? { accountId } : {}),
489
+ ...(networkPoolId ? { networkPoolId } : {}),
490
+ latencyMs,
491
+ ...(ttfbMs !== undefined ? { ttfbMs } : {}),
492
+ stream: capturedRequest.stream,
493
+ status: ok ? "completed" : "failed",
494
+ ...(ok ? {} : { errorCategory: errorMessage ?? "probe_failed" }),
495
+ ...(usage ? { usage } : {}),
496
+ ...(tokensPerSec !== undefined ? { tokensPerSec } : {}),
497
+ });
498
+ if (this.telemetryBuffer && payloadCaptureEnabled) {
499
+ await this.payloadCapture
500
+ .capture({
501
+ tenantId,
502
+ requestId,
503
+ requestBody: capturedRequest,
504
+ responseBody: events,
505
+ scope: "tenant",
506
+ tenantOptIn: true,
507
+ })
508
+ .catch(() => undefined);
509
+ }
510
+
511
+ const sample = extractSample(events);
512
+ const reportedModel = await probeCapture?.reportedModel?.catch(() => undefined);
513
+ return {
514
+ ...(accountId ? { accountId } : {}),
515
+ ok,
516
+ latencyMs,
517
+ ...(ttfbMs !== undefined ? { ttfbMs } : {}),
518
+ ...(upstreamStatusCode === undefined ? {} : { statusCode: upstreamStatusCode }),
519
+ ...(sample ? { sample } : {}),
520
+ ...(reportedModel === undefined ? {} : { reportedModel }),
521
+ ...(searchResults !== undefined && searchResults.length > 0 ? { searchResults } : {}),
522
+ ...(errorMessage ? { error: errorMessage } : {}),
523
+ };
524
+ }
525
+
526
+ /**
527
+ * Ad-hoc connectivity test for a not-yet-persisted BYOK provider.
528
+ *
529
+ * Dispatches a real `GET <base>/models` through the same SSRF-validated
530
+ * outbound fetch live traffic uses, stamping the credential with the auth
531
+ * header the chosen wire family actually reads (`x-api-key` for Messages,
532
+ * `Authorization: Bearer` otherwise) plus that wire's CLI identity headers.
533
+ * Reachability is the contract: any HTTP response proves the operator's base
534
+ * URL, credential, and egress path work — a 4xx is reported with its status
535
+ * rather than disguised as a transport failure.
536
+ */
537
+ async testByokConnection(
538
+ tenantId: string,
539
+ request: ByokConnectionTestRequest,
540
+ ): Promise<ByokConnectionTestResult> {
541
+ const startedAt = Date.now();
542
+ const requestedWire = request.wireFamily;
543
+ const wireFamily: WireFamily = (WIRE_FAMILIES as readonly string[]).includes(requestedWire)
544
+ ? (requestedWire as WireFamily)
545
+ : "chat";
546
+ // An operator who pasted a full endpoint (`…/anthropic/v1/messages`) gets it
547
+ // stripped before the model list is derived, and told so. Probing the
548
+ // corrected root is what makes the result actionable: the alternative is a
549
+ // bare 404 against a path the gateway built from their own typo.
550
+ const submitted = splitEndpointBaseUrl(request.baseUrl);
551
+ const warning =
552
+ submitted.stripped === ""
553
+ ? undefined
554
+ : `Base URL pointed at the endpoint "${submitted.stripped}". Cartethyia derives the model list from the root, so it tested "${submitted.baseUrl}" instead. Save that root as the Base URL.`;
555
+ const url = modelListUrl(request.baseUrl);
556
+ const outbound = asOutboundBinding(await this.outboundFetchFor(tenantId));
557
+ const probeFetch = createProbeFetch(outbound.fetch);
558
+ try {
559
+ const headers: Record<string, string> = {
560
+ accept: "application/json",
561
+ ...authHeaders(
562
+ byokAuthHeaderShape([wireFamily]),
563
+ new TextEncoder().encode(request.apiKey),
564
+ request.apiKey.trim().length > 0 ? "api_key" : "none",
565
+ ),
566
+ ...(request.cliIdentity === false ? {} : resolveCustomCliHeaders(wireFamily)),
567
+ ...(request.extraHeaders ?? {}),
568
+ };
569
+ const response = await probeFetch(url, {
570
+ method: "GET",
571
+ headers,
572
+ signal: AbortSignal.timeout(15_000),
573
+ });
574
+ const latencyMs = Date.now() - startedAt;
575
+ if (!response.ok) {
576
+ return {
577
+ ok: false,
578
+ latencyMs,
579
+ statusCode: response.status,
580
+ error: `Upstream rejected the connection test with HTTP ${response.status}`,
581
+ ...(warning === undefined ? {} : { warning }),
582
+ };
583
+ }
584
+ // A body is nice to have but not required: some gateways answer `/models`
585
+ // with an empty envelope. Read it defensively so a non-JSON body cannot
586
+ // turn a successful reachability check into a failure.
587
+ let modelCount: number | undefined;
588
+ try {
589
+ const payload = (await response.json()) as unknown;
590
+ const entries = Array.isArray(payload)
591
+ ? payload
592
+ : isRecord(payload) && Array.isArray(payload["data"])
593
+ ? payload["data"]
594
+ : isRecord(payload) && Array.isArray(payload["models"])
595
+ ? payload["models"]
596
+ : undefined;
597
+ if (entries) modelCount = entries.length;
598
+ } catch {
599
+ // Non-JSON success body: reachability already proven by the 2xx.
600
+ }
601
+ return {
602
+ ok: true,
603
+ latencyMs,
604
+ statusCode: response.status,
605
+ ...(modelCount === undefined ? {} : { modelCount }),
606
+ ...(warning === undefined ? {} : { warning }),
607
+ };
608
+ } catch (error) {
609
+ return {
610
+ ok: false,
611
+ latencyMs: Date.now() - startedAt,
612
+ error: error instanceof Error ? error.message : "Connection test failed",
613
+ ...(warning === undefined ? {} : { warning }),
614
+ };
615
+ } finally {
616
+ outbound.release?.();
617
+ }
618
+ }
619
+
620
+ async syncModels(tenantId: string, providerId: string): Promise<{ synced: number }> {
621
+ const outbound = asOutboundBinding(await this.outboundFetchFor(tenantId, providerId));
622
+ try {
623
+ const discoveryFetch = asFetchLike(outbound.fetch);
624
+ const resolver = await this.providerRegistry.resolveModelDiscovery(providerId);
625
+ if (resolver) {
626
+ // An account is used when one is usable, and the fetch is attempted
627
+ // either way. Gating on a credential only made the common case
628
+ // unreachable: most providers list their models without auth, and the
629
+ // ones that do not answer 401 themselves, which names the actual
630
+ // problem where a local 409 named a missing account the operator may
631
+ // not even need.
632
+ const account = await this.usableAccount(tenantId, providerId);
633
+ const secret = account?.secret;
634
+ const providerRow = await this.loadProviderWireRow(providerId);
635
+ const effectiveBaseUrl = providerRow?.baseUrl ?? providerBaseUrl(providerId) ?? "";
636
+ let discovered: readonly ModelDefinition[] | null;
637
+ try {
638
+ discovered = await resolver({
639
+ baseUrl: effectiveBaseUrl,
640
+ credential: secret ?? "",
641
+ ...(discoveryFetch ? { fetcher: discoveryFetch } : {}),
642
+ ...(account?.authState === undefined ? {} : { authState: account.authState }),
643
+ });
644
+ } catch (error) {
645
+ throw new GatewayError(
646
+ "transport_unavailable",
647
+ 502,
648
+ `Model discovery failed for ${providerId}: ${error instanceof Error ? error.message : "unknown error"}`,
649
+ {},
650
+ "upstream",
651
+ );
652
+ }
653
+ if (!discovered) {
654
+ throw new GatewayError(
655
+ "transport_unavailable",
656
+ 502,
657
+ `Model discovery failed for ${providerId}: endpoint returned no usable models`,
658
+ {},
659
+ "upstream",
660
+ );
661
+ }
662
+ const ctx = wireContextFrom(providerRow);
663
+ return this.persistDiscoveredModels(
664
+ providerId,
665
+ discovered,
666
+ (modelId) => resolveDiscoveredWire(modelId, ctx, undefined, this.defaultEndpoints),
667
+ ctx.endpointPathsByWireFamily,
668
+ effectiveBaseUrl,
669
+ );
670
+ }
671
+
672
+ const providerRow = await this.loadProviderWireRow(providerId);
673
+ if (providerRow?.baseUrl) {
674
+ const secret = await this.usableAccountSecret(tenantId, providerId);
675
+ if (secret) {
676
+ // An Anthropic-compatible root reads `x-api-key`, not bearer auth,
677
+ // and a bare host needs its `/v1` segment before `/models` joins on.
678
+ // Both are derived from the same wire profile dispatch uses, so
679
+ // auto-fetch behaves identically for either flavor of custom provider.
680
+ const wireProfile = resolveByokWireProfile(
681
+ providerRow.wireFamilyDefault,
682
+ providerRow.compatibilityProfile as CompatibilityProfile | null,
683
+ );
684
+ const modelsBaseUrl = modelDiscoveryBaseUrl(providerRow.baseUrl);
685
+ let discovered: readonly ModelDefinition[] | null;
686
+ try {
687
+ discovered = await fetchOpenAICompatibleModels({
688
+ baseUrl: modelsBaseUrl,
689
+ providerId,
690
+ headers: {
691
+ ...authHeaders(
692
+ wireProfile.authHeaderShape,
693
+ new TextEncoder().encode(secret),
694
+ "api_key",
695
+ ),
696
+ },
697
+ ...(discoveryFetch ? { fetcher: discoveryFetch as typeof fetch } : {}),
698
+ });
699
+ } catch (error) {
700
+ throw new GatewayError(
701
+ "transport_unavailable",
702
+ 502,
703
+ `Model discovery failed for ${providerId}: ${error instanceof Error ? error.message : "unknown error"}`,
704
+ {},
705
+ "upstream",
706
+ );
707
+ }
708
+ if (discovered && discovered.length > 0) {
709
+ const ctx = wireContextFrom(providerRow);
710
+ return this.persistDiscoveredModels(
711
+ providerId,
712
+ discovered,
713
+ (modelId) => resolveDiscoveredWire(modelId, ctx, undefined, this.defaultEndpoints),
714
+ ctx.endpointPathsByWireFamily,
715
+ providerRow.baseUrl ?? undefined,
716
+ // The generic `/models` fetch guesses a wire from the model id;
717
+ // the row's own profile is what the adapter will enforce, so it
718
+ // decides which guess is admissible.
719
+ wireProfile.supportedWireFamilies,
720
+ );
721
+ }
722
+ }
723
+ }
724
+
725
+ // Two reasons a sync reaches here with nothing dialed, and the operator
726
+ // needs to be told which one. Returning `{synced: 0}` for both made an
727
+ // unsupported provider indistinguishable from a successful fetch that
728
+ // found nothing — the console showed a green "0 synced" for a provider
729
+ // it can never sync.
730
+ const bundled = isBundledProviderId(providerId);
731
+ if (!bundled) {
732
+ // A custom provider has a row and a base URL but no usable credential.
733
+ // The fix is an account, and saying "no listing endpoint" would send the
734
+ // operator looking for a capability the provider may well have.
735
+ throw new GatewayError(
736
+ "invalid_request",
737
+ 400,
738
+ `Add an account for ${providerId} first — model listing needs a credential.`,
739
+ {},
740
+ "cartethyia",
741
+ );
742
+ }
743
+ if (!(await this.usableAccountSecret(tenantId, providerId))) {
744
+ // A bundled provider with an account would have gone through the
745
+ // resolver branch above, so reaching here means none is registered.
746
+ // Add one first, then fetch.
747
+ throw new GatewayError(
748
+ "invalid_request",
749
+ 400,
750
+ `Add an account for ${providerId} before fetching its models.`,
751
+ {},
752
+ "cartethyia",
753
+ );
754
+ }
755
+ // A credential exists and the provider still has no listing path: the
756
+ // catalog is the compiled one, and new models arrive with an update.
757
+ throw new GatewayError(
758
+ "capability_unsupported",
759
+ 400,
760
+ `${providerId} publishes no model-listing endpoint. Its catalog ships with Cartethyia; new models appear with an update.`,
761
+ {},
762
+ "cartethyia",
763
+ );
764
+ } finally {
765
+ outbound.release?.();
766
+ }
767
+ }
768
+
769
+ private async persistDiscoveredModels(
770
+ providerId: string,
771
+ discoveredModels: readonly (string | ModelDefinition)[],
772
+ resolve: (modelId: string) => DiscoveredModelWire,
773
+ endpointOverrides?: Partial<Record<WireFamily, string>>,
774
+ baseUrl?: string,
775
+ supportedWireFamilies?: readonly WireFamily[],
776
+ ): Promise<{ synced: number }> {
777
+ // Static definitions are the injected bundled catalog; the probing service
778
+ // and the console store are both handed the same startup-built map.
779
+ const staticDefinitions: readonly ModelDefinition[] =
780
+ this.bundledModelCatalog.get(providerId) ?? [];
781
+ const staticEndpoints: Partial<Record<WireFamily, string>> = {};
782
+ for (const definition of staticDefinitions) {
783
+ staticEndpoints[definition.wireFamily] ??= definition.endpointPath;
784
+ }
785
+ // Fetch only adds genuinely new models: an id the builtin catalog
786
+ // already owns stays exactly one (builtin) row instead of gaining a
787
+ // discovered shadow that confuses pickers and deletes.
788
+ const builtinIds = new Set(staticDefinitions.map((definition) => definition.modelId));
789
+ // `(modelId, endpointPath)` is the row identity, so a corrected wire family
790
+ // lands on a *new* row and would leave the old one behind as a dead route.
791
+ // Collect this run's pairs and drop the provider's superseded discovered
792
+ // rows for the same ids afterwards.
793
+ const resolvedPairs: Array<{ modelId: string; endpointPath: string }> = [];
794
+ const rows: Array<typeof models.$inferInsert> = [];
795
+ // `(modelId, endpointPath)` is the row identity and the upsert's conflict
796
+ // target, so a listing that repeats a pair (the bespoke discoveries return
797
+ // raw upstream ids) would make a multi-row `ON CONFLICT DO UPDATE` fail with
798
+ // "cannot affect row a second time". Keep the first row per pair, as the
799
+ // builtin seeder does, so one statement can carry a whole chunk.
800
+ const seenPairs = new Set<string>();
801
+ for (const item of discoveredModels) {
802
+ const modelId = typeof item === "string" ? item : item.modelId;
803
+ if (builtinIds.has(modelId)) continue;
804
+ const resolved = resolve(modelId);
805
+ const discDef = typeof item === "string" ? undefined : item;
806
+ const { wireFamily, endpointPath } = applyDiscoveredWire({
807
+ resolvedWireFamily: resolved.wireFamily,
808
+ resolvedEndpointPath: resolved.endpointPath,
809
+ ...(discDef !== undefined
810
+ ? { discoveredWireFamily: discDef.wireFamily, discoveredEndpointPath: discDef.endpointPath }
811
+ : {}),
812
+ staticEndpoints,
813
+ ...(endpointOverrides !== undefined ? { endpointOverrides } : {}),
814
+ ...(supportedWireFamilies !== undefined ? { supportedWireFamilies } : {}),
815
+ ...(baseUrl !== undefined ? { baseUrl } : {}),
816
+ });
817
+ resolvedPairs.push({ modelId, endpointPath });
818
+ const pairKey = `${modelId}\u0000${endpointPath}`;
819
+ if (seenPairs.has(pairKey)) continue;
820
+ seenPairs.add(pairKey);
821
+ const knownDefinition = staticDefinitions.find((definition) => definition.modelId === modelId);
822
+ const sourceUpdatedAt = new Date();
823
+ const metadata = {
824
+ contextLimit: knownDefinition?.contextLimit ?? discDef?.contextLimit ?? null,
825
+ outputLimit: knownDefinition?.outputLimit ?? discDef?.outputLimit ?? null,
826
+ modalities: knownDefinition?.modalities ?? discDef?.modalities ?? null,
827
+ reasoning: knownDefinition?.reasoning ?? discDef?.reasoning ?? false,
828
+ toolCall: knownDefinition?.toolCall ?? discDef?.toolCall ?? false,
829
+ cost: modelsDevCatalog.costFor(providerId, modelId),
830
+ // Discovery builds its rows from the same static catalog, so a fetched
831
+ // row carries the same answer a seeded one does. Null when neither
832
+ // source marked it, which reads as "unknown" rather than "free".
833
+ proOnly: (discDef?.proOnly ?? knownDefinition?.proOnly ?? null),
834
+ // Same shape for the tier the provider states itself (Qoder `is_free`).
835
+ freeTier: (discDef?.freeTier ?? knownDefinition?.freeTier ?? null),
836
+ };
837
+ rows.push({
838
+ providerId,
839
+ modelId,
840
+ wireFamily,
841
+ // A discovery module that classifies an id as a non-chat service (the
842
+ // System One decision API) states it on its own definition; otherwise the
843
+ // static catalog's classification wins, and an unclassified id is `llm`.
844
+ serviceKind: discDef?.serviceKind ?? knownDefinition?.serviceKind ?? "llm",
845
+ endpointPath,
846
+ ...metadata,
847
+ // A discovery module that marks its rows as a free tier gets its own
848
+ // source, because the model list groups on it: a free-tier row is not
849
+ // an ordinary fetched one, and collapsing both into `discovered` lost
850
+ // that distinction at the only point where it is still known.
851
+ source: discDef?.freeTier === true ? "auto_free" : "discovered",
852
+ sourceUpdatedAt,
853
+ enabled: true,
854
+ });
855
+ }
856
+ // One statement per chunk (500, the account-health batch step) instead of
857
+ // one round-trip per row. `excluded.*` reads the proposed row, so the update
858
+ // set is the same columns the per-row upsert wrote.
859
+ for (let offset = 0; offset < rows.length; offset += 500) {
860
+ await this.db
861
+ .insert(models)
862
+ .values(rows.slice(offset, offset + 500))
863
+ .onConflictDoUpdate({
864
+ target: [models.providerId, models.modelId, models.endpointPath],
865
+ set: {
866
+ serviceKind: sql`excluded.service_kind`,
867
+ contextLimit: sql`excluded.context_limit`,
868
+ outputLimit: sql`excluded.output_limit`,
869
+ modalities: sql`excluded.modalities`,
870
+ reasoning: sql`excluded.reasoning`,
871
+ toolCall: sql`excluded.tool_call`,
872
+ cost: sql`excluded.cost`,
873
+ proOnly: sql`excluded.pro_only`,
874
+ freeTier: sql`excluded.free_tier`,
875
+ // The proposed row's own source, not a constant: a free-tier
876
+ // discovery and an ordinary one write the same upsert, and pinning
877
+ // this to `discovered` would relabel every free-tier row on the
878
+ // next sync.
879
+ source: sql`excluded.source`,
880
+ sourceUpdatedAt: sql`excluded.source_updated_at`,
881
+ },
882
+ });
883
+ }
884
+ const synced = rows.length;
885
+ // Drop this provider's discovered rows for the synced ids that are no
886
+ // longer part of the resolved set — the stale `(model, endpoint)` pair left
887
+ // behind when a corrected wire family moved a model to a new path. Rows the
888
+ // operator added by hand (`manual`) and ids outside this run are untouched.
889
+ // `auto_free` is included: it is written by this same sync, so leaving it
890
+ // out would make a free-tier row the one kind of synced row that never gets
891
+ // pruned when its id moves.
892
+ if (resolvedPairs.length > 0) {
893
+ const pairs = resolvedPairs.map(
894
+ (pair) => sql`(${pair.modelId}, ${pair.endpointPath})`,
895
+ );
896
+ const ids = resolvedPairs.map((pair) => pair.modelId);
897
+ await this.db
898
+ .delete(models)
899
+ .where(
900
+ and(
901
+ eq(models.providerId, providerId),
902
+ inArray(models.source, ["discovered", "auto_free"]),
903
+ sql`${models.modelId} IN (${sql.join(
904
+ ids.map((id) => sql`${id}`),
905
+ sql`, `,
906
+ )})`,
907
+ sql`(${models.modelId}, ${models.endpointPath}) NOT IN (${sql.join(pairs, sql`, `)})`,
908
+ ),
909
+ );
910
+ }
911
+ return { synced };
912
+ }
913
+
914
+ private async usableAccountSecret(
915
+ tenantId: string,
916
+ providerId: string,
917
+ ): Promise<string | undefined> {
918
+ return (await this.usableAccount(tenantId, providerId))?.secret;
919
+ }
920
+
921
+ /**
922
+ * The account a credential-scoped discovery will use, with its non-secret
923
+ * auth configuration. Selection is unchanged — first usable row, preferring a
924
+ * tenant-owned one — so discovery and dispatch agree on which account answered.
925
+ */
926
+ private async usableAccount(
927
+ tenantId: string,
928
+ providerId: string,
929
+ ): Promise<{ readonly secret: string; readonly authState?: Readonly<Record<string, unknown>> } | undefined> {
930
+ const rows = await this.db
931
+ .select()
932
+ .from(providerAccounts)
933
+ .where(
934
+ and(
935
+ eq(providerAccounts.providerId, providerId),
936
+ globalOrOwnedBy(providerAccounts.tenantId, tenantId),
937
+ ),
938
+ );
939
+ const now = Date.now();
940
+ const usable = rows
941
+ .filter(
942
+ (row) =>
943
+ row.status !== "disabled" &&
944
+ (row.cooldownUntil === null || row.cooldownUntil.getTime() <= now),
945
+ )
946
+ .sort((left, right) => Number(right.tenantId !== null) - Number(left.tenantId !== null))[0];
947
+ if (!usable?.credentialCiphertext) return undefined;
948
+ const authState = isRecord(usable.authState) ? usable.authState as Readonly<Record<string, unknown>> : undefined;
949
+ return {
950
+ secret: decryptCredentialToString(usable.credentialCiphertext),
951
+ ...(authState === undefined ? {} : { authState }),
952
+ };
953
+ }
954
+
955
+ /**
956
+ * Batched provider-wide probe: the first request runs sequentially (it warms
957
+ * OAuth token caches and connection pools for the provider), the rest run
958
+ * with `Promise.allSettled` bounded to `PROBE_CONCURRENCY`. Each request
959
+ * carries its model route and service kind so native services never fall
960
+ * through to an LLM probe.
961
+ */
962
+ async probeAllModels(
963
+ tenantId: string,
964
+ providerId: string,
965
+ requests: readonly ProbeModelRequest[],
966
+ ): Promise<ProbeAllModelsResult> {
967
+ const results: Array<ProbeAllModelsResult["results"][number]> = [];
968
+ const [firstRequest, ...restRequests] = requests;
969
+ if (firstRequest === undefined) return { providerId, results };
970
+
971
+ const runOne = async (probeRequest: ProbeModelRequest): Promise<void> => {
972
+ const outcome = await this.probeModel(tenantId, providerId, probeRequest);
973
+ results.push({
974
+ modelId: probeRequest.modelId,
975
+ ok: outcome.ok,
976
+ latencyMs: outcome.latencyMs,
977
+ ...(outcome.error ? { error: outcome.error } : {}),
978
+ });
979
+ };
980
+
981
+ await runOne(firstRequest);
982
+ for (let offset = 0; offset < restRequests.length; offset += PROBE_CONCURRENCY) {
983
+ const batch = restRequests.slice(offset, offset + PROBE_CONCURRENCY);
984
+ await Promise.allSettled(batch.map((request) => runOne(request)));
985
+ }
986
+ return { providerId, results };
987
+ }
988
+ async probeAllAccounts(
989
+ tenantId: string,
990
+ providerId: string,
991
+ request: ProbeModelRequest,
992
+ ): Promise<ProbeAllAccountsResult> {
993
+ const accounts = await this.db
994
+ .select({ id: providerAccounts.id })
995
+ .from(providerAccounts)
996
+ .where(
997
+ and(
998
+ eq(providerAccounts.providerId, providerId),
999
+ globalOrOwnedBy(providerAccounts.tenantId, tenantId),
1000
+ ),
1001
+ );
1002
+ const results: ProbeAllAccountsResult["results"][number][] = [];
1003
+ for (let offset = 0; offset < accounts.length; offset += PROBE_ACCOUNT_CONCURRENCY) {
1004
+ const batch = accounts.slice(offset, offset + PROBE_ACCOUNT_CONCURRENCY);
1005
+ const batchResults = await Promise.all(
1006
+ batch.map(async (account) => {
1007
+ const result = await this.probeModel(tenantId, providerId, {
1008
+ ...request,
1009
+ accountId: account.id,
1010
+ });
1011
+ // No health write here: this used to regex the model's own answer text
1012
+ // for "202" and degrade the account on a match. A probe that genuinely
1013
+ // fails already reports through `recordAccountFailure`, which classifies
1014
+ // the real status; matching answer text is not a health signal.
1015
+ return { ...result, accountId: account.id };
1016
+ }),
1017
+ );
1018
+ results.push(...batchResults);
1019
+ }
1020
+ return { providerId, modelId: request.modelId, results };
1021
+ }
1022
+ }