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.
Files changed (38) hide show
  1. {whetkit-0.2.0 → whetkit-0.3.0}/PKG-INFO +1 -1
  2. {whetkit-0.2.0 → whetkit-0.3.0}/pyproject.toml +1 -1
  3. {whetkit-0.2.0 → whetkit-0.3.0}/src/whetkit/cli.py +119 -7
  4. {whetkit-0.2.0 → whetkit-0.3.0}/src/whetkit/generate.py +35 -7
  5. whetkit-0.3.0/src/whetkit/llm/pricing.py +27 -0
  6. {whetkit-0.2.0 → whetkit-0.3.0}/src/whetkit/mcp/transport.py +27 -1
  7. {whetkit-0.2.0 → whetkit-0.3.0}/src/whetkit/scoring/aggregate.py +8 -0
  8. {whetkit-0.2.0 → whetkit-0.3.0}/LICENSE +0 -0
  9. {whetkit-0.2.0 → whetkit-0.3.0}/README.md +0 -0
  10. {whetkit-0.2.0 → whetkit-0.3.0}/src/whetkit/__init__.py +0 -0
  11. {whetkit-0.2.0 → whetkit-0.3.0}/src/whetkit/curation/__init__.py +0 -0
  12. {whetkit-0.2.0 → whetkit-0.3.0}/src/whetkit/curation/export.py +0 -0
  13. {whetkit-0.2.0 → whetkit-0.3.0}/src/whetkit/curation/optimizer.py +0 -0
  14. {whetkit-0.2.0 → whetkit-0.3.0}/src/whetkit/curation/overlay.py +0 -0
  15. {whetkit-0.2.0 → whetkit-0.3.0}/src/whetkit/curation/plan.py +0 -0
  16. {whetkit-0.2.0 → whetkit-0.3.0}/src/whetkit/datasets/__init__.py +0 -0
  17. {whetkit-0.2.0 → whetkit-0.3.0}/src/whetkit/datasets/tasks.py +0 -0
  18. {whetkit-0.2.0 → whetkit-0.3.0}/src/whetkit/doctor.py +0 -0
  19. {whetkit-0.2.0 → whetkit-0.3.0}/src/whetkit/llm/__init__.py +0 -0
  20. {whetkit-0.2.0 → whetkit-0.3.0}/src/whetkit/llm/anthropic_provider.py +0 -0
  21. {whetkit-0.2.0 → whetkit-0.3.0}/src/whetkit/llm/base.py +0 -0
  22. {whetkit-0.2.0 → whetkit-0.3.0}/src/whetkit/llm/openai_provider.py +0 -0
  23. {whetkit-0.2.0 → whetkit-0.3.0}/src/whetkit/llm/registry.py +0 -0
  24. {whetkit-0.2.0 → whetkit-0.3.0}/src/whetkit/mcp/__init__.py +0 -0
  25. {whetkit-0.2.0 → whetkit-0.3.0}/src/whetkit/mcp/client.py +0 -0
  26. {whetkit-0.2.0 → whetkit-0.3.0}/src/whetkit/mcp/introspect.py +0 -0
  27. {whetkit-0.2.0 → whetkit-0.3.0}/src/whetkit/py.typed +0 -0
  28. {whetkit-0.2.0 → whetkit-0.3.0}/src/whetkit/report/__init__.py +0 -0
  29. {whetkit-0.2.0 → whetkit-0.3.0}/src/whetkit/report/builder.py +0 -0
  30. {whetkit-0.2.0 → whetkit-0.3.0}/src/whetkit/report/html.py +0 -0
  31. {whetkit-0.2.0 → whetkit-0.3.0}/src/whetkit/runner/__init__.py +0 -0
  32. {whetkit-0.2.0 → whetkit-0.3.0}/src/whetkit/runner/agent.py +0 -0
  33. {whetkit-0.2.0 → whetkit-0.3.0}/src/whetkit/scoring/__init__.py +0 -0
  34. {whetkit-0.2.0 → whetkit-0.3.0}/src/whetkit/scoring/deterministic.py +0 -0
  35. {whetkit-0.2.0 → whetkit-0.3.0}/src/whetkit/scoring/judge.py +0 -0
  36. {whetkit-0.2.0 → whetkit-0.3.0}/src/whetkit/tracing/__init__.py +0 -0
  37. {whetkit-0.2.0 → whetkit-0.3.0}/src/whetkit/tracing/records.py +0 -0
  38. {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.2.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
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "whetkit"
3
- version = "0.2.0"
3
+ version = "0.3.0"
4
4
  description = "Measure and improve how well LLM agents select and use the tools exposed by an MCP server"
5
5
  readme = "README.md"
6
6
  license = "Apache-2.0"
@@ -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 = resolve_server_spec(server_override, http_mode=http_mode)
42
+ spec = _resolve_server(server_override, http_mode)
35
43
  return {task.server: spec for task in tasks}
36
44
  return {
37
- task.server: resolve_server_spec(task.server, http_mode=http_mode)
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 = resolve_server_spec(server, http_mode=http_mode)
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 = resolve_server_spec(server, http_mode=http_mode)
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 = resolve_server_spec(server, http_mode=http_mode)
234
+ spec = _resolve_server(server, http_mode)
219
235
  inventory = asyncio.run(inspect_server(spec))
220
236
  tasks, warnings = asyncio.run(
221
- generate_tasks(inventory, server, count=count, config=GeneratorConfig(model=model))
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 = resolve_server_spec(server, http_mode=http_mode)
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
- GENERATOR_SYSTEM_PROMPT = """\
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
- - Prefer read-only tasks. Include a write task only when the tool set is
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
- def build_generator_prompt(inventory: ServerInventory, count: int) -> str:
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=GENERATOR_SYSTEM_PROMPT,
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