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,200 @@
1
+ from __future__ import annotations
2
+
3
+ import csv
4
+ import io
5
+ import json
6
+ from pathlib import Path
7
+
8
+ import click
9
+
10
+ from tokenjam.core.models import TraceFilters
11
+ from tokenjam.utils.formatting import console
12
+ from tokenjam.utils.time_parse import parse_since
13
+
14
+
15
+ @click.command("export")
16
+ @click.option("--agent", default=None, help="Filter to specific agent_id")
17
+ @click.option("--since", default="7d", help="Time window (e.g. 1h, 7d)")
18
+ @click.option("--format", "fmt",
19
+ type=click.Choice(["json", "csv", "otlp", "openevals"]),
20
+ default="json")
21
+ @click.option("--output", "output_path", default=None, help="Output file path (stdout if omitted)")
22
+ @click.pass_context
23
+ def cmd_export(ctx: click.Context, agent: str | None, since: str,
24
+ fmt: str, output_path: str | None) -> None:
25
+ """Export spans in various formats."""
26
+ db = ctx.obj["db"]
27
+ agent_filter = agent or ctx.obj.get("agent")
28
+ filters = TraceFilters(
29
+ agent_id=agent_filter,
30
+ since=parse_since(since),
31
+ limit=10000,
32
+ )
33
+ traces = db.get_traces(filters)
34
+
35
+ if fmt == "json":
36
+ output = _export_json(db, traces)
37
+ elif fmt == "csv":
38
+ output = _export_csv(db, traces)
39
+ elif fmt == "otlp":
40
+ _export_otlp(ctx, db, traces)
41
+ return
42
+ elif fmt == "openevals":
43
+ output = _export_openevals(db, traces)
44
+ else:
45
+ console.print(f"[red]Unknown format: {fmt}[/red]")
46
+ return
47
+
48
+ if output_path:
49
+ Path(output_path).write_text(output)
50
+ console.print(f"[green]Exported to {output_path}[/green]")
51
+ else:
52
+ click.echo(output)
53
+
54
+
55
+ def _export_json(db: object, traces: list) -> str:
56
+ lines = []
57
+ seen_traces: set[str] = set()
58
+ for t in traces:
59
+ if t.trace_id in seen_traces:
60
+ continue
61
+ seen_traces.add(t.trace_id)
62
+ spans = db.get_trace_spans(t.trace_id)
63
+ for s in spans:
64
+ lines.append(json.dumps({
65
+ "span_id": s.span_id,
66
+ "trace_id": s.trace_id,
67
+ "agent_id": s.agent_id,
68
+ "name": s.name,
69
+ "start_time": s.start_time.isoformat() if s.start_time else None,
70
+ "end_time": s.end_time.isoformat() if s.end_time else None,
71
+ "duration_ms": s.duration_ms,
72
+ "cost_usd": s.cost_usd,
73
+ "input_tokens": s.input_tokens,
74
+ "output_tokens": s.output_tokens,
75
+ "status_code": s.status_code.value,
76
+ "provider": s.provider,
77
+ "model": s.model,
78
+ "tool_name": s.tool_name,
79
+ "attributes": s.attributes,
80
+ }, default=str))
81
+ return "\n".join(lines)
82
+
83
+
84
+ def _export_csv(db: object, traces: list) -> str:
85
+ buf = io.StringIO()
86
+ writer = csv.writer(buf)
87
+ writer.writerow([
88
+ "span_id", "trace_id", "agent_id", "name", "start_time",
89
+ "duration_ms", "cost_usd", "input_tokens", "output_tokens", "status_code",
90
+ ])
91
+ seen_traces: set[str] = set()
92
+ for t in traces:
93
+ if t.trace_id in seen_traces:
94
+ continue
95
+ seen_traces.add(t.trace_id)
96
+ spans = db.get_trace_spans(t.trace_id)
97
+ for s in spans:
98
+ writer.writerow([
99
+ s.span_id, s.trace_id, s.agent_id, s.name,
100
+ s.start_time.isoformat() if s.start_time else "",
101
+ s.duration_ms, s.cost_usd,
102
+ s.input_tokens, s.output_tokens, s.status_code.value,
103
+ ])
104
+ return buf.getvalue()
105
+
106
+
107
+ _KIND_MAP = {"internal": 1, "server": 2, "client": 3, "producer": 4, "consumer": 5}
108
+
109
+
110
+ def _export_otlp(ctx: click.Context, db: object, traces: list) -> None:
111
+ config = ctx.obj["config"]
112
+ if not config.export.otlp.enabled:
113
+ console.print("[red]OTLP export is not enabled. Set export.otlp.enabled = true "
114
+ "in config.[/red]")
115
+ return
116
+
117
+ import httpx
118
+ endpoint = config.export.otlp.endpoint.rstrip("/") + "/v1/traces"
119
+ headers = dict(config.export.otlp.headers)
120
+ headers.setdefault("Content-Type", "application/json")
121
+
122
+ seen_traces: set[str] = set()
123
+ succeeded = 0
124
+ failed = 0
125
+ for t in traces:
126
+ if t.trace_id in seen_traces:
127
+ continue
128
+ seen_traces.add(t.trace_id)
129
+ spans = db.get_trace_spans(t.trace_id)
130
+ payload = {
131
+ "resourceSpans": [{
132
+ "resource": {"attributes": [
133
+ {"key": "service.name", "value": {"stringValue": "tokenjam"}},
134
+ ]},
135
+ "scopeSpans": [{
136
+ "spans": [
137
+ {
138
+ "traceId": s.trace_id,
139
+ "spanId": s.span_id,
140
+ "name": s.name,
141
+ "kind": _KIND_MAP.get(s.kind.value, 1) if s.kind else 1,
142
+ "startTimeUnixNano": str(int(s.start_time.timestamp() * 1e9))
143
+ if s.start_time else "0",
144
+ "endTimeUnixNano": str(int(s.end_time.timestamp() * 1e9))
145
+ if s.end_time else "0",
146
+ }
147
+ for s in spans
148
+ ],
149
+ }],
150
+ }],
151
+ }
152
+ resp = httpx.post(endpoint, json=payload, headers=headers)
153
+ if resp.status_code >= 400:
154
+ console.print(f"[red]OTLP export failed for trace {t.trace_id}: "
155
+ f"{resp.status_code}[/red]")
156
+ failed += 1
157
+ else:
158
+ succeeded += 1
159
+
160
+ console.print(f"[green]Exported {succeeded} traces to {endpoint}[/green]"
161
+ + (f" ({failed} failed)" if failed else ""))
162
+
163
+
164
+ def _export_openevals(db: object, traces: list) -> str:
165
+ results = []
166
+ seen_traces: set[str] = set()
167
+ for t in traces:
168
+ if t.trace_id in seen_traces:
169
+ continue
170
+ seen_traces.add(t.trace_id)
171
+ spans = db.get_trace_spans(t.trace_id)
172
+ messages = []
173
+ for s in spans:
174
+ if s.attributes.get("gen_ai.prompt.content"):
175
+ messages.append({
176
+ "role": "user",
177
+ "content": s.attributes["gen_ai.prompt.content"],
178
+ })
179
+ if s.tool_name:
180
+ messages.append({
181
+ "role": "assistant",
182
+ "content": "",
183
+ "tool_calls": [{"name": s.tool_name}],
184
+ })
185
+ if s.attributes.get("gen_ai.tool.output"):
186
+ messages.append({
187
+ "role": "tool",
188
+ "content": s.attributes["gen_ai.tool.output"],
189
+ })
190
+ if s.attributes.get("gen_ai.completion.content"):
191
+ messages.append({
192
+ "role": "assistant",
193
+ "content": s.attributes["gen_ai.completion.content"],
194
+ })
195
+ results.append({
196
+ "trace_id": t.trace_id,
197
+ "agent_id": t.agent_id,
198
+ "messages": messages,
199
+ })
200
+ return json.dumps(results, default=str, indent=2)
@@ -0,0 +1,78 @@
1
+ """tj mcp — start the stdio MCP server."""
2
+ from __future__ import annotations
3
+
4
+ import shutil
5
+ import socket
6
+ import subprocess
7
+ import sys
8
+ import time
9
+ from pathlib import Path
10
+
11
+ import click
12
+ import duckdb
13
+
14
+ from tokenjam.core.config import find_config_file, load_config
15
+
16
+
17
+ def _port_open(host: str, port: int) -> bool:
18
+ """Return True if something is listening on host:port."""
19
+ with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
20
+ s.settimeout(0.5)
21
+ try:
22
+ s.connect((host, port))
23
+ return True
24
+ except (ConnectionRefusedError, OSError):
25
+ return False
26
+
27
+
28
+ def _start_and_wait(host: str, port: int, timeout: float = 10.0) -> bool:
29
+ """Start tj serve in the background and wait up to *timeout* seconds for it
30
+ to accept connections. Returns True if the server is ready in time."""
31
+ ocw_bin = shutil.which("tj") or sys.argv[0]
32
+ popen_kwargs: dict = {
33
+ "stdout": subprocess.DEVNULL,
34
+ "stderr": subprocess.DEVNULL,
35
+ }
36
+ if sys.platform == "win32":
37
+ popen_kwargs["creationflags"] = subprocess.DETACHED_PROCESS
38
+ else:
39
+ popen_kwargs["start_new_session"] = True
40
+
41
+ try:
42
+ subprocess.Popen([ocw_bin, "serve"], **popen_kwargs)
43
+ except (FileNotFoundError, OSError):
44
+ return False
45
+
46
+ deadline = time.monotonic() + timeout
47
+ while time.monotonic() < deadline:
48
+ time.sleep(0.25)
49
+ if _port_open(host, port):
50
+ return True
51
+ return False
52
+
53
+
54
+ @click.command("mcp")
55
+ @click.pass_context
56
+ def cmd_mcp(ctx: click.Context) -> None:
57
+ """Start the OCW MCP server (stdio transport for Claude Code)."""
58
+ from tokenjam.mcp.server import mcp, init
59
+
60
+ config_path = find_config_file()
61
+ if config_path is not None:
62
+ config = load_config(str(config_path))
63
+ host = config.api.host
64
+ port = config.api.port
65
+
66
+ if _port_open(host, port) or _start_and_wait(host, port):
67
+ # tj serve is running (already up or we just started it)
68
+ serve_url = f"http://{host}:{port}"
69
+ init(ro_conn=None, config=config, serve_url=serve_url)
70
+ else:
71
+ # Could not reach or start tj serve — fall back to read-only DuckDB
72
+ # so MCP read tools still work, though live ingest won't be available.
73
+ db_path = str(Path(config.storage.path).expanduser())
74
+ ro_conn = duckdb.connect(db_path, read_only=True)
75
+ init(ro_conn=ro_conn, config=config, serve_url=None)
76
+ # If no config: init is not called; tools return the no-config sentinel.
77
+
78
+ mcp.run()