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.
- tokenjam/__init__.py +1 -0
- tokenjam/api/__init__.py +0 -0
- tokenjam/api/app.py +104 -0
- tokenjam/api/deps.py +18 -0
- tokenjam/api/middleware.py +28 -0
- tokenjam/api/routes/__init__.py +0 -0
- tokenjam/api/routes/agents.py +33 -0
- tokenjam/api/routes/alerts.py +77 -0
- tokenjam/api/routes/budget.py +96 -0
- tokenjam/api/routes/cost.py +43 -0
- tokenjam/api/routes/drift.py +63 -0
- tokenjam/api/routes/logs.py +511 -0
- tokenjam/api/routes/metrics.py +81 -0
- tokenjam/api/routes/otlp.py +63 -0
- tokenjam/api/routes/spans.py +202 -0
- tokenjam/api/routes/status.py +84 -0
- tokenjam/api/routes/tools.py +22 -0
- tokenjam/api/routes/traces.py +92 -0
- tokenjam/cli/__init__.py +0 -0
- tokenjam/cli/cmd_alerts.py +94 -0
- tokenjam/cli/cmd_budget.py +119 -0
- tokenjam/cli/cmd_cost.py +90 -0
- tokenjam/cli/cmd_demo.py +82 -0
- tokenjam/cli/cmd_doctor.py +173 -0
- tokenjam/cli/cmd_drift.py +238 -0
- tokenjam/cli/cmd_export.py +200 -0
- tokenjam/cli/cmd_mcp.py +78 -0
- tokenjam/cli/cmd_onboard.py +779 -0
- tokenjam/cli/cmd_serve.py +85 -0
- tokenjam/cli/cmd_status.py +153 -0
- tokenjam/cli/cmd_stop.py +87 -0
- tokenjam/cli/cmd_tools.py +45 -0
- tokenjam/cli/cmd_traces.py +161 -0
- tokenjam/cli/cmd_uninstall.py +159 -0
- tokenjam/cli/main.py +110 -0
- tokenjam/core/__init__.py +0 -0
- tokenjam/core/alerts.py +619 -0
- tokenjam/core/api_backend.py +235 -0
- tokenjam/core/config.py +360 -0
- tokenjam/core/cost.py +102 -0
- tokenjam/core/db.py +718 -0
- tokenjam/core/drift.py +256 -0
- tokenjam/core/ingest.py +265 -0
- tokenjam/core/models.py +225 -0
- tokenjam/core/pricing.py +54 -0
- tokenjam/core/retention.py +21 -0
- tokenjam/core/schema_validator.py +156 -0
- tokenjam/demo/__init__.py +0 -0
- tokenjam/demo/env.py +96 -0
- tokenjam/mcp/__init__.py +0 -0
- tokenjam/mcp/server.py +1067 -0
- tokenjam/otel/__init__.py +0 -0
- tokenjam/otel/exporters.py +26 -0
- tokenjam/otel/provider.py +207 -0
- tokenjam/otel/semconv.py +144 -0
- tokenjam/pricing/models.toml +70 -0
- tokenjam/py.typed +0 -0
- tokenjam/sdk/__init__.py +21 -0
- tokenjam/sdk/agent.py +206 -0
- tokenjam/sdk/bootstrap.py +120 -0
- tokenjam/sdk/http_exporter.py +109 -0
- tokenjam/sdk/integrations/__init__.py +0 -0
- tokenjam/sdk/integrations/anthropic.py +200 -0
- tokenjam/sdk/integrations/autogen.py +97 -0
- tokenjam/sdk/integrations/base.py +27 -0
- tokenjam/sdk/integrations/bedrock.py +103 -0
- tokenjam/sdk/integrations/crewai.py +96 -0
- tokenjam/sdk/integrations/gemini.py +131 -0
- tokenjam/sdk/integrations/langchain.py +156 -0
- tokenjam/sdk/integrations/langgraph.py +101 -0
- tokenjam/sdk/integrations/litellm.py +323 -0
- tokenjam/sdk/integrations/llamaindex.py +52 -0
- tokenjam/sdk/integrations/nemoclaw.py +139 -0
- tokenjam/sdk/integrations/openai.py +159 -0
- tokenjam/sdk/integrations/openai_agents_sdk.py +47 -0
- tokenjam/sdk/transport.py +98 -0
- tokenjam/ui/index.html +1213 -0
- tokenjam/utils/__init__.py +0 -0
- tokenjam/utils/formatting.py +43 -0
- tokenjam/utils/ids.py +15 -0
- tokenjam/utils/time_parse.py +54 -0
- tokenjam-0.2.0.dist-info/METADATA +622 -0
- tokenjam-0.2.0.dist-info/RECORD +86 -0
- tokenjam-0.2.0.dist-info/WHEEL +4 -0
- tokenjam-0.2.0.dist-info/entry_points.txt +2 -0
- 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"))
|