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,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)
|
tokenjam/cli/cmd_mcp.py
ADDED
|
@@ -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()
|