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