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,368 @@
1
+ """spanforge.integrations.openai — Auto-instrumentation for the OpenAI Python SDK.
2
+
3
+ This module monkey-patches the OpenAI client so every
4
+ ``client.chat.completions.create(...)`` call automatically populates the
5
+ active :class:`~spanforge._span.Span` with:
6
+
7
+ * :class:`~spanforge.namespaces.trace.TokenUsage` (input / output / cached /
8
+ reasoning token counts)
9
+ * :class:`~spanforge.namespaces.trace.ModelInfo` (provider = ``openai``, name
10
+ from response)
11
+ * :class:`~spanforge.namespaces.trace.CostBreakdown` (computed from the static
12
+ pricing table in :mod:`spanforge.integrations._pricing`)
13
+
14
+ Usage::
15
+
16
+ from spanforge.integrations import openai as openai_integration
17
+ openai_integration.patch()
18
+
19
+ import openai
20
+ client = openai.OpenAI()
21
+
22
+ import spanforge
23
+ spanforge.configure(exporter="console")
24
+
25
+ with spanforge.tracer.span("chat", model="gpt-4o") as span:
26
+ resp = client.chat.completions.create(
27
+ model="gpt-4o",
28
+ messages=[{"role": "user", "content": "Hello"}],
29
+ )
30
+ # → span.token_usage and span.cost auto-populated on exit
31
+
32
+ Calling ``patch()`` is **idempotent** — calling it multiple times has no
33
+ effect. Call :func:`unpatch` to restore the original methods.
34
+
35
+ Install with::
36
+
37
+ pip install "spanforge[openai]"
38
+ """
39
+
40
+ from __future__ import annotations
41
+
42
+ import functools
43
+ from typing import Any
44
+
45
+ from spanforge.integrations._pricing import PRICING_DATE, get_pricing
46
+ from spanforge.namespaces.trace import (
47
+ CostBreakdown,
48
+ GenAISystem,
49
+ ModelInfo,
50
+ TokenUsage,
51
+ )
52
+
53
+ __all__ = [
54
+ "is_patched",
55
+ "normalize_response",
56
+ "patch",
57
+ "unpatch",
58
+ ]
59
+
60
+ # Sentinel attribute set on the openai module to prevent double-patching.
61
+ _PATCH_FLAG = "_spanforge_patched"
62
+
63
+
64
+ # ---------------------------------------------------------------------------
65
+ # Public API
66
+ # ---------------------------------------------------------------------------
67
+
68
+
69
+ def patch() -> None:
70
+ """Monkey-patch the OpenAI client to auto-instrument all chat completions.
71
+
72
+ Wraps both ``openai.resources.chat.completions.Completions.create``
73
+ (sync) and ``AsyncCompletions.create`` (async). The wrapper calls
74
+ :func:`normalize_response` on the result and, if a span is currently
75
+ active on this thread, updates it with token usage, model info, and cost.
76
+
77
+ This function is **idempotent** — safe to call multiple times.
78
+
79
+ Raises:
80
+ ImportError: If the ``openai`` package is not installed.
81
+ """
82
+ openai_mod = _require_openai()
83
+
84
+ if getattr(openai_mod, _PATCH_FLAG, False):
85
+ return # already patched
86
+
87
+ # --- sync ----------------------------------------------------------------
88
+ from openai.resources.chat.completions import (
89
+ Completions, # type: ignore[import-untyped]
90
+ )
91
+
92
+ _orig_sync = Completions.create # type: ignore[attr-defined]
93
+
94
+ @functools.wraps(_orig_sync)
95
+ def _patched_sync(self: Any, *args: Any, **kwargs: Any) -> Any:
96
+ response = _orig_sync(self, *args, **kwargs)
97
+ _auto_populate_span(response)
98
+ return response
99
+
100
+ Completions.create = _patched_sync # type: ignore[method-assign]
101
+ Completions._spanforge_orig_create = _orig_sync # type: ignore[attr-defined]
102
+
103
+ # --- async ---------------------------------------------------------------
104
+ try:
105
+ from openai.resources.chat.completions import (
106
+ AsyncCompletions, # type: ignore[import-untyped]
107
+ )
108
+
109
+ _orig_async = AsyncCompletions.create # type: ignore[attr-defined]
110
+
111
+ @functools.wraps(_orig_async)
112
+ async def _patched_async(self: Any, *args: Any, **kwargs: Any) -> Any:
113
+ response = await _orig_async(self, *args, **kwargs)
114
+ _auto_populate_span(response)
115
+ return response
116
+
117
+ AsyncCompletions.create = _patched_async # type: ignore[method-assign]
118
+ AsyncCompletions._spanforge_orig_create = _orig_async # type: ignore[attr-defined]
119
+ except (ImportError, AttributeError): # pragma: no cover
120
+ pass
121
+
122
+ setattr(openai_mod, _PATCH_FLAG, True)
123
+
124
+
125
+ def unpatch() -> None:
126
+ """Restore the original OpenAI methods and remove the patch flag.
127
+
128
+ Safe to call even if :func:`patch` was never called.
129
+
130
+ Raises:
131
+ ImportError: If the ``openai`` package is not installed.
132
+ """
133
+ openai_mod = _require_openai()
134
+
135
+ if not getattr(openai_mod, _PATCH_FLAG, False):
136
+ return # nothing to do
137
+
138
+ try:
139
+ from openai.resources.chat.completions import (
140
+ Completions, # type: ignore[import-untyped]
141
+ )
142
+
143
+ Completions.create = Completions._spanforge_orig_create # type: ignore[attr-defined,method-assign]
144
+ del Completions._spanforge_orig_create # type: ignore[attr-defined]
145
+ except (ImportError, AttributeError): # pragma: no cover
146
+ pass
147
+
148
+ try:
149
+ from openai.resources.chat.completions import (
150
+ AsyncCompletions, # type: ignore[import-untyped]
151
+ )
152
+
153
+ AsyncCompletions.create = AsyncCompletions._spanforge_orig_create # type: ignore[attr-defined,method-assign]
154
+ del AsyncCompletions._spanforge_orig_create # type: ignore[attr-defined]
155
+ except (ImportError, AttributeError): # pragma: no cover
156
+ pass
157
+
158
+ try:
159
+ delattr(openai_mod, _PATCH_FLAG)
160
+ except AttributeError: # pragma: no cover
161
+ pass
162
+
163
+
164
+ def is_patched() -> bool:
165
+ """Return ``True`` if the OpenAI client has been patched by spanforge.
166
+
167
+ Returns ``False`` if the ``openai`` package is not installed.
168
+ """
169
+ try:
170
+ openai_mod = _require_openai()
171
+ return bool(getattr(openai_mod, _PATCH_FLAG, False))
172
+ except ImportError:
173
+ return False
174
+
175
+
176
+ def normalize_response(
177
+ response: Any,
178
+ ) -> tuple[TokenUsage, ModelInfo, CostBreakdown]:
179
+ """Extract structured observability data from an OpenAI chat completion.
180
+
181
+ Works with both ``openai.types.chat.ChatCompletion`` objects and any
182
+ duck-typed mock with the same attribute structure.
183
+
184
+ Args:
185
+ response: An OpenAI ``ChatCompletion`` (or compatible object).
186
+
187
+ Returns:
188
+ A 3-tuple of ``(TokenUsage, ModelInfo, CostBreakdown)``.
189
+
190
+ Field mapping:
191
+
192
+ +--------------------------------------------+---------------------------+
193
+ | OpenAI field | SpanForge field |
194
+ +============================================+===========================+
195
+ | ``response.model`` | ``ModelInfo.name`` |
196
+ | ``usage.prompt_tokens`` | ``TokenUsage.input_tokens``|
197
+ | ``usage.completion_tokens`` | ``TokenUsage.output_tokens``|
198
+ | ``usage.total_tokens`` | ``TokenUsage.total_tokens``|
199
+ | ``usage.prompt_tokens_details.cached_tokens`` | ``TokenUsage.cached_tokens``|
200
+ | ``usage.completion_tokens_details.reasoning_tokens``| ``TokenUsage.reasoning_tokens``|
201
+ +--------------------------------------------+---------------------------+
202
+ """
203
+ # ------------------------------------------------------------------ usage
204
+ usage = getattr(response, "usage", None)
205
+ input_tokens: int = 0
206
+ output_tokens: int = 0
207
+ total_tokens: int = 0
208
+ cached_tokens: int | None = None
209
+ reasoning_tokens: int | None = None
210
+
211
+ if usage is not None:
212
+ input_tokens = int(getattr(usage, "prompt_tokens", 0) or 0)
213
+ output_tokens = int(getattr(usage, "completion_tokens", 0) or 0)
214
+ total_tokens = int(getattr(usage, "total_tokens", input_tokens + output_tokens) or 0)
215
+
216
+ # Prompt token details (cached)
217
+ ptd = getattr(usage, "prompt_tokens_details", None)
218
+ if ptd is not None:
219
+ ct = getattr(ptd, "cached_tokens", None)
220
+ if ct is not None:
221
+ cached_tokens = int(ct)
222
+
223
+ # Completion token details (reasoning)
224
+ ctd = getattr(usage, "completion_tokens_details", None)
225
+ if ctd is not None:
226
+ rt = getattr(ctd, "reasoning_tokens", None)
227
+ if rt is not None:
228
+ reasoning_tokens = int(rt)
229
+
230
+ token_usage = TokenUsage(
231
+ input_tokens=input_tokens,
232
+ output_tokens=output_tokens,
233
+ total_tokens=total_tokens,
234
+ cached_tokens=cached_tokens,
235
+ reasoning_tokens=reasoning_tokens,
236
+ )
237
+
238
+ # ---------------------------------------------------------------- model
239
+ model_name: str = getattr(response, "model", None) or "unknown"
240
+ model_info = ModelInfo(system=GenAISystem.OPENAI, name=model_name)
241
+
242
+ # ----------------------------------------------------------------- cost
243
+ cost = _compute_cost(model_name, input_tokens, output_tokens, cached_tokens, reasoning_tokens)
244
+
245
+ return token_usage, model_info, cost
246
+
247
+
248
+ # ---------------------------------------------------------------------------
249
+ # Internal helpers
250
+ # ---------------------------------------------------------------------------
251
+
252
+
253
+ def _require_openai() -> Any:
254
+ """Import and return the ``openai`` module, raising ``ImportError`` if absent."""
255
+ try:
256
+ import openai # type: ignore[import-untyped]
257
+ except ImportError as exc:
258
+ raise ImportError(
259
+ "The 'openai' package is required for spanforge OpenAI integration.\n"
260
+ "Install it with: pip install 'spanforge[openai]'"
261
+ ) from exc
262
+ else:
263
+ return openai
264
+
265
+
266
+ def _compute_cost(
267
+ model_name: str,
268
+ input_tokens: int,
269
+ output_tokens: int,
270
+ cached_tokens: int | None,
271
+ reasoning_tokens: int | None,
272
+ ) -> CostBreakdown:
273
+ """Compute :class:`~spanforge.namespaces.trace.CostBreakdown` from token counts.
274
+
275
+ Uses the static pricing table. Falls back to :meth:`CostBreakdown.zero`
276
+ for unknown models.
277
+
278
+ Args:
279
+ model_name: Model name string (as returned by the API).
280
+ input_tokens: Total input tokens (including any cached tokens).
281
+ output_tokens: Output / completion tokens.
282
+ cached_tokens: Subset of input tokens served from the prompt cache.
283
+ reasoning_tokens: Reasoning tokens (o1/o3 models).
284
+
285
+ Returns:
286
+ A :class:`~spanforge.namespaces.trace.CostBreakdown` instance.
287
+ """
288
+ pricing = get_pricing(model_name)
289
+ if pricing is None:
290
+ return CostBreakdown.zero()
291
+
292
+ input_rate = pricing["input"] # $/1M tokens
293
+ output_rate = pricing["output"]
294
+
295
+ # Full-price input cost (we'll deduct the cached discount separately)
296
+ input_cost = input_tokens * input_rate / 1_000_000.0
297
+ output_cost = output_tokens * output_rate / 1_000_000.0
298
+
299
+ # Cached discount: tokens served from cache are billed at cached_input rate
300
+ cached_discount = 0.0
301
+ cached_rate = pricing.get("cached_input")
302
+ if cached_tokens and cached_rate is not None:
303
+ # We already charged these at full input_rate; reduce by the difference
304
+ cached_discount = cached_tokens * (input_rate - cached_rate) / 1_000_000.0
305
+ cached_discount = max(0.0, cached_discount)
306
+
307
+ # Reasoning cost: reasoning tokens in o1/o3 are billed at the output rate
308
+ # (already included in output_tokens from the API, so reasoning_cost_usd = 0
309
+ # unless the model has a separate reasoning rate)
310
+ reasoning_cost = 0.0
311
+ reasoning_rate = pricing.get("reasoning")
312
+ if reasoning_tokens and reasoning_rate is not None:
313
+ # Some models bill reasoning tokens at a rate that may differ from the
314
+ # output rate (future-proofing). For o1, reasoning_rate == output_rate
315
+ # so this branch is arithmetically a no-op, but the code path is kept
316
+ # for models where they diverge.
317
+ # Reasoning tokens are already counted within output_tokens by the API,
318
+ # so we rebill them separately and remove them from regular output cost.
319
+ regular_output = output_tokens - reasoning_tokens
320
+ regular_output = max(0, regular_output)
321
+ output_cost = regular_output * output_rate / 1_000_000.0
322
+ reasoning_cost = reasoning_tokens * reasoning_rate / 1_000_000.0
323
+
324
+ total = input_cost + output_cost + reasoning_cost - cached_discount
325
+ total = max(0.0, total)
326
+
327
+ return CostBreakdown(
328
+ input_cost_usd=input_cost,
329
+ output_cost_usd=output_cost,
330
+ total_cost_usd=total,
331
+ cached_discount_usd=cached_discount,
332
+ reasoning_cost_usd=reasoning_cost,
333
+ pricing_date=PRICING_DATE,
334
+ )
335
+
336
+
337
+ def _auto_populate_span(response: Any) -> None:
338
+ """If there is an active span on this thread, populate it from *response*.
339
+
340
+ Silently does nothing if:
341
+
342
+ * There is no active span.
343
+ * ``normalize_response`` raises (malformed response).
344
+ * The span already has ``token_usage`` set (don't overwrite manual data).
345
+ """
346
+ try:
347
+ from spanforge._span import _span_stack
348
+
349
+ stack = _span_stack()
350
+ if not stack:
351
+ return
352
+ span = stack[-1]
353
+
354
+ # Don't overwrite data that the user set manually.
355
+ if span.token_usage is not None:
356
+ return
357
+
358
+ token_usage, model_info, cost = normalize_response(response)
359
+ span.token_usage = token_usage
360
+ span.cost = cost
361
+
362
+ # Update the model string if not already set
363
+ if span.model is None:
364
+ span.model = model_info.name
365
+
366
+ except Exception: # NOSONAR
367
+ # Never let instrumentation errors surface in user code.
368
+ return