modelfunction-cli 0.5.0 → 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/README.md CHANGED
@@ -5,7 +5,7 @@
5
5
  ## 安装
6
6
 
7
7
  ```bash
8
- npx -y modelfunction-cli@0.4.4 --version
8
+ npx -y modelfunction-cli@0.5.0 --version
9
9
  # 或
10
10
  npm i -D modelfunction-cli
11
11
  npx mf --version
@@ -26,6 +26,7 @@ mf scan --root . --probe # 连 stdio MCP server 拉 tools/list
26
26
  mf coverage --suite mf.suite.json # 看每个工具还缺多少用例
27
27
  mf eval --suite mf.suite.json --models deepseek,qwen --show-cases
28
28
  mf compare before.json after.json # 跨版本比较(内容指纹判定可比性)
29
+ mf audit --suite mf.suite.json --out audit.md # 一次静态体检:lint + 边界 + 覆盖 + 挂载
29
30
  ```
30
31
 
31
32
  被测模型用环境变量提供 Key:
@@ -74,10 +75,11 @@ INTERNAL_LLM_KEY=... mf eval --suite mf.suite.json \
74
75
 
75
76
  | 命令 | 作用 |
76
77
  | --- | --- |
77
- | `mf scan` | 扫描工具定义(function.json / OpenAPI / MCP 配置 / Python `@tool` / JS·TS 声明与 TypeBox·zod schema);`--probe` 连 MCP server(运行时结果覆盖静态推断);`--from` 导入运行时工具数组;草稿按来源分组(`profiles`)并列出未覆盖的 skill |
78
- | `mf eval` | 跑评分卡;`--repeat` 多次取多数并报不稳定用例;`--fail-under` 阈值;`--baseline` + `--fail-on-regression` 回归门禁;`--profile` 按挂载组分别评分 |
78
+ | `mf scan` | 扫描工具定义(function.json / OpenAPI / MCP 配置 / Python `@tool` / JS·TS 声明与 TypeBox·zod schema);`--probe` 连 stdio 或 streamable-HTTP MCP server(运行时结果覆盖静态推断;headers/query 不写入报告);`--from` 导入运行时工具数组;草稿按来源分组(`profiles`)并列出未覆盖的 skill |
79
+ | `mf eval` | 跑评分卡;`--repeat` 多次取多数并报不稳定用例;`--fail-under` 阈值;`--baseline` + `--fail-on-regression` 回归门禁;`--profile` 按挂载组分别评分;`--max-cost` 按累计成本停机(退出 2);`--progress` 逐用例进度(stderr) |
79
80
  | `mf case add` | 写入用例(默认 `source=agent`、`reviewed=false`) |
80
81
  | `mf coverage` | 每工具用例数、参数覆盖、负例比例、疑似混淆对;有 `profiles` 时按组统计 |
82
+ | `mf audit` | 一次静态审计:lint + 混淆族边界 + 用例覆盖 + 挂载检查,输出可转发的 Markdown/JSON;不调用模型 |
81
83
  | `mf lint` | 静态检查工具描述与参数 schema,以及 skills 的 name/description(不调用模型);重复规则自动合并成一条;有 `profiles` 时给出「这些组不要合并挂载」 |
82
84
  | `mf check` | 安装门禁:一个新能力 × 已装能力(`--manifest` + `--installed`),纯静态、返回 pass/warn、不阻断;也可 `import { check } from "modelfunction-cli"` |
83
85
  | `mf compare` | 跨版本比较、干扰归因、混淆矩阵;两份报告都有分组时按组对比 |
@@ -87,7 +89,7 @@ INTERNAL_LLM_KEY=... mf eval --suite mf.suite.json \
87
89
  | `mf eval --execute` | 一次跑完选择 + 参数 + 运行断言(`toolInput` / `expect`) |
88
90
  | `mf sandbox check` | 探测本机可用隔离引擎与降级建议 |
89
91
  | `mf config set-key` | 把模型 Key 写入 `~/.modelfunction/credentials.json`(隐藏输入);`remove-key` 删除;`list` 只显示掩码,`path` 打印路径 |
90
- | `mf stats` | 本地使用摘要(命令次数、模型、通过率区间、lint 规则命中);不联网 |
92
+ | `mf stats` | 本地使用摘要(命令次数、本地/CI 区分、模型、通过率区间、lint 规则命中);不联网 |
91
93
  | `mf feedback` | 导出脱敏反馈包(仅计数与比率),发不发由你决定 |
92
94
  | `mf mcp` | stdio MCP server,供 Claude Code / Cursor / Kimi Code 调用 |
93
95
 
@@ -124,7 +126,7 @@ mf eval --suite mf.suite.json --models internal-ds \
124
126
  "mcpServers": {
125
127
  "modelfunction": {
126
128
  "command": "npx",
127
- "args": ["-y", "modelfunction-cli@0.4.4", "mcp"]
129
+ "args": ["-y", "modelfunction-cli@0.5.0", "mcp"]
128
130
  }
129
131
  }
130
132
  }
@@ -136,7 +138,7 @@ mf eval --suite mf.suite.json --models internal-ds \
136
138
 
137
139
  对外 JSON 字段、`check()` 返回结构、MCP 参数与退出码见 [SCHEMA.md](SCHEMA.md),自 0.4.3 起冻结为 v1:v1 内只做加法,重命名或删除字段需 major 并提前两个 minor 或 90 天弃用。`check()` 的类型声明随包发布。
