modelfunction-cli 0.5.3 → 0.5.5

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.5.1 --version
8
+ npx -y modelfunction-cli@0.5.5 --version
9
9
  # 或
10
10
  npm i -D modelfunction-cli
11
11
  npx mf --version
@@ -76,7 +76,7 @@ INTERNAL_LLM_KEY=... mf eval --suite mf.suite.json \
76
76
  | 命令 | 作用 |
77
77
  | --- | --- |
78
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
+ | `mf eval` | 跑评分卡;`--repeat` 多次取多数并报不稳定用例;`--fail-under` 阈值;`--baseline` + `--fail-on-regression` 回归门禁;`--profile` 按挂载组分别评分;`--max-cost` 按累计成本停机(退出 2);`--retry` 429/5xx 重试次数;`--min-interval` 请求最小间隔(压 RPM);`--progress` 逐用例进度(stderr) |
80
80
  | `mf case add` | 写入用例(默认 `source=agent`、`reviewed=false`) |
81
81
  | `mf coverage` | 每工具用例数、参数覆盖、负例比例、疑似混淆对;有 `profiles` 时按组统计 |
82
82
  | `mf audit` | 一次静态审计:lint + 混淆族边界 + 用例覆盖 + 挂载检查,输出可转发的 Markdown/JSON;不调用模型 |
@@ -126,19 +126,19 @@ mf eval --suite mf.suite.json --models internal-ds \
126
126
  "mcpServers": {
127
127
  "modelfunction": {
128
128
  "command": "npx",
129
- "args": ["-y", "modelfunction-cli@0.5.1", "mcp"]
129
+ "args": ["-y", "modelfunction-cli@0.5.5", "mcp"]
130
130
  }
131
131
  }
132
132
  }
133
133
  ```
134
134
 
135
- 暴露:`scan_tools`、`list_suite`、`coverage`、`audit`、`lint`、`check`、`cluster`、`compare`、`add_cases`、`run_eval`(`run_eval` 支持 `profile`、`repeat`、`maxCost`、`progress`)。`lint`/`check`/`cluster`/`compare` 都是静态操作,不调用模型;`check` 就是安装门禁,Agent 可直接传入 manifest 与 installed。
135
+ 暴露:`scan_tools`、`list_suite`、`coverage`、`audit`、`lint`、`check`、`cluster`、`compare`、`add_cases`、`run_eval`(`run_eval` 支持 `profile`、`repeat`、`maxCost`、`retry`、`minInterval`、`progress`)。`lint`/`check`/`cluster`/`compare` 都是静态操作,不调用模型;`check` 就是安装门禁,Agent 可直接传入 manifest 与 installed。
136
136
 
137
137
  ## 契约与版本
138
138
 
139
139
  对外 JSON 字段、`check()` 返回结构、MCP 参数与退出码见 [SCHEMA.md](SCHEMA.md),自 0.4.3 起冻结为 v1:v1 内只做加法,重命名或删除字段需 major 并提前两个 minor 或 90 天弃用。`check()` 的类型声明随包发布。
140
140
 
141
- `0.5.2` 已发布到 npm:含凭证与本地反馈、usage `ci` 标记、eval `--progress`/`--max-cost`、`mf audit`、`scan --probe` streamable HTTP、MCP 静态工具 `lint`/`check`/`cluster`/`compare`、`checkMany` 一包批量检查,门禁 warn 只保留混淆对/必填来源/结构性错误。本仓库 `0.5.3` 待发布:静态规则性能(真实 100 能力清单 `checkMany` 471ms→40ms,550 能力 9.6s→2.1s,输出与 golden 一致),以及 `installed` 形状校验(接受 `mf scan --json` 输出,无法识别时报错而不是静默返回假 pass)和 `profiles` 兼容平台形态(对象或 `[{agentType, tools, skills}]` 数组)。示例统一使用 `@0.5.2`。
141
+ `0.5.5`(待 npm 发布):eval 新增 `--retry <n>`(429/5xx/网络错误指数退避,默认 2,共 3 次尝试;优先采用服务端 `Retry-After`)与 `--min-interval <ms>`(请求最小间隔,用于压住端点 RPM),评分卡新增 `retries` 计数;CI 模板 static 层补上 `cluster --json`,与 `action.yml` 对齐;新增 `templates/local/` 与 `docs/local-gate.md`(内网/非 GitHub 环境的 pre-push 静态门禁、变更门、定时回归)。`0.5.4` 含用例级 `excludeFromBase` / `excludeFromProfiles`、评分卡 `errorKind` / `errorsByKind` / `scoredTotal` / `selectionAccuracyExcludingErrors`、挂载对比错误不计 jammed、audit 分组 `neg%` 与分组 token 用量聚合。`0.5.3` 及更早的能力见下:凭证与本地反馈、usage `ci` 标记、eval `--progress`/`--max-cost`、`mf audit`、`scan --probe` streamable HTTP、MCP 静态工具 `lint`/`check`/`cluster`/`compare`、`checkMany` 一包批量检查、门禁严重度收敛、静态规则性能(真实 100 能力清单 `checkMany` 471ms→40ms)、`installed` 形状校验与 `profiles` 平台形态兼容。示例统一使用 `@0.5.5`。
142
142
 
143
143
  ## 数据边界
144
144
 
@@ -167,7 +167,7 @@ mf feedback --out out.json # 导出脱敏反馈包(聚合值,不含原始
167
167
 
168
168
  ```bash
169
169
  # 1. 把 mf 挂成 MCP server
170
- claude mcp add modelfunction -- npx -y modelfunction-cli@0.5.1 mcp
170
+ claude mcp add modelfunction -- npx -y modelfunction-cli@0.5.5 mcp
171
171
  ```
172
172
 
173
173
  ```text
package/SCHEMA.md CHANGED
@@ -51,6 +51,8 @@
51
51
  "note": "",
52
52
  "source": "manual",
53
53
  "reviewed": true,
54
+ "excludeFromBase": false,
55
+ "excludeFromProfiles": false,
54
56
  "tags": []
55
57
  }
