@linxiraos/pi-ai 1.3.0 → 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.
@@ -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 {
@@ -66,16 +66,48 @@ export function nativeJudgmentSupportsImages(model: Model): boolean {
66
66
  return isJudgmentApi(model.api) && JUDGMENT_WIRES[model.api].images !== undefined && model.input.includes("image");
67
67
  }
68
68
 
69
+ /** Whether `model`'s native judgment takes images and its wire forwards box questions. The model itself may still refuse them (Box needs Jev 1.14+). */
70
+ export function nativeJudgmentSupportsBoxes(model: Model): boolean {
71
+ return nativeJudgmentSupportsImages(model) && isJudgmentApi(model.api) && JUDGMENT_WIRES[model.api].boxes === true;
72
+ }
73
+
69
74
  /**
70
- * Whether `model`'s native judgment API takes these exact images: image support plus every documented
71
- * transport and catalog bound. A judge chain skips a candidate that would refuse them instead of failing on it.
75
+ * Every image bound `model`'s native judgment enforces: its wire's transport limits tightened by the
76
+ * catalog's model bounds. `undefined` when it takes no images. Native APIs reject images outside these
77
+ * rather than resizing them, so callers size images to them first.
72
78
  */
79
+ export function nativeJudgmentImageBounds(model: Model): JudgmentImageInput | undefined {
80
+ if (!isJudgmentApi(model.api) || !model.input.includes("image")) return undefined;
81
+ const wire = JUDGMENT_WIRES[model.api].images;
82
+ if (wire === undefined) return undefined;
83
+ const catalog = resolveJudgmentImageInput(model);
84
+ return catalog === undefined ? wire : intersectImageInput(wire, catalog);
85
+ }
86
+
87
+ /** Whether `model`'s native judgment API takes these exact images: image support within every {@link nativeJudgmentImageBounds}. */
73
88
  export function nativeJudgmentAcceptsImages(model: Model, images: readonly ImageContent[]): boolean {
74
- if (!isJudgmentApi(model.api) || !model.input.includes("image")) return false;
75
- const wireLimits = JUDGMENT_WIRES[model.api].images;
76
- if (wireLimits === undefined || imageLimitViolation(images, wireLimits) !== undefined) return false;
77
- const modelLimits = resolveJudgmentImageInput(model);
78
- return modelLimits === undefined || imageLimitViolation(images, modelLimits) === undefined;
89
+ const bounds = nativeJudgmentImageBounds(model);
90
+ return bounds !== undefined && imageLimitViolation(images, bounds) === undefined;
91
+ }
92
+
93
+ /** The bounds both `a` and `b` allow: shared MIME types, the lower of each cap, the higher of each floor. */
94
+ function intersectImageInput(a: JudgmentImageInput, b: JudgmentImageInput): JudgmentImageInput {
95
+ const bounds: JudgmentImageInput = {};
96
+ const mimeTypes =
97
+ a.mimeTypes && b.mimeTypes
98
+ ? a.mimeTypes.filter(type => b.mimeTypes?.includes(type))
99
+ : (a.mimeTypes ?? b.mimeTypes);
100
+ if (mimeTypes !== undefined) bounds.mimeTypes = mimeTypes;
101
+ for (const cap of ["maxCount", "maxBytes", "maxTotalBytes", "maxPixels", "maxSide"] as const) {
102
+ const limit = a[cap] === undefined ? b[cap] : b[cap] === undefined ? a[cap] : Math.min(a[cap], b[cap]);
103
+ if (limit !== undefined) bounds[cap] = limit;
104
+ }
105
+ for (const floor of ["minPixels", "minSide"] as const) {
106
+ const limit =
107
+ a[floor] === undefined ? b[floor] : b[floor] === undefined ? a[floor] : Math.max(a[floor], b[floor]);
108
+ if (limit !== undefined) bounds[floor] = limit;
109
+ }
110
+ return bounds;
79
111
  }
80
112
 
81
113
  /**
@@ -103,6 +135,23 @@ export function checkNativeJudgmentImages(
103
135
  }
104
136
  }
105
137
 
138
+ /**
139
+ * Reject box questions whose `image` is not an index into `request.images`: nothing would say which
140
+ * image their edges measure.
141
+ * @throws {AIError.ValidationError} naming the question.
142
+ */
143
+ export function checkBoxQuestions(request: JudgmentRequest): void {
144
+ const count = request.images?.length ?? 0;
145
+ for (const id in request.questions) {
146
+ const question = request.questions[id];
147
+ if (question.type !== "box") continue;
148
+ if (Number.isInteger(question.image) && question.image >= 0 && question.image < count) continue;
149
+ throw new AIError.ValidationError(
150
+ `judgment: box question "${id}" names image ${question.image}, but the request has ${count} images`,
151
+ );
152
+ }
153
+ }
154
+
106
155
  /** `TYPESAFE_BASE_URL` when set, else the public API root; trailing slashes stripped. */
107
156
  export function typesafeBaseUrl(): string {
108
157
  return ($env.TYPESAFE_BASE_URL?.trim() || TYPESAFE_DEFAULT_BASE_URL).replace(/\/+$/, "");
@@ -315,6 +364,13 @@ export class NativeJudge implements Judge {
315
364
  options?.imageAssets,
316
365
  this.#imageInput,
317
366
  );
367
+ checkBoxQuestions(request);
368
+ if (this.#wire.boxes !== true) {
369
+ for (const id in request.questions) {
370
+ if (request.questions[id].type !== "box") continue;
371
+ throw new AIError.ValidationError(`Native judgment backend ${this.label} does not answer box questions`);
372
+ }
373
+ }
318
374
  const ids = Object.keys(request.questions);
319
375
  const cap = this.#maxQuestions;
320
376
  if (cap !== undefined && ids.length > cap) return this.#judgeChunked(request, ids, cap, options);
@@ -20,6 +20,7 @@
20
20
  * tokens only, so cost is priced from the catalog model.
21
21
  */
22
22
  import { isRecord, prompt } from "@linxiraos/pi-utils";
23
+ import * as AIError from "../error";
23
24
  import { embeddedImageDataUrl } from "../images/shared";
24
25
  import predicateTemplate from "./openai-decisions-predicate.md" with { type: "text" };
25
26
  import { type Answer, type Question, tokenUsage } from "./types";
@@ -60,6 +61,8 @@ function encodeQuestion(name: string, question: Question): DecisionQuestion {
60
61
  instructions: question.instructions,
61
62
  levels: question.criteria.map(label => ({ label })),
62
63
  };
64
+ case "box":
65
+ throw new AIError.ValidationError(`OpenAI Decisions has no box question type (question "${name}")`);
63
66
  }
64
67
  }
65
68