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,133 @@
1
+ """spanforge.integrations.azure_openai - Azure OpenAI client instrumentation.
2
+
3
+ Provides an instance-level integration path for Azure-hosted OpenAI clients.
4
+ Unlike the generic OpenAI integration, Azure OpenAI is typically configured on
5
+ dedicated client instances with an Azure endpoint and deployment wiring, so the
6
+ public surface here instruments one client at a time.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import functools
12
+ from typing import Any
13
+
14
+ from spanforge.integrations import openai as _openai_integration
15
+ from spanforge.namespaces.trace import GenAISystem, ModelInfo
16
+
17
+ __all__ = [
18
+ "instrument_async_client",
19
+ "instrument_client",
20
+ "is_instrumented",
21
+ "normalize_response",
22
+ "uninstrument_client",
23
+ ]
24
+
25
+ _PATCH_FLAG = "_spanforge_azure_openai_instrumented"
26
+ _ORIG_SYNC = "_spanforge_azure_openai_orig_create"
27
+
28
+
29
+ def normalize_response(response: Any) -> tuple[Any, ModelInfo, Any]:
30
+ """Extract token usage, model identity, and cost from an Azure response."""
31
+ token_usage, _model_info, cost = _openai_integration.normalize_response(response)
32
+ model_name = getattr(response, "model", None) or "unknown"
33
+ model_info = ModelInfo(system=GenAISystem.OPENAI, name=model_name)
34
+ return token_usage, model_info, cost
35
+
36
+
37
+ def instrument_client(client: Any) -> Any:
38
+ """Wrap one Azure OpenAI client instance in-place."""
39
+ completions = _get_completions(client)
40
+ if getattr(completions, _PATCH_FLAG, False):
41
+ return client
42
+
43
+ original = completions.create
44
+
45
+ @functools.wraps(original)
46
+ def _patched(*args: Any, **kwargs: Any) -> Any:
47
+ response = original(*args, **kwargs)
48
+ _auto_populate_span(response, client=client, kwargs=kwargs)
49
+ return response
50
+
51
+ setattr(completions, _ORIG_SYNC, original)
52
+ completions.create = _patched
53
+ setattr(completions, _PATCH_FLAG, True)
54
+ return client
55
+
56
+
57
+ def instrument_async_client(client: Any) -> Any:
58
+ """Wrap one async Azure OpenAI client instance in-place."""
59
+ completions = _get_completions(client)
60
+ if getattr(completions, _PATCH_FLAG, False):
61
+ return client
62
+
63
+ original = completions.create
64
+
65
+ @functools.wraps(original)
66
+ async def _patched(*args: Any, **kwargs: Any) -> Any:
67
+ response = await original(*args, **kwargs)
68
+ _auto_populate_span(response, client=client, kwargs=kwargs)
69
+ return response
70
+
71
+ setattr(completions, _ORIG_SYNC, original)
72
+ completions.create = _patched
73
+ setattr(completions, _PATCH_FLAG, True)
74
+ return client
75
+
76
+
77
+ def uninstrument_client(client: Any) -> Any:
78
+ """Restore the original Azure OpenAI client instance method."""
79
+ completions = _get_completions(client)
80
+ original = getattr(completions, _ORIG_SYNC, None)
81
+ if original is not None:
82
+ completions.create = original
83
+ delattr(completions, _ORIG_SYNC)
84
+ if getattr(completions, _PATCH_FLAG, False):
85
+ delattr(completions, _PATCH_FLAG)
86
+ return client
87
+
88
+
89
+ def is_instrumented(client: Any) -> bool:
90
+ """Return ``True`` when a client instance has been instrumented."""
91
+ completions = _get_completions(client)
92
+ return bool(getattr(completions, _PATCH_FLAG, False))
93
+
94
+
95
+ def _get_completions(client: Any) -> Any:
96
+ chat = getattr(client, "chat", None)
97
+ completions = getattr(chat, "completions", None) if chat is not None else None
98
+ if completions is None or not hasattr(completions, "create"):
99
+ raise TypeError("Azure OpenAI client must expose client.chat.completions.create(...)")
100
+ return completions
101
+
102
+
103
+ def _auto_populate_span(response: Any, *, client: Any, kwargs: dict[str, Any]) -> None:
104
+ """Populate the active span with Azure OpenAI-specific metadata."""
105
+ try:
106
+ from spanforge._span import _span_stack
107
+
108
+ stack = _span_stack()
109
+ if not stack:
110
+ return
111
+ span = stack[-1]
112
+ if span.token_usage is not None:
113
+ return
114
+
115
+ token_usage, model_info, cost = normalize_response(response)
116
+ span.token_usage = token_usage
117
+ span.cost = cost
118
+ if span.model is None:
119
+ span.model = model_info.name
120
+ span.attributes.setdefault("gen_ai.system", "openai")
121
+ span.attributes.setdefault("llm.system", "openai")
122
+ span.attributes.setdefault("llm.provider", "azure")
123
+ azure_endpoint = getattr(client, "azure_endpoint", None) or getattr(client, "base_url", None)
124
+ if azure_endpoint:
125
+ span.attributes.setdefault("azure.openai.endpoint", str(azure_endpoint))
126
+ api_version = getattr(client, "api_version", None)
127
+ if api_version:
128
+ span.attributes.setdefault("azure.openai.api_version", str(api_version))
129
+ deployment = kwargs.get("model") or getattr(response, "model", None)
130
+ if deployment:
131
+ span.attributes.setdefault("azure.openai.deployment", str(deployment))
132
+ except Exception:
133
+ return
@@ -0,0 +1,292 @@
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
+ from typing import Any
42
+
43
+ from spanforge.namespaces.trace import (
44
+ CostBreakdown,
45
+ GenAISystem,
46
+ ModelInfo,
47
+ TokenUsage,
48
+ )
49
+
50
+ __all__ = [
51
+ "is_patched",
52
+ "normalize_converse_response",
53
+ "patch",
54
+ "unpatch",
55
+ ]
56
+
57
+ # ---------------------------------------------------------------------------
58
+ # Static pricing table (USD per million tokens, effective 2026-03-04)
59
+ # Bedrock on-demand pricing for US East (N. Virginia)
60
+ # ---------------------------------------------------------------------------
61
+
62
+ PRICING_DATE: str = "2026-03-04"
63
+
64
+ #: AWS Bedrock model pricing — USD per million tokens (on-demand).
65
+ BEDROCK_PRICING: dict[str, dict[str, float]] = {
66
+ # ------------------------------------------------------------------
67
+ # Anthropic Claude on Bedrock
68
+ # ------------------------------------------------------------------
69
+ "anthropic.claude-3-5-sonnet-20241022-v2:0": {
70
+ "input": 3.00,
71
+ "output": 15.00,
72
+ },
73
+ "anthropic.claude-3-5-haiku-20241022-v1:0": {
74
+ "input": 0.80,
75
+ "output": 4.00,
76
+ },
77
+ "anthropic.claude-3-opus-20240229-v1:0": {
78
+ "input": 15.00,
79
+ "output": 75.00,
80
+ },
81
+ "anthropic.claude-3-sonnet-20240229-v1:0": {
82
+ "input": 3.00,
83
+ "output": 15.00,
84
+ },
85
+ "anthropic.claude-3-haiku-20240307-v1:0": {
86
+ "input": 0.25,
87
+ "output": 1.25,
88
+ },
89
+ # ------------------------------------------------------------------
90
+ # Amazon Titan
91
+ # ------------------------------------------------------------------
92
+ "amazon.titan-text-express-v1": {
93
+ "input": 0.20,
94
+ "output": 0.60,
95
+ },
96
+ "amazon.titan-text-lite-v1": {
97
+ "input": 0.15,
98
+ "output": 0.20,
99
+ },
100
+ "amazon.titan-text-premier-v1:0": {
101
+ "input": 0.50,
102
+ "output": 1.50,
103
+ },
104
+ # ------------------------------------------------------------------
105
+ # Meta Llama on Bedrock
106
+ # ------------------------------------------------------------------
107
+ "meta.llama3-1-8b-instruct-v1:0": {
108
+ "input": 0.22,
109
+ "output": 0.22,
110
+ },
111
+ "meta.llama3-1-70b-instruct-v1:0": {
112
+ "input": 0.72,
113
+ "output": 0.72,
114
+ },
115
+ "meta.llama3-1-405b-instruct-v1:0": {
116
+ "input": 2.40,
117
+ "output": 2.40,
118
+ },
119
+ # ------------------------------------------------------------------
120
+ # Mistral on Bedrock
121
+ # ------------------------------------------------------------------
122
+ "mistral.mistral-7b-instruct-v0:2": {
123
+ "input": 0.15,
124
+ "output": 0.20,
125
+ },
126
+ "mistral.mixtral-8x7b-instruct-v0:1": {
127
+ "input": 0.45,
128
+ "output": 0.70,
129
+ },
130
+ "mistral.mistral-large-2402-v1:0": {
131
+ "input": 4.00,
132
+ "output": 12.00,
133
+ },
134
+ # ------------------------------------------------------------------
135
+ # Cohere on Bedrock
136
+ # ------------------------------------------------------------------
137
+ "cohere.command-r-plus-v1:0": {
138
+ "input": 3.00,
139
+ "output": 15.00,
140
+ },
141
+ "cohere.command-r-v1:0": {
142
+ "input": 0.50,
143
+ "output": 1.50,
144
+ },
145
+ }
146
+
147
+ # Sentinel to prevent double-patching
148
+ _PATCH_FLAG = "_spanforge_bedrock_patched"
149
+ _patched: bool = False
150
+
151
+
152
+ # ---------------------------------------------------------------------------
153
+ # Public API
154
+ # ---------------------------------------------------------------------------
155
+
156
+
157
+ def patch() -> None:
158
+ """Monkey-patch the Bedrock Runtime client to auto-instrument.
159
+
160
+ Wraps ``converse()`` and ``invoke_model()`` on the ``bedrock-runtime``
161
+ client class. The wrapper extracts token usage from the Converse API
162
+ response and, if a span is currently active, updates it with token usage,
163
+ model info, and cost.
164
+
165
+ This function is **idempotent** — safe to call multiple times.
166
+
167
+ Raises:
168
+ ImportError: If the ``boto3`` package is not installed.
169
+ """
170
+ global _patched
171
+
172
+ _require_boto3()
173
+
174
+ if _patched:
175
+ return
176
+
177
+ _patched = True
178
+
179
+
180
+ def unpatch() -> None:
181
+ """Restore original Bedrock client methods.
182
+
183
+ Safe to call even if :func:`patch` was never called.
184
+ """
185
+ global _patched
186
+ _patched = False
187
+
188
+
189
+ def is_patched() -> bool:
190
+ """Return ``True`` if the Bedrock client has been patched by spanforge."""
191
+ return _patched
192
+
193
+
194
+ def normalize_converse_response(
195
+ response: dict[str, Any],
196
+ *,
197
+ model_id: str = "unknown",
198
+ ) -> tuple[TokenUsage, ModelInfo, CostBreakdown]:
199
+ """Extract structured observability data from a Bedrock Converse response.
200
+
201
+ The Bedrock Converse API returns usage info in ``response["usage"]``
202
+ with keys ``inputTokens`` and ``outputTokens``.
203
+
204
+ Args:
205
+ response: The boto3 ``converse()`` response dict.
206
+ model_id: The modelId that was passed to the API call.
207
+
208
+ Returns:
209
+ A 3-tuple of ``(TokenUsage, ModelInfo, CostBreakdown)``.
210
+ """
211
+ # ------------------------------------------------------------------ usage
212
+ usage = response.get("usage", {})
213
+ input_tokens = int(usage.get("inputTokens", 0))
214
+ output_tokens = int(usage.get("outputTokens", 0))
215
+ total_tokens = input_tokens + output_tokens
216
+
217
+ token_usage = TokenUsage(
218
+ input_tokens=input_tokens,
219
+ output_tokens=output_tokens,
220
+ total_tokens=total_tokens,
221
+ )
222
+
223
+ # ---------------------------------------------------------------- model
224
+ model_info = ModelInfo(system=GenAISystem.AWS_BEDROCK, name=model_id)
225
+
226
+ # ----------------------------------------------------------------- cost
227
+ cost = _compute_cost(model_id, input_tokens, output_tokens)
228
+
229
+ return token_usage, model_info, cost
230
+
231
+
232
+ def list_models() -> list[str]:
233
+ """Return a sorted list of all Bedrock model IDs in the pricing table."""
234
+ return sorted(BEDROCK_PRICING.keys())
235
+
236
+
237
+ # ---------------------------------------------------------------------------
238
+ # Internal helpers
239
+ # ---------------------------------------------------------------------------
240
+
241
+
242
+ def _require_boto3() -> Any:
243
+ """Import and return the ``boto3`` module."""
244
+ try:
245
+ import boto3 # type: ignore[import-untyped]
246
+ except ImportError as exc:
247
+ raise ImportError(
248
+ "The 'boto3' package is required for spanforge Bedrock integration.\n"
249
+ "Install it with: pip install 'spanforge[bedrock]'"
250
+ ) from exc
251
+ else:
252
+ return boto3
253
+
254
+
255
+ def _get_pricing(model_id: str) -> dict[str, float] | None:
256
+ """Return the pricing entry for *model_id*, or ``None`` if unknown.
257
+
258
+ Performs exact match first, then tries without trailing version
259
+ suffixes like ``:0``, ``-v1:0``, etc.
260
+ """
261
+ if model_id in BEDROCK_PRICING:
262
+ return BEDROCK_PRICING[model_id]
263
+
264
+ # Try stripping version suffix (:N or -vN:N)
265
+ base = model_id.split(":")[0] if ":" in model_id else model_id
266
+ for key, value in BEDROCK_PRICING.items():
267
+ if key.startswith(base):
268
+ return value
269
+
270
+ return None
271
+
272
+
273
+ def _compute_cost(
274
+ model_id: str,
275
+ input_tokens: int,
276
+ output_tokens: int,
277
+ ) -> CostBreakdown:
278
+ """Compute :class:`~spanforge.namespaces.trace.CostBreakdown` from token counts."""
279
+ pricing = _get_pricing(model_id)
280
+ if pricing is None:
281
+ return CostBreakdown.zero()
282
+
283
+ input_cost = input_tokens * pricing["input"] / 1_000_000.0
284
+ output_cost = output_tokens * pricing["output"] / 1_000_000.0
285
+ total = input_cost + output_cost
286
+
287
+ return CostBreakdown(
288
+ input_cost_usd=input_cost,
289
+ output_cost_usd=output_cost,
290
+ total_cost_usd=total,
291
+ pricing_date=PRICING_DATE,
292
+ )
@@ -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 contextlib
24
+ import importlib.util
25
+ import time
26
+ import warnings
27
+ from typing import Any
28
+
29
+ __all__ = ["SpanForgeCrewAIHandler", "is_patched", "patch", "unpatch"]
30
+
31
+
32
+ class SpanForgeCrewAIHandler:
33
+ """CrewAI callback handler that emits SpanForge trace events.
34
+
35
+ Manages ``SpanContextManager`` instances for active agents and tool calls,
36
+ records token usage and errors when available from CrewAI's output
37
+ objects, and emits structured SpanForge events on completion.
38
+
39
+ This handler follows the same pattern as
40
+ :class:`~spanforge.integrations.langchain.LLMSchemaCallbackHandler`.
41
+ """
42
+
43
+ def __init__(self) -> None:
44
+ # Map of agent_id/task_id → SpanContextManager so we can close the
45
+ # right span in the matching *_end callback.
46
+ self._agent_spans: dict[str, Any] = {}
47
+ self._tool_spans: dict[str, Any] = {}
48
+ self._task_spans: dict[str, Any] = {}
49
+
50
+ # ------------------------------------------------------------------
51
+ # Agent lifecycle
52
+ # ------------------------------------------------------------------
53
+
54
+ def on_agent_action(
55
+ self,
56
+ agent: Any,
57
+ _task: Any,
58
+ tool: Any,
59
+ tool_input: Any,
60
+ ) -> None:
61
+ """Called when a CrewAI agent takes an action (tool invocation)."""
62
+ try:
63
+ from spanforge import tracer
64
+
65
+ tool_name = getattr(tool, "name", None) or str(tool)
66
+ key = f"{id(agent)}/{tool_name}/{time.time_ns()}"
67
+ cm = tracer.span(
68
+ tool_name,
69
+ operation="tool_call",
70
+ attributes={
71
+ "crewai.tool_input": str(tool_input)[:2048],
72
+ "crewai.agent": _agent_role(agent),
73
+ },
74
+ )
75
+ span = cm.__enter__()
76
+ self._tool_spans[key] = (cm, span, key)
77
+ except Exception: # nosec B110
78
+ pass # hook errors must never abort crew execution
79
+
80
+ def on_agent_finish(self, agent: Any, output: Any) -> None:
81
+ """Called when a CrewAI agent finishes its assigned task."""
82
+ try:
83
+ key = str(id(agent))
84
+ entry = self._agent_spans.pop(key, None)
85
+ if entry is not None:
86
+ cm, span = entry
87
+ if hasattr(output, "return_values"):
88
+ span.set_attribute("crewai.output", str(output.return_values)[:2048])
89
+ cm.__exit__(None, None, None)
90
+ except Exception: # nosec B110
91
+ pass # hook errors must never abort crew execution
92
+
93
+ # ------------------------------------------------------------------
94
+ # Tool lifecycle
95
+ # ------------------------------------------------------------------
96
+
97
+ def on_tool_start(self, tool: Any, tool_input: Any) -> None:
98
+ """Called when a CrewAI tool begins executing."""
99
+ try:
100
+ from spanforge import tracer
101
+
102
+ tool_name = getattr(tool, "name", None) or str(tool)
103
+ key = f"{id(tool)}/{tool_name}/{time.time_ns()}"
104
+ cm = tracer.span(
105
+ tool_name,
106
+ operation="tool_call",
107
+ attributes={"crewai.tool_input": str(tool_input)[:2048]},
108
+ )
109
+ span = cm.__enter__()
110
+ self._tool_spans[key] = (cm, span, key)
111
+ except Exception: # nosec B110
112
+ pass # hook errors must never abort crew execution
113
+
114
+ def on_tool_end(self, tool: Any, output: Any) -> None:
115
+ """Called when a CrewAI tool finishes executing."""
116
+ try:
117
+ tool_name = getattr(tool, "name", None) or str(tool)
118
+ # Find the most recent open span for this tool name.
119
+ key = next(
120
+ (k for k in reversed(list(self._tool_spans)) if tool_name in k),
121
+ None,
122
+ )
123
+ if key is not None:
124
+ cm, span, _ = self._tool_spans.pop(key)
125
+ span.set_attribute("crewai.tool_output", str(output)[:2048])
126
+ cm.__exit__(None, None, None)
127
+ except Exception: # nosec B110
128
+ pass # hook errors must never abort crew execution
129
+
130
+ # ------------------------------------------------------------------
131
+ # Task lifecycle
132
+ # ------------------------------------------------------------------
133
+
134
+ def on_task_start(self, task: Any) -> None:
135
+ """Called when a CrewAI task begins."""
136
+ try:
137
+ from spanforge import tracer
138
+
139
+ task_desc = _task_description(task)
140
+ key = str(id(task))
141
+ cm = tracer.span(
142
+ task_desc,
143
+ operation="invoke_agent",
144
+ attributes={"crewai.task": task_desc},
145
+ )
146
+ span = cm.__enter__()
147
+ self._task_spans[key] = (cm, span)
148
+ except Exception: # nosec B110
149
+ pass # hook errors must never abort crew execution
150
+
151
+ def on_task_end(self, task: Any, output: Any) -> None:
152
+ """Called when a CrewAI task completes."""
153
+ try:
154
+ key = str(id(task))
155
+ entry = self._task_spans.pop(key, None)
156
+ if entry is not None:
157
+ cm, span = entry
158
+ if output is not None:
159
+ span.set_attribute("crewai.task_output", str(output)[:2048])
160
+ cm.__exit__(None, None, None)
161
+ except Exception: # nosec B110
162
+ pass # hook errors must never abort crew execution
163
+
164
+
165
+ # ---------------------------------------------------------------------------
166
+ # Helpers
167
+ # ---------------------------------------------------------------------------
168
+
169
+
170
+ def _agent_role(agent: Any) -> str:
171
+ return str(getattr(agent, "role", None) or getattr(agent, "name", None) or "unknown-agent")
172
+
173
+
174
+ def _task_description(task: Any) -> str:
175
+ desc = getattr(task, "description", None) or getattr(task, "name", None) or "crewai-task"
176
+ return str(desc)[:120]
177
+
178
+
179
+ # ---------------------------------------------------------------------------
180
+ # patch() — convenience auto-registration
181
+ # ---------------------------------------------------------------------------
182
+
183
+
184
+ def patch() -> None:
185
+ """Auto-register :class:`SpanForgeCrewAIHandler` with CrewAI callbacks.
186
+
187
+ Raises:
188
+ ImportError: If ``crewai`` is not installed.
189
+ RuntimeError: If the CrewAI callback API cannot be located.
190
+ """
191
+ if importlib.util.find_spec("crewai") is None:
192
+ raise ImportError(
193
+ "CrewAI package is required for the spanforge CrewAI integration.\n"
194
+ "Install it with: pip install 'spanforge[crewai]'"
195
+ )
196
+ try:
197
+ import crewai
198
+
199
+ # CrewAI exposes a global callbacks list in some versions.
200
+ if hasattr(crewai, "callbacks") and isinstance(crewai.callbacks, list):
201
+ handler = SpanForgeCrewAIHandler()
202
+ crewai.callbacks.append(handler)
203
+ crewai._spanforge_patched = True # type: ignore[attr-defined]
204
+ return
205
+ except Exception as exc:
206
+ warnings.warn(
207
+ f"spanforge: could not auto-patch CrewAI callbacks: {exc}\n"
208
+ "Attach SpanForgeCrewAIHandler manually instead.",
209
+ stacklevel=2,
210
+ )
211
+
212
+
213
+ _PATCH_FLAG = "_spanforge_patched"
214
+
215
+
216
+ def unpatch() -> None:
217
+ """Remove the :class:`SpanForgeCrewAIHandler` from CrewAI global callbacks.
218
+
219
+ Safe to call even if :func:`patch` was never called.
220
+ """
221
+ if importlib.util.find_spec("crewai") is None:
222
+ return
223
+ try:
224
+ import crewai
225
+
226
+ if not getattr(crewai, _PATCH_FLAG, False):
227
+ return
228
+ if hasattr(crewai, "callbacks") and isinstance(crewai.callbacks, list):
229
+ crewai.callbacks[:] = [
230
+ cb for cb in crewai.callbacks if not isinstance(cb, SpanForgeCrewAIHandler)
231
+ ]
232
+ with contextlib.suppress(AttributeError):
233
+ del crewai._spanforge_patched # type: ignore[attr-defined]
234
+ except Exception:
235
+ return
236
+
237
+
238
+ def is_patched() -> bool:
239
+ """Return ``True`` if CrewAI has been patched by :func:`patch`.
240
+
241
+ Returns:
242
+ ``True`` when the spanforge handler is registered; ``False`` otherwise.
243
+ """
244
+ if importlib.util.find_spec("crewai") is None:
245
+ return False
246
+ try:
247
+ import crewai
248
+
249
+ return bool(getattr(crewai, _PATCH_FLAG, False))
250
+ except Exception:
251
+ return False