cartethyia 2.5.4 → 2.5.7

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 (395) hide show
  1. package/.env.example +13 -4
  2. package/README.md +2 -2
  3. package/dist/dashboard/assets/{BannedPage-C2orBsI2.js → BannedPage-DE2607Tb.js} +1 -1
  4. package/dist/dashboard/assets/BannedPage-DE2607Tb.js.gz +0 -0
  5. package/dist/dashboard/assets/CliToolDetailPage-Duv6mfrl.js +1 -0
  6. package/dist/dashboard/assets/CliToolDetailPage-Duv6mfrl.js.gz +0 -0
  7. package/dist/dashboard/assets/{CliToolsPage-BV6pxVQo.js → CliToolsPage-C2PNYWzk.js} +1 -1
  8. package/dist/dashboard/assets/CliToolsPage-C2PNYWzk.js.gz +0 -0
  9. package/dist/dashboard/assets/CombosPage-TXP9OfAM.js +1 -0
  10. package/dist/dashboard/assets/CombosPage-TXP9OfAM.js.gz +0 -0
  11. package/dist/dashboard/assets/{ConsoleLogPage-CW-wPmJ6.js → ConsoleLogPage-B-9xnAQN.js} +1 -1
  12. package/dist/dashboard/assets/ConsoleLogPage-B-9xnAQN.js.gz +0 -0
  13. package/dist/dashboard/assets/{CustomizationPage-0p8uX7vB.js → CustomizationPage-xeax9nJt.js} +1 -1
  14. package/dist/dashboard/assets/CustomizationPage-xeax9nJt.js.gz +0 -0
  15. package/dist/dashboard/assets/{HealthEventsModal-BjqSMfko.js → HealthEventsModal-Df4jjaHJ.js} +1 -1
  16. package/dist/dashboard/assets/HealthEventsModal-Df4jjaHJ.js.gz +0 -0
  17. package/dist/dashboard/assets/{LoginPage-BvzCx8Kr.js → LoginPage-Dyojsm84.js} +1 -1
  18. package/dist/dashboard/assets/LoginPage-Dyojsm84.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-BJfg4ow6.js +1 -0
  22. package/dist/dashboard/assets/OverviewPage-BJfg4ow6.js.gz +0 -0
  23. package/dist/dashboard/assets/ProviderDetailPage-ChCX1gmT.js +3 -0
  24. package/dist/dashboard/assets/ProviderDetailPage-ChCX1gmT.js.gz +0 -0
  25. package/dist/dashboard/assets/{ProvidersPage-Js9jUPSb.js → ProvidersPage-DeWSOEUO.js} +1 -1
  26. package/dist/dashboard/assets/ProvidersPage-DeWSOEUO.js.gz +0 -0
  27. package/dist/dashboard/assets/ProxyPage-CkpWln2S.js +7 -0
  28. package/dist/dashboard/assets/ProxyPage-CkpWln2S.js.gz +0 -0
  29. package/dist/dashboard/assets/QuotaPage-B3VOgQmt.js +1 -0
  30. package/dist/dashboard/assets/QuotaPage-B3VOgQmt.js.gz +0 -0
  31. package/dist/dashboard/assets/SettingsPage-C_klIZom.js +1 -0
  32. package/dist/dashboard/assets/SettingsPage-C_klIZom.js.gz +0 -0
  33. package/dist/dashboard/assets/{SetupPage-DDbMQsoo.js → SetupPage-BYph-InK.js} +1 -1
  34. package/dist/dashboard/assets/SetupPage-BYph-InK.js.gz +0 -0
  35. package/dist/dashboard/assets/{SortableList-CB3Z7VTf.js → SortableList-CvyQCYWJ.js} +1 -1
  36. package/dist/dashboard/assets/SortableList-CvyQCYWJ.js.gz +0 -0
  37. package/dist/dashboard/assets/{StudioPage-D95UCPHI.js → StudioPage-Cm1JYilI.js} +3 -3
  38. package/dist/dashboard/assets/StudioPage-Cm1JYilI.js.gz +0 -0
  39. package/dist/dashboard/assets/UsagePage-DlFnc3_S.js +56 -0
  40. package/dist/dashboard/assets/UsagePage-DlFnc3_S.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-Chi7pCnt.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-D4pQapKj.js → badge-CR72dasT.js} +1 -1
  48. package/dist/dashboard/assets/badge-CR72dasT.js.gz +0 -0
  49. package/dist/dashboard/assets/{banner-DH6cg-_B.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-BKqlUOe4.js → card-DS7CQEsO.js} +1 -1
  52. package/dist/dashboard/assets/card-DS7CQEsO.js.gz +0 -0
  53. package/dist/dashboard/assets/{cli-tools-fTgljTGA.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-D5Ys_sM0.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/dialog-k37exjNt.js +1 -0
  60. package/dist/dashboard/assets/dialog-k37exjNt.js.gz +0 -0
  61. package/dist/dashboard/assets/drawer-CX7N8ucR.js +1 -0
  62. package/dist/dashboard/assets/drawer-CX7N8ucR.js.gz +0 -0
  63. package/dist/dashboard/assets/{entry-CQBr6U9c.js → entry-B9Xvtnfj.js} +1 -1
  64. package/dist/dashboard/assets/entry-B9Xvtnfj.js.gz +0 -0
  65. package/dist/dashboard/assets/entry-Bog0p925.js +4 -0
  66. package/dist/dashboard/assets/entry-Bog0p925.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/{github-badge-BlptL8Uf.js → github-badge-sltk1qUi.js} +1 -1
  70. package/dist/dashboard/assets/github-badge-sltk1qUi.js.gz +0 -0
  71. package/dist/dashboard/assets/{index-js6TJvdr.js → index-CZQIrdoX.js} +2 -2
  72. package/dist/dashboard/assets/index-CZQIrdoX.js.gz +0 -0
  73. package/dist/dashboard/assets/{inline-DUWDSmDV.js → inline-BhY2sSnO.js} +1 -1
  74. package/dist/dashboard/assets/inline-BhY2sSnO.js.gz +0 -0
  75. package/dist/dashboard/assets/{input-LVjgZq4f.js → input-V1umFiBk.js} +1 -1
  76. package/dist/dashboard/assets/input-V1umFiBk.js.gz +0 -0
  77. package/dist/dashboard/assets/layout-BBOYycyS.js +1 -0
  78. package/dist/dashboard/assets/layout-BBOYycyS.js.gz +0 -0
  79. package/dist/dashboard/assets/{list-row-DupvLfDx.js → list-row-1zzZYM0B.js} +1 -1
  80. package/dist/dashboard/assets/list-row-1zzZYM0B.js.gz +0 -0
  81. package/dist/dashboard/assets/{live-C4pEJUqI.js → live-BivGfuUs.js} +1 -1
  82. package/dist/dashboard/assets/live-BivGfuUs.js.gz +0 -0
  83. package/dist/dashboard/assets/{model-bans-BRiuufkB.js → model-bans-CmffjLuG.js} +1 -1
  84. package/dist/dashboard/assets/model-bans-CmffjLuG.js.gz +0 -0
  85. package/dist/dashboard/assets/{mutation-B4ROwk8L.js → mutation-46P0oWMe.js} +1 -1
  86. package/dist/dashboard/assets/mutation-46P0oWMe.js.gz +0 -0
  87. package/dist/dashboard/assets/numbers-DkfMR5Qs.js +2 -0
  88. package/dist/dashboard/assets/numbers-DkfMR5Qs.js.gz +0 -0
  89. package/dist/dashboard/assets/{query-keys-gmjzC3RS.js → query-keys-BFhGpwR1.js} +1 -1
  90. package/dist/dashboard/assets/query-keys-BFhGpwR1.js.gz +0 -0
  91. package/dist/dashboard/assets/{routing-C7tBT5o-.js → routing-CAb4F37R.js} +1 -1
  92. package/dist/dashboard/assets/routing-CAb4F37R.js.gz +0 -0
  93. package/dist/dashboard/assets/{select-WUCXorXw.js → select-BPpSsBgL.js} +1 -1
  94. package/dist/dashboard/assets/select-BPpSsBgL.js.gz +0 -0
  95. package/dist/dashboard/assets/{settings-C_zKl1GS.js → settings-DNX9sydi.js} +1 -1
  96. package/dist/dashboard/assets/settings-DNX9sydi.js.gz +0 -0
  97. package/dist/dashboard/assets/{stack-_3O0F8Xn.js → stack-BHTfXdz_.js} +1 -1
  98. package/dist/dashboard/assets/stack-BHTfXdz_.js.gz +0 -0
  99. package/dist/dashboard/assets/{state-BgTxHcJS.js → state-mPDRlQkl.js} +1 -1
  100. package/dist/dashboard/assets/state-mPDRlQkl.js.gz +0 -0
  101. package/dist/dashboard/assets/{switch-DEjTtOWf.js → switch-DOmJxg7H.js} +1 -1
  102. package/dist/dashboard/assets/switch-DOmJxg7H.js.gz +0 -0
  103. package/dist/dashboard/assets/tokens-COzario9.js +1 -0
  104. package/dist/dashboard/assets/tokens-COzario9.js.gz +0 -0
  105. package/dist/dashboard/assets/use-modal-focus-By7oJoGd.js +1 -0
  106. package/dist/dashboard/assets/use-modal-focus-By7oJoGd.js.gz +0 -0
  107. package/dist/dashboard/assets/{useMutation-BWSC0UAS.js → useMutation-Dem0lXQj.js} +1 -1
  108. package/dist/dashboard/assets/useMutation-Dem0lXQj.js.gz +0 -0
  109. package/dist/dashboard/index.html +1 -1
  110. package/dist/dashboard/index.html.gz +0 -0
  111. package/dist/dashboard/precompressed-assets.json +54 -54
  112. package/migrations/README.md +52 -52
  113. package/migrations/lite-sqlite3/0000_baseline.sql +2 -2
  114. package/package.json +95 -95
  115. package/scripts/cli/admin.ts +125 -0
  116. package/scripts/cli/{ui/background.ts → background.ts} +1 -1
  117. package/scripts/cli/{command/console-menu.ts → console-menu.ts} +275 -244
  118. package/scripts/cli/doctor.ts +37 -0
  119. package/scripts/cli/{support/env.ts → env.ts} +99 -99
  120. package/scripts/cli/{gateway/spawn.ts → gateway.ts} +112 -24
  121. package/scripts/cli/instance.ts +224 -0
  122. package/scripts/cli/main.ts +61 -18
  123. package/scripts/cli/{support/platform.ts → platform.ts} +64 -64
  124. package/scripts/cli/port.ts +100 -0
  125. package/scripts/cli/{support/services.ts → services.ts} +88 -88
  126. package/scripts/cli/{command/setup.ts → setup.ts} +35 -34
  127. package/scripts/cli/{command/start.ts → start.ts} +10 -5
  128. package/scripts/cli/{support/startup-signal.ts → startup-signal.ts} +23 -23
  129. package/scripts/cli/tray/index.ts +62 -12
  130. package/scripts/cli/{command/tray.ts → tray.ts} +208 -208
  131. package/scripts/cli/{command/update.ts → update.ts} +39 -39
  132. package/scripts/cli/version.ts +67 -0
  133. package/scripts/cli/{ui/wordmark.ts → wordmark.ts} +58 -58
  134. package/src/boot/app.ts +697 -693
  135. package/src/boot/dependencies.ts +647 -646
  136. package/src/boot/timeout.ts +2 -13
  137. package/src/config/cooldowns.ts +186 -138
  138. package/src/config/network.ts +8 -14
  139. package/src/config/providers.ts +7 -10
  140. package/src/config/runtime.ts +76 -76
  141. package/src/config/security.ts +15 -0
  142. package/src/config/spec.ts +20 -30
  143. package/src/config/telemetry.ts +0 -20
  144. package/src/config/timeouts.ts +0 -6
  145. package/src/console/admin/model-abuse/contracts.ts +28 -6
  146. package/src/console/admin/model-abuse/routes.ts +56 -3
  147. package/src/console/api-keys/contracts.ts +410 -414
  148. package/src/console/api-keys/routes.ts +881 -880
  149. package/src/console/auth/credentials.ts +3 -3
  150. package/src/console/auth/setup-service.ts +2 -2
  151. package/src/console/backup/export-endpoint.ts +6 -2
  152. package/src/console/backup/nine-router.ts +2 -5
  153. package/src/console/backup/service.ts +4 -0
  154. package/src/console/backup/store.ts +192 -29
  155. package/src/console/backup/validate.ts +3 -45
  156. package/src/console/cli-tools/fs-ops.ts +487 -487
  157. package/src/console/console-router-guards.ts +1 -1
  158. package/src/console/data-management/contracts.ts +13 -6
  159. package/src/console/data-management/operations.ts +23 -18
  160. package/src/console/data-management/routes.ts +0 -1
  161. package/src/console/domain-registration.ts +10 -5
  162. package/src/console/observability/live.ts +22 -0
  163. package/src/console/observability/store.ts +39 -16
  164. package/src/console/observability/usage-dimensions.ts +4 -4
  165. package/src/console/providers/catalog/catalog-projections.ts +6 -7
  166. package/src/console/providers/catalog/model-operations.ts +5 -2
  167. package/src/console/providers/catalog/provider-operations.ts +4 -4
  168. package/src/console/providers/catalog/public-model-store.ts +8 -11
  169. package/src/console/providers/catalog/store.ts +1553 -1553
  170. package/src/console/providers/oauth/routes.ts +8 -12
  171. package/src/console/quota/account-quota-tenant.ts +6 -6
  172. package/src/console/quota/quota-view.ts +1 -1
  173. package/src/console/remote-support/contracts.ts +61 -0
  174. package/src/console/remote-support/routes.ts +65 -0
  175. package/src/console/remote-support/service.ts +207 -0
  176. package/src/console/response-compression.ts +0 -3
  177. package/src/console/routing/pools/probe-result.ts +3 -2
  178. package/src/console/routing/pools/probe-verdict.ts +132 -132
  179. package/src/console/routing/pools/store.ts +8 -8
  180. package/src/console/settings/contracts.ts +58 -2
  181. package/src/console/settings/store.ts +30 -3
  182. package/src/console/shared/cursor.ts +8 -8
  183. package/src/console/shared/errors.ts +1 -3
  184. package/src/network/http2-bidi.ts +2 -4
  185. package/src/network/pool/agent.ts +478 -478
  186. package/src/network/pool/selector.ts +5 -4
  187. package/src/network/retry.ts +1 -2
  188. package/src/network/ssrf.ts +5 -4
  189. package/src/observability/event-compaction.ts +80 -78
  190. package/src/observability/event-day-index.ts +13 -15
  191. package/src/observability/event-log.ts +4 -4
  192. package/src/observability/logger.ts +6 -6
  193. package/src/observability/payload-capture.ts +151 -33
  194. package/src/observability/payload-store.ts +397 -440
  195. package/src/observability/redaction.ts +5 -6
  196. package/src/observability/request-notify.ts +43 -0
  197. package/src/observability/telemetry-status.ts +8 -9
  198. package/src/observability/token-speed.ts +5 -4
  199. package/src/persistence/db-mode.ts +7 -7
  200. package/src/persistence/readiness.ts +1 -1
  201. package/src/persistence/schema.ts +1406 -1402
  202. package/src/persistence/shared-cache.ts +1 -2
  203. package/src/persistence/stores/api-key-store.ts +18 -6
  204. package/src/persistence/stores/share-store.ts +4 -3
  205. package/src/persistence/stores/telemetry-store.ts +236 -237
  206. package/src/protocol/canonical-model.ts +11 -3
  207. package/src/protocol/codecs.ts +138 -4
  208. package/src/protocol/gateway-error.ts +6 -4
  209. package/src/protocol/request/chat.ts +21 -1
  210. package/src/protocol/request/codex.ts +10 -1
  211. package/src/protocol/request/messages.ts +4 -5
  212. package/src/protocol/request/responses.ts +9 -1
  213. package/src/protocol/response/chat.ts +34 -4
  214. package/src/protocol/response/codex.ts +7 -4
  215. package/src/protocol/response/dsml.ts +140 -28
  216. package/src/protocol/response/responses.ts +11 -5
  217. package/src/protocol/stream-error-frames.ts +4 -5
  218. package/src/providers/authentication/jwt-validator.ts +9 -10
  219. package/src/providers/authentication/oauth-refresh-service.ts +9 -11
  220. package/src/providers/configured-openai-providers.ts +134 -136
  221. package/src/providers/discovery/catalog/catalog-blob.ts +18 -18
  222. package/src/providers/discovery/catalog/catalog-format.ts +3 -6
  223. package/src/providers/discovery/models-dev-catalog.ts +354 -354
  224. package/src/providers/discovery/probe-phases.ts +4 -4
  225. package/src/providers/discovery/probe-wire.ts +6 -13
  226. package/src/providers/discovery/probing-service.ts +1117 -1045
  227. package/src/providers/integrations/alibabacp/alibabacp.ts +1 -1
  228. package/src/providers/integrations/antigravity/antigravity-quota.ts +5 -7
  229. package/src/providers/integrations/buddy/buddy-chat-shared.ts +23 -2
  230. package/src/providers/integrations/buddy/buddy-checkin.ts +1 -4
  231. package/src/providers/integrations/buddy/buddy-cli-headers.ts +23 -6
  232. package/src/providers/integrations/buddy/buddy-conversation-headers.ts +0 -1
  233. package/src/providers/integrations/buddy/buddy-identity.ts +52 -0
  234. package/src/providers/integrations/buddy/buddy-media.ts +2 -2
  235. package/src/providers/integrations/buddy/buddy-usage-store.ts +3 -4
  236. package/src/providers/integrations/buddy/codebuddy/quota.ts +2 -13
  237. package/src/providers/integrations/buddy/codebuddy/shared.ts +118 -115
  238. package/src/providers/integrations/buddy/workbuddy/quota.ts +0 -11
  239. package/src/providers/integrations/buddy/workbuddy/shared.ts +5 -2
  240. package/src/providers/integrations/claude/claude.ts +608 -608
  241. package/src/providers/integrations/cline/cline-quota.ts +132 -132
  242. package/src/providers/integrations/cline/cline.ts +537 -539
  243. package/src/providers/integrations/codex/codex-oauth.ts +1 -1
  244. package/src/providers/integrations/commandcode/commandcode.ts +674 -675
  245. package/src/providers/integrations/commandcode/discovery.ts +117 -116
  246. package/src/providers/integrations/cursor/cursor-auth.ts +418 -418
  247. package/src/providers/integrations/cursor/cursor-quota.ts +309 -310
  248. package/src/providers/integrations/cursor/cursor.ts +11 -163
  249. package/src/providers/integrations/factory/factory-models.ts +3 -5
  250. package/src/providers/integrations/grok/grok-install-id.ts +2 -3
  251. package/src/providers/integrations/grok/grok.ts +6 -7
  252. package/src/providers/integrations/hermes.ts +14 -18
  253. package/src/providers/integrations/kiro/kiro-discovery.ts +1 -1
  254. package/src/providers/integrations/kiro/kiro.ts +1 -1
  255. package/src/providers/integrations/mistral.ts +6 -6
  256. package/src/providers/integrations/opencode/fingerprint.ts +3 -3
  257. package/src/providers/integrations/opencode/provider.ts +8 -8
  258. package/src/providers/integrations/qoder/qoder-catalog.ts +450 -450
  259. package/src/providers/integrations/qoder/qoder-oauth.ts +388 -310
  260. package/src/providers/integrations/qoder/qoder.ts +884 -865
  261. package/src/providers/integrations/shared/connect.ts +6 -6
  262. package/src/providers/integrations/trae/trae-catalog.ts +1 -2
  263. package/src/providers/integrations/trae/trae.ts +2 -2
  264. package/src/providers/integrations/zcode/oauth.ts +6 -1
  265. package/src/providers/operations/account-health-service.ts +1528 -1523
  266. package/src/providers/operations/account-reset-service.ts +1 -1
  267. package/src/providers/operations/client-versions.ts +6 -6
  268. package/src/providers/operations/custom-cli-headers.ts +3 -4
  269. package/src/providers/operations/provider-catalog-seeder.ts +95 -95
  270. package/src/providers/operations/provider-catalog-service.ts +3 -4
  271. package/src/providers/provider-metadata.ts +4 -8
  272. package/src/providers/provider-registry.ts +6 -7
  273. package/src/providers/provider-usage.ts +1 -1
  274. package/src/security/abuse.ts +15 -10
  275. package/src/security/access-control.ts +144 -146
  276. package/src/security/admission/contracts.ts +11 -0
  277. package/src/security/admission/in-memory-store.ts +4 -3
  278. package/src/security/admission/lease-sweep.ts +6 -6
  279. package/src/security/admission/reasons.ts +5 -1
  280. package/src/security/admission/redis-store.ts +211 -210
  281. package/src/security/admission/service.ts +4 -0
  282. package/src/security/api-key-auth.ts +504 -504
  283. package/src/security/ip-boundary.ts +155 -15
  284. package/src/security/model-abuse.ts +366 -94
  285. package/src/security/outbound-headers.ts +3 -4
  286. package/src/shared/sleep.ts +4 -0
  287. package/src/transport/dispatch/attempt-finalize.ts +47 -5
  288. package/src/transport/dispatch/attempt-loop.ts +531 -530
  289. package/src/transport/dispatch/stream-bridge.ts +1 -3
  290. package/src/transport/dispatch/streaming/finalize.ts +1 -1
  291. package/src/transport/dispatch/streaming/stream.ts +5 -6
  292. package/src/transport/failure-policy.ts +162 -8
  293. package/src/transport/guards/auth-guard.ts +7 -4
  294. package/src/transport/guards/concurrency-guard.ts +0 -5
  295. package/src/transport/guards/error-lifecycle.ts +13 -6
  296. package/src/transport/guards/pipeline.ts +15 -3
  297. package/src/transport/guards/request-context.ts +8 -2
  298. package/src/transport/request/inflight.ts +5 -6
  299. package/src/transport/request/preparer.ts +2 -2
  300. package/src/transport/request/state.ts +12 -8
  301. package/src/transport/routing/admission.ts +4 -0
  302. package/src/transport/routing/route-catalog.ts +8 -1
  303. package/src/transport/routing/route-model.ts +46 -14
  304. package/src/transport/routing/router.ts +21 -14
  305. package/src/transport/surface/completion.ts +2 -3
  306. package/src/transport/surface/dialects.ts +5 -6
  307. package/src/transport/surface/responses/encode.ts +38 -0
  308. package/src/transport/surface/responses/parse.ts +4 -0
  309. package/src/transport/translation/capabilities.ts +2 -3
  310. package/src/transport/translation/quirks.ts +3 -3
  311. package/src/workers/buddy-growth-tasks.ts +5 -10
  312. package/src/workers/daily-checkin.ts +17 -23
  313. package/src/workers/quota-refresh-sweep.ts +26 -26
  314. package/dist/dashboard/assets/BannedPage-C2orBsI2.js.gz +0 -0
  315. package/dist/dashboard/assets/CliToolDetailPage-DUcZ6pMB.js +0 -1
  316. package/dist/dashboard/assets/CliToolDetailPage-DUcZ6pMB.js.gz +0 -0
  317. package/dist/dashboard/assets/CliToolsPage-BV6pxVQo.js.gz +0 -0
  318. package/dist/dashboard/assets/CombosPage-Bsan4679.js +0 -1
  319. package/dist/dashboard/assets/CombosPage-Bsan4679.js.gz +0 -0
  320. package/dist/dashboard/assets/ConsoleLogPage-CW-wPmJ6.js.gz +0 -0
  321. package/dist/dashboard/assets/CustomizationPage-0p8uX7vB.js.gz +0 -0
  322. package/dist/dashboard/assets/HealthEventsModal-BjqSMfko.js.gz +0 -0
  323. package/dist/dashboard/assets/LoginPage-BvzCx8Kr.js.gz +0 -0
  324. package/dist/dashboard/assets/ModelPicker-BY1aKEkP.js +0 -1
  325. package/dist/dashboard/assets/ModelPicker-BY1aKEkP.js.gz +0 -0
  326. package/dist/dashboard/assets/OverviewPage-DOQpy1y0.js +0 -1
  327. package/dist/dashboard/assets/OverviewPage-DOQpy1y0.js.gz +0 -0
  328. package/dist/dashboard/assets/ProviderDetailPage-ecxyUMzt.js +0 -3
  329. package/dist/dashboard/assets/ProviderDetailPage-ecxyUMzt.js.gz +0 -0
  330. package/dist/dashboard/assets/ProvidersPage-Js9jUPSb.js.gz +0 -0
  331. package/dist/dashboard/assets/ProxyPage-J3TBi3Kx.js +0 -7
  332. package/dist/dashboard/assets/ProxyPage-J3TBi3Kx.js.gz +0 -0
  333. package/dist/dashboard/assets/QuotaPage-C1gZS6xr.js +0 -1
  334. package/dist/dashboard/assets/QuotaPage-C1gZS6xr.js.gz +0 -0
  335. package/dist/dashboard/assets/SettingsPage-CS86ho5X.js +0 -1
  336. package/dist/dashboard/assets/SettingsPage-CS86ho5X.js.gz +0 -0
  337. package/dist/dashboard/assets/SetupPage-DDbMQsoo.js.gz +0 -0
  338. package/dist/dashboard/assets/SortableList-CB3Z7VTf.js.gz +0 -0
  339. package/dist/dashboard/assets/StudioPage-D95UCPHI.js.gz +0 -0
  340. package/dist/dashboard/assets/UsagePage-Cnae9SWA.js +0 -56
  341. package/dist/dashboard/assets/UsagePage-Cnae9SWA.js.gz +0 -0
  342. package/dist/dashboard/assets/api-keys-VY68Mhd1.js +0 -1
  343. package/dist/dashboard/assets/api-keys-VY68Mhd1.js.gz +0 -0
  344. package/dist/dashboard/assets/app-COlK95oi.css +0 -2
  345. package/dist/dashboard/assets/app-COlK95oi.css.gz +0 -0
  346. package/dist/dashboard/assets/assert-Chi7pCnt.js.gz +0 -0
  347. package/dist/dashboard/assets/badge-D4pQapKj.js.gz +0 -0
  348. package/dist/dashboard/assets/banner-DH6cg-_B.js.gz +0 -0
  349. package/dist/dashboard/assets/card-BKqlUOe4.js.gz +0 -0
  350. package/dist/dashboard/assets/cli-tools-fTgljTGA.js.gz +0 -0
  351. package/dist/dashboard/assets/clipboard-button-D5Ys_sM0.js.gz +0 -0
  352. package/dist/dashboard/assets/dialog-BYj-8WuK.js +0 -1
  353. package/dist/dashboard/assets/dialog-BYj-8WuK.js.gz +0 -0
  354. package/dist/dashboard/assets/drawer-CmXIZ6H1.js +0 -1
  355. package/dist/dashboard/assets/drawer-CmXIZ6H1.js.gz +0 -0
  356. package/dist/dashboard/assets/entry-CBg5zeu8.js +0 -1
  357. package/dist/dashboard/assets/entry-CBg5zeu8.js.gz +0 -0
  358. package/dist/dashboard/assets/entry-CQBr6U9c.js.gz +0 -0
  359. package/dist/dashboard/assets/entry-CgdSAzEV.js +0 -4
  360. package/dist/dashboard/assets/entry-CgdSAzEV.js.gz +0 -0
  361. package/dist/dashboard/assets/fingerprint-pattern-BWuHtbW4.js.gz +0 -0
  362. package/dist/dashboard/assets/github-badge-BlptL8Uf.js.gz +0 -0
  363. package/dist/dashboard/assets/index-js6TJvdr.js.gz +0 -0
  364. package/dist/dashboard/assets/inline-DUWDSmDV.js.gz +0 -0
  365. package/dist/dashboard/assets/input-LVjgZq4f.js.gz +0 -0
  366. package/dist/dashboard/assets/layout-CPfxpsfy.js +0 -1
  367. package/dist/dashboard/assets/layout-CPfxpsfy.js.gz +0 -0
  368. package/dist/dashboard/assets/list-row-DupvLfDx.js.gz +0 -0
  369. package/dist/dashboard/assets/live-C4pEJUqI.js.gz +0 -0
  370. package/dist/dashboard/assets/model-bans-BRiuufkB.js.gz +0 -0
  371. package/dist/dashboard/assets/mutation-B4ROwk8L.js.gz +0 -0
  372. package/dist/dashboard/assets/numbers-YNcHvRY7.js +0 -2
  373. package/dist/dashboard/assets/numbers-YNcHvRY7.js.gz +0 -0
  374. package/dist/dashboard/assets/query-keys-gmjzC3RS.js.gz +0 -0
  375. package/dist/dashboard/assets/routing-C7tBT5o-.js.gz +0 -0
  376. package/dist/dashboard/assets/select-WUCXorXw.js.gz +0 -0
  377. package/dist/dashboard/assets/settings-C_zKl1GS.js.gz +0 -0
  378. package/dist/dashboard/assets/stack-_3O0F8Xn.js.gz +0 -0
  379. package/dist/dashboard/assets/state-BgTxHcJS.js.gz +0 -0
  380. package/dist/dashboard/assets/switch-DEjTtOWf.js.gz +0 -0
  381. package/dist/dashboard/assets/tokens-DDxMbSRL.js +0 -1
  382. package/dist/dashboard/assets/tokens-DDxMbSRL.js.gz +0 -0
  383. package/dist/dashboard/assets/use-presence-BSZEBx7U.js +0 -1
  384. package/dist/dashboard/assets/use-presence-BSZEBx7U.js.gz +0 -0
  385. package/dist/dashboard/assets/useMutation-BWSC0UAS.js.gz +0 -0
  386. package/scripts/cli/command/admin.ts +0 -65
  387. package/scripts/cli/command/doctor.ts +0 -257
  388. package/scripts/cli/gateway/drain-token.ts +0 -78
  389. package/scripts/cli/gateway/instance-lock.ts +0 -105
  390. package/scripts/cli/gateway/stop.ts +0 -51
  391. package/scripts/cli/support/bun.ts +0 -27
  392. package/scripts/cli/support/version.ts +0 -37
  393. /package/dist/dashboard/assets/{app-DCQZRmHY.js → app-BIt446yE.js} +0 -0
  394. /package/dist/dashboard/assets/{app-DCQZRmHY.js.gz → app-BIt446yE.js.gz} +0 -0
  395. /package/scripts/cli/{ui/prompt.ts → prompt.ts} +0 -0
@@ -1,1553 +1,1553 @@
1
- // Drizzle-backed provider, account, model, routing, and API-key repositories.
2
- import { and, asc, eq, inArray, isNull, or, sql, type SQL } from "drizzle-orm";
3
- import { ConsoleDomainError } from "../../shared/errors";
4
- import { parseCustomProviderId, isBundledProviderId } from "../../../providers/provider-contracts";
5
- import type { ModelDefinition } from "../../../providers/provider-model-definition";
6
- import type { ProviderRegistry } from "../../../providers/provider-registry";
7
- import { BUNDLED_PROVIDER_MODULES } from "../../../providers/default-registry";
8
- import { modelsDevCatalog } from "../../../providers/discovery/models-dev-catalog";
9
- import { globalOrOwnedBy, ownedByOnly } from "../../../persistence/tenant-scope";
10
- import { nextSortIndex, reorderSortIndex } from "../../../persistence/sort-order";
11
- import type { CartethyiaDatabase } from "../../../persistence/db-handle";
12
- import { buddyTaskLedger, models, providerAccounts, providerOauthStates, providers, telemetryUsageTotals, tenantDisabledModels } from "../../../persistence/schema";
13
- import { queryTelemetryEvents } from "../../../observability/event-log";
14
- import { isGatewayError } from "../../../observability/telemetry-status";
15
- import type { WireFamily } from "../../../protocol/canonical-model";
16
- import { listAccountHealthEvents, recoverAccount, deleteAccountHealthEvents, type AccountHealthEventRecord } from "../../../providers/operations/account-health-service";
17
- import { invalidateCredentialCache } from "../../../providers/operations/provider-credential-service";
18
- import { encryptCredential, hashSecret } from "../../../security/crypto";
19
- import type { TelemetryBatchBuffer } from "../../../observability/telemetry-buffer";
20
- import { bumpCatalogRevision, type BundledProviderCatalog } from "../../../providers/operations/provider-catalog-service";
21
- import type { AccountInflightReading, BuddyGrowthTaskLedgerEntry, ByokConnectionTestRequest, ByokConnectionTestResult, CreateProviderAccountBatchItemResult, CreateProviderAccountBatchResult, CreateProviderAccountRequest, CredentialMode, ModelCatalogEntry, ProbeAllAccountsResult, ProbeAllModelsResult, ProbeModelRequest, ProbeModelResult, ProviderAccountResponse, ProviderAccountTokenUsage, ProviderCatalogStore, ProviderRecord, SetModelEnabledRequest, UpdateModelMetadataRequest, UpdateProviderAccountRequest, ModelMetadataOverride } from "./contracts";
22
- import { isServiceKind, validateCompatibilityProfile } from "./contracts";
23
- import { ProviderProbingService, type ProbeOutboundResolver } from "../../../providers/discovery/probing-service";
24
- import {
25
- applyModelMetadataOverride,
26
- FALLBACK_CONTEXT_LIMIT,
27
- FALLBACK_OUTPUT_LIMIT,
28
- resolveManualModelMetadata,
29
- } from "../../../providers/provider-model-definition";
30
- import { isUniqueViolation } from "../../../persistence/boot";
31
- import { DEFAULT_ENDPOINT_BY_WIRE_FAMILY, endpointPathForProviderModel, mapProviderRow } from "./catalog-projections";
32
- import { pushStructuredConsoleLog } from "../../../observability/log-ring";
33
- import { decodeJwtPayload } from "../../../providers/authentication/oauth-flow-store";
34
-
35
- /** Real Drizzle-backed provider and model catalog repository. */
36
-
37
- /** Field names a pasted OAuth credential may carry its access token under. */
38
- const OAUTH_ACCESS_FIELDS = ["accessToken", "access_token", "access", "token"] as const;
39
- /** Field names a pasted OAuth credential may carry its refresh token under. */
40
- const OAUTH_REFRESH_FIELDS = ["refreshToken", "refresh_token", "refresh"] as const;
41
-
42
- function firstString(record: Record<string, unknown>, fields: readonly string[]): string | undefined {
43
- for (const field of fields) {
44
- const value = record[field];
45
- if (typeof value === "string" && value.trim().length > 0) return value.trim();
46
- }
47
- return undefined;
48
- }
49
-
50
- /** Parses an expiry field that may be an ISO string, an epoch ms, or epoch seconds. */
51
- function parseExpiry(value: unknown): Date | undefined {
52
- if (typeof value === "number" && Number.isFinite(value)) {
53
- // Heuristic: a 10-digit value is epoch seconds, a 13-digit value is ms.
54
- const ms = value < 1e12 ? value * 1000 : value;
55
- const date = new Date(ms);
56
- return Number.isFinite(date.getTime()) ? date : undefined;
57
- }
58
- if (typeof value === "string" && value.trim().length > 0) {
59
- const asNumber = Number(value);
60
- if (Number.isFinite(asNumber)) return parseExpiry(asNumber);
61
- const date = new Date(value);
62
- return Number.isFinite(date.getTime()) ? date : undefined;
63
- }
64
- return undefined;
65
- }
66
- function parseJwtCredential(raw: string): { readonly expiresAt?: Date } | undefined {
67
- const payload = decodeJwtPayload(raw.trim());
68
- if (payload === undefined) return undefined;
69
- const expiresAt = parseExpiry(payload["exp"]);
70
- return expiresAt === undefined ? {} : { expiresAt };
71
- }
72
-
73
- /**
74
- * Splits a pasted OAuth credential into its access token, refresh token, and
75
- * expiry.
76
- *
77
- * The console accepts either a bare access token or the JSON export a client
78
- * produces (`{accessToken, refreshToken, expiresAt, …}`, possibly nested under
79
- * `data`). A bare token has no refresh token — the account can be dispatched
80
- * with it but cannot be refreshed, so the caller stores it as a static
81
- * API credential instead of sending the access token to a refresh endpoint.
82
- */
83
- /**
84
- * Middle segment of an auto-generated account label, from how the credential
85
- * was actually detected.
86
- *
87
- * Only the two kinds that arrive with no name of their own are named here. An
88
- * OAuth paste carries its own identity in `authState` (the account it was
89
- * exported from), so it keeps whatever the export or the operator supplied —
90
- * generating a name for it would overwrite an identity that was already there.
91
- * An unrecognized mode falls back to `key`, matching the mode it is stored as.
92
- */
93
- export function accountLabelKindSegment(credentialMode: CredentialMode): string {
94
- return credentialMode === "jwt" ? "jwt" : "key";
95
- }
96
-
97
- /**
98
- * First unused `dcint-key-N` / `dcint-jwt-N` label for one provider.
99
- *
100
- * Polished tokens and plain JWTs are the credentials that arrive nameless: an
101
- * operator pastes several into one provider and every row came out as
102
- * "<providerId> account", so five accounts were five identical labels and the
103
- * list could not tell them apart — which account failed, or is cooling, was
104
- * unreadable.
105
- *
106
- * Numbering runs per kind, so the first JWT is `jwt-1` rather than inheriting
107
- * whatever number the api_key rows reached (`jwt-3` with no `jwt-1` reads as
108
- * two missing accounts). Counting is case-insensitive on the whole label, so a
109
- * hand-typed `DCINT-KEY-2` still reserves its number.
110
- *
111
- * Pure, so the numbering can be pinned without a database.
112
- */
113
- export function nextGeneratedAccountLabel(
114
- providerId: string,
115
- credentialMode: CredentialMode,
116
- existingLabels: readonly string[],
117
- ): string {
118
- const kind = accountLabelKindSegment(credentialMode);
119
- const prefix = `${providerId}-${kind}-`;
120
- const taken = new Set(
121
- existingLabels.map((label) => label.trim().toLowerCase()),
122
- );
123
- for (let n = 1; ; n += 1) {
124
- const candidate = `${prefix}${n}`;
125
- if (!taken.has(candidate.toLowerCase())) return candidate;
126
- }
127
- }
128
-
129
- export function parsePastedOAuthCredential(raw: string): {
130
- readonly accessToken: string;
131
- readonly refreshToken: string;
132
- readonly expiresAt: Date | undefined;
133
- /**
134
- * Non-secret account configuration the export carried — the profile the
135
- * account is bound to, how it authenticates, the region it was minted in.
136
- * Empty for a bare token, which carries none.
137
- */
138
- readonly authState: Readonly<Record<string, unknown>>;
139
- } {
140
- let record: Record<string, unknown> | undefined;
141
- try {
142
- const parsed = JSON.parse(raw) as unknown;
143
- if (parsed !== null && typeof parsed === "object" && !Array.isArray(parsed)) {
144
- record = parsed as Record<string, unknown>;
145
- }
146
- } catch {
147
- record = undefined;
148
- }
149
- if (record === undefined) {
150
- // Bare token: it is the access token, and there is no refresh token.
151
- const jwt = parseJwtCredential(raw);
152
- return {
153
- accessToken: raw,
154
- refreshToken: raw,
155
- expiresAt: jwt?.expiresAt,
156
- authState: {},
157
- };
158
- }
159
- // A nested `data` object is unwrapped the same way the dashboard's parser does.
160
- const nested =
161
- record["data"] !== null && typeof record["data"] === "object" && !Array.isArray(record["data"])
162
- ? (record["data"] as Record<string, unknown>)
163
- : undefined;
164
- const accessToken =
165
- firstString(record, OAUTH_ACCESS_FIELDS) ??
166
- (nested ? firstString(nested, OAUTH_ACCESS_FIELDS) : undefined) ??
167
- raw;
168
- const refreshToken =
169
- firstString(record, OAUTH_REFRESH_FIELDS) ??
170
- (nested ? firstString(nested, OAUTH_REFRESH_FIELDS) : undefined) ??
171
- accessToken;
172
- const expiresAt =
173
- parseExpiry(
174
- record["expiresAt"] ??
175
- record["expires_at"] ??
176
- (nested ? (nested["expiresAt"] ?? nested["expires_at"]) : undefined) ??
177
- record["expires"] ??
178
- (nested ? nested["expires"] : undefined),
179
- ) ?? parseJwtCredential(accessToken)?.expiresAt;
180
- return { accessToken, refreshToken, expiresAt, authState: pastedAuthState(record, nested) };
181
- }
182
-
183
- /**
184
- * The account configuration a pasted export carries beside its tokens.
185
- *
186
- * Kiro refuses a generation request that omits the profile the account is bound
187
- * to, and legacy exports carry that profile under a different key. Reading
188
- * neither is how an imported account probes with no
189
- * profile and is answered `400 profileArn is required`.
190
- */
191
- function pastedAuthState(
192
- record: Record<string, unknown>,
193
- nested: Record<string, unknown> | undefined,
194
- ): Readonly<Record<string, unknown>> {
195
- const sources = [record, nested, asRecord(record["providerSpecificData"]), asRecord(record["authState"]), asRecord(nested?.["authState"])];
196
- const profileArn = firstStringFrom(sources, ["profileArn", "profile_arn"]);
197
- const authMethod = firstStringFrom(sources, ["authMethod", "auth_method"]);
198
- const region = firstStringFrom(sources, ["region"]);
199
- return {
200
- ...(profileArn === undefined ? {} : { profileArn }),
201
- ...(authMethod === undefined ? {} : { authMethod }),
202
- ...(region === undefined ? {} : { region }),
203
- };
204
- }
205
-
206
- function asRecord(value: unknown): Record<string, unknown> | undefined {
207
- return value !== null && typeof value === "object" && !Array.isArray(value)
208
- ? (value as Record<string, unknown>)
209
- : undefined;
210
- }
211
-
212
- /**
213
- * Adds or removes the `image` input modality on a stored modalities blob.
214
- *
215
- * The blob is a JSON string whose shape varies by source (bundled seed,
216
- * discovery, manual add), so it is normalized rather than pattern-matched: an
217
- * unparseable blob is replaced with the minimal text shape rather than left to
218
- * corrupt capability preflight.
219
- */
220
- export function withImageModality(stored: string | null | undefined, image: boolean): string {
221
- const parsed = asRecord(safeParseJson(stored));
222
- const input = Array.isArray(parsed?.input)
223
- ? (parsed.input as unknown[]).filter((m): m is string => typeof m === "string")
224
- : ["text"];
225
- const output = Array.isArray(parsed?.output)
226
- ? (parsed.output as unknown[]).filter((m): m is string => typeof m === "string")
227
- : ["text"];
228
- const nextInput = image
229
- ? Array.from(new Set([...input, "image"]))
230
- : input.filter((m) => m !== "image");
231
- return JSON.stringify({
232
- ...(parsed ?? {}),
233
- input: nextInput.length > 0 ? nextInput : ["text"],
234
- output: output.length > 0 ? output : ["text"],
235
- });
236
- }
237
-
238
- function safeParseJson(value: string | null | undefined): unknown {
239
- if (typeof value !== "string" || value.length === 0) return undefined;
240
- try {
241
- return JSON.parse(value) as unknown;
242
- } catch {
243
- return undefined;
244
- }
245
- }
246
-
247
- /** Normalizes a text column read as `unknown` back to a string or null. */
248
- function asStoredText(value: unknown): string | null {
249
- return typeof value === "string" ? value : null;
250
- }
251
-
252
- function firstStringFrom(
253
- sources: readonly (Record<string, unknown> | undefined)[],
254
- fields: readonly string[],
255
- ): string | undefined {
256
- for (const source of sources) {
257
- if (source === undefined) continue;
258
- const value = firstString(source, fields);
259
- if (value !== undefined) return value;
260
- }
261
- return undefined;
262
- }
263
-
264
- /**
265
- * The account's still-active per-model backoffs, or `undefined` when none are.
266
- *
267
- * Expired entries are dropped rather than returned: the sweep that clears them
268
- * runs on a timer, so between the deadline passing and the sweep a raw read
269
- * would report a cooldown that no longer applies.
270
- */
271
- function liveModelCooldowns(
272
- raw: unknown,
273
- ): { modelCooldowns: Record<string, string> } | undefined {
274
- if (raw === null || typeof raw !== "object" || Array.isArray(raw)) return undefined;
275
- const now = Date.now();
276
- const live: Record<string, string> = {};
277
- for (const [modelId, value] of Object.entries(raw as Record<string, unknown>)) {
278
- if (typeof value !== "string") continue;
279
- const at = new Date(value).getTime();
280
- if (Number.isFinite(at) && at > now) live[modelId] = value;
281
- }
282
- return Object.keys(live).length > 0 ? { modelCooldowns: live } : undefined;
283
- }
284
-
285
-
286
- /**
287
- * Projects one persisted `models` row into the console's catalog entry.
288
- *
289
- * `disabled` is resolved by the caller (from `tenant_disabled_models`), because
290
- * the bulk read has to look it up across every provider at once while the
291
- * single-provider read looks it up for one; the projection itself is identical
292
- * either way, so it lives here rather than being written twice.
293
- *
294
- * Exported so a test can pin the unsized-model substitution without a database:
295
- * which rows get the fallback, and that they are the same rows marked estimated.
296
- */
297
- export function mapModelRow(
298
- row: typeof models.$inferSelect,
299
- disabled: boolean,
300
- ): ModelCatalogEntry {
301
- const modalities =
302
- row.modalities && typeof row.modalities === "object"
303
- ? (row.modalities as { input?: unknown; output?: unknown })
304
- : undefined;
305
- const inputModalities = Array.isArray(modalities?.input) ? modalities.input : [];
306
- const outputModalities = Array.isArray(modalities?.output) ? modalities.output : [];
307
- // Nothing filed this model — not its own row, not the base catalog under this
308
- // provider's id, not a majority across the catalog's other resellers. The
309
- // fallback is substituted here rather than persisted: the stored `null` is the
310
- // honest record of what upstream said, and substituting at the write would
311
- // make a guess indistinguishable from a measurement on the next read. The
312
- // marker rides along so the card can say which one it is showing.
313
- const unfiled =
314
- row.contextLimit === null &&
315
- modelsDevCatalog.resolve(row.providerId, row.modelId) === undefined &&
316
- modelsDevCatalog.majorityFor(row.modelId) === undefined;
317
- return {
318
- modelId: row.modelId,
319
- route: row.endpointPath,
320
- provider: row.providerId,
321
- wireFamily: row.wireFamily,
322
- serviceKind: row.serviceKind,
323
- enabled: disabled ? false : row.enabled,
324
- // A fetched model that published no context size read `n/a ctx · n/a out`
325
- // while still being routable — a working model that looked unusable. These
326
- // rows get the same ceilings a manual add is offered, flagged as estimated.
327
- contextLimit: unfiled ? FALLBACK_CONTEXT_LIMIT : row.contextLimit,
328
- outputLimit: unfiled ? FALLBACK_OUTPUT_LIMIT : row.outputLimit,
329
- reasoning: row.reasoning,
330
- toolCall: row.toolCall,
331
- vision: inputModalities.includes("image"),
332
- document: inputModalities.includes("document"),
333
- audio: inputModalities.includes("audio"),
334
- mediaGeneration: outputModalities.includes("image"),
335
- cost: row.cost as ModelCatalogEntry["cost"],
336
- source: row.source,
337
- ...(unfiled ? { limitsEstimated: true } : {}),
338
- // Three-valued on the wire as it is in the column: only a row that claims
339
- // `true` is pro-only. Absence means nobody established the tier, and the
340
- // surface must render that as unknown rather than as free.
341
- ...(row.proOnly === true ? { proOnly: true } : {}),
342
- // Same three-valued rule, from the provider's own directory. Qoder states
343
- // `is_free` per row, so a plan change moves the pill without a code change.
344
- ...(row.freeTier === true ? { freeTier: true } : {}),
345
- sourceUpdatedAt: row.sourceUpdatedAt?.toISOString() ?? null,
346
- } satisfies ModelCatalogEntry;
347
- }
348
-
349
- export class DrizzleProviderCatalogStore implements ProviderCatalogStore {
350
- private readonly probing: ProviderProbingService;
351
- private readonly bundledModelCatalog: ReadonlyMap<string, readonly ModelDefinition[]>;
352
- private readonly providerRegistry: ProviderRegistry;
353
-
354
- private readonly readAccountInflight:
355
- | ((
356
- providerId: string,
357
- tenantId: string,
358
- ) => Promise<readonly AccountInflightReading[]>)
359
- | undefined;
360
-
361
- constructor(
362
- private readonly db: CartethyiaDatabase,
363
- options: {
364
- readonly telemetryBuffer: TelemetryBatchBuffer;
365
- readonly bundledModelCatalog: BundledProviderCatalog;
366
- readonly providerRegistry: ProviderRegistry;
367
- readonly outboundFetchFor: ProbeOutboundResolver;
368
- readonly snapshotInvalidator: { invalidate(): unknown };
369
- readonly readAccountInflight?:
370
- | ((
371
- providerId: string,
372
- tenantId: string,
373
- ) => Promise<readonly AccountInflightReading[]>)
374
- | undefined;
375
- },
376
- ) {
377
- this.bundledModelCatalog = options.bundledModelCatalog.modelsByProvider;
378
- this.providerRegistry = options.providerRegistry;
379
- this.readAccountInflight = options.readAccountInflight;
380
- this.probing = new ProviderProbingService({
381
- db,
382
- telemetryBuffer: options.telemetryBuffer,
383
- defaultEndpoints: DEFAULT_ENDPOINT_BY_WIRE_FAMILY,
384
- bundledModelCatalog: this.bundledModelCatalog,
385
- outboundFetchFor: options.outboundFetchFor,
386
- snapshotInvalidator: options.snapshotInvalidator,
387
- providerRegistry: options.providerRegistry,
388
- });
389
- }
390
- async list(tenantId: string): Promise<readonly ProviderRecord[]> {
391
- const bundledIds = BUNDLED_PROVIDER_MODULES.map((provider) => provider.id);
392
- const rows = await this.db
393
- .select()
394
- .from(providers)
395
- .where(
396
- and(
397
- globalOrOwnedBy(providers.tenantId, tenantId),
398
- or(inArray(providers.id, bundledIds), eq(providers.tenantId, tenantId)),
399
- ),
400
- )
401
- .orderBy(asc(providers.id));
402
- return rows.map((row) => mapProviderRow(row, this.providerRegistry));
403
- }
404
- async get(tenantId: string, providerId: string): Promise<ProviderRecord | undefined> {
405
- const rows = await this.db
406
- .select()
407
- .from(providers)
408
- .where(
409
- and(
410
- eq(providers.id, providerId),
411
- globalOrOwnedBy(providers.tenantId, tenantId),
412
- ),
413
- )
414
- .limit(1);
415
- const row = rows[0];
416
- return row ? mapProviderRow(row, this.providerRegistry) : undefined;
417
- }
418
- async create(record: ProviderRecord): Promise<void> {
419
- if (isBundledProviderId(record.providerId))
420
- throw new ConsoleDomainError(
421
- "provider_is_builtin",
422
- 403,
423
- "Built-in provider cannot be created",
424
- );
425
- parseCustomProviderId(record.providerId);
426
- if (record.compatibilityProfile) validateCompatibilityProfile(record.compatibilityProfile);
427
- await this.db.insert(providers).values({
428
- id: record.providerId,
429
- tenantId: record.tenantId ?? null,
430
- enabled: record.enabled,
431
- ...(record.wireFamilyDefault ? { wireFamilyDefault: record.wireFamilyDefault } : {}),
432
- capabilityProfile: record.capabilityProfile ?? null,
433
- baseUrl: record.baseUrl ?? null,
434
- compatibilityProfile: record.compatibilityProfile ?? null,
435
- });
436
- }
437
- async update(
438
- tenantId: string,
439
- providerId: string,
440
- patch: Partial<ProviderRecord>,
441
- ): Promise<ProviderRecord | undefined> {
442
- if (isBundledProviderId(providerId))
443
- throw new ConsoleDomainError(
444
- "provider_is_builtin",
445
- 403,
446
- "Built-in provider cannot be updated",
447
- );
448
- if (patch.compatibilityProfile) validateCompatibilityProfile(patch.compatibilityProfile);
449
- const set: Partial<typeof providers.$inferInsert> = {};
450
- if (patch.enabled !== undefined) set.enabled = patch.enabled;
451
- if (patch.capabilityProfile !== undefined) set.capabilityProfile = patch.capabilityProfile;
452
- if (patch.baseUrl !== undefined) set.baseUrl = patch.baseUrl;
453
- if (patch.compatibilityProfile !== undefined)
454
- set.compatibilityProfile = patch.compatibilityProfile;
455
- const rows =
456
- Object.keys(set).length > 0
457
- ? await this.db
458
- .update(providers)
459
- .set(set)
460
- .where(
461
- and(
462
- eq(providers.id, providerId),
463
- ownedByOnly(providers.tenantId, tenantId),
464
- ),
465
- )
466
- .returning()
467
- : await this.db
468
- .select()
469
- .from(providers)
470
- .where(
471
- and(
472
- eq(providers.id, providerId),
473
- ownedByOnly(providers.tenantId, tenantId),
474
- ),
475
- );
476
- const row = rows[0];
477
- return row ? mapProviderRow(row, this.providerRegistry) : undefined;
478
- }
479
- async delete(tenantId: string, providerId: string): Promise<boolean> {
480
- if (isBundledProviderId(providerId))
481
- throw new ConsoleDomainError(
482
- "provider_is_builtin",
483
- 403,
484
- "Built-in provider cannot be deleted",
485
- );
486
- // Custom-provider rows own their models, accounts, disabled-model flags,
487
- // and routing preferences: deleting the parent cascades all of them
488
- // (models/provider_accounts/provider_oauth_states via provider FK,
489
- // health_events via account FK, tenant_disabled_models and
490
- // provider_routing_settings via provider FK) in one atomic DELETE.
491
- const deleted = await this.db
492
- .delete(providers)
493
- .where(and(eq(providers.id, providerId), ownedByOnly(providers.tenantId, tenantId)))
494
- .returning({ id: providers.id });
495
- if (deleted.length === 0) return false;
496
- this.providerRegistry.unregister(providerId);
497
- // The provider left the registry, so the compiled catalog no longer
498
- // contains it — the snapshot's re-seed is gated on that revision, and a
499
- // delete that did not bump it would leave the provider's model rows behind.
500
- bumpCatalogRevision();
501
- return true;
502
- }
503
- async updateGlobal(
504
- providerId: string,
505
- patch: Partial<ProviderRecord>,
506
- ): Promise<ProviderRecord | undefined> {
507
- const isBuiltin = isBundledProviderId(providerId);
508
- if (isBuiltin && Object.keys(patch).some((field) => field !== "enabled")) {
509
- throw new ConsoleDomainError(
510
- "provider_is_builtin",
511
- 403,
512
- "Built-in providers only support enable/disable",
513
- );
514
- }
515
- if (patch.compatibilityProfile) validateCompatibilityProfile(patch.compatibilityProfile);
516
- const set: Partial<typeof providers.$inferInsert> = {};
517
- if (patch.enabled !== undefined) set.enabled = patch.enabled;
518
- if (patch.capabilityProfile !== undefined) set.capabilityProfile = patch.capabilityProfile;
519
- if (patch.baseUrl !== undefined) set.baseUrl = patch.baseUrl;
520
- if (patch.compatibilityProfile !== undefined) {
521
- set.compatibilityProfile = patch.compatibilityProfile;
522
- }
523
- const rows =
524
- Object.keys(set).length > 0
525
- ? await this.db
526
- .update(providers)
527
- .set(set)
528
- .where(and(eq(providers.id, providerId), isNull(providers.tenantId)))
529
- .returning()
530
- : await this.db
531
- .select()
532
- .from(providers)
533
- .where(and(eq(providers.id, providerId), isNull(providers.tenantId)));
534
- const row = rows[0];
535
- return row ? mapProviderRow(row, this.providerRegistry) : undefined;
536
- }
537
-
538
- async deleteGlobal(providerId: string): Promise<boolean> {
539
- if (isBundledProviderId(providerId)) {
540
- throw new ConsoleDomainError(
541
- "provider_is_builtin",
542
- 403,
543
- "Built-in provider cannot be deleted",
544
- );
545
- }
546
- const rows = await this.db
547
- .delete(providers)
548
- .where(and(eq(providers.id, providerId), isNull(providers.tenantId)))
549
- .returning({ id: providers.id });
550
- return rows.length > 0;
551
- }
552
- async listModels(tenantId: string, providerId: string): Promise<readonly ModelCatalogEntry[]> {
553
- const byProvider = await this.listModelsForTenant(tenantId, providerId);
554
- return byProvider.get(providerId) ?? [];
555
- }
556
-
557
- /**
558
- * Every enabled-and-disabled model row the tenant can see, grouped by
559
- * provider. One pair of statements for the whole tenant instead of a pair per
560
- * provider.
561
- *
562
- * The per-provider `listModels` shape costs two queries per call, so a caller
563
- * that needs every provider's models — the flat catalog the picker renders —
564
- * issued `2 * providers + 2` statements, growing with the bundled catalog.
565
- * Grouping here makes it constant: one disabled-model read and one models
566
- * join, regardless of provider count. `providerId` narrows it to a single
567
- * provider for callers that already know which one they want.
568
- */
569
- async listModelsForTenant(
570
- tenantId: string,
571
- providerId?: string,
572
- ): Promise<Map<string, readonly ModelCatalogEntry[]>> {
573
- const disabled = await this.db
574
- .select({
575
- providerId: tenantDisabledModels.providerId,
576
- modelId: tenantDisabledModels.modelId,
577
- endpointPath: tenantDisabledModels.endpointPath,
578
- })
579
- .from(tenantDisabledModels)
580
- .where(
581
- providerId === undefined
582
- ? eq(tenantDisabledModels.tenantId, tenantId)
583
- : and(
584
- eq(tenantDisabledModels.tenantId, tenantId),
585
- eq(tenantDisabledModels.providerId, providerId),
586
- ),
587
- );
588
- const disabledKeys = new Set(
589
- disabled.map((d) => `${d.providerId}::${d.modelId}::${d.endpointPath}`),
590
- );
591
- const dbRows = await this.db
592
- .select({ model: models })
593
- .from(models)
594
- .innerJoin(providers, eq(models.providerId, providers.id))
595
- .where(
596
- and(
597
- ...(providerId === undefined ? [] : [eq(models.providerId, providerId)]),
598
- globalOrOwnedBy(providers.tenantId, tenantId),
599
- ),
600
- );
601
- const grouped = new Map<string, ModelCatalogEntry[]>();
602
- for (const { model: row } of dbRows) {
603
- const isDisabled = disabledKeys.has(
604
- `${row.providerId}::${row.modelId}::${row.endpointPath}`,
605
- );
606
- const list = grouped.get(row.providerId) ?? [];
607
- list.push(mapModelRow(row, isDisabled));
608
- grouped.set(row.providerId, list);
609
- }
610
- return grouped;
611
- }
612
- async registerModels(
613
- tenantId: string,
614
- providerId: string,
615
- modelIds: readonly string[],
616
- wireFamily?: string,
617
- metadata?: ModelMetadataOverride,
618
- ): Promise<void> {
619
- const owner = await this.db
620
- .select({
621
- id: providers.id,
622
- baseUrl: providers.baseUrl,
623
- compatibilityProfile: providers.compatibilityProfile,
624
- })
625
- .from(providers)
626
- .where(and(eq(providers.id, providerId), globalOrOwnedBy(providers.tenantId, tenantId)))
627
- .limit(1);
628
- if (!owner[0]) {
629
- throw new ConsoleDomainError("provider_not_found", 404, `Provider ${providerId} not found`);
630
- }
631
- const family = (wireFamily ?? "chat") as WireFamily;
632
- // Static endpoint from the provider's own bundled catalog via the probing
633
- // service (the same startup-built map this store is constructed with).
634
- // Without this, bundled providers on versioned bases (cline) get the
635
- // generic `/v1/…` default, which joins into an unreachable path and every
636
- // probe/dispatch 404s.
637
- const staticEndpoint = await this.probing.resolveStaticEndpoint(providerId, family);
638
- const endpointPath = endpointPathForProviderModel(
639
- family,
640
- owner[0].compatibilityProfile,
641
- staticEndpoint === undefined ? undefined : { [family]: staticEndpoint },
642
- );
643
- if (modelIds.length === 0) return;
644
- // Single multi-row insert (was N sequential round-trips) and upsert the
645
- // capability flags so a re-registration repairs a row whose `tool_call`
646
- // (and `reasoning`) was seeded false — a manual add previously persisted
647
- // schema defaults, which capability preflight reads as "route cannot use
648
- // tools" and silently strips `tools` from every request. `enabled` stays
649
- // operator-owned; `source` is refreshed to `manual`.
650
- await this.db
651
- .insert(models)
652
- .values(
653
- modelIds.map((modelId) => ({
654
- providerId,
655
- modelId,
656
- wireFamily: family,
657
- endpointPath,
658
- ...applyModelMetadataOverride(
659
- resolveManualModelMetadata(providerId, modelId),
660
- metadata,
661
- ),
662
- source: "manual",
663
- sourceUpdatedAt: new Date(),
664
- enabled: true,
665
- })),
666
- )
667
- .onConflictDoUpdate({
668
- target: [models.providerId, models.modelId, models.endpointPath],
669
- set: {
670
- wireFamily: sql`excluded.wire_family`,
671
- contextLimit: sql`excluded.context_limit`,
672
- outputLimit: sql`excluded.output_limit`,
673
- modalities: sql`excluded.modalities`,
674
- reasoning: sql`excluded.reasoning`,
675
- toolCall: sql`excluded.tool_call`,
676
- source: sql`'manual'`,
677
- sourceUpdatedAt: sql`excluded.source_updated_at`,
678
- },
679
- });
680
- }
681
-
682
- /**
683
- * One-shot connectivity probe and model discovery live in
684
- * `services/provider-probing.ts` — the store only forwards so the domain
685
- * `ProviderCatalogStore` interface is unchanged.
686
- */
687
- async probeModel(
688
- tenantId: string,
689
- providerId: string,
690
- request: ProbeModelRequest,
691
- ): Promise<ProbeModelResult> {
692
- return this.probing.probeModel(tenantId, providerId, request);
693
- }
694
-
695
- /** Ad-hoc BYOK connectivity test; delegates to the shared probing service. */
696
- async testByokConnection(
697
- tenantId: string,
698
- request: ByokConnectionTestRequest,
699
- ): Promise<ByokConnectionTestResult> {
700
- return this.probing.testByokConnection(tenantId, request);
701
- }
702
-
703
- /**
704
- * Batched provider-wide probe: the first registered model runs sequentially
705
- * (warms OAuth token / connection pools), the rest run concurrently bounded
706
- * to 5. Registered model ids come from this store's own catalog read.
707
- */
708
- async probeAllModels(tenantId: string, providerId: string): Promise<ProbeAllModelsResult> {
709
- const entries = await this.listModels(tenantId, providerId);
710
- const requests = entries.map((entry) => ({
711
- modelId: entry.modelId,
712
- route: entry.route,
713
- serviceKind: isServiceKind(entry.serviceKind) ? entry.serviceKind : ("llm" as const),
714
- }));
715
- const uniqueRequests = requests.filter(
716
- (request, index, all) =>
717
- all.findIndex(
718
- (candidate) =>
719
- candidate.modelId === request.modelId &&
720
- candidate.route === request.route &&
721
- candidate.serviceKind === request.serviceKind,
722
- ) === index,
723
- );
724
- return this.probing.probeAllModels(tenantId, providerId, uniqueRequests);
725
- }
726
- async probeAllAccounts(
727
- tenantId: string,
728
- providerId: string,
729
- request: ProbeModelRequest,
730
- ): Promise<ProbeAllAccountsResult> {
731
- return this.probing.probeAllAccounts(tenantId, providerId, request);
732
- }
733
- /** Toggles a persisted model's routing eligibility. */
734
- async setModelEnabled(
735
- tenantId: string,
736
- providerId: string,
737
- request: SetModelEnabledRequest,
738
- ): Promise<boolean> {
739
- // Tenant-owned (BYOK) model: toggle the shared row directly.
740
- const owned = await this.db
741
- .select({ id: models.id })
742
- .from(models)
743
- .innerJoin(providers, eq(models.providerId, providers.id))
744
- .where(
745
- and(
746
- eq(models.providerId, providerId),
747
- eq(models.modelId, request.modelId),
748
- eq(models.endpointPath, request.route),
749
- ownedByOnly(providers.tenantId, tenantId),
750
- ),
751
- )
752
- .limit(1);
753
- const ownedTarget = owned[0];
754
- if (ownedTarget) {
755
- await this.db
756
- .update(models)
757
- .set({ enabled: request.enabled })
758
- .where(eq(models.id, ownedTarget.id));
759
- return true;
760
- }
761
-
762
- // Global/built-in model: tenants cannot mutate the shared row, so record
763
- // (or clear) a tenant-scoped disable in `tenantDisabledModels`.
764
- const global = await this.db
765
- .select({ id: models.id })
766
- .from(models)
767
- .innerJoin(providers, eq(models.providerId, providers.id))
768
- .where(
769
- and(
770
- eq(models.providerId, providerId),
771
- eq(models.modelId, request.modelId),
772
- eq(models.endpointPath, request.route),
773
- isNull(providers.tenantId),
774
- ),
775
- )
776
- .limit(1);
777
- if (global[0] === undefined) return false;
778
- if (request.enabled) {
779
- await this.db
780
- .delete(tenantDisabledModels)
781
- .where(
782
- and(
783
- eq(tenantDisabledModels.tenantId, tenantId),
784
- eq(tenantDisabledModels.providerId, providerId),
785
- eq(tenantDisabledModels.modelId, request.modelId),
786
- eq(tenantDisabledModels.endpointPath, request.route),
787
- ),
788
- );
789
- return true;
790
- }
791
- await this.db
792
- .insert(tenantDisabledModels)
793
- .values({
794
- tenantId,
795
- providerId,
796
- modelId: request.modelId,
797
- endpointPath: request.route,
798
- })
799
- .onConflictDoNothing({
800
- target: [
801
- tenantDisabledModels.tenantId,
802
- tenantDisabledModels.providerId,
803
- tenantDisabledModels.modelId,
804
- tenantDisabledModels.endpointPath,
805
- ],
806
- });
807
- return true;
808
- }
809
-
810
- /**
811
- * Updates one model's limits and capability flags.
812
- *
813
- * Two row kinds are writable. A tenant-owned (BYOK) row is updated directly.
814
- * A model belonging to a *global* provider row is also updated directly:
815
- * bundled providers are seeded once with `tenant_id IS NULL` and `providers.id`
816
- * is the primary key, so a bundled provider can only ever have that one row —
817
- * there is no tenant-scoped row to edit instead. Auto-fetched rows
818
- * (`discovered` / `auto_free`) live on exactly those providers, which is why
819
- * the previous `ownedByOnly` filter answered `not_found` for every model the
820
- * operator had just fetched and could not be edited at all.
821
- *
822
- * A global `builtin` row is rejected: it is re-seeded from the bundle on every
823
- * boot, so an edit there would be silently reverted, and `deleteModel` refuses
824
- * it for the same reason.
825
- *
826
- * Absent override fields leave the stored value untouched — an edit that only
827
- * sets the context ceiling must not reset the capability flags.
828
- */
829
- async updateModelMetadata(
830
- tenantId: string,
831
- providerId: string,
832
- request: UpdateModelMetadataRequest,
833
- ): Promise<boolean> {
834
- const findRow = (ownership: SQL) =>
835
- this.db
836
- .select({ id: models.id, modalities: models.modalities, source: models.source })
837
- .from(models)
838
- .innerJoin(providers, eq(models.providerId, providers.id))
839
- .where(
840
- and(
841
- eq(models.providerId, providerId),
842
- eq(models.modelId, request.modelId),
843
- eq(models.endpointPath, request.route),
844
- ownership,
845
- ),
846
- )
847
- .limit(1);
848
- const owned = await findRow(ownedByOnly(providers.tenantId, tenantId));
849
- let row = owned[0];
850
- if (row === undefined) {
851
- const global = await findRow(isNull(providers.tenantId));
852
- const candidate = global[0];
853
- if (candidate === undefined) return false;
854
- if (candidate.source === "builtin") {
855
- throw new ConsoleDomainError(
856
- "builtin_model_immutable",
857
- 403,
858
- `Built-in model ${providerId}/${request.modelId} cannot be edited — the bundle re-seeds it on every boot`,
859
- );
860
- }
861
- row = candidate;
862
- }
863
-
864
- const override = request.metadata;
865
- const set: Record<string, unknown> = { sourceUpdatedAt: new Date() };
866
- if (override?.contextLimit !== undefined) set.contextLimit = override.contextLimit;
867
- if (override?.outputLimit !== undefined) set.outputLimit = override.outputLimit;
868
- // Passthrough grants every capability and cannot be partly revoked by a
869
- // per-flag field: the point of the flag is that the operator does not know
870
- // which capabilities the model has, so an explicit `false` alongside it is
871
- // either stale or a mistake.
872
- if (override?.passthrough === true) {
873
- set.reasoning = true;
874
- set.toolCall = true;
875
- set.modalities = withImageModality(asStoredText(row.modalities), true);
876
- } else {
877
- if (override?.reasoning !== undefined) set.reasoning = override.reasoning;
878
- if (override?.toolCall !== undefined) set.toolCall = override.toolCall;
879
- if (override?.image !== undefined)
880
- set.modalities = withImageModality(asStoredText(row.modalities), override.image);
881
- }
882
- await this.db.update(models).set(set).where(eq(models.id, row.id));
883
- return true;
884
- }
885
-
886
- async deleteModel(
887
- tenantId: string,
888
- providerId: string,
889
- request: SetModelEnabledRequest,
890
- ): Promise<boolean> {
891
- const owned = await this.db
892
- .select({ id: models.id })
893
- .from(models)
894
- .innerJoin(providers, eq(models.providerId, providers.id))
895
- .where(
896
- and(
897
- eq(models.providerId, providerId),
898
- eq(models.modelId, request.modelId),
899
- eq(models.endpointPath, request.route),
900
- ownedByOnly(providers.tenantId, tenantId),
901
- ),
902
- )
903
- .limit(1);
904
- const target = owned[0];
905
- if (target) {
906
- const rows = await this.db
907
- .delete(models)
908
- .where(eq(models.id, target.id))
909
- .returning({ id: models.id });
910
- return rows.length > 0;
911
- }
912
- const global = await this.db
913
- .select({ id: models.id, source: models.source })
914
- .from(models)
915
- .innerJoin(providers, eq(models.providerId, providers.id))
916
- .where(
917
- and(
918
- eq(models.providerId, providerId),
919
- eq(models.modelId, request.modelId),
920
- eq(models.endpointPath, request.route),
921
- isNull(providers.tenantId),
922
- ),
923
- )
924
- .limit(1);
925
- if (!global[0]) return false;
926
- // Built-in models are immutable catalog: tenants can only disable them
927
- // (via tenantDisabledModels), never delete the shared row.
928
- if (global[0].source === "builtin") {
929
- throw new ConsoleDomainError(
930
- "builtin_model_immutable",
931
- 403,
932
- `Built-in model ${providerId}/${request.modelId} cannot be deleted — disable it instead`,
933
- );
934
- }
935
- const rows = await this.db
936
- .delete(models)
937
- .where(eq(models.id, global[0].id))
938
- .returning({ id: models.id });
939
- await this.db
940
- .delete(tenantDisabledModels)
941
- .where(
942
- and(
943
- eq(tenantDisabledModels.tenantId, tenantId),
944
- eq(tenantDisabledModels.providerId, providerId),
945
- eq(tenantDisabledModels.modelId, request.modelId),
946
- eq(tenantDisabledModels.endpointPath, request.route),
947
- ),
948
- );
949
- return rows.length > 0;
950
- }
951
-
952
- private async accountsWithUsage(
953
- tenantId: string,
954
- rows: readonly (typeof providerAccounts.$inferSelect)[],
955
- ): Promise<readonly ProviderAccountResponse[]> {
956
- if (rows.length === 0) return [];
957
- const todayStart = new Date();
958
- todayStart.setUTCHours(0, 0, 0, 0);
959
- const accountIds = rows.map((row) => row.id);
960
- const todayEvents = await queryTelemetryEvents({ tenantId, accountIds, since: todayStart });
961
- const todayByAccount = new Map<string, { requests: number; errors: number; inputTokens: number; outputTokens: number }>();
962
- for (const event of todayEvents) {
963
- if (!event.accountId) continue;
964
- const bucket = todayByAccount.get(event.accountId) ?? { requests: 0, errors: 0, inputTokens: 0, outputTokens: 0 };
965
- bucket.requests += 1;
966
- if (isGatewayError(event.status, event.httpStatus)) bucket.errors += 1;
967
- bucket.inputTokens += event.inputTokens ?? 0;
968
- bucket.outputTokens += event.outputTokens ?? 0;
969
- todayByAccount.set(event.accountId, bucket);
970
- }
971
- const [lifetimeRows] = await Promise.all([
972
- this.db
973
- .select()
974
- .from(telemetryUsageTotals)
975
- .where(
976
- and(
977
- eq(telemetryUsageTotals.tenantId, tenantId),
978
- eq(telemetryUsageTotals.identityType, "account"),
979
- inArray(telemetryUsageTotals.entityId, accountIds),
980
- ),
981
- ),
982
- ]);
983
- const lifetimeByAccount = new Map(lifetimeRows.map((row) => [row.entityId, row]));
984
- const mapUsage = (row: {
985
- readonly requests: number | string | null;
986
- readonly errors: number | string | null;
987
- readonly inputTokens: number | string | null;
988
- readonly outputTokens: number | string | null;
989
- } | undefined): ProviderAccountTokenUsage => {
990
- const inputTokens = Number(row?.inputTokens ?? 0);
991
- const outputTokens = Number(row?.outputTokens ?? 0);
992
- return {
993
- requests: Number(row?.requests ?? 0),
994
- errors: Number(row?.errors ?? 0),
995
- inputTokens,
996
- outputTokens,
997
- totalTokens: inputTokens + outputTokens,
998
- };
999
- };
1000
- const first = rows[0];
1001
- const inflightByAccount = await this.accountInflightFor(tenantId, first?.providerId);
1002
- return rows.map((row) => {
1003
- const inflight = inflightByAccount.get(row.id);
1004
- return this.mapAccount(row, {
1005
- today: mapUsage(todayByAccount.get(row.id)),
1006
- allTime: mapUsage(lifetimeByAccount.get(row.id)),
1007
- ...(inflight === undefined ? {} : { inflight }),
1008
- });
1009
- });
1010
- }
1011
-
1012
- private async accountInflightFor(
1013
- tenantId: string,
1014
- providerId: string | undefined,
1015
- ): Promise<Map<string, number>> {
1016
- const readings = new Map<string, number>();
1017
- if (!this.readAccountInflight || !providerId) return readings;
1018
- const rows = await this.readAccountInflight(providerId, tenantId);
1019
- for (const row of rows) {
1020
- if (typeof row.accountId !== "string" || !Number.isFinite(row.inflight)) continue;
1021
- readings.set(row.accountId, Math.max(0, Math.floor(row.inflight)));
1022
- }
1023
- return readings;
1024
- }
1025
-
1026
- /**
1027
- * Next free list position for a new account in this provider's list.
1028
- *
1029
- * Scoped to (provider, tenant) because the list is per provider: a tenant
1030
- * with accounts under two providers must not have the second provider's
1031
- * first account land at a high index.
1032
- */
1033
- /**
1034
- * Labels already in use for one provider, for numbering a generated name.
1035
- *
1036
- * Scoped to this provider and tenant: `dcint-key-1` and `other-key-1` belong
1037
- * to different providers and must not collide with each other.
1038
- */
1039
- private async generateAccountLabel(
1040
- db: CartethyiaDatabase | Parameters<Parameters<CartethyiaDatabase["transaction"]>[0]>[0],
1041
- providerId: string,
1042
- tenantId: string,
1043
- credentialMode: CredentialMode,
1044
- ): Promise<string> {
1045
- const rows = await db
1046
- .select({ label: providerAccounts.label })
1047
- .from(providerAccounts)
1048
- .where(
1049
- and(
1050
- eq(providerAccounts.providerId, providerId),
1051
- globalOrOwnedBy(providerAccounts.tenantId, tenantId),
1052
- ),
1053
- );
1054
- return nextGeneratedAccountLabel(
1055
- providerId,
1056
- credentialMode,
1057
- rows.map((row) => row.label).filter((label): label is string => typeof label === "string"),
1058
- );
1059
- }
1060
-
1061
- private async nextAccountSortIndex(
1062
- db: CartethyiaDatabase | Parameters<Parameters<CartethyiaDatabase["transaction"]>[0]>[0],
1063
- providerId: string,
1064
- tenantId: string,
1065
- ): Promise<number> {
1066
- return nextSortIndex(
1067
- db,
1068
- { table: providerAccounts, idColumn: providerAccounts.id, sortIndexColumn: providerAccounts.sortIndex },
1069
- and(
1070
- eq(providerAccounts.providerId, providerId),
1071
- globalOrOwnedBy(providerAccounts.tenantId, tenantId),
1072
- ),
1073
- );
1074
- }
1075
-
1076
- /** Rewrites one provider's account order; `accountIds` is the full order. */
1077
- async reorderAccounts(
1078
- tenantId: string,
1079
- providerId: string,
1080
- accountIds: readonly string[],
1081
- ): Promise<void> {
1082
- await reorderSortIndex(
1083
- this.db,
1084
- { table: providerAccounts, idColumn: providerAccounts.id, sortIndexColumn: providerAccounts.sortIndex },
1085
- and(
1086
- eq(providerAccounts.providerId, providerId),
1087
- globalOrOwnedBy(providerAccounts.tenantId, tenantId),
1088
- ),
1089
- accountIds,
1090
- );
1091
- }
1092
-
1093
- async listAccounts(
1094
- tenantId: string,
1095
- providerId: string,
1096
- ): Promise<readonly ProviderAccountResponse[]> {
1097
- const rows = await this.db
1098
- .select()
1099
- .from(providerAccounts)
1100
- .where(
1101
- and(
1102
- eq(providerAccounts.providerId, providerId),
1103
- globalOrOwnedBy(providerAccounts.tenantId, tenantId),
1104
- ),
1105
- )
1106
- // Stable list position, not `created_at`: two accounts created in the
1107
- // same millisecond could swap between loads. `id` breaks a tie so the
1108
- // order is total even before a reorder assigns distinct indices.
1109
- .orderBy(asc(providerAccounts.sortIndex), asc(providerAccounts.createdAt), asc(providerAccounts.id));
1110
- return this.accountsWithUsage(tenantId, rows);
1111
- }
1112
-
1113
- async listAllAccounts(tenantId: string): Promise<readonly ProviderAccountResponse[]> {
1114
- const rows = await this.db
1115
- .select()
1116
- .from(providerAccounts)
1117
- .where(globalOrOwnedBy(providerAccounts.tenantId, tenantId))
1118
- .orderBy(asc(providerAccounts.sortIndex), asc(providerAccounts.createdAt), asc(providerAccounts.id));
1119
- return this.accountsWithUsage(tenantId, rows);
1120
- }
1121
-
1122
- async createAccount(
1123
- tenantId: string,
1124
- providerId: string,
1125
- request: CreateProviderAccountRequest,
1126
- existingTx?: Parameters<Parameters<CartethyiaDatabase["transaction"]>[0]>[0],
1127
- includeUsage = true,
1128
- sortIndexOverride?: number,
1129
- ): Promise<ProviderAccountResponse> {
1130
- // Callers inside an existing transaction pass it in so the account lands in
1131
- // that unit of work (the batch path); otherwise this opens its own.
1132
- const run = async (
1133
- tx: Parameters<Parameters<CartethyiaDatabase["transaction"]>[0]>[0],
1134
- ): Promise<ProviderAccountResponse> => {
1135
- let effectiveSecret = request.secret;
1136
- let effectiveCredentialKind = request.credentialKind;
1137
- const requestedCredentialMode: CredentialMode = request.credentialMode ?? "auto";
1138
- let effectiveCredentialMode: CredentialMode = requestedCredentialMode;
1139
- let tokenExpiresAt: Date | undefined;
1140
- let oauthRefreshCiphertext: Buffer | undefined;
1141
- let oauthExpiresAt: Date | undefined;
1142
- let oauthHasRefreshToken = false;
1143
- let pastedAuthState: Readonly<Record<string, unknown>> = {};
1144
-
1145
- if (
1146
- (request.credentialKind === "oauth" || request.credentialKind === "api_key") &&
1147
- request.secret.trim().length > 0
1148
- ) {
1149
- const trimmed = request.secret.trim();
1150
- const parts = parsePastedOAuthCredential(trimmed);
1151
- pastedAuthState = parts.authState;
1152
- const hasRefreshToken = parts.refreshToken !== parts.accessToken;
1153
- const parsedJwt =
1154
- requestedCredentialMode === "api_key"
1155
- ? undefined
1156
- : parseJwtCredential(parts.accessToken);
1157
- if (requestedCredentialMode === "jwt" && parsedJwt === undefined) {
1158
- throw new ConsoleDomainError(
1159
- "invalid_request",
1160
- 400,
1161
- "credentialMode jwt requires a valid three-part JWT",
1162
- );
1163
- }
1164
- if (requestedCredentialMode === "api_key") {
1165
- effectiveSecret = parts.accessToken;
1166
- effectiveCredentialKind = "api_key";
1167
- effectiveCredentialMode = "api_key";
1168
- } else if (
1169
- parsedJwt !== undefined &&
1170
- (requestedCredentialMode === "jwt" || !hasRefreshToken)
1171
- ) {
1172
- effectiveSecret = parts.accessToken;
1173
- effectiveCredentialKind = "api_key";
1174
- effectiveCredentialMode = "jwt";
1175
- tokenExpiresAt = parsedJwt.expiresAt;
1176
- } else if (request.credentialKind === "oauth" || hasRefreshToken) {
1177
- effectiveSecret = parts.accessToken;
1178
- effectiveCredentialKind = hasRefreshToken ? "oauth" : "api_key";
1179
- effectiveCredentialMode = hasRefreshToken ? "auto" : "api_key";
1180
- if (hasRefreshToken) {
1181
- oauthRefreshCiphertext = encryptCredential(parts.refreshToken);
1182
- oauthHasRefreshToken = true;
1183
- }
1184
- oauthExpiresAt = parts.expiresAt;
1185
- } else {
1186
- effectiveCredentialMode = "api_key";
1187
- }
1188
- }
1189
-
1190
- // Append to the end of this provider's list rather than relying on
1191
- // creation time, so a new account never displaces existing positions.
1192
- const credentialFingerprint =
1193
- effectiveCredentialKind === "none" || effectiveSecret.length === 0
1194
- ? undefined
1195
- : hashSecret(effectiveSecret);
1196
- const accountAuthState = {
1197
- ...pastedAuthState,
1198
- ...(request.authState ?? {}),
1199
- credentialMode: effectiveCredentialMode,
1200
- ...(tokenExpiresAt === undefined ? {} : { tokenExpiresAt: tokenExpiresAt.toISOString() }),
1201
- };
1202
- const nextSortIndex =
1203
- sortIndexOverride ?? (await this.nextAccountSortIndex(tx, providerId, tenantId));
1204
- // Read existing labels only when one has to be generated: a named
1205
- // account costs no extra query, and this is the path a large batch
1206
- // paste walks once per row.
1207
- const label =
1208
- request.label ??
1209
- (await this.generateAccountLabel(tx, providerId, tenantId, effectiveCredentialMode));
1210
- const rows = await tx
1211
- .insert(providerAccounts)
1212
- .values({
1213
- providerId,
1214
- tenantId,
1215
- sortIndex: nextSortIndex,
1216
- label,
1217
- credentialCiphertext: encryptCredential(effectiveSecret),
1218
- ...(credentialFingerprint ? { credentialFingerprint } : {}),
1219
- credentialKind: effectiveCredentialKind,
1220
- status: "active",
1221
- authState: accountAuthState,
1222
- // An OAuth account pasted without a refresh token is a *static*
1223
- // token: the pasted value is a bearer token used exactly as issued
1224
- // and there is no refresh grant to run. Flag it as static so the
1225
- // console reads it as an informational "static token" rather than
1226
- // "re-login required" — the token is still valid and dispatchable —
1227
- // and so the refresh sweep skips it instead of retrying a refresh
1228
- // that can never succeed.
1229
- ...(effectiveCredentialKind === "oauth" && !oauthHasRefreshToken || effectiveCredentialMode === "jwt"
1230
- ? { staticToken: true }
1231
- : {}),
1232
- })
1233
- .returning();
1234
- const row = rows[0];
1235
- if (!row) throw new Error("failed to create provider account");
1236
-
1237
- if (effectiveCredentialKind === "oauth") {
1238
- await tx.insert(providerOauthStates).values({
1239
- providerAccountId: row.id,
1240
- ...(oauthRefreshCiphertext ? { refreshCiphertext: oauthRefreshCiphertext } : {}),
1241
- expiresAt: oauthExpiresAt ?? null,
1242
- });
1243
- }
1244
- if (request.credentialKind === "oauth" && !oauthHasRefreshToken) {
1245
- pushStructuredConsoleLog(
1246
- "info",
1247
- "OAuth access token stored as a static API credential; it will not be refreshed",
1248
- {
1249
- event: "token_refresh",
1250
- providerId,
1251
- accountId: row.id,
1252
- errorCode: "static_token",
1253
- },
1254
- );
1255
- }
1256
-
1257
- if (!includeUsage) {
1258
- const emptyUsage = { requests: 0, errors: 0, inputTokens: 0, outputTokens: 0, totalTokens: 0 };
1259
- return this.mapAccount(row, { today: emptyUsage, allTime: emptyUsage });
1260
- }
1261
- const [account] = await this.accountsWithUsage(tenantId, [row]);
1262
- if (!account) throw new Error("created provider account could not be mapped");
1263
- return account;
1264
- };
1265
-
1266
- try {
1267
- if (existingTx !== undefined) {
1268
- return await run(existingTx);
1269
- }
1270
- return await this.db.transaction(run);
1271
- } catch (error) {
1272
- if (isUniqueViolation(error)) {
1273
- throw new ConsoleDomainError(
1274
- "provider_account_duplicate",
1275
- 409,
1276
- "A provider account with this credential already exists",
1277
- );
1278
- }
1279
- throw error;
1280
- }
1281
- }
1282
-
1283
- /**
1284
- * Creates many accounts in one round-trip.
1285
- *
1286
- * The whole batch shares one transaction, so a paste of 40 credentials costs
1287
- * one connection checkout rather than 40. Each item is created through the
1288
- * same `createAccount` path the single route uses — there is no second
1289
- * implementation of credential detection to drift.
1290
- *
1291
- * Items are created sequentially inside that transaction: the unique
1292
- * credential fingerprint makes two identical pastes collide, and a nested
1293
- * transaction per item keeps that collision from aborting the whole batch.
1294
- * Outcomes are reported per index, so a partial paste still lands the
1295
- * accounts that were fine — an operator fixing one bad line should not have
1296
- * to re-import 39.
1297
- */
1298
- async createAccountBatch(
1299
- tenantId: string,
1300
- providerId: string,
1301
- requests: readonly CreateProviderAccountRequest[],
1302
- ): Promise<CreateProviderAccountBatchResult> {
1303
- if (requests.length === 0) {
1304
- return { created: 0, failed: 0, results: [] };
1305
- }
1306
- const results: CreateProviderAccountBatchItemResult[] = [];
1307
- let created = 0;
1308
- let failed = 0;
1309
-
1310
- await this.db.transaction(async (tx) => {
1311
- let nextSortIndex = await this.nextAccountSortIndex(tx, providerId, tenantId);
1312
- for (const [index, request] of requests.entries()) {
1313
- try {
1314
- // A nested transaction acts as a savepoint: a duplicate-fingerprint
1315
- // violation rolls back only this item, not the accounts before it.
1316
- const account = await tx.transaction(async (inner) =>
1317
- this.createAccount(tenantId, providerId, request, inner, false, nextSortIndex),
1318
- );
1319
- created += 1;
1320
- nextSortIndex += 1;
1321
- results.push({ index, ok: true, ...(account ? { account } : {}) });
1322
- } catch (error) {
1323
- failed += 1;
1324
- results.push({
1325
- index,
1326
- ok: false,
1327
- error: error instanceof Error ? error.message : String(error),
1328
- code: error instanceof ConsoleDomainError ? error.code : "create_failed",
1329
- });
1330
- }
1331
- }
1332
- });
1333
-
1334
- return { created, failed, results };
1335
- }
1336
-
1337
- async updateAccount(
1338
- tenantId: string,
1339
- providerId: string,
1340
- accountId: string,
1341
- patch: UpdateProviderAccountRequest,
1342
- ): Promise<ProviderAccountResponse | undefined> {
1343
- const set: Partial<typeof providerAccounts.$inferInsert> = {};
1344
- if (patch.label !== undefined) set.label = patch.label;
1345
- if (patch.secret !== undefined) {
1346
- set.credentialCiphertext = encryptCredential(patch.secret);
1347
- set.credentialFingerprint =
1348
- patch.secret.length === 0 ? null : hashSecret(patch.secret);
1349
- }
1350
- if (patch.status !== undefined) set.status = patch.status;
1351
- // Toggling the static-token flag: turning it on declares the credential is
1352
- // used as issued and never refreshed, so any stale re-auth flag is cleared —
1353
- // the operator has said the account is fine, and the pill must stop reading
1354
- // "Re-login required". Turning it off returns the account to normal OAuth
1355
- // refresh handling; the sweep picks it up again on its next pass.
1356
- if (patch.staticToken !== undefined) {
1357
- set.staticToken = patch.staticToken;
1358
- if (patch.staticToken) {
1359
- set.lastError = null;
1360
- set.lastErrorCategory = null;
1361
- set.lastErrorAt = null;
1362
- }
1363
- }
1364
- // Status toggles are not recovery: preserve the health machine's evidence
1365
- // until the operator uses Recover or replaces the rejected credential.
1366
- const clearsFailureState = patch.secret !== undefined && patch.secret.length > 0;
1367
- if (clearsFailureState) {
1368
- set.consecutiveFailures = 0;
1369
- set.cooldownUntil = null;
1370
- set.modelCooldowns = {};
1371
- set.lastError = null;
1372
- set.lastErrorCategory = null;
1373
- set.lastErrorAt = null;
1374
- }
1375
- const where = and(
1376
- eq(providerAccounts.id, accountId),
1377
- eq(providerAccounts.providerId, providerId),
1378
- globalOrOwnedBy(providerAccounts.tenantId, tenantId),
1379
- );
1380
- try {
1381
- const rows =
1382
- Object.keys(set).length > 0
1383
- ? await this.db.update(providerAccounts).set(set).where(where).returning()
1384
- : await this.db.select().from(providerAccounts).where(where);
1385
- const row = rows[0];
1386
- if (!row) return undefined;
1387
- // Secret/status mutation: drop any cached credential so the next
1388
- // attempt decrypts the new ciphertext (or refuses a disabled account).
1389
- if (patch.secret !== undefined || patch.status !== undefined) {
1390
- invalidateCredentialCache(accountId);
1391
- }
1392
- const [account] = await this.accountsWithUsage(tenantId, [row]);
1393
- return account;
1394
- } catch (error) {
1395
- if (isUniqueViolation(error)) {
1396
- throw new ConsoleDomainError(
1397
- "provider_account_duplicate",
1398
- 409,
1399
- "A provider account with this credential already exists",
1400
- );
1401
- }
1402
- throw error;
1403
- }
1404
- }
1405
-
1406
- private mapAccount(
1407
- row: typeof providerAccounts.$inferSelect,
1408
- usage: {
1409
- readonly today: ProviderAccountTokenUsage;
1410
- readonly allTime: ProviderAccountTokenUsage;
1411
- readonly inflight?: number | undefined;
1412
- },
1413
- ): ProviderAccountResponse {
1414
- const authState =
1415
- row.authState !== null && typeof row.authState === "object" && !Array.isArray(row.authState)
1416
- ? (row.authState as Record<string, unknown>)
1417
- : undefined;
1418
- const rawCredentialMode = authState?.["credentialMode"];
1419
- const credentialMode: CredentialMode | undefined =
1420
- rawCredentialMode === "auto" || rawCredentialMode === "jwt" || rawCredentialMode === "api_key"
1421
- ? rawCredentialMode
1422
- : undefined;
1423
- const tokenExpiresAt =
1424
- typeof authState?.["tokenExpiresAt"] === "string" ? authState["tokenExpiresAt"] : undefined;
1425
- return {
1426
- id: row.id,
1427
- providerId: row.providerId,
1428
- tenantId: row.tenantId,
1429
- label: row.label,
1430
- credentialKind: row.credentialKind,
1431
- ...(credentialMode === undefined ? {} : { credentialMode }),
1432
- ...(tokenExpiresAt === undefined ? {} : { tokenExpiresAt }),
1433
- status: row.status,
1434
- ...(usage.inflight === undefined ? {} : { inflight: usage.inflight }),
1435
- usageToday: usage.today,
1436
- usageAllTime: usage.allTime,
1437
- consecutiveFailures: row.consecutiveFailures,
1438
- ...(row.lastSuccessAt ? { lastSuccessAt: row.lastSuccessAt.toISOString() } : {}),
1439
- ...(row.lastError ? { lastError: row.lastError } : {}),
1440
- ...(row.lastErrorCategory ? { lastErrorCategory: row.lastErrorCategory } : {}),
1441
- ...(row.lastErrorAt ? { lastErrorAt: row.lastErrorAt.toISOString() } : {}),
1442
- ...(row.cooldownUntil ? { cooldownUntil: row.cooldownUntil.toISOString() } : {}),
1443
- ...(liveModelCooldowns(row.modelCooldowns) ?? {}),
1444
- ...(row.lastRecoveredAt ? { lastRecoveredAt: row.lastRecoveredAt.toISOString() } : {}),
1445
- createdAt: row.createdAt.toISOString(),
1446
- sortIndex: row.sortIndex,
1447
- ...(row.staticToken ? { staticToken: true } : {}),
1448
- };
1449
- }
1450
-
1451
- async listAccountHealthEvents(
1452
- tenantId: string,
1453
- providerId: string,
1454
- accountId: string,
1455
- ): Promise<readonly AccountHealthEventRecord[]> {
1456
- const owned = await this.db
1457
- .select({ id: providerAccounts.id })
1458
- .from(providerAccounts)
1459
- .where(
1460
- and(
1461
- eq(providerAccounts.id, accountId),
1462
- eq(providerAccounts.providerId, providerId),
1463
- globalOrOwnedBy(providerAccounts.tenantId, tenantId),
1464
- ),
1465
- )
1466
- .limit(1);
1467
- if (owned.length === 0) return [];
1468
- return listAccountHealthEvents(this.db, accountId);
1469
- }
1470
-
1471
- /** Deletes one account's stored health & error rows, for the modal's purge. */
1472
- async purgeAccountHealthEvents(
1473
- tenantId: string,
1474
- providerId: string,
1475
- accountId: string,
1476
- ): Promise<number> {
1477
- const owned = await this.db
1478
- .select({ id: providerAccounts.id })
1479
- .from(providerAccounts)
1480
- .where(
1481
- and(
1482
- eq(providerAccounts.id, accountId),
1483
- eq(providerAccounts.providerId, providerId),
1484
- globalOrOwnedBy(providerAccounts.tenantId, tenantId),
1485
- ),
1486
- )
1487
- .limit(1);
1488
- if (owned.length === 0) return 0;
1489
- return deleteAccountHealthEvents(this.db, accountId);
1490
- }
1491
- async listBuddyGrowthTasks(
1492
- tenantId: string,
1493
- providerId: string,
1494
- accountId: string,
1495
- dayKey: string,
1496
- ): Promise<readonly BuddyGrowthTaskLedgerEntry[]> {
1497
- const owned = await this.db
1498
- .select({ id: providerAccounts.id })
1499
- .from(providerAccounts)
1500
- .where(
1501
- and(
1502
- eq(providerAccounts.id, accountId),
1503
- eq(providerAccounts.providerId, providerId),
1504
- globalOrOwnedBy(providerAccounts.tenantId, tenantId),
1505
- ),
1506
- )
1507
- .limit(1);
1508
- if (owned.length === 0) return [];
1509
- const rows = await this.db
1510
- .select({
1511
- accountId: buddyTaskLedger.accountId,
1512
- task: buddyTaskLedger.task,
1513
- claimedAt: buddyTaskLedger.claimedAt,
1514
- })
1515
- .from(buddyTaskLedger)
1516
- .where(
1517
- and(
1518
- eq(buddyTaskLedger.accountId, accountId),
1519
- eq(buddyTaskLedger.dayKey, dayKey),
1520
- ),
1521
- );
1522
- return rows.map((row) => ({
1523
- accountId: row.accountId,
1524
- task: row.task,
1525
- claimedAt: row.claimedAt.toISOString(),
1526
- }));
1527
- }
1528
-
1529
- async recoverAccount(
1530
- tenantId: string,
1531
- providerId: string,
1532
- accountId: string,
1533
- ): Promise<boolean> {
1534
- const owned = await this.db
1535
- .select({ id: providerAccounts.id })
1536
- .from(providerAccounts)
1537
- .where(
1538
- and(
1539
- eq(providerAccounts.id, accountId),
1540
- eq(providerAccounts.providerId, providerId),
1541
- globalOrOwnedBy(providerAccounts.tenantId, tenantId),
1542
- ),
1543
- )
1544
- .limit(1);
1545
- if (owned.length === 0) return false;
1546
- return recoverAccount(this.db, accountId, "manual_operator_recovery");
1547
- }
1548
-
1549
-
1550
- async syncModels(tenantId: string, providerId: string): Promise<{ synced: number }> {
1551
- return this.probing.syncModels(tenantId, providerId);
1552
- }
1553
- }
1
+ // Drizzle-backed provider, account, model, routing, and API-key repositories.
2
+ import { and, asc, eq, inArray, isNull, or, sql, type SQL } from "drizzle-orm";
3
+ import { ConsoleDomainError } from "../../shared/errors";
4
+ import { parseCustomProviderId, isBundledProviderId } from "../../../providers/provider-contracts";
5
+ import type { ModelDefinition } from "../../../providers/provider-model-definition";
6
+ import type { ProviderRegistry } from "../../../providers/provider-registry";
7
+ import { BUNDLED_PROVIDER_MODULES } from "../../../providers/default-registry";
8
+ import { modelsDevCatalog } from "../../../providers/discovery/models-dev-catalog";
9
+ import { globalOrOwnedBy, ownedByOnly } from "../../../persistence/tenant-scope";
10
+ import { nextSortIndex, reorderSortIndex } from "../../../persistence/sort-order";
11
+ import type { CartethyiaDatabase } from "../../../persistence/db-handle";
12
+ import { buddyTaskLedger, models, providerAccounts, providerOauthStates, providers, telemetryUsageTotals, tenantDisabledModels } from "../../../persistence/schema";
13
+ import { queryTelemetryEvents } from "../../../observability/event-log";
14
+ import { isGatewayError } from "../../../observability/telemetry-status";
15
+ import type { WireFamily } from "../../../protocol/canonical-model";
16
+ import { listAccountHealthEvents, recoverAccount, deleteAccountHealthEvents, type AccountHealthEventRecord } from "../../../providers/operations/account-health-service";
17
+ import { invalidateCredentialCache } from "../../../providers/operations/provider-credential-service";
18
+ import { encryptCredential, hashSecret } from "../../../security/crypto";
19
+ import type { TelemetryBatchBuffer } from "../../../observability/telemetry-buffer";
20
+ import { bumpCatalogRevision, type BundledProviderCatalog } from "../../../providers/operations/provider-catalog-service";
21
+ import type { AccountInflightReading, BuddyGrowthTaskLedgerEntry, ByokConnectionTestRequest, ByokConnectionTestResult, CreateProviderAccountBatchItemResult, CreateProviderAccountBatchResult, CreateProviderAccountRequest, CredentialMode, ModelCatalogEntry, ProbeAllAccountsResult, ProbeAllModelsResult, ProbeModelRequest, ProbeModelResult, ProviderAccountResponse, ProviderAccountTokenUsage, ProviderCatalogStore, ProviderRecord, SetModelEnabledRequest, UpdateModelMetadataRequest, UpdateProviderAccountRequest, ModelMetadataOverride } from "./contracts";
22
+ import { isServiceKind, validateCompatibilityProfile } from "./contracts";
23
+ import { ProviderProbingService, type ProbeOutboundResolver } from "../../../providers/discovery/probing-service";
24
+ import {
25
+ applyModelMetadataOverride,
26
+ FALLBACK_CONTEXT_LIMIT,
27
+ FALLBACK_OUTPUT_LIMIT,
28
+ resolveManualModelMetadata,
29
+ } from "../../../providers/provider-model-definition";
30
+ import { isUniqueViolation } from "../../../persistence/boot";
31
+ import { DEFAULT_ENDPOINT_BY_WIRE_FAMILY, endpointPathForProviderModel, mapProviderRow } from "./catalog-projections";
32
+ import { pushStructuredConsoleLog } from "../../../observability/log-ring";
33
+ import { decodeJwtPayload } from "../../../providers/authentication/oauth-flow-store";
34
+
35
+ /** Real Drizzle-backed provider and model catalog repository. */
36
+
37
+ /** Field names a pasted OAuth credential may carry its access token under. */
38
+ const OAUTH_ACCESS_FIELDS = ["accessToken", "access_token", "access", "token"] as const;
39
+ /** Field names a pasted OAuth credential may carry its refresh token under. */
40
+ const OAUTH_REFRESH_FIELDS = ["refreshToken", "refresh_token", "refresh"] as const;
41
+
42
+ function firstString(record: Record<string, unknown>, fields: readonly string[]): string | undefined {
43
+ for (const field of fields) {
44
+ const value = record[field];
45
+ if (typeof value === "string" && value.trim().length > 0) return value.trim();
46
+ }
47
+ return undefined;
48
+ }
49
+
50
+ /** Parses an expiry field that may be an ISO string, an epoch ms, or epoch seconds. */
51
+ function parseExpiry(value: unknown): Date | undefined {
52
+ if (typeof value === "number" && Number.isFinite(value)) {
53
+ // Heuristic: a 10-digit value is epoch seconds, a 13-digit value is ms.
54
+ const ms = value < 1e12 ? value * 1000 : value;
55
+ const date = new Date(ms);
56
+ return Number.isFinite(date.getTime()) ? date : undefined;
57
+ }
58
+ if (typeof value === "string" && value.trim().length > 0) {
59
+ const asNumber = Number(value);
60
+ if (Number.isFinite(asNumber)) return parseExpiry(asNumber);
61
+ const date = new Date(value);
62
+ return Number.isFinite(date.getTime()) ? date : undefined;
63
+ }
64
+ return undefined;
65
+ }
66
+ function parseJwtCredential(raw: string): { readonly expiresAt?: Date } | undefined {
67
+ const payload = decodeJwtPayload(raw.trim());
68
+ if (payload === undefined) return undefined;
69
+ const expiresAt = parseExpiry(payload["exp"]);
70
+ return expiresAt === undefined ? {} : { expiresAt };
71
+ }
72
+
73
+ /**
74
+ * Splits a pasted OAuth credential into its access token, refresh token, and
75
+ * expiry.
76
+ *
77
+ * The console accepts either a bare access token or the JSON export a client
78
+ * produces (`{accessToken, refreshToken, expiresAt, …}`, possibly nested under
79
+ * `data`). A bare token has no refresh token — the account can be dispatched
80
+ * with it but cannot be refreshed, so the caller stores it as a static
81
+ * API credential instead of sending the access token to a refresh endpoint.
82
+ */
83
+ /**
84
+ * Middle segment of an auto-generated account label, from how the credential
85
+ * was actually detected.
86
+ *
87
+ * Only the two kinds that arrive with no name of their own are named here. An
88
+ * OAuth paste carries its own identity in `authState` (the account it was
89
+ * exported from), so it keeps whatever the export or the operator supplied —
90
+ * generating a name for it would overwrite an identity that was already there.
91
+ * An unrecognized mode falls back to `key`, matching the mode it is stored as.
92
+ */
93
+ export function accountLabelKindSegment(credentialMode: CredentialMode): string {
94
+ return credentialMode === "jwt" ? "jwt" : "key";
95
+ }
96
+
97
+ /**
98
+ * First unused `dcint-key-N` / `dcint-jwt-N` label for one provider.
99
+ *
100
+ * Polished tokens and plain JWTs are the credentials that arrive nameless: an
101
+ * operator pastes several into one provider and every row came out as
102
+ * "<providerId> account", so five accounts were five identical labels and the
103
+ * list could not tell them apart — which account failed, or is cooling, was
104
+ * unreadable.
105
+ *
106
+ * Numbering runs per kind, so the first JWT is `jwt-1` rather than inheriting
107
+ * whatever number the api_key rows reached (`jwt-3` with no `jwt-1` reads as
108
+ * two missing accounts). Counting is case-insensitive on the whole label, so a
109
+ * hand-typed `DCINT-KEY-2` still reserves its number.
110
+ *
111
+ * Pure, so the numbering can be pinned without a database.
112
+ */
113
+ export function nextGeneratedAccountLabel(
114
+ providerId: string,
115
+ credentialMode: CredentialMode,
116
+ existingLabels: readonly string[],
117
+ ): string {
118
+ const kind = accountLabelKindSegment(credentialMode);
119
+ const prefix = `${providerId}-${kind}-`;
120
+ const taken = new Set(
121
+ existingLabels.map((label) => label.trim().toLowerCase()),
122
+ );
123
+ for (let n = 1; ; n += 1) {
124
+ const candidate = `${prefix}${n}`;
125
+ if (!taken.has(candidate.toLowerCase())) return candidate;
126
+ }
127
+ }
128
+
129
+ export function parsePastedOAuthCredential(raw: string): {
130
+ readonly accessToken: string;
131
+ readonly refreshToken: string;
132
+ readonly expiresAt: Date | undefined;
133
+ /**
134
+ * Non-secret account configuration the export carried — the profile the
135
+ * account is bound to, how it authenticates, the region it was minted in.
136
+ * Empty for a bare token, which carries none.
137
+ */
138
+ readonly authState: Readonly<Record<string, unknown>>;
139
+ } {
140
+ let record: Record<string, unknown> | undefined;
141
+ try {
142
+ const parsed = JSON.parse(raw) as unknown;
143
+ if (parsed !== null && typeof parsed === "object" && !Array.isArray(parsed)) {
144
+ record = parsed as Record<string, unknown>;
145
+ }
146
+ } catch {
147
+ record = undefined;
148
+ }
149
+ if (record === undefined) {
150
+ // Bare token: it is the access token, and there is no refresh token.
151
+ const jwt = parseJwtCredential(raw);
152
+ return {
153
+ accessToken: raw,
154
+ refreshToken: raw,
155
+ expiresAt: jwt?.expiresAt,
156
+ authState: {},
157
+ };
158
+ }
159
+ // A nested `data` object is unwrapped the same way the dashboard's parser does.
160
+ const nested =
161
+ record["data"] !== null && typeof record["data"] === "object" && !Array.isArray(record["data"])
162
+ ? (record["data"] as Record<string, unknown>)
163
+ : undefined;
164
+ const accessToken =
165
+ firstString(record, OAUTH_ACCESS_FIELDS) ??
166
+ (nested ? firstString(nested, OAUTH_ACCESS_FIELDS) : undefined) ??
167
+ raw;
168
+ const refreshToken =
169
+ firstString(record, OAUTH_REFRESH_FIELDS) ??
170
+ (nested ? firstString(nested, OAUTH_REFRESH_FIELDS) : undefined) ??
171
+ accessToken;
172
+ const expiresAt =
173
+ parseExpiry(
174
+ record["expiresAt"] ??
175
+ record["expires_at"] ??
176
+ (nested ? (nested["expiresAt"] ?? nested["expires_at"]) : undefined) ??
177
+ record["expires"] ??
178
+ (nested ? nested["expires"] : undefined),
179
+ ) ?? parseJwtCredential(accessToken)?.expiresAt;
180
+ return { accessToken, refreshToken, expiresAt, authState: pastedAuthState(record, nested) };
181
+ }
182
+
183
+ /**
184
+ * The account configuration a pasted export carries beside its tokens.
185
+ *
186
+ * Kiro refuses a generation request that omits the profile the account is bound
187
+ * to, and exports from older installs carry that profile under a different key.
188
+ * Reading neither is how an imported account probes with no
189
+ * profile and is answered `400 profileArn is required`.
190
+ */
191
+ function pastedAuthState(
192
+ record: Record<string, unknown>,
193
+ nested: Record<string, unknown> | undefined,
194
+ ): Readonly<Record<string, unknown>> {
195
+ const sources = [record, nested, asRecord(record["providerSpecificData"]), asRecord(record["authState"]), asRecord(nested?.["authState"])];
196
+ const profileArn = firstStringFrom(sources, ["profileArn", "profile_arn"]);
197
+ const authMethod = firstStringFrom(sources, ["authMethod", "auth_method"]);
198
+ const region = firstStringFrom(sources, ["region"]);
199
+ return {
200
+ ...(profileArn === undefined ? {} : { profileArn }),
201
+ ...(authMethod === undefined ? {} : { authMethod }),
202
+ ...(region === undefined ? {} : { region }),
203
+ };
204
+ }
205
+
206
+ function asRecord(value: unknown): Record<string, unknown> | undefined {
207
+ return value !== null && typeof value === "object" && !Array.isArray(value)
208
+ ? (value as Record<string, unknown>)
209
+ : undefined;
210
+ }
211
+
212
+ /**
213
+ * Adds or removes the `image` input modality on a stored modalities blob.
214
+ *
215
+ * The blob is a JSON string whose shape varies by source (bundled seed,
216
+ * discovery, manual add), so it is normalized rather than pattern-matched: an
217
+ * unparseable blob is replaced with the minimal text shape rather than left to
218
+ * corrupt capability preflight.
219
+ */
220
+ export function withImageModality(stored: string | null | undefined, image: boolean): string {
221
+ const parsed = asRecord(safeParseJson(stored));
222
+ const input = Array.isArray(parsed?.input)
223
+ ? (parsed.input as unknown[]).filter((m): m is string => typeof m === "string")
224
+ : ["text"];
225
+ const output = Array.isArray(parsed?.output)
226
+ ? (parsed.output as unknown[]).filter((m): m is string => typeof m === "string")
227
+ : ["text"];
228
+ const nextInput = image
229
+ ? Array.from(new Set([...input, "image"]))
230
+ : input.filter((m) => m !== "image");
231
+ return JSON.stringify({
232
+ ...(parsed ?? {}),
233
+ input: nextInput.length > 0 ? nextInput : ["text"],
234
+ output: output.length > 0 ? output : ["text"],
235
+ });
236
+ }
237
+
238
+ function safeParseJson(value: string | null | undefined): unknown {
239
+ if (typeof value !== "string" || value.length === 0) return undefined;
240
+ try {
241
+ return JSON.parse(value) as unknown;
242
+ } catch {
243
+ return undefined;
244
+ }
245
+ }
246
+
247
+ /** Normalizes a text column read as `unknown` back to a string or null. */
248
+ function asStoredText(value: unknown): string | null {
249
+ return typeof value === "string" ? value : null;
250
+ }
251
+
252
+ function firstStringFrom(
253
+ sources: readonly (Record<string, unknown> | undefined)[],
254
+ fields: readonly string[],
255
+ ): string | undefined {
256
+ for (const source of sources) {
257
+ if (source === undefined) continue;
258
+ const value = firstString(source, fields);
259
+ if (value !== undefined) return value;
260
+ }
261
+ return undefined;
262
+ }
263
+
264
+ /**
265
+ * The account's still-active per-model backoffs, or `undefined` when none are.
266
+ *
267
+ * Expired entries are dropped rather than returned: the sweep that clears them
268
+ * runs on a timer, so between the deadline passing and the sweep a raw read
269
+ * would report a cooldown that is not in force.
270
+ */
271
+ function liveModelCooldowns(
272
+ raw: unknown,
273
+ ): { modelCooldowns: Record<string, string> } | undefined {
274
+ if (raw === null || typeof raw !== "object" || Array.isArray(raw)) return undefined;
275
+ const now = Date.now();
276
+ const live: Record<string, string> = {};
277
+ for (const [modelId, value] of Object.entries(raw as Record<string, unknown>)) {
278
+ if (typeof value !== "string") continue;
279
+ const at = new Date(value).getTime();
280
+ if (Number.isFinite(at) && at > now) live[modelId] = value;
281
+ }
282
+ return Object.keys(live).length > 0 ? { modelCooldowns: live } : undefined;
283
+ }
284
+
285
+
286
+ /**
287
+ * Projects one persisted `models` row into the console's catalog entry.
288
+ *
289
+ * `disabled` is resolved by the caller (from `tenant_disabled_models`), because
290
+ * the bulk read has to look it up across every provider at once while the
291
+ * single-provider read looks it up for one; the projection itself is identical
292
+ * either way, so it lives here rather than being written twice.
293
+ *
294
+ * Exported so a test can pin the unsized-model substitution without a database:
295
+ * which rows get the fallback, and that they are the same rows marked estimated.
296
+ */
297
+ export function mapModelRow(
298
+ row: typeof models.$inferSelect,
299
+ disabled: boolean,
300
+ ): ModelCatalogEntry {
301
+ const modalities =
302
+ row.modalities && typeof row.modalities === "object"
303
+ ? (row.modalities as { input?: unknown; output?: unknown })
304
+ : undefined;
305
+ const inputModalities = Array.isArray(modalities?.input) ? modalities.input : [];
306
+ const outputModalities = Array.isArray(modalities?.output) ? modalities.output : [];
307
+ // Nothing filed this model — not its own row, not the base catalog under this
308
+ // provider's id, not a majority across the catalog's other resellers. The
309
+ // fallback is substituted here rather than persisted: the stored `null` is the
310
+ // honest record of what upstream said, and substituting at the write would
311
+ // make a guess indistinguishable from a measurement on the next read. The
312
+ // marker rides along so the card can say which one it is showing.
313
+ const unfiled =
314
+ row.contextLimit === null &&
315
+ modelsDevCatalog.resolve(row.providerId, row.modelId) === undefined &&
316
+ modelsDevCatalog.majorityFor(row.modelId) === undefined;
317
+ return {
318
+ modelId: row.modelId,
319
+ route: row.endpointPath,
320
+ provider: row.providerId,
321
+ wireFamily: row.wireFamily,
322
+ serviceKind: row.serviceKind,
323
+ enabled: disabled ? false : row.enabled,
324
+ // A fetched model that published no context size read `n/a ctx · n/a out`
325
+ // while still being routable — a working model that looked unusable. These
326
+ // rows get the same ceilings a manual add is offered, flagged as estimated.
327
+ contextLimit: unfiled ? FALLBACK_CONTEXT_LIMIT : row.contextLimit,
328
+ outputLimit: unfiled ? FALLBACK_OUTPUT_LIMIT : row.outputLimit,
329
+ reasoning: row.reasoning,
330
+ toolCall: row.toolCall,
331
+ vision: inputModalities.includes("image"),
332
+ document: inputModalities.includes("document"),
333
+ audio: inputModalities.includes("audio"),
334
+ mediaGeneration: outputModalities.includes("image"),
335
+ cost: row.cost as ModelCatalogEntry["cost"],
336
+ source: row.source,
337
+ ...(unfiled ? { limitsEstimated: true } : {}),
338
+ // Three-valued on the wire as it is in the column: only a row that claims
339
+ // `true` is pro-only. Absence means nobody established the tier, and the
340
+ // surface must render that as unknown rather than as free.
341
+ ...(row.proOnly === true ? { proOnly: true } : {}),
342
+ // Same three-valued rule, from the provider's own directory. Qoder states
343
+ // `is_free` per row, so a plan change moves the pill without a code change.
344
+ ...(row.freeTier === true ? { freeTier: true } : {}),
345
+ sourceUpdatedAt: row.sourceUpdatedAt?.toISOString() ?? null,
346
+ } satisfies ModelCatalogEntry;
347
+ }
348
+
349
+ export class DrizzleProviderCatalogStore implements ProviderCatalogStore {
350
+ private readonly probing: ProviderProbingService;
351
+ private readonly bundledModelCatalog: ReadonlyMap<string, readonly ModelDefinition[]>;
352
+ private readonly providerRegistry: ProviderRegistry;
353
+
354
+ private readonly readAccountInflight:
355
+ | ((
356
+ providerId: string,
357
+ tenantId: string,
358
+ ) => Promise<readonly AccountInflightReading[]>)
359
+ | undefined;
360
+
361
+ constructor(
362
+ private readonly db: CartethyiaDatabase,
363
+ options: {
364
+ readonly telemetryBuffer: TelemetryBatchBuffer;
365
+ readonly bundledModelCatalog: BundledProviderCatalog;
366
+ readonly providerRegistry: ProviderRegistry;
367
+ readonly outboundFetchFor: ProbeOutboundResolver;
368
+ readonly snapshotInvalidator: { invalidate(): unknown };
369
+ readonly readAccountInflight?:
370
+ | ((
371
+ providerId: string,
372
+ tenantId: string,
373
+ ) => Promise<readonly AccountInflightReading[]>)
374
+ | undefined;
375
+ },
376
+ ) {
377
+ this.bundledModelCatalog = options.bundledModelCatalog.modelsByProvider;
378
+ this.providerRegistry = options.providerRegistry;
379
+ this.readAccountInflight = options.readAccountInflight;
380
+ this.probing = new ProviderProbingService({
381
+ db,
382
+ telemetryBuffer: options.telemetryBuffer,
383
+ defaultEndpoints: DEFAULT_ENDPOINT_BY_WIRE_FAMILY,
384
+ bundledModelCatalog: this.bundledModelCatalog,
385
+ outboundFetchFor: options.outboundFetchFor,
386
+ snapshotInvalidator: options.snapshotInvalidator,
387
+ providerRegistry: options.providerRegistry,
388
+ });
389
+ }
390
+ async list(tenantId: string): Promise<readonly ProviderRecord[]> {
391
+ const bundledIds = BUNDLED_PROVIDER_MODULES.map((provider) => provider.id);
392
+ const rows = await this.db
393
+ .select()
394
+ .from(providers)
395
+ .where(
396
+ and(
397
+ globalOrOwnedBy(providers.tenantId, tenantId),
398
+ or(inArray(providers.id, bundledIds), eq(providers.tenantId, tenantId)),
399
+ ),
400
+ )
401
+ .orderBy(asc(providers.id));
402
+ return rows.map((row) => mapProviderRow(row, this.providerRegistry));
403
+ }
404
+ async get(tenantId: string, providerId: string): Promise<ProviderRecord | undefined> {
405
+ const rows = await this.db
406
+ .select()
407
+ .from(providers)
408
+ .where(
409
+ and(
410
+ eq(providers.id, providerId),
411
+ globalOrOwnedBy(providers.tenantId, tenantId),
412
+ ),
413
+ )
414
+ .limit(1);
415
+ const row = rows[0];
416
+ return row ? mapProviderRow(row, this.providerRegistry) : undefined;
417
+ }
418
+ async create(record: ProviderRecord): Promise<void> {
419
+ if (isBundledProviderId(record.providerId))
420
+ throw new ConsoleDomainError(
421
+ "provider_is_builtin",
422
+ 403,
423
+ "Built-in provider cannot be created",
424
+ );
425
+ parseCustomProviderId(record.providerId);
426
+ if (record.compatibilityProfile) validateCompatibilityProfile(record.compatibilityProfile);
427
+ await this.db.insert(providers).values({
428
+ id: record.providerId,
429
+ tenantId: record.tenantId ?? null,
430
+ enabled: record.enabled,
431
+ ...(record.wireFamilyDefault ? { wireFamilyDefault: record.wireFamilyDefault } : {}),
432
+ capabilityProfile: record.capabilityProfile ?? null,
433
+ baseUrl: record.baseUrl ?? null,
434
+ compatibilityProfile: record.compatibilityProfile ?? null,
435
+ });
436
+ }
437
+ async update(
438
+ tenantId: string,
439
+ providerId: string,
440
+ patch: Partial<ProviderRecord>,
441
+ ): Promise<ProviderRecord | undefined> {
442
+ if (isBundledProviderId(providerId))
443
+ throw new ConsoleDomainError(
444
+ "provider_is_builtin",
445
+ 403,
446
+ "Built-in provider cannot be updated",
447
+ );
448
+ if (patch.compatibilityProfile) validateCompatibilityProfile(patch.compatibilityProfile);
449
+ const set: Partial<typeof providers.$inferInsert> = {};
450
+ if (patch.enabled !== undefined) set.enabled = patch.enabled;
451
+ if (patch.capabilityProfile !== undefined) set.capabilityProfile = patch.capabilityProfile;
452
+ if (patch.baseUrl !== undefined) set.baseUrl = patch.baseUrl;
453
+ if (patch.compatibilityProfile !== undefined)
454
+ set.compatibilityProfile = patch.compatibilityProfile;
455
+ const rows =
456
+ Object.keys(set).length > 0
457
+ ? await this.db
458
+ .update(providers)
459
+ .set(set)
460
+ .where(
461
+ and(
462
+ eq(providers.id, providerId),
463
+ ownedByOnly(providers.tenantId, tenantId),
464
+ ),
465
+ )
466
+ .returning()
467
+ : await this.db
468
+ .select()
469
+ .from(providers)
470
+ .where(
471
+ and(
472
+ eq(providers.id, providerId),
473
+ ownedByOnly(providers.tenantId, tenantId),
474
+ ),
475
+ );
476
+ const row = rows[0];
477
+ return row ? mapProviderRow(row, this.providerRegistry) : undefined;
478
+ }
479
+ async delete(tenantId: string, providerId: string): Promise<boolean> {
480
+ if (isBundledProviderId(providerId))
481
+ throw new ConsoleDomainError(
482
+ "provider_is_builtin",
483
+ 403,
484
+ "Built-in provider cannot be deleted",
485
+ );
486
+ // Custom-provider rows own their models, accounts, disabled-model flags,
487
+ // and routing preferences: deleting the parent cascades all of them
488
+ // (models/provider_accounts/provider_oauth_states via provider FK,
489
+ // health_events via account FK, tenant_disabled_models and
490
+ // provider_routing_settings via provider FK) in one atomic DELETE.
491
+ const deleted = await this.db
492
+ .delete(providers)
493
+ .where(and(eq(providers.id, providerId), ownedByOnly(providers.tenantId, tenantId)))
494
+ .returning({ id: providers.id });
495
+ if (deleted.length === 0) return false;
496
+ this.providerRegistry.unregister(providerId);
497
+ // The provider left the registry, so the compiled catalog is now missing it
498
+ // — the snapshot's re-seed is gated on that revision, and a delete that did
499
+ // not bump it would leave the provider's model rows behind.
500
+ bumpCatalogRevision();
501
+ return true;
502
+ }
503
+ async updateGlobal(
504
+ providerId: string,
505
+ patch: Partial<ProviderRecord>,
506
+ ): Promise<ProviderRecord | undefined> {
507
+ const isBuiltin = isBundledProviderId(providerId);
508
+ if (isBuiltin && Object.keys(patch).some((field) => field !== "enabled")) {
509
+ throw new ConsoleDomainError(
510
+ "provider_is_builtin",
511
+ 403,
512
+ "Built-in providers only support enable/disable",
513
+ );
514
+ }
515
+ if (patch.compatibilityProfile) validateCompatibilityProfile(patch.compatibilityProfile);
516
+ const set: Partial<typeof providers.$inferInsert> = {};
517
+ if (patch.enabled !== undefined) set.enabled = patch.enabled;
518
+ if (patch.capabilityProfile !== undefined) set.capabilityProfile = patch.capabilityProfile;
519
+ if (patch.baseUrl !== undefined) set.baseUrl = patch.baseUrl;
520
+ if (patch.compatibilityProfile !== undefined) {
521
+ set.compatibilityProfile = patch.compatibilityProfile;
522
+ }
523
+ const rows =
524
+ Object.keys(set).length > 0
525
+ ? await this.db
526
+ .update(providers)
527
+ .set(set)
528
+ .where(and(eq(providers.id, providerId), isNull(providers.tenantId)))
529
+ .returning()
530
+ : await this.db
531
+ .select()
532
+ .from(providers)
533
+ .where(and(eq(providers.id, providerId), isNull(providers.tenantId)));
534
+ const row = rows[0];
535
+ return row ? mapProviderRow(row, this.providerRegistry) : undefined;
536
+ }
537
+
538
+ async deleteGlobal(providerId: string): Promise<boolean> {
539
+ if (isBundledProviderId(providerId)) {
540
+ throw new ConsoleDomainError(
541
+ "provider_is_builtin",
542
+ 403,
543
+ "Built-in provider cannot be deleted",
544
+ );
545
+ }
546
+ const rows = await this.db
547
+ .delete(providers)
548
+ .where(and(eq(providers.id, providerId), isNull(providers.tenantId)))
549
+ .returning({ id: providers.id });
550
+ return rows.length > 0;
551
+ }
552
+ async listModels(tenantId: string, providerId: string): Promise<readonly ModelCatalogEntry[]> {
553
+ const byProvider = await this.listModelsForTenant(tenantId, providerId);
554
+ return byProvider.get(providerId) ?? [];
555
+ }
556
+
557
+ /**
558
+ * Every enabled-and-disabled model row the tenant can see, grouped by
559
+ * provider. One pair of statements for the whole tenant instead of a pair per
560
+ * provider.
561
+ *
562
+ * The per-provider `listModels` shape costs two queries per call, so a caller
563
+ * that needs every provider's models — the flat catalog the picker renders —
564
+ * issued `2 * providers + 2` statements, growing with the bundled catalog.
565
+ * Grouping here makes it constant: one disabled-model read and one models
566
+ * join, regardless of provider count. `providerId` narrows it to a single
567
+ * provider for callers that already know which one they want.
568
+ */
569
+ async listModelsForTenant(
570
+ tenantId: string,
571
+ providerId?: string,
572
+ ): Promise<Map<string, readonly ModelCatalogEntry[]>> {
573
+ const disabled = await this.db
574
+ .select({
575
+ providerId: tenantDisabledModels.providerId,
576
+ modelId: tenantDisabledModels.modelId,
577
+ endpointPath: tenantDisabledModels.endpointPath,
578
+ })
579
+ .from(tenantDisabledModels)
580
+ .where(
581
+ providerId === undefined
582
+ ? eq(tenantDisabledModels.tenantId, tenantId)
583
+ : and(
584
+ eq(tenantDisabledModels.tenantId, tenantId),
585
+ eq(tenantDisabledModels.providerId, providerId),
586
+ ),
587
+ );
588
+ const disabledKeys = new Set(
589
+ disabled.map((d) => `${d.providerId}::${d.modelId}::${d.endpointPath}`),
590
+ );
591
+ const dbRows = await this.db
592
+ .select({ model: models })
593
+ .from(models)
594
+ .innerJoin(providers, eq(models.providerId, providers.id))
595
+ .where(
596
+ and(
597
+ ...(providerId === undefined ? [] : [eq(models.providerId, providerId)]),
598
+ globalOrOwnedBy(providers.tenantId, tenantId),
599
+ ),
600
+ );
601
+ const grouped = new Map<string, ModelCatalogEntry[]>();
602
+ for (const { model: row } of dbRows) {
603
+ const isDisabled = disabledKeys.has(
604
+ `${row.providerId}::${row.modelId}::${row.endpointPath}`,
605
+ );
606
+ const list = grouped.get(row.providerId) ?? [];
607
+ list.push(mapModelRow(row, isDisabled));
608
+ grouped.set(row.providerId, list);
609
+ }
610
+ return grouped;
611
+ }
612
+ async registerModels(
613
+ tenantId: string,
614
+ providerId: string,
615
+ modelIds: readonly string[],
616
+ wireFamily?: string,
617
+ metadata?: ModelMetadataOverride,
618
+ ): Promise<void> {
619
+ const owner = await this.db
620
+ .select({
621
+ id: providers.id,
622
+ baseUrl: providers.baseUrl,
623
+ compatibilityProfile: providers.compatibilityProfile,
624
+ })
625
+ .from(providers)
626
+ .where(and(eq(providers.id, providerId), globalOrOwnedBy(providers.tenantId, tenantId)))
627
+ .limit(1);
628
+ if (!owner[0]) {
629
+ throw new ConsoleDomainError("provider_not_found", 404, `Provider ${providerId} not found`);
630
+ }
631
+ const family = (wireFamily ?? "chat") as WireFamily;
632
+ // Static endpoint from the provider's own bundled catalog via the probing
633
+ // service (the same startup-built map this store is constructed with).
634
+ // Without this, bundled providers on versioned bases (cline) get the
635
+ // generic `/v1/…` default, which joins into an unreachable path and every
636
+ // probe/dispatch 404s.
637
+ const staticEndpoint = await this.probing.resolveStaticEndpoint(providerId, family);
638
+ const endpointPath = endpointPathForProviderModel(
639
+ family,
640
+ owner[0].compatibilityProfile,
641
+ staticEndpoint === undefined ? undefined : { [family]: staticEndpoint },
642
+ );
643
+ if (modelIds.length === 0) return;
644
+ // Single multi-row insert and upsert of the capability flags so a
645
+ // re-registration repairs a row whose `tool_call` (and `reasoning`) was
646
+ // seeded false — a manual add persists schema defaults, which capability
647
+ // preflight reads as "route cannot use tools" and silently strips `tools`
648
+ // from every request. `enabled` stays operator-owned; `source` is refreshed
649
+ // to `manual`.
650
+ await this.db
651
+ .insert(models)
652
+ .values(
653
+ modelIds.map((modelId) => ({
654
+ providerId,
655
+ modelId,
656
+ wireFamily: family,
657
+ endpointPath,
658
+ ...applyModelMetadataOverride(
659
+ resolveManualModelMetadata(providerId, modelId),
660
+ metadata,
661
+ ),
662
+ source: "manual",
663
+ sourceUpdatedAt: new Date(),
664
+ enabled: true,
665
+ })),
666
+ )
667
+ .onConflictDoUpdate({
668
+ target: [models.providerId, models.modelId, models.endpointPath],
669
+ set: {
670
+ wireFamily: sql`excluded.wire_family`,
671
+ contextLimit: sql`excluded.context_limit`,
672
+ outputLimit: sql`excluded.output_limit`,
673
+ modalities: sql`excluded.modalities`,
674
+ reasoning: sql`excluded.reasoning`,
675
+ toolCall: sql`excluded.tool_call`,
676
+ source: sql`'manual'`,
677
+ sourceUpdatedAt: sql`excluded.source_updated_at`,
678
+ },
679
+ });
680
+ }
681
+
682
+ /**
683
+ * One-shot connectivity probe and model discovery live in
684
+ * `services/provider-probing.ts` — the store only forwards so the domain
685
+ * `ProviderCatalogStore` interface is unchanged.
686
+ */
687
+ async probeModel(
688
+ tenantId: string,
689
+ providerId: string,
690
+ request: ProbeModelRequest,
691
+ ): Promise<ProbeModelResult> {
692
+ return this.probing.probeModel(tenantId, providerId, request);
693
+ }
694
+
695
+ /** Ad-hoc BYOK connectivity test; delegates to the shared probing service. */
696
+ async testByokConnection(
697
+ tenantId: string,
698
+ request: ByokConnectionTestRequest,
699
+ ): Promise<ByokConnectionTestResult> {
700
+ return this.probing.testByokConnection(tenantId, request);
701
+ }
702
+
703
+ /**
704
+ * Batched provider-wide probe: the first registered model runs sequentially
705
+ * (warms OAuth token / connection pools), the rest run concurrently bounded
706
+ * to 5. Registered model ids come from this store's own catalog read.
707
+ */
708
+ async probeAllModels(tenantId: string, providerId: string): Promise<ProbeAllModelsResult> {
709
+ const entries = await this.listModels(tenantId, providerId);
710
+ const requests = entries.map((entry) => ({
711
+ modelId: entry.modelId,
712
+ route: entry.route,
713
+ serviceKind: isServiceKind(entry.serviceKind) ? entry.serviceKind : ("llm" as const),
714
+ }));
715
+ const uniqueRequests = requests.filter(
716
+ (request, index, all) =>
717
+ all.findIndex(
718
+ (candidate) =>
719
+ candidate.modelId === request.modelId &&
720
+ candidate.route === request.route &&
721
+ candidate.serviceKind === request.serviceKind,
722
+ ) === index,
723
+ );
724
+ return this.probing.probeAllModels(tenantId, providerId, uniqueRequests);
725
+ }
726
+ async probeAllAccounts(
727
+ tenantId: string,
728
+ providerId: string,
729
+ request: ProbeModelRequest,
730
+ ): Promise<ProbeAllAccountsResult> {
731
+ return this.probing.probeAllAccounts(tenantId, providerId, request);
732
+ }
733
+ /** Toggles a persisted model's routing eligibility. */
734
+ async setModelEnabled(
735
+ tenantId: string,
736
+ providerId: string,
737
+ request: SetModelEnabledRequest,
738
+ ): Promise<boolean> {
739
+ // Tenant-owned (BYOK) model: toggle the shared row directly.
740
+ const owned = await this.db
741
+ .select({ id: models.id })
742
+ .from(models)
743
+ .innerJoin(providers, eq(models.providerId, providers.id))
744
+ .where(
745
+ and(
746
+ eq(models.providerId, providerId),
747
+ eq(models.modelId, request.modelId),
748
+ eq(models.endpointPath, request.route),
749
+ ownedByOnly(providers.tenantId, tenantId),
750
+ ),
751
+ )
752
+ .limit(1);
753
+ const ownedTarget = owned[0];
754
+ if (ownedTarget) {
755
+ await this.db
756
+ .update(models)
757
+ .set({ enabled: request.enabled })
758
+ .where(eq(models.id, ownedTarget.id));
759
+ return true;
760
+ }
761
+
762
+ // Global/built-in model: tenants cannot mutate the shared row, so record
763
+ // (or clear) a tenant-scoped disable in `tenantDisabledModels`.
764
+ const global = await this.db
765
+ .select({ id: models.id })
766
+ .from(models)
767
+ .innerJoin(providers, eq(models.providerId, providers.id))
768
+ .where(
769
+ and(
770
+ eq(models.providerId, providerId),
771
+ eq(models.modelId, request.modelId),
772
+ eq(models.endpointPath, request.route),
773
+ isNull(providers.tenantId),
774
+ ),
775
+ )
776
+ .limit(1);
777
+ if (global[0] === undefined) return false;
778
+ if (request.enabled) {
779
+ await this.db
780
+ .delete(tenantDisabledModels)
781
+ .where(
782
+ and(
783
+ eq(tenantDisabledModels.tenantId, tenantId),
784
+ eq(tenantDisabledModels.providerId, providerId),
785
+ eq(tenantDisabledModels.modelId, request.modelId),
786
+ eq(tenantDisabledModels.endpointPath, request.route),
787
+ ),
788
+ );
789
+ return true;
790
+ }
791
+ await this.db
792
+ .insert(tenantDisabledModels)
793
+ .values({
794
+ tenantId,
795
+ providerId,
796
+ modelId: request.modelId,
797
+ endpointPath: request.route,
798
+ })
799
+ .onConflictDoNothing({
800
+ target: [
801
+ tenantDisabledModels.tenantId,
802
+ tenantDisabledModels.providerId,
803
+ tenantDisabledModels.modelId,
804
+ tenantDisabledModels.endpointPath,
805
+ ],
806
+ });
807
+ return true;
808
+ }
809
+
810
+ /**
811
+ * Updates one model's limits and capability flags.
812
+ *
813
+ * Two row kinds are writable. A tenant-owned (BYOK) row is updated directly.
814
+ * A model belonging to a *global* provider row is also updated directly:
815
+ * bundled providers are seeded once with `tenant_id IS NULL` and `providers.id`
816
+ * is the primary key, so a bundled provider can only ever have that one row —
817
+ * there is no tenant-scoped row to edit instead. Auto-fetched rows
818
+ * (`discovered` / `auto_free`) live on exactly those providers, which is why
819
+ * the previous `ownedByOnly` filter answered `not_found` for every model the
820
+ * operator had just fetched and could not be edited at all.
821
+ *
822
+ * A global `builtin` row is rejected: it is re-seeded from the bundle on every
823
+ * boot, so an edit there would be silently reverted, and `deleteModel` refuses
824
+ * it for the same reason.
825
+ *
826
+ * Absent override fields leave the stored value untouched — an edit that only
827
+ * sets the context ceiling must not reset the capability flags.
828
+ */
829
+ async updateModelMetadata(
830
+ tenantId: string,
831
+ providerId: string,
832
+ request: UpdateModelMetadataRequest,
833
+ ): Promise<boolean> {
834
+ const findRow = (ownership: SQL) =>
835
+ this.db
836
+ .select({ id: models.id, modalities: models.modalities, source: models.source })
837
+ .from(models)
838
+ .innerJoin(providers, eq(models.providerId, providers.id))
839
+ .where(
840
+ and(
841
+ eq(models.providerId, providerId),
842
+ eq(models.modelId, request.modelId),
843
+ eq(models.endpointPath, request.route),
844
+ ownership,
845
+ ),
846
+ )
847
+ .limit(1);
848
+ const owned = await findRow(ownedByOnly(providers.tenantId, tenantId));
849
+ let row = owned[0];
850
+ if (row === undefined) {
851
+ const global = await findRow(isNull(providers.tenantId));
852
+ const candidate = global[0];
853
+ if (candidate === undefined) return false;
854
+ if (candidate.source === "builtin") {
855
+ throw new ConsoleDomainError(
856
+ "builtin_model_immutable",
857
+ 403,
858
+ `Built-in model ${providerId}/${request.modelId} cannot be edited — the bundle re-seeds it on every boot`,
859
+ );
860
+ }
861
+ row = candidate;
862
+ }
863
+
864
+ const override = request.metadata;
865
+ const set: Record<string, unknown> = { sourceUpdatedAt: new Date() };
866
+ if (override?.contextLimit !== undefined) set.contextLimit = override.contextLimit;
867
+ if (override?.outputLimit !== undefined) set.outputLimit = override.outputLimit;
868
+ // Passthrough grants every capability and cannot be partly revoked by a
869
+ // per-flag field: the point of the flag is that the operator does not know
870
+ // which capabilities the model has, so an explicit `false` alongside it is
871
+ // either stale or a mistake.
872
+ if (override?.passthrough === true) {
873
+ set.reasoning = true;
874
+ set.toolCall = true;
875
+ set.modalities = withImageModality(asStoredText(row.modalities), true);
876
+ } else {
877
+ if (override?.reasoning !== undefined) set.reasoning = override.reasoning;
878
+ if (override?.toolCall !== undefined) set.toolCall = override.toolCall;
879
+ if (override?.image !== undefined)
880
+ set.modalities = withImageModality(asStoredText(row.modalities), override.image);
881
+ }
882
+ await this.db.update(models).set(set).where(eq(models.id, row.id));
883
+ return true;
884
+ }
885
+
886
+ async deleteModel(
887
+ tenantId: string,
888
+ providerId: string,
889
+ request: SetModelEnabledRequest,
890
+ ): Promise<boolean> {
891
+ const owned = await this.db
892
+ .select({ id: models.id })
893
+ .from(models)
894
+ .innerJoin(providers, eq(models.providerId, providers.id))
895
+ .where(
896
+ and(
897
+ eq(models.providerId, providerId),
898
+ eq(models.modelId, request.modelId),
899
+ eq(models.endpointPath, request.route),
900
+ ownedByOnly(providers.tenantId, tenantId),
901
+ ),
902
+ )
903
+ .limit(1);
904
+ const target = owned[0];
905
+ if (target) {
906
+ const rows = await this.db
907
+ .delete(models)
908
+ .where(eq(models.id, target.id))
909
+ .returning({ id: models.id });
910
+ return rows.length > 0;
911
+ }
912
+ const global = await this.db
913
+ .select({ id: models.id, source: models.source })
914
+ .from(models)
915
+ .innerJoin(providers, eq(models.providerId, providers.id))
916
+ .where(
917
+ and(
918
+ eq(models.providerId, providerId),
919
+ eq(models.modelId, request.modelId),
920
+ eq(models.endpointPath, request.route),
921
+ isNull(providers.tenantId),
922
+ ),
923
+ )
924
+ .limit(1);
925
+ if (!global[0]) return false;
926
+ // Built-in models are immutable catalog: tenants can only disable them
927
+ // (via tenantDisabledModels), never delete the shared row.
928
+ if (global[0].source === "builtin") {
929
+ throw new ConsoleDomainError(
930
+ "builtin_model_immutable",
931
+ 403,
932
+ `Built-in model ${providerId}/${request.modelId} cannot be deleted — disable it instead`,
933
+ );
934
+ }
935
+ const rows = await this.db
936
+ .delete(models)
937
+ .where(eq(models.id, global[0].id))
938
+ .returning({ id: models.id });
939
+ await this.db
940
+ .delete(tenantDisabledModels)
941
+ .where(
942
+ and(
943
+ eq(tenantDisabledModels.tenantId, tenantId),
944
+ eq(tenantDisabledModels.providerId, providerId),
945
+ eq(tenantDisabledModels.modelId, request.modelId),
946
+ eq(tenantDisabledModels.endpointPath, request.route),
947
+ ),
948
+ );
949
+ return rows.length > 0;
950
+ }
951
+
952
+ private async accountsWithUsage(
953
+ tenantId: string,
954
+ rows: readonly (typeof providerAccounts.$inferSelect)[],
955
+ ): Promise<readonly ProviderAccountResponse[]> {
956
+ if (rows.length === 0) return [];
957
+ const todayStart = new Date();
958
+ todayStart.setUTCHours(0, 0, 0, 0);
959
+ const accountIds = rows.map((row) => row.id);
960
+ const todayEvents = await queryTelemetryEvents({ tenantId, accountIds, since: todayStart });
961
+ const todayByAccount = new Map<string, { requests: number; errors: number; inputTokens: number; outputTokens: number }>();
962
+ for (const event of todayEvents) {
963
+ if (!event.accountId) continue;
964
+ const bucket = todayByAccount.get(event.accountId) ?? { requests: 0, errors: 0, inputTokens: 0, outputTokens: 0 };
965
+ bucket.requests += 1;
966
+ if (isGatewayError(event.status, event.httpStatus)) bucket.errors += 1;
967
+ bucket.inputTokens += event.inputTokens ?? 0;
968
+ bucket.outputTokens += event.outputTokens ?? 0;
969
+ todayByAccount.set(event.accountId, bucket);
970
+ }
971
+ const [lifetimeRows] = await Promise.all([
972
+ this.db
973
+ .select()
974
+ .from(telemetryUsageTotals)
975
+ .where(
976
+ and(
977
+ eq(telemetryUsageTotals.tenantId, tenantId),
978
+ eq(telemetryUsageTotals.identityType, "account"),
979
+ inArray(telemetryUsageTotals.entityId, accountIds),
980
+ ),
981
+ ),
982
+ ]);
983
+ const lifetimeByAccount = new Map(lifetimeRows.map((row) => [row.entityId, row]));
984
+ const mapUsage = (row: {
985
+ readonly requests: number | string | null;
986
+ readonly errors: number | string | null;
987
+ readonly inputTokens: number | string | null;
988
+ readonly outputTokens: number | string | null;
989
+ } | undefined): ProviderAccountTokenUsage => {
990
+ const inputTokens = Number(row?.inputTokens ?? 0);
991
+ const outputTokens = Number(row?.outputTokens ?? 0);
992
+ return {
993
+ requests: Number(row?.requests ?? 0),
994
+ errors: Number(row?.errors ?? 0),
995
+ inputTokens,
996
+ outputTokens,
997
+ totalTokens: inputTokens + outputTokens,
998
+ };
999
+ };
1000
+ const first = rows[0];
1001
+ const inflightByAccount = await this.accountInflightFor(tenantId, first?.providerId);
1002
+ return rows.map((row) => {
1003
+ const inflight = inflightByAccount.get(row.id);
1004
+ return this.mapAccount(row, {
1005
+ today: mapUsage(todayByAccount.get(row.id)),
1006
+ allTime: mapUsage(lifetimeByAccount.get(row.id)),
1007
+ ...(inflight === undefined ? {} : { inflight }),
1008
+ });
1009
+ });
1010
+ }
1011
+
1012
+ private async accountInflightFor(
1013
+ tenantId: string,
1014
+ providerId: string | undefined,
1015
+ ): Promise<Map<string, number>> {
1016
+ const readings = new Map<string, number>();
1017
+ if (!this.readAccountInflight || !providerId) return readings;
1018
+ const rows = await this.readAccountInflight(providerId, tenantId);
1019
+ for (const row of rows) {
1020
+ if (typeof row.accountId !== "string" || !Number.isFinite(row.inflight)) continue;
1021
+ readings.set(row.accountId, Math.max(0, Math.floor(row.inflight)));
1022
+ }
1023
+ return readings;
1024
+ }
1025
+
1026
+ /**
1027
+ * Next free list position for a new account in this provider's list.
1028
+ *
1029
+ * Scoped to (provider, tenant) because the list is per provider: a tenant
1030
+ * with accounts under two providers must not have the second provider's
1031
+ * first account land at a high index.
1032
+ */
1033
+ /**
1034
+ * Labels already in use for one provider, for numbering a generated name.
1035
+ *
1036
+ * Scoped to this provider and tenant: `dcint-key-1` and `other-key-1` belong
1037
+ * to different providers and must not collide with each other.
1038
+ */
1039
+ private async generateAccountLabel(
1040
+ db: CartethyiaDatabase | Parameters<Parameters<CartethyiaDatabase["transaction"]>[0]>[0],
1041
+ providerId: string,
1042
+ tenantId: string,
1043
+ credentialMode: CredentialMode,
1044
+ ): Promise<string> {
1045
+ const rows = await db
1046
+ .select({ label: providerAccounts.label })
1047
+ .from(providerAccounts)
1048
+ .where(
1049
+ and(
1050
+ eq(providerAccounts.providerId, providerId),
1051
+ globalOrOwnedBy(providerAccounts.tenantId, tenantId),
1052
+ ),
1053
+ );
1054
+ return nextGeneratedAccountLabel(
1055
+ providerId,
1056
+ credentialMode,
1057
+ rows.map((row) => row.label).filter((label): label is string => typeof label === "string"),
1058
+ );
1059
+ }
1060
+
1061
+ private async nextAccountSortIndex(
1062
+ db: CartethyiaDatabase | Parameters<Parameters<CartethyiaDatabase["transaction"]>[0]>[0],
1063
+ providerId: string,
1064
+ tenantId: string,
1065
+ ): Promise<number> {
1066
+ return nextSortIndex(
1067
+ db,
1068
+ { table: providerAccounts, idColumn: providerAccounts.id, sortIndexColumn: providerAccounts.sortIndex },
1069
+ and(
1070
+ eq(providerAccounts.providerId, providerId),
1071
+ globalOrOwnedBy(providerAccounts.tenantId, tenantId),
1072
+ ),
1073
+ );
1074
+ }
1075
+
1076
+ /** Rewrites one provider's account order; `accountIds` is the full order. */
1077
+ async reorderAccounts(
1078
+ tenantId: string,
1079
+ providerId: string,
1080
+ accountIds: readonly string[],
1081
+ ): Promise<void> {
1082
+ await reorderSortIndex(
1083
+ this.db,
1084
+ { table: providerAccounts, idColumn: providerAccounts.id, sortIndexColumn: providerAccounts.sortIndex },
1085
+ and(
1086
+ eq(providerAccounts.providerId, providerId),
1087
+ globalOrOwnedBy(providerAccounts.tenantId, tenantId),
1088
+ ),
1089
+ accountIds,
1090
+ );
1091
+ }
1092
+
1093
+ async listAccounts(
1094
+ tenantId: string,
1095
+ providerId: string,
1096
+ ): Promise<readonly ProviderAccountResponse[]> {
1097
+ const rows = await this.db
1098
+ .select()
1099
+ .from(providerAccounts)
1100
+ .where(
1101
+ and(
1102
+ eq(providerAccounts.providerId, providerId),
1103
+ globalOrOwnedBy(providerAccounts.tenantId, tenantId),
1104
+ ),
1105
+ )
1106
+ // Stable list position, not `created_at`: two accounts created in the
1107
+ // same millisecond could swap between loads. `id` breaks a tie so the
1108
+ // order is total even before a reorder assigns distinct indices.
1109
+ .orderBy(asc(providerAccounts.sortIndex), asc(providerAccounts.createdAt), asc(providerAccounts.id));
1110
+ return this.accountsWithUsage(tenantId, rows);
1111
+ }
1112
+
1113
+ async listAllAccounts(tenantId: string): Promise<readonly ProviderAccountResponse[]> {
1114
+ const rows = await this.db
1115
+ .select()
1116
+ .from(providerAccounts)
1117
+ .where(globalOrOwnedBy(providerAccounts.tenantId, tenantId))
1118
+ .orderBy(asc(providerAccounts.sortIndex), asc(providerAccounts.createdAt), asc(providerAccounts.id));
1119
+ return this.accountsWithUsage(tenantId, rows);
1120
+ }
1121
+
1122
+ async createAccount(
1123
+ tenantId: string,
1124
+ providerId: string,
1125
+ request: CreateProviderAccountRequest,
1126
+ existingTx?: Parameters<Parameters<CartethyiaDatabase["transaction"]>[0]>[0],
1127
+ includeUsage = true,
1128
+ sortIndexOverride?: number,
1129
+ ): Promise<ProviderAccountResponse> {
1130
+ // Callers inside an existing transaction pass it in so the account lands in
1131
+ // that unit of work (the batch path); otherwise this opens its own.
1132
+ const run = async (
1133
+ tx: Parameters<Parameters<CartethyiaDatabase["transaction"]>[0]>[0],
1134
+ ): Promise<ProviderAccountResponse> => {
1135
+ let effectiveSecret = request.secret;
1136
+ let effectiveCredentialKind = request.credentialKind;
1137
+ const requestedCredentialMode: CredentialMode = request.credentialMode ?? "auto";
1138
+ let effectiveCredentialMode: CredentialMode = requestedCredentialMode;
1139
+ let tokenExpiresAt: Date | undefined;
1140
+ let oauthRefreshCiphertext: Buffer | undefined;
1141
+ let oauthExpiresAt: Date | undefined;
1142
+ let oauthHasRefreshToken = false;
1143
+ let pastedAuthState: Readonly<Record<string, unknown>> = {};
1144
+
1145
+ if (
1146
+ (request.credentialKind === "oauth" || request.credentialKind === "api_key") &&
1147
+ request.secret.trim().length > 0
1148
+ ) {
1149
+ const trimmed = request.secret.trim();
1150
+ const parts = parsePastedOAuthCredential(trimmed);
1151
+ pastedAuthState = parts.authState;
1152
+ const hasRefreshToken = parts.refreshToken !== parts.accessToken;
1153
+ const parsedJwt =
1154
+ requestedCredentialMode === "api_key"
1155
+ ? undefined
1156
+ : parseJwtCredential(parts.accessToken);
1157
+ if (requestedCredentialMode === "jwt" && parsedJwt === undefined) {
1158
+ throw new ConsoleDomainError(
1159
+ "invalid_request",
1160
+ 400,
1161
+ "credentialMode jwt requires a valid three-part JWT",
1162
+ );
1163
+ }
1164
+ if (requestedCredentialMode === "api_key") {
1165
+ effectiveSecret = parts.accessToken;
1166
+ effectiveCredentialKind = "api_key";
1167
+ effectiveCredentialMode = "api_key";
1168
+ } else if (
1169
+ parsedJwt !== undefined &&
1170
+ (requestedCredentialMode === "jwt" || !hasRefreshToken)
1171
+ ) {
1172
+ effectiveSecret = parts.accessToken;
1173
+ effectiveCredentialKind = "api_key";
1174
+ effectiveCredentialMode = "jwt";
1175
+ tokenExpiresAt = parsedJwt.expiresAt;
1176
+ } else if (request.credentialKind === "oauth" || hasRefreshToken) {
1177
+ effectiveSecret = parts.accessToken;
1178
+ effectiveCredentialKind = hasRefreshToken ? "oauth" : "api_key";
1179
+ effectiveCredentialMode = hasRefreshToken ? "auto" : "api_key";
1180
+ if (hasRefreshToken) {
1181
+ oauthRefreshCiphertext = encryptCredential(parts.refreshToken);
1182
+ oauthHasRefreshToken = true;
1183
+ }
1184
+ oauthExpiresAt = parts.expiresAt;
1185
+ } else {
1186
+ effectiveCredentialMode = "api_key";
1187
+ }
1188
+ }
1189
+
1190
+ // Append to the end of this provider's list rather than relying on
1191
+ // creation time, so a new account never displaces existing positions.
1192
+ const credentialFingerprint =
1193
+ effectiveCredentialKind === "none" || effectiveSecret.length === 0
1194
+ ? undefined
1195
+ : hashSecret(effectiveSecret);
1196
+ const accountAuthState = {
1197
+ ...pastedAuthState,
1198
+ ...(request.authState ?? {}),
1199
+ credentialMode: effectiveCredentialMode,
1200
+ ...(tokenExpiresAt === undefined ? {} : { tokenExpiresAt: tokenExpiresAt.toISOString() }),
1201
+ };
1202
+ const nextSortIndex =
1203
+ sortIndexOverride ?? (await this.nextAccountSortIndex(tx, providerId, tenantId));
1204
+ // Read existing labels only when one has to be generated: a named
1205
+ // account costs no extra query, and this is the path a large batch
1206
+ // paste walks once per row.
1207
+ const label =
1208
+ request.label ??
1209
+ (await this.generateAccountLabel(tx, providerId, tenantId, effectiveCredentialMode));
1210
+ const rows = await tx
1211
+ .insert(providerAccounts)
1212
+ .values({
1213
+ providerId,
1214
+ tenantId,
1215
+ sortIndex: nextSortIndex,
1216
+ label,
1217
+ credentialCiphertext: encryptCredential(effectiveSecret),
1218
+ ...(credentialFingerprint ? { credentialFingerprint } : {}),
1219
+ credentialKind: effectiveCredentialKind,
1220
+ status: "active",
1221
+ authState: accountAuthState,
1222
+ // An OAuth account pasted without a refresh token is a *static*
1223
+ // token: the pasted value is a bearer token used exactly as issued
1224
+ // and there is no refresh grant to run. Flag it as static so the
1225
+ // console reads it as an informational "static token" rather than
1226
+ // "re-login required" — the token is still valid and dispatchable —
1227
+ // and so the refresh sweep skips it instead of retrying a refresh
1228
+ // that can never succeed.
1229
+ ...(effectiveCredentialKind === "oauth" && !oauthHasRefreshToken || effectiveCredentialMode === "jwt"
1230
+ ? { staticToken: true }
1231
+ : {}),
1232
+ })
1233
+ .returning();
1234
+ const row = rows[0];
1235
+ if (!row) throw new Error("failed to create provider account");
1236
+
1237
+ if (effectiveCredentialKind === "oauth") {
1238
+ await tx.insert(providerOauthStates).values({
1239
+ providerAccountId: row.id,
1240
+ ...(oauthRefreshCiphertext ? { refreshCiphertext: oauthRefreshCiphertext } : {}),
1241
+ expiresAt: oauthExpiresAt ?? null,
1242
+ });
1243
+ }
1244
+ if (request.credentialKind === "oauth" && !oauthHasRefreshToken) {
1245
+ pushStructuredConsoleLog(
1246
+ "info",
1247
+ "OAuth access token stored as a static API credential; it will not be refreshed",
1248
+ {
1249
+ event: "token_refresh",
1250
+ providerId,
1251
+ accountId: row.id,
1252
+ errorCode: "static_token",
1253
+ },
1254
+ );
1255
+ }
1256
+
1257
+ if (!includeUsage) {
1258
+ const emptyUsage = { requests: 0, errors: 0, inputTokens: 0, outputTokens: 0, totalTokens: 0 };
1259
+ return this.mapAccount(row, { today: emptyUsage, allTime: emptyUsage });
1260
+ }
1261
+ const [account] = await this.accountsWithUsage(tenantId, [row]);
1262
+ if (!account) throw new Error("created provider account could not be mapped");
1263
+ return account;
1264
+ };
1265
+
1266
+ try {
1267
+ if (existingTx !== undefined) {
1268
+ return await run(existingTx);
1269
+ }
1270
+ return await this.db.transaction(run);
1271
+ } catch (error) {
1272
+ if (isUniqueViolation(error)) {
1273
+ throw new ConsoleDomainError(
1274
+ "provider_account_duplicate",
1275
+ 409,
1276
+ "A provider account with this credential already exists",
1277
+ );
1278
+ }
1279
+ throw error;
1280
+ }
1281
+ }
1282
+
1283
+ /**
1284
+ * Creates many accounts in one round-trip.
1285
+ *
1286
+ * The whole batch shares one transaction, so a paste of 40 credentials costs
1287
+ * one connection checkout rather than 40. Each item is created through the
1288
+ * same `createAccount` path the single route uses — there is no second
1289
+ * implementation of credential detection to drift.
1290
+ *
1291
+ * Items are created sequentially inside that transaction: the unique
1292
+ * credential fingerprint makes two identical pastes collide, and a nested
1293
+ * transaction per item keeps that collision from aborting the whole batch.
1294
+ * Outcomes are reported per index, so a partial paste still lands the
1295
+ * accounts that were fine — an operator fixing one bad line should not have
1296
+ * to re-import 39.
1297
+ */
1298
+ async createAccountBatch(
1299
+ tenantId: string,
1300
+ providerId: string,
1301
+ requests: readonly CreateProviderAccountRequest[],
1302
+ ): Promise<CreateProviderAccountBatchResult> {
1303
+ if (requests.length === 0) {
1304
+ return { created: 0, failed: 0, results: [] };
1305
+ }
1306
+ const results: CreateProviderAccountBatchItemResult[] = [];
1307
+ let created = 0;
1308
+ let failed = 0;
1309
+
1310
+ await this.db.transaction(async (tx) => {
1311
+ let nextSortIndex = await this.nextAccountSortIndex(tx, providerId, tenantId);
1312
+ for (const [index, request] of requests.entries()) {
1313
+ try {
1314
+ // A nested transaction acts as a savepoint: a duplicate-fingerprint
1315
+ // violation rolls back only this item, not the accounts before it.
1316
+ const account = await tx.transaction(async (inner) =>
1317
+ this.createAccount(tenantId, providerId, request, inner, false, nextSortIndex),
1318
+ );
1319
+ created += 1;
1320
+ nextSortIndex += 1;
1321
+ results.push({ index, ok: true, ...(account ? { account } : {}) });
1322
+ } catch (error) {
1323
+ failed += 1;
1324
+ results.push({
1325
+ index,
1326
+ ok: false,
1327
+ error: error instanceof Error ? error.message : String(error),
1328
+ code: error instanceof ConsoleDomainError ? error.code : "create_failed",
1329
+ });
1330
+ }
1331
+ }
1332
+ });
1333
+
1334
+ return { created, failed, results };
1335
+ }
1336
+
1337
+ async updateAccount(
1338
+ tenantId: string,
1339
+ providerId: string,
1340
+ accountId: string,
1341
+ patch: UpdateProviderAccountRequest,
1342
+ ): Promise<ProviderAccountResponse | undefined> {
1343
+ const set: Partial<typeof providerAccounts.$inferInsert> = {};
1344
+ if (patch.label !== undefined) set.label = patch.label;
1345
+ if (patch.secret !== undefined) {
1346
+ set.credentialCiphertext = encryptCredential(patch.secret);
1347
+ set.credentialFingerprint =
1348
+ patch.secret.length === 0 ? null : hashSecret(patch.secret);
1349
+ }
1350
+ if (patch.status !== undefined) set.status = patch.status;
1351
+ // Toggling the static-token flag: turning it on declares the credential is
1352
+ // used as issued and never refreshed, so any stale re-auth flag is cleared —
1353
+ // the operator has said the account is fine, and the pill must stop reading
1354
+ // "Re-login required". Turning it off returns the account to normal OAuth
1355
+ // refresh handling; the sweep picks it up again on its next pass.
1356
+ if (patch.staticToken !== undefined) {
1357
+ set.staticToken = patch.staticToken;
1358
+ if (patch.staticToken) {
1359
+ set.lastError = null;
1360
+ set.lastErrorCategory = null;
1361
+ set.lastErrorAt = null;
1362
+ }
1363
+ }
1364
+ // Status toggles are not recovery: preserve the health machine's evidence
1365
+ // until the operator uses Recover or replaces the rejected credential.
1366
+ const clearsFailureState = patch.secret !== undefined && patch.secret.length > 0;
1367
+ if (clearsFailureState) {
1368
+ set.consecutiveFailures = 0;
1369
+ set.cooldownUntil = null;
1370
+ set.modelCooldowns = {};
1371
+ set.lastError = null;
1372
+ set.lastErrorCategory = null;
1373
+ set.lastErrorAt = null;
1374
+ }
1375
+ const where = and(
1376
+ eq(providerAccounts.id, accountId),
1377
+ eq(providerAccounts.providerId, providerId),
1378
+ globalOrOwnedBy(providerAccounts.tenantId, tenantId),
1379
+ );
1380
+ try {
1381
+ const rows =
1382
+ Object.keys(set).length > 0
1383
+ ? await this.db.update(providerAccounts).set(set).where(where).returning()
1384
+ : await this.db.select().from(providerAccounts).where(where);
1385
+ const row = rows[0];
1386
+ if (!row) return undefined;
1387
+ // Secret/status mutation: drop any cached credential so the next
1388
+ // attempt decrypts the new ciphertext (or refuses a disabled account).
1389
+ if (patch.secret !== undefined || patch.status !== undefined) {
1390
+ invalidateCredentialCache(accountId);
1391
+ }
1392
+ const [account] = await this.accountsWithUsage(tenantId, [row]);
1393
+ return account;
1394
+ } catch (error) {
1395
+ if (isUniqueViolation(error)) {
1396
+ throw new ConsoleDomainError(
1397
+ "provider_account_duplicate",
1398
+ 409,
1399
+ "A provider account with this credential already exists",
1400
+ );
1401
+ }
1402
+ throw error;
1403
+ }
1404
+ }
1405
+
1406
+ private mapAccount(
1407
+ row: typeof providerAccounts.$inferSelect,
1408
+ usage: {
1409
+ readonly today: ProviderAccountTokenUsage;
1410
+ readonly allTime: ProviderAccountTokenUsage;
1411
+ readonly inflight?: number | undefined;
1412
+ },
1413
+ ): ProviderAccountResponse {
1414
+ const authState =
1415
+ row.authState !== null && typeof row.authState === "object" && !Array.isArray(row.authState)
1416
+ ? (row.authState as Record<string, unknown>)
1417
+ : undefined;
1418
+ const rawCredentialMode = authState?.["credentialMode"];
1419
+ const credentialMode: CredentialMode | undefined =
1420
+ rawCredentialMode === "auto" || rawCredentialMode === "jwt" || rawCredentialMode === "api_key"
1421
+ ? rawCredentialMode
1422
+ : undefined;
1423
+ const tokenExpiresAt =
1424
+ typeof authState?.["tokenExpiresAt"] === "string" ? authState["tokenExpiresAt"] : undefined;
1425
+ return {
1426
+ id: row.id,
1427
+ providerId: row.providerId,
1428
+ tenantId: row.tenantId,
1429
+ label: row.label,
1430
+ credentialKind: row.credentialKind,
1431
+ ...(credentialMode === undefined ? {} : { credentialMode }),
1432
+ ...(tokenExpiresAt === undefined ? {} : { tokenExpiresAt }),
1433
+ status: row.status,
1434
+ ...(usage.inflight === undefined ? {} : { inflight: usage.inflight }),
1435
+ usageToday: usage.today,
1436
+ usageAllTime: usage.allTime,
1437
+ consecutiveFailures: row.consecutiveFailures,
1438
+ ...(row.lastSuccessAt ? { lastSuccessAt: row.lastSuccessAt.toISOString() } : {}),
1439
+ ...(row.lastError ? { lastError: row.lastError } : {}),
1440
+ ...(row.lastErrorCategory ? { lastErrorCategory: row.lastErrorCategory } : {}),
1441
+ ...(row.lastErrorAt ? { lastErrorAt: row.lastErrorAt.toISOString() } : {}),
1442
+ ...(row.cooldownUntil ? { cooldownUntil: row.cooldownUntil.toISOString() } : {}),
1443
+ ...(liveModelCooldowns(row.modelCooldowns) ?? {}),
1444
+ ...(row.lastRecoveredAt ? { lastRecoveredAt: row.lastRecoveredAt.toISOString() } : {}),
1445
+ createdAt: row.createdAt.toISOString(),
1446
+ sortIndex: row.sortIndex,
1447
+ ...(row.staticToken ? { staticToken: true } : {}),
1448
+ };
1449
+ }
1450
+
1451
+ async listAccountHealthEvents(
1452
+ tenantId: string,
1453
+ providerId: string,
1454
+ accountId: string,
1455
+ ): Promise<readonly AccountHealthEventRecord[]> {
1456
+ const owned = await this.db
1457
+ .select({ id: providerAccounts.id })
1458
+ .from(providerAccounts)
1459
+ .where(
1460
+ and(
1461
+ eq(providerAccounts.id, accountId),
1462
+ eq(providerAccounts.providerId, providerId),
1463
+ globalOrOwnedBy(providerAccounts.tenantId, tenantId),
1464
+ ),
1465
+ )
1466
+ .limit(1);
1467
+ if (owned.length === 0) return [];
1468
+ return listAccountHealthEvents(this.db, accountId);
1469
+ }
1470
+
1471
+ /** Deletes one account's stored health & error rows, for the modal's purge. */
1472
+ async purgeAccountHealthEvents(
1473
+ tenantId: string,
1474
+ providerId: string,
1475
+ accountId: string,
1476
+ ): Promise<number> {
1477
+ const owned = await this.db
1478
+ .select({ id: providerAccounts.id })
1479
+ .from(providerAccounts)
1480
+ .where(
1481
+ and(
1482
+ eq(providerAccounts.id, accountId),
1483
+ eq(providerAccounts.providerId, providerId),
1484
+ globalOrOwnedBy(providerAccounts.tenantId, tenantId),
1485
+ ),
1486
+ )
1487
+ .limit(1);
1488
+ if (owned.length === 0) return 0;
1489
+ return deleteAccountHealthEvents(this.db, accountId);
1490
+ }
1491
+ async listBuddyGrowthTasks(
1492
+ tenantId: string,
1493
+ providerId: string,
1494
+ accountId: string,
1495
+ dayKey: string,
1496
+ ): Promise<readonly BuddyGrowthTaskLedgerEntry[]> {
1497
+ const owned = await this.db
1498
+ .select({ id: providerAccounts.id })
1499
+ .from(providerAccounts)
1500
+ .where(
1501
+ and(
1502
+ eq(providerAccounts.id, accountId),
1503
+ eq(providerAccounts.providerId, providerId),
1504
+ globalOrOwnedBy(providerAccounts.tenantId, tenantId),
1505
+ ),
1506
+ )
1507
+ .limit(1);
1508
+ if (owned.length === 0) return [];
1509
+ const rows = await this.db
1510
+ .select({
1511
+ accountId: buddyTaskLedger.accountId,
1512
+ task: buddyTaskLedger.task,
1513
+ claimedAt: buddyTaskLedger.claimedAt,
1514
+ })
1515
+ .from(buddyTaskLedger)
1516
+ .where(
1517
+ and(
1518
+ eq(buddyTaskLedger.accountId, accountId),
1519
+ eq(buddyTaskLedger.dayKey, dayKey),
1520
+ ),
1521
+ );
1522
+ return rows.map((row) => ({
1523
+ accountId: row.accountId,
1524
+ task: row.task,
1525
+ claimedAt: row.claimedAt.toISOString(),
1526
+ }));
1527
+ }
1528
+
1529
+ async recoverAccount(
1530
+ tenantId: string,
1531
+ providerId: string,
1532
+ accountId: string,
1533
+ ): Promise<boolean> {
1534
+ const owned = await this.db
1535
+ .select({ id: providerAccounts.id })
1536
+ .from(providerAccounts)
1537
+ .where(
1538
+ and(
1539
+ eq(providerAccounts.id, accountId),
1540
+ eq(providerAccounts.providerId, providerId),
1541
+ globalOrOwnedBy(providerAccounts.tenantId, tenantId),
1542
+ ),
1543
+ )
1544
+ .limit(1);
1545
+ if (owned.length === 0) return false;
1546
+ return recoverAccount(this.db, accountId, "manual_operator_recovery");
1547
+ }
1548
+
1549
+
1550
+ async syncModels(tenantId: string, providerId: string): Promise<{ synced: number }> {
1551
+ return this.probing.syncModels(tenantId, providerId);
1552
+ }
1553
+ }