tokenjam 0.2.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 (86) hide show
  1. tokenjam/__init__.py +1 -0
  2. tokenjam/api/__init__.py +0 -0
  3. tokenjam/api/app.py +104 -0
  4. tokenjam/api/deps.py +18 -0
  5. tokenjam/api/middleware.py +28 -0
  6. tokenjam/api/routes/__init__.py +0 -0
  7. tokenjam/api/routes/agents.py +33 -0
  8. tokenjam/api/routes/alerts.py +77 -0
  9. tokenjam/api/routes/budget.py +96 -0
  10. tokenjam/api/routes/cost.py +43 -0
  11. tokenjam/api/routes/drift.py +63 -0
  12. tokenjam/api/routes/logs.py +511 -0
  13. tokenjam/api/routes/metrics.py +81 -0
  14. tokenjam/api/routes/otlp.py +63 -0
  15. tokenjam/api/routes/spans.py +202 -0
  16. tokenjam/api/routes/status.py +84 -0
  17. tokenjam/api/routes/tools.py +22 -0
  18. tokenjam/api/routes/traces.py +92 -0
  19. tokenjam/cli/__init__.py +0 -0
  20. tokenjam/cli/cmd_alerts.py +94 -0
  21. tokenjam/cli/cmd_budget.py +119 -0
  22. tokenjam/cli/cmd_cost.py +90 -0
  23. tokenjam/cli/cmd_demo.py +82 -0
  24. tokenjam/cli/cmd_doctor.py +173 -0
  25. tokenjam/cli/cmd_drift.py +238 -0
  26. tokenjam/cli/cmd_export.py +200 -0
  27. tokenjam/cli/cmd_mcp.py +78 -0
  28. tokenjam/cli/cmd_onboard.py +779 -0
  29. tokenjam/cli/cmd_serve.py +85 -0
  30. tokenjam/cli/cmd_status.py +153 -0
  31. tokenjam/cli/cmd_stop.py +87 -0
  32. tokenjam/cli/cmd_tools.py +45 -0
  33. tokenjam/cli/cmd_traces.py +161 -0
  34. tokenjam/cli/cmd_uninstall.py +159 -0
  35. tokenjam/cli/main.py +110 -0
  36. tokenjam/core/__init__.py +0 -0
  37. tokenjam/core/alerts.py +619 -0
  38. tokenjam/core/api_backend.py +235 -0
  39. tokenjam/core/config.py +360 -0
  40. tokenjam/core/cost.py +102 -0
  41. tokenjam/core/db.py +718 -0
  42. tokenjam/core/drift.py +256 -0
  43. tokenjam/core/ingest.py +265 -0
  44. tokenjam/core/models.py +225 -0
  45. tokenjam/core/pricing.py +54 -0
  46. tokenjam/core/retention.py +21 -0
  47. tokenjam/core/schema_validator.py +156 -0
  48. tokenjam/demo/__init__.py +0 -0
  49. tokenjam/demo/env.py +96 -0
  50. tokenjam/mcp/__init__.py +0 -0
  51. tokenjam/mcp/server.py +1067 -0
  52. tokenjam/otel/__init__.py +0 -0
  53. tokenjam/otel/exporters.py +26 -0
  54. tokenjam/otel/provider.py +207 -0
  55. tokenjam/otel/semconv.py +144 -0
  56. tokenjam/pricing/models.toml +70 -0
  57. tokenjam/py.typed +0 -0
  58. tokenjam/sdk/__init__.py +21 -0
  59. tokenjam/sdk/agent.py +206 -0
  60. tokenjam/sdk/bootstrap.py +120 -0
  61. tokenjam/sdk/http_exporter.py +109 -0
  62. tokenjam/sdk/integrations/__init__.py +0 -0
  63. tokenjam/sdk/integrations/anthropic.py +200 -0
  64. tokenjam/sdk/integrations/autogen.py +97 -0
  65. tokenjam/sdk/integrations/base.py +27 -0
  66. tokenjam/sdk/integrations/bedrock.py +103 -0
  67. tokenjam/sdk/integrations/crewai.py +96 -0
  68. tokenjam/sdk/integrations/gemini.py +131 -0
  69. tokenjam/sdk/integrations/langchain.py +156 -0
  70. tokenjam/sdk/integrations/langgraph.py +101 -0
  71. tokenjam/sdk/integrations/litellm.py +323 -0
  72. tokenjam/sdk/integrations/llamaindex.py +52 -0
  73. tokenjam/sdk/integrations/nemoclaw.py +139 -0
  74. tokenjam/sdk/integrations/openai.py +159 -0
  75. tokenjam/sdk/integrations/openai_agents_sdk.py +47 -0
  76. tokenjam/sdk/transport.py +98 -0
  77. tokenjam/ui/index.html +1213 -0
  78. tokenjam/utils/__init__.py +0 -0
  79. tokenjam/utils/formatting.py +43 -0
  80. tokenjam/utils/ids.py +15 -0
  81. tokenjam/utils/time_parse.py +54 -0
  82. tokenjam-0.2.0.dist-info/METADATA +622 -0
  83. tokenjam-0.2.0.dist-info/RECORD +86 -0
  84. tokenjam-0.2.0.dist-info/WHEEL +4 -0
  85. tokenjam-0.2.0.dist-info/entry_points.txt +2 -0
  86. tokenjam-0.2.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,323 @@
