htmlgraph 0.20.1__py3-none-any.whl → 0.27.5__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.
- htmlgraph/.htmlgraph/.session-warning-state.json +6 -0
- htmlgraph/.htmlgraph/agents.json +72 -0
- htmlgraph/.htmlgraph/htmlgraph.db +0 -0
- htmlgraph/__init__.py +51 -1
- htmlgraph/__init__.pyi +123 -0
- htmlgraph/agent_detection.py +26 -10
- htmlgraph/agent_registry.py +2 -1
- htmlgraph/analytics/__init__.py +8 -1
- htmlgraph/analytics/cli.py +86 -20
- htmlgraph/analytics/cost_analyzer.py +391 -0
- htmlgraph/analytics/cost_monitor.py +664 -0
- htmlgraph/analytics/cost_reporter.py +675 -0
- htmlgraph/analytics/cross_session.py +617 -0
- htmlgraph/analytics/dependency.py +10 -6
- htmlgraph/analytics/pattern_learning.py +771 -0
- htmlgraph/analytics/session_graph.py +707 -0
- htmlgraph/analytics/strategic/__init__.py +80 -0
- htmlgraph/analytics/strategic/cost_optimizer.py +611 -0
- htmlgraph/analytics/strategic/pattern_detector.py +876 -0
- htmlgraph/analytics/strategic/preference_manager.py +709 -0
- htmlgraph/analytics/strategic/suggestion_engine.py +747 -0
- htmlgraph/analytics/work_type.py +67 -27
- htmlgraph/analytics_index.py +53 -20
- htmlgraph/api/__init__.py +3 -0
- htmlgraph/api/cost_alerts_websocket.py +416 -0
- htmlgraph/api/main.py +2498 -0
- htmlgraph/api/static/htmx.min.js +1 -0
- htmlgraph/api/static/style-redesign.css +1344 -0
- htmlgraph/api/static/style.css +1079 -0
- htmlgraph/api/templates/dashboard-redesign.html +1366 -0
- htmlgraph/api/templates/dashboard.html +794 -0
- htmlgraph/api/templates/partials/activity-feed-hierarchical.html +326 -0
- htmlgraph/api/templates/partials/activity-feed.html +1100 -0
- htmlgraph/api/templates/partials/agents-redesign.html +317 -0
- htmlgraph/api/templates/partials/agents.html +317 -0
- htmlgraph/api/templates/partials/event-traces.html +373 -0
- htmlgraph/api/templates/partials/features-kanban-redesign.html +509 -0
- htmlgraph/api/templates/partials/features.html +578 -0
- htmlgraph/api/templates/partials/metrics-redesign.html +346 -0
- htmlgraph/api/templates/partials/metrics.html +346 -0
- htmlgraph/api/templates/partials/orchestration-redesign.html +443 -0
- htmlgraph/api/templates/partials/orchestration.html +198 -0
- htmlgraph/api/templates/partials/spawners.html +375 -0
- htmlgraph/api/templates/partials/work-items.html +613 -0
- htmlgraph/api/websocket.py +538 -0
- htmlgraph/archive/__init__.py +24 -0
- htmlgraph/archive/bloom.py +234 -0
- htmlgraph/archive/fts.py +297 -0
- htmlgraph/archive/manager.py +583 -0
- htmlgraph/archive/search.py +244 -0
- htmlgraph/atomic_ops.py +560 -0
- htmlgraph/attribute_index.py +2 -1
- htmlgraph/bounded_paths.py +539 -0
- htmlgraph/builders/base.py +57 -2
- htmlgraph/builders/bug.py +19 -3
- htmlgraph/builders/chore.py +19 -3
- htmlgraph/builders/epic.py +19 -3
- htmlgraph/builders/feature.py +27 -3
- htmlgraph/builders/insight.py +2 -1
- htmlgraph/builders/metric.py +2 -1
- htmlgraph/builders/pattern.py +2 -1
- htmlgraph/builders/phase.py +19 -3
- htmlgraph/builders/spike.py +29 -3
- htmlgraph/builders/track.py +42 -1
- htmlgraph/cigs/__init__.py +81 -0
- htmlgraph/cigs/autonomy.py +385 -0
- htmlgraph/cigs/cost.py +475 -0
- htmlgraph/cigs/messages_basic.py +472 -0
- htmlgraph/cigs/messaging.py +365 -0
- htmlgraph/cigs/models.py +771 -0
- htmlgraph/cigs/pattern_storage.py +427 -0
- htmlgraph/cigs/patterns.py +503 -0
- htmlgraph/cigs/posttool_analyzer.py +234 -0
- htmlgraph/cigs/reporter.py +818 -0
- htmlgraph/cigs/tracker.py +317 -0
- htmlgraph/cli/.htmlgraph/.session-warning-state.json +6 -0
- htmlgraph/cli/.htmlgraph/agents.json +72 -0
- htmlgraph/cli/.htmlgraph/htmlgraph.db +0 -0
- htmlgraph/cli/__init__.py +42 -0
- htmlgraph/cli/__main__.py +6 -0
- htmlgraph/cli/analytics.py +1424 -0
- htmlgraph/cli/base.py +685 -0
- htmlgraph/cli/constants.py +206 -0
- htmlgraph/cli/core.py +954 -0
- htmlgraph/cli/main.py +147 -0
- htmlgraph/cli/models.py +475 -0
- htmlgraph/cli/templates/__init__.py +1 -0
- htmlgraph/cli/templates/cost_dashboard.py +399 -0
- htmlgraph/cli/work/__init__.py +239 -0
- htmlgraph/cli/work/browse.py +115 -0
- htmlgraph/cli/work/features.py +568 -0
- htmlgraph/cli/work/orchestration.py +676 -0
- htmlgraph/cli/work/report.py +728 -0
- htmlgraph/cli/work/sessions.py +466 -0
- htmlgraph/cli/work/snapshot.py +559 -0
- htmlgraph/cli/work/tracks.py +486 -0
- htmlgraph/cli_commands/__init__.py +1 -0
- htmlgraph/cli_commands/feature.py +195 -0
- htmlgraph/cli_framework.py +115 -0
- htmlgraph/collections/__init__.py +2 -0
- htmlgraph/collections/base.py +197 -14
- htmlgraph/collections/bug.py +2 -1
- htmlgraph/collections/chore.py +2 -1
- htmlgraph/collections/epic.py +2 -1
- htmlgraph/collections/feature.py +2 -1
- htmlgraph/collections/insight.py +2 -1
- htmlgraph/collections/metric.py +2 -1
- htmlgraph/collections/pattern.py +2 -1
- htmlgraph/collections/phase.py +2 -1
- htmlgraph/collections/session.py +194 -0
- htmlgraph/collections/spike.py +13 -2
- htmlgraph/collections/task_delegation.py +241 -0
- htmlgraph/collections/todo.py +14 -1
- htmlgraph/collections/traces.py +487 -0
- htmlgraph/config/cost_models.json +56 -0
- htmlgraph/config.py +190 -0
- htmlgraph/context_analytics.py +2 -1
- htmlgraph/converter.py +116 -7
- htmlgraph/cost_analysis/__init__.py +5 -0
- htmlgraph/cost_analysis/analyzer.py +438 -0
- htmlgraph/dashboard.html +2246 -248
- htmlgraph/dashboard.html.backup +6592 -0
- htmlgraph/dashboard.html.bak +7181 -0
- htmlgraph/dashboard.html.bak2 +7231 -0
- htmlgraph/dashboard.html.bak3 +7232 -0
- htmlgraph/db/__init__.py +38 -0
- htmlgraph/db/queries.py +790 -0
- htmlgraph/db/schema.py +1788 -0
- htmlgraph/decorators.py +317 -0
- htmlgraph/dependency_models.py +2 -1
- htmlgraph/deploy.py +26 -27
- htmlgraph/docs/API_REFERENCE.md +841 -0
- htmlgraph/docs/HTTP_API.md +750 -0
- htmlgraph/docs/INTEGRATION_GUIDE.md +752 -0
- htmlgraph/docs/ORCHESTRATION_PATTERNS.md +717 -0
- htmlgraph/docs/README.md +532 -0
- htmlgraph/docs/__init__.py +77 -0
- htmlgraph/docs/docs_version.py +55 -0
- htmlgraph/docs/metadata.py +93 -0
- htmlgraph/docs/migrations.py +232 -0
- htmlgraph/docs/template_engine.py +143 -0
- htmlgraph/docs/templates/_sections/cli_reference.md.j2 +52 -0
- htmlgraph/docs/templates/_sections/core_concepts.md.j2 +29 -0
- htmlgraph/docs/templates/_sections/sdk_basics.md.j2 +69 -0
- htmlgraph/docs/templates/base_agents.md.j2 +78 -0
- htmlgraph/docs/templates/example_user_override.md.j2 +47 -0
- htmlgraph/docs/version_check.py +163 -0
- htmlgraph/edge_index.py +2 -1
- htmlgraph/error_handler.py +544 -0
- htmlgraph/event_log.py +86 -37
- htmlgraph/event_migration.py +2 -1
- htmlgraph/file_watcher.py +12 -8
- htmlgraph/find_api.py +2 -1
- htmlgraph/git_events.py +67 -9
- htmlgraph/hooks/.htmlgraph/.session-warning-state.json +6 -0
- htmlgraph/hooks/.htmlgraph/agents.json +72 -0
- htmlgraph/hooks/.htmlgraph/index.sqlite +0 -0
- htmlgraph/hooks/__init__.py +8 -0
- htmlgraph/hooks/bootstrap.py +169 -0
- htmlgraph/hooks/cigs_pretool_enforcer.py +354 -0
- htmlgraph/hooks/concurrent_sessions.py +208 -0
- htmlgraph/hooks/context.py +350 -0
- htmlgraph/hooks/drift_handler.py +525 -0
- htmlgraph/hooks/event_tracker.py +790 -99
- htmlgraph/hooks/git_commands.py +175 -0
- htmlgraph/hooks/installer.py +5 -1
- htmlgraph/hooks/orchestrator.py +327 -76
- htmlgraph/hooks/orchestrator_reflector.py +31 -4
- htmlgraph/hooks/post_tool_use_failure.py +32 -7
- htmlgraph/hooks/post_tool_use_handler.py +257 -0
- htmlgraph/hooks/posttooluse.py +92 -19
- htmlgraph/hooks/pretooluse.py +527 -7
- htmlgraph/hooks/prompt_analyzer.py +637 -0
- htmlgraph/hooks/session_handler.py +668 -0
- htmlgraph/hooks/session_summary.py +395 -0
- htmlgraph/hooks/state_manager.py +504 -0
- htmlgraph/hooks/subagent_detection.py +202 -0
- htmlgraph/hooks/subagent_stop.py +369 -0
- htmlgraph/hooks/task_enforcer.py +99 -4
- htmlgraph/hooks/validator.py +212 -91
- htmlgraph/ids.py +2 -1
- htmlgraph/learning.py +125 -100
- htmlgraph/mcp_server.py +2 -1
- htmlgraph/models.py +217 -18
- htmlgraph/operations/README.md +62 -0
- htmlgraph/operations/__init__.py +79 -0
- htmlgraph/operations/analytics.py +339 -0
- htmlgraph/operations/bootstrap.py +289 -0
- htmlgraph/operations/events.py +244 -0
- htmlgraph/operations/fastapi_server.py +231 -0
- htmlgraph/operations/hooks.py +350 -0
- htmlgraph/operations/initialization.py +597 -0
- htmlgraph/operations/initialization.py.backup +228 -0
- htmlgraph/operations/server.py +303 -0
- htmlgraph/orchestration/__init__.py +58 -0
- htmlgraph/orchestration/claude_launcher.py +179 -0
- htmlgraph/orchestration/command_builder.py +72 -0
- htmlgraph/orchestration/headless_spawner.py +281 -0
- htmlgraph/orchestration/live_events.py +377 -0
- htmlgraph/orchestration/model_selection.py +327 -0
- htmlgraph/orchestration/plugin_manager.py +140 -0
- htmlgraph/orchestration/prompts.py +137 -0
- htmlgraph/orchestration/spawner_event_tracker.py +383 -0
- htmlgraph/orchestration/spawners/__init__.py +16 -0
- htmlgraph/orchestration/spawners/base.py +194 -0
- htmlgraph/orchestration/spawners/claude.py +173 -0
- htmlgraph/orchestration/spawners/codex.py +435 -0
- htmlgraph/orchestration/spawners/copilot.py +294 -0
- htmlgraph/orchestration/spawners/gemini.py +471 -0
- htmlgraph/orchestration/subprocess_runner.py +36 -0
- htmlgraph/{orchestration.py → orchestration/task_coordination.py} +16 -8
- htmlgraph/orchestration.md +563 -0
- htmlgraph/orchestrator-system-prompt-optimized.txt +863 -0
- htmlgraph/orchestrator.py +2 -1
- htmlgraph/orchestrator_config.py +357 -0
- htmlgraph/orchestrator_mode.py +115 -4
- htmlgraph/parallel.py +2 -1
- htmlgraph/parser.py +86 -6
- htmlgraph/path_query.py +608 -0
- htmlgraph/pattern_matcher.py +636 -0
- htmlgraph/pydantic_models.py +476 -0
- htmlgraph/quality_gates.py +350 -0
- htmlgraph/query_builder.py +2 -1
- htmlgraph/query_composer.py +509 -0
- htmlgraph/reflection.py +443 -0
- htmlgraph/refs.py +344 -0
- htmlgraph/repo_hash.py +512 -0
- htmlgraph/repositories/__init__.py +292 -0
- htmlgraph/repositories/analytics_repository.py +455 -0
- htmlgraph/repositories/analytics_repository_standard.py +628 -0
- htmlgraph/repositories/feature_repository.py +581 -0
- htmlgraph/repositories/feature_repository_htmlfile.py +668 -0
- htmlgraph/repositories/feature_repository_memory.py +607 -0
- htmlgraph/repositories/feature_repository_sqlite.py +858 -0
- htmlgraph/repositories/filter_service.py +620 -0
- htmlgraph/repositories/filter_service_standard.py +445 -0
- htmlgraph/repositories/shared_cache.py +621 -0
- htmlgraph/repositories/shared_cache_memory.py +395 -0
- htmlgraph/repositories/track_repository.py +552 -0
- htmlgraph/repositories/track_repository_htmlfile.py +619 -0
- htmlgraph/repositories/track_repository_memory.py +508 -0
- htmlgraph/repositories/track_repository_sqlite.py +711 -0
- htmlgraph/sdk/__init__.py +398 -0
- htmlgraph/sdk/__init__.pyi +14 -0
- htmlgraph/sdk/analytics/__init__.py +19 -0
- htmlgraph/sdk/analytics/engine.py +155 -0
- htmlgraph/sdk/analytics/helpers.py +178 -0
- htmlgraph/sdk/analytics/registry.py +109 -0
- htmlgraph/sdk/base.py +484 -0
- htmlgraph/sdk/constants.py +216 -0
- htmlgraph/sdk/core.pyi +308 -0
- htmlgraph/sdk/discovery.py +120 -0
- htmlgraph/sdk/help/__init__.py +12 -0
- htmlgraph/sdk/help/mixin.py +699 -0
- htmlgraph/sdk/mixins/__init__.py +15 -0
- htmlgraph/sdk/mixins/attribution.py +113 -0
- htmlgraph/sdk/mixins/mixin.py +410 -0
- htmlgraph/sdk/operations/__init__.py +12 -0
- htmlgraph/sdk/operations/mixin.py +427 -0
- htmlgraph/sdk/orchestration/__init__.py +17 -0
- htmlgraph/sdk/orchestration/coordinator.py +203 -0
- htmlgraph/sdk/orchestration/spawner.py +204 -0
- htmlgraph/sdk/planning/__init__.py +19 -0
- htmlgraph/sdk/planning/bottlenecks.py +93 -0
- htmlgraph/sdk/planning/mixin.py +211 -0
- htmlgraph/sdk/planning/parallel.py +186 -0
- htmlgraph/sdk/planning/queue.py +210 -0
- htmlgraph/sdk/planning/recommendations.py +87 -0
- htmlgraph/sdk/planning/smart_planning.py +319 -0
- htmlgraph/sdk/session/__init__.py +19 -0
- htmlgraph/sdk/session/continuity.py +57 -0
- htmlgraph/sdk/session/handoff.py +110 -0
- htmlgraph/sdk/session/info.py +309 -0
- htmlgraph/sdk/session/manager.py +103 -0
- htmlgraph/sdk/strategic/__init__.py +26 -0
- htmlgraph/sdk/strategic/mixin.py +563 -0
- htmlgraph/server.py +295 -107
- htmlgraph/session_hooks.py +300 -0
- htmlgraph/session_manager.py +285 -3
- htmlgraph/session_registry.py +587 -0
- htmlgraph/session_state.py +436 -0
- htmlgraph/session_warning.py +2 -1
- htmlgraph/sessions/__init__.py +23 -0
- htmlgraph/sessions/handoff.py +756 -0
- htmlgraph/system_prompts.py +450 -0
- htmlgraph/templates/orchestration-view.html +350 -0
- htmlgraph/track_builder.py +33 -1
- htmlgraph/track_manager.py +38 -0
- htmlgraph/transcript.py +18 -5
- htmlgraph/validation.py +115 -0
- htmlgraph/watch.py +2 -1
- htmlgraph/work_type_utils.py +2 -1
- {htmlgraph-0.20.1.data → htmlgraph-0.27.5.data}/data/htmlgraph/dashboard.html +2246 -248
- {htmlgraph-0.20.1.dist-info → htmlgraph-0.27.5.dist-info}/METADATA +95 -64
- htmlgraph-0.27.5.dist-info/RECORD +337 -0
- {htmlgraph-0.20.1.dist-info → htmlgraph-0.27.5.dist-info}/entry_points.txt +1 -1
- htmlgraph/cli.py +0 -4839
- htmlgraph/sdk.py +0 -2359
- htmlgraph-0.20.1.dist-info/RECORD +0 -118
- {htmlgraph-0.20.1.data → htmlgraph-0.27.5.data}/data/htmlgraph/styles.css +0 -0
- {htmlgraph-0.20.1.data → htmlgraph-0.27.5.data}/data/htmlgraph/templates/AGENTS.md.template +0 -0
- {htmlgraph-0.20.1.data → htmlgraph-0.27.5.data}/data/htmlgraph/templates/CLAUDE.md.template +0 -0
- {htmlgraph-0.20.1.data → htmlgraph-0.27.5.data}/data/htmlgraph/templates/GEMINI.md.template +0 -0
- {htmlgraph-0.20.1.dist-info → htmlgraph-0.27.5.dist-info}/WHEEL +0 -0
|
@@ -0,0 +1,664 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Real-Time Cost Monitoring & Alerts for HtmlGraph - Phase 3.1
|
|
3
|
+
|
|
4
|
+
Provides real-time token consumption tracking, cost calculation, and alert generation.
|
|
5
|
+
|
|
6
|
+
Features:
|
|
7
|
+
- Real-time token consumption tracking per session
|
|
8
|
+
- Cost calculation based on model rates from config
|
|
9
|
+
- Cost breakdown by model, agent, and tool type
|
|
10
|
+
- Alert generation with <1s latency via WebSocket
|
|
11
|
+
- Cost threshold detection (80% budget, trajectory overage, model overage)
|
|
12
|
+
- 5% tracking accuracy target
|
|
13
|
+
|
|
14
|
+
Architecture:
|
|
15
|
+
- CostMonitor: Core monitoring service
|
|
16
|
+
- CostAlert: Alert data model
|
|
17
|
+
- CostBreakdown: Cost analysis by dimension
|
|
18
|
+
- Integration with PostToolUse hook for token tracking
|
|
19
|
+
- WebSocket streaming for real-time alerts
|
|
20
|
+
|
|
21
|
+
Design Reference:
|
|
22
|
+
- Phase 3.1: Real-Time Cost Monitoring & Alerts
|
|
23
|
+
- WebSocket foundation from api/websocket.py
|
|
24
|
+
- CostCalculator from cigs/cost.py
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
import json
|
|
28
|
+
import logging
|
|
29
|
+
import sqlite3
|
|
30
|
+
import time
|
|
31
|
+
from dataclasses import asdict, dataclass, field
|
|
32
|
+
from datetime import datetime, timezone
|
|
33
|
+
from pathlib import Path
|
|
34
|
+
from typing import Any
|
|
35
|
+
|
|
36
|
+
logger = logging.getLogger(__name__)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
@dataclass
|
|
40
|
+
class TokenCost:
|
|
41
|
+
"""Token consumption record."""
|
|
42
|
+
|
|
43
|
+
timestamp: datetime
|
|
44
|
+
tool_name: str
|
|
45
|
+
model: str
|
|
46
|
+
input_tokens: int
|
|
47
|
+
output_tokens: int
|
|
48
|
+
total_tokens: int
|
|
49
|
+
cost_usd: float
|
|
50
|
+
session_id: str
|
|
51
|
+
event_id: str | None = None
|
|
52
|
+
agent_id: str | None = None
|
|
53
|
+
subagent_type: str | None = None
|
|
54
|
+
|
|
55
|
+
def to_dict(self) -> dict[str, Any]:
|
|
56
|
+
"""Convert to dictionary."""
|
|
57
|
+
return {
|
|
58
|
+
"timestamp": self.timestamp.isoformat(),
|
|
59
|
+
"tool_name": self.tool_name,
|
|
60
|
+
"model": self.model,
|
|
61
|
+
"input_tokens": self.input_tokens,
|
|
62
|
+
"output_tokens": self.output_tokens,
|
|
63
|
+
"total_tokens": self.total_tokens,
|
|
64
|
+
"cost_usd": self.cost_usd,
|
|
65
|
+
"session_id": self.session_id,
|
|
66
|
+
"event_id": self.event_id,
|
|
67
|
+
"agent_id": self.agent_id,
|
|
68
|
+
"subagent_type": self.subagent_type,
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
@dataclass
|
|
73
|
+
class CostAlert:
|
|
74
|
+
"""Cost alert data model."""
|
|
75
|
+
|
|
76
|
+
alert_id: str
|
|
77
|
+
alert_type: str # "budget_warning", "trajectory_overage", "model_overage", "breach"
|
|
78
|
+
session_id: str
|
|
79
|
+
timestamp: datetime
|
|
80
|
+
message: str
|
|
81
|
+
current_cost_usd: float
|
|
82
|
+
budget_usd: float | None = None
|
|
83
|
+
predicted_cost_usd: float | None = None
|
|
84
|
+
model: str | None = None
|
|
85
|
+
severity: str = "warning" # "info", "warning", "critical"
|
|
86
|
+
acknowledged: bool = False
|
|
87
|
+
|
|
88
|
+
def to_dict(self) -> dict[str, Any]:
|
|
89
|
+
"""Convert to dictionary."""
|
|
90
|
+
return {
|
|
91
|
+
"alert_id": self.alert_id,
|
|
92
|
+
"alert_type": self.alert_type,
|
|
93
|
+
"session_id": self.session_id,
|
|
94
|
+
"timestamp": self.timestamp.isoformat(),
|
|
95
|
+
"message": self.message,
|
|
96
|
+
"current_cost_usd": self.current_cost_usd,
|
|
97
|
+
"budget_usd": self.budget_usd,
|
|
98
|
+
"predicted_cost_usd": self.predicted_cost_usd,
|
|
99
|
+
"model": self.model,
|
|
100
|
+
"severity": self.severity,
|
|
101
|
+
"acknowledged": self.acknowledged,
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
@dataclass
|
|
106
|
+
class CostBreakdown:
|
|
107
|
+
"""Cost breakdown analysis by dimensions."""
|
|
108
|
+
|
|
109
|
+
by_model: dict[str, float] = field(default_factory=dict)
|
|
110
|
+
by_tool: dict[str, float] = field(default_factory=dict)
|
|
111
|
+
by_agent: dict[str, float] = field(default_factory=dict)
|
|
112
|
+
by_subagent_type: dict[str, float] = field(default_factory=dict)
|
|
113
|
+
total_cost_usd: float = 0.0
|
|
114
|
+
total_tokens: int = 0
|
|
115
|
+
session_count: int = 0
|
|
116
|
+
|
|
117
|
+
def to_dict(self) -> dict[str, Any]:
|
|
118
|
+
"""Convert to dictionary."""
|
|
119
|
+
return asdict(self)
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
class CostMonitor:
|
|
123
|
+
"""
|
|
124
|
+
Real-time cost monitoring service.
|
|
125
|
+
|
|
126
|
+
Tracks token consumption, calculates costs, and generates alerts.
|
|
127
|
+
"""
|
|
128
|
+
|
|
129
|
+
def __init__(self, db_path: str | None = None, config_path: str | None = None):
|
|
130
|
+
"""
|
|
131
|
+
Initialize CostMonitor.
|
|
132
|
+
|
|
133
|
+
Args:
|
|
134
|
+
db_path: Path to SQLite database
|
|
135
|
+
config_path: Path to cost_models.json configuration
|
|
136
|
+
"""
|
|
137
|
+
if db_path is None:
|
|
138
|
+
db_path = str(Path.home() / ".htmlgraph" / "htmlgraph.db")
|
|
139
|
+
|
|
140
|
+
self.db_path = db_path
|
|
141
|
+
self.config = self._load_config(config_path)
|
|
142
|
+
self.connection: sqlite3.Connection | None = None
|
|
143
|
+
self._alert_cache: dict[str, CostAlert] = {}
|
|
144
|
+
self._session_costs: dict[str, dict[str, Any]] = {}
|
|
145
|
+
|
|
146
|
+
def _load_config(self, config_path: str | None = None) -> dict[str, Any]:
|
|
147
|
+
"""Load cost model configuration."""
|
|
148
|
+
if config_path is None:
|
|
149
|
+
# Try to find config_path relative to this module
|
|
150
|
+
module_dir = Path(__file__).parent.parent
|
|
151
|
+
config_path = str(module_dir / "config" / "cost_models.json")
|
|
152
|
+
|
|
153
|
+
try:
|
|
154
|
+
with open(config_path) as f:
|
|
155
|
+
config: dict[str, Any] = json.load(f)
|
|
156
|
+
return config
|
|
157
|
+
except FileNotFoundError:
|
|
158
|
+
logger.warning(
|
|
159
|
+
f"Cost models config not found at {config_path}, using defaults"
|
|
160
|
+
)
|
|
161
|
+
return self._default_config()
|
|
162
|
+
|
|
163
|
+
def _default_config(self) -> dict[str, Any]:
|
|
164
|
+
"""Return default cost configuration."""
|
|
165
|
+
return {
|
|
166
|
+
"models": {
|
|
167
|
+
"claude-haiku-4-5-20251001": {
|
|
168
|
+
"name": "Claude Haiku",
|
|
169
|
+
"input_cost_per_mtok": 0.80,
|
|
170
|
+
"output_cost_per_mtok": 4.00,
|
|
171
|
+
},
|
|
172
|
+
"claude-sonnet-4-20250514": {
|
|
173
|
+
"name": "Claude Sonnet",
|
|
174
|
+
"input_cost_per_mtok": 3.00,
|
|
175
|
+
"output_cost_per_mtok": 15.00,
|
|
176
|
+
},
|
|
177
|
+
"claude-opus-4-1-20250805": {
|
|
178
|
+
"name": "Claude Opus",
|
|
179
|
+
"input_cost_per_mtok": 15.00,
|
|
180
|
+
"output_cost_per_mtok": 75.00,
|
|
181
|
+
},
|
|
182
|
+
},
|
|
183
|
+
"defaults": {
|
|
184
|
+
"input_cost_per_mtok": 2.00,
|
|
185
|
+
"output_cost_per_mtok": 10.00,
|
|
186
|
+
},
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
def connect(self) -> sqlite3.Connection:
|
|
190
|
+
"""Connect to database."""
|
|
191
|
+
if self.connection is None:
|
|
192
|
+
self.connection = sqlite3.connect(self.db_path)
|
|
193
|
+
self.connection.row_factory = sqlite3.Row
|
|
194
|
+
self.connection.execute("PRAGMA foreign_keys = ON")
|
|
195
|
+
return self.connection
|
|
196
|
+
|
|
197
|
+
def disconnect(self) -> None:
|
|
198
|
+
"""Close database connection."""
|
|
199
|
+
if self.connection:
|
|
200
|
+
self.connection.close()
|
|
201
|
+
self.connection = None
|
|
202
|
+
|
|
203
|
+
def calculate_cost_usd(
|
|
204
|
+
self, model: str, input_tokens: int, output_tokens: int
|
|
205
|
+
) -> float:
|
|
206
|
+
"""
|
|
207
|
+
Calculate cost in USD for token usage.
|
|
208
|
+
|
|
209
|
+
Args:
|
|
210
|
+
model: Model identifier (e.g., "claude-haiku-4-5-20251001")
|
|
211
|
+
input_tokens: Number of input tokens
|
|
212
|
+
output_tokens: Number of output tokens
|
|
213
|
+
|
|
214
|
+
Returns:
|
|
215
|
+
Cost in USD
|
|
216
|
+
"""
|
|
217
|
+
models = self.config.get("models", {})
|
|
218
|
+
defaults = self.config.get("defaults", {})
|
|
219
|
+
|
|
220
|
+
if model in models:
|
|
221
|
+
model_config = models[model]
|
|
222
|
+
else:
|
|
223
|
+
model_config = defaults
|
|
224
|
+
|
|
225
|
+
input_cost_per_mtok: float = model_config.get("input_cost_per_mtok", 0.0)
|
|
226
|
+
output_cost_per_mtok: float = model_config.get("output_cost_per_mtok", 0.0)
|
|
227
|
+
input_cost = (input_tokens / 1_000_000) * input_cost_per_mtok
|
|
228
|
+
output_cost = (output_tokens / 1_000_000) * output_cost_per_mtok
|
|
229
|
+
|
|
230
|
+
return float(input_cost + output_cost)
|
|
231
|
+
|
|
232
|
+
def track_token_usage(
|
|
233
|
+
self,
|
|
234
|
+
session_id: str,
|
|
235
|
+
event_id: str,
|
|
236
|
+
tool_name: str,
|
|
237
|
+
model: str,
|
|
238
|
+
input_tokens: int,
|
|
239
|
+
output_tokens: int,
|
|
240
|
+
agent_id: str | None = None,
|
|
241
|
+
subagent_type: str | None = None,
|
|
242
|
+
) -> TokenCost:
|
|
243
|
+
"""
|
|
244
|
+
Track token usage and record in database.
|
|
245
|
+
|
|
246
|
+
Args:
|
|
247
|
+
session_id: Session identifier
|
|
248
|
+
event_id: Event identifier
|
|
249
|
+
tool_name: Name of tool used
|
|
250
|
+
model: Model used for processing
|
|
251
|
+
input_tokens: Number of input tokens
|
|
252
|
+
output_tokens: Number of output tokens
|
|
253
|
+
agent_id: Optional agent identifier
|
|
254
|
+
subagent_type: Optional subagent type
|
|
255
|
+
|
|
256
|
+
Returns:
|
|
257
|
+
TokenCost record
|
|
258
|
+
"""
|
|
259
|
+
cost_usd = self.calculate_cost_usd(model, input_tokens, output_tokens)
|
|
260
|
+
timestamp = datetime.now(timezone.utc)
|
|
261
|
+
|
|
262
|
+
token_cost = TokenCost(
|
|
263
|
+
timestamp=timestamp,
|
|
264
|
+
tool_name=tool_name,
|
|
265
|
+
model=model,
|
|
266
|
+
input_tokens=input_tokens,
|
|
267
|
+
output_tokens=output_tokens,
|
|
268
|
+
total_tokens=input_tokens + output_tokens,
|
|
269
|
+
cost_usd=cost_usd,
|
|
270
|
+
session_id=session_id,
|
|
271
|
+
event_id=event_id,
|
|
272
|
+
agent_id=agent_id,
|
|
273
|
+
subagent_type=subagent_type,
|
|
274
|
+
)
|
|
275
|
+
|
|
276
|
+
# Record in database
|
|
277
|
+
self._store_token_cost(token_cost)
|
|
278
|
+
|
|
279
|
+
# Update session cost tracking
|
|
280
|
+
self._update_session_cost(session_id, token_cost)
|
|
281
|
+
|
|
282
|
+
# Check for alerts
|
|
283
|
+
self._check_alerts(session_id, token_cost)
|
|
284
|
+
|
|
285
|
+
return token_cost
|
|
286
|
+
|
|
287
|
+
def _store_token_cost(self, token_cost: TokenCost) -> None:
|
|
288
|
+
"""Store token cost in database."""
|
|
289
|
+
conn = self.connect()
|
|
290
|
+
cursor = conn.cursor()
|
|
291
|
+
|
|
292
|
+
cursor.execute(
|
|
293
|
+
"""
|
|
294
|
+
INSERT INTO cost_events (
|
|
295
|
+
event_id, session_id, tool_name, model,
|
|
296
|
+
input_tokens, output_tokens, total_tokens,
|
|
297
|
+
cost_usd, agent_id, subagent_type, timestamp
|
|
298
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
299
|
+
""",
|
|
300
|
+
(
|
|
301
|
+
token_cost.event_id,
|
|
302
|
+
token_cost.session_id,
|
|
303
|
+
token_cost.tool_name,
|
|
304
|
+
token_cost.model,
|
|
305
|
+
token_cost.input_tokens,
|
|
306
|
+
token_cost.output_tokens,
|
|
307
|
+
token_cost.total_tokens,
|
|
308
|
+
token_cost.cost_usd,
|
|
309
|
+
token_cost.agent_id,
|
|
310
|
+
token_cost.subagent_type,
|
|
311
|
+
token_cost.timestamp.isoformat(),
|
|
312
|
+
),
|
|
313
|
+
)
|
|
314
|
+
conn.commit()
|
|
315
|
+
|
|
316
|
+
def _update_session_cost(self, session_id: str, token_cost: TokenCost) -> None:
|
|
317
|
+
"""Update session cost tracking in memory and database."""
|
|
318
|
+
if session_id not in self._session_costs:
|
|
319
|
+
self._session_costs[session_id] = {
|
|
320
|
+
"total_cost_usd": 0.0,
|
|
321
|
+
"total_tokens": 0,
|
|
322
|
+
"by_model": {},
|
|
323
|
+
"by_tool": {},
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
session_data = self._session_costs[session_id]
|
|
327
|
+
session_data["total_cost_usd"] += token_cost.cost_usd
|
|
328
|
+
session_data["total_tokens"] += token_cost.total_tokens
|
|
329
|
+
|
|
330
|
+
# Track by model
|
|
331
|
+
model = token_cost.model
|
|
332
|
+
if model not in session_data["by_model"]:
|
|
333
|
+
session_data["by_model"][model] = 0.0
|
|
334
|
+
session_data["by_model"][model] += token_cost.cost_usd
|
|
335
|
+
|
|
336
|
+
# Track by tool
|
|
337
|
+
tool = token_cost.tool_name
|
|
338
|
+
if tool not in session_data["by_tool"]:
|
|
339
|
+
session_data["by_tool"][tool] = 0.0
|
|
340
|
+
session_data["by_tool"][tool] += token_cost.cost_usd
|
|
341
|
+
|
|
342
|
+
# Update database session record
|
|
343
|
+
conn = self.connect()
|
|
344
|
+
cursor = conn.cursor()
|
|
345
|
+
cursor.execute(
|
|
346
|
+
"""
|
|
347
|
+
UPDATE sessions
|
|
348
|
+
SET total_tokens_used = ?, metadata = ?
|
|
349
|
+
WHERE session_id = ?
|
|
350
|
+
""",
|
|
351
|
+
(
|
|
352
|
+
session_data["total_tokens"],
|
|
353
|
+
json.dumps(
|
|
354
|
+
{
|
|
355
|
+
"cost_breakdown": session_data,
|
|
356
|
+
"updated_at": datetime.now(timezone.utc).isoformat(),
|
|
357
|
+
}
|
|
358
|
+
),
|
|
359
|
+
session_id,
|
|
360
|
+
),
|
|
361
|
+
)
|
|
362
|
+
conn.commit()
|
|
363
|
+
|
|
364
|
+
def get_session_cost(self, session_id: str) -> dict[str, Any]:
|
|
365
|
+
"""Get total cost for a session."""
|
|
366
|
+
if session_id in self._session_costs:
|
|
367
|
+
return self._session_costs[session_id]
|
|
368
|
+
|
|
369
|
+
# Query from database
|
|
370
|
+
conn = self.connect()
|
|
371
|
+
cursor = conn.cursor()
|
|
372
|
+
cursor.execute(
|
|
373
|
+
"""
|
|
374
|
+
SELECT SUM(cost_usd) as total_cost, SUM(total_tokens) as total_tokens,
|
|
375
|
+
COUNT(DISTINCT model) as model_count
|
|
376
|
+
FROM cost_events WHERE session_id = ?
|
|
377
|
+
""",
|
|
378
|
+
(session_id,),
|
|
379
|
+
)
|
|
380
|
+
row = cursor.fetchone()
|
|
381
|
+
|
|
382
|
+
if row:
|
|
383
|
+
return {
|
|
384
|
+
"total_cost_usd": row["total_cost"] or 0.0,
|
|
385
|
+
"total_tokens": row["total_tokens"] or 0,
|
|
386
|
+
"model_count": row["model_count"] or 0,
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
return {"total_cost_usd": 0.0, "total_tokens": 0, "model_count": 0}
|
|
390
|
+
|
|
391
|
+
def get_cost_breakdown(self, session_id: str) -> CostBreakdown:
|
|
392
|
+
"""Get detailed cost breakdown for a session."""
|
|
393
|
+
conn = self.connect()
|
|
394
|
+
cursor = conn.cursor()
|
|
395
|
+
|
|
396
|
+
# Get totals
|
|
397
|
+
cursor.execute(
|
|
398
|
+
"""
|
|
399
|
+
SELECT SUM(cost_usd) as total_cost, SUM(total_tokens) as total_tokens
|
|
400
|
+
FROM cost_events WHERE session_id = ?
|
|
401
|
+
""",
|
|
402
|
+
(session_id,),
|
|
403
|
+
)
|
|
404
|
+
row = cursor.fetchone()
|
|
405
|
+
total_cost = row["total_cost"] or 0.0
|
|
406
|
+
total_tokens = row["total_tokens"] or 0
|
|
407
|
+
|
|
408
|
+
# By model
|
|
409
|
+
cursor.execute(
|
|
410
|
+
"""
|
|
411
|
+
SELECT model, SUM(cost_usd) as cost FROM cost_events
|
|
412
|
+
WHERE session_id = ? GROUP BY model
|
|
413
|
+
""",
|
|
414
|
+
(session_id,),
|
|
415
|
+
)
|
|
416
|
+
by_model = {row["model"]: row["cost"] for row in cursor.fetchall()}
|
|
417
|
+
|
|
418
|
+
# By tool
|
|
419
|
+
cursor.execute(
|
|
420
|
+
"""
|
|
421
|
+
SELECT tool_name, SUM(cost_usd) as cost FROM cost_events
|
|
422
|
+
WHERE session_id = ? GROUP BY tool_name
|
|
423
|
+
""",
|
|
424
|
+
(session_id,),
|
|
425
|
+
)
|
|
426
|
+
by_tool = {row["tool_name"]: row["cost"] for row in cursor.fetchall()}
|
|
427
|
+
|
|
428
|
+
# By agent
|
|
429
|
+
cursor.execute(
|
|
430
|
+
"""
|
|
431
|
+
SELECT agent_id, SUM(cost_usd) as cost FROM cost_events
|
|
432
|
+
WHERE session_id = ? AND agent_id IS NOT NULL GROUP BY agent_id
|
|
433
|
+
""",
|
|
434
|
+
(session_id,),
|
|
435
|
+
)
|
|
436
|
+
by_agent = {row["agent_id"]: row["cost"] for row in cursor.fetchall()}
|
|
437
|
+
|
|
438
|
+
# By subagent type
|
|
439
|
+
cursor.execute(
|
|
440
|
+
"""
|
|
441
|
+
SELECT subagent_type, SUM(cost_usd) as cost FROM cost_events
|
|
442
|
+
WHERE session_id = ? AND subagent_type IS NOT NULL GROUP BY subagent_type
|
|
443
|
+
""",
|
|
444
|
+
(session_id,),
|
|
445
|
+
)
|
|
446
|
+
by_subagent_type = {
|
|
447
|
+
row["subagent_type"]: row["cost"] for row in cursor.fetchall()
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
return CostBreakdown(
|
|
451
|
+
by_model=by_model,
|
|
452
|
+
by_tool=by_tool,
|
|
453
|
+
by_agent=by_agent,
|
|
454
|
+
by_subagent_type=by_subagent_type,
|
|
455
|
+
total_cost_usd=total_cost,
|
|
456
|
+
total_tokens=total_tokens,
|
|
457
|
+
session_count=1,
|
|
458
|
+
)
|
|
459
|
+
|
|
460
|
+
def _check_alerts(self, session_id: str, token_cost: TokenCost) -> None:
|
|
461
|
+
"""Check if cost triggers any alerts."""
|
|
462
|
+
conn = self.connect()
|
|
463
|
+
cursor = conn.cursor()
|
|
464
|
+
|
|
465
|
+
# Get session budget and cost info
|
|
466
|
+
cursor.execute(
|
|
467
|
+
"""
|
|
468
|
+
SELECT cost_budget, cost_threshold_breached FROM sessions WHERE session_id = ?
|
|
469
|
+
""",
|
|
470
|
+
(session_id,),
|
|
471
|
+
)
|
|
472
|
+
session_row = cursor.fetchone()
|
|
473
|
+
|
|
474
|
+
if not session_row or not session_row["cost_budget"]:
|
|
475
|
+
return # No budget set
|
|
476
|
+
|
|
477
|
+
budget_usd = session_row["cost_budget"]
|
|
478
|
+
session_cost = self.get_session_cost(session_id)
|
|
479
|
+
current_cost = session_cost["total_cost_usd"]
|
|
480
|
+
|
|
481
|
+
# Check 80% budget warning
|
|
482
|
+
if current_cost >= budget_usd * 0.8 and current_cost < budget_usd * 0.9:
|
|
483
|
+
self._create_alert(
|
|
484
|
+
session_id=session_id,
|
|
485
|
+
alert_type="budget_warning",
|
|
486
|
+
message=f"Cost at 80% of budget: ${current_cost:.2f} of ${budget_usd:.2f}",
|
|
487
|
+
current_cost_usd=current_cost,
|
|
488
|
+
budget_usd=budget_usd,
|
|
489
|
+
severity="warning",
|
|
490
|
+
)
|
|
491
|
+
|
|
492
|
+
# Check budget breach
|
|
493
|
+
if current_cost >= budget_usd:
|
|
494
|
+
self._create_alert(
|
|
495
|
+
session_id=session_id,
|
|
496
|
+
alert_type="breach",
|
|
497
|
+
message=f"Cost exceeded budget: ${current_cost:.2f} of ${budget_usd:.2f}",
|
|
498
|
+
current_cost_usd=current_cost,
|
|
499
|
+
budget_usd=budget_usd,
|
|
500
|
+
severity="critical",
|
|
501
|
+
)
|
|
502
|
+
# Mark in database
|
|
503
|
+
cursor.execute(
|
|
504
|
+
"""
|
|
505
|
+
UPDATE sessions SET cost_threshold_breached = 1 WHERE session_id = ?
|
|
506
|
+
""",
|
|
507
|
+
(session_id,),
|
|
508
|
+
)
|
|
509
|
+
conn.commit()
|
|
510
|
+
|
|
511
|
+
def _create_alert(
|
|
512
|
+
self,
|
|
513
|
+
session_id: str,
|
|
514
|
+
alert_type: str,
|
|
515
|
+
message: str,
|
|
516
|
+
current_cost_usd: float,
|
|
517
|
+
budget_usd: float | None = None,
|
|
518
|
+
predicted_cost_usd: float | None = None,
|
|
519
|
+
model: str | None = None,
|
|
520
|
+
severity: str = "warning",
|
|
521
|
+
) -> CostAlert:
|
|
522
|
+
"""Create and store a cost alert."""
|
|
523
|
+
alert_id = f"alert-{int(time.time() * 1000)}"
|
|
524
|
+
timestamp = datetime.now(timezone.utc)
|
|
525
|
+
|
|
526
|
+
alert = CostAlert(
|
|
527
|
+
alert_id=alert_id,
|
|
528
|
+
alert_type=alert_type,
|
|
529
|
+
session_id=session_id,
|
|
530
|
+
timestamp=timestamp,
|
|
531
|
+
message=message,
|
|
532
|
+
current_cost_usd=current_cost_usd,
|
|
533
|
+
budget_usd=budget_usd,
|
|
534
|
+
predicted_cost_usd=predicted_cost_usd,
|
|
535
|
+
model=model,
|
|
536
|
+
severity=severity,
|
|
537
|
+
)
|
|
538
|
+
|
|
539
|
+
# Store in database
|
|
540
|
+
conn = self.connect()
|
|
541
|
+
cursor = conn.cursor()
|
|
542
|
+
cursor.execute(
|
|
543
|
+
"""
|
|
544
|
+
INSERT INTO cost_events (
|
|
545
|
+
event_id, session_id, alert_type, message,
|
|
546
|
+
current_cost_usd, budget_usd, severity, timestamp
|
|
547
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
|
548
|
+
""",
|
|
549
|
+
(
|
|
550
|
+
alert_id,
|
|
551
|
+
session_id,
|
|
552
|
+
alert_type,
|
|
553
|
+
message,
|
|
554
|
+
current_cost_usd,
|
|
555
|
+
budget_usd,
|
|
556
|
+
severity,
|
|
557
|
+
timestamp.isoformat(),
|
|
558
|
+
),
|
|
559
|
+
)
|
|
560
|
+
conn.commit()
|
|
561
|
+
|
|
562
|
+
# Cache alert
|
|
563
|
+
self._alert_cache[alert_id] = alert
|
|
564
|
+
|
|
565
|
+
logger.info(f"Cost alert created: {alert_type} for {session_id}: {message}")
|
|
566
|
+
|
|
567
|
+
return alert
|
|
568
|
+
|
|
569
|
+
def get_alerts(self, session_id: str, limit: int = 100) -> list[CostAlert]:
|
|
570
|
+
"""Get recent cost alerts for a session."""
|
|
571
|
+
conn = self.connect()
|
|
572
|
+
cursor = conn.cursor()
|
|
573
|
+
|
|
574
|
+
cursor.execute(
|
|
575
|
+
"""
|
|
576
|
+
SELECT * FROM cost_events
|
|
577
|
+
WHERE session_id = ? AND alert_type IS NOT NULL
|
|
578
|
+
ORDER BY timestamp DESC LIMIT ?
|
|
579
|
+
""",
|
|
580
|
+
(session_id, limit),
|
|
581
|
+
)
|
|
582
|
+
|
|
583
|
+
alerts = []
|
|
584
|
+
for row in cursor.fetchall():
|
|
585
|
+
# sqlite3.Row doesn't have .get(), use dict conversion or try/except
|
|
586
|
+
severity = "warning"
|
|
587
|
+
try:
|
|
588
|
+
severity = row["severity"]
|
|
589
|
+
except (KeyError, IndexError):
|
|
590
|
+
pass
|
|
591
|
+
alert = CostAlert(
|
|
592
|
+
alert_id=row["event_id"],
|
|
593
|
+
alert_type=row["alert_type"],
|
|
594
|
+
session_id=row["session_id"],
|
|
595
|
+
timestamp=datetime.fromisoformat(row["timestamp"]),
|
|
596
|
+
message=row["message"],
|
|
597
|
+
current_cost_usd=row["current_cost_usd"],
|
|
598
|
+
budget_usd=row["budget_usd"],
|
|
599
|
+
severity=severity,
|
|
600
|
+
)
|
|
601
|
+
alerts.append(alert)
|
|
602
|
+
|
|
603
|
+
return alerts
|
|
604
|
+
|
|
605
|
+
def predict_cost_trajectory(
|
|
606
|
+
self, session_id: str, lookback_minutes: int = 5
|
|
607
|
+
) -> dict[str, Any]:
|
|
608
|
+
"""
|
|
609
|
+
Predict future cost based on recent trajectory.
|
|
610
|
+
|
|
611
|
+
Args:
|
|
612
|
+
session_id: Session identifier
|
|
613
|
+
lookback_minutes: Minutes of history to analyze
|
|
614
|
+
|
|
615
|
+
Returns:
|
|
616
|
+
Prediction data with projected cost
|
|
617
|
+
"""
|
|
618
|
+
conn = self.connect()
|
|
619
|
+
cursor = conn.cursor()
|
|
620
|
+
|
|
621
|
+
# Get recent costs
|
|
622
|
+
cursor.execute(
|
|
623
|
+
"""
|
|
624
|
+
SELECT timestamp, cost_usd FROM cost_events
|
|
625
|
+
WHERE session_id = ? AND cost_usd > 0
|
|
626
|
+
AND timestamp > datetime('now', '-' || ? || ' minutes')
|
|
627
|
+
ORDER BY timestamp ASC
|
|
628
|
+
""",
|
|
629
|
+
(session_id, lookback_minutes),
|
|
630
|
+
)
|
|
631
|
+
|
|
632
|
+
costs = []
|
|
633
|
+
for row in cursor.fetchall():
|
|
634
|
+
costs.append(
|
|
635
|
+
{
|
|
636
|
+
"timestamp": datetime.fromisoformat(row["timestamp"]),
|
|
637
|
+
"cost_usd": row["cost_usd"],
|
|
638
|
+
}
|
|
639
|
+
)
|
|
640
|
+
|
|
641
|
+
if len(costs) < 2:
|
|
642
|
+
return {"prediction_available": False, "reason": "insufficient_data"}
|
|
643
|
+
|
|
644
|
+
# Calculate average cost per minute
|
|
645
|
+
time_span = (
|
|
646
|
+
costs[-1]["timestamp"] - costs[0]["timestamp"]
|
|
647
|
+
).total_seconds() / 60
|
|
648
|
+
if time_span == 0:
|
|
649
|
+
return {"prediction_available": False, "reason": "zero_time_span"}
|
|
650
|
+
|
|
651
|
+
total_cost = sum(c["cost_usd"] for c in costs)
|
|
652
|
+
cost_per_minute = total_cost / time_span
|
|
653
|
+
|
|
654
|
+
# Project to 1 hour
|
|
655
|
+
projected_cost = cost_per_minute * 60
|
|
656
|
+
|
|
657
|
+
return {
|
|
658
|
+
"prediction_available": True,
|
|
659
|
+
"recent_cost_usd": total_cost,
|
|
660
|
+
"lookback_minutes": lookback_minutes,
|
|
661
|
+
"cost_per_minute": cost_per_minute,
|
|
662
|
+
"projected_hourly_cost": projected_cost,
|
|
663
|
+
"sample_count": len(costs),
|
|
664
|
+
}
|