cartethyia 2.7.0 → 2.7.1

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 (320) hide show
  1. package/.env.example +107 -106
  2. package/README.md +25 -2
  3. package/dist/dashboard/assets/{BannedPage-P76QLGJA.js → BannedPage-D-J75hBM.js} +1 -1
  4. package/dist/dashboard/assets/BannedPage-D-J75hBM.js.gz +0 -0
  5. package/dist/dashboard/assets/{CliToolDetailPage-DdBggJS9.js → CliToolDetailPage-CVe6RaBH.js} +1 -1
  6. package/dist/dashboard/assets/CliToolDetailPage-CVe6RaBH.js.gz +0 -0
  7. package/dist/dashboard/assets/{CliToolsPage-aN1iXhhP.js → CliToolsPage-Dd8ZSamp.js} +1 -1
  8. package/dist/dashboard/assets/CliToolsPage-Dd8ZSamp.js.gz +0 -0
  9. package/dist/dashboard/assets/{CombosPage-DE-F1nxZ.js → CombosPage-QoJ6foxo.js} +1 -1
  10. package/dist/dashboard/assets/CombosPage-QoJ6foxo.js.gz +0 -0
  11. package/dist/dashboard/assets/ConsoleLogPage-DpNRay4C.js +1 -0
  12. package/dist/dashboard/assets/ConsoleLogPage-DpNRay4C.js.gz +0 -0
  13. package/dist/dashboard/assets/{CustomizationPage-3CUxMy2X.js → CustomizationPage-CKUFTcW4.js} +1 -1
  14. package/dist/dashboard/assets/CustomizationPage-CKUFTcW4.js.gz +0 -0
  15. package/dist/dashboard/assets/HealthEventsModal-CzSLiDzr.js +1 -0
  16. package/dist/dashboard/assets/HealthEventsModal-CzSLiDzr.js.gz +0 -0
  17. package/dist/dashboard/assets/LoginPage-DubdQcvX.js +1 -0
  18. package/dist/dashboard/assets/LoginPage-DubdQcvX.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-DuWzAQFL.js +1 -0
  22. package/dist/dashboard/assets/OverviewPage-DuWzAQFL.js.gz +0 -0
  23. package/dist/dashboard/assets/ProviderDetailPage-BKEKe2Fm.js +4 -0
  24. package/dist/dashboard/assets/ProviderDetailPage-BKEKe2Fm.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-Bwotu8UJ.js +1 -0
  28. package/dist/dashboard/assets/ProvidersPage-Bwotu8UJ.js.gz +0 -0
  29. package/dist/dashboard/assets/ProxyPage-D7hpWbg9.js +9 -0
  30. package/dist/dashboard/assets/ProxyPage-D7hpWbg9.js.gz +0 -0
  31. package/dist/dashboard/assets/{QuotaPage-BjldQwIU.js → QuotaPage-ATpaxrbl.js} +1 -1
  32. package/dist/dashboard/assets/QuotaPage-ATpaxrbl.js.gz +0 -0
  33. package/dist/dashboard/assets/{SettingsPage-DUIoLRxk.js → SettingsPage-CETophpx.js} +1 -1
  34. package/dist/dashboard/assets/SettingsPage-CETophpx.js.gz +0 -0
  35. package/dist/dashboard/assets/SetupPage-BdgECjqq.js +1 -0
  36. package/dist/dashboard/assets/SetupPage-BdgECjqq.js.gz +0 -0
  37. package/dist/dashboard/assets/{SortableList-fI7JkdIB.js → SortableList-CAK-EN9z.js} +1 -1
  38. package/dist/dashboard/assets/SortableList-CAK-EN9z.js.gz +0 -0
  39. package/dist/dashboard/assets/{StudioPage-CHVZqskZ.js → StudioPage-B7W2_RhT.js} +3 -3
  40. package/dist/dashboard/assets/StudioPage-B7W2_RhT.js.gz +0 -0
  41. package/dist/dashboard/assets/UsagePage-6T4EFDjW.js +56 -0
  42. package/dist/dashboard/assets/UsagePage-6T4EFDjW.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-CWBY5A0f.js → entry-BLBLxX0h.js} +3 -3
  64. package/dist/dashboard/assets/entry-BLBLxX0h.js.gz +0 -0
  65. package/dist/dashboard/assets/{entry-BJfsVMvA.js → entry-Bqy8RFvM.js} +1 -1
  66. package/dist/dashboard/assets/entry-Bqy8RFvM.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-mpBPqAQ8.js +1 -0
  70. package/dist/dashboard/assets/formatters-mpBPqAQ8.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-D6QzEC_n.js} +2 -2
  74. package/dist/dashboard/assets/index-D6QzEC_n.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-Cj5Nw43g.js} +1 -1
  96. package/dist/dashboard/assets/select-Cj5Nw43g.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/console-router.ts +233 -225
  125. package/src/console/domain-registration.ts +398 -381
  126. package/src/console/providers/catalog/catalog-projections.ts +2 -6
  127. package/src/console/providers/catalog/contracts.ts +19 -0
  128. package/src/console/providers/catalog/provider-operations.ts +38 -0
  129. package/src/console/providers/catalog/routes.ts +14 -0
  130. package/src/console/providers/catalog/store-helpers.ts +326 -0
  131. package/src/console/providers/catalog/store.ts +1292 -1563
  132. package/src/console/quota/account-quota-global.ts +2 -2
  133. package/src/console/quota/account-quota-shared.ts +1 -1
  134. package/src/console/quota/account-quota-tenant.ts +35 -12
  135. package/src/console/quota/quota-view.ts +2 -2
  136. package/src/console/routing/pools/contracts.ts +41 -1
  137. package/src/console/routing/pools/operations.ts +183 -4
  138. package/src/console/routing/pools/relay-deploy.ts +17 -4
  139. package/src/console/routing/pools/routes.ts +30 -0
  140. package/src/console/routing/pools/store.ts +98 -8
  141. package/src/main.ts +183 -167
  142. package/src/network/outbound-fetch.ts +226 -365
  143. package/src/network/pool/agent.ts +8 -0
  144. package/src/network/pool/pool-state.ts +7 -0
  145. package/src/network/pool/selector.ts +110 -2
  146. package/src/network/pool-cooldown-store.ts +252 -0
  147. package/src/network/pool-health-machine.ts +5 -3
  148. package/src/network/pool-health.ts +36 -9
  149. package/src/observability/event-query.ts +57 -0
  150. package/src/observability/metrics.ts +467 -467
  151. package/src/observability/runtime-metrics.ts +269 -269
  152. package/src/persistence/health-event-flush.ts +2 -0
  153. package/src/persistence/schema.ts +19 -0
  154. package/src/persistence/usage-flush.ts +8 -3
  155. package/src/protocol/canonical-model.ts +1 -1
  156. package/src/protocol/dsml.ts +377 -0
  157. package/src/protocol/gateway-error.ts +232 -232
  158. package/src/{providers/provider-reasoning.ts → protocol/reasoning.ts} +10 -9
  159. package/src/protocol/request/chat.ts +7 -7
  160. package/src/protocol/request/codex.ts +2 -2
  161. package/src/protocol/request/gemini.ts +1 -1
  162. package/src/protocol/request/messages.ts +6 -6
  163. package/src/protocol/request/responses.ts +7 -7
  164. package/src/protocol/response/dsml.ts +12 -133
  165. package/src/{transport → protocol}/translation/attachment-integrity.ts +2 -2
  166. package/src/{transport → protocol}/translation/cache-controls.ts +1 -1
  167. package/src/{transport → protocol}/translation/capabilities.ts +2 -2
  168. package/src/{transport → protocol}/translation/normalize.ts +1 -1
  169. package/src/{transport → protocol}/translation/quirks.ts +1 -1
  170. package/src/{transport → protocol}/translation/search.ts +1 -1
  171. package/src/{transport → protocol}/translation/thinking.ts +23 -1
  172. package/src/{transport → protocol}/translation/tool-id.ts +1 -1
  173. package/src/{transport → protocol}/translation/tool-repair.ts +2 -2
  174. package/src/protocol/upstream-parsing.ts +176 -0
  175. package/src/providers/adapters/compatible-adapter.ts +11 -2
  176. package/src/providers/authentication/oauth-refresh-service.ts +9 -5
  177. package/src/providers/default-registry.ts +21 -5
  178. package/src/providers/discovery/probe-phases.ts +16 -3
  179. package/src/providers/discovery/probing-service.ts +4 -1
  180. package/src/providers/integrations/anthropic.ts +1 -1
  181. package/src/providers/integrations/buddy/buddy-catalog-shared.ts +1 -4
  182. package/src/providers/integrations/buddy/buddy-chat-shared.ts +141 -8
  183. package/src/providers/integrations/buddy/buddy-checkin.ts +5 -1
  184. package/src/providers/integrations/buddy/buddy-growth-shared.ts +0 -82
  185. package/src/providers/integrations/buddy/buddy-identity.ts +25 -0
  186. package/src/providers/integrations/buddy/buddy-server-tools.ts +240 -0
  187. package/src/providers/integrations/buddy/codebuddy/cn-catalog.ts +36 -0
  188. package/src/providers/integrations/buddy/codebuddy/cn-provider.ts +6 -31
  189. package/src/providers/integrations/buddy/codebuddy/provider.ts +2 -0
  190. package/src/providers/integrations/buddy/codebuddy/shared.ts +19 -4
  191. package/src/providers/integrations/buddy/turing-device-token.ts +262 -0
  192. package/src/providers/integrations/buddy/workbuddy/provider.ts +2 -0
  193. package/src/providers/integrations/buddy/workbuddy/shared.ts +56 -3
  194. package/src/providers/integrations/claude/claude.ts +1 -1
  195. package/src/providers/integrations/cloudflareai/cloudflareai.ts +1 -1
  196. package/src/providers/integrations/codex/codex-errors.ts +1 -1
  197. package/src/providers/integrations/codex/codex.ts +1 -1
  198. package/src/providers/integrations/factory/factory.ts +1 -1
  199. package/src/providers/integrations/github/github.ts +1 -1
  200. package/src/providers/integrations/grok/grok.ts +1 -1
  201. package/src/providers/integrations/inception/provider.ts +91 -0
  202. package/src/providers/integrations/inferhub.ts +1 -1
  203. package/src/providers/integrations/kiro/kiro-request.ts +1 -1
  204. package/src/providers/integrations/mistral.ts +193 -19
  205. package/src/providers/integrations/opencode/fingerprint.ts +19 -2
  206. package/src/providers/integrations/opencode/prompt.ts +111 -0
  207. package/src/providers/integrations/opencode/provider.ts +101 -23
  208. package/src/providers/integrations/opencode/tool-definitions.ts +4 -4
  209. package/src/providers/integrations/qoder/qoder-checkin.ts +303 -0
  210. package/src/providers/integrations/qoder/qoder.ts +1641 -1458
  211. package/src/providers/integrations/shared/install-id.ts +2 -1
  212. package/src/providers/integrations/trae/trae-cache.ts +110 -0
  213. package/src/providers/integrations/trae/trae-oauth.ts +29 -4
  214. package/src/providers/integrations/trae/trae-protocol.ts +2 -1
  215. package/src/providers/integrations/trae/trae.ts +106 -23
  216. package/src/providers/operations/account-error-classification.ts +1660 -0
  217. package/src/providers/operations/account-health-service.ts +621 -2097
  218. package/src/providers/operations/account-reset-service.ts +2 -0
  219. package/src/providers/provider-contracts.ts +0 -2
  220. package/src/providers/provider-metadata.ts +25 -4
  221. package/src/providers/provider-probe.ts +2 -2
  222. package/src/{console → providers}/quota/cache.ts +1 -1
  223. package/src/{console → providers}/quota/refresh.ts +5 -5
  224. package/src/transport/dispatch/attempt-finalize.ts +114 -12
  225. package/src/transport/dispatch/attempt-loop.ts +3 -0
  226. package/src/transport/dispatch/empty-stream.ts +224 -217
  227. package/src/transport/dispatch/generation.ts +37 -55
  228. package/src/transport/dispatch/handler-deps.ts +32 -0
  229. package/src/transport/dispatch/leases.ts +9 -4
  230. package/src/transport/dispatch/proxy-request.ts +6 -30
  231. package/src/transport/dispatch/responses-compact.ts +22 -43
  232. package/src/transport/dispatch/streaming/finalize.ts +3 -0
  233. package/src/transport/dispatch/streaming/stream.ts +0 -8
  234. package/src/transport/dispatch/systemone.ts +22 -42
  235. package/src/transport/dispatch/token-count.ts +23 -39
  236. package/src/transport/dispatch/upstream.ts +1 -3
  237. package/src/transport/dispatch/websearch.ts +22 -41
  238. package/src/transport/failure-policy.ts +59 -178
  239. package/src/transport/guards/abuse-guard.ts +10 -0
  240. package/src/transport/guards/auth-guard.ts +109 -109
  241. package/src/transport/guards/error-lifecycle.ts +1 -1
  242. package/src/transport/guards/pipeline.ts +220 -220
  243. package/src/transport/guards/request-context.ts +245 -238
  244. package/src/transport/request/degradation.ts +20 -1
  245. package/src/transport/request/inflight.ts +4 -114
  246. package/src/transport/request/preparer.ts +6 -6
  247. package/src/transport/request/state.ts +66 -38
  248. package/src/transport/request/tenant-preferences.ts +1 -1
  249. package/src/transport/routing/route-catalog.ts +41 -31
  250. package/src/transport/routing/router.ts +2 -2
  251. package/src/transport/surface/messages/parse.ts +1 -1
  252. package/src/workers/buddy-growth-tasks.ts +346 -425
  253. package/src/workers/daily-checkin.ts +17 -6
  254. package/src/workers/quota-refresh-sweep.ts +2 -2
  255. package/src/workers/scheduler.ts +17 -1
  256. package/dist/dashboard/assets/BannedPage-P76QLGJA.js.gz +0 -0
  257. package/dist/dashboard/assets/CliToolDetailPage-DdBggJS9.js.gz +0 -0
  258. package/dist/dashboard/assets/CliToolsPage-aN1iXhhP.js.gz +0 -0
  259. package/dist/dashboard/assets/CombosPage-DE-F1nxZ.js.gz +0 -0
  260. package/dist/dashboard/assets/ConsoleLogPage-5SpmfLyw.js +0 -1
  261. package/dist/dashboard/assets/ConsoleLogPage-5SpmfLyw.js.gz +0 -0
  262. package/dist/dashboard/assets/CustomizationPage-3CUxMy2X.js.gz +0 -0
  263. package/dist/dashboard/assets/HealthEventsModal-BkmJQKC0.js +0 -1
  264. package/dist/dashboard/assets/HealthEventsModal-BkmJQKC0.js.gz +0 -0
  265. package/dist/dashboard/assets/LoginPage-B_0FUKWc.js +0 -1
  266. package/dist/dashboard/assets/LoginPage-B_0FUKWc.js.gz +0 -0
  267. package/dist/dashboard/assets/ModelPicker-DKVslqG3.js.gz +0 -0
  268. package/dist/dashboard/assets/OverviewPage-D-H4MEiT.js +0 -1
  269. package/dist/dashboard/assets/OverviewPage-D-H4MEiT.js.gz +0 -0
  270. package/dist/dashboard/assets/ProviderDetailPage-CEJw6Guo.js +0 -3
  271. package/dist/dashboard/assets/ProviderDetailPage-CEJw6Guo.js.gz +0 -0
  272. package/dist/dashboard/assets/ProviderIcon-CmfzxpVM.js +0 -1
  273. package/dist/dashboard/assets/ProviderIcon-CmfzxpVM.js.gz +0 -0
  274. package/dist/dashboard/assets/ProvidersPage-CEplAR-i.js +0 -1
  275. package/dist/dashboard/assets/ProvidersPage-CEplAR-i.js.gz +0 -0
  276. package/dist/dashboard/assets/ProxyPage-CAN7hy0f.js +0 -7
  277. package/dist/dashboard/assets/ProxyPage-CAN7hy0f.js.gz +0 -0
  278. package/dist/dashboard/assets/QuotaPage-BjldQwIU.js.gz +0 -0
  279. package/dist/dashboard/assets/SettingsPage-DUIoLRxk.js.gz +0 -0
  280. package/dist/dashboard/assets/SetupPage-_bCaTM0v.js +0 -1
  281. package/dist/dashboard/assets/SetupPage-_bCaTM0v.js.gz +0 -0
  282. package/dist/dashboard/assets/SortableList-fI7JkdIB.js.gz +0 -0
  283. package/dist/dashboard/assets/StudioPage-CHVZqskZ.js.gz +0 -0
  284. package/dist/dashboard/assets/UsagePage-DiFBymHj.js +0 -56
  285. package/dist/dashboard/assets/UsagePage-DiFBymHj.js.gz +0 -0
  286. package/dist/dashboard/assets/api-keys-DFY_fZU4.js.gz +0 -0
  287. package/dist/dashboard/assets/app-BOJ1_xUB.css +0 -2
  288. package/dist/dashboard/assets/app-BOJ1_xUB.css.gz +0 -0
  289. package/dist/dashboard/assets/assert-CwdnZARt.js.gz +0 -0
  290. package/dist/dashboard/assets/badge-Dr-uf0Au.js.gz +0 -0
  291. package/dist/dashboard/assets/banner-BddmMdoS.js.gz +0 -0
  292. package/dist/dashboard/assets/card-CK0PXWlX.js.gz +0 -0
  293. package/dist/dashboard/assets/cli-tools-CcB6mGh2.js.gz +0 -0
  294. package/dist/dashboard/assets/clipboard-button-DGb2P9Yx.js.gz +0 -0
  295. package/dist/dashboard/assets/cog-CdWhsajs.js +0 -1
  296. package/dist/dashboard/assets/cog-CdWhsajs.js.gz +0 -0
  297. package/dist/dashboard/assets/dialog-LkfuL50u.js.gz +0 -0
  298. package/dist/dashboard/assets/drawer-ZbEZ_84c.js.gz +0 -0
  299. package/dist/dashboard/assets/entry-BJfsVMvA.js.gz +0 -0
  300. package/dist/dashboard/assets/entry-CWBY5A0f.js.gz +0 -0
  301. package/dist/dashboard/assets/entry-CvC1-Zqx.js.gz +0 -0
  302. package/dist/dashboard/assets/github-badge-BxpznFLt.js.gz +0 -0
  303. package/dist/dashboard/assets/index-CDzP3ovJ.js.gz +0 -0
  304. package/dist/dashboard/assets/inline-Ae13MQ5w.js.gz +0 -0
  305. package/dist/dashboard/assets/input-D23fo7cp.js.gz +0 -0
  306. package/dist/dashboard/assets/layout-CSsxjj5P.js.gz +0 -0
  307. package/dist/dashboard/assets/list-row-dmbwZMDY.js.gz +0 -0
  308. package/dist/dashboard/assets/live-BG1oUsZZ.js.gz +0 -0
  309. package/dist/dashboard/assets/mutation-DSF-e4ho.js.gz +0 -0
  310. package/dist/dashboard/assets/numbers-Bm_aECY7.js.gz +0 -0
  311. package/dist/dashboard/assets/query-keys-CF56nYIV.js.gz +0 -0
  312. package/dist/dashboard/assets/routing-CeA71n7G.js.gz +0 -0
  313. package/dist/dashboard/assets/select-KIT0rC6G.js.gz +0 -0
  314. package/dist/dashboard/assets/settings-B_2gkePf.js.gz +0 -0
  315. package/dist/dashboard/assets/stack-D7WOSzcX.js.gz +0 -0
  316. package/dist/dashboard/assets/state-BBxtKZOQ.js.gz +0 -0
  317. package/dist/dashboard/assets/switch-DNS_D5Ea.js.gz +0 -0
  318. package/dist/dashboard/assets/useMutation-BgoxdoMk.js.gz +0 -0
  319. /package/dist/dashboard/assets/{app-CaXZ9SCn.js → app-BaWd0puM.js} +0 -0
  320. /package/dist/dashboard/assets/{app-CaXZ9SCn.js.gz → app-BaWd0puM.js.gz} +0 -0
