tracecast 0.2.6__tar.gz → 0.2.8__tar.gz

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 (108) hide show
  1. {tracecast-0.2.6 → tracecast-0.2.8}/PKG-INFO +1 -1
  2. {tracecast-0.2.6 → tracecast-0.2.8}/pyproject.toml +1 -1
  3. {tracecast-0.2.6 → tracecast-0.2.8}/tests/test_cost_calculator.py +51 -1
  4. {tracecast-0.2.6 → tracecast-0.2.8}/tracecast/__init__.py +2 -2
  5. {tracecast-0.2.6 → tracecast-0.2.8}/tracecast/core/cost_calculator.py +26 -0
  6. {tracecast-0.2.6 → tracecast-0.2.8}/tracecast/instrument.py +18 -0
  7. tracecast-0.2.8/tracecast/instrumentors/openai_inst.py +351 -0
  8. {tracecast-0.2.6 → tracecast-0.2.8}/tracecast/models/span.py +5 -3
  9. {tracecast-0.2.6 → tracecast-0.2.8}/tracecast.egg-info/PKG-INFO +1 -1
  10. tracecast-0.2.6/tracecast/instrumentors/openai_inst.py +0 -174
  11. {tracecast-0.2.6 → tracecast-0.2.8}/LICENSE +0 -0
  12. {tracecast-0.2.6 → tracecast-0.2.8}/README.md +0 -0
  13. {tracecast-0.2.6 → tracecast-0.2.8}/setup.cfg +0 -0
  14. {tracecast-0.2.6 → tracecast-0.2.8}/tests/test_aggregator_graph.py +0 -0
  15. {tracecast-0.2.6 → tracecast-0.2.8}/tests/test_context_propagation.py +0 -0
  16. {tracecast-0.2.6 → tracecast-0.2.8}/tests/test_dashboard_mount.py +0 -0
  17. {tracecast-0.2.6 → tracecast-0.2.8}/tests/test_dashboard_pushdown.py +0 -0
  18. {tracecast-0.2.6 → tracecast-0.2.8}/tests/test_dashboard_sessions.py +0 -0
  19. {tracecast-0.2.6 → tracecast-0.2.8}/tests/test_decorators.py +0 -0
  20. {tracecast-0.2.6 → tracecast-0.2.8}/tests/test_e2e_langgraph.py +0 -0
  21. {tracecast-0.2.6 → tracecast-0.2.8}/tests/test_exporters_db.py +0 -0
  22. {tracecast-0.2.6 → tracecast-0.2.8}/tests/test_integration_frameworks.py +0 -0
  23. {tracecast-0.2.6 → tracecast-0.2.8}/tests/test_integration_langchain.py +0 -0
  24. {tracecast-0.2.6 → tracecast-0.2.8}/tests/test_json_exporter.py +0 -0
  25. {tracecast-0.2.6 → tracecast-0.2.8}/tests/test_langchain_callback.py +0 -0
  26. {tracecast-0.2.6 → tracecast-0.2.8}/tests/test_llm_wrappers.py +0 -0
  27. {tracecast-0.2.6 → tracecast-0.2.8}/tests/test_logging.py +0 -0
  28. {tracecast-0.2.6 → tracecast-0.2.8}/tests/test_mongo_exporter.py +0 -0
  29. {tracecast-0.2.6 → tracecast-0.2.8}/tests/test_prompt_dashboard.py +0 -0
  30. {tracecast-0.2.6 → tracecast-0.2.8}/tests/test_prompts.py +0 -0
  31. {tracecast-0.2.6 → tracecast-0.2.8}/tests/test_roundtrip.py +0 -0
  32. {tracecast-0.2.6 → tracecast-0.2.8}/tests/test_schema_v2.py +0 -0
  33. {tracecast-0.2.6 → tracecast-0.2.8}/tests/test_score.py +0 -0
  34. {tracecast-0.2.6 → tracecast-0.2.8}/tests/test_score_dashboard.py +0 -0
  35. {tracecast-0.2.6 → tracecast-0.2.8}/tests/test_scoring_api.py +0 -0
  36. {tracecast-0.2.6 → tracecast-0.2.8}/tests/test_serve.py +0 -0
  37. {tracecast-0.2.6 → tracecast-0.2.8}/tests/test_span.py +0 -0
  38. {tracecast-0.2.6 → tracecast-0.2.8}/tests/test_streaming.py +0 -0
  39. {tracecast-0.2.6 → tracecast-0.2.8}/tests/test_token_counter.py +0 -0
  40. {tracecast-0.2.6 → tracecast-0.2.8}/tests/test_trace.py +0 -0
  41. {tracecast-0.2.6 → tracecast-0.2.8}/tests/test_trace_span.py +0 -0
  42. {tracecast-0.2.6 → tracecast-0.2.8}/tests/test_tracer.py +0 -0
  43. {tracecast-0.2.6 → tracecast-0.2.8}/tracecast/core/__init__.py +0 -0
  44. {tracecast-0.2.6 → tracecast-0.2.8}/tracecast/core/logger.py +0 -0
  45. {tracecast-0.2.6 → tracecast-0.2.8}/tracecast/core/scoring.py +0 -0
  46. {tracecast-0.2.6 → tracecast-0.2.8}/tracecast/core/token_counter.py +0 -0
  47. {tracecast-0.2.6 → tracecast-0.2.8}/tracecast/core/tracer.py +0 -0
  48. {tracecast-0.2.6 → tracecast-0.2.8}/tracecast/dashboard/__init__.py +0 -0
  49. {tracecast-0.2.6 → tracecast-0.2.8}/tracecast/dashboard/aggregator.py +0 -0
  50. {tracecast-0.2.6 → tracecast-0.2.8}/tracecast/dashboard/asgi_middleware.py +0 -0
  51. {tracecast-0.2.6 → tracecast-0.2.8}/tracecast/dashboard/blueprint.py +0 -0
  52. {tracecast-0.2.6 → tracecast-0.2.8}/tracecast/dashboard/eval_reader.py +0 -0
  53. {tracecast-0.2.6 → tracecast-0.2.8}/tracecast/dashboard/prompt_reader.py +0 -0
  54. {tracecast-0.2.6 → tracecast-0.2.8}/tracecast/dashboard/reader.py +0 -0
  55. {tracecast-0.2.6 → tracecast-0.2.8}/tracecast/dashboard/router.py +0 -0
  56. {tracecast-0.2.6 → tracecast-0.2.8}/tracecast/dashboard/score_reader.py +0 -0
  57. {tracecast-0.2.6 → tracecast-0.2.8}/tracecast/dashboard/standalone.py +0 -0
  58. {tracecast-0.2.6 → tracecast-0.2.8}/tracecast/dashboard/static/assets/index-B07hiBI2.js +0 -0
  59. {tracecast-0.2.6 → tracecast-0.2.8}/tracecast/dashboard/static/assets/index-BMIlEmLS.js +0 -0
  60. {tracecast-0.2.6 → tracecast-0.2.8}/tracecast/dashboard/static/assets/index-CY5YOA8F.js +0 -0
  61. {tracecast-0.2.6 → tracecast-0.2.8}/tracecast/dashboard/static/assets/index-DllJJzmE.css +0 -0
  62. {tracecast-0.2.6 → tracecast-0.2.8}/tracecast/dashboard/static/assets/index-Dt0awpJJ.js +0 -0
  63. {tracecast-0.2.6 → tracecast-0.2.8}/tracecast/dashboard/static/index.html +0 -0
  64. {tracecast-0.2.6 → tracecast-0.2.8}/tracecast/decorators.py +0 -0
  65. {tracecast-0.2.6 → tracecast-0.2.8}/tracecast/eval/__init__.py +0 -0
  66. {tracecast-0.2.6 → tracecast-0.2.8}/tracecast/eval/cli.py +0 -0
  67. {tracecast-0.2.6 → tracecast-0.2.8}/tracecast/eval/compare.py +0 -0
  68. {tracecast-0.2.6 → tracecast-0.2.8}/tracecast/eval/dataset.py +0 -0
  69. {tracecast-0.2.6 → tracecast-0.2.8}/tracecast/eval/decorator.py +0 -0
  70. {tracecast-0.2.6 → tracecast-0.2.8}/tracecast/eval/judge.py +0 -0
  71. {tracecast-0.2.6 → tracecast-0.2.8}/tracecast/eval/metrics.py +0 -0
  72. {tracecast-0.2.6 → tracecast-0.2.8}/tracecast/eval/models.py +0 -0
  73. {tracecast-0.2.6 → tracecast-0.2.8}/tracecast/eval/online.py +0 -0
  74. {tracecast-0.2.6 → tracecast-0.2.8}/tracecast/eval/runner.py +0 -0
  75. {tracecast-0.2.6 → tracecast-0.2.8}/tracecast/eval/scorers.py +0 -0
  76. {tracecast-0.2.6 → tracecast-0.2.8}/tracecast/exporters/__init__.py +0 -0
  77. {tracecast-0.2.6 → tracecast-0.2.8}/tracecast/exporters/_eval_store.py +0 -0
  78. {tracecast-0.2.6 → tracecast-0.2.8}/tracecast/exporters/base.py +0 -0
  79. {tracecast-0.2.6 → tracecast-0.2.8}/tracecast/exporters/dict_exporter.py +0 -0
  80. {tracecast-0.2.6 → tracecast-0.2.8}/tracecast/exporters/json_file.py +0 -0
  81. {tracecast-0.2.6 → tracecast-0.2.8}/tracecast/exporters/mongo.py +0 -0
  82. {tracecast-0.2.6 → tracecast-0.2.8}/tracecast/exporters/postgres.py +0 -0
  83. {tracecast-0.2.6 → tracecast-0.2.8}/tracecast/exporters/query.py +0 -0
  84. {tracecast-0.2.6 → tracecast-0.2.8}/tracecast/instrumentors/__init__.py +0 -0
  85. {tracecast-0.2.6 → tracecast-0.2.8}/tracecast/instrumentors/_llamaindex_handler.py +0 -0
  86. {tracecast-0.2.6 → tracecast-0.2.8}/tracecast/instrumentors/_streaming.py +0 -0
  87. {tracecast-0.2.6 → tracecast-0.2.8}/tracecast/instrumentors/anthropic_inst.py +0 -0
  88. {tracecast-0.2.6 → tracecast-0.2.8}/tracecast/instrumentors/base.py +0 -0
  89. {tracecast-0.2.6 → tracecast-0.2.8}/tracecast/instrumentors/crewai_inst.py +0 -0
  90. {tracecast-0.2.6 → tracecast-0.2.8}/tracecast/instrumentors/gemini_inst.py +0 -0
  91. {tracecast-0.2.6 → tracecast-0.2.8}/tracecast/instrumentors/langchain_inst.py +0 -0
  92. {tracecast-0.2.6 → tracecast-0.2.8}/tracecast/instrumentors/llamaindex_inst.py +0 -0
  93. {tracecast-0.2.6 → tracecast-0.2.8}/tracecast/integrations/__init__.py +0 -0
  94. {tracecast-0.2.6 → tracecast-0.2.8}/tracecast/integrations/langchain.py +0 -0
  95. {tracecast-0.2.6 → tracecast-0.2.8}/tracecast/integrations/llm.py +0 -0
  96. {tracecast-0.2.6 → tracecast-0.2.8}/tracecast/middleware.py +0 -0
  97. {tracecast-0.2.6 → tracecast-0.2.8}/tracecast/models/__init__.py +0 -0
  98. {tracecast-0.2.6 → tracecast-0.2.8}/tracecast/models/score.py +0 -0
  99. {tracecast-0.2.6 → tracecast-0.2.8}/tracecast/models/trace.py +0 -0
  100. {tracecast-0.2.6 → tracecast-0.2.8}/tracecast/prompts/__init__.py +0 -0
  101. {tracecast-0.2.6 → tracecast-0.2.8}/tracecast/prompts/client.py +0 -0
  102. {tracecast-0.2.6 → tracecast-0.2.8}/tracecast/prompts/models.py +0 -0
  103. {tracecast-0.2.6 → tracecast-0.2.8}/tracecast/serve.py +0 -0
  104. {tracecast-0.2.6 → tracecast-0.2.8}/tracecast.egg-info/SOURCES.txt +0 -0
  105. {tracecast-0.2.6 → tracecast-0.2.8}/tracecast.egg-info/dependency_links.txt +0 -0
  106. {tracecast-0.2.6 → tracecast-0.2.8}/tracecast.egg-info/entry_points.txt +0 -0
  107. {tracecast-0.2.6 → tracecast-0.2.8}/tracecast.egg-info/requires.txt +0 -0
  108. {tracecast-0.2.6 → tracecast-0.2.8}/tracecast.egg-info/top_level.txt +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: tracecast
