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
@@ -152,6 +152,118 @@ function pairedClose(
152
152
  return undefined;
153
153
  }
154
154
 
155
+ /**
156
+ * Stateful strip of paired thinking tags that preserves source order.
157
+ *
158
+ * The reasoning channel (e.g. Chat `reasoning_content`) is one stream where
159
+ * every byte — tagged or not — is thought. The two-bucket split used by
160
+ * `ThinkSplitter` (`{ text, reasoning }`) cannot express interleaving, so a
161
+ * call site that joins `${reasoning}${text}` reorders the stream: untagged
162
+ * prose that preceded a `<think>` span lands after it. This stripper instead
163
+ * emits the whole settled content with paired tags removed, in source order,
164
+ * while keeping `ThinkSplitter`'s stateful guarantees: a tag split across two
165
+ * deltas is recognized, code spans are left alone, and an unclosed span fails
166
+ * open at the cap.
167
+ */
168
+ export class ThinkingTagStripper {
169
+ #buffer = "";
170
+ #codeTicks = 0;
171
+ #codeFenced = false;
172
+ /** True once an opening tag has been seen and its close has not arrived yet. */
173
+ #inUnclosedSpan = false;
174
+ /** The opening tag currently held, restored verbatim on fail-open/flush. */
175
+ #openMarker = "";
176
+
177
+ /** Feeds a chunk; returns settled content with paired tags removed, in order. */
178
+ push(chunk: string): string {
179
+ this.#buffer += stripNul(chunk);
180
+ let out = "";
181
+ for (;;) {
182
+ if (this.#inUnclosedSpan) {
183
+ const closed = this.#findTag(CLOSE_TAGS);
184
+ if (closed === undefined) break;
185
+ out += this.#buffer.slice(0, closed.index);
186
+ this.#buffer = this.#buffer.slice(closed.index + closed.length);
187
+ this.#codeTicks = closed.codeTicks;
188
+ this.#codeFenced = closed.codeFenced;
189
+ this.#inUnclosedSpan = false;
190
+ this.#openMarker = "";
191
+ continue;
192
+ }
193
+ const opened = this.#findTag(OPEN_TAGS);
194
+ if (opened === undefined) break;
195
+ out += this.#buffer.slice(0, opened.index);
196
+ this.#openMarker = this.#buffer.slice(opened.index, opened.index + opened.length);
197
+ this.#buffer = this.#buffer.slice(opened.index + opened.length);
198
+ this.#codeTicks = opened.codeTicks;
199
+ this.#codeFenced = opened.codeFenced;
200
+ this.#inUnclosedSpan = true;
201
+ }
202
+ if (this.#inUnclosedSpan) {
203
+ // No close yet: hold the whole span content — including any text after it
204
+ // — until a later close or `flush()`. Streaming it now would release the
205
+ // span content before its opening tag on fail-open.
206
+ if (this.#buffer.length > MAX_THINK_BLOCK_BYTES) {
207
+ out += this.#openMarker + this.#buffer;
208
+ this.#buffer = "";
209
+ this.#inUnclosedSpan = false;
210
+ this.#openMarker = "";
211
+ }
212
+ return out;
213
+ }
214
+ const held = danglingTagPrefix(this.#buffer);
215
+ const settled = held.length > 0 ? this.#buffer.slice(0, -held.length) : this.#buffer;
216
+ this.#buffer = held;
217
+ return out + settled;
218
+ }
219
+
220
+ /** Releases content held back for a tag that never completed. */
221
+ flush(): string {
222
+ const rest = this.#buffer;
223
+ this.#buffer = "";
224
+ this.#codeTicks = 0;
225
+ this.#codeFenced = false;
226
+ if (this.#inUnclosedSpan) {
227
+ // An open tag that never closed is not evidence of reasoning — restore it
228
+ // byte-for-byte and release the whole tail as-is.
229
+ const marker = this.#openMarker;
230
+ this.#inUnclosedSpan = false;
231
+ this.#openMarker = "";
232
+ return marker + rest;
233
+ }
234
+ return rest;
235
+ }
236
+
237
+ #findTag(tags: readonly string[]): TagMatch | undefined {
238
+ const buffer = this.#buffer;
239
+ let ticks = this.#codeTicks;
240
+ let fenced = this.#codeFenced;
241
+ for (let i = 0; i < buffer.length; i++) {
242
+ const char = buffer[i];
243
+ if (char === "`") {
244
+ let run = 1;
245
+ while (buffer[i + run] === "`") run++;
246
+ if (ticks === 0) {
247
+ ticks = run;
248
+ fenced = run >= 3;
249
+ } else if (run === ticks) {
250
+ ticks = 0;
251
+ fenced = false;
252
+ }
253
+ i += run - 1;
254
+ continue;
255
+ }
256
+ if (char === "\n" && ticks > 0 && !fenced) ticks = 0;
257
+ if (char !== "<" || ticks > 0) continue;
258
+ for (const tag of tags) {
259
+ if (!buffer.startsWith(tag, i)) continue;
260
+ return { index: i, length: tag.length, codeTicks: ticks, codeFenced: fenced };
261
+ }
262
+ }
263
+ return undefined;
264
+ }
265
+ }
266
+
155
267
  /** The longest suffix of `text` that is a proper prefix of any tag. */
