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
tokenjam/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.1.9"
|
tokenjam/api/__init__.py
ADDED
|
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}
|