draft-board 0.1.0-beta.0
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.
- package/app/backend/.env.example +9 -0
- package/app/backend/.smartkanban/evidence/8b383839-cbec-45af-86ee-c7708d075cbe/bddf2ed5-2e21-4d46-a62b-10b87f1642a6_patch.txt +195 -0
- package/app/backend/.smartkanban/evidence/8b383839-cbec-45af-86ee-c7708d075cbe/bddf2ed5-2e21-4d46-a62b-10b87f1642a6_stat.txt +6 -0
- package/app/backend/CURL_EXAMPLES.md +335 -0
- package/app/backend/ENV_SETUP.md +65 -0
- package/app/backend/alembic/env.py +71 -0
- package/app/backend/alembic/script.py.mako +28 -0
- package/app/backend/alembic/versions/001_initial_schema.py +104 -0
- package/app/backend/alembic/versions/002_add_jobs_table.py +52 -0
- package/app/backend/alembic/versions/003_add_workspace_table.py +48 -0
- package/app/backend/alembic/versions/004_add_evidence_table.py +56 -0
- package/app/backend/alembic/versions/005_add_verification_commands.py +32 -0
- package/app/backend/alembic/versions/006_add_planner_lock_table.py +39 -0
- package/app/backend/alembic/versions/007_add_revision_review_tables.py +126 -0
- package/app/backend/alembic/versions/008_add_revision_idempotency_and_traceability.py +52 -0
- package/app/backend/alembic/versions/009_add_job_health_fields.py +46 -0
- package/app/backend/alembic/versions/010_add_review_comment_line_content.py +36 -0
- package/app/backend/alembic/versions/011_add_analysis_cache.py +47 -0
- package/app/backend/alembic/versions/012_add_boards_table.py +102 -0
- package/app/backend/alembic/versions/013_add_ticket_blocking.py +45 -0
- package/app/backend/alembic/versions/014_add_agent_sessions.py +220 -0
- package/app/backend/alembic/versions/015_add_ticket_sort_order.py +33 -0
- package/app/backend/alembic/versions/03220f0b93ae_add_pr_fields_to_ticket.py +49 -0
- package/app/backend/alembic/versions/0c2d89fff3b1_seed_board_configs_from_yaml.py +206 -0
- package/app/backend/alembic/versions/3348e5cf54c1_add_merge_checklist_table.py +67 -0
- package/app/backend/alembic/versions/357c780ee445_add_goal_status.py +34 -0
- package/app/backend/alembic/versions/553340b7e26c_add_autonomy_fields_to_goal.py +65 -0
- package/app/backend/alembic/versions/774dc335c679_merge_migration_heads.py +23 -0
- package/app/backend/alembic/versions/7b307e847cbd_merge_heads.py +23 -0
- package/app/backend/alembic/versions/82ecd978cc70_add_missing_indexes.py +48 -0
- package/app/backend/alembic/versions/8ef5054dc280_add_normalized_log_entries.py +173 -0
- package/app/backend/alembic/versions/8f3e2bd8ea3b_merge_migration_heads.py +23 -0
- package/app/backend/alembic/versions/9d17f0698d3b_add_config_column_to_boards_table.py +30 -0
- package/app/backend/alembic/versions/add_agent_conversation_history.py +72 -0
- package/app/backend/alembic/versions/add_job_variant.py +34 -0
- package/app/backend/alembic/versions/add_performance_indexes.py +95 -0
- package/app/backend/alembic/versions/add_repos_and_board_repos.py +174 -0
- package/app/backend/alembic/versions/add_session_id_to_jobs.py +27 -0
- package/app/backend/alembic/versions/add_sqlite_backend_tables.py +104 -0
- package/app/backend/alembic/versions/b10fb0b62240_add_diff_content_to_revisions.py +34 -0
- package/app/backend/alembic.ini +89 -0
- package/app/backend/app/__init__.py +3 -0
- package/app/backend/app/data_dir.py +85 -0
- package/app/backend/app/database.py +70 -0
- package/app/backend/app/database_sync.py +64 -0
- package/app/backend/app/dependencies/__init__.py +5 -0
- package/app/backend/app/dependencies/auth.py +80 -0
- package/app/backend/app/dependencies.py +43 -0
- package/app/backend/app/exceptions.py +178 -0
- package/app/backend/app/executors/__init__.py +1 -0
- package/app/backend/app/executors/adapters/__init__.py +1 -0
- package/app/backend/app/executors/adapters/aider.py +152 -0
- package/app/backend/app/executors/adapters/amazon_q.py +103 -0
- package/app/backend/app/executors/adapters/amp.py +123 -0
- package/app/backend/app/executors/adapters/claude.py +177 -0
- package/app/backend/app/executors/adapters/cline.py +127 -0
- package/app/backend/app/executors/adapters/codex.py +167 -0
- package/app/backend/app/executors/adapters/copilot.py +202 -0
- package/app/backend/app/executors/adapters/cursor.py +87 -0
- package/app/backend/app/executors/adapters/droid.py +123 -0
- package/app/backend/app/executors/adapters/gemini.py +132 -0
- package/app/backend/app/executors/adapters/goose.py +131 -0
- package/app/backend/app/executors/adapters/opencode.py +123 -0
- package/app/backend/app/executors/adapters/qwen.py +123 -0
- package/app/backend/app/executors/plugins/__init__.py +1 -0
- package/app/backend/app/executors/registry.py +202 -0
- package/app/backend/app/executors/spec.py +226 -0
- package/app/backend/app/main.py +486 -0
- package/app/backend/app/middleware/__init__.py +13 -0
- package/app/backend/app/middleware/idempotency.py +426 -0
- package/app/backend/app/middleware/rate_limit.py +312 -0
- package/app/backend/app/middleware/security_headers.py +43 -0
- package/app/backend/app/middleware/timeout.py +37 -0
- package/app/backend/app/models/__init__.py +56 -0
- package/app/backend/app/models/agent_conversation_history.py +56 -0
- package/app/backend/app/models/agent_session.py +127 -0
- package/app/backend/app/models/analysis_cache.py +49 -0
- package/app/backend/app/models/base.py +9 -0
- package/app/backend/app/models/board.py +79 -0
- package/app/backend/app/models/board_repo.py +68 -0
- package/app/backend/app/models/cost_budget.py +42 -0
- package/app/backend/app/models/enums.py +40 -0
- package/app/backend/app/models/evidence.py +132 -0
- package/app/backend/app/models/goal.py +102 -0
- package/app/backend/app/models/idempotency_entry.py +30 -0
- package/app/backend/app/models/job.py +163 -0
- package/app/backend/app/models/job_queue.py +39 -0
- package/app/backend/app/models/kv_store.py +28 -0
- package/app/backend/app/models/merge_checklist.py +87 -0
- package/app/backend/app/models/normalized_log.py +100 -0
- package/app/backend/app/models/planner_lock.py +43 -0
- package/app/backend/app/models/rate_limit_entry.py +25 -0
- package/app/backend/app/models/repo.py +66 -0
- package/app/backend/app/models/review_comment.py +91 -0
- package/app/backend/app/models/review_summary.py +69 -0
- package/app/backend/app/models/revision.py +130 -0
- package/app/backend/app/models/ticket.py +223 -0
- package/app/backend/app/models/ticket_event.py +83 -0
- package/app/backend/app/models/user.py +47 -0
- package/app/backend/app/models/workspace.py +71 -0
- package/app/backend/app/redis_client.py +119 -0
- package/app/backend/app/routers/__init__.py +29 -0
- package/app/backend/app/routers/agents.py +296 -0
- package/app/backend/app/routers/auth.py +94 -0
- package/app/backend/app/routers/board.py +885 -0
- package/app/backend/app/routers/dashboard.py +351 -0
- package/app/backend/app/routers/debug.py +528 -0
- package/app/backend/app/routers/evidence.py +96 -0
- package/app/backend/app/routers/executors.py +324 -0
- package/app/backend/app/routers/goals.py +574 -0
- package/app/backend/app/routers/jobs.py +448 -0
- package/app/backend/app/routers/maintenance.py +172 -0
- package/app/backend/app/routers/merge.py +360 -0
- package/app/backend/app/routers/planner.py +537 -0
- package/app/backend/app/routers/pull_requests.py +382 -0
- package/app/backend/app/routers/repos.py +263 -0
- package/app/backend/app/routers/revisions.py +939 -0
- package/app/backend/app/routers/settings.py +267 -0
- package/app/backend/app/routers/tickets.py +2003 -0
- package/app/backend/app/routers/webhooks.py +143 -0
- package/app/backend/app/routers/websocket.py +249 -0
- package/app/backend/app/schemas/__init__.py +109 -0
- package/app/backend/app/schemas/board.py +87 -0
- package/app/backend/app/schemas/common.py +33 -0
- package/app/backend/app/schemas/evidence.py +87 -0
- package/app/backend/app/schemas/goal.py +90 -0
- package/app/backend/app/schemas/job.py +97 -0
- package/app/backend/app/schemas/merge.py +139 -0
- package/app/backend/app/schemas/planner.py +500 -0
- package/app/backend/app/schemas/repo.py +187 -0
- package/app/backend/app/schemas/review.py +137 -0
- package/app/backend/app/schemas/revision.py +114 -0
- package/app/backend/app/schemas/ticket.py +238 -0
- package/app/backend/app/schemas/ticket_event.py +72 -0
- package/app/backend/app/schemas/workspace.py +19 -0
- package/app/backend/app/services/__init__.py +31 -0
- package/app/backend/app/services/agent_memory_service.py +223 -0
- package/app/backend/app/services/agent_registry.py +346 -0
- package/app/backend/app/services/agent_session_manager.py +318 -0
- package/app/backend/app/services/agent_session_service.py +219 -0
- package/app/backend/app/services/agent_tools.py +379 -0
- package/app/backend/app/services/auth_service.py +98 -0
- package/app/backend/app/services/autonomy_service.py +380 -0
- package/app/backend/app/services/board_repo_service.py +201 -0
- package/app/backend/app/services/board_service.py +326 -0
- package/app/backend/app/services/cleanup_service.py +1085 -0
- package/app/backend/app/services/config_service.py +908 -0
- package/app/backend/app/services/context_gatherer.py +557 -0
- package/app/backend/app/services/cost_tracking_service.py +293 -0
- package/app/backend/app/services/cursor_log_normalizer.py +536 -0
- package/app/backend/app/services/delivery_pipeline.py +440 -0
- package/app/backend/app/services/executor_service.py +634 -0
- package/app/backend/app/services/git_host/__init__.py +11 -0
- package/app/backend/app/services/git_host/factory.py +87 -0
- package/app/backend/app/services/git_host/github.py +270 -0
- package/app/backend/app/services/git_host/gitlab.py +194 -0
- package/app/backend/app/services/git_host/protocol.py +75 -0
- package/app/backend/app/services/git_merge_simple.py +346 -0
- package/app/backend/app/services/git_ops.py +384 -0
- package/app/backend/app/services/github_service.py +233 -0
- package/app/backend/app/services/goal_service.py +113 -0
- package/app/backend/app/services/job_service.py +423 -0
- package/app/backend/app/services/job_watchdog_service.py +424 -0
- package/app/backend/app/services/langchain_adapter.py +122 -0
- package/app/backend/app/services/llm_provider_clients.py +351 -0
- package/app/backend/app/services/llm_service.py +285 -0
- package/app/backend/app/services/log_normalizer.py +342 -0
- package/app/backend/app/services/log_stream_service.py +276 -0
- package/app/backend/app/services/merge_checklist_service.py +264 -0
- package/app/backend/app/services/merge_service.py +784 -0
- package/app/backend/app/services/orchestrator_log.py +84 -0
- package/app/backend/app/services/planner_service.py +1662 -0
- package/app/backend/app/services/planner_tick_sync.py +1040 -0
- package/app/backend/app/services/queued_message_service.py +156 -0
- package/app/backend/app/services/reliability_wrapper.py +389 -0
- package/app/backend/app/services/repo_discovery_service.py +318 -0
- package/app/backend/app/services/review_service.py +334 -0
- package/app/backend/app/services/revision_service.py +389 -0
- package/app/backend/app/services/safe_autopilot.py +510 -0
- package/app/backend/app/services/sqlite_worker.py +372 -0
- package/app/backend/app/services/task_dispatch.py +135 -0
- package/app/backend/app/services/ticket_generation_service.py +1781 -0
- package/app/backend/app/services/ticket_service.py +486 -0
- package/app/backend/app/services/udar_planner_service.py +1007 -0
- package/app/backend/app/services/webhook_service.py +126 -0
- package/app/backend/app/services/workspace_service.py +465 -0
- package/app/backend/app/services/worktree_file_service.py +92 -0
- package/app/backend/app/services/worktree_validator.py +213 -0
- package/app/backend/app/sqlite_kv.py +278 -0
- package/app/backend/app/state_machine.py +128 -0
- package/app/backend/app/templates/__init__.py +5 -0
- package/app/backend/app/templates/registry.py +243 -0
- package/app/backend/app/utils/__init__.py +5 -0
- package/app/backend/app/utils/artifact_reader.py +87 -0
- package/app/backend/app/utils/circuit_breaker.py +229 -0
- package/app/backend/app/utils/db_retry.py +136 -0
- package/app/backend/app/utils/ignored_fields.py +123 -0
- package/app/backend/app/utils/validators.py +54 -0
- package/app/backend/app/websocket/__init__.py +5 -0
- package/app/backend/app/websocket/manager.py +179 -0
- package/app/backend/app/websocket/state_tracker.py +113 -0
- package/app/backend/app/worker.py +3190 -0
- package/app/backend/calculator_tickets.json +40 -0
- package/app/backend/canary_tests.sh +591 -0
- package/app/backend/celerybeat-schedule +0 -0
- package/app/backend/celerybeat-schedule-shm +0 -0
- package/app/backend/celerybeat-schedule-wal +0 -0
- package/app/backend/logs/.gitkeep +3 -0
- package/app/backend/multiplication_division_implementation_tickets.json +55 -0
- package/app/backend/multiplication_division_tickets.json +42 -0
- package/app/backend/pyproject.toml +45 -0
- package/app/backend/requirements-dev.txt +8 -0
- package/app/backend/requirements.txt +20 -0
- package/app/backend/run.sh +30 -0
- package/app/backend/run_with_logs.sh +10 -0
- package/app/backend/scientific_calculator_tickets.json +40 -0
- package/app/backend/scripts/extract_openapi.py +21 -0
- package/app/backend/scripts/seed_demo.py +187 -0
- package/app/backend/setup_demo_review.py +302 -0
- package/app/backend/test_actual_parse.py +41 -0
- package/app/backend/test_agent_streaming.py +61 -0
- package/app/backend/test_parse.py +51 -0
- package/app/backend/test_streaming.py +51 -0
- package/app/backend/test_subprocess_streaming.py +50 -0
- package/app/backend/tests/__init__.py +1 -0
- package/app/backend/tests/conftest.py +46 -0
- package/app/backend/tests/test_auth.py +341 -0
- package/app/backend/tests/test_autonomy_service.py +391 -0
- package/app/backend/tests/test_cleanup_service_safety.py +417 -0
- package/app/backend/tests/test_middleware.py +279 -0
- package/app/backend/tests/test_planner_providers.py +290 -0
- package/app/backend/tests/test_planner_unblock.py +183 -0
- package/app/backend/tests/test_revision_invariants.py +618 -0
- package/app/backend/tests/test_sqlite_kv.py +290 -0
- package/app/backend/tests/test_sqlite_worker.py +353 -0
- package/app/backend/tests/test_task_dispatch.py +100 -0
- package/app/backend/tests/test_ticket_validation.py +304 -0
- package/app/backend/tests/test_udar_agent.py +693 -0
- package/app/backend/tests/test_webhook_service.py +184 -0
- package/app/backend/tickets_output.json +59 -0
- package/app/backend/user_management_tickets.json +50 -0
- package/app/backend/uvicorn.log +0 -0
- package/app/draft.yaml +313 -0
- package/app/frontend/dist/assets/index-LcjCczu5.js +155 -0
- package/app/frontend/dist/assets/index-_FP_279e.css +1 -0
- package/app/frontend/dist/index.html +14 -0
- package/app/frontend/dist/vite.svg +1 -0
- package/app/frontend/package.json +101 -0
- package/bin/cli.js +527 -0
- package/package.json +37 -0
|
@@ -0,0 +1,293 @@
|
|
|
1
|
+
"""Cost tracking service for AI agent usage.
|
|
2
|
+
|
|
3
|
+
Tracks and aggregates costs across:
|
|
4
|
+
- Individual tickets
|
|
5
|
+
- Goals/sprints
|
|
6
|
+
- Time periods (daily, weekly, monthly)
|
|
7
|
+
|
|
8
|
+
Helps individual developers stay within budget and understand spending patterns.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
import logging
|
|
12
|
+
from dataclasses import dataclass
|
|
13
|
+
from datetime import datetime, timedelta
|
|
14
|
+
|
|
15
|
+
from sqlalchemy import func, select
|
|
16
|
+
from sqlalchemy.ext.asyncio import AsyncSession
|
|
17
|
+
|
|
18
|
+
from app.models.agent_session import AgentSession
|
|
19
|
+
from app.models.ticket import Ticket
|
|
20
|
+
from app.services.agent_registry import AGENT_REGISTRY, AgentType
|
|
21
|
+
|
|
22
|
+
logger = logging.getLogger(__name__)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@dataclass
|
|
26
|
+
class CostSummary:
|
|
27
|
+
"""Cost summary for a period or entity."""
|
|
28
|
+
|
|
29
|
+
total_cost_usd: float
|
|
30
|
+
total_input_tokens: int
|
|
31
|
+
total_output_tokens: int
|
|
32
|
+
session_count: int
|
|
33
|
+
avg_cost_per_session: float
|
|
34
|
+
cost_by_agent: dict[str, float]
|
|
35
|
+
cost_by_goal: dict[str, float]
|
|
36
|
+
daily_costs: list[tuple[str, float]] # (date, cost) pairs
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
@dataclass
|
|
40
|
+
class BudgetStatus:
|
|
41
|
+
"""Budget tracking status."""
|
|
42
|
+
|
|
43
|
+
daily_budget: float | None
|
|
44
|
+
daily_spent: float
|
|
45
|
+
daily_remaining: float
|
|
46
|
+
weekly_budget: float | None
|
|
47
|
+
weekly_spent: float
|
|
48
|
+
weekly_remaining: float
|
|
49
|
+
monthly_budget: float | None
|
|
50
|
+
monthly_spent: float
|
|
51
|
+
monthly_remaining: float
|
|
52
|
+
is_over_budget: bool
|
|
53
|
+
warning_threshold_reached: bool # 80% of any budget
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
class CostTrackingService:
|
|
57
|
+
"""Service for tracking and analyzing AI agent costs."""
|
|
58
|
+
|
|
59
|
+
# Default budgets (can be overridden in config)
|
|
60
|
+
DEFAULT_DAILY_BUDGET = 10.0 # $10/day
|
|
61
|
+
DEFAULT_WEEKLY_BUDGET = 50.0 # $50/week
|
|
62
|
+
DEFAULT_MONTHLY_BUDGET = 150.0 # $150/month
|
|
63
|
+
WARNING_THRESHOLD = 0.8 # Warn at 80%
|
|
64
|
+
|
|
65
|
+
def __init__(self, db: AsyncSession):
|
|
66
|
+
self.db = db
|
|
67
|
+
|
|
68
|
+
async def calculate_cost(
|
|
69
|
+
self, agent_type: AgentType, input_tokens: int, output_tokens: int
|
|
70
|
+
) -> float:
|
|
71
|
+
"""Calculate cost for given token usage."""
|
|
72
|
+
config = AGENT_REGISTRY.get(agent_type)
|
|
73
|
+
if not config or not config.cost_per_1k_input:
|
|
74
|
+
return 0.0
|
|
75
|
+
|
|
76
|
+
input_cost = (input_tokens / 1000) * config.cost_per_1k_input
|
|
77
|
+
output_cost = (output_tokens / 1000) * (
|
|
78
|
+
config.cost_per_1k_output or config.cost_per_1k_input
|
|
79
|
+
)
|
|
80
|
+
|
|
81
|
+
return round(input_cost + output_cost, 6)
|
|
82
|
+
|
|
83
|
+
async def get_period_cost(
|
|
84
|
+
self, start_date: datetime, end_date: datetime, goal_id: str | None = None
|
|
85
|
+
) -> float:
|
|
86
|
+
"""Get total cost for a time period, optionally filtered by goal."""
|
|
87
|
+
query = select(func.sum(AgentSession.estimated_cost_usd)).where(
|
|
88
|
+
AgentSession.created_at >= start_date, AgentSession.created_at < end_date
|
|
89
|
+
)
|
|
90
|
+
|
|
91
|
+
if goal_id:
|
|
92
|
+
query = query.join(Ticket).where(Ticket.goal_id == goal_id)
|
|
93
|
+
|
|
94
|
+
result = await self.db.execute(query)
|
|
95
|
+
total = result.scalar()
|
|
96
|
+
return float(total or 0)
|
|
97
|
+
|
|
98
|
+
async def get_daily_cost(self, date: datetime | None = None) -> float:
|
|
99
|
+
"""Get cost for a specific day (defaults to today)."""
|
|
100
|
+
if date is None:
|
|
101
|
+
date = datetime.utcnow()
|
|
102
|
+
|
|
103
|
+
start = date.replace(hour=0, minute=0, second=0, microsecond=0)
|
|
104
|
+
end = start + timedelta(days=1)
|
|
105
|
+
|
|
106
|
+
return await self.get_period_cost(start, end)
|
|
107
|
+
|
|
108
|
+
async def get_weekly_cost(self, date: datetime | None = None) -> float:
|
|
109
|
+
"""Get cost for the week containing the given date."""
|
|
110
|
+
if date is None:
|
|
111
|
+
date = datetime.utcnow()
|
|
112
|
+
|
|
113
|
+
# Start of week (Monday)
|
|
114
|
+
start = date - timedelta(days=date.weekday())
|
|
115
|
+
start = start.replace(hour=0, minute=0, second=0, microsecond=0)
|
|
116
|
+
end = start + timedelta(weeks=1)
|
|
117
|
+
|
|
118
|
+
return await self.get_period_cost(start, end)
|
|
119
|
+
|
|
120
|
+
async def get_monthly_cost(self, date: datetime | None = None) -> float:
|
|
121
|
+
"""Get cost for the month containing the given date."""
|
|
122
|
+
if date is None:
|
|
123
|
+
date = datetime.utcnow()
|
|
124
|
+
|
|
125
|
+
start = date.replace(day=1, hour=0, minute=0, second=0, microsecond=0)
|
|
126
|
+
if start.month == 12:
|
|
127
|
+
end = start.replace(year=start.year + 1, month=1)
|
|
128
|
+
else:
|
|
129
|
+
end = start.replace(month=start.month + 1)
|
|
130
|
+
|
|
131
|
+
return await self.get_period_cost(start, end)
|
|
132
|
+
|
|
133
|
+
async def get_budget_status(
|
|
134
|
+
self,
|
|
135
|
+
daily_budget: float | None = None,
|
|
136
|
+
weekly_budget: float | None = None,
|
|
137
|
+
monthly_budget: float | None = None,
|
|
138
|
+
) -> BudgetStatus:
|
|
139
|
+
"""Get current budget status across all time periods."""
|
|
140
|
+
daily_budget = daily_budget or self.DEFAULT_DAILY_BUDGET
|
|
141
|
+
weekly_budget = weekly_budget or self.DEFAULT_WEEKLY_BUDGET
|
|
142
|
+
monthly_budget = monthly_budget or self.DEFAULT_MONTHLY_BUDGET
|
|
143
|
+
|
|
144
|
+
daily_spent = await self.get_daily_cost()
|
|
145
|
+
weekly_spent = await self.get_weekly_cost()
|
|
146
|
+
monthly_spent = await self.get_monthly_cost()
|
|
147
|
+
|
|
148
|
+
daily_remaining = max(0, daily_budget - daily_spent)
|
|
149
|
+
weekly_remaining = max(0, weekly_budget - weekly_spent)
|
|
150
|
+
monthly_remaining = max(0, monthly_budget - monthly_spent)
|
|
151
|
+
|
|
152
|
+
is_over_budget = (
|
|
153
|
+
daily_spent > daily_budget
|
|
154
|
+
or weekly_spent > weekly_budget
|
|
155
|
+
or monthly_spent > monthly_budget
|
|
156
|
+
)
|
|
157
|
+
|
|
158
|
+
warning_reached = (
|
|
159
|
+
daily_spent >= daily_budget * self.WARNING_THRESHOLD
|
|
160
|
+
or weekly_spent >= weekly_budget * self.WARNING_THRESHOLD
|
|
161
|
+
or monthly_spent >= monthly_budget * self.WARNING_THRESHOLD
|
|
162
|
+
)
|
|
163
|
+
|
|
164
|
+
return BudgetStatus(
|
|
165
|
+
daily_budget=daily_budget,
|
|
166
|
+
daily_spent=round(daily_spent, 2),
|
|
167
|
+
daily_remaining=round(daily_remaining, 2),
|
|
168
|
+
weekly_budget=weekly_budget,
|
|
169
|
+
weekly_spent=round(weekly_spent, 2),
|
|
170
|
+
weekly_remaining=round(weekly_remaining, 2),
|
|
171
|
+
monthly_budget=monthly_budget,
|
|
172
|
+
monthly_spent=round(monthly_spent, 2),
|
|
173
|
+
monthly_remaining=round(monthly_remaining, 2),
|
|
174
|
+
is_over_budget=is_over_budget,
|
|
175
|
+
warning_threshold_reached=warning_reached,
|
|
176
|
+
)
|
|
177
|
+
|
|
178
|
+
async def get_cost_summary(
|
|
179
|
+
self, days: int = 30, goal_id: str | None = None
|
|
180
|
+
) -> CostSummary:
|
|
181
|
+
"""Get detailed cost summary for the last N days."""
|
|
182
|
+
end_date = datetime.utcnow()
|
|
183
|
+
start_date = end_date - timedelta(days=days)
|
|
184
|
+
|
|
185
|
+
# Base query
|
|
186
|
+
base_query = select(AgentSession).where(
|
|
187
|
+
AgentSession.created_at >= start_date, AgentSession.created_at < end_date
|
|
188
|
+
)
|
|
189
|
+
|
|
190
|
+
if goal_id:
|
|
191
|
+
base_query = base_query.join(Ticket).where(Ticket.goal_id == goal_id)
|
|
192
|
+
|
|
193
|
+
result = await self.db.execute(base_query)
|
|
194
|
+
sessions = result.scalars().all()
|
|
195
|
+
|
|
196
|
+
# Aggregate data
|
|
197
|
+
total_cost = sum(s.estimated_cost_usd for s in sessions)
|
|
198
|
+
total_input = sum(s.total_input_tokens for s in sessions)
|
|
199
|
+
total_output = sum(s.total_output_tokens for s in sessions)
|
|
200
|
+
session_count = len(sessions)
|
|
201
|
+
|
|
202
|
+
# Cost by agent
|
|
203
|
+
cost_by_agent: dict[str, float] = {}
|
|
204
|
+
for session in sessions:
|
|
205
|
+
agent = session.agent_type
|
|
206
|
+
cost_by_agent[agent] = (
|
|
207
|
+
cost_by_agent.get(agent, 0) + session.estimated_cost_usd
|
|
208
|
+
)
|
|
209
|
+
|
|
210
|
+
# Cost by goal (need to join with tickets)
|
|
211
|
+
cost_by_goal: dict[str, float] = {}
|
|
212
|
+
for session in sessions:
|
|
213
|
+
if session.ticket and session.ticket.goal_id:
|
|
214
|
+
goal_id = str(session.ticket.goal_id)
|
|
215
|
+
cost_by_goal[goal_id] = (
|
|
216
|
+
cost_by_goal.get(goal_id, 0) + session.estimated_cost_usd
|
|
217
|
+
)
|
|
218
|
+
|
|
219
|
+
# Daily breakdown
|
|
220
|
+
daily_costs: dict[str, float] = {}
|
|
221
|
+
for session in sessions:
|
|
222
|
+
day_key = session.created_at.strftime("%Y-%m-%d")
|
|
223
|
+
daily_costs[day_key] = (
|
|
224
|
+
daily_costs.get(day_key, 0) + session.estimated_cost_usd
|
|
225
|
+
)
|
|
226
|
+
|
|
227
|
+
daily_list = sorted(daily_costs.items())
|
|
228
|
+
|
|
229
|
+
return CostSummary(
|
|
230
|
+
total_cost_usd=round(total_cost, 2),
|
|
231
|
+
total_input_tokens=total_input,
|
|
232
|
+
total_output_tokens=total_output,
|
|
233
|
+
session_count=session_count,
|
|
234
|
+
avg_cost_per_session=round(total_cost / max(1, session_count), 4),
|
|
235
|
+
cost_by_agent={k: round(v, 2) for k, v in cost_by_agent.items()},
|
|
236
|
+
cost_by_goal={k: round(v, 2) for k, v in cost_by_goal.items()},
|
|
237
|
+
daily_costs=[(d, round(c, 2)) for d, c in daily_list],
|
|
238
|
+
)
|
|
239
|
+
|
|
240
|
+
async def get_ticket_cost(self, ticket_id: str) -> float:
|
|
241
|
+
"""Get total cost for a specific ticket."""
|
|
242
|
+
query = select(func.sum(AgentSession.estimated_cost_usd)).where(
|
|
243
|
+
AgentSession.ticket_id == ticket_id
|
|
244
|
+
)
|
|
245
|
+
result = await self.db.execute(query)
|
|
246
|
+
total = result.scalar()
|
|
247
|
+
return float(total or 0)
|
|
248
|
+
|
|
249
|
+
async def get_goal_cost(self, goal_id: str) -> float:
|
|
250
|
+
"""Get total cost for all tickets in a goal."""
|
|
251
|
+
query = (
|
|
252
|
+
select(func.sum(AgentSession.estimated_cost_usd))
|
|
253
|
+
.join(Ticket)
|
|
254
|
+
.where(Ticket.goal_id == goal_id)
|
|
255
|
+
)
|
|
256
|
+
result = await self.db.execute(query)
|
|
257
|
+
total = result.scalar()
|
|
258
|
+
return float(total or 0)
|
|
259
|
+
|
|
260
|
+
async def estimate_remaining_cost(
|
|
261
|
+
self, goal_id: str, avg_cost_per_ticket: float | None = None
|
|
262
|
+
) -> dict[str, float]:
|
|
263
|
+
"""Estimate remaining cost to complete a goal."""
|
|
264
|
+
# Get incomplete tickets
|
|
265
|
+
query = (
|
|
266
|
+
select(func.count())
|
|
267
|
+
.select_from(Ticket)
|
|
268
|
+
.where(
|
|
269
|
+
Ticket.goal_id == goal_id,
|
|
270
|
+
Ticket.state.in_(["todo", "planned", "executing", "blocked"]),
|
|
271
|
+
)
|
|
272
|
+
)
|
|
273
|
+
result = await self.db.execute(query)
|
|
274
|
+
remaining_tickets = result.scalar() or 0
|
|
275
|
+
|
|
276
|
+
# Get average cost per ticket if not provided
|
|
277
|
+
if avg_cost_per_ticket is None:
|
|
278
|
+
summary = await self.get_cost_summary(days=30, goal_id=goal_id)
|
|
279
|
+
if summary.session_count > 0:
|
|
280
|
+
avg_cost_per_ticket = summary.avg_cost_per_session
|
|
281
|
+
else:
|
|
282
|
+
avg_cost_per_ticket = 0.50 # Default estimate
|
|
283
|
+
|
|
284
|
+
spent = await self.get_goal_cost(goal_id)
|
|
285
|
+
estimated_remaining = remaining_tickets * avg_cost_per_ticket
|
|
286
|
+
|
|
287
|
+
return {
|
|
288
|
+
"spent": round(spent, 2),
|
|
289
|
+
"estimated_remaining": round(estimated_remaining, 2),
|
|
290
|
+
"estimated_total": round(spent + estimated_remaining, 2),
|
|
291
|
+
"remaining_tickets": remaining_tickets,
|
|
292
|
+
"avg_cost_per_ticket": round(avg_cost_per_ticket, 4),
|
|
293
|
+
}
|