spanforge 2.0.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (101) hide show
  1. spanforge/__init__.py +695 -0
  2. spanforge/_batch_exporter.py +322 -0
  3. spanforge/_cli.py +3081 -0
  4. spanforge/_hooks.py +340 -0
  5. spanforge/_server.py +953 -0
  6. spanforge/_span.py +1015 -0
  7. spanforge/_store.py +287 -0
  8. spanforge/_stream.py +654 -0
  9. spanforge/_trace.py +334 -0
  10. spanforge/_tracer.py +253 -0
  11. spanforge/actor.py +141 -0
  12. spanforge/alerts.py +464 -0
  13. spanforge/auto.py +181 -0
  14. spanforge/baseline.py +336 -0
  15. spanforge/config.py +460 -0
  16. spanforge/consent.py +227 -0
  17. spanforge/consumer.py +379 -0
  18. spanforge/core/__init__.py +5 -0
  19. spanforge/core/compliance_mapping.py +1060 -0
  20. spanforge/cost.py +597 -0
  21. spanforge/debug.py +514 -0
  22. spanforge/drift.py +488 -0
  23. spanforge/egress.py +63 -0
  24. spanforge/eval.py +575 -0
  25. spanforge/event.py +1052 -0
  26. spanforge/exceptions.py +246 -0
  27. spanforge/explain.py +181 -0
  28. spanforge/export/__init__.py +50 -0
  29. spanforge/export/append_only.py +342 -0
  30. spanforge/export/cloud.py +349 -0
  31. spanforge/export/datadog.py +495 -0
  32. spanforge/export/grafana.py +331 -0
  33. spanforge/export/jsonl.py +198 -0
  34. spanforge/export/otel_bridge.py +291 -0
  35. spanforge/export/otlp.py +817 -0
  36. spanforge/export/otlp_bridge.py +231 -0
  37. spanforge/export/redis_backend.py +282 -0
  38. spanforge/export/webhook.py +302 -0
  39. spanforge/exporters/__init__.py +29 -0
  40. spanforge/exporters/console.py +271 -0
  41. spanforge/exporters/jsonl.py +144 -0
  42. spanforge/hitl.py +297 -0
  43. spanforge/inspect.py +429 -0
  44. spanforge/integrations/__init__.py +39 -0
  45. spanforge/integrations/_pricing.py +277 -0
  46. spanforge/integrations/anthropic.py +388 -0
  47. spanforge/integrations/bedrock.py +306 -0
  48. spanforge/integrations/crewai.py +251 -0
  49. spanforge/integrations/gemini.py +349 -0
  50. spanforge/integrations/groq.py +444 -0
  51. spanforge/integrations/langchain.py +349 -0
  52. spanforge/integrations/llamaindex.py +370 -0
  53. spanforge/integrations/ollama.py +286 -0
  54. spanforge/integrations/openai.py +370 -0
  55. spanforge/integrations/together.py +485 -0
  56. spanforge/metrics.py +393 -0
  57. spanforge/metrics_export.py +342 -0
  58. spanforge/migrate.py +278 -0
  59. spanforge/model_registry.py +282 -0
  60. spanforge/models.py +407 -0
  61. spanforge/namespaces/__init__.py +215 -0
  62. spanforge/namespaces/audit.py +253 -0
  63. spanforge/namespaces/cache.py +209 -0
  64. spanforge/namespaces/chain.py +74 -0
  65. spanforge/namespaces/confidence.py +69 -0
  66. spanforge/namespaces/consent.py +85 -0
  67. spanforge/namespaces/cost.py +175 -0
  68. spanforge/namespaces/decision.py +135 -0
  69. spanforge/namespaces/diff.py +146 -0
  70. spanforge/namespaces/drift.py +79 -0
  71. spanforge/namespaces/eval_.py +232 -0
  72. spanforge/namespaces/fence.py +180 -0
  73. spanforge/namespaces/guard.py +104 -0
  74. spanforge/namespaces/hitl.py +92 -0
  75. spanforge/namespaces/latency.py +69 -0
  76. spanforge/namespaces/prompt.py +185 -0
  77. spanforge/namespaces/redact.py +172 -0
  78. spanforge/namespaces/template.py +197 -0
  79. spanforge/namespaces/tool_call.py +76 -0
  80. spanforge/namespaces/trace.py +1006 -0
  81. spanforge/normalizer.py +183 -0
  82. spanforge/presidio_backend.py +149 -0
  83. spanforge/processor.py +258 -0
  84. spanforge/prompt_registry.py +415 -0
  85. spanforge/py.typed +0 -0
  86. spanforge/redact.py +780 -0
  87. spanforge/sampling.py +500 -0
  88. spanforge/schemas/v1.0/schema.json +170 -0
  89. spanforge/schemas/v2.0/schema.json +536 -0
  90. spanforge/signing.py +1152 -0
  91. spanforge/stream.py +559 -0
  92. spanforge/testing.py +376 -0
  93. spanforge/trace.py +199 -0
  94. spanforge/types.py +696 -0
  95. spanforge/ulid.py +304 -0
  96. spanforge/validate.py +383 -0
  97. spanforge-2.0.0.dist-info/METADATA +1777 -0
  98. spanforge-2.0.0.dist-info/RECORD +101 -0
  99. spanforge-2.0.0.dist-info/WHEEL +4 -0
  100. spanforge-2.0.0.dist-info/entry_points.txt +5 -0
  101. spanforge-2.0.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,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