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
tokenjam/__init__.py ADDED
@@ -0,0 +1 @@
1
+ __version__ = "0.1.9"
File without changes
tokenjam/api/app.py ADDED
@@ -0,0 +1,104 @@
1
+ """FastAPI application factory. Called by `tj serve`."""
2
+ from __future__ import annotations
3
+
4
+ from html import escape as html_escape
5
+ from pathlib import Path
6
+ from typing import TYPE_CHECKING
7
+
8
+ from fastapi import FastAPI
9
+ from fastapi.middleware.cors import CORSMiddleware
10
+ from fastapi.responses import HTMLResponse
11
+
12
+ from tokenjam.api.middleware import IngestAuthMiddleware
13
+ from tokenjam.core.config import TjConfig
14
+
15
+ if TYPE_CHECKING:
16
+ from tokenjam.core.db import StorageBackend
17
+ from tokenjam.core.ingest import IngestPipeline
18
+
19
+ _UI_DIR = Path(__file__).resolve().parent.parent / "ui"
20
+
21
+
22
+ def create_app(
23
+ config: TjConfig,
24
+ db: StorageBackend,
25
+ ingest_pipeline: IngestPipeline,
26
+ ) -> FastAPI:
27
+ """
28
+ Build and return the FastAPI app.
29
+
30
+ db and ingest_pipeline are passed in (not imported globally) so tests
31
+ can inject mocks easily.
32
+ """
33
+ app = FastAPI(
34
+ title="TokenJam",
35
+ version="0.1.0",
36
+ docs_url="/docs",
37
+ redoc_url=None,
38
+ )
39
+
40
+ # CORS — local only by default
41
+ app.add_middleware(
42
+ CORSMiddleware,
43
+ allow_origin_regex=r"^https?://(localhost|127\.0\.0\.1)(:\d+)?$",
44
+ allow_methods=["GET", "POST", "PATCH"],
45
+ allow_headers=["Authorization", "Content-Type"],
46
+ )
47
+
48
+ # Ingest auth middleware
49
+ app.add_middleware(IngestAuthMiddleware)
50
+
51
+ # Shared state for routes
52
+ app.state.config = config
53
+ app.state.db = db
54
+ app.state.pipeline = ingest_pipeline
55
+
56
+ # Register routers
57
+ from tokenjam.api.routes.spans import router as spans_router
58
+ from tokenjam.api.routes.traces import router as traces_router
59
+ from tokenjam.api.routes.cost import router as cost_router
60
+ from tokenjam.api.routes.tools import router as tools_router
61
+ from tokenjam.api.routes.alerts import router as alerts_router
62
+ from tokenjam.api.routes.drift import router as drift_router
63
+ from tokenjam.api.routes.metrics import router as metrics_router
64
+ from tokenjam.api.routes.status import router as status_router
65
+ from tokenjam.api.routes.otlp import router as otlp_router
66
+ from tokenjam.api.routes.budget import router as budget_router
67
+ from tokenjam.api.routes.agents import router as agents_router
68
+
69
+ app.include_router(spans_router, prefix="/api/v1")
70
+ app.include_router(traces_router, prefix="/api/v1")
71
+ app.include_router(cost_router, prefix="/api/v1")
72
+ app.include_router(tools_router, prefix="/api/v1")
73
+ app.include_router(alerts_router, prefix="/api/v1")
74
+ app.include_router(drift_router, prefix="/api/v1")
75
+ app.include_router(status_router, prefix="/api/v1")
76
+ app.include_router(budget_router, prefix="/api/v1")
77
+ app.include_router(agents_router, prefix="/api/v1")
78
+ app.include_router(metrics_router) # /metrics — no prefix
79
+ app.include_router(otlp_router) # /v1/traces, /v1/metrics, /v1/logs — no prefix
80
+
81
+ # --- Web UI ---
82
+ _index_html = ""
83
+ index_path = _UI_DIR / "index.html"
84
+ if index_path.exists():
85
+ _index_html = index_path.read_text()
86
+
87
+ def _serve_ui() -> HTMLResponse:
88
+ html = _index_html
89
+ if config.api.auth.enabled and config.api.auth.api_key:
90
+ html = html.replace(
91
+ "</head>",
92
+ f'<meta name="tj-api-key" content="{html_escape(config.api.auth.api_key, quote=True)}">\n</head>',
93
+ )
94
+ return HTMLResponse(html)
95
+
96
+ @app.get("/", include_in_schema=False)
97
+ async def ui_root():
98
+ return _serve_ui()
99
+
100
+ @app.get("/ui/{path:path}", include_in_schema=False)
101
+ async def ui_catchall(path: str):
102
+ return _serve_ui()
103
+
104
+ return app
tokenjam/api/deps.py ADDED
@@ -0,0 +1,18 @@
1
+ """Shared FastAPI dependencies."""
2
+ from __future__ import annotations
3
+
4
+ from fastapi import Depends, HTTPException, Request
5
+ from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
6
+
7
+ security = HTTPBearer(auto_error=False)
8
+
9
+
10
+ def require_api_key(
11
+ request: Request,
12
+ credentials: HTTPAuthorizationCredentials | None = Depends(security),
13
+ ) -> None:
14
+ config = request.app.state.config
15
+ if not config.api.auth.enabled:
16
+ return
17
+ if credentials is None or credentials.credentials != config.api.auth.api_key:
18
+ raise HTTPException(status_code=401, detail="Invalid API key")
@@ -0,0 +1,28 @@
1
+ """Ingest auth middleware — validates Bearer token on POST /api/v1/spans."""
2
+ from __future__ import annotations
3
+
4
+ from fastapi import Request
5
+ from fastapi.responses import JSONResponse
6
+ from starlette.middleware.base import BaseHTTPMiddleware
7
+
8
+
9
+ class IngestAuthMiddleware(BaseHTTPMiddleware):
10
+ """
11
+ Validates the ingest secret on POST /api/v1/spans.
12
+ If security.ingest_secret is empty string, auth is disabled.
13
+ Returns 401 with JSON error if secret is wrong or missing.
14
+ """
15
+
16
+ PROTECTED_PATHS = {"/api/v1/spans", "/v1/logs", "/v1/traces"}
17
+
18
+ async def dispatch(self, request: Request, call_next): # type: ignore[override]
19
+ if request.method == "POST" and request.url.path in self.PROTECTED_PATHS:
20
+ secret = request.app.state.config.security.ingest_secret
21
+ if secret:
22
+ auth = request.headers.get("Authorization", "")
23
+ if not auth.startswith("Bearer ") or auth[7:] != secret:
24
+ return JSONResponse(
25
+ status_code=401,
26
+ content={"detail": "Invalid ingest secret"},
27
+ )
28
+ return await call_next(request)
File without changes
@@ -0,0 +1,33 @@
1
+ """GET /api/v1/agents — agent registry."""
2
+ from __future__ import annotations
3
+
4
+ from fastapi import APIRouter, Depends, Request
5
+
6
+ from tokenjam.api.deps import require_api_key
7
+
8
+ router = APIRouter(dependencies=[Depends(require_api_key)])
9
+
10
+
11
+ @router.get("/agents")
12
+ async def list_agents(request: Request) -> dict:
13
+ db = request.app.state.db
14
+ if not hasattr(db, "conn") or db.conn is None:
15
+ return {"agents": []}
16
+ rows = db.conn.execute(
17
+ "SELECT a.agent_id, a.first_seen, a.last_seen, "
18
+ "COALESCE(SUM(s.cost_usd), 0.0) AS lifetime_cost "
19
+ "FROM agents a LEFT JOIN spans s ON a.agent_id = s.agent_id "
20
+ "GROUP BY a.agent_id, a.first_seen, a.last_seen "
21
+ "ORDER BY a.last_seen DESC NULLS LAST"
22
+ ).fetchall()
23
+ return {
24
+ "agents": [
25
+ {
26
+ "agent_id": r[0],
27
+ "first_seen": r[1].isoformat() if r[1] else None,
28
+ "last_seen": r[2].isoformat() if r[2] else None,
29
+ "lifetime_cost_usd": float(r[3]),
30
+ }
31
+ for r in rows
32
+ ]
33
+ }
@@ -0,0 +1,77 @@
1
+ """GET /api/v1/alerts — alert history."""
2
+ from __future__ import annotations
3
+
4
+ from fastapi import APIRouter, Depends, HTTPException, Request
5
+
6
+ from tokenjam.api.deps import require_api_key
7
+ from tokenjam.core.models import AlertFilters, AlertType, Severity
8
+ from tokenjam.utils.time_parse import parse_since
9
+
10
+ router = APIRouter(dependencies=[Depends(require_api_key)])
11
+
12
+
13
+
14
+ @router.get("/alerts")
15
+ async def get_alerts(
16
+ request: Request,
17
+ agent_id: str | None = None,
18
+ since: str | None = None,
19
+ severity: str | None = None,
20
+ type: str | None = None,
21
+ unread: bool = False,
22
+ ) -> dict:
23
+ db = request.app.state.db
24
+ try:
25
+ sev = Severity(severity) if severity else None
26
+ except ValueError:
27
+ raise HTTPException(status_code=422, detail=f"Invalid severity: {severity!r}")
28
+ try:
29
+ typ = AlertType(type) if type else None
30
+ except ValueError:
31
+ raise HTTPException(status_code=422, detail=f"Invalid type: {type!r}")
32
+ filters = AlertFilters(
33
+ agent_id=agent_id,
34
+ since=parse_since(since) if since else None,
35
+ severity=sev,
36
+ type=typ,
37
+ unread=unread,
38
+ )
39
+ alerts = db.get_alerts(filters)
40
+ return {
41
+ "alerts": [
42
+ {
43
+ "alert_id": a.alert_id,
44
+ "fired_at": a.fired_at.isoformat(),
45
+ "type": a.type.value,
46
+ "severity": a.severity.value,
47
+ "title": a.title,
48
+ "detail": a.detail,
49
+ "agent_id": a.agent_id,
50
+ "session_id": a.session_id,
51
+ "span_id": a.span_id,
52
+ "acknowledged": a.acknowledged,
53
+ "suppressed": a.suppressed,
54
+ }
55
+ for a in alerts
56
+ ],
57
+ "count": len(alerts),
58
+ }
59
+
60
+
61
+ @router.patch("/alerts/{alert_id}/acknowledge")
62
+ async def acknowledge_alert(alert_id: str, request: Request) -> dict:
63
+ db = request.app.state.db
64
+ conn = getattr(db, "conn", None)
65
+ if conn is None:
66
+ from fastapi import HTTPException
67
+ raise HTTPException(status_code=503, detail="Write operations unavailable in read-only mode")
68
+ result = conn.execute(
69
+ "SELECT alert_id FROM alerts WHERE alert_id = $1", [alert_id]
70
+ ).fetchone()
71
+ if result is None:
72
+ from fastapi import HTTPException
73
+ raise HTTPException(status_code=404, detail=f"Alert {alert_id} not found")
74
+ conn.execute(
75
+ "UPDATE alerts SET acknowledged = true WHERE alert_id = $1", [alert_id]
76
+ )
77
+ return {"acknowledged": True, "alert_id": alert_id}
@@ -0,0 +1,96 @@
1
+ """GET /api/v1/budget — read budgets. POST /api/v1/budget — update budgets."""
2
+ from __future__ import annotations
3
+
4
+ from pathlib import Path
5
+
6
+ from fastapi import APIRouter, Depends, Request
7
+ from fastapi.responses import JSONResponse
8
+ from pydantic import BaseModel
9
+
10
+ from tokenjam.api.deps import require_api_key
11
+ from tokenjam.core.config import (
12
+ AgentConfig,
13
+ BudgetConfig,
14
+ find_config_file,
15
+ resolve_effective_budget,
16
+ validate_budget_value,
17
+ write_config,
18
+ )
19
+
20
+ router = APIRouter(dependencies=[Depends(require_api_key)])
21
+
22
+
23
+ def _budget_payload(config, agent_ids: list[str]) -> dict:
24
+ def _b(b):
25
+ return {"daily_usd": b.daily_usd, "session_usd": b.session_usd}
26
+
27
+ agents = {}
28
+ for aid in agent_ids:
29
+ agent_cfg = config.agents.get(aid)
30
+ raw = _b(agent_cfg.budget) if agent_cfg else _b(BudgetConfig())
31
+ eff = _b(resolve_effective_budget(aid, config))
32
+ agents[aid] = {"configured": raw, "effective": eff}
33
+
34
+ return {
35
+ "defaults": _b(config.defaults.budget),
36
+ "agents": agents,
37
+ }
38
+
39
+
40
+ @router.get("/budget")
41
+ async def get_budget(request: Request) -> dict:
42
+ config = request.app.state.config
43
+ db = request.app.state.db
44
+
45
+ # Merge: config agents + DB-observed agents
46
+ db_agent_ids: set[str] = set()
47
+ if hasattr(db, "conn"):
48
+ rows = db.conn.execute(
49
+ "SELECT DISTINCT agent_id FROM sessions ORDER BY agent_id"
50
+ ).fetchall()
51
+ db_agent_ids = {r[0] for r in rows}
52
+
53
+ all_agent_ids = sorted(set(config.agents) | db_agent_ids)
54
+ return _budget_payload(config, all_agent_ids)
55
+
56
+
57
+ class BudgetUpdate(BaseModel):
58
+ scope: str # "defaults" or an agent_id
59
+ daily_usd: float | None = None
60
+ session_usd: float | None = None
61
+
62
+
63
+ @router.post("/budget")
64
+ async def post_budget(request: Request, body: BudgetUpdate):
65
+ config = request.app.state.config
66
+ config_path_str = find_config_file()
67
+ if config_path_str is None:
68
+ return JSONResponse(status_code=400, content={"error": "No config file found"})
69
+
70
+ if body.scope == "defaults":
71
+ budget = config.defaults.budget
72
+ else:
73
+ if body.scope not in config.agents:
74
+ config.agents[body.scope] = AgentConfig()
75
+ budget = config.agents[body.scope].budget
76
+
77
+ try:
78
+ if body.daily_usd is not None:
79
+ budget.daily_usd = validate_budget_value(body.daily_usd, "daily_usd")
80
+ if body.session_usd is not None:
81
+ budget.session_usd = validate_budget_value(body.session_usd, "session_usd")
82
+ except ValueError as e:
83
+ return JSONResponse(status_code=400, content={"error": str(e)})
84
+
85
+ write_config(config, Path(config_path_str))
86
+
87
+ # Return updated payload with all known agents
88
+ db = request.app.state.db
89
+ db_agent_ids: set[str] = set()
90
+ if hasattr(db, "conn"):
91
+ rows = db.conn.execute(
92
+ "SELECT DISTINCT agent_id FROM sessions ORDER BY agent_id"
93
+ ).fetchall()
94
+ db_agent_ids = {r[0] for r in rows}
95
+ all_agent_ids = sorted(set(config.agents) | db_agent_ids)
96
+ return _budget_payload(config, all_agent_ids)
@@ -0,0 +1,43 @@
1
+ """GET /api/v1/cost — aggregated cost data."""
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.models import CostFilters
8
+ from tokenjam.utils.time_parse import parse_since
9
+
10
+ router = APIRouter(dependencies=[Depends(require_api_key)])
11
+
12
+
13
+ @router.get("/cost")
14
+ async def get_cost(
15
+ request: Request,
16
+ agent_id: str | None = None,
17
+ since: str | None = None,
18
+ until: str | None = None,
19
+ group_by: str = "day",
20
+ ) -> dict:
21
+ db = request.app.state.db
22
+ filters = CostFilters(
23
+ agent_id=agent_id,
24
+ since=parse_since(since) if since else None,
25
+ until=parse_since(until) if until else None,
26
+ group_by=group_by,
27
+ )
28
+ rows = db.get_cost_summary(filters)
29
+ total = sum(r.cost_usd for r in rows)
30
+ return {
31
+ "rows": [
32
+ {
33
+ "group": r.group,
34
+ "agent_id": r.agent_id,
35
+ "model": r.model,
36
+ "input_tokens": r.input_tokens,
37
+ "output_tokens": r.output_tokens,
38
+ "cost_usd": r.cost_usd,
39
+ }
40
+ for r in rows
41
+ ],
42
+ "total_cost_usd": total,
43
+ }
@@ -0,0 +1,63 @@
1
+ """GET /api/v1/drift — drift baseline and latest session comparison."""
2
+ from __future__ import annotations
3
+
4
+ from typing import Any
5
+
6
+ from fastapi import APIRouter, Depends, Request
7
+
8
+ from tokenjam.api.deps import require_api_key
9
+
10
+ router = APIRouter(dependencies=[Depends(require_api_key)])
11
+
12
+
13
+ def _build_agent_drift(db: Any, agent_id: str) -> dict:
14
+ """Build drift info dict for a single agent."""
15
+ baseline = db.get_baseline(agent_id)
16
+ if baseline is None:
17
+ return {"agent_id": agent_id, "baseline": None, "latest_session": None}
18
+
19
+ sessions = db.get_completed_sessions(agent_id, limit=1)
20
+ latest = None
21
+ if sessions:
22
+ s = sessions[0]
23
+ latest = {
24
+ "session_id": s.session_id,
25
+ "input_tokens": s.input_tokens,
26
+ "output_tokens": s.output_tokens,
27
+ "tool_call_count": s.tool_call_count,
28
+ "duration_seconds": s.duration_seconds,
29
+ }
30
+
31
+ return {
32
+ "agent_id": agent_id,
33
+ "baseline": {
34
+ "sessions_sampled": baseline.sessions_sampled,
35
+ "computed_at": baseline.computed_at.isoformat() if baseline.computed_at else None,
36
+ "avg_input_tokens": baseline.avg_input_tokens,
37
+ "stddev_input_tokens": baseline.stddev_input_tokens,
38
+ "avg_output_tokens": baseline.avg_output_tokens,
39
+ "stddev_output_tokens": baseline.stddev_output_tokens,
40
+ "avg_session_duration_s": baseline.avg_session_duration_s,
41
+ "stddev_session_duration": baseline.stddev_session_duration,
42
+ "avg_tool_call_count": baseline.avg_tool_call_count,
43
+ "stddev_tool_call_count": baseline.stddev_tool_call_count,
44
+ },
45
+ "latest_session": latest,
46
+ }
47
+
48
+
49
+ @router.get("/drift", response_model=None)
50
+ async def get_drift(request: Request, agent_id: str | None = None):
51
+ db = request.app.state.db
52
+
53
+ if agent_id:
54
+ return _build_agent_drift(db, agent_id)
55
+
56
+ # No agent_id: return drift info for all agents with baselines.
57
+ if not hasattr(db, "conn"):
58
+ return {"agents": []}
59
+ rows = db.conn.execute(
60
+ "SELECT DISTINCT agent_id FROM drift_baselines ORDER BY agent_id"
61
+ ).fetchall()
62
+ agents = [_build_agent_drift(db, row[0]) for row in rows]
63
+ return {"agents": agents}