@linxiraos/pi-ai 1.2.1 → 1.3.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.
Files changed (153) hide show
  1. package/CHANGELOG.md +98 -0
  2. package/README.md +4 -2
  3. package/THIRD-PARTY-NOTICES.txt +55 -5
  4. package/dist/types/auth/cascade.d.ts +5 -3
  5. package/dist/types/auth/pool.d.ts +3 -3
  6. package/dist/types/auth/select.d.ts +5 -1
  7. package/dist/types/auth/types.d.ts +15 -5
  8. package/dist/types/auth/usage-cache.d.ts +3 -1
  9. package/dist/types/auth-gateway/clients.d.ts +67 -0
  10. package/dist/types/auth-gateway/dispatch.d.ts +28 -10
  11. package/dist/types/auth-gateway/http.d.ts +2 -0
  12. package/dist/types/auth-gateway/index.d.ts +1 -0
  13. package/dist/types/auth-gateway/server.d.ts +9 -4
  14. package/dist/types/auth-gateway/session-state.d.ts +6 -0
  15. package/dist/types/auth-gateway/types.d.ts +16 -2
  16. package/dist/types/dialect/coercion.d.ts +6 -0
  17. package/dist/types/dialect/deepseek.d.ts +13 -0
  18. package/dist/types/error/classes.d.ts +1 -1
  19. package/dist/types/error/flags.d.ts +15 -0
  20. package/dist/types/error/index.d.ts +1 -0
  21. package/dist/types/error/network-cause.d.ts +40 -0
  22. package/dist/types/images/shared.d.ts +26 -2
  23. package/dist/types/index.d.ts +1 -0
  24. package/dist/types/judgment/native.d.ts +62 -3
  25. package/dist/types/judgment/systemone.d.ts +9 -2
  26. package/dist/types/judgment/text.d.ts +11 -5
  27. package/dist/types/judgment/types.d.ts +37 -6
  28. package/dist/types/judgment/wire.d.ts +18 -3
  29. package/dist/types/provider-session-state.d.ts +2 -2
  30. package/dist/types/providers/anthropic-images.d.ts +7 -0
  31. package/dist/types/providers/anthropic-signature.d.ts +12 -2
  32. package/dist/types/providers/anthropic-state.d.ts +7 -0
  33. package/dist/types/providers/anthropic.d.ts +21 -6
  34. package/dist/types/providers/connection-prewarm.d.ts +15 -0
  35. package/dist/types/providers/cursor/exec-modern.d.ts +1 -1
  36. package/dist/types/providers/cursor-pi-args.d.ts +13 -0
  37. package/dist/types/providers/cursor.d.ts +7 -0
  38. package/dist/types/providers/ollama.d.ts +6 -1
  39. package/dist/types/providers/openai-codex-responses.d.ts +6 -0
  40. package/dist/types/providers/openai-responses.d.ts +9 -0
  41. package/dist/types/providers/openai-shared.d.ts +30 -0
  42. package/dist/types/providers/systemone-server.d.ts +7 -3
  43. package/dist/types/providers/transform-messages.d.ts +28 -5
  44. package/dist/types/registry/oauth/google-antigravity.d.ts +1 -1
  45. package/dist/types/stream.d.ts +0 -2
  46. package/dist/types/types.d.ts +33 -9
  47. package/dist/types/usage/muse-code.d.ts +1 -1
  48. package/dist/types/utils/dsml-leak.d.ts +17 -13
  49. package/dist/types/utils/narration.d.ts +9 -0
  50. package/dist/types/utils/openai-http.d.ts +2 -0
  51. package/dist/types/utils/request-body.d.ts +11 -0
  52. package/dist/types/utils/request-compression.d.ts +21 -0
  53. package/dist/types/utils/tool-call-arguments.d.ts +2 -1
  54. package/dist/types/utils/transport-fetch.d.ts +8 -0
  55. package/dist/types/utils.d.ts +1 -1
  56. package/package.json +7 -7
  57. package/src/auth/cascade.ts +23 -13
  58. package/src/auth/pool.ts +3 -3
  59. package/src/auth/refresh.ts +39 -12
  60. package/src/auth/select.ts +8 -2
  61. package/src/auth/types.ts +16 -5
  62. package/src/auth/usage-cache.ts +23 -7
  63. package/src/auth/usage.ts +2 -4
  64. package/src/auth-broker/remote-store.ts +31 -6
  65. package/src/auth-gateway/clients.ts +318 -0
  66. package/src/auth-gateway/dispatch.ts +66 -35
  67. package/src/auth-gateway/http.ts +11 -5
  68. package/src/auth-gateway/index.ts +1 -0
  69. package/src/auth-gateway/routes/embeddings.ts +4 -10
  70. package/src/auth-gateway/routes/images.ts +6 -10
  71. package/src/auth-gateway/routes/rerank.ts +4 -10
  72. package/src/auth-gateway/routes/speech.ts +10 -10
  73. package/src/auth-gateway/routes/systemone.ts +22 -12
  74. package/src/auth-gateway/routes/transcriptions.ts +4 -2
  75. package/src/auth-gateway/routes/video.ts +12 -15
  76. package/src/auth-gateway/server.ts +136 -53
  77. package/src/auth-gateway/session-state.ts +14 -0
  78. package/src/auth-gateway/types.ts +16 -2
  79. package/src/dialect/coercion.ts +16 -0
  80. package/src/dialect/deepseek.ts +344 -81
  81. package/src/dialect/hermes.ts +7 -11
  82. package/src/dialect/qwen3.ts +3 -11
  83. package/src/error/classes.ts +3 -2
  84. package/src/error/flags.ts +24 -3
  85. package/src/error/index.ts +1 -0
  86. package/src/error/network-cause.ts +179 -0
  87. package/src/images/codex-format-directive.md +3 -0
  88. package/src/images/google-antigravity.ts +2 -1
  89. package/src/images/google-generative-ai.ts +4 -2
  90. package/src/images/openai-hosted.ts +25 -2
  91. package/src/images/openai-images.ts +11 -6
  92. package/src/images/shared.ts +77 -3
  93. package/src/index.ts +1 -0
  94. package/src/judgment/chat.ts +17 -1
  95. package/src/judgment/native.ts +238 -12
  96. package/src/judgment/openai-decisions.ts +30 -6
  97. package/src/judgment/systemone.ts +153 -11
  98. package/src/judgment/text.ts +26 -7
  99. package/src/judgment/types.ts +46 -6
  100. package/src/judgment/wire.ts +19 -3
  101. package/src/provider-session-state.ts +2 -2
  102. package/src/providers/amazon-bedrock.ts +4 -1
  103. package/src/providers/anthropic-client.ts +1 -1
  104. package/src/providers/anthropic-images.ts +263 -0
  105. package/src/providers/anthropic-messages-server.ts +84 -1
  106. package/src/providers/anthropic-signature.ts +35 -8
  107. package/src/providers/anthropic-state.ts +13 -0
  108. package/src/providers/anthropic.ts +479 -397
  109. package/src/providers/connection-prewarm.ts +78 -0
  110. package/src/providers/cowork-fetch.ts +5 -1
  111. package/src/providers/cursor/exec-modern.ts +1 -0
  112. package/src/providers/cursor-pi-args.ts +19 -0
  113. package/src/providers/cursor.ts +53 -13
  114. package/src/providers/devin.ts +10 -4
  115. package/src/providers/factory-droid/gemini.ts +9 -3
  116. package/src/providers/google-gemini-cli.ts +11 -1
  117. package/src/providers/google-shared.ts +2 -6
  118. package/src/providers/images-server.ts +6 -1
  119. package/src/providers/ollama.ts +23 -5
  120. package/src/providers/openai-anthropic-shim.ts +1 -0
  121. package/src/providers/openai-chat-server.ts +29 -0
  122. package/src/providers/openai-codex/access-programs.ts +2 -1
  123. package/src/providers/openai-codex-responses.ts +49 -82
  124. package/src/providers/openai-completions.ts +117 -19
  125. package/src/providers/openai-responses.ts +25 -2
  126. package/src/providers/openai-shared.ts +36 -5
  127. package/src/providers/pi-native-server.ts +1 -0
  128. package/src/providers/systemone-server.ts +72 -12
  129. package/src/providers/transform-messages.ts +104 -55
  130. package/src/registry/oauth/google-antigravity.ts +1 -1
  131. package/src/stream.ts +13 -73
  132. package/src/types.ts +33 -9
  133. package/src/usage/claude.ts +2 -9
  134. package/src/usage/github-copilot.ts +26 -4
  135. package/src/usage/muse-code.ts +115 -29
  136. package/src/usage/openai-codex.ts +13 -7
  137. package/src/usage/opencode-go.ts +7 -8
  138. package/src/usage/xai-oauth.ts +2 -9
  139. package/src/utils/dsml-leak.ts +88 -33
  140. package/src/utils/leaked-thinking-stream.ts +10 -0
  141. package/src/utils/narration.ts +18 -0
  142. package/src/utils/openai-http.ts +27 -18
  143. package/src/utils/request-body.ts +27 -0
  144. package/src/utils/request-compression.ts +202 -0
  145. package/src/utils/request-debug.ts +23 -9
  146. package/src/utils/schema/dereference.ts +51 -18
  147. package/src/utils/schema/draft.ts +10 -2
  148. package/src/utils/schema/json-schema-validator.ts +4 -10
  149. package/src/utils/schema/normalize.ts +6 -9
  150. package/src/utils/tool-call-arguments.ts +2 -1
  151. package/src/utils/transport-fetch.ts +37 -0
  152. package/src/utils/validation.ts +26 -16
  153. package/src/utils.ts +38 -23
