codi-api-agent 0.3.1__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.
- api_agent/__init__.py +60 -0
- api_agent/__main__.py +5 -0
- api_agent/_version.py +2 -0
- api_agent/agent.py +6901 -0
- api_agent/catalog.py +147 -0
- api_agent/chart.py +144 -0
- api_agent/cli.py +31 -0
- api_agent/config.py +296 -0
- api_agent/doc_extract.py +431 -0
- api_agent/graphql_loader.py +309 -0
- api_agent/llm.py +470 -0
- api_agent/log.py +138 -0
- api_agent/metrics.py +1030 -0
- api_agent/openapi_loader.py +560 -0
- api_agent/prompts/__init__.py +59 -0
- api_agent/prompts/advisory.py +52 -0
- api_agent/prompts/executor.py +175 -0
- api_agent/prompts/judges.py +207 -0
- api_agent/prompts/support.py +59 -0
- api_agent/prompts/synthesis.py +350 -0
- api_agent/router.py +294 -0
- api_agent/schemas.py +250 -0
- api_agent/spec_convert.py +86 -0
- api_agent/sql_loader.py +1254 -0
- api_agent/supervisor.py +178 -0
- api_agent/ui.py +918 -0
- codi_api_agent-0.3.1.dist-info/METADATA +260 -0
- codi_api_agent-0.3.1.dist-info/RECORD +31 -0
- codi_api_agent-0.3.1.dist-info/WHEEL +5 -0
- codi_api_agent-0.3.1.dist-info/entry_points.txt +2 -0
- codi_api_agent-0.3.1.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,309 @@
|
|
|
1
|
+
"""Load a GraphQL API into the same read-only `Catalog` the rest of the pipeline uses.
|
|
2
|
+
|
|
3
|
+
GraphQL doesn't map cleanly onto REST paths, so instead of converting to OpenAPI we read
|
|
4
|
+
the schema natively (from a live endpoint via introspection, or a local SDL `.graphql` file)
|
|
5
|
+
and build one `Tool` per **Query** field. This keeps the agent READ-ONLY by construction:
|
|
6
|
+
mutations and subscriptions are recorded in the catalog's `reference` (so documentation mode can
|
|
7
|
+
DESCRIBE them) but are never turned into callable tools.
|
|
8
|
+
|
|
9
|
+
Each query tool's executor builds a GraphQL query string — `query(...) { field(args) { <auto
|
|
10
|
+
selection> } }` — with an auto-generated selection set (scalar/enum fields, shallow object
|
|
11
|
+
recursion), POSTs `{query, variables}` to the endpoint, and returns the same
|
|
12
|
+
``{"ok","content","source","error"}`` contract as the OpenAPI executor (reusing `_compact_json`
|
|
13
|
+
for PII redaction + size). GraphQL auth errors are surfaced as ``HTTP 401`` so the agent's
|
|
14
|
+
existing re-auth handling kicks in.
|
|
15
|
+
"""
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import requests
|
|
19
|
+
from graphql import (
|
|
20
|
+
GraphQLEnumType,
|
|
21
|
+
GraphQLInputObjectType,
|
|
22
|
+
GraphQLInterfaceType,
|
|
23
|
+
GraphQLList,
|
|
24
|
+
GraphQLNonNull,
|
|
25
|
+
GraphQLObjectType,
|
|
26
|
+
GraphQLScalarType,
|
|
27
|
+
build_client_schema,
|
|
28
|
+
build_schema,
|
|
29
|
+
get_introspection_query,
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
import requests
|
|
33
|
+
|
|
34
|
+
from .catalog import Catalog, Tool
|
|
35
|
+
from .openapi_loader import (
|
|
36
|
+
_UA,
|
|
37
|
+
READ_METHODS,
|
|
38
|
+
_compact_json,
|
|
39
|
+
_parse,
|
|
40
|
+
_redact_text,
|
|
41
|
+
_sanitize,
|
|
42
|
+
_unique,
|
|
43
|
+
build_catalog,
|
|
44
|
+
load_openapi,
|
|
45
|
+
)
|
|
46
|
+
from .spec_convert import CONVERTIBLE, convert_to_openapi, detect_format, set_base_url
|
|
47
|
+
|
|
48
|
+
_SCALAR_JSON = {"Int": "integer", "Float": "number", "Boolean": "boolean",
|
|
49
|
+
"String": "string", "ID": "string"}
|
|
50
|
+
_GQL_EXT = (".graphql", ".gql", ".graphqls", ".sdl")
|
|
51
|
+
_AUTH_WORDS = ("unauthorized", "unauthenticated", "not authenticated", "forbidden",
|
|
52
|
+
"access denied", "permission", "login", "auth token", "must be logged in")
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
# --------------------------------------------------------------------------- #
|
|
56
|
+
# Public entry points
|
|
57
|
+
# --------------------------------------------------------------------------- #
|
|
58
|
+
def load_catalog(source: str, *, graphql: bool = False, endpoint: str | None = None,
|
|
59
|
+
base_url: str | None = None, max_operations: int = 1000, timeout: int = 20,
|
|
60
|
+
auth_header: str | None = None, auth_value: str | None = None) -> Catalog:
|
|
61
|
+
"""One entry point for ANY spec format. Detects the source and dispatches:
|
|
62
|
+
- **GraphQL** (flag or `.graphql/.gql/.sdl`) → introspection/SDL loader.
|
|
63
|
+
- **OpenAPI / Swagger** → loaded directly.
|
|
64
|
+
- **Postman collection / RAML 0.8 / API Blueprint** → auto-CONVERTED to OpenAPI (via npx),
|
|
65
|
+
then loaded — so the user can point the agent at a non-OpenAPI spec and it just works.
|
|
66
|
+
`base_url` sets the server for a converted spec whose source left a `{{baseUrl}}`/no host
|
|
67
|
+
(Postman/RAML); `endpoint` is the GraphQL query URL for an SDL file."""
|
|
68
|
+
if graphql or source.lower().rstrip("/").endswith(_GQL_EXT):
|
|
69
|
+
return load_graphql(source, endpoint=endpoint, max_operations=max_operations,
|
|
70
|
+
timeout=timeout, auth_header=auth_header, auth_value=auth_value)
|
|
71
|
+
|
|
72
|
+
# Read the raw source once, then detect its format.
|
|
73
|
+
if source.startswith(("http://", "https://")):
|
|
74
|
+
resp = requests.get(source, timeout=timeout, headers=_UA)
|
|
75
|
+
resp.raise_for_status()
|
|
76
|
+
text, origin = resp.text, source
|
|
77
|
+
else:
|
|
78
|
+
with open(source, encoding="utf-8") as f:
|
|
79
|
+
text, origin = f.read(), None
|
|
80
|
+
fmt = detect_format(text, source)
|
|
81
|
+
|
|
82
|
+
auth = (auth_header, auth_value) if auth_header and auth_value else None
|
|
83
|
+
if fmt in CONVERTIBLE: # Postman / RAML / API Blueprint → convert to OpenAPI, then load
|
|
84
|
+
spec = convert_to_openapi(text, fmt)
|
|
85
|
+
if base_url:
|
|
86
|
+
spec = set_base_url(spec, base_url)
|
|
87
|
+
return build_catalog(spec, origin, methods=READ_METHODS,
|
|
88
|
+
max_operations=max_operations, auth=auth)
|
|
89
|
+
# OpenAPI / Swagger / unknown → parse and load directly (single fetch, reuse the text).
|
|
90
|
+
return build_catalog(_parse(text), origin, methods=READ_METHODS,
|
|
91
|
+
max_operations=max_operations, auth=auth)
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def load_graphql(source: str, endpoint: str | None = None, max_operations: int = 1000,
|
|
95
|
+
timeout: int = 20, auth_header: str | None = None,
|
|
96
|
+
auth_value: str | None = None) -> Catalog:
|
|
97
|
+
"""Build a read-only Catalog from a GraphQL schema.
|
|
98
|
+
|
|
99
|
+
`source` is a live endpoint URL (introspected) or a local SDL file. For an SDL file you may
|
|
100
|
+
pass `endpoint` (the server to actually query); without it the tools document the schema but
|
|
101
|
+
can't be called. Auth header is injected into both introspection and query calls.
|
|
102
|
+
"""
|
|
103
|
+
auth = (auth_header, auth_value) if auth_header and auth_value else None
|
|
104
|
+
schema, endpoint = _load_schema(source, endpoint, timeout, auth)
|
|
105
|
+
|
|
106
|
+
tools: list[Tool] = []
|
|
107
|
+
reference: list[dict] = []
|
|
108
|
+
seen: set[str] = set()
|
|
109
|
+
|
|
110
|
+
qtype = schema.query_type
|
|
111
|
+
if qtype:
|
|
112
|
+
for fname, field in qtype.fields.items():
|
|
113
|
+
if len(tools) >= max_operations:
|
|
114
|
+
break
|
|
115
|
+
name = _unique(_sanitize(fname), seen)
|
|
116
|
+
selection = _selection_set(field.type, depth=2, seen=frozenset())
|
|
117
|
+
arg_types = {a: str(arg.type) for a, arg in field.args.items()}
|
|
118
|
+
tools.append(Tool(
|
|
119
|
+
name=name,
|
|
120
|
+
description=_describe(fname, field),
|
|
121
|
+
parameters=_parameters(field),
|
|
122
|
+
data_type="structured",
|
|
123
|
+
fn=_make_executor(endpoint, fname, selection, arg_types, auth),
|
|
124
|
+
))
|
|
125
|
+
reference.append(_reference_entry("query", fname, field))
|
|
126
|
+
|
|
127
|
+
# Mutations/subscriptions are documented but NEVER callable (read-only by construction).
|
|
128
|
+
for op_type, t in (("mutation", schema.mutation_type),
|
|
129
|
+
("subscription", schema.subscription_type)):
|
|
130
|
+
if t:
|
|
131
|
+
for fname, field in t.fields.items():
|
|
132
|
+
reference.append(_reference_entry(op_type, fname, field))
|
|
133
|
+
|
|
134
|
+
return Catalog(tools, reference=reference)
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
# --------------------------------------------------------------------------- #
|
|
138
|
+
# Schema loading
|
|
139
|
+
# --------------------------------------------------------------------------- #
|
|
140
|
+
def _load_schema(source: str, endpoint: str | None, timeout: int, auth):
|
|
141
|
+
if source.startswith(("http://", "https://")):
|
|
142
|
+
endpoint = endpoint or source
|
|
143
|
+
return build_client_schema(_introspect(source, timeout, auth)), endpoint
|
|
144
|
+
with open(source, encoding="utf-8") as f:
|
|
145
|
+
sdl = f.read()
|
|
146
|
+
# assume_valid: tolerate custom directives/scalars in third-party SDL dumps.
|
|
147
|
+
return build_schema(sdl, assume_valid=True), endpoint
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def _introspect(url: str, timeout: int, auth) -> dict:
|
|
151
|
+
headers = {**_UA, "Content-Type": "application/json"}
|
|
152
|
+
if auth:
|
|
153
|
+
headers[auth[0]] = auth[1]
|
|
154
|
+
resp = requests.post(url, json={"query": get_introspection_query(descriptions=True)},
|
|
155
|
+
headers=headers, timeout=timeout)
|
|
156
|
+
resp.raise_for_status()
|
|
157
|
+
payload = resp.json()
|
|
158
|
+
if not payload.get("data"):
|
|
159
|
+
raise ValueError(f"GraphQL introspection failed: {payload.get('errors')}")
|
|
160
|
+
return payload["data"]
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
# --------------------------------------------------------------------------- #
|
|
164
|
+
# Type / selection helpers
|
|
165
|
+
# --------------------------------------------------------------------------- #
|
|
166
|
+
def _unwrap(t):
|
|
167
|
+
"""Strip NonNull/List wrappers to the underlying named type."""
|
|
168
|
+
while isinstance(t, (GraphQLNonNull, GraphQLList)):
|
|
169
|
+
t = t.of_type
|
|
170
|
+
return t
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def _json_type(gtype) -> dict:
|
|
174
|
+
"""Map a GraphQL type to a JSON-schema fragment for the tool's parameter spec."""
|
|
175
|
+
if isinstance(gtype, GraphQLNonNull):
|
|
176
|
+
return _json_type(gtype.of_type)
|
|
177
|
+
if isinstance(gtype, GraphQLList):
|
|
178
|
+
return {"type": "array", "items": _json_type(gtype.of_type)}
|
|
179
|
+
if isinstance(gtype, GraphQLScalarType):
|
|
180
|
+
return {"type": _SCALAR_JSON.get(gtype.name, "string")}
|
|
181
|
+
if isinstance(gtype, GraphQLEnumType):
|
|
182
|
+
return {"type": "string", "enum": list(gtype.values.keys())}
|
|
183
|
+
if isinstance(gtype, GraphQLInputObjectType):
|
|
184
|
+
return {"type": "object"} # opaque input object — the model supplies a dict
|
|
185
|
+
return {"type": "string"}
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def _selection_set(gtype, depth: int, seen: frozenset) -> str:
|
|
189
|
+
"""Auto-build a selection set: all scalar/enum fields, recursing shallowly into object
|
|
190
|
+
fields, skipping fields that REQUIRE arguments (can't be auto-filled) and avoiding type
|
|
191
|
+
cycles. Returns '' for leaf (scalar) return types — they need no sub-selection."""
|
|
192
|
+
t = _unwrap(gtype)
|
|
193
|
+
if isinstance(t, (GraphQLScalarType, GraphQLEnumType)):
|
|
194
|
+
return ""
|
|
195
|
+
if not isinstance(t, (GraphQLObjectType, GraphQLInterfaceType)):
|
|
196
|
+
return "" # union/input — skip rather than guess
|
|
197
|
+
if t.name in seen or depth <= 0:
|
|
198
|
+
return "{ __typename }" # minimal valid selection to stop recursion
|
|
199
|
+
seen = seen | {t.name}
|
|
200
|
+
parts: list[str] = []
|
|
201
|
+
for fname, f in t.fields.items():
|
|
202
|
+
if any(isinstance(a.type, GraphQLNonNull) for a in f.args.values()):
|
|
203
|
+
continue # needs required args we can't supply
|
|
204
|
+
inner = _unwrap(f.type)
|
|
205
|
+
if isinstance(inner, (GraphQLScalarType, GraphQLEnumType)):
|
|
206
|
+
parts.append(fname)
|
|
207
|
+
elif isinstance(inner, (GraphQLObjectType, GraphQLInterfaceType)) and depth > 1:
|
|
208
|
+
sub = _selection_set(f.type, depth - 1, seen)
|
|
209
|
+
if sub:
|
|
210
|
+
parts.append(f"{fname} {sub}")
|
|
211
|
+
if len(parts) >= 20: # cap breadth so queries stay reasonable
|
|
212
|
+
break
|
|
213
|
+
return "{ " + " ".join(parts) + " }" if parts else "{ __typename }"
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
def _describe(fname: str, field) -> str:
|
|
217
|
+
desc = (field.description or "").strip()
|
|
218
|
+
head = f"GraphQL query `{fname}` → {field.type}."
|
|
219
|
+
return (head + (" " + desc if desc else "")).strip()
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
def _parameters(field) -> dict:
|
|
223
|
+
props: dict = {}
|
|
224
|
+
required: list[str] = []
|
|
225
|
+
for aname, arg in field.args.items():
|
|
226
|
+
sch = _json_type(arg.type)
|
|
227
|
+
if arg.description:
|
|
228
|
+
sch["description"] = arg.description.strip()[:160]
|
|
229
|
+
props[aname] = sch
|
|
230
|
+
if isinstance(arg.type, GraphQLNonNull):
|
|
231
|
+
required.append(aname)
|
|
232
|
+
return {"type": "object", "properties": props, "required": required}
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
def _reference_entry(op_type: str, fname: str, field) -> dict:
|
|
236
|
+
"""Documentation record for one GraphQL field. `method` maps query->GET (callable read),
|
|
237
|
+
mutation->POST, subscription->SUBSCRIPTION so the agent's existing GET/HEAD-vs-write logic
|
|
238
|
+
treats them correctly."""
|
|
239
|
+
method = {"query": "GET", "mutation": "POST", "subscription": "SUBSCRIPTION"}[op_type]
|
|
240
|
+
desc = (field.description or "").strip()
|
|
241
|
+
return {
|
|
242
|
+
"name": _sanitize(fname),
|
|
243
|
+
"method": method,
|
|
244
|
+
"path": fname,
|
|
245
|
+
"summary": (desc.split("\n", 1)[0][:120] or f"{op_type} → {field.type}"),
|
|
246
|
+
"description": desc[:300],
|
|
247
|
+
"params": [
|
|
248
|
+
{"name": a, "in": "query", "required": isinstance(arg.type, GraphQLNonNull),
|
|
249
|
+
"type": _json_type(arg.type).get("type", "string"), "example": None}
|
|
250
|
+
for a, arg in field.args.items()
|
|
251
|
+
],
|
|
252
|
+
"body": [],
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
|
|
256
|
+
# --------------------------------------------------------------------------- #
|
|
257
|
+
# Execution
|
|
258
|
+
# --------------------------------------------------------------------------- #
|
|
259
|
+
def _build_query(field_name: str, selection: str, arg_gql_types: dict, provided: dict) -> str:
|
|
260
|
+
"""Compose a GraphQL query using ONLY the args the model supplied (so optional args are
|
|
261
|
+
simply omitted and required-but-missing args surface as a server error, not a silent null)."""
|
|
262
|
+
var_defs = ", ".join(f"${k}: {arg_gql_types[k]}" for k in provided)
|
|
263
|
+
call_args = ", ".join(f"{k}: ${k}" for k in provided)
|
|
264
|
+
head = f"query({var_defs})" if var_defs else "query"
|
|
265
|
+
call = f"{field_name}({call_args})" if call_args else field_name
|
|
266
|
+
return f"{head} {{ {call} {selection} }}".replace(" ", " ").strip()
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
def _make_executor(endpoint: str | None, field_name: str, selection: str,
|
|
270
|
+
arg_gql_types: dict, auth):
|
|
271
|
+
def fn(args: dict, timeout: int, list_limit: int | None = None) -> dict:
|
|
272
|
+
n = list_limit or 50 # how many items of a list response to keep (per-query override)
|
|
273
|
+
if not endpoint:
|
|
274
|
+
return {"ok": False, "content": "", "source": "",
|
|
275
|
+
"error": "No GraphQL endpoint configured for this schema — add the server URL "
|
|
276
|
+
"so I can run queries (the schema only documents the operations)."}
|
|
277
|
+
provided = {k: v for k, v in (args or {}).items()
|
|
278
|
+
if v is not None and k in arg_gql_types}
|
|
279
|
+
query = _build_query(field_name, selection, arg_gql_types, provided)
|
|
280
|
+
headers = {**_UA, "Content-Type": "application/json"}
|
|
281
|
+
if auth:
|
|
282
|
+
headers[auth[0]] = auth[1]
|
|
283
|
+
try:
|
|
284
|
+
resp = requests.post(endpoint, json={"query": query, "variables": provided},
|
|
285
|
+
headers=headers, timeout=timeout)
|
|
286
|
+
except Exception as e:
|
|
287
|
+
return {"ok": False, "error": str(e), "content": "", "source": endpoint}
|
|
288
|
+
if resp.status_code >= 400:
|
|
289
|
+
return {"ok": False, "error": f"HTTP {resp.status_code}",
|
|
290
|
+
"content": resp.text[:500], "source": endpoint}
|
|
291
|
+
try:
|
|
292
|
+
payload = resp.json()
|
|
293
|
+
except Exception:
|
|
294
|
+
return {"ok": False, "error": "Non-JSON response from GraphQL endpoint",
|
|
295
|
+
"content": _redact_text(resp.text[:500]), "source": endpoint}
|
|
296
|
+
errors = payload.get("errors")
|
|
297
|
+
data = payload.get("data")
|
|
298
|
+
result = data.get(field_name) if isinstance(data, dict) else None
|
|
299
|
+
# Surface the error only when nothing usable came back; if data is present (even partial),
|
|
300
|
+
# prefer returning it. Map auth-ish GraphQL errors to 401 for the agent's re-auth handling.
|
|
301
|
+
if errors and result is None and not data:
|
|
302
|
+
msg = "; ".join(e.get("message", "") for e in errors[:3] if isinstance(e, dict))
|
|
303
|
+
if any(w in msg.lower() for w in _AUTH_WORDS):
|
|
304
|
+
return {"ok": False, "error": f"HTTP 401 — {msg[:200]}", "content": "", "source": endpoint}
|
|
305
|
+
return {"ok": False, "error": f"GraphQL error: {msg[:300]}", "content": "", "source": endpoint}
|
|
306
|
+
content = _compact_json(result if result is not None else data, max_items=n)
|
|
307
|
+
return {"ok": True, "content": content[: max(8000, n * 600)], "source": endpoint}
|
|
308
|
+
|
|
309
|
+
return fn
|