cartethyia 2.7.9 → 2.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (375) hide show
  1. package/.env.example +3 -2
  2. package/dist/dashboard/assets/{BannedPage-BxoG1MrL.js → BannedPage-Cc0vKpMG.js} +1 -1
  3. package/dist/dashboard/assets/BannedPage-Cc0vKpMG.js.gz +0 -0
  4. package/dist/dashboard/assets/{CliToolDetailPage-ixwgKk53.js → CliToolDetailPage-Bqj2aa1T.js} +1 -1
  5. package/dist/dashboard/assets/CliToolDetailPage-Bqj2aa1T.js.gz +0 -0
  6. package/dist/dashboard/assets/{CliToolsPage-BmttNV3U.js → CliToolsPage-CXsaOAO2.js} +1 -1
  7. package/dist/dashboard/assets/CliToolsPage-CXsaOAO2.js.gz +0 -0
  8. package/dist/dashboard/assets/{CombosPage-BE3qwwfC.js → CombosPage-BeLpJ4ma.js} +1 -1
  9. package/dist/dashboard/assets/CombosPage-BeLpJ4ma.js.gz +0 -0
  10. package/dist/dashboard/assets/{ConsoleLogPage-CRZdmMIX.js → ConsoleLogPage-DnV0uZ3m.js} +1 -1
  11. package/dist/dashboard/assets/ConsoleLogPage-DnV0uZ3m.js.gz +0 -0
  12. package/dist/dashboard/assets/{CustomizationPage-Dv_FJ6Er.js → CustomizationPage-CBxAjo63.js} +1 -1
  13. package/dist/dashboard/assets/CustomizationPage-CBxAjo63.js.gz +0 -0
  14. package/dist/dashboard/assets/HealthEventsModal-B5yEE0k6.js +1 -0
  15. package/dist/dashboard/assets/HealthEventsModal-B5yEE0k6.js.gz +0 -0
  16. package/dist/dashboard/assets/{InjectSystemPromptPanel-BOoO8rpz.js → InjectSystemPromptPanel-BWFLECmz.js} +2 -2
  17. package/dist/dashboard/assets/InjectSystemPromptPanel-BWFLECmz.js.gz +0 -0
  18. package/dist/dashboard/assets/{LoginPage-ChJuNNVN.js → LoginPage-CgoPc5B9.js} +1 -1
  19. package/dist/dashboard/assets/LoginPage-CgoPc5B9.js.gz +0 -0
  20. package/dist/dashboard/assets/{ModelPicker-BZAJtPg1.js → ModelPicker-D9bFQern.js} +1 -1
  21. package/dist/dashboard/assets/ModelPicker-D9bFQern.js.gz +0 -0
  22. package/dist/dashboard/assets/{OverviewPage-BE40s3u5.js → OverviewPage-B_p4tL8k.js} +1 -1
  23. package/dist/dashboard/assets/OverviewPage-B_p4tL8k.js.gz +0 -0
  24. package/dist/dashboard/assets/ProviderDetailPage-rs5E2rxv.js +8 -0
  25. package/dist/dashboard/assets/ProviderDetailPage-rs5E2rxv.js.gz +0 -0
  26. package/dist/dashboard/assets/{ProviderIcon-CQ6bPUf_.js → ProviderIcon-D_0PiZtg.js} +1 -1
  27. package/dist/dashboard/assets/ProviderIcon-D_0PiZtg.js.gz +0 -0
  28. package/dist/dashboard/assets/ProvidersPage-CLI2bJTK.js +1 -0
  29. package/dist/dashboard/assets/ProvidersPage-CLI2bJTK.js.gz +0 -0
  30. package/dist/dashboard/assets/{ProxyPage-BJ87Pp0U.js → ProxyPage-BwNC_-Cx.js} +1 -1
  31. package/dist/dashboard/assets/ProxyPage-BwNC_-Cx.js.gz +0 -0
  32. package/dist/dashboard/assets/QuotaPage-DA-a1iSN.js +1 -0
  33. package/dist/dashboard/assets/QuotaPage-DA-a1iSN.js.gz +0 -0
  34. package/dist/dashboard/assets/{SettingsPage-C5HPWqEv.js → SettingsPage-CctTga2C.js} +1 -1
  35. package/dist/dashboard/assets/SettingsPage-CctTga2C.js.gz +0 -0
  36. package/dist/dashboard/assets/{SetupPage-CFNcATJJ.js → SetupPage-febLU4r1.js} +1 -1
  37. package/dist/dashboard/assets/SetupPage-febLU4r1.js.gz +0 -0
  38. package/dist/dashboard/assets/{StudioPage-YoD4tY5l.js → StudioPage-M6fvR5Wz.js} +1 -1
  39. package/dist/dashboard/assets/StudioPage-M6fvR5Wz.js.gz +0 -0
  40. package/dist/dashboard/assets/{UsagePage-DxjaAhb_.js → UsagePage-nyeuRqz9.js} +1 -1
  41. package/dist/dashboard/assets/UsagePage-nyeuRqz9.js.gz +0 -0
  42. package/dist/dashboard/assets/{api-keys-BTlkq_Uk.js → api-keys-BFOlvrJC.js} +1 -1
  43. package/dist/dashboard/assets/api-keys-BFOlvrJC.js.gz +0 -0
  44. package/dist/dashboard/assets/app-Dx2QgsNj.css +2 -0
  45. package/dist/dashboard/assets/app-Dx2QgsNj.css.gz +0 -0
  46. package/dist/dashboard/assets/assert-CqTLw5e7.js +1 -0
  47. package/dist/dashboard/assets/assert-CqTLw5e7.js.gz +0 -0
  48. package/dist/dashboard/assets/{badge-DdbT_7Am.js → badge-C83EqLB0.js} +1 -1
  49. package/dist/dashboard/assets/badge-C83EqLB0.js.gz +0 -0
  50. package/dist/dashboard/assets/{banner-CsGN-nbm.js → banner-D1xn7pIe.js} +1 -1
  51. package/dist/dashboard/assets/banner-D1xn7pIe.js.gz +0 -0
  52. package/dist/dashboard/assets/{card-mgZ_TLLZ.js → card-DU3Lq5Lm.js} +1 -1
  53. package/dist/dashboard/assets/card-DU3Lq5Lm.js.gz +0 -0
  54. package/dist/dashboard/assets/{cli-tools-A1-v8-03.js → cli-tools-Dw1U_drK.js} +1 -1
  55. package/dist/dashboard/assets/cli-tools-Dw1U_drK.js.gz +0 -0
  56. package/dist/dashboard/assets/{clipboard-button-CJ4Zty89.js → clipboard-button-CEL5Sg_J.js} +1 -1
  57. package/dist/dashboard/assets/clipboard-button-CEL5Sg_J.js.gz +0 -0
  58. package/dist/dashboard/assets/{dialog-B5L2jPQ_.js → dialog-BjFR5UHy.js} +1 -1
  59. package/dist/dashboard/assets/dialog-BjFR5UHy.js.gz +0 -0
  60. package/dist/dashboard/assets/{drawer-DkjUouAj.js → drawer-HvBW0AjI.js} +1 -1
  61. package/dist/dashboard/assets/drawer-HvBW0AjI.js.gz +0 -0
  62. package/dist/dashboard/assets/entry-BSu3gFlt.js +4 -0
  63. package/dist/dashboard/assets/entry-BSu3gFlt.js.gz +0 -0
  64. package/dist/dashboard/assets/{entry-B-LVLV6o.js → entry-CECVELyy.js} +1 -1
  65. package/dist/dashboard/assets/entry-CECVELyy.js.gz +0 -0
  66. package/dist/dashboard/assets/{entry-CAhdYl3B.js → entry-DYYofQ7W.js} +1 -1
  67. package/dist/dashboard/assets/entry-DYYofQ7W.js.gz +0 -0
  68. package/dist/dashboard/assets/{formatters-CrsdLVsn.js → formatters-DLW4i8zK.js} +1 -1
  69. package/dist/dashboard/assets/formatters-DLW4i8zK.js.gz +0 -0
  70. package/dist/dashboard/assets/{index-BU64Lqvp.js → index-1-fCqe64.js} +2 -2
  71. package/dist/dashboard/assets/index-1-fCqe64.js.gz +0 -0
  72. package/dist/dashboard/assets/{inline-B2aDLyyg.js → inline-DGvU2oLK.js} +1 -1
  73. package/dist/dashboard/assets/inline-DGvU2oLK.js.gz +0 -0
  74. package/dist/dashboard/assets/{input-BCVkdR9d.js → input-iNnO1X2K.js} +1 -1
  75. package/dist/dashboard/assets/input-iNnO1X2K.js.gz +0 -0
  76. package/dist/dashboard/assets/{layout-DNcAdMK-.js → layout-0q8UGypb.js} +1 -1
  77. package/dist/dashboard/assets/layout-0q8UGypb.js.gz +0 -0
  78. package/dist/dashboard/assets/{list-row-DgrIH0hw.js → list-row-C0w2bQjJ.js} +1 -1
  79. package/dist/dashboard/assets/list-row-C0w2bQjJ.js.gz +0 -0
  80. package/dist/dashboard/assets/{live-Cae01iu8.js → live-M5aJ_xCa.js} +1 -1
  81. package/dist/dashboard/assets/live-M5aJ_xCa.js.gz +0 -0
  82. package/dist/dashboard/assets/{network-CRib7H_2.js → network-BKeQGT3K.js} +1 -1
  83. package/dist/dashboard/assets/network-BKeQGT3K.js.gz +0 -0
  84. package/dist/dashboard/assets/{numbers-BKQE8Xpz.js → numbers-CivAk3da.js} +1 -1
  85. package/dist/dashboard/assets/numbers-CivAk3da.js.gz +0 -0
  86. package/dist/dashboard/assets/{pagination-ecPvG_8g.js → pagination-CpzvI5ep.js} +1 -1
  87. package/dist/dashboard/assets/pagination-CpzvI5ep.js.gz +0 -0
  88. package/dist/dashboard/assets/providers/zro.svg +3 -0
  89. package/dist/dashboard/assets/providers/zro.svg.gz +0 -0
  90. package/dist/dashboard/assets/quota-DiPD6UKH.js +1 -0
  91. package/dist/dashboard/assets/quota-DiPD6UKH.js.gz +0 -0
  92. package/dist/dashboard/assets/{routing-Ckm1UXuC.js → routing-BFQX2VBc.js} +1 -1
  93. package/dist/dashboard/assets/routing-BFQX2VBc.js.gz +0 -0
  94. package/dist/dashboard/assets/{select-ChoFWyKz.js → select-CXbck0jG.js} +2 -2
  95. package/dist/dashboard/assets/select-CXbck0jG.js.gz +0 -0
  96. package/dist/dashboard/assets/{settings-CKjFPlKW.js → settings-C_XvMHh9.js} +1 -1
  97. package/dist/dashboard/assets/settings-C_XvMHh9.js.gz +0 -0
  98. package/dist/dashboard/assets/{stack-DWCwU3fh.js → stack-DggRiCxE.js} +1 -1
  99. package/dist/dashboard/assets/stack-DggRiCxE.js.gz +0 -0
  100. package/dist/dashboard/assets/{state-Cg3VL5J_.js → state-ClFzfbYp.js} +1 -1
  101. package/dist/dashboard/assets/state-ClFzfbYp.js.gz +0 -0
  102. package/dist/dashboard/assets/{switch-Db70BMix.js → switch-CTIFxKqS.js} +1 -1
  103. package/dist/dashboard/assets/switch-CTIFxKqS.js.gz +0 -0
  104. package/dist/dashboard/assets/{useMutation-BqdE0LuM.js → useMutation-BClG3yWj.js} +1 -1
  105. package/dist/dashboard/assets/useMutation-BClG3yWj.js.gz +0 -0
  106. package/dist/dashboard/index.html +2 -2
  107. package/dist/dashboard/index.html.gz +0 -0
  108. package/dist/dashboard/precompressed-assets.json +54 -53
  109. package/migrations/full-postgres/0046_add_accounts_quota_plan_fetched_at.sql +7 -0
  110. package/migrations/lite-sqlite3/0046_add_accounts_quota_plan_fetched_at.sql +4 -0
  111. package/package.json +1 -1
  112. package/src/boot/app.ts +10 -13
  113. package/src/boot/dependencies.ts +1 -1
  114. package/src/boot/flush.ts +20 -0
  115. package/src/boot/lifecycle.ts +7 -5
  116. package/src/config/routing.ts +26 -0
  117. package/src/config/spec-metadata.ts +72 -0
  118. package/src/config/spec.ts +20 -0
  119. package/src/console/api-keys/contracts.ts +12 -1
  120. package/src/console/api-keys/routes.ts +40 -28
  121. package/src/console/auth/account-reset.ts +7 -9
  122. package/src/console/backup/contracts.ts +4 -0
  123. package/src/console/backup/export-endpoint.ts +7 -5
  124. package/src/console/cli-tools/service.ts +6 -12
  125. package/src/console/console-router-guards.ts +23 -16
  126. package/src/console/domain-registration.ts +0 -2
  127. package/src/console/inferhub/normalize.ts +24 -28
  128. package/src/console/inferhub/types.ts +3 -0
  129. package/src/console/ip-bans/routes.ts +7 -13
  130. package/src/console/observability/store.ts +0 -53
  131. package/src/console/providers/catalog/contracts.ts +2 -2
  132. package/src/console/providers/catalog/discover-constants.ts +6 -0
  133. package/src/console/providers/catalog/routes.ts +1 -0
  134. package/src/console/providers/catalog/store.ts +21 -16
  135. package/src/console/quota/account-quota-global.ts +14 -24
  136. package/src/console/quota/account-quota-shared.ts +49 -0
  137. package/src/console/quota/account-quota-tenant.ts +6 -18
  138. package/src/console/quota/quota-view.ts +19 -3
  139. package/src/console/routing/pools/operations.ts +31 -46
  140. package/src/console/shared/errors.ts +33 -6
  141. package/src/main.ts +2 -3
  142. package/src/network/pool/byte-accounting.ts +3 -2
  143. package/src/network/pool/selector.ts +182 -39
  144. package/src/network/pool-cooldown-store.ts +89 -14
  145. package/src/network/pool-health.ts +49 -61
  146. package/src/network/rate-limit-verdict.ts +1 -1
  147. package/src/network/ssrf.ts +6 -6
  148. package/src/observability/event-log.ts +7 -361
  149. package/src/observability/performance-metrics.ts +9 -0
  150. package/src/observability/runtime-metrics.ts +14 -9
  151. package/src/observability/telemetry-buffer.ts +15 -25
  152. package/src/observability/telemetry-reconciliation.ts +9 -37
  153. package/src/observability/telemetry-row.ts +125 -0
  154. package/src/persistence/db-mode.ts +4 -1
  155. package/src/persistence/dialect.ts +18 -0
  156. package/src/persistence/migrate.ts +6 -3
  157. package/src/persistence/schema.ts +8 -0
  158. package/src/persistence/ttl-cache.ts +20 -2
  159. package/src/protocol/codecs.ts +1 -1
  160. package/src/protocol/dsml.ts +34 -86
  161. package/src/protocol/markup-stream-parser.ts +195 -0
  162. package/src/protocol/reasoning-text.ts +19 -0
  163. package/src/protocol/request/chat.ts +10 -10
  164. package/src/protocol/request/gemini.ts +8 -2
  165. package/src/protocol/request/messages.ts +3 -4
  166. package/src/protocol/response/chat.ts +12 -20
  167. package/src/protocol/response/omp-tool-call-markup.ts +43 -86
  168. package/src/protocol/response/responses.ts +18 -2
  169. package/src/protocol/stop-reason.ts +23 -1
  170. package/src/protocol/think-split.ts +112 -0
  171. package/src/protocol/translation/dialect/qwen.ts +26 -58
  172. package/src/providers/adapters/compatible-adapter.ts +71 -12
  173. package/src/providers/authentication/oauth-client.ts +1 -1
  174. package/src/providers/authentication/oauth-device-flow.ts +98 -8
  175. package/src/providers/authentication/oauth-refresh-service.ts +30 -49
  176. package/src/providers/configured-provider.ts +148 -0
  177. package/src/providers/default-registry.ts +61 -36
  178. package/src/providers/discovery/probe-phases.ts +14 -4
  179. package/src/providers/discovery/probing-service.ts +15 -2
  180. package/src/providers/integrations/alibabacp/alibabacp-models.ts +1 -2
  181. package/src/providers/integrations/{dashscope-models.ts → alibabacp/dashscope-models.ts} +2 -2
  182. package/src/providers/integrations/antigravity/antigravity-protocol.ts +5 -1
  183. package/src/providers/integrations/buddy/buddy-chat-shared.ts +173 -17
  184. package/src/providers/integrations/buddy/buddy-identity.ts +19 -6
  185. package/src/providers/integrations/buddy/capabilities.ts +2 -2
  186. package/src/providers/integrations/buddy/codebuddy/cn-provider.ts +3 -0
  187. package/src/providers/integrations/buddy/codebuddy/provider.ts +4 -0
  188. package/src/providers/integrations/buddy/codebuddy/shared.ts +37 -22
  189. package/src/providers/integrations/claude/claude-quota.ts +14 -17
  190. package/src/providers/integrations/cline/cline.ts +2 -2
  191. package/src/providers/integrations/commandcode/quota.ts +22 -28
  192. package/src/providers/integrations/cursor/cursor-auth.ts +0 -8
  193. package/src/providers/integrations/cursor/cursor-exec.ts +9 -2
  194. package/src/providers/integrations/cursor/cursor-request.ts +26 -12
  195. package/src/providers/integrations/cursor/cursor-shared.ts +7 -4
  196. package/src/providers/integrations/cursor/cursor.ts +89 -40
  197. package/src/providers/integrations/cursor/protocol/messages.ts +22 -29
  198. package/src/providers/integrations/cursor/protocol/protobuf-schema.ts +37 -0
  199. package/src/providers/integrations/devin/protocol/devin-proto.ts +1 -27
  200. package/src/providers/integrations/factory/factory-oauth.ts +17 -44
  201. package/src/providers/integrations/freebuff/assets/official-system-manager.txt +88 -0
  202. package/src/providers/integrations/freebuff/assets/official-system-worker.txt +57 -0
  203. package/src/providers/integrations/freebuff/assets/official-tools.json +1070 -0
  204. package/src/providers/integrations/freebuff/freebuff-catalog.ts +120 -0
  205. package/src/providers/integrations/freebuff/freebuff-constants.ts +1233 -0
  206. package/src/providers/integrations/freebuff/freebuff-discovery.ts +108 -0
  207. package/src/providers/integrations/freebuff/freebuff-oauth.ts +240 -0
  208. package/src/providers/integrations/freebuff/freebuff-quota.ts +260 -0
  209. package/src/providers/integrations/freebuff/freebuff-virtual.ts +89 -0
  210. package/src/providers/integrations/freebuff/freebuff.ts +682 -0
  211. package/src/providers/integrations/grok/grok-oauth.ts +48 -45
  212. package/src/providers/integrations/grok/grok-ratelimit-store.ts +10 -17
  213. package/src/providers/integrations/grok/grok-turn-index.ts +11 -16
  214. package/src/providers/integrations/{hermes.ts → hermes/hermes.ts} +9 -9
  215. package/src/providers/integrations/hermes/index.ts +2 -0
  216. package/src/providers/integrations/kiro/kiro-catalog.ts +17 -1
  217. package/src/providers/integrations/kiro/kiro-discovery.ts +10 -12
  218. package/src/providers/integrations/kiro/kiro-quota.ts +8 -4
  219. package/src/providers/integrations/kiro/kiro.ts +15 -19
  220. package/src/providers/integrations/ollamacloud/index.ts +2 -0
  221. package/src/providers/integrations/{ollamacloud-models.ts → ollamacloud/models.ts} +2 -2
  222. package/src/providers/integrations/{ollamacloud-quota.ts → ollamacloud/quota.ts} +3 -3
  223. package/src/providers/integrations/opencode/opencode-go-quota.ts +2 -8
  224. package/src/providers/integrations/perch/perch-credential.ts +25 -0
  225. package/src/providers/integrations/perch/perch-protocol.ts +0 -3
  226. package/src/providers/integrations/perch/perch-quota.ts +2 -16
  227. package/src/providers/integrations/perch/perch.ts +3 -13
  228. package/src/providers/integrations/qoder/claim-qoder-credits.ts +18 -59
  229. package/src/providers/integrations/qoder/qoder-adapter.ts +1 -12
  230. package/src/providers/integrations/qoder/qoder-auth.ts +101 -29
  231. package/src/providers/integrations/qoder/qoder-catalog.ts +20 -5
  232. package/src/providers/integrations/qoder/qoder-chat.ts +7 -21
  233. package/src/providers/integrations/qoder/qoder-checkin.ts +4 -4
  234. package/src/providers/integrations/qoder/qoder-grpc.ts +0 -1
  235. package/src/providers/integrations/qoder/qoder-ide.ts +0 -107
  236. package/src/providers/integrations/qoder/qoder.ts +0 -1
  237. package/src/providers/integrations/trae/trae-protocol.ts +9 -5
  238. package/src/providers/integrations/trae/trae-quota.ts +6 -28
  239. package/src/providers/integrations/trae/trae-realm.ts +38 -0
  240. package/src/providers/integrations/trae/trae.ts +7 -24
  241. package/src/providers/integrations/zcode-start-plan/provider.ts +226 -23
  242. package/src/providers/integrations/zcode-start-plan/quota.ts +19 -2
  243. package/src/providers/integrations/zro/zro.ts +204 -0
  244. package/src/providers/operations/account-error-classification.ts +199 -307
  245. package/src/providers/operations/account-health-service.ts +82 -1
  246. package/src/providers/operations/account-refresh-status.ts +22 -37
  247. package/src/providers/operations/account-tier.ts +15 -16
  248. package/src/providers/operations/client-version-resolver.ts +1 -1
  249. package/src/providers/operations/client-versions.ts +5 -51
  250. package/src/providers/operations/cooldown-rules.ts +11 -16
  251. package/src/providers/operations/{model-catalog-cache.ts → provider-caches.ts} +68 -16
  252. package/src/providers/operations/provider-catalog-service.ts +1 -1
  253. package/src/providers/operations/provider-credential-service.ts +102 -32
  254. package/src/providers/operations/provider-rules/github.ts +48 -0
  255. package/src/providers/operations/provider-rules/grok.ts +33 -0
  256. package/src/providers/operations/provider-rules/inception.ts +49 -0
  257. package/src/providers/operations/provider-rules/index.ts +32 -0
  258. package/src/providers/operations/provider-rules/perch.ts +37 -0
  259. package/src/providers/operations/provider-rules/qoder.ts +70 -0
  260. package/src/providers/operations/provider-rules/trae.ts +81 -0
  261. package/src/providers/provider-contracts.ts +4 -2
  262. package/src/providers/{provider-credential-resolver.ts → provider-credentials.ts} +40 -3
  263. package/src/providers/provider-metadata.ts +40 -10
  264. package/src/providers/provider-probe.ts +15 -1
  265. package/src/providers/provider-registry.ts +42 -33
  266. package/src/providers/quota/quota-limit-windows.ts +16 -5
  267. package/src/providers/quota/quota-support.ts +1 -1
  268. package/src/providers/quota/quota-window-parser.ts +90 -18
  269. package/src/providers/quota/refresh.ts +75 -18
  270. package/src/security/api-key-auth-cache.ts +1 -1
  271. package/src/security/api-key-auth.ts +6 -2
  272. package/src/transport/dispatch/attempt-finalize.ts +14 -13
  273. package/src/transport/dispatch/attempt-loop.ts +71 -17
  274. package/src/transport/dispatch/generation.ts +2 -8
  275. package/src/transport/dispatch/native-shared.ts +25 -0
  276. package/src/transport/dispatch/proxy-request.ts +8 -46
  277. package/src/transport/dispatch/responses-compact.ts +2 -8
  278. package/src/transport/dispatch/streaming/finalize.ts +9 -6
  279. package/src/transport/dispatch/streaming/stream.ts +8 -36
  280. package/src/transport/dispatch/systemone.ts +2 -8
  281. package/src/transport/dispatch/token-count.ts +2 -6
  282. package/src/transport/dispatch/upstream.ts +63 -0
  283. package/src/transport/drain-endpoint.ts +31 -4
  284. package/src/transport/failure-policy.ts +16 -30
  285. package/src/transport/guards/body-policy.ts +7 -1
  286. package/src/transport/guards/error-lifecycle.ts +13 -1
  287. package/src/transport/guards/request-context.ts +22 -14
  288. package/src/transport/request/preparer.ts +81 -71
  289. package/src/transport/routing/bounded-lru.ts +14 -0
  290. package/src/transport/routing/drain-reserve.ts +129 -0
  291. package/src/transport/routing/plan-gate.ts +7 -1
  292. package/src/transport/routing/round-robin.ts +60 -0
  293. package/src/transport/routing/route-catalog.ts +123 -111
  294. package/src/transport/routing/route-model.ts +51 -9
  295. package/src/transport/routing/router.ts +393 -32
  296. package/src/transport/surface/adapters.ts +11 -2
  297. package/src/transport/surface/chat/adapter.ts +2 -2
  298. package/src/transport/surface/chat/encode.ts +47 -4
  299. package/src/transport/surface/chat/parse.ts +2 -12
  300. package/src/transport/surface/completion.ts +44 -33
  301. package/src/transport/surface/content-parts.ts +21 -0
  302. package/src/transport/surface/messages/adapter.ts +7 -1
  303. package/src/transport/surface/messages/encode.ts +11 -3
  304. package/src/transport/surface/messages/parse.ts +2 -1
  305. package/src/transport/surface/messages/stream.ts +33 -17
  306. package/src/transport/surface/responses/encode.ts +22 -5
  307. package/src/transport/surface/responses/parse.ts +3 -11
  308. package/dist/dashboard/assets/BannedPage-BxoG1MrL.js.gz +0 -0
  309. package/dist/dashboard/assets/CliToolDetailPage-ixwgKk53.js.gz +0 -0
  310. package/dist/dashboard/assets/CliToolsPage-BmttNV3U.js.gz +0 -0
  311. package/dist/dashboard/assets/CombosPage-BE3qwwfC.js.gz +0 -0
  312. package/dist/dashboard/assets/ConsoleLogPage-CRZdmMIX.js.gz +0 -0
  313. package/dist/dashboard/assets/CustomizationPage-Dv_FJ6Er.js.gz +0 -0
  314. package/dist/dashboard/assets/HealthEventsModal-CoVrw2sM.js +0 -1
  315. package/dist/dashboard/assets/HealthEventsModal-CoVrw2sM.js.gz +0 -0
  316. package/dist/dashboard/assets/InjectSystemPromptPanel-BOoO8rpz.js.gz +0 -0
  317. package/dist/dashboard/assets/LoginPage-ChJuNNVN.js.gz +0 -0
  318. package/dist/dashboard/assets/ModelPicker-BZAJtPg1.js.gz +0 -0
  319. package/dist/dashboard/assets/OverviewPage-BE40s3u5.js.gz +0 -0
  320. package/dist/dashboard/assets/ProviderDetailPage-sfLoQtPV.js +0 -8
  321. package/dist/dashboard/assets/ProviderDetailPage-sfLoQtPV.js.gz +0 -0
  322. package/dist/dashboard/assets/ProviderIcon-CQ6bPUf_.js.gz +0 -0
  323. package/dist/dashboard/assets/ProvidersPage-DcepmPWo.js +0 -1
  324. package/dist/dashboard/assets/ProvidersPage-DcepmPWo.js.gz +0 -0
  325. package/dist/dashboard/assets/ProxyPage-BJ87Pp0U.js.gz +0 -0
  326. package/dist/dashboard/assets/QuotaPage-gkSyRAKa.js +0 -1
  327. package/dist/dashboard/assets/QuotaPage-gkSyRAKa.js.gz +0 -0
  328. package/dist/dashboard/assets/SettingsPage-C5HPWqEv.js.gz +0 -0
  329. package/dist/dashboard/assets/SetupPage-CFNcATJJ.js.gz +0 -0
  330. package/dist/dashboard/assets/StudioPage-YoD4tY5l.js.gz +0 -0
  331. package/dist/dashboard/assets/UsagePage-DxjaAhb_.js.gz +0 -0
  332. package/dist/dashboard/assets/api-keys-BTlkq_Uk.js.gz +0 -0
  333. package/dist/dashboard/assets/app-BX2nnKqs.css +0 -2
  334. package/dist/dashboard/assets/app-BX2nnKqs.css.gz +0 -0
  335. package/dist/dashboard/assets/assert-CndthXFQ.js +0 -1
  336. package/dist/dashboard/assets/assert-CndthXFQ.js.gz +0 -0
  337. package/dist/dashboard/assets/badge-DdbT_7Am.js.gz +0 -0
  338. package/dist/dashboard/assets/banner-CsGN-nbm.js.gz +0 -0
  339. package/dist/dashboard/assets/card-mgZ_TLLZ.js.gz +0 -0
  340. package/dist/dashboard/assets/cli-tools-A1-v8-03.js.gz +0 -0
  341. package/dist/dashboard/assets/clipboard-button-CJ4Zty89.js.gz +0 -0
  342. package/dist/dashboard/assets/dialog-B5L2jPQ_.js.gz +0 -0
  343. package/dist/dashboard/assets/drawer-DkjUouAj.js.gz +0 -0
  344. package/dist/dashboard/assets/entry-B-LVLV6o.js.gz +0 -0
  345. package/dist/dashboard/assets/entry-CAhdYl3B.js.gz +0 -0
  346. package/dist/dashboard/assets/entry-D2xD0Tmd.js +0 -4
  347. package/dist/dashboard/assets/entry-D2xD0Tmd.js.gz +0 -0
  348. package/dist/dashboard/assets/formatters-CrsdLVsn.js.gz +0 -0
  349. package/dist/dashboard/assets/index-BU64Lqvp.js.gz +0 -0
  350. package/dist/dashboard/assets/inline-B2aDLyyg.js.gz +0 -0
  351. package/dist/dashboard/assets/input-BCVkdR9d.js.gz +0 -0
  352. package/dist/dashboard/assets/layout-DNcAdMK-.js.gz +0 -0
  353. package/dist/dashboard/assets/list-row-DgrIH0hw.js.gz +0 -0
  354. package/dist/dashboard/assets/live-Cae01iu8.js.gz +0 -0
  355. package/dist/dashboard/assets/network-CRib7H_2.js.gz +0 -0
  356. package/dist/dashboard/assets/numbers-BKQE8Xpz.js.gz +0 -0
  357. package/dist/dashboard/assets/pagination-ecPvG_8g.js.gz +0 -0
  358. package/dist/dashboard/assets/quota-DJ1QlAOq.js +0 -1
  359. package/dist/dashboard/assets/quota-DJ1QlAOq.js.gz +0 -0
  360. package/dist/dashboard/assets/routing-Ckm1UXuC.js.gz +0 -0
  361. package/dist/dashboard/assets/select-ChoFWyKz.js.gz +0 -0
  362. package/dist/dashboard/assets/settings-CKjFPlKW.js.gz +0 -0
  363. package/dist/dashboard/assets/stack-DWCwU3fh.js.gz +0 -0
  364. package/dist/dashboard/assets/state-Cg3VL5J_.js.gz +0 -0
  365. package/dist/dashboard/assets/switch-Db70BMix.js.gz +0 -0
  366. package/dist/dashboard/assets/useMutation-BqdE0LuM.js.gz +0 -0
  367. package/src/providers/bundled-discovery-providers.ts +0 -98
  368. package/src/providers/configured-openai-providers.ts +0 -120
  369. package/src/providers/operations/model-discovery-cache.ts +0 -34
  370. package/src/providers/operations/probe-fetch.ts +0 -15
  371. package/src/providers/operations/provider-version-cache.ts +0 -30
  372. package/src/providers/provider-capabilities.ts +0 -33
  373. package/src/providers/provider-credential-envelope.ts +0 -43
  374. /package/dist/dashboard/assets/{app-B7kIWFm5.js → app-DIXLjojg.js} +0 -0
  375. /package/dist/dashboard/assets/{app-B7kIWFm5.js.gz → app-DIXLjojg.js.gz} +0 -0