56
58
  ```
@@ -59,6 +61,7 @@
59
61
  - `expectedArgs` 仅在有 `expectedTool` 时可用;值为断言,支持字面量、`$contains`、`$oneOf`、`$exists`。
60
62
  - `toolInput` + `expect` 用于 `--execute`:`expect` 支持 `ok`、`contains`、`equals`、`errorType`、`maxDurationMs`、`maxPeakRssMB`。
61
63
  - `reviewed` 缺省为 `true`(手写用例);`mf case add` 缺省写入 `source=agent`、`reviewed=false`。
64
+ - `excludeFromBase` / `excludeFromProfiles` 必须是布尔值。用例仍会实际调用(不省 token),只是不计入对应范围的正式指标;用于「该用例只对某一挂载形态有意义」的情况。
62
65
 
63
66
  ### skills[]
64
67
 
@@ -89,21 +92,26 @@ skills 只做静态检查,不参与模型评测。
89
92
  "fingerprint": "sha256:...",
90
93
  "execution": { "runtime": "node", "engine": "node-permission", "entry": "index.mjs", "hash": "..." }
91
94
  },
92
- "models": [ { "model": "dsv4-flash", "total": 12, "reviewedTotal": 12, "selectionAccuracy": 0.83,
93
- "argAccuracy": 0.9, "passRate": 0.75, "errors": 0, "p50LatencyMs": 900,
95
+ "models": [ { "model": "dsv4-flash", "total": 12, "reviewedTotal": 12, "excludedTotal": 0,
96
+ "selectionAccuracy": 0.83, "selectionAccuracyExcludingErrors": 0.83,
97
+ "argAccuracy": 0.9, "passRate": 0.75, "errors": 0, "errorsByKind": {},
98
+ "scoredTotal": 12, "retries": 0, "p50LatencyMs": 900,
94
99
  "p95LatencyMs": 1800, "avgCostCny": 0.001, "totalCostCny": 0.012,
95
100
  "inputTokens": 1000, "cachedTokens": 0, "outputTokens": 200,
96
101
  "rows": [ { "id": "c1", "expectedTool": "...", "actualTool": "...",
97
102
  "selectionCorrect": true, "args": { "total": 1, "matched": 1, "failures": [] },
98
103
  "passed": true, "reviewed": true, "source": "manual",
99
- "latencyMs": 900, "usage": { "input": 80, "output": 20 }, "error": null } ] } ],
104
+ "latencyMs": 900, "usage": { "input": 80, "output": 20 }, "error": null,
105
+ "retries": 1 } ] } ],
100
106
  "profiles": { "groups": [ ... ] }
101
107
  }
102
108
  ```
103
109
 
104
- - 正式指标只统计 `reviewed` 行;未复核结果单独给出。
110
+ - 正式指标只统计 `reviewed` 且未被 `excludeFromBase` 排除的行;未复核与排除结果单独给出。
111
+ - `errors` 是调用层失败(超时、HTTP、网络)的数量,按 `errorsByKind` 分类;它们仍计入 `selectionAccuracy`(保守口径),同时给出排除错误后的 `selectionAccuracyExcludingErrors` 与可评分行数 `scoredTotal`。
112
+ - `retries` 是本轮因 429/5xx/网络错误而重试的请求次数(模型级汇总;行级只在大于 0 时出现)。重试不改变判分:最终成功的请求按正常结果计分,最终失败的仍计入 `errors`。
105
113
  - `--repeat` 时模型对象带 `stability`(翻转用例与多次结果分布)。
106
- - `--profile` 时每个组含 `profile`、`mounted`、`allInOne`、`delta`、`lifted`、`jammed`。
114
+ - `--profile` 时每个组含 `profile`、`mounted`、`allInOne`、`delta`、`lifted`、`jammed`、`errors`;错误行不参与挂载对比(不会被记成 jammed),每个组的 `reviewed` 是可评分行数。
107
115
  - `fingerprint` 由工具与用例内容计算;`mf compare` 用它判断两份报告是否可比。
108
116
 
109
117
  ## 3. 扫描输出(`mf scan`)
@@ -338,4 +346,4 @@ const result = checkMany({ manifests: [/* ... */], installed: [/* ... */] });
338
346
  }
