whetkit 0.1.0__py3-none-any.whl

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/mcp/client.py ADDED
@@ -0,0 +1,59 @@
1
+ """High-level MCP client used by the inspector, runner, and overlay proxy."""
2
+
3
+ from contextlib import AsyncExitStack
4
+ from types import TracebackType
5
+ from typing import Any, Self
6
+
7
+ import mcp.types as types
8
+ from mcp import ClientSession
9
+
10
+ from whetkit.mcp.transport import HttpMode, HttpSpec, ServerSpec, open_session
11
+
12
+
13
+ class MCPClient:
14
+ """Async client over any ServerSpec.
15
+
16
+ For stdio and stateful HTTP the client holds one session for its whole
17
+ lifetime. For stateless HTTP (2026-07-28 spec semantics) every operation
18
+ runs on a fresh exchange, so no session state is assumed server-side.
19
+ """
20
+
21
+ def __init__(self, spec: ServerSpec):
22
+ self.spec = spec
23
+ self._stack: AsyncExitStack | None = None
24
+ self._session: ClientSession | None = None
25
+
26
+ @property
27
+ def _stateless(self) -> bool:
28
+ return isinstance(self.spec, HttpSpec) and self.spec.mode == HttpMode.STATELESS
29
+
30
+ async def __aenter__(self) -> Self:
31
+ if not self._stateless:
32
+ self._stack = AsyncExitStack()
33
+ self._session = await self._stack.enter_async_context(open_session(self.spec))
34
+ return self
35
+
36
+ async def __aexit__(
37
+ self,
38
+ exc_type: type[BaseException] | None,
39
+ exc: BaseException | None,
40
+ tb: TracebackType | None,
41
+ ) -> None:
42
+ if self._stack is not None:
43
+ await self._stack.aclose()
44
+ self._stack = None
45
+ self._session = None
46
+
47
+ async def list_tools(self) -> list[types.Tool]:
48
+ if self._session is not None:
49
+ result = await self._session.list_tools()
50
+ return list(result.tools)
51
+ async with open_session(self.spec) as session:
52
+ result = await session.list_tools()
53
+ return list(result.tools)
54
+
55
+ async def call_tool(self, name: str, arguments: dict[str, Any]) -> types.CallToolResult:
56
+ if self._session is not None:
57
+ return await self._session.call_tool(name, arguments)
58
+ async with open_session(self.spec) as session:
59
+ return await session.call_tool(name, arguments)
@@ -0,0 +1,106 @@
1
+ """Tool introspection: inventory a server's tools and summarize their cost."""
2
+
3
+ from typing import Any
4
+
5
+ from pydantic import BaseModel
6
+
7
+ from whetkit.mcp.client import MCPClient
8
+ from whetkit.mcp.transport import ServerSpec
9
+
10
+
11
+ def estimate_tokens(text: str) -> int:
12
+ """Rough token estimate (~4 chars/token for English prose). Good enough
13
+ for comparing tool-set sizes; not a billing meter."""
14
+ return max(1, round(len(text) / 4)) if text else 0
15
+
16
+
17
+ def schema_complexity(schema: dict[str, Any] | None) -> int:
18
+ """Score a JSON schema: one point per property/branch, plus nesting depth.
19
+
20
+ A flat 2-arg tool scores ~3; deeply nested or union-heavy schemas score
21
+ much higher — which correlates with how hard the schema is for a model
22
+ to fill correctly.
23
+ """
24
+ if not schema:
25
+ return 0
26
+
27
+ def walk(node: Any, depth: int) -> int:
28
+ if not isinstance(node, dict):
29
+ return 0
30
+ score = 0
31
+ props = node.get("properties")
32
+ if isinstance(props, dict):
33
+ for sub in props.values():
34
+ score += 1 + walk(sub, depth + 1)
35
+ for branch_key in ("anyOf", "oneOf", "allOf"):
36
+ branches = node.get(branch_key)
37
+ if isinstance(branches, list):
38
+ for sub in branches:
39
+ score += 1 + walk(sub, depth + 1)
40
+ items = node.get("items")
41
+ if isinstance(items, dict):
42
+ score += walk(items, depth + 1)
43
+ return score + (1 if depth == 0 else 0)
44
+
45
+ return walk(schema, 0)
46
+
47
+
48
+ class ToolInfo(BaseModel):
49
+ name: str
50
+ title: str | None = None
51
+ description: str = ""
52
+ input_schema: dict[str, Any] = {}
53
+
54
+ @property
55
+ def description_tokens(self) -> int:
56
+ return estimate_tokens(self.description)
57
+
58
+ @property
59
+ def complexity(self) -> int:
60
+ return schema_complexity(self.input_schema)
61
+
62
+ @property
63
+ def param_count(self) -> int:
64
+ props = self.input_schema.get("properties")
65
+ return len(props) if isinstance(props, dict) else 0
66
+
67
+
68
+ class ServerInventory(BaseModel):
69
+ server: str
70
+ tools: list[ToolInfo]
71
+
72
+ @property
73
+ def tool_count(self) -> int:
74
+ return len(self.tools)
75
+
76
+ @property
77
+ def total_description_tokens(self) -> int:
78
+ return sum(t.description_tokens for t in self.tools)
79
+
80
+ @property
81
+ def total_complexity(self) -> int:
82
+ return sum(t.complexity for t in self.tools)
83
+
84
+ def summary_lines(self) -> list[str]:
85
+ avg = self.total_complexity / self.tool_count if self.tools else 0.0
86
+ return [
87
+ f"Server: {self.server}",
88
+ f"Tools: {self.tool_count}",
89
+ f"Total description tokens (est.): {self.total_description_tokens}",
90
+ f"Schema complexity: total {self.total_complexity}, avg {avg:.1f}",
91
+ ]
92
+
93
+
94
+ async def inspect_server(spec: ServerSpec) -> ServerInventory:
95
+ async with MCPClient(spec) as client:
96
+ tools = await client.list_tools()
97
+ infos = [
98
+ ToolInfo(
99
+ name=t.name,
100
+ title=t.title,
101
+ description=t.description or "",
102
+ input_schema=t.inputSchema or {},
103
+ )
104
+ for t in tools
105
+ ]
106
+ return ServerInventory(server=spec.label(), tools=infos)
@@ -0,0 +1,150 @@
1
+ """Transport layer: how to reach an MCP server.
2
+
3
+ All construction of SDK transports is isolated here so the planned move to
4
+ the MCP Python SDK v2 (see MIGRATION.md) stays contained in this module.
5
+
6
+ Three connection modes are supported, because real-world servers are mixed:
7
+
8
+ - ``stdio``: a local server subprocess speaking JSON-RPC over stdin/stdout.
9
+ - ``http`` with ``mode="stateful"``: legacy (2025 spec) streamable HTTP —
10
+ one long-lived session per connection, identified by a server-issued
11
+ session id.
12
+ - ``http`` with ``mode="stateless"``: the 2026-07-28 stateless spec —
13
+ no session affinity; the client opens a fresh exchange per operation.
14
+ On the v1 SDK this is modeled by re-connecting per operation (see
15
+ ``MCPClient``), which matches the per-POST semantics from the client side.
16
+ """
17
+
18
+ import json
19
+ import shutil
20
+ import sys
21
+ from collections.abc import AsyncIterator
22
+ from contextlib import asynccontextmanager
23
+ from enum import StrEnum
24
+ from pathlib import Path
25
+ from typing import Annotated, Literal
26
+
27
+ import httpx
28
+ from mcp import ClientSession, StdioServerParameters
29
+ from mcp.client.stdio import stdio_client
30
+ from mcp.client.streamable_http import streamable_http_client
31
+ from pydantic import BaseModel, Field
32
+
33
+
34
+ class HttpMode(StrEnum):
35
+ STATEFUL = "stateful"
36
+ STATELESS = "stateless"
37
+
38
+
39
+ class StdioSpec(BaseModel):
40
+ kind: Literal["stdio"] = "stdio"
41
+ command: str
42
+ args: list[str] = []
43
+ env: dict[str, str] | None = None
44
+ cwd: str | None = None
45
+
46
+ def label(self) -> str:
47
+ return f"stdio: {self.command} {' '.join(self.args)}".strip()
48
+
49
+
50
+ class HttpSpec(BaseModel):
51
+ kind: Literal["http"] = "http"
52
+ url: str
53
+ headers: dict[str, str] | None = None
54
+ mode: HttpMode = HttpMode.STATEFUL
55
+
56
+ def label(self) -> str:
57
+ return f"http ({self.mode}): {self.url}"
58
+
59
+
60
+ ServerSpec = Annotated[StdioSpec | HttpSpec, Field(discriminator="kind")]
61
+
62
+
63
+ class _SpecAdapter(BaseModel):
64
+ spec: ServerSpec
65
+
66
+
67
+ def spec_from_dict(data: dict) -> ServerSpec:
68
+ return _SpecAdapter(spec=data).spec
69
+
70
+
71
+ def _python_command(command: str) -> str:
72
+ """Map a bare ``python`` to the running interpreter so stdio servers get
73
+ the project virtualenv."""
74
+ if command in ("python", "python3"):
75
+ return sys.executable
76
+ return shutil.which(command) or command
77
+
78
+
79
+ def resolve_server_spec(value: str, http_mode: HttpMode = HttpMode.STATEFUL) -> ServerSpec:
80
+ """Turn a CLI-friendly string into a ServerSpec.
81
+
82
+ Accepted forms:
83
+ - ``http(s)://...`` -> streamable-HTTP server
84
+ - directory -> ``server.json`` inside it, else ``server.py`` via stdio
85
+ - ``*.json`` file -> full spec document
86
+ - ``*.py`` file -> stdio python server
87
+ """
88
+ if value.startswith(("http://", "https://")):
89
+ return HttpSpec(url=value, mode=http_mode)
90
+
91
+ path = Path(value)
92
+ if path.is_dir():
93
+ config = path / "server.json"
94
+ if config.is_file():
95
+ return _spec_from_json(config)
96
+ script = path / "server.py"
97
+ if script.is_file():
98
+ return StdioSpec(command=sys.executable, args=[str(script)])
99
+ raise ValueError(f"{value}: directory has neither server.json nor server.py")
100
+ if path.suffix == ".json" and path.is_file():
101
+ return _spec_from_json(path)
102
+ if path.suffix == ".py" and path.is_file():
103
+ return StdioSpec(command=sys.executable, args=[str(path)])
104
+ raise ValueError(f"{value}: not a URL, directory, .json spec, or .py server")
105
+
106
+
107
+ def _spec_from_json(path: Path) -> ServerSpec:
108
+ data = json.loads(path.read_text())
109
+ spec = spec_from_dict(data)
110
+ if isinstance(spec, StdioSpec):
111
+ spec.command = _python_command(spec.command)
112
+ # Relative script paths are relative to the spec file, not the CWD.
113
+ spec.args = [
114
+ str((path.parent / a).resolve()) if (path.parent / a).is_file() else a
115
+ for a in spec.args
116
+ ]
117
+ if spec.cwd is None:
118
+ spec.cwd = str(path.parent)
119
+ return spec
120
+
121
+
122
+ @asynccontextmanager
123
+ async def open_session(spec: ServerSpec) -> AsyncIterator[ClientSession]:
124
+ """Open one initialized MCP session over the spec's transport."""
125
+ if isinstance(spec, StdioSpec):
126
+ params = StdioServerParameters(
127
+ command=spec.command, args=spec.args, env=spec.env, cwd=spec.cwd
128
+ )
129
+ async with (
130
+ stdio_client(params) as (read, write),
131
+ ClientSession(read, write) as session,
132
+ ):
133
+ await session.initialize()
134
+ yield session
135
+ else:
136
+ http_client = (
137
+ httpx.AsyncClient(headers=spec.headers, follow_redirects=True, timeout=30)
138
+ if spec.headers
139
+ else None
140
+ )
141
+ try:
142
+ async with (
143
+ streamable_http_client(spec.url, http_client=http_client) as (read, write, _sid),
144
+ ClientSession(read, write) as session,
145
+ ):
146
+ await session.initialize()
147
+ yield session
148
+ finally:
149
+ if http_client is not None:
150
+ await http_client.aclose()
whetkit/py.typed ADDED
File without changes
@@ -0,0 +1,6 @@
1
+ """Before/after comparison reports (JSON + self-contained HTML)."""
2
+
3
+ from whetkit.report.builder import ComparisonReport, TaskComparison, build_report
4
+ from whetkit.report.html import render_html
5
+
6
+ __all__ = ["ComparisonReport", "TaskComparison", "build_report", "render_html"]
@@ -0,0 +1,228 @@
1
+ """Assemble the before/after comparison from two scored eval batches."""
2
+
3
+ import json
4
+ from datetime import UTC, datetime
5
+
6
+ from pydantic import BaseModel, Field
7
+
8
+ from whetkit.curation.plan import CurationPlan, ToolOverride
9
+ from whetkit.datasets import TaskSpec
10
+ from whetkit.scoring import EvalSummary, TaskScore
11
+ from whetkit.tracing import TaskRun
12
+
13
+
14
+ class CallView(BaseModel):
15
+ """One tool call as shown in the report's trace panel."""
16
+
17
+ name: str
18
+ args: str = ""
19
+ is_error: bool = False
20
+
21
+
22
+ class SideView(BaseModel):
23
+ """One side (before or after) of a task comparison."""
24
+
25
+ hit: bool
26
+ tool_hit: bool
27
+ judge_passed: bool | None = None
28
+ judge_rationale: str | None = None
29
+ tools_called: list[str] = []
30
+ missing_slots: list[list[str]] = []
31
+ calls: list[CallView] = []
32
+ final_text: str | None = None
33
+ input_tokens: int = 0
34
+ output_tokens: int = 0
35
+ latency_ms: float = 0.0
36
+
37
+ @classmethod
38
+ def from_score(cls, score: TaskScore, run: TaskRun | None) -> "SideView":
39
+ usage = run.total_usage if run else None
40
+ calls = []
41
+ if run:
42
+ for turn in run.turns:
43
+ for call in turn.tool_calls:
44
+ args = json.dumps(call.arguments, separators=(", ", ": "))
45
+ if len(args) > 80:
46
+ args = args[:77] + "…"
47
+ calls.append(CallView(name=call.name, args=args, is_error=call.is_error))
48
+ return cls(
49
+ hit=score.hit,
50
+ tool_hit=score.tool_hit,
51
+ judge_passed=score.judge.passed if score.judge else None,
52
+ judge_rationale=score.judge.rationale if score.judge else None,
53
+ tools_called=score.tool_match.called,
54
+ missing_slots=score.tool_match.missing_slots,
55
+ calls=calls,
56
+ final_text=run.final_text if run else None,
57
+ input_tokens=usage.input_tokens if usage else 0,
58
+ output_tokens=usage.output_tokens if usage else 0,
59
+ latency_ms=run.total_latency_ms if run else 0.0,
60
+ )
61
+
62
+
63
+ class TaskComparison(BaseModel):
64
+ task_id: str
65
+ prompt: str
66
+ expected_slots: list[list[str]]
67
+ before: SideView
68
+ after: SideView
69
+
70
+ @property
71
+ def outcome(self) -> str:
72
+ if not self.before.hit and self.after.hit:
73
+ return "improved"
74
+ if self.before.hit and not self.after.hit:
75
+ return "regressed"
76
+ return "unchanged"
77
+
78
+
79
+ class ActionImpact(BaseModel):
80
+ """One curation action and the tasks it plausibly affected.
81
+
82
+ Attribution is heuristic: an override is linked to a task when the tool
83
+ it touches appears in the task's expected slots or in either run's calls.
84
+ """
85
+
86
+ override: ToolOverride
87
+ action: str
88
+ touched_tasks: list[str] = []
89
+ improved_tasks: list[str] = []
90
+
91
+
92
+ class Totals(BaseModel):
93
+ hit_rate: float
94
+ tool_hit_rate: float
95
+ judge_pass_rate: float | None
96
+ avg_precision: float
97
+ avg_recall: float
98
+ input_tokens: int
99
+ output_tokens: int
100
+ latency_ms: float
101
+
102
+ @classmethod
103
+ def from_summary(cls, summary: EvalSummary, runs: list[TaskRun]) -> "Totals":
104
+ return cls(
105
+ hit_rate=summary.hit_rate,
106
+ tool_hit_rate=summary.tool_hit_rate,
107
+ judge_pass_rate=summary.judge_pass_rate,
108
+ avg_precision=summary.avg_precision,
109
+ avg_recall=summary.avg_recall,
110
+ input_tokens=sum(r.total_usage.input_tokens for r in runs),
111
+ output_tokens=sum(r.total_usage.output_tokens for r in runs),
112
+ latency_ms=sum(r.total_latency_ms for r in runs),
113
+ )
114
+
115
+
116
+ class ComparisonReport(BaseModel):
117
+ title: str = "whetkit before/after report"
118
+ server: str = ""
119
+ model: str = ""
120
+ generated_at: datetime = Field(default_factory=lambda: datetime.now(UTC))
121
+ tools_before: int | None = None
122
+ tools_after: int | None = None
123
+ before: Totals
124
+ after: Totals
125
+ tasks: list[TaskComparison]
126
+ plan: CurationPlan
127
+ action_impacts: list[ActionImpact]
128
+
129
+ @property
130
+ def run_id(self) -> str:
131
+ """Short stable id derived from the report's content."""
132
+ import hashlib
133
+
134
+ payload = (
135
+ json.dumps([self.server, self.model, [t.task_id for t in self.tasks]], sort_keys=True)
136
+ + self.generated_at.isoformat()
137
+ )
138
+ return hashlib.sha256(payload.encode()).hexdigest()[:6]
139
+
140
+ @property
141
+ def improved(self) -> list[TaskComparison]:
142
+ return [t for t in self.tasks if t.outcome == "improved"]
143
+
144
+ @property
145
+ def regressed(self) -> list[TaskComparison]:
146
+ return [t for t in self.tasks if t.outcome == "regressed"]
147
+
148
+
149
+ def _override_action(override: ToolOverride) -> str:
150
+ if override.hidden:
151
+ return "prune"
152
+ if override.new_name and override.new_description:
153
+ return "rename + rewrite"
154
+ if override.new_name:
155
+ return "rename"
156
+ if override.new_description:
157
+ return "rewrite"
158
+ return "keep"
159
+
160
+
161
+ def _attribute_actions(
162
+ plan: CurationPlan, comparisons: list[TaskComparison], tasks: list[TaskSpec]
163
+ ) -> list[ActionImpact]:
164
+ tasks_by_id = {t.id: t for t in tasks}
165
+ impacts: list[ActionImpact] = []
166
+ for override in plan.overrides:
167
+ names = {override.original_name, override.presented_name}
168
+ impact = ActionImpact(override=override, action=_override_action(override))
169
+ for comparison in comparisons:
170
+ task = tasks_by_id.get(comparison.task_id)
171
+ slot_names = {n for slot in (task.expected_tool_slots if task else []) for n in slot}
172
+ involved = bool(
173
+ names & slot_names
174
+ or names & set(comparison.before.tools_called)
175
+ or names & set(comparison.after.tools_called)
176
+ )
177
+ if involved:
178
+ impact.touched_tasks.append(comparison.task_id)
179
+ if comparison.outcome == "improved":
180
+ impact.improved_tasks.append(comparison.task_id)
181
+ impacts.append(impact)
182
+ return impacts
183
+
184
+
185
+ def build_report(
186
+ tasks: list[TaskSpec],
187
+ baseline_runs: list[TaskRun],
188
+ baseline_summary: EvalSummary,
189
+ curated_runs: list[TaskRun],
190
+ curated_summary: EvalSummary,
191
+ plan: CurationPlan,
192
+ model: str = "",
193
+ server: str = "",
194
+ tools_before: int | None = None,
195
+ tools_after: int | None = None,
196
+ ) -> ComparisonReport:
197
+ baseline_runs_by_id = {r.task_id: r for r in baseline_runs}
198
+ curated_runs_by_id = {r.task_id: r for r in curated_runs}
199
+ baseline_scores = {s.task_id: s for s in baseline_summary.scores}
200
+ curated_scores = {s.task_id: s for s in curated_summary.scores}
201
+
202
+ comparisons: list[TaskComparison] = []
203
+ for task in tasks:
204
+ before_score = baseline_scores.get(task.id)
205
+ after_score = curated_scores.get(task.id)
206
+ if before_score is None or after_score is None:
207
+ continue
208
+ comparisons.append(
209
+ TaskComparison(
210
+ task_id=task.id,
211
+ prompt=task.prompt.strip(),
212
+ expected_slots=task.expected_tool_slots,
213
+ before=SideView.from_score(before_score, baseline_runs_by_id.get(task.id)),
214
+ after=SideView.from_score(after_score, curated_runs_by_id.get(task.id)),
215
+ )
216
+ )
217
+
218
+ return ComparisonReport(
219
+ server=server,
220
+ model=model,
221
+ tools_before=tools_before,
222
+ tools_after=tools_after,
223
+ before=Totals.from_summary(baseline_summary, baseline_runs),
224
+ after=Totals.from_summary(curated_summary, curated_runs),
225
+ tasks=comparisons,
226
+ plan=plan,
227
+ action_impacts=_attribute_actions(plan, comparisons, tasks),
228
+ )