@oh-my-pi/pi-ai 18.5.1 → 18.6.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/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,13 @@
|
|
|
2
2
|
|
|
3
3
|
## [Unreleased]
|
|
4
4
|
|
|
5
|
+
## [18.6.0] - 2026-10-03
|
|
6
|
+
|
|
7
|
+
### Fixed
|
|
8
|
+
|
|
9
|
+
- 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.
|
|
10
|
+
- 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)).
|
|
11
|
+
|
|
5
12
|
## [18.5.1] - 2026-10-03
|
|
6
13
|
|
|
7
14
|
### 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;
|
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.0",
|
|
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.0",
|
|
159
|
+
"@oh-my-pi/pi-catalog": "18.6.0",
|
|
160
|
+
"@oh-my-pi/pi-natives": "18.6.0",
|
|
161
|
+
"@oh-my-pi/pi-utils": "18.6.0",
|
|
162
|
+
"@oh-my-pi/pi-wire": "18.6.0"
|
|
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"
|
|
@@ -125,6 +130,15 @@ export class DeepSeekInbandScanner implements InbandScanner {
|
|
|
125
130
|
#dsmlParamRaw = "";
|
|
126
131
|
#rawBlock = "";
|
|
127
132
|
#stripLeadingWhitespace = false;
|
|
133
|
+
/**
|
|
134
|
+
* Visible text already carries a bare DSML `invoke`/`parameter` opener (a
|
|
135
|
+
* malformed call missing its `tool_calls` wrapper). Its closers are then kept
|
|
136
|
+
* verbatim instead of stripped as orphans, so the agent loop can find where
|
|
137
|
+
* the broken call ends and remove exactly that span, keeping prose after it.
|
|
138
|
+
*/
|
|
139
|
+
#bareDsmlOpenVisible = false;
|
|
140
|
+
/** Last few visible characters, so a bare opener split across chunks is still seen. */
|
|
141
|
+
#visibleTail = "";
|
|
128
142
|
|
|
129
143
|
constructor(options: InbandScannerOptions = {}) {
|
|
130
144
|
this.#parseThinking = options.parseThinking ?? true;
|
|
@@ -201,12 +215,11 @@ export class DeepSeekInbandScanner implements InbandScanner {
|
|
|
201
215
|
const match = findEarliestToken(this.#buffer, OUTSIDE_TOKENS);
|
|
202
216
|
if (!match) {
|
|
203
217
|
const hold = final ? 0 : partialSuffixOverlapAny(this.#buffer, OUTSIDE_TOKENS);
|
|
204
|
-
|
|
205
|
-
if (emit.length > 0) events.push({ type: "text", text: emit });
|
|
218
|
+
this.#emitText(this.#buffer.slice(0, this.#buffer.length - hold), events);
|
|
206
219
|
this.#buffer = this.#buffer.slice(this.#buffer.length - hold);
|
|
207
220
|
return;
|
|
208
221
|
}
|
|
209
|
-
|
|
222
|
+
this.#emitText(this.#buffer.slice(0, match.index), events);
|
|
210
223
|
this.#buffer = this.#buffer.slice(match.index);
|
|
211
224
|
if (this.#buffer.startsWith(DEEPSEEK_TOOL_CALLS_BEGIN)) {
|
|
212
225
|
this.#buffer = this.#buffer.slice(DEEPSEEK_TOOL_CALLS_BEGIN.length);
|
|
@@ -241,12 +254,22 @@ export class DeepSeekInbandScanner implements InbandScanner {
|
|
|
241
254
|
}
|
|
242
255
|
const orphanClose = this.#matchingOrphanDsmlClose();
|
|
243
256
|
if (orphanClose) {
|
|
257
|
+
if (this.#bareDsmlOpenVisible) this.#emitText(orphanClose, events);
|
|
244
258
|
this.#buffer = this.#buffer.slice(orphanClose.length);
|
|
245
259
|
continue;
|
|
246
260
|
}
|
|
247
261
|
const control = this.#matchingControlToken();
|
|
248
262
|
if (control) {
|
|
249
263
|
this.#buffer = this.#buffer.slice(control.length);
|
|
264
|
+
if (
|
|
265
|
+
this.#bareDsmlOpenVisible &&
|
|
266
|
+
(control === DSML_TOOL_CALLS_CLOSE_FULLWIDTH || control === DSML_TOOL_CALLS_CLOSE_ASCII)
|
|
267
|
+
) {
|
|
268
|
+
// Closes the malformed call: keep it as its end marker.
|
|
269
|
+
this.#emitText(control, events);
|
|
270
|
+
this.#bareDsmlOpenVisible = false;
|
|
271
|
+
continue;
|
|
272
|
+
}
|
|
250
273
|
this.#stripLeadingWhitespace = true;
|
|
251
274
|
continue;
|
|
252
275
|
}
|
|
@@ -254,6 +277,13 @@ export class DeepSeekInbandScanner implements InbandScanner {
|
|
|
254
277
|
}
|
|
255
278
|
}
|
|
256
279
|
|
|
280
|
+
#emitText(text: string, events: InbandScanEvent[]): void {
|
|
281
|
+
if (text.length === 0) return;
|
|
282
|
+
events.push({ type: "text", text });
|
|
283
|
+
this.#visibleTail = (this.#visibleTail + text).slice(-VISIBLE_TAIL_LIMIT);
|
|
284
|
+
if (!this.#bareDsmlOpenVisible && BARE_DSML_OPEN.test(this.#visibleTail)) this.#bareDsmlOpenVisible = true;
|
|
285
|
+
}
|
|
286
|
+
|
|
257
287
|
#consumeThinking(final: boolean, events: InbandScanEvent[]): void {
|
|
258
288
|
const close = this.#buffer.indexOf(THINK_CLOSE);
|
|
259
289
|
if (close === -1) {
|
|
@@ -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 = {
|
|
@@ -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
|
+
}
|