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/lib/scan.js ADDED
@@ -0,0 +1,510 @@
1
+ import { readdir, readFile, stat } from "node:fs/promises";
2
+ import { basename, join, relative, resolve } from "node:path";
3
+
4
+ const SKIP_DIRS = new Set([
5
+ "node_modules",
6
+ ".git",
7
+ "dist",
8
+ "build",
9
+ ".next",
10
+ ".nuxt",
11
+ ".svelte-kit",
12
+ "coverage",
13
+ ".venv",
14
+ "venv",
15
+ "__pycache__",
16
+ ".idea",
17
+ ".turbo",
18
+ ".cache"
19
+ ]);
20
+
21
+ const MAX_DEPTH = 6;
22
+ const MAX_FILES = 5000;
23
+ const MAX_FILE_BYTES = 200_000;
24
+
25
+ const FUNCTION_SPEC_NAMES = new Set([
26
+ "function.json",
27
+ "tool.json",
28
+ "tools.json",
29
+ "functions.json",
30
+ "mcp-tools.json"
31
+ ]);
32
+
33
+ const MCP_CONFIG_NAMES = new Set([
34
+ "mcp.json",
35
+ ".mcp.json",
36
+ "mcp_config.json",
37
+ "claude_desktop_config.json",
38
+ "cline_mcp_settings.json",
39
+ "mcp_settings.json"
40
+ ]);
41
+
42
+ const OPENAPI_PATTERN = /^(openapi|swagger)[^/]*\.json$/i;
43
+ const SKILL_FILE = "skill.md";
44
+ const CODE_EXTENSIONS = new Set([".ts", ".tsx", ".js", ".mjs", ".cjs", ".py"]);
45
+
46
+ const CODE_PATTERNS = [
47
+ { label: "registerTool", confidence: "medium", re: /registerTool\s*\(\s*["'`]([\w.-]+)["'`]/ },
48
+ { label: "server.tool", confidence: "medium", re: /\.tool\s*\(\s*["'`]([\w.-]+)["'`]/ },
49
+ { label: "py-tool-decorator", confidence: "low", re: /^\s*@tool\b/ },
50
+ { label: "vercel-tool-call", confidence: "low", re: /\btool\s*\(\s*\{/ },
51
+ { label: "openai-tools-array", confidence: "low", re: /\btools\s*:\s*\[\s*\{/ },
52
+ { label: "anthropic-input-schema", confidence: "low", re: /\binput_?schema\s*:/ }
53
+ ];
54
+
55
+ const PY_FRAMEWORKS = {
56
+ openai: "openai",
57
+ anthropic: "anthropic",
58
+ langchain: "langchain",
59
+ langgraph: "langgraph",
60
+ crewai: "crewai",
61
+ litellm: "litellm",
62
+ mcp: "mcp",
63
+ "openai-agents": "openai-agents"
64
+ };
65
+
66
+ const JS_FRAMEWORKS = {
67
+ openai: "openai",
68
+ "@anthropic-ai/sdk": "anthropic",
69
+ ai: "vercel-ai-sdk",
70
+ langchain: "langchain",
71
+ "@langchain/core": "langchain",
72
+ langgraph: "langgraph",
73
+ "@modelcontextprotocol/sdk": "mcp",
74
+ "@openai/agents": "openai-agents"
75
+ };
76
+
77
+ export async function scanProject(root = ".") {
78
+ const absRoot = resolve(root);
79
+ const files = [];
80
+ await walk(absRoot, 0, files);
81
+
82
+ const report = {
83
+ root: absRoot,
84
+ filesScanned: files.length,
85
+ sources: [],
86
+ tools: [],
87
+ skills: [],
88
+ mcpServers: [],
89
+ probes: [],
90
+ codeMatches: [],
91
+ dependencies: [],
92
+ frameworks: [],
93
+ warnings: []
94
+ };
95
+
96
+ const seenTools = new Set();
97
+ const dependencies = new Set();
98
+ const frameworks = new Set();
99
+
100
+ for (const file of files.sort()) {
101
+ const name = basename(file).toLowerCase();
102
+ const rel = relative(absRoot, file);
103
+
104
+ if (name === "package.json") {
105
+ const pkg = await readJson(file, report);
106
+ if (pkg) {
107
+ const deps = { ...(pkg.dependencies ?? {}), ...(pkg.devDependencies ?? {}) };
108
+ for (const dep of Object.keys(deps)) {
109
+ dependencies.add(dep);
110
+ if (JS_FRAMEWORKS[dep]) frameworks.add(JS_FRAMEWORKS[dep]);
111
+ }
112
+ }
113
+ continue;
114
+ }
115
+
116
+ if (name === "requirements.txt" || name === "pyproject.toml") {
117
+ const text = await readText(file, report);
118
+ if (text) {
119
+ for (const [pkg, framework] of Object.entries(PY_FRAMEWORKS)) {
120
+ if (new RegExp(`(^|[^a-z0-9_-])${pkg}([^a-z0-9_-]|$)`, "i").test(text)) {
121
+ dependencies.add(pkg);
122
+ frameworks.add(framework);
123
+ }
124
+ }
125
+ }
126
+ continue;
127
+ }
128
+
129
+ if (FUNCTION_SPEC_NAMES.has(name)) {
130
+ const spec = await readJson(file, report);
131
+ if (!spec) continue;
132
+ const tools = toolsFromSpec(spec);
133
+ if (tools.length === 0) {
134
+ report.warnings.push(`${rel}: 未识别出工具定义(需要 name + input/parameters,或 tools 数组)`);
135
+ continue;
136
+ }
137
+ const added = [];
138
+ for (const tool of tools) {
139
+ if (seenTools.has(tool.name)) {
140
+ report.warnings.push(`${rel}: 工具 "${tool.name}" 重复,已忽略`);
141
+ continue;
142
+ }
143
+ seenTools.add(tool.name);
144
+ report.tools.push({ ...tool, source: rel, origin: "function-spec" });
145
+ added.push(tool.name);
146
+ }
147
+ report.sources.push({ type: "function-spec", path: rel, confidence: "high", tools: added });
148
+ continue;
149
+ }
150
+
151
+ if (MCP_CONFIG_NAMES.has(name)) {
152
+ const config = await readJson(file, report);
153
+ if (!config) continue;
154
+ const servers = mcpServersFrom(config);
155
+ if (servers.length === 0) {
156
+ report.warnings.push(`${rel}: 未找到 mcpServers/servers 配置`);
157
+ continue;
158
+ }
159
+ report.mcpServers.push(...servers.map((server) => ({ ...server, source: rel })));
160
+ report.sources.push({
161
+ type: "mcp-config",
162
+ path: rel,
163
+ confidence: "high",
164
+ servers: servers.map((server) => server.name),
165
+ note: "工具清单需连接 MCP server 后获取(本轮 scan 不启动进程)"
166
+ });
167
+ continue;
168
+ }
169
+
170
+ if (OPENAPI_PATTERN.test(name)) {
171
+ const spec = await readJson(file, report);
172
+ if (!spec || !spec.paths) continue;
173
+ const tools = toolsFromOpenApi(spec);
174
+ const added = [];
175
+ for (const tool of tools) {
176
+ if (seenTools.has(tool.name)) {
177
+ report.warnings.push(`${rel}: 工具 "${tool.name}" 重复,已忽略`);
178
+ continue;
179
+ }
180
+ seenTools.add(tool.name);
181
+ report.tools.push({ ...tool, source: rel, origin: "openapi" });
182
+ added.push(tool.name);
183
+ }
184
+ report.sources.push({ type: "openapi", path: rel, confidence: "medium", tools: added });
185
+ continue;
186
+ }
187
+
188
+ if (name === SKILL_FILE) {
189
+ const text = await readText(file, report);
190
+ if (!text) continue;
191
+ const meta = parseFrontmatter(text);
192
+ if (!meta.name) {
193
+ report.warnings.push(`${rel}: SKILL.md 缺少 frontmatter name`);
194
+ continue;
195
+ }
196
+ report.skills.push({ name: meta.name, description: meta.description ?? "", source: rel });
197
+ report.sources.push({
198
+ type: "skill",
199
+ path: rel,
200
+ confidence: "high",
201
+ skills: [meta.name],
202
+ note: "skill 没有参数 schema,不参与工具评测;可作为 skill 选择评测的候选"
203
+ });
204
+ continue;
205
+ }
206
+
207
+ if (CODE_EXTENSIONS.has(name.slice(name.lastIndexOf(".")))) {
208
+ const info = await stat(file).catch(() => null);
209
+ if (!info || info.size > MAX_FILE_BYTES) continue;
210
+ const text = await readText(file, report);
211
+ if (!text) continue;
212
+ const matches = scanCode(text);
213
+ for (const match of matches) {
214
+ report.codeMatches.push({
215
+ source: rel,
216
+ line: match.line,
217
+ label: match.label,
218
+ name: match.name,
219
+ confidence: match.confidence
220
+ });
221
+ }
222
+ if (matches.length > 0) {
223
+ report.sources.push({
224
+ type: "code",
225
+ path: rel,
226
+ confidence: "low",
227
+ matches: matches.length,
228
+ note: "代码内定义只能作为草稿,需人工确认"
229
+ });
230
+ }
231
+ }
232
+ }
233
+
234
+ report.dependencies = [...dependencies].sort();
235
+ report.frameworks = [...frameworks].sort();
236
+ return report;
237
+ }
238
+
239
+ export function addTools(report, tools, { source, origin, confidence = "high", note }) {
240
+ const existing = new Set(report.tools.map((tool) => tool.name));
241
+ const added = [];
242
+ for (const tool of tools) {
243
+ if (!tool?.name || existing.has(tool.name)) continue;
244
+ existing.add(tool.name);
245
+ report.tools.push({ ...tool, source, origin });
246
+ added.push(tool.name);
247
+ }
248
+ report.sources.push({
249
+ type: origin,
250
+ path: source,
251
+ confidence,
252
+ tools: added,
253
+ ...(note ? { note } : {})
254
+ });
255
+ return added;
256
+ }
257
+
258
+ export function redactReport(report) {
259
+ return {
260
+ ...report,
261
+ mcpServers: report.mcpServers.map(({ env, ...rest }) => ({
262
+ ...rest,
263
+ envKeys: env ? Object.keys(env) : []
264
+ }))
265
+ };
266
+ }
267
+
268
+ export function buildSuiteDraft(report) {
269
+ return {
270
+ name: `${basename(report.root)}-draft`,
271
+ description: "由 mf scan 生成。请人工确认工具描述与参数,并补充 cases 后再运行 mf eval。",
272
+ tools: report.tools.map((tool) => ({
273
+ name: tool.name,
274
+ description: tool.description,
275
+ parameters: tool.parameters
276
+ })),
277
+ cases: []
278
+ };
279
+ }
280
+
281
+ export function formatScanSummary(report) {
282
+ const lines = [`scan: ${report.root}(${report.filesScanned} 个文件)`];
283
+
284
+ if (report.sources.length === 0) {
285
+ lines.push("未发现可识别的工具来源");
286
+ } else {
287
+ lines.push("", "sources:");
288
+ for (const source of report.sources) {
289
+ const detail = source.tools?.length
290
+ ? `${source.tools.length} tool(s): ${source.tools.join(", ")}`
291
+ : source.servers?.length
292
+ ? `${source.servers.length} server(s): ${source.servers.join(", ")}`
293
+ : source.skills?.length
294
+ ? `${source.skills.length} skill(s): ${source.skills.join(", ")}`
295
+ : source.matches
296
+ ? `${source.matches} code match(es)`
297
+ : "";
298
+ lines.push(` ${source.type.padEnd(13)} ${source.path.padEnd(40)} ${detail} [${source.confidence}]`);
299
+ if (source.note) lines.push(` ${" ".repeat(14)}note: ${source.note}`);
300
+ }
301
+ }
302
+
303
+ if (report.frameworks.length > 0) {
304
+ lines.push("", `frameworks: ${report.frameworks.join(", ")}`);
305
+ }
306
+
307
+ const probed = (report.probes ?? []).length;
308
+ lines.push(
309
+ "",
310
+ `tools: ${report.tools.length}`,
311
+ `skills: ${report.skills.length}(不进入工具评测)`,
312
+ `mcp servers: ${report.mcpServers.length}${probed > 0 ? `(已 probe ${probed} 个)` : "(需 --probe 才能拿到工具清单)"}`,
313
+ `code match: ${report.codeMatches.length}(low confidence,需人工确认)`
314
+ );
315
+
316
+ if ((report.probes ?? []).length > 0) {
317
+ lines.push("", "probes:");
318
+ for (const probe of report.probes) {
319
+ lines.push(
320
+ ` ${probe.server.padEnd(20)} ${probe.ok ? `ok, ${probe.toolCount} tool(s)` : `failed: ${probe.error}`}`
321
+ );
322
+ }
323
+ }
324
+
325
+ if (report.warnings.length > 0) {
326
+ lines.push("", "warnings:");
327
+ for (const warning of report.warnings) lines.push(` - ${warning}`);
328
+ }
329
+
330
+ const hints = [];
331
+ if (report.tools.length === 0) hints.push("没有可直接使用的工具定义:请手写 tools 或先连接 MCP server(后续版本支持)");
332
+ if (report.tools.length > 0) hints.push("cases 为空:补 1 个以上用例后才能运行 mf eval");
333
+ if (report.codeMatches.length > 0) hints.push("代码内定义请对照源码逐个确认,不要直接采信");
334
+ if (hints.length > 0) {
335
+ lines.push("", "next:");
336
+ for (const hint of hints) lines.push(` - ${hint}`);
337
+ }
338
+
339
+ return lines.join("\n");
340
+ }
341
+
342
+ async function walk(dir, depth, files) {
343
+ if (depth > MAX_DEPTH || files.length >= MAX_FILES) return;
344
+ let entries;
345
+ try {
346
+ entries = await readdir(dir, { withFileTypes: true });
347
+ } catch {
348
+ return;
349
+ }
350
+ entries.sort((a, b) => a.name.localeCompare(b.name));
351
+ for (const entry of entries) {
352
+ if (files.length >= MAX_FILES) return;
353
+ const full = join(dir, entry.name);
354
+ if (entry.isDirectory()) {
355
+ if (SKIP_DIRS.has(entry.name)) continue;
356
+ await walk(full, depth + 1, files);
357
+ } else if (entry.isFile()) {
358
+ files.push(full);
359
+ }
360
+ }
361
+ }
362
+
363
+ async function readJson(file, report) {
364
+ const text = await readText(file, report);
365
+ if (text === null) return null;
366
+ try {
367
+ return JSON.parse(text);
368
+ } catch (error) {
369
+ report.warnings.push(`${relative(report.root, file)}: JSON 解析失败(${String(error.message).slice(0, 80)})`);
370
+ return null;
371
+ }
372
+ }
373
+
374
+ async function readText(file, report) {
375
+ try {
376
+ return await readFile(file, "utf8");
377
+ } catch (error) {
378
+ report.warnings.push(`${relative(report.root, file)}: 读取失败(${String(error.message).slice(0, 80)})`);
379
+ return null;
380
+ }
381
+ }
382
+
383
+ function toolsFromSpec(spec) {
384
+ const raw = Array.isArray(spec)
385
+ ? spec
386
+ : Array.isArray(spec.tools)
387
+ ? spec.tools
388
+ : typeof spec.name === "string"
389
+ ? [spec]
390
+ : [];
391
+ return raw.map(normalizeTool).filter(Boolean);
392
+ }
393
+
394
+ export function normalizeTool(entry) {
395
+ const source = entry.function ?? entry;
396
+ if (!source || typeof source.name !== "string" || source.name.trim() === "") return null;
397
+ return {
398
+ name: source.name.trim(),
399
+ description: source.description ?? "",
400
+ parameters: source.input ?? source.parameters ?? source.inputSchema ?? { type: "object", properties: {} },
401
+ sideEffects: typeof source.sideEffects === "string" ? source.sideEffects : null
402
+ };
403
+ }
404
+
405
+ function mcpServersFrom(config) {
406
+ const servers = config.mcpServers ?? config.servers ?? {};
407
+ return Object.entries(servers).map(([name, value]) => ({
408
+ name,
409
+ transport: value.url ? "http" : "stdio",
410
+ command: value.command ?? null,
411
+ args: Array.isArray(value.args) ? value.args : [],
412
+ env: value.env && typeof value.env === "object" ? value.env : null,
413
+ url: value.url ?? null
414
+ }));
415
+ }
416
+
417
+ function toolsFromOpenApi(spec) {
418
+ const tools = [];
419
+ const paths = spec.paths ?? {};
420
+ for (const [path, operations] of Object.entries(paths)) {
421
+ if (!operations || typeof operations !== "object") continue;
422
+ const pathParams = Array.isArray(operations.parameters) ? operations.parameters : [];
423
+ for (const method of ["get", "post", "put", "patch", "delete"]) {
424
+ const op = operations[method];
425
+ if (!op || typeof op !== "object") continue;
426
+ const name = op.operationId ?? `${method}_${path}`.replace(/[^a-z0-9]+/gi, "_").replace(/^_+|_+$/g, "");
427
+ const properties = {};
428
+ const required = new Set();
429
+ const params = [...pathParams, ...(Array.isArray(op.parameters) ? op.parameters : [])];
430
+ for (const rawParam of params) {
431
+ const param = deref(spec, rawParam);
432
+ if (!param?.name) continue;
433
+ properties[param.name] = param.schema ?? { type: param.type ?? "string" };
434
+ if (param.description) properties[param.name].description = param.description;
435
+ if (param.required) required.add(param.name);
436
+ }
437
+ const bodySchema = deref(spec, op.requestBody?.content?.["application/json"]?.schema);
438
+ if (bodySchema?.properties) {
439
+ for (const [key, value] of Object.entries(bodySchema.properties)) {
440
+ properties[key] = deref(spec, value);
441
+ }
442
+ for (const key of bodySchema.required ?? []) required.add(key);
443
+ }
444
+ tools.push({
445
+ name,
446
+ description: op.summary ?? op.description ?? "",
447
+ parameters: {
448
+ type: "object",
449
+ properties,
450
+ ...(required.size > 0 ? { required: [...required] } : {})
451
+ }
452
+ });
453
+ }
454
+ }
455
+ return tools;
456
+ }
457
+
458
+ function deref(spec, node, depth = 0) {
459
+ if (!node || depth > 5) return node;
460
+ if (typeof node.$ref === "string" && node.$ref.startsWith("#/")) {
461
+ let target = spec;
462
+ for (const part of node.$ref.slice(2).split("/")) {
463
+ target = target?.[part.replace(/~1/g, "/").replace(/~0/g, "~")];
464
+ if (!target) return node;
465
+ }
466
+ return deref(spec, target, depth + 1);
467
+ }
468
+ return node;
469
+ }
470
+
471
+ function parseFrontmatter(text) {
472
+ const match = text.match(/^---\r?\n([\s\S]*?)\r?\n---/);
473
+ if (!match) return {};
474
+ const meta = {};
475
+ for (const line of match[1].split(/\r?\n/)) {
476
+ const pair = line.match(/^([A-Za-z0-9_-]+)\s*:\s*(.*)$/);
477
+ if (!pair) continue;
478
+ meta[pair[1]] = pair[2].replace(/^["']|["']$/g, "").trim();
479
+ }
480
+ return meta;
481
+ }
482
+
483
+ function scanCode(text) {
484
+ const matches = [];
485
+ for (const pattern of CODE_PATTERNS) {
486
+ const re = new RegExp(pattern.re.source, pattern.re.flags.includes("g") ? pattern.re.flags : `${pattern.re.flags}g`);
487
+ let match;
488
+ while ((match = re.exec(text)) !== null) {
489
+ if (match[0].length <= 500) {
490
+ matches.push({
491
+ line: lineAt(text, match.index),
492
+ label: pattern.label,
493
+ name: match[1] ?? null,
494
+ confidence: pattern.confidence
495
+ });
496
+ }
497
+ if (matches.length >= 50) return matches.sort((a, b) => a.line - b.line);
498
+ if (re.lastIndex === match.index) re.lastIndex += 1;
499
+ }
500
+ }
501
+ return matches.sort((a, b) => a.line - b.line);
502
+ }
503
+
504
+ function lineAt(text, index) {
505
+ let line = 1;
506
+ for (let i = 0; i < index; i += 1) {
507
+ if (text.charCodeAt(i) === 10) line += 1;
508
+ }
509
+ return line;
510
+ }
package/lib/score.js ADDED
@@ -0,0 +1,137 @@
1
+ function normalizeText(value) {
2
+ return String(value).replace(/\s+/g, " ").trim();
3
+ }
4
+
5
+ function deepEqual(a, b) {
6
+ if (a === b) return true;
7
+ if (typeof a === "number" && typeof b === "number") return a === b;
8
+ if (typeof a === "string" && typeof b === "string") {
9
+ return normalizeText(a) === normalizeText(b);
10
+ }
11
+ if (Array.isArray(a) && Array.isArray(b)) {
12
+ return a.length === b.length && a.every((item, i) => deepEqual(item, b[i]));
13
+ }
14
+ if (a && b && typeof a === "object" && typeof b === "object") {
15
+ const aKeys = Object.keys(a);
16
+ const bKeys = Object.keys(b);
17
+ if (aKeys.length !== bKeys.length) return false;
18
+ return aKeys.every((key) => deepEqual(a[key], b[key]));
19
+ }
20
+ return false;
21
+ }
22
+
23
+ export function matchArg(actual, expected) {
24
+ if (expected && typeof expected === "object" && !Array.isArray(expected)) {
25
+ if ("$exists" in expected) {
26
+ const present = actual !== undefined && actual !== null;
27
+ return present === Boolean(expected.$exists);
28
+ }
29
+ if ("$contains" in expected) {
30
+ const needle = normalizeText(expected.$contains);
31
+ if (typeof actual === "string") return normalizeText(actual).includes(needle);
32
+ if (Array.isArray(actual)) {
33
+ return actual.some((item) => normalizeText(item).includes(needle));
34
+ }
35
+ return false;
36
+ }
37
+ if ("$oneOf" in expected) {
38
+ return expected.$oneOf.some((option) => deepEqual(actual, option));
39
+ }
40
+ }
41
+ return deepEqual(actual, expected);
42
+ }
43
+
44
+ export function matchArgs(actualArgs, expectedArgs) {
45
+ const fields = Object.entries(expectedArgs ?? {});
46
+ const failures = [];
47
+ for (const [key, expected] of fields) {
48
+ if (!matchArg(actualArgs?.[key], expected)) failures.push(key);
49
+ }
50
+ return {
51
+ total: fields.length,
52
+ matched: fields.length - failures.length,
53
+ failures
54
+ };
55
+ }
56
+
57
+ export function scoreModel({ model, cases, results, pricing }) {
58
+ const rows = cases.map((testCase, index) => {
59
+ const result = results[index];
60
+ const actualTool = result.ok ? (result.toolCalls?.[0]?.name ?? null) : null;
61
+ const selectionCorrect = Boolean(result.ok) && actualTool === testCase.expectedTool;
62
+ const args = selectionCorrect && testCase.expectedTool
63
+ ? matchArgs(result.toolCalls?.[0]?.arguments, testCase.expectedArgs)
64
+ : { total: 0, matched: 0, failures: [] };
65
+ const passed = Boolean(result.ok) && selectionCorrect && args.failures.length === 0;
66
+ return {
67
+ id: testCase.id,
68
+ expectedTool: testCase.expectedTool,
69
+ actualTool,
70
+ selectionCorrect,
71
+ args,
72
+ passed,
73
+ reviewed: testCase.reviewed !== false,
74
+ source: testCase.source ?? "manual",
75
+ latencyMs: result.latencyMs ?? 0,
76
+ usage: result.usage ?? { input: 0, output: 0 },
77
+ error: result.error ?? null
78
+ };
79
+ });
80
+
81
+ const total = rows.length;
82
+ const reviewedRows = rows.filter((row) => row.reviewed);
83
+ const unreviewedRows = rows.filter((row) => !row.reviewed);
84
+ const selectionHits = reviewedRows.filter((row) => row.selectionCorrect).length;
85
+ const argFields = reviewedRows.reduce((sum, row) => sum + row.args.total, 0);
86
+ const argHits = reviewedRows.reduce((sum, row) => sum + row.args.matched, 0);
87
+ const passed = reviewedRows.filter((row) => row.passed).length;
88
+ const errors = rows.filter((row) => row.error).length;
89
+ const latencies = rows.filter((row) => row.latencyMs > 0).map((row) => row.latencyMs);
90
+ const inputTokens = rows.reduce((sum, row) => sum + (row.usage.input ?? 0), 0);
91
+ const outputTokens = rows.reduce((sum, row) => sum + (row.usage.output ?? 0), 0);
92
+ const cachedTokens = Math.min(
93
+ inputTokens,
94
+ rows.reduce((sum, row) => sum + (row.usage.cached ?? 0), 0)
95
+ );
96
+ const uncachedTokens = inputTokens - cachedTokens;
97
+ const costCny = pricing
98
+ ? (uncachedTokens * (pricing.input ?? 0) +
99
+ cachedTokens * (pricing.cachedInput ?? pricing.input ?? 0) +
100
+ outputTokens * (pricing.output ?? 0)) /
101
+ 1e6
102
+ : 0;
103
+ const reviewedTotal = reviewedRows.length;
104
+
105
+ return {
106
+ model,
107
+ total,
108
+ reviewedTotal,
109
+ unreviewedTotal: unreviewedRows.length,
110
+ unreviewedSelectionAccuracy: unreviewedRows.length
111
+ ? unreviewedRows.filter((row) => row.selectionCorrect).length / unreviewedRows.length
112
+ : null,
113
+ selectionAccuracy: reviewedTotal ? selectionHits / reviewedTotal : 0,
114
+ argAccuracy: argFields ? argHits / argFields : null,
115
+ passRate: reviewedTotal ? passed / reviewedTotal : 0,
116
+ errors,
117
+ p50LatencyMs: percentile(latencies, 0.5),
118
+ p95LatencyMs: percentile(latencies, 0.95),
119
+ avgCostCny: total ? costCny / total : 0,
120
+ totalCostCny: costCny,
121
+ inputTokens,
122
+ cachedTokens,
123
+ outputTokens,
124
+ rows
125
+ };
126
+ }
127
+
128
+ function percentile(values, ratio) {
129
+ if (values.length === 0) return 0;
130
+ const sorted = [...values].sort((a, b) => a - b);
131
+ const index = Math.max(0, Math.min(sorted.length - 1, Math.ceil(ratio * sorted.length) - 1));
132
+ return Math.round(sorted[index]);
133
+ }
134
+
135
+ export function pct(value) {
136
+ return value === null ? "n/a" : `${Math.round(value * 100)}%`;
137
+ }