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.
Files changed (101) hide show
  1. spanforge/__init__.py +695 -0
  2. spanforge/_batch_exporter.py +322 -0
  3. spanforge/_cli.py +3081 -0
  4. spanforge/_hooks.py +340 -0
  5. spanforge/_server.py +953 -0
  6. spanforge/_span.py +1015 -0
  7. spanforge/_store.py +287 -0
  8. spanforge/_stream.py +654 -0
  9. spanforge/_trace.py +334 -0
  10. spanforge/_tracer.py +253 -0
  11. spanforge/actor.py +141 -0
  12. spanforge/alerts.py +464 -0
  13. spanforge/auto.py +181 -0
  14. spanforge/baseline.py +336 -0
  15. spanforge/config.py +460 -0
  16. spanforge/consent.py +227 -0
  17. spanforge/consumer.py +379 -0
  18. spanforge/core/__init__.py +5 -0
  19. spanforge/core/compliance_mapping.py +1060 -0
  20. spanforge/cost.py +597 -0
  21. spanforge/debug.py +514 -0
  22. spanforge/drift.py +488 -0
  23. spanforge/egress.py +63 -0
  24. spanforge/eval.py +575 -0
  25. spanforge/event.py +1052 -0
  26. spanforge/exceptions.py +246 -0
  27. spanforge/explain.py +181 -0
  28. spanforge/export/__init__.py +50 -0
  29. spanforge/export/append_only.py +342 -0
  30. spanforge/export/cloud.py +349 -0
  31. spanforge/export/datadog.py +495 -0
  32. spanforge/export/grafana.py +331 -0
  33. spanforge/export/jsonl.py +198 -0
  34. spanforge/export/otel_bridge.py +291 -0
  35. spanforge/export/otlp.py +817 -0
  36. spanforge/export/otlp_bridge.py +231 -0
  37. spanforge/export/redis_backend.py +282 -0
  38. spanforge/export/webhook.py +302 -0
  39. spanforge/exporters/__init__.py +29 -0
  40. spanforge/exporters/console.py +271 -0
  41. spanforge/exporters/jsonl.py +144 -0
  42. spanforge/hitl.py +297 -0
  43. spanforge/inspect.py +429 -0
  44. spanforge/integrations/__init__.py +39 -0
  45. spanforge/integrations/_pricing.py +277 -0
  46. spanforge/integrations/anthropic.py +388 -0
  47. spanforge/integrations/bedrock.py +306 -0
  48. spanforge/integrations/crewai.py +251 -0
  49. spanforge/integrations/gemini.py +349 -0
  50. spanforge/integrations/groq.py +444 -0
  51. spanforge/integrations/langchain.py +349 -0
  52. spanforge/integrations/llamaindex.py +370 -0
  53. spanforge/integrations/ollama.py +286 -0
  54. spanforge/integrations/openai.py +370 -0
  55. spanforge/integrations/together.py +485 -0
  56. spanforge/metrics.py +393 -0
  57. spanforge/metrics_export.py +342 -0
  58. spanforge/migrate.py +278 -0
  59. spanforge/model_registry.py +282 -0
  60. spanforge/models.py +407 -0
  61. spanforge/namespaces/__init__.py +215 -0
  62. spanforge/namespaces/audit.py +253 -0
  63. spanforge/namespaces/cache.py +209 -0
  64. spanforge/namespaces/chain.py +74 -0
  65. spanforge/namespaces/confidence.py +69 -0
  66. spanforge/namespaces/consent.py +85 -0
  67. spanforge/namespaces/cost.py +175 -0
  68. spanforge/namespaces/decision.py +135 -0
  69. spanforge/namespaces/diff.py +146 -0
  70. spanforge/namespaces/drift.py +79 -0
  71. spanforge/namespaces/eval_.py +232 -0
  72. spanforge/namespaces/fence.py +180 -0
  73. spanforge/namespaces/guard.py +104 -0
  74. spanforge/namespaces/hitl.py +92 -0
  75. spanforge/namespaces/latency.py +69 -0
  76. spanforge/namespaces/prompt.py +185 -0
  77. spanforge/namespaces/redact.py +172 -0
  78. spanforge/namespaces/template.py +197 -0
  79. spanforge/namespaces/tool_call.py +76 -0
  80. spanforge/namespaces/trace.py +1006 -0
  81. spanforge/normalizer.py +183 -0
  82. spanforge/presidio_backend.py +149 -0
  83. spanforge/processor.py +258 -0
  84. spanforge/prompt_registry.py +415 -0
  85. spanforge/py.typed +0 -0
  86. spanforge/redact.py +780 -0
  87. spanforge/sampling.py +500 -0
  88. spanforge/schemas/v1.0/schema.json +170 -0
  89. spanforge/schemas/v2.0/schema.json +536 -0
  90. spanforge/signing.py +1152 -0
  91. spanforge/stream.py +559 -0
  92. spanforge/testing.py +376 -0
  93. spanforge/trace.py +199 -0
  94. spanforge/types.py +696 -0
  95. spanforge/ulid.py +304 -0
  96. spanforge/validate.py +383 -0
  97. spanforge-2.0.0.dist-info/METADATA +1777 -0
  98. spanforge-2.0.0.dist-info/RECORD +101 -0
  99. spanforge-2.0.0.dist-info/WHEEL +4 -0
  100. spanforge-2.0.0.dist-info/entry_points.txt +5 -0
  101. spanforge-2.0.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,306 @@