138
140
 
139
- 本仓库当前版本 `0.5.0`(凭证与本地反馈渠道,待发布);上文的 `@0.4.4` 是 npm 上已发布的版本,保证示例可复制。发布后把示例里的版本号统一升级即可。
141
+ npm 已发布 `0.5.0`(凭证与本地反馈渠道);`0.5.1` 待发布,合并开发改动:usage `ci` 标记与 local/ci 统计、eval `--progress`/`--max-cost`、`mf audit` 组合审计、`scan --probe` 支持 streamable HTTP。示例固定 `@0.5.0`,发布后统一升级。
140
142
 
141
143
  ## 数据边界
142
144
 
@@ -146,7 +148,7 @@ mf eval --suite mf.suite.json --models internal-ds \
146
148
 
147
149
  ## 本地使用记录与反馈(默认不上传)
148
150
 
149
- 每条命令会在 `~/.modelfunction/usage/YYYY-MM.jsonl` 追加一行**计数记录**:命令名、退出码、耗时、mf/Node/OS 版本、模型 id、用例/工具数量、通过率区间、lint 规则命中数。**不记录**用例原文、工具描述、源码、文件路径或密钥。
151
+ 每条命令会在 `~/.modelfunction/usage/YYYY-MM.jsonl` 追加一行**计数记录**:命令名、退出码、耗时、mf/Node/OS 版本、模型 id、用例/工具数量、通过率区间、lint 规则命中数、是否 CI。**不记录**用例原文、工具描述、源码、文件路径或密钥。`mf stats` 会把本地运行与 CI 运行分开统计。
150
152
 
151
153
  ```bash
152
154
  mf stats # 看本机记录;--json 给脚本
@@ -165,7 +167,7 @@ mf feedback --out out.json # 导出脱敏反馈包(聚合值,不含原始
165
167
 
166
168
  ```bash
167
169
  # 1. 把 mf 挂成 MCP server
168
- claude mcp add modelfunction -- npx -y modelfunction-cli@0.4.4 mcp
170
+ claude mcp add modelfunction -- npx -y modelfunction-cli@0.5.0 mcp
169
171
  ```
170
172
 
171
173
  ```text
package/SCHEMA.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # Schemas v1
2
2
 
3
- 自 `modelfunction-cli@0.4.3` 起冻结为 v1;`0.4.4` 是同一 v1 的补丁;`0.5.0` 增加 §9 本地文件,仍属 v1。这里描述 CLI 与库对外的稳定契约。人类可读文本(表格、提示语)不属于契约;**JSON 字段**属于。
3
+ 自 `modelfunction-cli@0.4.3` 起冻结为 v1;`0.4.4` 是同一 v1 的补丁;`0.5.0` 增加 §9 本地文件、`0.5.1` 增加 §10 审计 JSON,仍属 v1。这里描述 CLI 与库对外的稳定契约。人类可读文本(表格、提示语)不属于契约;**JSON 字段**属于。
4
4
 
5
5
  ## 兼容政策
6
6
 
@@ -110,7 +110,7 @@ skills 只做静态检查,不参与模型评测。
110
110
 
111
111
  `--json` 输出 `{ "report": {...}, "draft": {...} }`;默认写 `mf.suite.json`(draft)与 `mf.scan.json`(report)。
112
112
 
113
- report:`root`、`tools[]`(`name` / `description` / `parameters` / `origin` / `confidence` / `source` / `note?` / `descriptionSource?`)、`skills[]`、`mcpServers[]`(env 只保留 `envKeys`)、`probes[]`、`sources[]`、`warnings[]`。
113
+ report:`root`、`tools[]`(`name` / `description` / `parameters` / `origin` / `confidence` / `source` / `note?` / `descriptionSource?`)、`skills[]`、`mcpServers[]`(env 只保留 `envKeys`,headers 只保留 `headerKeys`;URL 去掉 userinfo / query / hash)、`probes[]`(`server` / `ok` / `toolCount?` / `tools?` / `error?`)、`sources[]`、`warnings[]`。`--probe` 支持 stdio 与 streamable HTTP 两种传输。
114
114
 
115
115
  `scan --json` 在未找到任何工具时退出码为 3。
116
116
 
@@ -184,8 +184,9 @@ import { check } from "modelfunction-cli";
184
184
  | `scan_tools` | `root?`、`probe?`、`timeoutMs?`、`writeSuite?` |
185
185
  | `list_suite` | `suite?` |
186
186
  | `coverage` | `suite?` |
187
+ | `audit` | `suite?` |
187
188
  | `add_cases` | `suite?`、`cases[]`、`source?`、`reviewed?`、`dryRun?` |
188
- | `run_eval` | `suite?`、`models?`、`cases?`、`profile?`、`repeat?`、`failUnder?` |
189
+ | `run_eval` | `suite?`、`models?`、`cases?`、`profile?`、`repeat?`、`failUnder?`、`maxCost?`、`progress?` |
189
190
 
190
191
  返回值均为 JSON 文本。
191
192
 
@@ -195,7 +196,7 @@ import { check } from "modelfunction-cli";
195
196
  | --- | --- |
196
197
  | 0 | 成功(`mf check` 的 warn 也是 0) |