@@ -23,23 +23,17 @@ import {
23
23
  resolveAccountTransientCooldownMs,
24
24
  resolveAccountUnclassifiedCooldownMs,
25
25
  } from "../../config/cooldowns";
26
+ import {
27
+ githubRules,
28
+ qoderPlanGatedRule,
29
+ qoderModelQuotaRule,
30
+ traeRules,
31
+ inceptionRules,
32
+ perchRules,
33
+ isGrokFreeTierExhaustion,
34
+ GROK_QUOTA_COOLDOWN_MS,
35
+ } from "./provider-rules";
26
36
  const DEFAULT_RATE_LIMIT_COOLDOWN_MS = resolveAccountRateLimitCooldownMs;
27
- /**
28
- * Perch's `turn_rate_limited` window. The upstream does not state a reset and
29
- * answers the same on every ticket mint while the window is exhausted, so a
30
- * day matches the practical cadence and stops the redial loop.
31
- */
32
- const PERCH_TURN_RATE_LIMIT_COOLDOWN_MS = 24 * 60 * 60 * 1000;
33
- /**
34
- * Inception's per-minute limit window (1 minute).
35
- *
36
- * Inception's Free tier throttles per minute (1 000 requests / 1M input
37
- * tokens / 100K output tokens) and the window resets every minute. The generic
38
- * 15-minute rate-limit fallback would park a healthy key long after the window
39
- * refilled; 1 minute is the provider's own cadence, hardcoded because it is a
40
- * fact about the upstream, not a deployment knob.
41
- */
42
- const INCEPTION_RATE_LIMIT_COOLDOWN_MS = 60_000;
43
37
  // Fallback when the error states no usable duration: deliberately short (1h,
