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,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"))