197
198
  | 1 | 输入/用法错误;`mf compare` 判定不可直接比较 |
198
- | 2 | `mf eval --fail-under` 未达标,或 `--fail-on-regression` 发现回退 |
199
+ | 2 | `mf eval --fail-under` 未达标、`--fail-on-regression` 发现回退,或 `--max-cost` 超限 |
199
200
  | 3 | `mf exec` 被权限策略拒绝;`mf scan --json` 未找到工具 |
200
201
  | 4 | `mf exec` 超时 |
201
202
 
@@ -226,6 +227,7 @@ import { check } from "modelfunction-cli";
226
227
  "platform": "win32",
227
228
  "arch": "x64",
228
229
  "node": "24.6.0",
230
+ "ci": false,
229
231
  "models": ["mock"],
230
232
  "caseCount": 13,
231
233
  "toolCount": 4,
@@ -241,7 +243,7 @@ import { check } from "modelfunction-cli";
241
243
  }
242
244
  ```
243
245
 
244
- 字段为白名单,只含计数、比率与 id;**不含**用例原文、工具描述、源码、文件路径或密钥。新增字段属于 v1 加法,消费方可忽略未知字段。`MF_NO_USAGE=1` 关闭写入;删除 `usage/` 即清空。
246
+ 字段为白名单,只含计数、比率与 id;**不含**用例原文、工具描述、源码、文件路径或密钥。`ci` 表示检测到 CI 环境(`CI=true/1` 或 `GITHUB_ACTIONS` / `GITLAB_CI` / `CIRCLECI` / `TRAVIS` / `BUILDKITE` / `TF_BUILD` / `JENKINS_URL` / `CNB_BUILD_ID`),用于把机器运行与本地运行分开;`mf stats` 分别统计。新增字段属于 v1 加法,消费方可忽略未知字段。`MF_NO_USAGE=1` 关闭写入;删除 `usage/` 即清空。
245
247
 
246
248
  ### `mf feedback` 包
247
249
 
@@ -260,3 +262,47 @@ import { check } from "modelfunction-cli";
260
262
  ```
261
263
 
262
264
  `usage` 是 `mf stats` 的聚合结果,不含原始事件、路径或密钥。是否发送由用户决定。
