@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.
@@ -20,7 +20,14 @@ import { $env, isRecord } from "@linxiraos/pi-utils";
20
20
  import * as AIError from "../error";
21
21
  import { embeddedImageDataUrl } from "../images/shared";
22
22
  import type { ImageContent } from "../types";
23
- import { type JsonValue, type JudgmentRequest, tokenUsage } from "./types";
23
+ import {
24
+ type BoxQuestion,
25
+ type JsonValue,
26
+ type JudgmentRequest,
27
+ type Question,
28
+ type Questions,
29
+ tokenUsage,
30
+ } from "./types";
24
31
  import { type DecodedJudgment, finiteNumber, type JudgmentImageAssets, type JudgmentWire } from "./wire";
25
32
 
26
33
  function decodeSystemOne(payload: unknown): DecodedJudgment | undefined {
@@ -70,7 +77,7 @@ export function referencedImageAssets(request: JudgmentRequest, assets: Judgment
70
77
  for (const id in request.questions) {
71
78
  const question = request.questions[id];
72
79
  visit(question.instructions);
73
- visit(question.criteria);
80
+ if (question.type !== "box") visit(question.criteria);
74
81
  }
75
82
  // `fromEntries` defines own properties, so a `__proto__` reference stays an ordinary key.
76
83
  return Object.fromEntries(Object.entries(assets).filter(([reference]) => !pending.has(reference)));
@@ -91,11 +98,23 @@ function encodeSystemOneImage(image: ImageContent): SystemOneImageAsset {
91
98
  };
92
99
  }
93
100
 
101
+ type WireQuestion = Exclude<Question, BoxQuestion> | Omit<BoxQuestion, "image">;
102
+
103
+ /** `questions` as System One takes them: a box question's `image` only names the image its edges measure locally. */
104
+ function wireQuestions(questions: Questions): Record<string, WireQuestion> {
105
+ const wire: Record<string, WireQuestion> = {};
106
+ for (const id in questions) {
107
+ const question = questions[id];
108
+ wire[id] = question.type === "box" ? { type: "box", instructions: question.instructions } : question;
109
+ }
110
+ return wire;
111
+ }
112
+
94
113
  /** A System One dialect posting the verbatim request to `path` under the base URL with the key as bearer. */
95
114
  function systemOneWire(path: string): JudgmentWire {
96
115
  return {
97
116
  target: (baseUrl, _model, key) => ({ url: `${baseUrl}${path}`, token: key }),
98
- encode: (request, model) => ({ state: request.state, model, questions: request.questions }),
117
+ encode: (request, model) => ({ state: request.state, model, questions: wireQuestions(request.questions) }),
99
118
  decode: decodeSystemOne,
100
119
  };
101
120
  }
@@ -108,11 +127,12 @@ export const typesafeWire: JudgmentWire = {
108
127
  imagesRequireOptIn: true,
109
128
  imageHeaders: { "TypeSafe-Danger-Zone-Images": "true" },
110
129
  ignoresImageDetail: true,
130
+ boxes: true,
111
131
  encode(request, model, imageAssets) {
112
132
  const suppliedAssets = Object.entries(imageAssets ?? {});
113
133
  const images = request.images ?? [];
114
134
  if (suppliedAssets.length === 0 && images.length === 0) {
115
- return { state: request.state, model, questions: request.questions };
135
+ return { state: request.state, model, questions: wireQuestions(request.questions) };
116
136
  }
117
137
 
118
138
  const assets: Record<string, SystemOneImageAsset> = Object.fromEntries(
@@ -129,7 +149,7 @@ export const typesafeWire: JudgmentWire = {
129
149
  return {
130
150
  state: references.length ? stateWithImages(request.state, references) : request.state,
131
151
  model,
132
- questions: request.questions,
152
+ questions: wireQuestions(request.questions),
133
153
  assets,
134
154
  };
135
155
  },
@@ -140,6 +160,7 @@ export const openrouterDecisionsWire: JudgmentWire = {
140
160
  ...systemOneWire("/decisions"),
141
161
  images: {},
142
162
  imagesRequireOptIn: true,
163
+ boxes: true,
143
164
  encode(request, model) {
144
165
  const imageParts: JsonValue[] = (request.images ?? []).map(image => ({
145
166
  type: "image_url",
@@ -151,7 +172,7 @@ export const openrouterDecisionsWire: JudgmentWire = {
151
172
  return {
152
173
  state: imageParts.length ? stateWithImages(request.state, imageParts) : request.state,
153
174
  model,
154
- questions: request.questions,
175
+ questions: wireQuestions(request.questions),
155
176
  };
156
177
  },
157
178
  };
@@ -265,6 +265,8 @@ export function parseAnswer(id: string, question: Question, reply: string): Answ
265
265
  }
266
266
  return { type: "score", score: level, probabilities, confidence: 1 };
267
267
  }
268
+ case "box":
269
+ throw new AIError.ValidationError(`judgment "${id}": text judges cannot answer box questions`);
268
270
  }
269
271
  }
270
272
 
@@ -288,6 +290,10 @@ export class TextJudge implements Judge {
288
290
  if (request.images?.length && this.#backend.supportsImages !== true) {
289
291
  throw new AIError.ValidationError(`Text judgment backend ${this.label} does not support image inputs`);
290
292
  }
293
+ for (const id of ids) {
294
+ if (request.questions[id].type !== "box") continue;
295
+ throw new AIError.ValidationError(`Text judgment backend ${this.label} does not answer box questions`);
296
+ }
291
297
  const rendered = renderJudgmentPrompt(request, { guardState: this.#backend.guardState });
292
298
  const retries = this.#backend.parseRetries ?? 0;
293
299
  for (let attempt = 0; ; attempt++) {
@@ -3,8 +3,9 @@
3
3
  *
4
4
  * A {@link Judge} answers a map of named questions — {@link ChoiceQuestion}
5
5
  * (one option from a fixed set), {@link NoulQuestion} (probability that a
6
- * yes/no condition holds), {@link ScoreQuestion} (position on ordered levels)
7
- * — about one {@link JudgmentState}. Every question in a request sees the same
6
+ * yes/no condition holds), {@link ScoreQuestion} (position on ordered levels),
7
+ * {@link BoxQuestion} (where a target is in one of the request's images, native
8
+ * judges only) — about one {@link JudgmentState}. Every question in a request sees the same
8
9
  * state and is answered independently, so callers batch independent questions
9
10
  * into one call. The shape mirrors TypeSafe's System One API so the native
10
11
  * backend ({@link NativeJudge}) forwards requests verbatim, while the text
@@ -40,7 +41,20 @@ export interface ScoreQuestion {
40
41
  criteria: readonly [string, string, ...string[]];
41
42
  }
42
43
 
43
- export type Question = ChoiceQuestion | NoulQuestion | ScoreQuestion;
44
+ /**
45
+ * Where one target is in an image (TypeSafe Box, Jev 1.14+): https://docs.typesafe.ai/primitives/box.
46
+ * Only native judges answer it. `instructions` must name the target and, among several images, which
47
+ * one to search; `image` stays local and says which entry of {@link JudgmentRequest.images} the
48
+ * answer's edges measure, so a judge that resizes or pads that image can map them back.
49
+ */
50
+ export interface BoxQuestion {
51
+ type: "box";
52
+ instructions: string;
53
+ /** Index into the request's `images` of the image searched. */
54
+ image: number;
55
+ }
56
+
57
+ export type Question = ChoiceQuestion | NoulQuestion | ScoreQuestion | BoxQuestion;
44
58
 
45
59
  /** Questions keyed by caller-chosen ids; answers come back under the same ids. */
46
60
  export type Questions = Record<string, Question>;
@@ -71,11 +85,33 @@ export interface ScoreAnswer {
71
85
  confidence: number;
72
86
  }
73
87
 
74
- export type Answer = ChoiceAnswer | NoulAnswer | ScoreAnswer;
88
+ /** A rectangle's edges as 0–1 fractions of an image's width (`left` < `right`) and height (`top` < `bottom`), from its top-left corner. */
89
+ export interface BoxEdges {
90
+ top: number;
91
+ bottom: number;
92
+ left: number;
93
+ right: number;
94
+ }
95
+
96
+ export interface BoxAnswer {
97
+ type: "box";
98
+ /** Smallest rectangle containing the target, on the image the question's `image` names. Always present: absence is a separate yes/no question. */
99
+ box: BoxEdges;
100
+ /** How sure the model is of the box, 0–1. */
101
+ confidence: number;
102
+ }
103
+
104
+ export type Answer = ChoiceAnswer | NoulAnswer | ScoreAnswer | BoxAnswer;
75
105
 
76
106
  /** The answer type for a question, preserving choice labels. */
77
107
  export type AnswerFor<Q extends Question> =
78
- Q extends ChoiceQuestion<infer L> ? ChoiceAnswer<L> : Q extends NoulQuestion ? NoulAnswer : ScoreAnswer;
108
+ Q extends ChoiceQuestion<infer L>
109
+ ? ChoiceAnswer<L>
110
+ : Q extends NoulQuestion
111
+ ? NoulAnswer
112
+ : Q extends BoxQuestion
113
+ ? BoxAnswer
114
+ : ScoreAnswer;
79
115
 
80
116
  export interface JudgmentRequest<Q extends Questions = Questions> {
81
117
  state: JudgmentState;
@@ -46,6 +46,8 @@ export interface JudgmentWire {
46
46
  imagesRequireOptIn?: boolean;
47
47
  /** Whether this wire drops per-image detail metadata. */
48
48
  ignoresImageDetail?: boolean;
49
+ /** Whether this dialect forwards `box` questions; others reject them before sending. */
50
+ boxes?: boolean;
49
51
  /** The response's answers and usage; `undefined` when the payload is not this API's response shape. */
50
52
  decode(payload: unknown): DecodedJudgment | undefined;
51
53
  }
@@ -245,6 +245,17 @@ function convertMessage(
245
245
  };
246
246
  }
247
247
 
248
+ /** Whether Ollama chat removes assistant thinking from replayed history. */
249
+ export function ollamaChatDropsOwnThinking(
250
+ model: { api?: string; compat?: Model["compat"] } | null | undefined,
251
+ ): boolean {
252
+ if (model?.api !== "ollama-chat") return false;
253
+ const compat = model.compat;
254
+ // Cached model rows without compat predate this guard; strip for safe replay.
255
+ if (!compat || !("stripThinkingHistory" in compat)) return true;
256
+ return compat.stripThinkingHistory ?? true;
257
+ }
258
+
248
259
  function convertMessages(model: Model<"ollama-chat">, context: Context): OllamaMessage[] {
249
260
  const systemPrompts = normalizeSystemPrompts(context.systemPrompt);
250
261
  const systemMessages: Message[] = systemPrompts.map(systemPrompt => ({
@@ -253,10 +264,7 @@ function convertMessages(model: Model<"ollama-chat">, context: Context): OllamaM
253
264
  timestamp: Date.now(),
254
265
  }));
255
266
  const messages: Message[] = [...systemMessages, ...context.messages];
256
- // Model rows cached or bundled before Ollama had a compat record carry none;
257
- // stripping is the replay-safe answer for them until they are rebuilt.
258
- const compat: Partial<typeof model.compat> | undefined = model.compat;
259
- const stripThinkingHistory = compat?.stripThinkingHistory ?? true;
267
+ const stripThinkingHistory = ollamaChatDropsOwnThinking(model);
260
268
  const supportsImages = model.input.includes("image");
261
269
  const converted = transformMessages(messages, model).map((msg, index) => {
262
270
  // Real `systemPrompt` entries (always emitted first) stay on Ollama's
@@ -518,6 +518,14 @@ export function isAnthropicMessagesModel(model: Model): model is Model<"anthropi
518
518
  return model.api === "anthropic-messages";
519
519
  }
520
520
 
521
+ /** Whether `turn` came from `model` itself: its own turns replay their native reasoning unchanged. */
522
+ export function isSameModelTurn(
523
+ turn: Pick<AssistantMessage, "provider" | "api" | "model">,
524
+ model: Pick<Model, "provider" | "api" | "id">,
525
+ ): boolean {
526
+ return turn.provider === model.provider && turn.api === model.api && turn.model === model.id;
527
+ }
528
+
521
529
  /**
522
530
  * Targets that have proven they read unsigned foreign thinking when replayed
523
531
  * natively. This is a semantic-carry allowlist only: OpenAI-compatible
@@ -883,10 +891,7 @@ export function transformMessages<TApi extends Api>(
883
891
  // Assistant messages need transformation check
884
892
  if (msg.role === "assistant") {
885
893
  const assistantMsg = msg as AssistantMessage;
886
- const isSameModel =
887
- assistantMsg.provider === model.provider &&
888
- assistantMsg.api === model.api &&
889
- assistantMsg.model === model.id;
894
+ const isSameModel = isSameModelTurn(assistantMsg, model);
890
895
  // The same model on another host (or another id of the same model):
891
896
  // its full plaintext reasoning replays natively where the target has
892
897
  // a native slot, exactly as the target replays its own turns. Errored
@@ -1,11 +1,25 @@
1
1
  import * as AIError from "../error";
2
- import { type MuseCodeKeyResponse, parseMuseCodeCredential, requestMuseCodeKey } from "../registry/oauth/muse-code";
3
- import type { UsageAmount, UsageFetchParams, UsageLimit, UsageProvider, UsageReport, UsageWindow } from "../usage";
2
+ import { parseMuseCodeCredential, requestMuseCodeKey, type MuseCodeKeyResponse } from "../registry/oauth/muse-code";
3
+ import {
4
+ resolveUsedFraction,
5
+ type UsageAmount,
6
+ type UsageFetchParams,
7
+ type UsageLimit,
8
+ type UsageProvider,
9
+ type UsageReport,
10
+ type UsageWindow,
11
+ } from "../usage";
4
12
  import { parseIsoTimestamp, parsePositiveTimestamp, usageStatus, WEEK_MS } from "./shared";
5
13
 
6
14
  const PROVIDER = "muse-code";
7
15
  const SOURCE = "api.meta.ai/muse-code/key";
8
16
  const FAILURE_BACKOFF_MS = 5 * 60_000;
17
+ const WEEKLY_ID = "1w";
18
+ /** Limit ids `rollingWindow()` produces: `<minutes>m`, or `rolling` when Meta names no duration. */
19
+ const ROLLING_ID = /^(?:rolling|\d+m)$/;
20
+ /** Meta's documented rolling window, used when no earlier report names one. */
21
+ const DEFAULT_ROLLING_MINUTES = 300;
22
+ type MuseCodeSubscriptionUsage = NonNullable<MuseCodeKeyResponse["subs_usage"]>;
9
23
 
10
24
  function parseResetTimestamp(value: string | number | undefined): number | undefined {
11
25
  return typeof value === "string" ? parseIsoTimestamp(value) : parsePositiveTimestamp(value);
@@ -24,15 +38,15 @@ function percentAmount(usedPercent: number): UsageAmount {
24
38
  };
25
39
  }
26
40
 
27
- function buildLimit(
41
+ function percentLimit(
28
42
  id: string,
29
43
  label: string,
30
- usedPercent: number | undefined,
44
+ usedPercent: number,
31
45
  window: UsageWindow,
32
46
  tier: string | undefined,
33
47
  accountId: string | undefined,
34
- ): UsageLimit | undefined {
35
- if (typeof usedPercent !== "number" || !Number.isFinite(usedPercent) || usedPercent < 0) return undefined;
48
+ notes?: string[],
49
+ ): UsageLimit {
36
50
  const amount = percentAmount(usedPercent);
37
51
  return {
38
52
  id,
@@ -41,9 +55,22 @@ function buildLimit(
41
55
  window,
42
56
  amount,
43
57
  status: usageStatus(amount.usedFraction),
58
+ ...(notes ? { notes } : {}),
44
59
  };
45
60
  }
46
61
 
62
+ function buildLimit(
63
+ id: string,
64
+ label: string,
65
+ usedPercent: number | undefined,
66
+ window: UsageWindow,
67
+ tier: string | undefined,
68
+ accountId: string | undefined,
69
+ ): UsageLimit | undefined {
70
+ if (typeof usedPercent !== "number" || !Number.isFinite(usedPercent) || usedPercent < 0) return undefined;
71
+ return percentLimit(id, label, usedPercent, window, tier, accountId);
72
+ }
73
+
47
74
  function formatRollingWindowLabel(minutes: number | undefined): string {
48
75
  if (minutes === undefined) return "Rolling Window";
49
76
  if (minutes % 60 === 0) {
@@ -53,29 +80,27 @@ function formatRollingWindowLabel(minutes: number | undefined): string {
53
80
  return `${minutes} ${minutes === 1 ? "Minute" : "Minutes"}`;
54
81
  }
55
82
 
56
- function buildLimits(payload: MuseCodeKeyResponse, accountId: string | undefined): UsageLimit[] {
57
- const usage = payload.subs_usage;
58
- if (!usage) return [];
59
- const tier = payload.subs_tier_name?.trim() || payload.subs_tier_id?.trim() || undefined;
83
+ function rollingWindow(minutes: number | undefined): UsageWindow {
84
+ const valid = typeof minutes === "number" && Number.isFinite(minutes) && minutes > 0;
85
+ const durationMs = valid ? minutes * 60_000 : undefined;
86
+ const label = formatRollingWindowLabel(valid ? Math.round(minutes) : undefined);
87
+ const id = durationMs === undefined ? "rolling" : `${Math.round(durationMs / 60_000)}m`;
88
+ return { id, label, durationMs };
89
+ }
90
+
91
+ function buildLimits(
92
+ usage: MuseCodeSubscriptionUsage,
93
+ tier: string | undefined,
94
+ accountId: string | undefined,
95
+ ): UsageLimit[] {
60
96
  const limits: UsageLimit[] = [];
61
97
  if (usage.window) {
62
- const minutes = usage.window.window_duration_mins;
63
- const durationMs =
64
- typeof minutes === "number" && Number.isFinite(minutes) && minutes > 0 ? minutes * 60_000 : undefined;
65
- const label = formatRollingWindowLabel(
66
- typeof minutes === "number" && Number.isFinite(minutes) && minutes > 0 ? Math.round(minutes) : undefined,
67
- );
68
- const id = durationMs === undefined ? "rolling" : `${Math.round(durationMs / 60_000)}m`;
98
+ const window = rollingWindow(usage.window.window_duration_mins);
69
99
  const limit = buildLimit(
70
- id,
71
- label,
100
+ window.id,
101
+ window.label,
72
102
  usage.window.used_percent,
73
- {
74
- id,
75
- label,
76
- durationMs,
77
- resetsAt: parseResetTimestamp(usage.window.resets_at),
78
- },
103
+ { ...window, resetsAt: parseResetTimestamp(usage.window.resets_at) },
79
104
  tier,
80
105
  accountId,
81
106
  );
@@ -83,11 +108,11 @@ function buildLimits(payload: MuseCodeKeyResponse, accountId: string | undefined
83
108
  }
84
109
  if (usage.weekly) {
85
110
  const limit = buildLimit(
86
- "1w",
111
+ WEEKLY_ID,
87
112
  "Weekly",
88
113
  usage.weekly.used_percent,
89
114
  {
90
- id: "1w",
115
+ id: WEEKLY_ID,
91
116
  label: "Weekly",
92
117
  durationMs: WEEK_MS,
93
118
  resetsAt: parseResetTimestamp(usage.weekly.resets_at),
@@ -100,6 +125,54 @@ function buildLimits(payload: MuseCodeKeyResponse, accountId: string | undefined
100
125
  return limits;
101
126
  }
102
127
 
128
+ /**
129
+ * Limits for a key response that omits `subs_usage`, which Meta does while the
130
+ * rolling window is idle: no request has opened one, so it is unused. Weekly
131
+ * usage only accrues inside a rolling window, so the previous report's weekly
132
+ * figure holds until its reset and is zero after it. The carried figure is a
133
+ * lower bound: usage after that report (late in its window, or from another
134
+ * client on the same subscription) is not visible. Meta's weekly reset is a
135
+ * fixed weekly boundary (observed at Monday 00:00 UTC), so a passed reset rolls
136
+ * forward by whole weeks. Without a previous weekly figure and its reset the
137
+ * weekly limit stays unreported: an unbounded carry would present old usage as
138
+ * current forever.
139
+ */
140
+ function buildIdleLimits(
141
+ previous: UsageReport | undefined,
142
+ tier: string | undefined,
143
+ accountId: string | undefined,
144
+ now: number,
145
+ ): UsageLimit[] {
146
+ const previousRolling = previous?.limits.find(limit => ROLLING_ID.test(limit.id));
147
+ const shape = previousRolling?.window ?? rollingWindow(DEFAULT_ROLLING_MINUTES);
148
+ const window: UsageWindow = { id: shape.id, label: shape.label, durationMs: shape.durationMs };
149
+ const limits = [
150
+ percentLimit(window.id, window.label, 0, window, tier, accountId, ["Idle: no request has opened this window"]),
151
+ ];
152
+ const previousWeekly = previous?.limits.find(limit => limit.id === WEEKLY_ID);
153
+ const usedFraction = previousWeekly ? resolveUsedFraction(previousWeekly) : undefined;
154
+ let resetsAt = previousWeekly?.window?.resetsAt;
155
+ if (usedFraction === undefined || resetsAt === undefined) return limits;
156
+ const reset = resetsAt <= now;
157
+ if (reset) resetsAt += (Math.floor((now - resetsAt) / WEEK_MS) + 1) * WEEK_MS;
158
+ limits.push(
159
+ percentLimit(
160
+ WEEKLY_ID,
161
+ "Weekly",
162
+ reset ? 0 : usedFraction * 100,
163
+ { id: WEEKLY_ID, label: "Weekly", durationMs: WEEK_MS, resetsAt },
164
+ tier,
165
+ accountId,
166
+ [
167
+ reset
168
+ ? "Reset since the last reported usage"
169
+ : "Carried from the last reported usage; later usage not visible",
170
+ ],
171
+ ),
172
+ );
173
+ return limits;
174
+ }
175
+
103
176
  export const museCodeUsageProvider: UsageProvider = {
104
177
  id: PROVIDER,
105
178
  validatesCredentials: true,
@@ -137,14 +210,27 @@ export const museCodeUsageProvider: UsageProvider = {
137
210
  }
138
211
  const email = payload.user_email?.trim().toLowerCase() || params.credential.email;
139
212
  const accountId = payload.user_id?.trim() || params.credential.accountId || email;
140
- const limits = buildLimits(payload, accountId);
213
+ const tier = payload.subs_tier_name?.trim() || payload.subs_tier_id?.trim() || undefined;
214
+ const now = Date.now();
215
+ // Payment-required responses omit `subs_usage` too; only an explicitly
216
+ // active subscription with no payment demand or action means an idle window.
217
+ const idle =
218
+ payload.is_subs_active === true &&
219
+ payload.require_payment !== true &&
220
+ !payload.require_payment_action_url?.trim() &&
221
+ !payload.action_url?.trim();
222
+ const limits = payload.subs_usage
223
+ ? buildLimits(payload.subs_usage, tier, accountId)
224
+ : idle
225
+ ? buildIdleLimits(ctx.previousReport, tier, accountId, now)
226
+ : [];
141
227
  if (limits.length === 0) return null;
142
228
  const raw = { ...payload };
143
229
  delete raw.api_key;
144
230
 
145
231
  return {
146
232
  provider: PROVIDER,
147
- fetchedAt: Date.now(),
233
+ fetchedAt: now,
148
234
  limits,
149
235
  metadata: {
150
236
  source: SOURCE,
@@ -1,15 +1,22 @@
1
1
  /**
2
- * Removal of DeepSeek DSML tool-call markup left in visible assistant text.
2
+ * Removal of DeepSeek tool-call markup left in visible assistant text.
3
3
  *
4
4
  * DeepSeek models on OpenAI-compatible hosts sometimes emit their tool calls as
5
- * raw `<|DSML|…>` markup in `content`. A well-formed envelope is healed into a
6
- * structured call by `StreamMarkupHealing`, but a degraded model can drop the
7
- * opening `<|DSML|tool_calls>` / `<|DSML|invoke name="…">` tags (or the host
8
- * runs no healer at all). That markup must not stay in history: replaying it
9
- * teaches the model to keep writing tool calls as text (issue #10556). Callers
10
- * strip it before the message is committed and tell the model its call failed.
5
+ * text in `content`: the V4 `<|DSML|…>` envelope or the V3 chat-template
6
+ * tokens (`<|tool▁calls▁begin|>` …). A well-formed call is healed into a
7
+ * structured call by `StreamMarkupHealing`, but a degraded model can drop
8
+ * required tags (or the host runs no healer at all). That markup must not stay
9
+ * in history: replaying it teaches the model to keep writing tool calls as text
10
+ * (issue #10556). Callers strip it before the message is committed and tell the
11
+ * model its call failed.
11
12
  */
12
13
 
14
+ import {
15
+ DEEPSEEK_TOOL_CALL_END,
16
+ DEEPSEEK_TOOL_CALL_TOKENS,
17
+ DEEPSEEK_TOOL_CALLS_END,
18
+ findDeepSeekTemplateToken,
19
+ } from "../dialect/deepseek";
13
20
  import type { AssistantMessage, Model } from "../types";
14
21
 
15
22
  // Structural DSML tags in either pipe spelling (fullwidth `|` U+FF5C or ASCII),
@@ -24,26 +31,56 @@ const TRAILING_TOOL_NAME_LINE = /(^|\n)[ \t]*[A-Za-z_][\w.-]*[ \t]*\r?\n[ \t]*$/
24
31
  const CLOSING_TAG_PATTERN = /^<\s*\//;
25
32
  const BLANK_LINE_PATTERN = /\r?\n[ \t]*\r?\n/;
26
33
 
34
+ interface MarkupTag {
35
+ readonly index: number;
36
+ readonly end: number;
37
+ /** Ends a call or envelope, so the broken call stops here. */
38
+ readonly closing: boolean;
39
+ }
40
+
41
+ /** DSML tags and tool-call template tokens (in any spelling the scanner accepts), in text order. */
42
+ function findMarkupTags(text: string): MarkupTag[] {
43
+ const tags: MarkupTag[] = [];
44
+ for (const match of text.matchAll(DSML_TAG_PATTERN)) {
45
+ tags.push({
46
+ index: match.index,
47
+ end: match.index + match[0].length,
48
+ closing: CLOSING_TAG_PATTERN.test(match[0]),
49
+ });
50
+ }
51
+ for (
52
+ let match = findDeepSeekTemplateToken(text, DEEPSEEK_TOOL_CALL_TOKENS);
53
+ match;
54
+ match = findDeepSeekTemplateToken(text, DEEPSEEK_TOOL_CALL_TOKENS, match.index + match.raw.length)
55
+ ) {
56
+ tags.push({
57
+ index: match.index,
58
+ end: match.index + match.raw.length,
59
+ closing: match.token === DEEPSEEK_TOOL_CALL_END || match.token === DEEPSEEK_TOOL_CALLS_END,
60
+ });
61
+ }
62
+ return tags.sort((a, b) => a.index - b.index);
63
+ }
64
+
27
65
  /**
28
66
  * Remove the broken tool call from one stretch of unquoted text: everything from
29
- * the first DSML tag (or the tool-name line just before it) through the last.
67
+ * the first markup tag (or the tool-name line just before it) through the last.
30
68
  * The DSML scanner keeps a malformed call's closers, so its last closer marks
31
69
  * the end. When the closers never arrived, the dangling argument value runs to
32
70
  * the next blank line (or the end of the stretch); prose after that survives.
33
71
  */
34
72
  function stripUnquoted(text: string): string {
35
- const tags = [...text.matchAll(DSML_TAG_PATTERN)];
73
+ const tags = findMarkupTags(text);
36
74
  if (tags.length === 0) return text;
37
75
  const first = tags[0]!;
38
76
  const last = tags[tags.length - 1]!;
39
77
  let start = first.index;
40
78
  const nameLine = TRAILING_TOOL_NAME_LINE.exec(text.slice(0, start));
41
79
  if (nameLine) start = nameLine.index + nameLine[1]!.length;
42
- const lastEnd = last.index + last[0].length;
43
- let end = lastEnd;
44
- if (!CLOSING_TAG_PATTERN.test(last[0])) {
45
- const blankLine = text.slice(lastEnd).search(BLANK_LINE_PATTERN);
46
- end = blankLine === -1 ? text.length : lastEnd + blankLine;
80
+ let end = last.end;
81
+ if (!last.closing) {
82
+ const blankLine = text.slice(last.end).search(BLANK_LINE_PATTERN);
83
+ end = blankLine === -1 ? text.length : last.end + blankLine;
47
84
  }
48
85
  const before = text.slice(0, start).trimEnd();
49
86
  const after = text.slice(end).trimStart();
@@ -53,8 +90,8 @@ function stripUnquoted(text: string): string {
53
90
  /**
54
91
  * Whether DSML leak recovery applies to `model`: a DeepSeek-class model, or one
55
92
  * explicitly configured with the `dsml` stream healer (e.g. a `models.yml` entry
56
- * whose id the classifier cannot place). Other models never write DSML tool
57
- * calls, so DSML text from them is prose and is left alone.
93
+ * whose id the classifier cannot place). Other models never write DeepSeek tool
94
+ * calls, so such markup from them is prose and is left alone.
58
95
  */
59
96
  export function isDsmlLeakRecoveryTarget(model: Model): boolean {
60
97
  if (model.identity?.class === "deepseek") return true;
@@ -65,11 +102,13 @@ export function isDsmlLeakRecoveryTarget(model: Model): boolean {
65
102
  }
66
103
 
67
104
  /**
68
- * Strip leaked DSML tool-call markup from `text`, leaving quoted code intact.
69
- * Returns `undefined` when `text` carries no such markup.
105
+ * Strip leaked DeepSeek tool-call markup (DSML or chat-template tokens) from
106
+ * `text`, leaving quoted code intact. Returns `undefined` when `text` carries
107
+ * no such markup.
70
108
  */
71
109
  export function stripDsmlToolMarkup(text: string): string | undefined {
72
- if (!text.includes("DSML")) return undefined;
110
+ // Every DSML tag names `DSML`; every template token spelling carries a fullwidth bar.
111
+ if (!text.includes("DSML") && !text.includes("|")) return undefined;
73
112
  let out = "";
74
113
  let cursor = 0;
75
114
  let changed = false;