cartethyia 2.4.5 → 2.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (415) hide show
  1. package/.env.example +4 -1
  2. package/README.md +63 -13
  3. package/dist/dashboard/assets/AccountCooldown-ChpdzxRH.js.gz +0 -0
  4. package/dist/dashboard/assets/BannedPage-9igfctfZ.js +1 -0
  5. package/dist/dashboard/assets/BannedPage-9igfctfZ.js.gz +0 -0
  6. package/dist/dashboard/assets/{CliToolDetailPage-BzGevuql.js → CliToolDetailPage-ZRr2K2sF.js} +1 -1
  7. package/dist/dashboard/assets/CliToolDetailPage-ZRr2K2sF.js.gz +0 -0
  8. package/dist/dashboard/assets/{CliToolsPage-BaRM_7We.js → CliToolsPage-D3xqZbN-.js} +1 -1
  9. package/dist/dashboard/assets/CliToolsPage-D3xqZbN-.js.gz +0 -0
  10. package/dist/dashboard/assets/CombosPage-CVnQPBBz.js +1 -0
  11. package/dist/dashboard/assets/CombosPage-CVnQPBBz.js.gz +0 -0
  12. package/dist/dashboard/assets/ConsoleLogPage-u6W1UXSc.js +1 -0
  13. package/dist/dashboard/assets/ConsoleLogPage-u6W1UXSc.js.gz +0 -0
  14. package/dist/dashboard/assets/{CustomizationPage-DqUGcoH0.js → CustomizationPage-CTTpCb25.js} +1 -1
  15. package/dist/dashboard/assets/CustomizationPage-CTTpCb25.js.gz +0 -0
  16. package/dist/dashboard/assets/HealthEventsModal-C5XpWlvy.js +1 -0
  17. package/dist/dashboard/assets/HealthEventsModal-C5XpWlvy.js.gz +0 -0
  18. package/dist/dashboard/assets/{LoginPage-DbhTGfhb.js → LoginPage-DHoB3Agn.js} +1 -1
  19. package/dist/dashboard/assets/LoginPage-DHoB3Agn.js.gz +0 -0
  20. package/dist/dashboard/assets/ModelPicker-oQ-SDXp8.js +1 -0
  21. package/dist/dashboard/assets/ModelPicker-oQ-SDXp8.js.gz +0 -0
  22. package/dist/dashboard/assets/OverviewPage-BrY4SgIt.js +1 -0
  23. package/dist/dashboard/assets/OverviewPage-BrY4SgIt.js.gz +0 -0
  24. package/dist/dashboard/assets/ProviderDetailPage-D9VSt7T_.js +3 -0
  25. package/dist/dashboard/assets/ProviderDetailPage-D9VSt7T_.js.gz +0 -0
  26. package/dist/dashboard/assets/ProviderIcon-CmfzxpVM.js +1 -0
  27. package/dist/dashboard/assets/ProviderIcon-CmfzxpVM.js.gz +0 -0
  28. package/dist/dashboard/assets/ProvidersPage-Df77V5I5.js +1 -0
  29. package/dist/dashboard/assets/ProvidersPage-Df77V5I5.js.gz +0 -0
  30. package/dist/dashboard/assets/ProxyPage-CDfxP7AC.js +7 -0
  31. package/dist/dashboard/assets/ProxyPage-CDfxP7AC.js.gz +0 -0
  32. package/dist/dashboard/assets/QuotaPage-CY4o2t4p.js +1 -0
  33. package/dist/dashboard/assets/QuotaPage-CY4o2t4p.js.gz +0 -0
  34. package/dist/dashboard/assets/{SettingsPage-YxRml8AT.js → SettingsPage-CvjZxHE4.js} +1 -1
  35. package/dist/dashboard/assets/SettingsPage-CvjZxHE4.js.gz +0 -0
  36. package/dist/dashboard/assets/SetupPage-D3nza7LH.js +1 -0
  37. package/dist/dashboard/assets/SetupPage-D3nza7LH.js.gz +0 -0
  38. package/dist/dashboard/assets/{SortableList-Bol1ASmI.js → SortableList-Dtt12WT1.js} +1 -1
  39. package/dist/dashboard/assets/SortableList-Dtt12WT1.js.gz +0 -0
  40. package/dist/dashboard/assets/StudioPage-CbK3hXvU.js +31 -0
  41. package/dist/dashboard/assets/StudioPage-CbK3hXvU.js.gz +0 -0
  42. package/dist/dashboard/assets/ToolIcon-K1ckcDKg.js.gz +0 -0
  43. package/dist/dashboard/assets/UsagePage-BQlWbWg2.js +56 -0
  44. package/dist/dashboard/assets/UsagePage-BQlWbWg2.js.gz +0 -0
  45. package/dist/dashboard/assets/api-keys-BmDbsior.js +1 -0
  46. package/dist/dashboard/assets/api-keys-BmDbsior.js.gz +0 -0
  47. package/dist/dashboard/assets/app-BbpSarVV.css +2 -0
  48. package/dist/dashboard/assets/app-BbpSarVV.css.gz +0 -0
  49. package/dist/dashboard/assets/{app-BDJWMThu.js → app-C-Y4oZT0.js} +3 -3
  50. package/dist/dashboard/assets/app-C-Y4oZT0.js.gz +0 -0
  51. package/dist/dashboard/assets/arrow-left-CpBxkoxI.js.gz +0 -0
  52. package/dist/dashboard/assets/arrow-right-Dl5yKnp8.js.gz +0 -0
  53. package/dist/dashboard/assets/assert-BHfaei-X.js +1 -0
  54. package/dist/dashboard/assets/assert-BHfaei-X.js.gz +0 -0
  55. package/dist/dashboard/assets/backup-file-gdKIlg8U.js.gz +0 -0
  56. package/dist/dashboard/assets/{badge-BwmwEOgA.js → badge-DpXT4Ixt.js} +1 -1
  57. package/dist/dashboard/assets/badge-DpXT4Ixt.js.gz +0 -0
  58. package/dist/dashboard/assets/{banner-BKBfKTqn.js → banner-CSAAWLJv.js} +1 -1
  59. package/dist/dashboard/assets/banner-CSAAWLJv.js.gz +0 -0
  60. package/dist/dashboard/assets/brain-Cwed_72z.js.gz +0 -0
  61. package/dist/dashboard/assets/{card-BTdbRFFg.js → card-DEhRXXw4.js} +1 -1
  62. package/dist/dashboard/assets/card-DEhRXXw4.js.gz +0 -0
  63. package/dist/dashboard/assets/check-D69FzdvC.js.gz +0 -0
  64. package/dist/dashboard/assets/chevron-right-BXIy5l3h.js.gz +0 -0
  65. package/dist/dashboard/assets/circle-alert-CntYHV5v.js.gz +0 -0
  66. package/dist/dashboard/assets/circle-check-BU3v_mFW.js.gz +0 -0
  67. package/dist/dashboard/assets/cli-tools-B0wBq-RL.js +1 -0
  68. package/dist/dashboard/assets/cli-tools-B0wBq-RL.js.gz +0 -0
  69. package/dist/dashboard/assets/clipboard-button-DOo6EU8O.js +1 -0
  70. package/dist/dashboard/assets/clipboard-button-DOo6EU8O.js.gz +0 -0
  71. package/dist/dashboard/assets/cog-CdWhsajs.js.gz +0 -0
  72. package/dist/dashboard/assets/copy-ngmQ9zcZ.js.gz +0 -0
  73. package/dist/dashboard/assets/{dialog-iocKA3jr.js → dialog-BvQeBMfP.js} +1 -1
  74. package/dist/dashboard/assets/dialog-BvQeBMfP.js.gz +0 -0
  75. package/dist/dashboard/assets/dialog-actions-B1axpEg7.js.gz +0 -0
  76. package/dist/dashboard/assets/download-A0SW9OCW.js.gz +0 -0
  77. package/dist/dashboard/assets/download-dRFo5_Wu.js.gz +0 -0
  78. package/dist/dashboard/assets/{drawer-CDKOTZdY.js → drawer-CMwPXwQB.js} +1 -1
  79. package/dist/dashboard/assets/drawer-CMwPXwQB.js.gz +0 -0
  80. package/dist/dashboard/assets/entry-D4O2229X.js +1 -0
  81. package/dist/dashboard/assets/entry-D4O2229X.js.gz +0 -0
  82. package/dist/dashboard/assets/entry-DKvr5Q9K.js +1 -0
  83. package/dist/dashboard/assets/entry-DKvr5Q9K.js.gz +0 -0
  84. package/dist/dashboard/assets/entry-FQCejoDY.js +4 -0
  85. package/dist/dashboard/assets/entry-FQCejoDY.js.gz +0 -0
  86. package/dist/dashboard/assets/eye-C_hawfH4.js.gz +0 -0
  87. package/dist/dashboard/assets/eye-off-Bk5g2luF.js.gz +0 -0
  88. package/dist/dashboard/assets/fingerprint-pattern-BWuHtbW4.js.gz +0 -0
  89. package/dist/dashboard/assets/gauge-BbIMy7MB.js.gz +0 -0
  90. package/dist/dashboard/assets/github-badge-zliLvBwr.js +1 -0
  91. package/dist/dashboard/assets/github-badge-zliLvBwr.js.gz +0 -0
  92. package/dist/dashboard/assets/image-BPaouViG.js.gz +0 -0
  93. package/dist/dashboard/assets/{index-C4hXfAMs.js → index-CXm3FIyt.js} +2 -2
  94. package/dist/dashboard/assets/index-CXm3FIyt.js.gz +0 -0
  95. package/dist/dashboard/assets/{inline-DCejSLlA.js → inline-D1IlZZ8H.js} +1 -1
  96. package/dist/dashboard/assets/inline-D1IlZZ8H.js.gz +0 -0
  97. package/dist/dashboard/assets/{input-Bn4_kNav.js → input-BpVZdrbc.js} +1 -1
  98. package/dist/dashboard/assets/input-BpVZdrbc.js.gz +0 -0
  99. package/dist/dashboard/assets/jsx-runtime-CXUMngI_.js.gz +0 -0
  100. package/dist/dashboard/assets/key-round-UkavsGxp.js.gz +0 -0
  101. package/dist/dashboard/assets/layers-2-glBbjl.js.gz +0 -0
  102. package/dist/dashboard/assets/{layout-Bv-NxqZ5.js → layout-BioLW7gN.js} +1 -1
  103. package/dist/dashboard/assets/layout-BioLW7gN.js.gz +0 -0
  104. package/dist/dashboard/assets/{list-row-BLA0sldD.js → list-row-CwVcGAIc.js} +1 -1
  105. package/dist/dashboard/assets/list-row-CwVcGAIc.js.gz +0 -0
  106. package/dist/dashboard/assets/{live-YlifPYox.js → live-yfEJFF9T.js} +1 -1
  107. package/dist/dashboard/assets/live-yfEJFF9T.js.gz +0 -0
  108. package/dist/dashboard/assets/log-in-BmRjy60c.js.gz +0 -0
  109. package/dist/dashboard/assets/message-circle-CCxGwRvv.js.gz +0 -0
  110. package/dist/dashboard/assets/model-bans-CT-TxSNX.js +1 -0
  111. package/dist/dashboard/assets/model-bans-CT-TxSNX.js.gz +0 -0
  112. package/dist/dashboard/assets/mutation-BkoLM5Ba.js +1 -0
  113. package/dist/dashboard/assets/mutation-BkoLM5Ba.js.gz +0 -0
  114. package/dist/dashboard/assets/numbers-CFL4_Dqc.js.gz +0 -0
  115. package/dist/dashboard/assets/pencil-768ysRAr.js.gz +0 -0
  116. package/dist/dashboard/assets/plus-DIdX_g-x.js.gz +0 -0
  117. package/dist/dashboard/assets/power-off-Yjw9GMJh.js +1 -0
  118. package/dist/dashboard/assets/power-off-Yjw9GMJh.js.gz +0 -0
  119. package/dist/dashboard/assets/privacy-mode-GM8bej85.js +1 -0
  120. package/dist/dashboard/assets/privacy-mode-GM8bej85.js.gz +0 -0
  121. package/dist/dashboard/assets/providers/agentrouter.svg.gz +0 -0
  122. package/dist/dashboard/assets/providers/aihubmix.svg.gz +0 -0
  123. package/dist/dashboard/assets/providers/alibaba-coding-plan.svg.gz +0 -0
  124. package/dist/dashboard/assets/providers/alibaba.svg.gz +0 -0
  125. package/dist/dashboard/assets/providers/antigravity.svg.gz +0 -0
  126. package/dist/dashboard/assets/providers/bai.svg.gz +0 -0
  127. package/dist/dashboard/assets/providers/blackbox.svg.gz +0 -0
  128. package/dist/dashboard/assets/providers/claude-code.svg.gz +0 -0
  129. package/dist/dashboard/assets/providers/cursor.webp +0 -0
  130. package/dist/dashboard/assets/providers/exa.svg.gz +0 -0
  131. package/dist/dashboard/assets/providers/gmi-cloud.svg.gz +0 -0
  132. package/dist/dashboard/assets/providers/inferhub.svg.gz +0 -0
  133. package/dist/dashboard/assets/providers/muse.svg.gz +0 -0
  134. package/dist/dashboard/assets/providers/nous-research.svg.gz +0 -0
  135. package/dist/dashboard/assets/providers/openai-light.svg.gz +0 -0
  136. package/dist/dashboard/assets/providers/parallel.svg.gz +0 -0
  137. package/dist/dashboard/assets/providers/tokenharbor.svg.gz +0 -0
  138. package/dist/dashboard/assets/providers/trae.webp +0 -0
  139. package/dist/dashboard/assets/providers/zai-coding-plan.svg.gz +0 -0
  140. package/dist/dashboard/assets/{query-keys-ROf-g1Wi.js → query-keys-D7wX2vM5.js} +1 -1
  141. package/dist/dashboard/assets/query-keys-D7wX2vM5.js.gz +0 -0
  142. package/dist/dashboard/assets/quota-DZGyp0N5.js.gz +0 -0
  143. package/dist/dashboard/assets/refresh-cw-BqJFk4sU.js.gz +0 -0
  144. package/dist/dashboard/assets/rotate-ccw-CufcqITi.js.gz +0 -0
  145. package/dist/dashboard/assets/route-CNSNj9Zm.js.gz +0 -0
  146. package/dist/dashboard/assets/routing-Cs6jl49L.js +1 -0
  147. package/dist/dashboard/assets/routing-Cs6jl49L.js.gz +0 -0
  148. package/dist/dashboard/assets/scaling-nWE4Cx8f.js.gz +0 -0
  149. package/dist/dashboard/assets/search-JOKus9rb.js.gz +0 -0
  150. package/dist/dashboard/assets/select-C0Rer3gF.js +41 -0
  151. package/dist/dashboard/assets/select-C0Rer3gF.js.gz +0 -0
  152. package/dist/dashboard/assets/settings-CfcTcOyU.js +1 -0
  153. package/dist/dashboard/assets/settings-CfcTcOyU.js.gz +0 -0
  154. package/dist/dashboard/assets/shield-check-DVS0Foqh.js.gz +0 -0
  155. package/dist/dashboard/assets/sparkles-BRaRUxAU.js.gz +0 -0
  156. package/dist/dashboard/assets/{stack-C1kTcOmP.js → stack-C9_uUjaA.js} +1 -1
  157. package/dist/dashboard/assets/stack-C9_uUjaA.js.gz +0 -0
  158. package/dist/dashboard/assets/{state-DHKdg-pm.js → state-DA0B95UQ.js} +1 -1
  159. package/dist/dashboard/assets/state-DA0B95UQ.js.gz +0 -0
  160. package/dist/dashboard/assets/stream-retry-5QnVuSSo.js.gz +0 -0
  161. package/dist/dashboard/assets/{switch-DIDeAiz9.js → switch-Cittxy-x.js} +1 -1
  162. package/dist/dashboard/assets/switch-Cittxy-x.js.gz +0 -0
  163. package/dist/dashboard/assets/terminal-Bd4ee8TA.js +1 -0
  164. package/dist/dashboard/assets/terminal-Bd4ee8TA.js.gz +0 -0
  165. package/dist/dashboard/assets/toast-CNt_jzO4.js +2 -0
  166. package/dist/dashboard/assets/toast-CNt_jzO4.js.gz +0 -0
  167. package/dist/dashboard/assets/tokens-DDxMbSRL.js.gz +0 -0
  168. package/dist/dashboard/assets/trash-Cppn5c3R.js.gz +0 -0
  169. package/dist/dashboard/assets/use-clipboard-B0Sy03fE.js.gz +0 -0
  170. package/dist/dashboard/assets/use-presence-BSZEBx7U.js.gz +0 -0
  171. package/dist/dashboard/assets/use-reduced-motion-C4B0R6Sz.js.gz +0 -0
  172. package/dist/dashboard/assets/use-timeout-BwcBBRrT.js.gz +0 -0
  173. package/dist/dashboard/assets/{useMutation-DVmwOKU-.js → useMutation-BQKMcJ1H.js} +1 -1
  174. package/dist/dashboard/assets/useMutation-BQKMcJ1H.js.gz +0 -0
  175. package/dist/dashboard/assets/wrench-B6E1oEuY.js.gz +0 -0
  176. package/dist/dashboard/index.html +1 -1
  177. package/dist/dashboard/index.html.gz +0 -0
  178. package/dist/dashboard/precompressed-assets.json +122 -0
  179. package/migrations/full-postgres/0013_add_providers_tenant_id_index.sql +22 -0
  180. package/migrations/full-postgres/0014_add_provider_accounts_tenant_id_index.sql +21 -0
  181. package/migrations/full-postgres/0015_add_payload_stages.sql +18 -0
  182. package/migrations/full-postgres/0016_add_share_link_slug.sql +20 -0
  183. package/migrations/full-postgres/0017_add_models_pro_only.sql +18 -0
  184. package/migrations/full-postgres/0018_add_accounts_quota_plan.sql +18 -0
  185. package/migrations/full-postgres/0019_add_models_free_tier.sql +15 -0
  186. package/migrations/lite-sqlite3/0000_baseline.sql +4 -2
  187. package/migrations/lite-sqlite3/0013_add_providers_tenant_id_index.sql +4 -0
  188. package/migrations/lite-sqlite3/0014_add_provider_accounts_tenant_id_index.sql +4 -0
  189. package/migrations/lite-sqlite3/0015_add_payload_stages.sql +18 -0
  190. package/migrations/lite-sqlite3/0016_add_share_link_slug.sql +20 -0
  191. package/migrations/lite-sqlite3/0017_add_models_pro_only.sql +18 -0
  192. package/migrations/lite-sqlite3/0018_add_accounts_quota_plan.sql +18 -0
  193. package/migrations/lite-sqlite3/0019_add_models_free_tier.sql +15 -0
  194. package/package.json +26 -30
  195. package/scripts/cli/{carte-launcher.cjs → bin/carte.cjs} +8 -8
  196. package/scripts/cli/{cli-launcher.cjs → bin/cartethyia.cjs} +167 -134
  197. package/scripts/cli/command/admin.ts +65 -0
  198. package/scripts/cli/command/console-menu.ts +246 -0
  199. package/scripts/cli/{doctor.ts → command/doctor.ts} +257 -258
  200. package/scripts/cli/{setup.ts → command/setup.ts} +8 -12
  201. package/scripts/cli/command/start.ts +77 -0
  202. package/scripts/cli/command/tray.ts +165 -0
  203. package/scripts/cli/command/update.ts +23 -0
  204. package/scripts/cli/{runtime-state.ts → gateway/drain-token.ts} +78 -78
  205. package/scripts/cli/gateway/instance-lock.ts +105 -0
  206. package/scripts/cli/gateway/spawn.ts +173 -0
  207. package/scripts/cli/gateway/stop.ts +51 -0
  208. package/scripts/cli/main.ts +94 -0
  209. package/scripts/cli/support/bun.ts +27 -0
  210. package/scripts/cli/support/env.ts +99 -0
  211. package/scripts/cli/support/platform.ts +64 -0
  212. package/scripts/cli/support/services.ts +88 -0
  213. package/scripts/cli/support/startup-signal.ts +23 -0
  214. package/scripts/cli/{version.ts → support/version.ts} +1 -1
  215. package/scripts/cli/tray/hidden-window.vbs +28 -0
  216. package/scripts/cli/{tray.ts → tray/index.ts} +3 -3
  217. package/scripts/cli/{tray.ps1 → tray/windows.ps1} +107 -98
  218. package/scripts/cli/ui/background.ts +110 -0
  219. package/src/boot/app.ts +23 -7
  220. package/src/boot/dependencies.ts +55 -13
  221. package/src/boot/elysia-typebox-bridge.ts +37 -0
  222. package/src/boot/lifecycle.ts +2 -0
  223. package/src/config/cooldowns.ts +138 -138
  224. package/src/config/providers.ts +31 -5
  225. package/src/config/runtime.ts +19 -3
  226. package/src/config/spec.ts +20 -0
  227. package/src/console/api-keys/contracts.ts +414 -391
  228. package/src/console/api-keys/routes.ts +880 -870
  229. package/src/console/auth/account-reset-store.ts +14 -0
  230. package/src/console/auth/account-reset.ts +3 -2
  231. package/src/console/auth/setup-service.ts +6 -3
  232. package/src/console/backup/routes.ts +8 -0
  233. package/src/console/cli-tools/contracts.ts +6 -6
  234. package/src/console/console-router.ts +5 -3
  235. package/src/console/dashboard-assets.ts +81 -11
  236. package/src/console/domain-registration.ts +9 -2
  237. package/src/console/observability/contracts.ts +475 -423
  238. package/src/console/observability/routes.ts +31 -6
  239. package/src/console/observability/store.ts +1015 -912
  240. package/src/console/providers/catalog/contracts.ts +31 -0
  241. package/src/console/providers/catalog/provider-operations.ts +8 -0
  242. package/src/console/providers/catalog/public-model-store.ts +91 -60
  243. package/src/console/providers/catalog/store.ts +1532 -1521
  244. package/src/console/providers/oauth/callback-listener.ts +18 -3
  245. package/src/console/providers/oauth/routes.ts +38 -15
  246. package/src/console/quota/account-quota-tenant.ts +9 -6
  247. package/src/console/quota/refresh.ts +19 -0
  248. package/src/console/response-compression.ts +69 -2
  249. package/src/console/routing/pools/probe-verdict.ts +72 -0
  250. package/src/console/share/share-router.ts +7 -4
  251. package/src/console/share/share-stats.ts +12 -5
  252. package/src/console/shared/read-through-cache.ts +133 -7
  253. package/src/console/updates/repository-updates.ts +39 -34
  254. package/src/console/updates/routes.ts +162 -4
  255. package/src/main.ts +3 -0
  256. package/src/network/http2-bidi.ts +85 -0
  257. package/src/network/outbound-fetch.ts +87 -24
  258. package/src/network/pool/agent.ts +478 -460
  259. package/src/network/pool/resolver.ts +1104 -544
  260. package/src/network/pool-health-machine.ts +12 -5
  261. package/src/observability/event-log.ts +29 -8
  262. package/src/observability/metrics.ts +40 -13
  263. package/src/observability/payload-capture.ts +344 -324
  264. package/src/observability/runtime-metrics.ts +7 -0
  265. package/src/persistence/db-pg.ts +9 -3
  266. package/src/persistence/db-sqlite.ts +497 -495
  267. package/src/persistence/health-event-flush.ts +137 -0
  268. package/src/persistence/migrate.ts +181 -129
  269. package/src/persistence/schema.ts +1360 -1257
  270. package/src/persistence/settings-revision.ts +25 -0
  271. package/src/persistence/shared-cache.ts +217 -0
  272. package/src/persistence/stores/share-store.ts +556 -528
  273. package/src/persistence/usage-flush.ts +61 -1
  274. package/src/protocol/canonical-model.ts +23 -1
  275. package/src/protocol/response/chat.ts +143 -10
  276. package/src/protocol/response/codex.ts +9 -75
  277. package/src/protocol/response/omp-tool-call-markup.ts +221 -0
  278. package/src/protocol/tool-identity.ts +43 -9
  279. package/src/providers/authentication/jwt-validator.ts +130 -187
  280. package/src/providers/authentication/oauth-flow-store.ts +16 -9
  281. package/src/providers/default-registry.ts +177 -1
  282. package/src/providers/discovery/catalog/catalog-format.ts +20 -0
  283. package/src/providers/discovery/models-dev-catalog.ts +39 -22
  284. package/src/providers/discovery/openai-model-discovery.ts +39 -3
  285. package/src/providers/discovery/openrouter-free-roster.ts +98 -0
  286. package/src/providers/discovery/probe-wire.ts +29 -5
  287. package/src/providers/discovery/probing-service.ts +1022 -1001
  288. package/src/providers/integrations/buddy/buddy-catalog-shared.ts +42 -2
  289. package/src/providers/integrations/buddy/buddy-chat-shared.ts +913 -914
  290. package/src/providers/integrations/buddy/buddy-cli-headers.ts +88 -0
  291. package/src/providers/integrations/buddy/buddy-conversation-headers.ts +22 -5
  292. package/src/providers/integrations/buddy/buddy-discovery-shared.ts +33 -1
  293. package/src/providers/integrations/buddy/buddy-identity.ts +46 -0
  294. package/src/providers/integrations/buddy/buddy-oauth-shared.ts +37 -1
  295. package/src/providers/integrations/buddy/buddy-usage-store.ts +40 -5
  296. package/src/providers/integrations/buddy/codebuddy/cn-provider.ts +4 -0
  297. package/src/providers/integrations/buddy/codebuddy/provider.ts +180 -175
  298. package/src/providers/integrations/buddy/codebuddy/shared.ts +115 -73
  299. package/src/providers/integrations/buddy/workbuddy/cn-provider.ts +5 -2
  300. package/src/providers/integrations/buddy/workbuddy/provider.ts +206 -204
  301. package/src/providers/integrations/buddy/workbuddy/quota.ts +172 -159
  302. package/src/providers/integrations/buddy/workbuddy/shared.ts +200 -171
  303. package/src/providers/integrations/claude/claude.ts +606 -582
  304. package/src/providers/integrations/cline/cline-quota.ts +18 -16
  305. package/src/providers/integrations/cline/cline.ts +539 -468
  306. package/src/providers/integrations/codex/codex.ts +5 -8
  307. package/src/providers/integrations/commandcode/commandcode.ts +675 -541
  308. package/src/providers/integrations/commandcode/discovery.ts +116 -0
  309. package/src/providers/integrations/commandcode/quota.ts +160 -0
  310. package/src/providers/integrations/cursor/cursor-auth.ts +418 -0
  311. package/src/providers/integrations/cursor/cursor-catalog.ts +368 -0
  312. package/src/providers/integrations/cursor/cursor-http2.ts +300 -0
  313. package/src/providers/integrations/cursor/cursor-identity.ts +92 -0
  314. package/src/providers/integrations/cursor/cursor-request.ts +471 -0
  315. package/src/providers/integrations/cursor/cursor-session-manager.ts +281 -0
  316. package/src/providers/integrations/cursor/cursor-shared.ts +108 -0
  317. package/src/providers/integrations/cursor/cursor.ts +960 -0
  318. package/src/providers/integrations/cursor/protocol/framing.ts +129 -0
  319. package/src/providers/integrations/cursor/protocol/messages.ts +2355 -0
  320. package/src/providers/integrations/cursor/protocol/struct.ts +305 -0
  321. package/src/providers/integrations/factory/factory-oauth.ts +7 -11
  322. package/src/providers/integrations/github/github.ts +4 -4
  323. package/src/providers/integrations/hermes.ts +79 -8
  324. package/src/providers/integrations/kilo/kilo-discovery.ts +10 -1
  325. package/src/providers/integrations/kilo/kilo.ts +153 -146
  326. package/src/providers/integrations/opencode/opencode-go-quota.ts +130 -0
  327. package/src/providers/integrations/opencode/provider.ts +532 -499
  328. package/src/providers/integrations/openrouter/provider.ts +139 -281
  329. package/src/providers/integrations/qoder/qoder-catalog.ts +450 -0
  330. package/src/providers/integrations/qoder/qoder-oauth.ts +310 -263
  331. package/src/providers/integrations/qoder/qoder-realm.ts +106 -0
  332. package/src/providers/integrations/qoder/qoder-virtual.ts +77 -0
  333. package/src/providers/integrations/qoder/qoder.ts +865 -730
  334. package/src/providers/integrations/shared/install-id.ts +29 -2
  335. package/src/providers/integrations/trae/trae-catalog.ts +190 -0
  336. package/src/providers/integrations/trae/trae-headers.ts +149 -0
  337. package/src/providers/integrations/trae/trae-oauth.ts +773 -0
  338. package/src/providers/integrations/trae/trae-protocol.ts +400 -0
  339. package/src/providers/integrations/trae/trae-realm.ts +220 -0
  340. package/src/providers/integrations/trae/trae.ts +458 -0
  341. package/src/providers/operations/account-health-service.ts +107 -10
  342. package/src/providers/operations/client-versions.ts +143 -6
  343. package/src/providers/operations/model-catalog-cache.ts +18 -3
  344. package/src/providers/operations/provider-catalog-seeder.ts +95 -86
  345. package/src/providers/operations/provider-catalog-service.ts +76 -0
  346. package/src/providers/operations/provider-credential-service.ts +46 -7
  347. package/src/providers/provider-contracts.ts +76 -0
  348. package/src/providers/provider-metadata.ts +78 -6
  349. package/src/providers/provider-model-definition.ts +373 -349
  350. package/src/security/api-key-auth.ts +126 -8
  351. package/src/transport/contracts.ts +3 -0
  352. package/src/transport/dispatch/attempt-finalize.ts +39 -2
  353. package/src/transport/dispatch/attempt-loop.ts +530 -513
  354. package/src/transport/dispatch/empty-stream.ts +17 -26
  355. package/src/transport/dispatch/generation.ts +88 -21
  356. package/src/transport/dispatch/leases.ts +99 -2
  357. package/src/transport/dispatch/proxy-request.ts +60 -3
  358. package/src/transport/dispatch/streaming/finalize.ts +16 -1
  359. package/src/transport/dispatch/streaming/stream.ts +22 -2
  360. package/src/transport/dispatch/upstream.ts +19 -1
  361. package/src/transport/failure-policy.ts +20 -0
  362. package/src/transport/guards/auth-guard.ts +26 -1
  363. package/src/transport/guards/body-policy.ts +5 -4
  364. package/src/transport/request/preparer.ts +103 -0
  365. package/src/transport/request/state.ts +14 -0
  366. package/src/transport/routing/plan-gate.ts +45 -0
  367. package/src/transport/routing/route-catalog.ts +799 -738
  368. package/src/transport/routing/route-model.ts +52 -1
  369. package/src/transport/routing/router.ts +51 -0
  370. package/src/transport/surface/chat/encode.ts +18 -31
  371. package/src/transport/surface/messages/stream.ts +3 -2
  372. package/src/transport/translation/tool-repair.ts +37 -16
  373. package/src/workers/account-identity-sweep.ts +14 -12
  374. package/src/workers/daily-checkin.ts +62 -0
  375. package/src/workers/quota-refresh-sweep.ts +27 -2
  376. package/dist/dashboard/assets/BannedPage-DUrXpp-X.js +0 -1
  377. package/dist/dashboard/assets/CombosPage-B7ETkfVH.js +0 -1
  378. package/dist/dashboard/assets/ConsoleLogPage-AisQzCI1.js +0 -1
  379. package/dist/dashboard/assets/HealthEventsModal-BNflDm_f.js +0 -1
  380. package/dist/dashboard/assets/ModelPicker-CAiPR5Vd.js +0 -1
  381. package/dist/dashboard/assets/OverviewPage-C3NIzsDx.js +0 -1
  382. package/dist/dashboard/assets/ProviderDetailPage-BK8i5B5X.js +0 -3
  383. package/dist/dashboard/assets/ProviderIcon-paOCq8I0.js +0 -1
  384. package/dist/dashboard/assets/ProvidersPage-CvCVgm1e.js +0 -1
  385. package/dist/dashboard/assets/ProxyPage-DjabkuzP.js +0 -7
  386. package/dist/dashboard/assets/QuotaPage-Dxe0B0iu.js +0 -1
  387. package/dist/dashboard/assets/SetupPage-BaD4P_jW.js +0 -1
  388. package/dist/dashboard/assets/StudioPage-CLP4le3U.js +0 -31
  389. package/dist/dashboard/assets/UsagePage-Djes-Epy.js +0 -56
  390. package/dist/dashboard/assets/api-keys-CjIGQhe7.js +0 -1
  391. package/dist/dashboard/assets/app-Cjgo4-Lf.css +0 -2
  392. package/dist/dashboard/assets/assert-Bwh6DJTK.js +0 -1
  393. package/dist/dashboard/assets/cli-tools-DccaOfhX.js +0 -1
  394. package/dist/dashboard/assets/clipboard-button-DQmMikNQ.js +0 -1
  395. package/dist/dashboard/assets/entry-Cpi0rph1.js +0 -4
  396. package/dist/dashboard/assets/entry-Dm3PpDCO.js +0 -1
  397. package/dist/dashboard/assets/entry-keNDZ_Wz.js +0 -1
  398. package/dist/dashboard/assets/github-badge-BuAJr8oU.js +0 -1
  399. package/dist/dashboard/assets/model-bans-CVvHZ-qz.js +0 -1
  400. package/dist/dashboard/assets/mutation-BIizFZav.js +0 -1
  401. package/dist/dashboard/assets/privacy-mode-CQjbZ3xk.js +0 -1
  402. package/dist/dashboard/assets/routing-jntw9-Ui.js +0 -1
  403. package/dist/dashboard/assets/select-CGUuTQmj.js +0 -41
  404. package/dist/dashboard/assets/settings-BAS6QbmJ.js +0 -1
  405. package/dist/dashboard/assets/sliders-horizontal-ClG7dBeJ.js +0 -1
  406. package/dist/dashboard/assets/terminal-C9mZ4Ugm.js +0 -1
  407. package/dist/dashboard/assets/toast-DKAdP2mo.js +0 -2
  408. package/scripts/cli/cli.ts +0 -771
  409. package/scripts/operations/probe-egress.ts +0 -335
  410. package/scripts/operations/restart.ts +0 -120
  411. package/scripts/shared/env.ts +0 -269
  412. /package/{tray-assets → scripts/cli/tray/assets}/cartethyia.ico +0 -0
  413. /package/{tray-assets → scripts/cli/tray/assets}/cartethyia.png +0 -0
  414. /package/scripts/cli/{menu.ts → ui/prompt.ts} +0 -0
  415. /package/scripts/cli/{wordmark.ts → ui/wordmark.ts} +0 -0