265
+
266
+ ## 10. 工具集审计(`mf audit --json`,0.5.1 起)
267
+
268
+ ```json
269
+ {
270
+ "suite": { "name": "…", "path": "…", "tools": 4, "skills": 0, "cases": 13, "reviewed": 13 },
271
+ "lint": {
272
+ "tools": 4,
273
+ "skills": 0,
274
+ "counts": { "error": 0, "warn": 6, "info": 0 },
275
+ "findings": [],
276
+ "profilePairs": []
277
+ },
278
+ "clusters": {
279
+ "threshold": 0.5,
280
+ "total": 4,
281
+ "clusters": [],
282
+ "riskyPairs": [],
283
+ "familyHints": [],
284
+ "boundaries": []
285
+ },
286
+ "coverage": {
287
+ "total": 13,
288
+ "negative": 1,
289
+ "negativeRatio": 0.08,
290
+ "unreviewed": 0,
291
+ "tools": [],
292
+ "pairs": [],
293
+ "profiles": [],
294
+ "warnings": []
295
+ },
296
+ "profiles": { "groups": [], "warnings": [], "unmounted": [] },
297
+ "actions": [
298
+ {
299
+ "kind": "boundary|param-source|cases|args|review|mount",
300
+ "subject": "…",
301
+ "message": "…",
302
+ "detail": "…"
303
+ }
304
+ ]
305
+ }
306
+ ```
307
+
308
+ `profiles` 为 `null` 表示套件未定义分组。`clusters`/`coverage` 的明细与 §5/§4 同源;`actions` 是给人看的优先级列表。`mf audit` 恒退出 0(advisory),不阻断流程。
package/lib/audit.js ADDED
@@ -0,0 +1,149 @@
1
+ // One static pass over a whole tool set: lint + cluster boundaries + case
2
+ // coverage + mount checks. No model calls, no execution, no network.
3
+ // The markdown output is meant to be forwarded as a trial deliverable.
4
+ import { lintSuite, formatLint } from "./lint.js";
5
+ import { clusterTools, formatClusters } from "./cluster.js";
6
+ import { computeCoverage, formatCoverage } from "./cases.js";
7
+ import { normalizeProfiles, profileWarnings, profileCaseCoverage } from "./profile.js";
8
+
9
+ export function auditSuite(suite) {
10
+ const lint = lintSuite(suite);
11
+ const clusters = clusterTools(suite.tools);
12
+ const coverage = computeCoverage(suite);
13
+ const profiles = normalizeProfiles(suite.profiles, suite.tools.map((tool) => tool.name));
14
+ const mounted = profiles ? new Set(profiles.flatMap((profile) => profile.tools)) : null;
15
+ const profile = profiles
16
+ ? {
17
+ groups: profileCaseCoverage(profiles, suite.cases),
18
+ warnings: profileWarnings(profiles, suite),
19
+ unmounted: suite.tools.map((tool) => tool.name).filter((name) => !mounted.has(name))
20
+ }
21
+ : null;
22
+
23
+ return {
24
+ suite: {
25
+ name: suite.name,
26
+ path: suite.path ?? null,
27
+ tools: suite.tools.length,
28
+ skills: suite.skills?.length ?? 0,
29
+ cases: suite.cases.length,
30
+ reviewed: suite.cases.filter((testCase) => testCase.reviewed !== false).length
31
+ },
32
+ lint: {
33
+ tools: lint.tools,
34
+ skills: lint.skills,
35
+ counts: lint.counts,
36
+ findings: lint.findings,
37
+ profilePairs: lint.profilePairs ?? []
38
+ },
39
+ clusters,
40
+ coverage,
41
+ profiles: profile,
42
+ actions: buildActions({ lint, clusters, coverage, profile })
43
+ };
44
+ }
45
+
46
+ function buildActions({ lint, clusters, coverage, profile }) {
47
+ const actions = [];
48
+
49
+ const families = (clusters.boundaries ?? [])
50
+ .filter((family) => family.size > 1)
51
+ .sort((a, b) => b.pairs.length - a.pairs.length)
52
+ .slice(0, 3);
53
+ for (const family of families) {
54
+ actions.push({
55
+ kind: "boundary",
56
+ subject: family.label,
57
+ message: `为「${family.label}」补选择边界(${family.pairs.length} 对互抢)`,
58
+ detail: family.suggestion
59
+ });
60
+ }
61
+
62
+ const unsourced = lint.findings.filter((finding) => finding.code === "required-unsourced");
63
+ for (const finding of unsourced.slice(0, 5)) {
64
+ actions.push({
65
+ kind: "param-source",
66
+ subject: `${finding.tool}.${finding.param}`,
67
+ message: `补参数来源:${finding.tool}.${finding.param}`,
68
+ detail: finding.message
69
+ });
70
+ }
71
+
72
+ const empty = coverage.tools.filter((tool) => tool.total === 0);
73
+ if (empty.length > 0) {
74
+ actions.push({
75
+ kind: "cases",
76
+ subject: `${empty.length} tools`,
77
+ message: `为 ${empty.length} 个工具补用例`,
78
+ detail: empty.map((tool) => tool.name).join(", ")
79
+ });
80
+ }
81
+ const noArgs = coverage.tools.filter((tool) => tool.total > 0 && tool.withArgs === 0);
82
+ if (noArgs.length > 0) {
83
+ actions.push({
84
+ kind: "args",
85
+ subject: `${noArgs.length} tools`,
86
+ message: `为 ${noArgs.length} 个工具补带参数的用例`,
87
+ detail: noArgs.map((tool) => tool.name).join(", ")
88
+ });
89
+ }
90
+ if (coverage.unreviewed > 0) {
91
+ actions.push({
92
+ kind: "review",
93
+ subject: `${coverage.unreviewed} cases`,
94
+ message: `复核 ${coverage.unreviewed} 条未复核用例(复核后才计入正式分数)`,
95
+ detail: "把认可的用例 reviewed 改为 true"
96
+ });
97
+ }
98
+ if (profile && profile.unmounted.length > 0) {
99
+ actions.push({
100
+ kind: "mount",
101
+ subject: `${profile.unmounted.length} tools`,
102
+ message: `${profile.unmounted.length} 个工具不在任何挂载组`,
103
+ detail: profile.unmounted.join(", ")
104
+ });
105
+ }
106
+
107
+ return actions;
108
+ }
109
+
110
+ export function formatAudit(report) {
111
+ const { suite, lint, clusters, coverage, profiles, actions } = report;
112
+ const lines = [
113
+ `# ${suite.name} — 工具集审计`,
114
+ "",
115
+ `- 工具 ${suite.tools} · skills ${suite.skills} · 用例 ${suite.cases}(已复核 ${suite.reviewed})`,
116
+ `- 文件:${suite.path ?? "(内存)"}`,
117
+ `- 纯静态:不调用模型、不执行工具、无网络`,
118
+ "",
119
+ "## 建议动作"
120
+ ];
121
+ if (actions.length === 0) {
122
+ lines.push("暂无必须处理项。");
123
+ } else {
124
+ actions.forEach((action, index) => {
125
+ lines.push(`${index + 1}. ${action.message}`);
126
+ if (action.detail) lines.push(` ${action.detail}`);
127
+ });
128
+ }
129
+
130
+ lines.push("", "## 描述与参数(lint)", "", formatLint(lint));
131
+ lines.push("", "## 混淆与分组(cluster)", "", formatClusters(clusters));
132
+ lines.push("", "## 用例覆盖(coverage)", "", formatCoverage(coverage));
133
+ lines.push("", "## 挂载分组(profiles)", "");
134
+ if (!profiles) {
135
+ lines.push("未定义 profiles(平台可先用 `mf cluster --out <suite>` 生成候选分组)。");
136
+ } else {
137
+ lines.push("group tools cases negatives tools-without-cases");
138
+ lines.push("---------------------- ----- ----- --------- -------------------");
139
+ for (const row of profiles.groups) {
140
+ lines.push(
141
+ `${row.name.padEnd(22)} ${String(row.tools).padStart(5)} ${String(row.cases).padStart(5)} ${String(row.negatives).padStart(9)} ${row.missing.length ? row.missing.join(", ") : "-"}`
142
+ );
143
+ }
144
+ if (profiles.unmounted.length > 0) lines.push("", `未挂载工具:${profiles.unmounted.join(", ")}`);
145
+ for (const warning of profiles.warnings) lines.push(`- ${warning}`);
146
+ }
147
+
148
+ return lines.join("\n");
149
+ }
package/lib/budget.js ADDED
@@ -0,0 +1,30 @@
1
+ // Running cost guard for evaluations. Pricing is known per model, but token
2
+ // usage is only known after a call returns, so the limit is enforced after
3
+ // each call: the run stops as soon as the budget is crossed.
4
+ export class CostLimitError extends Error {
5
+ constructor({ limit, spent, calls }) {
6
+ super(`cost limit reached: spent ¥${spent.toFixed(4)} of ¥${limit} after ${calls} call(s)`);
7
+ this.name = "CostLimitError";
8
+ this.limit = limit;
9
+ this.spent = spent;
10
+ this.calls = calls;
11
+ }
12
+ }
13
+
14
+ export function createBudget(limit = null) {
15
+ let spent = 0;
16
+ let calls = 0;
17
+ return {
18
+ get spent() {
19
+ return spent;
20
+ },
21
+ get calls() {
22
+ return calls;
23
+ },
24
+ spend(cost) {
25
+ calls += 1;
26
+ if (Number.isFinite(cost) && cost > 0) spent += cost;
27
+ if (limit !== null && spent > limit) throw new CostLimitError({ limit, spent, calls });
28
+ }
29
+ };
30
+ }
package/lib/mcp-probe.js CHANGED
@@ -1,10 +1,67 @@
1
1
  import { spawn } from "node:child_process";
