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,120 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Auto-bootstrap: lazily initialise the OCW TracerProvider + IngestPipeline
|
|
3
|
+
the first time @watch() or a provider patch creates a span.
|
|
4
|
+
|
|
5
|
+
This ensures that SDK users don't need to manually wire up the pipeline.
|
|
6
|
+
"""
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import atexit
|
|
10
|
+
import logging
|
|
11
|
+
import threading
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
logger = logging.getLogger("tokenjam.sdk")
|
|
15
|
+
|
|
16
|
+
_lock = threading.Lock()
|
|
17
|
+
_initialised = False
|
|
18
|
+
_provider = None
|
|
19
|
+
_pipeline = None
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def ensure_initialised() -> None:
|
|
23
|
+
"""
|
|
24
|
+
Idempotent bootstrap. Safe to call multiple times / from multiple threads.
|
|
25
|
+
Sets up: config -> DuckDB -> IngestPipeline -> TjSpanExporter -> TracerProvider.
|
|
26
|
+
"""
|
|
27
|
+
global _initialised, _provider, _pipeline
|
|
28
|
+
if _initialised:
|
|
29
|
+
return
|
|
30
|
+
|
|
31
|
+
with _lock:
|
|
32
|
+
if _initialised:
|
|
33
|
+
return
|
|
34
|
+
|
|
35
|
+
try:
|
|
36
|
+
from tokenjam.core.config import load_config
|
|
37
|
+
from tokenjam.otel.provider import build_tracer_provider
|
|
38
|
+
|
|
39
|
+
config = load_config()
|
|
40
|
+
|
|
41
|
+
# Check if tj serve is running — use HTTP exporter if so
|
|
42
|
+
if _try_http_mode(config):
|
|
43
|
+
_initialised = True
|
|
44
|
+
atexit.register(_shutdown)
|
|
45
|
+
return
|
|
46
|
+
|
|
47
|
+
# Direct DuckDB mode
|
|
48
|
+
from tokenjam.core.db import open_db
|
|
49
|
+
from tokenjam.core.ingest import build_default_pipeline
|
|
50
|
+
|
|
51
|
+
db = open_db(config.storage)
|
|
52
|
+
pipeline = build_default_pipeline(db, config)
|
|
53
|
+
_pipeline = pipeline
|
|
54
|
+
_provider = build_tracer_provider(config, pipeline)
|
|
55
|
+
_initialised = True
|
|
56
|
+
|
|
57
|
+
# Ensure spans are flushed on exit
|
|
58
|
+
atexit.register(_shutdown)
|
|
59
|
+
|
|
60
|
+
logger.debug("OCW: writing spans to local DuckDB (%s)", config.storage.path)
|
|
61
|
+
|
|
62
|
+
except Exception as exc:
|
|
63
|
+
# DuckDB lock error — try HTTP fallback
|
|
64
|
+
err_msg = str(exc).lower()
|
|
65
|
+
if "lock" in err_msg or "i/o error" in err_msg:
|
|
66
|
+
try:
|
|
67
|
+
config = load_config()
|
|
68
|
+
if _try_http_mode(config):
|
|
69
|
+
_initialised = True
|
|
70
|
+
atexit.register(_shutdown)
|
|
71
|
+
return
|
|
72
|
+
except Exception:
|
|
73
|
+
pass
|
|
74
|
+
logger.warning("OCW bootstrap failed — spans will not be recorded: %s", exc)
|
|
75
|
+
_initialised = True # Don't retry on every call
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def _try_http_mode(config) -> bool:
|
|
79
|
+
"""Try to connect to tj serve and set up HTTP exporter. Returns True on success."""
|
|
80
|
+
global _provider
|
|
81
|
+
import httpx
|
|
82
|
+
base_url = f"http://{config.api.host}:{config.api.port}"
|
|
83
|
+
try:
|
|
84
|
+
resp = httpx.get(f"{base_url}/api/v1/status", timeout=2)
|
|
85
|
+
if resp.status_code not in (200, 401):
|
|
86
|
+
return False
|
|
87
|
+
except (httpx.ConnectError, httpx.TimeoutException):
|
|
88
|
+
return False
|
|
89
|
+
|
|
90
|
+
from tokenjam.sdk.http_exporter import TjHttpExporter
|
|
91
|
+
from opentelemetry.sdk.trace import TracerProvider
|
|
92
|
+
from opentelemetry.sdk.trace.export import BatchSpanProcessor
|
|
93
|
+
from opentelemetry.sdk.resources import Resource
|
|
94
|
+
from opentelemetry import trace as trace_api
|
|
95
|
+
import tokenjam
|
|
96
|
+
|
|
97
|
+
endpoint = f"{base_url}/api/v1/spans"
|
|
98
|
+
exporter = TjHttpExporter(endpoint, config.security.ingest_secret)
|
|
99
|
+
|
|
100
|
+
resource = Resource.create({
|
|
101
|
+
"service.name": "tokenjam",
|
|
102
|
+
"service.version": tokenjam.__version__,
|
|
103
|
+
})
|
|
104
|
+
provider = TracerProvider(resource=resource)
|
|
105
|
+
provider.add_span_processor(BatchSpanProcessor(exporter))
|
|
106
|
+
trace_api.set_tracer_provider(provider)
|
|
107
|
+
_provider = provider
|
|
108
|
+
|
|
109
|
+
logger.info("TJ: sending spans to tj serve at %s", base_url)
|
|
110
|
+
return True
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def _shutdown() -> None:
|
|
114
|
+
"""Flush pending spans on interpreter exit."""
|
|
115
|
+
if _provider is not None:
|
|
116
|
+
try:
|
|
117
|
+
_provider.force_flush(timeout_millis=5000)
|
|
118
|
+
_provider.shutdown()
|
|
119
|
+
except Exception:
|
|
120
|
+
pass
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
"""
|
|
2
|
+
OTel SpanExporter that POSTs OTLP JSON to tj serve's /api/v1/spans endpoint.
|
|
3
|
+
Used when tj serve is running and holds the DuckDB lock.
|
|
4
|
+
"""
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
import logging
|
|
8
|
+
from typing import Sequence
|
|
9
|
+
|
|
10
|
+
import httpx
|
|
11
|
+
from opentelemetry.sdk.trace.export import SpanExporter, SpanExportResult
|
|
12
|
+
from opentelemetry.sdk.trace import ReadableSpan
|
|
13
|
+
|
|
14
|
+
logger = logging.getLogger("tokenjam.sdk")
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class TjHttpExporter(SpanExporter):
|
|
18
|
+
"""Exports spans via HTTP POST to tj serve."""
|
|
19
|
+
|
|
20
|
+
def __init__(self, endpoint: str, ingest_secret: str) -> None:
|
|
21
|
+
self._endpoint = endpoint
|
|
22
|
+
self._headers = {
|
|
23
|
+
"Content-Type": "application/json",
|
|
24
|
+
"Authorization": f"Bearer {ingest_secret}",
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
def export(self, spans: Sequence[ReadableSpan]) -> SpanExportResult:
|
|
28
|
+
otlp_spans = [_span_to_otlp(s) for s in spans]
|
|
29
|
+
payload = {
|
|
30
|
+
"resourceSpans": [{
|
|
31
|
+
"resource": {"attributes": [
|
|
32
|
+
{"key": "service.name", "value": {"stringValue": "tokenjam"}},
|
|
33
|
+
]},
|
|
34
|
+
"scopeSpans": [{"spans": otlp_spans}],
|
|
35
|
+
}],
|
|
36
|
+
}
|
|
37
|
+
try:
|
|
38
|
+
resp = httpx.post(
|
|
39
|
+
self._endpoint, json=payload, headers=self._headers, timeout=5.0,
|
|
40
|
+
)
|
|
41
|
+
if resp.status_code < 300:
|
|
42
|
+
return SpanExportResult.SUCCESS
|
|
43
|
+
logger.warning("tj serve returned %d on span export", resp.status_code)
|
|
44
|
+
except (httpx.ConnectError, httpx.TimeoutException) as exc:
|
|
45
|
+
logger.warning("Failed to export spans to tj serve: %s", exc)
|
|
46
|
+
return SpanExportResult.FAILURE
|
|
47
|
+
|
|
48
|
+
def shutdown(self) -> None:
|
|
49
|
+
pass
|
|
50
|
+
|
|
51
|
+
def force_flush(self, timeout_millis: int = 30000) -> bool:
|
|
52
|
+
return True
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _span_to_otlp(span: ReadableSpan) -> dict:
|
|
56
|
+
"""Convert an OTel ReadableSpan to OTLP JSON dict."""
|
|
57
|
+
ctx = span.context
|
|
58
|
+
attrs = []
|
|
59
|
+
for k, v in (span.attributes or {}).items():
|
|
60
|
+
attrs.append({"key": k, "value": _to_otlp_value(v)})
|
|
61
|
+
|
|
62
|
+
result: dict = {
|
|
63
|
+
"traceId": format(ctx.trace_id, "032x") if ctx else "",
|
|
64
|
+
"spanId": format(ctx.span_id, "016x") if ctx else "",
|
|
65
|
+
"name": span.name,
|
|
66
|
+
"kind": (span.kind.value + 1) if span.kind is not None else 1,
|
|
67
|
+
"startTimeUnixNano": str(span.start_time or 0),
|
|
68
|
+
"endTimeUnixNano": str(span.end_time or 0),
|
|
69
|
+
"attributes": attrs,
|
|
70
|
+
"status": {},
|
|
71
|
+
"events": [],
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
if span.parent and span.parent.span_id:
|
|
75
|
+
result["parentSpanId"] = format(span.parent.span_id, "016x")
|
|
76
|
+
|
|
77
|
+
if span.status:
|
|
78
|
+
code_map = {0: 0, 1: 1, 2: 2} # UNSET, OK, ERROR
|
|
79
|
+
result["status"] = {
|
|
80
|
+
"code": code_map.get(span.status.status_code.value, 0),
|
|
81
|
+
}
|
|
82
|
+
if span.status.description:
|
|
83
|
+
result["status"]["message"] = span.status.description
|
|
84
|
+
|
|
85
|
+
for event in span.events or []:
|
|
86
|
+
evt_attrs = [
|
|
87
|
+
{"key": k, "value": _to_otlp_value(v)}
|
|
88
|
+
for k, v in (event.attributes or {}).items()
|
|
89
|
+
]
|
|
90
|
+
result["events"].append({
|
|
91
|
+
"name": event.name,
|
|
92
|
+
"timeUnixNano": str(event.timestamp or 0),
|
|
93
|
+
"attributes": evt_attrs,
|
|
94
|
+
})
|
|
95
|
+
|
|
96
|
+
return result
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def _to_otlp_value(v: object) -> dict:
|
|
100
|
+
"""Convert a Python value to an OTLP AttributeValue dict."""
|
|
101
|
+
if isinstance(v, bool):
|
|
102
|
+
return {"boolValue": v}
|
|
103
|
+
if isinstance(v, int):
|
|
104
|
+
return {"intValue": str(v)}
|
|
105
|
+
if isinstance(v, float):
|
|
106
|
+
return {"doubleValue": v}
|
|
107
|
+
if isinstance(v, (list, tuple)):
|
|
108
|
+
return {"arrayValue": {"values": [_to_otlp_value(item) for item in v]}}
|
|
109
|
+
return {"stringValue": str(v)}
|
|
File without changes
|
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Anthropic provider integration.
|
|
3
|
+
|
|
4
|
+
Wraps anthropic.resources.Messages.create and .stream to automatically
|
|
5
|
+
create OTel spans with token usage and model attributes.
|
|
6
|
+
"""
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import functools
|
|
10
|
+
import logging
|
|
11
|
+
|
|
12
|
+
from opentelemetry import trace
|
|
13
|
+
|
|
14
|
+
from tokenjam.otel.semconv import GenAIAttributes
|
|
15
|
+
|
|
16
|
+
logger = logging.getLogger(__name__)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class AnthropicIntegration:
|
|
20
|
+
name = "anthropic"
|
|
21
|
+
installed = False
|
|
22
|
+
|
|
23
|
+
def __init__(self) -> None:
|
|
24
|
+
self._original_create = None
|
|
25
|
+
self._original_stream = None
|
|
26
|
+
self._tracer = None
|
|
27
|
+
|
|
28
|
+
def install(self, tracer) -> None:
|
|
29
|
+
"""Patch anthropic.resources.Messages.create and .stream."""
|
|
30
|
+
if self.installed:
|
|
31
|
+
return
|
|
32
|
+
self._tracer = tracer
|
|
33
|
+
try:
|
|
34
|
+
from anthropic.resources import Messages
|
|
35
|
+
except ImportError:
|
|
36
|
+
logger.warning("anthropic package not installed — skipping patch")
|
|
37
|
+
return
|
|
38
|
+
|
|
39
|
+
self._original_create = Messages.create
|
|
40
|
+
self._original_stream = getattr(Messages, "stream", None)
|
|
41
|
+
|
|
42
|
+
integration = self
|
|
43
|
+
|
|
44
|
+
@functools.wraps(self._original_create)
|
|
45
|
+
def patched_create(self_msg, *args, **kwargs):
|
|
46
|
+
# Skip span if call originates from litellm (avoids double-counting)
|
|
47
|
+
from tokenjam.sdk.integrations.litellm import _tj_litellm_active
|
|
48
|
+
if _tj_litellm_active.get(False):
|
|
49
|
+
return integration._original_create(self_msg, *args, **kwargs)
|
|
50
|
+
span = integration._tracer.start_span(GenAIAttributes.SPAN_LLM_CALL)
|
|
51
|
+
span.set_attribute(GenAIAttributes.PROVIDER_NAME, "anthropic")
|
|
52
|
+
span.set_attribute(
|
|
53
|
+
GenAIAttributes.REQUEST_MODEL,
|
|
54
|
+
kwargs.get("model", "unknown"),
|
|
55
|
+
)
|
|
56
|
+
# Inherit agent_id from parent span (set by @watch())
|
|
57
|
+
parent_span = trace.get_current_span()
|
|
58
|
+
if parent_span and parent_span.is_recording():
|
|
59
|
+
agent_id = parent_span.attributes.get(GenAIAttributes.AGENT_ID)
|
|
60
|
+
if agent_id:
|
|
61
|
+
span.set_attribute(GenAIAttributes.AGENT_ID, agent_id)
|
|
62
|
+
conv_id = parent_span.attributes.get(GenAIAttributes.CONVERSATION_ID)
|
|
63
|
+
if conv_id:
|
|
64
|
+
span.set_attribute(GenAIAttributes.CONVERSATION_ID, conv_id)
|
|
65
|
+
try:
|
|
66
|
+
response = integration._original_create(self_msg, *args, **kwargs)
|
|
67
|
+
if hasattr(response, "usage"):
|
|
68
|
+
span.set_attribute(
|
|
69
|
+
GenAIAttributes.INPUT_TOKENS,
|
|
70
|
+
response.usage.input_tokens,
|
|
71
|
+
)
|
|
72
|
+
span.set_attribute(
|
|
73
|
+
GenAIAttributes.OUTPUT_TOKENS,
|
|
74
|
+
response.usage.output_tokens,
|
|
75
|
+
)
|
|
76
|
+
cache_read = getattr(response.usage, "cache_read_input_tokens", None)
|
|
77
|
+
if cache_read:
|
|
78
|
+
span.set_attribute(GenAIAttributes.CACHE_READ_TOKENS, cache_read)
|
|
79
|
+
cache_create = getattr(response.usage, "cache_creation_input_tokens", None)
|
|
80
|
+
if cache_create:
|
|
81
|
+
span.set_attribute(GenAIAttributes.CACHE_CREATE_TOKENS, cache_create)
|
|
82
|
+
span.set_status(trace.Status(trace.StatusCode.OK))
|
|
83
|
+
return response
|
|
84
|
+
except TypeError as exc:
|
|
85
|
+
if "api_key" in str(exc) or "auth" in str(exc).lower():
|
|
86
|
+
span.set_status(trace.Status(trace.StatusCode.ERROR, str(exc)))
|
|
87
|
+
import sys
|
|
88
|
+
print(
|
|
89
|
+
"\n\033[1;31mError: Anthropic API key not found.\033[0m\n"
|
|
90
|
+
"\n"
|
|
91
|
+
" Set it in your environment:\n"
|
|
92
|
+
"\n"
|
|
93
|
+
" export ANTHROPIC_API_KEY='sk-ant-...'\n"
|
|
94
|
+
"\n"
|
|
95
|
+
" Or pass it directly:\n"
|
|
96
|
+
"\n"
|
|
97
|
+
" anthropic.Anthropic(api_key='...')\n",
|
|
98
|
+
file=sys.stderr,
|
|
99
|
+
)
|
|
100
|
+
raise SystemExit(1)
|
|
101
|
+
span.set_status(trace.Status(trace.StatusCode.ERROR, str(exc)))
|
|
102
|
+
raise
|
|
103
|
+
except Exception as exc:
|
|
104
|
+
span.set_status(trace.Status(trace.StatusCode.ERROR, str(exc)))
|
|
105
|
+
raise
|
|
106
|
+
finally:
|
|
107
|
+
span.end()
|
|
108
|
+
|
|
109
|
+
Messages.create = patched_create
|
|
110
|
+
|
|
111
|
+
if self._original_stream is not None:
|
|
112
|
+
@functools.wraps(self._original_stream)
|
|
113
|
+
def patched_stream(self_msg, *args, **kwargs):
|
|
114
|
+
from tokenjam.sdk.integrations.litellm import _tj_litellm_active
|
|
115
|
+
if _tj_litellm_active.get(False):
|
|
116
|
+
return integration._original_stream(self_msg, *args, **kwargs)
|
|
117
|
+
span = integration._tracer.start_span(GenAIAttributes.SPAN_LLM_CALL)
|
|
118
|
+
span.set_attribute(GenAIAttributes.PROVIDER_NAME, "anthropic")
|
|
119
|
+
span.set_attribute(
|
|
120
|
+
GenAIAttributes.REQUEST_MODEL,
|
|
121
|
+
kwargs.get("model", "unknown"),
|
|
122
|
+
)
|
|
123
|
+
parent_span = trace.get_current_span()
|
|
124
|
+
if parent_span and parent_span.is_recording():
|
|
125
|
+
agent_id = parent_span.attributes.get(GenAIAttributes.AGENT_ID)
|
|
126
|
+
if agent_id:
|
|
127
|
+
span.set_attribute(GenAIAttributes.AGENT_ID, agent_id)
|
|
128
|
+
conv_id = parent_span.attributes.get(GenAIAttributes.CONVERSATION_ID)
|
|
129
|
+
if conv_id:
|
|
130
|
+
span.set_attribute(GenAIAttributes.CONVERSATION_ID, conv_id)
|
|
131
|
+
try:
|
|
132
|
+
stream = integration._original_stream(self_msg, *args, **kwargs)
|
|
133
|
+
return _StreamWrapper(stream, span)
|
|
134
|
+
except Exception as exc:
|
|
135
|
+
span.set_status(trace.Status(trace.StatusCode.ERROR, str(exc)))
|
|
136
|
+
span.end()
|
|
137
|
+
raise
|
|
138
|
+
|
|
139
|
+
Messages.stream = patched_stream
|
|
140
|
+
|
|
141
|
+
self.installed = True
|
|
142
|
+
logger.debug("Anthropic integration installed")
|
|
143
|
+
|
|
144
|
+
def uninstall(self) -> None:
|
|
145
|
+
if not self.installed:
|
|
146
|
+
return
|
|
147
|
+
try:
|
|
148
|
+
from anthropic.resources import Messages
|
|
149
|
+
if self._original_create:
|
|
150
|
+
Messages.create = self._original_create
|
|
151
|
+
if self._original_stream:
|
|
152
|
+
Messages.stream = self._original_stream
|
|
153
|
+
except ImportError:
|
|
154
|
+
pass
|
|
155
|
+
self.installed = False
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
class _StreamWrapper:
|
|
159
|
+
"""Wraps an Anthropic stream to capture final usage and end the span."""
|
|
160
|
+
|
|
161
|
+
def __init__(self, stream, span):
|
|
162
|
+
self._stream = stream
|
|
163
|
+
self._span = span
|
|
164
|
+
|
|
165
|
+
def __enter__(self):
|
|
166
|
+
self._stream.__enter__()
|
|
167
|
+
return self
|
|
168
|
+
|
|
169
|
+
def __exit__(self, exc_type, exc_val, exc_tb):
|
|
170
|
+
result = self._stream.__exit__(exc_type, exc_val, exc_tb)
|
|
171
|
+
final_message = getattr(self._stream, "get_final_message", lambda: None)()
|
|
172
|
+
if final_message and hasattr(final_message, "usage"):
|
|
173
|
+
self._span.set_attribute(
|
|
174
|
+
GenAIAttributes.INPUT_TOKENS,
|
|
175
|
+
final_message.usage.input_tokens,
|
|
176
|
+
)
|
|
177
|
+
self._span.set_attribute(
|
|
178
|
+
GenAIAttributes.OUTPUT_TOKENS,
|
|
179
|
+
final_message.usage.output_tokens,
|
|
180
|
+
)
|
|
181
|
+
if exc_type is None:
|
|
182
|
+
self._span.set_status(trace.Status(trace.StatusCode.OK))
|
|
183
|
+
else:
|
|
184
|
+
self._span.set_status(trace.Status(trace.StatusCode.ERROR, str(exc_val)))
|
|
185
|
+
self._span.end()
|
|
186
|
+
return result
|
|
187
|
+
|
|
188
|
+
def __iter__(self):
|
|
189
|
+
return iter(self._stream)
|
|
190
|
+
|
|
191
|
+
def __next__(self):
|
|
192
|
+
return next(self._stream)
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def patch_anthropic() -> None:
|
|
196
|
+
"""Convenience function. Instantiates and installs AnthropicIntegration."""
|
|
197
|
+
from tokenjam.sdk.bootstrap import ensure_initialised
|
|
198
|
+
ensure_initialised()
|
|
199
|
+
integration = AnthropicIntegration()
|
|
200
|
+
integration.install(trace.get_tracer("tokenjam.sdk"))
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
"""
|
|
2
|
+
AutoGen framework integration.
|
|
3
|
+
|
|
4
|
+
Patches ConversableAgent.generate_reply and ConversableAgent.initiate_chat
|
|
5
|
+
to create OTel spans.
|
|
6
|
+
"""
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import functools
|
|
10
|
+
import logging
|
|
11
|
+
|
|
12
|
+
from opentelemetry import trace
|
|
13
|
+
|
|
14
|
+
from tokenjam.otel.semconv import GenAIAttributes
|
|
15
|
+
|
|
16
|
+
logger = logging.getLogger(__name__)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class AutoGenIntegration:
|
|
20
|
+
name = "autogen"
|
|
21
|
+
installed = False
|
|
22
|
+
|
|
23
|
+
def __init__(self) -> None:
|
|
24
|
+
self._original_generate_reply = None
|
|
25
|
+
self._original_initiate_chat = None
|
|
26
|
+
self._tracer = None
|
|
27
|
+
|
|
28
|
+
def install(self, tracer) -> None:
|
|
29
|
+
if self.installed:
|
|
30
|
+
return
|
|
31
|
+
self._tracer = tracer
|
|
32
|
+
try:
|
|
33
|
+
from autogen import ConversableAgent
|
|
34
|
+
except ImportError:
|
|
35
|
+
logger.warning("pyautogen not installed — skipping patch")
|
|
36
|
+
return
|
|
37
|
+
|
|
38
|
+
integration = self
|
|
39
|
+
|
|
40
|
+
self._original_generate_reply = ConversableAgent.generate_reply
|
|
41
|
+
@functools.wraps(self._original_generate_reply)
|
|
42
|
+
def patched_generate_reply(self_agent, *args, **kwargs):
|
|
43
|
+
span = integration._tracer.start_span("autogen.generate_reply")
|
|
44
|
+
span.set_attribute(GenAIAttributes.PROVIDER_NAME, "autogen")
|
|
45
|
+
span.set_attribute("autogen.agent_name", getattr(self_agent, "name", "unknown"))
|
|
46
|
+
try:
|
|
47
|
+
result = integration._original_generate_reply(self_agent, *args, **kwargs)
|
|
48
|
+
span.set_status(trace.Status(trace.StatusCode.OK))
|
|
49
|
+
return result
|
|
50
|
+
except Exception as exc:
|
|
51
|
+
span.set_status(trace.Status(trace.StatusCode.ERROR, str(exc)))
|
|
52
|
+
raise
|
|
53
|
+
finally:
|
|
54
|
+
span.end()
|
|
55
|
+
|
|
56
|
+
ConversableAgent.generate_reply = patched_generate_reply
|
|
57
|
+
|
|
58
|
+
self._original_initiate_chat = ConversableAgent.initiate_chat
|
|
59
|
+
@functools.wraps(self._original_initiate_chat)
|
|
60
|
+
def patched_initiate_chat(self_agent, *args, **kwargs):
|
|
61
|
+
span = integration._tracer.start_span("autogen.initiate_chat")
|
|
62
|
+
span.set_attribute(GenAIAttributes.PROVIDER_NAME, "autogen")
|
|
63
|
+
span.set_attribute("autogen.agent_name", getattr(self_agent, "name", "unknown"))
|
|
64
|
+
try:
|
|
65
|
+
result = integration._original_initiate_chat(self_agent, *args, **kwargs)
|
|
66
|
+
span.set_status(trace.Status(trace.StatusCode.OK))
|
|
67
|
+
return result
|
|
68
|
+
except Exception as exc:
|
|
69
|
+
span.set_status(trace.Status(trace.StatusCode.ERROR, str(exc)))
|
|
70
|
+
raise
|
|
71
|
+
finally:
|
|
72
|
+
span.end()
|
|
73
|
+
|
|
74
|
+
ConversableAgent.initiate_chat = patched_initiate_chat
|
|
75
|
+
self.installed = True
|
|
76
|
+
logger.debug("AutoGen integration installed")
|
|
77
|
+
|
|
78
|
+
def uninstall(self) -> None:
|
|
79
|
+
if not self.installed:
|
|
80
|
+
return
|
|
81
|
+
try:
|
|
82
|
+
from autogen import ConversableAgent
|
|
83
|
+
if self._original_generate_reply:
|
|
84
|
+
ConversableAgent.generate_reply = self._original_generate_reply
|
|
85
|
+
if self._original_initiate_chat:
|
|
86
|
+
ConversableAgent.initiate_chat = self._original_initiate_chat
|
|
87
|
+
except ImportError:
|
|
88
|
+
pass
|
|
89
|
+
self.installed = False
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def patch_autogen() -> None:
|
|
93
|
+
"""Convenience function. Instantiates and installs AutoGenIntegration."""
|
|
94
|
+
from tokenjam.sdk.bootstrap import ensure_initialised
|
|
95
|
+
ensure_initialised()
|
|
96
|
+
integration = AutoGenIntegration()
|
|
97
|
+
integration.install(trace.get_tracer("tokenjam.sdk"))
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
from typing import Protocol, runtime_checkable
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
@runtime_checkable
|
|
5
|
+
class Integration(Protocol):
|
|
6
|
+
"""
|
|
7
|
+
Formal interface for all framework and provider integrations.
|
|
8
|
+
|
|
9
|
+
Convenience functions like patch_anthropic() instantiate and install
|
|
10
|
+
the integration automatically. You can also install manually:
|
|
11
|
+
|
|
12
|
+
integration = AnthropicIntegration()
|
|
13
|
+
integration.install(tracer)
|
|
14
|
+
|
|
15
|
+
tj doctor uses `integration.installed` to list active integrations
|
|
16
|
+
and detect conflicts (two integrations patching the same method).
|
|
17
|
+
"""
|
|
18
|
+
name: str
|
|
19
|
+
installed: bool
|
|
20
|
+
|
|
21
|
+
def install(self, tracer) -> None:
|
|
22
|
+
"""Register all hooks. Idempotent — safe to call multiple times."""
|
|
23
|
+
...
|
|
24
|
+
|
|
25
|
+
def uninstall(self) -> None:
|
|
26
|
+
"""Remove all hooks. Called on process shutdown."""
|
|
27
|
+
...
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
"""
|
|
2
|
+
AWS Bedrock provider integration.
|
|
3
|
+
|
|
4
|
+
Wraps boto3 client invoke_model and invoke_agent calls. The Bedrock response
|
|
5
|
+
body is JSON — parsed to extract token counts.
|
|
6
|
+
"""
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import functools
|
|
10
|
+
import json
|
|
11
|
+
import logging
|
|
12
|
+
|
|
13
|
+
from opentelemetry import trace
|
|
14
|
+
|
|
15
|
+
from tokenjam.otel.semconv import GenAIAttributes
|
|
16
|
+
|
|
17
|
+
logger = logging.getLogger(__name__)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class BedrockIntegration:
|
|
21
|
+
name = "aws.bedrock"
|
|
22
|
+
installed = False
|
|
23
|
+
|
|
24
|
+
def __init__(self) -> None:
|
|
25
|
+
self._original_invoke_model = None
|
|
26
|
+
self._tracer = None
|
|
27
|
+
|
|
28
|
+
def install(self, tracer) -> None:
|
|
29
|
+
if self.installed:
|
|
30
|
+
return
|
|
31
|
+
self._tracer = tracer
|
|
32
|
+
try:
|
|
33
|
+
import botocore.client
|
|
34
|
+
except ImportError:
|
|
35
|
+
logger.warning("boto3/botocore not installed — skipping patch")
|
|
36
|
+
return
|
|
37
|
+
|
|
38
|
+
original_api_call = botocore.client.ClientCreator._create_api_method
|
|
39
|
+
integration = self
|
|
40
|
+
|
|
41
|
+
def patched_create_api_method(client_creator, py_operation_name, operation_name, service_model):
|
|
42
|
+
method = original_api_call(client_creator, py_operation_name, operation_name, service_model)
|
|
43
|
+
if py_operation_name not in ("invoke_model", "invoke_agent"):
|
|
44
|
+
return method
|
|
45
|
+
|
|
46
|
+
@functools.wraps(method)
|
|
47
|
+
def wrapped(self_client, *args, **kwargs):
|
|
48
|
+
span = integration._tracer.start_span(GenAIAttributes.SPAN_LLM_CALL)
|
|
49
|
+
span.set_attribute(GenAIAttributes.PROVIDER_NAME, "aws.bedrock")
|
|
50
|
+
model_id = kwargs.get("modelId", "unknown")
|
|
51
|
+
span.set_attribute(GenAIAttributes.REQUEST_MODEL, model_id)
|
|
52
|
+
try:
|
|
53
|
+
response = method(self_client, *args, **kwargs)
|
|
54
|
+
_extract_bedrock_usage(response, span)
|
|
55
|
+
span.set_status(trace.Status(trace.StatusCode.OK))
|
|
56
|
+
return response
|
|
57
|
+
except Exception as exc:
|
|
58
|
+
span.set_status(trace.Status(trace.StatusCode.ERROR, str(exc)))
|
|
59
|
+
raise
|
|
60
|
+
finally:
|
|
61
|
+
span.end()
|
|
62
|
+
|
|
63
|
+
return wrapped
|
|
64
|
+
|
|
65
|
+
botocore.client.ClientCreator._create_api_method = patched_create_api_method
|
|
66
|
+
self.installed = True
|
|
67
|
+
logger.debug("Bedrock integration installed")
|
|
68
|
+
|
|
69
|
+
def uninstall(self) -> None:
|
|
70
|
+
self.installed = False
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _extract_bedrock_usage(response: dict, span) -> None:
|
|
74
|
+
"""Extract token counts from a Bedrock response."""
|
|
75
|
+
body = response.get("body")
|
|
76
|
+
if body is None:
|
|
77
|
+
return
|
|
78
|
+
try:
|
|
79
|
+
if hasattr(body, "read"):
|
|
80
|
+
raw = body.read()
|
|
81
|
+
body_dict = json.loads(raw)
|
|
82
|
+
# Reset stream for caller
|
|
83
|
+
import io
|
|
84
|
+
response["body"] = io.BytesIO(raw)
|
|
85
|
+
else:
|
|
86
|
+
body_dict = json.loads(body) if isinstance(body, (str, bytes)) else body
|
|
87
|
+
except (json.JSONDecodeError, TypeError):
|
|
88
|
+
return
|
|
89
|
+
|
|
90
|
+
usage = body_dict.get("usage", {})
|
|
91
|
+
if usage:
|
|
92
|
+
if "input_tokens" in usage:
|
|
93
|
+
span.set_attribute(GenAIAttributes.INPUT_TOKENS, usage["input_tokens"])
|
|
94
|
+
if "output_tokens" in usage:
|
|
95
|
+
span.set_attribute(GenAIAttributes.OUTPUT_TOKENS, usage["output_tokens"])
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def patch_bedrock() -> None:
|
|
99
|
+
"""Convenience function. Instantiates and installs BedrockIntegration."""
|
|
100
|
+
from tokenjam.sdk.bootstrap import ensure_initialised
|
|
101
|
+
ensure_initialised()
|
|
102
|
+
integration = BedrockIntegration()
|
|
103
|
+
integration.install(trace.get_tracer("tokenjam.sdk"))
|