@oh-my-pi/pi-ai 18.5.1 → 18.6.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +13 -0
- package/dist/types/utils/dsml-leak.d.ts +29 -0
- package/dist/types/utils/stream-markup-healing.d.ts +4 -4
- package/package.json +6 -6
- package/src/dialect/deepseek.ts +57 -6
- package/src/images/google-antigravity.ts +0 -1
- package/src/providers/google-gemini-cli.ts +4 -0
- package/src/providers/openai-shared.ts +39 -16
- package/src/utils/dsml-leak.ts +109 -0
- package/src/utils/stream-markup-healing.ts +4 -4
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,19 @@
|
|
|
2
2
|
|
|
3
3
|
## [Unreleased]
|
|
4
4
|
|
|
5
|
+
## [18.6.1] - 2026-10-04
|
|
6
|
+
|
|
7
|
+
### Fixed
|
|
8
|
+
|
|
9
|
+
- Fixed compatibility with Command Code DeepSeek and other DeepSeek-family models by preserving the reasoning context required for warm OpenAI Responses sessions and correctly handling incomplete DSML tool-call wrappers in visible output.
|
|
10
|
+
|
|
11
|
+
## [18.6.0] - 2026-10-03
|
|
12
|
+
|
|
13
|
+
### Fixed
|
|
14
|
+
|
|
15
|
+
- Fixed Antigravity chat and image requests sending an outdated client version when the model list came from cache, which could make newer models such as Claude Opus 5.5 unavailable.
|
|
16
|
+
- When a DeepSeek model writes a broken DSML tool call (for example with the opening `<|DSML|tool_calls>` and `<|DSML|invoke>` tags missing), its closing tags are now kept in the streamed text instead of being dropped. This lets the agent remove exactly the broken call while keeping any text after it ([#14202](https://github.com/can1357/oh-my-pi/pull/14202) by [@H4vC](https://github.com/H4vC)).
|
|
17
|
+
|
|
5
18
|
## [18.5.1] - 2026-10-03
|
|
6
19
|
|
|
7
20
|
### Fixed
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Removal of DeepSeek DSML tool-call markup left in visible assistant text.
|
|
3
|
+
*
|
|
4
|
+
* DeepSeek models on OpenAI-compatible hosts sometimes emit their tool calls as
|
|
5
|
+
* raw `<|DSML|…>` markup in `content`. A well-formed envelope is healed into a
|
|
6
|
+
* structured call by `StreamMarkupHealing`, but a degraded model can drop the
|
|
7
|
+
* opening `<|DSML|tool_calls>` / `<|DSML|invoke name="…">` tags (or the host
|
|
8
|
+
* runs no healer at all). That markup must not stay in history: replaying it
|
|
9
|
+
* teaches the model to keep writing tool calls as text (issue #10556). Callers
|
|
10
|
+
* strip it before the message is committed and tell the model its call failed.
|
|
11
|
+
*/
|
|
12
|
+
import type { AssistantMessage, Model } from "../types.js";
|
|
13
|
+
/**
|
|
14
|
+
* Whether DSML leak recovery applies to `model`: a DeepSeek-class model, or one
|
|
15
|
+
* explicitly configured with the `dsml` stream healer (e.g. a `models.yml` entry
|
|
16
|
+
* whose id the classifier cannot place). Other models never write DSML tool
|
|
17
|
+
* calls, so DSML text from them is prose and is left alone.
|
|
18
|
+
*/
|
|
19
|
+
export declare function isDsmlLeakRecoveryTarget(model: Model): boolean;
|
|
20
|
+
/**
|
|
21
|
+
* Strip leaked DSML tool-call markup from `text`, leaving quoted code intact.
|
|
22
|
+
* Returns `undefined` when `text` carries no such markup.
|
|
23
|
+
*/
|
|
24
|
+
export declare function stripDsmlToolMarkup(text: string): string | undefined;
|
|
25
|
+
/**
|
|
26
|
+
* Remove leaked DSML tool-call markup from `message`'s text blocks in place,
|
|
27
|
+
* dropping blocks left empty. Returns true when anything was removed.
|
|
28
|
+
*/
|
|
29
|
+
export declare function removeDsmlToolMarkupLeak(message: AssistantMessage): boolean;
|
|
@@ -63,10 +63,10 @@ export declare class StreamMarkupHealing {
|
|
|
63
63
|
/** Drain accumulated tool calls from calls to {@link feed}. */
|
|
64
64
|
drainCompleted(): HealedToolCall[];
|
|
65
65
|
/**
|
|
66
|
-
* Flush held-back stream-end fragments as ordered events. Partial tool
|
|
67
|
-
*
|
|
68
|
-
*
|
|
69
|
-
*
|
|
66
|
+
* Flush held-back stream-end fragments as ordered events. Partial tool calls
|
|
67
|
+
* are dropped by the delegated scanners; a DSML wrapper without an invoke
|
|
68
|
+
* remains visible for agent-layer leak recovery. Unterminated thinking blocks
|
|
69
|
+
* are emitted as thinking.
|
|
70
70
|
*/
|
|
71
71
|
flushEvents(): StreamMarkupHealingEvent[];
|
|
72
72
|
/** Flush held-back text only. Reconstructed calls are retained for {@link drainCompleted}. */
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@oh-my-pi/pi-ai",
|
|
3
|
-
"version": "18.
|
|
3
|
+
"version": "18.6.1",
|
|
4
4
|
"description": "Unified LLM API with automatic model discovery and provider configuration",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"ai",
|
|
@@ -155,11 +155,11 @@
|
|
|
155
155
|
"fmt": "oxfmt --no-error-on-unmatched-pattern 'src/**/*.{ts,tsx}' '{test,bench,examples,scripts}/**/*.ts' '*.ts'"
|
|
156
156
|
},
|
|
157
157
|
"dependencies": {
|
|
158
|
-
"@oh-my-pi/omptype": "18.
|
|
159
|
-
"@oh-my-pi/pi-catalog": "18.
|
|
160
|
-
"@oh-my-pi/pi-natives": "18.
|
|
161
|
-
"@oh-my-pi/pi-utils": "18.
|
|
162
|
-
"@oh-my-pi/pi-wire": "18.
|
|
158
|
+
"@oh-my-pi/omptype": "18.6.1",
|
|
159
|
+
"@oh-my-pi/pi-catalog": "18.6.1",
|
|
160
|
+
"@oh-my-pi/pi-natives": "18.6.1",
|
|
161
|
+
"@oh-my-pi/pi-utils": "18.6.1",
|
|
162
|
+
"@oh-my-pi/pi-wire": "18.6.1"
|
|
163
163
|
},
|
|
164
164
|
"devDependencies": {
|
|
165
165
|
"@types/bun": "^1.3.14"
|
package/src/dialect/deepseek.ts
CHANGED
|
@@ -99,6 +99,11 @@ const DSML_INVOKE_TOKENS = [
|
|
|
99
99
|
] as const;
|
|
100
100
|
const DSML_PARAMETER_CLOSE_TOKENS = [DSML_PARAMETER_CLOSE_FULLWIDTH, DSML_PARAMETER_CLOSE_ASCII] as const;
|
|
101
101
|
|
|
102
|
+
// A DSML invoke/parameter opener in visible text: a call whose `tool_calls`
|
|
103
|
+
// wrapper is missing, so the envelope scanner never consumed it.
|
|
104
|
+
const BARE_DSML_OPEN = /<[||]DSML[||](?:invoke|parameter)\b/u;
|
|
105
|
+
const VISIBLE_TAIL_LIMIT = 48;
|
|
106
|
+
|
|
102
107
|
type State =
|
|
103
108
|
| "outside"
|
|
104
109
|
| "thinking"
|
|
@@ -123,8 +128,19 @@ export class DeepSeekInbandScanner implements InbandScanner {
|
|
|
123
128
|
#dsmlParamName = "";
|
|
124
129
|
#dsmlParamIsString = true;
|
|
125
130
|
#dsmlParamRaw = "";
|
|
131
|
+
/** Unclassified wrapper text is kept until an invoke proves it is a tool call. */
|
|
132
|
+
#pendingDsmlSection: string | undefined;
|
|
126
133
|
#rawBlock = "";
|
|
127
134
|
#stripLeadingWhitespace = false;
|
|
135
|
+
/**
|
|
136
|
+
* Visible text already carries a bare DSML `invoke`/`parameter` opener (a
|
|
137
|
+
* malformed call missing its `tool_calls` wrapper). Its closers are then kept
|
|
138
|
+
* verbatim instead of stripped as orphans, so the agent loop can find where
|
|
139
|
+
* the broken call ends and remove exactly that span, keeping prose after it.
|
|
140
|
+
*/
|
|
141
|
+
#bareDsmlOpenVisible = false;
|
|
142
|
+
/** Last few visible characters, so a bare opener split across chunks is still seen. */
|
|
143
|
+
#visibleTail = "";
|
|
128
144
|
|
|
129
145
|
constructor(options: InbandScannerOptions = {}) {
|
|
130
146
|
this.#parseThinking = options.parseThinking ?? true;
|
|
@@ -180,6 +196,12 @@ export class DeepSeekInbandScanner implements InbandScanner {
|
|
|
180
196
|
if (!this.#consumeDsmlParam(final, events)) break;
|
|
181
197
|
}
|
|
182
198
|
if (final && this.#state === "thinking") this.#endThinking(events);
|
|
199
|
+
if (final && this.#pendingDsmlSection !== undefined) {
|
|
200
|
+
this.#emitText(this.#pendingDsmlSection + this.#buffer, events);
|
|
201
|
+
this.#pendingDsmlSection = undefined;
|
|
202
|
+
this.#buffer = "";
|
|
203
|
+
this.#state = "outside";
|
|
204
|
+
}
|
|
183
205
|
if (final && this.#buffer.length === 0 && this.#rawBlock.length > 0) this.#rawBlock = "";
|
|
184
206
|
return events;
|
|
185
207
|
}
|
|
@@ -201,12 +223,11 @@ export class DeepSeekInbandScanner implements InbandScanner {
|
|
|
201
223
|
const match = findEarliestToken(this.#buffer, OUTSIDE_TOKENS);
|
|
202
224
|
if (!match) {
|
|
203
225
|
const hold = final ? 0 : partialSuffixOverlapAny(this.#buffer, OUTSIDE_TOKENS);
|
|
204
|
-
|
|
205
|
-
if (emit.length > 0) events.push({ type: "text", text: emit });
|
|
226
|
+
this.#emitText(this.#buffer.slice(0, this.#buffer.length - hold), events);
|
|
206
227
|
this.#buffer = this.#buffer.slice(this.#buffer.length - hold);
|
|
207
228
|
return;
|
|
208
229
|
}
|
|
209
|
-
|
|
230
|
+
this.#emitText(this.#buffer.slice(0, match.index), events);
|
|
210
231
|
this.#buffer = this.#buffer.slice(match.index);
|
|
211
232
|
if (this.#buffer.startsWith(DEEPSEEK_TOOL_CALLS_BEGIN)) {
|
|
212
233
|
this.#buffer = this.#buffer.slice(DEEPSEEK_TOOL_CALLS_BEGIN.length);
|
|
@@ -236,17 +257,28 @@ export class DeepSeekInbandScanner implements InbandScanner {
|
|
|
236
257
|
? DSML_TOOL_CALLS_OPEN_FULLWIDTH
|
|
237
258
|
: DSML_TOOL_CALLS_OPEN_ASCII;
|
|
238
259
|
this.#buffer = this.#buffer.slice(openToken.length);
|
|
260
|
+
this.#pendingDsmlSection = openToken;
|
|
239
261
|
this.#state = "dsmlSection";
|
|
240
262
|
return;
|
|
241
263
|
}
|
|
242
264
|
const orphanClose = this.#matchingOrphanDsmlClose();
|
|
243
265
|
if (orphanClose) {
|
|
266
|
+
if (this.#bareDsmlOpenVisible) this.#emitText(orphanClose, events);
|
|
244
267
|
this.#buffer = this.#buffer.slice(orphanClose.length);
|
|
245
268
|
continue;
|
|
246
269
|
}
|
|
247
270
|
const control = this.#matchingControlToken();
|
|
248
271
|
if (control) {
|
|
249
272
|
this.#buffer = this.#buffer.slice(control.length);
|
|
273
|
+
if (
|
|
274
|
+
this.#bareDsmlOpenVisible &&
|
|
275
|
+
(control === DSML_TOOL_CALLS_CLOSE_FULLWIDTH || control === DSML_TOOL_CALLS_CLOSE_ASCII)
|
|
276
|
+
) {
|
|
277
|
+
// Closes the malformed call: keep it as its end marker.
|
|
278
|
+
this.#emitText(control, events);
|
|
279
|
+
this.#bareDsmlOpenVisible = false;
|
|
280
|
+
continue;
|
|
281
|
+
}
|
|
250
282
|
this.#stripLeadingWhitespace = true;
|
|
251
283
|
continue;
|
|
252
284
|
}
|
|
@@ -254,6 +286,13 @@ export class DeepSeekInbandScanner implements InbandScanner {
|
|
|
254
286
|
}
|
|
255
287
|
}
|
|
256
288
|
|
|
289
|
+
#emitText(text: string, events: InbandScanEvent[]): void {
|
|
290
|
+
if (text.length === 0) return;
|
|
291
|
+
events.push({ type: "text", text });
|
|
292
|
+
this.#visibleTail = (this.#visibleTail + text).slice(-VISIBLE_TAIL_LIMIT);
|
|
293
|
+
if (!this.#bareDsmlOpenVisible && BARE_DSML_OPEN.test(this.#visibleTail)) this.#bareDsmlOpenVisible = true;
|
|
294
|
+
}
|
|
295
|
+
|
|
257
296
|
#consumeThinking(final: boolean, events: InbandScanEvent[]): void {
|
|
258
297
|
const close = this.#buffer.indexOf(THINK_CLOSE);
|
|
259
298
|
if (close === -1) {
|
|
@@ -351,16 +390,25 @@ export class DeepSeekInbandScanner implements InbandScanner {
|
|
|
351
390
|
}
|
|
352
391
|
|
|
353
392
|
#consumeDsmlSection(final: boolean, events: InbandScanEvent[]): boolean {
|
|
393
|
+
const initial = this.#buffer;
|
|
354
394
|
while (this.#buffer.length > 0) {
|
|
355
395
|
this.#skipWhitespace();
|
|
356
396
|
const close = this.#matchingDsmlClose(DSML_TOOL_CALLS_CLOSE_FULLWIDTH, DSML_TOOL_CALLS_CLOSE_ASCII);
|
|
357
397
|
if (close) {
|
|
398
|
+
if (this.#pendingDsmlSection !== undefined) {
|
|
399
|
+
this.#emitText(
|
|
400
|
+
this.#pendingDsmlSection + initial.slice(0, initial.length - this.#buffer.length) + close,
|
|
401
|
+
events,
|
|
402
|
+
);
|
|
403
|
+
this.#pendingDsmlSection = undefined;
|
|
404
|
+
}
|
|
358
405
|
this.#buffer = this.#buffer.slice(close.length);
|
|
359
406
|
this.#state = "outside";
|
|
360
407
|
return true;
|
|
361
408
|
}
|
|
362
409
|
const invoke = this.#matchDsmlOpen("invoke");
|
|
363
410
|
if (invoke) {
|
|
411
|
+
this.#pendingDsmlSection = undefined;
|
|
364
412
|
this.#rawBlock = invoke.raw;
|
|
365
413
|
this.#name = invoke.name;
|
|
366
414
|
this.#id = mintToolCallId();
|
|
@@ -374,12 +422,15 @@ export class DeepSeekInbandScanner implements InbandScanner {
|
|
|
374
422
|
(this.#buffer.startsWith("<|DSML|invoke") || this.#buffer.startsWith("<|DSML|invoke")) &&
|
|
375
423
|
!this.#buffer.includes(">")
|
|
376
424
|
)
|
|
377
|
-
|
|
378
|
-
if (partialSuffixOverlapAny(this.#buffer, DSML_SECTION_TOKENS) === this.#buffer.length)
|
|
425
|
+
break;
|
|
426
|
+
if (partialSuffixOverlapAny(this.#buffer, DSML_SECTION_TOKENS) === this.#buffer.length) break;
|
|
379
427
|
}
|
|
380
|
-
if (this.#buffer.length === 0)
|
|
428
|
+
if (this.#buffer.length === 0) break;
|
|
381
429
|
this.#buffer = this.#buffer.slice(1);
|
|
382
430
|
}
|
|
431
|
+
if (this.#pendingDsmlSection !== undefined) {
|
|
432
|
+
this.#pendingDsmlSection += initial.slice(0, initial.length - this.#buffer.length);
|
|
433
|
+
}
|
|
383
434
|
return final;
|
|
384
435
|
}
|
|
385
436
|
|
|
@@ -60,7 +60,6 @@ async function resolveTarget(
|
|
|
60
60
|
const advertised = await fetchAntigravityImageModel({
|
|
61
61
|
token: credentials.accessToken,
|
|
62
62
|
endpoint: endpoints.length === 1 ? endpoints[0] : undefined,
|
|
63
|
-
userAgent: getAntigravityUserAgent(),
|
|
64
63
|
signal,
|
|
65
64
|
fetcher: fetchImpl,
|
|
66
65
|
});
|
|
@@ -8,6 +8,7 @@ import { scheduler } from "node:timers/promises";
|
|
|
8
8
|
import { type } from "@oh-my-pi/omptype";
|
|
9
9
|
import { calculateCost } from "@oh-my-pi/pi-catalog/models";
|
|
10
10
|
import {
|
|
11
|
+
ensureAntigravityVersion,
|
|
11
12
|
getAntigravityModelWireProfile,
|
|
12
13
|
getAntigravityUserAgent,
|
|
13
14
|
getGeminiCliHeaders,
|
|
@@ -577,6 +578,9 @@ export const streamGoogleGeminiCli: StreamFunction<"google-gemini-cli"> = (
|
|
|
577
578
|
if (replacementPayload !== undefined) {
|
|
578
579
|
requestBody = replacementPayload as typeof requestBody;
|
|
579
580
|
}
|
|
581
|
+
// The backend gates newer models on the client version; a process that
|
|
582
|
+
// skipped discovery (fresh model cache) must still send the current one.
|
|
583
|
+
if (isAntigravity) await ensureAntigravityVersion(options?.fetch ?? fetch, options?.signal);
|
|
580
584
|
const headers = isAntigravity ? { "User-Agent": getAntigravityUserAgent() } : getGeminiCliHeaders(model.id);
|
|
581
585
|
|
|
582
586
|
const requestHeaders = {
|
|
@@ -2303,6 +2303,30 @@ function createSyntheticResponsesReasoningItem(
|
|
|
2303
2303
|
return item as ResponseReasoningItem;
|
|
2304
2304
|
}
|
|
2305
2305
|
|
|
2306
|
+
/**
|
|
2307
|
+
* Fill a replayed reasoning item's missing `reasoning_text` for targets that
|
|
2308
|
+
* require it on every replayed turn (DeepSeek family, #10690). Servers that
|
|
2309
|
+
* stream reasoning as summary text return summary-only items; a warm session
|
|
2310
|
+
* replays them natively, so without this the wire carries no `reasoning_text`
|
|
2311
|
+
* while the cold rebuild of the same turn does (#14288). The summary is the
|
|
2312
|
+
* same text the cold path carries from the thinking block. Items carrying
|
|
2313
|
+
* `encrypted_content` are the server's opaque reasoning and replay untouched:
|
|
2314
|
+
* OpenRouter sets the tool-call requirement for every reasoning model, and its
|
|
2315
|
+
* OpenAI-family items must not gain a summary posing as raw reasoning text.
|
|
2316
|
+
*/
|
|
2317
|
+
function withRequiredReasoningText(item: ResponseReasoningItem): ResponseReasoningItem {
|
|
2318
|
+
if (typeof item.encrypted_content === "string") return item;
|
|
2319
|
+
if (item.content?.some(part => part.type === "reasoning_text" && part.text.trim().length > 0)) return item;
|
|
2320
|
+
const summaryText = (item.summary ?? [])
|
|
2321
|
+
.map(part => part.text)
|
|
2322
|
+
.filter(text => text.trim().length > 0)
|
|
2323
|
+
.join("\n\n");
|
|
2324
|
+
return {
|
|
2325
|
+
...item,
|
|
2326
|
+
content: [{ type: "reasoning_text", text: summaryText || SYNTHETIC_REASONING_REPLAY_PLACEHOLDER }],
|
|
2327
|
+
};
|
|
2328
|
+
}
|
|
2329
|
+
|
|
2306
2330
|
function isResponsesAssistantTurnBoundary(item: ResponseInput[number]): boolean {
|
|
2307
2331
|
if (responsesToolOutputKind(item.type) !== undefined) return true;
|
|
2308
2332
|
if (item.type === "compaction") return true;
|
|
@@ -2317,7 +2341,8 @@ function ensureRequiredResponsesReasoningReplay(
|
|
|
2317
2341
|
): ResponseInput {
|
|
2318
2342
|
if (stopReason === "error" || (!requiresAllTurns && !requiresToolCalls)) return items;
|
|
2319
2343
|
|
|
2320
|
-
const
|
|
2344
|
+
const repaired: ResponseInput = [];
|
|
2345
|
+
let changed = false;
|
|
2321
2346
|
let turnStart = 0;
|
|
2322
2347
|
for (let index = 0; index <= items.length; index++) {
|
|
2323
2348
|
if (index < items.length && !isResponsesAssistantTurnBoundary(items[index])) continue;
|
|
@@ -2334,23 +2359,21 @@ function ensureRequiredResponsesReasoningReplay(
|
|
|
2334
2359
|
hasContent = true;
|
|
2335
2360
|
if (classifyResponsesBatchItem(item) === "call") hasToolCall = true;
|
|
2336
2361
|
}
|
|
2337
|
-
|
|
2338
|
-
|
|
2339
|
-
}
|
|
2340
|
-
turnStart = index + 1;
|
|
2341
|
-
}
|
|
2342
|
-
if (insertBefore.length === 0) return items;
|
|
2343
|
-
|
|
2344
|
-
const repaired: ResponseInput = [];
|
|
2345
|
-
let insertionIndex = 0;
|
|
2346
|
-
for (let index = 0; index < items.length; index++) {
|
|
2347
|
-
if (insertBefore[insertionIndex] === index) {
|
|
2362
|
+
const required = hasContent && (requiresAllTurns || (requiresToolCalls && hasToolCall));
|
|
2363
|
+
if (required && !hasReasoning) {
|
|
2348
2364
|
repaired.push(createSyntheticResponsesReasoningItem());
|
|
2349
|
-
|
|
2365
|
+
changed = true;
|
|
2350
2366
|
}
|
|
2351
|
-
|
|
2367
|
+
for (let turnIndex = turnStart; turnIndex < index; turnIndex++) {
|
|
2368
|
+
const item = items[turnIndex];
|
|
2369
|
+
const replayed = required && item.type === "reasoning" ? withRequiredReasoningText(item) : item;
|
|
2370
|
+
if (replayed !== item) changed = true;
|
|
2371
|
+
repaired.push(replayed);
|
|
2372
|
+
}
|
|
2373
|
+
if (index < items.length) repaired.push(items[index]);
|
|
2374
|
+
turnStart = index + 1;
|
|
2352
2375
|
}
|
|
2353
|
-
return repaired;
|
|
2376
|
+
return changed ? repaired : items;
|
|
2354
2377
|
}
|
|
2355
2378
|
|
|
2356
2379
|
export function convertResponsesAssistantMessage<TApi extends Api>(
|
|
@@ -2404,7 +2427,7 @@ export function convertResponsesAssistantMessage<TApi extends Api>(
|
|
|
2404
2427
|
}
|
|
2405
2428
|
const reasoningItem = parseResponseReasoningReplayItem(block.thinkingSignature);
|
|
2406
2429
|
if (reasoningItem) {
|
|
2407
|
-
outputItems.push(reasoningItem);
|
|
2430
|
+
outputItems.push(requiresReasoningItem ? withRequiredReasoningText(reasoningItem) : reasoningItem);
|
|
2408
2431
|
reasoningItemEmitted = true;
|
|
2409
2432
|
}
|
|
2410
2433
|
continue;
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Removal of DeepSeek DSML tool-call markup left in visible assistant text.
|
|
3
|
+
*
|
|
4
|
+
* DeepSeek models on OpenAI-compatible hosts sometimes emit their tool calls as
|
|
5
|
+
* raw `<|DSML|…>` markup in `content`. A well-formed envelope is healed into a
|
|
6
|
+
* structured call by `StreamMarkupHealing`, but a degraded model can drop the
|
|
7
|
+
* opening `<|DSML|tool_calls>` / `<|DSML|invoke name="…">` tags (or the host
|
|
8
|
+
* runs no healer at all). That markup must not stay in history: replaying it
|
|
9
|
+
* teaches the model to keep writing tool calls as text (issue #10556). Callers
|
|
10
|
+
* strip it before the message is committed and tell the model its call failed.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import type { AssistantMessage, Model } from "../types";
|
|
14
|
+
|
|
15
|
+
// Structural DSML tags in either pipe spelling (fullwidth `|` U+FF5C or ASCII),
|
|
16
|
+
// tolerating stray whitespace inside the delimiter run.
|
|
17
|
+
const DSML_TAG_PATTERN = /<\s*\/?\s*[||]\s*DSML\s*[||]\s*(?:tool_calls|invoke|parameter)\b[^<>]*>?/gu;
|
|
18
|
+
// Code the model deliberately quoted (fences, inline spans) is not a leak.
|
|
19
|
+
const QUOTED_CODE_PATTERN = /(```|~~~)[\s\S]*?(?:\1|$)|`[^`\n]*`/g;
|
|
20
|
+
// The identifier-only line a degraded model writes in place of
|
|
21
|
+
// `<|DSML|invoke name="…">` (e.g. `bash\n<|DSML|parameter …>`): part of the
|
|
22
|
+
// broken call, so it goes with the markup.
|
|
23
|
+
const TRAILING_TOOL_NAME_LINE = /(^|\n)[ \t]*[A-Za-z_][\w.-]*[ \t]*\r?\n[ \t]*$/;
|
|
24
|
+
const CLOSING_TAG_PATTERN = /^<\s*\//;
|
|
25
|
+
const BLANK_LINE_PATTERN = /\r?\n[ \t]*\r?\n/;
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Remove the broken tool call from one stretch of unquoted text: everything from
|
|
29
|
+
* the first DSML tag (or the tool-name line just before it) through the last.
|
|
30
|
+
* The DSML scanner keeps a malformed call's closers, so its last closer marks
|
|
31
|
+
* the end. When the closers never arrived, the dangling argument value runs to
|
|
32
|
+
* the next blank line (or the end of the stretch); prose after that survives.
|
|
33
|
+
*/
|
|
34
|
+
function stripUnquoted(text: string): string {
|
|
35
|
+
const tags = [...text.matchAll(DSML_TAG_PATTERN)];
|
|
36
|
+
if (tags.length === 0) return text;
|
|
37
|
+
const first = tags[0]!;
|
|
38
|
+
const last = tags[tags.length - 1]!;
|
|
39
|
+
let start = first.index;
|
|
40
|
+
const nameLine = TRAILING_TOOL_NAME_LINE.exec(text.slice(0, start));
|
|
41
|
+
if (nameLine) start = nameLine.index + nameLine[1]!.length;
|
|
42
|
+
const lastEnd = last.index + last[0].length;
|
|
43
|
+
let end = lastEnd;
|
|
44
|
+
if (!CLOSING_TAG_PATTERN.test(last[0])) {
|
|
45
|
+
const blankLine = text.slice(lastEnd).search(BLANK_LINE_PATTERN);
|
|
46
|
+
end = blankLine === -1 ? text.length : lastEnd + blankLine;
|
|
47
|
+
}
|
|
48
|
+
const before = text.slice(0, start).trimEnd();
|
|
49
|
+
const after = text.slice(end).trimStart();
|
|
50
|
+
return before.length > 0 && after.length > 0 ? `${before}\n\n${after}` : before + after;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Whether DSML leak recovery applies to `model`: a DeepSeek-class model, or one
|
|
55
|
+
* explicitly configured with the `dsml` stream healer (e.g. a `models.yml` entry
|
|
56
|
+
* whose id the classifier cannot place). Other models never write DSML tool
|
|
57
|
+
* calls, so DSML text from them is prose and is left alone.
|
|
58
|
+
*/
|
|
59
|
+
export function isDsmlLeakRecoveryTarget(model: Model): boolean {
|
|
60
|
+
if (model.identity?.class === "deepseek") return true;
|
|
61
|
+
const compat = model.compat;
|
|
62
|
+
return (
|
|
63
|
+
compat !== undefined && "streamMarkupHealingPattern" in compat && compat.streamMarkupHealingPattern === "dsml"
|
|
64
|
+
);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Strip leaked DSML tool-call markup from `text`, leaving quoted code intact.
|
|
69
|
+
* Returns `undefined` when `text` carries no such markup.
|
|
70
|
+
*/
|
|
71
|
+
export function stripDsmlToolMarkup(text: string): string | undefined {
|
|
72
|
+
if (!text.includes("DSML")) return undefined;
|
|
73
|
+
let out = "";
|
|
74
|
+
let cursor = 0;
|
|
75
|
+
let changed = false;
|
|
76
|
+
const flush = (end: number): void => {
|
|
77
|
+
const segment = text.slice(cursor, end);
|
|
78
|
+
const stripped = stripUnquoted(segment);
|
|
79
|
+
if (stripped !== segment) changed = true;
|
|
80
|
+
out += stripped;
|
|
81
|
+
};
|
|
82
|
+
for (const quoted of text.matchAll(QUOTED_CODE_PATTERN)) {
|
|
83
|
+
flush(quoted.index);
|
|
84
|
+
out += quoted[0];
|
|
85
|
+
cursor = quoted.index + quoted[0].length;
|
|
86
|
+
}
|
|
87
|
+
flush(text.length);
|
|
88
|
+
return changed ? out : undefined;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Remove leaked DSML tool-call markup from `message`'s text blocks in place,
|
|
93
|
+
* dropping blocks left empty. Returns true when anything was removed.
|
|
94
|
+
*/
|
|
95
|
+
export function removeDsmlToolMarkupLeak(message: AssistantMessage): boolean {
|
|
96
|
+
let removed = false;
|
|
97
|
+
const content: AssistantMessage["content"] = [];
|
|
98
|
+
for (const block of message.content) {
|
|
99
|
+
const stripped = block.type === "text" ? stripDsmlToolMarkup(block.text) : undefined;
|
|
100
|
+
if (block.type !== "text" || stripped === undefined) {
|
|
101
|
+
content.push(block);
|
|
102
|
+
continue;
|
|
103
|
+
}
|
|
104
|
+
removed = true;
|
|
105
|
+
if (stripped.trim().length > 0) content.push({ ...block, text: stripped });
|
|
106
|
+
}
|
|
107
|
+
if (removed) message.content = content;
|
|
108
|
+
return removed;
|
|
109
|
+
}
|
|
@@ -126,10 +126,10 @@ export class StreamMarkupHealing {
|
|
|
126
126
|
}
|
|
127
127
|
|
|
128
128
|
/**
|
|
129
|
-
* Flush held-back stream-end fragments as ordered events. Partial tool
|
|
130
|
-
*
|
|
131
|
-
*
|
|
132
|
-
*
|
|
129
|
+
* Flush held-back stream-end fragments as ordered events. Partial tool calls
|
|
130
|
+
* are dropped by the delegated scanners; a DSML wrapper without an invoke
|
|
131
|
+
* remains visible for agent-layer leak recovery. Unterminated thinking blocks
|
|
132
|
+
* are emitted as thinking.
|
|
133
133
|
*/
|
|
134
134
|
flushEvents(): StreamMarkupHealingEvent[] {
|
|
135
135
|
const tail = this.#toolScanner ? this.#healThinking(this.#toolScanner.flush()) : [];
|