modelfunction-cli 0.5.0 → 0.5.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/mf.js CHANGED
@@ -4,14 +4,15 @@ 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
- import { check, formatCheck } from "./lib/check.js";
14
+ import { check, checkMany, formatCheck, formatCheckMany } 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,6 +31,7 @@ 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";
34
36
  import {
35
37
  applyModelCredentials,
@@ -71,10 +73,11 @@ Usage:
71
73
  mf scan [options]
72
74
  mf case add --suite <file> (--input <file> | stdin) [--source agent] [--reviewed] [--dry-run]
73
75
  mf coverage [--suite <file>] [--json]
76
+ mf audit [--suite <file>] [--out <file>] [--json] # 静态审计:lint + 分组边界 + 用例覆盖 + 挂载
74
77
  mf lint [--suite <file>] [--json] # 静态检查 tools 与 skills,不调用模型
75
- mf check --manifest <file> [--installed <file>] [--json] # 安装门禁:新能力 × 已装能力
78
+ mf check (--manifest <file> | --manifests <file>) [--installed <file>] [--json] # 安装门禁:新能力 × 已装能力(--manifests 支持一包)
76
79
  mf compare <before.json> <after.json> [--json]
77
- 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]
78
81
  mf cluster [--suite <file>] [--threshold <0.5>] [--max-size <10>] [--out <file>] [--json] # 建议挂载分组 + 同族候选
79
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]
80
83
  mf sandbox check [--json] # probe available isolation engines on this machine
@@ -93,6 +96,8 @@ Eval options:
93
96
  --case <ids> run only these case ids (comma-separated, repeatable)
94
97
  --profile <names> score each mounted group separately ("all" or names from suite.profiles)
95
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)
96
101
  --show-cases print per-case results
97
102
  --json print JSON instead of the table
98
103
  --out <file> write scorecard (.md => markdown, otherwise JSON)
@@ -106,7 +111,7 @@ Eval options:
106
111
  Scan options:
107
112
  --root <dir> repository to scan (default: .)
108
113
  --from <file> import tools from a module (export default [...] or export const tools)
109
- --probe start stdio MCP servers and read tools/list
114
+ --probe connect stdio or streamable-HTTP MCP servers and read tools/list
110
115
  --timeout <ms> probe timeout per server (default: 5000)
111
116
  --out <file> suite draft to write (default: mf.suite.json)
112
117
  --report <file> scan report to write (default: mf.scan.json)
@@ -134,17 +139,31 @@ export async function runEval(options, io = console) {
134
139
  const { registry, adapters } = await resolveModels(options.models, options.modelsFile, io);
135
140
  const tools = toModelTools(suite.tools);
136
141
 
142
+ const budget = createBudget(options.maxCost ?? null);
137
143
  const runs = [];
138
- for (const adapter of adapters) {
139
- const evaluated = await evaluate(adapter, tools, cases, options.repeat ?? 1);
140
- const summary = scoreModel({
141
- model: adapter.id,
142
- cases,
143
- results: evaluated.results,
144
- pricing: registry[adapter.id]?.pricing ?? null
145
- });
146
- if (evaluated.stability) summary.stability = evaluated.stability;
147
- 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;
148
167
  }
149
168
  const summaries = runs.map((run) => run.summary);
150
169
 
@@ -173,16 +192,28 @@ export async function runEval(options, io = console) {
173
192
  };
174
193
  }
175
194
 
