modelfunction-cli 0.0.0-stage → 0.1.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/LICENSE +21 -0
- package/README.md +74 -2
- package/fixtures/models.json +39 -0
- package/fixtures/suite.json +150 -0
- package/lib/ablate.js +120 -0
- package/lib/adapter-import.js +24 -0
- package/lib/adapters/mock.js +143 -0
- package/lib/adapters/openai.js +95 -0
- package/lib/cases.js +188 -0
- package/lib/compare.js +220 -0
- package/lib/eval-exec.js +140 -0
- package/lib/exec.js +265 -0
- package/lib/fingerprint.js +52 -0
- package/lib/fsutil.js +7 -0
- package/lib/lint.js +110 -0
- package/lib/mcp-probe.js +152 -0
- package/lib/mcp-server.js +216 -0
- package/lib/report.js +119 -0
- package/lib/runtime/harness.mjs +145 -0
- package/lib/runtime/harness.py +373 -0
- package/lib/sandbox-check.js +166 -0
- package/lib/scan.js +510 -0
- package/lib/score.js +137 -0
- package/lib/suite.js +117 -0
- package/mf.js +636 -0
- package/package.json +38 -4
- package/skills/mf-cases/SKILL.md +58 -0
package/mf.js
ADDED
|
@@ -0,0 +1,636 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { readFile } from "node:fs/promises";
|
|
3
|
+
import { basename, dirname, extname, relative, resolve } from "node:path";
|
|
4
|
+
import { fileURLToPath } from "node:url";
|
|
5
|
+
import { loadSuite, toModelTools } from "./lib/suite.js";
|
|
6
|
+
import { scoreModel, pct } from "./lib/score.js";
|
|
7
|
+
import { formatTable, formatCaseLines, formatMarkdown } from "./lib/report.js";
|
|
8
|
+
import { scanProject, buildSuiteDraft, formatScanSummary, addTools, redactReport } from "./lib/scan.js";
|
|
9
|
+
import { probeStdioServer } from "./lib/mcp-probe.js";
|
|
10
|
+
import { loadAdapterTools } from "./lib/adapter-import.js";
|
|
11
|
+
import { addCases, computeCoverage, formatCoverage } from "./lib/cases.js";
|
|
12
|
+
import { lintSuite, formatLint } from "./lib/lint.js";
|
|
13
|
+
import { suiteFingerprint, hashText } from "./lib/fingerprint.js";
|
|
14
|
+
import { runExecutionChecks } from "./lib/eval-exec.js";
|
|
15
|
+
import { checkSandbox, formatSandboxReport } from "./lib/sandbox-check.js";
|
|
16
|
+
import { compareScorecards, formatCompare } from "./lib/compare.js";
|
|
17
|
+
import { analyzeAblation, formatAblation } from "./lib/ablate.js";
|
|
18
|
+
import { runExec } from "./lib/exec.js";
|
|
19
|
+
import { writeFileAtomic } from "./lib/fsutil.js";
|
|
20
|
+
import { createMockAdapter } from "./lib/adapters/mock.js";
|
|
21
|
+
import { createOpenAiAdapter } from "./lib/adapters/openai.js";
|
|
22
|
+
|
|
23
|
+
const HERE = dirname(fileURLToPath(import.meta.url));
|
|
24
|
+
const DEFAULT_SUITE = resolve(HERE, "fixtures/suite.json");
|
|
25
|
+
const DEFAULT_MODELS = resolve(HERE, "fixtures/models.json");
|
|
26
|
+
|
|
27
|
+
const SYSTEM_PROMPT = [
|
|
28
|
+
"You are a tool-calling assistant.",
|
|
29
|
+
"Call exactly one tool when the user request clearly matches it.",
|
|
30
|
+
"Do not call any tool when no tool applies; ask a clarifying question instead of inventing arguments.",
|
|
31
|
+
"Only use values that appear in the user message."
|
|
32
|
+
].join(" ");
|
|
33
|
+
|
|
34
|
+
const USAGE = `mf — function-calling accuracy scorecard
|
|
35
|
+
|
|
36
|
+
Usage:
|
|
37
|
+
mf eval [options]
|
|
38
|
+
mf scan [options]
|
|
39
|
+
mf case add --suite <file> (--input <file> | stdin) [--source agent] [--reviewed] [--dry-run]
|
|
40
|
+
mf coverage [--suite <file>] [--json]
|
|
41
|
+
mf lint [--suite <file>] [--json]
|
|
42
|
+
mf compare <before.json> <after.json> [--json]
|
|
43
|
+
mf ablate --tool <name> [--suite <file>] [--models <ids>] [--json]
|
|
44
|
+
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]
|
|
45
|
+
mf sandbox check [--json] # probe available isolation engines on this machine
|
|
46
|
+
mf mcp # start stdio MCP server for coding agents
|
|
47
|
+
|
|
48
|
+
Eval options:
|
|
49
|
+
--suite <path> eval suite (default: cli/fixtures/suite.json)
|
|
50
|
+
--models <ids> comma-separated ids from models.json (default: models with API keys set)
|
|
51
|
+
--models-file <path> model registry (default: cli/fixtures/models.json)
|
|
52
|
+
--case <ids> run only these case ids (comma-separated, repeatable)
|
|
53
|
+
--show-cases print per-case results
|
|
54
|
+
--json print JSON instead of the table
|
|
55
|
+
--out <file> write scorecard (.md => markdown, otherwise JSON)
|
|
56
|
+
--fail-under <ratio> exit 2 if any model's selection accuracy is below ratio (e.g. 0.8)
|
|
57
|
+
--baseline <file> previous scorecard JSON (used with --fail-on-regression)
|
|
58
|
+
--fail-on-regression exit 2 if any reviewed case regressed vs --baseline
|
|
59
|
+
--execute run toolInput and assert expect (needs --entry or suite.execution.entry)
|
|
60
|
+
--exec-timeout <ms> per execution timeout (suite.execution.timeoutMs or 10000)
|
|
61
|
+
--allow-side-effects allow executing tools declaring write/external/unknown sideEffects
|
|
62
|
+
|
|
63
|
+
Scan options:
|
|
64
|
+
--root <dir> repository to scan (default: .)
|
|
65
|
+
--from <file> import tools from a module (export default [...] or export const tools)
|
|
66
|
+
--probe start stdio MCP servers and read tools/list
|
|
67
|
+
--timeout <ms> probe timeout per server (default: 5000)
|
|
68
|
+
--out <file> suite draft to write (default: mf.suite.json)
|
|
69
|
+
--report <file> scan report to write (default: mf.scan.json)
|
|
70
|
+
--no-write print only, do not write files
|
|
71
|
+
|
|
72
|
+
Env keys: DEEPSEEK_API_KEY, DASHSCOPE_API_KEY, ZHIPU_API_KEY, OPENAI_API_KEY, SILICONFLOW_API_KEY
|
|
73
|
+
|
|
74
|
+
Examples:
|
|
75
|
+
mf eval --models mock --show-cases
|
|
76
|
+
mf eval --models deepseek,qwen --out scorecard.md
|
|
77
|
+
mf scan
|
|
78
|
+
mf scan --root ../my-agent --no-write
|
|
79
|
+
mf case add --suite mf.suite.json --input cases.json
|
|
80
|
+
mf coverage --suite mf.suite.json
|
|
81
|
+
`;
|
|
82
|
+
|
|
83
|
+
export async function runEval(options, io = console) {
|
|
84
|
+
const suite = await loadSuite(options.suite);
|
|
85
|
+
const cases = selectCases(suite.cases, options.cases);
|
|
86
|
+
const { registry, adapters } = await resolveModels(options.models, options.modelsFile, io);
|
|
87
|
+
const tools = toModelTools(suite.tools);
|
|
88
|
+
|
|
89
|
+
const summaries = [];
|
|
90
|
+
for (const adapter of adapters) {
|
|
91
|
+
const results = await evaluate(adapter, tools, cases);
|
|
92
|
+
summaries.push(
|
|
93
|
+
scoreModel({
|
|
94
|
+
model: adapter.id,
|
|
95
|
+
cases,
|
|
96
|
+
results,
|
|
97
|
+
pricing: registry[adapter.id]?.pricing ?? null
|
|
98
|
+
})
|
|
99
|
+
);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
let executionInfo = null;
|
|
103
|
+
if (options.execute) {
|
|
104
|
+
const entry = options.execEntry ?? options.entry ?? suite.execution?.entry;
|
|
105
|
+
if (!entry) throw new Error("--execute 需要 --entry,或套件中的 execution.entry");
|
|
106
|
+
await runExecutionChecks({
|
|
107
|
+
suite,
|
|
108
|
+
cases,
|
|
109
|
+
summaries,
|
|
110
|
+
options: {
|
|
111
|
+
...options,
|
|
112
|
+
execEntry: entry,
|
|
113
|
+
execRuntime: options.runtime,
|
|
114
|
+
execPython: options.python
|
|
115
|
+
}
|
|
116
|
+
});
|
|
117
|
+
const entryPath = resolve(entry);
|
|
118
|
+
const runtime = suite.execution?.runtime ?? options.runtime ?? "node";
|
|
119
|
+
executionInfo = {
|
|
120
|
+
runtime,
|
|
121
|
+
engine: runtime === "python" ? "python-audit" : "node-permission",
|
|
122
|
+
entry: basename(entryPath),
|
|
123
|
+
hash: hashText(await readFile(entryPath, "utf8"))
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
if (options.out) {
|
|
128
|
+
const target = resolve(options.out);
|
|
129
|
+
const markdown = extname(target).toLowerCase() === ".md";
|
|
130
|
+
const payload = markdown
|
|
131
|
+
? formatMarkdown(suite, cases, summaries)
|
|
132
|
+
: JSON.stringify(serialize(suite, cases, summaries, executionInfo), null, 2);
|
|
133
|
+
await writeFileAtomic(target, payload);
|
|
134
|
+
io.log(`wrote ${target}`);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
if (options.json) {
|
|
138
|
+
io.log(JSON.stringify(serialize(suite, cases, summaries, executionInfo), null, 2));
|
|
139
|
+
} else {
|
|
140
|
+
io.log(`suite: ${suite.name} — ${cases.length} cases, ${suite.tools.length} tools`);
|
|
141
|
+
io.log(`file: ${suite.path}`);
|
|
142
|
+
for (const summary of summaries) {
|
|
143
|
+
io.log("");
|
|
144
|
+
if (options.showCases) {
|
|
145
|
+
io.log(`[${summary.model}]`);
|
|
146
|
+
for (const line of formatCaseLines(summary, cases)) io.log(` ${line}`);
|
|
147
|
+
io.log("");
|
|
148
|
+
}
|
|
149
|
+
io.log(formatTable([summary]));
|
|
150
|
+
if (summary.unreviewedTotal > 0) {
|
|
151
|
+
io.log(`note: ${summary.unreviewedTotal} unreviewed case(s) excluded from select/args/pass`);
|
|
152
|
+
}
|
|
153
|
+
if (summary.exec) {
|
|
154
|
+
const parts = [
|
|
155
|
+
`exec: ${summary.exec.passed}/${summary.exec.executed} passed`,
|
|
156
|
+
`engine ${summary.exec.engine}`
|
|
157
|
+
];
|
|
158
|
+
if (summary.exec.skipped > 0) {
|
|
159
|
+
parts.push(
|
|
160
|
+
`${summary.exec.skipped} skipped${summary.exec.blocked > 0 ? `(${summary.exec.blocked} 因 sideEffects 被拦截,需 --allow-side-effects)` : ""}`
|
|
161
|
+
);
|
|
162
|
+
}
|
|
163
|
+
if (summary.exec.p50DurationMs !== null) parts.push(`p50 ${summary.exec.p50DurationMs}ms`);
|
|
164
|
+
if (summary.exec.maxPeakRssMB !== null) parts.push(`peak ${summary.exec.maxPeakRssMB}MB`);
|
|
165
|
+
io.log(parts.join(", "));
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
if (summaries.length > 1) {
|
|
169
|
+
io.log("");
|
|
170
|
+
io.log("combined");
|
|
171
|
+
io.log(formatTable(summaries));
|
|
172
|
+
}
|
|
173
|
+
if (summaries.some((summary) => summary.rows.some((row) => row.usage?.estimated))) {
|
|
174
|
+
io.log("");
|
|
175
|
+
io.log("note: tokens marked with ~ are estimated (mock adapter); pricing in models.json is indicative.");
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
let regressions = [];
|
|
180
|
+
if (options.failOnRegression) {
|
|
181
|
+
if (!options.baseline) {
|
|
182
|
+
io.error("--fail-on-regression requires --baseline <scorecard.json>");
|
|
183
|
+
return 2;
|
|
184
|
+
}
|
|
185
|
+
const baseline = JSON.parse(await readFile(resolve(options.baseline), "utf8"));
|
|
186
|
+
const comparison = compareScorecards(baseline, serialize(suite, cases, summaries, executionInfo));
|
|
187
|
+
if (!options.json) {
|
|
188
|
+
io.log("");
|
|
189
|
+
io.log(formatCompare(comparison));
|
|
190
|
+
}
|
|
191
|
+
const comparable = comparison.models.filter((model) => model.comparable && model.reviewed > 0);
|
|
192
|
+
if (comparable.length === 0) {
|
|
193
|
+
io.error("定义或用例已变更,无法判定回归(门禁跳过)");
|
|
194
|
+
} else {
|
|
195
|
+
regressions = comparable.flatMap((model) =>
|
|
196
|
+
model.regressions.map((item) => `${model.model}/${item.id}`)
|
|
197
|
+
);
|
|
198
|
+
if (regressions.length > 0) io.error(`regressions: ${regressions.join(", ")}`);
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
const failed = options.failUnder === null
|
|
203
|
+
? []
|
|
204
|
+
: summaries.filter((summary) => summary.selectionAccuracy < options.failUnder);
|
|
205
|
+
if (failed.length > 0) {
|
|
206
|
+
io.error(
|
|
207
|
+
`below threshold ${pct(options.failUnder)}: ${failed
|
|
208
|
+
.map((summary) => `${summary.model} (${pct(summary.selectionAccuracy)})`)
|
|
209
|
+
.join(", ")}`
|
|
210
|
+
);
|
|
211
|
+
return 2;
|
|
212
|
+
}
|
|
213
|
+
if (regressions.length > 0) return 2;
|
|
214
|
+
return 0;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
export async function runAblate(options, io = console) {
|
|
218
|
+
const suite = await loadSuite(resolve(options.suite ?? DEFAULT_SUITE));
|
|
219
|
+
if (!options.tool) throw new Error("--tool is required");
|
|
220
|
+
const ablated = suite.tools.find((tool) => tool.name === options.tool);
|
|
221
|
+
if (!ablated) throw new Error(`unknown tool "${options.tool}"`);
|
|
222
|
+
|
|
223
|
+
const baseCases = selectCases(suite.cases, options.cases);
|
|
224
|
+
const reducedCases = baseCases.filter((testCase) => testCase.expectedTool !== ablated.name);
|
|
225
|
+
const droppedCases = baseCases.length - reducedCases.length;
|
|
226
|
+
const { registry, adapters } = await resolveModels(options.models, options.modelsFile, io);
|
|
227
|
+
|
|
228
|
+
const fullTools = toModelTools(suite.tools);
|
|
229
|
+
const reducedTools = toModelTools(suite.tools.filter((tool) => tool.name !== ablated.name));
|
|
230
|
+
const models = [];
|
|
231
|
+
|
|
232
|
+
for (const adapter of adapters) {
|
|
233
|
+
const pricing = registry[adapter.id]?.pricing ?? null;
|
|
234
|
+
const fullResults = await evaluate(adapter, fullTools, baseCases);
|
|
235
|
+
const full = scoreModel({ model: adapter.id, cases: baseCases, results: fullResults, pricing });
|
|
236
|
+
const reducedResults = await evaluate(adapter, reducedTools, reducedCases);
|
|
237
|
+
const reduced = scoreModel({ model: adapter.id, cases: reducedCases, results: reducedResults, pricing });
|
|
238
|
+
models.push(analyzeAblation(adapter.id, full, reduced));
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
const payload = { tool: ablated.name, suite: suite.name, droppedCases, models };
|
|
242
|
+
io.log(options.json ? JSON.stringify(payload, null, 2) : formatAblation(payload));
|
|
243
|
+
return 0;
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
export async function runScan(options, io = console) {
|
|
247
|
+
const report = await scanProject(options.root ?? ".");
|
|
248
|
+
|
|
249
|
+
if (options.from) {
|
|
250
|
+
const tools = await loadAdapterTools(options.from);
|
|
251
|
+
addTools(report, tools, {
|
|
252
|
+
source: relative(process.cwd(), resolve(options.from)) || options.from,
|
|
253
|
+
origin: "adapter",
|
|
254
|
+
confidence: "high",
|
|
255
|
+
note: "从用户模块动态导入,schema 为运行时真实值"
|
|
256
|
+
});
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
if (options.probe) {
|
|
260
|
+
for (const server of [...report.mcpServers]) {
|
|
261
|
+
if (server.transport !== "stdio") {
|
|
262
|
+
report.probes.push({ server: server.name, ok: false, error: "http transport 暂不支持" });
|
|
263
|
+
continue;
|
|
264
|
+
}
|
|
265
|
+
const result = await probeStdioServer(server, { timeoutMs: options.timeoutMs });
|
|
266
|
+
if (!result.ok) {
|
|
267
|
+
report.probes.push({ server: server.name, ok: false, error: result.error, stderr: result.stderr });
|
|
268
|
+
continue;
|
|
269
|
+
}
|
|
270
|
+
const added = addTools(report, result.tools, {
|
|
271
|
+
source: server.source,
|
|
272
|
+
origin: "mcp-probe",
|
|
273
|
+
confidence: "high",
|
|
274
|
+
note: `server ${server.name}`
|
|
275
|
+
});
|
|
276
|
+
report.probes.push({ server: server.name, ok: true, toolCount: added.length, tools: added });
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
const draft = buildSuiteDraft(report);
|
|
281
|
+
|
|
282
|
+
if (options.json) {
|
|
283
|
+
io.log(JSON.stringify({ report: redactReport(report), draft }, null, 2));
|
|
284
|
+
return report.tools.length > 0 ? 0 : 3;
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
io.log(formatScanSummary(report));
|
|
288
|
+
|
|
289
|
+
if (!options.noWrite) {
|
|
290
|
+
const suitePath = resolve(options.out ?? "mf.suite.json");
|
|
291
|
+
const reportPath = resolve(options.report ?? "mf.scan.json");
|
|
292
|
+
await writeFileAtomic(suitePath, JSON.stringify(draft, null, 2));
|
|
293
|
+
await writeFileAtomic(reportPath, JSON.stringify(redactReport(report), null, 2));
|
|
294
|
+
io.log("");
|
|
295
|
+
io.log(`wrote ${suitePath}`);
|
|
296
|
+
io.log(`wrote ${reportPath}`);
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
return report.tools.length > 0 ? 0 : 3;
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
export async function runCaseAdd(options, io = console) {
|
|
303
|
+
const suitePath = resolve(options.suite ?? DEFAULT_SUITE);
|
|
304
|
+
let payload;
|
|
305
|
+
if (options.inputPath) {
|
|
306
|
+
payload = JSON.parse(await readFile(resolve(options.inputPath), "utf8"));
|
|
307
|
+
} else {
|
|
308
|
+
payload = JSON.parse(await readStdin());
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
const result = await addCases(suitePath, payload, {
|
|
312
|
+
source: options.source,
|
|
313
|
+
reviewed: options.reviewed,
|
|
314
|
+
dryRun: options.dryRun
|
|
315
|
+
});
|
|
316
|
+
|
|
317
|
+
io.log(`suite: ${result.path}`);
|
|
318
|
+
io.log(`added: ${result.added.length}${result.dryRun ? " (dry-run)" : ""}`);
|
|
319
|
+
for (const testCase of result.added) {
|
|
320
|
+
io.log(
|
|
321
|
+
` + ${testCase.id} → ${testCase.expectedTool ?? "(no tool)"} [${testCase.source}${testCase.reviewed ? ", reviewed" : ""}]`
|
|
322
|
+
);
|
|
323
|
+
}
|
|
324
|
+
if (result.warnings.length > 0) {
|
|
325
|
+
for (const warning of result.warnings) io.error(`warn: ${warning}`);
|
|
326
|
+
}
|
|
327
|
+
if (result.rejected.length > 0) {
|
|
328
|
+
io.error(`rejected: ${result.rejected.length}`);
|
|
329
|
+
for (const item of result.rejected) io.error(` - ${item.id}: ${item.reason}`);
|
|
330
|
+
return 1;
|
|
331
|
+
}
|
|
332
|
+
return 0;
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
export async function runCoverage(options, io = console) {
|
|
336
|
+
const suite = await loadSuite(resolve(options.suite ?? DEFAULT_SUITE));
|
|
337
|
+
const report = computeCoverage(suite);
|
|
338
|
+
io.log(options.json ? JSON.stringify(report, null, 2) : formatCoverage(report));
|
|
339
|
+
return 0;
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
export async function runSandboxCheck(options, io = console) {
|
|
343
|
+
const report = checkSandbox();
|
|
344
|
+
io.log(options.json ? JSON.stringify(report, null, 2) : formatSandboxReport(report));
|
|
345
|
+
return 0;
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
export async function runLint(options, io = console) {
|
|
349
|
+
const suite = await loadSuite(resolve(options.suite ?? DEFAULT_SUITE));
|
|
350
|
+
const report = lintSuite(suite);
|
|
351
|
+
io.log(options.json ? JSON.stringify(report, null, 2) : formatLint(report));
|
|
352
|
+
return report.counts.error > 0 ? 1 : 0;
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
export async function runCompare(options, io = console) {
|
|
356
|
+
const [beforePath, afterPath] = options.paths;
|
|
357
|
+
const before = JSON.parse(await readFile(resolve(beforePath), "utf8"));
|
|
358
|
+
const after = JSON.parse(await readFile(resolve(afterPath), "utf8"));
|
|
359
|
+
const report = compareScorecards(before, after);
|
|
360
|
+
io.log(options.json ? JSON.stringify(report, null, 2) : formatCompare(report));
|
|
361
|
+
const changed = report.toolChanges.length > 0 || report.caseChanges.length > 0;
|
|
362
|
+
return changed ? 1 : 0;
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
async function readStdin() {
|
|
366
|
+
const chunks = [];
|
|
367
|
+
for await (const chunk of process.stdin) chunks.push(chunk);
|
|
368
|
+
return Buffer.concat(chunks).toString("utf8");
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
function selectCases(cases, ids) {
|
|
372
|
+
if (!ids || ids.length === 0) return cases;
|
|
373
|
+
const wanted = new Set(ids);
|
|
374
|
+
const missing = ids.filter((id) => !cases.some((testCase) => testCase.id === id));
|
|
375
|
+
if (missing.length > 0) throw new Error(`unknown case ids: ${missing.join(", ")}`);
|
|
376
|
+
return cases.filter((testCase) => wanted.has(testCase.id));
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
async function resolveModels(ids, modelsFile, io) {
|
|
380
|
+
const registry = JSON.parse(await readFile(resolve(modelsFile), "utf8"));
|
|
381
|
+
const explicit = Boolean(ids && ids.length > 0);
|
|
382
|
+
const chosen = explicit ? ids : autoSelect(registry);
|
|
383
|
+
const adapters = [];
|
|
384
|
+
|
|
385
|
+
for (const id of chosen) {
|
|
386
|
+
const config = registry[id];
|
|
387
|
+
if (!config) {
|
|
388
|
+
throw new Error(`unknown model "${id}" (available: ${Object.keys(registry).join(", ")})`);
|
|
389
|
+
}
|
|
390
|
+
if (config.type === "mock") {
|
|
391
|
+
adapters.push({ id, ...createMockAdapter() });
|
|
392
|
+
continue;
|
|
393
|
+
}
|
|
394
|
+
if (config.type !== "openai") {
|
|
395
|
+
throw new Error(`model "${id}": unsupported type "${config.type}"`);
|
|
396
|
+
}
|
|
397
|
+
if (!process.env[config.apiKeyEnv]) {
|
|
398
|
+
if (explicit) throw new Error(`model "${id}": environment variable ${config.apiKeyEnv} is not set`);
|
|
399
|
+
io.error(`skip ${id}: ${config.apiKeyEnv} is not set`);
|
|
400
|
+
continue;
|
|
401
|
+
}
|
|
402
|
+
adapters.push({ id, ...createOpenAiAdapter({ id, ...config }) });
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
if (adapters.length === 0) {
|
|
406
|
+
if (explicit) throw new Error("no runnable models");
|
|
407
|
+
io.error("no API keys found; falling back to the mock adapter");
|
|
408
|
+
adapters.push({ id: "mock", ...createMockAdapter() });
|
|
409
|
+
}
|
|
410
|
+
return { registry, adapters };
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
function autoSelect(registry) {
|
|
414
|
+
return Object.entries(registry)
|
|
415
|
+
.filter(([, config]) => config.type === "openai" && process.env[config.apiKeyEnv])
|
|
416
|
+
.map(([id]) => id);
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
async function evaluate(adapter, tools, cases) {
|
|
420
|
+
const results = [];
|
|
421
|
+
for (const testCase of cases) {
|
|
422
|
+
const messages = [
|
|
423
|
+
{ role: "system", content: SYSTEM_PROMPT },
|
|
424
|
+
{ role: "user", content: testCase.input }
|
|
425
|
+
];
|
|
426
|
+
try {
|
|
427
|
+
results.push(await adapter.call({ input: testCase.input, messages, tools }));
|
|
428
|
+
} catch (error) {
|
|
429
|
+
results.push({
|
|
430
|
+
ok: false,
|
|
431
|
+
error: String(error?.message ?? error),
|
|
432
|
+
toolCalls: [],
|
|
433
|
+
usage: { input: 0, output: 0 },
|
|
434
|
+
latencyMs: 0
|
|
435
|
+
});
|
|
436
|
+
}
|
|
437
|
+
}
|
|
438
|
+
return results;
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
function serialize(suite, cases, summaries, executionInfo = null) {
|
|
442
|
+
return {
|
|
443
|
+
suite: {
|
|
444
|
+
name: suite.name,
|
|
445
|
+
path: suite.path,
|
|
446
|
+
tools: suite.tools.map((tool) => tool.name),
|
|
447
|
+
cases: cases.length,
|
|
448
|
+
fingerprint: suiteFingerprint(suite, cases),
|
|
449
|
+
...(executionInfo ? { execution: executionInfo } : {})
|
|
450
|
+
},
|
|
451
|
+
models: summaries
|
|
452
|
+
};
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
function parseArgs(argv) {
|
|
456
|
+
const options = {
|
|
457
|
+
command: argv[0],
|
|
458
|
+
suite: DEFAULT_SUITE,
|
|
459
|
+
models: null,
|
|
460
|
+
modelsFile: DEFAULT_MODELS,
|
|
461
|
+
cases: null,
|
|
462
|
+
showCases: false,
|
|
463
|
+
json: false,
|
|
464
|
+
out: null,
|
|
465
|
+
failUnder: null,
|
|
466
|
+
root: ".",
|
|
467
|
+
report: null,
|
|
468
|
+
noWrite: false,
|
|
469
|
+
from: null,
|
|
470
|
+
probe: false,
|
|
471
|
+
timeoutMs: 5000,
|
|
472
|
+
inputPath: null,
|
|
473
|
+
source: "agent",
|
|
474
|
+
reviewed: false,
|
|
475
|
+
dryRun: false,
|
|
476
|
+
tool: null,
|
|
477
|
+
baseline: null,
|
|
478
|
+
failOnRegression: false,
|
|
479
|
+
execute: false,
|
|
480
|
+
execEntry: null,
|
|
481
|
+
execTimeoutMs: 10_000,
|
|
482
|
+
allowSideEffects: false,
|
|
483
|
+
entry: null,
|
|
484
|
+
runtime: "node",
|
|
485
|
+
python: "python",
|
|
486
|
+
exportName: "run",
|
|
487
|
+
inputJson: null,
|
|
488
|
+
allowRead: [],
|
|
489
|
+
allowWrite: [],
|
|
490
|
+
allowNet: [],
|
|
491
|
+
passEnv: [],
|
|
492
|
+
warmup: 0
|
|
493
|
+
};
|
|
494
|
+
|
|
495
|
+
for (let i = 1; i < argv.length; i += 1) {
|
|
496
|
+
const arg = argv[i];
|
|
497
|
+
const value = () => {
|
|
498
|
+
const next = argv[i + 1];
|
|
499
|
+
if (next === undefined || next.startsWith("--")) throw new Error(`missing value for ${arg}`);
|
|
500
|
+
i += 1;
|
|
501
|
+
return next;
|
|
502
|
+
};
|
|
503
|
+
switch (arg) {
|
|
504
|
+
case "--suite": options.suite = resolve(value()); break;
|
|
505
|
+
case "--models": options.models = splitList(value()); break;
|
|
506
|
+
case "--models-file": options.modelsFile = resolve(value()); break;
|
|
507
|
+
case "--case": options.cases = (options.cases ?? []).concat(splitList(value())); break;
|
|
508
|
+
case "--show-cases": options.showCases = true; break;
|
|
509
|
+
case "--json": options.json = true; break;
|
|
510
|
+
case "--out": options.out = value(); break;
|
|
511
|
+
case "--root": options.root = resolve(value()); break;
|
|
512
|
+
case "--from": options.from = value(); break;
|
|
513
|
+
case "--probe": options.probe = true; break;
|
|
514
|
+
case "--timeout": {
|
|
515
|
+
const raw = Number(value());
|
|
516
|
+
if (!Number.isFinite(raw) || raw <= 0) throw new Error("--timeout expects a positive number of milliseconds");
|
|
517
|
+
options.timeoutMs = raw;
|
|
518
|
+
break;
|
|
519
|
+
}
|
|
520
|
+
case "--report": options.report = value(); break;
|
|
521
|
+
case "--no-write": options.noWrite = true; break;
|
|
522
|
+
case "--input": options.inputPath = value(); break;
|
|
523
|
+
case "--source": options.source = value(); break;
|
|
524
|
+
case "--reviewed": options.reviewed = true; break;
|
|
525
|
+
case "--dry-run": options.dryRun = true; break;
|
|
526
|
+
case "--tool": options.tool = value(); break;
|
|
527
|
+
case "--baseline": options.baseline = resolve(value()); break;
|
|
528
|
+
case "--fail-on-regression": options.failOnRegression = true; break;
|
|
529
|
+
case "--entry": options.entry = value(); break;
|
|
530
|
+
case "--execute": options.execute = true; break;
|
|
531
|
+
case "--exec-timeout": options.execTimeoutMs = Number(value()) || 10_000; break;
|
|
532
|
+
case "--allow-side-effects": options.allowSideEffects = true; break;
|
|
533
|
+
case "--runtime": options.runtime = value(); break;
|
|
534
|
+
case "--python": options.python = value(); break;
|
|
535
|
+
case "--export": options.exportName = value(); break;
|
|
536
|
+
case "--input-json": options.inputJson = value(); break;
|
|
537
|
+
case "--allow-read": options.allowRead = splitList(value()); break;
|
|
538
|
+
case "--allow-write": options.allowWrite = splitList(value()); break;
|
|
539
|
+
case "--allow-net": options.allowNet = splitList(value()); break;
|
|
540
|
+
case "--pass-env": options.passEnv = splitList(value()); break;
|
|
541
|
+
case "--warmup": options.warmup = Number(value()) || 0; break;
|
|
542
|
+
case "--fail-under": {
|
|
543
|
+
const raw = Number(value());
|
|
544
|
+
if (!Number.isFinite(raw) || raw < 0 || raw > 1) throw new Error("--fail-under expects a ratio between 0 and 1");
|
|
545
|
+
options.failUnder = raw;
|
|
546
|
+
break;
|
|
547
|
+
}
|
|
548
|
+
case "-h":
|
|
549
|
+
case "--help":
|
|
550
|
+
options.help = true;
|
|
551
|
+
break;
|
|
552
|
+
default:
|
|
553
|
+
throw new Error(`unknown option "${arg}"`);
|
|
554
|
+
}
|
|
555
|
+
}
|
|
556
|
+
return options;
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
function splitList(value) {
|
|
560
|
+
return value.split(",").map((item) => item.trim()).filter(Boolean);
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
const isMain = process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url);
|
|
564
|
+
|
|
565
|
+
if (isMain) {
|
|
566
|
+
const argv = process.argv.slice(2);
|
|
567
|
+
if (argv[0] === "--version" || argv[0] === "-v") {
|
|
568
|
+
const pkg = JSON.parse(await readFile(new URL("./package.json", import.meta.url), "utf8"));
|
|
569
|
+
process.stdout.write(`mf ${pkg.version}\n`);
|
|
570
|
+
process.exit(0);
|
|
571
|
+
} else if (argv[0] === "sandbox") {
|
|
572
|
+
if (argv[1] !== "check") {
|
|
573
|
+
process.stderr.write("usage: mf sandbox check [--json]\n");
|
|
574
|
+
process.exit(1);
|
|
575
|
+
}
|
|
576
|
+
try {
|
|
577
|
+
const code = await runSandboxCheck({ json: argv.includes("--json") });
|
|
578
|
+
process.exit(code);
|
|
579
|
+
} catch (error) {
|
|
580
|
+
process.stderr.write(`${error?.message ?? error}\n`);
|
|
581
|
+
process.exit(1);
|
|
582
|
+
}
|
|
583
|
+
} else if (argv[0] === "compare") {
|
|
584
|
+
const paths = argv.slice(1).filter((arg) => !arg.startsWith("--"));
|
|
585
|
+
if (paths.length !== 2) {
|
|
586
|
+
process.stderr.write("usage: mf compare <before.json> <after.json> [--json]\n");
|
|
587
|
+
process.exit(1);
|
|
588
|
+
}
|
|
589
|
+
try {
|
|
590
|
+
const code = await runCompare({ paths, json: argv.includes("--json") });
|
|
591
|
+
process.exit(code);
|
|
592
|
+
} catch (error) {
|
|
593
|
+
process.stderr.write(`${error?.message ?? error}\n`);
|
|
594
|
+
process.exit(1);
|
|
595
|
+
}
|
|
596
|
+
} else if (argv[0] === "mcp") {
|
|
597
|
+
try {
|
|
598
|
+
const { startMcpServer } = await import("./lib/mcp-server.js");
|
|
599
|
+
await startMcpServer();
|
|
600
|
+
} catch (error) {
|
|
601
|
+
process.stderr.write(`${error?.message ?? error}\n`);
|
|
602
|
+
process.exit(1);
|
|
603
|
+
}
|
|
604
|
+
} else if (argv.length === 0 || argv[0] === "help" || argv[0] === "-h" || argv[0] === "--help") {
|
|
605
|
+
process.stdout.write(USAGE);
|
|
606
|
+
} else if (!["eval", "scan", "case", "coverage", "lint", "ablate", "exec"].includes(argv[0])) {
|
|
607
|
+
process.stderr.write(`unknown command "${argv[0]}"\n\n${USAGE}`);
|
|
608
|
+
process.exit(1);
|
|
609
|
+
} else if (argv[0] === "case" && argv[1] !== "add") {
|
|
610
|
+
process.stderr.write(`usage: mf case add --suite <file> (--input <file> | stdin)\n`);
|
|
611
|
+
process.exit(1);
|
|
612
|
+
} else {
|
|
613
|
+
try {
|
|
614
|
+
const args = argv[0] === "case" ? [argv[0], ...argv.slice(2)] : argv;
|
|
615
|
+
const options = parseArgs(args);
|
|
616
|
+
const code =
|
|
617
|
+
argv[0] === "scan"
|
|
618
|
+
? await runScan(options)
|
|
619
|
+
: argv[0] === "case"
|
|
620
|
+
? await runCaseAdd(options)
|
|
621
|
+
: argv[0] === "coverage"
|
|
622
|
+
? await runCoverage(options)
|
|
623
|
+
: argv[0] === "lint"
|
|
624
|
+
? await runLint(options)
|
|
625
|
+
: argv[0] === "ablate"
|
|
626
|
+
? await runAblate(options)
|
|
627
|
+
: argv[0] === "exec"
|
|
628
|
+
? await runExec(options)
|
|
629
|
+
: await runEval(options);
|
|
630
|
+
process.exit(code);
|
|
631
|
+
} catch (error) {
|
|
632
|
+
process.stderr.write(`${error?.message ?? error}\n`);
|
|
633
|
+
process.exit(1);
|
|
634
|
+
}
|
|
635
|
+
}
|
|
636
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,40 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "modelfunction-cli",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"description": "mf — function-calling accuracy scorecard for AI agents",
|
|
7
|
+
"keywords": [
|
|
8
|
+
"llm",
|
|
9
|
+
"function-calling",
|
|
10
|
+
"tool-use",
|
|
11
|
+
"mcp",
|
|
12
|
+
"eval",
|
|
13
|
+
"agent"
|
|
14
|
+
],
|
|
15
|
+
"bin": {
|
|
16
|
+
"mf": "./mf.js"
|
|
17
|
+
},
|
|
18
|
+
"files": [
|
|
19
|
+
"mf.js",
|
|
20
|
+
"lib",
|
|
21
|
+
"fixtures",
|
|
22
|
+
"skills",
|
|
23
|
+
"README.md",
|
|
24
|
+
"LICENSE"
|
|
25
|
+
],
|
|
26
|
+
"scripts": {
|
|
27
|
+
"test": "node --test \"test/**/*.test.js\"",
|
|
28
|
+
"eval": "node mf.js eval",
|
|
29
|
+
"eval:mock": "node mf.js eval --models mock --show-cases",
|
|
30
|
+
"pack:check": "npm pack --dry-run",
|
|
31
|
+
"prepublishOnly": "npm test"
|
|
32
|
+
},
|
|
33
|
+
"engines": {
|
|
34
|
+
"node": ">=20"
|
|
35
|
+
},
|
|
36
|
+
"dependencies": {
|
|
37
|
+
"@modelcontextprotocol/sdk": "^1.32.2",
|
|
38
|
+
"zod": "^4.6.7"
|
|
39
|
+
}
|
|
40
|
+
}
|