skillpool 4.3.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.
- skillpool/__init__.py +74 -0
- skillpool/__main__.py +6 -0
- skillpool/adapters/__init__.py +8 -0
- skillpool/adapters/base.py +41 -0
- skillpool/adapters/claude_adapter.py +36 -0
- skillpool/adapters/codex_adapter.py +92 -0
- skillpool/adapters/hermes_adapter.py +38 -0
- skillpool/audit/__init__.py +651 -0
- skillpool/bridge/__init__.py +16 -0
- skillpool/bridge/freeze_detector.py +134 -0
- skillpool/bridge/maintenance.py +119 -0
- skillpool/bridge/wal_manager.py +136 -0
- skillpool/clawmem_client.py +176 -0
- skillpool/cli.py +700 -0
- skillpool/combiner/__init__.py +31 -0
- skillpool/combiner/lifecycle.py +453 -0
- skillpool/combiner/models.py +99 -0
- skillpool/config.py +34 -0
- skillpool/cost/__init__.py +111 -0
- skillpool/cost/audit_hash.py +51 -0
- skillpool/cost/budget_tracker.py +66 -0
- skillpool/cost/dashboard.py +189 -0
- skillpool/cost/models.py +129 -0
- skillpool/cost/token_governor.py +264 -0
- skillpool/cost/trace_ceiling.py +38 -0
- skillpool/csdf.py +126 -0
- skillpool/evolver/__init__.py +978 -0
- skillpool/gain/__init__.py +285 -0
- skillpool/gate.py +282 -0
- skillpool/gate_policy/__init__.py +31 -0
- skillpool/gate_policy/incremental.py +157 -0
- skillpool/gate_policy/parser.py +258 -0
- skillpool/gate_policy/state_machine.py +432 -0
- skillpool/graph/__init__.py +14 -0
- skillpool/graph/ppr.py +279 -0
- skillpool/health/__init__.py +73 -0
- skillpool/health/check.py +85 -0
- skillpool/health/degradation.py +90 -0
- skillpool/health/models.py +43 -0
- skillpool/hooks/__init__.py +4 -0
- skillpool/hooks/security_scanner.py +288 -0
- skillpool/lifecycle.py +150 -0
- skillpool/materializer/__init__.py +124 -0
- skillpool/materializer/budget_cropper.py +178 -0
- skillpool/materializer/csdf_loader.py +114 -0
- skillpool/materializer/lazy_loader.py +265 -0
- skillpool/materializer/lifecycle_filter.py +93 -0
- skillpool/materializer/mapper.py +178 -0
- skillpool/materializer/models.py +66 -0
- skillpool/mcp_server.py +2005 -0
- skillpool/monitor/__init__.py +576 -0
- skillpool/monitor/bug_collector.py +392 -0
- skillpool/monitor/defect_classifier.py +218 -0
- skillpool/monitor/self_healing.py +530 -0
- skillpool/monitor/telemetry_bridge.py +197 -0
- skillpool/paradigm/__init__.py +312 -0
- skillpool/paradigm/override.py +285 -0
- skillpool/profile.py +94 -0
- skillpool/quality.py +254 -0
- skillpool/registry/__init__.py +509 -0
- skillpool/registry/models.py +98 -0
- skillpool/resolver/__init__.py +320 -0
- skillpool/resolver/cache.py +103 -0
- skillpool/resolver/circuit_breaker.py +103 -0
- skillpool/resolver/conflict_detector.py +111 -0
- skillpool/resolver/health_filter.py +38 -0
- skillpool/resolver/models.py +154 -0
- skillpool/resolver/rate_limiter.py +48 -0
- skillpool/resolver/skill_graph.py +183 -0
- skillpool/review/__init__.py +242 -0
- skillpool/review/async_queue.py +96 -0
- skillpool/review/checkpoint_runner.py +345 -0
- skillpool/review/models.py +164 -0
- skillpool/review/suspect_marker.py +39 -0
- skillpool/review/veto_evaluator.py +94 -0
- skillpool/router/__init__.py +481 -0
- skillpool/schemas.py +119 -0
- skillpool/synergy/__init__.py +240 -0
- skillpool/synergy/detector.py +5 -0
- skillpool/telemetry.py +126 -0
- skillpool/utils/__init__.py +21 -0
- skillpool/utils/changelog.py +218 -0
- skillpool/utils/logger.py +273 -0
- skillpool/utils/runtime_audit.py +163 -0
- skillpool/utils/time_utils.py +13 -0
- skillpool-4.3.0.dist-info/METADATA +21 -0
- skillpool-4.3.0.dist-info/RECORD +90 -0
- skillpool-4.3.0.dist-info/WHEEL +5 -0
- skillpool-4.3.0.dist-info/entry_points.txt +3 -0
- skillpool-4.3.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
"""CostManager — unified cost tracking, budgeting, and throttling."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from skillpool.cost.models import (
|
|
6
|
+
BudgetStatus,
|
|
7
|
+
CostDashboardResponse,
|
|
8
|
+
CostQuery,
|
|
9
|
+
CostRecord,
|
|
10
|
+
ThrottleAction,
|
|
11
|
+
)
|
|
12
|
+
from skillpool.cost.token_governor import TokenGovernor
|
|
13
|
+
from skillpool.cost.budget_tracker import BudgetTracker
|
|
14
|
+
from skillpool.cost.trace_ceiling import TraceCeiling
|
|
15
|
+
from skillpool.cost.audit_hash import AuditHashChain
|
|
16
|
+
from skillpool.cost.dashboard import CostDashboard
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class CostManager:
|
|
20
|
+
"""Central cost management: recording, throttling, budgeting, auditing.
|
|
21
|
+
|
|
22
|
+
Combines TokenGovernor, BudgetTracker, TraceCeiling, AuditHashChain/AuditLayer,
|
|
23
|
+
and CostDashboard into a single facade.
|
|
24
|
+
|
|
25
|
+
When an AuditLayer is provided via audit_layer parameter, it is used for
|
|
26
|
+
full 34-field OTel audit records. Otherwise the lightweight AuditHashChain
|
|
27
|
+
is used for basic hash chain integrity.
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
def __init__(
|
|
31
|
+
self,
|
|
32
|
+
monthly_budget_usd: float = 5000.0,
|
|
33
|
+
trace_ceiling_usd: float = 5.0,
|
|
34
|
+
audit_layer=None,
|
|
35
|
+
) -> None:
|
|
36
|
+
self._governor = TokenGovernor()
|
|
37
|
+
self._budget = BudgetTracker(monthly_budget_usd=monthly_budget_usd)
|
|
38
|
+
self._trace_ceiling = TraceCeiling(ceiling_usd=trace_ceiling_usd)
|
|
39
|
+
# Use full AuditLayer if provided, otherwise lightweight AuditHashChain
|
|
40
|
+
if audit_layer is not None:
|
|
41
|
+
self._audit = audit_layer
|
|
42
|
+
self._audit_full = True
|
|
43
|
+
else:
|
|
44
|
+
self._audit = AuditHashChain()
|
|
45
|
+
self._audit_full = False
|
|
46
|
+
self._dashboard = CostDashboard(governor=self._governor, budget_tracker=self._budget)
|
|
47
|
+
|
|
48
|
+
def report_cost(self, record: CostRecord) -> bool:
|
|
49
|
+
"""Process a cost record: validate, record, check throttles.
|
|
50
|
+
|
|
51
|
+
Returns True if the cost was accepted, False if rejected by
|
|
52
|
+
budget or trace ceiling.
|
|
53
|
+
"""
|
|
54
|
+
total_tokens = record.tokens_input + record.tokens_output
|
|
55
|
+
|
|
56
|
+
# Check trace ceiling
|
|
57
|
+
allowed, reason = self._trace_ceiling.check(record.trace_id, record.cost_usd)
|
|
58
|
+
if not allowed:
|
|
59
|
+
return False
|
|
60
|
+
|
|
61
|
+
# Check budget threshold
|
|
62
|
+
threshold, action = self._budget.check_budget_threshold()
|
|
63
|
+
if action == "block":
|
|
64
|
+
return False
|
|
65
|
+
|
|
66
|
+
# Record usage
|
|
67
|
+
self._governor.record_usage(record.agent_id, total_tokens)
|
|
68
|
+
self._budget.record_cost(record.cost_usd)
|
|
69
|
+
self._trace_ceiling.record_trace_cost(record.trace_id, record.cost_usd)
|
|
70
|
+
|
|
71
|
+
# Audit record: full AuditLayer or lightweight AuditHashChain
|
|
72
|
+
if self._audit_full:
|
|
73
|
+
self._audit.append(
|
|
74
|
+
action="cost_record",
|
|
75
|
+
object_id=record.agent_id,
|
|
76
|
+
result="success",
|
|
77
|
+
metadata={
|
|
78
|
+
"cost_usd": record.cost_usd,
|
|
79
|
+
"tokens_input": record.tokens_input,
|
|
80
|
+
"tokens_output": record.tokens_output,
|
|
81
|
+
"trace_id": record.trace_id,
|
|
82
|
+
},
|
|
83
|
+
trace_id=record.trace_id,
|
|
84
|
+
)
|
|
85
|
+
else:
|
|
86
|
+
self._audit.append(record.model_dump(mode="json"))
|
|
87
|
+
|
|
88
|
+
self._dashboard.record(
|
|
89
|
+
agent_id=record.agent_id,
|
|
90
|
+
tokens_input=record.tokens_input,
|
|
91
|
+
tokens_output=record.tokens_output,
|
|
92
|
+
cost_usd=record.cost_usd,
|
|
93
|
+
model=record.model,
|
|
94
|
+
operation=record.operation,
|
|
95
|
+
skill_id=record.skill_id,
|
|
96
|
+
)
|
|
97
|
+
|
|
98
|
+
return True
|
|
99
|
+
|
|
100
|
+
def get_dashboard(self, query: CostQuery) -> CostDashboardResponse:
|
|
101
|
+
"""Query the cost dashboard."""
|
|
102
|
+
return self._dashboard.query(query)
|
|
103
|
+
|
|
104
|
+
def get_budget(self) -> BudgetStatus:
|
|
105
|
+
"""Return current monthly budget status."""
|
|
106
|
+
return self._dashboard.get_budget_status()
|
|
107
|
+
|
|
108
|
+
def check_throttle(self, agent_id: str, tokens: int) -> ThrottleAction:
|
|
109
|
+
"""Check throttle status for an agent/token request."""
|
|
110
|
+
action, _ = self._governor.check(agent_id, tokens)
|
|
111
|
+
return action
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
"""AuditHashChain — SHA-256 hash chain for cost record integrity."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import hashlib
|
|
6
|
+
import json
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class AuditHashChain:
|
|
10
|
+
"""Maintain a tamper-evident hash chain over cost records.
|
|
11
|
+
|
|
12
|
+
Each record's hash is computed from the previous hash + current record data,
|
|
13
|
+
creating a linked chain where any modification to a prior record invalidates
|
|
14
|
+
all subsequent hashes.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
def __init__(self) -> None:
|
|
18
|
+
self._hashes: list[str] = []
|
|
19
|
+
self._records: list[dict] = []
|
|
20
|
+
|
|
21
|
+
@staticmethod
|
|
22
|
+
def compute_hash(previous_hash: str, record_data: dict) -> str:
|
|
23
|
+
"""Compute SHA-256 hash from previous hash and record data."""
|
|
24
|
+
payload = previous_hash + json.dumps(record_data, sort_keys=True, separators=(",", ":"))
|
|
25
|
+
return hashlib.sha256(payload.encode()).hexdigest()
|
|
26
|
+
|
|
27
|
+
def append(self, record_data: dict) -> str:
|
|
28
|
+
"""Append a record to the chain and return its hash."""
|
|
29
|
+
previous = self._hashes[-1] if self._hashes else "0" * 64
|
|
30
|
+
new_hash = self.compute_hash(previous, record_data)
|
|
31
|
+
self._hashes.append(new_hash)
|
|
32
|
+
self._records.append(record_data)
|
|
33
|
+
return new_hash
|
|
34
|
+
|
|
35
|
+
def verify_chain(self) -> bool:
|
|
36
|
+
"""Verify the entire hash chain is consistent.
|
|
37
|
+
|
|
38
|
+
Recomputes every hash from scratch and checks against stored values.
|
|
39
|
+
Returns True if the chain is intact.
|
|
40
|
+
"""
|
|
41
|
+
previous = "0" * 64
|
|
42
|
+
for i, record in enumerate(self._records):
|
|
43
|
+
expected = self.compute_hash(previous, record)
|
|
44
|
+
if self._hashes[i] != expected:
|
|
45
|
+
return False
|
|
46
|
+
previous = expected
|
|
47
|
+
return True
|
|
48
|
+
|
|
49
|
+
def get_chain(self) -> list[str]:
|
|
50
|
+
"""Return a copy of the hash chain."""
|
|
51
|
+
return list(self._hashes)
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
"""BudgetTracker — monthly budget enforcement with threshold alerts."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from skillpool.cost.models import AgentConfig, BudgetStatus
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class BudgetTracker:
|
|
9
|
+
"""Track monthly cost against a budget with threshold actions.
|
|
10
|
+
|
|
11
|
+
Thresholds:
|
|
12
|
+
- >=50% remaining: normal
|
|
13
|
+
- >=25% remaining: caution
|
|
14
|
+
- >=10% remaining: warning
|
|
15
|
+
- 0% remaining: critical (all spending blocked)
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
def __init__(self, monthly_budget_usd: float = 5000.0) -> None:
|
|
19
|
+
self.monthly_budget_usd = monthly_budget_usd
|
|
20
|
+
self._consumed_usd: float = 0.0
|
|
21
|
+
|
|
22
|
+
def record_cost(self, cost_usd: float) -> None:
|
|
23
|
+
"""Record a cost event against the monthly budget."""
|
|
24
|
+
self._consumed_usd += cost_usd
|
|
25
|
+
|
|
26
|
+
def get_status(self, agent_configs: list[AgentConfig] | None = None) -> BudgetStatus:
|
|
27
|
+
"""Return current monthly budget status."""
|
|
28
|
+
remaining = self.monthly_budget_usd - self._consumed_usd
|
|
29
|
+
consumed_pct = self.get_consumed_pct()
|
|
30
|
+
# Project: if we've used X% so far, assume linear burn to month end
|
|
31
|
+
projected = self._consumed_usd # simplified; real impl would use day-of-month
|
|
32
|
+
if consumed_pct > 0:
|
|
33
|
+
# Approximate projection assuming mid-month
|
|
34
|
+
projected = self._consumed_usd * 2.0
|
|
35
|
+
return BudgetStatus(
|
|
36
|
+
monthly_budget_usd=self.monthly_budget_usd,
|
|
37
|
+
consumed_usd=round(self._consumed_usd, 4),
|
|
38
|
+
remaining_usd=round(max(0.0, remaining), 4),
|
|
39
|
+
consumed_pct=round(consumed_pct, 4),
|
|
40
|
+
projected_month_end_usd=round(projected, 4),
|
|
41
|
+
per_agent_limits=agent_configs or [],
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
def get_consumed_pct(self) -> float:
|
|
45
|
+
"""Return percentage of monthly budget consumed (0.0 - 1.0+)."""
|
|
46
|
+
if self.monthly_budget_usd <= 0:
|
|
47
|
+
return 1.0
|
|
48
|
+
return self._consumed_usd / self.monthly_budget_usd
|
|
49
|
+
|
|
50
|
+
def check_budget_threshold(self) -> tuple[str, str]:
|
|
51
|
+
"""Check budget threshold and return (threshold_name, action).
|
|
52
|
+
|
|
53
|
+
Returns:
|
|
54
|
+
- ("normal", "continue") when >50% budget remaining
|
|
55
|
+
- ("caution", "monitor") when 25-50% remaining
|
|
56
|
+
- ("warning", "throttle") when 10-25% remaining
|
|
57
|
+
- ("critical", "block") when <10% remaining
|
|
58
|
+
"""
|
|
59
|
+
remaining_pct = 1.0 - self.get_consumed_pct()
|
|
60
|
+
if remaining_pct < 0.10:
|
|
61
|
+
return "critical", "block"
|
|
62
|
+
if remaining_pct < 0.25:
|
|
63
|
+
return "warning", "throttle"
|
|
64
|
+
if remaining_pct < 0.50:
|
|
65
|
+
return "caution", "monitor"
|
|
66
|
+
return "normal", "continue"
|
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
"""CostDashboard — aggregated cost query and reporting."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from datetime import datetime, timedelta
|
|
6
|
+
|
|
7
|
+
from skillpool.cost.models import (
|
|
8
|
+
AgentCost,
|
|
9
|
+
CostDashboardResponse,
|
|
10
|
+
CostQuery,
|
|
11
|
+
BudgetStatus,
|
|
12
|
+
)
|
|
13
|
+
from skillpool.cost.token_governor import TokenGovernor
|
|
14
|
+
from skillpool.cost.budget_tracker import BudgetTracker
|
|
15
|
+
from skillpool.utils.time_utils import utc_now
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class CostDashboard:
|
|
19
|
+
"""Aggregate cost data across agents for dashboard queries.
|
|
20
|
+
|
|
21
|
+
Uses TokenGovernor for per-agent budget/throttle state and
|
|
22
|
+
BudgetTracker for monthly budget status.
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
def __init__(
|
|
26
|
+
self,
|
|
27
|
+
governor: TokenGovernor,
|
|
28
|
+
budget_tracker: BudgetTracker,
|
|
29
|
+
) -> None:
|
|
30
|
+
self._governor = governor
|
|
31
|
+
self._budget = budget_tracker
|
|
32
|
+
# In-memory cost records for aggregation
|
|
33
|
+
self._records: list[dict] = []
|
|
34
|
+
|
|
35
|
+
def record(
|
|
36
|
+
self,
|
|
37
|
+
agent_id: str,
|
|
38
|
+
tokens_input: int,
|
|
39
|
+
tokens_output: int,
|
|
40
|
+
cost_usd: float,
|
|
41
|
+
model: str = "",
|
|
42
|
+
operation: str = "",
|
|
43
|
+
skill_id: str = "",
|
|
44
|
+
) -> None:
|
|
45
|
+
"""Store a cost record for later querying."""
|
|
46
|
+
self._records.append(
|
|
47
|
+
{
|
|
48
|
+
"agent_id": agent_id,
|
|
49
|
+
"tokens_input": tokens_input,
|
|
50
|
+
"tokens_output": tokens_output,
|
|
51
|
+
"cost_usd": cost_usd,
|
|
52
|
+
"model": model,
|
|
53
|
+
"operation": operation,
|
|
54
|
+
"skill_id": skill_id,
|
|
55
|
+
"timestamp": utc_now().isoformat(),
|
|
56
|
+
}
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
def query(self, request: CostQuery) -> CostDashboardResponse:
|
|
60
|
+
"""Query aggregated cost data.
|
|
61
|
+
|
|
62
|
+
Supports multi-dimension grouping: agent_id, model, skill_id, operation.
|
|
63
|
+
"""
|
|
64
|
+
# Filter by agent_id if specified
|
|
65
|
+
records = self._records
|
|
66
|
+
if request.agent_id:
|
|
67
|
+
records = [r for r in records if r["agent_id"] == request.agent_id]
|
|
68
|
+
|
|
69
|
+
# Aggregate by agent
|
|
70
|
+
agent_data: dict[str, dict] = {}
|
|
71
|
+
model_data: dict[str, dict] = {}
|
|
72
|
+
skill_data: dict[str, dict] = {}
|
|
73
|
+
op_data: dict[str, dict] = {}
|
|
74
|
+
total_cost = 0.0
|
|
75
|
+
total_tokens = 0
|
|
76
|
+
|
|
77
|
+
for r in records:
|
|
78
|
+
aid = r["agent_id"]
|
|
79
|
+
if aid not in agent_data:
|
|
80
|
+
agent_data[aid] = {"tokens": 0, "cost_usd": 0.0}
|
|
81
|
+
agent_data[aid]["tokens"] += r["tokens_input"] + r["tokens_output"]
|
|
82
|
+
agent_data[aid]["cost_usd"] += r["cost_usd"]
|
|
83
|
+
|
|
84
|
+
# Group by model
|
|
85
|
+
model = r.get("model", "")
|
|
86
|
+
if model:
|
|
87
|
+
if model not in model_data:
|
|
88
|
+
model_data[model] = {"tokens": 0, "cost_usd": 0.0}
|
|
89
|
+
model_data[model]["tokens"] += r["tokens_input"] + r["tokens_output"]
|
|
90
|
+
model_data[model]["cost_usd"] += r["cost_usd"]
|
|
91
|
+
|
|
92
|
+
# Group by skill_id
|
|
93
|
+
skill = r.get("skill_id", "")
|
|
94
|
+
if skill:
|
|
95
|
+
if skill not in skill_data:
|
|
96
|
+
skill_data[skill] = {"tokens": 0, "cost_usd": 0.0}
|
|
97
|
+
skill_data[skill]["tokens"] += r["tokens_input"] + r["tokens_output"]
|
|
98
|
+
skill_data[skill]["cost_usd"] += r["cost_usd"]
|
|
99
|
+
|
|
100
|
+
# Group by operation
|
|
101
|
+
op = r.get("operation", "")
|
|
102
|
+
if op:
|
|
103
|
+
if op not in op_data:
|
|
104
|
+
op_data[op] = {"tokens": 0, "cost_usd": 0.0}
|
|
105
|
+
op_data[op]["tokens"] += r["tokens_input"] + r["tokens_output"]
|
|
106
|
+
op_data[op]["cost_usd"] += r["cost_usd"]
|
|
107
|
+
|
|
108
|
+
total_cost += r["cost_usd"]
|
|
109
|
+
total_tokens += r["tokens_input"] + r["tokens_output"]
|
|
110
|
+
|
|
111
|
+
# Build per-agent summaries
|
|
112
|
+
by_agent: list[AgentCost] = []
|
|
113
|
+
for aid, data in agent_data.items():
|
|
114
|
+
cfg = self._governor.get_config(aid)
|
|
115
|
+
pct_of_total = (data["cost_usd"] / total_cost * 100) if total_cost > 0 else 0.0
|
|
116
|
+
budget_limit = cfg.daily_limit_tokens if cfg else 0
|
|
117
|
+
daily_usage = self._governor.get_daily_usage(aid)
|
|
118
|
+
budget_consumed_pct = (daily_usage / budget_limit) if budget_limit > 0 else 0.0
|
|
119
|
+
|
|
120
|
+
by_agent.append(
|
|
121
|
+
AgentCost(
|
|
122
|
+
agent_id=aid,
|
|
123
|
+
agent_type=cfg.agent_type if cfg else "",
|
|
124
|
+
tokens=data["tokens"],
|
|
125
|
+
cost_usd=round(data["cost_usd"], 4),
|
|
126
|
+
pct_of_total=round(pct_of_total, 2),
|
|
127
|
+
budget_limit=budget_limit,
|
|
128
|
+
budget_consumed_pct=round(budget_consumed_pct, 4),
|
|
129
|
+
throttled=self._governor.is_throttled(aid),
|
|
130
|
+
)
|
|
131
|
+
)
|
|
132
|
+
|
|
133
|
+
budget_pct = self._budget.get_consumed_pct() * 100
|
|
134
|
+
|
|
135
|
+
# Projected overspend
|
|
136
|
+
consumed_pct = self._budget.get_consumed_pct()
|
|
137
|
+
projected_overspend = max(0.0, (consumed_pct * 2.0 - 1.0) * 100) if consumed_pct > 0.5 else 0.0
|
|
138
|
+
|
|
139
|
+
# Time series generation
|
|
140
|
+
series = self._build_time_series(records, request.granularity)
|
|
141
|
+
|
|
142
|
+
return CostDashboardResponse(
|
|
143
|
+
window=request.window,
|
|
144
|
+
total_cost_usd=round(total_cost, 4),
|
|
145
|
+
total_tokens=total_tokens,
|
|
146
|
+
monthly_budget_usd=self._budget.monthly_budget_usd,
|
|
147
|
+
monthly_budget_consumed_pct=round(budget_pct, 2),
|
|
148
|
+
by_agent=by_agent,
|
|
149
|
+
by_model=model_data,
|
|
150
|
+
by_skill=skill_data,
|
|
151
|
+
by_operation=op_data,
|
|
152
|
+
series=series,
|
|
153
|
+
projected_overspend_pct=round(projected_overspend, 2),
|
|
154
|
+
)
|
|
155
|
+
|
|
156
|
+
@staticmethod
|
|
157
|
+
def _build_time_series(records: list[dict], granularity: str) -> list[dict]:
|
|
158
|
+
"""Build time series buckets from cost records.
|
|
159
|
+
|
|
160
|
+
granularity: "5m" → 5-minute buckets, "1h" → hourly, "1d" → daily.
|
|
161
|
+
"""
|
|
162
|
+
if not records:
|
|
163
|
+
return []
|
|
164
|
+
|
|
165
|
+
delta_map = {"5m": timedelta(minutes=5), "1h": timedelta(hours=1), "1d": timedelta(days=1)}
|
|
166
|
+
_delta = delta_map.get(granularity, timedelta(hours=1))
|
|
167
|
+
|
|
168
|
+
buckets: dict[str, dict] = {}
|
|
169
|
+
for r in records:
|
|
170
|
+
ts = datetime.fromisoformat(r["timestamp"])
|
|
171
|
+
# Floor to bucket boundary
|
|
172
|
+
if granularity == "5m":
|
|
173
|
+
bucket_key = ts.replace(minute=(ts.minute // 5) * 5, second=0, microsecond=0).isoformat()
|
|
174
|
+
elif granularity == "1d":
|
|
175
|
+
bucket_key = ts.replace(hour=0, minute=0, second=0, microsecond=0).isoformat()
|
|
176
|
+
else: # 1h
|
|
177
|
+
bucket_key = ts.replace(minute=0, second=0, microsecond=0).isoformat()
|
|
178
|
+
|
|
179
|
+
if bucket_key not in buckets:
|
|
180
|
+
buckets[bucket_key] = {"timestamp": bucket_key, "tokens": 0, "cost_usd": 0.0}
|
|
181
|
+
buckets[bucket_key]["tokens"] += r["tokens_input"] + r["tokens_output"]
|
|
182
|
+
buckets[bucket_key]["cost_usd"] += r["cost_usd"]
|
|
183
|
+
|
|
184
|
+
return sorted(buckets.values(), key=lambda b: b["timestamp"])
|
|
185
|
+
|
|
186
|
+
def get_budget_status(self) -> BudgetStatus:
|
|
187
|
+
"""Return current monthly budget status."""
|
|
188
|
+
configs = list(self._governor._configs.values())
|
|
189
|
+
return self._budget.get_status(agent_configs=configs)
|
skillpool/cost/models.py
ADDED
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
"""Cost models — Pydantic schemas for cost tracking and budgeting."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from datetime import datetime
|
|
6
|
+
from enum import StrEnum
|
|
7
|
+
from typing import Optional
|
|
8
|
+
|
|
9
|
+
from pydantic import BaseModel, Field
|
|
10
|
+
|
|
11
|
+
from skillpool.utils.time_utils import utc_now
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class ThrottleAction(StrEnum):
|
|
15
|
+
ALLOW = "allow"
|
|
16
|
+
THROTTLE = "throttle"
|
|
17
|
+
REJECT = "reject"
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class AgentConfig(BaseModel):
|
|
21
|
+
"""Per-agent token budget configuration."""
|
|
22
|
+
|
|
23
|
+
agent_id: str
|
|
24
|
+
agent_type: str = "worker"
|
|
25
|
+
daily_limit_tokens: int = Field(default=0, description="0 means unlimited")
|
|
26
|
+
throttle_at_pct: float = Field(default=0.8, ge=0.0, le=1.0)
|
|
27
|
+
throttle_to_pct: float = Field(default=0.25, ge=0.0, le=1.0)
|
|
28
|
+
is_critical: bool = False
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class CostRecord(BaseModel):
|
|
32
|
+
"""A single cost event record."""
|
|
33
|
+
|
|
34
|
+
agent_id: str
|
|
35
|
+
tokens_input: int = 0
|
|
36
|
+
tokens_output: int = 0
|
|
37
|
+
cost_usd: float = Field(default=0.0, ge=0.0)
|
|
38
|
+
model: str = ""
|
|
39
|
+
operation: str = ""
|
|
40
|
+
trace_id: str = ""
|
|
41
|
+
skill_id: str = "" # V4.1: per-skill cost attribution
|
|
42
|
+
timestamp: datetime = Field(default_factory=utc_now)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class CostQuery(BaseModel):
|
|
46
|
+
"""Query parameters for cost dashboard.
|
|
47
|
+
|
|
48
|
+
V4.1: Supports multi-dimension grouping and granularity.
|
|
49
|
+
"""
|
|
50
|
+
|
|
51
|
+
window: str = Field(default="24h", pattern=r"^(1h|6h|24h|7d|30d)$")
|
|
52
|
+
group_by: list[str] = Field(default_factory=lambda: ["agent_id"])
|
|
53
|
+
agent_id: Optional[str] = None
|
|
54
|
+
granularity: str = Field(default="1d", pattern=r"^(5m|1h|1d)$", description="Time series granularity")
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
class AgentCost(BaseModel):
|
|
58
|
+
"""Cost summary for a single agent."""
|
|
59
|
+
|
|
60
|
+
agent_id: str
|
|
61
|
+
agent_type: str = ""
|
|
62
|
+
tokens: int = 0
|
|
63
|
+
cost_usd: float = 0.0
|
|
64
|
+
pct_of_total: float = 0.0
|
|
65
|
+
budget_limit: int = 0
|
|
66
|
+
budget_consumed_pct: float = 0.0
|
|
67
|
+
throttled: bool = False
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
class CostDashboardResponse(BaseModel):
|
|
71
|
+
"""Response from cost dashboard query.
|
|
72
|
+
|
|
73
|
+
V4.1: Multi-dimension grouping (by_agent, by_model, by_skill, by_operation)
|
|
74
|
+
and time series data.
|
|
75
|
+
"""
|
|
76
|
+
|
|
77
|
+
window: str
|
|
78
|
+
total_cost_usd: float = 0.0
|
|
79
|
+
total_tokens: int = 0
|
|
80
|
+
monthly_budget_usd: float = 0.0
|
|
81
|
+
monthly_budget_consumed_pct: float = 0.0
|
|
82
|
+
by_agent: list[AgentCost] = Field(default_factory=list)
|
|
83
|
+
by_model: dict[str, dict] = Field(
|
|
84
|
+
default_factory=dict, description="Cost grouped by model: {model: {tokens, cost_usd}}"
|
|
85
|
+
)
|
|
86
|
+
by_skill: dict[str, dict] = Field(
|
|
87
|
+
default_factory=dict, description="Cost grouped by skill_id: {skill_id: {tokens, cost_usd}}"
|
|
88
|
+
)
|
|
89
|
+
by_operation: dict[str, dict] = Field(
|
|
90
|
+
default_factory=dict, description="Cost grouped by operation: {op: {tokens, cost_usd}}"
|
|
91
|
+
)
|
|
92
|
+
series: list[dict] = Field(
|
|
93
|
+
default_factory=list, description="Time series data [{timestamp, agent_id, tokens, cost_usd}]"
|
|
94
|
+
)
|
|
95
|
+
projected_overspend_pct: float = Field(
|
|
96
|
+
default=0.0, description="Projected overspend percentage if current burn rate continues"
|
|
97
|
+
)
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
class BudgetStatus(BaseModel):
|
|
101
|
+
"""Monthly budget status."""
|
|
102
|
+
|
|
103
|
+
monthly_budget_usd: float
|
|
104
|
+
consumed_usd: float = 0.0
|
|
105
|
+
remaining_usd: float = 0.0
|
|
106
|
+
consumed_pct: float = 0.0
|
|
107
|
+
projected_month_end_usd: float = 0.0
|
|
108
|
+
per_agent_limits: list[AgentConfig] = Field(default_factory=list)
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
class CostEstimate(BaseModel):
|
|
112
|
+
"""Session cost estimation result.
|
|
113
|
+
|
|
114
|
+
V4.2: P50 conservative pricing model for cost estimation.
|
|
115
|
+
Combines skill execution cost + review overhead + checkpoint overhead.
|
|
116
|
+
"""
|
|
117
|
+
|
|
118
|
+
skill_id: str
|
|
119
|
+
skill_length: int = Field(description="Character count of skill definition")
|
|
120
|
+
token_count: int = Field(description="Estimated token count")
|
|
121
|
+
base_cost_usd: float = Field(description="Skill execution cost (P50 pricing)")
|
|
122
|
+
l2_review_overhead_usd: float = Field(default=0.0, description="L2 checkpoint review overhead")
|
|
123
|
+
l3_review_overhead_usd: float = Field(default=0.0, description="L3+L2+ checkpoint review overhead")
|
|
124
|
+
review_checkpoint_overhead_usd: float = Field(default=0.0, description="Review checkpoint overhead")
|
|
125
|
+
total_cost_usd: float = Field(description="Total estimated cost")
|
|
126
|
+
price_per_1k_tokens: float = Field(default=0.003, description="P50 pricing: $0.003/1K tokens")
|
|
127
|
+
gate_passed: bool = Field(default=True, description="Gate validation result")
|
|
128
|
+
gate_block_reason: str | None = Field(default=None, description="Gate block reason if failed")
|
|
129
|
+
emergency_bypass_active: bool = Field(default=False, description="Emergency bypass status")
|