modelfunction-cli 0.4.4 → 0.5.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/mf.js CHANGED
@@ -1,17 +1,18 @@
1
1
  #!/usr/bin/env node
2
2
  import { realpathSync } from "node:fs";
3
- import { readFile } from "node:fs/promises";
3
+ import { mkdir, readFile } from "node:fs/promises";
4
4
  import { basename, dirname, extname, relative, resolve } from "node:path";
5
5
  import { fileURLToPath } from "node:url";
6
6
  import { loadSuite, toModelTools } from "./lib/suite.js";
7
- import { scoreModel, pickRepresentative, pct } from "./lib/score.js";
7
+ import { scoreModel, pickRepresentative, pct, costCnyOf } from "./lib/score.js";
8
8
  import { formatTable, formatCaseLines, formatMarkdown } from "./lib/report.js";
9
9
  import { scanProject, buildSuiteDraft, formatScanSummary, addTools, redactReport } from "./lib/scan.js";
10
- import { probeStdioServer } from "./lib/mcp-probe.js";
10
+ import { probeStdioServer, probeHttpServer } from "./lib/mcp-probe.js";
11
11
  import { loadAdapterTools } from "./lib/adapter-import.js";
12
12
  import { addCases, computeCoverage, formatCoverage } from "./lib/cases.js";
13
13
  import { lintSuite, formatLint } from "./lib/lint.js";
14
14
  import { check, formatCheck } from "./lib/check.js";
15
+ import { auditSuite, formatAudit } from "./lib/audit.js";
15
16
  import { suiteFingerprint, hashText, hashOf } from "./lib/fingerprint.js";
16
17
  import { runExecutionChecks } from "./lib/eval-exec.js";
17
18
  import { checkSandbox, formatSandboxReport } from "./lib/sandbox-check.js";
@@ -30,7 +31,27 @@ import {
30
31
  formatProfileMarkdown
31
32
  } from "./lib/profile.js";
32
33
  import { runExec } from "./lib/exec.js";
34
+ import { createBudget, CostLimitError } from "./lib/budget.js";
33
35
  import { writeFileAtomic } from "./lib/fsutil.js";
36
+ import {
37
+ applyModelCredentials,
38
+ credentialsPath,
39
+ listCredentials,
40
+ redactText,
41
+ registerSecrets,
42
+ removeCredential,
43
+ setCredential
44
+ } from "./lib/credentials.js";
45
+ import {
46
+ buildFeedbackBundle,
47
+ defaultFeedbackPath,
48
+ formatStats,
49
+ noteUsage,
50
+ readUsageEvents,
51
+ recordRunSync,
52
+ summarizeUsage,
53
+ usageDir
54
+ } from "./lib/usage.js";
34
55
  import { createMockAdapter } from "./lib/adapters/mock.js";
35
56
  import { createOpenAiAdapter } from "./lib/adapters/openai.js";
36
57
 
@@ -52,13 +73,20 @@ Usage:
52
73
  mf scan [options]
53
74
  mf case add --suite <file> (--input <file> | stdin) [--source agent] [--reviewed] [--dry-run]
54
75
  mf coverage [--suite <file>] [--json]
76
+ mf audit [--suite <file>] [--out <file>] [--json] # 静态审计:lint + 分组边界 + 用例覆盖 + 挂载
55
77
  mf lint [--suite <file>] [--json] # 静态检查 tools 与 skills,不调用模型
56
78
  mf check --manifest <file> [--installed <file>] [--json] # 安装门禁:新能力 × 已装能力
57
79
  mf compare <before.json> <after.json> [--json]
58
- mf ablate --tool <name> [--suite <file>] [--profile <group>] [--models <ids>] [--json]
80
+ mf ablate --tool <name> [--suite <file>] [--profile <group>] [--models <ids>] [--max-cost <cny>] [--progress] [--json]
59
81
  mf cluster [--suite <file>] [--threshold <0.5>] [--max-size <10>] [--out <file>] [--json] # 建议挂载分组 + 同族候选
60
82
  mf exec --entry <file> (--input <json> | --input-json <json>) [--runtime node|python] [--python <cmd>] [--export run] [--allow-read <paths>] [--allow-write <paths>] [--allow-net <hosts>] [--timeout <ms>] [--warmup <n>] [--json]
61
83
  mf sandbox check [--json] # probe available isolation engines on this machine
84
+ mf config set-key <ENV_NAME> # store a model key in ~/.modelfunction/credentials.json (hidden input)
85
+ mf config remove-key <ENV_NAME> # delete a stored key
86
+ mf config list [--json] # names + masked values only
87
+ mf config path # print the credentials file path
88
+ mf stats [--json] # local usage summary (never uploaded)
89
+ mf feedback [--out <file>] [--json] # export a redacted feedback bundle; you decide whether to send it
62
90
  mf mcp # start stdio MCP server for coding agents
63
91
 
64
92
  Eval options:
