cartethyia 2.6.6 → 2.7.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 (189) hide show
  1. package/.env.example +106 -113
  2. package/dist/dashboard/assets/{BannedPage-1v5rzKMl.js → BannedPage-P76QLGJA.js} +1 -1
  3. package/dist/dashboard/assets/BannedPage-P76QLGJA.js.gz +0 -0
  4. package/dist/dashboard/assets/{CliToolDetailPage-chMg74ct.js → CliToolDetailPage-DdBggJS9.js} +1 -1
  5. package/dist/dashboard/assets/CliToolDetailPage-DdBggJS9.js.gz +0 -0
  6. package/dist/dashboard/assets/{CliToolsPage-CKG-Vxrt.js → CliToolsPage-aN1iXhhP.js} +1 -1
  7. package/dist/dashboard/assets/CliToolsPage-aN1iXhhP.js.gz +0 -0
  8. package/dist/dashboard/assets/{CombosPage-JYmVKzIx.js → CombosPage-DE-F1nxZ.js} +1 -1
  9. package/dist/dashboard/assets/CombosPage-DE-F1nxZ.js.gz +0 -0
  10. package/dist/dashboard/assets/{ConsoleLogPage-CPflWDry.js → ConsoleLogPage-5SpmfLyw.js} +1 -1
  11. package/dist/dashboard/assets/ConsoleLogPage-5SpmfLyw.js.gz +0 -0
  12. package/dist/dashboard/assets/{CustomizationPage-B0EYUE47.js → CustomizationPage-3CUxMy2X.js} +1 -1
  13. package/dist/dashboard/assets/CustomizationPage-3CUxMy2X.js.gz +0 -0
  14. package/dist/dashboard/assets/{HealthEventsModal-F1c4lkNG.js → HealthEventsModal-BkmJQKC0.js} +1 -1
  15. package/dist/dashboard/assets/HealthEventsModal-BkmJQKC0.js.gz +0 -0
  16. package/dist/dashboard/assets/{LoginPage-BYTpzcoU.js → LoginPage-B_0FUKWc.js} +1 -1
  17. package/dist/dashboard/assets/LoginPage-B_0FUKWc.js.gz +0 -0
  18. package/dist/dashboard/assets/{ModelPicker-CA1kLs45.js → ModelPicker-DKVslqG3.js} +1 -1
  19. package/dist/dashboard/assets/ModelPicker-DKVslqG3.js.gz +0 -0
  20. package/dist/dashboard/assets/OverviewPage-D-H4MEiT.js +1 -0
  21. package/dist/dashboard/assets/OverviewPage-D-H4MEiT.js.gz +0 -0
  22. package/dist/dashboard/assets/{ProviderDetailPage-CM4MNy45.js → ProviderDetailPage-CEJw6Guo.js} +3 -3
  23. package/dist/dashboard/assets/ProviderDetailPage-CEJw6Guo.js.gz +0 -0
  24. package/dist/dashboard/assets/{ProvidersPage-Bsoqg2HG.js → ProvidersPage-CEplAR-i.js} +1 -1
  25. package/dist/dashboard/assets/ProvidersPage-CEplAR-i.js.gz +0 -0
  26. package/dist/dashboard/assets/{ProxyPage-8G3doV7j.js → ProxyPage-CAN7hy0f.js} +1 -1
  27. package/dist/dashboard/assets/ProxyPage-CAN7hy0f.js.gz +0 -0
  28. package/dist/dashboard/assets/{QuotaPage-CVIQq6JG.js → QuotaPage-BjldQwIU.js} +1 -1
  29. package/dist/dashboard/assets/QuotaPage-BjldQwIU.js.gz +0 -0
  30. package/dist/dashboard/assets/SettingsPage-DUIoLRxk.js +1 -0
  31. package/dist/dashboard/assets/SettingsPage-DUIoLRxk.js.gz +0 -0
  32. package/dist/dashboard/assets/{SetupPage-BCx07-bj.js → SetupPage-_bCaTM0v.js} +1 -1
  33. package/dist/dashboard/assets/SetupPage-_bCaTM0v.js.gz +0 -0
  34. package/dist/dashboard/assets/{SortableList-MhkB6msu.js → SortableList-fI7JkdIB.js} +1 -1
  35. package/dist/dashboard/assets/SortableList-fI7JkdIB.js.gz +0 -0
  36. package/dist/dashboard/assets/{StudioPage-CTqK6Nsj.js → StudioPage-CHVZqskZ.js} +1 -1
  37. package/dist/dashboard/assets/StudioPage-CHVZqskZ.js.gz +0 -0
  38. package/dist/dashboard/assets/UsagePage-DiFBymHj.js +56 -0
  39. package/dist/dashboard/assets/UsagePage-DiFBymHj.js.gz +0 -0
  40. package/dist/dashboard/assets/{api-keys-C23S9sgp.js → api-keys-DFY_fZU4.js} +1 -1
  41. package/dist/dashboard/assets/api-keys-DFY_fZU4.js.gz +0 -0
  42. package/dist/dashboard/assets/app-BOJ1_xUB.css +2 -0
  43. package/dist/dashboard/assets/app-BOJ1_xUB.css.gz +0 -0
  44. package/dist/dashboard/assets/{assert-DU6qrMA_.js → assert-CwdnZARt.js} +1 -1
  45. package/dist/dashboard/assets/assert-CwdnZARt.js.gz +0 -0
  46. package/dist/dashboard/assets/{badge-Bkz_-RrM.js → badge-Dr-uf0Au.js} +1 -1
  47. package/dist/dashboard/assets/badge-Dr-uf0Au.js.gz +0 -0
  48. package/dist/dashboard/assets/{banner-C-7y3VKV.js → banner-BddmMdoS.js} +1 -1
  49. package/dist/dashboard/assets/banner-BddmMdoS.js.gz +0 -0
  50. package/dist/dashboard/assets/{card-BOPBuBzD.js → card-CK0PXWlX.js} +1 -1
  51. package/dist/dashboard/assets/card-CK0PXWlX.js.gz +0 -0
  52. package/dist/dashboard/assets/{cli-tools-BaFUuFFF.js → cli-tools-CcB6mGh2.js} +1 -1
  53. package/dist/dashboard/assets/cli-tools-CcB6mGh2.js.gz +0 -0
  54. package/dist/dashboard/assets/{clipboard-button-e-Mlk0FH.js → clipboard-button-DGb2P9Yx.js} +1 -1
  55. package/dist/dashboard/assets/clipboard-button-DGb2P9Yx.js.gz +0 -0
  56. package/dist/dashboard/assets/{dialog-Iec_ywbD.js → dialog-LkfuL50u.js} +1 -1
  57. package/dist/dashboard/assets/dialog-LkfuL50u.js.gz +0 -0
  58. package/dist/dashboard/assets/{drawer-Bs8MIIHc.js → drawer-ZbEZ_84c.js} +1 -1
  59. package/dist/dashboard/assets/drawer-ZbEZ_84c.js.gz +0 -0
  60. package/dist/dashboard/assets/{entry-DcobaKFu.js → entry-BJfsVMvA.js} +1 -1
  61. package/dist/dashboard/assets/entry-BJfsVMvA.js.gz +0 -0
  62. package/dist/dashboard/assets/{entry-CBHzQ6Qh.js → entry-CWBY5A0f.js} +3 -3
  63. package/dist/dashboard/assets/entry-CWBY5A0f.js.gz +0 -0
  64. package/dist/dashboard/assets/{entry-BRszm4_Z.js → entry-CvC1-Zqx.js} +1 -1
  65. package/dist/dashboard/assets/entry-CvC1-Zqx.js.gz +0 -0
  66. package/dist/dashboard/assets/{github-badge-BuDsrsPn.js → github-badge-BxpznFLt.js} +1 -1
  67. package/dist/dashboard/assets/github-badge-BxpznFLt.js.gz +0 -0
  68. package/dist/dashboard/assets/{index-Ck4uwuYb.js → index-CDzP3ovJ.js} +2 -2
  69. package/dist/dashboard/assets/index-CDzP3ovJ.js.gz +0 -0
  70. package/dist/dashboard/assets/{inline-DToMICwl.js → inline-Ae13MQ5w.js} +1 -1
  71. package/dist/dashboard/assets/inline-Ae13MQ5w.js.gz +0 -0
  72. package/dist/dashboard/assets/{input-WlyHWeG2.js → input-D23fo7cp.js} +1 -1
  73. package/dist/dashboard/assets/input-D23fo7cp.js.gz +0 -0
  74. package/dist/dashboard/assets/{layout-DaJ5baJr.js → layout-CSsxjj5P.js} +1 -1
  75. package/dist/dashboard/assets/layout-CSsxjj5P.js.gz +0 -0
  76. package/dist/dashboard/assets/{list-row-B3oSK13z.js → list-row-dmbwZMDY.js} +1 -1
  77. package/dist/dashboard/assets/list-row-dmbwZMDY.js.gz +0 -0
  78. package/dist/dashboard/assets/{live-ta6GMb-8.js → live-BG1oUsZZ.js} +1 -1
  79. package/dist/dashboard/assets/live-BG1oUsZZ.js.gz +0 -0
  80. package/dist/dashboard/assets/minimize-2-D-MN8W8a.js +1 -0
  81. package/dist/dashboard/assets/minimize-2-D-MN8W8a.js.gz +0 -0
  82. package/dist/dashboard/assets/{mutation-g_fduiNw.js → mutation-DSF-e4ho.js} +1 -1
  83. package/dist/dashboard/assets/mutation-DSF-e4ho.js.gz +0 -0
  84. package/dist/dashboard/assets/{numbers-Dulqgys5.js → numbers-Bm_aECY7.js} +1 -1
  85. package/dist/dashboard/assets/{numbers-Dulqgys5.js.gz → numbers-Bm_aECY7.js.gz} +0 -0
  86. package/dist/dashboard/assets/{query-keys-UETqfytp.js → query-keys-CF56nYIV.js} +1 -1
  87. package/dist/dashboard/assets/query-keys-CF56nYIV.js.gz +0 -0
  88. package/dist/dashboard/assets/{routing-BPUo-W8d.js → routing-CeA71n7G.js} +1 -1
  89. package/dist/dashboard/assets/routing-CeA71n7G.js.gz +0 -0
  90. package/dist/dashboard/assets/{select-BrrUDUPc.js → select-KIT0rC6G.js} +1 -1
  91. package/dist/dashboard/assets/select-KIT0rC6G.js.gz +0 -0
  92. package/dist/dashboard/assets/settings-B_2gkePf.js +1 -0
  93. package/dist/dashboard/assets/settings-B_2gkePf.js.gz +0 -0
  94. package/dist/dashboard/assets/{stack-B1w5BM0u.js → stack-D7WOSzcX.js} +1 -1
  95. package/dist/dashboard/assets/stack-D7WOSzcX.js.gz +0 -0
  96. package/dist/dashboard/assets/{state-DhZBfWgx.js → state-BBxtKZOQ.js} +1 -1
  97. package/dist/dashboard/assets/state-BBxtKZOQ.js.gz +0 -0
  98. package/dist/dashboard/assets/{switch-yD7D99Lg.js → switch-DNS_D5Ea.js} +1 -1
  99. package/dist/dashboard/assets/switch-DNS_D5Ea.js.gz +0 -0
  100. package/dist/dashboard/assets/{useMutation-EDFRkn1i.js → useMutation-BgoxdoMk.js} +1 -1
  101. package/dist/dashboard/assets/useMutation-BgoxdoMk.js.gz +0 -0
  102. package/dist/dashboard/index.html +1 -1
  103. package/dist/dashboard/index.html.gz +0 -0
  104. package/dist/dashboard/precompressed-assets.json +51 -51
  105. package/package.json +96 -96
  106. package/src/boot/app.ts +714 -722
  107. package/src/boot/dependencies.ts +729 -750
  108. package/src/config/resources.ts +70 -137
  109. package/src/config/security.ts +37 -73
  110. package/src/config/spec.ts +358 -378
  111. package/src/console/console-router.ts +225 -233
  112. package/src/console/domain-registration.ts +381 -391
  113. package/src/console/remote-support/contracts.ts +3 -1
  114. package/src/console/remote-support/service.ts +115 -6
  115. package/src/main.ts +167 -169
  116. package/src/observability/metrics.ts +467 -457
  117. package/src/observability/runtime-metrics.ts +269 -254
  118. package/src/protocol/gateway-error.ts +232 -233
  119. package/src/providers/integrations/commandcode/commandcode.ts +134 -104
  120. package/src/providers/integrations/commandcode/discovery.ts +6 -5
  121. package/src/providers/integrations/qoder/qoder.ts +597 -23
  122. package/src/providers/operations/account-health-service.ts +52 -3
  123. package/src/security/abuse.ts +17 -0
  124. package/src/transport/guards/auth-guard.ts +109 -127
  125. package/src/transport/guards/pipeline.ts +220 -226
  126. package/src/transport/guards/request-context.ts +238 -318
  127. package/src/transport/request/inflight.ts +21 -2
  128. package/src/workers/scheduler.ts +73 -6
  129. package/dist/dashboard/assets/BannedPage-1v5rzKMl.js.gz +0 -0
  130. package/dist/dashboard/assets/CliToolDetailPage-chMg74ct.js.gz +0 -0
  131. package/dist/dashboard/assets/CliToolsPage-CKG-Vxrt.js.gz +0 -0
  132. package/dist/dashboard/assets/CombosPage-JYmVKzIx.js.gz +0 -0
  133. package/dist/dashboard/assets/ConsoleLogPage-CPflWDry.js.gz +0 -0
  134. package/dist/dashboard/assets/CustomizationPage-B0EYUE47.js.gz +0 -0
  135. package/dist/dashboard/assets/HealthEventsModal-F1c4lkNG.js.gz +0 -0
  136. package/dist/dashboard/assets/LoginPage-BYTpzcoU.js.gz +0 -0
  137. package/dist/dashboard/assets/ModelPicker-CA1kLs45.js.gz +0 -0
  138. package/dist/dashboard/assets/OverviewPage-DIYZRAcb.js +0 -1
  139. package/dist/dashboard/assets/OverviewPage-DIYZRAcb.js.gz +0 -0
  140. package/dist/dashboard/assets/ProviderDetailPage-CM4MNy45.js.gz +0 -0
  141. package/dist/dashboard/assets/ProvidersPage-Bsoqg2HG.js.gz +0 -0
  142. package/dist/dashboard/assets/ProxyPage-8G3doV7j.js.gz +0 -0
  143. package/dist/dashboard/assets/QuotaPage-CVIQq6JG.js.gz +0 -0
  144. package/dist/dashboard/assets/SettingsPage-BgXvRN2Q.js +0 -1
  145. package/dist/dashboard/assets/SettingsPage-BgXvRN2Q.js.gz +0 -0
  146. package/dist/dashboard/assets/SetupPage-BCx07-bj.js.gz +0 -0
  147. package/dist/dashboard/assets/SortableList-MhkB6msu.js.gz +0 -0
  148. package/dist/dashboard/assets/StudioPage-CTqK6Nsj.js.gz +0 -0
  149. package/dist/dashboard/assets/UsagePage-Do65OWbr.js +0 -56
  150. package/dist/dashboard/assets/UsagePage-Do65OWbr.js.gz +0 -0
  151. package/dist/dashboard/assets/api-keys-C23S9sgp.js.gz +0 -0
  152. package/dist/dashboard/assets/app-BUSiYL6F.css +0 -2
  153. package/dist/dashboard/assets/app-BUSiYL6F.css.gz +0 -0
  154. package/dist/dashboard/assets/assert-DU6qrMA_.js.gz +0 -0
  155. package/dist/dashboard/assets/badge-Bkz_-RrM.js.gz +0 -0
  156. package/dist/dashboard/assets/banner-C-7y3VKV.js.gz +0 -0
  157. package/dist/dashboard/assets/card-BOPBuBzD.js.gz +0 -0
  158. package/dist/dashboard/assets/cli-tools-BaFUuFFF.js.gz +0 -0
  159. package/dist/dashboard/assets/clipboard-button-e-Mlk0FH.js.gz +0 -0
  160. package/dist/dashboard/assets/dialog-Iec_ywbD.js.gz +0 -0
  161. package/dist/dashboard/assets/drawer-Bs8MIIHc.js.gz +0 -0
  162. package/dist/dashboard/assets/entry-BRszm4_Z.js.gz +0 -0
  163. package/dist/dashboard/assets/entry-CBHzQ6Qh.js.gz +0 -0
  164. package/dist/dashboard/assets/entry-DcobaKFu.js.gz +0 -0
  165. package/dist/dashboard/assets/github-badge-BuDsrsPn.js.gz +0 -0
  166. package/dist/dashboard/assets/index-Ck4uwuYb.js.gz +0 -0
  167. package/dist/dashboard/assets/inline-DToMICwl.js.gz +0 -0
  168. package/dist/dashboard/assets/input-WlyHWeG2.js.gz +0 -0
  169. package/dist/dashboard/assets/layout-DaJ5baJr.js.gz +0 -0
  170. package/dist/dashboard/assets/list-row-B3oSK13z.js.gz +0 -0
  171. package/dist/dashboard/assets/live-ta6GMb-8.js.gz +0 -0
  172. package/dist/dashboard/assets/model-bans-BmYTmbAk.js +0 -1
  173. package/dist/dashboard/assets/model-bans-BmYTmbAk.js.gz +0 -0
  174. package/dist/dashboard/assets/mutation-g_fduiNw.js.gz +0 -0
  175. package/dist/dashboard/assets/query-keys-UETqfytp.js.gz +0 -0
  176. package/dist/dashboard/assets/routing-BPUo-W8d.js.gz +0 -0
  177. package/dist/dashboard/assets/select-BrrUDUPc.js.gz +0 -0
  178. package/dist/dashboard/assets/settings--3hxSyry.js +0 -1
  179. package/dist/dashboard/assets/settings--3hxSyry.js.gz +0 -0
  180. package/dist/dashboard/assets/stack-B1w5BM0u.js.gz +0 -0
  181. package/dist/dashboard/assets/state-DhZBfWgx.js.gz +0 -0
  182. package/dist/dashboard/assets/switch-yD7D99Lg.js.gz +0 -0
  183. package/dist/dashboard/assets/useMutation-EDFRkn1i.js.gz +0 -0
  184. package/src/console/admin/model-abuse/contracts.ts +0 -38
  185. package/src/console/admin/model-abuse/routes.ts +0 -97
  186. package/src/security/model-abuse.ts +0 -704
  187. package/src/transport/guards/concurrency-guard.ts +0 -125
  188. /package/dist/dashboard/assets/{app-MdVfwraB.js → app-CaXZ9SCn.js} +0 -0
  189. /package/dist/dashboard/assets/{app-MdVfwraB.js.gz → app-CaXZ9SCn.js.gz} +0 -0