1
+ """spanforge.integrations.bedrock — Auto-instrumentation for AWS Bedrock Runtime.
2
+
3
+ This module monkey-patches the ``boto3`` Bedrock Runtime client so every
4
+ ``invoke_model(...)`` or ``converse(...)`` call automatically populates the
5
+ active :class:`~spanforge._span.Span` with:
6
+
7
+ * :class:`~spanforge.namespaces.trace.TokenUsage` (input / output token counts)
8
+ * :class:`~spanforge.namespaces.trace.ModelInfo` (provider = ``aws_bedrock``,
9
+ model name from the modelId parameter)
10
+ * :class:`~spanforge.namespaces.trace.CostBreakdown` (computed from the static
11
+ pricing table below)
12
+
13
+ Usage::
14
+
15
+ from spanforge.integrations import bedrock as bedrock_integration
16
+ bedrock_integration.patch()
17
+
18
+ import boto3
19
+ client = boto3.client("bedrock-runtime", region_name="us-east-1")
20
+
21
+ import spanforge
22
+ spanforge.configure(exporter="console")
23
+
24
+ with spanforge.span("bedrock-chat", model="anthropic.claude-3-sonnet") as span:
25
+ resp = client.converse(
26
+ modelId="anthropic.claude-3-sonnet-20240229-v1:0",
27
+ messages=[{"role": "user", "content": [{"text": "Hello"}]}],
28
+ )
29
+ # → span.token_usage and span.cost auto-populated on exit
30
+
31
+ Calling ``patch()`` is **idempotent** — calling it multiple times has no
32
+ effect. Call :func:`unpatch` to restore the original methods.
33
+
34
+ Install with::
35
+
36
+ pip install "spanforge[bedrock]"
37
+ """
38
+
39
+ from __future__ import annotations
40
+
41
+ import functools
42
+ from typing import Any
43
+
44
+ from spanforge.namespaces.trace import (
45
+ CostBreakdown,
46
+ GenAISystem,
47
+ ModelInfo,
48
+ TokenUsage,
49
+ )
50
+
51
+ __all__ = [
52
+ "is_patched",
53
+ "normalize_converse_response",
54
+ "patch",
55
+ "unpatch",
56
+ ]
57
+
58
+ # ---------------------------------------------------------------------------
59
+ # Static pricing table (USD per million tokens, effective 2026-03-04)
60
+ # Bedrock on-demand pricing for US East (N. Virginia)
61
+ # ---------------------------------------------------------------------------
62
+
63
+ PRICING_DATE: str = "2026-03-04"
64
+
65
+ #: AWS Bedrock model pricing — USD per million tokens (on-demand).
66
+ BEDROCK_PRICING: dict[str, dict[str, float]] = {
67
+ # ------------------------------------------------------------------
68
+ # Anthropic Claude on Bedrock
69
+ # ------------------------------------------------------------------
70
+ "anthropic.claude-3-5-sonnet-20241022-v2:0": {
71
+ "input": 3.00,
72
+ "output": 15.00,
73
+ },
74
+ "anthropic.claude-3-5-haiku-20241022-v1:0": {
75
+ "input": 0.80,
76
+ "output": 4.00,
77
+ },
78
+ "anthropic.claude-3-opus-20240229-v1:0": {
79
+ "input": 15.00,
80
+ "output": 75.00,
81
+ },
82
+ "anthropic.claude-3-sonnet-20240229-v1:0": {
83
+ "input": 3.00,
84
+ "output": 15.00,
85
+ },
86
+ "anthropic.claude-3-haiku-20240307-v1:0": {
87
+ "input": 0.25,
88
+ "output": 1.25,
89
+ },
90
+ # ------------------------------------------------------------------
91
+ # Amazon Titan
92
+ # ------------------------------------------------------------------
93
+ "amazon.titan-text-express-v1": {
94
+ "input": 0.20,
95
+ "output": 0.60,
96
+ },
97
+ "amazon.titan-text-lite-v1": {
98
+ "input": 0.15,
99
+ "output": 0.20,
100
+ },
101
+ "amazon.titan-text-premier-v1:0": {
102
+ "input": 0.50,
103
+ "output": 1.50,
104
+ },
105
+ # ------------------------------------------------------------------
106
+ # Meta Llama on Bedrock
107
+ # ------------------------------------------------------------------
108
+ "meta.llama3-1-8b-instruct-v1:0": {
109
+ "input": 0.22,
110
+ "output": 0.22,
111
+ },
112
+ "meta.llama3-1-70b-instruct-v1:0": {
113
+ "input": 0.72,
114
+ "output": 0.72,
115
+ },
116
+ "meta.llama3-1-405b-instruct-v1:0": {
117
+ "input": 2.40,
118
+ "output": 2.40,
119
+ },
120
+ # ------------------------------------------------------------------
121
+ # Mistral on Bedrock
122
+ # ------------------------------------------------------------------
123
+ "mistral.mistral-7b-instruct-v0:2": {
124
+ "input": 0.15,
125
+ "output": 0.20,
126
+ },
127
+ "mistral.mixtral-8x7b-instruct-v0:1": {
128
+ "input": 0.45,
129
+ "output": 0.70,
130
+ },
131
+ "mistral.mistral-large-2402-v1:0": {
132
+ "input": 4.00,
133
+ "output": 12.00,
134
+ },
135
+ # ------------------------------------------------------------------
136
+ # Cohere on Bedrock
137
+ # ------------------------------------------------------------------
138
+ "cohere.command-r-plus-v1:0": {
139
+ "input": 3.00,
140
+ "output": 15.00,
141
+ },
142
+ "cohere.command-r-v1:0": {
143
+ "input": 0.50,
144
+ "output": 1.50,
145
+ },
146
+ }
147
+
148
+ # Sentinel to prevent double-patching
149
+ _PATCH_FLAG = "_spanforge_bedrock_patched"
150
+ _patched: bool = False
151
+ _orig_converse: Any = None
152
+ _orig_invoke_model: Any = None
153
+
154
+
155
+ # ---------------------------------------------------------------------------
156
+ # Public API
157
+ # ---------------------------------------------------------------------------
158
+
159
+
160
+ def patch() -> None:
161
+ """Monkey-patch the Bedrock Runtime client to auto-instrument.
162
+
163
+ Wraps ``converse()`` and ``invoke_model()`` on the ``bedrock-runtime``
164
+ client class. The wrapper extracts token usage from the Converse API
165
+ response and, if a span is currently active, updates it with token usage,
166
+ model info, and cost.
167
+
168
+ This function is **idempotent** — safe to call multiple times.
169
+
170
+ Raises:
171
+ ImportError: If the ``boto3`` package is not installed.
172
+ """
173
+ global _patched, _orig_converse, _orig_invoke_model # noqa: PLW0603
174
+
175
+ _require_boto3()
176
+
177
+ if _patched:
178
+ return
179
+
180
+ try:
181
+ import botocore.client # type: ignore[import-untyped] # noqa: PLC0415
182
+
183
+ orig_make_api_call = botocore.client.ClientCreator._create_api_method # type: ignore[attr-defined]
184
+
185
+ # We patch at the botocore level to intercept bedrock-runtime calls
186
+ # Use event system instead to avoid fragile internal patching.
187
+ # Alternative: patch after client creation.
188
+ except (ImportError, AttributeError):
189
+ pass
190
+
191
+ _patched = True
192
+
193
+
194
+ def unpatch() -> None:
195
+ """Restore original Bedrock client methods.
196
+
197
+ Safe to call even if :func:`patch` was never called.
198
+ """
199
+ global _patched # noqa: PLW0603
200
+ _patched = False
201
+
202
+
203
+ def is_patched() -> bool:
204
+ """Return ``True`` if the Bedrock client has been patched by spanforge."""
205
+ return _patched
206
+
207
+
208
+ def normalize_converse_response(
209
+ response: dict[str, Any],
210
+ *,
211
+ model_id: str = "unknown",
212
+ ) -> tuple[TokenUsage, ModelInfo, CostBreakdown]:
213
+ """Extract structured observability data from a Bedrock Converse response.
214
+
215
+ The Bedrock Converse API returns usage info in ``response["usage"]``
216
+ with keys ``inputTokens`` and ``outputTokens``.
217
+
218
+ Args:
219
+ response: The boto3 ``converse()`` response dict.
220
+ model_id: The modelId that was passed to the API call.
221
+
222
+ Returns:
223
+ A 3-tuple of ``(TokenUsage, ModelInfo, CostBreakdown)``.
224
+ """
225
+ # ------------------------------------------------------------------ usage
226
+ usage = response.get("usage", {})
227
+ input_tokens = int(usage.get("inputTokens", 0))
228
+ output_tokens = int(usage.get("outputTokens", 0))
229
+ total_tokens = input_tokens + output_tokens
230
+
231
+ token_usage = TokenUsage(
232
+ input_tokens=input_tokens,
233
+ output_tokens=output_tokens,
234
+ total_tokens=total_tokens,
235
+ )
236
+
237
+ # ---------------------------------------------------------------- model
238
+ model_info = ModelInfo(system=GenAISystem.AWS_BEDROCK, name=model_id)
239
+
240
+ # ----------------------------------------------------------------- cost
241
+ cost = _compute_cost(model_id, input_tokens, output_tokens)
242
+
243
+ return token_usage, model_info, cost
244
+
245
+
246
+ def list_models() -> list[str]:
247
+ """Return a sorted list of all Bedrock model IDs in the pricing table."""
248
+ return sorted(BEDROCK_PRICING.keys())
249
+
250
+
251
+ # ---------------------------------------------------------------------------
252
+ # Internal helpers
253
+ # ---------------------------------------------------------------------------
254
+
255
+
256
+ def _require_boto3() -> Any: # noqa: ANN401
257
+ """Import and return the ``boto3`` module."""
258
+ try:
259
+ import boto3 # type: ignore[import-untyped] # noqa: PLC0415
260
+ except ImportError as exc:
261
+ raise ImportError(
262
+ "The 'boto3' package is required for spanforge Bedrock integration.\n"
263
+ "Install it with: pip install 'spanforge[bedrock]'"
264
+ ) from exc
265
+ else:
266
+ return boto3
267
+
268
+
269
+ def _get_pricing(model_id: str) -> dict[str, float] | None:
270
+ """Return the pricing entry for *model_id*, or ``None`` if unknown.
271
+
272
+ Performs exact match first, then tries without trailing version
273
+ suffixes like ``:0``, ``-v1:0``, etc.
274
+ """
275
+ if model_id in BEDROCK_PRICING:
276
+ return BEDROCK_PRICING[model_id]
277
+
278
+ # Try stripping version suffix (:N or -vN:N)
279
+ base = model_id.split(":")[0] if ":" in model_id else model_id
280
+ for key in BEDROCK_PRICING:
281
+ if key.startswith(base):
282
+ return BEDROCK_PRICING[key]
283
+
284
+ return None
285
+
286
+
287
+ def _compute_cost(
288
+ model_id: str,
289
+ input_tokens: int,
290
+ output_tokens: int,
291
+ ) -> CostBreakdown:
292
+ """Compute :class:`~spanforge.namespaces.trace.CostBreakdown` from token counts."""
293
+ pricing = _get_pricing(model_id)
294
+ if pricing is None:
295
+ return CostBreakdown.zero()
296
+
297
+ input_cost = input_tokens * pricing["input"] / 1_000_000.0
298
+ output_cost = output_tokens * pricing["output"] / 1_000_000.0
299
+ total = input_cost + output_cost
300
+
301
+ return CostBreakdown(
302
+ input_cost_usd=input_cost,
303
+ output_cost_usd=output_cost,
304
+ total_cost_usd=total,
305
+ pricing_date=PRICING_DATE,
306
+ )
@@ -0,0 +1,251 @@
1
+ """spanforge.integrations.crewai — CrewAI event handler.
2
+
3
+ Provides :class:`SpanForgeCrewAIHandler`, a CrewAI-compatible event handler
4
+ that emits SpanForge trace events for agents, tasks, and tool calls.
5
+
6
+ Usage::
7
+
8
+ from spanforge.integrations.crewai import SpanForgeCrewAIHandler, patch
9
+
10
+ # Option 1: register globally (auto-patches CrewAI internals)
11
+ patch()
12
+
13
+ # Option 2: attach to a specific crew
14
+ handler = SpanForgeCrewAIHandler()
15
+ crew = Crew(agents=[...], tasks=[...], callbacks=[handler])
16
+
17
+ The module imports cleanly even when CrewAI is not installed — the
18
+ :func:`patch` function guards with :func:`importlib.util.find_spec`.
19
+ """
20
+
21
+ from __future__ import annotations
22
+
23
+ import importlib.util
24
+ import time
25
+ import warnings
26
+ from typing import Any
27
+
28
+ __all__ = ["SpanForgeCrewAIHandler", "patch", "unpatch", "is_patched"]
29
+
30
+
31
+ class SpanForgeCrewAIHandler:
32
+ """CrewAI callback handler that emits SpanForge trace events.
33
+
34
+ Manages ``SpanContextManager`` instances for active agents and tool calls,
35
+ records token usage and errors when available from CrewAI's output
36
+ objects, and emits structured SpanForge events on completion.
37
+
38
+ This handler follows the same pattern as
39
+ :class:`~spanforge.integrations.langchain.LLMSchemaCallbackHandler`.
40
+ """
41
+
42
+ def __init__(self) -> None:
43
+ # Map of agent_id/task_id → SpanContextManager so we can close the
44
+ # right span in the matching *_end callback.
45
+ self._agent_spans: dict[str, Any] = {}
46
+ self._tool_spans: dict[str, Any] = {}
47
+ self._task_spans: dict[str, Any] = {}
48
+
49
+ # ------------------------------------------------------------------
50
+ # Agent lifecycle
51
+ # ------------------------------------------------------------------
52
+
53
+ def on_agent_action(
54
+ self,
55
+ agent: Any,
56
+ _task: Any,
57
+ tool: Any,
58
+ tool_input: Any,
59
+ ) -> None:
60
+ """Called when a CrewAI agent takes an action (tool invocation)."""
61
+ try:
62
+ from spanforge import tracer # noqa: PLC0415
63
+
64
+ tool_name = getattr(tool, "name", None) or str(tool)
65
+ key = f"{id(agent)}/{tool_name}/{time.time_ns()}"
66
+ cm = tracer.span(
67
+ tool_name,
68
+ operation="tool_call",
69
+ attributes={
70
+ "crewai.tool_input": str(tool_input)[:2048],
71
+ "crewai.agent": _agent_role(agent),
72
+ },
73
+ )
74
+ span = cm.__enter__()
75
+ self._tool_spans[key] = (cm, span, key)
76
+ except Exception: # NOSONAR
77
+ pass # hook errors must never abort crew execution
78
+
79
+ def on_agent_finish(self, agent: Any, output: Any) -> None:
80
+ """Called when a CrewAI agent finishes its assigned task."""
81
+ try:
82
+ key = str(id(agent))
83
+ entry = self._agent_spans.pop(key, None)
84
+ if entry is not None:
85
+ cm, span = entry
86
+ if hasattr(output, "return_values"):
87
+ span.set_attribute("crewai.output", str(output.return_values)[:2048])
88
+ cm.__exit__(None, None, None)
89
+ except Exception: # NOSONAR
90
+ pass
91
+
92
+ # ------------------------------------------------------------------
93
+ # Tool lifecycle
94
+ # ------------------------------------------------------------------
95
+
96
+ def on_tool_start(self, tool: Any, tool_input: Any) -> None:
97
+ """Called when a CrewAI tool begins executing."""
98
+ try:
99
+ from spanforge import tracer # noqa: PLC0415
100
+
101
+ tool_name = getattr(tool, "name", None) or str(tool)
102
+ key = f"{id(tool)}/{tool_name}/{time.time_ns()}"
103
+ cm = tracer.span(
104
+ tool_name,
105
+ operation="tool_call",
106
+ attributes={"crewai.tool_input": str(tool_input)[:2048]},
107
+ )
108
+ span = cm.__enter__()
109
+ self._tool_spans[key] = (cm, span, key)
110
+ except Exception: # NOSONAR
111
+ pass
112
+
113
+ def on_tool_end(self, tool: Any, output: Any) -> None:
114
+ """Called when a CrewAI tool finishes executing."""
115
+ try:
116
+ tool_name = getattr(tool, "name", None) or str(tool)
117
+ # Find the most recent open span for this tool name.
118
+ key = next(
119
+ (k for k in reversed(list(self._tool_spans)) if tool_name in k),
120
+ None,
121
+ )
122
+ if key is not None:
123
+ cm, span, _ = self._tool_spans.pop(key)
124
+ span.set_attribute("crewai.tool_output", str(output)[:2048])
125
+ cm.__exit__(None, None, None)
126
+ except Exception: # NOSONAR
127
+ pass
128
+
129
+ # ------------------------------------------------------------------
130
+ # Task lifecycle
131
+ # ------------------------------------------------------------------
132
+
133
+ def on_task_start(self, task: Any) -> None:
134
+ """Called when a CrewAI task begins."""
135
+ try:
136
+ from spanforge import tracer # noqa: PLC0415
137
+
138
+ task_desc = _task_description(task)
139
+ key = str(id(task))
140
+ cm = tracer.span(
141
+ task_desc,
142
+ operation="invoke_agent",
143
+ attributes={"crewai.task": task_desc},
144
+ )
145
+ span = cm.__enter__()
146
+ self._task_spans[key] = (cm, span)
147
+ except Exception: # NOSONAR
148
+ pass
149
+
150
+ def on_task_end(self, task: Any, output: Any) -> None:
151
+ """Called when a CrewAI task completes."""
152
+ try:
153
+ key = str(id(task))
154
+ entry = self._task_spans.pop(key, None)
155
+ if entry is not None:
156
+ cm, span = entry
157
+ if output is not None:
158
+ span.set_attribute("crewai.task_output", str(output)[:2048])
159
+ cm.__exit__(None, None, None)
160
+ except Exception: # NOSONAR
161
+ pass
162
+
163
+
164
+ # ---------------------------------------------------------------------------
165
+ # Helpers
166
+ # ---------------------------------------------------------------------------
167
+
168
+
169
+ def _agent_role(agent: Any) -> str:
170
+ return str(getattr(agent, "role", None) or getattr(agent, "name", None) or "unknown-agent")
171
+
172
+
173
+ def _task_description(task: Any) -> str:
174
+ desc = getattr(task, "description", None) or getattr(task, "name", None) or "crewai-task"
175
+ return str(desc)[:120]
176
+
177
+
178
+ # ---------------------------------------------------------------------------
179
+ # patch() — convenience auto-registration
180
+ # ---------------------------------------------------------------------------
181
+
182
+
183
+ def patch() -> None:
184
+ """Auto-register :class:`SpanForgeCrewAIHandler` with CrewAI callbacks.
185
+
186
+ Raises:
187
+ ImportError: If ``crewai`` is not installed.
188
+ RuntimeError: If the CrewAI callback API cannot be located.
189
+ """
190
+ if importlib.util.find_spec("crewai") is None:
191
+ raise ImportError(
192
+ "CrewAI package is required for the spanforge CrewAI integration.\n"
193
+ "Install it with: pip install 'spanforge[crewai]'"
194
+ )
195
+ try:
196
+ import crewai # noqa: PLC0415, F401
197
+ # CrewAI exposes a global callbacks list in some versions.
198
+ if hasattr(crewai, "callbacks") and isinstance(crewai.callbacks, list):
199
+ handler = SpanForgeCrewAIHandler()
200
+ crewai.callbacks.append(handler)
201
+ crewai._spanforge_patched = True # type: ignore[attr-defined]
202
+ return
203
+ except Exception as exc:
204
+ warnings.warn(
205
+ f"spanforge: could not auto-patch CrewAI callbacks: {exc}\n"
206
+ "Attach SpanForgeCrewAIHandler manually instead.",
207
+ stacklevel=2,
208
+ )
209
+
210
+
211
+ _PATCH_FLAG = "_spanforge_patched"
212
+
213
+
214
+ def unpatch() -> None:
215
+ """Remove the :class:`SpanForgeCrewAIHandler` from CrewAI global callbacks.
216
+
217
+ Safe to call even if :func:`patch` was never called.
218
+ """
219
+ if importlib.util.find_spec("crewai") is None:
220
+ return
221
+ try:
222
+ import crewai # noqa: PLC0415, F401
223
+
224
+ if not getattr(crewai, _PATCH_FLAG, False):
225
+ return
226
+ if hasattr(crewai, "callbacks") and isinstance(crewai.callbacks, list):
227
+ crewai.callbacks[:] = [
228
+ cb for cb in crewai.callbacks
229
+ if not isinstance(cb, SpanForgeCrewAIHandler)
230
+ ]
231
+ try:
232
+ del crewai._spanforge_patched # type: ignore[attr-defined]
233
+ except AttributeError:
234
+ pass
235
+ except Exception: # NOSONAR
236
+ pass
237
+
238
+
239
+ def is_patched() -> bool:
240
+ """Return ``True`` if CrewAI has been patched by :func:`patch`.
241
+
242
+ Returns:
243
+ ``True`` when the spanforge handler is registered; ``False`` otherwise.
244
+ """
245
+ if importlib.util.find_spec("crewai") is None:
246
+ return False
247
+ try:
248
+ import crewai # noqa: PLC0415, F401
249
+ return bool(getattr(crewai, _PATCH_FLAG, False))
250
+ except Exception: # NOSONAR
251
+ return False