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,351 @@
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
140
+
141
+ _orig_sync = GenerativeModel.generate_content
142
+
143
+ @functools.wraps(_orig_sync)
144
+ def _patched_sync(self: Any, *args: Any, **kwargs: Any) -> Any:
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
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:
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
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 = (
194
+ GenerativeModel._spanforge_orig_generate_content_async
195
+ )
196
+ del GenerativeModel._spanforge_orig_generate_content_async
197
+ except (ImportError, AttributeError): # pragma: no cover
198
+ pass
199
+
200
+ try:
201
+ del genai_mod._spanforge_patched # type: ignore[attr-defined]
202
+ except AttributeError: # pragma: no cover
203
+ pass
204
+
205
+
206
+ def is_patched() -> bool:
207
+ """Return ``True`` if the Google Generative AI client has been patched.
208
+
209
+ Returns ``False`` if the ``google-generativeai`` package is not installed.
210
+ """
211
+ try:
212
+ genai_mod = _require_genai()
213
+ return bool(getattr(genai_mod, _PATCH_FLAG, False))
214
+ except ImportError:
215
+ return False
216
+
217
+
218
+ def normalize_response(
219
+ response: Any,
220
+ *,
221
+ model_name: str | None = None,
222
+ ) -> tuple[TokenUsage, ModelInfo, CostBreakdown]:
223
+ """Extract structured observability data from a Gemini response.
224
+
225
+ Works with ``google.generativeai.types.GenerateContentResponse`` objects
226
+ and any duck-typed mock with the same attribute structure.
227
+
228
+ Args:
229
+ response: A Gemini ``GenerateContentResponse`` (or compatible).
230
+ model_name: Optional model name override (from the GenerativeModel).
231
+
232
+ Returns:
233
+ A 3-tuple of ``(TokenUsage, ModelInfo, CostBreakdown)``.
234
+ """
235
+ # ------------------------------------------------------------------ usage
236
+ usage_meta = getattr(response, "usage_metadata", None)
237
+ input_tokens: int = 0
238
+ output_tokens: int = 0
239
+ cached_tokens: int | None = None
240
+
241
+ if usage_meta is not None:
242
+ input_tokens = int(getattr(usage_meta, "prompt_token_count", 0) or 0)
243
+ output_tokens = int(getattr(usage_meta, "candidates_token_count", 0) or 0)
244
+ ct = getattr(usage_meta, "cached_content_token_count", None)
245
+ if ct is not None:
246
+ cached_tokens = int(ct)
247
+
248
+ total_tokens = input_tokens + output_tokens
249
+
250
+ token_usage = TokenUsage(
251
+ input_tokens=input_tokens,
252
+ output_tokens=output_tokens,
253
+ total_tokens=total_tokens,
254
+ cached_tokens=cached_tokens,
255
+ )
256
+
257
+ # ---------------------------------------------------------------- model
258
+ name = model_name or "unknown"
259
+ # Strip "models/" prefix if present (Gemini SDK convention)
260
+ if name.startswith("models/"):
261
+ name = name[7:]
262
+ model_info = ModelInfo(system=GenAISystem.GOOGLE, name=name)
263
+
264
+ # ----------------------------------------------------------------- cost
265
+ cost = _compute_cost(name, input_tokens, output_tokens)
266
+
267
+ return token_usage, model_info, cost
268
+
269
+
270
+ def list_models() -> list[str]:
271
+ """Return a sorted list of all Gemini model names in the pricing table."""
272
+ return sorted(GEMINI_PRICING.keys())
273
+
274
+
275
+ # ---------------------------------------------------------------------------
276
+ # Internal helpers
277
+ # ---------------------------------------------------------------------------
278
+
279
+
280
+ def _require_genai() -> Any:
281
+ """Import and return the ``google.generativeai`` module."""
282
+ try:
283
+ import google.generativeai as genai # type: ignore[import-untyped]
284
+ except ImportError as exc:
285
+ raise ImportError(
286
+ "The 'google-generativeai' package is required for spanforge Gemini integration.\n"
287
+ "Install it with: pip install 'spanforge[gemini]'"
288
+ ) from exc
289
+ else:
290
+ return genai
291
+
292
+
293
+ def _get_pricing(model: str) -> dict[str, float] | None:
294
+ """Return the pricing entry for *model*, or ``None`` if unknown."""
295
+ if model in GEMINI_PRICING:
296
+ return GEMINI_PRICING[model]
297
+
298
+ # Try prefix match (strip trailing version date)
299
+ parts = model.rsplit("-", 2)
300
+ for i in range(len(parts) - 1, 0, -1):
301
+ candidate = "-".join(parts[:i])
302
+ if candidate in GEMINI_PRICING:
303
+ return GEMINI_PRICING[candidate]
304
+
305
+ return None
306
+
307
+
308
+ def _compute_cost(
309
+ model_name: str,
310
+ input_tokens: int,
311
+ output_tokens: int,
312
+ ) -> CostBreakdown:
313
+ """Compute :class:`~spanforge.namespaces.trace.CostBreakdown` from token counts."""
314
+ pricing = _get_pricing(model_name)
315
+ if pricing is None:
316
+ return CostBreakdown.zero()
317
+
318
+ input_cost = input_tokens * pricing["input"] / 1_000_000.0
319
+ output_cost = output_tokens * pricing["output"] / 1_000_000.0
320
+ total = input_cost + output_cost
321
+
322
+ return CostBreakdown(
323
+ input_cost_usd=input_cost,
324
+ output_cost_usd=output_cost,
325
+ total_cost_usd=total,
326
+ pricing_date=PRICING_DATE,
327
+ )
328
+
329
+
330
+ def _auto_populate_span(response: Any, *, model_name: str | None = None) -> None:
331
+ """If there is an active span, populate it from *response*."""
332
+ try:
333
+ from spanforge._span import _span_stack
334
+
335
+ stack = _span_stack()
336
+ if not stack:
337
+ return
338
+ span = stack[-1]
339
+
340
+ if span.token_usage is not None:
341
+ return
342
+
343
+ token_usage, model_info, cost = normalize_response(response, model_name=model_name)
344
+ span.token_usage = token_usage
345
+ span.cost = cost
346
+
347
+ if span.model is None:
348
+ span.model = model_info.name
349
+
350
+ except Exception: # NOSONAR
351
+ return