crewforge 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.
- crewforge/__init__.py +3 -0
- crewforge/__main__.py +4 -0
- crewforge/cli.py +128 -0
- crewforge/cli_support.py +109 -0
- crewforge/core/__init__.py +54 -0
- crewforge/core/agent.py +118 -0
- crewforge/core/client.py +253 -0
- crewforge/core/config.py +181 -0
- crewforge/core/crew.py +144 -0
- crewforge/core/errors.py +106 -0
- crewforge/core/parsing.py +110 -0
- crewforge/core/providers.py +131 -0
- crewforge/core/usage.py +163 -0
- crewforge/py.typed +0 -0
- crewforge/recipes/__init__.py +1 -0
- crewforge/recipes/api/__init__.py +38 -0
- crewforge/recipes/api/agents.py +523 -0
- crewforge/recipes/api/analytics.py +99 -0
- crewforge/recipes/api/appcheck.py +73 -0
- crewforge/recipes/api/cli.py +127 -0
- crewforge/recipes/api/formatters.py +32 -0
- crewforge/recipes/api/generator.py +37 -0
- crewforge/recipes/api/pipeline.py +86 -0
- crewforge/recipes/api/scaffold.py +68 -0
- crewforge/recipes/api/templates/docker-compose.j2 +9 -0
- crewforge/recipes/api/templates/dockerfile.j2 +7 -0
- crewforge/recipes/api/templates/env.example.j2 +2 -0
- crewforge/recipes/api/templates/fastapi_main.j2 +10 -0
- crewforge/recipes/api/templates/model.j2 +4 -0
- crewforge/recipes/api/templates/requirements.j2 +6 -0
- crewforge/recipes/api/templates/route.j2 +4 -0
- crewforge/recipes/api/templates/test.j2 +3 -0
- crewforge/recipes/api/utils.py +134 -0
- crewforge/recipes/content/__init__.py +20 -0
- crewforge/recipes/content/agents.py +735 -0
- crewforge/recipes/content/cli.py +128 -0
- crewforge/recipes/content/export.py +84 -0
- crewforge/recipes/content/pipeline.py +222 -0
- crewforge/recipes/content/settings.py +63 -0
- crewforge/recipes/content/text.py +57 -0
- crewforge/recipes/docs/__init__.py +48 -0
- crewforge/recipes/docs/agents.py +426 -0
- crewforge/recipes/docs/cli.py +156 -0
- crewforge/recipes/docs/export.py +118 -0
- crewforge/recipes/docs/index.py +117 -0
- crewforge/recipes/docs/ingest.py +212 -0
- crewforge/recipes/docs/pipeline.py +223 -0
- crewforge/recipes/docs/retrieve.py +70 -0
- crewforge/recipes/docs/settings.py +54 -0
- crewforge/recipes/review/__init__.py +1 -0
- crewforge/recipes/review/agents.py +331 -0
- crewforge/recipes/review/cli.py +144 -0
- crewforge/recipes/review/pipeline.py +125 -0
- crewforge/recipes/review/report.py +103 -0
- crewforge/recipes/review/sources.py +89 -0
- crewforge-0.1.0.dist-info/METADATA +372 -0
- crewforge-0.1.0.dist-info/RECORD +61 -0
- crewforge-0.1.0.dist-info/WHEEL +5 -0
- crewforge-0.1.0.dist-info/entry_points.txt +2 -0
- crewforge-0.1.0.dist-info/licenses/LICENSE +21 -0
- crewforge-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,523 @@
|
|
|
1
|
+
"""The api crew: parse a description, then build spec, routes, models, auth, tests and docs.
|
|
2
|
+
|
|
3
|
+
Deterministic agents (schema, test_gen, validator) never call the LLM. The LLM
|
|
4
|
+
agents return code only through a fenced block that must compile, and the
|
|
5
|
+
parser must return the documented JSON shape with at least one endpoint;
|
|
6
|
+
anything else fails the run naming the agent (apiforge used to fall back to
|
|
7
|
+
an empty description that a later agent filled with invented routes).
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
from dataclasses import dataclass, field
|
|
13
|
+
from typing import Any
|
|
14
|
+
|
|
15
|
+
from ...core.agent import Agent
|
|
16
|
+
from ...core.client import LLMClient
|
|
17
|
+
from ...core.errors import OutputParseError
|
|
18
|
+
from ...core.parsing import extract_code_block
|
|
19
|
+
from ...core.usage import UsageTracker
|
|
20
|
+
|
|
21
|
+
HTTP_METHODS = ("GET", "POST", "PUT", "PATCH", "DELETE")
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@dataclass
|
|
25
|
+
class ParsedEndpoint:
|
|
26
|
+
path: str
|
|
27
|
+
method: str
|
|
28
|
+
description: str
|
|
29
|
+
parameters: list[dict[str, Any]] = field(default_factory=list)
|
|
30
|
+
request_body: dict[str, Any] | None = None
|
|
31
|
+
response_schema: dict[str, Any] = field(default_factory=dict)
|
|
32
|
+
tags: list[str] = field(default_factory=list)
|
|
33
|
+
auth_required: bool = True
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@dataclass
|
|
37
|
+
class APIDescription:
|
|
38
|
+
title: str
|
|
39
|
+
description: str
|
|
40
|
+
version: str = "1.0.0"
|
|
41
|
+
base_path: str = "/api/v1"
|
|
42
|
+
endpoints: list[ParsedEndpoint] = field(default_factory=list)
|
|
43
|
+
auth_type: str = "bearer"
|
|
44
|
+
models: list[dict[str, Any]] = field(default_factory=list)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class ApiAgent(Agent):
|
|
48
|
+
"""Base for the api agents (keeps apiforge's ``run(**kwargs)`` entry point)."""
|
|
49
|
+
|
|
50
|
+
def __init__(
|
|
51
|
+
self, client: LLMClient | None = None, tracker: UsageTracker | None = None, **kwargs: Any
|
|
52
|
+
) -> None:
|
|
53
|
+
super().__init__(client, tracker, **kwargs) # type: ignore[arg-type]
|
|
54
|
+
|
|
55
|
+
async def run(self, **kwargs: Any) -> Any: # pragma: no cover - abstract
|
|
56
|
+
raise NotImplementedError
|
|
57
|
+
|
|
58
|
+
def code_from(self, content: str) -> str:
|
|
59
|
+
"""The fenced Python block of a reply; it must exist and compile."""
|
|
60
|
+
code = extract_code_block(content, "python")
|
|
61
|
+
if code is None or not code.strip():
|
|
62
|
+
raise OutputParseError(self.name, "no ```python code block in the reply", content[:120])
|
|
63
|
+
try:
|
|
64
|
+
compile(code, f"<{self.name}>", "exec")
|
|
65
|
+
except SyntaxError as exc:
|
|
66
|
+
raise OutputParseError(
|
|
67
|
+
self.name, f"generated code does not compile: {exc.msg} (line {exc.lineno})"
|
|
68
|
+
) from exc
|
|
69
|
+
return code.strip() + "\n"
|
|
70
|
+
|
|
71
|
+
async def review_code(self, code: str, instructions: str) -> str:
|
|
72
|
+
response = await self.ask(
|
|
73
|
+
f"{instructions}\nReturn the complete file in ONE ```python code block.\n"
|
|
74
|
+
f"```python\n{code}\n```"
|
|
75
|
+
)
|
|
76
|
+
return self.code_from(response.content)
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
# ------------------------------------------------------------------------- parser
|
|
80
|
+
|
|
81
|
+
_SCHEMA_HINT = """Reply with ONE JSON object and nothing else (no prose, no markdown).
|
|
82
|
+
Use exactly this shape:
|
|
83
|
+
{
|
|
84
|
+
"title": "string",
|
|
85
|
+
"description": "string",
|
|
86
|
+
"version": "1.0.0",
|
|
87
|
+
"base_path": "/api/v1",
|
|
88
|
+
"auth_type": "bearer" | "api_key" | "none",
|
|
89
|
+
"endpoints": [
|
|
90
|
+
{
|
|
91
|
+
"path": "/resource/{id}",
|
|
92
|
+
"method": "GET" | "POST" | "PUT" | "PATCH" | "DELETE",
|
|
93
|
+
"description": "string",
|
|
94
|
+
"parameters": [
|
|
95
|
+
{"name": "id", "in": "path" | "query",
|
|
96
|
+
"type": "string" | "integer" | "boolean", "required": true}
|
|
97
|
+
],
|
|
98
|
+
"request_body": {"field": "type"} | null,
|
|
99
|
+
"response_schema": {"field": "type"},
|
|
100
|
+
"tags": ["string"],
|
|
101
|
+
"auth_required": true
|
|
102
|
+
}
|
|
103
|
+
],
|
|
104
|
+
"models": [
|
|
105
|
+
{"name": "Resource", "fields": [{"name": "id", "type": "string", "required": true}]}
|
|
106
|
+
]
|
|
107
|
+
}
|
|
108
|
+
Paths are relative to base_path. Include every endpoint the user describes."""
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
class ParserAgent(ApiAgent):
|
|
112
|
+
name = "parser"
|
|
113
|
+
description = "Turns a natural-language API description into a structured spec"
|
|
114
|
+
system_prompt = (
|
|
115
|
+
"You are the parser of an API scaffolding crew. Turn a natural-language API "
|
|
116
|
+
"description into a structured JSON spec.\n" + _SCHEMA_HINT
|
|
117
|
+
)
|
|
118
|
+
temperature = 0.0
|
|
119
|
+
|
|
120
|
+
def to_description(self, data: dict[str, Any]) -> APIDescription:
|
|
121
|
+
endpoints: list[ParsedEndpoint] = []
|
|
122
|
+
for i, ep in enumerate(data.get("endpoints") or []):
|
|
123
|
+
if not isinstance(ep, dict) or not ep.get("path"):
|
|
124
|
+
raise OutputParseError(self.name, f"endpoint #{i + 1} has no path")
|
|
125
|
+
method = str(ep.get("method", "GET")).upper()
|
|
126
|
+
if method not in HTTP_METHODS:
|
|
127
|
+
raise OutputParseError(self.name, f"endpoint {ep['path']} has method {method!r}")
|
|
128
|
+
path = "/" + str(ep["path"]).lstrip("/")
|
|
129
|
+
endpoints.append(
|
|
130
|
+
ParsedEndpoint(
|
|
131
|
+
path=path,
|
|
132
|
+
method=method,
|
|
133
|
+
description=str(ep.get("description") or f"{method} {path}"),
|
|
134
|
+
parameters=list(ep.get("parameters") or []),
|
|
135
|
+
request_body=ep.get("request_body") or None,
|
|
136
|
+
response_schema=ep.get("response_schema") or {},
|
|
137
|
+
tags=list(ep.get("tags") or []),
|
|
138
|
+
auth_required=bool(ep.get("auth_required", True)),
|
|
139
|
+
)
|
|
140
|
+
)
|
|
141
|
+
if not endpoints:
|
|
142
|
+
raise OutputParseError(self.name, "the spec has no endpoints")
|
|
143
|
+
return APIDescription(
|
|
144
|
+
title=str(data.get("title") or "API"),
|
|
145
|
+
description=str(data.get("description") or ""),
|
|
146
|
+
version=str(data.get("version") or "1.0.0"),
|
|
147
|
+
base_path="/" + str(data.get("base_path") or "/api/v1").strip("/"),
|
|
148
|
+
endpoints=endpoints,
|
|
149
|
+
auth_type=str(data.get("auth_type") or "bearer"),
|
|
150
|
+
models=[m for m in data.get("models") or [] if isinstance(m, dict) and m.get("name")],
|
|
151
|
+
)
|
|
152
|
+
|
|
153
|
+
async def parse(self, description: str) -> APIDescription:
|
|
154
|
+
data = await self.ask_json(
|
|
155
|
+
"Parse this API description into the JSON shape above:\n" + description
|
|
156
|
+
)
|
|
157
|
+
return self.to_description(data)
|
|
158
|
+
|
|
159
|
+
async def run(self, **kwargs: Any) -> APIDescription:
|
|
160
|
+
return await self.parse(kwargs["description"])
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
# --------------------------------------------------------------- deterministic
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def _func_name(method: str, path: str) -> str:
|
|
167
|
+
stem = path.strip("/").replace("/", "_").replace("{", "").replace("}", "").replace("-", "_")
|
|
168
|
+
return f"{method.lower()}_{stem or 'root'}"
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
class SchemaAgent(ApiAgent):
|
|
172
|
+
"""Builds the OpenAPI 3.1 document from the parsed description (no LLM call)."""
|
|
173
|
+
|
|
174
|
+
name = "schema"
|
|
175
|
+
description = "Builds the OpenAPI 3.1 spec (deterministic)"
|
|
176
|
+
|
|
177
|
+
def _build_paths(self, desc: APIDescription) -> dict[str, Any]:
|
|
178
|
+
paths: dict[str, Any] = {}
|
|
179
|
+
for ep in desc.endpoints:
|
|
180
|
+
op: dict[str, Any] = {
|
|
181
|
+
"summary": ep.description,
|
|
182
|
+
"operationId": _func_name(ep.method, ep.path),
|
|
183
|
+
"tags": ep.tags or ["default"],
|
|
184
|
+
"responses": {
|
|
185
|
+
"200": {
|
|
186
|
+
"description": "Success",
|
|
187
|
+
"content": {
|
|
188
|
+
"application/json": {"schema": ep.response_schema or {"type": "object"}}
|
|
189
|
+
},
|
|
190
|
+
}
|
|
191
|
+
},
|
|
192
|
+
}
|
|
193
|
+
if ep.parameters:
|
|
194
|
+
op["parameters"] = [
|
|
195
|
+
{
|
|
196
|
+
"name": p.get("name"),
|
|
197
|
+
"in": p.get("in", "query"),
|
|
198
|
+
"required": p.get("required", p.get("in") == "path"),
|
|
199
|
+
"schema": {"type": p.get("type", "string")},
|
|
200
|
+
}
|
|
201
|
+
for p in ep.parameters
|
|
202
|
+
]
|
|
203
|
+
if ep.request_body and ep.method in ("POST", "PUT", "PATCH"):
|
|
204
|
+
op["requestBody"] = {
|
|
205
|
+
"required": True,
|
|
206
|
+
"content": {"application/json": {"schema": ep.request_body}},
|
|
207
|
+
}
|
|
208
|
+
if ep.auth_required and desc.auth_type != "none":
|
|
209
|
+
op["security"] = [{"bearerAuth": []}]
|
|
210
|
+
paths.setdefault(ep.path, {})[ep.method.lower()] = op
|
|
211
|
+
return paths
|
|
212
|
+
|
|
213
|
+
def _build_schemas(self, desc: APIDescription) -> dict[str, Any]:
|
|
214
|
+
schemas: dict[str, Any] = {}
|
|
215
|
+
for m in desc.models:
|
|
216
|
+
props, req = {}, []
|
|
217
|
+
for f in m.get("fields", []):
|
|
218
|
+
props[f["name"]] = {"type": f.get("type", "string")}
|
|
219
|
+
if f.get("required"):
|
|
220
|
+
req.append(f["name"])
|
|
221
|
+
schemas[m["name"]] = {"type": "object", "properties": props, "required": req}
|
|
222
|
+
return schemas
|
|
223
|
+
|
|
224
|
+
async def generate_spec(self, desc: APIDescription) -> dict[str, Any]:
|
|
225
|
+
return {
|
|
226
|
+
"openapi": "3.1.0",
|
|
227
|
+
"info": {"title": desc.title, "description": desc.description, "version": desc.version},
|
|
228
|
+
"servers": [{"url": desc.base_path}],
|
|
229
|
+
"paths": self._build_paths(desc),
|
|
230
|
+
"components": {
|
|
231
|
+
"schemas": self._build_schemas(desc),
|
|
232
|
+
"securitySchemes": {"bearerAuth": {"type": "http", "scheme": "bearer"}},
|
|
233
|
+
},
|
|
234
|
+
"security": [{"bearerAuth": []}],
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
async def run(self, **kwargs: Any) -> dict[str, Any]:
|
|
238
|
+
return await self.generate_spec(kwargs["description"])
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
class TestAgent(ApiAgent):
|
|
242
|
+
"""Writes a pytest smoke test per endpoint (no LLM call)."""
|
|
243
|
+
|
|
244
|
+
__test__ = False # not a pytest test class despite the name
|
|
245
|
+
name = "test_gen"
|
|
246
|
+
description = "Writes pytest smoke tests for every endpoint (deterministic)"
|
|
247
|
+
|
|
248
|
+
@staticmethod
|
|
249
|
+
def _sample_path(desc: APIDescription, path: str) -> str:
|
|
250
|
+
full = desc.base_path.rstrip("/") + path
|
|
251
|
+
for part in [s for s in full.split("/") if s.startswith("{")]:
|
|
252
|
+
full = full.replace(part, "1")
|
|
253
|
+
return full
|
|
254
|
+
|
|
255
|
+
async def generate_tests(self, desc: APIDescription) -> str:
|
|
256
|
+
lines = ['"""Auto-generated smoke tests (crewforge)."""', ""]
|
|
257
|
+
for ep in desc.endpoints:
|
|
258
|
+
method = ep.method.lower()
|
|
259
|
+
url = self._sample_path(desc, ep.path)
|
|
260
|
+
lines.append(f"def test_{_func_name(ep.method, ep.path)}(client):")
|
|
261
|
+
lines.append(
|
|
262
|
+
f' """{ep.method} {ep.path} answers (auth or validation errors allowed)."""'
|
|
263
|
+
)
|
|
264
|
+
if ep.method == "GET" or ep.method == "DELETE":
|
|
265
|
+
lines.append(f' resp = client.{method}("{url}")')
|
|
266
|
+
else:
|
|
267
|
+
lines.append(f' resp = client.{method}("{url}", json={{}})')
|
|
268
|
+
allowed = "200, 201, 204, 401, 403, 422"
|
|
269
|
+
if "{" in ep.path:
|
|
270
|
+
# The sample id ("1") need not exist: "not found" is a correct answer.
|
|
271
|
+
allowed += ", 404"
|
|
272
|
+
lines.append(f" assert resp.status_code in ({allowed})")
|
|
273
|
+
lines.append("")
|
|
274
|
+
return "\n".join(lines)
|
|
275
|
+
|
|
276
|
+
async def run(self, **kwargs: Any) -> str:
|
|
277
|
+
return await self.generate_tests(kwargs["description"])
|
|
278
|
+
|
|
279
|
+
|
|
280
|
+
# --------------------------------------------------------------------- LLM code
|
|
281
|
+
|
|
282
|
+
|
|
283
|
+
class RouterAgent(ApiAgent):
|
|
284
|
+
name = "router"
|
|
285
|
+
description = "Writes the FastAPI routes"
|
|
286
|
+
system_prompt = (
|
|
287
|
+
"You write FastAPI route modules. Keep every route path, HTTP method and the "
|
|
288
|
+
"module-level `router` variable exactly as given; improve the bodies, typing and "
|
|
289
|
+
"docstrings only."
|
|
290
|
+
)
|
|
291
|
+
|
|
292
|
+
def _generate_code(self, desc: APIDescription) -> str:
|
|
293
|
+
lines = [
|
|
294
|
+
'"""Auto-generated FastAPI routes (crewforge)."""',
|
|
295
|
+
"from fastapi import APIRouter, Query",
|
|
296
|
+
"",
|
|
297
|
+
f'router = APIRouter(prefix="{desc.base_path}")',
|
|
298
|
+
"",
|
|
299
|
+
]
|
|
300
|
+
for ep in desc.endpoints:
|
|
301
|
+
method = ep.method.lower()
|
|
302
|
+
params = []
|
|
303
|
+
for p in ep.parameters:
|
|
304
|
+
ptype = {"integer": "int", "string": "str", "boolean": "bool"}.get(
|
|
305
|
+
p.get("type", "string"), "str"
|
|
306
|
+
)
|
|
307
|
+
if p.get("in") == "path":
|
|
308
|
+
params.append(f"{p['name']}: {ptype}")
|
|
309
|
+
else:
|
|
310
|
+
params.append(f"{p['name']}: {ptype} | None = Query(None)")
|
|
311
|
+
if ep.request_body and method in ("post", "put", "patch"):
|
|
312
|
+
params.append("body: dict")
|
|
313
|
+
lines.append(f'@router.{method}("{ep.path}")')
|
|
314
|
+
lines.append(f"async def {_func_name(ep.method, ep.path)}({', '.join(params)}):")
|
|
315
|
+
lines.append(f' """{ep.description}"""')
|
|
316
|
+
lines.append(" return {}")
|
|
317
|
+
lines.append("")
|
|
318
|
+
return "\n".join(lines)
|
|
319
|
+
|
|
320
|
+
async def generate_routes(self, desc: APIDescription) -> str:
|
|
321
|
+
return await self.review_code(
|
|
322
|
+
self._generate_code(desc), "Review and improve this FastAPI routes module."
|
|
323
|
+
)
|
|
324
|
+
|
|
325
|
+
async def run(self, **kwargs: Any) -> str:
|
|
326
|
+
return await self.generate_routes(kwargs["description"])
|
|
327
|
+
|
|
328
|
+
|
|
329
|
+
class ModelAgent(ApiAgent):
|
|
330
|
+
name = "model"
|
|
331
|
+
description = "Writes the Pydantic v2 models"
|
|
332
|
+
system_prompt = "You write Pydantic v2 models. Keep every class and field name as given."
|
|
333
|
+
TYPE_MAP = {"string": "str", "integer": "int", "boolean": "bool", "number": "float"}
|
|
334
|
+
|
|
335
|
+
@classmethod
|
|
336
|
+
def _generate_models(cls, desc: APIDescription) -> str:
|
|
337
|
+
lines = ['"""Auto-generated models (crewforge)."""', "from pydantic import BaseModel", ""]
|
|
338
|
+
for m in desc.models:
|
|
339
|
+
lines.append(f"class {m['name']}(BaseModel):")
|
|
340
|
+
fields = m.get("fields") or []
|
|
341
|
+
for fld in fields:
|
|
342
|
+
ftype = cls.TYPE_MAP.get(fld.get("type", "string"), "str")
|
|
343
|
+
if not fld.get("required", True):
|
|
344
|
+
lines.append(f" {fld['name']}: {ftype} | None = None")
|
|
345
|
+
else:
|
|
346
|
+
lines.append(f" {fld['name']}: {ftype}")
|
|
347
|
+
if not fields:
|
|
348
|
+
lines.append(" pass")
|
|
349
|
+
lines.append("")
|
|
350
|
+
return "\n".join(lines)
|
|
351
|
+
|
|
352
|
+
async def generate_models(self, desc: APIDescription) -> str:
|
|
353
|
+
return await self.review_code(self._generate_models(desc), "Review these Pydantic models.")
|
|
354
|
+
|
|
355
|
+
async def run(self, **kwargs: Any) -> str:
|
|
356
|
+
return await self.generate_models(kwargs["description"])
|
|
357
|
+
|
|
358
|
+
|
|
359
|
+
_BEARER = '''"""JWT bearer auth."""
|
|
360
|
+
import os
|
|
361
|
+
|
|
362
|
+
import jwt
|
|
363
|
+
from fastapi import Depends, HTTPException
|
|
364
|
+
from fastapi.security import HTTPBearer
|
|
365
|
+
|
|
366
|
+
sec = HTTPBearer()
|
|
367
|
+
SECRET = os.environ["JWT_SECRET"]
|
|
368
|
+
|
|
369
|
+
|
|
370
|
+
async def get_user(creds=Depends(sec)):
|
|
371
|
+
try:
|
|
372
|
+
return jwt.decode(creds.credentials, SECRET, algorithms=["HS256"])
|
|
373
|
+
except jwt.InvalidTokenError:
|
|
374
|
+
raise HTTPException(401, "Invalid token") from None
|
|
375
|
+
'''
|
|
376
|
+
|
|
377
|
+
_API_KEY = '''"""API key auth."""
|
|
378
|
+
import os
|
|
379
|
+
|
|
380
|
+
from fastapi import HTTPException, Security
|
|
381
|
+
from fastapi.security import APIKeyHeader
|
|
382
|
+
|
|
383
|
+
header = APIKeyHeader(name="X-API-Key")
|
|
384
|
+
KEYS = {k for k in os.getenv("API_KEYS", "").split(",") if k}
|
|
385
|
+
|
|
386
|
+
|
|
387
|
+
async def verify(key: str = Security(header)):
|
|
388
|
+
if key not in KEYS:
|
|
389
|
+
raise HTTPException(403, "Invalid API key")
|
|
390
|
+
return key
|
|
391
|
+
'''
|
|
392
|
+
|
|
393
|
+
|
|
394
|
+
class AuthAgent(ApiAgent):
|
|
395
|
+
name = "auth"
|
|
396
|
+
description = "Writes the auth dependency (JWT bearer or API key)"
|
|
397
|
+
system_prompt = "You review FastAPI auth code for security problems and fix them."
|
|
398
|
+
|
|
399
|
+
async def generate_auth(self, auth_type: str = "bearer") -> str:
|
|
400
|
+
code = _API_KEY if auth_type == "api_key" else _BEARER
|
|
401
|
+
return await self.review_code(code, "Review this auth code.")
|
|
402
|
+
|
|
403
|
+
async def run(self, **kwargs: Any) -> str:
|
|
404
|
+
return await self.generate_auth(kwargs.get("auth_type", "bearer"))
|
|
405
|
+
|
|
406
|
+
|
|
407
|
+
class DocAgent(ApiAgent):
|
|
408
|
+
name = "doc_gen"
|
|
409
|
+
description = "Writes API.md"
|
|
410
|
+
system_prompt = (
|
|
411
|
+
"You write clear API documentation in Markdown. Document only the endpoints you "
|
|
412
|
+
"are given; do not invent any."
|
|
413
|
+
)
|
|
414
|
+
|
|
415
|
+
def _skeleton(self, desc: APIDescription) -> str:
|
|
416
|
+
lines = [
|
|
417
|
+
f"# {desc.title}",
|
|
418
|
+
"",
|
|
419
|
+
desc.description,
|
|
420
|
+
"",
|
|
421
|
+
"## Base URL",
|
|
422
|
+
f"`{desc.base_path}`",
|
|
423
|
+
"",
|
|
424
|
+
"## Authentication",
|
|
425
|
+
f"Type: {desc.auth_type}",
|
|
426
|
+
"",
|
|
427
|
+
"## Endpoints",
|
|
428
|
+
"",
|
|
429
|
+
]
|
|
430
|
+
for ep in desc.endpoints:
|
|
431
|
+
lines.append(f"### `{ep.method} {ep.path}`")
|
|
432
|
+
lines.append(ep.description)
|
|
433
|
+
if ep.parameters:
|
|
434
|
+
lines.append("| Name | In | Type | Required |")
|
|
435
|
+
lines.append("|------|-----|------|----------|")
|
|
436
|
+
for p in ep.parameters:
|
|
437
|
+
lines.append(
|
|
438
|
+
f"| {p.get('name')} | {p.get('in', 'query')} | "
|
|
439
|
+
f"{p.get('type', 'string')} | {p.get('required', False)} |"
|
|
440
|
+
)
|
|
441
|
+
lines.append("")
|
|
442
|
+
return "\n".join(lines)
|
|
443
|
+
|
|
444
|
+
async def generate_docs(self, desc: APIDescription) -> str:
|
|
445
|
+
response = await self.ask("Improve this API documentation:\n\n" + self._skeleton(desc))
|
|
446
|
+
return response.content.strip() + "\n"
|
|
447
|
+
|
|
448
|
+
async def run(self, **kwargs: Any) -> str:
|
|
449
|
+
return await self.generate_docs(kwargs["description"])
|
|
450
|
+
|
|
451
|
+
|
|
452
|
+
# ---------------------------------------------------------------------- validate
|
|
453
|
+
|
|
454
|
+
|
|
455
|
+
@dataclass
|
|
456
|
+
class ValidationResult:
|
|
457
|
+
valid: bool
|
|
458
|
+
errors: list[str]
|
|
459
|
+
warnings: list[str]
|
|
460
|
+
score: int # 0-100
|
|
461
|
+
operations: int = 0 # how many operations were actually checked
|
|
462
|
+
|
|
463
|
+
def to_dict(self) -> dict[str, Any]:
|
|
464
|
+
return {
|
|
465
|
+
"valid": self.valid,
|
|
466
|
+
"score": self.score,
|
|
467
|
+
"operations_checked": self.operations,
|
|
468
|
+
"errors": self.errors,
|
|
469
|
+
"warnings": self.warnings,
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
|
|
473
|
+
class ValidateAgent:
|
|
474
|
+
"""Checks an OpenAPI document offline (no LLM call, no key needed)."""
|
|
475
|
+
|
|
476
|
+
name = "validator"
|
|
477
|
+
description = "Checks an OpenAPI spec (deterministic)"
|
|
478
|
+
|
|
479
|
+
def __init__(self, *args: Any, **kwargs: Any) -> None:
|
|
480
|
+
pass
|
|
481
|
+
|
|
482
|
+
async def validate(self, spec: dict[str, Any]) -> ValidationResult:
|
|
483
|
+
errors: list[str] = []
|
|
484
|
+
warnings: list[str] = []
|
|
485
|
+
if not isinstance(spec, dict):
|
|
486
|
+
return ValidationResult(False, ["Spec is not a JSON object"], [], 0)
|
|
487
|
+
if "openapi" not in spec:
|
|
488
|
+
errors.append("Missing openapi version")
|
|
489
|
+
if "info" not in spec:
|
|
490
|
+
errors.append("Missing info object")
|
|
491
|
+
paths = spec.get("paths")
|
|
492
|
+
if paths is None:
|
|
493
|
+
errors.append("Missing paths object")
|
|
494
|
+
elif not paths:
|
|
495
|
+
errors.append("No endpoints defined (paths is empty)")
|
|
496
|
+
info = spec.get("info") if isinstance(spec.get("info"), dict) else {}
|
|
497
|
+
if not info.get("title"):
|
|
498
|
+
errors.append("Missing API title")
|
|
499
|
+
if not info.get("version"):
|
|
500
|
+
errors.append("Missing API version")
|
|
501
|
+
operations = 0
|
|
502
|
+
for path, methods in (paths or {}).items() if isinstance(paths, dict) else []:
|
|
503
|
+
if not isinstance(methods, dict):
|
|
504
|
+
errors.append(f"Path {path} is not an object")
|
|
505
|
+
continue
|
|
506
|
+
for method, op in methods.items():
|
|
507
|
+
if method not in ("get", "post", "put", "delete", "patch"):
|
|
508
|
+
continue
|
|
509
|
+
operations += 1
|
|
510
|
+
if not isinstance(op, dict) or "responses" not in op:
|
|
511
|
+
errors.append(f"Missing responses for {method.upper()} {path}")
|
|
512
|
+
elif "summary" not in op and "operationId" not in op:
|
|
513
|
+
warnings.append(f"Missing summary for {method.upper()} {path}")
|
|
514
|
+
if paths and operations == 0:
|
|
515
|
+
# A validator that checked nothing must not hand out a score.
|
|
516
|
+
errors.append("No HTTP operations found under paths (validator checked 0 operations)")
|
|
517
|
+
score = 0 if operations == 0 else max(0, 100 - len(errors) * 10 - len(warnings) * 2)
|
|
518
|
+
return ValidationResult(
|
|
519
|
+
valid=not errors, errors=errors, warnings=warnings, score=score, operations=operations
|
|
520
|
+
)
|
|
521
|
+
|
|
522
|
+
async def run(self, **kwargs: Any) -> ValidationResult:
|
|
523
|
+
return await self.validate(kwargs["spec"])
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
"""Aggregate metrics across several scaffold runs (from apiforge)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import time
|
|
6
|
+
from collections import defaultdict
|
|
7
|
+
from dataclasses import dataclass, field
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
@dataclass
|
|
12
|
+
class PipelineMetric:
|
|
13
|
+
"""Single pipeline run metric."""
|
|
14
|
+
|
|
15
|
+
run_id: str
|
|
16
|
+
description_length: int
|
|
17
|
+
endpoints_generated: int
|
|
18
|
+
models_generated: int
|
|
19
|
+
validation_score: int
|
|
20
|
+
total_tokens: int
|
|
21
|
+
total_calls: int
|
|
22
|
+
duration_seconds: float
|
|
23
|
+
framework: str
|
|
24
|
+
auth_type: str
|
|
25
|
+
timestamp: float = field(default_factory=time.time)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class PipelineAnalytics:
|
|
29
|
+
"""Track and analyze pipeline runs over time."""
|
|
30
|
+
|
|
31
|
+
def __init__(self) -> None:
|
|
32
|
+
self._metrics: list[PipelineMetric] = []
|
|
33
|
+
|
|
34
|
+
def record(self, metric: PipelineMetric) -> None:
|
|
35
|
+
"""Record a pipeline run metric."""
|
|
36
|
+
self._metrics.append(metric)
|
|
37
|
+
|
|
38
|
+
@property
|
|
39
|
+
def total_runs(self) -> int:
|
|
40
|
+
return len(self._metrics)
|
|
41
|
+
|
|
42
|
+
def average_score(self) -> float:
|
|
43
|
+
"""Average validation score across all runs."""
|
|
44
|
+
if not self._metrics:
|
|
45
|
+
return 0.0
|
|
46
|
+
return sum(m.validation_score for m in self._metrics) / len(self._metrics)
|
|
47
|
+
|
|
48
|
+
def average_tokens(self) -> float:
|
|
49
|
+
"""Average tokens per run."""
|
|
50
|
+
if not self._metrics:
|
|
51
|
+
return 0.0
|
|
52
|
+
return sum(m.total_tokens for m in self._metrics) / len(self._metrics)
|
|
53
|
+
|
|
54
|
+
def average_duration(self) -> float:
|
|
55
|
+
"""Average run duration in seconds."""
|
|
56
|
+
if not self._metrics:
|
|
57
|
+
return 0.0
|
|
58
|
+
return sum(m.duration_seconds for m in self._metrics) / len(self._metrics)
|
|
59
|
+
|
|
60
|
+
def total_tokens_consumed(self) -> int:
|
|
61
|
+
return sum(m.total_tokens for m in self._metrics)
|
|
62
|
+
|
|
63
|
+
def total_api_calls(self) -> int:
|
|
64
|
+
return sum(m.total_calls for m in self._metrics)
|
|
65
|
+
|
|
66
|
+
def framework_breakdown(self) -> dict[str, int]:
|
|
67
|
+
"""Count of runs per framework."""
|
|
68
|
+
counts: dict[str, int] = defaultdict(int)
|
|
69
|
+
for m in self._metrics:
|
|
70
|
+
counts[m.framework] += 1
|
|
71
|
+
return dict(counts)
|
|
72
|
+
|
|
73
|
+
def auth_type_breakdown(self) -> dict[str, int]:
|
|
74
|
+
"""Count of runs per auth type."""
|
|
75
|
+
counts: dict[str, int] = defaultdict(int)
|
|
76
|
+
for m in self._metrics:
|
|
77
|
+
counts[m.auth_type] += 1
|
|
78
|
+
return dict(counts)
|
|
79
|
+
|
|
80
|
+
def top_runs(self, n: int = 5) -> list[PipelineMetric]:
|
|
81
|
+
"""Top N runs by validation score."""
|
|
82
|
+
return sorted(self._metrics, key=lambda m: m.validation_score, reverse=True)[:n]
|
|
83
|
+
|
|
84
|
+
def summary(self) -> dict[str, Any]:
|
|
85
|
+
"""Full analytics summary."""
|
|
86
|
+
return {
|
|
87
|
+
"total_runs": self.total_runs,
|
|
88
|
+
"average_score": round(self.average_score(), 1),
|
|
89
|
+
"average_tokens": round(self.average_tokens(), 0),
|
|
90
|
+
"average_duration_seconds": round(self.average_duration(), 2),
|
|
91
|
+
"total_tokens_consumed": self.total_tokens_consumed(),
|
|
92
|
+
"total_api_calls": self.total_api_calls(),
|
|
93
|
+
"framework_breakdown": self.framework_breakdown(),
|
|
94
|
+
"auth_type_breakdown": self.auth_type_breakdown(),
|
|
95
|
+
"top_scores": [m.validation_score for m in self.top_runs()],
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
def reset(self) -> None:
|
|
99
|
+
self._metrics.clear()
|