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,373 @@
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:
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
51
+
52
+ import llama_index.core
53
+ import llama_index.core.callbacks
54
+
55
+ return sys.modules["llama_index.core.callbacks"]
56
+ except ImportError:
57
+ pass
58
+ # Fall back to legacy llama_index package.
59
+ try:
60
+ import sys
61
+
62
+ import llama_index
63
+ import llama_index.callbacks
64
+
65
+ return sys.modules["llama_index.callbacks"]
66
+ except ImportError:
67
+ pass
68
+ raise ImportError(
69
+ "LlamaIndex package is required for the spanforge LlamaIndex integration.\n"
70
+ "Install it with: pip install 'spanforge[llamaindex]'"
71
+ )
72
+
73
+
74
+ # ---------------------------------------------------------------------------
75
+ # Event type mapping
76
+ # ---------------------------------------------------------------------------
77
+
78
+ #: Maps LlamaIndex CBEventType string → (start_event_type, end_event_type)
79
+ _CB_TYPE_MAP: dict[str, tuple[str, str]] = {
80
+ "LLM": ("llm.trace.span.started", "llm.trace.span.completed"),
81
+ "FUNCTION_CALL": ("llm.trace.tool_call.started", "llm.trace.tool_call.completed"),
82
+ "QUERY": ("llm.trace.query.started", "llm.trace.query.completed"),
83
+ "RETRIEVE": ("llm.trace.retrieve.started", "llm.trace.retrieve.completed"),
84
+ }
85
+
86
+
87
+ # ---------------------------------------------------------------------------
88
+ # Event handler
89
+ # ---------------------------------------------------------------------------
90
+
91
+
92
+ class LLMSchemaEventHandler:
93
+ """LlamaIndex callback handler that emits SpanForge events.
94
+
95
+ Compatible with both ``llama_index.core`` and legacy ``llama_index``
96
+ SDK versions. Events are accumulated in :attr:`events` and optionally
97
+ forwarded to an async exporter.
98
+
99
+ Args:
100
+ source: Value for ``Event.source`` (e.g. ``"rag-app@1.0.0"``).
101
+ org_id: Optional organisation identifier.
102
+ exporter: Optional async exporter with an ``export(event)``
103
+ coroutine method. Export is scheduled via ``create_task``
104
+ when the event loop is running.
105
+
106
+ Attributes:
107
+ events: List of all :class:`~spanforge.event.Event` objects emitted by
108
+ this handler in chronological order.
109
+ """
110
+
111
+ def __init__(
112
+ self,
113
+ source: str,
114
+ *,
115
+ org_id: str | None = None,
116
+ exporter: Any | None = None,
117
+ ) -> None:
118
+ self._source = source
119
+ self._org_id = org_id
120
+ self._exporter = exporter
121
+ self.events: list[Event] = []
122
+ # Tracks start monotonic time by event_id
123
+ self._start_times: dict[str, float] = {}
124
+
125
+ # ------------------------------------------------------------------
126
+ # Static helpers
127
+ # ------------------------------------------------------------------
128
+
129
+ @staticmethod
130
+ def _cb_event_type_str(event_type: Any) -> str:
131
+ """Convert a LlamaIndex CBEventType (or string) to a plain string.
132
+
133
+ If *event_type* has a ``value`` attribute (i.e. it is an ``Enum``),
134
+ the ``str`` representation of that value is returned. Otherwise the
135
+ object is converted to ``str`` directly.
136
+
137
+ Args:
138
+ event_type: A ``CBEventType`` enum member or plain string.
139
+
140
+ Returns:
141
+ The string representation of the event type.
142
+ """
143
+ if hasattr(event_type, "value"):
144
+ return str(event_type.value)
145
+ return str(event_type)
146
+
147
+ # ------------------------------------------------------------------
148
+ # Internal helpers
149
+ # ------------------------------------------------------------------
150
+
151
+ def _make_event(self, event_type: str, payload: dict[str, Any]) -> Event:
152
+ """Create a SpanForge event, append it, and optionally schedule async export.
153
+
154
+ Args:
155
+ event_type: Dotted event type string.
156
+ payload: Event payload dict.
157
+
158
+ Returns:
159
+ The newly created :class:`~spanforge.event.Event`.
160
+ """
161
+ event = Event(
162
+ event_type=event_type,
163
+ source=self._source,
164
+ org_id=self._org_id,
165
+ payload=payload,
166
+ event_id=gen_ulid(),
167
+ )
168
+ self.events.append(event)
169
+
170
+ if self._exporter is not None:
171
+ try:
172
+ loop = asyncio.get_running_loop()
173
+ loop.create_task(self._exporter.export(event))
174
+ except RuntimeError:
175
+ pass # no event loop configured
176
+
177
+ return event
178
+
179
+ def _duration_ms(self, event_id: str) -> float | None:
180
+ """Return elapsed milliseconds since *event_id* was started, or ``None``.
181
+
182
+ Args:
183
+ event_id: The event identifier recorded in :attr:`_start_times`.
184
+
185
+ Returns:
186
+ Duration in milliseconds, or ``None`` if *event_id* was not found.
187
+ """
188
+ start = self._start_times.pop(event_id, None)
189
+ if start is None:
190
+ return None
191
+ return (time.monotonic() - start) * 1000.0
192
+
193
+ def _start_event_payload(self, et: str, payload: dict[str, Any]) -> dict[str, Any]:
194
+ """Build the payload dict for a start event based on the event type."""
195
+ result: dict[str, Any] = {}
196
+ if et == "LLM":
197
+ model_dict = payload.get("model_dict") or {}
198
+ if isinstance(model_dict, dict):
199
+ result["model"] = model_dict.get("model")
200
+ elif et == "FUNCTION_CALL":
201
+ tool_info = payload.get("tool") or {}
202
+ if isinstance(tool_info, dict):
203
+ result["tool_name"] = tool_info.get("name")
204
+ elif et == "QUERY":
205
+ result["query"] = payload.get("query_str")
206
+ return result
207
+
208
+ def _end_event_payload(self, et: str, payload: dict[str, Any]) -> dict[str, Any]:
209
+ """Build the payload dict for an end event based on the event type."""
210
+ result: dict[str, Any] = {}
211
+ if et == "LLM":
212
+ response = payload.get("response")
213
+ raw = getattr(response, "raw", None) if response is not None else None
214
+ if isinstance(raw, dict):
215
+ usage = raw.get("usage") or {}
216
+ if isinstance(usage, dict):
217
+ result["prompt_tokens"] = usage.get("prompt_tokens")
218
+ result["completion_tokens"] = usage.get("completion_tokens")
219
+ result["total_tokens"] = usage.get("total_tokens")
220
+ elif et == "FUNCTION_CALL":
221
+ result["output"] = payload.get("output")
222
+ elif et == "QUERY":
223
+ result["response"] = str(payload.get("response", ""))[:2048]
224
+ return result
225
+
226
+ # ------------------------------------------------------------------
227
+ # LlamaIndex callback interface
228
+ # ------------------------------------------------------------------
229
+
230
+ def on_event_start(
231
+ self,
232
+ event_type: Any,
233
+ *,
234
+ payload: dict[str, Any] | None = None,
235
+ event_id: str | None = None,
236
+ **kwargs: Any,
237
+ ) -> str:
238
+ """Called when a LlamaIndex callback event begins.
239
+
240
+ Args:
241
+ event_type: A ``CBEventType`` enum member or plain string.
242
+ payload: Optional dict of event-specific data.
243
+ event_id: Optional identifier for this event; generated if absent.
244
+ **kwargs: Additional keyword arguments (ignored).
245
+
246
+ Returns:
247
+ The ``event_id`` (passed through or generated).
248
+ """
249
+ if event_id is None:
250
+ event_id = gen_ulid()
251
+
252
+ et = self._cb_event_type_str(event_type)
253
+ type_map = _CB_TYPE_MAP.get(et)
254
+ if type_map is None:
255
+ # Unknown event type — record start time but emit nothing.
256
+ self._start_times[event_id] = time.monotonic()
257
+ return event_id
258
+
259
+ self._start_times[event_id] = time.monotonic()
260
+
261
+ start_et, _ = type_map
262
+ payload = payload or {}
263
+ event_payload: dict[str, Any] = {
264
+ "event_id": event_id,
265
+ **self._start_event_payload(et, payload),
266
+ }
267
+
268
+ self._make_event(start_et, event_payload)
269
+ return event_id
270
+
271
+ def on_event_end(
272
+ self,
273
+ event_type: Any,
274
+ *,
275
+ payload: dict[str, Any] | None = None,
276
+ event_id: str | None = None,
277
+ **kwargs: Any,
278
+ ) -> None:
279
+ """Called when a LlamaIndex callback event ends.
280
+
281
+ Args:
282
+ event_type: A ``CBEventType`` enum member or plain string.
283
+ payload: Optional dict of event-specific data (e.g. response).
284
+ event_id: Identifies which started event this ends.
285
+ **kwargs: Additional keyword arguments (ignored).
286
+ """
287
+ et = self._cb_event_type_str(event_type)
288
+ type_map = _CB_TYPE_MAP.get(et)
289
+ if type_map is None:
290
+ # Unknown event type — consume any pending start time silently.
291
+ if event_id:
292
+ self._start_times.pop(event_id, None)
293
+ return
294
+
295
+ _, end_et = type_map
296
+ duration: float | None = self._duration_ms(event_id) if event_id else None
297
+ payload = payload or {}
298
+ event_payload: dict[str, Any] = {
299
+ "event_id": event_id,
300
+ "duration_ms": duration,
301
+ **self._end_event_payload(et, payload),
302
+ }
303
+
304
+ self._make_event(end_et, event_payload)
305
+
306
+ def start_trace(self, trace_id: str | None = None, **kwargs: Any) -> None:
307
+ """No-op — LlamaIndex trace lifecycle hook.
308
+
309
+ Args:
310
+ trace_id: Ignored.
311
+ **kwargs: Ignored.
312
+ """
313
+ pass # intentionally empty
314
+
315
+ def end_trace(
316
+ self,
317
+ trace_id: str | None = None,
318
+ trace_map: dict[str, Any] | None = None,
319
+ **kwargs: Any,
320
+ ) -> None:
321
+ """No-op — LlamaIndex trace lifecycle hook.
322
+
323
+ Args:
324
+ trace_id: Ignored.
325
+ trace_map: Ignored.
326
+ **kwargs: Ignored.
327
+ """
328
+ pass # intentionally empty
329
+
330
+ def clear_events(self) -> None:
331
+ """Remove all accumulated events from :attr:`events`."""
332
+ self.events.clear()
333
+
334
+ # ------------------------------------------------------------------
335
+ # dunder
336
+ # ------------------------------------------------------------------
337
+
338
+ def __repr__(self) -> str:
339
+ return f"LLMSchemaEventHandler(source={self._source!r}, events={len(self.events)})"
340
+
341
+
342
+ # ---------------------------------------------------------------------------
343
+ # Module-level patch / unpatch API (for API consistency with other integrations)
344
+ # ---------------------------------------------------------------------------
345
+
346
+
347
+ def is_patched() -> bool:
348
+ """Return ``True`` if the LlamaIndex integration module is importable.
349
+
350
+ LlamaIndex uses a callback-handler pattern rather than monkey-patching, so
351
+ there is no global state to check. This function simply verifies that the
352
+ LlamaIndex package is available.
353
+
354
+ Returns:
355
+ ``True`` when LlamaIndex is installed and the handler can be created.
356
+ """
357
+ try:
358
+ _require_llamaindex()
359
+ except ImportError:
360
+ return False
361
+ else:
362
+ return True
363
+
364
+
365
+ def unpatch() -> None:
366
+ """No-op for the LlamaIndex integration.
367
+
368
+ LlamaIndex uses an explicit callback handler (not monkey-patching), so
369
+ there is nothing to undo globally. Remove the handler from your
370
+ ``CallbackManager`` to stop receiving events::
371
+
372
+ Settings.callback_manager = CallbackManager([]) # remove all handlers
373
+ """
@@ -0,0 +1,287 @@
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
+
92
+ @functools.wraps(_orig_chat)
93
+ def _patched_chat(*args: Any, **kwargs: Any) -> Any:
94
+ response = _orig_chat(*args, **kwargs)
95
+ _auto_populate_span(response)
96
+ return response
97
+
98
+ ollama_mod.chat = _patched_chat # type: ignore[attr-defined]
99
+ ollama_mod._spanforge_orig_chat = _orig_chat # type: ignore[attr-defined]
100
+
101
+ # --- Client.chat ---------------------------------------------------------
102
+ try:
103
+ from ollama import Client # type: ignore[import-untyped]
104
+
105
+ _orig_client_chat = Client.chat # type: ignore[attr-defined]
106
+
107
+ @functools.wraps(_orig_client_chat)
108
+ def _patched_client_chat(self: Any, *args: Any, **kwargs: Any) -> Any:
109
+ response = _orig_client_chat(self, *args, **kwargs)
110
+ _auto_populate_span(response)
111
+ return response
112
+
113
+ Client.chat = _patched_client_chat # type: ignore[method-assign]
114
+ Client._spanforge_orig_chat = _orig_client_chat # type: ignore[attr-defined]
115
+ except (ImportError, AttributeError): # pragma: no cover
116
+ pass
117
+
118
+ # --- AsyncClient.chat ----------------------------------------------------
119
+ try:
120
+ from ollama import AsyncClient # type: ignore[import-untyped]
121
+
122
+ _orig_async_chat = AsyncClient.chat # type: ignore[attr-defined]
123
+
124
+ @functools.wraps(_orig_async_chat)
125
+ async def _patched_async_chat(self: Any, *args: Any, **kwargs: Any) -> Any:
126
+ response = await _orig_async_chat(self, *args, **kwargs)
127
+ _auto_populate_span(response)
128
+ return response
129
+
130
+ AsyncClient.chat = _patched_async_chat # type: ignore[method-assign]
131
+ AsyncClient._spanforge_orig_chat = _orig_async_chat # type: ignore[attr-defined]
132
+ except (ImportError, AttributeError): # pragma: no cover
133
+ pass
134
+
135
+ ollama_mod._spanforge_patched = True # type: ignore[attr-defined]
136
+
137
+
138
+ def unpatch() -> None:
139
+ """Restore the original Ollama functions and remove the patch flag.
140
+
141
+ Safe to call even if :func:`patch` was never called.
142
+
143
+ Raises:
144
+ ImportError: If the ``ollama`` package is not installed.
145
+ """
146
+ ollama_mod = _require_ollama()
147
+
148
+ if not getattr(ollama_mod, _PATCH_FLAG, False):
149
+ return # nothing to do
150
+
151
+ orig_chat = getattr(ollama_mod, "_spanforge_orig_chat", None)
152
+ if orig_chat is not None:
153
+ ollama_mod.chat = orig_chat # type: ignore[attr-defined]
154
+ del ollama_mod._spanforge_orig_chat # type: ignore[attr-defined]
155
+
156
+ try:
157
+ from ollama import Client # type: ignore[import-untyped]
158
+
159
+ Client.chat = Client._spanforge_orig_chat # type: ignore[attr-defined,method-assign]
160
+ del Client._spanforge_orig_chat # type: ignore[attr-defined]
161
+ except (ImportError, AttributeError): # pragma: no cover
162
+ pass
163
+
164
+ try:
165
+ from ollama import AsyncClient # type: ignore[import-untyped]
166
+
167
+ AsyncClient.chat = AsyncClient._spanforge_orig_chat # type: ignore[attr-defined,method-assign]
168
+ del AsyncClient._spanforge_orig_chat # type: ignore[attr-defined]
169
+ except (ImportError, AttributeError): # pragma: no cover
170
+ pass
171
+
172
+ try:
173
+ del ollama_mod._spanforge_patched # type: ignore[attr-defined]
174
+ except AttributeError: # pragma: no cover
175
+ pass
176
+
177
+
178
+ def is_patched() -> bool:
179
+ """Return ``True`` if Ollama has been patched by spanforge.
180
+
181
+ Returns ``False`` if the ``ollama`` package is not installed.
182
+ """
183
+ try:
184
+ ollama_mod = _require_ollama()
185
+ return bool(getattr(ollama_mod, _PATCH_FLAG, False))
186
+ except ImportError:
187
+ return False
188
+
189
+
190
+ def normalize_response(
191
+ response: Any,
192
+ ) -> tuple[TokenUsage, ModelInfo, CostBreakdown]:
193
+ """Extract structured observability data from an Ollama chat response.
194
+
195
+ Works with both ``ollama.ChatResponse`` objects and any duck-typed mock
196
+ with the same attribute structure.
197
+
198
+ Args:
199
+ response: An Ollama chat response (or compatible object).
200
+
201
+ Returns:
202
+ A 3-tuple of ``(TokenUsage, ModelInfo, CostBreakdown)``.
203
+ The ``CostBreakdown`` is **always zero** — Ollama has no billing.
204
+
205
+ Field mapping:
206
+
207
+ +--------------------------------------------+---------------------------+
208
+ | Ollama field | SpanForge field |
209
+ +============================================+===========================+
210
+ | ``response.model`` | ``ModelInfo.name`` |
211
+ | ``response.prompt_eval_count`` | ``TokenUsage.input_tokens``|
212
+ | ``response.eval_count`` | ``TokenUsage.output_tokens``|
213
+ +--------------------------------------------+---------------------------+
214
+ """
215
+ # Ollama may return a dict or an object depending on SDK version.
216
+ if isinstance(response, dict):
217
+ model_name: str = response.get("model", None) or "unknown"
218
+ input_tokens = int(response.get("prompt_eval_count", 0) or 0)
219
+ output_tokens = int(response.get("eval_count", 0) or 0)
220
+ else:
221
+ model_name = getattr(response, "model", None) or "unknown"
222
+ input_tokens = int(getattr(response, "prompt_eval_count", 0) or 0)
223
+ output_tokens = int(getattr(response, "eval_count", 0) or 0)
224
+
225
+ total_tokens = input_tokens + output_tokens
226
+
227
+ token_usage = TokenUsage(
228
+ input_tokens=input_tokens,
229
+ output_tokens=output_tokens,
230
+ total_tokens=total_tokens,
231
+ )
232
+
233
+ model_info = ModelInfo(system=GenAISystem.OLLAMA, name=model_name)
234
+
235
+ # Ollama is local — no billing data available.
236
+ cost = CostBreakdown.zero()
237
+
238
+ return token_usage, model_info, cost
239
+
240
+
241
+ # ---------------------------------------------------------------------------
242
+ # Internal helpers
243
+ # ---------------------------------------------------------------------------
244
+
245
+
246
+ def _require_ollama() -> Any:
247
+ """Import and return the ``ollama`` module, raising ``ImportError`` if absent."""
248
+ try:
249
+ import ollama # type: ignore[import-untyped]
250
+ except ImportError as exc:
251
+ raise ImportError(
252
+ "The 'ollama' package is required for spanforge Ollama integration.\n"
253
+ "Install it with: pip install 'spanforge[ollama]'"
254
+ ) from exc
255
+ else:
256
+ return ollama
257
+
258
+
259
+ def _auto_populate_span(response: Any) -> None:
260
+ """If there is an active span on this thread, populate it from *response*.
261
+
262
+ Silently does nothing if:
263
+
264
+ * There is no active span.
265
+ * ``normalize_response`` raises (malformed response).
266
+ * The span already has ``token_usage`` set (don't overwrite manual data).
267
+ """
268
+ try:
269
+ from spanforge._span import _span_stack
270
+
271
+ stack = _span_stack()
272
+ if not stack:
273
+ return
274
+ span = stack[-1]
275
+
276
+ if span.token_usage is not None:
277
+ return
278
+
279
+ token_usage, model_info, cost = normalize_response(response)
280
+ span.token_usage = token_usage
281
+ span.cost = cost
282
+
283
+ if span.model is None:
284
+ span.model = model_info.name
285
+
286
+ except Exception: # NOSONAR
287
+ return