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,235 @@
|
|
|
1
|
+
"""
|
|
2
|
+
API-based backend for CLI commands when DuckDB is locked by tj serve.
|
|
3
|
+
Implements the subset of StorageBackend used by CLI commands by routing
|
|
4
|
+
queries through the REST API.
|
|
5
|
+
"""
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
from datetime import date, datetime
|
|
9
|
+
|
|
10
|
+
import httpx
|
|
11
|
+
|
|
12
|
+
from tokenjam.core.models import (
|
|
13
|
+
Alert,
|
|
14
|
+
AlertFilters,
|
|
15
|
+
AlertType,
|
|
16
|
+
CostFilters,
|
|
17
|
+
CostRow,
|
|
18
|
+
NormalizedSpan,
|
|
19
|
+
Severity,
|
|
20
|
+
SessionRecord,
|
|
21
|
+
SpanKind,
|
|
22
|
+
SpanStatus,
|
|
23
|
+
TraceFilters,
|
|
24
|
+
TraceRecord,
|
|
25
|
+
)
|
|
26
|
+
from tokenjam.utils.time_parse import utcnow
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class ApiBackend:
|
|
30
|
+
"""Read-only backend that queries tj serve instead of DuckDB."""
|
|
31
|
+
|
|
32
|
+
def __init__(self, base_url: str, api_key: str | None = None) -> None:
|
|
33
|
+
self.base_url = base_url.rstrip("/")
|
|
34
|
+
headers: dict[str, str] = {}
|
|
35
|
+
if api_key:
|
|
36
|
+
headers["Authorization"] = f"Bearer {api_key}"
|
|
37
|
+
self.client = httpx.Client(base_url=self.base_url, headers=headers, timeout=10)
|
|
38
|
+
|
|
39
|
+
def _get(self, path: str, params: dict | None = None) -> dict:
|
|
40
|
+
resp = self.client.get(path, params=params)
|
|
41
|
+
resp.raise_for_status()
|
|
42
|
+
return resp.json()
|
|
43
|
+
|
|
44
|
+
def get_traces(self, filters: TraceFilters) -> list[TraceRecord]:
|
|
45
|
+
params: dict[str, str | int] = {"limit": filters.limit, "offset": filters.offset}
|
|
46
|
+
if filters.agent_id:
|
|
47
|
+
params["agent_id"] = filters.agent_id
|
|
48
|
+
if filters.since:
|
|
49
|
+
params["since"] = filters.since.isoformat()
|
|
50
|
+
if filters.until:
|
|
51
|
+
params["until"] = filters.until.isoformat()
|
|
52
|
+
if filters.status:
|
|
53
|
+
params["status"] = filters.status
|
|
54
|
+
if filters.span_name:
|
|
55
|
+
params["span_name"] = filters.span_name
|
|
56
|
+
data = self._get("/api/v1/traces", params)
|
|
57
|
+
return [
|
|
58
|
+
TraceRecord(
|
|
59
|
+
trace_id=t["trace_id"],
|
|
60
|
+
agent_id=t["agent_id"],
|
|
61
|
+
name=t["name"],
|
|
62
|
+
start_time=datetime.fromisoformat(t["start_time"]) if t.get("start_time") else None,
|
|
63
|
+
duration_ms=t.get("duration_ms"),
|
|
64
|
+
cost_usd=t.get("cost_usd"),
|
|
65
|
+
status_code=t.get("status_code", "ok"),
|
|
66
|
+
span_count=t.get("span_count", 0),
|
|
67
|
+
)
|
|
68
|
+
for t in data.get("traces", [])
|
|
69
|
+
]
|
|
70
|
+
|
|
71
|
+
def get_trace_spans(self, trace_id: str) -> list[NormalizedSpan]:
|
|
72
|
+
data = self._get(f"/api/v1/traces/{trace_id}")
|
|
73
|
+
return [_dict_to_span(s) for s in data.get("spans", [])]
|
|
74
|
+
|
|
75
|
+
def get_cost_summary(self, filters: CostFilters) -> list[CostRow]:
|
|
76
|
+
params: dict[str, str] = {}
|
|
77
|
+
if filters.agent_id:
|
|
78
|
+
params["agent_id"] = filters.agent_id
|
|
79
|
+
if filters.since:
|
|
80
|
+
params["since"] = filters.since.isoformat()
|
|
81
|
+
if filters.until:
|
|
82
|
+
params["until"] = filters.until.isoformat()
|
|
83
|
+
if filters.group_by:
|
|
84
|
+
params["group_by"] = filters.group_by
|
|
85
|
+
data = self._get("/api/v1/cost", params)
|
|
86
|
+
return [
|
|
87
|
+
CostRow(
|
|
88
|
+
group=r["group"],
|
|
89
|
+
agent_id=r["agent_id"],
|
|
90
|
+
model=r["model"],
|
|
91
|
+
input_tokens=r.get("input_tokens", 0),
|
|
92
|
+
output_tokens=r.get("output_tokens", 0),
|
|
93
|
+
cost_usd=r.get("cost_usd", 0.0),
|
|
94
|
+
)
|
|
95
|
+
for r in data.get("rows", [])
|
|
96
|
+
]
|
|
97
|
+
|
|
98
|
+
def get_alerts(self, filters: AlertFilters) -> list[Alert]:
|
|
99
|
+
params: dict[str, str | int | bool] = {}
|
|
100
|
+
if filters.agent_id:
|
|
101
|
+
params["agent_id"] = filters.agent_id
|
|
102
|
+
if filters.since:
|
|
103
|
+
params["since"] = filters.since.isoformat()
|
|
104
|
+
if filters.severity:
|
|
105
|
+
params["severity"] = filters.severity.value
|
|
106
|
+
if filters.type:
|
|
107
|
+
params["type"] = filters.type.value
|
|
108
|
+
if filters.unread:
|
|
109
|
+
params["unread"] = True
|
|
110
|
+
data = self._get("/api/v1/alerts", params)
|
|
111
|
+
return [
|
|
112
|
+
Alert(
|
|
113
|
+
alert_id=a["alert_id"],
|
|
114
|
+
fired_at=datetime.fromisoformat(a["fired_at"]),
|
|
115
|
+
type=AlertType(a["type"]),
|
|
116
|
+
severity=Severity(a["severity"]),
|
|
117
|
+
title=a["title"],
|
|
118
|
+
detail=a.get("detail", {}),
|
|
119
|
+
agent_id=a.get("agent_id"),
|
|
120
|
+
session_id=a.get("session_id"),
|
|
121
|
+
span_id=a.get("span_id"),
|
|
122
|
+
acknowledged=a.get("acknowledged", False),
|
|
123
|
+
suppressed=a.get("suppressed", False),
|
|
124
|
+
)
|
|
125
|
+
for a in data.get("alerts", [])
|
|
126
|
+
]
|
|
127
|
+
|
|
128
|
+
def get_tool_calls(
|
|
129
|
+
self, agent_id: str | None, since: datetime | None, tool_name: str | None,
|
|
130
|
+
) -> list[dict]:
|
|
131
|
+
params: dict[str, str] = {}
|
|
132
|
+
if agent_id:
|
|
133
|
+
params["agent_id"] = agent_id
|
|
134
|
+
if since:
|
|
135
|
+
params["since"] = since.isoformat()
|
|
136
|
+
if tool_name:
|
|
137
|
+
params["tool_name"] = tool_name
|
|
138
|
+
data = self._get("/api/v1/tools", params)
|
|
139
|
+
return data.get("tools", [])
|
|
140
|
+
|
|
141
|
+
def get_daily_cost(self, agent_id: str, day: date) -> float:
|
|
142
|
+
params: dict[str, str] = {
|
|
143
|
+
"agent_id": agent_id,
|
|
144
|
+
"since": datetime(day.year, day.month, day.day).isoformat(),
|
|
145
|
+
"group_by": "day",
|
|
146
|
+
}
|
|
147
|
+
data = self._get("/api/v1/cost", params)
|
|
148
|
+
return data.get("total_cost_usd", 0.0)
|
|
149
|
+
|
|
150
|
+
def get_completed_sessions(self, agent_id: str, limit: int) -> list[SessionRecord]:
|
|
151
|
+
# Use /api/v1/status which already returns the latest session per agent
|
|
152
|
+
# with token counts and tool_call_count populated. Without this,
|
|
153
|
+
# `tj status` over a running server shows every agent as "idle" with
|
|
154
|
+
# zeros even when sessions exist (U3).
|
|
155
|
+
try:
|
|
156
|
+
data = self._get("/api/v1/status", {"agent_id": agent_id})
|
|
157
|
+
except (httpx.HTTPError, ValueError):
|
|
158
|
+
return []
|
|
159
|
+
agents = data.get("agents", [])
|
|
160
|
+
if not agents:
|
|
161
|
+
return []
|
|
162
|
+
a = agents[0]
|
|
163
|
+
if not a.get("session_id"):
|
|
164
|
+
return []
|
|
165
|
+
started_at = a.get("started_at")
|
|
166
|
+
return [SessionRecord(
|
|
167
|
+
session_id=a["session_id"],
|
|
168
|
+
agent_id=a.get("agent_id") or agent_id,
|
|
169
|
+
started_at=datetime.fromisoformat(started_at) if started_at else utcnow(),
|
|
170
|
+
ended_at=None,
|
|
171
|
+
conversation_id=None,
|
|
172
|
+
status=a.get("status", "completed"),
|
|
173
|
+
total_cost_usd=a.get("total_cost_usd"),
|
|
174
|
+
input_tokens=a.get("input_tokens", 0) or 0,
|
|
175
|
+
output_tokens=a.get("output_tokens", 0) or 0,
|
|
176
|
+
cache_tokens=0,
|
|
177
|
+
tool_call_count=a.get("tool_call_count", 0) or 0,
|
|
178
|
+
error_count=a.get("error_count", 0) or 0,
|
|
179
|
+
)][:limit]
|
|
180
|
+
|
|
181
|
+
def get_completed_session_count(self, agent_id: str) -> int:
|
|
182
|
+
return 0
|
|
183
|
+
|
|
184
|
+
def get_session_cost(self, session_id: str) -> float:
|
|
185
|
+
return 0.0
|
|
186
|
+
|
|
187
|
+
def get_recent_spans(self, session_id: str, limit: int) -> list[NormalizedSpan]:
|
|
188
|
+
return []
|
|
189
|
+
|
|
190
|
+
def close(self) -> None:
|
|
191
|
+
self.client.close()
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def _dict_to_span(d: dict) -> NormalizedSpan:
|
|
195
|
+
return NormalizedSpan(
|
|
196
|
+
span_id=d["span_id"],
|
|
197
|
+
trace_id=d["trace_id"],
|
|
198
|
+
name=d["name"],
|
|
199
|
+
kind=SpanKind(d.get("kind", "internal")),
|
|
200
|
+
status_code=SpanStatus(d.get("status_code", "ok")),
|
|
201
|
+
start_time=datetime.fromisoformat(d["start_time"]) if d.get("start_time") else None,
|
|
202
|
+
parent_span_id=d.get("parent_span_id"),
|
|
203
|
+
session_id=d.get("session_id"),
|
|
204
|
+
agent_id=d.get("agent_id"),
|
|
205
|
+
end_time=datetime.fromisoformat(d["end_time"]) if d.get("end_time") else None,
|
|
206
|
+
duration_ms=d.get("duration_ms"),
|
|
207
|
+
status_message=d.get("status_message"),
|
|
208
|
+
attributes=d.get("attributes", {}),
|
|
209
|
+
events=d.get("events", []),
|
|
210
|
+
provider=d.get("provider"),
|
|
211
|
+
model=d.get("model"),
|
|
212
|
+
tool_name=d.get("tool_name"),
|
|
213
|
+
input_tokens=d.get("input_tokens"),
|
|
214
|
+
output_tokens=d.get("output_tokens"),
|
|
215
|
+
cache_tokens=d.get("cache_tokens"),
|
|
216
|
+
cost_usd=d.get("cost_usd"),
|
|
217
|
+
request_type=d.get("request_type"),
|
|
218
|
+
conversation_id=d.get("conversation_id"),
|
|
219
|
+
)
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
def probe_api(host: str, port: int, api_key: str | None = None) -> ApiBackend | None:
|
|
223
|
+
"""Check if tj serve is running and return an ApiBackend if so."""
|
|
224
|
+
base_url = f"http://{host}:{port}"
|
|
225
|
+
try:
|
|
226
|
+
headers: dict[str, str] = {}
|
|
227
|
+
if api_key:
|
|
228
|
+
headers["Authorization"] = f"Bearer {api_key}"
|
|
229
|
+
resp = httpx.get(f"{base_url}/api/v1/traces", params={"limit": 1},
|
|
230
|
+
headers=headers, timeout=2)
|
|
231
|
+
if resp.status_code in (200, 401):
|
|
232
|
+
return ApiBackend(base_url, api_key)
|
|
233
|
+
except (httpx.ConnectError, httpx.TimeoutException):
|
|
234
|
+
pass
|
|
235
|
+
return None
|
tokenjam/core/config.py
ADDED
|
@@ -0,0 +1,360 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
import sys
|
|
3
|
+
from dataclasses import dataclass, field, fields
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
if sys.version_info >= (3, 11):
|
|
7
|
+
import tomllib
|
|
8
|
+
else:
|
|
9
|
+
import tomli as tomllib # type: ignore[no-redef]
|
|
10
|
+
|
|
11
|
+
import tomli_w
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
# -- Nested config dataclasses --
|
|
15
|
+
|
|
16
|
+
@dataclass
|
|
17
|
+
class SensitiveAction:
|
|
18
|
+
name: str
|
|
19
|
+
severity: str = "warning" # critical | warning | info
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@dataclass
|
|
23
|
+
class BudgetConfig:
|
|
24
|
+
daily_usd: float | None = None
|
|
25
|
+
session_usd: float | None = None
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@dataclass
|
|
29
|
+
class DriftConfig:
|
|
30
|
+
enabled: bool = True
|
|
31
|
+
baseline_sessions: int = 10
|
|
32
|
+
token_threshold: float = 2.0
|
|
33
|
+
tool_sequence_diff: float = 0.4
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@dataclass
|
|
37
|
+
class AgentConfig:
|
|
38
|
+
description: str = ""
|
|
39
|
+
budget: BudgetConfig = field(default_factory=BudgetConfig)
|
|
40
|
+
sensitive_actions: list[SensitiveAction] = field(default_factory=list)
|
|
41
|
+
output_schema: str | None = None
|
|
42
|
+
drift: DriftConfig = field(default_factory=DriftConfig)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
@dataclass
|
|
46
|
+
class DefaultsConfig:
|
|
47
|
+
budget: BudgetConfig = field(default_factory=BudgetConfig)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
@dataclass
|
|
51
|
+
class StorageConfig:
|
|
52
|
+
path: str = "~/.tj/telemetry.duckdb"
|
|
53
|
+
retention_days: int = 90
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
@dataclass
|
|
57
|
+
class OtlpConfig:
|
|
58
|
+
enabled: bool = False
|
|
59
|
+
endpoint: str = "http://localhost:4318"
|
|
60
|
+
protocol: str = "http" # http | grpc
|
|
61
|
+
headers: dict = field(default_factory=dict)
|
|
62
|
+
insecure: bool = True
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
@dataclass
|
|
66
|
+
class PrometheusConfig:
|
|
67
|
+
enabled: bool = True
|
|
68
|
+
port: int = 9464
|
|
69
|
+
path: str = "/metrics"
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
@dataclass
|
|
73
|
+
class ExportConfig:
|
|
74
|
+
otlp: OtlpConfig = field(default_factory=OtlpConfig)
|
|
75
|
+
prometheus: PrometheusConfig = field(default_factory=PrometheusConfig)
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
@dataclass
|
|
79
|
+
class AlertChannelConfig:
|
|
80
|
+
type: str
|
|
81
|
+
# stdout / file
|
|
82
|
+
path: str | None = None
|
|
83
|
+
# ntfy
|
|
84
|
+
topic: str | None = None
|
|
85
|
+
server: str = "https://ntfy.sh"
|
|
86
|
+
token: str = ""
|
|
87
|
+
# webhook
|
|
88
|
+
url: str | None = None
|
|
89
|
+
method: str = "POST"
|
|
90
|
+
headers: dict = field(default_factory=dict)
|
|
91
|
+
# discord
|
|
92
|
+
webhook_url: str | None = None
|
|
93
|
+
# telegram
|
|
94
|
+
bot_token: str | None = None
|
|
95
|
+
chat_id: str | None = None
|
|
96
|
+
# shared
|
|
97
|
+
min_severity: str = "info"
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
@dataclass
|
|
101
|
+
class AlertsConfig:
|
|
102
|
+
cooldown_seconds: int = 60
|
|
103
|
+
include_captured_content: bool = False
|
|
104
|
+
channels: list[AlertChannelConfig] = field(default_factory=lambda: [
|
|
105
|
+
AlertChannelConfig(type="stdout"),
|
|
106
|
+
])
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
@dataclass
|
|
110
|
+
class SecurityConfig:
|
|
111
|
+
ingest_secret: str = ""
|
|
112
|
+
max_attribute_bytes: int = 65536
|
|
113
|
+
max_attributes_per_span: int = 256
|
|
114
|
+
max_attribute_depth: int = 10
|
|
115
|
+
webhook_allowed_domains: list[str] = field(default_factory=list)
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
@dataclass
|
|
119
|
+
class ApiAuthConfig:
|
|
120
|
+
enabled: bool = False
|
|
121
|
+
api_key: str = ""
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
@dataclass
|
|
125
|
+
class ApiConfig:
|
|
126
|
+
enabled: bool = True
|
|
127
|
+
host: str = "127.0.0.1"
|
|
128
|
+
port: int = 7391
|
|
129
|
+
auth: ApiAuthConfig = field(default_factory=ApiAuthConfig)
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
@dataclass
|
|
133
|
+
class CaptureConfig:
|
|
134
|
+
prompts: bool = False
|
|
135
|
+
completions: bool = False
|
|
136
|
+
tool_inputs: bool = False
|
|
137
|
+
tool_outputs: bool = False
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
@dataclass
|
|
141
|
+
class TjConfig:
|
|
142
|
+
version: str
|
|
143
|
+
defaults: DefaultsConfig = field(default_factory=DefaultsConfig)
|
|
144
|
+
agents: dict[str, AgentConfig] = field(default_factory=dict)
|
|
145
|
+
storage: StorageConfig = field(default_factory=StorageConfig)
|
|
146
|
+
export: ExportConfig = field(default_factory=ExportConfig)
|
|
147
|
+
alerts: AlertsConfig = field(default_factory=AlertsConfig)
|
|
148
|
+
security: SecurityConfig = field(default_factory=SecurityConfig)
|
|
149
|
+
api: ApiConfig = field(default_factory=ApiConfig)
|
|
150
|
+
capture: CaptureConfig = field(default_factory=CaptureConfig)
|
|
151
|
+
# Path to the config file on disk; set by load_config() so that relative
|
|
152
|
+
# paths in the config (e.g. output_schema) can be resolved correctly.
|
|
153
|
+
config_path: Path | None = field(default=None, repr=False, compare=False)
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
# -- File discovery --
|
|
157
|
+
|
|
158
|
+
SEARCH_PATHS = [
|
|
159
|
+
Path("tokenjam.toml"),
|
|
160
|
+
Path(".tj/config.toml"),
|
|
161
|
+
Path.home() / ".config" / "tj" / "config.toml",
|
|
162
|
+
]
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def find_config_file(override: str | None = None) -> Path | None:
|
|
166
|
+
if override:
|
|
167
|
+
p = Path(override)
|
|
168
|
+
if p.exists():
|
|
169
|
+
return p
|
|
170
|
+
raise FileNotFoundError(f"Config file not found: {override}")
|
|
171
|
+
for path in SEARCH_PATHS:
|
|
172
|
+
if path.exists():
|
|
173
|
+
return path
|
|
174
|
+
return None
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def load_config(path: str | None = None) -> TjConfig:
|
|
178
|
+
"""
|
|
179
|
+
Load config from file, merge with defaults, return TjConfig.
|
|
180
|
+
|
|
181
|
+
IMPORTANT: tomllib requires binary mode "rb" -- not text mode "r".
|
|
182
|
+
Using "r" raises TypeError at runtime.
|
|
183
|
+
"""
|
|
184
|
+
config_path = find_config_file(path)
|
|
185
|
+
if config_path is None:
|
|
186
|
+
return TjConfig(version="1")
|
|
187
|
+
|
|
188
|
+
with open(config_path, "rb") as f: # "rb" is REQUIRED
|
|
189
|
+
raw = tomllib.load(f)
|
|
190
|
+
|
|
191
|
+
cfg = _parse(raw)
|
|
192
|
+
cfg.config_path = config_path.resolve()
|
|
193
|
+
return cfg
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def write_config(config: TjConfig, path: Path) -> None:
|
|
197
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
198
|
+
with open(path, "wb") as f:
|
|
199
|
+
tomli_w.dump(_serialise(config), f)
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
def _parse(raw: dict) -> TjConfig:
|
|
203
|
+
"""Convert raw TOML dict to TjConfig, applying defaults for missing keys."""
|
|
204
|
+
agents = {}
|
|
205
|
+
for agent_id, agent_raw in raw.get("agents", {}).items():
|
|
206
|
+
budget = BudgetConfig(**agent_raw.get("budget", {}))
|
|
207
|
+
sensitive_actions = [
|
|
208
|
+
SensitiveAction(**sa) for sa in agent_raw.get("sensitive_actions", [])
|
|
209
|
+
]
|
|
210
|
+
drift = DriftConfig(**agent_raw.get("drift", {}))
|
|
211
|
+
agents[agent_id] = AgentConfig(
|
|
212
|
+
description=agent_raw.get("description", ""),
|
|
213
|
+
budget=budget,
|
|
214
|
+
sensitive_actions=sensitive_actions,
|
|
215
|
+
output_schema=agent_raw.get("output_schema"),
|
|
216
|
+
drift=drift,
|
|
217
|
+
)
|
|
218
|
+
|
|
219
|
+
storage_raw = raw.get("storage", {})
|
|
220
|
+
storage = StorageConfig(
|
|
221
|
+
path=storage_raw.get("path", StorageConfig.path),
|
|
222
|
+
retention_days=storage_raw.get("retention_days", StorageConfig.retention_days),
|
|
223
|
+
)
|
|
224
|
+
|
|
225
|
+
export_raw = raw.get("export", {})
|
|
226
|
+
otlp_raw = export_raw.get("otlp", {})
|
|
227
|
+
otlp = OtlpConfig(
|
|
228
|
+
enabled=otlp_raw.get("enabled", False),
|
|
229
|
+
endpoint=otlp_raw.get("endpoint", OtlpConfig.endpoint),
|
|
230
|
+
protocol=otlp_raw.get("protocol", OtlpConfig.protocol),
|
|
231
|
+
headers=otlp_raw.get("headers", {}),
|
|
232
|
+
insecure=otlp_raw.get("insecure", True),
|
|
233
|
+
)
|
|
234
|
+
prom_raw = export_raw.get("prometheus", {})
|
|
235
|
+
prometheus = PrometheusConfig(
|
|
236
|
+
enabled=prom_raw.get("enabled", True),
|
|
237
|
+
port=prom_raw.get("port", PrometheusConfig.port),
|
|
238
|
+
path=prom_raw.get("path", PrometheusConfig.path),
|
|
239
|
+
)
|
|
240
|
+
export = ExportConfig(otlp=otlp, prometheus=prometheus)
|
|
241
|
+
|
|
242
|
+
alerts_raw = raw.get("alerts", {})
|
|
243
|
+
channels = []
|
|
244
|
+
for ch_raw in alerts_raw.get("channels", []):
|
|
245
|
+
channels.append(AlertChannelConfig(**ch_raw))
|
|
246
|
+
alerts = AlertsConfig(
|
|
247
|
+
cooldown_seconds=alerts_raw.get("cooldown_seconds", AlertsConfig.cooldown_seconds),
|
|
248
|
+
include_captured_content=alerts_raw.get("include_captured_content", False),
|
|
249
|
+
channels=channels if channels else [AlertChannelConfig(type="stdout")],
|
|
250
|
+
)
|
|
251
|
+
|
|
252
|
+
security_raw = raw.get("security", {})
|
|
253
|
+
security = SecurityConfig(
|
|
254
|
+
ingest_secret=security_raw.get("ingest_secret", ""),
|
|
255
|
+
max_attribute_bytes=security_raw.get("max_attribute_bytes", 65536),
|
|
256
|
+
max_attributes_per_span=security_raw.get("max_attributes_per_span", 256),
|
|
257
|
+
max_attribute_depth=security_raw.get("max_attribute_depth", 10),
|
|
258
|
+
webhook_allowed_domains=security_raw.get("webhook_allowed_domains", []),
|
|
259
|
+
)
|
|
260
|
+
|
|
261
|
+
api_raw = raw.get("api", {})
|
|
262
|
+
api_auth_raw = api_raw.get("auth", {})
|
|
263
|
+
api_auth = ApiAuthConfig(
|
|
264
|
+
enabled=api_auth_raw.get("enabled", False),
|
|
265
|
+
api_key=api_auth_raw.get("api_key", ""),
|
|
266
|
+
)
|
|
267
|
+
api = ApiConfig(
|
|
268
|
+
enabled=api_raw.get("enabled", True),
|
|
269
|
+
host=api_raw.get("host", ApiConfig.host),
|
|
270
|
+
port=api_raw.get("port", ApiConfig.port),
|
|
271
|
+
auth=api_auth,
|
|
272
|
+
)
|
|
273
|
+
|
|
274
|
+
capture_raw = raw.get("capture", {})
|
|
275
|
+
capture = CaptureConfig(
|
|
276
|
+
prompts=capture_raw.get("prompts", False),
|
|
277
|
+
completions=capture_raw.get("completions", False),
|
|
278
|
+
tool_inputs=capture_raw.get("tool_inputs", False),
|
|
279
|
+
tool_outputs=capture_raw.get("tool_outputs", False),
|
|
280
|
+
)
|
|
281
|
+
|
|
282
|
+
defaults_raw = raw.get("defaults", {})
|
|
283
|
+
defaults_budget_raw = defaults_raw.get("budget", {})
|
|
284
|
+
defaults = DefaultsConfig(budget=BudgetConfig(**defaults_budget_raw))
|
|
285
|
+
|
|
286
|
+
return TjConfig(
|
|
287
|
+
version=raw.get("version", "1"),
|
|
288
|
+
defaults=defaults,
|
|
289
|
+
agents=agents,
|
|
290
|
+
storage=storage,
|
|
291
|
+
export=export,
|
|
292
|
+
alerts=alerts,
|
|
293
|
+
security=security,
|
|
294
|
+
api=api,
|
|
295
|
+
capture=capture,
|
|
296
|
+
)
|
|
297
|
+
|
|
298
|
+
|
|
299
|
+
def _serialise(config: TjConfig) -> dict:
|
|
300
|
+
"""Convert TjConfig back to a plain dict suitable for tomli_w."""
|
|
301
|
+
def _dc_to_dict(obj: object) -> dict:
|
|
302
|
+
result = {}
|
|
303
|
+
for f in fields(obj): # type: ignore[arg-type]
|
|
304
|
+
val = getattr(obj, f.name)
|
|
305
|
+
if isinstance(val, dict):
|
|
306
|
+
result[f.name] = val
|
|
307
|
+
elif isinstance(val, list):
|
|
308
|
+
result[f.name] = [
|
|
309
|
+
_dc_to_dict(item) if hasattr(item, "__dataclass_fields__") else item
|
|
310
|
+
for item in val
|
|
311
|
+
]
|
|
312
|
+
elif hasattr(val, "__dataclass_fields__"):
|
|
313
|
+
result[f.name] = _dc_to_dict(val)
|
|
314
|
+
elif val is not None and not isinstance(val, Path):
|
|
315
|
+
result[f.name] = val
|
|
316
|
+
return result
|
|
317
|
+
|
|
318
|
+
d = _dc_to_dict(config)
|
|
319
|
+
|
|
320
|
+
# agents is a dict of str -> AgentConfig, handle specially
|
|
321
|
+
agents_out = {}
|
|
322
|
+
for agent_id, agent_cfg in config.agents.items():
|
|
323
|
+
agents_out[agent_id] = _dc_to_dict(agent_cfg)
|
|
324
|
+
d["agents"] = agents_out
|
|
325
|
+
|
|
326
|
+
return d
|
|
327
|
+
|
|
328
|
+
|
|
329
|
+
def resolve_effective_budget(agent_id: str, config: TjConfig) -> BudgetConfig:
|
|
330
|
+
"""
|
|
331
|
+
Return the effective budget for an agent, merging per-agent overrides
|
|
332
|
+
with global defaults on a per-field basis.
|
|
333
|
+
|
|
334
|
+
Each field (daily_usd, session_usd) independently uses the agent value
|
|
335
|
+
if set, otherwise falls back to the defaults value.
|
|
336
|
+
"""
|
|
337
|
+
defaults = config.defaults.budget
|
|
338
|
+
agent_cfg = config.agents.get(agent_id)
|
|
339
|
+
if agent_cfg is None:
|
|
340
|
+
return BudgetConfig(
|
|
341
|
+
daily_usd=defaults.daily_usd,
|
|
342
|
+
session_usd=defaults.session_usd,
|
|
343
|
+
)
|
|
344
|
+
ab = agent_cfg.budget
|
|
345
|
+
return BudgetConfig(
|
|
346
|
+
daily_usd=ab.daily_usd if ab.daily_usd is not None else defaults.daily_usd,
|
|
347
|
+
session_usd=ab.session_usd if ab.session_usd is not None else defaults.session_usd,
|
|
348
|
+
)
|
|
349
|
+
|
|
350
|
+
|
|
351
|
+
def validate_budget_value(value: float, field_name: str) -> float | None:
|
|
352
|
+
"""
|
|
353
|
+
Validate and normalise a budget value from user input.
|
|
354
|
+
|
|
355
|
+
Positive values are returned as-is. Zero means 'remove limit' (returns None).
|
|
356
|
+
Negative values raise ValueError.
|
|
357
|
+
"""
|
|
358
|
+
if value < 0:
|
|
359
|
+
raise ValueError(f"Budget {field_name} must be non-negative, got {value}")
|
|
360
|
+
return value if value > 0 else None
|
tokenjam/core/cost.py
ADDED
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
import logging
|
|
3
|
+
from tokenjam.core.models import NormalizedSpan
|
|
4
|
+
from tokenjam.core.pricing import get_rates, ModelRates, DEFAULT_INPUT_PER_MTOK, DEFAULT_OUTPUT_PER_MTOK
|
|
5
|
+
|
|
6
|
+
logger = logging.getLogger(__name__)
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def calculate_cost(
|
|
10
|
+
provider: str,
|
|
11
|
+
model: str,
|
|
12
|
+
input_tokens: int,
|
|
13
|
+
output_tokens: int,
|
|
14
|
+
cache_read_tokens: int = 0,
|
|
15
|
+
cache_write_tokens: int = 0,
|
|
16
|
+
) -> float:
|
|
17
|
+
"""
|
|
18
|
+
Calculate USD cost for a single LLM call.
|
|
19
|
+
|
|
20
|
+
Returns cost rounded to 8 decimal places.
|
|
21
|
+
Falls back to default rates if the provider/model is not in the pricing table.
|
|
22
|
+
Logs a warning on fallback so developers know to add the model.
|
|
23
|
+
Zero tokens -> zero cost (no warning).
|
|
24
|
+
"""
|
|
25
|
+
if input_tokens == 0 and output_tokens == 0:
|
|
26
|
+
return 0.0
|
|
27
|
+
|
|
28
|
+
rates = get_rates(provider, model)
|
|
29
|
+
if rates is None:
|
|
30
|
+
logger.warning(
|
|
31
|
+
"No pricing data for %s/%s — using default rates. "
|
|
32
|
+
"Add to pricing/models.toml to get accurate costs.",
|
|
33
|
+
provider, model,
|
|
34
|
+
)
|
|
35
|
+
rates = ModelRates(
|
|
36
|
+
input_per_mtok=DEFAULT_INPUT_PER_MTOK,
|
|
37
|
+
output_per_mtok=DEFAULT_OUTPUT_PER_MTOK,
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
cost = (
|
|
41
|
+
(input_tokens / 1_000_000) * rates.input_per_mtok
|
|
42
|
+
+ (output_tokens / 1_000_000) * rates.output_per_mtok
|
|
43
|
+
+ (cache_read_tokens / 1_000_000) * rates.cache_read_per_mtok
|
|
44
|
+
+ (cache_write_tokens / 1_000_000) * rates.cache_write_per_mtok
|
|
45
|
+
)
|
|
46
|
+
return round(cost, 8)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class CostEngine:
|
|
50
|
+
"""
|
|
51
|
+
Post-ingest hook. Called by IngestPipeline after each span is written.
|
|
52
|
+
Calculates cost and updates span.cost_usd + session.total_cost_usd in DB.
|
|
53
|
+
"""
|
|
54
|
+
|
|
55
|
+
def __init__(self, db: object) -> None:
|
|
56
|
+
self.db = db
|
|
57
|
+
|
|
58
|
+
def process_span(self, span: NormalizedSpan) -> None:
|
|
59
|
+
"""
|
|
60
|
+
If the span has token counts and a provider/model, calculate cost,
|
|
61
|
+
update span.cost_usd in DB, update session.total_cost_usd in DB.
|
|
62
|
+
No-op if tokens are missing or zero.
|
|
63
|
+
"""
|
|
64
|
+
if not span.provider or not span.model:
|
|
65
|
+
return
|
|
66
|
+
input_tokens = span.input_tokens or 0
|
|
67
|
+
output_tokens = span.output_tokens or 0
|
|
68
|
+
if input_tokens == 0 and output_tokens == 0:
|
|
69
|
+
return
|
|
70
|
+
|
|
71
|
+
cache_read_tokens = span.cache_tokens or 0
|
|
72
|
+
|
|
73
|
+
# Record whether the span was already pre-priced before we compute.
|
|
74
|
+
# Pre-priced spans have their session cost handled by _build_or_update_session
|
|
75
|
+
# in ingest.py; updating the session again here would double-count.
|
|
76
|
+
was_pre_priced = span.cost_usd is not None
|
|
77
|
+
|
|
78
|
+
cost = calculate_cost(
|
|
79
|
+
provider=span.provider,
|
|
80
|
+
model=span.model,
|
|
81
|
+
input_tokens=input_tokens,
|
|
82
|
+
output_tokens=output_tokens,
|
|
83
|
+
cache_read_tokens=cache_read_tokens,
|
|
84
|
+
)
|
|
85
|
+
|
|
86
|
+
span.cost_usd = cost
|
|
87
|
+
|
|
88
|
+
# Update span cost in DB
|
|
89
|
+
if hasattr(self.db, 'conn'):
|
|
90
|
+
self.db.conn.execute(
|
|
91
|
+
"UPDATE spans SET cost_usd = $1 WHERE span_id = $2",
|
|
92
|
+
[cost, span.span_id],
|
|
93
|
+
)
|
|
94
|
+
|
|
95
|
+
# Only accumulate into session total when we computed the cost here.
|
|
96
|
+
# Skip the session update for pre-priced spans to avoid double-counting.
|
|
97
|
+
if span.session_id and not was_pre_priced:
|
|
98
|
+
self.db.conn.execute(
|
|
99
|
+
"UPDATE sessions SET total_cost_usd = COALESCE(total_cost_usd, 0) + $1 "
|
|
100
|
+
"WHERE session_id = $2",
|
|
101
|
+
[cost, span.session_id],
|
|
102
|
+
)
|