156
268
  function danglingTagPrefix(text: string): string {
157
269
  const tags = [...OPEN_TAGS, ...CLOSE_TAGS];
@@ -12,77 +12,45 @@
12
12
  * rest as text, mirroring the DSML/OMP scanners used for other families.
13
13
  */
14
14
  import type { InbandScanner, InbandScanResult } from "./inband";
15
+ import { MarkupStreamParser } from "../../markup-stream-parser";
15
16
 
16
17
  const OPEN_TAG = "<tool_call>";
17
18
  const CLOSE_TAG = "</tool_call>";
18
19
 
19
20
  export class QwenToolCallScanner implements InbandScanner {
20
- readonly #allowedNames: ReadonlySet<string>;
21
- #pending = "";
22
- #sequence = 0;
21
+ readonly #parser: MarkupStreamParser<InbandScanResult["calls"][number]>;
23
22
 
24
23
  constructor(allowedNames: ReadonlySet<string>) {
25
- this.#allowedNames = allowedNames;
24
+ this.#parser = new MarkupStreamParser<InbandScanResult["calls"][number]>({
25
+ openers: [OPEN_TAG],
26
+ classifyOpener: (_pending, _openIndex, opener) => {
27
+ if (opener !== OPEN_TAG) return { kind: "literal" };
28
+ return {
29
+ kind: "found",
30
+ closer: CLOSE_TAG,
31
+ parse: (block) => {
32
+ const inner = block.slice(OPEN_TAG.length, -CLOSE_TAG.length).trim();
33
+ const parsed = parseToolCall(inner);
34
+ if (parsed === undefined) return undefined;
35
+ if (!allowedNames.has(parsed.name)) {
36
+ // An undeclared tool is model text, not authority to invoke it.
37
+ return undefined;
38
+ }
39
+ return [parsed];
40
+ },
41
+ callId: (call, _index, seq) => ({ ...call, id: `call_qwen_${seq}` }),
42
+ };
43
+ },
44
+ flush: (pending) => (isPossibleOpener(pending) ? "" : pending),
45
+ });
26
46
  }
27
47
 
28
48
  push(chunk: string): InbandScanResult {
29
- this.#pending += chunk;
30
- let cursor = 0;
31
- let text = "";
32
- const calls: Array<InbandScanResult["calls"][number]> = [];
33
- for (;;) {
34
- const open = this.#pending.indexOf(OPEN_TAG, cursor);
35
- if (open < 0) {
36
- // Hold a possible pending opener at the tail; settle everything before it.
37
- text += this.#pending.slice(cursor, this.#settledEnd(this.#pending.length));
38
- cursor = this.#settledEnd(this.#pending.length);
39
- break;
40
- }
41
- text += this.#pending.slice(cursor, open);
42
- const bodyStart = open + OPEN_TAG.length;
43
- const close = this.#pending.indexOf(CLOSE_TAG, bodyStart);
44
- if (close < 0) {
45
- // Unterminated block: hold from the opener on and settle what preceded it.
46
- cursor = open;
47
- break;
48
- }
49
- const inner = this.#pending.slice(bodyStart, close).trim();
50
- const parsed = parseToolCall(inner);
51
- if (parsed !== undefined) {
52
- if (this.#allowedNames.has(parsed.name)) {
53
- calls.push({
54
- id: `call_qwen_${this.#sequence++}`,
55
- name: parsed.name,
56
- arguments: parsed.arguments,
57
- });
58
- } else {
59
- // An undeclared tool is model text, not authority to invoke it.
60
- text += this.#pending.slice(open, close + CLOSE_TAG.length);
61
- }
62
- } else {
63
- text += this.#pending.slice(open, close + CLOSE_TAG.length);
64
- }
65
- cursor = close + CLOSE_TAG.length;
66
- }
67
- this.#pending = this.#pending.slice(cursor);
68
- return { text, calls };
49
+ return this.#parser.push(chunk);
69
50
  }