44
38
  // not 24h) — quota state is re-probed on the next failure, so an unknown
45
39
  // reset cadence self-corrects instead of parking the account for a day.
@@ -58,32 +52,6 @@ export const COOLDOWN_RECOVERY_BATCH = 500;
58
52
  * account returns to rotation quickly.
59
53
  */
60
54
  const UNCLASSIFIED_COOLDOWN_MS = resolveAccountUnclassifiedCooldownMs;
61
- /**
62
- * xAI Grok Build's free tier resets on a rolling 24-hour window, so its
63
- * exhaustion is a full-day quota cooldown — never the generic 1h fallback,
64
- * which let the account re-enter rotation inside the window.
65
- */
66
- const GROK_QUOTA_COOLDOWN_MS = 24 * 60 * 60 * 1000;
67
- /**
68
- * Trae answers `4013 "AI service is not available in the current region now."`
69
- * when the upstream does not serve the account's region. A day because the
70
- * verdict is durable: it reproduces on every request until the region opens,
71
- * and the account must not be re-dialled every thirty seconds for the whole
72
- * window.
73
- */
74
- const TRAE_REGION_COOLDOWN_MS = 24 * 60 * 60 * 1000;
75
- /**
76
- * Trae's free-tier quota (`4008` / "exceeded the quota") resets daily, so its
77
- * exhaustion is a full-day model backoff — never the generic 1h fallback,
78
- * which let the account re-enter rotation inside the window.
79
- */
80
- const TRAE_QUOTA_COOLDOWN_MS = 24 * 60 * 60 * 1000;
81
- /**
82
- * Qoder's quota verdicts (`110` billing-daily-count, the upstream-499 close)
83
- * are per-model: one model's spend cap does not drain another. A day matches
84
- * the daily billing window the provider names.
85
- */
86
- const QODER_QUOTA_COOLDOWN_MS = 24 * 60 * 60 * 1000;
87
55
  /**
88
56
  * The buddy family's policy/channel block (`11140`, `11128`) is a *base* backoff,
89
57
  * not a flat penalty — see {@link resolveAccountPolicyBlockCooldownMs}. It used
@@ -186,12 +154,6 @@ export type AccountErrorCategory =
186
154
  | "model_capacity"
187
155
  | "model_unreachable"
188
156
  | "auth_invalidated"
189
- /**
190
- * Deprecated: an upstream ban. Kept so existing health rows keep rendering
191
- * instead of showing a blank cause. No longer produced — see
192
- * `policy_blocked_banned` and `policy_blocked_invalid_request`.
193
- */
194
- | "policy_blocked"
195
157
  /**
196
158
  * The upstream banned this credential: a verdict on the account, e.g. buddy
197
159
  * `11140` "request illegal" with "Your account has been restricted due to
@@ -303,6 +265,15 @@ export interface AccountErrorClassification {
303
265
  * reads it: a provider-stated window is the truth and is never multiplied.
304
266
  */
