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