@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.5.1",
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.5.1",
159
- "@oh-my-pi/pi-catalog": "18.5.1",
160
- "@oh-my-pi/pi-natives": "18.5.1",
161
- "@oh-my-pi/pi-utils": "18.5.1",
162
- "@oh-my-pi/pi-wire": "18.5.1"
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"
@@ -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
- const emit = this.#buffer.slice(0, this.#buffer.length - hold);
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
- if (match.index > 0) events.push({ type: "text", text: this.#buffer.slice(0, match.index) });
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
+ }