evalkeep 0.1.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.
Files changed (75) hide show
  1. evalkeep/__init__.py +12 -0
  2. evalkeep/__main__.py +6 -0
  3. evalkeep/adapters/__init__.py +45 -0
  4. evalkeep/adapters/base.py +92 -0
  5. evalkeep/adapters/jsonl.py +164 -0
  6. evalkeep/adapters/langsmith.py +436 -0
  7. evalkeep/adapters/otlp.py +442 -0
  8. evalkeep/adapters/semconv.py +208 -0
  9. evalkeep/analysis.py +174 -0
  10. evalkeep/analysis_run.py +160 -0
  11. evalkeep/analyzers/__init__.py +52 -0
  12. evalkeep/analyzers/anthropic.py +145 -0
  13. evalkeep/analyzers/stub.py +34 -0
  14. evalkeep/cache.py +122 -0
  15. evalkeep/cli.py +1933 -0
  16. evalkeep/clustering.py +383 -0
  17. evalkeep/clusters.py +101 -0
  18. evalkeep/commands/__init__.py +1 -0
  19. evalkeep/commands/analyze_cmd.py +100 -0
  20. evalkeep/commands/compare_cmd.py +169 -0
  21. evalkeep/commands/dataset_cmd.py +182 -0
  22. evalkeep/commands/detect_cmd.py +154 -0
  23. evalkeep/commands/discover_cmd.py +274 -0
  24. evalkeep/commands/ingest_cmd.py +50 -0
  25. evalkeep/commands/init_cmd.py +151 -0
  26. evalkeep/commands/pipeline_cmd.py +156 -0
  27. evalkeep/commands/review_cmd.py +141 -0
  28. evalkeep/commands/run_cmd.py +131 -0
  29. evalkeep/commands/target_cmd.py +109 -0
  30. evalkeep/commands/trace_cmd.py +58 -0
  31. evalkeep/comparison.py +432 -0
  32. evalkeep/config.py +209 -0
  33. evalkeep/detection.py +94 -0
  34. evalkeep/detectors.py +182 -0
  35. evalkeep/discovery.py +208 -0
  36. evalkeep/embeddings/__init__.py +31 -0
  37. evalkeep/embeddings/base.py +32 -0
  38. evalkeep/embeddings/hashing.py +98 -0
  39. evalkeep/errors.py +42 -0
  40. evalkeep/examples/__init__.py +37 -0
  41. evalkeep/examples/langsmith/runs.jsonl +18 -0
  42. evalkeep/examples/opentelemetry/spans.json +898 -0
  43. evalkeep/examples/refund-agent/agents/baseline.py +66 -0
  44. evalkeep/examples/refund-agent/agents/candidate.py +66 -0
  45. evalkeep/examples/refund-agent/traces.jsonl +5 -0
  46. evalkeep/examples/tau-bench/prepare.py +230 -0
  47. evalkeep/exporters/__init__.py +45 -0
  48. evalkeep/exporters/generic.py +31 -0
  49. evalkeep/exporters/promptfoo.py +219 -0
  50. evalkeep/failures.py +95 -0
  51. evalkeep/generation.py +303 -0
  52. evalkeep/hashing.py +56 -0
  53. evalkeep/ingest.py +257 -0
  54. evalkeep/prompts.py +127 -0
  55. evalkeep/pseudonyms.py +82 -0
  56. evalkeep/py.typed +0 -0
  57. evalkeep/redaction.py +333 -0
  58. evalkeep/regression.py +409 -0
  59. evalkeep/review.py +309 -0
  60. evalkeep/runner.py +302 -0
  61. evalkeep/runs.py +185 -0
  62. evalkeep/storage/__init__.py +37 -0
  63. evalkeep/storage/clusters.py +163 -0
  64. evalkeep/storage/failures.py +254 -0
  65. evalkeep/storage/migrations.py +370 -0
  66. evalkeep/storage/regression.py +136 -0
  67. evalkeep/storage/runs.py +223 -0
  68. evalkeep/storage/store.py +429 -0
  69. evalkeep/targets.py +205 -0
  70. evalkeep/trace.py +238 -0
  71. evalkeep-0.1.0.dist-info/METADATA +221 -0
  72. evalkeep-0.1.0.dist-info/RECORD +75 -0
  73. evalkeep-0.1.0.dist-info/WHEEL +4 -0
  74. evalkeep-0.1.0.dist-info/entry_points.txt +3 -0
  75. evalkeep-0.1.0.dist-info/licenses/LICENSE +202 -0
