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,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
+ }
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)