modelfunction-cli 0.4.4 → 0.5.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +36 -6
- package/SCHEMA.md +112 -4
- package/lib/audit.js +149 -0
- package/lib/budget.js +30 -0
- package/lib/credentials.js +121 -0
- package/lib/fsutil.js +5 -3
- package/lib/mcp-probe.js +59 -9
- package/lib/mcp-server.js +29 -9
- package/lib/sandbox-check.js +3 -1
- package/lib/scan.js +20 -2
- package/lib/score.js +14 -7
- package/lib/usage.js +341 -0
- package/mf.js +441 -59
- package/package.json +1 -1
package/mf.js
CHANGED
|
@@ -1,17 +1,18 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import { realpathSync } from "node:fs";
|
|
3
|
-
import { readFile } from "node:fs/promises";
|
|
3
|
+
import { mkdir, readFile } from "node:fs/promises";
|
|
4
4
|
import { basename, dirname, extname, relative, resolve } from "node:path";
|
|
5
5
|
import { fileURLToPath } from "node:url";
|
|
6
6
|
import { loadSuite, toModelTools } from "./lib/suite.js";
|
|
7
|
-
import { scoreModel, pickRepresentative, pct } from "./lib/score.js";
|
|
7
|
+
import { scoreModel, pickRepresentative, pct, costCnyOf } from "./lib/score.js";
|
|
8
8
|
import { formatTable, formatCaseLines, formatMarkdown } from "./lib/report.js";
|
|
9
9
|
import { scanProject, buildSuiteDraft, formatScanSummary, addTools, redactReport } from "./lib/scan.js";
|
|
10
|
-
import { probeStdioServer } from "./lib/mcp-probe.js";
|
|
10
|
+
import { probeStdioServer, probeHttpServer } from "./lib/mcp-probe.js";
|
|
11
11
|
import { loadAdapterTools } from "./lib/adapter-import.js";
|
|
12
12
|
import { addCases, computeCoverage, formatCoverage } from "./lib/cases.js";
|
|
13
13
|
import { lintSuite, formatLint } from "./lib/lint.js";
|
|
14
14
|
import { check, formatCheck } from "./lib/check.js";
|
|
15
|
+
import { auditSuite, formatAudit } from "./lib/audit.js";
|
|
15
16
|
import { suiteFingerprint, hashText, hashOf } from "./lib/fingerprint.js";
|
|
16
17
|
import { runExecutionChecks } from "./lib/eval-exec.js";
|
|
17
18
|
import { checkSandbox, formatSandboxReport } from "./lib/sandbox-check.js";
|
|
@@ -30,7 +31,27 @@ import {
|
|
|
30
31
|
formatProfileMarkdown
|
|
31
32
|
} from "./lib/profile.js";
|
|
32
33
|
import { runExec } from "./lib/exec.js";
|
|
34
|
+
import { createBudget, CostLimitError } from "./lib/budget.js";
|
|
33
35
|
import { writeFileAtomic } from "./lib/fsutil.js";
|
|
36
|
+
import {
|
|
37
|
+
applyModelCredentials,
|
|
38
|
+
credentialsPath,
|
|
39
|
+
listCredentials,
|
|
40
|
+
redactText,
|
|
41
|
+
registerSecrets,
|
|
42
|
+
removeCredential,
|
|
43
|
+
setCredential
|
|
44
|
+
} from "./lib/credentials.js";
|
|
45
|
+
import {
|
|
46
|
+
buildFeedbackBundle,
|
|
47
|
+
defaultFeedbackPath,
|
|
48
|
+
formatStats,
|
|
49
|
+
noteUsage,
|
|
50
|
+
readUsageEvents,
|
|
51
|
+
recordRunSync,
|
|
52
|
+
summarizeUsage,
|
|
53
|
+
usageDir
|
|
54
|
+
} from "./lib/usage.js";
|
|
34
55
|
import { createMockAdapter } from "./lib/adapters/mock.js";
|
|
35
56
|
import { createOpenAiAdapter } from "./lib/adapters/openai.js";
|
|
36
57
|
|
|
@@ -52,13 +73,20 @@ Usage:
|
|
|
52
73
|
mf scan [options]
|
|
53
74
|
mf case add --suite <file> (--input <file> | stdin) [--source agent] [--reviewed] [--dry-run]
|
|
54
75
|
mf coverage [--suite <file>] [--json]
|
|
76
|
+
mf audit [--suite <file>] [--out <file>] [--json] # 静态审计:lint + 分组边界 + 用例覆盖 + 挂载
|
|
55
77
|
mf lint [--suite <file>] [--json] # 静态检查 tools 与 skills,不调用模型
|
|
56
78
|
mf check --manifest <file> [--installed <file>] [--json] # 安装门禁:新能力 × 已装能力
|
|
57
79
|
mf compare <before.json> <after.json> [--json]
|
|
58
|
-
mf ablate --tool <name> [--suite <file>] [--profile <group>] [--models <ids>] [--json]
|
|
80
|
+
mf ablate --tool <name> [--suite <file>] [--profile <group>] [--models <ids>] [--max-cost <cny>] [--progress] [--json]
|
|
59
81
|
mf cluster [--suite <file>] [--threshold <0.5>] [--max-size <10>] [--out <file>] [--json] # 建议挂载分组 + 同族候选
|
|
60
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]
|
|
61
83
|
mf sandbox check [--json] # probe available isolation engines on this machine
|
|
84
|
+
mf config set-key <ENV_NAME> # store a model key in ~/.modelfunction/credentials.json (hidden input)
|
|
85
|
+
mf config remove-key <ENV_NAME> # delete a stored key
|
|
86
|
+
mf config list [--json] # names + masked values only
|
|
87
|
+
mf config path # print the credentials file path
|
|
88
|
+
mf stats [--json] # local usage summary (never uploaded)
|
|
89
|
+
mf feedback [--out <file>] [--json] # export a redacted feedback bundle; you decide whether to send it
|
|
62
90
|
mf mcp # start stdio MCP server for coding agents
|
|
63
91
|
|
|
64
92
|
Eval options:
|
|
@@ -68,6 +96,8 @@ Eval options:
|
|
|
68
96
|
--case <ids> run only these case ids (comma-separated, repeatable)
|
|
69
97
|
--profile <names> score each mounted group separately ("all" or names from suite.profiles)
|
|
70
98
|
--repeat <n> run each case n times and score the majority; reports unstable cases (default 1)
|
|
99
|
+
--max-cost <cny> stop after the estimated spend crosses this limit (exit 2; no partial scorecard)
|
|
100
|
+
--progress print per-case progress to stderr (stdout stays clean for --json)
|
|
71
101
|
--show-cases print per-case results
|
|
72
102
|
--json print JSON instead of the table
|
|
73
103
|
--out <file> write scorecard (.md => markdown, otherwise JSON)
|
|
@@ -81,13 +111,15 @@ Eval options:
|
|
|
81
111
|
Scan options:
|
|
82
112
|
--root <dir> repository to scan (default: .)
|
|
83
113
|
--from <file> import tools from a module (export default [...] or export const tools)
|
|
84
|
-
--probe
|
|
114
|
+
--probe connect stdio or streamable-HTTP MCP servers and read tools/list
|
|
85
115
|
--timeout <ms> probe timeout per server (default: 5000)
|
|
86
116
|
--out <file> suite draft to write (default: mf.suite.json)
|
|
87
117
|
--report <file> scan report to write (default: mf.scan.json)
|
|
88
118
|
--no-write print only, do not write files
|
|
89
119
|
|
|
90
120
|
Env keys: DEEPSEEK_API_KEY, DASHSCOPE_API_KEY, ZHIPU_API_KEY, OPENAI_API_KEY, SILICONFLOW_API_KEY
|
|
121
|
+
Credentials: env vars win; missing model keys are read from ~/.modelfunction/credentials.json (MF_CONFIG_DIR overrides).
|
|
122
|
+
Usage: recorded locally under ~/.modelfunction/usage (counts only, never uploaded); MF_NO_USAGE=1 opts out.
|
|
91
123
|
|
|
92
124
|
Examples:
|
|
93
125
|
mf eval --models mock --show-cases
|
|
@@ -107,17 +139,31 @@ export async function runEval(options, io = console) {
|
|
|
107
139
|
const { registry, adapters } = await resolveModels(options.models, options.modelsFile, io);
|
|
108
140
|
const tools = toModelTools(suite.tools);
|
|
109
141
|
|
|
142
|
+
const budget = createBudget(options.maxCost ?? null);
|
|
110
143
|
const runs = [];
|
|
111
|
-
|
|
112
|
-
const
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
144
|
+
try {
|
|
145
|
+
for (const adapter of adapters) {
|
|
146
|
+
const pricing = registry[adapter.id]?.pricing ?? null;
|
|
147
|
+
const evaluated = await evaluate(adapter, tools, cases, options.repeat ?? 1, {
|
|
148
|
+
pricing,
|
|
149
|
+
budget,
|
|
150
|
+
onProgress: makeProgress(options, io, adapter.id, cases.length)
|
|
151
|
+
});
|
|
152
|
+
const summary = scoreModel({
|
|
153
|
+
model: adapter.id,
|
|
154
|
+
cases,
|
|
155
|
+
results: evaluated.results,
|
|
156
|
+
pricing
|
|
157
|
+
});
|
|
158
|
+
if (evaluated.stability) summary.stability = evaluated.stability;
|
|
159
|
+
runs.push({ adapter, results: evaluated.results, summary });
|
|
160
|
+
}
|
|
161
|
+
} catch (error) {
|
|
162
|
+
if (error instanceof CostLimitError) {
|
|
163
|
+
io.error(costLimitMessage(error));
|
|
164
|
+
return 2;
|
|
165
|
+
}
|
|
166
|
+
throw error;
|
|
121
167
|
}
|
|
122
168
|
const summaries = runs.map((run) => run.summary);
|
|
123
169
|
|
|
@@ -146,16 +192,28 @@ export async function runEval(options, io = console) {
|
|
|
146
192
|
};
|
|
147
193
|
}
|
|
148
194
|
|
|
149
|
-
|
|
150
|
-
|
|
195
|
+
let profileReport = null;
|
|
196
|
+
if (options.profiles) {
|
|
197
|
+
try {
|
|
198
|
+
profileReport = await evaluateProfiles({
|
|
151
199
|
suite,
|
|
152
200
|
cases,
|
|
153
201
|
runs,
|
|
154
202
|
registry,
|
|
155
203
|
selection: options.profiles,
|
|
156
|
-
repeat: options.repeat ?? 1
|
|
157
|
-
|
|
158
|
-
|
|
204
|
+
repeat: options.repeat ?? 1,
|
|
205
|
+
budget,
|
|
206
|
+
progress: options.progress,
|
|
207
|
+
io
|
|
208
|
+
});
|
|
209
|
+
} catch (error) {
|
|
210
|
+
if (error instanceof CostLimitError) {
|
|
211
|
+
io.error(costLimitMessage(error));
|
|
212
|
+
return 2;
|
|
213
|
+
}
|
|
214
|
+
throw error;
|
|
215
|
+
}
|
|
216
|
+
}
|
|
159
217
|
|
|
160
218
|
if (options.out) {
|
|
161
219
|
const target = resolve(options.out);
|
|
@@ -252,6 +310,35 @@ export async function runEval(options, io = console) {
|
|
|
252
310
|
const failed = options.failUnder === null
|
|
253
311
|
? []
|
|
254
312
|
: summaries.filter((summary) => summary.selectionAccuracy < options.failUnder);
|
|
313
|
+
const profileBelow =
|
|
314
|
+
profileReport && options.failUnder !== null
|
|
315
|
+
? profileReport.models.flatMap((model) =>
|
|
316
|
+
model.profiles
|
|
317
|
+
.filter((entry) => entry.reviewed > 0 && entry.mounted.selectionAccuracy < options.failUnder)
|
|
318
|
+
.map((entry) => `${model.model}/${entry.profile} (${pct(entry.mounted.selectionAccuracy)})`)
|
|
319
|
+
)
|
|
320
|
+
: [];
|
|
321
|
+
|
|
322
|
+
noteUsage({
|
|
323
|
+
models: summaries.map((summary) => summary.model),
|
|
324
|
+
caseCount: cases.length,
|
|
325
|
+
toolCount: suite.tools.length,
|
|
326
|
+
reviewedCount: cases.filter((testCase) => testCase.reviewed !== false).length,
|
|
327
|
+
repeat: options.repeat ?? 1,
|
|
328
|
+
profileGroups: profileReport ? profileReport.groups.length : 0,
|
|
329
|
+
selectionAccuracy: average(summaries.map((summary) => summary.selectionAccuracy)),
|
|
330
|
+
argAccuracy: average(summaries.map((summary) => summary.argAccuracy)),
|
|
331
|
+
passRate: average(summaries.map((summary) => summary.passRate)),
|
|
332
|
+
unstableCases: summaries.reduce((total, summary) => total + (summary.stability?.unstable.length ?? 0), 0),
|
|
333
|
+
execEngine: executionInfo?.engine,
|
|
334
|
+
execExecuted: sumExec(summaries, "executed"),
|
|
335
|
+
execPassed: sumExec(summaries, "passed"),
|
|
336
|
+
execBlocked: sumExec(summaries, "blocked"),
|
|
337
|
+
execSkipped: sumExec(summaries, "skipped"),
|
|
338
|
+
failedThreshold: failed.length > 0 || profileBelow.length > 0,
|
|
339
|
+
regressions: regressions.length > 0
|
|
340
|
+
});
|
|
341
|
+
|
|
255
342
|
if (failed.length > 0) {
|
|
256
343
|
io.error(
|
|
257
344
|
`below threshold ${pct(options.failUnder)}: ${failed
|
|
@@ -260,21 +347,24 @@ export async function runEval(options, io = console) {
|
|
|
260
347
|
);
|
|
261
348
|
return 2;
|
|
262
349
|
}
|
|
263
|
-
if (
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
.filter((entry) => entry.reviewed > 0 && entry.mounted.selectionAccuracy < options.failUnder)
|
|
267
|
-
.map((entry) => `${model.model}/${entry.profile} (${pct(entry.mounted.selectionAccuracy)})`)
|
|
268
|
-
);
|
|
269
|
-
if (below.length > 0) {
|
|
270
|
-
io.error(`profile below threshold ${pct(options.failUnder)}: ${below.join(", ")}`);
|
|
271
|
-
return 2;
|
|
272
|
-
}
|
|
350
|
+
if (profileBelow.length > 0) {
|
|
351
|
+
io.error(`profile below threshold ${pct(options.failUnder)}: ${profileBelow.join(", ")}`);
|
|
352
|
+
return 2;
|
|
273
353
|
}
|
|
274
354
|
if (regressions.length > 0) return 2;
|
|
275
355
|
return 0;
|
|
276
356
|
}
|
|
277
357
|
|
|
358
|
+
function average(values) {
|
|
359
|
+
const usable = values.filter((value) => typeof value === "number" && Number.isFinite(value));
|
|
360
|
+
if (usable.length === 0) return undefined;
|
|
361
|
+
return usable.reduce((sum, value) => sum + value, 0) / usable.length;
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
function sumExec(summaries, field) {
|
|
365
|
+
return summaries.reduce((total, summary) => total + (summary.exec?.[field] ?? 0), 0);
|
|
366
|
+
}
|
|
367
|
+
|
|
278
368
|
export async function runAblate(options, io = console) {
|
|
279
369
|
const suite = await loadSuite(resolve(options.suite ?? DEFAULT_SUITE));
|
|
280
370
|
if (!options.tool) throw new Error("--tool is required");
|
|
@@ -298,14 +388,35 @@ export async function runAblate(options, io = console) {
|
|
|
298
388
|
const fullTools = toModelTools(baseTools);
|
|
299
389
|
const reducedTools = toModelTools(baseTools.filter((tool) => tool.name !== ablated.name));
|
|
300
390
|
const models = [];
|
|
391
|
+
const budget = createBudget(options.maxCost ?? null);
|
|
301
392
|
|
|
302
|
-
|
|
303
|
-
const
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
393
|
+
try {
|
|
394
|
+
for (const adapter of adapters) {
|
|
395
|
+
const pricing = registry[adapter.id]?.pricing ?? null;
|
|
396
|
+
const fullResults = (
|
|
397
|
+
await evaluate(adapter, fullTools, baseCases, 1, {
|
|
398
|
+
pricing,
|
|
399
|
+
budget,
|
|
400
|
+
onProgress: makeProgress(options, io, `${adapter.id}/full`, baseCases.length)
|
|
401
|
+
})
|
|
402
|
+
).results;
|
|
403
|
+
const full = scoreModel({ model: adapter.id, cases: baseCases, results: fullResults, pricing });
|
|
404
|
+
const reducedResults = (
|
|
405
|
+
await evaluate(adapter, reducedTools, reducedCases, 1, {
|
|
406
|
+
pricing,
|
|
407
|
+
budget,
|
|
408
|
+
onProgress: makeProgress(options, io, `${adapter.id}/ablated`, reducedCases.length)
|
|
409
|
+
})
|
|
410
|
+
).results;
|
|
411
|
+
const reduced = scoreModel({ model: adapter.id, cases: reducedCases, results: reducedResults, pricing });
|
|
412
|
+
models.push(analyzeAblation(adapter.id, full, reduced));
|
|
413
|
+
}
|
|
414
|
+
} catch (error) {
|
|
415
|
+
if (error instanceof CostLimitError) {
|
|
416
|
+
io.error(costLimitMessage(error));
|
|
417
|
+
return 2;
|
|
418
|
+
}
|
|
419
|
+
throw error;
|
|
309
420
|
}
|
|
310
421
|
|
|
311
422
|
const payload = {
|
|
@@ -315,6 +426,12 @@ export async function runAblate(options, io = console) {
|
|
|
315
426
|
models,
|
|
316
427
|
...(scope ? { profile: { name: scope.name, tools: baseTools.length } } : {})
|
|
317
428
|
};
|
|
429
|
+
noteUsage({
|
|
430
|
+
models: models.map((model) => model.model),
|
|
431
|
+
caseCount: baseCases.length,
|
|
432
|
+
toolCount: baseTools.length,
|
|
433
|
+
profileGroups: scope ? 1 : 0
|
|
434
|
+
});
|
|
318
435
|
io.log(options.json ? JSON.stringify(payload, null, 2) : formatAblation(payload));
|
|
319
436
|
return 0;
|
|
320
437
|
}
|
|
@@ -334,11 +451,10 @@ export async function runScan(options, io = console) {
|
|
|
334
451
|
|
|
335
452
|
if (options.probe) {
|
|
336
453
|
for (const server of [...report.mcpServers]) {
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
const result = await probeStdioServer(server, { timeoutMs: options.timeoutMs });
|
|
454
|
+
const result =
|
|
455
|
+
server.transport === "stdio"
|
|
456
|
+
? await probeStdioServer(server, { timeoutMs: options.timeoutMs })
|
|
457
|
+
: await probeHttpServer(server, { timeoutMs: options.timeoutMs });
|
|
342
458
|
if (!result.ok) {
|
|
343
459
|
report.probes.push({ server: server.name, ok: false, error: result.error, stderr: result.stderr });
|
|
344
460
|
continue;
|
|
@@ -355,6 +471,13 @@ export async function runScan(options, io = console) {
|
|
|
355
471
|
}
|
|
356
472
|
}
|
|
357
473
|
|
|
474
|
+
noteUsage({
|
|
475
|
+
scanTools: report.tools.length,
|
|
476
|
+
scanSkills: report.skills.length,
|
|
477
|
+
scanMcpServers: report.mcpServers.length,
|
|
478
|
+
scanProbes: report.probes.length
|
|
479
|
+
});
|
|
480
|
+
|
|
358
481
|
const draft = buildSuiteDraft(report);
|
|
359
482
|
|
|
360
483
|
if (options.json) {
|
|
@@ -418,10 +541,40 @@ export async function runCoverage(options, io = console) {
|
|
|
418
541
|
allowEmptyTools: true
|
|
419
542
|
});
|
|
420
543
|
const report = computeCoverage(suite);
|
|
544
|
+
noteUsage({ toolCount: suite.tools.length, caseCount: suite.cases.length });
|
|
421
545
|
io.log(options.json ? JSON.stringify(report, null, 2) : formatCoverage(report));
|
|
422
546
|
return 0;
|
|
423
547
|
}
|
|
424
548
|
|
|
549
|
+
export async function runAudit(options, io = console) {
|
|
550
|
+
const suite = await loadSuite(resolve(options.suite ?? DEFAULT_SUITE), {
|
|
551
|
+
allowEmptyCases: true,
|
|
552
|
+
allowEmptyTools: true
|
|
553
|
+
});
|
|
554
|
+
const report = auditSuite(suite);
|
|
555
|
+
const rules = {};
|
|
556
|
+
for (const finding of report.lint.findings) rules[finding.code] = (rules[finding.code] ?? 0) + 1;
|
|
557
|
+
noteUsage({
|
|
558
|
+
toolCount: report.suite.tools,
|
|
559
|
+
caseCount: report.suite.cases,
|
|
560
|
+
lintErrors: report.lint.counts.error,
|
|
561
|
+
lintWarns: report.lint.counts.warn,
|
|
562
|
+
lintInfos: report.lint.counts.info,
|
|
563
|
+
lintRules: rules,
|
|
564
|
+
clusters: report.clusters.clusters.length,
|
|
565
|
+
profileGroups: report.profiles?.groups.length ?? 0
|
|
566
|
+
});
|
|
567
|
+
|
|
568
|
+
if (options.out) {
|
|
569
|
+
const target = resolve(options.out);
|
|
570
|
+
const markdown = extname(target).toLowerCase() === ".md";
|
|
571
|
+
await writeFileAtomic(target, markdown ? `${formatAudit(report)}\n` : `${JSON.stringify(report, null, 2)}\n`);
|
|
572
|
+
io.log(`wrote ${target}`);
|
|
573
|
+
}
|
|
574
|
+
io.log(options.json ? JSON.stringify(report, null, 2) : formatAudit(report));
|
|
575
|
+
return 0;
|
|
576
|
+
}
|
|
577
|
+
|
|
425
578
|
export async function runSandboxCheck(options, io = console) {
|
|
426
579
|
const report = checkSandbox();
|
|
427
580
|
io.log(options.json ? JSON.stringify(report, null, 2) : formatSandboxReport(report));
|
|
@@ -437,6 +590,7 @@ export async function runCheck(options, io = console) {
|
|
|
437
590
|
? JSON.parse(await readFile(resolve(options.installed), "utf8"))
|
|
438
591
|
: [];
|
|
439
592
|
const result = check({ manifest, installed });
|
|
593
|
+
noteUsage({ checkVerdict: result.verdict, checkFindings: result.findings.length });
|
|
440
594
|
io.log(options.json ? JSON.stringify(result, null, 2) : formatCheck(result));
|
|
441
595
|
return 0;
|
|
442
596
|
}
|
|
@@ -447,6 +601,15 @@ export async function runLint(options, io = console) {
|
|
|
447
601
|
allowEmptyTools: true
|
|
448
602
|
});
|
|
449
603
|
const report = lintSuite(suite);
|
|
604
|
+
const rules = {};
|
|
605
|
+
for (const finding of report.findings) rules[finding.code] = (rules[finding.code] ?? 0) + 1;
|
|
606
|
+
noteUsage({
|
|
607
|
+
toolCount: report.tools,
|
|
608
|
+
lintErrors: report.counts.error,
|
|
609
|
+
lintWarns: report.counts.warn,
|
|
610
|
+
lintInfos: report.counts.info,
|
|
611
|
+
lintRules: rules
|
|
612
|
+
});
|
|
450
613
|
io.log(options.json ? JSON.stringify(report, null, 2) : formatLint(report));
|
|
451
614
|
return report.counts.error > 0 ? 1 : 0;
|
|
452
615
|
}
|
|
@@ -461,6 +624,123 @@ export async function runCompare(options, io = console) {
|
|
|
461
624
|
return changed ? 1 : 0;
|
|
462
625
|
}
|
|
463
626
|
|
|
627
|
+
export async function runConfig(options, io = console) {
|
|
628
|
+
const [sub, name] = options.args ?? [];
|
|
629
|
+
if (sub === "set-key") {
|
|
630
|
+
if (!name) throw new Error("usage: mf config set-key <ENV_NAME>");
|
|
631
|
+
const value = await (options.readSecret ?? readSecret)(`${name}: `);
|
|
632
|
+
const result = await setCredential(name, value);
|
|
633
|
+
io.log(`saved ${result.name} → ${result.path}`);
|
|
634
|
+
io.log("permissions: 0600 where supported. env vars still win; CI should use env or a secret manager.");
|
|
635
|
+
return 0;
|
|
636
|
+
}
|
|
637
|
+
if (sub === "remove-key") {
|
|
638
|
+
if (!name) throw new Error("usage: mf config remove-key <ENV_NAME>");
|
|
639
|
+
const result = await removeCredential(name);
|
|
640
|
+
io.log(
|
|
641
|
+
result.removed
|
|
642
|
+
? `removed ${result.name} from ${result.path}`
|
|
643
|
+
: `${result.name} is not stored (${result.path})`
|
|
644
|
+
);
|
|
645
|
+
return 0;
|
|
646
|
+
}
|
|
647
|
+
if (sub === "list") {
|
|
648
|
+
const entries = await listCredentials();
|
|
649
|
+
if (options.json) {
|
|
650
|
+
io.log(JSON.stringify({ path: credentialsPath(), keys: entries }, null, 2));
|
|
651
|
+
return 0;
|
|
652
|
+
}
|
|
653
|
+
if (entries.length === 0) {
|
|
654
|
+
io.log(`no stored keys (${credentialsPath()})`);
|
|
655
|
+
return 0;
|
|
656
|
+
}
|
|
657
|
+
for (const entry of entries) io.log(`${entry.name} ${entry.masked}`);
|
|
658
|
+
io.log(`file: ${credentialsPath()}`);
|
|
659
|
+
return 0;
|
|
660
|
+
}
|
|
661
|
+
if (sub === "path") {
|
|
662
|
+
io.log(credentialsPath());
|
|
663
|
+
return 0;
|
|
664
|
+
}
|
|
665
|
+
throw new Error("usage: mf config <set-key <ENV_NAME> | remove-key <ENV_NAME> | list | path>");
|
|
666
|
+
}
|
|
667
|
+
|
|
668
|
+
export async function runStats(options, io = console) {
|
|
669
|
+
const { events, files, skipped } = await readUsageEvents();
|
|
670
|
+
const summary = summarizeUsage(events);
|
|
671
|
+
if (options.json) {
|
|
672
|
+
io.log(JSON.stringify({ path: usageDir(), files: files.length, skipped, ...summary }, null, 2));
|
|
673
|
+
} else {
|
|
674
|
+
io.log(formatStats(summary, { events: events.length, files: files.length, skipped, path: usageDir() }));
|
|
675
|
+
}
|
|
676
|
+
return 0;
|
|
677
|
+
}
|
|
678
|
+
|
|
679
|
+
export async function runFeedback(options, io = console) {
|
|
680
|
+
const { events } = await readUsageEvents();
|
|
681
|
+
const version = await readVersion();
|
|
682
|
+
const bundle = buildFeedbackBundle({ events, version });
|
|
683
|
+
const target = resolve(options.out ?? defaultFeedbackPath());
|
|
684
|
+
await mkdir(dirname(target), { recursive: true, mode: 0o700 });
|
|
685
|
+
await writeFileAtomic(target, `${JSON.stringify(bundle, null, 2)}\n`, { mode: 0o600 });
|
|
686
|
+
if (options.json) io.log(JSON.stringify(bundle, null, 2));
|
|
687
|
+
io.log(`wrote ${target}`);
|
|
688
|
+
io.log(bundle.privacy);
|
|
689
|
+
io.log("send it to hi@yunzerui.com if you want us to look(可选,不发送不影响使用)。");
|
|
690
|
+
return 0;
|
|
691
|
+
}
|
|
692
|
+
|
|
693
|
+
// Hidden input: a key must never appear in argv, shell history, or logs.
|
|
694
|
+
async function readSecret(prompt) {
|
|
695
|
+
const stdin = process.stdin;
|
|
696
|
+
if (!stdin.isTTY) {
|
|
697
|
+
const chunks = [];
|
|
698
|
+
for await (const chunk of stdin) chunks.push(chunk);
|
|
699
|
+
const value = Buffer.concat(chunks).toString("utf8").trim();
|
|
700
|
+
if (!value) throw new Error("empty input; nothing written");
|
|
701
|
+
return value;
|
|
702
|
+
}
|
|
703
|
+
process.stdout.write(prompt);
|
|
704
|
+
return new Promise((resolvePromise, reject) => {
|
|
705
|
+
let value = "";
|
|
706
|
+
const cleanup = () => {
|
|
707
|
+
stdin.removeListener("data", onData);
|
|
708
|
+
stdin.setRawMode(false);
|
|
709
|
+
stdin.pause();
|
|
710
|
+
};
|
|
711
|
+
const onData = (chunk) => {
|
|
712
|
+
for (const char of String(chunk)) {
|
|
713
|
+
if (char === "\u0003") {
|
|
714
|
+
cleanup();
|
|
715
|
+
process.stdout.write("\n");
|
|
716
|
+
reject(new Error("cancelled"));
|
|
717
|
+
return;
|
|
718
|
+
}
|
|
719
|
+
if (char === "\r" || char === "\n") {
|
|
720
|
+
cleanup();
|
|
721
|
+
process.stdout.write("\n");
|
|
722
|
+
resolvePromise(value.trim());
|
|
723
|
+
return;
|
|
724
|
+
}
|
|
725
|
+
if (char === "\u007f" || char === "\b") {
|
|
726
|
+
value = value.slice(0, -1);
|
|
727
|
+
continue;
|
|
728
|
+
}
|
|
729
|
+
value += char;
|
|
730
|
+
}
|
|
731
|
+
};
|
|
732
|
+
stdin.setRawMode(true);
|
|
733
|
+
stdin.setEncoding("utf8");
|
|
734
|
+
stdin.resume();
|
|
735
|
+
stdin.on("data", onData);
|
|
736
|
+
});
|
|
737
|
+
}
|
|
738
|
+
|
|
739
|
+
async function readVersion() {
|
|
740
|
+
const pkg = JSON.parse(await readFile(new URL("./package.json", import.meta.url), "utf8"));
|
|
741
|
+
return pkg.version;
|
|
742
|
+
}
|
|
743
|
+
|
|
464
744
|
async function readStdin() {
|
|
465
745
|
const chunks = [];
|
|
466
746
|
for await (const chunk of process.stdin) chunks.push(chunk);
|
|
@@ -477,6 +757,8 @@ function selectCases(cases, ids) {
|
|
|
477
757
|
|
|
478
758
|
async function resolveModels(ids, modelsFile, io) {
|
|
479
759
|
const registry = JSON.parse(await readFile(resolve(modelsFile), "utf8"));
|
|
760
|
+
// Env wins; the credentials file only fills what is missing.
|
|
761
|
+
await applyModelCredentials(registry);
|
|
480
762
|
const explicit = Boolean(ids && ids.length > 0);
|
|
481
763
|
const chosen = explicit ? ids : autoSelect(registry);
|
|
482
764
|
const adapters = [];
|
|
@@ -493,11 +775,13 @@ async function resolveModels(ids, modelsFile, io) {
|
|
|
493
775
|
if (config.type !== "openai") {
|
|
494
776
|
throw new Error(`model "${id}": unsupported type "${config.type}"`);
|
|
495
777
|
}
|
|
496
|
-
|
|
778
|
+
const apiKey = process.env[config.apiKeyEnv];
|
|
779
|
+
if (!apiKey) {
|
|
497
780
|
if (explicit) throw new Error(`model "${id}": environment variable ${config.apiKeyEnv} is not set`);
|
|
498
781
|
io.error(`skip ${id}: ${config.apiKeyEnv} is not set`);
|
|
499
782
|
continue;
|
|
500
783
|
}
|
|
784
|
+
registerSecrets([apiKey]);
|
|
501
785
|
adapters.push({ id, ...createOpenAiAdapter({ id, ...config }) });
|
|
502
786
|
}
|
|
503
787
|
|
|
@@ -515,25 +799,47 @@ function autoSelect(registry) {
|
|
|
515
799
|
.map(([id]) => id);
|
|
516
800
|
}
|
|
517
801
|
|
|
518
|
-
async function evaluate(
|
|
802
|
+
async function evaluate(
|
|
803
|
+
adapter,
|
|
804
|
+
tools,
|
|
805
|
+
cases,
|
|
806
|
+
repeat = 1,
|
|
807
|
+
{ pricing = null, budget = null, onProgress = null } = {}
|
|
808
|
+
) {
|
|
519
809
|
const results = [];
|
|
520
810
|
const unstable = [];
|
|
521
|
-
for (const testCase of cases) {
|
|
811
|
+
for (const [index, testCase] of cases.entries()) {
|
|
522
812
|
const runs = [];
|
|
523
813
|
for (let attempt = 0; attempt < repeat; attempt += 1) {
|
|
524
|
-
|
|
814
|
+
const result = await callOnce(adapter, tools, testCase);
|
|
815
|
+
budget?.spend(costCnyOf(result.usage, pricing));
|
|
816
|
+
runs.push(result);
|
|
525
817
|
}
|
|
526
818
|
if (repeat === 1) {
|
|
527
819
|
results.push(runs[0]);
|
|
528
|
-
|
|
820
|
+
} else {
|
|
821
|
+
const picked = pickRepresentative(runs);
|
|
822
|
+
results.push(picked.result);
|
|
823
|
+
if (picked.unstable) unstable.push({ id: testCase.id, outcomes: picked.outcomes });
|
|
529
824
|
}
|
|
530
|
-
|
|
531
|
-
results.push(picked.result);
|
|
532
|
-
if (picked.unstable) unstable.push({ id: testCase.id, outcomes: picked.outcomes });
|
|
825
|
+
onProgress?.(index + 1, cases.length);
|
|
533
826
|
}
|
|
534
827
|
return { results, stability: repeat > 1 ? { runs: repeat, unstable } : null };
|
|
535
828
|
}
|
|
536
829
|
|
|
830
|
+
function makeProgress(options, io, label, total) {
|
|
831
|
+
if (!options.progress) return null;
|
|
832
|
+
const step = Math.max(1, Math.ceil(total / 10));
|
|
833
|
+
return (done, count) => {
|
|
834
|
+
if (done !== count && done % step !== 0) return;
|
|
835
|
+
io.error(`[${label}] ${done}/${count}`);
|
|
836
|
+
};
|
|
837
|
+
}
|
|
838
|
+
|
|
839
|
+
function costLimitMessage(error) {
|
|
840
|
+
return `${error.message};已发出的请求已计费,本次不计分。减少 --case/--models/--repeat 或提高 --max-cost 后重跑。`;
|
|
841
|
+
}
|
|
842
|
+
|
|
537
843
|
async function callOnce(adapter, tools, testCase) {
|
|
538
844
|
const messages = [
|
|
539
845
|
{ role: "system", content: SYSTEM_PROMPT },
|
|
@@ -578,7 +884,17 @@ function ablationScope(suite, names) {
|
|
|
578
884
|
return { name: profile.name, tools: suite.tools.filter((tool) => mounted.has(tool.name)) };
|
|
579
885
|
}
|
|
580
886
|
|
|
581
|
-
async function evaluateProfiles({
|
|
887
|
+
async function evaluateProfiles({
|
|
888
|
+
suite,
|
|
889
|
+
cases,
|
|
890
|
+
runs,
|
|
891
|
+
registry,
|
|
892
|
+
selection,
|
|
893
|
+
repeat = 1,
|
|
894
|
+
budget = null,
|
|
895
|
+
progress = false,
|
|
896
|
+
io = console
|
|
897
|
+
}) {
|
|
582
898
|
const profiles = normalizeProfiles(suite.profiles, suite.tools.map((tool) => tool.name));
|
|
583
899
|
if (!profiles) {
|
|
584
900
|
throw new Error(`suite ${suite.name} 没有 profiles;先运行 mf cluster --out <suite> 生成分组`);
|
|
@@ -608,7 +924,11 @@ async function evaluateProfiles({ suite, cases, runs, registry, selection, repea
|
|
|
608
924
|
const profileCases = caseSets[index];
|
|
609
925
|
if (profileCases.length === 0) continue;
|
|
610
926
|
const mountedTools = suite.tools.filter((tool) => profile.tools.includes(tool.name));
|
|
611
|
-
const mountedEval = await evaluate(run.adapter, toModelTools(mountedTools), profileCases, repeat
|
|
927
|
+
const mountedEval = await evaluate(run.adapter, toModelTools(mountedTools), profileCases, repeat, {
|
|
928
|
+
pricing,
|
|
929
|
+
budget,
|
|
930
|
+
onProgress: makeProgress({ progress }, io, `${run.adapter.id}/${profile.name}`, profileCases.length)
|
|
931
|
+
});
|
|
612
932
|
const mounted = scoreModel({
|
|
613
933
|
model: run.adapter.id,
|
|
614
934
|
cases: profileCases,
|
|
@@ -644,6 +964,7 @@ async function evaluateProfiles({ suite, cases, runs, registry, selection, repea
|
|
|
644
964
|
export async function runCluster(options, io = console) {
|
|
645
965
|
const suite = await loadSuite(options.suite, { allowEmptyCases: true });
|
|
646
966
|
const result = clusterTools(suite.tools, { threshold: options.threshold, maxSize: options.maxSize });
|
|
967
|
+
noteUsage({ toolCount: suite.tools.length, clusters: result.clusters.length });
|
|
647
968
|
if (options.out) {
|
|
648
969
|
const target = resolve(options.out);
|
|
649
970
|
const profiles = Object.fromEntries(result.clusters.map((cluster) => [cluster.label, cluster.tools]));
|
|
@@ -670,6 +991,8 @@ function parseArgs(argv) {
|
|
|
670
991
|
cases: null,
|
|
671
992
|
profiles: null,
|
|
672
993
|
repeat: 1,
|
|
994
|
+
maxCost: null,
|
|
995
|
+
progress: false,
|
|
673
996
|
showCases: false,
|
|
674
997
|
json: false,
|
|
675
998
|
out: null,
|
|
@@ -727,6 +1050,13 @@ function parseArgs(argv) {
|
|
|
727
1050
|
options.repeat = raw;
|
|
728
1051
|
break;
|
|
729
1052
|
}
|
|
1053
|
+
case "--max-cost": {
|
|
1054
|
+
const raw = Number(value());
|
|
1055
|
+
if (!Number.isFinite(raw) || raw <= 0) throw new Error("--max-cost expects a positive CNY amount");
|
|
1056
|
+
options.maxCost = raw;
|
|
1057
|
+
break;
|
|
1058
|
+
}
|
|
1059
|
+
case "--progress": options.progress = true; break;
|
|
730
1060
|
case "--show-cases": options.showCases = true; break;
|
|
731
1061
|
case "--json": options.json = true; break;
|
|
732
1062
|
case "--out": options.out = value(); break;
|
|
@@ -811,20 +1141,36 @@ if (isMain) {
|
|
|
811
1141
|
let argv = process.argv.slice(2);
|
|
812
1142
|
// `npx modelfunction-cli mf scan` passes the bin name through; accept it.
|
|
813
1143
|
if (argv[0] === "mf") argv = argv.slice(1);
|
|
1144
|
+
|
|
1145
|
+
const version = await readVersion();
|
|
1146
|
+
const startedAt = Date.now();
|
|
1147
|
+
let commandName = null;
|
|
1148
|
+
const skipUsage = new Set(["mcp", "config", "stats", "feedback"]);
|
|
1149
|
+
// Usage is local and count-only; the exit hook keeps every branch covered.
|
|
1150
|
+
process.on("exit", (code) => {
|
|
1151
|
+
if (!commandName || skipUsage.has(commandName)) return;
|
|
1152
|
+
recordRunSync({
|
|
1153
|
+
command: commandName,
|
|
1154
|
+
exitCode: typeof code === "number" ? code : 0,
|
|
1155
|
+
durationMs: Date.now() - startedAt,
|
|
1156
|
+
version
|
|
1157
|
+
});
|
|
1158
|
+
});
|
|
1159
|
+
|
|
814
1160
|
if (argv[0] === "--version" || argv[0] === "-v") {
|
|
815
|
-
|
|
816
|
-
process.stdout.write(`mf ${pkg.version}\n`);
|
|
1161
|
+
process.stdout.write(`mf ${version}\n`);
|
|
817
1162
|
process.exit(0);
|
|
818
1163
|
} else if (argv[0] === "sandbox") {
|
|
819
1164
|
if (argv[1] !== "check") {
|
|
820
1165
|
process.stderr.write("usage: mf sandbox check [--json]\n");
|
|
821
1166
|
process.exit(1);
|
|
822
1167
|
}
|
|
1168
|
+
commandName = "sandbox";
|
|
823
1169
|
try {
|
|
824
1170
|
const code = await runSandboxCheck({ json: argv.includes("--json") });
|
|
825
1171
|
process.exit(code);
|
|
826
1172
|
} catch (error) {
|
|
827
|
-
process.stderr.write(`${error?.message ?? error}\n`);
|
|
1173
|
+
process.stderr.write(`${redactText(error?.message ?? error)}\n`);
|
|
828
1174
|
process.exit(1);
|
|
829
1175
|
}
|
|
830
1176
|
} else if (argv[0] === "compare") {
|
|
@@ -833,11 +1179,44 @@ if (isMain) {
|
|
|
833
1179
|
process.stderr.write("usage: mf compare <before.json> <after.json> [--json]\n");
|
|
834
1180
|
process.exit(1);
|
|
835
1181
|
}
|
|
1182
|
+
commandName = "compare";
|
|
836
1183
|
try {
|
|
837
1184
|
const code = await runCompare({ paths, json: argv.includes("--json") });
|
|
838
1185
|
process.exit(code);
|
|
839
1186
|
} catch (error) {
|
|
840
|
-
process.stderr.write(`${error?.message ?? error}\n`);
|
|
1187
|
+
process.stderr.write(`${redactText(error?.message ?? error)}\n`);
|
|
1188
|
+
process.exit(1);
|
|
1189
|
+
}
|
|
1190
|
+
} else if (argv[0] === "config") {
|
|
1191
|
+
try {
|
|
1192
|
+
const code = await runConfig({ args: argv.slice(1), json: argv.includes("--json") });
|
|
1193
|
+
process.exit(code);
|
|
1194
|
+
} catch (error) {
|
|
1195
|
+
process.stderr.write(`${redactText(error?.message ?? error)}\n`);
|
|
1196
|
+
process.exit(1);
|
|
1197
|
+
}
|
|
1198
|
+
} else if (argv[0] === "stats") {
|
|
1199
|
+
try {
|
|
1200
|
+
const code = await runStats({ json: argv.includes("--json") });
|
|
1201
|
+
process.exit(code);
|
|
1202
|
+
} catch (error) {
|
|
1203
|
+
process.stderr.write(`${redactText(error?.message ?? error)}\n`);
|
|
1204
|
+
process.exit(1);
|
|
1205
|
+
}
|
|
1206
|
+
} else if (argv[0] === "feedback") {
|
|
1207
|
+
try {
|
|
1208
|
+
const outIndex = argv.indexOf("--out");
|
|
1209
|
+
if (outIndex >= 0 && !argv[outIndex + 1]) {
|
|
1210
|
+
process.stderr.write("usage: mf feedback [--out <file>] [--json]\n");
|
|
1211
|
+
process.exit(1);
|
|
1212
|
+
}
|
|
1213
|
+
const code = await runFeedback({
|
|
1214
|
+
out: outIndex >= 0 ? argv[outIndex + 1] : null,
|
|
1215
|
+
json: argv.includes("--json")
|
|
1216
|
+
});
|
|
1217
|
+
process.exit(code);
|
|
1218
|
+
} catch (error) {
|
|
1219
|
+
process.stderr.write(`${redactText(error?.message ?? error)}\n`);
|
|
841
1220
|
process.exit(1);
|
|
842
1221
|
}
|
|
843
1222
|
} else if (argv[0] === "mcp") {
|
|
@@ -845,12 +1224,12 @@ if (isMain) {
|
|
|
845
1224
|
const { startMcpServer } = await import("./lib/mcp-server.js");
|
|
846
1225
|
await startMcpServer();
|
|
847
1226
|
} catch (error) {
|
|
848
|
-
process.stderr.write(`${error?.message ?? error}\n`);
|
|
1227
|
+
process.stderr.write(`${redactText(error?.message ?? error)}\n`);
|
|
849
1228
|
process.exit(1);
|
|
850
1229
|
}
|
|
851
1230
|
} else if (argv.length === 0 || argv[0] === "help" || argv[0] === "-h" || argv[0] === "--help") {
|
|
852
1231
|
process.stdout.write(USAGE);
|
|
853
|
-
} else if (!["eval", "scan", "case", "coverage", "lint", "check", "ablate", "cluster", "exec"].includes(argv[0])) {
|
|
1232
|
+
} else if (!["eval", "scan", "case", "coverage", "audit", "lint", "check", "ablate", "cluster", "exec"].includes(argv[0])) {
|
|
854
1233
|
process.stderr.write(`unknown command "${argv[0]}"\n\n${USAGE}`);
|
|
855
1234
|
process.exit(1);
|
|
856
1235
|
} else if (argv[0] === "case" && argv[1] !== "add") {
|
|
@@ -864,6 +1243,7 @@ if (isMain) {
|
|
|
864
1243
|
process.stdout.write(USAGE);
|
|
865
1244
|
process.exit(0);
|
|
866
1245
|
}
|
|
1246
|
+
commandName = argv[0] === "case" ? "case" : argv[0];
|
|
867
1247
|
const code =
|
|
868
1248
|
argv[0] === "scan"
|
|
869
1249
|
? await runScan(options)
|
|
@@ -871,6 +1251,8 @@ if (isMain) {
|
|
|
871
1251
|
? await runCaseAdd(options)
|
|
872
1252
|
: argv[0] === "coverage"
|
|
873
1253
|
? await runCoverage(options)
|
|
1254
|
+
: argv[0] === "audit"
|
|
1255
|
+
? await runAudit(options)
|
|
874
1256
|
: argv[0] === "lint"
|
|
875
1257
|
? await runLint(options)
|
|
876
1258
|
: argv[0] === "check"
|
|
@@ -884,7 +1266,7 @@ if (isMain) {
|
|
|
884
1266
|
: await runEval(options);
|
|
885
1267
|
process.exit(code);
|
|
886
1268
|
} catch (error) {
|
|
887
|
-
process.stderr.write(`${error?.message ?? error}\n`);
|
|
1269
|
+
process.stderr.write(`${redactText(error?.message ?? error)}\n`);
|
|
888
1270
|
process.exit(1);
|
|
889
1271
|
}
|
|
890
1272
|
}
|