spanforge 2.0.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (101) hide show
  1. spanforge/__init__.py +695 -0
  2. spanforge/_batch_exporter.py +322 -0
  3. spanforge/_cli.py +3081 -0
  4. spanforge/_hooks.py +340 -0
  5. spanforge/_server.py +953 -0
  6. spanforge/_span.py +1015 -0
  7. spanforge/_store.py +287 -0
  8. spanforge/_stream.py +654 -0
  9. spanforge/_trace.py +334 -0
  10. spanforge/_tracer.py +253 -0
  11. spanforge/actor.py +141 -0
  12. spanforge/alerts.py +464 -0
  13. spanforge/auto.py +181 -0
  14. spanforge/baseline.py +336 -0
  15. spanforge/config.py +460 -0
  16. spanforge/consent.py +227 -0
  17. spanforge/consumer.py +379 -0
  18. spanforge/core/__init__.py +5 -0
  19. spanforge/core/compliance_mapping.py +1060 -0
  20. spanforge/cost.py +597 -0
  21. spanforge/debug.py +514 -0
  22. spanforge/drift.py +488 -0
  23. spanforge/egress.py +63 -0
  24. spanforge/eval.py +575 -0
  25. spanforge/event.py +1052 -0
  26. spanforge/exceptions.py +246 -0
  27. spanforge/explain.py +181 -0
  28. spanforge/export/__init__.py +50 -0
  29. spanforge/export/append_only.py +342 -0
  30. spanforge/export/cloud.py +349 -0
  31. spanforge/export/datadog.py +495 -0
  32. spanforge/export/grafana.py +331 -0
  33. spanforge/export/jsonl.py +198 -0
  34. spanforge/export/otel_bridge.py +291 -0
  35. spanforge/export/otlp.py +817 -0
  36. spanforge/export/otlp_bridge.py +231 -0
  37. spanforge/export/redis_backend.py +282 -0
  38. spanforge/export/webhook.py +302 -0
  39. spanforge/exporters/__init__.py +29 -0
  40. spanforge/exporters/console.py +271 -0
  41. spanforge/exporters/jsonl.py +144 -0
  42. spanforge/hitl.py +297 -0
  43. spanforge/inspect.py +429 -0
  44. spanforge/integrations/__init__.py +39 -0
  45. spanforge/integrations/_pricing.py +277 -0
  46. spanforge/integrations/anthropic.py +388 -0
  47. spanforge/integrations/bedrock.py +306 -0
  48. spanforge/integrations/crewai.py +251 -0
  49. spanforge/integrations/gemini.py +349 -0
  50. spanforge/integrations/groq.py +444 -0
  51. spanforge/integrations/langchain.py +349 -0
  52. spanforge/integrations/llamaindex.py +370 -0
  53. spanforge/integrations/ollama.py +286 -0
  54. spanforge/integrations/openai.py +370 -0
  55. spanforge/integrations/together.py +485 -0
  56. spanforge/metrics.py +393 -0
  57. spanforge/metrics_export.py +342 -0
  58. spanforge/migrate.py +278 -0
  59. spanforge/model_registry.py +282 -0
  60. spanforge/models.py +407 -0
  61. spanforge/namespaces/__init__.py +215 -0
  62. spanforge/namespaces/audit.py +253 -0
  63. spanforge/namespaces/cache.py +209 -0
  64. spanforge/namespaces/chain.py +74 -0
  65. spanforge/namespaces/confidence.py +69 -0
  66. spanforge/namespaces/consent.py +85 -0
  67. spanforge/namespaces/cost.py +175 -0
  68. spanforge/namespaces/decision.py +135 -0
  69. spanforge/namespaces/diff.py +146 -0
  70. spanforge/namespaces/drift.py +79 -0
  71. spanforge/namespaces/eval_.py +232 -0
  72. spanforge/namespaces/fence.py +180 -0
  73. spanforge/namespaces/guard.py +104 -0
  74. spanforge/namespaces/hitl.py +92 -0
  75. spanforge/namespaces/latency.py +69 -0
  76. spanforge/namespaces/prompt.py +185 -0
  77. spanforge/namespaces/redact.py +172 -0
  78. spanforge/namespaces/template.py +197 -0
  79. spanforge/namespaces/tool_call.py +76 -0
  80. spanforge/namespaces/trace.py +1006 -0
  81. spanforge/normalizer.py +183 -0
  82. spanforge/presidio_backend.py +149 -0
  83. spanforge/processor.py +258 -0
  84. spanforge/prompt_registry.py +415 -0
  85. spanforge/py.typed +0 -0
  86. spanforge/redact.py +780 -0
  87. spanforge/sampling.py +500 -0
  88. spanforge/schemas/v1.0/schema.json +170 -0
  89. spanforge/schemas/v2.0/schema.json +536 -0
  90. spanforge/signing.py +1152 -0
  91. spanforge/stream.py +559 -0
  92. spanforge/testing.py +376 -0
  93. spanforge/trace.py +199 -0
  94. spanforge/types.py +696 -0
  95. spanforge/ulid.py +304 -0
  96. spanforge/validate.py +383 -0
  97. spanforge-2.0.0.dist-info/METADATA +1777 -0
  98. spanforge-2.0.0.dist-info/RECORD +101 -0
  99. spanforge-2.0.0.dist-info/WHEEL +4 -0
  100. spanforge-2.0.0.dist-info/entry_points.txt +5 -0
  101. spanforge-2.0.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,370 @@