2
+ import { readFileSync } from "node:fs";
3
+ import { Client } from "@modelcontextprotocol/sdk/client/index.js";
4
+ import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
2
5
 
3
6
  const MAX_BYTES = 1_000_000;
4
7
  const MAX_STDERR = 4_000;
5
- const CLIENT_INFO = { name: "mf", version: "0.1.0" };
8
+ const PACKAGE = JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8"));
9
+ const CLIENT_INFO = { name: "mf", version: PACKAGE.version };
6
10
  const PROTOCOL_VERSION = "2025-06-18";
7
11
 
12
+ // Streamable HTTP is the current remote MCP transport. Headers come from the
13
+ // user's own config and are never copied into reports or logs.
14
+ export async function probeHttpServer(server, { timeoutMs = 5_000 } = {}) {
15
+ if (!server?.url) return { ok: false, error: "missing url" };
16
+ let url;
17
+ try {
18
+ url = new URL(server.url);
19
+ } catch {
20
+ return { ok: false, error: "invalid url" };
21
+ }
22
+
23
+ const client = new Client(CLIENT_INFO);
24
+ const headers = server.headers && Object.keys(server.headers).length > 0 ? server.headers : null;
25
+ const transport = new StreamableHTTPClientTransport(url, {
26
+ requestInit: headers ? { headers } : undefined
27
+ });
28
+
29
+ try {
30
+ await withTimeout(client.connect(transport), timeoutMs, "connect");
31
+ const result = await withTimeout(client.listTools(), timeoutMs, "tools/list");
32
+ return { ok: true, tools: normalizeTools(result?.tools ?? []) };
33
+ } catch (error) {
34
+ return { ok: false, error: `http: ${scrubUrl(error?.message ?? error, url)}` };
35
+ } finally {
36
+ await client.close().catch(() => {});
37
+ }
38
+ }
39
+
40
+ function withTimeout(promise, ms, label) {
41
+ let id;
42
+ const timeout = new Promise((_, reject) => {
43
+ id = setTimeout(() => reject(new Error(`${label} timeout after ${ms}ms`)), ms);
44
+ });
45
+ return Promise.race([promise, timeout]).finally(() => clearTimeout(id));
46
+ }
47
+
48
+ function scrubUrl(message, url) {
49
+ let text = String(message ?? "");
50
+ text = text.split(url.href).join(`${url.origin}${url.pathname}`);
51
+ if (url.search) text = text.split(url.search).join("");
52
+ return text;
53
+ }
54
+
55
+ function normalizeTools(tools) {
56
+ return tools
57
+ .filter((tool) => typeof tool?.name === "string" && tool.name.trim() !== "")
58
+ .map((tool) => ({
59
+ name: tool.name.trim(),
60
+ description: tool.description ?? "",
61
+ parameters: tool.inputSchema ?? { type: "object", properties: {} }
62
+ }));
63
+ }
64
+
8
65
  export async function probeStdioServer(server, { timeoutMs = 5_000, cwd } = {}) {
9
66
  if (!server?.command) {
10
67
  return { ok: false, error: "missing command" };
@@ -129,14 +186,7 @@ export async function probeStdioServer(server, { timeoutMs = 5_000, cwd } = {})
129
186
  });
130
187
  send({ jsonrpc: "2.0", method: "notifications/initialized" });
131
188
  const result = await request("tools/list", {});
