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/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