tokenable 1.0.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.
tokenable/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ """TokEnable — FinOps CLI for LLM API costs."""
2
+
3
+ __version__ = "1.0.0"
tokenable/__main__.py ADDED
@@ -0,0 +1,5 @@
1
+ """Allow running as `python -m tokenable`."""
2
+
3
+ from tokenable.cli import app
4
+
5
+ app()
@@ -0,0 +1,129 @@
1
+ """Calibration — fetch real usage data and compute correction factors."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import os
7
+ from datetime import UTC, datetime, timezone
8
+ from pathlib import Path
9
+ from typing import Any
10
+
11
+ from tokenable.models import CalibrationData, ModelCalibration, Provider
12
+
13
+ CALIBRATION_DIR = ".tokenable"
14
+ CALIBRATION_FILE = "calibration.json"
15
+
16
+
17
+ def _calibration_path(project_root: str) -> Path:
18
+ return Path(project_root).resolve() / CALIBRATION_DIR / CALIBRATION_FILE
19
+
20
+
21
+ def load_calibration(project_root: str | None = None) -> CalibrationData | None:
22
+ """Load calibration data from .tokenable/calibration.json."""
23
+ path = _calibration_path(project_root or os.getcwd())
24
+ if not path.exists():
25
+ return None
26
+ try:
27
+ raw = json.loads(path.read_text())
28
+ return CalibrationData.model_validate(raw)
29
+ except Exception:
30
+ return None
31
+
32
+
33
+ def save_calibration(data: CalibrationData, project_root: str | None = None) -> str:
34
+ """Save calibration data. Returns file path."""
35
+ path = _calibration_path(project_root or os.getcwd())
36
+ path.parent.mkdir(parents=True, exist_ok=True)
37
+ path.write_text(json.dumps(data.model_dump(), indent=2))
38
+ return str(path)
39
+
40
+
41
+ def compute_confidence(sample_size: int) -> str:
42
+ """Derive confidence from sample size."""
43
+ if sample_size >= 1000:
44
+ return "high"
45
+ if sample_size >= 100:
46
+ return "medium"
47
+ return "low"
48
+
49
+
50
+ def fetch_and_calibrate(
51
+ scan_path: str,
52
+ provider: str | None = None,
53
+ days: int = 30,
54
+ dry_run: bool = False,
55
+ config_path: str | None = None,
56
+ ) -> dict[str, Any]:
57
+ """Fetch usage data from provider APIs and compute calibration factors."""
58
+ from tokenable.calibration.providers import fetch_all_usage
59
+ from tokenable.config import load_config
60
+ from tokenable.estimator import buildEstimateRows, typical_input_tokens, typical_output_tokens
61
+ from tokenable.providers import get_model
62
+ from tokenable.scanner import scan_directory
63
+
64
+ cfg = load_config(config_path)
65
+ results = scan_directory(scan_path, cfg.ignore if cfg else [])
66
+
67
+ if not results:
68
+ return {"error": "No LLM API calls found to calibrate."}
69
+
70
+ # Get estimated averages per model
71
+ estimates: dict[str, dict[str, float]] = {}
72
+ for r in results:
73
+ if not r.model:
74
+ continue
75
+ key = f"{r.provider.value}/{r.model}"
76
+ if key in estimates:
77
+ continue
78
+ pricing = get_model(r.provider, r.model)
79
+ if pricing:
80
+ estimates[key] = {
81
+ "input": typical_input_tokens(pricing),
82
+ "output": typical_output_tokens(pricing),
83
+ }
84
+
85
+ # Fetch actual usage
86
+ usage_data = fetch_all_usage(provider=provider, days=days)
87
+ if not usage_data:
88
+ return {
89
+ "error": "No usage data returned. Check API keys (ANTHROPIC_ADMIN_API_KEY, OPENAI_API_KEY, OPENROUTER_API_KEY)."
90
+ }
91
+
92
+ # Compute calibration
93
+ models: dict[str, ModelCalibration] = {}
94
+ for key, actual in usage_data.items():
95
+ est = estimates.get(key)
96
+ if not est:
97
+ continue
98
+ est_input = est["input"]
99
+ est_output = est["output"]
100
+ if est_input == 0 or est_output == 0:
101
+ continue
102
+
103
+ input_ratio = max(0.001, min(100, actual["avg_input"] / est_input))
104
+ output_ratio = max(0.001, min(100, actual["avg_output"] / est_output))
105
+
106
+ models[key] = ModelCalibration(
107
+ input_ratio=input_ratio,
108
+ output_ratio=output_ratio,
109
+ sample_size=actual["request_count"],
110
+ confidence=compute_confidence(actual["request_count"]),
111
+ actual_avg_input=actual["avg_input"],
112
+ actual_avg_output=actual["avg_output"],
113
+ estimated_avg_input=est_input,
114
+ estimated_avg_output=est_output,
115
+ )
116
+
117
+ if not models:
118
+ return {"error": "No matching models found between codebase and usage data."}
119
+
120
+ cal_data = CalibrationData(
121
+ version=1,
122
+ calibrated_at=datetime.now(UTC).isoformat(),
123
+ models=models,
124
+ )
125
+
126
+ if not dry_run:
127
+ save_calibration(cal_data, scan_path)
128
+
129
+ return cal_data.model_dump()
@@ -0,0 +1,202 @@
1
+ """Provider usage API clients for calibration."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ from datetime import UTC
7
+ from typing import Any
8
+
9
+ import httpx
10
+
11
+
12
+ def _fetch_anthropic_usage(days: int) -> dict[str, dict[str, Any]] | None:
13
+ """Fetch usage from Anthropic Admin API."""
14
+ api_key = os.environ.get("ANTHROPIC_ADMIN_API_KEY")
15
+ if not api_key:
16
+ return None
17
+
18
+ from datetime import datetime, timedelta
19
+
20
+ end = datetime.now(UTC)
21
+ start = end - timedelta(days=days)
22
+
23
+ url = "https://api.anthropic.com/v1/organizations/usage_report/messages"
24
+ params = {
25
+ "starting_at": start.isoformat(),
26
+ "ending_at": end.isoformat(),
27
+ "bucket_width": "1d",
28
+ "group_by[]": "model",
29
+ }
30
+ headers = {"x-api-key": api_key, "anthropic-version": "2023-06-01"}
31
+
32
+ results: dict[str, dict[str, Any]] = {}
33
+ try:
34
+ with httpx.Client(timeout=30) as client:
35
+ resp = client.get(url, params=params, headers=headers)
36
+ resp.raise_for_status()
37
+ data = resp.json()
38
+
39
+ by_model: dict[str, dict[str, int]] = {}
40
+ for bucket in data.get("data", []):
41
+ model = bucket.get("model", "unknown")
42
+ entry = by_model.setdefault(model, {"input": 0, "output": 0, "requests": 0})
43
+ entry["input"] += bucket.get("input_tokens", 0) + bucket.get(
44
+ "cache_read_input_tokens", 0
45
+ )
46
+ entry["output"] += bucket.get("output_tokens", 0)
47
+ entry["requests"] += bucket.get("num_requests", 0)
48
+
49
+ for model, agg in by_model.items():
50
+ if agg["requests"] == 0:
51
+ continue
52
+ results[f"anthropic/{model}"] = {
53
+ "avg_input": agg["input"] / agg["requests"],
54
+ "avg_output": agg["output"] / agg["requests"],
55
+ "request_count": agg["requests"],
56
+ }
57
+ except Exception:
58
+ return None
59
+ return results or None
60
+
61
+
62
+ def _fetch_openai_usage(days: int) -> dict[str, dict[str, Any]] | None:
63
+ """Fetch usage from OpenAI Organization Usage API."""
64
+ api_key = os.environ.get("OPENAI_API_KEY")
65
+ if not api_key:
66
+ return None
67
+
68
+ import time
69
+
70
+ end_time = int(time.time())
71
+ start_time = end_time - days * 86400
72
+
73
+ url = "https://api.openai.com/v1/organization/usage/completions"
74
+ params = {
75
+ "start_time": str(start_time),
76
+ "end_time": str(end_time),
77
+ "bucket_width": "1d",
78
+ "group_by[]": "model",
79
+ }
80
+ headers = {"Authorization": f"Bearer {api_key}"}
81
+
82
+ results: dict[str, dict[str, Any]] = {}
83
+ try:
84
+ with httpx.Client(timeout=30) as client:
85
+ resp = client.get(url, params=params, headers=headers)
86
+ resp.raise_for_status()
87
+ data = resp.json()
88
+
89
+ by_model: dict[str, dict[str, int]] = {}
90
+ for bucket in data.get("data", []):
91
+ model = bucket.get("model", "unknown")
92
+ entry = by_model.setdefault(model, {"input": 0, "output": 0, "requests": 0})
93
+ entry["input"] += bucket.get("input_tokens", 0) + bucket.get(
94
+ "cached_input_tokens", 0
95
+ )
96
+ entry["output"] += bucket.get("output_tokens", 0)
97
+ entry["requests"] += bucket.get("num_model_requests", 0)
98
+
99
+ for model, agg in by_model.items():
100
+ if agg["requests"] == 0:
101
+ continue
102
+ results[f"openai/{model}"] = {
103
+ "avg_input": agg["input"] / agg["requests"],
104
+ "avg_output": agg["output"] / agg["requests"],
105
+ "request_count": agg["requests"],
106
+ }
107
+ except Exception:
108
+ return None
109
+ return results or None
110
+
111
+
112
+ def _fetch_openrouter_usage(days: int) -> dict[str, dict[str, Any]] | None:
113
+ """Fetch usage from OpenRouter Activity API."""
114
+ api_key = os.environ.get("OPENROUTER_API_KEY")
115
+ if not api_key:
116
+ return None
117
+
118
+ from datetime import datetime, timedelta
119
+
120
+ PROVIDER_MAP = {
121
+ "anthropic": "anthropic",
122
+ "openai": "openai",
123
+ "google": "google",
124
+ "google ai studio": "google",
125
+ "xai": "xai",
126
+ "perplexity": "perplexity",
127
+ }
128
+
129
+ results: dict[str, dict[str, int]] = {}
130
+ try:
131
+ with httpx.Client(timeout=30) as client:
132
+ now = datetime.now()
133
+ for i in range(min(days, 30)):
134
+ date = (now - timedelta(days=i)).strftime("%Y-%m-%d")
135
+ resp = client.get(
136
+ f"https://openrouter.ai/api/v1/activity?date={date}",
137
+ headers={"Authorization": f"Bearer {api_key}"},
138
+ )
139
+ if not resp.is_success:
140
+ continue
141
+ for record in resp.json().get("data", []):
142
+ if record.get("requests", 0) == 0:
143
+ continue
144
+ prov_name = record.get("provider_name", "").lower()
145
+ provider = PROVIDER_MAP.get(prov_name)
146
+ if not provider:
147
+ continue
148
+ raw_model = record.get("model", "")
149
+ model = raw_model.split("/", 1)[-1] if "/" in raw_model else raw_model
150
+ key = f"{provider}/{model}"
151
+ entry = results.setdefault(key, {"input": 0, "output": 0, "requests": 0})
152
+ entry["input"] += record.get("prompt_tokens", 0)
153
+ entry["output"] += record.get("completion_tokens", 0) + record.get(
154
+ "reasoning_tokens", 0
155
+ )
156
+ entry["requests"] += record.get("requests", 0)
157
+ except Exception:
158
+ return None
159
+
160
+ final: dict[str, dict[str, Any]] = {}
161
+ for key, agg in results.items():
162
+ if agg["requests"] == 0:
163
+ continue
164
+ final[key] = {
165
+ "avg_input": agg["input"] / agg["requests"],
166
+ "avg_output": agg["output"] / agg["requests"],
167
+ "request_count": agg["requests"],
168
+ }
169
+ return final or None
170
+
171
+
172
+ def fetch_all_usage(
173
+ provider: str | None = None, days: int = 30
174
+ ) -> dict[str, dict[str, Any]] | None:
175
+ """Fetch usage from all available provider APIs. Returns merged results."""
176
+ all_results: dict[str, dict[str, Any]] = {}
177
+
178
+ fetchers = {
179
+ "anthropic": _fetch_anthropic_usage,
180
+ "openai": _fetch_openai_usage,
181
+ "openrouter": _fetch_openrouter_usage,
182
+ }
183
+
184
+ if provider:
185
+ fetcher = fetchers.get(provider)
186
+ if fetcher:
187
+ result = fetcher(days)
188
+ if result:
189
+ all_results.update(result)
190
+ else:
191
+ for name, fetcher in fetchers.items():
192
+ result = fetcher(days)
193
+ if result:
194
+ # Direct provider APIs take precedence over OpenRouter
195
+ if name == "openrouter":
196
+ for k, v in result.items():
197
+ if k not in all_results:
198
+ all_results[k] = v
199
+ else:
200
+ all_results.update(result)
201
+
202
+ return all_results or None