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/models.py
ADDED
|
@@ -0,0 +1,614 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import hashlib
|
|
4
|
+
import json
|
|
5
|
+
import math
|
|
6
|
+
import re
|
|
7
|
+
from dataclasses import dataclass
|
|
8
|
+
from enum import StrEnum
|
|
9
|
+
from typing import Annotated, Literal, TypeAlias
|
|
10
|
+
|
|
11
|
+
import yaml
|
|
12
|
+
from pydantic import BaseModel, ConfigDict, Field
|
|
13
|
+
|
|
14
|
+
from mcp4api.model import (
|
|
15
|
+
ApiSpec,
|
|
16
|
+
JsonObject,
|
|
17
|
+
JsonObjectField,
|
|
18
|
+
JsonSchema,
|
|
19
|
+
JsonSchemaField,
|
|
20
|
+
JsonValue,
|
|
21
|
+
JsonValueField,
|
|
22
|
+
Parameter,
|
|
23
|
+
bundle_schema,
|
|
24
|
+
expect_json,
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
ExclusionReason: TypeAlias = Literal[
|
|
28
|
+
"admin",
|
|
29
|
+
"internal",
|
|
30
|
+
"deprecated",
|
|
31
|
+
"duplicate",
|
|
32
|
+
"webhook-management",
|
|
33
|
+
"destructive-bulk",
|
|
34
|
+
"low-value",
|
|
35
|
+
"unsupported",
|
|
36
|
+
"invalid-curation",
|
|
37
|
+
]
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class CurationMode(StrEnum):
|
|
41
|
+
AGENT = "agent"
|
|
42
|
+
HEURISTIC = "heuristic"
|
|
43
|
+
MANUAL = "manual"
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
ParameterIn: TypeAlias = Literal["path", "query", "header", "cookie", "body"]
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
_FROZEN = ConfigDict(frozen=True, populate_by_name=True, extra="forbid")
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
_DECISION = ConfigDict(extra="ignore")
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
_NAME = re.compile(r"^[a-zA-Z0-9_-]{1,64}$")
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
_NAMESPACE = re.compile(r"^[a-zA-Z0-9_-]{1,32}$")
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
_VERSION_TOKEN = re.compile(r"^v\d+$", re.IGNORECASE)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
_HTTP = frozenset({"get", "post", "put", "patch", "delete", "head", "options", "trace"})
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
_SAFE = frozenset({"GET", "HEAD", "OPTIONS"})
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
_IDEMPOTENT = _SAFE | {"PUT", "DELETE"}
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
_NOTES: dict[ExclusionReason, str] = {
|
|
74
|
+
"admin": "Admin operations are excluded unless the matching tag is included.",
|
|
75
|
+
"internal": "Internal operations are excluded unless the matching tag is included.",
|
|
76
|
+
"deprecated": "Deprecated operations are excluded unless the matching tag is included.",
|
|
77
|
+
"webhook-management": "Webhook management is excluded unless the matching tag is included.",
|
|
78
|
+
"destructive-bulk": (
|
|
79
|
+
"Bulk destructive operations are excluded unless the matching tag is included."
|
|
80
|
+
),
|
|
81
|
+
"unsupported": "The endpoint requires an authentication scheme the server cannot send.",
|
|
82
|
+
"invalid-curation": "The tool failed validation and was not emitted.",
|
|
83
|
+
"low-value": "Omitted because it does not map to a primary user intent.",
|
|
84
|
+
"duplicate": "Omitted because another tool already covers it.",
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
class Annotations(BaseModel):
|
|
89
|
+
model_config = _FROZEN
|
|
90
|
+
|
|
91
|
+
readOnlyHint: bool
|
|
92
|
+
destructiveHint: bool
|
|
93
|
+
idempotentHint: bool
|
|
94
|
+
openWorldHint: bool
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
class MapsTo(BaseModel):
|
|
98
|
+
model_config = _FROZEN
|
|
99
|
+
|
|
100
|
+
endpoint_id: str
|
|
101
|
+
param: str
|
|
102
|
+
in_: Annotated[ParameterIn, Field(alias="in")]
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
class ToolInput(BaseModel):
|
|
106
|
+
model_config = _FROZEN
|
|
107
|
+
|
|
108
|
+
name: str
|
|
109
|
+
schema_: Annotated[JsonSchemaField, Field(alias="schema")]
|
|
110
|
+
description: str
|
|
111
|
+
required: bool
|
|
112
|
+
maps_to: MapsTo
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
class FixedParam(BaseModel):
|
|
116
|
+
model_config = _FROZEN
|
|
117
|
+
|
|
118
|
+
endpoint_id: str
|
|
119
|
+
param: str
|
|
120
|
+
in_: Annotated[ParameterIn, Field(alias="in")]
|
|
121
|
+
value: JsonValueField
|
|
122
|
+
reason: str
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
class Bind(BaseModel):
|
|
126
|
+
model_config = _FROZEN
|
|
127
|
+
|
|
128
|
+
param: str
|
|
129
|
+
in_: Annotated[ParameterIn, Field(alias="in")]
|
|
130
|
+
ref: str
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
class Step(BaseModel):
|
|
134
|
+
model_config = _FROZEN
|
|
135
|
+
|
|
136
|
+
endpoint_id: str
|
|
137
|
+
bind: list[Bind]
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
class ToolOutput(BaseModel):
|
|
141
|
+
model_config = _FROZEN
|
|
142
|
+
|
|
143
|
+
from_step: int
|
|
144
|
+
fields: list[str]
|
|
145
|
+
schema_: Annotated[JsonSchemaField, Field(alias="schema")]
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
class Verify(BaseModel):
|
|
149
|
+
model_config = _FROZEN
|
|
150
|
+
|
|
151
|
+
example_args: JsonObjectField
|
|
152
|
+
depends_on: list[str]
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
class Tool(BaseModel):
|
|
156
|
+
model_config = _FROZEN
|
|
157
|
+
|
|
158
|
+
name: str
|
|
159
|
+
title: str
|
|
160
|
+
description: str
|
|
161
|
+
intent: str
|
|
162
|
+
annotations: Annotations
|
|
163
|
+
inputs: list[ToolInput]
|
|
164
|
+
fixed: list[FixedParam]
|
|
165
|
+
steps: list[Step]
|
|
166
|
+
output: ToolOutput
|
|
167
|
+
verify: Verify
|
|
168
|
+
rationale: str
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
class Excluded(BaseModel):
|
|
172
|
+
model_config = _FROZEN
|
|
173
|
+
|
|
174
|
+
endpoint_id: str
|
|
175
|
+
reason: ExclusionReason
|
|
176
|
+
note: str
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
class ApiInfo(BaseModel):
|
|
180
|
+
model_config = _FROZEN
|
|
181
|
+
|
|
182
|
+
title: str
|
|
183
|
+
base_url: str
|
|
184
|
+
namespace: str
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
class Usage(BaseModel):
|
|
188
|
+
model_config = _FROZEN
|
|
189
|
+
|
|
190
|
+
input_tokens: int = 0
|
|
191
|
+
output_tokens: int = 0
|
|
192
|
+
cache_read_input_tokens: int = 0
|
|
193
|
+
cache_write_input_tokens: int = 0
|
|
194
|
+
estimated_usd: float = 0
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
class CurationInfo(BaseModel):
|
|
198
|
+
model_config = _FROZEN
|
|
199
|
+
|
|
200
|
+
mode: CurationMode
|
|
201
|
+
model: str
|
|
202
|
+
input_hash: str
|
|
203
|
+
usage: Usage
|
|
204
|
+
fallback_tools: list[str]
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
class Manifest(BaseModel):
|
|
208
|
+
model_config = _FROZEN
|
|
209
|
+
|
|
210
|
+
api: ApiInfo
|
|
211
|
+
tools: list[Tool]
|
|
212
|
+
excluded: list[Excluded]
|
|
213
|
+
curation: CurationInfo
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
class AnnotationDraft(BaseModel):
|
|
217
|
+
model_config = _DECISION
|
|
218
|
+
|
|
219
|
+
readOnlyHint: bool
|
|
220
|
+
destructiveHint: bool
|
|
221
|
+
idempotentHint: bool
|
|
222
|
+
openWorldHint: bool
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
class InputDraft(BaseModel):
|
|
226
|
+
model_config = _DECISION
|
|
227
|
+
|
|
228
|
+
name: str
|
|
229
|
+
description: str
|
|
230
|
+
required: bool
|
|
231
|
+
endpoint_id: str
|
|
232
|
+
param: str
|
|
233
|
+
in_: Annotated[str, Field(alias="in")]
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
class FixedDraft(BaseModel):
|
|
237
|
+
model_config = _DECISION
|
|
238
|
+
|
|
239
|
+
endpoint_id: str
|
|
240
|
+
param: str
|
|
241
|
+
in_: Annotated[str, Field(alias="in")]
|
|
242
|
+
value_json: str
|
|
243
|
+
reason: str
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
class BindDraft(BaseModel):
|
|
247
|
+
model_config = _DECISION
|
|
248
|
+
|
|
249
|
+
param: str
|
|
250
|
+
in_: Annotated[str, Field(alias="in")]
|
|
251
|
+
ref: str
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
class StepDraft(BaseModel):
|
|
255
|
+
model_config = _DECISION
|
|
256
|
+
|
|
257
|
+
endpoint_id: str
|
|
258
|
+
binds: list[BindDraft]
|
|
259
|
+
|
|
260
|
+
|
|
261
|
+
class ExampleDraft(BaseModel):
|
|
262
|
+
model_config = _DECISION
|
|
263
|
+
|
|
264
|
+
name: str
|
|
265
|
+
value_json: str
|
|
266
|
+
|
|
267
|
+
|
|
268
|
+
class ToolDraft(BaseModel):
|
|
269
|
+
model_config = _DECISION
|
|
270
|
+
|
|
271
|
+
name: str
|
|
272
|
+
title: str
|
|
273
|
+
description: str
|
|
274
|
+
intent: str
|
|
275
|
+
annotations: AnnotationDraft
|
|
276
|
+
inputs: list[InputDraft]
|
|
277
|
+
fixed: list[FixedDraft]
|
|
278
|
+
steps: list[StepDraft]
|
|
279
|
+
output_from_step: int
|
|
280
|
+
output_fields: list[str]
|
|
281
|
+
examples: list[ExampleDraft]
|
|
282
|
+
depends_on: list[str]
|
|
283
|
+
rationale: str
|
|
284
|
+
|
|
285
|
+
|
|
286
|
+
class PlanExclusion(BaseModel):
|
|
287
|
+
model_config = _DECISION
|
|
288
|
+
|
|
289
|
+
endpoint_id: str
|
|
290
|
+
reason: str
|
|
291
|
+
note: str
|
|
292
|
+
|
|
293
|
+
|
|
294
|
+
class PlanTool(BaseModel):
|
|
295
|
+
model_config = _DECISION
|
|
296
|
+
|
|
297
|
+
name: str
|
|
298
|
+
endpoint_ids: list[str]
|
|
299
|
+
rationale: str
|
|
300
|
+
|
|
301
|
+
|
|
302
|
+
class PlanDecision(BaseModel):
|
|
303
|
+
model_config = _DECISION
|
|
304
|
+
|
|
305
|
+
tools: list[PlanTool]
|
|
306
|
+
excluded: list[PlanExclusion]
|
|
307
|
+
|
|
308
|
+
|
|
309
|
+
class DetailDecision(BaseModel):
|
|
310
|
+
model_config = _DECISION
|
|
311
|
+
|
|
312
|
+
tools: list[ToolDraft]
|
|
313
|
+
excluded: list[PlanExclusion]
|
|
314
|
+
|
|
315
|
+
|
|
316
|
+
class ExcludeOverride(BaseModel):
|
|
317
|
+
model_config = _DECISION
|
|
318
|
+
|
|
319
|
+
endpoint_id: str
|
|
320
|
+
reason: str
|
|
321
|
+
note: str = ""
|
|
322
|
+
|
|
323
|
+
|
|
324
|
+
class FixedOverride(BaseModel):
|
|
325
|
+
model_config = _DECISION
|
|
326
|
+
|
|
327
|
+
tool: str
|
|
328
|
+
endpoint_id: str
|
|
329
|
+
param: str
|
|
330
|
+
in_: Annotated[ParameterIn, Field(alias="in")]
|
|
331
|
+
value: JsonValueField
|
|
332
|
+
reason: str
|
|
333
|
+
|
|
334
|
+
|
|
335
|
+
class Overrides(BaseModel):
|
|
336
|
+
model_config = _DECISION
|
|
337
|
+
|
|
338
|
+
renames: dict[str, str] = Field(default_factory=dict)
|
|
339
|
+
descriptions: dict[str, str] = Field(default_factory=dict)
|
|
340
|
+
exclude_endpoints: list[ExcludeOverride] = Field(default_factory=list)
|
|
341
|
+
include_endpoints: list[str] = Field(default_factory=list)
|
|
342
|
+
fixed: list[FixedOverride] = Field(default_factory=list)
|
|
343
|
+
outputs: dict[str, list[str]] = Field(default_factory=dict)
|
|
344
|
+
|
|
345
|
+
|
|
346
|
+
@dataclass(frozen=True)
|
|
347
|
+
class PlanResult:
|
|
348
|
+
tools: tuple[PlanTool, ...]
|
|
349
|
+
excluded: tuple[Excluded, ...]
|
|
350
|
+
uncovered: tuple[str, ...]
|
|
351
|
+
errors: tuple[str, ...]
|
|
352
|
+
|
|
353
|
+
|
|
354
|
+
@dataclass(frozen=True)
|
|
355
|
+
class DetailResult:
|
|
356
|
+
tools: tuple[Tool, ...]
|
|
357
|
+
excluded: tuple[Excluded, ...]
|
|
358
|
+
errors: tuple[str, ...]
|
|
359
|
+
|
|
360
|
+
|
|
361
|
+
def zero_usage() -> Usage:
|
|
362
|
+
return Usage()
|
|
363
|
+
|
|
364
|
+
|
|
365
|
+
def base_url(spec: ApiSpec) -> str:
|
|
366
|
+
if not spec.servers:
|
|
367
|
+
return ""
|
|
368
|
+
server = spec.servers[0]
|
|
369
|
+
url = server.url
|
|
370
|
+
for name, variable in (server.variables or {}).items():
|
|
371
|
+
url = url.replace("{" + name + "}", variable.default)
|
|
372
|
+
return url
|
|
373
|
+
|
|
374
|
+
|
|
375
|
+
def minor_version(version: str) -> str:
|
|
376
|
+
numbers: list[str] = []
|
|
377
|
+
for part in version.split(".")[:2]:
|
|
378
|
+
digits = ""
|
|
379
|
+
for char in part:
|
|
380
|
+
if not char.isdigit():
|
|
381
|
+
break
|
|
382
|
+
digits += char
|
|
383
|
+
numbers.append(digits or "0")
|
|
384
|
+
if len(numbers) < 2:
|
|
385
|
+
numbers.append("0")
|
|
386
|
+
return ".".join(numbers)
|
|
387
|
+
|
|
388
|
+
|
|
389
|
+
def canonical_flags(
|
|
390
|
+
*,
|
|
391
|
+
mode: str,
|
|
392
|
+
include_tags: tuple[str, ...],
|
|
393
|
+
max_tools: int,
|
|
394
|
+
namespace: str,
|
|
395
|
+
) -> str:
|
|
396
|
+
tags = ",".join(sorted(include_tags))
|
|
397
|
+
return f"curation={mode};include_tag={tags};max_tools={max_tools};namespace={namespace}"
|
|
398
|
+
|
|
399
|
+
|
|
400
|
+
def curation_hash(
|
|
401
|
+
endpoints: bytes,
|
|
402
|
+
*,
|
|
403
|
+
prompt: str,
|
|
404
|
+
model: str,
|
|
405
|
+
mode: str,
|
|
406
|
+
include_tags: tuple[str, ...],
|
|
407
|
+
max_tools: int,
|
|
408
|
+
namespace: str,
|
|
409
|
+
version: str,
|
|
410
|
+
) -> str:
|
|
411
|
+
payload = b"\0".join(
|
|
412
|
+
(
|
|
413
|
+
endpoints,
|
|
414
|
+
prompt.encode("utf-8"),
|
|
415
|
+
model.encode("utf-8"),
|
|
416
|
+
minor_version(version).encode("utf-8"),
|
|
417
|
+
canonical_flags(
|
|
418
|
+
mode=mode,
|
|
419
|
+
include_tags=include_tags,
|
|
420
|
+
max_tools=max_tools,
|
|
421
|
+
namespace=namespace,
|
|
422
|
+
).encode("utf-8"),
|
|
423
|
+
)
|
|
424
|
+
)
|
|
425
|
+
return hashlib.sha256(payload).hexdigest()
|
|
426
|
+
|
|
427
|
+
|
|
428
|
+
def namespace_error(namespace: str) -> str | None:
|
|
429
|
+
if namespace and _NAMESPACE.fullmatch(namespace) is None:
|
|
430
|
+
return f"namespace {namespace} is not allowed"
|
|
431
|
+
return None
|
|
432
|
+
|
|
433
|
+
|
|
434
|
+
def name_error(name: str, *, namespace: str = "") -> str | None:
|
|
435
|
+
if _NAME.fullmatch(name) is None:
|
|
436
|
+
return f"tool name {name} is not allowed"
|
|
437
|
+
short = name
|
|
438
|
+
verb_at = 0
|
|
439
|
+
if namespace and name.startswith(f"{namespace}_"):
|
|
440
|
+
short = name[len(namespace) + 1 :]
|
|
441
|
+
verb_at = len([token for token in re.split(r"[_-]", namespace) if token])
|
|
442
|
+
if short.startswith("api_"):
|
|
443
|
+
return f"tool name {name} uses an api_ prefix"
|
|
444
|
+
tokens = [token for token in re.split(r"[_-]", name) if token]
|
|
445
|
+
for index, token in enumerate(tokens):
|
|
446
|
+
if _VERSION_TOKEN.fullmatch(token):
|
|
447
|
+
return f"tool name {name} contains a version segment"
|
|
448
|
+
lowered = token.lower()
|
|
449
|
+
if lowered in _HTTP and not (index == verb_at and lowered in {"get", "delete"}):
|
|
450
|
+
return f"tool name {name} contains an HTTP verb"
|
|
451
|
+
return None
|
|
452
|
+
|
|
453
|
+
|
|
454
|
+
def apply_namespace(name: str, namespace: str) -> str:
|
|
455
|
+
if not namespace:
|
|
456
|
+
return name
|
|
457
|
+
prefix = f"{namespace}_"
|
|
458
|
+
short = name[len(prefix) :] if name.startswith(prefix) else name
|
|
459
|
+
return f"{prefix}{short}"
|
|
460
|
+
|
|
461
|
+
|
|
462
|
+
def annotations_for(methods: list[str]) -> Annotations:
|
|
463
|
+
upper = [method.upper() for method in methods] or ["GET"]
|
|
464
|
+
return Annotations(
|
|
465
|
+
readOnlyHint=all(method in _SAFE for method in upper),
|
|
466
|
+
destructiveHint=any(method == "DELETE" for method in upper),
|
|
467
|
+
idempotentHint=all(method in _IDEMPOTENT for method in upper),
|
|
468
|
+
openWorldHint=True,
|
|
469
|
+
)
|
|
470
|
+
|
|
471
|
+
|
|
472
|
+
def parse_reason(value: str) -> ExclusionReason | None:
|
|
473
|
+
if value == "admin":
|
|
474
|
+
return "admin"
|
|
475
|
+
if value == "internal":
|
|
476
|
+
return "internal"
|
|
477
|
+
if value == "deprecated":
|
|
478
|
+
return "deprecated"
|
|
479
|
+
if value == "duplicate":
|
|
480
|
+
return "duplicate"
|
|
481
|
+
if value == "webhook-management":
|
|
482
|
+
return "webhook-management"
|
|
483
|
+
if value == "destructive-bulk":
|
|
484
|
+
return "destructive-bulk"
|
|
485
|
+
if value == "low-value":
|
|
486
|
+
return "low-value"
|
|
487
|
+
if value == "unsupported":
|
|
488
|
+
return "unsupported"
|
|
489
|
+
if value == "invalid-curation":
|
|
490
|
+
return "invalid-curation"
|
|
491
|
+
return None
|
|
492
|
+
|
|
493
|
+
|
|
494
|
+
def parse_location(value: str) -> ParameterIn | None:
|
|
495
|
+
if value == "path":
|
|
496
|
+
return "path"
|
|
497
|
+
if value == "query":
|
|
498
|
+
return "query"
|
|
499
|
+
if value == "header":
|
|
500
|
+
return "header"
|
|
501
|
+
if value == "cookie":
|
|
502
|
+
return "cookie"
|
|
503
|
+
if value == "body":
|
|
504
|
+
return "body"
|
|
505
|
+
return None
|
|
506
|
+
|
|
507
|
+
|
|
508
|
+
def bundle_parameter(spec: ApiSpec, parameter: Parameter) -> JsonSchema:
|
|
509
|
+
return bundle_schema(spec, parameter.schema_)
|
|
510
|
+
|
|
511
|
+
|
|
512
|
+
def dump_manifest(manifest: Manifest) -> str:
|
|
513
|
+
ordered = manifest.model_copy(
|
|
514
|
+
update={
|
|
515
|
+
"tools": sorted(manifest.tools, key=lambda tool: tool.name),
|
|
516
|
+
"excluded": sorted(manifest.excluded, key=lambda item: item.endpoint_id),
|
|
517
|
+
}
|
|
518
|
+
)
|
|
519
|
+
payload = _object(ordered.model_dump(mode="json", by_alias=True))
|
|
520
|
+
text = yaml.safe_dump(payload, sort_keys=False, allow_unicode=True, width=4096)
|
|
521
|
+
if not text.endswith("\n"):
|
|
522
|
+
text += "\n"
|
|
523
|
+
return text.replace("\r\n", "\n").replace("\r", "\n")
|
|
524
|
+
|
|
525
|
+
|
|
526
|
+
def load_manifest(text: str) -> Manifest:
|
|
527
|
+
return Manifest.model_validate(_yaml_object(text))
|
|
528
|
+
|
|
529
|
+
|
|
530
|
+
def load_overrides(text: str) -> Overrides:
|
|
531
|
+
if not text.strip():
|
|
532
|
+
return Overrides()
|
|
533
|
+
return Overrides.model_validate(_yaml_object(text))
|
|
534
|
+
|
|
535
|
+
|
|
536
|
+
def build_manifest(
|
|
537
|
+
spec: ApiSpec,
|
|
538
|
+
tools: list[Tool],
|
|
539
|
+
excluded: list[Excluded],
|
|
540
|
+
*,
|
|
541
|
+
namespace: str,
|
|
542
|
+
mode: CurationMode,
|
|
543
|
+
model: str,
|
|
544
|
+
digest: str,
|
|
545
|
+
usage: Usage,
|
|
546
|
+
fallback_tools: list[str],
|
|
547
|
+
max_tools: int,
|
|
548
|
+
) -> Manifest:
|
|
549
|
+
ready: list[Tool] = []
|
|
550
|
+
over_target = len(tools) > max_tools
|
|
551
|
+
for tool in tools:
|
|
552
|
+
rationale = tool.rationale.strip()
|
|
553
|
+
if over_target and not rationale:
|
|
554
|
+
rationale = "Exceeds the tool target."
|
|
555
|
+
elif not rationale:
|
|
556
|
+
rationale = tool.rationale
|
|
557
|
+
ready.append(tool.model_copy(update={"rationale": rationale or "Curated tool."}))
|
|
558
|
+
claimed: set[str] = set()
|
|
559
|
+
unique_excluded: list[Excluded] = []
|
|
560
|
+
for item in excluded:
|
|
561
|
+
if item.endpoint_id in claimed:
|
|
562
|
+
continue
|
|
563
|
+
claimed.add(item.endpoint_id)
|
|
564
|
+
unique_excluded.append(item)
|
|
565
|
+
return Manifest(
|
|
566
|
+
api=ApiInfo(title=spec.title, base_url=base_url(spec), namespace=namespace),
|
|
567
|
+
tools=ready,
|
|
568
|
+
excluded=unique_excluded,
|
|
569
|
+
curation=CurationInfo(
|
|
570
|
+
mode=mode,
|
|
571
|
+
model=model,
|
|
572
|
+
input_hash=digest,
|
|
573
|
+
usage=usage,
|
|
574
|
+
fallback_tools=sorted(set(fallback_tools)),
|
|
575
|
+
),
|
|
576
|
+
)
|
|
577
|
+
|
|
578
|
+
|
|
579
|
+
def invalid_exclusions(endpoint_ids: list[str], note: str) -> list[Excluded]:
|
|
580
|
+
text = note.strip() or _NOTES["invalid-curation"]
|
|
581
|
+
return [
|
|
582
|
+
Excluded(endpoint_id=endpoint_id, reason="invalid-curation", note=text)
|
|
583
|
+
for endpoint_id in endpoint_ids
|
|
584
|
+
]
|
|
585
|
+
|
|
586
|
+
|
|
587
|
+
def parse_scalar(text: str) -> tuple[JsonValue, str | None]:
|
|
588
|
+
try:
|
|
589
|
+
loaded = json.loads(text)
|
|
590
|
+
except json.JSONDecodeError:
|
|
591
|
+
return None, "fixed value is not JSON"
|
|
592
|
+
try:
|
|
593
|
+
parsed = expect_json(loaded)
|
|
594
|
+
except TypeError:
|
|
595
|
+
return None, "fixed value must be a scalar"
|
|
596
|
+
if isinstance(parsed, float) and not math.isfinite(parsed):
|
|
597
|
+
return None, "fixed value is not a finite number"
|
|
598
|
+
if parsed is None or isinstance(parsed, (str, bool, int)):
|
|
599
|
+
return parsed, None
|
|
600
|
+
if isinstance(parsed, float):
|
|
601
|
+
return parsed, None
|
|
602
|
+
return None, "fixed value must be a scalar"
|
|
603
|
+
|
|
604
|
+
|
|
605
|
+
def _yaml_object(text: str) -> JsonObject:
|
|
606
|
+
loaded = yaml.safe_load(text)
|
|
607
|
+
return _object(loaded)
|
|
608
|
+
|
|
609
|
+
|
|
610
|
+
def _object(value: object) -> JsonObject:
|
|
611
|
+
parsed = expect_json(value)
|
|
612
|
+
if isinstance(parsed, dict):
|
|
613
|
+
return parsed
|
|
614
|
+
raise ValueError("YAML must be an object")
|