spanforge 2.0.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (101) hide show
  1. spanforge/__init__.py +695 -0
  2. spanforge/_batch_exporter.py +322 -0
  3. spanforge/_cli.py +3081 -0
  4. spanforge/_hooks.py +340 -0
  5. spanforge/_server.py +953 -0
  6. spanforge/_span.py +1015 -0
  7. spanforge/_store.py +287 -0
  8. spanforge/_stream.py +654 -0
  9. spanforge/_trace.py +334 -0
  10. spanforge/_tracer.py +253 -0
  11. spanforge/actor.py +141 -0
  12. spanforge/alerts.py +464 -0
  13. spanforge/auto.py +181 -0
  14. spanforge/baseline.py +336 -0
  15. spanforge/config.py +460 -0
  16. spanforge/consent.py +227 -0
  17. spanforge/consumer.py +379 -0
  18. spanforge/core/__init__.py +5 -0
  19. spanforge/core/compliance_mapping.py +1060 -0
  20. spanforge/cost.py +597 -0
  21. spanforge/debug.py +514 -0
  22. spanforge/drift.py +488 -0
  23. spanforge/egress.py +63 -0
  24. spanforge/eval.py +575 -0
  25. spanforge/event.py +1052 -0
  26. spanforge/exceptions.py +246 -0
  27. spanforge/explain.py +181 -0
  28. spanforge/export/__init__.py +50 -0
  29. spanforge/export/append_only.py +342 -0
  30. spanforge/export/cloud.py +349 -0
  31. spanforge/export/datadog.py +495 -0
  32. spanforge/export/grafana.py +331 -0
  33. spanforge/export/jsonl.py +198 -0
  34. spanforge/export/otel_bridge.py +291 -0
  35. spanforge/export/otlp.py +817 -0
  36. spanforge/export/otlp_bridge.py +231 -0
  37. spanforge/export/redis_backend.py +282 -0
  38. spanforge/export/webhook.py +302 -0
  39. spanforge/exporters/__init__.py +29 -0
  40. spanforge/exporters/console.py +271 -0
  41. spanforge/exporters/jsonl.py +144 -0
  42. spanforge/hitl.py +297 -0
  43. spanforge/inspect.py +429 -0
  44. spanforge/integrations/__init__.py +39 -0
  45. spanforge/integrations/_pricing.py +277 -0
  46. spanforge/integrations/anthropic.py +388 -0
  47. spanforge/integrations/bedrock.py +306 -0
  48. spanforge/integrations/crewai.py +251 -0
  49. spanforge/integrations/gemini.py +349 -0
  50. spanforge/integrations/groq.py +444 -0
  51. spanforge/integrations/langchain.py +349 -0
  52. spanforge/integrations/llamaindex.py +370 -0
  53. spanforge/integrations/ollama.py +286 -0
  54. spanforge/integrations/openai.py +370 -0
  55. spanforge/integrations/together.py +485 -0
  56. spanforge/metrics.py +393 -0
  57. spanforge/metrics_export.py +342 -0
  58. spanforge/migrate.py +278 -0
  59. spanforge/model_registry.py +282 -0
  60. spanforge/models.py +407 -0
  61. spanforge/namespaces/__init__.py +215 -0
  62. spanforge/namespaces/audit.py +253 -0
  63. spanforge/namespaces/cache.py +209 -0
  64. spanforge/namespaces/chain.py +74 -0
  65. spanforge/namespaces/confidence.py +69 -0
  66. spanforge/namespaces/consent.py +85 -0
  67. spanforge/namespaces/cost.py +175 -0
  68. spanforge/namespaces/decision.py +135 -0
  69. spanforge/namespaces/diff.py +146 -0
  70. spanforge/namespaces/drift.py +79 -0
  71. spanforge/namespaces/eval_.py +232 -0
  72. spanforge/namespaces/fence.py +180 -0
  73. spanforge/namespaces/guard.py +104 -0
  74. spanforge/namespaces/hitl.py +92 -0
  75. spanforge/namespaces/latency.py +69 -0
  76. spanforge/namespaces/prompt.py +185 -0
  77. spanforge/namespaces/redact.py +172 -0
  78. spanforge/namespaces/template.py +197 -0
  79. spanforge/namespaces/tool_call.py +76 -0
  80. spanforge/namespaces/trace.py +1006 -0
  81. spanforge/normalizer.py +183 -0
  82. spanforge/presidio_backend.py +149 -0
  83. spanforge/processor.py +258 -0
  84. spanforge/prompt_registry.py +415 -0
  85. spanforge/py.typed +0 -0
  86. spanforge/redact.py +780 -0
  87. spanforge/sampling.py +500 -0
  88. spanforge/schemas/v1.0/schema.json +170 -0
  89. spanforge/schemas/v2.0/schema.json +536 -0
  90. spanforge/signing.py +1152 -0
  91. spanforge/stream.py +559 -0
  92. spanforge/testing.py +376 -0
  93. spanforge/trace.py +199 -0
  94. spanforge/types.py +696 -0
  95. spanforge/ulid.py +304 -0
  96. spanforge/validate.py +383 -0
  97. spanforge-2.0.0.dist-info/METADATA +1777 -0
  98. spanforge-2.0.0.dist-info/RECORD +101 -0
  99. spanforge-2.0.0.dist-info/WHEEL +4 -0
  100. spanforge-2.0.0.dist-info/entry_points.txt +5 -0
  101. spanforge-2.0.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,485 @@