@@ -11,7 +11,14 @@ import {
11
11
  upstreamErrorIdentifier,
12
12
  } from "../../../transport/failure-policy";
13
13
  import { GatewayError } from "../../../protocol/gateway-error";
14
- import type { CanonicalEvent, CanonicalRequest, CanonicalStopReason, ContentPart } from "../../../protocol/canonical-model";
14
+ import type {
15
+ CanonicalEvent,
16
+ CanonicalRequest,
17
+ CanonicalStopReason,
18
+ ContentPart,
19
+ ToolChoice,
20
+ ToolDefinition,
21
+ } from "../../../protocol/canonical-model";
15
22
  import { toolResultParts } from "../../../protocol/canonical-model";
16
23
  import { decodeSseEvents } from "../../../protocol/sse-decoder";
17
24
  import { usageFromProvider, readReasoningText } from "../../provider-usage";
@@ -479,11 +486,146 @@ function qoderRequestedMaxTokens(request: CanonicalRequest, modelConfig: QoderMo
479
486
  return typeof requested === "number" && requested > 0 ? Math.min(requested, configured) : configured;
480
487
  }
481
488
 
489
+ /**
490
+ * Whether this model needs the prior turn's reasoning echoed back.
491
+ *
492
+ * The DeepSeek family ties multi-turn consistency to `reasoning_content`: an
493
+ * assistant turn that thought must be replayed with that field or the upstream
494
+ * refuses the whole history. The upstream reference bridge keeps it for exactly
495
+ * these models and drops it as noise for every other one
496
+ * (`keep_reasoning=is_deepseek_model(...)`, and `reasoning_content` in
497
+ * `NOISE_KEYS` otherwise), and notes the symptom when it is missed: occasional
498
+ * failures that sometimes pass on retry.
499
+ *
500
+ * Matched by the upstream key and by the display name, because a caller may
501
+ * name either — the reference warns that matching only the "deepseek" prefix
502
+ * missed every request that used the key.
503
+ */
504
+ function qoderKeepsReasoning(modelId: string): boolean {
505
+ const key = modelId.trim().toLowerCase();
506
+ if (key === "dmodel" || key === "dfmodel") return true;
507
+ return key.startsWith("deepseek");
508
+ }
509
+
510
+ /**
511
+ * The `reasoning_content` field for an assistant turn, when one is needed.
512
+ *
513
+ * Presence, not length, decides: a client that already knows the rule sends
514
+ * `""` on a thinking turn whose text is not readable back, and dropping that
515
+ * empty string re-triggers the complaint it was sent to avoid. An assistant
516
+ * turn with no reasoning at all gains nothing, so nothing is fabricated.
517
+ */
518
+ function qoderReasoningEcho(
519
+ message: { content: readonly ContentPart[] },
520
+ modelId: string,
521
+ ): { reasoning_content: string } | Record<string, never> {
522
+ if (!qoderKeepsReasoning(modelId)) return {};
523
+ const reasoning = message.content.filter((part) => part.kind === "reasoning");
524
+ if (reasoning.length === 0) return {};
525
+ const text = reasoning
526
+ .map((part) => (part.kind === "reasoning" ? (part.summary ?? part.payload ?? "") : ""))
527
+ .join("");
528
+ return { reasoning_content: text };
529
+ }
530
+
531
+ /**
532
+ * The tool arguments as the object the `tool_use` block expects.
533
+ *
534
+ * The canonical form keeps them as the raw JSON text the caller sent, which is
535
+ * what a chat-completions `tool_calls` entry holds. A `tool_use` block wants
536
+ * structured input, so the text is parsed back.
537
+ *
538
+ * The fallbacks mirror the upstream reference bridge rather than inventing
539
+ * shapes: unparseable text becomes `{}` and a non-object becomes `{value}`.
540
+ * Smuggling the raw text through instead would emit an argument shape the
541
+ * upstream has never seen for this field.
542
+ */
543
+ /**
544
+ * Projects the canonical tools onto Qoder's chat-completions `tools` array.
545
+ *
546
+ * Without this the upstream is never told a tool exists, so a model that wants
547
+ * one improvises markup in its prose (`<Read file_path="..." />`) instead of
548
+ * emitting a structured call — the call is then neither runnable nor
549
+ * recoverable, because it never was a call on either side.
550
+ *
551
+ * Only `function` tools are representable upstream. Anything else is dropped
552
+ * rather than forwarded: Qoder rejects an unknown variant during
553
+ * deserialization and fails the whole turn, which is worse than omitting a
554
+ * tool the model could not have used anyway.
555
+ */
556
+ function qoderTools(
557
+ tools: readonly ToolDefinition[] | undefined,
558
+ ): Array<Record<string, unknown>> {
559
+ const out: Array<Record<string, unknown>> = [];
560
+ for (const tool of tools ?? []) {
561
+ if (tool.tool_type !== undefined && tool.tool_type !== "function") continue;
562
+ // The upstream requires `tools[].function` to be an object. A flat
563
+ // declaration naming the tool at the top level is rejected outright with
564
+ // "'function' is a required property, expected an object", which fails the
565
+ // whole request rather than just this tool.
566
+ out.push({
567
+ type: "function",
568
+ function: {
569
+ name: tool.name,
570
+ ...(tool.description === undefined ? { description: "" } : { description: tool.description }),
571
+ parameters: tool.jsonSchema ?? { type: "object", properties: {} },
572
+ ...(tool.strict === undefined ? {} : { strict: tool.strict }),
573
+ },
574
+ });
575
+ }
576
+ return out;
577
+ }
578
+
579
+
580
+ /**
581
+ * Projects the canonical tool choice onto the upstream's field.
582
+ *
583
+ * The upstream only accepts a string here. Given the object form
584
+ * `{type:"function",function:{name}}` it ignores the field, and the model then
585
+ * calls the tool with no arguments filled in.
586
+ *
587
+ * `allowed_tools` has no chat-completions equivalent, so it collapses to the
588
+ * direction it already names rather than being dropped: a caller that asked for
589
+ * `required` must not silently get `auto`.
590
+ */
591
+ function qoderToolChoice(choice: ToolChoice | undefined): unknown {
592
+ if (choice === undefined) return undefined;
593
+ if (typeof choice === "string") return choice;
594
+ if (choice.type === "tool" || choice.type === "custom") {
595
+ const name = choice.name.trim();
596
+ return name.length > 0 ? name : "auto";
597
+ }
598
+ return choice.mode;
599
+ }
600
+
601
+ /**
602
+ * Marks a tool call recorded into the text of an assistant turn.
603
+ *
604
+ * The upstream only reads structured turns on its CN realm, so on INTL a past
605
+ * call has to be described in prose or the model loses the record of what it
606
+ * asked for. The wording matches the reference bridge, which is what the
607
+ * upstream has been trained to read back.
608
+ */
609
+ const QODER_TOOL_MARKER = "[assistant 请求调用工具]";
610
+
611
+ /**
612
+ * Whether this request sends its tool history as structured turns.
613
+ *
614
+ * The upstream honours the structured form on its CN realm only. On INTL it is
615
+ * not enabled, and sending it anyway yields tool calls that come back right in
616
+ * name but with no arguments filled in — a call that cannot run. So the shape
617
+ * follows the realm rather than being assumed everywhere.
618
+ */
619
+ function qoderStructuredTools(realm: QoderRealm): boolean {
620
+ return realm === "cn";
621
+ }
622
+
482
623
  export function buildQoderRequest(
483
624
  modelId: string,
484
625
  request: CanonicalRequest,
485
626
  modelConfig: QoderModelConfig,
486
627
  auth: QoderAuth,
628
+ realm: QoderRealm = "intl",
487
629
  ): Record<string, unknown> {
488
630
  const allMessages = request.messages;
489
631
  const systemParts: string[] = [];
@@ -501,11 +643,11 @@ export function buildQoderRequest(
501
643
  }
502
644
  const systemPrompt = systemParts.join("\n\n").trim();
503
645
  const qoderMessages: Array<Record<string, unknown>> = [];
646
+ const structuredTools = qoderStructuredTools(realm);
504
647
  if (MODERN_PROFILE.mirrorTopLevelSystem && systemPrompt.length > 0) {
505
648
  qoderMessages.push({
506
649
  role: "system",
507
650
  content: systemPrompt,
508
- contents: [{ type: "text", text: systemPrompt }],
509
651
  });
510
652
  }
511
653
  for (const m of allMessages) {
@@ -514,7 +656,7 @@ export function buildQoderRequest(
514
656
  // so the result scan is role-agnostic. Each result becomes its own `tool`
515
657
  // turn carrying its `tool_call_id`; any non-result content on the same turn
516
658
  // is emitted after, so nothing is duplicated or lost.
517
- const results = toolResultParts(m);
659
+ const results = structuredTools ? toolResultParts(m) : [];
518
660
  if (results.length > 0) {
519
661
  for (const block of results) {
520
662
  const text = flattenCanonicalContent(
@@ -525,25 +667,75 @@ export function buildQoderRequest(
525
667
  qoderMessages.push({
526
668
  role: "tool",
527
669
  content: text,
528
- contents: [{ type: "text", text }],
529
670
  tool_call_id: block.call_id,
530
671
  });
531
672
  }
673
+ // Text that shared the turn with a result is emitted after it, minus the
674
+ // result itself. Re-emitting the result's own text here was doubling
675
+ // every tool answer: the `tool` turn above and a `user` turn with the
676
+ // same content, which doubled the payload for no reason.
532
677
  const remaining = m.content.filter((part) => part.kind !== "toolResult");
533
- if (remaining.length === 0) continue;
534
678
  const flat = flattenCanonicalContent(remaining);
679
+ if (flat.length === 0) continue;
535
680
  qoderMessages.push({
536
681
  role: "user",
537
682
  content: flat,
538
- contents: [{ type: "text", text: flat }],
683
+ });
684
+ continue;
685
+ }
686
+ // An assistant turn that called tools must carry the calls, not just its
687
+ // text. Falling through to the text-only emit dropped them, so the payload
688
+ // arrived as a `tool` result with no preceding call and the upstream
689
+ // rejected the whole transcript: "Messages with role 'tool' must be a
690
+ // response to a preceding message with 'tool_calls'".
691
+ const allCalls = m.content.filter((part) => part.kind === "toolCall");
692
+ const reasoningEcho = qoderReasoningEcho(m, modelId);
693
+ if (!structuredTools && allCalls.length > 0) {
694
+ // INTL does not read structured turns, so the call is written into the
695
+ // text instead of being dropped. Dropping it left the next `tool` turn
696
+ // answering a call that was never made, and the model had no record of
697
+ // what it had asked for — so it re-emitted a call with no arguments.
698
+ //
699
+ // The marker and the JSON shape mirror the reference bridge, which is
700
+ // what the upstream has actually been trained to read back.
701
+ const textParts = m.content.filter((part) => part.kind !== "toolCall");
702
+ const prose = flattenCanonicalContent(textParts);
703
+ const recorded = allCalls.map((part) => ({
704
+ name: part.name,
705
+ arguments: typeof part.arguments === "string" ? part.arguments : "",
706
+ }));
707
+ qoderMessages.push({
708
+ role: "assistant",
709
+ content: (prose.length > 0 ? prose + "\n\n" : "") + QODER_TOOL_MARKER + "\n" + JSON.stringify(recorded),
710
+ ...reasoningEcho,
711
+ });
712
+ continue;
713
+ }
714
+ const calls = structuredTools ? allCalls : [];
715
+ if (calls.length > 0) {
716
+ const textParts = m.content.filter((part) => part.kind !== "toolCall");
717
+ const flat = flattenCanonicalContent(textParts);
718
+ qoderMessages.push({
719
+ role: "assistant",
720
+ content: flat,
721
+ ...reasoningEcho,
722
+ tool_calls: calls.map((part) => ({
723
+ id: part.call_id,
724
+ type: "function",
725
+ function: {
726
+ name: part.name,
727
+ arguments: typeof part.arguments === "string" ? part.arguments : "",
728
+ },
729
+ })),
539
730
  });
540
731
  continue;
541
732
  }
542
733
  const flat = flattenCanonicalContent(m.content);
734
+ const role = m.role === "assistant" ? "assistant" : "user";
543
735
  qoderMessages.push({
544
- role: m.role === "assistant" ? "assistant" : "user",
736
+ role,
545
737
  content: flat,
546
- contents: [{ type: "text", text: flat }],
738
+ ...(role === "assistant" ? qoderReasoningEcho(m, modelId) : {}),
547
739
  });
548
740
  }
549
741
  const latestUserMessage = [...qoderMessages].reverse().find((m) => m["role"] === "user");
@@ -573,13 +765,21 @@ export function buildQoderRequest(
573
765
  chat_context: {
574
766
  chatPrompt: MODERN_PROFILE.mirrorTopLevelSystem ? systemPrompt : "",
575
767
  imageUrls: null,
576
- extra: { context: [], modelConfig: { key: modelId, is_reasoning: reasoning }, originalContent: latestUserText },
768
+ extra: { context: [], modelConfig: { key: modelId, is_reasoning: reasoning }, originalContent: { text: latestUserText } },
577
769
  features: [],
578
- text: latestUserText,
770
+ // An object, as the reference sends it: `cc["text"]["text"] = prompt`.
771
+ text: { text: latestUserText },
579
772
  },
580
773
  model_config: {
581
774
  key: modelId,
582
775
  display_name: modelConfig.display_name,
776
+ // Declares the wire dialect for the tool calls. Without it the upstream
777
+ // does not read the OpenAI-shaped `tools` array the same way, and the
778
+ // model then emits a call with no arguments filled in.
779
+ format: "openai",
780
+ model: "",
781
+ api_key: "",
782
+ url: "",
583
783
  is_vl: modelConfig.is_vl,
584
784
  is_reasoning: modelConfig.is_reasoning,
585
785
  max_input_tokens: modelConfig.max_input_tokens,
@@ -591,20 +791,259 @@ export function buildQoderRequest(
591
791
  },
592
792
  messages: qoderMessages,
593
793
  max_tokens: maxTokens,
794
+ // Always present, even when empty. Omitting the field makes Qoder fall back
795
+ // to the desktop agent's own toolset (Bash/Edit/...), which then leaks to a
796
+ // caller that declared none — the reference bridge sends `[]` for exactly
797
+ // that reason. Parallel calls are stated explicitly rather than left to the
798
+ // upstream default, so a caller expecting them is not served serial
799
+ // behaviour.
800
+ tools: qoderTools(request.tools),
801
+ parallel_tool_calls: true,
802
+ ...(() => {
803
+ const choice = qoderToolChoice(request.tool_choice);
804
+ return choice === undefined ? {} : { tool_choice: choice };
805
+ })(),
594
806
  };
595
807
  }
596
808
 
809
+ // ---------------------------------------------------------------------------
810
+ // DSML tool-call recovery — DeepSeek's native markup in the text stream
811
+ // ---------------------------------------------------------------------------
812
+ // Some DeepSeek-family models write their tool calls as XML-like DSML markup
813
+ // inside the plain-text `content` stream instead of the structured
814
+ // `tool_calls` field. Left alone, the markup reaches the caller as visible
815
+ // prose and the tool never runs — which is exactly the symptom reported.
816
+ //
817
+ // The reference bridge (`qoder_proxy.py`, `parse_dsml_tool_calls`) recovers
818
+ // these at end of stream. This is the same rule: buffer once DSML appears,
819
+ // parse at the end, emit the clean prose then the real tool calls.
820
+ //
821
+ // Patterns mirror the Python originals (`re.DOTALL` becomes the `s` flag):
822
+ // TAG_START = r"<[^>]*DSML[^>]*"
823
+ // DSML_CALLS_RE = TAG_START + r"calls>(.*?)</[^>]*DSML[^>]*calls>"
824
+ // DSML_INVOKE_RE = TAG_START + r"invoke\s+name=["']([^"']+)["']>(.*?)</[^>]*invoke>"
825
+ // DSML_PARAM_RE = TAG_START + r"parameter\s+name=["']([^"']+)["'][^>]*>(.*?)</[^>]*parameter>"
826
+
827
+ const DSML_CALLS_RE = /<[^>]*DSML[^>]*calls>([\s\S]*?)<\/[^>]*DSML[^>]*calls>/;
828
+ const DSML_INVOKE_RE = /<[^>]*DSML[^>]*invoke\s+name=["']([^"']+)["']>([\s\S]*?)<\/[^>]*invoke>/g;
829
+ const DSML_PARAM_RE = /<[^>]*DSML[^>]*parameter\s+name=["']([^"']+)["'][^>]*>([\s\S]*?)<\/[^>]*parameter>/g;
830
+
831
+ /** One recovered call, in the shape the canonical events want. */
832
+ export interface DsmlCall {
833
+ readonly id: string;
834
+ readonly name: string;
835
+ readonly arguments: string;
836
+ }
837
+
838
+ /**
839
+ * Recovers DSML tool calls out of a plain-text string.
840
+ *
841
+ * `clean` is the text with the markup removed. With no DSML present, `calls`
842
+ * is empty and `clean` is the original text unchanged — so this is safe to run
843
+ * over every stream, not just suspected ones.
844
+ */
845
+ export function parseDsmlToolCalls(text: string): { calls: DsmlCall[]; clean: string } {
846
+ if (text.length === 0 || !text.includes("DSML")) return { calls: [], clean: text };
847
+
848
+ const callsMatch = DSML_CALLS_RE.exec(text);
849
+ let block: string;
850
+ let clean: string;
851
+
852
+ if (callsMatch) {
853
+ block = callsMatch[1] ?? "";
854
+ const before = text.slice(0, callsMatch.index).trimEnd();
855
+ const after = text.slice(callsMatch.index + callsMatch[0].length).trimStart();
856
+ clean = (before + " " + after).trim();
857
+ } else {
858
+ // No <calls> wrapper (issue #51): find bare <invoke> blocks in the whole
859
+ // text, then cut the prose off at the last `<` before the first invoke.
860
+ DSML_INVOKE_RE.lastIndex = 0;
861
+ const first = DSML_INVOKE_RE.exec(text);
862
+ if (!first) return { calls: [], clean: text };
863
+ block = text;
864
+ const cut = text.lastIndexOf("<", first.index);
865
+ clean = text.slice(0, cut >= 0 ? cut : first.index).trim();
866
+ }
867
+
868
+ const calls: DsmlCall[] = [];
869
+ DSML_INVOKE_RE.lastIndex = 0;
870
+ let inv: RegExpExecArray | null;
871
+ while ((inv = DSML_INVOKE_RE.exec(block)) !== null) {
872
+ const params: Record<string, string> = {};
873
+ DSML_PARAM_RE.lastIndex = 0;
874
+ let par: RegExpExecArray | null;
875
+ while ((par = DSML_PARAM_RE.exec(inv[2] ?? "")) !== null) {
876
+ params[par[1] ?? ""] = (par[2] ?? "").trim();
877
+ }
878
+ calls.push({
879
+ id: `call_${randomUUID().replace(/-/g, "").slice(0, 16)}`,
880
+ name: inv[1] ?? "",
881
+ arguments: JSON.stringify(params),
882
+ });
883
+ }
884
+
885
+ return { calls, clean };
886
+ }
887
+
888
+ /**
889
+ * Escapes the bare quotes that belong inside a JSON string value.
890
+ *
891
+ * Escaped quotes are parked first so they are not escaped twice, then every
892
+ * remaining quote is escaped, then the parked ones are restored.
893
+ */
894
+ function repairJsonQuotes(text: string): string {
895
+ const ph = "\u0000";
896
+ return text.replace(/\\"/g, ph).replace(/"/g, '\\"').replaceAll(ph, '\\"');
897
+ }
898
+
899
+ /** `[{"name":"…","arguments":"<unescaped JSON>"}]`, one call only. */
900
+ const ECHO_LOOSE_RE = /^\[\s*\{\s*"name"\s*:\s*"([^"]{1,200})"\s*,\s*"arguments"\s*:\s*"(.*)"\s*\}\s*\]$/s;
901
+
902
+ /**
903
+ * Rebuilds the single-call echo shape when strict parsing fails.
904
+ *
905
+ * Returns `null` when the text is not that shape at all, so a caller can fall
906
+ * back rather than accepting a guess. The result still goes through the same
907
+ * strict checks as a normally-parsed array.
908
+ */
909
+ function salvageEchoArray(rest: string): unknown {
910
+ const m = ECHO_LOOSE_RE.exec(rest.trim());
911
+ if (!m) return null;
912
+ const name = m[1] ?? "";
913
+ const raw = m[2] ?? "";
914
+ for (const candidate of [raw, repairJsonQuotes(raw)]) {
915
+ try {
916
+ JSON.parse(candidate);
917
+ return [{ name, arguments: candidate }];
918
+ } catch {
919
+ continue;
920
+ }
921
+ }
922
+ return null;
923
+ }
924
+
925
+ /**
926
+ * Recovers a tool call the model echoed back as text.
927
+ *
928
+ * On INTL the upstream reads tool calls as prose, so a turn that called a tool
929
+ * is described with `QODER_TOOL_MARKER` followed by a JSON array of
930
+ * `{name, arguments}`. The model occasionally reproduces that shape verbatim as
931
+ * its answer instead of calling again, and left alone the client shows the JSON
932
+ * as content while the tool never runs.
933
+ *
934
+ * Only a well-formed array whose every entry names a tool and carries parseable
935
+ * arguments is accepted. Anything else returns no calls and the original text,
936
+ * so a model merely talking about the format is never mistaken for a call.
937
+ */
938
+ export function parseLeakedToolCalls(
939
+ text: string,
940
+ allowedNames?: ReadonlySet<string>,
941
+ ): { calls: DsmlCall[]; clean: string } {
942
+ if (text.length === 0 || !text.includes(QODER_TOOL_MARKER)) {
943
+ return { calls: [], clean: text };
944
+ }
945
+ const at = text.indexOf(QODER_TOOL_MARKER);
946
+ const head = text.slice(0, at).trim();
947
+ const rest = text.slice(at + QODER_TOOL_MARKER.length).trim();
948
+
949
+ let parsed: unknown;
950
+ try {
951
+ parsed = JSON.parse(rest);
952
+ } catch {
953
+ // A model echoing the call writes the inner quotes unescaped:
954
+ // `[{"name":"Read","arguments":"{"file_path":"a.ts"}"}]`. That is not
955
+ // valid JSON at either level, so the array has to be rebuilt before it can
956
+ // be trusted. Only the single-call shape is salvaged, which is what an echo
957
+ // almost always is.
958
+ const salvaged = salvageEchoArray(rest);
959
+ if (salvaged === null) return { calls: [], clean: text };
960
+ parsed = salvaged;
961
+ }
962
+ if (!Array.isArray(parsed) || parsed.length === 0) return { calls: [], clean: text };
963
+
964
+ const calls: DsmlCall[] = [];
965
+ for (const item of parsed) {
966
+ if (typeof item !== "object" || item === null) return { calls: [], clean: text };
967
+ const rec = item as Record<string, unknown>;
968
+ const name = typeof rec["name"] === "string" ? rec["name"].trim() : "";
969
+ if (name.length === 0) return { calls: [], clean: text };
970
+ if (allowedNames && !allowedNames.has(name)) return { calls: [], clean: text };
971
+ let args = rec["arguments"];
972
+ if (typeof args === "object" && args !== null) args = JSON.stringify(args);
973
+ if (typeof args !== "string") return { calls: [], clean: text };
974
+ // Arguments that do not parse are not a call this can trust.
975
+ if (args.trim().length > 0) {
976
+ try {
977
+ JSON.parse(args);
978
+ } catch {
979
+ return { calls: [], clean: text };
980
+ }
981
+ }
982
+ calls.push({ id: `call_${randomUUID().replace(/-/g, "").slice(0, 16)}`, name, arguments: args });
983
+ }
984
+ return { calls, clean: head };
985
+ }
986
+
987
+ /**
988
+ * The holdable tail of a content chunk: a short suffix that looks like the
989
+ * start of a DSML tag and may finish in the next frame. Empty when there is
990
+ * nothing worth holding.
991
+ *
992
+ * Mirrors `_dsml_tail_hold` in the reference bridge.
993
+ */
994
+ export function dsmlTailHold(text: string): string {
995
+ const at = text.lastIndexOf("<");
996
+ if (at < 0 || text.length - at > 24) return "";
997
+ const tail = text.slice(at);
998
+ // A complete tag already — nothing to wait for.
999
+ if (tail.includes(">")) return "";
1000
+ const body = tail.replace(/\uFF5C/g, "|").toUpperCase().slice(1).replace(/^\//, "");
1001
+ if (body.length === 0) return tail; // bare "<" or "</"
1002
+ if (!body.startsWith("|")) return "";
1003
+ const rest = body.slice(1);
1004
+ if (rest.length === 0) return tail;
1005
+ if ("DSML".startsWith(rest) || rest.startsWith("DSML")) return tail;
1006
+ return "";
1007
+ }
1008
+
1009
+ /**
1010
+ * Removes leftover DSML closing tags from the tail of a text.
1011
+ *
1012
+ * The upstream sometimes leaks only the tail of a tool-call block — bare
1013
+ * `</parameter>` / `</invoke>` / `</calls>` with no opening and, in the
1014
+ * thinking channel, occasionally without the `DSML` marker at all. Those
1015
+ * fragments are pure protocol residue: they are never natural prose, so
1016
+ * dropping them is safe and keeps internal markup out of what the user sees.
1017
+ *
1018
+ * Anchored to the end, as in the reference bridge: a closing tag in the middle
1019
+ * of a text is not this residue and stripping it would eat real prose. The
1020
+ * caller loops, so several trailing tags come off one per pass.
1021
+ *
1022
+ * Mirrors `_strip_dsml_close_tail`, widened to the bare form because the
1023
+ * thinking channel does not always carry the marker.
1024
+ */
1025
+ const DSML_CLOSE_TAG_RE = /<\/(?:[^>]*DSML[^>]*|parameter|invoke|calls|tool_calls|function_calls)\s*>\s*$/i;
1026
+
1027
+ export function stripDsmlCloseTags(text: string): string {
1028
+ let previous: string | null = null;
1029
+ let out = text.trimEnd();
1030
+ while (previous !== out) {
1031
+ previous = out;
1032
+ out = out.replace(DSML_CLOSE_TAG_RE, "").trimEnd();
1033
+ }
1034
+ return out;
1035
+ }
1036
+
597
1037
  // Stream transformation — decode SSE, unwrap COSY envelope, map OpenAI chat chunks to CanonicalEvent
598
1038
 
599
1039
  function mapQoderFinishReason(raw: unknown): CanonicalStopReason | undefined {
600
- if (raw === "stop") return "stop";
601
1040
  if (raw === "length") return "length";
602
1041
  if (raw === "tool_calls") return "tool_use";
603
1042
  if (raw === "content_filter") return "content_filter";
604
1043
  return undefined;
605
1044
  }
606
1045
 
607
- async function* qoderBodyToCanonicalEvents(
1046
+ export async function* qoderBodyToCanonicalEvents(
608
1047
  body: ReadableStream<Uint8Array>,
609
1048
  signal: AbortSignal,
610
1049
  model: string,
@@ -614,6 +1053,20 @@ async function* qoderBodyToCanonicalEvents(
614
1053
  let finishReason: unknown = undefined;
615
1054
  let rawUsage: Record<string, unknown> | undefined = undefined;
616
1055
  let hasTerminal = false;
1056
+ // DSML recovery state. While `buf` is empty the text passes through
1057
+ // untouched; once a chunk carries DSML the rest is buffered so the markup can
1058
+ // be parsed whole at end of stream. `tail` holds a possible half-tag that
1059
+ // straddles a frame boundary.
1060
+ let buf = "";
1061
+ let tail = "";
1062
+ /** Calls recovered from the thinking channel, emitted if content had none. */
1063
+ let reasonCalls: DsmlCall[] = [];
1064
+ // The same recovery for the thinking channel. A model that plans its call
1065
+ // inside `reasoning_content` writes the markup there instead of in `content`,
1066
+ // so recovering only the content channel loses the call *and* leaves the
1067
+ // tags visible in the user's thinking.
1068
+ let reasonBuf = "";
1069
+ let reasonTail = "";
617
1070
 
618
1071
  for await (const sse of decodeSseEvents(body, { signal })) {
619
1072
  const sseData = sse.data;
@@ -642,19 +1095,56 @@ async function* qoderBodyToCanonicalEvents(
642
1095
  const delta = choice["delta"] as Record<string, unknown> | undefined;
643
1096
  if (delta) {
644
1097
  if (typeof delta["content"] === "string" && (delta["content"] as string).length > 0) {
645
- yield {
646
- type: "content_delta",
647
- sequence_number: seq++,
648
- content: { kind: "text", text: delta["content"] as string },
649
- } as CanonicalEvent;
1098
+ const piece = tail + (delta["content"] as string);
1099
+ tail = "";
1100
+ if (buf.length === 0) {
1101
+ // Not yet in a DSML stream: hold back only a possible tag start,
1102
+ // and pass everything before it through unchanged.
1103
+ const hold = dsmlTailHold(piece);
1104
+ const body = hold.length > 0 ? piece.slice(0, piece.length - hold.length) : piece;
1105
+ if (body.includes("DSML")) {
1106
+ buf = body;
1107
+ tail = hold;
1108
+ continue;
1109
+ }
1110
+ if (body.length > 0) {
1111
+ yield {
1112
+ type: "content_delta",
1113
+ sequence_number: seq++,
1114
+ content: { kind: "text", text: body },
1115
+ } as CanonicalEvent;
1116
+ }
1117
+ tail = hold;
1118
+ continue;
1119
+ }
1120
+ buf += piece;
1121
+ continue;
650
1122
  }
651
1123
  const reasoningText = readReasoningText(delta);
652
1124
  if (typeof reasoningText === "string" && reasoningText.length > 0) {
653
- yield {
654
- type: "content_delta",
655
- sequence_number: seq++,
656
- content: { kind: "reasoning", payload: null, summary: reasoningText },
657
- } as CanonicalEvent;
1125
+ const piece = reasonTail + reasoningText;
1126
+ reasonTail = "";
1127
+ if (reasonBuf.length === 0) {
1128
+ const hold = dsmlTailHold(piece);
1129
+ const body = hold.length > 0 ? piece.slice(0, piece.length - hold.length) : piece;
1130
+ if (body.includes("DSML")) {
1131
+ reasonBuf = body;
1132
+ reasonTail = hold;
1133
+ continue;
1134
+ }
1135
+ const visible = stripDsmlCloseTags(body);
1136
+ if (visible.length > 0) {
1137
+ yield {
1138
+ type: "content_delta",
1139
+ sequence_number: seq++,
1140
+ content: { kind: "reasoning", payload: null, summary: visible },
1141
+ } as CanonicalEvent;
1142
+ }
1143
+ reasonTail = hold;
1144
+ continue;
1145
+ }
1146
+ reasonBuf += piece;
1147
+ continue;
658
1148
  }
659
1149
  const toolCalls = delta["tool_calls"] as Array<Record<string, unknown>> | undefined;
660
1150
  if (toolCalls) {
@@ -679,6 +1169,90 @@ async function* qoderBodyToCanonicalEvents(
679
1169
  if (json["finish_reason"] !== undefined) finishReason = json["finish_reason"];
680
1170
  }
681
1171
  }
1172
+ // End of stream: resolve whatever the DSML buffer collected.
1173
+ if (reasonBuf.length > 0) {
1174
+ const { calls, clean } = parseDsmlToolCalls(reasonBuf);
1175
+ const visible = stripDsmlCloseTags(clean).trim();
1176
+ if (visible.length > 0) {
1177
+ yield {
1178
+ type: "content_delta",
1179
+ sequence_number: seq++,
1180
+ content: { kind: "reasoning", payload: null, summary: visible },
1181
+ } as CanonicalEvent;
1182
+ }
1183
+ // A call recovered here counts exactly like one recovered from the content
1184
+ // channel — the two are deduplicated below, so a model that writes the
1185
+ // block in one channel and its tail in the other still yields one call.
1186
+ if (calls.length > 0) reasonCalls = calls;
1187
+ }
1188
+ if (buf.length > 0) {
1189
+ // Two ways a call can arrive as text: DSML markup, and the marker + JSON
1190
+ // array the upstream itself uses on INTL. The second is tried only when the
1191
+ // first finds nothing, so a DSML stream is never re-read as an echo.
1192
+ let { calls, clean } = parseDsmlToolCalls(buf);
1193
+ if (calls.length === 0) {
1194
+ const leaked = parseLeakedToolCalls(buf);
1195
+ if (leaked.calls.length > 0) {
1196
+ calls = leaked.calls;
1197
+ clean = leaked.clean;
1198
+ }
1199
+ }
1200
+ if (calls.length > 0) {
1201
+ const visible = stripDsmlCloseTags(clean).trim();
1202
+ if (visible.length > 0) {
1203
+ yield {
1204
+ type: "content_delta",
1205
+ sequence_number: seq++,
1206
+ content: { kind: "text", text: visible },
1207
+ } as CanonicalEvent;
1208
+ }
1209
+ for (const call of calls) {
1210
+ yield {
1211
+ type: "tool_call_delta",
1212
+ sequence_number: seq++,
1213
+ call_id: call.id,
1214
+ name: call.name,
1215
+ arguments_delta: call.arguments,
1216
+ } as CanonicalEvent;
1217
+ }
1218
+ reasonCalls = [];
1219
+ // The turn ended by calling a tool, whatever the upstream reported.
1220
+ finishReason = "tool_calls";
1221
+ } else {
1222
+ // Unparseable markup: emit as prose rather than dropping it, so nothing
1223
+ // the model said is lost.
1224
+ const visible = stripDsmlCloseTags(buf).trim();
1225
+ if (visible.length > 0) {
1226
+ yield {
1227
+ type: "content_delta",
1228
+ sequence_number: seq++,
1229
+ content: { kind: "text", text: visible },
1230
+ } as CanonicalEvent;
1231
+ }
1232
+ }
1233
+ }
1234
+ // Content carried no call of its own: whatever the thinking channel
1235
+ // recovered is the turn's call.
1236
+ for (const call of reasonCalls) {
1237
+ yield {
1238
+ type: "tool_call_delta",
1239
+ sequence_number: seq++,
1240
+ call_id: call.id,
1241
+ name: call.name,
1242
+ arguments_delta: call.arguments,
1243
+ } as CanonicalEvent;
1244
+ }
1245
+ // A recovered call outranks what the upstream reported. It says `stop`
1246
+ // because the markup never became a real tool call on its side, so leaving it
1247
+ // alone ends the turn "finished" with a call the caller never sees run.
1248
+ if (reasonCalls.length > 0) finishReason = "tool_calls";
1249
+ if (tail.length > 0) {
1250
+ yield {
1251
+ type: "content_delta",
1252
+ sequence_number: seq++,
1253
+ content: { kind: "text", text: tail },
1254
+ } as CanonicalEvent;
1255
+ }
682
1256
  // If no terminal arrived, synthesize one (prevents truncation error)
683
1257
  const stopReason = mapQoderFinishReason(finishReason);
684
1258
  const _providerStop = typeof finishReason === "string" ? finishReason : undefined;
@@ -794,7 +1368,7 @@ class QoderAdapter implements ProviderAdapter {
794
1368
  const auth = isQoderPat(secret)
795
1369
  ? await exchangeQoderPat(secret, lifecycle.signal, fetcher, version, this.realm)
796
1370
  : qoderOAuthAuth(secret, context.credential.auth_state, context.credential.account_id);
797
- const qoderBody = buildQoderRequest(modelId, request, modelConfig, auth);
1371
+ const qoderBody = buildQoderRequest(modelId, request, modelConfig, auth, this.realm);
798
1372
  // A full URL from the route wins, but the fallback is this realm's
799
1373
  // gateway — not the intl host hardcoded into `MODERN_PROFILE`. Dispatching
800
1374
  // a CN credential to `*.qoder.sh` is refused outright.