3
- Version: 0.2.6
3
+ Version: 0.2.8
4
4
  Summary: LLM observability SDK — framework-agnostic tracing for AI applications
5
5
  Author: Pedro Castanheira Costa
6
6
  License: MIT
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "tracecast"
7
- version = "0.2.6"
7
+ version = "0.2.8"
8
8
  description = "LLM observability SDK — framework-agnostic tracing for AI applications"
9
9
  readme = "README.md"
10
10
  license = { text = "MIT" }
@@ -1,5 +1,5 @@
1
1
  import pytest
2
- from tracecast.core.cost_calculator import calculate_cost, PRICE_TABLE
2
+ from tracecast.core.cost_calculator import calculate_cost, calculate_audio_cost, PRICE_TABLE
3
3
 
4
4
  @pytest.mark.parametrize("model", [
5
5
  "gpt-4o", "gpt-4o-mini", "gpt-4-turbo",
@@ -151,3 +151,53 @@ def test_modelos_sem_suporte_a_cache_nao_tem_chave_cached():
151
151
  modelos_sem_cache = ["gpt-4-turbo", "gemini-2.5-flash", "llama-4-scout"]
152
152
  for model in modelos_sem_cache:
153
153
  assert "cached" not in PRICE_TABLE[model], f"{model} nao deveria ter preco de cache"
154
+
155
+
156
+ # --- gpt-4.1-nano (bug: ausente da tabela, custo virava $0 silenciosamente) ---
157
+
158
+ def test_gpt_4_1_nano_tem_preco():
159
+ assert "gpt-4.1-nano" in PRICE_TABLE
160
+
161
+
162
+ def test_calcula_custo_gpt_4_1_nano():
163
+ # $0.10 / $0.40 por 1M tokens = 0.00010 / 0.00040 por 1k
164
+ cost = calculate_cost("gpt-4.1-nano", tokens_in=1000, tokens_out=1000)
165
+ assert abs(cost - (0.00010 + 0.00040)) < 1e-9
166
+
167
+
168
+ def test_gpt_4_1_nano_cached_e_25_pct_do_input():
169
+ # mesmo padrao dos irmaos gpt-4.1 / gpt-4.1-mini: cached = 25% do input
170
+ entry = PRICE_TABLE["gpt-4.1-nano"]
171
+ assert abs(entry["cached"] - entry["input"] * 0.25) < 1e-9
172
+
173
+
174
+ # --- embeddings (bug: nunca precificados, custo $0 mesmo se instrumentados) ---
175
+
176
+ @pytest.mark.parametrize("model,expected_input", [
177
+ ("text-embedding-3-small", 0.00002),
178
+ ("text-embedding-3-large", 0.00013),
179
+ ("text-embedding-ada-002", 0.00010),
180
+ ])
181
+ def test_calcula_custo_embeddings(model, expected_input):
182
+ cost = calculate_cost(model, tokens_in=1000, tokens_out=0)
183
+ assert abs(cost - expected_input) < 1e-9
184
+
185
+
186
+ # --- audio (bug: whisper/tts nunca precificados nem instrumentados) ---
187
+
188
+ def test_calculate_audio_cost_whisper_por_minuto():
189
+ cost = calculate_audio_cost("whisper-1", minutes=2.5)
190
+ assert abs(cost - (2.5 * 0.006)) < 1e-9
191
+
192
+
193
+ def test_calculate_audio_cost_tts_por_caractere():
194
+ cost = calculate_audio_cost("gpt-4o-mini-tts", chars=1000)
195
+ assert cost > 0
196
+
197
+
198
+ def test_calculate_audio_cost_modelo_desconhecido_retorna_zero():
199
+ assert calculate_audio_cost("modelo-inexistente", minutes=10) == 0.0
200
+
201
+
202
+ def test_calculate_audio_cost_sem_uso_retorna_zero():
203
+ assert calculate_audio_cost("whisper-1") == 0.0
@@ -8,7 +8,7 @@ from .decorators import trace_cast, trace_span, set_default_tracer
8
8
  from .core.scoring import score
9
9
  from .prompts import create_prompt, get_prompt, set_label, PromptVersion
10
10
  from .integrations.llm import trace_llm_call, wrap_openai, wrap_anthropic
11
- from .instrument import auto_instrument
11
+ from .instrument import auto_instrument, instrument_openai
12
12
  from .exporters.dict_exporter import DictExporter
13
13
 
14
14
  __all__ = [
@@ -18,5 +18,5 @@ __all__ = [
18
18
  "trace_cast", "trace_span", "set_default_tracer", "score",
19
19
  "create_prompt", "get_prompt", "set_label", "PromptVersion",
20
20
  "trace_llm_call", "wrap_openai", "wrap_anthropic",
21
- "auto_instrument", "DictExporter",
21
+ "auto_instrument", "instrument_openai", "DictExporter",
22
22
  ]
@@ -8,9 +8,14 @@ PRICE_TABLE: Dict[str, Dict[str, float]] = {
8
8
  "gpt-4-turbo": {"input": 0.01000, "output": 0.03000},
9
9
  "gpt-4.1": {"input": 0.00200, "output": 0.00800, "cached": 0.000500},
10
10
  "gpt-4.1-mini": {"input": 0.00040, "output": 0.00160, "cached": 0.000100},
11
+ "gpt-4.1-nano": {"input": 0.00010, "output": 0.00040, "cached": 0.000025},
11
12
  "o3": {"input": 0.00200, "output": 0.00800, "cached": 0.000500},
12
13
  "o4-mini": {"input": 0.00110, "output": 0.00440, "cached": 0.000275},
13
14
 
15
+ "text-embedding-3-small": {"input": 0.00002, "output": 0.0},
16
+ "text-embedding-3-large": {"input": 0.00013, "output": 0.0},
17
+ "text-embedding-ada-002": {"input": 0.00010, "output": 0.0},
18
+
14
19
  "claude-opus-4-6": {"input": 0.00500, "output": 0.02500, "cached": 0.000500},
15
20
  "claude-sonnet-4-6": {"input": 0.00300, "output": 0.01500, "cached": 0.000300},
16
21
  "claude-haiku-4-5": {"input": 0.00100, "output": 0.00500, "cached": 0.000100},
@@ -62,3 +67,24 @@ def calculate_cost(
62
67
  def _prefix_match(model: str, prices: Dict) -> Optional[Dict]:
63
68
  provider = model.split("/")[0] + "/*"
64
69
  return prices.get(provider)
70
+
71
+
72
+ # Audio isn't token-billed: transcription is per-minute of input audio, TTS is
73
+ # per-character of input text. gpt-4o-mini-tts's rate is an approximation
74
+ # (not published on OpenAI's current pricing page as of 2026-07) — verify
75
+ # against an actual invoice before trusting it for reconciliation.
76
+ AUDIO_PRICE_TABLE: Dict[str, Dict[str, float]] = {
77
+ "whisper-1": {"unit": "minute", "price": 0.006},
78
+ "gpt-4o-mini-tts": {"unit": "char", "price": 0.000015},
79
+ "tts-1": {"unit": "char", "price": 0.000015},
80
+ "tts-1-hd": {"unit": "char", "price": 0.00003},
81
+ }
82
+
83
+
84
+ def calculate_audio_cost(model: str, *, minutes: float = 0.0, chars: int = 0) -> float:
85
+ entry = AUDIO_PRICE_TABLE.get(model)
86
+ if not entry:
87
+ return 0.0
88
+ if entry["unit"] == "minute":
89
+ return minutes * entry["price"]
90
+ return chars * entry["price"]
@@ -34,6 +34,24 @@ def auto_instrument(tracer: Optional[Tracer] = None) -> None:
34
34
  _instrumented = True
35
35
 
36
36
 
37
+ def instrument_openai(*, chat: bool = True, embeddings: bool = True, audio: bool = True) -> None:
38
+ """Patch only the requested OpenAI API surfaces, independent of auto_instrument().
39
+
40
+ Use `chat=False` when chat completions are already traced through another
41
+ mechanism (e.g. a manually-wired LangChain callback) and you only want to
42
+ add embeddings/audio coverage — re-patching chat.completions in that case
43
+ would produce duplicate spans for every chat call.
44
+ """
45
+ from .instrumentors.openai_inst import OpenAIInstrumentor
46
+ with _lock:
47
+ inst = _registry.get("openai")
48
+ if inst is None:
49
+ inst = OpenAIInstrumentor()
50
+ _registry["openai"] = inst
51
+ if not inst.is_patched():
52
+ inst.patch(chat=chat, embeddings=embeddings, audio=audio)
53
+
54
+
37
55
  def _reset() -> None:
38
56
  global _instrumented
39
57
  with _lock:
@@ -0,0 +1,351 @@
1
+ import sys
2
+ import uuid
3
+ from datetime import datetime, timezone
4
+ from typing import Any, Optional
5
+ from .base import BaseInstrumentor, active_parent_id
6
+
7
+
8
+ def _handled_by_langchain() -> bool:
9
+ from ..instrument import _registry
10
+
11
+ inst = _registry.get("langchain")
12
+ if inst is None or not inst.is_patched():
13
+ return False
14
+ frame = sys._getframe(2)
15
+ depth = 0
16
+ while frame is not None and depth < 60:
17
+ module = frame.f_globals.get("__name__", "")
18
+ if module.startswith("langchain"):
19
+ return True
20
+ frame = frame.f_back
21
+ depth += 1
22
+ return False
23
+
24
+
25
+ class OpenAIInstrumentor(BaseInstrumentor):
26
+
27
+ def __init__(self) -> None:
28
+ self._original_create: Optional[Any] = None
29
+ self._original_acreate: Optional[Any] = None
30
+ self._original_embeddings_create: Optional[Any] = None
31
+ self._original_transcriptions_create: Optional[Any] = None
32
+ self._original_speech_create: Optional[Any] = None
33
+ self._patched: bool = False
34
+
35
+ def patch(self, *, chat: bool = True, embeddings: bool = True, audio: bool = True) -> None:
36
+ """Patch the requested OpenAI API surfaces.
37
+
38
+ `chat=False` lets callers add embeddings/audio coverage without
39
+ re-patching chat.completions — needed when chat completions are
40
+ already traced through another mechanism (e.g. a manually-wired
41
+ LangChain callback), where re-patching would double-count spans.
42
+ """
43
+ if self._patched:
44
+ return
45
+
46
+ if chat:
47
+ import openai.resources.chat.completions as mod
48
+
49
+ # Sync
50
+ self._original_create = mod.Completions.create
51
+ self_ref = self
52
+ _orig_create = self._original_create
53
+
54
+ def patched_create(client_self, *args, **kwargs):
55
+ return self_ref._intercept(client_self, args, kwargs, _orig_create)
56
+
57
+ mod.Completions.create = patched_create
58
+
59
+ # Async
60
+ if hasattr(mod, "AsyncCompletions"):
61
+ self._original_acreate = mod.AsyncCompletions.create
62
+ _orig_acreate = self._original_acreate
63
+
64
+ async def patched_acreate(client_self, *args, **kwargs):
65
+ return await self_ref._async_intercept(client_self, args, kwargs, _orig_acreate)
66
+
67
+ mod.AsyncCompletions.create = patched_acreate
68
+
69
+ if embeddings:
70
+ self._patch_embeddings()
71
+ if audio:
72
+ self._patch_audio()
73
+
74
+ self._patched = True
75
+
76
+ def _patch_embeddings(self) -> None:
77
+ try:
78
+ import openai.resources.embeddings as emb_mod
79
+ except ImportError:
80
+ return
81
+ self._original_embeddings_create = emb_mod.Embeddings.create
82
+ self_ref = self
83
+ _orig = self._original_embeddings_create
84
+
85
+ def patched(client_self, *args, **kwargs):
86
+ return self_ref._intercept_embeddings(client_self, args, kwargs, _orig)
87
+
88
+ emb_mod.Embeddings.create = patched
89
+
90
+ def _patch_audio(self) -> None:
91
+ try:
92
+ import openai.resources.audio.transcriptions as tr_mod
93
+ except ImportError:
94
+ tr_mod = None
95
+ if tr_mod is not None:
96
+ self._original_transcriptions_create = tr_mod.Transcriptions.create
97
+ self_ref = self
98
+ _orig = self._original_transcriptions_create
99
+
100
+ def patched_tr(client_self, *args, **kwargs):
101
+ return self_ref._intercept_transcription(client_self, args, kwargs, _orig)
102
+
103
+ tr_mod.Transcriptions.create = patched_tr
104
+
105
+ try:
106
+ import openai.resources.audio.speech as sp_mod
107
+ except ImportError:
108
+ sp_mod = None
109
+ if sp_mod is not None:
110
+ self._original_speech_create = sp_mod.Speech.create
111
+ self_ref = self
112
+ _orig_sp = self._original_speech_create
113
+
114
+ def patched_sp(client_self, *args, **kwargs):
115
+ return self_ref._intercept_speech(client_self, args, kwargs, _orig_sp)
116
+
117
+ sp_mod.Speech.create = patched_sp
118
+
119
+ def unpatch(self) -> None:
120
+ if not self._patched:
121
+ return
122
+ if self._original_create is not None:
123
+ import openai.resources.chat.completions as mod
124
+ mod.Completions.create = self._original_create
125
+ if self._original_acreate is not None:
126
+ mod.AsyncCompletions.create = self._original_acreate
127
+ self._original_create = None
128
+ self._original_acreate = None
129
+
130
+ if self._original_embeddings_create is not None:
131
+ import openai.resources.embeddings as emb_mod
132
+ emb_mod.Embeddings.create = self._original_embeddings_create
133
+ self._original_embeddings_create = None
134
+
135
+ if self._original_transcriptions_create is not None:
136
+ import openai.resources.audio.transcriptions as tr_mod
137
+ tr_mod.Transcriptions.create = self._original_transcriptions_create
138
+ self._original_transcriptions_create = None
139
+
140
+ if self._original_speech_create is not None:
141
+ import openai.resources.audio.speech as sp_mod
142
+ sp_mod.Speech.create = self._original_speech_create
143
+ self._original_speech_create = None
144
+
145
+ self._patched = False
146
+
147
+ def is_patched(self) -> bool:
148
+ return self._patched
149
+
150
+ def _intercept(self, client_self: Any, args: tuple, kwargs: dict, original_fn: Any) -> Any:
151
+ from ..core.tracer import Tracer
152
+ trace = Tracer.current()
153
+ if trace is None or _handled_by_langchain():
154
+ return original_fn(client_self, *args, **kwargs)
155
+ return self._capture(client_self, args, kwargs, original_fn, trace)
156
+
157
+ def _capture(self, client_self: Any, args: tuple, kwargs: dict, original_fn: Any, trace: Any) -> Any:
158
+ from ..models.span import Span, SpanType
159
+ from ..core.token_counter import extract_tokens, extract_content, extract_input_text
160
+ from ..core.cost_calculator import calculate_cost
161
+
162
+ model = kwargs.get("model", "unknown")
163
+ input_text = extract_input_text(kwargs, "openai")
164
+
165
+ span = Span(
166
+ span_id=str(uuid.uuid4()),
167
+ parent_span_id=active_parent_id(),
168
+ type=SpanType.LLM,
169
+ name=f"llm:{model}",
170
+ model=model,
171
+ started_at=datetime.now(timezone.utc),
172
+ input=input_text,
173
+ )
174
+
175
+ if kwargs.get("stream"):
176
+ from ._streaming import stream_openai
177
+ kwargs.setdefault("stream_options", {"include_usage": True})
178
+ raw = original_fn(client_self, *args, **kwargs)
179
+ return stream_openai(raw, span, trace, model)
180
+
181
+ try:
182
+ response = original_fn(client_self, *args, **kwargs)
183
+ except Exception as exc:
184
+ span.finished_at = datetime.now(timezone.utc)
185
+ span.mark_error(exc)
186
+ trace.spans.append(span)
187
+ raise
188
+
189
+ span.finished_at = datetime.now(timezone.utc)
190
+ tokens = extract_tokens(response, "openai")
191
+ span.tokens_in = int(tokens["input"])
192
+ span.tokens_out = int(tokens["output"])
193
+ span.tokens_in_cached = int(tokens.get("cached", 0))
194
+ span.cost_usd = calculate_cost(
195
+ model, span.tokens_in, span.tokens_out, tokens_in_cached=span.tokens_in_cached
196
+ )
197
+ span.output = extract_content(response, "openai")
198
+ trace.spans.append(span)
199
+
200
+ return response
201
+
202
+ async def _async_intercept(self, client_self: Any, args: tuple, kwargs: dict, original_fn: Any) -> Any:
203
+ from ..core.tracer import Tracer
204
+ trace = Tracer.current()
205
+ if trace is None or _handled_by_langchain():
206
+ return await original_fn(client_self, *args, **kwargs)
207
+ return await self._async_capture(client_self, args, kwargs, original_fn, trace)
208
+
209
+ async def _async_capture(self, client_self: Any, args: tuple, kwargs: dict, original_fn: Any, trace: Any) -> Any:
210
+ from ..models.span import Span, SpanType
211
+ from ..core.token_counter import extract_tokens, extract_content, extract_input_text
212
+ from ..core.cost_calculator import calculate_cost
213
+
214
+ model = kwargs.get("model", "unknown")
215
+ input_text = extract_input_text(kwargs, "openai")
216
+
217
+ span = Span(
218
+ span_id=str(uuid.uuid4()),
219
+ parent_span_id=active_parent_id(),
220
+ type=SpanType.LLM,
221
+ name=f"llm:{model}",
222
+ model=model,
223
+ started_at=datetime.now(timezone.utc),
224
+ input=input_text,
225
+ )
226
+
227
+ if kwargs.get("stream"):
228
+ from ._streaming import astream_openai
229
+ kwargs.setdefault("stream_options", {"include_usage": True})
230
+ raw = await original_fn(client_self, *args, **kwargs)
231
+ return astream_openai(raw, span, trace, model)
232
+
233
+ try:
234
+ response = await original_fn(client_self, *args, **kwargs)
235
+ except Exception as exc:
236
+ span.finished_at = datetime.now(timezone.utc)
237
+ span.mark_error(exc)
238
+ trace.spans.append(span)
239
+ raise
240
+
241
+ span.finished_at = datetime.now(timezone.utc)
242
+ tokens = extract_tokens(response, "openai")
243
+ span.tokens_in = int(tokens["input"])
244
+ span.tokens_out = int(tokens["output"])
245
+ span.tokens_in_cached = int(tokens.get("cached", 0))
246
+ span.cost_usd = calculate_cost(
247
+ model, span.tokens_in, span.tokens_out, tokens_in_cached=span.tokens_in_cached
248
+ )
249
+ span.output = extract_content(response, "openai")
250
+ trace.spans.append(span)
251
+ return response
252
+
253
+ def _intercept_embeddings(self, client_self: Any, args: tuple, kwargs: dict, original_fn: Any) -> Any:
254
+ from ..core.tracer import Tracer
255
+ trace = Tracer.current()
256
+ if trace is None or _handled_by_langchain():
257
+ return original_fn(client_self, *args, **kwargs)
258
+
259
+ from ..models.span import Span, SpanType
260
+ from ..core.cost_calculator import calculate_cost
261
+
262
+ model = kwargs.get("model", "unknown")
263
+ span = Span(
264
+ span_id=str(uuid.uuid4()),
265
+ parent_span_id=active_parent_id(),
266
+ type=SpanType.EMBEDDING,
267
+ name=f"embedding:{model}",
268
+ model=model,
269
+ started_at=datetime.now(timezone.utc),
270
+ )
271
+ try:
272
+ response = original_fn(client_self, *args, **kwargs)
273
+ except Exception as exc:
274
+ span.finished_at = datetime.now(timezone.utc)
275
+ span.mark_error(exc)
276
+ trace.spans.append(span)
277
+ raise
278
+
279
+ span.finished_at = datetime.now(timezone.utc)
280
+ usage = getattr(response, "usage", None)
281
+ span.tokens_in = int(getattr(usage, "prompt_tokens", 0) or 0) if usage else 0
282
+ span.cost_usd = calculate_cost(model, span.tokens_in, 0)
283
+ trace.spans.append(span)
284
+ return response
285
+
286
+ def _intercept_transcription(self, client_self: Any, args: tuple, kwargs: dict, original_fn: Any) -> Any:
287
+ from ..core.tracer import Tracer
288
+ trace = Tracer.current()
289
+ if trace is None:
290
+ return original_fn(client_self, *args, **kwargs)
291
+
292
+ from ..models.span import Span, SpanType
293
+ from ..core.cost_calculator import calculate_audio_cost
294
+
295
+ model = kwargs.get("model", "unknown")
296
+ span = Span(
297
+ span_id=str(uuid.uuid4()),
298
+ parent_span_id=active_parent_id(),
299
+ type=SpanType.AUDIO,
300
+ name=f"audio:{model}",
301
+ model=model,
302
+ started_at=datetime.now(timezone.utc),
303
+ )
304
+ try:
305
+ response = original_fn(client_self, *args, **kwargs)
306
+ except Exception as exc:
307
+ span.finished_at = datetime.now(timezone.utc)
308
+ span.mark_error(exc)
309
+ trace.spans.append(span)
310
+ raise
311
+
312
+ span.finished_at = datetime.now(timezone.utc)
313
+ duration = getattr(response, "duration", None)
314
+ if duration is not None:
315
+ span.metadata["duration_seconds"] = duration
316
+ span.cost_usd = calculate_audio_cost(model, minutes=duration / 60)
317
+ trace.spans.append(span)
318
+ return response
319
+
320
+ def _intercept_speech(self, client_self: Any, args: tuple, kwargs: dict, original_fn: Any) -> Any:
321
+ from ..core.tracer import Tracer
322
+ trace = Tracer.current()
323
+ if trace is None:
324
+ return original_fn(client_self, *args, **kwargs)
325
+
326
+ from ..models.span import Span, SpanType
327
+ from ..core.cost_calculator import calculate_audio_cost
328
+
329
+ model = kwargs.get("model", "unknown")
330
+ char_count = len(kwargs.get("input", "") or "")
331
+ span = Span(
332
+ span_id=str(uuid.uuid4()),
333
+ parent_span_id=active_parent_id(),
334
+ type=SpanType.AUDIO,
335
+ name=f"audio:{model}",
336
+ model=model,
337
+ started_at=datetime.now(timezone.utc),
338
+ metadata={"char_count": char_count},
339
+ )
340
+ try:
341
+ response = original_fn(client_self, *args, **kwargs)
342
+ except Exception as exc:
343
+ span.finished_at = datetime.now(timezone.utc)
344
+ span.mark_error(exc)
345
+ trace.spans.append(span)
346
+ raise
347
+
348
+ span.finished_at = datetime.now(timezone.utc)
349
+ span.cost_usd = calculate_audio_cost(model, chars=char_count)
350
+ trace.spans.append(span)
351
+ return response
@@ -5,9 +5,11 @@ from typing import Optional, Dict, Any
5
5
 
6
6
 
7
7
  class SpanType(str, Enum):
8
- LLM = "llm"
9
- TOOL = "tool"
10
- AGENT = "agent"
8
+ LLM = "llm"
9
+ TOOL = "tool"
10
+ AGENT = "agent"
11
+ EMBEDDING = "embedding"
12
+ AUDIO = "audio"
11
13
 
12
14
 
13
15
  class SpanStatus(str, Enum):
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: tracecast
3
- Version: 0.2.6
3
+ Version: 0.2.8
4
4
  Summary: LLM observability SDK — framework-agnostic tracing for AI applications
5
5
  Author: Pedro Castanheira Costa
6
6
  License: MIT
@@ -1,174 +0,0 @@
1
- import sys
2
- import uuid
3
- from datetime import datetime, timezone
4
- from typing import Any, Optional
5
- from .base import BaseInstrumentor, active_parent_id
6
-
7
-
8
- def _handled_by_langchain() -> bool:
9
- from ..instrument import _registry
10
-
11
- inst = _registry.get("langchain")
12
- if inst is None or not inst.is_patched():
13
- return False
14
- frame = sys._getframe(2)
15
- depth = 0
16
- while frame is not None and depth < 60:
17
- module = frame.f_globals.get("__name__", "")
18
- if module.startswith("langchain"):
19
- return True
20
- frame = frame.f_back
21
- depth += 1
22
- return False
23
-
24
-
25
- class OpenAIInstrumentor(BaseInstrumentor):
26
-
27
- def __init__(self) -> None:
28
- self._original_create: Optional[Any] = None
29
- self._original_acreate: Optional[Any] = None
30
- self._patched: bool = False
31
-
32
- def patch(self) -> None:
33
- if self._patched:
34
- return
35
- import openai.resources.chat.completions as mod
36
-
37
- # Sync
38
- self._original_create = mod.Completions.create
39
- self_ref = self
40
- _orig_create = self._original_create
41
-
42
- def patched_create(client_self, *args, **kwargs):
43
- return self_ref._intercept(client_self, args, kwargs, _orig_create)
44
-
45
- mod.Completions.create = patched_create
46
-
47
- # Async
48
- if hasattr(mod, "AsyncCompletions"):
49
- self._original_acreate = mod.AsyncCompletions.create
50
- _orig_acreate = self._original_acreate
51
-
52
- async def patched_acreate(client_self, *args, **kwargs):
53
- return await self_ref._async_intercept(client_self, args, kwargs, _orig_acreate)
54
-
55
- mod.AsyncCompletions.create = patched_acreate
56
-
57
- self._patched = True
58
-
59
- def unpatch(self) -> None:
60
- if not self._patched:
61
- return
62
- import openai.resources.chat.completions as mod
63
- mod.Completions.create = self._original_create
64
- if self._original_acreate is not None:
65
- mod.AsyncCompletions.create = self._original_acreate
66
- self._original_create = None
67
- self._original_acreate = None
68
- self._patched = False
69
-
70
- def is_patched(self) -> bool:
71
- return self._patched
72
-
73
- def _intercept(self, client_self: Any, args: tuple, kwargs: dict, original_fn: Any) -> Any:
74
- from ..core.tracer import Tracer
75
- trace = Tracer.current()
76
- if trace is None or _handled_by_langchain():
77
- return original_fn(client_self, *args, **kwargs)
78
- return self._capture(client_self, args, kwargs, original_fn, trace)
79
-
80
- def _capture(self, client_self: Any, args: tuple, kwargs: dict, original_fn: Any, trace: Any) -> Any:
81
- from ..models.span import Span, SpanType
82
- from ..core.token_counter import extract_tokens, extract_content, extract_input_text
83
- from ..core.cost_calculator import calculate_cost
84
-
85
- model = kwargs.get("model", "unknown")
86
- input_text = extract_input_text(kwargs, "openai")
87
-
88
- span = Span(
89
- span_id=str(uuid.uuid4()),
90
- parent_span_id=active_parent_id(),
91
- type=SpanType.LLM,
92
- name=f"llm:{model}",
93
- model=model,
94
- started_at=datetime.now(timezone.utc),
95
- input=input_text,
96
- )
97
-
98
- if kwargs.get("stream"):
99
- from ._streaming import stream_openai
100
- kwargs.setdefault("stream_options", {"include_usage": True})
101
- raw = original_fn(client_self, *args, **kwargs)
102
- return stream_openai(raw, span, trace, model)
103
-
104
- try:
105
- response = original_fn(client_self, *args, **kwargs)
106
- except Exception as exc:
107
- span.finished_at = datetime.now(timezone.utc)
108
- span.mark_error(exc)
109
- trace.spans.append(span)
110
- raise
111
-
112
- span.finished_at = datetime.now(timezone.utc)
113
- tokens = extract_tokens(response, "openai")
114
- span.tokens_in = int(tokens["input"])
115
- span.tokens_out = int(tokens["output"])
116
- span.tokens_in_cached = int(tokens.get("cached", 0))
117
- span.cost_usd = calculate_cost(
118
- model, span.tokens_in, span.tokens_out, tokens_in_cached=span.tokens_in_cached
119
- )
120
- span.output = extract_content(response, "openai")
121
- trace.spans.append(span)
122
-
123
- return response
124
-
125
- async def _async_intercept(self, client_self: Any, args: tuple, kwargs: dict, original_fn: Any) -> Any:
126
- from ..core.tracer import Tracer
127
- trace = Tracer.current()
128
- if trace is None or _handled_by_langchain():
129
- return await original_fn(client_self, *args, **kwargs)
130
- return await self._async_capture(client_self, args, kwargs, original_fn, trace)
131
-
132
- async def _async_capture(self, client_self: Any, args: tuple, kwargs: dict, original_fn: Any, trace: Any) -> Any:
133
- from ..models.span import Span, SpanType
134
- from ..core.token_counter import extract_tokens, extract_content, extract_input_text
135
- from ..core.cost_calculator import calculate_cost
136
-
137
- model = kwargs.get("model", "unknown")
138
- input_text = extract_input_text(kwargs, "openai")
139
-
140
- span = Span(
141
- span_id=str(uuid.uuid4()),
142
- parent_span_id=active_parent_id(),
143
- type=SpanType.LLM,
144
- name=f"llm:{model}",
145
- model=model,
146
- started_at=datetime.now(timezone.utc),
147
- input=input_text,
148
- )
149
-
150
- if kwargs.get("stream"):
151
- from ._streaming import astream_openai
152
- kwargs.setdefault("stream_options", {"include_usage": True})
153
- raw = await original_fn(client_self, *args, **kwargs)
154
- return astream_openai(raw, span, trace, model)
155
-
156
- try:
157
- response = await original_fn(client_self, *args, **kwargs)
158
- except Exception as exc:
159
- span.finished_at = datetime.now(timezone.utc)
160
- span.mark_error(exc)
161
- trace.spans.append(span)
162
- raise
163
-
164
- span.finished_at = datetime.now(timezone.utc)
165
- tokens = extract_tokens(response, "openai")
166
- span.tokens_in = int(tokens["input"])
167
- span.tokens_out = int(tokens["output"])
168
- span.tokens_in_cached = int(tokens.get("cached", 0))
169
- span.cost_usd = calculate_cost(
170
- model, span.tokens_in, span.tokens_out, tokens_in_cached=span.tokens_in_cached
171
- )
172
- span.output = extract_content(response, "openai")
173
- trace.spans.append(span)
174
- return response
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes