cartethyia 2.5.4 → 2.5.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (395) hide show
  1. package/.env.example +13 -4
  2. package/README.md +2 -2
  3. package/dist/dashboard/assets/{BannedPage-C2orBsI2.js → BannedPage-DE2607Tb.js} +1 -1
  4. package/dist/dashboard/assets/BannedPage-DE2607Tb.js.gz +0 -0
  5. package/dist/dashboard/assets/CliToolDetailPage-Duv6mfrl.js +1 -0
  6. package/dist/dashboard/assets/CliToolDetailPage-Duv6mfrl.js.gz +0 -0
  7. package/dist/dashboard/assets/{CliToolsPage-BV6pxVQo.js → CliToolsPage-C2PNYWzk.js} +1 -1
  8. package/dist/dashboard/assets/CliToolsPage-C2PNYWzk.js.gz +0 -0
  9. package/dist/dashboard/assets/CombosPage-TXP9OfAM.js +1 -0
  10. package/dist/dashboard/assets/CombosPage-TXP9OfAM.js.gz +0 -0
  11. package/dist/dashboard/assets/{ConsoleLogPage-CW-wPmJ6.js → ConsoleLogPage-B-9xnAQN.js} +1 -1
  12. package/dist/dashboard/assets/ConsoleLogPage-B-9xnAQN.js.gz +0 -0
  13. package/dist/dashboard/assets/{CustomizationPage-0p8uX7vB.js → CustomizationPage-xeax9nJt.js} +1 -1
  14. package/dist/dashboard/assets/CustomizationPage-xeax9nJt.js.gz +0 -0
  15. package/dist/dashboard/assets/{HealthEventsModal-BjqSMfko.js → HealthEventsModal-Df4jjaHJ.js} +1 -1
  16. package/dist/dashboard/assets/HealthEventsModal-Df4jjaHJ.js.gz +0 -0
  17. package/dist/dashboard/assets/{LoginPage-BvzCx8Kr.js → LoginPage-Dyojsm84.js} +1 -1
  18. package/dist/dashboard/assets/LoginPage-Dyojsm84.js.gz +0 -0
  19. package/dist/dashboard/assets/ModelPicker-C7pDb41R.js +1 -0
  20. package/dist/dashboard/assets/ModelPicker-C7pDb41R.js.gz +0 -0
  21. package/dist/dashboard/assets/OverviewPage-BJfg4ow6.js +1 -0
  22. package/dist/dashboard/assets/OverviewPage-BJfg4ow6.js.gz +0 -0
  23. package/dist/dashboard/assets/ProviderDetailPage-ChCX1gmT.js +3 -0
  24. package/dist/dashboard/assets/ProviderDetailPage-ChCX1gmT.js.gz +0 -0
  25. package/dist/dashboard/assets/{ProvidersPage-Js9jUPSb.js → ProvidersPage-DeWSOEUO.js} +1 -1
  26. package/dist/dashboard/assets/ProvidersPage-DeWSOEUO.js.gz +0 -0
  27. package/dist/dashboard/assets/ProxyPage-CkpWln2S.js +7 -0
  28. package/dist/dashboard/assets/ProxyPage-CkpWln2S.js.gz +0 -0
  29. package/dist/dashboard/assets/QuotaPage-B3VOgQmt.js +1 -0
  30. package/dist/dashboard/assets/QuotaPage-B3VOgQmt.js.gz +0 -0
  31. package/dist/dashboard/assets/SettingsPage-C_klIZom.js +1 -0
  32. package/dist/dashboard/assets/SettingsPage-C_klIZom.js.gz +0 -0
  33. package/dist/dashboard/assets/{SetupPage-DDbMQsoo.js → SetupPage-BYph-InK.js} +1 -1
  34. package/dist/dashboard/assets/SetupPage-BYph-InK.js.gz +0 -0
  35. package/dist/dashboard/assets/{SortableList-CB3Z7VTf.js → SortableList-CvyQCYWJ.js} +1 -1
  36. package/dist/dashboard/assets/SortableList-CvyQCYWJ.js.gz +0 -0
  37. package/dist/dashboard/assets/{StudioPage-D95UCPHI.js → StudioPage-Cm1JYilI.js} +3 -3
  38. package/dist/dashboard/assets/StudioPage-Cm1JYilI.js.gz +0 -0
  39. package/dist/dashboard/assets/UsagePage-DlFnc3_S.js +56 -0
  40. package/dist/dashboard/assets/UsagePage-DlFnc3_S.js.gz +0 -0
  41. package/dist/dashboard/assets/api-keys-DQYLQxpW.js +1 -0
  42. package/dist/dashboard/assets/api-keys-DQYLQxpW.js.gz +0 -0
  43. package/dist/dashboard/assets/app-CX_Z8cMs.css +2 -0
  44. package/dist/dashboard/assets/app-CX_Z8cMs.css.gz +0 -0
  45. package/dist/dashboard/assets/{assert-Chi7pCnt.js → assert-C9FD_JwJ.js} +1 -1
  46. package/dist/dashboard/assets/assert-C9FD_JwJ.js.gz +0 -0
  47. package/dist/dashboard/assets/{badge-D4pQapKj.js → badge-CR72dasT.js} +1 -1
  48. package/dist/dashboard/assets/badge-CR72dasT.js.gz +0 -0
  49. package/dist/dashboard/assets/{banner-DH6cg-_B.js → banner-CI9_eSfO.js} +1 -1
  50. package/dist/dashboard/assets/banner-CI9_eSfO.js.gz +0 -0
  51. package/dist/dashboard/assets/{card-BKqlUOe4.js → card-DS7CQEsO.js} +1 -1
  52. package/dist/dashboard/assets/card-DS7CQEsO.js.gz +0 -0
  53. package/dist/dashboard/assets/{cli-tools-fTgljTGA.js → cli-tools-CNqzrfdb.js} +1 -1
  54. package/dist/dashboard/assets/cli-tools-CNqzrfdb.js.gz +0 -0
  55. package/dist/dashboard/assets/{clipboard-button-D5Ys_sM0.js → clipboard-button-611pSsaX.js} +1 -1
  56. package/dist/dashboard/assets/clipboard-button-611pSsaX.js.gz +0 -0
  57. package/dist/dashboard/assets/{fingerprint-pattern-BWuHtbW4.js → concurrency-BDF0ok7y.js} +1 -1
  58. package/dist/dashboard/assets/concurrency-BDF0ok7y.js.gz +0 -0
  59. package/dist/dashboard/assets/dialog-k37exjNt.js +1 -0
  60. package/dist/dashboard/assets/dialog-k37exjNt.js.gz +0 -0
  61. package/dist/dashboard/assets/drawer-CX7N8ucR.js +1 -0
  62. package/dist/dashboard/assets/drawer-CX7N8ucR.js.gz +0 -0
  63. package/dist/dashboard/assets/{entry-CQBr6U9c.js → entry-B9Xvtnfj.js} +1 -1
  64. package/dist/dashboard/assets/entry-B9Xvtnfj.js.gz +0 -0
  65. package/dist/dashboard/assets/entry-Bog0p925.js +4 -0
  66. package/dist/dashboard/assets/entry-Bog0p925.js.gz +0 -0
  67. package/dist/dashboard/assets/entry-CTP8AyiS.js +1 -0
  68. package/dist/dashboard/assets/entry-CTP8AyiS.js.gz +0 -0
  69. package/dist/dashboard/assets/{github-badge-BlptL8Uf.js → github-badge-sltk1qUi.js} +1 -1
  70. package/dist/dashboard/assets/github-badge-sltk1qUi.js.gz +0 -0
  71. package/dist/dashboard/assets/{index-js6TJvdr.js → index-CZQIrdoX.js} +2 -2
  72. package/dist/dashboard/assets/index-CZQIrdoX.js.gz +0 -0
  73. package/dist/dashboard/assets/{inline-DUWDSmDV.js → inline-BhY2sSnO.js} +1 -1
  74. package/dist/dashboard/assets/inline-BhY2sSnO.js.gz +0 -0
  75. package/dist/dashboard/assets/{input-LVjgZq4f.js → input-V1umFiBk.js} +1 -1
  76. package/dist/dashboard/assets/input-V1umFiBk.js.gz +0 -0
  77. package/dist/dashboard/assets/layout-BBOYycyS.js +1 -0
  78. package/dist/dashboard/assets/layout-BBOYycyS.js.gz +0 -0
  79. package/dist/dashboard/assets/{list-row-DupvLfDx.js → list-row-1zzZYM0B.js} +1 -1
  80. package/dist/dashboard/assets/list-row-1zzZYM0B.js.gz +0 -0
  81. package/dist/dashboard/assets/{live-C4pEJUqI.js → live-BivGfuUs.js} +1 -1
  82. package/dist/dashboard/assets/live-BivGfuUs.js.gz +0 -0
  83. package/dist/dashboard/assets/{model-bans-BRiuufkB.js → model-bans-CmffjLuG.js} +1 -1
  84. package/dist/dashboard/assets/model-bans-CmffjLuG.js.gz +0 -0
  85. package/dist/dashboard/assets/{mutation-B4ROwk8L.js → mutation-46P0oWMe.js} +1 -1
  86. package/dist/dashboard/assets/mutation-46P0oWMe.js.gz +0 -0
  87. package/dist/dashboard/assets/numbers-DkfMR5Qs.js +2 -0
  88. package/dist/dashboard/assets/numbers-DkfMR5Qs.js.gz +0 -0
  89. package/dist/dashboard/assets/{query-keys-gmjzC3RS.js → query-keys-BFhGpwR1.js} +1 -1
  90. package/dist/dashboard/assets/query-keys-BFhGpwR1.js.gz +0 -0
  91. package/dist/dashboard/assets/{routing-C7tBT5o-.js → routing-CAb4F37R.js} +1 -1
  92. package/dist/dashboard/assets/routing-CAb4F37R.js.gz +0 -0
  93. package/dist/dashboard/assets/{select-WUCXorXw.js → select-BPpSsBgL.js} +1 -1
  94. package/dist/dashboard/assets/select-BPpSsBgL.js.gz +0 -0
  95. package/dist/dashboard/assets/{settings-C_zKl1GS.js → settings-DNX9sydi.js} +1 -1
  96. package/dist/dashboard/assets/settings-DNX9sydi.js.gz +0 -0
  97. package/dist/dashboard/assets/{stack-_3O0F8Xn.js → stack-BHTfXdz_.js} +1 -1
  98. package/dist/dashboard/assets/stack-BHTfXdz_.js.gz +0 -0
  99. package/dist/dashboard/assets/{state-BgTxHcJS.js → state-mPDRlQkl.js} +1 -1
  100. package/dist/dashboard/assets/state-mPDRlQkl.js.gz +0 -0
  101. package/dist/dashboard/assets/{switch-DEjTtOWf.js → switch-DOmJxg7H.js} +1 -1
  102. package/dist/dashboard/assets/switch-DOmJxg7H.js.gz +0 -0
  103. package/dist/dashboard/assets/tokens-COzario9.js +1 -0
  104. package/dist/dashboard/assets/tokens-COzario9.js.gz +0 -0
  105. package/dist/dashboard/assets/use-modal-focus-By7oJoGd.js +1 -0
  106. package/dist/dashboard/assets/use-modal-focus-By7oJoGd.js.gz +0 -0
  107. package/dist/dashboard/assets/{useMutation-BWSC0UAS.js → useMutation-Dem0lXQj.js} +1 -1
  108. package/dist/dashboard/assets/useMutation-Dem0lXQj.js.gz +0 -0
  109. package/dist/dashboard/index.html +1 -1
  110. package/dist/dashboard/index.html.gz +0 -0
  111. package/dist/dashboard/precompressed-assets.json +54 -54
  112. package/migrations/README.md +52 -52
  113. package/migrations/lite-sqlite3/0000_baseline.sql +2 -2
  114. package/package.json +95 -95
  115. package/scripts/cli/admin.ts +125 -0
  116. package/scripts/cli/{ui/background.ts → background.ts} +1 -1
  117. package/scripts/cli/{command/console-menu.ts → console-menu.ts} +275 -244
  118. package/scripts/cli/doctor.ts +37 -0
  119. package/scripts/cli/{support/env.ts → env.ts} +99 -99
  120. package/scripts/cli/{gateway/spawn.ts → gateway.ts} +112 -24
  121. package/scripts/cli/instance.ts +224 -0
  122. package/scripts/cli/main.ts +61 -18
  123. package/scripts/cli/{support/platform.ts → platform.ts} +64 -64
  124. package/scripts/cli/port.ts +100 -0
  125. package/scripts/cli/{support/services.ts → services.ts} +88 -88
  126. package/scripts/cli/{command/setup.ts → setup.ts} +35 -34
  127. package/scripts/cli/{command/start.ts → start.ts} +10 -5
  128. package/scripts/cli/{support/startup-signal.ts → startup-signal.ts} +23 -23
  129. package/scripts/cli/tray/index.ts +62 -12
  130. package/scripts/cli/{command/tray.ts → tray.ts} +208 -208
  131. package/scripts/cli/{command/update.ts → update.ts} +39 -39
  132. package/scripts/cli/version.ts +67 -0
  133. package/scripts/cli/{ui/wordmark.ts → wordmark.ts} +58 -58
  134. package/src/boot/app.ts +697 -693
  135. package/src/boot/dependencies.ts +647 -646
  136. package/src/boot/timeout.ts +2 -13
  137. package/src/config/cooldowns.ts +186 -138
  138. package/src/config/network.ts +8 -14
  139. package/src/config/providers.ts +7 -10
  140. package/src/config/runtime.ts +76 -76
  141. package/src/config/security.ts +15 -0
  142. package/src/config/spec.ts +20 -30
  143. package/src/config/telemetry.ts +0 -20
  144. package/src/config/timeouts.ts +0 -6
  145. package/src/console/admin/model-abuse/contracts.ts +28 -6
  146. package/src/console/admin/model-abuse/routes.ts +56 -3
  147. package/src/console/api-keys/contracts.ts +410 -414
  148. package/src/console/api-keys/routes.ts +881 -880
  149. package/src/console/auth/credentials.ts +3 -3
  150. package/src/console/auth/setup-service.ts +2 -2
  151. package/src/console/backup/export-endpoint.ts +6 -2
  152. package/src/console/backup/nine-router.ts +2 -5
  153. package/src/console/backup/service.ts +4 -0
  154. package/src/console/backup/store.ts +192 -29
  155. package/src/console/backup/validate.ts +3 -45
  156. package/src/console/cli-tools/fs-ops.ts +487 -487
  157. package/src/console/console-router-guards.ts +1 -1
  158. package/src/console/data-management/contracts.ts +13 -6
  159. package/src/console/data-management/operations.ts +23 -18
  160. package/src/console/data-management/routes.ts +0 -1
  161. package/src/console/domain-registration.ts +10 -5
  162. package/src/console/observability/live.ts +22 -0
  163. package/src/console/observability/store.ts +39 -16
  164. package/src/console/observability/usage-dimensions.ts +4 -4
  165. package/src/console/providers/catalog/catalog-projections.ts +6 -7
  166. package/src/console/providers/catalog/model-operations.ts +5 -2
  167. package/src/console/providers/catalog/provider-operations.ts +4 -4
  168. package/src/console/providers/catalog/public-model-store.ts +8 -11
  169. package/src/console/providers/catalog/store.ts +1553 -1553
  170. package/src/console/providers/oauth/routes.ts +8 -12
  171. package/src/console/quota/account-quota-tenant.ts +6 -6
  172. package/src/console/quota/quota-view.ts +1 -1
  173. package/src/console/remote-support/contracts.ts +61 -0
  174. package/src/console/remote-support/routes.ts +65 -0
  175. package/src/console/remote-support/service.ts +207 -0
  176. package/src/console/response-compression.ts +0 -3
  177. package/src/console/routing/pools/probe-result.ts +3 -2
  178. package/src/console/routing/pools/probe-verdict.ts +132 -132
  179. package/src/console/routing/pools/store.ts +8 -8
  180. package/src/console/settings/contracts.ts +58 -2
  181. package/src/console/settings/store.ts +30 -3
  182. package/src/console/shared/cursor.ts +8 -8
  183. package/src/console/shared/errors.ts +1 -3
  184. package/src/network/http2-bidi.ts +2 -4
  185. package/src/network/pool/agent.ts +478 -478
  186. package/src/network/pool/selector.ts +5 -4
  187. package/src/network/retry.ts +1 -2
  188. package/src/network/ssrf.ts +5 -4
  189. package/src/observability/event-compaction.ts +80 -78
  190. package/src/observability/event-day-index.ts +13 -15
  191. package/src/observability/event-log.ts +4 -4
  192. package/src/observability/logger.ts +6 -6
  193. package/src/observability/payload-capture.ts +151 -33
  194. package/src/observability/payload-store.ts +397 -440
  195. package/src/observability/redaction.ts +5 -6
  196. package/src/observability/request-notify.ts +43 -0
  197. package/src/observability/telemetry-status.ts +8 -9
  198. package/src/observability/token-speed.ts +5 -4
  199. package/src/persistence/db-mode.ts +7 -7
  200. package/src/persistence/readiness.ts +1 -1
  201. package/src/persistence/schema.ts +1406 -1402
  202. package/src/persistence/shared-cache.ts +1 -2
  203. package/src/persistence/stores/api-key-store.ts +18 -6
  204. package/src/persistence/stores/share-store.ts +4 -3
  205. package/src/persistence/stores/telemetry-store.ts +236 -237
  206. package/src/protocol/canonical-model.ts +11 -3
  207. package/src/protocol/codecs.ts +138 -4
  208. package/src/protocol/gateway-error.ts +6 -4
  209. package/src/protocol/request/chat.ts +21 -1
  210. package/src/protocol/request/codex.ts +10 -1
  211. package/src/protocol/request/messages.ts +4 -5
  212. package/src/protocol/request/responses.ts +9 -1
  213. package/src/protocol/response/chat.ts +34 -4
  214. package/src/protocol/response/codex.ts +7 -4
  215. package/src/protocol/response/dsml.ts +140 -28
  216. package/src/protocol/response/responses.ts +11 -5
  217. package/src/protocol/stream-error-frames.ts +4 -5
  218. package/src/providers/authentication/jwt-validator.ts +9 -10
  219. package/src/providers/authentication/oauth-refresh-service.ts +9 -11
  220. package/src/providers/configured-openai-providers.ts +134 -136
  221. package/src/providers/discovery/catalog/catalog-blob.ts +18 -18
  222. package/src/providers/discovery/catalog/catalog-format.ts +3 -6
  223. package/src/providers/discovery/models-dev-catalog.ts +354 -354
  224. package/src/providers/discovery/probe-phases.ts +4 -4
  225. package/src/providers/discovery/probe-wire.ts +6 -13
  226. package/src/providers/discovery/probing-service.ts +1117 -1045
  227. package/src/providers/integrations/alibabacp/alibabacp.ts +1 -1
  228. package/src/providers/integrations/antigravity/antigravity-quota.ts +5 -7
  229. package/src/providers/integrations/buddy/buddy-chat-shared.ts +23 -2
  230. package/src/providers/integrations/buddy/buddy-checkin.ts +1 -4
  231. package/src/providers/integrations/buddy/buddy-cli-headers.ts +23 -6
  232. package/src/providers/integrations/buddy/buddy-conversation-headers.ts +0 -1
  233. package/src/providers/integrations/buddy/buddy-identity.ts +52 -0
  234. package/src/providers/integrations/buddy/buddy-media.ts +2 -2
  235. package/src/providers/integrations/buddy/buddy-usage-store.ts +3 -4
  236. package/src/providers/integrations/buddy/codebuddy/quota.ts +2 -13
  237. package/src/providers/integrations/buddy/codebuddy/shared.ts +118 -115
  238. package/src/providers/integrations/buddy/workbuddy/quota.ts +0 -11
  239. package/src/providers/integrations/buddy/workbuddy/shared.ts +5 -2
  240. package/src/providers/integrations/claude/claude.ts +608 -608
  241. package/src/providers/integrations/cline/cline-quota.ts +132 -132
  242. package/src/providers/integrations/cline/cline.ts +537 -539
  243. package/src/providers/integrations/codex/codex-oauth.ts +1 -1
  244. package/src/providers/integrations/commandcode/commandcode.ts +674 -675
  245. package/src/providers/integrations/commandcode/discovery.ts +117 -116
  246. package/src/providers/integrations/cursor/cursor-auth.ts +418 -418
  247. package/src/providers/integrations/cursor/cursor-quota.ts +309 -310
  248. package/src/providers/integrations/cursor/cursor.ts +11 -163
  249. package/src/providers/integrations/factory/factory-models.ts +3 -5
  250. package/src/providers/integrations/grok/grok-install-id.ts +2 -3
  251. package/src/providers/integrations/grok/grok.ts +6 -7
  252. package/src/providers/integrations/hermes.ts +14 -18
  253. package/src/providers/integrations/kiro/kiro-discovery.ts +1 -1
  254. package/src/providers/integrations/kiro/kiro.ts +1 -1
  255. package/src/providers/integrations/mistral.ts +6 -6
  256. package/src/providers/integrations/opencode/fingerprint.ts +3 -3
  257. package/src/providers/integrations/opencode/provider.ts +8 -8
  258. package/src/providers/integrations/qoder/qoder-catalog.ts +450 -450
  259. package/src/providers/integrations/qoder/qoder-oauth.ts +388 -310
  260. package/src/providers/integrations/qoder/qoder.ts +884 -865
  261. package/src/providers/integrations/shared/connect.ts +6 -6
  262. package/src/providers/integrations/trae/trae-catalog.ts +1 -2
  263. package/src/providers/integrations/trae/trae.ts +2 -2
  264. package/src/providers/integrations/zcode/oauth.ts +6 -1
  265. package/src/providers/operations/account-health-service.ts +1528 -1523
  266. package/src/providers/operations/account-reset-service.ts +1 -1
  267. package/src/providers/operations/client-versions.ts +6 -6
  268. package/src/providers/operations/custom-cli-headers.ts +3 -4
  269. package/src/providers/operations/provider-catalog-seeder.ts +95 -95
  270. package/src/providers/operations/provider-catalog-service.ts +3 -4
  271. package/src/providers/provider-metadata.ts +4 -8
  272. package/src/providers/provider-registry.ts +6 -7
  273. package/src/providers/provider-usage.ts +1 -1
  274. package/src/security/abuse.ts +15 -10
  275. package/src/security/access-control.ts +144 -146
  276. package/src/security/admission/contracts.ts +11 -0
  277. package/src/security/admission/in-memory-store.ts +4 -3
  278. package/src/security/admission/lease-sweep.ts +6 -6
  279. package/src/security/admission/reasons.ts +5 -1
  280. package/src/security/admission/redis-store.ts +211 -210
  281. package/src/security/admission/service.ts +4 -0
  282. package/src/security/api-key-auth.ts +504 -504
  283. package/src/security/ip-boundary.ts +155 -15
  284. package/src/security/model-abuse.ts +366 -94
  285. package/src/security/outbound-headers.ts +3 -4
  286. package/src/shared/sleep.ts +4 -0
  287. package/src/transport/dispatch/attempt-finalize.ts +47 -5
  288. package/src/transport/dispatch/attempt-loop.ts +531 -530
  289. package/src/transport/dispatch/stream-bridge.ts +1 -3
  290. package/src/transport/dispatch/streaming/finalize.ts +1 -1
  291. package/src/transport/dispatch/streaming/stream.ts +5 -6
  292. package/src/transport/failure-policy.ts +162 -8
  293. package/src/transport/guards/auth-guard.ts +7 -4
  294. package/src/transport/guards/concurrency-guard.ts +0 -5
  295. package/src/transport/guards/error-lifecycle.ts +13 -6
  296. package/src/transport/guards/pipeline.ts +15 -3
  297. package/src/transport/guards/request-context.ts +8 -2
  298. package/src/transport/request/inflight.ts +5 -6
  299. package/src/transport/request/preparer.ts +2 -2
  300. package/src/transport/request/state.ts +12 -8
  301. package/src/transport/routing/admission.ts +4 -0
  302. package/src/transport/routing/route-catalog.ts +8 -1
  303. package/src/transport/routing/route-model.ts +46 -14
  304. package/src/transport/routing/router.ts +21 -14
  305. package/src/transport/surface/completion.ts +2 -3
  306. package/src/transport/surface/dialects.ts +5 -6
  307. package/src/transport/surface/responses/encode.ts +38 -0
  308. package/src/transport/surface/responses/parse.ts +4 -0
  309. package/src/transport/translation/capabilities.ts +2 -3
  310. package/src/transport/translation/quirks.ts +3 -3
  311. package/src/workers/buddy-growth-tasks.ts +5 -10
  312. package/src/workers/daily-checkin.ts +17 -23
  313. package/src/workers/quota-refresh-sweep.ts +26 -26
  314. package/dist/dashboard/assets/BannedPage-C2orBsI2.js.gz +0 -0
  315. package/dist/dashboard/assets/CliToolDetailPage-DUcZ6pMB.js +0 -1
  316. package/dist/dashboard/assets/CliToolDetailPage-DUcZ6pMB.js.gz +0 -0
  317. package/dist/dashboard/assets/CliToolsPage-BV6pxVQo.js.gz +0 -0
  318. package/dist/dashboard/assets/CombosPage-Bsan4679.js +0 -1
  319. package/dist/dashboard/assets/CombosPage-Bsan4679.js.gz +0 -0
  320. package/dist/dashboard/assets/ConsoleLogPage-CW-wPmJ6.js.gz +0 -0
  321. package/dist/dashboard/assets/CustomizationPage-0p8uX7vB.js.gz +0 -0
  322. package/dist/dashboard/assets/HealthEventsModal-BjqSMfko.js.gz +0 -0
  323. package/dist/dashboard/assets/LoginPage-BvzCx8Kr.js.gz +0 -0
  324. package/dist/dashboard/assets/ModelPicker-BY1aKEkP.js +0 -1
  325. package/dist/dashboard/assets/ModelPicker-BY1aKEkP.js.gz +0 -0
  326. package/dist/dashboard/assets/OverviewPage-DOQpy1y0.js +0 -1
  327. package/dist/dashboard/assets/OverviewPage-DOQpy1y0.js.gz +0 -0
  328. package/dist/dashboard/assets/ProviderDetailPage-ecxyUMzt.js +0 -3
  329. package/dist/dashboard/assets/ProviderDetailPage-ecxyUMzt.js.gz +0 -0
  330. package/dist/dashboard/assets/ProvidersPage-Js9jUPSb.js.gz +0 -0
  331. package/dist/dashboard/assets/ProxyPage-J3TBi3Kx.js +0 -7
  332. package/dist/dashboard/assets/ProxyPage-J3TBi3Kx.js.gz +0 -0
  333. package/dist/dashboard/assets/QuotaPage-C1gZS6xr.js +0 -1
  334. package/dist/dashboard/assets/QuotaPage-C1gZS6xr.js.gz +0 -0
  335. package/dist/dashboard/assets/SettingsPage-CS86ho5X.js +0 -1
  336. package/dist/dashboard/assets/SettingsPage-CS86ho5X.js.gz +0 -0
  337. package/dist/dashboard/assets/SetupPage-DDbMQsoo.js.gz +0 -0
  338. package/dist/dashboard/assets/SortableList-CB3Z7VTf.js.gz +0 -0
  339. package/dist/dashboard/assets/StudioPage-D95UCPHI.js.gz +0 -0
  340. package/dist/dashboard/assets/UsagePage-Cnae9SWA.js +0 -56
  341. package/dist/dashboard/assets/UsagePage-Cnae9SWA.js.gz +0 -0
  342. package/dist/dashboard/assets/api-keys-VY68Mhd1.js +0 -1
  343. package/dist/dashboard/assets/api-keys-VY68Mhd1.js.gz +0 -0
  344. package/dist/dashboard/assets/app-COlK95oi.css +0 -2
  345. package/dist/dashboard/assets/app-COlK95oi.css.gz +0 -0
  346. package/dist/dashboard/assets/assert-Chi7pCnt.js.gz +0 -0
  347. package/dist/dashboard/assets/badge-D4pQapKj.js.gz +0 -0
  348. package/dist/dashboard/assets/banner-DH6cg-_B.js.gz +0 -0
  349. package/dist/dashboard/assets/card-BKqlUOe4.js.gz +0 -0
  350. package/dist/dashboard/assets/cli-tools-fTgljTGA.js.gz +0 -0
  351. package/dist/dashboard/assets/clipboard-button-D5Ys_sM0.js.gz +0 -0
  352. package/dist/dashboard/assets/dialog-BYj-8WuK.js +0 -1
  353. package/dist/dashboard/assets/dialog-BYj-8WuK.js.gz +0 -0
  354. package/dist/dashboard/assets/drawer-CmXIZ6H1.js +0 -1
  355. package/dist/dashboard/assets/drawer-CmXIZ6H1.js.gz +0 -0
  356. package/dist/dashboard/assets/entry-CBg5zeu8.js +0 -1
  357. package/dist/dashboard/assets/entry-CBg5zeu8.js.gz +0 -0
  358. package/dist/dashboard/assets/entry-CQBr6U9c.js.gz +0 -0
  359. package/dist/dashboard/assets/entry-CgdSAzEV.js +0 -4
  360. package/dist/dashboard/assets/entry-CgdSAzEV.js.gz +0 -0
  361. package/dist/dashboard/assets/fingerprint-pattern-BWuHtbW4.js.gz +0 -0
  362. package/dist/dashboard/assets/github-badge-BlptL8Uf.js.gz +0 -0
  363. package/dist/dashboard/assets/index-js6TJvdr.js.gz +0 -0
  364. package/dist/dashboard/assets/inline-DUWDSmDV.js.gz +0 -0
  365. package/dist/dashboard/assets/input-LVjgZq4f.js.gz +0 -0
  366. package/dist/dashboard/assets/layout-CPfxpsfy.js +0 -1
  367. package/dist/dashboard/assets/layout-CPfxpsfy.js.gz +0 -0
  368. package/dist/dashboard/assets/list-row-DupvLfDx.js.gz +0 -0
  369. package/dist/dashboard/assets/live-C4pEJUqI.js.gz +0 -0
  370. package/dist/dashboard/assets/model-bans-BRiuufkB.js.gz +0 -0
  371. package/dist/dashboard/assets/mutation-B4ROwk8L.js.gz +0 -0
  372. package/dist/dashboard/assets/numbers-YNcHvRY7.js +0 -2
  373. package/dist/dashboard/assets/numbers-YNcHvRY7.js.gz +0 -0
  374. package/dist/dashboard/assets/query-keys-gmjzC3RS.js.gz +0 -0
  375. package/dist/dashboard/assets/routing-C7tBT5o-.js.gz +0 -0
  376. package/dist/dashboard/assets/select-WUCXorXw.js.gz +0 -0
  377. package/dist/dashboard/assets/settings-C_zKl1GS.js.gz +0 -0
  378. package/dist/dashboard/assets/stack-_3O0F8Xn.js.gz +0 -0
  379. package/dist/dashboard/assets/state-BgTxHcJS.js.gz +0 -0
  380. package/dist/dashboard/assets/switch-DEjTtOWf.js.gz +0 -0
  381. package/dist/dashboard/assets/tokens-DDxMbSRL.js +0 -1
  382. package/dist/dashboard/assets/tokens-DDxMbSRL.js.gz +0 -0
  383. package/dist/dashboard/assets/use-presence-BSZEBx7U.js +0 -1
  384. package/dist/dashboard/assets/use-presence-BSZEBx7U.js.gz +0 -0
  385. package/dist/dashboard/assets/useMutation-BWSC0UAS.js.gz +0 -0
  386. package/scripts/cli/command/admin.ts +0 -65
  387. package/scripts/cli/command/doctor.ts +0 -257
  388. package/scripts/cli/gateway/drain-token.ts +0 -78
  389. package/scripts/cli/gateway/instance-lock.ts +0 -105
  390. package/scripts/cli/gateway/stop.ts +0 -51
  391. package/scripts/cli/support/bun.ts +0 -27
  392. package/scripts/cli/support/version.ts +0 -37
  393. /package/dist/dashboard/assets/{app-DCQZRmHY.js → app-BIt446yE.js} +0 -0
  394. /package/dist/dashboard/assets/{app-DCQZRmHY.js.gz → app-BIt446yE.js.gz} +0 -0
  395. /package/scripts/cli/{ui/prompt.ts → prompt.ts} +0 -0
