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
api_agent/schemas.py
ADDED
|
@@ -0,0 +1,250 @@
|
|
|
1
|
+
"""Data structures that flow through the pipeline.
|
|
2
|
+
|
|
3
|
+
These mirror the architecture's named artifacts: gathered Evidence, the
|
|
4
|
+
provenance-tiered Citation, the per-tool-call trace, the Validator's
|
|
5
|
+
Faithfulness verdict, and the final AgentResult.
|
|
6
|
+
"""
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from dataclasses import dataclass, field
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@dataclass
|
|
13
|
+
class Evidence:
|
|
14
|
+
"""One piece of information gathered from an API call."""
|
|
15
|
+
|
|
16
|
+
id: str # e.g. "E1"
|
|
17
|
+
tool: str
|
|
18
|
+
args: dict
|
|
19
|
+
data_type: str # "doc" | "structured"
|
|
20
|
+
source: str # citable URL / reference
|
|
21
|
+
content: str # textual content used for grounding
|
|
22
|
+
ok: bool = True
|
|
23
|
+
error: str | None = None
|
|
24
|
+
# The COMPLETE result rows (every column, PII-redacted) when the backend provides them
|
|
25
|
+
# (the SQL executor does). Powers deterministic table rendering + full-width figure
|
|
26
|
+
# grounding; `content` stays the compacted string the LLM context uses.
|
|
27
|
+
rows: list | None = None
|
|
28
|
+
rows_truncated: bool = False # True when a LIMIT cut the row set
|
|
29
|
+
# A describe()-style statistical profile computed over EVERY row SCANNED (up to
|
|
30
|
+
# Config.profile_rows), not just the `rows` shown. This is what makes "the maximum" the
|
|
31
|
+
# real maximum rather than the top of the first page — see sql_loader.describe_rows.
|
|
32
|
+
profile: dict | None = None
|
|
33
|
+
rows_scanned: int = 0 # rows the profile covers (>= len(rows) when the display was capped)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@dataclass
|
|
37
|
+
class Citation:
|
|
38
|
+
evidence_id: str
|
|
39
|
+
source: str
|
|
40
|
+
locator: str = "" # which tool/field the claim came from
|
|
41
|
+
provenance: str = "curated" # curated | user-supplied (Tier-1 URLs, later)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
@dataclass
|
|
45
|
+
class ToolCall:
|
|
46
|
+
tool: str
|
|
47
|
+
args: dict
|
|
48
|
+
ok: bool
|
|
49
|
+
source: str = ""
|
|
50
|
+
error: str | None = None
|
|
51
|
+
detail: str = "" # short human note on the outcome (e.g. "161 of 217 items", "no data")
|
|
52
|
+
kind: str | None = None # normalized failure taxonomy: auth | unreachable | server_error
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
@dataclass
|
|
56
|
+
class Faithfulness:
|
|
57
|
+
score: float = 0.0 # fraction of claims supported by evidence (0..1) — GROUNDING
|
|
58
|
+
supported: bool = False
|
|
59
|
+
unsupported_claims: list[str] = field(default_factory=list)
|
|
60
|
+
notes: str = ""
|
|
61
|
+
judged: bool = True
|
|
62
|
+
# The evaluator's extra dimensions beyond grounding (score). answers_question = does the answer
|
|
63
|
+
# actually + fully address the question (responsiveness/completeness); sufficient_evidence = was
|
|
64
|
+
# enough real data retrieved to answer (deterministic). Both default True (nothing wrong).
|
|
65
|
+
answers_question: bool = True
|
|
66
|
+
sufficient_evidence: bool = True
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
@dataclass
|
|
70
|
+
class RubricDimension:
|
|
71
|
+
"""One dimension of the answer scorecard (mirrors the eval rubric D1–D5)."""
|
|
72
|
+
name: str # correctness | completeness | groundedness | safety | communication
|
|
73
|
+
score: float = 0.0 # 0..1
|
|
74
|
+
passed: bool = True
|
|
75
|
+
method: str = "derived" # how it was produced: llm | deterministic | derived
|
|
76
|
+
note: str = ""
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
@dataclass
|
|
80
|
+
class RubricReport:
|
|
81
|
+
"""Five-dimension quality scorecard produced inline on every substantive answer.
|
|
82
|
+
|
|
83
|
+
Groundedness reuses the isolated faithfulness judge (unchanged); communication adds ONE
|
|
84
|
+
narrow presentation-only judge; correctness / completeness / safety are DERIVED from signals
|
|
85
|
+
the pipeline already computes. There is no live oracle at answer time, so correctness and
|
|
86
|
+
completeness measure "consistent with the retrieved evidence and addresses the question",
|
|
87
|
+
NOT correctness against external ground truth (that is what the offline eval does). The
|
|
88
|
+
headline `overall` (0–5) is derived by a fixed ladder, never assigned by an LLM."""
|
|
89
|
+
correctness: RubricDimension
|
|
90
|
+
completeness: RubricDimension
|
|
91
|
+
groundedness: RubricDimension
|
|
92
|
+
safety: RubricDimension
|
|
93
|
+
communication: RubricDimension
|
|
94
|
+
overall: int = 0 # derived 0..5 ladder
|
|
95
|
+
judged: bool = False # True when at least one LLM dimension actually graded
|
|
96
|
+
# ADVISORY turns only (recommendations rather than retrieved facts). Grading a recommendation
|
|
97
|
+
# on figure-groundedness is a category error — "deploy a locum tenens provider" is not a claim
|
|
98
|
+
# about the data — so advisory answers get this extra dimension instead: does every
|
|
99
|
+
# recommendation trace back to a stated finding and carry an owner, a timeframe and an
|
|
100
|
+
# expected impact? None on every non-advisory answer, and `dims()` omits it when absent so
|
|
101
|
+
# existing consumers (UI, eval grader) see exactly the five they already handle.
|
|
102
|
+
traceability: RubricDimension | None = None
|
|
103
|
+
# WHY the headline landed where it did. The ladder takes the FIRST rule that matches, so a
|
|
104
|
+
# scorecard showing five green dimensions and one amber gives the reader no way to tell which
|
|
105
|
+
# rule fired — reported live as "all score 100% except traceability, why is this 1/5?". The
|
|
106
|
+
# offline grader has carried a `reasons` list for exactly this since it was written.
|
|
107
|
+
overall_reason: str = ""
|
|
108
|
+
|
|
109
|
+
def dims(self) -> list[RubricDimension]:
|
|
110
|
+
base = [self.correctness, self.completeness, self.groundedness,
|
|
111
|
+
self.safety, self.communication]
|
|
112
|
+
return base + ([self.traceability] if self.traceability is not None else [])
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
@dataclass
|
|
116
|
+
class Usage:
|
|
117
|
+
"""LLM token consumption for producing one answer (summed across every call — router,
|
|
118
|
+
executor turns, synthesis, judge, retries). A cache hit is ~0. `by_model` keeps a per-model
|
|
119
|
+
prompt/completion split so cost can be priced accurately even when the generator, judge and
|
|
120
|
+
router use different models."""
|
|
121
|
+
prompt_tokens: int = 0
|
|
122
|
+
completion_tokens: int = 0
|
|
123
|
+
total_tokens: int = 0
|
|
124
|
+
calls: int = 0 # number of LLM round-trips
|
|
125
|
+
by_model: dict = field(default_factory=dict) # model -> [prompt_tokens, completion_tokens]
|
|
126
|
+
# Prompt tokens the provider served from its PREFIX CACHE (OpenAI reports these as
|
|
127
|
+
# usage.prompt_tokens_details.cached_tokens; they are a SUBSET of prompt_tokens, billed at a
|
|
128
|
+
# discount). Tracked because 84% of this agent's input is a stable system+tool-spec prefix —
|
|
129
|
+
# without measuring hits there is no way to tell whether caching is working at all.
|
|
130
|
+
cached_prompt_tokens: int = 0
|
|
131
|
+
cached_by_model: dict = field(default_factory=dict) # model -> cached prompt tokens
|
|
132
|
+
# PIPELINE STAGE -> [prompt, completion, cached, calls]. Without this the only visible number
|
|
133
|
+
# is a per-query total, which hides that a handful of full-evidence passes (synthesis,
|
|
134
|
+
# validator, evaluator) dominate spend while the executor turns — the ones that DO cache —
|
|
135
|
+
# are comparatively cheap. Compaction has to be aimed, not sprayed.
|
|
136
|
+
by_stage: dict = field(default_factory=dict)
|
|
137
|
+
|
|
138
|
+
def add(self, prompt: int | None, completion: int | None, total: int | None,
|
|
139
|
+
model: str = "", cached: int | None = None, stage: str = "") -> None:
|
|
140
|
+
p, c, ca = prompt or 0, completion or 0, cached or 0
|
|
141
|
+
self.prompt_tokens += p
|
|
142
|
+
self.completion_tokens += c
|
|
143
|
+
self.total_tokens += total if total is not None else (p + c)
|
|
144
|
+
self.cached_prompt_tokens += ca
|
|
145
|
+
self.calls += 1
|
|
146
|
+
pp, cc = self.by_model.get(model, (0, 0))
|
|
147
|
+
self.by_model[model] = (pp + p, cc + c)
|
|
148
|
+
if ca:
|
|
149
|
+
self.cached_by_model[model] = self.cached_by_model.get(model, 0) + ca
|
|
150
|
+
s = self.by_stage.setdefault(stage or "other", [0, 0, 0, 0])
|
|
151
|
+
s[0] += p
|
|
152
|
+
s[1] += c
|
|
153
|
+
s[2] += ca
|
|
154
|
+
s[3] += 1
|
|
155
|
+
|
|
156
|
+
@property
|
|
157
|
+
def cache_hit_rate(self) -> float:
|
|
158
|
+
"""Fraction of prompt tokens served from cache (0.0 when nothing was cached)."""
|
|
159
|
+
return (self.cached_prompt_tokens / self.prompt_tokens) if self.prompt_tokens else 0.0
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
# Providers bill a cache HIT at a fraction of the normal input rate (OpenAI: 50% off for the
|
|
163
|
+
# 4o/4.1/o-series; Groq's discount differs by model). Applied to the cached SUBSET of prompt
|
|
164
|
+
# tokens so reported cost matches the invoice instead of over-charging every cached prefix.
|
|
165
|
+
CACHED_INPUT_DISCOUNT = 0.5
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def _cost_in(prompt: int, cached: int, in_rate: float) -> float:
|
|
169
|
+
"""Input $ for one model's tokens, charging the cached subset at the discounted rate."""
|
|
170
|
+
cached = min(cached, prompt) # cached is a SUBSET of prompt_tokens — never let it exceed
|
|
171
|
+
full = prompt - cached
|
|
172
|
+
return (full / 1_000_000) * in_rate + (cached / 1_000_000) * in_rate * CACHED_INPUT_DISCOUNT
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def usage_cost(usage: "Usage", pricing: dict) -> float | None:
|
|
176
|
+
"""Estimate the USD cost of `usage` from a pricing table `{model: (in_per_1M, out_per_1M)}`.
|
|
177
|
+
Matches a model name exactly, then by its base name (after the last '/', e.g. a Groq
|
|
178
|
+
'openai/gpt-4o' → 'gpt-4o'). Returns None when NO model matched the table (unknown / free
|
|
179
|
+
provider) so callers can simply hide the cost rather than show a misleading $0."""
|
|
180
|
+
if not pricing:
|
|
181
|
+
return None
|
|
182
|
+
total, matched = 0.0, False
|
|
183
|
+
for model, (p, c) in usage.by_model.items():
|
|
184
|
+
rate = pricing.get(model) or (pricing.get(model.split("/")[-1]) if model else None)
|
|
185
|
+
if rate:
|
|
186
|
+
matched = True
|
|
187
|
+
total += (_cost_in(p, usage.cached_by_model.get(model, 0), rate[0])
|
|
188
|
+
+ (c / 1_000_000) * rate[1])
|
|
189
|
+
return round(total, 6) if matched else None
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def _rate_for(model: str, pricing: dict):
|
|
193
|
+
"""The (input, output) per-1M rate for `model`, matching exactly then by base name
|
|
194
|
+
(a Groq `openai/gpt-4o` falls back to `gpt-4o`). None when the model isn't priced."""
|
|
195
|
+
if not pricing or not model:
|
|
196
|
+
return pricing.get(model) if pricing else None
|
|
197
|
+
return pricing.get(model) or pricing.get(model.split("/")[-1])
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def unpriced_models(usage: "Usage", pricing: dict) -> list[str]:
|
|
201
|
+
"""Models that actually consumed tokens but have NO entry in the pricing table.
|
|
202
|
+
|
|
203
|
+
Cost is summed per model, so an unpriced model contributes $0 while the total still looks
|
|
204
|
+
authoritative — the number is quietly too low rather than obviously absent. That matters more
|
|
205
|
+
since the writing stages moved to their own model: with `SYNTHESIS_MODEL` set to something
|
|
206
|
+
unpriced, roughly 42% of input tokens would vanish from the bill with no indication. Callers
|
|
207
|
+
should surface this next to any cost they display.
|
|
208
|
+
"""
|
|
209
|
+
return sorted(m for m, (p, c) in (usage.by_model or {}).items()
|
|
210
|
+
if (p or c) and not _rate_for(m, pricing))
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
def usage_cost_split(usage: "Usage", pricing: dict) -> tuple[float, float] | None:
|
|
214
|
+
"""Like `usage_cost` but returns (input_usd, output_usd) separately, so a UI can show what the
|
|
215
|
+
prompt vs the completion tokens cost. Returns None when no model matched the pricing table."""
|
|
216
|
+
if not pricing:
|
|
217
|
+
return None
|
|
218
|
+
in_usd, out_usd, matched = 0.0, 0.0, False
|
|
219
|
+
for model, (p, c) in usage.by_model.items():
|
|
220
|
+
rate = pricing.get(model) or (pricing.get(model.split("/")[-1]) if model else None)
|
|
221
|
+
if rate:
|
|
222
|
+
matched = True
|
|
223
|
+
in_usd += _cost_in(p, usage.cached_by_model.get(model, 0), rate[0])
|
|
224
|
+
out_usd += (c / 1_000_000) * rate[1]
|
|
225
|
+
return (round(in_usd, 6), round(out_usd, 6)) if matched else None
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
@dataclass
|
|
229
|
+
class AgentResult:
|
|
230
|
+
answer: str
|
|
231
|
+
status: str # answered | partial | abstained
|
|
232
|
+
citations: list[Citation] = field(default_factory=list)
|
|
233
|
+
evidence: list[Evidence] = field(default_factory=list)
|
|
234
|
+
trace: list[ToolCall] = field(default_factory=list)
|
|
235
|
+
faithfulness: Faithfulness | None = None
|
|
236
|
+
routed_tools: list[str] = field(default_factory=list)
|
|
237
|
+
route_method: str = "" # "all (small catalog)" | "lexical" | "llm"
|
|
238
|
+
needs_keys: list[str] = field(default_factory=list) # hosts needing an API key
|
|
239
|
+
error: str | None = None
|
|
240
|
+
elapsed_s: float = 0.0 # wall-clock time to produce this response
|
|
241
|
+
cached: bool = False # True when served from the query cache (no pipeline re-run)
|
|
242
|
+
answered_by: str = "" # which agent produced this (set by the Supervisor; blank single-agent)
|
|
243
|
+
usage: Usage | None = None # LLM tokens spent producing this answer
|
|
244
|
+
cost_usd: float | None = None # estimated $ cost from model pricing (None if pricing unknown)
|
|
245
|
+
rubric: "RubricReport | None" = None # five-dimension quality scorecard (None when disabled)
|
|
246
|
+
# Which data source answered, when the question named none and others publish the same metric.
|
|
247
|
+
# Deliberately OUT-OF-BAND: the reader must be able to learn it, but naming a practice-
|
|
248
|
+
# management system inside the prose is exactly the disclosure the confidentiality rule
|
|
249
|
+
# forbids. The UI renders it in the Sources panel, which is opened deliberately.
|
|
250
|
+
source_note: str = ""
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
"""Auto-detect and convert a non-OpenAPI API description to an OpenAPI dict, so the loader can
|
|
2
|
+
ingest **Postman collections, RAML 0.8, and API Blueprint** directly — the agent recognises the
|
|
3
|
+
format and converts it instead of failing. Uses the same npx tools as `scripts/convert_spec.py`
|
|
4
|
+
(`postman-to-openapi`, `api-spec-converter`, `apib2swagger`), so Node/npx must be on PATH for those
|
|
5
|
+
formats. OpenAPI/Swagger and GraphQL need no conversion.
|
|
6
|
+
"""
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import json
|
|
10
|
+
import subprocess
|
|
11
|
+
import tempfile
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
from urllib.parse import urlparse
|
|
14
|
+
|
|
15
|
+
# Formats this module converts to OpenAPI (GraphQL/OpenAPI are handled by their own loaders).
|
|
16
|
+
CONVERTIBLE = {"postman", "raml", "apib"}
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def detect_format(text: str, source: str = "") -> str:
|
|
20
|
+
"""Sniff the API-description format from its content (and filename hint). Returns one of
|
|
21
|
+
postman | raml | apib | openapi | graphql | unknown."""
|
|
22
|
+
low = source.lower().rstrip("/")
|
|
23
|
+
head = text.lstrip()[:600].lower()
|
|
24
|
+
if low.endswith((".graphql", ".gql", ".graphqls", ".sdl")):
|
|
25
|
+
return "graphql"
|
|
26
|
+
if low.endswith(".apib") or head.startswith("format:"):
|
|
27
|
+
return "apib"
|
|
28
|
+
if low.endswith(".raml") or head.startswith("#%raml"):
|
|
29
|
+
return "raml"
|
|
30
|
+
if ("schema.getpostman.com" in head or '"_postman_id"' in head
|
|
31
|
+
or low.endswith("postman_collection.json")
|
|
32
|
+
or ('"info"' in head and '"item"' in head)): # Postman v2.x shape
|
|
33
|
+
return "postman"
|
|
34
|
+
if ('"openapi"' in head or head.startswith("openapi:")
|
|
35
|
+
or '"swagger"' in head or head.startswith("swagger:")):
|
|
36
|
+
return "openapi"
|
|
37
|
+
return "unknown"
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _run(cmd: list[str]) -> str:
|
|
41
|
+
proc = subprocess.run(cmd, capture_output=True, text=True, timeout=300)
|
|
42
|
+
if proc.returncode != 0 or not proc.stdout.strip():
|
|
43
|
+
msg = (proc.stderr or proc.stdout or "").strip()
|
|
44
|
+
if "Unsupported RAML version" in msg:
|
|
45
|
+
raise RuntimeError("RAML 1.0 isn't supported by the free converter (it parses RAML 0.8). "
|
|
46
|
+
"Convert to RAML 0.8 or OpenAPI first.")
|
|
47
|
+
raise RuntimeError(f"Spec converter failed ({cmd[2] if len(cmd) > 2 else cmd}):\n{msg[:500]}")
|
|
48
|
+
return proc.stdout
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _parse(text: str) -> dict:
|
|
52
|
+
if text.lstrip().startswith("{"):
|
|
53
|
+
return json.loads(text)
|
|
54
|
+
import yaml
|
|
55
|
+
return yaml.safe_load(text)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def convert_to_openapi(text: str, fmt: str) -> dict:
|
|
59
|
+
"""Convert Postman / RAML / API Blueprint source TEXT to an OpenAPI (or Swagger 2.0) dict."""
|
|
60
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
61
|
+
ext = {"apib": "apib", "raml": "raml", "postman": "json"}[fmt]
|
|
62
|
+
src = Path(tmp) / f"source.{ext}"
|
|
63
|
+
src.write_text(text, encoding="utf-8")
|
|
64
|
+
if fmt == "apib":
|
|
65
|
+
return _parse(_run(["npx", "--yes", "apib2swagger", "-i", str(src)])) # -> Swagger 2.0
|
|
66
|
+
if fmt == "raml":
|
|
67
|
+
return _parse(_run(["npx", "--yes", "api-spec-converter",
|
|
68
|
+
"--from=raml", "--to=openapi_3", str(src)])) # -> OpenAPI 3
|
|
69
|
+
# postman-to-openapi writes to a file
|
|
70
|
+
dst = Path(tmp) / "out.yaml"
|
|
71
|
+
_run(["npx", "--yes", "postman-to-openapi@latest", str(src), "-f", str(dst)])
|
|
72
|
+
return _parse(dst.read_text(encoding="utf-8"))
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def set_base_url(spec: dict, base_url: str) -> dict:
|
|
76
|
+
"""Point the converted spec at the real server (Postman/RAML often leave a `{{baseUrl}}`
|
|
77
|
+
placeholder or no host). Works for both OpenAPI 3 (`servers`) and Swagger 2.0 (host/basePath)."""
|
|
78
|
+
base_url = base_url.strip().rstrip("/")
|
|
79
|
+
if "openapi" in spec:
|
|
80
|
+
spec["servers"] = [{"url": base_url}]
|
|
81
|
+
else: # Swagger 2.0 (apib2swagger output)
|
|
82
|
+
p = urlparse(base_url)
|
|
83
|
+
spec["host"] = p.netloc or base_url
|
|
84
|
+
spec["schemes"] = [p.scheme or "https"]
|
|
85
|
+
spec["basePath"] = p.path or "/"
|
|
86
|
+
return spec
|