cartethyia 2.5.3 → 2.5.6

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 (456) hide show
  1. package/.env.example +13 -4
  2. package/README.md +2 -2
  3. package/dist/dashboard/assets/{BannedPage-hfCzi-Ux.js → BannedPage-CADDq4IU.js} +1 -1
  4. package/dist/dashboard/assets/BannedPage-CADDq4IU.js.gz +0 -0
  5. package/dist/dashboard/assets/CliToolDetailPage-_z1hID7E.js +1 -0
  6. package/dist/dashboard/assets/CliToolDetailPage-_z1hID7E.js.gz +0 -0
  7. package/dist/dashboard/assets/{CliToolsPage-DEDyKBlC.js → CliToolsPage-DuO1Z7dq.js} +1 -1
  8. package/dist/dashboard/assets/CliToolsPage-DuO1Z7dq.js.gz +0 -0
  9. package/dist/dashboard/assets/CombosPage-DNEPi3xU.js +1 -0
  10. package/dist/dashboard/assets/CombosPage-DNEPi3xU.js.gz +0 -0
  11. package/dist/dashboard/assets/{ConsoleLogPage-bsLuZmYH.js → ConsoleLogPage-BVdpfiz1.js} +1 -1
  12. package/dist/dashboard/assets/ConsoleLogPage-BVdpfiz1.js.gz +0 -0
  13. package/dist/dashboard/assets/{CustomizationPage-_UWhwziJ.js → CustomizationPage-BR5HHvbi.js} +1 -1
  14. package/dist/dashboard/assets/CustomizationPage-BR5HHvbi.js.gz +0 -0
  15. package/dist/dashboard/assets/HealthEventsModal-mUfcknqm.js +1 -0
  16. package/dist/dashboard/assets/HealthEventsModal-mUfcknqm.js.gz +0 -0
  17. package/dist/dashboard/assets/{LoginPage-DDVIfZGW.js → LoginPage-V5faV9V2.js} +1 -1
  18. package/dist/dashboard/assets/LoginPage-V5faV9V2.js.gz +0 -0
  19. package/dist/dashboard/assets/ModelPicker-C7pDb41R.js +1 -0
  20. package/dist/dashboard/assets/ModelPicker-C7pDb41R.js.gz +0 -0
  21. package/dist/dashboard/assets/OverviewPage-BDu20Jdx.js +1 -0
  22. package/dist/dashboard/assets/OverviewPage-BDu20Jdx.js.gz +0 -0
  23. package/dist/dashboard/assets/ProviderDetailPage-Dllr_aFO.js +3 -0
  24. package/dist/dashboard/assets/ProviderDetailPage-Dllr_aFO.js.gz +0 -0
  25. package/dist/dashboard/assets/ProvidersPage-uPiGVTTK.js +1 -0
  26. package/dist/dashboard/assets/ProvidersPage-uPiGVTTK.js.gz +0 -0
  27. package/dist/dashboard/assets/ProxyPage-BaUkO-4O.js +7 -0
  28. package/dist/dashboard/assets/ProxyPage-BaUkO-4O.js.gz +0 -0
  29. package/dist/dashboard/assets/QuotaPage-o0IFAsFb.js +1 -0
  30. package/dist/dashboard/assets/QuotaPage-o0IFAsFb.js.gz +0 -0
  31. package/dist/dashboard/assets/SettingsPage-CYWqbXjp.js +1 -0
  32. package/dist/dashboard/assets/SettingsPage-CYWqbXjp.js.gz +0 -0
  33. package/dist/dashboard/assets/{SetupPage-D6pmQ86L.js → SetupPage-UB690SE2.js} +1 -1
  34. package/dist/dashboard/assets/SetupPage-UB690SE2.js.gz +0 -0
  35. package/dist/dashboard/assets/{SortableList-DWvKiuSl.js → SortableList-BLujdX7N.js} +1 -1
  36. package/dist/dashboard/assets/SortableList-BLujdX7N.js.gz +0 -0
  37. package/dist/dashboard/assets/{StudioPage-CmkUrgdj.js → StudioPage-D5C2raAc.js} +3 -3
  38. package/dist/dashboard/assets/StudioPage-D5C2raAc.js.gz +0 -0
  39. package/dist/dashboard/assets/UsagePage-8uIcxa1W.js +56 -0
  40. package/dist/dashboard/assets/UsagePage-8uIcxa1W.js.gz +0 -0
  41. package/dist/dashboard/assets/api-keys-DQYLQxpW.js +1 -0
  42. package/dist/dashboard/assets/api-keys-DQYLQxpW.js.gz +0 -0
  43. package/dist/dashboard/assets/app-CX_Z8cMs.css +2 -0
  44. package/dist/dashboard/assets/app-CX_Z8cMs.css.gz +0 -0
  45. package/dist/dashboard/assets/{assert-NHwYq95t.js → assert-C9FD_JwJ.js} +1 -1
  46. package/dist/dashboard/assets/assert-C9FD_JwJ.js.gz +0 -0
  47. package/dist/dashboard/assets/{badge-6uzDDvqs.js → badge-CR72dasT.js} +1 -1
  48. package/dist/dashboard/assets/badge-CR72dasT.js.gz +0 -0
  49. package/dist/dashboard/assets/{banner-4_WNg2WZ.js → banner-CI9_eSfO.js} +1 -1
  50. package/dist/dashboard/assets/banner-CI9_eSfO.js.gz +0 -0
  51. package/dist/dashboard/assets/{card--AtFedAq.js → card-DS7CQEsO.js} +1 -1
  52. package/dist/dashboard/assets/card-DS7CQEsO.js.gz +0 -0
  53. package/dist/dashboard/assets/{cli-tools-B3e7dx3N.js → cli-tools-CNqzrfdb.js} +1 -1
  54. package/dist/dashboard/assets/cli-tools-CNqzrfdb.js.gz +0 -0
  55. package/dist/dashboard/assets/{clipboard-button-NreUx6c8.js → clipboard-button-611pSsaX.js} +1 -1
  56. package/dist/dashboard/assets/clipboard-button-611pSsaX.js.gz +0 -0
  57. package/dist/dashboard/assets/{fingerprint-pattern-BWuHtbW4.js → concurrency-BDF0ok7y.js} +1 -1
  58. package/dist/dashboard/assets/concurrency-BDF0ok7y.js.gz +0 -0
  59. package/dist/dashboard/assets/database-Dz9N3zGd.js +1 -0
  60. package/dist/dashboard/assets/database-Dz9N3zGd.js.gz +0 -0
  61. package/dist/dashboard/assets/dialog-k37exjNt.js +1 -0
  62. package/dist/dashboard/assets/dialog-k37exjNt.js.gz +0 -0
  63. package/dist/dashboard/assets/drawer-CX7N8ucR.js +1 -0
  64. package/dist/dashboard/assets/drawer-CX7N8ucR.js.gz +0 -0
  65. package/dist/dashboard/assets/entry-B9Xvtnfj.js +1 -0
  66. package/dist/dashboard/assets/entry-B9Xvtnfj.js.gz +0 -0
  67. package/dist/dashboard/assets/entry-CTP8AyiS.js +1 -0
  68. package/dist/dashboard/assets/entry-CTP8AyiS.js.gz +0 -0
  69. package/dist/dashboard/assets/entry-DxBHnauy.js +4 -0
  70. package/dist/dashboard/assets/entry-DxBHnauy.js.gz +0 -0
  71. package/dist/dashboard/assets/{github-badge-D_eM3IgG.js → github-badge-sltk1qUi.js} +1 -1
  72. package/dist/dashboard/assets/github-badge-sltk1qUi.js.gz +0 -0
  73. package/dist/dashboard/assets/{index-BydyKaDz.js → index-CmxZr14F.js} +2 -2
  74. package/dist/dashboard/assets/index-CmxZr14F.js.gz +0 -0
  75. package/dist/dashboard/assets/{inline-BcNGF8Gr.js → inline-BhY2sSnO.js} +1 -1
  76. package/dist/dashboard/assets/inline-BhY2sSnO.js.gz +0 -0
  77. package/dist/dashboard/assets/{input-BG41et9G.js → input-V1umFiBk.js} +1 -1
  78. package/dist/dashboard/assets/input-V1umFiBk.js.gz +0 -0
  79. package/dist/dashboard/assets/layout-BBOYycyS.js +1 -0
  80. package/dist/dashboard/assets/layout-BBOYycyS.js.gz +0 -0
  81. package/dist/dashboard/assets/{list-row-Gh_W4OR_.js → list-row-1zzZYM0B.js} +1 -1
  82. package/dist/dashboard/assets/list-row-1zzZYM0B.js.gz +0 -0
  83. package/dist/dashboard/assets/{live-BJGYRBnR.js → live-BivGfuUs.js} +1 -1
  84. package/dist/dashboard/assets/live-BivGfuUs.js.gz +0 -0
  85. package/dist/dashboard/assets/model-bans-CmffjLuG.js +1 -0
  86. package/dist/dashboard/assets/model-bans-CmffjLuG.js.gz +0 -0
  87. package/dist/dashboard/assets/{mutation-BkoLM5Ba.js → mutation-46P0oWMe.js} +1 -1
  88. package/dist/dashboard/assets/mutation-46P0oWMe.js.gz +0 -0
  89. package/dist/dashboard/assets/numbers-DkfMR5Qs.js +2 -0
  90. package/dist/dashboard/assets/numbers-DkfMR5Qs.js.gz +0 -0
  91. package/dist/dashboard/assets/{query-keys-D7wX2vM5.js → query-keys-BFhGpwR1.js} +1 -1
  92. package/dist/dashboard/assets/query-keys-BFhGpwR1.js.gz +0 -0
  93. package/dist/dashboard/assets/{routing-CcusqXA7.js → routing-CAb4F37R.js} +1 -1
  94. package/dist/dashboard/assets/routing-CAb4F37R.js.gz +0 -0
  95. package/dist/dashboard/assets/{select-BO6h8yNv.js → select-BYR_5K2c.js} +2 -2
  96. package/dist/dashboard/assets/select-BYR_5K2c.js.gz +0 -0
  97. package/dist/dashboard/assets/{settings-mpmXTgUa.js → settings-DNX9sydi.js} +1 -1
  98. package/dist/dashboard/assets/settings-DNX9sydi.js.gz +0 -0
  99. package/dist/dashboard/assets/{stack-DwLmjUFK.js → stack-BHTfXdz_.js} +1 -1
  100. package/dist/dashboard/assets/stack-BHTfXdz_.js.gz +0 -0
  101. package/dist/dashboard/assets/{state-DYdQmdAl.js → state-mPDRlQkl.js} +1 -1
  102. package/dist/dashboard/assets/state-mPDRlQkl.js.gz +0 -0
  103. package/dist/dashboard/assets/switch-DOmJxg7H.js +1 -0
  104. package/dist/dashboard/assets/switch-DOmJxg7H.js.gz +0 -0
  105. package/dist/dashboard/assets/terminal-0_IjPWTk.js +1 -0
  106. package/dist/dashboard/assets/terminal-0_IjPWTk.js.gz +0 -0
  107. package/dist/dashboard/assets/tokens-COzario9.js +1 -0
  108. package/dist/dashboard/assets/tokens-COzario9.js.gz +0 -0
  109. package/dist/dashboard/assets/use-modal-focus-By7oJoGd.js +1 -0
  110. package/dist/dashboard/assets/use-modal-focus-By7oJoGd.js.gz +0 -0
  111. package/dist/dashboard/assets/{useMutation-w4u4CVue.js → useMutation-Dem0lXQj.js} +1 -1
  112. package/dist/dashboard/assets/useMutation-Dem0lXQj.js.gz +0 -0
  113. package/dist/dashboard/assets/zap-C4EeVW4q.js +1 -0
  114. package/dist/dashboard/assets/zap-C4EeVW4q.js.gz +0 -0
  115. package/dist/dashboard/donation/qris-risun-marke.png +0 -0
  116. package/dist/dashboard/index.html +1 -1
  117. package/dist/dashboard/index.html.gz +0 -0
  118. package/dist/dashboard/precompressed-assets.json +57 -57
  119. package/migrations/README.md +52 -52
  120. package/migrations/lite-sqlite3/0000_baseline.sql +2 -2
  121. package/package.json +96 -95
  122. package/scripts/cli/admin.ts +125 -0
  123. package/scripts/cli/autostart.ts +237 -0
  124. package/scripts/cli/{ui/background.ts → background.ts} +1 -1
  125. package/scripts/cli/{command/console-menu.ts → console-menu.ts} +275 -248
  126. package/scripts/cli/doctor.ts +37 -0
  127. package/scripts/cli/{support/env.ts → env.ts} +99 -99
  128. package/scripts/cli/{gateway/spawn.ts → gateway.ts} +112 -24
  129. package/scripts/cli/instance.ts +224 -0
  130. package/scripts/cli/main.ts +88 -18
  131. package/scripts/cli/{support/platform.ts → platform.ts} +64 -64
  132. package/scripts/cli/port.ts +100 -0
  133. package/scripts/cli/{support/services.ts → services.ts} +88 -88
  134. package/scripts/cli/{command/setup.ts → setup.ts} +35 -34
  135. package/scripts/cli/{command/start.ts → start.ts} +10 -5
  136. package/scripts/cli/{support/startup-signal.ts → startup-signal.ts} +23 -23
  137. package/scripts/cli/tray/index.ts +62 -12
  138. package/scripts/cli/{command/tray.ts → tray.ts} +208 -208
  139. package/scripts/cli/update.ts +40 -0
  140. package/scripts/cli/version.ts +67 -0
  141. package/scripts/cli/{ui/wordmark.ts → wordmark.ts} +58 -58
  142. package/src/boot/app.ts +11 -7
  143. package/src/boot/dependencies.ts +50 -19
  144. package/src/boot/timeout.ts +2 -13
  145. package/src/config/cooldowns.ts +186 -138
  146. package/src/config/network.ts +8 -14
  147. package/src/config/providers.ts +7 -10
  148. package/src/config/runtime.ts +76 -76
  149. package/src/config/security.ts +15 -0
  150. package/src/config/spec.ts +52 -7
  151. package/src/config/telemetry.ts +26 -0
  152. package/src/config/timeouts.ts +0 -6
  153. package/src/console/admin/model-abuse/contracts.ts +28 -6
  154. package/src/console/admin/model-abuse/routes.ts +56 -3
  155. package/src/console/api-keys/bucket-spend.ts +15 -4
  156. package/src/console/api-keys/contracts.ts +410 -414
  157. package/src/console/api-keys/routes.ts +881 -880
  158. package/src/console/auth/credentials.ts +3 -3
  159. package/src/console/auth/setup-service.ts +2 -2
  160. package/src/console/backup/contracts.ts +15 -2
  161. package/src/console/backup/export-endpoint.ts +6 -2
  162. package/src/console/backup/nine-router.ts +2 -5
  163. package/src/console/backup/service.ts +4 -0
  164. package/src/console/backup/store.ts +192 -29
  165. package/src/console/backup/validate.ts +3 -45
  166. package/src/console/cli-tools/fs-ops.ts +487 -487
  167. package/src/console/console-router-guards.ts +1 -1
  168. package/src/console/console-router.ts +8 -0
  169. package/src/console/data-management/contracts.ts +100 -0
  170. package/src/console/data-management/operations.ts +234 -0
  171. package/src/console/data-management/routes.ts +103 -0
  172. package/src/console/domain-registration.ts +45 -1
  173. package/src/console/observability/contracts.ts +524 -485
  174. package/src/console/observability/live.ts +22 -0
  175. package/src/console/observability/store.ts +1138 -1018
  176. package/src/console/observability/usage-dimensions.ts +4 -4
  177. package/src/console/providers/catalog/catalog-projections.ts +6 -7
  178. package/src/console/providers/catalog/contracts.ts +6 -0
  179. package/src/console/providers/catalog/model-operations.ts +5 -2
  180. package/src/console/providers/catalog/provider-operations.ts +24 -4
  181. package/src/console/providers/catalog/public-model-store.ts +8 -11
  182. package/src/console/providers/catalog/routes.ts +7 -0
  183. package/src/console/providers/catalog/store.ts +1553 -1532
  184. package/src/console/providers/oauth/routes.ts +8 -12
  185. package/src/console/quota/account-quota-shared.ts +52 -39
  186. package/src/console/quota/account-quota-tenant.ts +6 -6
  187. package/src/console/quota/quota-view.ts +1 -1
  188. package/src/console/quota/refresh.ts +441 -429
  189. package/src/console/remote-support/contracts.ts +61 -0
  190. package/src/console/remote-support/routes.ts +65 -0
  191. package/src/console/remote-support/service.ts +207 -0
  192. package/src/console/response-compression.ts +0 -3
  193. package/src/console/routing/pools/import-parse.ts +206 -0
  194. package/src/console/routing/pools/operations.ts +543 -501
  195. package/src/console/routing/pools/probe-result.ts +3 -2
  196. package/src/console/routing/pools/probe-verdict.ts +132 -132
  197. package/src/console/routing/pools/routes.ts +181 -164
  198. package/src/console/routing/pools/store.ts +8 -8
  199. package/src/console/settings/contracts.ts +95 -2
  200. package/src/console/settings/store.ts +81 -3
  201. package/src/console/shared/cursor.ts +8 -8
  202. package/src/console/shared/errors.ts +1 -3
  203. package/src/console/updates/routes.ts +16 -0
  204. package/src/main.ts +10 -0
  205. package/src/network/http2-bidi.ts +2 -4
  206. package/src/network/pool/agent.ts +478 -478
  207. package/src/network/pool/selector.ts +5 -4
  208. package/src/network/retry.ts +1 -2
  209. package/src/network/ssrf.ts +5 -4
  210. package/src/observability/event-compaction.ts +317 -0
  211. package/src/observability/event-day-index.ts +440 -0
  212. package/src/observability/event-log.ts +155 -6
  213. package/src/observability/logger.ts +6 -6
  214. package/src/observability/payload-capture.ts +158 -33
  215. package/src/observability/payload-store.ts +82 -61
  216. package/src/observability/redaction.ts +5 -6
  217. package/src/observability/request-notify.ts +43 -0
  218. package/src/observability/telemetry-status.ts +8 -9
  219. package/src/observability/token-speed.ts +5 -4
  220. package/src/persistence/db-mode.ts +7 -7
  221. package/src/persistence/health-event-flush.ts +36 -0
  222. package/src/persistence/readiness.ts +1 -1
  223. package/src/persistence/schema.ts +75 -29
  224. package/src/persistence/shared-cache.ts +1 -2
  225. package/src/persistence/stores/api-key-store.ts +18 -6
  226. package/src/persistence/stores/share-store.ts +4 -3
  227. package/src/persistence/stores/telemetry-store.ts +33 -11
  228. package/src/protocol/canonical-model.ts +11 -3
  229. package/src/protocol/codecs.ts +139 -6
  230. package/src/protocol/gateway-error.ts +6 -4
  231. package/src/protocol/image-clamp.ts +19 -1
  232. package/src/protocol/registry.ts +8 -0
  233. package/src/protocol/request/chat.ts +21 -1
  234. package/src/protocol/request/codex.ts +10 -1
  235. package/src/protocol/request/gemini.ts +9 -2
  236. package/src/protocol/request/messages.ts +18 -7
  237. package/src/protocol/request/responses.ts +9 -1
  238. package/src/protocol/response/chat.ts +34 -4
  239. package/src/protocol/response/codex.ts +7 -4
  240. package/src/protocol/response/dsml.ts +140 -28
  241. package/src/protocol/response/responses.ts +11 -5
  242. package/src/protocol/stream-error-frames.ts +4 -5
  243. package/src/providers/adapters/compatible-adapter.ts +3 -0
  244. package/src/providers/authentication/jwt-validator.ts +9 -10
  245. package/src/providers/authentication/oauth-refresh-service.ts +9 -11
  246. package/src/providers/bundled-discovery-providers.ts +122 -119
  247. package/src/providers/configured-openai-providers.ts +4 -4
  248. package/src/providers/default-registry.ts +5 -4
  249. package/src/providers/discovery/catalog/catalog-blob.ts +18 -18
  250. package/src/providers/discovery/catalog/catalog-format.ts +3 -6
  251. package/src/providers/discovery/models-dev-catalog.ts +354 -354
  252. package/src/providers/discovery/openai-model-discovery.ts +5 -0
  253. package/src/providers/discovery/openrouter-free-roster.ts +15 -17
  254. package/src/providers/discovery/probe-phases.ts +4 -4
  255. package/src/providers/discovery/probe-wire.ts +6 -13
  256. package/src/providers/discovery/probing-service.ts +1117 -1045
  257. package/src/providers/integrations/alibabacp/alibabacp.ts +1 -1
  258. package/src/providers/integrations/antigravity/antigravity-quota.ts +5 -7
  259. package/src/providers/integrations/buddy/buddy-chat-shared.ts +23 -2
  260. package/src/providers/integrations/buddy/buddy-checkin.ts +1 -4
  261. package/src/providers/integrations/buddy/buddy-cli-headers.ts +23 -6
  262. package/src/providers/integrations/buddy/buddy-conversation-headers.ts +0 -1
  263. package/src/providers/integrations/buddy/buddy-identity.ts +52 -0
  264. package/src/providers/integrations/buddy/buddy-media.ts +2 -2
  265. package/src/providers/integrations/buddy/buddy-usage-store.ts +3 -4
  266. package/src/providers/integrations/buddy/codebuddy/quota.ts +2 -13
  267. package/src/providers/integrations/buddy/codebuddy/shared.ts +118 -115
  268. package/src/providers/integrations/buddy/workbuddy/quota.ts +0 -11
  269. package/src/providers/integrations/buddy/workbuddy/shared.ts +5 -2
  270. package/src/providers/integrations/claude/claude.ts +608 -608
  271. package/src/providers/integrations/cline/cline-quota.ts +27 -20
  272. package/src/providers/integrations/cline/cline.ts +537 -539
  273. package/src/providers/integrations/codex/codex-headers.ts +25 -39
  274. package/src/providers/integrations/codex/codex-oauth.ts +1 -1
  275. package/src/providers/integrations/codex/codex.ts +2 -29
  276. package/src/providers/integrations/commandcode/commandcode.ts +674 -675
  277. package/src/providers/integrations/commandcode/discovery.ts +117 -116
  278. package/src/providers/integrations/cursor/cursor-auth.ts +418 -418
  279. package/src/providers/integrations/cursor/cursor-quota.ts +309 -0
  280. package/src/providers/integrations/cursor/cursor-request.ts +616 -471
  281. package/src/providers/integrations/cursor/cursor-session-manager.ts +319 -281
  282. package/src/providers/integrations/cursor/cursor.ts +1333 -960
  283. package/src/providers/integrations/cursor/protocol/messages.ts +25 -0
  284. package/src/providers/integrations/factory/factory-models.ts +3 -5
  285. package/src/providers/integrations/factory/factory.ts +8 -1
  286. package/src/providers/integrations/gemini.ts +6 -1
  287. package/src/providers/integrations/grok/grok-install-id.ts +2 -3
  288. package/src/providers/integrations/grok/grok-quota.ts +279 -177
  289. package/src/providers/integrations/grok/grok-ratelimit-store.ts +73 -0
  290. package/src/providers/integrations/grok/grok-ratelimit.ts +218 -0
  291. package/src/providers/integrations/grok/grok.ts +6 -7
  292. package/src/providers/integrations/hermes.ts +14 -18
  293. package/src/providers/integrations/kiro/kiro-discovery.ts +1 -1
  294. package/src/providers/integrations/kiro/kiro.ts +1 -1
  295. package/src/providers/integrations/mistral.ts +6 -6
  296. package/src/providers/integrations/opencode/fingerprint.ts +3 -3
  297. package/src/providers/integrations/opencode/provider.ts +19 -20
  298. package/src/providers/integrations/qoder/qoder-catalog.ts +450 -450
  299. package/src/providers/integrations/qoder/qoder-oauth.ts +388 -310
  300. package/src/providers/integrations/qoder/qoder-quota.ts +192 -0
  301. package/src/providers/integrations/qoder/qoder.ts +884 -865
  302. package/src/providers/integrations/shared/connect.ts +6 -6
  303. package/src/providers/integrations/trae/trae-catalog.ts +1 -2
  304. package/src/providers/integrations/trae/trae-quota.ts +384 -0
  305. package/src/providers/integrations/trae/trae.ts +2 -2
  306. package/src/providers/integrations/zcode/oauth.ts +6 -1
  307. package/src/providers/operations/account-health-service.ts +75 -27
  308. package/src/providers/operations/account-reset-service.ts +1 -1
  309. package/src/providers/operations/client-versions.ts +6 -6
  310. package/src/providers/operations/custom-cli-headers.ts +3 -4
  311. package/src/providers/operations/provider-catalog-seeder.ts +95 -95
  312. package/src/providers/operations/provider-catalog-service.ts +3 -4
  313. package/src/providers/provider-contracts.ts +7 -0
  314. package/src/providers/provider-metadata.ts +10 -8
  315. package/src/providers/provider-registry.ts +6 -7
  316. package/src/providers/provider-usage.ts +1 -1
  317. package/src/providers/quota/quota-support.ts +8 -0
  318. package/src/security/abuse.ts +15 -10
  319. package/src/security/access-control.ts +144 -146
  320. package/src/security/admission/contracts.ts +11 -0
  321. package/src/security/admission/in-memory-store.ts +4 -3
  322. package/src/security/admission/lease-sweep.ts +6 -6
  323. package/src/security/admission/reasons.ts +5 -1
  324. package/src/security/admission/redis-store.ts +211 -210
  325. package/src/security/admission/service.ts +4 -0
  326. package/src/security/api-key-auth.ts +27 -17
  327. package/src/security/ip-boundary.ts +155 -15
  328. package/src/security/model-abuse.ts +366 -94
  329. package/src/security/outbound-headers.ts +3 -4
  330. package/src/shared/sleep.ts +4 -0
  331. package/src/transport/dispatch/attempt-finalize.ts +47 -5
  332. package/src/transport/dispatch/attempt-loop.ts +531 -530
  333. package/src/transport/dispatch/generation.ts +6 -0
  334. package/src/transport/dispatch/proxy-request.ts +24 -1
  335. package/src/transport/dispatch/ratelimit-capture.ts +37 -0
  336. package/src/transport/dispatch/stream-bridge.ts +1 -3
  337. package/src/transport/dispatch/streaming/finalize.ts +1 -1
  338. package/src/transport/dispatch/streaming/stream.ts +19 -6
  339. package/src/transport/dispatch/upstream.ts +9 -0
  340. package/src/transport/failure-policy.ts +162 -8
  341. package/src/transport/guards/auth-guard.ts +7 -4
  342. package/src/transport/guards/concurrency-guard.ts +0 -5
  343. package/src/transport/guards/error-lifecycle.ts +13 -6
  344. package/src/transport/guards/pipeline.ts +15 -3
  345. package/src/transport/guards/request-context.ts +8 -2
  346. package/src/transport/request/inflight.ts +5 -6
  347. package/src/transport/request/preparer.ts +9 -8
  348. package/src/transport/request/state.ts +21 -8
  349. package/src/transport/request/tenant-preferences.ts +60 -0
  350. package/src/transport/routing/admission.ts +4 -0
  351. package/src/transport/routing/route-catalog.ts +65 -10
  352. package/src/transport/routing/route-model.ts +46 -14
  353. package/src/transport/routing/router.ts +21 -14
  354. package/src/transport/surface/completion.ts +2 -3
  355. package/src/transport/surface/dialects.ts +5 -6
  356. package/src/transport/surface/responses/encode.ts +38 -0
  357. package/src/transport/surface/responses/parse.ts +4 -0
  358. package/src/transport/translation/capabilities.ts +2 -3
  359. package/src/transport/translation/quirks.ts +3 -3
  360. package/src/workers/buddy-growth-tasks.ts +5 -10
  361. package/src/workers/daily-checkin.ts +17 -23
  362. package/src/workers/quota-refresh-sweep.ts +26 -26
  363. package/dist/dashboard/assets/BannedPage-hfCzi-Ux.js.gz +0 -0
  364. package/dist/dashboard/assets/CliToolDetailPage-DLGqYEwI.js +0 -1
  365. package/dist/dashboard/assets/CliToolDetailPage-DLGqYEwI.js.gz +0 -0
  366. package/dist/dashboard/assets/CliToolsPage-DEDyKBlC.js.gz +0 -0
  367. package/dist/dashboard/assets/CombosPage-CfuYiQ8Z.js +0 -1
  368. package/dist/dashboard/assets/CombosPage-CfuYiQ8Z.js.gz +0 -0
  369. package/dist/dashboard/assets/ConsoleLogPage-bsLuZmYH.js.gz +0 -0
  370. package/dist/dashboard/assets/CustomizationPage-_UWhwziJ.js.gz +0 -0
  371. package/dist/dashboard/assets/HealthEventsModal-Cjfc5WFS.js +0 -1
  372. package/dist/dashboard/assets/HealthEventsModal-Cjfc5WFS.js.gz +0 -0
  373. package/dist/dashboard/assets/LoginPage-DDVIfZGW.js.gz +0 -0
  374. package/dist/dashboard/assets/ModelPicker-CWkTUtpX.js +0 -1
  375. package/dist/dashboard/assets/ModelPicker-CWkTUtpX.js.gz +0 -0
  376. package/dist/dashboard/assets/OverviewPage-B53YYMng.js +0 -1
  377. package/dist/dashboard/assets/OverviewPage-B53YYMng.js.gz +0 -0
  378. package/dist/dashboard/assets/ProviderDetailPage-B0KaAQf0.js +0 -3
  379. package/dist/dashboard/assets/ProviderDetailPage-B0KaAQf0.js.gz +0 -0
  380. package/dist/dashboard/assets/ProvidersPage-Dx31DgIO.js +0 -1
  381. package/dist/dashboard/assets/ProvidersPage-Dx31DgIO.js.gz +0 -0
  382. package/dist/dashboard/assets/ProxyPage-BpnR4kXT.js +0 -7
  383. package/dist/dashboard/assets/ProxyPage-BpnR4kXT.js.gz +0 -0
  384. package/dist/dashboard/assets/QuotaPage-BpOQCnun.js +0 -1
  385. package/dist/dashboard/assets/QuotaPage-BpOQCnun.js.gz +0 -0
  386. package/dist/dashboard/assets/SettingsPage-DV1eo2J_.js +0 -1
  387. package/dist/dashboard/assets/SettingsPage-DV1eo2J_.js.gz +0 -0
  388. package/dist/dashboard/assets/SetupPage-D6pmQ86L.js.gz +0 -0
  389. package/dist/dashboard/assets/SortableList-DWvKiuSl.js.gz +0 -0
  390. package/dist/dashboard/assets/StudioPage-CmkUrgdj.js.gz +0 -0
  391. package/dist/dashboard/assets/UsagePage-B47uOydr.js +0 -56
  392. package/dist/dashboard/assets/UsagePage-B47uOydr.js.gz +0 -0
  393. package/dist/dashboard/assets/api-keys-uNywYlfv.js +0 -1
  394. package/dist/dashboard/assets/api-keys-uNywYlfv.js.gz +0 -0
  395. package/dist/dashboard/assets/app-DxivpfYG.css +0 -2
  396. package/dist/dashboard/assets/app-DxivpfYG.css.gz +0 -0
  397. package/dist/dashboard/assets/assert-NHwYq95t.js.gz +0 -0
  398. package/dist/dashboard/assets/badge-6uzDDvqs.js.gz +0 -0
  399. package/dist/dashboard/assets/banner-4_WNg2WZ.js.gz +0 -0
  400. package/dist/dashboard/assets/card--AtFedAq.js.gz +0 -0
  401. package/dist/dashboard/assets/cli-tools-B3e7dx3N.js.gz +0 -0
  402. package/dist/dashboard/assets/clipboard-button-NreUx6c8.js.gz +0 -0
  403. package/dist/dashboard/assets/dialog-BtpsJU98.js +0 -1
  404. package/dist/dashboard/assets/dialog-BtpsJU98.js.gz +0 -0
  405. package/dist/dashboard/assets/download-A0SW9OCW.js +0 -1
  406. package/dist/dashboard/assets/download-A0SW9OCW.js.gz +0 -0
  407. package/dist/dashboard/assets/drawer-WwwtzihF.js +0 -1
  408. package/dist/dashboard/assets/drawer-WwwtzihF.js.gz +0 -0
  409. package/dist/dashboard/assets/entry-CkryrIea.js +0 -4
  410. package/dist/dashboard/assets/entry-CkryrIea.js.gz +0 -0
  411. package/dist/dashboard/assets/entry-TJIye_jT.js +0 -1
  412. package/dist/dashboard/assets/entry-TJIye_jT.js.gz +0 -0
  413. package/dist/dashboard/assets/entry-jy9_4-Ke.js +0 -1
  414. package/dist/dashboard/assets/entry-jy9_4-Ke.js.gz +0 -0
  415. package/dist/dashboard/assets/fingerprint-pattern-BWuHtbW4.js.gz +0 -0
  416. package/dist/dashboard/assets/github-badge-D_eM3IgG.js.gz +0 -0
  417. package/dist/dashboard/assets/index-BydyKaDz.js.gz +0 -0
  418. package/dist/dashboard/assets/inline-BcNGF8Gr.js.gz +0 -0
  419. package/dist/dashboard/assets/input-BG41et9G.js.gz +0 -0
  420. package/dist/dashboard/assets/layout-C8mer_Ak.js +0 -1
  421. package/dist/dashboard/assets/layout-C8mer_Ak.js.gz +0 -0
  422. package/dist/dashboard/assets/list-row-Gh_W4OR_.js.gz +0 -0
  423. package/dist/dashboard/assets/live-BJGYRBnR.js.gz +0 -0
  424. package/dist/dashboard/assets/model-bans-DeVLDA7I.js +0 -1
  425. package/dist/dashboard/assets/model-bans-DeVLDA7I.js.gz +0 -0
  426. package/dist/dashboard/assets/mutation-BkoLM5Ba.js.gz +0 -0
  427. package/dist/dashboard/assets/numbers-Bs4LOOuf.js +0 -2
  428. package/dist/dashboard/assets/numbers-Bs4LOOuf.js.gz +0 -0
  429. package/dist/dashboard/assets/query-keys-D7wX2vM5.js.gz +0 -0
  430. package/dist/dashboard/assets/routing-CcusqXA7.js.gz +0 -0
  431. package/dist/dashboard/assets/select-BO6h8yNv.js.gz +0 -0
  432. package/dist/dashboard/assets/settings-mpmXTgUa.js.gz +0 -0
  433. package/dist/dashboard/assets/sparkles-BRaRUxAU.js +0 -1
  434. package/dist/dashboard/assets/sparkles-BRaRUxAU.js.gz +0 -0
  435. package/dist/dashboard/assets/stack-DwLmjUFK.js.gz +0 -0
  436. package/dist/dashboard/assets/state-DYdQmdAl.js.gz +0 -0
  437. package/dist/dashboard/assets/switch-CidSfbG7.js +0 -1
  438. package/dist/dashboard/assets/switch-CidSfbG7.js.gz +0 -0
  439. package/dist/dashboard/assets/terminal-Bd4ee8TA.js +0 -1
  440. package/dist/dashboard/assets/terminal-Bd4ee8TA.js.gz +0 -0
  441. package/dist/dashboard/assets/tokens-DDxMbSRL.js +0 -1
  442. package/dist/dashboard/assets/tokens-DDxMbSRL.js.gz +0 -0
  443. package/dist/dashboard/assets/use-presence-BSZEBx7U.js +0 -1
  444. package/dist/dashboard/assets/use-presence-BSZEBx7U.js.gz +0 -0
  445. package/dist/dashboard/assets/useMutation-w4u4CVue.js.gz +0 -0
  446. package/scripts/cli/command/admin.ts +0 -65
  447. package/scripts/cli/command/doctor.ts +0 -257
  448. package/scripts/cli/command/update.ts +0 -23
  449. package/scripts/cli/gateway/drain-token.ts +0 -78
  450. package/scripts/cli/gateway/instance-lock.ts +0 -105
  451. package/scripts/cli/gateway/stop.ts +0 -51
  452. package/scripts/cli/support/bun.ts +0 -27
  453. package/scripts/cli/support/version.ts +0 -37
  454. /package/dist/dashboard/assets/{app-CafWtVXA.js → app-BIt446yE.js} +0 -0
  455. /package/dist/dashboard/assets/{app-CafWtVXA.js.gz → app-BIt446yE.js.gz} +0 -0
  456. /package/scripts/cli/{ui/prompt.ts → prompt.ts} +0 -0