339
347
  ```
340
348
 
341
- `profiles` 为 `null` 表示套件未定义分组。`clusters`/`coverage` 的明细与 §5/§4 同源;`actions` 是给人看的优先级列表。`mf audit` 恒退出 0(advisory),不阻断流程。
349
+ `profiles` 为 `null` 表示套件未定义分组。`clusters`/`coverage` 的明细与 §5/§4 同源;`actions` 是给人看的优先级列表。`mf audit` 恒退出 0(advisory),不阻断流程;负例占比告警只按整 suite 计算,分组表里的 `negativeRatio` 是该组「正例 + 负例」内的比例,仅供参考。
@@ -1,5 +1,6 @@
1
1
  const TIMEOUT_MS = 60_000;
2
- const MAX_ATTEMPTS = 3;
2
+ const DEFAULT_RETRIES = 2;
3
+ const MAX_RETRY_DELAY_MS = 30_000;
3
4
 
4
5
  export function createOpenAiAdapter(config) {
5
6
  const apiKey = process.env[config.apiKeyEnv];
@@ -7,13 +8,25 @@ export function createOpenAiAdapter(config) {
7
8
  throw new Error(`${config.id}: environment variable ${config.apiKeyEnv} is not set`);
8
9
  }
9
10
  const endpoint = `${config.baseUrl.replace(/\/+$/, "")}/chat/completions`;
11
+ const retries = clampInt(config.retries, 0, 10, DEFAULT_RETRIES);
12
+ const minIntervalMs = clampInt(config.minIntervalMs, 0, 60_000, 0);
13
+ let lastRequestAt = 0;
14
+
15
+ async function throttle() {
16
+ if (minIntervalMs <= 0) return;
17
+ const wait = lastRequestAt + minIntervalMs - Date.now();
18
+ if (wait > 0) await sleep(wait);
19
+ lastRequestAt = Date.now();
20
+ }
10
21
 
11
22
  return {
12
23
  label: config.model,
13
24
  async call({ messages, tools }) {
14
25
  const started = performance.now();
15
26
  let lastError = "request failed";
16
- for (let attempt = 1; attempt <= MAX_ATTEMPTS; attempt += 1) {
27
+ for (let attempt = 1; attempt <= retries + 1; attempt += 1) {
28
+ const retried = attempt - 1;
29
+ await throttle();
17
30
  try {
18
31
  const response = await fetch(endpoint, {
19
32
  method: "POST",
@@ -33,13 +46,16 @@ export function createOpenAiAdapter(config) {
33
46
 
34
47
  if (response.status === 429 || response.status >= 500) {
35
48
  lastError = `HTTP ${response.status}: ${(await response.text()).slice(0, 300)}`;
36
- await sleep(attempt * 1000);
49
+ if (attempt <= retries) {
50
+ await sleep(retryDelayMs(response.headers.get("retry-after"), retried));
51
+ }
37
52
  continue;
38
53
  }
39
54
  if (!response.ok) {
40
55
  const body = await response.text();
41
56
  return {
42
57
  ok: false,
58
+ retries: retried,
43
59
  error: `HTTP ${response.status}: ${body.slice(0, 300)}`,
44
60
  toolCalls: [],
45
61
  usage: { input: 0, output: 0 },
@@ -55,6 +71,7 @@ export function createOpenAiAdapter(config) {
55
71
  }));
56
72
  return {
57
73
  ok: true,
74
+ retries: retried,
58
75
  toolCalls,
59
76
  usage: {
60
77
  input: data.usage?.prompt_tokens ?? 0,
@@ -66,11 +83,12 @@ export function createOpenAiAdapter(config) {
66
83
  };
67
84
  } catch (error) {
68
85
  lastError = error?.name === "TimeoutError" ? `timeout after ${TIMEOUT_MS}ms` : String(error?.message ?? error);
69
- if (attempt < MAX_ATTEMPTS) await sleep(attempt * 1000);
86
+ if (attempt <= retries) await sleep(retryDelayMs(null, retried));
70
87
  }
71
88
  }
72
89
  return {
73
90
  ok: false,
91
+ retries,
74
92
  error: lastError,
75
93
  toolCalls: [],
76
94
  usage: { input: 0, output: 0 },
@@ -80,6 +98,22 @@ export function createOpenAiAdapter(config) {
80
98
  };
81
99
  }
82
100
 
101
+ function retryDelayMs(retryAfter, retried) {
102
+ const header = retryAfter == null || String(retryAfter).trim() === "" ? NaN : Number(retryAfter);
103
+ if (Number.isFinite(header) && header >= 0) {
104
+ return Math.min(header * 1000, MAX_RETRY_DELAY_MS);
105
+ }
106
+ return Math.min(1000 * 2 ** retried, MAX_RETRY_DELAY_MS);
107
+ }
108
+
109
+ function clampInt(value, min, max, fallback) {
110
+ const parsed = Number(value);
111
+ if (!Number.isFinite(parsed)) return fallback;
112
+ const rounded = Math.floor(parsed);
113
+ if (rounded < min || rounded > max) return fallback;
114
+ return rounded;
115
+ }
116
+
83
117
  function parseArguments(raw) {
84
118
  if (raw === undefined || raw === null || raw === "") return {};
85
119
  if (typeof raw === "object") return raw;
package/lib/audit.js CHANGED
@@ -134,11 +134,11 @@ export function formatAudit(report) {
134
134
  if (!profiles) {
135
135
  lines.push("未定义 profiles(平台可先用 `mf cluster --out <suite>` 生成候选分组)。");
136
136
  } else {
137
- lines.push("group tools cases negatives tools-without-cases");
138
- lines.push("---------------------- ----- ----- --------- -------------------");
137
+ lines.push("group tools cases negatives neg% tools-without-cases");
138
+ lines.push("---------------------- ----- ----- --------- ---- -------------------");
139
139
  for (const row of profiles.groups) {
140
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(", ") : "-"}`
141
+ `${row.name.padEnd(22)} ${String(row.tools).padStart(5)} ${String(row.cases).padStart(5)} ${String(row.negatives).padStart(9)} ${`${Math.round((row.negativeRatio ?? 0) * 100)}%`.padStart(4)} ${row.missing.length ? row.missing.join(", ") : "-"}`
142
142
  );
143
143
  }
144
144
  if (profiles.unmounted.length > 0) lines.push("", `未挂载工具:${profiles.unmounted.join(", ")}`);
package/lib/cases.js CHANGED
@@ -185,12 +185,12 @@ export function formatCoverage(report) {
185
185
  );
186
186
  }
187
187
  if (report.profiles && report.profiles.length > 0) {
188
- lines.push("", "profiles(分组后可评分的用例;负例每组都跑):");
189
- lines.push("group tools cases neg tools-without-cases");
190
- lines.push("---------------------- ----- ----- --- -------------------");
188
+ lines.push("", "profiles(分组后可评分的用例;负例每组都跑,比例按组内正例+负例计):");
189
+ lines.push("group tools cases neg neg% tools-without-cases");
190
+ lines.push("---------------------- ----- ----- --- ---- -------------------");
191
191
  for (const row of report.profiles) {
192
192
  lines.push(
193
- `${row.name.padEnd(22)} ${String(row.tools).padStart(5)} ${String(row.cases).padStart(5)} ${String(row.negatives).padStart(3)} ${row.missing.length ? row.missing.join(", ") : "-"}`
193
+ `${row.name.padEnd(22)} ${String(row.tools).padStart(5)} ${String(row.cases).padStart(5)} ${String(row.negatives).padStart(3)} ${`${Math.round((row.negativeRatio ?? 0) * 100)}%`.padStart(4)} ${row.missing.length ? row.missing.join(", ") : "-"}`
194
194
  );
195
195
  }
196
196
  }
package/lib/exec.js CHANGED
@@ -1,4 +1,5 @@
1
1
  import { spawn } from "node:child_process";
2
+ import { realpathSync } from "node:fs";
2
3
  import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
