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.
Files changed (174) hide show
  1. spanforge/__init__.py +815 -0
  2. spanforge/_ansi.py +93 -0
  3. spanforge/_batch_exporter.py +409 -0
  4. spanforge/_cli.py +2094 -0
  5. spanforge/_cli_audit.py +639 -0
  6. spanforge/_cli_compliance.py +711 -0
  7. spanforge/_cli_cost.py +243 -0
  8. spanforge/_cli_ops.py +791 -0
  9. spanforge/_cli_phase11.py +356 -0
  10. spanforge/_hooks.py +337 -0
  11. spanforge/_server.py +1708 -0
  12. spanforge/_span.py +1036 -0
  13. spanforge/_store.py +288 -0
  14. spanforge/_stream.py +664 -0
  15. spanforge/_trace.py +335 -0
  16. spanforge/_tracer.py +254 -0
  17. spanforge/actor.py +141 -0
  18. spanforge/alerts.py +469 -0
  19. spanforge/auto.py +464 -0
  20. spanforge/baseline.py +335 -0
  21. spanforge/cache.py +635 -0
  22. spanforge/compliance.py +325 -0
  23. spanforge/config.py +532 -0
  24. spanforge/consent.py +228 -0
  25. spanforge/consumer.py +377 -0
  26. spanforge/core/__init__.py +5 -0
  27. spanforge/core/compliance_mapping.py +1254 -0
  28. spanforge/cost.py +600 -0
  29. spanforge/debug.py +548 -0
  30. spanforge/deprecations.py +205 -0
  31. spanforge/drift.py +482 -0
  32. spanforge/egress.py +58 -0
  33. spanforge/eval.py +648 -0
  34. spanforge/event.py +1064 -0
  35. spanforge/exceptions.py +240 -0
  36. spanforge/explain.py +178 -0
  37. spanforge/export/__init__.py +69 -0
  38. spanforge/export/append_only.py +337 -0
  39. spanforge/export/cloud.py +357 -0
  40. spanforge/export/datadog.py +497 -0
  41. spanforge/export/grafana.py +320 -0
  42. spanforge/export/jsonl.py +195 -0
  43. spanforge/export/openinference.py +158 -0
  44. spanforge/export/otel_bridge.py +294 -0
  45. spanforge/export/otlp.py +811 -0
  46. spanforge/export/otlp_bridge.py +233 -0
  47. spanforge/export/redis_backend.py +282 -0
  48. spanforge/export/siem_schema.py +98 -0
  49. spanforge/export/siem_splunk.py +264 -0
  50. spanforge/export/siem_syslog.py +212 -0
  51. spanforge/export/webhook.py +299 -0
  52. spanforge/exporters/__init__.py +30 -0
  53. spanforge/exporters/console.py +271 -0
  54. spanforge/exporters/jsonl.py +144 -0
  55. spanforge/exporters/sqlite.py +142 -0
  56. spanforge/gate.py +1150 -0
  57. spanforge/governance.py +181 -0
  58. spanforge/hitl.py +295 -0
  59. spanforge/http.py +187 -0
  60. spanforge/inspect.py +427 -0
  61. spanforge/integrations/__init__.py +45 -0
  62. spanforge/integrations/_pricing.py +280 -0
  63. spanforge/integrations/anthropic.py +388 -0
  64. spanforge/integrations/azure_openai.py +133 -0
  65. spanforge/integrations/bedrock.py +292 -0
  66. spanforge/integrations/crewai.py +251 -0
  67. spanforge/integrations/gemini.py +351 -0
  68. spanforge/integrations/groq.py +442 -0
  69. spanforge/integrations/langchain.py +349 -0
  70. spanforge/integrations/langgraph.py +306 -0
  71. spanforge/integrations/llamaindex.py +373 -0
  72. spanforge/integrations/ollama.py +287 -0
  73. spanforge/integrations/openai.py +368 -0
  74. spanforge/integrations/together.py +483 -0
  75. spanforge/io.py +214 -0
  76. spanforge/lint.py +322 -0
  77. spanforge/metrics.py +417 -0
  78. spanforge/metrics_export.py +343 -0
  79. spanforge/migrate.py +402 -0
  80. spanforge/model_registry.py +278 -0
  81. spanforge/models.py +389 -0
  82. spanforge/namespaces/__init__.py +254 -0
  83. spanforge/namespaces/audit.py +256 -0
  84. spanforge/namespaces/cache.py +237 -0
  85. spanforge/namespaces/chain.py +77 -0
  86. spanforge/namespaces/confidence.py +72 -0
  87. spanforge/namespaces/consent.py +92 -0
  88. spanforge/namespaces/cost.py +179 -0
  89. spanforge/namespaces/decision.py +143 -0
  90. spanforge/namespaces/diff.py +157 -0
  91. spanforge/namespaces/drift.py +80 -0
  92. spanforge/namespaces/eval_.py +251 -0
  93. spanforge/namespaces/feedback.py +241 -0
  94. spanforge/namespaces/fence.py +193 -0
  95. spanforge/namespaces/guard.py +105 -0
  96. spanforge/namespaces/hitl.py +91 -0
  97. spanforge/namespaces/latency.py +72 -0
  98. spanforge/namespaces/prompt.py +190 -0
  99. spanforge/namespaces/redact.py +173 -0
  100. spanforge/namespaces/retrieval.py +379 -0
  101. spanforge/namespaces/runtime_governance.py +494 -0
  102. spanforge/namespaces/template.py +208 -0
  103. spanforge/namespaces/tool_call.py +77 -0
  104. spanforge/namespaces/trace.py +1029 -0
  105. spanforge/normalizer.py +171 -0
  106. spanforge/plugins.py +82 -0
  107. spanforge/presidio_backend.py +349 -0
  108. spanforge/processor.py +258 -0
  109. spanforge/prompt_registry.py +418 -0
  110. spanforge/py.typed +0 -0
  111. spanforge/redact.py +914 -0
  112. spanforge/regression.py +192 -0
  113. spanforge/runtime_policy.py +159 -0
  114. spanforge/sampling.py +511 -0
  115. spanforge/schema.py +183 -0
  116. spanforge/schemas/v1.0/schema.json +170 -0
  117. spanforge/schemas/v2.0/schema.json +536 -0
  118. spanforge/sdk/__init__.py +625 -0
  119. spanforge/sdk/_base.py +584 -0
  120. spanforge/sdk/_base.pyi +71 -0
  121. spanforge/sdk/_exceptions.py +1096 -0
  122. spanforge/sdk/_types.py +2184 -0
  123. spanforge/sdk/alert.py +1514 -0
  124. spanforge/sdk/alert.pyi +56 -0
  125. spanforge/sdk/audit.py +1196 -0
  126. spanforge/sdk/audit.pyi +67 -0
  127. spanforge/sdk/cec.py +1215 -0
  128. spanforge/sdk/cec.pyi +37 -0
  129. spanforge/sdk/config.py +641 -0
  130. spanforge/sdk/config.pyi +55 -0
  131. spanforge/sdk/enterprise.py +714 -0
  132. spanforge/sdk/enterprise.pyi +79 -0
  133. spanforge/sdk/explain.py +170 -0
  134. spanforge/sdk/fallback.py +432 -0
  135. spanforge/sdk/feedback.py +351 -0
  136. spanforge/sdk/gate.py +874 -0
  137. spanforge/sdk/gate.pyi +51 -0
  138. spanforge/sdk/identity.py +2114 -0
  139. spanforge/sdk/identity.pyi +47 -0
  140. spanforge/sdk/lineage.py +175 -0
  141. spanforge/sdk/observe.py +1065 -0
  142. spanforge/sdk/observe.pyi +50 -0
  143. spanforge/sdk/operator.py +338 -0
  144. spanforge/sdk/pii.py +1473 -0
  145. spanforge/sdk/pii.pyi +119 -0
  146. spanforge/sdk/pipelines.py +458 -0
  147. spanforge/sdk/pipelines.pyi +39 -0
  148. spanforge/sdk/policy.py +930 -0
  149. spanforge/sdk/rag.py +594 -0
  150. spanforge/sdk/rbac.py +280 -0
  151. spanforge/sdk/registry.py +430 -0
  152. spanforge/sdk/registry.pyi +46 -0
  153. spanforge/sdk/scope.py +279 -0
  154. spanforge/sdk/secrets.py +293 -0
  155. spanforge/sdk/secrets.pyi +25 -0
  156. spanforge/sdk/security.py +560 -0
  157. spanforge/sdk/security.pyi +57 -0
  158. spanforge/sdk/trust.py +472 -0
  159. spanforge/sdk/trust.pyi +41 -0
  160. spanforge/secrets.py +799 -0
  161. spanforge/signing.py +1179 -0
  162. spanforge/stats.py +100 -0
  163. spanforge/stream.py +560 -0
  164. spanforge/testing.py +378 -0
  165. spanforge/testing_mocks.py +1052 -0
  166. spanforge/trace.py +199 -0
  167. spanforge/types.py +696 -0
  168. spanforge/ulid.py +300 -0
  169. spanforge/validate.py +379 -0
  170. spanforge-1.0.0.dist-info/METADATA +1509 -0
  171. spanforge-1.0.0.dist-info/RECORD +174 -0
  172. spanforge-1.0.0.dist-info/WHEEL +4 -0
  173. spanforge-1.0.0.dist-info/entry_points.txt +5 -0
  174. 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)