@@ -68,6 +96,8 @@ Eval options:
68
96
  --case <ids> run only these case ids (comma-separated, repeatable)
69
97
  --profile <names> score each mounted group separately ("all" or names from suite.profiles)
70
98
  --repeat <n> run each case n times and score the majority; reports unstable cases (default 1)
99
+ --max-cost <cny> stop after the estimated spend crosses this limit (exit 2; no partial scorecard)
100
+ --progress print per-case progress to stderr (stdout stays clean for --json)
71
101
  --show-cases print per-case results
72
102
  --json print JSON instead of the table
73
103
  --out <file> write scorecard (.md => markdown, otherwise JSON)
@@ -81,13 +111,15 @@ Eval options:
81
111
  Scan options:
82
112
  --root <dir> repository to scan (default: .)
83
113
  --from <file> import tools from a module (export default [...] or export const tools)
84
- --probe start stdio MCP servers and read tools/list
114
+ --probe connect stdio or streamable-HTTP MCP servers and read tools/list
85
115
  --timeout <ms> probe timeout per server (default: 5000)
86
116
  --out <file> suite draft to write (default: mf.suite.json)
87
117
  --report <file> scan report to write (default: mf.scan.json)
88
118
  --no-write print only, do not write files
89
119
 
90
120
  Env keys: DEEPSEEK_API_KEY, DASHSCOPE_API_KEY, ZHIPU_API_KEY, OPENAI_API_KEY, SILICONFLOW_API_KEY
121
+ Credentials: env vars win; missing model keys are read from ~/.modelfunction/credentials.json (MF_CONFIG_DIR overrides).
122
+ Usage: recorded locally under ~/.modelfunction/usage (counts only, never uploaded); MF_NO_USAGE=1 opts out.
91
123
 
92
124
  Examples:
93
125
  mf eval --models mock --show-cases