1
+ """spanforge.integrations.together — Auto-instrumentation for the Together AI Python SDK.
2
+
3
+ This module monkey-patches the Together AI 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 token counts)
8
+ * :class:`~spanforge.namespaces.trace.ModelInfo` (provider = ``together_ai``,
9
+ normalized name from response)
10
+ * :class:`~spanforge.namespaces.trace.CostBreakdown` (computed from the static
11
+ pricing table below)
12
+
13
+ Together AI model names include an organization prefix separated by ``/``
14
+ (e.g. ``"meta-llama/Meta-Llama-3.1-8B-Instruct-Turbo"``). This module
15
+ normalizes the name by stripping the org prefix so ``ModelInfo.name`` is
16
+ ``"Meta-Llama-3.1-8B-Instruct-Turbo"``. The full identifier (with prefix)
17
+ is retained as :attr:`~spanforge.namespaces.trace.ModelInfo.name` when the
18
+ normalized name is not found in the pricing table, to preserve observability
19
+ accuracy.
20
+
21
+ Usage::
22
+
23
+ from spanforge.integrations import together as together_integration
24
+ together_integration.patch()
25
+
26
+ from together import Together
27
+ client = Together()
28
+
29
+ import spanforge
30
+ spanforge.configure(exporter="console")
31
+
32
+ with spanforge.span("together-chat") as span:
33
+ resp = client.chat.completions.create(
34
+ model="meta-llama/Meta-Llama-3.1-8B-Instruct-Turbo",
35
+ messages=[{"role": "user", "content": "Hello"}],
36
+ )
37
+ # → span.token_usage and span.cost auto-populated on exit
38
+
39
+ Calling ``patch()`` is **idempotent** — calling it multiple times has no
40
+ effect. Call :func:`unpatch` to restore the original methods.
41
+
42
+ Install with::
43
+
44
+ pip install "spanforge[together]"
45
+ """
46
+
47
+ from __future__ import annotations
48
+
49
+ import functools
50
+ from typing import Any
51
+
52
+ from spanforge.namespaces.trace import (
53
+ CostBreakdown,
54
+ GenAISystem,
55
+ ModelInfo,
56
+ TokenUsage,
57
+ )
58
+
59
+ __all__ = [
60
+ "is_patched",
61
+ "normalize_model_name",
62
+ "normalize_response",
63
+ "patch",
64
+ "unpatch",
65
+ ]
66
+
67
+ # ---------------------------------------------------------------------------
68
+ # Static pricing table (USD per million tokens, effective 2026-03-04)
69
+ # Keys are the *full* model identifiers as returned by the Together AI API.
70
+ # ---------------------------------------------------------------------------
71
+
72
+ PRICING_DATE: str = "2026-03-04"
73
+
74
+ #: Together AI model pricing — USD per million tokens.
75
+ #: Keys use the full ``org/model`` identifier from the API.
76
+ TOGETHER_PRICING: dict[str, dict[str, float]] = {
77
+ # ------------------------------------------------------------------
78
+ # Meta LLaMA 3.3
79
+ # ------------------------------------------------------------------
80
+ "meta-llama/Llama-3.3-70B-Instruct-Turbo": {
81
+ "input": 0.88,
82
+ "output": 0.88,
83
+ },
84
+ "meta-llama/Llama-3.3-70B-Instruct-Turbo-Free": {
85
+ "input": 0.00,
86
+ "output": 0.00,
87
+ },
88
+ # ------------------------------------------------------------------
89
+ # Meta LLaMA 3.2
90
+ # ------------------------------------------------------------------
91
+ "meta-llama/Llama-3.2-90B-Vision-Instruct-Turbo": {
92
+ "input": 1.20,
93
+ "output": 1.20,
94
+ },
95
+ "meta-llama/Llama-3.2-11B-Vision-Instruct-Turbo": {
96
+ "input": 0.18,
97
+ "output": 0.18,
98
+ },
99
+ "meta-llama/Llama-3.2-3B-Instruct-Turbo": {
100
+ "input": 0.06,
101
+ "output": 0.06,
102
+ },
103
+ "meta-llama/Llama-3.2-1B-Instruct-Turbo": {
104
+ "input": 0.04,
105
+ "output": 0.04,
106
+ },
107
+ # ------------------------------------------------------------------
108
+ # Meta LLaMA 3.1
109
+ # ------------------------------------------------------------------
110
+ "meta-llama/Meta-Llama-3.1-405B-Instruct-Turbo": {
111
+ "input": 3.50,
112
+ "output": 3.50,
113
+ },
114
+ "meta-llama/Meta-Llama-3.1-70B-Instruct-Turbo": {
115
+ "input": 0.88,
116
+ "output": 0.88,
117
+ },
118
+ "meta-llama/Meta-Llama-3.1-8B-Instruct-Turbo": {
119
+ "input": 0.18,
120
+ "output": 0.18,
121
+ },
122
+ # ------------------------------------------------------------------
123
+ # Meta LLaMA 3
124
+ # ------------------------------------------------------------------
125
+ "meta-llama/Meta-Llama-3-70B-Instruct-Turbo": {
126
+ "input": 0.88,
127
+ "output": 0.88,
128
+ },
129
+ "meta-llama/Meta-Llama-3-8B-Instruct-Turbo": {
130
+ "input": 0.18,
131
+ "output": 0.18,
132
+ },
133
+ # ------------------------------------------------------------------
134
+ # Qwen
135
+ # ------------------------------------------------------------------
136
+ "Qwen/Qwen2.5-72B-Instruct-Turbo": {
137
+ "input": 1.20,
138
+ "output": 1.20,
139
+ },
140
+ "Qwen/Qwen2.5-7B-Instruct-Turbo": {
141
+ "input": 0.30,
142
+ "output": 0.30,
143
+ },
144
+ "Qwen/QwQ-32B-Preview": {
145
+ "input": 1.20,
146
+ "output": 1.20,
147
+ },
148
+ # ------------------------------------------------------------------
149
+ # Mistral / Mixtral
150
+ # ------------------------------------------------------------------
151
+ "mistralai/Mixtral-8x7B-Instruct-v0.1": {
152
+ "input": 0.60,
153
+ "output": 0.60,
154
+ },
155
+ "mistralai/Mixtral-8x22B-Instruct-v0.1": {
156
+ "input": 1.20,
157
+ "output": 1.20,
158
+ },
159
+ "mistralai/Mistral-7B-Instruct-v0.3": {
160
+ "input": 0.20,
161
+ "output": 0.20,
162
+ },
163
+ # ------------------------------------------------------------------
164
+ # DeepSeek
165
+ # ------------------------------------------------------------------
166
+ "deepseek-ai/DeepSeek-V3": {
167
+ "input": 1.25,
168
+ "output": 1.25,
169
+ },
170
+ "deepseek-ai/DeepSeek-R1": {
171
+ "input": 2.19,
172
+ "output": 7.89,
173
+ },
174
+ "deepseek-ai/DeepSeek-R1-Distill-Llama-70B": {
175
+ "input": 2.19,
176
+ "output": 2.19,
177
+ },
178
+ "deepseek-ai/DeepSeek-R1-Distill-Qwen-1.5B": {
179
+ "input": 0.18,
180
+ "output": 0.18,
181
+ },
182
+ # ------------------------------------------------------------------
183
+ # Google Gemma
184
+ # ------------------------------------------------------------------
185
+ "google/gemma-2-27b-it": {
186
+ "input": 0.80,
187
+ "output": 0.80,
188
+ },
189
+ "google/gemma-2-9b-it": {
190
+ "input": 0.30,
191
+ "output": 0.30,
192
+ },
193
+ }
194
+
195
+ # Sentinel attribute set on the together module to prevent double-patching.
196
+ _PATCH_FLAG = "_spanforge_patched"
197
+
198
+
199
+ # ---------------------------------------------------------------------------
200
+ # Public API
201
+ # ---------------------------------------------------------------------------
202
+
203
+
204
+ def patch() -> None:
205
+ """Monkey-patch the Together AI client to auto-instrument all chat completions.
206
+
207
+ Wraps both the sync and async ``create`` methods on the Completions
208
+ resource. The wrapper calls :func:`normalize_response` on the result
209
+ and, if a span is currently active on this thread, updates it.
210
+
211
+ This function is **idempotent** — safe to call multiple times.
212
+
213
+ Raises:
214
+ ImportError: If the ``together`` package is not installed.
215
+ """
216
+ together_mod = _require_together()
217
+
218
+ if getattr(together_mod, _PATCH_FLAG, False):
219
+ return # already patched
220
+
221
+ # --- sync ----------------------------------------------------------------
222
+ try:
223
+ from together.resources.chat.completions import ( # noqa: PLC0415
224
+ Completions, # type: ignore[import-untyped]
225
+ )
226
+
227
+ _orig_sync = Completions.create # type: ignore[attr-defined]
228
+
229
+ @functools.wraps(_orig_sync)
230
+ def _patched_sync(self: Any, *args: Any, **kwargs: Any) -> Any: # noqa: ANN401
231
+ response = _orig_sync(self, *args, **kwargs)
232
+ _auto_populate_span(response)
233
+ return response
234
+
235
+ Completions.create = _patched_sync # type: ignore[method-assign]
236
+ Completions._spanforge_orig_create = _orig_sync # type: ignore[attr-defined]
237
+ except (ImportError, AttributeError): # pragma: no cover
238
+ pass
239
+
240
+ # --- async ---------------------------------------------------------------
241
+ try:
242
+ from together.resources.chat.completions import ( # noqa: PLC0415
243
+ AsyncCompletions, # type: ignore[import-untyped]
244
+ )
245
+
246
+ _orig_async = AsyncCompletions.create # type: ignore[attr-defined]
247
+
248
+ @functools.wraps(_orig_async)
249
+ async def _patched_async(self: Any, *args: Any, **kwargs: Any) -> Any: # noqa: ANN401
250
+ response = await _orig_async(self, *args, **kwargs)
251
+ _auto_populate_span(response)
252
+ return response
253
+
254
+ AsyncCompletions.create = _patched_async # type: ignore[method-assign]
255
+ AsyncCompletions._spanforge_orig_create = _orig_async # type: ignore[attr-defined]
256
+ except (ImportError, AttributeError): # pragma: no cover
257
+ pass
258
+
259
+ together_mod._spanforge_patched = True # type: ignore[attr-defined]
260
+
261
+
262
+ def unpatch() -> None:
263
+ """Restore the original Together AI methods and remove the patch flag.
264
+
265
+ Safe to call even if :func:`patch` was never called.
266
+
267
+ Raises:
268
+ ImportError: If the ``together`` package is not installed.
269
+ """
270
+ together_mod = _require_together()
271
+
272
+ if not getattr(together_mod, _PATCH_FLAG, False):
273
+ return # nothing to do
274
+
275
+ try:
276
+ from together.resources.chat.completions import ( # noqa: PLC0415
277
+ Completions, # type: ignore[import-untyped]
278
+ )
279
+
280
+ Completions.create = Completions._spanforge_orig_create # type: ignore[attr-defined,method-assign]
281
+ del Completions._spanforge_orig_create # type: ignore[attr-defined]
282
+ except (ImportError, AttributeError): # pragma: no cover
283
+ pass
284
+
285
+ try:
286
+ from together.resources.chat.completions import ( # noqa: PLC0415
287
+ AsyncCompletions, # type: ignore[import-untyped]
288
+ )
289
+
290
+ AsyncCompletions.create = AsyncCompletions._spanforge_orig_create # type: ignore[attr-defined,method-assign]
291
+ del AsyncCompletions._spanforge_orig_create # type: ignore[attr-defined]
292
+ except (ImportError, AttributeError): # pragma: no cover
293
+ pass
294
+
295
+ try: # noqa: SIM105
296
+ del together_mod._spanforge_patched # type: ignore[attr-defined]
297
+ except AttributeError: # pragma: no cover
298
+ pass
299
+
300
+
301
+ def is_patched() -> bool:
302
+ """Return ``True`` if the Together AI client has been patched by spanforge.
303
+
304
+ Returns ``False`` if the ``together`` package is not installed.
305
+ """
306
+ try:
307
+ together_mod = _require_together()
308
+ return bool(getattr(together_mod, _PATCH_FLAG, False))
309
+ except ImportError:
310
+ return False
311
+
312
+
313
+ def normalize_model_name(raw_name: str) -> str:
314
+ """Normalize a Together AI model name by stripping the organization prefix.
315
+
316
+ Together AI uses ``org/model-name`` identifiers. This function strips
317
+ the ``org/`` prefix so the returned name contains only the model
318
+ component. If no ``/`` is present the name is returned unchanged.
319
+
320
+ Examples::
321
+
322
+ >>> normalize_model_name("meta-llama/Meta-Llama-3.1-8B-Instruct-Turbo")
323
+ 'Meta-Llama-3.1-8B-Instruct-Turbo'
324
+ >>> normalize_model_name("gpt-4o")
325
+ 'gpt-4o'
326
+
327
+ Args:
328
+ raw_name: Model identifier as returned by the Together AI API.
329
+
330
+ Returns:
331
+ Model name without the organization prefix.
332
+ """
333
+ if "/" in raw_name:
334
+ return raw_name.split("/", 1)[1]
335
+ return raw_name
336
+
337
+
338
+ def normalize_response(
339
+ response: Any, # noqa: ANN401
340
+ ) -> tuple[TokenUsage, ModelInfo, CostBreakdown]:
341
+ """Extract structured observability data from a Together AI chat completion.
342
+
343
+ Together AI mirrors the OpenAI response format for token fields, but
344
+ model names use an ``org/model`` scheme. The model name is stored with
345
+ the full identifier preserved for unique identification, while the
346
+ normalized (org-stripped) name is available via :func:`normalize_model_name`.
347
+
348
+ Args:
349
+ response: A Together AI ``ChatCompletion`` (or compatible object).
350
+
351
+ Returns:
352
+ A 3-tuple of ``(TokenUsage, ModelInfo, CostBreakdown)``.
353
+
354
+ Field mapping:
355
+
356
+ +--------------------------------------------+---------------------------+
357
+ | Together AI field | SpanForge field |
358
+ +============================================+===========================+
359
+ | ``response.model`` | ``ModelInfo.name`` (full) |
360
+ | ``usage.prompt_tokens`` | ``TokenUsage.input_tokens``|
361
+ | ``usage.completion_tokens`` | ``TokenUsage.output_tokens``|
362
+ | ``usage.total_tokens`` | ``TokenUsage.total_tokens``|
363
+ +--------------------------------------------+---------------------------+
364
+ """
365
+ # ------------------------------------------------------------------ usage
366
+ usage = getattr(response, "usage", None)
367
+ input_tokens: int = 0
368
+ output_tokens: int = 0
369
+ total_tokens: int = 0
370
+
371
+ if usage is not None:
372
+ input_tokens = int(getattr(usage, "prompt_tokens", 0) or 0)
373
+ output_tokens = int(getattr(usage, "completion_tokens", 0) or 0)
374
+ total_tokens = int(
375
+ getattr(usage, "total_tokens", input_tokens + output_tokens) or 0
376
+ )
377
+
378
+ token_usage = TokenUsage(
379
+ input_tokens=input_tokens,
380
+ output_tokens=output_tokens,
381
+ total_tokens=total_tokens,
382
+ )
383
+
384
+ # ---------------------------------------------------------------- model
385
+ # Keep the full ``org/model`` identifier for unique model identification.
386
+ model_name: str = getattr(response, "model", None) or "unknown"
387
+ model_info = ModelInfo(system=GenAISystem.TOGETHER_AI, name=model_name)
388
+
389
+ # ----------------------------------------------------------------- cost
390
+ cost = _compute_cost(model_name, input_tokens, output_tokens)
391
+
392
+ return token_usage, model_info, cost
393
+
394
+
395
+ def list_models() -> list[str]:
396
+ """Return a sorted list of all Together AI model identifiers in the pricing table."""
397
+ return sorted(TOGETHER_PRICING.keys())
398
+
399
+
400
+ # ---------------------------------------------------------------------------
401
+ # Internal helpers
402
+ # ---------------------------------------------------------------------------
403
+
404
+
405
+ def _require_together() -> Any: # noqa: ANN401
406
+ """Import and return the ``together`` module, raising ``ImportError`` if absent."""
407
+ try:
408
+ import together # type: ignore[import-untyped] # noqa: PLC0415
409
+ except ImportError as exc:
410
+ raise ImportError(
411
+ "The 'together' package is required for spanforge Together AI integration.\n"
412
+ "Install it with: pip install 'spanforge[together]'"
413
+ ) from exc
414
+ else:
415
+ return together
416
+
417
+
418
+ def _get_pricing(model: str) -> dict[str, float] | None:
419
+ """Return the pricing entry for *model*, or ``None`` if unknown.
420
+
421
+ Tries the full ``org/model`` key first, then falls back to the
422
+ normalized (org-stripped) name.
423
+ """
424
+ if model in TOGETHER_PRICING:
425
+ return TOGETHER_PRICING[model]
426
+
427
+ # Try without org prefix
428
+ normalized = normalize_model_name(model)
429
+ if normalized in TOGETHER_PRICING:
430
+ return TOGETHER_PRICING[normalized]
431
+
432
+ return None
433
+
434
+
435
+ def _compute_cost(
436
+ model_name: str,
437
+ input_tokens: int,
438
+ output_tokens: int,
439
+ ) -> CostBreakdown:
440
+ """Compute :class:`~spanforge.namespaces.trace.CostBreakdown` from token counts."""
441
+ pricing = _get_pricing(model_name)
442
+ if pricing is None:
443
+ return CostBreakdown.zero()
444
+
445
+ input_cost = input_tokens * pricing["input"] / 1_000_000.0
446
+ output_cost = output_tokens * pricing["output"] / 1_000_000.0
447
+ total = input_cost + output_cost
448
+
449
+ return CostBreakdown(
450
+ input_cost_usd=input_cost,
451
+ output_cost_usd=output_cost,
452
+ total_cost_usd=total,
453
+ pricing_date=PRICING_DATE,
454
+ )
455
+
456
+
457
+ def _auto_populate_span(response: Any) -> None: # noqa: ANN401
458
+ """If there is an active span on this thread, populate it from *response*.
459
+
460
+ Silently does nothing if:
461
+
462
+ * There is no active span.
463
+ * ``normalize_response`` raises (malformed response).
464
+ * The span already has ``token_usage`` set (don't overwrite manual data).
465
+ """
466
+ try:
467
+ from spanforge._span import _span_stack # noqa: PLC0415
468
+
469
+ stack = _span_stack()
470
+ if not stack:
471
+ return
472
+ span = stack[-1]
473
+
474
+ if span.token_usage is not None:
475
+ return
476
+
477
+ token_usage, model_info, cost = normalize_response(response)
478
+ span.token_usage = token_usage
479
+ span.cost = cost
480
+
481
+ if span.model is None:
482
+ span.model = model_info.name
483
+
484
+ except Exception: # noqa: S110 # NOSONAR
485
+ pass