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.
- spanforge/__init__.py +815 -0
- spanforge/_ansi.py +93 -0
- spanforge/_batch_exporter.py +409 -0
- spanforge/_cli.py +2094 -0
- spanforge/_cli_audit.py +639 -0
- spanforge/_cli_compliance.py +711 -0
- spanforge/_cli_cost.py +243 -0
- spanforge/_cli_ops.py +791 -0
- spanforge/_cli_phase11.py +356 -0
- spanforge/_hooks.py +337 -0
- spanforge/_server.py +1708 -0
- spanforge/_span.py +1036 -0
- spanforge/_store.py +288 -0
- spanforge/_stream.py +664 -0
- spanforge/_trace.py +335 -0
- spanforge/_tracer.py +254 -0
- spanforge/actor.py +141 -0
- spanforge/alerts.py +469 -0
- spanforge/auto.py +464 -0
- spanforge/baseline.py +335 -0
- spanforge/cache.py +635 -0
- spanforge/compliance.py +325 -0
- spanforge/config.py +532 -0
- spanforge/consent.py +228 -0
- spanforge/consumer.py +377 -0
- spanforge/core/__init__.py +5 -0
- spanforge/core/compliance_mapping.py +1254 -0
- spanforge/cost.py +600 -0
- spanforge/debug.py +548 -0
- spanforge/deprecations.py +205 -0
- spanforge/drift.py +482 -0
- spanforge/egress.py +58 -0
- spanforge/eval.py +648 -0
- spanforge/event.py +1064 -0
- spanforge/exceptions.py +240 -0
- spanforge/explain.py +178 -0
- spanforge/export/__init__.py +69 -0
- spanforge/export/append_only.py +337 -0
- spanforge/export/cloud.py +357 -0
- spanforge/export/datadog.py +497 -0
- spanforge/export/grafana.py +320 -0
- spanforge/export/jsonl.py +195 -0
- spanforge/export/openinference.py +158 -0
- spanforge/export/otel_bridge.py +294 -0
- spanforge/export/otlp.py +811 -0
- spanforge/export/otlp_bridge.py +233 -0
- spanforge/export/redis_backend.py +282 -0
- spanforge/export/siem_schema.py +98 -0
- spanforge/export/siem_splunk.py +264 -0
- spanforge/export/siem_syslog.py +212 -0
- spanforge/export/webhook.py +299 -0
- spanforge/exporters/__init__.py +30 -0
- spanforge/exporters/console.py +271 -0
- spanforge/exporters/jsonl.py +144 -0
- spanforge/exporters/sqlite.py +142 -0
- spanforge/gate.py +1150 -0
- spanforge/governance.py +181 -0
- spanforge/hitl.py +295 -0
- spanforge/http.py +187 -0
- spanforge/inspect.py +427 -0
- spanforge/integrations/__init__.py +45 -0
- spanforge/integrations/_pricing.py +280 -0
- spanforge/integrations/anthropic.py +388 -0
- spanforge/integrations/azure_openai.py +133 -0
- spanforge/integrations/bedrock.py +292 -0
- spanforge/integrations/crewai.py +251 -0
- spanforge/integrations/gemini.py +351 -0
- spanforge/integrations/groq.py +442 -0
- spanforge/integrations/langchain.py +349 -0
- spanforge/integrations/langgraph.py +306 -0
- spanforge/integrations/llamaindex.py +373 -0
- spanforge/integrations/ollama.py +287 -0
- spanforge/integrations/openai.py +368 -0
- spanforge/integrations/together.py +483 -0
- spanforge/io.py +214 -0
- spanforge/lint.py +322 -0
- spanforge/metrics.py +417 -0
- spanforge/metrics_export.py +343 -0
- spanforge/migrate.py +402 -0
- spanforge/model_registry.py +278 -0
- spanforge/models.py +389 -0
- spanforge/namespaces/__init__.py +254 -0
- spanforge/namespaces/audit.py +256 -0
- spanforge/namespaces/cache.py +237 -0
- spanforge/namespaces/chain.py +77 -0
- spanforge/namespaces/confidence.py +72 -0
- spanforge/namespaces/consent.py +92 -0
- spanforge/namespaces/cost.py +179 -0
- spanforge/namespaces/decision.py +143 -0
- spanforge/namespaces/diff.py +157 -0
- spanforge/namespaces/drift.py +80 -0
- spanforge/namespaces/eval_.py +251 -0
- spanforge/namespaces/feedback.py +241 -0
- spanforge/namespaces/fence.py +193 -0
- spanforge/namespaces/guard.py +105 -0
- spanforge/namespaces/hitl.py +91 -0
- spanforge/namespaces/latency.py +72 -0
- spanforge/namespaces/prompt.py +190 -0
- spanforge/namespaces/redact.py +173 -0
- spanforge/namespaces/retrieval.py +379 -0
- spanforge/namespaces/runtime_governance.py +494 -0
- spanforge/namespaces/template.py +208 -0
- spanforge/namespaces/tool_call.py +77 -0
- spanforge/namespaces/trace.py +1029 -0
- spanforge/normalizer.py +171 -0
- spanforge/plugins.py +82 -0
- spanforge/presidio_backend.py +349 -0
- spanforge/processor.py +258 -0
- spanforge/prompt_registry.py +418 -0
- spanforge/py.typed +0 -0
- spanforge/redact.py +914 -0
- spanforge/regression.py +192 -0
- spanforge/runtime_policy.py +159 -0
- spanforge/sampling.py +511 -0
- spanforge/schema.py +183 -0
- spanforge/schemas/v1.0/schema.json +170 -0
- spanforge/schemas/v2.0/schema.json +536 -0
- spanforge/sdk/__init__.py +625 -0
- spanforge/sdk/_base.py +584 -0
- spanforge/sdk/_base.pyi +71 -0
- spanforge/sdk/_exceptions.py +1096 -0
- spanforge/sdk/_types.py +2184 -0
- spanforge/sdk/alert.py +1514 -0
- spanforge/sdk/alert.pyi +56 -0
- spanforge/sdk/audit.py +1196 -0
- spanforge/sdk/audit.pyi +67 -0
- spanforge/sdk/cec.py +1215 -0
- spanforge/sdk/cec.pyi +37 -0
- spanforge/sdk/config.py +641 -0
- spanforge/sdk/config.pyi +55 -0
- spanforge/sdk/enterprise.py +714 -0
- spanforge/sdk/enterprise.pyi +79 -0
- spanforge/sdk/explain.py +170 -0
- spanforge/sdk/fallback.py +432 -0
- spanforge/sdk/feedback.py +351 -0
- spanforge/sdk/gate.py +874 -0
- spanforge/sdk/gate.pyi +51 -0
- spanforge/sdk/identity.py +2114 -0
- spanforge/sdk/identity.pyi +47 -0
- spanforge/sdk/lineage.py +175 -0
- spanforge/sdk/observe.py +1065 -0
- spanforge/sdk/observe.pyi +50 -0
- spanforge/sdk/operator.py +338 -0
- spanforge/sdk/pii.py +1473 -0
- spanforge/sdk/pii.pyi +119 -0
- spanforge/sdk/pipelines.py +458 -0
- spanforge/sdk/pipelines.pyi +39 -0
- spanforge/sdk/policy.py +930 -0
- spanforge/sdk/rag.py +594 -0
- spanforge/sdk/rbac.py +280 -0
- spanforge/sdk/registry.py +430 -0
- spanforge/sdk/registry.pyi +46 -0
- spanforge/sdk/scope.py +279 -0
- spanforge/sdk/secrets.py +293 -0
- spanforge/sdk/secrets.pyi +25 -0
- spanforge/sdk/security.py +560 -0
- spanforge/sdk/security.pyi +57 -0
- spanforge/sdk/trust.py +472 -0
- spanforge/sdk/trust.pyi +41 -0
- spanforge/secrets.py +799 -0
- spanforge/signing.py +1179 -0
- spanforge/stats.py +100 -0
- spanforge/stream.py +560 -0
- spanforge/testing.py +378 -0
- spanforge/testing_mocks.py +1052 -0
- spanforge/trace.py +199 -0
- spanforge/types.py +696 -0
- spanforge/ulid.py +300 -0
- spanforge/validate.py +379 -0
- spanforge-1.0.0.dist-info/METADATA +1509 -0
- spanforge-1.0.0.dist-info/RECORD +174 -0
- spanforge-1.0.0.dist-info/WHEEL +4 -0
- spanforge-1.0.0.dist-info/entry_points.txt +5 -0
- 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
|