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.
- spanforge/__init__.py +815 -0
- spanforge/_ansi.py +93 -0
- spanforge/_batch_exporter.py +409 -0
- spanforge/_cli.py +2094 -0
- spanforge/_cli_audit.py +639 -0
- spanforge/_cli_compliance.py +711 -0
- spanforge/_cli_cost.py +243 -0
- spanforge/_cli_ops.py +791 -0
- spanforge/_cli_phase11.py +356 -0
- spanforge/_hooks.py +337 -0
- spanforge/_server.py +1708 -0
- spanforge/_span.py +1036 -0
- spanforge/_store.py +288 -0
- spanforge/_stream.py +664 -0
- spanforge/_trace.py +335 -0
- spanforge/_tracer.py +254 -0
- spanforge/actor.py +141 -0
- spanforge/alerts.py +469 -0
- spanforge/auto.py +464 -0
- spanforge/baseline.py +335 -0
- spanforge/cache.py +635 -0
- spanforge/compliance.py +325 -0
- spanforge/config.py +532 -0
- spanforge/consent.py +228 -0
- spanforge/consumer.py +377 -0
- spanforge/core/__init__.py +5 -0
- spanforge/core/compliance_mapping.py +1254 -0
- spanforge/cost.py +600 -0
- spanforge/debug.py +548 -0
- spanforge/deprecations.py +205 -0
- spanforge/drift.py +482 -0
- spanforge/egress.py +58 -0
- spanforge/eval.py +648 -0
- spanforge/event.py +1064 -0
- spanforge/exceptions.py +240 -0
- spanforge/explain.py +178 -0
- spanforge/export/__init__.py +69 -0
- spanforge/export/append_only.py +337 -0
- spanforge/export/cloud.py +357 -0
- spanforge/export/datadog.py +497 -0
- spanforge/export/grafana.py +320 -0
- spanforge/export/jsonl.py +195 -0
- spanforge/export/openinference.py +158 -0
- spanforge/export/otel_bridge.py +294 -0
- spanforge/export/otlp.py +811 -0
- spanforge/export/otlp_bridge.py +233 -0
- spanforge/export/redis_backend.py +282 -0
- spanforge/export/siem_schema.py +98 -0
- spanforge/export/siem_splunk.py +264 -0
- spanforge/export/siem_syslog.py +212 -0
- spanforge/export/webhook.py +299 -0
- spanforge/exporters/__init__.py +30 -0
- spanforge/exporters/console.py +271 -0
- spanforge/exporters/jsonl.py +144 -0
- spanforge/exporters/sqlite.py +142 -0
- spanforge/gate.py +1150 -0
- spanforge/governance.py +181 -0
- spanforge/hitl.py +295 -0
- spanforge/http.py +187 -0
- spanforge/inspect.py +427 -0
- spanforge/integrations/__init__.py +45 -0
- spanforge/integrations/_pricing.py +280 -0
- spanforge/integrations/anthropic.py +388 -0
- spanforge/integrations/azure_openai.py +133 -0
- spanforge/integrations/bedrock.py +292 -0
- spanforge/integrations/crewai.py +251 -0
- spanforge/integrations/gemini.py +351 -0
- spanforge/integrations/groq.py +442 -0
- spanforge/integrations/langchain.py +349 -0
- spanforge/integrations/langgraph.py +306 -0
- spanforge/integrations/llamaindex.py +373 -0
- spanforge/integrations/ollama.py +287 -0
- spanforge/integrations/openai.py +368 -0
- spanforge/integrations/together.py +483 -0
- spanforge/io.py +214 -0
- spanforge/lint.py +322 -0
- spanforge/metrics.py +417 -0
- spanforge/metrics_export.py +343 -0
- spanforge/migrate.py +402 -0
- spanforge/model_registry.py +278 -0
- spanforge/models.py +389 -0
- spanforge/namespaces/__init__.py +254 -0
- spanforge/namespaces/audit.py +256 -0
- spanforge/namespaces/cache.py +237 -0
- spanforge/namespaces/chain.py +77 -0
- spanforge/namespaces/confidence.py +72 -0
- spanforge/namespaces/consent.py +92 -0
- spanforge/namespaces/cost.py +179 -0
- spanforge/namespaces/decision.py +143 -0
- spanforge/namespaces/diff.py +157 -0
- spanforge/namespaces/drift.py +80 -0
- spanforge/namespaces/eval_.py +251 -0
- spanforge/namespaces/feedback.py +241 -0
- spanforge/namespaces/fence.py +193 -0
- spanforge/namespaces/guard.py +105 -0
- spanforge/namespaces/hitl.py +91 -0
- spanforge/namespaces/latency.py +72 -0
- spanforge/namespaces/prompt.py +190 -0
- spanforge/namespaces/redact.py +173 -0
- spanforge/namespaces/retrieval.py +379 -0
- spanforge/namespaces/runtime_governance.py +494 -0
- spanforge/namespaces/template.py +208 -0
- spanforge/namespaces/tool_call.py +77 -0
- spanforge/namespaces/trace.py +1029 -0
- spanforge/normalizer.py +171 -0
- spanforge/plugins.py +82 -0
- spanforge/presidio_backend.py +349 -0
- spanforge/processor.py +258 -0
- spanforge/prompt_registry.py +418 -0
- spanforge/py.typed +0 -0
- spanforge/redact.py +914 -0
- spanforge/regression.py +192 -0
- spanforge/runtime_policy.py +159 -0
- spanforge/sampling.py +511 -0
- spanforge/schema.py +183 -0
- spanforge/schemas/v1.0/schema.json +170 -0
- spanforge/schemas/v2.0/schema.json +536 -0
- spanforge/sdk/__init__.py +625 -0
- spanforge/sdk/_base.py +584 -0
- spanforge/sdk/_base.pyi +71 -0
- spanforge/sdk/_exceptions.py +1096 -0
- spanforge/sdk/_types.py +2184 -0
- spanforge/sdk/alert.py +1514 -0
- spanforge/sdk/alert.pyi +56 -0
- spanforge/sdk/audit.py +1196 -0
- spanforge/sdk/audit.pyi +67 -0
- spanforge/sdk/cec.py +1215 -0
- spanforge/sdk/cec.pyi +37 -0
- spanforge/sdk/config.py +641 -0
- spanforge/sdk/config.pyi +55 -0
- spanforge/sdk/enterprise.py +714 -0
- spanforge/sdk/enterprise.pyi +79 -0
- spanforge/sdk/explain.py +170 -0
- spanforge/sdk/fallback.py +432 -0
- spanforge/sdk/feedback.py +351 -0
- spanforge/sdk/gate.py +874 -0
- spanforge/sdk/gate.pyi +51 -0
- spanforge/sdk/identity.py +2114 -0
- spanforge/sdk/identity.pyi +47 -0
- spanforge/sdk/lineage.py +175 -0
- spanforge/sdk/observe.py +1065 -0
- spanforge/sdk/observe.pyi +50 -0
- spanforge/sdk/operator.py +338 -0
- spanforge/sdk/pii.py +1473 -0
- spanforge/sdk/pii.pyi +119 -0
- spanforge/sdk/pipelines.py +458 -0
- spanforge/sdk/pipelines.pyi +39 -0
- spanforge/sdk/policy.py +930 -0
- spanforge/sdk/rag.py +594 -0
- spanforge/sdk/rbac.py +280 -0
- spanforge/sdk/registry.py +430 -0
- spanforge/sdk/registry.pyi +46 -0
- spanforge/sdk/scope.py +279 -0
- spanforge/sdk/secrets.py +293 -0
- spanforge/sdk/secrets.pyi +25 -0
- spanforge/sdk/security.py +560 -0
- spanforge/sdk/security.pyi +57 -0
- spanforge/sdk/trust.py +472 -0
- spanforge/sdk/trust.pyi +41 -0
- spanforge/secrets.py +799 -0
- spanforge/signing.py +1179 -0
- spanforge/stats.py +100 -0
- spanforge/stream.py +560 -0
- spanforge/testing.py +378 -0
- spanforge/testing_mocks.py +1052 -0
- spanforge/trace.py +199 -0
- spanforge/types.py +696 -0
- spanforge/ulid.py +300 -0
- spanforge/validate.py +379 -0
- spanforge-1.0.0.dist-info/METADATA +1509 -0
- spanforge-1.0.0.dist-info/RECORD +174 -0
- spanforge-1.0.0.dist-info/WHEEL +4 -0
- spanforge-1.0.0.dist-info/entry_points.txt +5 -0
- 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
|