cartethyia 2.6.7 → 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.
- package/.env.example +106 -113
- package/dist/dashboard/assets/{BannedPage-Dskhyp0l.js → BannedPage-P76QLGJA.js} +1 -1
- package/dist/dashboard/assets/BannedPage-P76QLGJA.js.gz +0 -0
- package/dist/dashboard/assets/{CliToolDetailPage--SJhk7qQ.js → CliToolDetailPage-DdBggJS9.js} +1 -1
- package/dist/dashboard/assets/CliToolDetailPage-DdBggJS9.js.gz +0 -0
- package/dist/dashboard/assets/{CliToolsPage-C203VT-c.js → CliToolsPage-aN1iXhhP.js} +1 -1
- package/dist/dashboard/assets/CliToolsPage-aN1iXhhP.js.gz +0 -0
- package/dist/dashboard/assets/{CombosPage-BcS7kGG1.js → CombosPage-DE-F1nxZ.js} +1 -1
- package/dist/dashboard/assets/CombosPage-DE-F1nxZ.js.gz +0 -0
- package/dist/dashboard/assets/{ConsoleLogPage-ChmiSLwO.js → ConsoleLogPage-5SpmfLyw.js} +1 -1
- package/dist/dashboard/assets/ConsoleLogPage-5SpmfLyw.js.gz +0 -0
- package/dist/dashboard/assets/{CustomizationPage-uujbBYwC.js → CustomizationPage-3CUxMy2X.js} +1 -1
- package/dist/dashboard/assets/CustomizationPage-3CUxMy2X.js.gz +0 -0
- package/dist/dashboard/assets/{HealthEventsModal-fKAWd-OQ.js → HealthEventsModal-BkmJQKC0.js} +1 -1
- package/dist/dashboard/assets/HealthEventsModal-BkmJQKC0.js.gz +0 -0
- package/dist/dashboard/assets/{LoginPage-DoOUthbG.js → LoginPage-B_0FUKWc.js} +1 -1
- package/dist/dashboard/assets/LoginPage-B_0FUKWc.js.gz +0 -0
- package/dist/dashboard/assets/{ModelPicker-CA1kLs45.js → ModelPicker-DKVslqG3.js} +1 -1
- package/dist/dashboard/assets/ModelPicker-DKVslqG3.js.gz +0 -0
- package/dist/dashboard/assets/OverviewPage-D-H4MEiT.js +1 -0
- package/dist/dashboard/assets/OverviewPage-D-H4MEiT.js.gz +0 -0
- package/dist/dashboard/assets/{ProviderDetailPage-CFu1qGcT.js → ProviderDetailPage-CEJw6Guo.js} +3 -3
- package/dist/dashboard/assets/ProviderDetailPage-CEJw6Guo.js.gz +0 -0
- package/dist/dashboard/assets/{ProvidersPage-CnlJZbxF.js → ProvidersPage-CEplAR-i.js} +1 -1
- package/dist/dashboard/assets/ProvidersPage-CEplAR-i.js.gz +0 -0
- package/dist/dashboard/assets/{ProxyPage-B9mBTweh.js → ProxyPage-CAN7hy0f.js} +1 -1
- package/dist/dashboard/assets/ProxyPage-CAN7hy0f.js.gz +0 -0
- package/dist/dashboard/assets/{QuotaPage-BueSWX5D.js → QuotaPage-BjldQwIU.js} +1 -1
- package/dist/dashboard/assets/QuotaPage-BjldQwIU.js.gz +0 -0
- package/dist/dashboard/assets/SettingsPage-DUIoLRxk.js +1 -0
- package/dist/dashboard/assets/SettingsPage-DUIoLRxk.js.gz +0 -0
- package/dist/dashboard/assets/{SetupPage-DQ9oDRpi.js → SetupPage-_bCaTM0v.js} +1 -1
- package/dist/dashboard/assets/SetupPage-_bCaTM0v.js.gz +0 -0
- package/dist/dashboard/assets/{SortableList-DrKeMcGN.js → SortableList-fI7JkdIB.js} +1 -1
- package/dist/dashboard/assets/SortableList-fI7JkdIB.js.gz +0 -0
- package/dist/dashboard/assets/{StudioPage-qjuYtccc.js → StudioPage-CHVZqskZ.js} +1 -1
- package/dist/dashboard/assets/StudioPage-CHVZqskZ.js.gz +0 -0
- package/dist/dashboard/assets/UsagePage-DiFBymHj.js +56 -0
- package/dist/dashboard/assets/UsagePage-DiFBymHj.js.gz +0 -0
- package/dist/dashboard/assets/{api-keys-C23S9sgp.js → api-keys-DFY_fZU4.js} +1 -1
- package/dist/dashboard/assets/api-keys-DFY_fZU4.js.gz +0 -0
- package/dist/dashboard/assets/app-BOJ1_xUB.css +2 -0
- package/dist/dashboard/assets/app-BOJ1_xUB.css.gz +0 -0
- package/dist/dashboard/assets/{assert-DU6qrMA_.js → assert-CwdnZARt.js} +1 -1
- package/dist/dashboard/assets/assert-CwdnZARt.js.gz +0 -0
- package/dist/dashboard/assets/{badge-Bkz_-RrM.js → badge-Dr-uf0Au.js} +1 -1
- package/dist/dashboard/assets/badge-Dr-uf0Au.js.gz +0 -0
- package/dist/dashboard/assets/{banner-C-7y3VKV.js → banner-BddmMdoS.js} +1 -1
- package/dist/dashboard/assets/banner-BddmMdoS.js.gz +0 -0
- package/dist/dashboard/assets/{card-BOPBuBzD.js → card-CK0PXWlX.js} +1 -1
- package/dist/dashboard/assets/card-CK0PXWlX.js.gz +0 -0
- package/dist/dashboard/assets/{cli-tools-BaFUuFFF.js → cli-tools-CcB6mGh2.js} +1 -1
- package/dist/dashboard/assets/cli-tools-CcB6mGh2.js.gz +0 -0
- package/dist/dashboard/assets/{clipboard-button-e-Mlk0FH.js → clipboard-button-DGb2P9Yx.js} +1 -1
- package/dist/dashboard/assets/clipboard-button-DGb2P9Yx.js.gz +0 -0
- package/dist/dashboard/assets/{dialog-Iec_ywbD.js → dialog-LkfuL50u.js} +1 -1
- package/dist/dashboard/assets/dialog-LkfuL50u.js.gz +0 -0
- package/dist/dashboard/assets/{drawer-Bs8MIIHc.js → drawer-ZbEZ_84c.js} +1 -1
- package/dist/dashboard/assets/drawer-ZbEZ_84c.js.gz +0 -0
- package/dist/dashboard/assets/{entry-DcobaKFu.js → entry-BJfsVMvA.js} +1 -1
- package/dist/dashboard/assets/entry-BJfsVMvA.js.gz +0 -0
- package/dist/dashboard/assets/{entry-DOddwoc0.js → entry-CWBY5A0f.js} +3 -3
- package/dist/dashboard/assets/entry-CWBY5A0f.js.gz +0 -0
- package/dist/dashboard/assets/{entry-BRszm4_Z.js → entry-CvC1-Zqx.js} +1 -1
- package/dist/dashboard/assets/entry-CvC1-Zqx.js.gz +0 -0
- package/dist/dashboard/assets/{github-badge-BuDsrsPn.js → github-badge-BxpznFLt.js} +1 -1
- package/dist/dashboard/assets/github-badge-BxpznFLt.js.gz +0 -0
- package/dist/dashboard/assets/{index-BeLRhsmt.js → index-CDzP3ovJ.js} +2 -2
- package/dist/dashboard/assets/index-CDzP3ovJ.js.gz +0 -0
- package/dist/dashboard/assets/{inline-DToMICwl.js → inline-Ae13MQ5w.js} +1 -1
- package/dist/dashboard/assets/inline-Ae13MQ5w.js.gz +0 -0
- package/dist/dashboard/assets/{input-WlyHWeG2.js → input-D23fo7cp.js} +1 -1
- package/dist/dashboard/assets/input-D23fo7cp.js.gz +0 -0
- package/dist/dashboard/assets/{layout-DaJ5baJr.js → layout-CSsxjj5P.js} +1 -1
- package/dist/dashboard/assets/layout-CSsxjj5P.js.gz +0 -0
- package/dist/dashboard/assets/{list-row-B3oSK13z.js → list-row-dmbwZMDY.js} +1 -1
- package/dist/dashboard/assets/list-row-dmbwZMDY.js.gz +0 -0
- package/dist/dashboard/assets/{live-ta6GMb-8.js → live-BG1oUsZZ.js} +1 -1
- package/dist/dashboard/assets/live-BG1oUsZZ.js.gz +0 -0
- package/dist/dashboard/assets/minimize-2-D-MN8W8a.js +1 -0
- package/dist/dashboard/assets/minimize-2-D-MN8W8a.js.gz +0 -0
- package/dist/dashboard/assets/{mutation-g_fduiNw.js → mutation-DSF-e4ho.js} +1 -1
- package/dist/dashboard/assets/mutation-DSF-e4ho.js.gz +0 -0
- package/dist/dashboard/assets/{numbers-Dulqgys5.js → numbers-Bm_aECY7.js} +1 -1
- package/dist/dashboard/assets/{numbers-Dulqgys5.js.gz → numbers-Bm_aECY7.js.gz} +0 -0
- package/dist/dashboard/assets/{query-keys-UETqfytp.js → query-keys-CF56nYIV.js} +1 -1
- package/dist/dashboard/assets/query-keys-CF56nYIV.js.gz +0 -0
- package/dist/dashboard/assets/{routing-BPUo-W8d.js → routing-CeA71n7G.js} +1 -1
- package/dist/dashboard/assets/routing-CeA71n7G.js.gz +0 -0
- package/dist/dashboard/assets/{select-BfZ3SCTM.js → select-KIT0rC6G.js} +1 -1
- package/dist/dashboard/assets/select-KIT0rC6G.js.gz +0 -0
- package/dist/dashboard/assets/settings-B_2gkePf.js +1 -0
- package/dist/dashboard/assets/settings-B_2gkePf.js.gz +0 -0
- package/dist/dashboard/assets/{stack-B1w5BM0u.js → stack-D7WOSzcX.js} +1 -1
- package/dist/dashboard/assets/stack-D7WOSzcX.js.gz +0 -0
- package/dist/dashboard/assets/{state-DhZBfWgx.js → state-BBxtKZOQ.js} +1 -1
- package/dist/dashboard/assets/state-BBxtKZOQ.js.gz +0 -0
- package/dist/dashboard/assets/{switch-yD7D99Lg.js → switch-DNS_D5Ea.js} +1 -1
- package/dist/dashboard/assets/switch-DNS_D5Ea.js.gz +0 -0
- package/dist/dashboard/assets/{useMutation-EDFRkn1i.js → useMutation-BgoxdoMk.js} +1 -1
- package/dist/dashboard/assets/useMutation-BgoxdoMk.js.gz +0 -0
- package/dist/dashboard/index.html +1 -1
- package/dist/dashboard/index.html.gz +0 -0
- package/dist/dashboard/precompressed-assets.json +51 -51
- package/package.json +96 -96
- package/src/boot/app.ts +714 -722
- package/src/boot/dependencies.ts +729 -750
- package/src/config/resources.ts +70 -137
- package/src/config/security.ts +37 -73
- package/src/config/spec.ts +358 -378
- package/src/console/console-router.ts +225 -233
- package/src/console/domain-registration.ts +381 -391
- package/src/console/remote-support/contracts.ts +3 -1
- package/src/console/remote-support/service.ts +115 -6
- package/src/main.ts +167 -169
- package/src/observability/metrics.ts +467 -457
- package/src/observability/runtime-metrics.ts +269 -254
- package/src/protocol/gateway-error.ts +232 -233
- package/src/providers/integrations/commandcode/commandcode.ts +134 -104
- package/src/providers/integrations/commandcode/discovery.ts +6 -5
- package/src/providers/integrations/qoder/qoder.ts +597 -23
- package/src/security/abuse.ts +17 -0
- package/src/transport/guards/auth-guard.ts +109 -127
- package/src/transport/guards/pipeline.ts +220 -226
- package/src/transport/guards/request-context.ts +238 -318
- package/src/transport/request/inflight.ts +21 -2
- package/dist/dashboard/assets/BannedPage-Dskhyp0l.js.gz +0 -0
- package/dist/dashboard/assets/CliToolDetailPage--SJhk7qQ.js.gz +0 -0
- package/dist/dashboard/assets/CliToolsPage-C203VT-c.js.gz +0 -0
- package/dist/dashboard/assets/CombosPage-BcS7kGG1.js.gz +0 -0
- package/dist/dashboard/assets/ConsoleLogPage-ChmiSLwO.js.gz +0 -0
- package/dist/dashboard/assets/CustomizationPage-uujbBYwC.js.gz +0 -0
- package/dist/dashboard/assets/HealthEventsModal-fKAWd-OQ.js.gz +0 -0
- package/dist/dashboard/assets/LoginPage-DoOUthbG.js.gz +0 -0
- package/dist/dashboard/assets/ModelPicker-CA1kLs45.js.gz +0 -0
- package/dist/dashboard/assets/OverviewPage-C5Z-UBz1.js +0 -1
- package/dist/dashboard/assets/OverviewPage-C5Z-UBz1.js.gz +0 -0
- package/dist/dashboard/assets/ProviderDetailPage-CFu1qGcT.js.gz +0 -0
- package/dist/dashboard/assets/ProvidersPage-CnlJZbxF.js.gz +0 -0
- package/dist/dashboard/assets/ProxyPage-B9mBTweh.js.gz +0 -0
- package/dist/dashboard/assets/QuotaPage-BueSWX5D.js.gz +0 -0
- package/dist/dashboard/assets/SettingsPage-B0x00MVY.js +0 -1
- package/dist/dashboard/assets/SettingsPage-B0x00MVY.js.gz +0 -0
- package/dist/dashboard/assets/SetupPage-DQ9oDRpi.js.gz +0 -0
- package/dist/dashboard/assets/SortableList-DrKeMcGN.js.gz +0 -0
- package/dist/dashboard/assets/StudioPage-qjuYtccc.js.gz +0 -0
- package/dist/dashboard/assets/UsagePage-Be7aNviQ.js +0 -56
- package/dist/dashboard/assets/UsagePage-Be7aNviQ.js.gz +0 -0
- package/dist/dashboard/assets/api-keys-C23S9sgp.js.gz +0 -0
- package/dist/dashboard/assets/app-BUSiYL6F.css +0 -2
- package/dist/dashboard/assets/app-BUSiYL6F.css.gz +0 -0
- package/dist/dashboard/assets/assert-DU6qrMA_.js.gz +0 -0
- package/dist/dashboard/assets/badge-Bkz_-RrM.js.gz +0 -0
- package/dist/dashboard/assets/banner-C-7y3VKV.js.gz +0 -0
- package/dist/dashboard/assets/card-BOPBuBzD.js.gz +0 -0
- package/dist/dashboard/assets/cli-tools-BaFUuFFF.js.gz +0 -0
- package/dist/dashboard/assets/clipboard-button-e-Mlk0FH.js.gz +0 -0
- package/dist/dashboard/assets/dialog-Iec_ywbD.js.gz +0 -0
- package/dist/dashboard/assets/drawer-Bs8MIIHc.js.gz +0 -0
- package/dist/dashboard/assets/entry-BRszm4_Z.js.gz +0 -0
- package/dist/dashboard/assets/entry-DOddwoc0.js.gz +0 -0
- package/dist/dashboard/assets/entry-DcobaKFu.js.gz +0 -0
- package/dist/dashboard/assets/github-badge-BuDsrsPn.js.gz +0 -0
- package/dist/dashboard/assets/index-BeLRhsmt.js.gz +0 -0
- package/dist/dashboard/assets/inline-DToMICwl.js.gz +0 -0
- package/dist/dashboard/assets/input-WlyHWeG2.js.gz +0 -0
- package/dist/dashboard/assets/layout-DaJ5baJr.js.gz +0 -0
- package/dist/dashboard/assets/list-row-B3oSK13z.js.gz +0 -0
- package/dist/dashboard/assets/live-ta6GMb-8.js.gz +0 -0
- package/dist/dashboard/assets/model-bans-BmYTmbAk.js +0 -1
- package/dist/dashboard/assets/model-bans-BmYTmbAk.js.gz +0 -0
- package/dist/dashboard/assets/mutation-g_fduiNw.js.gz +0 -0
- package/dist/dashboard/assets/query-keys-UETqfytp.js.gz +0 -0
- package/dist/dashboard/assets/routing-BPUo-W8d.js.gz +0 -0
- package/dist/dashboard/assets/select-BfZ3SCTM.js.gz +0 -0
- package/dist/dashboard/assets/settings--3hxSyry.js +0 -1
- package/dist/dashboard/assets/settings--3hxSyry.js.gz +0 -0
- package/dist/dashboard/assets/stack-B1w5BM0u.js.gz +0 -0
- package/dist/dashboard/assets/state-DhZBfWgx.js.gz +0 -0
- package/dist/dashboard/assets/switch-yD7D99Lg.js.gz +0 -0
- package/dist/dashboard/assets/useMutation-EDFRkn1i.js.gz +0 -0
- package/src/console/admin/model-abuse/contracts.ts +0 -38
- package/src/console/admin/model-abuse/routes.ts +0 -97
- package/src/security/model-abuse.ts +0 -704
- package/src/transport/guards/concurrency-guard.ts +0 -125
- /package/dist/dashboard/assets/{app-MdVfwraB.js → app-CaXZ9SCn.js} +0 -0
- /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 {
|
|
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
|
-
|
|
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
|
|
736
|
+
role,
|
|
545
737
|
content: flat,
|
|
546
|
-
|
|
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
|
-
|
|
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
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
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
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
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.
|