1
+ """
2
+ LiteLLM provider integration.
3
+
4
+ Wraps litellm.completion and litellm.acompletion to automatically
5
+ create OTel spans with token usage and provider attribution.
6
+
7
+ LiteLLM provides a unified interface across 100+ LLM providers. A single
8
+ patch_litellm() call gives tj coverage across all of them.
9
+
10
+ When both patch_litellm() and patch_openai()/patch_anthropic() are active,
11
+ the LiteLLM patch wins — a context variable prevents inner provider patches
12
+ from creating duplicate spans.
13
+ """
14
+ from __future__ import annotations
15
+
16
+ import contextvars
17
+ import functools
18
+ import logging
19
+
20
+ from opentelemetry import trace
21
+
22
+ from tokenjam.otel.semconv import GenAIAttributes
23
+
24
+ logger = logging.getLogger(__name__)
25
+
26
+ # Context variable used to suppress inner provider patches (openai, anthropic)
27
+ # when a call originates from litellm.completion/acompletion.
28
+ _tj_litellm_active: contextvars.ContextVar[bool] = contextvars.ContextVar(
29
+ "_tj_litellm_active", default=False,
30
+ )
31
+
32
+
33
+ def _parse_provider(model: str, response: object) -> str:
34
+ """Extract provider name from response or model string prefix."""
35
+ # Prefer the actual provider from LiteLLM's hidden params
36
+ hidden = getattr(response, "_hidden_params", None)
37
+ if isinstance(hidden, dict):
38
+ provider = hidden.get("custom_llm_provider")
39
+ if provider:
40
+ return str(provider)
41
+ # Fallback: infer from model string prefix (e.g. "anthropic/claude-...")
42
+ if "/" in model:
43
+ return model.split("/", 1)[0]
44
+ return "litellm"
45
+
46
+
47
+ def _strip_provider_prefix(model: str) -> str:
48
+ """Strip provider prefix from LiteLLM model string.
49
+
50
+ 'openai/gpt-4o-mini' -> 'gpt-4o-mini', consistent with patch_openai.
51
+ """
52
+ if "/" in model:
53
+ return model.split("/", 1)[1]
54
+ return model
55
+
56
+
57
+ class LiteLLMIntegration:
58
+ name = "litellm"
59
+ installed = False
60
+
61
+ def __init__(self) -> None:
62
+ self._original_completion = None
63
+ self._original_acompletion = None
64
+ self._tracer = None
65
+
66
+ def install(self, tracer) -> None:
67
+ """Patch litellm.completion and litellm.acompletion."""
68
+ if self.installed:
69
+ return
70
+ self._tracer = tracer
71
+ try:
72
+ import litellm
73
+ except ImportError:
74
+ logger.warning("litellm package not installed — skipping patch")
75
+ return
76
+
77
+ self._original_completion = litellm.completion
78
+ self._original_acompletion = litellm.acompletion
79
+ integration = self
80
+
81
+ @functools.wraps(self._original_completion)
82
+ def patched_completion(*args, **kwargs):
83
+ raw_model = str(args[0] if args else kwargs.get("model", "unknown"))
84
+ model = _strip_provider_prefix(raw_model)
85
+ is_stream = kwargs.get("stream", False)
86
+
87
+ span = integration._tracer.start_span(GenAIAttributes.SPAN_LLM_CALL)
88
+ span.set_attribute(GenAIAttributes.REQUEST_MODEL, model)
89
+
90
+ # Inherit agent_id / conversation_id from parent span
91
+ parent_span = trace.get_current_span()
92
+ if parent_span and parent_span.is_recording():
93
+ agent_id = parent_span.attributes.get(GenAIAttributes.AGENT_ID)
94
+ if agent_id:
95
+ span.set_attribute(GenAIAttributes.AGENT_ID, agent_id)
96
+ conv_id = parent_span.attributes.get(
97
+ GenAIAttributes.CONVERSATION_ID,
98
+ )
99
+ if conv_id:
100
+ span.set_attribute(
101
+ GenAIAttributes.CONVERSATION_ID, conv_id,
102
+ )
103
+
104
+ token = _tj_litellm_active.set(True)
105
+ try:
106
+ response = integration._original_completion(*args, **kwargs)
107
+ if is_stream:
108
+ return _SyncStreamWrapper(response, span, raw_model, token)
109
+ _record_usage(response, span, raw_model)
110
+ span.set_status(trace.Status(trace.StatusCode.OK))
111
+ return response
112
+ except Exception as exc:
113
+ span.set_status(
114
+ trace.Status(trace.StatusCode.ERROR, str(exc)),
115
+ )
116
+ raise
117
+ finally:
118
+ if not is_stream:
119
+ span.end()
120
+ _tj_litellm_active.reset(token)
121
+
122
+ litellm.completion = patched_completion
123
+
124
+ @functools.wraps(self._original_acompletion)
125
+ async def patched_acompletion(*args, **kwargs):
126
+ raw_model = str(args[0] if args else kwargs.get("model", "unknown"))
127
+ model = _strip_provider_prefix(raw_model)
128
+ is_stream = kwargs.get("stream", False)
129
+
130
+ span = integration._tracer.start_span(GenAIAttributes.SPAN_LLM_CALL)
131
+ span.set_attribute(GenAIAttributes.REQUEST_MODEL, model)
132
+
133
+ parent_span = trace.get_current_span()
134
+ if parent_span and parent_span.is_recording():
135
+ agent_id = parent_span.attributes.get(GenAIAttributes.AGENT_ID)
136
+ if agent_id:
137
+ span.set_attribute(GenAIAttributes.AGENT_ID, agent_id)
138
+ conv_id = parent_span.attributes.get(
139
+ GenAIAttributes.CONVERSATION_ID,
140
+ )
141
+ if conv_id:
142
+ span.set_attribute(
143
+ GenAIAttributes.CONVERSATION_ID, conv_id,
144
+ )
145
+
146
+ token = _tj_litellm_active.set(True)
147
+ try:
148
+ response = await integration._original_acompletion(
149
+ *args, **kwargs,
150
+ )
151
+ if is_stream:
152
+ return _AsyncStreamWrapper(response, span, raw_model, token)
153
+ _record_usage(response, span, raw_model)
154
+ span.set_status(trace.Status(trace.StatusCode.OK))
155
+ return response
156
+ except Exception as exc:
157
+ span.set_status(
158
+ trace.Status(trace.StatusCode.ERROR, str(exc)),
159
+ )
160
+ raise
161
+ finally:
162
+ if not is_stream:
163
+ span.end()
164
+ _tj_litellm_active.reset(token)
165
+
166
+ litellm.acompletion = patched_acompletion
167
+
168
+ self.installed = True
169
+ logger.debug("LiteLLM integration installed")
170
+
171
+ def uninstall(self) -> None:
172
+ if not self.installed:
173
+ return
174
+ try:
175
+ import litellm
176
+ if self._original_completion:
177
+ litellm.completion = self._original_completion
178
+ if self._original_acompletion:
179
+ litellm.acompletion = self._original_acompletion
180
+ except ImportError:
181
+ pass
182
+ self.installed = False
183
+
184
+
185
+ def _record_usage(response: object, span, model: str) -> None:
186
+ """Extract provider and token usage from a LiteLLM ModelResponse."""
187
+ provider = _parse_provider(model, response)
188
+ span.set_attribute(GenAIAttributes.PROVIDER_NAME, provider)
189
+ # Update model attribute to strip provider prefix for consistent pricing
190
+ span.set_attribute(GenAIAttributes.REQUEST_MODEL, _strip_provider_prefix(model))
191
+
192
+ usage = getattr(response, "usage", None)
193
+ if usage:
194
+ prompt_tokens = getattr(usage, "prompt_tokens", None)
195
+ if prompt_tokens is not None:
196
+ span.set_attribute(GenAIAttributes.INPUT_TOKENS, prompt_tokens)
197
+ completion_tokens = getattr(usage, "completion_tokens", None)
198
+ if completion_tokens is not None:
199
+ span.set_attribute(
200
+ GenAIAttributes.OUTPUT_TOKENS, completion_tokens,
201
+ )
202
+
203
+
204
+ class _SyncStreamWrapper:
205
+ """Wraps a LiteLLM sync stream to capture usage and end the span."""
206
+
207
+ def __init__(self, stream, span, model: str, token):
208
+ self._stream = stream
209
+ self._span = span
210
+ self._model = model
211
+ self._token = token
212
+ self._usage = None
213
+ self._last_chunk = None
214
+
215
+ def __iter__(self):
216
+ _ok = False
217
+ try:
218
+ for chunk in self._stream:
219
+ usage = getattr(chunk, "usage", None)
220
+ if usage:
221
+ self._usage = usage
222
+ self._last_chunk = chunk
223
+ yield chunk
224
+ _ok = True
225
+ except Exception as exc:
226
+ self._span.set_status(
227
+ trace.Status(trace.StatusCode.ERROR, str(exc)),
228
+ )
229
+ raise
230
+ finally:
231
+ provider = _parse_provider(self._model, self._last_chunk)
232
+ self._span.set_attribute(GenAIAttributes.PROVIDER_NAME, provider)
233
+ self._span.set_attribute(
234
+ GenAIAttributes.REQUEST_MODEL,
235
+ _strip_provider_prefix(self._model),
236
+ )
237
+ if self._usage:
238
+ prompt_tokens = getattr(
239
+ self._usage, "prompt_tokens", None,
240
+ )
241
+ if prompt_tokens is not None:
242
+ self._span.set_attribute(
243
+ GenAIAttributes.INPUT_TOKENS, prompt_tokens,
244
+ )
245
+ completion_tokens = getattr(
246
+ self._usage, "completion_tokens", None,
247
+ )
248
+ if completion_tokens is not None:
249
+ self._span.set_attribute(
250
+ GenAIAttributes.OUTPUT_TOKENS, completion_tokens,
251
+ )
252
+ if _ok:
253
+ self._span.set_status(trace.Status(trace.StatusCode.OK))
254
+ self._span.end()
255
+ _tj_litellm_active.reset(self._token)
256
+
257
+ def __next__(self):
258
+ return self._stream.__next__()
259
+
260
+
261
+ class _AsyncStreamWrapper:
262
+ """Wraps a LiteLLM async stream to capture usage and end the span."""
263
+
264
+ def __init__(self, stream, span, model: str, token):
265
+ self._stream = stream
266
+ self._span = span
267
+ self._model = model
268
+ self._token = token
269
+ self._usage = None
270
+ self._last_chunk = None
271
+
272
+ def __aiter__(self):
273
+ return self._iterate()
274
+
275
+ async def _iterate(self):
276
+ _ok = False
277
+ try:
278
+ async for chunk in self._stream:
279
+ usage = getattr(chunk, "usage", None)
280
+ if usage:
281
+ self._usage = usage
282
+ self._last_chunk = chunk
283
+ yield chunk
284
+ _ok = True
285
+ except Exception as exc:
286
+ self._span.set_status(
287
+ trace.Status(trace.StatusCode.ERROR, str(exc)),
288
+ )
289
+ raise
290
+ finally:
291
+ provider = _parse_provider(self._model, self._last_chunk)
292
+ self._span.set_attribute(GenAIAttributes.PROVIDER_NAME, provider)
293
+ self._span.set_attribute(
294
+ GenAIAttributes.REQUEST_MODEL,
295
+ _strip_provider_prefix(self._model),
296
+ )
297
+ if self._usage:
298
+ prompt_tokens = getattr(
299
+ self._usage, "prompt_tokens", None,
300
+ )
301
+ if prompt_tokens is not None:
302
+ self._span.set_attribute(
303
+ GenAIAttributes.INPUT_TOKENS, prompt_tokens,
304
+ )
305
+ completion_tokens = getattr(
306
+ self._usage, "completion_tokens", None,
307
+ )
308
+ if completion_tokens is not None:
309
+ self._span.set_attribute(
310
+ GenAIAttributes.OUTPUT_TOKENS, completion_tokens,
311
+ )
312
+ if _ok:
313
+ self._span.set_status(trace.Status(trace.StatusCode.OK))
314
+ self._span.end()
315
+ _tj_litellm_active.reset(self._token)
316
+
317
+
318
+ def patch_litellm() -> None:
319
+ """Convenience function. Instantiates and installs LiteLLMIntegration."""
320
+ from tokenjam.sdk.bootstrap import ensure_initialised
321
+ ensure_initialised()
322
+ integration = LiteLLMIntegration()
323
+ integration.install(trace.get_tracer("tokenjam.sdk"))
@@ -0,0 +1,52 @@
1
+ """
2
+ LlamaIndex framework integration.
3
+
4
+ LlamaIndex has native OTel support. This module is a thin convenience wrapper
5
+ that configures LlamaIndex's built-in OTel instrumentation to point at tj serve.
6
+
7
+ Does NOT monkey-patch LlamaIndex internals — uses its official instrumentation API.
8
+
9
+ Requires: pip install opentelemetry-instrumentation-llama-index
10
+ """
11
+ from __future__ import annotations
12
+
13
+ import logging
14
+ from typing import TYPE_CHECKING
15
+
16
+ from tokenjam.core.config import load_config
17
+
18
+ if TYPE_CHECKING:
19
+ from tokenjam.core.config import TjConfig
20
+
21
+ logger = logging.getLogger(__name__)
22
+
23
+
24
+ def patch_llamaindex(config: TjConfig | None = None) -> None:
25
+ """
26
+ Configure LlamaIndex's built-in OTel support to export to tj serve.
27
+ """
28
+ if config is None:
29
+ config = load_config()
30
+
31
+ try:
32
+ from opentelemetry.instrumentation.llama_index import LlamaIndexInstrumentor
33
+ from opentelemetry.sdk.trace import TracerProvider
34
+ from opentelemetry.sdk.trace.export import BatchSpanProcessor
35
+ from opentelemetry.exporter.otlp.proto.http.trace_exporter import OTLPSpanExporter
36
+ except ImportError:
37
+ logger.warning(
38
+ "opentelemetry-instrumentation-llama-index not installed — "
39
+ "run: pip install opentelemetry-instrumentation-llama-index"
40
+ )
41
+ return
42
+
43
+ endpoint = f"http://{config.api.host}:{config.api.port}/api/v1/spans"
44
+ headers: dict[str, str] = {}
45
+ if config.security.ingest_secret:
46
+ headers["Authorization"] = f"Bearer {config.security.ingest_secret}"
47
+
48
+ exporter = OTLPSpanExporter(endpoint=endpoint, headers=headers)
49
+ provider = TracerProvider()
50
+ provider.add_span_processor(BatchSpanProcessor(exporter))
51
+ LlamaIndexInstrumentor().instrument(tracer_provider=provider)
52
+ logger.debug("LlamaIndex integration installed (via native OTel)")
@@ -0,0 +1,139 @@
1
+ """
2
+ NemoClaw/OpenShell Gateway WebSocket observer.
3
+
4
+ Connects to the OpenShell Gateway as an observer client, receives sandbox
5
+ events, and translates them into OTel spans fed into the ingest pipeline.
6
+
7
+ NOTICE: NemoClaw is licensed under Apache License 2.0.
8
+ This integration module acknowledges the upstream Apache 2.0 license.
9
+ See https://github.com/NVIDIA/NemoClaw/blob/main/LICENSE
10
+ """
11
+ from __future__ import annotations
12
+
13
+ import asyncio
14
+ import json
15
+ import logging
16
+ from typing import TYPE_CHECKING
17
+
18
+
19
+ from tokenjam.core.models import NormalizedSpan, SpanKind, SpanStatus
20
+ from tokenjam.otel.semconv import TjAttributes
21
+ from tokenjam.utils.ids import new_span_id, new_trace_id
22
+ from tokenjam.utils.time_parse import utcnow
23
+
24
+ if TYPE_CHECKING:
25
+ from tokenjam.core.config import TjConfig
26
+
27
+ logger = logging.getLogger(__name__)
28
+
29
+ SANDBOX_EVENT_MAP = {
30
+ "network_blocked": "network_blocked",
31
+ "fs_access_denied": "fs_denied",
32
+ "syscall_blocked": "syscall_denied",
33
+ "inference_reroute": "inference_rerouted",
34
+ }
35
+
36
+
37
+ class NemoClawGatewayObserver:
38
+ """
39
+ Observes a NemoClaw OpenShell sandbox by connecting to the
40
+ OpenShell Gateway WebSocket as an observer client.
41
+
42
+ All inference calls, blocked network requests, filesystem denials,
43
+ and syscall blocks are translated into OTel spans and fed into the
44
+ standard tj ingest pipeline.
45
+
46
+ Usage:
47
+ observer = NemoClawGatewayObserver(ingest_pipeline)
48
+ asyncio.run(observer.connect()) # runs until cancelled
49
+ """
50
+
51
+ def __init__(self, ingest_pipeline, gateway_url: str = "ws://127.0.0.1:18789"):
52
+ self.pipeline = ingest_pipeline
53
+ self.gateway_url = gateway_url
54
+
55
+ async def connect(self) -> None:
56
+ """Connect and observe. Reconnects with backoff on disconnect."""
57
+ try:
58
+ import websockets
59
+ except ImportError:
60
+ logger.error("websockets package not installed — cannot observe NemoClaw")
61
+ return
62
+
63
+ backoff = 1.0
64
+ while True:
65
+ try:
66
+ async with websockets.connect(self.gateway_url) as ws:
67
+ logger.info("Connected to NemoClaw gateway at %s", self.gateway_url)
68
+ backoff = 1.0
69
+ async for message in ws:
70
+ try:
71
+ event = json.loads(message)
72
+ span = self._translate_event(event)
73
+ if span:
74
+ self.pipeline.process(span)
75
+ except (json.JSONDecodeError, KeyError) as exc:
76
+ logger.debug("Skipping malformed gateway event: %s", exc)
77
+ except Exception as exc:
78
+ logger.warning(
79
+ "NemoClaw gateway disconnected: %s — reconnecting in %.0fs",
80
+ exc, backoff,
81
+ )
82
+ await asyncio.sleep(backoff)
83
+ backoff = min(backoff * 2, 60.0)
84
+
85
+ def _translate_event(self, event: dict) -> NormalizedSpan | None:
86
+ """Convert an OpenShell gateway event to a NormalizedSpan."""
87
+ event_type = event.get("type", "")
88
+ ocw_event = SANDBOX_EVENT_MAP.get(event_type)
89
+ if not ocw_event:
90
+ return None
91
+
92
+ now = utcnow()
93
+ attrs: dict = {
94
+ TjAttributes.SANDBOX_EVENT: ocw_event,
95
+ }
96
+
97
+ if ocw_event == "network_blocked":
98
+ attrs[TjAttributes.EGRESS_HOST] = event.get("host", "unknown")
99
+ attrs[TjAttributes.EGRESS_PORT] = event.get("port")
100
+ elif ocw_event == "fs_denied":
101
+ attrs[TjAttributes.FILESYSTEM_PATH] = event.get("path", "unknown")
102
+ elif ocw_event == "syscall_denied":
103
+ attrs[TjAttributes.SYSCALL_NAME] = event.get("syscall", "unknown")
104
+
105
+ return NormalizedSpan(
106
+ span_id=new_span_id(),
107
+ trace_id=new_trace_id(),
108
+ name=f"sandbox.{ocw_event}",
109
+ kind=SpanKind.INTERNAL,
110
+ status_code=SpanStatus.ERROR,
111
+ start_time=now,
112
+ end_time=now,
113
+ duration_ms=0.0,
114
+ agent_id=event.get("agent_id"),
115
+ attributes=attrs,
116
+ )
117
+
118
+
119
+ def watch_nemoclaw(
120
+ gateway_url: str = "ws://127.0.0.1:18789",
121
+ config: TjConfig | None = None,
122
+ ) -> NemoClawGatewayObserver:
123
+ """
124
+ Convenience function. Creates an observer instance.
125
+ Call observer.connect() in an asyncio task to start observing.
126
+
127
+ Note: You must pass an ingest_pipeline instance. If config is None,
128
+ a default config is loaded.
129
+ """
130
+ from tokenjam.core.config import load_config
131
+ from tokenjam.sdk.bootstrap import ensure_initialised, _pipeline
132
+ if config is None:
133
+ config = load_config()
134
+ ensure_initialised()
135
+ logger.info("NemoClaw observer created for %s", gateway_url)
136
+ return NemoClawGatewayObserver(
137
+ ingest_pipeline=_pipeline,
138
+ gateway_url=gateway_url,
139
+ )
@@ -0,0 +1,159 @@
1
+ """
2
+ OpenAI provider integration.
3
+
4
+ Wraps openai.resources.chat.completions.Completions.create to automatically
5
+ create OTel spans with token usage and model attributes.
6
+
7
+ Also works for OpenAI-compatible providers (Groq, Together, Fireworks, xAI,
8
+ Azure OpenAI) — pass the provider's base_url and set provider name from it.
9
+ """
10
+ from __future__ import annotations
11
+
12
+ import functools
13
+ import logging
14
+
15
+ from opentelemetry import trace
16
+
17
+ from tokenjam.otel.semconv import GenAIAttributes
18
+
19
+ logger = logging.getLogger(__name__)
20
+
21
+
22
+ class OpenAIIntegration:
23
+ name = "openai"
24
+ installed = False
25
+
26
+ def __init__(self, provider_name: str = "openai") -> None:
27
+ self._original_create = None
28
+ self._tracer = None
29
+ self._provider_name = provider_name
30
+
31
+ def install(self, tracer) -> None:
32
+ """Patch openai.resources.chat.completions.Completions.create."""
33
+ if self.installed:
34
+ return
35
+ self._tracer = tracer
36
+ try:
37
+ from openai.resources.chat.completions import Completions
38
+ except ImportError:
39
+ logger.warning("openai package not installed — skipping patch")
40
+ return
41
+
42
+ self._original_create = Completions.create
43
+ integration = self
44
+
45
+ @functools.wraps(self._original_create)
46
+ def patched_create(self_comp, *args, **kwargs):
47
+ # Skip span if call originates from litellm (avoids double-counting)
48
+ from tokenjam.sdk.integrations.litellm import _tj_litellm_active
49
+ if _tj_litellm_active.get(False):
50
+ return integration._original_create(self_comp, *args, **kwargs)
51
+ span = integration._tracer.start_span(GenAIAttributes.SPAN_LLM_CALL)
52
+ span.set_attribute(GenAIAttributes.PROVIDER_NAME, integration._provider_name)
53
+ span.set_attribute(
54
+ GenAIAttributes.REQUEST_MODEL,
55
+ kwargs.get("model", "unknown"),
56
+ )
57
+ is_stream = kwargs.get("stream", False)
58
+ try:
59
+ response = integration._original_create(self_comp, *args, **kwargs)
60
+ if is_stream:
61
+ return _StreamWrapper(response, span)
62
+ if hasattr(response, "usage") and response.usage:
63
+ span.set_attribute(
64
+ GenAIAttributes.INPUT_TOKENS,
65
+ response.usage.prompt_tokens,
66
+ )
67
+ span.set_attribute(
68
+ GenAIAttributes.OUTPUT_TOKENS,
69
+ response.usage.completion_tokens,
70
+ )
71
+ span.set_status(trace.Status(trace.StatusCode.OK))
72
+ span.end()
73
+ return response
74
+ except Exception as exc:
75
+ span.set_status(trace.Status(trace.StatusCode.ERROR, str(exc)))
76
+ span.end()
77
+ raise
78
+
79
+ Completions.create = patched_create
80
+ self.installed = True
81
+ logger.debug("OpenAI integration installed (provider=%s)", self._provider_name)
82
+
83
+ def uninstall(self) -> None:
84
+ if not self.installed:
85
+ return
86
+ try:
87
+ from openai.resources.chat.completions import Completions
88
+ if self._original_create:
89
+ Completions.create = self._original_create
90
+ except ImportError:
91
+ pass
92
+ self.installed = False
93
+
94
+
95
+ class _StreamWrapper:
96
+ """Wraps an OpenAI stream to capture final usage chunk and end the span."""
97
+
98
+ def __init__(self, stream, span):
99
+ self._stream = stream
100
+ self._span = span
101
+ self._usage = None
102
+
103
+ def __iter__(self):
104
+ _ok = False
105
+ try:
106
+ for chunk in self._stream:
107
+ if hasattr(chunk, "usage") and chunk.usage:
108
+ self._usage = chunk.usage
109
+ yield chunk
110
+ _ok = True
111
+ except Exception as exc:
112
+ self._span.set_status(trace.Status(trace.StatusCode.ERROR, str(exc)))
113
+ raise
114
+ finally:
115
+ if self._usage:
116
+ self._span.set_attribute(
117
+ GenAIAttributes.INPUT_TOKENS,
118
+ self._usage.prompt_tokens,
119
+ )
120
+ self._span.set_attribute(
121
+ GenAIAttributes.OUTPUT_TOKENS,
122
+ self._usage.completion_tokens,
123
+ )
124
+ if _ok:
125
+ self._span.set_status(trace.Status(trace.StatusCode.OK))
126
+ self._span.end()
127
+
128
+ def __next__(self):
129
+ return self._stream.__next__()
130
+
131
+
132
+ def patch_openai(base_url: str | None = None) -> None:
133
+ """
134
+ Wraps the OpenAI client.
135
+ Also works for OpenAI-compatible providers (Groq, Together, Fireworks, xAI,
136
+ Azure OpenAI) — pass the provider's base_url and set provider name from it.
137
+ """
138
+ from tokenjam.sdk.bootstrap import ensure_initialised
139
+ ensure_initialised()
140
+ provider = "openai"
141
+ if base_url:
142
+ # Infer provider name from base_url domain
143
+ from urllib.parse import urlparse
144
+ domain = urlparse(base_url).hostname or ""
145
+ if "groq" in domain:
146
+ provider = "groq"
147
+ elif "together" in domain:
148
+ provider = "together"
149
+ elif "fireworks" in domain:
150
+ provider = "fireworks"
151
+ elif "xai" in domain or "x.ai" in domain:
152
+ provider = "xai"
153
+ elif "azure" in domain:
154
+ provider = "azure.openai"
155
+ else:
156
+ provider = domain.split(".")[0] if domain else "openai-compatible"
157
+
158
+ integration = OpenAIIntegration(provider_name=provider)
159
+ integration.install(trace.get_tracer("tokenjam.sdk"))