whetkit 0.2.0__tar.gz → 0.3.0__tar.gz
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.
- {whetkit-0.2.0 → whetkit-0.3.0}/PKG-INFO +1 -1
- {whetkit-0.2.0 → whetkit-0.3.0}/pyproject.toml +1 -1
- {whetkit-0.2.0 → whetkit-0.3.0}/src/whetkit/cli.py +119 -7
- {whetkit-0.2.0 → whetkit-0.3.0}/src/whetkit/generate.py +35 -7
- whetkit-0.3.0/src/whetkit/llm/pricing.py +27 -0
- {whetkit-0.2.0 → whetkit-0.3.0}/src/whetkit/mcp/transport.py +27 -1
- {whetkit-0.2.0 → whetkit-0.3.0}/src/whetkit/scoring/aggregate.py +8 -0
- {whetkit-0.2.0 → whetkit-0.3.0}/LICENSE +0 -0
- {whetkit-0.2.0 → whetkit-0.3.0}/README.md +0 -0
- {whetkit-0.2.0 → whetkit-0.3.0}/src/whetkit/__init__.py +0 -0
- {whetkit-0.2.0 → whetkit-0.3.0}/src/whetkit/curation/__init__.py +0 -0
- {whetkit-0.2.0 → whetkit-0.3.0}/src/whetkit/curation/export.py +0 -0
- {whetkit-0.2.0 → whetkit-0.3.0}/src/whetkit/curation/optimizer.py +0 -0
- {whetkit-0.2.0 → whetkit-0.3.0}/src/whetkit/curation/overlay.py +0 -0
- {whetkit-0.2.0 → whetkit-0.3.0}/src/whetkit/curation/plan.py +0 -0
- {whetkit-0.2.0 → whetkit-0.3.0}/src/whetkit/datasets/__init__.py +0 -0
- {whetkit-0.2.0 → whetkit-0.3.0}/src/whetkit/datasets/tasks.py +0 -0
- {whetkit-0.2.0 → whetkit-0.3.0}/src/whetkit/doctor.py +0 -0
- {whetkit-0.2.0 → whetkit-0.3.0}/src/whetkit/llm/__init__.py +0 -0
- {whetkit-0.2.0 → whetkit-0.3.0}/src/whetkit/llm/anthropic_provider.py +0 -0
- {whetkit-0.2.0 → whetkit-0.3.0}/src/whetkit/llm/base.py +0 -0
- {whetkit-0.2.0 → whetkit-0.3.0}/src/whetkit/llm/openai_provider.py +0 -0
- {whetkit-0.2.0 → whetkit-0.3.0}/src/whetkit/llm/registry.py +0 -0
- {whetkit-0.2.0 → whetkit-0.3.0}/src/whetkit/mcp/__init__.py +0 -0
- {whetkit-0.2.0 → whetkit-0.3.0}/src/whetkit/mcp/client.py +0 -0
- {whetkit-0.2.0 → whetkit-0.3.0}/src/whetkit/mcp/introspect.py +0 -0
- {whetkit-0.2.0 → whetkit-0.3.0}/src/whetkit/py.typed +0 -0
- {whetkit-0.2.0 → whetkit-0.3.0}/src/whetkit/report/__init__.py +0 -0
- {whetkit-0.2.0 → whetkit-0.3.0}/src/whetkit/report/builder.py +0 -0
- {whetkit-0.2.0 → whetkit-0.3.0}/src/whetkit/report/html.py +0 -0
- {whetkit-0.2.0 → whetkit-0.3.0}/src/whetkit/runner/__init__.py +0 -0
- {whetkit-0.2.0 → whetkit-0.3.0}/src/whetkit/runner/agent.py +0 -0
- {whetkit-0.2.0 → whetkit-0.3.0}/src/whetkit/scoring/__init__.py +0 -0
- {whetkit-0.2.0 → whetkit-0.3.0}/src/whetkit/scoring/deterministic.py +0 -0
- {whetkit-0.2.0 → whetkit-0.3.0}/src/whetkit/scoring/judge.py +0 -0
- {whetkit-0.2.0 → whetkit-0.3.0}/src/whetkit/tracing/__init__.py +0 -0
- {whetkit-0.2.0 → whetkit-0.3.0}/src/whetkit/tracing/records.py +0 -0
- {whetkit-0.2.0 → whetkit-0.3.0}/src/whetkit/tracing/store.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: whetkit
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.3.0
|
|
4
4
|
Summary: Measure and improve how well LLM agents select and use the tools exposed by an MCP server
|
|
5
5
|
Keywords: mcp,model-context-protocol,llm,agents,evals,tool-use,tool-selection,benchmark
|
|
6
6
|
Author: Mohammed Benlamlih
|
|
@@ -16,6 +16,14 @@ app = typer.Typer(
|
|
|
16
16
|
)
|
|
17
17
|
|
|
18
18
|
|
|
19
|
+
def _resolve_server(value: str, http_mode: HttpMode) -> ServerSpec:
|
|
20
|
+
"""resolve_server_spec with a CLI-friendly error instead of a traceback."""
|
|
21
|
+
try:
|
|
22
|
+
return resolve_server_spec(value, http_mode=http_mode)
|
|
23
|
+
except ValueError as exc:
|
|
24
|
+
raise typer.BadParameter(str(exc)) from exc
|
|
25
|
+
|
|
26
|
+
|
|
19
27
|
def _judge_enabled(judge: str, judge_model: str) -> bool:
|
|
20
28
|
"""--judge auto: grade with the LLM judge only when its API key is set."""
|
|
21
29
|
if judge in ("on", "off"):
|
|
@@ -31,10 +39,10 @@ def _resolve_task_servers(
|
|
|
31
39
|
tasks: list, server_override: str | None, http_mode: HttpMode
|
|
32
40
|
) -> dict[str, ServerSpec]:
|
|
33
41
|
if server_override is not None:
|
|
34
|
-
spec =
|
|
42
|
+
spec = _resolve_server(server_override, http_mode)
|
|
35
43
|
return {task.server: spec for task in tasks}
|
|
36
44
|
return {
|
|
37
|
-
task.server:
|
|
45
|
+
task.server: _resolve_server(task.server, http_mode)
|
|
38
46
|
for task in {t.server: t for t in tasks}.values()
|
|
39
47
|
}
|
|
40
48
|
|
|
@@ -70,6 +78,7 @@ def _summary_payload(group_name: str, summary, task_runs: list) -> dict:
|
|
|
70
78
|
"hit": score.hit,
|
|
71
79
|
"tool_hit": score.tool_hit,
|
|
72
80
|
"judge_passed": score.judge.passed if score.judge else None,
|
|
81
|
+
"spec_gap": score.spec_gap,
|
|
73
82
|
"called": score.tool_match.called,
|
|
74
83
|
"missing": score.tool_match.missing_slots,
|
|
75
84
|
"extra_calls": score.tool_match.extra_calls,
|
|
@@ -117,7 +126,7 @@ def inspect(
|
|
|
117
126
|
] = HttpMode.STATEFUL,
|
|
118
127
|
) -> None:
|
|
119
128
|
"""Connect to an MCP server and print its tool inventory."""
|
|
120
|
-
spec =
|
|
129
|
+
spec = _resolve_server(server, http_mode)
|
|
121
130
|
inventory = asyncio.run(inspect_server(spec))
|
|
122
131
|
|
|
123
132
|
for line in inventory.summary_lines():
|
|
@@ -164,7 +173,7 @@ def doctor(
|
|
|
164
173
|
if fail_on not in ("error", "warn", "never"):
|
|
165
174
|
raise typer.BadParameter("--fail-on must be 'error', 'warn', or 'never'")
|
|
166
175
|
|
|
167
|
-
spec =
|
|
176
|
+
spec = _resolve_server(server, http_mode)
|
|
168
177
|
inventory = asyncio.run(inspect_server(spec))
|
|
169
178
|
findings = diagnose(inventory)
|
|
170
179
|
|
|
@@ -209,16 +218,30 @@ def generate(
|
|
|
209
218
|
model: Annotated[
|
|
210
219
|
str, typer.Option("--model", help="Generator model as provider:model_id")
|
|
211
220
|
] = "anthropic:claude-sonnet-5",
|
|
221
|
+
allow_writes: Annotated[
|
|
222
|
+
bool,
|
|
223
|
+
typer.Option(
|
|
224
|
+
"--allow-writes",
|
|
225
|
+
help="Let drafts include non-destructive write tasks (default: read-only only)",
|
|
226
|
+
),
|
|
227
|
+
] = False,
|
|
212
228
|
http_mode: Annotated[HttpMode, typer.Option("--http-mode")] = HttpMode.STATEFUL,
|
|
213
229
|
) -> None:
|
|
214
230
|
"""Draft eval tasks from the server's tool inventory (review before
|
|
215
231
|
trusting — the header in the output file says the same)."""
|
|
216
232
|
from whetkit.generate import GeneratorConfig, generate_tasks, write_tasks_yaml
|
|
217
233
|
|
|
218
|
-
spec =
|
|
234
|
+
spec = _resolve_server(server, http_mode)
|
|
219
235
|
inventory = asyncio.run(inspect_server(spec))
|
|
220
236
|
tasks, warnings = asyncio.run(
|
|
221
|
-
generate_tasks(
|
|
237
|
+
generate_tasks(
|
|
238
|
+
inventory,
|
|
239
|
+
server,
|
|
240
|
+
count=count,
|
|
241
|
+
config=GeneratorConfig(model=model),
|
|
242
|
+
server_context=spec.label(),
|
|
243
|
+
allow_writes=allow_writes,
|
|
244
|
+
)
|
|
222
245
|
)
|
|
223
246
|
for warning in warnings:
|
|
224
247
|
typer.echo(f"warning: {warning}", err=True)
|
|
@@ -273,6 +296,60 @@ def export(
|
|
|
273
296
|
typer.echo(rendered, nl=False)
|
|
274
297
|
|
|
275
298
|
|
|
299
|
+
@app.command()
|
|
300
|
+
def diff(
|
|
301
|
+
before: Annotated[str, typer.Argument(help="Baseline --summary-json file")],
|
|
302
|
+
after: Annotated[str, typer.Argument(help="Comparison --summary-json file")],
|
|
303
|
+
) -> None:
|
|
304
|
+
"""Compare two --summary-json files: headline deltas plus per-task
|
|
305
|
+
transitions. The before/after table without re-running anything."""
|
|
306
|
+
import json as jsonlib
|
|
307
|
+
|
|
308
|
+
docs = []
|
|
309
|
+
for path in (before, after):
|
|
310
|
+
if not Path(path).is_file():
|
|
311
|
+
raise typer.BadParameter(f"no summary file at {path}")
|
|
312
|
+
docs.append(jsonlib.loads(Path(path).read_text()))
|
|
313
|
+
|
|
314
|
+
def mean(doc: dict, key: str) -> float:
|
|
315
|
+
values = [r[key] for r in doc["runs"] if r.get(key) is not None]
|
|
316
|
+
return sum(values) / len(values) if values else 0.0
|
|
317
|
+
|
|
318
|
+
typer.echo(f"{'metric':<28} {'before':>10} {'after':>10}")
|
|
319
|
+
for label, key, fmt in (
|
|
320
|
+
("Hit-rate", "hit_rate", "{:.0%}"),
|
|
321
|
+
("Tool-selection hit-rate", "tool_hit_rate", "{:.0%}"),
|
|
322
|
+
("Judge pass-rate", "judge_pass_rate", "{:.0%}"),
|
|
323
|
+
("Precision (avg)", "avg_precision", "{:.0%}"),
|
|
324
|
+
("Unnecessary calls/task", "avg_extra_calls", "{:.1f}"),
|
|
325
|
+
("Tokens in (per run)", "tokens_in", "{:.0f}"),
|
|
326
|
+
):
|
|
327
|
+
b, a = mean(docs[0], key), mean(docs[1], key)
|
|
328
|
+
typer.echo(f"{label:<28} {fmt.format(b):>10} {fmt.format(a):>10}")
|
|
329
|
+
|
|
330
|
+
def outcomes(doc: dict) -> dict[str, list[bool]]:
|
|
331
|
+
per_task: dict[str, list[bool]] = {}
|
|
332
|
+
for run_doc in doc["runs"]:
|
|
333
|
+
for task in run_doc["tasks"]:
|
|
334
|
+
per_task.setdefault(task["id"], []).append(bool(task["hit"]))
|
|
335
|
+
return per_task
|
|
336
|
+
|
|
337
|
+
before_tasks, after_tasks = outcomes(docs[0]), outcomes(docs[1])
|
|
338
|
+
typer.echo("")
|
|
339
|
+
for task_id in sorted(set(before_tasks) | set(after_tasks)):
|
|
340
|
+
|
|
341
|
+
def word(hits: list[bool] | None) -> str:
|
|
342
|
+
if not hits:
|
|
343
|
+
return "—"
|
|
344
|
+
if all(hits):
|
|
345
|
+
return "PASS"
|
|
346
|
+
return "MISS" if not any(hits) else f"FLAKY {sum(hits)}/{len(hits)}"
|
|
347
|
+
|
|
348
|
+
b_word, a_word = word(before_tasks.get(task_id)), word(after_tasks.get(task_id))
|
|
349
|
+
marker = "" if b_word == a_word else (" ↑" if a_word == "PASS" else " ↓")
|
|
350
|
+
typer.echo(f" {task_id:<28} {b_word:>10} -> {a_word:<10}{marker}")
|
|
351
|
+
|
|
352
|
+
|
|
276
353
|
@app.command()
|
|
277
354
|
def run(
|
|
278
355
|
tasks: Annotated[
|
|
@@ -347,6 +424,16 @@ def run(
|
|
|
347
424
|
help="Write a machine-readable summary (metrics + per-task outcomes) to this path",
|
|
348
425
|
),
|
|
349
426
|
] = None,
|
|
427
|
+
reset_cmd: Annotated[
|
|
428
|
+
str | None,
|
|
429
|
+
typer.Option(
|
|
430
|
+
"--reset-cmd",
|
|
431
|
+
help=(
|
|
432
|
+
"Shell command run before each repetition (and the first run) to "
|
|
433
|
+
"reset server-side fixtures — makes --runs honest on stateful servers"
|
|
434
|
+
),
|
|
435
|
+
),
|
|
436
|
+
] = None,
|
|
350
437
|
http_mode: Annotated[HttpMode, typer.Option("--http-mode")] = HttpMode.STATEFUL,
|
|
351
438
|
) -> None:
|
|
352
439
|
"""Run eval tasks against an MCP server and print scored results."""
|
|
@@ -411,13 +498,27 @@ def run(
|
|
|
411
498
|
line += f" judge={'pass' if score.judge.passed else 'FAIL'}"
|
|
412
499
|
typer.echo(line)
|
|
413
500
|
typer.echo("")
|
|
501
|
+
for score in summary.scores:
|
|
502
|
+
if score.spec_gap:
|
|
503
|
+
called = ", ".join(score.tool_match.called) or "(none)"
|
|
504
|
+
typer.echo(
|
|
505
|
+
f"⚠ possible task-spec gap: '{score.task_id}' reached a correct "
|
|
506
|
+
f"outcome (judge passed) via tools not listed in expected_tools "
|
|
507
|
+
f"— called: {called}"
|
|
508
|
+
)
|
|
414
509
|
for line in summary.summary_lines():
|
|
415
510
|
typer.echo(line)
|
|
416
511
|
tokens_in = sum(r.total_usage.input_tokens for r in task_runs)
|
|
417
512
|
tokens_out = sum(r.total_usage.output_tokens for r in task_runs)
|
|
513
|
+
from whetkit.llm import parse_model
|
|
514
|
+
from whetkit.llm.pricing import estimate_cost_usd
|
|
515
|
+
|
|
516
|
+
cost = estimate_cost_usd(parse_model(model)[1], tokens_in, tokens_out)
|
|
517
|
+
cost_note = f" ≈ ${cost:.4f} (est.)" if cost is not None else ""
|
|
418
518
|
typer.echo(
|
|
419
519
|
f"Tokens in/out: {tokens_in}/{tokens_out} "
|
|
420
520
|
f"Total latency: {sum(r.total_latency_ms for r in task_runs) / 1000:.1f}s"
|
|
521
|
+
f"{cost_note}"
|
|
421
522
|
)
|
|
422
523
|
return task_runs, summary
|
|
423
524
|
|
|
@@ -427,6 +528,17 @@ def run(
|
|
|
427
528
|
cache = JudgeCache(default_store_path().parent / "judge_cache.sqlite3")
|
|
428
529
|
try:
|
|
429
530
|
for run_index in range(1, runs + 1):
|
|
531
|
+
if reset_cmd:
|
|
532
|
+
import subprocess
|
|
533
|
+
|
|
534
|
+
typer.echo(f"reset: {reset_cmd}", err=True)
|
|
535
|
+
proc = subprocess.run(reset_cmd, shell=True)
|
|
536
|
+
if proc.returncode != 0:
|
|
537
|
+
typer.echo(
|
|
538
|
+
f"error: --reset-cmd failed with exit code {proc.returncode}",
|
|
539
|
+
err=True,
|
|
540
|
+
)
|
|
541
|
+
raise typer.Exit(code=1)
|
|
430
542
|
group_name = group if runs == 1 else f"{group}-{run_index}"
|
|
431
543
|
task_runs, summary = await _run_once(group_name, cache)
|
|
432
544
|
summaries.append(summary)
|
|
@@ -729,7 +841,7 @@ def overlay(
|
|
|
729
841
|
the origin is never modified)."""
|
|
730
842
|
from whetkit.curation import load_plan, serve_overlay
|
|
731
843
|
|
|
732
|
-
origin =
|
|
844
|
+
origin = _resolve_server(server, http_mode)
|
|
733
845
|
asyncio.run(serve_overlay(origin, load_plan(plan)))
|
|
734
846
|
|
|
735
847
|
|
|
@@ -18,7 +18,7 @@ from whetkit.mcp.introspect import ServerInventory
|
|
|
18
18
|
|
|
19
19
|
DEFAULT_GENERATOR_MODEL = "anthropic:claude-sonnet-5"
|
|
20
20
|
|
|
21
|
-
|
|
21
|
+
GENERATOR_SYSTEM_PROMPT_TEMPLATE = """\
|
|
22
22
|
You write eval tasks for an AI-agent benchmark. You are given the tools an
|
|
23
23
|
MCP server exposes. Draft tasks that measure whether an agent picks the
|
|
24
24
|
right tools for realistic user requests.
|
|
@@ -32,9 +32,7 @@ Rules for every task:
|
|
|
32
32
|
alternatives for that step. Use ONLY tool names from the given list.
|
|
33
33
|
- success_criteria is one or two concrete, checkable sentences a grader can
|
|
34
34
|
verify from the agent's final answer alone.
|
|
35
|
-
|
|
36
|
-
clearly built for writes, and never anything destructive or irreversible
|
|
37
|
-
(no deletes, resets, or purges).
|
|
35
|
+
{writes_rule}
|
|
38
36
|
- Set "ordered": true only when the steps must happen in sequence.
|
|
39
37
|
- ids are short kebab-case slugs, unique across the set.
|
|
40
38
|
|
|
@@ -59,9 +57,37 @@ def _inventory_block(inventory: ServerInventory) -> str:
|
|
|
59
57
|
return "\n".join(lines)
|
|
60
58
|
|
|
61
59
|
|
|
62
|
-
|
|
60
|
+
READ_ONLY_RULE = (
|
|
61
|
+
"- Draft ONLY read-only tasks: nothing that creates, modifies, deletes,\n"
|
|
62
|
+
" or sends anything. If the tool set is write-heavy, still restrict\n"
|
|
63
|
+
" yourself to its read/query tools."
|
|
64
|
+
)
|
|
65
|
+
WRITES_ALLOWED_RULE = (
|
|
66
|
+
"- Prefer read-only tasks. Write tasks are allowed when the tool set is\n"
|
|
67
|
+
" clearly built for writes, but never anything destructive or\n"
|
|
68
|
+
" irreversible (no deletes, resets, or purges)."
|
|
69
|
+
)
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def generator_system_prompt(allow_writes: bool) -> str:
|
|
73
|
+
rule = WRITES_ALLOWED_RULE if allow_writes else READ_ONLY_RULE
|
|
74
|
+
# plain replace, not str.format — the template contains JSON braces
|
|
75
|
+
return GENERATOR_SYSTEM_PROMPT_TEMPLATE.replace("{writes_rule}", rule)
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def build_generator_prompt(inventory: ServerInventory, count: int, server_context: str = "") -> str:
|
|
79
|
+
context_block = ""
|
|
80
|
+
if server_context:
|
|
81
|
+
context_block = (
|
|
82
|
+
"## Execution context\n"
|
|
83
|
+
f"These tasks will execute against: {server_context}\n"
|
|
84
|
+
"Write prompts whose arguments fit THAT context — the real paths,\n"
|
|
85
|
+
"repositories, or resources it implies. Never invent placeholder\n"
|
|
86
|
+
"examples like /home/user/project or acme-corp/website.\n\n"
|
|
87
|
+
)
|
|
63
88
|
return (
|
|
64
89
|
f"## Tools exposed by the server\n{_inventory_block(inventory)}\n\n"
|
|
90
|
+
f"{context_block}"
|
|
65
91
|
f"Draft exactly {count} eval tasks now."
|
|
66
92
|
)
|
|
67
93
|
|
|
@@ -102,6 +128,8 @@ async def generate_tasks(
|
|
|
102
128
|
count: int = 5,
|
|
103
129
|
config: GeneratorConfig | None = None,
|
|
104
130
|
provider: LLMProvider | None = None,
|
|
131
|
+
server_context: str = "",
|
|
132
|
+
allow_writes: bool = False,
|
|
105
133
|
) -> tuple[list[TaskSpec], list[str]]:
|
|
106
134
|
"""Draft ``count`` tasks for ``server``. Returns (tasks, warnings);
|
|
107
135
|
drafts that fail validation are dropped, never written."""
|
|
@@ -109,12 +137,12 @@ async def generate_tasks(
|
|
|
109
137
|
provider_name, model_id = parse_model(config.model)
|
|
110
138
|
provider = provider or get_provider(provider_name)
|
|
111
139
|
|
|
112
|
-
prompt = build_generator_prompt(inventory, count)
|
|
140
|
+
prompt = build_generator_prompt(inventory, count, server_context=server_context)
|
|
113
141
|
drafts = None
|
|
114
142
|
for _attempt in range(2):
|
|
115
143
|
turn = await provider.complete(
|
|
116
144
|
model=model_id,
|
|
117
|
-
system=
|
|
145
|
+
system=generator_system_prompt(allow_writes),
|
|
118
146
|
messages=[ChatMessage(role="user", content=prompt)],
|
|
119
147
|
tools=[],
|
|
120
148
|
max_tokens=config.max_tokens,
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
"""Rough USD cost estimates for known models.
|
|
2
|
+
|
|
3
|
+
Prices are per million tokens (input, output), hand-checked 2026-07-08
|
|
4
|
+
against the providers' public pricing pages. They rot — treat every
|
|
5
|
+
number this module produces as an estimate, which is how the CLI labels
|
|
6
|
+
it. Unknown models simply get no estimate.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
PRICES_PER_MTOK: dict[str, tuple[float, float]] = {
|
|
10
|
+
"claude-sonnet-5": (3.00, 15.00),
|
|
11
|
+
"claude-haiku-4-5": (1.00, 5.00),
|
|
12
|
+
"gpt-4.1": (2.00, 8.00),
|
|
13
|
+
"gpt-4.1-mini": (0.40, 1.60),
|
|
14
|
+
"gpt-4.1-nano": (0.10, 0.40),
|
|
15
|
+
"gpt-5-mini": (0.25, 2.00),
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def estimate_cost_usd(model_id: str, tokens_in: int, tokens_out: int) -> float | None:
|
|
20
|
+
"""Best-effort estimate; None when the model isn't in the table.
|
|
21
|
+
Dated model ids (claude-haiku-4-5-20251001) match their base entry."""
|
|
22
|
+
# longest key first, so gpt-4.1-mini never matches the gpt-4.1 entry
|
|
23
|
+
for known in sorted(PRICES_PER_MTOK, key=len, reverse=True):
|
|
24
|
+
price_in, price_out = PRICES_PER_MTOK[known]
|
|
25
|
+
if model_id == known or model_id.startswith(f"{known}-"):
|
|
26
|
+
return (tokens_in * price_in + tokens_out * price_out) / 1_000_000
|
|
27
|
+
return None
|
|
@@ -17,6 +17,7 @@ Three connection modes are supported, because real-world servers are mixed:
|
|
|
17
17
|
|
|
18
18
|
import json
|
|
19
19
|
import os
|
|
20
|
+
import re
|
|
20
21
|
import shutil
|
|
21
22
|
import sys
|
|
22
23
|
from collections.abc import AsyncIterator
|
|
@@ -105,8 +106,33 @@ def resolve_server_spec(value: str, http_mode: HttpMode = HttpMode.STATEFUL) ->
|
|
|
105
106
|
raise ValueError(f"{value}: not a URL, directory, .json spec, or .py server")
|
|
106
107
|
|
|
107
108
|
|
|
109
|
+
_ENV_REF_RE = re.compile(r"\$\{(\w+)\}")
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def _expand_env(value):
|
|
113
|
+
"""Substitute ``${VAR}`` references from the environment in every string
|
|
114
|
+
of a spec document — so credentials (HTTP headers, child env) never have
|
|
115
|
+
to be written into server.json itself."""
|
|
116
|
+
if isinstance(value, str):
|
|
117
|
+
|
|
118
|
+
def sub(match: re.Match) -> str:
|
|
119
|
+
name = match.group(1)
|
|
120
|
+
if (resolved := os.environ.get(name)) is None:
|
|
121
|
+
raise ValueError(
|
|
122
|
+
f"server spec references ${{{name}}} but it is not set in the environment"
|
|
123
|
+
)
|
|
124
|
+
return resolved
|
|
125
|
+
|
|
126
|
+
return _ENV_REF_RE.sub(sub, value)
|
|
127
|
+
if isinstance(value, dict):
|
|
128
|
+
return {k: _expand_env(v) for k, v in value.items()}
|
|
129
|
+
if isinstance(value, list):
|
|
130
|
+
return [_expand_env(v) for v in value]
|
|
131
|
+
return value
|
|
132
|
+
|
|
133
|
+
|
|
108
134
|
def _spec_from_json(path: Path) -> ServerSpec:
|
|
109
|
-
data = json.loads(path.read_text())
|
|
135
|
+
data = _expand_env(json.loads(path.read_text()))
|
|
110
136
|
spec = spec_from_dict(data)
|
|
111
137
|
if isinstance(spec, StdioSpec):
|
|
112
138
|
spec.command = _python_command(spec.command)
|
|
@@ -27,6 +27,14 @@ class TaskScore(BaseModel):
|
|
|
27
27
|
return False
|
|
28
28
|
return self.tool_hit
|
|
29
29
|
|
|
30
|
+
@property
|
|
31
|
+
def spec_gap(self) -> bool:
|
|
32
|
+
"""Judge passed but the tool match failed: the agent reached a
|
|
33
|
+
correct outcome via tools the task never listed. Nine times out of
|
|
34
|
+
ten that means expected_tools is incomplete, not that the agent
|
|
35
|
+
failed."""
|
|
36
|
+
return self.judge is not None and self.judge.passed and not self.tool_hit
|
|
37
|
+
|
|
30
38
|
|
|
31
39
|
class EvalSummary(BaseModel):
|
|
32
40
|
scores: list[TaskScore]
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|