@linxiraos/pi-ai 1.3.0 → 1.3.2
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 +40 -21
- package/dist/types/dialect/deepseek.d.ts +13 -0
- package/dist/types/judgment/native.d.ts +13 -2
- package/dist/types/judgment/types.d.ts +32 -5
- package/dist/types/judgment/wire.d.ts +2 -0
- package/dist/types/providers/ollama.d.ts +6 -1
- package/dist/types/providers/transform-messages.d.ts +2 -0
- package/dist/types/usage/muse-code.d.ts +1 -1
- package/dist/types/utils/dsml-leak.d.ts +13 -11
- package/package.json +6 -6
- package/src/dialect/deepseek.ts +344 -81
- package/src/judgment/native.ts +63 -7
- package/src/judgment/openai-decisions.ts +3 -0
- package/src/judgment/systemone.ts +27 -6
- package/src/judgment/text.ts +6 -0
- package/src/judgment/types.ts +41 -5
- package/src/judgment/wire.ts +2 -0
- package/src/providers/ollama.ts +12 -4
- package/src/providers/transform-messages.ts +9 -4
- package/src/usage/muse-code.ts +115 -29
- package/src/utils/dsml-leak.ts +58 -19
package/src/dialect/deepseek.ts
CHANGED
|
@@ -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
|
-
|
|
157
|
-
|
|
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 =
|
|
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
|
|
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
|
-
|
|
241
|
-
|
|
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 (
|
|
247
|
-
this.#
|
|
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 (
|
|
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.#
|
|
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
|
-
|
|
273
|
-
|
|
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
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
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
|
-
|
|
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
|
-
|
|
321
|
-
|
|
322
|
-
|
|
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 (
|
|
328
|
-
this.#buffer =
|
|
329
|
-
this.#rawBlock =
|
|
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 (
|
|
334
|
-
|
|
335
|
-
|
|
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
|
|
342
|
-
if (sep
|
|
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 +
|
|
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
|
-
|
|
377
|
-
|
|
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
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
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(
|
|
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
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
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
|
|
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 {
|
package/src/judgment/native.ts
CHANGED
|
@@ -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
|
-
*
|
|
71
|
-
*
|
|
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
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
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
|
|