3
4
  import { tmpdir } from "node:os";
4
5
  import { dirname, join, resolve } from "node:path";
@@ -13,18 +14,19 @@ const ENV_KEEP = ["PATH", "SystemRoot", "SYSTEMROOT", "TEMP", "TMP", "HOME", "US
13
14
  export async function executeTool(options) {
14
15
  const runtime = options.runtime ?? "node";
15
16
  const engine = runtime === "python" ? "python-audit" : "node-permission";
16
- const entry = resolve(options.entry);
17
+ const entry = realpathAllow(options.entry);
17
18
  const nodeMajor = Number(process.versions.node.split(".")[0]);
18
19
  if (runtime === "node" && options.allowNet?.length && nodeMajor < 25) {
19
20
  throw new Error("--allow-net 需要 Node >= 25(Node 24 的权限模型不限制网络)");
20
21
  }
21
22
 
22
23
  let tempDir = null;
23
- let inputPath = options.inputPath ? resolve(options.inputPath) : null;
24
+ let inputPath = options.inputPath ? realpathAllow(options.inputPath) : null;
24
25
  if (!inputPath) {
25
26
  tempDir = await mkdtemp(join(tmpdir(), "mf-exec-"));
26
27
  inputPath = join(tempDir, "input.json");
27
28
  await writeFile(inputPath, options.inputJson ?? "{}", "utf8");
29
+ inputPath = realpathAllow(inputPath);
28
30
  }
29
31
 
30
32
  try {
@@ -57,11 +59,11 @@ export async function executeTool(options) {
57
59
  dirname(inputPath),
58
60
  process.cwd(),
59
61
  ...(options.allowRead ?? [])
60
- ].map((path) => resolve(path));
62
+ ].map((path) => realpathAllow(path));
61
63
  args = [permissionFlag()];
62
64
  for (const path of dedupe(readPaths)) args.push(`--allow-fs-read=${path}`);
63
65
  if (options.allowWrite?.length) {
64
- for (const path of dedupe(options.allowWrite.map((item) => resolve(item)))) {
66
+ for (const path of dedupe(options.allowWrite.map((item) => realpathAllow(item)))) {
65
67
  args.push(`--allow-fs-write=${path}`);
66
68
  }
67
69
  }
@@ -264,3 +266,14 @@ function formatExec(entry, result) {
264
266
  if (result.permissionDenied) lines.push(" note: 权限模型拒绝了文件/网络访问,可用于推断沙箱需求");
265
267
  return lines.join("\n");
266
268
  }
269
+
270
+ // Node's permission model matches real paths. On macOS /var is a symlink to
271
+ // /private/var, so an unresolved tmpdir path in --allow-fs-read is rejected.
272
+ function realpathAllow(path) {
273
+ const abs = resolve(path);
274
+ try {
275
+ return realpathSync.native ? realpathSync.native(abs) : realpathSync(abs);
276
+ } catch {
277
+ return abs;
278
+ }
279
+ }
package/lib/mcp-server.js CHANGED
@@ -248,10 +248,12 @@ export async function startMcpServer() {
248
248
  .describe("每条用例重复次数,取多数结果;建议正式基线用 3"),
249
249
  failUnder: z.number().optional().describe("选择准确率阈值,低于则退出码为 2"),
250
250
  maxCost: z.number().positive().optional().describe("累计估算成本上限(CNY),超限退出 2 且不返回部分评分卡"),
251
+ retry: z.number().int().min(0).max(10).optional().describe("单次请求遇到 429/5xx/网络错误的重试次数(默认 2,共 3 次尝试)"),
252
+ minInterval: z.number().int().min(0).max(60000).optional().describe("两次模型请求的最小间隔(毫秒),用于压住端点 RPM"),
251
253
  progress: z.boolean().optional().describe("把逐用例进度写到 stderr(stdout 仍为纯 JSON)")
252
254
  }
253
255
  },
254
- async ({ suite, models, cases, profile, repeat, failUnder, maxCost, progress }) => {
256
+ async ({ suite, models, cases, profile, repeat, failUnder, maxCost, retry, minInterval, progress }) => {
255
257
  const args = ["eval", "--json", "--suite", suitePath(suite)];
256
258
  if (models) args.push("--models", models);
257
259
  if (cases) args.push("--case", cases);
@@ -259,6 +261,8 @@ export async function startMcpServer() {
259
261
  if (repeat !== undefined) args.push("--repeat", String(repeat));
260
262
  if (failUnder !== undefined) args.push("--fail-under", String(failUnder));
261
263
  if (maxCost !== undefined) args.push("--max-cost", String(maxCost));
264
+ if (retry !== undefined) args.push("--retry", String(retry));
265
+ if (minInterval !== undefined) args.push("--min-interval", String(minInterval));
262
266
  if (progress) args.push("--progress");
263
267
 
264
268
  const result = await runCli(args);
package/lib/profile.js CHANGED
@@ -54,7 +54,11 @@ export function selectProfiles(profiles, wanted) {
54
54
  // A case belongs to a group when its expected tool is mounted there. Negative
55
55
  // cases ("no tool applies") hold everywhere: the group's agent still gets them.
56
56
  export function selectProfileCases(cases, mountedTools) {
57
- return cases.filter((testCase) => testCase.expectedTool === null || mountedTools.has(testCase.expectedTool));
57
+ return cases.filter(
58
+ (testCase) =>
59
+ testCase.excludeFromProfiles !== true &&
60
+ (testCase.expectedTool === null || mountedTools.has(testCase.expectedTool))
61
+ );
58
62
  }
59
63
 
60
64
  export function profileWarnings(profiles, suite) {
@@ -76,11 +80,12 @@ export function profileWarnings(profiles, suite) {
76
80
  }
77
81
 
78
82
  export function profileCaseCoverage(profiles, cases) {
79
- const negatives = cases.filter((testCase) => testCase.expectedTool === null).length;
83
+ const considered = cases.filter((testCase) => testCase.excludeFromProfiles !== true);
84
+ const negatives = considered.filter((testCase) => testCase.expectedTool === null).length;
80
85
  return profiles.map((profile) => {
81
86
  const mounted = new Set(profile.tools);
82
87
  const counts = new Map(profile.tools.map((name) => [name, 0]));
83
- for (const testCase of cases) {
88
+ for (const testCase of considered) {
84
89
  if (testCase.expectedTool !== null && mounted.has(testCase.expectedTool)) {
85
90
  counts.set(testCase.expectedTool, counts.get(testCase.expectedTool) + 1);
86
91
  }
@@ -88,7 +93,14 @@ export function profileCaseCoverage(profiles, cases) {
88
93
  const missing = [...counts.entries()].filter(([, count]) => count === 0).map(([name]) => name);
89
94
  let total = 0;
90
95
  for (const count of counts.values()) total += count;
91
- return { name: profile.name, tools: profile.tools.length, cases: total, negatives, missing };
96
+ return {
97
+ name: profile.name,
98
+ tools: profile.tools.length,
99
+ cases: total,
100
+ negatives,
101
+ negativeRatio: total + negatives > 0 ? negatives / (total + negatives) : 0,
102
+ missing
103
+ };
92
104
  });
93
105
  }
94
106
 
@@ -99,18 +111,21 @@ export function analyzeMount(profile, mounted, allInOne) {
99
111
  for (const before of allInOne.rows) {
100
112
  const row = after.get(before.id);
101
113
  if (!row || before.reviewed === false || row.reviewed === false) continue;
114
+ // A provider error is not evidence about mounting; score it separately.
115
+ if (before.error || row.error) continue;
102
116
  if (!before.selectionCorrect && row.selectionCorrect) lifted.push(changeOf(before, row));
103
117
  else if (before.selectionCorrect && !row.selectionCorrect) jammed.push(changeOf(before, row));
104
118
  }
105
119
 
106
120
  const mountedMetrics = metricsOf(mounted);
107
121
  const allMetrics = metricsOf(allInOne);
108
- const reviewed = mounted.reviewedTotal;
122
+ const reviewed = mountedMetrics.scored;
109
123
  return {
110
124
  profile: profile.name,
111
125
  mountedTools: profile.tools.length,
112
126
  cases: mounted.total,
113
127
  reviewed,
128
+ errors: mountedMetrics.errors,
114
129
  mounted: mountedMetrics,
115
130
  allInOne: allMetrics,
116
131
  delta: {
@@ -128,32 +143,44 @@ export function analyzeMount(profile, mounted, allInOne) {
128
143
 
129
144
  export function modelTotals(entries) {
130
145
  let reviewed = 0;
146
+ let errors = 0;
131
147
  let mountedCorrect = 0;
132
148
  let allCorrect = 0;
149
+ const usage = { inputTokens: 0, cachedTokens: 0, outputTokens: 0, costCny: 0, estimated: false };
133
150
  for (const entry of entries) {
134
151
  reviewed += entry.reviewed;
152
+ errors += entry.errors ?? 0;
135
153
  mountedCorrect += entry.mounted.correct;
136
154
  allCorrect += entry.allInOne.correct;
155
+ usage.inputTokens += entry.usage?.inputTokens ?? 0;
156
+ usage.cachedTokens += entry.usage?.cachedTokens ?? 0;
157
+ usage.outputTokens += entry.usage?.outputTokens ?? 0;
158
+ usage.costCny += entry.usage?.costCny ?? 0;
159
+ usage.estimated ||= Boolean(entry.usage?.estimated);
137
160
  }
138
161
  if (reviewed === 0) {
139
162
  return {
140
163
  reviewed: 0,
164
+ errors,
141
165
  mountedCorrect: 0,
142
166
  allCorrect: 0,
143
167
  mountedAccuracy: null,
144
168
  allAccuracy: null,
145
- delta: null
169
+ delta: null,
170
+ usage
146
171
  };
147
172
  }
148
173
  const mountedAccuracy = mountedCorrect / reviewed;
149
174
  const allAccuracy = allCorrect / reviewed;
150
175
  return {
151
176
  reviewed,
177
+ errors,
152
178
  mountedCorrect,
153
179
  allCorrect,
154
180
  mountedAccuracy,
155
181
  allAccuracy,
156
- delta: mountedAccuracy - allAccuracy
182
+ delta: mountedAccuracy - allAccuracy,
183
+ usage
157
184
  };
158
185
  }
159
186
 
@@ -169,15 +196,16 @@ export function formatProfileReport(report, { showCases = false } = {}) {
169
196
  entry.profile,
170
197
  String(entry.mountedTools),
171
198
  String(entry.cases),
199
+ String(entry.errors ?? 0),
172
200
  pct(entry.mounted.selectionAccuracy),
173
201
  pct(entry.allInOne.selectionAccuracy),
174
202
  signedPoints(entry.delta.selection)
175
203
  ]);
176
204
  lines.push(
177
205
  ...renderTable(
178
- ["profile", "tools", "cases", "mounted", "all-in-one", "delta"],
206
+ ["profile", "tools", "cases", "errors", "mounted", "all-in-one", "delta"],
179
207
  rows,
180
- [1, 2, 3, 4, 5]
208
+ [1, 2, 3, 4, 5, 6]
181
209
  )
182
210
  );
183
211
  const totals = model.totals;
@@ -185,7 +213,8 @@ export function formatProfileReport(report, { showCases = false } = {}) {
185
213
  lines.push(
186
214
  ` 合计: 分组 ${pct(totals.mountedAccuracy)}(${totals.mountedCorrect}/${totals.reviewed})` +
187
215
  ` / 全量 ${pct(totals.allAccuracy)}(${totals.allCorrect}/${totals.reviewed})` +
188
- `,${signedPoints(totals.delta)}`
216
+ `,${signedPoints(totals.delta)}` +
217
+ (totals.errors > 0 ? `;${totals.errors} 次调用错误不计入对比` : "")
189
218
  );
190
219
  }
191
220
  const unstable = unstableOf(model.profiles);
@@ -224,12 +253,12 @@ export function formatProfileMarkdown(report) {
224
253
  lines.push(
225
254
  `### ${model.model}`,
226
255
  "",
227
- "| group | tools | cases | mounted | all-in-one | delta |",
228
- "| --- | --- | --- | --- | --- | --- |"
256
+ "| group | tools | cases | errors | mounted | all-in-one | delta |",
257
+ "| --- | --- | --- | --- | --- | --- | --- |"
229
258
  );
230
259
  for (const entry of model.profiles) {
231
260
  lines.push(
232
- `| ${entry.profile} | ${entry.mountedTools} | ${entry.cases} | ${pct(entry.mounted.selectionAccuracy)} | ${pct(entry.allInOne.selectionAccuracy)} | ${signedPoints(entry.delta.selection)} |`
261
+ `| ${entry.profile} | ${entry.mountedTools} | ${entry.cases} | ${entry.errors ?? 0} | ${pct(entry.mounted.selectionAccuracy)} | ${pct(entry.allInOne.selectionAccuracy)} | ${signedPoints(entry.delta.selection)} |`
233
262
  );
234
263
  }
235
264
  const totals = model.totals;
@@ -237,7 +266,8 @@ export function formatProfileMarkdown(report) {
237
266
  if (totals.reviewed > 0) {
238
267
  lines.push(
239
268
  `- 合计:分组 ${pct(totals.mountedAccuracy)}(${totals.mountedCorrect}/${totals.reviewed}),` +
240
- `全量 ${pct(totals.allAccuracy)}(${totals.allCorrect}/${totals.reviewed}),${signedPoints(totals.delta)}`
269
+ `全量 ${pct(totals.allAccuracy)}(${totals.allCorrect}/${totals.reviewed}),${signedPoints(totals.delta)}` +
270
+ (totals.errors > 0 ? `;${totals.errors} 次调用错误不计入对比` : "")
241
271
  );
242
272
  } else {
243
273
  lines.push("- 合计:没有已复核用例,无法比较");
@@ -267,14 +297,18 @@ function changeOf(before, after) {
267
297
 
268
298
  function metricsOf(summary) {
269
299
  const reviewedRows = summary.rows.filter((row) => row.reviewed !== false);
270
- const correct = reviewedRows.filter((row) => row.selectionCorrect).length;
300
+ const errorRows = reviewedRows.filter((row) => row.error);
301
+ const viableRows = reviewedRows.filter((row) => !row.error);
302
+ const correct = viableRows.filter((row) => row.selectionCorrect).length;
271
303
  return {
272
- selectionAccuracy: reviewedRows.length ? correct / reviewedRows.length : null,
304
+ selectionAccuracy: viableRows.length ? correct / viableRows.length : null,
273
305
  argAccuracy: summary.argAccuracy,
274
- passRate: reviewedRows.length
275
- ? reviewedRows.filter((row) => row.passed).length / reviewedRows.length
306
+ passRate: viableRows.length
307
+ ? viableRows.filter((row) => row.passed).length / viableRows.length
276
308
  : null,
277
- correct
309
+ correct,
310
+ errors: errorRows.length,
311
+ scored: viableRows.length
278
312
  };
279
313
  }
280
314
 
package/lib/score.js CHANGED
@@ -89,7 +89,7 @@ export function costCnyOf(usage, pricing) {
89
89
  );
90
90
  }
91
91
 
92
- export function scoreModel({ model, cases, results, pricing }) {
92
+ export function scoreModel({ model, cases, results, pricing, scope = "base" }) {
93
93
  const rows = cases.map((testCase, index) => {
94
94
  const result = results[index];
95
95
  const actualTool = result.ok ? (result.toolCalls?.[0]?.name ?? null) : null;
@@ -98,6 +98,7 @@ export function scoreModel({ model, cases, results, pricing }) {
98
98
  ? matchArgs(result.toolCalls?.[0]?.arguments, testCase.expectedArgs)
99
99
  : { total: 0, matched: 0, failures: [] };
100
100
  const passed = Boolean(result.ok) && selectionCorrect && args.failures.length === 0;
101
+ const error = result.error ?? null;
101
102
  return {
102
103
  id: testCase.id,
103
104
  expectedTool: testCase.expectedTool,
@@ -106,21 +107,36 @@ export function scoreModel({ model, cases, results, pricing }) {
106
107
  args,
107
108
  passed,
108
109
  reviewed: testCase.reviewed !== false,
110
+ ...(testCase.excludeFromBase ? { excludeFromBase: true } : {}),
111
+ ...(testCase.excludeFromProfiles ? { excludeFromProfiles: true } : {}),
109
112
  source: testCase.source ?? "manual",
110
113
  latencyMs: result.latencyMs ?? 0,
111
114
  usage: result.usage ?? { input: 0, output: 0 },
112
- error: result.error ?? null
115
+ ...(result.retries > 0 ? { retries: result.retries } : {}),
116
+ error,
117
+ ...(error ? { errorKind: classifyError(error) } : {})
113
118
  };
114
119
  });
115
120
 
116
121
  const total = rows.length;
117
- const reviewedRows = rows.filter((row) => row.reviewed);
122
+ // An excluded case still runs (so profile / all-in-one views keep their rows);
123
+ // it just does not contribute to this scope's official metrics.
124
+ const excluded = (row) => (scope === "profiles" ? row.excludeFromProfiles : row.excludeFromBase);
125
+ const reviewedRows = rows.filter((row) => row.reviewed && !excluded(row));
118
126
  const unreviewedRows = rows.filter((row) => !row.reviewed);
127
+ const excludedRows = rows.filter((row) => row.reviewed && excluded(row));
119
128
  const selectionHits = reviewedRows.filter((row) => row.selectionCorrect).length;
120
129
  const argFields = reviewedRows.reduce((sum, row) => sum + row.args.total, 0);
121
130
  const argHits = reviewedRows.reduce((sum, row) => sum + row.args.matched, 0);
122
131
  const passed = reviewedRows.filter((row) => row.passed).length;
123
- const errors = rows.filter((row) => row.error).length;
132
+ const errorRows = reviewedRows.filter((row) => row.error);
133
+ const errors = errorRows.length;
134
+ const errorsByKind = errorRows.reduce((counts, row) => {
135
+ counts[row.errorKind] = (counts[row.errorKind] ?? 0) + 1;
136
+ return counts;
137
+ }, {});
138
+ const scoredTotal = reviewedRows.length - errors;
139
+ const selectionHitsWithoutErrors = reviewedRows.filter((row) => !row.error && row.selectionCorrect).length;
124
140
  const latencies = rows.filter((row) => row.latencyMs > 0).map((row) => row.latencyMs);
125
141
  const inputTokens = rows.reduce((sum, row) => sum + (row.usage.input ?? 0), 0);
126
142
  const outputTokens = rows.reduce((sum, row) => sum + (row.usage.output ?? 0), 0);
@@ -135,14 +151,19 @@ export function scoreModel({ model, cases, results, pricing }) {
135
151
  model,
136
152
  total,
137
153
  reviewedTotal,
154
+ excludedTotal: excludedRows.length,
138
155
  unreviewedTotal: unreviewedRows.length,
139
156
  unreviewedSelectionAccuracy: unreviewedRows.length
140
157
  ? unreviewedRows.filter((row) => row.selectionCorrect).length / unreviewedRows.length
141
158
  : null,
142
159
  selectionAccuracy: reviewedTotal ? selectionHits / reviewedTotal : 0,
160
+ selectionAccuracyExcludingErrors: scoredTotal ? selectionHitsWithoutErrors / scoredTotal : null,
143
161
  argAccuracy: argFields ? argHits / argFields : null,
144
162
  passRate: reviewedTotal ? passed / reviewedTotal : 0,
145
163
  errors,
164
+ errorsByKind,
165
+ scoredTotal,
166
+ retries: rows.reduce((sum, row) => sum + (row.retries ?? 0), 0),
146
167
  p50LatencyMs: percentile(latencies, 0.5),
147
168
  p95LatencyMs: percentile(latencies, 0.95),
148
169
  avgCostCny: total ? costCny / total : 0,
@@ -154,6 +175,15 @@ export function scoreModel({ model, cases, results, pricing }) {
154
175
  };
155
176
  }
156
177
 
178
+ export function classifyError(error) {
179
+ const text = String(error ?? "");
180
+ if (/timeout|timed out/i.test(text)) return "timeout";
181
+ if (/^HTTP 429\b/.test(text)) return "rate-limit";
182
+ if (/^HTTP \d{3}\b/.test(text)) return "http";
183
+ if (/ECONN|ENOTFOUND|EAI_AGAIN|fetch failed|network/i.test(text)) return "network";
184
+ return "other";
185
+ }
186
+
157
187
  function percentile(values, ratio) {
158
188
  if (values.length === 0) return 0;
159
189
  const sorted = [...values].sort((a, b) => a - b);
package/lib/suite.js CHANGED
@@ -95,6 +95,11 @@ function normalizeCase(entry, index, toolNames) {
95
95
  if (toolInput && expectedTool === null) {
96
96
  throw new Error(`case ${id}: toolInput requires an expectedTool`);
97
97
  }
98
+ for (const key of ["excludeFromBase", "excludeFromProfiles"]) {
99
+ if (entry[key] !== undefined && typeof entry[key] !== "boolean") {
100
+ throw new Error(`case ${id}: "${key}" must be a boolean`);
101
+ }
102
+ }
98
103
  return {
99
104
  id,
100
105
  input: entry.input,
@@ -103,6 +108,8 @@ function normalizeCase(entry, index, toolNames) {
103
108
  note: entry.note ?? "",
104
109
  source: typeof entry.source === "string" && entry.source ? entry.source : "manual",
105
110
  reviewed: entry.reviewed === undefined ? true : Boolean(entry.reviewed),
111
+ ...(entry.excludeFromBase ? { excludeFromBase: true } : {}),
112
+ ...(entry.excludeFromProfiles ? { excludeFromProfiles: true } : {}),
106
113
  tags: Array.isArray(entry.tags) ? entry.tags.map(String) : [],
107
114
  toolInput,
108
115
  expect:
package/mf.js CHANGED
@@ -97,6 +97,8 @@ Eval options:
97
97
  --profile <names> score each mounted group separately ("all" or names from suite.profiles)
98
98
  --repeat <n> run each case n times and score the majority; reports unstable cases (default 1)
99
99
  --max-cost <cny> stop after the estimated spend crosses this limit (exit 2; no partial scorecard)
100
+ --retry <n> retries per request on HTTP 429/5xx and network errors (default 2; 3 attempts total)
101
+ --min-interval <ms> minimum spacing between model requests; use it to stay under an endpoint RPM
100
102
  --progress print per-case progress to stderr (stdout stays clean for --json)
101
103
  --show-cases print per-case results
102
104
  --json print JSON instead of the table
@@ -136,7 +138,10 @@ export async function runEval(options, io = console) {
136
138
  }
137
139
  const suite = await loadSuite(options.suite);
138
140
  const cases = selectCases(suite.cases, options.cases);
139
- const { registry, adapters } = await resolveModels(options.models, options.modelsFile, io);
141
+ const { registry, adapters } = await resolveModels(options.models, options.modelsFile, io, {
142
+ retries: options.retries,
143
+ minIntervalMs: options.minIntervalMs
144
+ });
140
145
  const tools = toModelTools(suite.tools);
141
146
 
142
147
  const budget = createBudget(options.maxCost ?? null);
@@ -153,7 +158,8 @@ export async function runEval(options, io = console) {
153
158
  model: adapter.id,
154
159
  cases,
155
160
  results: evaluated.results,
156
- pricing
161
+ pricing,
162
+ scope: "base"
157
163
  });
158
164
  if (evaluated.stability) summary.stability = evaluated.stability;
159
165
  runs.push({ adapter, results: evaluated.results, summary });
@@ -250,6 +256,15 @@ export async function runEval(options, io = console) {
250
256
  if (summary.unreviewedTotal > 0) {
251
257
  io.log(`note: ${summary.unreviewedTotal} unreviewed case(s) excluded from select/args/pass`);
252
258
  }
259
+ if (summary.excludedTotal > 0) {
260
+ io.log(`note: ${summary.excludedTotal} case(s) marked excludeFromBase, excluded from base score`);
261
+ }
262
+ if (summary.errors > 0) {
263
+ const kinds = Object.entries(summary.errorsByKind)
264
+ .map(([kind, count]) => `${kind} ${count}`)
265
+ .join(", ");
266
+ io.log(`note: ${summary.errors} call error(s) counted as misses (${kinds})`);
267
+ }
253
268
  if (summary.exec) {
254
269
  const parts = [
255
270
  `exec: ${summary.exec.passed}/${summary.exec.executed} passed`,
@@ -383,7 +398,10 @@ export async function runAblate(options, io = console) {
383
398
  : selectedCases;
384
399
  const reducedCases = baseCases.filter((testCase) => testCase.expectedTool !== ablated.name);
385
400
  const droppedCases = baseCases.length - reducedCases.length;
386
- const { registry, adapters } = await resolveModels(options.models, options.modelsFile, io);
401
+ const { registry, adapters } = await resolveModels(options.models, options.modelsFile, io, {
402
+ retries: options.retries,
403
+ minIntervalMs: options.minIntervalMs
404
+ });
387
405
 
388
406
  const fullTools = toModelTools(baseTools);
389
407
  const reducedTools = toModelTools(baseTools.filter((tool) => tool.name !== ablated.name));
@@ -768,7 +786,7 @@ function selectCases(cases, ids) {
768
786
  return cases.filter((testCase) => wanted.has(testCase.id));
769
787
  }
770
788
 
771
- async function resolveModels(ids, modelsFile, io) {
789
+ async function resolveModels(ids, modelsFile, io, network = {}) {
772
790
  const registry = JSON.parse(await readFile(resolve(modelsFile), "utf8"));
773
791
  // Env wins; the credentials file only fills what is missing.
774
792
  await applyModelCredentials(registry);
@@ -795,7 +813,15 @@ async function resolveModels(ids, modelsFile, io) {
795
813
  continue;
796
814
  }
797
815
  registerSecrets([apiKey]);
798
- adapters.push({ id, ...createOpenAiAdapter({ id, ...config }) });
816
+ adapters.push({
817
+ id,
818
+ ...createOpenAiAdapter({
819
+ id,
820
+ ...config,
821
+ retries: network.retries,
822
+ minIntervalMs: network.minIntervalMs
823
+ })
824
+ });
799
825
  }
800
826
 
801
827
  if (adapters.length === 0) {
@@ -915,6 +941,14 @@ async function evaluateProfiles({
915
941
  const selected = selectProfiles(profiles, selection);
916
942
  const indexById = new Map(cases.map((testCase, index) => [testCase.id, index]));
917
943
  const warnings = profileWarnings(selected, suite);
944
+ const excludedFromProfiles = cases.filter((testCase) => testCase.excludeFromProfiles === true);
945
+ if (excludedFromProfiles.length > 0) {
946
+ warnings.push(
947
+ `${excludedFromProfiles.length} 条用例标记 excludeFromProfiles,不参与分组评分: ${excludedFromProfiles
948
+ .map((testCase) => testCase.id)
949
+ .join(", ")}`
950
+ );
951
+ }
918
952
 
919
953
  const groups = [];
920
954
  const caseSets = selected.map((profile) => {
@@ -946,15 +980,24 @@ async function evaluateProfiles({
946
980
  model: run.adapter.id,
947
981
  cases: profileCases,
948
982
  results: mountedEval.results,
949
- pricing
983
+ pricing,
984
+ scope: "profiles"
950
985
  });
951
986
  const allInOne = scoreModel({
952
987
  model: run.adapter.id,
953
988
  cases: profileCases,
954
989
  results: profileCases.map((testCase) => run.results[indexById.get(testCase.id)]),
955
- pricing
990
+ pricing,
991
+ scope: "profiles"
956
992
  });
957
993
  const entry = analyzeMount(profile, mounted, allInOne);
994
+ entry.usage = {
995
+ inputTokens: mounted.inputTokens,
996
+ cachedTokens: mounted.cachedTokens,
997
+ outputTokens: mounted.outputTokens,
998
+ costCny: mounted.totalCostCny,
999
+ estimated: mounted.rows.some((row) => row.usage?.estimated)
1000
+ };
958
1001
  if (mountedEval.stability) entry.stability = mountedEval.stability;
959
1002
  entries.push(entry);
960
1003
  }
@@ -1005,6 +1048,8 @@ function parseArgs(argv) {
1005
1048
  profiles: null,
1006
1049
  repeat: 1,
1007
1050
  maxCost: null,
1051
+ retries: 2,
1052
+ minIntervalMs: 0,
1008
1053
  progress: false,
1009
1054
  showCases: false,
1010
1055
  json: false,
@@ -1070,6 +1115,18 @@ function parseArgs(argv) {
1070
1115
  options.maxCost = raw;
1071
1116
  break;
1072
1117
  }
1118
+ case "--retry": {
1119
+ const raw = Number(value());
1120
+ if (!Number.isInteger(raw) || raw < 0 || raw > 10) throw new Error("--retry expects an integer between 0 and 10");
1121
+ options.retries = raw;
1122
+ break;
1123
+ }
1124
+ case "--min-interval": {
1125
+ const raw = Number(value());
1126
+ if (!Number.isInteger(raw) || raw < 0 || raw > 60_000) throw new Error("--min-interval expects an integer between 0 and 60000 (ms)");
1127
+ options.minIntervalMs = raw;
1128
+ break;
1129
+ }
1073
1130
  case "--progress": options.progress = true; break;
1074
1131
  case "--show-cases": options.showCases = true; break;
1075
1132
  case "--json": options.json = true; break;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "modelfunction-cli",
3
- "version": "0.5.3",
3
+ "version": "0.5.5",
4
4
  "type": "module",
5
5
  "license": "MIT",
6
6
  "description": "mf — function-calling accuracy scorecard for AI agents",