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