@@ -1,865 +1,884 @@
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 { classifyUpstreamError, extractUpstreamMessage, mapUpstreamHttpError, upstreamErrorIdentifier } from "../../../transport/failure-policy";
6
- import { GatewayError } from "../../../protocol/gateway-error";
7
- import type { CanonicalEvent, CanonicalRequest, CanonicalStopReason, ContentPart } from "../../../protocol/canonical-model";
8
- import { toolResultParts } from "../../../protocol/canonical-model";
9
- import { decodeSseEvents } from "../../../protocol/sse-decoder";
10
- import { usageFromProvider, readReasoningText } from "../../provider-usage";
11
- import { readCredentialSecret, type ProviderDispatchTarget, type ProviderAdapter, type ProviderDispatchContext, type ProviderId } from "../../provider-contracts";
12
- import type { ModelDefinition } from "../../provider-model-definition";
13
- import { parseQoderOAuthState } from "./qoder-oauth";
14
- import { getQoderVersion } from "../../operations/client-versions";
15
- import { abortGatewayError, createUpstreamDeadlineLifecycle } from "../../operations/upstream-deadline";
16
- import { qoderMachineOs } from "../../operations/cli-platform";
17
- import { qoderGatewayCandidates, QODER_CHAT_PATH, QODER_REALMS, type QoderRealm } from "./qoder-realm";
18
- import { QODER_CN_MODEL_CONFIGS, QODER_INTL_MODEL_CONFIGS, QODER_INTL_MODELS } from "./qoder-catalog";
19
- import { isQoderVirtualModel, qoderDispatchModelId } from "./qoder-virtual";
20
-
21
- interface QoderModeProfile {
22
- readonly chatUrl: string;
23
- readonly businessProduct: string;
24
- readonly businessType: string;
25
- readonly businessVersion: string;
26
- readonly cosyScene: string;
27
- readonly mirrorTopLevelSystem: boolean;
28
- readonly sendBusinessHeaders: boolean;
29
- readonly sendModelSourceHeaders: boolean;
30
- readonly emptyAliyunUserType: boolean;
31
- }
32
-
33
- export const MODERN_PROFILE: QoderModeProfile = {
34
- chatUrl: "https://api2.qoder.sh/algo/api/v2/service/pro/sse/agent_chat_generation?FetchKeys=llm_model_result&AgentId=agent_common&Encode=1",
35
- businessProduct: "cli",
36
- businessType: "agent",
37
- businessVersion: "1.0.22",
38
- cosyScene: "assistant",
39
- mirrorTopLevelSystem: true,
40
- sendBusinessHeaders: true,
41
- sendModelSourceHeaders: true,
42
- emptyAliyunUserType: true,
43
- };
44
-
45
- const QODER_PROVIDER_ID = "qoder" as const;
46
-
47
- export const QODER_MODEL_CONFIGS: Record<
48
- string,
49
- { id: string; display_name: string; max_input_tokens: number; max_output_tokens?: number; is_vl: boolean; is_reasoning: boolean; source?: string }
50
- > = {
51
- auto: { id: "auto", display_name: "Auto", max_input_tokens: 180000, is_vl: true, is_reasoning: false },
52
- ultimate: { id: "ultimate", display_name: "Ultimate", max_input_tokens: 180000, is_vl: true, is_reasoning: true },
53
- performance: { id: "performance", display_name: "Performance", max_input_tokens: 272000, is_vl: true, is_reasoning: false },
54
- efficient: { id: "efficient", display_name: "Efficient", max_input_tokens: 180000, is_vl: true, is_reasoning: false },
55
- lite: { id: "lite", display_name: "Lite", max_input_tokens: 180000, is_vl: false, is_reasoning: true },
56
- qmodel: { id: "qmodel", display_name: "Qwen 3.6 Plus", max_input_tokens: 180000, is_vl: true, is_reasoning: false },
57
- qmodel_latest: { id: "qmodel_latest", display_name: "Qwen 3.7 Max", max_input_tokens: 1000000, is_vl: true, is_reasoning: false },
58
- qmodel_preview: { id: "qmodel_preview", display_name: "Qwen 3.8 Max", max_input_tokens: 1000000, is_vl: true, is_reasoning: false },
59
- dmodel: { id: "dmodel", display_name: "DeepSeek V4 Pro", max_input_tokens: 180000, is_vl: true, is_reasoning: true },
60
- dfmodel: { id: "dfmodel", display_name: "DeepSeek V4 Flash", max_input_tokens: 180000, is_vl: true, is_reasoning: true },
61
- gm51model: { id: "gm51model", display_name: "GLM 5.1", max_input_tokens: 180000, is_vl: true, is_reasoning: true },
62
- kmodel: { id: "kmodel", display_name: "Kimi K2.6", max_input_tokens: 256000, is_vl: true, is_reasoning: false },
63
- mmodel: { id: "mmodel", display_name: "MiniMax M2.7", max_input_tokens: 180000, is_vl: true, is_reasoning: false },
64
- kmodel_latest: { id: "kmodel_latest", display_name: "Kimi K2.7 Latest", max_input_tokens: 256000, is_vl: true, is_reasoning: false },
65
- };
66
-
67
- const QODER_APPCODE = "cosy";
68
- const QODER_SIG_SECRET = "d2FyLCB3YXIgbmV2ZXIgY2hhbmdlcw==";
69
- /**
70
- * Where a PAT is exchanged for a short-lived job token, per realm.
71
- *
72
- * The job token is realm-bound: one minted on the global host answers "Login
73
- * expired" on the China gateway. Exchanging against the wrong host therefore
74
- * looks like a dead credential rather than a routing bug, which is why the
75
- * URL is derived from the realm table instead of being one constant.
76
- *
77
- * `intl` keeps its historical `center.qoder.sh` v3 endpoint. `cn` uses the
78
- * openapi host the donor's `PATH_JOB_EXCHANGE` describes; if that path is
79
- * ever wrong for CN, the failure surfaces as a 401/403 on exchange, not as a
80
- * silently misrouted request.
81
- */
82
- const QODER_JOB_TOKEN_URL = "https://center.qoder.sh/algo/api/v3/user/jobToken?Encode=1";
83
- function qoderJobTokenUrl(realm: QoderRealm): string {
84
- return realm === "cn"
85
- ? `${QODER_REALMS.cn.openapi}/api/v1/jobToken/exchange`
86
- : QODER_JOB_TOKEN_URL;
87
- }
88
- const QODER_STANDARD_ALPHABET = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
89
- const QODER_ALPHABET = "_doRTgHZBKcGVjlvpC,@aFSx#DPuNJme&i*MzLOEn)sUrthbf%Y^w.(kIQyXqWA!";
90
- const QODER_RSA_PUBLIC_KEY = `-----BEGIN PUBLIC KEY-----
91
- MIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQDA8iMH5c02LilrsERw9t6Pv5Nc
92
- 4k6Pz1EaDicBMpdpxKduSZu5OANqUq8er4GM95omAGIOPOh+Nx0spthYA2BqGz+l
93
- 6HRkPJ7S236FZz73In/KVuLnwI8JJ2CbuJap8kvheCCZpmAWpb/cPx/3Vr/J6I17
94
- XcW+ML9FoCI6AOvOzwIDAQAB
95
- -----END PUBLIC KEY-----`;
96
-
97
- export interface QoderAuth {
98
- userId: string;
99
- userName: string;
100
- userType: string;
101
- securityOauthToken: string;
102
- refreshToken: string;
103
- machineId: string;
104
- }
105
- interface QoderModelConfig {
106
- id: string;
107
- display_name: string;
108
- max_input_tokens: number;
109
- max_output_tokens?: number;
110
- is_vl: boolean;
111
- is_reasoning: boolean;
112
- source?: string;
113
- }
114
-
115
-
116
- function qoderMd5(value: string | Uint8Array): string {
117
- return createHash("md5").update(value).digest("hex");
118
- }
119
- function qoderPathSignature(url: string): string {
120
- const path = new URL(url).pathname;
121
- return path.startsWith("/algo") ? path.slice(5) : path;
122
- }
123
-
124
- const QODER_MACHINE_ID_TTL_MS = 60 * 60 * 1000;
125
- const QODER_MAX_MACHINE_IDS = 1_024;
126
- const qoderMachineIds = new Map<string, { id: string; expiresAt: number }>();
127
- /**
128
- * A PAT (`pt-...`) must be exchanged for a short-lived job token before it
129
- * can sign COSY requests. Device tokens (`dt-...`) from OAuth login and job
130
- * tokens (`jt-...`) are used directly.
131
- */
132
- export function isQoderPat(secret: string): boolean {
133
- return secret.startsWith("pt-");
134
- }
135
-
136
- /**
137
- * Stable per-account machine id for OAuth dispatches. Derived from the
138
- * account id (not minted per request) so pooled accounts keep one device
139
- * fingerprint across restarts instead of looking like a new device every
140
- * time, while staying distinct per account.
141
- */
142
- export function qoderMachineIdForAccount(accountId: string): string {
143
- const hex = createHash("sha256").update(`qoder-machine-v1:${accountId}`).digest("hex");
144
- return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20, 32)}`;
145
- }
146
-
147
- /**
148
- * Builds dispatch auth from an OAuth device token. The token is used
149
- * directly as the COSY identity's oauth token; the user id and machine id
150
- * come from the `auth_state` the login persisted. Fails closed with a
151
- * reconnect prompt when the identity is absent (e.g. an account created
152
- * before `auth_state` existed) instead of signing with a guessed id.
153
- */
154
- export function qoderOAuthAuth(
155
- secret: string,
156
- authState: unknown,
157
- accountId: string | undefined,
158
- ): QoderAuth {
159
- const state = parseQoderOAuthState(authState);
160
- if (state === undefined || !state.userId) {
161
- throw new GatewayError(
162
- "authentication_failed",
163
- 401,
164
- "Qoder OAuth account is missing its user identity; sign in again",
165
- );
166
- }
167
- return {
168
- userId: state.userId,
169
- userName: "",
170
- userType: "personal_standard",
171
- securityOauthToken: secret,
172
- refreshToken: "",
173
- machineId: state.machineId || (accountId ? qoderMachineIdForAccount(accountId) : randomUUID()),
174
- };
175
- }
176
-
177
- function qoderMachineIdFromPat(pat: string): string {
178
- const key = createHash("sha256").update(pat).digest("hex");
179
- const now = Date.now();
180
- const existing = qoderMachineIds.get(key);
181
- if (existing && existing.expiresAt > now) return existing.id;
182
- const id = randomUUID();
183
- qoderMachineIds.delete(key);
184
- qoderMachineIds.set(key, { id, expiresAt: now + QODER_MACHINE_ID_TTL_MS });
185
- for (const [candidate, value] of qoderMachineIds) {
186
- if (value.expiresAt <= now) qoderMachineIds.delete(candidate);
187
- }
188
- while (qoderMachineIds.size > QODER_MAX_MACHINE_IDS) {
189
- const oldest = qoderMachineIds.keys().next();
190
- if (oldest.done) break;
191
- qoderMachineIds.delete(oldest.value);
192
- }
193
- return id;
194
- }
195
-
196
- function qoderStaticHeaders(machineId: string, version = getQoderVersion()): Record<string, string> {
197
- const date = new Date().toUTCString();
198
- return {
199
- "cosy-machinetoken": machineId,
200
- "cosy-machinetype": "5",
201
- "cosy-machineid": machineId,
202
- "cosy-clienttype": "5",
203
- "cosy-version": version,
204
- "login-version": "v2",
205
- appcode: QODER_APPCODE,
206
- accept: "application/json",
207
- "accept-encoding": "identity",
208
- date,
209
- signature: qoderMd5(`${QODER_APPCODE}&${QODER_SIG_SECRET}&${date}`),
210
- "content-type": "application/json",
211
- "user-agent": "Go-http-client/2.0",
212
- };
213
- }
214
-
215
- function transformQoderBody(plaintext: string): Uint8Array {
216
- const base64 = Buffer.from(plaintext).toString("base64");
217
- const third = Math.floor(base64.length / 3);
218
- const reordered = base64.slice(base64.length - third) + base64.slice(third, base64.length - third) + base64.slice(0, third);
219
- let encoded = "";
220
- for (const char of reordered) {
221
- if (char === "=") encoded += "$";
222
- else encoded += QODER_ALPHABET[QODER_STANDARD_ALPHABET.indexOf(char)] ?? char;
223
- }
224
- return Buffer.from(encoded, "latin1");
225
- }
226
-
227
- function qoderBuildCosyHeaders(
228
- body: Uint8Array,
229
- url: string,
230
- auth: QoderAuth,
231
- version = getQoderVersion(),
232
- ): Record<string, string> {
233
- const key = randomUUID().replaceAll("-", "").slice(0, 16);
234
- const identity = JSON.stringify({
235
- name: auth.userName,
236
- aid: auth.userId,
237
- uid: auth.userId,
238
- yx_uid: "",
239
- organization_id: "",
240
- organization_name: "",
241
- user_type: auth.userType,
242
- security_oauth_token: auth.securityOauthToken,
243
- refresh_token: auth.refreshToken,
244
- });
245
- const cipher = createCipheriv("aes-128-cbc", Buffer.from(key), Buffer.from(key));
246
- const info = Buffer.concat([cipher.update(identity), cipher.final()]).toString("base64");
247
- const cosyKey = publicEncrypt({ key: QODER_RSA_PUBLIC_KEY, padding: constants.RSA_PKCS1_PADDING }, Buffer.from(key)).toString("base64");
248
- const payload = Buffer.from(
249
- JSON.stringify({ version: "v1", requestId: randomUUID(), info, cosyVersion: version, ideVersion: "" }),
250
- ).toString("base64");
251
- const date = String(Math.floor(Date.now() / 1000));
252
- const signature = qoderMd5(`${payload}\n${cosyKey}\n${date}\n${Buffer.from(body).toString("latin1")}\n${qoderPathSignature(url)}`);
253
- return {
254
- authorization: `Bearer COSY.${payload}.${signature}`,
255
- "cosy-data-policy": "agree",
256
- "cosy-machinetype": "5",
257
- "cosy-machineos": qoderMachineOs(),
258
- "cosy-clienttype": "5",
259
- "cosy-date": date,
260
- "cosy-user": auth.userId,
261
- "cosy-key": cosyKey,
262
- "cosy-bodyhash": qoderMd5(body),
263
- "cosy-bodylength": String(body.byteLength),
264
- "cosy-sigpath": qoderPathSignature(url),
265
- "cosy-organization-id": "",
266
- "cosy-organization-tags": "",
267
- "x-request-id": randomUUID(),
268
- ...(MODERN_PROFILE.sendBusinessHeaders
269
- ? {
270
- "cosy-business-product": MODERN_PROFILE.businessProduct,
271
- "cosy-business-type": MODERN_PROFILE.businessType,
272
- "cosy-business-version": version,
273
- "cosy-scene": MODERN_PROFILE.cosyScene,
274
- }
275
- : {}),
276
- "cosy-version": version,
277
- "login-version": "v2",
278
- appcode: QODER_APPCODE,
279
- accept: "application/json",
280
- "accept-encoding": "identity",
281
- date: new Date().toUTCString(),
282
- signature: qoderMd5(`${QODER_APPCODE}&${QODER_SIG_SECRET}&${new Date().toUTCString()}`),
283
- "content-type": "application/json",
284
- "user-agent": "Go-http-client/2.0",
285
- };
286
- }
287
-
288
- type QoderFetcher = (url: string, init: RequestInit) => Promise<Response>;
289
-
290
- async function exchangeQoderPat(
291
- pat: string,
292
- signal: AbortSignal,
293
- fetcher: QoderFetcher,
294
- version = getQoderVersion(),
295
- realm: QoderRealm = "intl",
296
- ): Promise<QoderAuth> {
297
- const machineId = qoderMachineIdFromPat(pat);
298
- const encodedBody = transformQoderBody(JSON.stringify({ payload: JSON.stringify({ personalToken: pat, securityOauthToken: "", refreshToken: "", needRefresh: false, authInfo: {} }), encodeVersion: "1" }));
299
- const response = await fetcher(qoderJobTokenUrl(realm), { method: "POST", headers: qoderStaticHeaders(machineId, version), body: encodedBody as unknown as BodyInit, signal });
300
- if (!response.ok) throw await mapUpstreamHttpError(response, "qoder");
301
- let responseBody: unknown;
302
- try {
303
- responseBody = await response.json();
304
- } catch {
305
- throw new GatewayError("platform_unavailable", 502, "Qoder PAT exchange returned invalid JSON", {}, "upstream");
306
- }
307
- if (typeof responseBody !== "object" || responseBody === null || Array.isArray(responseBody)) {
308
- throw new GatewayError("platform_unavailable", 502, "Qoder PAT exchange returned unexpected response", {}, "upstream");
309
- }
310
- const rec = responseBody as Record<string, unknown>;
311
- if (typeof rec["id"] !== "string" || typeof rec["securityOauthToken"] !== "string") {
312
- throw new GatewayError("platform_unavailable", 502, "Qoder PAT exchange returned incomplete credentials", {}, "upstream");
313
- }
314
- return {
315
- userId: rec["id"] as string,
316
- userName: typeof rec["name"] === "string" ? (rec["name"] as string) : "",
317
- userType: typeof rec["userType"] === "string" ? (rec["userType"] as string) : "personal_standard",
318
- securityOauthToken: rec["securityOauthToken"] as string,
319
- refreshToken: typeof rec["refreshToken"] === "string" ? (rec["refreshToken"] as string) : "",
320
- machineId,
321
- };
322
- }
323
-
324
- async function callQoder(
325
- url: string,
326
- body: Record<string, unknown>,
327
- modelId: string,
328
- auth: QoderAuth,
329
- signal: AbortSignal,
330
- fetcher: QoderFetcher,
331
- version = getQoderVersion(),
332
- ): Promise<Response> {
333
- const encoded = transformQoderBody(JSON.stringify(body));
334
- const modelConfig = (body["model_config"] as Record<string, unknown> | null) ?? null;
335
- return fetcher(url, {
336
- method: "POST",
337
- headers: {
338
- ...qoderBuildCosyHeaders(encoded, url, auth, version),
339
- ...(MODERN_PROFILE.sendModelSourceHeaders
340
- ? { "x-model-key": modelId, "x-model-source": typeof modelConfig?.["source"] === "string" ? (modelConfig["source"] as string) : "system" }
341
- : {}),
342
- Accept: "text/event-stream",
343
- "Cache-Control": "no-cache",
344
- "Accept-Encoding": "identity",
345
- },
346
- body: encoded as unknown as BodyInit,
347
- signal,
348
- });
349
- }
350
-
351
- /**
352
- * The human-readable text out of a Qoder error envelope.
353
- *
354
- * The envelope is Qoder's own wrapper around the stream, and it does not use
355
- * one field for the message: the text has been seen as a nested `error`
356
- * object, a bare string `body`, and a top-level `message`/`msg`. The canonical
357
- * extractor covers the structured shapes; a string body and the top-level
358
- * fields cover the rest. Whatever survives is bounded the same way an upstream
359
- * message is, so a verbose envelope cannot blow up the public error line.
360
- */
361
- function qoderEnvelopeMessage(rec: Record<string, unknown>): string {
362
- const structured = extractUpstreamMessage(rec["body"]);
363
- if (structured.length > 0) return structured;
364
- if (typeof rec["body"] === "string" && rec["body"].trim().length > 0 && rec["body"] !== "[DONE]")
365
- return rec["body"].replace(/[\r\n]+/g, " ").trim().slice(0, 500);
366
- return extractUpstreamMessage(rec);
367
- }
368
-
369
- function qoderEnvelopeToFrames(data: string): Array<{ event: string | null; data: string }> {
370
- if (data === "[DONE]") return [{ event: null, data: "[DONE]" }];
371
- let envelope: unknown;
372
- try {
373
- envelope = JSON.parse(data);
374
- } catch {
375
- }
376
- if (typeof envelope !== "object" || envelope === null || Array.isArray(envelope)) return [];
377
- const rec = envelope as Record<string, unknown>;
378
- if (rec["statusCodeValue"] !== undefined && rec["statusCodeValue"] !== 200) {
379
- const status = typeof rec["statusCodeValue"] === "number" ? (rec["statusCodeValue"] as number) : 502;
380
- // Qoder reports failures inside the stream envelope instead of as HTTP
381
- // responses, so classify its structured provider code and status through
382
- // the same gateway error policy as the other dispatch paths.
383
- const detail = qoderEnvelopeMessage(rec);
384
- const identifier = upstreamErrorIdentifier(rec["body"]) ?? upstreamErrorIdentifier(rec);
385
- const classification = classifyUpstreamError(status, identifier);
386
- throw new GatewayError(
387
- classification.code,
388
- classification.status,
389
- detail.length > 0 ? detail : `Qoder stream envelope returned HTTP ${status}`,
390
- {
391
- providerId: QODER_PROVIDER_ID,
392
- providerStatus: status,
393
- ...(identifier ? { providerCode: identifier } : {}),
394
- ...(classification.credentialEvidence ? { credentialEvidence: true } : {}),
395
- ...(classification.rateLimitScope
396
- ? { rateLimitScope: classification.rateLimitScope }
397
- : {}),
398
- },
399
- classification.origin,
400
- );
401
- }
402
- const body = rec["body"];
403
- if (body === "[DONE]") return [{ event: null, data: "[DONE]" }];
404
- if (typeof body !== "string") return [];
405
- return [{ event: null, data: body.replace(/\r?\n/g, "") }];
406
- }
407
-
408
- function qoderStableHash(prefix: string, ...parts: string[]): string {
409
- const h = createHash("sha256");
410
- h.update(prefix);
411
- for (const p of parts) {
412
- h.update("\0");
413
- h.update(p);
414
- }
415
- return h.digest("hex").slice(0, 16);
416
- }
417
-
418
- function flattenCanonicalContent(content: readonly ContentPart[]): string {
419
- return content
420
- .filter((b) => b.kind === "text" || b.kind === "toolResult")
421
- .map((b) => {
422
- if (b.kind === "text") return b.text;
423
- if (b.kind === "toolResult") return typeof b.content === "string" ? b.content : JSON.stringify(b.content);
424
- return "";
425
- })
426
- .join("\n");
427
- }
428
-
429
- function qoderStableRecordId(modelId: string, messages: CanonicalRequest["messages"], maxTokens: number): string {
430
- const h = createHash("sha256");
431
- h.update("qoder-record\0");
432
- h.update(modelId);
433
- for (const m of messages) {
434
- h.update("\0");
435
- h.update(m.role);
436
- const text = flattenCanonicalContent(m.content);
437
- if (text) {
438
- h.update("\0");
439
- h.update(text);
440
- }
441
- }
442
- h.update(`\0mt=${maxTokens}`);
443
- return h.digest("hex").slice(0, 16);
444
- }
445
-
446
- function qoderRequestedMaxTokens(request: CanonicalRequest, modelConfig: QoderModelConfig): number {
447
- /**
448
- * The ceiling is the roster's stated one. The 32k this used to fall back to
449
- * clamped every model alike, so a request asking for more was silently
450
- * lowered no matter which model it named.
451
- */
452
- const configured = typeof modelConfig.max_output_tokens === "number" ? modelConfig.max_output_tokens : 65_536;
453
- const controls = request.generation_controls as Record<string, unknown>;
454
- const requested =
455
- typeof controls["max_tokens"] === "number"
456
- ? (controls["max_tokens"] as number)
457
- : typeof controls["max_output_tokens"] === "number"
458
- ? (controls["max_output_tokens"] as number)
459
- : null;
460
- return typeof requested === "number" && requested > 0 ? Math.min(requested, configured) : configured;
461
- }
462
-
463
- export function buildQoderRequest(
464
- modelId: string,
465
- request: CanonicalRequest,
466
- modelConfig: QoderModelConfig,
467
- auth: QoderAuth,
468
- ): Record<string, unknown> {
469
- const allMessages = request.messages;
470
- const systemParts: string[] = [];
471
- if (request.system) {
472
- for (const p of request.system) if (p.kind === "text") systemParts.push(p.text);
473
- }
474
- if (request.instructions) {
475
- for (const p of request.instructions) if (p.kind === "text") systemParts.push(p.text);
476
- }
477
- for (const m of allMessages) {
478
- if (m.role === "system" || m.role === "developer") {
479
- const t = flattenCanonicalContent(m.content);
480
- if (t) systemParts.push(t);
481
- }
482
- }
483
- const systemPrompt = systemParts.join("\n\n").trim();
484
- const qoderMessages: Array<Record<string, unknown>> = [];
485
- if (MODERN_PROFILE.mirrorTopLevelSystem && systemPrompt.length > 0) {
486
- qoderMessages.push({
487
- role: "system",
488
- content: systemPrompt,
489
- contents: [{ type: "text", text: systemPrompt }],
490
- });
491
- }
492
- for (const m of allMessages) {
493
- if (m.role === "system" || m.role === "developer") continue;
494
- // Answers may live in a `user` turn (the Messages ledger re-homes them),
495
- // so the result scan is role-agnostic. Each result becomes its own `tool`
496
- // turn carrying its `tool_call_id`; any non-result content on the same turn
497
- // is emitted after, so nothing is duplicated or lost.
498
- const results = toolResultParts(m);
499
- if (results.length > 0) {
500
- for (const block of results) {
501
- const text = flattenCanonicalContent(
502
- typeof block.content === "string"
503
- ? [{ kind: "text", text: block.content }]
504
- : block.content,
505
- );
506
- qoderMessages.push({
507
- role: "tool",
508
- content: text,
509
- contents: [{ type: "text", text }],
510
- tool_call_id: block.call_id,
511
- });
512
- }
513
- const remaining = m.content.filter((part) => part.kind !== "toolResult");
514
- if (remaining.length === 0) continue;
515
- const flat = flattenCanonicalContent(remaining);
516
- qoderMessages.push({
517
- role: "user",
518
- content: flat,
519
- contents: [{ type: "text", text: flat }],
520
- });
521
- continue;
522
- }
523
- const flat = flattenCanonicalContent(m.content);
524
- qoderMessages.push({
525
- role: m.role === "assistant" ? "assistant" : "user",
526
- content: flat,
527
- contents: [{ type: "text", text: flat }],
528
- });
529
- }
530
- const latestUserMessage = [...qoderMessages].reverse().find((m) => m["role"] === "user");
531
- const latestUserText = typeof latestUserMessage?.["content"] === "string" ? (latestUserMessage["content"] as string) : "";
532
- const maxTokens = qoderRequestedMaxTokens(request, modelConfig);
533
- const reasoning = modelConfig.is_reasoning === true;
534
- const sessionId = qoderStableHash("qoder-session", auth.userId, modelId);
535
- const recordId = qoderStableRecordId(modelId, allMessages, maxTokens);
536
- return {
537
- request_id: randomUUID(),
538
- request_set_id: recordId,
539
- chat_record_id: recordId,
540
- session_id: sessionId,
541
- stream: true,
542
- ...(MODERN_PROFILE.emptyAliyunUserType ? { aliyun_user_type: "" } : {}),
543
- chat_task: "FREE_INPUT",
544
- is_reply: true,
545
- is_retry: false,
546
- source: 1,
547
- version: "3",
548
- session_type: "qodercli",
549
- agent_id: "agent_common",
550
- task_id: "common",
551
- code_language: "",
552
- chat_prompt: MODERN_PROFILE.mirrorTopLevelSystem ? systemPrompt : "",
553
- image_urls: null,
554
- chat_context: {
555
- chatPrompt: MODERN_PROFILE.mirrorTopLevelSystem ? systemPrompt : "",
556
- imageUrls: null,
557
- extra: { context: [], modelConfig: { key: modelId, is_reasoning: reasoning }, originalContent: latestUserText },
558
- features: [],
559
- text: latestUserText,
560
- },
561
- model_config: {
562
- key: modelId,
563
- display_name: modelConfig.display_name,
564
- is_vl: modelConfig.is_vl,
565
- is_reasoning: modelConfig.is_reasoning,
566
- max_input_tokens: modelConfig.max_input_tokens,
567
- // Mirrors the roster's stated ceiling. The 32k this used to fall back to
568
- // was sent for every model alike, so the request declared a cap the
569
- // catalog no longer publishes.
570
- max_output_tokens: modelConfig.max_output_tokens ?? 65_536,
571
- source: modelConfig.source ?? "system",
572
- },
573
- messages: qoderMessages,
574
- max_tokens: maxTokens,
575
- };
576
- }
577
-
578
- // Stream transformation — decode SSE, unwrap COSY envelope, map OpenAI chat chunks to CanonicalEvent
579
-
580
- function mapQoderFinishReason(raw: unknown): CanonicalStopReason | undefined {
581
- if (raw === "stop") return "stop";
582
- if (raw === "length") return "length";
583
- if (raw === "tool_calls") return "tool_use";
584
- if (raw === "content_filter") return "content_filter";
585
- return undefined;
586
- }
587
-
588
- async function* qoderBodyToCanonicalEvents(
589
- body: ReadableStream<Uint8Array>,
590
- signal: AbortSignal,
591
- model: string,
592
- ): AsyncIterable<CanonicalEvent> {
593
- let seq = 1;
594
- yield { type: "response_start", sequence_number: seq++, model } as CanonicalEvent;
595
- let finishReason: unknown = undefined;
596
- let rawUsage: Record<string, unknown> | undefined = undefined;
597
- let hasTerminal = false;
598
-
599
- for await (const sse of decodeSseEvents(body, { signal })) {
600
- const sseData = sse.data;
601
- let frames: Array<{ event: string | null; data: string }>;
602
- try {
603
- frames = qoderEnvelopeToFrames(sseData);
604
- } catch (e) {
605
- throw e;
606
- }
607
- for (const frame of frames) {
608
- const data = frame.data.trim();
609
- if (!data) continue;
610
- if (data === "[DONE]") {
611
- hasTerminal = true;
612
- continue;
613
- }
614
- let json: Record<string, unknown>;
615
- try {
616
- json = JSON.parse(data) as Record<string, unknown>;
617
- } catch {
618
- continue;
619
- }
620
- if (json["usage"] && typeof json["usage"] === "object") rawUsage = json["usage"] as Record<string, unknown>;
621
- const choices = (json["choices"] as Array<Record<string, unknown>>) ?? [];
622
- for (const choice of choices) {
623
- const delta = choice["delta"] as Record<string, unknown> | undefined;
624
- if (delta) {
625
- if (typeof delta["content"] === "string" && (delta["content"] as string).length > 0) {
626
- yield {
627
- type: "content_delta",
628
- sequence_number: seq++,
629
- content: { kind: "text", text: delta["content"] as string },
630
- } as CanonicalEvent;
631
- }
632
- const reasoningText = readReasoningText(delta);
633
- if (typeof reasoningText === "string" && reasoningText.length > 0) {
634
- yield {
635
- type: "content_delta",
636
- sequence_number: seq++,
637
- content: { kind: "reasoning", payload: null, summary: reasoningText },
638
- } as CanonicalEvent;
639
- }
640
- const toolCalls = delta["tool_calls"] as Array<Record<string, unknown>> | undefined;
641
- if (toolCalls) {
642
- for (const tc of toolCalls) {
643
- const fn = tc["function"] as Record<string, unknown> | undefined;
644
- yield {
645
- type: "tool_call_delta",
646
- sequence_number: seq++,
647
- call_id: (tc["id"] as string) ?? `call_${String(tc["index"] ?? 0)}`,
648
- name: fn?.["name"] as string | undefined,
649
- arguments_delta: (fn?.["arguments"] as string) ?? "",
650
- } as CanonicalEvent;
651
- }
652
- }
653
- }
654
- const fr = choice["finish_reason"];
655
- if (fr !== null && fr !== undefined) finishReason = fr;
656
- // Some providers send usage in choices[0].usage or top-level
657
- if (choice["usage"] && typeof choice["usage"] === "object") rawUsage = choice["usage"] as Record<string, unknown>;
658
- }
659
- // Some Qoder chunks carry finish_reason at top level
660
- if (json["finish_reason"] !== undefined) finishReason = json["finish_reason"];
661
- }
662
- }
663
- // If no terminal arrived, synthesize one (prevents truncation error)
664
- const stopReason = mapQoderFinishReason(finishReason);
665
- const _providerStop = typeof finishReason === "string" ? finishReason : undefined;
666
- void hasTerminal;
667
- yield {
668
- type: "terminal",
669
- sequence_number: seq++,
670
- state: "complete",
671
- ...(stopReason === undefined ? {} : { stop_reason: stopReason }),
672
- ...(_providerStop === undefined ? {} : { provider_stop_reason: _providerStop }),
673
- usage: usageFromProvider(rawUsage),
674
- } as CanonicalEvent;
675
- }
676
-
677
- // Model catalog — maps QODER_MODEL_CONFIGS (provider) to Cartethyia ModelDefinition
678
-
679
- /**
680
- * The intl catalog this provider publishes.
681
- *
682
- * Previously built from the legacy `QODER_MODEL_CONFIGS`, which froze most
683
- * rows at 180k while the upstream reports 1M for the same keys. That number is
684
- * not cosmetic: it is sent upstream as `model_config.max_input_tokens`, so a
685
- * stale low value told Qoder the conversation window was small and made it
686
- * behave that way. Both the published catalog and the dispatched request now
687
- * come from the realm's roster, which carries the upstream's own numbers.
688
- */
689
- export const QODER_MODELS: readonly ModelDefinition[] = QODER_INTL_MODELS;
690
-
691
- // ProviderAdapter — manual dispatch preserving COSY encryption + envelope logic
692
-
693
- interface QoderAdapterOptions {
694
- readonly fetch?: typeof fetch;
695
- /** Which deployment this adapter dispatches to. Defaults to intl. */
696
- readonly realm?: QoderRealm;
697
- /** Bundled provider id, so the adapter's own id matches the route's. */
698
- readonly providerId?: string;
699
- }
700
-
701
- class QoderAdapter implements ProviderAdapter {
702
- readonly provider_id: ProviderId;
703
- private readonly realm: QoderRealm;
704
- private readonly fetchFn: typeof fetch;
705
-
706
- constructor(options: QoderAdapterOptions = {}) {
707
- this.provider_id = (options.providerId ?? QODER_PROVIDER_ID) as ProviderId;
708
- this.realm = options.realm ?? "intl";
709
- this.fetchFn = options.fetch ?? globalThis.fetch;
710
- }
711
-
712
- /**
713
- * The static roster for this adapter's realm.
714
- *
715
- * A single merged table advertised CN-only keys to global accounts and vice
716
- * versa, so a request was routed to a deployment that refuses it.
717
- */
718
- #modelConfigs(): Record<string, QoderModelConfig> {
719
- return (this.realm === "cn" ? QODER_CN_MODEL_CONFIGS : QODER_INTL_MODEL_CONFIGS) as Record<
720
- string,
721
- QoderModelConfig
722
- >;
723
- }
724
-
725
- async *dispatch(
726
- request: CanonicalRequest,
727
- candidate: ProviderDispatchTarget,
728
- context: ProviderDispatchContext,
729
- ): AsyncIterable<CanonicalEvent> {
730
- if (candidate.wire_family !== "chat") {
731
- throw new GatewayError("capability_unsupported", 400, `qoder supports chat only, got ${candidate.wire_family}`);
732
- }
733
- const secret = readCredentialSecret(context.credential, "Qoder credential is required");
734
-
735
- /**
736
- * A caller-facing virtual name is translated to its roster key here.
737
- *
738
- * The translation has to happen before the config lookup and before the
739
- * request body: the body carries the roster key, and looking up the
740
- * virtual id in the config table would fall through to the synthetic
741
- * default and silently dispatch under-declared capabilities.
742
- */
743
- const requestedModelId = candidate.model_id || request.model;
744
- if (isQoderVirtualModel(requestedModelId) && !this.#modelConfigs()[qoderDispatchModelId(requestedModelId)]) {
745
- throw new GatewayError(
746
- "model_not_found",
747
- 404,
748
- `Qoder virtual model ${requestedModelId} resolves to a roster key this realm does not serve`,
749
- { virtual_model: requestedModelId, target: qoderDispatchModelId(requestedModelId) },
750
- "upstream",
751
- );
752
- }
753
- const modelId = qoderDispatchModelId(requestedModelId);
754
- const modelConfig = this.#modelConfigs()[modelId] ?? {
755
- id: modelId,
756
- display_name: modelId,
757
- max_input_tokens: 180_000,
758
- is_vl: true,
759
- is_reasoning: false,
760
- };
761
-
762
- const lifecycle = createUpstreamDeadlineLifecycle(context);
763
-
764
- // Outbound fetch respects SSRF binding when provided
765
- const outboundFetch: typeof fetch = (context.outbound_fetch as unknown as typeof fetch) ?? this.fetchFn;
766
-
767
- const fetcher: QoderFetcher = (url, init) =>
768
- outboundFetch(url, { ...init, signal: lifecycle.signal } as RequestInit) as Promise<Response>;
769
-
770
- try {
771
- const version = getQoderVersion();
772
- // Dual mode like Cline: a pasted PAT is exchanged for a job token per
773
- // request, while an OAuth device token is used directly with the
774
- // identity the login persisted.
775
- const auth = isQoderPat(secret)
776
- ? await exchangeQoderPat(secret, lifecycle.signal, fetcher, version, this.realm)
777
- : qoderOAuthAuth(secret, context.credential.auth_state, context.credential.account_id);
778
- const qoderBody = buildQoderRequest(modelId, request, modelConfig, auth);
779
- // A full URL from the route wins, but the fallback is this realm's
780
- // gateway — not the intl host hardcoded into `MODERN_PROFILE`. Dispatching
781
- // a CN credential to `*.qoder.sh` is refused outright.
782
- const url =
783
- candidate.endpoint_path && candidate.endpoint_path.startsWith("http")
784
- ? candidate.endpoint_path
785
- : `${qoderGatewayCandidates(this.realm)[0] ?? ""}${QODER_CHAT_PATH}`;
786
-
787
- const response = await callQoder(url, qoderBody, modelId, auth, lifecycle.signal, fetcher, version);
788
- if (!response.ok) throw await mapUpstreamHttpError(response, "qoder");
789
- if (!response.body) throw new GatewayError("platform_unavailable", 502, "Qoder returned empty body", {}, "upstream");
790
- // Headers arrived: the pre-stream deadline has served its purpose.
791
- // From here the gateway stall/first-chunk watchdog (propagated via
792
- // context.abort_signal) governs the body. Keeping this timer armed
793
- // would kill healthy long streams at the stale pre-stream deadline.
794
- lifecycle.release();
795
- yield* qoderBodyToCanonicalEvents(response.body as ReadableStream<Uint8Array>, lifecycle.signal, request.model);
796
- } catch (err: unknown) {
797
- const abortError = abortGatewayError(lifecycle, err, context.abort_signal);
798
- if (abortError) throw abortError;
799
- throw err;
800
- } finally {
801
- lifecycle.release();
802
- }
803
- }
804
- }
805
-
806
- export function createQoderAdapter(options: QoderAdapterOptions = {}): ProviderAdapter {
807
- return new QoderAdapter(options);
808
- }
809
-
810
- /**
811
- * Adapter for the China deployment.
812
- *
813
- * Same wire, different gateway host and roster — see `qoder-realm.ts`.
814
- */
815
- export function createQoderCnAdapter(options: QoderAdapterOptions = {}): ProviderAdapter {
816
- return new QoderAdapter({ ...options, realm: "cn", providerId: "qodercn" });
817
- }
818
-
819
- /**
820
- * A COSY signer for one already-resolved account identity.
821
- *
822
- * Discovery needs signed headers but lives in `qoder-catalog.ts`, which cannot
823
- * import this module (the adapter imports its rosters — that would be a cycle).
824
- * Exporting the signer keeps one signing implementation and lets discovery
825
- * stay pure and injectable.
826
- */
827
- export function qoderSignerFor(
828
- auth: QoderAuth,
829
- version = getQoderVersion(),
830
- ): (url: string, body: string) => Record<string, string> {
831
- return (url: string, body: string): Record<string, string> =>
832
- qoderBuildCosyHeaders(Buffer.from(body, "latin1"), url, auth, version);
833
- }
834
-
835
- /** Qoder's own base64 alphabet transform, for a request body. */
836
- export function qoderEncodeBody(plaintext: Uint8Array | string): string {
837
- const text = typeof plaintext === "string" ? plaintext : new TextDecoder().decode(plaintext);
838
- return Buffer.from(transformQoderBody(text)).toString("latin1");
839
- }
840
-
841
- /**
842
- * Resolves the dispatch identity for one Qoder credential.
843
- *
844
- * Shared by the adapter and by discovery: both need the same `QoderAuth` the
845
- * COSY signature is built from, and duplicating the PAT-vs-OAuth branch would
846
- * let the two drift.
847
- */
848
- export async function resolveQoderAuth(input: {
849
- readonly secret: string;
850
- readonly authState: unknown;
851
- readonly signal: AbortSignal;
852
- readonly fetcher: (url: string, init: RequestInit) => Promise<Response>;
853
- /** Which deployment mints the job token; a PAT is realm-bound. */
854
- readonly realm?: QoderRealm;
855
- }): Promise<QoderAuth> {
856
- const realm = input.realm ?? "intl";
857
- if (isQoderPat(input.secret)) {
858
- return await exchangeQoderPat(input.secret, input.signal, input.fetcher, getQoderVersion(), realm);
859
- }
860
- // Discovery is handed only the secret and the persisted state, so the
861
- // account id is read from that state — the same place `qoderOAuthAuth` reads
862
- // it — rather than being a separate field callers can pass wrongly.
863
- const state = parseQoderOAuthState(input.authState);
864
- return qoderOAuthAuth(input.secret, input.authState, state?.userId);
865
- }
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 { CanonicalEvent, CanonicalRequest, CanonicalStopReason, ContentPart } from "../../../protocol/canonical-model";
15
+ import { toolResultParts } from "../../../protocol/canonical-model";
16
+ import { decodeSseEvents } from "../../../protocol/sse-decoder";
17
+ import { usageFromProvider, readReasoningText } from "../../provider-usage";
18
+ import { readCredentialSecret, type ProviderDispatchTarget, type ProviderAdapter, type ProviderDispatchContext, type ProviderId } from "../../provider-contracts";
19
+ import type { ModelDefinition } from "../../provider-model-definition";
20
+ import { parseQoderOAuthState } from "./qoder-oauth";
21
+ import { getQoderVersion } from "../../operations/client-versions";
22
+ import { abortGatewayError, createUpstreamDeadlineLifecycle } from "../../operations/upstream-deadline";
23
+ import { qoderMachineOs } from "../../operations/cli-platform";
24
+ import { qoderGatewayCandidates, QODER_CHAT_PATH, QODER_REALMS, type QoderRealm } from "./qoder-realm";
25
+ import { QODER_CN_MODEL_CONFIGS, QODER_INTL_MODEL_CONFIGS, QODER_INTL_MODELS } from "./qoder-catalog";
26
+ import { isQoderVirtualModel, qoderDispatchModelId } from "./qoder-virtual";
27
+
28
+ interface QoderModeProfile {
29
+ readonly chatUrl: string;
30
+ readonly businessProduct: string;
31
+ readonly businessType: string;
32
+ readonly businessVersion: string;
33
+ readonly cosyScene: string;
34
+ readonly mirrorTopLevelSystem: boolean;
35
+ readonly sendBusinessHeaders: boolean;
36
+ readonly sendModelSourceHeaders: boolean;
37
+ readonly emptyAliyunUserType: boolean;
38
+ }
39
+
40
+ export const MODERN_PROFILE: QoderModeProfile = {
41
+ chatUrl: "https://api2.qoder.sh/algo/api/v2/service/pro/sse/agent_chat_generation?FetchKeys=llm_model_result&AgentId=agent_common&Encode=1",
42
+ businessProduct: "cli",
43
+ businessType: "agent",
44
+ businessVersion: "1.0.22",
45
+ cosyScene: "assistant",
46
+ mirrorTopLevelSystem: true,
47
+ sendBusinessHeaders: true,
48
+ sendModelSourceHeaders: true,
49
+ emptyAliyunUserType: true,
50
+ };
51
+
52
+ const QODER_PROVIDER_ID = "qoder" as const;
53
+
54
+ export const QODER_MODEL_CONFIGS: Record<
55
+ string,
56
+ { id: string; display_name: string; max_input_tokens: number; max_output_tokens?: number; is_vl: boolean; is_reasoning: boolean; source?: string }
57
+ > = {
58
+ auto: { id: "auto", display_name: "Auto", max_input_tokens: 180000, is_vl: true, is_reasoning: false },
59
+ ultimate: { id: "ultimate", display_name: "Ultimate", max_input_tokens: 180000, is_vl: true, is_reasoning: true },
60
+ performance: { id: "performance", display_name: "Performance", max_input_tokens: 272000, is_vl: true, is_reasoning: false },
61
+ efficient: { id: "efficient", display_name: "Efficient", max_input_tokens: 180000, is_vl: true, is_reasoning: false },
62
+ lite: { id: "lite", display_name: "Lite", max_input_tokens: 180000, is_vl: false, is_reasoning: true },
63
+ qmodel: { id: "qmodel", display_name: "Qwen 3.6 Plus", max_input_tokens: 180000, is_vl: true, is_reasoning: false },
64
+ qmodel_latest: { id: "qmodel_latest", display_name: "Qwen 3.7 Max", max_input_tokens: 1000000, is_vl: true, is_reasoning: false },
65
+ qmodel_preview: { id: "qmodel_preview", display_name: "Qwen 3.8 Max", max_input_tokens: 1000000, is_vl: true, is_reasoning: false },
66
+ dmodel: { id: "dmodel", display_name: "DeepSeek V4 Pro", max_input_tokens: 180000, is_vl: true, is_reasoning: true },
67
+ dfmodel: { id: "dfmodel", display_name: "DeepSeek V4 Flash", max_input_tokens: 180000, is_vl: true, is_reasoning: true },
68
+ gm51model: { id: "gm51model", display_name: "GLM 5.1", max_input_tokens: 180000, is_vl: true, is_reasoning: true },
69
+ kmodel: { id: "kmodel", display_name: "Kimi K2.6", max_input_tokens: 256000, is_vl: true, is_reasoning: false },
70
+ mmodel: { id: "mmodel", display_name: "MiniMax M2.7", max_input_tokens: 180000, is_vl: true, is_reasoning: false },
71
+ kmodel_latest: { id: "kmodel_latest", display_name: "Kimi K2.7 Latest", max_input_tokens: 256000, is_vl: true, is_reasoning: false },
72
+ };
73
+
74
+ const QODER_APPCODE = "cosy";
75
+ const QODER_SIG_SECRET = "d2FyLCB3YXIgbmV2ZXIgY2hhbmdlcw==";
76
+ /**
77
+ * Where a PAT is exchanged for a short-lived job token, per realm.
78
+ *
79
+ * The job token is realm-bound: one minted on the global host answers "Login
80
+ * expired" on the China gateway. Exchanging against the wrong host therefore
81
+ * looks like a dead credential rather than a routing bug, which is why the
82
+ * URL is derived from the realm table instead of being one constant.
83
+ *
84
+ * `intl` keeps its historical `center.qoder.sh` v3 endpoint. `cn` uses the
85
+ * openapi host the donor's `PATH_JOB_EXCHANGE` describes; if that path is
86
+ * ever wrong for CN, the failure surfaces as a 401/403 on exchange, not as a
87
+ * silently misrouted request.
88
+ */
89
+ const QODER_JOB_TOKEN_URL = "https://center.qoder.sh/algo/api/v3/user/jobToken?Encode=1";
90
+ function qoderJobTokenUrl(realm: QoderRealm): string {
91
+ return realm === "cn"
92
+ ? `${QODER_REALMS.cn.openapi}/api/v1/jobToken/exchange`
93
+ : QODER_JOB_TOKEN_URL;
94
+ }
95
+ const QODER_STANDARD_ALPHABET = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
96
+ const QODER_ALPHABET = "_doRTgHZBKcGVjlvpC,@aFSx#DPuNJme&i*MzLOEn)sUrthbf%Y^w.(kIQyXqWA!";
97
+ const QODER_RSA_PUBLIC_KEY = `-----BEGIN PUBLIC KEY-----
98
+ MIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQDA8iMH5c02LilrsERw9t6Pv5Nc
99
+ 4k6Pz1EaDicBMpdpxKduSZu5OANqUq8er4GM95omAGIOPOh+Nx0spthYA2BqGz+l
100
+ 6HRkPJ7S236FZz73In/KVuLnwI8JJ2CbuJap8kvheCCZpmAWpb/cPx/3Vr/J6I17
101
+ XcW+ML9FoCI6AOvOzwIDAQAB
102
+ -----END PUBLIC KEY-----`;
103
+
104
+ export interface QoderAuth {
105
+ userId: string;
106
+ userName: string;
107
+ userType: string;
108
+ securityOauthToken: string;
109
+ refreshToken: string;
110
+ machineId: string;
111
+ }
112
+ interface QoderModelConfig {
113
+ id: string;
114
+ display_name: string;
115
+ max_input_tokens: number;
116
+ max_output_tokens?: number;
117
+ is_vl: boolean;
118
+ is_reasoning: boolean;
119
+ source?: string;
120
+ }
121
+
122
+
123
+ function qoderMd5(value: string | Uint8Array): string {
124
+ return createHash("md5").update(value).digest("hex");
125
+ }
126
+ function qoderPathSignature(url: string): string {
127
+ const path = new URL(url).pathname;
128
+ return path.startsWith("/algo") ? path.slice(5) : path;
129
+ }
130
+
131
+ const QODER_MACHINE_ID_TTL_MS = 60 * 60 * 1000;
132
+ const QODER_MAX_MACHINE_IDS = 1_024;
133
+ const qoderMachineIds = new Map<string, { id: string; expiresAt: number }>();
134
+ /**
135
+ * A PAT (`pt-...`) must be exchanged for a short-lived job token before it
136
+ * can sign COSY requests. Device tokens (`dt-...`) from OAuth login and job
137
+ * tokens (`jt-...`) are used directly.
138
+ */
139
+ export function isQoderPat(secret: string): boolean {
140
+ return secret.startsWith("pt-");
141
+ }
142
+
143
+ /**
144
+ * Stable per-account machine id for OAuth dispatches. Derived from the
145
+ * account id (not minted per request) so pooled accounts keep one device
146
+ * fingerprint across restarts instead of looking like a new device every
147
+ * time, while staying distinct per account.
148
+ */
149
+ export function qoderMachineIdForAccount(accountId: string): string {
150
+ const hex = createHash("sha256").update(`qoder-machine-v1:${accountId}`).digest("hex");
151
+ return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20, 32)}`;
152
+ }
153
+
154
+ /**
155
+ * Builds dispatch auth from an OAuth device token. The token is used
156
+ * directly as the COSY identity's oauth token; the user id and machine id
157
+ * come from the `auth_state` the login persisted. Fails closed with a
158
+ * reconnect prompt when the identity is absent (e.g. an account created
159
+ * before `auth_state` existed) instead of signing with a guessed id.
160
+ */
161
+ export function qoderOAuthAuth(
162
+ secret: string,
163
+ authState: unknown,
164
+ accountId: string | undefined,
165
+ ): QoderAuth {
166
+ const state = parseQoderOAuthState(authState);
167
+ if (state === undefined || !state.userId) {
168
+ throw new GatewayError(
169
+ "authentication_failed",
170
+ 401,
171
+ "Qoder OAuth account is missing its user identity; sign in again",
172
+ );
173
+ }
174
+ return {
175
+ userId: state.userId,
176
+ userName: "",
177
+ userType: "personal_standard",
178
+ securityOauthToken: secret,
179
+ refreshToken: "",
180
+ machineId: state.machineId || (accountId ? qoderMachineIdForAccount(accountId) : randomUUID()),
181
+ };
182
+ }
183
+
184
+ function qoderMachineIdFromPat(pat: string): string {
185
+ const key = createHash("sha256").update(pat).digest("hex");
186
+ const now = Date.now();
187
+ const existing = qoderMachineIds.get(key);
188
+ if (existing && existing.expiresAt > now) return existing.id;
189
+ const id = randomUUID();
190
+ qoderMachineIds.delete(key);
191
+ qoderMachineIds.set(key, { id, expiresAt: now + QODER_MACHINE_ID_TTL_MS });
192
+ for (const [candidate, value] of qoderMachineIds) {
193
+ if (value.expiresAt <= now) qoderMachineIds.delete(candidate);
194
+ }
195
+ while (qoderMachineIds.size > QODER_MAX_MACHINE_IDS) {
196
+ const oldest = qoderMachineIds.keys().next();
197
+ if (oldest.done) break;
198
+ qoderMachineIds.delete(oldest.value);
199
+ }
200
+ return id;
201
+ }
202
+
203
+ function qoderStaticHeaders(machineId: string, version = getQoderVersion()): Record<string, string> {
204
+ const date = new Date().toUTCString();
205
+ return {
206
+ "cosy-machinetoken": machineId,
207
+ "cosy-machinetype": "5",
208
+ "cosy-machineid": machineId,
209
+ "cosy-clienttype": "5",
210
+ "cosy-version": version,
211
+ "login-version": "v2",
212
+ appcode: QODER_APPCODE,
213
+ accept: "application/json",
214
+ "accept-encoding": "identity",
215
+ date,
216
+ signature: qoderMd5(`${QODER_APPCODE}&${QODER_SIG_SECRET}&${date}`),
217
+ "content-type": "application/json",
218
+ "user-agent": "Go-http-client/2.0",
219
+ };
220
+ }
221
+
222
+ function transformQoderBody(plaintext: string): Uint8Array {
223
+ const base64 = Buffer.from(plaintext).toString("base64");
224
+ const third = Math.floor(base64.length / 3);
225
+ const reordered = base64.slice(base64.length - third) + base64.slice(third, base64.length - third) + base64.slice(0, third);
226
+ let encoded = "";
227
+ for (const char of reordered) {
228
+ if (char === "=") encoded += "$";
229
+ else encoded += QODER_ALPHABET[QODER_STANDARD_ALPHABET.indexOf(char)] ?? char;
230
+ }
231
+ return Buffer.from(encoded, "latin1");
232
+ }
233
+
234
+ function qoderBuildCosyHeaders(
235
+ body: Uint8Array,
236
+ url: string,
237
+ auth: QoderAuth,
238
+ version = getQoderVersion(),
239
+ ): Record<string, string> {
240
+ const key = randomUUID().replaceAll("-", "").slice(0, 16);
241
+ const identity = JSON.stringify({
242
+ name: auth.userName,
243
+ aid: auth.userId,
244
+ uid: auth.userId,
245
+ yx_uid: "",
246
+ organization_id: "",
247
+ organization_name: "",
248
+ user_type: auth.userType,
249
+ security_oauth_token: auth.securityOauthToken,
250
+ refresh_token: auth.refreshToken,
251
+ });
252
+ const cipher = createCipheriv("aes-128-cbc", Buffer.from(key), Buffer.from(key));
253
+ const info = Buffer.concat([cipher.update(identity), cipher.final()]).toString("base64");
254
+ const cosyKey = publicEncrypt({ key: QODER_RSA_PUBLIC_KEY, padding: constants.RSA_PKCS1_PADDING }, Buffer.from(key)).toString("base64");
255
+ const payload = Buffer.from(
256
+ JSON.stringify({ version: "v1", requestId: randomUUID(), info, cosyVersion: version, ideVersion: "" }),
257
+ ).toString("base64");
258
+ const date = String(Math.floor(Date.now() / 1000));
259
+ const signature = qoderMd5(`${payload}\n${cosyKey}\n${date}\n${Buffer.from(body).toString("latin1")}\n${qoderPathSignature(url)}`);
260
+ return {
261
+ authorization: `Bearer COSY.${payload}.${signature}`,
262
+ "cosy-data-policy": "agree",
263
+ "cosy-machinetype": "5",
264
+ "cosy-machineos": qoderMachineOs(),
265
+ "cosy-clienttype": "5",
266
+ "cosy-date": date,
267
+ "cosy-user": auth.userId,
268
+ "cosy-key": cosyKey,
269
+ "cosy-bodyhash": qoderMd5(body),
270
+ "cosy-bodylength": String(body.byteLength),
271
+ "cosy-sigpath": qoderPathSignature(url),
272
+ "cosy-organization-id": "",
273
+ "cosy-organization-tags": "",
274
+ "x-request-id": randomUUID(),
275
+ ...(MODERN_PROFILE.sendBusinessHeaders
276
+ ? {
277
+ "cosy-business-product": MODERN_PROFILE.businessProduct,
278
+ "cosy-business-type": MODERN_PROFILE.businessType,
279
+ "cosy-business-version": version,
280
+ "cosy-scene": MODERN_PROFILE.cosyScene,
281
+ }
282
+ : {}),
283
+ "cosy-version": version,
284
+ "login-version": "v2",
285
+ appcode: QODER_APPCODE,
286
+ accept: "application/json",
287
+ "accept-encoding": "identity",
288
+ date: new Date().toUTCString(),
289
+ signature: qoderMd5(`${QODER_APPCODE}&${QODER_SIG_SECRET}&${new Date().toUTCString()}`),
290
+ "content-type": "application/json",
291
+ "user-agent": "Go-http-client/2.0",
292
+ };
293
+ }
294
+
295
+ type QoderFetcher = (url: string, init: RequestInit) => Promise<Response>;
296
+
297
+ async function exchangeQoderPat(
298
+ pat: string,
299
+ signal: AbortSignal,
300
+ fetcher: QoderFetcher,
301
+ version = getQoderVersion(),
302
+ realm: QoderRealm = "intl",
303
+ ): Promise<QoderAuth> {
304
+ const machineId = qoderMachineIdFromPat(pat);
305
+ const encodedBody = transformQoderBody(JSON.stringify({ payload: JSON.stringify({ personalToken: pat, securityOauthToken: "", refreshToken: "", needRefresh: false, authInfo: {} }), encodeVersion: "1" }));
306
+ const response = await fetcher(qoderJobTokenUrl(realm), { method: "POST", headers: qoderStaticHeaders(machineId, version), body: encodedBody as unknown as BodyInit, signal });
307
+ if (!response.ok) throw await mapUpstreamHttpError(response, "qoder");
308
+ let responseBody: unknown;
309
+ try {
310
+ responseBody = await response.json();
311
+ } catch {
312
+ throw new GatewayError("platform_unavailable", 502, "Qoder PAT exchange returned invalid JSON", {}, "upstream");
313
+ }
314
+ if (typeof responseBody !== "object" || responseBody === null || Array.isArray(responseBody)) {
315
+ throw new GatewayError("platform_unavailable", 502, "Qoder PAT exchange returned unexpected response", {}, "upstream");
316
+ }
317
+ const rec = responseBody as Record<string, unknown>;
318
+ if (typeof rec["id"] !== "string" || typeof rec["securityOauthToken"] !== "string") {
319
+ throw new GatewayError("platform_unavailable", 502, "Qoder PAT exchange returned incomplete credentials", {}, "upstream");
320
+ }
321
+ return {
322
+ userId: rec["id"] as string,
323
+ userName: typeof rec["name"] === "string" ? (rec["name"] as string) : "",
324
+ userType: typeof rec["userType"] === "string" ? (rec["userType"] as string) : "personal_standard",
325
+ securityOauthToken: rec["securityOauthToken"] as string,
326
+ refreshToken: typeof rec["refreshToken"] === "string" ? (rec["refreshToken"] as string) : "",
327
+ machineId,
328
+ };
329
+ }
330
+
331
+ async function callQoder(
332
+ url: string,
333
+ body: Record<string, unknown>,
334
+ modelId: string,
335
+ auth: QoderAuth,
336
+ signal: AbortSignal,
337
+ fetcher: QoderFetcher,
338
+ version = getQoderVersion(),
339
+ ): Promise<Response> {
340
+ const encoded = transformQoderBody(JSON.stringify(body));
341
+ const modelConfig = (body["model_config"] as Record<string, unknown> | null) ?? null;
342
+ return fetcher(url, {
343
+ method: "POST",
344
+ headers: {
345
+ ...qoderBuildCosyHeaders(encoded, url, auth, version),
346
+ ...(MODERN_PROFILE.sendModelSourceHeaders
347
+ ? { "x-model-key": modelId, "x-model-source": typeof modelConfig?.["source"] === "string" ? (modelConfig["source"] as string) : "system" }
348
+ : {}),
349
+ Accept: "text/event-stream",
350
+ "Cache-Control": "no-cache",
351
+ "Accept-Encoding": "identity",
352
+ },
353
+ body: encoded as unknown as BodyInit,
354
+ signal,
355
+ });
356
+ }
357
+
358
+ /**
359
+ * The human-readable text out of a Qoder error envelope.
360
+ *
361
+ * The envelope is Qoder's own wrapper around the stream, and it does not use
362
+ * one field for the message: the text has been seen as a nested `error`
363
+ * object, a bare string `body`, and a top-level `message`/`msg`. The canonical
364
+ * extractor covers the structured shapes; a string body and the top-level
365
+ * fields cover the rest. Whatever survives is bounded the same way an upstream
366
+ * message is, so a verbose envelope cannot blow up the public error line.
367
+ */
368
+ function qoderEnvelopeMessage(rec: Record<string, unknown>): string {
369
+ const structured = extractUpstreamMessage(rec["body"]);
370
+ if (structured.length > 0) return structured;
371
+ if (typeof rec["body"] === "string" && rec["body"].trim().length > 0 && rec["body"] !== "[DONE]")
372
+ return rec["body"].replace(/[\r\n]+/g, " ").trim().slice(0, 500);
373
+ return extractUpstreamMessage(rec);
374
+ }
375
+
376
+ function qoderEnvelopeToFrames(data: string): Array<{ event: string | null; data: string }> {
377
+ if (data === "[DONE]") return [{ event: null, data: "[DONE]" }];
378
+ let envelope: unknown;
379
+ try {
380
+ envelope = JSON.parse(data);
381
+ } catch {
382
+ }
383
+ if (typeof envelope !== "object" || envelope === null || Array.isArray(envelope)) return [];
384
+ const rec = envelope as Record<string, unknown>;
385
+ if (rec["statusCodeValue"] !== undefined && rec["statusCodeValue"] !== 200) {
386
+ const status = typeof rec["statusCodeValue"] === "number" ? (rec["statusCodeValue"] as number) : 502;
387
+ // Qoder reports failures inside the stream envelope instead of as HTTP
388
+ // responses, so classify its structured provider code and status through
389
+ // the same gateway error policy as the other dispatch paths.
390
+ const detail = qoderEnvelopeMessage(rec);
391
+ const identifier = upstreamErrorIdentifier(rec["body"]) ?? upstreamErrorIdentifier(rec);
392
+ // Qoder signals a queued request the same way inside the stream as it does
393
+ // on HTTP: `{"code":"10605", "isQueued":true, "retryAfterSeconds":N}` on a
394
+ // non-200 envelope. The status table would read the 403 as a refusal, and
395
+ // the caller would end its turn on a request that was only waiting.
396
+ const queuedWaitMs = isUpstreamQueued(rec["body"]) ?? false;
397
+ const waitMs = queuedWaitMs
398
+ ? (queueRetryAfterMs(rec["body"]) ?? undefined)
399
+ : undefined;
400
+ const classification = queuedWaitMs
401
+ ? { code: "upstream_queued" as const, status: 503, origin: "upstream" as const }
402
+ : classifyUpstreamError(status, identifier);
403
+ throw new GatewayError(
404
+ classification.code,
405
+ classification.status,
406
+ detail.length > 0 ? detail : `Qoder stream envelope returned HTTP ${status}`,
407
+ {
408
+ providerId: QODER_PROVIDER_ID,
409
+ providerStatus: status,
410
+ ...(identifier ? { providerCode: identifier } : {}),
411
+ ...(classification.credentialEvidence ? { credentialEvidence: true } : {}),
412
+ ...(classification.rateLimitScope
413
+ ? { rateLimitScope: classification.rateLimitScope }
414
+ : {}),
415
+ ...(waitMs === undefined ? {} : { retryAfterMs: waitMs, cooldownMs: waitMs }),
416
+ ...(queuedWaitMs ? { reason: "upstream-queued" } : {}),
417
+ },
418
+ classification.origin,
419
+ );
420
+ }
421
+ const body = rec["body"];
422
+ if (body === "[DONE]") return [{ event: null, data: "[DONE]" }];
423
+ if (typeof body !== "string") return [];
424
+ return [{ event: null, data: body.replace(/\r?\n/g, "") }];
425
+ }
426
+
427
+ function qoderStableHash(prefix: string, ...parts: string[]): string {
428
+ const h = createHash("sha256");
429
+ h.update(prefix);
430
+ for (const p of parts) {
431
+ h.update("\0");
432
+ h.update(p);
433
+ }
434
+ return h.digest("hex").slice(0, 16);
435
+ }
436
+
437
+ function flattenCanonicalContent(content: readonly ContentPart[]): string {
438
+ return content
439
+ .filter((b) => b.kind === "text" || b.kind === "toolResult")
440
+ .map((b) => {
441
+ if (b.kind === "text") return b.text;
442
+ if (b.kind === "toolResult") return typeof b.content === "string" ? b.content : JSON.stringify(b.content);
443
+ return "";
444
+ })
445
+ .join("\n");
446
+ }
447
+
448
+ function qoderStableRecordId(modelId: string, messages: CanonicalRequest["messages"], maxTokens: number): string {
449
+ const h = createHash("sha256");
450
+ h.update("qoder-record\0");
451
+ h.update(modelId);
452
+ for (const m of messages) {
453
+ h.update("\0");
454
+ h.update(m.role);
455
+ const text = flattenCanonicalContent(m.content);
456
+ if (text) {
457
+ h.update("\0");
458
+ h.update(text);
459
+ }
460
+ }
461
+ h.update(`\0mt=${maxTokens}`);
462
+ return h.digest("hex").slice(0, 16);
463
+ }
464
+
465
+ function qoderRequestedMaxTokens(request: CanonicalRequest, modelConfig: QoderModelConfig): number {
466
+ /**
467
+ * The ceiling is the roster's stated one. A fixed 32k fallback would clamp
468
+ * every model alike, so a request asking for more would be silently lowered
469
+ * no matter which model it named.
470
+ */
471
+ const configured = typeof modelConfig.max_output_tokens === "number" ? modelConfig.max_output_tokens : 65_536;
472
+ const controls = request.generation_controls as Record<string, unknown>;
473
+ const requested =
474
+ typeof controls["max_tokens"] === "number"
475
+ ? (controls["max_tokens"] as number)
476
+ : typeof controls["max_output_tokens"] === "number"
477
+ ? (controls["max_output_tokens"] as number)
478
+ : null;
479
+ return typeof requested === "number" && requested > 0 ? Math.min(requested, configured) : configured;
480
+ }
481
+
482
+ export function buildQoderRequest(
483
+ modelId: string,
484
+ request: CanonicalRequest,
485
+ modelConfig: QoderModelConfig,
486
+ auth: QoderAuth,
487
+ ): Record<string, unknown> {
488
+ const allMessages = request.messages;
489
+ const systemParts: string[] = [];
490
+ if (request.system) {
491
+ for (const p of request.system) if (p.kind === "text") systemParts.push(p.text);
492
+ }
493
+ if (request.instructions) {
494
+ for (const p of request.instructions) if (p.kind === "text") systemParts.push(p.text);
495
+ }
496
+ for (const m of allMessages) {
497
+ if (m.role === "system" || m.role === "developer") {
498
+ const t = flattenCanonicalContent(m.content);
499
+ if (t) systemParts.push(t);
500
+ }
501
+ }
502
+ const systemPrompt = systemParts.join("\n\n").trim();
503
+ const qoderMessages: Array<Record<string, unknown>> = [];
504
+ if (MODERN_PROFILE.mirrorTopLevelSystem && systemPrompt.length > 0) {
505
+ qoderMessages.push({
506
+ role: "system",
507
+ content: systemPrompt,
508
+ contents: [{ type: "text", text: systemPrompt }],
509
+ });
510
+ }
511
+ for (const m of allMessages) {
512
+ if (m.role === "system" || m.role === "developer") continue;
513
+ // Answers may live in a `user` turn (the Messages ledger re-homes them),
514
+ // so the result scan is role-agnostic. Each result becomes its own `tool`
515
+ // turn carrying its `tool_call_id`; any non-result content on the same turn
516
+ // is emitted after, so nothing is duplicated or lost.
517
+ const results = toolResultParts(m);
518
+ if (results.length > 0) {
519
+ for (const block of results) {
520
+ const text = flattenCanonicalContent(
521
+ typeof block.content === "string"
522
+ ? [{ kind: "text", text: block.content }]
523
+ : block.content,
524
+ );
525
+ qoderMessages.push({
526
+ role: "tool",
527
+ content: text,
528
+ contents: [{ type: "text", text }],
529
+ tool_call_id: block.call_id,
530
+ });
531
+ }
532
+ const remaining = m.content.filter((part) => part.kind !== "toolResult");
533
+ if (remaining.length === 0) continue;
534
+ const flat = flattenCanonicalContent(remaining);
535
+ qoderMessages.push({
536
+ role: "user",
537
+ content: flat,
538
+ contents: [{ type: "text", text: flat }],
539
+ });
540
+ continue;
541
+ }
542
+ const flat = flattenCanonicalContent(m.content);
543
+ qoderMessages.push({
544
+ role: m.role === "assistant" ? "assistant" : "user",
545
+ content: flat,
546
+ contents: [{ type: "text", text: flat }],
547
+ });
548
+ }
549
+ const latestUserMessage = [...qoderMessages].reverse().find((m) => m["role"] === "user");
550
+ const latestUserText = typeof latestUserMessage?.["content"] === "string" ? (latestUserMessage["content"] as string) : "";
551
+ const maxTokens = qoderRequestedMaxTokens(request, modelConfig);
552
+ const reasoning = modelConfig.is_reasoning === true;
553
+ const sessionId = qoderStableHash("qoder-session", auth.userId, modelId);
554
+ const recordId = qoderStableRecordId(modelId, allMessages, maxTokens);
555
+ return {
556
+ request_id: randomUUID(),
557
+ request_set_id: recordId,
558
+ chat_record_id: recordId,
559
+ session_id: sessionId,
560
+ stream: true,
561
+ ...(MODERN_PROFILE.emptyAliyunUserType ? { aliyun_user_type: "" } : {}),
562
+ chat_task: "FREE_INPUT",
563
+ is_reply: true,
564
+ is_retry: false,
565
+ source: 1,
566
+ version: "3",
567
+ session_type: "qodercli",
568
+ agent_id: "agent_common",
569
+ task_id: "common",
570
+ code_language: "",
571
+ chat_prompt: MODERN_PROFILE.mirrorTopLevelSystem ? systemPrompt : "",
572
+ image_urls: null,
573
+ chat_context: {
574
+ chatPrompt: MODERN_PROFILE.mirrorTopLevelSystem ? systemPrompt : "",
575
+ imageUrls: null,
576
+ extra: { context: [], modelConfig: { key: modelId, is_reasoning: reasoning }, originalContent: latestUserText },
577
+ features: [],
578
+ text: latestUserText,
579
+ },
580
+ model_config: {
581
+ key: modelId,
582
+ display_name: modelConfig.display_name,
583
+ is_vl: modelConfig.is_vl,
584
+ is_reasoning: modelConfig.is_reasoning,
585
+ max_input_tokens: modelConfig.max_input_tokens,
586
+ // Mirrors the roster's stated ceiling. A fixed 32k fallback would be sent
587
+ // for every model alike, so the request would declare a cap the catalog
588
+ // does not publish.
589
+ max_output_tokens: modelConfig.max_output_tokens ?? 65_536,
590
+ source: modelConfig.source ?? "system",
591
+ },
592
+ messages: qoderMessages,
593
+ max_tokens: maxTokens,
594
+ };
595
+ }
596
+
597
+ // Stream transformation — decode SSE, unwrap COSY envelope, map OpenAI chat chunks to CanonicalEvent
598
+
599
+ function mapQoderFinishReason(raw: unknown): CanonicalStopReason | undefined {
600
+ if (raw === "stop") return "stop";
601
+ if (raw === "length") return "length";
602
+ if (raw === "tool_calls") return "tool_use";
603
+ if (raw === "content_filter") return "content_filter";
604
+ return undefined;
605
+ }
606
+
607
+ async function* qoderBodyToCanonicalEvents(
608
+ body: ReadableStream<Uint8Array>,
609
+ signal: AbortSignal,
610
+ model: string,
611
+ ): AsyncIterable<CanonicalEvent> {
612
+ let seq = 1;
613
+ yield { type: "response_start", sequence_number: seq++, model } as CanonicalEvent;
614
+ let finishReason: unknown = undefined;
615
+ let rawUsage: Record<string, unknown> | undefined = undefined;
616
+ let hasTerminal = false;
617
+
618
+ for await (const sse of decodeSseEvents(body, { signal })) {
619
+ const sseData = sse.data;
620
+ let frames: Array<{ event: string | null; data: string }>;
621
+ try {
622
+ frames = qoderEnvelopeToFrames(sseData);
623
+ } catch (e) {
624
+ throw e;
625
+ }
626
+ for (const frame of frames) {
627
+ const data = frame.data.trim();
628
+ if (!data) continue;
629
+ if (data === "[DONE]") {
630
+ hasTerminal = true;
631
+ continue;
632
+ }
633
+ let json: Record<string, unknown>;
634
+ try {
635
+ json = JSON.parse(data) as Record<string, unknown>;
636
+ } catch {
637
+ continue;
638
+ }
639
+ if (json["usage"] && typeof json["usage"] === "object") rawUsage = json["usage"] as Record<string, unknown>;
640
+ const choices = (json["choices"] as Array<Record<string, unknown>>) ?? [];
641
+ for (const choice of choices) {
642
+ const delta = choice["delta"] as Record<string, unknown> | undefined;
643
+ if (delta) {
644
+ if (typeof delta["content"] === "string" && (delta["content"] as string).length > 0) {
645
+ yield {
646
+ type: "content_delta",
647
+ sequence_number: seq++,
648
+ content: { kind: "text", text: delta["content"] as string },
649
+ } as CanonicalEvent;
650
+ }
651
+ const reasoningText = readReasoningText(delta);
652
+ if (typeof reasoningText === "string" && reasoningText.length > 0) {
653
+ yield {
654
+ type: "content_delta",
655
+ sequence_number: seq++,
656
+ content: { kind: "reasoning", payload: null, summary: reasoningText },
657
+ } as CanonicalEvent;
658
+ }
659
+ const toolCalls = delta["tool_calls"] as Array<Record<string, unknown>> | undefined;
660
+ if (toolCalls) {
661
+ for (const tc of toolCalls) {
662
+ const fn = tc["function"] as Record<string, unknown> | undefined;
663
+ yield {
664
+ type: "tool_call_delta",
665
+ sequence_number: seq++,
666
+ call_id: (tc["id"] as string) ?? `call_${String(tc["index"] ?? 0)}`,
667
+ name: fn?.["name"] as string | undefined,
668
+ arguments_delta: (fn?.["arguments"] as string) ?? "",
669
+ } as CanonicalEvent;
670
+ }
671
+ }
672
+ }
673
+ const fr = choice["finish_reason"];
674
+ if (fr !== null && fr !== undefined) finishReason = fr;
675
+ // Some providers send usage in choices[0].usage or top-level
676
+ if (choice["usage"] && typeof choice["usage"] === "object") rawUsage = choice["usage"] as Record<string, unknown>;
677
+ }
678
+ // Some Qoder chunks carry finish_reason at top level
679
+ if (json["finish_reason"] !== undefined) finishReason = json["finish_reason"];
680
+ }
681
+ }
682
+ // If no terminal arrived, synthesize one (prevents truncation error)
683
+ const stopReason = mapQoderFinishReason(finishReason);
684
+ const _providerStop = typeof finishReason === "string" ? finishReason : undefined;
685
+ void hasTerminal;
686
+ yield {
687
+ type: "terminal",
688
+ sequence_number: seq++,
689
+ state: "complete",
690
+ ...(stopReason === undefined ? {} : { stop_reason: stopReason }),
691
+ ...(_providerStop === undefined ? {} : { provider_stop_reason: _providerStop }),
692
+ usage: usageFromProvider(rawUsage),
693
+ } as CanonicalEvent;
694
+ }
695
+
696
+ // Model catalog — maps QODER_MODEL_CONFIGS (provider) to Cartethyia ModelDefinition
697
+
698
+ /**
699
+ * The intl catalog this provider publishes.
700
+ *
701
+ * `QODER_MODEL_CONFIGS` is not the source here: it freezes most rows at 180k
702
+ * while the upstream reports 1M for the same keys. That number is not
703
+ * cosmetic: it is sent upstream as `model_config.max_input_tokens`, so a stale
704
+ * low value tells Qoder the conversation window is small and makes it behave
705
+ * that way. Both the published catalog and the dispatched request come from the
706
+ * realm's roster, which carries the upstream's own numbers.
707
+ */
708
+ export const QODER_MODELS: readonly ModelDefinition[] = QODER_INTL_MODELS;
709
+
710
+ // ProviderAdapter — manual dispatch preserving COSY encryption + envelope logic
711
+
712
+ interface QoderAdapterOptions {
713
+ readonly fetch?: typeof fetch;
714
+ /** Which deployment this adapter dispatches to. Defaults to intl. */
715
+ readonly realm?: QoderRealm;
716
+ /** Bundled provider id, so the adapter's own id matches the route's. */
717
+ readonly providerId?: string;
718
+ }
719
+
720
+ class QoderAdapter implements ProviderAdapter {
721
+ readonly provider_id: ProviderId;
722
+ private readonly realm: QoderRealm;
723
+ private readonly fetchFn: typeof fetch;
724
+
725
+ constructor(options: QoderAdapterOptions = {}) {
726
+ this.provider_id = (options.providerId ?? QODER_PROVIDER_ID) as ProviderId;
727
+ this.realm = options.realm ?? "intl";
728
+ this.fetchFn = options.fetch ?? globalThis.fetch;
729
+ }
730
+
731
+ /**
732
+ * The static roster for this adapter's realm.
733
+ *
734
+ * A single merged table advertised CN-only keys to global accounts and vice
735
+ * versa, so a request was routed to a deployment that refuses it.
736
+ */
737
+ #modelConfigs(): Record<string, QoderModelConfig> {
738
+ return (this.realm === "cn" ? QODER_CN_MODEL_CONFIGS : QODER_INTL_MODEL_CONFIGS) as Record<
739
+ string,
740
+ QoderModelConfig
741
+ >;
742
+ }
743
+
744
+ async *dispatch(
745
+ request: CanonicalRequest,
746
+ candidate: ProviderDispatchTarget,
747
+ context: ProviderDispatchContext,
748
+ ): AsyncIterable<CanonicalEvent> {
749
+ if (candidate.wire_family !== "chat") {
750
+ throw new GatewayError("capability_unsupported", 400, `qoder supports chat only, got ${candidate.wire_family}`);
751
+ }
752
+ const secret = readCredentialSecret(context.credential, "Qoder credential is required");
753
+
754
+ /**
755
+ * A caller-facing virtual name is translated to its roster key here.
756
+ *
757
+ * The translation has to happen before the config lookup and before the
758
+ * request body: the body carries the roster key, and looking up the
759
+ * virtual id in the config table would fall through to the synthetic
760
+ * default and silently dispatch under-declared capabilities.
761
+ */
762
+ const requestedModelId = candidate.model_id || request.model;
763
+ if (isQoderVirtualModel(requestedModelId) && !this.#modelConfigs()[qoderDispatchModelId(requestedModelId)]) {
764
+ throw new GatewayError(
765
+ "model_not_found",
766
+ 404,
767
+ `Qoder virtual model ${requestedModelId} resolves to a roster key this realm does not serve`,
768
+ { virtual_model: requestedModelId, target: qoderDispatchModelId(requestedModelId) },
769
+ "upstream",
770
+ );
771
+ }
772
+ const modelId = qoderDispatchModelId(requestedModelId);
773
+ const modelConfig = this.#modelConfigs()[modelId] ?? {
774
+ id: modelId,
775
+ display_name: modelId,
776
+ max_input_tokens: 180_000,
777
+ is_vl: true,
778
+ is_reasoning: false,
779
+ };
780
+
781
+ const lifecycle = createUpstreamDeadlineLifecycle(context);
782
+
783
+ // Outbound fetch respects SSRF binding when provided
784
+ const outboundFetch: typeof fetch = (context.outbound_fetch as unknown as typeof fetch) ?? this.fetchFn;
785
+
786
+ const fetcher: QoderFetcher = (url, init) =>
787
+ outboundFetch(url, { ...init, signal: lifecycle.signal } as RequestInit) as Promise<Response>;
788
+
789
+ try {
790
+ const version = getQoderVersion();
791
+ // Dual mode like Cline: a pasted PAT is exchanged for a job token per
792
+ // request, while an OAuth device token is used directly with the
793
+ // identity the login persisted.
794
+ const auth = isQoderPat(secret)
795
+ ? await exchangeQoderPat(secret, lifecycle.signal, fetcher, version, this.realm)
796
+ : qoderOAuthAuth(secret, context.credential.auth_state, context.credential.account_id);
797
+ const qoderBody = buildQoderRequest(modelId, request, modelConfig, auth);
798
+ // A full URL from the route wins, but the fallback is this realm's
799
+ // gateway — not the intl host hardcoded into `MODERN_PROFILE`. Dispatching
800
+ // a CN credential to `*.qoder.sh` is refused outright.
801
+ const url =
802
+ candidate.endpoint_path && candidate.endpoint_path.startsWith("http")
803
+ ? candidate.endpoint_path
804
+ : `${qoderGatewayCandidates(this.realm)[0] ?? ""}${QODER_CHAT_PATH}`;
805
+
806
+ const response = await callQoder(url, qoderBody, modelId, auth, lifecycle.signal, fetcher, version);
807
+ if (!response.ok) throw await mapUpstreamHttpError(response, "qoder");
808
+ if (!response.body) throw new GatewayError("platform_unavailable", 502, "Qoder returned empty body", {}, "upstream");
809
+ // Headers arrived: the pre-stream deadline has served its purpose.
810
+ // From here the gateway stall/first-chunk watchdog (propagated via
811
+ // context.abort_signal) governs the body. Keeping this timer armed
812
+ // would kill healthy long streams at the stale pre-stream deadline.
813
+ lifecycle.release();
814
+ yield* qoderBodyToCanonicalEvents(response.body as ReadableStream<Uint8Array>, lifecycle.signal, request.model);
815
+ } catch (err: unknown) {
816
+ const abortError = abortGatewayError(lifecycle, err, context.abort_signal);
817
+ if (abortError) throw abortError;
818
+ throw err;
819
+ } finally {
820
+ lifecycle.release();
821
+ }
822
+ }
823
+ }
824
+
825
+ export function createQoderAdapter(options: QoderAdapterOptions = {}): ProviderAdapter {
826
+ return new QoderAdapter(options);
827
+ }
828
+
829
+ /**
830
+ * Adapter for the China deployment.
831
+ *
832
+ * Same wire, different gateway host and roster — see `qoder-realm.ts`.
833
+ */
834
+ export function createQoderCnAdapter(options: QoderAdapterOptions = {}): ProviderAdapter {
835
+ return new QoderAdapter({ ...options, realm: "cn", providerId: "qodercn" });
836
+ }
837
+
838
+ /**
839
+ * A COSY signer for one already-resolved account identity.
840
+ *
841
+ * Discovery needs signed headers but lives in `qoder-catalog.ts`, which cannot
842
+ * import this module (the adapter imports its rosters — that would be a cycle).
843
+ * Exporting the signer keeps one signing implementation and lets discovery
844
+ * stay pure and injectable.
845
+ */
846
+ export function qoderSignerFor(
847
+ auth: QoderAuth,
848
+ version = getQoderVersion(),
849
+ ): (url: string, body: string) => Record<string, string> {
850
+ return (url: string, body: string): Record<string, string> =>
851
+ qoderBuildCosyHeaders(Buffer.from(body, "latin1"), url, auth, version);
852
+ }
853
+
854
+ /** Qoder's own base64 alphabet transform, for a request body. */
855
+ export function qoderEncodeBody(plaintext: Uint8Array | string): string {
856
+ const text = typeof plaintext === "string" ? plaintext : new TextDecoder().decode(plaintext);
857
+ return Buffer.from(transformQoderBody(text)).toString("latin1");
858
+ }
859
+
860
+ /**
861
+ * Resolves the dispatch identity for one Qoder credential.
862
+ *
863
+ * Shared by the adapter and by discovery: both need the same `QoderAuth` the
864
+ * COSY signature is built from, and duplicating the PAT-vs-OAuth branch would
865
+ * let the two drift.
866
+ */
867
+ export async function resolveQoderAuth(input: {
868
+ readonly secret: string;
869
+ readonly authState: unknown;
870
+ readonly signal: AbortSignal;
871
+ readonly fetcher: (url: string, init: RequestInit) => Promise<Response>;
872
+ /** Which deployment mints the job token; a PAT is realm-bound. */
873
+ readonly realm?: QoderRealm;
874
+ }): Promise<QoderAuth> {
875
+ const realm = input.realm ?? "intl";
876
+ if (isQoderPat(input.secret)) {
877
+ return await exchangeQoderPat(input.secret, input.signal, input.fetcher, getQoderVersion(), realm);
878
+ }
879
+ // Discovery is handed only the secret and the persisted state, so the
880
+ // account id is read from that state — the same place `qoderOAuthAuth` reads
881
+ // it — rather than being a separate field callers can pass wrongly.
882
+ const state = parseQoderOAuthState(input.authState);
883
+ return qoderOAuthAuth(input.secret, input.authState, state?.userId);
884
+ }