132
- const tools = (result?.tools ?? [])
133
- .filter((tool) => typeof tool?.name === "string" && tool.name.trim() !== "")
134
- .map((tool) => ({
135
- name: tool.name.trim(),
136
- description: tool.description ?? "",
137
- parameters: tool.inputSchema ?? { type: "object", properties: {} }
138
- }));
139
- return { ok: true, tools };
189
+ return { ok: true, tools: normalizeTools(result?.tools ?? []) };
140
190
  } catch (error) {
141
191
  return {
142
192
  ok: false,
package/lib/mcp-server.js CHANGED
@@ -8,8 +8,9 @@ import { z } from "zod";
8
8
  import { loadSuite } from "./suite.js";
9
9
  import { scanProject, addTools, buildSuiteDraft, redactReport } from "./scan.js";
10
10
  import { writeFileAtomic } from "./fsutil.js";
11
- import { probeStdioServer } from "./mcp-probe.js";
11
+ import { probeStdioServer, probeHttpServer } from "./mcp-probe.js";
12
12
  import { addCases, computeCoverage } from "./cases.js";
13
+ import { auditSuite } from "./audit.js";
13
14
 
14
15
  const MF_PATH = fileURLToPath(new URL("../mf.js", import.meta.url));
15
16
  const PACKAGE_JSON = new URL("../package.json", import.meta.url);
@@ -32,7 +33,7 @@ export async function startMcpServer() {
32
33
  {
33
34
  title: "Scan tools",
34
35
  description:
35
- "扫描仓库里的 function.json / OpenAPI / MCP 配置 / SKILL.md / 代码内定义。probe=true 时连接 stdio MCP server 拉取 tools/list。不执行工具。默认只读;传 writeSuite 时在该路径写出套件草稿(cases 为空,需补用例并复核;来源 ≥2 时草稿带按来源分组的 profiles,并列出不参与评测的 skills)。",
36
+ "扫描仓库里的 function.json / OpenAPI / MCP 配置 / SKILL.md / 代码内定义。probe=true 时连接 stdio 或 streamable-HTTP MCP server 拉取 tools/list(HTTP headers 只用于连接,不写入报告)。不执行工具。默认只读;传 writeSuite 时在该路径写出套件草稿(cases 为空,需补用例并复核;来源 ≥2 时草稿带按来源分组的 profiles,并列出不参与评测的 skills)。",
36
37
  inputSchema: {
37
38
  root: z.string().optional().describe("仓库目录,默认进程当前目录"),
38
39
  probe: z.boolean().optional().describe("是否连接 MCP server 拉取工具清单"),
@@ -44,11 +45,10 @@ export async function startMcpServer() {
44
45
  const report = await scanProject(root ?? process.cwd());
45
46
  if (probe) {
46
47
  for (const mcpServer of [...report.mcpServers]) {
47
- if (mcpServer.transport !== "stdio") {
48
- report.probes.push({ server: mcpServer.name, ok: false, error: "http transport 暂不支持" });
49
- continue;
50
- }
51
- const result = await probeStdioServer(mcpServer, { timeoutMs: timeoutMs ?? 5000 });
48
+ const result =
49
+ mcpServer.transport === "stdio"
50
+ ? await probeStdioServer(mcpServer, { timeoutMs: timeoutMs ?? 5000 })
51
+ : await probeHttpServer(mcpServer, { timeoutMs: timeoutMs ?? 5000 });
52
52
  if (!result.ok) {
53
53
  report.probes.push({ server: mcpServer.name, ok: false, error: result.error });
54
54
  continue;
@@ -109,6 +109,22 @@ export async function startMcpServer() {
109
109
  async ({ suite }) => json(computeCoverage(await loadSuite(suitePath(suite))))
110
110
  );
111
111
 
112
+ server.registerTool(
113
+ "audit",
114
+ {
115
+ title: "Audit tool set",
116
+ description:
117
+ "一次静态审计整个工具集:lint 发现、混淆族与边界建议、用例覆盖、挂载检查。不调用模型、不执行工具、无网络。返回 JSON。",
118
+ inputSchema: { suite: z.string().optional().describe("套件路径,默认 ./mf.suite.json") }
119
+ },
120
+ async ({ suite }) =>
121
+ json(
122
+ auditSuite(
123
+ await loadSuite(suitePath(suite), { allowEmptyCases: true, allowEmptyTools: true })
124
+ )
125
+ )
126
+ );
127
+
112
128
  server.registerTool(
113
129
  "add_cases",
114
130
  {
@@ -154,16 +170,20 @@ export async function startMcpServer() {
154
170
  .max(10)
155
171
  .optional()
156
172
  .describe("每条用例重复次数,取多数结果;建议正式基线用 3"),
157
- failUnder: z.number().optional().describe("选择准确率阈值,低于则退出码为 2")
173
+ failUnder: z.number().optional().describe("选择准确率阈值,低于则退出码为 2"),
174
+ maxCost: z.number().positive().optional().describe("累计估算成本上限(CNY),超限退出 2 且不返回部分评分卡"),
175
+ progress: z.boolean().optional().describe("把逐用例进度写到 stderr(stdout 仍为纯 JSON)")
158
176
  }
159
177
  },
160
- async ({ suite, models, cases, profile, repeat, failUnder }) => {
178
+ async ({ suite, models, cases, profile, repeat, failUnder, maxCost, progress }) => {
161
179
  const args = ["eval", "--json", "--suite", suitePath(suite)];
162
180
  if (models) args.push("--models", models);
163
181
  if (cases) args.push("--case", cases);
164
182
  if (profile) args.push("--profile", profile);
165
183
  if (repeat !== undefined) args.push("--repeat", String(repeat));
166
184
  if (failUnder !== undefined) args.push("--fail-under", String(failUnder));
185
+ if (maxCost !== undefined) args.push("--max-cost", String(maxCost));
186
+ if (progress) args.push("--progress");
167
187
 
168
188
  const result = await runCli(args);
169
189
  let scorecard = null;
package/lib/scan.js CHANGED
@@ -337,9 +337,11 @@ export function addTools(
337
337
  export function redactReport(report) {
338
338
  return {
339
339
  ...report,
340
- mcpServers: report.mcpServers.map(({ env, ...rest }) => ({
340
+ mcpServers: report.mcpServers.map(({ env, headers, ...rest }) => ({
341
341
  ...rest,
342
- envKeys: env ? Object.keys(env) : []
342
+ url: rest.url ? sanitizeUrl(rest.url) : null,
343
+ envKeys: env ? Object.keys(env) : [],
344
+ headerKeys: headers ? Object.keys(headers).sort() : []
343
345
  }))
344
346
  };
345
347
  }
@@ -526,10 +528,26 @@ function mcpServersFrom(config) {
526
528
  command: value.command ?? null,
527
529
  args: Array.isArray(value.args) ? value.args : [],
528
530
  env: value.env && typeof value.env === "object" ? value.env : null,
531
+ headers: value.headers && typeof value.headers === "object" ? value.headers : null,
529
532
  url: value.url ?? null
530
533
  }));
531
534
  }
532
535
 
536
+ // Remote MCP configs often carry tokens in the query string; keep the report
537
+ // safe to share while the live server object still probes the full URL.
538
+ function sanitizeUrl(raw) {
539
+ try {
540
+ const url = new URL(raw);
541
+ url.username = "";
542
+ url.password = "";
543
+ url.search = "";
544
+ url.hash = "";
545
+ return url.toString();
546
+ } catch {
547
+ return null;
548
+ }
549
+ }
550
+
533
551
  function toolsFromOpenApi(spec) {
534
552
  const tools = [];
535
553
  const paths = spec.paths ?? {};
package/lib/score.js CHANGED
@@ -76,6 +76,19 @@ export function pickRepresentative(runs) {
76
76
  };
77
77
  }
78
78
 
79
+ export function costCnyOf(usage, pricing) {
80
+ if (!pricing) return 0;
81
+ const input = usage?.input ?? 0;
82
+ const cached = Math.min(input, usage?.cached ?? 0);
83
+ const output = usage?.output ?? 0;
84
+ return (
85
+ ((input - cached) * (pricing.input ?? 0) +
86
+ cached * (pricing.cachedInput ?? pricing.input ?? 0) +
87
+ output * (pricing.output ?? 0)) /
88
+ 1e6
89
+ );
90
+ }
91
+
79
92
  export function scoreModel({ model, cases, results, pricing }) {
80
93
  const rows = cases.map((testCase, index) => {
81
94
  const result = results[index];
@@ -115,13 +128,7 @@ export function scoreModel({ model, cases, results, pricing }) {
115
128
  inputTokens,
116
129
  rows.reduce((sum, row) => sum + (row.usage.cached ?? 0), 0)
117
130
  );
118
- const uncachedTokens = inputTokens - cachedTokens;
119
- const costCny = pricing
120
- ? (uncachedTokens * (pricing.input ?? 0) +
121
- cachedTokens * (pricing.cachedInput ?? pricing.input ?? 0) +
122
- outputTokens * (pricing.output ?? 0)) /
123
- 1e6
124
- : 0;
131
+ const costCny = rows.reduce((sum, row) => sum + costCnyOf(row.usage, pricing), 0);
125
132
  const reviewedTotal = reviewedRows.length;
126
133
 
127
134
  return {
package/lib/usage.js CHANGED
@@ -6,6 +6,16 @@ import { configDir } from "./credentials.js";
6
6
  // Events are redacted by construction: only counts, ids and ratios leave the
7
7
  // process. Never add free-form text (inputs, descriptions, paths, errors).
8
8
  const OPT_OUT_ENV = "MF_NO_USAGE";
9
+ const CI_ENV_KEYS = [
10
+ "GITHUB_ACTIONS",
11
+ "GITLAB_CI",
12
+ "CIRCLECI",
13
+ "TRAVIS",
14
+ "BUILDKITE",
15
+ "TF_BUILD",
16
+ "JENKINS_URL",
17
+ "CNB_BUILD_ID"
18
+ ];
9
19
 
10
20
  let pendingDetails = null;
11
21
 
@@ -75,10 +85,16 @@ export function buildRecord({ command, exitCode, durationMs, version }) {
75
85
  platform: process.platform,
76
86
  arch: process.arch,
77
87
  node: process.versions.node,
88
+ ci: detectCi(),
78
89
  ...(pendingDetails ?? {})
79
90
  };
80
91
  }
81
92
 
93
+ function detectCi() {
94
+ if (process.env.CI === "true" || process.env.CI === "1") return true;
95
+ return CI_ENV_KEYS.some((key) => Boolean(process.env[key]));
96
+ }
97
+
82
98
  export async function readUsageEvents() {
83
99
  let files = [];
84
100
  try {
@@ -112,6 +128,8 @@ export function summarizeUsage(events) {
112
128
  firstRun: events[0]?.ts ?? null,
113
129
  lastRun: events.at(-1)?.ts ?? null,
114
130
  activeDays: new Set(events.map((event) => event.ts.slice(0, 10))).size,
131
+ ciRuns: 0,
132
+ localRuns: 0,
115
133
  exitCodes: {},
116
134
  commands: {},
117
135
  platforms: {},
@@ -128,6 +146,8 @@ export function summarizeUsage(events) {
128
146
  const args = [];
129
147
 
130
148
  for (const event of events) {
149
+ if (event.ci) summary.ciRuns += 1;
150
+ else summary.localRuns += 1;
131
151
  bump(summary.exitCodes, String(event.exitCode ?? 0));
132
152
  bump(summary.commands, event.command ?? "unknown");
133
153
  if (event.platform) bump(summary.platforms, event.platform);
@@ -198,7 +218,7 @@ export function formatStats(summary, { events = 0, files = 0, skipped = 0, path
198
218
  }
199
219
  const lines = [
200
220
  `local usage: ${path} (${events} events, ${files} file(s)${skipped > 0 ? `, ${skipped} unreadable line(s)` : ""})`,
201
- `runs: ${summary.runs} first: ${summary.firstRun} last: ${summary.lastRun} active days: ${summary.activeDays}`,
221
+ `runs: ${summary.runs} (local ${summary.localRuns}, ci ${summary.ciRuns}) first: ${summary.firstRun} last: ${summary.lastRun} active days: ${summary.activeDays}`,
202
222
  `exit codes: ${pairs(summary.exitCodes)}`,
203
223
  `commands: ${pairs(summary.commands)}`
204
224
  ];
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
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,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
78
  mf check --manifest <file> [--installed <file>] [--json] # 安装门禁:新能力 × 已装能力
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));
@@ -719,25 +799,47 @@ function autoSelect(registry) {
719
799
  .map(([id]) => id);
720
800
  }
721
801
 
722
- 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
+ ) {
723
809
  const results = [];
724
810
  const unstable = [];
725
- for (const testCase of cases) {
811
+ for (const [index, testCase] of cases.entries()) {
726
812
  const runs = [];
727
813
  for (let attempt = 0; attempt < repeat; attempt += 1) {
728
- 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);
729
817
  }
730
818
  if (repeat === 1) {
731
819
  results.push(runs[0]);
732
- 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 });
733
824
  }
734
- const picked = pickRepresentative(runs);
735
- results.push(picked.result);
736
- if (picked.unstable) unstable.push({ id: testCase.id, outcomes: picked.outcomes });
825
+ onProgress?.(index + 1, cases.length);
737
826
  }
738
827
  return { results, stability: repeat > 1 ? { runs: repeat, unstable } : null };
739
828
  }
740
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
+
741
843
  async function callOnce(adapter, tools, testCase) {
742
844
  const messages = [
743
845
  { role: "system", content: SYSTEM_PROMPT },
@@ -782,7 +884,17 @@ function ablationScope(suite, names) {
782
884
  return { name: profile.name, tools: suite.tools.filter((tool) => mounted.has(tool.name)) };
783
885
  }
784
886
 
785
- 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
+ }) {
786
898
  const profiles = normalizeProfiles(suite.profiles, suite.tools.map((tool) => tool.name));
787
899
  if (!profiles) {
788
900
  throw new Error(`suite ${suite.name} 没有 profiles;先运行 mf cluster --out <suite> 生成分组`);
@@ -812,7 +924,11 @@ async function evaluateProfiles({ suite, cases, runs, registry, selection, repea
812
924
  const profileCases = caseSets[index];
813
925
  if (profileCases.length === 0) continue;
814
926
  const mountedTools = suite.tools.filter((tool) => profile.tools.includes(tool.name));
815
- 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
+ });
816
932
  const mounted = scoreModel({
817
933
  model: run.adapter.id,
818
934
  cases: profileCases,
@@ -875,6 +991,8 @@ function parseArgs(argv) {
875
991
  cases: null,
876
992
  profiles: null,
877
993
  repeat: 1,
994
+ maxCost: null,
995
+ progress: false,
878
996
  showCases: false,
879
997
  json: false,
880
998
  out: null,
@@ -932,6 +1050,13 @@ function parseArgs(argv) {
932
1050
  options.repeat = raw;
933
1051
  break;
934
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;
935
1060
  case "--show-cases": options.showCases = true; break;
936
1061
  case "--json": options.json = true; break;
937
1062
  case "--out": options.out = value(); break;
@@ -1104,7 +1229,7 @@ if (isMain) {
1104
1229
  }
1105
1230
  } else if (argv.length === 0 || argv[0] === "help" || argv[0] === "-h" || argv[0] === "--help") {
1106
1231
  process.stdout.write(USAGE);
1107
- } 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])) {
1108
1233
  process.stderr.write(`unknown command "${argv[0]}"\n\n${USAGE}`);
1109
1234
  process.exit(1);
1110
1235
  } else if (argv[0] === "case" && argv[1] !== "add") {
@@ -1126,6 +1251,8 @@ if (isMain) {
1126
1251
  ? await runCaseAdd(options)
1127
1252
  : argv[0] === "coverage"
1128
1253
  ? await runCoverage(options)
1254
+ : argv[0] === "audit"
1255
+ ? await runAudit(options)
1129
1256
  : argv[0] === "lint"
1130
1257
  ? await runLint(options)
1131
1258
  : 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.1",
4
4
  "type": "module",
5
5
  "license": "MIT",
6
6
  "description": "mf — function-calling accuracy scorecard for AI agents",