mlx-bun 0.0.11 → 0.0.13
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/README.md +63 -27
- package/THIRD_PARTY_LICENSES.md +9 -0
- package/docs/design/colibri-glm52-port.md +1486 -0
- package/docs/design/pre-colibri-stabilization.md +329 -0
- package/docs/investigations/colibri-oracle-pin.md +1284 -0
- package/docs/investigations/indexshare-performance-spike.md +357 -0
- package/docs/planning/release-notes-v0.0.12.md +80 -0
- package/docs/planning/release-notes-v0.0.13.md +77 -0
- package/docs/reference/cli.md +28 -2
- package/docs/reference/distribution.md +25 -1
- package/docs/reference/embedding.md +5 -2
- package/docs/reference/features-matrix.md +5 -2
- package/docs/reference/library-api.md +41 -13
- package/docs/reference/models.md +49 -2
- package/docs/reference/server-api.md +113 -20
- package/docs/reference/server-config.md +21 -14
- package/docs/reference/training.md +10 -1
- package/package.json +1 -1
- package/src/anthropic.ts +54 -44
- package/src/chat-template.ts +189 -11
- package/src/cli.ts +127 -8
- package/src/config.ts +81 -26
- package/src/diffusion/diffusion-generate.ts +14 -8
- package/src/download.ts +103 -12
- package/src/eval/ifeval.ts +63 -120
- package/src/eval/runner.ts +14 -5
- package/src/eval/tasks/ifeval.ts +167 -31
- package/src/expert-io.ts +391 -0
- package/src/expert-residency.ts +1134 -0
- package/src/expert-usage.ts +415 -0
- package/src/generate.ts +114 -17
- package/src/hf-push.ts +1 -1
- package/src/index.ts +14 -2
- package/src/jobs/runner.ts +37 -18
- package/src/kv-store.ts +147 -8
- package/src/lora.ts +77 -14
- package/src/media-fetch.ts +198 -56
- package/src/mlx/array.ts +43 -0
- package/src/mlx/autograd.ts +51 -14
- package/src/mlx/checkpoint.ts +46 -13
- package/src/mlx/custom-vjp.ts +83 -35
- package/src/model/factory.ts +96 -5
- package/src/model/gemma4-base.ts +32 -0
- package/src/model/glm52-atlas.ts +511 -0
- package/src/model/glm52-cache.ts +706 -0
- package/src/model/glm52-config.ts +262 -0
- package/src/model/glm52-container.ts +305 -0
- package/src/model/glm52-coupling.ts +388 -0
- package/src/model/glm52-dsa.ts +602 -0
- package/src/model/glm52-expert-layout.ts +253 -0
- package/src/model/glm52-layout.ts +152 -0
- package/src/model/glm52-memory.ts +473 -0
- package/src/model/glm52-mla.ts +786 -0
- package/src/model/glm52-moe.ts +223 -0
- package/src/model/glm52-pilot.ts +537 -0
- package/src/model/glm52-reference.ts +445 -0
- package/src/model/glm52-residency.ts +557 -0
- package/src/model/glm52-resident-weights.ts +135 -0
- package/src/model/glm52-streamed-experts.ts +453 -0
- package/src/model/glm52-streamed-metal.ts +610 -0
- package/src/model/glm52-weights.ts +503 -0
- package/src/model/glm52.ts +1153 -0
- package/src/model/support.ts +8 -1
- package/src/native/expert_io.c +523 -0
- package/src/native-pack.ts +6 -4
- package/src/responses.ts +62 -49
- package/src/serve/batch-scheduler.ts +95 -16
- package/src/serve/generation-gateway.ts +41 -16
- package/src/server.ts +471 -93
- package/src/spec/glm52-mtp-source.ts +392 -0
- package/src/spec/serve-loop.ts +99 -18
- package/src/spec/source.ts +31 -6
- package/src/ssd-cache.ts +6 -2
- package/src/tool-call.ts +30 -2
- package/src/train/job.ts +0 -10
- package/src/train/lora-params.ts +17 -6
- package/src/train/trainer.ts +59 -26
- package/src/web/app.html +13 -2
- package/src/web/app.js +30 -3
- package/src/web/src/status.ts +51 -3
- package/src/web-tools.ts +54 -2
- package/src/weights.ts +82 -19
package/src/ssd-cache.ts
CHANGED
|
@@ -42,10 +42,12 @@ export interface SsdIndexEntry {
|
|
|
42
42
|
|
|
43
43
|
/** Trimmability from a file header alone (mirror of the live caches'
|
|
44
44
|
* isTrimmable(): kv/qkv/turboquant always trim; rotating kinds only pre-wrap
|
|
45
|
-
* (offset < maxSize); ssm never). Keep in
|
|
45
|
+
* (offset < maxSize); GLM compressed kinds always trim; ssm never). Keep in
|
|
46
|
+
* sync with gemma4-base/qwen3-delta/glm52-cache. */
|
|
46
47
|
const headerTrimmable = (caches: { kind: string; offset: number; maxSize?: number }[]): boolean =>
|
|
47
48
|
caches.every((c) =>
|
|
48
|
-
c.kind === "kv" || c.kind === "qkv" || c.kind === "turboquant"
|
|
49
|
+
c.kind === "kv" || c.kind === "qkv" || c.kind === "turboquant" ||
|
|
50
|
+
c.kind === "mla" || c.kind === "mla-dsa" || c.kind === "mtp-mla"
|
|
49
51
|
? true
|
|
50
52
|
: c.kind === "rotating" || c.kind === "rotating-qkv"
|
|
51
53
|
? c.offset < (c.maxSize ?? 0)
|
|
@@ -111,6 +113,7 @@ export class SsdCacheStore {
|
|
|
111
113
|
try {
|
|
112
114
|
const h = readKvHeader(path); // verifies the header hash
|
|
113
115
|
if (
|
|
116
|
+
h.modelId !== this.#opts.modelId ||
|
|
114
117
|
h.configFingerprint !== this.#opts.configFingerprint ||
|
|
115
118
|
h.tokenizerHash !== this.#opts.tokenizerHash
|
|
116
119
|
)
|
|
@@ -159,6 +162,7 @@ export class SsdCacheStore {
|
|
|
159
162
|
const t0 = performance.now();
|
|
160
163
|
try {
|
|
161
164
|
const expect: KvLoadExpect = {
|
|
165
|
+
modelId: this.#opts.modelId,
|
|
162
166
|
configFingerprint: this.#opts.configFingerprint,
|
|
163
167
|
tokenizerHash: this.#opts.tokenizerHash,
|
|
164
168
|
ns: entry.ns,
|
package/src/tool-call.ts
CHANGED
|
@@ -162,6 +162,8 @@ export function parseToolCalls(text: string): ParsedToolCall[] {
|
|
|
162
162
|
const TOOL_CALL_BLOCK_RE = /<tool_call>\s*([\s\S]*?)\s*<\/tool_call>/gi;
|
|
163
163
|
const XML_FUNCTION_EQUALS_RE = /^\s*<function=([^>\s]+)>\s*([\s\S]*?)\s*<\/function>\s*$/i;
|
|
164
164
|
const XML_PARAMETER_EQUALS_RE = /<parameter=([^>\s]+)>\s*([\s\S]*?)\s*<\/parameter>/gi;
|
|
165
|
+
const GLM52_ARG_KEY_RE = /<arg_key>/i;
|
|
166
|
+
const GLM52_ARG_PAIR_RE = /<arg_key>\s*([\s\S]*?)\s*<\/arg_key>\s*<arg_value>\s*([\s\S]*?)\s*<\/arg_value>/gi;
|
|
165
167
|
const XML_FUNCTION_ATTR_RE = /<function\s+name=["']([^"']+)["']\s*>\s*([\s\S]*?)\s*<\/function>/gi;
|
|
166
168
|
// CDATA alternative first so a `</param>` inside a CDATA block never
|
|
167
169
|
// terminates the value early.
|
|
@@ -560,6 +562,29 @@ function parseXmlEqualsToolCall(block: string, tools: ToolSpec[]): ParsedToolCal
|
|
|
560
562
|
return { name, arguments: args };
|
|
561
563
|
}
|
|
562
564
|
|
|
565
|
+
function parseGlm52ToolCall(block: string, tools: ToolSpec[]): ParsedToolCall | null {
|
|
566
|
+
const firstArg = block.search(GLM52_ARG_KEY_RE);
|
|
567
|
+
const name = (firstArg < 0 ? block : block.slice(0, firstArg)).trim();
|
|
568
|
+
if (!name || !/^[^\s<>]+$/.test(name)) return null;
|
|
569
|
+
if (firstArg < 0) return { name, arguments: {} };
|
|
570
|
+
|
|
571
|
+
const body = block.slice(firstArg);
|
|
572
|
+
const args: Record<string, unknown> = {};
|
|
573
|
+
let cursor = 0;
|
|
574
|
+
let matched = false;
|
|
575
|
+
for (const pair of body.matchAll(GLM52_ARG_PAIR_RE)) {
|
|
576
|
+
if (body.slice(cursor, pair.index).trim()) return null;
|
|
577
|
+
const key = pair[1]!.trim();
|
|
578
|
+
if (!key) throw new Error(`tool '${name}' contains an empty argument name`);
|
|
579
|
+
args[key] = decodeToolValue(pair[2]!, toolParameterSchema(tools, name, key));
|
|
580
|
+
cursor = pair.index + pair[0].length;
|
|
581
|
+
matched = true;
|
|
582
|
+
}
|
|
583
|
+
GLM52_ARG_PAIR_RE.lastIndex = 0;
|
|
584
|
+
if (!matched || body.slice(cursor).trim()) return null;
|
|
585
|
+
return { name, arguments: args };
|
|
586
|
+
}
|
|
587
|
+
|
|
563
588
|
function parseXmlAttrToolCalls(text: string, tools: ToolSpec[]): ParsedToolCall[] {
|
|
564
589
|
const calls: ParsedToolCall[] = [];
|
|
565
590
|
for (const fn of text.matchAll(XML_FUNCTION_ATTR_RE)) {
|
|
@@ -582,7 +607,8 @@ function parseXmlAttrToolCalls(text: string, tools: ToolSpec[]): ParsedToolCall[
|
|
|
582
607
|
* after generation, and only when tools are active; tokenizer ids are
|
|
583
608
|
* model-family-specific and must not be used globally. Supports the
|
|
584
609
|
* OpenAI JSON `<tool_call>...</tool_call>` contract, Qwen-style
|
|
585
|
-
* `<function=name><parameter=...>`,
|
|
610
|
+
* `<function=name><parameter=...>`, GLM-5.2's
|
|
611
|
+
* `name<arg_key>...<arg_value>...`, and MiniCPM5's native
|
|
586
612
|
* `<function name="..."><param name="...">...` template shape. */
|
|
587
613
|
export function parseGeneratedToolCalls(text: string, tools: ToolSpec[]): ParsedToolCall[] {
|
|
588
614
|
if (!tools.length) return [];
|
|
@@ -590,7 +616,9 @@ export function parseGeneratedToolCalls(text: string, tools: ToolSpec[]): Parsed
|
|
|
590
616
|
const calls: ParsedToolCall[] = [];
|
|
591
617
|
for (const block of text.matchAll(TOOL_CALL_BLOCK_RE)) {
|
|
592
618
|
const body = block[1]!.trim();
|
|
593
|
-
const parsed = parseJsonToolCall(body) ??
|
|
619
|
+
const parsed = parseJsonToolCall(body) ??
|
|
620
|
+
parseXmlEqualsToolCall(body, tools) ??
|
|
621
|
+
parseGlm52ToolCall(body, tools);
|
|
594
622
|
if (!parsed) throw new Error("unsupported tool_call payload format");
|
|
595
623
|
calls.push(parsed);
|
|
596
624
|
}
|
package/src/train/job.ts
CHANGED
|
@@ -123,16 +123,6 @@ function parseConfig(raw: Record<string, unknown>): { modelDir: string; dataDir:
|
|
|
123
123
|
export const finetuneRunner: JobRunner = async (emit, config) => {
|
|
124
124
|
const { modelDir, dataDir, cfg } = parseConfig(config);
|
|
125
125
|
|
|
126
|
-
// Gemma/e4b needs these training env flags set BEFORE the model loads — same
|
|
127
|
-
// as the `mlx-bun train` CLI. The subprocess job path doesn't inherit them
|
|
128
|
-
// from the server, so without this a web-submitted ORPO/SFT run on Gemma uses
|
|
129
|
-
// the wrong kernels. ??= so an explicit env (or the CLI) still wins.
|
|
130
|
-
try {
|
|
131
|
-
const isGemma = (await Bun.file(`${modelDir}/config.json`).text()).toLowerCase().includes("gemma");
|
|
132
|
-
if (isGemma) {
|
|
133
|
-
}
|
|
134
|
-
} catch {}
|
|
135
|
-
|
|
136
126
|
emit({ type: "stage", stage: "load", progress: 0.01, message: `loading model ${modelDir}` });
|
|
137
127
|
|
|
138
128
|
const { loadModelConfig } = await import("../config");
|
package/src/train/lora-params.ts
CHANGED
|
@@ -165,15 +165,21 @@ export interface SaveAdapterConfig {
|
|
|
165
165
|
rsLora?: boolean;
|
|
166
166
|
}
|
|
167
167
|
|
|
168
|
+
export interface SaveAdapterOptions {
|
|
169
|
+
/** Override config-file writes for deterministic durability tests. */
|
|
170
|
+
writeFile?: (path: string, data: string) => Promise<unknown>;
|
|
171
|
+
}
|
|
172
|
+
|
|
168
173
|
/** Save the trained adapter into `dir` in the AdapterManager.mount format:
|
|
169
174
|
* adapters.safetensors (lora_a / lora_b tensor names), optiq_lora_config.json,
|
|
170
175
|
* and a PEFT-compatible adapter_config.json. */
|
|
171
|
-
export function saveAdapter(
|
|
176
|
+
export async function saveAdapter(
|
|
172
177
|
lora: TrainableLora,
|
|
173
178
|
dir: string,
|
|
174
179
|
cfg: SaveAdapterConfig,
|
|
175
180
|
appliedRanks: Record<string, number>,
|
|
176
|
-
|
|
181
|
+
options: SaveAdapterOptions = {},
|
|
182
|
+
): Promise<void> {
|
|
177
183
|
mkdirSync(dir, { recursive: true });
|
|
178
184
|
|
|
179
185
|
// Build the native string→array map and insert lora_a / lora_b per target.
|
|
@@ -211,8 +217,6 @@ export function saveAdapter(
|
|
|
211
217
|
applied_ranks: appliedRanks,
|
|
212
218
|
source_model: cfg.baseModel,
|
|
213
219
|
};
|
|
214
|
-
Bun.write(`${dir}/optiq_lora_config.json`, JSON.stringify(optiqCfg, null, 2) + "\n");
|
|
215
|
-
|
|
216
220
|
// PEFT-compatible adapter_config.json (loadable by mlx-lm / PEFT).
|
|
217
221
|
const peftCfg = {
|
|
218
222
|
fine_tune_type: "lora",
|
|
@@ -223,15 +227,22 @@ export function saveAdapter(
|
|
|
223
227
|
fan_in_fan_out: false,
|
|
224
228
|
inference_mode: false,
|
|
225
229
|
init_lora_weights: true,
|
|
226
|
-
|
|
230
|
+
// mlx-bun's `scale` is the numerator before rsLoRA's √rank division.
|
|
231
|
+
// Ordinary PEFT needs alpha/r = scale; rsLoRA needs alpha/√r = scale/√r.
|
|
232
|
+
lora_alpha: cfg.rsLora ? cfg.scale : Math.round(cfg.rank * cfg.scale),
|
|
227
233
|
lora_dropout: 0.0,
|
|
228
234
|
peft_type: "LORA",
|
|
229
235
|
r: cfg.rank,
|
|
230
236
|
target_modules: cfg.targetModules,
|
|
231
237
|
task_type: "CAUSAL_LM",
|
|
238
|
+
use_rslora: cfg.rsLora ?? false,
|
|
232
239
|
optiq: { rank_scaling: cfg.rankScaling, applied_ranks: appliedRanks },
|
|
233
240
|
};
|
|
234
|
-
|
|
241
|
+
const writeFile = options.writeFile ?? ((path: string, data: string) => Bun.write(path, data));
|
|
242
|
+
await Promise.all([
|
|
243
|
+
writeFile(`${dir}/optiq_lora_config.json`, JSON.stringify(optiqCfg, null, 2) + "\n"),
|
|
244
|
+
writeFile(`${dir}/adapter_config.json`, JSON.stringify(peftCfg, null, 2) + "\n"),
|
|
245
|
+
]);
|
|
235
246
|
}
|
|
236
247
|
|
|
237
248
|
/** Dispose all trainable leaves (frees A/B). */
|
package/src/train/trainer.ts
CHANGED
|
@@ -202,6 +202,51 @@ export const DEFAULT_TRAIN_CONFIG: TrainConfig = {
|
|
|
202
202
|
baseModel: "",
|
|
203
203
|
};
|
|
204
204
|
|
|
205
|
+
/** Build the per-leaf LoRA+ LR multipliers for flatParams' stable
|
|
206
|
+
* `[...A, ...B]` order. `undefined` keeps AdamW's uniform-LR fast path. */
|
|
207
|
+
export function loraPlusLrScale(
|
|
208
|
+
paramCount: number,
|
|
209
|
+
targetCount: number,
|
|
210
|
+
ratio: number,
|
|
211
|
+
): number[] | undefined {
|
|
212
|
+
if (!Number.isFinite(ratio) || ratio < 1)
|
|
213
|
+
throw new RangeError(`loraPlusRatio must be a finite number >= 1 (got ${ratio})`);
|
|
214
|
+
if (paramCount !== targetCount * 2)
|
|
215
|
+
throw new Error(
|
|
216
|
+
`LoRA+ expected ${targetCount * 2} A/B leaves for ${targetCount} targets, got ${paramCount}`,
|
|
217
|
+
);
|
|
218
|
+
if (ratio === 1) return undefined;
|
|
219
|
+
return Array.from({ length: paramCount }, (_, i) => (i < targetCount ? 1 : ratio));
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/** One optimizer policy for SFT, DPO, and ORPO. Keeping LoRA+ here prevents a
|
|
223
|
+
* method-specific loop from silently accepting the knob without applying it. */
|
|
224
|
+
function createLoraOptimizer(
|
|
225
|
+
params: MlxArray[],
|
|
226
|
+
lora: TrainableLora,
|
|
227
|
+
cfg: TrainConfig,
|
|
228
|
+
emit: Emit,
|
|
229
|
+
): AdamW {
|
|
230
|
+
const lrScale = loraPlusLrScale(params.length, lora.targets.length, cfg.loraPlusRatio);
|
|
231
|
+
if (lrScale) emit({
|
|
232
|
+
type: "stage",
|
|
233
|
+
stage: "setup",
|
|
234
|
+
progress: 0.04,
|
|
235
|
+
message: `LoRA+ : B leaves at ${cfg.loraPlusRatio}× LR`,
|
|
236
|
+
});
|
|
237
|
+
return new AdamW(
|
|
238
|
+
params,
|
|
239
|
+
{
|
|
240
|
+
lr: cfg.learningRate,
|
|
241
|
+
betas: cfg.betas,
|
|
242
|
+
eps: 1e-8,
|
|
243
|
+
weightDecay: cfg.weightDecay,
|
|
244
|
+
lrScale,
|
|
245
|
+
},
|
|
246
|
+
(i, p) => writeParam(lora, i, p),
|
|
247
|
+
);
|
|
248
|
+
}
|
|
249
|
+
|
|
205
250
|
/** Run a LoRA fine-tune. Emits per-step train metrics, periodic val metrics,
|
|
206
251
|
* and a final stage:done with the adapter path + applied ranks. Saves the
|
|
207
252
|
* adapter (last + best-on-val) and returns where it landed. */
|
|
@@ -286,6 +331,7 @@ export async function trainLora(
|
|
|
286
331
|
// weak proxy. Capture them when the loop emits them so metrics.json lets you
|
|
287
332
|
// pick the best-MARGIN checkpoint (overfit shows as train↑ / val-margin flat).
|
|
288
333
|
const valHistory: { step: number; loss: number; margin?: number; accuracy?: number; checkpoint: string | null }[] = [];
|
|
334
|
+
const checkpointSaves: Promise<void>[] = [];
|
|
289
335
|
const startedAt = Date.now();
|
|
290
336
|
|
|
291
337
|
// Live, append-only metrics stream (the source `mlx-bun train-watch` tails).
|
|
@@ -319,7 +365,11 @@ export async function trainLora(
|
|
|
319
365
|
if (cfg.saveCheckpoints) {
|
|
320
366
|
const tag = `step-${String(e.step).padStart(5, "0")}-val${e.loss.toFixed(4)}`;
|
|
321
367
|
checkpoint = `${cfg.adapterPath}/checkpoints/${tag}`;
|
|
322
|
-
saveAdapter(lora, checkpoint, saveCfg, appliedRanks);
|
|
368
|
+
const save = saveAdapter(lora, checkpoint, saveCfg, appliedRanks);
|
|
369
|
+
checkpointSaves.push(save);
|
|
370
|
+
// Keep a handler attached while training continues. Promise.all below
|
|
371
|
+
// still surfaces the original rejection before the run can succeed.
|
|
372
|
+
void save.catch(() => {});
|
|
323
373
|
emit({ type: "stage", stage: "checkpoint", progress: e.step / cfg.iters,
|
|
324
374
|
message: `checkpoint ${tag}` });
|
|
325
375
|
}
|
|
@@ -337,9 +387,11 @@ export async function trainLora(
|
|
|
337
387
|
? await orpoLoop(model, tok, tmpl, dataDir, cfg, lora, collect)
|
|
338
388
|
: await sftLoop(model, tok, tmpl, dataDir, cfg, lora, collect);
|
|
339
389
|
|
|
390
|
+
await Promise.all(checkpointSaves);
|
|
391
|
+
|
|
340
392
|
// Final save (last adapter).
|
|
341
393
|
detachTraining(model, lora);
|
|
342
|
-
saveAdapter(lora, cfg.adapterPath, saveCfg, appliedRanks);
|
|
394
|
+
await saveAdapter(lora, cfg.adapterPath, saveCfg, appliedRanks);
|
|
343
395
|
|
|
344
396
|
// Durable, structured run record alongside the adapter (only when we kept
|
|
345
397
|
// checkpoints). Pick the best-val one for convenience, but ship ALL of them
|
|
@@ -415,11 +467,7 @@ async function sftLoop(
|
|
|
415
467
|
: [];
|
|
416
468
|
|
|
417
469
|
const params = flatParams(lora);
|
|
418
|
-
const opt =
|
|
419
|
-
params,
|
|
420
|
-
{ lr: cfg.learningRate, betas: cfg.betas, eps: 1e-8, weightDecay: cfg.weightDecay },
|
|
421
|
-
(i, p) => writeParam(lora, i, p),
|
|
422
|
-
);
|
|
470
|
+
const opt = createLoraOptimizer(params, lora, cfg, emit);
|
|
423
471
|
|
|
424
472
|
// Segmented backward: stream the SFT backward segment-by-segment (only one
|
|
425
473
|
// segment's activations live at a time). Phase A is MiniCPM5 SFT B=1 only;
|
|
@@ -658,11 +706,7 @@ async function dpoLoop(
|
|
|
658
706
|
: [];
|
|
659
707
|
|
|
660
708
|
const params = flatParams(lora);
|
|
661
|
-
const opt =
|
|
662
|
-
params,
|
|
663
|
-
{ lr: cfg.learningRate, betas: cfg.betas, eps: 1e-8, weightDecay: cfg.weightDecay },
|
|
664
|
-
(i, p) => writeParam(lora, i, p),
|
|
665
|
-
);
|
|
709
|
+
const opt = createLoraOptimizer(params, lora, cfg, emit);
|
|
666
710
|
|
|
667
711
|
const schedule =
|
|
668
712
|
cfg.dpoLrSchedule === "cosine" || cfg.dpoWarmupIters > 0
|
|
@@ -789,18 +833,7 @@ async function orpoLoop(
|
|
|
789
833
|
: [];
|
|
790
834
|
|
|
791
835
|
const params = flatParams(lora);
|
|
792
|
-
|
|
793
|
-
const nT = lora.targets.length;
|
|
794
|
-
const lrScale = cfg.loraPlusRatio !== 1
|
|
795
|
-
? params.map((_, i) => (i < nT ? 1 : cfg.loraPlusRatio))
|
|
796
|
-
: undefined;
|
|
797
|
-
if (lrScale) emit({ type: "stage", stage: "setup", progress: 0.04,
|
|
798
|
-
message: `LoRA+ : B leaves at ${cfg.loraPlusRatio}× LR` });
|
|
799
|
-
const opt = new AdamW(
|
|
800
|
-
params,
|
|
801
|
-
{ lr: cfg.learningRate, betas: cfg.betas, eps: 1e-8, weightDecay: cfg.weightDecay, lrScale },
|
|
802
|
-
(i, p) => writeParam(lora, i, p),
|
|
803
|
-
);
|
|
836
|
+
const opt = createLoraOptimizer(params, lora, cfg, emit);
|
|
804
837
|
|
|
805
838
|
const schedule =
|
|
806
839
|
cfg.orpoLrSchedule === "cosine" || cfg.orpoWarmupIters > 0
|
|
@@ -1172,11 +1205,11 @@ function clipGradsByNorm(grads: MlxArray[], normVal: number, maxNorm: number): v
|
|
|
1172
1205
|
|
|
1173
1206
|
/** Supervised (response) tokens across ALL rows of the batch — the count
|
|
1174
1207
|
* tokens/sec is reported over (mlx-lm's n_tokens counts every row too). */
|
|
1175
|
-
function countResponseTokens(batch: SftBatch): number {
|
|
1208
|
+
export function countResponseTokens(batch: SftBatch): number {
|
|
1176
1209
|
let total = 0;
|
|
1177
1210
|
for (let r = 0; r < batch.ids.length; r++) {
|
|
1178
1211
|
const promptLen = batch.promptLens[r]!;
|
|
1179
|
-
const len = batch
|
|
1212
|
+
const len = rowLength(batch, r);
|
|
1180
1213
|
total += Math.max(0, len - Math.max(promptLen, 1));
|
|
1181
1214
|
}
|
|
1182
1215
|
return total;
|
package/src/web/app.html
CHANGED
|
@@ -2027,6 +2027,14 @@
|
|
|
2027
2027
|
<div class="kv"><b>mode</b><span id="st-kv-mode">—</span></div>
|
|
2028
2028
|
<div id="st-kv-layers"></div>
|
|
2029
2029
|
</div>
|
|
2030
|
+
<div class="card" id="st-glm-expert-card" style="display:none">
|
|
2031
|
+
<h3>GLM expert tiers · last turn</h3>
|
|
2032
|
+
<div class="kv"><b>main cache hit</b><span class="num" id="st-glm-main-hit">—</span></div>
|
|
2033
|
+
<div class="kv"><b>main SSD read</b><span class="num" id="st-glm-main-read">—</span></div>
|
|
2034
|
+
<div class="kv"><b>MTP cache hit</b><span class="num" id="st-glm-mtp-hit">—</span></div>
|
|
2035
|
+
<div class="kv"><b>MTP SSD read</b><span class="num" id="st-glm-mtp-read">—</span></div>
|
|
2036
|
+
<div class="cap" id="st-glm-residency">waiting for the first completed turn</div>
|
|
2037
|
+
</div>
|
|
2030
2038
|
<div class="card" id="st-dl-card" style="grid-column:1 / -1;display:none">
|
|
2031
2039
|
<h3>Downloads</h3>
|
|
2032
2040
|
<div id="st-dl-list"></div>
|
|
@@ -2057,9 +2065,12 @@
|
|
|
2057
2065
|
</div>
|
|
2058
2066
|
<div class="card">
|
|
2059
2067
|
<h3>At max context · <span class="num" id="st-fit-ctx">—</span> tokens</h3>
|
|
2060
|
-
<div class="kv"><b>
|
|
2068
|
+
<div class="kv" data-glm-fit style="display:none"><b>artifact on disk</b><span class="num" id="st-f-artifact">—</span></div>
|
|
2069
|
+
<div class="kv"><b id="st-f-weights-label">weights</b><span class="num" id="st-f-weights">—</span></div>
|
|
2070
|
+
<div class="kv" data-glm-fit style="display:none"><b>main expert slab</b><span class="num" id="st-f-main-slab">—</span></div>
|
|
2071
|
+
<div class="kv" data-glm-fit style="display:none"><b>MTP expert slab</b><span class="num" id="st-f-mtp-slab">—</span></div>
|
|
2061
2072
|
<div class="kv"><b>kv cache</b><span class="num" id="st-f-kv">—</span></div>
|
|
2062
|
-
<div class="kv"><b>transient</b><span class="num" id="st-f-transient">—</span></div>
|
|
2073
|
+
<div class="kv"><b id="st-f-transient-label">transient</b><span class="num" id="st-f-transient">—</span></div>
|
|
2063
2074
|
<div class="kv"><b>total / usable</b><span class="num" id="st-f-total">—</span></div>
|
|
2064
2075
|
<div class="meter"><i id="st-fit-bar"></i></div>
|
|
2065
2076
|
</div>
|
package/src/web/app.js
CHANGED
|
@@ -4791,6 +4791,24 @@ function createStatusController() {
|
|
|
4791
4791
|
$("st-kv-mode").innerHTML = "<code>" + esc(kv.mode || "—") + "</code>";
|
|
4792
4792
|
const att = kv.attention;
|
|
4793
4793
|
$("st-kv-layers").innerHTML = Object.entries(kv.layers || {}).map(([k, v]) => '<div class="kv"><b>' + esc(k) + ' layers</b><span class="num">' + esc(v) + "</span></div>").join("") + (att ? '<div class="kv"><b>attention</b><span class="num">' + esc(att.global) + " global · " + esc(att.sliding_window) + " sliding</span></div>" : "");
|
|
4794
|
+
const glm = stats.glm52;
|
|
4795
|
+
const expert = glm && glm.expert_runtime;
|
|
4796
|
+
const expertCard = $("st-glm-expert-card");
|
|
4797
|
+
expertCard.style.display = glm ? "" : "none";
|
|
4798
|
+
if (glm) {
|
|
4799
|
+
const rate = (d) => {
|
|
4800
|
+
const total = (d?.hits || 0) + (d?.misses || 0);
|
|
4801
|
+
return total ? (100 * (d?.hits || 0) / total).toFixed(1) + "%" : "—";
|
|
4802
|
+
};
|
|
4803
|
+
const turn = expert && expert.last_turn;
|
|
4804
|
+
$("st-glm-main-hit").textContent = rate(turn?.main?.demand);
|
|
4805
|
+
$("st-glm-main-read").textContent = turn?.main?.demand ? gb(turn.main.demand.readBytes) : "—";
|
|
4806
|
+
$("st-glm-mtp-hit").textContent = rate(turn?.mtp?.demand);
|
|
4807
|
+
$("st-glm-mtp-read").textContent = turn?.mtp?.demand ? gb(turn.mtp.demand.readBytes) : "—";
|
|
4808
|
+
const main = expert?.main_residency;
|
|
4809
|
+
const mtp = expert?.mtp_residency;
|
|
4810
|
+
$("st-glm-residency").textContent = main ? "resident slots main " + num(main.resident + main.pinned) + " / " + num(main.resident + main.pinned + main.working) + (mtp ? " · MTP " + num(mtp.resident + mtp.pinned) + " / " + num(mtp.resident + mtp.pinned + mtp.working) : "") : "expert runtime telemetry unavailable";
|
|
4811
|
+
}
|
|
4794
4812
|
await loadDownloads();
|
|
4795
4813
|
if (!fitLoaded) {
|
|
4796
4814
|
await loadFit();
|
|
@@ -4872,10 +4890,11 @@ function createStatusController() {
|
|
|
4872
4890
|
}
|
|
4873
4891
|
fitLoaded = true;
|
|
4874
4892
|
const r = f.report || {};
|
|
4893
|
+
const tps = (value) => value < 1 ? value.toFixed(2) : value.toFixed(1);
|
|
4875
4894
|
if (f.measured_decode_tps) {
|
|
4876
|
-
$("st-tps").textContent = f.measured_decode_tps
|
|
4895
|
+
$("st-tps").textContent = tps(f.measured_decode_tps);
|
|
4877
4896
|
$("st-tps").closest(".card").querySelector("h3").textContent = "Decode · measured";
|
|
4878
|
-
$("st-tps-cap").textContent = "mlx-bun benchmark on this machine · predicted " + (f.typical_decode_tps || 0)
|
|
4897
|
+
$("st-tps-cap").textContent = f.glm52 ? "quality-preserving warm · direct oracle " + tps(f.glm52.direct_oracle_warm_decode_tps || 0) + " · aspiration " + tps(f.glm52.aspirational_decode_tps || 0) : "mlx-bun benchmark on this machine · predicted " + tps(f.typical_decode_tps || 0) + " at " + num(f.typical_context_tokens) + " ctx";
|
|
4879
4898
|
} else if (f.typical_decode_tps) {
|
|
4880
4899
|
$("st-tps").textContent = f.typical_decode_tps.toFixed(1);
|
|
4881
4900
|
$("st-tps-cap").textContent = "at " + num(f.typical_context_tokens) + " context · " + (r.predicted_decode_tps || 0).toFixed(1) + " tok/s at the " + num(f.context_tokens) + " max (full-KV reads)";
|
|
@@ -4886,6 +4905,14 @@ function createStatusController() {
|
|
|
4886
4905
|
$("st-m-bw").textContent = (f.machine && f.machine.bandwidth_gbs || "—") + " GB/s";
|
|
4887
4906
|
$("st-m-fits").innerHTML = r.fits ? '<span style="color:var(--green);font-weight:700">FITS</span>' : '<span style="color:var(--red);font-weight:700">DOES NOT FIT</span>';
|
|
4888
4907
|
$("st-fit-ctx").textContent = num(f.context_tokens);
|
|
4908
|
+
document.querySelectorAll("[data-glm-fit]").forEach((row) => {
|
|
4909
|
+
row.style.display = f.glm52 ? "" : "none";
|
|
4910
|
+
});
|
|
4911
|
+
$("st-f-weights-label").textContent = f.glm52 ? "resident weights" : "weights";
|
|
4912
|
+
$("st-f-transient-label").textContent = f.glm52 ? "other reserves" : "transient";
|
|
4913
|
+
$("st-f-artifact").textContent = f.glm52?.artifact_disk_bytes ? gb(f.glm52.artifact_disk_bytes) : "registry unavailable";
|
|
4914
|
+
$("st-f-main-slab").textContent = gb(f.glm52?.main_expert_slab_bytes);
|
|
4915
|
+
$("st-f-mtp-slab").textContent = gb(f.glm52?.mtp_expert_slab_bytes);
|
|
4889
4916
|
$("st-f-weights").textContent = gb(r.weights_bytes);
|
|
4890
4917
|
$("st-f-kv").textContent = gb(r.kv_bytes);
|
|
4891
4918
|
$("st-f-transient").textContent = gb(r.transient_bytes);
|
|
@@ -4910,7 +4937,7 @@ function createStatusController() {
|
|
|
4910
4937
|
$("st-sku-body").innerHTML = rows.map((row, i) => {
|
|
4911
4938
|
const you = i === youIdx;
|
|
4912
4939
|
const dimCls = row.fits ? "num" : "num fit-no";
|
|
4913
|
-
return "<tr" + (you ? ' class="you"' : "") + "><td>" + esc(row.sku) + (you ? " · <b>this machine</b>" : "") + '</td><td class="num">' + row.ram_gb + " GB</td>" + '<td class="' + (row.fits ? "fit-yes" : "fit-no") + '">' + (row.fits ? "fits" : "no") + "</td>" + '<td class="' + dimCls + '">' + (row.max_context > 0 ? num(row.max_context) : "0 — weights alone don’t fit") + "</td>" + '<td class="' + dimCls + '">' + (row.decode_tps > 0 ? "~" + row.decode_tps
|
|
4940
|
+
return "<tr" + (you ? ' class="you"' : "") + "><td>" + esc(row.sku) + (you ? " · <b>this machine</b>" : "") + '</td><td class="num">' + row.ram_gb + " GB</td>" + '<td class="' + (row.fits ? "fit-yes" : "fit-no") + '">' + (row.fits ? "fits" : "no") + "</td>" + '<td class="' + dimCls + '">' + (row.max_context > 0 ? num(row.max_context) : "0 — weights alone don’t fit") + "</td>" + '<td class="' + dimCls + '">' + (row.decode_tps > 0 ? "~" + tps(row.decode_tps) + " tok/s" : "—") + "</td></tr>";
|
|
4914
4941
|
}).join("");
|
|
4915
4942
|
}
|
|
4916
4943
|
return {
|
package/src/web/src/status.ts
CHANGED
|
@@ -90,6 +90,32 @@ export function createStatusController() {
|
|
|
90
90
|
.map(([k, v]) => '<div class="kv"><b>' + esc(k) + ' layers</b><span class="num">' + esc(v) + "</span></div>").join("") +
|
|
91
91
|
(att ? '<div class="kv"><b>attention</b><span class="num">' + esc(att.global) + " global · " + esc(att.sliding_window) + " sliding</span></div>" : "");
|
|
92
92
|
|
|
93
|
+
const glm = stats.glm52;
|
|
94
|
+
const expert = glm && glm.expert_runtime;
|
|
95
|
+
const expertCard = $("st-glm-expert-card");
|
|
96
|
+
expertCard.style.display = glm ? "" : "none";
|
|
97
|
+
if (glm) {
|
|
98
|
+
const rate = (d: { hits?: number; misses?: number } | null | undefined): string => {
|
|
99
|
+
const total = (d?.hits || 0) + (d?.misses || 0);
|
|
100
|
+
return total ? (100 * (d?.hits || 0) / total).toFixed(1) + "%" : "—";
|
|
101
|
+
};
|
|
102
|
+
const turn = expert && expert.last_turn;
|
|
103
|
+
$("st-glm-main-hit").textContent = rate(turn?.main?.demand);
|
|
104
|
+
$("st-glm-main-read").textContent = turn?.main?.demand
|
|
105
|
+
? gb(turn.main.demand.readBytes) : "—";
|
|
106
|
+
$("st-glm-mtp-hit").textContent = rate(turn?.mtp?.demand);
|
|
107
|
+
$("st-glm-mtp-read").textContent = turn?.mtp?.demand
|
|
108
|
+
? gb(turn.mtp.demand.readBytes) : "—";
|
|
109
|
+
const main = expert?.main_residency;
|
|
110
|
+
const mtp = expert?.mtp_residency;
|
|
111
|
+
$("st-glm-residency").textContent = main
|
|
112
|
+
? "resident slots main " + num(main.resident + main.pinned) +
|
|
113
|
+
" / " + num(main.resident + main.pinned + main.working) +
|
|
114
|
+
(mtp ? " · MTP " + num(mtp.resident + mtp.pinned) +
|
|
115
|
+
" / " + num(mtp.resident + mtp.pinned + mtp.working) : "")
|
|
116
|
+
: "expert runtime telemetry unavailable";
|
|
117
|
+
}
|
|
118
|
+
|
|
93
119
|
await loadDownloads();
|
|
94
120
|
if (!fitLoaded) { await loadFit(); await loadLibrary(); }
|
|
95
121
|
if (stats.server && stats.server.started_at) {
|
|
@@ -179,15 +205,28 @@ export function createStatusController() {
|
|
|
179
205
|
machine?: { ram_bytes?: number; bandwidth_gbs?: number; chip?: string };
|
|
180
206
|
sku_matrix_ctx?: number;
|
|
181
207
|
sku_matrix?: SkuRow[];
|
|
208
|
+
glm52?: {
|
|
209
|
+
artifact_disk_bytes?: number | null;
|
|
210
|
+
main_expert_slab_bytes?: number;
|
|
211
|
+
mtp_expert_slab_bytes?: number;
|
|
212
|
+
direct_oracle_warm_decode_tps?: number;
|
|
213
|
+
aspirational_decode_tps?: number;
|
|
214
|
+
};
|
|
182
215
|
}
|
|
183
216
|
let f: FitResponse;
|
|
184
217
|
try { f = await fetch("/fit").then((r) => r.json()); } catch { return; }
|
|
185
218
|
fitLoaded = true;
|
|
186
219
|
const r = f.report || {};
|
|
220
|
+
const tps = (value: number): string => value < 1 ? value.toFixed(2) : value.toFixed(1);
|
|
187
221
|
if (f.measured_decode_tps) {
|
|
188
|
-
$("st-tps").textContent = f.measured_decode_tps
|
|
222
|
+
$("st-tps").textContent = tps(f.measured_decode_tps);
|
|
189
223
|
$("st-tps").closest(".card")!.querySelector("h3")!.textContent = "Decode · measured";
|
|
190
|
-
$("st-tps-cap").textContent =
|
|
224
|
+
$("st-tps-cap").textContent = f.glm52
|
|
225
|
+
? "quality-preserving warm · direct oracle " +
|
|
226
|
+
tps(f.glm52.direct_oracle_warm_decode_tps || 0) +
|
|
227
|
+
" · aspiration " + tps(f.glm52.aspirational_decode_tps || 0)
|
|
228
|
+
: "mlx-bun benchmark on this machine · predicted " +
|
|
229
|
+
tps(f.typical_decode_tps || 0) + " at " + num(f.typical_context_tokens) + " ctx";
|
|
191
230
|
} else if (f.typical_decode_tps) {
|
|
192
231
|
$("st-tps").textContent = f.typical_decode_tps.toFixed(1);
|
|
193
232
|
$("st-tps-cap").textContent = "at " + num(f.typical_context_tokens) + " context · " + (r.predicted_decode_tps || 0).toFixed(1) + " tok/s at the " + num(f.context_tokens) + " max (full-KV reads)";
|
|
@@ -198,6 +237,15 @@ export function createStatusController() {
|
|
|
198
237
|
$("st-m-bw").textContent = (f.machine && f.machine.bandwidth_gbs || "—") + " GB/s";
|
|
199
238
|
$("st-m-fits").innerHTML = r.fits ? '<span style="color:var(--green);font-weight:700">FITS</span>' : '<span style="color:var(--red);font-weight:700">DOES NOT FIT</span>';
|
|
200
239
|
$("st-fit-ctx").textContent = num(f.context_tokens);
|
|
240
|
+
document.querySelectorAll<HTMLElement>("[data-glm-fit]").forEach((row) => {
|
|
241
|
+
row.style.display = f.glm52 ? "" : "none";
|
|
242
|
+
});
|
|
243
|
+
$("st-f-weights-label").textContent = f.glm52 ? "resident weights" : "weights";
|
|
244
|
+
$("st-f-transient-label").textContent = f.glm52 ? "other reserves" : "transient";
|
|
245
|
+
$("st-f-artifact").textContent = f.glm52?.artifact_disk_bytes
|
|
246
|
+
? gb(f.glm52.artifact_disk_bytes) : "registry unavailable";
|
|
247
|
+
$("st-f-main-slab").textContent = gb(f.glm52?.main_expert_slab_bytes);
|
|
248
|
+
$("st-f-mtp-slab").textContent = gb(f.glm52?.mtp_expert_slab_bytes);
|
|
201
249
|
$("st-f-weights").textContent = gb(r.weights_bytes);
|
|
202
250
|
$("st-f-kv").textContent = gb(r.kv_bytes);
|
|
203
251
|
$("st-f-transient").textContent = gb(r.transient_bytes);
|
|
@@ -221,7 +269,7 @@ export function createStatusController() {
|
|
|
221
269
|
'</td><td class="num">' + row.ram_gb + " GB</td>" +
|
|
222
270
|
'<td class="' + (row.fits ? "fit-yes" : "fit-no") + '">' + (row.fits ? "fits" : "no") + "</td>" +
|
|
223
271
|
'<td class="' + dimCls + '">' + (row.max_context > 0 ? num(row.max_context) : "0 — weights alone don’t fit") + "</td>" +
|
|
224
|
-
'<td class="' + dimCls + '">' + (row.decode_tps > 0 ? "~" + row.decode_tps
|
|
272
|
+
'<td class="' + dimCls + '">' + (row.decode_tps > 0 ? "~" + tps(row.decode_tps) + " tok/s" : "—") + "</td></tr>";
|
|
225
273
|
}).join("");
|
|
226
274
|
}
|
|
227
275
|
|
package/src/web-tools.ts
CHANGED
|
@@ -21,6 +21,11 @@
|
|
|
21
21
|
|
|
22
22
|
import { defineTool, type ToolDefinition } from "@earendil-works/pi-coding-agent";
|
|
23
23
|
import { Type } from "typebox";
|
|
24
|
+
import {
|
|
25
|
+
fetchRestrictedHttpBytes,
|
|
26
|
+
type MediaFetchPolicy,
|
|
27
|
+
type RestrictedFetchDependencies,
|
|
28
|
+
} from "./media-fetch";
|
|
24
29
|
|
|
25
30
|
/** Tool names, exported so pi-web can add them to its tool allowlist. */
|
|
26
31
|
export const WEB_TOOL_NAMES = ["web_search", "web_fetch", "weather"] as const;
|
|
@@ -31,6 +36,7 @@ const USER_AGENT =
|
|
|
31
36
|
"(KHTML, like Gecko) Chrome/124.0 Safari/537.36";
|
|
32
37
|
|
|
33
38
|
const FETCH_TIMEOUT_MS = 20_000;
|
|
39
|
+
const MAX_FETCH_BYTES = 1024 * 1024;
|
|
34
40
|
/** Cap on text returned to the model from a fetched page. */
|
|
35
41
|
const MAX_FETCH_CHARS = 12_000;
|
|
36
42
|
const DEFAULT_SEARCH_RESULTS = 5;
|
|
@@ -44,7 +50,7 @@ function withTimeout(signal: AbortSignal | undefined, ms: number): AbortSignal {
|
|
|
44
50
|
return signal ? AbortSignal.any([signal, timeout]) : timeout;
|
|
45
51
|
}
|
|
46
52
|
|
|
47
|
-
interface FetchResult {
|
|
53
|
+
export interface FetchResult {
|
|
48
54
|
ok: boolean;
|
|
49
55
|
status: number;
|
|
50
56
|
finalUrl: string;
|
|
@@ -52,6 +58,20 @@ interface FetchResult {
|
|
|
52
58
|
text: string;
|
|
53
59
|
}
|
|
54
60
|
|
|
61
|
+
export interface WebFetchOptions {
|
|
62
|
+
policy?: MediaFetchPolicy;
|
|
63
|
+
dependencies?: Partial<RestrictedFetchDependencies>;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const WEB_FETCH_POLICY: MediaFetchPolicy = {
|
|
67
|
+
allowPrivate: false,
|
|
68
|
+
privateDestinationHint:
|
|
69
|
+
"private/loopback destinations are never available to the model-driven web_fetch tool",
|
|
70
|
+
timeoutMs: FETCH_TIMEOUT_MS,
|
|
71
|
+
maxBytes: MAX_FETCH_BYTES,
|
|
72
|
+
maxRedirects: 5,
|
|
73
|
+
};
|
|
74
|
+
|
|
55
75
|
/** GET/POST a URL and read the body as text, with timeout + abort wiring. */
|
|
56
76
|
async function httpText(
|
|
57
77
|
url: string,
|
|
@@ -69,6 +89,38 @@ async function httpText(
|
|
|
69
89
|
return { ok: res.ok, status: res.status, finalUrl: res.url || url, contentType, text };
|
|
70
90
|
}
|
|
71
91
|
|
|
92
|
+
/**
|
|
93
|
+
* Fetch a model-selected URL through the shared restricted-destination
|
|
94
|
+
* transport. Exported with dependency injection for deterministic,
|
|
95
|
+
* model-free security tests.
|
|
96
|
+
*/
|
|
97
|
+
export async function fetchWebText(
|
|
98
|
+
url: string,
|
|
99
|
+
signal?: AbortSignal,
|
|
100
|
+
options: WebFetchOptions = {},
|
|
101
|
+
): Promise<FetchResult> {
|
|
102
|
+
const result = await fetchRestrictedHttpBytes(
|
|
103
|
+
url,
|
|
104
|
+
"web",
|
|
105
|
+
options.policy ?? WEB_FETCH_POLICY,
|
|
106
|
+
{
|
|
107
|
+
signal,
|
|
108
|
+
dependencies: options.dependencies,
|
|
109
|
+
headers: {
|
|
110
|
+
"user-agent": USER_AGENT,
|
|
111
|
+
accept: "text/html,application/json,text/plain,*/*",
|
|
112
|
+
},
|
|
113
|
+
},
|
|
114
|
+
);
|
|
115
|
+
return {
|
|
116
|
+
ok: result.ok,
|
|
117
|
+
status: result.status,
|
|
118
|
+
finalUrl: result.finalUrl,
|
|
119
|
+
contentType: result.contentType,
|
|
120
|
+
text: new TextDecoder().decode(result.bytes),
|
|
121
|
+
};
|
|
122
|
+
}
|
|
123
|
+
|
|
72
124
|
/** Normalize anything thrown by fetch (timeout, DNS, abort) into a message. */
|
|
73
125
|
function describeFetchError(err: unknown): string {
|
|
74
126
|
if (err instanceof Error) {
|
|
@@ -362,7 +414,7 @@ const webFetchTool = defineTool({
|
|
|
362
414
|
let url = params.url.trim();
|
|
363
415
|
if (!/^https?:\/\//i.test(url)) url = "https://" + url;
|
|
364
416
|
try {
|
|
365
|
-
const res = await
|
|
417
|
+
const res = await fetchWebText(url, signal);
|
|
366
418
|
const header = `Fetched ${res.finalUrl} (HTTP ${res.status})`;
|
|
367
419
|
if (!res.ok) return textResult(`${header}\n\n${truncate(htmlToText(res.text), 2_000)}`);
|
|
368
420
|
const isHtml = /text\/html|application\/xhtml/i.test(res.contentType);
|