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/core/models.py
ADDED
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
from dataclasses import dataclass, field
|
|
3
|
+
from datetime import datetime, timedelta
|
|
4
|
+
from enum import Enum
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
# Sessions with no spans for this long are considered stale (zombie).
|
|
8
|
+
SESSION_STALE_THRESHOLD = timedelta(minutes=5)
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class Severity(str, Enum):
|
|
12
|
+
CRITICAL = "critical"
|
|
13
|
+
WARNING = "warning"
|
|
14
|
+
INFO = "info"
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class AlertType(str, Enum):
|
|
18
|
+
COST_BUDGET_DAILY = "cost_budget_daily"
|
|
19
|
+
COST_BUDGET_SESSION = "cost_budget_session"
|
|
20
|
+
SENSITIVE_ACTION = "sensitive_action"
|
|
21
|
+
RETRY_LOOP = "retry_loop"
|
|
22
|
+
TOKEN_ANOMALY = "token_anomaly"
|
|
23
|
+
SESSION_DURATION = "session_duration"
|
|
24
|
+
SCHEMA_VIOLATION = "schema_violation"
|
|
25
|
+
DRIFT_DETECTED = "drift_detected"
|
|
26
|
+
FAILURE_RATE = "failure_rate"
|
|
27
|
+
NETWORK_EGRESS_BLOCKED = "network_egress_blocked"
|
|
28
|
+
FILESYSTEM_ACCESS_DENIED = "filesystem_access_denied"
|
|
29
|
+
SYSCALL_DENIED = "syscall_denied"
|
|
30
|
+
INFERENCE_REROUTED = "inference_rerouted"
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class SpanStatus(str, Enum):
|
|
34
|
+
OK = "ok"
|
|
35
|
+
ERROR = "error"
|
|
36
|
+
UNSET = "unset"
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class SpanKind(str, Enum):
|
|
40
|
+
INTERNAL = "internal"
|
|
41
|
+
CLIENT = "client"
|
|
42
|
+
SERVER = "server"
|
|
43
|
+
PRODUCER = "producer"
|
|
44
|
+
CONSUMER = "consumer"
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
@dataclass
|
|
48
|
+
class NormalizedSpan:
|
|
49
|
+
span_id: str
|
|
50
|
+
trace_id: str
|
|
51
|
+
name: str
|
|
52
|
+
kind: SpanKind
|
|
53
|
+
status_code: SpanStatus
|
|
54
|
+
start_time: datetime
|
|
55
|
+
parent_span_id: str | None = None
|
|
56
|
+
session_id: str | None = None
|
|
57
|
+
agent_id: str | None = None
|
|
58
|
+
end_time: datetime | None = None
|
|
59
|
+
duration_ms: float | None = None
|
|
60
|
+
status_message: str | None = None
|
|
61
|
+
attributes: dict[str, Any] = field(default_factory=dict)
|
|
62
|
+
events: list[dict] = field(default_factory=list)
|
|
63
|
+
# Extracted indexed fields
|
|
64
|
+
provider: str | None = None
|
|
65
|
+
model: str | None = None
|
|
66
|
+
tool_name: str | None = None
|
|
67
|
+
input_tokens: int | None = None
|
|
68
|
+
output_tokens: int | None = None
|
|
69
|
+
cache_tokens: int | None = None
|
|
70
|
+
cost_usd: float | None = None
|
|
71
|
+
request_type: str | None = None
|
|
72
|
+
conversation_id: str | None = None
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
@dataclass
|
|
76
|
+
class SessionRecord:
|
|
77
|
+
session_id: str
|
|
78
|
+
agent_id: str
|
|
79
|
+
started_at: datetime
|
|
80
|
+
conversation_id: str | None = None
|
|
81
|
+
ended_at: datetime | None = None
|
|
82
|
+
status: str = "active"
|
|
83
|
+
total_cost_usd: float | None = None
|
|
84
|
+
input_tokens: int = 0
|
|
85
|
+
output_tokens: int = 0
|
|
86
|
+
cache_tokens: int = 0
|
|
87
|
+
tool_call_count: int = 0
|
|
88
|
+
error_count: int = 0
|
|
89
|
+
|
|
90
|
+
@property
|
|
91
|
+
def duration_seconds(self) -> float | None:
|
|
92
|
+
if self.started_at and self.ended_at:
|
|
93
|
+
return (self.ended_at - self.started_at).total_seconds()
|
|
94
|
+
return None
|
|
95
|
+
|
|
96
|
+
@property
|
|
97
|
+
def effective_status(self) -> str:
|
|
98
|
+
"""Return 'stale' for zombie sessions whose process was killed."""
|
|
99
|
+
if self.status != "active":
|
|
100
|
+
return self.status
|
|
101
|
+
from tokenjam.utils.time_parse import utcnow
|
|
102
|
+
last_activity = self.ended_at or self.started_at
|
|
103
|
+
if last_activity and (utcnow() - last_activity) > SESSION_STALE_THRESHOLD:
|
|
104
|
+
return "stale"
|
|
105
|
+
return "active"
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
@dataclass
|
|
109
|
+
class AgentRecord:
|
|
110
|
+
agent_id: str
|
|
111
|
+
first_seen: datetime
|
|
112
|
+
last_seen: datetime
|
|
113
|
+
name: str | None = None
|
|
114
|
+
version: str | None = None
|
|
115
|
+
provider: str | None = None
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
@dataclass
|
|
119
|
+
class Alert:
|
|
120
|
+
alert_id: str
|
|
121
|
+
fired_at: datetime
|
|
122
|
+
type: AlertType
|
|
123
|
+
severity: Severity
|
|
124
|
+
title: str
|
|
125
|
+
detail: dict[str, Any]
|
|
126
|
+
agent_id: str | None = None
|
|
127
|
+
session_id: str | None = None
|
|
128
|
+
span_id: str | None = None
|
|
129
|
+
acknowledged: bool = False
|
|
130
|
+
suppressed: bool = False
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
@dataclass
|
|
134
|
+
class DriftBaseline:
|
|
135
|
+
agent_id: str
|
|
136
|
+
sessions_sampled: int
|
|
137
|
+
computed_at: datetime
|
|
138
|
+
avg_input_tokens: float | None = None
|
|
139
|
+
stddev_input_tokens: float | None = None
|
|
140
|
+
avg_output_tokens: float | None = None
|
|
141
|
+
stddev_output_tokens: float | None = None
|
|
142
|
+
avg_session_duration_s: float | None = None
|
|
143
|
+
stddev_session_duration: float | None = None
|
|
144
|
+
avg_tool_call_count: float | None = None
|
|
145
|
+
stddev_tool_call_count: float | None = None
|
|
146
|
+
common_tool_sequences: list | None = None
|
|
147
|
+
output_schema_inferred: dict | None = None
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
@dataclass
|
|
151
|
+
class DriftViolation:
|
|
152
|
+
dimension: str
|
|
153
|
+
z_score: float | None = None
|
|
154
|
+
expected: str | None = None
|
|
155
|
+
observed: str | None = None
|
|
156
|
+
detail: str | None = None
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
@dataclass
|
|
160
|
+
class DriftResult:
|
|
161
|
+
violations: list[DriftViolation]
|
|
162
|
+
drifted: bool
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
@dataclass
|
|
166
|
+
class SchemaValidationResult:
|
|
167
|
+
validation_id: str
|
|
168
|
+
span_id: str
|
|
169
|
+
validated_at: datetime
|
|
170
|
+
passed: bool
|
|
171
|
+
errors: list[str] = field(default_factory=list)
|
|
172
|
+
agent_id: str | None = None
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
@dataclass
|
|
176
|
+
class TraceRecord:
|
|
177
|
+
trace_id: str
|
|
178
|
+
agent_id: str | None
|
|
179
|
+
name: str
|
|
180
|
+
start_time: datetime
|
|
181
|
+
duration_ms: float | None = None
|
|
182
|
+
cost_usd: float | None = None
|
|
183
|
+
status_code: str = "ok"
|
|
184
|
+
span_count: int = 0
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
@dataclass
|
|
188
|
+
class CostRow:
|
|
189
|
+
group: str
|
|
190
|
+
agent_id: str | None = None
|
|
191
|
+
model: str | None = None
|
|
192
|
+
input_tokens: int = 0
|
|
193
|
+
output_tokens: int = 0
|
|
194
|
+
cost_usd: float = 0.0
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
# -- Filter dataclasses used by StorageBackend --
|
|
198
|
+
|
|
199
|
+
@dataclass
|
|
200
|
+
class TraceFilters:
|
|
201
|
+
agent_id: str | None = None
|
|
202
|
+
since: datetime | None = None
|
|
203
|
+
until: datetime | None = None
|
|
204
|
+
span_name: str | None = None
|
|
205
|
+
status: str | None = None
|
|
206
|
+
limit: int = 50
|
|
207
|
+
offset: int = 0
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
@dataclass
|
|
211
|
+
class CostFilters:
|
|
212
|
+
agent_id: str | None = None
|
|
213
|
+
since: datetime | None = None
|
|
214
|
+
until: datetime | None = None
|
|
215
|
+
group_by: str = "day" # agent | model | day | tool
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
@dataclass
|
|
219
|
+
class AlertFilters:
|
|
220
|
+
agent_id: str | None = None
|
|
221
|
+
since: datetime | None = None
|
|
222
|
+
severity: Severity | None = None
|
|
223
|
+
type: AlertType | None = None
|
|
224
|
+
unread: bool = False
|
|
225
|
+
limit: int = 100
|
tokenjam/core/pricing.py
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
import sys
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
from functools import lru_cache
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
if sys.version_info >= (3, 11):
|
|
8
|
+
import tomllib
|
|
9
|
+
else:
|
|
10
|
+
import tomli as tomllib # type: ignore[no-redef]
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
PRICING_FILE = Path(__file__).parent.parent / "pricing" / "models.toml"
|
|
14
|
+
|
|
15
|
+
# Default rate used when a model is not in the pricing table.
|
|
16
|
+
# 0.50 per MTok input, 2.00 per MTok output — conservative mid-range estimate.
|
|
17
|
+
DEFAULT_INPUT_PER_MTOK = 0.50
|
|
18
|
+
DEFAULT_OUTPUT_PER_MTOK = 2.00
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@dataclass(frozen=True)
|
|
22
|
+
class ModelRates:
|
|
23
|
+
input_per_mtok: float
|
|
24
|
+
output_per_mtok: float
|
|
25
|
+
cache_read_per_mtok: float = 0.0
|
|
26
|
+
cache_write_per_mtok: float = 0.0
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@lru_cache(maxsize=1)
|
|
30
|
+
def load_pricing_table() -> dict[str, dict[str, ModelRates]]:
|
|
31
|
+
"""
|
|
32
|
+
Load pricing/models.toml and return a nested dict:
|
|
33
|
+
{ provider: { model_name: ModelRates } }
|
|
34
|
+
Cached after first load — restart process to pick up changes.
|
|
35
|
+
"""
|
|
36
|
+
with open(PRICING_FILE, "rb") as f:
|
|
37
|
+
raw = tomllib.load(f)
|
|
38
|
+
result: dict[str, dict[str, ModelRates]] = {}
|
|
39
|
+
for provider, models in raw.items():
|
|
40
|
+
result[provider] = {}
|
|
41
|
+
for model_name, rates in models.items():
|
|
42
|
+
result[provider][model_name] = ModelRates(
|
|
43
|
+
input_per_mtok=rates.get("input_per_mtok", DEFAULT_INPUT_PER_MTOK),
|
|
44
|
+
output_per_mtok=rates.get("output_per_mtok", DEFAULT_OUTPUT_PER_MTOK),
|
|
45
|
+
cache_read_per_mtok=rates.get("cache_read_per_mtok", 0.0),
|
|
46
|
+
cache_write_per_mtok=rates.get("cache_write_per_mtok", 0.0),
|
|
47
|
+
)
|
|
48
|
+
return result
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def get_rates(provider: str, model: str) -> ModelRates | None:
|
|
52
|
+
"""Return ModelRates for the given provider/model, or None if not found."""
|
|
53
|
+
table = load_pricing_table()
|
|
54
|
+
return table.get(provider, {}).get(model)
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
"""Storage retention cleanup. Deletes spans older than config.retention_days."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from datetime import timedelta
|
|
5
|
+
from typing import TYPE_CHECKING
|
|
6
|
+
|
|
7
|
+
from tokenjam.utils.time_parse import utcnow
|
|
8
|
+
|
|
9
|
+
if TYPE_CHECKING:
|
|
10
|
+
from tokenjam.core.config import StorageConfig
|
|
11
|
+
from tokenjam.core.db import StorageBackend
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def run_retention_cleanup(db: StorageBackend, config: StorageConfig) -> int:
|
|
15
|
+
"""
|
|
16
|
+
Delete spans older than config.retention_days.
|
|
17
|
+
Returns the number of spans deleted.
|
|
18
|
+
Called by the apscheduler background job in tj serve.
|
|
19
|
+
"""
|
|
20
|
+
cutoff = utcnow() - timedelta(days=config.retention_days)
|
|
21
|
+
return db.delete_spans_before(cutoff)
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import logging
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import TYPE_CHECKING, Any
|
|
7
|
+
|
|
8
|
+
import jsonschema
|
|
9
|
+
from genson import SchemaBuilder
|
|
10
|
+
|
|
11
|
+
from tokenjam.core.models import SchemaValidationResult, AlertType, Severity
|
|
12
|
+
from tokenjam.otel.semconv import GenAIAttributes
|
|
13
|
+
from tokenjam.utils.ids import new_uuid
|
|
14
|
+
from tokenjam.utils.time_parse import utcnow
|
|
15
|
+
|
|
16
|
+
if TYPE_CHECKING:
|
|
17
|
+
from tokenjam.core.db import StorageBackend
|
|
18
|
+
from tokenjam.core.models import NormalizedSpan
|
|
19
|
+
from tokenjam.core.alerts import AlertEngine
|
|
20
|
+
from tokenjam.core.config import TjConfig
|
|
21
|
+
|
|
22
|
+
logger = logging.getLogger("tokenjam.schema")
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class SchemaValidator:
|
|
26
|
+
"""
|
|
27
|
+
Post-ingest hook. Called by IngestPipeline after each span.
|
|
28
|
+
|
|
29
|
+
Only runs when ALL of these are true:
|
|
30
|
+
1. span.name == "gen_ai.tool.call"
|
|
31
|
+
2. span has gen_ai.tool.output in its attributes (capture.tool_outputs = true)
|
|
32
|
+
3. The agent has an output_schema configured, OR an inferred schema exists in baseline
|
|
33
|
+
|
|
34
|
+
If output_schema is a file path in config, load the JSON Schema from that file.
|
|
35
|
+
If no schema is declared, use inferred schema from drift baseline.
|
|
36
|
+
If neither exists yet, silently skip.
|
|
37
|
+
"""
|
|
38
|
+
|
|
39
|
+
def __init__(self, db: StorageBackend, alert_engine: AlertEngine, config: TjConfig):
|
|
40
|
+
self.db = db
|
|
41
|
+
self.alert_engine = alert_engine
|
|
42
|
+
self.config = config
|
|
43
|
+
self._schema_cache: dict[str, dict] = {}
|
|
44
|
+
|
|
45
|
+
def validate(self, span: NormalizedSpan) -> None:
|
|
46
|
+
"""
|
|
47
|
+
Validate tool output against schema if applicable.
|
|
48
|
+
Persists a SchemaValidationResult to DB regardless of pass/fail.
|
|
49
|
+
Fires SCHEMA_VIOLATION alert on failure.
|
|
50
|
+
"""
|
|
51
|
+
if span.name != GenAIAttributes.SPAN_TOOL_CALL:
|
|
52
|
+
return
|
|
53
|
+
|
|
54
|
+
tool_output = span.attributes.get(GenAIAttributes.TOOL_OUTPUT)
|
|
55
|
+
if tool_output is None:
|
|
56
|
+
return
|
|
57
|
+
|
|
58
|
+
schema = self._get_schema(span.agent_id)
|
|
59
|
+
if schema is None:
|
|
60
|
+
return
|
|
61
|
+
|
|
62
|
+
# Parse tool_output if it's a string
|
|
63
|
+
parsed_output = tool_output
|
|
64
|
+
if isinstance(parsed_output, str):
|
|
65
|
+
try:
|
|
66
|
+
parsed_output = json.loads(parsed_output)
|
|
67
|
+
except (json.JSONDecodeError, TypeError):
|
|
68
|
+
pass # Validate the raw string against the schema
|
|
69
|
+
|
|
70
|
+
errors = list(jsonschema.Draft7Validator(schema).iter_errors(parsed_output))
|
|
71
|
+
|
|
72
|
+
result = SchemaValidationResult(
|
|
73
|
+
validation_id=new_uuid(),
|
|
74
|
+
span_id=span.span_id,
|
|
75
|
+
agent_id=span.agent_id,
|
|
76
|
+
validated_at=utcnow(),
|
|
77
|
+
passed=len(errors) == 0,
|
|
78
|
+
errors=[e.message for e in errors],
|
|
79
|
+
)
|
|
80
|
+
self.db.insert_validation(result)
|
|
81
|
+
|
|
82
|
+
if not result.passed:
|
|
83
|
+
self.alert_engine.fire(
|
|
84
|
+
alert_type=AlertType.SCHEMA_VIOLATION,
|
|
85
|
+
span_or_session=span,
|
|
86
|
+
detail={"errors": result.errors, "tool_name": span.tool_name},
|
|
87
|
+
severity=Severity.WARNING,
|
|
88
|
+
)
|
|
89
|
+
|
|
90
|
+
def _get_schema(self, agent_id: str | None) -> dict | None:
|
|
91
|
+
"""
|
|
92
|
+
Return the JSON Schema for this agent, or None if unavailable.
|
|
93
|
+
Priority: 1) declared schema file in config, 2) inferred schema in baseline.
|
|
94
|
+
Caches loaded schemas in-memory.
|
|
95
|
+
"""
|
|
96
|
+
if agent_id is None:
|
|
97
|
+
return None
|
|
98
|
+
|
|
99
|
+
if agent_id in self._schema_cache:
|
|
100
|
+
return self._schema_cache[agent_id]
|
|
101
|
+
|
|
102
|
+
# 1. Check declared schema in agent config
|
|
103
|
+
agent_cfg = self.config.agents.get(agent_id)
|
|
104
|
+
if agent_cfg and agent_cfg.output_schema:
|
|
105
|
+
schema = self._load_schema_file(agent_cfg.output_schema)
|
|
106
|
+
if schema is not None:
|
|
107
|
+
self._schema_cache[agent_id] = schema
|
|
108
|
+
return schema
|
|
109
|
+
|
|
110
|
+
# 2. Fall back to inferred schema from drift baseline
|
|
111
|
+
baseline = self.db.get_baseline(agent_id)
|
|
112
|
+
if baseline and baseline.output_schema_inferred:
|
|
113
|
+
self._schema_cache[agent_id] = baseline.output_schema_inferred
|
|
114
|
+
return baseline.output_schema_inferred
|
|
115
|
+
|
|
116
|
+
return None
|
|
117
|
+
|
|
118
|
+
def _load_schema_file(self, path_str: str) -> dict | None:
|
|
119
|
+
"""Load a JSON Schema from a file path.
|
|
120
|
+
|
|
121
|
+
Relative paths are resolved relative to the config file's parent directory
|
|
122
|
+
(config.config_path). Falls back to CWD-relative resolution when no config
|
|
123
|
+
file path is available (e.g. in tests with a synthetic TjConfig).
|
|
124
|
+
"""
|
|
125
|
+
path = Path(path_str)
|
|
126
|
+
if not path.is_absolute():
|
|
127
|
+
config_file_path = getattr(self.config, "config_path", None)
|
|
128
|
+
if config_file_path is not None:
|
|
129
|
+
path = Path(config_file_path).parent / path_str
|
|
130
|
+
if not path.exists():
|
|
131
|
+
logger.warning("Schema file not found: %s", path)
|
|
132
|
+
return None
|
|
133
|
+
try:
|
|
134
|
+
with open(path, "r") as f:
|
|
135
|
+
return json.load(f)
|
|
136
|
+
except (json.JSONDecodeError, OSError) as exc:
|
|
137
|
+
logger.warning("Failed to load schema file %s: %s", path, exc)
|
|
138
|
+
return None
|
|
139
|
+
|
|
140
|
+
def infer_schema_from_outputs(self, tool_outputs: list[Any]) -> dict | None:
|
|
141
|
+
"""
|
|
142
|
+
Use genson to infer a JSON Schema from a list of tool outputs.
|
|
143
|
+
Returns None if the list is empty.
|
|
144
|
+
"""
|
|
145
|
+
if not tool_outputs:
|
|
146
|
+
return None
|
|
147
|
+
|
|
148
|
+
builder = SchemaBuilder()
|
|
149
|
+
for output in tool_outputs:
|
|
150
|
+
if isinstance(output, str):
|
|
151
|
+
try:
|
|
152
|
+
output = json.loads(output)
|
|
153
|
+
except (json.JSONDecodeError, TypeError):
|
|
154
|
+
pass
|
|
155
|
+
builder.add_object(output)
|
|
156
|
+
return builder.to_schema()
|
|
File without changes
|
tokenjam/demo/env.py
ADDED
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Shared demo environment: wires IngestPipeline + InMemoryBackend directly,
|
|
3
|
+
bypassing the SDK/OTel TracerProvider so scenarios have zero setup friction.
|
|
4
|
+
"""
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
from dataclasses import dataclass
|
|
8
|
+
from typing import TYPE_CHECKING
|
|
9
|
+
|
|
10
|
+
if TYPE_CHECKING:
|
|
11
|
+
from tokenjam.core.config import AgentConfig
|
|
12
|
+
from tokenjam.core.models import NormalizedSpan
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@dataclass
|
|
16
|
+
class DemoResult:
|
|
17
|
+
agent_id: str
|
|
18
|
+
span_count: int
|
|
19
|
+
alert_count: int
|
|
20
|
+
alert_types: list[str]
|
|
21
|
+
total_cost_usd: float
|
|
22
|
+
trace_count: int
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class DemoEnvironment:
|
|
26
|
+
"""
|
|
27
|
+
Self-contained observability stack for demo scenarios.
|
|
28
|
+
No API keys, no OTel global state, no config files needed.
|
|
29
|
+
Alerts are silenced (no stdout/file channels) so scenarios run cleanly.
|
|
30
|
+
"""
|
|
31
|
+
|
|
32
|
+
def __init__(
|
|
33
|
+
self,
|
|
34
|
+
agent_configs: "dict[str, AgentConfig] | None" = None,
|
|
35
|
+
) -> None:
|
|
36
|
+
from tokenjam.core.alerts import AlertEngine
|
|
37
|
+
from tokenjam.core.config import AlertsConfig, TjConfig, SecurityConfig
|
|
38
|
+
from tokenjam.core.cost import CostEngine
|
|
39
|
+
from tokenjam.core.db import InMemoryBackend
|
|
40
|
+
from tokenjam.core.drift import DriftDetector
|
|
41
|
+
from tokenjam.core.ingest import IngestPipeline
|
|
42
|
+
|
|
43
|
+
self.db = InMemoryBackend()
|
|
44
|
+
|
|
45
|
+
self.config = TjConfig(
|
|
46
|
+
version="1",
|
|
47
|
+
security=SecurityConfig(ingest_secret="demo"),
|
|
48
|
+
alerts=AlertsConfig(channels=[], cooldown_seconds=0),
|
|
49
|
+
agents=agent_configs or {},
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
cost_engine = CostEngine(db=self.db)
|
|
53
|
+
alert_engine = AlertEngine(db=self.db, config=self.config)
|
|
54
|
+
drift_detector = DriftDetector(
|
|
55
|
+
db=self.db, alert_engine=alert_engine, config=self.config
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
self.pipeline = IngestPipeline(
|
|
59
|
+
db=self.db,
|
|
60
|
+
config=self.config,
|
|
61
|
+
cost_engine=cost_engine,
|
|
62
|
+
alert_engine=alert_engine,
|
|
63
|
+
drift_detector=drift_detector,
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
def process(self, span: "NormalizedSpan") -> None:
|
|
67
|
+
self.pipeline.process(span)
|
|
68
|
+
|
|
69
|
+
def get_alerts(self) -> list:
|
|
70
|
+
from tokenjam.core.models import AlertFilters
|
|
71
|
+
return self.db.get_alerts(AlertFilters(limit=1000))
|
|
72
|
+
|
|
73
|
+
def total_cost_usd(self) -> float:
|
|
74
|
+
row = self.db.conn.execute(
|
|
75
|
+
"SELECT COALESCE(SUM(cost_usd), 0.0) FROM spans"
|
|
76
|
+
).fetchone()
|
|
77
|
+
return float(row[0]) if row else 0.0
|
|
78
|
+
|
|
79
|
+
def span_count(self) -> int:
|
|
80
|
+
row = self.db.conn.execute("SELECT COUNT(*) FROM spans").fetchone()
|
|
81
|
+
return int(row[0]) if row else 0
|
|
82
|
+
|
|
83
|
+
def trace_count(self) -> int:
|
|
84
|
+
from tokenjam.core.models import TraceFilters
|
|
85
|
+
return len(self.db.get_traces(TraceFilters()))
|
|
86
|
+
|
|
87
|
+
def build_result(self, agent_id: str) -> DemoResult:
|
|
88
|
+
alerts = self.get_alerts()
|
|
89
|
+
return DemoResult(
|
|
90
|
+
agent_id=agent_id,
|
|
91
|
+
span_count=self.span_count(),
|
|
92
|
+
alert_count=len(alerts),
|
|
93
|
+
alert_types=[a.type.value for a in alerts],
|
|
94
|
+
total_cost_usd=self.total_cost_usd(),
|
|
95
|
+
trace_count=self.trace_count(),
|
|
96
|
+
)
|
tokenjam/mcp/__init__.py
ADDED
|
File without changes
|