70
51
 
71
52
  flush(): InbandScanResult {
72
- const pending = this.#pending;
73
- this.#pending = "";
74
- if (isPossibleOpener(pending)) return { text: "", calls: [] };
75
- return { text: pending, calls: [] };
76
- }
77
-
78
- #settledEnd(end: number): number {
79
- // Keep a trailing fragment that could be the start of an opener.
80
- const tail = this.#pending.slice(0, end);
81
- const maxPrefix = Math.min(OPEN_TAG.length - 1, tail.length);
82
- for (let len = maxPrefix; len > 0; len -= 1) {
83
- if (OPEN_TAG.startsWith(tail.slice(-len))) return end - len;
84
- }
85
- return end;
53
+ return this.#parser.flush();
86
54
  }
87
55
  }
88
56
 
@@ -17,6 +17,7 @@ import { resolveDialect } from "../../protocol/translation/dialect/resolver";
17
17
  import { resolveThinkingForModel } from "../../protocol/translation/dialect/thinking-ladder";
18
18
  import type { DialectPolicy } from "../../protocol/translation/dialect/types";
19
19
  import { postUpstreamJson } from "../../protocol/upstream/post-json";
20
+ import { gzipSync } from "node:zlib";
20
21
  import { GATEWAY_USER_AGENT } from "../operations/gateway-user-agent";
21
22
  import { resolvePromptCacheKey } from "../operations/session-resolution";