305
267
  readonly providerStated?: boolean;
268
+ /**
269
+ * True when the classification is a verdict on the *credential* (not a
270
+ * transient backoff): the request should rotate to a sibling account now
271
+ * instead of waiting out the cooldown on this one. Usage-limit exhaustion,
272
+ * spending caps, and banned/revoked credentials rotate; per-minute
273
+ * throttles (TPM/RPM), model-capacity overload, and concurrency caps back
274
+ * off on the same account instead.
275
+ */
276
+ readonly rotateAccount?: boolean;
306
277
  /**
307
278
  * True when the upstream reported the model as one it does not serve (the
308
279
  * buddy family's `11102`), rather than one this account merely cannot reach.
@@ -312,6 +283,12 @@ export interface AccountErrorClassification {
312
283
  * apart — it needs to know whether to count the hit and escalate the window.
313
284
  */
314
285
  readonly modelUnsupported?: boolean;
286
+ /**
287
+ * This quota verdict closes only the requested model on an otherwise usable
288
+ * account. Kept separate from `category`: quota exhaustion remains the
289
+ * operator-facing cause, while the writer routes it through modelCooldowns.
290
+ */
291
+ readonly modelScoped?: boolean;
315
292
  /**
316
293
  * Where the cooldown duration came from (D18). A duration resolved from a
317
294
  * tier/provider rule (`provider-rule`) is a policy decision and must NOT go
@@ -499,26 +476,6 @@ function banWording(lower: string): boolean {
499
476
  );
500
477
  }
501
478
 
502
- /**
503
- * Whether GitHub Copilot stated the account lacks a license — a ban-shaped
504
- * verdict on the account, not a refreshable auth rejection.
505
- *
506
- * Observed live (2026-11-03): the health log showed
507
- * `unauthorized: not licensed to use Copilot` and `forbidden` on
508
- * `gpt-6-luna`. Copilot answers 401 for a bad credential and 403 for a
509
- * license/entitlement refusal, so `forbidden`/`unauthorized` on this surface
510
- * is the same verdict. No amount of waiting or refreshing changes it — the
511
- * subscription cannot serve this credential.
512
- */
513
- function copilotNotLicensed(lower: string): boolean {
514
- return (
515
- lower.includes("not licensed") ||
516
- lower.includes("forbidden") ||
517
- lower.includes("unauthorized") ||
518
- lower.includes("license")
519
- );
520
- }
521
-
522
479
  /**
523
480
  * Bounded to a day, matching `MAX_COOLDOWN_MS` in the failure policy: a stated
524
481
  * window longer than that is a monthly or plan-level limit, and parking an
@@ -537,6 +494,19 @@ function tryParseUpstreamBody(text: string): unknown {
537
494
  }
538
495
  }
539
496
 
497
+ /** Reads `error.message` from a parsed upstream body object. */
498
+ function nestedErrorMessage(body: unknown): string {
499
+ if (typeof body !== "object" || body === null) return "";
500
+ const record = body as Record<string, unknown>;
501
+ if (typeof record["message"] === "string") return record["message"];
502
+ const error = record["error"];
503
+ if (typeof error === "object" && error !== null) {
504
+ const message = (error as Record<string, unknown>)["message"];
505
+ if (typeof message === "string") return message;
506
+ }
507
+ return "";
508
+ }
509
+
540
510
  /**
541
511
  * Reads a reset window the upstream stated in structured fields.
542
512
  *
@@ -550,13 +520,26 @@ function tryParseUpstreamBody(text: string): unknown {
550
520
  * (`resets_at`, seconds or milliseconds) and a relative delay
551
521
  * (`resets_in_seconds`). The later of the two wins, since they are the same
552
522
  * claim computed at slightly different instants.
523
+ *
524
+ * Structured Google rpc error details are read too — Vertex/Antigravity returns
525
+ * the reset inside `details[]` as `ErrorInfo.metadata.quotaResetDelay` /
526
+ * `quotaResetTimeStamp` and `RetryInfo.retryDelay` (a Go duration like
527
+ * `596805.017558959s`). Without this a "Resets in 165h46m45s" refusal cooled
528
+ * for the 15-minute fallback and re-entered rotation hours before it could
529
+ * serve. The prose `message` is parsed through `parseProviderResetDuration` as
530
+ * a last resort, because several providers state the window only there.
553
531
  */
