cartethyia 2.7.0 → 2.7.2

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 (330) hide show
  1. package/.env.example +107 -106
  2. package/README.md +25 -2
  3. package/dist/dashboard/assets/{BannedPage-P76QLGJA.js → BannedPage-XaUmiBrC.js} +1 -1
  4. package/dist/dashboard/assets/BannedPage-XaUmiBrC.js.gz +0 -0
  5. package/dist/dashboard/assets/{CliToolDetailPage-DdBggJS9.js → CliToolDetailPage-CqD7QiLM.js} +1 -1
  6. package/dist/dashboard/assets/CliToolDetailPage-CqD7QiLM.js.gz +0 -0
  7. package/dist/dashboard/assets/{CliToolsPage-aN1iXhhP.js → CliToolsPage-0XUo4UMn.js} +1 -1
  8. package/dist/dashboard/assets/CliToolsPage-0XUo4UMn.js.gz +0 -0
  9. package/dist/dashboard/assets/{CombosPage-DE-F1nxZ.js → CombosPage-CXGS_Ct-.js} +1 -1
  10. package/dist/dashboard/assets/CombosPage-CXGS_Ct-.js.gz +0 -0
  11. package/dist/dashboard/assets/ConsoleLogPage-DGnGNYnM.js +1 -0
  12. package/dist/dashboard/assets/ConsoleLogPage-DGnGNYnM.js.gz +0 -0
  13. package/dist/dashboard/assets/{CustomizationPage-3CUxMy2X.js → CustomizationPage-DAG5J9EY.js} +1 -1
  14. package/dist/dashboard/assets/CustomizationPage-DAG5J9EY.js.gz +0 -0
  15. package/dist/dashboard/assets/HealthEventsModal-BKtAPYVF.js +1 -0
  16. package/dist/dashboard/assets/HealthEventsModal-BKtAPYVF.js.gz +0 -0
  17. package/dist/dashboard/assets/LoginPage-BdmRLnq2.js +1 -0
  18. package/dist/dashboard/assets/LoginPage-BdmRLnq2.js.gz +0 -0
  19. package/dist/dashboard/assets/{ModelPicker-DKVslqG3.js → ModelPicker-BH8Tehdi.js} +1 -1
  20. package/dist/dashboard/assets/ModelPicker-BH8Tehdi.js.gz +0 -0
  21. package/dist/dashboard/assets/OverviewPage-CqMxibJB.js +1 -0
  22. package/dist/dashboard/assets/OverviewPage-CqMxibJB.js.gz +0 -0
  23. package/dist/dashboard/assets/ProviderDetailPage-CqMh2NRX.js +4 -0
  24. package/dist/dashboard/assets/ProviderDetailPage-CqMh2NRX.js.gz +0 -0
  25. package/dist/dashboard/assets/ProviderIcon-FB6yS4v1.js +1 -0
  26. package/dist/dashboard/assets/ProviderIcon-FB6yS4v1.js.gz +0 -0
  27. package/dist/dashboard/assets/ProvidersPage-B9uWu4Nz.js +1 -0
  28. package/dist/dashboard/assets/ProvidersPage-B9uWu4Nz.js.gz +0 -0
  29. package/dist/dashboard/assets/ProxyPage-BoWOhpDO.js +9 -0
  30. package/dist/dashboard/assets/ProxyPage-BoWOhpDO.js.gz +0 -0
  31. package/dist/dashboard/assets/{QuotaPage-BjldQwIU.js → QuotaPage-7P2n--Fv.js} +1 -1
  32. package/dist/dashboard/assets/QuotaPage-7P2n--Fv.js.gz +0 -0
  33. package/dist/dashboard/assets/{SettingsPage-DUIoLRxk.js → SettingsPage-CxZ1fTkN.js} +1 -1
  34. package/dist/dashboard/assets/SettingsPage-CxZ1fTkN.js.gz +0 -0
  35. package/dist/dashboard/assets/SetupPage-Co12Zzaw.js +1 -0
  36. package/dist/dashboard/assets/SetupPage-Co12Zzaw.js.gz +0 -0
  37. package/dist/dashboard/assets/{SortableList-fI7JkdIB.js → SortableList-Dqf4zpS-.js} +1 -1
  38. package/dist/dashboard/assets/SortableList-Dqf4zpS-.js.gz +0 -0
  39. package/dist/dashboard/assets/{StudioPage-CHVZqskZ.js → StudioPage-Byj2lAEm.js} +3 -3
  40. package/dist/dashboard/assets/StudioPage-Byj2lAEm.js.gz +0 -0
  41. package/dist/dashboard/assets/UsagePage-CTkLAuRc.js +56 -0
  42. package/dist/dashboard/assets/UsagePage-CTkLAuRc.js.gz +0 -0
  43. package/dist/dashboard/assets/{api-keys-DFY_fZU4.js → api-keys-CUi6UXsR.js} +1 -1
  44. package/dist/dashboard/assets/api-keys-CUi6UXsR.js.gz +0 -0
  45. package/dist/dashboard/assets/app-KvsUJUMF.css +2 -0
  46. package/dist/dashboard/assets/app-KvsUJUMF.css.gz +0 -0
  47. package/dist/dashboard/assets/{assert-CwdnZARt.js → assert-67xXH5rf.js} +1 -1
  48. package/dist/dashboard/assets/assert-67xXH5rf.js.gz +0 -0
  49. package/dist/dashboard/assets/{badge-Dr-uf0Au.js → badge-DC2L43Mj.js} +1 -1
  50. package/dist/dashboard/assets/badge-DC2L43Mj.js.gz +0 -0
  51. package/dist/dashboard/assets/{banner-BddmMdoS.js → banner-BBVcb0J3.js} +1 -1
  52. package/dist/dashboard/assets/banner-BBVcb0J3.js.gz +0 -0
  53. package/dist/dashboard/assets/{card-CK0PXWlX.js → card-Cca1XC6G.js} +1 -1
  54. package/dist/dashboard/assets/card-Cca1XC6G.js.gz +0 -0
  55. package/dist/dashboard/assets/{cli-tools-CcB6mGh2.js → cli-tools-KvsWa1Jl.js} +1 -1
  56. package/dist/dashboard/assets/cli-tools-KvsWa1Jl.js.gz +0 -0
  57. package/dist/dashboard/assets/{clipboard-button-DGb2P9Yx.js → clipboard-button-DUzEbynq.js} +1 -1
  58. package/dist/dashboard/assets/clipboard-button-DUzEbynq.js.gz +0 -0
  59. package/dist/dashboard/assets/{dialog-LkfuL50u.js → dialog-DFvWhwTa.js} +1 -1
  60. package/dist/dashboard/assets/dialog-DFvWhwTa.js.gz +0 -0
  61. package/dist/dashboard/assets/{drawer-ZbEZ_84c.js → drawer-CFM2sMX1.js} +1 -1
  62. package/dist/dashboard/assets/drawer-CFM2sMX1.js.gz +0 -0
  63. package/dist/dashboard/assets/{entry-BJfsVMvA.js → entry-Bqy8RFvM.js} +1 -1
  64. package/dist/dashboard/assets/entry-Bqy8RFvM.js.gz +0 -0
  65. package/dist/dashboard/assets/{entry-CWBY5A0f.js → entry-ClCMCWWQ.js} +3 -3
  66. package/dist/dashboard/assets/entry-ClCMCWWQ.js.gz +0 -0
  67. package/dist/dashboard/assets/{entry-CvC1-Zqx.js → entry-_xHo5x6r.js} +1 -1
  68. package/dist/dashboard/assets/entry-_xHo5x6r.js.gz +0 -0
  69. package/dist/dashboard/assets/formatters-CWWZ-uCZ.js +1 -0
  70. package/dist/dashboard/assets/formatters-CWWZ-uCZ.js.gz +0 -0
  71. package/dist/dashboard/assets/{github-badge-BxpznFLt.js → github-badge-C9e8Ts7e.js} +1 -1
  72. package/dist/dashboard/assets/github-badge-C9e8Ts7e.js.gz +0 -0
  73. package/dist/dashboard/assets/{index-CDzP3ovJ.js → index-DFVFrnWW.js} +2 -2
  74. package/dist/dashboard/assets/index-DFVFrnWW.js.gz +0 -0
  75. package/dist/dashboard/assets/{inline-Ae13MQ5w.js → inline-BFUcvllj.js} +1 -1
  76. package/dist/dashboard/assets/inline-BFUcvllj.js.gz +0 -0
  77. package/dist/dashboard/assets/{input-D23fo7cp.js → input-C_ILAvBb.js} +1 -1
  78. package/dist/dashboard/assets/input-C_ILAvBb.js.gz +0 -0
  79. package/dist/dashboard/assets/{layout-CSsxjj5P.js → layout-CBIyT6Rt.js} +1 -1
  80. package/dist/dashboard/assets/layout-CBIyT6Rt.js.gz +0 -0
  81. package/dist/dashboard/assets/{list-row-dmbwZMDY.js → list-row-ebJ_-Nsb.js} +1 -1
  82. package/dist/dashboard/assets/list-row-ebJ_-Nsb.js.gz +0 -0
  83. package/dist/dashboard/assets/{live-BG1oUsZZ.js → live-D0mm--ME.js} +1 -1
  84. package/dist/dashboard/assets/live-D0mm--ME.js.gz +0 -0
  85. package/dist/dashboard/assets/{mutation-DSF-e4ho.js → mutation-sMD965oO.js} +1 -1
  86. package/dist/dashboard/assets/mutation-sMD965oO.js.gz +0 -0
  87. package/dist/dashboard/assets/{numbers-Bm_aECY7.js → numbers-BO9dKaFr.js} +1 -1
  88. package/dist/dashboard/assets/numbers-BO9dKaFr.js.gz +0 -0
  89. package/dist/dashboard/assets/providers/inception.svg +5 -0
  90. package/dist/dashboard/assets/providers/inception.svg.gz +0 -0
  91. package/dist/dashboard/assets/{query-keys-CF56nYIV.js → query-keys-CfL_Zdoh.js} +1 -1
  92. package/dist/dashboard/assets/query-keys-CfL_Zdoh.js.gz +0 -0
  93. package/dist/dashboard/assets/{routing-CeA71n7G.js → routing-BnIpi1IJ.js} +1 -1
  94. package/dist/dashboard/assets/routing-BnIpi1IJ.js.gz +0 -0
  95. package/dist/dashboard/assets/{select-KIT0rC6G.js → select-DBNVLdUu.js} +1 -1
  96. package/dist/dashboard/assets/select-DBNVLdUu.js.gz +0 -0
  97. package/dist/dashboard/assets/{settings-B_2gkePf.js → settings-BAFhDgX8.js} +1 -1
  98. package/dist/dashboard/assets/settings-BAFhDgX8.js.gz +0 -0
  99. package/dist/dashboard/assets/{stack-D7WOSzcX.js → stack-EMrOMt4I.js} +1 -1
  100. package/dist/dashboard/assets/stack-EMrOMt4I.js.gz +0 -0
  101. package/dist/dashboard/assets/{state-BBxtKZOQ.js → state-CWoo7ty5.js} +1 -1
  102. package/dist/dashboard/assets/state-CWoo7ty5.js.gz +0 -0
  103. package/dist/dashboard/assets/{switch-DNS_D5Ea.js → switch-CCjT6IkF.js} +1 -1
  104. package/dist/dashboard/assets/switch-CCjT6IkF.js.gz +0 -0
  105. package/dist/dashboard/assets/{useMutation-BgoxdoMk.js → useMutation-DjkLHUcE.js} +1 -1
  106. package/dist/dashboard/assets/useMutation-DjkLHUcE.js.gz +0 -0
  107. package/dist/dashboard/index.html +1 -1
  108. package/dist/dashboard/index.html.gz +0 -0
  109. package/dist/dashboard/precompressed-assets.json +54 -53
  110. package/migrations/full-postgres/0024_add_pool_provider_cooldowns.sql +12 -0
  111. package/migrations/full-postgres/0025_add_pool_random_strategy.sql +3 -0
  112. package/migrations/lite-sqlite3/0024_add_pool_provider_cooldowns.sql +12 -0
  113. package/migrations/lite-sqlite3/0025_add_pool_random_strategy.sql +20 -0
  114. package/package.json +96 -96
  115. package/scripts/cli/console-menu.ts +5 -2
  116. package/scripts/cli/tray.ts +21 -6
  117. package/src/boot/app.ts +684 -714
  118. package/src/boot/dependencies.ts +65 -62
  119. package/src/config/cooldowns.ts +16 -0
  120. package/src/config/providers.ts +13 -1
  121. package/src/config/resources.ts +70 -70
  122. package/src/config/security.ts +37 -37
  123. package/src/config/spec.ts +371 -358
  124. package/src/console/cli-tools/contracts.ts +10 -4
  125. package/src/console/console-router.ts +233 -225
  126. package/src/console/domain-registration.ts +398 -381
  127. package/src/console/providers/catalog/catalog-projections.ts +2 -6
  128. package/src/console/providers/catalog/contracts.ts +19 -0
  129. package/src/console/providers/catalog/provider-operations.ts +38 -0
  130. package/src/console/providers/catalog/routes.ts +14 -0
  131. package/src/console/providers/catalog/store-helpers.ts +326 -0
  132. package/src/console/providers/catalog/store.ts +1292 -1563
  133. package/src/console/quota/account-quota-global.ts +2 -2
  134. package/src/console/quota/account-quota-shared.ts +1 -1
  135. package/src/console/quota/account-quota-tenant.ts +35 -12
  136. package/src/console/quota/quota-view.ts +2 -2
  137. package/src/console/routing/pools/contracts.ts +41 -1
  138. package/src/console/routing/pools/operations.ts +183 -4
  139. package/src/console/routing/pools/relay-deploy.ts +17 -4
  140. package/src/console/routing/pools/routes.ts +30 -0
  141. package/src/console/routing/pools/store.ts +98 -8
  142. package/src/main.ts +183 -167
  143. package/src/network/outbound-fetch.ts +124 -208
  144. package/src/network/pool/agent.ts +15 -0
  145. package/src/network/pool/pool-state.ts +7 -0
  146. package/src/network/pool/selector.ts +110 -2
  147. package/src/network/pool-cooldown-store.ts +252 -0
  148. package/src/network/pool-health-machine.ts +5 -3
  149. package/src/network/pool-health.ts +36 -9
  150. package/src/observability/event-query.ts +57 -0
  151. package/src/observability/metrics.ts +467 -467
  152. package/src/observability/runtime-metrics.ts +269 -269
  153. package/src/persistence/health-event-flush.ts +2 -0
  154. package/src/persistence/schema.ts +19 -0
  155. package/src/persistence/usage-flush.ts +8 -3
  156. package/src/protocol/canonical-model.ts +1 -1
  157. package/src/protocol/dsml.ts +377 -0
  158. package/src/protocol/gateway-error.ts +232 -232
  159. package/src/{providers/provider-reasoning.ts → protocol/reasoning.ts} +10 -9
  160. package/src/protocol/request/chat.ts +7 -7
  161. package/src/protocol/request/codex.ts +2 -2
  162. package/src/protocol/request/gemini.ts +1 -1
  163. package/src/protocol/request/messages.ts +6 -6
  164. package/src/protocol/request/responses.ts +7 -7
  165. package/src/protocol/response/dsml.ts +12 -133
  166. package/src/{transport → protocol}/translation/attachment-integrity.ts +2 -2
  167. package/src/{transport → protocol}/translation/cache-controls.ts +1 -1
  168. package/src/{transport → protocol}/translation/capabilities.ts +2 -2
  169. package/src/{transport → protocol}/translation/normalize.ts +1 -1
  170. package/src/{transport → protocol}/translation/quirks.ts +1 -1
  171. package/src/{transport → protocol}/translation/search.ts +1 -1
  172. package/src/{transport → protocol}/translation/thinking.ts +23 -1
  173. package/src/{transport → protocol}/translation/tool-id.ts +1 -1
  174. package/src/{transport → protocol}/translation/tool-repair.ts +2 -2
  175. package/src/protocol/upstream-parsing.ts +176 -0
  176. package/src/providers/adapters/compatible-adapter.ts +11 -2
  177. package/src/providers/authentication/oauth-refresh-service.ts +9 -5
  178. package/src/providers/default-registry.ts +22 -5
  179. package/src/providers/discovery/probe-phases.ts +16 -3
  180. package/src/providers/discovery/probing-service.ts +4 -1
  181. package/src/providers/integrations/anthropic.ts +1 -1
  182. package/src/providers/integrations/buddy/buddy-catalog-shared.ts +1 -4
  183. package/src/providers/integrations/buddy/buddy-chat-shared.ts +141 -8
  184. package/src/providers/integrations/buddy/buddy-checkin.ts +5 -1
  185. package/src/providers/integrations/buddy/buddy-growth-shared.ts +0 -82
  186. package/src/providers/integrations/buddy/buddy-identity.ts +25 -0
  187. package/src/providers/integrations/buddy/buddy-server-tools.ts +240 -0
  188. package/src/providers/integrations/buddy/codebuddy/cn-catalog.ts +36 -0
  189. package/src/providers/integrations/buddy/codebuddy/cn-provider.ts +6 -31
  190. package/src/providers/integrations/buddy/codebuddy/provider.ts +2 -0
  191. package/src/providers/integrations/buddy/codebuddy/shared.ts +19 -4
  192. package/src/providers/integrations/buddy/turing-device-token.ts +262 -0
  193. package/src/providers/integrations/buddy/workbuddy/provider.ts +2 -0
  194. package/src/providers/integrations/buddy/workbuddy/shared.ts +56 -3
  195. package/src/providers/integrations/claude/claude.ts +1 -1
  196. package/src/providers/integrations/cloudflareai/cloudflareai.ts +1 -1
  197. package/src/providers/integrations/codex/codex-errors.ts +1 -1
  198. package/src/providers/integrations/codex/codex.ts +1 -1
  199. package/src/providers/integrations/factory/factory.ts +1 -1
  200. package/src/providers/integrations/github/github.ts +1 -1
  201. package/src/providers/integrations/grok/grok.ts +1 -1
  202. package/src/providers/integrations/inception/provider.ts +91 -0
  203. package/src/providers/integrations/inferhub.ts +1 -1
  204. package/src/providers/integrations/kiro/kiro-request.ts +1 -1
  205. package/src/providers/integrations/mistral.ts +193 -19
  206. package/src/providers/integrations/ollamacloud-models.ts +50 -0
  207. package/src/providers/integrations/opencode/fingerprint.ts +19 -2
  208. package/src/providers/integrations/opencode/prompt.ts +111 -0
  209. package/src/providers/integrations/opencode/provider.ts +101 -23
  210. package/src/providers/integrations/opencode/tool-definitions.ts +4 -4
  211. package/src/providers/integrations/qoder/qoder-checkin.ts +303 -0
  212. package/src/providers/integrations/qoder/qoder-oauth.ts +12 -1
  213. package/src/providers/integrations/qoder/qoder-realm.ts +20 -0
  214. package/src/providers/integrations/qoder/qoder-text-tools.ts +277 -0
  215. package/src/providers/integrations/qoder/qoder.ts +315 -400
  216. package/src/providers/integrations/shared/install-id.ts +2 -1
  217. package/src/providers/integrations/trae/trae-cache.ts +110 -0
  218. package/src/providers/integrations/trae/trae-oauth.ts +29 -4
  219. package/src/providers/integrations/trae/trae-protocol.ts +2 -1
  220. package/src/providers/integrations/trae/trae.ts +106 -23
  221. package/src/providers/operations/account-error-classification.ts +1684 -0
  222. package/src/providers/operations/account-health-service.ts +27 -1503
  223. package/src/providers/operations/account-reset-service.ts +2 -0
  224. package/src/providers/operations/provider-catalog-service.ts +11 -1
  225. package/src/providers/provider-contracts.ts +7 -4
  226. package/src/providers/provider-metadata.ts +25 -4
  227. package/src/providers/provider-probe.ts +2 -2
  228. package/src/{console → providers}/quota/cache.ts +1 -1
  229. package/src/{console → providers}/quota/refresh.ts +5 -5
  230. package/src/security/cli-client-fingerprint.ts +9 -2
  231. package/src/transport/dispatch/attempt-finalize.ts +114 -12
  232. package/src/transport/dispatch/attempt-loop.ts +3 -0
  233. package/src/transport/dispatch/empty-stream.ts +9 -2
  234. package/src/transport/dispatch/generation.ts +37 -55
  235. package/src/transport/dispatch/handler-deps.ts +32 -0
  236. package/src/transport/dispatch/leases.ts +9 -4
  237. package/src/transport/dispatch/proxy-request.ts +6 -30
  238. package/src/transport/dispatch/responses-compact.ts +22 -43
  239. package/src/transport/dispatch/streaming/finalize.ts +3 -0
  240. package/src/transport/dispatch/streaming/stream.ts +0 -8
  241. package/src/transport/dispatch/systemone.ts +22 -42
  242. package/src/transport/dispatch/token-count.ts +23 -39
  243. package/src/transport/dispatch/upstream.ts +1 -3
  244. package/src/transport/dispatch/websearch.ts +22 -41
  245. package/src/transport/failure-policy.ts +92 -178
  246. package/src/transport/guards/abuse-guard.ts +10 -0
  247. package/src/transport/guards/auth-guard.ts +109 -109
  248. package/src/transport/guards/error-lifecycle.ts +1 -1
  249. package/src/transport/guards/pipeline.ts +220 -220
  250. package/src/transport/guards/request-context.ts +245 -238
  251. package/src/transport/request/degradation.ts +20 -1
  252. package/src/transport/request/inflight.ts +4 -114
  253. package/src/transport/request/preparer.ts +6 -6
  254. package/src/transport/request/state.ts +66 -38
  255. package/src/transport/request/tenant-preferences.ts +1 -1
  256. package/src/transport/routing/route-catalog.ts +75 -43
  257. package/src/transport/routing/router.ts +30 -2
  258. package/src/transport/surface/messages/parse.ts +1 -1
  259. package/src/transport/surface/responses/encode.ts +2 -3
  260. package/src/transport/surface/responses/parse.ts +49 -7
  261. package/src/types/undici.d.ts +33 -0
  262. package/src/workers/buddy-growth-tasks.ts +346 -425
  263. package/src/workers/daily-checkin.ts +17 -6
  264. package/src/workers/quota-refresh-sweep.ts +2 -2
  265. package/src/workers/scheduler.ts +17 -1
  266. package/dist/dashboard/assets/BannedPage-P76QLGJA.js.gz +0 -0
  267. package/dist/dashboard/assets/CliToolDetailPage-DdBggJS9.js.gz +0 -0
  268. package/dist/dashboard/assets/CliToolsPage-aN1iXhhP.js.gz +0 -0
  269. package/dist/dashboard/assets/CombosPage-DE-F1nxZ.js.gz +0 -0
  270. package/dist/dashboard/assets/ConsoleLogPage-5SpmfLyw.js +0 -1
  271. package/dist/dashboard/assets/ConsoleLogPage-5SpmfLyw.js.gz +0 -0
  272. package/dist/dashboard/assets/CustomizationPage-3CUxMy2X.js.gz +0 -0
  273. package/dist/dashboard/assets/HealthEventsModal-BkmJQKC0.js +0 -1
  274. package/dist/dashboard/assets/HealthEventsModal-BkmJQKC0.js.gz +0 -0
  275. package/dist/dashboard/assets/LoginPage-B_0FUKWc.js +0 -1
  276. package/dist/dashboard/assets/LoginPage-B_0FUKWc.js.gz +0 -0
  277. package/dist/dashboard/assets/ModelPicker-DKVslqG3.js.gz +0 -0
  278. package/dist/dashboard/assets/OverviewPage-D-H4MEiT.js +0 -1
  279. package/dist/dashboard/assets/OverviewPage-D-H4MEiT.js.gz +0 -0
  280. package/dist/dashboard/assets/ProviderDetailPage-CEJw6Guo.js +0 -3
  281. package/dist/dashboard/assets/ProviderDetailPage-CEJw6Guo.js.gz +0 -0
  282. package/dist/dashboard/assets/ProviderIcon-CmfzxpVM.js +0 -1
  283. package/dist/dashboard/assets/ProviderIcon-CmfzxpVM.js.gz +0 -0
  284. package/dist/dashboard/assets/ProvidersPage-CEplAR-i.js +0 -1
  285. package/dist/dashboard/assets/ProvidersPage-CEplAR-i.js.gz +0 -0
  286. package/dist/dashboard/assets/ProxyPage-CAN7hy0f.js +0 -7
  287. package/dist/dashboard/assets/ProxyPage-CAN7hy0f.js.gz +0 -0
  288. package/dist/dashboard/assets/QuotaPage-BjldQwIU.js.gz +0 -0
  289. package/dist/dashboard/assets/SettingsPage-DUIoLRxk.js.gz +0 -0
  290. package/dist/dashboard/assets/SetupPage-_bCaTM0v.js +0 -1
  291. package/dist/dashboard/assets/SetupPage-_bCaTM0v.js.gz +0 -0
  292. package/dist/dashboard/assets/SortableList-fI7JkdIB.js.gz +0 -0
  293. package/dist/dashboard/assets/StudioPage-CHVZqskZ.js.gz +0 -0
  294. package/dist/dashboard/assets/UsagePage-DiFBymHj.js +0 -56
  295. package/dist/dashboard/assets/UsagePage-DiFBymHj.js.gz +0 -0
  296. package/dist/dashboard/assets/api-keys-DFY_fZU4.js.gz +0 -0
  297. package/dist/dashboard/assets/app-BOJ1_xUB.css +0 -2
  298. package/dist/dashboard/assets/app-BOJ1_xUB.css.gz +0 -0
  299. package/dist/dashboard/assets/assert-CwdnZARt.js.gz +0 -0
  300. package/dist/dashboard/assets/badge-Dr-uf0Au.js.gz +0 -0
  301. package/dist/dashboard/assets/banner-BddmMdoS.js.gz +0 -0
  302. package/dist/dashboard/assets/card-CK0PXWlX.js.gz +0 -0
  303. package/dist/dashboard/assets/cli-tools-CcB6mGh2.js.gz +0 -0
  304. package/dist/dashboard/assets/clipboard-button-DGb2P9Yx.js.gz +0 -0
  305. package/dist/dashboard/assets/cog-CdWhsajs.js +0 -1
  306. package/dist/dashboard/assets/cog-CdWhsajs.js.gz +0 -0
  307. package/dist/dashboard/assets/dialog-LkfuL50u.js.gz +0 -0
  308. package/dist/dashboard/assets/drawer-ZbEZ_84c.js.gz +0 -0
  309. package/dist/dashboard/assets/entry-BJfsVMvA.js.gz +0 -0
  310. package/dist/dashboard/assets/entry-CWBY5A0f.js.gz +0 -0
  311. package/dist/dashboard/assets/entry-CvC1-Zqx.js.gz +0 -0
  312. package/dist/dashboard/assets/github-badge-BxpznFLt.js.gz +0 -0
  313. package/dist/dashboard/assets/index-CDzP3ovJ.js.gz +0 -0
  314. package/dist/dashboard/assets/inline-Ae13MQ5w.js.gz +0 -0
  315. package/dist/dashboard/assets/input-D23fo7cp.js.gz +0 -0
  316. package/dist/dashboard/assets/layout-CSsxjj5P.js.gz +0 -0
  317. package/dist/dashboard/assets/list-row-dmbwZMDY.js.gz +0 -0
  318. package/dist/dashboard/assets/live-BG1oUsZZ.js.gz +0 -0
  319. package/dist/dashboard/assets/mutation-DSF-e4ho.js.gz +0 -0
  320. package/dist/dashboard/assets/numbers-Bm_aECY7.js.gz +0 -0
  321. package/dist/dashboard/assets/query-keys-CF56nYIV.js.gz +0 -0
  322. package/dist/dashboard/assets/routing-CeA71n7G.js.gz +0 -0
  323. package/dist/dashboard/assets/select-KIT0rC6G.js.gz +0 -0
  324. package/dist/dashboard/assets/settings-B_2gkePf.js.gz +0 -0
  325. package/dist/dashboard/assets/stack-D7WOSzcX.js.gz +0 -0
  326. package/dist/dashboard/assets/state-BBxtKZOQ.js.gz +0 -0
  327. package/dist/dashboard/assets/switch-DNS_D5Ea.js.gz +0 -0
  328. package/dist/dashboard/assets/useMutation-BgoxdoMk.js.gz +0 -0
  329. /package/dist/dashboard/assets/{app-CaXZ9SCn.js → app-BaWd0puM.js} +0 -0
  330. /package/dist/dashboard/assets/{app-CaXZ9SCn.js.gz → app-BaWd0puM.js.gz} +0 -0