@@ -1,1045 +1,1117 @@
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
- // Body capture on, unlike a live dispatch: a probe is one-shot and
278
- // operator-initiated, so it exists to be inspected. With it off, the
279
- // provider-response tee resolved to null and every probe row reported
280
- // the middle two stages as absent — a constant, not a measurement,
281
- // which made the stage markers useless for the diagnosis they exist
282
- // for. The 256 KiB cap in the tee bounds the cost.
283
- captureProviderExchange(outbound.fetch, probeCapture, true),
284
- );
285
- try {
286
- if (serviceKind === "websearch") {
287
- if (typeof adapter.websearch !== "function") {
288
- throw new GatewayError(
289
- "capability_unsupported",
290
- 400,
291
- `Provider ${providerId} has no web search transport`,
292
- );
293
- }
294
- const searchProbeQuery = request.prompt?.trim() || "Cartethyia gateway probe";
295
- const response = await adapter.websearch(
296
- { model: modelId, query: searchProbeQuery, max_results: 3 },
297
- candidate,
298
- {
299
- credential,
300
- deadline: startedAt + 30_000,
301
- abort_signal: probeSignal,
302
- outbound_fetch: probeFetch,
303
- },
304
- );
305
- if (ttfbMs === undefined) ttfbMs = Date.now() - startedAt;
306
- searchResults = response.results.map((hit) => ({
307
- title: hit.title,
308
- url: hit.url,
309
- snippet: hit.snippet,
310
- }));
311
- if (!response.results || response.results.length === 0) {
312
- dispatchError = new GatewayError("platform_unavailable", 502, "web search returned no results");
313
- }
314
- } else if (serviceKind === "systemone") {
315
- if (typeof adapter.systemone !== "function") {
316
- throw new GatewayError(
317
- "capability_unsupported",
318
- 400,
319
- `Provider ${providerId} has no System One transport`,
320
- );
321
- }
322
- const response = await adapter.systemone(
323
- {
324
- model: modelId,
325
- state: "Customer: I was charged twice for my order this morning.",
326
- questions: {
327
- probe: {
328
- type: "noul",
329
- instructions: "Is the customer reporting a billing problem?",
330
- },
331
- },
332
- },
333
- candidate,
334
- {
335
- credential,
336
- deadline: startedAt + 30_000,
337
- abort_signal: probeSignal,
338
- outbound_fetch: probeFetch,
339
- },
340
- );
341
- if (ttfbMs === undefined) ttfbMs = Date.now() - startedAt;
342
- const raw = await response.text().catch(() => "");
343
- if (!response.ok) {
344
- dispatchError = new GatewayError(
345
- response.status >= 500 ? "platform_unavailable" : "invalid_request",
346
- response.status,
347
- `System One upstream error: ${raw.slice(0, 240)}`,
348
- );
349
- } else {
350
- let parsed: unknown;
351
- try {
352
- parsed = raw ? JSON.parse(raw) : null;
353
- } catch {
354
- dispatchError = new GatewayError("platform_unavailable", 502, "System One returned invalid JSON");
355
- }
356
- if (parsed !== null && parsed !== undefined) {
357
- const answers =
358
- typeof parsed === "object" ? (parsed as Record<string, unknown>).answers : undefined;
359
- const hasAnswers =
360
- answers !== undefined &&
361
- answers !== null &&
362
- typeof answers === "object" &&
363
- Object.keys(answers as Record<string, unknown>).length > 0;
364
- if (!hasAnswers) {
365
- dispatchError = new GatewayError(
366
- "platform_unavailable",
367
- 502,
368
- "System One returned no answers",
369
- );
370
- }
371
- }
372
- }
373
- } else {
374
- const dispatchProbe = async (request: CanonicalRequest): Promise<void> => {
375
- capturedRequest = request;
376
- for await (const event of adapter.dispatch(request, candidate, {
377
- credential,
378
- deadline: startedAt + 30_000,
379
- abort_signal: probeSignal,
380
- outbound_fetch: probeFetch,
381
- // A bidi-only provider (Cursor's Connect-RPC Run) has no `fetch`
382
- // transport at all: without the HTTP/2 binding the probe fails with
383
- // "requires validated HTTP/2 egress" while live dispatch — which
384
- // always supplies it — succeeds.
385
- ...(outbound.http2Bidi === undefined ? {} : { outbound_http2: outbound.http2Bidi }),
386
- })) {
387
- if (ttfbMs === undefined) ttfbMs = Date.now() - startedAt;
388
- events.push(event);
389
- }
390
- };
391
- try {
392
- await dispatchProbe(canonicalRequest);
393
- if (!hasMeaningfulOutput(events)) throw new Error("probe response content empty");
394
- } catch (error) {
395
- const message = error instanceof Error ? error.message : String(error);
396
- if (!/empty\s+(?:response\s+)?content|response\s+content\s+empty/i.test(message)) {
397
- throw error;
398
- }
399
- // Some reasoning models spend the short probe budget before producing
400
- // visible text. Retry with a larger output allowance, and stream when
401
- // the first attempt did not — a streamed probe keeps the connection
402
- // open while the model reasons instead of waiting for a complete body.
403
- // Generic: no provider/model id special case is needed.
404
- events.length = 0;
405
- ttfbMs = undefined;
406
- await dispatchProbe({
407
- ...canonicalRequest,
408
- stream: true,
409
- generation_controls: {
410
- ...canonicalRequest.generation_controls,
411
- max_tokens: 1024,
412
- max_output_tokens: 1024,
413
- },
414
- });
415
- }
416
- }
417
- } finally {
418
- outbound.release?.();
419
- }
420
- } catch (error) {
421
- dispatchError = error;
422
- }
423
- const featureFailure = grok407ProbeFailure({
424
- providerId,
425
- prompt: request.prompt,
426
- events,
427
- dispatchError,
428
- });
429
- if (featureFailure !== undefined) dispatchError = featureFailure;
430
-
431
- const upstreamStatusCode =
432
- dispatchError instanceof GatewayError
433
- ? typeof dispatchError.details.upstreamStatus === "number"
434
- ? dispatchError.details.upstreamStatus
435
- : typeof dispatchError.details.providerStatus === "number"
436
- ? dispatchError.details.providerStatus
437
- : undefined
438
- : undefined;
439
- await recordProbeHealth({
440
- db: this.db,
441
- invalidator: this.snapshotInvalidator,
442
- accountId,
443
- dispatchError,
444
- // Passed so a refusal delivered as a terminal event — not as a thrown
445
- // error — still reaches the health machine. Without it, Cursor's
446
- // `resource_exhausted` left `dispatchError` undefined and the probe
447
- // recovered an exhausted account.
448
- ...(events === undefined ? {} : { events }),
449
- modelId,
450
- upstreamStatusCode,
451
- });
452
-
453
- const latencyMs = Date.now() - startedAt;
454
- // A native-service probe carries no canonical events: its verdict is the
455
- // decision response itself (no dispatch error + answers present), which the
456
- // branch above already encoded into `dispatchError`. Web search is a
457
- // native service too — routing it through the LLM verdict demanded a
458
- // `terminal` event the search transport never emits, so a search that
459
- // returned hits was reported as a failure with an empty message.
460
- const { ok, errorMessage, usage } =
461
- serviceKind === "systemone" || serviceKind === "websearch"
462
- ? {
463
- ok: dispatchError === undefined,
464
- errorMessage:
465
- dispatchError === undefined
466
- ? undefined
467
- : dispatchError instanceof GatewayError
468
- ? `Search provider ${providerId}/${modelId} — ${dispatchError.message} (HTTP ${dispatchError.status})`
469
- : dispatchError instanceof Error
470
- ? `${providerId}/${modelId} — ${dispatchError.message}`
471
- : String(dispatchError),
472
- usage: undefined,
473
- }
474
- : computeProbeVerdict({
475
- events,
476
- dispatchError,
477
- wireFamily,
478
- });
479
-
480
- const requestId = crypto.randomUUID();
481
-
482
- // Token speed shared with live traffic (`computeTokensPerSec`): probes
483
- // record TTFT+latency only, so they report end-to-end effective speed.
484
- const tokensPerSec = computeTokensPerSec({
485
- outputTokens: usage?.output_tokens,
486
- latencyMs,
487
- });
488
-
489
- // Probe telemetry rides the same batch buffer as live traffic. Keep the
490
- // probe bodies linked by request_id so the console can inspect the exact
491
- // canonical request and provider event stream that produced the result.
492
- this.telemetryBuffer?.enqueue({
493
- tenantId,
494
- requestId,
495
- sourceSurface,
496
- requestedModel: modelId,
497
- endpoint: endpointPath,
498
- providerId,
499
- ...(accountId ? { accountId } : {}),
500
- ...(networkPoolId ? { networkPoolId } : {}),
501
- latencyMs,
502
- ...(ttfbMs !== undefined ? { ttfbMs } : {}),
503
- stream: capturedRequest.stream,
504
- status: ok ? "completed" : "failed",
505
- ...(ok ? {} : { errorCategory: errorMessage ?? "probe_failed" }),
506
- ...(usage ? { usage } : {}),
507
- ...(tokensPerSec !== undefined ? { tokensPerSec } : {}),
508
- });
509
- if (this.telemetryBuffer && payloadCaptureEnabled) {
510
- // All four surfaces, so the row's stage markers mean the same thing here
511
- // as they do on a live request: which hop actually produced bytes.
512
- const providerRequest = probeCapture?.request ?? null;
513
- const providerResponse = await probeCapture?.response.catch(() => null) ?? null;
514
- await this.payloadCapture
515
- .capture({
516
- tenantId,
517
- requestId,
518
- requestBody: capturedRequest,
519
- // `events` is what the *client* receives, so it belongs in the client
520
- // surface. Passing it as `responseBody` lit the client bit on probes
521
- // whose event list was empty, over a body that was never captured.
522
- // The legacy alias is explicitly absent here; `null` leaves bit 8 to
523
- // be decided by the real client body alone.
524
- responseBody: null,
525
- ...(events === undefined ? {} : { clientResponseBody: events }),
526
- ...(providerRequest === null ? {} : { providerRequestBody: providerRequest }),
527
- ...(providerResponse === null ? {} : { providerResponseBody: providerResponse }),
528
- scope: "tenant",
529
- tenantOptIn: true,
530
- })
531
- .catch(() => undefined);
532
- }
533
-
534
- const sample = extractSample(events);
535
- const reportedModel = await probeCapture?.reportedModel?.catch(() => undefined);
536
- return {
537
- ...(accountId ? { accountId } : {}),
538
- ok,
539
- latencyMs,
540
- ...(ttfbMs !== undefined ? { ttfbMs } : {}),
541
- ...(upstreamStatusCode === undefined ? {} : { statusCode: upstreamStatusCode }),
542
- ...(sample ? { sample } : {}),
543
- ...(reportedModel === undefined ? {} : { reportedModel }),
544
- ...(searchResults !== undefined && searchResults.length > 0 ? { searchResults } : {}),
545
- ...(errorMessage ? { error: errorMessage } : {}),
546
- };
547
- }
548
-
549
- /**
550
- * Ad-hoc connectivity test for a not-yet-persisted BYOK provider.
551
- *
552
- * Dispatches a real `GET <base>/models` through the same SSRF-validated
553
- * outbound fetch live traffic uses, stamping the credential with the auth
554
- * header the chosen wire family actually reads (`x-api-key` for Messages,
555
- * `Authorization: Bearer` otherwise) plus that wire's CLI identity headers.
556
- * Reachability is the contract: any HTTP response proves the operator's base
557
- * URL, credential, and egress path work — a 4xx is reported with its status
558
- * rather than disguised as a transport failure.
559
- */
560
- async testByokConnection(
561
- tenantId: string,
562
- request: ByokConnectionTestRequest,
563
- ): Promise<ByokConnectionTestResult> {
564
- const startedAt = Date.now();
565
- const requestedWire = request.wireFamily;
566
- const wireFamily: WireFamily = (WIRE_FAMILIES as readonly string[]).includes(requestedWire)
567
- ? (requestedWire as WireFamily)
568
- : "chat";
569
- // An operator who pasted a full endpoint (`…/anthropic/v1/messages`) gets it
570
- // stripped before the model list is derived, and told so. Probing the
571
- // corrected root is what makes the result actionable: the alternative is a
572
- // bare 404 against a path the gateway built from their own typo.
573
- const submitted = splitEndpointBaseUrl(request.baseUrl);
574
- const warning =
575
- submitted.stripped === ""
576
- ? undefined
577
- : `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.`;
578
- const url = modelListUrl(request.baseUrl);
579
- const outbound = asOutboundBinding(await this.outboundFetchFor(tenantId));
580
- const probeFetch = createProbeFetch(outbound.fetch);
581
- try {
582
- const headers: Record<string, string> = {
583
- accept: "application/json",
584
- ...authHeaders(
585
- byokAuthHeaderShape([wireFamily]),
586
- new TextEncoder().encode(request.apiKey),
587
- request.apiKey.trim().length > 0 ? "api_key" : "none",
588
- ),
589
- ...(request.cliIdentity === false ? {} : resolveCustomCliHeaders(wireFamily)),
590
- ...(request.extraHeaders ?? {}),
591
- };
592
- const response = await probeFetch(url, {
593
- method: "GET",
594
- headers,
595
- signal: AbortSignal.timeout(15_000),
596
- });
597
- const latencyMs = Date.now() - startedAt;
598
- if (!response.ok) {
599
- return {
600
- ok: false,
601
- latencyMs,
602
- statusCode: response.status,
603
- error: `Upstream rejected the connection test with HTTP ${response.status}`,
604
- ...(warning === undefined ? {} : { warning }),
605
- };
606
- }
607
- // A body is nice to have but not required: some gateways answer `/models`
608
- // with an empty envelope. Read it defensively so a non-JSON body cannot
609
- // turn a successful reachability check into a failure.
610
- let modelCount: number | undefined;
611
- try {
612
- const payload = (await response.json()) as unknown;
613
- const entries = Array.isArray(payload)
614
- ? payload
615
- : isRecord(payload) && Array.isArray(payload["data"])
616
- ? payload["data"]
617
- : isRecord(payload) && Array.isArray(payload["models"])
618
- ? payload["models"]
619
- : undefined;
620
- if (entries) modelCount = entries.length;
621
- } catch {
622
- // Non-JSON success body: reachability already proven by the 2xx.
623
- }
624
- return {
625
- ok: true,
626
- latencyMs,
627
- statusCode: response.status,
628
- ...(modelCount === undefined ? {} : { modelCount }),
629
- ...(warning === undefined ? {} : { warning }),
630
- };
631
- } catch (error) {
632
- return {
633
- ok: false,
634
- latencyMs: Date.now() - startedAt,
635
- error: error instanceof Error ? error.message : "Connection test failed",
636
- ...(warning === undefined ? {} : { warning }),
637
- };
638
- } finally {
639
- outbound.release?.();
640
- }
641
- }
642
-
643
- async syncModels(tenantId: string, providerId: string): Promise<{ synced: number }> {
644
- const outbound = asOutboundBinding(await this.outboundFetchFor(tenantId, providerId));
645
- try {
646
- const discoveryFetch = asFetchLike(outbound.fetch);
647
- const resolver = await this.providerRegistry.resolveModelDiscovery(providerId);
648
- if (resolver) {
649
- // An account is used when one is usable, and the fetch is attempted
650
- // either way. Gating on a credential only made the common case
651
- // unreachable: most providers list their models without auth, and the
652
- // ones that do not answer 401 themselves, which names the actual
653
- // problem where a local 409 named a missing account the operator may
654
- // not even need.
655
- const account = await this.usableAccount(tenantId, providerId);
656
- const secret = account?.secret;
657
- const providerRow = await this.loadProviderWireRow(providerId);
658
- const effectiveBaseUrl = providerRow?.baseUrl ?? providerBaseUrl(providerId) ?? "";
659
- let discovered: readonly ModelDefinition[] | null;
660
- try {
661
- discovered = await resolver({
662
- baseUrl: effectiveBaseUrl,
663
- credential: secret ?? "",
664
- ...(discoveryFetch ? { fetcher: discoveryFetch } : {}),
665
- ...(account?.authState === undefined ? {} : { authState: account.authState }),
666
- });
667
- } catch (error) {
668
- throw new GatewayError(
669
- "transport_unavailable",
670
- 502,
671
- `Model discovery failed for ${providerId}: ${error instanceof Error ? error.message : "unknown error"}`,
672
- {},
673
- "upstream",
674
- );
675
- }
676
- if (!discovered) {
677
- throw new GatewayError(
678
- "transport_unavailable",
679
- 502,
680
- `Model discovery failed for ${providerId}: endpoint returned no usable models`,
681
- {},
682
- "upstream",
683
- );
684
- }
685
- const ctx = wireContextFrom(providerRow);
686
- return this.persistDiscoveredModels(
687
- providerId,
688
- discovered,
689
- (modelId) => resolveDiscoveredWire(modelId, ctx, undefined, this.defaultEndpoints),
690
- ctx.endpointPathsByWireFamily,
691
- effectiveBaseUrl,
692
- );
693
- }
694
-
695
- const providerRow = await this.loadProviderWireRow(providerId);
696
- if (providerRow?.baseUrl) {
697
- const secret = await this.usableAccountSecret(tenantId, providerId);
698
- if (secret) {
699
- // An Anthropic-compatible root reads `x-api-key`, not bearer auth,
700
- // and a bare host needs its `/v1` segment before `/models` joins on.
701
- // Both are derived from the same wire profile dispatch uses, so
702
- // auto-fetch behaves identically for either flavor of custom provider.
703
- const wireProfile = resolveByokWireProfile(
704
- providerRow.wireFamilyDefault,
705
- providerRow.compatibilityProfile as CompatibilityProfile | null,
706
- );
707
- const modelsBaseUrl = modelDiscoveryBaseUrl(providerRow.baseUrl);
708
- let discovered: readonly ModelDefinition[] | null;
709
- try {
710
- discovered = await fetchOpenAICompatibleModels({
711
- baseUrl: modelsBaseUrl,
712
- providerId,
713
- headers: {
714
- ...authHeaders(
715
- wireProfile.authHeaderShape,
716
- new TextEncoder().encode(secret),
717
- "api_key",
718
- ),
719
- },
720
- ...(discoveryFetch ? { fetcher: discoveryFetch as typeof fetch } : {}),
721
- });
722
- } catch (error) {
723
- throw new GatewayError(
724
- "transport_unavailable",
725
- 502,
726
- `Model discovery failed for ${providerId}: ${error instanceof Error ? error.message : "unknown error"}`,
727
- {},
728
- "upstream",
729
- );
730
- }
731
- if (discovered && discovered.length > 0) {
732
- const ctx = wireContextFrom(providerRow);
733
- return this.persistDiscoveredModels(
734
- providerId,
735
- discovered,
736
- (modelId) => resolveDiscoveredWire(modelId, ctx, undefined, this.defaultEndpoints),
737
- ctx.endpointPathsByWireFamily,
738
- providerRow.baseUrl ?? undefined,
739
- // The generic `/models` fetch guesses a wire from the model id;
740
- // the row's own profile is what the adapter will enforce, so it
741
- // decides which guess is admissible.
742
- wireProfile.supportedWireFamilies,
743
- );
744
- }
745
- }
746
- }
747
-
748
- // Two reasons a sync reaches here with nothing dialed, and the operator
749
- // needs to be told which one. Returning `{synced: 0}` for both made an
750
- // unsupported provider indistinguishable from a successful fetch that
751
- // found nothing — the console showed a green "0 synced" for a provider
752
- // it can never sync.
753
- const bundled = isBundledProviderId(providerId);
754
- if (!bundled) {
755
- // A custom provider has a row and a base URL but no usable credential.
756
- // The fix is an account, and saying "no listing endpoint" would send the
757
- // operator looking for a capability the provider may well have.
758
- throw new GatewayError(
759
- "invalid_request",
760
- 400,
761
- `Add an account for ${providerId} first — model listing needs a credential.`,
762
- {},
763
- "cartethyia",
764
- );
765
- }
766
- if (!(await this.usableAccountSecret(tenantId, providerId))) {
767
- // A bundled provider with an account would have gone through the
768
- // resolver branch above, so reaching here means none is registered.
769
- // Add one first, then fetch.
770
- throw new GatewayError(
771
- "invalid_request",
772
- 400,
773
- `Add an account for ${providerId} before fetching its models.`,
774
- {},
775
- "cartethyia",
776
- );
777
- }
778
- // A credential exists and the provider still has no listing path: the
779
- // catalog is the compiled one, and new models arrive with an update.
780
- throw new GatewayError(
781
- "capability_unsupported",
782
- 400,
783
- `${providerId} publishes no model-listing endpoint. Its catalog ships with Cartethyia; new models appear with an update.`,
784
- {},
785
- "cartethyia",
786
- );
787
- } finally {
788
- outbound.release?.();
789
- }
790
- }
791
-
792
- private async persistDiscoveredModels(
793
- providerId: string,
794
- discoveredModels: readonly (string | ModelDefinition)[],
795
- resolve: (modelId: string) => DiscoveredModelWire,
796
- endpointOverrides?: Partial<Record<WireFamily, string>>,
797
- baseUrl?: string,
798
- supportedWireFamilies?: readonly WireFamily[],
799
- ): Promise<{ synced: number }> {
800
- // Static definitions are the injected bundled catalog; the probing service
801
- // and the console store are both handed the same startup-built map.
802
- const staticDefinitions: readonly ModelDefinition[] =
803
- this.bundledModelCatalog.get(providerId) ?? [];
804
- const staticEndpoints: Partial<Record<WireFamily, string>> = {};
805
- for (const definition of staticDefinitions) {
806
- staticEndpoints[definition.wireFamily] ??= definition.endpointPath;
807
- }
808
- // Fetch only adds genuinely new models: an id the builtin catalog
809
- // already owns stays exactly one (builtin) row instead of gaining a
810
- // discovered shadow that confuses pickers and deletes.
811
- const builtinIds = new Set(staticDefinitions.map((definition) => definition.modelId));
812
- // `(modelId, endpointPath)` is the row identity, so a corrected wire family
813
- // lands on a *new* row and would leave the old one behind as a dead route.
814
- // Collect this run's pairs and drop the provider's superseded discovered
815
- // rows for the same ids afterwards.
816
- const resolvedPairs: Array<{ modelId: string; endpointPath: string }> = [];
817
- const rows: Array<typeof models.$inferInsert> = [];
818
- // `(modelId, endpointPath)` is the row identity and the upsert's conflict
819
- // target, so a listing that repeats a pair (the bespoke discoveries return
820
- // raw upstream ids) would make a multi-row `ON CONFLICT DO UPDATE` fail with
821
- // "cannot affect row a second time". Keep the first row per pair, as the
822
- // builtin seeder does, so one statement can carry a whole chunk.
823
- const seenPairs = new Set<string>();
824
- for (const item of discoveredModels) {
825
- const modelId = typeof item === "string" ? item : item.modelId;
826
- if (builtinIds.has(modelId)) continue;
827
- const resolved = resolve(modelId);
828
- const discDef = typeof item === "string" ? undefined : item;
829
- const { wireFamily, endpointPath } = applyDiscoveredWire({
830
- resolvedWireFamily: resolved.wireFamily,
831
- resolvedEndpointPath: resolved.endpointPath,
832
- ...(discDef !== undefined
833
- ? { discoveredWireFamily: discDef.wireFamily, discoveredEndpointPath: discDef.endpointPath }
834
- : {}),
835
- staticEndpoints,
836
- ...(endpointOverrides !== undefined ? { endpointOverrides } : {}),
837
- ...(supportedWireFamilies !== undefined ? { supportedWireFamilies } : {}),
838
- ...(baseUrl !== undefined ? { baseUrl } : {}),
839
- });
840
- resolvedPairs.push({ modelId, endpointPath });
841
- const pairKey = `${modelId}\u0000${endpointPath}`;
842
- if (seenPairs.has(pairKey)) continue;
843
- seenPairs.add(pairKey);
844
- const knownDefinition = staticDefinitions.find((definition) => definition.modelId === modelId);
845
- const sourceUpdatedAt = new Date();
846
- const metadata = {
847
- contextLimit: knownDefinition?.contextLimit ?? discDef?.contextLimit ?? null,
848
- outputLimit: knownDefinition?.outputLimit ?? discDef?.outputLimit ?? null,
849
- modalities: knownDefinition?.modalities ?? discDef?.modalities ?? null,
850
- reasoning: knownDefinition?.reasoning ?? discDef?.reasoning ?? false,
851
- toolCall: knownDefinition?.toolCall ?? discDef?.toolCall ?? false,
852
- cost: modelsDevCatalog.costFor(providerId, modelId),
853
- // Discovery builds its rows from the same static catalog, so a fetched
854
- // row carries the same answer a seeded one does. Null when neither
855
- // source marked it, which reads as "unknown" rather than "free".
856
- proOnly: (discDef?.proOnly ?? knownDefinition?.proOnly ?? null),
857
- // Same shape for the tier the provider states itself (Qoder `is_free`).
858
- freeTier: (discDef?.freeTier ?? knownDefinition?.freeTier ?? null),
859
- };
860
- rows.push({
861
- providerId,
862
- modelId,
863
- wireFamily,
864
- // A discovery module that classifies an id as a non-chat service (the
865
- // System One decision API) states it on its own definition; otherwise the
866
- // static catalog's classification wins, and an unclassified id is `llm`.
867
- serviceKind: discDef?.serviceKind ?? knownDefinition?.serviceKind ?? "llm",
868
- endpointPath,
869
- ...metadata,
870
- // A discovery module that marks its rows as a free tier gets its own
871
- // source, because the model list groups on it: a free-tier row is not
872
- // an ordinary fetched one, and collapsing both into `discovered` lost
873
- // that distinction at the only point where it is still known.
874
- source: discDef?.freeTier === true ? "auto_free" : "discovered",
875
- sourceUpdatedAt,
876
- enabled: true,
877
- });
878
- }
879
- // One statement per chunk (500, the account-health batch step) instead of
880
- // one round-trip per row. `excluded.*` reads the proposed row, so the update
881
- // set is the same columns the per-row upsert wrote.
882
- for (let offset = 0; offset < rows.length; offset += 500) {
883
- await this.db
884
- .insert(models)
885
- .values(rows.slice(offset, offset + 500))
886
- .onConflictDoUpdate({
887
- target: [models.providerId, models.modelId, models.endpointPath],
888
- set: {
889
- serviceKind: sql`excluded.service_kind`,
890
- contextLimit: sql`excluded.context_limit`,
891
- outputLimit: sql`excluded.output_limit`,
892
- modalities: sql`excluded.modalities`,
893
- reasoning: sql`excluded.reasoning`,
894
- toolCall: sql`excluded.tool_call`,
895
- cost: sql`excluded.cost`,
896
- proOnly: sql`excluded.pro_only`,
897
- freeTier: sql`excluded.free_tier`,
898
- // The proposed row's own source, not a constant: a free-tier
899
- // discovery and an ordinary one write the same upsert, and pinning
900
- // this to `discovered` would relabel every free-tier row on the
901
- // next sync.
902
- source: sql`excluded.source`,
903
- sourceUpdatedAt: sql`excluded.source_updated_at`,
904
- },
905
- });
906
- }
907
- const synced = rows.length;
908
- // Drop this provider's discovered rows for the synced ids that are no
909
- // longer part of the resolved set — the stale `(model, endpoint)` pair left
910
- // behind when a corrected wire family moved a model to a new path. Rows the
911
- // operator added by hand (`manual`) and ids outside this run are untouched.
912
- // `auto_free` is included: it is written by this same sync, so leaving it
913
- // out would make a free-tier row the one kind of synced row that never gets
914
- // pruned when its id moves.
915
- if (resolvedPairs.length > 0) {
916
- const pairs = resolvedPairs.map(
917
- (pair) => sql`(${pair.modelId}, ${pair.endpointPath})`,
918
- );
919
- const ids = resolvedPairs.map((pair) => pair.modelId);
920
- await this.db
921
- .delete(models)
922
- .where(
923
- and(
924
- eq(models.providerId, providerId),
925
- inArray(models.source, ["discovered", "auto_free"]),
926
- sql`${models.modelId} IN (${sql.join(
927
- ids.map((id) => sql`${id}`),
928
- sql`, `,
929
- )})`,
930
- sql`(${models.modelId}, ${models.endpointPath}) NOT IN (${sql.join(pairs, sql`, `)})`,
931
- ),
932
- );
933
- }
934
- return { synced };
935
- }
936
-
937
- private async usableAccountSecret(
938
- tenantId: string,
939
- providerId: string,
940
- ): Promise<string | undefined> {
941
- return (await this.usableAccount(tenantId, providerId))?.secret;
942
- }
943
-
944
- /**
945
- * The account a credential-scoped discovery will use, with its non-secret
946
- * auth configuration. Selection is unchanged — first usable row, preferring a
947
- * tenant-owned one — so discovery and dispatch agree on which account answered.
948
- */
949
- private async usableAccount(
950
- tenantId: string,
951
- providerId: string,
952
- ): Promise<{ readonly secret: string; readonly authState?: Readonly<Record<string, unknown>> } | undefined> {
953
- const rows = await this.db
954
- .select()
955
- .from(providerAccounts)
956
- .where(
957
- and(
958
- eq(providerAccounts.providerId, providerId),
959
- globalOrOwnedBy(providerAccounts.tenantId, tenantId),
960
- ),
961
- );
962
- const now = Date.now();
963
- const usable = rows
964
- .filter(
965
- (row) =>
966
- row.status !== "disabled" &&
967
- (row.cooldownUntil === null || row.cooldownUntil.getTime() <= now),
968
- )
969
- .sort((left, right) => Number(right.tenantId !== null) - Number(left.tenantId !== null))[0];
970
- if (!usable?.credentialCiphertext) return undefined;
971
- const authState = isRecord(usable.authState) ? usable.authState as Readonly<Record<string, unknown>> : undefined;
972
- return {
973
- secret: decryptCredentialToString(usable.credentialCiphertext),
974
- ...(authState === undefined ? {} : { authState }),
975
- };
976
- }
977
-
978
- /**
979
- * Batched provider-wide probe: the first request runs sequentially (it warms
980
- * OAuth token caches and connection pools for the provider), the rest run
981
- * with `Promise.allSettled` bounded to `PROBE_CONCURRENCY`. Each request
982
- * carries its model route and service kind so native services never fall
983
- * through to an LLM probe.
984
- */
985
- async probeAllModels(
986
- tenantId: string,
987
- providerId: string,
988
- requests: readonly ProbeModelRequest[],
989
- ): Promise<ProbeAllModelsResult> {
990
- const results: Array<ProbeAllModelsResult["results"][number]> = [];
991
- const [firstRequest, ...restRequests] = requests;
992
- if (firstRequest === undefined) return { providerId, results };
993
-
994
- const runOne = async (probeRequest: ProbeModelRequest): Promise<void> => {
995
- const outcome = await this.probeModel(tenantId, providerId, probeRequest);
996
- results.push({
997
- modelId: probeRequest.modelId,
998
- ok: outcome.ok,
999
- latencyMs: outcome.latencyMs,
1000
- ...(outcome.error ? { error: outcome.error } : {}),
1001
- });
1002
- };
1003
-
1004
- await runOne(firstRequest);
1005
- for (let offset = 0; offset < restRequests.length; offset += PROBE_CONCURRENCY) {
1006
- const batch = restRequests.slice(offset, offset + PROBE_CONCURRENCY);
1007
- await Promise.allSettled(batch.map((request) => runOne(request)));
1008
- }
1009
- return { providerId, results };
1010
- }
1011
- async probeAllAccounts(
1012
- tenantId: string,
1013
- providerId: string,
1014
- request: ProbeModelRequest,
1015
- ): Promise<ProbeAllAccountsResult> {
1016
- const accounts = await this.db
1017
- .select({ id: providerAccounts.id })
1018
- .from(providerAccounts)
1019
- .where(
1020
- and(
1021
- eq(providerAccounts.providerId, providerId),
1022
- globalOrOwnedBy(providerAccounts.tenantId, tenantId),
1023
- ),
1024
- );
1025
- const results: ProbeAllAccountsResult["results"][number][] = [];
1026
- for (let offset = 0; offset < accounts.length; offset += PROBE_ACCOUNT_CONCURRENCY) {
1027
- const batch = accounts.slice(offset, offset + PROBE_ACCOUNT_CONCURRENCY);
1028
- const batchResults = await Promise.all(
1029
- batch.map(async (account) => {
1030
- const result = await this.probeModel(tenantId, providerId, {
1031
- ...request,
1032
- accountId: account.id,
1033
- });
1034
- // No health write here: this used to regex the model's own answer text
1035
- // for "202" and degrade the account on a match. A probe that genuinely
1036
- // fails already reports through `recordAccountFailure`, which classifies
1037
- // the real status; matching answer text is not a health signal.
1038
- return { ...result, accountId: account.id };
1039
- }),
1040
- );
1041
- results.push(...batchResults);
1042
- }
1043
- return { providerId, modelId: request.modelId, results };
1044
- }
1045
- }
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
+ // Body capture on, unlike a live dispatch: a probe is one-shot and
278
+ // operator-initiated, so it exists to be inspected. With it off, the
279
+ // provider-response tee resolved to null and every probe row reported
280
+ // the middle two stages as absent — a constant, not a measurement,
281
+ // which made the stage markers useless for the diagnosis they exist
282
+ // for. The 256 KiB cap in the tee bounds the cost.
283
+ captureProviderExchange(outbound.fetch, probeCapture, true),
284
+ );
285
+ try {
286
+ if (serviceKind === "websearch") {
287
+ if (typeof adapter.websearch !== "function") {
288
+ throw new GatewayError(
289
+ "capability_unsupported",
290
+ 400,
291
+ `Provider ${providerId} has no web search transport`,
292
+ );
293
+ }
294
+ const searchProbeQuery = request.prompt?.trim() || "Cartethyia gateway probe";
295
+ const response = await adapter.websearch(
296
+ { model: modelId, query: searchProbeQuery, max_results: 3 },
297
+ candidate,
298
+ {
299
+ credential,
300
+ deadline: startedAt + 30_000,
301
+ abort_signal: probeSignal,
302
+ outbound_fetch: probeFetch,
303
+ },
304
+ );
305
+ if (ttfbMs === undefined) ttfbMs = Date.now() - startedAt;
306
+ searchResults = response.results.map((hit) => ({
307
+ title: hit.title,
308
+ url: hit.url,
309
+ snippet: hit.snippet,
310
+ }));
311
+ if (!response.results || response.results.length === 0) {
312
+ dispatchError = new GatewayError("platform_unavailable", 502, "web search returned no results");
313
+ }
314
+ } else if (serviceKind === "systemone") {
315
+ if (typeof adapter.systemone !== "function") {
316
+ throw new GatewayError(
317
+ "capability_unsupported",
318
+ 400,
319
+ `Provider ${providerId} has no System One transport`,
320
+ );
321
+ }
322
+ const response = await adapter.systemone(
323
+ {
324
+ model: modelId,
325
+ state: "Customer: I was charged twice for my order this morning.",
326
+ questions: {
327
+ probe: {
328
+ type: "noul",
329
+ instructions: "Is the customer reporting a billing problem?",
330
+ },
331
+ },
332
+ },
333
+ candidate,
334
+ {
335
+ credential,
336
+ deadline: startedAt + 30_000,
337
+ abort_signal: probeSignal,
338
+ outbound_fetch: probeFetch,
339
+ },
340
+ );
341
+ if (ttfbMs === undefined) ttfbMs = Date.now() - startedAt;
342
+ const raw = await response.text().catch(() => "");
343
+ if (!response.ok) {
344
+ dispatchError = new GatewayError(
345
+ response.status >= 500 ? "platform_unavailable" : "invalid_request",
346
+ response.status,
347
+ `System One upstream error: ${raw.slice(0, 240)}`,
348
+ );
349
+ } else {
350
+ let parsed: unknown;
351
+ try {
352
+ parsed = raw ? JSON.parse(raw) : null;
353
+ } catch {
354
+ dispatchError = new GatewayError("platform_unavailable", 502, "System One returned invalid JSON");
355
+ }
356
+ if (parsed !== null && parsed !== undefined) {
357
+ const answers =
358
+ typeof parsed === "object" ? (parsed as Record<string, unknown>).answers : undefined;
359
+ const hasAnswers =
360
+ answers !== undefined &&
361
+ answers !== null &&
362
+ typeof answers === "object" &&
363
+ Object.keys(answers as Record<string, unknown>).length > 0;
364
+ if (!hasAnswers) {
365
+ dispatchError = new GatewayError(
366
+ "platform_unavailable",
367
+ 502,
368
+ "System One returned no answers",
369
+ );
370
+ }
371
+ }
372
+ }
373
+ } else {
374
+ const dispatchProbe = async (request: CanonicalRequest): Promise<void> => {
375
+ capturedRequest = request;
376
+ for await (const event of adapter.dispatch(request, candidate, {
377
+ credential,
378
+ deadline: startedAt + 30_000,
379
+ abort_signal: probeSignal,
380
+ outbound_fetch: probeFetch,
381
+ // A bidi-only provider (Cursor's Connect-RPC Run) has no `fetch`
382
+ // transport at all: without the HTTP/2 binding the probe fails with
383
+ // "requires validated HTTP/2 egress" while live dispatch — which
384
+ // always supplies it — succeeds.
385
+ ...(outbound.http2Bidi === undefined ? {} : { outbound_http2: outbound.http2Bidi }),
386
+ })) {
387
+ if (ttfbMs === undefined) ttfbMs = Date.now() - startedAt;
388
+ events.push(event);
389
+ }
390
+ };
391
+ try {
392
+ await dispatchProbe(canonicalRequest);
393
+ if (!hasMeaningfulOutput(events)) throw new Error("probe response content empty");
394
+ } catch (error) {
395
+ const message = error instanceof Error ? error.message : String(error);
396
+ if (!/empty\s+(?:response\s+)?content|response\s+content\s+empty/i.test(message)) {
397
+ throw error;
398
+ }
399
+ // Some reasoning models spend the short probe budget before producing
400
+ // visible text. Retry with a larger output allowance, and stream when
401
+ // the first attempt did not — a streamed probe keeps the connection
402
+ // open while the model reasons instead of waiting for a complete body.
403
+ // Generic: no provider/model id special case is needed.
404
+ events.length = 0;
405
+ ttfbMs = undefined;
406
+ await dispatchProbe({
407
+ ...canonicalRequest,
408
+ stream: true,
409
+ generation_controls: {
410
+ ...canonicalRequest.generation_controls,
411
+ max_tokens: 1024,
412
+ max_output_tokens: 1024,
413
+ },
414
+ });
415
+ }
416
+ }
417
+ } finally {
418
+ outbound.release?.();
419
+ }
420
+ } catch (error) {
421
+ dispatchError = error;
422
+ }
423
+ const featureFailure = grok407ProbeFailure({
424
+ providerId,
425
+ prompt: request.prompt,
426
+ events,
427
+ dispatchError,
428
+ });
429
+ if (featureFailure !== undefined) dispatchError = featureFailure;
430
+
431
+ const upstreamStatusCode =
432
+ dispatchError instanceof GatewayError
433
+ ? typeof dispatchError.details.upstreamStatus === "number"
434
+ ? dispatchError.details.upstreamStatus
435
+ : typeof dispatchError.details.providerStatus === "number"
436
+ ? dispatchError.details.providerStatus
437
+ : undefined
438
+ : undefined;
439
+ await recordProbeHealth({
440
+ db: this.db,
441
+ invalidator: this.snapshotInvalidator,
442
+ accountId,
443
+ dispatchError,
444
+ // Passed so a refusal delivered as a terminal event — not as a thrown
445
+ // error — still reaches the health machine. Without it, Cursor's
446
+ // `resource_exhausted` left `dispatchError` undefined and the probe
447
+ // recovered an exhausted account.
448
+ ...(events === undefined ? {} : { events }),
449
+ modelId,
450
+ upstreamStatusCode,
451
+ });
452
+
453
+ const latencyMs = Date.now() - startedAt;
454
+ // A native-service probe carries no canonical events: its verdict is the
455
+ // decision response itself (no dispatch error + answers present), which the
456
+ // branch above already encoded into `dispatchError`. Web search is a
457
+ // native service too — routing it through the LLM verdict demanded a
458
+ // `terminal` event the search transport never emits, so a search that
459
+ // returned hits was reported as a failure with an empty message.
460
+ const { ok, errorMessage, usage } =
461
+ serviceKind === "systemone" || serviceKind === "websearch"
462
+ ? {
463
+ ok: dispatchError === undefined,
464
+ errorMessage:
465
+ dispatchError === undefined
466
+ ? undefined
467
+ : dispatchError instanceof GatewayError
468
+ ? `Search provider ${providerId}/${modelId} — ${dispatchError.message} (HTTP ${dispatchError.status})`
469
+ : dispatchError instanceof Error
470
+ ? `${providerId}/${modelId} — ${dispatchError.message}`
471
+ : String(dispatchError),
472
+ usage: undefined,
473
+ }
474
+ : computeProbeVerdict({
475
+ events,
476
+ dispatchError,
477
+ wireFamily,
478
+ });
479
+
480
+ const requestId = crypto.randomUUID();
481
+
482
+ // Token speed shared with live traffic (`computeTokensPerSec`): probes
483
+ // record TTFT+latency only, so they report end-to-end effective speed.
484
+ const tokensPerSec = computeTokensPerSec({
485
+ outputTokens: usage?.output_tokens,
486
+ latencyMs,
487
+ });
488
+
489
+ // Probe telemetry rides the same batch buffer as live traffic. Keep the
490
+ // probe bodies linked by request_id so the console can inspect the exact
491
+ // canonical request and provider event stream that produced the result.
492
+ this.telemetryBuffer?.enqueue({
493
+ tenantId,
494
+ requestId,
495
+ sourceSurface,
496
+ requestedModel: modelId,
497
+ endpoint: endpointPath,
498
+ providerId,
499
+ ...(accountId ? { accountId } : {}),
500
+ ...(networkPoolId ? { networkPoolId } : {}),
501
+ latencyMs,
502
+ ...(ttfbMs !== undefined ? { ttfbMs } : {}),
503
+ stream: capturedRequest.stream,
504
+ status: ok ? "completed" : "failed",
505
+ ...(ok ? {} : { errorCategory: errorMessage ?? "probe_failed" }),
506
+ ...(usage ? { usage } : {}),
507
+ ...(tokensPerSec !== undefined ? { tokensPerSec } : {}),
508
+ });
509
+ if (this.telemetryBuffer && payloadCaptureEnabled) {
510
+ // All four surfaces, so the row's stage markers mean the same thing here
511
+ // as they do on a live request: which hop actually produced bytes.
512
+ const providerRequest = probeCapture?.request ?? null;
513
+ const providerResponse = await probeCapture?.response.catch(() => null) ?? null;
514
+ await this.payloadCapture
515
+ .capture({
516
+ tenantId,
517
+ requestId,
518
+ requestBody: capturedRequest,
519
+ // `events` is what the *client* receives, so it belongs in the client
520
+ // surface. Passing it as `responseBody` lit the client bit on probes
521
+ // whose event list was empty, over a body that was never captured.
522
+ // The legacy alias is explicitly absent here; `null` leaves bit 8 to
523
+ // be decided by the real client body alone.
524
+ responseBody: null,
525
+ ...(events === undefined ? {} : { clientResponseBody: events }),
526
+ ...(providerRequest === null ? {} : { providerRequestBody: providerRequest }),
527
+ ...(providerResponse === null ? {} : { providerResponseBody: providerResponse }),
528
+ scope: "tenant",
529
+ tenantOptIn: true,
530
+ })
531
+ .catch(() => undefined);
532
+ }
533
+
534
+ const sample = extractSample(events);
535
+ const reportedModel = await probeCapture?.reportedModel?.catch(() => undefined);
536
+ return {
537
+ ...(accountId ? { accountId } : {}),
538
+ ok,
539
+ latencyMs,
540
+ ...(ttfbMs !== undefined ? { ttfbMs } : {}),
541
+ ...(upstreamStatusCode === undefined ? {} : { statusCode: upstreamStatusCode }),
542
+ ...(sample ? { sample } : {}),
543
+ ...(reportedModel === undefined ? {} : { reportedModel }),
544
+ ...(searchResults !== undefined && searchResults.length > 0 ? { searchResults } : {}),
545
+ ...(errorMessage ? { error: errorMessage } : {}),
546
+ };
547
+ }
548
+
549
+ /**
550
+ * Ad-hoc connectivity test for a not-yet-persisted BYOK provider.
551
+ *
552
+ * Dispatches a real `GET <base>/models` through the same SSRF-validated
553
+ * outbound fetch live traffic uses, stamping the credential with the auth
554
+ * header the chosen wire family actually reads (`x-api-key` for Messages,
555
+ * `Authorization: Bearer` otherwise) plus that wire's CLI identity headers.
556
+ * Reachability is the contract: any HTTP response proves the operator's base
557
+ * URL, credential, and egress path work — a 4xx is reported with its status
558
+ * rather than disguised as a transport failure.
559
+ */
560
+ async testByokConnection(
561
+ tenantId: string,
562
+ request: ByokConnectionTestRequest,
563
+ ): Promise<ByokConnectionTestResult> {
564
+ const startedAt = Date.now();
565
+ const requestedWire = request.wireFamily;
566
+ const wireFamily: WireFamily = (WIRE_FAMILIES as readonly string[]).includes(requestedWire)
567
+ ? (requestedWire as WireFamily)
568
+ : "chat";
569
+ // An operator who pasted a full endpoint (`…/anthropic/v1/messages`) gets it
570
+ // stripped before the model list is derived, and told so. Probing the
571
+ // corrected root is what makes the result actionable: the alternative is a
572
+ // bare 404 against a path the gateway built from their own typo.
573
+ const submitted = splitEndpointBaseUrl(request.baseUrl);
574
+ const warning =
575
+ submitted.stripped === ""
576
+ ? undefined
577
+ : `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.`;
578
+ const url = modelListUrl(request.baseUrl);
579
+ const outbound = asOutboundBinding(await this.outboundFetchFor(tenantId));
580
+ const probeFetch = createProbeFetch(outbound.fetch);
581
+ try {
582
+ const headers: Record<string, string> = {
583
+ accept: "application/json",
584
+ ...authHeaders(
585
+ byokAuthHeaderShape([wireFamily]),
586
+ new TextEncoder().encode(request.apiKey),
587
+ request.apiKey.trim().length > 0 ? "api_key" : "none",
588
+ ),
589
+ ...(request.cliIdentity === false ? {} : resolveCustomCliHeaders(wireFamily)),
590
+ ...(request.extraHeaders ?? {}),
591
+ };
592
+ const response = await probeFetch(url, {
593
+ method: "GET",
594
+ headers,
595
+ signal: AbortSignal.timeout(15_000),
596
+ });
597
+ const latencyMs = Date.now() - startedAt;
598
+ if (!response.ok) {
599
+ return {
600
+ ok: false,
601
+ latencyMs,
602
+ statusCode: response.status,
603
+ error: `Upstream rejected the connection test with HTTP ${response.status}`,
604
+ ...(warning === undefined ? {} : { warning }),
605
+ };
606
+ }
607
+ // A body is nice to have but not required: some gateways answer `/models`
608
+ // with an empty envelope. Read it defensively so a non-JSON body cannot
609
+ // turn a successful reachability check into a failure.
610
+ let modelCount: number | undefined;
611
+ try {
612
+ const payload = (await response.json()) as unknown;
613
+ const entries = Array.isArray(payload)
614
+ ? payload
615
+ : isRecord(payload) && Array.isArray(payload["data"])
616
+ ? payload["data"]
617
+ : isRecord(payload) && Array.isArray(payload["models"])
618
+ ? payload["models"]
619
+ : undefined;
620
+ if (entries) modelCount = entries.length;
621
+ } catch {
622
+ // Non-JSON success body: reachability already proven by the 2xx.
623
+ }
624
+ return {
625
+ ok: true,
626
+ latencyMs,
627
+ statusCode: response.status,
628
+ ...(modelCount === undefined ? {} : { modelCount }),
629
+ ...(warning === undefined ? {} : { warning }),
630
+ };
631
+ } catch (error) {
632
+ return {
633
+ ok: false,
634
+ latencyMs: Date.now() - startedAt,
635
+ error: error instanceof Error ? error.message : "Connection test failed",
636
+ ...(warning === undefined ? {} : { warning }),
637
+ };
638
+ } finally {
639
+ outbound.release?.();
640
+ }
641
+ }
642
+
643
+ async syncModels(tenantId: string, providerId: string): Promise<{ synced: number }> {
644
+ const outbound = asOutboundBinding(await this.outboundFetchFor(tenantId, providerId));
645
+ try {
646
+ const discoveryFetch = asFetchLike(outbound.fetch);
647
+ const resolver = await this.providerRegistry.resolveModelDiscovery(providerId);
648
+ if (resolver) {
649
+ // An account is used when one is usable, and the fetch is attempted
650
+ // either way. Gating on a credential only made the common case
651
+ // unreachable: most providers list their models without auth, and the
652
+ // ones that do not answer 401 themselves, which names the actual
653
+ // problem where a local 409 named a missing account the operator may
654
+ // not even need.
655
+ const account = await this.usableAccount(tenantId, providerId);
656
+ const secret = account?.secret;
657
+ const providerRow = await this.loadProviderWireRow(providerId);
658
+ const effectiveBaseUrl = providerRow?.baseUrl ?? providerBaseUrl(providerId) ?? "";
659
+ let discovered: readonly ModelDefinition[] | null;
660
+ try {
661
+ discovered = await resolver({
662
+ baseUrl: effectiveBaseUrl,
663
+ credential: secret ?? "",
664
+ ...(discoveryFetch ? { fetcher: discoveryFetch } : {}),
665
+ ...(account?.authState === undefined ? {} : { authState: account.authState }),
666
+ });
667
+ } catch (error) {
668
+ throw new GatewayError(
669
+ "transport_unavailable",
670
+ 502,
671
+ `Model discovery failed for ${providerId}: ${error instanceof Error ? error.message : "unknown error"}`,
672
+ {},
673
+ "upstream",
674
+ );
675
+ }
676
+ if (!discovered) {
677
+ throw new GatewayError(
678
+ "transport_unavailable",
679
+ 502,
680
+ `Model discovery failed for ${providerId}: endpoint returned no usable models`,
681
+ {},
682
+ "upstream",
683
+ );
684
+ }
685
+ const ctx = wireContextFrom(providerRow);
686
+ return this.persistDiscoveredModels(
687
+ providerId,
688
+ discovered,
689
+ (modelId) => resolveDiscoveredWire(modelId, ctx, undefined, this.defaultEndpoints),
690
+ ctx.endpointPathsByWireFamily,
691
+ effectiveBaseUrl,
692
+ );
693
+ }
694
+
695
+ const providerRow = await this.loadProviderWireRow(providerId);
696
+ if (providerRow?.baseUrl) {
697
+ const secret = await this.usableAccountSecret(tenantId, providerId);
698
+ if (secret) {
699
+ // An Anthropic-compatible root reads `x-api-key`, not bearer auth,
700
+ // and a bare host needs its `/v1` segment before `/models` joins on.
701
+ // Both are derived from the same wire profile dispatch uses, so
702
+ // auto-fetch behaves identically for either flavor of custom provider.
703
+ const wireProfile = resolveByokWireProfile(
704
+ providerRow.wireFamilyDefault,
705
+ providerRow.compatibilityProfile as CompatibilityProfile | null,
706
+ );
707
+ const modelsBaseUrl = modelDiscoveryBaseUrl(providerRow.baseUrl);
708
+ let discovered: readonly ModelDefinition[] | null;
709
+ try {
710
+ discovered = await fetchOpenAICompatibleModels({
711
+ baseUrl: modelsBaseUrl,
712
+ providerId,
713
+ headers: {
714
+ ...authHeaders(
715
+ wireProfile.authHeaderShape,
716
+ new TextEncoder().encode(secret),
717
+ "api_key",
718
+ ),
719
+ },
720
+ ...(discoveryFetch ? { fetcher: discoveryFetch as typeof fetch } : {}),
721
+ });
722
+ } catch (error) {
723
+ throw new GatewayError(
724
+ "transport_unavailable",
725
+ 502,
726
+ `Model discovery failed for ${providerId}: ${error instanceof Error ? error.message : "unknown error"}`,
727
+ {},
728
+ "upstream",
729
+ );
730
+ }
731
+ if (discovered && discovered.length > 0) {
732
+ const ctx = wireContextFrom(providerRow);
733
+ return this.persistDiscoveredModels(
734
+ providerId,
735
+ discovered,
736
+ (modelId) => resolveDiscoveredWire(modelId, ctx, undefined, this.defaultEndpoints),
737
+ ctx.endpointPathsByWireFamily,
738
+ providerRow.baseUrl ?? undefined,
739
+ // The generic `/models` fetch guesses a wire from the model id;
740
+ // the row's own profile is what the adapter will enforce, so it
741
+ // decides which guess is admissible.
742
+ wireProfile.supportedWireFamilies,
743
+ );
744
+ }
745
+ }
746
+ }
747
+
748
+ // Two reasons a sync reaches here with nothing dialed, and the operator
749
+ // needs to be told which one. Returning `{synced: 0}` for both made an
750
+ // unsupported provider indistinguishable from a successful fetch that
751
+ // found nothing — the console showed a green "0 synced" for a provider
752
+ // it can never sync.
753
+ const bundled = isBundledProviderId(providerId);
754
+ if (!bundled) {
755
+ // A custom provider has a row and a base URL but no usable credential.
756
+ // The fix is an account, and saying "no listing endpoint" would send the
757
+ // operator looking for a capability the provider may well have.
758
+ throw new GatewayError(
759
+ "invalid_request",
760
+ 400,
761
+ `Add an account for ${providerId} first — model listing needs a credential.`,
762
+ {},
763
+ "cartethyia",
764
+ );
765
+ }
766
+ if (!(await this.usableAccountSecret(tenantId, providerId))) {
767
+ // A bundled provider with an account would have gone through the
768
+ // resolver branch above, so reaching here means none is registered.
769
+ // Add one first, then fetch.
770
+ throw new GatewayError(
771
+ "invalid_request",
772
+ 400,
773
+ `Add an account for ${providerId} before fetching its models.`,
774
+ {},
775
+ "cartethyia",
776
+ );
777
+ }
778
+ // A credential exists and the provider still has no listing path: the
779
+ // catalog is the compiled one, and new models arrive with an update.
780
+ throw new GatewayError(
781
+ "capability_unsupported",
782
+ 400,
783
+ `${providerId} publishes no model-listing endpoint. Its catalog ships with Cartethyia; new models appear with an update.`,
784
+ {},
785
+ "cartethyia",
786
+ );
787
+ } finally {
788
+ outbound.release?.();
789
+ }
790
+ }
791
+
792
+ private async persistDiscoveredModels(
793
+ providerId: string,
794
+ discoveredModels: readonly (string | ModelDefinition)[],
795
+ resolve: (modelId: string) => DiscoveredModelWire,
796
+ endpointOverrides?: Partial<Record<WireFamily, string>>,
797
+ baseUrl?: string,
798
+ supportedWireFamilies?: readonly WireFamily[],
799
+ ): Promise<{ synced: number }> {
800
+ // Static definitions are the injected bundled catalog; the probing service
801
+ // and the console store are both handed the same startup-built map.
802
+ const staticDefinitions: readonly ModelDefinition[] =
803
+ this.bundledModelCatalog.get(providerId) ?? [];
804
+ const staticEndpoints: Partial<Record<WireFamily, string>> = {};
805
+ for (const definition of staticDefinitions) {
806
+ staticEndpoints[definition.wireFamily] ??= definition.endpointPath;
807
+ }
808
+ // Fetch only adds genuinely new models: an id the builtin catalog
809
+ // already owns stays exactly one (builtin) row instead of gaining a
810
+ // discovered shadow that confuses pickers and deletes.
811
+ const builtinIds = new Set(staticDefinitions.map((definition) => definition.modelId));
812
+ // `(modelId, endpointPath)` is the row identity, so a corrected wire family
813
+ // lands on a *new* row and would leave the old one behind as a dead route.
814
+ // Collect this run's pairs and drop the provider's superseded discovered
815
+ // rows for the same ids afterwards.
816
+ const resolvedPairs: Array<{ modelId: string; endpointPath: string }> = [];
817
+ const rows: Array<typeof models.$inferInsert> = [];
818
+ // `(modelId, endpointPath)` is the row identity and the upsert's conflict
819
+ // target, so a listing that repeats a pair (the bespoke discoveries return
820
+ // raw upstream ids) would make a multi-row `ON CONFLICT DO UPDATE` fail with
821
+ // "cannot affect row a second time". Keep the first row per pair, as the
822
+ // builtin seeder does, so one statement can carry a whole chunk.
823
+ const seenPairs = new Set<string>();
824
+ for (const item of discoveredModels) {
825
+ const modelId = typeof item === "string" ? item : item.modelId;
826
+ if (builtinIds.has(modelId)) continue;
827
+ const resolved = resolve(modelId);
828
+ const discDef = typeof item === "string" ? undefined : item;
829
+ const { wireFamily, endpointPath } = applyDiscoveredWire({
830
+ resolvedWireFamily: resolved.wireFamily,
831
+ resolvedEndpointPath: resolved.endpointPath,
832
+ ...(discDef !== undefined
833
+ ? { discoveredWireFamily: discDef.wireFamily, discoveredEndpointPath: discDef.endpointPath }
834
+ : {}),
835
+ staticEndpoints,
836
+ ...(endpointOverrides !== undefined ? { endpointOverrides } : {}),
837
+ ...(supportedWireFamilies !== undefined ? { supportedWireFamilies } : {}),
838
+ ...(baseUrl !== undefined ? { baseUrl } : {}),
839
+ });
840
+ resolvedPairs.push({ modelId, endpointPath });
841
+ const pairKey = `${modelId}\u0000${endpointPath}`;
842
+ if (seenPairs.has(pairKey)) continue;
843
+ seenPairs.add(pairKey);
844
+ const knownDefinition = staticDefinitions.find((definition) => definition.modelId === modelId);
845
+ const sourceUpdatedAt = new Date();
846
+ const metadata = {
847
+ contextLimit: knownDefinition?.contextLimit ?? discDef?.contextLimit ?? null,
848
+ outputLimit: knownDefinition?.outputLimit ?? discDef?.outputLimit ?? null,
849
+ modalities: knownDefinition?.modalities ?? discDef?.modalities ?? null,
850
+ reasoning: knownDefinition?.reasoning ?? discDef?.reasoning ?? false,
851
+ toolCall: knownDefinition?.toolCall ?? discDef?.toolCall ?? false,
852
+ cost: modelsDevCatalog.costFor(providerId, modelId),
853
+ // Discovery builds its rows from the same static catalog, so a fetched
854
+ // row carries the same answer a seeded one does. Null when neither
855
+ // source marked it, which reads as "unknown" rather than "free".
856
+ proOnly: (discDef?.proOnly ?? knownDefinition?.proOnly ?? null),
857
+ // Same shape for the tier the provider states itself (Qoder `is_free`).
858
+ freeTier: (discDef?.freeTier ?? knownDefinition?.freeTier ?? null),
859
+ };
860
+ rows.push({
861
+ providerId,
862
+ modelId,
863
+ wireFamily,
864
+ // A discovery module that classifies an id as a non-chat service (the
865
+ // System One decision API) states it on its own definition; otherwise the
866
+ // static catalog's classification wins, and an unclassified id is `llm`.
867
+ serviceKind: discDef?.serviceKind ?? knownDefinition?.serviceKind ?? "llm",
868
+ endpointPath,
869
+ ...metadata,
870
+ // A discovery module that marks its rows as a free tier gets its own
871
+ // source, because the model list groups on it: a free-tier row is not
872
+ // an ordinary fetched one, and collapsing both into `discovered` lost
873
+ // that distinction at the only point where it is still known.
874
+ source: discDef?.freeTier === true ? "auto_free" : "discovered",
875
+ sourceUpdatedAt,
876
+ enabled: true,
877
+ });
878
+ }
879
+ // One statement per chunk (500, the account-health batch step) instead of
880
+ // one round-trip per row. `excluded.*` reads the proposed row, so the update
881
+ // set is the same columns the per-row upsert wrote.
882
+ for (let offset = 0; offset < rows.length; offset += 500) {
883
+ await this.db
884
+ .insert(models)
885
+ .values(rows.slice(offset, offset + 500))
886
+ .onConflictDoUpdate({
887
+ target: [models.providerId, models.modelId, models.endpointPath],
888
+ set: {
889
+ serviceKind: sql`excluded.service_kind`,
890
+ contextLimit: sql`excluded.context_limit`,
891
+ outputLimit: sql`excluded.output_limit`,
892
+ modalities: sql`excluded.modalities`,
893
+ reasoning: sql`excluded.reasoning`,
894
+ toolCall: sql`excluded.tool_call`,
895
+ cost: sql`excluded.cost`,
896
+ proOnly: sql`excluded.pro_only`,
897
+ freeTier: sql`excluded.free_tier`,
898
+ // The proposed row's own source, not a constant: a free-tier
899
+ // discovery and an ordinary one write the same upsert, and pinning
900
+ // this to `discovered` would relabel every free-tier row on the
901
+ // next sync.
902
+ source: sql`excluded.source`,
903
+ sourceUpdatedAt: sql`excluded.source_updated_at`,
904
+ },
905
+ });
906
+ }
907
+ const synced = rows.length;
908
+ // Drop this provider's discovered rows for the synced ids that are no
909
+ // longer part of the resolved set — the stale `(model, endpoint)` pair left
910
+ // behind when a corrected wire family moved a model to a new path. Rows the
911
+ // operator added by hand (`manual`) and ids outside this run are untouched.
912
+ // `auto_free` is included: it is written by this same sync, so leaving it
913
+ // out would make a free-tier row the one kind of synced row that never gets
914
+ // pruned when its id moves.
915
+ if (resolvedPairs.length > 0) {
916
+ const pairs = resolvedPairs.map(
917
+ (pair) => sql`(${pair.modelId}, ${pair.endpointPath})`,
918
+ );
919
+ const ids = resolvedPairs.map((pair) => pair.modelId);
920
+ await this.db
921
+ .delete(models)
922
+ .where(
923
+ and(
924
+ eq(models.providerId, providerId),
925
+ inArray(models.source, ["discovered", "auto_free"]),
926
+ sql`${models.modelId} IN (${sql.join(
927
+ ids.map((id) => sql`${id}`),
928
+ sql`, `,
929
+ )})`,
930
+ sql`(${models.modelId}, ${models.endpointPath}) NOT IN (${sql.join(pairs, sql`, `)})`,
931
+ ),
932
+ );
933
+ }
934
+ return { synced };
935
+ }
936
+
937
+ private async usableAccountSecret(
938
+ tenantId: string,
939
+ providerId: string,
940
+ ): Promise<string | undefined> {
941
+ return (await this.usableAccount(tenantId, providerId))?.secret;
942
+ }
943
+
944
+ /**
945
+ * The account a credential-scoped discovery will use, with its non-secret
946
+ * auth configuration. Selection is unchanged — first usable row, preferring a
947
+ * tenant-owned one — so discovery and dispatch agree on which account answered.
948
+ */
949
+ private async usableAccount(
950
+ tenantId: string,
951
+ providerId: string,
952
+ ): Promise<{ readonly secret: string; readonly authState?: Readonly<Record<string, unknown>> } | undefined> {
953
+ const rows = await this.db
954
+ .select()
955
+ .from(providerAccounts)
956
+ .where(
957
+ and(
958
+ eq(providerAccounts.providerId, providerId),
959
+ globalOrOwnedBy(providerAccounts.tenantId, tenantId),
960
+ ),
961
+ );
962
+ const now = Date.now();
963
+ const usable = rows
964
+ .filter(
965
+ (row) =>
966
+ row.status !== "disabled" &&
967
+ (row.cooldownUntil === null || row.cooldownUntil.getTime() <= now),
968
+ )
969
+ .sort((left, right) => Number(right.tenantId !== null) - Number(left.tenantId !== null))[0];
970
+ if (!usable?.credentialCiphertext) return undefined;
971
+ const authState = isRecord(usable.authState) ? usable.authState as Readonly<Record<string, unknown>> : undefined;
972
+ return {
973
+ secret: decryptCredentialToString(usable.credentialCiphertext),
974
+ ...(authState === undefined ? {} : { authState }),
975
+ };
976
+ }
977
+
978
+ /**
979
+ * Batched provider-wide probe: the first request runs sequentially (it warms
980
+ * OAuth token caches and connection pools for the provider), the rest run
981
+ * through a sliding worker pool bounded to `PROBE_CONCURRENCY`. Each request
982
+ * carries its model route and service kind so native services never fall
983
+ * through to an LLM probe.
984
+ *
985
+ * Sliding window beats barrel-batching here: the historical pattern waited
986
+ * for the slowest probe in each group of `N` before starting the next group,
987
+ * so one slow route stalled nine others. With the worker loop, a worker
988
+ * picks up the next request as soon as its current one settles, which keeps
989
+ * the full window of `PROBE_CONCURRENCY` in flight across the whole batch.
990
+ */
991
+ async probeAllModels(
992
+ tenantId: string,
993
+ providerId: string,
994
+ requests: readonly ProbeModelRequest[],
995
+ ): Promise<ProbeAllModelsResult> {
996
+ const [firstRequest, ...restRequests] = requests;
997
+ if (firstRequest === undefined) return { providerId, results: [] };
998
+
999
+ // Position the warm-up at index 0 and the rest at their original indices
1000
+ // so a caller inspecting `results` sees them in the same order they were
1001
+ // requested. Pre-sized `Array` means workers assign by index rather than
1002
+ // racing to `push`.
1003
+ const results: ProbeAllModelsResult["results"][number][] = new Array(requests.length);
1004
+
1005
+ const runOne = async (probeRequest: ProbeModelRequest, slot: number): Promise<void> => {
1006
+ const outcome = await this.probeModel(tenantId, providerId, probeRequest);
1007
+ results[slot] = {
1008
+ modelId: probeRequest.modelId,
1009
+ ok: outcome.ok,
1010
+ latencyMs: outcome.latencyMs,
1011
+ ...(outcome.error ? { error: outcome.error } : {}),
1012
+ };
1013
+ };
1014
+
1015
+ // Warm-up stays sequential: it primes OAuth token caches and connection
1016
+ // pools so the parallel workers do not each pay the first-call tax.
1017
+ await runOne(firstRequest, 0);
1018
+
1019
+ let cursor = 0;
1020
+ const worker = async (): Promise<void> => {
1021
+ while (true) {
1022
+ const index = cursor++;
1023
+ if (index >= restRequests.length) return;
1024
+ const request = restRequests[index]!;
1025
+ // Swallow per-probe errors to the array shape: `probeModel` is
1026
+ // supposed to return an `outcome` object even on failure, but a
1027
+ // throw here (unexpected shape, aborted socket) must not fail the
1028
+ // whole batch — the operator still needs to see which models passed.
1029
+ try {
1030
+ await runOne(request, index + 1);
1031
+ } catch (error) {
1032
+ results[index + 1] = {
1033
+ modelId: request.modelId,
1034
+ ok: false,
1035
+ latencyMs: 0,
1036
+ error: error instanceof Error ? error.message : String(error),
1037
+ };
1038
+ }
1039
+ }
1040
+ };
1041
+
1042
+ const workerCount = Math.min(PROBE_CONCURRENCY, restRequests.length);
1043
+ await Promise.all(Array.from({ length: workerCount }, () => worker()));
1044
+
1045
+ // Drop any gaps left by a `requests.length` of 1 (no rest) or an array
1046
+ // that was somehow sparser than planned — defensive, not expected.
1047
+ return { providerId, results: results.filter((entry) => entry !== undefined) };
1048
+ }
1049
+ async probeAllAccounts(
1050
+ tenantId: string,
1051
+ providerId: string,
1052
+ request: ProbeModelRequest,
1053
+ ): Promise<ProbeAllAccountsResult> {
1054
+ const accounts = await this.db
1055
+ .select({ id: providerAccounts.id })
1056
+ .from(providerAccounts)
1057
+ .where(
1058
+ and(
1059
+ eq(providerAccounts.providerId, providerId),
1060
+ globalOrOwnedBy(providerAccounts.tenantId, tenantId),
1061
+ ),
1062
+ );
1063
+ // Sliding worker pool: workers claim the next account as soon as their
1064
+ // current probe settles, so one slow account cannot stall the rest of the
1065
+ // window the way `Promise.all` on fixed-size batches did. Order of
1066
+ // completion is irrelevant to the caller (each result carries its own
1067
+ // `accountId`), but we still assign by slot to keep the output stable
1068
+ // for golden-file tests and operator audit replay.
1069
+ //
1070
+ // Fault tolerance: the old `Promise.all` meant a single thrown probe
1071
+ // aborted the entire batch and the operator lost the status of every
1072
+ // other account they clicked "test all" on. Catching per-account turns a
1073
+ // thrown probe into a failed result for that account only — matching the
1074
+ // dashboard's existing per-account expectation.
1075
+ const results: ProbeAllAccountsResult["results"][number][] = new Array(accounts.length);
1076
+ if (accounts.length === 0) return { providerId, modelId: request.modelId, results: [] };
1077
+
1078
+ const runOne = async (account: { id: string }, slot: number): Promise<void> => {
1079
+ try {
1080
+ const result = await this.probeModel(tenantId, providerId, {
1081
+ ...request,
1082
+ accountId: account.id,
1083
+ });
1084
+ // No health write here: a probe that genuinely fails already reports
1085
+ // through `recordAccountFailure`, which classifies the real status;
1086
+ // matching answer text is not a health signal.
1087
+ results[slot] = { ...result, accountId: account.id };
1088
+ } catch (error) {
1089
+ results[slot] = {
1090
+ ok: false,
1091
+ latencyMs: 0,
1092
+ accountId: account.id,
1093
+ error: error instanceof Error ? error.message : String(error),
1094
+ } as ProbeAllAccountsResult["results"][number];
1095
+ }
1096
+ };
1097
+
1098
+ // As in probeAllModels, the first probe primes OAuth token caches and
1099
+ // connection pools before concurrent workers start. Ten simultaneous cold
1100
+ // refreshes could otherwise trip provider rate limits and mark healthy
1101
+ // accounts as broken during a "test all" run.
1102
+ await runOne(accounts[0]!, 0);
1103
+ const restAccounts = accounts.slice(1);
1104
+ let cursor = 0;
1105
+ const worker = async (): Promise<void> => {
1106
+ while (true) {
1107
+ const index = cursor++;
1108
+ if (index >= restAccounts.length) return;
1109
+ await runOne(restAccounts[index]!, index + 1);
1110
+ }
1111
+ };
1112
+ const workerCount = Math.min(PROBE_ACCOUNT_CONCURRENCY, restAccounts.length);
1113
+ await Promise.all(Array.from({ length: workerCount }, () => worker()));
1114
+
1115
+ return { providerId, modelId: request.modelId, results };
1116
+ }
1117
+ }