554
532
  function parseUpstreamBodyReset(body: unknown): number | null {
555
533
  if (typeof body !== "object" || body === null) return null;
556
534
  const record = body as Record<string, unknown>;
557
535
  // Some providers nest under `error`, some return it at the top level.
558
- const candidates = [record, (record["error"] as Record<string, unknown> | undefined) ?? undefined];
536
+ const errorRecord = (record["error"] as Record<string, unknown> | undefined) ?? undefined;
537
+ const candidates = [record, errorRecord];
559
538
  let best: number | null = null;
539
+ const consider = (delay: number | null | undefined): void => {
540
+ if (typeof delay !== "number" || !Number.isFinite(delay) || delay <= 0) return;
541
+ best = best === null ? delay : Math.max(best, delay);
542
+ };
560
543
  for (const candidate of candidates) {
561
544
  if (typeof candidate !== "object" || candidate === null) continue;
562
545
  const now = Date.now();
@@ -567,8 +550,7 @@ function parseUpstreamBodyReset(body: unknown): number | null {
567
550
  // milliseconds ~13, and the two are four orders of magnitude apart — so
568
551
  // the smaller is scaled up rather than guessed from a threshold date.
569
552
  const epochMs = absolute < 1e11 ? absolute * 1000 : absolute;
570
- const delay = epochMs - now;
571
- if (delay > 0) best = best === null ? delay : Math.max(best, delay);
553
+ consider(epochMs - now);
572
554
  }
573
555
 
574
556
  const relative =
@@ -576,19 +558,69 @@ function parseUpstreamBodyReset(body: unknown): number | null {
576
558
  candidate["reset_in_seconds"] ??
577
559
  candidate["retry_after_seconds"];
578
560
  if (typeof relative === "number" && Number.isFinite(relative) && relative > 0) {
579
- const delay = relative * 1000;
580
- best = best === null ? delay : Math.max(best, delay);
561
+ consider(relative * 1000);
581
562
  }
582
563
 
583
564
  const minutes = candidate["limit_window_minutes"] ?? candidate["reset_in_minutes"];
584
565
  if (typeof minutes === "number" && Number.isFinite(minutes) && minutes > 0) {
585
- const delay = minutes * 60_000;
586
- best = best === null ? delay : Math.max(best, delay);
566
+ consider(minutes * 60_000);
567
+ }
568
+
569
+ // The body's own prose (both top-level and nested under `error`): providers
570
+ // that state the window only in the message ("Resets in 165h46m45s") are
571
+ // honored rather than cooled for the 15-minute fallback.
572
+ const prose = candidate["message"];
573
+ if (typeof prose === "string" && prose.length > 0) {
574
+ consider(parseProviderResetDuration(prose));
575
+ }
576
+ }
577
+
578
+ // Google rpc `details[]` (Vertex/Antigravity error frames): `ErrorInfo`
579
+ // metadata carries the reset delay/timestamp, `RetryInfo` carries the retry
580
+ // delay directly.
581
+ const details = errorRecord?.["details"];
582
+ if (Array.isArray(details)) {
583
+ for (const detail of details) {
584
+ if (typeof detail !== "object" || detail === null) continue;
585
+ const entry = detail as Record<string, unknown>;
586
+ const type = typeof entry["@type"] === "string" ? entry["@type"] : "";
587
+ if (type.includes("google.rpc.ErrorInfo")) {
588
+ const metadata = entry["metadata"] as Record<string, unknown> | undefined;
589
+ const delay = metadata?.["quotaResetDelay"] ?? metadata?.["quotaResetDelaySeconds"];
590
+ consider(parseGoDuration(delay));
591
+ const stamp =
592
+ metadata?.["quotaResetTimeStamp"] ?? metadata?.["quotaResetTimestamp"];
593
+ if (typeof stamp === "string" && stamp.length > 0) {
594
+ const t = new Date(stamp).getTime();
595
+ if (Number.isFinite(t)) consider(t - Date.now());
596
+ }
597
+ }
598
+ if (type.includes("google.rpc.RetryInfo")) {
599
+ consider(parseGoDuration(entry["retryDelay"]));
600
+ }
601
+ // Some providers put the retry delay on the detail without a type.
602
+ consider(parseGoDuration(entry["retryDelay"]));
587
603
  }
588
604
  }
589
605
  return best === null ? null : Math.min(MAX_UPSTREAM_STATED_COOLDOWN_MS, Math.round(best));
590
606
  }
591
607
 
608
+ /** Parses a Go duration string (`596805.017558959s`, `1h30m0s`) to ms. */
609
+ function parseGoDuration(value: unknown): number | null {
610
+ if (typeof value !== "string") return null;
611
+ const text = value.trim().replace(/\s+/g, "");
612
+ if (!/^(?:(\d+(?:\.\d+)?)h)?(?:(\d+(?:\.\d+)?)m)?(?:(\d+(?:\.\d+)?)s)?$/.test(text)) {
613
+ return null;
614
+ }
615
+ const match = /^(?:(\d+(?:\.\d+)?)h)?(?:(\d+(?:\.\d+)?)m)?(?:(\d+(?:\.\d+)?)s)?$/.exec(text);
616
+ if (!match) return null;
617
+ let ms = 0;
618
+ if (match[1] !== undefined) ms += Number(match[1]) * 3_600_000;
619
+ if (match[2] !== undefined) ms += Number(match[2]) * 60_000;
620
+ if (match[3] !== undefined) ms += Number(match[3]) * 1_000;
621
+ return ms > 0 ? ms : null;
622
+ }
623
+
592
624
 
593
625
  interface AccountErrorContext {
594
626
  readonly message: string;
@@ -621,6 +653,7 @@ interface AccountErrorContext {
621
653
  explanation: string,
622
654
  mutatesAccount?: boolean,
623
655
  modelUnsupported?: boolean,
656
+ modelScoped?: boolean,
624
657
  ) => AccountErrorClassification;
625
658
  }
626
659
 
@@ -639,14 +672,14 @@ interface AccountErrorContext {
639
672
  * classifier's locals, no allocation per classification): the table is built
640
673
  * once per call from values already computed for every arm.
641
674
  */
642
- interface AccountErrorRule {
675
+ export interface AccountErrorRule {
643
676
  readonly name: string;
644
677
  readonly match: (signals: AccountErrorSignals) => boolean;
645
678
  readonly decide: (signals: AccountErrorSignals) => AccountErrorClassification;
646
679
  }
647
680
 
648
681
  /** The shared signals every arm reads, computed once per classification. */
649
- interface AccountErrorSignals {
682
+ export interface AccountErrorSignals {
650
683
  readonly message: string;
651
684
  readonly lower: string;
652
685
  readonly folded: string;
@@ -676,10 +709,36 @@ interface AccountErrorSignals {
676
709
  readonly queuedUpstream: boolean;
677
710
  readonly validationRequired: boolean;
678
711
  readonly validationUrl: string | undefined;
712
+ /** True when this failure should switch to a sibling account rather than wait. */
713
+ readonly rotateAccount: boolean;
679
714
  }
680
715
 
681
716
  const VALIDATION_URL_RE = /https?:\/\/[^\s"'\\]+/;
682
717
 
718
+ /**
719
+ * True when the wording names a *transient* rolling throttle — per-minute
720
+ * token/request windows (TPM/RPM), "too many requests", "per minute" — that
721
+ * self-heals within the window, so it must back off on the same account rather
722
+ * than rotate to a sibling. Mirrors oh-my-pi's `RATE_LIMIT_EXCEEDED` lane.
723
+ */
724
+ function transientRateLimitWording(lower: string): boolean {
725
+ return (
726
+ lower.includes("rate limit") ||
727
+ lower.includes("rate_limit") ||
728
+ lower.includes("too many requests") ||
729
+ lower.includes("per minute") ||
730
+ lower.includes("tokens per minute") ||
731
+ lower.includes("requests per minute") ||
732
+ lower.includes("tpm limit") ||
733
+ lower.includes("rpm limit") ||
734
+ lower.includes("tpm exhausted") ||
735
+ lower.includes("rpm exhausted") ||
736
+ lower.includes("ratelimitexceeded.endpointtpm") ||
737
+ lower.includes("ratelimitexceeded.endpointrpm") ||
738
+ lower.includes("presque")
739
+ );
740
+ }
741
+
683
742
  /**
684
743
  * Extracts the Google `validation_url` from an Antigravity rejection.
685
744
  *
@@ -760,13 +819,27 @@ function accountErrorContext(error: unknown, options?: AccountFailureEvidence):
760
819
  const headerCooldown =
761
820
  options?.retryAfterMs ??
762
821
  parseUpstreamBackoff({ get: getHeader });
763
- const messageCooldown = parseProviderResetDuration(message);
764
822
  const rawUpstreamResponse =
765
823
  origin === "upstream" &&
766
824
  error instanceof GatewayError &&
767
825
  typeof error.details.raw === "string"
768
826
  ? error.details.raw
769
827
  : undefined;
828
+ // The surface `message` may be a generic placeholder ("Provider returned HTTP
829
+ // 429.") even when the upstream body states a reset window. Fall through to
830
+ // the raw body's prose when the surface message names none, so a
831
+ // "Resets in 165h46m45s" refusal is honored instead of cooling for the
832
+ // 15-minute fallback.
833
+ const messageCooldown =
834
+ parseProviderResetDuration(message) ??
835
+ (rawUpstreamResponse === undefined
836
+ ? null
837
+ : parseProviderResetDuration(rawUpstreamResponse)) ??
838
+ (options?.upstreamBody === undefined
839
+ ? null
840
+ : typeof options.upstreamBody === "string"
841
+ ? parseProviderResetDuration(options.upstreamBody)
842
+ : parseProviderResetDuration(nestedErrorMessage(options.upstreamBody)));
770
843
  // The body's own fields, when they can be reached. Checked alongside the
771
844
  // header and the prose rather than instead of them: a provider may state the
772
845
  // window in any of the three, and the longest honest statement wins.
@@ -803,6 +876,7 @@ function accountErrorContext(error: unknown, options?: AccountFailureEvidence):
803
876
  explanation: string,
804
877
  mutatesAccount = canMutate,
805
878
  modelUnsupported = false,
879
+ modelScoped = false,
806
880
  ): AccountErrorClassification => ({
807
881
  category,
808
882
  status,
@@ -816,7 +890,18 @@ function accountErrorContext(error: unknown, options?: AccountFailureEvidence):
816
890
  // invisible). Defaulting true keeps existing callers conservative.
817
891
  routesVisible: true,
818
892
  providerStated,
893
+ // Usage-limit exhaustion, spending caps, and credential verdicts rotate to a
894
+ // sibling account now; transient throttles (per-minute, capacity) do not.
895
+ // The transient guard also applies to a category the classifier resolved to
896
+ // `quota_exhausted` — a TPM/RPM throttle phrased with quota wording
897
+ // ("tpm exhausted (type=quota_exceeded_error)") still self-heals within the
898
+ // minute and must not burn a sibling credential.
899
+ rotateAccount:
900
+ (category === "quota_exhausted" || category === "policy_blocked_banned") &&
901
+ !(statusCode === 429 && transientRateLimitWording(lower)) &&
902
+ !(statusCode === 503 || statusCode === 529),
819
903
  modelUnsupported,
904
+ modelScoped,
820
905
  });
821
906
 
822
907
  return {
@@ -993,9 +1078,12 @@ export function classifyAccountError(
993
1078
  lower.includes("have exceeded the quota") ||
994
1079
  lower.includes("balance exhausted") ||
995
1080
  lower.includes("insufficient balance") ||
1081
+ lower.includes("insufficient credits") ||
996
1082
  lower.includes("out of credits") ||
997
1083
  lower.includes("credits exhausted") ||
998
1084
  lower.includes("spending limit") ||
1085
+ lower.includes("spending cap") ||
1086
+ lower.includes("monthly spend limit") ||
999
1087
  lower.includes("billing daily count exceeded") ||
1000
1088
  // Same Qoder family reached by body text when the code is not parsed out.
1001
1089
  (lower.includes("pricingurl") && lower.includes("qoder.com/pricing")));
@@ -1226,6 +1314,16 @@ export function classifyAccountError(
1226
1314
  queuedUpstream,
1227
1315
  validationRequired,
1228
1316
  validationUrl,
1317
+ // A quota/spend/cap signal rotates to a sibling account; a per-minute
1318
+ // throttle, model capacity, or concurrency cap backs off on the same one.
1319
+ // `quotaSignal` is the broad price-refusal match — exclude explicit
1320
+ // throttles it would otherwise sweep (see the rate-limit and Inception
1321
+ // arms, which match the same signals and must keep backoff-only behavior).
1322
+ rotateAccount:
1323
+ quotaSignal &&
1324
+ !modelFrequencyWindow &&
1325
+ !(statusCode === 429 && transientRateLimitWording(lower)) &&
1326
+ !(statusCode === 503 || statusCode === 529),
1229
1327
  };
1230
1328
 
1231
1329
  for (const rule of ACCOUNT_ERROR_RULES) {
@@ -1324,33 +1422,7 @@ const ACCOUNT_ERROR_RULES: readonly AccountErrorRule[] = [
1324
1422
  true,
1325
1423
  ),
1326
1424
  },
1327
- {
1328
- // GitHub Copilot answers a license/entitlement refusal with 403 and
1329
- // "not licensed to use Copilot" / "forbidden" / "unauthorized". That is a
1330
- // verdict on the account: the subscription cannot serve this credential,
1331
- // and no amount of waiting or refreshing changes it. Cooldown cycling
1332
- // (auth_invalidated + 5m backoff) kept a dead account in rotation where a
1333
- // client's retry budget burned sibling accounts — the same failure the
1334
- // buddy ban exists to prevent. Disable immediately. Must run before
1335
- // `credential-rejected`, which would claim the bare 403 as a refreshable
1336
- // auth rejection.
1337
- name: "github-copilot-not-licensed",
1338
- match: (s) =>
1339
- s.origin === "upstream" &&
1340
- s.providerId === "github" &&
1341
- copilotNotLicensed(s.lower),
1342
- decide: (s) =>
1343
- s.result(
1344
- "policy_blocked_banned",
1345
- "disabled",
1346
- 0,
1347
- null,
1348
- s.reason("GitHub Copilot account not licensed"),
1349
- // A license refusal is evidence about the account even at provider
1350
- // scope, for the same reason a named revocation or a buddy ban is.
1351
- true,
1352
- ),
1353
- },
1425
+ ...githubRules,
1354
1426
  {
1355
1427
  name: "credential-rejected",
1356
1428
  match: (s) =>
@@ -1442,30 +1514,7 @@ const ACCOUNT_ERROR_RULES: readonly AccountErrorRule[] = [
1442
1514
  decide: (s) =>
1443
1515
  s.result("quota_exhausted", "cooldown", 0, null, s.reason("Request price refused by the provider"), false),
1444
1516
  },
1445
- {
1446
- // Qoder's code-112 pricing refusal (`{"code":"112","message":"{\"pricingUrl\":...}"}`)
1447
- // is a "this model is not included in this account's plan, buy more" verdict
1448
- // for one pair — the account keeps serving its other models. The probe path
1449
- // does not always carry the code in its evidence (only the message does), so
1450
- // the match keys on the provider id plus the pricing-page wording as well.
1451
- // It must run before `quota-exhausted`, which would park the whole account.
1452
- name: "qoder-plan-gated",
1453
- match: (s) =>
1454
- (s.providerId === "qoder" || s.providerId === "qodercn") &&
1455
- (s.providerCode === "112" || s.lower.includes("pricingurl")),
1456
- decide: (s) => {
1457
- const cooldownMs = unsupportedModelCooldownMs(s.modelBlockHits);
1458
- return s.result(
1459
- "model_unreachable",
1460
- "cooldown",
1461
- cooldownMs,
1462
- new Date(Date.now() + cooldownMs),
1463
- s.reason("Model not included in this account's plan"),
1464
- s.origin === "upstream" && s.modelId !== undefined,
1465
- true,
1466
- );
1467
- },
1468
- },
1517
+ qoderPlanGatedRule,
1469
1518
  {
1470
1519
  name: "model-plan-gated",
1471
1520
  match: (s) => s.quotaSignal && s.providerCode === "112",
@@ -1482,106 +1531,29 @@ const ACCOUNT_ERROR_RULES: readonly AccountErrorRule[] = [
1482
1531
  );
1483
1532
  },
1484
1533
  },
1485
- {
1486
- // Trae answers `4013 "AI service is not available in the current region
1487
- // now."` when the upstream does not serve the account's region. That is a
1488
- // durable verdict on the account, not a transient transport fault: it
1489
- // reproduces on every request until the region opens, so it must cool hard
1490
- // for a day rather than be re-dialled every thirty seconds. Must run
1491
- // before `transport-fault`, which would read the 502 as a short
1492
- // `server_error`.
1493
- name: "trae-region-unavailable",
1494
- match: (s) =>
1495
- (s.providerId === "trae" || s.providerId === "traecn") &&
1496
- (s.providerCode === "4013" || s.lower.includes("not available in the current region")),
1497
- decide: (s) => {
1498
- const cooldownMs = TRAE_REGION_COOLDOWN_MS;
1499
- return s.result(
1500
- "quota_exhausted",
1501
- "cooldown",
1502
- cooldownMs,
1503
- new Date(Date.now() + cooldownMs),
1504
- s.reason("Trae service unavailable in the current region"),
1505
- // Account-scoped provider evidence still has to reach the writer: a
1506
- // stream frame carries no credential detail, so `canMutate` is false
1507
- // and without this the account stays in rotation to reproduce the
1508
- // refusal for the whole window the region is closed for.
1509
- true,
1510
- );
1511
- },
1512
- },
1513
- {
1514
- // Trae's free-tier quota (`4008` / "exceeded the quota") resets daily.
1515
- // Treating it like the generic quota cooldown (1h account-wide fallback)
1516
- // let the account re-enter rotation inside the window and reproduce the
1517
- // refusal every few minutes — the "cooldown not holding" report. The
1518
- // verdict is a full-day backoff on *any* model (the account's quota is
1519
- // spent, so every model on it shares the wall). Scoped to the model so
1520
- // the account is not disabled wholesale; the 1-day window means a model
1521
- // stays parked for the day instead of flapping. Must run before
1522
- // `quota-exhausted`, which would apply the generic shorter fallback.
1523
- name: "trae-quota-exhausted",
1524
- match: (s) =>
1525
- (s.providerId === "trae" || s.providerId === "traecn") &&
1526
- (s.providerCode === "4008" || s.lower.includes("have exceeded the quota")),
1527
- decide: (s) => {
1528
- const cooldownMs = s.statedCooldownMs ?? TRAE_QUOTA_COOLDOWN_MS;
1529
- return s.result(
1530
- "model_unreachable",
1531
- "cooldown",
1532
- cooldownMs,
1533
- new Date(Date.now() + cooldownMs),
1534
- s.reason("Trae quota exhausted for this model"),
1535
- s.origin === "upstream" && s.modelId !== undefined,
1536
- );
1537
- },
1538
- },
1539
- {
1540
- // Qoder's quota verdicts — `110` "Billing daily count exceeded" and the
1541
- // upstream-499 exhausted-quota close — are per-model: one model's spend
1542
- // cap does not drain another. Scoping them to the model keeps the account
1543
- // serving its other models. Must run before `quota-exhausted`, which
1544
- // parks the whole account.
1545
- name: "qoder-model-quota",
1546
- match: (s) =>
1547
- (s.providerId === "qoder" || s.providerId === "qodercn") && s.quotaSignal,
1548
- decide: (s) => {
1549
- const cooldownMs = s.statedCooldownMs ?? QODER_QUOTA_COOLDOWN_MS;
1550
- return s.result(
1551
- "model_unreachable",
1552
- "cooldown",
1553
- cooldownMs,
1554
- new Date(Date.now() + cooldownMs),
1555
- s.reason("Qoder quota exhausted for this model"),
1556
- s.origin === "upstream" && s.modelId !== undefined,
1557
- );
1558
- },
1559
- },
1534
+ ...traeRules,
1535
+ qoderModelQuotaRule,
1560
1536
  {
1561
1537
  name: "quota-exhausted",
1562
- match: (s) => s.origin === "upstream" && s.quotaSignal,
1538
+ match: (s) =>
1539
+ s.origin === "upstream" &&
1540
+ s.quotaSignal &&
1541
+ // A transient rolling throttle phrased with quota wording (TPM/RPM
1542
+ // "tpm exhausted (type=quota_exceeded_error)") self-heals within the
1543
+ // minute; it belongs to the rate-limit arm below, never here.
1544
+ !transientRateLimitWording(s.lower),
1563
1545
  decide: (s) => {
1564
1546
  // xAI Grok Build's free tier resets on a rolling window. Use a duration
1565
1547
  // stated by the provider when present; otherwise park for the conservative
1566
1548
  // 24-hour fallback.
1567
- const grokFreeTier =
1568
- s.providerCode === "subscription:free-usage-exhausted" ||
1569
- s.providerCode.startsWith("subscription:") ||
1570
- // A 402 spend/entitlement wall (`personal-team-blocked:spending-limit`,
1571
- // "out of credits") is the same verdict: the account cannot serve until
1572
- // the operator tops up or upgrades. The default 1h fallback re-dialled
1573
- // it every hour inside a window that will not clear on its own.
1574
- s.providerCode.includes("spending") ||
1575
- s.providerCode.includes("credits") ||
1576
- s.lower.includes("free-usage") ||
1577
- s.lower.includes("free usage") ||
1578
- s.lower.includes("included free usage") ||
1579
- s.lower.includes("rolling 24-hour") ||
1580
- s.lower.includes("rolling 24 hour") ||
1581
- s.lower.includes("spending limit") ||
1582
- s.lower.includes("out of credits") ||
1583
- s.lower.includes("run out of credits") ||
1584
- s.lower.includes("need a grok subscription");
1549
+ const grokFreeTier = isGrokFreeTierExhaustion(s);
1550
+ // Only the explicit free-usage allowance is model-scoped. Subscription
1551
+ // spending caps are account-level and still park the whole credential.
1552
+ const grokFreeUsageModelScope =
1553
+ s.providerId === "grok" &&
1554
+ (s.providerCode === "subscription:free-usage-exhausted" ||
1555
+ s.lower.includes("subscription:free-usage-exhausted") ||
1556
+ s.lower.includes("included free usage"));
1585
1557
  const buddyCreditsExhausted =
1586
1558
  s.providerCode === "14018" || s.lower.includes("credits exhausted");
1587
1559
  const cooldownMs = grokFreeTier
@@ -1600,65 +1572,13 @@ const ACCOUNT_ERROR_RULES: readonly AccountErrorRule[] = [
1600
1572
  new Date(Date.now() + cooldownMs),
1601
1573
  s.reason("Provider quota exhausted"),
1602
1574
  s.origin === "upstream",
1575
+ false,
1576
+ grokFreeUsageModelScope && s.modelId !== undefined,
1603
1577
  );
1604
1578
  },
1605
1579
  },
1606
- {
1607
- // Inception's Free tier throttles per minute (1 000 requests / 1M input
1608
- // tokens / 100K output tokens) and the window resets every minute. A
1609
- // generic 429 would cool the account for the 15-minute default, leaving a
1610
- // healthy key parked long after the window refilled — the source of
1611
- // "limitasiya lumayan keras". This arm must run before `rate-limit`: it
1612
- // matches the same signals and shortens the window to 1 minute so routing
1613
- // moves to the next account and the flagged one returns almost at once.
1614
- name: "inception-per-minute-limit",
1615
- match: (s) =>
1616
- s.origin === "upstream" &&
1617
- s.providerId === "inception" &&
1618
- (s.statusCode === 429 ||
1619
- s.providerCode === "rate_limit_reached" ||
1620
- s.providerCode === "rate_limit_exceeded" ||
1621
- s.lower.includes("rate_limit") ||
1622
- s.lower.includes("rate limit") ||
1623
- s.lower.includes("too many requests") ||
1624
- s.lower.includes("tokens per minute") ||
1625
- s.lower.includes("input token")),
1626
- decide: (s) => {
1627
- const cooldownMs = s.statedCooldownMs ?? INCEPTION_RATE_LIMIT_COOLDOWN_MS;
1628
- return s.result(
1629
- "rate_limit_transient",
1630
- "cooldown",
1631
- cooldownMs,
1632
- new Date(Date.now() + cooldownMs),
1633
- s.reason("Inception per-minute input-token limit"),
1634
- s.origin === "upstream",
1635
- );
1636
- },
1637
- },
1638
- {
1639
- // Perch's `turn_rate_limited` is a per-turn API window, not a per-request
1640
- // 429: the account keeps answering but every call mints another ticket
1641
- // into the same exhausted window. The stated remedy ("contact us for
1642
- // direct API access") implies the window is long — park the account for a
1643
- // day so routing picks a sibling instead of hammering the same key.
1644
- name: "perch-turn-rate-limit",
1645
- match: (s) =>
1646
- s.origin === "upstream" &&
1647
- s.providerId === "perch" &&
1648
- (s.providerCode === "turn_rate_limited" ||
1649
- (s.statusCode === 429 && s.lower.includes("turn rate"))),
1650
- decide: (s) => {
1651
- const cooldownMs = s.statedCooldownMs ?? PERCH_TURN_RATE_LIMIT_COOLDOWN_MS;
1652
- return s.result(
1653
- "rate_limit_transient",
1654
- "cooldown",
1655
- cooldownMs,
1656
- new Date(Date.now() + cooldownMs),
1657
- s.reason("Perch turn rate limit"),
1658
- s.origin === "upstream",
1659
- );
1660
- },
1661
- },
1580
+ ...inceptionRules,
1581
+ ...perchRules,
1662
1582
  {
1663
1583
  name: "rate-limit",
1664
1584
  match: (s) =>
@@ -1750,34 +1670,6 @@ const ACCOUNT_ERROR_RULES: readonly AccountErrorRule[] = [
1750
1670
  );
1751
1671
  },
1752
1672
  },
1753
- {
1754
- // Qoder closes an exhausted-quota stream with upstream 499 (nginx
1755
- // client-close style). The gateway's own aborts are local `transport_closed`
1756
- // and never carry a qoder providerId, so a 499 scoped to qoder is a quota
1757
- // verdict, not a transport fault. It must run before `transport-fault`,
1758
- // which otherwise reads the status as a 5xx-class outage and cools the
1759
- // account for the shorter transient window — or leaves it unmutated.
1760
- //
1761
- // The verdict is per-model, like Qoder's other quota signs: one model's
1762
- // spend cap does not drain another, so it cools the pair, never the whole
1763
- // account.
1764
- name: "qoder-499-quota",
1765
- match: (s) =>
1766
- (s.providerId === "qoder" || s.providerId === "qodercn") &&
1767
- s.statusCode === 499 &&
1768
- s.origin === "upstream",
1769
- decide: (s) => {
1770
- const cooldownMs = s.statedCooldownMs ?? QODER_QUOTA_COOLDOWN_MS;
1771
- return s.result(
1772
- "model_unreachable",
1773
- "cooldown",
1774
- cooldownMs,
1775
- new Date(Date.now() + cooldownMs),
1776
- s.reason("Qoder quota exhausted for this model (upstream 499)"),
1777
- s.origin === "upstream" && s.modelId !== undefined,
1778
- );
1779
- },
1780
- },
1781
1673
  {
1782
1674
  name: "transport-fault",
1783
1675
  match: (s) =>