modelfunction-cli 0.5.4 → 0.6.0
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 +7 -9
- package/SCHEMA.md +11 -6
- package/fixtures/models.json +6 -1
- package/lib/adapters/openai.js +38 -4
- package/lib/budget.js +29 -7
- package/lib/compare.js +40 -0
- package/lib/exec.js +17 -4
- package/lib/mcp-server.js +8 -2
- package/lib/profile.js +14 -1
- package/lib/report.js +11 -8
- package/lib/score.js +18 -5
- package/mf.js +103 -36
- 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.6.0 --version
|
|
9
9
|
# 或
|
|
10
10
|
npm i -D modelfunction-cli
|
|
11
11
|
npx mf --version
|
|
@@ -69,14 +69,14 @@ INTERNAL_LLM_KEY=... mf eval --suite mf.suite.json \
|
|
|
69
69
|
--models-file my-models.json --models internal-ds
|
|
70
70
|
```
|
|
71
71
|
|
|
72
|
-
`pricing` 可省(省了就不输出成本);要算成本请按自家价目填写,默认 registry
|
|
72
|
+
`pricing` 可省(省了就不输出成本);要算成本请按自家价目填写,默认 registry 里的价格只是参考值。价格条目旁写 `"currency": "USD"`(默认 `CNY`)即可切换币种;token 用量不受此影响,始终是客观计量。
|
|
73
73
|
|
|
74
74
|
## 命令
|
|
75
75
|
|
|
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,币种随 registry);`--max-tokens` 按累计 input+output token 停机(退出 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.
|
|
129
|
+
"args": ["-y", "modelfunction-cli@0.6.0", "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`、`maxTokens`、`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`
|
|
142
|
-
|
|
143
|
-
`0.5.4`(待发布):用例新增 `excludeFromBase` / `excludeFromProfiles`(布尔,排除计分但不省调用);评分卡新增行级 `errorKind` 与 `errors` / `errorsByKind` / `scoredTotal` / `selectionAccuracyExcludingErrors`;挂载对比不再把超时/HTTP 错误记成 jammed,分组表单列 `errors`;`mf audit` 分组表增加 `neg%`。契约见 `SCHEMA.md`。
|
|
141
|
+
`0.6.0` 待 npm 发布:token 成为主口径(评分卡新增 `totalTokens`),模型注册表支持 `currency`(CNY/USD,默认 CNY),成本字段改为 `totalCost` / `avgCost` + `costCurrency`(`totalCostCny` / `avgCostCny` 仅在 CNY 时保留),新增 `mf eval --max-tokens <n>` 与 MCP `maxTokens`;`--fail-on-regression` 在基线模型缺失或本轮全部调用失败时改为 exit 2(无法判定 ≠ 无回归),自动选模型时在 stderr 打 note;本地门禁模板改用退出码判定、只在成功 eval 后更新 state,`.cmd` 固定 CRLF。`0.5.5` 含 eval `--retry <n>` / `--min-interval <ms>`、评分卡 `retries`、CI 模板 static 层 `cluster --json`、`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.6.0`。
|
|
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.
|
|
170
|
+
claude mcp add modelfunction -- npx -y modelfunction-cli@0.6.0 mcp
|
|
173
171
|
```
|
|
174
172
|
|
|
175
173
|
```text
|
package/SCHEMA.md
CHANGED
|
@@ -95,21 +95,26 @@ 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,
|
|
99
|
-
"p95LatencyMs": 1800, "
|
|
98
|
+
"scoredTotal": 12, "retries": 0, "p50LatencyMs": 900,
|
|
99
|
+
"p95LatencyMs": 1800, "avgCost": 0.001, "totalCost": 0.012,
|
|
100
|
+
"costCurrency": "CNY", "avgCostCny": 0.001, "totalCostCny": 0.012,
|
|
100
101
|
"inputTokens": 1000, "cachedTokens": 0, "outputTokens": 200,
|
|
102
|
+
"totalTokens": 1200,
|
|
101
103
|
"rows": [ { "id": "c1", "expectedTool": "...", "actualTool": "...",
|
|
102
104
|
"selectionCorrect": true, "args": { "total": 1, "matched": 1, "failures": [] },
|
|
103
105
|
"passed": true, "reviewed": true, "source": "manual",
|
|
104
|
-
"latencyMs": 900, "usage": { "input": 80, "output": 20 }, "error": null
|
|
106
|
+
"latencyMs": 900, "usage": { "input": 80, "output": 20 }, "error": null,
|
|
107
|
+
"retries": 1 } ] } ],
|
|
105
108
|
"profiles": { "groups": [ ... ] }
|
|
106
109
|
}
|
|
107
110
|
```
|
|
108
111
|
|
|
109
112
|
- 正式指标只统计 `reviewed` 且未被 `excludeFromBase` 排除的行;未复核与排除结果单独给出。
|
|
110
113
|
- `errors` 是调用层失败(超时、HTTP、网络)的数量,按 `errorsByKind` 分类;它们仍计入 `selectionAccuracy`(保守口径),同时给出排除错误后的 `selectionAccuracyExcludingErrors` 与可评分行数 `scoredTotal`。
|
|
114
|
+
- `retries` 是本轮因 429/5xx/网络错误而重试的请求次数(模型级汇总;行级只在大于 0 时出现)。重试不改变判分:最终成功的请求按正常结果计分,最终失败的仍计入 `errors`。
|
|
115
|
+
- `inputTokens` / `cachedTokens` / `outputTokens` / `totalTokens` 是客观计量(`totalTokens = inputTokens + outputTokens`,缓存 token 已含在输入内)。成本需要模型注册表的 `pricing`;`currency` 取模型注册表条目(CNY/USD,默认 CNY)。读 `totalCost` / `avgCost` 时一并读 `costCurrency`;`totalCostCny` / `avgCostCny` 只在 CNY 时出现,是兼容别名。
|
|
111
116
|
- `--repeat` 时模型对象带 `stability`(翻转用例与多次结果分布)。
|
|
112
|
-
- `--profile` 时每个组含 `profile`、`mounted`、`allInOne`、`delta`、`lifted`、`jammed`、`errors
|
|
117
|
+
- `--profile` 时每个组含 `profile`、`mounted`、`allInOne`、`delta`、`lifted`、`jammed`、`errors`,以及 `usage`(`inputTokens` / `cachedTokens` / `outputTokens` / `totalTokens` / `cost` / `costCurrency`;CNY 时另带 `costCny`);错误行不参与挂载对比(不会被记成 jammed),每个组的 `reviewed` 是可评分行数。
|
|
113
118
|
- `fingerprint` 由工具与用例内容计算;`mf compare` 用它判断两份报告是否可比。
|
|
114
119
|
|
|
115
120
|
## 3. 扫描输出(`mf scan`)
|
|
@@ -225,7 +230,7 @@ const result = checkMany({ manifests: [/* ... */], installed: [/* ... */] });
|
|
|
225
230
|
| `cluster` | `suite?`、`threshold?`、`maxSize?` |
|
|
226
231
|
| `compare` | `before`、`after`(评分卡 JSON 路径) |
|
|
227
232
|
| `add_cases` | `suite?`、`cases[]`、`source?`、`reviewed?`、`dryRun?` |
|
|
228
|
-
| `run_eval` | `suite?`、`models?`、`cases?`、`profile?`、`repeat?`、`failUnder?`、`maxCost?`、`progress?` |
|
|
233
|
+
| `run_eval` | `suite?`、`models?`、`cases?`、`profile?`、`repeat?`、`failUnder?`、`maxCost?`、`maxTokens?`、`retry?`、`minInterval?`、`progress?` |
|
|
229
234
|
|
|
230
235
|
返回值均为 JSON 文本。
|
|
231
236
|
|
|
@@ -235,7 +240,7 @@ const result = checkMany({ manifests: [/* ... */], installed: [/* ... */] });
|
|
|
235
240
|
| --- | --- |
|
|
236
241
|
| 0 | 成功(`mf check` 的 warn 也是 0) |
|
|
237
242
|
| 1 | 输入/用法错误;`mf compare` 判定不可直接比较 |
|
|
238
|
-
| 2 | `mf eval --fail-under` 未达标、`--fail-on-regression`
|
|
243
|
+
| 2 | `mf eval --fail-under` 未达标、`--fail-on-regression` 发现回退或无法判定(基线模型缺失、本轮全部调用失败)、`--max-cost` / `--max-tokens` 超限 |
|
|
239
244
|
| 3 | `mf exec` 被权限策略拒绝;`mf scan --json` 未找到工具或 skill |
|
|
240
245
|
| 4 | `mf exec` 超时 |
|
|
241
246
|
|
package/fixtures/models.json
CHANGED
|
@@ -1,11 +1,12 @@
|
|
|
1
1
|
{
|
|
2
|
-
"_note": "pricing
|
|
2
|
+
"_note": "pricing 为每百万 tokens 的参考价,currency 指定币种(CNY/USD,默认 CNY);运行前请按各家最新价目核对",
|
|
3
3
|
"mock": { "type": "mock" },
|
|
4
4
|
"deepseek": {
|
|
5
5
|
"type": "openai",
|
|
6
6
|
"baseUrl": "https://api.deepseek.com/v1",
|
|
7
7
|
"model": "deepseek-chat",
|
|
8
8
|
"apiKeyEnv": "DEEPSEEK_API_KEY",
|
|
9
|
+
"currency": "CNY",
|
|
9
10
|
"pricing": { "input": 2, "output": 8 }
|
|
10
11
|
},
|
|
11
12
|
"dsv4-flash": {
|
|
@@ -13,6 +14,7 @@
|
|
|
13
14
|
"baseUrl": "https://api.siliconflow.cn/v1",
|
|
14
15
|
"model": "deepseek-ai/DeepSeek-V4-Flash",
|
|
15
16
|
"apiKeyEnv": "SILICONFLOW_API_KEY",
|
|
17
|
+
"currency": "CNY",
|
|
16
18
|
"pricing": { "input": 1.0, "cachedInput": 0.02, "output": 2.0 }
|
|
17
19
|
},
|
|
18
20
|
"qwen": {
|
|
@@ -20,6 +22,7 @@
|
|
|
20
22
|
"baseUrl": "https://dashscope.aliyuncs.com/compatible-mode/v1",
|
|
21
23
|
"model": "qwen-plus",
|
|
22
24
|
"apiKeyEnv": "DASHSCOPE_API_KEY",
|
|
25
|
+
"currency": "CNY",
|
|
23
26
|
"pricing": { "input": 0.8, "output": 2 }
|
|
24
27
|
},
|
|
25
28
|
"glm": {
|
|
@@ -27,6 +30,7 @@
|
|
|
27
30
|
"baseUrl": "https://open.bigmodel.cn/api/paas/v4",
|
|
28
31
|
"model": "glm-4-air",
|
|
29
32
|
"apiKeyEnv": "ZHIPU_API_KEY",
|
|
33
|
+
"currency": "CNY",
|
|
30
34
|
"pricing": { "input": 0.5, "output": 0.5 }
|
|
31
35
|
},
|
|
32
36
|
"openai": {
|
|
@@ -34,6 +38,7 @@
|
|
|
34
38
|
"baseUrl": "https://api.openai.com/v1",
|
|
35
39
|
"model": "gpt-4o-mini",
|
|
36
40
|
"apiKeyEnv": "OPENAI_API_KEY",
|
|
41
|
+
"currency": "CNY",
|
|
37
42
|
"pricing": { "input": 1.1, "output": 4.3 }
|
|
38
43
|
}
|
|
39
44
|
}
|
package/lib/adapters/openai.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
const TIMEOUT_MS = 60_000;
|
|
2
|
-
const
|
|
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 <=
|
|
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
|
-
|
|
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
|
|
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/budget.js
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
|
-
// Running
|
|
2
|
-
//
|
|
3
|
-
//
|
|
1
|
+
// Running guards for evaluations. Token and cost usage are only known after a
|
|
2
|
+
// call returns, so limits are enforced after each call: the run stops as soon
|
|
3
|
+
// as either limit is crossed.
|
|
4
4
|
export class CostLimitError extends Error {
|
|
5
5
|
constructor({ limit, spent, calls }) {
|
|
6
|
-
super(`cost limit reached: spent
|
|
6
|
+
super(`cost limit reached: spent ${spent.toFixed(4)} of ${limit} after ${calls} call(s)`);
|
|
7
7
|
this.name = "CostLimitError";
|
|
8
8
|
this.limit = limit;
|
|
9
9
|
this.spent = spent;
|
|
@@ -11,20 +11,42 @@ export class CostLimitError extends Error {
|
|
|
11
11
|
}
|
|
12
12
|
}
|
|
13
13
|
|
|
14
|
-
export
|
|
14
|
+
export class TokenLimitError extends Error {
|
|
15
|
+
constructor({ limit, spent, calls }) {
|
|
16
|
+
super(`token limit reached: used ${spent} of ${limit} tokens after ${calls} call(s)`);
|
|
17
|
+
this.name = "TokenLimitError";
|
|
18
|
+
this.limit = limit;
|
|
19
|
+
this.spent = spent;
|
|
20
|
+
this.calls = calls;
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function createBudget(limits = null) {
|
|
25
|
+
const { maxCost = null, maxTokens = null } =
|
|
26
|
+
typeof limits === "object" && limits !== null
|
|
27
|
+
? limits
|
|
28
|
+
: { maxCost: limits, maxTokens: null };
|
|
15
29
|
let spent = 0;
|
|
30
|
+
let tokens = 0;
|
|
16
31
|
let calls = 0;
|
|
17
32
|
return {
|
|
18
33
|
get spent() {
|
|
19
34
|
return spent;
|
|
20
35
|
},
|
|
36
|
+
get tokens() {
|
|
37
|
+
return tokens;
|
|
38
|
+
},
|
|
21
39
|
get calls() {
|
|
22
40
|
return calls;
|
|
23
41
|
},
|
|
24
|
-
spend(
|
|
42
|
+
spend(usage) {
|
|
43
|
+
const { cost, tokens: usedTokens } =
|
|
44
|
+
typeof usage === "number" ? { cost: usage, tokens: 0 } : (usage ?? {});
|
|
25
45
|
calls += 1;
|
|
26
46
|
if (Number.isFinite(cost) && cost > 0) spent += cost;
|
|
27
|
-
if (
|
|
47
|
+
if (Number.isFinite(usedTokens) && usedTokens > 0) tokens += usedTokens;
|
|
48
|
+
if (maxCost !== null && spent > maxCost) throw new CostLimitError({ limit: maxCost, spent, calls });
|
|
49
|
+
if (maxTokens !== null && tokens > maxTokens) throw new TokenLimitError({ limit: maxTokens, spent: tokens, calls });
|
|
28
50
|
}
|
|
29
51
|
};
|
|
30
52
|
}
|
package/lib/compare.js
CHANGED
|
@@ -182,6 +182,46 @@ function compareProfiles(before, after) {
|
|
|
182
182
|
};
|
|
183
183
|
}
|
|
184
184
|
|
|
185
|
+
export function regressionGate(comparison, summaries = []) {
|
|
186
|
+
const missing = comparison.models.filter((model) => model.reason === "模型不在新报告中");
|
|
187
|
+
if (missing.length > 0) {
|
|
188
|
+
return {
|
|
189
|
+
status: "fail",
|
|
190
|
+
message: `baseline model(s) not in this report: ${missing.map((model) => model.model).join(", ")};无法判定回归(门禁失败)`
|
|
191
|
+
};
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
const errored = summaries.filter(
|
|
195
|
+
(summary) => summary.reviewedTotal > 0 && summary.errors === summary.reviewedTotal
|
|
196
|
+
);
|
|
197
|
+
if (errored.length > 0) {
|
|
198
|
+
const detail = errored
|
|
199
|
+
.map((summary) => {
|
|
200
|
+
const kinds = summary.errorsByKind && Object.keys(summary.errorsByKind).length
|
|
201
|
+
? ` ${JSON.stringify(summary.errorsByKind)}`
|
|
202
|
+
: "";
|
|
203
|
+
return `${summary.model} (${summary.errors}/${summary.reviewedTotal}${kinds})`;
|
|
204
|
+
})
|
|
205
|
+
.join(", ");
|
|
206
|
+
return {
|
|
207
|
+
status: "fail",
|
|
208
|
+
message: `all calls failed for: ${detail};基础设施故障,无法判定回归(门禁失败)`
|
|
209
|
+
};
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
const comparable = comparison.models.filter((model) => model.comparable && model.reviewed > 0);
|
|
213
|
+
if (comparable.length === 0) {
|
|
214
|
+
return { status: "skip", message: "定义或用例已变更,无法判定回归(门禁跳过)" };
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
return {
|
|
218
|
+
status: "ok",
|
|
219
|
+
regressions: comparable.flatMap((model) =>
|
|
220
|
+
model.regressions.map((item) => `${model.model}/${item.id}`)
|
|
221
|
+
)
|
|
222
|
+
};
|
|
223
|
+
}
|
|
224
|
+
|
|
185
225
|
export function formatCompare(report) {
|
|
186
226
|
const lines = [];
|
|
187
227
|
if (!report.beforeHash || !report.afterHash) {
|
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 =
|
|
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 ?
|
|
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) =>
|
|
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) =>
|
|
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
|
@@ -247,11 +247,14 @@ export async function startMcpServer() {
|
|
|
247
247
|
.optional()
|
|
248
248
|
.describe("每条用例重复次数,取多数结果;建议正式基线用 3"),
|
|
249
249
|
failUnder: z.number().optional().describe("选择准确率阈值,低于则退出码为 2"),
|
|
250
|
-
maxCost: z.number().positive().optional().describe("
|
|
250
|
+
maxCost: z.number().positive().optional().describe("累计估算成本上限(币种见模型注册表 currency),超限退出 2 且不返回部分评分卡"),
|
|
251
|
+
maxTokens: z.number().int().positive().optional().describe("累计 input+output token 上限,超限退出 2 且不返回部分评分卡"),
|
|
252
|
+
retry: z.number().int().min(0).max(10).optional().describe("单次请求遇到 429/5xx/网络错误的重试次数(默认 2,共 3 次尝试)"),
|
|
253
|
+
minInterval: z.number().int().min(0).max(60000).optional().describe("两次模型请求的最小间隔(毫秒),用于压住端点 RPM"),
|
|
251
254
|
progress: z.boolean().optional().describe("把逐用例进度写到 stderr(stdout 仍为纯 JSON)")
|
|
252
255
|
}
|
|
253
256
|
},
|
|
254
|
-
async ({ suite, models, cases, profile, repeat, failUnder, maxCost, progress }) => {
|
|
257
|
+
async ({ suite, models, cases, profile, repeat, failUnder, maxCost, maxTokens, retry, minInterval, progress }) => {
|
|
255
258
|
const args = ["eval", "--json", "--suite", suitePath(suite)];
|
|
256
259
|
if (models) args.push("--models", models);
|
|
257
260
|
if (cases) args.push("--case", cases);
|
|
@@ -259,6 +262,9 @@ export async function startMcpServer() {
|
|
|
259
262
|
if (repeat !== undefined) args.push("--repeat", String(repeat));
|
|
260
263
|
if (failUnder !== undefined) args.push("--fail-under", String(failUnder));
|
|
261
264
|
if (maxCost !== undefined) args.push("--max-cost", String(maxCost));
|
|
265
|
+
if (maxTokens !== undefined) args.push("--max-tokens", String(maxTokens));
|
|
266
|
+
if (retry !== undefined) args.push("--retry", String(retry));
|
|
267
|
+
if (minInterval !== undefined) args.push("--min-interval", String(minInterval));
|
|
262
268
|
if (progress) args.push("--progress");
|
|
263
269
|
|
|
264
270
|
const result = await runCli(args);
|
package/lib/profile.js
CHANGED
|
@@ -146,7 +146,16 @@ export function modelTotals(entries) {
|
|
|
146
146
|
let errors = 0;
|
|
147
147
|
let mountedCorrect = 0;
|
|
148
148
|
let allCorrect = 0;
|
|
149
|
-
const usage = {
|
|
149
|
+
const usage = {
|
|
150
|
+
inputTokens: 0,
|
|
151
|
+
cachedTokens: 0,
|
|
152
|
+
outputTokens: 0,
|
|
153
|
+
totalTokens: 0,
|
|
154
|
+
cost: 0,
|
|
155
|
+
costCurrency: null,
|
|
156
|
+
costCny: 0,
|
|
157
|
+
estimated: false
|
|
158
|
+
};
|
|
150
159
|
for (const entry of entries) {
|
|
151
160
|
reviewed += entry.reviewed;
|
|
152
161
|
errors += entry.errors ?? 0;
|
|
@@ -155,9 +164,13 @@ export function modelTotals(entries) {
|
|
|
155
164
|
usage.inputTokens += entry.usage?.inputTokens ?? 0;
|
|
156
165
|
usage.cachedTokens += entry.usage?.cachedTokens ?? 0;
|
|
157
166
|
usage.outputTokens += entry.usage?.outputTokens ?? 0;
|
|
167
|
+
usage.totalTokens += entry.usage?.totalTokens ?? 0;
|
|
168
|
+
usage.cost += entry.usage?.cost ?? 0;
|
|
169
|
+
usage.costCurrency ||= entry.usage?.costCurrency ?? null;
|
|
158
170
|
usage.costCny += entry.usage?.costCny ?? 0;
|
|
159
171
|
usage.estimated ||= Boolean(entry.usage?.estimated);
|
|
160
172
|
}
|
|
173
|
+
usage.costCurrency ??= "CNY";
|
|
161
174
|
if (reviewed === 0) {
|
|
162
175
|
return {
|
|
163
176
|
reviewed: 0,
|
package/lib/report.js
CHANGED
|
@@ -1,10 +1,13 @@
|
|
|
1
1
|
import { pct } from "./score.js";
|
|
2
2
|
import { suiteFingerprint } from "./fingerprint.js";
|
|
3
3
|
|
|
4
|
-
|
|
5
|
-
|
|
4
|
+
const CURRENCY_SYMBOLS = { CNY: "¥", USD: "$" };
|
|
5
|
+
|
|
6
|
+
function money(value, estimated, currency = "CNY") {
|
|
7
|
+
if (!value) return "0";
|
|
6
8
|
const text = value >= 0.01 ? value.toFixed(3) : value.toFixed(4);
|
|
7
|
-
|
|
9
|
+
const symbol = CURRENCY_SYMBOLS[currency] ?? `${currency} `;
|
|
10
|
+
return `${estimated ? "~" : ""}${symbol}${text}`;
|
|
8
11
|
}
|
|
9
12
|
|
|
10
13
|
function isEstimated(summary) {
|
|
@@ -32,7 +35,7 @@ export function formatCaseLines(summary, cases) {
|
|
|
32
35
|
}
|
|
33
36
|
|
|
34
37
|
export function formatTable(summaries) {
|
|
35
|
-
const headers = ["model", "select", "args", "pass", "p50ms", "p95ms", "
|
|
38
|
+
const headers = ["model", "select", "args", "pass", "p50ms", "p95ms", "cost/call", "errors"];
|
|
36
39
|
const rows = summaries.map((summary) => [
|
|
37
40
|
summary.model,
|
|
38
41
|
pct(summary.selectionAccuracy),
|
|
@@ -40,7 +43,7 @@ export function formatTable(summaries) {
|
|
|
40
43
|
pct(summary.passRate),
|
|
41
44
|
String(summary.p50LatencyMs),
|
|
42
45
|
String(summary.p95LatencyMs),
|
|
43
|
-
money(summary.avgCostCny, isEstimated(summary)),
|
|
46
|
+
money(summary.avgCost ?? summary.avgCostCny, isEstimated(summary), summary.costCurrency ?? "CNY"),
|
|
44
47
|
String(summary.errors)
|
|
45
48
|
]);
|
|
46
49
|
|
|
@@ -71,8 +74,8 @@ export function formatMarkdown(suite, cases, summaries) {
|
|
|
71
74
|
|
|
72
75
|
const withExec = summaries.some((summary) => summary.exec);
|
|
73
76
|
const headers = withExec
|
|
74
|
-
? ["model", "select", "args", "pass", "exec", "run p50 ms", "max RSS MB", "p50 ms", "p95 ms", "
|
|
75
|
-
: ["model", "select", "args", "pass", "p50 ms", "p95 ms", "
|
|
77
|
+
? ["model", "select", "args", "pass", "exec", "run p50 ms", "max RSS MB", "p50 ms", "p95 ms", "cost/call", "errors"]
|
|
78
|
+
: ["model", "select", "args", "pass", "p50 ms", "p95 ms", "cost/call", "errors"];
|
|
76
79
|
lines.push(`| ${headers.join(" | ")} |`, `| ${headers.map(() => "---").join(" | ")} |`);
|
|
77
80
|
|
|
78
81
|
for (const summary of summaries) {
|
|
@@ -90,7 +93,7 @@ export function formatMarkdown(suite, cases, summaries) {
|
|
|
90
93
|
cells.push(
|
|
91
94
|
String(summary.p50LatencyMs),
|
|
92
95
|
String(summary.p95LatencyMs),
|
|
93
|
-
money(summary.avgCostCny, isEstimated(summary)),
|
|
96
|
+
money(summary.avgCost ?? summary.avgCostCny, isEstimated(summary), summary.costCurrency ?? "CNY"),
|
|
94
97
|
String(summary.errors)
|
|
95
98
|
);
|
|
96
99
|
lines.push(`| ${cells.join(" | ")} |`);
|
package/lib/score.js
CHANGED
|
@@ -76,7 +76,11 @@ export function pickRepresentative(runs) {
|
|
|
76
76
|
};
|
|
77
77
|
}
|
|
78
78
|
|
|
79
|
-
export function
|
|
79
|
+
export function tokensOf(usage) {
|
|
80
|
+
return (usage?.input ?? 0) + (usage?.output ?? 0);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export function costOf(usage, pricing) {
|
|
80
84
|
if (!pricing) return 0;
|
|
81
85
|
const input = usage?.input ?? 0;
|
|
82
86
|
const cached = Math.min(input, usage?.cached ?? 0);
|
|
@@ -89,7 +93,7 @@ export function costCnyOf(usage, pricing) {
|
|
|
89
93
|
);
|
|
90
94
|
}
|
|
91
95
|
|
|
92
|
-
export function scoreModel({ model, cases, results, pricing, scope = "base" }) {
|
|
96
|
+
export function scoreModel({ model, cases, results, pricing, scope = "base", currency = "CNY" }) {
|
|
93
97
|
const rows = cases.map((testCase, index) => {
|
|
94
98
|
const result = results[index];
|
|
95
99
|
const actualTool = result.ok ? (result.toolCalls?.[0]?.name ?? null) : null;
|
|
@@ -112,6 +116,7 @@ export function scoreModel({ model, cases, results, pricing, scope = "base" }) {
|
|
|
112
116
|
source: testCase.source ?? "manual",
|
|
113
117
|
latencyMs: result.latencyMs ?? 0,
|
|
114
118
|
usage: result.usage ?? { input: 0, output: 0 },
|
|
119
|
+
...(result.retries > 0 ? { retries: result.retries } : {}),
|
|
115
120
|
error,
|
|
116
121
|
...(error ? { errorKind: classifyError(error) } : {})
|
|
117
122
|
};
|
|
@@ -143,7 +148,9 @@ export function scoreModel({ model, cases, results, pricing, scope = "base" }) {
|
|
|
143
148
|
inputTokens,
|
|
144
149
|
rows.reduce((sum, row) => sum + (row.usage.cached ?? 0), 0)
|
|
145
150
|
);
|
|
146
|
-
const
|
|
151
|
+
const totalCost = rows.reduce((sum, row) => sum + costOf(row.usage, pricing), 0);
|
|
152
|
+
const totalTokens = inputTokens + outputTokens;
|
|
153
|
+
const costCurrency = String(currency || "CNY").trim().toUpperCase();
|
|
147
154
|
const reviewedTotal = reviewedRows.length;
|
|
148
155
|
|
|
149
156
|
return {
|
|
@@ -162,13 +169,19 @@ export function scoreModel({ model, cases, results, pricing, scope = "base" }) {
|
|
|
162
169
|
errors,
|
|
163
170
|
errorsByKind,
|
|
164
171
|
scoredTotal,
|
|
172
|
+
retries: rows.reduce((sum, row) => sum + (row.retries ?? 0), 0),
|
|
165
173
|
p50LatencyMs: percentile(latencies, 0.5),
|
|
166
174
|
p95LatencyMs: percentile(latencies, 0.95),
|
|
167
|
-
|
|
168
|
-
|
|
175
|
+
avgCost: total ? totalCost / total : 0,
|
|
176
|
+
totalCost,
|
|
177
|
+
costCurrency,
|
|
178
|
+
...(costCurrency === "CNY"
|
|
179
|
+
? { avgCostCny: total ? totalCost / total : 0, totalCostCny: totalCost }
|
|
180
|
+
: {}),
|
|
169
181
|
inputTokens,
|
|
170
182
|
cachedTokens,
|
|
171
183
|
outputTokens,
|
|
184
|
+
totalTokens,
|
|
172
185
|
rows
|
|
173
186
|
};
|
|
174
187
|
}
|
package/mf.js
CHANGED
|
@@ -4,7 +4,7 @@ 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,
|
|
7
|
+
import { scoreModel, pickRepresentative, pct, costOf, tokensOf } 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
10
|
import { probeStdioServer, probeHttpServer } from "./lib/mcp-probe.js";
|
|
@@ -16,7 +16,7 @@ import { auditSuite, formatAudit } from "./lib/audit.js";
|
|
|
16
16
|
import { suiteFingerprint, hashText, hashOf } from "./lib/fingerprint.js";
|
|
17
17
|
import { runExecutionChecks } from "./lib/eval-exec.js";
|
|
18
18
|
import { checkSandbox, formatSandboxReport } from "./lib/sandbox-check.js";
|
|
19
|
-
import { compareScorecards, formatCompare } from "./lib/compare.js";
|
|
19
|
+
import { compareScorecards, formatCompare, regressionGate } from "./lib/compare.js";
|
|
20
20
|
import { analyzeAblation, formatAblation } from "./lib/ablate.js";
|
|
21
21
|
import { clusterTools, formatClusters } from "./lib/cluster.js";
|
|
22
22
|
import {
|
|
@@ -31,7 +31,7 @@ import {
|
|
|
31
31
|
formatProfileMarkdown
|
|
32
32
|
} from "./lib/profile.js";
|
|
33
33
|
import { runExec } from "./lib/exec.js";
|
|
34
|
-
import { createBudget, CostLimitError } from "./lib/budget.js";
|
|
34
|
+
import { createBudget, CostLimitError, TokenLimitError } from "./lib/budget.js";
|
|
35
35
|
import { writeFileAtomic } from "./lib/fsutil.js";
|
|
36
36
|
import {
|
|
37
37
|
applyModelCredentials,
|
|
@@ -77,7 +77,7 @@ Usage:
|
|
|
77
77
|
mf lint [--suite <file>] [--json] # 静态检查 tools 与 skills,不调用模型
|
|
78
78
|
mf check (--manifest <file> | --manifests <file>) [--installed <file>] [--json] # 安装门禁:新能力 × 已装能力(--manifests 支持一包)
|
|
79
79
|
mf compare <before.json> <after.json> [--json]
|
|
80
|
-
mf ablate --tool <name> [--suite <file>] [--profile <group>] [--models <ids>] [--max-cost <
|
|
80
|
+
mf ablate --tool <name> [--suite <file>] [--profile <group>] [--models <ids>] [--max-cost <amount>] [--max-tokens <n>] [--progress] [--json]
|
|
81
81
|
mf cluster [--suite <file>] [--threshold <0.5>] [--max-size <10>] [--out <file>] [--json] # 建议挂载分组 + 同族候选
|
|
82
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]
|
|
83
83
|
mf sandbox check [--json] # probe available isolation engines on this machine
|
|
@@ -96,7 +96,10 @@ Eval options:
|
|
|
96
96
|
--case <ids> run only these case ids (comma-separated, repeatable)
|
|
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
|
-
--max-cost <
|
|
99
|
+
--max-cost <amount> stop after the estimated spend crosses this limit (exit 2; no partial scorecard)
|
|
100
|
+
--max-tokens <n> stop after cumulative input+output tokens cross this limit (exit 2)
|
|
101
|
+
--retry <n> retries per request on HTTP 429/5xx and network errors (default 2; 3 attempts total)
|
|
102
|
+
--min-interval <ms> minimum spacing between model requests; use it to stay under an endpoint RPM
|
|
100
103
|
--progress print per-case progress to stderr (stdout stays clean for --json)
|
|
101
104
|
--show-cases print per-case results
|
|
102
105
|
--json print JSON instead of the table
|
|
@@ -136,14 +139,19 @@ export async function runEval(options, io = console) {
|
|
|
136
139
|
}
|
|
137
140
|
const suite = await loadSuite(options.suite);
|
|
138
141
|
const cases = selectCases(suite.cases, options.cases);
|
|
139
|
-
const { registry, adapters } = await resolveModels(options.models, options.modelsFile, io
|
|
142
|
+
const { registry, adapters } = await resolveModels(options.models, options.modelsFile, io, {
|
|
143
|
+
retries: options.retries,
|
|
144
|
+
minIntervalMs: options.minIntervalMs
|
|
145
|
+
});
|
|
140
146
|
const tools = toModelTools(suite.tools);
|
|
141
147
|
|
|
142
|
-
const budget = createBudget(options.maxCost ?? null);
|
|
148
|
+
const budget = createBudget({ maxCost: options.maxCost ?? null, maxTokens: options.maxTokens ?? null });
|
|
143
149
|
const runs = [];
|
|
144
150
|
try {
|
|
145
151
|
for (const adapter of adapters) {
|
|
146
|
-
const
|
|
152
|
+
const config = registry[adapter.id];
|
|
153
|
+
const pricing = config?.pricing ?? null;
|
|
154
|
+
const currency = currencyOf(adapter.id, config);
|
|
147
155
|
const evaluated = await evaluate(adapter, tools, cases, options.repeat ?? 1, {
|
|
148
156
|
pricing,
|
|
149
157
|
budget,
|
|
@@ -154,14 +162,15 @@ export async function runEval(options, io = console) {
|
|
|
154
162
|
cases,
|
|
155
163
|
results: evaluated.results,
|
|
156
164
|
pricing,
|
|
157
|
-
scope: "base"
|
|
165
|
+
scope: "base",
|
|
166
|
+
currency
|
|
158
167
|
});
|
|
159
168
|
if (evaluated.stability) summary.stability = evaluated.stability;
|
|
160
169
|
runs.push({ adapter, results: evaluated.results, summary });
|
|
161
170
|
}
|
|
162
171
|
} catch (error) {
|
|
163
|
-
if (error instanceof CostLimitError) {
|
|
164
|
-
io.error(
|
|
172
|
+
if (error instanceof CostLimitError || error instanceof TokenLimitError) {
|
|
173
|
+
io.error(budgetLimitMessage(error));
|
|
165
174
|
return 2;
|
|
166
175
|
}
|
|
167
176
|
throw error;
|
|
@@ -208,8 +217,8 @@ export async function runEval(options, io = console) {
|
|
|
208
217
|
io
|
|
209
218
|
});
|
|
210
219
|
} catch (error) {
|
|
211
|
-
if (error instanceof CostLimitError) {
|
|
212
|
-
io.error(
|
|
220
|
+
if (error instanceof CostLimitError || error instanceof TokenLimitError) {
|
|
221
|
+
io.error(budgetLimitMessage(error));
|
|
213
222
|
return 2;
|
|
214
223
|
}
|
|
215
224
|
throw error;
|
|
@@ -306,13 +315,15 @@ export async function runEval(options, io = console) {
|
|
|
306
315
|
io.log("");
|
|
307
316
|
io.log(formatCompare(comparison));
|
|
308
317
|
}
|
|
309
|
-
const
|
|
310
|
-
if (
|
|
311
|
-
io.error(
|
|
318
|
+
const gate = regressionGate(comparison, summaries);
|
|
319
|
+
if (gate.status === "fail") {
|
|
320
|
+
io.error(gate.message);
|
|
321
|
+
return 2;
|
|
322
|
+
}
|
|
323
|
+
if (gate.status === "skip") {
|
|
324
|
+
io.error(gate.message);
|
|
312
325
|
} else {
|
|
313
|
-
regressions =
|
|
314
|
-
model.regressions.map((item) => `${model.model}/${item.id}`)
|
|
315
|
-
);
|
|
326
|
+
regressions = gate.regressions;
|
|
316
327
|
if (regressions.length > 0) io.error(`regressions: ${regressions.join(", ")}`);
|
|
317
328
|
}
|
|
318
329
|
}
|
|
@@ -393,16 +404,21 @@ export async function runAblate(options, io = console) {
|
|
|
393
404
|
: selectedCases;
|
|
394
405
|
const reducedCases = baseCases.filter((testCase) => testCase.expectedTool !== ablated.name);
|
|
395
406
|
const droppedCases = baseCases.length - reducedCases.length;
|
|
396
|
-
const { registry, adapters } = await resolveModels(options.models, options.modelsFile, io
|
|
407
|
+
const { registry, adapters } = await resolveModels(options.models, options.modelsFile, io, {
|
|
408
|
+
retries: options.retries,
|
|
409
|
+
minIntervalMs: options.minIntervalMs
|
|
410
|
+
});
|
|
397
411
|
|
|
398
412
|
const fullTools = toModelTools(baseTools);
|
|
399
413
|
const reducedTools = toModelTools(baseTools.filter((tool) => tool.name !== ablated.name));
|
|
400
414
|
const models = [];
|
|
401
|
-
const budget = createBudget(options.maxCost ?? null);
|
|
415
|
+
const budget = createBudget({ maxCost: options.maxCost ?? null, maxTokens: options.maxTokens ?? null });
|
|
402
416
|
|
|
403
417
|
try {
|
|
404
418
|
for (const adapter of adapters) {
|
|
405
|
-
const
|
|
419
|
+
const config = registry[adapter.id];
|
|
420
|
+
const pricing = config?.pricing ?? null;
|
|
421
|
+
const currency = currencyOf(adapter.id, config);
|
|
406
422
|
const fullResults = (
|
|
407
423
|
await evaluate(adapter, fullTools, baseCases, 1, {
|
|
408
424
|
pricing,
|
|
@@ -410,7 +426,7 @@ export async function runAblate(options, io = console) {
|
|
|
410
426
|
onProgress: makeProgress(options, io, `${adapter.id}/full`, baseCases.length)
|
|
411
427
|
})
|
|
412
428
|
).results;
|
|
413
|
-
const full = scoreModel({ model: adapter.id, cases: baseCases, results: fullResults, pricing });
|
|
429
|
+
const full = scoreModel({ model: adapter.id, cases: baseCases, results: fullResults, pricing, currency });
|
|
414
430
|
const reducedResults = (
|
|
415
431
|
await evaluate(adapter, reducedTools, reducedCases, 1, {
|
|
416
432
|
pricing,
|
|
@@ -418,12 +434,12 @@ export async function runAblate(options, io = console) {
|
|
|
418
434
|
onProgress: makeProgress(options, io, `${adapter.id}/ablated`, reducedCases.length)
|
|
419
435
|
})
|
|
420
436
|
).results;
|
|
421
|
-
const reduced = scoreModel({ model: adapter.id, cases: reducedCases, results: reducedResults, pricing });
|
|
437
|
+
const reduced = scoreModel({ model: adapter.id, cases: reducedCases, results: reducedResults, pricing, currency });
|
|
422
438
|
models.push(analyzeAblation(adapter.id, full, reduced));
|
|
423
439
|
}
|
|
424
440
|
} catch (error) {
|
|
425
|
-
if (error instanceof CostLimitError) {
|
|
426
|
-
io.error(
|
|
441
|
+
if (error instanceof CostLimitError || error instanceof TokenLimitError) {
|
|
442
|
+
io.error(budgetLimitMessage(error));
|
|
427
443
|
return 2;
|
|
428
444
|
}
|
|
429
445
|
throw error;
|
|
@@ -778,12 +794,26 @@ function selectCases(cases, ids) {
|
|
|
778
794
|
return cases.filter((testCase) => wanted.has(testCase.id));
|
|
779
795
|
}
|
|
780
796
|
|
|
781
|
-
|
|
797
|
+
function currencyOf(id, config) {
|
|
798
|
+
const code = String(config?.currency ?? "CNY").trim().toUpperCase();
|
|
799
|
+
if (!/^[A-Z]{3}$/.test(code)) {
|
|
800
|
+
throw new Error(`model "${id}": currency must be a 3-letter code (got "${config.currency}")`);
|
|
801
|
+
}
|
|
802
|
+
return code;
|
|
803
|
+
}
|
|
804
|
+
|
|
805
|
+
async function resolveModels(ids, modelsFile, io, network = {}) {
|
|
782
806
|
const registry = JSON.parse(await readFile(resolve(modelsFile), "utf8"));
|
|
783
807
|
// Env wins; the credentials file only fills what is missing.
|
|
784
808
|
await applyModelCredentials(registry);
|
|
785
809
|
const explicit = Boolean(ids && ids.length > 0);
|
|
786
810
|
const chosen = explicit ? ids : autoSelect(registry);
|
|
811
|
+
if (!explicit && chosen.length > 0) {
|
|
812
|
+
const detail = chosen
|
|
813
|
+
.map((id) => `${id} (${registry[id]?.apiKeyEnv ?? "?"})`)
|
|
814
|
+
.join(", ");
|
|
815
|
+
io.error(`note: auto-selected ${detail} from ${modelsFile}; pass --models or --models-file to pin the registry`);
|
|
816
|
+
}
|
|
787
817
|
const adapters = [];
|
|
788
818
|
|
|
789
819
|
for (const id of chosen) {
|
|
@@ -805,7 +835,15 @@ async function resolveModels(ids, modelsFile, io) {
|
|
|
805
835
|
continue;
|
|
806
836
|
}
|
|
807
837
|
registerSecrets([apiKey]);
|
|
808
|
-
adapters.push({
|
|
838
|
+
adapters.push({
|
|
839
|
+
id,
|
|
840
|
+
...createOpenAiAdapter({
|
|
841
|
+
id,
|
|
842
|
+
...config,
|
|
843
|
+
retries: network.retries,
|
|
844
|
+
minIntervalMs: network.minIntervalMs
|
|
845
|
+
})
|
|
846
|
+
});
|
|
809
847
|
}
|
|
810
848
|
|
|
811
849
|
if (adapters.length === 0) {
|
|
@@ -835,7 +873,7 @@ async function evaluate(
|
|
|
835
873
|
const runs = [];
|
|
836
874
|
for (let attempt = 0; attempt < repeat; attempt += 1) {
|
|
837
875
|
const result = await callOnce(adapter, tools, testCase);
|
|
838
|
-
budget?.spend(
|
|
876
|
+
budget?.spend({ cost: costOf(result.usage, pricing), tokens: tokensOf(result.usage) });
|
|
839
877
|
runs.push(result);
|
|
840
878
|
}
|
|
841
879
|
if (repeat === 1) {
|
|
@@ -859,8 +897,9 @@ function makeProgress(options, io, label, total) {
|
|
|
859
897
|
};
|
|
860
898
|
}
|
|
861
899
|
|
|
862
|
-
function
|
|
863
|
-
|
|
900
|
+
function budgetLimitMessage(error) {
|
|
901
|
+
const hint = error instanceof TokenLimitError ? "--max-tokens" : "--max-cost";
|
|
902
|
+
return `${error.message};已发出的请求已计费,本次不计分。减少 --case/--models/--repeat 或提高 ${hint} 后重跑。`;
|
|
864
903
|
}
|
|
865
904
|
|
|
866
905
|
async function callOnce(adapter, tools, testCase) {
|
|
@@ -949,7 +988,9 @@ async function evaluateProfiles({
|
|
|
949
988
|
|
|
950
989
|
const models = [];
|
|
951
990
|
for (const run of runs) {
|
|
952
|
-
const
|
|
991
|
+
const config = registry[run.adapter.id];
|
|
992
|
+
const pricing = config?.pricing ?? null;
|
|
993
|
+
const currency = currencyOf(run.adapter.id, config);
|
|
953
994
|
const entries = [];
|
|
954
995
|
for (const [index, profile] of selected.entries()) {
|
|
955
996
|
const profileCases = caseSets[index];
|
|
@@ -965,21 +1006,26 @@ async function evaluateProfiles({
|
|
|
965
1006
|
cases: profileCases,
|
|
966
1007
|
results: mountedEval.results,
|
|
967
1008
|
pricing,
|
|
968
|
-
scope: "profiles"
|
|
1009
|
+
scope: "profiles",
|
|
1010
|
+
currency
|
|
969
1011
|
});
|
|
970
1012
|
const allInOne = scoreModel({
|
|
971
1013
|
model: run.adapter.id,
|
|
972
1014
|
cases: profileCases,
|
|
973
1015
|
results: profileCases.map((testCase) => run.results[indexById.get(testCase.id)]),
|
|
974
1016
|
pricing,
|
|
975
|
-
scope: "profiles"
|
|
1017
|
+
scope: "profiles",
|
|
1018
|
+
currency
|
|
976
1019
|
});
|
|
977
1020
|
const entry = analyzeMount(profile, mounted, allInOne);
|
|
978
1021
|
entry.usage = {
|
|
979
1022
|
inputTokens: mounted.inputTokens,
|
|
980
1023
|
cachedTokens: mounted.cachedTokens,
|
|
981
1024
|
outputTokens: mounted.outputTokens,
|
|
982
|
-
|
|
1025
|
+
totalTokens: mounted.totalTokens,
|
|
1026
|
+
cost: mounted.totalCost,
|
|
1027
|
+
costCurrency: mounted.costCurrency,
|
|
1028
|
+
...(mounted.costCurrency === "CNY" ? { costCny: mounted.totalCostCny } : {}),
|
|
983
1029
|
estimated: mounted.rows.some((row) => row.usage?.estimated)
|
|
984
1030
|
};
|
|
985
1031
|
if (mountedEval.stability) entry.stability = mountedEval.stability;
|
|
@@ -1032,6 +1078,9 @@ function parseArgs(argv) {
|
|
|
1032
1078
|
profiles: null,
|
|
1033
1079
|
repeat: 1,
|
|
1034
1080
|
maxCost: null,
|
|
1081
|
+
maxTokens: null,
|
|
1082
|
+
retries: 2,
|
|
1083
|
+
minIntervalMs: 0,
|
|
1035
1084
|
progress: false,
|
|
1036
1085
|
showCases: false,
|
|
1037
1086
|
json: false,
|
|
@@ -1093,10 +1142,28 @@ function parseArgs(argv) {
|
|
|
1093
1142
|
}
|
|
1094
1143
|
case "--max-cost": {
|
|
1095
1144
|
const raw = Number(value());
|
|
1096
|
-
if (!Number.isFinite(raw) || raw <= 0) throw new Error("--max-cost expects a positive
|
|
1145
|
+
if (!Number.isFinite(raw) || raw <= 0) throw new Error("--max-cost expects a positive amount");
|
|
1097
1146
|
options.maxCost = raw;
|
|
1098
1147
|
break;
|
|
1099
1148
|
}
|
|
1149
|
+
case "--max-tokens": {
|
|
1150
|
+
const raw = Number(value());
|
|
1151
|
+
if (!Number.isInteger(raw) || raw <= 0) throw new Error("--max-tokens expects a positive integer");
|
|
1152
|
+
options.maxTokens = raw;
|
|
1153
|
+
break;
|
|
1154
|
+
}
|
|
1155
|
+
case "--retry": {
|
|
1156
|
+
const raw = Number(value());
|
|
1157
|
+
if (!Number.isInteger(raw) || raw < 0 || raw > 10) throw new Error("--retry expects an integer between 0 and 10");
|
|
1158
|
+
options.retries = raw;
|
|
1159
|
+
break;
|
|
1160
|
+
}
|
|
1161
|
+
case "--min-interval": {
|
|
1162
|
+
const raw = Number(value());
|
|
1163
|
+
if (!Number.isInteger(raw) || raw < 0 || raw > 60_000) throw new Error("--min-interval expects an integer between 0 and 60000 (ms)");
|
|
1164
|
+
options.minIntervalMs = raw;
|
|
1165
|
+
break;
|
|
1166
|
+
}
|
|
1100
1167
|
case "--progress": options.progress = true; break;
|
|
1101
1168
|
case "--show-cases": options.showCases = true; break;
|
|
1102
1169
|
case "--json": options.json = true; break;
|