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.
- spanforge/__init__.py +695 -0
- spanforge/_batch_exporter.py +322 -0
- spanforge/_cli.py +3081 -0
- spanforge/_hooks.py +340 -0
- spanforge/_server.py +953 -0
- spanforge/_span.py +1015 -0
- spanforge/_store.py +287 -0
- spanforge/_stream.py +654 -0
- spanforge/_trace.py +334 -0
- spanforge/_tracer.py +253 -0
- spanforge/actor.py +141 -0
- spanforge/alerts.py +464 -0
- spanforge/auto.py +181 -0
- spanforge/baseline.py +336 -0
- spanforge/config.py +460 -0
- spanforge/consent.py +227 -0
- spanforge/consumer.py +379 -0
- spanforge/core/__init__.py +5 -0
- spanforge/core/compliance_mapping.py +1060 -0
- spanforge/cost.py +597 -0
- spanforge/debug.py +514 -0
- spanforge/drift.py +488 -0
- spanforge/egress.py +63 -0
- spanforge/eval.py +575 -0
- spanforge/event.py +1052 -0
- spanforge/exceptions.py +246 -0
- spanforge/explain.py +181 -0
- spanforge/export/__init__.py +50 -0
- spanforge/export/append_only.py +342 -0
- spanforge/export/cloud.py +349 -0
- spanforge/export/datadog.py +495 -0
- spanforge/export/grafana.py +331 -0
- spanforge/export/jsonl.py +198 -0
- spanforge/export/otel_bridge.py +291 -0
- spanforge/export/otlp.py +817 -0
- spanforge/export/otlp_bridge.py +231 -0
- spanforge/export/redis_backend.py +282 -0
- spanforge/export/webhook.py +302 -0
- spanforge/exporters/__init__.py +29 -0
- spanforge/exporters/console.py +271 -0
- spanforge/exporters/jsonl.py +144 -0
- spanforge/hitl.py +297 -0
- spanforge/inspect.py +429 -0
- spanforge/integrations/__init__.py +39 -0
- spanforge/integrations/_pricing.py +277 -0
- spanforge/integrations/anthropic.py +388 -0
- spanforge/integrations/bedrock.py +306 -0
- spanforge/integrations/crewai.py +251 -0
- spanforge/integrations/gemini.py +349 -0
- spanforge/integrations/groq.py +444 -0
- spanforge/integrations/langchain.py +349 -0
- spanforge/integrations/llamaindex.py +370 -0
- spanforge/integrations/ollama.py +286 -0
- spanforge/integrations/openai.py +370 -0
- spanforge/integrations/together.py +485 -0
- spanforge/metrics.py +393 -0
- spanforge/metrics_export.py +342 -0
- spanforge/migrate.py +278 -0
- spanforge/model_registry.py +282 -0
- spanforge/models.py +407 -0
- spanforge/namespaces/__init__.py +215 -0
- spanforge/namespaces/audit.py +253 -0
- spanforge/namespaces/cache.py +209 -0
- spanforge/namespaces/chain.py +74 -0
- spanforge/namespaces/confidence.py +69 -0
- spanforge/namespaces/consent.py +85 -0
- spanforge/namespaces/cost.py +175 -0
- spanforge/namespaces/decision.py +135 -0
- spanforge/namespaces/diff.py +146 -0
- spanforge/namespaces/drift.py +79 -0
- spanforge/namespaces/eval_.py +232 -0
- spanforge/namespaces/fence.py +180 -0
- spanforge/namespaces/guard.py +104 -0
- spanforge/namespaces/hitl.py +92 -0
- spanforge/namespaces/latency.py +69 -0
- spanforge/namespaces/prompt.py +185 -0
- spanforge/namespaces/redact.py +172 -0
- spanforge/namespaces/template.py +197 -0
- spanforge/namespaces/tool_call.py +76 -0
- spanforge/namespaces/trace.py +1006 -0
- spanforge/normalizer.py +183 -0
- spanforge/presidio_backend.py +149 -0
- spanforge/processor.py +258 -0
- spanforge/prompt_registry.py +415 -0
- spanforge/py.typed +0 -0
- spanforge/redact.py +780 -0
- spanforge/sampling.py +500 -0
- spanforge/schemas/v1.0/schema.json +170 -0
- spanforge/schemas/v2.0/schema.json +536 -0
- spanforge/signing.py +1152 -0
- spanforge/stream.py +559 -0
- spanforge/testing.py +376 -0
- spanforge/trace.py +199 -0
- spanforge/types.py +696 -0
- spanforge/ulid.py +304 -0
- spanforge/validate.py +383 -0
- spanforge-2.0.0.dist-info/METADATA +1777 -0
- spanforge-2.0.0.dist-info/RECORD +101 -0
- spanforge-2.0.0.dist-info/WHEEL +4 -0
- spanforge-2.0.0.dist-info/entry_points.txt +5 -0
- spanforge-2.0.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,277 @@
|
|
|
1
|
+
"""spanforge.integrations._pricing — Unified model pricing table (all providers).
|
|
2
|
+
|
|
3
|
+
Prices are in **USD per million tokens**. This module consolidates pricing
|
|
4
|
+
data from OpenAI, Anthropic, Groq, Together AI and any future providers into
|
|
5
|
+
a single lookup so that ``spanforge.cost._calculate_cost()`` can resolve
|
|
6
|
+
costs for *any* supported model without knowing which provider it belongs to.
|
|
7
|
+
|
|
8
|
+
Individual provider modules (``anthropic.py``, ``groq.py``, ``together.py``)
|
|
9
|
+
still carry their own ``_PRICING`` dicts for use inside ``_compute_cost()``,
|
|
10
|
+
but :func:`get_pricing` here is the **canonical** cross-provider entry point.
|
|
11
|
+
|
|
12
|
+
Schema for each entry::
|
|
13
|
+
|
|
14
|
+
{
|
|
15
|
+
"input": float, # $ / 1M input tokens (required)
|
|
16
|
+
"output": float, # $ / 1M output tokens (required)
|
|
17
|
+
"cached_input": float, # $ / 1M cached input tokens (optional)
|
|
18
|
+
"reasoning": float, # $ / 1M reasoning tokens (optional, o1/o3 only)
|
|
19
|
+
"effective_date": str, # YYYY-MM-DD (optional)
|
|
20
|
+
}
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
from __future__ import annotations
|
|
24
|
+
|
|
25
|
+
__all__ = [
|
|
26
|
+
"OPENAI_PRICING",
|
|
27
|
+
"PRICING_DATE",
|
|
28
|
+
"get_pricing",
|
|
29
|
+
"list_models",
|
|
30
|
+
]
|
|
31
|
+
|
|
32
|
+
# Effective date of this pricing snapshot
|
|
33
|
+
PRICING_DATE: str = "2026-03-04"
|
|
34
|
+
|
|
35
|
+
# ---------------------------------------------------------------------------
|
|
36
|
+
# Static pricing table (USD per million tokens)
|
|
37
|
+
# ---------------------------------------------------------------------------
|
|
38
|
+
|
|
39
|
+
OPENAI_PRICING: dict[str, dict[str, float]] = {
|
|
40
|
+
# ------------------------------------------------------------------
|
|
41
|
+
# GPT-4o family
|
|
42
|
+
# ------------------------------------------------------------------
|
|
43
|
+
"gpt-4o": {
|
|
44
|
+
"input": 2.50,
|
|
45
|
+
"output": 10.00,
|
|
46
|
+
"cached_input": 1.25,
|
|
47
|
+
},
|
|
48
|
+
"gpt-4o-2024-11-20": {
|
|
49
|
+
"input": 2.50,
|
|
50
|
+
"output": 10.00,
|
|
51
|
+
"cached_input": 1.25,
|
|
52
|
+
},
|
|
53
|
+
"gpt-4o-2024-08-06": {
|
|
54
|
+
"input": 2.50,
|
|
55
|
+
"output": 10.00,
|
|
56
|
+
"cached_input": 1.25,
|
|
57
|
+
},
|
|
58
|
+
"gpt-4o-2024-05-13": {
|
|
59
|
+
"input": 5.00,
|
|
60
|
+
"output": 15.00,
|
|
61
|
+
},
|
|
62
|
+
# GPT-4o-mini
|
|
63
|
+
"gpt-4o-mini": {
|
|
64
|
+
"input": 0.15,
|
|
65
|
+
"output": 0.60,
|
|
66
|
+
"cached_input": 0.075,
|
|
67
|
+
},
|
|
68
|
+
"gpt-4o-mini-2024-07-18": {
|
|
69
|
+
"input": 0.15,
|
|
70
|
+
"output": 0.60,
|
|
71
|
+
"cached_input": 0.075,
|
|
72
|
+
},
|
|
73
|
+
# ------------------------------------------------------------------
|
|
74
|
+
# GPT-4 Turbo
|
|
75
|
+
# ------------------------------------------------------------------
|
|
76
|
+
"gpt-4-turbo": {
|
|
77
|
+
"input": 10.00,
|
|
78
|
+
"output": 30.00,
|
|
79
|
+
},
|
|
80
|
+
"gpt-4-turbo-2024-04-09": {
|
|
81
|
+
"input": 10.00,
|
|
82
|
+
"output": 30.00,
|
|
83
|
+
},
|
|
84
|
+
"gpt-4-0125-preview": {
|
|
85
|
+
"input": 10.00,
|
|
86
|
+
"output": 30.00,
|
|
87
|
+
},
|
|
88
|
+
"gpt-4-1106-preview": {
|
|
89
|
+
"input": 10.00,
|
|
90
|
+
"output": 30.00,
|
|
91
|
+
},
|
|
92
|
+
# ------------------------------------------------------------------
|
|
93
|
+
# GPT-4 base
|
|
94
|
+
# ------------------------------------------------------------------
|
|
95
|
+
"gpt-4": {
|
|
96
|
+
"input": 30.00,
|
|
97
|
+
"output": 60.00,
|
|
98
|
+
},
|
|
99
|
+
"gpt-4-0613": {
|
|
100
|
+
"input": 30.00,
|
|
101
|
+
"output": 60.00,
|
|
102
|
+
},
|
|
103
|
+
# ------------------------------------------------------------------
|
|
104
|
+
# GPT-3.5 Turbo
|
|
105
|
+
# ------------------------------------------------------------------
|
|
106
|
+
"gpt-3.5-turbo": {
|
|
107
|
+
"input": 0.50,
|
|
108
|
+
"output": 1.50,
|
|
109
|
+
},
|
|
110
|
+
"gpt-3.5-turbo-0125": {
|
|
111
|
+
"input": 0.50,
|
|
112
|
+
"output": 1.50,
|
|
113
|
+
},
|
|
114
|
+
"gpt-3.5-turbo-1106": {
|
|
115
|
+
"input": 1.00,
|
|
116
|
+
"output": 2.00,
|
|
117
|
+
},
|
|
118
|
+
# ------------------------------------------------------------------
|
|
119
|
+
# o1 reasoning family
|
|
120
|
+
# ------------------------------------------------------------------
|
|
121
|
+
"o1": {
|
|
122
|
+
"input": 15.00,
|
|
123
|
+
"output": 60.00,
|
|
124
|
+
"cached_input": 7.50,
|
|
125
|
+
"reasoning": 60.00,
|
|
126
|
+
},
|
|
127
|
+
"o1-2024-12-17": {
|
|
128
|
+
"input": 15.00,
|
|
129
|
+
"output": 60.00,
|
|
130
|
+
"cached_input": 7.50,
|
|
131
|
+
"reasoning": 60.00,
|
|
132
|
+
},
|
|
133
|
+
"o1-mini": {
|
|
134
|
+
"input": 3.00,
|
|
135
|
+
"output": 12.00,
|
|
136
|
+
"cached_input": 1.50,
|
|
137
|
+
},
|
|
138
|
+
"o1-mini-2024-09-12": {
|
|
139
|
+
"input": 3.00,
|
|
140
|
+
"output": 12.00,
|
|
141
|
+
"cached_input": 1.50,
|
|
142
|
+
},
|
|
143
|
+
"o1-preview": {
|
|
144
|
+
"input": 15.00,
|
|
145
|
+
"output": 60.00,
|
|
146
|
+
"cached_input": 7.50,
|
|
147
|
+
},
|
|
148
|
+
# ------------------------------------------------------------------
|
|
149
|
+
# o3 reasoning family
|
|
150
|
+
# ------------------------------------------------------------------
|
|
151
|
+
"o3-mini": {
|
|
152
|
+
"input": 1.10,
|
|
153
|
+
"output": 4.40,
|
|
154
|
+
"cached_input": 0.55,
|
|
155
|
+
},
|
|
156
|
+
"o3-mini-2025-01-31": {
|
|
157
|
+
"input": 1.10,
|
|
158
|
+
"output": 4.40,
|
|
159
|
+
"cached_input": 0.55,
|
|
160
|
+
},
|
|
161
|
+
"o3": {
|
|
162
|
+
"input": 10.00,
|
|
163
|
+
"output": 40.00,
|
|
164
|
+
"cached_input": 2.50,
|
|
165
|
+
},
|
|
166
|
+
# ------------------------------------------------------------------
|
|
167
|
+
# Embeddings
|
|
168
|
+
# ------------------------------------------------------------------
|
|
169
|
+
"text-embedding-3-small": {
|
|
170
|
+
"input": 0.02,
|
|
171
|
+
"output": 0.00,
|
|
172
|
+
},
|
|
173
|
+
"text-embedding-3-large": {
|
|
174
|
+
"input": 0.13,
|
|
175
|
+
"output": 0.00,
|
|
176
|
+
},
|
|
177
|
+
"text-embedding-ada-002": {
|
|
178
|
+
"input": 0.10,
|
|
179
|
+
"output": 0.00,
|
|
180
|
+
},
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
# ---------------------------------------------------------------------------
|
|
185
|
+
# Public helpers
|
|
186
|
+
# ---------------------------------------------------------------------------
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def get_pricing(model: str) -> dict[str, float] | None:
|
|
190
|
+
"""Return the pricing entry for *model*, or ``None`` if unknown.
|
|
191
|
+
|
|
192
|
+
Searches **all** provider pricing tables in order: OpenAI, Anthropic,
|
|
193
|
+
Groq, Together AI. Performs an exact lookup first, then falls back to
|
|
194
|
+
stripping trailing date suffixes so ``"gpt-4o-mini"`` matches
|
|
195
|
+
``"gpt-4o-mini-2024-07-18"`` entries.
|
|
196
|
+
|
|
197
|
+
Args:
|
|
198
|
+
model: Model name string exactly as returned by the provider API.
|
|
199
|
+
|
|
200
|
+
Returns:
|
|
201
|
+
Pricing dict with at least ``"input"`` and ``"output"`` keys ($/1M
|
|
202
|
+
tokens), or ``None`` if the model is not in any table.
|
|
203
|
+
"""
|
|
204
|
+
result = _lookup_in_table(model, OPENAI_PRICING)
|
|
205
|
+
if result is not None:
|
|
206
|
+
return result
|
|
207
|
+
|
|
208
|
+
# Lazy-import provider tables to avoid circular imports and keep
|
|
209
|
+
# the module importable even if provider packages are not installed.
|
|
210
|
+
for _table_getter in (_get_anthropic_table, _get_groq_table, _get_together_table):
|
|
211
|
+
table = _table_getter()
|
|
212
|
+
if table is not None:
|
|
213
|
+
result = _lookup_in_table(model, table)
|
|
214
|
+
if result is not None:
|
|
215
|
+
return result
|
|
216
|
+
|
|
217
|
+
return None
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
def list_models() -> list[str]:
|
|
221
|
+
"""Return a sorted list of all model names across all provider pricing tables."""
|
|
222
|
+
all_models: set[str] = set(OPENAI_PRICING.keys())
|
|
223
|
+
for _table_getter in (_get_anthropic_table, _get_groq_table, _get_together_table):
|
|
224
|
+
table = _table_getter()
|
|
225
|
+
if table is not None:
|
|
226
|
+
all_models.update(table.keys())
|
|
227
|
+
return sorted(all_models)
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
# ---------------------------------------------------------------------------
|
|
231
|
+
# Internal helpers
|
|
232
|
+
# ---------------------------------------------------------------------------
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
def _lookup_in_table(model: str, table: dict[str, dict[str, float]]) -> dict[str, float] | None:
|
|
236
|
+
"""Exact match, then strip trailing date suffixes."""
|
|
237
|
+
if model in table:
|
|
238
|
+
return table[model]
|
|
239
|
+
|
|
240
|
+
parts = model.rsplit("-", 3)
|
|
241
|
+
for i in range(len(parts) - 1, 0, -1):
|
|
242
|
+
candidate = "-".join(parts[:i])
|
|
243
|
+
if candidate in table:
|
|
244
|
+
return table[candidate]
|
|
245
|
+
|
|
246
|
+
# Together AI uses org/model keys — also try with org prefix stripped.
|
|
247
|
+
if "/" in model:
|
|
248
|
+
bare = model.split("/", 1)[1]
|
|
249
|
+
for key in table:
|
|
250
|
+
if "/" in key and key.split("/", 1)[1] == bare:
|
|
251
|
+
return table[key]
|
|
252
|
+
|
|
253
|
+
return None
|
|
254
|
+
|
|
255
|
+
|
|
256
|
+
def _get_anthropic_table() -> dict[str, dict[str, float]] | None:
|
|
257
|
+
try:
|
|
258
|
+
from spanforge.integrations.anthropic import ANTHROPIC_PRICING # noqa: PLC0415
|
|
259
|
+
return ANTHROPIC_PRICING
|
|
260
|
+
except Exception: # noqa: BLE001
|
|
261
|
+
return None
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
def _get_groq_table() -> dict[str, dict[str, float]] | None:
|
|
265
|
+
try:
|
|
266
|
+
from spanforge.integrations.groq import GROQ_PRICING # noqa: PLC0415
|
|
267
|
+
return GROQ_PRICING
|
|
268
|
+
except Exception: # noqa: BLE001
|
|
269
|
+
return None
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
def _get_together_table() -> dict[str, dict[str, float]] | None:
|
|
273
|
+
try:
|
|
274
|
+
from spanforge.integrations.together import TOGETHER_PRICING # noqa: PLC0415
|
|
275
|
+
return TOGETHER_PRICING
|
|
276
|
+
except Exception: # noqa: BLE001
|
|
277
|
+
return None
|
|
@@ -0,0 +1,388 @@
|
|
|
1
|
+
"""spanforge.integrations.anthropic — Auto-instrumentation for the Anthropic Python SDK.
|
|
2
|
+
|
|
3
|
+
This module monkey-patches the Anthropic client so every
|
|
4
|
+
``client.messages.create(...)`` 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 = ``anthropic``, name
|
|
9
|
+
from response)
|
|
10
|
+
* :class:`~spanforge.namespaces.trace.CostBreakdown` (computed from the static
|
|
11
|
+
pricing table below)
|
|
12
|
+
|
|
13
|
+
Usage::
|
|
14
|
+
|
|
15
|
+
from spanforge.integrations import anthropic as anthropic_integration
|
|
16
|
+
anthropic_integration.patch()
|
|
17
|
+
|
|
18
|
+
import anthropic
|
|
19
|
+
client = anthropic.Anthropic()
|
|
20
|
+
|
|
21
|
+
import spanforge
|
|
22
|
+
spanforge.configure(exporter="console")
|
|
23
|
+
|
|
24
|
+
with spanforge.span("claude-chat", model="claude-3-5-sonnet-20241022") as span:
|
|
25
|
+
resp = client.messages.create(
|
|
26
|
+
model="claude-3-5-sonnet-20241022",
|
|
27
|
+
max_tokens=1024,
|
|
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[anthropic]"
|
|
38
|
+
"""
|
|
39
|
+
|
|
40
|
+
from __future__ import annotations
|
|
41
|
+
|
|
42
|
+
import functools
|
|
43
|
+
from typing import Any
|
|
44
|
+
|
|
45
|
+
from spanforge.namespaces.trace import (
|
|
46
|
+
CostBreakdown,
|
|
47
|
+
GenAISystem,
|
|
48
|
+
ModelInfo,
|
|
49
|
+
TokenUsage,
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
__all__ = [
|
|
53
|
+
"is_patched",
|
|
54
|
+
"normalize_response",
|
|
55
|
+
"patch",
|
|
56
|
+
"unpatch",
|
|
57
|
+
]
|
|
58
|
+
|
|
59
|
+
# ---------------------------------------------------------------------------
|
|
60
|
+
# Static pricing table (USD per million tokens, effective 2026-03-04)
|
|
61
|
+
# ---------------------------------------------------------------------------
|
|
62
|
+
|
|
63
|
+
PRICING_DATE: str = "2026-03-04"
|
|
64
|
+
|
|
65
|
+
#: Anthropic model pricing — USD per million tokens.
|
|
66
|
+
ANTHROPIC_PRICING: dict[str, dict[str, float]] = {
|
|
67
|
+
# ------------------------------------------------------------------
|
|
68
|
+
# Claude 3.5 family
|
|
69
|
+
# ------------------------------------------------------------------
|
|
70
|
+
"claude-3-5-sonnet-20241022": {
|
|
71
|
+
"input": 3.00,
|
|
72
|
+
"output": 15.00,
|
|
73
|
+
},
|
|
74
|
+
"claude-3-5-sonnet-20240620": {
|
|
75
|
+
"input": 3.00,
|
|
76
|
+
"output": 15.00,
|
|
77
|
+
},
|
|
78
|
+
"claude-3-5-haiku-20241022": {
|
|
79
|
+
"input": 0.80,
|
|
80
|
+
"output": 4.00,
|
|
81
|
+
},
|
|
82
|
+
# ------------------------------------------------------------------
|
|
83
|
+
# Claude 3 family
|
|
84
|
+
# ------------------------------------------------------------------
|
|
85
|
+
"claude-3-opus-20240229": {
|
|
86
|
+
"input": 15.00,
|
|
87
|
+
"output": 75.00,
|
|
88
|
+
},
|
|
89
|
+
"claude-3-sonnet-20240229": {
|
|
90
|
+
"input": 3.00,
|
|
91
|
+
"output": 15.00,
|
|
92
|
+
},
|
|
93
|
+
"claude-3-haiku-20240307": {
|
|
94
|
+
"input": 0.25,
|
|
95
|
+
"output": 1.25,
|
|
96
|
+
},
|
|
97
|
+
# ------------------------------------------------------------------
|
|
98
|
+
# Claude 2
|
|
99
|
+
# ------------------------------------------------------------------
|
|
100
|
+
"claude-2.1": {
|
|
101
|
+
"input": 8.00,
|
|
102
|
+
"output": 24.00,
|
|
103
|
+
},
|
|
104
|
+
"claude-2.0": {
|
|
105
|
+
"input": 8.00,
|
|
106
|
+
"output": 24.00,
|
|
107
|
+
},
|
|
108
|
+
# ------------------------------------------------------------------
|
|
109
|
+
# Claude Instant
|
|
110
|
+
# ------------------------------------------------------------------
|
|
111
|
+
"claude-instant-1.2": {
|
|
112
|
+
"input": 0.80,
|
|
113
|
+
"output": 2.40,
|
|
114
|
+
},
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
# Sentinel attribute set on the anthropic module to prevent double-patching.
|
|
118
|
+
_PATCH_FLAG = "_spanforge_patched"
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
# ---------------------------------------------------------------------------
|
|
122
|
+
# Public API
|
|
123
|
+
# ---------------------------------------------------------------------------
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def patch() -> None:
|
|
127
|
+
"""Monkey-patch the Anthropic client to auto-instrument all message creations.
|
|
128
|
+
|
|
129
|
+
Wraps both ``anthropic.resources.Messages.create`` (sync) and the
|
|
130
|
+
async variant. The wrapper calls :func:`normalize_response` on the
|
|
131
|
+
result and, if a span is currently active, updates it with token usage,
|
|
132
|
+
model info, and cost.
|
|
133
|
+
|
|
134
|
+
This function is **idempotent** — safe to call multiple times.
|
|
135
|
+
|
|
136
|
+
Raises:
|
|
137
|
+
ImportError: If the ``anthropic`` package is not installed.
|
|
138
|
+
"""
|
|
139
|
+
anthropic_mod = _require_anthropic()
|
|
140
|
+
|
|
141
|
+
if getattr(anthropic_mod, _PATCH_FLAG, False):
|
|
142
|
+
return # already patched
|
|
143
|
+
|
|
144
|
+
# --- sync ----------------------------------------------------------------
|
|
145
|
+
try:
|
|
146
|
+
from anthropic.resources.messages import ( # noqa: PLC0415
|
|
147
|
+
Messages, # type: ignore[import-untyped]
|
|
148
|
+
)
|
|
149
|
+
|
|
150
|
+
_orig_sync = Messages.create # type: ignore[attr-defined]
|
|
151
|
+
|
|
152
|
+
@functools.wraps(_orig_sync)
|
|
153
|
+
def _patched_sync(self: Any, *args: Any, **kwargs: Any) -> Any: # noqa: ANN401
|
|
154
|
+
response = _orig_sync(self, *args, **kwargs)
|
|
155
|
+
_auto_populate_span(response)
|
|
156
|
+
return response
|
|
157
|
+
|
|
158
|
+
Messages.create = _patched_sync # type: ignore[method-assign]
|
|
159
|
+
Messages._spanforge_orig_create = _orig_sync # type: ignore[attr-defined]
|
|
160
|
+
except (ImportError, AttributeError): # pragma: no cover
|
|
161
|
+
pass
|
|
162
|
+
|
|
163
|
+
# --- async ---------------------------------------------------------------
|
|
164
|
+
try:
|
|
165
|
+
from anthropic.resources.messages import ( # noqa: PLC0415
|
|
166
|
+
AsyncMessages, # type: ignore[import-untyped]
|
|
167
|
+
)
|
|
168
|
+
|
|
169
|
+
_orig_async = AsyncMessages.create # type: ignore[attr-defined]
|
|
170
|
+
|
|
171
|
+
@functools.wraps(_orig_async)
|
|
172
|
+
async def _patched_async(self: Any, *args: Any, **kwargs: Any) -> Any: # noqa: ANN401
|
|
173
|
+
response = await _orig_async(self, *args, **kwargs)
|
|
174
|
+
_auto_populate_span(response)
|
|
175
|
+
return response
|
|
176
|
+
|
|
177
|
+
AsyncMessages.create = _patched_async # type: ignore[method-assign]
|
|
178
|
+
AsyncMessages._spanforge_orig_create = _orig_async # type: ignore[attr-defined]
|
|
179
|
+
except (ImportError, AttributeError): # pragma: no cover
|
|
180
|
+
pass
|
|
181
|
+
|
|
182
|
+
anthropic_mod._spanforge_patched = True # type: ignore[attr-defined]
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def unpatch() -> None:
|
|
186
|
+
"""Restore the original Anthropic methods and remove the patch flag.
|
|
187
|
+
|
|
188
|
+
Safe to call even if :func:`patch` was never called.
|
|
189
|
+
|
|
190
|
+
Raises:
|
|
191
|
+
ImportError: If the ``anthropic`` package is not installed.
|
|
192
|
+
"""
|
|
193
|
+
anthropic_mod = _require_anthropic()
|
|
194
|
+
|
|
195
|
+
if not getattr(anthropic_mod, _PATCH_FLAG, False):
|
|
196
|
+
return # nothing to do
|
|
197
|
+
|
|
198
|
+
try:
|
|
199
|
+
from anthropic.resources.messages import ( # noqa: PLC0415
|
|
200
|
+
Messages, # type: ignore[import-untyped]
|
|
201
|
+
)
|
|
202
|
+
|
|
203
|
+
Messages.create = Messages._spanforge_orig_create # type: ignore[attr-defined,method-assign]
|
|
204
|
+
del Messages._spanforge_orig_create # type: ignore[attr-defined]
|
|
205
|
+
except (ImportError, AttributeError): # pragma: no cover
|
|
206
|
+
pass
|
|
207
|
+
|
|
208
|
+
try:
|
|
209
|
+
from anthropic.resources.messages import ( # noqa: PLC0415
|
|
210
|
+
AsyncMessages, # type: ignore[import-untyped]
|
|
211
|
+
)
|
|
212
|
+
|
|
213
|
+
AsyncMessages.create = AsyncMessages._spanforge_orig_create # type: ignore[attr-defined,method-assign]
|
|
214
|
+
del AsyncMessages._spanforge_orig_create # type: ignore[attr-defined]
|
|
215
|
+
except (ImportError, AttributeError): # pragma: no cover
|
|
216
|
+
pass
|
|
217
|
+
|
|
218
|
+
try: # noqa: SIM105
|
|
219
|
+
del anthropic_mod._spanforge_patched # type: ignore[attr-defined]
|
|
220
|
+
except AttributeError: # pragma: no cover
|
|
221
|
+
pass
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
def is_patched() -> bool:
|
|
225
|
+
"""Return ``True`` if the Anthropic client has been patched by spanforge.
|
|
226
|
+
|
|
227
|
+
Returns ``False`` if the ``anthropic`` package is not installed.
|
|
228
|
+
"""
|
|
229
|
+
try:
|
|
230
|
+
anthropic_mod = _require_anthropic()
|
|
231
|
+
return bool(getattr(anthropic_mod, _PATCH_FLAG, False))
|
|
232
|
+
except ImportError:
|
|
233
|
+
return False
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
def normalize_response(
|
|
237
|
+
response: Any, # noqa: ANN401
|
|
238
|
+
) -> tuple[TokenUsage, ModelInfo, CostBreakdown]:
|
|
239
|
+
"""Extract structured observability data from an Anthropic message response.
|
|
240
|
+
|
|
241
|
+
Works with both ``anthropic.types.Message`` objects and any duck-typed
|
|
242
|
+
mock with the same attribute structure.
|
|
243
|
+
|
|
244
|
+
Args:
|
|
245
|
+
response: An Anthropic ``Message`` (or compatible object).
|
|
246
|
+
|
|
247
|
+
Returns:
|
|
248
|
+
A 3-tuple of ``(TokenUsage, ModelInfo, CostBreakdown)``.
|
|
249
|
+
|
|
250
|
+
Field mapping:
|
|
251
|
+
|
|
252
|
+
+--------------------------------------------+---------------------------+
|
|
253
|
+
| Anthropic field | SpanForge field |
|
|
254
|
+
+============================================+===========================+
|
|
255
|
+
| ``response.model`` | ``ModelInfo.name`` |
|
|
256
|
+
| ``usage.input_tokens`` | ``TokenUsage.input_tokens``|
|
|
257
|
+
| ``usage.output_tokens`` | ``TokenUsage.output_tokens``|
|
|
258
|
+
| ``usage.cache_read_input_tokens`` | ``TokenUsage.cached_tokens``|
|
|
259
|
+
+--------------------------------------------+---------------------------+
|
|
260
|
+
"""
|
|
261
|
+
# ------------------------------------------------------------------ usage
|
|
262
|
+
usage = getattr(response, "usage", None)
|
|
263
|
+
input_tokens: int = 0
|
|
264
|
+
output_tokens: int = 0
|
|
265
|
+
cached_tokens: int | None = None
|
|
266
|
+
|
|
267
|
+
if usage is not None:
|
|
268
|
+
input_tokens = int(getattr(usage, "input_tokens", 0) or 0)
|
|
269
|
+
output_tokens = int(getattr(usage, "output_tokens", 0) or 0)
|
|
270
|
+
|
|
271
|
+
# Anthropic exposes cache reads as ``cache_read_input_tokens``
|
|
272
|
+
cr = getattr(usage, "cache_read_input_tokens", None)
|
|
273
|
+
if cr is not None:
|
|
274
|
+
cached_tokens = int(cr)
|
|
275
|
+
|
|
276
|
+
total_tokens = input_tokens + output_tokens
|
|
277
|
+
|
|
278
|
+
token_usage = TokenUsage(
|
|
279
|
+
input_tokens=input_tokens,
|
|
280
|
+
output_tokens=output_tokens,
|
|
281
|
+
total_tokens=total_tokens,
|
|
282
|
+
cached_tokens=cached_tokens,
|
|
283
|
+
)
|
|
284
|
+
|
|
285
|
+
# ---------------------------------------------------------------- model
|
|
286
|
+
model_name: str = getattr(response, "model", None) or "unknown"
|
|
287
|
+
model_info = ModelInfo(system=GenAISystem.ANTHROPIC, name=model_name)
|
|
288
|
+
|
|
289
|
+
# ----------------------------------------------------------------- cost
|
|
290
|
+
cost = _compute_cost(model_name, input_tokens, output_tokens)
|
|
291
|
+
|
|
292
|
+
return token_usage, model_info, cost
|
|
293
|
+
|
|
294
|
+
|
|
295
|
+
def list_models() -> list[str]:
|
|
296
|
+
"""Return a sorted list of all Anthropic model names in the pricing table."""
|
|
297
|
+
return sorted(ANTHROPIC_PRICING.keys())
|
|
298
|
+
|
|
299
|
+
|
|
300
|
+
# ---------------------------------------------------------------------------
|
|
301
|
+
# Internal helpers
|
|
302
|
+
# ---------------------------------------------------------------------------
|
|
303
|
+
|
|
304
|
+
|
|
305
|
+
def _require_anthropic() -> Any: # noqa: ANN401
|
|
306
|
+
"""Import and return the ``anthropic`` module, raising ``ImportError`` if absent."""
|
|
307
|
+
try:
|
|
308
|
+
import anthropic # type: ignore[import-untyped] # noqa: PLC0415
|
|
309
|
+
except ImportError as exc:
|
|
310
|
+
raise ImportError(
|
|
311
|
+
"The 'anthropic' package is required for spanforge Anthropic integration.\n"
|
|
312
|
+
"Install it with: pip install 'spanforge[anthropic]'"
|
|
313
|
+
) from exc
|
|
314
|
+
else:
|
|
315
|
+
return anthropic
|
|
316
|
+
|
|
317
|
+
|
|
318
|
+
def _get_pricing(model: str) -> dict[str, float] | None:
|
|
319
|
+
"""Return the pricing entry for *model*, or ``None`` if unknown.
|
|
320
|
+
|
|
321
|
+
Performs an exact lookup first, then tries stripping trailing version
|
|
322
|
+
date suffixes (e.g. ``"claude-3-5-sonnet"`` matches
|
|
323
|
+
``"claude-3-5-sonnet-20241022"``).
|
|
324
|
+
"""
|
|
325
|
+
if model in ANTHROPIC_PRICING:
|
|
326
|
+
return ANTHROPIC_PRICING[model]
|
|
327
|
+
|
|
328
|
+
# Try prefix-only matches (strip trailing -YYYYMMDD or -YYYY-MM-DD)
|
|
329
|
+
parts = model.rsplit("-", 3)
|
|
330
|
+
for i in range(len(parts) - 1, 0, -1):
|
|
331
|
+
candidate = "-".join(parts[:i])
|
|
332
|
+
if candidate in ANTHROPIC_PRICING:
|
|
333
|
+
return ANTHROPIC_PRICING[candidate]
|
|
334
|
+
|
|
335
|
+
return None
|
|
336
|
+
|
|
337
|
+
|
|
338
|
+
def _compute_cost(
|
|
339
|
+
model_name: str,
|
|
340
|
+
input_tokens: int,
|
|
341
|
+
output_tokens: int,
|
|
342
|
+
) -> CostBreakdown:
|
|
343
|
+
"""Compute :class:`~spanforge.namespaces.trace.CostBreakdown` from token counts."""
|
|
344
|
+
pricing = _get_pricing(model_name)
|
|
345
|
+
if pricing is None:
|
|
346
|
+
return CostBreakdown.zero()
|
|
347
|
+
|
|
348
|
+
input_cost = input_tokens * pricing["input"] / 1_000_000.0
|
|
349
|
+
output_cost = output_tokens * pricing["output"] / 1_000_000.0
|
|
350
|
+
total = input_cost + output_cost
|
|
351
|
+
|
|
352
|
+
return CostBreakdown(
|
|
353
|
+
input_cost_usd=input_cost,
|
|
354
|
+
output_cost_usd=output_cost,
|
|
355
|
+
total_cost_usd=total,
|
|
356
|
+
pricing_date=PRICING_DATE,
|
|
357
|
+
)
|
|
358
|
+
|
|
359
|
+
|
|
360
|
+
def _auto_populate_span(response: Any) -> None: # noqa: ANN401
|
|
361
|
+
"""If there is an active span on this thread, populate it from *response*.
|
|
362
|
+
|
|
363
|
+
Silently does nothing if:
|
|
364
|
+
|
|
365
|
+
* There is no active span.
|
|
366
|
+
* ``normalize_response`` raises (malformed response).
|
|
367
|
+
* The span already has ``token_usage`` set (don't overwrite manual data).
|
|
368
|
+
"""
|
|
369
|
+
try:
|
|
370
|
+
from spanforge._span import _span_stack # noqa: PLC0415
|
|
371
|
+
|
|
372
|
+
stack = _span_stack()
|
|
373
|
+
if not stack:
|
|
374
|
+
return
|
|
375
|
+
span = stack[-1]
|
|
376
|
+
|
|
377
|
+
if span.token_usage is not None:
|
|
378
|
+
return
|
|
379
|
+
|
|
380
|
+
token_usage, model_info, cost = normalize_response(response)
|
|
381
|
+
span.token_usage = token_usage
|
|
382
|
+
span.cost = cost
|
|
383
|
+
|
|
384
|
+
if span.model is None:
|
|
385
|
+
span.model = model_info.name
|
|
386
|
+
|
|
387
|
+
except Exception: # noqa: S110 # NOSONAR
|
|
388
|
+
pass
|