@@ -1,4 +1,4 @@
1
- import { parseJsonWithRepair } from "@linxiraos/pi-utils";
1
+ import { classifyJsonPrefix, parseJsonWithRepair } from "@linxiraos/pi-utils";
2
2
  import type { Message, ToolCall } from "../types";
3
3
  import { parseToolCallArguments } from "../utils/tool-call-arguments";
4
4
  import { asRecord, mintToolCallId, partialSuffixOverlapAny } from "./coercion";
@@ -84,8 +84,133 @@ const OUTSIDE_TOKENS = [
84
84
  ...DSML_ORPHAN_CLOSE_TOKENS,
85
85
  ...CONTROL_TOKENS,
86
86
  ] as const;
87
+ /** Outside-state tokens dropped from visible text (closers with nothing open, template control tokens). */
88
+ const OUTSIDE_DROPPED_TOKENS: Readonly<Record<string, true>> = Object.fromEntries(
89
+ [
90
+ DEEPSEEK_TOOL_CALLS_END,
91
+ THINK_CLOSE,
92
+ DSML_TOOL_CALLS_CLOSE_FULLWIDTH,
93
+ DSML_TOOL_CALLS_CLOSE_ASCII,
94
+ ...CONTROL_TOKENS,
95
+ ].map(token => [token, true]),
96
+ );
97
+
98
+ // DeepSeek's chat template delimits special tokens with fullwidth bars (U+FF5C)
99
+ // and joins words with the tokenizer's `▁` (U+2581). Hosts that detokenize them
100
+ // into text, and models degraded by long contexts, re-spell them: `_` for `▁`
101
+ // (wholly or partly), or one bar dropped (`<tool_call_begin|>`). Those spellings
102
+ // resolve to the canonical token wherever the grammar expects one. At least one
103
+ // fullwidth bar is required: ASCII `<|…|>` tokens belong to other templates
104
+ // (Kimi's `<|tool_call_begin|>`, Harmony), and a bare `<word>` is prose or XML.
105
+ const TEMPLATE_TOKEN_SPELLING = /<(|?)([A-Za-z]+(?:[▁_][A-Za-z]+)*)(|?)>/gu;
106
+ const TEMPLATE_TOKEN_SPELLING_AT_START = new RegExp(TEMPLATE_TOKEN_SPELLING.source, "uy");
107
+ // A trailing fragment that may still grow into a template token spelling.
108
+ const TEMPLATE_TOKEN_PARTIAL = /^<|?([A-Za-z▁_]*)(|?)$/u;
109
+ // Every accepted spelling carries at least one bar, so text without one cannot
110
+ // complete a template token; argument bodies are deferred until a bar arrives.
111
+ const TEMPLATE_TOKEN_BAR = "|";
112
+ /**
113
+ * Canonical template token by spelling-independent key (`tool_calls_begin`).
114
+ * A Map, not a Record: keys come from model output, so `constructor` and friends
115
+ * must not resolve through the prototype.
116
+ */
117
+ const TEMPLATE_TOKEN_BY_KEY: ReadonlyMap<string, string> = new Map(
118
+ [
119
+ DEEPSEEK_TOOL_CALLS_BEGIN,
120
+ DEEPSEEK_TOOL_CALLS_END,
121
+ DEEPSEEK_TOOL_CALL_BEGIN,
122
+ DEEPSEEK_TOOL_CALL_END,
123
+ DEEPSEEK_TOOL_SEPARATOR,
124
+ ...CONTROL_TOKENS,
125
+ ].flatMap(token => {
126
+ // `<|▁pad▁|>` and ASCII `<|EOT|>` fall outside the re-spelling grammar and stay literal.
127
+ const name = /^<|([A-Za-z]+(?:▁[A-Za-z]+)*)|>$/u.exec(token)?.[1];
128
+ return name === undefined ? [] : [[templateTokenKey(name), token] as const];
129
+ }),
130
+ );
131
+ const TEMPLATE_TOKEN_KEYS = [...TEMPLATE_TOKEN_BY_KEY.keys()];
132
+ // `<`, both bars, the longest name, `>`.
133
+ const TEMPLATE_TOKEN_MAX_SPELLING = Math.max(...TEMPLATE_TOKEN_KEYS.map(key => key.length)) + 4;
134
+
135
+ const OUTSIDE_TEMPLATE_TOKENS: Readonly<Record<string, true>> = Object.fromEntries(
136
+ [DEEPSEEK_TOOL_CALLS_BEGIN, DEEPSEEK_TOOL_CALLS_END, DEEPSEEK_TOOL_CALL_BEGIN, ...CONTROL_TOKENS].map(token => [
137
+ token,
138
+ true,
139
+ ]),
140
+ );
141
+ const SEPARATOR_TOKENS: Readonly<Record<string, true>> = { [DEEPSEEK_TOOL_SEPARATOR]: true };
142
+ // A call's arguments end at its own close or, when the model skips that close,
143
+ // at the section close or the next call's open (left in the buffer for the next
144
+ // state to consume). Only the canonical close ends them unconditionally; a
145
+ // re-spelled close and both fallbacks count once the arguments before them are
146
+ // no longer an open JSON prefix (see `#consumeArgs`).
147
+ const ARGS_TERMINATOR_TOKENS: Readonly<Record<string, true>> = {
148
+ [DEEPSEEK_TOOL_CALL_END]: true,
149
+ [DEEPSEEK_TOOL_CALLS_END]: true,
150
+ [DEEPSEEK_TOOL_CALL_BEGIN]: true,
151
+ };
152
+ /** DeepSeek template tokens that structure a tool call. */
153
+ export const DEEPSEEK_TOOL_CALL_TOKENS: Readonly<Record<string, true>> = {
154
+ [DEEPSEEK_TOOL_CALLS_BEGIN]: true,
155
+ [DEEPSEEK_TOOL_CALLS_END]: true,
156
+ [DEEPSEEK_TOOL_CALL_BEGIN]: true,
157
+ [DEEPSEEK_TOOL_CALL_END]: true,
158
+ [DEEPSEEK_TOOL_SEPARATOR]: true,
159
+ };
160
+
161
+ /** A token found in text: its canonical spelling and the text actually written. */
162
+ export interface DeepSeekTokenMatch {
163
+ readonly token: string;
164
+ readonly raw: string;
165
+ readonly index: number;
166
+ }
167
+
168
+ function templateTokenKey(name: string): string {
169
+ return name.replaceAll("▁", "_");
170
+ }
171
+
172
+ function resolveTemplateSpelling(match: RegExpExecArray): string | undefined {
173
+ if (match[1]!.length === 0 && match[3]!.length === 0) return undefined;
174
+ return TEMPLATE_TOKEN_BY_KEY.get(templateTokenKey(match[2]!));
175
+ }
176
+
177
+ /**
178
+ * Find the earliest of `tokens` in `text` at or after `from`, in its canonical
179
+ * spelling or any re-spelling described at {@link TEMPLATE_TOKEN_SPELLING}.
180
+ */
181
+ export function findDeepSeekTemplateToken(
182
+ text: string,
183
+ tokens: Readonly<Record<string, true>>,
184
+ from = 0,
185
+ ): DeepSeekTokenMatch | undefined {
186
+ TEMPLATE_TOKEN_SPELLING.lastIndex = from;
187
+ for (let match = TEMPLATE_TOKEN_SPELLING.exec(text); match; match = TEMPLATE_TOKEN_SPELLING.exec(text)) {
188
+ const token = resolveTemplateSpelling(match);
189
+ if (token !== undefined && tokens[token]) return { token, raw: match[0], index: match.index };
190
+ }
191
+ return undefined;
192
+ }
193
+
194
+ function templateTokenAtStart(text: string): DeepSeekTokenMatch | undefined {
195
+ TEMPLATE_TOKEN_SPELLING_AT_START.lastIndex = 0;
196
+ const match = TEMPLATE_TOKEN_SPELLING_AT_START.exec(text);
197
+ if (!match) return undefined;
198
+ const token = resolveTemplateSpelling(match);
199
+ return token === undefined ? undefined : { token, raw: match[0], index: 0 };
200
+ }
201
+
202
+ /** Length of a trailing fragment of `text` that could still complete into a template token spelling. */
203
+ function templateTokenPartialLength(text: string): number {
204
+ const start = text.lastIndexOf("<");
205
+ if (start === -1 || text.length - start >= TEMPLATE_TOKEN_MAX_SPELLING) return 0;
206
+ const match = TEMPLATE_TOKEN_PARTIAL.exec(text.slice(start));
207
+ if (!match) return 0;
208
+ const key = templateTokenKey(match[1]!);
209
+ const viable =
210
+ match[2]!.length > 0 ? TEMPLATE_TOKEN_BY_KEY.has(key) : TEMPLATE_TOKEN_KEYS.some(known => known.startsWith(key));
211
+ return viable ? text.length - start : 0;
212
+ }
87
213
 