@@ -0,0 +1,442 @@
1
+ """Reading OpenTelemetry traces exported as OTLP JSON.
2
+
3
+ This is the adapter that matters most for adoption, because OTel is the hub
4
+ rather than another vendor: Langfuse, Braintrust and Phoenix all ingest OTLP, so
5
+ an application instrumented for any of them can be pointed here without changing
6
+ its instrumentation.
7
+
8
+ **One OTel trace becomes one Evalkeep trace.** Spans sharing a trace ID are a
9
+ single interaction, and Evalkeep's unit is the interaction, so they are grouped
10
+ rather than emitted one by one.
11
+
12
+ That grouping is the one place this adapter differs from the JSONL one, which
13
+ streams a trace per line. Spans of a trace can appear anywhere in an export, so
14
+ they are held until the file ends. Memory is therefore proportional to the file,
15
+ not constant -- see the measured figures in `docs/pipeline.md`. Split very large
16
+ exports by time range.
17
+
18
+ **What it does not do.** OTel records what an application did, not whether the
19
+ answer was any good. A span with an ERROR status is real evidence and becomes an
20
+ ``error`` outcome; everything else becomes ``unknown``, and it is detection's job
21
+ to say it found nothing. Inventing a `success` here would be inventing evidence.
22
+ """
23
+
24
+ from __future__ import annotations
25
+
26
+ import json
27
+ from collections import Counter
28
+ from collections.abc import Iterable, Iterator
29
+ from dataclasses import dataclass, field
30
+ from datetime import UTC, datetime
31
+ from pathlib import Path
32
+ from typing import Any, ClassVar
33
+
34
+ from pydantic import ValidationError
35
+
36
+ from evalkeep.adapters.base import AdapterRecord, IssueKind, TraceIssue
37
+ from evalkeep.adapters.semconv import (
38
+ GEN_AI_REQUEST_MODEL,
39
+ GEN_AI_SYSTEM,
40
+ GEN_AI_TOOL_ARGUMENTS,
41
+ GEN_AI_TOOL_NAME,
42
+ INPUT_VALUE,
43
+ KIND_TOOL,
44
+ LLM_MODEL_NAME,
45
+ OUTPUT_VALUE,
46
+ SERVICE_NAME,
47
+ SPAN_KIND,
48
+ TOOL_NAME,
49
+ TOOL_PARAMETERS,
50
+ decode_attributes,
51
+ json_object,
52
+ messages,
53
+ text,
54
+ tool_calls,
55
+ )
56
+ from evalkeep.trace import NormalizedTrace
57
+
58
+ #: OTLP status codes. 2 is ERROR; 0 is unset and 1 is OK.
59
+ _STATUS_ERROR = 2
60
+
61
+ _NANOSECONDS = 1_000_000_000
62
+
63
+
64
+ @dataclass
65
+ class Span:
66
+ """One OTel span, decoded far enough to be useful."""
67
+
68
+ trace_id: str
69
+ span_id: str
70
+ parent_span_id: str | None
71
+ name: str
72
+ start_nanos: int
73
+ end_nanos: int
74
+ attributes: dict[str, Any] = field(default_factory=dict)
75
+ resource: dict[str, Any] = field(default_factory=dict)
76
+ status_code: int = 0
77
+ status_message: str | None = None
78
+
79
+ @property
80
+ def errored(self) -> bool:
81
+ return self.status_code == _STATUS_ERROR
82
+
83
+ @property
84
+ def kind(self) -> str:
85
+ value = self.attributes.get(SPAN_KIND)
86
+ return value.upper() if isinstance(value, str) else ""
87
+
88
+ @property
89
+ def started_at(self) -> datetime | None:
90
+ if not self.start_nanos:
91
+ return None
92
+ return datetime.fromtimestamp(self.start_nanos / _NANOSECONDS, tz=UTC)
93
+
94
+
95
+ class OtlpAdapter:
96
+ """Reads OTLP JSON, in either of the two shapes exporters produce."""
97
+
98
+ name: ClassVar[str] = "otlp"
99
+ description: ClassVar[str] = (
100
+ "OpenTelemetry spans as OTLP JSON (OpenInference or gen_ai conventions)"
101
+ )
102
+
103
+ def read(self, path: Path) -> Iterator[AdapterRecord]:
104
+ try:
105
+ documents = list(_documents(path))
106
+ except (OSError, UnicodeDecodeError) as exc:
107
+ yield AdapterRecord.rejected(
108
+ 1,
109
+ TraceIssue(
110
+ line=1, kind=IssueKind.ENCODING, message=f"could not read {path}: {exc}"
111
+ ),
112
+ )
113
+ return
114
+
115
+ spans: list[Span] = []
116
+ for line, document in documents:
117
+ if isinstance(document, Exception):
118
+ yield AdapterRecord.rejected(
119
+ line,
120
+ TraceIssue(
121
+ line=line,
122
+ kind=IssueKind.JSON,
123
+ message=f"invalid JSON: {document}",
124
+ ),
125
+ )
126
+ continue
127
+ spans.extend(_spans(document))
128
+
129
+ yield from self._group(spans)
130
+
131
+ def _group(self, spans: list[Span]) -> Iterator[AdapterRecord]:
132
+ """One record per OTel trace, in first-seen order."""
133
+ grouped: dict[str, list[Span]] = {}
134
+ for span in spans:
135
+ grouped.setdefault(span.trace_id, []).append(span)
136
+
137
+ for line, (trace_id, group) in enumerate(grouped.items(), start=1):
138
+ group.sort(key=lambda span: (span.start_nanos, span.span_id))
139
+ try:
140
+ yield AdapterRecord.valid(line, _build(trace_id, group))
141
+ except ValidationError as exc:
142
+ yield AdapterRecord.rejected(
143
+ line,
144
+ *[
145
+ TraceIssue(
146
+ line=line,
147
+ kind=IssueKind.SCHEMA,
148
+ message=detail["msg"],
149
+ trace_id=trace_id,
150
+ field=".".join(str(part) for part in detail["loc"]) or None,
151
+ )
152
+ for detail in exc.errors()
153
+ ],
154
+ )
155
+
156
+
157
+ def _documents(path: Path) -> Iterable[tuple[int, Any]]:
158
+ """Yield each JSON document in the file, with the line it started on.
159
+
160
+ Exporters produce either one JSON document per file or one per line; both
161
+ are common enough that requiring the right one would just be a papercut.
162
+ """
163
+ raw = path.read_text(encoding="utf-8")
164
+ stripped = raw.strip()
165
+ if not stripped:
166
+ return
167
+
168
+ try:
169
+ yield 1, json.loads(stripped)
170
+ return
171
+ except json.JSONDecodeError:
172
+ pass
173
+
174
+ for line_number, line in enumerate(raw.splitlines(), start=1):
175
+ if not line.strip():
176
+ continue
177
+ try:
178
+ yield line_number, json.loads(line)
179
+ except json.JSONDecodeError as exc:
180
+ yield line_number, exc
181
+
182
+
183
+ def _spans(document: Any) -> Iterator[Span]:
184
+ """Walk the OTLP envelope down to individual spans."""
185
+ if not isinstance(document, dict):
186
+ return
187
+ for resource_spans in document.get("resourceSpans") or []:
188
+ if not isinstance(resource_spans, dict):
189
+ continue
190
+ resource = decode_attributes((resource_spans.get("resource") or {}).get("attributes"))
191
+ for scope_spans in resource_spans.get("scopeSpans") or []:
192
+ if not isinstance(scope_spans, dict):
193
+ continue
194
+ for raw in scope_spans.get("spans") or []:
195
+ span = _span(raw, resource)
196
+ if span is not None:
197
+ yield span
198
+
199
+
200
+ def _span(raw: Any, resource: dict[str, Any]) -> Span | None:
201
+ if not isinstance(raw, dict):
202
+ return None
203
+ trace_id = raw.get("traceId")
204
+ span_id = raw.get("spanId")
205
+ if not isinstance(trace_id, str) or not trace_id:
206
+ return None
207
+ status = raw.get("status") or {}
208
+ return Span(
209
+ trace_id=trace_id,
210
+ span_id=span_id if isinstance(span_id, str) else "",
211
+ parent_span_id=raw.get("parentSpanId") or None,
212
+ name=str(raw.get("name") or ""),
213
+ start_nanos=_nanos(raw.get("startTimeUnixNano")),
214
+ end_nanos=_nanos(raw.get("endTimeUnixNano")),
215
+ attributes=decode_attributes(raw.get("attributes")),
216
+ resource=resource,
217
+ status_code=int(status.get("code") or 0),
218
+ status_message=status.get("message") or None,
219
+ )
220
+
221
+
222
+ def _nanos(value: Any) -> int:
223
+ try:
224
+ return int(value)
225
+ except (TypeError, ValueError):
226
+ return 0
227
+
228
+
229
+ def _build(trace_id: str, spans: list[Span]) -> NormalizedTrace:
230
+ """Assemble one Evalkeep trace from the spans of one OTel trace."""
231
+ root = _root(spans)
232
+ payload: dict[str, Any] = {
233
+ "trace_id": trace_id,
234
+ "input": _input(root, spans),
235
+ "events": _events(spans),
236
+ "outcome": _outcome(spans),
237
+ "metadata": _metadata(root, spans),
238
+ }
239
+ output = _output(root)
240
+ if output is not None:
241
+ payload["output"] = output
242
+ return NormalizedTrace.model_validate(payload)
243
+
244
+
245
+ def _root(spans: list[Span]) -> Span:
246
+ """The span nothing else in this trace parents, or the earliest one."""
247
+ known = {span.span_id for span in spans}
248
+ for span in spans:
249
+ if not span.parent_span_id or span.parent_span_id not in known:
250
+ return span
251
+ return spans[0]
252
+
253
+
254
+ def _input(root: Span, spans: list[Span]) -> dict[str, Any]:
255
+ """What the interaction was asked to do.
256
+
257
+ Falls back through the trace when the root span carries nothing, because
258
+ instrumentation often records the prompt on the first LLM span rather than
259
+ on the enclosing agent span.
260
+ """
261
+ for span in [root, *spans]:
262
+ conversation = messages(span.attributes)
263
+ if conversation:
264
+ return {"messages": conversation}
265
+ value = text(span.attributes, INPUT_VALUE)
266
+ if value:
267
+ return {"text": value}
268
+ return {"text": root.name or "(no recorded input)"}
269
+
270
+
271
+ def _output(root: Span) -> dict[str, Any] | None:
272
+ conversation = messages(root.attributes, output=True)
273
+ if conversation:
274
+ return {"messages": conversation}
275
+ value = text(root.attributes, OUTPUT_VALUE)
276
+ return {"text": value} if value else None
277
+
278
+
279
+ def _events(spans: list[Span]) -> list[dict[str, Any]]:
280
+ """Tool spans become a call and its result; unexecuted intents become calls.
281
+
282
+ A tool span records both the request and what came back, so it produces two
283
+ events -- which is what lets an expectation assert on the arguments and a
284
+ fixture replay the result.
285
+
286
+ The same call is usually recorded twice: the LLM span declares the intent in
287
+ `message.tool_calls`, and a sibling tool span records the execution. Emitting
288
+ both would double every tool call, so an intent that a tool span accounts for
289
+ is dropped, one for one. An intent with no matching span is kept -- a tool
290
+ the agent asked for and never ran is a real observation, and often the
291
+ interesting one.
292
+ """
293
+ executed = Counter(
294
+ _call_key(_safe_tool_name(name), _tool_arguments(span))
295
+ for span in spans
296
+ if (name := _tool_name(span)) is not None
297
+ )
298
+
299
+ events: list[dict[str, Any]] = []
300
+ for span in spans:
301
+ name = _tool_name(span)
302
+ if name is not None:
303
+ events.extend(_tool_span_events(span, _safe_tool_name(name), len(events)))
304
+ continue
305
+
306
+ for index, (tool, arguments) in enumerate(tool_calls(span.attributes)):
307
+ key = _call_key(_safe_tool_name(tool), arguments)
308
+ if executed.get(key, 0) > 0:
309
+ executed[key] -= 1
310
+ continue
311
+ events.append(
312
+ {
313
+ "event_id": f"{span.span_id or len(events)}-tool-{index}",
314
+ "type": "tool_call",
315
+ "tool": _safe_tool_name(tool),
316
+ "arguments": arguments,
317
+ "timestamp": _timestamp(span.start_nanos),
318
+ }
319
+ )
320
+ return events
321
+
322
+
323
+ def _call_key(tool: str, arguments: dict[str, Any]) -> str:
324
+ return f"{tool}:{json.dumps(arguments, sort_keys=True, default=str)}"
325
+
326
+
327
+ def _tool_span_events(span: Span, tool: str, position: int) -> list[dict[str, Any]]:
328
+ call_id = span.span_id or f"call-{position}"
329
+ events: list[dict[str, Any]] = [
330
+ {
331
+ "event_id": f"{span.span_id or position}-call",
332
+ "type": "tool_call",
333
+ "tool": tool,
334
+ "call_id": call_id,
335
+ "arguments": _tool_arguments(span),
336
+ "timestamp": _timestamp(span.start_nanos),
337
+ }
338
+ ]
339
+ result = text(span.attributes, OUTPUT_VALUE)
340
+ if result is not None or span.errored:
341
+ events.append(
342
+ {
343
+ "event_id": f"{span.span_id or position}-result",
344
+ "type": "tool_result",
345
+ "tool": tool,
346
+ "call_id": call_id,
347
+ "result": _maybe_json(result),
348
+ "error": span.status_message if span.errored else None,
349
+ "timestamp": _timestamp(span.end_nanos or span.start_nanos),
350
+ }
351
+ )
352
+ return events
353
+
354
+
355
+ def _tool_name(span: Span) -> str | None:
356
+ """The tool this span invoked, if it is a tool span at all."""
357
+ for key in (TOOL_NAME, GEN_AI_TOOL_NAME):
358
+ value = span.attributes.get(key)
359
+ if isinstance(value, str) and value:
360
+ return value
361
+ if span.kind == KIND_TOOL:
362
+ return span.name or None
363
+ return None
364
+
365
+
366
+ def _tool_arguments(span: Span) -> dict[str, Any]:
367
+ for key in (TOOL_PARAMETERS, GEN_AI_TOOL_ARGUMENTS, INPUT_VALUE):
368
+ arguments = json_object(span.attributes, key)
369
+ if arguments:
370
+ return arguments
371
+ return {}
372
+
373
+
374
+ def _safe_tool_name(name: str) -> str:
375
+ """Coerce a span name into something the trace schema accepts as a tool.
376
+
377
+ Instrumentation names tool spans freely -- "Tool: refund order" is common --
378
+ and the schema requires an identifier, so the alternative to normalizing is
379
+ rejecting traces over a cosmetic difference.
380
+ """
381
+ cleaned = "".join(character if character.isalnum() else "_" for character in name.strip())
382
+ cleaned = cleaned.strip("_") or "tool"
383
+ if not (cleaned[0].isalpha() or cleaned[0] == "_"):
384
+ cleaned = f"_{cleaned}"
385
+ return cleaned[:128]
386
+
387
+
388
+ def _outcome(spans: list[Span]) -> dict[str, Any]:
389
+ """An errored span is evidence. Everything else is silence, not success."""
390
+ failed = [span for span in spans if span.errored]
391
+ if not failed:
392
+ return {"status": "unknown"}
393
+ return {
394
+ "status": "error",
395
+ "evaluations": [
396
+ {
397
+ "name": span.name or "span",
398
+ "passed": False,
399
+ "reason": span.status_message or "the span reported an error status",
400
+ }
401
+ for span in failed
402
+ ],
403
+ }
404
+
405
+
406
+ def _metadata(root: Span, spans: list[Span]) -> dict[str, Any]:
407
+ model = text(root.attributes, LLM_MODEL_NAME) or text(root.attributes, GEN_AI_REQUEST_MODEL)
408
+ if model is None:
409
+ for span in spans:
410
+ model = text(span.attributes, LLM_MODEL_NAME) or text(
411
+ span.attributes, GEN_AI_REQUEST_MODEL
412
+ )
413
+ if model:
414
+ break
415
+ started = root.started_at
416
+ return {
417
+ "source": "opentelemetry",
418
+ "agent": text(root.resource, SERVICE_NAME),
419
+ "model": model,
420
+ "recorded_at": started.isoformat() if started else None,
421
+ "extra": {
422
+ "spans": len(spans),
423
+ "root_span": root.name,
424
+ "gen_ai_system": text(root.attributes, GEN_AI_SYSTEM),
425
+ },
426
+ }
427
+
428
+
429
+ def _timestamp(nanos: int) -> str | None:
430
+ if not nanos:
431
+ return None
432
+ return datetime.fromtimestamp(nanos / _NANOSECONDS, tz=UTC).isoformat()
433
+
434
+
435
+ def _maybe_json(value: str | None) -> Any:
436
+ """Tool results are often JSON in a string; keep the structure when so."""
437
+ if value is None:
438
+ return None
439
+ try:
440
+ return json.loads(value)
441
+ except json.JSONDecodeError:
442
+ return value
@@ -0,0 +1,208 @@
1
+ """Reading OpenTelemetry span attributes, across two competing conventions.
2
+
3
+ OTel attributes are a flat list of typed key/value pairs, so anything nested has
4
+ to be flattened into indexed keys on the way in::
5
+
6
+ llm.input_messages.0.message.role = "user"
7
+ llm.output_messages.0.message.tool_calls.0.tool_call.function.name = "refund_order"
8
+
9
+ Undoing that is most of the work here.
10
+
11
+ The other half is that there is no single convention for GenAI spans. Two are in
12
+ use and neither has won:
13
+
14
+ * **OpenInference** (Arize) -- purpose-built for agents. It models tool calls and
15
+ their arguments directly, which is what Evalkeep's expectations are written
16
+ against, so it is the convention this adapter targets.
17
+ * **OTel GenAI** (``gen_ai.*``) -- broader vendor support, and its messages are
18
+ whole JSON documents rather than flattened fields. Supported as a fallback for
19
+ the fields where the mapping is unambiguous.
20
+
21
+ Where a trace carries both, OpenInference wins: it is the more specific of the
22
+ two, and guessing between them would be worse than preferring the one that can
23
+ actually express a tool call.
24
+ """
25
+
26
+ from __future__ import annotations
27
+
28
+ import json
29
+ from typing import Any
30
+
31
+ # -- OpenInference ---------------------------------------------------------
32
+
33
+ SPAN_KIND = "openinference.span.kind"
34
+ INPUT_VALUE = "input.value"
35
+ OUTPUT_VALUE = "output.value"
36
+ LLM_INPUT_MESSAGES = "llm.input_messages"
37
+ LLM_OUTPUT_MESSAGES = "llm.output_messages"
38
+ MESSAGE_ROLE = "message.role"
39
+ MESSAGE_CONTENT = "message.content"
40
+ MESSAGE_TOOL_CALLS = "message.tool_calls"
41
+ TOOL_CALL_NAME = "tool_call.function.name"
42
+ TOOL_CALL_ARGUMENTS = "tool_call.function.arguments"
43
+ TOOL_NAME = "tool.name"
44
+ TOOL_PARAMETERS = "tool.parameters"
45
+ LLM_MODEL_NAME = "llm.model_name"
46
+
47
+ #: The span kinds Evalkeep reads. Others are carried as context only.
48
+ KIND_TOOL = "TOOL"
49
+ KIND_LLM = "LLM"
50
+ KIND_AGENT = "AGENT"
51
+ KIND_CHAIN = "CHAIN"
52
+
53
+ # -- OTel GenAI ------------------------------------------------------------
54
+
55
+ GEN_AI_INPUT_MESSAGES = "gen_ai.input.messages"
56
+ GEN_AI_OUTPUT_MESSAGES = "gen_ai.output.messages"
57
+ GEN_AI_TOOL_NAME = "gen_ai.tool.name"
58
+ GEN_AI_TOOL_ARGUMENTS = "gen_ai.tool.call.arguments"
59
+ GEN_AI_OPERATION = "gen_ai.operation.name"
60
+ GEN_AI_REQUEST_MODEL = "gen_ai.request.model"
61
+ GEN_AI_SYSTEM = "gen_ai.system"
62
+
63
+ SERVICE_NAME = "service.name"
64
+
65
+
66
+ def decode_attributes(raw: Any) -> dict[str, Any]:
67
+ """Turn OTLP's ``[{key, value: {...}}]`` list into a flat dictionary."""
68
+ decoded: dict[str, Any] = {}
69
+ if not isinstance(raw, list):
70
+ return decoded
71
+ for item in raw:
72
+ if not isinstance(item, dict):
73
+ continue
74
+ key = item.get("key")
75
+ if isinstance(key, str):
76
+ decoded[key] = decode_value(item.get("value"))
77
+ return decoded
78
+
79
+
80
+ def decode_value(value: Any) -> Any:
81
+ """One OTLP ``AnyValue``. Unknown shapes decode to ``None`` rather than raise."""
82
+ if not isinstance(value, dict):
83
+ return value
84
+ for field in ("stringValue", "boolValue", "doubleValue"):
85
+ if field in value:
86
+ return value[field]
87
+ if "intValue" in value:
88
+ raw = value["intValue"]
89
+ # Protobuf renders 64-bit integers as strings in JSON.
90
+ try:
91
+ return int(raw)
92
+ except (TypeError, ValueError):
93
+ return raw
94
+ if "arrayValue" in value:
95
+ values = (value["arrayValue"] or {}).get("values") or []
96
+ return [decode_value(item) for item in values]
97
+ if "kvlistValue" in value:
98
+ return decode_attributes((value["kvlistValue"] or {}).get("values"))
99
+ if "bytesValue" in value:
100
+ return value["bytesValue"]
101
+ return None
102
+
103
+
104
+ def indexed(attributes: dict[str, Any], prefix: str) -> list[dict[str, Any]]:
105
+ """Collect ``prefix.<i>.rest`` keys back into a list of dictionaries.
106
+
107
+ Ordering follows the index, not the order the attributes happened to arrive
108
+ in, because a message list that reorders itself is a different conversation.
109
+ """
110
+ grouped: dict[int, dict[str, Any]] = {}
111
+ marker = f"{prefix}."
112
+ for key, value in attributes.items():
113
+ if not key.startswith(marker):
114
+ continue
115
+ remainder = key[len(marker) :]
116
+ index, separator, rest = remainder.partition(".")
117
+ if not separator or not index.isdigit():
118
+ continue
119
+ grouped.setdefault(int(index), {})[rest] = value
120
+ return [grouped[index] for index in sorted(grouped)]
121
+
122
+
123
+ def text(attributes: dict[str, Any], key: str) -> str | None:
124
+ """A string attribute, or ``None`` when absent or empty."""
125
+ value = attributes.get(key)
126
+ if value is None:
127
+ return None
128
+ rendered = value if isinstance(value, str) else json.dumps(value, default=str)
129
+ return rendered or None
130
+
131
+
132
+ def json_object(attributes: dict[str, Any], key: str) -> dict[str, Any]:
133
+ """An attribute holding a JSON object, however it was encoded.
134
+
135
+ Conventions disagree about whether structured values are JSON strings or
136
+ real key/value lists, so both are accepted rather than one being declared
137
+ correct.
138
+ """
139
+ value = attributes.get(key)
140
+ if isinstance(value, dict):
141
+ return value
142
+ if isinstance(value, str) and value.strip():
143
+ try:
144
+ parsed = json.loads(value)
145
+ except json.JSONDecodeError:
146
+ return {}
147
+ return parsed if isinstance(parsed, dict) else {}
148
+ return {}
149
+
150
+
151
+ def json_list(attributes: dict[str, Any], key: str) -> list[Any]:
152
+ """An attribute holding a JSON array, however it was encoded."""
153
+ value = attributes.get(key)
154
+ if isinstance(value, list):
155
+ return value
156
+ if isinstance(value, str) and value.strip():
157
+ try:
158
+ parsed = json.loads(value)
159
+ except json.JSONDecodeError:
160
+ return []
161
+ return parsed if isinstance(parsed, list) else []
162
+ return []
163
+
164
+
165
+ def messages(attributes: dict[str, Any], *, output: bool = False) -> list[dict[str, str]]:
166
+ """Conversation messages from either convention, as role/content pairs."""
167
+ prefix = LLM_OUTPUT_MESSAGES if output else LLM_INPUT_MESSAGES
168
+ found: list[dict[str, str]] = []
169
+ for entry in indexed(attributes, prefix):
170
+ role = entry.get(MESSAGE_ROLE)
171
+ content = entry.get(MESSAGE_CONTENT)
172
+ if isinstance(role, str) and isinstance(content, str) and content:
173
+ found.append({"role": role, "content": content})
174
+ if found:
175
+ return found
176
+
177
+ # OTel GenAI keeps whole messages as one JSON document.
178
+ key = GEN_AI_OUTPUT_MESSAGES if output else GEN_AI_INPUT_MESSAGES
179
+ for entry in json_list(attributes, key):
180
+ if not isinstance(entry, dict):
181
+ continue
182
+ role = entry.get("role")
183
+ content = _flatten_content(entry.get("content") or entry.get("parts"))
184
+ if isinstance(role, str) and content:
185
+ found.append({"role": role, "content": content})
186
+ return found
187
+
188
+
189
+ def _flatten_content(content: Any) -> str:
190
+ """Message content, whether a string or a list of typed parts."""
191
+ if isinstance(content, str):
192
+ return content
193
+ if isinstance(content, list):
194
+ parts = [part.get("text", "") if isinstance(part, dict) else str(part) for part in content]
195
+ return "".join(part for part in parts if part)
196
+ return ""
197
+
198
+
199
+ def tool_calls(attributes: dict[str, Any]) -> list[tuple[str, dict[str, Any]]]:
200
+ """Tool calls recorded on an LLM span's output messages."""
201
+ calls: list[tuple[str, dict[str, Any]]] = []
202
+ for message in indexed(attributes, LLM_OUTPUT_MESSAGES):
203
+ for call in indexed(message, MESSAGE_TOOL_CALLS):
204
+ name = call.get(TOOL_CALL_NAME)
205
+ if not isinstance(name, str) or not name:
206
+ continue
207
+ calls.append((name, json_object(call, TOOL_CALL_ARGUMENTS)))
208
+ return calls