cartethyia 2.5.3 → 2.5.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (456) hide show
  1. package/.env.example +13 -4
  2. package/README.md +2 -2
  3. package/dist/dashboard/assets/{BannedPage-hfCzi-Ux.js → BannedPage-CADDq4IU.js} +1 -1
  4. package/dist/dashboard/assets/BannedPage-CADDq4IU.js.gz +0 -0
  5. package/dist/dashboard/assets/CliToolDetailPage-_z1hID7E.js +1 -0
  6. package/dist/dashboard/assets/CliToolDetailPage-_z1hID7E.js.gz +0 -0
  7. package/dist/dashboard/assets/{CliToolsPage-DEDyKBlC.js → CliToolsPage-DuO1Z7dq.js} +1 -1
  8. package/dist/dashboard/assets/CliToolsPage-DuO1Z7dq.js.gz +0 -0
  9. package/dist/dashboard/assets/CombosPage-DNEPi3xU.js +1 -0
  10. package/dist/dashboard/assets/CombosPage-DNEPi3xU.js.gz +0 -0
  11. package/dist/dashboard/assets/{ConsoleLogPage-bsLuZmYH.js → ConsoleLogPage-BVdpfiz1.js} +1 -1
  12. package/dist/dashboard/assets/ConsoleLogPage-BVdpfiz1.js.gz +0 -0
  13. package/dist/dashboard/assets/{CustomizationPage-_UWhwziJ.js → CustomizationPage-BR5HHvbi.js} +1 -1
  14. package/dist/dashboard/assets/CustomizationPage-BR5HHvbi.js.gz +0 -0
  15. package/dist/dashboard/assets/HealthEventsModal-mUfcknqm.js +1 -0
  16. package/dist/dashboard/assets/HealthEventsModal-mUfcknqm.js.gz +0 -0
  17. package/dist/dashboard/assets/{LoginPage-DDVIfZGW.js → LoginPage-V5faV9V2.js} +1 -1
  18. package/dist/dashboard/assets/LoginPage-V5faV9V2.js.gz +0 -0
  19. package/dist/dashboard/assets/ModelPicker-C7pDb41R.js +1 -0
  20. package/dist/dashboard/assets/ModelPicker-C7pDb41R.js.gz +0 -0
  21. package/dist/dashboard/assets/OverviewPage-BDu20Jdx.js +1 -0
  22. package/dist/dashboard/assets/OverviewPage-BDu20Jdx.js.gz +0 -0
  23. package/dist/dashboard/assets/ProviderDetailPage-Dllr_aFO.js +3 -0
  24. package/dist/dashboard/assets/ProviderDetailPage-Dllr_aFO.js.gz +0 -0
  25. package/dist/dashboard/assets/ProvidersPage-uPiGVTTK.js +1 -0
  26. package/dist/dashboard/assets/ProvidersPage-uPiGVTTK.js.gz +0 -0
  27. package/dist/dashboard/assets/ProxyPage-BaUkO-4O.js +7 -0
  28. package/dist/dashboard/assets/ProxyPage-BaUkO-4O.js.gz +0 -0
  29. package/dist/dashboard/assets/QuotaPage-o0IFAsFb.js +1 -0
  30. package/dist/dashboard/assets/QuotaPage-o0IFAsFb.js.gz +0 -0
  31. package/dist/dashboard/assets/SettingsPage-CYWqbXjp.js +1 -0
  32. package/dist/dashboard/assets/SettingsPage-CYWqbXjp.js.gz +0 -0
  33. package/dist/dashboard/assets/{SetupPage-D6pmQ86L.js → SetupPage-UB690SE2.js} +1 -1
  34. package/dist/dashboard/assets/SetupPage-UB690SE2.js.gz +0 -0
  35. package/dist/dashboard/assets/{SortableList-DWvKiuSl.js → SortableList-BLujdX7N.js} +1 -1
  36. package/dist/dashboard/assets/SortableList-BLujdX7N.js.gz +0 -0
  37. package/dist/dashboard/assets/{StudioPage-CmkUrgdj.js → StudioPage-D5C2raAc.js} +3 -3
  38. package/dist/dashboard/assets/StudioPage-D5C2raAc.js.gz +0 -0
  39. package/dist/dashboard/assets/UsagePage-8uIcxa1W.js +56 -0
  40. package/dist/dashboard/assets/UsagePage-8uIcxa1W.js.gz +0 -0
  41. package/dist/dashboard/assets/api-keys-DQYLQxpW.js +1 -0
  42. package/dist/dashboard/assets/api-keys-DQYLQxpW.js.gz +0 -0
  43. package/dist/dashboard/assets/app-CX_Z8cMs.css +2 -0
  44. package/dist/dashboard/assets/app-CX_Z8cMs.css.gz +0 -0
  45. package/dist/dashboard/assets/{assert-NHwYq95t.js → assert-C9FD_JwJ.js} +1 -1
  46. package/dist/dashboard/assets/assert-C9FD_JwJ.js.gz +0 -0
  47. package/dist/dashboard/assets/{badge-6uzDDvqs.js → badge-CR72dasT.js} +1 -1
  48. package/dist/dashboard/assets/badge-CR72dasT.js.gz +0 -0
  49. package/dist/dashboard/assets/{banner-4_WNg2WZ.js → banner-CI9_eSfO.js} +1 -1
  50. package/dist/dashboard/assets/banner-CI9_eSfO.js.gz +0 -0
  51. package/dist/dashboard/assets/{card--AtFedAq.js → card-DS7CQEsO.js} +1 -1
  52. package/dist/dashboard/assets/card-DS7CQEsO.js.gz +0 -0
  53. package/dist/dashboard/assets/{cli-tools-B3e7dx3N.js → cli-tools-CNqzrfdb.js} +1 -1
  54. package/dist/dashboard/assets/cli-tools-CNqzrfdb.js.gz +0 -0
  55. package/dist/dashboard/assets/{clipboard-button-NreUx6c8.js → clipboard-button-611pSsaX.js} +1 -1
  56. package/dist/dashboard/assets/clipboard-button-611pSsaX.js.gz +0 -0
  57. package/dist/dashboard/assets/{fingerprint-pattern-BWuHtbW4.js → concurrency-BDF0ok7y.js} +1 -1
  58. package/dist/dashboard/assets/concurrency-BDF0ok7y.js.gz +0 -0
  59. package/dist/dashboard/assets/database-Dz9N3zGd.js +1 -0
  60. package/dist/dashboard/assets/database-Dz9N3zGd.js.gz +0 -0
  61. package/dist/dashboard/assets/dialog-k37exjNt.js +1 -0
  62. package/dist/dashboard/assets/dialog-k37exjNt.js.gz +0 -0
  63. package/dist/dashboard/assets/drawer-CX7N8ucR.js +1 -0
  64. package/dist/dashboard/assets/drawer-CX7N8ucR.js.gz +0 -0
  65. package/dist/dashboard/assets/entry-B9Xvtnfj.js +1 -0
  66. package/dist/dashboard/assets/entry-B9Xvtnfj.js.gz +0 -0
  67. package/dist/dashboard/assets/entry-CTP8AyiS.js +1 -0
  68. package/dist/dashboard/assets/entry-CTP8AyiS.js.gz +0 -0
  69. package/dist/dashboard/assets/entry-DxBHnauy.js +4 -0
  70. package/dist/dashboard/assets/entry-DxBHnauy.js.gz +0 -0
  71. package/dist/dashboard/assets/{github-badge-D_eM3IgG.js → github-badge-sltk1qUi.js} +1 -1
  72. package/dist/dashboard/assets/github-badge-sltk1qUi.js.gz +0 -0
  73. package/dist/dashboard/assets/{index-BydyKaDz.js → index-CmxZr14F.js} +2 -2
  74. package/dist/dashboard/assets/index-CmxZr14F.js.gz +0 -0
  75. package/dist/dashboard/assets/{inline-BcNGF8Gr.js → inline-BhY2sSnO.js} +1 -1
  76. package/dist/dashboard/assets/inline-BhY2sSnO.js.gz +0 -0
  77. package/dist/dashboard/assets/{input-BG41et9G.js → input-V1umFiBk.js} +1 -1
  78. package/dist/dashboard/assets/input-V1umFiBk.js.gz +0 -0
  79. package/dist/dashboard/assets/layout-BBOYycyS.js +1 -0
  80. package/dist/dashboard/assets/layout-BBOYycyS.js.gz +0 -0
  81. package/dist/dashboard/assets/{list-row-Gh_W4OR_.js → list-row-1zzZYM0B.js} +1 -1
  82. package/dist/dashboard/assets/list-row-1zzZYM0B.js.gz +0 -0
  83. package/dist/dashboard/assets/{live-BJGYRBnR.js → live-BivGfuUs.js} +1 -1
  84. package/dist/dashboard/assets/live-BivGfuUs.js.gz +0 -0
  85. package/dist/dashboard/assets/model-bans-CmffjLuG.js +1 -0
  86. package/dist/dashboard/assets/model-bans-CmffjLuG.js.gz +0 -0
  87. package/dist/dashboard/assets/{mutation-BkoLM5Ba.js → mutation-46P0oWMe.js} +1 -1
  88. package/dist/dashboard/assets/mutation-46P0oWMe.js.gz +0 -0
  89. package/dist/dashboard/assets/numbers-DkfMR5Qs.js +2 -0
  90. package/dist/dashboard/assets/numbers-DkfMR5Qs.js.gz +0 -0
  91. package/dist/dashboard/assets/{query-keys-D7wX2vM5.js → query-keys-BFhGpwR1.js} +1 -1
  92. package/dist/dashboard/assets/query-keys-BFhGpwR1.js.gz +0 -0
  93. package/dist/dashboard/assets/{routing-CcusqXA7.js → routing-CAb4F37R.js} +1 -1
  94. package/dist/dashboard/assets/routing-CAb4F37R.js.gz +0 -0
  95. package/dist/dashboard/assets/{select-BO6h8yNv.js → select-BYR_5K2c.js} +2 -2
  96. package/dist/dashboard/assets/select-BYR_5K2c.js.gz +0 -0
  97. package/dist/dashboard/assets/{settings-mpmXTgUa.js → settings-DNX9sydi.js} +1 -1
  98. package/dist/dashboard/assets/settings-DNX9sydi.js.gz +0 -0
  99. package/dist/dashboard/assets/{stack-DwLmjUFK.js → stack-BHTfXdz_.js} +1 -1
  100. package/dist/dashboard/assets/stack-BHTfXdz_.js.gz +0 -0
  101. package/dist/dashboard/assets/{state-DYdQmdAl.js → state-mPDRlQkl.js} +1 -1
  102. package/dist/dashboard/assets/state-mPDRlQkl.js.gz +0 -0
  103. package/dist/dashboard/assets/switch-DOmJxg7H.js +1 -0
  104. package/dist/dashboard/assets/switch-DOmJxg7H.js.gz +0 -0
  105. package/dist/dashboard/assets/terminal-0_IjPWTk.js +1 -0
  106. package/dist/dashboard/assets/terminal-0_IjPWTk.js.gz +0 -0
  107. package/dist/dashboard/assets/tokens-COzario9.js +1 -0
  108. package/dist/dashboard/assets/tokens-COzario9.js.gz +0 -0
  109. package/dist/dashboard/assets/use-modal-focus-By7oJoGd.js +1 -0
  110. package/dist/dashboard/assets/use-modal-focus-By7oJoGd.js.gz +0 -0
  111. package/dist/dashboard/assets/{useMutation-w4u4CVue.js → useMutation-Dem0lXQj.js} +1 -1
  112. package/dist/dashboard/assets/useMutation-Dem0lXQj.js.gz +0 -0
  113. package/dist/dashboard/assets/zap-C4EeVW4q.js +1 -0
  114. package/dist/dashboard/assets/zap-C4EeVW4q.js.gz +0 -0
  115. package/dist/dashboard/donation/qris-risun-marke.png +0 -0
  116. package/dist/dashboard/index.html +1 -1
  117. package/dist/dashboard/index.html.gz +0 -0
  118. package/dist/dashboard/precompressed-assets.json +57 -57
  119. package/migrations/README.md +52 -52
  120. package/migrations/lite-sqlite3/0000_baseline.sql +2 -2
  121. package/package.json +96 -95
  122. package/scripts/cli/admin.ts +125 -0
  123. package/scripts/cli/autostart.ts +237 -0
  124. package/scripts/cli/{ui/background.ts → background.ts} +1 -1
  125. package/scripts/cli/{command/console-menu.ts → console-menu.ts} +275 -248
  126. package/scripts/cli/doctor.ts +37 -0
  127. package/scripts/cli/{support/env.ts → env.ts} +99 -99
  128. package/scripts/cli/{gateway/spawn.ts → gateway.ts} +112 -24
  129. package/scripts/cli/instance.ts +224 -0
  130. package/scripts/cli/main.ts +88 -18
  131. package/scripts/cli/{support/platform.ts → platform.ts} +64 -64
  132. package/scripts/cli/port.ts +100 -0
  133. package/scripts/cli/{support/services.ts → services.ts} +88 -88
  134. package/scripts/cli/{command/setup.ts → setup.ts} +35 -34
  135. package/scripts/cli/{command/start.ts → start.ts} +10 -5
  136. package/scripts/cli/{support/startup-signal.ts → startup-signal.ts} +23 -23
  137. package/scripts/cli/tray/index.ts +62 -12
  138. package/scripts/cli/{command/tray.ts → tray.ts} +208 -208
  139. package/scripts/cli/update.ts +40 -0
  140. package/scripts/cli/version.ts +67 -0
  141. package/scripts/cli/{ui/wordmark.ts → wordmark.ts} +58 -58
  142. package/src/boot/app.ts +11 -7
  143. package/src/boot/dependencies.ts +50 -19
  144. package/src/boot/timeout.ts +2 -13
  145. package/src/config/cooldowns.ts +186 -138
  146. package/src/config/network.ts +8 -14
  147. package/src/config/providers.ts +7 -10
  148. package/src/config/runtime.ts +76 -76
  149. package/src/config/security.ts +15 -0
  150. package/src/config/spec.ts +52 -7
  151. package/src/config/telemetry.ts +26 -0
  152. package/src/config/timeouts.ts +0 -6
  153. package/src/console/admin/model-abuse/contracts.ts +28 -6
  154. package/src/console/admin/model-abuse/routes.ts +56 -3
  155. package/src/console/api-keys/bucket-spend.ts +15 -4
  156. package/src/console/api-keys/contracts.ts +410 -414
  157. package/src/console/api-keys/routes.ts +881 -880
  158. package/src/console/auth/credentials.ts +3 -3
  159. package/src/console/auth/setup-service.ts +2 -2
  160. package/src/console/backup/contracts.ts +15 -2
  161. package/src/console/backup/export-endpoint.ts +6 -2
  162. package/src/console/backup/nine-router.ts +2 -5
  163. package/src/console/backup/service.ts +4 -0
  164. package/src/console/backup/store.ts +192 -29
  165. package/src/console/backup/validate.ts +3 -45
  166. package/src/console/cli-tools/fs-ops.ts +487 -487
  167. package/src/console/console-router-guards.ts +1 -1
  168. package/src/console/console-router.ts +8 -0
  169. package/src/console/data-management/contracts.ts +100 -0
  170. package/src/console/data-management/operations.ts +234 -0
  171. package/src/console/data-management/routes.ts +103 -0
  172. package/src/console/domain-registration.ts +45 -1
  173. package/src/console/observability/contracts.ts +524 -485
  174. package/src/console/observability/live.ts +22 -0
  175. package/src/console/observability/store.ts +1138 -1018
  176. package/src/console/observability/usage-dimensions.ts +4 -4
  177. package/src/console/providers/catalog/catalog-projections.ts +6 -7
  178. package/src/console/providers/catalog/contracts.ts +6 -0
  179. package/src/console/providers/catalog/model-operations.ts +5 -2
  180. package/src/console/providers/catalog/provider-operations.ts +24 -4
  181. package/src/console/providers/catalog/public-model-store.ts +8 -11
  182. package/src/console/providers/catalog/routes.ts +7 -0
  183. package/src/console/providers/catalog/store.ts +1553 -1532
  184. package/src/console/providers/oauth/routes.ts +8 -12
  185. package/src/console/quota/account-quota-shared.ts +52 -39
  186. package/src/console/quota/account-quota-tenant.ts +6 -6
  187. package/src/console/quota/quota-view.ts +1 -1
  188. package/src/console/quota/refresh.ts +441 -429
  189. package/src/console/remote-support/contracts.ts +61 -0
  190. package/src/console/remote-support/routes.ts +65 -0
  191. package/src/console/remote-support/service.ts +207 -0
  192. package/src/console/response-compression.ts +0 -3
  193. package/src/console/routing/pools/import-parse.ts +206 -0
  194. package/src/console/routing/pools/operations.ts +543 -501
  195. package/src/console/routing/pools/probe-result.ts +3 -2
  196. package/src/console/routing/pools/probe-verdict.ts +132 -132
  197. package/src/console/routing/pools/routes.ts +181 -164
  198. package/src/console/routing/pools/store.ts +8 -8
  199. package/src/console/settings/contracts.ts +95 -2
  200. package/src/console/settings/store.ts +81 -3
  201. package/src/console/shared/cursor.ts +8 -8
  202. package/src/console/shared/errors.ts +1 -3
  203. package/src/console/updates/routes.ts +16 -0
  204. package/src/main.ts +10 -0
  205. package/src/network/http2-bidi.ts +2 -4
  206. package/src/network/pool/agent.ts +478 -478
  207. package/src/network/pool/selector.ts +5 -4
  208. package/src/network/retry.ts +1 -2
  209. package/src/network/ssrf.ts +5 -4
  210. package/src/observability/event-compaction.ts +317 -0
  211. package/src/observability/event-day-index.ts +440 -0
  212. package/src/observability/event-log.ts +155 -6
  213. package/src/observability/logger.ts +6 -6
  214. package/src/observability/payload-capture.ts +158 -33
  215. package/src/observability/payload-store.ts +82 -61
  216. package/src/observability/redaction.ts +5 -6
  217. package/src/observability/request-notify.ts +43 -0
  218. package/src/observability/telemetry-status.ts +8 -9
  219. package/src/observability/token-speed.ts +5 -4
  220. package/src/persistence/db-mode.ts +7 -7
  221. package/src/persistence/health-event-flush.ts +36 -0
  222. package/src/persistence/readiness.ts +1 -1
  223. package/src/persistence/schema.ts +75 -29
  224. package/src/persistence/shared-cache.ts +1 -2
  225. package/src/persistence/stores/api-key-store.ts +18 -6
  226. package/src/persistence/stores/share-store.ts +4 -3
  227. package/src/persistence/stores/telemetry-store.ts +33 -11
  228. package/src/protocol/canonical-model.ts +11 -3
  229. package/src/protocol/codecs.ts +139 -6
  230. package/src/protocol/gateway-error.ts +6 -4
  231. package/src/protocol/image-clamp.ts +19 -1
  232. package/src/protocol/registry.ts +8 -0
  233. package/src/protocol/request/chat.ts +21 -1
  234. package/src/protocol/request/codex.ts +10 -1
  235. package/src/protocol/request/gemini.ts +9 -2
  236. package/src/protocol/request/messages.ts +18 -7
  237. package/src/protocol/request/responses.ts +9 -1
  238. package/src/protocol/response/chat.ts +34 -4
  239. package/src/protocol/response/codex.ts +7 -4
  240. package/src/protocol/response/dsml.ts +140 -28
  241. package/src/protocol/response/responses.ts +11 -5
  242. package/src/protocol/stream-error-frames.ts +4 -5
  243. package/src/providers/adapters/compatible-adapter.ts +3 -0
  244. package/src/providers/authentication/jwt-validator.ts +9 -10
  245. package/src/providers/authentication/oauth-refresh-service.ts +9 -11
  246. package/src/providers/bundled-discovery-providers.ts +122 -119
  247. package/src/providers/configured-openai-providers.ts +4 -4
  248. package/src/providers/default-registry.ts +5 -4
  249. package/src/providers/discovery/catalog/catalog-blob.ts +18 -18
  250. package/src/providers/discovery/catalog/catalog-format.ts +3 -6
  251. package/src/providers/discovery/models-dev-catalog.ts +354 -354
  252. package/src/providers/discovery/openai-model-discovery.ts +5 -0
  253. package/src/providers/discovery/openrouter-free-roster.ts +15 -17
  254. package/src/providers/discovery/probe-phases.ts +4 -4
  255. package/src/providers/discovery/probe-wire.ts +6 -13
  256. package/src/providers/discovery/probing-service.ts +1117 -1045
  257. package/src/providers/integrations/alibabacp/alibabacp.ts +1 -1
  258. package/src/providers/integrations/antigravity/antigravity-quota.ts +5 -7
  259. package/src/providers/integrations/buddy/buddy-chat-shared.ts +23 -2
  260. package/src/providers/integrations/buddy/buddy-checkin.ts +1 -4
  261. package/src/providers/integrations/buddy/buddy-cli-headers.ts +23 -6
  262. package/src/providers/integrations/buddy/buddy-conversation-headers.ts +0 -1
  263. package/src/providers/integrations/buddy/buddy-identity.ts +52 -0
  264. package/src/providers/integrations/buddy/buddy-media.ts +2 -2
  265. package/src/providers/integrations/buddy/buddy-usage-store.ts +3 -4
  266. package/src/providers/integrations/buddy/codebuddy/quota.ts +2 -13
  267. package/src/providers/integrations/buddy/codebuddy/shared.ts +118 -115
  268. package/src/providers/integrations/buddy/workbuddy/quota.ts +0 -11
  269. package/src/providers/integrations/buddy/workbuddy/shared.ts +5 -2
  270. package/src/providers/integrations/claude/claude.ts +608 -608
  271. package/src/providers/integrations/cline/cline-quota.ts +27 -20
  272. package/src/providers/integrations/cline/cline.ts +537 -539
  273. package/src/providers/integrations/codex/codex-headers.ts +25 -39
  274. package/src/providers/integrations/codex/codex-oauth.ts +1 -1
  275. package/src/providers/integrations/codex/codex.ts +2 -29
  276. package/src/providers/integrations/commandcode/commandcode.ts +674 -675
  277. package/src/providers/integrations/commandcode/discovery.ts +117 -116
  278. package/src/providers/integrations/cursor/cursor-auth.ts +418 -418
  279. package/src/providers/integrations/cursor/cursor-quota.ts +309 -0
  280. package/src/providers/integrations/cursor/cursor-request.ts +616 -471
  281. package/src/providers/integrations/cursor/cursor-session-manager.ts +319 -281
  282. package/src/providers/integrations/cursor/cursor.ts +1333 -960
  283. package/src/providers/integrations/cursor/protocol/messages.ts +25 -0
  284. package/src/providers/integrations/factory/factory-models.ts +3 -5
  285. package/src/providers/integrations/factory/factory.ts +8 -1
  286. package/src/providers/integrations/gemini.ts +6 -1
  287. package/src/providers/integrations/grok/grok-install-id.ts +2 -3
  288. package/src/providers/integrations/grok/grok-quota.ts +279 -177
  289. package/src/providers/integrations/grok/grok-ratelimit-store.ts +73 -0
  290. package/src/providers/integrations/grok/grok-ratelimit.ts +218 -0
  291. package/src/providers/integrations/grok/grok.ts +6 -7
  292. package/src/providers/integrations/hermes.ts +14 -18
  293. package/src/providers/integrations/kiro/kiro-discovery.ts +1 -1
  294. package/src/providers/integrations/kiro/kiro.ts +1 -1
  295. package/src/providers/integrations/mistral.ts +6 -6
  296. package/src/providers/integrations/opencode/fingerprint.ts +3 -3
  297. package/src/providers/integrations/opencode/provider.ts +19 -20
  298. package/src/providers/integrations/qoder/qoder-catalog.ts +450 -450
  299. package/src/providers/integrations/qoder/qoder-oauth.ts +388 -310
  300. package/src/providers/integrations/qoder/qoder-quota.ts +192 -0
  301. package/src/providers/integrations/qoder/qoder.ts +884 -865
  302. package/src/providers/integrations/shared/connect.ts +6 -6
  303. package/src/providers/integrations/trae/trae-catalog.ts +1 -2
  304. package/src/providers/integrations/trae/trae-quota.ts +384 -0
  305. package/src/providers/integrations/trae/trae.ts +2 -2
  306. package/src/providers/integrations/zcode/oauth.ts +6 -1
  307. package/src/providers/operations/account-health-service.ts +75 -27
  308. package/src/providers/operations/account-reset-service.ts +1 -1
  309. package/src/providers/operations/client-versions.ts +6 -6
  310. package/src/providers/operations/custom-cli-headers.ts +3 -4
  311. package/src/providers/operations/provider-catalog-seeder.ts +95 -95
  312. package/src/providers/operations/provider-catalog-service.ts +3 -4
  313. package/src/providers/provider-contracts.ts +7 -0
  314. package/src/providers/provider-metadata.ts +10 -8
  315. package/src/providers/provider-registry.ts +6 -7
  316. package/src/providers/provider-usage.ts +1 -1
  317. package/src/providers/quota/quota-support.ts +8 -0
  318. package/src/security/abuse.ts +15 -10
  319. package/src/security/access-control.ts +144 -146
  320. package/src/security/admission/contracts.ts +11 -0
  321. package/src/security/admission/in-memory-store.ts +4 -3
  322. package/src/security/admission/lease-sweep.ts +6 -6
  323. package/src/security/admission/reasons.ts +5 -1
  324. package/src/security/admission/redis-store.ts +211 -210
  325. package/src/security/admission/service.ts +4 -0
  326. package/src/security/api-key-auth.ts +27 -17
  327. package/src/security/ip-boundary.ts +155 -15
  328. package/src/security/model-abuse.ts +366 -94
  329. package/src/security/outbound-headers.ts +3 -4
  330. package/src/shared/sleep.ts +4 -0
  331. package/src/transport/dispatch/attempt-finalize.ts +47 -5
  332. package/src/transport/dispatch/attempt-loop.ts +531 -530
  333. package/src/transport/dispatch/generation.ts +6 -0
  334. package/src/transport/dispatch/proxy-request.ts +24 -1
  335. package/src/transport/dispatch/ratelimit-capture.ts +37 -0
  336. package/src/transport/dispatch/stream-bridge.ts +1 -3
  337. package/src/transport/dispatch/streaming/finalize.ts +1 -1
  338. package/src/transport/dispatch/streaming/stream.ts +19 -6
  339. package/src/transport/dispatch/upstream.ts +9 -0
  340. package/src/transport/failure-policy.ts +162 -8
  341. package/src/transport/guards/auth-guard.ts +7 -4
  342. package/src/transport/guards/concurrency-guard.ts +0 -5
  343. package/src/transport/guards/error-lifecycle.ts +13 -6
  344. package/src/transport/guards/pipeline.ts +15 -3
  345. package/src/transport/guards/request-context.ts +8 -2
  346. package/src/transport/request/inflight.ts +5 -6
  347. package/src/transport/request/preparer.ts +9 -8
  348. package/src/transport/request/state.ts +21 -8
  349. package/src/transport/request/tenant-preferences.ts +60 -0
  350. package/src/transport/routing/admission.ts +4 -0
  351. package/src/transport/routing/route-catalog.ts +65 -10
  352. package/src/transport/routing/route-model.ts +46 -14
  353. package/src/transport/routing/router.ts +21 -14
  354. package/src/transport/surface/completion.ts +2 -3
  355. package/src/transport/surface/dialects.ts +5 -6
  356. package/src/transport/surface/responses/encode.ts +38 -0
  357. package/src/transport/surface/responses/parse.ts +4 -0
  358. package/src/transport/translation/capabilities.ts +2 -3
  359. package/src/transport/translation/quirks.ts +3 -3
  360. package/src/workers/buddy-growth-tasks.ts +5 -10
  361. package/src/workers/daily-checkin.ts +17 -23
  362. package/src/workers/quota-refresh-sweep.ts +26 -26
  363. package/dist/dashboard/assets/BannedPage-hfCzi-Ux.js.gz +0 -0
  364. package/dist/dashboard/assets/CliToolDetailPage-DLGqYEwI.js +0 -1
  365. package/dist/dashboard/assets/CliToolDetailPage-DLGqYEwI.js.gz +0 -0
  366. package/dist/dashboard/assets/CliToolsPage-DEDyKBlC.js.gz +0 -0
  367. package/dist/dashboard/assets/CombosPage-CfuYiQ8Z.js +0 -1
  368. package/dist/dashboard/assets/CombosPage-CfuYiQ8Z.js.gz +0 -0
  369. package/dist/dashboard/assets/ConsoleLogPage-bsLuZmYH.js.gz +0 -0
  370. package/dist/dashboard/assets/CustomizationPage-_UWhwziJ.js.gz +0 -0
  371. package/dist/dashboard/assets/HealthEventsModal-Cjfc5WFS.js +0 -1
  372. package/dist/dashboard/assets/HealthEventsModal-Cjfc5WFS.js.gz +0 -0
  373. package/dist/dashboard/assets/LoginPage-DDVIfZGW.js.gz +0 -0
  374. package/dist/dashboard/assets/ModelPicker-CWkTUtpX.js +0 -1
  375. package/dist/dashboard/assets/ModelPicker-CWkTUtpX.js.gz +0 -0
  376. package/dist/dashboard/assets/OverviewPage-B53YYMng.js +0 -1
  377. package/dist/dashboard/assets/OverviewPage-B53YYMng.js.gz +0 -0
  378. package/dist/dashboard/assets/ProviderDetailPage-B0KaAQf0.js +0 -3
  379. package/dist/dashboard/assets/ProviderDetailPage-B0KaAQf0.js.gz +0 -0
  380. package/dist/dashboard/assets/ProvidersPage-Dx31DgIO.js +0 -1
  381. package/dist/dashboard/assets/ProvidersPage-Dx31DgIO.js.gz +0 -0
  382. package/dist/dashboard/assets/ProxyPage-BpnR4kXT.js +0 -7
  383. package/dist/dashboard/assets/ProxyPage-BpnR4kXT.js.gz +0 -0
  384. package/dist/dashboard/assets/QuotaPage-BpOQCnun.js +0 -1
  385. package/dist/dashboard/assets/QuotaPage-BpOQCnun.js.gz +0 -0
  386. package/dist/dashboard/assets/SettingsPage-DV1eo2J_.js +0 -1
  387. package/dist/dashboard/assets/SettingsPage-DV1eo2J_.js.gz +0 -0
  388. package/dist/dashboard/assets/SetupPage-D6pmQ86L.js.gz +0 -0
  389. package/dist/dashboard/assets/SortableList-DWvKiuSl.js.gz +0 -0
  390. package/dist/dashboard/assets/StudioPage-CmkUrgdj.js.gz +0 -0
  391. package/dist/dashboard/assets/UsagePage-B47uOydr.js +0 -56
  392. package/dist/dashboard/assets/UsagePage-B47uOydr.js.gz +0 -0
  393. package/dist/dashboard/assets/api-keys-uNywYlfv.js +0 -1
  394. package/dist/dashboard/assets/api-keys-uNywYlfv.js.gz +0 -0
  395. package/dist/dashboard/assets/app-DxivpfYG.css +0 -2
  396. package/dist/dashboard/assets/app-DxivpfYG.css.gz +0 -0
  397. package/dist/dashboard/assets/assert-NHwYq95t.js.gz +0 -0
  398. package/dist/dashboard/assets/badge-6uzDDvqs.js.gz +0 -0
  399. package/dist/dashboard/assets/banner-4_WNg2WZ.js.gz +0 -0
  400. package/dist/dashboard/assets/card--AtFedAq.js.gz +0 -0
  401. package/dist/dashboard/assets/cli-tools-B3e7dx3N.js.gz +0 -0
  402. package/dist/dashboard/assets/clipboard-button-NreUx6c8.js.gz +0 -0
  403. package/dist/dashboard/assets/dialog-BtpsJU98.js +0 -1
  404. package/dist/dashboard/assets/dialog-BtpsJU98.js.gz +0 -0
  405. package/dist/dashboard/assets/download-A0SW9OCW.js +0 -1
  406. package/dist/dashboard/assets/download-A0SW9OCW.js.gz +0 -0
  407. package/dist/dashboard/assets/drawer-WwwtzihF.js +0 -1
  408. package/dist/dashboard/assets/drawer-WwwtzihF.js.gz +0 -0
  409. package/dist/dashboard/assets/entry-CkryrIea.js +0 -4
  410. package/dist/dashboard/assets/entry-CkryrIea.js.gz +0 -0
  411. package/dist/dashboard/assets/entry-TJIye_jT.js +0 -1
  412. package/dist/dashboard/assets/entry-TJIye_jT.js.gz +0 -0
  413. package/dist/dashboard/assets/entry-jy9_4-Ke.js +0 -1
  414. package/dist/dashboard/assets/entry-jy9_4-Ke.js.gz +0 -0
  415. package/dist/dashboard/assets/fingerprint-pattern-BWuHtbW4.js.gz +0 -0
  416. package/dist/dashboard/assets/github-badge-D_eM3IgG.js.gz +0 -0
  417. package/dist/dashboard/assets/index-BydyKaDz.js.gz +0 -0
  418. package/dist/dashboard/assets/inline-BcNGF8Gr.js.gz +0 -0
  419. package/dist/dashboard/assets/input-BG41et9G.js.gz +0 -0
  420. package/dist/dashboard/assets/layout-C8mer_Ak.js +0 -1
  421. package/dist/dashboard/assets/layout-C8mer_Ak.js.gz +0 -0
  422. package/dist/dashboard/assets/list-row-Gh_W4OR_.js.gz +0 -0
  423. package/dist/dashboard/assets/live-BJGYRBnR.js.gz +0 -0
  424. package/dist/dashboard/assets/model-bans-DeVLDA7I.js +0 -1
  425. package/dist/dashboard/assets/model-bans-DeVLDA7I.js.gz +0 -0
  426. package/dist/dashboard/assets/mutation-BkoLM5Ba.js.gz +0 -0
  427. package/dist/dashboard/assets/numbers-Bs4LOOuf.js +0 -2
  428. package/dist/dashboard/assets/numbers-Bs4LOOuf.js.gz +0 -0
  429. package/dist/dashboard/assets/query-keys-D7wX2vM5.js.gz +0 -0
  430. package/dist/dashboard/assets/routing-CcusqXA7.js.gz +0 -0
  431. package/dist/dashboard/assets/select-BO6h8yNv.js.gz +0 -0
  432. package/dist/dashboard/assets/settings-mpmXTgUa.js.gz +0 -0
  433. package/dist/dashboard/assets/sparkles-BRaRUxAU.js +0 -1
  434. package/dist/dashboard/assets/sparkles-BRaRUxAU.js.gz +0 -0
  435. package/dist/dashboard/assets/stack-DwLmjUFK.js.gz +0 -0
  436. package/dist/dashboard/assets/state-DYdQmdAl.js.gz +0 -0
  437. package/dist/dashboard/assets/switch-CidSfbG7.js +0 -1
  438. package/dist/dashboard/assets/switch-CidSfbG7.js.gz +0 -0
  439. package/dist/dashboard/assets/terminal-Bd4ee8TA.js +0 -1
  440. package/dist/dashboard/assets/terminal-Bd4ee8TA.js.gz +0 -0
  441. package/dist/dashboard/assets/tokens-DDxMbSRL.js +0 -1
  442. package/dist/dashboard/assets/tokens-DDxMbSRL.js.gz +0 -0
  443. package/dist/dashboard/assets/use-presence-BSZEBx7U.js +0 -1
  444. package/dist/dashboard/assets/use-presence-BSZEBx7U.js.gz +0 -0
  445. package/dist/dashboard/assets/useMutation-w4u4CVue.js.gz +0 -0
  446. package/scripts/cli/command/admin.ts +0 -65
  447. package/scripts/cli/command/doctor.ts +0 -257
  448. package/scripts/cli/command/update.ts +0 -23
  449. package/scripts/cli/gateway/drain-token.ts +0 -78
  450. package/scripts/cli/gateway/instance-lock.ts +0 -105
  451. package/scripts/cli/gateway/stop.ts +0 -51
  452. package/scripts/cli/support/bun.ts +0 -27
  453. package/scripts/cli/support/version.ts +0 -37
  454. /package/dist/dashboard/assets/{app-CafWtVXA.js → app-BIt446yE.js} +0 -0
  455. /package/dist/dashboard/assets/{app-CafWtVXA.js.gz → app-BIt446yE.js.gz} +0 -0
  456. /package/scripts/cli/{ui/prompt.ts → prompt.ts} +0 -0
