modelfunction-cli 0.5.4 → 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.3 --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,21 +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.3", "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.3` 已发布到 npm:含凭证与本地反馈、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.3`。
142
-
143
- `0.5.4`(待发布):用例新增 `excludeFromBase` / `excludeFromProfiles`(布尔,排除计分但不省调用);评分卡新增行级 `errorKind` 与 `errors` / `errorsByKind` / `scoredTotal` / `selectionAccuracyExcludingErrors`;挂载对比不再把超时/HTTP 错误记成 jammed,分组表单列 `errors`;`mf audit` 分组表增加 `neg%`。契约见 `SCHEMA.md`。
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`。
144
142
 
145
143
  ## 数据边界
146
144
 
@@ -169,7 +167,7 @@ mf feedback --out out.json # 导出脱敏反馈包(聚合值,不含原始
169
167
 
170
168
  ```bash
171
169
  # 1. 把 mf 挂成 MCP server
172
- claude mcp add modelfunction -- npx -y modelfunction-cli@0.5.3 mcp
170
+ claude mcp add modelfunction -- npx -y modelfunction-cli@0.5.5 mcp
173
171
  ```
174
172
 
175
173
  ```text
package/SCHEMA.md CHANGED
@@ -95,19 +95,21 @@ skills 只做静态检查,不参与模型评测。
95
95
  "models": [ { "model": "dsv4-flash", "total": 12, "reviewedTotal": 12, "excludedTotal": 0,
96
96
  "selectionAccuracy": 0.83, "selectionAccuracyExcludingErrors": 0.83,
97
97
  "argAccuracy": 0.9, "passRate": 0.75, "errors": 0, "errorsByKind": {},
98
- "scoredTotal": 12, "p50LatencyMs": 900,
98
+ "scoredTotal": 12, "retries": 0, "p50LatencyMs": 900,
99
99
  "p95LatencyMs": 1800, "avgCostCny": 0.001, "totalCostCny": 0.012,
100
100
  "inputTokens": 1000, "cachedTokens": 0, "outputTokens": 200,
101
101
  "rows": [ { "id": "c1", "expectedTool": "...", "actualTool": "...",
102
102
  "selectionCorrect": true, "args": { "total": 1, "matched": 1, "failures": [] },
103
103
  "passed": true, "reviewed": true, "source": "manual",
104
- "latencyMs": 900, "usage": { "input": 80, "output": 20 }, "error": null } ] } ],
104
+ "latencyMs": 900, "usage": { "input": 80, "output": 20 }, "error": null,
105
+ "retries": 1 } ] } ],
105
106
  "profiles": { "groups": [ ... ] }
106
107
  }
107
108
  ```
108
109
 
109
110
  - 正式指标只统计 `reviewed` 且未被 `excludeFromBase` 排除的行;未复核与排除结果单独给出。
110
111
  - `errors` 是调用层失败(超时、HTTP、网络)的数量,按 `errorsByKind` 分类;它们仍计入 `selectionAccuracy`(保守口径),同时给出排除错误后的 `selectionAccuracyExcludingErrors` 与可评分行数 `scoredTotal`。
112
+ - `retries` 是本轮因 429/5xx/网络错误而重试的请求次数(模型级汇总;行级只在大于 0 时出现)。重试不改变判分:最终成功的请求按正常结果计分,最终失败的仍计入 `errors`。
111
113
  - `--repeat` 时模型对象带 `stability`(翻转用例与多次结果分布)。
112
114
  - `--profile` 时每个组含 `profile`、`mounted`、`allInOne`、`delta`、`lifted`、`jammed`、`errors`;错误行不参与挂载对比(不会被记成 jammed),每个组的 `reviewed` 是可评分行数。
113
115
  - `fingerprint` 由工具与用例内容计算;`mf compare` 用它判断两份报告是否可比。
@@ -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/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/score.js CHANGED
@@ -112,6 +112,7 @@ export function scoreModel({ model, cases, results, pricing, scope = "base" }) {
112
112
  source: testCase.source ?? "manual",
113
113
  latencyMs: result.latencyMs ?? 0,
114
114
  usage: result.usage ?? { input: 0, output: 0 },
115
+ ...(result.retries > 0 ? { retries: result.retries } : {}),
115
116
  error,
116
117
  ...(error ? { errorKind: classifyError(error) } : {})
117
118
  };
@@ -162,6 +163,7 @@ export function scoreModel({ model, cases, results, pricing, scope = "base" }) {
162
163
  errors,
163
164
  errorsByKind,
164
165
  scoredTotal,
166
+ retries: rows.reduce((sum, row) => sum + (row.retries ?? 0), 0),
165
167
  p50LatencyMs: percentile(latencies, 0.5),
166
168
  p95LatencyMs: percentile(latencies, 0.95),
167
169
  avgCostCny: total ? costCny / total : 0,
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);
@@ -393,7 +398,10 @@ export async function runAblate(options, io = console) {
393
398
  : selectedCases;
394
399
  const reducedCases = baseCases.filter((testCase) => testCase.expectedTool !== ablated.name);
395
400
  const droppedCases = baseCases.length - reducedCases.length;
396
- 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
+ });
397
405
 
398
406
  const fullTools = toModelTools(baseTools);
399
407
  const reducedTools = toModelTools(baseTools.filter((tool) => tool.name !== ablated.name));
@@ -778,7 +786,7 @@ function selectCases(cases, ids) {
778
786
  return cases.filter((testCase) => wanted.has(testCase.id));
779
787
  }
780
788
 
781
- async function resolveModels(ids, modelsFile, io) {
789
+ async function resolveModels(ids, modelsFile, io, network = {}) {
782
790
  const registry = JSON.parse(await readFile(resolve(modelsFile), "utf8"));
783
791
  // Env wins; the credentials file only fills what is missing.
784
792
  await applyModelCredentials(registry);
@@ -805,7 +813,15 @@ async function resolveModels(ids, modelsFile, io) {
805
813
  continue;
806
814
  }
807
815
  registerSecrets([apiKey]);
808
- 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
+ });
809
825
  }
810
826
 
811
827
  if (adapters.length === 0) {
@@ -1032,6 +1048,8 @@ function parseArgs(argv) {
1032
1048
  profiles: null,
1033
1049
  repeat: 1,
1034
1050
  maxCost: null,
1051
+ retries: 2,
1052
+ minIntervalMs: 0,
1035
1053
  progress: false,
1036
1054
  showCases: false,
1037
1055
  json: false,
@@ -1097,6 +1115,18 @@ function parseArgs(argv) {
1097
1115
  options.maxCost = raw;
1098
1116
  break;
1099
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
+ }
1100
1130
  case "--progress": options.progress = true; break;
1101
1131
  case "--show-cases": options.showCases = true; break;
1102
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.4",
3
+ "version": "0.5.5",
4
4
  "type": "module",
5
5
  "license": "MIT",
6
6
  "description": "mf — function-calling accuracy scorecard for AI agents",