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/__init__.py +3 -0
- whetkit/cli.py +434 -0
- whetkit/curation/__init__.py +17 -0
- whetkit/curation/optimizer.py +189 -0
- whetkit/curation/overlay.py +73 -0
- whetkit/curation/plan.py +109 -0
- whetkit/datasets/__init__.py +5 -0
- whetkit/datasets/tasks.py +118 -0
- whetkit/llm/__init__.py +24 -0
- whetkit/llm/anthropic_provider.py +90 -0
- whetkit/llm/base.py +88 -0
- whetkit/llm/openai_provider.py +100 -0
- whetkit/llm/registry.py +37 -0
- whetkit/mcp/__init__.py +17 -0
- whetkit/mcp/client.py +59 -0
- whetkit/mcp/introspect.py +106 -0
- whetkit/mcp/transport.py +150 -0
- whetkit/py.typed +0 -0
- whetkit/report/__init__.py +6 -0
- whetkit/report/builder.py +228 -0
- whetkit/report/html.py +420 -0
- whetkit/runner/__init__.py +5 -0
- whetkit/runner/agent.py +172 -0
- whetkit/scoring/__init__.py +18 -0
- whetkit/scoring/aggregate.py +117 -0
- whetkit/scoring/deterministic.py +99 -0
- whetkit/scoring/judge.py +170 -0
- whetkit/tracing/__init__.py +21 -0
- whetkit/tracing/records.py +76 -0
- whetkit/tracing/store.py +172 -0
- whetkit-0.1.0.dist-info/METADATA +182 -0
- whetkit-0.1.0.dist-info/RECORD +35 -0
- whetkit-0.1.0.dist-info/WHEEL +4 -0
- whetkit-0.1.0.dist-info/entry_points.txt +3 -0
- whetkit-0.1.0.dist-info/licenses/LICENSE +202 -0
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
"""The overlay: presents a curated tool set while delegating every call to
|
|
2
|
+
the untouched origin server.
|
|
3
|
+
|
|
4
|
+
Two ways to use it:
|
|
5
|
+
|
|
6
|
+
- :class:`CuratedMCPClient` — in-process overlay used by the eval runner for
|
|
7
|
+
before/after comparisons.
|
|
8
|
+
- :func:`serve_overlay` — a real stdio MCP server (``whetkit overlay``) so
|
|
9
|
+
any MCP client (Claude Code, an IDE, another agent) can talk to the
|
|
10
|
+
curated view. The origin server is never modified; stop the proxy and
|
|
11
|
+
nothing remains.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
import mcp.types as types
|
|
15
|
+
from mcp.server.lowlevel import Server
|
|
16
|
+
from mcp.server.stdio import stdio_server
|
|
17
|
+
|
|
18
|
+
from whetkit.curation.plan import CurationPlan
|
|
19
|
+
from whetkit.mcp import MCPClient, ServerSpec
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class UnknownCuratedTool(Exception):
|
|
23
|
+
pass
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class CuratedMCPClient(MCPClient):
|
|
27
|
+
"""MCPClient that shows plan-transformed tools and un-maps names on call."""
|
|
28
|
+
|
|
29
|
+
def __init__(self, spec: ServerSpec, plan: CurationPlan):
|
|
30
|
+
super().__init__(spec)
|
|
31
|
+
self.plan = plan
|
|
32
|
+
self._name_map: dict[str, str] | None = None
|
|
33
|
+
|
|
34
|
+
async def _mapping(self) -> dict[str, str]:
|
|
35
|
+
if self._name_map is None:
|
|
36
|
+
origin_tools = await super().list_tools()
|
|
37
|
+
self._name_map = self.plan.presented_to_original({t.name for t in origin_tools})
|
|
38
|
+
return self._name_map
|
|
39
|
+
|
|
40
|
+
async def list_tools(self) -> list[types.Tool]:
|
|
41
|
+
tools = await super().list_tools()
|
|
42
|
+
self._name_map = self.plan.presented_to_original({t.name for t in tools})
|
|
43
|
+
return self.plan.transform_tools(tools)
|
|
44
|
+
|
|
45
|
+
async def call_tool(self, name: str, arguments: dict) -> types.CallToolResult:
|
|
46
|
+
mapping = await self._mapping()
|
|
47
|
+
if name not in mapping:
|
|
48
|
+
raise UnknownCuratedTool(f"tool {name!r} is not part of the curated tool set")
|
|
49
|
+
return await super().call_tool(mapping[name], arguments)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def build_overlay_server(client: CuratedMCPClient, name: str = "whetkit-overlay") -> Server:
|
|
53
|
+
server = Server(name)
|
|
54
|
+
|
|
55
|
+
@server.list_tools()
|
|
56
|
+
async def _list_tools() -> list[types.Tool]:
|
|
57
|
+
return await client.list_tools()
|
|
58
|
+
|
|
59
|
+
@server.call_tool()
|
|
60
|
+
async def _call_tool(tool_name: str, arguments: dict) -> types.CallToolResult:
|
|
61
|
+
# Pass the origin's result through verbatim (content, structured
|
|
62
|
+
# content, and error flag) — the overlay transforms metadata only.
|
|
63
|
+
return await client.call_tool(tool_name, arguments)
|
|
64
|
+
|
|
65
|
+
return server
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
async def serve_overlay(origin: ServerSpec, plan: CurationPlan) -> None:
|
|
69
|
+
"""Run the overlay as a stdio MCP server until the client disconnects."""
|
|
70
|
+
async with CuratedMCPClient(origin, plan) as client:
|
|
71
|
+
server = build_overlay_server(client)
|
|
72
|
+
async with stdio_server() as (read, write):
|
|
73
|
+
await server.run(read, write, server.create_initialization_options())
|
whetkit/curation/plan.py
ADDED
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
"""Curation plans: a declarative, reversible description of tool-set changes.
|
|
2
|
+
|
|
3
|
+
A plan never touches the origin server. It only says how the overlay should
|
|
4
|
+
*present* the origin's tools: hide (prune), rename, or rewrite descriptions.
|
|
5
|
+
Merging duplicate tools is expressed as hiding the redundant copies and
|
|
6
|
+
renaming/redescribing the canonical one — call behavior is always delegated
|
|
7
|
+
1:1 to an existing origin tool, which is what keeps the overlay fully
|
|
8
|
+
reversible (delete the plan and nothing remains).
|
|
9
|
+
|
|
10
|
+
Plans serialize to YAML so they can be reviewed, versioned, and hand-edited.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
import re
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
|
|
16
|
+
import mcp.types as types
|
|
17
|
+
import yaml
|
|
18
|
+
from pydantic import BaseModel, Field
|
|
19
|
+
|
|
20
|
+
_NAME_RE = re.compile(r"^[a-zA-Z0-9_-]{1,128}$")
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class ToolOverride(BaseModel):
|
|
24
|
+
"""How the overlay presents one origin tool."""
|
|
25
|
+
|
|
26
|
+
original_name: str
|
|
27
|
+
new_name: str | None = None
|
|
28
|
+
new_description: str | None = None
|
|
29
|
+
hidden: bool = False
|
|
30
|
+
reason: str = ""
|
|
31
|
+
|
|
32
|
+
@property
|
|
33
|
+
def presented_name(self) -> str:
|
|
34
|
+
return self.new_name or self.original_name
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class CurationPlan(BaseModel):
|
|
38
|
+
server: str = ""
|
|
39
|
+
notes: str = ""
|
|
40
|
+
overrides: list[ToolOverride] = Field(default_factory=list)
|
|
41
|
+
|
|
42
|
+
def override_for(self, original_name: str) -> ToolOverride | None:
|
|
43
|
+
return next((o for o in self.overrides if o.original_name == original_name), None)
|
|
44
|
+
|
|
45
|
+
def validate_against(self, origin_tool_names: set[str]) -> list[str]:
|
|
46
|
+
"""Return every problem that makes the plan unsafe to apply."""
|
|
47
|
+
problems: list[str] = []
|
|
48
|
+
seen_originals: set[str] = set()
|
|
49
|
+
for override in self.overrides:
|
|
50
|
+
if override.original_name not in origin_tool_names:
|
|
51
|
+
problems.append(f"override targets unknown tool {override.original_name!r}")
|
|
52
|
+
if override.original_name in seen_originals:
|
|
53
|
+
problems.append(f"duplicate override for {override.original_name!r}")
|
|
54
|
+
seen_originals.add(override.original_name)
|
|
55
|
+
if override.new_name is not None and not _NAME_RE.match(override.new_name):
|
|
56
|
+
problems.append(f"invalid new name {override.new_name!r}")
|
|
57
|
+
|
|
58
|
+
presented = [
|
|
59
|
+
self.override_for(name).presented_name if self.override_for(name) else name
|
|
60
|
+
for name in sorted(origin_tool_names)
|
|
61
|
+
if not (self.override_for(name) and self.override_for(name).hidden)
|
|
62
|
+
]
|
|
63
|
+
duplicates = {name for name in presented if presented.count(name) > 1}
|
|
64
|
+
problems.extend(f"presented tool name collision: {name!r}" for name in sorted(duplicates))
|
|
65
|
+
return problems
|
|
66
|
+
|
|
67
|
+
def presented_to_original(self, origin_tool_names: set[str]) -> dict[str, str]:
|
|
68
|
+
"""Map every name the agent sees to the origin tool it delegates to."""
|
|
69
|
+
mapping: dict[str, str] = {}
|
|
70
|
+
for name in origin_tool_names:
|
|
71
|
+
override = self.override_for(name)
|
|
72
|
+
if override and override.hidden:
|
|
73
|
+
continue
|
|
74
|
+
mapping[override.presented_name if override else name] = name
|
|
75
|
+
return mapping
|
|
76
|
+
|
|
77
|
+
def transform_tools(self, tools: list[types.Tool]) -> list[types.Tool]:
|
|
78
|
+
"""Present the origin's tool list through this plan."""
|
|
79
|
+
presented: list[types.Tool] = []
|
|
80
|
+
for tool in tools:
|
|
81
|
+
override = self.override_for(tool.name)
|
|
82
|
+
if override is None:
|
|
83
|
+
presented.append(tool)
|
|
84
|
+
continue
|
|
85
|
+
if override.hidden:
|
|
86
|
+
continue
|
|
87
|
+
presented.append(
|
|
88
|
+
tool.model_copy(
|
|
89
|
+
update={
|
|
90
|
+
"name": override.presented_name,
|
|
91
|
+
"description": (
|
|
92
|
+
override.new_description
|
|
93
|
+
if override.new_description is not None
|
|
94
|
+
else tool.description
|
|
95
|
+
),
|
|
96
|
+
}
|
|
97
|
+
)
|
|
98
|
+
)
|
|
99
|
+
return presented
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def save_plan(plan: CurationPlan, path: str | Path) -> None:
|
|
103
|
+
path = Path(path)
|
|
104
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
105
|
+
path.write_text(yaml.safe_dump(plan.model_dump(exclude_defaults=True), sort_keys=False))
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def load_plan(path: str | Path) -> CurationPlan:
|
|
109
|
+
return CurationPlan.model_validate(yaml.safe_load(Path(path).read_text()))
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
"""Task schema and YAML loader.
|
|
2
|
+
|
|
3
|
+
A task file is YAML holding either a single task mapping or a list of task
|
|
4
|
+
mappings. The format is documented in docs/task-format.md; the source of
|
|
5
|
+
truth for validation is :class:`TaskSpec`.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import re
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
|
|
11
|
+
import yaml
|
|
12
|
+
from pydantic import BaseModel, Field, field_validator
|
|
13
|
+
|
|
14
|
+
_ID_RE = re.compile(r"^[a-z0-9][a-z0-9_-]*$")
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class TaskSpec(BaseModel):
|
|
18
|
+
"""One eval task: a user request plus what a correct agent run looks like."""
|
|
19
|
+
|
|
20
|
+
id: str
|
|
21
|
+
prompt: str = Field(min_length=1)
|
|
22
|
+
server: str = Field(min_length=1, description="MCP server: URL, directory, or file path")
|
|
23
|
+
expected_tools: list[str | list[str]] = Field(
|
|
24
|
+
min_length=1,
|
|
25
|
+
description=(
|
|
26
|
+
"Tool calls a correct run makes. Each entry is one expected call; "
|
|
27
|
+
"an entry may list acceptable alternatives for that call."
|
|
28
|
+
),
|
|
29
|
+
)
|
|
30
|
+
ordered: bool = Field(
|
|
31
|
+
default=False,
|
|
32
|
+
description="If true, expected calls must happen in the listed order.",
|
|
33
|
+
)
|
|
34
|
+
success_criteria: str = Field(
|
|
35
|
+
min_length=1,
|
|
36
|
+
description="Natural-language rubric the LLM judge grades the final answer against.",
|
|
37
|
+
)
|
|
38
|
+
tags: list[str] = []
|
|
39
|
+
|
|
40
|
+
@field_validator("id")
|
|
41
|
+
@classmethod
|
|
42
|
+
def _valid_id(cls, v: str) -> str:
|
|
43
|
+
if not _ID_RE.match(v):
|
|
44
|
+
raise ValueError(
|
|
45
|
+
f"task id {v!r} must be lowercase alphanumeric with '-' or '_' separators"
|
|
46
|
+
)
|
|
47
|
+
return v
|
|
48
|
+
|
|
49
|
+
@field_validator("expected_tools")
|
|
50
|
+
@classmethod
|
|
51
|
+
def _non_empty_slots(cls, v: list[str | list[str]]) -> list[str | list[str]]:
|
|
52
|
+
for slot in v:
|
|
53
|
+
if isinstance(slot, list) and not slot:
|
|
54
|
+
raise ValueError("an expected_tools alternatives list may not be empty")
|
|
55
|
+
return v
|
|
56
|
+
|
|
57
|
+
@property
|
|
58
|
+
def expected_tool_slots(self) -> list[list[str]]:
|
|
59
|
+
"""expected_tools normalized so every slot is a list of alternatives."""
|
|
60
|
+
return [[s] if isinstance(s, str) else list(s) for s in self.expected_tools]
|
|
61
|
+
|
|
62
|
+
def resolve_server(self, base_dir: Path | None = None) -> str:
|
|
63
|
+
"""Resolve a relative ``server`` path against the task file's directory."""
|
|
64
|
+
if self.server.startswith(("http://", "https://")):
|
|
65
|
+
return self.server
|
|
66
|
+
path = Path(self.server)
|
|
67
|
+
if not path.is_absolute() and base_dir is not None:
|
|
68
|
+
candidate = (base_dir / path).resolve()
|
|
69
|
+
if candidate.exists():
|
|
70
|
+
return str(candidate)
|
|
71
|
+
return self.server
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def load_task_file(path: Path) -> list[TaskSpec]:
|
|
75
|
+
"""Load one YAML file containing a task mapping or a list of them."""
|
|
76
|
+
data = yaml.safe_load(path.read_text())
|
|
77
|
+
if data is None:
|
|
78
|
+
raise ValueError(f"{path}: file is empty")
|
|
79
|
+
raw_tasks = data if isinstance(data, list) else [data]
|
|
80
|
+
tasks = []
|
|
81
|
+
for i, raw in enumerate(raw_tasks):
|
|
82
|
+
if not isinstance(raw, dict):
|
|
83
|
+
raise ValueError(f"{path}: entry {i} is not a mapping")
|
|
84
|
+
try:
|
|
85
|
+
task = TaskSpec.model_validate(raw)
|
|
86
|
+
except Exception as exc:
|
|
87
|
+
raise ValueError(f"{path}: entry {i} is invalid: {exc}") from exc
|
|
88
|
+
task = task.model_copy(update={"server": task.resolve_server(path.parent)})
|
|
89
|
+
tasks.append(task)
|
|
90
|
+
return tasks
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def load_tasks(path: str | Path) -> list[TaskSpec]:
|
|
94
|
+
"""Load tasks from a YAML file or every ``*.yaml``/``*.yml`` in a directory.
|
|
95
|
+
|
|
96
|
+
Raises ValueError on validation failures or duplicate task ids.
|
|
97
|
+
"""
|
|
98
|
+
root = Path(path)
|
|
99
|
+
if root.is_dir():
|
|
100
|
+
files = sorted(p for p in root.iterdir() if p.suffix in (".yaml", ".yml"))
|
|
101
|
+
if not files:
|
|
102
|
+
raise ValueError(f"{root}: no .yaml/.yml task files found")
|
|
103
|
+
elif root.is_file():
|
|
104
|
+
files = [root]
|
|
105
|
+
else:
|
|
106
|
+
raise ValueError(f"{root}: no such file or directory")
|
|
107
|
+
|
|
108
|
+
tasks: list[TaskSpec] = []
|
|
109
|
+
seen: dict[str, Path] = {}
|
|
110
|
+
for file in files:
|
|
111
|
+
for task in load_task_file(file):
|
|
112
|
+
if task.id in seen:
|
|
113
|
+
raise ValueError(
|
|
114
|
+
f"duplicate task id {task.id!r} in {file} (first seen in {seen[task.id]})"
|
|
115
|
+
)
|
|
116
|
+
seen[task.id] = file
|
|
117
|
+
tasks.append(task)
|
|
118
|
+
return tasks
|
whetkit/llm/__init__.py
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
"""Provider-abstracted LLM layer (Anthropic + OpenAI)."""
|
|
2
|
+
|
|
3
|
+
from whetkit.llm.base import (
|
|
4
|
+
ChatMessage,
|
|
5
|
+
LLMProvider,
|
|
6
|
+
LLMTurn,
|
|
7
|
+
ToolCall,
|
|
8
|
+
ToolDef,
|
|
9
|
+
ToolResult,
|
|
10
|
+
Usage,
|
|
11
|
+
)
|
|
12
|
+
from whetkit.llm.registry import get_provider, parse_model
|
|
13
|
+
|
|
14
|
+
__all__ = [
|
|
15
|
+
"ChatMessage",
|
|
16
|
+
"LLMProvider",
|
|
17
|
+
"LLMTurn",
|
|
18
|
+
"ToolCall",
|
|
19
|
+
"ToolDef",
|
|
20
|
+
"ToolResult",
|
|
21
|
+
"Usage",
|
|
22
|
+
"get_provider",
|
|
23
|
+
"parse_model",
|
|
24
|
+
]
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
"""Anthropic provider (Messages API, anthropic SDK).
|
|
2
|
+
|
|
3
|
+
Reads the API key from ``ANTHROPIC_API_KEY``.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
import json
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
from anthropic import AsyncAnthropic
|
|
10
|
+
|
|
11
|
+
from whetkit.llm.base import ChatMessage, LLMProvider, LLMTurn, ToolCall, ToolDef, Usage
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def _to_anthropic_messages(messages: list[ChatMessage]) -> list[dict[str, Any]]:
|
|
15
|
+
out: list[dict[str, Any]] = []
|
|
16
|
+
for msg in messages:
|
|
17
|
+
blocks: list[dict[str, Any]] = []
|
|
18
|
+
if msg.role == "assistant":
|
|
19
|
+
if msg.content:
|
|
20
|
+
blocks.append({"type": "text", "text": msg.content})
|
|
21
|
+
for call in msg.tool_calls:
|
|
22
|
+
blocks.append(
|
|
23
|
+
{"type": "tool_use", "id": call.id, "name": call.name, "input": call.arguments}
|
|
24
|
+
)
|
|
25
|
+
else:
|
|
26
|
+
for result in msg.tool_results:
|
|
27
|
+
blocks.append(
|
|
28
|
+
{
|
|
29
|
+
"type": "tool_result",
|
|
30
|
+
"tool_use_id": result.call_id,
|
|
31
|
+
"content": result.content,
|
|
32
|
+
"is_error": result.is_error,
|
|
33
|
+
}
|
|
34
|
+
)
|
|
35
|
+
if msg.content:
|
|
36
|
+
blocks.append({"type": "text", "text": msg.content})
|
|
37
|
+
out.append({"role": msg.role, "content": blocks})
|
|
38
|
+
return out
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class AnthropicProvider(LLMProvider):
|
|
42
|
+
name = "anthropic"
|
|
43
|
+
|
|
44
|
+
def __init__(self, client: AsyncAnthropic | None = None):
|
|
45
|
+
self._client = client or AsyncAnthropic()
|
|
46
|
+
|
|
47
|
+
async def complete(
|
|
48
|
+
self,
|
|
49
|
+
*,
|
|
50
|
+
model: str,
|
|
51
|
+
system: str | None,
|
|
52
|
+
messages: list[ChatMessage],
|
|
53
|
+
tools: list[ToolDef],
|
|
54
|
+
max_tokens: int = 1024,
|
|
55
|
+
) -> LLMTurn:
|
|
56
|
+
kwargs: dict[str, Any] = {}
|
|
57
|
+
if system:
|
|
58
|
+
kwargs["system"] = system
|
|
59
|
+
if tools:
|
|
60
|
+
kwargs["tools"] = [
|
|
61
|
+
{"name": t.name, "description": t.description, "input_schema": t.input_schema}
|
|
62
|
+
for t in tools
|
|
63
|
+
]
|
|
64
|
+
response = await self._client.messages.create(
|
|
65
|
+
model=model,
|
|
66
|
+
max_tokens=max_tokens,
|
|
67
|
+
messages=_to_anthropic_messages(messages),
|
|
68
|
+
**kwargs,
|
|
69
|
+
)
|
|
70
|
+
|
|
71
|
+
text_parts: list[str] = []
|
|
72
|
+
tool_calls: list[ToolCall] = []
|
|
73
|
+
for block in response.content:
|
|
74
|
+
if block.type == "text":
|
|
75
|
+
text_parts.append(block.text)
|
|
76
|
+
elif block.type == "tool_use":
|
|
77
|
+
arguments = block.input
|
|
78
|
+
if isinstance(arguments, str): # defensive: some models emit JSON strings
|
|
79
|
+
arguments = json.loads(arguments or "{}")
|
|
80
|
+
tool_calls.append(ToolCall(id=block.id, name=block.name, arguments=arguments))
|
|
81
|
+
|
|
82
|
+
return LLMTurn(
|
|
83
|
+
text="\n".join(text_parts) or None,
|
|
84
|
+
tool_calls=tool_calls,
|
|
85
|
+
usage=Usage(
|
|
86
|
+
input_tokens=response.usage.input_tokens,
|
|
87
|
+
output_tokens=response.usage.output_tokens,
|
|
88
|
+
),
|
|
89
|
+
stop_reason=response.stop_reason,
|
|
90
|
+
)
|
whetkit/llm/base.py
ADDED
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
"""Provider-neutral chat/tool-use types and the provider interface.
|
|
2
|
+
|
|
3
|
+
The runner speaks only these types; each provider module translates to and
|
|
4
|
+
from its SDK's wire format. Adding a provider means implementing
|
|
5
|
+
:class:`LLMProvider` and registering it in ``registry.py``.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from abc import ABC, abstractmethod
|
|
9
|
+
from typing import Any, Literal
|
|
10
|
+
|
|
11
|
+
from pydantic import BaseModel, Field
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class ToolDef(BaseModel):
|
|
15
|
+
"""A tool offered to the model."""
|
|
16
|
+
|
|
17
|
+
name: str
|
|
18
|
+
description: str = ""
|
|
19
|
+
input_schema: dict[str, Any] = Field(default_factory=lambda: {"type": "object"})
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class ToolCall(BaseModel):
|
|
23
|
+
"""A tool invocation requested by the model."""
|
|
24
|
+
|
|
25
|
+
id: str
|
|
26
|
+
name: str
|
|
27
|
+
arguments: dict[str, Any] = {}
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class ToolResult(BaseModel):
|
|
31
|
+
"""The outcome of executing a ToolCall, fed back to the model."""
|
|
32
|
+
|
|
33
|
+
call_id: str
|
|
34
|
+
content: str
|
|
35
|
+
is_error: bool = False
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class ChatMessage(BaseModel):
|
|
39
|
+
"""One conversation message in provider-neutral form.
|
|
40
|
+
|
|
41
|
+
- role="user": ``content`` holds user text and/or ``tool_results`` holds
|
|
42
|
+
results for the assistant's previous tool calls.
|
|
43
|
+
- role="assistant": ``content`` holds assistant text and ``tool_calls``
|
|
44
|
+
holds any tool invocations it requested.
|
|
45
|
+
"""
|
|
46
|
+
|
|
47
|
+
role: Literal["user", "assistant"]
|
|
48
|
+
content: str | None = None
|
|
49
|
+
tool_calls: list[ToolCall] = []
|
|
50
|
+
tool_results: list[ToolResult] = []
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
class Usage(BaseModel):
|
|
54
|
+
input_tokens: int = 0
|
|
55
|
+
output_tokens: int = 0
|
|
56
|
+
|
|
57
|
+
def __add__(self, other: "Usage") -> "Usage":
|
|
58
|
+
return Usage(
|
|
59
|
+
input_tokens=self.input_tokens + other.input_tokens,
|
|
60
|
+
output_tokens=self.output_tokens + other.output_tokens,
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
class LLMTurn(BaseModel):
|
|
65
|
+
"""One assistant completion."""
|
|
66
|
+
|
|
67
|
+
text: str | None = None
|
|
68
|
+
tool_calls: list[ToolCall] = []
|
|
69
|
+
usage: Usage = Usage()
|
|
70
|
+
stop_reason: str | None = None
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
class LLMProvider(ABC):
|
|
74
|
+
"""One chat completion with tool use. Implementations must be stateless
|
|
75
|
+
across calls: the full conversation is passed in every time."""
|
|
76
|
+
|
|
77
|
+
name: str
|
|
78
|
+
|
|
79
|
+
@abstractmethod
|
|
80
|
+
async def complete(
|
|
81
|
+
self,
|
|
82
|
+
*,
|
|
83
|
+
model: str,
|
|
84
|
+
system: str | None,
|
|
85
|
+
messages: list[ChatMessage],
|
|
86
|
+
tools: list[ToolDef],
|
|
87
|
+
max_tokens: int = 1024,
|
|
88
|
+
) -> LLMTurn: ...
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
"""OpenAI provider (Chat Completions API, openai SDK).
|
|
2
|
+
|
|
3
|
+
Chat Completions (rather than the Responses API) keeps the tool-use loop
|
|
4
|
+
symmetric with other providers; both live behind :class:`LLMProvider`, so
|
|
5
|
+
swapping APIs is contained here. Reads the API key from ``OPENAI_API_KEY``.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import json
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
from openai import AsyncOpenAI
|
|
12
|
+
|
|
13
|
+
from whetkit.llm.base import ChatMessage, LLMProvider, LLMTurn, ToolCall, ToolDef, Usage
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def _to_openai_messages(system: str | None, messages: list[ChatMessage]) -> list[dict[str, Any]]:
|
|
17
|
+
out: list[dict[str, Any]] = []
|
|
18
|
+
if system:
|
|
19
|
+
out.append({"role": "system", "content": system})
|
|
20
|
+
for msg in messages:
|
|
21
|
+
if msg.role == "assistant":
|
|
22
|
+
entry: dict[str, Any] = {"role": "assistant", "content": msg.content}
|
|
23
|
+
if msg.tool_calls:
|
|
24
|
+
entry["tool_calls"] = [
|
|
25
|
+
{
|
|
26
|
+
"id": call.id,
|
|
27
|
+
"type": "function",
|
|
28
|
+
"function": {
|
|
29
|
+
"name": call.name,
|
|
30
|
+
"arguments": json.dumps(call.arguments),
|
|
31
|
+
},
|
|
32
|
+
}
|
|
33
|
+
for call in msg.tool_calls
|
|
34
|
+
]
|
|
35
|
+
out.append(entry)
|
|
36
|
+
else:
|
|
37
|
+
for result in msg.tool_results:
|
|
38
|
+
out.append(
|
|
39
|
+
{"role": "tool", "tool_call_id": result.call_id, "content": result.content}
|
|
40
|
+
)
|
|
41
|
+
if msg.content:
|
|
42
|
+
out.append({"role": "user", "content": msg.content})
|
|
43
|
+
return out
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class OpenAIProvider(LLMProvider):
|
|
47
|
+
name = "openai"
|
|
48
|
+
|
|
49
|
+
def __init__(self, client: AsyncOpenAI | None = None):
|
|
50
|
+
self._client = client or AsyncOpenAI()
|
|
51
|
+
|
|
52
|
+
async def complete(
|
|
53
|
+
self,
|
|
54
|
+
*,
|
|
55
|
+
model: str,
|
|
56
|
+
system: str | None,
|
|
57
|
+
messages: list[ChatMessage],
|
|
58
|
+
tools: list[ToolDef],
|
|
59
|
+
max_tokens: int = 1024,
|
|
60
|
+
) -> LLMTurn:
|
|
61
|
+
kwargs: dict[str, Any] = {}
|
|
62
|
+
if tools:
|
|
63
|
+
kwargs["tools"] = [
|
|
64
|
+
{
|
|
65
|
+
"type": "function",
|
|
66
|
+
"function": {
|
|
67
|
+
"name": t.name,
|
|
68
|
+
"description": t.description,
|
|
69
|
+
"parameters": t.input_schema,
|
|
70
|
+
},
|
|
71
|
+
}
|
|
72
|
+
for t in tools
|
|
73
|
+
]
|
|
74
|
+
response = await self._client.chat.completions.create(
|
|
75
|
+
model=model,
|
|
76
|
+
max_completion_tokens=max_tokens,
|
|
77
|
+
messages=_to_openai_messages(system, messages),
|
|
78
|
+
**kwargs,
|
|
79
|
+
)
|
|
80
|
+
|
|
81
|
+
choice = response.choices[0]
|
|
82
|
+
tool_calls = [
|
|
83
|
+
ToolCall(
|
|
84
|
+
id=call.id,
|
|
85
|
+
name=call.function.name,
|
|
86
|
+
arguments=json.loads(call.function.arguments or "{}"),
|
|
87
|
+
)
|
|
88
|
+
for call in (choice.message.tool_calls or [])
|
|
89
|
+
if call.type == "function"
|
|
90
|
+
]
|
|
91
|
+
usage = response.usage
|
|
92
|
+
return LLMTurn(
|
|
93
|
+
text=choice.message.content or None,
|
|
94
|
+
tool_calls=tool_calls,
|
|
95
|
+
usage=Usage(
|
|
96
|
+
input_tokens=usage.prompt_tokens if usage else 0,
|
|
97
|
+
output_tokens=usage.completion_tokens if usage else 0,
|
|
98
|
+
),
|
|
99
|
+
stop_reason=choice.finish_reason,
|
|
100
|
+
)
|
whetkit/llm/registry.py
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
"""Model-string parsing and provider lookup.
|
|
2
|
+
|
|
3
|
+
Models are addressed as ``provider:model_id`` (e.g. ``anthropic:claude-sonnet-5``,
|
|
4
|
+
``openai:gpt-5.2``). A bare model id defaults to the Anthropic provider.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from functools import cache
|
|
8
|
+
|
|
9
|
+
from whetkit.llm.base import LLMProvider
|
|
10
|
+
|
|
11
|
+
DEFAULT_PROVIDER = "anthropic"
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def parse_model(model: str) -> tuple[str, str]:
|
|
15
|
+
"""Split ``provider:model_id`` -> (provider_name, model_id)."""
|
|
16
|
+
if ":" in model:
|
|
17
|
+
provider_name, model_id = model.split(":", 1)
|
|
18
|
+
else:
|
|
19
|
+
provider_name, model_id = DEFAULT_PROVIDER, model
|
|
20
|
+
if not model_id:
|
|
21
|
+
raise ValueError(f"invalid model string {model!r}")
|
|
22
|
+
return provider_name, model_id
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@cache
|
|
26
|
+
def get_provider(provider_name: str) -> LLMProvider:
|
|
27
|
+
"""Instantiate (and cache) a provider by name. Imports lazily so one
|
|
28
|
+
missing SDK/key never blocks the other provider."""
|
|
29
|
+
if provider_name == "anthropic":
|
|
30
|
+
from whetkit.llm.anthropic_provider import AnthropicProvider
|
|
31
|
+
|
|
32
|
+
return AnthropicProvider()
|
|
33
|
+
if provider_name == "openai":
|
|
34
|
+
from whetkit.llm.openai_provider import OpenAIProvider
|
|
35
|
+
|
|
36
|
+
return OpenAIProvider()
|
|
37
|
+
raise ValueError(f"unknown provider {provider_name!r} (expected 'anthropic' or 'openai')")
|
whetkit/mcp/__init__.py
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
"""MCP connectivity: transports, client, and tool introspection."""
|
|
2
|
+
|
|
3
|
+
from whetkit.mcp.client import MCPClient
|
|
4
|
+
from whetkit.mcp.introspect import ServerInventory, ToolInfo, inspect_server
|
|
5
|
+
from whetkit.mcp.transport import HttpMode, HttpSpec, ServerSpec, StdioSpec, resolve_server_spec
|
|
6
|
+
|
|
7
|
+
__all__ = [
|
|
8
|
+
"HttpMode",
|
|
9
|
+
"HttpSpec",
|
|
10
|
+
"MCPClient",
|
|
11
|
+
"ServerInventory",
|
|
12
|
+
"ServerSpec",
|
|
13
|
+
"StdioSpec",
|
|
14
|
+
"ToolInfo",
|
|
15
|
+
"inspect_server",
|
|
16
|
+
"resolve_server_spec",
|
|
17
|
+
]
|