22
23
  import {
@@ -28,6 +29,24 @@ import {
28
29
  } from "../../protocol/codecs";
29
30
  const TEXT_DECODER = new TextDecoder("utf-8", { fatal: false });
30
31
 
32
+ /**
33
+ * Whether an upstream error body is the channel-check rejection the CodeBuddy
34
+ * console fallback recovers from. Ported from the donor gateway
35
+ * (`codebuddy2api/upstream_compat.is_channel_check_error`): it requires both
36
+ * a channel-check business code (`11128` / `11-128`) and one of the
37
+ * explanatory texts. A bare code match swept unrelated failures into a retry.
38
+ */
39
+ export function isBuddyChannelCheckError(raw: string): boolean {
40
+ if (!/code["']?\s*:\s*["']?(?:11128|11-128)/i.test(raw)) return false;
41
+ const lower = raw.toLowerCase();
42
+ return (
43
+ lower.includes("渠道校验") || // 渠道校验
44
+ lower.includes("channel verification") ||
45
+ lower.includes("official client") ||
46
+ lower.includes("illegal api invocation from an unapproved channel")
47
+ );
48
+ }
49
+
31
50
  /**
32
51
  * Provider ids that understand the OpenAI-style `service_tier` control. Only
33
52
  * these upstreams accept the field; every other OpenAI-compatible provider
@@ -111,8 +130,21 @@ export interface OpenAICompatibleAdapterConfig {
111
130
  * stamps `Content-Encoding: zstd` — the Grok Build CLI does this via its
112
131
  * sampler (`request_compression.rs`), and the proxy accepts it (verified
113
132
  * live 2026-11-02). Falls back to plain JSON if the runtime lacks zstd.
133
+ * `"gzip"` compresses with `gzipSync` and stamps `Content-Encoding: gzip`
134
+ * — the CodeBuddy CLI contract (donor `codebuddy2api`): the CLI profiles
135
+ * always compress; the CN desktop profile compresses requests >= 1024 B.
136
+ * The caller must also send `Accept-Encoding: identity` (the compressed
137
+ * body and the uncompressed response are independent choices).
138
+ */
139
+ readonly request_compression?: "json" | "zstd" | "gzip";
140
+ /**
141
+ * One retry against this path when the primary chat endpoint answers a
142
+ * channel-check 400 (`11128` / `11-128`). Matches the donor gateway's
143
+ * `/console/chat/completions` fallback for the international profile: the
144
+ * primary `v2` endpoint rejects requests that do not carry the channel
145
+ * marker, and a single console retry is the reference's recovery.
114
146
  */
115
- readonly request_compression?: "json" | "zstd";
147
+ readonly channel_fallback_path?: string;
116
148
  /**
117
149
  * Stamps `user-agent: Cartethyia/<version>` on every dispatch. Explicit
118
150
  * opt-in per provider spec — never a default — so first-party cloaking
@@ -216,6 +248,13 @@ export class OpenAICompatibleAdapter extends BaseProviderAdapter {
216
248
  // here — alongside the other request headers — so auth/proxy layers see the
217
249
  // final shape before the transport runs.
218
250
  if (this.config.request_compression === "zstd") headers["content-encoding"] = "zstd";
251
+ else if (this.config.request_compression === "gzip") {
252
+ headers["content-encoding"] = "gzip";
253
+ // The compressed request and the response encoding are independent; the
254
+ // reference requests the identity response encoding so the stream arrives
255
+ // uncompressed (matching `Accept-Encoding: identity` in the donor).
256
+ headers["accept-encoding"] = "identity";
257
+ }
219
258
  Object.assign(headers, filterProviderCustomHeaders(context.credential.custom_headers));
220
259
  applyRouteUserAgent(headers, context.credential, context.user_agent);
221
260
  return headers;
@@ -330,18 +369,38 @@ export class OpenAICompatibleAdapter extends BaseProviderAdapter {
330
369
  return u.toString();
331
370
  })();
332
371
  const outboundFetch = context.outbound_fetch ?? this.config.fetch ?? globalThis.fetch;
333
- const upstream = await postUpstreamJson(
334
- fetchUrl,
335
- headers,
336
- payload as Record<string, unknown>,
337
- context,
338
- outboundFetch,
339
- // zstd body encoding: matches the Grok Build CLI's
340
- // `request_compression` (zstd with a JSON fallback when unavailable).
372
+ const bodyEncoder =
341
373
  this.config.request_compression === "zstd" && typeof Bun?.zstdCompressSync === "function"
342
- ? (p) => Bun.zstdCompressSync(Buffer.from(JSON.stringify(p))) as Uint8Array<ArrayBuffer>
343
- : undefined,
344
- );
374
+ ? (p: Record<string, unknown>) => Bun.zstdCompressSync(Buffer.from(JSON.stringify(p))) as Uint8Array<ArrayBuffer>
375
+ : this.config.request_compression === "gzip"
376
+ ? (p: Record<string, unknown>) => gzipSync(Buffer.from(JSON.stringify(p)), { level: 6 })
377
+ : undefined;
378
+ const channelFallback =
379
+ this.config.channel_fallback_path !== undefined &&
380
+ endpointPath !== this.config.channel_fallback_path
381
+ ? joinUrl(this.config.resolveBaseUrl?.(context) ?? this.config.base_url, this.config.channel_fallback_path)
382
+ : undefined;
383
+ const upstream = await postUpstreamJson(fetchUrl, headers, payload as Record<string, unknown>, context, outboundFetch, bodyEncoder);
384
+ if (channelFallback !== undefined && upstream.res.status === 400) {
385
+ // Read the body only for a 400 that could still be retried. The channel
386
+ // check is the one refusal the console endpoint recovers from; a rejected
387
+ // body is consumed here so the fallback attempt carries a fresh stream.
388
+ const raw = await upstream.res.text().catch(() => "");
389
+ upstream.release();
390
+ if (isBuddyChannelCheckError(raw)) {
391
+ const retry = await postUpstreamJson(channelFallback, headers, payload as Record<string, unknown>, context, outboundFetch, bodyEncoder);
392
+ retry.release();
393
+ return retry.res;
394
+ }
395
+ // Not a channel check — reconstruct the 400 response so downstream error
396
+ // classification still sees the upstream's body. A `Response` body can be
397
+ // consumed once, so rebuild it from the captured text.
398
+ return new Response(raw, {
399
+ status: 400,
400
+ statusText: upstream.res.statusText,
401
+ headers: { "content-type": "application/json" },
402
+ });
403
+ }
345
404
  upstream.release();
346
405
  return upstream.res;
347
406
  }
@@ -23,7 +23,7 @@ import type { OAuthTokenRefreshResult, OAuthTokenRefresher } from "./oauth-refre
23
23
  export type FetchLike = (input: RequestInfo | URL, init?: RequestInit) => Promise<Response>;
24
24
 
25
25
  /** Normalized token shape returned by provider token endpoints. */
26
- interface NormalizedTokenResponse {
26
+ export interface NormalizedTokenResponse {
27
27
  readonly access: string;
28
28
  readonly refresh: string;
29
29
  readonly expiresAt: Date;
@@ -8,8 +8,9 @@ import type {
8
8
  OAuthDeviceFlowContext,
9
9
  OAuthDevicePollResult,
10
10
  OAuthDeviceStartResult,
11
+ OAuthExchangeResult,
11
12
  } from "./oauth-flow-store";
12
- import { OAuthClient } from "./oauth-client";
13
+ import { OAuthClient, type NormalizedTokenResponse } from "./oauth-client";
13
14
 
14
15
  /** Configuration for standard device-code start and poll requests. */
15
16
  export interface DeviceFlowConfig {
@@ -67,6 +68,58 @@ export abstract class OAuthDeviceFlow extends OAuthClient {
67
68
  return new URLSearchParams(params);
68
69
  }
69
70
 
71
+ /**
72
+ * Optional verdict computed from the parsed token body before the status
73
+ * code is considered. Defaults to undefined (continue with standard
74
+ * handling). A provider whose authoritative endpoint states the poll state
75
+ * in the body on any status can return a backoff verdict here — e.g. Grok,
76
+ * which votes on `error` before the HTTP status.
77
+ */
78
+ protected preVerdict?(_payload: unknown): OAuthDevicePollResult | undefined;
79
+
80
+ /**
81
+ * Post-success hook for the standard device-token poll.
82
+ *
83
+ * Receives the raw token response payload and the normalized parsed token.
84
+ * Returns the exchange result to complete with, or a verdict (typically
85
+ * `failed`) instead. The default reproduces the generic behavior exactly.
86
+ * Errors thrown by a hook are converted to a `failed` verdict by
87
+ * `pollGenericDeviceAuth`, keeping the poll contract (verdicts, never
88
+ * throws).
89
+ */
90
+ protected afterDeviceTokenExchange?(
91
+ _payload: unknown,
92
+ _normalized: NormalizedTokenResponse,
93
+ ): OAuthExchangeResult | OAuthDevicePollResult | Promise<OAuthExchangeResult | OAuthDevicePollResult>;
94
+
95
+ /**
96
+ * Failure reason for a non-JSON token body. Defaults to the generic
97
+ * phrasing; providers whose wire carries a distinct message override it.
98
+ */
99
+ protected unparsablePollReason(status: number): string {
100
+ return `device polling failed (${status})`;
101
+ }
102
+
103
+ /**
104
+ * Failure reason for a non-OK token response. The default preserves the
105
+ * generic behavior: the response's `error` field names the reason on the
106
+ * RFC 8628 400/428 statuses, and every other non-OK status gets the generic
107
+ * phrasing (the body is not trusted there).
108
+ */
109
+ protected pollFailureReason(
110
+ status: number,
111
+ errorKey: string | undefined,
112
+ _errorDescription: string | undefined,
113
+ ): string {
114
+ if (status === 400 || status === 428) return errorKey || `device polling failed (${status})`;
115
+ return `device polling failed (${status})`;
116
+ }
117
+
118
+ /** Failure reason when a token response omits `access_token`. */
119
+ protected missingTokenReason(_errorKey: string | undefined, _errorDescription: string | undefined): string {
120
+ return `${this.providerLabel} token response omitted access_token`;
121
+ }
122
+
70
123
  /** Standard device-token poll. Returns pending while authorization is incomplete. */
71
124
  protected async pollGenericDeviceAuth(
72
125
  deviceAuthId: string,
@@ -88,20 +141,57 @@ export abstract class OAuthDeviceFlow extends OAuthClient {
88
141
  } catch {
89
142
  payload = undefined;
90
143
  }
144
+ // A body that is not JSON is a failed poll regardless of status: verdicts
145
+ // must never throw, and the reason below lets providers keep their own
146
+ // phrasing (e.g. Grok's "returned invalid JSON").
147
+ if (payload === undefined) {
148
+ return { status: "failed", reason: this.unparsablePollReason(response.status) };
149
+ }
91
150
  const root = record(payload) ?? {};
151
+ if (this.preVerdict) {
152
+ const verdict = this.preVerdict(payload);
153
+ if (verdict !== undefined) return verdict;
154
+ }
155
+ const errorKey = typeof root.error === "string" ? root.error : undefined;
156
+ const errorDescription = typeof root.error_description === "string" ? root.error_description : undefined;
92
157
  if (response.status === 400 || response.status === 428) {
93
- const err = typeof root.error === "string" ? root.error : "";
94
- const verdict = devicePollBackoff(err, root.interval);
158
+ const verdict = devicePollBackoff(errorKey, root.interval);
95
159
  if (verdict !== undefined) return verdict;
96
- return { status: "failed", reason: err || `device polling failed (${response.status})` };
160
+ return {
161
+ status: "failed",
162
+ reason: this.pollFailureReason(response.status, errorKey, errorDescription),
163
+ };
97
164
  }
98
- if (!response.ok || payload === undefined) {
99
- return { status: "failed", reason: `device polling failed (${response.status})` };
165
+ if (!response.ok) {
166
+ return { status: "failed", reason: this.pollFailureReason(response.status, errorKey, errorDescription) };
100
167
  }
101
168
  if (typeof root.access_token !== "string" || !root.access_token) {
102
- return { status: "failed", reason: `${this.providerLabel} token response omitted access_token` };
169
+ return {
170
+ status: "failed",
171
+ reason: this.missingTokenReason(errorKey, errorDescription),
172
+ };
173
+ }
174
+ let normalized: NormalizedTokenResponse;
175
+ try {
176
+ normalized = this.parseTokenResponse(payload);
177
+ } catch (error) {
178
+ return {
179
+ status: "failed",
180
+ reason: error instanceof Error ? error.message : `${this.providerLabel} token response omitted access_token`,
181
+ };
182
+ }
183
+ if (this.afterDeviceTokenExchange) {
184
+ try {
185
+ const hooked = await this.afterDeviceTokenExchange(payload, normalized);
186
+ if ("status" in hooked) return hooked;
187
+ return { status: "complete", result: hooked };
188
+ } catch (error) {
189
+ return {
190
+ status: "failed",
191
+ reason: error instanceof Error ? error.message : `${this.providerLabel} authentication failed`,
192
+ };
193
+ }
103
194
  }
104
- const normalized = this.parseTokenResponse(payload);
105
195
  return { status: "complete", result: this.toExchangeResult(normalized) };
106
196
  }
107
197
  }
@@ -26,7 +26,8 @@ import type { CartethyiaDatabase } from "../../persistence/db-handle";
26
26
  import { sleep } from "../../shared/sleep";
27
27
  import { providerAccounts, providerOauthStates } from "../../persistence/schema";
28
28
  import { decryptCredentialToString, encryptCredential } from "../../security/crypto";
29
- import { invalidateCredentialCache } from "../operations/provider-credential-service";
29
+ import { invalidateCredentialCache, loadAccountWithOauthState } from "../operations/provider-credential-service";
30
+ import { applyAccountVerdict } from "../operations/account-health-service";
30
31
  import { resolveAccountDisabledRecoveryMs } from "../../config/cooldowns";
31
32
  import { asJsonbRecord } from "../../shared/coerce";
32
33
  import {
@@ -294,48 +295,32 @@ async function disableAccount(
294
295
  .returning({ id: providerOauthStates.providerAccountId });
295
296
  if (leaseRows.length === 0) return;
296
297
  const disabledRecoveryMs = resolveAccountDisabledRecoveryMs();
297
- // D23: read the row before writing so an operator disable (status `disabled`
298
- // with no deadline) is not upgraded with a machine recovery deadline. The
299
- // read is cheap and the lease already serializes refresh writers.
300
- let isOperatorDisable = false;
301
- if (options.preserveOperatorDisable === true) {
302
- const rows = await tx
303
- .select({
304
- status: providerAccounts.status,
305
- cooldownUntil: providerAccounts.cooldownUntil,
306
- })
307
- .from(providerAccounts)
308
- .where(eq(providerAccounts.id, accountId))
309
- .limit(1);
310
- const row = rows[0];
311
- isOperatorDisable = row?.status === "disabled" && row.cooldownUntil == null;
312
- }
313
- await tx
314
- .update(providerAccounts)
315
- .set({
316
- status: "disabled",
317
- lastError: message.slice(0, 500),
318
- // `auth_invalidated` is the category the console reads to render the
319
- // "Re-login required" pill, and the one the quota sweep excludes so it
320
- // stops re-probing a credential that cannot repair itself. The refresh
321
- // grant was definitively rejected and the stored access token could not
322
- // be shown to still work, so this is exactly that state.
323
- lastErrorCategory: "auth_invalidated",
324
- lastErrorAt: new Date(),
325
- // Carries a recovery deadline so `sweepExpiredCooldowns` can bring this
326
- // account back once it elapses; without one, one definitive rejection
327
- // parks the credential for good. The deadline is also what distinguishes
328
- // this (machine-disabled) from an operator's own disable, which leaves
329
- // `cooldownUntil` null and is therefore never swept. See
330
- // `resolveAccountDisabledRecoveryMs`. An operator-disabled row keeps its
331
- // `null` deadline (D23): the machine must not make the operator's
332
- // terminal disable look recoverable.
333
- ...(!isOperatorDisable && disabledRecoveryMs > 0
334
- ? { cooldownUntil: new Date(Date.now() + disabledRecoveryMs) }
335
- : {}),
336
- })
337
- .where(eq(providerAccounts.id, accountId));
338
- invalidateCredentialCache(accountId);
298
+ // Converge on the health application seam (proposal §3.5): a definitive
299
+ // OAuth refresh failure is a producer into `applyAccountVerdict`, not a
300
+ // separate state machine. The seam preserves D23 (operator-disable rows
301
+ // keep their `null` recovery deadline) via `preserveOperatorDisable`.
302
+ const applied = await applyAccountVerdict(tx, {
303
+ accountId,
304
+ status: "disabled",
305
+ // `auth_invalidated` is the category the console reads to render the
306
+ // "Re-login required" pill, and the one the quota sweep excludes so it
307
+ // stops re-probing a credential that cannot repair itself. The refresh
308
+ // grant was definitively rejected and the stored access token could not
309
+ // be shown to still work, so this is exactly that state.
310
+ reason: message,
311
+ category: "auth_invalidated",
312
+ // Carries a recovery deadline so `sweepExpiredCooldowns` can bring this
313
+ // account back once it elapses; without one, one definitive rejection
314
+ // parks the credential for good. The deadline is also what distinguishes
315
+ // this (machine-disabled) from an operator's own disable, which leaves
316
+ // `cooldownUntil` null and is therefore never swept (D23).
317
+ cooldownUntil:
318
+ disabledRecoveryMs > 0 ? new Date(Date.now() + disabledRecoveryMs) : null,
319
+ ...(options.preserveOperatorDisable === true
320
+ ? { preserveOperatorDisable: true }
321
+ : {}),
322
+ });
323
+ if (applied) invalidateCredentialCache(accountId);
339
324
  });
340
325
  }
341
326
 
@@ -749,10 +734,6 @@ export class OAuthRefreshService {
749
734
 
750
735
  /** Reads one account's status (used only for logging/observability, D26). */
751
736
  async function readAccountStatus(db: CartethyiaDatabase, accountId: string): Promise<string | undefined> {
752
- const rows = await db
753
- .select({ status: providerAccounts.status })
754
- .from(providerAccounts)
755
- .where(eq(providerAccounts.id, accountId))
756
- .limit(1);
757
- return rows[0]?.status;
737
+ const loaded = await loadAccountWithOauthState(db, accountId, { includeStatus: true });
738
+ return loaded?.status;
758
739
  }