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.
@@ -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