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
@@ -0,0 +1,483 @@
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 (
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:
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 (
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:
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 (
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 (
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:
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,
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(getattr(usage, "total_tokens", input_tokens + output_tokens) or 0)
375
+
376
+ token_usage = TokenUsage(
377
+ input_tokens=input_tokens,
378
+ output_tokens=output_tokens,
379
+ total_tokens=total_tokens,
380
+ )
381
+
382
+ # ---------------------------------------------------------------- model
383
+ # Keep the full ``org/model`` identifier for unique model identification.
384
+ model_name: str = getattr(response, "model", None) or "unknown"
385
+ model_info = ModelInfo(system=GenAISystem.TOGETHER_AI, name=model_name)
386
+
387
+ # ----------------------------------------------------------------- cost
388
+ cost = _compute_cost(model_name, input_tokens, output_tokens)
389
+
390
+ return token_usage, model_info, cost
391
+
392
+
393
+ def list_models() -> list[str]:
394
+ """Return a sorted list of all Together AI model identifiers in the pricing table."""
395
+ return sorted(TOGETHER_PRICING.keys())
396
+
397
+
398
+ # ---------------------------------------------------------------------------
399
+ # Internal helpers
400
+ # ---------------------------------------------------------------------------
401
+
402
+
403
+ def _require_together() -> Any:
404
+ """Import and return the ``together`` module, raising ``ImportError`` if absent."""
405
+ try:
406
+ import together # type: ignore[import-untyped]
407
+ except ImportError as exc:
408
+ raise ImportError(
409
+ "The 'together' package is required for spanforge Together AI integration.\n"
410
+ "Install it with: pip install 'spanforge[together]'"
411
+ ) from exc
412
+ else:
413
+ return together
414
+
415
+
416
+ def _get_pricing(model: str) -> dict[str, float] | None:
417
+ """Return the pricing entry for *model*, or ``None`` if unknown.
418
+
419
+ Tries the full ``org/model`` key first, then falls back to the
420
+ normalized (org-stripped) name.
421
+ """
422
+ if model in TOGETHER_PRICING:
423
+ return TOGETHER_PRICING[model]
424
+
425
+ # Try without org prefix
426
+ normalized = normalize_model_name(model)
427
+ if normalized in TOGETHER_PRICING:
428
+ return TOGETHER_PRICING[normalized]
429
+
430
+ return None
431
+
432
+
433
+ def _compute_cost(
434
+ model_name: str,
435
+ input_tokens: int,
436
+ output_tokens: int,
437
+ ) -> CostBreakdown:
438
+ """Compute :class:`~spanforge.namespaces.trace.CostBreakdown` from token counts."""
439
+ pricing = _get_pricing(model_name)
440
+ if pricing is None:
441
+ return CostBreakdown.zero()
442
+
443
+ input_cost = input_tokens * pricing["input"] / 1_000_000.0
444
+ output_cost = output_tokens * pricing["output"] / 1_000_000.0
445
+ total = input_cost + output_cost
446
+
447
+ return CostBreakdown(
448
+ input_cost_usd=input_cost,
449
+ output_cost_usd=output_cost,
450
+ total_cost_usd=total,
451
+ pricing_date=PRICING_DATE,
452
+ )
453
+
454
+
455
+ def _auto_populate_span(response: Any) -> None:
456
+ """If there is an active span on this thread, populate it from *response*.
457
+
458
+ Silently does nothing if:
459
+
460
+ * There is no active span.
461
+ * ``normalize_response`` raises (malformed response).
462
+ * The span already has ``token_usage`` set (don't overwrite manual data).
463
+ """
464
+ try:
465
+ from spanforge._span import _span_stack
466
+
467
+ stack = _span_stack()
468
+ if not stack:
469
+ return
470
+ span = stack[-1]
471
+
472
+ if span.token_usage is not None:
473
+ return
474
+
475
+ token_usage, model_info, cost = normalize_response(response)
476
+ span.token_usage = token_usage
477
+ span.cost = cost
478
+
479
+ if span.model is None:
480
+ span.model = model_info.name
481
+
482
+ except Exception: # NOSONAR
483
+ return
spanforge/io.py ADDED
@@ -0,0 +1,214 @@
1
+ """spanforge.io — Reliable synchronous JSONL read / write utilities.
2
+
3
+ These helpers solve a practical problem surfaced when integrating spanforge
4
+ into tool authors' pipelines: ``JSONLExporter`` is async and
5
+ ``EventStream.from_file`` occasionally raises on malformed lines rather than
6
+ skipping them, forcing every caller to write bespoke fallback code.
7
+
8
+ :func:`write_jsonl` and :func:`read_jsonl` are **synchronous**, handle
9
+ edge-cases (missing parents, empty files, partial writes, corrupt lines) with
10
+ predictable error semantics, and never swallow exceptions silently.
11
+
12
+ Usage::
13
+
14
+ from spanforge.io import write_jsonl, read_jsonl
15
+
16
+ # Persist a list of result dicts as JSONL
17
+ write_jsonl(results, "results/run-001.jsonl")
18
+
19
+ # Read back, optionally filtering by spanforge event_type
20
+ rows = read_jsonl("results/run-001.jsonl")
21
+
22
+ # Append a single record
23
+ from spanforge.io import append_jsonl
24
+ append_jsonl({"metric": "faithfulness", "score": 0.91}, "scores.jsonl")
25
+
26
+ Spanforge-event-aware variants
27
+ -------------------------------
28
+ :func:`write_events` wraps each dict in a spanforge ``Event`` envelope and
29
+ delegates to :func:`write_jsonl`. :func:`read_events` unwraps the payload
30
+ for lines that match the requested *event_type*, falling back gracefully for
31
+ plain-JSON lines.
32
+ """
33
+
34
+ from __future__ import annotations
35
+
36
+ import json
37
+ from pathlib import Path
38
+ from typing import TYPE_CHECKING, Any
39
+
40
+ if TYPE_CHECKING:
41
+ from collections.abc import Iterable
42
+
43
+ __all__ = [
44
+ "append_jsonl",
45
+ "read_events",
46
+ "read_jsonl",
47
+ "write_events",
48
+ "write_jsonl",
49
+ ]
50
+
51
+
52
+ # ---------------------------------------------------------------------------
53
+ # Core primitives
54
+ # ---------------------------------------------------------------------------
55
+
56
+
57
+ def write_jsonl(
58
+ records: Iterable[dict[str, Any]],
59
+ path: str | Path,
60
+ *,
61
+ mode: str = "w",
62
+ ) -> int:
63
+ """Write *records* to a JSONL file, one JSON object per line.
64
+
65
+ Args:
66
+ records: Any iterable of JSON-serialisable dicts.
67
+ path: Destination file path. Parent directories are created
68
+ automatically.
69
+ mode: ``"w"`` (default) to overwrite, ``"a"`` to append.
70
+
71
+ Returns:
72
+ Number of records written.
73
+
74
+ Raises:
75
+ ValueError: If *mode* is not ``"w"`` or ``"a"``.
76
+ OSError: If the file cannot be created or written.
77
+ """
78
+ if mode not in ("w", "a"):
79
+ raise ValueError(f"mode must be 'w' or 'a', got {mode!r}")
80
+ dest = Path(path)
81
+ dest.parent.mkdir(parents=True, exist_ok=True)
82
+ count = 0
83
+ with dest.open(mode, encoding="utf-8") as fh:
84
+ for record in records:
85
+ fh.write(json.dumps(record, default=str) + "\n")
86
+ count += 1
87
+ return count
88
+
89
+
90
+ def append_jsonl(record: dict[str, Any], path: str | Path) -> None:
91
+ """Append a single *record* to a JSONL file.
92
+
93
+ The file is created (with parent directories) if it does not exist.
94
+
95
+ Args:
96
+ record: A JSON-serialisable dict.
97
+ path: Destination file path.
98
+ """
99
+ write_jsonl([record], path, mode="a")
100
+
101
+
102
+ def read_jsonl(
103
+ path: str | Path,
104
+ *,
105
+ event_type: str | None = None,
106
+ skip_errors: bool = True,
107
+ ) -> list[dict[str, Any]]:
108
+ """Read records from a JSONL file.
109
+
110
+ Args:
111
+ path: JSONL file to read.
112
+ event_type: When given, only lines whose top-level ``event_type``
113
+ key equals this value are returned. ``None`` returns
114
+ every line.
115
+ skip_errors: When ``True`` (default), lines that cannot be parsed as
116
+ JSON are silently skipped. Set to ``False`` to raise
117
+ :class:`json.JSONDecodeError` on the first bad line.
118
+
119
+ Returns:
120
+ List of raw dicts (one per valid line).
121
+
122
+ Raises:
123
+ FileNotFoundError: If *path* does not exist.
124
+ json.JSONDecodeError: If *skip_errors* is ``False`` and a line is
125
+ not valid JSON.
126
+ """
127
+ results: list[dict[str, Any]] = []
128
+ with Path(path).open("r", encoding="utf-8") as fh:
129
+ for line in fh:
130
+ line = line.strip()
131
+ if not line:
132
+ continue
133
+ try:
134
+ obj: dict[str, Any] = json.loads(line)
135
+ except json.JSONDecodeError:
136
+ if not skip_errors:
137
+ raise
138
+ continue
139
+ if not isinstance(obj, dict):
140
+ continue
141
+ if event_type is not None and obj.get("event_type") != event_type:
142
+ continue
143
+ results.append(obj)
144
+ return results
145
+
146
+
147
+ # ---------------------------------------------------------------------------
148
+ # Spanforge-event-aware variants
149
+ # ---------------------------------------------------------------------------
150
+
151
+ _DEFAULT_SOURCE = "spanforge"
152
+
153
+
154
+ def write_events(
155
+ payloads: Iterable[dict[str, Any]],
156
+ path: str | Path,
157
+ *,
158
+ event_type: str,
159
+ source: str = _DEFAULT_SOURCE,
160
+ mode: str = "w",
161
+ ) -> int:
162
+ """Wrap each payload dict in a spanforge event envelope and write to JSONL.
163
+
164
+ Each line has the shape::
165
+
166
+ {"event_type": "<event_type>", "source": "<source>", "payload": {...}}
167
+
168
+ The envelope is compatible with :func:`read_events` and can also be parsed
169
+ by :class:`~spanforge.stream.EventStream`.
170
+
171
+ Args:
172
+ payloads: Iterable of payload dicts to wrap.
173
+ path: Destination file path.
174
+ event_type: Value for the ``event_type`` envelope field.
175
+ source: Value for the ``source`` envelope field.
176
+ mode: ``"w"`` (overwrite) or ``"a"`` (append).
177
+
178
+ Returns:
179
+ Number of records written.
180
+ """
181
+
182
+ def _wrap(p: dict[str, Any]) -> dict[str, Any]:
183
+ return {"event_type": event_type, "source": source, "payload": p}
184
+
185
+ return write_jsonl((_wrap(p) for p in payloads), path, mode=mode)
186
+
187
+
188
+ def read_events(
189
+ path: str | Path,
190
+ *,
191
+ event_type: str,
192
+ skip_errors: bool = True,
193
+ ) -> list[dict[str, Any]]:
194
+ """Read spanforge-event-wrapped payloads from a JSONL file.
195
+
196
+ Lines whose ``event_type`` field matches *event_type* are returned with
197
+ their ``payload`` dict unwrapped. Lines written by :func:`write_jsonl`
198
+ directly (without an envelope) are silently ignored.
199
+
200
+ Args:
201
+ path: JSONL file to read.
202
+ event_type: Only lines with this ``event_type`` are returned.
203
+ skip_errors: Passed through to :func:`read_jsonl`.
204
+
205
+ Returns:
206
+ List of unwrapped payload dicts.
207
+ """
208
+ rows = read_jsonl(path, event_type=event_type, skip_errors=skip_errors)
209
+ result: list[dict[str, Any]] = []
210
+ for row in rows:
211
+ payload = row.get("payload")
212
+ if isinstance(payload, dict):
213
+ result.append(payload)
214
+ return result