176
- const profileReport = options.profiles
177
- ? await evaluateProfiles({
195
+ let profileReport = null;
196
+ if (options.profiles) {
197
+ try {
198
+ profileReport = await evaluateProfiles({
178
199
  suite,
179
200
  cases,
180
201
  runs,
181
202
  registry,
182
203
  selection: options.profiles,
183
- repeat: options.repeat ?? 1
184
- })
185
- : 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
+ }
186
217
 
187
218
  if (options.out) {
188
219
  const target = resolve(options.out);
@@ -357,14 +388,35 @@ export async function runAblate(options, io = console) {
357
388
  const fullTools = toModelTools(baseTools);
358
389
  const reducedTools = toModelTools(baseTools.filter((tool) => tool.name !== ablated.name));
359
390
  const models = [];
391
+ const budget = createBudget(options.maxCost ?? null);
360
392
 
361
- for (const adapter of adapters) {
362
- const pricing = registry[adapter.id]?.pricing ?? null;
363
- const fullResults = (await evaluate(adapter, fullTools, baseCases)).results;
364
- const full = scoreModel({ model: adapter.id, cases: baseCases, results: fullResults, pricing });
365
- const reducedResults = (await evaluate(adapter, reducedTools, reducedCases)).results;
366
- const reduced = scoreModel({ model: adapter.id, cases: reducedCases, results: reducedResults, pricing });
367
- 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;
368
420
  }
369
421
 
370
422
  const payload = {
@@ -399,11 +451,10 @@ export async function runScan(options, io = console) {
399
451
 
400
452
  if (options.probe) {
401
453
  for (const server of [...report.mcpServers]) {
402
- if (server.transport !== "stdio") {
403
- report.probes.push({ server: server.name, ok: false, error: "http transport 暂不支持" });
404
- continue;
405
- }
406
- 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 });
407
458
  if (!result.ok) {
408
459
  report.probes.push({ server: server.name, ok: false, error: result.error, stderr: result.stderr });
409
460
  continue;
@@ -495,6 +546,35 @@ export async function runCoverage(options, io = console) {
495
546
  return 0;
496
547
  }
497
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
+
498
578
  export async function runSandboxCheck(options, io = console) {
499
579
  const report = checkSandbox();
500
580
  io.log(options.json ? JSON.stringify(report, null, 2) : formatSandboxReport(report));
@@ -504,11 +584,24 @@ export async function runSandboxCheck(options, io = console) {
504
584
  // Advisory install gate: static only, never blocks. Exit code stays 0 for a
505
585
  // warn verdict so a platform can log it without failing the install.
506
586
  export async function runCheck(options, io = console) {
507
- if (!options.manifest) throw new Error("--manifest <file> 必填(新能力的 [name, description, parameters?])");
508
- const manifest = JSON.parse(await readFile(resolve(options.manifest), "utf8"));
509
587
  const installed = options.installed
510
588
  ? JSON.parse(await readFile(resolve(options.installed), "utf8"))
511
589
  : [];
590
+
591
+ if (options.manifests) {
592
+ const manifests = JSON.parse(await readFile(resolve(options.manifests), "utf8"));
593
+ const result = checkMany({ manifests, installed });
594
+ noteUsage({
595
+ checkVerdict: result.verdict,
596
+ checkFindings: Object.values(result.summary).reduce((sum, count) => sum + count, 0)
597
+ });
598
+ io.log(options.json ? JSON.stringify(result, null, 2) : formatCheckMany(result));
599
+ return 0;
600
+ }
601
+ if (!options.manifest) {
602
+ throw new Error("--manifest 或 --manifests <file> 必填([name, description, parameters?])");
603
+ }
604
+ const manifest = JSON.parse(await readFile(resolve(options.manifest), "utf8"));
512
605
  const result = check({ manifest, installed });
513
606
  noteUsage({ checkVerdict: result.verdict, checkFindings: result.findings.length });
514
607
  io.log(options.json ? JSON.stringify(result, null, 2) : formatCheck(result));
@@ -719,25 +812,47 @@ function autoSelect(registry) {
719
812
  .map(([id]) => id);
720
813
  }
721
814
 
722
- async function evaluate(adapter, tools, cases, repeat = 1) {
815
+ async function evaluate(
816
+ adapter,
817
+ tools,
818
+ cases,
819
+ repeat = 1,
820
+ { pricing = null, budget = null, onProgress = null } = {}
821
+ ) {
723
822
  const results = [];
724
823
  const unstable = [];
725
- for (const testCase of cases) {
824
+ for (const [index, testCase] of cases.entries()) {
726
825
  const runs = [];
727
826
  for (let attempt = 0; attempt < repeat; attempt += 1) {
728
- runs.push(await callOnce(adapter, tools, testCase));
827
+ const result = await callOnce(adapter, tools, testCase);
828
+ budget?.spend(costCnyOf(result.usage, pricing));
829
+ runs.push(result);
729
830
  }
730
831
  if (repeat === 1) {
731
832
  results.push(runs[0]);
732
- continue;
833
+ } else {
834
+ const picked = pickRepresentative(runs);
835
+ results.push(picked.result);
836
+ if (picked.unstable) unstable.push({ id: testCase.id, outcomes: picked.outcomes });
733
837
  }
734
- const picked = pickRepresentative(runs);
735
- results.push(picked.result);
736
- if (picked.unstable) unstable.push({ id: testCase.id, outcomes: picked.outcomes });
838
+ onProgress?.(index + 1, cases.length);
737
839
  }
738
840
  return { results, stability: repeat > 1 ? { runs: repeat, unstable } : null };
739
841
  }
740
842
 
843
+ function makeProgress(options, io, label, total) {
844
+ if (!options.progress) return null;
845
+ const step = Math.max(1, Math.ceil(total / 10));
846
+ return (done, count) => {
847
+ if (done !== count && done % step !== 0) return;
848
+ io.error(`[${label}] ${done}/${count}`);
849
+ };
850
+ }
851
+
852
+ function costLimitMessage(error) {
853
+ return `${error.message};已发出的请求已计费,本次不计分。减少 --case/--models/--repeat 或提高 --max-cost 后重跑。`;
854
+ }
855
+
741
856
  async function callOnce(adapter, tools, testCase) {
742
857
  const messages = [
743
858
  { role: "system", content: SYSTEM_PROMPT },
@@ -782,7 +897,17 @@ function ablationScope(suite, names) {
782
897
  return { name: profile.name, tools: suite.tools.filter((tool) => mounted.has(tool.name)) };
783
898
  }
784
899
 
785
- async function evaluateProfiles({ suite, cases, runs, registry, selection, repeat = 1 }) {
900
+ async function evaluateProfiles({
901
+ suite,
902
+ cases,
903
+ runs,
904
+ registry,
905
+ selection,
906
+ repeat = 1,
907
+ budget = null,
908
+ progress = false,
909
+ io = console
910
+ }) {
786
911
  const profiles = normalizeProfiles(suite.profiles, suite.tools.map((tool) => tool.name));
787
912
  if (!profiles) {
788
913
  throw new Error(`suite ${suite.name} 没有 profiles;先运行 mf cluster --out <suite> 生成分组`);
@@ -812,7 +937,11 @@ async function evaluateProfiles({ suite, cases, runs, registry, selection, repea
812
937
  const profileCases = caseSets[index];
813
938
  if (profileCases.length === 0) continue;
814
939
  const mountedTools = suite.tools.filter((tool) => profile.tools.includes(tool.name));
815
- const mountedEval = await evaluate(run.adapter, toModelTools(mountedTools), profileCases, repeat);
940
+ const mountedEval = await evaluate(run.adapter, toModelTools(mountedTools), profileCases, repeat, {
941
+ pricing,
942
+ budget,
943
+ onProgress: makeProgress({ progress }, io, `${run.adapter.id}/${profile.name}`, profileCases.length)
944
+ });
816
945
  const mounted = scoreModel({
817
946
  model: run.adapter.id,
818
947
  cases: profileCases,
@@ -875,6 +1004,8 @@ function parseArgs(argv) {
875
1004
  cases: null,
876
1005
  profiles: null,
877
1006
  repeat: 1,
1007
+ maxCost: null,
1008
+ progress: false,
878
1009
  showCases: false,
879
1010
  json: false,
880
1011
  out: null,
@@ -891,6 +1022,7 @@ function parseArgs(argv) {
891
1022
  dryRun: false,
892
1023
  tool: null,
893
1024
  manifest: null,
1025
+ manifests: null,
894
1026
  installed: null,
895
1027
  threshold: 0.5,
896
1028
  maxSize: 10,
@@ -932,6 +1064,13 @@ function parseArgs(argv) {
932
1064
  options.repeat = raw;
933
1065
  break;
934
1066
  }
1067
+ case "--max-cost": {
1068
+ const raw = Number(value());
1069
+ if (!Number.isFinite(raw) || raw <= 0) throw new Error("--max-cost expects a positive CNY amount");
1070
+ options.maxCost = raw;
1071
+ break;
1072
+ }
1073
+ case "--progress": options.progress = true; break;
935
1074
  case "--show-cases": options.showCases = true; break;
936
1075
  case "--json": options.json = true; break;
937
1076
  case "--out": options.out = value(); break;
@@ -952,6 +1091,7 @@ function parseArgs(argv) {
952
1091
  case "--dry-run": options.dryRun = true; break;
953
1092
  case "--tool": options.tool = value(); break;
954
1093
  case "--manifest": options.manifest = resolve(value()); break;
1094
+ case "--manifests": options.manifests = resolve(value()); break;
955
1095
  case "--installed": options.installed = resolve(value()); break;
956
1096
  case "--threshold": {
957
1097
  const raw = Number(value());
@@ -1104,7 +1244,7 @@ if (isMain) {
1104
1244
  }
1105
1245
  } else if (argv.length === 0 || argv[0] === "help" || argv[0] === "-h" || argv[0] === "--help") {
1106
1246
  process.stdout.write(USAGE);
1107
- } else if (!["eval", "scan", "case", "coverage", "lint", "check", "ablate", "cluster", "exec"].includes(argv[0])) {
1247
+ } else if (!["eval", "scan", "case", "coverage", "audit", "lint", "check", "ablate", "cluster", "exec"].includes(argv[0])) {
1108
1248
  process.stderr.write(`unknown command "${argv[0]}"\n\n${USAGE}`);
1109
1249
  process.exit(1);
1110
1250
  } else if (argv[0] === "case" && argv[1] !== "add") {
@@ -1126,6 +1266,8 @@ if (isMain) {
1126
1266
  ? await runCaseAdd(options)
1127
1267
  : argv[0] === "coverage"
1128
1268
  ? await runCoverage(options)
1269
+ : argv[0] === "audit"
1270
+ ? await runAudit(options)
1129
1271
  : argv[0] === "lint"
1130
1272
  ? await runLint(options)
1131
1273
  : argv[0] === "check"
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "modelfunction-cli",
3
- "version": "0.5.0",
3
+ "version": "0.5.2",
4
4
  "type": "module",
5
5
  "license": "MIT",
6
6
  "description": "mf — function-calling accuracy scorecard for AI agents",