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 +3 -0
- tokenable/__main__.py +5 -0
- tokenable/calibration/__init__.py +129 -0
- tokenable/calibration/providers.py +202 -0
- tokenable/cli/__init__.py +695 -0
- tokenable/config/__init__.py +155 -0
- tokenable/enforcer/__init__.py +124 -0
- tokenable/estimator/__init__.py +192 -0
- tokenable/fixer/__init__.py +101 -0
- tokenable/mcp/__init__.py +249 -0
- tokenable/models/__init__.py +308 -0
- tokenable/pricing_sync.py +145 -0
- tokenable/providers/__init__.py +485 -0
- tokenable/providers/data/anthropic.json +452 -0
- tokenable/providers/data/benchmarks.json +324 -0
- tokenable/providers/data/google.json +318 -0
- tokenable/providers/data/openai.json +507 -0
- tokenable/providers/data/perplexity.json +88 -0
- tokenable/providers/data/xai.json +263 -0
- tokenable/py.typed +1 -0
- tokenable/recommender/__init__.py +209 -0
- tokenable/scanner/__init__.py +303 -0
- tokenable/telemetry/__init__.py +92 -0
- tokenable/utils/__init__.py +19 -0
- tokenable-1.0.0.dist-info/METADATA +196 -0
- tokenable-1.0.0.dist-info/RECORD +29 -0
- tokenable-1.0.0.dist-info/WHEEL +4 -0
- tokenable-1.0.0.dist-info/entry_points.txt +2 -0
- tokenable-1.0.0.dist-info/licenses/LICENSE +190 -0
tokenable/__init__.py
ADDED
tokenable/__main__.py
ADDED
|
@@ -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
|