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 +10 -8
- package/SCHEMA.md +51 -5
- package/lib/audit.js +149 -0
- package/lib/budget.js +30 -0
- package/lib/mcp-probe.js +59 -9
- package/lib/mcp-server.js +29 -9
- package/lib/scan.js +20 -2
- package/lib/score.js +14 -7
- package/lib/usage.js +21 -1
- package/mf.js +168 -41
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
## 安装
|
|
6
6
|
|
|
7
7
|
```bash
|
|
8
|
-
npx -y modelfunction-cli@0.
|
|
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
|
|
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` |
|
|
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.
|
|
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
|
-
|
|
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.
|
|
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
|
|
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
|
|
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`
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
48
|
-
|
|
49
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
139
|
-
const
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
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
|
-
|
|
177
|
-
|
|
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
|
-
|
|
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
|
-
|
|
362
|
-
const
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
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
|
-
|
|
403
|
-
|
|
404
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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({
|
|
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"
|