88
- const SECTION_TOKENS = [DEEPSEEK_TOOL_CALL_BEGIN, DEEPSEEK_TOOL_CALLS_END] as const;
89
214
  const DSML_SECTION_TOKENS = [
90
215
  DSML_TOOL_CALLS_CLOSE_FULLWIDTH,
91
216
  DSML_TOOL_CALLS_CLOSE_ASCII,
@@ -105,6 +230,79 @@ const DSML_PARAMETER_CLOSE_TOKENS = [DSML_PARAMETER_CLOSE_FULLWIDTH, DSML_PARAME
105
230
  const BARE_DSML_OPEN = /<[||]DSML[||](?:invoke|parameter)\b/u;
106
231
  const VISIBLE_TAIL_LIMIT = 48;
107
232
 
233
+ /**
234
+ * Markdown code the model has written in visible text, tracked so re-spelled
235
+ * template tokens it quotes (`` `<|tool_calls_begin|>` ``) stay prose. `fence`
236
+ * is the backtick run that opened the current span or block (0 outside code),
237
+ * `run` a backtick run still being read, `newline` whether the last
238
+ * non-blank character was a line break.
239
+ */
240
+ interface CodeState {
241
+ readonly fence: number;
242
+ readonly run: number;
243
+ readonly newline: boolean;
244
+ }
245
+ const OUTSIDE_CODE: CodeState = { fence: 0, run: 0, newline: false };
246
+ const BACKTICK = 0x60;
247
+ const LINE_FEED = 0x0a;
248
+
249
+ /** A finished backtick run opens code, or closes it when it matches the opening run. */
250
+ function settleBacktickRun(fence: number, run: number): number {
251
+ if (run === 0) return fence;
252
+ if (fence === 0) return run;
253
+ return run === fence ? 0 : fence;
254
+ }
255
+
256
+ function advanceCodeState(state: CodeState, text: string): CodeState {
257
+ let { fence, run, newline } = state;
258
+ for (let i = 0; i < text.length; i++) {
259
+ const c = text.charCodeAt(i);
260
+ if (c === BACKTICK) {
261
+ run++;
262
+ newline = false;
263
+ continue;
264
+ }
265
+ fence = settleBacktickRun(fence, run);
266
+ run = 0;
267
+ if (c === LINE_FEED) {
268
+ // A blank line ends the paragraph and any inline span left open in it.
269
+ if (newline && fence < 3) fence = 0;
270
+ newline = true;
271
+ } else if (!/\s/.test(text[i]!)) {
272
+ newline = false;
273
+ }
274
+ }
275
+ return { fence, run, newline };
276
+ }
277
+
278
+ /**
279
+ * Whether an inline span opened by a `fence`-long backtick run closes in
280
+ * `text` before its paragraph ends. Markdown renders an unmatched run as plain
281
+ * text, so only a closed span is code. `undefined` until the stream decides.
282
+ */
283
+ function inlineSpanCloses(text: string, fence: number, final: boolean): boolean | undefined {
284
+ let run = 0;
285
+ let newline = false;
286
+ for (let i = 0; i < text.length; i++) {
287
+ const c = text.charCodeAt(i);
288
+ if (c === BACKTICK) {
289
+ run++;
290
+ newline = false;
291
+ continue;
292
+ }
293
+ if (run === fence) return true;
294
+ run = 0;
295
+ if (c === LINE_FEED) {
296
+ if (newline) return false;
297
+ newline = true;
298
+ } else if (!/\s/.test(text[i]!)) {
299
+ newline = false;
300
+ }
301
+ }
302
+ if (!final) return undefined;
303
+ return run === fence;
304
+ }
305
+
108
306
  type State =
109
307
  | "outside"
110
308
  | "thinking"
@@ -142,6 +340,8 @@ export class DeepSeekInbandScanner implements InbandScanner {
142
340
  #bareDsmlOpenVisible = false;
143
341
  /** Last few visible characters, so a bare opener split across chunks is still seen. */
144
342
  #visibleTail = "";
343
+ /** Markdown code state of the visible text emitted so far. */
344
+ #code = OUTSIDE_CODE;
145
345
  readonly #closeWait = new TerminatorWait();
146
346
 
147
347
  constructor(options: InbandScannerOptions = {}) {
@@ -153,8 +353,11 @@ export class DeepSeekInbandScanner implements InbandScanner {
153
353
  if (this.#closeWait.absorb(text)) return [];
154
354
  this.#buffer = this.#closeWait.release(this.#buffer) + text;
155
355
  const events = this.#consume(false);
156
- if (this.#state === "args" || this.#state === "legacyArgs") {
157
- this.#closeWait.arm(DEEPSEEK_TOOL_CALL_END, this.#buffer);
356
+ // Arguments end at a template token in any accepted spelling, so defer
357
+ // deltas until a bar arrives — unless a fragment already pending at the
358
+ // tail could complete without one (`<tool_call_end` + `|>`).
359
+ if ((this.#state === "args" || this.#state === "legacyArgs") && templateTokenPartialLength(this.#buffer) === 0) {
360
+ this.#closeWait.arm(TEMPLATE_TOKEN_BAR, this.#buffer);
158
361
  }
159
362
  return events;
160
363
  }
@@ -178,7 +381,7 @@ export class DeepSeekInbandScanner implements InbandScanner {
178
381
  continue;
179
382
  }
180
383
  if (this.#state === "section") {
181
- if (!this.#consumeSection(final)) break;
384
+ if (!this.#consumeSection(final, events)) break;
182
385
  continue;
183
386
  }
184
387
  if (this.#state === "header") {
@@ -228,69 +431,66 @@ export class DeepSeekInbandScanner implements InbandScanner {
228
431
  this.#buffer = trimmed;
229
432
  this.#stripLeadingWhitespace = false;
230
433
  }
231
- const match = findEarliestToken(this.#buffer, OUTSIDE_TOKENS);
434
+ const match = this.#findOutsideToken(final);
435
+ if (typeof match === "number") {
436
+ this.#emitText(this.#buffer.slice(0, match), events);
437
+ this.#buffer = this.#buffer.slice(match);
438
+ return;
439
+ }
232
440
  if (!match) {
233
- const hold = final ? 0 : partialSuffixOverlapAny(this.#buffer, OUTSIDE_TOKENS);
441
+ const hold = final
442
+ ? 0
443
+ : Math.max(
444
+ partialSuffixOverlapAny(this.#buffer, OUTSIDE_TOKENS),
445
+ templateTokenPartialLength(this.#buffer),
446
+ );
234
447
  this.#emitText(this.#buffer.slice(0, this.#buffer.length - hold), events);
235
448
  this.#buffer = this.#buffer.slice(this.#buffer.length - hold);
236
449
  return;
237
450
  }
238
451
  this.#emitText(this.#buffer.slice(0, match.index), events);
239
- this.#buffer = this.#buffer.slice(match.index);
240
- if (this.#buffer.startsWith(DEEPSEEK_TOOL_CALLS_BEGIN)) {
241
- this.#buffer = this.#buffer.slice(DEEPSEEK_TOOL_CALLS_BEGIN.length);
452
+ this.#buffer = this.#buffer.slice(match.index + match.raw.length);
453
+ // Markup inside an open inline span proves its backticks never closed.
454
+ if (match.raw !== match.token) {
455
+ const fence = settleBacktickRun(this.#code.fence, this.#code.run);
456
+ if (fence > 0 && fence < 3) this.#code = OUTSIDE_CODE;
457
+ }
458
+ if (match.token === DEEPSEEK_TOOL_CALLS_BEGIN) {
242
459
  this.#inToolSection = true;
243
460
  this.#state = "section";
244
461
  return;
245
462
  }
246
- if (this.#buffer.startsWith(DEEPSEEK_TOOL_CALL_BEGIN)) {
247
- this.#buffer = this.#buffer.slice(DEEPSEEK_TOOL_CALL_BEGIN.length);
248
- this.#rawBlock = DEEPSEEK_TOOL_CALL_BEGIN;
463
+ if (match.token === DEEPSEEK_TOOL_CALL_BEGIN) {
464
+ this.#rawBlock = match.raw;
249
465
  this.#inToolSection = false;
250
466
  this.#state = "header";
251
467
  return;
252
468
  }
253
- if (this.#buffer.startsWith(THINK_OPEN)) {
254
- this.#buffer = this.#buffer.slice(THINK_OPEN.length);
469
+ if (match.token === THINK_OPEN) {
255
470
  this.#state = "thinking";
256
471
  this.#thinking = "";
257
472
  if (this.#parseThinking) events.push({ type: "thinkingStart" });
258
473
  return;
259
474
  }
260
- if (
261
- this.#buffer.startsWith(DSML_TOOL_CALLS_OPEN_FULLWIDTH) ||
262
- this.#buffer.startsWith(DSML_TOOL_CALLS_OPEN_ASCII)
263
- ) {
264
- const openToken = this.#buffer.startsWith(DSML_TOOL_CALLS_OPEN_FULLWIDTH)
265
- ? DSML_TOOL_CALLS_OPEN_FULLWIDTH
266
- : DSML_TOOL_CALLS_OPEN_ASCII;
267
- this.#buffer = this.#buffer.slice(openToken.length);
268
- this.#pendingDsmlSection = openToken;
475
+ if (match.token === DSML_TOOL_CALLS_OPEN_FULLWIDTH || match.token === DSML_TOOL_CALLS_OPEN_ASCII) {
476
+ this.#pendingDsmlSection = match.raw;
269
477
  this.#state = "dsmlSection";
270
478
  return;
271
479
  }
272
- const orphanClose = this.#matchingOrphanDsmlClose();
273
- if (orphanClose) {
274
- if (this.#bareDsmlOpenVisible) this.#emitText(orphanClose, events);
275
- this.#buffer = this.#buffer.slice(orphanClose.length);
480
+ if ((DSML_ORPHAN_CLOSE_TOKENS as readonly string[]).includes(match.token)) {
481
+ if (this.#bareDsmlOpenVisible) this.#emitText(match.raw, events);
276
482
  continue;
277
483
  }
278
- const control = this.#matchingControlToken();
279
- if (control) {
280
- this.#buffer = this.#buffer.slice(control.length);
281
- if (
282
- this.#bareDsmlOpenVisible &&
283
- (control === DSML_TOOL_CALLS_CLOSE_FULLWIDTH || control === DSML_TOOL_CALLS_CLOSE_ASCII)
284
- ) {
285
- // Closes the malformed call: keep it as its end marker.
286
- this.#emitText(control, events);
287
- this.#bareDsmlOpenVisible = false;
288
- continue;
289
- }
290
- this.#stripLeadingWhitespace = true;
484
+ if (
485
+ this.#bareDsmlOpenVisible &&
486
+ (match.token === DSML_TOOL_CALLS_CLOSE_FULLWIDTH || match.token === DSML_TOOL_CALLS_CLOSE_ASCII)
487
+ ) {
488
+ // Closes the malformed call: keep it as its end marker.
489
+ this.#emitText(match.raw, events);
490
+ this.#bareDsmlOpenVisible = false;
291
491
  continue;
292
492
  }
293
- this.#buffer = this.#buffer.slice(match.token.length);
493
+ if (OUTSIDE_DROPPED_TOKENS[match.token]) this.#stripLeadingWhitespace = true;
294
494
  }
295
495
  }
296
496
 
@@ -298,6 +498,7 @@ export class DeepSeekInbandScanner implements InbandScanner {
298
498
  if (text.length === 0) return;
299
499
  events.push({ type: "text", text });
300
500
  this.#visibleTail = (this.#visibleTail + text).slice(-VISIBLE_TAIL_LIMIT);
501
+ this.#code = advanceCodeState(this.#code, text);
301
502
  if (!this.#bareDsmlOpenVisible && BARE_DSML_OPEN.test(this.#visibleTail)) this.#bareDsmlOpenVisible = true;
302
503
  }
303
504
 
@@ -315,36 +516,59 @@ export class DeepSeekInbandScanner implements InbandScanner {
315
516
  this.#endThinking(events);
316
517
  }
317
518
 
318
- #consumeSection(final: boolean): boolean {
519
+ #consumeSection(final: boolean, events: InbandScanEvent[]): boolean {
319
520
  while (this.#buffer.length > 0) {
320
- this.#skipWhitespace();
321
- if (this.#buffer.startsWith(DEEPSEEK_TOOL_CALLS_END)) {
322
- this.#buffer = this.#buffer.slice(DEEPSEEK_TOOL_CALLS_END.length);
521
+ // Whitespace before a token is template layout and is dropped with it;
522
+ // whitespace before prose belongs to the prose. Peek, don't skip, so a
523
+ // chunk boundary cannot decide which.
524
+ const indent = this.#buffer.length - this.#buffer.trimStart().length;
525
+ const rest = this.#buffer.slice(indent);
526
+ const lead = templateTokenAtStart(rest);
527
+ if (lead?.token === DEEPSEEK_TOOL_CALLS_END) {
528
+ this.#buffer = rest.slice(lead.raw.length);
323
529
  this.#inToolSection = false;
324
530
  this.#state = "outside";
325
531
  return true;
326
532
  }
327
- if (this.#buffer.startsWith(DEEPSEEK_TOOL_CALL_BEGIN)) {
328
- this.#buffer = this.#buffer.slice(DEEPSEEK_TOOL_CALL_BEGIN.length);
329
- this.#rawBlock = DEEPSEEK_TOOL_CALL_BEGIN;
533
+ if (lead?.token === DEEPSEEK_TOOL_CALL_BEGIN) {
534
+ this.#buffer = rest.slice(lead.raw.length);
535
+ this.#rawBlock = lead.raw;
330
536
  this.#state = "header";
331
537
  return true;
332
538
  }
333
- if (!final && partialSuffixOverlapAny(this.#buffer, SECTION_TOKENS) === this.#buffer.length) return false;
334
- if (this.#buffer.length === 0) return false;
335
- this.#buffer = this.#buffer.slice(1);
539
+ if (lead) {
540
+ // Any other template token here (`<|end▁of▁sentence|>`, a stray
541
+ // `<|tool▁sep|>`) is template structure, never prose: drop it.
542
+ this.#buffer = rest.slice(lead.raw.length);
543
+ continue;
544
+ }
545
+ if (rest.length === 0 || templateTokenPartialLength(rest) === rest.length) {
546
+ if (!final) return false;
547
+ // The stream ended on layout whitespace or mid-token
548
+ // (`<|tool_call_begin|`): markup, not prose.
549
+ this.#buffer = "";
550
+ return true;
551
+ }
552
+ // The template puts nothing but calls in a section, so anything else is
553
+ // the model writing prose after a stray section open: keep it visible.
554
+ // Trailing whitespace stays buffered until what follows it decides.
555
+ const next = rest.indexOf("<", 1);
556
+ const run = this.#buffer.slice(0, next === -1 ? this.#buffer.length : indent + next);
557
+ const prose = run.trimEnd();
558
+ this.#emitText(prose, events);
559
+ this.#buffer = this.#buffer.slice(prose.length);
336
560
  }
337
561
  return final;
338
562
  }
339
563
 
340
564
  #consumeHeader(final: boolean, events: InbandScanEvent[]): boolean {
341
- const sep = this.#buffer.indexOf(DEEPSEEK_TOOL_SEPARATOR);
342
- if (sep === -1) {
565
+ const sep = findDeepSeekTemplateToken(this.#buffer, SEPARATOR_TOKENS);
566
+ if (!sep) {
343
567
  if (final) this.#resetTool();
344
568
  return false;
345
569
  }
346
- const rawHead = this.#buffer.slice(0, sep + DEEPSEEK_TOOL_SEPARATOR.length);
347
- const head = this.#buffer.slice(0, sep).trim();
570
+ const rawHead = this.#buffer.slice(0, sep.index + sep.raw.length);
571
+ const head = this.#buffer.slice(0, sep.index).trim();
348
572
  this.#rawBlock += rawHead;
349
573
  this.#buffer = this.#buffer.slice(rawHead.length);
350
574
  if (head === LEGACY_TOOL_TYPE) {
@@ -373,18 +597,45 @@ export class DeepSeekInbandScanner implements InbandScanner {
373
597
  }
374
598
 
375
599
  #consumeArgs(final: boolean, events: InbandScanEvent[]): boolean {
376
- const end = this.#buffer.indexOf(DEEPSEEK_TOOL_CALL_END);
377
- if (end === -1) {
600
+ let end: DeepSeekTokenMatch | undefined;
601
+ let rawArgs = "";
602
+ for (
603
+ let candidate = findDeepSeekTemplateToken(this.#buffer, ARGS_TERMINATOR_TOKENS);
604
+ candidate;
605
+ candidate = findDeepSeekTemplateToken(
606
+ this.#buffer,
607
+ ARGS_TERMINATOR_TOKENS,
608
+ candidate.index + candidate.raw.length,
609
+ )
610
+ ) {
611
+ rawArgs = this.#buffer.slice(0, candidate.index);
612
+ if (this.#state === "legacyArgs") {
613
+ const fence = rawArgs.lastIndexOf(CODE_FENCE);
614
+ if (fence !== -1) rawArgs = rawArgs.slice(0, fence);
615
+ }
616
+ // The call's own close in its canonical spelling always ends it, even
617
+ // after malformed JSON (the parser repairs or flags it). A re-spelled
618
+ // close and the fallbacks are inferred: inside a still-open JSON string
619
+ // or container they are argument text — e.g. a grep for the token, or
620
+ // a write of this very file.
621
+ const trimmed = rawArgs.trim();
622
+ if (
623
+ candidate.raw === DEEPSEEK_TOOL_CALL_END ||
624
+ trimmed.length === 0 ||
625
+ classifyJsonPrefix(trimmed) !== "prefix"
626
+ ) {
627
+ end = candidate;
628
+ break;
629
+ }
630
+ }
631
+ if (!end) {
378
632
  if (final) this.#resetTool();
379
633
  return false;
380
634
  }
381
- let rawArgs = this.#buffer.slice(0, end);
382
- if (this.#state === "legacyArgs") {
383
- const fence = rawArgs.lastIndexOf(CODE_FENCE);
384
- if (fence !== -1) rawArgs = rawArgs.slice(0, fence);
385
- }
386
- const rawTail = this.#buffer.slice(0, end + DEEPSEEK_TOOL_CALL_END.length);
387
- this.#rawBlock += rawTail;
635
+ // Only the call's own close belongs to it; a section close or the next
636
+ // call's open stays buffered for the state that owns it.
637
+ const consumed = end.index + (end.token === DEEPSEEK_TOOL_CALL_END ? end.raw.length : 0);
638
+ this.#rawBlock += this.#buffer.slice(0, consumed);
388
639
  events.push({
389
640
  type: "toolEnd",
390
641
  id: this.#id,
@@ -392,7 +643,7 @@ export class DeepSeekInbandScanner implements InbandScanner {
392
643
  arguments: this.#parseArgs(rawArgs),
393
644
  rawBlock: this.#rawBlock,
394
645
  });
395
- this.#buffer = this.#buffer.slice(rawTail.length);
646
+ this.#buffer = this.#buffer.slice(consumed);
396
647
  this.#resetTool(this.#inToolSection ? "section" : "outside");
397
648
  return true;
398
649
  }
@@ -561,22 +812,34 @@ export class DeepSeekInbandScanner implements InbandScanner {
561
812
  return "";
562
813
  }
563
814
 
564
- #matchingOrphanDsmlClose(): string | undefined {
565
- for (const token of DSML_ORPHAN_CLOSE_TOKENS) {
566
- if (this.#buffer.startsWith(token)) return token;
567
- }
568
- return undefined;
569
- }
570
-
571
- #matchingControlToken(): string | undefined {
572
- if (this.#buffer.startsWith(DEEPSEEK_TOOL_CALLS_END)) return DEEPSEEK_TOOL_CALLS_END;
573
- if (this.#buffer.startsWith(THINK_CLOSE)) return THINK_CLOSE;
574
- if (this.#buffer.startsWith(DSML_TOOL_CALLS_CLOSE_FULLWIDTH)) return DSML_TOOL_CALLS_CLOSE_FULLWIDTH;
575
- if (this.#buffer.startsWith(DSML_TOOL_CALLS_CLOSE_ASCII)) return DSML_TOOL_CALLS_CLOSE_ASCII;
576
- for (const token of CONTROL_TOKENS) {
577
- if (this.#buffer.startsWith(token)) return token;
815
+ /**
816
+ * Earliest outside-state token: a literal (`<think>`, DSML tags, canonical
817
+ * template tokens) or a re-spelled template token. Ties keep the literal.
818
+ * A re-spelled token inside code the model quoted is prose about the token,
819
+ * not markup; canonical spellings keep their long-standing handling. An
820
+ * inline span is code only once its closing run arrives, so a token after an
821
+ * unclosed run returns its index to hold from until the paragraph decides.
822
+ */
823
+ #findOutsideToken(final: boolean): DeepSeekTokenMatch | number | undefined {
824
+ const literal = findEarliestToken(this.#buffer, OUTSIDE_TOKENS);
825
+ let template = findDeepSeekTemplateToken(this.#buffer, OUTSIDE_TEMPLATE_TOKENS);
826
+ while (template && template.raw !== template.token && (!literal || template.index < literal.index)) {
827
+ const code = advanceCodeState(this.#code, this.#buffer.slice(0, template.index));
828
+ const fence = settleBacktickRun(code.fence, code.run);
829
+ if (fence === 0) break;
830
+ if (fence < 3) {
831
+ const closes = inlineSpanCloses(this.#buffer.slice(template.index + template.raw.length), fence, final);
832
+ if (closes === undefined) return template.index;
833
+ if (!closes) break;
834
+ }
835
+ template = findDeepSeekTemplateToken(
836
+ this.#buffer,
837
+ OUTSIDE_TEMPLATE_TOKENS,
838
+ template.index + template.raw.length,
839
+ );
578
840
  }
579
- return undefined;
841
+ if (template && (!literal || template.index < literal.index)) return template;
842
+ return literal && { token: literal.token, raw: literal.token, index: literal.index };
580
843
  }
581
844
 
582
845
  #matchingDsmlClose(fullwidth: string, ascii: string): string | undefined {
@@ -1,7 +1,7 @@
1
- import { parseJsonWithRepair, parseStreamingJson } from "@linxiraos/pi-utils";
1
+ import { parseJsonWithRepair } from "@linxiraos/pi-utils";
2
2
  import type { Message, ToolCall } from "../types";
3
3
  import { parseToolCallArguments } from "../utils/tool-call-arguments";
4
- import { asRecord, mintToolCallId, partialSuffixOverlapAny } from "./coercion";
4
+ import { asRecord, mintToolCallId, parseCompleteToolName, partialSuffixOverlapAny } from "./coercion";
5
5
  import dialectPrompt from "./hermes.md" with { type: "text" };
6
6
  import { renderChatMlTranscript, renderDelimitedThinking, renderToolResponseResults, stringifyJson } from "./rendering";
7
7
  import { TerminatorWait } from "./terminator-wait";
@@ -137,15 +137,11 @@ export class HermesInbandScanner implements InbandScanner {
137
137
  }
138
138
 
139
139
  #tryStart(body: string, events: InbandScanEvent[]): void {
140
- try {
141
- const partial = parseStreamingJson<{ name?: unknown }>(body);
142
- if (typeof partial.name !== "string" || partial.name.length === 0) return;
143
- this.#name = partial.name;
144
- this.#started = true;
145
- events.push({ type: "toolStart", id: this.#id, name: this.#name });
146
- } catch {
147
- // Partial JSON is allowed until the closing tag arrives.
148
- }
140
+ const name = parseCompleteToolName(body);
141
+ if (!name) return;
142
+ this.#name = name;
143
+ this.#started = true;
144
+ events.push({ type: "toolStart", id: this.#id, name: this.#name });
149
145
  }
150
146
 
151
147
  #parseCall(body: string): { name: string; arguments: Record<string, unknown> } | undefined {
@@ -1,7 +1,7 @@
1
1
  import { parseJsonWithRepair } from "@linxiraos/pi-utils";
2
2
  import type { Message, ToolCall } from "../types";
3
3
  import { parseToolCallArguments } from "../utils/tool-call-arguments";
4
- import { asRecord, mintToolCallId, partialSuffixOverlapAny } from "./coercion";
4
+ import { asRecord, mintToolCallId, parseCompleteToolName, partialSuffixOverlapAny } from "./coercion";
5
5
  import dialectPrompt from "./qwen3.md" with { type: "text" };
6
6
  import { renderChatMlTranscript, renderToolResponseResults, stringifyJson } from "./rendering";
7
7
  import { TerminatorWait } from "./terminator-wait";
@@ -22,7 +22,6 @@ const THINK_CLOSE = "</think>";
22
22
  const TOOL_START_TAGS = [TOOL_OPEN] as const;
23
23
  const START_TAGS = [TOOL_OPEN, THINK_OPEN] as const;
24
24
  const THINK_CLOSE_TAGS = [THINK_CLOSE] as const;
25
- const COMPLETE_NAME = /^\s*\{\s*"name"\s*:\s*("(?:\\.|[^"\\])*")/;
26
25
 
27
26
  type State = "outside" | "thinking" | "tool";
28
27
 
@@ -167,15 +166,8 @@ export class Qwen3InbandScanner implements InbandScanner {
167
166
  }
168
167
 
169
168
  #tryStart(body: string, events: InbandScanEvent[]): void {
170
- const nameMatch = COMPLETE_NAME.exec(body);
171
- if (!nameMatch) return;
172
- let name: unknown;
173
- try {
174
- name = JSON.parse(nameMatch[1]!);
175
- } catch {
176
- return;
177
- }
178
- if (typeof name !== "string" || name.length === 0) return;
169
+ const name = parseCompleteToolName(body);
170
+ if (!name) return;
179
171
  this.#name = name;
180
172
  this.#started = true;
181
173
  events.push({ type: "toolStart", id: this.#id, name: this.#name });
@@ -1,5 +1,6 @@
1
1
  import type { CapturedHttpErrorResponse } from "../utils/http-inspector";
2
2
  import { AbortError } from "./abort";
3
+ import { networkFailureMessage } from "./network-cause";
3
4
 
4
5
  /** Prefix on errors raised when an Anthropic SSE stream envelope is malformed. */
5
6
  export const STREAM_ENVELOPE_ERROR_PREFIX = "Anthropic stream envelope error:";
@@ -202,8 +203,8 @@ export class AnthropicApiError extends ProviderHttpError {
202
203
 
203
204
  /** Network-level failure (DNS, TLS, socket reset) after retries were exhausted. */
204
205
  export class AnthropicConnectionError extends Error {
205
- constructor(cause: unknown) {
206
- super("Connection error.", { cause });
206
+ constructor(cause: unknown, requestUrl?: string) {
207
+ super(networkFailureMessage(cause, requestUrl) ?? "Connection error.", { cause });
207
208
  this.name = "AnthropicConnectionError";
208
209
  }
209
210
  }