cartethyia 2.5.4 → 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 (395) hide show
  1. package/.env.example +13 -4
  2. package/README.md +2 -2
  3. package/dist/dashboard/assets/{BannedPage-C2orBsI2.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-BV6pxVQo.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-CW-wPmJ6.js → ConsoleLogPage-BVdpfiz1.js} +1 -1
  12. package/dist/dashboard/assets/ConsoleLogPage-BVdpfiz1.js.gz +0 -0
  13. package/dist/dashboard/assets/{CustomizationPage-0p8uX7vB.js → CustomizationPage-BR5HHvbi.js} +1 -1
  14. package/dist/dashboard/assets/CustomizationPage-BR5HHvbi.js.gz +0 -0
  15. package/dist/dashboard/assets/{HealthEventsModal-BjqSMfko.js → HealthEventsModal-mUfcknqm.js} +1 -1
  16. package/dist/dashboard/assets/HealthEventsModal-mUfcknqm.js.gz +0 -0
  17. package/dist/dashboard/assets/{LoginPage-BvzCx8Kr.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-Js9jUPSb.js → ProvidersPage-uPiGVTTK.js} +1 -1
  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-DDbMQsoo.js → SetupPage-UB690SE2.js} +1 -1
  34. package/dist/dashboard/assets/SetupPage-UB690SE2.js.gz +0 -0
  35. package/dist/dashboard/assets/{SortableList-CB3Z7VTf.js → SortableList-BLujdX7N.js} +1 -1
  36. package/dist/dashboard/assets/SortableList-BLujdX7N.js.gz +0 -0
  37. package/dist/dashboard/assets/{StudioPage-D95UCPHI.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-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-CTP8AyiS.js +1 -0
  66. package/dist/dashboard/assets/entry-CTP8AyiS.js.gz +0 -0
  67. package/dist/dashboard/assets/entry-DxBHnauy.js +4 -0
  68. package/dist/dashboard/assets/entry-DxBHnauy.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-CmxZr14F.js} +2 -2
  72. package/dist/dashboard/assets/index-CmxZr14F.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-BYR_5K2c.js} +1 -1
  94. package/dist/dashboard/assets/select-BYR_5K2c.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 +96 -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
@@ -44,9 +44,7 @@ export function createDispatchStreamEncoder(
44
44
  return {
45
45
  push: (event) => encoder.push(event).map((wire) => formatSseEvent(wire.type, wire)),
46
46
  finish: () => encoder.finish().map((wire) => formatSseEvent(wire.type, wire)),
47
- encodeError: ({ origin, code, message, details }) => [
48
- formatSseEvent("response.error", { type: "response.error", origin, code, message, details }),
49
- ],
47
+ encodeError: (input) => encoder.encodeError(input).map((wire) => formatSseEvent(wire.type, wire)),
50
48
  };
51
49
  }
52
50
  if (surface === "messages") {
@@ -154,7 +154,7 @@ export class StreamFinalizer {
154
154
  *
155
155
  * Once the upstream has produced anything — even surface-invisible
156
156
  * reasoning deltas — it is alive: the stall bound governs and the tighter
157
- * first-chunk bound no longer applies.
157
+ * first-chunk bound does not apply.
158
158
  *
159
159
  * The request deadline is a fixed ceiling measured from ingress, and a
160
160
  * stream that keeps sending past it is not stalled. A live WorkBuddy stream
@@ -232,10 +232,9 @@ export async function dispatchStreamingAttempt(input: StreamingDispatchInput): P
232
232
  });
233
233
  // `ProviderAdapter.dispatch` is declared to return a non-nullable
234
234
  // `AsyncIterable`, so an adapter that returned `undefined` would be a
235
- // type violation rather than a state this code could handle. The guard
236
- // that used to sit here (`if (!iterable) throw ...`) was therefore
237
- // unreachable, and it advertised a contract the interface does not
238
- // have. Enforcing it belongs at the adapter boundary, not here.
235
+ // type violation rather than a state this code could handle — no null check
236
+ // is needed here, and adding one would advertise a contract the interface
237
+ // does not have. Enforcing it belongs at the adapter boundary.
239
238
  const createUpstreamIterable = () =>