1
+ """spanforge.integrations.llamaindex — LlamaIndex event handler.
2
+
3
+ Provides :class:`LLMSchemaEventHandler`, a LlamaIndex-compatible callback
4
+ handler that records LLM, tool-call, and query activity as SpanForge events.
5
+
6
+ Usage::
7
+
8
+ from spanforge.integrations.llamaindex import LLMSchemaEventHandler
9
+
10
+ handler = LLMSchemaEventHandler(source="rag-app", org_id="org-2")
11
+
12
+ Settings.callback_manager = CallbackManager([handler])
13
+ # or inject via the query engine constructor
14
+
15
+ for event in handler.events:
16
+ print(event.to_json())
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ import asyncio
22
+ import time
23
+ from typing import Any
24
+
25
+ from spanforge.event import Event
26
+ from spanforge.ulid import generate as gen_ulid
27
+
28
+ __all__ = [
29
+ "LLMSchemaEventHandler",
30
+ "is_patched",
31
+ "unpatch",
32
+ ]
33
+
34
+ # ---------------------------------------------------------------------------
35
+ # Module resolver
36
+ # ---------------------------------------------------------------------------
37
+
38
+
39
+ def _require_llamaindex() -> Any: # noqa: ANN401
40
+ """Return the LlamaIndex callbacks module.
41
+
42
+ Tries ``llama_index.core.callbacks`` first (preferred, modern API), then
43
+ falls back to the legacy ``llama_index.callbacks`` module.
44
+
45
+ Raises:
46
+ ImportError: If neither ``llama_index.core`` nor ``llama_index`` is installed.
47
+ """
48
+ # Try modern llama_index.core first.
49
+ try:
50
+ import sys # noqa: PLC0415
51
+
52
+ import llama_index.core # noqa: PLC0415
53
+ import llama_index.core.callbacks # noqa: PLC0415
54
+ return sys.modules["llama_index.core.callbacks"]
55
+ except ImportError:
56
+ pass
57
+ # Fall back to legacy llama_index package.
58
+ try:
59
+ import sys # noqa: PLC0415
60
+
61
+ import llama_index # noqa: PLC0415
62
+ import llama_index.callbacks # noqa: PLC0415, F401
63
+ return sys.modules["llama_index.callbacks"]
64
+ except ImportError:
65
+ pass
66
+ raise ImportError(
67
+ "LlamaIndex package is required for the spanforge LlamaIndex integration.\n"
68
+ "Install it with: pip install 'spanforge[llamaindex]'"
69
+ )
70
+
71
+
72
+ # ---------------------------------------------------------------------------
73
+ # Event type mapping
74
+ # ---------------------------------------------------------------------------
75
+
76
+ #: Maps LlamaIndex CBEventType string → (start_event_type, end_event_type)
77
+ _CB_TYPE_MAP: dict[str, tuple[str, str]] = {
78
+ "LLM": ("llm.trace.span.started", "llm.trace.span.completed"),
79
+ "FUNCTION_CALL": ("llm.trace.tool_call.started", "llm.trace.tool_call.completed"),
80
+ "QUERY": ("llm.trace.query.started", "llm.trace.query.completed"),
81
+ "RETRIEVE": ("llm.trace.retrieve.started", "llm.trace.retrieve.completed"),
82
+ }
83
+
84
+
85
+ # ---------------------------------------------------------------------------
86
+ # Event handler
87
+ # ---------------------------------------------------------------------------
88
+
89
+
90
+ class LLMSchemaEventHandler:
91
+ """LlamaIndex callback handler that emits SpanForge events.
92
+
93
+ Compatible with both ``llama_index.core`` and legacy ``llama_index``
94
+ SDK versions. Events are accumulated in :attr:`events` and optionally
95
+ forwarded to an async exporter.
96
+
97
+ Args:
98
+ source: Value for ``Event.source`` (e.g. ``"rag-app@1.0.0"``).
99
+ org_id: Optional organisation identifier.
100
+ exporter: Optional async exporter with an ``export(event)``
101
+ coroutine method. Export is scheduled via ``create_task``
102
+ when the event loop is running.
103
+
104
+ Attributes:
105
+ events: List of all :class:`~spanforge.event.Event` objects emitted by
106
+ this handler in chronological order.
107
+ """
108
+
109
+ def __init__(
110
+ self,
111
+ source: str,
112
+ *,
113
+ org_id: str | None = None,
114
+ exporter: Any | None = None, # noqa: ANN401
115
+ ) -> None:
116
+ self._source = source
117
+ self._org_id = org_id
118
+ self._exporter = exporter
119
+ self.events: list[Event] = []
120
+ # Tracks start monotonic time by event_id
121
+ self._start_times: dict[str, float] = {}
122
+
123
+ # ------------------------------------------------------------------
124
+ # Static helpers
125
+ # ------------------------------------------------------------------
126
+
127
+ @staticmethod
128
+ def _cb_event_type_str(event_type: Any) -> str: # noqa: ANN401
129
+ """Convert a LlamaIndex CBEventType (or string) to a plain string.
130
+
131
+ If *event_type* has a ``value`` attribute (i.e. it is an ``Enum``),
132
+ the ``str`` representation of that value is returned. Otherwise the
133
+ object is converted to ``str`` directly.
134
+
135
+ Args:
136
+ event_type: A ``CBEventType`` enum member or plain string.
137
+
138
+ Returns:
139
+ The string representation of the event type.
140
+ """
141
+ if hasattr(event_type, "value"):
142
+ return str(event_type.value)
143
+ return str(event_type)
144
+
145
+ # ------------------------------------------------------------------
146
+ # Internal helpers
147
+ # ------------------------------------------------------------------
148
+
149
+ def _make_event(self, event_type: str, payload: dict[str, Any]) -> Event:
150
+ """Create a SpanForge event, append it, and optionally schedule async export.
151
+
152
+ Args:
153
+ event_type: Dotted event type string.
154
+ payload: Event payload dict.
155
+
156
+ Returns:
157
+ The newly created :class:`~spanforge.event.Event`.
158
+ """
159
+ event = Event(
160
+ event_type=event_type,
161
+ source=self._source,
162
+ org_id=self._org_id,
163
+ payload=payload,
164
+ event_id=gen_ulid(),
165
+ )
166
+ self.events.append(event)
167
+
168
+ if self._exporter is not None:
169
+ try:
170
+ loop = asyncio.get_running_loop()
171
+ loop.create_task(self._exporter.export(event)) # noqa: RUF006
172
+ except RuntimeError:
173
+ pass # no event loop configured
174
+
175
+ return event
176
+
177
+ def _duration_ms(self, event_id: str) -> float | None:
178
+ """Return elapsed milliseconds since *event_id* was started, or ``None``.
179
+
180
+ Args:
181
+ event_id: The event identifier recorded in :attr:`_start_times`.
182
+
183
+ Returns:
184
+ Duration in milliseconds, or ``None`` if *event_id* was not found.
185
+ """
186
+ start = self._start_times.pop(event_id, None)
187
+ if start is None:
188
+ return None
189
+ return (time.monotonic() - start) * 1000.0
190
+
191
+ def _start_event_payload(self, et: str, payload: dict[str, Any]) -> dict[str, Any]:
192
+ """Build the payload dict for a start event based on the event type."""
193
+ result: dict[str, Any] = {}
194
+ if et == "LLM":
195
+ model_dict = payload.get("model_dict") or {}
196
+ if isinstance(model_dict, dict):
197
+ result["model"] = model_dict.get("model")
198
+ elif et == "FUNCTION_CALL":
199
+ tool_info = payload.get("tool") or {}
200
+ if isinstance(tool_info, dict):
201
+ result["tool_name"] = tool_info.get("name")
202
+ elif et == "QUERY":
203
+ result["query"] = payload.get("query_str")
204
+ return result
205
+
206
+ def _end_event_payload(self, et: str, payload: dict[str, Any]) -> dict[str, Any]:
207
+ """Build the payload dict for an end event based on the event type."""
208
+ result: dict[str, Any] = {}
209
+ if et == "LLM":
210
+ response = payload.get("response")
211
+ raw = getattr(response, "raw", None) if response is not None else None
212
+ if isinstance(raw, dict):
213
+ usage = raw.get("usage") or {}
214
+ if isinstance(usage, dict):
215
+ result["prompt_tokens"] = usage.get("prompt_tokens")
216
+ result["completion_tokens"] = usage.get("completion_tokens")
217
+ result["total_tokens"] = usage.get("total_tokens")
218
+ elif et == "FUNCTION_CALL":
219
+ result["output"] = payload.get("output")
220
+ elif et == "QUERY":
221
+ result["response"] = str(payload.get("response", ""))[:2048]
222
+ return result
223
+
224
+ # ------------------------------------------------------------------
225
+ # LlamaIndex callback interface
226
+ # ------------------------------------------------------------------
227
+
228
+ def on_event_start(
229
+ self,
230
+ event_type: Any, # noqa: ANN401
231
+ *,
232
+ payload: dict[str, Any] | None = None,
233
+ event_id: str | None = None,
234
+ **kwargs: Any, # noqa: ANN401
235
+ ) -> str:
236
+ """Called when a LlamaIndex callback event begins.
237
+
238
+ Args:
239
+ event_type: A ``CBEventType`` enum member or plain string.
240
+ payload: Optional dict of event-specific data.
241
+ event_id: Optional identifier for this event; generated if absent.
242
+ **kwargs: Additional keyword arguments (ignored).
243
+
244
+ Returns:
245
+ The ``event_id`` (passed through or generated).
246
+ """
247
+ if event_id is None:
248
+ event_id = gen_ulid()
249
+
250
+ et = self._cb_event_type_str(event_type)
251
+ type_map = _CB_TYPE_MAP.get(et)
252
+ if type_map is None:
253
+ # Unknown event type — record start time but emit nothing.
254
+ self._start_times[event_id] = time.monotonic()
255
+ return event_id
256
+
257
+ self._start_times[event_id] = time.monotonic()
258
+
259
+ start_et, _ = type_map
260
+ payload = payload or {}
261
+ event_payload: dict[str, Any] = {"event_id": event_id, **self._start_event_payload(et, payload)}
262
+
263
+ self._make_event(start_et, event_payload)
264
+ return event_id
265
+
266
+ def on_event_end(
267
+ self,
268
+ event_type: Any, # noqa: ANN401
269
+ *,
270
+ payload: dict[str, Any] | None = None,
271
+ event_id: str | None = None,
272
+ **kwargs: Any, # noqa: ANN401
273
+ ) -> None:
274
+ """Called when a LlamaIndex callback event ends.
275
+
276
+ Args:
277
+ event_type: A ``CBEventType`` enum member or plain string.
278
+ payload: Optional dict of event-specific data (e.g. response).
279
+ event_id: Identifies which started event this ends.
280
+ **kwargs: Additional keyword arguments (ignored).
281
+ """
282
+ et = self._cb_event_type_str(event_type)
283
+ type_map = _CB_TYPE_MAP.get(et)
284
+ if type_map is None:
285
+ # Unknown event type — consume any pending start time silently.
286
+ if event_id:
287
+ self._start_times.pop(event_id, None)
288
+ return
289
+
290
+ _, end_et = type_map
291
+ duration: float | None = self._duration_ms(event_id) if event_id else None
292
+ payload = payload or {}
293
+ event_payload: dict[str, Any] = {
294
+ "event_id": event_id,
295
+ "duration_ms": duration,
296
+ **self._end_event_payload(et, payload),
297
+ }
298
+
299
+ self._make_event(end_et, event_payload)
300
+
301
+ def start_trace(self, trace_id: str | None = None, **kwargs: Any) -> None: # noqa: ANN401
302
+ """No-op — LlamaIndex trace lifecycle hook.
303
+
304
+ Args:
305
+ trace_id: Ignored.
306
+ **kwargs: Ignored.
307
+ """
308
+ pass # intentionally empty
309
+
310
+ def end_trace(
311
+ self,
312
+ trace_id: str | None = None,
313
+ trace_map: dict[str, Any] | None = None,
314
+ **kwargs: Any, # noqa: ANN401
315
+ ) -> None:
316
+ """No-op — LlamaIndex trace lifecycle hook.
317
+
318
+ Args:
319
+ trace_id: Ignored.
320
+ trace_map: Ignored.
321
+ **kwargs: Ignored.
322
+ """
323
+ pass # intentionally empty
324
+
325
+ def clear_events(self) -> None:
326
+ """Remove all accumulated events from :attr:`events`."""
327
+ self.events.clear()
328
+
329
+ # ------------------------------------------------------------------
330
+ # dunder
331
+ # ------------------------------------------------------------------
332
+
333
+ def __repr__(self) -> str:
334
+ return (
335
+ f"LLMSchemaEventHandler("
336
+ f"source={self._source!r}, "
337
+ f"events={len(self.events)})"
338
+ )
339
+
340
+
341
+ # ---------------------------------------------------------------------------
342
+ # Module-level patch / unpatch API (for API consistency with other integrations)
343
+ # ---------------------------------------------------------------------------
344
+
345
+ def is_patched() -> bool:
346
+ """Return ``True`` if the LlamaIndex integration module is importable.
347
+
348
+ LlamaIndex uses a callback-handler pattern rather than monkey-patching, so
349
+ there is no global state to check. This function simply verifies that the
350
+ LlamaIndex package is available.
351
+
352
+ Returns:
353
+ ``True`` when LlamaIndex is installed and the handler can be created.
354
+ """
355
+ try:
356
+ _require_llamaindex()
357
+ return True
358
+ except ImportError:
359
+ return False
360
+
361
+
362
+ def unpatch() -> None:
363
+ """No-op for the LlamaIndex integration.
364
+
365
+ LlamaIndex uses an explicit callback handler (not monkey-patching), so
366
+ there is nothing to undo globally. Remove the handler from your
367
+ ``CallbackManager`` to stop receiving events::
368
+
369
+ Settings.callback_manager = CallbackManager([]) # remove all handlers
370
+ """
@@ -0,0 +1,286 @@
1
+ """spanforge.integrations.ollama — Auto-instrumentation for the Ollama Python SDK.
2
+
3
+ This module monkey-patches the Ollama client so every
4
+ ``ollama.chat(...)`` (or ``client.chat(...)``) call automatically populates the
5
+ active :class:`~spanforge._span.Span` with:
6
+
7
+ * :class:`~spanforge.namespaces.trace.TokenUsage` (prompt / eval token counts
8
+ mapped to input / output)
9
+ * :class:`~spanforge.namespaces.trace.ModelInfo` (provider = ``ollama``, name
10
+ from response)
11
+ * :class:`~spanforge.namespaces.trace.CostBreakdown` (always zero — Ollama is a
12
+ local runtime with no per-token billing)
13
+
14
+ Usage::
15
+
16
+ from spanforge.integrations import ollama as ollama_integration
17
+ ollama_integration.patch()
18
+
19
+ import ollama
20
+
21
+ import spanforge
22
+ spanforge.configure(exporter="console")
23
+
24
+ with spanforge.span("ollama-chat", model="llama3") as span:
25
+ resp = ollama.chat(
26
+ model="llama3",
27
+ messages=[{"role": "user", "content": "Hello"}],
28
+ )
29
+ # → span.token_usage auto-populated on exit; cost is always zero
30
+
31
+ Calling ``patch()`` is **idempotent** — calling it multiple times has no
32
+ effect. Call :func:`unpatch` to restore the original functions.
33
+
34
+ Install with::
35
+
36
+ pip install "spanforge[ollama]"
37
+
38
+ .. note::
39
+ Ollama has no per-token pricing. :func:`normalize_response` always returns
40
+ :meth:`~spanforge.namespaces.trace.CostBreakdown.zero` for the cost field.
41
+ """
42
+
43
+ from __future__ import annotations
44
+
45
+ import functools
46
+ from typing import Any
47
+
48
+ from spanforge.namespaces.trace import (
49
+ CostBreakdown,
50
+ GenAISystem,
51
+ ModelInfo,
52
+ TokenUsage,
53
+ )
54
+
55
+ __all__ = [
56
+ "is_patched",
57
+ "normalize_response",
58
+ "patch",
59
+ "unpatch",
60
+ ]
61
+
62
+ # Sentinel to prevent double-patching.
63
+ _PATCH_FLAG = "_spanforge_patched"
64
+
65
+
66
+ # ---------------------------------------------------------------------------
67
+ # Public API
68
+ # ---------------------------------------------------------------------------
69
+
70
+
71
+ def patch() -> None:
72
+ """Monkey-patch the Ollama module to auto-instrument all chat calls.
73
+
74
+ Wraps the module-level ``ollama.chat`` function and, when instantiated,
75
+ ``ollama.Client.chat``. The wrapper calls :func:`normalize_response` on
76
+ the result and, if a span is currently active, updates it.
77
+
78
+ This function is **idempotent** — safe to call multiple times.
79
+
80
+ Raises:
81
+ ImportError: If the ``ollama`` package is not installed.
82
+ """
83
+ ollama_mod = _require_ollama()
84
+
85
+ if getattr(ollama_mod, _PATCH_FLAG, False):
86
+ return # already patched
87
+
88
+ # --- module-level ollama.chat --------------------------------------------
89
+ _orig_chat = getattr(ollama_mod, "chat", None)
90
+ if _orig_chat is not None:
91
+ @functools.wraps(_orig_chat)
92
+ def _patched_chat(*args: Any, **kwargs: Any) -> Any: # noqa: ANN401
93
+ response = _orig_chat(*args, **kwargs)
94
+ _auto_populate_span(response)
95
+ return response
96
+
97
+ ollama_mod.chat = _patched_chat # type: ignore[attr-defined]
98
+ ollama_mod._spanforge_orig_chat = _orig_chat # type: ignore[attr-defined]
99
+
100
+ # --- Client.chat ---------------------------------------------------------
101
+ try:
102
+ from ollama import Client # type: ignore[import-untyped] # noqa: PLC0415
103
+
104
+ _orig_client_chat = Client.chat # type: ignore[attr-defined]
105
+
106
+ @functools.wraps(_orig_client_chat)
107
+ def _patched_client_chat(self: Any, *args: Any, **kwargs: Any) -> Any: # noqa: ANN401
108
+ response = _orig_client_chat(self, *args, **kwargs)
109
+ _auto_populate_span(response)
110
+ return response
111
+
112
+ Client.chat = _patched_client_chat # type: ignore[method-assign]
113
+ Client._spanforge_orig_chat = _orig_client_chat # type: ignore[attr-defined]
114
+ except (ImportError, AttributeError): # pragma: no cover
115
+ pass
116
+
117
+ # --- AsyncClient.chat ----------------------------------------------------
118
+ try:
119
+ from ollama import AsyncClient # type: ignore[import-untyped] # noqa: PLC0415
120
+
121
+ _orig_async_chat = AsyncClient.chat # type: ignore[attr-defined]
122
+
123
+ @functools.wraps(_orig_async_chat)
124
+ async def _patched_async_chat(self: Any, *args: Any, **kwargs: Any) -> Any: # noqa: ANN401
125
+ response = await _orig_async_chat(self, *args, **kwargs)
126
+ _auto_populate_span(response)
127
+ return response
128
+
129
+ AsyncClient.chat = _patched_async_chat # type: ignore[method-assign]
130
+ AsyncClient._spanforge_orig_chat = _orig_async_chat # type: ignore[attr-defined]
131
+ except (ImportError, AttributeError): # pragma: no cover
132
+ pass
133
+
134
+ ollama_mod._spanforge_patched = True # type: ignore[attr-defined]
135
+
136
+
137
+ def unpatch() -> None:
138
+ """Restore the original Ollama functions and remove the patch flag.
139
+
140
+ Safe to call even if :func:`patch` was never called.
141
+
142
+ Raises:
143
+ ImportError: If the ``ollama`` package is not installed.
144
+ """
145
+ ollama_mod = _require_ollama()
146
+
147
+ if not getattr(ollama_mod, _PATCH_FLAG, False):
148
+ return # nothing to do
149
+
150
+ orig_chat = getattr(ollama_mod, "_spanforge_orig_chat", None)
151
+ if orig_chat is not None:
152
+ ollama_mod.chat = orig_chat # type: ignore[attr-defined]
153
+ del ollama_mod._spanforge_orig_chat # type: ignore[attr-defined]
154
+
155
+ try:
156
+ from ollama import Client # type: ignore[import-untyped] # noqa: PLC0415
157
+
158
+ Client.chat = Client._spanforge_orig_chat # type: ignore[attr-defined,method-assign]
159
+ del Client._spanforge_orig_chat # type: ignore[attr-defined]
160
+ except (ImportError, AttributeError): # pragma: no cover
161
+ pass
162
+
163
+ try:
164
+ from ollama import AsyncClient # type: ignore[import-untyped] # noqa: PLC0415
165
+
166
+ AsyncClient.chat = AsyncClient._spanforge_orig_chat # type: ignore[attr-defined,method-assign]
167
+ del AsyncClient._spanforge_orig_chat # type: ignore[attr-defined]
168
+ except (ImportError, AttributeError): # pragma: no cover
169
+ pass
170
+
171
+ try: # noqa: SIM105
172
+ del ollama_mod._spanforge_patched # type: ignore[attr-defined]
173
+ except AttributeError: # pragma: no cover
174
+ pass
175
+
176
+
177
+ def is_patched() -> bool:
178
+ """Return ``True`` if Ollama has been patched by spanforge.
179
+
180
+ Returns ``False`` if the ``ollama`` package is not installed.
181
+ """
182
+ try:
183
+ ollama_mod = _require_ollama()
184
+ return bool(getattr(ollama_mod, _PATCH_FLAG, False))
185
+ except ImportError:
186
+ return False
187
+
188
+
189
+ def normalize_response(
190
+ response: Any, # noqa: ANN401
191
+ ) -> tuple[TokenUsage, ModelInfo, CostBreakdown]:
192
+ """Extract structured observability data from an Ollama chat response.
193
+
194
+ Works with both ``ollama.ChatResponse`` objects and any duck-typed mock
195
+ with the same attribute structure.
196
+
197
+ Args:
198
+ response: An Ollama chat response (or compatible object).
199
+
200
+ Returns:
201
+ A 3-tuple of ``(TokenUsage, ModelInfo, CostBreakdown)``.
202
+ The ``CostBreakdown`` is **always zero** — Ollama has no billing.
203
+
204
+ Field mapping:
205
+
206
+ +--------------------------------------------+---------------------------+
207
+ | Ollama field | SpanForge field |
208
+ +============================================+===========================+
209
+ | ``response.model`` | ``ModelInfo.name`` |
210
+ | ``response.prompt_eval_count`` | ``TokenUsage.input_tokens``|
211
+ | ``response.eval_count`` | ``TokenUsage.output_tokens``|
212
+ +--------------------------------------------+---------------------------+
213
+ """
214
+ # Ollama may return a dict or an object depending on SDK version.
215
+ if isinstance(response, dict):
216
+ model_name: str = response.get("model", None) or "unknown"
217
+ input_tokens = int(response.get("prompt_eval_count", 0) or 0)
218
+ output_tokens = int(response.get("eval_count", 0) or 0)
219
+ else:
220
+ model_name = getattr(response, "model", None) or "unknown"
221
+ input_tokens = int(getattr(response, "prompt_eval_count", 0) or 0)
222
+ output_tokens = int(getattr(response, "eval_count", 0) or 0)
223
+
224
+ total_tokens = input_tokens + output_tokens
225
+
226
+ token_usage = TokenUsage(
227
+ input_tokens=input_tokens,
228
+ output_tokens=output_tokens,
229
+ total_tokens=total_tokens,
230
+ )
231
+
232
+ model_info = ModelInfo(system=GenAISystem.OLLAMA, name=model_name)
233
+
234
+ # Ollama is local — no billing data available.
235
+ cost = CostBreakdown.zero()
236
+
237
+ return token_usage, model_info, cost
238
+
239
+
240
+ # ---------------------------------------------------------------------------
241
+ # Internal helpers
242
+ # ---------------------------------------------------------------------------
243
+
244
+
245
+ def _require_ollama() -> Any: # noqa: ANN401
246
+ """Import and return the ``ollama`` module, raising ``ImportError`` if absent."""
247
+ try:
248
+ import ollama # type: ignore[import-untyped] # noqa: PLC0415
249
+ except ImportError as exc:
250
+ raise ImportError(
251
+ "The 'ollama' package is required for spanforge Ollama integration.\n"
252
+ "Install it with: pip install 'spanforge[ollama]'"
253
+ ) from exc
254
+ else:
255
+ return ollama
256
+
257
+
258
+ def _auto_populate_span(response: Any) -> None: # noqa: ANN401
259
+ """If there is an active span on this thread, populate it from *response*.
260
+
261
+ Silently does nothing if:
262
+
263
+ * There is no active span.
264
+ * ``normalize_response`` raises (malformed response).
265
+ * The span already has ``token_usage`` set (don't overwrite manual data).
266
+ """
267
+ try:
268
+ from spanforge._span import _span_stack # noqa: PLC0415
269
+
270
+ stack = _span_stack()
271
+ if not stack:
272
+ return
273
+ span = stack[-1]
274
+
275
+ if span.token_usage is not None:
276
+ return
277
+
278
+ token_usage, model_info, cost = normalize_response(response)
279
+ span.token_usage = token_usage
280
+ span.cost = cost
281
+
282
+ if span.model is None:
283
+ span.model = model_info.name
284
+
285
+ except Exception: # noqa: S110 # NOSONAR
286
+ pass