@@ -1,914 +1,913 @@
1
- /**
2
- * Chat-wire payload and message normalization shared by the Tencent "buddy"
3
- * family providers — CodeBuddy International (`cb`), CodeBuddy CN (`cbcn`),
4
- * and WorkBuddy. All three speak the same upstream contract for this part of
5
- * the payload, so the rules live here once instead of being copied per
6
- * variant:
7
- *
8
- * - streaming is mandatory (`stream=true`);
9
- * - `reasoning_summary` is opted in only when a `reasoning_effort` is
10
- * requested;
11
- * - the agent-namespace fields are never forwarded;
12
- * - caller `system`/`developer` turns are replaced by the variant's fixed
13
- * leading system prompt, and bare string user content becomes a typed text
14
- * block; and
15
- * - consecutive same-role `user` turns must be merged before dispatch.
16
- *
17
- * Provider-specific identity headers and catalog tables stay in each variant's
18
- * own `*-shared.ts`.
19
- */
20
- import { createHash } from "node:crypto";
21
- import { completeRequiredSchema } from "../../../protocol/codecs";
22
- import type { CanonicalRequest } from "../../../protocol/canonical-model";
23
- import { isRecord } from "../../../protocol/codecs";
24
- import { applyDeepSeekReasoning, applyOpenAIReasoning, backfillDeepSeekReasoningContent, clampedEffortOutputFloor } from "../../provider-reasoning";
25
- import { BUDDY_CN_EFFORTS, BUDDY_INTL_EFFORTS, BUDDY_SHARED_RAW } from "./buddy-catalog-shared";
26
- // Imported for the CN roster only: a CN-only id has no row in the shared
27
- // table, and without one `raiseOutputForRaisedEffort` silently no-ops, so a
28
- // raised effort still answers inside a budget sized for the lower one. It is
29
- // read inside the function below — never at module init — so the cycle with
30
- // cn-provider (which imports this module) resolves before any call.
31
- import { CODEBUDDY_CN_RAW } from "./codebuddy/cn-provider";
32
- import { buddyAccountUid } from "./buddy-oauth-shared";
33
- import type { ProviderDispatchContext } from "../../provider-contracts";
34
-
35
- const BUDDY_TOOL_NAME_PATTERN = /^[a-zA-Z0-9_-]+$/;
36
- const INVALID_BUDDY_TOOL_NAME_CHARS = /[^a-zA-Z0-9_-]/g;
37
- const MAX_BUDDY_TOOL_NAME_LENGTH = 64;
38
-
39
- function uniqueToolName(base: string, used: Set<string>): string {
40
- const clipped = (base || "tool").slice(0, MAX_BUDDY_TOOL_NAME_LENGTH);
41
- if (!used.has(clipped)) return clipped;
42
- for (let suffix = 2; ; suffix += 1) {
43
- const suffixText = `_${suffix}`;
44
- const candidate = `${clipped.slice(0, MAX_BUDDY_TOOL_NAME_LENGTH - suffixText.length)}${suffixText}`;
45
- if (!used.has(candidate)) return candidate;
46
- }
47
- }
48
-
49
- function normalizedToolName(value: string, index: number, used: Set<string>): string {
50
- const base =
51
- BUDDY_TOOL_NAME_PATTERN.test(value) && value.length <= MAX_BUDDY_TOOL_NAME_LENGTH
52
- ? value
53
- : value.replace(INVALID_BUDDY_TOOL_NAME_CHARS, "");
54
- return uniqueToolName(base || `tool_${index + 1}`, used);
55
- }
56
-
57
- /**
58
- * Buddy's Chat Completions wire has no `web_search_preview` hosted tool; its
59
- * search is an ordinary function named `web_search` with a `{query}` schema.
60
- * Rewriting the hosted declaration here lets the caller's own tool loop
61
- * (Model Lab, or any client that executes its functions) run the query at
62
- * `POST /v1/search`. The gateway never executes it: an unexecuted call is
63
- * returned to the caller like any other function call.
64
- */
65
- function buddyHostedSearchFunction(rawTool: Record<string, unknown>): Record<string, unknown> | undefined {
66
- const type = rawTool["type"];
67
- if (type !== "web_search" && type !== "web_search_preview") return undefined;
68
- return {
69
- type: "function",
70
- function: {
71
- name: "web_search",
72
- description: "Search the web for current information.",
73
- parameters: {
74
- type: "object",
75
- properties: {
76
- query: { type: "string", description: "The search query." },
77
- },
78
- required: ["query"],
79
- additionalProperties: false,
80
- },
81
- },
82
- };
83
- }
84
-
85
-
86
- export function normalizeBuddyToolNames(payload: Record<string, unknown>): void {
87
- if (!Array.isArray(payload["tools"])) return;
88
- const used = new Set<string>();
89
- const replacements = new Map<string, string>();
90
- payload["tools"] = payload["tools"].map((rawTool, index) => {
91
- if (!isRecord(rawTool)) return rawTool;
92
- const hostedSearch = buddyHostedSearchFunction(rawTool);
93
- if (hostedSearch !== undefined) {
94
- used.add("web_search");
95
- return hostedSearch;
96
- }
97
- const fn = isRecord(rawTool["function"]) ? rawTool["function"] : undefined;
98
- const custom = isRecord(rawTool["custom"]) ? rawTool["custom"] : undefined;
99
- const sourceName = fn?.["name"] ?? custom?.["name"] ?? rawTool["name"];
100
- if (typeof sourceName !== "string") return rawTool;
101
- const name = normalizedToolName(sourceName, index, used);
102
- used.add(name);
103
- if (!replacements.has(sourceName)) replacements.set(sourceName, name);
104
- if (fn !== undefined)
105
- return {
106
- ...rawTool,
107
- function: {
108
- ...fn,
109
- name,
110
- ...(fn["parameters"] === undefined ? {} : { parameters: completeRequiredSchema(fn["parameters"]) }),
111
- },
112
- };
113
- if (custom !== undefined)
114
- return {
115
- ...rawTool,
116
- custom: {
117
- ...custom,
118
- name,
119
- ...(custom["format"] === undefined ? {} : { format: completeRequiredSchema(custom["format"]) }),
120
- },
121
- };
122
- return {
123
- ...rawTool,
124
- name,
125
- ...(rawTool["parameters"] === undefined ? {} : { parameters: completeRequiredSchema(rawTool["parameters"]) }),
126
- };
127
- });
128
- const messages = payload["messages"];
129
- if (Array.isArray(messages)) {
130
- for (const message of messages) {
131
- if (!isRecord(message) || !Array.isArray(message["tool_calls"])) continue;
132
- for (const call of message["tool_calls"]) {
133
- if (!isRecord(call) || !isRecord(call["function"])) continue;
134
- const name = call["function"]["name"];
135
- const replacement = typeof name === "string" ? replacements.get(name) : undefined;
136
- if (replacement !== undefined) call["function"]["name"] = replacement;
137
- }
138
- }
139
- }
140
- const choice = payload["tool_choice"];
141
- if (!isRecord(choice)) return;
142
- if (choice["type"] === "allowed_tools" && Array.isArray(choice["tools"])) {
143
- for (const rawAllowed of choice["tools"]) {
144
- if (!isRecord(rawAllowed)) continue;
145
- const allowedFn = isRecord(rawAllowed["function"]) ? rawAllowed["function"] : rawAllowed;
146
- const name = allowedFn["name"];
147
- const replacement = typeof name === "string" ? replacements.get(name) : undefined;
148
- if (replacement !== undefined) allowedFn["name"] = replacement;
149
- }
150
- return;
151
- }
152
- const choiceFn = isRecord(choice["function"]) ? choice["function"] : undefined;
153
- const choiceCustom = isRecord(choice["custom"]) ? choice["custom"] : undefined;
154
- const choiceName = choiceFn?.["name"] ?? choiceCustom?.["name"] ?? choice["name"];
155
- if (typeof choiceName !== "string") return;
156
- const replacement = replacements.get(choiceName);
157
- if (replacement === undefined) return;
158
- if (choiceFn !== undefined) choice["function"] = { ...choiceFn, name: replacement };
159
- else if (choiceCustom !== undefined) choice["custom"] = { ...choiceCustom, name: replacement };
160
- else choice["name"] = replacement;
161
- }
162
-
163
-
164
- /**
165
- * The upstream id `auto` resolves to, per realm.
166
- *
167
- * Both realms have a "let the backend choose" id, and the reference rewrites
168
- * `auto` onto it before anything downstream keys on the name — so eligibility,
169
- * cooldowns and the catalog all see one id. CN's is `default`; intl's is
170
- * `default-model`.
171
- */
172
- const BUDDY_AUTO_TARGET: Readonly<Record<"intl" | "cn", string>> = {
173
- intl: "auto",
174
- cn: "default",
175
- };
176
-
177
- /**
178
- * Rewrites `auto` onto the realm's backend-selected id.
179
- *
180
- * Only a bare, exact `auto` is rewritten (after trimming): a provider-qualified
181
- * name or anything carrying a prefix is a different request and is left alone.
182
- */
183
- function resolveBuddyAutoModel(payload: Record<string, unknown>, realm: "intl" | "cn"): void {
184
- const value = payload["model"];
185
- if (typeof value !== "string") return;
186
- if (value.trim().toLowerCase() !== "auto") return;
187
- payload["model"] = BUDDY_AUTO_TARGET[realm];
188
- }
189
-
190
- /**
191
- * Account-isolated prompt-cache key, `wb2a-<uid8>-<convHex>`.
192
- *
193
- * A client-supplied key is kept: the caller already knows which prefix it wants
194
- * reused. Otherwise the key is derived here, and the uid segment is a hard
195
- * isolation factor. The same conversation id on two pooled accounts must not
196
- * produce the same key — upstream prefix-cache reuse across accounts both
197
- * leaks one account's conversation into another's cache and bills the wrong
198
- * account for the hit. An empty conversation still hashes the uid alone, so
199
- * two accounts with no session still never collide.
200
- *
201
- * The generic router only forwards a client key. This stays in the buddy hook
202
- * because the `wb2a-` shape and the uid isolation are a buddy-family fact.
203
- */
204
- function injectBuddyPromptCacheKey(
205
- payload: Record<string, unknown>,
206
- request: CanonicalRequest | undefined,
207
- context: ProviderDispatchContext | undefined,
208
- ): void {
209
- const existing = payload["prompt_cache_key"];
210
- if (typeof existing === "string" && existing.trim().length > 0) return;
211
- const uid = buddyCacheUid(context);
212
- const conversation = buddyCacheConversation(payload, request, context);
213
- payload["prompt_cache_key"] = buildBuddyPromptCacheKey(uid, conversation);
214
- }
215
-
216
- /**
217
- * The account uid from the access token, or `""` when the credential carries
218
- * none. Empty still injects a key — the readable segment becomes `"-"` — which
219
- * is the reference's own fallback for a credential with no identity.
220
- */
221
- function buddyCacheUid(context: ProviderDispatchContext | undefined): string {
222
- const secret = context?.credential?.secret;
223
- if (secret === undefined || secret.byteLength === 0) return "";
224
- const token = new TextDecoder().decode(secret).trim();
225
- if (token.length === 0) return "";
226
- return buddyAccountUid(token) ?? "";
227
- }
228
-
229
- /**
230
- * Conversation hash source: the body's own id first, then the inbound session
231
- * the gateway already resolved. A fabricated id is never invented — an empty
232
- * source hashes the uid alone, so a new session does not reuse another
233
- * session's prefix while accounts still never collide.
234
- */
235
- function buddyCacheConversation(
236
- payload: Record<string, unknown>,
237
- request: CanonicalRequest | undefined,
238
- context: ProviderDispatchContext | undefined,
239
- ): string {
240
- for (const field of ["conversation_id", "conversationId"] as const) {
241
- const value = payload[field];
242
- if (typeof value === "string" && value.trim().length > 0) return value.trim();
243
- }
244
- const fromRequest = request?.conversation?.conversation_id;
245
- if (typeof fromRequest === "string" && fromRequest.trim().length > 0) return fromRequest.trim();
246
- const header = context?.request_headers?.["x-conversation-id"]?.trim();
247
- return header && header.length > 0 ? header : "";
248
- }
249
-
250
- /**
251
- * `wb2a-<uid8>-<sha256(uid|conversation) hex[:32]>`.
252
- *
253
- * `uid8` is the isolation segment a human can read; the hash covers the full
254
- * uid, so two accounts sharing a prefix still never collide. Stable for the
255
- * same account and conversation, different for any other pair.
256
- */
257
- export function buildBuddyPromptCacheKey(uid: string, conversation: string): string {
258
- const uid8 = uid.length > 8 ? uid.slice(0, 8) : uid.length > 0 ? uid : "-";
259
- const digest = createHash("sha256").update(`${uid}|${conversation}`).digest("hex").slice(0, 32);
260
- return `wb2a-${uid8}-${digest}`;
261
- }
262
-
263
- /**
264
- * Rewrites the literal `11128` to `11-128` in outbound message text.
265
- *
266
- * The upstream blocks any request whose body contains that exact digit run —
267
- * it is the code of the block itself, and the check is a verbatim match, not a
268
- * semantic one. A client that quotes the error (a retry, a pasted log, a tool
269
- * result) is refused on the next turn for mentioning it. The hyphen keeps the
270
- * number readable and breaks the match. Adjacent codes (`11148`, `11101`) are
271
- * left alone: only this one is the tripwire.
272
- *
273
- * Only text is touched. Image parts and tool-call argument structure are not
274
- * rewritten, because a digit run inside a base64 blob or a JSON key is not
275
- * what the check reads and rewriting it would corrupt the payload.
276
- */
277
- function rewriteBuddyBlockedCode(payload: Record<string, unknown>): void {
278
- const messages = payload["messages"];
279
- if (!Array.isArray(messages)) return;
280
- for (const message of messages) {
281
- if (!isRecord(message)) continue;
282
- rewriteBlockedCodeField(message, "content");
283
- rewriteBlockedCodeField(message, "reasoning_content");
284
- const calls = message["tool_calls"];
285
- if (!Array.isArray(calls)) continue;
286
- for (const call of calls) {
287
- if (!isRecord(call)) continue;
288
- const fn = call["function"];
289
- if (isRecord(fn) && typeof fn["arguments"] === "string") {
290
- fn["arguments"] = fn["arguments"].split("11128").join("11-128");
291
- }
292
- }
293
- }
294
- }
295
-
296
- function rewriteBlockedCodeField(message: Record<string, unknown>, field: string): void {
297
- const value = message[field];
298
- if (typeof value === "string") {
299
- if (value.includes("11128")) message[field] = value.split("11128").join("11-128");
300
- return;
301
- }
302
- if (!Array.isArray(value)) return;
303
- for (const part of value) {
304
- if (!isRecord(part)) continue;
305
- const text = part["text"];
306
- if (typeof text === "string" && text.includes("11128")) {
307
- part["text"] = text.split("11128").join("11-128");
308
- }
309
- }
310
- }
311
-
312
- /**
313
- * Payload prefix shared by the buddy pre-payload hooks: force `stream=true`,
314
- * apply the provider's DeepSeek thinking contract, opt `reasoning_summary` in
315
- * only when an effort is requested, and drop agent-namespace fields.
316
- */
317
- export function buddyPrePayloadCommon(
318
- payload: Record<string, unknown>,
319
- request?: CanonicalRequest,
320
- realm: "intl" | "cn" = "intl",
321
- context?: ProviderDispatchContext,
322
- ): void {
323
- injectBuddyPromptCacheKey(payload, request, context);
324
- // `auto` and `default-model` are the same upstream model under two names, and
325
- // the reference resolves one to the other everywhere — catalog, eligibility,
326
- // cooldown keys. Treating them as two independent rows meant a client that
327
- // sent `auto` got a different (account, model) cooldown key from a client that
328
- // sent `default-model` for the same backend model, so one could be cooling
329
- // while the other dialed it.
330
- //
331
- // Resolved here rather than in the generic router because it is a buddy-family
332
- // fact: `auto` is a real model name on other providers.
333
- resolveBuddyAutoModel(payload, realm);
334
- // Canonical upstream renames: max_completion_tokens is OpenAI compat alias, buddy only reads max_tokens
335
- if (payload["max_tokens"] === undefined && payload["max_completion_tokens"] !== undefined) {
336
- const v = payload["max_completion_tokens"];
337
- if (typeof v === "number" && Number.isFinite(v) && v > 0) payload["max_tokens"] = v;
338
- delete payload["max_completion_tokens"];
339
- } else if (payload["max_completion_tokens"] !== undefined) {
340
- delete payload["max_completion_tokens"];
341
- }
342
- // tool_choice: buddy Go struct is string — object form 400s (code 11101). Normalize to wire string.
343
- normalizeBuddyToolChoice(payload);
344
- // image_url string → object (OpenAI allows string, buddy requires {url})
345
- normalizeBuddyImageUrl(payload);
346
- // developer role → system (buddy whitelist rejects developer, 11128)
347
- normalizeBuddyRoles(payload);
348
- applyOpenAIReasoning(payload, request);
349
- applyDeepSeekReasoning(payload);
350
- // sol family defaults to high reasoning like reference thinking.go defaultDeepSeekEffort — sol without effort gets 500/invoke error otherwise
351
- injectSolReasoningDefault(payload);
352
- payload["stream"] = true;
353
- const effort = payload["reasoning_effort"];
354
- if (effort === "none" || effort === "off") {
355
- delete payload["reasoning_effort"];
356
- delete payload["reasoning_summary"];
357
- } else if (typeof effort === "string" && effort.length > 0) {
358
- payload["reasoning_summary"] = "auto";
359
- } else {
360
- delete payload["reasoning_summary"];
361
- }
362
- backfillDeepSeekReasoningContent(payload);
363
- clampBuddyReasoningEffort(payload, realm);
364
- const messages = payload["messages"];
365
- if (Array.isArray(messages)) {
366
- const arr = messages as Array<Record<string, unknown>>;
367
- // Nameless calls 400 before anything else can be judged, and their results
368
- // must be orphans before the repack below tries to reorder them.
369
- dropBuddyEmptyNameToolCalls(arr);
370
- // Buddy wire: repack tool result blocks before cleanup (image_resize_notice splits tool results → 11148)
371
- repackBuddyToolResultBlocks(arr);
372
- cleanupBuddyOrphanToolPairs(arr);
373
- coalesceConsecutiveAssistantMessages(arr);
374
- // After every merge: a quoted `11128` in any surviving text blocks the
375
- // whole request, and the merges above are what produce the final text.
376
- rewriteBuddyBlockedCode(payload);
377
- }
378
- delete payload["agent"];
379
- delete payload["agent_mode"];
380
- delete payload["agent_prompt"];
381
- }
382
-
383
- function injectSolReasoningDefault(payload: Record<string, unknown>): void {
384
- const model = typeof payload["model"] === "string" ? (payload["model"] as string) : "";
385
- const isSol = model.toLowerCase().includes("sol");
386
- if (!isSol) return;
387
- if (payload["reasoning_effort"] !== undefined) return;
388
- const thinking = payload["thinking"] as Record<string, unknown> | undefined;
389
- if (thinking && typeof thinking["type"] === "string" && (thinking["type"] as string) !== "") return;
390
- payload["reasoning_effort"] = "high";
391
- }
392
-
393
- /**
394
- * Repack tool result blocks: move non-tool messages interleaved between
395
- * assistant tool_calls and its tool results to after the block.
396
- * Port of workbuddy2api repackToolResultBlocks — fixes 11148 tool_call_sequence_broken.
397
- */
398
- function repackBuddyToolResultBlocks(messages: Array<Record<string, unknown>>): void {
399
- if (messages.length < 3) return;
400
- const out: Array<Record<string, unknown>> = [];
401
- let changed = false;
402
- let i = 0;
403
- while (i < messages.length) {
404
- const m = messages[i] as Record<string, unknown>;
405
- if (!m || m["role"] !== "assistant" || !Array.isArray(m["tool_calls"]) || (m["tool_calls"] as unknown[]).length === 0) {
406
- out.push(m);
407
- i += 1;
408
- continue;
409
- }
410
- const want = new Set<string>();
411
- for (const tc of m["tool_calls"] as unknown[]) {
412
- const id = (tc as Record<string, unknown>)?.["id"];
413
- if (typeof id === "string" && id.length > 0) want.add(id);
414
- }
415
- out.push(m);
416
- i += 1;
417
- const results: Array<Record<string, unknown>> = [];
418
- const between: Array<Record<string, unknown>> = [];
419
- let sawNonTool = false;
420
- while (i < messages.length) {
421
- const mm = messages[i] as Record<string, unknown>;
422
- if (!mm || typeof mm !== "object") break;
423
- const role = mm["role"];
424
- if (role === "tool") {
425
- const id = mm["tool_call_id"];
426
- if (typeof id !== "string" || !want.has(id)) break;
427
- results.push(mm);
428
- if (sawNonTool) changed = true;
429
- i += 1;
430
- continue;
431
- }
432
- if (results.length === 0) break;
433
- between.push(mm);
434
- sawNonTool = true;
435
- i += 1;
436
- }
437
- out.push(...results);
438
- out.push(...between);
439
- if (between.length > 0) changed = true;
440
- }
441
- if (!changed) return;
442
- messages.length = 0;
443
- messages.push(...out);
444
- }
445
-
446
- /**
447
- * Drop assistant tool_calls whose function name is empty.
448
- *
449
- * A streamed call can lose its name and land in the client transcript as
450
- * `function.name: ""` (see `tool-identity.ts`: the client then replays it and
451
- * every later request is rejected with "`name` must be non-empty"). Buddy
452
- * reports this as `11133` with `param: ""`, naming no field, so the transcript
453
- * is the only place it can be caught.
454
- *
455
- * The name is only ever absent, never wrong: replaying the same body with the
456
- * name set to an identifier that is not even in `tools` returns 200, so the
457
- * upstream rejects the missing value rather than an unknown one. Dropping the
458
- * call is therefore not lossy — a nameless call cannot be dispatched anyway.
459
- * The matching tool result becomes an orphan and
460
- * `cleanupBuddyOrphanToolPairs` removes it in the same pass.
461
- */
462
- function dropBuddyEmptyNameToolCalls(messages: Array<Record<string, unknown>>): void {
463
- let changed = false;
464
- for (const message of messages) {
465
- if (message["role"] !== "assistant" || !Array.isArray(message["tool_calls"])) continue;
466
- const calls = message["tool_calls"] as unknown[];
467
- const kept = calls.filter((call) => {
468
- if (call === null || typeof call !== "object") return true;
469
- const fn = (call as Record<string, unknown>)["function"];
470
- if (fn === null || typeof fn !== "object") return true;
471
- const name = (fn as Record<string, unknown>)["name"];
472
- return typeof name === "string" && name.trim().length > 0;
473
- });
474
- if (kept.length === calls.length) continue;
475
- const { tool_calls: _dropped, ...rest } = message as Record<string, unknown> & { tool_calls: unknown };
476
- if (kept.length === 0) {
477
- messages[messages.indexOf(message)] = rest;
478
- } else {
479
- (message as Record<string, unknown>)["tool_calls"] = kept;
480
- }
481
- changed = true;
482
- }
483
- if (!changed) return;
484
- // A message left with neither content nor calls carries nothing upstream.
485
- for (let i = messages.length - 1; i >= 0; i -= 1) {
486
- const message = messages[i];
487
- if (message === undefined || message["role"] !== "assistant") continue;
488
- const hasCalls = Array.isArray(message["tool_calls"]) && (message["tool_calls"] as unknown[]).length > 0;
489
- const content = message["content"];
490
- const hasContent = (typeof content === "string" && content.trim().length > 0) || Array.isArray(content);
491
- if (!hasCalls && !hasContent) messages.splice(i, 1);
492
- }
493
- }
494
-
495
- /**
496
- * Remove orphan tool_calls / tool results that lack a pair — port of
497
- * workbuddy2api cleanupOrphanToolCalls. Lets a broken history self-heal
498
- * instead of 400ing every subsequent request.
499
- */
500
- function cleanupBuddyOrphanToolPairs(messages: Array<Record<string, unknown>>): void {
501
- const toolResultIds = new Set<string>();
502
- const toolCallIds = new Set<string>();
503
- for (const m of messages) {
504
- if (m["role"] === "tool" && typeof m["tool_call_id"] === "string") toolResultIds.add(m["tool_call_id"] as string);
505
- if (m["role"] === "assistant" && Array.isArray(m["tool_calls"])) {
506
- for (const tc of m["tool_calls"] as unknown[]) {
507
- const id = (tc as Record<string, unknown>)?.["id"];
508
- if (typeof id === "string") toolCallIds.add(id);
509
- }
510
- }
511
- }
512
- if (toolResultIds.size === 0 && toolCallIds.size === 0) return;
513
- const out: Array<Record<string, unknown>> = [];
514
- let changed = false;
515
- for (const m of messages) {
516
- if (m["role"] === "assistant" && Array.isArray(m["tool_calls"])) {
517
- const kept = (m["tool_calls"] as unknown[]).filter((tc) => {
518
- const id = (tc as Record<string, unknown>)?.["id"];
519
- return typeof id === "string" && toolResultIds.has(id);
520
- });
521
- if (kept.length === 0 && toolCallIds.size > 0 && (m["tool_calls"] as unknown[]).length > 0) {
522
- // Drop empty tool_calls key — keep message without it
523
- const { tool_calls: _drop, ...rest } = m as Record<string, unknown> & { tool_calls: unknown };
524
- if (Object.keys(kept).length === 0) changed = true;
525
- out.push(rest as Record<string, unknown>);
526
- if (kept.length !== (m["tool_calls"] as unknown[]).length) changed = true;
527
- continue;
528
- }
529
- if (kept.length !== (m["tool_calls"] as unknown[]).length) {
530
- changed = true;
531
- out.push({ ...m, tool_calls: kept });
532
- continue;
533
- }
534
- }
535
- if (m["role"] === "tool") {
536
- const id = m["tool_call_id"];
537
- if (typeof id === "string" && !toolCallIds.has(id as string)) {
538
- changed = true;
539
- continue;
540
- }
541
- }
542
- out.push(m);
543
- }
544
- if (!changed) return;
545
- messages.length = 0;
546
- messages.push(...out);
547
- }
548
-
549
-
550
-
551
- const BUDDY_EFFORT_RANK: Readonly<Record<string, number>> = {
552
- off: 0,
553
- none: 0,
554
- minimal: 1,
555
- low: 2,
556
- medium: 3,
557
- high: 4,
558
- xhigh: 5,
559
- max: 6,
560
- };
561
-
562
- /**
563
- * Clamps an effort the model's ladder does not serve to the nearest served
564
- * level, and clamps a `max_tokens` above the catalog output limit down to it.
565
- *
566
- * Both are repaired here rather than refused. A client that asked for `max`
567
- * on a model that serves only `high` wants an answer, and the gateway is the
568
- * place that knows the ladder — refusing with 400 makes the client retry a
569
- * request the gateway could have served. The output budget is lifted when the
570
- * effort was raised, because a higher level spends part of the same budget on
571
- * reasoning and a limit sized for the lower one truncates the answer.
572
- *
573
- * A model with no ladder is left alone. Absence means the levels have not
574
- * been measured, and clamping it would invent a constraint.
575
- */
576
- function clampBuddyReasoningEffort(payload: Record<string, unknown>, realm: "intl" | "cn"): void {
577
- const model = typeof payload["model"] === "string" ? payload["model"].trim().toLowerCase() : "";
578
- const table = realm === "cn" ? BUDDY_CN_EFFORTS : BUDDY_INTL_EFFORTS;
579
- const supported = model.length > 0 ? table[model] : undefined;
580
- const key =
581
- "reasoning_effort" in payload
582
- ? "reasoning_effort"
583
- : "reasoningEffort" in payload
584
- ? "reasoningEffort"
585
- : undefined;
586
- if (key !== undefined && supported !== undefined) {
587
- const requested = payload[key];
588
- if (typeof requested === "string") {
589
- const normalized = requested.trim().toLowerCase();
590
- const requestedRank = BUDDY_EFFORT_RANK[normalized];
591
- if (
592
- requestedRank !== undefined &&
593
- !supported.some((effort) => effort.toLowerCase() === normalized)
594
- ) {
595
- const ranked = supported
596
- .map((effort) => ({ effort, rank: BUDDY_EFFORT_RANK[effort.toLowerCase()] }))
597
- .filter((entry): entry is { effort: string; rank: number } => entry.rank !== undefined);
598
- const below = ranked.filter((entry) => entry.rank <= requestedRank);
599
- const chosen =
600
- below.length > 0
601
- ? below.reduce((best, entry) => (entry.rank > best.rank ? entry : best))
602
- : ranked.reduce((best, entry) => (entry.rank < best.rank ? entry : best));
603
- payload[key] = chosen.effort;
604
- raiseOutputForRaisedEffort(payload, normalized, chosen.effort.trim().toLowerCase());
605
- }
606
- }
607
- }
608
- const limit = model.length > 0 ? buddyModelOutputLimit(model) : undefined;
609
- const maxTokens = payload["max_tokens"];
610
- if (
611
- limit !== undefined &&
612
- typeof maxTokens === "number" &&
613
- Number.isFinite(maxTokens) &&
614
- maxTokens > limit
615
- ) {
616
- payload["max_tokens"] = limit;
617
- }
618
- }
619
-
620
- /**
621
- * Lifts `max_tokens` when the ladder raised the caller's effort.
622
- *
623
- * A higher level spends part of the same output budget on reasoning, so a
624
- * limit sized for the lower one can no longer hold the answer. The floor is
625
- * half the model's catalog output limit, and a budget already above it is
626
- * left alone.
627
- */
628
- function raiseOutputForRaisedEffort(
629
- payload: Record<string, unknown>,
630
- requested: string,
631
- applied: string,
632
- ): void {
633
- const model = typeof payload["model"] === "string" ? payload["model"].trim().toLowerCase() : "";
634
- if (model.length === 0) return;
635
- const floor = clampedEffortOutputFloor(requested, applied, buddyModelOutputLimit(model));
636
- if (floor === undefined) return;
637
- const current = payload["max_tokens"];
638
- if (typeof current === "number" && Number.isFinite(current) && current >= floor) return;
639
- payload["max_tokens"] = floor;
640
- }
641
-
642
- /**
643
- * The catalog output limit for one buddy model id, when a catalog names it.
644
- *
645
- * Both rosters are searched: CN-only ids (glm-5.1, hy4-preview, the CN
646
- * DeepSeek rows) exist only in the CN table, and searching the shared one
647
- * alone left every CN model without a limit.
648
- */
649
- function buddyModelOutputLimit(model: string): number | undefined {
650
- const row =
651
- BUDDY_SHARED_RAW.find((entry) => entry[0]?.toLowerCase() === model) ??
652
- CODEBUDDY_CN_RAW.find((entry) => entry[0]?.toLowerCase() === model);
653
- const out = row?.[5];
654
- return typeof out === "number" && Number.isFinite(out) && out > 0 ? out : undefined;
655
- }
656
-
657
- function normalizeBuddyToolChoice(payload: Record<string, unknown>): void {
658
- if (!("tool_choice" in payload)) return;
659
- const tc = payload["tool_choice"];
660
- if (typeof tc === "string") {
661
- // `none` means "do not call this turn", not "forget the tool schemas".
662
- // Deleting tools makes DeepSeek emit unparseable DSML text instead.
663
- if (tc.trim().toLowerCase() === "none") payload["tool_choice"] = "none";
664
- return;
665
- }
666
- if (tc && typeof tc === "object" && !Array.isArray(tc)) {
667
- const obj = tc as Record<string, unknown>;
668
- const typ = typeof obj["type"] === "string" ? (obj["type"] as string).trim().toLowerCase() : "";
669
- if (typ === "none") {
670
- payload["tool_choice"] = "none";
671
- } else if (typ === "auto" || typ === "required") {
672
- payload["tool_choice"] = typ;
673
- } else if (typ === "function") {
674
- let name = "";
675
- const fn = obj["function"];
676
- if (fn && typeof fn === "object" && !Array.isArray(fn)) name = typeof (fn as Record<string, unknown>)["name"] === "string" ? ((fn as Record<string, unknown>)["name"] as string) : "";
677
- if (!name && typeof obj["name"] === "string") name = obj["name"] as string;
678
- name = name.trim();
679
- payload["tool_choice"] = name.length > 0 ? name : "auto";
680
- } else {
681
- delete payload["tool_choice"];
682
- }
683
- return;
684
- }
685
- delete payload["tool_choice"];
686
- }
687
-
688
- /** Images kept in one outbound body. The upstream rejects a larger set. */
689
- const BUDDY_MAX_IMAGES = 16;
690
- const BUDDY_IMAGE_OMITTED = "[Image omitted by request image limit.]";
691
-
692
- function normalizeBuddyImageUrl(payload: Record<string, unknown>): void {
693
- const messages = payload["messages"];
694
- if (!Array.isArray(messages)) return;
695
- let kept = 0;
696
- for (const m of messages) {
697
- if (!m || typeof m !== "object") continue;
698
- const content = (m as Record<string, unknown>)["content"];
699
- if (!Array.isArray(content)) continue;
700
- for (let index = 0; index < content.length; index += 1) {
701
- const part = content[index];
702
- if (!part || typeof part !== "object") continue;
703
- const p = part as Record<string, unknown>;
704
- if (p["type"] !== "image_url") continue;
705
- const v = p["image_url"];
706
- if (typeof v === "string") {
707
- if (v.length === 0) continue;
708
- p["image_url"] = { url: v };
709
- }
710
- kept += 1;
711
- if (kept > BUDDY_MAX_IMAGES) {
712
- content[index] = { type: "text", text: BUDDY_IMAGE_OMITTED };
713
- }
714
- }
715
- }
716
- }
717
-
718
- function normalizeBuddyRoles(payload: Record<string, unknown>): void {
719
- const messages = payload["messages"];
720
- if (!Array.isArray(messages)) return;
721
- for (const m of messages) {
722
- if (!m || typeof m !== "object") continue;
723
- const rec = m as Record<string, unknown>;
724
- if (rec["role"] === "developer") rec["role"] = "system";
725
- }
726
- }
727
-
728
- /** Normalizes one message's content into a Chat-wire content-part array. */
729
- function contentParts(content: unknown): Array<Record<string, unknown>> {
730
- if (typeof content === "string")
731
- return content.length === 0 ? [] : [{ type: "text", text: content }];
732
- if (!Array.isArray(content)) return [];
733
- return content.filter(
734
- (part): part is Record<string, unknown> => part !== null && typeof part === "object",
735
- );
736
- }
737
-
738
- /**
739
- * Coalesces consecutive `user` messages into one, preserving content-part
740
- * order. The buddy upstream merges consecutive same-role turns itself and
741
- * drops `image_url` parts while doing so, so an attachment delivered as its
742
- * own user turn (the shape VS Code Copilot emits for a pasted screenshot)
743
- * reaches the model as text only — the image is silently gone even though the
744
- * gateway forwarded it. Merging here keeps every image in the same message as
745
- * the text the caller sent alongside it.
746
- *
747
- * Lossless: only adjacent same-role `user` turns merge, and every part is
748
- * retained in order. Assistant/tool/system turns are untouched.
749
- */
750
- export function coalesceConsecutiveUserMessages(messages: Array<Record<string, unknown>>): void {
751
- const out: Array<Record<string, unknown>> = [];
752
- for (const message of messages) {
753
- const previous = out[out.length - 1];
754
- if (message["role"] !== "user" || previous?.["role"] !== "user") {
755
- out.push(message);
756
- continue;
757
- }
758
- out[out.length - 1] = {
759
- ...previous,
760
- content: [...contentParts(previous["content"]), ...contentParts(message["content"])],
761
- };
762
- }
763
- messages.length = 0;
764
- messages.push(...out);
765
- }
766
-
767
- /**
768
- * Responses input can split one assistant turn into several adjacent items
769
- * (text, reasoning, and parallel function calls). Chat wire needs a single
770
- * assistant message so the reasoning trace accompanies every call in the
771
- * batch; tool results or user turns always end the batch.
772
- */
773
- function coalesceConsecutiveAssistantMessages(messages: Array<Record<string, unknown>>): void {
774
- let write = 0;
775
- for (const message of messages) {
776
- const previous = messages[write - 1];
777
- if (message["role"] !== "assistant" || previous?.["role"] !== "assistant") {
778
- messages[write++] = message;
779
- continue;
780
- }
781
- const firstContent = previous["content"];
782
- const nextContent = message["content"];
783
- let content: unknown = firstContent ?? nextContent;
784
- if (firstContent != null && nextContent != null) {
785
- if (typeof firstContent === "string" && typeof nextContent === "string") {
786
- if (firstContent.length === 0) content = nextContent;
787
- else if (nextContent.length === 0) content = firstContent;
788
- else content = `${firstContent}\n${nextContent}`;
789
- } else content = [...contentParts(firstContent), ...contentParts(nextContent)];
790
- }
791
- const firstCalls = previous["tool_calls"];
792
- const nextCalls = message["tool_calls"];
793
- const firstReasoning = previous["reasoning_content"];
794
- const nextReasoning = message["reasoning_content"];
795
- const merged: Record<string, unknown> = { ...previous, ...message, content };
796
- if (Array.isArray(firstCalls) || Array.isArray(nextCalls))
797
- merged["tool_calls"] = [
798
- ...(Array.isArray(firstCalls) ? firstCalls : []),
799
- ...(Array.isArray(nextCalls) ? nextCalls : []),
800
- ];
801
- if (typeof firstReasoning === "string" && typeof nextReasoning === "string") {
802
- if (firstReasoning.length === 0) merged["reasoning_content"] = nextReasoning;
803
- else if (nextReasoning.length === 0) merged["reasoning_content"] = firstReasoning;
804
- else merged["reasoning_content"] = `${firstReasoning}\n${nextReasoning}`;
805
- } else if (typeof firstReasoning === "string" || typeof nextReasoning === "string") {
806
- merged["reasoning_content"] = firstReasoning ?? nextReasoning;
807
- }
808
- messages[write - 1] = merged;
809
- }
810
- messages.length = write;
811
- }
812
-
813
- /**
814
- * Drops messages whose content is empty, mirroring the buddy upstream's own
815
- * validation (empty-content turns are rejected with code `11151`). Guarded:
816
- * anything carrying tool calls, tool results, or reasoning is kept — an
817
- * assistant `tool_calls` turn with null content and a ledger-folded result
818
- * text are both load-bearing, not empty.
819
- */
820
- export function dropEmptyBuddyMessages(messages: Array<Record<string, unknown>>): void {
821
- const kept = messages.filter((message) => {
822
- if (!isRecord(message)) return true;
823
- if (Array.isArray(message["tool_calls"]) && message["tool_calls"].length > 0) return true;
824
- if (typeof message["tool_call_id"] === "string" && message["tool_call_id"].trim().length > 0)
825
- return true;
826
- if (typeof message["reasoning_content"] === "string") return true;
827
- const content = message["content"];
828
- if (typeof content === "string") return content.trim().length > 0;
829
- if (Array.isArray(content)) return content.length > 0;
830
- // Null content with no tool/reasoning load is a bare turn the upstream
831
- // rejects — drop it rather than dispatch a known-invalid payload.
832
- return false;
833
- });
834
- if (kept.length === messages.length) return;
835
- messages.length = 0;
836
- messages.push(...kept);
837
- }
838
-
839
- /**
840
- * Guarantees the wire opens with a `system` turn. The buddy upstream rejects a
841
- * first message that is not a system prompt (code `11128`), and a CN-neutralized
842
- * or caller-less history can otherwise arrive with a bare `user` turn first.
843
- * Non-empty by construction: an empty system turn trips the sibling `11151`
844
- * validation above, so the placeholder carries real content.
845
- */
846
- export function ensureBuddyLeadingSystem(
847
- messages: Array<Record<string, unknown>>,
848
- systemPrompt: string,
849
- ): void {
850
- if (messages.length === 0) {
851
- messages.push({ role: "system", content: systemPrompt });
852
- return;
853
- }
854
- const first = messages[0];
855
- if (isRecord(first) && first["role"] === "system") return;
856
- messages.unshift({ role: "system", content: systemPrompt });
857
- }
858
-
859
- /**
860
- * Runs the full buddy message-envelope tail shared by every variant: merge
861
- * consecutive user turns, drop empty-content turns the upstream rejects
862
- * (`11151`), then guarantee a leading system turn (`11128`). The system text
863
- * is the variant's own prompt, so each pre-payload hook passes its prompt in.
864
- */
865
- function finalizeBuddyMessages(
866
- messages: Array<Record<string, unknown>>,
867
- systemPrompt: string,
868
- ): void {
869
- coalesceConsecutiveUserMessages(messages);
870
- dropEmptyBuddyMessages(messages);
871
- ensureBuddyLeadingSystem(messages, systemPrompt);
872
- }
873
-
874
- /**
875
- * Applies the buddy-family message envelope in place: drop caller
876
- * `system`/`developer` turns, install the variant's fixed leading system
877
- * prompt, rebuild bare string user content as a typed text block, then
878
- * coalesce consecutive user turns.
879
- *
880
- * The upstream gateway expects that fixed leading prompt — and validates its
881
- * text, not merely the presence of a `system` turn. Substituting caller text
882
- * there answers `400 · 11128 "Illegal API invocation from an unapproved
883
- * channel"` on CodeBuddy, which reads the prompt as the calling channel's
884
- * identity; forwarding caller text as a `developer` turn is rejected too. So
885
- * every variant that has one applies the same transformation — only the prompt
886
- * text differs, which is why the caller passes it in. Variants without a fixed
887
- * prompt (CodeBuddy CN replaces caller system text with a neutralizer instead)
888
- * keep their own path and share only `coalesceConsecutiveUserMessages`.
889
- *
890
- * Tool call/output pairing is intentionally NOT handled here: the shared
891
- * canonical repair (`repairRequestToolCalls`, request/preparer) owns it for
892
- * every route, so this stays a system-prompt and content-shape hook.
893
- */
894
- export function applyBuddySystemPrompt(
895
- messages: Array<Record<string, unknown>>,
896
- systemPrompt: string,
897
- ): void {
898
- const source = messages.filter(
899
- (message) => message["role"] !== "system" && message["role"] !== "developer",
900
- );
901
- messages.length = 0;
902
- messages.push({ role: "system", content: systemPrompt });
903
- for (const message of source) {
904
- if (message["role"] === "user" && typeof message["content"] === "string") {
905
- messages.push({
906
- ...message,
907
- content: [{ type: "text", text: message["content"] }],
908
- });
909
- } else {
910
- messages.push({ ...message });
911
- }
912
- }
913
- finalizeBuddyMessages(messages, systemPrompt);
914
- }
1
+ /**
2
+ * Chat-wire payload and message normalization shared by the Tencent "buddy"
3
+ * family providers — CodeBuddy International (`cb`), CodeBuddy CN (`cbcn`),
4
+ * and WorkBuddy. All three speak the same upstream contract for this part of
5
+ * the payload, so the rules live here once instead of being copied per
6
+ * variant:
7
+ *
8
+ * - streaming is mandatory (`stream=true`);
9
+ * - `reasoning_summary` is opted in only when a `reasoning_effort` is
10
+ * requested;
11
+ * - the agent-namespace fields are never forwarded;
12
+ * - caller `system`/`developer` turns are replaced by the variant's fixed
13
+ * leading system prompt, and bare string user content becomes a typed text
14
+ * block; and
15
+ * - consecutive same-role `user` turns must be merged before dispatch.
16
+ *
17
+ * Provider-specific identity headers and catalog tables stay in each variant's
18
+ * own `*-shared.ts`.
19
+ */
20
+ import { createHash } from "node:crypto";
21
+ import { completeRequiredSchema } from "../../../protocol/codecs";
22
+ import type { CanonicalRequest } from "../../../protocol/canonical-model";
23
+ import { isRecord } from "../../../protocol/codecs";
24
+ import { applyDeepSeekReasoning, applyOpenAIReasoning, backfillDeepSeekReasoningContent, clampedEffortOutputFloor } from "../../provider-reasoning";
25
+ import { BUDDY_CN_EFFORTS, BUDDY_INTL_EFFORTS, BUDDY_SHARED_RAW } from "./buddy-catalog-shared";
26
+ // Imported for the CN roster only: a CN-only id has no row in the shared
27
+ // table, and without one `raiseOutputForRaisedEffort` silently no-ops, so a
28
+ // raised effort still answers inside a budget sized for the lower one. It is
29
+ // read inside the function below — never at module init — so the cycle with
30
+ // cn-provider (which imports this module) resolves before any call.
31
+ import { CODEBUDDY_CN_RAW } from "./codebuddy/cn-provider";
32
+ import { buddyAccountUid } from "./buddy-oauth-shared";
33
+ import type { ProviderDispatchContext } from "../../provider-contracts";
34
+
35
+ const BUDDY_TOOL_NAME_PATTERN = /^[a-zA-Z0-9_-]+$/;
36
+ const INVALID_BUDDY_TOOL_NAME_CHARS = /[^a-zA-Z0-9_-]/g;
37
+ const MAX_BUDDY_TOOL_NAME_LENGTH = 64;
38
+
39
+ function uniqueToolName(base: string, used: Set<string>): string {
40
+ const clipped = (base || "tool").slice(0, MAX_BUDDY_TOOL_NAME_LENGTH);
41
+ if (!used.has(clipped)) return clipped;
42
+ for (let suffix = 2; ; suffix += 1) {
43
+ const suffixText = `_${suffix}`;
44
+ const candidate = `${clipped.slice(0, MAX_BUDDY_TOOL_NAME_LENGTH - suffixText.length)}${suffixText}`;
45
+ if (!used.has(candidate)) return candidate;
46
+ }
47
+ }
48
+
49
+ function normalizedToolName(value: string, index: number, used: Set<string>): string {
50
+ const base =
51
+ BUDDY_TOOL_NAME_PATTERN.test(value) && value.length <= MAX_BUDDY_TOOL_NAME_LENGTH
52
+ ? value
53
+ : value.replace(INVALID_BUDDY_TOOL_NAME_CHARS, "");
54
+ return uniqueToolName(base || `tool_${index + 1}`, used);
55
+ }
56
+
57
+ /**
58
+ * Buddy's Chat Completions wire has no `web_search_preview` hosted tool; its
59
+ * search is an ordinary function named `web_search` with a `{query}` schema.
60
+ * Rewriting the hosted declaration here lets the caller's own tool loop
61
+ * (Model Lab, or any client that executes its functions) run the query at
62
+ * `POST /v1/search`. The gateway never executes it: an unexecuted call is
63
+ * returned to the caller like any other function call.
64
+ */
65
+ function buddyHostedSearchFunction(rawTool: Record<string, unknown>): Record<string, unknown> | undefined {
66
+ const type = rawTool["type"];
67
+ if (type !== "web_search" && type !== "web_search_preview") return undefined;
68
+ return {
69
+ type: "function",
70
+ function: {
71
+ name: "web_search",
72
+ description: "Search the web for current information.",
73
+ parameters: {
74
+ type: "object",
75
+ properties: {
76
+ query: { type: "string", description: "The search query." },
77
+ },
78
+ required: ["query"],
79
+ additionalProperties: false,
80
+ },
81
+ },
82
+ };
83
+ }
84
+
85
+
86
+ export function normalizeBuddyToolNames(payload: Record<string, unknown>): void {
87
+ if (!Array.isArray(payload["tools"])) return;
88
+ const used = new Set<string>();
89
+ const replacements = new Map<string, string>();
90
+ payload["tools"] = payload["tools"].map((rawTool, index) => {
91
+ if (!isRecord(rawTool)) return rawTool;
92
+ const hostedSearch = buddyHostedSearchFunction(rawTool);
93
+ if (hostedSearch !== undefined) {
94
+ used.add("web_search");
95
+ return hostedSearch;
96
+ }
97
+ const fn = isRecord(rawTool["function"]) ? rawTool["function"] : undefined;
98
+ const custom = isRecord(rawTool["custom"]) ? rawTool["custom"] : undefined;
99
+ const sourceName = fn?.["name"] ?? custom?.["name"] ?? rawTool["name"];
100
+ if (typeof sourceName !== "string") return rawTool;
101
+ const name = normalizedToolName(sourceName, index, used);
102
+ used.add(name);
103
+ if (!replacements.has(sourceName)) replacements.set(sourceName, name);
104
+ if (fn !== undefined)
105
+ return {
106
+ ...rawTool,
107
+ function: {
108
+ ...fn,
109
+ name,
110
+ ...(fn["parameters"] === undefined ? {} : { parameters: completeRequiredSchema(fn["parameters"]) }),
111
+ },
112
+ };
113
+ if (custom !== undefined)
114
+ return {
115
+ ...rawTool,
116
+ custom: {
117
+ ...custom,
118
+ name,
119
+ ...(custom["format"] === undefined ? {} : { format: completeRequiredSchema(custom["format"]) }),
120
+ },
121
+ };
122
+ return {
123
+ ...rawTool,
124
+ name,
125
+ ...(rawTool["parameters"] === undefined ? {} : { parameters: completeRequiredSchema(rawTool["parameters"]) }),
126
+ };
127
+ });
128
+ const messages = payload["messages"];
129
+ if (Array.isArray(messages)) {
130
+ for (const message of messages) {
131
+ if (!isRecord(message) || !Array.isArray(message["tool_calls"])) continue;
132
+ for (const call of message["tool_calls"]) {
133
+ if (!isRecord(call) || !isRecord(call["function"])) continue;
134
+ const name = call["function"]["name"];
135
+ const replacement = typeof name === "string" ? replacements.get(name) : undefined;
136
+ if (replacement !== undefined) call["function"]["name"] = replacement;
137
+ }
138
+ }
139
+ }
140
+ const choice = payload["tool_choice"];
141
+ if (!isRecord(choice)) return;
142
+ if (choice["type"] === "allowed_tools" && Array.isArray(choice["tools"])) {
143
+ for (const rawAllowed of choice["tools"]) {
144
+ if (!isRecord(rawAllowed)) continue;
145
+ const allowedFn = isRecord(rawAllowed["function"]) ? rawAllowed["function"] : rawAllowed;
146
+ const name = allowedFn["name"];
147
+ const replacement = typeof name === "string" ? replacements.get(name) : undefined;
148
+ if (replacement !== undefined) allowedFn["name"] = replacement;
149
+ }
150
+ return;
151
+ }
152
+ const choiceFn = isRecord(choice["function"]) ? choice["function"] : undefined;
153
+ const choiceCustom = isRecord(choice["custom"]) ? choice["custom"] : undefined;
154
+ const choiceName = choiceFn?.["name"] ?? choiceCustom?.["name"] ?? choice["name"];
155
+ if (typeof choiceName !== "string") return;
156
+ const replacement = replacements.get(choiceName);
157
+ if (replacement === undefined) return;
158
+ if (choiceFn !== undefined) choice["function"] = { ...choiceFn, name: replacement };
159
+ else if (choiceCustom !== undefined) choice["custom"] = { ...choiceCustom, name: replacement };
160
+ else choice["name"] = replacement;
161
+ }
162
+
163
+
164
+ /**
165
+ * The upstream id `auto` resolves to, per realm.
166
+ *
167
+ * Both realms have a "let the backend choose" id, and the reference rewrites
168
+ * `auto` onto it before anything downstream keys on the name — so eligibility,
169
+ * cooldowns and the catalog all see one id. CN's is `default`; intl's is
170
+ * `default-model`.
171
+ */
172
+ const BUDDY_AUTO_TARGET: Readonly<Record<"intl" | "cn", string>> = {
173
+ intl: "auto",
174
+ cn: "default",
175
+ };
176
+
177
+ /**
178
+ * Rewrites `auto` onto the realm's backend-selected id.
179
+ *
180
+ * Only a bare, exact `auto` is rewritten (after trimming): a provider-qualified
181
+ * name or anything carrying a prefix is a different request and is left alone.
182
+ */
183
+ function resolveBuddyAutoModel(payload: Record<string, unknown>, realm: "intl" | "cn"): void {
184
+ const value = payload["model"];
185
+ if (typeof value !== "string") return;
186
+ if (value.trim().toLowerCase() !== "auto") return;
187
+ payload["model"] = BUDDY_AUTO_TARGET[realm];
188
+ }
189
+
190
+ /**
191
+ * Account-isolated prompt-cache key, `wb2a-<uid8>-<convHex>`.
192
+ *
193
+ * A client-supplied key is kept: the caller already knows which prefix it wants
194
+ * reused. Otherwise the key is derived here, and the uid segment is a hard
195
+ * isolation factor. The same conversation id on two pooled accounts must not
196
+ * produce the same key — upstream prefix-cache reuse across accounts both
197
+ * leaks one account's conversation into another's cache and bills the wrong
198
+ * account for the hit. An empty conversation still hashes the uid alone, so
199
+ * two accounts with no session still never collide.
200
+ *
201
+ * The generic router only forwards a client key. This stays in the buddy hook
202
+ * because the `wb2a-` shape and the uid isolation are a buddy-family fact.
203
+ */
204
+ function injectBuddyPromptCacheKey(
205
+ payload: Record<string, unknown>,
206
+ request: CanonicalRequest | undefined,
207
+ context: ProviderDispatchContext | undefined,
208
+ ): void {
209
+ const existing = payload["prompt_cache_key"];
210
+ if (typeof existing === "string" && existing.trim().length > 0) return;
211
+ const uid = buddyCacheUid(context);
212
+ const conversation = buddyCacheConversation(payload, request, context);
213
+ payload["prompt_cache_key"] = buildBuddyPromptCacheKey(uid, conversation);
214
+ }
215
+
216
+ /**
217
+ * The account uid from the access token, or `""` when the credential carries
218
+ * none. Empty still injects a key — the readable segment becomes `"-"` — which
219
+ * is the reference's own fallback for a credential with no identity.
220
+ */
221
+ function buddyCacheUid(context: ProviderDispatchContext | undefined): string {
222
+ const secret = context?.credential?.secret;
223
+ if (secret === undefined || secret.byteLength === 0) return "";
224
+ const token = new TextDecoder().decode(secret).trim();
225
+ if (token.length === 0) return "";
226
+ return buddyAccountUid(token) ?? "";
227
+ }
228
+
229
+ /**
230
+ * Conversation hash source: the body's own id first, then the inbound session
231
+ * the gateway already resolved. A fabricated id is never invented — an empty
232
+ * source hashes the uid alone, so a new session does not reuse another
233
+ * session's prefix while accounts still never collide.
234
+ */
235
+ function buddyCacheConversation(
236
+ payload: Record<string, unknown>,
237
+ request: CanonicalRequest | undefined,
238
+ context: ProviderDispatchContext | undefined,
239
+ ): string {
240
+ for (const field of ["conversation_id", "conversationId"] as const) {
241
+ const value = payload[field];
242
+ if (typeof value === "string" && value.trim().length > 0) return value.trim();
243
+ }
244
+ const fromRequest = request?.conversation?.conversation_id;
245
+ if (typeof fromRequest === "string" && fromRequest.trim().length > 0) return fromRequest.trim();
246
+ const header = context?.request_headers?.["x-conversation-id"]?.trim();
247
+ return header && header.length > 0 ? header : "";
248
+ }
249
+
250
+ /**
251
+ * `wb2a-<uid8>-<sha256(uid|conversation) hex[:32]>`.
252
+ *
253
+ * `uid8` is the isolation segment a human can read; the hash covers the full
254
+ * uid, so two accounts sharing a prefix still never collide. Stable for the
255
+ * same account and conversation, different for any other pair.
256
+ */
257
+ export function buildBuddyPromptCacheKey(uid: string, conversation: string): string {
258
+ const uid8 = uid.length > 8 ? uid.slice(0, 8) : uid.length > 0 ? uid : "-";
259
+ const digest = createHash("sha256").update(`${uid}|${conversation}`).digest("hex").slice(0, 32);
260
+ return `wb2a-${uid8}-${digest}`;
261
+ }
262
+
263
+ /**
264
+ * Rewrites the buddy channel-block code in outbound message text.
265
+ *
266
+ * The upstream blocks any request whose body contains that exact digit run —
267
+ * the check is a verbatim match on the code itself, not a semantic one. A client
268
+ * that quotes the error (a retry, a pasted log, a tool result) is refused on
269
+ * the next turn merely for mentioning it. Inserting a hyphen keeps the number
270
+ * readable and breaks the match.
271
+ *
272
+ * Only text is touched. Image parts and tool-call argument structure are not
273
+ * rewritten, because a digit run inside a base64 blob or a JSON key is not
274
+ * what the check reads and rewriting it would corrupt the payload.
275
+ */
276
+ function rewriteBuddyBlockedCode(payload: Record<string, unknown>): void {
277
+ const messages = payload["messages"];
278
+ if (!Array.isArray(messages)) return;
279
+ for (const message of messages) {
280
+ if (!isRecord(message)) continue;
281
+ rewriteBlockedCodeField(message, "content");
282
+ rewriteBlockedCodeField(message, "reasoning_content");
283
+ const calls = message["tool_calls"];
284
+ if (!Array.isArray(calls)) continue;
285
+ for (const call of calls) {
286
+ if (!isRecord(call)) continue;
287
+ const fn = call["function"];
288
+ if (isRecord(fn) && typeof fn["arguments"] === "string") {
289
+ fn["arguments"] = fn["arguments"].split("11128").join("11-128");
290
+ }
291
+ }
292
+ }
293
+ }
294
+
295
+ function rewriteBlockedCodeField(message: Record<string, unknown>, field: string): void {
296
+ const value = message[field];
297
+ if (typeof value === "string") {
298
+ if (value.includes("11128")) message[field] = value.split("11128").join("11-128");
299
+ return;
300
+ }
301
+ if (!Array.isArray(value)) return;
302
+ for (const part of value) {
303
+ if (!isRecord(part)) continue;
304
+ const text = part["text"];
305
+ if (typeof text === "string" && text.includes("11128")) {
306
+ part["text"] = text.split("11128").join("11-128");
307
+ }
308
+ }
309
+ }
310
+
311
+ /**
312
+ * Payload prefix shared by the buddy pre-payload hooks: force `stream=true`,
313
+ * apply the provider's DeepSeek thinking contract, opt `reasoning_summary` in
314
+ * only when an effort is requested, and drop agent-namespace fields.
315
+ */
316
+ export function buddyPrePayloadCommon(
317
+ payload: Record<string, unknown>,
318
+ request?: CanonicalRequest,
319
+ realm: "intl" | "cn" = "intl",
320
+ context?: ProviderDispatchContext,
321
+ ): void {
322
+ injectBuddyPromptCacheKey(payload, request, context);
323
+ // `auto` and `default-model` are the same upstream model under two names, and
324
+ // the reference resolves one to the other everywhere — catalog, eligibility,
325
+ // cooldown keys. Treating them as two independent rows meant a client that
326
+ // sent `auto` got a different (account, model) cooldown key from a client that
327
+ // sent `default-model` for the same backend model, so one could be cooling
328
+ // while the other dialed it.
329
+ //
330
+ // Resolved here rather than in the generic router because it is a buddy-family
331
+ // fact: `auto` is a real model name on other providers.
332
+ resolveBuddyAutoModel(payload, realm);
333
+ // Canonical upstream renames: max_completion_tokens is OpenAI compat alias, buddy only reads max_tokens
334
+ if (payload["max_tokens"] === undefined && payload["max_completion_tokens"] !== undefined) {
335
+ const v = payload["max_completion_tokens"];
336
+ if (typeof v === "number" && Number.isFinite(v) && v > 0) payload["max_tokens"] = v;
337
+ delete payload["max_completion_tokens"];
338
+ } else if (payload["max_completion_tokens"] !== undefined) {
339
+ delete payload["max_completion_tokens"];
340
+ }
341
+ // tool_choice: buddy Go struct is string — object form 400s (code 11101). Normalize to wire string.
342
+ normalizeBuddyToolChoice(payload);
343
+ // image_url string → object (OpenAI allows string, buddy requires {url})
344
+ normalizeBuddyImageUrl(payload);
345
+ // developer role → system (buddy whitelist rejects developer, 11128)
346
+ normalizeBuddyRoles(payload);
347
+ applyOpenAIReasoning(payload, request);
348
+ applyDeepSeekReasoning(payload);
349
+ // sol family defaults to high reasoning like reference thinking.go defaultDeepSeekEffort — sol without effort gets 500/invoke error otherwise
350
+ injectSolReasoningDefault(payload);
351
+ payload["stream"] = true;
352
+ const effort = payload["reasoning_effort"];
353
+ if (effort === "none" || effort === "off") {
354
+ delete payload["reasoning_effort"];
355
+ delete payload["reasoning_summary"];
356
+ } else if (typeof effort === "string" && effort.length > 0) {
357
+ payload["reasoning_summary"] = "auto";
358
+ } else {
359
+ delete payload["reasoning_summary"];
360
+ }
361
+ backfillDeepSeekReasoningContent(payload);
362
+ clampBuddyReasoningEffort(payload, realm);
363
+ const messages = payload["messages"];
364
+ if (Array.isArray(messages)) {
365
+ const arr = messages as Array<Record<string, unknown>>;
366
+ // Nameless calls 400 before anything else can be judged, and their results
367
+ // must be orphans before the repack below tries to reorder them.
368
+ dropBuddyEmptyNameToolCalls(arr);
369
+ // Buddy wire: repack tool result blocks before cleanup (image_resize_notice splits tool results → 11148)
370
+ repackBuddyToolResultBlocks(arr);
371
+ cleanupBuddyOrphanToolPairs(arr);
372
+ coalesceConsecutiveAssistantMessages(arr);
373
+ // After every merge: a quoted `11128` in any surviving text blocks the
374
+ // whole request, and the merges above are what produce the final text.
375
+ rewriteBuddyBlockedCode(payload);
376
+ }
377
+ delete payload["agent"];
378
+ delete payload["agent_mode"];
379
+ delete payload["agent_prompt"];
380
+ }
381
+
382
+ function injectSolReasoningDefault(payload: Record<string, unknown>): void {
383
+ const model = typeof payload["model"] === "string" ? (payload["model"] as string) : "";
384
+ const isSol = model.toLowerCase().includes("sol");
385
+ if (!isSol) return;
386
+ if (payload["reasoning_effort"] !== undefined) return;
387
+ const thinking = payload["thinking"] as Record<string, unknown> | undefined;
388
+ if (thinking && typeof thinking["type"] === "string" && (thinking["type"] as string) !== "") return;
389
+ payload["reasoning_effort"] = "high";
390
+ }
391
+
392
+ /**
393
+ * Repack tool result blocks: move non-tool messages interleaved between
394
+ * assistant tool_calls and its tool results to after the block.
395
+ * Port of workbuddy2api repackToolResultBlocks — fixes 11148 tool_call_sequence_broken.
396
+ */
397
+ function repackBuddyToolResultBlocks(messages: Array<Record<string, unknown>>): void {
398
+ if (messages.length < 3) return;
399
+ const out: Array<Record<string, unknown>> = [];
400
+ let changed = false;
401
+ let i = 0;
402
+ while (i < messages.length) {
403
+ const m = messages[i] as Record<string, unknown>;
404
+ if (!m || m["role"] !== "assistant" || !Array.isArray(m["tool_calls"]) || (m["tool_calls"] as unknown[]).length === 0) {
405
+ out.push(m);
406
+ i += 1;
407
+ continue;
408
+ }
409
+ const want = new Set<string>();
410
+ for (const tc of m["tool_calls"] as unknown[]) {
411
+ const id = (tc as Record<string, unknown>)?.["id"];
412
+ if (typeof id === "string" && id.length > 0) want.add(id);
413
+ }
414
+ out.push(m);
415
+ i += 1;
416
+ const results: Array<Record<string, unknown>> = [];
417
+ const between: Array<Record<string, unknown>> = [];
418
+ let sawNonTool = false;
419
+ while (i < messages.length) {
420
+ const mm = messages[i] as Record<string, unknown>;
421
+ if (!mm || typeof mm !== "object") break;
422
+ const role = mm["role"];
423
+ if (role === "tool") {
424
+ const id = mm["tool_call_id"];
425
+ if (typeof id !== "string" || !want.has(id)) break;
426
+ results.push(mm);
427
+ if (sawNonTool) changed = true;
428
+ i += 1;
429
+ continue;
430
+ }
431
+ if (results.length === 0) break;
432
+ between.push(mm);
433
+ sawNonTool = true;
434
+ i += 1;
435
+ }
436
+ out.push(...results);
437
+ out.push(...between);
438
+ if (between.length > 0) changed = true;
439
+ }
440
+ if (!changed) return;
441
+ messages.length = 0;
442
+ messages.push(...out);
443
+ }
444
+
445
+ /**
446
+ * Drop assistant tool_calls whose function name is empty.
447
+ *
448
+ * A streamed call can lose its name and land in the client transcript as
449
+ * `function.name: ""` (see `tool-identity.ts`: the client then replays it and
450
+ * every later request is rejected with "`name` must be non-empty"). Buddy
451
+ * reports this as `11133` with `param: ""`, naming no field, so the transcript
452
+ * is the only place it can be caught.
453
+ *
454
+ * The name is only ever absent, never wrong: replaying the same body with the
455
+ * name set to an identifier that is not even in `tools` returns 200, so the
456
+ * upstream rejects the missing value rather than an unknown one. Dropping the
457
+ * call is therefore not lossy — a nameless call cannot be dispatched anyway.
458
+ * The matching tool result becomes an orphan and
459
+ * `cleanupBuddyOrphanToolPairs` removes it in the same pass.
460
+ */
461
+ function dropBuddyEmptyNameToolCalls(messages: Array<Record<string, unknown>>): void {
462
+ let changed = false;
463
+ for (const message of messages) {
464
+ if (message["role"] !== "assistant" || !Array.isArray(message["tool_calls"])) continue;
465
+ const calls = message["tool_calls"] as unknown[];
466
+ const kept = calls.filter((call) => {
467
+ if (call === null || typeof call !== "object") return true;
468
+ const fn = (call as Record<string, unknown>)["function"];
469
+ if (fn === null || typeof fn !== "object") return true;
470
+ const name = (fn as Record<string, unknown>)["name"];
471
+ return typeof name === "string" && name.trim().length > 0;
472
+ });
473
+ if (kept.length === calls.length) continue;
474
+ const { tool_calls: _dropped, ...rest } = message as Record<string, unknown> & { tool_calls: unknown };
475
+ if (kept.length === 0) {
476
+ messages[messages.indexOf(message)] = rest;
477
+ } else {
478
+ (message as Record<string, unknown>)["tool_calls"] = kept;
479
+ }
480
+ changed = true;
481
+ }
482
+ if (!changed) return;
483
+ // A message left with neither content nor calls carries nothing upstream.
484
+ for (let i = messages.length - 1; i >= 0; i -= 1) {
485
+ const message = messages[i];
486
+ if (message === undefined || message["role"] !== "assistant") continue;
487
+ const hasCalls = Array.isArray(message["tool_calls"]) && (message["tool_calls"] as unknown[]).length > 0;
488
+ const content = message["content"];
489
+ const hasContent = (typeof content === "string" && content.trim().length > 0) || Array.isArray(content);
490
+ if (!hasCalls && !hasContent) messages.splice(i, 1);
491
+ }
492
+ }
493
+
494
+ /**
495
+ * Remove orphan tool_calls / tool results that lack a pair — port of
496
+ * workbuddy2api cleanupOrphanToolCalls. Lets a broken history self-heal
497
+ * instead of 400ing every subsequent request.
498
+ */
499
+ function cleanupBuddyOrphanToolPairs(messages: Array<Record<string, unknown>>): void {
500
+ const toolResultIds = new Set<string>();
501
+ const toolCallIds = new Set<string>();
502
+ for (const m of messages) {
503
+ if (m["role"] === "tool" && typeof m["tool_call_id"] === "string") toolResultIds.add(m["tool_call_id"] as string);
504
+ if (m["role"] === "assistant" && Array.isArray(m["tool_calls"])) {
505
+ for (const tc of m["tool_calls"] as unknown[]) {
506
+ const id = (tc as Record<string, unknown>)?.["id"];
507
+ if (typeof id === "string") toolCallIds.add(id);
508
+ }
509
+ }
510
+ }
511
+ if (toolResultIds.size === 0 && toolCallIds.size === 0) return;
512
+ const out: Array<Record<string, unknown>> = [];
513
+ let changed = false;
514
+ for (const m of messages) {
515
+ if (m["role"] === "assistant" && Array.isArray(m["tool_calls"])) {
516
+ const kept = (m["tool_calls"] as unknown[]).filter((tc) => {
517
+ const id = (tc as Record<string, unknown>)?.["id"];
518
+ return typeof id === "string" && toolResultIds.has(id);
519
+ });
520
+ if (kept.length === 0 && toolCallIds.size > 0 && (m["tool_calls"] as unknown[]).length > 0) {
521
+ // Drop empty tool_calls key — keep message without it
522
+ const { tool_calls: _drop, ...rest } = m as Record<string, unknown> & { tool_calls: unknown };
523
+ if (Object.keys(kept).length === 0) changed = true;
524
+ out.push(rest as Record<string, unknown>);
525
+ if (kept.length !== (m["tool_calls"] as unknown[]).length) changed = true;
526
+ continue;
527
+ }
528
+ if (kept.length !== (m["tool_calls"] as unknown[]).length) {
529
+ changed = true;
530
+ out.push({ ...m, tool_calls: kept });
531
+ continue;
532
+ }
533
+ }
534
+ if (m["role"] === "tool") {
535
+ const id = m["tool_call_id"];
536
+ if (typeof id === "string" && !toolCallIds.has(id as string)) {
537
+ changed = true;
538
+ continue;
539
+ }
540
+ }
541
+ out.push(m);
542
+ }
543
+ if (!changed) return;
544
+ messages.length = 0;
545
+ messages.push(...out);
546
+ }
547
+
548
+
549
+
550
+ const BUDDY_EFFORT_RANK: Readonly<Record<string, number>> = {
551
+ off: 0,
552
+ none: 0,
553
+ minimal: 1,
554
+ low: 2,
555
+ medium: 3,
556
+ high: 4,
557
+ xhigh: 5,
558
+ max: 6,
559
+ };
560
+
561
+ /**
562
+ * Clamps an effort the model's ladder does not serve to the nearest served
563
+ * level, and clamps a `max_tokens` above the catalog output limit down to it.
564
+ *
565
+ * Both are repaired here rather than refused. A client that asked for `max`
566
+ * on a model that serves only `high` wants an answer, and the gateway is the
567
+ * place that knows the ladder — refusing with 400 makes the client retry a
568
+ * request the gateway could have served. The output budget is lifted when the
569
+ * effort was raised, because a higher level spends part of the same budget on
570
+ * reasoning and a limit sized for the lower one truncates the answer.
571
+ *
572
+ * A model with no ladder is left alone. Absence means the levels have not
573
+ * been measured, and clamping it would invent a constraint.
574
+ */
575
+ function clampBuddyReasoningEffort(payload: Record<string, unknown>, realm: "intl" | "cn"): void {
576
+ const model = typeof payload["model"] === "string" ? payload["model"].trim().toLowerCase() : "";
577
+ const table = realm === "cn" ? BUDDY_CN_EFFORTS : BUDDY_INTL_EFFORTS;
578
+ const supported = model.length > 0 ? table[model] : undefined;
579
+ const key =
580
+ "reasoning_effort" in payload
581
+ ? "reasoning_effort"
582
+ : "reasoningEffort" in payload
583
+ ? "reasoningEffort"
584
+ : undefined;
585
+ if (key !== undefined && supported !== undefined) {
586
+ const requested = payload[key];
587
+ if (typeof requested === "string") {
588
+ const normalized = requested.trim().toLowerCase();
589
+ const requestedRank = BUDDY_EFFORT_RANK[normalized];
590
+ if (
591
+ requestedRank !== undefined &&
592
+ !supported.some((effort) => effort.toLowerCase() === normalized)
593
+ ) {
594
+ const ranked = supported
595
+ .map((effort) => ({ effort, rank: BUDDY_EFFORT_RANK[effort.toLowerCase()] }))
596
+ .filter((entry): entry is { effort: string; rank: number } => entry.rank !== undefined);
597
+ const below = ranked.filter((entry) => entry.rank <= requestedRank);
598
+ const chosen =
599
+ below.length > 0
600
+ ? below.reduce((best, entry) => (entry.rank > best.rank ? entry : best))
601
+ : ranked.reduce((best, entry) => (entry.rank < best.rank ? entry : best));
602
+ payload[key] = chosen.effort;
603
+ raiseOutputForRaisedEffort(payload, normalized, chosen.effort.trim().toLowerCase());
604
+ }
605
+ }
606
+ }
607
+ const limit = model.length > 0 ? buddyModelOutputLimit(model) : undefined;
608
+ const maxTokens = payload["max_tokens"];
609
+ if (
610
+ limit !== undefined &&
611
+ typeof maxTokens === "number" &&
612
+ Number.isFinite(maxTokens) &&
613
+ maxTokens > limit
614
+ ) {
615
+ payload["max_tokens"] = limit;
616
+ }
617
+ }
618
+
619
+ /**
620
+ * Lifts `max_tokens` when the ladder raised the caller's effort.
621
+ *
622
+ * A higher level spends part of the same output budget on reasoning, so a
623
+ * limit sized for the lower one can no longer hold the answer. The floor is
624
+ * half the model's catalog output limit, and a budget already above it is
625
+ * left alone.
626
+ */
627
+ function raiseOutputForRaisedEffort(
628
+ payload: Record<string, unknown>,
629
+ requested: string,
630
+ applied: string,
631
+ ): void {
632
+ const model = typeof payload["model"] === "string" ? payload["model"].trim().toLowerCase() : "";
633
+ if (model.length === 0) return;
634
+ const floor = clampedEffortOutputFloor(requested, applied, buddyModelOutputLimit(model));
635
+ if (floor === undefined) return;
636
+ const current = payload["max_tokens"];
637
+ if (typeof current === "number" && Number.isFinite(current) && current >= floor) return;
638
+ payload["max_tokens"] = floor;
639
+ }
640
+
641
+ /**
642
+ * The catalog output limit for one buddy model id, when a catalog names it.
643
+ *
644
+ * Both rosters are searched: CN-only ids (glm-5.1, hy4-preview, the CN
645
+ * DeepSeek rows) exist only in the CN table, and searching the shared one
646
+ * alone left every CN model without a limit.
647
+ */
648
+ function buddyModelOutputLimit(model: string): number | undefined {
649
+ const row =
650
+ BUDDY_SHARED_RAW.find((entry) => entry[0]?.toLowerCase() === model) ??
651
+ CODEBUDDY_CN_RAW.find((entry) => entry[0]?.toLowerCase() === model);
652
+ const out = row?.[5];
653
+ return typeof out === "number" && Number.isFinite(out) && out > 0 ? out : undefined;
654
+ }
655
+
656
+ function normalizeBuddyToolChoice(payload: Record<string, unknown>): void {
657
+ if (!("tool_choice" in payload)) return;
658
+ const tc = payload["tool_choice"];
659
+ if (typeof tc === "string") {
660
+ // `none` means "do not call this turn", not "forget the tool schemas".
661
+ // Deleting tools makes DeepSeek emit unparseable DSML text instead.
662
+ if (tc.trim().toLowerCase() === "none") payload["tool_choice"] = "none";
663
+ return;
664
+ }
665
+ if (tc && typeof tc === "object" && !Array.isArray(tc)) {
666
+ const obj = tc as Record<string, unknown>;
667
+ const typ = typeof obj["type"] === "string" ? (obj["type"] as string).trim().toLowerCase() : "";
668
+ if (typ === "none") {
669
+ payload["tool_choice"] = "none";
670
+ } else if (typ === "auto" || typ === "required") {
671
+ payload["tool_choice"] = typ;
672
+ } else if (typ === "function") {
673
+ let name = "";
674
+ const fn = obj["function"];
675
+ if (fn && typeof fn === "object" && !Array.isArray(fn)) name = typeof (fn as Record<string, unknown>)["name"] === "string" ? ((fn as Record<string, unknown>)["name"] as string) : "";
676
+ if (!name && typeof obj["name"] === "string") name = obj["name"] as string;
677
+ name = name.trim();
678
+ payload["tool_choice"] = name.length > 0 ? name : "auto";
679
+ } else {
680
+ delete payload["tool_choice"];
681
+ }
682
+ return;
683
+ }
684
+ delete payload["tool_choice"];
685
+ }
686
+
687
+ /** Images kept in one outbound body. The upstream rejects a larger set. */
688
+ const BUDDY_MAX_IMAGES = 16;
689
+ const BUDDY_IMAGE_OMITTED = "[Image omitted by request image limit.]";
690
+
691
+ function normalizeBuddyImageUrl(payload: Record<string, unknown>): void {
692
+ const messages = payload["messages"];
693
+ if (!Array.isArray(messages)) return;
694
+ let kept = 0;
695
+ for (const m of messages) {
696
+ if (!m || typeof m !== "object") continue;
697
+ const content = (m as Record<string, unknown>)["content"];
698
+ if (!Array.isArray(content)) continue;
699
+ for (let index = 0; index < content.length; index += 1) {
700
+ const part = content[index];
701
+ if (!part || typeof part !== "object") continue;
702
+ const p = part as Record<string, unknown>;
703
+ if (p["type"] !== "image_url") continue;
704
+ const v = p["image_url"];
705
+ if (typeof v === "string") {
706
+ if (v.length === 0) continue;
707
+ p["image_url"] = { url: v };
708
+ }
709
+ kept += 1;
710
+ if (kept > BUDDY_MAX_IMAGES) {
711
+ content[index] = { type: "text", text: BUDDY_IMAGE_OMITTED };
712
+ }
713
+ }
714
+ }
715
+ }
716
+
717
+ function normalizeBuddyRoles(payload: Record<string, unknown>): void {
718
+ const messages = payload["messages"];
719
+ if (!Array.isArray(messages)) return;
720
+ for (const m of messages) {
721
+ if (!m || typeof m !== "object") continue;
722
+ const rec = m as Record<string, unknown>;
723
+ if (rec["role"] === "developer") rec["role"] = "system";
724
+ }
725
+ }
726
+
727
+ /** Normalizes one message's content into a Chat-wire content-part array. */
728
+ function contentParts(content: unknown): Array<Record<string, unknown>> {
729
+ if (typeof content === "string")
730
+ return content.length === 0 ? [] : [{ type: "text", text: content }];
731
+ if (!Array.isArray(content)) return [];
732
+ return content.filter(
733
+ (part): part is Record<string, unknown> => part !== null && typeof part === "object",
734
+ );
735
+ }
736
+
737
+ /**
738
+ * Coalesces consecutive `user` messages into one, preserving content-part
739
+ * order. The buddy upstream merges consecutive same-role turns itself and
740
+ * drops `image_url` parts while doing so, so an attachment delivered as its
741
+ * own user turn (the shape VS Code Copilot emits for a pasted screenshot)
742
+ * reaches the model as text only — the image is silently gone even though the
743
+ * gateway forwarded it. Merging here keeps every image in the same message as
744
+ * the text the caller sent alongside it.
745
+ *
746
+ * Lossless: only adjacent same-role `user` turns merge, and every part is
747
+ * retained in order. Assistant/tool/system turns are untouched.
748
+ */
749
+ export function coalesceConsecutiveUserMessages(messages: Array<Record<string, unknown>>): void {
750
+ const out: Array<Record<string, unknown>> = [];
751
+ for (const message of messages) {
752
+ const previous = out[out.length - 1];
753
+ if (message["role"] !== "user" || previous?.["role"] !== "user") {
754
+ out.push(message);
755
+ continue;
756
+ }
757
+ out[out.length - 1] = {
758
+ ...previous,
759
+ content: [...contentParts(previous["content"]), ...contentParts(message["content"])],
760
+ };
761
+ }
762
+ messages.length = 0;
763
+ messages.push(...out);
764
+ }
765
+
766
+ /**
767
+ * Responses input can split one assistant turn into several adjacent items
768
+ * (text, reasoning, and parallel function calls). Chat wire needs a single
769
+ * assistant message so the reasoning trace accompanies every call in the
770
+ * batch; tool results or user turns always end the batch.
771
+ */
772
+ function coalesceConsecutiveAssistantMessages(messages: Array<Record<string, unknown>>): void {
773
+ let write = 0;
774
+ for (const message of messages) {
775
+ const previous = messages[write - 1];
776
+ if (message["role"] !== "assistant" || previous?.["role"] !== "assistant") {
777
+ messages[write++] = message;
778
+ continue;
779
+ }
780
+ const firstContent = previous["content"];
781
+ const nextContent = message["content"];
782
+ let content: unknown = firstContent ?? nextContent;
783
+ if (firstContent != null && nextContent != null) {
784
+ if (typeof firstContent === "string" && typeof nextContent === "string") {
785
+ if (firstContent.length === 0) content = nextContent;
786
+ else if (nextContent.length === 0) content = firstContent;
787
+ else content = `${firstContent}\n${nextContent}`;
788
+ } else content = [...contentParts(firstContent), ...contentParts(nextContent)];
789
+ }
790
+ const firstCalls = previous["tool_calls"];
791
+ const nextCalls = message["tool_calls"];
792
+ const firstReasoning = previous["reasoning_content"];
793
+ const nextReasoning = message["reasoning_content"];
794
+ const merged: Record<string, unknown> = { ...previous, ...message, content };
795
+ if (Array.isArray(firstCalls) || Array.isArray(nextCalls))
796
+ merged["tool_calls"] = [
797
+ ...(Array.isArray(firstCalls) ? firstCalls : []),
798
+ ...(Array.isArray(nextCalls) ? nextCalls : []),
799
+ ];
800
+ if (typeof firstReasoning === "string" && typeof nextReasoning === "string") {
801
+ if (firstReasoning.length === 0) merged["reasoning_content"] = nextReasoning;
802
+ else if (nextReasoning.length === 0) merged["reasoning_content"] = firstReasoning;
803
+ else merged["reasoning_content"] = `${firstReasoning}\n${nextReasoning}`;
804
+ } else if (typeof firstReasoning === "string" || typeof nextReasoning === "string") {
805
+ merged["reasoning_content"] = firstReasoning ?? nextReasoning;
806
+ }
807
+ messages[write - 1] = merged;
808
+ }
809
+ messages.length = write;
810
+ }
811
+
812
+ /**
813
+ * Drops messages whose content is empty, mirroring the buddy upstream's own
814
+ * validation (empty-content turns are rejected with code `11151`). Guarded:
815
+ * anything carrying tool calls, tool results, or reasoning is kept — an
816
+ * assistant `tool_calls` turn with null content and a ledger-folded result
817
+ * text are both load-bearing, not empty.
818
+ */
819
+ export function dropEmptyBuddyMessages(messages: Array<Record<string, unknown>>): void {
820
+ const kept = messages.filter((message) => {
821
+ if (!isRecord(message)) return true;
822
+ if (Array.isArray(message["tool_calls"]) && message["tool_calls"].length > 0) return true;
823
+ if (typeof message["tool_call_id"] === "string" && message["tool_call_id"].trim().length > 0)
824
+ return true;
825
+ if (typeof message["reasoning_content"] === "string") return true;
826
+ const content = message["content"];
827
+ if (typeof content === "string") return content.trim().length > 0;
828
+ if (Array.isArray(content)) return content.length > 0;
829
+ // Null content with no tool/reasoning load is a bare turn the upstream
830
+ // rejects — drop it rather than dispatch a known-invalid payload.
831
+ return false;
832
+ });
833
+ if (kept.length === messages.length) return;
834
+ messages.length = 0;
835
+ messages.push(...kept);
836
+ }
837
+
838
+ /**
839
+ * Guarantees the wire opens with a `system` turn. The buddy upstream rejects a
840
+ * first message that is not a system prompt (code `11128`), and a CN-neutralized
841
+ * or caller-less history can otherwise arrive with a bare `user` turn first.
842
+ * Non-empty by construction: an empty system turn trips the sibling `11151`
843
+ * validation above, so the placeholder carries real content.
844
+ */
845
+ export function ensureBuddyLeadingSystem(
846
+ messages: Array<Record<string, unknown>>,
847
+ systemPrompt: string,
848
+ ): void {
849
+ if (messages.length === 0) {
850
+ messages.push({ role: "system", content: systemPrompt });
851
+ return;
852
+ }
853
+ const first = messages[0];
854
+ if (isRecord(first) && first["role"] === "system") return;
855
+ messages.unshift({ role: "system", content: systemPrompt });
856
+ }
857
+
858
+ /**
859
+ * Runs the full buddy message-envelope tail shared by every variant: merge
860
+ * consecutive user turns, drop empty-content turns the upstream rejects
861
+ * (`11151`), then guarantee a leading system turn (`11128`). The system text
862
+ * is the variant's own prompt, so each pre-payload hook passes its prompt in.
863
+ */
864
+ function finalizeBuddyMessages(
865
+ messages: Array<Record<string, unknown>>,
866
+ systemPrompt: string,
867
+ ): void {
868
+ coalesceConsecutiveUserMessages(messages);
869
+ dropEmptyBuddyMessages(messages);
870
+ ensureBuddyLeadingSystem(messages, systemPrompt);
871
+ }
872
+
873
+ /**
874
+ * Applies the buddy-family message envelope in place: drop caller
875
+ * `system`/`developer` turns, install the variant's fixed leading system
876
+ * prompt, rebuild bare string user content as a typed text block, then
877
+ * coalesce consecutive user turns.
878
+ *
879
+ * The upstream gateway expects that fixed leading prompt — and validates its
880
+ * text, not merely the presence of a `system` turn. Substituting caller text
881
+ * there answers `400 · 11128 "Illegal API invocation from an unapproved
882
+ * channel"` on CodeBuddy, which reads the prompt as the calling channel's
883
+ * identity; forwarding caller text as a `developer` turn is rejected too. So
884
+ * every variant that has one applies the same transformation — only the prompt
885
+ * text differs, which is why the caller passes it in. Variants without a fixed
886
+ * prompt (CodeBuddy CN replaces caller system text with a neutralizer instead)
887
+ * keep their own path and share only `coalesceConsecutiveUserMessages`.
888
+ *
889
+ * Tool call/output pairing is intentionally NOT handled here: the shared
890
+ * canonical repair (`repairRequestToolCalls`, request/preparer) owns it for
891
+ * every route, so this stays a system-prompt and content-shape hook.
892
+ */
893
+ export function applyBuddySystemPrompt(
894
+ messages: Array<Record<string, unknown>>,
895
+ systemPrompt: string,
896
+ ): void {
897
+ const source = messages.filter(
898
+ (message) => message["role"] !== "system" && message["role"] !== "developer",
899
+ );
900
+ messages.length = 0;
901
+ messages.push({ role: "system", content: systemPrompt });
902
+ for (const message of source) {
903
+ if (message["role"] === "user" && typeof message["content"] === "string") {
904
+ messages.push({
905
+ ...message,
906
+ content: [{ type: "text", text: message["content"] }],
907
+ });
908
+ } else {
909
+ messages.push({ ...message });
910
+ }
911
+ }
912
+ finalizeBuddyMessages(messages, systemPrompt);
913
+ }