spanforge 2.0.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.
- spanforge/__init__.py +695 -0
- spanforge/_batch_exporter.py +322 -0
- spanforge/_cli.py +3081 -0
- spanforge/_hooks.py +340 -0
- spanforge/_server.py +953 -0
- spanforge/_span.py +1015 -0
- spanforge/_store.py +287 -0
- spanforge/_stream.py +654 -0
- spanforge/_trace.py +334 -0
- spanforge/_tracer.py +253 -0
- spanforge/actor.py +141 -0
- spanforge/alerts.py +464 -0
- spanforge/auto.py +181 -0
- spanforge/baseline.py +336 -0
- spanforge/config.py +460 -0
- spanforge/consent.py +227 -0
- spanforge/consumer.py +379 -0
- spanforge/core/__init__.py +5 -0
- spanforge/core/compliance_mapping.py +1060 -0
- spanforge/cost.py +597 -0
- spanforge/debug.py +514 -0
- spanforge/drift.py +488 -0
- spanforge/egress.py +63 -0
- spanforge/eval.py +575 -0
- spanforge/event.py +1052 -0
- spanforge/exceptions.py +246 -0
- spanforge/explain.py +181 -0
- spanforge/export/__init__.py +50 -0
- spanforge/export/append_only.py +342 -0
- spanforge/export/cloud.py +349 -0
- spanforge/export/datadog.py +495 -0
- spanforge/export/grafana.py +331 -0
- spanforge/export/jsonl.py +198 -0
- spanforge/export/otel_bridge.py +291 -0
- spanforge/export/otlp.py +817 -0
- spanforge/export/otlp_bridge.py +231 -0
- spanforge/export/redis_backend.py +282 -0
- spanforge/export/webhook.py +302 -0
- spanforge/exporters/__init__.py +29 -0
- spanforge/exporters/console.py +271 -0
- spanforge/exporters/jsonl.py +144 -0
- spanforge/hitl.py +297 -0
- spanforge/inspect.py +429 -0
- spanforge/integrations/__init__.py +39 -0
- spanforge/integrations/_pricing.py +277 -0
- spanforge/integrations/anthropic.py +388 -0
- spanforge/integrations/bedrock.py +306 -0
- spanforge/integrations/crewai.py +251 -0
- spanforge/integrations/gemini.py +349 -0
- spanforge/integrations/groq.py +444 -0
- spanforge/integrations/langchain.py +349 -0
- spanforge/integrations/llamaindex.py +370 -0
- spanforge/integrations/ollama.py +286 -0
- spanforge/integrations/openai.py +370 -0
- spanforge/integrations/together.py +485 -0
- spanforge/metrics.py +393 -0
- spanforge/metrics_export.py +342 -0
- spanforge/migrate.py +278 -0
- spanforge/model_registry.py +282 -0
- spanforge/models.py +407 -0
- spanforge/namespaces/__init__.py +215 -0
- spanforge/namespaces/audit.py +253 -0
- spanforge/namespaces/cache.py +209 -0
- spanforge/namespaces/chain.py +74 -0
- spanforge/namespaces/confidence.py +69 -0
- spanforge/namespaces/consent.py +85 -0
- spanforge/namespaces/cost.py +175 -0
- spanforge/namespaces/decision.py +135 -0
- spanforge/namespaces/diff.py +146 -0
- spanforge/namespaces/drift.py +79 -0
- spanforge/namespaces/eval_.py +232 -0
- spanforge/namespaces/fence.py +180 -0
- spanforge/namespaces/guard.py +104 -0
- spanforge/namespaces/hitl.py +92 -0
- spanforge/namespaces/latency.py +69 -0
- spanforge/namespaces/prompt.py +185 -0
- spanforge/namespaces/redact.py +172 -0
- spanforge/namespaces/template.py +197 -0
- spanforge/namespaces/tool_call.py +76 -0
- spanforge/namespaces/trace.py +1006 -0
- spanforge/normalizer.py +183 -0
- spanforge/presidio_backend.py +149 -0
- spanforge/processor.py +258 -0
- spanforge/prompt_registry.py +415 -0
- spanforge/py.typed +0 -0
- spanforge/redact.py +780 -0
- spanforge/sampling.py +500 -0
- spanforge/schemas/v1.0/schema.json +170 -0
- spanforge/schemas/v2.0/schema.json +536 -0
- spanforge/signing.py +1152 -0
- spanforge/stream.py +559 -0
- spanforge/testing.py +376 -0
- spanforge/trace.py +199 -0
- spanforge/types.py +696 -0
- spanforge/ulid.py +304 -0
- spanforge/validate.py +383 -0
- spanforge-2.0.0.dist-info/METADATA +1777 -0
- spanforge-2.0.0.dist-info/RECORD +101 -0
- spanforge-2.0.0.dist-info/WHEEL +4 -0
- spanforge-2.0.0.dist-info/entry_points.txt +5 -0
- spanforge-2.0.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,1006 @@
|
|
|
1
|
+
"""spanforge.namespaces.trace — Span and agent payload types (RFC-0001 §8).
|
|
2
|
+
|
|
3
|
+
This module provides Python dataclasses for the ``llm.trace.*`` namespace.
|
|
4
|
+
All types map directly to the JSON Schema defined in
|
|
5
|
+
``docs/schema/payloads/span.schema.json``,
|
|
6
|
+
``docs/schema/payloads/agent-step.schema.json``, and
|
|
7
|
+
``docs/schema/payloads/agent-run.schema.json``.
|
|
8
|
+
|
|
9
|
+
Shared value objects (``TokenUsage``, ``ModelInfo``, ``CostBreakdown``,
|
|
10
|
+
``PricingTier``, ``ToolCall``, ``ReasoningStep``, ``DecisionPoint``) are
|
|
11
|
+
defined here because the trace namespace is where they are first introduced
|
|
12
|
+
and other namespaces reference them.
|
|
13
|
+
|
|
14
|
+
Enumerations
|
|
15
|
+
------------
|
|
16
|
+
GenAISystem
|
|
17
|
+
RFC §10.1 — normalised provider identifier (OTel ``gen_ai.system``).
|
|
18
|
+
GenAIOperationName
|
|
19
|
+
RFC §10.2 — type of LLM operation (OTel ``gen_ai.operation.name``).
|
|
20
|
+
SpanKind
|
|
21
|
+
RFC §10.3 — OTel SpanKind values relevant to LLM operations.
|
|
22
|
+
|
|
23
|
+
Value objects
|
|
24
|
+
-------------
|
|
25
|
+
TokenUsage
|
|
26
|
+
RFC §9.1 — token counts with OTel-aligned field names.
|
|
27
|
+
ModelInfo
|
|
28
|
+
RFC §9.2 — model identity and provider.
|
|
29
|
+
CostBreakdown
|
|
30
|
+
RFC §9.3 — typed cost attribution record.
|
|
31
|
+
PricingTier
|
|
32
|
+
RFC §9.4 — pricing rates snapshot for cost reproduction.
|
|
33
|
+
ToolCall
|
|
34
|
+
RFC §8.1 — single tool invocation within a span.
|
|
35
|
+
ReasoningStep
|
|
36
|
+
RFC §8.2 — chain-of-thought unit; raw content MUST NOT be stored.
|
|
37
|
+
DecisionPoint
|
|
38
|
+
RFC §8.3 — explicit branching decision made by an agent.
|
|
39
|
+
|
|
40
|
+
Payload dataclasses
|
|
41
|
+
-------------------
|
|
42
|
+
SpanPayload
|
|
43
|
+
RFC §8.1 — single unit of LLM work (model call, tool, agent invocation).
|
|
44
|
+
AgentStepPayload
|
|
45
|
+
RFC §8.4 — one iteration of a multi-step agent loop.
|
|
46
|
+
AgentRunPayload
|
|
47
|
+
RFC §8.5 — root summary for a complete agent run.
|
|
48
|
+
"""
|
|
49
|
+
|
|
50
|
+
from __future__ import annotations
|
|
51
|
+
|
|
52
|
+
import re
|
|
53
|
+
import time
|
|
54
|
+
from dataclasses import dataclass, field
|
|
55
|
+
from enum import Enum
|
|
56
|
+
from typing import Any
|
|
57
|
+
|
|
58
|
+
__all__ = [
|
|
59
|
+
"AgentRunPayload",
|
|
60
|
+
"AgentStepPayload",
|
|
61
|
+
"CostBreakdown",
|
|
62
|
+
"DecisionPoint",
|
|
63
|
+
"GenAIOperationName",
|
|
64
|
+
# Enumerations
|
|
65
|
+
"GenAISystem",
|
|
66
|
+
"ModelInfo",
|
|
67
|
+
"PricingTier",
|
|
68
|
+
"ReasoningStep",
|
|
69
|
+
"SpanEvent",
|
|
70
|
+
"SpanKind",
|
|
71
|
+
# Payloads
|
|
72
|
+
"SpanPayload",
|
|
73
|
+
# Value objects
|
|
74
|
+
"TokenUsage",
|
|
75
|
+
"ToolCall",
|
|
76
|
+
]
|
|
77
|
+
|
|
78
|
+
# ---------------------------------------------------------------------------
|
|
79
|
+
# Compiled validation patterns (module-level, reused across instances)
|
|
80
|
+
# ---------------------------------------------------------------------------
|
|
81
|
+
_SPAN_ID_RE = re.compile(r"^[0-9a-f]{16}$")
|
|
82
|
+
_TRACE_ID_RE = re.compile(r"^[0-9a-f]{32}$")
|
|
83
|
+
_SHA256_RE = re.compile(r"^[0-9a-f]{64}$")
|
|
84
|
+
_ISO_DATE_RE = re.compile(r"^\d{4}-\d{2}-\d{2}$")
|
|
85
|
+
_CURRENCY_RE = re.compile(r"^[A-Z]{3}$")
|
|
86
|
+
# RFC-0001 §8.1 — session_id / user_id: 1–256 non-whitespace printable chars
|
|
87
|
+
_SAFE_ID_RE = re.compile(r"^\S{1,256}$")
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
# ---------------------------------------------------------------------------
|
|
91
|
+
# Enumerations (RFC §10)
|
|
92
|
+
# ---------------------------------------------------------------------------
|
|
93
|
+
|
|
94
|
+
class GenAISystem(str, Enum):
|
|
95
|
+
"""RFC-0001 §10.1 — LLM provider identifier.
|
|
96
|
+
|
|
97
|
+
Maps directly to OTel ``gen_ai.system`` semantic convention values.
|
|
98
|
+
Use ``CUSTOM`` (``"_custom"``) for private or enterprise deployments;
|
|
99
|
+
MUST set ``ModelInfo.custom_system_name`` when ``CUSTOM`` is used.
|
|
100
|
+
"""
|
|
101
|
+
|
|
102
|
+
OPENAI = "openai"
|
|
103
|
+
ANTHROPIC = "anthropic"
|
|
104
|
+
GOOGLE = "google"
|
|
105
|
+
COHERE = "cohere"
|
|
106
|
+
VERTEX_AI = "vertex_ai"
|
|
107
|
+
AWS_BEDROCK = "aws_bedrock"
|
|
108
|
+
AZ_AI_INFERENCE = "az.ai.inference"
|
|
109
|
+
GROQ = "groq"
|
|
110
|
+
OLLAMA = "ollama"
|
|
111
|
+
MISTRAL_AI = "mistral_ai"
|
|
112
|
+
TOGETHER_AI = "together_ai"
|
|
113
|
+
HUGGING_FACE = "hugging_face"
|
|
114
|
+
CUSTOM = "_custom"
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
class GenAIOperationName(str, Enum):
|
|
118
|
+
"""RFC-0001 §10.2 — Type of LLM operation performed.
|
|
119
|
+
|
|
120
|
+
Maps to OTel ``gen_ai.operation.name``.
|
|
121
|
+
"""
|
|
122
|
+
|
|
123
|
+
CHAT = "chat"
|
|
124
|
+
TEXT_COMPLETION = "text_completion"
|
|
125
|
+
EMBEDDINGS = "embeddings"
|
|
126
|
+
IMAGE_GENERATION = "image_generation"
|
|
127
|
+
EXECUTE_TOOL = "execute_tool"
|
|
128
|
+
INVOKE_AGENT = "invoke_agent"
|
|
129
|
+
CREATE_AGENT = "create_agent"
|
|
130
|
+
REASONING = "reasoning"
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
class SpanKind(str, Enum):
|
|
134
|
+
"""RFC-0001 §10.3 — OTel SpanKind for LLM operations."""
|
|
135
|
+
|
|
136
|
+
CLIENT = "CLIENT" # Outbound LLM API call — most common
|
|
137
|
+
SERVER = "SERVER" # Incoming agent request
|
|
138
|
+
INTERNAL = "INTERNAL" # Internal reasoning or routing step
|
|
139
|
+
CONSUMER = "CONSUMER" # Tool execution triggered by LLM output
|
|
140
|
+
PRODUCER = "PRODUCER" # Event emitted by an agent for downstream consumption
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
# ---------------------------------------------------------------------------
|
|
144
|
+
# Value objects (RFC §9, §8.1-§8.3)
|
|
145
|
+
# ---------------------------------------------------------------------------
|
|
146
|
+
|
|
147
|
+
@dataclass(frozen=True)
|
|
148
|
+
class TokenUsage:
|
|
149
|
+
"""RFC-0001 §9.1 — Token consumption record for a model call.
|
|
150
|
+
|
|
151
|
+
Uses OTel-aligned names: ``input_tokens`` / ``output_tokens`` (not
|
|
152
|
+
``prompt_tokens`` / ``completion_tokens``).
|
|
153
|
+
"""
|
|
154
|
+
|
|
155
|
+
input_tokens: int
|
|
156
|
+
output_tokens: int
|
|
157
|
+
total_tokens: int
|
|
158
|
+
cached_tokens: int | None = None
|
|
159
|
+
cache_creation_tokens: int | None = None
|
|
160
|
+
reasoning_tokens: int | None = None
|
|
161
|
+
image_tokens: int | None = None
|
|
162
|
+
|
|
163
|
+
def __post_init__(self) -> None:
|
|
164
|
+
for name in ("input_tokens", "output_tokens", "total_tokens"):
|
|
165
|
+
v = getattr(self, name)
|
|
166
|
+
if not isinstance(v, int) or v < 0:
|
|
167
|
+
raise ValueError(f"TokenUsage.{name} must be a non-negative int")
|
|
168
|
+
for opt in ("cached_tokens", "cache_creation_tokens", "reasoning_tokens", "image_tokens"):
|
|
169
|
+
v = getattr(self, opt)
|
|
170
|
+
if v is not None and (not isinstance(v, int) or v < 0):
|
|
171
|
+
raise ValueError(f"TokenUsage.{opt} must be a non-negative int or None")
|
|
172
|
+
|
|
173
|
+
def to_dict(self) -> dict[str, Any]:
|
|
174
|
+
"""Serialise the payload to a plain ``dict``."""
|
|
175
|
+
d: dict[str, Any] = {
|
|
176
|
+
"input_tokens": self.input_tokens,
|
|
177
|
+
"output_tokens": self.output_tokens,
|
|
178
|
+
"total_tokens": self.total_tokens,
|
|
179
|
+
}
|
|
180
|
+
for name in ("cached_tokens", "cache_creation_tokens", "reasoning_tokens", "image_tokens"):
|
|
181
|
+
v = getattr(self, name)
|
|
182
|
+
if v is not None:
|
|
183
|
+
d[name] = v
|
|
184
|
+
return d
|
|
185
|
+
|
|
186
|
+
@classmethod
|
|
187
|
+
def from_dict(cls, data: dict[str, Any]) -> TokenUsage:
|
|
188
|
+
"""Deserialise from a plain ``dict``."""
|
|
189
|
+
return cls(
|
|
190
|
+
input_tokens=int(data["input_tokens"]),
|
|
191
|
+
output_tokens=int(data["output_tokens"]),
|
|
192
|
+
total_tokens=int(data["total_tokens"]),
|
|
193
|
+
cached_tokens=int(data["cached_tokens"]) if "cached_tokens" in data else None,
|
|
194
|
+
cache_creation_tokens=int(data["cache_creation_tokens"]) if "cache_creation_tokens" in data else None, # noqa: E501
|
|
195
|
+
reasoning_tokens=int(data["reasoning_tokens"]) if "reasoning_tokens" in data else None,
|
|
196
|
+
image_tokens=int(data["image_tokens"]) if "image_tokens" in data else None,
|
|
197
|
+
)
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
@dataclass(frozen=True)
|
|
201
|
+
class ModelInfo:
|
|
202
|
+
"""RFC-0001 §9.2 — Model identity and provider information.
|
|
203
|
+
|
|
204
|
+
``custom_system_name`` is REQUIRED when ``system`` is
|
|
205
|
+
:attr:`GenAISystem.CUSTOM` (``"_custom"``).
|
|
206
|
+
"""
|
|
207
|
+
|
|
208
|
+
system: GenAISystem | str
|
|
209
|
+
name: str
|
|
210
|
+
response_model: str | None = None
|
|
211
|
+
version: str | None = None
|
|
212
|
+
custom_system_name: str | None = None
|
|
213
|
+
|
|
214
|
+
def __post_init__(self) -> None:
|
|
215
|
+
sys_val = self.system.value if isinstance(self.system, GenAISystem) else self.system
|
|
216
|
+
if sys_val == "_custom" and not self.custom_system_name:
|
|
217
|
+
raise ValueError(
|
|
218
|
+
"ModelInfo.custom_system_name is REQUIRED when system is '_custom' (RFC §4 P6)"
|
|
219
|
+
)
|
|
220
|
+
if not isinstance(self.name, str) or not self.name:
|
|
221
|
+
raise ValueError("ModelInfo.name must be a non-empty string")
|
|
222
|
+
|
|
223
|
+
def to_dict(self) -> dict[str, Any]:
|
|
224
|
+
"""Serialise the payload to a plain ``dict``."""
|
|
225
|
+
sys_val = self.system.value if isinstance(self.system, GenAISystem) else self.system
|
|
226
|
+
d: dict[str, Any] = {"system": sys_val, "name": self.name}
|
|
227
|
+
if self.response_model is not None:
|
|
228
|
+
d["response_model"] = self.response_model
|
|
229
|
+
if self.version is not None:
|
|
230
|
+
d["version"] = self.version
|
|
231
|
+
if self.custom_system_name is not None:
|
|
232
|
+
d["custom_system_name"] = self.custom_system_name
|
|
233
|
+
return d
|
|
234
|
+
|
|
235
|
+
@classmethod
|
|
236
|
+
def from_dict(cls, data: dict[str, Any]) -> ModelInfo:
|
|
237
|
+
"""Deserialise from a plain ``dict``."""
|
|
238
|
+
sys_raw = data["system"]
|
|
239
|
+
try:
|
|
240
|
+
system: GenAISystem | str = GenAISystem(sys_raw)
|
|
241
|
+
except ValueError:
|
|
242
|
+
system = sys_raw
|
|
243
|
+
return cls(
|
|
244
|
+
system=system,
|
|
245
|
+
name=data["name"],
|
|
246
|
+
response_model=data.get("response_model"),
|
|
247
|
+
version=data.get("version"),
|
|
248
|
+
custom_system_name=data.get("custom_system_name"),
|
|
249
|
+
)
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
@dataclass(frozen=True)
|
|
253
|
+
class CostBreakdown:
|
|
254
|
+
"""RFC-0001 §9.3 — Typed cost attribution record.
|
|
255
|
+
|
|
256
|
+
``total_cost_usd`` MUST equal
|
|
257
|
+
``input_cost_usd + output_cost_usd + reasoning_cost_usd - cached_discount_usd``
|
|
258
|
+
within ±1e-6 absolute tolerance.
|
|
259
|
+
|
|
260
|
+
Use :meth:`zero` to create a zero-filled instance when pricing data
|
|
261
|
+
is unavailable (§9.3 zero-fill allowance).
|
|
262
|
+
"""
|
|
263
|
+
|
|
264
|
+
input_cost_usd: float
|
|
265
|
+
output_cost_usd: float
|
|
266
|
+
total_cost_usd: float
|
|
267
|
+
cached_discount_usd: float = 0.0
|
|
268
|
+
reasoning_cost_usd: float = 0.0
|
|
269
|
+
currency: str = "USD"
|
|
270
|
+
pricing_date: str | None = None
|
|
271
|
+
|
|
272
|
+
_TOLERANCE: float = 1e-6
|
|
273
|
+
|
|
274
|
+
def __post_init__(self) -> None:
|
|
275
|
+
for name in ("input_cost_usd", "output_cost_usd", "total_cost_usd",
|
|
276
|
+
"cached_discount_usd", "reasoning_cost_usd"):
|
|
277
|
+
v = getattr(self, name)
|
|
278
|
+
if not isinstance(v, (int, float)) or v < 0:
|
|
279
|
+
raise ValueError(f"CostBreakdown.{name} must be a non-negative number")
|
|
280
|
+
if not _CURRENCY_RE.match(self.currency):
|
|
281
|
+
raise ValueError("CostBreakdown.currency must be a 3-letter ISO 4217 code")
|
|
282
|
+
if self.pricing_date is not None and not _ISO_DATE_RE.match(self.pricing_date):
|
|
283
|
+
raise ValueError("CostBreakdown.pricing_date must be YYYY-MM-DD")
|
|
284
|
+
expected = (
|
|
285
|
+
self.input_cost_usd
|
|
286
|
+
+ self.output_cost_usd
|
|
287
|
+
+ self.reasoning_cost_usd
|
|
288
|
+
- self.cached_discount_usd
|
|
289
|
+
)
|
|
290
|
+
if abs(self.total_cost_usd - expected) > self._TOLERANCE:
|
|
291
|
+
raise ValueError(
|
|
292
|
+
f"CostBreakdown.total_cost_usd {self.total_cost_usd} != "
|
|
293
|
+
f"input + output + reasoning - cached_discount = {expected:.8f} (±{self._TOLERANCE})" # noqa: E501
|
|
294
|
+
)
|
|
295
|
+
|
|
296
|
+
def to_dict(self) -> dict[str, Any]:
|
|
297
|
+
"""Serialise the payload to a plain ``dict``."""
|
|
298
|
+
d: dict[str, Any] = {
|
|
299
|
+
"input_cost_usd": self.input_cost_usd,
|
|
300
|
+
"output_cost_usd": self.output_cost_usd,
|
|
301
|
+
"total_cost_usd": self.total_cost_usd,
|
|
302
|
+
}
|
|
303
|
+
if self.cached_discount_usd:
|
|
304
|
+
d["cached_discount_usd"] = self.cached_discount_usd
|
|
305
|
+
if self.reasoning_cost_usd:
|
|
306
|
+
d["reasoning_cost_usd"] = self.reasoning_cost_usd
|
|
307
|
+
if self.currency != "USD":
|
|
308
|
+
d["currency"] = self.currency
|
|
309
|
+
if self.pricing_date is not None:
|
|
310
|
+
d["pricing_date"] = self.pricing_date
|
|
311
|
+
return d
|
|
312
|
+
|
|
313
|
+
@classmethod
|
|
314
|
+
def from_dict(cls, data: dict[str, Any]) -> CostBreakdown:
|
|
315
|
+
"""Deserialise from a plain ``dict``."""
|
|
316
|
+
return cls(
|
|
317
|
+
input_cost_usd=float(data["input_cost_usd"]),
|
|
318
|
+
output_cost_usd=float(data["output_cost_usd"]),
|
|
319
|
+
total_cost_usd=float(data["total_cost_usd"]),
|
|
320
|
+
cached_discount_usd=float(data.get("cached_discount_usd", 0.0)),
|
|
321
|
+
reasoning_cost_usd=float(data.get("reasoning_cost_usd", 0.0)),
|
|
322
|
+
currency=data.get("currency", "USD"),
|
|
323
|
+
pricing_date=data.get("pricing_date"),
|
|
324
|
+
)
|
|
325
|
+
|
|
326
|
+
@classmethod
|
|
327
|
+
def zero(cls) -> CostBreakdown:
|
|
328
|
+
"""Return a zero-filled CostBreakdown (§9.3 zero-fill allowance)."""
|
|
329
|
+
return cls(input_cost_usd=0.0, output_cost_usd=0.0, total_cost_usd=0.0)
|
|
330
|
+
|
|
331
|
+
|
|
332
|
+
@dataclass(frozen=True)
|
|
333
|
+
class PricingTier:
|
|
334
|
+
"""RFC-0001 §9.4 — Pricing rates snapshot for cost reproduction.
|
|
335
|
+
|
|
336
|
+
Stores the exact pricing rates used to compute a ``CostBreakdown`` so
|
|
337
|
+
cost calculations remain reproducible indefinitely.
|
|
338
|
+
"""
|
|
339
|
+
|
|
340
|
+
system: GenAISystem | str
|
|
341
|
+
model: str
|
|
342
|
+
input_per_million_usd: float
|
|
343
|
+
output_per_million_usd: float
|
|
344
|
+
effective_date: str
|
|
345
|
+
cached_input_per_million_usd: float | None = None
|
|
346
|
+
reasoning_per_million_usd: float | None = None
|
|
347
|
+
|
|
348
|
+
def __post_init__(self) -> None:
|
|
349
|
+
if not isinstance(self.model, str) or not self.model:
|
|
350
|
+
raise ValueError("PricingTier.model must be a non-empty string")
|
|
351
|
+
if not _ISO_DATE_RE.match(self.effective_date):
|
|
352
|
+
raise ValueError("PricingTier.effective_date must be YYYY-MM-DD")
|
|
353
|
+
for name in ("input_per_million_usd", "output_per_million_usd"):
|
|
354
|
+
v = getattr(self, name)
|
|
355
|
+
if not isinstance(v, (int, float)) or v < 0:
|
|
356
|
+
raise ValueError(f"PricingTier.{name} must be a non-negative number")
|
|
357
|
+
|
|
358
|
+
def to_dict(self) -> dict[str, Any]:
|
|
359
|
+
"""Serialise the payload to a plain ``dict``."""
|
|
360
|
+
sys_val = self.system.value if isinstance(self.system, GenAISystem) else self.system
|
|
361
|
+
d: dict[str, Any] = {
|
|
362
|
+
"system": sys_val,
|
|
363
|
+
"model": self.model,
|
|
364
|
+
"input_per_million_usd": self.input_per_million_usd,
|
|
365
|
+
"output_per_million_usd": self.output_per_million_usd,
|
|
366
|
+
"effective_date": self.effective_date,
|
|
367
|
+
}
|
|
368
|
+
if self.cached_input_per_million_usd is not None:
|
|
369
|
+
d["cached_input_per_million_usd"] = self.cached_input_per_million_usd
|
|
370
|
+
if self.reasoning_per_million_usd is not None:
|
|
371
|
+
d["reasoning_per_million_usd"] = self.reasoning_per_million_usd
|
|
372
|
+
return d
|
|
373
|
+
|
|
374
|
+
@classmethod
|
|
375
|
+
def from_dict(cls, data: dict[str, Any]) -> PricingTier:
|
|
376
|
+
"""Deserialise from a plain ``dict``."""
|
|
377
|
+
sys_raw = data["system"]
|
|
378
|
+
try:
|
|
379
|
+
system: GenAISystem | str = GenAISystem(sys_raw)
|
|
380
|
+
except ValueError:
|
|
381
|
+
system = sys_raw
|
|
382
|
+
return cls(
|
|
383
|
+
system=system,
|
|
384
|
+
model=data["model"],
|
|
385
|
+
input_per_million_usd=float(data["input_per_million_usd"]),
|
|
386
|
+
output_per_million_usd=float(data["output_per_million_usd"]),
|
|
387
|
+
effective_date=data["effective_date"],
|
|
388
|
+
cached_input_per_million_usd=float(data["cached_input_per_million_usd"])
|
|
389
|
+
if "cached_input_per_million_usd" in data else None,
|
|
390
|
+
reasoning_per_million_usd=float(data["reasoning_per_million_usd"])
|
|
391
|
+
if "reasoning_per_million_usd" in data else None,
|
|
392
|
+
)
|
|
393
|
+
|
|
394
|
+
|
|
395
|
+
@dataclass
|
|
396
|
+
class SpanEvent:
|
|
397
|
+
"""A named, timestamped event within a span.
|
|
398
|
+
|
|
399
|
+
Unlike spans (which represent durations), events are instantaneous
|
|
400
|
+
points in time recorded inside an enclosing span.
|
|
401
|
+
|
|
402
|
+
Attributes:
|
|
403
|
+
name: Event name (e.g. ``"cache.hit"``, ``"retry.attempt.2"``).
|
|
404
|
+
timestamp_ns: Nanosecond-precision Unix timestamp (auto-filled on creation).
|
|
405
|
+
metadata: Arbitrary key-value metadata attached to this event.
|
|
406
|
+
"""
|
|
407
|
+
|
|
408
|
+
name: str
|
|
409
|
+
timestamp_ns: int = field(default_factory=time.time_ns)
|
|
410
|
+
metadata: dict[str, Any] = field(default_factory=dict)
|
|
411
|
+
|
|
412
|
+
def __post_init__(self) -> None:
|
|
413
|
+
if not isinstance(self.name, str) or not self.name:
|
|
414
|
+
raise ValueError("SpanEvent.name must be a non-empty string")
|
|
415
|
+
if self.timestamp_ns < 0:
|
|
416
|
+
raise ValueError("SpanEvent.timestamp_ns must be non-negative")
|
|
417
|
+
|
|
418
|
+
def to_dict(self) -> dict[str, Any]:
|
|
419
|
+
"""Serialise to a plain dict."""
|
|
420
|
+
return {
|
|
421
|
+
"name": self.name,
|
|
422
|
+
"timestamp_ns": self.timestamp_ns,
|
|
423
|
+
"metadata": self.metadata,
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
@classmethod
|
|
427
|
+
def from_dict(cls, data: dict[str, Any]) -> SpanEvent:
|
|
428
|
+
"""Deserialise from a plain dict."""
|
|
429
|
+
return cls(
|
|
430
|
+
name=data["name"],
|
|
431
|
+
timestamp_ns=int(data["timestamp_ns"]),
|
|
432
|
+
metadata=dict(data.get("metadata", {})),
|
|
433
|
+
)
|
|
434
|
+
|
|
435
|
+
|
|
436
|
+
@dataclass(frozen=True)
|
|
437
|
+
class ToolCall:
|
|
438
|
+
"""RFC-0001 §8.1 — A single tool invocation within a span.
|
|
439
|
+
|
|
440
|
+
``arguments_hash`` stores a SHA-256 hash of the canonical JSON of
|
|
441
|
+
arguments. Raw argument values SHOULD NOT be stored by default (§20.4);
|
|
442
|
+
set ``SpanForgeConfig.include_raw_tool_io = True`` to opt in.
|
|
443
|
+
"""
|
|
444
|
+
|
|
445
|
+
tool_call_id: str
|
|
446
|
+
function_name: str
|
|
447
|
+
status: str # "success" | "error" | "timeout" | "cancelled"
|
|
448
|
+
arguments_hash: str | None = None # 64 lowercase hex chars, no prefix
|
|
449
|
+
error_type: str | None = None
|
|
450
|
+
duration_ms: float | None = None
|
|
451
|
+
arguments_raw: str | None = None # populated only when include_raw_tool_io=True
|
|
452
|
+
result_raw: str | None = None # populated only when include_raw_tool_io=True
|
|
453
|
+
retry_count: int | None = None
|
|
454
|
+
external_api: str | None = None
|
|
455
|
+
|
|
456
|
+
_VALID_STATUSES = frozenset({"success", "error", "timeout", "cancelled"})
|
|
457
|
+
|
|
458
|
+
def __post_init__(self) -> None:
|
|
459
|
+
if not isinstance(self.tool_call_id, str) or not self.tool_call_id:
|
|
460
|
+
raise ValueError("ToolCall.tool_call_id must be a non-empty string")
|
|
461
|
+
if not isinstance(self.function_name, str) or not self.function_name:
|
|
462
|
+
raise ValueError("ToolCall.function_name must be a non-empty string")
|
|
463
|
+
if self.status not in self._VALID_STATUSES:
|
|
464
|
+
raise ValueError(f"ToolCall.status must be one of {sorted(self._VALID_STATUSES)}")
|
|
465
|
+
if self.arguments_hash is not None and not _SHA256_RE.match(self.arguments_hash):
|
|
466
|
+
raise ValueError("ToolCall.arguments_hash must be 64 lowercase hex chars (SHA-256)")
|
|
467
|
+
if self.duration_ms is not None and self.duration_ms < 0:
|
|
468
|
+
raise ValueError("ToolCall.duration_ms must be non-negative")
|
|
469
|
+
if self.retry_count is not None and self.retry_count < 0:
|
|
470
|
+
raise ValueError("ToolCall.retry_count must be a non-negative integer")
|
|
471
|
+
|
|
472
|
+
def to_dict(self) -> dict[str, Any]:
|
|
473
|
+
"""Serialise the payload to a plain ``dict``."""
|
|
474
|
+
d: dict[str, Any] = {
|
|
475
|
+
"tool_call_id": self.tool_call_id,
|
|
476
|
+
"function_name": self.function_name,
|
|
477
|
+
"status": self.status,
|
|
478
|
+
}
|
|
479
|
+
if self.arguments_hash is not None:
|
|
480
|
+
d["arguments_hash"] = self.arguments_hash
|
|
481
|
+
if self.error_type is not None:
|
|
482
|
+
d["error_type"] = self.error_type
|
|
483
|
+
if self.duration_ms is not None:
|
|
484
|
+
d["duration_ms"] = self.duration_ms
|
|
485
|
+
# arguments_raw / result_raw contain raw PII; only emit when the
|
|
486
|
+
# operator has explicitly enabled raw tool I/O capture.
|
|
487
|
+
from spanforge.config import get_config # noqa: PLC0415
|
|
488
|
+
if self.arguments_raw is not None and get_config().include_raw_tool_io:
|
|
489
|
+
d["arguments_raw"] = self.arguments_raw
|
|
490
|
+
if self.result_raw is not None and get_config().include_raw_tool_io:
|
|
491
|
+
d["result_raw"] = self.result_raw
|
|
492
|
+
if self.retry_count is not None:
|
|
493
|
+
d["retry_count"] = self.retry_count
|
|
494
|
+
if self.external_api is not None:
|
|
495
|
+
d["external_api"] = self.external_api
|
|
496
|
+
return d
|
|
497
|
+
|
|
498
|
+
@classmethod
|
|
499
|
+
def from_dict(cls, data: dict[str, Any]) -> ToolCall:
|
|
500
|
+
"""Deserialise from a plain ``dict``."""
|
|
501
|
+
return cls(
|
|
502
|
+
tool_call_id=data["tool_call_id"],
|
|
503
|
+
function_name=data["function_name"],
|
|
504
|
+
status=data["status"],
|
|
505
|
+
arguments_hash=data.get("arguments_hash"),
|
|
506
|
+
error_type=data.get("error_type"),
|
|
507
|
+
duration_ms=float(data["duration_ms"]) if "duration_ms" in data else None,
|
|
508
|
+
arguments_raw=data.get("arguments_raw"),
|
|
509
|
+
result_raw=data.get("result_raw"),
|
|
510
|
+
retry_count=int(data["retry_count"]) if "retry_count" in data else None,
|
|
511
|
+
external_api=data.get("external_api"),
|
|
512
|
+
)
|
|
513
|
+
|
|
514
|
+
|
|
515
|
+
@dataclass(frozen=True)
|
|
516
|
+
class ReasoningStep:
|
|
517
|
+
"""RFC-0001 §8.2 — A discrete chain-of-thought unit.
|
|
518
|
+
|
|
519
|
+
**Critical:** Raw reasoning content MUST NOT be stored — only the
|
|
520
|
+
SHA-256 ``content_hash`` (64 lowercase hex chars, no prefix) MAY be
|
|
521
|
+
stored.
|
|
522
|
+
"""
|
|
523
|
+
|
|
524
|
+
step_index: int
|
|
525
|
+
reasoning_tokens: int
|
|
526
|
+
duration_ms: float | None = None
|
|
527
|
+
content_hash: str | None = None # 64 lowercase hex chars, no prefix
|
|
528
|
+
|
|
529
|
+
def __post_init__(self) -> None:
|
|
530
|
+
if not isinstance(self.step_index, int) or self.step_index < 0:
|
|
531
|
+
raise ValueError("ReasoningStep.step_index must be a non-negative int")
|
|
532
|
+
if not isinstance(self.reasoning_tokens, int) or self.reasoning_tokens < 0:
|
|
533
|
+
raise ValueError("ReasoningStep.reasoning_tokens must be a non-negative int")
|
|
534
|
+
if self.duration_ms is not None and self.duration_ms < 0:
|
|
535
|
+
raise ValueError("ReasoningStep.duration_ms must be non-negative")
|
|
536
|
+
if self.content_hash is not None and not _SHA256_RE.match(self.content_hash):
|
|
537
|
+
raise ValueError(
|
|
538
|
+
"ReasoningStep.content_hash must be 64 lowercase hex chars (SHA-256, no prefix)"
|
|
539
|
+
)
|
|
540
|
+
|
|
541
|
+
def to_dict(self) -> dict[str, Any]:
|
|
542
|
+
"""Serialise the payload to a plain ``dict``."""
|
|
543
|
+
d: dict[str, Any] = {
|
|
544
|
+
"step_index": self.step_index,
|
|
545
|
+
"reasoning_tokens": self.reasoning_tokens,
|
|
546
|
+
}
|
|
547
|
+
if self.duration_ms is not None:
|
|
548
|
+
d["duration_ms"] = self.duration_ms
|
|
549
|
+
if self.content_hash is not None:
|
|
550
|
+
d["content_hash"] = self.content_hash
|
|
551
|
+
return d
|
|
552
|
+
|
|
553
|
+
@classmethod
|
|
554
|
+
def from_dict(cls, data: dict[str, Any]) -> ReasoningStep:
|
|
555
|
+
"""Deserialise from a plain ``dict``."""
|
|
556
|
+
return cls(
|
|
557
|
+
step_index=int(data["step_index"]),
|
|
558
|
+
reasoning_tokens=int(data["reasoning_tokens"]),
|
|
559
|
+
duration_ms=float(data["duration_ms"]) if "duration_ms" in data else None,
|
|
560
|
+
content_hash=data.get("content_hash"),
|
|
561
|
+
)
|
|
562
|
+
|
|
563
|
+
|
|
564
|
+
@dataclass(frozen=True)
|
|
565
|
+
class DecisionPoint:
|
|
566
|
+
"""RFC-0001 §8.3 — An explicit branching decision recorded during an agent step.
|
|
567
|
+
|
|
568
|
+
``rationale`` is OPTIONAL for black-box models that do not expose reasoning.
|
|
569
|
+
"""
|
|
570
|
+
|
|
571
|
+
decision_id: str
|
|
572
|
+
decision_type: str # "tool_selection"|"route_choice"|"loop_termination"|"escalation"
|
|
573
|
+
options_considered: list[str]
|
|
574
|
+
chosen_option: str
|
|
575
|
+
rationale: str | None = None
|
|
576
|
+
|
|
577
|
+
_VALID_TYPES = frozenset({"tool_selection", "route_choice", "loop_termination", "escalation"})
|
|
578
|
+
|
|
579
|
+
def __post_init__(self) -> None:
|
|
580
|
+
if not isinstance(self.decision_id, str) or not self.decision_id:
|
|
581
|
+
raise ValueError("DecisionPoint.decision_id must be a non-empty string")
|
|
582
|
+
if self.decision_type not in self._VALID_TYPES:
|
|
583
|
+
raise ValueError(f"DecisionPoint.decision_type must be one of {sorted(self._VALID_TYPES)}") # noqa: E501
|
|
584
|
+
if not self.options_considered:
|
|
585
|
+
raise ValueError("DecisionPoint.options_considered must be a non-empty list")
|
|
586
|
+
if not isinstance(self.chosen_option, str) or not self.chosen_option:
|
|
587
|
+
raise ValueError("DecisionPoint.chosen_option must be a non-empty string")
|
|
588
|
+
|
|
589
|
+
def to_dict(self) -> dict[str, Any]:
|
|
590
|
+
"""Serialise the payload to a plain ``dict``."""
|
|
591
|
+
d: dict[str, Any] = {
|
|
592
|
+
"decision_id": self.decision_id,
|
|
593
|
+
"decision_type": self.decision_type,
|
|
594
|
+
"options_considered": list(self.options_considered),
|
|
595
|
+
"chosen_option": self.chosen_option,
|
|
596
|
+
}
|
|
597
|
+
if self.rationale is not None:
|
|
598
|
+
d["rationale"] = self.rationale
|
|
599
|
+
return d
|
|
600
|
+
|
|
601
|
+
@classmethod
|
|
602
|
+
def from_dict(cls, data: dict[str, Any]) -> DecisionPoint:
|
|
603
|
+
"""Deserialise from a plain ``dict``."""
|
|
604
|
+
return cls(
|
|
605
|
+
decision_id=data["decision_id"],
|
|
606
|
+
decision_type=data["decision_type"],
|
|
607
|
+
options_considered=list(data["options_considered"]),
|
|
608
|
+
chosen_option=data["chosen_option"],
|
|
609
|
+
rationale=data.get("rationale"),
|
|
610
|
+
)
|
|
611
|
+
|
|
612
|
+
|
|
613
|
+
# ---------------------------------------------------------------------------
|
|
614
|
+
# Payload dataclasses
|
|
615
|
+
# ---------------------------------------------------------------------------
|
|
616
|
+
|
|
617
|
+
@dataclass(frozen=True)
|
|
618
|
+
class SpanPayload:
|
|
619
|
+
"""RFC-0001 §8.1 — A single unit of LLM work.
|
|
620
|
+
|
|
621
|
+
Used with event types: ``llm.trace.span.started``,
|
|
622
|
+
``llm.trace.span.completed``, ``llm.trace.span.failed``,
|
|
623
|
+
``llm.trace.reasoning.step``.
|
|
624
|
+
"""
|
|
625
|
+
|
|
626
|
+
span_id: str # 16 lowercase hex chars
|
|
627
|
+
trace_id: str # 32 lowercase hex chars
|
|
628
|
+
span_name: str
|
|
629
|
+
operation: GenAIOperationName | str
|
|
630
|
+
span_kind: SpanKind | str
|
|
631
|
+
status: str # "ok" | "error" | "timeout"
|
|
632
|
+
start_time_unix_nano: int
|
|
633
|
+
end_time_unix_nano: int
|
|
634
|
+
duration_ms: float
|
|
635
|
+
parent_span_id: str | None = None
|
|
636
|
+
agent_run_id: str | None = None
|
|
637
|
+
model: ModelInfo | None = None
|
|
638
|
+
token_usage: TokenUsage | None = None
|
|
639
|
+
cost: CostBreakdown | None = None
|
|
640
|
+
tool_calls: list[ToolCall] = field(default_factory=list)
|
|
641
|
+
reasoning_steps: list[ReasoningStep] = field(default_factory=list)
|
|
642
|
+
finish_reason: str | None = None
|
|
643
|
+
error: str | None = None
|
|
644
|
+
error_type: str | None = None
|
|
645
|
+
attributes: dict[str, Any] | None = None
|
|
646
|
+
temperature: float | None = None
|
|
647
|
+
top_p: float | None = None
|
|
648
|
+
max_tokens: int | None = None
|
|
649
|
+
error_category: str | None = None
|
|
650
|
+
events: list[SpanEvent] = field(default_factory=list)
|
|
651
|
+
session_id: str | None = None
|
|
652
|
+
user_id: str | None = None
|
|
653
|
+
incoming_traceparent: str | None = None # W3C traceparent header from the caller
|
|
654
|
+
|
|
655
|
+
_VALID_STATUSES = frozenset({"ok", "error", "timeout"})
|
|
656
|
+
|
|
657
|
+
def __post_init__(self) -> None:
|
|
658
|
+
if not _SPAN_ID_RE.match(self.span_id):
|
|
659
|
+
raise ValueError(f"SpanPayload.span_id must be 16 lowercase hex chars, got {self.span_id!r}") # noqa: E501
|
|
660
|
+
if not _TRACE_ID_RE.match(self.trace_id):
|
|
661
|
+
raise ValueError(f"SpanPayload.trace_id must be 32 lowercase hex chars, got {self.trace_id!r}") # noqa: E501
|
|
662
|
+
if not isinstance(self.span_name, str) or not self.span_name:
|
|
663
|
+
raise ValueError("SpanPayload.span_name must be a non-empty string")
|
|
664
|
+
if self.parent_span_id is not None and not _SPAN_ID_RE.match(self.parent_span_id):
|
|
665
|
+
raise ValueError("SpanPayload.parent_span_id must be 16 lowercase hex chars")
|
|
666
|
+
status_val = self.status.value if isinstance(self.status, Enum) else self.status
|
|
667
|
+
if status_val not in self._VALID_STATUSES:
|
|
668
|
+
raise ValueError(f"SpanPayload.status must be one of {sorted(self._VALID_STATUSES)}")
|
|
669
|
+
if self.start_time_unix_nano < 0:
|
|
670
|
+
raise ValueError("SpanPayload.start_time_unix_nano must be non-negative")
|
|
671
|
+
if self.end_time_unix_nano < self.start_time_unix_nano:
|
|
672
|
+
raise ValueError("SpanPayload.end_time_unix_nano must be >= start_time_unix_nano")
|
|
673
|
+
if self.duration_ms < 0:
|
|
674
|
+
raise ValueError("SpanPayload.duration_ms must be non-negative")
|
|
675
|
+
# RFC-0001 §8.1 — duration_ms MUST equal (end - start) / 1_000_000 ± 1 ms
|
|
676
|
+
_computed_ms = (self.end_time_unix_nano - self.start_time_unix_nano) / 1_000_000
|
|
677
|
+
if abs(self.duration_ms - _computed_ms) > 1.0:
|
|
678
|
+
raise ValueError(
|
|
679
|
+
f"SpanPayload.duration_ms {self.duration_ms} must equal "
|
|
680
|
+
f"(end_time_unix_nano - start_time_unix_nano) / 1_000_000 "
|
|
681
|
+
f"= {_computed_ms:.3f} \u00b11 ms (RFC-0001 \u00a78.1)"
|
|
682
|
+
)
|
|
683
|
+
# Validate session_id / user_id: prevent PII/injection leakage via
|
|
684
|
+
# embedding whitespace, control chars, or excessively long values.
|
|
685
|
+
if self.session_id is not None and not _SAFE_ID_RE.match(self.session_id):
|
|
686
|
+
raise ValueError(
|
|
687
|
+
"SpanPayload.session_id must be 1–256 non-whitespace characters"
|
|
688
|
+
)
|
|
689
|
+
if self.user_id is not None and not _SAFE_ID_RE.match(self.user_id):
|
|
690
|
+
raise ValueError(
|
|
691
|
+
"SpanPayload.user_id must be 1–256 non-whitespace characters"
|
|
692
|
+
)
|
|
693
|
+
|
|
694
|
+
def _add_optional_fields(self, d: dict[str, Any]) -> None:
|
|
695
|
+
"""Add optional fields to *d* when they are not None/empty."""
|
|
696
|
+
if self.parent_span_id is not None:
|
|
697
|
+
d["parent_span_id"] = self.parent_span_id
|
|
698
|
+
if self.agent_run_id is not None:
|
|
699
|
+
d["agent_run_id"] = self.agent_run_id
|
|
700
|
+
if self.model is not None:
|
|
701
|
+
d["model"] = self.model.to_dict()
|
|
702
|
+
if self.token_usage is not None:
|
|
703
|
+
d["token_usage"] = self.token_usage.to_dict()
|
|
704
|
+
if self.cost is not None:
|
|
705
|
+
d["cost"] = self.cost.to_dict()
|
|
706
|
+
if self.finish_reason is not None:
|
|
707
|
+
d["finish_reason"] = self.finish_reason
|
|
708
|
+
if self.error is not None:
|
|
709
|
+
d["error"] = self.error
|
|
710
|
+
if self.error_type is not None:
|
|
711
|
+
d["error_type"] = self.error_type
|
|
712
|
+
if self.attributes is not None:
|
|
713
|
+
d["attributes"] = self.attributes
|
|
714
|
+
if self.temperature is not None:
|
|
715
|
+
d["temperature"] = self.temperature
|
|
716
|
+
if self.top_p is not None:
|
|
717
|
+
d["top_p"] = self.top_p
|
|
718
|
+
if self.max_tokens is not None:
|
|
719
|
+
d["max_tokens"] = self.max_tokens
|
|
720
|
+
if self.error_category is not None:
|
|
721
|
+
d["error_category"] = self.error_category
|
|
722
|
+
if self.events:
|
|
723
|
+
d["events"] = [e.to_dict() for e in self.events]
|
|
724
|
+
if self.session_id is not None:
|
|
725
|
+
d["session_id"] = self.session_id
|
|
726
|
+
if self.user_id is not None:
|
|
727
|
+
d["user_id"] = self.user_id
|
|
728
|
+
if self.incoming_traceparent is not None:
|
|
729
|
+
d["incoming_traceparent"] = self.incoming_traceparent
|
|
730
|
+
|
|
731
|
+
def to_dict(self) -> dict[str, Any]:
|
|
732
|
+
"""Serialise the payload to a plain ``dict``."""
|
|
733
|
+
op = self.operation.value if isinstance(self.operation, Enum) else self.operation
|
|
734
|
+
sk = self.span_kind.value if isinstance(self.span_kind, Enum) else self.span_kind
|
|
735
|
+
st = self.status.value if isinstance(self.status, Enum) else self.status
|
|
736
|
+
d: dict[str, Any] = {
|
|
737
|
+
"span_id": self.span_id,
|
|
738
|
+
"trace_id": self.trace_id,
|
|
739
|
+
"span_name": self.span_name,
|
|
740
|
+
"operation": op,
|
|
741
|
+
"span_kind": sk,
|
|
742
|
+
"status": st,
|
|
743
|
+
"start_time_unix_nano": self.start_time_unix_nano,
|
|
744
|
+
"end_time_unix_nano": self.end_time_unix_nano,
|
|
745
|
+
"duration_ms": self.duration_ms,
|
|
746
|
+
"tool_calls": [tc.to_dict() for tc in self.tool_calls],
|
|
747
|
+
"reasoning_steps": [rs.to_dict() for rs in self.reasoning_steps],
|
|
748
|
+
}
|
|
749
|
+
self._add_optional_fields(d)
|
|
750
|
+
return d
|
|
751
|
+
|
|
752
|
+
@classmethod
|
|
753
|
+
def from_dict(cls, data: dict[str, Any]) -> SpanPayload:
|
|
754
|
+
"""Deserialise from a plain ``dict``."""
|
|
755
|
+
op_raw = data["operation"]
|
|
756
|
+
try:
|
|
757
|
+
operation: GenAIOperationName | str = GenAIOperationName(op_raw)
|
|
758
|
+
except ValueError:
|
|
759
|
+
operation = op_raw
|
|
760
|
+
sk_raw = data["span_kind"]
|
|
761
|
+
try:
|
|
762
|
+
span_kind: SpanKind | str = SpanKind(sk_raw)
|
|
763
|
+
except ValueError:
|
|
764
|
+
span_kind = sk_raw
|
|
765
|
+
return cls(
|
|
766
|
+
span_id=data["span_id"],
|
|
767
|
+
trace_id=data["trace_id"],
|
|
768
|
+
span_name=data["span_name"],
|
|
769
|
+
operation=operation,
|
|
770
|
+
span_kind=span_kind,
|
|
771
|
+
status=data["status"],
|
|
772
|
+
start_time_unix_nano=int(data["start_time_unix_nano"]),
|
|
773
|
+
end_time_unix_nano=int(data["end_time_unix_nano"]),
|
|
774
|
+
duration_ms=float(data["duration_ms"]),
|
|
775
|
+
parent_span_id=data.get("parent_span_id"),
|
|
776
|
+
agent_run_id=data.get("agent_run_id"),
|
|
777
|
+
model=ModelInfo.from_dict(data["model"]) if "model" in data else None,
|
|
778
|
+
token_usage=TokenUsage.from_dict(data["token_usage"]) if "token_usage" in data else None, # noqa: E501
|
|
779
|
+
cost=CostBreakdown.from_dict(data["cost"]) if "cost" in data else None,
|
|
780
|
+
tool_calls=[ToolCall.from_dict(tc) for tc in data.get("tool_calls", [])],
|
|
781
|
+
reasoning_steps=[ReasoningStep.from_dict(rs) for rs in data.get("reasoning_steps", [])],
|
|
782
|
+
finish_reason=data.get("finish_reason"),
|
|
783
|
+
error=data.get("error"),
|
|
784
|
+
error_type=data.get("error_type"),
|
|
785
|
+
attributes=data.get("attributes"),
|
|
786
|
+
temperature=float(data["temperature"]) if "temperature" in data else None,
|
|
787
|
+
top_p=float(data["top_p"]) if "top_p" in data else None,
|
|
788
|
+
max_tokens=int(data["max_tokens"]) if "max_tokens" in data else None,
|
|
789
|
+
error_category=data.get("error_category"),
|
|
790
|
+
events=[SpanEvent.from_dict(e) for e in data.get("events", [])],
|
|
791
|
+
session_id=data.get("session_id"),
|
|
792
|
+
user_id=data.get("user_id"),
|
|
793
|
+
incoming_traceparent=data.get("incoming_traceparent"),
|
|
794
|
+
)
|
|
795
|
+
|
|
796
|
+
|
|
797
|
+
@dataclass(frozen=True)
|
|
798
|
+
class AgentStepPayload:
|
|
799
|
+
"""RFC-0001 §8.4 — One iteration of a multi-step agent loop.
|
|
800
|
+
|
|
801
|
+
Used with event type: ``llm.trace.agent.step``.
|
|
802
|
+
``step_index`` is zero-based.
|
|
803
|
+
"""
|
|
804
|
+
|
|
805
|
+
agent_run_id: str
|
|
806
|
+
step_index: int
|
|
807
|
+
span_id: str # 16 lowercase hex chars
|
|
808
|
+
trace_id: str # 32 lowercase hex chars
|
|
809
|
+
operation: GenAIOperationName | str
|
|
810
|
+
tool_calls: list[ToolCall]
|
|
811
|
+
reasoning_steps: list[ReasoningStep]
|
|
812
|
+
decision_points: list[DecisionPoint]
|
|
813
|
+
status: str # "ok" | "error" | "timeout"
|
|
814
|
+
start_time_unix_nano: int
|
|
815
|
+
end_time_unix_nano: int
|
|
816
|
+
duration_ms: float
|
|
817
|
+
parent_span_id: str | None = None
|
|
818
|
+
model: ModelInfo | None = None
|
|
819
|
+
token_usage: TokenUsage | None = None
|
|
820
|
+
cost: CostBreakdown | None = None
|
|
821
|
+
error: str | None = None
|
|
822
|
+
error_type: str | None = None
|
|
823
|
+
step_name: str | None = None
|
|
824
|
+
|
|
825
|
+
_VALID_STATUSES = frozenset({"ok", "error", "timeout"})
|
|
826
|
+
|
|
827
|
+
def __post_init__(self) -> None:
|
|
828
|
+
if not isinstance(self.agent_run_id, str) or not self.agent_run_id:
|
|
829
|
+
raise ValueError("AgentStepPayload.agent_run_id must be a non-empty string")
|
|
830
|
+
if not isinstance(self.step_index, int) or self.step_index < 0:
|
|
831
|
+
raise ValueError("AgentStepPayload.step_index must be a non-negative int")
|
|
832
|
+
if not _SPAN_ID_RE.match(self.span_id):
|
|
833
|
+
raise ValueError("AgentStepPayload.span_id must be 16 lowercase hex chars")
|
|
834
|
+
if not _TRACE_ID_RE.match(self.trace_id):
|
|
835
|
+
raise ValueError("AgentStepPayload.trace_id must be 32 lowercase hex chars")
|
|
836
|
+
if self.parent_span_id is not None and not _SPAN_ID_RE.match(self.parent_span_id):
|
|
837
|
+
raise ValueError("AgentStepPayload.parent_span_id must be 16 lowercase hex chars")
|
|
838
|
+
status_val = self.status.value if isinstance(self.status, Enum) else self.status
|
|
839
|
+
if status_val not in self._VALID_STATUSES:
|
|
840
|
+
raise ValueError(f"AgentStepPayload.status must be one of {sorted(self._VALID_STATUSES)}") # noqa: E501
|
|
841
|
+
if self.start_time_unix_nano < 0:
|
|
842
|
+
raise ValueError("AgentStepPayload.start_time_unix_nano must be non-negative")
|
|
843
|
+
if self.end_time_unix_nano < self.start_time_unix_nano:
|
|
844
|
+
raise ValueError("AgentStepPayload.end_time_unix_nano must be >= start_time_unix_nano")
|
|
845
|
+
# RFC-0001 §8.1 — duration_ms MUST equal (end - start) / 1_000_000 ± 1 ms
|
|
846
|
+
_computed_ms = (self.end_time_unix_nano - self.start_time_unix_nano) / 1_000_000
|
|
847
|
+
if abs(self.duration_ms - _computed_ms) > 1.0:
|
|
848
|
+
raise ValueError(
|
|
849
|
+
f"AgentStepPayload.duration_ms {self.duration_ms} must equal "
|
|
850
|
+
f"(end_time_unix_nano - start_time_unix_nano) / 1_000_000 "
|
|
851
|
+
f"= {_computed_ms:.3f} \u00b11 ms (RFC-0001 \u00a78.1)"
|
|
852
|
+
)
|
|
853
|
+
|
|
854
|
+
def to_dict(self) -> dict[str, Any]:
|
|
855
|
+
"""Serialise the payload to a plain ``dict``."""
|
|
856
|
+
op = self.operation.value if isinstance(self.operation, Enum) else self.operation
|
|
857
|
+
st = self.status.value if isinstance(self.status, Enum) else self.status
|
|
858
|
+
d: dict[str, Any] = {
|
|
859
|
+
"agent_run_id": self.agent_run_id,
|
|
860
|
+
"step_index": self.step_index,
|
|
861
|
+
"span_id": self.span_id,
|
|
862
|
+
"trace_id": self.trace_id,
|
|
863
|
+
"operation": op,
|
|
864
|
+
"tool_calls": [tc.to_dict() for tc in self.tool_calls],
|
|
865
|
+
"reasoning_steps": [rs.to_dict() for rs in self.reasoning_steps],
|
|
866
|
+
"decision_points": [dp.to_dict() for dp in self.decision_points],
|
|
867
|
+
"status": st,
|
|
868
|
+
"start_time_unix_nano": self.start_time_unix_nano,
|
|
869
|
+
"end_time_unix_nano": self.end_time_unix_nano,
|
|
870
|
+
"duration_ms": self.duration_ms,
|
|
871
|
+
}
|
|
872
|
+
if self.parent_span_id is not None:
|
|
873
|
+
d["parent_span_id"] = self.parent_span_id
|
|
874
|
+
if self.model is not None:
|
|
875
|
+
d["model"] = self.model.to_dict()
|
|
876
|
+
if self.token_usage is not None:
|
|
877
|
+
d["token_usage"] = self.token_usage.to_dict()
|
|
878
|
+
if self.cost is not None:
|
|
879
|
+
d["cost"] = self.cost.to_dict()
|
|
880
|
+
if self.error is not None:
|
|
881
|
+
d["error"] = self.error
|
|
882
|
+
if self.error_type is not None:
|
|
883
|
+
d["error_type"] = self.error_type
|
|
884
|
+
if self.step_name is not None:
|
|
885
|
+
d["step_name"] = self.step_name
|
|
886
|
+
return d
|
|
887
|
+
|
|
888
|
+
@classmethod
|
|
889
|
+
def from_dict(cls, data: dict[str, Any]) -> AgentStepPayload:
|
|
890
|
+
"""Deserialise from a plain ``dict``."""
|
|
891
|
+
op_raw = data["operation"]
|
|
892
|
+
try:
|
|
893
|
+
operation: GenAIOperationName | str = GenAIOperationName(op_raw)
|
|
894
|
+
except ValueError:
|
|
895
|
+
operation = op_raw
|
|
896
|
+
return cls(
|
|
897
|
+
agent_run_id=data["agent_run_id"],
|
|
898
|
+
step_index=int(data["step_index"]),
|
|
899
|
+
span_id=data["span_id"],
|
|
900
|
+
trace_id=data["trace_id"],
|
|
901
|
+
operation=operation,
|
|
902
|
+
tool_calls=[ToolCall.from_dict(tc) for tc in data.get("tool_calls", [])],
|
|
903
|
+
reasoning_steps=[ReasoningStep.from_dict(rs) for rs in data.get("reasoning_steps", [])],
|
|
904
|
+
decision_points=[DecisionPoint.from_dict(dp) for dp in data.get("decision_points", [])],
|
|
905
|
+
status=data["status"],
|
|
906
|
+
start_time_unix_nano=int(data["start_time_unix_nano"]),
|
|
907
|
+
end_time_unix_nano=int(data["end_time_unix_nano"]),
|
|
908
|
+
duration_ms=float(data["duration_ms"]),
|
|
909
|
+
parent_span_id=data.get("parent_span_id"),
|
|
910
|
+
model=ModelInfo.from_dict(data["model"]) if "model" in data else None,
|
|
911
|
+
token_usage=TokenUsage.from_dict(data["token_usage"]) if "token_usage" in data else None, # noqa: E501
|
|
912
|
+
cost=CostBreakdown.from_dict(data["cost"]) if "cost" in data else None,
|
|
913
|
+
error=data.get("error"),
|
|
914
|
+
error_type=data.get("error_type"),
|
|
915
|
+
step_name=data.get("step_name"),
|
|
916
|
+
)
|
|
917
|
+
|
|
918
|
+
|
|
919
|
+
@dataclass(frozen=True)
|
|
920
|
+
class AgentRunPayload:
|
|
921
|
+
"""RFC-0001 §8.5 — Root-level summary for a complete agent run.
|
|
922
|
+
|
|
923
|
+
Used with event type: ``llm.trace.agent.completed``.
|
|
924
|
+
``agent_run_id`` MUST match across all :class:`AgentStepPayload`
|
|
925
|
+
events for this run.
|
|
926
|
+
"""
|
|
927
|
+
|
|
928
|
+
agent_run_id: str
|
|
929
|
+
agent_name: str
|
|
930
|
+
trace_id: str # 32 lowercase hex chars
|
|
931
|
+
root_span_id: str # 16 lowercase hex chars
|
|
932
|
+
total_steps: int
|
|
933
|
+
total_model_calls: int
|
|
934
|
+
total_tool_calls: int
|
|
935
|
+
total_token_usage: TokenUsage
|
|
936
|
+
total_cost: CostBreakdown
|
|
937
|
+
status: str # "ok"|"error"|"timeout"|"max_steps_exceeded"
|
|
938
|
+
start_time_unix_nano: int
|
|
939
|
+
end_time_unix_nano: int
|
|
940
|
+
duration_ms: float
|
|
941
|
+
termination_reason: str | None = None
|
|
942
|
+
|
|
943
|
+
_VALID_STATUSES = frozenset({"ok", "error", "timeout", "max_steps_exceeded"})
|
|
944
|
+
|
|
945
|
+
def __post_init__(self) -> None:
|
|
946
|
+
if not isinstance(self.agent_run_id, str) or not self.agent_run_id:
|
|
947
|
+
raise ValueError("AgentRunPayload.agent_run_id must be a non-empty string")
|
|
948
|
+
if not isinstance(self.agent_name, str) or not self.agent_name:
|
|
949
|
+
raise ValueError("AgentRunPayload.agent_name must be a non-empty string")
|
|
950
|
+
if not _TRACE_ID_RE.match(self.trace_id):
|
|
951
|
+
raise ValueError("AgentRunPayload.trace_id must be 32 lowercase hex chars")
|
|
952
|
+
if not _SPAN_ID_RE.match(self.root_span_id):
|
|
953
|
+
raise ValueError("AgentRunPayload.root_span_id must be 16 lowercase hex chars")
|
|
954
|
+
for name in ("total_steps", "total_model_calls", "total_tool_calls"):
|
|
955
|
+
v = getattr(self, name)
|
|
956
|
+
if not isinstance(v, int) or v < 0:
|
|
957
|
+
raise ValueError(f"AgentRunPayload.{name} must be a non-negative int")
|
|
958
|
+
status_val = self.status.value if isinstance(self.status, Enum) else self.status
|
|
959
|
+
if status_val not in self._VALID_STATUSES:
|
|
960
|
+
raise ValueError(f"AgentRunPayload.status must be one of {sorted(self._VALID_STATUSES)}") # noqa: E501
|
|
961
|
+
if self.start_time_unix_nano < 0:
|
|
962
|
+
raise ValueError("AgentRunPayload.start_time_unix_nano must be non-negative")
|
|
963
|
+
if self.end_time_unix_nano < self.start_time_unix_nano:
|
|
964
|
+
raise ValueError("AgentRunPayload.end_time_unix_nano must be >= start_time_unix_nano")
|
|
965
|
+
|
|
966
|
+
def to_dict(self) -> dict[str, Any]:
|
|
967
|
+
"""Serialise the payload to a plain ``dict``."""
|
|
968
|
+
st = self.status.value if isinstance(self.status, Enum) else self.status
|
|
969
|
+
d: dict[str, Any] = {
|
|
970
|
+
"agent_run_id": self.agent_run_id,
|
|
971
|
+
"agent_name": self.agent_name,
|
|
972
|
+
"trace_id": self.trace_id,
|
|
973
|
+
"root_span_id": self.root_span_id,
|
|
974
|
+
"total_steps": self.total_steps,
|
|
975
|
+
"total_model_calls": self.total_model_calls,
|
|
976
|
+
"total_tool_calls": self.total_tool_calls,
|
|
977
|
+
"total_token_usage": self.total_token_usage.to_dict(),
|
|
978
|
+
"total_cost": self.total_cost.to_dict(),
|
|
979
|
+
"status": st,
|
|
980
|
+
"start_time_unix_nano": self.start_time_unix_nano,
|
|
981
|
+
"end_time_unix_nano": self.end_time_unix_nano,
|
|
982
|
+
"duration_ms": self.duration_ms,
|
|
983
|
+
}
|
|
984
|
+
if self.termination_reason is not None:
|
|
985
|
+
d["termination_reason"] = self.termination_reason
|
|
986
|
+
return d
|
|
987
|
+
|
|
988
|
+
@classmethod
|
|
989
|
+
def from_dict(cls, data: dict[str, Any]) -> AgentRunPayload:
|
|
990
|
+
"""Deserialise from a plain ``dict``."""
|
|
991
|
+
return cls(
|
|
992
|
+
agent_run_id=data["agent_run_id"],
|
|
993
|
+
agent_name=data["agent_name"],
|
|
994
|
+
trace_id=data["trace_id"],
|
|
995
|
+
root_span_id=data["root_span_id"],
|
|
996
|
+
total_steps=int(data["total_steps"]),
|
|
997
|
+
total_model_calls=int(data["total_model_calls"]),
|
|
998
|
+
total_tool_calls=int(data["total_tool_calls"]),
|
|
999
|
+
total_token_usage=TokenUsage.from_dict(data["total_token_usage"]),
|
|
1000
|
+
total_cost=CostBreakdown.from_dict(data["total_cost"]),
|
|
1001
|
+
status=data["status"],
|
|
1002
|
+
start_time_unix_nano=int(data["start_time_unix_nano"]),
|
|
1003
|
+
end_time_unix_nano=int(data["end_time_unix_nano"]),
|
|
1004
|
+
duration_ms=float(data["duration_ms"]),
|
|
1005
|
+
termination_reason=data.get("termination_reason"),
|
|
1006
|
+
)
|