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,368 @@
|
|
|
1
|
+
"""spanforge.integrations.openai — Auto-instrumentation for the OpenAI Python SDK.
|
|
2
|
+
|
|
3
|
+
This module monkey-patches the OpenAI client so every
|
|
4
|
+
``client.chat.completions.create(...)`` call automatically populates the
|
|
5
|
+
active :class:`~spanforge._span.Span` with:
|
|
6
|
+
|
|
7
|
+
* :class:`~spanforge.namespaces.trace.TokenUsage` (input / output / cached /
|
|
8
|
+
reasoning token counts)
|
|
9
|
+
* :class:`~spanforge.namespaces.trace.ModelInfo` (provider = ``openai``, name
|
|
10
|
+
from response)
|
|
11
|
+
* :class:`~spanforge.namespaces.trace.CostBreakdown` (computed from the static
|
|
12
|
+
pricing table in :mod:`spanforge.integrations._pricing`)
|
|
13
|
+
|
|
14
|
+
Usage::
|
|
15
|
+
|
|
16
|
+
from spanforge.integrations import openai as openai_integration
|
|
17
|
+
openai_integration.patch()
|
|
18
|
+
|
|
19
|
+
import openai
|
|
20
|
+
client = openai.OpenAI()
|
|
21
|
+
|
|
22
|
+
import spanforge
|
|
23
|
+
spanforge.configure(exporter="console")
|
|
24
|
+
|
|
25
|
+
with spanforge.tracer.span("chat", model="gpt-4o") as span:
|
|
26
|
+
resp = client.chat.completions.create(
|
|
27
|
+
model="gpt-4o",
|
|
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[openai]"
|
|
38
|
+
"""
|
|
39
|
+
|
|
40
|
+
from __future__ import annotations
|
|
41
|
+
|
|
42
|
+
import functools
|
|
43
|
+
from typing import Any
|
|
44
|
+
|
|
45
|
+
from spanforge.integrations._pricing import PRICING_DATE, get_pricing
|
|
46
|
+
from spanforge.namespaces.trace import (
|
|
47
|
+
CostBreakdown,
|
|
48
|
+
GenAISystem,
|
|
49
|
+
ModelInfo,
|
|
50
|
+
TokenUsage,
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
__all__ = [
|
|
54
|
+
"is_patched",
|
|
55
|
+
"normalize_response",
|
|
56
|
+
"patch",
|
|
57
|
+
"unpatch",
|
|
58
|
+
]
|
|
59
|
+
|
|
60
|
+
# Sentinel attribute set on the openai module to prevent double-patching.
|
|
61
|
+
_PATCH_FLAG = "_spanforge_patched"
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
# ---------------------------------------------------------------------------
|
|
65
|
+
# Public API
|
|
66
|
+
# ---------------------------------------------------------------------------
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def patch() -> None:
|
|
70
|
+
"""Monkey-patch the OpenAI client to auto-instrument all chat completions.
|
|
71
|
+
|
|
72
|
+
Wraps both ``openai.resources.chat.completions.Completions.create``
|
|
73
|
+
(sync) and ``AsyncCompletions.create`` (async). The wrapper calls
|
|
74
|
+
:func:`normalize_response` on the result and, if a span is currently
|
|
75
|
+
active on this thread, updates it with token usage, model info, and cost.
|
|
76
|
+
|
|
77
|
+
This function is **idempotent** — safe to call multiple times.
|
|
78
|
+
|
|
79
|
+
Raises:
|
|
80
|
+
ImportError: If the ``openai`` package is not installed.
|
|
81
|
+
"""
|
|
82
|
+
openai_mod = _require_openai()
|
|
83
|
+
|
|
84
|
+
if getattr(openai_mod, _PATCH_FLAG, False):
|
|
85
|
+
return # already patched
|
|
86
|
+
|
|
87
|
+
# --- sync ----------------------------------------------------------------
|
|
88
|
+
from openai.resources.chat.completions import (
|
|
89
|
+
Completions, # type: ignore[import-untyped]
|
|
90
|
+
)
|
|
91
|
+
|
|
92
|
+
_orig_sync = Completions.create # type: ignore[attr-defined]
|
|
93
|
+
|
|
94
|
+
@functools.wraps(_orig_sync)
|
|
95
|
+
def _patched_sync(self: Any, *args: Any, **kwargs: Any) -> Any:
|
|
96
|
+
response = _orig_sync(self, *args, **kwargs)
|
|
97
|
+
_auto_populate_span(response)
|
|
98
|
+
return response
|
|
99
|
+
|
|
100
|
+
Completions.create = _patched_sync # type: ignore[method-assign]
|
|
101
|
+
Completions._spanforge_orig_create = _orig_sync # type: ignore[attr-defined]
|
|
102
|
+
|
|
103
|
+
# --- async ---------------------------------------------------------------
|
|
104
|
+
try:
|
|
105
|
+
from openai.resources.chat.completions import (
|
|
106
|
+
AsyncCompletions, # type: ignore[import-untyped]
|
|
107
|
+
)
|
|
108
|
+
|
|
109
|
+
_orig_async = AsyncCompletions.create # type: ignore[attr-defined]
|
|
110
|
+
|
|
111
|
+
@functools.wraps(_orig_async)
|
|
112
|
+
async def _patched_async(self: Any, *args: Any, **kwargs: Any) -> Any:
|
|
113
|
+
response = await _orig_async(self, *args, **kwargs)
|
|
114
|
+
_auto_populate_span(response)
|
|
115
|
+
return response
|
|
116
|
+
|
|
117
|
+
AsyncCompletions.create = _patched_async # type: ignore[method-assign]
|
|
118
|
+
AsyncCompletions._spanforge_orig_create = _orig_async # type: ignore[attr-defined]
|
|
119
|
+
except (ImportError, AttributeError): # pragma: no cover
|
|
120
|
+
pass
|
|
121
|
+
|
|
122
|
+
setattr(openai_mod, _PATCH_FLAG, True)
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def unpatch() -> None:
|
|
126
|
+
"""Restore the original OpenAI methods and remove the patch flag.
|
|
127
|
+
|
|
128
|
+
Safe to call even if :func:`patch` was never called.
|
|
129
|
+
|
|
130
|
+
Raises:
|
|
131
|
+
ImportError: If the ``openai`` package is not installed.
|
|
132
|
+
"""
|
|
133
|
+
openai_mod = _require_openai()
|
|
134
|
+
|
|
135
|
+
if not getattr(openai_mod, _PATCH_FLAG, False):
|
|
136
|
+
return # nothing to do
|
|
137
|
+
|
|
138
|
+
try:
|
|
139
|
+
from openai.resources.chat.completions import (
|
|
140
|
+
Completions, # type: ignore[import-untyped]
|
|
141
|
+
)
|
|
142
|
+
|
|
143
|
+
Completions.create = Completions._spanforge_orig_create # type: ignore[attr-defined,method-assign]
|
|
144
|
+
del Completions._spanforge_orig_create # type: ignore[attr-defined]
|
|
145
|
+
except (ImportError, AttributeError): # pragma: no cover
|
|
146
|
+
pass
|
|
147
|
+
|
|
148
|
+
try:
|
|
149
|
+
from openai.resources.chat.completions import (
|
|
150
|
+
AsyncCompletions, # type: ignore[import-untyped]
|
|
151
|
+
)
|
|
152
|
+
|
|
153
|
+
AsyncCompletions.create = AsyncCompletions._spanforge_orig_create # type: ignore[attr-defined,method-assign]
|
|
154
|
+
del AsyncCompletions._spanforge_orig_create # type: ignore[attr-defined]
|
|
155
|
+
except (ImportError, AttributeError): # pragma: no cover
|
|
156
|
+
pass
|
|
157
|
+
|
|
158
|
+
try:
|
|
159
|
+
delattr(openai_mod, _PATCH_FLAG)
|
|
160
|
+
except AttributeError: # pragma: no cover
|
|
161
|
+
pass
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def is_patched() -> bool:
|
|
165
|
+
"""Return ``True`` if the OpenAI client has been patched by spanforge.
|
|
166
|
+
|
|
167
|
+
Returns ``False`` if the ``openai`` package is not installed.
|
|
168
|
+
"""
|
|
169
|
+
try:
|
|
170
|
+
openai_mod = _require_openai()
|
|
171
|
+
return bool(getattr(openai_mod, _PATCH_FLAG, False))
|
|
172
|
+
except ImportError:
|
|
173
|
+
return False
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def normalize_response(
|
|
177
|
+
response: Any,
|
|
178
|
+
) -> tuple[TokenUsage, ModelInfo, CostBreakdown]:
|
|
179
|
+
"""Extract structured observability data from an OpenAI chat completion.
|
|
180
|
+
|
|
181
|
+
Works with both ``openai.types.chat.ChatCompletion`` objects and any
|
|
182
|
+
duck-typed mock with the same attribute structure.
|
|
183
|
+
|
|
184
|
+
Args:
|
|
185
|
+
response: An OpenAI ``ChatCompletion`` (or compatible object).
|
|
186
|
+
|
|
187
|
+
Returns:
|
|
188
|
+
A 3-tuple of ``(TokenUsage, ModelInfo, CostBreakdown)``.
|
|
189
|
+
|
|
190
|
+
Field mapping:
|
|
191
|
+
|
|
192
|
+
+--------------------------------------------+---------------------------+
|
|
193
|
+
| OpenAI field | SpanForge field |
|
|
194
|
+
+============================================+===========================+
|
|
195
|
+
| ``response.model`` | ``ModelInfo.name`` |
|
|
196
|
+
| ``usage.prompt_tokens`` | ``TokenUsage.input_tokens``|
|
|
197
|
+
| ``usage.completion_tokens`` | ``TokenUsage.output_tokens``|
|
|
198
|
+
| ``usage.total_tokens`` | ``TokenUsage.total_tokens``|
|
|
199
|
+
| ``usage.prompt_tokens_details.cached_tokens`` | ``TokenUsage.cached_tokens``|
|
|
200
|
+
| ``usage.completion_tokens_details.reasoning_tokens``| ``TokenUsage.reasoning_tokens``|
|
|
201
|
+
+--------------------------------------------+---------------------------+
|
|
202
|
+
"""
|
|
203
|
+
# ------------------------------------------------------------------ usage
|
|
204
|
+
usage = getattr(response, "usage", None)
|
|
205
|
+
input_tokens: int = 0
|
|
206
|
+
output_tokens: int = 0
|
|
207
|
+
total_tokens: int = 0
|
|
208
|
+
cached_tokens: int | None = None
|
|
209
|
+
reasoning_tokens: int | None = None
|
|
210
|
+
|
|
211
|
+
if usage is not None:
|
|
212
|
+
input_tokens = int(getattr(usage, "prompt_tokens", 0) or 0)
|
|
213
|
+
output_tokens = int(getattr(usage, "completion_tokens", 0) or 0)
|
|
214
|
+
total_tokens = int(getattr(usage, "total_tokens", input_tokens + output_tokens) or 0)
|
|
215
|
+
|
|
216
|
+
# Prompt token details (cached)
|
|
217
|
+
ptd = getattr(usage, "prompt_tokens_details", None)
|
|
218
|
+
if ptd is not None:
|
|
219
|
+
ct = getattr(ptd, "cached_tokens", None)
|
|
220
|
+
if ct is not None:
|
|
221
|
+
cached_tokens = int(ct)
|
|
222
|
+
|
|
223
|
+
# Completion token details (reasoning)
|
|
224
|
+
ctd = getattr(usage, "completion_tokens_details", None)
|
|
225
|
+
if ctd is not None:
|
|
226
|
+
rt = getattr(ctd, "reasoning_tokens", None)
|
|
227
|
+
if rt is not None:
|
|
228
|
+
reasoning_tokens = int(rt)
|
|
229
|
+
|
|
230
|
+
token_usage = TokenUsage(
|
|
231
|
+
input_tokens=input_tokens,
|
|
232
|
+
output_tokens=output_tokens,
|
|
233
|
+
total_tokens=total_tokens,
|
|
234
|
+
cached_tokens=cached_tokens,
|
|
235
|
+
reasoning_tokens=reasoning_tokens,
|
|
236
|
+
)
|
|
237
|
+
|
|
238
|
+
# ---------------------------------------------------------------- model
|
|
239
|
+
model_name: str = getattr(response, "model", None) or "unknown"
|
|
240
|
+
model_info = ModelInfo(system=GenAISystem.OPENAI, name=model_name)
|
|
241
|
+
|
|
242
|
+
# ----------------------------------------------------------------- cost
|
|
243
|
+
cost = _compute_cost(model_name, input_tokens, output_tokens, cached_tokens, reasoning_tokens)
|
|
244
|
+
|
|
245
|
+
return token_usage, model_info, cost
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
# ---------------------------------------------------------------------------
|
|
249
|
+
# Internal helpers
|
|
250
|
+
# ---------------------------------------------------------------------------
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
def _require_openai() -> Any:
|
|
254
|
+
"""Import and return the ``openai`` module, raising ``ImportError`` if absent."""
|
|
255
|
+
try:
|
|
256
|
+
import openai # type: ignore[import-untyped]
|
|
257
|
+
except ImportError as exc:
|
|
258
|
+
raise ImportError(
|
|
259
|
+
"The 'openai' package is required for spanforge OpenAI integration.\n"
|
|
260
|
+
"Install it with: pip install 'spanforge[openai]'"
|
|
261
|
+
) from exc
|
|
262
|
+
else:
|
|
263
|
+
return openai
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
def _compute_cost(
|
|
267
|
+
model_name: str,
|
|
268
|
+
input_tokens: int,
|
|
269
|
+
output_tokens: int,
|
|
270
|
+
cached_tokens: int | None,
|
|
271
|
+
reasoning_tokens: int | None,
|
|
272
|
+
) -> CostBreakdown:
|
|
273
|
+
"""Compute :class:`~spanforge.namespaces.trace.CostBreakdown` from token counts.
|
|
274
|
+
|
|
275
|
+
Uses the static pricing table. Falls back to :meth:`CostBreakdown.zero`
|
|
276
|
+
for unknown models.
|
|
277
|
+
|
|
278
|
+
Args:
|
|
279
|
+
model_name: Model name string (as returned by the API).
|
|
280
|
+
input_tokens: Total input tokens (including any cached tokens).
|
|
281
|
+
output_tokens: Output / completion tokens.
|
|
282
|
+
cached_tokens: Subset of input tokens served from the prompt cache.
|
|
283
|
+
reasoning_tokens: Reasoning tokens (o1/o3 models).
|
|
284
|
+
|
|
285
|
+
Returns:
|
|
286
|
+
A :class:`~spanforge.namespaces.trace.CostBreakdown` instance.
|
|
287
|
+
"""
|
|
288
|
+
pricing = get_pricing(model_name)
|
|
289
|
+
if pricing is None:
|
|
290
|
+
return CostBreakdown.zero()
|
|
291
|
+
|
|
292
|
+
input_rate = pricing["input"] # $/1M tokens
|
|
293
|
+
output_rate = pricing["output"]
|
|
294
|
+
|
|
295
|
+
# Full-price input cost (we'll deduct the cached discount separately)
|
|
296
|
+
input_cost = input_tokens * input_rate / 1_000_000.0
|
|
297
|
+
output_cost = output_tokens * output_rate / 1_000_000.0
|
|
298
|
+
|
|
299
|
+
# Cached discount: tokens served from cache are billed at cached_input rate
|
|
300
|
+
cached_discount = 0.0
|
|
301
|
+
cached_rate = pricing.get("cached_input")
|
|
302
|
+
if cached_tokens and cached_rate is not None:
|
|
303
|
+
# We already charged these at full input_rate; reduce by the difference
|
|
304
|
+
cached_discount = cached_tokens * (input_rate - cached_rate) / 1_000_000.0
|
|
305
|
+
cached_discount = max(0.0, cached_discount)
|
|
306
|
+
|
|
307
|
+
# Reasoning cost: reasoning tokens in o1/o3 are billed at the output rate
|
|
308
|
+
# (already included in output_tokens from the API, so reasoning_cost_usd = 0
|
|
309
|
+
# unless the model has a separate reasoning rate)
|
|
310
|
+
reasoning_cost = 0.0
|
|
311
|
+
reasoning_rate = pricing.get("reasoning")
|
|
312
|
+
if reasoning_tokens and reasoning_rate is not None:
|
|
313
|
+
# Some models bill reasoning tokens at a rate that may differ from the
|
|
314
|
+
# output rate (future-proofing). For o1, reasoning_rate == output_rate
|
|
315
|
+
# so this branch is arithmetically a no-op, but the code path is kept
|
|
316
|
+
# for models where they diverge.
|
|
317
|
+
# Reasoning tokens are already counted within output_tokens by the API,
|
|
318
|
+
# so we rebill them separately and remove them from regular output cost.
|
|
319
|
+
regular_output = output_tokens - reasoning_tokens
|
|
320
|
+
regular_output = max(0, regular_output)
|
|
321
|
+
output_cost = regular_output * output_rate / 1_000_000.0
|
|
322
|
+
reasoning_cost = reasoning_tokens * reasoning_rate / 1_000_000.0
|
|
323
|
+
|
|
324
|
+
total = input_cost + output_cost + reasoning_cost - cached_discount
|
|
325
|
+
total = max(0.0, total)
|
|
326
|
+
|
|
327
|
+
return CostBreakdown(
|
|
328
|
+
input_cost_usd=input_cost,
|
|
329
|
+
output_cost_usd=output_cost,
|
|
330
|
+
total_cost_usd=total,
|
|
331
|
+
cached_discount_usd=cached_discount,
|
|
332
|
+
reasoning_cost_usd=reasoning_cost,
|
|
333
|
+
pricing_date=PRICING_DATE,
|
|
334
|
+
)
|
|
335
|
+
|
|
336
|
+
|
|
337
|
+
def _auto_populate_span(response: Any) -> None:
|
|
338
|
+
"""If there is an active span on this thread, populate it from *response*.
|
|
339
|
+
|
|
340
|
+
Silently does nothing if:
|
|
341
|
+
|
|
342
|
+
* There is no active span.
|
|
343
|
+
* ``normalize_response`` raises (malformed response).
|
|
344
|
+
* The span already has ``token_usage`` set (don't overwrite manual data).
|
|
345
|
+
"""
|
|
346
|
+
try:
|
|
347
|
+
from spanforge._span import _span_stack
|
|
348
|
+
|
|
349
|
+
stack = _span_stack()
|
|
350
|
+
if not stack:
|
|
351
|
+
return
|
|
352
|
+
span = stack[-1]
|
|
353
|
+
|
|
354
|
+
# Don't overwrite data that the user set manually.
|
|
355
|
+
if span.token_usage is not None:
|
|
356
|
+
return
|
|
357
|
+
|
|
358
|
+
token_usage, model_info, cost = normalize_response(response)
|
|
359
|
+
span.token_usage = token_usage
|
|
360
|
+
span.cost = cost
|
|
361
|
+
|
|
362
|
+
# Update the model string if not already set
|
|
363
|
+
if span.model is None:
|
|
364
|
+
span.model = model_info.name
|
|
365
|
+
|
|
366
|
+
except Exception: # NOSONAR
|
|
367
|
+
# Never let instrumentation errors surface in user code.
|
|
368
|
+
return
|