@@ -1,1458 +1,1641 @@
1
- // Factory-blocked (Phase C4): PAT-to-jobToken session handshake, COSY
2
- // AES/RSA request signing, alphabet-scrambled binary body, enveloped SSE
3
- // frames — session/crypto protocol outside factory reach; stays bespoke.
4
- import { constants, createCipheriv, createHash, publicEncrypt, randomUUID } from "node:crypto";
5
- import {
6
- classifyUpstreamError,
7
- extractUpstreamMessage,
8
- isUpstreamQueued,
9
- mapUpstreamHttpError,
10
- queueRetryAfterMs,
11
- upstreamErrorIdentifier,
12
- } from "../../../transport/failure-policy";
13
- import { GatewayError } from "../../../protocol/gateway-error";
14
- import type {
15
- CanonicalEvent,
16
- CanonicalRequest,
17
- CanonicalStopReason,
18
- ContentPart,
19
- ToolChoice,
20
- ToolDefinition,
21
- } from "../../../protocol/canonical-model";
22
- import { toolResultParts } from "../../../protocol/canonical-model";
23
- import { decodeSseEvents } from "../../../protocol/sse-decoder";
24
- import { usageFromProvider, readReasoningText } from "../../provider-usage";
25
- import { readCredentialSecret, type ProviderDispatchTarget, type ProviderAdapter, type ProviderDispatchContext, type ProviderId } from "../../provider-contracts";
26
- import type { ModelDefinition } from "../../provider-model-definition";
27
- import { parseQoderOAuthState } from "./qoder-oauth";
28
- import { getQoderVersion } from "../../operations/client-versions";
29
- import { abortGatewayError, createUpstreamDeadlineLifecycle } from "../../operations/upstream-deadline";
30
- import { qoderMachineOs } from "../../operations/cli-platform";
31
- import { qoderGatewayCandidates, QODER_CHAT_PATH, QODER_REALMS, type QoderRealm } from "./qoder-realm";
32
- import { QODER_CN_MODEL_CONFIGS, QODER_INTL_MODEL_CONFIGS, QODER_INTL_MODELS } from "./qoder-catalog";
33
- import { isQoderVirtualModel, qoderDispatchModelId } from "./qoder-virtual";
34
-
35
- interface QoderModeProfile {
36
- readonly chatUrl: string;
37
- readonly businessProduct: string;
38
- readonly businessType: string;
39
- readonly businessVersion: string;
40
- readonly cosyScene: string;
41
- readonly mirrorTopLevelSystem: boolean;
42
- readonly sendBusinessHeaders: boolean;
43
- readonly sendModelSourceHeaders: boolean;
44
- readonly emptyAliyunUserType: boolean;
45
- }
46
-
47
- export const MODERN_PROFILE: QoderModeProfile = {
48
- chatUrl: "https://api2.qoder.sh/algo/api/v2/service/pro/sse/agent_chat_generation?FetchKeys=llm_model_result&AgentId=agent_common&Encode=1",
49
- businessProduct: "cli",
50
- businessType: "agent",
51
- businessVersion: "1.0.22",
52
- cosyScene: "assistant",
53
- mirrorTopLevelSystem: true,
54
- sendBusinessHeaders: true,
55
- sendModelSourceHeaders: true,
56
- emptyAliyunUserType: true,
57
- };
58
-
59
- const QODER_PROVIDER_ID = "qoder" as const;
60
-
61
- export const QODER_MODEL_CONFIGS: Record<
62
- string,
63
- { id: string; display_name: string; max_input_tokens: number; max_output_tokens?: number; is_vl: boolean; is_reasoning: boolean; source?: string }
64
- > = {
65
- auto: { id: "auto", display_name: "Auto", max_input_tokens: 180000, is_vl: true, is_reasoning: false },
66
- ultimate: { id: "ultimate", display_name: "Ultimate", max_input_tokens: 180000, is_vl: true, is_reasoning: true },
67
- performance: { id: "performance", display_name: "Performance", max_input_tokens: 272000, is_vl: true, is_reasoning: false },
68
- efficient: { id: "efficient", display_name: "Efficient", max_input_tokens: 180000, is_vl: true, is_reasoning: false },
69
- lite: { id: "lite", display_name: "Lite", max_input_tokens: 180000, is_vl: false, is_reasoning: true },
70
- qmodel: { id: "qmodel", display_name: "Qwen 3.6 Plus", max_input_tokens: 180000, is_vl: true, is_reasoning: false },
71
- qmodel_latest: { id: "qmodel_latest", display_name: "Qwen 3.7 Max", max_input_tokens: 1000000, is_vl: true, is_reasoning: false },
72
- qmodel_preview: { id: "qmodel_preview", display_name: "Qwen 3.8 Max", max_input_tokens: 1000000, is_vl: true, is_reasoning: false },
73
- dmodel: { id: "dmodel", display_name: "DeepSeek V4 Pro", max_input_tokens: 180000, is_vl: true, is_reasoning: true },
74
- dfmodel: { id: "dfmodel", display_name: "DeepSeek V4 Flash", max_input_tokens: 180000, is_vl: true, is_reasoning: true },
75
- gm51model: { id: "gm51model", display_name: "GLM 5.1", max_input_tokens: 180000, is_vl: true, is_reasoning: true },
76
- kmodel: { id: "kmodel", display_name: "Kimi K2.6", max_input_tokens: 256000, is_vl: true, is_reasoning: false },
77
- mmodel: { id: "mmodel", display_name: "MiniMax M2.7", max_input_tokens: 180000, is_vl: true, is_reasoning: false },
78
- kmodel_latest: { id: "kmodel_latest", display_name: "Kimi K2.7 Latest", max_input_tokens: 256000, is_vl: true, is_reasoning: false },
79
- };
80
-
81
- const QODER_APPCODE = "cosy";
82
- const QODER_SIG_SECRET = "d2FyLCB3YXIgbmV2ZXIgY2hhbmdlcw==";
83
- /**
84
- * Where a PAT is exchanged for a short-lived job token, per realm.
85
- *
86
- * The job token is realm-bound: one minted on the global host answers "Login
87
- * expired" on the China gateway. Exchanging against the wrong host therefore
88
- * looks like a dead credential rather than a routing bug, which is why the
89
- * URL is derived from the realm table instead of being one constant.
90
- *
91
- * `intl` keeps its historical `center.qoder.sh` v3 endpoint. `cn` uses the
92
- * openapi host the donor's `PATH_JOB_EXCHANGE` describes; if that path is
93
- * ever wrong for CN, the failure surfaces as a 401/403 on exchange, not as a
94
- * silently misrouted request.
95
- */
96
- const QODER_JOB_TOKEN_URL = "https://center.qoder.sh/algo/api/v3/user/jobToken?Encode=1";
97
- function qoderJobTokenUrl(realm: QoderRealm): string {
98
- return realm === "cn"
99
- ? `${QODER_REALMS.cn.openapi}/api/v1/jobToken/exchange`
100
- : QODER_JOB_TOKEN_URL;
101
- }
102
- const QODER_STANDARD_ALPHABET = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
103
- const QODER_ALPHABET = "_doRTgHZBKcGVjlvpC,@aFSx#DPuNJme&i*MzLOEn)sUrthbf%Y^w.(kIQyXqWA!";
104
- const QODER_RSA_PUBLIC_KEY = `-----BEGIN PUBLIC KEY-----
105
- MIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQDA8iMH5c02LilrsERw9t6Pv5Nc
106
- 4k6Pz1EaDicBMpdpxKduSZu5OANqUq8er4GM95omAGIOPOh+Nx0spthYA2BqGz+l
107
- 6HRkPJ7S236FZz73In/KVuLnwI8JJ2CbuJap8kvheCCZpmAWpb/cPx/3Vr/J6I17
108
- XcW+ML9FoCI6AOvOzwIDAQAB
109
- -----END PUBLIC KEY-----`;
110
-
111
- export interface QoderAuth {
112
- userId: string;
113
- userName: string;
114
- userType: string;
115
- securityOauthToken: string;
116
- refreshToken: string;
117
- machineId: string;
118
- }
119
- interface QoderModelConfig {
120
- id: string;
121
- display_name: string;
122
- max_input_tokens: number;
123
- max_output_tokens?: number;
124
- is_vl: boolean;
125
- is_reasoning: boolean;
126
- source?: string;
127
- }
128
-
129
-
130
- function qoderMd5(value: string | Uint8Array): string {
131
- return createHash("md5").update(value).digest("hex");
132
- }
133
- function qoderPathSignature(url: string): string {
134
- const path = new URL(url).pathname;
135
- return path.startsWith("/algo") ? path.slice(5) : path;
136
- }
137
-
138
- const QODER_MACHINE_ID_TTL_MS = 60 * 60 * 1000;
139
- const QODER_MAX_MACHINE_IDS = 1_024;
140
- const qoderMachineIds = new Map<string, { id: string; expiresAt: number }>();
141
- /**
142
- * A PAT (`pt-...`) must be exchanged for a short-lived job token before it
143
- * can sign COSY requests. Device tokens (`dt-...`) from OAuth login and job
144
- * tokens (`jt-...`) are used directly.
145
- */
146
- export function isQoderPat(secret: string): boolean {
147
- return secret.startsWith("pt-");
148
- }
149
-
150
- /**
151
- * Stable per-account machine id for OAuth dispatches. Derived from the
152
- * account id (not minted per request) so pooled accounts keep one device
153
- * fingerprint across restarts instead of looking like a new device every
154
- * time, while staying distinct per account.
155
- */
156
- export function qoderMachineIdForAccount(accountId: string): string {
157
- const hex = createHash("sha256").update(`qoder-machine-v1:${accountId}`).digest("hex");
158
- return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20, 32)}`;
159
- }
160
-
161
- /**
162
- * Builds dispatch auth from an OAuth device token. The token is used
163
- * directly as the COSY identity's oauth token; the user id and machine id
164
- * come from the `auth_state` the login persisted. Fails closed with a
165
- * reconnect prompt when the identity is absent (e.g. an account created
166
- * before `auth_state` existed) instead of signing with a guessed id.
167
- */
168
- export function qoderOAuthAuth(
169
- secret: string,
170
- authState: unknown,
171
- accountId: string | undefined,
172
- ): QoderAuth {
173
- const state = parseQoderOAuthState(authState);
174
- if (state === undefined || !state.userId) {
175
- throw new GatewayError(
176
- "authentication_failed",
177
- 401,
178
- "Qoder OAuth account is missing its user identity; sign in again",
179
- );
180
- }
181
- return {
182
- userId: state.userId,
183
- userName: "",
184
- userType: "personal_standard",
185
- securityOauthToken: secret,
186
- refreshToken: "",
187
- machineId: state.machineId || (accountId ? qoderMachineIdForAccount(accountId) : randomUUID()),
188
- };
189
- }
190
-
191
- function qoderMachineIdFromPat(pat: string): string {
192
- const key = createHash("sha256").update(pat).digest("hex");
193
- const now = Date.now();
194
- const existing = qoderMachineIds.get(key);
195
- if (existing && existing.expiresAt > now) return existing.id;
196
- const id = randomUUID();
197
- qoderMachineIds.delete(key);
198
- qoderMachineIds.set(key, { id, expiresAt: now + QODER_MACHINE_ID_TTL_MS });
199
- for (const [candidate, value] of qoderMachineIds) {
200
- if (value.expiresAt <= now) qoderMachineIds.delete(candidate);
201
- }
202
- while (qoderMachineIds.size > QODER_MAX_MACHINE_IDS) {
203
- const oldest = qoderMachineIds.keys().next();
204
- if (oldest.done) break;
205
- qoderMachineIds.delete(oldest.value);
206
- }
207
- return id;
208
- }
209
-
210
- function qoderStaticHeaders(machineId: string, version = getQoderVersion()): Record<string, string> {
211
- const date = new Date().toUTCString();
212
- return {
213
- "cosy-machinetoken": machineId,
214
- "cosy-machinetype": "5",
215
- "cosy-machineid": machineId,
216
- "cosy-clienttype": "5",
217
- "cosy-version": version,
218
- "login-version": "v2",
219
- appcode: QODER_APPCODE,
220
- accept: "application/json",
221
- "accept-encoding": "identity",
222
- date,
223
- signature: qoderMd5(`${QODER_APPCODE}&${QODER_SIG_SECRET}&${date}`),
224
- "content-type": "application/json",
225
- "user-agent": "Go-http-client/2.0",
226
- };
227
- }
228
-
229
- function transformQoderBody(plaintext: string): Uint8Array {
230
- const base64 = Buffer.from(plaintext).toString("base64");
231
- const third = Math.floor(base64.length / 3);
232
- const reordered = base64.slice(base64.length - third) + base64.slice(third, base64.length - third) + base64.slice(0, third);
233
- let encoded = "";
234
- for (const char of reordered) {
235
- if (char === "=") encoded += "$";
236
- else encoded += QODER_ALPHABET[QODER_STANDARD_ALPHABET.indexOf(char)] ?? char;
237
- }
238
- return Buffer.from(encoded, "latin1");
239
- }
240
-
241
- function qoderBuildCosyHeaders(
242
- body: Uint8Array,
243
- url: string,
244
- auth: QoderAuth,
245
- version = getQoderVersion(),
246
- ): Record<string, string> {
247
- const key = randomUUID().replaceAll("-", "").slice(0, 16);
248
- const identity = JSON.stringify({
249
- name: auth.userName,
250
- aid: auth.userId,
251
- uid: auth.userId,
252
- yx_uid: "",
253
- organization_id: "",
254
- organization_name: "",
255
- user_type: auth.userType,
256
- security_oauth_token: auth.securityOauthToken,
257
- refresh_token: auth.refreshToken,
258
- });
259
- const cipher = createCipheriv("aes-128-cbc", Buffer.from(key), Buffer.from(key));
260
- const info = Buffer.concat([cipher.update(identity), cipher.final()]).toString("base64");
261
- const cosyKey = publicEncrypt({ key: QODER_RSA_PUBLIC_KEY, padding: constants.RSA_PKCS1_PADDING }, Buffer.from(key)).toString("base64");
262
- const payload = Buffer.from(
263
- JSON.stringify({ version: "v1", requestId: randomUUID(), info, cosyVersion: version, ideVersion: "" }),
264
- ).toString("base64");
265
- const date = String(Math.floor(Date.now() / 1000));
266
- const signature = qoderMd5(`${payload}\n${cosyKey}\n${date}\n${Buffer.from(body).toString("latin1")}\n${qoderPathSignature(url)}`);
267
- return {
268
- authorization: `Bearer COSY.${payload}.${signature}`,
269
- "cosy-data-policy": "agree",
270
- "cosy-machinetype": "5",
271
- "cosy-machineos": qoderMachineOs(),
272
- "cosy-clienttype": "5",
273
- "cosy-date": date,
274
- "cosy-user": auth.userId,
275
- "cosy-key": cosyKey,
276
- "cosy-bodyhash": qoderMd5(body),
277
- "cosy-bodylength": String(body.byteLength),
278
- "cosy-sigpath": qoderPathSignature(url),
279
- "cosy-organization-id": "",
280
- "cosy-organization-tags": "",
281
- "x-request-id": randomUUID(),
282
- ...(MODERN_PROFILE.sendBusinessHeaders
283
- ? {
284
- "cosy-business-product": MODERN_PROFILE.businessProduct,
285
- "cosy-business-type": MODERN_PROFILE.businessType,
286
- "cosy-business-version": version,
287
- "cosy-scene": MODERN_PROFILE.cosyScene,
288
- }
289
- : {}),
290
- "cosy-version": version,
291
- "login-version": "v2",
292
- appcode: QODER_APPCODE,
293
- accept: "application/json",
294
- "accept-encoding": "identity",
295
- date: new Date().toUTCString(),
296
- signature: qoderMd5(`${QODER_APPCODE}&${QODER_SIG_SECRET}&${new Date().toUTCString()}`),
297
- "content-type": "application/json",
298
- "user-agent": "Go-http-client/2.0",
299
- };
300
- }
301
-
302
- type QoderFetcher = (url: string, init: RequestInit) => Promise<Response>;
303
-
304
- async function exchangeQoderPat(
305
- pat: string,
306
- signal: AbortSignal,
307
- fetcher: QoderFetcher,
308
- version = getQoderVersion(),
309
- realm: QoderRealm = "intl",
310
- ): Promise<QoderAuth> {
311
- const machineId = qoderMachineIdFromPat(pat);
312
- const encodedBody = transformQoderBody(JSON.stringify({ payload: JSON.stringify({ personalToken: pat, securityOauthToken: "", refreshToken: "", needRefresh: false, authInfo: {} }), encodeVersion: "1" }));
313
- const response = await fetcher(qoderJobTokenUrl(realm), { method: "POST", headers: qoderStaticHeaders(machineId, version), body: encodedBody as unknown as BodyInit, signal });
314
- if (!response.ok) throw await mapUpstreamHttpError(response, "qoder");
315
- let responseBody: unknown;
316
- try {
317
- responseBody = await response.json();
318
- } catch {
319
- throw new GatewayError("platform_unavailable", 502, "Qoder PAT exchange returned invalid JSON", {}, "upstream");
320
- }
321
- if (typeof responseBody !== "object" || responseBody === null || Array.isArray(responseBody)) {
322
- throw new GatewayError("platform_unavailable", 502, "Qoder PAT exchange returned unexpected response", {}, "upstream");
323
- }
324
- const rec = responseBody as Record<string, unknown>;
325
- if (typeof rec["id"] !== "string" || typeof rec["securityOauthToken"] !== "string") {
326
- throw new GatewayError("platform_unavailable", 502, "Qoder PAT exchange returned incomplete credentials", {}, "upstream");
327
- }
328
- return {
329
- userId: rec["id"] as string,
330
- userName: typeof rec["name"] === "string" ? (rec["name"] as string) : "",
331
- userType: typeof rec["userType"] === "string" ? (rec["userType"] as string) : "personal_standard",
332
- securityOauthToken: rec["securityOauthToken"] as string,
333
- refreshToken: typeof rec["refreshToken"] === "string" ? (rec["refreshToken"] as string) : "",
334
- machineId,
335
- };
336
- }
337
-
338
- async function callQoder(
339
- url: string,
340
- body: Record<string, unknown>,
341
- modelId: string,
342
- auth: QoderAuth,
343
- signal: AbortSignal,
344
- fetcher: QoderFetcher,
345
- version = getQoderVersion(),
346
- ): Promise<Response> {
347
- const encoded = transformQoderBody(JSON.stringify(body));
348
- const modelConfig = (body["model_config"] as Record<string, unknown> | null) ?? null;
349
- return fetcher(url, {
350
- method: "POST",
351
- headers: {
352
- ...qoderBuildCosyHeaders(encoded, url, auth, version),
353
- ...(MODERN_PROFILE.sendModelSourceHeaders
354
- ? { "x-model-key": modelId, "x-model-source": typeof modelConfig?.["source"] === "string" ? (modelConfig["source"] as string) : "system" }
355
- : {}),
356
- Accept: "text/event-stream",
357
- "Cache-Control": "no-cache",
358
- "Accept-Encoding": "identity",
359
- },
360
- body: encoded as unknown as BodyInit,
361
- signal,
362
- });
363
- }
364
-
365
- /**
366
- * The human-readable text out of a Qoder error envelope.
367
- *
368
- * The envelope is Qoder's own wrapper around the stream, and it does not use
369
- * one field for the message: the text has been seen as a nested `error`
370
- * object, a bare string `body`, and a top-level `message`/`msg`. The canonical
371
- * extractor covers the structured shapes; a string body and the top-level
372
- * fields cover the rest. Whatever survives is bounded the same way an upstream
373
- * message is, so a verbose envelope cannot blow up the public error line.
374
- */
375
- function qoderEnvelopeMessage(rec: Record<string, unknown>): string {
376
- const structured = extractUpstreamMessage(rec["body"]);
377
- if (structured.length > 0) return structured;
378
- if (typeof rec["body"] === "string" && rec["body"].trim().length > 0 && rec["body"] !== "[DONE]")
379
- return rec["body"].replace(/[\r\n]+/g, " ").trim().slice(0, 500);
380
- return extractUpstreamMessage(rec);
381
- }
382
-
383
- function qoderEnvelopeToFrames(data: string): Array<{ event: string | null; data: string }> {
384
- if (data === "[DONE]") return [{ event: null, data: "[DONE]" }];
385
- let envelope: unknown;
386
- try {
387
- envelope = JSON.parse(data);
388
- } catch {
389
- }
390
- if (typeof envelope !== "object" || envelope === null || Array.isArray(envelope)) return [];
391
- const rec = envelope as Record<string, unknown>;
392
- if (rec["statusCodeValue"] !== undefined && rec["statusCodeValue"] !== 200) {
393
- const status = typeof rec["statusCodeValue"] === "number" ? (rec["statusCodeValue"] as number) : 502;
394
- // Qoder reports failures inside the stream envelope instead of as HTTP
395
- // responses, so classify its structured provider code and status through
396
- // the same gateway error policy as the other dispatch paths.
397
- const detail = qoderEnvelopeMessage(rec);
398
- const identifier = upstreamErrorIdentifier(rec["body"]) ?? upstreamErrorIdentifier(rec);
399
- // Qoder signals a queued request the same way inside the stream as it does
400
- // on HTTP: `{"code":"10605", "isQueued":true, "retryAfterSeconds":N}` on a
401
- // non-200 envelope. The status table would read the 403 as a refusal, and
402
- // the caller would end its turn on a request that was only waiting.
403
- const queuedWaitMs = isUpstreamQueued(rec["body"]) ?? false;
404
- const waitMs = queuedWaitMs
405
- ? (queueRetryAfterMs(rec["body"]) ?? undefined)
406
- : undefined;
407
- const classification = queuedWaitMs
408
- ? { code: "upstream_queued" as const, status: 503, origin: "upstream" as const }
409
- : classifyUpstreamError(status, identifier);
410
- throw new GatewayError(
411
- classification.code,
412
- classification.status,
413
- detail.length > 0 ? detail : `Qoder stream envelope returned HTTP ${status}`,
414
- {
415
- providerId: QODER_PROVIDER_ID,
416
- providerStatus: status,
417
- ...(identifier ? { providerCode: identifier } : {}),
418
- ...(classification.credentialEvidence ? { credentialEvidence: true } : {}),
419
- ...(classification.rateLimitScope
420
- ? { rateLimitScope: classification.rateLimitScope }
421
- : {}),
422
- ...(waitMs === undefined ? {} : { retryAfterMs: waitMs, cooldownMs: waitMs }),
423
- ...(queuedWaitMs ? { reason: "upstream-queued" } : {}),
424
- },
425
- classification.origin,
426
- );
427
- }
428
- const body = rec["body"];
429
- if (body === "[DONE]") return [{ event: null, data: "[DONE]" }];
430
- if (typeof body !== "string") return [];
431
- return [{ event: null, data: body.replace(/\r?\n/g, "") }];
432
- }
433
-
434
- function qoderStableHash(prefix: string, ...parts: string[]): string {
435
- const h = createHash("sha256");
436
- h.update(prefix);
437
- for (const p of parts) {
438
- h.update("\0");
439
- h.update(p);
440
- }
441
- return h.digest("hex").slice(0, 16);
442
- }
443
-
444
- function flattenCanonicalContent(content: readonly ContentPart[]): string {
445
- return content
446
- .filter((b) => b.kind === "text" || b.kind === "toolResult")
447
- .map((b) => {
448
- if (b.kind === "text") return b.text;
449
- if (b.kind === "toolResult") return typeof b.content === "string" ? b.content : JSON.stringify(b.content);
450
- return "";
451
- })
452
- .join("\n");
453
- }
454
-
455
- function qoderStableRecordId(modelId: string, messages: CanonicalRequest["messages"], maxTokens: number): string {
456
- const h = createHash("sha256");
457
- h.update("qoder-record\0");
458
- h.update(modelId);
459
- for (const m of messages) {
460
- h.update("\0");
461
- h.update(m.role);
462
- const text = flattenCanonicalContent(m.content);
463
- if (text) {
464
- h.update("\0");
465
- h.update(text);
466
- }
467
- }
468
- h.update(`\0mt=${maxTokens}`);
469
- return h.digest("hex").slice(0, 16);
470
- }
471
-
472
- function qoderRequestedMaxTokens(request: CanonicalRequest, modelConfig: QoderModelConfig): number {
473
- /**
474
- * The ceiling is the roster's stated one. A fixed 32k fallback would clamp
475
- * every model alike, so a request asking for more would be silently lowered
476
- * no matter which model it named.
477
- */
478
- const configured = typeof modelConfig.max_output_tokens === "number" ? modelConfig.max_output_tokens : 65_536;
479
- const controls = request.generation_controls as Record<string, unknown>;
480
- const requested =
481
- typeof controls["max_tokens"] === "number"
482
- ? (controls["max_tokens"] as number)
483
- : typeof controls["max_output_tokens"] === "number"
484
- ? (controls["max_output_tokens"] as number)
485
- : null;
486
- return typeof requested === "number" && requested > 0 ? Math.min(requested, configured) : configured;
487
- }
488
-
489
- /**
490
- * Whether this model needs the prior turn's reasoning echoed back.
491
- *
492
- * The DeepSeek family ties multi-turn consistency to `reasoning_content`: an
493
- * assistant turn that thought must be replayed with that field or the upstream
494
- * refuses the whole history. The upstream reference bridge keeps it for exactly
495
- * these models and drops it as noise for every other one
496
- * (`keep_reasoning=is_deepseek_model(...)`, and `reasoning_content` in
497
- * `NOISE_KEYS` otherwise), and notes the symptom when it is missed: occasional
498
- * failures that sometimes pass on retry.
499
- *
500
- * Matched by the upstream key and by the display name, because a caller may
501
- * name either — the reference warns that matching only the "deepseek" prefix
502
- * missed every request that used the key.
503
- */
504
- function qoderKeepsReasoning(modelId: string): boolean {
505
- const key = modelId.trim().toLowerCase();
506
- if (key === "dmodel" || key === "dfmodel") return true;
507
- return key.startsWith("deepseek");
508
- }
509
-
510
- /**
511
- * The `reasoning_content` field for an assistant turn, when one is needed.
512
- *
513
- * Presence, not length, decides: a client that already knows the rule sends
514
- * `""` 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.
517
- */
518
- function qoderReasoningEcho(
519
- message: { content: readonly ContentPart[] },
520
- modelId: string,
521
- ): { reasoning_content: string } | Record<string, never> {
522
- if (!qoderKeepsReasoning(modelId)) return {};
523
- const reasoning = message.content.filter((part) => part.kind === "reasoning");
524
- if (reasoning.length === 0) return {};
525
- const text = reasoning
526
- .map((part) => (part.kind === "reasoning" ? (part.summary ?? part.payload ?? "") : ""))
527
- .join("");
528
- return { reasoning_content: text };
529
- }
530
-
531
- /**
532
- * The tool arguments as the object the `tool_use` block expects.
533
- *
534
- * The canonical form keeps them as the raw JSON text the caller sent, which is
535
- * what a chat-completions `tool_calls` entry holds. A `tool_use` block wants
536
- * structured input, so the text is parsed back.
537
- *
538
- * The fallbacks mirror the upstream reference bridge rather than inventing
539
- * shapes: unparseable text becomes `{}` and a non-object becomes `{value}`.
540
- * Smuggling the raw text through instead would emit an argument shape the
541
- * upstream has never seen for this field.
542
- */
543
- /**
544
- * Projects the canonical tools onto Qoder's chat-completions `tools` array.
545
- *
546
- * Without this the upstream is never told a tool exists, so a model that wants
547
- * one improvises markup in its prose (`<Read file_path="..." />`) instead of
548
- * emitting a structured call — the call is then neither runnable nor
549
- * recoverable, because it never was a call on either side.
550
- *
551
- * Only `function` tools are representable upstream. Anything else is dropped
552
- * rather than forwarded: Qoder rejects an unknown variant during
553
- * deserialization and fails the whole turn, which is worse than omitting a
554
- * tool the model could not have used anyway.
555
- */
556
- function qoderTools(
557
- tools: readonly ToolDefinition[] | undefined,
558
- ): Array<Record<string, unknown>> {
559
- const out: Array<Record<string, unknown>> = [];
560
- for (const tool of tools ?? []) {
561
- if (tool.tool_type !== undefined && tool.tool_type !== "function") continue;
562
- // The upstream requires `tools[].function` to be an object. A flat
563
- // declaration naming the tool at the top level is rejected outright with
564
- // "'function' is a required property, expected an object", which fails the
565
- // whole request rather than just this tool.
566
- out.push({
567
- type: "function",
568
- function: {
569
- name: tool.name,
570
- ...(tool.description === undefined ? { description: "" } : { description: tool.description }),
571
- parameters: tool.jsonSchema ?? { type: "object", properties: {} },
572
- ...(tool.strict === undefined ? {} : { strict: tool.strict }),
573
- },
574
- });
575
- }
576
- return out;
577
- }
578
-
579
-
580
- /**
581
- * Projects the canonical tool choice onto the upstream's field.
582
- *
583
- * The upstream only accepts a string here. Given the object form
584
- * `{type:"function",function:{name}}` it ignores the field, and the model then
585
- * calls the tool with no arguments filled in.
586
- *
587
- * `allowed_tools` has no chat-completions equivalent, so it collapses to the
588
- * direction it already names rather than being dropped: a caller that asked for
589
- * `required` must not silently get `auto`.
590
- */
591
- function qoderToolChoice(choice: ToolChoice | undefined): unknown {
592
- if (choice === undefined) return undefined;
593
- if (typeof choice === "string") return choice;
594
- if (choice.type === "tool" || choice.type === "custom") {
595
- const name = choice.name.trim();
596
- return name.length > 0 ? name : "auto";
597
- }
598
- return choice.mode;
599
- }
600
-
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
- /**
612
- * Whether this request sends its tool history as structured turns.
613
- *
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.
618
- */
619
- function qoderStructuredTools(realm: QoderRealm): boolean {
620
- return realm === "cn";
621
- }
622
-
623
- export function buildQoderRequest(
624
- modelId: string,
625
- request: CanonicalRequest,
626
- modelConfig: QoderModelConfig,
627
- auth: QoderAuth,
628
- realm: QoderRealm = "intl",
629
- ): Record<string, unknown> {
630
- const allMessages = request.messages;
631
- const systemParts: string[] = [];
632
- if (request.system) {
633
- for (const p of request.system) if (p.kind === "text") systemParts.push(p.text);
634
- }
635
- if (request.instructions) {
636
- for (const p of request.instructions) if (p.kind === "text") systemParts.push(p.text);
637
- }
638
- for (const m of allMessages) {
639
- if (m.role === "system" || m.role === "developer") {
640
- const t = flattenCanonicalContent(m.content);
641
- if (t) systemParts.push(t);
642
- }
643
- }
644
- const systemPrompt = systemParts.join("\n\n").trim();
645
- const qoderMessages: Array<Record<string, unknown>> = [];
646
- const structuredTools = qoderStructuredTools(realm);
647
- if (MODERN_PROFILE.mirrorTopLevelSystem && systemPrompt.length > 0) {
648
- qoderMessages.push({
649
- role: "system",
650
- content: systemPrompt,
651
- });
652
- }
653
- for (const m of allMessages) {
654
- if (m.role === "system" || m.role === "developer") continue;
655
- // Answers may live in a `user` turn (the Messages ledger re-homes them),
656
- // so the result scan is role-agnostic. Each result becomes its own `tool`
657
- // turn carrying its `tool_call_id`; any non-result content on the same turn
658
- // is emitted after, so nothing is duplicated or lost.
659
- const results = structuredTools ? toolResultParts(m) : [];
660
- if (results.length > 0) {
661
- for (const block of results) {
662
- const text = flattenCanonicalContent(
663
- typeof block.content === "string"
664
- ? [{ kind: "text", text: block.content }]
665
- : block.content,
666
- );
667
- qoderMessages.push({
668
- role: "tool",
669
- content: text,
670
- tool_call_id: block.call_id,
671
- });
672
- }
673
- // Text that shared the turn with a result is emitted after it, minus the
674
- // result itself. Re-emitting the result's own text here was doubling
675
- // every tool answer: the `tool` turn above and a `user` turn with the
676
- // same content, which doubled the payload for no reason.
677
- const remaining = m.content.filter((part) => part.kind !== "toolResult");
678
- const flat = flattenCanonicalContent(remaining);
679
- if (flat.length === 0) continue;
680
- qoderMessages.push({
681
- role: "user",
682
- content: flat,
683
- });
684
- continue;
685
- }
686
- // An assistant turn that called tools must carry the calls, not just its
687
- // text. Falling through to the text-only emit dropped them, so the payload
688
- // arrived as a `tool` result with no preceding call and the upstream
689
- // rejected the whole transcript: "Messages with role 'tool' must be a
690
- // response to a preceding message with 'tool_calls'".
691
- 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 : [];
715
- if (calls.length > 0) {
716
- const textParts = m.content.filter((part) => part.kind !== "toolCall");
717
- const flat = flattenCanonicalContent(textParts);
718
- qoderMessages.push({
719
- role: "assistant",
720
- content: flat,
721
- ...reasoningEcho,
722
- tool_calls: calls.map((part) => ({
723
- id: part.call_id,
724
- type: "function",
725
- function: {
726
- name: part.name,
727
- arguments: typeof part.arguments === "string" ? part.arguments : "",
728
- },
729
- })),
730
- });
731
- continue;
732
- }
733
- const flat = flattenCanonicalContent(m.content);
734
- const role = m.role === "assistant" ? "assistant" : "user";
735
- qoderMessages.push({
736
- role,
737
- content: flat,
738
- ...(role === "assistant" ? qoderReasoningEcho(m, modelId) : {}),
739
- });
740
- }
741
- const latestUserMessage = [...qoderMessages].reverse().find((m) => m["role"] === "user");
742
- const latestUserText = typeof latestUserMessage?.["content"] === "string" ? (latestUserMessage["content"] as string) : "";
743
- const maxTokens = qoderRequestedMaxTokens(request, modelConfig);
744
- const reasoning = modelConfig.is_reasoning === true;
745
- const sessionId = qoderStableHash("qoder-session", auth.userId, modelId);
746
- const recordId = qoderStableRecordId(modelId, allMessages, maxTokens);
747
- return {
748
- request_id: randomUUID(),
749
- request_set_id: recordId,
750
- chat_record_id: recordId,
751
- session_id: sessionId,
752
- stream: true,
753
- ...(MODERN_PROFILE.emptyAliyunUserType ? { aliyun_user_type: "" } : {}),
754
- chat_task: "FREE_INPUT",
755
- is_reply: true,
756
- is_retry: false,
757
- source: 1,
758
- version: "3",
759
- session_type: "qodercli",
760
- agent_id: "agent_common",
761
- task_id: "common",
762
- code_language: "",
763
- chat_prompt: MODERN_PROFILE.mirrorTopLevelSystem ? systemPrompt : "",
764
- image_urls: null,
765
- chat_context: {
766
- chatPrompt: MODERN_PROFILE.mirrorTopLevelSystem ? systemPrompt : "",
767
- imageUrls: null,
768
- extra: { context: [], modelConfig: { key: modelId, is_reasoning: reasoning }, originalContent: { text: latestUserText } },
769
- features: [],
770
- // An object, as the reference sends it: `cc["text"]["text"] = prompt`.
771
- text: { text: latestUserText },
772
- },
773
- model_config: {
774
- key: modelId,
775
- display_name: modelConfig.display_name,
776
- // Declares the wire dialect for the tool calls. Without it the upstream
777
- // does not read the OpenAI-shaped `tools` array the same way, and the
778
- // model then emits a call with no arguments filled in.
779
- format: "openai",
780
- model: "",
781
- api_key: "",
782
- url: "",
783
- is_vl: modelConfig.is_vl,
784
- is_reasoning: modelConfig.is_reasoning,
785
- max_input_tokens: modelConfig.max_input_tokens,
786
- // Mirrors the roster's stated ceiling. A fixed 32k fallback would be sent
787
- // for every model alike, so the request would declare a cap the catalog
788
- // does not publish.
789
- max_output_tokens: modelConfig.max_output_tokens ?? 65_536,
790
- source: modelConfig.source ?? "system",
791
- },
792
- messages: qoderMessages,
793
- max_tokens: maxTokens,
794
- // Always present, even when empty. Omitting the field makes Qoder fall back
795
- // to the desktop agent's own toolset (Bash/Edit/...), which then leaks to a
796
- // caller that declared none — the reference bridge sends `[]` for exactly
797
- // that reason. Parallel calls are stated explicitly rather than left to the
798
- // upstream default, so a caller expecting them is not served serial
799
- // behaviour.
800
- tools: qoderTools(request.tools),
801
- parallel_tool_calls: true,
802
- ...(() => {
803
- const choice = qoderToolChoice(request.tool_choice);
804
- return choice === undefined ? {} : { tool_choice: choice };
805
- })(),
806
- };
807
- }
808
-
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
- // Stream transformation — decode SSE, unwrap COSY envelope, map OpenAI chat chunks to CanonicalEvent
1038
-
1039
- function mapQoderFinishReason(raw: unknown): CanonicalStopReason | undefined {
1040
- if (raw === "length") return "length";
1041
- if (raw === "tool_calls") return "tool_use";
1042
- if (raw === "content_filter") return "content_filter";
1043
- return undefined;
1044
- }
1045
-
1046
- export async function* qoderBodyToCanonicalEvents(
1047
- body: ReadableStream<Uint8Array>,
1048
- signal: AbortSignal,
1049
- model: string,
1050
- ): AsyncIterable<CanonicalEvent> {
1051
- let seq = 1;
1052
- yield { type: "response_start", sequence_number: seq++, model } as CanonicalEvent;
1053
- let finishReason: unknown = undefined;
1054
- let rawUsage: Record<string, unknown> | undefined = undefined;
1055
- 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 = "";
1070
-
1071
- for await (const sse of decodeSseEvents(body, { signal })) {
1072
- const sseData = sse.data;
1073
- let frames: Array<{ event: string | null; data: string }>;
1074
- try {
1075
- frames = qoderEnvelopeToFrames(sseData);
1076
- } catch (e) {
1077
- throw e;
1078
- }
1079
- for (const frame of frames) {
1080
- const data = frame.data.trim();
1081
- if (!data) continue;
1082
- if (data === "[DONE]") {
1083
- hasTerminal = true;
1084
- continue;
1085
- }
1086
- let json: Record<string, unknown>;
1087
- try {
1088
- json = JSON.parse(data) as Record<string, unknown>;
1089
- } catch {
1090
- continue;
1091
- }
1092
- if (json["usage"] && typeof json["usage"] === "object") rawUsage = json["usage"] as Record<string, unknown>;
1093
- const choices = (json["choices"] as Array<Record<string, unknown>>) ?? [];
1094
- for (const choice of choices) {
1095
- const delta = choice["delta"] as Record<string, unknown> | undefined;
1096
- if (delta) {
1097
- 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;
1109
- }
1110
- if (body.length > 0) {
1111
- yield {
1112
- type: "content_delta",
1113
- sequence_number: seq++,
1114
- content: { kind: "text", text: body },
1115
- } as CanonicalEvent;
1116
- }
1117
- tail = hold;
1118
- continue;
1119
- }
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;
1134
- }
1135
- const visible = stripDsmlCloseTags(body);
1136
- if (visible.length > 0) {
1137
- yield {
1138
- type: "content_delta",
1139
- sequence_number: seq++,
1140
- content: { kind: "reasoning", payload: null, summary: visible },
1141
- } as CanonicalEvent;
1142
- }
1143
- reasonTail = hold;
1144
- continue;
1145
- }
1146
- reasonBuf += piece;
1147
- continue;
1148
- }
1149
- const toolCalls = delta["tool_calls"] as Array<Record<string, unknown>> | undefined;
1150
- if (toolCalls) {
1151
- for (const tc of toolCalls) {
1152
- const fn = tc["function"] as Record<string, unknown> | undefined;
1153
- yield {
1154
- type: "tool_call_delta",
1155
- sequence_number: seq++,
1156
- call_id: (tc["id"] as string) ?? `call_${String(tc["index"] ?? 0)}`,
1157
- name: fn?.["name"] as string | undefined,
1158
- arguments_delta: (fn?.["arguments"] as string) ?? "",
1159
- } as CanonicalEvent;
1160
- }
1161
- }
1162
- }
1163
- const fr = choice["finish_reason"];
1164
- if (fr !== null && fr !== undefined) finishReason = fr;
1165
- // Some providers send usage in choices[0].usage or top-level
1166
- if (choice["usage"] && typeof choice["usage"] === "object") rawUsage = choice["usage"] as Record<string, unknown>;
1167
- }
1168
- // Some Qoder chunks carry finish_reason at top level
1169
- if (json["finish_reason"] !== undefined) finishReason = json["finish_reason"];
1170
- }
1171
- }
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) {
1210
- yield {
1211
- type: "tool_call_delta",
1212
- sequence_number: seq++,
1213
- call_id: call.id,
1214
- name: call.name,
1215
- arguments_delta: call.arguments,
1216
- } as CanonicalEvent;
1217
- }
1218
- reasonCalls = [];
1219
- // The turn ended by calling a tool, whatever the upstream reported.
1220
- finishReason = "tool_calls";
1221
- } 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();
1225
- if (visible.length > 0) {
1226
- yield {
1227
- type: "content_delta",
1228
- sequence_number: seq++,
1229
- content: { kind: "text", text: visible },
1230
- } as CanonicalEvent;
1231
- }
1232
- }
1233
- }
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
- // If no terminal arrived, synthesize one (prevents truncation error)
1257
- const stopReason = mapQoderFinishReason(finishReason);
1258
- const _providerStop = typeof finishReason === "string" ? finishReason : undefined;
1259
- void hasTerminal;
1260
- yield {
1261
- type: "terminal",
1262
- sequence_number: seq++,
1263
- state: "complete",
1264
- ...(stopReason === undefined ? {} : { stop_reason: stopReason }),
1265
- ...(_providerStop === undefined ? {} : { provider_stop_reason: _providerStop }),
1266
- usage: usageFromProvider(rawUsage),
1267
- } as CanonicalEvent;
1268
- }
1269
-
1270
- // Model catalog — maps QODER_MODEL_CONFIGS (provider) to Cartethyia ModelDefinition
1271
-
1272
- /**
1273
- * The intl catalog this provider publishes.
1274
- *
1275
- * `QODER_MODEL_CONFIGS` is not the source here: it freezes most rows at 180k
1276
- * while the upstream reports 1M for the same keys. That number is not
1277
- * cosmetic: it is sent upstream as `model_config.max_input_tokens`, so a stale
1278
- * low value tells Qoder the conversation window is small and makes it behave
1279
- * that way. Both the published catalog and the dispatched request come from the
1280
- * realm's roster, which carries the upstream's own numbers.
1281
- */
1282
- export const QODER_MODELS: readonly ModelDefinition[] = QODER_INTL_MODELS;
1283
-
1284
- // ProviderAdapter — manual dispatch preserving COSY encryption + envelope logic
1285
-
1286
- interface QoderAdapterOptions {
1287
- readonly fetch?: typeof fetch;
1288
- /** Which deployment this adapter dispatches to. Defaults to intl. */
1289
- readonly realm?: QoderRealm;
1290
- /** Bundled provider id, so the adapter's own id matches the route's. */
1291
- readonly providerId?: string;
1292
- }
1293
-
1294
- class QoderAdapter implements ProviderAdapter {
1295
- readonly provider_id: ProviderId;
1296
- private readonly realm: QoderRealm;
1297
- private readonly fetchFn: typeof fetch;
1298
-
1299
- constructor(options: QoderAdapterOptions = {}) {
1300
- this.provider_id = (options.providerId ?? QODER_PROVIDER_ID) as ProviderId;
1301
- this.realm = options.realm ?? "intl";
1302
- this.fetchFn = options.fetch ?? globalThis.fetch;
1303
- }
1304
-
1305
- /**
1306
- * The static roster for this adapter's realm.
1307
- *
1308
- * A single merged table advertised CN-only keys to global accounts and vice
1309
- * versa, so a request was routed to a deployment that refuses it.
1310
- */
1311
- #modelConfigs(): Record<string, QoderModelConfig> {
1312
- return (this.realm === "cn" ? QODER_CN_MODEL_CONFIGS : QODER_INTL_MODEL_CONFIGS) as Record<
1313
- string,
1314
- QoderModelConfig
1315
- >;
1316
- }
1317
-
1318
- async *dispatch(
1319
- request: CanonicalRequest,
1320
- candidate: ProviderDispatchTarget,
1321
- context: ProviderDispatchContext,
1322
- ): AsyncIterable<CanonicalEvent> {
1323
- if (candidate.wire_family !== "chat") {
1324
- throw new GatewayError("capability_unsupported", 400, `qoder supports chat only, got ${candidate.wire_family}`);
1325
- }
1326
- const secret = readCredentialSecret(context.credential, "Qoder credential is required");
1327
-
1328
- /**
1329
- * A caller-facing virtual name is translated to its roster key here.
1330
- *
1331
- * The translation has to happen before the config lookup and before the
1332
- * request body: the body carries the roster key, and looking up the
1333
- * virtual id in the config table would fall through to the synthetic
1334
- * default and silently dispatch under-declared capabilities.
1335
- */
1336
- const requestedModelId = candidate.model_id || request.model;
1337
- if (isQoderVirtualModel(requestedModelId) && !this.#modelConfigs()[qoderDispatchModelId(requestedModelId)]) {
1338
- throw new GatewayError(
1339
- "model_not_found",
1340
- 404,
1341
- `Qoder virtual model ${requestedModelId} resolves to a roster key this realm does not serve`,
1342
- { virtual_model: requestedModelId, target: qoderDispatchModelId(requestedModelId) },
1343
- "upstream",
1344
- );
1345
- }
1346
- const modelId = qoderDispatchModelId(requestedModelId);
1347
- const modelConfig = this.#modelConfigs()[modelId] ?? {
1348
- id: modelId,
1349
- display_name: modelId,
1350
- max_input_tokens: 180_000,
1351
- is_vl: true,
1352
- is_reasoning: false,
1353
- };
1354
-
1355
- const lifecycle = createUpstreamDeadlineLifecycle(context);
1356
-
1357
- // Outbound fetch respects SSRF binding when provided
1358
- const outboundFetch: typeof fetch = (context.outbound_fetch as unknown as typeof fetch) ?? this.fetchFn;
1359
-
1360
- const fetcher: QoderFetcher = (url, init) =>
1361
- outboundFetch(url, { ...init, signal: lifecycle.signal } as RequestInit) as Promise<Response>;
1362
-
1363
- try {
1364
- const version = getQoderVersion();
1365
- // Dual mode like Cline: a pasted PAT is exchanged for a job token per
1366
- // request, while an OAuth device token is used directly with the
1367
- // identity the login persisted.
1368
- const auth = isQoderPat(secret)
1369
- ? await exchangeQoderPat(secret, lifecycle.signal, fetcher, version, this.realm)
1370
- : qoderOAuthAuth(secret, context.credential.auth_state, context.credential.account_id);
1371
- const qoderBody = buildQoderRequest(modelId, request, modelConfig, auth, this.realm);
1372
- // A full URL from the route wins, but the fallback is this realm's
1373
- // gateway — not the intl host hardcoded into `MODERN_PROFILE`. Dispatching
1374
- // a CN credential to `*.qoder.sh` is refused outright.
1375
- const url =
1376
- candidate.endpoint_path && candidate.endpoint_path.startsWith("http")
1377
- ? candidate.endpoint_path
1378
- : `${qoderGatewayCandidates(this.realm)[0] ?? ""}${QODER_CHAT_PATH}`;
1379
-
1380
- const response = await callQoder(url, qoderBody, modelId, auth, lifecycle.signal, fetcher, version);
1381
- if (!response.ok) throw await mapUpstreamHttpError(response, "qoder");
1382
- if (!response.body) throw new GatewayError("platform_unavailable", 502, "Qoder returned empty body", {}, "upstream");
1383
- // Headers arrived: the pre-stream deadline has served its purpose.
1384
- // From here the gateway stall/first-chunk watchdog (propagated via
1385
- // context.abort_signal) governs the body. Keeping this timer armed
1386
- // would kill healthy long streams at the stale pre-stream deadline.
1387
- lifecycle.release();
1388
- yield* qoderBodyToCanonicalEvents(response.body as ReadableStream<Uint8Array>, lifecycle.signal, request.model);
1389
- } catch (err: unknown) {
1390
- const abortError = abortGatewayError(lifecycle, err, context.abort_signal);
1391
- if (abortError) throw abortError;
1392
- throw err;
1393
- } finally {
1394
- lifecycle.release();
1395
- }
1396
- }
1397
- }
1398
-
1399
- export function createQoderAdapter(options: QoderAdapterOptions = {}): ProviderAdapter {
1400
- return new QoderAdapter(options);
1401
- }
1402
-
1403
- /**
1404
- * Adapter for the China deployment.
1405
- *
1406
- * Same wire, different gateway host and roster — see `qoder-realm.ts`.
1407
- */
1408
- export function createQoderCnAdapter(options: QoderAdapterOptions = {}): ProviderAdapter {
1409
- return new QoderAdapter({ ...options, realm: "cn", providerId: "qodercn" });
1410
- }
1411
-
1412
- /**
1413
- * A COSY signer for one already-resolved account identity.
1414
- *
1415
- * Discovery needs signed headers but lives in `qoder-catalog.ts`, which cannot
1416
- * import this module (the adapter imports its rosters — that would be a cycle).
1417
- * Exporting the signer keeps one signing implementation and lets discovery
1418
- * stay pure and injectable.
1419
- */
1420
- export function qoderSignerFor(
1421
- auth: QoderAuth,
1422
- version = getQoderVersion(),
1423
- ): (url: string, body: string) => Record<string, string> {
1424
- return (url: string, body: string): Record<string, string> =>
1425
- qoderBuildCosyHeaders(Buffer.from(body, "latin1"), url, auth, version);
1426
- }
1427
-
1428
- /** Qoder's own base64 alphabet transform, for a request body. */
1429
- export function qoderEncodeBody(plaintext: Uint8Array | string): string {
1430
- const text = typeof plaintext === "string" ? plaintext : new TextDecoder().decode(plaintext);
1431
- return Buffer.from(transformQoderBody(text)).toString("latin1");
1432
- }
1433
-
1434
- /**
1435
- * Resolves the dispatch identity for one Qoder credential.
1436
- *
1437
- * Shared by the adapter and by discovery: both need the same `QoderAuth` the
1438
- * COSY signature is built from, and duplicating the PAT-vs-OAuth branch would
1439
- * let the two drift.
1440
- */
1441
- export async function resolveQoderAuth(input: {
1442
- readonly secret: string;
1443
- readonly authState: unknown;
1444
- readonly signal: AbortSignal;
1445
- readonly fetcher: (url: string, init: RequestInit) => Promise<Response>;
1446
- /** Which deployment mints the job token; a PAT is realm-bound. */
1447
- readonly realm?: QoderRealm;
1448
- }): Promise<QoderAuth> {
1449
- const realm = input.realm ?? "intl";
1450
- if (isQoderPat(input.secret)) {
1451
- return await exchangeQoderPat(input.secret, input.signal, input.fetcher, getQoderVersion(), realm);
1452
- }
1453
- // Discovery is handed only the secret and the persisted state, so the
1454
- // account id is read from that state — the same place `qoderOAuthAuth` reads
1455
- // it — rather than being a separate field callers can pass wrongly.
1456
- const state = parseQoderOAuthState(input.authState);
1457
- return qoderOAuthAuth(input.secret, input.authState, state?.userId);
1458
- }
1
+ // Factory-blocked (Phase C4): PAT-to-jobToken session handshake, COSY
2
+ // AES/RSA request signing, alphabet-scrambled binary body, enveloped SSE
3
+ // frames — session/crypto protocol outside factory reach; stays bespoke.
4
+ import { constants, createCipheriv, createHash, publicEncrypt, randomUUID } from "node:crypto";
5
+ import {
6
+ classifyUpstreamError,
7
+ extractUpstreamMessage,
8
+ isUpstreamQueued,
9
+ mapUpstreamHttpError,
10
+ queueRetryAfterMs,
11
+ upstreamErrorIdentifier,
12
+ } from "../../../transport/failure-policy";
13
+ import { GatewayError } from "../../../protocol/gateway-error";
14
+ import { resolveImageSource } from "../../../protocol/codecs";
15
+ import type {
16
+ CanonicalEvent,
17
+ CanonicalRequest,
18
+ CanonicalStopReason,
19
+ ContentPart,
20
+ ToolChoice,
21
+ ToolDefinition,
22
+ } from "../../../protocol/canonical-model";
23
+ import { toolResultParts } from "../../../protocol/canonical-model";
24
+ import { decodeSseEvents } from "../../../protocol/sse-decoder";
25
+ import { usageFromProvider, readReasoningText } from "../../provider-usage";
26
+ import { readCredentialSecret, type ProviderDispatchTarget, type ProviderAdapter, type ProviderDispatchContext, type ProviderId } from "../../provider-contracts";
27
+ import type { ModelDefinition } from "../../provider-model-definition";
28
+ import { parseQoderOAuthState } from "./qoder-oauth";
29
+ import { getQoderVersion } from "../../operations/client-versions";
30
+ import { abortGatewayError, createUpstreamDeadlineLifecycle } from "../../operations/upstream-deadline";
31
+ import { qoderMachineOs } from "../../operations/cli-platform";
32
+ import { qoderGatewayCandidates, QODER_CHAT_PATH, QODER_REALMS, type QoderRealm } from "./qoder-realm";
33
+ import { QODER_CN_MODEL_CONFIGS, QODER_INTL_MODEL_CONFIGS, QODER_INTL_MODELS } from "./qoder-catalog";
34
+ import { isQoderVirtualModel, qoderDispatchModelId } from "./qoder-virtual";
35
+
36
+ interface QoderModeProfile {
37
+ readonly chatUrl: string;
38
+ readonly businessProduct: string;
39
+ readonly businessType: string;
40
+ readonly businessVersion: string;
41
+ readonly cosyScene: string;
42
+ readonly mirrorTopLevelSystem: boolean;
43
+ readonly sendBusinessHeaders: boolean;
44
+ readonly sendModelSourceHeaders: boolean;
45
+ readonly emptyAliyunUserType: boolean;
46
+ }
47
+
48
+ export const MODERN_PROFILE: QoderModeProfile = {
49
+ chatUrl: "https://api2.qoder.sh/algo/api/v2/service/pro/sse/agent_chat_generation?FetchKeys=llm_model_result&AgentId=agent_common&Encode=1",
50
+ businessProduct: "cli",
51
+ businessType: "agent",
52
+ businessVersion: "1.0.22",
53
+ cosyScene: "assistant",
54
+ mirrorTopLevelSystem: true,
55
+ sendBusinessHeaders: true,
56
+ sendModelSourceHeaders: true,
57
+ emptyAliyunUserType: true,
58
+ };
59
+
60
+ const QODER_PROVIDER_ID = "qoder" as const;
61
+
62
+ export const QODER_MODEL_CONFIGS: Record<
63
+ string,
64
+ { id: string; display_name: string; max_input_tokens: number; max_output_tokens?: number; is_vl: boolean; is_reasoning: boolean; source?: string }
65
+ > = {
66
+ auto: { id: "auto", display_name: "Auto", max_input_tokens: 180000, is_vl: true, is_reasoning: false },
67
+ ultimate: { id: "ultimate", display_name: "Ultimate", max_input_tokens: 180000, is_vl: true, is_reasoning: true },
68
+ performance: { id: "performance", display_name: "Performance", max_input_tokens: 272000, is_vl: true, is_reasoning: false },
69
+ efficient: { id: "efficient", display_name: "Efficient", max_input_tokens: 180000, is_vl: true, is_reasoning: false },
70
+ lite: { id: "lite", display_name: "Lite", max_input_tokens: 180000, is_vl: false, is_reasoning: true },
71
+ qmodel: { id: "qmodel", display_name: "Qwen 3.6 Plus", max_input_tokens: 180000, is_vl: true, is_reasoning: false },
72
+ qmodel_latest: { id: "qmodel_latest", display_name: "Qwen 3.7 Max", max_input_tokens: 1000000, is_vl: true, is_reasoning: false },
73
+ qmodel_preview: { id: "qmodel_preview", display_name: "Qwen 3.8 Max", max_input_tokens: 1000000, is_vl: true, is_reasoning: false },
74
+ dmodel: { id: "dmodel", display_name: "DeepSeek V4 Pro", max_input_tokens: 180000, is_vl: true, is_reasoning: true },
75
+ dfmodel: { id: "dfmodel", display_name: "DeepSeek V4 Flash", max_input_tokens: 180000, is_vl: true, is_reasoning: true },
76
+ gm51model: { id: "gm51model", display_name: "GLM 5.1", max_input_tokens: 180000, is_vl: true, is_reasoning: true },
77
+ kmodel: { id: "kmodel", display_name: "Kimi K2.6", max_input_tokens: 256000, is_vl: true, is_reasoning: false },
78
+ mmodel: { id: "mmodel", display_name: "MiniMax M2.7", max_input_tokens: 180000, is_vl: true, is_reasoning: false },
79
+ kmodel_latest: { id: "kmodel_latest", display_name: "Kimi K2.7 Latest", max_input_tokens: 256000, is_vl: true, is_reasoning: false },
80
+ };
81
+
82
+ const QODER_APPCODE = "cosy";
83
+ const QODER_SIG_SECRET = "d2FyLCB3YXIgbmV2ZXIgY2hhbmdlcw==";
84
+ /**
85
+ * Where a PAT is exchanged for a short-lived job token, per realm.
86
+ *
87
+ * The job token is realm-bound: one minted on the global host answers "Login
88
+ * expired" on the China gateway. Exchanging against the wrong host therefore
89
+ * looks like a dead credential rather than a routing bug, which is why the
90
+ * URL is derived from the realm table instead of being one constant.
91
+ *
92
+ * `intl` keeps its historical `center.qoder.sh` v3 endpoint. `cn` uses the
93
+ * openapi host the donor's `PATH_JOB_EXCHANGE` describes; if that path is
94
+ * ever wrong for CN, the failure surfaces as a 401/403 on exchange, not as a
95
+ * silently misrouted request.
96
+ */
97
+ const QODER_JOB_TOKEN_URL = "https://center.qoder.sh/algo/api/v3/user/jobToken?Encode=1";
98
+ function qoderJobTokenUrl(realm: QoderRealm): string {
99
+ return realm === "cn"
100
+ ? `${QODER_REALMS.cn.openapi}/api/v1/jobToken/exchange`
101
+ : QODER_JOB_TOKEN_URL;
102
+ }
103
+ const QODER_STANDARD_ALPHABET = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
104
+ const QODER_ALPHABET = "_doRTgHZBKcGVjlvpC,@aFSx#DPuNJme&i*MzLOEn)sUrthbf%Y^w.(kIQyXqWA!";
105
+ const QODER_RSA_PUBLIC_KEY = `-----BEGIN PUBLIC KEY-----
106
+ MIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQDA8iMH5c02LilrsERw9t6Pv5Nc
107
+ 4k6Pz1EaDicBMpdpxKduSZu5OANqUq8er4GM95omAGIOPOh+Nx0spthYA2BqGz+l
108
+ 6HRkPJ7S236FZz73In/KVuLnwI8JJ2CbuJap8kvheCCZpmAWpb/cPx/3Vr/J6I17
109
+ XcW+ML9FoCI6AOvOzwIDAQAB
110
+ -----END PUBLIC KEY-----`;
111
+
112
+ export interface QoderAuth {
113
+ userId: string;
114
+ userName: string;
115
+ userType: string;
116
+ securityOauthToken: string;
117
+ refreshToken: string;
118
+ machineId: string;
119
+ }
120
+ interface QoderModelConfig {
121
+ id: string;
122
+ display_name: string;
123
+ max_input_tokens: number;
124
+ max_output_tokens?: number;
125
+ is_vl: boolean;
126
+ is_reasoning: boolean;
127
+ source?: string;
128
+ }
129
+
130
+
131
+ function qoderMd5(value: string | Uint8Array): string {
132
+ return createHash("md5").update(value).digest("hex");
133
+ }
134
+ function qoderPathSignature(url: string): string {
135
+ const path = new URL(url).pathname;
136
+ return path.startsWith("/algo") ? path.slice(5) : path;
137
+ }
138
+
139
+ const QODER_MACHINE_ID_TTL_MS = 60 * 60 * 1000;
140
+ const QODER_MAX_MACHINE_IDS = 1_024;
141
+ const qoderMachineIds = new Map<string, { id: string; expiresAt: number }>();
142
+ /**
143
+ * A PAT (`pt-...`) must be exchanged for a short-lived job token before it
144
+ * can sign COSY requests. Device tokens (`dt-...`) from OAuth login and job
145
+ * tokens (`jt-...`) are used directly.
146
+ */
147
+ export function isQoderPat(secret: string): boolean {
148
+ return secret.startsWith("pt-");
149
+ }
150
+
151
+ /**
152
+ * Stable per-account machine id for OAuth dispatches. Derived from the
153
+ * account id (not minted per request) so pooled accounts keep one device
154
+ * fingerprint across restarts instead of looking like a new device every
155
+ * time, while staying distinct per account.
156
+ */
157
+ export function qoderMachineIdForAccount(accountId: string): string {
158
+ const hex = createHash("sha256").update(`qoder-machine-v1:${accountId}`).digest("hex");
159
+ return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20, 32)}`;
160
+ }
161
+
162
+ /**
163
+ * Builds dispatch auth from an OAuth device token. The token is used
164
+ * directly as the COSY identity's oauth token; the user id and machine id
165
+ * come from the `auth_state` the login persisted. Fails closed with a
166
+ * reconnect prompt when the identity is absent (e.g. an account created
167
+ * before `auth_state` existed) instead of signing with a guessed id.
168
+ */
169
+ export function qoderOAuthAuth(
170
+ secret: string,
171
+ authState: unknown,
172
+ accountId: string | undefined,
173
+ ): QoderAuth {
174
+ const state = parseQoderOAuthState(authState);
175
+ if (state === undefined || !state.userId) {
176
+ throw new GatewayError(
177
+ "authentication_failed",
178
+ 401,
179
+ "Qoder OAuth account is missing its user identity; sign in again",
180
+ );
181
+ }
182
+ return {
183
+ userId: state.userId,
184
+ userName: "",
185
+ userType: "personal_standard",
186
+ securityOauthToken: secret,
187
+ refreshToken: "",
188
+ machineId: state.machineId || (accountId ? qoderMachineIdForAccount(accountId) : randomUUID()),
189
+ };
190
+ }
191
+
192
+ function qoderMachineIdFromPat(pat: string): string {
193
+ const key = createHash("sha256").update(pat).digest("hex");
194
+ const now = Date.now();
195
+ const existing = qoderMachineIds.get(key);
196
+ if (existing && existing.expiresAt > now) return existing.id;
197
+ const id = randomUUID();
198
+ qoderMachineIds.delete(key);
199
+ qoderMachineIds.set(key, { id, expiresAt: now + QODER_MACHINE_ID_TTL_MS });
200
+ for (const [candidate, value] of qoderMachineIds) {
201
+ if (value.expiresAt <= now) qoderMachineIds.delete(candidate);
202
+ }
203
+ while (qoderMachineIds.size > QODER_MAX_MACHINE_IDS) {
204
+ const oldest = qoderMachineIds.keys().next();
205
+ if (oldest.done) break;
206
+ qoderMachineIds.delete(oldest.value);
207
+ }
208
+ return id;
209
+ }
210
+
211
+ function qoderStaticHeaders(machineId: string, version = getQoderVersion()): Record<string, string> {
212
+ const date = new Date().toUTCString();
213
+ return {
214
+ "cosy-machinetoken": machineId,
215
+ "cosy-machinetype": "5",
216
+ "cosy-machineid": machineId,
217
+ "cosy-clienttype": "5",
218
+ "cosy-version": version,
219
+ "login-version": "v2",
220
+ appcode: QODER_APPCODE,
221
+ accept: "application/json",
222
+ "accept-encoding": "identity",
223
+ date,
224
+ signature: qoderMd5(`${QODER_APPCODE}&${QODER_SIG_SECRET}&${date}`),
225
+ "content-type": "application/json",
226
+ "user-agent": "Go-http-client/2.0",
227
+ };
228
+ }
229
+
230
+ function transformQoderBody(plaintext: string): Uint8Array {
231
+ const base64 = Buffer.from(plaintext).toString("base64");
232
+ const third = Math.floor(base64.length / 3);
233
+ const reordered = base64.slice(base64.length - third) + base64.slice(third, base64.length - third) + base64.slice(0, third);
234
+ let encoded = "";
235
+ for (const char of reordered) {
236
+ if (char === "=") encoded += "$";
237
+ else encoded += QODER_ALPHABET[QODER_STANDARD_ALPHABET.indexOf(char)] ?? char;
238
+ }
239
+ return Buffer.from(encoded, "latin1");
240
+ }
241
+
242
+ function qoderBuildCosyHeaders(
243
+ body: Uint8Array,
244
+ url: string,
245
+ auth: QoderAuth,
246
+ version = getQoderVersion(),
247
+ ): Record<string, string> {
248
+ const key = randomUUID().replaceAll("-", "").slice(0, 16);
249
+ const identity = JSON.stringify({
250
+ name: auth.userName,
251
+ aid: auth.userId,
252
+ uid: auth.userId,
253
+ yx_uid: "",
254
+ organization_id: "",
255
+ organization_name: "",
256
+ user_type: auth.userType,
257
+ security_oauth_token: auth.securityOauthToken,
258
+ refresh_token: auth.refreshToken,
259
+ });
260
+ const cipher = createCipheriv("aes-128-cbc", Buffer.from(key), Buffer.from(key));
261
+ const info = Buffer.concat([cipher.update(identity), cipher.final()]).toString("base64");
262
+ const cosyKey = publicEncrypt({ key: QODER_RSA_PUBLIC_KEY, padding: constants.RSA_PKCS1_PADDING }, Buffer.from(key)).toString("base64");
263
+ const payload = Buffer.from(
264
+ JSON.stringify({ version: "v1", requestId: randomUUID(), info, cosyVersion: version, ideVersion: "" }),
265
+ ).toString("base64");
266
+ const date = String(Math.floor(Date.now() / 1000));
267
+ const signature = qoderMd5(`${payload}\n${cosyKey}\n${date}\n${Buffer.from(body).toString("latin1")}\n${qoderPathSignature(url)}`);
268
+ return {
269
+ authorization: `Bearer COSY.${payload}.${signature}`,
270
+ "cosy-data-policy": "agree",
271
+ "cosy-machinetype": "5",
272
+ "cosy-machineos": qoderMachineOs(),
273
+ "cosy-clienttype": "5",
274
+ "cosy-date": date,
275
+ "cosy-user": auth.userId,
276
+ "cosy-key": cosyKey,
277
+ "cosy-bodyhash": qoderMd5(body),
278
+ "cosy-bodylength": String(body.byteLength),
279
+ "cosy-sigpath": qoderPathSignature(url),
280
+ "cosy-organization-id": "",
281
+ "cosy-organization-tags": "",
282
+ "x-request-id": randomUUID(),
283
+ ...(MODERN_PROFILE.sendBusinessHeaders
284
+ ? {
285
+ "cosy-business-product": MODERN_PROFILE.businessProduct,
286
+ "cosy-business-type": MODERN_PROFILE.businessType,
287
+ "cosy-business-version": version,
288
+ "cosy-scene": MODERN_PROFILE.cosyScene,
289
+ }
290
+ : {}),
291
+ "cosy-version": version,
292
+ "login-version": "v2",
293
+ appcode: QODER_APPCODE,
294
+ accept: "application/json",
295
+ "accept-encoding": "identity",
296
+ date: new Date().toUTCString(),
297
+ signature: qoderMd5(`${QODER_APPCODE}&${QODER_SIG_SECRET}&${new Date().toUTCString()}`),
298
+ "content-type": "application/json",
299
+ "user-agent": "Go-http-client/2.0",
300
+ };
301
+ }
302
+
303
+ type QoderFetcher = (url: string, init: RequestInit) => Promise<Response>;
304
+
305
+ async function exchangeQoderPat(
306
+ pat: string,
307
+ signal: AbortSignal,
308
+ fetcher: QoderFetcher,
309
+ version = getQoderVersion(),
310
+ realm: QoderRealm = "intl",
311
+ ): Promise<QoderAuth> {
312
+ const machineId = qoderMachineIdFromPat(pat);
313
+ const encodedBody = transformQoderBody(JSON.stringify({ payload: JSON.stringify({ personalToken: pat, securityOauthToken: "", refreshToken: "", needRefresh: false, authInfo: {} }), encodeVersion: "1" }));
314
+ const response = await fetcher(qoderJobTokenUrl(realm), { method: "POST", headers: qoderStaticHeaders(machineId, version), body: encodedBody as unknown as BodyInit, signal });
315
+ if (!response.ok) throw await mapUpstreamHttpError(response, "qoder");
316
+ let responseBody: unknown;
317
+ try {
318
+ responseBody = await response.json();
319
+ } catch {
320
+ throw new GatewayError("platform_unavailable", 502, "Qoder PAT exchange returned invalid JSON", {}, "upstream");
321
+ }
322
+ if (typeof responseBody !== "object" || responseBody === null || Array.isArray(responseBody)) {
323
+ throw new GatewayError("platform_unavailable", 502, "Qoder PAT exchange returned unexpected response", {}, "upstream");
324
+ }
325
+ const rec = responseBody as Record<string, unknown>;
326
+ if (typeof rec["id"] !== "string" || typeof rec["securityOauthToken"] !== "string") {
327
+ throw new GatewayError("platform_unavailable", 502, "Qoder PAT exchange returned incomplete credentials", {}, "upstream");
328
+ }
329
+ return {
330
+ userId: rec["id"] as string,
331
+ userName: typeof rec["name"] === "string" ? (rec["name"] as string) : "",
332
+ userType: typeof rec["userType"] === "string" ? (rec["userType"] as string) : "personal_standard",
333
+ securityOauthToken: rec["securityOauthToken"] as string,
334
+ refreshToken: typeof rec["refreshToken"] === "string" ? (rec["refreshToken"] as string) : "",
335
+ machineId,
336
+ };
337
+ }
338
+
339
+ async function callQoder(
340
+ url: string,
341
+ body: Record<string, unknown>,
342
+ modelId: string,
343
+ auth: QoderAuth,
344
+ signal: AbortSignal,
345
+ fetcher: QoderFetcher,
346
+ version = getQoderVersion(),
347
+ ): Promise<Response> {
348
+ const encoded = transformQoderBody(JSON.stringify(body));
349
+ const modelConfig = (body["model_config"] as Record<string, unknown> | null) ?? null;
350
+ return fetcher(url, {
351
+ method: "POST",
352
+ headers: {
353
+ ...qoderBuildCosyHeaders(encoded, url, auth, version),
354
+ ...(MODERN_PROFILE.sendModelSourceHeaders
355
+ ? { "x-model-key": modelId, "x-model-source": typeof modelConfig?.["source"] === "string" ? (modelConfig["source"] as string) : "system" }
356
+ : {}),
357
+ Accept: "text/event-stream",
358
+ "Cache-Control": "no-cache",
359
+ "Accept-Encoding": "identity",
360
+ },
361
+ body: encoded as unknown as BodyInit,
362
+ signal,
363
+ });
364
+ }
365
+
366
+ /**
367
+ * The human-readable text out of a Qoder error envelope.
368
+ *
369
+ * The envelope is Qoder's own wrapper around the stream, and it does not use
370
+ * one field for the message: the text has been seen as a nested `error`
371
+ * object, a bare string `body`, and a top-level `message`/`msg`. The canonical
372
+ * extractor covers the structured shapes; a string body and the top-level
373
+ * fields cover the rest. Whatever survives is bounded the same way an upstream
374
+ * message is, so a verbose envelope cannot blow up the public error line.
375
+ */
376
+ function qoderEnvelopeMessage(rec: Record<string, unknown>): string {
377
+ const structured = extractUpstreamMessage(rec["body"]);
378
+ if (structured.length > 0) return structured;
379
+ if (typeof rec["body"] === "string" && rec["body"].trim().length > 0 && rec["body"] !== "[DONE]")
380
+ return rec["body"].replace(/[\r\n]+/g, " ").trim().slice(0, 500);
381
+ return extractUpstreamMessage(rec);
382
+ }
383
+
384
+ function qoderEnvelopeToFrames(data: string): Array<{ event: string | null; data: string }> {
385
+ if (data === "[DONE]") return [{ event: null, data: "[DONE]" }];
386
+ let envelope: unknown;
387
+ try {
388
+ envelope = JSON.parse(data);
389
+ } catch {
390
+ }
391
+ if (typeof envelope !== "object" || envelope === null || Array.isArray(envelope)) return [];
392
+ const rec = envelope as Record<string, unknown>;
393
+ if (rec["statusCodeValue"] !== undefined && rec["statusCodeValue"] !== 200) {
394
+ const status = typeof rec["statusCodeValue"] === "number" ? (rec["statusCodeValue"] as number) : 502;
395
+ // Qoder reports failures inside the stream envelope instead of as HTTP
396
+ // responses, so classify its structured provider code and status through
397
+ // the same gateway error policy as the other dispatch paths.
398
+ const detail = qoderEnvelopeMessage(rec);
399
+ const identifier = upstreamErrorIdentifier(rec["body"]) ?? upstreamErrorIdentifier(rec);
400
+ // Qoder signals a queued request the same way inside the stream as it does
401
+ // on HTTP: `{"code":"10605", "isQueued":true, "retryAfterSeconds":N}` on a
402
+ // non-200 envelope. The status table would read the 403 as a refusal, and
403
+ // the caller would end its turn on a request that was only waiting.
404
+ const queuedWaitMs = isUpstreamQueued(rec["body"]) ?? false;
405
+ const waitMs = queuedWaitMs
406
+ ? (queueRetryAfterMs(rec["body"]) ?? undefined)
407
+ : undefined;
408
+ const classification = queuedWaitMs
409
+ ? { code: "upstream_queued" as const, status: 503, origin: "upstream" as const }
410
+ : classifyUpstreamError(status, identifier);
411
+ throw new GatewayError(
412
+ classification.code,
413
+ classification.status,
414
+ detail.length > 0 ? detail : `Qoder stream envelope returned HTTP ${status}`,
415
+ {
416
+ providerId: QODER_PROVIDER_ID,
417
+ providerStatus: status,
418
+ ...(identifier ? { providerCode: identifier } : {}),
419
+ ...(classification.credentialEvidence ? { credentialEvidence: true } : {}),
420
+ ...(classification.rateLimitScope
421
+ ? { rateLimitScope: classification.rateLimitScope }
422
+ : {}),
423
+ ...(waitMs === undefined ? {} : { retryAfterMs: waitMs, cooldownMs: waitMs }),
424
+ ...(queuedWaitMs ? { reason: "upstream-queued" } : {}),
425
+ },
426
+ classification.origin,
427
+ );
428
+ }
429
+ const body = rec["body"];
430
+ if (body === "[DONE]") return [{ event: null, data: "[DONE]" }];
431
+ if (typeof body !== "string") return [];
432
+ return [{ event: null, data: body.replace(/\r?\n/g, "") }];
433
+ }
434
+
435
+ function qoderStableHash(prefix: string, ...parts: string[]): string {
436
+ const h = createHash("sha256");
437
+ h.update(prefix);
438
+ for (const p of parts) {
439
+ h.update("\0");
440
+ h.update(p);
441
+ }
442
+ return h.digest("hex").slice(0, 16);
443
+ }
444
+
445
+ function flattenCanonicalContent(content: readonly ContentPart[]): string {
446
+ return content
447
+ .filter((b) => b.kind === "text" || b.kind === "toolResult")
448
+ .map((b) => {
449
+ if (b.kind === "text") return b.text;
450
+ if (b.kind === "toolResult") return typeof b.content === "string" ? b.content : JSON.stringify(b.content);
451
+ return "";
452
+ })
453
+ .join("\n");
454
+ }
455
+
456
+ function qoderStableRecordId(modelId: string, messages: CanonicalRequest["messages"], maxTokens: number): string {
457
+ const h = createHash("sha256");
458
+ h.update("qoder-record\0");
459
+ h.update(modelId);
460
+ for (const m of messages) {
461
+ h.update("\0");
462
+ h.update(m.role);
463
+ const text = flattenCanonicalContent(m.content);
464
+ if (text) {
465
+ h.update("\0");
466
+ h.update(text);
467
+ }
468
+ }
469
+ h.update(`\0mt=${maxTokens}`);
470
+ return h.digest("hex").slice(0, 16);
471
+ }
472
+
473
+ function qoderRequestedMaxTokens(request: CanonicalRequest, modelConfig: QoderModelConfig): number {
474
+ /**
475
+ * The ceiling is the roster's stated one. A fixed 32k fallback would clamp
476
+ * every model alike, so a request asking for more would be silently lowered
477
+ * no matter which model it named.
478
+ */
479
+ const configured = typeof modelConfig.max_output_tokens === "number" ? modelConfig.max_output_tokens : 65_536;
480
+ const controls = request.generation_controls as Record<string, unknown>;
481
+ const requested =
482
+ typeof controls["max_tokens"] === "number"
483
+ ? (controls["max_tokens"] as number)
484
+ : typeof controls["max_output_tokens"] === "number"
485
+ ? (controls["max_output_tokens"] as number)
486
+ : null;
487
+ return typeof requested === "number" && requested > 0 ? Math.min(requested, configured) : configured;
488
+ }
489
+
490
+ /**
491
+ * Whether this model needs the prior turn's reasoning echoed back.
492
+ *
493
+ * The DeepSeek family ties multi-turn consistency to `reasoning_content`: an
494
+ * assistant turn that thought must be replayed with that field or the upstream
495
+ * refuses the whole history. The upstream reference bridge keeps it for exactly
496
+ * these models and drops it as noise for every other one
497
+ * (`keep_reasoning=is_deepseek_model(...)`, and `reasoning_content` in
498
+ * `NOISE_KEYS` otherwise), and notes the symptom when it is missed: occasional
499
+ * failures that sometimes pass on retry.
500
+ *
501
+ * Matched by the upstream key and by the display name, because a caller may
502
+ * name either — the reference warns that matching only the "deepseek" prefix
503
+ * missed every request that used the key.
504
+ */
505
+ function qoderKeepsReasoning(modelId: string): boolean {
506
+ const key = modelId.trim().toLowerCase();
507
+ if (key === "dmodel" || key === "dfmodel") return true;
508
+ return key.startsWith("deepseek");
509
+ }
510
+
511
+ /**
512
+ * The `reasoning_content` field for an assistant turn, when one is needed.
513
+ *
514
+ * Presence, not length, decides: a client that already knows the rule sends
515
+ * `""` on a thinking turn whose text is not readable back, and dropping that
516
+ * empty string re-triggers the complaint it was sent to avoid.
517
+ *
518
+ * DeepSeek multi-turn consistency also backfills the field on every assistant
519
+ * turn once the conversation carries a reasoning trace at all — the reference
520
+ * bridge does the same (`backfill_reasoning_content`), and notes the symptom
521
+ * when it is missed: occasional failures that sometimes pass on retry. An
522
+ * assistant turn with no reasoning and no trace gains nothing, so nothing is
523
+ * fabricated.
524
+ */
525
+ function qoderReasoningEcho(
526
+ message: { content: readonly ContentPart[] },
527
+ modelId: string,
528
+ hasReasoningTrace = false,
529
+ ): { reasoning_content: string } | Record<string, never> {
530
+ if (!qoderKeepsReasoning(modelId)) return {};
531
+ const reasoning = message.content.filter((part) => part.kind === "reasoning");
532
+ if (reasoning.length === 0) {
533
+ return hasReasoningTrace ? { reasoning_content: "" } : {};
534
+ }
535
+ const text = reasoning
536
+ .map((part) => (part.kind === "reasoning" ? (part.summary ?? part.payload ?? "") : ""))
537
+ .join("");
538
+ return { reasoning_content: text };
539
+ }
540
+
541
+ /**
542
+ * The tool arguments as the object the `tool_use` block expects.
543
+ *
544
+ * The canonical form keeps them as the raw JSON text the caller sent, which is
545
+ * what a chat-completions `tool_calls` entry holds. A `tool_use` block wants
546
+ * structured input, so the text is parsed back.
547
+ *
548
+ * The fallbacks mirror the upstream reference bridge rather than inventing
549
+ * shapes: unparseable text becomes `{}` and a non-object becomes `{value}`.
550
+ * Smuggling the raw text through instead would emit an argument shape the
551
+ * upstream has never seen for this field.
552
+ */
553
+ /**
554
+ * Projects the canonical tools onto Qoder's chat-completions `tools` array.
555
+ *
556
+ * Without this the upstream is never told a tool exists, so a model that wants
557
+ * one improvises markup in its prose (`<Read file_path="..." />`) instead of
558
+ * emitting a structured call — the call is then neither runnable nor
559
+ * recoverable, because it never was a call on either side.
560
+ *
561
+ * Only `function` tools are representable upstream. Anything else is dropped
562
+ * rather than forwarded: Qoder rejects an unknown variant during
563
+ * deserialization and fails the whole turn, which is worse than omitting a
564
+ * tool the model could not have used anyway.
565
+ */
566
+ function qoderTools(
567
+ tools: readonly ToolDefinition[] | undefined,
568
+ ): Array<Record<string, unknown>> {
569
+ const out: Array<Record<string, unknown>> = [];
570
+ for (const tool of tools ?? []) {
571
+ if (tool.tool_type !== undefined && tool.tool_type !== "function") continue;
572
+ // The upstream requires `tools[].function` to be an object. A flat
573
+ // declaration naming the tool at the top level is rejected outright with
574
+ // "'function' is a required property, expected an object", which fails the
575
+ // whole request rather than just this tool.
576
+ out.push({
577
+ type: "function",
578
+ function: {
579
+ name: tool.name,
580
+ ...(tool.description === undefined ? { description: "" } : { description: tool.description }),
581
+ parameters: tool.jsonSchema ?? { type: "object", properties: {} },
582
+ ...(tool.strict === undefined ? {} : { strict: tool.strict }),
583
+ },
584
+ });
585
+ }
586
+ return out;
587
+ }
588
+
589
+
590
+ /**
591
+ * Projects the canonical tool choice onto the upstream's field.
592
+ *
593
+ * The upstream only accepts a string here. Given the object form
594
+ * `{type:"function",function:{name}}` it ignores the field, and the model then
595
+ * calls the tool with no arguments filled in.
596
+ *
597
+ * `allowed_tools` has no chat-completions equivalent, so it collapses to the
598
+ * direction it already names rather than being dropped: a caller that asked for
599
+ * `required` must not silently get `auto`.
600
+ */
601
+ function qoderToolChoice(choice: ToolChoice | undefined): unknown {
602
+ if (choice === undefined) return undefined;
603
+ if (typeof choice === "string") return choice;
604
+ if (choice.type === "tool" || choice.type === "custom") {
605
+ const name = choice.name.trim();
606
+ return name.length > 0 ? name : "auto";
607
+ }
608
+ return choice.mode;
609
+ }
610
+
611
+ /**
612
+ * Marks a tool call recorded into the text of an assistant turn.
613
+ *
614
+ * The upstream only reads structured turns on its CN realm, so on INTL a past
615
+ * call has to be described in prose or the model loses the record of what it
616
+ * asked for. The wording matches the reference bridge, which is what the
617
+ * upstream has been trained to read back.
618
+ */
619
+ const QODER_TOOL_MARKER = "[assistant 请求调用工具]";
620
+
621
+ /**
622
+ * Whether this request sends its tool history as structured turns.
623
+ *
624
+ * The upstream honours the structured form on its CN realm only. On INTL it is
625
+ * not enabled, and sending it anyway yields tool calls that come back right in
626
+ * name but with no arguments filled in — a call that cannot run. So the shape
627
+ * follows the realm rather than being assumed everywhere.
628
+ */
629
+ function qoderStructuredTools(realm: QoderRealm): boolean {
630
+ return realm === "cn";
631
+ }
632
+
633
+ export function buildQoderRequest(
634
+ modelId: string,
635
+ request: CanonicalRequest,
636
+ modelConfig: QoderModelConfig,
637
+ auth: QoderAuth,
638
+ realm: QoderRealm = "intl",
639
+ ): Record<string, unknown> {
640
+ const allMessages = request.messages;
641
+ const systemParts: string[] = [];
642
+ if (request.system) {
643
+ for (const p of request.system) if (p.kind === "text") systemParts.push(p.text);
644
+ }
645
+ if (request.instructions) {
646
+ for (const p of request.instructions) if (p.kind === "text") systemParts.push(p.text);
647
+ }
648
+ for (const m of allMessages) {
649
+ if (m.role === "system" || m.role === "developer") {
650
+ const t = flattenCanonicalContent(m.content);
651
+ if (t) systemParts.push(t);
652
+ }
653
+ }
654
+ const systemPrompt = systemParts.join("\n\n").trim();
655
+ const qoderMessages: Array<Record<string, unknown>> = [];
656
+ const structuredTools = qoderStructuredTools(realm);
657
+ // DeepSeek multi-turn: once any turn in the conversation carries a reasoning
658
+ // trace, every assistant message must replay `reasoning_content` (see
659
+ // `qoderReasoningEcho`).
660
+ const hasReasoningTrace = allMessages.some((message) =>
661
+ message.content.some((part) => part.kind === "reasoning"),
662
+ );
663
+ if (MODERN_PROFILE.mirrorTopLevelSystem && systemPrompt.length > 0) {
664
+ qoderMessages.push({
665
+ role: "system",
666
+ content: systemPrompt,
667
+ });
668
+ }
669
+ for (const m of allMessages) {
670
+ if (m.role === "system" || m.role === "developer") continue;
671
+ // Answers may live in a `user` turn (the Messages ledger re-homes them),
672
+ // so the result scan is role-agnostic. Each result becomes its own `tool`
673
+ // turn carrying its `tool_call_id`; any non-result content on the same turn
674
+ // is emitted after, so nothing is duplicated or lost.
675
+ const results = structuredTools ? toolResultParts(m) : [];
676
+ if (results.length > 0) {
677
+ for (const block of results) {
678
+ const text = flattenCanonicalContent(
679
+ typeof block.content === "string"
680
+ ? [{ kind: "text", text: block.content }]
681
+ : block.content,
682
+ );
683
+ qoderMessages.push({
684
+ role: "tool",
685
+ content: text,
686
+ tool_call_id: block.call_id,
687
+ });
688
+ }
689
+ // Text that shared the turn with a result is emitted after it, minus the
690
+ // result itself. Re-emitting the result's own text here was doubling
691
+ // every tool answer: the `tool` turn above and a `user` turn with the
692
+ // same content, which doubled the payload for no reason.
693
+ const remaining = m.content.filter((part) => part.kind !== "toolResult");
694
+ const flat = flattenCanonicalContent(remaining);
695
+ if (flat.length === 0) continue;
696
+ qoderMessages.push({
697
+ role: "user",
698
+ content: flat,
699
+ });
700
+ continue;
701
+ }
702
+ // An assistant turn that called tools must carry the calls, not just its
703
+ // text. Falling through to the text-only emit dropped them, so the payload
704
+ // arrived as a `tool` result with no preceding call and the upstream
705
+ // rejected the whole transcript: "Messages with role 'tool' must be a
706
+ // response to a preceding message with 'tool_calls'".
707
+ const allCalls = m.content.filter((part) => part.kind === "toolCall");
708
+ const reasoningEcho = qoderReasoningEcho(m, modelId, hasReasoningTrace);
709
+ // Structured tool turns are only safe when every call carries an id — the
710
+ // upstream drops or rejects a transcript whose `tool` result answers a call
711
+ // without one. Fall back to prose history exactly like INTL in that case.
712
+ const structuredIdsOk = allCalls.every(
713
+ (part) => typeof part.call_id === "string" && part.call_id.length > 0,
714
+ );
715
+ if ((!structuredTools || !structuredIdsOk) && allCalls.length > 0) {
716
+ // INTL does not read structured turns, so the call is written into the
717
+ // text instead of being dropped. Dropping it left the next `tool` turn
718
+ // answering a call that was never made, and the model had no record of
719
+ // what it had asked for — so it re-emitted a call with no arguments.
720
+ //
721
+ // The marker and the JSON shape mirror the reference bridge, which is
722
+ // what the upstream has actually been trained to read back.
723
+ const textParts = m.content.filter((part) => part.kind !== "toolCall");
724
+ const prose = flattenCanonicalContent(textParts);
725
+ const recorded = allCalls.map((part) => ({
726
+ name: part.name,
727
+ arguments: typeof part.arguments === "string" ? part.arguments : "",
728
+ }));
729
+ qoderMessages.push({
730
+ role: "assistant",
731
+ content: (prose.length > 0 ? prose + "\n\n" : "") + QODER_TOOL_MARKER + "\n" + JSON.stringify(recorded),
732
+ ...reasoningEcho,
733
+ });
734
+ continue;
735
+ }
736
+ const calls = structuredTools && structuredIdsOk ? allCalls : [];
737
+ if (calls.length > 0) {
738
+ const textParts = m.content.filter((part) => part.kind !== "toolCall");
739
+ const flat = flattenCanonicalContent(textParts);
740
+ qoderMessages.push({
741
+ role: "assistant",
742
+ content: flat,
743
+ ...reasoningEcho,
744
+ tool_calls: calls.map((part) => ({
745
+ id: part.call_id,
746
+ type: "function",
747
+ function: {
748
+ name: part.name,
749
+ arguments: typeof part.arguments === "string" ? part.arguments : "",
750
+ },
751
+ })),
752
+ });
753
+ continue;
754
+ }
755
+ const role = m.role === "assistant" ? "assistant" : "user";
756
+ // Images must travel as structured OpenAI parts inside the message content —
757
+ // the upstream reads `image_url` parts there and not the top-level
758
+ // `image_urls` (mirroring `flatten_messages` in the reference bridge). A
759
+ // bare file_id reference degrades to a text note instead of being dropped.
760
+ const hasImages = m.content.some((part) => part.kind === "image");
761
+ if (hasImages) {
762
+ const parts: Array<Record<string, unknown>> = [];
763
+ for (const part of m.content) {
764
+ if (part.kind === "text") {
765
+ parts.push({ type: "text", text: part.text });
766
+ } else if (part.kind === "image") {
767
+ const source = resolveImageSource(part.payload);
768
+ if (source?.url !== undefined) {
769
+ parts.push({ type: "image_url", image_url: { url: source.url } });
770
+ } else {
771
+ parts.push({
772
+ type: "text",
773
+ text: source?.fileId !== undefined ? `[image: ${source.fileId}]` : "[image: unsupported source]",
774
+ });
775
+ }
776
+ }
777
+ }
778
+ qoderMessages.push({
779
+ role,
780
+ content: parts,
781
+ ...(role === "assistant" ? qoderReasoningEcho(m, modelId, hasReasoningTrace) : {}),
782
+ });
783
+ continue;
784
+ }
785
+ const flat = flattenCanonicalContent(m.content);
786
+ qoderMessages.push({
787
+ role,
788
+ content: flat,
789
+ ...(role === "assistant" ? qoderReasoningEcho(m, modelId, hasReasoningTrace) : {}),
790
+ });
791
+ }
792
+ const latestUserMessage = [...qoderMessages].reverse().find((m) => m["role"] === "user");
793
+ const latestUserText = typeof latestUserMessage?.["content"] === "string" ? (latestUserMessage["content"] as string) : "";
794
+ const maxTokens = qoderRequestedMaxTokens(request, modelConfig);
795
+ const reasoning = modelConfig.is_reasoning === true;
796
+ const sessionId = qoderStableHash("qoder-session", auth.userId, modelId);
797
+ const recordId = qoderStableRecordId(modelId, allMessages, maxTokens);
798
+ return {
799
+ request_id: randomUUID(),
800
+ request_set_id: recordId,
801
+ chat_record_id: recordId,
802
+ session_id: sessionId,
803
+ stream: true,
804
+ ...(MODERN_PROFILE.emptyAliyunUserType ? { aliyun_user_type: "" } : {}),
805
+ chat_task: "FREE_INPUT",
806
+ is_reply: true,
807
+ is_retry: false,
808
+ source: 1,
809
+ version: "3",
810
+ session_type: "qodercli",
811
+ agent_id: "agent_common",
812
+ task_id: "common",
813
+ code_language: "",
814
+ chat_prompt: MODERN_PROFILE.mirrorTopLevelSystem ? systemPrompt : "",
815
+ image_urls: null,
816
+ chat_context: {
817
+ chatPrompt: MODERN_PROFILE.mirrorTopLevelSystem ? systemPrompt : "",
818
+ imageUrls: null,
819
+ extra: { context: [], modelConfig: { key: modelId, is_reasoning: reasoning }, originalContent: { text: latestUserText } },
820
+ features: [],
821
+ // An object, as the reference sends it: `cc["text"]["text"] = prompt`.
822
+ text: { text: latestUserText },
823
+ },
824
+ model_config: {
825
+ key: modelId,
826
+ display_name: modelConfig.display_name,
827
+ // Declares the wire dialect for the tool calls. Without it the upstream
828
+ // does not read the OpenAI-shaped `tools` array the same way, and the
829
+ // model then emits a call with no arguments filled in.
830
+ format: "openai",
831
+ model: "",
832
+ api_key: "",
833
+ url: "",
834
+ is_vl: modelConfig.is_vl,
835
+ is_reasoning: modelConfig.is_reasoning,
836
+ max_input_tokens: modelConfig.max_input_tokens,
837
+ // Mirrors the roster's stated ceiling. A fixed 32k fallback would be sent
838
+ // for every model alike, so the request would declare a cap the catalog
839
+ // does not publish.
840
+ max_output_tokens: modelConfig.max_output_tokens ?? 65_536,
841
+ source: modelConfig.source ?? "system",
842
+ },
843
+ messages: qoderMessages,
844
+ max_tokens: maxTokens,
845
+ // Always present, even when empty. Omitting the field makes Qoder fall back
846
+ // to the desktop agent's own toolset (Bash/Edit/...), which then leaks to a
847
+ // caller that declared none — the reference bridge sends `[]` for exactly
848
+ // that reason. Parallel calls are stated explicitly rather than left to the
849
+ // upstream default, so a caller expecting them is not served serial
850
+ // behaviour.
851
+ tools: qoderTools(request.tools),
852
+ parallel_tool_calls: true,
853
+ ...(() => {
854
+ const choice = qoderToolChoice(request.tool_choice);
855
+ return choice === undefined ? {} : { tool_choice: choice };
856
+ })(),
857
+ };
858
+ }
859
+
860
+ // ---------------------------------------------------------------------------
861
+ // DSML tool-call recovery — DeepSeek's native markup in the text stream
862
+ // ---------------------------------------------------------------------------
863
+ // Some DeepSeek-family models write their tool calls as XML-like DSML markup
864
+ // inside the plain-text `content` stream instead of the structured
865
+ // `tool_calls` field. Left alone, the markup reaches the caller as visible
866
+ // prose and the tool never runs — which is exactly the symptom reported.
867
+ //
868
+ // The parser lives in `src/protocol/dsml.ts`, shared with the CodeBuddy
869
+ // family; Qoder selects the fuzzy dialect, which mirrors the reference bridge's
870
+ // Python regexes (`qoder_proxy.py`, `parse_dsml_tool_calls`).
871
+ import {
872
+ parseFuzzyDsmlToolCalls,
873
+ dsmlTailHold,
874
+ stripDsmlCloseTags,
875
+ type DsmlToolCall,
876
+ } from "../../../protocol/dsml";
877
+
878
+ /** Qoder's dialect bounds the shared parser to the fuzzy (non-strict) form. */
879
+ export const parseDsmlToolCalls = (text: string) => parseFuzzyDsmlToolCalls(text);
880
+ export { dsmlTailHold, stripDsmlCloseTags } from "../../../protocol/dsml";
881
+
882
+ /** One recovered call, in the shape the canonical events want. */
883
+ export type DsmlCall = DsmlToolCall;
884
+
885
+ /**
886
+ * Escapes the bare quotes that belong inside a JSON string value.
887
+ *
888
+ * Escaped quotes are parked first so they are not escaped twice, then every
889
+ * remaining quote is escaped, then the parked ones are restored.
890
+ */
891
+ function repairJsonQuotes(text: string): string {
892
+ const ph = "\u0000";
893
+ return text.replace(/\\"/g, ph).replace(/"/g, '\\"').replaceAll(ph, '\\"');
894
+ }
895
+
896
+ /** `[{"name":"…","arguments":"<unescaped JSON>"}]`, one call only. */
897
+ const ECHO_LOOSE_RE = /^\[\s*\{\s*"name"\s*:\s*"([^"]{1,200})"\s*,\s*"arguments"\s*:\s*"(.*)"\s*\}\s*\]$/s;
898
+
899
+ /**
900
+ * Any spelling of the INTL tool-call leak marker — the square-bracket
901
+ * canonical form, the angle-bracket variant the upstream also emits
902
+ * (`<assistant 请求调用工具>`), and partial variants missing the closing
903
+ * bracket. Mirrors `_LEAK_MARKER_RE` in the reference bridge.
904
+ */
905
+ const LEAK_MARKER_ANY_RE = /[\[<(]*\s*assistant\s*请求调用工具\s*[\]>)]?/;
906
+
907
+ /** Canonical marker literal used to seed prefix matching. */
908
+ const LEAK_MARKER_LITERALS: readonly string[] = [
909
+ "[assistant 请求调用工具]",
910
+ "<assistant 请求调用工具>",
911
+ "assistant 请求调用工具",
912
+ ];
913
+
914
+ /** Longest partial marker we hold across frames (mirrors `_MARKER_HOLD_WINDOW`). */
915
+ const LEAK_HOLD_WINDOW = 24;
916
+ /** Fail-open caps so an endless lookalike cannot hold text forever. */
917
+ const LEAK_HOLD_MAX_CHARS = 32_768;
918
+ const LEAK_HOLD_MAX_FRAMES = 200;
919
+
920
+ /**
921
+ * True when `suffix` is a strict prefix of some marker literal — the stream
922
+ * may be mid-marker and the rest arrives in the next frame.
923
+ */
924
+ function isLeakMarkerPrefix(suffix: string): boolean {
925
+ if (suffix.length === 0 || suffix.length >= LEAK_HOLD_WINDOW) return false;
926
+ return LEAK_MARKER_LITERALS.some((literal) => literal.startsWith(suffix));
927
+ }
928
+
929
+ /**
930
+ * Splits one content chunk into `{ emit, hold }`, mirroring the reference
931
+ * bridge's `_find_marker_split`: prose before a complete marker is emitted
932
+ * immediately, the marker and everything after it is held, and a partial
933
+ * marker at the very end is held for the next frame.
934
+ */
935
+ function qoderLeakSplit(text: string): { emit: string; hold: string } {
936
+ if (text.length === 0) return { emit: "", hold: "" };
937
+ const marker = LEAK_MARKER_ANY_RE.exec(text);
938
+ if (marker) {
939
+ return { emit: text.slice(0, marker.index), hold: text.slice(marker.index) };
940
+ }
941
+ // No complete marker: hold a trailing partial-marker suffix only.
942
+ const tail = text.slice(-LEAK_HOLD_WINDOW);
943
+ for (let n = tail.length; n >= 1; n -= 1) {
944
+ const seg = tail.slice(-n);
945
+ if (isLeakMarkerPrefix(seg)) {
946
+ return { emit: text.slice(0, text.length - n), hold: text.slice(text.length - n) };
947
+ }
948
+ }
949
+ return { emit: text, hold: "" };
950
+ }
951
+
952
+ /**
953
+ * True when `text` is still plausibly the INTL leak block (marker + JSON
954
+ * array). Used to keep holding across frames; false means it is ordinary
955
+ * prose and can be released. Mirrors `_still_leak_candidate`.
956
+ */
957
+ function qoderStillLeakCandidate(text: string): boolean {
958
+ if (text.length === 0) return true;
959
+ const marker = LEAK_MARKER_ANY_RE.exec(text);
960
+ if (!marker) {
961
+ // No complete marker yet: still a candidate only while it could grow into
962
+ // one (full prefix or pending marker remainder).
963
+ return isLeakMarkerPrefix(text) || LEAK_MARKER_ANY_RE.test(text);
964
+ }
965
+ const rest = text.slice(marker.index + marker[0].length).trim();
966
+ if (rest.length === 0) return true;
967
+ if (rest.startsWith("[")) return true;
968
+ if (rest.startsWith("{")) return true;
969
+ return false;
970
+ }
971
+
972
+ /**
973
+ * Rebuilds the single-call echo shape when strict parsing fails.
974
+ *
975
+ * Returns `null` when the text is not that shape at all, so a caller can fall
976
+ * back rather than accepting a guess. The result still goes through the same
977
+ * strict checks as a normally-parsed array.
978
+ */
979
+ function salvageEchoArray(rest: string): unknown {
980
+ const m = ECHO_LOOSE_RE.exec(rest.trim());
981
+ if (!m) return null;
982
+ const name = m[1] ?? "";
983
+ const raw = m[2] ?? "";
984
+ for (const candidate of [raw, repairJsonQuotes(raw)]) {
985
+ try {
986
+ JSON.parse(candidate);
987
+ return [{ name, arguments: candidate }];
988
+ } catch {
989
+ continue;
990
+ }
991
+ }
992
+ return null;
993
+ }
994
+
995
+ /**
996
+ * Recovers a tool call the model echoed back as text.
997
+ *
998
+ * On INTL the upstream reads tool calls as prose, so a turn that called a tool
999
+ * is described with `QODER_TOOL_MARKER` followed by a JSON array of
1000
+ * `{name, arguments}`. The model occasionally reproduces that shape verbatim as
1001
+ * its answer instead of calling again, and left alone the client shows the JSON
1002
+ * as content while the tool never runs.
1003
+ *
1004
+ * Only a well-formed array whose every entry names a tool and carries parseable
1005
+ * arguments is accepted. Anything else returns no calls and the original text,
1006
+ * so a model merely talking about the format is never mistaken for a call.
1007
+ */
1008
+ export function parseLeakedToolCalls(
1009
+ text: string,
1010
+ allowedNames?: ReadonlySet<string>,
1011
+ ): { calls: DsmlCall[]; clean: string } {
1012
+ if (text.length === 0) {
1013
+ return { calls: [], clean: text };
1014
+ }
1015
+ // The upstream occasionally echoes the marker in variants — angle brackets,
1016
+ // a missing closing bracket, or wrapped inside a ``` fence. Strip a leading
1017
+ // fence, then locate any marker spelling, mirroring the reference bridge's
1018
+ // `_normalize_leak_marker`.
1019
+ const fence = text.match(/^```[a-z]*\s*\n?/i);
1020
+ const src = fence && fence[0] ? text.slice(fence[0].length) : text;
1021
+ const marker = LEAK_MARKER_ANY_RE.exec(src);
1022
+ if (!marker) {
1023
+ return { calls: [], clean: text };
1024
+ }
1025
+ const head = src.slice(0, marker.index).trim();
1026
+ let rest = src.slice(marker.index + marker[0].length).trim();
1027
+ // The model occasionally leaves raw DSML closing tags after the echo array
1028
+ // (`</|DSML|parameter>` etc.), and a ``` fence may close the block. Strip
1029
+ // trailing residue so the JSON parse is not poisoned by "Extra data".
1030
+ rest = stripDsmlCloseTags(rest);
1031
+ rest = rest.replace(/\s*```\s*$/, "").trim();
1032
+
1033
+ let parsed: unknown;
1034
+ try {
1035
+ parsed = JSON.parse(rest);
1036
+ } catch {
1037
+ // A model echoing the call writes the inner quotes unescaped:
1038
+ // `[{"name":"Read","arguments":"{"file_path":"a.ts"}"}]`. That is not
1039
+ // valid JSON at either level, so the array has to be rebuilt before it can
1040
+ // be trusted. Only the single-call shape is salvaged, which is what an echo
1041
+ // almost always is.
1042
+ const salvaged = salvageEchoArray(rest);
1043
+ if (salvaged === null) return { calls: [], clean: text };
1044
+ parsed = salvaged;
1045
+ }
1046
+ if (Array.isArray(parsed) && parsed.length === 0) return { calls: [], clean: text };
1047
+
1048
+ // The model echoes the call either as a JSON array (`[{name,arguments},…]`)
1049
+ // or as a single object (`{"name":…,"arguments":…}`). Normalize the single
1050
+ // object into a one-entry array so both shapes share one validator.
1051
+ const items: unknown[] = Array.isArray(parsed) ? parsed : [parsed];
1052
+ const calls: DsmlCall[] = [];
1053
+ for (const item of items) {
1054
+ if (typeof item !== "object" || item === null) return { calls: [], clean: text };
1055
+ const rec = item as Record<string, unknown>;
1056
+ const name = typeof rec["name"] === "string" ? rec["name"].trim() : "";
1057
+ if (name.length === 0) return { calls: [], clean: text };
1058
+ if (allowedNames && !allowedNames.has(name)) return { calls: [], clean: text };
1059
+ let args = rec["arguments"];
1060
+ if (typeof args === "object" && args !== null) args = JSON.stringify(args);
1061
+ if (typeof args !== "string") return { calls: [], clean: text };
1062
+ // Arguments that do not parse are not a call this can trust.
1063
+ if (args.trim().length > 0) {
1064
+ try {
1065
+ JSON.parse(args);
1066
+ } catch {
1067
+ return { calls: [], clean: text };
1068
+ }
1069
+ }
1070
+ calls.push({ id: `call_${randomUUID().replace(/-/g, "").slice(0, 16)}`, name, arguments: args });
1071
+ }
1072
+ return { calls, clean: head };
1073
+ }
1074
+
1075
+ // Stream transformation — decode SSE, unwrap COSY envelope, map OpenAI chat chunks to CanonicalEvent
1076
+
1077
+ function mapQoderFinishReason(raw: unknown): CanonicalStopReason | undefined {
1078
+ if (raw === "length") return "length";
1079
+ if (raw === "tool_calls") return "tool_use";
1080
+ if (raw === "content_filter") return "content_filter";
1081
+ return undefined;
1082
+ }
1083
+
1084
+ export async function* qoderBodyToCanonicalEvents(
1085
+ body: ReadableStream<Uint8Array>,
1086
+ signal: AbortSignal,
1087
+ model: string,
1088
+ ): AsyncIterable<CanonicalEvent> {
1089
+ let seq = 1;
1090
+ yield { type: "response_start", sequence_number: seq++, model } as CanonicalEvent;
1091
+ let finishReason: unknown = undefined;
1092
+ let rawUsage: Record<string, unknown> | undefined = undefined;
1093
+ let hasTerminal = false;
1094
+ /** First-sighting id per wire tool-call index, for continuation chunks. */
1095
+ const indexIds = new Map<number, string>();
1096
+ /** Last seen call id/name, for index-less continuation fragments. */
1097
+ let lastSeenToolId: string | undefined = undefined;
1098
+ let lastSeenToolName: string | undefined = undefined;
1099
+ // DSML recovery state. While `buf` is empty the text passes through
1100
+ // untouched; once a chunk carries DSML the rest is buffered so the markup can
1101
+ // be parsed whole at end of stream. `tail` holds a possible half-tag that
1102
+ // straddles a frame boundary.
1103
+ let buf = "";
1104
+ let tail = "";
1105
+ // Text that flowed through while not in a DSML stream. The INTL leak marker
1106
+ // (`[assistant 请求调用工具] …`) is not DSML, so it was never buffered and
1107
+ // recovered; keeping the plain text lets the end-of-stream recovery see it.
1108
+ let plainBuf = "";
1109
+ // INTL leak-marker hold-back. Text that may grow into the leak block is
1110
+ // withheld from the client (not yielded) until the stream proves it is
1111
+ // either a tool-call echo (recovered at end of stream) or ordinary prose
1112
+ // (released). Mirrors the reference bridge's `hold` logic.
1113
+ let leakBuf = "";
1114
+ let leakFrames = 0;
1115
+ /** Calls recovered from the thinking channel, emitted if content had none. */
1116
+ let reasonCalls: readonly DsmlCall[] = [];
1117
+ /** True once any tool call has been emitted by any channel this turn. */
1118
+ let hasEmittedToolCall = false;
1119
+ // The same recovery for the thinking channel. A model that plans its call
1120
+ // inside `reasoning_content` writes the markup there instead of in `content`,
1121
+ // so recovering only the content channel loses the call *and* leaves the
1122
+ // tags visible in the user's thinking.
1123
+ let reasonBuf = "";
1124
+ let reasonTail = "";
1125
+
1126
+ for await (const sse of decodeSseEvents(body, { signal })) {
1127
+ const sseData = sse.data;
1128
+ let frames: Array<{ event: string | null; data: string }>;
1129
+ try {
1130
+ frames = qoderEnvelopeToFrames(sseData);
1131
+ } catch (e) {
1132
+ throw e;
1133
+ }
1134
+ for (const frame of frames) {
1135
+ const data = frame.data.trim();
1136
+ if (!data) continue;
1137
+ if (data === "[DONE]") {
1138
+ hasTerminal = true;
1139
+ continue;
1140
+ }
1141
+ let json: Record<string, unknown>;
1142
+ try {
1143
+ json = JSON.parse(data) as Record<string, unknown>;
1144
+ } catch {
1145
+ continue;
1146
+ }
1147
+ if (json["usage"] && typeof json["usage"] === "object") rawUsage = json["usage"] as Record<string, unknown>;
1148
+ const choices = (json["choices"] as Array<Record<string, unknown>>) ?? [];
1149
+ for (const choice of choices) {
1150
+ const delta = choice["delta"] as Record<string, unknown> | undefined;
1151
+ if (delta) {
1152
+ if (typeof delta["content"] === "string" && (delta["content"] as string).length > 0) {
1153
+ const piece = tail + (delta["content"] as string);
1154
+ tail = "";
1155
+ if (buf.length === 0) {
1156
+ // Not yet in a DSML stream: hold back only a possible tag start,
1157
+ // and pass everything before it through unchanged.
1158
+ const hold = dsmlTailHold(piece);
1159
+ const body = hold.length > 0 ? piece.slice(0, piece.length - hold.length) : piece;
1160
+ if (body.includes("DSML")) {
1161
+ buf = body;
1162
+ tail = hold;
1163
+ continue;
1164
+ }
1165
+ // INTL leak-marker hold-back: do not let a marker echo reach the
1166
+ // client as text. Prose before a complete marker is emitted now;
1167
+ // the marker and anything after it are held until the stream
1168
+ // decides (a valid echo → tool calls; anything else → released).
1169
+ if (leakBuf.length > 0) {
1170
+ leakBuf += body;
1171
+ leakFrames += 1;
1172
+ if (
1173
+ leakBuf.length > LEAK_HOLD_MAX_CHARS ||
1174
+ leakFrames > LEAK_HOLD_MAX_FRAMES ||
1175
+ !qoderStillLeakCandidate(leakBuf)
1176
+ ) {
1177
+ // Released as ordinary prose: it was already shown, so it
1178
+ // must not re-enter `plainBuf` (the end-of-stream recovery
1179
+ // would re-parse it and emit a duplicate tool call).
1180
+ if (leakBuf.length > 0) {
1181
+ yield {
1182
+ type: "content_delta",
1183
+ sequence_number: seq++,
1184
+ content: { kind: "text", text: leakBuf },
1185
+ } as CanonicalEvent;
1186
+ }
1187
+ leakBuf = "";
1188
+ leakFrames = 0;
1189
+ }
1190
+ tail = hold;
1191
+ continue;
1192
+ }
1193
+ const { emit, hold: leakHold } = qoderLeakSplit(body);
1194
+ if (emit.length > 0) {
1195
+ plainBuf += emit;
1196
+ yield {
1197
+ type: "content_delta",
1198
+ sequence_number: seq++,
1199
+ content: { kind: "text", text: emit },
1200
+ } as CanonicalEvent;
1201
+ }
1202
+ if (leakHold.length > 0) {
1203
+ leakBuf = leakHold;
1204
+ leakFrames = 1;
1205
+ tail = hold;
1206
+ continue;
1207
+ }
1208
+ // No marker at all: `emit` already covered the whole body, so
1209
+ // only yield here when the split held nothing and emitted nothing.
1210
+ if (emit.length === 0 && body.length > 0) {
1211
+ plainBuf += body;
1212
+ yield {
1213
+ type: "content_delta",
1214
+ sequence_number: seq++,
1215
+ content: { kind: "text", text: body },
1216
+ } as CanonicalEvent;
1217
+ }
1218
+ tail = hold;
1219
+ continue;
1220
+ }
1221
+ buf += piece;
1222
+ continue;
1223
+ }
1224
+ const reasoningText = readReasoningText(delta);
1225
+ if (typeof reasoningText === "string" && reasoningText.length > 0) {
1226
+ const piece = reasonTail + reasoningText;
1227
+ reasonTail = "";
1228
+ if (reasonBuf.length === 0) {
1229
+ const hold = dsmlTailHold(piece);
1230
+ const body = hold.length > 0 ? piece.slice(0, piece.length - hold.length) : piece;
1231
+ if (body.includes("DSML")) {
1232
+ reasonBuf = body;
1233
+ reasonTail = hold;
1234
+ continue;
1235
+ }
1236
+ const visible = stripDsmlCloseTags(body);
1237
+ if (visible.length > 0) {
1238
+ yield {
1239
+ type: "content_delta",
1240
+ sequence_number: seq++,
1241
+ content: { kind: "reasoning", payload: null, summary: visible },
1242
+ } as CanonicalEvent;
1243
+ }
1244
+ reasonTail = hold;
1245
+ continue;
1246
+ }
1247
+ reasonBuf += piece;
1248
+ continue;
1249
+ }
1250
+ const toolCalls = delta["tool_calls"] as Array<Record<string, unknown>> | undefined;
1251
+ if (toolCalls) {
1252
+ for (const tc of toolCalls) {
1253
+ const fn = tc["function"] as Record<string, unknown> | undefined;
1254
+ const index = typeof tc["index"] === "number" ? (tc["index"] as number) : undefined;
1255
+ const declaredId = typeof tc["id"] === "string" ? (tc["id"] as string) : undefined;
1256
+ const fragName = typeof fn?.["name"] === "string" ? (fn["name"] as string) : undefined;
1257
+ // OpenAI continuation chunks re-identify their call by wire
1258
+ // `index` alone — the `id` appears only once, in the first chunk.
1259
+ // Some bridges omit `index` too, so a fragment then falls back to
1260
+ // the most recently seen call id (mirroring the generic chat
1261
+ // parser's `lastSeenToolId`). Pinning the first id per index and
1262
+ // reusing it for continuations is what keeps fragments attached
1263
+ // to the right call; synthesizing a fresh `call_<index>` id per
1264
+ // chunk instead made every continuation an unknown nameless call
1265
+ // that encoders dropped, so the client saw a call whose arguments
1266
+ // were always empty.
1267
+ let callId: string;
1268
+ if (declaredId !== undefined) {
1269
+ callId = declaredId;
1270
+ if (index !== undefined) indexIds.set(index, callId);
1271
+ lastSeenToolId = callId;
1272
+ if (fragName !== undefined) lastSeenToolName = fragName;
1273
+ } else if (index !== undefined && indexIds.has(index)) {
1274
+ callId = indexIds.get(index)!;
1275
+ } else if (
1276
+ lastSeenToolId !== undefined &&
1277
+ (fragName === undefined || lastSeenToolName === fragName)
1278
+ ) {
1279
+ callId = lastSeenToolId;
1280
+ } else {
1281
+ callId = `call_${String(index ?? 0)}`;
1282
+ }
1283
+ hasEmittedToolCall = true;
1284
+ yield {
1285
+ type: "tool_call_delta",
1286
+ sequence_number: seq++,
1287
+ call_id: callId,
1288
+ ...(index !== undefined ? { index } : {}),
1289
+ ...(fragName === undefined ? {} : { name: fragName }),
1290
+ arguments_delta: (fn?.["arguments"] as string) ?? "",
1291
+ } as CanonicalEvent;
1292
+ }
1293
+ }
1294
+ }
1295
+ const fr = choice["finish_reason"];
1296
+ if (fr !== null && fr !== undefined) finishReason = fr;
1297
+ // Some providers send usage in choices[0].usage or top-level
1298
+ if (choice["usage"] && typeof choice["usage"] === "object") rawUsage = choice["usage"] as Record<string, unknown>;
1299
+ }
1300
+ // Some Qoder chunks carry finish_reason at top level
1301
+ if (json["finish_reason"] !== undefined) finishReason = json["finish_reason"];
1302
+ }
1303
+ }
1304
+ // End of stream: resolve whatever the DSML buffer collected.
1305
+ if (reasonBuf.length > 0) {
1306
+ const { calls, clean } = parseDsmlToolCalls(reasonBuf);
1307
+ const visible = stripDsmlCloseTags(clean).trim();
1308
+ if (visible.length > 0) {
1309
+ yield {
1310
+ type: "content_delta",
1311
+ sequence_number: seq++,
1312
+ content: { kind: "reasoning", payload: null, summary: visible },
1313
+ } as CanonicalEvent;
1314
+ }
1315
+ // A call recovered here counts exactly like one recovered from the content
1316
+ // channel — the two are deduplicated below, so a model that writes the
1317
+ // block in one channel and its tail in the other still yields one call.
1318
+ if (calls.length > 0) reasonCalls = calls;
1319
+ }
1320
+ if (buf.length > 0) {
1321
+ // Two ways a call can arrive as text: DSML markup, and the marker + JSON
1322
+ // array the upstream itself uses on INTL. The second is tried only when the
1323
+ // first finds nothing, so a DSML stream is never re-read as an echo.
1324
+ let { calls, clean } = parseDsmlToolCalls(buf);
1325
+ if (calls.length === 0) {
1326
+ const leaked = parseLeakedToolCalls(buf);
1327
+ if (leaked.calls.length > 0) {
1328
+ calls = leaked.calls;
1329
+ clean = leaked.clean;
1330
+ }
1331
+ }
1332
+ if (calls.length > 0) {
1333
+ const visible = stripDsmlCloseTags(clean).trim();
1334
+ if (visible.length > 0) {
1335
+ yield {
1336
+ type: "content_delta",
1337
+ sequence_number: seq++,
1338
+ content: { kind: "text", text: visible },
1339
+ } as CanonicalEvent;
1340
+ }
1341
+ for (const call of calls) {
1342
+ hasEmittedToolCall = true;
1343
+ yield {
1344
+ type: "tool_call_delta",
1345
+ sequence_number: seq++,
1346
+ call_id: call.id,
1347
+ name: call.name,
1348
+ arguments_delta: call.arguments,
1349
+ } as CanonicalEvent;
1350
+ }
1351
+ reasonCalls = [];
1352
+ // The turn ended by calling a tool, whatever the upstream reported.
1353
+ finishReason = "tool_calls";
1354
+ } else {
1355
+ // Unparseable markup: emit as prose rather than dropping it, so nothing
1356
+ // the model said is lost.
1357
+ const visible = stripDsmlCloseTags(buf).trim();
1358
+ if (visible.length > 0) {
1359
+ yield {
1360
+ type: "content_delta",
1361
+ sequence_number: seq++,
1362
+ content: { kind: "text", text: visible },
1363
+ } as CanonicalEvent;
1364
+ }
1365
+ }
1366
+ }
1367
+ // Content carried no call of its own: whatever the thinking channel
1368
+ // recovered is the turn's call.
1369
+ for (const call of reasonCalls) {
1370
+ hasEmittedToolCall = true;
1371
+ yield {
1372
+ type: "tool_call_delta",
1373
+ sequence_number: seq++,
1374
+ call_id: call.id,
1375
+ name: call.name,
1376
+ arguments_delta: call.arguments,
1377
+ } as CanonicalEvent;
1378
+ }
1379
+ // The INTL leak marker is plain text, not DSML, so it flowed through to
1380
+ // `plainBuf` instead of the buffered `buf`. Recover it the same way: a
1381
+ // model that answers with `[assistant 请求调用工具] [{"name":…,"arguments":…}]`
1382
+ // intended the tool to run, and left alone the client sees the JSON as
1383
+ // content while nothing executes. Only when no other channel already
1384
+ // claimed the turn.
1385
+ if (!hasEmittedToolCall) {
1386
+ // Held-back marker block first: it was withheld from the client, so a
1387
+ // recovery here never duplicates text already shown. A valid echo becomes
1388
+ // tool calls; anything else is released as ordinary prose.
1389
+ if (leakBuf.length > 0) {
1390
+ const leaked = parseLeakedToolCalls(leakBuf);
1391
+ if (leaked.calls.length > 0) {
1392
+ for (const call of leaked.calls) {
1393
+ hasEmittedToolCall = true;
1394
+ yield {
1395
+ type: "tool_call_delta",
1396
+ sequence_number: seq++,
1397
+ call_id: call.id,
1398
+ name: call.name,
1399
+ arguments_delta: call.arguments,
1400
+ } as CanonicalEvent;
1401
+ }
1402
+ finishReason = "tool_calls";
1403
+ } else if (leaked.clean.length > 0) {
1404
+ yield {
1405
+ type: "content_delta",
1406
+ sequence_number: seq++,
1407
+ content: { kind: "text", text: leaked.clean },
1408
+ } as CanonicalEvent;
1409
+ }
1410
+ }
1411
+ if (plainBuf.length > 0 && !hasEmittedToolCall) {
1412
+ const leaked = parseLeakedToolCalls(plainBuf);
1413
+ if (leaked.calls.length > 0) {
1414
+ for (const call of leaked.calls) {
1415
+ hasEmittedToolCall = true;
1416
+ yield {
1417
+ type: "tool_call_delta",
1418
+ sequence_number: seq++,
1419
+ call_id: call.id,
1420
+ name: call.name,
1421
+ arguments_delta: call.arguments,
1422
+ } as CanonicalEvent;
1423
+ }
1424
+ finishReason = "tool_calls";
1425
+ }
1426
+ }
1427
+ }
1428
+ // A recovered call outranks what the upstream reported. It says `stop`
1429
+ // because the markup never became a real tool call on its side, so leaving it
1430
+ // alone ends the turn "finished" with a call the caller never sees run.
1431
+ if (reasonCalls.length > 0) finishReason = "tool_calls";
1432
+ if (tail.length > 0) {
1433
+ yield {
1434
+ type: "content_delta",
1435
+ sequence_number: seq++,
1436
+ content: { kind: "text", text: tail },
1437
+ } as CanonicalEvent;
1438
+ }
1439
+ // If no terminal arrived, synthesize one (prevents truncation error)
1440
+ const stopReason = mapQoderFinishReason(finishReason);
1441
+ const _providerStop = typeof finishReason === "string" ? finishReason : undefined;
1442
+ void hasTerminal;
1443
+ yield {
1444
+ type: "terminal",
1445
+ sequence_number: seq++,
1446
+ state: "complete",
1447
+ ...(stopReason === undefined ? {} : { stop_reason: stopReason }),
1448
+ ...(_providerStop === undefined ? {} : { provider_stop_reason: _providerStop }),
1449
+ usage: usageFromProvider(rawUsage),
1450
+ } as CanonicalEvent;
1451
+ }
1452
+
1453
+ // Model catalog — maps QODER_MODEL_CONFIGS (provider) to Cartethyia ModelDefinition
1454
+
1455
+ /**
1456
+ * The intl catalog this provider publishes.
1457
+ *
1458
+ * `QODER_MODEL_CONFIGS` is not the source here: it freezes most rows at 180k
1459
+ * while the upstream reports 1M for the same keys. That number is not
1460
+ * cosmetic: it is sent upstream as `model_config.max_input_tokens`, so a stale
1461
+ * low value tells Qoder the conversation window is small and makes it behave
1462
+ * that way. Both the published catalog and the dispatched request come from the
1463
+ * realm's roster, which carries the upstream's own numbers.
1464
+ */
1465
+ export const QODER_MODELS: readonly ModelDefinition[] = QODER_INTL_MODELS;
1466
+
1467
+ // ProviderAdapter — manual dispatch preserving COSY encryption + envelope logic
1468
+
1469
+ interface QoderAdapterOptions {
1470
+ readonly fetch?: typeof fetch;
1471
+ /** Which deployment this adapter dispatches to. Defaults to intl. */
1472
+ readonly realm?: QoderRealm;
1473
+ /** Bundled provider id, so the adapter's own id matches the route's. */
1474
+ readonly providerId?: string;
1475
+ }
1476
+
1477
+ class QoderAdapter implements ProviderAdapter {
1478
+ readonly provider_id: ProviderId;
1479
+ private readonly realm: QoderRealm;
1480
+ private readonly fetchFn: typeof fetch;
1481
+
1482
+ constructor(options: QoderAdapterOptions = {}) {
1483
+ this.provider_id = (options.providerId ?? QODER_PROVIDER_ID) as ProviderId;
1484
+ this.realm = options.realm ?? "intl";
1485
+ this.fetchFn = options.fetch ?? globalThis.fetch;
1486
+ }
1487
+
1488
+ /**
1489
+ * The static roster for this adapter's realm.
1490
+ *
1491
+ * A single merged table advertised CN-only keys to global accounts and vice
1492
+ * versa, so a request was routed to a deployment that refuses it.
1493
+ */
1494
+ #modelConfigs(): Record<string, QoderModelConfig> {
1495
+ return (this.realm === "cn" ? QODER_CN_MODEL_CONFIGS : QODER_INTL_MODEL_CONFIGS) as Record<
1496
+ string,
1497
+ QoderModelConfig
1498
+ >;
1499
+ }
1500
+
1501
+ async *dispatch(
1502
+ request: CanonicalRequest,
1503
+ candidate: ProviderDispatchTarget,
1504
+ context: ProviderDispatchContext,
1505
+ ): AsyncIterable<CanonicalEvent> {
1506
+ if (candidate.wire_family !== "chat") {
1507
+ throw new GatewayError("capability_unsupported", 400, `qoder supports chat only, got ${candidate.wire_family}`);
1508
+ }
1509
+ const secret = readCredentialSecret(context.credential, "Qoder credential is required");
1510
+
1511
+ /**
1512
+ * A caller-facing virtual name is translated to its roster key here.
1513
+ *
1514
+ * The translation has to happen before the config lookup and before the
1515
+ * request body: the body carries the roster key, and looking up the
1516
+ * virtual id in the config table would fall through to the synthetic
1517
+ * default and silently dispatch under-declared capabilities.
1518
+ */
1519
+ const requestedModelId = candidate.model_id || request.model;
1520
+ if (isQoderVirtualModel(requestedModelId) && !this.#modelConfigs()[qoderDispatchModelId(requestedModelId)]) {
1521
+ throw new GatewayError(
1522
+ "model_not_found",
1523
+ 404,
1524
+ `Qoder virtual model ${requestedModelId} resolves to a roster key this realm does not serve`,
1525
+ { virtual_model: requestedModelId, target: qoderDispatchModelId(requestedModelId) },
1526
+ "upstream",
1527
+ );
1528
+ }
1529
+ const modelId = qoderDispatchModelId(requestedModelId);
1530
+ const modelConfig = this.#modelConfigs()[modelId] ?? {
1531
+ id: modelId,
1532
+ display_name: modelId,
1533
+ max_input_tokens: 180_000,
1534
+ is_vl: true,
1535
+ is_reasoning: false,
1536
+ };
1537
+
1538
+ const lifecycle = createUpstreamDeadlineLifecycle(context);
1539
+
1540
+ // Outbound fetch respects SSRF binding when provided
1541
+ const outboundFetch: typeof fetch = (context.outbound_fetch as unknown as typeof fetch) ?? this.fetchFn;
1542
+
1543
+ const fetcher: QoderFetcher = (url, init) =>
1544
+ outboundFetch(url, { ...init, signal: lifecycle.signal } as RequestInit) as Promise<Response>;
1545
+
1546
+ try {
1547
+ const version = getQoderVersion();
1548
+ // Dual mode like Cline: a pasted PAT is exchanged for a job token per
1549
+ // request, while an OAuth device token is used directly with the
1550
+ // identity the login persisted.
1551
+ const auth = isQoderPat(secret)
1552
+ ? await exchangeQoderPat(secret, lifecycle.signal, fetcher, version, this.realm)
1553
+ : qoderOAuthAuth(secret, context.credential.auth_state, context.credential.account_id);
1554
+ const qoderBody = buildQoderRequest(modelId, request, modelConfig, auth, this.realm);
1555
+ // A full URL from the route wins, but the fallback is this realm's
1556
+ // gateway — not the intl host hardcoded into `MODERN_PROFILE`. Dispatching
1557
+ // a CN credential to `*.qoder.sh` is refused outright.
1558
+ const url =
1559
+ candidate.endpoint_path && candidate.endpoint_path.startsWith("http")
1560
+ ? candidate.endpoint_path
1561
+ : `${qoderGatewayCandidates(this.realm)[0] ?? ""}${QODER_CHAT_PATH}`;
1562
+
1563
+ const response = await callQoder(url, qoderBody, modelId, auth, lifecycle.signal, fetcher, version);
1564
+ if (!response.ok) throw await mapUpstreamHttpError(response, "qoder");
1565
+ if (!response.body) throw new GatewayError("platform_unavailable", 502, "Qoder returned empty body", {}, "upstream");
1566
+ // Headers arrived: the pre-stream deadline has served its purpose.
1567
+ // From here the gateway stall/first-chunk watchdog (propagated via
1568
+ // context.abort_signal) governs the body. Keeping this timer armed
1569
+ // would kill healthy long streams at the stale pre-stream deadline.
1570
+ lifecycle.release();
1571
+ yield* qoderBodyToCanonicalEvents(response.body as ReadableStream<Uint8Array>, lifecycle.signal, request.model);
1572
+ } catch (err: unknown) {
1573
+ const abortError = abortGatewayError(lifecycle, err, context.abort_signal);
1574
+ if (abortError) throw abortError;
1575
+ throw err;
1576
+ } finally {
1577
+ lifecycle.release();
1578
+ }
1579
+ }
1580
+ }
1581
+
1582
+ export function createQoderAdapter(options: QoderAdapterOptions = {}): ProviderAdapter {
1583
+ return new QoderAdapter(options);
1584
+ }
1585
+
1586
+ /**
1587
+ * Adapter for the China deployment.
1588
+ *
1589
+ * Same wire, different gateway host and roster — see `qoder-realm.ts`.
1590
+ */
1591
+ export function createQoderCnAdapter(options: QoderAdapterOptions = {}): ProviderAdapter {
1592
+ return new QoderAdapter({ ...options, realm: "cn", providerId: "qodercn" });
1593
+ }
1594
+
1595
+ /**
1596
+ * A COSY signer for one already-resolved account identity.
1597
+ *
1598
+ * Discovery needs signed headers but lives in `qoder-catalog.ts`, which cannot
1599
+ * import this module (the adapter imports its rosters — that would be a cycle).
1600
+ * Exporting the signer keeps one signing implementation and lets discovery
1601
+ * stay pure and injectable.
1602
+ */
1603
+ export function qoderSignerFor(
1604
+ auth: QoderAuth,
1605
+ version = getQoderVersion(),
1606
+ ): (url: string, body: string) => Record<string, string> {
1607
+ return (url: string, body: string): Record<string, string> =>
1608
+ qoderBuildCosyHeaders(Buffer.from(body, "latin1"), url, auth, version);
1609
+ }
1610
+
1611
+ /** Qoder's own base64 alphabet transform, for a request body. */
1612
+ export function qoderEncodeBody(plaintext: Uint8Array | string): string {
1613
+ const text = typeof plaintext === "string" ? plaintext : new TextDecoder().decode(plaintext);
1614
+ return Buffer.from(transformQoderBody(text)).toString("latin1");
1615
+ }
1616
+
1617
+ /**
1618
+ * Resolves the dispatch identity for one Qoder credential.
1619
+ *
1620
+ * Shared by the adapter and by discovery: both need the same `QoderAuth` the
1621
+ * COSY signature is built from, and duplicating the PAT-vs-OAuth branch would
1622
+ * let the two drift.
1623
+ */
1624
+ export async function resolveQoderAuth(input: {
1625
+ readonly secret: string;
1626
+ readonly authState: unknown;
1627
+ readonly signal: AbortSignal;
1628
+ readonly fetcher: (url: string, init: RequestInit) => Promise<Response>;
1629
+ /** Which deployment mints the job token; a PAT is realm-bound. */
1630
+ readonly realm?: QoderRealm;
1631
+ }): Promise<QoderAuth> {
1632
+ const realm = input.realm ?? "intl";
1633
+ if (isQoderPat(input.secret)) {
1634
+ return await exchangeQoderPat(input.secret, input.signal, input.fetcher, getQoderVersion(), realm);
1635
+ }
1636
+ // Discovery is handed only the secret and the persisted state, so the
1637
+ // account id is read from that state — the same place `qoderOAuthAuth` reads
1638
+ // it — rather than being a separate field callers can pass wrongly.
1639
+ const state = parseQoderOAuthState(input.authState);
1640
+ return qoderOAuthAuth(input.secret, input.authState, state?.userId);
1641
+ }