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