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