cost-intel 0.1.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.
cost_intel/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ """Cost Intelligence — AI spending tracker with cost-quality correlation."""
2
+
3
+ __version__ = "0.1.0"
cost_intel/__main__.py ADDED
@@ -0,0 +1,6 @@
1
+ """Allow running as `python -m cost_intel`."""
2
+
3
+ from cost_intel.cli import app
4
+
5
+ if __name__ == "__main__":
6
+ app()
@@ -0,0 +1 @@
1
+ """Quality score import adapters."""
@@ -0,0 +1,67 @@
1
+ """Braintrust adapter — import quality scores via the Braintrust REST API."""
2
+
3
+ from typing import Optional
4
+
5
+ import httpx
6
+
7
+ from cost_intel.quality import import_score
8
+
9
+
10
+ def import_from_api(
11
+ api_key: str,
12
+ project_id: str,
13
+ experiment_id: Optional[str] = None,
14
+ source: str = "braintrust",
15
+ base_url: str = "https://api.braintrust.dev/v1",
16
+ ) -> int:
17
+ """Fetch quality scores from Braintrust and import them.
18
+
19
+ Args:
20
+ api_key: Braintrust API key (sent as ``Authorization: Bearer``).
21
+ project_id: Braintrust project identifier.
22
+ experiment_id: Optional experiment id. If omitted, all
23
+ experiments under the project are imported.
24
+ source: Provenance label written to ``quality_scores.source``.
25
+ base_url: Braintrust API base URL.
26
+
27
+ Returns:
28
+ Count of imported rows.
29
+ """
30
+ headers = {"Authorization": f"Bearer {api_key}"}
31
+ count = 0
32
+
33
+ with httpx.Client(base_url=base_url, headers=headers, timeout=30) as client:
34
+ if experiment_id:
35
+ exp_ids = [experiment_id]
36
+ else:
37
+ resp = client.get(f"/projects/{project_id}/experiments")
38
+ resp.raise_for_status()
39
+ experiments = resp.json().get("data", [])
40
+ exp_ids = [exp["id"] for exp in experiments]
41
+
42
+ for eid in exp_ids:
43
+ resp = client.get(f"/experiments/{eid}/events")
44
+ resp.raise_for_status()
45
+ events = resp.json().get("data", [])
46
+
47
+ for event in events:
48
+ run_id = event.get("run_id") or event.get("id")
49
+ scores = event.get("scores", {})
50
+ if not run_id or not scores:
51
+ continue
52
+ score_val = scores.get("quality") or scores.get("score")
53
+ if score_val is None:
54
+ numeric = [
55
+ v for v in scores.values() if isinstance(v, (int, float))
56
+ ]
57
+ score_val = numeric[0] if numeric else None
58
+ if score_val is not None:
59
+ import_score(
60
+ run_id=str(run_id),
61
+ score=float(score_val),
62
+ source=source,
63
+ eval_dimensions=scores if len(scores) > 1 else None,
64
+ )
65
+ count += 1
66
+
67
+ return count
@@ -0,0 +1,53 @@
1
+ """Eval Harness adapter — import quality scores from a SQLite database."""
2
+
3
+ import sqlite3
4
+
5
+ from cost_intel.quality import import_score
6
+
7
+
8
+ def import_from_db(
9
+ db_path: str,
10
+ source: str = "eval_harness",
11
+ run_id_column: str = "run_id",
12
+ score_column: str = "score",
13
+ ) -> int:
14
+ """Read scores from an Eval Harness SQLite DB and import them.
15
+
16
+ The adapter tries the ``results`` table first, then ``eval_results``
17
+ as a fallback. Returns ``0`` if neither table exists.
18
+
19
+ Args:
20
+ db_path: Filesystem path to the Eval Harness database.
21
+ source: Provenance label written to ``quality_scores.source``.
22
+ run_id_column: Name of the column holding the run id.
23
+ score_column: Name of the column holding the score.
24
+
25
+ Returns:
26
+ Count of imported rows.
27
+ """
28
+ conn = sqlite3.connect(db_path)
29
+ conn.row_factory = sqlite3.Row
30
+ rows: list = []
31
+ try:
32
+ try:
33
+ rows = conn.execute(
34
+ f"SELECT {run_id_column}, {score_column} FROM results"
35
+ ).fetchall()
36
+ except sqlite3.OperationalError:
37
+ try:
38
+ rows = conn.execute(
39
+ f"SELECT {run_id_column}, {score_column} FROM eval_results"
40
+ ).fetchall()
41
+ except sqlite3.OperationalError:
42
+ return 0
43
+ finally:
44
+ conn.close()
45
+
46
+ count = 0
47
+ for row in rows:
48
+ run_id = str(row[run_id_column]) if row[run_id_column] else None
49
+ score = float(row[score_column]) if row[score_column] is not None else None
50
+ if run_id and score is not None:
51
+ import_score(run_id=run_id, score=score, source=source)
52
+ count += 1
53
+ return count
cost_intel/alerts.py ADDED
@@ -0,0 +1,134 @@
1
+ """Budget alert dispatch — Slack webhook + SMTP email."""
2
+
3
+ import smtplib
4
+ from email.mime.text import MIMEText
5
+ from typing import Any
6
+
7
+ import httpx
8
+
9
+ from cost_intel.budget import get_budget_status
10
+ from cost_intel.config import load_config
11
+
12
+
13
+ def send_slack_alert(webhook_url: str, message: str) -> bool:
14
+ """Send alert to Slack via incoming webhook.
15
+
16
+ Args:
17
+ webhook_url: Slack incoming webhook URL.
18
+ message: Plain text body for the Slack message.
19
+
20
+ Returns:
21
+ True on HTTP 200 response, False otherwise (including empty URL
22
+ or any exception).
23
+ """
24
+ if not webhook_url:
25
+ return False
26
+ try:
27
+ resp = httpx.post(
28
+ webhook_url,
29
+ json={"text": message},
30
+ timeout=10.0,
31
+ )
32
+ return resp.status_code == 200
33
+ except Exception:
34
+ return False
35
+
36
+
37
+ def send_email_alert(
38
+ smtp_host: str,
39
+ smtp_from: str,
40
+ recipients: list[str],
41
+ subject: str,
42
+ body: str,
43
+ ) -> bool:
44
+ """Send alert via SMTP.
45
+
46
+ Args:
47
+ smtp_host: SMTP server hostname.
48
+ smtp_from: Sender address.
49
+ recipients: List of recipient addresses.
50
+ subject: Email subject.
51
+ body: Plain text email body.
52
+
53
+ Returns:
54
+ True on success, False if smtp_host or recipients are empty,
55
+ or on any exception.
56
+ """
57
+ if not smtp_host or not recipients:
58
+ return False
59
+ try:
60
+ msg = MIMEText(body)
61
+ msg["Subject"] = subject
62
+ msg["From"] = smtp_from
63
+ msg["To"] = ", ".join(recipients)
64
+
65
+ with smtplib.SMTP(smtp_host) as server:
66
+ server.sendmail(smtp_from, recipients, msg.as_string())
67
+ return True
68
+ except Exception:
69
+ return False
70
+
71
+
72
+ def _build_alert_message(status: dict[str, Any]) -> str:
73
+ """Build a human-readable alert message from a budget status dict."""
74
+ return (
75
+ "⚠️ Cost Intelligence Budget Alert\n"
76
+ f"Budget: ${status['monthly']:.2f}/month\n"
77
+ f"Spent: ${status['spent']:.2f} ({status['percent_used']:.1f}%)\n"
78
+ f"Remaining: ${status['remaining']:.2f}\n"
79
+ f"Alert threshold: {status['alert_threshold']}%"
80
+ )
81
+
82
+
83
+ def check_and_alert() -> dict[str, Any]:
84
+ """Check budget status and dispatch alerts when threshold is reached.
85
+
86
+ Reads ``slack_webhook_url``, ``smtp_host``, ``smtp_from``, and
87
+ ``alert_recipients`` from config and routes the alert message to each
88
+ configured channel.
89
+
90
+ Returns:
91
+ Dict with keys ``triggered`` (bool), ``alert_sent`` (bool), and
92
+ ``message`` (str). ``alert_sent`` is True when at least one
93
+ channel reported a successful dispatch.
94
+ """
95
+ result: dict[str, Any] = {
96
+ "triggered": False,
97
+ "alert_sent": False,
98
+ "message": "",
99
+ }
100
+
101
+ status = get_budget_status()
102
+ if not status["budget_set"]:
103
+ return result
104
+
105
+ if status["percent_used"] < status["alert_threshold"]:
106
+ return result
107
+
108
+ message = _build_alert_message(status)
109
+ result["triggered"] = True
110
+ result["message"] = message
111
+
112
+ cfg = load_config()
113
+ slack_webhook_url = cfg.get("slack_webhook_url", "") or ""
114
+ smtp_host = cfg.get("smtp_host", "") or ""
115
+ smtp_from = cfg.get("smtp_from", "") or ""
116
+ recipients = cfg.get("alert_recipients", []) or []
117
+
118
+ any_sent = False
119
+ if slack_webhook_url:
120
+ any_sent = send_slack_alert(slack_webhook_url, message) or any_sent
121
+ if smtp_host and recipients:
122
+ any_sent = (
123
+ send_email_alert(
124
+ smtp_host=smtp_host,
125
+ smtp_from=smtp_from,
126
+ recipients=list(recipients),
127
+ subject="Cost Intelligence Budget Alert",
128
+ body=message,
129
+ )
130
+ or any_sent
131
+ )
132
+
133
+ result["alert_sent"] = any_sent
134
+ return result
cost_intel/budget.py ADDED
@@ -0,0 +1,74 @@
1
+ """Budget management — set, status, and alert tracking."""
2
+
3
+ from cost_intel.db import connect
4
+
5
+
6
+ def set_budget(monthly: float, alert_threshold: int = 80) -> None:
7
+ """Set the monthly budget and alert threshold.
8
+
9
+ Args:
10
+ monthly: Monthly budget in USD.
11
+ alert_threshold: Percentage at which to trigger alerts (0-100).
12
+ """
13
+ with connect() as conn:
14
+ conn.execute(
15
+ "INSERT OR REPLACE INTO config (key, value) VALUES (?, ?)",
16
+ ("monthly_budget", str(monthly)),
17
+ )
18
+ conn.execute(
19
+ "INSERT OR REPLACE INTO config (key, value) VALUES (?, ?)",
20
+ ("alert_threshold", str(alert_threshold)),
21
+ )
22
+
23
+
24
+ def get_budget_status() -> dict:
25
+ """Get current budget status including spending.
26
+
27
+ Returns:
28
+ Dict with budget_set, monthly, alert_threshold, spent,
29
+ remaining, percent_used.
30
+ """
31
+ with connect() as conn:
32
+ budget_row = conn.execute(
33
+ "SELECT value FROM config WHERE key = ?",
34
+ ("monthly_budget",),
35
+ ).fetchone()
36
+ threshold_row = conn.execute(
37
+ "SELECT value FROM config WHERE key = ?",
38
+ ("alert_threshold",),
39
+ ).fetchone()
40
+
41
+ if budget_row is None:
42
+ return {
43
+ "budget_set": False,
44
+ "monthly": None,
45
+ "alert_threshold": None,
46
+ "spent": 0.0,
47
+ "remaining": None,
48
+ "percent_used": 0.0,
49
+ }
50
+
51
+ monthly = float(budget_row["value"])
52
+ alert_threshold = int(threshold_row["value"]) if threshold_row else 80
53
+
54
+ # Calculate current month spending
55
+ with connect() as conn:
56
+ spent_row = conn.execute(
57
+ "SELECT COALESCE(SUM(call_cost), 0) as spent "
58
+ "FROM cost_run_calls crc "
59
+ "JOIN cost_runs cr ON crc.run_id = cr.run_id "
60
+ "WHERE cr.started_at >= date('now', 'start of month')"
61
+ ).fetchone()
62
+ spent = float(spent_row["spent"]) if spent_row else 0.0
63
+
64
+ remaining = max(0.0, monthly - spent)
65
+ percent_used = (spent / monthly * 100) if monthly > 0 else 0.0
66
+
67
+ return {
68
+ "budget_set": True,
69
+ "monthly": monthly,
70
+ "alert_threshold": alert_threshold,
71
+ "spent": round(spent, 2),
72
+ "remaining": round(remaining, 2),
73
+ "percent_used": round(percent_used, 1),
74
+ }