@@ -11,6 +11,7 @@ import {
11
11
  upstreamErrorIdentifier,
12
12
  } from "../../../transport/failure-policy";
13
13
  import { GatewayError } from "../../../protocol/gateway-error";
14
+ import { resolveImageSource } from "../../../protocol/codecs";
14
15
  import type {
15
16
  CanonicalEvent,
16
17
  CanonicalRequest,
@@ -19,7 +20,7 @@ import type {
19
20
  ToolChoice,
20
21
  ToolDefinition,
21
22
  } from "../../../protocol/canonical-model";
22
- import { toolResultParts } from "../../../protocol/canonical-model";
23
+ import { toolResultParts, toolCallParts } from "../../../protocol/canonical-model";
23
24
  import { decodeSseEvents } from "../../../protocol/sse-decoder";
24
25
  import { usageFromProvider, readReasoningText } from "../../provider-usage";
25
26
  import { readCredentialSecret, type ProviderDispatchTarget, type ProviderAdapter, type ProviderDispatchContext, type ProviderId } from "../../provider-contracts";
@@ -28,9 +29,62 @@ import { parseQoderOAuthState } from "./qoder-oauth";
28
29
  import { getQoderVersion } from "../../operations/client-versions";
29
30
  import { abortGatewayError, createUpstreamDeadlineLifecycle } from "../../operations/upstream-deadline";
30
31
  import { qoderMachineOs } from "../../operations/cli-platform";
31
- import { qoderGatewayCandidates, QODER_CHAT_PATH, QODER_REALMS, type QoderRealm } from "./qoder-realm";
32
+ import { qoderInferenceCandidates, QODER_CHAT_PATH, QODER_REALMS, type QoderRealm } from "./qoder-realm";
32
33
  import { QODER_CN_MODEL_CONFIGS, QODER_INTL_MODEL_CONFIGS, QODER_INTL_MODELS } from "./qoder-catalog";
33
34
  import { isQoderVirtualModel, qoderDispatchModelId } from "./qoder-virtual";
35
+ import {
36
+ LEAK_MARKER_ANY_RE,
37
+ LEAK_MARKER_LITERALS,
38
+ parseQoderTextToolCalls,
39
+ isQoderEchoAttempt,
40
+ } from "./qoder-text-tools";
41
+
42
+ /** How long a trailing partial marker may be before it is held for the next frame. */
43
+ const LEAK_HOLD_WINDOW = 24;
44
+ /** Fail-open caps so an endless lookalike cannot hold text forever. */
45
+ const LEAK_HOLD_MAX_CHARS = 32_768;
46
+ const LEAK_HOLD_MAX_FRAMES = 200;
47
+
48
+ const BAD_ECHO_MESSAGE =
49
+ "Qoder returned an unparseable tool-call echo; no tools were emitted";
50
+
51
+ function badEchoError(): GatewayError {
52
+ return new GatewayError(
53
+ "platform_unavailable",
54
+ 502,
55
+ BAD_ECHO_MESSAGE,
56
+ { providerId: QODER_PROVIDER_ID },
57
+ "upstream",
58
+ );
59
+ }
60
+
61
+ /** True when `suffix` is a strict prefix of some marker literal. */
62
+ function isLeakMarkerPrefix(suffix: string): boolean {
63
+ if (suffix.length === 0 || suffix.length >= LEAK_HOLD_WINDOW) return false;
64
+ return LEAK_MARKER_LITERALS.some((literal) => literal.startsWith(suffix));
65
+ }
66
+
67
+ /**
68
+ * True when `text` is still plausibly the INTL marker echo block (marker +
69
+ * JSON array/object, or a tool-style tag). Used to keep holding across frames;
70
+ * false means it is ordinary prose and can be released.
71
+ */
72
+ function qoderStillLeakCandidate(text: string): boolean {
73
+ if (text.length === 0) return true;
74
+ const marker = LEAK_MARKER_ANY_RE.exec(text);
75
+ if (!marker) {
76
+ return isLeakMarkerPrefix(text) || LEAK_MARKER_ANY_RE.test(text);
77
+ }
78
+ const rest = text.slice(marker.index + marker[0].length).trim();
79
+ if (rest.length === 0) return true;
80
+ if (rest.startsWith("[") || rest.startsWith("{")) return true;
81
+ if (/^<[a-zA-Z0-9_.:-]+\b/.test(rest)) return true;
82
+ // Angle-wrapped JSON: `<{` / `<[` — candidate until a closing `>` appears.
83
+ if (rest.startsWith("<{") || rest.startsWith("<[")) {
84
+ return !rest.includes(">") || rest.endsWith(">");
85
+ }
86
+ return false;
87
+ }
34
88
 
35
89
  interface QoderModeProfile {
36
90
  readonly chatUrl: string;
@@ -126,10 +180,25 @@ interface QoderModelConfig {
126
180
  source?: string;
127
181
  }
128
182
 
129
-
130
183
  function qoderMd5(value: string | Uint8Array): string {
131
184
  return createHash("md5").update(value).digest("hex");
132
185
  }
186
+
187
+ /**
188
+ * Canonical compact JSON used by Qoder's COSY signing: keys sorted
189
+ * lexicographically, no whitespace, null/undefined rendered as empty string.
190
+ * The upstream verifies the signature against this exact serialization, so a
191
+ * payload built with plain `JSON.stringify` (insertion order) fails with
192
+ * `code 101 / Signature invalid`.
193
+ */
194
+ function qoderJsonSortedCompact(mapping: Record<string, unknown>): string {
195
+ const parts: string[] = [];
196
+ for (const key of Object.keys(mapping).sort()) {
197
+ const value = mapping[key] === null || mapping[key] === undefined ? "" : mapping[key];
198
+ parts.push(`${JSON.stringify(key)}:${JSON.stringify(value)}`);
199
+ }
200
+ return `{${parts.join(",")}}`;
201
+ }
133
202
  function qoderPathSignature(url: string): string {
134
203
  const path = new URL(url).pathname;
135
204
  return path.startsWith("/algo") ? path.slice(5) : path;
@@ -245,7 +314,7 @@ function qoderBuildCosyHeaders(
245
314
  version = getQoderVersion(),
246
315
  ): Record<string, string> {
247
316
  const key = randomUUID().replaceAll("-", "").slice(0, 16);
248
- const identity = JSON.stringify({
317
+ const identity = qoderJsonSortedCompact({
249
318
  name: auth.userName,
250
319
  aid: auth.userId,
251
320
  uid: auth.userId,
@@ -260,7 +329,7 @@ function qoderBuildCosyHeaders(
260
329
  const info = Buffer.concat([cipher.update(identity), cipher.final()]).toString("base64");
261
330
  const cosyKey = publicEncrypt({ key: QODER_RSA_PUBLIC_KEY, padding: constants.RSA_PKCS1_PADDING }, Buffer.from(key)).toString("base64");
262
331
  const payload = Buffer.from(
263
- JSON.stringify({ version: "v1", requestId: randomUUID(), info, cosyVersion: version, ideVersion: "" }),
332
+ qoderJsonSortedCompact({ version: "v1", requestId: randomUUID(), info, cosyVersion: version, ideVersion: "" }),
264
333
  ).toString("base64");
265
334
  const date = String(Math.floor(Date.now() / 1000));
266
335
  const signature = qoderMd5(`${payload}\n${cosyKey}\n${date}\n${Buffer.from(body).toString("latin1")}\n${qoderPathSignature(url)}`);
@@ -512,16 +581,25 @@ function qoderKeepsReasoning(modelId: string): boolean {
512
581
  *
513
582
  * Presence, not length, decides: a client that already knows the rule sends
514
583
  * `""` on a thinking turn whose text is not readable back, and dropping that
515
- * empty string re-triggers the complaint it was sent to avoid. An assistant
516
- * turn with no reasoning at all gains nothing, so nothing is fabricated.
584
+ * empty string re-triggers the complaint it was sent to avoid.
585
+ *
586
+ * DeepSeek multi-turn consistency also backfills the field on every assistant
587
+ * turn once the conversation carries a reasoning trace at all — the reference
588
+ * bridge does the same (`backfill_reasoning_content`), and notes the symptom
589
+ * when it is missed: occasional failures that sometimes pass on retry. An
590
+ * assistant turn with no reasoning and no trace gains nothing, so nothing is
591
+ * fabricated.
517
592
  */
518
593
  function qoderReasoningEcho(
519
594
  message: { content: readonly ContentPart[] },
520
595
  modelId: string,
596
+ hasReasoningTrace = false,
521
597
  ): { reasoning_content: string } | Record<string, never> {
522
598
  if (!qoderKeepsReasoning(modelId)) return {};
523
599
  const reasoning = message.content.filter((part) => part.kind === "reasoning");
524
- if (reasoning.length === 0) return {};
600
+ if (reasoning.length === 0) {
601
+ return hasReasoningTrace ? { reasoning_content: "" } : {};
602
+ }
525
603
  const text = reasoning
526
604
  .map((part) => (part.kind === "reasoning" ? (part.summary ?? part.payload ?? "") : ""))
527
605
  .join("");
@@ -576,7 +654,6 @@ function qoderTools(
576
654
  return out;
577
655
  }
578
656
 
579
-
580
657
  /**
581
658
  * Projects the canonical tool choice onto the upstream's field.
582
659
  *
@@ -598,26 +675,18 @@ function qoderToolChoice(choice: ToolChoice | undefined): unknown {
598
675
  return choice.mode;
599
676
  }
600
677
 
601
- /**
602
- * Marks a tool call recorded into the text of an assistant turn.
603
- *
604
- * The upstream only reads structured turns on its CN realm, so on INTL a past
605
- * call has to be described in prose or the model loses the record of what it
606
- * asked for. The wording matches the reference bridge, which is what the
607
- * upstream has been trained to read back.
608
- */
609
- const QODER_TOOL_MARKER = "[assistant 请求调用工具]";
610
-
611
678
  /**
612
679
  * Whether this request sends its tool history as structured turns.
613
680
  *
614
- * The upstream honours the structured form on its CN realm only. On INTL it is
615
- * not enabled, and sending it anyway yields tool calls that come back right in
616
- * name but with no arguments filled in — a call that cannot run. So the shape
617
- * follows the realm rather than being assumed everywhere.
681
+ * Qoder's wire is OpenAI-compatible `tool_calls` deltas on every realm; the
682
+ * model is fed the same structured turns in both INTL and CN. Writing a past
683
+ * call as prose (`[assistant 请求调用工具] …`) contaminates the conversation
684
+ * history with a text echo the model starts imitating — the origin of the
685
+ * text-leak feedback loop. Structured history keeps the model seeing only real
686
+ * tool calls, so it never learns the prose marker.
618
687
  */
619
- function qoderStructuredTools(realm: QoderRealm): boolean {
620
- return realm === "cn";
688
+ function qoderStructuredTools(_realm: QoderRealm): boolean {
689
+ return true;
621
690
  }
622
691
 
623
692
  export function buildQoderRequest(
@@ -644,12 +713,57 @@ export function buildQoderRequest(
644
713
  const systemPrompt = systemParts.join("\n\n").trim();
645
714
  const qoderMessages: Array<Record<string, unknown>> = [];
646
715
  const structuredTools = qoderStructuredTools(realm);
716
+ // DeepSeek multi-turn: once any turn in the conversation carries a reasoning
717
+ // trace, every assistant message must replay `reasoning_content` (see
718
+ // `qoderReasoningEcho`).
719
+ const hasReasoningTrace = allMessages.some((message) =>
720
+ message.content.some((part) => part.kind === "reasoning"),
721
+ );
647
722
  if (MODERN_PROFILE.mirrorTopLevelSystem && systemPrompt.length > 0) {
648
723
  qoderMessages.push({
649
724
  role: "system",
650
725
  content: systemPrompt,
651
726
  });
652
727
  }
728
+ // Every tool result across the whole conversation, keyed by call id. An
729
+ // assistant `tool_calls` turn is only sent structured when every call has a
730
+ // matching result somewhere in the history — otherwise the upstream rejects
731
+ // the transcript ("An assistant message with 'tool_calls' must be followed by
732
+ // tool messages responding to each 'tool_call_id'"). Calls without a result
733
+ // are left out of the structured turn instead of poisoning it.
734
+ const toolResultsById = new Map<string, string>();
735
+ for (const msg of allMessages) {
736
+ for (const part of toolResultParts(msg)) {
737
+ if (typeof part.call_id === "string" && part.call_id.length > 0) {
738
+ toolResultsById.set(part.call_id, part.call_id);
739
+ }
740
+ }
741
+ }
742
+ // The tool calls that actually survive onto the wire. A call only makes it
743
+ // when every call on its assistant turn carries an id AND a matching result
744
+ // exists in the history. This is the single source of truth for both sides
745
+ // of the transcript: the assistant `tool_calls` emit and the `tool` result
746
+ // turns. If a result's call is not here, its `tool` turn is dropped too —
747
+ // leaving it would emit an orphan `tool` turn with no preceding call, which
748
+ // the upstream rejects ("Messages with role 'tool' must be a response to a
749
+ // preceding message with 'tool_calls'"). This is what a cancelled or
750
+ // compacted parallel call looks like, and it is the "insufficient tool
751
+ // messages" failure the client sees on the turn after.
752
+ const emittableCallIds = new Set<string>();
753
+ for (const msg of allMessages) {
754
+ if (msg.role !== "assistant") continue;
755
+ const calls = toolCallParts(msg);
756
+ if (calls.length === 0) continue;
757
+ const idsOk = calls.every(
758
+ (part) => typeof part.call_id === "string" && part.call_id.length > 0,
759
+ );
760
+ const everyCallHasResult = calls.every((part) =>
761
+ typeof part.call_id === "string" ? toolResultsById.has(part.call_id) : false,
762
+ );
763
+ if (idsOk && everyCallHasResult) {
764
+ for (const part of calls) emittableCallIds.add(part.call_id as string);
765
+ }
766
+ }
653
767
  for (const m of allMessages) {
654
768
  if (m.role === "system" || m.role === "developer") continue;
655
769
  // Answers may live in a `user` turn (the Messages ledger re-homes them),
@@ -658,7 +772,13 @@ export function buildQoderRequest(
658
772
  // is emitted after, so nothing is duplicated or lost.
659
773
  const results = structuredTools ? toolResultParts(m) : [];
660
774
  if (results.length > 0) {
775
+ let emittedAnyTool = false;
661
776
  for (const block of results) {
777
+ // Drop orphan results: their assistant `tool_calls` turn was filtered
778
+ // out (a sibling call has no result), and emitting the `tool` turn
779
+ // anyway would make the upstream reject the whole transcript.
780
+ if (!emittableCallIds.has(block.call_id)) continue;
781
+ emittedAnyTool = true;
662
782
  const text = flattenCanonicalContent(
663
783
  typeof block.content === "string"
664
784
  ? [{ kind: "text", text: block.content }]
@@ -676,11 +796,13 @@ export function buildQoderRequest(
676
796
  // same content, which doubled the payload for no reason.
677
797
  const remaining = m.content.filter((part) => part.kind !== "toolResult");
678
798
  const flat = flattenCanonicalContent(remaining);
679
- if (flat.length === 0) continue;
680
- qoderMessages.push({
681
- role: "user",
682
- content: flat,
683
- });
799
+ if (flat.length === 0 && !emittedAnyTool) continue;
800
+ if (flat.length > 0) {
801
+ qoderMessages.push({
802
+ role: "user",
803
+ content: flat,
804
+ });
805
+ }
684
806
  continue;
685
807
  }
686
808
  // An assistant turn that called tools must carry the calls, not just its
@@ -689,29 +811,16 @@ export function buildQoderRequest(
689
811
  // rejected the whole transcript: "Messages with role 'tool' must be a
690
812
  // response to a preceding message with 'tool_calls'".
691
813
  const allCalls = m.content.filter((part) => part.kind === "toolCall");
692
- const reasoningEcho = qoderReasoningEcho(m, modelId);
693
- if (!structuredTools && allCalls.length > 0) {
694
- // INTL does not read structured turns, so the call is written into the
695
- // text instead of being dropped. Dropping it left the next `tool` turn
696
- // answering a call that was never made, and the model had no record of
697
- // what it had asked for — so it re-emitted a call with no arguments.
698
- //
699
- // The marker and the JSON shape mirror the reference bridge, which is
700
- // what the upstream has actually been trained to read back.
701
- const textParts = m.content.filter((part) => part.kind !== "toolCall");
702
- const prose = flattenCanonicalContent(textParts);
703
- const recorded = allCalls.map((part) => ({
704
- name: part.name,
705
- arguments: typeof part.arguments === "string" ? part.arguments : "",
706
- }));
707
- qoderMessages.push({
708
- role: "assistant",
709
- content: (prose.length > 0 ? prose + "\n\n" : "") + QODER_TOOL_MARKER + "\n" + JSON.stringify(recorded),
710
- ...reasoningEcho,
711
- });
712
- continue;
713
- }
714
- const calls = structuredTools ? allCalls : [];
814
+ const reasoningEcho = qoderReasoningEcho(m, modelId, hasReasoningTrace);
815
+ // Structured tool turns are only safe when every call carries an id AND a
816
+ // matching result exists in the history — the upstream rejects a transcript
817
+ // whose `tool_calls` is not fully answered by `tool` turns. The same set
818
+ // that gates the `tool` emit above decides this, so the two halves of the
819
+ // transcript can never disagree.
820
+ const calls =
821
+ structuredTools && allCalls.every((part) => emittableCallIds.has(part.call_id))
822
+ ? allCalls
823
+ : [];
715
824
  if (calls.length > 0) {
716
825
  const textParts = m.content.filter((part) => part.kind !== "toolCall");
717
826
  const flat = flattenCanonicalContent(textParts);
@@ -724,18 +833,52 @@ export function buildQoderRequest(
724
833
  type: "function",
725
834
  function: {
726
835
  name: part.name,
727
- arguments: typeof part.arguments === "string" ? part.arguments : "",
836
+ arguments:
837
+ typeof part.arguments === "string"
838
+ ? part.arguments
839
+ : part.arguments !== undefined && part.arguments !== null
840
+ ? JSON.stringify(part.arguments)
841
+ : "",
728
842
  },
729
843
  })),
730
844
  });
731
845
  continue;
732
846
  }
733
- const flat = flattenCanonicalContent(m.content);
734
847
  const role = m.role === "assistant" ? "assistant" : "user";
848
+ // Images must travel as structured OpenAI parts inside the message content —
849
+ // the upstream reads `image_url` parts there and not the top-level
850
+ // `image_urls` (mirroring `flatten_messages` in the reference bridge). A
851
+ // bare file_id reference degrades to a text note instead of being dropped.
852
+ const hasImages = m.content.some((part) => part.kind === "image");
853
+ if (hasImages) {
854
+ const parts: Array<Record<string, unknown>> = [];
855
+ for (const part of m.content) {
856
+ if (part.kind === "text") {
857
+ parts.push({ type: "text", text: part.text });
858
+ } else if (part.kind === "image") {
859
+ const source = resolveImageSource(part.payload);
860
+ if (source?.url !== undefined) {
861
+ parts.push({ type: "image_url", image_url: { url: source.url } });
862
+ } else {
863
+ parts.push({
864
+ type: "text",
865
+ text: source?.fileId !== undefined ? `[image: ${source.fileId}]` : "[image: unsupported source]",
866
+ });
867
+ }
868
+ }
869
+ }
870
+ qoderMessages.push({
871
+ role,
872
+ content: parts,
873
+ ...(role === "assistant" ? qoderReasoningEcho(m, modelId, hasReasoningTrace) : {}),
874
+ });
875
+ continue;
876
+ }
877
+ const flat = flattenCanonicalContent(m.content);
735
878
  qoderMessages.push({
736
879
  role,
737
880
  content: flat,
738
- ...(role === "assistant" ? qoderReasoningEcho(m, modelId) : {}),
881
+ ...(role === "assistant" ? qoderReasoningEcho(m, modelId, hasReasoningTrace) : {}),
739
882
  });
740
883
  }
741
884
  const latestUserMessage = [...qoderMessages].reverse().find((m) => m["role"] === "user");
@@ -806,234 +949,6 @@ export function buildQoderRequest(
806
949
  };
807
950
  }
808
951
 
809
- // ---------------------------------------------------------------------------
810
- // DSML tool-call recovery — DeepSeek's native markup in the text stream
811
- // ---------------------------------------------------------------------------
812
- // Some DeepSeek-family models write their tool calls as XML-like DSML markup
813
- // inside the plain-text `content` stream instead of the structured
814
- // `tool_calls` field. Left alone, the markup reaches the caller as visible
815
- // prose and the tool never runs — which is exactly the symptom reported.
816
- //
817
- // The reference bridge (`qoder_proxy.py`, `parse_dsml_tool_calls`) recovers
818
- // these at end of stream. This is the same rule: buffer once DSML appears,
819
- // parse at the end, emit the clean prose then the real tool calls.
820
- //
821
- // Patterns mirror the Python originals (`re.DOTALL` becomes the `s` flag):
822
- // TAG_START = r"<[^>]*DSML[^>]*"
823
- // DSML_CALLS_RE = TAG_START + r"calls>(.*?)</[^>]*DSML[^>]*calls>"
824
- // DSML_INVOKE_RE = TAG_START + r"invoke\s+name=["']([^"']+)["']>(.*?)</[^>]*invoke>"
825
- // DSML_PARAM_RE = TAG_START + r"parameter\s+name=["']([^"']+)["'][^>]*>(.*?)</[^>]*parameter>"
826
-
827
- const DSML_CALLS_RE = /<[^>]*DSML[^>]*calls>([\s\S]*?)<\/[^>]*DSML[^>]*calls>/;
828
- const DSML_INVOKE_RE = /<[^>]*DSML[^>]*invoke\s+name=["']([^"']+)["']>([\s\S]*?)<\/[^>]*invoke>/g;
829
- const DSML_PARAM_RE = /<[^>]*DSML[^>]*parameter\s+name=["']([^"']+)["'][^>]*>([\s\S]*?)<\/[^>]*parameter>/g;
830
-
831
- /** One recovered call, in the shape the canonical events want. */
832
- export interface DsmlCall {
833
- readonly id: string;
834
- readonly name: string;
835
- readonly arguments: string;
836
- }
837
-
838
- /**
839
- * Recovers DSML tool calls out of a plain-text string.
840
- *
841
- * `clean` is the text with the markup removed. With no DSML present, `calls`
842
- * is empty and `clean` is the original text unchanged — so this is safe to run
843
- * over every stream, not just suspected ones.
844
- */
845
- export function parseDsmlToolCalls(text: string): { calls: DsmlCall[]; clean: string } {
846
- if (text.length === 0 || !text.includes("DSML")) return { calls: [], clean: text };
847
-
848
- const callsMatch = DSML_CALLS_RE.exec(text);
849
- let block: string;
850
- let clean: string;
851
-
852
- if (callsMatch) {
853
- block = callsMatch[1] ?? "";
854
- const before = text.slice(0, callsMatch.index).trimEnd();
855
- const after = text.slice(callsMatch.index + callsMatch[0].length).trimStart();
856
- clean = (before + " " + after).trim();
857
- } else {
858
- // No <calls> wrapper (issue #51): find bare <invoke> blocks in the whole
859
- // text, then cut the prose off at the last `<` before the first invoke.
860
- DSML_INVOKE_RE.lastIndex = 0;
861
- const first = DSML_INVOKE_RE.exec(text);
862
- if (!first) return { calls: [], clean: text };
863
- block = text;
864
- const cut = text.lastIndexOf("<", first.index);
865
- clean = text.slice(0, cut >= 0 ? cut : first.index).trim();
866
- }
867
-
868
- const calls: DsmlCall[] = [];
869
- DSML_INVOKE_RE.lastIndex = 0;
870
- let inv: RegExpExecArray | null;
871
- while ((inv = DSML_INVOKE_RE.exec(block)) !== null) {
872
- const params: Record<string, string> = {};
873
- DSML_PARAM_RE.lastIndex = 0;
874
- let par: RegExpExecArray | null;
875
- while ((par = DSML_PARAM_RE.exec(inv[2] ?? "")) !== null) {
876
- params[par[1] ?? ""] = (par[2] ?? "").trim();
877
- }
878
- calls.push({
879
- id: `call_${randomUUID().replace(/-/g, "").slice(0, 16)}`,
880
- name: inv[1] ?? "",
881
- arguments: JSON.stringify(params),
882
- });
883
- }
884
-
885
- return { calls, clean };
886
- }
887
-
888
- /**
889
- * Escapes the bare quotes that belong inside a JSON string value.
890
- *
891
- * Escaped quotes are parked first so they are not escaped twice, then every
892
- * remaining quote is escaped, then the parked ones are restored.
893
- */
894
- function repairJsonQuotes(text: string): string {
895
- const ph = "\u0000";
896
- return text.replace(/\\"/g, ph).replace(/"/g, '\\"').replaceAll(ph, '\\"');
897
- }
898
-
899
- /** `[{"name":"…","arguments":"<unescaped JSON>"}]`, one call only. */
900
- const ECHO_LOOSE_RE = /^\[\s*\{\s*"name"\s*:\s*"([^"]{1,200})"\s*,\s*"arguments"\s*:\s*"(.*)"\s*\}\s*\]$/s;
901
-
902
- /**
903
- * Rebuilds the single-call echo shape when strict parsing fails.
904
- *
905
- * Returns `null` when the text is not that shape at all, so a caller can fall
906
- * back rather than accepting a guess. The result still goes through the same
907
- * strict checks as a normally-parsed array.
908
- */
909
- function salvageEchoArray(rest: string): unknown {
910
- const m = ECHO_LOOSE_RE.exec(rest.trim());
911
- if (!m) return null;
912
- const name = m[1] ?? "";
913
- const raw = m[2] ?? "";
914
- for (const candidate of [raw, repairJsonQuotes(raw)]) {
915
- try {
916
- JSON.parse(candidate);
917
- return [{ name, arguments: candidate }];
918
- } catch {
919
- continue;
920
- }
921
- }
922
- return null;
923
- }
924
-
925
- /**
926
- * Recovers a tool call the model echoed back as text.
927
- *
928
- * On INTL the upstream reads tool calls as prose, so a turn that called a tool
929
- * is described with `QODER_TOOL_MARKER` followed by a JSON array of
930
- * `{name, arguments}`. The model occasionally reproduces that shape verbatim as
931
- * its answer instead of calling again, and left alone the client shows the JSON
932
- * as content while the tool never runs.
933
- *
934
- * Only a well-formed array whose every entry names a tool and carries parseable
935
- * arguments is accepted. Anything else returns no calls and the original text,
936
- * so a model merely talking about the format is never mistaken for a call.
937
- */
938
- export function parseLeakedToolCalls(
939
- text: string,
940
- allowedNames?: ReadonlySet<string>,
941
- ): { calls: DsmlCall[]; clean: string } {
942
- if (text.length === 0 || !text.includes(QODER_TOOL_MARKER)) {
943
- return { calls: [], clean: text };
944
- }
945
- const at = text.indexOf(QODER_TOOL_MARKER);
946
- const head = text.slice(0, at).trim();
947
- const rest = text.slice(at + QODER_TOOL_MARKER.length).trim();
948
-
949
- let parsed: unknown;
950
- try {
951
- parsed = JSON.parse(rest);
952
- } catch {
953
- // A model echoing the call writes the inner quotes unescaped:
954
- // `[{"name":"Read","arguments":"{"file_path":"a.ts"}"}]`. That is not
955
- // valid JSON at either level, so the array has to be rebuilt before it can
956
- // be trusted. Only the single-call shape is salvaged, which is what an echo
957
- // almost always is.
958
- const salvaged = salvageEchoArray(rest);
959
- if (salvaged === null) return { calls: [], clean: text };
960
- parsed = salvaged;
961
- }
962
- if (!Array.isArray(parsed) || parsed.length === 0) return { calls: [], clean: text };
963
-
964
- const calls: DsmlCall[] = [];
965
- for (const item of parsed) {
966
- if (typeof item !== "object" || item === null) return { calls: [], clean: text };
967
- const rec = item as Record<string, unknown>;
968
- const name = typeof rec["name"] === "string" ? rec["name"].trim() : "";
969
- if (name.length === 0) return { calls: [], clean: text };
970
- if (allowedNames && !allowedNames.has(name)) return { calls: [], clean: text };
971
- let args = rec["arguments"];
972
- if (typeof args === "object" && args !== null) args = JSON.stringify(args);
973
- if (typeof args !== "string") return { calls: [], clean: text };
974
- // Arguments that do not parse are not a call this can trust.
975
- if (args.trim().length > 0) {
976
- try {
977
- JSON.parse(args);
978
- } catch {
979
- return { calls: [], clean: text };
980
- }
981
- }
982
- calls.push({ id: `call_${randomUUID().replace(/-/g, "").slice(0, 16)}`, name, arguments: args });
983
- }
984
- return { calls, clean: head };
985
- }
986
-
987
- /**
988
- * The holdable tail of a content chunk: a short suffix that looks like the
989
- * start of a DSML tag and may finish in the next frame. Empty when there is
990
- * nothing worth holding.
991
- *
992
- * Mirrors `_dsml_tail_hold` in the reference bridge.
993
- */
994
- export function dsmlTailHold(text: string): string {
995
- const at = text.lastIndexOf("<");
996
- if (at < 0 || text.length - at > 24) return "";
997
- const tail = text.slice(at);
998
- // A complete tag already — nothing to wait for.
999
- if (tail.includes(">")) return "";
1000
- const body = tail.replace(/\uFF5C/g, "|").toUpperCase().slice(1).replace(/^\//, "");
1001
- if (body.length === 0) return tail; // bare "<" or "</"
1002
- if (!body.startsWith("|")) return "";
1003
- const rest = body.slice(1);
1004
- if (rest.length === 0) return tail;
1005
- if ("DSML".startsWith(rest) || rest.startsWith("DSML")) return tail;
1006
- return "";
1007
- }
1008
-
1009
- /**
1010
- * Removes leftover DSML closing tags from the tail of a text.
1011
- *
1012
- * The upstream sometimes leaks only the tail of a tool-call block — bare
1013
- * `</parameter>` / `</invoke>` / `</calls>` with no opening and, in the
1014
- * thinking channel, occasionally without the `DSML` marker at all. Those
1015
- * fragments are pure protocol residue: they are never natural prose, so
1016
- * dropping them is safe and keeps internal markup out of what the user sees.
1017
- *
1018
- * Anchored to the end, as in the reference bridge: a closing tag in the middle
1019
- * of a text is not this residue and stripping it would eat real prose. The
1020
- * caller loops, so several trailing tags come off one per pass.
1021
- *
1022
- * Mirrors `_strip_dsml_close_tail`, widened to the bare form because the
1023
- * thinking channel does not always carry the marker.
1024
- */
1025
- const DSML_CLOSE_TAG_RE = /<\/(?:[^>]*DSML[^>]*|parameter|invoke|calls|tool_calls|function_calls)\s*>\s*$/i;
1026
-
1027
- export function stripDsmlCloseTags(text: string): string {
1028
- let previous: string | null = null;
1029
- let out = text.trimEnd();
1030
- while (previous !== out) {
1031
- previous = out;
1032
- out = out.replace(DSML_CLOSE_TAG_RE, "").trimEnd();
1033
- }
1034
- return out;
1035
- }
1036
-
1037
952
  // Stream transformation — decode SSE, unwrap COSY envelope, map OpenAI chat chunks to CanonicalEvent
1038
953
 
1039
954
  function mapQoderFinishReason(raw: unknown): CanonicalStopReason | undefined {
@@ -1053,20 +968,16 @@ export async function* qoderBodyToCanonicalEvents(
1053
968
  let finishReason: unknown = undefined;
1054
969
  let rawUsage: Record<string, unknown> | undefined = undefined;
1055
970
  let hasTerminal = false;
1056
- // DSML recovery state. While `buf` is empty the text passes through
1057
- // untouched; once a chunk carries DSML the rest is buffered so the markup can
1058
- // be parsed whole at end of stream. `tail` holds a possible half-tag that
1059
- // straddles a frame boundary.
1060
- let buf = "";
1061
- let tail = "";
1062
- /** Calls recovered from the thinking channel, emitted if content had none. */
1063
- let reasonCalls: DsmlCall[] = [];
1064
- // The same recovery for the thinking channel. A model that plans its call
1065
- // inside `reasoning_content` writes the markup there instead of in `content`,
1066
- // so recovering only the content channel loses the call *and* leaves the
1067
- // tags visible in the user's thinking.
1068
- let reasonBuf = "";
1069
- let reasonTail = "";
971
+ /** First-sighting id per wire tool-call index, for continuation chunks. */
972
+ const indexIds = new Map<number, string>();
973
+ /** Last seen call id/name, for index-less continuation fragments. */
974
+ let lastSeenToolId: string | undefined = undefined;
975
+ let lastSeenToolName: string | undefined = undefined;
976
+ // INTL leak-marker hold-back: text that may grow into the marker+JSON echo
977
+ // is withheld from the client until end of stream proves it is a tool call
978
+ // (recovered) or ordinary prose (released).
979
+ let leakBuf = "";
980
+ let leakFrames = 0;
1070
981
 
1071
982
  for await (const sse of decodeSseEvents(body, { signal })) {
1072
983
  const sseData = sse.data;
@@ -1095,66 +1006,120 @@ export async function* qoderBodyToCanonicalEvents(
1095
1006
  const delta = choice["delta"] as Record<string, unknown> | undefined;
1096
1007
  if (delta) {
1097
1008
  if (typeof delta["content"] === "string" && (delta["content"] as string).length > 0) {
1098
- const piece = tail + (delta["content"] as string);
1099
- tail = "";
1100
- if (buf.length === 0) {
1101
- // Not yet in a DSML stream: hold back only a possible tag start,
1102
- // and pass everything before it through unchanged.
1103
- const hold = dsmlTailHold(piece);
1104
- const body = hold.length > 0 ? piece.slice(0, piece.length - hold.length) : piece;
1105
- if (body.includes("DSML")) {
1106
- buf = body;
1107
- tail = hold;
1108
- continue;
1009
+ const piece = delta["content"] as string;
1010
+ // Marker+JSON hold-back. A complete marker and everything after it is
1011
+ // held until end of stream; prose before it is emitted now.
1012
+ if (leakBuf.length > 0) {
1013
+ leakBuf += piece;
1014
+ leakFrames = piece.length === 0 ? leakFrames + 1 : 0;
1015
+ if (
1016
+ leakBuf.length > LEAK_HOLD_MAX_CHARS ||
1017
+ leakFrames > LEAK_HOLD_MAX_FRAMES ||
1018
+ !qoderStillLeakCandidate(leakBuf)
1019
+ ) {
1020
+ if (leakBuf.length > 0) {
1021
+ yield {
1022
+ type: "content_delta",
1023
+ sequence_number: seq++,
1024
+ content: { kind: "text", text: leakBuf },
1025
+ } as CanonicalEvent;
1026
+ }
1027
+ leakBuf = "";
1028
+ leakFrames = 0;
1109
1029
  }
1110
- if (body.length > 0) {
1030
+ continue;
1031
+ }
1032
+ const marker = LEAK_MARKER_ANY_RE.exec(piece);
1033
+ if (marker) {
1034
+ const emit = piece.slice(0, marker.index);
1035
+ if (emit.length > 0) {
1111
1036
  yield {
1112
1037
  type: "content_delta",
1113
1038
  sequence_number: seq++,
1114
- content: { kind: "text", text: body },
1039
+ content: { kind: "text", text: emit },
1115
1040
  } as CanonicalEvent;
1116
1041
  }
1117
- tail = hold;
1042
+ leakBuf = piece.slice(marker.index);
1043
+ leakFrames = 1;
1118
1044
  continue;
1119
1045
  }
1120
- buf += piece;
1121
- continue;
1122
- }
1123
- const reasoningText = readReasoningText(delta);
1124
- if (typeof reasoningText === "string" && reasoningText.length > 0) {
1125
- const piece = reasonTail + reasoningText;
1126
- reasonTail = "";
1127
- if (reasonBuf.length === 0) {
1128
- const hold = dsmlTailHold(piece);
1129
- const body = hold.length > 0 ? piece.slice(0, piece.length - hold.length) : piece;
1130
- if (body.includes("DSML")) {
1131
- reasonBuf = body;
1132
- reasonTail = hold;
1133
- continue;
1046
+ // No marker: hold only a possible partial-marker suffix.
1047
+ const tail = piece.slice(-LEAK_HOLD_WINDOW);
1048
+ let heldFrom = -1;
1049
+ for (let n = tail.length; n >= 1; n -= 1) {
1050
+ if (isLeakMarkerPrefix(tail.slice(-n))) {
1051
+ heldFrom = piece.length - n;
1052
+ break;
1134
1053
  }
1135
- const visible = stripDsmlCloseTags(body);
1136
- if (visible.length > 0) {
1054
+ }
1055
+ if (heldFrom >= 0) {
1056
+ const emit = piece.slice(0, heldFrom);
1057
+ if (emit.length > 0) {
1137
1058
  yield {
1138
1059
  type: "content_delta",
1139
1060
  sequence_number: seq++,
1140
- content: { kind: "reasoning", payload: null, summary: visible },
1061
+ content: { kind: "text", text: emit },
1141
1062
  } as CanonicalEvent;
1142
1063
  }
1143
- reasonTail = hold;
1064
+ leakBuf = piece.slice(heldFrom);
1065
+ leakFrames = 1;
1144
1066
  continue;
1145
1067
  }
1146
- reasonBuf += piece;
1068
+ yield {
1069
+ type: "content_delta",
1070
+ sequence_number: seq++,
1071
+ content: { kind: "text", text: piece },
1072
+ } as CanonicalEvent;
1073
+ continue;
1074
+ }
1075
+ const reasoningText = readReasoningText(delta);
1076
+ if (typeof reasoningText === "string" && reasoningText.length > 0) {
1077
+ yield {
1078
+ type: "content_delta",
1079
+ sequence_number: seq++,
1080
+ content: { kind: "reasoning", payload: null, summary: reasoningText },
1081
+ } as CanonicalEvent;
1147
1082
  continue;
1148
1083
  }
1149
1084
  const toolCalls = delta["tool_calls"] as Array<Record<string, unknown>> | undefined;
1150
1085
  if (toolCalls) {
1151
1086
  for (const tc of toolCalls) {
1152
1087
  const fn = tc["function"] as Record<string, unknown> | undefined;
1088
+ const index = typeof tc["index"] === "number" ? (tc["index"] as number) : undefined;
1089
+ const declaredId = typeof tc["id"] === "string" ? (tc["id"] as string) : undefined;
1090
+ const fragName = typeof fn?.["name"] === "string" ? (fn["name"] as string) : undefined;
1091
+ // OpenAI continuation chunks re-identify their call by wire
1092
+ // `index` alone — the `id` appears only once, in the first chunk.
1093
+ // Some bridges omit `index` too, so a fragment then falls back to
1094
+ // the most recently seen call id (mirroring the generic chat
1095
+ // parser's `lastSeenToolId`). Pinning the first id per index and
1096
+ // reusing it for continuations is what keeps fragments attached
1097
+ // to the right call; synthesizing a fresh `call_<index>` id per
1098
+ // chunk instead made every continuation an unknown nameless call
1099
+ // that encoders dropped, so the client saw a call whose arguments
1100
+ // were always empty.
1101
+ let callId: string;
1102
+ if (declaredId !== undefined) {
1103
+ callId = declaredId;
1104
+ if (index !== undefined) indexIds.set(index, callId);
1105
+ lastSeenToolId = callId;
1106
+ if (fragName !== undefined) lastSeenToolName = fragName;
1107
+ } else if (index !== undefined && indexIds.has(index)) {
1108
+ callId = indexIds.get(index)!;
1109
+ } else if (
1110
+ lastSeenToolId !== undefined &&
1111
+ (fragName === undefined || lastSeenToolName === fragName)
1112
+ ) {
1113
+ callId = lastSeenToolId;
1114
+ } else {
1115
+ callId = `call_${String(index ?? 0)}`;
1116
+ }
1153
1117
  yield {
1154
1118
  type: "tool_call_delta",
1155
1119
  sequence_number: seq++,
1156
- call_id: (tc["id"] as string) ?? `call_${String(tc["index"] ?? 0)}`,
1157
- name: fn?.["name"] as string | undefined,
1120
+ call_id: callId,
1121
+ ...(index !== undefined ? { index } : {}),
1122
+ ...(fragName === undefined ? {} : { name: fragName }),
1158
1123
  arguments_delta: (fn?.["arguments"] as string) ?? "",
1159
1124
  } as CanonicalEvent;
1160
1125
  }
@@ -1169,44 +1134,13 @@ export async function* qoderBodyToCanonicalEvents(
1169
1134
  if (json["finish_reason"] !== undefined) finishReason = json["finish_reason"];
1170
1135
  }
1171
1136
  }
1172
- // End of stream: resolve whatever the DSML buffer collected.
1173
- if (reasonBuf.length > 0) {
1174
- const { calls, clean } = parseDsmlToolCalls(reasonBuf);
1175
- const visible = stripDsmlCloseTags(clean).trim();
1176
- if (visible.length > 0) {
1177
- yield {
1178
- type: "content_delta",
1179
- sequence_number: seq++,
1180
- content: { kind: "reasoning", payload: null, summary: visible },
1181
- } as CanonicalEvent;
1182
- }
1183
- // A call recovered here counts exactly like one recovered from the content
1184
- // channel — the two are deduplicated below, so a model that writes the
1185
- // block in one channel and its tail in the other still yields one call.
1186
- if (calls.length > 0) reasonCalls = calls;
1187
- }
1188
- if (buf.length > 0) {
1189
- // Two ways a call can arrive as text: DSML markup, and the marker + JSON
1190
- // array the upstream itself uses on INTL. The second is tried only when the
1191
- // first finds nothing, so a DSML stream is never re-read as an echo.
1192
- let { calls, clean } = parseDsmlToolCalls(buf);
1193
- if (calls.length === 0) {
1194
- const leaked = parseLeakedToolCalls(buf);
1195
- if (leaked.calls.length > 0) {
1196
- calls = leaked.calls;
1197
- clean = leaked.clean;
1198
- }
1199
- }
1200
- if (calls.length > 0) {
1201
- const visible = stripDsmlCloseTags(clean).trim();
1202
- if (visible.length > 0) {
1203
- yield {
1204
- type: "content_delta",
1205
- sequence_number: seq++,
1206
- content: { kind: "text", text: visible },
1207
- } as CanonicalEvent;
1208
- }
1209
- for (const call of calls) {
1137
+ // End of stream: recover a held-back marker+JSON echo. A valid echo becomes
1138
+ // tool calls; anything else is released as ordinary prose (or fails closed
1139
+ // when it is unmistakably an echo that cannot be parsed).
1140
+ if (leakBuf.length > 0) {
1141
+ const leaked = parseQoderTextToolCalls(leakBuf);
1142
+ if (leaked.calls.length > 0) {
1143
+ for (const call of leaked.calls) {
1210
1144
  yield {
1211
1145
  type: "tool_call_delta",
1212
1146
  sequence_number: seq++,
@@ -1215,13 +1149,14 @@ export async function* qoderBodyToCanonicalEvents(
1215
1149
  arguments_delta: call.arguments,
1216
1150
  } as CanonicalEvent;
1217
1151
  }
1218
- reasonCalls = [];
1219
- // The turn ended by calling a tool, whatever the upstream reported.
1220
1152
  finishReason = "tool_calls";
1221
1153
  } else {
1222
- // Unparseable markup: emit as prose rather than dropping it, so nothing
1223
- // the model said is lost.
1224
- const visible = stripDsmlCloseTags(buf).trim();
1154
+ // An echo that could not be recovered must fail the turn (502) so the
1155
+ // harness retries — never surface protocol text as visible content.
1156
+ if (isQoderEchoAttempt(leakBuf)) {
1157
+ throw badEchoError();
1158
+ }
1159
+ const visible = leaked.clean.trim();
1225
1160
  if (visible.length > 0) {
1226
1161
  yield {
1227
1162
  type: "content_delta",
@@ -1231,28 +1166,6 @@ export async function* qoderBodyToCanonicalEvents(
1231
1166
  }
1232
1167
  }
1233
1168
  }
1234
- // Content carried no call of its own: whatever the thinking channel
1235
- // recovered is the turn's call.
1236
- for (const call of reasonCalls) {
1237
- yield {
1238
- type: "tool_call_delta",
1239
- sequence_number: seq++,
1240
- call_id: call.id,
1241
- name: call.name,
1242
- arguments_delta: call.arguments,
1243
- } as CanonicalEvent;
1244
- }
1245
- // A recovered call outranks what the upstream reported. It says `stop`
1246
- // because the markup never became a real tool call on its side, so leaving it
1247
- // alone ends the turn "finished" with a call the caller never sees run.
1248
- if (reasonCalls.length > 0) finishReason = "tool_calls";
1249
- if (tail.length > 0) {
1250
- yield {
1251
- type: "content_delta",
1252
- sequence_number: seq++,
1253
- content: { kind: "text", text: tail },
1254
- } as CanonicalEvent;
1255
- }
1256
1169
  // If no terminal arrived, synthesize one (prevents truncation error)
1257
1170
  const stopReason = mapQoderFinishReason(finishReason);
1258
1171
  const _providerStop = typeof finishReason === "string" ? finishReason : undefined;
@@ -1371,11 +1284,13 @@ class QoderAdapter implements ProviderAdapter {
1371
1284
  const qoderBody = buildQoderRequest(modelId, request, modelConfig, auth, this.realm);
1372
1285
  // A full URL from the route wins, but the fallback is this realm's
1373
1286
  // gateway — not the intl host hardcoded into `MODERN_PROFILE`. Dispatching
1374
- // a CN credential to `*.qoder.sh` is refused outright.
1287
+ // a CN credential to `*.qoder.sh` is refused outright. The host order
1288
+ // follows the token kind: job tokens (`jt-...`) belong on `api2`, which
1289
+ // is the only intl host that serves them (`api3` answers "Login expired").
1375
1290
  const url =
1376
1291
  candidate.endpoint_path && candidate.endpoint_path.startsWith("http")
1377
1292
  ? candidate.endpoint_path
1378
- : `${qoderGatewayCandidates(this.realm)[0] ?? ""}${QODER_CHAT_PATH}`;
1293
+ : `${qoderInferenceCandidates(this.realm, auth.securityOauthToken)[0] ?? ""}${QODER_CHAT_PATH}`;
1379
1294
 
1380
1295
  const response = await callQoder(url, qoderBody, modelId, auth, lifecycle.signal, fetcher, version);
1381
1296
  if (!response.ok) throw await mapUpstreamHttpError(response, "qoder");