@@ -100,23 +100,36 @@ export interface ProxyRequestState {
100
100
  * Remote-diagnosis field: the drawer reads empty for reasons invisible from
101
101
  * the client — the tenant's capture mode, a missing tenant, a non-terminal
102
102
  * attempt — and without the reason an operator cannot tell "not configured"
103
- * from "broken". The four panels are the only surface that could previously
104
- * hint at it, and they look identical in both cases.
103
+ * from "broken". The four panels are the only surface that could hint at it,
104
+ * and they look identical in both cases.
105
105
  */
106
106
  payloadCaptureState?: PayloadCaptureState;
107
107
  /**
108
108
  * This request's resolved payload-capture preference, memoized on the request.
109
109
  *
110
110
  * The preference is a tenant setting, so it cannot change between attempts of
111
- * one request. It was resolved twice — once per attempt to decide whether to
112
- * tee the provider body, and again on the terminal attempt to decide what to
113
- * store — so a request with several attempts paid for it several times.
114
- * Memoized here, the second read is free and the two cannot disagree about
115
- * whether capture is on.
111
+ * one request. It is read twice — once per attempt to decide whether to tee
112
+ * the provider body, and again on the terminal attempt to decide what to
113
+ * store — so memoizing it here makes the second read free and keeps the two
114
+ * from disagreeing about whether capture is on.
116
115
  */
117
116
  payloadCapturePreference?:
118
- | { readonly ok: true; readonly mode: TelemetryPayloadMode; readonly depth: TelemetryPayloadDepth }
117
+ | {
118
+ readonly ok: true;
119
+ readonly mode: TelemetryPayloadMode;
120
+ readonly depth: TelemetryPayloadDepth;
121
+ readonly retentionMs: number;
122
+ }
119
123
  | { readonly ok: false };
124
+ /**
125
+ * This request's resolved inline-attachment ceiling, memoized for the same
126
+ * reason the capture preference is: it is a tenant setting, so it cannot
127
+ * change between attempts, and reading it per attempt would pay for it
128
+ * several times on a request that fails over.
129
+ *
130
+ * `undefined` means the deployment default.
131
+ */
132
+ imageClampMaxBytes?: number;
120
133
  /**
121
134
  * Set by the dispatch handler synchronously before returning a streaming
122
135
  * response. `afterResponse` lifecycle hooks read it to skip finalization
@@ -13,6 +13,66 @@ import type { PreparedProxyRequest } from "./preparer";
13
13
  import { normalizeThinkingConfig } from "../translation/thinking";
14
14
  import { compressRequest } from "./rtk/compress-request";
15
15
  import { preferencesReaderFor } from "../../persistence/tenant-preferences";
16
+ import { resolveUpstreamTimeoutMs } from "../../config/timeouts";
17
+
18
+ /**
19
+ * Re-arms the request deadline from the tenant's timeout override.
20
+ *
21
+ * The initial deadline is armed before authentication, when the tenant is not
22
+ * yet known, so a per-tenant timeout can only be applied here — once the
23
+ * snapshot says who is asking. `extendDeadline` keeps the timer, the state
24
+ * field, and the in-flight record in step, so the backstop sweep cannot
25
+ * force-release a request whose budget legitimately grew.
26
+ *
27
+ * Non-fatal: a settings read outage leaves the deployment default in place
28
+ * rather than failing a dispatchable request.
29
+ */
30
+ export async function applyTenantDeadlineOverride(
31
+ tenantId: string,
32
+ db: CartethyiaDatabase,
33
+ extend: (ms: number) => void,
34
+ ): Promise<void> {
35
+ try {
36
+ const prefs = await preferencesReaderFor(db).readPreferences(tenantId);
37
+ const seconds = prefs?.upstreamTimeoutSeconds;
38
+ if (typeof seconds !== "number" || !Number.isFinite(seconds) || seconds <= 0) return;
39
+ const wanted = Math.floor(seconds * 1000);
40
+ // Only ever shorten or leave alone a deadline that has already begun: an
41
+ // override longer than the default cannot give back time the request has
42
+ // already spent, and arming a longer one would silently extend a budget
43
+ // the caller's own client has likely abandoned.
44
+ const remaining = Math.min(wanted, resolveUpstreamTimeoutMs());
45
+ extend(remaining);
46
+ } catch {
47
+ // Non-fatal
48
+ }
49
+ }
50
+
51
+ /**
52
+ * The tenant's inline-attachment byte ceiling, or `undefined` for the default.
53
+ *
54
+ * Read through the shared revision-keyed preference reader, so the cost is a
55
+ * memory hit rather than a Postgres round trip — this is consulted while
56
+ * building a request body, on the dispatch path.
57
+ *
58
+ * Non-fatal: a settings read outage falls back to the deployment default,
59
+ * which is the safe direction (the default refuses less, but it never admits
60
+ * something no upstream will take).
61
+ */
62
+ export async function resolveTenantImageClampMaxBytes(
63
+ db: CartethyiaDatabase,
64
+ tenantId: string | undefined,
65
+ ): Promise<number | undefined> {
66
+ if (tenantId === undefined) return undefined;
67
+ try {
68
+ const prefs = await preferencesReaderFor(db).readPreferences(tenantId);
69
+ const bytes = prefs?.imageClampMaxBytes;
70
+ if (typeof bytes !== "number" || !Number.isFinite(bytes) || bytes <= 0) return undefined;
71
+ return Math.floor(bytes);
72
+ } catch {
73
+ return undefined;
74
+ }
75
+ }
16
76
 
17
77
  /**
18
78
  * Applies request shaping owned by tenant preferences and deployment config:
@@ -60,6 +60,10 @@ function admissionDirective(candidate: RouteCandidate): AdmissionDirective {
60
60
  /** The one shape of an admission rejection: the metric, then the decision. */
61
61
  function capacityRejected(): AdmissionDecision {
62
62
  metrics.proxy_admission_total.inc(1, { reason: "provider_capacity" });
63
+ // `reason` is the routing-internal label, not the public code. It stays the
64
+ // string the router and its tests match on; the envelope code is
65
+ // `provider_capacity_exhausted` so this layer cannot be confused with an
66
+ // API-key `max_concurrent` rejection (which owns `capacity_exhausted`).
63
67
  return { admitted: false, reason: "capacity_exhausted" };
64
68
  }
65
69
 
@@ -3,6 +3,7 @@
3
3
  import { asc, eq, ne } from "drizzle-orm";
4
4
  import type { CartethyiaDatabase } from "../../persistence/db-handle";
5
5
  import {
6
+ consoleSettings,
6
7
  models,
7
8
  modelAliases,
8
9
  modelCombos,
@@ -383,7 +384,7 @@ class RouteCatalogRepository {
383
384
  constructor(private readonly db: CartethyiaDatabase) {}
384
385
 
385
386
  async loadRouteCatalogSnapshot(tenantId?: string): Promise<RouteCatalogSnapshotResult> {
386
- const [providerRows, modelRows, accountRows, aliasRows, comboRows, routingRows, poolRows, disabledModelRows, cliMappingRows, cliMappingSettingRows, poolSettingRows] =
387
+ const [providerRows, modelRows, accountRows, aliasRows, comboRows, routingRows, poolRows, disabledModelRows, cliMappingRows, cliMappingSettingRows, poolSettingRows, settingsRows] =
387
388
  await Promise.all([
388
389
  this.db.select(PROVIDER_COLUMNS).from(providers).where(eq(providers.enabled, true)),
389
390
  this.db.select(MODEL_COLUMNS).from(models),
@@ -400,8 +401,38 @@ class RouteCatalogRepository {
400
401
  this.db.select(CLI_MAPPING_COLUMNS).from(cliToolMappings),
401
402
  this.db.select(CLI_MAPPING_SETTING_COLUMNS).from(cliToolSettings),
402
403
  this.db.select(POOL_SETTING_COLUMNS).from(poolRoutingSettings),
404
+ // The cooldown override is a console_settings preference, read inside
405
+ // the same fan-out rather than per request: one more row set on a
406
+ // query that already runs, instead of a Postgres round trip on every
407
+ // admitted request.
408
+ this.db
409
+ .select({ tenantId: consoleSettings.tenantId, preferences: consoleSettings.preferences })
410
+ .from(consoleSettings),
403
411
  ]);
404
412
  const mergedModelRows = mergeModelCatalog(modelRows);
413
+ /**
414
+ * Tenants that asked to ignore an account-wide cooldown.
415
+ *
416
+ * Read-only override: the cooldown is still on the row, still shown in
417
+ * Health, and still recovered by the sweep. This only stops it from
418
+ * excluding the candidate, so turning the setting off needs no cleanup.
419
+ *
420
+ * Per-(account, model) cooldowns are deliberately NOT overridden — see the
421
+ * preference's own doc.
422
+ */
423
+ const cooldownOverrideTenants = new Set<string>();
424
+ for (const row of settingsRows ?? []) {
425
+ if (row.preferences?.forceDisableCooldown === true && row.tenantId) {
426
+ cooldownOverrideTenants.add(row.tenantId);
427
+ }
428
+ }
429
+ /**
430
+ * A shared account has no tenant of its own (`tenant_id IS NULL`), so a
431
+ * tenant-keyed override would never match it — yet a shared account is
432
+ * exactly the one every tenant dials, and parking it stops all of them.
433
+ * When any tenant asks, the override covers shared accounts too.
434
+ */
435
+ const anyTenantOverrides = cooldownOverrideTenants.size > 0;
405
436
 
406
437
  // Compile tenant-scoped disables into a lookup keyed by the same
407
438
  // composite identity the `models` table uses. A disabled key suppresses
@@ -701,16 +732,40 @@ class RouteCatalogRepository {
701
732
  // Expired cooldowns read as healthy here without a fire-and-forget
702
733
  // write: AccountHealthSweeper materializes the recovery every 30s,
703
734
  // so the builder stays a pure read (no UPDATE racing the request).
735
+ //
736
+ // `forceDisableCooldown` skips the exclusion entirely. The cooldown
737
+ // is still real and still recorded — it simply stops deciding
738
+ // routing. That is what makes the setting reversible: nothing was
739
+ // written, so turning it off needs no cleanup, and Health still
740
+ // reports the truth about the account.
704
741
  if (account.cooldownUntil && account.cooldownUntil.getTime() > Date.now()) {
705
- candidate.health_status = "cooldown";
706
- candidate.cooldown_kind = hardCooldownCategory(account.lastErrorCategory)
707
- ? "hard"
708
- : "soft";
709
- // The deadline rides with the exclusion it explains. `plan()` reads
710
- // it to answer the all-cooling case with the real reset instead of
711
- // a bare 429, so a client backs off for the window that is actually
712
- // open rather than inventing one.
713
- candidate.cooldown_until = account.cooldownUntil.getTime();
742
+ const overrideActive =
743
+ rowTenantId === null
744
+ ? anyTenantOverrides
745
+ : cooldownOverrideTenants.has(rowTenantId);
746
+ if (overrideActive) {
747
+ // The account is still cooling — this only stops it from being
748
+ // excluded. `plan()` reads `cooldown_until` to answer the
749
+ // all-cooling case, so it stays populated either way.
750
+ //
751
+ // Neither `health_status` nor `cooldown_kind` is set: the
752
+ // evaluator excludes a hard cooldown on `health_status ===
753
+ // "cooldown"` alone, so stamping it here would keep excluding
754
+ // the very accounts the override exists to reach — a spent quota
755
+ // or a policy block, which are the ones an operator actually
756
+ // wants to override.
757
+ candidate.cooldown_until = account.cooldownUntil.getTime();
758
+ } else {
759
+ candidate.health_status = "cooldown";
760
+ candidate.cooldown_kind = hardCooldownCategory(account.lastErrorCategory)
761
+ ? "hard"
762
+ : "soft";
763
+ // The deadline rides with the exclusion it explains. `plan()`
764
+ // reads it to answer the all-cooling case with the real reset
765
+ // instead of a bare 429, so a client backs off for the window
766
+ // that is actually open rather than inventing one.
767
+ candidate.cooldown_until = account.cooldownUntil.getTime();
768
+ }
714
769
  }
715
770
  }
716
771
 
@@ -255,8 +255,8 @@ export interface AdmissionController {
255
255
  * Translates the evaluator's internal reason codes into operator English.
256
256
  *
257
257
  * These names are the vocabulary the routing layers use with each other —
258
- * `model_cooldown`, `cooldown_hard`, `locked` — and they used to be printed
259
- * verbatim into the public message, so the client read an enum instead of an
258
+ * `model_cooldown`, `cooldown_hard`, `locked` — not something to print into the
259
+ * public message, where the client would read an enum instead of an
260
260
  * explanation. The codes stay in the envelope's `reasons` field for anyone who
261
261
  * wants them; the sentence carries what they mean.
262
262
  *
@@ -324,11 +324,43 @@ export function capabilityUnsupportedError(capability: string): GatewayError {
324
324
  );
325
325
  }
326
326
 
327
- export function capacityExhaustedError(): GatewayError {
327
+ /**
328
+ * Every candidate in the plan refused admission: each account is already
329
+ * serving its configured max-inflight.
330
+ *
331
+ * Deliberately NOT `capacity_exhausted`. That code also belongs to the API-key
332
+ * admission layer (`security/admission`, reason `concurrency-limit`), which
333
+ * enforces a key's own `max_concurrent`. Sharing one code made the two
334
+ * indistinguishable on the wire: an operator who raised the provider's
335
+ * max-inflight saw no change because the rejection was the *key's* ceiling, and
336
+ * vice versa. Distinct codes let each be read off the envelope alone.
337
+ *
338
+ * The counts are carried in `details` because the interesting question is never
339
+ * "was it rejected" but "was every account really full, or did the plan only
340
+ * carry one candidate" — the latter is a routing/eligibility problem that
341
+ * raising the ceiling will not fix.
342
+ */
343
+ export function capacityExhaustedError(context?: {
344
+ readonly providerId?: string;
345
+ readonly model?: string;
346
+ readonly candidateCount?: number;
347
+ readonly maxInflight?: number;
348
+ }): GatewayError {
349
+ const onlyOne = context?.candidateCount === 1;
328
350
  return new GatewayError(
329
- "capacity_exhausted",
351
+ "provider_capacity_exhausted",
330
352
  429,
331
- "Every account is at its concurrency limit. Retry shortly; if it persists, raise the provider's max-inflight or add an account.",
353
+ onlyOne
354
+ ? "The only account routing this model is at its concurrency limit. Retry shortly; if it persists, raise the provider's max-inflight or add another account."
355
+ : "Every account is at its concurrency limit. Retry shortly; if it persists, raise the provider's max-inflight or add an account.",
356
+ {
357
+ ...(context?.providerId !== undefined ? { providerId: context.providerId } : {}),
358
+ ...(context?.model !== undefined ? { model: context.model } : {}),
359
+ ...(context?.candidateCount !== undefined
360
+ ? { candidate_count: context.candidateCount }
361
+ : {}),
362
+ ...(context?.maxInflight !== undefined ? { maxInflight: context.maxInflight } : {}),
363
+ },
332
364
  );
333
365
  }
334
366
 
@@ -423,10 +455,10 @@ export class InMemoryRouteSnapshotService implements RouteSnapshotService {
423
455
  *
424
456
  * The tag is what keeps a mutation from being lost. A build reads the catalog
425
457
  * over ten Postgres round-trips, so a dashboard write can land while one is
426
- * in flight. Without the tag two things went wrong: a reader that arrived
427
- * *after* the mutation was handed the still-running pre-mutation build, and
428
- * that build then overwrote the cache the mutation had just cleared — so the
429
- * data plane served the old routing until some later write invalidated it
458
+ * in flight. Without the tag two things go wrong: a reader that arrives
459
+ * *after* the mutation is handed the still-running pre-mutation build, and
460
+ * that build then overwrites the cache the mutation has just cleared — so the
461
+ * data plane serves stale routing until some later write invalidates it
430
462
  * again. A build may only be reused by readers at its own revision, and may
431
463
  * only populate the cache if no mutation happened while it ran.
432
464
  */
@@ -491,9 +523,9 @@ export class InMemoryRouteSnapshotService implements RouteSnapshotService {
491
523
  ]),
492
524
  )
493
525
  : undefined;
494
- // One literal for both routing modes. These used to be two verbatim copies
495
- // that differed only by the `providerRouting` key, so a snapshot field added
496
- // to one branch would silently vanish on the other.
526
+ // One literal for both routing modes: two verbatim copies differing only by
527
+ // the `providerRouting` key would mean a snapshot field added to one branch
528
+ // silently vanishes on the other.
497
529
  const snapshot = deepFreeze({
498
530
  revision,
499
531
  candidates: Object.freeze([...built.candidates]) as readonly RouteCandidate[],
@@ -508,8 +540,8 @@ export class InMemoryRouteSnapshotService implements RouteSnapshotService {
508
540
  });
509
541
  // A mutation during the build means this snapshot describes a catalog that
510
542
  // has already been superseded. Returning it is fine — the caller asked
511
- // before the mutation — but caching it would strand the data plane on the
512
- // old routing until the next write.
543
+ // before the mutation — but caching it would strand the data plane on stale
544
+ // routing until the next write.
513
545
  if (revision === this.revision) this.snapshot = snapshot;
514
546
  return snapshot;
515
547
  }
@@ -226,10 +226,10 @@ function spreadDeadline(cooldownUntil: number | undefined): { cooldown_until?: n
226
226
  * The soonest moment a quota window the caller is blocked on refills.
227
227
  *
228
228
  * A usage-floor exclusion is time-bounded by construction — the floor compares
229
- * the *remaining* allowance, and the window states when that allowance resets —
230
- * but the exclusion used to carry no deadline, so `plan()` could not tell a
231
- * client how long to wait and `earliestResetMs` reported nothing. The minimum
232
- * across windows, for the same reason `earliestResetMs` takes it: the account
229
+ * the *remaining* allowance, and the window states when that allowance resets.
230
+ * The exclusion carries a deadline so `plan()` can tell a client how long to
231
+ * wait and `earliestResetMs` reports a value. The minimum across windows, for
232
+ * the same reason `earliestResetMs` takes it: the account
233
233
  * can serve again as soon as its first window refills, so quoting the slowest
234
234
  * would hold the client off longer than necessary.
235
235
  *
@@ -317,9 +317,8 @@ export class EligibilityEvaluator {
317
317
  return { eligible: false, reason: "model_cooldown", candidate, ...spreadDeadline(state.cooldown_until) };
318
318
  if (state.health_status === "cooldown" && state.cooldown_kind === "hard")
319
319
  return { eligible: false, reason: "cooldown_hard", candidate, ...spreadDeadline(state.cooldown_until) };
320
- // The floor is checked here, not after selection: the old minimum-balance
321
- // feature computed the number and never consulted it, so an account below
322
- // its floor still won the route. An account with no reading is left
320
+ // The floor is checked here, not after selection: an account below its
321
+ // floor must not win the route. An account with no reading is left
323
322
  // eligible — a missing quota fetch is not proof the allowance is gone.
324
323
  if (belowUsageFloor(state))
325
324
  return {
@@ -487,8 +486,8 @@ export class RoutingEngine {
487
486
  * Stamps each account with its provider's usage floor and the quota reading
488
487
  * the console last fetched, so eligibility can reject an account that has
489
488
  * spent down to the floor. The reading is the live cache, not the route
490
- * snapshot: a snapshot rebuilt every few minutes would route on a stale
491
- * balance, which is how the old floor let a spent account through.
489
+ * snapshot: a snapshot rebuilt every few minutes routes on a stale balance
490
+ * and lets a spent account through.
492
491
  */
493
492
  private async applyUsageFloor(
494
493
  candidates: readonly RouteCandidate[],
@@ -598,10 +597,10 @@ export class RoutingEngine {
598
597
  d.reason === "model_cooldown" ||
599
598
  // A usage-floor exclusion whose window states a reset is the same
600
599
  // shape as the two above: the account is resting until a named
601
- // instant, not broken. It used to fall through to 503, which told
602
- // the client the model was down while every account serving it was
603
- // merely waiting for its window to refill — the "503 DOWN while the
604
- // model is allowed" report again. Only a floor with *no* stated
600
+ // instant, not broken. Falling through to 503 tells the client the
601
+ // model is down while every account serving it is merely waiting for
602
+ // its window to refill — the "503 DOWN while the model is allowed"
603
+ // report again. Only a floor with *no* stated
605
604
  // reset stays unavailable: there is nothing to retry against yet.
606
605
  (d.reason === "quota_exhausted" && typeof d.cooldown_until === "number"),
607
606
  );
@@ -828,7 +827,15 @@ export class RoutingEngine {
828
827
  const admitted = await this.admission.admit(candidate);
829
828
  if (admitted.admitted) return this.reservations.acquire(candidate, plan.revision);
830
829
  }
831
- throw capacityExhaustedError();
830
+ // Last candidate's identity names the provider the plan was actually for;
831
+ // every candidate shares it, so any one of them is representative.
832
+ const last = plan.candidates[plan.candidates.length - 1];
833
+ throw capacityExhaustedError({
834
+ ...(last?.provider_id !== undefined ? { providerId: last.provider_id } : {}),
835
+ ...(plan.resolved_model !== undefined ? { model: plan.resolved_model } : {}),
836
+ candidateCount: plan.candidates.length,
837
+ ...(last?.max_inflight !== undefined ? { maxInflight: last.max_inflight } : {}),
838
+ });
832
839
  }
833
840
  async release(reservation: Reservation): Promise<void> {
834
841
  await this.admission.release(reservation);
@@ -45,9 +45,8 @@ function stopReason(events: readonly CanonicalEvent[]): string | null {
45
45
  }
46
46
 
47
47
  /**
48
- * Completion wire usage. Delegates to the chat builder (same wire family) —
49
- * completion responses now carry cache/reasoning details instead of dropping
50
- * them (intended fix; previously only the three totals were forwarded).
48
+ * Completion wire usage. Delegates to the chat builder (same wire family), so
49
+ * completion responses carry cache/reasoning details, not just the three totals.
51
50
  */
52
51
  function usage(events: readonly CanonicalEvent[]): OpenAiUsage | undefined {
53
52
  const terminal = [...events].reverse().find((event) => event.type === "terminal");
@@ -79,12 +79,11 @@ function parseToolObject(item: Record<string, unknown>, dialect: ToolDialect): T
79
79
  }
80
80
  } else if (dialect === "responses") {
81
81
  // A Responses caller may still send the Chat-shaped envelope — the two
82
- // spellings are interchangeable in the wild, and a client that posts
83
- // `{"type":"function","function":{"parameters":…}}` here previously fell
84
- // through to `schema = item`, which shipped the whole tool object upstream
85
- // as the parameter schema and came back as
86
- // `invalid_function_parameters` (CodeBuddy 11129). Unwrap it the way the
87
- // Chat dialect does before falling back to the flat Responses spelling.
82
+ // spellings are interchangeable in the wild. Unwrap it the way the Chat
83
+ // dialect does before falling back to the flat Responses spelling; taking
84
+ // `schema = item` instead ships the whole tool object upstream as the
85
+ // parameter schema and comes back as `invalid_function_parameters`
86
+ // (CodeBuddy 11129).
88
87
  const wrapped = isRecord(item["function"]) ? item["function"] : undefined;
89
88
  schema =
90
89
  item["input_schema"] ??
@@ -704,6 +704,44 @@ export class ResponsesEventEncoder extends SurfaceStreamEncoder<CanonicalEvent,
704
704
  throw new ResponsesLifecycleError("Responses stream ended without a terminal event");
705
705
  return [];
706
706
  }
707
+
708
+ /**
709
+ * Encodes a fatal stream error as `response.error` followed immediately by
710
+ * `response.failed`.
711
+ *
712
+ * The OpenAI Responses SDK (and any strict client) requires a terminal
713
+ * lifecycle event (`response.completed`, `response.failed`, or
714
+ * `response.incomplete`) to close the stream cleanly. Emitting only
715
+ * `response.error` and then closing the SSE connection causes the SDK to
716
+ * throw "stream closed before a terminal response event was received".
717
+ *
718
+ * `response.error` is kept so the client can surface the error message;
719
+ * `response.failed` immediately follows so the client's state machine
720
+ * reaches a terminal state and stops waiting.
721
+ */
722
+ encodeError(input: {
723
+ readonly origin: string;
724
+ readonly code: string;
725
+ readonly message: string;
726
+ readonly details: unknown;
727
+ }): ResponsesWireEvent[] {
728
+ if (this.terminal) return [];
729
+ this.terminal = true;
730
+ const errorEvent = this.emit("response.error", {
731
+ origin: input.origin,
732
+ code: input.code,
733
+ message: input.message,
734
+ details: input.details,
735
+ });
736
+ const response = this.responseObject("failed");
737
+ response["error"] = {
738
+ type: "server_error",
739
+ code: input.code,
740
+ message: input.message,
741
+ };
742
+ const failedEvent = this.emit("response.failed", { response });
743
+ return [errorEvent, failedEvent];
744
+ }
707
745
  }
708
746
 
709
747
  /** Encode synchronous canonical events into ordered Responses wire events. */
@@ -456,6 +456,10 @@ function parseInputItem(item: ResponsesInputItem): {
456
456
  encrypted_content: encrypted,
457
457
  };
458
458
  if (summary !== undefined) reasoningPart.summary = summary;
459
+ // The upstream binds encrypted reasoning to the item id it was issued
460
+ // for; a replay under a different id is rejected outright.
461
+ const itemId = typeof item["id"] === "string" ? item["id"] : undefined;
462
+ if (itemId !== undefined) reasoningPart.item_id = itemId;
459
463
  return {
460
464
  message: { role: "assistant", content: [reasoningPart] },
461
465
  metadata: itemMetadata(item),
@@ -334,9 +334,8 @@ export function deriveRequiredCapabilities(
334
334
  for (const key of Object.keys(request.generation_controls)) {
335
335
  // `extension:*` keys are passthrough hints to the wire codec, not route
336
336
  // capabilities: the codec reads the names it understands and drops the
337
- // rest. Gating them here used to strip every hint (this set was never
338
- // populated), degrading `include_usage`, `prompt_cache_key`, `user`,
339
- // `metadata`, etc. and re-warning per request.
337
+ // rest. Gating them here would strip `include_usage`, `prompt_cache_key`,
338
+ // `user`, `metadata`, etc. and re-warn per request.
340
339
  if (key.startsWith("extension:")) continue;
341
340
  capabilities.push(`generation_control:${key}`);
342
341
  }
@@ -180,9 +180,9 @@ export function applyParamQuirks(
180
180
  const quirk = findParamQuirk(providerId, request.model);
181
181
  // The thinking-sampling strip is universal, not quirk-gated: extended
182
182
  // thinking rejects temperature/top_p/top_k on every Messages-wire upstream.
183
- // Previously the early return below skipped it for any provider without a
184
- // PARAM_QUIRKS row (only anthropic/claude had one), so strict upstreams got
185
- // a 400 the strip exists to prevent. Declarative rows still apply after.
183
+ // It runs before the early return below so a provider with no PARAM_QUIRKS
184
+ // row still gets it — otherwise a strict upstream takes a 400 the strip
185
+ // exists to prevent. Declarative rows still apply after.
186
186
 
187
187
  let controls = request.generation_controls;
188
188
  let changed = false;
@@ -22,6 +22,7 @@ import { randomUUID } from "node:crypto";
22
22
  import type { CartethyiaDatabase } from "../persistence/db-handle";
23
23
  import { buddyTaskLedger, providerAccounts } from "../persistence/schema";
24
24
  import { log } from "../observability/logger";
25
+ import { sleep } from "../shared/sleep";
25
26
  import { buddyAccountUid } from "../providers/integrations/buddy/buddy-oauth-shared";
26
27
  import {
27
28
  adoptFirstBuddy,
@@ -207,12 +208,6 @@ async function forAccount(
207
208
  return run(call);
208
209
  }
209
210
 
210
- /** Pause between accounts, so one pass does not look like a burst upstream. */
211
- function sleep(ms: number): Promise<void> {
212
- const { promise, resolve } = Promise.withResolvers<void>();
213
- setTimeout(resolve, ms);
214
- return promise;
215
- }
216
211
  /** Walks the accounts, skipping any already settled today. */
217
212
  async function runTask(
218
213
  deps: BuddyTaskDeps,
@@ -353,10 +348,10 @@ export function buddyTrialTask(deps: BuddyTaskDeps) {
353
348
  * Growth-activity report: the `/v2/report` batch that lights the login streak and
354
349
  * scores the daily growth event.
355
350
  *
356
- * It used to ride the quota sweep with no ledger of its own, which meant eight
357
- * POSTs per account *per sweep* (two rounds of four events) because the sweep
358
- * ran every minute. It is its own once-a-day task here, gated by the same
359
- * (day, account, task) ledger as every other growth task.
351
+ * It is a once-a-day task with its own ledger, gated by the same
352
+ * (day, account, task) ledger as every other growth task: running it on the
353
+ * quota sweep would post two rounds of four events per account per sweep, and
354
+ * the sweep runs every minute.
360
355
  *
361
356
  * The report is not a credit claim, so its failure never marks an account
362
357
  * unhealthy — the result is logged and the ledger is stamped either way.
@@ -120,9 +120,8 @@ export function ledgerKey(dayKey: string, accountId: string): string {
120
120
  * With Redis the `SET NX EX` is the cross-process guard (two gateway instances
121
121
  * cannot both attempt the same account). Without it the ledger *table* is the
122
122
  * guard, and its composite PK makes the insert atomic — so Lite records the
123
- * attempt it just made instead of silently returning `true`, which is what left
124
- * the console unable to answer "attempted today" and showing "Check-in due"
125
- * forever.
123
+ * attempt it just made instead of silently returning `true`, which leaves the
124
+ * console unable to answer "attempted today" and shows "Check-in due" forever.
126
125
  */
127
126
  async function reserveDaySlot(
128
127
  db: CartethyiaDatabase | undefined,
@@ -160,8 +159,8 @@ async function reserveDaySlot(
160
159
  * Releases today's slot for many accounts in one round-trip.
161
160
  *
162
161
  * The sweep reserves a slot per account as it walks the wave; releasing them
163
- * one at a time on the error path turned every failed pass into a Redis RTT per
164
- * failed account. A pass that budgets out with twenty failures paid twenty
162
+ * one at a time on the error path turns every failed pass into a Redis RTT per
163
+ * failed account. A pass that budgets out with twenty failures pays twenty
165
164
  * round-trips for bookkeeping that has no bearing on the claim itself.
166
165
  *
167
166
  * A pipeline is used when Redis is present (one `MULTI/DEL` for the whole
@@ -246,8 +245,8 @@ export async function markCheckinAttempted(args: {
246
245
  * The read belongs next to the writes because there are two places the marker
247
246
  * can live — the Redis key when Redis is present, the `daily_checkin_ledger`
248
247
  * row when it is not — and only this module knows which one its own writes
249
- * used. A caller that read the Redis key directly answered "not attempted" on
250
- * every Lite deployment, where the sweep had written the row.
248
+ * used. A caller that reads the Redis key directly answers "not attempted" on
249
+ * every Lite deployment, where the sweep writes the row.
251
250
  *
252
251
  * Absent or unreadable means unknown, never "not claimed": the sweep may
253
252
  * simply not have reached the account yet.
@@ -355,9 +354,9 @@ export function supportsDailyCheckin(providerId: string): boolean {
355
354
  * Wraps a proxy-bound fetcher so a 403 falls back to a direct connection once.
356
355
  *
357
356
  * A proxy that answers 403 (auth, expired pool credential, a provider that
358
- * rejects the egress) used to fail the whole claim even though the account's
359
- * own credential was fine — the proxy was the only thing wrong. One direct
360
- * retry costs nothing when the proxy was healthy (the first attempt already
357
+ * rejects the egress) would fail the whole claim even though the account's own
358
+ * credential was fine — the proxy is the only thing wrong. One direct retry
359
+ * costs nothing when the proxy was healthy (the first attempt already
361
360
  * succeeded) and rescues the claim when it was not.
362
361
  *
363
362
  * The fallback only fires on 403, not on every error: a 401 means the account
@@ -375,15 +374,10 @@ export function withDirectFallback(fetcher: typeof fetch): typeof fetch {
375
374
  return wrapped;
376
375
  }
377
376
  /**
378
- * The outcome of one daily check-in pass: the claim alone.
379
- *
380
- * The growth-activity report is no longer part of this pass — it moved to its
381
- * own once-a-day growth task (`buddy-activity-report`) with its own ledger, so
382
- /**
383
- * Runs the full daily growth pass for one account, once per calendar day: the
384
- * check-in claim alone. The growth-activity report moved to its own once-a-day
385
- * growth task (`buddy-activity-report`) with its own ledger, so it is not
386
- * re-POSTed on every sweep. See `buddy-growth-tasks.ts`.
377
+ * The outcome of one daily check-in pass: the claim alone. The
378
+ * growth-activity report runs as its own once-a-day growth task
379
+ * (`buddy-activity-report`) with its own ledger, so it is not re-POSTed on
380
+ * every sweep. See `buddy-growth-tasks.ts`.
387
381
  */
388
382
  export type DailyGrowthPassResult = {
389
383
  readonly checkin: DailyCheckinResult;
@@ -448,10 +442,10 @@ export async function attemptDailyGrowthPass(args: {
448
442
 
449
443
  // Only a transient `error` releases the slot so a later pass today can retry
450
444
  // (network, 5xx). `unavailable` is the status probe answering that no
451
- // activity is running — retrying that every pass spent the account's daily
452
- // slot on a door the provider had closed, and releasing the marker was also
453
- // what erased the evidence that anything had been attempted: the console then
454
- // read "Check-in due" forever while the sweep retried once a minute.
445
+ // activity is running — retrying that every pass spends the account's daily
446
+ // slot on a door the provider has closed, and releasing the marker also
447
+ // erases the evidence that anything was attempted: the console then reads
448
+ // "Check-in due" forever while the sweep retries once a minute.
455
449
  // `already_claimed`, `not_eligible`, and `event_ended` are terminal for the
456
450
  // day and keep the slot.
457
451
  return { checkin };