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/llm.py
ADDED
|
@@ -0,0 +1,470 @@
|
|
|
1
|
+
"""Thin wrapper over any OpenAI-compatible chat endpoint.
|
|
2
|
+
|
|
3
|
+
Because Groq, HuggingFace, Ollama and OpenAI all expose the same surface, the
|
|
4
|
+
only thing that changes between providers is `base_url` / `api_key` / `model` —
|
|
5
|
+
all of which are configuration. Per-component model choice is handled by the
|
|
6
|
+
caller passing `model=...`.
|
|
7
|
+
"""
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import json
|
|
11
|
+
import re
|
|
12
|
+
import time
|
|
13
|
+
import uuid
|
|
14
|
+
import contextvars
|
|
15
|
+
from contextlib import contextmanager
|
|
16
|
+
from typing import Any
|
|
17
|
+
|
|
18
|
+
from .config import Config
|
|
19
|
+
from .log import get_logger, trunc
|
|
20
|
+
from .schemas import Usage
|
|
21
|
+
|
|
22
|
+
# Current pipeline stage, for per-stage token attribution. A ContextVar, not an
|
|
23
|
+
# attribute: judges run concurrently and must not file each other's tokens.
|
|
24
|
+
_STAGE: contextvars.ContextVar[str] = contextvars.ContextVar("llm_stage", default="")
|
|
25
|
+
|
|
26
|
+
_LOG = get_logger("api_agent.llm")
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _log_request(messages: list[dict], model: str, tools, json_mode: bool, kind: str) -> None:
|
|
30
|
+
"""One line per LLM request. INFO = shape summary; DEBUG = every message in full."""
|
|
31
|
+
total = sum(len(str(m.get("content") or "")) for m in messages)
|
|
32
|
+
_LOG.info("%s → model=%s messages=%d (%s chars) tools=%d json=%s",
|
|
33
|
+
kind, model, len(messages), f"{total:,}", len(tools or []), json_mode)
|
|
34
|
+
if _LOG.isEnabledFor(10): # DEBUG — full inputs
|
|
35
|
+
for i, m in enumerate(messages):
|
|
36
|
+
_LOG.debug("%s msg[%d] %s: %s", kind, i, m.get("role"), trunc(m.get("content") or ""))
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _log_response(resp, model: str, kind: str) -> None:
|
|
40
|
+
"""One line per LLM response. INFO = shape + tokens; DEBUG = full content + tool calls."""
|
|
41
|
+
try:
|
|
42
|
+
msg = resp.choices[0].message
|
|
43
|
+
content = msg.content or ""
|
|
44
|
+
calls = getattr(msg, "tool_calls", None) or []
|
|
45
|
+
u = getattr(resp, "usage", None)
|
|
46
|
+
toks = f" tokens={u.prompt_tokens}+{u.completion_tokens}" if u else ""
|
|
47
|
+
if u:
|
|
48
|
+
det = getattr(u, "prompt_tokens_details", None)
|
|
49
|
+
hit = getattr(det, "cached_tokens", None) if det is not None else None
|
|
50
|
+
if hit: # only when the provider reports a hit — silence means "no cache info"
|
|
51
|
+
toks += f" (cached {hit}/{u.prompt_tokens} = {100 * hit / max(u.prompt_tokens, 1):.0f}%)"
|
|
52
|
+
_LOG.info("%s ← model=%s content=%d chars tool_calls=%d%s",
|
|
53
|
+
kind, model, len(content), len(calls), toks)
|
|
54
|
+
if _LOG.isEnabledFor(10): # DEBUG — full outputs
|
|
55
|
+
if content:
|
|
56
|
+
_LOG.debug("%s reply: %s", kind, trunc(content))
|
|
57
|
+
for c in calls:
|
|
58
|
+
_LOG.debug("%s tool_call: %s(%s)", kind, c.function.name,
|
|
59
|
+
trunc(c.function.arguments or ""))
|
|
60
|
+
except Exception: # logging must never break the pipeline
|
|
61
|
+
pass
|
|
62
|
+
|
|
63
|
+
# When a backend returns a rate-limit (429), rest it this long before trying it again — so the
|
|
64
|
+
# rotation stops hammering an exhausted key/model and spreads onto the others. A transient 5xx /
|
|
65
|
+
# connection blip gets a short rest instead (the provider is up, just hiccuped).
|
|
66
|
+
_RATE_COOLDOWN_S = 60
|
|
67
|
+
_TRANSIENT_COOLDOWN_S = 5
|
|
68
|
+
# When EVERY backend is rate-limited/hiccuping on a call (the common case with a SINGLE key), wait
|
|
69
|
+
# briefly and retry rather than failing the whole query on one 429 — providers cap requests-per-
|
|
70
|
+
# minute, and the agent bursts several calls per question, so a short wait usually clears it.
|
|
71
|
+
_RATE_RETRIES = 3 # extra attempts after the first
|
|
72
|
+
_RATE_RETRY_MAX_WAIT = 12.0 # seconds cap per backoff sleep (keeps a UI Stop responsive)
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _retry_after_seconds(error: Exception) -> float | None:
|
|
76
|
+
"""Seconds the provider asked us to wait, from a ``Retry-After`` response header, if present."""
|
|
77
|
+
resp = getattr(error, "response", None)
|
|
78
|
+
headers = getattr(resp, "headers", None)
|
|
79
|
+
if not headers:
|
|
80
|
+
return None
|
|
81
|
+
for key in ("retry-after", "Retry-After"):
|
|
82
|
+
val = headers.get(key) if hasattr(headers, "get") else None
|
|
83
|
+
if val:
|
|
84
|
+
try:
|
|
85
|
+
return float(str(val).strip().rstrip("s"))
|
|
86
|
+
except ValueError:
|
|
87
|
+
pass
|
|
88
|
+
return None
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def _retry_wait(error: Exception, attempt: int) -> float:
|
|
92
|
+
"""How long to sleep before the next retry: the provider's Retry-After, else exponential
|
|
93
|
+
backoff (2, 4, 8 …), capped so a UI Stop stays responsive."""
|
|
94
|
+
hinted = _retry_after_seconds(error)
|
|
95
|
+
return min(_RATE_RETRY_MAX_WAIT, hinted if hinted is not None else 2.0 * (2 ** attempt))
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def _is_rate_limit(error: Exception) -> bool:
|
|
99
|
+
"""True for a provider rate-limit / quota error (HTTP 429) — the signal to fail over."""
|
|
100
|
+
if error.__class__.__name__ == "RateLimitError":
|
|
101
|
+
return True
|
|
102
|
+
code = getattr(error, "status_code", None) or getattr(error, "code", None)
|
|
103
|
+
if code == 429 or code == "429":
|
|
104
|
+
return True
|
|
105
|
+
s = str(error).lower()
|
|
106
|
+
return any(h in s for h in ("rate limit", "429", "too many requests", "quota", "insufficient_quota"))
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def _is_transient(error: Exception) -> bool:
|
|
110
|
+
"""True for a temporary server/connection error worth retrying on a DIFFERENT backend (5xx,
|
|
111
|
+
connection, timeout) — but NOT a 4xx like `tool_use_failed`, which the caller must handle."""
|
|
112
|
+
if error.__class__.__name__ in ("APIConnectionError", "APITimeoutError", "InternalServerError"):
|
|
113
|
+
return True
|
|
114
|
+
code = getattr(error, "status_code", None)
|
|
115
|
+
return bool(isinstance(code, int) and 500 <= code < 600)
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
class LLMClient:
|
|
119
|
+
"""OpenAI-compatible client with built-in **multi-LLM rotation**. A model string may be a
|
|
120
|
+
comma-separated POOL ("a,b,c"), and multiple keys / a cross-provider `llm_pool` can be given;
|
|
121
|
+
the client round-robins across these backends PER QUERY (stable within a query for a consistent
|
|
122
|
+
answer, different across queries to spread load) and fails over on a 429 with a cooldown — so no
|
|
123
|
+
single free-tier model or key hits its limit. A single model + key behaves exactly as before."""
|
|
124
|
+
|
|
125
|
+
def __init__(self, config: Config):
|
|
126
|
+
from openai import OpenAI # lazy import so the pure helpers don't need the SDK
|
|
127
|
+
|
|
128
|
+
self.config = config
|
|
129
|
+
# Build the unique OpenAI clients + the (client_idx, model) backends to rotate over.
|
|
130
|
+
self._pool_entries: list[tuple[int, str]] | None = None # set only in cross-provider pool mode
|
|
131
|
+
if config.llm_pool:
|
|
132
|
+
uniq: dict[tuple[str, str], int] = {}
|
|
133
|
+
self._clients = []
|
|
134
|
+
self._pool_entries = []
|
|
135
|
+
for e in config.llm_pool:
|
|
136
|
+
bu = e.get("base_url") or config.base_url
|
|
137
|
+
key = e.get("api_key") or config.api_key or "not-needed"
|
|
138
|
+
model = e.get("model") or config.generator_model
|
|
139
|
+
ck = (bu, key)
|
|
140
|
+
if ck not in uniq:
|
|
141
|
+
uniq[ck] = len(self._clients)
|
|
142
|
+
self._clients.append(OpenAI(base_url=bu, api_key=key))
|
|
143
|
+
self._pool_entries.append((uniq[ck], model))
|
|
144
|
+
else:
|
|
145
|
+
# Simple mode: one client per key on the single base_url; models come from the passed pool.
|
|
146
|
+
self._clients = [OpenAI(base_url=config.base_url, api_key=k or "not-needed")
|
|
147
|
+
for k in config.effective_keys()]
|
|
148
|
+
|
|
149
|
+
self._query_idx = -1 # bumped each reset_usage → per-query rotation offset
|
|
150
|
+
self._cooldown: dict[tuple[int, str], float] = {} # (client_idx, model) -> usable-after epoch
|
|
151
|
+
# Running token tally for the CURRENT query (reset per Agent.run). `on_usage` is an optional
|
|
152
|
+
# callback fired after each call with the updated tally, so a UI can show a live counter.
|
|
153
|
+
self.usage = Usage()
|
|
154
|
+
self.on_usage = None
|
|
155
|
+
|
|
156
|
+
@contextmanager
|
|
157
|
+
def stage(self, name: str):
|
|
158
|
+
"""Attribute every call made inside this block to pipeline stage `name`.
|
|
159
|
+
|
|
160
|
+
Nests safely (the previous stage is restored on exit) so a helper that tags its own
|
|
161
|
+
stage can be called from within another without losing the outer attribution.
|
|
162
|
+
|
|
163
|
+
Held in a ContextVar rather than on `self` because stages can now run CONCURRENTLY. As a
|
|
164
|
+
plain attribute the last writer won: two judges running at once would file each other's
|
|
165
|
+
tokens, and the losing branch's `finally` would restore a stage that was never its own —
|
|
166
|
+
silently corrupting the per-stage numbers that make cost work targetable in the first
|
|
167
|
+
place. A ContextVar gives each thread its own value, and `contextvars.copy_context()` at
|
|
168
|
+
the point of hand-off carries the caller's stage in as the starting value."""
|
|
169
|
+
token = _STAGE.set(name)
|
|
170
|
+
try:
|
|
171
|
+
yield
|
|
172
|
+
finally:
|
|
173
|
+
_STAGE.reset(token)
|
|
174
|
+
|
|
175
|
+
# -- rotation helpers ------------------------------------------------------------------------ #
|
|
176
|
+
@staticmethod
|
|
177
|
+
def _split(model) -> list[str]:
|
|
178
|
+
if isinstance(model, (list, tuple)):
|
|
179
|
+
return [str(m).strip() for m in model if str(m).strip()] or [""]
|
|
180
|
+
return [m.strip() for m in str(model).split(",") if m.strip()] or [""]
|
|
181
|
+
|
|
182
|
+
def _combos(self, model) -> list[tuple[int, str]]:
|
|
183
|
+
"""The (client_idx, model) backends for this call. Cross-provider pool mode uses the fixed
|
|
184
|
+
pool; simple mode is every key × every model in the passed pool."""
|
|
185
|
+
if self._pool_entries is not None:
|
|
186
|
+
return self._pool_entries
|
|
187
|
+
return [(ci, m) for m in self._split(model) for ci in range(len(self._clients))]
|
|
188
|
+
|
|
189
|
+
def _ordered(self, model) -> list[tuple[int, str]]:
|
|
190
|
+
"""Backends to try, best first: start at the per-query offset, ready (not-cooled) ones first."""
|
|
191
|
+
combos = self._combos(model)
|
|
192
|
+
n = len(combos)
|
|
193
|
+
start = self._query_idx % n if n else 0
|
|
194
|
+
order = [combos[(start + i) % n] for i in range(n)]
|
|
195
|
+
now = time.time()
|
|
196
|
+
ready = [c for c in order if self._cooldown.get(c, 0) <= now]
|
|
197
|
+
return ready or order # if everything is cooling down, try anyway rather than give up
|
|
198
|
+
|
|
199
|
+
def reset_usage(self) -> None:
|
|
200
|
+
self.usage = Usage() # rebind (don't mutate) so an already-returned result keeps its own
|
|
201
|
+
self._query_idx += 1 # rotate to the next backend for this new query
|
|
202
|
+
|
|
203
|
+
@staticmethod
|
|
204
|
+
def _cached_tokens(raw) -> int | None:
|
|
205
|
+
"""Prompt tokens the provider served from its prefix cache, or None when it doesn't say.
|
|
206
|
+
OpenAI reports `usage.prompt_tokens_details.cached_tokens`; providers that don't support
|
|
207
|
+
caching simply omit the field, so this stays None and nothing is assumed."""
|
|
208
|
+
details = getattr(raw, "prompt_tokens_details", None)
|
|
209
|
+
if details is None:
|
|
210
|
+
return None
|
|
211
|
+
got = getattr(details, "cached_tokens", None)
|
|
212
|
+
if got is None and isinstance(details, dict):
|
|
213
|
+
got = details.get("cached_tokens")
|
|
214
|
+
return got
|
|
215
|
+
|
|
216
|
+
def _record_usage(self, raw, model: str = "") -> None:
|
|
217
|
+
"""Fold one response's token usage (attributed to `model`) into the running tally and notify
|
|
218
|
+
any listener."""
|
|
219
|
+
self.usage.add(getattr(raw, "prompt_tokens", None),
|
|
220
|
+
getattr(raw, "completion_tokens", None),
|
|
221
|
+
getattr(raw, "total_tokens", None), model=model,
|
|
222
|
+
cached=self._cached_tokens(raw), stage=_STAGE.get())
|
|
223
|
+
if self.on_usage:
|
|
224
|
+
try:
|
|
225
|
+
self.on_usage(self.usage)
|
|
226
|
+
except Exception:
|
|
227
|
+
pass
|
|
228
|
+
|
|
229
|
+
def _cool(self, combo: tuple[int, str], error: Exception) -> None:
|
|
230
|
+
self._cooldown[combo] = time.time() + (_RATE_COOLDOWN_S if _is_rate_limit(error)
|
|
231
|
+
else _TRANSIENT_COOLDOWN_S)
|
|
232
|
+
|
|
233
|
+
@staticmethod
|
|
234
|
+
def _kwargs(model, messages, tools, tool_choice, json_mode, temperature, stream) -> dict:
|
|
235
|
+
kwargs: dict[str, Any] = {"model": model, "messages": messages, "temperature": temperature}
|
|
236
|
+
if tools:
|
|
237
|
+
kwargs["tools"] = tools
|
|
238
|
+
kwargs["tool_choice"] = tool_choice or "auto"
|
|
239
|
+
if json_mode:
|
|
240
|
+
kwargs["response_format"] = {"type": "json_object"}
|
|
241
|
+
if stream:
|
|
242
|
+
kwargs["stream"] = True
|
|
243
|
+
kwargs["stream_options"] = {"include_usage": True} # final usage-only chunk
|
|
244
|
+
return kwargs
|
|
245
|
+
|
|
246
|
+
def complete(
|
|
247
|
+
self,
|
|
248
|
+
messages: list[dict],
|
|
249
|
+
model: str,
|
|
250
|
+
tools: list[dict] | None = None,
|
|
251
|
+
tool_choice: str | None = None,
|
|
252
|
+
json_mode: bool = False,
|
|
253
|
+
temperature: float = 0.0,
|
|
254
|
+
):
|
|
255
|
+
_log_request(messages, model, tools, json_mode, "llm")
|
|
256
|
+
last: Exception | None = None
|
|
257
|
+
for attempt in range(_RATE_RETRIES + 1):
|
|
258
|
+
last = None
|
|
259
|
+
for (ci, m) in self._ordered(model):
|
|
260
|
+
kwargs = self._kwargs(m, messages, tools, tool_choice, json_mode, temperature, stream=False)
|
|
261
|
+
try:
|
|
262
|
+
resp = self._clients[ci].chat.completions.create(**kwargs)
|
|
263
|
+
except Exception as e:
|
|
264
|
+
if _is_rate_limit(e) or _is_transient(e): # this backend is busy → try the next one
|
|
265
|
+
_LOG.warning("llm backend %s rate-limited/transient (%s) — failing over",
|
|
266
|
+
m, trunc(str(e), 200))
|
|
267
|
+
self._cool((ci, m), e)
|
|
268
|
+
last = e
|
|
269
|
+
continue
|
|
270
|
+
_LOG.warning("llm call failed on %s: %s", m, trunc(str(e), 300))
|
|
271
|
+
raise # e.g. tool_use_failed (400) — the caller recovers it; don't fail over
|
|
272
|
+
self._record_usage(getattr(resp, "usage", None), model=m)
|
|
273
|
+
_log_response(resp, m, "llm")
|
|
274
|
+
return resp
|
|
275
|
+
# Every backend was rate-limited/hiccuping this pass. Rather than fail the whole query on
|
|
276
|
+
# one 429 (the usual outcome with a SINGLE key), wait briefly and retry — per-minute caps
|
|
277
|
+
# clear fast. Only 429/5xx reach here; a hard 4xx re-raised above already.
|
|
278
|
+
if attempt < _RATE_RETRIES and last is not None:
|
|
279
|
+
time.sleep(_retry_wait(last, attempt))
|
|
280
|
+
continue
|
|
281
|
+
break
|
|
282
|
+
raise last if last else RuntimeError("no LLM backend available")
|
|
283
|
+
|
|
284
|
+
def stream(self, messages: list[dict], model: str, json_mode: bool = False,
|
|
285
|
+
temperature: float = 0.0):
|
|
286
|
+
"""Yield content-delta strings from a STREAMING chat completion (no tool calls). Rotates like
|
|
287
|
+
`complete`: fails over to the next backend on a pre-stream 429; once tokens start flowing it
|
|
288
|
+
re-raises (the caller falls back to a normal `complete`, which rotates again)."""
|
|
289
|
+
_log_request(messages, model, None, json_mode, "llm-stream")
|
|
290
|
+
last: Exception | None = None
|
|
291
|
+
for (ci, m) in self._ordered(model):
|
|
292
|
+
kwargs = self._kwargs(m, messages, None, None, json_mode, temperature, stream=True)
|
|
293
|
+
yielded = False
|
|
294
|
+
parts: list[str] = [] # accumulated for the output log (streamed = no single response)
|
|
295
|
+
try:
|
|
296
|
+
final_usage = None
|
|
297
|
+
for chunk in self._clients[ci].chat.completions.create(**kwargs):
|
|
298
|
+
u = getattr(chunk, "usage", None)
|
|
299
|
+
if u:
|
|
300
|
+
final_usage = u # arrives on the terminal chunk (choices is empty there)
|
|
301
|
+
try:
|
|
302
|
+
delta = chunk.choices[0].delta.content
|
|
303
|
+
except (IndexError, AttributeError):
|
|
304
|
+
delta = None
|
|
305
|
+
if delta:
|
|
306
|
+
yielded = True
|
|
307
|
+
parts.append(delta)
|
|
308
|
+
yield delta
|
|
309
|
+
self._record_usage(final_usage, model=m)
|
|
310
|
+
streamed = "".join(parts)
|
|
311
|
+
_LOG.info("llm-stream ← model=%s content=%d chars", m, len(streamed))
|
|
312
|
+
_LOG.debug("llm-stream reply: %s", trunc(streamed))
|
|
313
|
+
return
|
|
314
|
+
except Exception as e:
|
|
315
|
+
if not yielded and (_is_rate_limit(e) or _is_transient(e)):
|
|
316
|
+
_LOG.warning("llm-stream backend %s rate-limited/transient (%s) — failing over",
|
|
317
|
+
m, trunc(str(e), 200))
|
|
318
|
+
self._cool((ci, m), e)
|
|
319
|
+
last = e
|
|
320
|
+
continue
|
|
321
|
+
_LOG.warning("llm-stream failed on %s after %d chars: %s",
|
|
322
|
+
m, sum(len(p) for p in parts), trunc(str(e), 300))
|
|
323
|
+
raise
|
|
324
|
+
if last:
|
|
325
|
+
raise last
|
|
326
|
+
|
|
327
|
+
|
|
328
|
+
def extract_json(text: str | None) -> dict:
|
|
329
|
+
"""Best-effort JSON parse from a model response.
|
|
330
|
+
|
|
331
|
+
Small/open models sometimes wrap JSON in prose or code fences, so we strip
|
|
332
|
+
fences and fall back to the outermost ``{...}`` span before giving up.
|
|
333
|
+
"""
|
|
334
|
+
if not text:
|
|
335
|
+
return {}
|
|
336
|
+
text = text.strip()
|
|
337
|
+
if text.startswith("```"):
|
|
338
|
+
parts = text.split("```")
|
|
339
|
+
if len(parts) >= 2:
|
|
340
|
+
text = parts[1]
|
|
341
|
+
if text.lstrip().lower().startswith("json"):
|
|
342
|
+
text = text.lstrip()[4:]
|
|
343
|
+
text = text.strip()
|
|
344
|
+
try:
|
|
345
|
+
return json.loads(text)
|
|
346
|
+
except Exception:
|
|
347
|
+
start, end = text.find("{"), text.rfind("}")
|
|
348
|
+
if start != -1 and end != -1 and end > start:
|
|
349
|
+
try:
|
|
350
|
+
return json.loads(text[start : end + 1])
|
|
351
|
+
except Exception:
|
|
352
|
+
return {}
|
|
353
|
+
return {}
|
|
354
|
+
|
|
355
|
+
|
|
356
|
+
# Some open models (notably Llama on Groq) emit tool calls as text like
|
|
357
|
+
# `<function=name{...json...}</function>` instead of the structured tool_calls the
|
|
358
|
+
# provider expects. The provider then returns a 400 `tool_use_failed` with the raw
|
|
359
|
+
# attempt in `failed_generation`. We parse it and resume the loop.
|
|
360
|
+
_FUNCTION_RE = re.compile(r"<function=([\w.\-]+)\s*>?\s*(\{.*?\})", re.DOTALL)
|
|
361
|
+
|
|
362
|
+
|
|
363
|
+
def _error_body(error: Exception) -> dict | None:
|
|
364
|
+
"""The provider error object. The SDK stores it directly on ``.body``; older/other
|
|
365
|
+
shapes nest it under an ``error`` key — handle both."""
|
|
366
|
+
body = getattr(error, "body", None)
|
|
367
|
+
if isinstance(body, dict):
|
|
368
|
+
return body.get("error") if isinstance(body.get("error"), dict) else body
|
|
369
|
+
return None
|
|
370
|
+
|
|
371
|
+
|
|
372
|
+
def is_tool_use_failed(error: Exception) -> bool:
|
|
373
|
+
"""True if the error is a provider `tool_use_failed` (Groq/Llama tool-format issue)."""
|
|
374
|
+
err = _error_body(error)
|
|
375
|
+
if err and err.get("code") == "tool_use_failed":
|
|
376
|
+
return True
|
|
377
|
+
return "tool_use_failed" in str(error)
|
|
378
|
+
|
|
379
|
+
|
|
380
|
+
def _first_json_object(text: str, start: int = 0) -> str | None:
|
|
381
|
+
"""Return the first balanced ``{...}`` substring at/after ``start`` (brace-aware of
|
|
382
|
+
quoted strings), so we can pull a JSON tool call out of a stringified error."""
|
|
383
|
+
i = text.find("{", start)
|
|
384
|
+
while i != -1:
|
|
385
|
+
depth, in_str, esc, quote = 0, False, "", ""
|
|
386
|
+
for j in range(i, len(text)):
|
|
387
|
+
c = text[j]
|
|
388
|
+
if in_str:
|
|
389
|
+
if esc:
|
|
390
|
+
esc = False
|
|
391
|
+
elif c == "\\":
|
|
392
|
+
esc = True
|
|
393
|
+
elif c == quote:
|
|
394
|
+
in_str = False
|
|
395
|
+
elif c in ("\"", "'"):
|
|
396
|
+
in_str, quote = True, c
|
|
397
|
+
elif c == "{":
|
|
398
|
+
depth += 1
|
|
399
|
+
elif c == "}":
|
|
400
|
+
depth -= 1
|
|
401
|
+
if depth == 0:
|
|
402
|
+
return text[i : j + 1]
|
|
403
|
+
i = text.find("{", i + 1)
|
|
404
|
+
return None
|
|
405
|
+
|
|
406
|
+
|
|
407
|
+
def _calls_from_json(text: str) -> list[tuple[str, str, dict]]:
|
|
408
|
+
"""Parse ``{"name":..,"arguments":{..}}`` (or a list of them) into recovered calls."""
|
|
409
|
+
try:
|
|
410
|
+
parsed = json.loads(text)
|
|
411
|
+
except Exception:
|
|
412
|
+
return []
|
|
413
|
+
out: list[tuple[str, str, dict]] = []
|
|
414
|
+
for item in parsed if isinstance(parsed, list) else [parsed]:
|
|
415
|
+
if not isinstance(item, dict):
|
|
416
|
+
continue
|
|
417
|
+
name = item.get("name") or (item.get("function") or {}).get("name")
|
|
418
|
+
args = item.get("arguments")
|
|
419
|
+
if args is None:
|
|
420
|
+
args = item.get("parameters") or (item.get("function") or {}).get("arguments") or {}
|
|
421
|
+
if isinstance(args, str):
|
|
422
|
+
try:
|
|
423
|
+
args = json.loads(args)
|
|
424
|
+
except Exception:
|
|
425
|
+
args = {}
|
|
426
|
+
if name and isinstance(args, dict):
|
|
427
|
+
out.append((f"call_{uuid.uuid4().hex[:8]}", name, args))
|
|
428
|
+
return out
|
|
429
|
+
|
|
430
|
+
|
|
431
|
+
def recover_tool_calls(error: Exception) -> list[tuple[str, str, dict]] | None:
|
|
432
|
+
"""Recover tool calls from a `tool_use_failed` error.
|
|
433
|
+
|
|
434
|
+
Returns a list of ``(call_id, tool_name, args_dict)`` or ``None`` if nothing
|
|
435
|
+
recoverable was found.
|
|
436
|
+
"""
|
|
437
|
+
err = _error_body(error)
|
|
438
|
+
failed = err.get("failed_generation") if err else None
|
|
439
|
+
text = str(error)
|
|
440
|
+
|
|
441
|
+
calls: list[tuple[str, str, dict]] = []
|
|
442
|
+
# Format A: `<function=name{...json...}</function>` text (search the clean field and
|
|
443
|
+
# the raw error string).
|
|
444
|
+
for source in (failed, text):
|
|
445
|
+
if not source:
|
|
446
|
+
continue
|
|
447
|
+
for name, raw in _FUNCTION_RE.findall(source):
|
|
448
|
+
try:
|
|
449
|
+
args = json.loads(raw)
|
|
450
|
+
except Exception:
|
|
451
|
+
continue
|
|
452
|
+
calls.append((f"call_{uuid.uuid4().hex[:8]}", name, args))
|
|
453
|
+
if calls:
|
|
454
|
+
return calls
|
|
455
|
+
|
|
456
|
+
# Format B: a clean JSON tool call the provider rejected on schema validation, e.g.
|
|
457
|
+
# `{"name": "op", "arguments": {...}}` with a required param omitted. Recover the intent
|
|
458
|
+
# so the executor can run it (missing path params default to the collection/root).
|
|
459
|
+
candidates: list[str] = []
|
|
460
|
+
if failed:
|
|
461
|
+
candidates.append(failed)
|
|
462
|
+
if "failed_generation" in text: # body not a dict / unwrapped — pull it from the text
|
|
463
|
+
obj = _first_json_object(text, text.find("failed_generation"))
|
|
464
|
+
if obj:
|
|
465
|
+
candidates.append(obj)
|
|
466
|
+
for c in candidates:
|
|
467
|
+
calls.extend(_calls_from_json(c))
|
|
468
|
+
if calls:
|
|
469
|
+
return calls
|
|
470
|
+
return None
|
api_agent/log.py
ADDED
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
"""Pipeline logging — every step's inputs and outputs, correlated per run.
|
|
2
|
+
|
|
3
|
+
One logger tree (``api_agent.*``) instrumented at the pipeline's chokepoints, so a full
|
|
4
|
+
debug trace needs no code changes — just environment variables:
|
|
5
|
+
|
|
6
|
+
LOG_LEVEL=INFO step tracking: every stage, tool call, LLM call, routing decision,
|
|
7
|
+
verdict and final status, with truncated inputs/outputs (default)
|
|
8
|
+
LOG_LEVEL=DEBUG full payloads: complete LLM message lists + responses, complete
|
|
9
|
+
tool-call args + result content
|
|
10
|
+
LOG_LEVEL=WARNING quiet (failures/failovers only)
|
|
11
|
+
LOG_FILE=agent.log also write to a file (console always on)
|
|
12
|
+
LOG_PAYLOAD_CHARS=800 truncation for any single logged payload (0 = unlimited)
|
|
13
|
+
LOG_RAW_ROWS=1 log warehouse rows BEFORE PII redaction (opt-in; see below)
|
|
14
|
+
|
|
15
|
+
For the complete console trail — every step's full input and output — set:
|
|
16
|
+
|
|
17
|
+
LOG_LEVEL=DEBUG LOG_PAYLOAD_CHARS=0
|
|
18
|
+
|
|
19
|
+
Both are read AFTER `load_dotenv()` when the UI calls `setup(force=True)`, so putting them in
|
|
20
|
+
`.env` works. They did not, before that call existed: `agent.py` imports `metrics` → this module
|
|
21
|
+
before it imports `config`, so the level and the cap were frozen at their pre-dotenv values and a
|
|
22
|
+
setting in `.env` was ignored with nothing said.
|
|
23
|
+
|
|
24
|
+
Every record carries a ``run_id`` — a short id minted per ``Agent.run()`` — so parallel
|
|
25
|
+
tool calls and interleaved queries stay attributable:
|
|
26
|
+
|
|
27
|
+
12:01:03 INFO [a1b2c3d4] api_agent.agent: [call] Calling gold_fn_...(p_year=2026)
|
|
28
|
+
|
|
29
|
+
Payload notes: tool-result content is logged AFTER PII redaction (it's the same string the
|
|
30
|
+
model sees), and credentials are never logged (no DSNs, no auth headers, no api keys).
|
|
31
|
+
|
|
32
|
+
`LOG_RAW_ROWS=1` additionally logs the warehouse rows as the DATABASE returned them, before
|
|
33
|
+
redaction. It is log-only and changes nothing the model receives — redaction still runs, so the
|
|
34
|
+
answer's PII guarantee holds either way. It exists because redaction happens at FETCH, not at log
|
|
35
|
+
time, so without it the unredacted payload does not exist anywhere to log. It writes personal and
|
|
36
|
+
contact data to the console (and to LOG_FILE), so enable it only where that is acceptable.
|
|
37
|
+
"""
|
|
38
|
+
from __future__ import annotations
|
|
39
|
+
|
|
40
|
+
import contextvars
|
|
41
|
+
import logging
|
|
42
|
+
import os
|
|
43
|
+
import uuid
|
|
44
|
+
|
|
45
|
+
_run_id: contextvars.ContextVar[str] = contextvars.ContextVar("run_id", default="-")
|
|
46
|
+
|
|
47
|
+
# Default truncation for a single logged payload; 0 = unlimited.
|
|
48
|
+
#
|
|
49
|
+
# READ DYNAMICALLY, never frozen at import — that was a silent bug. `agent.py` imports `metrics`
|
|
50
|
+
# (and therefore this module) BEFORE it imports `config`, which is where `load_dotenv()` runs. So
|
|
51
|
+
# `LOG_LEVEL=DEBUG` / `LOG_PAYLOAD_CHARS=0` placed in `.env` were read too late and ignored
|
|
52
|
+
# entirely: the cap stayed 800 and the logger stayed INFO, with nothing to say so.
|
|
53
|
+
PAYLOAD_CHARS = int(os.getenv("LOG_PAYLOAD_CHARS", "800") or 0)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def payload_chars() -> int:
|
|
57
|
+
"""The CURRENT truncation cap. Env wins, so it can be set after this module was imported."""
|
|
58
|
+
raw = os.getenv("LOG_PAYLOAD_CHARS")
|
|
59
|
+
if raw is None or raw.strip() == "":
|
|
60
|
+
return PAYLOAD_CHARS
|
|
61
|
+
try:
|
|
62
|
+
return int(raw)
|
|
63
|
+
except ValueError:
|
|
64
|
+
return PAYLOAD_CHARS
|
|
65
|
+
|
|
66
|
+
_FMT = "%(asctime)s %(levelname)-7s [%(run_id)s] %(name)s: %(message)s"
|
|
67
|
+
_configured = False
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _install_record_factory() -> None:
|
|
71
|
+
"""Stamp the current run's correlation id onto EVERY LogRecord (a logger-level filter
|
|
72
|
+
would miss records propagated up from child loggers like ``api_agent.catalog``). The
|
|
73
|
+
id rides a contextvar, so the UI's worker thread and — via the context copy in
|
|
74
|
+
``Agent._run_calls`` — parallel tool-call threads all carry the right one."""
|
|
75
|
+
old = logging.getLogRecordFactory()
|
|
76
|
+
if getattr(old, "_api_agent_run_id", False): # idempotent
|
|
77
|
+
return
|
|
78
|
+
|
|
79
|
+
def factory(*args, **kwargs):
|
|
80
|
+
record = old(*args, **kwargs)
|
|
81
|
+
record.run_id = _run_id.get()
|
|
82
|
+
return record
|
|
83
|
+
|
|
84
|
+
factory._api_agent_run_id = True
|
|
85
|
+
logging.setLogRecordFactory(factory)
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def setup(level: str | None = None, file: str | None = None, force: bool = False) -> None:
|
|
89
|
+
"""Configure the ``api_agent`` logger (console + optional file). Idempotent — safe to call from
|
|
90
|
+
Agent/Supervisor/UI init; later calls are no-ops.
|
|
91
|
+
|
|
92
|
+
``force=True`` RE-APPLIES the level to an already-configured logger. The UI needs it: this
|
|
93
|
+
module is imported (and configured at the then-current LOG_LEVEL) before `config.load_dotenv()`
|
|
94
|
+
has run, so a level set in `.env` would otherwise never take effect.
|
|
95
|
+
"""
|
|
96
|
+
global _configured
|
|
97
|
+
if _configured:
|
|
98
|
+
if force:
|
|
99
|
+
logging.getLogger("api_agent").setLevel(
|
|
100
|
+
(level or os.getenv("LOG_LEVEL", "INFO")).upper())
|
|
101
|
+
return
|
|
102
|
+
_configured = True
|
|
103
|
+
_install_record_factory()
|
|
104
|
+
root = logging.getLogger("api_agent")
|
|
105
|
+
root.setLevel((level or os.getenv("LOG_LEVEL", "INFO")).upper())
|
|
106
|
+
root.propagate = False # our handlers own the format; don't double-print via the root logger
|
|
107
|
+
fmt = logging.Formatter(_FMT, datefmt="%H:%M:%S")
|
|
108
|
+
console = logging.StreamHandler()
|
|
109
|
+
console.setFormatter(fmt)
|
|
110
|
+
root.addHandler(console)
|
|
111
|
+
path = file or os.getenv("LOG_FILE", "")
|
|
112
|
+
if path:
|
|
113
|
+
fh = logging.FileHandler(path, encoding="utf-8")
|
|
114
|
+
fh.setFormatter(fmt)
|
|
115
|
+
root.addHandler(fh)
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def get_logger(name: str) -> logging.Logger:
|
|
119
|
+
setup()
|
|
120
|
+
return logging.getLogger(name)
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def new_run_id() -> str:
|
|
124
|
+
"""Mint + activate a fresh correlation id for one Agent.run() (returns it for reuse)."""
|
|
125
|
+
rid = uuid.uuid4().hex[:8]
|
|
126
|
+
_run_id.set(rid)
|
|
127
|
+
return rid
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def trunc(obj, limit: int | None = None) -> str:
|
|
131
|
+
"""A payload as a single log-safe string, truncated to LOG_PAYLOAD_CHARS (marker shows
|
|
132
|
+
how much was cut). ``limit`` overrides; 0/negative = unlimited."""
|
|
133
|
+
s = obj if isinstance(obj, str) else repr(obj)
|
|
134
|
+
s = s.replace("\n", "\\n")
|
|
135
|
+
lim = payload_chars() if limit is None else limit
|
|
136
|
+
if lim <= 0 or len(s) <= lim:
|
|
137
|
+
return s
|
|
138
|
+
return f"{s[:lim]}…(+{len(s) - lim:,} chars)"
|