dsh-livebench-panel 0.2.2 → 0.2.4

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.
Files changed (2) hide show
  1. package/lib/index.js +128 -37
  2. package/package.json +1 -1
package/lib/index.js CHANGED
@@ -25,7 +25,7 @@
25
25
  * @module dsh-livebench-panel
26
26
  */
27
27
  import { spawn } from "node:child_process";
28
- import { readdirSync, readFileSync, existsSync, statSync, writeFileSync } from "node:fs";
28
+ import { readdirSync, readFileSync, existsSync, statSync, writeFileSync, mkdirSync } from "node:fs";
29
29
  import { createRequire } from "node:module";
30
30
  import { delimiter, dirname, join } from "node:path";
31
31
  import { homedir } from "node:os";
@@ -365,7 +365,9 @@ function displayModelName(providerId, modelId) {
365
365
  */
366
366
  function writeGeneratedModelConfig(layout, YAML, { displayName, modelId, reasoningEffort, protocol }) {
367
367
  const configDir = join(layout.livebenchDir, "model", "model_configs");
368
- const target = join(configDir, "dsh_panel_generated.yaml");
368
+ // 每个模型一个独立配置文件:并发启动多模型时共享单文件会互相覆盖,
369
+ // 导致先启动的进程读到别人的配置 → anthropic/responses 路由失效 → 全 $ERROR$。
370
+ const target = join(configDir, "dsh_panel_generated__" + displayName.replace(/[^A-Za-z0-9._@-]/g, "_") + ".yaml");
369
371
  void YAML;
370
372
  const lines = [
371
373
  "# Generated by dsh-livebench-panel — regenerated on every evaluation start.",
@@ -427,43 +429,103 @@ function readJsonl(path) {
427
429
  }
428
430
  }
429
431
 
432
+ /**
433
+ * 异步流式读取一个 JSONL 文件,对每行回调。
434
+ * 不整读入内存、不阻塞事件循环 —— coding 类 answer 单行可达数 MB,
435
+ * 同步整读会卡死 node 事件循环(表现为 DSH 断连假死)。
436
+ */
437
+ async function streamJsonl(path, onRow) {
438
+ const fsPromises = await import("node:fs/promises");
439
+ let handle;
440
+ try {
441
+ handle = await fsPromises.open(path, "r");
442
+ } catch {
443
+ return;
444
+ }
445
+ try {
446
+ const decoder = new TextDecoder();
447
+ let buffer = "";
448
+ const chunkSize = 256 * 1024;
449
+ const buf = Buffer.alloc(chunkSize);
450
+ while (true) {
451
+ // 每读一块就让出事件循环,避免大文件连续占用
452
+ const { bytesRead } = await handle.read(buf, 0, chunkSize, null);
453
+ if (bytesRead === 0) break;
454
+ buffer += decoder.decode(buf.subarray(0, bytesRead), { stream: true });
455
+ let idx;
456
+ while ((idx = buffer.indexOf("\n")) >= 0) {
457
+ const line = buffer.slice(0, idx);
458
+ buffer = buffer.slice(idx + 1);
459
+ if (line.trim().length > 0) onRow(line);
460
+ }
461
+ }
462
+ buffer += decoder.decode();
463
+ if (buffer.trim().length > 0) onRow(buffer);
464
+ } catch {
465
+ /* 读取中断:尽力返回已读内容 */
466
+ } finally {
467
+ try { await handle.close(); } catch { /* ignore */ }
468
+ }
469
+ }
470
+
471
+ /**
472
+ * 从一行 answer JSON 提取 (model_id, 是否 $ERROR$)。不整对象解析时仍需 parse,
473
+ * 但只保留必要字段并立即释放 —— 避免大对象堆积。
474
+ */
475
+ function parseAnswerLineLight(line) {
476
+ try {
477
+ const row = JSON.parse(line);
478
+ const turns = row.choices?.[0]?.turns;
479
+ const text = Array.isArray(turns) ? String(turns[0] ?? "") : "";
480
+ return { model: typeof row.model_id === "string" ? row.model_id : null, is_error: text.startsWith("$ERROR$"), raw: row };
481
+ } catch {
482
+ return null;
483
+ }
484
+ }
485
+
430
486
  /**
431
487
  * Compute mean scores per (model, category, task) from ground-truth judgment
432
488
  * files, plus the judged/total question counts per task.
489
+ * 异步流式版:不再同步整读 answer 文件(coding 类单行数 MB,同步读会卡死
490
+ * node 事件循环导致 DSH 断连假死)。
433
491
  */
434
- function computeResults(dataDir) {
492
+ async function computeResults(dataDir) {
435
493
  const judged = new Map(); // `${model}\u0000${task}` -> {model, category, task, sum, n, time}
436
494
  const totals = new Map(); // task -> question count
437
495
  const answers = new Map(); // `${model}\u0000${task}` -> {answered, errors}
496
+ const catByTask = new Map(); // task -> category(rows 阶段查分类用)
438
497
  if (!existsSync(dataDir)) return { rows: [], taskTotals: {} };
439
498
  for (const category of readDirSafe(dataDir)) {
440
499
  const catDir = join(dataDir, category);
441
500
  for (const task of readDirSafe(catDir)) {
442
501
  const taskDir = join(catDir, task);
443
- const questions = readJsonl(join(taskDir, "question.jsonl"));
444
- totals.set(task, questions.length);
445
- // answer side: how many answers each model produced, and how many of
446
- // them were $ERROR$ (API call failures) — distinguishes "didn't run"
447
- // from "ran and failed" from "ran and scored".
502
+ catByTask.set(task, category);
503
+ let questionCount = 0;
504
+ await streamJsonl(join(taskDir, "question.jsonl"), () => { questionCount += 1; });
505
+ totals.set(task, questionCount);
506
+ // answer 侧:各模型产出了多少答案、其中多少 $ERROR$(API 失败)
448
507
  const answerDir = join(taskDir, "model_answer");
449
508
  for (const file of readDirSafe(answerDir)) {
450
509
  if (!file.endsWith(".jsonl")) continue;
451
- for (const line of readJsonl(join(answerDir, file))) {
452
- const model = typeof line.model_id === "string" ? line.model_id : file.slice(0, -".jsonl".length);
453
- const turns = line.choices?.[0]?.turns;
454
- const text = Array.isArray(turns) ? String(turns[0] ?? "") : "";
510
+ const fallbackModel = file.slice(0, -".jsonl".length);
511
+ await streamJsonl(join(answerDir, file), (line) => {
512
+ const parsed = parseAnswerLineLight(line);
513
+ if (!parsed) return;
514
+ const model = parsed.model ?? fallbackModel;
455
515
  const key = `${model}\u0000${task}`;
456
516
  const stat = answers.get(key) ?? { answered: 0, errors: 0 };
457
517
  stat.answered += 1;
458
- if (text.startsWith("$ERROR$")) stat.errors += 1;
518
+ if (parsed.is_error) stat.errors += 1;
459
519
  answers.set(key, stat);
460
- }
520
+ });
461
521
  }
462
- const judgments = readJsonl(join(taskDir, "model_judgment", "ground_truth_judgment.jsonl"));
463
- for (const row of judgments) {
522
+ // judgment 侧:判分与时间戳
523
+ await streamJsonl(join(taskDir, "model_judgment", "ground_truth_judgment.jsonl"), (line) => {
524
+ let row;
525
+ try { row = JSON.parse(line); } catch { return; }
464
526
  const model = typeof row.model === "string" ? row.model : null;
465
527
  const score = typeof row.score === "number" ? row.score : Number(row.score);
466
- if (model === null || !Number.isFinite(score) || score < 0) continue;
528
+ if (model === null || !Number.isFinite(score) || score < 0) return;
467
529
  const key = `${model}\u0000${task}`;
468
530
  const entry = judged.get(key) ?? { model, category, task, sum: 0, n: 0, time: 0 };
469
531
  entry.sum += score;
@@ -471,7 +533,9 @@ function computeResults(dataDir) {
471
533
  const tstamp = Number(row.tstamp);
472
534
  if (Number.isFinite(tstamp) && tstamp > entry.time) entry.time = tstamp;
473
535
  judged.set(key, entry);
474
- }
536
+ });
537
+ // 让出事件循环:任务之间穿插执行,保证 HTTP/websocket 实时响应
538
+ await new Promise((resolve) => setImmediate(resolve));
475
539
  }
476
540
  }
477
541
  const keys = new Set([...judged.keys(), ...answers.keys()]);
@@ -479,15 +543,10 @@ function computeResults(dataDir) {
479
543
  const entry = judged.get(key);
480
544
  const stat = answers.get(key) ?? { answered: 0, errors: 0 };
481
545
  const [model, task] = key.split("\u0000");
482
- let category = entry?.category;
483
- if (!category) {
484
- for (const cat of readDirSafe(dataDir)) {
485
- if (existsSync(join(dataDir, cat, task))) { category = cat; break; }
486
- }
487
- }
546
+ const category = entry?.category ?? catByTask.get(task) ?? "";
488
547
  return {
489
548
  model,
490
- category: category ?? "",
549
+ category,
491
550
  task,
492
551
  score: entry && entry.n > 0 ? (entry.sum / entry.n) * 100 : null,
493
552
  judged: entry?.n ?? 0,
@@ -501,6 +560,31 @@ function computeResults(dataDir) {
501
560
  return { rows, taskTotals };
502
561
  }
503
562
 
563
+ /**
564
+ * 异步统计某模型在指定 bench 范围内的 $ERROR$ 答案数(用于自动补跑判断)。
565
+ * 只扫 body.benchNames 涉及的任务目录,流式读取不阻塞事件循环。
566
+ */
567
+ async function countErrorAnswersAsync(dataDir, displayName, body) {
568
+ if (!/^[A-Za-z0-9._@-]{1,160}$/.test(displayName)) return 0;
569
+ const benches = Array.isArray(body?.benchNames) ? body.benchNames : [];
570
+ let errors = 0;
571
+ for (const bn of benches) {
572
+ const parts = bn.split("/").filter((s) => s.length > 0); // live_bench/<cat>[/<task>]
573
+ if (parts.length < 2) continue;
574
+ const category = parts[1];
575
+ const tasks = parts.length >= 3 ? [parts[2]] : readDirSafe(join(dataDir, category));
576
+ for (const task of tasks) {
577
+ if (!/^[A-Za-z0-9_]{1,80}$/.test(task)) continue;
578
+ const file = join(dataDir, category, task, "model_answer", displayName + ".jsonl");
579
+ await streamJsonl(file, (line) => {
580
+ const parsed = parseAnswerLineLight(line);
581
+ if (parsed && parsed.is_error) errors += 1;
582
+ });
583
+ }
584
+ }
585
+ return errors;
586
+ }
587
+
504
588
  /**
505
589
  * Remove every JSONL line of one model from a file (matched on the given
506
590
  * field), rewriting the file only when something changed.
@@ -740,18 +824,25 @@ function apply(ctx) {
740
824
  record.exitCode = code === null ? -1 : code;
741
825
  record.log.push(`[exit ${record.exitCode}]`);
742
826
  // 自动补跑:正常结束但存在 $ERROR$ 答案时,用 --resume --retry-failures
743
- // 只重跑失败题(最多 2 轮)。网络瞬断/超时导致的失败会被自动修复。
827
+ // 只重跑失败题(最多 2 轮)。统计改为异步流式(同步版曾阻塞事件循环,
828
+ // 且函数缺失会在 close 回调抛未捕获异常 → DSH 假死断连)。
744
829
  if (record.exitCode === 0 && record.autoRetries < 2 && record.body) {
745
- const errors = countErrorAnswers(layout.dataDir, record.displayName);
746
- if (errors > 0) {
747
- record.log.push(`[自动补跑] 检测到 ${errors} 条失败答案,自动重试(第 ${record.autoRetries + 1}/2 轮)`);
748
- const retryBody = { ...record.body, resume: true, retryFailures: true };
749
- setTimeout(() => {
750
- startRun(retryBody, record.autoRetries + 1).catch((e) => {
751
- appendLog(record, `[自动补跑启动失败] ${e.message}`);
752
- });
753
- }, 3000);
754
- }
830
+ (async () => {
831
+ try {
832
+ const errors = await countErrorAnswersAsync(layout.dataDir, record.displayName, record.body);
833
+ if (errors > 0) {
834
+ record.log.push(`[自动补跑] 检测到 ${errors} 条失败答案,自动重试(第 ${record.autoRetries + 1}/2 轮)`);
835
+ const retryBody = { ...record.body, resume: true, retryFailures: true };
836
+ setTimeout(() => {
837
+ startRun(retryBody, record.autoRetries + 1).catch((e) => {
838
+ appendLog(record, `[自动补跑启动失败] ${e.message}`);
839
+ });
840
+ }, 3000);
841
+ }
842
+ } catch (e) {
843
+ record.log.push(`[自动补跑检查失败] ${e.message}`);
844
+ }
845
+ })();
755
846
  }
756
847
  });
757
848
 
@@ -920,7 +1011,7 @@ function apply(ctx) {
920
1011
  return;
921
1012
  }
922
1013
  const layout = livebenchLayout();
923
- const { rows, taskTotals } = computeResults(layout.dataDir);
1014
+ const { rows, taskTotals } = await computeResults(layout.dataDir);
924
1015
  sendJson(res, 200, { ok: true, rows, taskTotals });
925
1016
  },
926
1017
  }), `${name}: results route`);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-livebench-panel",
3
- "version": "0.2.2",
3
+ "version": "0.2.4",
4
4
  "description": "DSH web plugin: a LiveBench tab in the Trajectory view (right of 对话/轨迹). Run LiveBench evaluations against every model configured in the DeepSeek Harness — pick provider/model, category, task, release and question range from dropdowns, watch progress, and read scores in place.",
5
5
  "license": "MIT",
6
6
  "type": "module",