240
239
  adapter.dispatch(
241
240
  dispatchRequest,
@@ -375,8 +374,8 @@ export async function dispatchStreamingAttempt(input: StreamingDispatchInput): P
375
374
  // reasoning item when the client is Chat Completions). Keep
376
375
  // pulling until at least one wire chunk is enqueued; returning
377
376
  // from pull with an empty queue can leave Web Streams waiting
378
- // forever, which is why real clients previously saw one
379
- // assistant header and then a 60s socket reset.
377
+ // forever — a client then sees one assistant header and then a
378
+ // 60s socket reset.
380
379
  for (;;) {
381
380
  // Re-arm on every upstream read: the watchdog fires only
382
381
  // when the provider stays silent longer than the bound.
@@ -229,6 +229,35 @@ export function extractUpstreamMessage(body: unknown): string {
229
229
  * Reading only a nested string dropped the code for the second family
230
230
  * entirely, so the operator saw a bare message with no provider code.
231
231
  */
232
+ /**
233
+ * The provider code hidden inside one string value, or `undefined`.
234
+ *
235
+ * Recognizes both a nested JSON envelope written as an escaped string
236
+ * (`"{\"code\":\"11-128\"}"`) and the bare escaped form (`"\"code\\\":\\\"11-128\""`).
237
+ * Cheap by construction: the scan is a substring test, and only a string that
238
+ * looks like it carries a code is handed to `JSON.parse`, so a large message
239
+ * body costs one `includes` rather than a parse.
240
+ */
241
+ function parseEmbeddedCode(value: string): string | undefined {
242
+ if (!value.includes("code")) return undefined;
243
+ // An escaped envelope: try it as JSON first, then with its escapes collapsed.
244
+ for (const candidate of [value, value.replace(/\\+"/g, '"')]) {
245
+ const trimmed = candidate.trim();
246
+ if (!trimmed.startsWith("{") && !trimmed.startsWith('"')) continue;
247
+ let parsed: unknown;
248
+ try {
249
+ parsed = JSON.parse(trimmed);
250
+ } catch {
251
+ continue;
252
+ }
253
+ const code = upstreamProviderCode(parsed);
254
+ if (code !== undefined) return code;
255
+ }
256
+ // A bare `"code": "11-128"` fragment inside a longer message.
257
+ const match = value.match(/\\*["']?code\\*["']?\s*:\s*\\*["']?([A-Za-z0-9_.:-]{3,32})/i);
258
+ return match?.[1];
259
+ }
260
+
232
261
  export function upstreamProviderCode(body: unknown): string | undefined {
233
262
  if (!isRecord(body)) return undefined;
234
263
  const nested = isRecord(body.error) ? body.error : undefined;
@@ -236,6 +265,20 @@ export function upstreamProviderCode(body: unknown): string | undefined {
236
265
  for (const candidate of [ext?.code, nested?.code, body.code]) {
237
266
  if (typeof candidate === "string" && candidate.length > 0) return candidate;
238
267
  }
268
+ // A provider may embed its error envelope as an *escaped string* rather than a
269
+ // nested object — `"error": "{\"code\":\"11-128\",...}"` — so none of the
270
+ // candidates above is a record and the code is invisible. That is the shape
271
+ // the reference gateways detect, and it matters because the channel code is
272
+ // what decides whether an account is cooled: missing it, a blocked account
273
+ // stays in rotation failing every request.
274
+ for (const candidate of [ext, nested, body]) {
275
+ if (!isRecord(candidate)) continue;
276
+ for (const value of Object.values(candidate)) {
277
+ if (typeof value !== "string") continue;
278
+ const embedded = parseEmbeddedCode(value);
279
+ if (embedded !== undefined) return embedded;
280
+ }
281
+ }
239
282
  // A numeric code is still the provider's identifier; keep it as text so an
240
283
  // operator can search for it. The nested string above wins when both exist.
241
284
  const numeric = ext?.code ?? nested?.code ?? body.code;
@@ -364,6 +407,14 @@ export function classifyUpstreamFailure(error: unknown): UpstreamFailurePolicy {
364
407
  error.code === "model_not_found" ||
365
408
  error.code === "admission_unavailable" ||
366
409
  error.code === "capacity_exhausted" ||
410
+ // A provider max-inflight rejection: the plan's next sibling account may
411
+ // have a free slot, exactly as with an API-key concurrency rejection.
412
+ error.code === "provider_capacity_exhausted" ||
413
+ // The upstream accepted the request and is holding it in a queue. Another
414
+ // account of the same provider is very likely not queued, so failover is
415
+ // the faster answer — and if every account is queued, the caller still has
416
+ // the stated interval to wait for.
417
+ error.code === "upstream_queued" ||
367
418
  error.code === "accounts_unavailable" ||
368
419
  error.code === "accounts_rate_limited" ||
369
420
  error.code === "quota_exceeded" ||
@@ -499,8 +550,8 @@ export interface TerminalOutcome {
499
550
  * Derives a failed attempt's status, error category, and error origin **from
500
551
  * one decision**, so the three can never contradict each other.
501
552
  *
502
- * They used to be computed independently at each dispatch call site, and they
503
- * drifted: a request that failed on an upstream condition could be recorded
553
+ * Computing them independently at a dispatch call site lets them
554
+ * drift: a request that failed on an upstream condition could be recorded
504
555
  * `cancelled` (499) while its category said `transport_unavailable` and its
505
556
  * http status said 502 — a row no operator could trust. Bundling them here
506
557
  * makes the inconsistency unrepresentable: a cancel always carries
@@ -591,6 +642,88 @@ export function isContextLengthFailure(body: unknown): boolean {
591
642
  }
592
643
  return false;
593
644
  }
645
+ /**
646
+ * How long an upstream asked the caller to wait before retrying, in
647
+ * milliseconds, or `undefined` when the body states no duration.
648
+ *
649
+ * `retryAfterSeconds` is the field the queue-shaped refusals carry. It is read
650
+ * from a few plausible spellings because the upstreams that queue a request do
651
+ * not agree on one, and a queue with no duration still has to be reported as a
652
+ * queue — the caller's wait is then bounded by the caller, not by us.
653
+ */
654
+ export function queueRetryAfterMs(body: unknown): number | undefined {
655
+ if (!isRecord(body)) return undefined;
656
+ const envelope = isRecord(body.error) ? body.error : body;
657
+ for (const source of [envelope, body]) {
658
+ for (const key of ["retryAfterSeconds", "retry_after_seconds", "retryAfterMs", "retry_after"]) {
659
+ const raw = source[key];
660
+ if (typeof raw === "number" && Number.isFinite(raw) && raw > 0) {
661
+ return key.toLowerCase().endsWith("ms") ? Math.round(raw) : Math.round(raw * 1000);
662
+ }
663
+ if (typeof raw === "string") {
664
+ const value = Number(raw.trim());
665
+ if (Number.isFinite(value) && value > 0) {
666
+ return key.toLowerCase().endsWith("ms") ? Math.round(value) : Math.round(value * 1000);
667
+ }
668
+ }
669
+ }
670
+ }
671
+ return undefined;
672
+ }
673
+
674
+ /**
675
+ * True when an upstream error body says the request is **queued**, not refused.
676
+ *
677
+ * Qoder answers `403` with `{"code":"10605", "isQueued":true,
678
+ * "retryAfterSeconds":N}`. That is not a rejection — the request is accepted and
679
+ * will be served once capacity frees up — but it arrives on a status the
680
+ * gateway treats as a failure, so without this it surfaced as a 403 the client
681
+ * read as "you may not do this" and the turn ended. Reporting it as a queue
682
+ * lets the caller wait for the stated interval instead.
683
+ *
684
+ * Recognized conservatively: an explicit `isQueued` flag, the `10605` code, or
685
+ * a message naming a queue. A bare word like "queue" appearing anywhere in a
686
+ * long body is not enough on its own, which is why the message test requires
687
+ * the queue to be what the message is *about* rather than a word it contains.
688
+ */
689
+ export function isUpstreamQueued(body: unknown): boolean {
690
+ if (!isRecord(body)) return false;
691
+ const envelope = isRecord(body.error) ? body.error : body;
692
+ if (envelope.isQueued === true || body.isQueued === true) return true;
693
+ if (envelope.queued === true || body.queued === true) return true;
694
+ for (const candidate of [envelope.code, body.code]) {
695
+ if (typeof candidate === "string" && candidate.trim() === "10605") return true;
696
+ }
697
+ for (const candidate of [envelope.message, envelope.msg, body.message, body.msg]) {
698
+ if (typeof candidate !== "string") continue;
699
+ const lower = candidate.toLowerCase();
700
+ // "is queued" / "queued, retry in Ns" — the queue as the message's subject.
701
+ if (/\bqueued\b|\bis queue\b|resource_limit/.test(lower)) return true;
702
+ }
703
+ return false;
704
+ }
705
+
706
+ /** The error a queued upstream request becomes: retryable, with its wait. */
707
+ export function upstreamQueuedError(
708
+ providerId: string,
709
+ retryAfterMs: number | undefined,
710
+ detail: string,
711
+ ): GatewayError {
712
+ return new GatewayError(
713
+ "upstream_queued",
714
+ 503,
715
+ detail.length > 0
716
+ ? detail
717
+ : "The upstream has queued this request. Retry after the stated interval.",
718
+ {
719
+ providerId,
720
+ reason: "upstream-queued",
721
+ ...(retryAfterMs === undefined ? {} : { retryAfterMs, cooldownMs: retryAfterMs }),
722
+ },
723
+ "upstream",
724
+ );
725
+ }
726
+
594
727
  const QUOTA_ERROR_IDENTIFIERS: ReadonlySet<string> = new Set([
595
728
  "rate_limit_exceeded",
596
729
  "rate_limit_error",
@@ -815,12 +948,31 @@ export async function mapUpstreamHttpError(response: Response, providerId: strin
815
948
  parsedBody = undefined;
816
949
  }
817
950
  const identifier = upstreamErrorIdentifier(parsedBody) ?? providerCode;
818
- const classification = isContextLengthFailure(parsedBody)
819
- ? { code: "context_length_exceeded" as const, status: 413, origin: "upstream" as const }
820
- : classifyUpstreamError(response.status, identifier);
951
+ // A queued request is checked *before* the status table: the upstream accepted
952
+ // the request and will serve it, but says so on a failure status (Qoder's 403
953
+ // + 10605). Letting the table classify it first turned "wait, then retry"
954
+ // into a refusal the client could not distinguish from a permissions error.
955
+ const upstreamQueued = isUpstreamQueued(parsedBody);
956
+ // The body's stated interval wins; the header is only a fallback, because an
957
+ // upstream that queues a request usually states the wait in the body and
958
+ // leaves `Retry-After` off entirely.
959
+ const queuedWaitMs = upstreamQueued
960
+ ? (queueRetryAfterMs(parsedBody) ??
961
+ (parseUpstreamBackoff(response.headers) ?? undefined))
962
+ : undefined;
963
+ const classification = upstreamQueued
964
+ ? { code: "upstream_queued" as const, status: 503, origin: "upstream" as const }
965
+ : isContextLengthFailure(parsedBody)
966
+ ? { code: "context_length_exceeded" as const, status: 413, origin: "upstream" as const }
967
+ : classifyUpstreamError(response.status, identifier);
821
968
  const extracted = extractUpstreamMessage(parsedBody ?? text);
822
969
  const message = (extracted.length > 0 ? extracted : text.trim() || `Provider returned HTTP ${response.status}.`).slice(0, MAX_UPSTREAM_ERROR_BYTES).replace(/[\r\n]+/g, " ");
823
- const retryAfterMs = parseUpstreamBackoff(response.headers);
970
+ const headerWaitMs = parseUpstreamBackoff(response.headers);
971
+ // A queue carries its own interval, which is the one the caller must honour:
972
+ // it is what the upstream promised, while `Retry-After` may be absent or may
973
+ // describe a different window. `cooldownMs` rides along so the account-health
974
+ // layer can also read it.
975
+ const retryAfterMs = queuedWaitMs ?? headerWaitMs;
824
976
  const requestId = upstreamRequestId(response.headers);
825
977
  const reportedProviderCode = providerCode ?? identifier;
826
978
  throw new GatewayError(classification.code, classification.status, message, {
@@ -830,6 +982,9 @@ export async function mapUpstreamHttpError(response: Response, providerId: strin
830
982
  ...(requestId ? { upstreamRequestId: requestId } : {}),
831
983
  raw: text.slice(0, MAX_UPSTREAM_ERROR_BYTES),
832
984
  ...(retryAfterMs === null || retryAfterMs === undefined ? {} : { retryAfterMs }),
985
+ ...(queuedWaitMs !== undefined
986
+ ? { reason: "upstream-queued", cooldownMs: queuedWaitMs }
987
+ : {}),
833
988
  ...(classification.rateLimitScope
834
989
  ? { rateLimitScope: classification.rateLimitScope }
835
990
  : {}),
@@ -895,8 +1050,7 @@ export function isRetryableFailure(error: unknown): boolean {
895
1050
  * timeout.
896
1051
  *
897
1052
  * Owned here so the attempt loop, the empty-stream replay, and the Codex
898
- * retry budget cannot drift apart; each previously declared its own copy of
899
- * the constant.
1053
+ * retry budget cannot drift apart.
900
1054
  */
901
1055
  export const MIN_RETRY_BUDGET_MS = 15_000;
902
1056
 
@@ -90,14 +90,17 @@ export function createApiKeyAuthenticationMiddleware(deps: {
90
90
  );
91
91
  // Abuse ban: refuse before parse/prepare and before authorization is
92
92
  // recorded, so a banned caller cannot keep producing failed rows. The ban
93
- // is keyed on the client address, not the key: one key can be shared by
94
- // every recipient of a share link, so refusing the key would punish
95
- // callers that did nothing.
93
+ // is keyed on the client address *and* the API key: one key can be shared
94
+ // by every recipient of a share link, so refusing the address alone would
95
+ // punish callers that did nothing, while refusing the key alone would let
96
+ // the caller that probed mint a fresh one.
96
97
  if (deps.modelStrikes) {
97
98
  const state = deps.stateStore.get(request);
98
99
  const ip = state?.clientIdentity?.address;
99
100
  if (ip !== undefined) {
100
- const banned = await deps.modelStrikes.check({ ip }).catch(() => false);
101
+ const banned = await deps.modelStrikes
102
+ .check({ ip, apiKeyId: authorization.id })
103
+ .catch(() => false);
101
104
  if (banned) throw modelAbuseBannedError();
102
105
  }
103
106
  }
@@ -22,11 +22,6 @@ const PROCESS_CONCURRENCY_RETRY_MS = 3_000;
22
22
 
23
23
  let inFlight = 0;
24
24
 
25
- /** The current in-flight count. Exposed for tests; not a control surface. */
26
- export function processInFlight(): number {
27
- return inFlight;
28
- }
29
-
30
25
  export function createProcessConcurrencyMiddleware(): Elysia {
31
26
  return new Elysia()
32
27
  .beforeHandle(({ request }) => {
@@ -12,6 +12,7 @@ import { computeTokensPerSec } from "../../observability/token-speed";
12
12
  import { pushStructuredConsoleLog } from "../../observability/log-ring";
13
13
  import type { ConsoleLogLevel, ConsoleLogMetadata } from "../../observability/log-ring";
14
14
  import { isProxyDispatchRoute } from "./body-policy";
15
+ import { notifyRequestCompleted } from "../../observability/request-notify";
15
16
 
16
17
  interface ElysiaBuiltinError {
17
18
  readonly status?: number;
@@ -217,12 +218,12 @@ export function finalizeRequestTelemetry(
217
218
  return;
218
219
  }
219
220
 
220
- // An early rejection never reaches `completeAttempt`, which is the only
221
- // caller that used to write a payload frame — so a 400 from ingress, auth,
222
- // parse, or the preparer produced a request row whose four capture panels
223
- // were all empty, and the one case an operator most wants to read (what did
224
- // the client actually send?) had nothing to read. Capture the ingress body
225
- // and the error the client saw, keyed to the same request id as the row.
221
+ // An early rejection never reaches `completeAttempt`, the caller that writes
222
+ // a payload frame — so a 400 from ingress, auth, parse, or the preparer would
223
+ // produce a request row whose four capture panels were all empty, and the one
224
+ // case an operator most wants to read (what did the client actually send?)
225
+ // would have nothing to read. Capture the ingress body and the error the
226
+ // client saw, keyed to the same request id as the row.
226
227
  if (requestData === undefined && state.ingressBody !== undefined) {
227
228
  void captureEarlyRejectionPayload(state, authorization);
228
229
  }
@@ -235,6 +236,12 @@ export function finalizeRequestTelemetry(
235
236
  tokensPerSec,
236
237
  }),
237
238
  );
239
+ notifyRequestCompleted({
240
+ tenantId: authorization.tenantId,
241
+ requestId: state.requestId,
242
+ status,
243
+ httpStatus: requestStatus,
244
+ });
238
245
  }
239
246
 
240
247
  /** Console-log severity for a terminal request status. */
@@ -28,7 +28,12 @@ import type { CartethyiaDatabase } from "../../persistence/db-handle";
28
28
  import type { RedisClient } from "../../persistence/redis";
29
29
  import type { TelemetryBatchBuffer } from "../../observability/telemetry-buffer";
30
30
  import type { ModelStrikeService } from "../../security/model-abuse";
31
- import { TelemetryPayloadCapture, payloadCaptureKeepsBodies } from "../../observability/payload-capture";
31
+ import {
32
+ TelemetryPayloadCapture,
33
+ payloadCaptureKeepsBodies,
34
+ payloadRetentionMs,
35
+ resolvePayloadCapture,
36
+ } from "../../observability/payload-capture";
32
37
 
33
38
  /** Shared state/context carried by every ordered transport stage. */
34
39
  export interface TransportPipelineContext {
@@ -167,8 +172,15 @@ export function createTransportPipeline(context: TransportPipelineContext): Tran
167
172
  stateStore: context.stateStore,
168
173
  telemetryBuffer: context.telemetry,
169
174
  // One capture for the process, shared with every request state so an
170
- // early rejection can record its payload from the finalize hook.
171
- payloadCapture: new TelemetryPayloadCapture(context.db),
175
+ // early rejection can record its payload from the finalize hook. It
176
+ // resolves the tenant's retention itself because an early rejection has
177
+ // no preference memo on its state — nothing resolved one before the
178
+ // request was refused.
179
+ payloadCapture: new TelemetryPayloadCapture(context.db, (tenantId) =>
180
+ resolvePayloadCapture(context.db, tenantId).then(
181
+ (resolved) => (resolved.ok ? resolved.retentionMs : payloadRetentionMs()),
182
+ ),
183
+ ),
172
184
  });
173
185
  } else {
174
186
  registerRequestCleanup(rootApp, { stateStore: context.stateStore });
@@ -269,7 +269,13 @@ export function createProxyRoutePreparationMiddleware(deps: {
269
269
  throw new GatewayError("admission_unavailable", 503, "The request was not authenticated and parsed, so this route cannot be served. Resend the request.");
270
270
  const strikes = deps.modelStrikes;
271
271
  const ip = state.clientIdentity?.address;
272
- const strikeIdentity = strikes && ip !== undefined ? { strikes, ip } : undefined;
272
+ // The key rides along so a strike is charged to the caller that made it.
273
+ // Without it, one recipient of a shared link could escalate a ban onto
274
+ // every other caller behind the same address.
275
+ const strikeIdentity =
276
+ strikes && ip !== undefined
277
+ ? { strikes, ip, apiKeyId: state.authorization?.id ?? null }
278
+ : undefined;
273
279
  try {
274
280
  state.preparedRequest = await deps.preparer.prepare({
275
281
  canonicalRequest: state.canonicalRequest,
@@ -286,7 +292,7 @@ export function createProxyRoutePreparationMiddleware(deps: {
286
292
  // request, or any other failure is not the caller probing for models.
287
293
  if (strikeIdentity && isModelRejection(error)) {
288
294
  const outcome = await strikeIdentity.strikes
289
- .noteInvalid({ ip: strikeIdentity.ip })
295
+ .noteInvalid({ ip: strikeIdentity.ip, apiKeyId: strikeIdentity.apiKeyId })
290
296
  .catch(() => null);
291
297
  if (outcome?.banned === true) throw modelAbuseBannedError();
292
298
  if (outcome) {
@@ -4,12 +4,11 @@
4
4
  * provider-dispatch leases, keyed by `requestId`, carrying the client IP
5
5
  * captured at ingress and the hard deadline the flight must not outlive.
6
6
  *
7
- * This used to be a process-global module that kept its own counter alongside
8
- * the request state store's `liveControllers` map — two registries that could
9
- * disagree, and did: a flight the store had already torn down could stay
10
- * counted here forever because nothing tied the two together. The registry is
11
- * now *owned* by the `ProxyRequestStateStore` (`state.ts`), which is also the
12
- * one place that evicts a request, so a flight can no longer outlive its state.
7
+ * The registry is *owned* by the `ProxyRequestStateStore` (`state.ts`), which
8
+ * is also the one place that evicts a request, so a flight cannot outlive its
9
+ * state. A separate process-global counter alongside the store's
10
+ * `liveControllers` map would be a second registry able to disagree with it: a
11
+ * flight the store had already torn down would stay counted there forever.
13
12
  *
14
13
  * The store hands each entry an `expiresAtMs` (the request's hard deadline). A
15
14
  * periodic backstop sweep reads `overdue()` and force-releases any flight whose
@@ -241,8 +241,8 @@ export class ProxyRequestPreparer {
241
241
  // than dispatched: a provider rejects the whole request for one corrupt
242
242
  // attachment, and the buddy family reports it with no field named, so the
243
243
  // caller gets a 400 it cannot act on. Running this before capability
244
- // derivation also means a request whose only image was defective no longer
245
- // requires the `image` capability — it plans like the text request it has
244
+ // derivation also means a request whose only image was defective does not
245
+ // require the `image` capability — it plans like the text request it has
246
246
  // become, instead of being routed to a vision route for nothing.
247
247
  const requestWithAttachments = dropCorruptAttachments(request).request;
248
248
  // Continuation affinity is resolved before anything else: a request
@@ -100,22 +100,26 @@ 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 };
120
124
  /**
121
125
  * This request's resolved inline-attachment ceiling, memoized for the same
@@ -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
 
@@ -404,7 +404,7 @@ class RouteCatalogRepository {
404
404
  // The cooldown override is a console_settings preference, read inside
405
405
  // the same fan-out rather than per request: one more row set on a
406
406
  // query that already runs, instead of a Postgres round trip on every
407
- // admitted request the way `tenantConcurrencyLimit` once did.
407
+ // admitted request.
408
408
  this.db
409
409
  .select({ tenantId: consoleSettings.tenantId, preferences: consoleSettings.preferences })
410
410
  .from(consoleSettings),
@@ -747,6 +747,13 @@ class RouteCatalogRepository {
747
747
  // The account is still cooling — this only stops it from being
748
748
  // excluded. `plan()` reads `cooldown_until` to answer the
749
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.
750
757
  candidate.cooldown_until = account.cooldownUntil.getTime();
751
758
  } else {
752
759
  candidate.health_status = "cooldown";
@@ -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
  }