@@ -107,17 +139,31 @@ export async function runEval(options, io = console) {
107
139
  const { registry, adapters } = await resolveModels(options.models, options.modelsFile, io);
108
140
  const tools = toModelTools(suite.tools);
109
141
 
142
+ const budget = createBudget(options.maxCost ?? null);
110
143
  const runs = [];
111
- for (const adapter of adapters) {
112
- const evaluated = await evaluate(adapter, tools, cases, options.repeat ?? 1);
113
- const summary = scoreModel({
114
- model: adapter.id,
115
- cases,
116
- results: evaluated.results,
117
- pricing: registry[adapter.id]?.pricing ?? null
118
- });
119
- if (evaluated.stability) summary.stability = evaluated.stability;
120
- runs.push({ adapter, results: evaluated.results, summary });
144
+ try {
145
+ for (const adapter of adapters) {
146
+ const pricing = registry[adapter.id]?.pricing ?? null;
147
+ const evaluated = await evaluate(adapter, tools, cases, options.repeat ?? 1, {
148
+ pricing,
149
+ budget,
150
+ onProgress: makeProgress(options, io, adapter.id, cases.length)
151
+ });
152
+ const summary = scoreModel({
153
+ model: adapter.id,
154
+ cases,
155
+ results: evaluated.results,
156
+ pricing
157
+ });
158
+ if (evaluated.stability) summary.stability = evaluated.stability;
159
+ runs.push({ adapter, results: evaluated.results, summary });
160
+ }
161
+ } catch (error) {
162
+ if (error instanceof CostLimitError) {
163
+ io.error(costLimitMessage(error));
164
+ return 2;
165
+ }
166
+ throw error;
121
167
  }
122
168
  const summaries = runs.map((run) => run.summary);
123
169
 
@@ -146,16 +192,28 @@ export async function runEval(options, io = console) {
146
192
  };
147
193
  }
148
194
 
149
- const profileReport = options.profiles
150
- ? await evaluateProfiles({
195
+ let profileReport = null;
196
+ if (options.profiles) {
197
+ try {
198
+ profileReport = await evaluateProfiles({
151
199
  suite,
152
200
  cases,
153
201
  runs,
154
202
  registry,
155
203
  selection: options.profiles,
156
- repeat: options.repeat ?? 1
157
- })
158
- : null;
204
+ repeat: options.repeat ?? 1,
205
+ budget,
206
+ progress: options.progress,
207
+ io
208
+ });
209
+ } catch (error) {
210
+ if (error instanceof CostLimitError) {
211
+ io.error(costLimitMessage(error));
212
+ return 2;
213
+ }
214
+ throw error;
215
+ }
216
+ }
159
217
 
160
218
  if (options.out) {
161
219
  const target = resolve(options.out);
@@ -252,6 +310,35 @@ export async function runEval(options, io = console) {
252
310
  const failed = options.failUnder === null
253
311
  ? []
254
312
  : summaries.filter((summary) => summary.selectionAccuracy < options.failUnder);
313
+ const profileBelow =
314
+ profileReport && options.failUnder !== null
315
+ ? profileReport.models.flatMap((model) =>
316
+ model.profiles
317
+ .filter((entry) => entry.reviewed > 0 && entry.mounted.selectionAccuracy < options.failUnder)
318
+ .map((entry) => `${model.model}/${entry.profile} (${pct(entry.mounted.selectionAccuracy)})`)
319
+ )
320
+ : [];
321
+
322
+ noteUsage({
323
+ models: summaries.map((summary) => summary.model),
324
+ caseCount: cases.length,
325
+ toolCount: suite.tools.length,
326
+ reviewedCount: cases.filter((testCase) => testCase.reviewed !== false).length,
327
+ repeat: options.repeat ?? 1,
328
+ profileGroups: profileReport ? profileReport.groups.length : 0,
329
+ selectionAccuracy: average(summaries.map((summary) => summary.selectionAccuracy)),
330
+ argAccuracy: average(summaries.map((summary) => summary.argAccuracy)),
331
+ passRate: average(summaries.map((summary) => summary.passRate)),
332
+ unstableCases: summaries.reduce((total, summary) => total + (summary.stability?.unstable.length ?? 0), 0),
333
+ execEngine: executionInfo?.engine,
334
+ execExecuted: sumExec(summaries, "executed"),
335
+ execPassed: sumExec(summaries, "passed"),
336
+ execBlocked: sumExec(summaries, "blocked"),
337
+ execSkipped: sumExec(summaries, "skipped"),
338
+ failedThreshold: failed.length > 0 || profileBelow.length > 0,
339
+ regressions: regressions.length > 0
340
+ });
341
+
255
342
  if (failed.length > 0) {
256
343
  io.error(
257
344
  `below threshold ${pct(options.failUnder)}: ${failed
@@ -260,21 +347,24 @@ export async function runEval(options, io = console) {
260
347
  );
261
348
  return 2;
262
349
  }
263
- if (profileReport && options.failUnder !== null) {
264
- const below = profileReport.models.flatMap((model) =>
265
- model.profiles
266
- .filter((entry) => entry.reviewed > 0 && entry.mounted.selectionAccuracy < options.failUnder)
267
- .map((entry) => `${model.model}/${entry.profile} (${pct(entry.mounted.selectionAccuracy)})`)
268
- );
269
- if (below.length > 0) {
270
- io.error(`profile below threshold ${pct(options.failUnder)}: ${below.join(", ")}`);
271
- return 2;
272
- }
350
+ if (profileBelow.length > 0) {
351
+ io.error(`profile below threshold ${pct(options.failUnder)}: ${profileBelow.join(", ")}`);
352
+ return 2;
273
353
  }
274
354
  if (regressions.length > 0) return 2;
275
355
  return 0;
276
356
  }
277
357
 
358
+ function average(values) {
359
+ const usable = values.filter((value) => typeof value === "number" && Number.isFinite(value));
360
+ if (usable.length === 0) return undefined;
361
+ return usable.reduce((sum, value) => sum + value, 0) / usable.length;
362
+ }
363
+
364
+ function sumExec(summaries, field) {
365
+ return summaries.reduce((total, summary) => total + (summary.exec?.[field] ?? 0), 0);
366
+ }
367
+
278
368
  export async function runAblate(options, io = console) {
279
369
  const suite = await loadSuite(resolve(options.suite ?? DEFAULT_SUITE));
280
370
  if (!options.tool) throw new Error("--tool is required");
@@ -298,14 +388,35 @@ export async function runAblate(options, io = console) {
298
388
  const fullTools = toModelTools(baseTools);
299
389
  const reducedTools = toModelTools(baseTools.filter((tool) => tool.name !== ablated.name));
300
390
  const models = [];
391
+ const budget = createBudget(options.maxCost ?? null);
301
392
 
302
- for (const adapter of adapters) {
303
- const pricing = registry[adapter.id]?.pricing ?? null;
304
- const fullResults = (await evaluate(adapter, fullTools, baseCases)).results;
305
- const full = scoreModel({ model: adapter.id, cases: baseCases, results: fullResults, pricing });
306
- const reducedResults = (await evaluate(adapter, reducedTools, reducedCases)).results;
307
- const reduced = scoreModel({ model: adapter.id, cases: reducedCases, results: reducedResults, pricing });
308
- models.push(analyzeAblation(adapter.id, full, reduced));
393
+ try {
394
+ for (const adapter of adapters) {
395
+ const pricing = registry[adapter.id]?.pricing ?? null;
396
+ const fullResults = (
397
+ await evaluate(adapter, fullTools, baseCases, 1, {
398
+ pricing,
399
+ budget,
400
+ onProgress: makeProgress(options, io, `${adapter.id}/full`, baseCases.length)
401
+ })
402
+ ).results;
403
+ const full = scoreModel({ model: adapter.id, cases: baseCases, results: fullResults, pricing });
404
+ const reducedResults = (
405
+ await evaluate(adapter, reducedTools, reducedCases, 1, {
406
+ pricing,
407
+ budget,
408
+ onProgress: makeProgress(options, io, `${adapter.id}/ablated`, reducedCases.length)
409
+ })
410
+ ).results;
411
+ const reduced = scoreModel({ model: adapter.id, cases: reducedCases, results: reducedResults, pricing });
412
+ models.push(analyzeAblation(adapter.id, full, reduced));
413
+ }
414
+ } catch (error) {
415
+ if (error instanceof CostLimitError) {
416
+ io.error(costLimitMessage(error));
417
+ return 2;
418
+ }
419
+ throw error;
309
420
  }
310
421
 
311
422
  const payload = {
@@ -315,6 +426,12 @@ export async function runAblate(options, io = console) {
315
426
  models,
316
427
  ...(scope ? { profile: { name: scope.name, tools: baseTools.length } } : {})
317
428
  };
429
+ noteUsage({
430
+ models: models.map((model) => model.model),
431
+ caseCount: baseCases.length,
432
+ toolCount: baseTools.length,
433
+ profileGroups: scope ? 1 : 0
434
+ });
318
435
  io.log(options.json ? JSON.stringify(payload, null, 2) : formatAblation(payload));
319
436
  return 0;
320
437
  }
@@ -334,11 +451,10 @@ export async function runScan(options, io = console) {
334
451
 
335
452
  if (options.probe) {
336
453
  for (const server of [...report.mcpServers]) {
337
- if (server.transport !== "stdio") {
338
- report.probes.push({ server: server.name, ok: false, error: "http transport 暂不支持" });
339
- continue;
340
- }
341
- const result = await probeStdioServer(server, { timeoutMs: options.timeoutMs });
454
+ const result =
455
+ server.transport === "stdio"
456
+ ? await probeStdioServer(server, { timeoutMs: options.timeoutMs })
457
+ : await probeHttpServer(server, { timeoutMs: options.timeoutMs });
342
458
  if (!result.ok) {
343
459
  report.probes.push({ server: server.name, ok: false, error: result.error, stderr: result.stderr });
344
460
  continue;
@@ -355,6 +471,13 @@ export async function runScan(options, io = console) {
355
471
  }
356
472
  }
357
473
 
474
+ noteUsage({
475
+ scanTools: report.tools.length,
476
+ scanSkills: report.skills.length,
477
+ scanMcpServers: report.mcpServers.length,
478
+ scanProbes: report.probes.length
479
+ });
480
+
358
481
  const draft = buildSuiteDraft(report);
359
482
 
360
483
  if (options.json) {
@@ -418,10 +541,40 @@ export async function runCoverage(options, io = console) {
418
541
  allowEmptyTools: true
419
542
  });
420
543
  const report = computeCoverage(suite);
544
+ noteUsage({ toolCount: suite.tools.length, caseCount: suite.cases.length });
421
545
  io.log(options.json ? JSON.stringify(report, null, 2) : formatCoverage(report));
422
546
  return 0;
423
547
  }
424
548
 
549
+ export async function runAudit(options, io = console) {
550
+ const suite = await loadSuite(resolve(options.suite ?? DEFAULT_SUITE), {
551
+ allowEmptyCases: true,
552
+ allowEmptyTools: true
553
+ });
554
+ const report = auditSuite(suite);
555
+ const rules = {};
556
+ for (const finding of report.lint.findings) rules[finding.code] = (rules[finding.code] ?? 0) + 1;
557
+ noteUsage({
558
+ toolCount: report.suite.tools,
559
+ caseCount: report.suite.cases,
560
+ lintErrors: report.lint.counts.error,
561
+ lintWarns: report.lint.counts.warn,
562
+ lintInfos: report.lint.counts.info,
563
+ lintRules: rules,
564
+ clusters: report.clusters.clusters.length,
565
+ profileGroups: report.profiles?.groups.length ?? 0
566
+ });
567
+
568
+ if (options.out) {
569
+ const target = resolve(options.out);
570
+ const markdown = extname(target).toLowerCase() === ".md";
571
+ await writeFileAtomic(target, markdown ? `${formatAudit(report)}\n` : `${JSON.stringify(report, null, 2)}\n`);
572
+ io.log(`wrote ${target}`);
573
+ }
574
+ io.log(options.json ? JSON.stringify(report, null, 2) : formatAudit(report));
575
+ return 0;
576
+ }
577
+
425
578
  export async function runSandboxCheck(options, io = console) {
426
579
  const report = checkSandbox();
427
580
  io.log(options.json ? JSON.stringify(report, null, 2) : formatSandboxReport(report));
@@ -437,6 +590,7 @@ export async function runCheck(options, io = console) {
437
590
  ? JSON.parse(await readFile(resolve(options.installed), "utf8"))
438
591
  : [];
439
592
  const result = check({ manifest, installed });
593
+ noteUsage({ checkVerdict: result.verdict, checkFindings: result.findings.length });
440
594
  io.log(options.json ? JSON.stringify(result, null, 2) : formatCheck(result));
441
595
  return 0;
442
596
  }
@@ -447,6 +601,15 @@ export async function runLint(options, io = console) {
447
601
  allowEmptyTools: true
448
602
  });
449
603
  const report = lintSuite(suite);
604
+ const rules = {};
605
+ for (const finding of report.findings) rules[finding.code] = (rules[finding.code] ?? 0) + 1;
606
+ noteUsage({
607
+ toolCount: report.tools,
608
+ lintErrors: report.counts.error,
609
+ lintWarns: report.counts.warn,
610
+ lintInfos: report.counts.info,
611
+ lintRules: rules
612
+ });
450
613
  io.log(options.json ? JSON.stringify(report, null, 2) : formatLint(report));
451
614
  return report.counts.error > 0 ? 1 : 0;
452
615
  }
@@ -461,6 +624,123 @@ export async function runCompare(options, io = console) {
461
624
  return changed ? 1 : 0;
462
625
  }
463
626
 
627
+ export async function runConfig(options, io = console) {
628
+ const [sub, name] = options.args ?? [];
629
+ if (sub === "set-key") {
630
+ if (!name) throw new Error("usage: mf config set-key <ENV_NAME>");
631
+ const value = await (options.readSecret ?? readSecret)(`${name}: `);
632
+ const result = await setCredential(name, value);
633
+ io.log(`saved ${result.name} → ${result.path}`);
634
+ io.log("permissions: 0600 where supported. env vars still win; CI should use env or a secret manager.");
635
+ return 0;
636
+ }
637
+ if (sub === "remove-key") {
638
+ if (!name) throw new Error("usage: mf config remove-key <ENV_NAME>");
639
+ const result = await removeCredential(name);
640
+ io.log(
641
+ result.removed
642
+ ? `removed ${result.name} from ${result.path}`
643
+ : `${result.name} is not stored (${result.path})`
644
+ );
645
+ return 0;
646
+ }
647
+ if (sub === "list") {
648
+ const entries = await listCredentials();
649
+ if (options.json) {
650
+ io.log(JSON.stringify({ path: credentialsPath(), keys: entries }, null, 2));
651
+ return 0;
652
+ }
653
+ if (entries.length === 0) {
654
+ io.log(`no stored keys (${credentialsPath()})`);
655
+ return 0;
656
+ }
657
+ for (const entry of entries) io.log(`${entry.name} ${entry.masked}`);
658
+ io.log(`file: ${credentialsPath()}`);
659
+ return 0;
660
+ }
661
+ if (sub === "path") {
662
+ io.log(credentialsPath());
663
+ return 0;
664
+ }
665
+ throw new Error("usage: mf config <set-key <ENV_NAME> | remove-key <ENV_NAME> | list | path>");
666
+ }
667
+
668
+ export async function runStats(options, io = console) {
669
+ const { events, files, skipped } = await readUsageEvents();
670
+ const summary = summarizeUsage(events);
671
+ if (options.json) {
672
+ io.log(JSON.stringify({ path: usageDir(), files: files.length, skipped, ...summary }, null, 2));
673
+ } else {
674
+ io.log(formatStats(summary, { events: events.length, files: files.length, skipped, path: usageDir() }));
675
+ }
676
+ return 0;
677
+ }
678
+
679
+ export async function runFeedback(options, io = console) {
680
+ const { events } = await readUsageEvents();
681
+ const version = await readVersion();
682
+ const bundle = buildFeedbackBundle({ events, version });
683
+ const target = resolve(options.out ?? defaultFeedbackPath());
684
+ await mkdir(dirname(target), { recursive: true, mode: 0o700 });
685
+ await writeFileAtomic(target, `${JSON.stringify(bundle, null, 2)}\n`, { mode: 0o600 });
686
+ if (options.json) io.log(JSON.stringify(bundle, null, 2));
687
+ io.log(`wrote ${target}`);
688
+ io.log(bundle.privacy);
689
+ io.log("send it to hi@yunzerui.com if you want us to look(可选,不发送不影响使用)。");
690
+ return 0;
691
+ }
692
+
693
+ // Hidden input: a key must never appear in argv, shell history, or logs.
694
+ async function readSecret(prompt) {
695
+ const stdin = process.stdin;
696
+ if (!stdin.isTTY) {
697
+ const chunks = [];
698
+ for await (const chunk of stdin) chunks.push(chunk);
699
+ const value = Buffer.concat(chunks).toString("utf8").trim();
700
+ if (!value) throw new Error("empty input; nothing written");
701
+ return value;
702
+ }
703
+ process.stdout.write(prompt);
704
+ return new Promise((resolvePromise, reject) => {
705
+ let value = "";
706
+ const cleanup = () => {
707
+ stdin.removeListener("data", onData);
708
+ stdin.setRawMode(false);
709
+ stdin.pause();
710
+ };
711
+ const onData = (chunk) => {
712
+ for (const char of String(chunk)) {
713
+ if (char === "\u0003") {
714
+ cleanup();
715
+ process.stdout.write("\n");
716
+ reject(new Error("cancelled"));
717
+ return;
718
+ }
719
+ if (char === "\r" || char === "\n") {
720
+ cleanup();
721
+ process.stdout.write("\n");
722
+ resolvePromise(value.trim());
723
+ return;
724
+ }
725
+ if (char === "\u007f" || char === "\b") {
726
+ value = value.slice(0, -1);
727
+ continue;
728
+ }
729
+ value += char;
730
+ }
731
+ };
732
+ stdin.setRawMode(true);
733
+ stdin.setEncoding("utf8");
734
+ stdin.resume();
735
+ stdin.on("data", onData);
736
+ });
737
+ }
738
+
739
+ async function readVersion() {
740
+ const pkg = JSON.parse(await readFile(new URL("./package.json", import.meta.url), "utf8"));
741
+ return pkg.version;
742
+ }
743
+
464
744
  async function readStdin() {
465
745
  const chunks = [];
466
746
  for await (const chunk of process.stdin) chunks.push(chunk);
@@ -477,6 +757,8 @@ function selectCases(cases, ids) {
477
757
 
478
758
  async function resolveModels(ids, modelsFile, io) {
479
759
  const registry = JSON.parse(await readFile(resolve(modelsFile), "utf8"));
760
+ // Env wins; the credentials file only fills what is missing.
761
+ await applyModelCredentials(registry);
480
762
  const explicit = Boolean(ids && ids.length > 0);
481
763
  const chosen = explicit ? ids : autoSelect(registry);
482
764
  const adapters = [];
@@ -493,11 +775,13 @@ async function resolveModels(ids, modelsFile, io) {
493
775
  if (config.type !== "openai") {
494
776
  throw new Error(`model "${id}": unsupported type "${config.type}"`);
495
777
  }
496
- if (!process.env[config.apiKeyEnv]) {
778
+ const apiKey = process.env[config.apiKeyEnv];
779
+ if (!apiKey) {
497
780
  if (explicit) throw new Error(`model "${id}": environment variable ${config.apiKeyEnv} is not set`);
498
781
  io.error(`skip ${id}: ${config.apiKeyEnv} is not set`);
499
782
  continue;
500
783
  }
784
+ registerSecrets([apiKey]);
501
785
  adapters.push({ id, ...createOpenAiAdapter({ id, ...config }) });
502
786
  }
503
787
 
@@ -515,25 +799,47 @@ function autoSelect(registry) {
515
799
  .map(([id]) => id);
516
800
  }
517
801
 
518
- async function evaluate(adapter, tools, cases, repeat = 1) {
802
+ async function evaluate(
803
+ adapter,
804
+ tools,
805
+ cases,
806
+ repeat = 1,
807
+ { pricing = null, budget = null, onProgress = null } = {}
808
+ ) {
519
809
  const results = [];
520
810
  const unstable = [];
521
- for (const testCase of cases) {
811
+ for (const [index, testCase] of cases.entries()) {
522
812
  const runs = [];
523
813
  for (let attempt = 0; attempt < repeat; attempt += 1) {
524
- runs.push(await callOnce(adapter, tools, testCase));
814
+ const result = await callOnce(adapter, tools, testCase);
815
+ budget?.spend(costCnyOf(result.usage, pricing));
816
+ runs.push(result);
525
817
  }
526
818
  if (repeat === 1) {
527
819
  results.push(runs[0]);
528
- continue;
820
+ } else {
821
+ const picked = pickRepresentative(runs);
822
+ results.push(picked.result);
823
+ if (picked.unstable) unstable.push({ id: testCase.id, outcomes: picked.outcomes });
529
824
  }
530
- const picked = pickRepresentative(runs);
531
- results.push(picked.result);
532
- if (picked.unstable) unstable.push({ id: testCase.id, outcomes: picked.outcomes });
825
+ onProgress?.(index + 1, cases.length);
533
826
  }
534
827
  return { results, stability: repeat > 1 ? { runs: repeat, unstable } : null };
535
828
  }
536
829
 
830
+ function makeProgress(options, io, label, total) {
831
+ if (!options.progress) return null;
832
+ const step = Math.max(1, Math.ceil(total / 10));
833
+ return (done, count) => {
834
+ if (done !== count && done % step !== 0) return;
835
+ io.error(`[${label}] ${done}/${count}`);
836
+ };
837
+ }
838
+
839
+ function costLimitMessage(error) {
840
+ return `${error.message};已发出的请求已计费,本次不计分。减少 --case/--models/--repeat 或提高 --max-cost 后重跑。`;
841
+ }
842
+
537
843
  async function callOnce(adapter, tools, testCase) {
538
844
  const messages = [
539
845
  { role: "system", content: SYSTEM_PROMPT },
@@ -578,7 +884,17 @@ function ablationScope(suite, names) {
578
884
  return { name: profile.name, tools: suite.tools.filter((tool) => mounted.has(tool.name)) };
579
885
  }
580
886
 
581
- async function evaluateProfiles({ suite, cases, runs, registry, selection, repeat = 1 }) {
887
+ async function evaluateProfiles({
888
+ suite,
889
+ cases,
890
+ runs,
891
+ registry,
892
+ selection,
893
+ repeat = 1,
894
+ budget = null,
895
+ progress = false,
896
+ io = console
897
+ }) {
582
898
  const profiles = normalizeProfiles(suite.profiles, suite.tools.map((tool) => tool.name));
583
899
  if (!profiles) {
584
900
  throw new Error(`suite ${suite.name} 没有 profiles;先运行 mf cluster --out <suite> 生成分组`);
@@ -608,7 +924,11 @@ async function evaluateProfiles({ suite, cases, runs, registry, selection, repea
608
924
  const profileCases = caseSets[index];
609
925
  if (profileCases.length === 0) continue;
610
926
  const mountedTools = suite.tools.filter((tool) => profile.tools.includes(tool.name));
611
- const mountedEval = await evaluate(run.adapter, toModelTools(mountedTools), profileCases, repeat);
927
+ const mountedEval = await evaluate(run.adapter, toModelTools(mountedTools), profileCases, repeat, {
928
+ pricing,
929
+ budget,
930
+ onProgress: makeProgress({ progress }, io, `${run.adapter.id}/${profile.name}`, profileCases.length)
931
+ });
612
932
  const mounted = scoreModel({
613
933
  model: run.adapter.id,
614
934
  cases: profileCases,
@@ -644,6 +964,7 @@ async function evaluateProfiles({ suite, cases, runs, registry, selection, repea
644
964
  export async function runCluster(options, io = console) {
645
965
  const suite = await loadSuite(options.suite, { allowEmptyCases: true });
646
966
  const result = clusterTools(suite.tools, { threshold: options.threshold, maxSize: options.maxSize });
967
+ noteUsage({ toolCount: suite.tools.length, clusters: result.clusters.length });
647
968
  if (options.out) {
648
969
  const target = resolve(options.out);
649
970
  const profiles = Object.fromEntries(result.clusters.map((cluster) => [cluster.label, cluster.tools]));
@@ -670,6 +991,8 @@ function parseArgs(argv) {
670
991
  cases: null,
671
992
  profiles: null,
672
993
  repeat: 1,
994
+ maxCost: null,
995
+ progress: false,
673
996
  showCases: false,
674
997
  json: false,
675
998
  out: null,
@@ -727,6 +1050,13 @@ function parseArgs(argv) {
727
1050
  options.repeat = raw;
728
1051
  break;
729
1052
  }
1053
+ case "--max-cost": {
1054
+ const raw = Number(value());
1055
+ if (!Number.isFinite(raw) || raw <= 0) throw new Error("--max-cost expects a positive CNY amount");
1056
+ options.maxCost = raw;
1057
+ break;
1058
+ }
1059
+ case "--progress": options.progress = true; break;
730
1060
  case "--show-cases": options.showCases = true; break;
731
1061
  case "--json": options.json = true; break;
732
1062
  case "--out": options.out = value(); break;
@@ -811,20 +1141,36 @@ if (isMain) {
811
1141
  let argv = process.argv.slice(2);
812
1142
  // `npx modelfunction-cli mf scan` passes the bin name through; accept it.
813
1143
  if (argv[0] === "mf") argv = argv.slice(1);
1144
+
1145
+ const version = await readVersion();
1146
+ const startedAt = Date.now();
1147
+ let commandName = null;
1148
+ const skipUsage = new Set(["mcp", "config", "stats", "feedback"]);
1149
+ // Usage is local and count-only; the exit hook keeps every branch covered.
1150
+ process.on("exit", (code) => {
1151
+ if (!commandName || skipUsage.has(commandName)) return;
1152
+ recordRunSync({
1153
+ command: commandName,
1154
+ exitCode: typeof code === "number" ? code : 0,
1155
+ durationMs: Date.now() - startedAt,
1156
+ version
1157
+ });
1158
+ });
1159
+
814
1160
  if (argv[0] === "--version" || argv[0] === "-v") {
815
- const pkg = JSON.parse(await readFile(new URL("./package.json", import.meta.url), "utf8"));
816
- process.stdout.write(`mf ${pkg.version}\n`);
1161
+ process.stdout.write(`mf ${version}\n`);
817
1162
  process.exit(0);
818
1163
  } else if (argv[0] === "sandbox") {
819
1164
  if (argv[1] !== "check") {
820
1165
  process.stderr.write("usage: mf sandbox check [--json]\n");
821
1166
  process.exit(1);
822
1167
  }
1168
+ commandName = "sandbox";
823
1169
  try {
824
1170
  const code = await runSandboxCheck({ json: argv.includes("--json") });
825
1171
  process.exit(code);
826
1172
  } catch (error) {
827
- process.stderr.write(`${error?.message ?? error}\n`);
1173
+ process.stderr.write(`${redactText(error?.message ?? error)}\n`);
828
1174
  process.exit(1);
829
1175
  }
830
1176
  } else if (argv[0] === "compare") {
@@ -833,11 +1179,44 @@ if (isMain) {
833
1179
  process.stderr.write("usage: mf compare <before.json> <after.json> [--json]\n");
834
1180
  process.exit(1);
835
1181
  }
1182
+ commandName = "compare";
836
1183
  try {
837
1184
  const code = await runCompare({ paths, json: argv.includes("--json") });
838
1185
  process.exit(code);
839
1186
  } catch (error) {
840
- process.stderr.write(`${error?.message ?? error}\n`);
1187
+ process.stderr.write(`${redactText(error?.message ?? error)}\n`);
1188
+ process.exit(1);
1189
+ }
1190
+ } else if (argv[0] === "config") {
1191
+ try {
1192
+ const code = await runConfig({ args: argv.slice(1), json: argv.includes("--json") });
1193
+ process.exit(code);
1194
+ } catch (error) {
1195
+ process.stderr.write(`${redactText(error?.message ?? error)}\n`);
1196
+ process.exit(1);
1197
+ }
1198
+ } else if (argv[0] === "stats") {
1199
+ try {
1200
+ const code = await runStats({ json: argv.includes("--json") });
1201
+ process.exit(code);
1202
+ } catch (error) {
1203
+ process.stderr.write(`${redactText(error?.message ?? error)}\n`);
1204
+ process.exit(1);
1205
+ }
1206
+ } else if (argv[0] === "feedback") {
1207
+ try {
1208
+ const outIndex = argv.indexOf("--out");
1209
+ if (outIndex >= 0 && !argv[outIndex + 1]) {
1210
+ process.stderr.write("usage: mf feedback [--out <file>] [--json]\n");
1211
+ process.exit(1);
1212
+ }
1213
+ const code = await runFeedback({
1214
+ out: outIndex >= 0 ? argv[outIndex + 1] : null,
1215
+ json: argv.includes("--json")
1216
+ });
1217
+ process.exit(code);
1218
+ } catch (error) {
1219
+ process.stderr.write(`${redactText(error?.message ?? error)}\n`);
841
1220
  process.exit(1);
842
1221
  }
843
1222
  } else if (argv[0] === "mcp") {
@@ -845,12 +1224,12 @@ if (isMain) {
845
1224
  const { startMcpServer } = await import("./lib/mcp-server.js");
846
1225
  await startMcpServer();
847
1226
  } catch (error) {
848
- process.stderr.write(`${error?.message ?? error}\n`);
1227
+ process.stderr.write(`${redactText(error?.message ?? error)}\n`);
849
1228
  process.exit(1);
850
1229
  }
851
1230
  } else if (argv.length === 0 || argv[0] === "help" || argv[0] === "-h" || argv[0] === "--help") {
852
1231
  process.stdout.write(USAGE);
853
- } else if (!["eval", "scan", "case", "coverage", "lint", "check", "ablate", "cluster", "exec"].includes(argv[0])) {
1232
+ } else if (!["eval", "scan", "case", "coverage", "audit", "lint", "check", "ablate", "cluster", "exec"].includes(argv[0])) {
854
1233
  process.stderr.write(`unknown command "${argv[0]}"\n\n${USAGE}`);
855
1234
  process.exit(1);
856
1235
  } else if (argv[0] === "case" && argv[1] !== "add") {
@@ -864,6 +1243,7 @@ if (isMain) {
864
1243
  process.stdout.write(USAGE);
865
1244
  process.exit(0);
866
1245
  }
1246
+ commandName = argv[0] === "case" ? "case" : argv[0];
867
1247
  const code =
868
1248
  argv[0] === "scan"
869
1249
  ? await runScan(options)
@@ -871,6 +1251,8 @@ if (isMain) {
871
1251
  ? await runCaseAdd(options)
872
1252
  : argv[0] === "coverage"
873
1253
  ? await runCoverage(options)
1254
+ : argv[0] === "audit"
1255
+ ? await runAudit(options)
874
1256
  : argv[0] === "lint"
875
1257
  ? await runLint(options)
876
1258
  : argv[0] === "check"
@@ -884,7 +1266,7 @@ if (isMain) {
884
1266
  : await runEval(options);
885
1267
  process.exit(code);
886
1268
  } catch (error) {
887
- process.stderr.write(`${error?.message ?? error}\n`);
1269
+ process.stderr.write(`${redactText(error?.message ?? error)}\n`);
888
1270
  process.exit(1);
889
1271
  }
890
1272
  }