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
spanforge/cost.py
ADDED
|
@@ -0,0 +1,600 @@
|
|
|
1
|
+
"""spanforge.cost — Cost Calculation Engine (RFC-0001 §9, Tool 2).
|
|
2
|
+
|
|
3
|
+
Provides:
|
|
4
|
+
- :class:`CostRecord` — immutable record for a single LLM call cost entry.
|
|
5
|
+
- :class:`CostTracker` — accumulates ``CostRecord`` objects; computes aggregates.
|
|
6
|
+
- :class:`BudgetMonitor` — fires a callback when a cost threshold is exceeded.
|
|
7
|
+
- :func:`budget_alert` — convenience factory that registers a :class:`BudgetMonitor`
|
|
8
|
+
against the global tracker.
|
|
9
|
+
- :func:`emit_cost_event` — builds a ``llm.cost.token.recorded`` event from a
|
|
10
|
+
:class:`~spanforge._span.Span` and dispatches it through the active exporter.
|
|
11
|
+
- :func:`emit_cost_attributed` — emits a ``llm.cost.attributed`` event.
|
|
12
|
+
- :func:`cost_summary` — returns a plain-text table of cost data from a tracker.
|
|
13
|
+
|
|
14
|
+
Usage::
|
|
15
|
+
|
|
16
|
+
from spanforge.cost import CostTracker, budget_alert
|
|
17
|
+
|
|
18
|
+
tracker = CostTracker()
|
|
19
|
+
budget_alert(0.50, on_exceeded=lambda t: print(f"Budget hit! ${t.total_usd:.4f}"),
|
|
20
|
+
tracker=tracker)
|
|
21
|
+
|
|
22
|
+
tracker.record("gpt-4o", input_tokens=500, output_tokens=200)
|
|
23
|
+
tracker.record("gpt-4o-mini", input_tokens=1000, output_tokens=400)
|
|
24
|
+
print(cost_summary(tracker))
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
from __future__ import annotations
|
|
28
|
+
|
|
29
|
+
import contextlib
|
|
30
|
+
import threading
|
|
31
|
+
import time
|
|
32
|
+
from dataclasses import dataclass, field
|
|
33
|
+
from typing import TYPE_CHECKING, Any, Callable
|
|
34
|
+
|
|
35
|
+
if TYPE_CHECKING:
|
|
36
|
+
from spanforge._span import Span
|
|
37
|
+
|
|
38
|
+
__all__ = [
|
|
39
|
+
"BudgetMonitor",
|
|
40
|
+
"CostRecord",
|
|
41
|
+
"CostTracker",
|
|
42
|
+
"budget_alert",
|
|
43
|
+
"cost_summary",
|
|
44
|
+
"emit_cost_attributed",
|
|
45
|
+
"emit_cost_event",
|
|
46
|
+
]
|
|
47
|
+
|
|
48
|
+
# ---------------------------------------------------------------------------
|
|
49
|
+
# Module-level default tracker (used by budget_alert() when tracker=None)
|
|
50
|
+
# ---------------------------------------------------------------------------
|
|
51
|
+
|
|
52
|
+
_global_tracker_lock = threading.Lock()
|
|
53
|
+
_global_tracker: CostTracker | None = None
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _get_global_tracker() -> CostTracker:
|
|
57
|
+
global _global_tracker
|
|
58
|
+
if _global_tracker is None:
|
|
59
|
+
with _global_tracker_lock:
|
|
60
|
+
if _global_tracker is None:
|
|
61
|
+
_global_tracker = CostTracker()
|
|
62
|
+
return _global_tracker
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
# ---------------------------------------------------------------------------
|
|
66
|
+
# CostRecord
|
|
67
|
+
# ---------------------------------------------------------------------------
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
@dataclass(frozen=True)
|
|
71
|
+
class CostRecord:
|
|
72
|
+
"""Immutable record for a single LLM call cost entry.
|
|
73
|
+
|
|
74
|
+
Attributes:
|
|
75
|
+
model: Model name (e.g. ``"gpt-4o"``).
|
|
76
|
+
input_tokens: Number of input/prompt tokens consumed.
|
|
77
|
+
output_tokens: Number of output/completion tokens generated.
|
|
78
|
+
total_usd: Total cost in USD for this call.
|
|
79
|
+
input_cost_usd: Cost for input tokens alone (None if unknown).
|
|
80
|
+
output_cost_usd: Cost for output tokens alone (None if unknown).
|
|
81
|
+
tags: Arbitrary string key-value metadata.
|
|
82
|
+
span_id: ID of the originating span, if any.
|
|
83
|
+
agent_run_id: ULID of the enclosing agent run, if any.
|
|
84
|
+
timestamp: Unix timestamp (seconds) when the record was created.
|
|
85
|
+
"""
|
|
86
|
+
|
|
87
|
+
model: str
|
|
88
|
+
input_tokens: int
|
|
89
|
+
output_tokens: int
|
|
90
|
+
total_usd: float
|
|
91
|
+
input_cost_usd: float | None = None
|
|
92
|
+
output_cost_usd: float | None = None
|
|
93
|
+
tags: dict[str, str] = field(default_factory=dict)
|
|
94
|
+
span_id: str | None = None
|
|
95
|
+
agent_run_id: str | None = None
|
|
96
|
+
timestamp: float = field(default_factory=time.time)
|
|
97
|
+
|
|
98
|
+
def to_dict(self) -> dict[str, Any]:
|
|
99
|
+
"""Serialise to a plain dict."""
|
|
100
|
+
d: dict[str, Any] = {
|
|
101
|
+
"model": self.model,
|
|
102
|
+
"input_tokens": self.input_tokens,
|
|
103
|
+
"output_tokens": self.output_tokens,
|
|
104
|
+
"total_usd": self.total_usd,
|
|
105
|
+
"timestamp": self.timestamp,
|
|
106
|
+
}
|
|
107
|
+
if self.input_cost_usd is not None:
|
|
108
|
+
d["input_cost_usd"] = self.input_cost_usd
|
|
109
|
+
if self.output_cost_usd is not None:
|
|
110
|
+
d["output_cost_usd"] = self.output_cost_usd
|
|
111
|
+
if self.tags:
|
|
112
|
+
d["tags"] = dict(self.tags)
|
|
113
|
+
if self.span_id is not None:
|
|
114
|
+
d["span_id"] = self.span_id
|
|
115
|
+
if self.agent_run_id is not None:
|
|
116
|
+
d["agent_run_id"] = self.agent_run_id
|
|
117
|
+
return d
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
# ---------------------------------------------------------------------------
|
|
121
|
+
# CostTracker
|
|
122
|
+
# ---------------------------------------------------------------------------
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
class CostTracker:
|
|
126
|
+
"""Accumulates :class:`CostRecord` entries and exposes aggregates.
|
|
127
|
+
|
|
128
|
+
Thread-safe: all mutations and reads are protected by an internal lock.
|
|
129
|
+
|
|
130
|
+
Usage::
|
|
131
|
+
|
|
132
|
+
tracker = CostTracker()
|
|
133
|
+
tracker.record("gpt-4o", input_tokens=500, output_tokens=200)
|
|
134
|
+
print(f"Total: ${tracker.total_usd:.6f}")
|
|
135
|
+
print(tracker.breakdown_by_model)
|
|
136
|
+
"""
|
|
137
|
+
|
|
138
|
+
def __init__(self) -> None:
|
|
139
|
+
self._lock = threading.Lock()
|
|
140
|
+
self._records: list[CostRecord] = []
|
|
141
|
+
self._monitors: list[BudgetMonitor] = []
|
|
142
|
+
|
|
143
|
+
# ------------------------------------------------------------------
|
|
144
|
+
# Recording
|
|
145
|
+
# ------------------------------------------------------------------
|
|
146
|
+
|
|
147
|
+
def record(
|
|
148
|
+
self,
|
|
149
|
+
model: str,
|
|
150
|
+
input_tokens: int,
|
|
151
|
+
output_tokens: int,
|
|
152
|
+
*,
|
|
153
|
+
total_usd: float | None = None,
|
|
154
|
+
input_cost_usd: float | None = None,
|
|
155
|
+
output_cost_usd: float | None = None,
|
|
156
|
+
tags: dict[str, str] | None = None,
|
|
157
|
+
span_id: str | None = None,
|
|
158
|
+
agent_run_id: str | None = None,
|
|
159
|
+
) -> CostRecord:
|
|
160
|
+
"""Record a single LLM call cost.
|
|
161
|
+
|
|
162
|
+
If *total_usd* is not provided, the cost is calculated from the
|
|
163
|
+
``spanforge.integrations._pricing`` table. If the model is not in the
|
|
164
|
+
table, ``total_usd`` defaults to ``0.0``.
|
|
165
|
+
|
|
166
|
+
Args:
|
|
167
|
+
model: Model name (e.g. ``"gpt-4o"``).
|
|
168
|
+
input_tokens: Input/prompt token count.
|
|
169
|
+
output_tokens: Output/completion token count.
|
|
170
|
+
total_usd: Override total cost in USD (skips pricing lookup).
|
|
171
|
+
input_cost_usd: Override just the input cost (optional).
|
|
172
|
+
output_cost_usd: Override just the output cost (optional).
|
|
173
|
+
tags: Arbitrary string metadata for grouping.
|
|
174
|
+
span_id: ID of the originating span.
|
|
175
|
+
agent_run_id: ULID of the enclosing agent run.
|
|
176
|
+
|
|
177
|
+
Returns:
|
|
178
|
+
The created :class:`CostRecord`.
|
|
179
|
+
"""
|
|
180
|
+
if not isinstance(model, str) or not model:
|
|
181
|
+
raise ValueError("CostTracker.record: model must be a non-empty string")
|
|
182
|
+
if not isinstance(input_tokens, int) or input_tokens < 0:
|
|
183
|
+
raise ValueError("CostTracker.record: input_tokens must be a non-negative int")
|
|
184
|
+
if not isinstance(output_tokens, int) or output_tokens < 0:
|
|
185
|
+
raise ValueError("CostTracker.record: output_tokens must be a non-negative int")
|
|
186
|
+
|
|
187
|
+
if total_usd is None:
|
|
188
|
+
input_cost_usd, output_cost_usd, total_usd = _calculate_cost(
|
|
189
|
+
model, input_tokens, output_tokens
|
|
190
|
+
)
|
|
191
|
+
elif input_cost_usd is None and output_cost_usd is None:
|
|
192
|
+
# No breakdown provided — leave both as None
|
|
193
|
+
pass
|
|
194
|
+
|
|
195
|
+
cr = CostRecord(
|
|
196
|
+
model=model,
|
|
197
|
+
input_tokens=input_tokens,
|
|
198
|
+
output_tokens=output_tokens,
|
|
199
|
+
total_usd=total_usd,
|
|
200
|
+
input_cost_usd=input_cost_usd,
|
|
201
|
+
output_cost_usd=output_cost_usd,
|
|
202
|
+
tags=dict(tags) if tags else {},
|
|
203
|
+
span_id=span_id,
|
|
204
|
+
agent_run_id=agent_run_id,
|
|
205
|
+
)
|
|
206
|
+
|
|
207
|
+
with self._lock:
|
|
208
|
+
self._records.append(cr)
|
|
209
|
+
|
|
210
|
+
# Check budget monitors outside the lock to avoid re-entrant deadlock.
|
|
211
|
+
self._check_monitors()
|
|
212
|
+
|
|
213
|
+
return cr
|
|
214
|
+
|
|
215
|
+
# ------------------------------------------------------------------
|
|
216
|
+
# Aggregates
|
|
217
|
+
# ------------------------------------------------------------------
|
|
218
|
+
|
|
219
|
+
@property
|
|
220
|
+
def total_usd(self) -> float:
|
|
221
|
+
"""Total cost in USD across all recorded calls."""
|
|
222
|
+
with self._lock:
|
|
223
|
+
return sum(r.total_usd for r in self._records)
|
|
224
|
+
|
|
225
|
+
@property
|
|
226
|
+
def call_count(self) -> int:
|
|
227
|
+
"""Number of recorded calls."""
|
|
228
|
+
with self._lock:
|
|
229
|
+
return len(self._records)
|
|
230
|
+
|
|
231
|
+
@property
|
|
232
|
+
def total_input_tokens(self) -> int:
|
|
233
|
+
"""Total input tokens across all recorded calls."""
|
|
234
|
+
with self._lock:
|
|
235
|
+
return sum(r.input_tokens for r in self._records)
|
|
236
|
+
|
|
237
|
+
@property
|
|
238
|
+
def total_output_tokens(self) -> int:
|
|
239
|
+
"""Total output tokens across all recorded calls."""
|
|
240
|
+
with self._lock:
|
|
241
|
+
return sum(r.output_tokens for r in self._records)
|
|
242
|
+
|
|
243
|
+
@property
|
|
244
|
+
def breakdown_by_model(self) -> dict[str, float]:
|
|
245
|
+
"""Per-model total cost in USD, sorted by descending cost."""
|
|
246
|
+
totals: dict[str, float] = {}
|
|
247
|
+
with self._lock:
|
|
248
|
+
for r in self._records:
|
|
249
|
+
totals[r.model] = totals.get(r.model, 0.0) + r.total_usd
|
|
250
|
+
return dict(sorted(totals.items(), key=lambda kv: kv[1], reverse=True))
|
|
251
|
+
|
|
252
|
+
@property
|
|
253
|
+
def breakdown_by_tag(self) -> dict[str, dict[str, float]]:
|
|
254
|
+
"""Per-tag-key/value total cost.
|
|
255
|
+
|
|
256
|
+
Returns ``{tag_key: {tag_value: total_usd, ...}, ...}``.
|
|
257
|
+
Only tags present on at least one record are included.
|
|
258
|
+
"""
|
|
259
|
+
result: dict[str, dict[str, float]] = {}
|
|
260
|
+
with self._lock:
|
|
261
|
+
for r in self._records:
|
|
262
|
+
for k, v in r.tags.items():
|
|
263
|
+
if k not in result:
|
|
264
|
+
result[k] = {}
|
|
265
|
+
result[k][v] = result[k].get(v, 0.0) + r.total_usd
|
|
266
|
+
return result
|
|
267
|
+
|
|
268
|
+
@property
|
|
269
|
+
def records(self) -> list[CostRecord]:
|
|
270
|
+
"""Return a snapshot of all recorded :class:`CostRecord` objects."""
|
|
271
|
+
with self._lock:
|
|
272
|
+
return list(self._records)
|
|
273
|
+
|
|
274
|
+
# ------------------------------------------------------------------
|
|
275
|
+
# Reset
|
|
276
|
+
# ------------------------------------------------------------------
|
|
277
|
+
|
|
278
|
+
def reset(self) -> None:
|
|
279
|
+
"""Clear all recorded cost data and reset per-monitor fired state."""
|
|
280
|
+
with self._lock:
|
|
281
|
+
self._records.clear()
|
|
282
|
+
for monitor in self._monitors:
|
|
283
|
+
monitor._fired = False
|
|
284
|
+
|
|
285
|
+
# ------------------------------------------------------------------
|
|
286
|
+
# Serialisation
|
|
287
|
+
# ------------------------------------------------------------------
|
|
288
|
+
|
|
289
|
+
def to_dict(self) -> dict[str, Any]:
|
|
290
|
+
"""Serialise the tracker state to a plain dict."""
|
|
291
|
+
with self._lock:
|
|
292
|
+
records = list(self._records)
|
|
293
|
+
return {
|
|
294
|
+
"total_usd": sum(r.total_usd for r in records),
|
|
295
|
+
"call_count": len(records),
|
|
296
|
+
"total_input_tokens": sum(r.input_tokens for r in records),
|
|
297
|
+
"total_output_tokens": sum(r.output_tokens for r in records),
|
|
298
|
+
"breakdown_by_model": {
|
|
299
|
+
m: sum(r.total_usd for r in records if r.model == m)
|
|
300
|
+
for m in {r.model for r in records}
|
|
301
|
+
},
|
|
302
|
+
"records": [r.to_dict() for r in records],
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
# ------------------------------------------------------------------
|
|
306
|
+
# Internal monitor management
|
|
307
|
+
# ------------------------------------------------------------------
|
|
308
|
+
|
|
309
|
+
def _add_monitor(self, monitor: BudgetMonitor) -> None:
|
|
310
|
+
with self._lock:
|
|
311
|
+
self._monitors.append(monitor)
|
|
312
|
+
|
|
313
|
+
def _check_monitors(self) -> None:
|
|
314
|
+
"""Fire any monitors whose threshold has been exceeded."""
|
|
315
|
+
with self._lock:
|
|
316
|
+
monitors = list(self._monitors)
|
|
317
|
+
# Check outside the lock — callbacks may call back into the tracker.
|
|
318
|
+
for monitor in monitors:
|
|
319
|
+
monitor.check(self)
|
|
320
|
+
|
|
321
|
+
|
|
322
|
+
# ---------------------------------------------------------------------------
|
|
323
|
+
# BudgetMonitor
|
|
324
|
+
# ---------------------------------------------------------------------------
|
|
325
|
+
|
|
326
|
+
|
|
327
|
+
class BudgetMonitor:
|
|
328
|
+
"""Fires a callback when a :class:`CostTracker` exceeds a USD threshold.
|
|
329
|
+
|
|
330
|
+
The callback is invoked **at most once** per budget period (unless the
|
|
331
|
+
tracker is :meth:`~CostTracker.reset`-ed, which resets the fired state).
|
|
332
|
+
|
|
333
|
+
Args:
|
|
334
|
+
threshold_usd: USD threshold that triggers the alert.
|
|
335
|
+
on_exceeded: Callable ``(CostTracker) -> None`` invoked on breach.
|
|
336
|
+
|
|
337
|
+
Usage::
|
|
338
|
+
|
|
339
|
+
monitor = BudgetMonitor(
|
|
340
|
+
threshold_usd=1.00,
|
|
341
|
+
on_exceeded=lambda t: print(f"Over budget: ${t.total_usd:.4f}")
|
|
342
|
+
)
|
|
343
|
+
tracker = CostTracker()
|
|
344
|
+
tracker._add_monitor(monitor)
|
|
345
|
+
"""
|
|
346
|
+
|
|
347
|
+
def __init__(
|
|
348
|
+
self,
|
|
349
|
+
threshold_usd: float,
|
|
350
|
+
on_exceeded: Callable[[CostTracker], None],
|
|
351
|
+
) -> None:
|
|
352
|
+
if threshold_usd <= 0:
|
|
353
|
+
raise ValueError("BudgetMonitor: threshold_usd must be > 0")
|
|
354
|
+
if not callable(on_exceeded):
|
|
355
|
+
raise TypeError("BudgetMonitor: on_exceeded must be callable")
|
|
356
|
+
self.threshold_usd = threshold_usd
|
|
357
|
+
self.on_exceeded = on_exceeded
|
|
358
|
+
self._fired = False
|
|
359
|
+
|
|
360
|
+
def check(self, tracker: CostTracker) -> bool:
|
|
361
|
+
"""Check whether the tracker exceeds the threshold and fire if so.
|
|
362
|
+
|
|
363
|
+
Fires **at most once** per tracker lifetime (until :meth:`~CostTracker.reset`
|
|
364
|
+
is called).
|
|
365
|
+
|
|
366
|
+
Args:
|
|
367
|
+
tracker: The :class:`CostTracker` to check against.
|
|
368
|
+
|
|
369
|
+
Returns:
|
|
370
|
+
``True`` if the callback was fired on this call, ``False`` otherwise.
|
|
371
|
+
"""
|
|
372
|
+
if self._fired:
|
|
373
|
+
return False
|
|
374
|
+
if tracker.total_usd >= self.threshold_usd:
|
|
375
|
+
self._fired = True
|
|
376
|
+
with contextlib.suppress(
|
|
377
|
+
Exception
|
|
378
|
+
): # NOSONAR — never let a callback kill the recording path
|
|
379
|
+
self.on_exceeded(tracker)
|
|
380
|
+
return True
|
|
381
|
+
return False
|
|
382
|
+
|
|
383
|
+
|
|
384
|
+
# ---------------------------------------------------------------------------
|
|
385
|
+
# budget_alert() factory
|
|
386
|
+
# ---------------------------------------------------------------------------
|
|
387
|
+
|
|
388
|
+
|
|
389
|
+
def budget_alert(
|
|
390
|
+
threshold_usd: float,
|
|
391
|
+
on_exceeded: Callable[[CostTracker], None],
|
|
392
|
+
*,
|
|
393
|
+
tracker: CostTracker | None = None,
|
|
394
|
+
) -> BudgetMonitor:
|
|
395
|
+
"""Register a :class:`BudgetMonitor` on *tracker* (or the global default).
|
|
396
|
+
|
|
397
|
+
Creates a new :class:`BudgetMonitor` and attaches it to *tracker*. If
|
|
398
|
+
*tracker* is ``None`` the module-level global tracker is used.
|
|
399
|
+
|
|
400
|
+
Args:
|
|
401
|
+
threshold_usd: USD amount that triggers *on_exceeded*.
|
|
402
|
+
on_exceeded: Callback ``(CostTracker) -> None`` fired on breach.
|
|
403
|
+
tracker: Tracker to monitor. Defaults to the global tracker.
|
|
404
|
+
|
|
405
|
+
Returns:
|
|
406
|
+
The created :class:`BudgetMonitor`.
|
|
407
|
+
"""
|
|
408
|
+
t = tracker if tracker is not None else _get_global_tracker()
|
|
409
|
+
monitor = BudgetMonitor(threshold_usd=threshold_usd, on_exceeded=on_exceeded)
|
|
410
|
+
t._add_monitor(monitor)
|
|
411
|
+
return monitor
|
|
412
|
+
|
|
413
|
+
|
|
414
|
+
# ---------------------------------------------------------------------------
|
|
415
|
+
# Cost calculation helper
|
|
416
|
+
# ---------------------------------------------------------------------------
|
|
417
|
+
|
|
418
|
+
|
|
419
|
+
def _calculate_cost(
|
|
420
|
+
model: str,
|
|
421
|
+
input_tokens: int,
|
|
422
|
+
output_tokens: int,
|
|
423
|
+
) -> tuple[float, float, float]:
|
|
424
|
+
"""Return ``(input_cost_usd, output_cost_usd, total_usd)`` for *model*.
|
|
425
|
+
|
|
426
|
+
Uses the static pricing table in ``spanforge.integrations._pricing``.
|
|
427
|
+
Returns ``(0.0, 0.0, 0.0)`` when the model is not found in the table.
|
|
428
|
+
"""
|
|
429
|
+
try:
|
|
430
|
+
from spanforge.integrations._pricing import get_pricing
|
|
431
|
+
|
|
432
|
+
pricing = get_pricing(model)
|
|
433
|
+
except Exception: # NOSONAR
|
|
434
|
+
pricing = None
|
|
435
|
+
|
|
436
|
+
if pricing is None:
|
|
437
|
+
return (0.0, 0.0, 0.0)
|
|
438
|
+
|
|
439
|
+
# Pricing table is USD per *million* tokens.
|
|
440
|
+
input_rate = pricing.get("input", 0.0)
|
|
441
|
+
output_rate = pricing.get("output", 0.0)
|
|
442
|
+
input_cost = (input_tokens / 1_000_000.0) * input_rate
|
|
443
|
+
output_cost = (output_tokens / 1_000_000.0) * output_rate
|
|
444
|
+
return (input_cost, output_cost, input_cost + output_cost)
|
|
445
|
+
|
|
446
|
+
|
|
447
|
+
# ---------------------------------------------------------------------------
|
|
448
|
+
# Event emission helpers
|
|
449
|
+
# ---------------------------------------------------------------------------
|
|
450
|
+
|
|
451
|
+
|
|
452
|
+
def emit_cost_event(
|
|
453
|
+
span: Span,
|
|
454
|
+
*,
|
|
455
|
+
token_usage: Any = None,
|
|
456
|
+
model_info: Any = None,
|
|
457
|
+
) -> None:
|
|
458
|
+
"""Emit a ``llm.cost.token.recorded`` event for *span*.
|
|
459
|
+
|
|
460
|
+
The span MUST have a ``cost`` attribute (``CostBreakdown``). If
|
|
461
|
+
*token_usage* or *model_info* are not provided they are read from
|
|
462
|
+
``span.token_usage`` and resolved from ``span.model`` respectively.
|
|
463
|
+
|
|
464
|
+
This function is a no-op when ``span.cost`` is ``None``.
|
|
465
|
+
|
|
466
|
+
Args:
|
|
467
|
+
span: The finished :class:`~spanforge._span.Span`.
|
|
468
|
+
token_usage: Override the :class:`~spanforge.namespaces.trace.TokenUsage`.
|
|
469
|
+
model_info: Override the :class:`~spanforge.namespaces.trace.ModelInfo`.
|
|
470
|
+
"""
|
|
471
|
+
from spanforge._span import Span, _resolve_model_info
|
|
472
|
+
from spanforge._stream import _build_event, _dispatch
|
|
473
|
+
from spanforge.namespaces.cost import CostTokenRecordedPayload
|
|
474
|
+
from spanforge.namespaces.trace import ModelInfo, TokenUsage
|
|
475
|
+
from spanforge.types import EventType
|
|
476
|
+
|
|
477
|
+
assert isinstance(span, Span) # nosec B101
|
|
478
|
+
if span.cost is None:
|
|
479
|
+
return
|
|
480
|
+
|
|
481
|
+
# Resolve token_usage
|
|
482
|
+
if token_usage is None:
|
|
483
|
+
token_usage = span.token_usage
|
|
484
|
+
if token_usage is None:
|
|
485
|
+
# Build a minimal TokenUsage so the payload is always valid.
|
|
486
|
+
token_usage = TokenUsage(input_tokens=0, output_tokens=0, total_tokens=0)
|
|
487
|
+
|
|
488
|
+
# Resolve model_info
|
|
489
|
+
if model_info is None:
|
|
490
|
+
if span.model:
|
|
491
|
+
model_info = _resolve_model_info(span.model)
|
|
492
|
+
else:
|
|
493
|
+
model_info = ModelInfo(system="openai", name="unknown")
|
|
494
|
+
|
|
495
|
+
payload = CostTokenRecordedPayload(
|
|
496
|
+
cost=span.cost,
|
|
497
|
+
token_usage=token_usage,
|
|
498
|
+
model=model_info,
|
|
499
|
+
span_id=span.span_id,
|
|
500
|
+
agent_run_id=span.agent_run_id,
|
|
501
|
+
)
|
|
502
|
+
event = _build_event(
|
|
503
|
+
event_type=EventType.COST_TOKEN_RECORDED,
|
|
504
|
+
payload_dict=payload.to_dict(),
|
|
505
|
+
span_id=span.span_id,
|
|
506
|
+
trace_id=span.trace_id,
|
|
507
|
+
parent_span_id=span.parent_span_id,
|
|
508
|
+
)
|
|
509
|
+
_dispatch(event)
|
|
510
|
+
|
|
511
|
+
|
|
512
|
+
def emit_cost_attributed(
|
|
513
|
+
attribution_target: str,
|
|
514
|
+
total_usd: float,
|
|
515
|
+
attribution_type: str = "direct",
|
|
516
|
+
*,
|
|
517
|
+
source_event_ids: list[str] | None = None,
|
|
518
|
+
pricing_date: str | None = None,
|
|
519
|
+
) -> None:
|
|
520
|
+
"""Emit a ``llm.cost.attributed`` event.
|
|
521
|
+
|
|
522
|
+
Args:
|
|
523
|
+
attribution_target: Identifier for the attribution target
|
|
524
|
+
(e.g. org/team/user/env).
|
|
525
|
+
total_usd: Total cost to attribute in USD.
|
|
526
|
+
attribution_type: One of ``"direct"``, ``"proportional"``,
|
|
527
|
+
``"estimated"``, ``"manual"``.
|
|
528
|
+
source_event_ids: Optional list of source event IDs.
|
|
529
|
+
pricing_date: ISO date string for reproducible cost calculation.
|
|
530
|
+
"""
|
|
531
|
+
from spanforge._stream import _build_event, _dispatch
|
|
532
|
+
from spanforge.namespaces.cost import CostAttributedPayload
|
|
533
|
+
from spanforge.namespaces.trace import CostBreakdown
|
|
534
|
+
from spanforge.types import EventType
|
|
535
|
+
|
|
536
|
+
cost = CostBreakdown(
|
|
537
|
+
input_cost_usd=total_usd,
|
|
538
|
+
output_cost_usd=0.0,
|
|
539
|
+
total_cost_usd=total_usd,
|
|
540
|
+
pricing_date=pricing_date or "2026-01-01",
|
|
541
|
+
)
|
|
542
|
+
payload = CostAttributedPayload(
|
|
543
|
+
cost=cost,
|
|
544
|
+
attribution_target=attribution_target,
|
|
545
|
+
attribution_type=attribution_type,
|
|
546
|
+
source_event_ids=list(source_event_ids) if source_event_ids else [],
|
|
547
|
+
)
|
|
548
|
+
event = _build_event(
|
|
549
|
+
event_type=EventType.COST_ATTRIBUTED,
|
|
550
|
+
payload_dict=payload.to_dict(),
|
|
551
|
+
)
|
|
552
|
+
_dispatch(event)
|
|
553
|
+
|
|
554
|
+
|
|
555
|
+
# ---------------------------------------------------------------------------
|
|
556
|
+
# cost_summary() — terminal display helper
|
|
557
|
+
# ---------------------------------------------------------------------------
|
|
558
|
+
|
|
559
|
+
|
|
560
|
+
def cost_summary(tracker: CostTracker | None = None) -> str:
|
|
561
|
+
"""Return a plain-text table of cost data from *tracker*.
|
|
562
|
+
|
|
563
|
+
Uses the global tracker if *tracker* is ``None``.
|
|
564
|
+
|
|
565
|
+
Args:
|
|
566
|
+
tracker: :class:`CostTracker` to summarise.
|
|
567
|
+
|
|
568
|
+
Returns:
|
|
569
|
+
A multi-line string table suitable for ``print()``.
|
|
570
|
+
"""
|
|
571
|
+
t = tracker if tracker is not None else _get_global_tracker()
|
|
572
|
+
|
|
573
|
+
lines: list[str] = []
|
|
574
|
+
lines.append("=" * 54)
|
|
575
|
+
lines.append(" SpanForge Cost Summary")
|
|
576
|
+
lines.append("=" * 54)
|
|
577
|
+
lines.append(f" Total calls : {t.call_count}")
|
|
578
|
+
lines.append(f" Total input tokens : {t.total_input_tokens:,}")
|
|
579
|
+
lines.append(f" Total output tokens: {t.total_output_tokens:,}")
|
|
580
|
+
lines.append(f" Total cost (USD) : ${t.total_usd:.6f}")
|
|
581
|
+
lines.append("-" * 54)
|
|
582
|
+
|
|
583
|
+
breakdown = t.breakdown_by_model
|
|
584
|
+
if breakdown:
|
|
585
|
+
lines.append(" Cost by model:")
|
|
586
|
+
for model, cost in breakdown.items():
|
|
587
|
+
lines.append(f" {model:<38} ${cost:.6f}")
|
|
588
|
+
else:
|
|
589
|
+
lines.append(" No calls recorded.")
|
|
590
|
+
|
|
591
|
+
tag_breakdown = t.breakdown_by_tag
|
|
592
|
+
if tag_breakdown:
|
|
593
|
+
lines.append("-" * 54)
|
|
594
|
+
lines.append(" Cost by tag:")
|
|
595
|
+
for tag_key, tag_values in tag_breakdown.items():
|
|
596
|
+
for tag_val, cost in sorted(tag_values.items(), key=lambda kv: kv[1], reverse=True):
|
|
597
|
+
lines.append(f" [{tag_key}={tag_val}]{'':>24} ${cost:.6f}")
|
|
598
|
+
|
|
599
|
+
lines.append("=" * 54)
|
|
600
|
+
return "\n".join(lines)
|