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
spanforge/_span.py ADDED
@@ -0,0 +1,1036 @@
1
+ """spanforge._span — Span, SpanContextManager, and agent context managers.
2
+
3
+ Provides the runtime tracing primitives that back ``tracer.span()``,
4
+ ``tracer.agent_run()``, and ``tracer.agent_step()``.
5
+
6
+ Design notes
7
+ ------------
8
+ * **Context-variable stacks** — uses :mod:`contextvars` so that context
9
+ propagates correctly across asyncio tasks, thread-pool executors, and
10
+ concurrent threads without manual ID management.
11
+ * **Immutable stack tuples** — each ``__enter__`` sets a *new* tuple on the
12
+ ContextVar and saves the reset token; ``__exit__`` calls
13
+ ``ContextVar.reset(token)`` so concurrent tasks each see their own stack
14
+ slice and cannot bleed into each other.
15
+ * **OTel-compatible IDs** — ``span_id`` is 8 random bytes (16 hex chars),
16
+ ``trace_id`` is 16 random bytes (32 hex chars), matching the OTel wire
17
+ format expected by :class:`~spanforge.namespaces.trace.SpanPayload`.
18
+ * **Zero external dependencies** — stdlib only (``contextvars``, ``os``,
19
+ ``time``, ``types``).
20
+ """
21
+
22
+ from __future__ import annotations
23
+
24
+ import contextvars
25
+ import logging
26
+ import os
27
+ import time
28
+ from collections import deque
29
+ from dataclasses import dataclass, field
30
+ from typing import TYPE_CHECKING, Any, Literal
31
+
32
+ from spanforge.namespaces.trace import (
33
+ AgentRunPayload,
34
+ AgentStepPayload,
35
+ CostBreakdown,
36
+ DecisionPoint,
37
+ GenAIOperationName,
38
+ GenAISystem,
39
+ ModelInfo,
40
+ ReasoningStep,
41
+ SpanEvent,
42
+ SpanKind,
43
+ SpanPayload,
44
+ TokenUsage,
45
+ ToolCall,
46
+ )
47
+
48
+ if TYPE_CHECKING:
49
+ import threading
50
+ from types import TracebackType
51
+
52
+ __all__ = [
53
+ "AgentRunContext",
54
+ "AgentRunContextManager",
55
+ "AgentStepContext",
56
+ "AgentStepContextManager",
57
+ "Span",
58
+ "SpanContextManager",
59
+ "copy_context",
60
+ "extract_traceparent",
61
+ "inject_traceparent",
62
+ ]
63
+
64
+ _log = logging.getLogger("spanforge.span")
65
+
66
+ # ---------------------------------------------------------------------------
67
+ # ID generation helpers
68
+ # ---------------------------------------------------------------------------
69
+
70
+
71
+ def _span_id() -> str:
72
+ """Generate an OTel-compatible span ID: 8 random bytes → 16 lowercase hex chars."""
73
+ return os.urandom(8).hex()
74
+
75
+
76
+ def _trace_id() -> str:
77
+ """Generate an OTel-compatible trace ID: 16 random bytes → 32 lowercase hex chars."""
78
+ return os.urandom(16).hex()
79
+
80
+
81
+ def _now_ns() -> int:
82
+ """Current time as integer nanoseconds since the Unix epoch."""
83
+ return time.time_ns()
84
+
85
+
86
+ # ---------------------------------------------------------------------------
87
+ # Context-variable stacks (asyncio-safe, thread-safe)
88
+ # ---------------------------------------------------------------------------
89
+
90
+ # Each ContextVar stores an *immutable tuple* so that asyncio tasks spawned
91
+ # inside a span inherit the parent's stack slice without mutating it.
92
+ _span_stack_var: contextvars.ContextVar[tuple[Span, ...]] = contextvars.ContextVar(
93
+ "spanforge_span_stack", default=()
94
+ )
95
+ _run_stack_var: contextvars.ContextVar[tuple[AgentRunContext, ...]] = contextvars.ContextVar(
96
+ "spanforge_run_stack", default=()
97
+ )
98
+
99
+
100
+ def _span_stack() -> tuple[Span, ...]:
101
+ """Return the current context's span stack (immutable tuple)."""
102
+ return _span_stack_var.get()
103
+
104
+
105
+ def _run_stack() -> tuple[AgentRunContext, ...]:
106
+ """Return the current context's agent-run stack (immutable tuple)."""
107
+ return _run_stack_var.get()
108
+
109
+
110
+ def copy_context() -> contextvars.Context:
111
+ """Return a shallow copy of the current :mod:`contextvars` context.
112
+
113
+ Pass this to :func:`contextvars.Context.run` when spawning threads or
114
+ ``loop.run_in_executor`` tasks that should inherit the active span::
115
+
116
+ ctx = spanforge.copy_context()
117
+ loop.run_in_executor(None, ctx.run, my_blocking_fn)
118
+ """
119
+ return contextvars.copy_context()
120
+
121
+
122
+ # ---------------------------------------------------------------------------
123
+ # W3C Trace Context helpers (RFC-0001 §15)
124
+ # ---------------------------------------------------------------------------
125
+
126
+ _TRACEPARENT_PARTS = 4
127
+ _TRACEPARENT_VERSION = "00"
128
+
129
+
130
+ def _parse_traceparent(header: str) -> tuple[str, str] | None:
131
+ """Parse a W3C ``traceparent`` header value.
132
+
133
+ Format: ``{version}-{trace-id}-{parent-id}-{trace-flags}``
134
+
135
+ Args:
136
+ header: The raw ``traceparent`` header value (e.g. from an HTTP request).
137
+
138
+ Returns:
139
+ ``(trace_id, parent_span_id)`` if the header is valid, else ``None``.
140
+ """
141
+ parts = header.strip().split("-")
142
+ if len(parts) != _TRACEPARENT_PARTS:
143
+ return None
144
+ version, trace_id, parent_id, _flags = parts
145
+ if version != _TRACEPARENT_VERSION:
146
+ return None
147
+ if len(trace_id) != 32 or not all(c in "0123456789abcdef" for c in trace_id):
148
+ return None
149
+ if len(parent_id) != 16 or not all(c in "0123456789abcdef" for c in parent_id):
150
+ return None
151
+ if trace_id == "0" * 32 or parent_id == "0" * 16:
152
+ return None # invalid all-zeros IDs per spec
153
+ return trace_id, parent_id
154
+
155
+
156
+ def extract_traceparent(headers: dict[str, str]) -> tuple[str, str] | None:
157
+ """Extract ``(trace_id, parent_span_id)`` from W3C Trace Context headers.
158
+
159
+ Looks for the ``traceparent`` key (case-insensitive) in *headers*.
160
+
161
+ Args:
162
+ headers: HTTP request headers dict.
163
+
164
+ Returns:
165
+ ``(trace_id, parent_span_id)`` if a valid ``traceparent`` header is
166
+ present, else ``None``.
167
+
168
+ Example::
169
+
170
+ ctx = extract_traceparent(request.headers)
171
+ if ctx:
172
+ trace_id, parent_id = ctx
173
+ with tracer.span("handle", incoming_traceparent=request.headers.get("traceparent")):
174
+ ...
175
+ """
176
+ # Case-insensitive lookup — HTTP/1.1 headers are case-insensitive (RFC 7230 §3.2).
177
+ raw = next((v for k, v in headers.items() if k.lower() == "traceparent"), "")
178
+ return _parse_traceparent(raw) if raw else None
179
+
180
+
181
+ def inject_traceparent(span: Span, headers: dict[str, str]) -> None:
182
+ """Inject W3C Trace Context into *headers* for downstream propagation.
183
+
184
+ Sets ``traceparent`` using the active trace and span IDs.
185
+
186
+ Args:
187
+ span: The currently active :class:`Span`.
188
+ headers: Mutable HTTP headers dict to inject into.
189
+
190
+ Example::
191
+
192
+ headers = {}
193
+ inject_traceparent(span, headers)
194
+ httpx.get(url, headers=headers)
195
+ """
196
+ span.inject(headers)
197
+
198
+
199
+ # ---------------------------------------------------------------------------
200
+ # Span helpers
201
+ # ---------------------------------------------------------------------------
202
+
203
+
204
+ def _default_span_events() -> deque[SpanEvent]:
205
+ """Return a deque with maxlen read from the global config (H2: configurable)."""
206
+ try:
207
+ from spanforge.config import get_config
208
+
209
+ maxlen = get_config().max_span_events
210
+ return deque(maxlen=maxlen if maxlen > 0 else None)
211
+ except Exception: # config not yet initialised
212
+ return deque(maxlen=1000)
213
+
214
+
215
+ # ---------------------------------------------------------------------------
216
+ # Span
217
+ # ---------------------------------------------------------------------------
218
+
219
+
220
+ @dataclass
221
+ class Span:
222
+ """Mutable span record accumulated during a ``with tracer.span(...)`` block.
223
+
224
+ Create via :class:`SpanContextManager` (i.e. ``tracer.span(...)``).
225
+ Direct construction is supported for testing.
226
+
227
+ Auto-populated fields
228
+ ----------------------
229
+ ``span_id``, ``trace_id``, and ``start_ns`` are assigned by
230
+ :class:`SpanContextManager.__enter__`; do not set them manually unless
231
+ you need custom IDs for testing.
232
+
233
+ Attributes:
234
+ name: Human-readable span name.
235
+ span_id: 16 lowercase hex chars (OTel span ID).
236
+ trace_id: 32 lowercase hex chars (OTel trace ID).
237
+ parent_span_id: Parent span ID if nested; ``None`` for root spans.
238
+ agent_run_id: ULID of the enclosing agent run, if any.
239
+ model: Model name string (e.g. ``"gpt-4o"``).
240
+ operation: GenAI operation name (default ``"chat"``).
241
+ attributes: Arbitrary key-value metadata set by the user.
242
+ start_ns: Start time as nanoseconds since Unix epoch.
243
+ end_ns: End time (set on :meth:`end`).
244
+ duration_ms: Computed duration in milliseconds.
245
+ status: ``"ok"`` or ``"error"`` or ``"timeout"``.
246
+ error: Error message if ``status == "error"``.
247
+ error_type: Exception class name if ``status == "error"``.
248
+ token_usage: Optional token counts (set by provider integrations).
249
+ cost: Optional cost breakdown (set by provider integrations).
250
+ """
251
+
252
+ name: str
253
+ span_id: str = field(default_factory=_span_id)
254
+ trace_id: str = field(default_factory=_trace_id)
255
+ parent_span_id: str | None = None
256
+ agent_run_id: str | None = None
257
+ model: str | None = None
258
+ operation: str = "chat"
259
+ attributes: dict[str, Any] = field(default_factory=dict)
260
+ start_ns: int = field(default_factory=_now_ns)
261
+ end_ns: int | None = None
262
+ duration_ms: float | None = None
263
+ status: str = "ok"
264
+ error: str | None = None
265
+ error_type: str | None = None
266
+ token_usage: TokenUsage | None = None
267
+ cost: CostBreakdown | None = None
268
+ tool_calls: list[ToolCall] = field(default_factory=list)
269
+ events: deque[SpanEvent] = field(default_factory=_default_span_events)
270
+ temperature: float | None = None
271
+ top_p: float | None = None
272
+ max_tokens: int | None = None
273
+ error_category: str | None = None # one of SpanErrorCategory literals
274
+ session_id: str | None = None # conversation / session identifier
275
+ user_id: str | None = None # end-user identifier
276
+ traceparent: str | None = None # incoming W3C traceparent (for propagation)
277
+ _timeout_timer: threading.Timer | None = field(default=None, init=False, repr=False)
278
+
279
+ # ------------------------------------------------------------------
280
+ # Mutation methods (call from inside ``with tracer.span(...) as s:``)
281
+ # ------------------------------------------------------------------
282
+
283
+ def set_attribute(self, key: str, value: Any) -> None:
284
+ """Add or update a key-value attribute on this span.
285
+
286
+ Args:
287
+ key: Attribute name (non-empty string).
288
+ value: Attribute value (any JSON-serialisable type).
289
+ """
290
+ if not isinstance(key, str) or not key:
291
+ raise ValueError("set_attribute: key must be a non-empty string")
292
+ self.attributes[key] = value
293
+
294
+ def add_event(self, name: str, metadata: dict[str, Any] | None = None) -> None:
295
+ """Record a named event at this point in time within the span.
296
+
297
+ Args:
298
+ name: Event name (non-empty string).
299
+ metadata: Optional key-value metadata for this event.
300
+ """
301
+ self.events.append(SpanEvent(name=name, metadata=metadata or {}))
302
+
303
+ def inject(self, headers: dict[str, str]) -> None:
304
+ """Inject W3C Trace Context headers for downstream propagation.
305
+
306
+ Sets ``traceparent`` (and optionally ``tracestate``) on *headers* so
307
+ that downstream services can correlate their spans with this one.
308
+
309
+ Args:
310
+ headers: Mutable dict-like object representing outgoing HTTP headers.
311
+ ``traceparent`` will be set (and ``tracestate`` cleared).
312
+
313
+ Example::
314
+
315
+ headers = {}
316
+ span.inject(headers)
317
+ requests.get(url, headers=headers)
318
+ """
319
+ flags = "01" # sampled
320
+ headers["traceparent"] = f"00-{self.trace_id}-{self.span_id}-{flags}"
321
+
322
+ def record_error(
323
+ self,
324
+ exc: Exception,
325
+ category: str | None = None,
326
+ ) -> None:
327
+ """Record an exception on this span, setting ``status = "error"``.
328
+
329
+ Args:
330
+ exc: The exception that caused the failure.
331
+ category: Optional error category — one of ``"agent_error"``,
332
+ ``"llm_error"``, ``"tool_error"``, ``"timeout_error"``,
333
+ ``"unknown_error"``. When omitted, :class:`TimeoutError`
334
+ is automatically mapped to ``"timeout_error"``; all
335
+ others default to ``"unknown_error"``.
336
+ """
337
+ self.status = "error"
338
+ self.error = str(exc)
339
+ self.error_type = type(exc).__qualname__
340
+ if category is not None:
341
+ self.error_category = category
342
+ elif isinstance(exc, TimeoutError):
343
+ self.error_category = "timeout_error"
344
+ else:
345
+ self.error_category = "unknown_error"
346
+
347
+ def set_token_usage(self, token_usage: TokenUsage) -> None:
348
+ """Attach token usage data (called by provider integrations)."""
349
+ self.token_usage = token_usage
350
+
351
+ def set_cost(self, cost: CostBreakdown) -> None:
352
+ """Attach cost breakdown data (called by provider integrations)."""
353
+ self.cost = cost
354
+
355
+ # ------------------------------------------------------------------
356
+ # Internal lifecycle
357
+ # ------------------------------------------------------------------
358
+
359
+ def set_timeout_deadline(self, seconds: float) -> None:
360
+ """Schedule this span to auto-timeout if not closed within *seconds*.
361
+
362
+ If the span is still open when the deadline passes, its ``status``
363
+ is set to ``"timeout"`` and ``error_category`` to ``"timeout_error"``.
364
+ The background timer is automatically cancelled when the span closes
365
+ normally via :meth:`end`.
366
+
367
+ Args:
368
+ seconds: Deadline in seconds (must be > 0).
369
+
370
+ Raises:
371
+ ValueError: If *seconds* is not greater than zero.
372
+ """
373
+ if seconds <= 0:
374
+ raise ValueError(f"set_timeout_deadline: seconds must be > 0, got {seconds!r}")
375
+ import threading
376
+
377
+ # Cancel any previously registered timer before installing a new one.
378
+ # Without this guard, double-calling would orphan the first timer.
379
+ if self._timeout_timer is not None:
380
+ self._timeout_timer.cancel()
381
+ self._timeout_timer = None
382
+
383
+ def _timeout_fn() -> None:
384
+ # Guard is evaluated on CPython under the GIL. end_ns is set by
385
+ # end() before cancel() is called; on CPython this sequence is
386
+ # safe. The double guard (end_ns + status) means a span that has
387
+ # already errored or finished is never overwritten.
388
+ if self.end_ns is None and self.status == "ok":
389
+ self.status = "timeout"
390
+ self.error = f"Span timed out after {seconds:.3f}s"
391
+ self.error_category = "timeout_error"
392
+
393
+ timer = threading.Timer(seconds, _timeout_fn)
394
+ timer.daemon = True
395
+ timer.start()
396
+ self._timeout_timer = timer
397
+
398
+ def end(self) -> None:
399
+ """Finalise the span by recording the end time and computing duration."""
400
+ if self.end_ns is None:
401
+ self.end_ns = _now_ns()
402
+ self.duration_ms = (self.end_ns - self.start_ns) / 1_000_000.0
403
+ if self._timeout_timer is not None:
404
+ self._timeout_timer.cancel()
405
+ self._timeout_timer = None
406
+
407
+ def to_span_payload(self) -> SpanPayload:
408
+ """Serialise this span to a :class:`~spanforge.namespaces.trace.SpanPayload`.
409
+
410
+ Called internally by :class:`SpanContextManager.__exit__` just before
411
+ event emission.
412
+ """
413
+ end_ns = self.end_ns if self.end_ns is not None else _now_ns()
414
+ duration_ms = (end_ns - self.start_ns) / 1_000_000.0
415
+
416
+ # Resolve ModelInfo from the model name string.
417
+ model_info: ModelInfo | None = None
418
+ if self.model:
419
+ model_info = _resolve_model_info(self.model)
420
+
421
+ # Resolve operation enum.
422
+ try:
423
+ operation: GenAIOperationName | str = GenAIOperationName(self.operation)
424
+ except ValueError:
425
+ operation = self.operation
426
+
427
+ return SpanPayload(
428
+ span_id=self.span_id,
429
+ trace_id=self.trace_id,
430
+ span_name=self.name,
431
+ operation=operation,
432
+ span_kind=SpanKind.CLIENT,
433
+ status=self.status,
434
+ start_time_unix_nano=self.start_ns,
435
+ end_time_unix_nano=end_ns,
436
+ duration_ms=duration_ms,
437
+ parent_span_id=self.parent_span_id,
438
+ agent_run_id=self.agent_run_id,
439
+ model=model_info,
440
+ token_usage=self.token_usage,
441
+ cost=self.cost,
442
+ tool_calls=list(self.tool_calls),
443
+ error=self.error,
444
+ error_type=self.error_type,
445
+ attributes=self.attributes if self.attributes else None,
446
+ temperature=self.temperature,
447
+ top_p=self.top_p,
448
+ max_tokens=self.max_tokens,
449
+ error_category=self.error_category,
450
+ events=list(self.events),
451
+ session_id=self.session_id,
452
+ user_id=self.user_id,
453
+ incoming_traceparent=self.traceparent,
454
+ )
455
+
456
+
457
+ # ---------------------------------------------------------------------------
458
+ # SpanContextManager
459
+ # ---------------------------------------------------------------------------
460
+
461
+
462
+ class SpanContextManager:
463
+ """Context manager returned by :meth:`~spanforge._tracer.Tracer.span`.
464
+
465
+ Usage::
466
+
467
+ with tracer.span("my-llm-call", model="gpt-4o") as span:
468
+ span.set_attribute("prompt_length", 256)
469
+ # ... call LLM ...
470
+ # → SpanPayload event emitted on exit
471
+
472
+ The :class:`Span` instance is bound to the ``as`` target and is also
473
+ pushed onto the context-variable span stack so nested spans can inherit the
474
+ ``trace_id``.
475
+ """
476
+
477
+ def __init__(
478
+ self,
479
+ name: str,
480
+ model: str | None = None,
481
+ operation: str = "chat",
482
+ temperature: float | None = None,
483
+ top_p: float | None = None,
484
+ max_tokens: int | None = None,
485
+ attributes: dict[str, Any] | None = None,
486
+ incoming_traceparent: str | None = None,
487
+ session_id: str | None = None,
488
+ user_id: str | None = None,
489
+ ) -> None:
490
+ self._name = name
491
+ self._model = model
492
+ self._operation = operation
493
+ self._temperature = temperature
494
+ self._top_p = top_p
495
+ self._max_tokens = max_tokens
496
+ self._initial_attributes = dict(attributes or {})
497
+ self._incoming_traceparent = incoming_traceparent
498
+ self._session_id = session_id
499
+ self._user_id = user_id
500
+ self._span: Span | None = None
501
+
502
+ # ------------------------------------------------------------------
503
+ # Context manager protocol
504
+ # ------------------------------------------------------------------
505
+
506
+ def __enter__(self) -> Span:
507
+ stack = _span_stack()
508
+ run_tuple = _run_stack()
509
+
510
+ # Inherit trace_id and parent_span_id from the enclosing span.
511
+ if stack:
512
+ parent = stack[-1]
513
+ trace_id = parent.trace_id
514
+ parent_span_id = parent.span_id
515
+ elif self._incoming_traceparent:
516
+ # Extract W3C traceparent from incoming headers for distributed tracing.
517
+ extracted = _parse_traceparent(self._incoming_traceparent)
518
+ if extracted is not None:
519
+ trace_id, parent_span_id = extracted
520
+ else:
521
+ trace_id = run_tuple[-1].trace_id if run_tuple else _trace_id()
522
+ parent_span_id = None
523
+ else:
524
+ # Fall back to the enclosing run context's trace_id when available
525
+ # so that all spans within a Trace share one trace_id.
526
+ trace_id = run_tuple[-1].trace_id if run_tuple else _trace_id()
527
+ parent_span_id = None
528
+
529
+ # Inherit agent_run_id from the enclosing run context.
530
+ agent_run_id = run_tuple[-1].agent_run_id if run_tuple else None
531
+
532
+ # Resolve session_id and user_id from explicit arg or config defaults.
533
+ try:
534
+ from spanforge.config import get_config as _gc
535
+
536
+ _cfg = _gc()
537
+ session_id = self._session_id or _cfg.default_session_id
538
+ user_id = self._user_id or _cfg.default_user_id
539
+ except Exception: # NOSONAR
540
+ session_id = self._session_id
541
+ user_id = self._user_id
542
+
543
+ self._span = Span(
544
+ name=self._name,
545
+ span_id=_span_id(),
546
+ trace_id=trace_id,
547
+ parent_span_id=parent_span_id,
548
+ agent_run_id=agent_run_id,
549
+ model=self._model,
550
+ operation=self._operation,
551
+ temperature=self._temperature,
552
+ top_p=self._top_p,
553
+ max_tokens=self._max_tokens,
554
+ attributes=dict(self._initial_attributes),
555
+ start_ns=_now_ns(),
556
+ session_id=session_id,
557
+ user_id=user_id,
558
+ traceparent=self._incoming_traceparent,
559
+ )
560
+ # Push onto an immutable tuple and save the reset token.
561
+ self._stack_token: contextvars.Token[tuple[Span, ...]] = _span_stack_var.set(
562
+ (*stack, self._span)
563
+ )
564
+ # Fire span processors on_start (errors suppressed).
565
+ try:
566
+ from spanforge.processor import _run_on_start
567
+
568
+ _run_on_start(self._span)
569
+ except Exception as exc:
570
+ _log.debug("suppressed span processor on_start error: %s", exc)
571
+ # Fire start hooks (errors suppressed — hooks must never abort user code).
572
+ try:
573
+ from spanforge._hooks import hooks as _hooks
574
+
575
+ _hooks._fire_start(self._span)
576
+ except Exception as exc:
577
+ _log.debug("suppressed span hook on_start error: %s", exc)
578
+ return self._span
579
+
580
+ def __exit__(
581
+ self,
582
+ exc_type: type[BaseException] | None,
583
+ exc_val: BaseException | None,
584
+ exc_tb: TracebackType | None,
585
+ ) -> Literal[False]:
586
+ assert self._span is not None, "SpanContextManager.__exit__ called before __enter__" # nosec B101
587
+
588
+ # Record any unhandled exception on the span.
589
+ # Exclude BaseException subclasses that are control-flow signals
590
+ # (KeyboardInterrupt, SystemExit, GeneratorExit) — only true
591
+ # application exceptions (Exception subclasses) are recorded.
592
+ if exc_val is not None and isinstance(exc_val, Exception) and self._span.status == "ok":
593
+ self._span.record_error(exc_val)
594
+
595
+ self._span.end()
596
+
597
+ # Restore the stack to its pre-enter state.
598
+ _span_stack_var.reset(self._stack_token)
599
+
600
+ # Fire span processors on_end (errors suppressed).
601
+ try:
602
+ from spanforge.processor import _run_on_end
603
+
604
+ _run_on_end(self._span)
605
+ except Exception as exc:
606
+ _log.debug("suppressed span processor on_end error: %s", exc)
607
+ # Fire end hooks before export (errors suppressed).
608
+ try:
609
+ from spanforge._hooks import hooks as _hooks
610
+
611
+ _hooks._fire_end(self._span)
612
+ except Exception as exc:
613
+ _log.debug("suppressed span hook on_end error: %s", exc)
614
+
615
+ # Emit the event.
616
+ try:
617
+ from spanforge import _stream as stream_mod
618
+
619
+ try:
620
+ stream_mod.emit_span(self._span)
621
+ except Exception as exc:
622
+ stream_mod._handle_export_error(exc)
623
+ except Exception as exc:
624
+ _log.debug("suppressed span export bootstrap error: %s", exc)
625
+
626
+ # Auto-emit cost event when configured (Tool 2).
627
+ if self._span.cost is not None:
628
+ try:
629
+ from spanforge.config import get_config as _gc
630
+
631
+ if _gc().auto_emit_cost:
632
+ from spanforge.cost import emit_cost_event
633
+
634
+ emit_cost_event(self._span)
635
+ except Exception as exc:
636
+ _log.debug("suppressed cost auto-emit error: %s", exc)
637
+
638
+ # Do NOT suppress the original exception.
639
+ return False
640
+
641
+ # ------------------------------------------------------------------
642
+ # Async context manager protocol (delegates to sync implementation)
643
+ # ------------------------------------------------------------------
644
+
645
+ async def __aenter__(self) -> Span:
646
+ """Async entry — identical to ``__enter__``; safe for ``async with``."""
647
+ return self.__enter__()
648
+
649
+ async def __aexit__(
650
+ self,
651
+ exc_type: type[BaseException] | None,
652
+ exc_val: BaseException | None,
653
+ exc_tb: TracebackType | None,
654
+ ) -> Literal[False]:
655
+ """Async exit — identical to ``__exit__``; safe for ``async with``."""
656
+ return self.__exit__(exc_type, exc_val, exc_tb)
657
+
658
+
659
+ # ---------------------------------------------------------------------------
660
+ # Agent step context
661
+ # ---------------------------------------------------------------------------
662
+
663
+
664
+ @dataclass
665
+ class AgentStepContext:
666
+ """Mutable record accumulated during ``with tracer.agent_step(...)``."""
667
+
668
+ step_name: str
669
+ agent_run_id: str
670
+ step_index: int
671
+ span_id: str = field(default_factory=_span_id)
672
+ trace_id: str = field(default_factory=_trace_id)
673
+ parent_span_id: str | None = None
674
+ operation: str = "invoke_agent"
675
+ start_ns: int = field(default_factory=_now_ns)
676
+ end_ns: int | None = None
677
+ duration_ms: float | None = None
678
+ status: str = "ok"
679
+ error: str | None = None
680
+ error_type: str | None = None
681
+ model: str | None = None
682
+ token_usage: TokenUsage | None = None
683
+ cost: CostBreakdown | None = None
684
+ tool_calls: list[ToolCall] = field(default_factory=list)
685
+ reasoning_steps: list[ReasoningStep] = field(default_factory=list)
686
+ decision_points: list[DecisionPoint] = field(default_factory=list)
687
+ attributes: dict[str, Any] = field(default_factory=dict)
688
+
689
+ def set_attribute(self, key: str, value: Any) -> None:
690
+ if not isinstance(key, str) or not key:
691
+ raise ValueError("set_attribute: key must be a non-empty string")
692
+ self.attributes[key] = value
693
+
694
+ def record_error(self, exc: Exception) -> None:
695
+ self.status = "error"
696
+ self.error = str(exc)
697
+ self.error_type = type(exc).__qualname__
698
+
699
+ def end(self) -> None:
700
+ if self.end_ns is None:
701
+ self.end_ns = _now_ns()
702
+ self.duration_ms = (self.end_ns - self.start_ns) / 1_000_000.0
703
+
704
+ def to_agent_step_payload(self) -> AgentStepPayload:
705
+ end_ns = self.end_ns if self.end_ns is not None else _now_ns()
706
+ duration_ms = (end_ns - self.start_ns) / 1_000_000.0
707
+ try:
708
+ operation: GenAIOperationName | str = GenAIOperationName(self.operation)
709
+ except ValueError:
710
+ operation = self.operation
711
+ return AgentStepPayload(
712
+ agent_run_id=self.agent_run_id,
713
+ step_index=self.step_index,
714
+ span_id=self.span_id,
715
+ trace_id=self.trace_id,
716
+ operation=operation,
717
+ tool_calls=list(self.tool_calls),
718
+ reasoning_steps=list(self.reasoning_steps),
719
+ decision_points=list(self.decision_points),
720
+ status=self.status,
721
+ start_time_unix_nano=self.start_ns,
722
+ end_time_unix_nano=end_ns,
723
+ duration_ms=duration_ms,
724
+ parent_span_id=self.parent_span_id,
725
+ model=_resolve_model_info(self.model) if self.model else None,
726
+ token_usage=self.token_usage,
727
+ cost=self.cost,
728
+ error=self.error,
729
+ error_type=self.error_type,
730
+ step_name=self.step_name,
731
+ )
732
+
733
+
734
+ class AgentStepContextManager:
735
+ """Context manager returned by :meth:`~spanforge._tracer.Tracer.agent_step`."""
736
+
737
+ def __init__(
738
+ self,
739
+ step_name: str,
740
+ operation: str = "invoke_agent",
741
+ attributes: dict[str, Any] | None = None,
742
+ ) -> None:
743
+ self._step_name = step_name
744
+ self._operation = operation
745
+ self._initial_attributes = dict(attributes or {})
746
+ self._ctx: AgentStepContext | None = None
747
+
748
+ def __enter__(self) -> AgentStepContext:
749
+ run_tuple = _run_stack()
750
+ if not run_tuple:
751
+ raise RuntimeError(
752
+ "tracer.agent_step() must be used inside a tracer.agent_run() context"
753
+ )
754
+ run = run_tuple[-1]
755
+
756
+ # Inherit trace_id + parent from any enclosing span.
757
+ span_tuple = _span_stack()
758
+ if span_tuple:
759
+ parent = span_tuple[-1]
760
+ trace_id = parent.trace_id
761
+ parent_span_id = parent.span_id
762
+ else:
763
+ trace_id = run.trace_id
764
+ parent_span_id = None
765
+
766
+ step_index = run.next_step_index()
767
+
768
+ self._ctx = AgentStepContext(
769
+ step_name=self._step_name,
770
+ agent_run_id=run.agent_run_id,
771
+ step_index=step_index,
772
+ span_id=_span_id(),
773
+ trace_id=trace_id,
774
+ parent_span_id=parent_span_id,
775
+ operation=self._operation,
776
+ start_ns=_now_ns(),
777
+ attributes=dict(self._initial_attributes),
778
+ )
779
+ return self._ctx
780
+
781
+ def __exit__(
782
+ self,
783
+ exc_type: type[BaseException] | None,
784
+ exc_val: BaseException | None,
785
+ exc_tb: TracebackType | None,
786
+ ) -> Literal[False]:
787
+ assert self._ctx is not None # nosec B101
788
+
789
+ if exc_val is not None and isinstance(exc_val, Exception) and self._ctx.status == "ok":
790
+ self._ctx.record_error(exc_val)
791
+ self._ctx.end()
792
+
793
+ # Register step with the parent run context.
794
+ run_tuple = _run_stack()
795
+ if run_tuple:
796
+ run_tuple[-1].record_step(self._ctx)
797
+
798
+ # Emit agent step event.
799
+ try:
800
+ from spanforge import _stream as stream_mod
801
+
802
+ try:
803
+ stream_mod.emit_agent_step(self._ctx)
804
+ except Exception as exc:
805
+ stream_mod._handle_export_error(exc)
806
+ except Exception as exc:
807
+ _log.debug("suppressed agent step export bootstrap error: %s", exc)
808
+
809
+ return False
810
+
811
+ # ------------------------------------------------------------------
812
+ # Async context manager protocol
813
+ # ------------------------------------------------------------------
814
+
815
+ async def __aenter__(self) -> AgentStepContext:
816
+ """Async entry — identical to ``__enter__``."""
817
+ return self.__enter__()
818
+
819
+ async def __aexit__(
820
+ self,
821
+ exc_type: type[BaseException] | None,
822
+ exc_val: BaseException | None,
823
+ exc_tb: TracebackType | None,
824
+ ) -> Literal[False]:
825
+ """Async exit — identical to ``__exit__``."""
826
+ return self.__exit__(exc_type, exc_val, exc_tb)
827
+
828
+
829
+ # ---------------------------------------------------------------------------
830
+ # Agent run context
831
+ # ---------------------------------------------------------------------------
832
+
833
+
834
+ @dataclass
835
+ class AgentRunContext:
836
+ """Mutable record accumulated during ``with tracer.agent_run(...)``."""
837
+
838
+ agent_name: str
839
+ agent_run_id: str = field(default_factory=_span_id) # 16 hex chars
840
+ trace_id: str = field(default_factory=_trace_id)
841
+ root_span_id: str = field(default_factory=_span_id)
842
+ start_ns: int = field(default_factory=_now_ns)
843
+ end_ns: int | None = None
844
+ duration_ms: float | None = None
845
+ status: str = "ok"
846
+ error: str | None = None
847
+ termination_reason: str | None = None
848
+ _step_count: int = field(default=0, init=False, repr=False)
849
+ _steps: list[AgentStepContext] = field(default_factory=list, init=False, repr=False)
850
+ _child_run_costs: list[CostBreakdown] = field(default_factory=list, init=False, repr=False)
851
+
852
+ def next_step_index(self) -> int:
853
+ idx = self._step_count
854
+ self._step_count += 1
855
+ return idx
856
+
857
+ def record_step(self, step: AgentStepContext) -> None:
858
+ self._steps.append(step)
859
+
860
+ def record_error(self, exc: Exception) -> None:
861
+ self.status = "error"
862
+ self.error = str(exc)
863
+
864
+ def record_child_run_cost(self, cost: CostBreakdown) -> None:
865
+ """Accumulate cost from a completed child agent run."""
866
+ self._child_run_costs.append(cost)
867
+
868
+ def end(self) -> None:
869
+ if self.end_ns is None:
870
+ self.end_ns = _now_ns()
871
+ self.duration_ms = (self.end_ns - self.start_ns) / 1_000_000.0
872
+
873
+ def to_agent_run_payload(self) -> AgentRunPayload:
874
+ end_ns = self.end_ns if self.end_ns is not None else _now_ns()
875
+ duration_ms = (end_ns - self.start_ns) / 1_000_000.0
876
+
877
+ # Aggregate token usage and cost across all steps.
878
+ total_input = 0
879
+ total_output = 0
880
+ total_tokens = 0
881
+ total_in_cost = 0.0
882
+ total_out_cost = 0.0
883
+ total_model_calls = 0
884
+ total_tool_calls = 0
885
+ for step in self._steps:
886
+ if step.token_usage:
887
+ total_input += step.token_usage.input_tokens
888
+ total_output += step.token_usage.output_tokens
889
+ total_tokens += step.token_usage.total_tokens
890
+ total_model_calls += 1
891
+ total_tool_calls += len(step.tool_calls)
892
+ if step.cost:
893
+ total_in_cost += step.cost.input_cost_usd
894
+ total_out_cost += step.cost.output_cost_usd
895
+
896
+ # Include costs bubbled up from child agent runs.
897
+ child_in_cost = 0.0
898
+ child_out_cost = 0.0
899
+ for child_cost in self._child_run_costs:
900
+ child_in_cost += child_cost.input_cost_usd
901
+ child_out_cost += child_cost.output_cost_usd
902
+
903
+ total_in_cost += child_in_cost
904
+ total_out_cost += child_out_cost
905
+
906
+ total_token_usage = TokenUsage(
907
+ input_tokens=total_input,
908
+ output_tokens=total_output,
909
+ total_tokens=total_tokens,
910
+ )
911
+ total_cost = CostBreakdown(
912
+ input_cost_usd=total_in_cost,
913
+ output_cost_usd=total_out_cost,
914
+ total_cost_usd=total_in_cost + total_out_cost,
915
+ )
916
+
917
+ return AgentRunPayload(
918
+ agent_run_id=self.agent_run_id,
919
+ agent_name=self.agent_name,
920
+ trace_id=self.trace_id,
921
+ root_span_id=self.root_span_id,
922
+ total_steps=len(self._steps),
923
+ total_model_calls=total_model_calls,
924
+ total_tool_calls=total_tool_calls,
925
+ total_token_usage=total_token_usage,
926
+ total_cost=total_cost,
927
+ status=self.status,
928
+ start_time_unix_nano=self.start_ns,
929
+ end_time_unix_nano=end_ns,
930
+ duration_ms=duration_ms,
931
+ termination_reason=self.termination_reason,
932
+ )
933
+
934
+
935
+ class AgentRunContextManager:
936
+ """Context manager returned by :meth:`~spanforge._tracer.Tracer.agent_run`."""
937
+
938
+ def __init__(self, agent_name: str) -> None:
939
+ self._agent_name = agent_name
940
+ self._ctx: AgentRunContext | None = None
941
+
942
+ def __enter__(self) -> AgentRunContext:
943
+ self._ctx = AgentRunContext(
944
+ agent_name=self._agent_name,
945
+ agent_run_id=_span_id(),
946
+ trace_id=_trace_id(),
947
+ root_span_id=_span_id(),
948
+ start_ns=_now_ns(),
949
+ )
950
+ # Push onto the immutable run-stack tuple and save the reset token.
951
+ self._run_token: contextvars.Token[tuple[AgentRunContext, ...]] = _run_stack_var.set(
952
+ (*_run_stack(), self._ctx)
953
+ )
954
+ return self._ctx
955
+
956
+ def __exit__(
957
+ self,
958
+ exc_type: type[BaseException] | None,
959
+ exc_val: BaseException | None,
960
+ exc_tb: TracebackType | None,
961
+ ) -> Literal[False]:
962
+ assert self._ctx is not None # nosec B101
963
+
964
+ if exc_val is not None and isinstance(exc_val, Exception) and self._ctx.status == "ok":
965
+ self._ctx.record_error(exc_val)
966
+ self._ctx.end()
967
+
968
+ # Restore the run-stack to its pre-enter state.
969
+ _run_stack_var.reset(self._run_token)
970
+
971
+ # Bubble this run's total cost up to the parent run (if any).
972
+ parent_stack = _run_stack()
973
+ if parent_stack:
974
+ parent_run = parent_stack[-1]
975
+ run_payload = self._ctx.to_agent_run_payload()
976
+ parent_run.record_child_run_cost(run_payload.total_cost)
977
+
978
+ try:
979
+ from spanforge import _stream as stream_mod
980
+
981
+ try:
982
+ stream_mod.emit_agent_run(self._ctx)
983
+ except Exception as exc:
984
+ stream_mod._handle_export_error(exc)
985
+ except Exception as exc:
986
+ _log.debug("suppressed agent run export bootstrap error: %s", exc)
987
+
988
+ return False
989
+
990
+ # ------------------------------------------------------------------
991
+ # Async context manager protocol
992
+ # ------------------------------------------------------------------
993
+
994
+ async def __aenter__(self) -> AgentRunContext:
995
+ """Async entry — identical to ``__enter__``."""
996
+ return self.__enter__()
997
+
998
+ async def __aexit__(
999
+ self,
1000
+ exc_type: type[BaseException] | None,
1001
+ exc_val: BaseException | None,
1002
+ exc_tb: TracebackType | None,
1003
+ ) -> Literal[False]:
1004
+ """Async exit — identical to ``__exit__``."""
1005
+ return self.__exit__(exc_type, exc_val, exc_tb)
1006
+
1007
+
1008
+ # ---------------------------------------------------------------------------
1009
+ # Helper: model name → ModelInfo
1010
+ # ---------------------------------------------------------------------------
1011
+
1012
+
1013
+ def _resolve_model_info(model_name: str) -> ModelInfo:
1014
+ """Infer :class:`~spanforge.namespaces.trace.ModelInfo` from a model name string.
1015
+
1016
+ Uses prefix heuristics (``"claude-"`` → Anthropic, etc.) with
1017
+ :attr:`~spanforge.namespaces.trace.GenAISystem.OPENAI` as the fallback.
1018
+ """
1019
+ name_lower = model_name.lower()
1020
+ if name_lower.startswith("claude"):
1021
+ system = GenAISystem.ANTHROPIC
1022
+ elif name_lower.startswith("gemini"):
1023
+ system = GenAISystem.VERTEX_AI
1024
+ elif name_lower.startswith("command"):
1025
+ system = GenAISystem.COHERE
1026
+ elif name_lower.startswith("mistral") or name_lower.startswith("mixtral"):
1027
+ system = GenAISystem.MISTRAL_AI
1028
+ elif (
1029
+ name_lower.startswith("llama")
1030
+ or name_lower.startswith("phi")
1031
+ or name_lower.startswith("qwen")
1032
+ ):
1033
+ system = GenAISystem.OLLAMA
1034
+ else:
1035
+ system = GenAISystem.OPENAI
1036
+ return ModelInfo(system=system, name=model_name)