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/catalog.py
ADDED
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
"""The operation catalog the router/executor select from.
|
|
2
|
+
|
|
3
|
+
A `Catalog` is a set of `Tool`s — each one an OpenAPI operation produced by
|
|
4
|
+
`openapi_loader.build_catalog(spec)`. A `Tool` carries an NL description (used for
|
|
5
|
+
routing), a JSON-schema for its arguments, and a `fn` that performs the HTTP call
|
|
6
|
+
and returns ``{"ok", "content", "source", "error"}``.
|
|
7
|
+
"""
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import json
|
|
11
|
+
import re
|
|
12
|
+
import time
|
|
13
|
+
from dataclasses import dataclass
|
|
14
|
+
from typing import Callable
|
|
15
|
+
|
|
16
|
+
from .log import get_logger, trunc
|
|
17
|
+
|
|
18
|
+
_LOG = get_logger("api_agent.catalog")
|
|
19
|
+
|
|
20
|
+
# Leading noise a model tends to add/drop on a tool name (a resource verb, or an `fn_`/`gold_`
|
|
21
|
+
# wrapper prefix) — stripped before matching so a mangled call can still resolve to its tool.
|
|
22
|
+
_NAME_PREFIX = re.compile(r"^(?:gold|fn|get|post|put|patch|delete|head|op)+")
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _name_core(name: str) -> str:
|
|
26
|
+
"""Alphanumeric-lowercased tool name with leading verb/wrapper prefixes removed."""
|
|
27
|
+
return _NAME_PREFIX.sub("", re.sub(r"[^a-z0-9]", "", (name or "").lower()))
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@dataclass
|
|
31
|
+
class Tool:
|
|
32
|
+
name: str
|
|
33
|
+
description: str
|
|
34
|
+
parameters: dict # JSON schema for the arguments
|
|
35
|
+
data_type: str # "structured"
|
|
36
|
+
# (args, timeout, list_limit=None) -> {"ok","content","source","error"[,"kind"]}.
|
|
37
|
+
# `kind` is the OPTIONAL normalized failure taxonomy — "auth" | "unreachable" |
|
|
38
|
+
# "server_error" — so the agent's failure bookkeeping doesn't have to sniff
|
|
39
|
+
# backend-specific error strings (HTTP status text vs SQLSTATE). When absent, the
|
|
40
|
+
# agent falls back to the HTTP string heuristics.
|
|
41
|
+
fn: Callable[..., dict]
|
|
42
|
+
backend: str = "http" # "http" | "sql" — which transport family produced this tool
|
|
43
|
+
# Scopes the shared result cache: tools with the same name+args but a DIFFERENT data
|
|
44
|
+
# source (e.g. two warehouse DSNs, dev vs prod) must never serve each other's results.
|
|
45
|
+
# Blank (the HTTP loader) keeps today's name-keyed behavior.
|
|
46
|
+
cache_key: str = ""
|
|
47
|
+
|
|
48
|
+
def openai_spec(self) -> dict:
|
|
49
|
+
return {
|
|
50
|
+
"type": "function",
|
|
51
|
+
"function": {
|
|
52
|
+
"name": self.name,
|
|
53
|
+
"description": self.description,
|
|
54
|
+
"parameters": self.parameters,
|
|
55
|
+
},
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
# Simple in-process result cache (TTL) so repeated calls don't re-hit the API.
|
|
60
|
+
_RESULT_CACHE: dict[str, tuple[float, dict]] = {}
|
|
61
|
+
_CACHE_TTL = 600.0 # seconds
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
class Catalog:
|
|
65
|
+
def __init__(self, tools: list[Tool], reference: list[dict] | None = None):
|
|
66
|
+
self.tools = {t.name: t for t in tools}
|
|
67
|
+
# Documentation records for ALL operations (incl. non-callable writes), so the agent
|
|
68
|
+
# can describe an operation's parameters without ever calling it. See openapi_loader.
|
|
69
|
+
self.reference = reference or []
|
|
70
|
+
# Resolves a location NAMED IN A QUESTION to the exact values a `*_location_names`
|
|
71
|
+
# parameter accepts (`sql_loader.LocationIndex`). Set by the SQL loader, absent for HTTP
|
|
72
|
+
# catalogs — the agent treats None as "no binding", so nothing depends on it existing.
|
|
73
|
+
self.locations = None
|
|
74
|
+
|
|
75
|
+
def specs(self, enabled: list[str] | None = None) -> list[dict]:
|
|
76
|
+
return [
|
|
77
|
+
t.openai_spec()
|
|
78
|
+
for name, t in self.tools.items()
|
|
79
|
+
if enabled is None or name in enabled
|
|
80
|
+
]
|
|
81
|
+
|
|
82
|
+
def resolve(self, name: str) -> str | None:
|
|
83
|
+
"""The real tool name for what the model asked to call. Exact match wins; otherwise recover a
|
|
84
|
+
mangled name (a small model that dropped a `gold_<source>_` prefix or used the underlying
|
|
85
|
+
`fn_...` function name) — but ONLY when it maps to a SINGLE tool, so we never guess wrong."""
|
|
86
|
+
if name in self.tools:
|
|
87
|
+
return name
|
|
88
|
+
low = name.lower()
|
|
89
|
+
for n in self.tools:
|
|
90
|
+
if n.lower() == low:
|
|
91
|
+
return n
|
|
92
|
+
want = _name_core(name)
|
|
93
|
+
if len(want) < 5: # too short to match safely
|
|
94
|
+
return None
|
|
95
|
+
cands = list({n for n in self.tools
|
|
96
|
+
if (c := _name_core(n)) and (c.endswith(want) or want.endswith(c))})
|
|
97
|
+
return cands[0] if len(cands) == 1 else None
|
|
98
|
+
|
|
99
|
+
def call(self, name: str, args: dict, timeout: int = 20, list_limit: int | None = None,
|
|
100
|
+
prefer_period: "tuple | None" = None,
|
|
101
|
+
prefer_terms: "frozenset[str] | None" = None) -> dict:
|
|
102
|
+
"""`prefer_period` is the (year, month) the QUESTION is about, when it names one.
|
|
103
|
+
|
|
104
|
+
A result larger than the display cap has to drop rows, and recency is the right default —
|
|
105
|
+
but it is wrong for a historical question. Measured: three eval cases asking for 2018,
|
|
106
|
+
2024 and 2025 provider production were answered "the evidence does not include any data
|
|
107
|
+
for 2024", because the most-recent-150 slice handed the model 2025-2026 rows from a table
|
|
108
|
+
that genuinely holds 2024. Backends that cannot use the hint ignore it.
|
|
109
|
+
"""
|
|
110
|
+
resolved = self.resolve(name)
|
|
111
|
+
tool = self.tools.get(resolved) if resolved else None
|
|
112
|
+
if not tool:
|
|
113
|
+
_LOG.warning("tool call: '%s' is not a known tool (args=%s)", name, trunc(args, 200))
|
|
114
|
+
return {"ok": False, "error": f"Unknown tool '{name}'", "content": "", "source": ""}
|
|
115
|
+
if resolved != name:
|
|
116
|
+
_LOG.info("tool call: recovered mangled name '%s' → '%s'", name, resolved)
|
|
117
|
+
name = resolved
|
|
118
|
+
key = (f"{tool.backend}:{tool.cache_key}:{name}:"
|
|
119
|
+
f"{json.dumps(args, sort_keys=True, default=str)}:{list_limit}:{prefer_period}:"
|
|
120
|
+
f"{sorted(prefer_terms) if prefer_terms else None}")
|
|
121
|
+
cached = _RESULT_CACHE.get(key)
|
|
122
|
+
now = time.time()
|
|
123
|
+
if cached and now - cached[0] < _CACHE_TTL:
|
|
124
|
+
_LOG.info("tool call: %s args=%s → result-cache HIT (%d chars)",
|
|
125
|
+
name, trunc(args, 300), len(str(cached[1].get("content", ""))))
|
|
126
|
+
return cached[1]
|
|
127
|
+
_LOG.info("tool call: %s args=%s limit=%s", name, trunc(args, 300), list_limit)
|
|
128
|
+
t0 = time.time()
|
|
129
|
+
try:
|
|
130
|
+
try:
|
|
131
|
+
result = tool.fn(args, timeout, list_limit, prefer_period=prefer_period,
|
|
132
|
+
prefer_terms=prefer_terms)
|
|
133
|
+
except TypeError: # an executor that predates the hint (the HTTP backend)
|
|
134
|
+
result = tool.fn(args, timeout, list_limit)
|
|
135
|
+
except Exception as e: # network/parse errors degrade gracefully
|
|
136
|
+
_LOG.warning("tool call: %s raised %s: %s", name, e.__class__.__name__, trunc(str(e), 300))
|
|
137
|
+
return {"ok": False, "error": str(e), "content": "", "source": ""}
|
|
138
|
+
if result.get("ok"):
|
|
139
|
+
_RESULT_CACHE[key] = (now, result)
|
|
140
|
+
_LOG.info("tool call: %s ← ok (%d chars, %.2fs) source=%s",
|
|
141
|
+
name, len(str(result.get("content", ""))), time.time() - t0,
|
|
142
|
+
trunc(result.get("source", ""), 200))
|
|
143
|
+
_LOG.debug("tool call: %s content: %s", name, trunc(result.get("content", "")))
|
|
144
|
+
else:
|
|
145
|
+
_LOG.warning("tool call: %s ← FAILED kind=%s error=%s", name,
|
|
146
|
+
result.get("kind", "-"), trunc(str(result.get("error", "")), 300))
|
|
147
|
+
return result
|
api_agent/chart.py
ADDED
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
"""When a result is worth drawing, and what to draw — decided in code, never by the model.
|
|
2
|
+
|
|
3
|
+
A trend is the one shape prose is genuinely bad at. Asked for "the Dentrix production trend for the
|
|
4
|
+
last 12 months through June 2026", the agent produced an accurate paragraph naming the first month,
|
|
5
|
+
the last month, the low, the high, the total and the year-over-year change — six numbers standing in
|
|
6
|
+
for twelve, and the reader still cannot see the shape. Twelve points on one line say it at a glance.
|
|
7
|
+
|
|
8
|
+
The whole design question is *when*, because a chart of the wrong thing is worse than no chart: it
|
|
9
|
+
is read faster than prose and doubted less. So this module answers with a spec or with None, and the
|
|
10
|
+
conditions are deliberately narrow — every one of them exists to stop a specific way a chart lies:
|
|
11
|
+
|
|
12
|
+
* TRUNCATED ROWS ARE NEVER CHARTED. A line drawn over 150 of 665 rows shows a shape the data does
|
|
13
|
+
not have, and unlike a table it carries no visible sign that anything is missing. This is the
|
|
14
|
+
strictest rule here and the one most likely to be relaxed by accident.
|
|
15
|
+
* A GENUINE TIME SERIES ONLY (`metrics.period_column`: every row carries a period, all distinct,
|
|
16
|
+
already in order). A panel with repeating periods — twelve months x forty providers — would draw
|
|
17
|
+
a zigzag that means nothing.
|
|
18
|
+
* SMALL. Above ~36 points the picture stops being "a year at a glance"; below 3 there is no shape
|
|
19
|
+
to see and a stat tile is the honest form.
|
|
20
|
+
* ONE SCALE. Series are kept only while they share the primary's order of magnitude, so a dollar
|
|
21
|
+
line and a percentage line can never share an axis. A dual-scale chart is the most common
|
|
22
|
+
charting mistake there is, and the only reliable defence is to refuse to build one.
|
|
23
|
+
* AT MOST THREE SERIES, in a fixed hue order — the count that stays distinguishable under
|
|
24
|
+
colour-vision deficiency for every pair, so a reader who cannot separate two lines by hue is
|
|
25
|
+
never left guessing.
|
|
26
|
+
|
|
27
|
+
Pure and dependency-free so it is testable without Streamlit or a browser; `ui.py` owns the drawing.
|
|
28
|
+
"""
|
|
29
|
+
from __future__ import annotations
|
|
30
|
+
|
|
31
|
+
import re
|
|
32
|
+
from dataclasses import dataclass, field
|
|
33
|
+
|
|
34
|
+
from .metrics import _as_period, _num, period_column
|
|
35
|
+
|
|
36
|
+
# A year of months at a glance is the case this exists for; three years still reads. Past that the
|
|
37
|
+
# eye is doing a job a table does better, and the points crowd below the 8px marker minimum.
|
|
38
|
+
MAX_POINTS = 36
|
|
39
|
+
MIN_POINTS = 3
|
|
40
|
+
# Three hues clear every colour-vision-deficiency pair test in BOTH light and dark; a fourth puts
|
|
41
|
+
# yellow and orange on screen together and stops clearing them. A result with more measures than
|
|
42
|
+
# this keeps them — in the table view, which is always available beside the chart.
|
|
43
|
+
MAX_SERIES = 3
|
|
44
|
+
|
|
45
|
+
# Columns that are never a measure worth plotting: identifiers and ordering keys. Matched as a
|
|
46
|
+
# whole word so `sort_key` and `card_id` go, because that is what these are — plumbing, not data.
|
|
47
|
+
_NOT_A_MEASURE = re.compile(r"(?:^|_)(?:id|key|sort|idx|rank|code)(?:_|$)", re.I)
|
|
48
|
+
# A BARE calendar column (`year`, `month`) holds an integer that would otherwise plot as a measure.
|
|
49
|
+
# It must be a WHOLE-name match: `prior_year` and `last_year_val` are money, and an affix match on
|
|
50
|
+
# `year` silently dropped the comparison series from every trend chart — the second line, the one
|
|
51
|
+
# the whole chart exists to compare against.
|
|
52
|
+
_CALENDAR_NAME = re.compile(r"(?:year|month|day|qtr|quarter|week|period|date)s?", re.I)
|
|
53
|
+
# A share, a rate or a count lives on a different scale from money and must never share its axis.
|
|
54
|
+
_RATIO = re.compile(r"pct|percent|rate|ratio|share|margin|per_", re.I)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
@dataclass
|
|
58
|
+
class ChartSpec:
|
|
59
|
+
"""Everything the renderer needs, and nothing about how it looks."""
|
|
60
|
+
period_column: str
|
|
61
|
+
series: list[str] # measure columns, primary first
|
|
62
|
+
points: list[dict] = field(default_factory=list) # [{period, series, value}]
|
|
63
|
+
label: str = "" # what the reader is looking at
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _measure_columns(rows: list[dict], period_col: str) -> list[str]:
|
|
67
|
+
"""Numeric columns that hold a measure, most-populated first.
|
|
68
|
+
|
|
69
|
+
Order matters: the first one becomes the primary and sets the scale every other series has to
|
|
70
|
+
match, so it must be the column the reader is most likely to have asked about. Completeness is
|
|
71
|
+
the best available proxy — a column the warehouse fills for every row is the one the function
|
|
72
|
+
exists to return, and a mostly-null `budget` should never define the axis.
|
|
73
|
+
"""
|
|
74
|
+
out: list[tuple[int, int, str]] = []
|
|
75
|
+
for i, col in enumerate(rows[0]):
|
|
76
|
+
if col == period_col or _NOT_A_MEASURE.search(col) or _CALENDAR_NAME.fullmatch(col):
|
|
77
|
+
continue
|
|
78
|
+
vals = [v for r in rows if (v := _num(r.get(col))) is not None]
|
|
79
|
+
if len(vals) < len(rows) * 0.5: # mostly empty — not this result's subject
|
|
80
|
+
continue
|
|
81
|
+
out.append((-len(vals), i, col)) # ties keep the warehouse's own column order
|
|
82
|
+
return [c for _, _, c in sorted(out)]
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _same_scale(rows: list[dict], primary: str, other: str) -> bool:
|
|
86
|
+
"""Can `other` share `primary`'s axis?
|
|
87
|
+
|
|
88
|
+
Two guards, because either alone lets a lie through. The NAME guard stops a percentage sharing
|
|
89
|
+
an axis with dollars even when a small month happens to make the magnitudes look close. The
|
|
90
|
+
MAGNITUDE guard stops a count sharing an axis with dollars even though neither name says so —
|
|
91
|
+
plotting 3,437 procedures against $421,448 makes the procedure line a flat stripe on the floor
|
|
92
|
+
and invites the reader to conclude volume never moved.
|
|
93
|
+
"""
|
|
94
|
+
if bool(_RATIO.search(primary)) != bool(_RATIO.search(other)):
|
|
95
|
+
return False
|
|
96
|
+
def typical(col: str) -> float:
|
|
97
|
+
vals = sorted(abs(v) for r in rows if (v := _num(r.get(col))) not in (None, 0))
|
|
98
|
+
return vals[len(vals) // 2] if vals else 0.0
|
|
99
|
+
a, b = typical(primary), typical(other)
|
|
100
|
+
if not a or not b:
|
|
101
|
+
return False
|
|
102
|
+
return max(a, b) / min(a, b) <= 20.0
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def chart_spec(rows: list[dict] | None, *, truncated: bool = False,
|
|
106
|
+
label: str = "") -> ChartSpec | None:
|
|
107
|
+
"""A spec for a chart worth drawing, or None. See the module docstring for why each gate exists.
|
|
108
|
+
|
|
109
|
+
Returning None is the DEFAULT and the safe answer; every condition below has to hold.
|
|
110
|
+
"""
|
|
111
|
+
if truncated or not rows or not isinstance(rows[0], dict):
|
|
112
|
+
return None
|
|
113
|
+
if not (MIN_POINTS <= len(rows) <= MAX_POINTS):
|
|
114
|
+
return None
|
|
115
|
+
period_col = period_column(rows) # distinct AND ordered — a panel is rejected here
|
|
116
|
+
if not period_col:
|
|
117
|
+
return None
|
|
118
|
+
measures = _measure_columns(rows, period_col)
|
|
119
|
+
if not measures:
|
|
120
|
+
return None
|
|
121
|
+
primary = measures[0]
|
|
122
|
+
series = [primary] + [c for c in measures[1:] if _same_scale(rows, primary, c)]
|
|
123
|
+
series = series[:MAX_SERIES]
|
|
124
|
+
points = [{"period": _period_label(r.get(period_col)), "series": c, "value": v}
|
|
125
|
+
for r in rows for c in series if (v := _num(r.get(c))) is not None]
|
|
126
|
+
if len(points) < MIN_POINTS:
|
|
127
|
+
return None
|
|
128
|
+
return ChartSpec(period_column=period_col, series=series, points=points, label=label)
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
_MONTHS = ("Jan Feb Mar Apr May Jun Jul Aug Sep Oct Nov Dec").split()
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def _period_label(v) -> str:
|
|
135
|
+
"""'2026-06-01' -> 'Jun 2026'. Axis ticks are read, not parsed."""
|
|
136
|
+
p = _as_period(v)
|
|
137
|
+
if not p:
|
|
138
|
+
return str(v)
|
|
139
|
+
return f"{_MONTHS[p[1] - 1]} {p[0]}"
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def humanize(col: str) -> str:
|
|
143
|
+
"""`prior_year` -> `Prior Year`. The legend is prose, not a schema."""
|
|
144
|
+
return re.sub(r"[_\s]+", " ", col or "").strip().title()
|
api_agent/cli.py
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
"""Console entry point. After ``pip install "codi-api-agent[ui]"``:
|
|
2
|
+
|
|
3
|
+
api-agent # launch the Streamlit UI
|
|
4
|
+
api-agent --help # (streamlit flags are passed through, e.g. --server.port 8600)
|
|
5
|
+
|
|
6
|
+
The DISTRIBUTION is `codi-api-agent` (the generic `api-agent` was already taken on PyPI); the
|
|
7
|
+
import name and this console script stay `api_agent` / `api-agent`. Any install hint printed to a
|
|
8
|
+
user has to name the distribution, or it sends them to a package that cannot be installed.
|
|
9
|
+
"""
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import sys
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def main() -> None:
|
|
17
|
+
ui = Path(__file__).with_name("ui.py")
|
|
18
|
+
try:
|
|
19
|
+
from streamlit.web import cli as stcli
|
|
20
|
+
except ModuleNotFoundError:
|
|
21
|
+
sys.exit(
|
|
22
|
+
'Streamlit is not installed. Install the UI extra:\n\n'
|
|
23
|
+
' pip install "codi-api-agent[ui]"\n\n'
|
|
24
|
+
"(or use the agent programmatically: from api_agent import Agent, Config, load_catalog)"
|
|
25
|
+
)
|
|
26
|
+
sys.argv = ["streamlit", "run", str(ui), *sys.argv[1:]]
|
|
27
|
+
sys.exit(stcli.main())
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
if __name__ == "__main__":
|
|
31
|
+
main()
|
api_agent/config.py
ADDED
|
@@ -0,0 +1,296 @@
|
|
|
1
|
+
"""Runtime configuration.
|
|
2
|
+
|
|
3
|
+
Everything is environment-driven so the LLM provider and the per-component
|
|
4
|
+
models stay swappable without touching code. The Streamlit UI can also override
|
|
5
|
+
any field at runtime via `Config.override(...)`.
|
|
6
|
+
"""
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import json
|
|
10
|
+
import os
|
|
11
|
+
from dataclasses import dataclass, field, replace
|
|
12
|
+
|
|
13
|
+
try: # .env is convenient in dev but optional
|
|
14
|
+
from dotenv import load_dotenv
|
|
15
|
+
|
|
16
|
+
load_dotenv()
|
|
17
|
+
except Exception: # pragma: no cover - dotenv is optional
|
|
18
|
+
pass
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
# Indicative USD pricing per 1,000,000 tokens as (input, output). Prices change and vary by
|
|
22
|
+
# provider — treat these as defaults and OVERRIDE via the MODEL_PRICING env var (JSON, e.g.
|
|
23
|
+
# '{"gpt-4o": [2.5, 10]}') or Config.model_pricing. A model not in the table (e.g. a free Groq
|
|
24
|
+
# model) is treated as unpriced and no $ cost is shown.
|
|
25
|
+
DEFAULT_MODEL_PRICING: dict[str, tuple[float, float]] = {
|
|
26
|
+
"gpt-4o": (2.50, 10.00),
|
|
27
|
+
"gpt-4o-mini": (0.15, 0.60),
|
|
28
|
+
"gpt-4.1": (2.00, 8.00),
|
|
29
|
+
"gpt-4.1-mini": (0.40, 1.60),
|
|
30
|
+
"gpt-4.1-nano": (0.10, 0.40),
|
|
31
|
+
"o3": (2.00, 8.00),
|
|
32
|
+
"o4-mini": (1.10, 4.40),
|
|
33
|
+
"gpt-4-turbo": (10.00, 30.00),
|
|
34
|
+
"gpt-3.5-turbo": (0.50, 1.50),
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _csv_env(name: str) -> list[str]:
|
|
39
|
+
"""Parse a comma-separated env var into a stripped, non-empty list (e.g. LLM_API_KEYS)."""
|
|
40
|
+
return [x.strip() for x in os.getenv(name, "").split(",") if x.strip()]
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _json_list_env(name: str) -> list:
|
|
44
|
+
"""Parse a JSON-array env var (e.g. LLM_POOL) into a list; [] on absent/invalid."""
|
|
45
|
+
raw = os.getenv(name, "").strip()
|
|
46
|
+
if not raw:
|
|
47
|
+
return []
|
|
48
|
+
try:
|
|
49
|
+
val = json.loads(raw)
|
|
50
|
+
return val if isinstance(val, list) else []
|
|
51
|
+
except Exception:
|
|
52
|
+
return []
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _pricing_from_env(base: dict) -> dict:
|
|
56
|
+
"""Merge a MODEL_PRICING JSON override (e.g. '{"gpt-4o":[2.5,10]}') over the defaults."""
|
|
57
|
+
raw = os.getenv("MODEL_PRICING", "").strip()
|
|
58
|
+
merged = dict(base)
|
|
59
|
+
if raw:
|
|
60
|
+
try:
|
|
61
|
+
for k, v in json.loads(raw).items():
|
|
62
|
+
merged[k] = (float(v[0]), float(v[1]))
|
|
63
|
+
except Exception:
|
|
64
|
+
pass
|
|
65
|
+
return merged
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
@dataclass
|
|
69
|
+
class Config:
|
|
70
|
+
# OpenAI-compatible provider
|
|
71
|
+
base_url: str = "https://api.groq.com/openai/v1"
|
|
72
|
+
api_key: str = ""
|
|
73
|
+
# Rotate over SEVERAL keys on the same base_url (e.g. multiple Groq free accounts) so each key's
|
|
74
|
+
# daily/TPM limit is spread. From LLM_API_KEYS (comma-separated); falls back to [api_key].
|
|
75
|
+
api_keys: list[str] = field(default_factory=list)
|
|
76
|
+
# Advanced: a cross-provider backend pool (Groq + HuggingFace + …). JSON list of
|
|
77
|
+
# {"base_url","api_key","model"} via LLM_POOL — when set, the client ROTATES over these full
|
|
78
|
+
# backends for every call (spreading each provider's limit), ignoring the per-role model below.
|
|
79
|
+
llm_pool: list[dict] = field(default_factory=list)
|
|
80
|
+
|
|
81
|
+
# Per-component model selection (both must support tool calling). Each MAY be a comma-separated
|
|
82
|
+
# POOL (e.g. "openai/gpt-oss-120b,llama-3.3-70b-versatile") — the client rotates across the pool
|
|
83
|
+
# per query so no single model hits its rate limit, with automatic failover on a 429.
|
|
84
|
+
generator_model: str = "llama-3.3-70b-versatile"
|
|
85
|
+
judge_model: str = "llama-3.3-70b-versatile"
|
|
86
|
+
router_model: str = "" # blank -> reuse the generator model
|
|
87
|
+
# MASTER SWITCH for LLM-BASED JUDGING / SCORING (JUDGE_ENABLED=0 to turn it off for a session).
|
|
88
|
+
# The judges cost real money — on a paid provider they are the grounding check, the
|
|
89
|
+
# responsiveness check and the presentation check, i.e. up to three extra calls per answered
|
|
90
|
+
# query, and the grounding one re-sends the whole evidence block. Off:
|
|
91
|
+
# * `_validate` (grounding), `_assess_answered` (responsiveness) and `_assess_communication`
|
|
92
|
+
# (presentation) return their neutral no-op verdicts WITHOUT calling the provider;
|
|
93
|
+
# * `AgentResult.faithfulness.judged` is False (the UI shows "not judged" and says why);
|
|
94
|
+
# * `AgentResult.rubric` is None — a scorecard whose groundedness dimension was never graded
|
|
95
|
+
# would read as 0/5 "figures not verified", which is a lie about an unjudged answer;
|
|
96
|
+
# * the self-correction retry cannot fire, because nothing flags a claim to fix.
|
|
97
|
+
# What does NOT change: every DETERMINISTIC guarantee. The figure-match against the fetched data
|
|
98
|
+
# (`_numbers_grounded`), the total / attribution / derived-figure / superlative repairs, the
|
|
99
|
+
# confidentiality scrub, the PII scan and the data-quality disclosure all still run — they are
|
|
100
|
+
# code, not judges. So the answer is still checked; it just isn't SCORED.
|
|
101
|
+
judge_enabled: bool = True
|
|
102
|
+
# The USER-FACING WRITING stages (synthesis, self-review, resynthesis) — the only ones whose
|
|
103
|
+
# output the user actually reads, and the only ones where a stronger model changes perceived
|
|
104
|
+
# quality. Measured on the 2026-07-25 eval run they are ~42% of input tokens (synthesis 9,896
|
|
105
|
+
# + self-review 7,224 + resynthesis 142 in/query), so upgrading ONLY these buys most of the
|
|
106
|
+
# quality for a fraction of a blanket upgrade: gpt-4.1-mini here + gpt-4o-mini everywhere else
|
|
107
|
+
# costs 1.7x total ($0.0071 -> $0.0123/query) vs 6.4x if every stage moved.
|
|
108
|
+
# Blank -> reuse the generator model (previous behaviour, so this is backwards-compatible).
|
|
109
|
+
synthesis_model: str = ""
|
|
110
|
+
|
|
111
|
+
# Routing (narrow a large spec's operations to the relevant few per query)
|
|
112
|
+
router_enabled: bool = True
|
|
113
|
+
router_top_k: int = 8 # caps.md: recall@5 stuck at 60% across 4 runs — give the executor 8 ops
|
|
114
|
+
router_min_tools: int = 6 # specs with at/below this many ops skip routing
|
|
115
|
+
router_llm_window: int = 80 # how many lexically-ranked ops the LLM picks from
|
|
116
|
+
|
|
117
|
+
# Embedding-based routing (semantic recall, fused with lexical via RRF = "hybrid"). Set to a
|
|
118
|
+
# local sentence-transformers model by default (downloaded once, ~80MB, runs offline after).
|
|
119
|
+
# Blank disables it (lexical only, no deps). If embedding_base_url is set it's a hosted
|
|
120
|
+
# OpenAI-compatible /embeddings endpoint instead of a local model.
|
|
121
|
+
embedding_model: str = "all-MiniLM-L6-v2" # local; or "text-embedding-3-small" w/ a base_url
|
|
122
|
+
embedding_base_url: str = "" # set -> hosted; blank -> local sentence-transformers
|
|
123
|
+
embedding_api_key: str = "" # hosted key; blank -> reuse api_key
|
|
124
|
+
|
|
125
|
+
# Behaviour
|
|
126
|
+
max_tool_iterations: int = 8 # caps.md: ARQ analytical cases legitimately use 6–10 calls
|
|
127
|
+
# When the faithfulness judge flags specific unsupported claims, hand them back to the
|
|
128
|
+
# synthesizer to fix and re-check. 0 disables; each retry is 1 synthesis + 1 judge call, and a
|
|
129
|
+
# retry is kept only if it scores higher — so it can't make the answer worse.
|
|
130
|
+
max_synthesis_retries: int = 1
|
|
131
|
+
# Attach a five-dimension quality scorecard (correctness / completeness / groundedness /
|
|
132
|
+
# safety / communication) to every substantive answer. Groundedness reuses the existing
|
|
133
|
+
# (isolated) faithfulness judge and safety/correctness/completeness are DERIVED from signals
|
|
134
|
+
# already computed — so the ONLY added cost is one narrow presentation-only "communication"
|
|
135
|
+
# judge call per answered/partial query. Set RUBRIC_REPORT=0 to disable (no extra call, and
|
|
136
|
+
# AgentResult.rubric is None).
|
|
137
|
+
rubric_report: bool = True
|
|
138
|
+
# DATA-QUALITY DISCLOSURE. On: a load fault found in the fetched rows is surfaced to the reader
|
|
139
|
+
# — a trailing "Data quality" caveat, and for a fault in a figure the answer actually SHOWS, a
|
|
140
|
+
# leading banner plus a downgrade to `partial`. Set DATA_QUALITY_NOTES=0 to hide all of it from
|
|
141
|
+
# the answer.
|
|
142
|
+
#
|
|
143
|
+
# Hiding it does NOT make the answer safer: the flags fire on incomplete loads (a column that
|
|
144
|
+
# collapses to near zero, reverses sign, or is empty), and a decline computed against those
|
|
145
|
+
# values reflects the missing data rather than the business. With this off the reader gets the
|
|
146
|
+
# figures with nothing to warn them. Detection still runs and every flag is still logged at
|
|
147
|
+
# WARNING, so the fault stays visible in the trail and to the eval — only the reader stops
|
|
148
|
+
# seeing it.
|
|
149
|
+
data_quality_notes: bool = True
|
|
150
|
+
# DEBUG PANELS — "Sources" and "How this answer was produced". They carry the real operation
|
|
151
|
+
# names, their exact arguments and the source URIs, because that is what makes an answer
|
|
152
|
+
# debuggable. That is also exactly what must never reach a customer, so set DEBUG_PANELS=0 in
|
|
153
|
+
# production. The customer-visible live step trail is unaffected either way: it names only the
|
|
154
|
+
# business category an operation belongs to.
|
|
155
|
+
debug_panels: bool = True
|
|
156
|
+
# Cache: for a reworded repeat that isn't token-identical, the FAST router LLM decides whether it
|
|
157
|
+
# wants the SAME answer as a recent cached query. This ONLY runs on candidates that already passed
|
|
158
|
+
# the deterministic VALUE GUARD (same signature, count, numbers AND month names), so the earlier
|
|
159
|
+
# false-match class (a March finance-and-payroll snapshot served an April NetSuite report) is
|
|
160
|
+
# fenced out before the LLM is consulted — it can only judge same-vs-different REQUEST within one
|
|
161
|
+
# period. On by default; set CACHE_SEMANTIC=0 to require an exact normalized-token repeat instead.
|
|
162
|
+
cache_semantic: bool = True
|
|
163
|
+
http_timeout: int = 20
|
|
164
|
+
# SQL (warehouse) backend — used when a Postgres DSN is loaded as a source. The DSN
|
|
165
|
+
# should point at a SELECT-only role; the loader additionally forces read-only
|
|
166
|
+
# transactions + a statement timeout on every connection (see sql_loader.py).
|
|
167
|
+
sql_dsn: str = "" # e.g. postgresql://agent_ro:***@localhost:7432/arq-dev
|
|
168
|
+
sql_schema: str = "gold" # schema whose functions become tools
|
|
169
|
+
sql_fn_prefix: str = "fn_" # only functions matching this prefix are exposed
|
|
170
|
+
sql_timeout: int = 30 # per-statement timeout, seconds
|
|
171
|
+
evidence_char_limit: int = 12000 # caps.md: floor on each result to SYNTHESIS (ev_cap scales with list_limit)
|
|
172
|
+
# How many items of a LIST response to show by default; the user can override per query ("show
|
|
173
|
+
# 100", "list all"). `list_max` caps "all" so a huge list can't blow the token budget. The
|
|
174
|
+
# per-result char budget scales with the requested count so the extra items actually fit.
|
|
175
|
+
list_default: int = 150 # caps.md: input is cheap (~15% of window); triples rows the model can filter from
|
|
176
|
+
list_max: int = 400 # caps.md: opt-in ceiling ("show all"); beyond this, aggregate instead of paging
|
|
177
|
+
# Append the code-rendered EVIDENCE TABLE beneath the narrative. Off by default: a wall of rows
|
|
178
|
+
# is not an answer, and the table has repeatedly been mistaken for one — a 54-figure dump
|
|
179
|
+
# outscored a correct sentence on the parity scorer. The figures a reader needs must come from
|
|
180
|
+
# the narrative and the derived facts, not from being handed the raw result to add up.
|
|
181
|
+
# NOTE: `_total_guard` computes its total from THIS table, so with tables off it cannot fire.
|
|
182
|
+
# Any total must then come from the derived facts (see the Phase 3 work in
|
|
183
|
+
# docs/hali_replacement_plan_v2.md).
|
|
184
|
+
render_evidence_table: bool = False
|
|
185
|
+
# SHARED CACHEABLE PREFIX across the three full-evidence stages (synthesis, self-review,
|
|
186
|
+
# validator). They each re-send the same evidence — measured at 453,543 input tokens over 62
|
|
187
|
+
# calls in one 20-case run, 60% of all input — and the provider's prefix cache cannot help,
|
|
188
|
+
# because each opens with a DIFFERENT system message so the token prefix diverges at position
|
|
189
|
+
# zero. With this on, every one of them leads with a byte-identical context block (question +
|
|
190
|
+
# evidence + code-computed facts) and puts its own instruction after it, so calls 2 and 3 read
|
|
191
|
+
# from cache. OFF by default: it moves each stage's task instruction behind a large block of
|
|
192
|
+
# data, which can weaken instruction adherence, and that trade has to be MEASURED on the eval
|
|
193
|
+
# set rather than assumed. Run the suite with it off and on and compare.
|
|
194
|
+
#
|
|
195
|
+
# The flag toggles LAYOUT ONLY, and making that true required one change in BOTH arms: the
|
|
196
|
+
# validator used to render evidence as `[id] content`, dropping the source label and skipping
|
|
197
|
+
# failed calls, while the other two stages used `[id] (source: …) content`. They now share one
|
|
198
|
+
# renderer (`_evidence_block`) — measured at +452 characters on a 62,055-character block, and
|
|
199
|
+
# it gains the judge the failed-call entries the writers already saw. Without that the flag
|
|
200
|
+
# would have been changing two things at once and the A/B would isolate nothing.
|
|
201
|
+
shared_prompt_prefix: bool = False
|
|
202
|
+
# Rows SCANNED (not shown) to build the statistical profile handed to the model in place of
|
|
203
|
+
# a huge table. The display cap stays `list_default`; this only widens what the DESCRIBE
|
|
204
|
+
# covers, so "the maximum" is the real maximum instead of the top of the first page — the
|
|
205
|
+
# measured failure was a $172,994 max computed from 50 of 5,830 rows (true: $228,588).
|
|
206
|
+
# Rows are discarded after profiling, so the PROMPT cost is ~10 lines regardless of this
|
|
207
|
+
# number; the only cost is fetch + PII-redaction time, measured at ~1s per 100k rows.
|
|
208
|
+
# Set high enough that real tables are profiled COMPLETELY (the largest ARQ function
|
|
209
|
+
# returns 5,830 rows, so this is ~17x headroom) — an incomplete scan makes the agent hedge
|
|
210
|
+
# ("large SAMPLE, not definitive") and blocks the deterministic superlative repair, which
|
|
211
|
+
# only fires on a complete profile. A table beyond this is still handled honestly rather
|
|
212
|
+
# than wrongly: `profile.complete` goes false and no extreme is asserted.
|
|
213
|
+
profile_rows: int = 100_000
|
|
214
|
+
# How much of each result is echoed BACK into the executor's working context per turn. Smaller
|
|
215
|
+
# than evidence_char_limit so the tool-loop stays lean and fast on multi-call (fan-out /
|
|
216
|
+
# pagination) queries — the executor only needs enough to decide the next call, while synthesis
|
|
217
|
+
# still gets the full result above. Raise if chaining needs more of each response visible.
|
|
218
|
+
executor_context_chars: int = 4000 # caps.md: modest bump for chaining; below the accumulation cliff
|
|
219
|
+
max_operations: int = 1000 # cap on ops loaded from a spec (router narrows per query)
|
|
220
|
+
|
|
221
|
+
# Spending guardrails (for paid, per-token providers)
|
|
222
|
+
# Stop a SINGLE response once its cumulative tokens reach this ceiling — the executor stops
|
|
223
|
+
# fanning out / retrying and answers with what it has (marked partial). 0 = unlimited.
|
|
224
|
+
max_response_tokens: int = 150_000 # caps.md: runaway ceiling (~3x a normal query, ~$0.03); was 0 = no cap
|
|
225
|
+
# Per-model USD pricing {model: (input_per_1M, output_per_1M)} used to estimate $ per response.
|
|
226
|
+
model_pricing: dict = field(default_factory=lambda: dict(DEFAULT_MODEL_PRICING))
|
|
227
|
+
|
|
228
|
+
@classmethod
|
|
229
|
+
def from_env(cls) -> "Config":
|
|
230
|
+
d = cls()
|
|
231
|
+
return cls(
|
|
232
|
+
base_url=os.getenv("LLM_BASE_URL", d.base_url),
|
|
233
|
+
api_key=os.getenv("LLM_API_KEY", d.api_key),
|
|
234
|
+
api_keys=_csv_env("LLM_API_KEYS"),
|
|
235
|
+
llm_pool=_json_list_env("LLM_POOL"),
|
|
236
|
+
generator_model=os.getenv("GENERATOR_MODEL", d.generator_model),
|
|
237
|
+
synthesis_model=os.getenv("SYNTHESIS_MODEL", d.synthesis_model),
|
|
238
|
+
judge_model=os.getenv("JUDGE_MODEL", d.judge_model),
|
|
239
|
+
judge_enabled=os.getenv("JUDGE_ENABLED", str(d.judge_enabled)).lower()
|
|
240
|
+
not in ("0", "false", "no", "off"),
|
|
241
|
+
router_model=os.getenv("ROUTER_MODEL", d.router_model),
|
|
242
|
+
router_enabled=os.getenv("ROUTER_ENABLED", str(d.router_enabled)).lower()
|
|
243
|
+
not in ("0", "false", "no", "off"),
|
|
244
|
+
router_top_k=int(os.getenv("ROUTER_TOP_K", d.router_top_k)),
|
|
245
|
+
router_min_tools=int(os.getenv("ROUTER_MIN_TOOLS", d.router_min_tools)),
|
|
246
|
+
router_llm_window=int(os.getenv("ROUTER_LLM_WINDOW", d.router_llm_window)),
|
|
247
|
+
max_tool_iterations=int(os.getenv("MAX_TOOL_ITERATIONS", d.max_tool_iterations)),
|
|
248
|
+
max_synthesis_retries=int(os.getenv("MAX_SYNTHESIS_RETRIES", d.max_synthesis_retries)),
|
|
249
|
+
rubric_report=os.getenv("RUBRIC_REPORT", str(d.rubric_report)).lower()
|
|
250
|
+
not in ("0", "false", "no", "off"),
|
|
251
|
+
data_quality_notes=os.getenv("DATA_QUALITY_NOTES", str(d.data_quality_notes)).lower()
|
|
252
|
+
not in ("0", "false", "no", "off"),
|
|
253
|
+
debug_panels=os.getenv("DEBUG_PANELS", str(d.debug_panels)).lower()
|
|
254
|
+
not in ("0", "false", "no", "off"),
|
|
255
|
+
cache_semantic=os.getenv("CACHE_SEMANTIC", str(d.cache_semantic)).lower()
|
|
256
|
+
not in ("0", "false", "no", "off"),
|
|
257
|
+
http_timeout=int(os.getenv("HTTP_TIMEOUT", d.http_timeout)),
|
|
258
|
+
sql_dsn=os.getenv("SQL_DSN", d.sql_dsn),
|
|
259
|
+
sql_schema=os.getenv("SQL_SCHEMA", d.sql_schema),
|
|
260
|
+
sql_fn_prefix=os.getenv("SQL_FN_PREFIX", d.sql_fn_prefix),
|
|
261
|
+
sql_timeout=int(os.getenv("SQL_TIMEOUT", d.sql_timeout)),
|
|
262
|
+
evidence_char_limit=int(os.getenv("EVIDENCE_CHAR_LIMIT", d.evidence_char_limit)),
|
|
263
|
+
executor_context_chars=int(os.getenv("EXECUTOR_CONTEXT_CHARS", d.executor_context_chars)),
|
|
264
|
+
list_default=int(os.getenv("LIST_DEFAULT", d.list_default)),
|
|
265
|
+
list_max=int(os.getenv("LIST_MAX", d.list_max)),
|
|
266
|
+
render_evidence_table=os.getenv("RENDER_EVIDENCE_TABLE",
|
|
267
|
+
str(d.render_evidence_table)).lower()
|
|
268
|
+
in ("1", "true", "yes", "on"),
|
|
269
|
+
shared_prompt_prefix=os.getenv("SHARED_PROMPT_PREFIX",
|
|
270
|
+
str(d.shared_prompt_prefix)).lower()
|
|
271
|
+
in ("1", "true", "yes", "on"),
|
|
272
|
+
profile_rows=int(os.getenv("PROFILE_ROWS", d.profile_rows)),
|
|
273
|
+
max_operations=int(os.getenv("MAX_OPERATIONS", d.max_operations)),
|
|
274
|
+
max_response_tokens=int(os.getenv("MAX_RESPONSE_TOKENS", d.max_response_tokens)),
|
|
275
|
+
model_pricing=_pricing_from_env(d.model_pricing),
|
|
276
|
+
embedding_model=os.getenv("EMBEDDING_MODEL", d.embedding_model),
|
|
277
|
+
embedding_base_url=os.getenv("EMBEDDING_BASE_URL", d.embedding_base_url),
|
|
278
|
+
embedding_api_key=os.getenv("EMBEDDING_API_KEY", d.embedding_api_key),
|
|
279
|
+
)
|
|
280
|
+
|
|
281
|
+
def override(self, **kwargs) -> "Config":
|
|
282
|
+
"""Return a copy with non-empty overrides applied (used by the UI)."""
|
|
283
|
+
clean = {k: v for k, v in kwargs.items() if v is not None and v != ""}
|
|
284
|
+
return replace(self, **clean)
|
|
285
|
+
|
|
286
|
+
def effective_keys(self) -> list[str]:
|
|
287
|
+
"""The API key(s) to rotate over: the LLM_API_KEYS pool if given, else the single api_key."""
|
|
288
|
+
return self.api_keys or [self.api_key]
|
|
289
|
+
|
|
290
|
+
def problems(self) -> list[str]:
|
|
291
|
+
issues: list[str] = []
|
|
292
|
+
if not self.api_key:
|
|
293
|
+
issues.append("LLM_API_KEY is not set — add your provider key.")
|
|
294
|
+
if not self.base_url:
|
|
295
|
+
issues.append("LLM_BASE_URL is not set.")
|
|
296
|
+
return issues
|