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/ui.py
ADDED
|
@@ -0,0 +1,918 @@
|
|
|
1
|
+
"""Streamlit UI for the API Agent β packaged entry point.
|
|
2
|
+
|
|
3
|
+
Launch it with the console command after installing: ``api-agent``
|
|
4
|
+
or directly: ``streamlit run -m api_agent.ui`` (or ``python -m api_agent`` β prints how).
|
|
5
|
+
|
|
6
|
+
Load an OpenAPI/Swagger, GraphQL, or Postman/RAML/API-Blueprint spec in the sidebar, then ask
|
|
7
|
+
questions β the agent picks the right operations, calls them (read-only), and answers with
|
|
8
|
+
citations and a faithfulness check.
|
|
9
|
+
"""
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import os
|
|
13
|
+
import queue
|
|
14
|
+
import re
|
|
15
|
+
import threading
|
|
16
|
+
|
|
17
|
+
try: # work both installed and from a source checkout
|
|
18
|
+
import api_agent # noqa: F401
|
|
19
|
+
except ModuleNotFoundError: # pragma: no cover
|
|
20
|
+
import sys
|
|
21
|
+
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
|
22
|
+
|
|
23
|
+
import streamlit as st
|
|
24
|
+
import streamlit.components.v1 as components
|
|
25
|
+
|
|
26
|
+
from api_agent import log as _log
|
|
27
|
+
from api_agent.agent import Agent, _tool_category
|
|
28
|
+
from api_agent.catalog import Catalog
|
|
29
|
+
from api_agent.config import Config
|
|
30
|
+
from api_agent.graphql_loader import load_catalog
|
|
31
|
+
from api_agent.openapi_loader import build_catalog, merge_catalogs, spec_label
|
|
32
|
+
from api_agent.schemas import CACHED_INPUT_DISCOUNT, AgentResult, unpriced_models
|
|
33
|
+
from api_agent.supervisor import Supervisor
|
|
34
|
+
|
|
35
|
+
st.set_page_config(page_title="API Agent", page_icon="π°οΈ", layout="wide")
|
|
36
|
+
|
|
37
|
+
ENV = Config.from_env()
|
|
38
|
+
|
|
39
|
+
# ---- Console step trail -------------------------------------------------------------------- #
|
|
40
|
+
# Re-apply the log level AFTER Config.from_env(), which is what runs `load_dotenv()`. Import order
|
|
41
|
+
# makes this necessary rather than tidy: `api_agent.agent` imports `metrics` β `log` before it
|
|
42
|
+
# imports `config`, so `log` was already configured at the pre-dotenv level and a `LOG_LEVEL` set
|
|
43
|
+
# in `.env` was silently ignored β the cap stayed 800 and the level stayed INFO with nothing said.
|
|
44
|
+
#
|
|
45
|
+
# LOG_LEVEL=DEBUG every step's FULL input and output (LLM message lists, replies,
|
|
46
|
+
# tool args, tool results, routing, verdicts)
|
|
47
|
+
# LOG_PAYLOAD_CHARS=0 no truncation (default 800 cuts exactly the long payloads you want)
|
|
48
|
+
# LOG_RAW_ROWS=1 log warehouse rows BEFORE PII redaction (see sql_loader._redact_row)
|
|
49
|
+
# LOG_FILE=agent.log also tee to a file
|
|
50
|
+
_log.setup(force=True)
|
|
51
|
+
_UILOG = _log.get_logger("api_agent.ui")
|
|
52
|
+
_UILOG.info("UI start β log level=%s payload_chars=%s raw_rows=%s (streamlit prints to THIS console)",
|
|
53
|
+
os.getenv("LOG_LEVEL", "INFO").upper(), _log.payload_chars(),
|
|
54
|
+
os.getenv("LOG_RAW_ROWS", "0"))
|
|
55
|
+
# No spec auto-loads by default (a fresh install has none) β set DEFAULT_SPEC to a URL/path to
|
|
56
|
+
# auto-load one. Example: DEFAULT_SPEC="https://petstore3.swagger.io/api/v3/openapi.json".
|
|
57
|
+
DEFAULT_SPEC = os.getenv("DEFAULT_SPEC", "").strip()
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _catalog_for_trace():
|
|
61
|
+
"""The active catalog, for turning an operation name into its business category. Best-effort:
|
|
62
|
+
the trace must render even before a catalog is loaded."""
|
|
63
|
+
try:
|
|
64
|
+
return st.session_state.get("catalog")
|
|
65
|
+
except Exception: # pragma: no cover - outside a Streamlit run
|
|
66
|
+
return None
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _md(text: str) -> str:
|
|
70
|
+
"""Prepare answer text for `st.markdown`. Streamlit renders `$β¦$` as LaTeX math, so currency
|
|
71
|
+
like `$5,606,794.94` breaks the whole line (bold turns into `ββ`). Escape every unescaped `$`
|
|
72
|
+
so amounts render literally."""
|
|
73
|
+
return re.sub(r"(?<!\\)\$", r"\\$", text or "")
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
# Categorical slots 1-3 of the validated palette, in fixed order. Three is the ceiling that clears
|
|
77
|
+
# every colour-vision-deficiency pair test in BOTH modes; a fourth puts yellow and orange together
|
|
78
|
+
# and stops clearing. Assigned by POSITION in `spec.series`, never cycled, so a series keeps its
|
|
79
|
+
# colour when a sibling drops out of the result.
|
|
80
|
+
_SERIES_LIGHT = ["#2a78d6", "#eb6834", "#1baf7a"]
|
|
81
|
+
_SERIES_DARK = ["#3987e5", "#d95926", "#199e70"]
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def _render_trend_chart(result: AgentResult) -> None:
|
|
85
|
+
"""Draw the ONE result worth drawing, when there is one. Otherwise draw nothing.
|
|
86
|
+
|
|
87
|
+
Prose is weakest at exactly one shape: a trend. Measured β asked for "the Dentrix production
|
|
88
|
+
trend for the last 12 months through June 2026" the agent wrote an accurate paragraph naming
|
|
89
|
+
the first month, the last, the low, the high, the total and the year-over-year change: six
|
|
90
|
+
numbers standing in for twelve, and the reader still cannot see the shape.
|
|
91
|
+
|
|
92
|
+
`chart.chart_spec` owns the decision and refuses by default β truncated rows are never drawn,
|
|
93
|
+
because a line over a partial series shows a shape the data does not have and, unlike a table,
|
|
94
|
+
carries no visible sign that anything is missing.
|
|
95
|
+
"""
|
|
96
|
+
try:
|
|
97
|
+
import altair as alt
|
|
98
|
+
import pandas as pd
|
|
99
|
+
except ModuleNotFoundError: # charting is a nicety; never break the answer over it
|
|
100
|
+
return
|
|
101
|
+
from api_agent.chart import chart_spec, humanize
|
|
102
|
+
|
|
103
|
+
spec = next((s for e in (result.evidence or [])
|
|
104
|
+
if (s := chart_spec(getattr(e, "rows", None),
|
|
105
|
+
truncated=getattr(e, "rows_truncated", False),
|
|
106
|
+
label=getattr(e, "tool", "")))), None)
|
|
107
|
+
if not spec:
|
|
108
|
+
return
|
|
109
|
+
|
|
110
|
+
dark = str(st.get_option("theme.base") or "").lower() == "dark"
|
|
111
|
+
palette = _SERIES_DARK if dark else _SERIES_LIGHT
|
|
112
|
+
ink, muted = ("#ffffff", "#c3c2b7") if dark else ("#0b0b0b", "#52514e")
|
|
113
|
+
names = [humanize(c) for c in spec.series]
|
|
114
|
+
df = pd.DataFrame(spec.points)
|
|
115
|
+
df["series"] = df["series"].map(humanize)
|
|
116
|
+
order = [n for n in names] # x stays in the order the warehouse returned
|
|
117
|
+
|
|
118
|
+
colour = alt.Color("series:N", title=None,
|
|
119
|
+
scale=alt.Scale(domain=order, range=palette[:len(order)]),
|
|
120
|
+
legend=alt.Legend(orient="top", direction="horizontal", labelColor=muted,
|
|
121
|
+
symbolStrokeWidth=3, symbolType="stroke"))
|
|
122
|
+
base = alt.Chart(df).encode(
|
|
123
|
+
# `sort=None` keeps the periods in the order the rows arrived. Altair sorts a nominal axis
|
|
124
|
+
# alphabetically by default, which turns "Jul 2025 β Jun 2026" into "Apr, Aug, Dec, β¦" β a
|
|
125
|
+
# chart that looks fine and is pure noise.
|
|
126
|
+
x=alt.X("period:N", title=None, sort=None,
|
|
127
|
+
axis=alt.Axis(labelColor=muted, labelAngle=-45, domainColor=muted,
|
|
128
|
+
tickColor=muted, grid=False)),
|
|
129
|
+
y=alt.Y("value:Q", title=None,
|
|
130
|
+
# A trend chart is read for SHAPE, so the axis is not forced to zero β but it is
|
|
131
|
+
# never clipped so tightly that a 2% wobble looks like a collapse.
|
|
132
|
+
scale=alt.Scale(zero=False, nice=True, padding=12),
|
|
133
|
+
axis=alt.Axis(labelColor=muted, format="~s", domain=False, ticks=False,
|
|
134
|
+
gridColor=muted, gridOpacity=0.18, gridDash=[])),
|
|
135
|
+
color=colour,
|
|
136
|
+
)
|
|
137
|
+
hover = alt.selection_point(on="mouseover", nearest=True, fields=["period"], empty=False)
|
|
138
|
+
chart = (
|
|
139
|
+
base.mark_line(strokeWidth=2, point=alt.OverlayMarkDef(size=64, filled=True))
|
|
140
|
+
+ base.mark_point(size=90, opacity=0).add_params(hover)
|
|
141
|
+
+ base.mark_rule(color=muted, strokeWidth=1).encode(
|
|
142
|
+
opacity=alt.condition(hover, alt.value(0.35), alt.value(0)),
|
|
143
|
+
tooltip=[alt.Tooltip("period:N", title="Period"),
|
|
144
|
+
alt.Tooltip("series:N", title="Series"),
|
|
145
|
+
alt.Tooltip("value:Q", title="Value", format=",.2f")])
|
|
146
|
+
).properties(height=280).configure_view(strokeWidth=0).configure_axis(labelFontSize=11)
|
|
147
|
+
|
|
148
|
+
st.altair_chart(chart, use_container_width=True)
|
|
149
|
+
# The table view is not optional garnish: two of the three light-mode hues sit below 3:1
|
|
150
|
+
# against the surface, and the palette's relief rule requires visible labels or a table
|
|
151
|
+
# wherever that holds. It also keeps every series the scale guard dropped reachable.
|
|
152
|
+
with st.expander("π’ The numbers behind this chart"):
|
|
153
|
+
st.dataframe(df.pivot(index="period", columns="series", values="value").reindex(
|
|
154
|
+
df["period"].drop_duplicates()), use_container_width=True)
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def render_result(result: AgentResult) -> None:
|
|
158
|
+
"""Render an answer's supporting detail: faithfulness, timing, sources, trace."""
|
|
159
|
+
# The sidebar may override per-model prices, and that `config` is built further down this
|
|
160
|
+
# module. Resolve it defensively rather than depending on definition order β falling back to
|
|
161
|
+
# the environment's table keeps costs priced even if this is ever called earlier.
|
|
162
|
+
pricing = getattr(globals().get("config") or ENV, "model_pricing", {})
|
|
163
|
+
cols = st.columns(3)
|
|
164
|
+
fh = result.faithfulness
|
|
165
|
+
if fh and fh.judged:
|
|
166
|
+
verdict = "grounded" if fh.supported else "check claims"
|
|
167
|
+
cols[0].caption(f"π Evaluation: **{fh.score:.0%} grounded** ({verdict})")
|
|
168
|
+
elif not getattr(globals().get("config") or ENV, "judge_enabled", True):
|
|
169
|
+
# A deliberate cost decision, not a failure. The full note (what still ran) is in the
|
|
170
|
+
# tooltip so the caption doesn't turn into a paragraph.
|
|
171
|
+
cols[0].caption("π Evaluation: **scoring off** for this session",
|
|
172
|
+
help=(fh.notes if fh else ""))
|
|
173
|
+
elif fh:
|
|
174
|
+
cols[0].caption(f"π Evaluation: not judged β {fh.notes}")
|
|
175
|
+
if result.cached:
|
|
176
|
+
cols[1].caption(f"β‘ **{result.elapsed_s * 1000:.1f} ms** (cached)")
|
|
177
|
+
else:
|
|
178
|
+
cols[1].caption(f"β±οΈ **{result.elapsed_s:.2f} s**")
|
|
179
|
+
u = result.usage
|
|
180
|
+
if u and u.total_tokens:
|
|
181
|
+
# Combined across every model (generator + writer + judge + router/cache), each priced at
|
|
182
|
+
# its OWN rate via Usage.by_model β the pipeline runs three different models, so a single
|
|
183
|
+
# blended rate would misprice it.
|
|
184
|
+
cost = f" Β· π² **${result.cost_usd:.4f}**" if result.cost_usd is not None else ""
|
|
185
|
+
split = (f" (in **{u.prompt_tokens:,}** Β· out **{u.completion_tokens:,}**)"
|
|
186
|
+
if (u.prompt_tokens or u.completion_tokens) else "")
|
|
187
|
+
cols[2].caption(f"π’ **{u.total_tokens:,}** tokens{split}{cost}")
|
|
188
|
+
# An UNPRICED model contributes $0 to the figure above, so the total reads as
|
|
189
|
+
# authoritative while being quietly too low. Say so rather than let it pass as free.
|
|
190
|
+
if missing := unpriced_models(u, pricing):
|
|
191
|
+
cols[2].caption(f"β οΈ no price set for `{', '.join(missing)}` β the $ above "
|
|
192
|
+
"excludes it")
|
|
193
|
+
if len(u.by_model or {}) > 1:
|
|
194
|
+
with st.expander("π² Cost by model"):
|
|
195
|
+
st.caption("Each model is priced at its own rate. The pipeline deliberately runs "
|
|
196
|
+
"a cheaper model for routing/execution/judging and a stronger one for "
|
|
197
|
+
"the user-facing writing, so these will differ.")
|
|
198
|
+
for m, (p, c) in sorted(u.by_model.items(),
|
|
199
|
+
key=lambda kv: -(kv[1][0] + kv[1][1])):
|
|
200
|
+
rate = pricing.get(m) or pricing.get(m.split("/")[-1])
|
|
201
|
+
cached = (u.cached_by_model or {}).get(m, 0)
|
|
202
|
+
if rate:
|
|
203
|
+
billed = max(p - cached, 0) + cached * CACHED_INPUT_DISCOUNT
|
|
204
|
+
line = f"**${billed / 1e6 * rate[0] + c / 1e6 * rate[1]:.4f}**"
|
|
205
|
+
else:
|
|
206
|
+
line = "*not priced*"
|
|
207
|
+
st.markdown(f"- `{m}` β in {p:,} Β· out {c:,}"
|
|
208
|
+
+ (f" Β· {cached:,} cached" if cached else "") + f" β {line}")
|
|
209
|
+
|
|
210
|
+
if getattr(result, "answered_by", ""):
|
|
211
|
+
st.caption(f"π€ Answered by the **{result.answered_by}** agent")
|
|
212
|
+
|
|
213
|
+
if result.routed_tools and result.route_method not in ("", "all (small catalog)"):
|
|
214
|
+
chosen = ", ".join(f"`{t}`" for t in result.routed_tools)
|
|
215
|
+
st.caption(f"π§ Router ({result.route_method}) selected {len(result.routed_tools)} op(s): {chosen}")
|
|
216
|
+
|
|
217
|
+
if result.needs_keys:
|
|
218
|
+
hosts = ", ".join(f"`{h}`" for h in result.needs_keys)
|
|
219
|
+
st.warning(
|
|
220
|
+
f"π This API needs authentication for {hosts}. Re-load the spec with the "
|
|
221
|
+
f"**Auth** fields filled in (header name + value), then ask again."
|
|
222
|
+
)
|
|
223
|
+
|
|
224
|
+
# Evaluation β a multi-dimensional verdict (grounding + responsiveness), not just one opaque
|
|
225
|
+
# number, so it's clear WHY the status is what it is.
|
|
226
|
+
if fh and fh.judged:
|
|
227
|
+
#head = "looks good β
" if (fh.supported and fh.answers_question) else "see caveats β οΈ"
|
|
228
|
+
with st.expander(f"π Evaluation"):
|
|
229
|
+
def _dim(ok, label_ok, label_bad):
|
|
230
|
+
return f"{'β
' if ok else 'β οΈ'} {label_ok if ok else label_bad}"
|
|
231
|
+
st.markdown(
|
|
232
|
+
f"- **Grounding:** {fh.score:.0%} of the answer's claims are backed by the fetched data\n"
|
|
233
|
+
f"- **Addresses the question:** {_dim(fh.answers_question, 'yes', 'not fully')}")
|
|
234
|
+
if fh.notes:
|
|
235
|
+
st.markdown(f"**Validator's justification:** {fh.notes}")
|
|
236
|
+
if fh.unsupported_claims:
|
|
237
|
+
st.markdown(
|
|
238
|
+
f"**{len(fh.unsupported_claims)} claim(s) not grounded in the data** "
|
|
239
|
+
"β this is what pulled the score below 100%:"
|
|
240
|
+
)
|
|
241
|
+
for u in fh.unsupported_claims:
|
|
242
|
+
st.markdown(f"- {u}")
|
|
243
|
+
elif fh.supported:
|
|
244
|
+
st.markdown("β
Every claim in the answer was grounded in the fetched data.")
|
|
245
|
+
|
|
246
|
+
# Five-dimension quality scorecard (mirrors the eval rubric D1βD5).
|
|
247
|
+
rb = getattr(result, "rubric", None)
|
|
248
|
+
if rb:
|
|
249
|
+
_RB_EMOJI = {"correctness": "π―", "completeness": "π§©", "groundedness": "π",
|
|
250
|
+
"safety": "π‘οΈ", "communication": "π¬", "traceability": "π§"}
|
|
251
|
+
with st.expander(f"π
Answer scorecard β **{rb.overall}/5**"):
|
|
252
|
+
# The ladder takes the FIRST rule that matches, so five green dimensions beside one
|
|
253
|
+
# amber leave the reader guessing which one set the headline. Say it outright.
|
|
254
|
+
if getattr(rb, "overall_reason", ""):
|
|
255
|
+
st.markdown(f"**{rb.overall}/5** β {_md(rb.overall_reason)}")
|
|
256
|
+
st.caption(
|
|
257
|
+
"Five quality dimensions (same rubric as the offline eval). Groundedness and "
|
|
258
|
+
"communication are LLM-judged; safety is a deterministic scan; correctness and "
|
|
259
|
+
"completeness are derived from grounding, data-sufficiency and responsiveness "
|
|
260
|
+
"(there's no reference answer at answer time, so those two mean 'consistent with "
|
|
261
|
+
"the fetched data and addresses the question')."
|
|
262
|
+
+ (" **Traceability** appears on advisory answers only: it checks deterministically "
|
|
263
|
+
"that every recommendation has an owner and a timeframe, that its figures trace "
|
|
264
|
+
"back to the analysis, and that no forward dollar/percent outcome was invented."
|
|
265
|
+
if rb.traceability is not None else ""))
|
|
266
|
+
for d in rb.dims():
|
|
267
|
+
icon = "β
" if d.passed else "β οΈ"
|
|
268
|
+
line = (f"{_RB_EMOJI.get(d.name, 'β’')} **{d.name.title()}** β {icon} "
|
|
269
|
+
f"{d.score:.0%} Β· *{d.method}*")
|
|
270
|
+
if d.note:
|
|
271
|
+
line += f" \n {_md(d.note)}"
|
|
272
|
+
st.markdown(line)
|
|
273
|
+
|
|
274
|
+
# Both panels below are DEBUG surfaces β they name operations, arguments and sources.
|
|
275
|
+
# DEBUG_PANELS=0 removes them entirely for production.
|
|
276
|
+
if ENV.debug_panels and (result.citations or getattr(result, "source_note", "")):
|
|
277
|
+
with st.expander(f"π Sources ({len(result.citations)})", expanded=True):
|
|
278
|
+
# Which system answered belongs HERE, not in the prose: the answer must never name the
|
|
279
|
+
# machinery, but the reader must still be able to find out what it drew on.
|
|
280
|
+
if note := getattr(result, "source_note", ""):
|
|
281
|
+
st.caption(_md(note))
|
|
282
|
+
for c in result.citations:
|
|
283
|
+
# Same reasoning as the trace panel below: a debugging surface, gated off in
|
|
284
|
+
# production, so it carries the real source and operation.
|
|
285
|
+
st.markdown(f"- [{c.source}]({c.source}) Β· via `{c.locator}`"
|
|
286
|
+
f" Β· *{_tool_category(c.locator, _catalog_for_trace())}*")
|
|
287
|
+
|
|
288
|
+
if ENV.debug_panels and result.trace:
|
|
289
|
+
with st.expander("π§ How this answer was produced"):
|
|
290
|
+
is_doc = result.route_method == "doc"
|
|
291
|
+
mode = ("Looked the operation(s) up in the loaded spec β nothing was called."
|
|
292
|
+
if is_doc else
|
|
293
|
+
"Picked the relevant operation(s) and called them; here's each step.")
|
|
294
|
+
st.caption(f"**{len(result.trace)} step(s).** {mode}")
|
|
295
|
+
for i, t in enumerate(result.trace, 1):
|
|
296
|
+
src = str(t.source or "")
|
|
297
|
+
src_md = f"[{src}]({src})" if src.startswith("http") else f"`{src}`"
|
|
298
|
+
if is_doc:
|
|
299
|
+
st.markdown(f"{i}. π **{t.detail}** β {src_md} \n *(read from the spec, not executed)*")
|
|
300
|
+
else:
|
|
301
|
+
# FULL DETAIL, deliberately. This panel is a DEBUGGING surface, not a
|
|
302
|
+
# customer one β it is gated off in production (`debug_panels`), and the
|
|
303
|
+
# customer-visible live step trail shows only the category. Understanding why
|
|
304
|
+
# an answer came out the way it did needs the operation, its exact arguments
|
|
305
|
+
# and the source it hit; a category alone cannot be debugged.
|
|
306
|
+
icon = "β
" if t.ok else "β οΈ"
|
|
307
|
+
line = f"{i}. {icon} Called **`{t.tool}`**"
|
|
308
|
+
line += f" Β· *{_tool_category(t.tool, _catalog_for_trace())}*"
|
|
309
|
+
if t.args:
|
|
310
|
+
line += f" with `{t.args}`"
|
|
311
|
+
if t.detail:
|
|
312
|
+
line += f" β returned {t.detail}"
|
|
313
|
+
if not t.ok and t.error:
|
|
314
|
+
line += f" β **{t.error}**"
|
|
315
|
+
st.markdown(line)
|
|
316
|
+
if src:
|
|
317
|
+
st.caption(f" β³ {src_md}")
|
|
318
|
+
if t.error:
|
|
319
|
+
st.caption(f" β³ β οΈ {t.error}")
|
|
320
|
+
|
|
321
|
+
|
|
322
|
+
def greeting_message() -> str:
|
|
323
|
+
"""Welcome shown at the start of every session/chat (and after clearing)."""
|
|
324
|
+
return (
|
|
325
|
+
"π **Hi β I'm your API agent.**\n\n"
|
|
326
|
+
"Add a spec in the sidebar (**OpenAPI/Swagger, GraphQL, or a Postman/RAML/API-Blueprint "
|
|
327
|
+
"collection** β I auto-convert those), then ask me anything about it in plain language. "
|
|
328
|
+
"I pick the right operations, call them, and answer with **citations** and a "
|
|
329
|
+
"**faithfulness check**. I'm **read-only** and I never make data up.\n\n"
|
|
330
|
+
"**No spec yet? Try one of these free, no-auth demos** (Sidebar β *Spec URL* β *Add spec*):\n"
|
|
331
|
+
"- **GraphQL** β URL `https://countries.trevorblades.com/`, tick **GraphQL API** β ask "
|
|
332
|
+
"*\"list the continents\"* or *\"capital and currency of Japan?\"*\n"
|
|
333
|
+
"- **Postman β auto-converted** β URL "
|
|
334
|
+
"`https://raw.githubusercontent.com/aisabel/SWAPI-PostmanTest/master/SWAPI.postman_collection.json`, "
|
|
335
|
+
"Base URL `https://swapi.dev/api` β ask *\"list the Star Wars characters\"*\n\n"
|
|
336
|
+
"What would you like to find out?"
|
|
337
|
+
)
|
|
338
|
+
|
|
339
|
+
|
|
340
|
+
# --------------------------------------------------------------------------- #
|
|
341
|
+
# Specs: load one or more specs (each with its own auth) into one catalog.
|
|
342
|
+
# --------------------------------------------------------------------------- #
|
|
343
|
+
if "specs" not in st.session_state:
|
|
344
|
+
st.session_state.specs = []
|
|
345
|
+
if DEFAULT_SPEC:
|
|
346
|
+
try:
|
|
347
|
+
st.session_state.specs.append(
|
|
348
|
+
{"label": spec_label(DEFAULT_SPEC), "source": DEFAULT_SPEC,
|
|
349
|
+
"catalog": load_catalog(DEFAULT_SPEC)}
|
|
350
|
+
)
|
|
351
|
+
except Exception as e:
|
|
352
|
+
st.sidebar.error(f"Failed to load DEFAULT_SPEC: {e}")
|
|
353
|
+
|
|
354
|
+
st.sidebar.title("βοΈ Configuration")
|
|
355
|
+
st.sidebar.subheader("π Load an API")
|
|
356
|
+
st.sidebar.caption("Add one or more sources β a **spec** (OpenAPI/Swagger, GraphQL, or Postman/RAML/"
|
|
357
|
+
"API Blueprint) or a prose **API reference doc** (endpoints extracted for you to "
|
|
358
|
+
"review). The agent can use operations from any of them in a single answer.")
|
|
359
|
+
src_kind = st.sidebar.radio(
|
|
360
|
+
"Source type", ["API spec", "API reference doc", "Postgres warehouse"], horizontal=True,
|
|
361
|
+
help="A structured spec, a prose reference document (endpoints extracted deterministically "
|
|
362
|
+
"for review), or a Postgres warehouse whose schema functions become read-only tools.")
|
|
363
|
+
_is_doc = src_kind == "API reference doc"
|
|
364
|
+
_is_sql = src_kind == "Postgres warehouse"
|
|
365
|
+
|
|
366
|
+
if _is_sql:
|
|
367
|
+
# ---- SQL (warehouse) backend: schema functions -> read-only tools ----
|
|
368
|
+
sql_dsn = st.sidebar.text_input(
|
|
369
|
+
"Postgres DSN", ENV.sql_dsn, type="password",
|
|
370
|
+
help="e.g. postgresql://agent_ro:pass@localhost:7432/arq-dev β use a SELECT-only role; "
|
|
371
|
+
"connections are additionally forced read-only with a statement timeout.")
|
|
372
|
+
sql_schema = st.sidebar.text_input("Schema", ENV.sql_schema)
|
|
373
|
+
sql_prefix = st.sidebar.text_input(
|
|
374
|
+
"Function prefix", ENV.sql_fn_prefix,
|
|
375
|
+
help="Only functions matching this prefix are exposed as tools (the whitelist).")
|
|
376
|
+
sql_label = st.sidebar.text_input("Label (optional)", "", key="sql_label")
|
|
377
|
+
if st.sidebar.button("β Connect warehouse"):
|
|
378
|
+
if not sql_dsn.strip():
|
|
379
|
+
st.sidebar.warning("Enter a Postgres DSN first.")
|
|
380
|
+
else:
|
|
381
|
+
try:
|
|
382
|
+
from api_agent.sql_loader import load_sql_catalog
|
|
383
|
+
loaded = load_sql_catalog(
|
|
384
|
+
sql_dsn.strip(), schema=sql_schema.strip() or "gold",
|
|
385
|
+
fn_prefix=sql_prefix.strip(), timeout=ENV.http_timeout,
|
|
386
|
+
statement_timeout_ms=ENV.sql_timeout * 1000,
|
|
387
|
+
profile_rows=ENV.profile_rows)
|
|
388
|
+
if not loaded.tools:
|
|
389
|
+
st.sidebar.error(
|
|
390
|
+
f"No functions matching `{sql_prefix}*` found in schema `{sql_schema}`.")
|
|
391
|
+
else:
|
|
392
|
+
st.session_state.specs.append(
|
|
393
|
+
{"label": sql_label.strip() or f"{sql_schema} (warehouse)",
|
|
394
|
+
"source": f"sql://{sql_schema}", "catalog": loaded})
|
|
395
|
+
st.sidebar.success(f"Connected β **{len(loaded.tools)}** warehouse functions.")
|
|
396
|
+
except Exception as e:
|
|
397
|
+
st.sidebar.error(f"Failed β {e}")
|
|
398
|
+
spec_src, spec_files = "", [] # the spec/doc widgets below don't apply to a warehouse
|
|
399
|
+
|
|
400
|
+
if not _is_sql:
|
|
401
|
+
spec_src = st.sidebar.text_input("URL or file path", "")
|
|
402
|
+
spec_files = st.sidebar.file_uploader(
|
|
403
|
+
"β¦or upload file(s)",
|
|
404
|
+
type=(["md", "markdown", "txt"] if _is_doc
|
|
405
|
+
else ["json", "yaml", "yml", "graphql", "gql", "sdl", "raml", "apib"]),
|
|
406
|
+
accept_multiple_files=True,
|
|
407
|
+
help=("Prose reference doc(s) (.md/.txt) β combined into one." if _is_doc
|
|
408
|
+
else "OpenAPI/Swagger, GraphQL SDL, or Postman/RAML/API-Blueprint. Each file loads as a "
|
|
409
|
+
"separate source.")) or []
|
|
410
|
+
with st.sidebar.expander("Auth for this source (optional)"):
|
|
411
|
+
auth_header = st.text_input("Header name", "Authorization", help="e.g. Authorization, Cookie, or X-API-Key")
|
|
412
|
+
auth_value = st.text_input(
|
|
413
|
+
"Header value", "", type="password",
|
|
414
|
+
help="e.g. 'Bearer <token>', 'adminhtml=<cookie>', or your API key")
|
|
415
|
+
label_in = st.text_input("Label (optional)", "", help="Short name shown in the trace; defaults to the file/URL name")
|
|
416
|
+
|
|
417
|
+
if not _is_doc and not _is_sql:
|
|
418
|
+
# ---- Structured spec (OpenAPI / GraphQL / Postman / RAML / API Blueprint) ----
|
|
419
|
+
max_ops = st.sidebar.number_input(
|
|
420
|
+
"Max operations to load", 10, 5000, ENV.max_operations, step=50,
|
|
421
|
+
help="Large specs (e.g. GitHub has 600+) load up to this many ops; the router narrows them per query.")
|
|
422
|
+
_spec_name = (spec_src or (spec_files[0].name if spec_files else "")).lower().rstrip("/")
|
|
423
|
+
is_graphql = st.sidebar.checkbox(
|
|
424
|
+
"GraphQL API", value=_spec_name.endswith((".graphql", ".gql", ".sdl")),
|
|
425
|
+
help="Treat the source as GraphQL: introspect an endpoint URL, or load a local .graphql/.sdl "
|
|
426
|
+
"schema (then set the endpoint below). Only query fields become callable β mutations stay read-only.")
|
|
427
|
+
gql_endpoint = st.sidebar.text_input(
|
|
428
|
+
"GraphQL endpoint (for SDL files)", "",
|
|
429
|
+
help="Server URL to query when the source is a .graphql/.sdl file. Blank if the source is already the endpoint URL.",
|
|
430
|
+
) if is_graphql else ""
|
|
431
|
+
base_url_in = "" if is_graphql else st.sidebar.text_input(
|
|
432
|
+
"Base URL (for Postman/RAML/Blueprint)", "",
|
|
433
|
+
help="The server to call for a converted spec, e.g. https://swapi.dev/api. Ignored for OpenAPI "
|
|
434
|
+
"specs that already declare their server. (Conversion needs Node/npx on PATH.)")
|
|
435
|
+
if base_url_in and not base_url_in.lower().startswith(("http://", "https://")):
|
|
436
|
+
base_url_in = "https://" + base_url_in.strip() # add a scheme if the user omitted it
|
|
437
|
+
if st.sidebar.button("β Add spec"):
|
|
438
|
+
# Each source loads as its own entry: the URL/path (if given) + every uploaded file.
|
|
439
|
+
import tempfile
|
|
440
|
+
jobs = [] # (display_name, path_or_url, is_temp_file)
|
|
441
|
+
if spec_src.strip():
|
|
442
|
+
jobs.append((spec_src.strip(), spec_src.strip(), False))
|
|
443
|
+
for f in spec_files:
|
|
444
|
+
ext = os.path.splitext(f.name)[1] or ".txt" # keep extension so format sniffing works
|
|
445
|
+
fd, tmp = tempfile.mkstemp(suffix=ext)
|
|
446
|
+
with os.fdopen(fd, "wb") as fh:
|
|
447
|
+
fh.write(f.getvalue())
|
|
448
|
+
jobs.append((f.name, tmp, True))
|
|
449
|
+
if not jobs:
|
|
450
|
+
st.sidebar.warning("Enter a spec URL/path or upload spec file(s) first.")
|
|
451
|
+
else:
|
|
452
|
+
single, added_ops, added, errors = len(jobs) == 1, 0, 0, []
|
|
453
|
+
for display, src, is_tmp in jobs:
|
|
454
|
+
try:
|
|
455
|
+
loaded = load_catalog(
|
|
456
|
+
src, graphql=is_graphql, endpoint=gql_endpoint or None,
|
|
457
|
+
base_url=base_url_in or None, max_operations=int(max_ops),
|
|
458
|
+
auth_header=auth_header or None, auth_value=auth_value or None)
|
|
459
|
+
if not loaded.tools:
|
|
460
|
+
errors.append(f"{display}: no callable read operations")
|
|
461
|
+
else:
|
|
462
|
+
st.session_state.specs.append(
|
|
463
|
+
{"label": (label_in.strip() if single else "") or spec_label(display),
|
|
464
|
+
"source": display, "catalog": loaded})
|
|
465
|
+
added_ops += len(loaded.tools)
|
|
466
|
+
added += 1
|
|
467
|
+
except Exception as e:
|
|
468
|
+
errors.append(f"{display}: {e}")
|
|
469
|
+
finally:
|
|
470
|
+
if is_tmp:
|
|
471
|
+
try:
|
|
472
|
+
os.remove(src)
|
|
473
|
+
except OSError:
|
|
474
|
+
pass
|
|
475
|
+
if added:
|
|
476
|
+
st.sidebar.success(f"Added **{added_ops}** operations from **{added}** source(s).")
|
|
477
|
+
for err in errors:
|
|
478
|
+
st.sidebar.error(f"Failed β {err}")
|
|
479
|
+
elif _is_doc:
|
|
480
|
+
# ---- Prose API reference doc: extract (structural, free) β review β load ----
|
|
481
|
+
base_url_in = st.sidebar.text_input(
|
|
482
|
+
"Base URL for these endpoints", "",
|
|
483
|
+
help="Server to call, e.g. https://api.example.com. Overrides any URL found in the doc.")
|
|
484
|
+
doc_use_llm = st.sidebar.checkbox(
|
|
485
|
+
"π€ Also use the LLM to enrich params (slower, costs tokens)", value=False,
|
|
486
|
+
help="Endpoints are extracted from the doc's method+path lines for free. Tick to also have the "
|
|
487
|
+
"LLM add param types/enums, or to handle a prose-only doc. Uses your .env model.")
|
|
488
|
+
if st.session_state.get("doc_loaded_note"):
|
|
489
|
+
st.sidebar.success(st.session_state["doc_loaded_note"])
|
|
490
|
+
if st.sidebar.button("π Extract endpoints"):
|
|
491
|
+
from api_agent.doc_extract import build_doc_openapi, fetch_doc_text, looks_graphql_heavy
|
|
492
|
+
from api_agent.llm import LLMClient
|
|
493
|
+
try:
|
|
494
|
+
parts = []
|
|
495
|
+
if spec_src.strip():
|
|
496
|
+
parts.append(fetch_doc_text(spec_src.strip(), timeout=ENV.http_timeout))
|
|
497
|
+
for f in spec_files:
|
|
498
|
+
parts.append(f.getvalue().decode("utf-8", "ignore"))
|
|
499
|
+
text = "\n\n".join(p for p in parts if p and p.strip()) # combine all docs into one
|
|
500
|
+
if not text.strip():
|
|
501
|
+
st.sidebar.warning("Provide a doc URL/path or upload doc file(s) first.")
|
|
502
|
+
elif doc_use_llm and Config.from_env().problems():
|
|
503
|
+
st.sidebar.warning("LLM enrichment needs LLM_BASE_URL + LLM_API_KEY in .env β or untick it.")
|
|
504
|
+
else:
|
|
505
|
+
st.session_state.pop("doc_loaded_note", None)
|
|
506
|
+
prog = st.sidebar.progress(0.0, text="Extractingβ¦")
|
|
507
|
+
_cfg = Config.from_env()
|
|
508
|
+
st.session_state.doc_draft = build_doc_openapi(
|
|
509
|
+
text, llm=LLMClient(_cfg) if doc_use_llm else None,
|
|
510
|
+
model=_cfg.generator_model, use_llm=doc_use_llm,
|
|
511
|
+
progress=lambda d, t: prog.progress(d / t, text=f"Enriching chunk {d}/{t}β¦"))
|
|
512
|
+
prog.empty()
|
|
513
|
+
heavy, n = looks_graphql_heavy(text)
|
|
514
|
+
st.session_state.doc_gql_note = (
|
|
515
|
+
f"βΉοΈ This doc also describes ~{n} **GraphQL** operations (e.g. `useGetTasksQuery`); "
|
|
516
|
+
"those have no REST path β load the **GraphQL API** for them.") if heavy else None
|
|
517
|
+
except Exception as e:
|
|
518
|
+
st.sidebar.error(f"Extraction failed: {e}")
|
|
519
|
+
|
|
520
|
+
draft = st.session_state.get("doc_draft")
|
|
521
|
+
if draft:
|
|
522
|
+
from api_agent.doc_extract import endpoint_rows, prune_openapi
|
|
523
|
+
# Show ALL methods, not just GET. Writes (POST/PUT/DELETEβ¦) load as DOCUMENTATION only β
|
|
524
|
+
# the agent can describe them ("what params does create-order take?") but the read-only
|
|
525
|
+
# guardrail never calls them; only GET/HEAD become callable tools.
|
|
526
|
+
rows = endpoint_rows(draft)
|
|
527
|
+
_readable = {"get", "head"}
|
|
528
|
+
n_get = sum(1 for m, _p, _op in rows if m in _readable)
|
|
529
|
+
n_write = len(rows) - n_get
|
|
530
|
+
if st.session_state.get("doc_gql_note"):
|
|
531
|
+
st.sidebar.info(st.session_state["doc_gql_note"])
|
|
532
|
+
xa = draft.get("x-auth") or {}
|
|
533
|
+
if xa.get("scheme"):
|
|
534
|
+
st.sidebar.caption(f"π Doc suggests **{xa['scheme']}** auth"
|
|
535
|
+
+ (f" via `{xa['header']}`" if xa.get("header") else "") + " β set it in *Auth* above.")
|
|
536
|
+
_found = f"**{len(rows)} endpoint(s) found**"
|
|
537
|
+
if n_write:
|
|
538
|
+
_found += f" β {n_get} callable GET/HEAD + {n_write} write op(s) (loaded as **read-only docs** the agent can describe, never call)"
|
|
539
|
+
st.sidebar.caption(_found + " β untick any to drop, then load:")
|
|
540
|
+
keep = set()
|
|
541
|
+
for m, p, op in rows:
|
|
542
|
+
tag = "" if m in _readable else " Β· _docs-only_"
|
|
543
|
+
label = f"`{m.upper()} {p}`" + tag + (f" β {op.get('summary')}" if op.get("summary") else "")
|
|
544
|
+
if st.sidebar.checkbox(label, value=True, key=f"docep::{m}::{p}"):
|
|
545
|
+
keep.add((m, p))
|
|
546
|
+
_keep_has_get = any(m in _readable for m, _p in keep)
|
|
547
|
+
# The endpoints are relative paths β they need a real server. Prefer the field; fall back to
|
|
548
|
+
# a real (non-placeholder) base URL found in the doc. Show which is in effect.
|
|
549
|
+
_doc_base = (draft.get("servers") or [{}])[0].get("url", "")
|
|
550
|
+
_eff_base = base_url_in.strip().rstrip("/") or _doc_base
|
|
551
|
+
if _eff_base and not _eff_base.lower().startswith(("http://", "https://")):
|
|
552
|
+
_eff_base = "https://" + _eff_base # e.g. "viciqa.tapdemo.com" β add scheme
|
|
553
|
+
if _eff_base:
|
|
554
|
+
st.sidebar.caption(f"π Base URL: `{_eff_base}`")
|
|
555
|
+
else:
|
|
556
|
+
st.sidebar.caption("π **No base URL** β the doc uses a placeholder like `https://{host}`. "
|
|
557
|
+
"Set **Base URL for these endpoints** above (e.g. `https://your-instance.tapclicks.com`).")
|
|
558
|
+
c1, c2 = st.sidebar.columns(2)
|
|
559
|
+
if c1.button(f"β
Approve & load ({len(keep)})"):
|
|
560
|
+
# A base URL is only needed to CALL an endpoint (GET/HEAD). A doc with only write ops
|
|
561
|
+
# loads as read-only documentation and is never called, so it doesn't require one.
|
|
562
|
+
if _keep_has_get and not _eff_base:
|
|
563
|
+
st.sidebar.warning("β οΈ Set a **Base URL for these endpoints** first β the endpoints are "
|
|
564
|
+
"relative and the doc's base is a placeholder (`https://{host}`), so "
|
|
565
|
+
"calls would have no host.")
|
|
566
|
+
st.stop()
|
|
567
|
+
pruned = prune_openapi(draft, keep)
|
|
568
|
+
pruned["servers"] = [{"url": _eff_base or "https://{host}"}]
|
|
569
|
+
auth = (auth_header, auth_value) if auth_value else None
|
|
570
|
+
cat = build_catalog(pruned, auth=auth)
|
|
571
|
+
if not cat.tools and not cat.reference:
|
|
572
|
+
st.sidebar.warning("Nothing approved β tick at least one endpoint to load.")
|
|
573
|
+
else:
|
|
574
|
+
title = (draft.get("info") or {}).get("title") or "extracted"
|
|
575
|
+
st.session_state.specs.append(
|
|
576
|
+
{"label": label_in.strip() or f"doc:{title}", "source": "doc-extract", "catalog": cat})
|
|
577
|
+
st.session_state.pop("doc_draft", None)
|
|
578
|
+
n_doc = max(len(cat.reference) - len(cat.tools), 0)
|
|
579
|
+
note = f"β
Loaded **{len(cat.tools)}** callable GET op(s) from the doc"
|
|
580
|
+
note += (f" + **{n_doc}** documented write op(s) (describe-only)." if n_doc else ".")
|
|
581
|
+
st.session_state.doc_loaded_note = note
|
|
582
|
+
st.rerun()
|
|
583
|
+
if c2.button("β Discard"):
|
|
584
|
+
st.session_state.pop("doc_draft", None)
|
|
585
|
+
st.rerun()
|
|
586
|
+
|
|
587
|
+
# Loaded specs, each removable
|
|
588
|
+
if st.session_state.specs:
|
|
589
|
+
st.sidebar.caption("**Loaded sources**")
|
|
590
|
+
for i, s in enumerate(st.session_state.specs):
|
|
591
|
+
c1, c2 = st.sidebar.columns([5, 1])
|
|
592
|
+
c1.caption(f"β’ **{s['label']}** Β· {len(s['catalog'].tools)} ops")
|
|
593
|
+
if c2.button("β", key=f"rmspec_{i}", help=f"Remove {s['label']}"):
|
|
594
|
+
st.session_state.specs.pop(i)
|
|
595
|
+
st.rerun()
|
|
596
|
+
else:
|
|
597
|
+
st.sidebar.info("No specs loaded β add one above (see the demos in the chat).")
|
|
598
|
+
|
|
599
|
+
catalog = (
|
|
600
|
+
merge_catalogs([(s["label"], s["catalog"]) for s in st.session_state.specs])
|
|
601
|
+
if st.session_state.specs else Catalog([])
|
|
602
|
+
)
|
|
603
|
+
st.session_state.catalog = catalog
|
|
604
|
+
st.session_state.catalog_label = " + ".join(s["label"] for s in st.session_state.specs) or "(none)"
|
|
605
|
+
st.sidebar.caption(f"Active: **{len(st.session_state.specs)} source(s)** Β· {len(catalog.tools)} operations")
|
|
606
|
+
|
|
607
|
+
# --------------------------------------------------------------------------- #
|
|
608
|
+
# Model + behaviour
|
|
609
|
+
# --------------------------------------------------------------------------- #
|
|
610
|
+
st.sidebar.subheader("Model")
|
|
611
|
+
base_url = st.sidebar.text_input("LLM base URL", ENV.base_url)
|
|
612
|
+
api_key = st.sidebar.text_input("API key", ENV.api_key, type="password")
|
|
613
|
+
generator_model = st.sidebar.text_input("Generator model", ENV.generator_model)
|
|
614
|
+
synthesis_model = st.sidebar.text_input(
|
|
615
|
+
"Writer model (blank = generator)", ENV.synthesis_model,
|
|
616
|
+
help="Used ONLY for the user-facing writing stages β synthesis, self-review and resynthesis. "
|
|
617
|
+
"Everything else (router, executor, judge) stays on the generator model. Point this at a "
|
|
618
|
+
"stronger model to raise answer quality without paying for it on every call.")
|
|
619
|
+
judge_enabled = st.sidebar.checkbox(
|
|
620
|
+
"Quality scoring (LLM judges)", value=bool(ENV.judge_enabled),
|
|
621
|
+
help="On: the answer is graded β a grounding fact-check, a responsiveness check and a "
|
|
622
|
+
"presentation check, plus the 0β5 scorecard. That is up to three extra LLM calls per "
|
|
623
|
+
"answered question, and the grounding one re-sends the whole evidence block, so it is the "
|
|
624
|
+
"most expensive optional part of a query.\n\n"
|
|
625
|
+
"Off: none of those calls are made and no scorecard is shown. Every DETERMINISTIC check "
|
|
626
|
+
"still runs β figures are still matched against the fetched data, the total / attribution "
|
|
627
|
+
"/ superlative repairs still fire, and the confidentiality and PII scrubs are untouched. "
|
|
628
|
+
"The answer is still checked; it just isn't scored. Default comes from JUDGE_ENABLED.")
|
|
629
|
+
judge_model = st.sidebar.text_input("Judge model", ENV.judge_model,
|
|
630
|
+
disabled=not judge_enabled)
|
|
631
|
+
# Routing, embeddings and the tool-iteration limit are set in .env (not the UI) β they rarely change
|
|
632
|
+
# per session. The router AND cache-match LLM reuse the generator model unless ROUTER_MODEL is set.
|
|
633
|
+
# st.sidebar.caption(
|
|
634
|
+
# f"βοΈ Routing, embeddings & tool-iteration limits are set in **.env** "
|
|
635
|
+
# f"(`ROUTER_*`, `EMBEDDING_MODEL` = `{ENV.embedding_model or 'lexical only'}`, "
|
|
636
|
+
# f"`MAX_TOOL_ITERATIONS` = `{ENV.max_tool_iterations}`). The router & cache-match LLM reuse the "
|
|
637
|
+
# f"**generator model** unless `ROUTER_MODEL` is set.")
|
|
638
|
+
|
|
639
|
+
st.sidebar.subheader("π² Budget & cost")
|
|
640
|
+
max_response_tokens = st.sidebar.number_input(
|
|
641
|
+
"Max tokens / response (0 = unlimited)", min_value=0, value=int(ENV.max_response_tokens), step=1000,
|
|
642
|
+
help="Spending guardrail: stop a single response once its cumulative tokens reach this. The "
|
|
643
|
+
"answer is returned with what was gathered (marked partial).",
|
|
644
|
+
)
|
|
645
|
+
def _rate_of(model: str) -> tuple[float, float]:
|
|
646
|
+
return (ENV.model_pricing.get(model)
|
|
647
|
+
or ENV.model_pricing.get((model or "").split("/")[-1]) or (0.0, 0.0))
|
|
648
|
+
|
|
649
|
+
# Prices are keyed by model name so they RE-PREFILL when you switch models (type gpt-4o-mini β
|
|
650
|
+
# 0.15/0.60 appears). Known OpenAI/Groq/etc. models auto-fill from config.DEFAULT_MODEL_PRICING; an
|
|
651
|
+
# unknown model prefills 0 (tokens shown, no $) β type a rate to price it.
|
|
652
|
+
pricing = dict(ENV.model_pricing)
|
|
653
|
+
|
|
654
|
+
# ---- Generator pricing (executor + synthesis; ALSO the router & cache-match LLM, which reuse it) --
|
|
655
|
+
st.sidebar.caption(f"**Generator** pricing Β· `{generator_model or '(unset)'}` β routing, the executor tool-loop, planning and the judge")
|
|
656
|
+
_grate = _rate_of(generator_model)
|
|
657
|
+
gen_in = st.sidebar.number_input(
|
|
658
|
+
"Generator input price ($ / 1M tokens)", min_value=0.0, value=float(_grate[0]), step=0.05,
|
|
659
|
+
format="%.4f", key=f"gen_price_in::{generator_model}",
|
|
660
|
+
help="Applies to routing, the executor tool-loop, planning and the judge. NOT to synthesis β "
|
|
661
|
+
"that moved to the Writer model below. 0 = unknown/free β not counted in $.")
|
|
662
|
+
gen_out = st.sidebar.number_input(
|
|
663
|
+
"Generator output price ($ / 1M tokens)", min_value=0.0, value=float(_grate[1]), step=0.05,
|
|
664
|
+
format="%.4f", key=f"gen_price_out::{generator_model}")
|
|
665
|
+
if gen_in or gen_out:
|
|
666
|
+
for _m in {generator_model, ENV.router_model or generator_model}:
|
|
667
|
+
if _m:
|
|
668
|
+
pricing[_m] = (gen_in, gen_out)
|
|
669
|
+
|
|
670
|
+
# ---- Writer pricing (synthesis + self-review + resynthesis). Only shown when the writer is a
|
|
671
|
+
# DISTINCT model; blank or same-as-generator means the generator price above already covers it. ----
|
|
672
|
+
if synthesis_model and synthesis_model != generator_model:
|
|
673
|
+
st.sidebar.caption(f"**Writer** pricing Β· `{synthesis_model}`")
|
|
674
|
+
_wrate = _rate_of(synthesis_model)
|
|
675
|
+
writer_in = st.sidebar.number_input(
|
|
676
|
+
"Writer input price ($ / 1M tokens)", min_value=0.0, value=float(_wrate[0]), step=0.05,
|
|
677
|
+
format="%.4f", key=f"writer_price_in::{synthesis_model}",
|
|
678
|
+
help="Applies to synthesis, self-review and resynthesis β roughly 42% of input tokens.")
|
|
679
|
+
writer_out = st.sidebar.number_input(
|
|
680
|
+
"Writer output price ($ / 1M tokens)", min_value=0.0, value=float(_wrate[1]), step=0.05,
|
|
681
|
+
format="%.4f", key=f"writer_price_out::{synthesis_model}")
|
|
682
|
+
if writer_in or writer_out:
|
|
683
|
+
pricing[synthesis_model] = (writer_in, writer_out)
|
|
684
|
+
|
|
685
|
+
# ---- Judge pricing (grounding + responsiveness checks). Only shown when the judge is a DISTINCT
|
|
686
|
+
# model; when judge == generator the generator price above already covers it. ----
|
|
687
|
+
if judge_enabled and judge_model and judge_model != generator_model:
|
|
688
|
+
st.sidebar.caption(f"**Judge** pricing Β· `{judge_model}`")
|
|
689
|
+
_jrate = _rate_of(judge_model)
|
|
690
|
+
judge_in = st.sidebar.number_input(
|
|
691
|
+
"Judge input price ($ / 1M tokens)", min_value=0.0, value=float(_jrate[0]), step=0.05,
|
|
692
|
+
format="%.4f", key=f"judge_price_in::{judge_model}",
|
|
693
|
+
help="Applies to the grounding + responsiveness checks. Set so the judge's tokens are priced "
|
|
694
|
+
"at ITS own rate (they're already counted in the total either way).")
|
|
695
|
+
judge_out = st.sidebar.number_input(
|
|
696
|
+
"Judge output price ($ / 1M tokens)", min_value=0.0, value=float(_jrate[1]), step=0.05,
|
|
697
|
+
format="%.4f", key=f"judge_price_out::{judge_model}")
|
|
698
|
+
if judge_in or judge_out:
|
|
699
|
+
pricing[judge_model] = (judge_in, judge_out)
|
|
700
|
+
|
|
701
|
+
tool_names = list(catalog.tools.keys())
|
|
702
|
+
enabled = st.sidebar.multiselect(
|
|
703
|
+
"Enabled operations", tool_names, default=tool_names,
|
|
704
|
+
key=f"enabled::{st.session_state.catalog_label}",
|
|
705
|
+
)
|
|
706
|
+
|
|
707
|
+
# Routing / embeddings / max-iterations are intentionally NOT overridden here β they come from .env
|
|
708
|
+
# (ENV already carries them). Only the per-session fields the sidebar still exposes are overridden.
|
|
709
|
+
config = ENV.override(
|
|
710
|
+
base_url=base_url, api_key=api_key, generator_model=generator_model, judge_model=judge_model,
|
|
711
|
+
judge_enabled=bool(judge_enabled), synthesis_model=synthesis_model,
|
|
712
|
+
max_response_tokens=int(max_response_tokens), model_pricing=pricing,
|
|
713
|
+
)
|
|
714
|
+
for issue in config.problems():
|
|
715
|
+
st.sidebar.warning(issue)
|
|
716
|
+
|
|
717
|
+
_clear_chat, _clear_cache = st.sidebar.columns(2)
|
|
718
|
+
if _clear_chat.button("ποΈ Clear chat", help="Remove the visible conversation."):
|
|
719
|
+
st.session_state.pop("messages", None)
|
|
720
|
+
if _clear_cache.button("β‘ Clear cache", help="Empty the in-memory answer cache so every question "
|
|
721
|
+
"re-runs fresh (the cache lives in memory only β there's no file)."):
|
|
722
|
+
from api_agent.agent import clear_query_cache
|
|
723
|
+
st.sidebar.success(f"Cleared {clear_query_cache()} cached answer(s).")
|
|
724
|
+
|
|
725
|
+
# One agent per BACKEND FAMILY when both are loaded (warehouse first β ties prefer the
|
|
726
|
+
# direct-SQL path); the Supervisor routes each question to ONE of them and falls back to
|
|
727
|
+
# the other if the chosen source is unreachable. A single family keeps today's one-agent path.
|
|
728
|
+
_sql_specs = [s for s in st.session_state.specs
|
|
729
|
+
if any(t.backend == "sql" for t in s["catalog"].tools.values())]
|
|
730
|
+
_http_specs = [s for s in st.session_state.specs if s not in _sql_specs]
|
|
731
|
+
if _sql_specs and _http_specs:
|
|
732
|
+
def _family_agent(specs):
|
|
733
|
+
return Agent(config, merge_catalogs([(s["label"], s["catalog"]) for s in specs]))
|
|
734
|
+
agent = Supervisor(config, [("warehouse", _family_agent(_sql_specs)),
|
|
735
|
+
("api", _family_agent(_http_specs))])
|
|
736
|
+
else:
|
|
737
|
+
agent = Agent(config, catalog)
|
|
738
|
+
|
|
739
|
+
# --------------------------------------------------------------------------- #
|
|
740
|
+
# Main: chat
|
|
741
|
+
# --------------------------------------------------------------------------- #
|
|
742
|
+
st.title("π°οΈ API Agent")
|
|
743
|
+
st.caption(
|
|
744
|
+
"Load one or more specs in the sidebar, then ask a question β the agent picks the right "
|
|
745
|
+
"operations across all loaded specs, calls them, and answers with citations + a faithfulness check."
|
|
746
|
+
)
|
|
747
|
+
|
|
748
|
+
if "messages" not in st.session_state:
|
|
749
|
+
st.session_state.messages = []
|
|
750
|
+
|
|
751
|
+
if not st.session_state.messages:
|
|
752
|
+
with st.chat_message("assistant"):
|
|
753
|
+
st.markdown(greeting_message())
|
|
754
|
+
|
|
755
|
+
for m in st.session_state.messages:
|
|
756
|
+
with st.chat_message(m["role"]):
|
|
757
|
+
st.markdown(_md(m["content"]))
|
|
758
|
+
if m.get("steps"): # the live-status log, kept (collapsed) so it doesn't vanish on completion
|
|
759
|
+
with st.expander("βοΈ Steps the agent took", expanded=False):
|
|
760
|
+
st.markdown(m["steps"])
|
|
761
|
+
if m.get("result") is not None:
|
|
762
|
+
_render_trend_chart(m["result"]) # above the metadata, below the answer it illustrates
|
|
763
|
+
render_result(m["result"])
|
|
764
|
+
|
|
765
|
+
# A response can take many seconds. Rather than block the whole script (which would freeze the
|
|
766
|
+
# page and make a Stop button unclickable), the agent runs in a BACKGROUND THREAD that pushes
|
|
767
|
+
# progress events onto a queue; the main script polls that queue on a short auto-rerun and can set
|
|
768
|
+
# a cancel Event the agent checks at each step. So the user sees live steps AND can hit Stop.
|
|
769
|
+
_STAGE_ICONS = {
|
|
770
|
+
"cache": "β‘", "route": "π§", "intent": "π‘οΈ", "doc": "π",
|
|
771
|
+
"call": "π§", "complete": "β", "synthesize": "βοΈ", "validate": "π",
|
|
772
|
+
"retry": "β»οΈ", "abstain": "β οΈ", "budget": "π",
|
|
773
|
+
}
|
|
774
|
+
_SPINNER = "ββββ" # rotates once per poll β a clearly visible "still working" indicator (no flicker)
|
|
775
|
+
# Pin the viewport to the NEW question so the long run doesn't look like it answered the old one.
|
|
776
|
+
_SCROLL_PIN = """<script>
|
|
777
|
+
(function () {
|
|
778
|
+
const doc = window.parent.document;
|
|
779
|
+
let n = 0;
|
|
780
|
+
function pin() {
|
|
781
|
+
const msgs = doc.querySelectorAll('[data-testid="stChatMessage"]');
|
|
782
|
+
const t = msgs[msgs.length - 2] || msgs[msgs.length - 1]; // the new USER question
|
|
783
|
+
if (t) t.scrollIntoView({block: "start", behavior: "instant"});
|
|
784
|
+
if (++n < 12) setTimeout(pin, 250);
|
|
785
|
+
}
|
|
786
|
+
pin();
|
|
787
|
+
})();
|
|
788
|
+
</script>"""
|
|
789
|
+
|
|
790
|
+
|
|
791
|
+
def _drain(run) -> bool:
|
|
792
|
+
"""Move all queued progress events into run['events']; return True once the worker is done."""
|
|
793
|
+
done = False
|
|
794
|
+
while True:
|
|
795
|
+
try:
|
|
796
|
+
kind, payload = run["queue"].get_nowait()
|
|
797
|
+
except queue.Empty:
|
|
798
|
+
break
|
|
799
|
+
if kind == "done":
|
|
800
|
+
done = True
|
|
801
|
+
else:
|
|
802
|
+
run["events"].append(payload)
|
|
803
|
+
return done
|
|
804
|
+
|
|
805
|
+
|
|
806
|
+
def _events_summary(run) -> tuple[str, str, str, str]:
|
|
807
|
+
"""Fold the queued progress events into (current-action header, step-log markdown, token line,
|
|
808
|
+
streaming answer). Rendered as plain markdown so the live view updates IN PLACE β unlike an
|
|
809
|
+
`st.status` expander, which replays an open/close animation on every poll (the flicker)."""
|
|
810
|
+
header, tokens, answer = "Startingβ¦", "", ""
|
|
811
|
+
lines: list[str] = []
|
|
812
|
+
for ev in run["events"]:
|
|
813
|
+
stage = ev.get("stage", "")
|
|
814
|
+
if stage == "routed":
|
|
815
|
+
continue
|
|
816
|
+
if stage == "usage":
|
|
817
|
+
cost = ev.get("cost")
|
|
818
|
+
tail = f" Β· π²${cost:.4f}" if cost is not None else ""
|
|
819
|
+
tokens = (f"π’ {ev.get('total', 0):,} tokens "
|
|
820
|
+
f"(in {ev.get('prompt', 0):,} Β· out {ev.get('completion', 0):,}){tail}")
|
|
821
|
+
continue
|
|
822
|
+
msg = ev.get("message", "")
|
|
823
|
+
if stage == "answer":
|
|
824
|
+
answer = msg
|
|
825
|
+
elif stage == "call_done":
|
|
826
|
+
lines.append(f"- {'β' if ev.get('ok') else 'β'} {msg}")
|
|
827
|
+
else:
|
|
828
|
+
header = msg
|
|
829
|
+
lines.append(f"- {_STAGE_ICONS.get(stage, 'β’')} {msg}")
|
|
830
|
+
return header, "\n".join(lines), tokens, answer
|
|
831
|
+
|
|
832
|
+
|
|
833
|
+
def _start_run(the_agent, prompt, history, enabled_tools) -> None:
|
|
834
|
+
"""Kick off agent.run in a daemon thread. The thread NEVER touches st.* β it only pushes events
|
|
835
|
+
onto a thread-safe queue and stashes the result; the main script renders on its poll reruns."""
|
|
836
|
+
q: queue.Queue = queue.Queue()
|
|
837
|
+
cancel = threading.Event()
|
|
838
|
+
holder: dict = {}
|
|
839
|
+
|
|
840
|
+
def worker():
|
|
841
|
+
try:
|
|
842
|
+
holder["result"] = the_agent.run(
|
|
843
|
+
prompt, history=history, enabled_tools=enabled_tools,
|
|
844
|
+
progress=lambda ev: q.put(("ev", ev)), cancel_check=cancel.is_set)
|
|
845
|
+
except Exception as exc: # pragma: no cover β surfaced in the UI on the next poll
|
|
846
|
+
holder["error"] = str(exc)
|
|
847
|
+
finally:
|
|
848
|
+
q.put(("done", None))
|
|
849
|
+
|
|
850
|
+
threading.Thread(target=worker, daemon=True).start()
|
|
851
|
+
st.session_state.run = {"queue": q, "cancel": cancel, "holder": holder,
|
|
852
|
+
"prompt": prompt, "events": []}
|
|
853
|
+
|
|
854
|
+
|
|
855
|
+
run_active = bool(st.session_state.get("run"))
|
|
856
|
+
prompt = st.chat_input("Ask a question about the loaded APIβ¦", disabled=run_active)
|
|
857
|
+
if prompt and not run_active:
|
|
858
|
+
st.session_state.messages.append({"role": "user", "content": prompt})
|
|
859
|
+
history = [
|
|
860
|
+
{"role": m["role"], "content": m["content"]}
|
|
861
|
+
for m in st.session_state.messages[:-1]
|
|
862
|
+
if m["role"] in ("user", "assistant")
|
|
863
|
+
]
|
|
864
|
+
_start_run(agent, prompt, history, enabled)
|
|
865
|
+
st.rerun()
|
|
866
|
+
|
|
867
|
+
@st.fragment(run_every=0.3)
|
|
868
|
+
def _render_active_run() -> None:
|
|
869
|
+
"""Render the in-flight run's live progress. Runs as a FRAGMENT, so only THIS bubble reloads
|
|
870
|
+
every 0.5s to poll the background worker β the sidebar and chat history don't re-render. The
|
|
871
|
+
progress is drawn as plain markdown inside a static bordered box (NOT an `st.status` expander),
|
|
872
|
+
so a poll updates the text in place instead of animating a dropdown open/closed. On completion it
|
|
873
|
+
does one full ``st.rerun()`` to return to the idle state (which re-enables the chat input)."""
|
|
874
|
+
run = st.session_state.get("run")
|
|
875
|
+
if not run:
|
|
876
|
+
return
|
|
877
|
+
done = _drain(run)
|
|
878
|
+
run["polls"] = run.get("polls", 0) + 1
|
|
879
|
+
spin = _SPINNER[run["polls"] % len(_SPINNER)] # advances each poll β a live "working" spinner
|
|
880
|
+
header, log_md, tokens, answer = _events_summary(run)
|
|
881
|
+
with st.container(border=True):
|
|
882
|
+
head = "β
Done" if done else f"{spin} Working β {header}"
|
|
883
|
+
st.markdown(f"**{head}** \n_{run['prompt'][:80]}_")
|
|
884
|
+
if log_md:
|
|
885
|
+
st.markdown(log_md)
|
|
886
|
+
if not done:
|
|
887
|
+
if tokens:
|
|
888
|
+
st.caption(tokens)
|
|
889
|
+
if answer:
|
|
890
|
+
st.markdown(_md(answer) + " β") # the answer streaming in as it's composed
|
|
891
|
+
# Stop is IMMEDIATE: flag cancel (so the worker makes no further calls) and ABANDON the run
|
|
892
|
+
# right now β don't wait for the in-flight call to return. Back to idle instantly with a
|
|
893
|
+
# "Stopped" message; the orphaned daemon thread finishes its current call and is discarded.
|
|
894
|
+
if st.button("βΉοΈ Stop", key="stop_run", type="primary",
|
|
895
|
+
help="Stop now β no further API/LLM calls."):
|
|
896
|
+
run["cancel"].set()
|
|
897
|
+
st.session_state.pop("run", None)
|
|
898
|
+
st.session_state.messages.append(
|
|
899
|
+
{"role": "assistant", "content": "βΉοΈ **Stopped.**", "steps": log_md})
|
|
900
|
+
st.rerun()
|
|
901
|
+
return # the fragment auto-reruns in ~0.3s to poll again
|
|
902
|
+
# ---- finished: stash the answer + the step log in history and drop back to the idle state ----
|
|
903
|
+
holder = run["holder"]
|
|
904
|
+
st.session_state.pop("run", None)
|
|
905
|
+
if "error" in holder:
|
|
906
|
+
st.session_state.messages.append(
|
|
907
|
+
{"role": "assistant", "content": f"β οΈ Agent error: {holder['error']}", "steps": log_md})
|
|
908
|
+
else:
|
|
909
|
+
result = holder["result"]
|
|
910
|
+
st.session_state.messages.append(
|
|
911
|
+
{"role": "assistant", "content": result.answer, "result": result, "steps": log_md})
|
|
912
|
+
st.rerun() # full rerun: re-enable the chat input and render the answer from history
|
|
913
|
+
|
|
914
|
+
|
|
915
|
+
if st.session_state.get("run"):
|
|
916
|
+
with st.chat_message("assistant"):
|
|
917
|
+
components.html(_SCROLL_PIN, height=0)
|
|
918
|
+
_render_active_run()
|