@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.
- package/CHANGELOG.md +36 -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
|
@@ -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 {
|
|
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
|
};
|
package/src/judgment/text.ts
CHANGED
|
@@ -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++) {
|
package/src/judgment/types.ts
CHANGED
|
@@ -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
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
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>
|
|
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;
|
package/src/judgment/wire.ts
CHANGED
|
@@ -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
|
}
|
package/src/providers/ollama.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
package/src/usage/muse-code.ts
CHANGED
|
@@ -1,11 +1,25 @@
|
|
|
1
1
|
import * as AIError from "../error";
|
|
2
|
-
import {
|
|
3
|
-
import
|
|
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
|
|
41
|
+
function percentLimit(
|
|
28
42
|
id: string,
|
|
29
43
|
label: string,
|
|
30
|
-
usedPercent: number
|
|
44
|
+
usedPercent: number,
|
|
31
45
|
window: UsageWindow,
|
|
32
46
|
tier: string | undefined,
|
|
33
47
|
accountId: string | undefined,
|
|
34
|
-
|
|
35
|
-
|
|
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
|
|
57
|
-
const
|
|
58
|
-
|
|
59
|
-
const
|
|
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
|
|
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
|
-
|
|
111
|
+
WEEKLY_ID,
|
|
87
112
|
"Weekly",
|
|
88
113
|
usage.weekly.used_percent,
|
|
89
114
|
{
|
|
90
|
-
id:
|
|
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
|
|
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:
|
|
233
|
+
fetchedAt: now,
|
|
148
234
|
limits,
|
|
149
235
|
metadata: {
|
|
150
236
|
source: SOURCE,
|
package/src/utils/dsml-leak.ts
CHANGED
|
@@ -1,15 +1,22 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Removal of DeepSeek
|
|
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
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
* runs no healer at all). That markup must not stay
|
|
9
|
-
* teaches the model to keep writing tool calls as text
|
|
10
|
-
* strip it before the message is committed and tell the
|
|
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
|
|
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 =
|
|
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
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
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
|
|
57
|
-
* calls, so
|
|
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
|
|
69
|
-
* Returns `undefined` when `text` carries
|
|
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
|
-
|
|
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;
|