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.
- spanforge/__init__.py +695 -0
- spanforge/_batch_exporter.py +322 -0
- spanforge/_cli.py +3081 -0
- spanforge/_hooks.py +340 -0
- spanforge/_server.py +953 -0
- spanforge/_span.py +1015 -0
- spanforge/_store.py +287 -0
- spanforge/_stream.py +654 -0
- spanforge/_trace.py +334 -0
- spanforge/_tracer.py +253 -0
- spanforge/actor.py +141 -0
- spanforge/alerts.py +464 -0
- spanforge/auto.py +181 -0
- spanforge/baseline.py +336 -0
- spanforge/config.py +460 -0
- spanforge/consent.py +227 -0
- spanforge/consumer.py +379 -0
- spanforge/core/__init__.py +5 -0
- spanforge/core/compliance_mapping.py +1060 -0
- spanforge/cost.py +597 -0
- spanforge/debug.py +514 -0
- spanforge/drift.py +488 -0
- spanforge/egress.py +63 -0
- spanforge/eval.py +575 -0
- spanforge/event.py +1052 -0
- spanforge/exceptions.py +246 -0
- spanforge/explain.py +181 -0
- spanforge/export/__init__.py +50 -0
- spanforge/export/append_only.py +342 -0
- spanforge/export/cloud.py +349 -0
- spanforge/export/datadog.py +495 -0
- spanforge/export/grafana.py +331 -0
- spanforge/export/jsonl.py +198 -0
- spanforge/export/otel_bridge.py +291 -0
- spanforge/export/otlp.py +817 -0
- spanforge/export/otlp_bridge.py +231 -0
- spanforge/export/redis_backend.py +282 -0
- spanforge/export/webhook.py +302 -0
- spanforge/exporters/__init__.py +29 -0
- spanforge/exporters/console.py +271 -0
- spanforge/exporters/jsonl.py +144 -0
- spanforge/hitl.py +297 -0
- spanforge/inspect.py +429 -0
- spanforge/integrations/__init__.py +39 -0
- spanforge/integrations/_pricing.py +277 -0
- spanforge/integrations/anthropic.py +388 -0
- spanforge/integrations/bedrock.py +306 -0
- spanforge/integrations/crewai.py +251 -0
- spanforge/integrations/gemini.py +349 -0
- spanforge/integrations/groq.py +444 -0
- spanforge/integrations/langchain.py +349 -0
- spanforge/integrations/llamaindex.py +370 -0
- spanforge/integrations/ollama.py +286 -0
- spanforge/integrations/openai.py +370 -0
- spanforge/integrations/together.py +485 -0
- spanforge/metrics.py +393 -0
- spanforge/metrics_export.py +342 -0
- spanforge/migrate.py +278 -0
- spanforge/model_registry.py +282 -0
- spanforge/models.py +407 -0
- spanforge/namespaces/__init__.py +215 -0
- spanforge/namespaces/audit.py +253 -0
- spanforge/namespaces/cache.py +209 -0
- spanforge/namespaces/chain.py +74 -0
- spanforge/namespaces/confidence.py +69 -0
- spanforge/namespaces/consent.py +85 -0
- spanforge/namespaces/cost.py +175 -0
- spanforge/namespaces/decision.py +135 -0
- spanforge/namespaces/diff.py +146 -0
- spanforge/namespaces/drift.py +79 -0
- spanforge/namespaces/eval_.py +232 -0
- spanforge/namespaces/fence.py +180 -0
- spanforge/namespaces/guard.py +104 -0
- spanforge/namespaces/hitl.py +92 -0
- spanforge/namespaces/latency.py +69 -0
- spanforge/namespaces/prompt.py +185 -0
- spanforge/namespaces/redact.py +172 -0
- spanforge/namespaces/template.py +197 -0
- spanforge/namespaces/tool_call.py +76 -0
- spanforge/namespaces/trace.py +1006 -0
- spanforge/normalizer.py +183 -0
- spanforge/presidio_backend.py +149 -0
- spanforge/processor.py +258 -0
- spanforge/prompt_registry.py +415 -0
- spanforge/py.typed +0 -0
- spanforge/redact.py +780 -0
- spanforge/sampling.py +500 -0
- spanforge/schemas/v1.0/schema.json +170 -0
- spanforge/schemas/v2.0/schema.json +536 -0
- spanforge/signing.py +1152 -0
- spanforge/stream.py +559 -0
- spanforge/testing.py +376 -0
- spanforge/trace.py +199 -0
- spanforge/types.py +696 -0
- spanforge/ulid.py +304 -0
- spanforge/validate.py +383 -0
- spanforge-2.0.0.dist-info/METADATA +1777 -0
- spanforge-2.0.0.dist-info/RECORD +101 -0
- spanforge-2.0.0.dist-info/WHEEL +4 -0
- spanforge-2.0.0.dist-info/entry_points.txt +5 -0
- spanforge-2.0.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,349 @@
|
|
|
1
|
+
"""spanforge.integrations.gemini — Auto-instrumentation for the Google Generative AI SDK.
|
|
2
|
+
|
|
3
|
+
This module monkey-patches the Google ``generativeai`` client so every
|
|
4
|
+
``model.generate_content(...)`` call automatically populates the
|
|
5
|
+
active :class:`~spanforge._span.Span` with:
|
|
6
|
+
|
|
7
|
+
* :class:`~spanforge.namespaces.trace.TokenUsage` (prompt / candidate token counts)
|
|
8
|
+
* :class:`~spanforge.namespaces.trace.ModelInfo` (provider = ``google``, model name)
|
|
9
|
+
* :class:`~spanforge.namespaces.trace.CostBreakdown` (computed from the static
|
|
10
|
+
pricing table below)
|
|
11
|
+
|
|
12
|
+
Usage::
|
|
13
|
+
|
|
14
|
+
from spanforge.integrations import gemini as gemini_integration
|
|
15
|
+
gemini_integration.patch()
|
|
16
|
+
|
|
17
|
+
import google.generativeai as genai
|
|
18
|
+
genai.configure(api_key="...")
|
|
19
|
+
|
|
20
|
+
import spanforge
|
|
21
|
+
spanforge.configure(exporter="console")
|
|
22
|
+
|
|
23
|
+
with spanforge.span("gemini-chat", model="gemini-1.5-pro") as span:
|
|
24
|
+
model = genai.GenerativeModel("gemini-1.5-pro")
|
|
25
|
+
resp = model.generate_content("Hello")
|
|
26
|
+
# → span.token_usage and span.cost auto-populated on exit
|
|
27
|
+
|
|
28
|
+
Calling ``patch()`` is **idempotent** — calling it multiple times has no
|
|
29
|
+
effect. Call :func:`unpatch` to restore the original methods.
|
|
30
|
+
|
|
31
|
+
Install with::
|
|
32
|
+
|
|
33
|
+
pip install "spanforge[gemini]"
|
|
34
|
+
"""
|
|
35
|
+
|
|
36
|
+
from __future__ import annotations
|
|
37
|
+
|
|
38
|
+
import functools
|
|
39
|
+
from typing import Any
|
|
40
|
+
|
|
41
|
+
from spanforge.namespaces.trace import (
|
|
42
|
+
CostBreakdown,
|
|
43
|
+
GenAISystem,
|
|
44
|
+
ModelInfo,
|
|
45
|
+
TokenUsage,
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
__all__ = [
|
|
49
|
+
"is_patched",
|
|
50
|
+
"normalize_response",
|
|
51
|
+
"patch",
|
|
52
|
+
"unpatch",
|
|
53
|
+
]
|
|
54
|
+
|
|
55
|
+
# ---------------------------------------------------------------------------
|
|
56
|
+
# Static pricing table (USD per million tokens, effective 2026-03-04)
|
|
57
|
+
# ---------------------------------------------------------------------------
|
|
58
|
+
|
|
59
|
+
PRICING_DATE: str = "2026-03-04"
|
|
60
|
+
|
|
61
|
+
#: Google Gemini model pricing — USD per million tokens.
|
|
62
|
+
GEMINI_PRICING: dict[str, dict[str, float]] = {
|
|
63
|
+
# ------------------------------------------------------------------
|
|
64
|
+
# Gemini 2.0
|
|
65
|
+
# ------------------------------------------------------------------
|
|
66
|
+
"gemini-2.0-flash": {
|
|
67
|
+
"input": 0.10,
|
|
68
|
+
"output": 0.40,
|
|
69
|
+
},
|
|
70
|
+
"gemini-2.0-flash-lite": {
|
|
71
|
+
"input": 0.075,
|
|
72
|
+
"output": 0.30,
|
|
73
|
+
},
|
|
74
|
+
# ------------------------------------------------------------------
|
|
75
|
+
# Gemini 1.5 family
|
|
76
|
+
# ------------------------------------------------------------------
|
|
77
|
+
"gemini-1.5-pro": {
|
|
78
|
+
"input": 1.25,
|
|
79
|
+
"output": 5.00,
|
|
80
|
+
},
|
|
81
|
+
"gemini-1.5-pro-latest": {
|
|
82
|
+
"input": 1.25,
|
|
83
|
+
"output": 5.00,
|
|
84
|
+
},
|
|
85
|
+
"gemini-1.5-flash": {
|
|
86
|
+
"input": 0.075,
|
|
87
|
+
"output": 0.30,
|
|
88
|
+
},
|
|
89
|
+
"gemini-1.5-flash-latest": {
|
|
90
|
+
"input": 0.075,
|
|
91
|
+
"output": 0.30,
|
|
92
|
+
},
|
|
93
|
+
"gemini-1.5-flash-8b": {
|
|
94
|
+
"input": 0.0375,
|
|
95
|
+
"output": 0.15,
|
|
96
|
+
},
|
|
97
|
+
# ------------------------------------------------------------------
|
|
98
|
+
# Gemini 1.0 family
|
|
99
|
+
# ------------------------------------------------------------------
|
|
100
|
+
"gemini-1.0-pro": {
|
|
101
|
+
"input": 0.50,
|
|
102
|
+
"output": 1.50,
|
|
103
|
+
},
|
|
104
|
+
"gemini-pro": {
|
|
105
|
+
"input": 0.50,
|
|
106
|
+
"output": 1.50,
|
|
107
|
+
},
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
# Sentinel attribute set on the genai module to prevent double-patching.
|
|
111
|
+
_PATCH_FLAG = "_spanforge_patched"
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
# ---------------------------------------------------------------------------
|
|
115
|
+
# Public API
|
|
116
|
+
# ---------------------------------------------------------------------------
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def patch() -> None:
|
|
120
|
+
"""Monkey-patch the Google Generative AI client to auto-instrument.
|
|
121
|
+
|
|
122
|
+
Wraps ``generativeai.GenerativeModel.generate_content`` (sync) and
|
|
123
|
+
``generate_content_async`` (async). The wrapper calls
|
|
124
|
+
:func:`normalize_response` on the result and, if a span is currently
|
|
125
|
+
active, updates it with token usage, model info, and cost.
|
|
126
|
+
|
|
127
|
+
This function is **idempotent** — safe to call multiple times.
|
|
128
|
+
|
|
129
|
+
Raises:
|
|
130
|
+
ImportError: If the ``google-generativeai`` package is not installed.
|
|
131
|
+
"""
|
|
132
|
+
genai_mod = _require_genai()
|
|
133
|
+
|
|
134
|
+
if getattr(genai_mod, _PATCH_FLAG, False):
|
|
135
|
+
return # already patched
|
|
136
|
+
|
|
137
|
+
# --- sync ----------------------------------------------------------------
|
|
138
|
+
try:
|
|
139
|
+
GenerativeModel = genai_mod.GenerativeModel # noqa: N806
|
|
140
|
+
|
|
141
|
+
_orig_sync = GenerativeModel.generate_content
|
|
142
|
+
|
|
143
|
+
@functools.wraps(_orig_sync)
|
|
144
|
+
def _patched_sync(self: Any, *args: Any, **kwargs: Any) -> Any: # noqa: ANN401
|
|
145
|
+
response = _orig_sync(self, *args, **kwargs)
|
|
146
|
+
_auto_populate_span(response, model_name=getattr(self, "model_name", None))
|
|
147
|
+
return response
|
|
148
|
+
|
|
149
|
+
GenerativeModel.generate_content = _patched_sync
|
|
150
|
+
GenerativeModel._spanforge_orig_generate_content = _orig_sync
|
|
151
|
+
except (ImportError, AttributeError): # pragma: no cover
|
|
152
|
+
pass
|
|
153
|
+
|
|
154
|
+
# --- async ---------------------------------------------------------------
|
|
155
|
+
try:
|
|
156
|
+
GenerativeModel = genai_mod.GenerativeModel # noqa: N806
|
|
157
|
+
|
|
158
|
+
_orig_async = GenerativeModel.generate_content_async
|
|
159
|
+
|
|
160
|
+
@functools.wraps(_orig_async)
|
|
161
|
+
async def _patched_async(self: Any, *args: Any, **kwargs: Any) -> Any: # noqa: ANN401
|
|
162
|
+
response = await _orig_async(self, *args, **kwargs)
|
|
163
|
+
_auto_populate_span(response, model_name=getattr(self, "model_name", None))
|
|
164
|
+
return response
|
|
165
|
+
|
|
166
|
+
GenerativeModel.generate_content_async = _patched_async
|
|
167
|
+
GenerativeModel._spanforge_orig_generate_content_async = _orig_async
|
|
168
|
+
except (ImportError, AttributeError): # pragma: no cover
|
|
169
|
+
pass
|
|
170
|
+
|
|
171
|
+
genai_mod._spanforge_patched = True # type: ignore[attr-defined]
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def unpatch() -> None:
|
|
175
|
+
"""Restore the original Google Generative AI methods.
|
|
176
|
+
|
|
177
|
+
Safe to call even if :func:`patch` was never called.
|
|
178
|
+
|
|
179
|
+
Raises:
|
|
180
|
+
ImportError: If the ``google-generativeai`` package is not installed.
|
|
181
|
+
"""
|
|
182
|
+
genai_mod = _require_genai()
|
|
183
|
+
|
|
184
|
+
if not getattr(genai_mod, _PATCH_FLAG, False):
|
|
185
|
+
return # nothing to do
|
|
186
|
+
|
|
187
|
+
try:
|
|
188
|
+
GenerativeModel = genai_mod.GenerativeModel # noqa: N806
|
|
189
|
+
if hasattr(GenerativeModel, "_spanforge_orig_generate_content"):
|
|
190
|
+
GenerativeModel.generate_content = GenerativeModel._spanforge_orig_generate_content
|
|
191
|
+
del GenerativeModel._spanforge_orig_generate_content
|
|
192
|
+
if hasattr(GenerativeModel, "_spanforge_orig_generate_content_async"):
|
|
193
|
+
GenerativeModel.generate_content_async = GenerativeModel._spanforge_orig_generate_content_async
|
|
194
|
+
del GenerativeModel._spanforge_orig_generate_content_async
|
|
195
|
+
except (ImportError, AttributeError): # pragma: no cover
|
|
196
|
+
pass
|
|
197
|
+
|
|
198
|
+
try: # noqa: SIM105
|
|
199
|
+
del genai_mod._spanforge_patched # type: ignore[attr-defined]
|
|
200
|
+
except AttributeError: # pragma: no cover
|
|
201
|
+
pass
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
def is_patched() -> bool:
|
|
205
|
+
"""Return ``True`` if the Google Generative AI client has been patched.
|
|
206
|
+
|
|
207
|
+
Returns ``False`` if the ``google-generativeai`` package is not installed.
|
|
208
|
+
"""
|
|
209
|
+
try:
|
|
210
|
+
genai_mod = _require_genai()
|
|
211
|
+
return bool(getattr(genai_mod, _PATCH_FLAG, False))
|
|
212
|
+
except ImportError:
|
|
213
|
+
return False
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
def normalize_response(
|
|
217
|
+
response: Any, # noqa: ANN401
|
|
218
|
+
*,
|
|
219
|
+
model_name: str | None = None,
|
|
220
|
+
) -> tuple[TokenUsage, ModelInfo, CostBreakdown]:
|
|
221
|
+
"""Extract structured observability data from a Gemini response.
|
|
222
|
+
|
|
223
|
+
Works with ``google.generativeai.types.GenerateContentResponse`` objects
|
|
224
|
+
and any duck-typed mock with the same attribute structure.
|
|
225
|
+
|
|
226
|
+
Args:
|
|
227
|
+
response: A Gemini ``GenerateContentResponse`` (or compatible).
|
|
228
|
+
model_name: Optional model name override (from the GenerativeModel).
|
|
229
|
+
|
|
230
|
+
Returns:
|
|
231
|
+
A 3-tuple of ``(TokenUsage, ModelInfo, CostBreakdown)``.
|
|
232
|
+
"""
|
|
233
|
+
# ------------------------------------------------------------------ usage
|
|
234
|
+
usage_meta = getattr(response, "usage_metadata", None)
|
|
235
|
+
input_tokens: int = 0
|
|
236
|
+
output_tokens: int = 0
|
|
237
|
+
cached_tokens: int | None = None
|
|
238
|
+
|
|
239
|
+
if usage_meta is not None:
|
|
240
|
+
input_tokens = int(getattr(usage_meta, "prompt_token_count", 0) or 0)
|
|
241
|
+
output_tokens = int(getattr(usage_meta, "candidates_token_count", 0) or 0)
|
|
242
|
+
ct = getattr(usage_meta, "cached_content_token_count", None)
|
|
243
|
+
if ct is not None:
|
|
244
|
+
cached_tokens = int(ct)
|
|
245
|
+
|
|
246
|
+
total_tokens = input_tokens + output_tokens
|
|
247
|
+
|
|
248
|
+
token_usage = TokenUsage(
|
|
249
|
+
input_tokens=input_tokens,
|
|
250
|
+
output_tokens=output_tokens,
|
|
251
|
+
total_tokens=total_tokens,
|
|
252
|
+
cached_tokens=cached_tokens,
|
|
253
|
+
)
|
|
254
|
+
|
|
255
|
+
# ---------------------------------------------------------------- model
|
|
256
|
+
name = model_name or "unknown"
|
|
257
|
+
# Strip "models/" prefix if present (Gemini SDK convention)
|
|
258
|
+
if name.startswith("models/"):
|
|
259
|
+
name = name[7:]
|
|
260
|
+
model_info = ModelInfo(system=GenAISystem.GOOGLE, name=name)
|
|
261
|
+
|
|
262
|
+
# ----------------------------------------------------------------- cost
|
|
263
|
+
cost = _compute_cost(name, input_tokens, output_tokens)
|
|
264
|
+
|
|
265
|
+
return token_usage, model_info, cost
|
|
266
|
+
|
|
267
|
+
|
|
268
|
+
def list_models() -> list[str]:
|
|
269
|
+
"""Return a sorted list of all Gemini model names in the pricing table."""
|
|
270
|
+
return sorted(GEMINI_PRICING.keys())
|
|
271
|
+
|
|
272
|
+
|
|
273
|
+
# ---------------------------------------------------------------------------
|
|
274
|
+
# Internal helpers
|
|
275
|
+
# ---------------------------------------------------------------------------
|
|
276
|
+
|
|
277
|
+
|
|
278
|
+
def _require_genai() -> Any: # noqa: ANN401
|
|
279
|
+
"""Import and return the ``google.generativeai`` module."""
|
|
280
|
+
try:
|
|
281
|
+
import google.generativeai as genai # type: ignore[import-untyped] # noqa: PLC0415
|
|
282
|
+
except ImportError as exc:
|
|
283
|
+
raise ImportError(
|
|
284
|
+
"The 'google-generativeai' package is required for spanforge Gemini integration.\n"
|
|
285
|
+
"Install it with: pip install 'spanforge[gemini]'"
|
|
286
|
+
) from exc
|
|
287
|
+
else:
|
|
288
|
+
return genai
|
|
289
|
+
|
|
290
|
+
|
|
291
|
+
def _get_pricing(model: str) -> dict[str, float] | None:
|
|
292
|
+
"""Return the pricing entry for *model*, or ``None`` if unknown."""
|
|
293
|
+
if model in GEMINI_PRICING:
|
|
294
|
+
return GEMINI_PRICING[model]
|
|
295
|
+
|
|
296
|
+
# Try prefix match (strip trailing version date)
|
|
297
|
+
parts = model.rsplit("-", 2)
|
|
298
|
+
for i in range(len(parts) - 1, 0, -1):
|
|
299
|
+
candidate = "-".join(parts[:i])
|
|
300
|
+
if candidate in GEMINI_PRICING:
|
|
301
|
+
return GEMINI_PRICING[candidate]
|
|
302
|
+
|
|
303
|
+
return None
|
|
304
|
+
|
|
305
|
+
|
|
306
|
+
def _compute_cost(
|
|
307
|
+
model_name: str,
|
|
308
|
+
input_tokens: int,
|
|
309
|
+
output_tokens: int,
|
|
310
|
+
) -> CostBreakdown:
|
|
311
|
+
"""Compute :class:`~spanforge.namespaces.trace.CostBreakdown` from token counts."""
|
|
312
|
+
pricing = _get_pricing(model_name)
|
|
313
|
+
if pricing is None:
|
|
314
|
+
return CostBreakdown.zero()
|
|
315
|
+
|
|
316
|
+
input_cost = input_tokens * pricing["input"] / 1_000_000.0
|
|
317
|
+
output_cost = output_tokens * pricing["output"] / 1_000_000.0
|
|
318
|
+
total = input_cost + output_cost
|
|
319
|
+
|
|
320
|
+
return CostBreakdown(
|
|
321
|
+
input_cost_usd=input_cost,
|
|
322
|
+
output_cost_usd=output_cost,
|
|
323
|
+
total_cost_usd=total,
|
|
324
|
+
pricing_date=PRICING_DATE,
|
|
325
|
+
)
|
|
326
|
+
|
|
327
|
+
|
|
328
|
+
def _auto_populate_span(response: Any, *, model_name: str | None = None) -> None: # noqa: ANN401
|
|
329
|
+
"""If there is an active span, populate it from *response*."""
|
|
330
|
+
try:
|
|
331
|
+
from spanforge._span import _span_stack # noqa: PLC0415
|
|
332
|
+
|
|
333
|
+
stack = _span_stack()
|
|
334
|
+
if not stack:
|
|
335
|
+
return
|
|
336
|
+
span = stack[-1]
|
|
337
|
+
|
|
338
|
+
if span.token_usage is not None:
|
|
339
|
+
return
|
|
340
|
+
|
|
341
|
+
token_usage, model_info, cost = normalize_response(response, model_name=model_name)
|
|
342
|
+
span.token_usage = token_usage
|
|
343
|
+
span.cost = cost
|
|
344
|
+
|
|
345
|
+
if span.model is None:
|
|
346
|
+
span.model = model_info.name
|
|
347
|
+
|
|
348
|
+
except Exception: # noqa: S110 # NOSONAR
|
|
349
|
+
pass
|