mcp4api 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.
- mcp4api/__init__.py +1 -0
- mcp4api/__main__.py +5 -0
- mcp4api/cassettes.py +96 -0
- mcp4api/cli.py +665 -0
- mcp4api/curate/__init__.py +3 -0
- mcp4api/curate/agent.py +850 -0
- mcp4api/curate/criteria.md +21 -0
- mcp4api/curate/draft.py +606 -0
- mcp4api/curate/models.py +614 -0
- mcp4api/curate/rules.py +1059 -0
- mcp4api/emit/__init__.py +205 -0
- mcp4api/emit/catalog.py +717 -0
- mcp4api/emit/render.py +917 -0
- mcp4api/emit/templates/README.md.j2 +58 -0
- mcp4api/emit/templates/client.py.j2 +305 -0
- mcp4api/emit/templates/conftest.py.j2 +20 -0
- mcp4api/emit/templates/file.py.j2 +4 -0
- mcp4api/emit/templates/header.py.j2 +2 -0
- mcp4api/emit/templates/main.py.j2 +99 -0
- mcp4api/emit/templates/pyproject.toml.j2 +24 -0
- mcp4api/emit/templates/test_tools.py.j2 +40 -0
- mcp4api/errors.py +35 -0
- mcp4api/ingest/__init__.py +3 -0
- mcp4api/ingest/build.py +1064 -0
- mcp4api/ingest/convert.py +569 -0
- mcp4api/ingest/load.py +603 -0
- mcp4api/model.py +324 -0
- mcp4api/py.typed +0 -0
- mcp4api/report.py +330 -0
- mcp4api/suite.py +1384 -0
- mcp4api/verify.py +1114 -0
- mcp4api-0.1.0.dist-info/METADATA +157 -0
- mcp4api-0.1.0.dist-info/RECORD +36 -0
- mcp4api-0.1.0.dist-info/WHEEL +4 -0
- mcp4api-0.1.0.dist-info/entry_points.txt +2 -0
- mcp4api-0.1.0.dist-info/licenses/LICENSE +21 -0
mcp4api/curate/agent.py
ADDED
|
@@ -0,0 +1,850 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import importlib.resources
|
|
4
|
+
import json
|
|
5
|
+
import os
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import TYPE_CHECKING, Literal, Protocol
|
|
9
|
+
|
|
10
|
+
from anthropic import APIError
|
|
11
|
+
from pydantic import BaseModel, ValidationError
|
|
12
|
+
|
|
13
|
+
from mcp4api import __version__
|
|
14
|
+
from mcp4api.curate.draft import apply_overrides, heuristic_tools
|
|
15
|
+
from mcp4api.curate.models import (
|
|
16
|
+
CurationMode,
|
|
17
|
+
DetailDecision,
|
|
18
|
+
Excluded,
|
|
19
|
+
Manifest,
|
|
20
|
+
Overrides,
|
|
21
|
+
PlanDecision,
|
|
22
|
+
PlanTool,
|
|
23
|
+
Tool,
|
|
24
|
+
Usage,
|
|
25
|
+
build_manifest,
|
|
26
|
+
bundle_parameter,
|
|
27
|
+
curation_hash,
|
|
28
|
+
dump_manifest,
|
|
29
|
+
invalid_exclusions,
|
|
30
|
+
load_manifest,
|
|
31
|
+
load_overrides,
|
|
32
|
+
namespace_error,
|
|
33
|
+
zero_usage,
|
|
34
|
+
)
|
|
35
|
+
from mcp4api.curate.rules import (
|
|
36
|
+
_shield_doc,
|
|
37
|
+
body_schema,
|
|
38
|
+
endpoint_index_document,
|
|
39
|
+
forced_exclusions,
|
|
40
|
+
interpret_detail,
|
|
41
|
+
interpret_plan,
|
|
42
|
+
validation_errors,
|
|
43
|
+
)
|
|
44
|
+
from mcp4api.errors import UpstreamError, UsageError
|
|
45
|
+
from mcp4api.model import ApiSpec, Endpoint, JsonObject, JsonValue, bundle_schema, expect_json
|
|
46
|
+
|
|
47
|
+
if TYPE_CHECKING:
|
|
48
|
+
from anthropic.types.beta import BetaTextBlockParam, BetaUserMessageParam
|
|
49
|
+
|
|
50
|
+
DEFAULT_MODEL = "claude-opus-5-5"
|
|
51
|
+
DEFAULT_BUDGET_TOKENS = 200_000
|
|
52
|
+
DEFAULT_BUDGET_USD = 5.0
|
|
53
|
+
DEFAULT_MAX_TOOLS = 20
|
|
54
|
+
PLAN_LIMIT = 80
|
|
55
|
+
MAX_OUTPUT_TOKENS = 16_000
|
|
56
|
+
RESERVE_OUTPUT_TOKENS = 4_096
|
|
57
|
+
_FALLBACK = "Heuristic fallback:"
|
|
58
|
+
_PRICES: dict[str, tuple[float, float, float, float]] = {
|
|
59
|
+
"claude-opus-5-5": (4.0, 5.0, 0.2, 20.0),
|
|
60
|
+
"claude-sonnet-5-5": (2.0, 2.5, 0.1, 10.0),
|
|
61
|
+
}
|
|
62
|
+
_INSTRUCTIONS = (
|
|
63
|
+
"Do not write JSON Schema. "
|
|
64
|
+
"Choose names, titles, descriptions, intents, annotations, parameters, fixed scalars, "
|
|
65
|
+
"step bindings, output field paths, example arguments, and a short rationale. "
|
|
66
|
+
"Bind with $input.name or $steps[n].body.field. "
|
|
67
|
+
"A list tool returns the first page. "
|
|
68
|
+
"The rationale is a short justification, not a chain of thought."
|
|
69
|
+
)
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
@dataclass(frozen=True)
|
|
73
|
+
class CurationOptions:
|
|
74
|
+
model: str = DEFAULT_MODEL
|
|
75
|
+
mode: CurationMode = CurationMode.AGENT
|
|
76
|
+
max_tools: int = DEFAULT_MAX_TOOLS
|
|
77
|
+
namespace: str = ""
|
|
78
|
+
include_tags: tuple[str, ...] = ()
|
|
79
|
+
budget_tokens: int = DEFAULT_BUDGET_TOKENS
|
|
80
|
+
budget_usd: float = DEFAULT_BUDGET_USD
|
|
81
|
+
dry_run: bool = False
|
|
82
|
+
recurate: bool = False
|
|
83
|
+
offline: bool = False
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
@dataclass(frozen=True)
|
|
87
|
+
class ModelUsage:
|
|
88
|
+
input_tokens: int
|
|
89
|
+
output_tokens: int
|
|
90
|
+
cache_read_input_tokens: int
|
|
91
|
+
cache_write_input_tokens: int
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
@dataclass(frozen=True)
|
|
95
|
+
class ModelTurn:
|
|
96
|
+
stop_reason: str
|
|
97
|
+
payload: JsonObject | None
|
|
98
|
+
usage: ModelUsage
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
@dataclass(frozen=True)
|
|
102
|
+
class CurationRequest:
|
|
103
|
+
kind: Literal["plan", "detail"]
|
|
104
|
+
system: str
|
|
105
|
+
index: str
|
|
106
|
+
group: str
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
@dataclass(frozen=True)
|
|
110
|
+
class CurateResult:
|
|
111
|
+
manifest: Manifest | None
|
|
112
|
+
report: str
|
|
113
|
+
warnings: tuple[str, ...]
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
class CurationClient(Protocol):
|
|
117
|
+
def count_tokens(self, request: CurationRequest) -> int: ...
|
|
118
|
+
|
|
119
|
+
def complete(self, request: CurationRequest) -> ModelTurn: ...
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
class _TokenUsage(Protocol):
|
|
123
|
+
input_tokens: int
|
|
124
|
+
output_tokens: int
|
|
125
|
+
cache_read_input_tokens: int | None
|
|
126
|
+
cache_creation_input_tokens: int | None
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def curate(
|
|
130
|
+
out: Path,
|
|
131
|
+
options: CurationOptions,
|
|
132
|
+
*,
|
|
133
|
+
client: CurationClient | None = None,
|
|
134
|
+
) -> CurateResult:
|
|
135
|
+
_check_options(options)
|
|
136
|
+
spec, raw = _load_spec(out)
|
|
137
|
+
if options.dry_run and options.offline:
|
|
138
|
+
raise UsageError("cannot estimate tokens offline")
|
|
139
|
+
digest = curation_hash(
|
|
140
|
+
raw,
|
|
141
|
+
prompt=system_prompt(),
|
|
142
|
+
model=options.model,
|
|
143
|
+
mode=options.mode,
|
|
144
|
+
include_tags=options.include_tags,
|
|
145
|
+
max_tools=options.max_tools,
|
|
146
|
+
namespace=options.namespace,
|
|
147
|
+
version=__version__,
|
|
148
|
+
)
|
|
149
|
+
if options.mode == "manual":
|
|
150
|
+
return _manual(out, spec, options, digest)
|
|
151
|
+
if options.mode == "heuristic":
|
|
152
|
+
return _store_heuristic(out, spec, options, digest)
|
|
153
|
+
if not options.recurate:
|
|
154
|
+
cached = _matching_base(out, digest)
|
|
155
|
+
if cached is not None:
|
|
156
|
+
return _publish(out, spec, options, cached)
|
|
157
|
+
if options.offline:
|
|
158
|
+
raise UsageError("offline curation needs a matching tools.yaml or --curation heuristic")
|
|
159
|
+
live = client if client is not None else _require_client(options.model)
|
|
160
|
+
if options.dry_run:
|
|
161
|
+
return CurateResult(
|
|
162
|
+
manifest=None,
|
|
163
|
+
report=_dry_run(spec, options, live),
|
|
164
|
+
warnings=(),
|
|
165
|
+
)
|
|
166
|
+
base = _agent_manifest(spec, options, live, digest)
|
|
167
|
+
return _publish(out, spec, options, base)
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def request_body(request: CurationRequest) -> JsonObject:
|
|
171
|
+
return {
|
|
172
|
+
"messages": [
|
|
173
|
+
{
|
|
174
|
+
"content": [
|
|
175
|
+
{
|
|
176
|
+
"cache_control": {"type": "ephemeral"},
|
|
177
|
+
"text": request.index,
|
|
178
|
+
"type": "text",
|
|
179
|
+
},
|
|
180
|
+
{"text": request.group, "type": "text"},
|
|
181
|
+
],
|
|
182
|
+
"role": "user",
|
|
183
|
+
}
|
|
184
|
+
],
|
|
185
|
+
"system": [
|
|
186
|
+
{
|
|
187
|
+
"cache_control": {"type": "ephemeral"},
|
|
188
|
+
"text": request.system,
|
|
189
|
+
"type": "text",
|
|
190
|
+
}
|
|
191
|
+
],
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def estimate_cost(
|
|
196
|
+
input_tokens: int,
|
|
197
|
+
output_tokens: int,
|
|
198
|
+
cache_read_tokens: int,
|
|
199
|
+
cache_write_tokens: int,
|
|
200
|
+
model: str,
|
|
201
|
+
) -> float:
|
|
202
|
+
input_price, write_price, read_price, output_price = _PRICES.get(model, _PRICES[DEFAULT_MODEL])
|
|
203
|
+
raw = (
|
|
204
|
+
input_tokens * input_price
|
|
205
|
+
+ cache_write_tokens * write_price
|
|
206
|
+
+ cache_read_tokens * read_price
|
|
207
|
+
+ output_tokens * output_price
|
|
208
|
+
) / 1_000_000
|
|
209
|
+
return round(raw, 6)
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
def system_prompt() -> str:
|
|
213
|
+
criteria = (
|
|
214
|
+
importlib.resources.files("mcp4api.curate")
|
|
215
|
+
.joinpath("criteria.md")
|
|
216
|
+
.read_text(encoding="utf-8")
|
|
217
|
+
)
|
|
218
|
+
return criteria.rstrip() + "\n\n" + _INSTRUCTIONS + "\n"
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
class AnthropicCurationClient:
|
|
222
|
+
def __init__(self, model: str) -> None:
|
|
223
|
+
from anthropic import Anthropic
|
|
224
|
+
|
|
225
|
+
self.model = model
|
|
226
|
+
self._client = Anthropic()
|
|
227
|
+
|
|
228
|
+
def count_tokens(self, request: CurationRequest) -> int:
|
|
229
|
+
try:
|
|
230
|
+
return self._count(request)
|
|
231
|
+
except UpstreamError:
|
|
232
|
+
raise
|
|
233
|
+
except APIError as exc:
|
|
234
|
+
raise UpstreamError(str(exc) or exc.__class__.__name__) from exc
|
|
235
|
+
|
|
236
|
+
def complete(self, request: CurationRequest) -> ModelTurn:
|
|
237
|
+
try:
|
|
238
|
+
return self._complete(request)
|
|
239
|
+
except UpstreamError:
|
|
240
|
+
raise
|
|
241
|
+
except APIError as exc:
|
|
242
|
+
raise UpstreamError(str(exc) or exc.__class__.__name__) from exc
|
|
243
|
+
|
|
244
|
+
def _count(self, request: CurationRequest) -> int:
|
|
245
|
+
from anthropic import transform_schema
|
|
246
|
+
|
|
247
|
+
system, messages = _sdk_messages(request)
|
|
248
|
+
schema = transform_schema(PlanDecision if request.kind == "plan" else DetailDecision)
|
|
249
|
+
counted = self._client.beta.messages.count_tokens(
|
|
250
|
+
betas=["server-side-fallback-2026-07-01"],
|
|
251
|
+
messages=messages,
|
|
252
|
+
model=self.model,
|
|
253
|
+
output_config={
|
|
254
|
+
"effort": "high",
|
|
255
|
+
"format": {"schema": schema, "type": "json_schema"},
|
|
256
|
+
},
|
|
257
|
+
system=system,
|
|
258
|
+
thinking={"type": "adaptive"},
|
|
259
|
+
)
|
|
260
|
+
return counted.input_tokens
|
|
261
|
+
|
|
262
|
+
def _complete(self, request: CurationRequest) -> ModelTurn:
|
|
263
|
+
system, messages = _sdk_messages(request)
|
|
264
|
+
model_type = PlanDecision if request.kind == "plan" else DetailDecision
|
|
265
|
+
with self._client.beta.messages.stream(
|
|
266
|
+
betas=["server-side-fallback-2026-07-01"],
|
|
267
|
+
fallbacks="default",
|
|
268
|
+
max_tokens=MAX_OUTPUT_TOKENS,
|
|
269
|
+
messages=messages,
|
|
270
|
+
model=self.model,
|
|
271
|
+
output_config={"effort": "high"},
|
|
272
|
+
output_format=model_type,
|
|
273
|
+
system=system,
|
|
274
|
+
thinking={"type": "adaptive"},
|
|
275
|
+
) as stream:
|
|
276
|
+
final = stream.get_final_message()
|
|
277
|
+
usage = _usage(final.usage)
|
|
278
|
+
stop = final.stop_reason or ""
|
|
279
|
+
parsed = final.parsed_output
|
|
280
|
+
if stop != "end_turn" or not isinstance(parsed, BaseModel):
|
|
281
|
+
return ModelTurn(stop_reason=stop or "unknown", payload=None, usage=usage)
|
|
282
|
+
body = expect_json(parsed.model_dump(mode="json", by_alias=True))
|
|
283
|
+
if not isinstance(body, dict):
|
|
284
|
+
return ModelTurn(stop_reason=stop, payload=None, usage=usage)
|
|
285
|
+
return ModelTurn(stop_reason=stop, payload=body, usage=usage)
|
|
286
|
+
|
|
287
|
+
|
|
288
|
+
def _check_options(options: CurationOptions) -> None:
|
|
289
|
+
problem = namespace_error(options.namespace)
|
|
290
|
+
if problem:
|
|
291
|
+
raise UsageError(problem)
|
|
292
|
+
if options.max_tools < 1:
|
|
293
|
+
raise UsageError("max tools must be positive")
|
|
294
|
+
if options.budget_tokens < 0 or options.budget_usd < 0:
|
|
295
|
+
raise UsageError("budget must not be negative")
|
|
296
|
+
if options.mode not in {"agent", "heuristic", "manual"}:
|
|
297
|
+
raise UsageError("curation mode is not supported")
|
|
298
|
+
|
|
299
|
+
|
|
300
|
+
def _load_spec(out: Path) -> tuple[ApiSpec, bytes]:
|
|
301
|
+
from mcp4api.ingest import read_endpoints
|
|
302
|
+
|
|
303
|
+
return read_endpoints(out / "endpoints.json")
|
|
304
|
+
|
|
305
|
+
|
|
306
|
+
def _require_client(model: str) -> CurationClient:
|
|
307
|
+
if not os.environ.get("ANTHROPIC_API_KEY"):
|
|
308
|
+
raise UsageError("missing ANTHROPIC_API_KEY")
|
|
309
|
+
return AnthropicCurationClient(model)
|
|
310
|
+
|
|
311
|
+
|
|
312
|
+
def _manual(out: Path, spec: ApiSpec, options: CurationOptions, digest: str) -> CurateResult:
|
|
313
|
+
path = out / "tools.yaml"
|
|
314
|
+
if not path.is_file():
|
|
315
|
+
raise UsageError(f"not found: {path}")
|
|
316
|
+
current = _read_manifest(path)
|
|
317
|
+
cache = _read_manifest(out / ".mcp4api" / "base-tools.yaml")
|
|
318
|
+
base = cache if cache is not None else current
|
|
319
|
+
if base is None:
|
|
320
|
+
raise UsageError("invalid tools.yaml")
|
|
321
|
+
if validation_errors(
|
|
322
|
+
base,
|
|
323
|
+
spec,
|
|
324
|
+
max_tools=options.max_tools,
|
|
325
|
+
include_tags=options.include_tags,
|
|
326
|
+
):
|
|
327
|
+
raise UsageError("invalid tools.yaml")
|
|
328
|
+
return _publish(out, spec, options, base, previous=_names(current))
|
|
329
|
+
|
|
330
|
+
|
|
331
|
+
def _store_heuristic(
|
|
332
|
+
out: Path,
|
|
333
|
+
spec: ApiSpec,
|
|
334
|
+
options: CurationOptions,
|
|
335
|
+
digest: str,
|
|
336
|
+
) -> CurateResult:
|
|
337
|
+
tools, excluded = heuristic_tools(
|
|
338
|
+
spec,
|
|
339
|
+
[endpoint.id for endpoint in spec.endpoints],
|
|
340
|
+
namespace=options.namespace,
|
|
341
|
+
include_tags=options.include_tags,
|
|
342
|
+
)
|
|
343
|
+
base = build_manifest(
|
|
344
|
+
spec,
|
|
345
|
+
tools,
|
|
346
|
+
excluded,
|
|
347
|
+
namespace=options.namespace,
|
|
348
|
+
mode=CurationMode.HEURISTIC,
|
|
349
|
+
model=options.model,
|
|
350
|
+
digest=digest,
|
|
351
|
+
usage=zero_usage(),
|
|
352
|
+
fallback_tools=[],
|
|
353
|
+
max_tools=options.max_tools,
|
|
354
|
+
)
|
|
355
|
+
return _publish(out, spec, options, base)
|
|
356
|
+
|
|
357
|
+
|
|
358
|
+
def _publish(
|
|
359
|
+
out: Path,
|
|
360
|
+
spec: ApiSpec,
|
|
361
|
+
options: CurationOptions,
|
|
362
|
+
base: Manifest,
|
|
363
|
+
*,
|
|
364
|
+
previous: set[str] | None = None,
|
|
365
|
+
) -> CurateResult:
|
|
366
|
+
if previous is None:
|
|
367
|
+
previous = _names(_read_manifest(out / "tools.yaml"))
|
|
368
|
+
overrides = _read_overrides(out / "overrides.yaml")
|
|
369
|
+
final, warnings = apply_overrides(base, overrides, spec)
|
|
370
|
+
_write(out / ".mcp4api" / "base-tools.yaml", dump_manifest(base))
|
|
371
|
+
_write(out / "tools.yaml", dump_manifest(final))
|
|
372
|
+
notes = list(warnings)
|
|
373
|
+
if previous is not None:
|
|
374
|
+
notes.extend(_name_diff(previous, {tool.name for tool in final.tools}))
|
|
375
|
+
return CurateResult(manifest=final, report="", warnings=tuple(notes))
|
|
376
|
+
|
|
377
|
+
|
|
378
|
+
def _agent_manifest(
|
|
379
|
+
spec: ApiSpec,
|
|
380
|
+
options: CurationOptions,
|
|
381
|
+
client: CurationClient,
|
|
382
|
+
digest: str,
|
|
383
|
+
) -> Manifest:
|
|
384
|
+
prompt = system_prompt()
|
|
385
|
+
index = endpoint_index_document(spec, options.include_tags)
|
|
386
|
+
budget = _Budget(
|
|
387
|
+
tokens_left=options.budget_tokens, usd_left=options.budget_usd, usage=zero_usage()
|
|
388
|
+
)
|
|
389
|
+
caller = _Caller(client=client, budget=budget, model=options.model)
|
|
390
|
+
forced = forced_exclusions(spec, options.include_tags)
|
|
391
|
+
forced_ids = {item.endpoint_id for item in forced}
|
|
392
|
+
eligible = [endpoint for endpoint in spec.endpoints if endpoint.id not in forced_ids]
|
|
393
|
+
tools: list[Tool] = []
|
|
394
|
+
excluded: list[Excluded] = list(forced)
|
|
395
|
+
fallback: list[str] = []
|
|
396
|
+
accepted: set[str] = set()
|
|
397
|
+
for chunk in _chunks(eligible):
|
|
398
|
+
planned, chunk_excluded, uncovered, failed = _plan_chunk(
|
|
399
|
+
spec,
|
|
400
|
+
chunk,
|
|
401
|
+
options,
|
|
402
|
+
caller,
|
|
403
|
+
prompt,
|
|
404
|
+
index,
|
|
405
|
+
)
|
|
406
|
+
if failed:
|
|
407
|
+
added, _out = heuristic_tools(
|
|
408
|
+
spec,
|
|
409
|
+
[endpoint.id for endpoint in chunk],
|
|
410
|
+
namespace=options.namespace,
|
|
411
|
+
include_tags=options.include_tags,
|
|
412
|
+
fallback=True,
|
|
413
|
+
reserved_names=accepted,
|
|
414
|
+
)
|
|
415
|
+
tools.extend(added)
|
|
416
|
+
fallback.extend(tool.name for tool in added)
|
|
417
|
+
accepted.update(tool.name for tool in added)
|
|
418
|
+
continue
|
|
419
|
+
excluded.extend(chunk_excluded)
|
|
420
|
+
for endpoint_id in uncovered:
|
|
421
|
+
added, _out = heuristic_tools(
|
|
422
|
+
spec,
|
|
423
|
+
[endpoint_id],
|
|
424
|
+
namespace=options.namespace,
|
|
425
|
+
include_tags=options.include_tags,
|
|
426
|
+
reserved_names=accepted,
|
|
427
|
+
)
|
|
428
|
+
tools.extend(added)
|
|
429
|
+
accepted.update(tool.name for tool in added)
|
|
430
|
+
for plan in planned:
|
|
431
|
+
detail_tools, detail_excluded, detail_fallback = _detail_group(
|
|
432
|
+
spec,
|
|
433
|
+
plan,
|
|
434
|
+
options,
|
|
435
|
+
caller,
|
|
436
|
+
prompt,
|
|
437
|
+
index,
|
|
438
|
+
accepted,
|
|
439
|
+
)
|
|
440
|
+
tools.extend(detail_tools)
|
|
441
|
+
excluded.extend(detail_excluded)
|
|
442
|
+
fallback.extend(detail_fallback)
|
|
443
|
+
accepted.update(tool.name for tool in detail_tools)
|
|
444
|
+
claimed = {step.endpoint_id for tool in tools for step in tool.steps}
|
|
445
|
+
claimed.update(item.endpoint_id for item in excluded)
|
|
446
|
+
missing = [endpoint.id for endpoint in spec.endpoints if endpoint.id not in claimed]
|
|
447
|
+
if missing:
|
|
448
|
+
added, still_out = heuristic_tools(
|
|
449
|
+
spec,
|
|
450
|
+
missing,
|
|
451
|
+
namespace=options.namespace,
|
|
452
|
+
include_tags=options.include_tags,
|
|
453
|
+
reserved_names=accepted,
|
|
454
|
+
)
|
|
455
|
+
tools.extend(added)
|
|
456
|
+
excluded.extend(still_out)
|
|
457
|
+
return build_manifest(
|
|
458
|
+
spec,
|
|
459
|
+
tools,
|
|
460
|
+
excluded,
|
|
461
|
+
namespace=options.namespace,
|
|
462
|
+
mode=CurationMode.AGENT,
|
|
463
|
+
model=options.model,
|
|
464
|
+
digest=digest,
|
|
465
|
+
usage=budget.usage,
|
|
466
|
+
fallback_tools=fallback,
|
|
467
|
+
max_tools=options.max_tools,
|
|
468
|
+
)
|
|
469
|
+
|
|
470
|
+
|
|
471
|
+
def _plan_chunk(
|
|
472
|
+
spec: ApiSpec,
|
|
473
|
+
chunk: list[Endpoint],
|
|
474
|
+
options: CurationOptions,
|
|
475
|
+
caller: _Caller,
|
|
476
|
+
prompt: str,
|
|
477
|
+
index: str,
|
|
478
|
+
) -> tuple[list[PlanTool], list[Excluded], list[str], bool]:
|
|
479
|
+
request = CurationRequest(
|
|
480
|
+
kind="plan",
|
|
481
|
+
system=prompt,
|
|
482
|
+
index=index,
|
|
483
|
+
group=_plan_group(chunk, options.max_tools),
|
|
484
|
+
)
|
|
485
|
+
decision = _decision(caller, request, PlanDecision)
|
|
486
|
+
if not isinstance(decision, PlanDecision):
|
|
487
|
+
return [], [], [], True
|
|
488
|
+
result = interpret_plan(spec, decision, options.include_tags)
|
|
489
|
+
if result.errors:
|
|
490
|
+
corrected = _decision(caller, _with_errors(request, list(result.errors)), PlanDecision)
|
|
491
|
+
if not isinstance(corrected, PlanDecision):
|
|
492
|
+
return [], [], [], True
|
|
493
|
+
result = interpret_plan(spec, corrected, options.include_tags)
|
|
494
|
+
return list(result.tools), list(result.excluded), list(result.uncovered), False
|
|
495
|
+
|
|
496
|
+
|
|
497
|
+
def _detail_group(
|
|
498
|
+
spec: ApiSpec,
|
|
499
|
+
plan: PlanTool,
|
|
500
|
+
options: CurationOptions,
|
|
501
|
+
caller: _Caller,
|
|
502
|
+
prompt: str,
|
|
503
|
+
index: str,
|
|
504
|
+
accepted: set[str],
|
|
505
|
+
) -> tuple[list[Tool], list[Excluded], list[str]]:
|
|
506
|
+
endpoints = [endpoint for endpoint in spec.endpoints if endpoint.id in set(plan.endpoint_ids)]
|
|
507
|
+
request = CurationRequest(
|
|
508
|
+
kind="detail",
|
|
509
|
+
system=prompt,
|
|
510
|
+
index=index,
|
|
511
|
+
group=_detail_group_text(spec, endpoints, plan.name),
|
|
512
|
+
)
|
|
513
|
+
decision = _decision(caller, request, DetailDecision)
|
|
514
|
+
allowed = set(plan.endpoint_ids)
|
|
515
|
+
if not isinstance(decision, DetailDecision):
|
|
516
|
+
added, _out = heuristic_tools(
|
|
517
|
+
spec,
|
|
518
|
+
list(plan.endpoint_ids),
|
|
519
|
+
namespace=options.namespace,
|
|
520
|
+
include_tags=options.include_tags,
|
|
521
|
+
fallback=True,
|
|
522
|
+
reserved_names=accepted,
|
|
523
|
+
)
|
|
524
|
+
return added, [], [tool.name for tool in added]
|
|
525
|
+
result = interpret_detail(
|
|
526
|
+
spec,
|
|
527
|
+
decision,
|
|
528
|
+
allowed=allowed,
|
|
529
|
+
namespace=options.namespace,
|
|
530
|
+
include_tags=options.include_tags,
|
|
531
|
+
accepted_names=accepted,
|
|
532
|
+
)
|
|
533
|
+
if result.errors:
|
|
534
|
+
corrected = _decision(caller, _with_errors(request, list(result.errors)), DetailDecision)
|
|
535
|
+
if isinstance(corrected, DetailDecision):
|
|
536
|
+
result = interpret_detail(
|
|
537
|
+
spec,
|
|
538
|
+
corrected,
|
|
539
|
+
allowed=allowed,
|
|
540
|
+
namespace=options.namespace,
|
|
541
|
+
include_tags=options.include_tags,
|
|
542
|
+
accepted_names=accepted | {tool.name for tool in result.tools},
|
|
543
|
+
)
|
|
544
|
+
if result.errors:
|
|
545
|
+
return [], invalid_exclusions(sorted(allowed), result.errors[0]), []
|
|
546
|
+
tools: list[Tool] = []
|
|
547
|
+
excluded = list(result.excluded)
|
|
548
|
+
fallback: list[str] = []
|
|
549
|
+
for tool in result.tools:
|
|
550
|
+
if tool.name in accepted or tool.name in {item.name for item in tools}:
|
|
551
|
+
excluded.extend(
|
|
552
|
+
invalid_exclusions([step.endpoint_id for step in tool.steps], "duplicate tool name")
|
|
553
|
+
)
|
|
554
|
+
continue
|
|
555
|
+
tools.append(tool)
|
|
556
|
+
covered = {step.endpoint_id for tool in tools for step in tool.steps}
|
|
557
|
+
covered.update(item.endpoint_id for item in excluded)
|
|
558
|
+
missing = [endpoint_id for endpoint_id in plan.endpoint_ids if endpoint_id not in covered]
|
|
559
|
+
if missing:
|
|
560
|
+
added, still_out = heuristic_tools(
|
|
561
|
+
spec,
|
|
562
|
+
missing,
|
|
563
|
+
namespace=options.namespace,
|
|
564
|
+
include_tags=options.include_tags,
|
|
565
|
+
reserved_names=accepted | {tool.name for tool in tools},
|
|
566
|
+
)
|
|
567
|
+
tools.extend(added)
|
|
568
|
+
excluded.extend(still_out)
|
|
569
|
+
return tools, excluded, fallback
|
|
570
|
+
|
|
571
|
+
|
|
572
|
+
def _decision(
|
|
573
|
+
caller: _Caller,
|
|
574
|
+
request: CurationRequest,
|
|
575
|
+
model: type[PlanDecision] | type[DetailDecision],
|
|
576
|
+
) -> PlanDecision | DetailDecision | None:
|
|
577
|
+
payload = caller.call(request)
|
|
578
|
+
if payload is None:
|
|
579
|
+
return None
|
|
580
|
+
try:
|
|
581
|
+
return model.model_validate(payload)
|
|
582
|
+
except ValidationError:
|
|
583
|
+
return None
|
|
584
|
+
|
|
585
|
+
|
|
586
|
+
def _dry_run(spec: ApiSpec, options: CurationOptions, client: CurationClient) -> str:
|
|
587
|
+
forced = {item.endpoint_id for item in forced_exclusions(spec, options.include_tags)}
|
|
588
|
+
eligible = [endpoint for endpoint in spec.endpoints if endpoint.id not in forced]
|
|
589
|
+
chunks = _chunks(eligible)
|
|
590
|
+
prompt = system_prompt()
|
|
591
|
+
index = endpoint_index_document(spec, options.include_tags)
|
|
592
|
+
input_tokens = 0
|
|
593
|
+
for chunk in chunks:
|
|
594
|
+
input_tokens += client.count_tokens(
|
|
595
|
+
CurationRequest(
|
|
596
|
+
kind="plan",
|
|
597
|
+
system=prompt,
|
|
598
|
+
index=index,
|
|
599
|
+
group=_plan_group(chunk, options.max_tools),
|
|
600
|
+
)
|
|
601
|
+
)
|
|
602
|
+
sample = chunk or eligible
|
|
603
|
+
input_tokens += client.count_tokens(
|
|
604
|
+
CurationRequest(
|
|
605
|
+
kind="detail",
|
|
606
|
+
system=prompt,
|
|
607
|
+
index=index,
|
|
608
|
+
group=_detail_group_text(spec, sample, "tool"),
|
|
609
|
+
)
|
|
610
|
+
)
|
|
611
|
+
groups = min(options.max_tools, len(eligible))
|
|
612
|
+
calls = len(chunks) + groups
|
|
613
|
+
output_tokens = RESERVE_OUTPUT_TOKENS * calls
|
|
614
|
+
usd = estimate_cost(input_tokens, output_tokens, 0, 0, options.model)
|
|
615
|
+
lines = [
|
|
616
|
+
f"input_tokens: {input_tokens}",
|
|
617
|
+
f"output_tokens_estimate: {output_tokens}",
|
|
618
|
+
f"calls: {calls}",
|
|
619
|
+
f"usd_estimate: {usd}",
|
|
620
|
+
]
|
|
621
|
+
if options.model not in _PRICES:
|
|
622
|
+
lines.append("price_table: claude-opus-5-5")
|
|
623
|
+
return "\n".join(lines) + "\n"
|
|
624
|
+
|
|
625
|
+
|
|
626
|
+
def _plan_group(endpoints: list[Endpoint], max_tools: int) -> str:
|
|
627
|
+
ids = [endpoint.id for endpoint in endpoints]
|
|
628
|
+
return (
|
|
629
|
+
"Propose tools for this endpoint set. "
|
|
630
|
+
f"The tool target is {max_tools}. "
|
|
631
|
+
"Return one group per tool and exclude endpoints that are not worth a tool.\n"
|
|
632
|
+
"Eligible endpoint ids:\n" + json.dumps(ids, indent=2)
|
|
633
|
+
)
|
|
634
|
+
|
|
635
|
+
|
|
636
|
+
def _detail_group_text(spec: ApiSpec, endpoints: list[Endpoint], name: str) -> str:
|
|
637
|
+
payload = [endpoint_detail(spec, endpoint) for endpoint in endpoints]
|
|
638
|
+
return (
|
|
639
|
+
f"Fill in the tool {name}. Use only these endpoints. Do not write JSON Schema.\n"
|
|
640
|
+
+ _shield_doc(json.dumps(payload, ensure_ascii=False, indent=2, sort_keys=True))
|
|
641
|
+
)
|
|
642
|
+
|
|
643
|
+
|
|
644
|
+
def endpoint_detail(spec: ApiSpec, endpoint: Endpoint) -> JsonObject:
|
|
645
|
+
parameters: list[JsonValue] = []
|
|
646
|
+
for parameter in endpoint.parameters:
|
|
647
|
+
row: JsonObject = {
|
|
648
|
+
"description": _shield_doc(parameter.description or ""),
|
|
649
|
+
"in": parameter.location,
|
|
650
|
+
"name": parameter.name,
|
|
651
|
+
"required": parameter.required,
|
|
652
|
+
"schema": bundle_parameter(spec, parameter),
|
|
653
|
+
}
|
|
654
|
+
parameters.append(row)
|
|
655
|
+
body = body_schema(spec, endpoint)
|
|
656
|
+
responses: JsonObject = {}
|
|
657
|
+
for code, response in endpoint.responses.items():
|
|
658
|
+
if response.schema_ is None:
|
|
659
|
+
continue
|
|
660
|
+
responses[code] = bundle_schema(spec, response.schema_)
|
|
661
|
+
tags: list[JsonValue] = []
|
|
662
|
+
for tag in endpoint.tags or []:
|
|
663
|
+
tags.append(_shield_doc(tag))
|
|
664
|
+
return {
|
|
665
|
+
"description": _shield_doc(endpoint.description or ""),
|
|
666
|
+
"id": endpoint.id,
|
|
667
|
+
"method": endpoint.method,
|
|
668
|
+
"parameters": parameters,
|
|
669
|
+
"request_body": body,
|
|
670
|
+
"responses": responses,
|
|
671
|
+
"summary": _shield_doc(endpoint.summary or ""),
|
|
672
|
+
"tags": tags,
|
|
673
|
+
}
|
|
674
|
+
|
|
675
|
+
|
|
676
|
+
def _chunks(endpoints: list[Endpoint]) -> list[list[Endpoint]]:
|
|
677
|
+
ordered = sorted(endpoints, key=lambda endpoint: endpoint.id)
|
|
678
|
+
if not ordered:
|
|
679
|
+
return []
|
|
680
|
+
if len(ordered) <= PLAN_LIMIT:
|
|
681
|
+
return [ordered]
|
|
682
|
+
grouped: dict[str, list[Endpoint]] = {}
|
|
683
|
+
for endpoint in ordered:
|
|
684
|
+
tags = sorted(tag.lower() for tag in endpoint.tags or [])
|
|
685
|
+
grouped.setdefault(tags[0] if tags else "", []).append(endpoint)
|
|
686
|
+
chunks: list[list[Endpoint]] = []
|
|
687
|
+
for key in sorted(grouped):
|
|
688
|
+
group = grouped[key]
|
|
689
|
+
for start in range(0, len(group), PLAN_LIMIT):
|
|
690
|
+
chunks.append(group[start : start + PLAN_LIMIT])
|
|
691
|
+
return chunks
|
|
692
|
+
|
|
693
|
+
|
|
694
|
+
def _with_errors(request: CurationRequest, errors: list[str]) -> CurationRequest:
|
|
695
|
+
lines = "\n".join(f"- {error}" for error in errors)
|
|
696
|
+
return CurationRequest(
|
|
697
|
+
kind=request.kind,
|
|
698
|
+
system=request.system,
|
|
699
|
+
index=request.index,
|
|
700
|
+
group=request.group + "\nFix these errors and return the same shape:\n" + lines,
|
|
701
|
+
)
|
|
702
|
+
|
|
703
|
+
|
|
704
|
+
def _matching_base(out: Path, digest: str) -> Manifest | None:
|
|
705
|
+
cache = _read_manifest(out / ".mcp4api" / "base-tools.yaml")
|
|
706
|
+
if cache is not None and cache.curation.input_hash == digest:
|
|
707
|
+
return cache
|
|
708
|
+
if (out / "overrides.yaml").is_file():
|
|
709
|
+
return None
|
|
710
|
+
current = _read_manifest(out / "tools.yaml")
|
|
711
|
+
if current is not None and current.curation.input_hash == digest:
|
|
712
|
+
return current
|
|
713
|
+
return None
|
|
714
|
+
|
|
715
|
+
|
|
716
|
+
def _read_manifest(path: Path) -> Manifest | None:
|
|
717
|
+
if not path.is_file():
|
|
718
|
+
return None
|
|
719
|
+
try:
|
|
720
|
+
return load_manifest(path.read_text(encoding="utf-8"))
|
|
721
|
+
except (OSError, ValueError, ValidationError):
|
|
722
|
+
return None
|
|
723
|
+
|
|
724
|
+
|
|
725
|
+
def _read_overrides(path: Path) -> Overrides:
|
|
726
|
+
if not path.is_file():
|
|
727
|
+
return Overrides()
|
|
728
|
+
try:
|
|
729
|
+
return load_overrides(path.read_text(encoding="utf-8"))
|
|
730
|
+
except (OSError, ValueError, ValidationError) as exc:
|
|
731
|
+
raise UsageError("invalid overrides.yaml") from exc
|
|
732
|
+
|
|
733
|
+
|
|
734
|
+
def _names(manifest: Manifest | None) -> set[str] | None:
|
|
735
|
+
if manifest is None:
|
|
736
|
+
return None
|
|
737
|
+
return {tool.name for tool in manifest.tools}
|
|
738
|
+
|
|
739
|
+
|
|
740
|
+
def _name_diff(previous: set[str], current: set[str]) -> list[str]:
|
|
741
|
+
added = sorted(current - previous)
|
|
742
|
+
removed = sorted(previous - current)
|
|
743
|
+
lines: list[str] = []
|
|
744
|
+
if added:
|
|
745
|
+
lines.append("tools added: " + ", ".join(added))
|
|
746
|
+
if removed:
|
|
747
|
+
lines.append("tools removed: " + ", ".join(removed))
|
|
748
|
+
return lines
|
|
749
|
+
|
|
750
|
+
|
|
751
|
+
def _write(path: Path, text: str) -> None:
|
|
752
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
753
|
+
path.write_bytes(text.encode("utf-8"))
|
|
754
|
+
|
|
755
|
+
|
|
756
|
+
def _sdk_messages(
|
|
757
|
+
request: CurationRequest,
|
|
758
|
+
) -> tuple[list[BetaTextBlockParam], list[BetaUserMessageParam]]:
|
|
759
|
+
system: list[BetaTextBlockParam] = [
|
|
760
|
+
{
|
|
761
|
+
"cache_control": {"type": "ephemeral"},
|
|
762
|
+
"text": request.system,
|
|
763
|
+
"type": "text",
|
|
764
|
+
}
|
|
765
|
+
]
|
|
766
|
+
index_block: BetaTextBlockParam = {
|
|
767
|
+
"cache_control": {"type": "ephemeral"},
|
|
768
|
+
"text": request.index,
|
|
769
|
+
"type": "text",
|
|
770
|
+
}
|
|
771
|
+
group_block: BetaTextBlockParam = {"text": request.group, "type": "text"}
|
|
772
|
+
message: BetaUserMessageParam = {
|
|
773
|
+
"content": [index_block, group_block],
|
|
774
|
+
"role": "user",
|
|
775
|
+
}
|
|
776
|
+
return system, [message]
|
|
777
|
+
|
|
778
|
+
|
|
779
|
+
def _usage(usage: _TokenUsage) -> ModelUsage:
|
|
780
|
+
return ModelUsage(
|
|
781
|
+
input_tokens=usage.input_tokens,
|
|
782
|
+
output_tokens=usage.output_tokens,
|
|
783
|
+
cache_read_input_tokens=usage.cache_read_input_tokens or 0,
|
|
784
|
+
cache_write_input_tokens=usage.cache_creation_input_tokens or 0,
|
|
785
|
+
)
|
|
786
|
+
|
|
787
|
+
|
|
788
|
+
class _Budget:
|
|
789
|
+
def __init__(self, *, tokens_left: int, usd_left: float, usage: Usage) -> None:
|
|
790
|
+
self.tokens_left = tokens_left
|
|
791
|
+
self.usd_left = usd_left
|
|
792
|
+
self.usage = usage
|
|
793
|
+
|
|
794
|
+
def allows(self, input_tokens: int, model: str) -> bool:
|
|
795
|
+
tokens = input_tokens + RESERVE_OUTPUT_TOKENS
|
|
796
|
+
usd = estimate_cost(input_tokens, RESERVE_OUTPUT_TOKENS, 0, 0, model)
|
|
797
|
+
return self.tokens_left >= tokens and self.usd_left + 1e-9 >= usd
|
|
798
|
+
|
|
799
|
+
def charge(self, spent: ModelUsage, model: str) -> None:
|
|
800
|
+
tokens = (
|
|
801
|
+
spent.input_tokens
|
|
802
|
+
+ spent.output_tokens
|
|
803
|
+
+ spent.cache_read_input_tokens
|
|
804
|
+
+ spent.cache_write_input_tokens
|
|
805
|
+
)
|
|
806
|
+
self.tokens_left -= tokens
|
|
807
|
+
cost = estimate_cost(
|
|
808
|
+
spent.input_tokens,
|
|
809
|
+
spent.output_tokens,
|
|
810
|
+
spent.cache_read_input_tokens,
|
|
811
|
+
spent.cache_write_input_tokens,
|
|
812
|
+
model,
|
|
813
|
+
)
|
|
814
|
+
self.usd_left -= cost
|
|
815
|
+
self.usage = Usage(
|
|
816
|
+
input_tokens=self.usage.input_tokens + spent.input_tokens,
|
|
817
|
+
output_tokens=self.usage.output_tokens + spent.output_tokens,
|
|
818
|
+
cache_read_input_tokens=self.usage.cache_read_input_tokens
|
|
819
|
+
+ spent.cache_read_input_tokens,
|
|
820
|
+
cache_write_input_tokens=self.usage.cache_write_input_tokens
|
|
821
|
+
+ spent.cache_write_input_tokens,
|
|
822
|
+
estimated_usd=round(self.usage.estimated_usd + cost, 6),
|
|
823
|
+
)
|
|
824
|
+
|
|
825
|
+
|
|
826
|
+
class _Caller:
|
|
827
|
+
def __init__(self, *, client: CurationClient, budget: _Budget, model: str) -> None:
|
|
828
|
+
self.client = client
|
|
829
|
+
self.budget = budget
|
|
830
|
+
self.model = model
|
|
831
|
+
self.stopped = False
|
|
832
|
+
|
|
833
|
+
def call(self, request: CurationRequest) -> JsonObject | None:
|
|
834
|
+
payload = self._attempt(request)
|
|
835
|
+
if payload is None and not self.stopped:
|
|
836
|
+
payload = self._attempt(request)
|
|
837
|
+
return payload
|
|
838
|
+
|
|
839
|
+
def _attempt(self, request: CurationRequest) -> JsonObject | None:
|
|
840
|
+
if self.stopped:
|
|
841
|
+
return None
|
|
842
|
+
counted = self.client.count_tokens(request)
|
|
843
|
+
if not self.budget.allows(counted, self.model):
|
|
844
|
+
self.stopped = True
|
|
845
|
+
return None
|
|
846
|
+
turn = self.client.complete(request)
|
|
847
|
+
self.budget.charge(turn.usage, self.model)
|
|
848
|
+
if turn.stop_reason != "end_turn" or turn.payload is None:
|
|
849
|
+
return None
|
|
850
|
+
return turn.payload
|