trodo-python 2.7.0__py3-none-any.whl → 2.9.0__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.
trodo/__init__.py CHANGED
@@ -40,7 +40,7 @@ Downstream microservice (join the caller's run instead of making a new one):
40
40
 
41
41
  from __future__ import annotations
42
42
 
43
- __version__ = "2.7.0"
43
+ __version__ = "2.9.0"
44
44
 
45
45
  from typing import Any, Callable, Dict, List, Optional, Union
46
46
 
@@ -22,6 +22,13 @@ def _hr_to_iso(nanos: Optional[int]) -> Optional[str]:
22
22
  return datetime.fromtimestamp(nanos / 1e9, tz=timezone.utc).isoformat().replace("+00:00", "Z")
23
23
 
24
24
 
25
+ def _trunc(s: Any, max_len: int) -> Optional[str]:
26
+ if s is None:
27
+ return None
28
+ s = str(s)
29
+ return s[:max_len] if len(s) > max_len else s
30
+
31
+
25
32
  def _infer_kind(attrs: Dict[str, Any]) -> str:
26
33
  if not attrs:
27
34
  return "generic"
@@ -111,6 +118,29 @@ def otel_span_to_trodo_span(otel_span: Any) -> Optional[TrodoSpan]:
111
118
  except Exception:
112
119
  ok = "error" if (status_code and str(status_code).endswith("ERROR")) else "ok"
113
120
 
121
+ # Rich error detail. The real exception class + stacktrace live in the OTel
122
+ # `exception` event (record_exception — emitted by the Anthropic/OpenAI/
123
+ # LangChain instrumentors), NOT span.status. The old bridge read only the
124
+ # status code and dropped all of it.
125
+ status_desc = getattr(status, "description", None)
126
+ exc_attrs: Dict[str, Any] = {}
127
+ for ev in (getattr(otel_span, "events", None) or []):
128
+ if getattr(ev, "name", None) == "exception":
129
+ exc_attrs = dict(getattr(ev, "attributes", {}) or {})
130
+ break
131
+ err_type = exc_attrs.get("exception.type") or attrs.get("exception.type") or attrs.get("error.type")
132
+ err_msg = exc_attrs.get("exception.message") or attrs.get("exception.message") or status_desc
133
+ err_stack = exc_attrs.get("exception.stacktrace") or attrs.get("exception.stacktrace")
134
+ status_code_val = (
135
+ attrs.get("http.response.status_code")
136
+ or attrs.get("http.status_code")
137
+ or attrs.get("gen_ai.response.status_code")
138
+ or attrs.get("error.code")
139
+ )
140
+ has_error = ok == "error" or err_type is not None
141
+ ok = "error" if has_error else "ok"
142
+ level = "error" if has_error else "default"
143
+
114
144
  # Accept both stable and experimental GenAI semconv keys.
115
145
  in_toks = (
116
146
  attrs.get("gen_ai.usage.input_tokens")
@@ -138,6 +168,11 @@ def otel_span_to_trodo_span(otel_span: Any) -> Optional[TrodoSpan]:
138
168
  kind=kind,
139
169
  name=getattr(otel_span, "name", kind),
140
170
  status=ok,
171
+ level=level,
172
+ error_type=_trunc(err_type, 128) if err_type else None,
173
+ error_message=_trunc(err_msg, 4_000) if err_msg else None,
174
+ status_code=_trunc(status_code_val, 32) if status_code_val is not None else None,
175
+ stack_trace=_trunc(err_stack, 20_000) if err_stack else None,
141
176
  started_at=started_at,
142
177
  ended_at=ended_at,
143
178
  duration_ms=duration_ms,
trodo/otel/helpers.py CHANGED
@@ -232,6 +232,52 @@ def _default_usage_extractor(result: Any) -> Tuple[Optional[int], Optional[int]]
232
232
  return (None, None)
233
233
 
234
234
 
235
+ def _coerce_num(v: Any) -> Optional[float]:
236
+ try:
237
+ n = float(v)
238
+ except (TypeError, ValueError):
239
+ return None
240
+ return n
241
+
242
+
243
+ def _default_usage_map(result: Any) -> Optional[Dict[str, float]]:
244
+ """Forward the FULL provider usage object (incl. cache/reasoning) as an open
245
+ map. The backend normalises raw keys (``prompt_tokens`` -> input,
246
+ ``cache_read_input_tokens`` / ``cached_tokens`` -> cache_read,
247
+ ``cache_creation_input_tokens`` -> cache_write, ``reasoning_tokens`` ->
248
+ reasoning, ...), so passing whatever the provider returned is enough.
249
+
250
+ Flattens OpenAI ``*_tokens_details`` so cached/reasoning leaves survive.
251
+ Accepts either the bare usage object or a full response carrying ``usage`` /
252
+ ``usageMetadata``.
253
+ """
254
+ if result is None:
255
+ return None
256
+ raw: Any = None
257
+ if isinstance(result, dict):
258
+ raw = result.get("usage") or result.get("usageMetadata")
259
+ # Bare usage object passed directly (has numeric token leaves).
260
+ if raw is None and any(_coerce_num(v) is not None or isinstance(v, dict) for v in result.values()):
261
+ raw = result
262
+ else:
263
+ raw = getattr(result, "usage", None) or getattr(result, "usageMetadata", None)
264
+ if not isinstance(raw, dict):
265
+ return None
266
+ out: Dict[str, float] = {}
267
+ for k, v in raw.items():
268
+ if isinstance(v, dict):
269
+ # OpenAI prompt_tokens_details / completion_tokens_details — flatten.
270
+ for dk, dv in v.items():
271
+ n = _coerce_num(dv)
272
+ if n is not None:
273
+ out[dk] = n
274
+ continue
275
+ n = _coerce_num(v)
276
+ if n is not None:
277
+ out[k] = n
278
+ return out or None
279
+
280
+
235
281
  def llm(
236
282
  name: Any = None,
237
283
  fn: Optional[Callable[..., Any]] = None,
@@ -240,13 +286,16 @@ def llm(
240
286
  provider: Optional[str] = None,
241
287
  temperature: Optional[float] = None,
242
288
  extract_usage: Optional[Callable[[Any], Tuple[Optional[int], Optional[int]]]] = None,
289
+ extract_usage_map: Optional[Callable[[Any], Optional[Dict[str, float]]]] = None,
243
290
  ) -> Any:
244
291
  """Wrap an LLM call as a ``kind='llm'`` span with auto token extraction.
245
292
 
246
- The helper records ``model``/``provider`` on entry; on return it inspects
247
- the response for the common usage shapes (OpenAI ``usage.prompt_tokens``,
248
- Anthropic ``usage.input_tokens``, Gemini ``usageMetadata.promptTokenCount``)
249
- and records tokens. Pass ``extract_usage=lambda r: (in, out)`` to override.
293
+ By default the helper forwards the FULL provider usage object (OpenAI
294
+ ``usage``, Anthropic ``usage`` incl. cache fields, Gemini ``usageMetadata``)
295
+ as an open map, so cache/reasoning tokens are captured and priced
296
+ automatically by the backend. Pass ``extract_usage=lambda r: (in, out)`` to
297
+ fall back to scalar-only extraction, or ``extract_usage_map=lambda r: {..}``
298
+ to build the map yourself.
250
299
 
251
300
  Usage::
252
301
 
@@ -257,7 +306,6 @@ def llm(
257
306
  @trodo.llm('plan', model='claude-haiku-4-5', provider='anthropic')
258
307
  def plan(messages): ...
259
308
  """
260
- extractor = extract_usage or _default_usage_extractor
261
309
 
262
310
  def _set_llm(s: SpanHandle) -> None:
263
311
  if model or provider or temperature is not None:
@@ -268,18 +316,25 @@ def llm(
268
316
  )
269
317
 
270
318
  def _on_result(s: SpanHandle, result: Any) -> None:
319
+ if extract_usage is not None:
320
+ # Caller opted into scalar-only extraction (back-compat).
321
+ try:
322
+ pt, ct = extract_usage(result)
323
+ except Exception:
324
+ pt, ct = (None, None)
325
+ if pt is not None or ct is not None:
326
+ s.set_llm(
327
+ model=model, provider=provider,
328
+ input_tokens=pt, output_tokens=ct, temperature=temperature,
329
+ )
330
+ return
331
+ # Default: forward the full provider usage map (incl. cache/reasoning).
271
332
  try:
272
- pt, ct = extractor(result)
333
+ usage_map = (extract_usage_map or _default_usage_map)(result)
273
334
  except Exception:
274
- pt, ct = (None, None)
275
- if pt is not None or ct is not None:
276
- s.set_llm(
277
- model=model,
278
- provider=provider,
279
- input_tokens=pt,
280
- output_tokens=ct,
281
- temperature=temperature,
282
- )
335
+ usage_map = None
336
+ if usage_map:
337
+ s.set_llm(model=model, provider=provider, temperature=temperature, usage_details=usage_map)
283
338
 
284
339
  return _dual_form("llm")(
285
340
  name, fn, kind="llm", extra_set=_set_llm, on_result=_on_result
@@ -358,6 +413,7 @@ def track_mcp(
358
413
  "kind": "tool",
359
414
  "name": f"tool.{tool}",
360
415
  "status": status,
416
+ "level": "error" if error else "default",
361
417
  "input": _stringify({"tool": tool, "params": input}) if input is not None else None,
362
418
  "output": _stringify(output_to_record),
363
419
  "tool_name": tool,
@@ -387,6 +443,11 @@ def track_llm_call(
387
443
  provider: Optional[str] = None,
388
444
  input_tokens: Optional[int] = None,
389
445
  output_tokens: Optional[int] = None,
446
+ cache_read_tokens: Optional[int] = None,
447
+ cache_write_tokens: Optional[int] = None,
448
+ usage_details: Optional[Dict[str, float]] = None,
449
+ usage: Any = None,
450
+ cost_details: Optional[Dict[str, float]] = None,
390
451
  prompt: Any = None,
391
452
  completion: Any = None,
392
453
  temperature: Optional[float] = None,
@@ -397,23 +458,38 @@ def track_llm_call(
397
458
  """Record a one-shot LLM span for a raw-HTTP caller.
398
459
 
399
460
  Opens and immediately closes a ``span(kind='llm')`` populated with the
400
- model + token counts + prompt/completion. No-op outside an active run
401
- context.
461
+ model + tokens + prompt/completion. No-op outside an active run context.
462
+
463
+ Cost can be reported three ways (in priority order):
464
+ 1. ``cost`` — a final USD figure (overrides all server-side derivation).
465
+ 2. ``cost_details`` — a per-category USD breakdown (authoritative).
466
+ 3. tokens only — the backend prices them against the team's model prices.
467
+
468
+ Tokens can be passed as scalars (``input_tokens``/``output_tokens``),
469
+ cache shorthands (``cache_read_tokens``/``cache_write_tokens``), an open
470
+ ``usage_details`` map, or a raw provider ``usage`` object to auto-extract
471
+ from (e.g. ``resp['usage']`` or ``resp['usageMetadata']``).
402
472
 
403
473
  Usage:
404
474
  resp = httpx.post(url, json=body).json()
405
475
  trodo.track_llm_call(
406
- model='gemini-2.5-flash',
407
- provider='google',
408
- input_tokens=resp['usageMetadata']['promptTokenCount'],
409
- output_tokens=resp['usageMetadata']['candidatesTokenCount'],
410
- prompt=body,
411
- completion=resp,
476
+ model='claude-sonnet-4', provider='anthropic',
477
+ usage=resp['usage'], # cache fields captured automatically
478
+ prompt=body, completion=resp,
412
479
  )
413
480
  """
414
481
  if get_active_context() is None:
415
482
  return
416
483
  span_name = name or (f"llm.{provider}.{model}" if model else "llm")
484
+ # Merge an explicit usage_details map with anything auto-extracted from a
485
+ # raw `usage` object the caller passed through.
486
+ merged_usage: Dict[str, float] = {}
487
+ if usage is not None:
488
+ from_usage = _default_usage_map(usage)
489
+ if from_usage:
490
+ merged_usage.update(from_usage)
491
+ if usage_details:
492
+ merged_usage.update(usage_details)
417
493
  with span_ctx(span_name, kind="llm", input=prompt, attributes=metadata) as s:
418
494
  s.set_llm(
419
495
  model=model,
@@ -421,6 +497,10 @@ def track_llm_call(
421
497
  input_tokens=input_tokens,
422
498
  output_tokens=output_tokens,
423
499
  cost=cost,
500
+ usage_details=merged_usage or None,
501
+ cost_details=cost_details,
502
+ cache_read_tokens=cache_read_tokens,
503
+ cache_write_tokens=cache_write_tokens,
424
504
  temperature=temperature,
425
505
  )
426
506
  if completion is not None:
trodo/otel/processor.py CHANGED
@@ -20,12 +20,18 @@ class TrodoRun:
20
20
  conversation_id: Optional[str] = None
21
21
  parent_run_id: Optional[str] = None
22
22
  status: str = "ok" # 'running' | 'ok' | 'error'
23
+ # Severity (Langfuse parity): 'debug' | 'default' | 'warning' | 'error'.
24
+ # Optional — backend derives it from status when omitted.
25
+ level: Optional[str] = None
23
26
  input: Optional[Union[str, Dict[str, Any]]] = None
24
27
  output: Optional[Union[str, Dict[str, Any]]] = None
25
28
  started_at: Optional[str] = None
26
29
  ended_at: Optional[str] = None
27
30
  duration_ms: Optional[int] = None
28
31
  error_summary: Optional[str] = None
32
+ # Exception class of the run-level failure (runs previously only had the
33
+ # free-text error_summary).
34
+ error_type: Optional[str] = None
29
35
  metadata: Optional[Dict[str, Any]] = None
30
36
  # Aggregates summed from child spans at finalisation.
31
37
  total_tokens_in: Optional[int] = None
@@ -47,6 +53,8 @@ class TrodoSpan:
47
53
  kind: str = "generic" # 'llm' | 'tool' | 'agent' | 'retrieval' | 'generic'
48
54
  name: str = ""
49
55
  status: str = "ok"
56
+ # Severity (Langfuse parity): 'debug' | 'default' | 'warning' | 'error'.
57
+ level: Optional[str] = None
50
58
  started_at: Optional[str] = None
51
59
  ended_at: Optional[str] = None
52
60
  duration_ms: Optional[int] = None
@@ -54,11 +62,23 @@ class TrodoSpan:
54
62
  output: Optional[Union[str, Dict[str, Any]]] = None
55
63
  error_type: Optional[str] = None
56
64
  error_message: Optional[str] = None
65
+ # HTTP/provider status code (e.g. '429', 'rate_limit_exceeded').
66
+ status_code: Optional[str] = None
67
+ # Truncated exception stacktrace.
68
+ stack_trace: Optional[str] = None
57
69
  model: Optional[str] = None
58
70
  provider: Optional[str] = None
59
71
  input_tokens: Optional[int] = None
60
72
  output_tokens: Optional[int] = None
61
73
  cost: Optional[float] = None
74
+ # Open token-usage map forwarded to the backend, which normalises raw
75
+ # provider field names to canonical categories (input, output, cache_read,
76
+ # cache_write, reasoning, + custom keys) and prices each against the team's
77
+ # configured model prices.
78
+ usage_details: Optional[Dict[str, float]] = None
79
+ # Per-category cost breakdown in USD (authoritative when set — ingested cost
80
+ # always wins over server-side derivation).
81
+ cost_details: Optional[Dict[str, float]] = None
62
82
  temperature: Optional[float] = None
63
83
  tool_name: Optional[str] = None
64
84
  attributes: Optional[Dict[str, Any]] = None
trodo/otel/wrap_agent.py CHANGED
@@ -26,6 +26,7 @@ from __future__ import annotations
26
26
 
27
27
  import json
28
28
  import time
29
+ import traceback
29
30
  import uuid
30
31
  from datetime import datetime, timezone
31
32
  from typing import Any, Callable, Dict, Optional, Union
@@ -79,6 +80,54 @@ def _truncate(value: Any, max_len: int = _MAX_VALUE_LEN) -> Optional[str]:
79
80
  return s[:max_len] if len(s) > max_len else s
80
81
 
81
82
 
83
+ def describe_error(exc_type, exc, tb=None) -> Dict[str, Optional[str]]:
84
+ """Extract rich error detail from a caught exception so spans carry error
85
+ TYPE, HTTP/provider STATUS CODE, and STACK TRACE — not just a message.
86
+
87
+ Works generically across provider SDKs: OpenAI (``exc.status`` /
88
+ ``exc.code``), Anthropic (``exc.status``), httpx/requests
89
+ (``exc.response.status_code``), and stdlib errors (``exc.errno``). Never
90
+ raises. Returns keys: error_type, error_message, status_code, stack_trace,
91
+ level.
92
+ """
93
+ if exc is None:
94
+ return {"error_type": None, "error_message": None, "status_code": None,
95
+ "stack_trace": None, "level": "error"}
96
+ error_type = getattr(exc_type, "__name__", None) or type(exc).__name__
97
+ error_message = _truncate(str(exc), 4_000)
98
+
99
+ status_code: Optional[str] = None
100
+ # HTTP status first (numeric), then a provider/system error code.
101
+ for attr in ("status", "status_code"):
102
+ v = getattr(exc, attr, None)
103
+ if v is not None:
104
+ status_code = str(v)[:32]
105
+ break
106
+ if status_code is None:
107
+ resp = getattr(exc, "response", None)
108
+ rc = getattr(resp, "status_code", None) if resp is not None else None
109
+ if rc is not None:
110
+ status_code = str(rc)[:32]
111
+ if status_code is None:
112
+ code = getattr(exc, "code", None) or getattr(exc, "errno", None)
113
+ if code is not None:
114
+ status_code = str(code)[:32]
115
+
116
+ stack_trace: Optional[str] = None
117
+ try:
118
+ stack_trace = _truncate("".join(traceback.format_exception(exc_type, exc, tb)), 20_000)
119
+ except Exception:
120
+ stack_trace = None
121
+
122
+ return {
123
+ "error_type": (error_type or "Error")[:128],
124
+ "error_message": error_message,
125
+ "status_code": status_code,
126
+ "stack_trace": stack_trace,
127
+ "level": "error",
128
+ }
129
+
130
+
82
131
  def _prepare_value(value: Any, max_len: int = _MAX_VALUE_LEN) -> Optional[Union[str, Dict[str, Any]]]:
83
132
  """Prepare a value for storage in the JSONB input/output column.
84
133
 
@@ -197,8 +246,22 @@ class SpanHandle:
197
246
  self.input_tokens: Optional[int] = None
198
247
  self.output_tokens: Optional[int] = None
199
248
  self.cost: Optional[float] = None
249
+ # Open token-usage map (canonical or raw provider keys — the backend
250
+ # normalises). Lets callers report cache/reasoning/custom categories.
251
+ self.usage_details: Optional[Dict[str, float]] = None
252
+ # Optional per-category cost breakdown in USD (authoritative when set).
253
+ self.cost_details: Optional[Dict[str, float]] = None
200
254
  self.temperature: Optional[float] = None
201
255
  self.tool_name: Optional[str] = None
256
+ # Severity for this span (Langfuse parity). Leave None for the default
257
+ # behaviour (backend derives 'error' on a thrown exception, else
258
+ # 'default'). Set 'warning' for a recovered/retried step, 'debug' for
259
+ # verbose spans.
260
+ self.level: Optional[str] = None
261
+
262
+ def set_level(self, level: str) -> None:
263
+ """Mark this span's severity (does not change ok/error status)."""
264
+ self.level = level
202
265
 
203
266
  def set_input(self, value: Any) -> None:
204
267
  self.input = _prepare_value(value)
@@ -217,6 +280,10 @@ class SpanHandle:
217
280
  input_tokens: Optional[int] = None,
218
281
  output_tokens: Optional[int] = None,
219
282
  cost: Optional[float] = None,
283
+ usage_details: Optional[Dict[str, float]] = None,
284
+ cost_details: Optional[Dict[str, float]] = None,
285
+ cache_read_tokens: Optional[int] = None,
286
+ cache_write_tokens: Optional[int] = None,
220
287
  temperature: Optional[float] = None,
221
288
  ) -> None:
222
289
  if model is not None:
@@ -231,6 +298,28 @@ class SpanHandle:
231
298
  self.cost = float(cost)
232
299
  if temperature is not None:
233
300
  self.temperature = float(temperature)
301
+ # Merge any usage map + cache shorthands into one forwarded map.
302
+ if usage_details or cache_read_tokens is not None or cache_write_tokens is not None:
303
+ merged: Dict[str, float] = dict(self.usage_details or {})
304
+ if usage_details:
305
+ for k, v in usage_details.items():
306
+ try:
307
+ merged[k] = float(v)
308
+ except (TypeError, ValueError):
309
+ continue
310
+ if cache_read_tokens is not None:
311
+ merged["cache_read"] = float(cache_read_tokens)
312
+ if cache_write_tokens is not None:
313
+ merged["cache_write"] = float(cache_write_tokens)
314
+ self.usage_details = merged
315
+ if cost_details:
316
+ merged_c: Dict[str, float] = dict(self.cost_details or {})
317
+ for k, v in cost_details.items():
318
+ try:
319
+ merged_c[k] = float(v)
320
+ except (TypeError, ValueError):
321
+ continue
322
+ self.cost_details = merged_c
234
323
 
235
324
  def set_tool(self, tool_name: str) -> None:
236
325
  self.tool_name = tool_name
@@ -388,9 +477,8 @@ class wrap_agent:
388
477
  ended_iso = _now_iso()
389
478
  duration_ms = int(time.time() * 1000.0 - self._started_ms)
390
479
  status = "error" if exc is not None else "ok"
391
- error_summary = None
392
- if exc is not None:
393
- error_summary = _truncate(str(exc), 4_000)
480
+ einfo = describe_error(exc_type, exc, tb) if exc is not None else None
481
+ error_summary = einfo["error_message"] if einfo else None
394
482
 
395
483
  pending = self._processor.get_pending(self.handle.run_id)
396
484
  agg = _aggregate(pending)
@@ -402,12 +490,14 @@ class wrap_agent:
402
490
  conversation_id=self._conversation_id,
403
491
  parent_run_id=self._parent_run_id,
404
492
  status=status,
493
+ level="error" if exc is not None else None,
405
494
  input=self.handle.input,
406
495
  output=self.handle.output,
407
496
  started_at=self._started_iso,
408
497
  ended_at=ended_iso,
409
498
  duration_ms=duration_ms,
410
499
  error_summary=error_summary,
500
+ error_type=einfo["error_type"] if einfo else None,
411
501
  metadata={**(self._metadata or {}), **self.handle.metadata} or None,
412
502
  total_tokens_in=agg["total_tokens_in"],
413
503
  total_tokens_out=agg["total_tokens_out"],
@@ -553,8 +643,8 @@ class join_run:
553
643
  ended_iso = _now_iso()
554
644
  duration_ms = int(time.time() * 1000.0 - self._started_ms)
555
645
  status = "error" if exc is not None else "ok"
556
- error_type = exc_type.__name__ if exc_type else None
557
- error_message = _truncate(str(exc), 4_000) if exc else None
646
+ einfo = describe_error(exc_type, exc, tb) if exc is not None else None
647
+ level = self.handle.level or ("error" if exc is not None else None)
558
648
 
559
649
  trodo_span = TrodoSpan(
560
650
  span_id=self._span_id,
@@ -563,18 +653,23 @@ class join_run:
563
653
  kind=self._kind,
564
654
  name=self._name,
565
655
  status=status,
656
+ level=level,
566
657
  started_at=self._started_iso,
567
658
  ended_at=ended_iso,
568
659
  duration_ms=duration_ms,
569
660
  input=self.handle.input,
570
661
  output=self.handle.output,
571
- error_type=error_type,
572
- error_message=error_message,
662
+ error_type=einfo["error_type"] if einfo else None,
663
+ error_message=einfo["error_message"] if einfo else None,
664
+ status_code=einfo["status_code"] if einfo else None,
665
+ stack_trace=einfo["stack_trace"] if einfo else None,
573
666
  model=self.handle.model,
574
667
  provider=self.handle.provider,
575
668
  input_tokens=self.handle.input_tokens,
576
669
  output_tokens=self.handle.output_tokens,
577
670
  cost=self.handle.cost,
671
+ usage_details=self.handle.usage_details,
672
+ cost_details=self.handle.cost_details,
578
673
  temperature=self.handle.temperature,
579
674
  tool_name=self.handle.tool_name,
580
675
  attributes=self.handle.attributes or None,
@@ -643,8 +738,8 @@ class span:
643
738
  ended_iso = _now_iso()
644
739
  duration_ms = int(time.time() * 1000.0 - self._started_ms)
645
740
  status = "error" if exc is not None else "ok"
646
- error_type = exc_type.__name__ if exc_type else None
647
- error_message = _truncate(str(exc), 4_000) if exc else None
741
+ einfo = describe_error(exc_type, exc, tb) if exc is not None else None
742
+ level = self.handle.level or ("error" if exc is not None else None)
648
743
 
649
744
  trodo_span = TrodoSpan(
650
745
  span_id=self._span_id,
@@ -653,18 +748,23 @@ class span:
653
748
  kind=self._kind,
654
749
  name=self._name,
655
750
  status=status,
751
+ level=level,
656
752
  started_at=self._started_iso,
657
753
  ended_at=ended_iso,
658
754
  duration_ms=duration_ms,
659
755
  input=self.handle.input,
660
756
  output=self.handle.output,
661
- error_type=error_type,
662
- error_message=error_message,
757
+ error_type=einfo["error_type"] if einfo else None,
758
+ error_message=einfo["error_message"] if einfo else None,
759
+ status_code=einfo["status_code"] if einfo else None,
760
+ stack_trace=einfo["stack_trace"] if einfo else None,
663
761
  model=self.handle.model,
664
762
  provider=self.handle.provider,
665
763
  input_tokens=self.handle.input_tokens,
666
764
  output_tokens=self.handle.output_tokens,
667
765
  cost=self.handle.cost,
766
+ usage_details=self.handle.usage_details,
767
+ cost_details=self.handle.cost_details,
668
768
  temperature=self.handle.temperature,
669
769
  tool_name=self.handle.tool_name,
670
770
  attributes=self.handle.attributes or None,
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: trodo-python
3
- Version: 2.7.0
3
+ Version: 2.9.0
4
4
  Summary: Trodo Analytics SDK for Python — server-side event tracking
5
5
  License: ISC
6
6
  Keywords: analytics,tracking,trodo,server-side
@@ -274,6 +274,69 @@ with tracer.start_as_current_span('custom') as sp:
274
274
  sp.set_attribute('gen_ai.system', 'my-llm')
275
275
  ```
276
276
 
277
+ ### Cost & token reporting (v2.8.0+)
278
+
279
+ Trodo computes per-span cost from whatever you report. **You don't have to send
280
+ cost** — send tokens and Trodo prices them using the team's **Model Price** config
281
+ (Configuration → Model Price), falling back to built-in defaults. Resolution per
282
+ span, highest priority first:
283
+
284
+ 1. **Explicit `cost`** (a final USD number) — used as-is, never recomputed.
285
+ 2. **`cost_details`** (per-category USD breakdown) — authoritative.
286
+ 3. **Tokens** (`usage_details` map, or `input_tokens`/`output_tokens`) — priced by
287
+ the team's configured model price → global default → left unset if unknown.
288
+
289
+ All token categories live in an open **`usage_details`** map. `input`/`output` are
290
+ the defaults; add `cache_read`, `cache_write`, `reasoning`, `audio`, `image`, or any
291
+ custom key. Raw provider field names are fine — the backend normalises them
292
+ (`prompt_tokens`→`input`, `cache_read_input_tokens`→`cache_read`, …). Custom keys
293
+ must match the category name you price in the UI.
294
+
295
+ ```python
296
+ # (a) Tokens only — Trodo prices it from the model name. The llm() helper
297
+ # auto-forwards the FULL provider usage object, so cache/reasoning tokens
298
+ # are captured with zero config.
299
+ answer = trodo.llm('answer', call_anthropic,
300
+ model='claude-sonnet-4', provider='anthropic')
301
+
302
+ # (b) Raw usage object via track_llm_call — same auto-normalisation.
303
+ trodo.track_llm_call(
304
+ model='gpt-4o', provider='openai',
305
+ usage=resp['usage'], # {prompt_tokens, completion_tokens, prompt_tokens_details:{cached_tokens}}
306
+ prompt=body, completion=resp,
307
+ )
308
+
309
+ # (c) Explicit usage map + cache shorthands.
310
+ trodo.track_llm_call(
311
+ model='claude-sonnet-4', provider='anthropic',
312
+ usage_details={'input': 1000, 'output': 500},
313
+ cache_read_tokens=200, cache_write_tokens=80, # → cache_read / cache_write
314
+ )
315
+
316
+ # (d) Pass cost straight through (skip server-side pricing).
317
+ trodo.track_llm_call(model='gpt-4o', provider='openai', cost=0.0123)
318
+
319
+ # (e) Per-category cost breakdown (authoritative).
320
+ trodo.track_llm_call(
321
+ model='gpt-4o', provider='openai',
322
+ cost_details={'input': 0.0003, 'output': 0.0005, 'cache_read': 0.00001},
323
+ )
324
+ ```
325
+
326
+ Inside a `wrap_agent` / `span` block, set the same fields on the handle:
327
+
328
+ ```python
329
+ s.set_llm(
330
+ model='gpt-4o', provider='openai',
331
+ usage_details={'input': 1000, 'output': 500},
332
+ cache_read_tokens=200,
333
+ # or: cost=0.0123 / cost_details={'input': ..., 'output': ...}
334
+ )
335
+ ```
336
+
337
+ Override auto-extraction with `extract_usage` (scalar in/out) or `extract_usage_map`
338
+ (open map) on `trodo.llm(name, fn, ...)`.
339
+
277
340
  ### Cross-service runs
278
341
 
279
342
  When one service calls another, the downstream service **joins** the
@@ -1,4 +1,4 @@
1
- trodo/__init__.py,sha256=NapUF153zVbbqH45gud9g9M_UyQgxuqw_yAYERTGJlw,16678
1
+ trodo/__init__.py,sha256=p9NGic9WzF53OYKtk-MSdQNcCTbXq8DNZeXYWl9OCUo,16678
2
2
  trodo/client.py,sha256=8DsKoLh_eaNxj93qkHynfeee-QsdomB_kXfUQjGnWDk,18607
3
3
  trodo/types.py,sha256=eySgUvCXROG2TxtxgiU0MNr5iH0DEcduK8bmYtTKG44,3138
4
4
  trodo/user_context.py,sha256=9la6azzwEanVmdP4ps_xMoufbeWVeIGU-M8ychmgajg,7859
@@ -12,20 +12,20 @@ trodo/managers/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
12
12
  trodo/managers/group_manager.py,sha256=ki3Se3qEoSZfREX63oeDeBmEfZF-ISHLE8azEtLg0tM,3542
13
13
  trodo/managers/people_manager.py,sha256=mMVnx40Mlifx6NGgChvohC9ViK6dQu2mkXNHbV8pK1E,2882
14
14
  trodo/otel/__init__.py,sha256=yiRFXWUU45bAM2CV37XeO7zf1hmnmjufdP4XO50yEyE,624
15
- trodo/otel/auto_instrument.py,sha256=7uKhir0o0Mo_od1H2oMf5PHZovcUocHtgV18mRm2Erc,11193
15
+ trodo/otel/auto_instrument.py,sha256=gym90cYD6NzVDVsNtUjKHdKANy17t4AnU4lYGEGHyo8,12866
16
16
  trodo/otel/context.py,sha256=iJ1rE42-SbO8VZHAxhIl2ZJXgNwLIVps5xLg8GKgfFc,1165
17
- trodo/otel/helpers.py,sha256=IEAHxAEN-Bvv_ZODrmRzC6PCGGhGTXU7IPcp6iO2nbA,16405
18
- trodo/otel/processor.py,sha256=9y5gUMg7iQ1anXf2vGLbmgdT8iRXUGu7QivAqxg4JS4,6384
17
+ trodo/otel/helpers.py,sha256=4HsjMOrE-7zuvaRSiGXxV7ZyfXQ5gLxtR3HdpLut9sk,20054
18
+ trodo/otel/processor.py,sha256=aqcTmzTw9cESgIp829pu_XCa5_dG_2MaeJNsqJZeqQU,7495
19
19
  trodo/otel/register.py,sha256=YV2EnkUoa-_54YAuChOe-Mg28UUKg8JO7-qhVP9G6u4,7644
20
20
  trodo/otel/transport.py,sha256=hzZz8gwSMGJ8CxdijmLn1Ljt18owr9XTWy13DLbwYbw,2441
21
- trodo/otel/wrap_agent.py,sha256=T5pbD2iGE2B7oilKszPsFTRyRtEvdXe2cz20xpg20ik,25529
21
+ trodo/otel/wrap_agent.py,sha256=Nvjj8CjIGHqfWNV_heHno9HKJbKU3lHqHInX1EyRhnw,30334
22
22
  trodo/queue/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
23
23
  trodo/queue/batch_flusher.py,sha256=4Lg6T3Urwi9U0Q4FpFGPmjDYKg4ZliCTR-ND8BJvWaY,1298
24
24
  trodo/queue/event_queue.py,sha256=EVFZrhlq_kwC3jJ2GK0wMhHISf9UzLCZNDnT_aZ2I2A,872
25
25
  trodo/session/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
26
26
  trodo/session/server_session.py,sha256=McsudEiq33XDq3nqxgzBcUvIjQxCMscwEuAPnYXrTjs,2136
27
27
  trodo/session/session_manager.py,sha256=JrgH1VeicmtlxPR4dXEuJbxhi23OelkgwW3-9Slv80o,2525
28
- trodo_python-2.7.0.dist-info/METADATA,sha256=K19DXcIe8jCHbOCEWXkyy0mxGhjCz_fH_VIdm0vuM9E,17882
29
- trodo_python-2.7.0.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
30
- trodo_python-2.7.0.dist-info/top_level.txt,sha256=VCQu1CJWFmNsqTs1YxMcw4Mq35Tc3z3uI9RwHEXAayQ,6
31
- trodo_python-2.7.0.dist-info/RECORD,,
28
+ trodo_python-2.9.0.dist-info/METADATA,sha256=Ab8QIUgDMayOyTjdtrdFP_EMk5bCcLHtX4hZJaaKEac,20482
29
+ trodo_python-2.9.0.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
30
+ trodo_python-2.9.0.dist-info/top_level.txt,sha256=VCQu1CJWFmNsqTs1YxMcw4Mq35Tc3z3uI9RwHEXAayQ,6
31
+ trodo_python-2.9.0.dist-info/RECORD,,