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,202 @@
|
|
|
1
|
+
"""POST /api/v1/spans — OTLP JSON span ingest endpoint."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import logging
|
|
5
|
+
from datetime import datetime, timezone
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
from fastapi import APIRouter, Request
|
|
9
|
+
from fastapi.responses import JSONResponse
|
|
10
|
+
|
|
11
|
+
from tokenjam.core.ingest import SpanRejectedError
|
|
12
|
+
from tokenjam.core.models import NormalizedSpan, SpanKind, SpanStatus
|
|
13
|
+
from tokenjam.otel.semconv import GenAIAttributes, TjAttributes
|
|
14
|
+
from tokenjam.utils.ids import new_span_id
|
|
15
|
+
|
|
16
|
+
logger = logging.getLogger(__name__)
|
|
17
|
+
|
|
18
|
+
router = APIRouter()
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@router.post("/spans")
|
|
22
|
+
async def ingest_spans(request: Request) -> JSONResponse:
|
|
23
|
+
"""
|
|
24
|
+
Accept a batch of spans in OTLP JSON format.
|
|
25
|
+
Auth is enforced by IngestAuthMiddleware.
|
|
26
|
+
Returns 200 even on partial rejection; 400 only if body is entirely malformed.
|
|
27
|
+
"""
|
|
28
|
+
try:
|
|
29
|
+
body = await request.json()
|
|
30
|
+
except Exception:
|
|
31
|
+
return JSONResponse(
|
|
32
|
+
status_code=400,
|
|
33
|
+
content={"error": "Invalid JSON body"},
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
if not isinstance(body, dict) or "resourceSpans" not in body:
|
|
37
|
+
return JSONResponse(
|
|
38
|
+
status_code=400,
|
|
39
|
+
content={"error": "Expected OTLP JSON with 'resourceSpans' key"},
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
pipeline = request.app.state.pipeline
|
|
43
|
+
ingested = 0
|
|
44
|
+
rejections: list[dict[str, str]] = []
|
|
45
|
+
|
|
46
|
+
for resource_span in body.get("resourceSpans", []):
|
|
47
|
+
resource_attrs = _extract_resource_attrs(resource_span)
|
|
48
|
+
for scope_span in resource_span.get("scopeSpans", []):
|
|
49
|
+
for raw_span in scope_span.get("spans", []):
|
|
50
|
+
span_id = raw_span.get("spanId", new_span_id())
|
|
51
|
+
try:
|
|
52
|
+
span = _parse_span(raw_span, resource_attrs)
|
|
53
|
+
pipeline.process(span)
|
|
54
|
+
ingested += 1
|
|
55
|
+
except SpanRejectedError as exc:
|
|
56
|
+
rejections.append({"span_id": span_id, "reason": str(exc)})
|
|
57
|
+
except Exception as exc:
|
|
58
|
+
logger.warning("Failed to process span %s: %s", span_id, exc)
|
|
59
|
+
rejections.append({"span_id": span_id, "reason": str(exc)})
|
|
60
|
+
|
|
61
|
+
return JSONResponse(
|
|
62
|
+
status_code=200,
|
|
63
|
+
content={
|
|
64
|
+
"ingested": ingested,
|
|
65
|
+
"rejected": len(rejections),
|
|
66
|
+
"rejections": rejections,
|
|
67
|
+
},
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _extract_resource_attrs(resource_span: dict) -> dict[str, Any]:
|
|
72
|
+
"""Pull flat attributes from the resource section."""
|
|
73
|
+
resource = resource_span.get("resource", {})
|
|
74
|
+
attrs: dict[str, Any] = {}
|
|
75
|
+
for attr in resource.get("attributes", []):
|
|
76
|
+
key = attr.get("key", "")
|
|
77
|
+
value = _otlp_value(attr.get("value", {}))
|
|
78
|
+
if key and value is not None:
|
|
79
|
+
attrs[key] = value
|
|
80
|
+
return attrs
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def _parse_span(raw: dict, resource_attrs: dict[str, Any]) -> NormalizedSpan:
|
|
84
|
+
"""Convert an OTLP JSON span dict into a NormalizedSpan."""
|
|
85
|
+
# Merge resource + span attributes
|
|
86
|
+
attrs: dict[str, Any] = dict(resource_attrs)
|
|
87
|
+
for attr in raw.get("attributes", []):
|
|
88
|
+
key = attr.get("key", "")
|
|
89
|
+
value = _otlp_value(attr.get("value", {}))
|
|
90
|
+
if key and value is not None:
|
|
91
|
+
attrs[key] = value
|
|
92
|
+
|
|
93
|
+
# Parse timestamps (OTLP uses nanoseconds as strings)
|
|
94
|
+
start_ns = int(raw.get("startTimeUnixNano", 0))
|
|
95
|
+
end_ns = int(raw.get("endTimeUnixNano", 0))
|
|
96
|
+
start_time = datetime.fromtimestamp(start_ns / 1e9, tz=timezone.utc) if start_ns else datetime.now(tz=timezone.utc)
|
|
97
|
+
end_time = datetime.fromtimestamp(end_ns / 1e9, tz=timezone.utc) if end_ns else None
|
|
98
|
+
|
|
99
|
+
duration_ms = None
|
|
100
|
+
if start_ns and end_ns:
|
|
101
|
+
duration_ms = (end_ns - start_ns) / 1e6
|
|
102
|
+
|
|
103
|
+
# Parse status
|
|
104
|
+
status_raw = raw.get("status", {})
|
|
105
|
+
status_code_int = status_raw.get("code", 0)
|
|
106
|
+
status_map = {0: SpanStatus.UNSET, 1: SpanStatus.OK, 2: SpanStatus.ERROR}
|
|
107
|
+
status_code = status_map.get(status_code_int, SpanStatus.UNSET)
|
|
108
|
+
|
|
109
|
+
# Parse kind
|
|
110
|
+
kind_int = raw.get("kind", 1)
|
|
111
|
+
kind_map = {
|
|
112
|
+
1: SpanKind.INTERNAL, 2: SpanKind.SERVER, 3: SpanKind.CLIENT,
|
|
113
|
+
4: SpanKind.PRODUCER, 5: SpanKind.CONSUMER,
|
|
114
|
+
}
|
|
115
|
+
kind = kind_map.get(kind_int, SpanKind.INTERNAL)
|
|
116
|
+
|
|
117
|
+
# --- OpenClaw / generic OTLP attribute enrichment ---
|
|
118
|
+
span_name = raw.get("name", "")
|
|
119
|
+
agent_id = attrs.get(GenAIAttributes.AGENT_ID)
|
|
120
|
+
tool_name = attrs.get(GenAIAttributes.TOOL_NAME)
|
|
121
|
+
provider = attrs.get(GenAIAttributes.PROVIDER_NAME)
|
|
122
|
+
|
|
123
|
+
# Fall back to service.name for agent_id (OpenClaw sets service.name)
|
|
124
|
+
if not agent_id:
|
|
125
|
+
agent_id = attrs.get("service.name") or None
|
|
126
|
+
|
|
127
|
+
# Fall back to gen_ai.system for provider (OpenClaw uses this)
|
|
128
|
+
if not provider:
|
|
129
|
+
provider = attrs.get("gen_ai.system") or None
|
|
130
|
+
|
|
131
|
+
# Extract tool_name from span names like "tool.Read", "tool.exec"
|
|
132
|
+
if not tool_name and span_name.startswith("tool."):
|
|
133
|
+
tool_name = span_name[5:] # strip "tool." prefix
|
|
134
|
+
|
|
135
|
+
return NormalizedSpan(
|
|
136
|
+
span_id=raw.get("spanId", new_span_id()),
|
|
137
|
+
trace_id=raw.get("traceId", ""),
|
|
138
|
+
name=span_name,
|
|
139
|
+
kind=kind,
|
|
140
|
+
status_code=status_code,
|
|
141
|
+
status_message=status_raw.get("message"),
|
|
142
|
+
start_time=start_time,
|
|
143
|
+
end_time=end_time,
|
|
144
|
+
duration_ms=duration_ms,
|
|
145
|
+
parent_span_id=raw.get("parentSpanId"),
|
|
146
|
+
attributes=attrs,
|
|
147
|
+
events=[
|
|
148
|
+
{"name": e.get("name", ""), "time": e.get("timeUnixNano"),
|
|
149
|
+
"attributes": {a.get("key", ""): _otlp_value(a.get("value", {})) for a in e.get("attributes", [])}}
|
|
150
|
+
for e in raw.get("events", [])
|
|
151
|
+
],
|
|
152
|
+
# Extract indexed fields from merged attributes
|
|
153
|
+
agent_id=agent_id,
|
|
154
|
+
provider=provider,
|
|
155
|
+
model=attrs.get(GenAIAttributes.REQUEST_MODEL),
|
|
156
|
+
tool_name=tool_name,
|
|
157
|
+
input_tokens=_safe_int(attrs.get(GenAIAttributes.INPUT_TOKENS)),
|
|
158
|
+
output_tokens=_safe_int(attrs.get(GenAIAttributes.OUTPUT_TOKENS)),
|
|
159
|
+
cache_tokens=_safe_int(attrs.get(GenAIAttributes.CACHE_READ_TOKENS)),
|
|
160
|
+
cost_usd=_safe_float(attrs.get(TjAttributes.COST_USD)),
|
|
161
|
+
request_type=attrs.get(GenAIAttributes.REQUEST_TYPE),
|
|
162
|
+
conversation_id=attrs.get(GenAIAttributes.CONVERSATION_ID),
|
|
163
|
+
session_id=attrs.get("session.id"),
|
|
164
|
+
)
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def _otlp_value(v: dict) -> Any:
|
|
168
|
+
"""Extract a value from an OTLP AttributeValue wrapper."""
|
|
169
|
+
if "stringValue" in v:
|
|
170
|
+
return v["stringValue"]
|
|
171
|
+
if "intValue" in v:
|
|
172
|
+
return int(v["intValue"])
|
|
173
|
+
if "doubleValue" in v:
|
|
174
|
+
return float(v["doubleValue"])
|
|
175
|
+
if "boolValue" in v:
|
|
176
|
+
return v["boolValue"]
|
|
177
|
+
if "arrayValue" in v:
|
|
178
|
+
return [_otlp_value(item) for item in v["arrayValue"].get("values", [])]
|
|
179
|
+
if "kvlistValue" in v:
|
|
180
|
+
return {
|
|
181
|
+
kv["key"]: _otlp_value(kv["value"])
|
|
182
|
+
for kv in v["kvlistValue"].get("values", [])
|
|
183
|
+
}
|
|
184
|
+
return None
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def _safe_int(v: Any) -> int | None:
|
|
188
|
+
if v is None:
|
|
189
|
+
return None
|
|
190
|
+
try:
|
|
191
|
+
return int(v)
|
|
192
|
+
except (TypeError, ValueError):
|
|
193
|
+
return None
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def _safe_float(v: Any) -> float | None:
|
|
197
|
+
if v is None:
|
|
198
|
+
return None
|
|
199
|
+
try:
|
|
200
|
+
return float(v)
|
|
201
|
+
except (TypeError, ValueError):
|
|
202
|
+
return None
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
"""GET /api/v1/status — agent status overview."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from fastapi import APIRouter, Depends, Request
|
|
5
|
+
|
|
6
|
+
from tokenjam.api.deps import require_api_key
|
|
7
|
+
from tokenjam.core.db import _row_to_session
|
|
8
|
+
from tokenjam.core.models import AlertFilters
|
|
9
|
+
from tokenjam.utils.time_parse import utcnow
|
|
10
|
+
|
|
11
|
+
router = APIRouter(dependencies=[Depends(require_api_key)])
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@router.get("/status")
|
|
15
|
+
async def get_status(
|
|
16
|
+
request: Request,
|
|
17
|
+
agent_id: str | None = None,
|
|
18
|
+
) -> dict:
|
|
19
|
+
db = request.app.state.db
|
|
20
|
+
|
|
21
|
+
# Discover agent IDs
|
|
22
|
+
if agent_id:
|
|
23
|
+
agent_ids = [agent_id]
|
|
24
|
+
elif hasattr(db, "conn"):
|
|
25
|
+
rows = db.conn.execute(
|
|
26
|
+
"SELECT DISTINCT agent_id FROM sessions WHERE agent_id IS NOT NULL "
|
|
27
|
+
"UNION "
|
|
28
|
+
"SELECT DISTINCT agent_id FROM spans WHERE agent_id IS NOT NULL "
|
|
29
|
+
"ORDER BY agent_id"
|
|
30
|
+
).fetchall()
|
|
31
|
+
agent_ids = [r[0] for r in rows]
|
|
32
|
+
else:
|
|
33
|
+
agent_ids = []
|
|
34
|
+
|
|
35
|
+
has_active_alerts = False
|
|
36
|
+
agents_data = []
|
|
37
|
+
|
|
38
|
+
for aid in agent_ids:
|
|
39
|
+
session = None
|
|
40
|
+
|
|
41
|
+
# Check for active session first, then fall back to latest completed
|
|
42
|
+
if hasattr(db, "conn"):
|
|
43
|
+
active_rows = db.conn.execute(
|
|
44
|
+
"SELECT * FROM sessions WHERE agent_id = $1 AND status = 'active' "
|
|
45
|
+
"ORDER BY started_at DESC LIMIT 1",
|
|
46
|
+
[aid],
|
|
47
|
+
).fetchall()
|
|
48
|
+
if active_rows:
|
|
49
|
+
cols = [d[0] for d in db.conn.description]
|
|
50
|
+
session = _row_to_session(active_rows[0], cols)
|
|
51
|
+
|
|
52
|
+
if session is None:
|
|
53
|
+
sessions = db.get_completed_sessions(aid, limit=1)
|
|
54
|
+
if sessions:
|
|
55
|
+
session = sessions[0]
|
|
56
|
+
|
|
57
|
+
today_cost = db.get_daily_cost(aid, utcnow().date())
|
|
58
|
+
|
|
59
|
+
# Active (unacknowledged, unsuppressed) alerts
|
|
60
|
+
alerts = db.get_alerts(AlertFilters(agent_id=aid, unread=True, limit=50))
|
|
61
|
+
active_alerts = [a for a in alerts if not a.acknowledged and not a.suppressed]
|
|
62
|
+
if active_alerts:
|
|
63
|
+
has_active_alerts = True
|
|
64
|
+
|
|
65
|
+
agent_data = {
|
|
66
|
+
"agent_id": aid,
|
|
67
|
+
"status": session.status if session else "idle",
|
|
68
|
+
"session_id": session.session_id if session else None,
|
|
69
|
+
"cost_today": today_cost,
|
|
70
|
+
"input_tokens": session.input_tokens if session else 0,
|
|
71
|
+
"output_tokens": session.output_tokens if session else 0,
|
|
72
|
+
"tool_call_count": session.tool_call_count if session else 0,
|
|
73
|
+
"error_count": session.error_count if session else 0,
|
|
74
|
+
"active_alerts": len(active_alerts),
|
|
75
|
+
"duration_seconds": session.duration_seconds if session else None,
|
|
76
|
+
"started_at": session.started_at.isoformat() if session and session.started_at else None,
|
|
77
|
+
"total_cost_usd": float(session.total_cost_usd) if session and session.total_cost_usd is not None else 0.0,
|
|
78
|
+
}
|
|
79
|
+
agents_data.append(agent_data)
|
|
80
|
+
|
|
81
|
+
return {
|
|
82
|
+
"agents": agents_data,
|
|
83
|
+
"has_active_alerts": has_active_alerts,
|
|
84
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
"""GET /api/v1/tools — tool call records with aggregated stats."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from fastapi import APIRouter, Depends, Request
|
|
5
|
+
|
|
6
|
+
from tokenjam.api.deps import require_api_key
|
|
7
|
+
from tokenjam.utils.time_parse import parse_since
|
|
8
|
+
|
|
9
|
+
router = APIRouter(dependencies=[Depends(require_api_key)])
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@router.get("/tools")
|
|
13
|
+
async def get_tools(
|
|
14
|
+
request: Request,
|
|
15
|
+
agent_id: str | None = None,
|
|
16
|
+
since: str | None = None,
|
|
17
|
+
tool_name: str | None = None,
|
|
18
|
+
) -> dict:
|
|
19
|
+
db = request.app.state.db
|
|
20
|
+
since_dt = parse_since(since) if since else None
|
|
21
|
+
rows = db.get_tool_calls(agent_id, since_dt, tool_name)
|
|
22
|
+
return {"tools": rows, "count": len(rows)}
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
"""GET /api/v1/traces — trace listing and detail."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
from fastapi import APIRouter, Depends, Request
|
|
6
|
+
|
|
7
|
+
from tokenjam.api.deps import require_api_key
|
|
8
|
+
from tokenjam.core.models import TraceFilters
|
|
9
|
+
from tokenjam.utils.time_parse import parse_since
|
|
10
|
+
|
|
11
|
+
router = APIRouter(dependencies=[Depends(require_api_key)])
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@router.get("/traces")
|
|
15
|
+
async def list_traces(
|
|
16
|
+
request: Request,
|
|
17
|
+
agent_id: str | None = None,
|
|
18
|
+
since: str | None = None,
|
|
19
|
+
until: str | None = None,
|
|
20
|
+
limit: int = 50,
|
|
21
|
+
offset: int = 0,
|
|
22
|
+
status: str | None = None,
|
|
23
|
+
span_name: str | None = None,
|
|
24
|
+
) -> dict:
|
|
25
|
+
db = request.app.state.db
|
|
26
|
+
filters = TraceFilters(
|
|
27
|
+
agent_id=agent_id,
|
|
28
|
+
since=parse_since(since) if since else None,
|
|
29
|
+
until=parse_since(until) if until else None,
|
|
30
|
+
limit=limit,
|
|
31
|
+
offset=offset,
|
|
32
|
+
status=status,
|
|
33
|
+
span_name=span_name,
|
|
34
|
+
)
|
|
35
|
+
traces = db.get_traces(filters)
|
|
36
|
+
return {
|
|
37
|
+
"traces": [
|
|
38
|
+
{
|
|
39
|
+
"trace_id": t.trace_id,
|
|
40
|
+
"agent_id": t.agent_id,
|
|
41
|
+
"name": t.name,
|
|
42
|
+
"start_time": t.start_time.isoformat() if t.start_time else None,
|
|
43
|
+
"duration_ms": t.duration_ms,
|
|
44
|
+
"cost_usd": t.cost_usd,
|
|
45
|
+
"status_code": t.status_code,
|
|
46
|
+
"span_count": t.span_count,
|
|
47
|
+
}
|
|
48
|
+
for t in traces
|
|
49
|
+
],
|
|
50
|
+
"count": len(traces),
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
@router.get("/traces/{trace_id}")
|
|
55
|
+
async def get_trace(request: Request, trace_id: str) -> dict:
|
|
56
|
+
db = request.app.state.db
|
|
57
|
+
spans = db.get_trace_spans(trace_id)
|
|
58
|
+
return {
|
|
59
|
+
"trace_id": trace_id,
|
|
60
|
+
"spans": [_span_to_dict(s) for s in spans],
|
|
61
|
+
"span_count": len(spans),
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _span_to_dict(span: object) -> dict:
|
|
66
|
+
"""Serialise a NormalizedSpan to a JSON-safe dict."""
|
|
67
|
+
from tokenjam.core.models import NormalizedSpan
|
|
68
|
+
assert isinstance(span, NormalizedSpan)
|
|
69
|
+
return {
|
|
70
|
+
"span_id": span.span_id,
|
|
71
|
+
"trace_id": span.trace_id,
|
|
72
|
+
"parent_span_id": span.parent_span_id,
|
|
73
|
+
"name": span.name,
|
|
74
|
+
"kind": span.kind.value,
|
|
75
|
+
"status_code": span.status_code.value,
|
|
76
|
+
"status_message": span.status_message,
|
|
77
|
+
"start_time": span.start_time.isoformat() if span.start_time else None,
|
|
78
|
+
"end_time": span.end_time.isoformat() if span.end_time else None,
|
|
79
|
+
"duration_ms": span.duration_ms,
|
|
80
|
+
"agent_id": span.agent_id,
|
|
81
|
+
"session_id": span.session_id,
|
|
82
|
+
"provider": span.provider,
|
|
83
|
+
"model": span.model,
|
|
84
|
+
"tool_name": span.tool_name,
|
|
85
|
+
"input_tokens": span.input_tokens,
|
|
86
|
+
"output_tokens": span.output_tokens,
|
|
87
|
+
"cache_tokens": span.cache_tokens,
|
|
88
|
+
"cost_usd": span.cost_usd,
|
|
89
|
+
"request_type": span.request_type,
|
|
90
|
+
"conversation_id": span.conversation_id,
|
|
91
|
+
"attributes": span.attributes,
|
|
92
|
+
}
|
tokenjam/cli/__init__.py
ADDED
|
File without changes
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import json
|
|
2
|
+
|
|
3
|
+
import click
|
|
4
|
+
|
|
5
|
+
from tokenjam.core.models import AlertFilters, AlertType, Severity
|
|
6
|
+
from tokenjam.utils.formatting import console, make_table, severity_colour
|
|
7
|
+
from tokenjam.utils.time_parse import parse_since
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
@click.command("alerts")
|
|
11
|
+
@click.option("--agent", default=None, help="Filter to specific agent_id")
|
|
12
|
+
@click.option("--since", default="24h", help="Time window (e.g. 1h, 24h, 7d)")
|
|
13
|
+
@click.option(
|
|
14
|
+
"--severity",
|
|
15
|
+
type=click.Choice(["critical", "warning", "info"]),
|
|
16
|
+
default=None,
|
|
17
|
+
help="Filter by minimum severity",
|
|
18
|
+
)
|
|
19
|
+
@click.option("--type", "alert_type", default=None, help="Filter by alert type")
|
|
20
|
+
@click.option("--unread", is_flag=True, help="Show only unacknowledged alerts")
|
|
21
|
+
@click.option("--json", "output_json", is_flag=True, help="Output as JSON")
|
|
22
|
+
@click.pass_context
|
|
23
|
+
def cmd_alerts(
|
|
24
|
+
ctx: click.Context,
|
|
25
|
+
agent: str | None,
|
|
26
|
+
since: str,
|
|
27
|
+
severity: str | None,
|
|
28
|
+
alert_type: str | None,
|
|
29
|
+
unread: bool,
|
|
30
|
+
output_json: bool,
|
|
31
|
+
) -> None:
|
|
32
|
+
"""Show alert history."""
|
|
33
|
+
db = ctx.obj["db"]
|
|
34
|
+
try:
|
|
35
|
+
since_dt = parse_since(since)
|
|
36
|
+
except ValueError as exc:
|
|
37
|
+
raise click.BadParameter(str(exc), param_hint="'--since'") from exc
|
|
38
|
+
filters = AlertFilters(
|
|
39
|
+
agent_id=agent,
|
|
40
|
+
since=since_dt,
|
|
41
|
+
severity=Severity(severity) if severity else None,
|
|
42
|
+
type=AlertType(alert_type) if alert_type else None,
|
|
43
|
+
unread=unread,
|
|
44
|
+
)
|
|
45
|
+
alerts = db.get_alerts(filters)
|
|
46
|
+
|
|
47
|
+
if output_json:
|
|
48
|
+
click.echo(json.dumps(
|
|
49
|
+
[
|
|
50
|
+
{
|
|
51
|
+
"alert_id": a.alert_id,
|
|
52
|
+
"fired_at": a.fired_at.isoformat(),
|
|
53
|
+
"type": a.type.value,
|
|
54
|
+
"severity": a.severity.value,
|
|
55
|
+
"title": a.title,
|
|
56
|
+
"detail": a.detail,
|
|
57
|
+
"agent_id": a.agent_id,
|
|
58
|
+
"session_id": a.session_id,
|
|
59
|
+
"span_id": a.span_id,
|
|
60
|
+
"acknowledged": a.acknowledged,
|
|
61
|
+
"suppressed": a.suppressed,
|
|
62
|
+
}
|
|
63
|
+
for a in alerts
|
|
64
|
+
],
|
|
65
|
+
default=str,
|
|
66
|
+
))
|
|
67
|
+
return
|
|
68
|
+
|
|
69
|
+
if not alerts:
|
|
70
|
+
console.print("[dim]No alerts found for the given filters.[/dim]")
|
|
71
|
+
return
|
|
72
|
+
|
|
73
|
+
critical_count = sum(1 for a in alerts if a.severity == Severity.CRITICAL)
|
|
74
|
+
warning_count = sum(1 for a in alerts if a.severity == Severity.WARNING)
|
|
75
|
+
console.print(
|
|
76
|
+
f"[bold]Alerts \u2014 last {since}[/bold] "
|
|
77
|
+
f"({len(alerts)} total: {critical_count} critical, {warning_count} warning)"
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
table = make_table("TIME", "SEVERITY", "TYPE", "AGENT", "DETAIL")
|
|
81
|
+
for a in alerts:
|
|
82
|
+
time_str = a.fired_at.strftime("%H:%M:%S")
|
|
83
|
+
colour = severity_colour(a.severity.value)
|
|
84
|
+
detail_msg = a.detail.get("message", a.title)
|
|
85
|
+
if len(detail_msg) > 60:
|
|
86
|
+
detail_msg = detail_msg[:57] + "..."
|
|
87
|
+
table.add_row(
|
|
88
|
+
time_str,
|
|
89
|
+
f"[{colour}]{a.severity.value.upper()}[/]",
|
|
90
|
+
a.type.value,
|
|
91
|
+
a.agent_id or "-",
|
|
92
|
+
detail_msg,
|
|
93
|
+
)
|
|
94
|
+
console.print(table)
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
import click
|
|
6
|
+
|
|
7
|
+
from tokenjam.core.config import (
|
|
8
|
+
AgentConfig,
|
|
9
|
+
find_config_file,
|
|
10
|
+
resolve_effective_budget,
|
|
11
|
+
validate_budget_value,
|
|
12
|
+
write_config,
|
|
13
|
+
)
|
|
14
|
+
from tokenjam.utils.formatting import console
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@click.command("budget")
|
|
18
|
+
@click.option("--agent", default=None, help="Agent ID to target (omit for global defaults)")
|
|
19
|
+
@click.option("--daily", "daily_usd", type=float, default=None,
|
|
20
|
+
help="Daily budget in USD (0 = remove limit)")
|
|
21
|
+
@click.option("--session", "session_usd", type=float, default=None,
|
|
22
|
+
help="Per-session budget in USD (0 = remove limit)")
|
|
23
|
+
@click.pass_context
|
|
24
|
+
def cmd_budget(
|
|
25
|
+
ctx: click.Context,
|
|
26
|
+
agent: str | None,
|
|
27
|
+
daily_usd: float | None,
|
|
28
|
+
session_usd: float | None,
|
|
29
|
+
) -> None:
|
|
30
|
+
"""View or set cost budgets for agents."""
|
|
31
|
+
config = ctx.obj["config"]
|
|
32
|
+
writing = daily_usd is not None or session_usd is not None
|
|
33
|
+
|
|
34
|
+
if not writing:
|
|
35
|
+
_show_budgets(config, ctx.obj.get("db"))
|
|
36
|
+
return
|
|
37
|
+
|
|
38
|
+
# Write mode — find config file on disk
|
|
39
|
+
config_path_str = find_config_file()
|
|
40
|
+
if config_path_str is None:
|
|
41
|
+
raise click.ClickException(
|
|
42
|
+
"No config file found. Run 'tj onboard' to create one."
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
if agent:
|
|
46
|
+
if agent not in config.agents:
|
|
47
|
+
config.agents[agent] = AgentConfig()
|
|
48
|
+
budget = config.agents[agent].budget
|
|
49
|
+
scope = f"agent '{agent}'"
|
|
50
|
+
else:
|
|
51
|
+
budget = config.defaults.budget
|
|
52
|
+
scope = "global defaults"
|
|
53
|
+
|
|
54
|
+
try:
|
|
55
|
+
if daily_usd is not None:
|
|
56
|
+
budget.daily_usd = validate_budget_value(daily_usd, "daily_usd")
|
|
57
|
+
if session_usd is not None:
|
|
58
|
+
budget.session_usd = validate_budget_value(session_usd, "session_usd")
|
|
59
|
+
except ValueError as e:
|
|
60
|
+
raise click.ClickException(str(e))
|
|
61
|
+
|
|
62
|
+
write_config(config, Path(config_path_str))
|
|
63
|
+
|
|
64
|
+
console.print(f"[green]\u2713[/green] Budget updated for {scope}")
|
|
65
|
+
if daily_usd is not None:
|
|
66
|
+
val = f"${daily_usd:.2f}" if daily_usd > 0 else "no limit"
|
|
67
|
+
console.print(f" Daily: {val}")
|
|
68
|
+
if session_usd is not None:
|
|
69
|
+
val = f"${session_usd:.2f}" if session_usd > 0 else "no limit"
|
|
70
|
+
console.print(f" Session: {val}")
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _show_budgets(config, db) -> None:
|
|
74
|
+
from rich.table import Table
|
|
75
|
+
|
|
76
|
+
table = Table(show_header=True, header_style="bold", box=None, padding=(0, 2))
|
|
77
|
+
table.add_column("Scope")
|
|
78
|
+
table.add_column("Daily", justify="right")
|
|
79
|
+
table.add_column("Session", justify="right")
|
|
80
|
+
|
|
81
|
+
def _fmt(val: float | None) -> str:
|
|
82
|
+
return f"${val:.2f}" if val is not None else "[dim]no limit[/dim]"
|
|
83
|
+
|
|
84
|
+
def _fmt_effective(raw: float | None, effective: float | None) -> str:
|
|
85
|
+
if raw is not None:
|
|
86
|
+
return f"${raw:.2f}"
|
|
87
|
+
if effective is not None:
|
|
88
|
+
return f"[dim]${effective:.2f}[/dim] [dim](default)[/dim]"
|
|
89
|
+
return "[dim]no limit[/dim]"
|
|
90
|
+
|
|
91
|
+
table.add_row(
|
|
92
|
+
"[bold]defaults[/bold]",
|
|
93
|
+
_fmt(config.defaults.budget.daily_usd),
|
|
94
|
+
_fmt(config.defaults.budget.session_usd),
|
|
95
|
+
)
|
|
96
|
+
|
|
97
|
+
# Merge agent IDs from config + DB-observed agents
|
|
98
|
+
agent_ids = set(config.agents)
|
|
99
|
+
if db is not None and hasattr(db, "conn"):
|
|
100
|
+
rows = db.conn.execute(
|
|
101
|
+
"SELECT DISTINCT agent_id FROM sessions ORDER BY agent_id"
|
|
102
|
+
).fetchall()
|
|
103
|
+
agent_ids |= {r[0] for r in rows}
|
|
104
|
+
|
|
105
|
+
for agent_id in sorted(agent_ids):
|
|
106
|
+
agent_cfg = config.agents.get(agent_id)
|
|
107
|
+
eff = resolve_effective_budget(agent_id, config)
|
|
108
|
+
raw_daily = agent_cfg.budget.daily_usd if agent_cfg else None
|
|
109
|
+
raw_session = agent_cfg.budget.session_usd if agent_cfg else None
|
|
110
|
+
table.add_row(
|
|
111
|
+
agent_id,
|
|
112
|
+
_fmt_effective(raw_daily, eff.daily_usd),
|
|
113
|
+
_fmt_effective(raw_session, eff.session_usd),
|
|
114
|
+
)
|
|
115
|
+
|
|
116
|
+
if not agent_ids:
|
|
117
|
+
table.add_row("[dim]no agents configured[/dim]", "", "")
|
|
118
|
+
|
|
119
|
+
console.print(table)
|