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
|
@@ -0,0 +1,695 @@
|
|
|
1
|
+
"""TokEnable CLI — Cost gates for LLM API calls."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Optional
|
|
6
|
+
|
|
7
|
+
import typer
|
|
8
|
+
from rich.console import Console
|
|
9
|
+
from rich.table import Table
|
|
10
|
+
|
|
11
|
+
import tokenable
|
|
12
|
+
|
|
13
|
+
app = typer.Typer(name="tokenable", help="Cost gates for LLM API calls.", no_args_is_help=True)
|
|
14
|
+
console = Console()
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _version_callback(value: bool) -> None:
|
|
18
|
+
if value:
|
|
19
|
+
typer.echo(f"tokenable {tokenable.__version__}")
|
|
20
|
+
raise typer.Exit()
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@app.callback()
|
|
24
|
+
def main(
|
|
25
|
+
version: bool = typer.Option(
|
|
26
|
+
False, "--version", "-v", callback=_version_callback, is_eager=True, help="Show version."
|
|
27
|
+
),
|
|
28
|
+
) -> None:
|
|
29
|
+
"""Cost gates for LLM API calls."""
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@app.command()
|
|
33
|
+
def estimate(
|
|
34
|
+
path: str = typer.Argument(".", help="Directory to scan."),
|
|
35
|
+
volume: str | None = typer.Option(
|
|
36
|
+
None, "--volume", "-n", help="Calls/month (number or low/medium/high)."
|
|
37
|
+
),
|
|
38
|
+
format: str = typer.Option("table", "--format", "-f", help="Output format: table, json, csv."),
|
|
39
|
+
config: str | None = typer.Option(None, "--config", "-c", help="Path to config file."),
|
|
40
|
+
) -> None:
|
|
41
|
+
"""Estimate monthly LLM API costs for a codebase."""
|
|
42
|
+
from tokenable.config import getEnvVolume, load_config, parseVolume, resolveVolume
|
|
43
|
+
from tokenable.estimator import buildEstimateRows
|
|
44
|
+
from tokenable.models import EstimateSummary
|
|
45
|
+
from tokenable.scanner import scan_directory
|
|
46
|
+
|
|
47
|
+
cfg = load_config(config)
|
|
48
|
+
cli_volume: int | None = None
|
|
49
|
+
cli_volume_explicit = False
|
|
50
|
+
if volume is not None:
|
|
51
|
+
cli_volume = parseVolume(volume)
|
|
52
|
+
cli_volume_explicit = True
|
|
53
|
+
elif (env_vol := getEnvVolume()) is not None:
|
|
54
|
+
cli_volume = env_vol
|
|
55
|
+
|
|
56
|
+
ignore = cfg.ignore if cfg else []
|
|
57
|
+
results = scan_directory(path, ignore)
|
|
58
|
+
|
|
59
|
+
if not results:
|
|
60
|
+
console.print("[yellow]No LLM API calls found.[/yellow]")
|
|
61
|
+
raise typer.Exit()
|
|
62
|
+
|
|
63
|
+
rows, unknown = buildEstimateRows(results, cfg, cli_volume, cli_volume_explicit)
|
|
64
|
+
|
|
65
|
+
if unknown:
|
|
66
|
+
for u in unknown:
|
|
67
|
+
console.print(f"[dim]⚠ Unknown model: {u}[/dim]")
|
|
68
|
+
|
|
69
|
+
total = sum(r.monthly_cost for r in rows)
|
|
70
|
+
effective_volume = (
|
|
71
|
+
rows[0].monthly_cost / rows[0].cost_per_call if rows and rows[0].cost_per_call > 0 else 1000
|
|
72
|
+
)
|
|
73
|
+
|
|
74
|
+
if format == "json":
|
|
75
|
+
import json as json_mod
|
|
76
|
+
|
|
77
|
+
summary = EstimateSummary(rows=rows, total_monthly_cost=total, volume=int(effective_volume))
|
|
78
|
+
typer.echo(json_mod.dumps(summary.model_dump(), indent=2))
|
|
79
|
+
elif format == "csv":
|
|
80
|
+
typer.echo("file,line,provider,model,input_tokens,output_tokens,cost_per_call,monthly_cost")
|
|
81
|
+
for r in rows:
|
|
82
|
+
typer.echo(
|
|
83
|
+
f"{r.file},{r.line},{r.provider},{r.model},{r.input_tokens},{r.output_tokens},{r.cost_per_call:.6f},{r.monthly_cost:.2f}"
|
|
84
|
+
)
|
|
85
|
+
else:
|
|
86
|
+
table = Table(title="TokEnable Estimate")
|
|
87
|
+
table.add_column("File", style="cyan")
|
|
88
|
+
table.add_column("Line", justify="right")
|
|
89
|
+
table.add_column("Provider")
|
|
90
|
+
table.add_column("Model", style="green")
|
|
91
|
+
table.add_column("In Tokens", justify="right")
|
|
92
|
+
table.add_column("Out Tokens", justify="right")
|
|
93
|
+
table.add_column("$/call", justify="right")
|
|
94
|
+
table.add_column("$/month", justify="right", style="bold")
|
|
95
|
+
for r in rows:
|
|
96
|
+
table.add_row(
|
|
97
|
+
r.file,
|
|
98
|
+
str(r.line),
|
|
99
|
+
r.provider,
|
|
100
|
+
r.model,
|
|
101
|
+
f"{r.input_tokens:,}",
|
|
102
|
+
f"{r.output_tokens:,}",
|
|
103
|
+
f"${r.cost_per_call:.4f}",
|
|
104
|
+
f"${r.monthly_cost:.2f}",
|
|
105
|
+
)
|
|
106
|
+
console.print(table)
|
|
107
|
+
console.print(f"\n[bold]Total estimated monthly cost:[/bold] [green]${total:.2f}[/green]")
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
@app.command()
|
|
111
|
+
def check(
|
|
112
|
+
path: str = typer.Argument(".", help="Directory to scan."),
|
|
113
|
+
volume: str | None = typer.Option(
|
|
114
|
+
None, "--volume", "-n", help="Calls/month (number or low/medium/high)."
|
|
115
|
+
),
|
|
116
|
+
format: str = typer.Option("table", "--format", "-f", help="Output format: table, json."),
|
|
117
|
+
config: str | None = typer.Option(None, "--config", "-c", help="Path to config file."),
|
|
118
|
+
max_monthly_cost: float | None = typer.Option(
|
|
119
|
+
None, "--max-monthly-cost", help="Fail if total monthly cost exceeds this."
|
|
120
|
+
),
|
|
121
|
+
max_cost_per_call: float | None = typer.Option(
|
|
122
|
+
None, "--max-cost-per-call", help="Fail if any single call exceeds this."
|
|
123
|
+
),
|
|
124
|
+
) -> None:
|
|
125
|
+
"""Check costs against budget thresholds (CI gate)."""
|
|
126
|
+
from tokenable.config import getEnvVolume, load_config, parseVolume
|
|
127
|
+
from tokenable.estimator import buildEstimateRows
|
|
128
|
+
from tokenable.scanner import scan_directory
|
|
129
|
+
|
|
130
|
+
cfg = load_config(config)
|
|
131
|
+
cli_volume: int | None = None
|
|
132
|
+
cli_volume_explicit = False
|
|
133
|
+
if volume is not None:
|
|
134
|
+
cli_volume = parseVolume(volume)
|
|
135
|
+
cli_volume_explicit = True
|
|
136
|
+
elif (env_vol := getEnvVolume()) is not None:
|
|
137
|
+
cli_volume = env_vol
|
|
138
|
+
|
|
139
|
+
ignore = cfg.ignore if cfg else []
|
|
140
|
+
results = scan_directory(path, ignore)
|
|
141
|
+
|
|
142
|
+
if not results:
|
|
143
|
+
console.print("[green]✓ No LLM API calls found.[/green]")
|
|
144
|
+
raise typer.Exit()
|
|
145
|
+
|
|
146
|
+
rows, _ = buildEstimateRows(results, cfg, cli_volume, cli_volume_explicit)
|
|
147
|
+
total = sum(r.monthly_cost for r in rows)
|
|
148
|
+
|
|
149
|
+
# Resolve thresholds from config budgets or CLI flags
|
|
150
|
+
threshold_monthly = max_monthly_cost
|
|
151
|
+
threshold_per_call = max_cost_per_call
|
|
152
|
+
if cfg and cfg.budgets:
|
|
153
|
+
if threshold_monthly is None and cfg.budgets.max_monthly_cost is not None:
|
|
154
|
+
threshold_monthly = cfg.budgets.max_monthly_cost
|
|
155
|
+
if threshold_per_call is None and cfg.budgets.block is not None:
|
|
156
|
+
threshold_per_call = cfg.budgets.block
|
|
157
|
+
|
|
158
|
+
violations: list[str] = []
|
|
159
|
+
|
|
160
|
+
if threshold_monthly is not None and total > threshold_monthly:
|
|
161
|
+
violations.append(f"Total monthly cost ${total:.2f} exceeds limit ${threshold_monthly:.2f}")
|
|
162
|
+
|
|
163
|
+
if threshold_per_call is not None:
|
|
164
|
+
for r in rows:
|
|
165
|
+
if r.cost_per_call > threshold_per_call:
|
|
166
|
+
violations.append(
|
|
167
|
+
f"{r.file}:{r.line} — ${r.cost_per_call:.4f}/call exceeds ${threshold_per_call:.4f}"
|
|
168
|
+
)
|
|
169
|
+
|
|
170
|
+
if format == "json":
|
|
171
|
+
import json as json_mod
|
|
172
|
+
|
|
173
|
+
typer.echo(
|
|
174
|
+
json_mod.dumps(
|
|
175
|
+
{
|
|
176
|
+
"passed": len(violations) == 0,
|
|
177
|
+
"total_monthly_cost": total,
|
|
178
|
+
"violations": violations,
|
|
179
|
+
},
|
|
180
|
+
indent=2,
|
|
181
|
+
)
|
|
182
|
+
)
|
|
183
|
+
else:
|
|
184
|
+
if violations:
|
|
185
|
+
console.print(f"[bold red]✗ Budget check failed[/bold red] — ${total:.2f}/month")
|
|
186
|
+
for v in violations:
|
|
187
|
+
console.print(f" [red]• {v}[/red]")
|
|
188
|
+
else:
|
|
189
|
+
console.print(f"[bold green]✓ Budget check passed[/bold green] — ${total:.2f}/month")
|
|
190
|
+
|
|
191
|
+
if violations:
|
|
192
|
+
raise typer.Exit(code=1)
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
@app.command()
|
|
196
|
+
def init(
|
|
197
|
+
hooks: bool = typer.Option(True, "--hooks/--no-hooks", help="Install git hooks."),
|
|
198
|
+
config_flag: bool = typer.Option(True, "--config/--no-config", help="Create config file."),
|
|
199
|
+
hook: str = typer.Option("pre-commit", "--hook", help="Hook type: pre-commit or pre-push."),
|
|
200
|
+
) -> None:
|
|
201
|
+
"""Initialize TokEnable in a project."""
|
|
202
|
+
import os
|
|
203
|
+
|
|
204
|
+
from tokenable.enforcer import create_config, find_git_root, setup_hooks
|
|
205
|
+
|
|
206
|
+
cwd = os.getcwd()
|
|
207
|
+
console.print("[bold]TokEnable Setup[/bold]\n")
|
|
208
|
+
|
|
209
|
+
if config_flag:
|
|
210
|
+
result = create_config(cwd)
|
|
211
|
+
if result:
|
|
212
|
+
console.print("[green] Created tokenable.config.json[/green]")
|
|
213
|
+
else:
|
|
214
|
+
console.print("[dim] tokenable.config.json already exists, skipping.[/dim]")
|
|
215
|
+
|
|
216
|
+
if hooks:
|
|
217
|
+
git_root = find_git_root(cwd)
|
|
218
|
+
if git_root:
|
|
219
|
+
msg = setup_hooks(git_root, hook)
|
|
220
|
+
console.print(f"[green] {msg}[/green]")
|
|
221
|
+
else:
|
|
222
|
+
console.print("[yellow] Not a git repository — skipping hooks.[/yellow]")
|
|
223
|
+
|
|
224
|
+
console.print("\n[bold]Next steps:[/bold]")
|
|
225
|
+
console.print(" 1. Edit tokenable.config.json to set budget thresholds")
|
|
226
|
+
console.print(" 2. Run: tokenable estimate .")
|
|
227
|
+
console.print(" 3. Commit tokenable.config.json to your repo")
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
@app.command()
|
|
231
|
+
def diff(
|
|
232
|
+
path: str = typer.Argument(".", help="Directory to scan."),
|
|
233
|
+
base: str = typer.Option("main", "--base", help="Base git ref."),
|
|
234
|
+
head: str = typer.Option("HEAD", "--head", help="Head git ref."),
|
|
235
|
+
volume: str | None = typer.Option(None, "--volume", "-n", help="Calls/month."),
|
|
236
|
+
format: str = typer.Option("table", "--format", "-f", help="Output format."),
|
|
237
|
+
fail_on_increase: float | None = typer.Option(
|
|
238
|
+
None, "--fail-on-increase", help="Exit 1 if monthly increase exceeds this USD amount."
|
|
239
|
+
),
|
|
240
|
+
config: str | None = typer.Option(None, "--config", "-c", help="Path to config file."),
|
|
241
|
+
) -> None:
|
|
242
|
+
"""Compare token costs between two git refs."""
|
|
243
|
+
import os
|
|
244
|
+
import tempfile
|
|
245
|
+
|
|
246
|
+
from git import Repo
|
|
247
|
+
|
|
248
|
+
from tokenable.config import getEnvVolume, load_config, parseVolume
|
|
249
|
+
from tokenable.estimator import buildEstimateRows
|
|
250
|
+
from tokenable.scanner import scan_directory
|
|
251
|
+
|
|
252
|
+
cfg = load_config(config)
|
|
253
|
+
cli_volume = parseVolume(volume) if volume else (getEnvVolume() or 1000)
|
|
254
|
+
cli_volume_explicit = volume is not None
|
|
255
|
+
|
|
256
|
+
git_root = os.path.abspath(path)
|
|
257
|
+
try:
|
|
258
|
+
repo = Repo(git_root, search_parent_directories=True)
|
|
259
|
+
except Exception:
|
|
260
|
+
console.print("[red]Error: Not a git repository.[/red]")
|
|
261
|
+
raise typer.Exit(1)
|
|
262
|
+
|
|
263
|
+
ignore = cfg.ignore if cfg else []
|
|
264
|
+
|
|
265
|
+
# Scan head (working dir if HEAD)
|
|
266
|
+
head_results = scan_directory(git_root, ignore)
|
|
267
|
+
head_rows, _ = buildEstimateRows(head_results, cfg, cli_volume, cli_volume_explicit)
|
|
268
|
+
head_total = sum(r.monthly_cost for r in head_rows)
|
|
269
|
+
|
|
270
|
+
# Scan base ref in temp dir
|
|
271
|
+
base_total = 0.0
|
|
272
|
+
try:
|
|
273
|
+
with tempfile.TemporaryDirectory():
|
|
274
|
+
repo.git.checkout(base, "--", ".")
|
|
275
|
+
base_results = scan_directory(git_root, ignore)
|
|
276
|
+
base_rows, _ = buildEstimateRows(base_results, cfg, cli_volume, cli_volume_explicit)
|
|
277
|
+
base_total = sum(r.monthly_cost for r in base_rows)
|
|
278
|
+
repo.git.checkout(head, "--", ".")
|
|
279
|
+
except Exception:
|
|
280
|
+
# Fallback: just compare current state
|
|
281
|
+
base_total = 0.0
|
|
282
|
+
|
|
283
|
+
delta = head_total - base_total
|
|
284
|
+
delta_str = f"+${delta:.2f}" if delta >= 0 else f"-${abs(delta):.2f}"
|
|
285
|
+
|
|
286
|
+
if format == "json":
|
|
287
|
+
import json as json_mod
|
|
288
|
+
|
|
289
|
+
typer.echo(
|
|
290
|
+
json_mod.dumps(
|
|
291
|
+
{
|
|
292
|
+
"base": base,
|
|
293
|
+
"head": head,
|
|
294
|
+
"baseMonthlyCost": base_total,
|
|
295
|
+
"headMonthlyCost": head_total,
|
|
296
|
+
"netMonthlyDelta": delta,
|
|
297
|
+
},
|
|
298
|
+
indent=2,
|
|
299
|
+
)
|
|
300
|
+
)
|
|
301
|
+
else:
|
|
302
|
+
console.print(f"[bold]Cost diff:[/bold] {base} → {head}")
|
|
303
|
+
console.print(f" Base: ${base_total:.2f}/mo")
|
|
304
|
+
console.print(f" Head: ${head_total:.2f}/mo")
|
|
305
|
+
color = "red" if delta > 0 else "green"
|
|
306
|
+
console.print(f" [bold {color}]Net: {delta_str}/mo[/bold {color}]")
|
|
307
|
+
|
|
308
|
+
if cfg and cfg.budgets:
|
|
309
|
+
if cfg.budgets.warn and delta >= cfg.budgets.warn:
|
|
310
|
+
console.print(
|
|
311
|
+
f"[yellow]⚠ Budget warning: exceeds warn threshold (${cfg.budgets.warn})[/yellow]"
|
|
312
|
+
)
|
|
313
|
+
if cfg.budgets.block and delta >= cfg.budgets.block:
|
|
314
|
+
console.print(
|
|
315
|
+
f"[red]✗ Budget blocked: exceeds block threshold (${cfg.budgets.block})[/red]"
|
|
316
|
+
)
|
|
317
|
+
raise typer.Exit(1)
|
|
318
|
+
|
|
319
|
+
if fail_on_increase is not None and delta > fail_on_increase:
|
|
320
|
+
console.print(
|
|
321
|
+
f"[red]✗ Cost increase ${delta:.2f} exceeds threshold ${fail_on_increase:.2f}[/red]"
|
|
322
|
+
)
|
|
323
|
+
raise typer.Exit(1)
|
|
324
|
+
|
|
325
|
+
|
|
326
|
+
@app.command()
|
|
327
|
+
def audit(
|
|
328
|
+
path: str = typer.Argument(".", help="Directory to scan."),
|
|
329
|
+
volume: str | None = typer.Option(None, "--volume", "-n", help="Calls/month."),
|
|
330
|
+
format: str = typer.Option("table", "--format", "-f", help="Output format."),
|
|
331
|
+
config: str | None = typer.Option(None, "--config", "-c", help="Path to config file."),
|
|
332
|
+
) -> None:
|
|
333
|
+
"""Scan for cost optimization opportunities."""
|
|
334
|
+
from tokenable.config import getEnvVolume, load_config, parseVolume
|
|
335
|
+
from tokenable.recommender import (
|
|
336
|
+
detect_batch_opportunities,
|
|
337
|
+
detect_caching_opportunities,
|
|
338
|
+
detect_smart_alternatives,
|
|
339
|
+
)
|
|
340
|
+
from tokenable.scanner import scan_directory
|
|
341
|
+
|
|
342
|
+
cfg = load_config(config)
|
|
343
|
+
vol = parseVolume(volume) if volume else (getEnvVolume() or 1000)
|
|
344
|
+
results = scan_directory(path, cfg.ignore if cfg else [])
|
|
345
|
+
|
|
346
|
+
if not results:
|
|
347
|
+
console.print("[yellow]No LLM API calls detected.[/yellow]")
|
|
348
|
+
raise typer.Exit()
|
|
349
|
+
|
|
350
|
+
smart = detect_smart_alternatives(results, vol)
|
|
351
|
+
caching = detect_caching_opportunities(results, vol)
|
|
352
|
+
batch = detect_batch_opportunities(results, vol)
|
|
353
|
+
total_savings = (
|
|
354
|
+
sum(f.monthly_savings for f in smart)
|
|
355
|
+
+ sum(f.monthly_savings for f in caching)
|
|
356
|
+
+ sum(f.monthly_savings for f in batch)
|
|
357
|
+
)
|
|
358
|
+
|
|
359
|
+
if format == "json":
|
|
360
|
+
import json as json_mod
|
|
361
|
+
|
|
362
|
+
typer.echo(
|
|
363
|
+
json_mod.dumps(
|
|
364
|
+
{
|
|
365
|
+
"totalPotentialSavings": total_savings,
|
|
366
|
+
"volume": vol,
|
|
367
|
+
"findings": [f.model_dump() for f in smart]
|
|
368
|
+
+ [f.model_dump() for f in caching]
|
|
369
|
+
+ [f.model_dump() for f in batch],
|
|
370
|
+
},
|
|
371
|
+
indent=2,
|
|
372
|
+
)
|
|
373
|
+
)
|
|
374
|
+
else:
|
|
375
|
+
if not smart and not caching and not batch:
|
|
376
|
+
console.print(
|
|
377
|
+
"[green]No optimization opportunities found. Your LLM usage looks efficient![/green]"
|
|
378
|
+
)
|
|
379
|
+
raise typer.Exit()
|
|
380
|
+
|
|
381
|
+
console.print(
|
|
382
|
+
f"[bold]TokEnable Audit — {len(smart) + len(caching) + len(batch)} opportunities found[/bold]\n"
|
|
383
|
+
)
|
|
384
|
+
|
|
385
|
+
if smart:
|
|
386
|
+
console.print("[bold yellow]SMART MODEL ALTERNATIVES[/bold yellow]")
|
|
387
|
+
for f in smart:
|
|
388
|
+
prov_note = (
|
|
389
|
+
f" ({f.suggested_provider.value})"
|
|
390
|
+
if f.suggested_provider != f.current_provider
|
|
391
|
+
else ""
|
|
392
|
+
)
|
|
393
|
+
console.print(
|
|
394
|
+
f" [cyan]{f.file}:{f.line}[/cyan] {f.current_model} → [green]{f.suggested_model}{prov_note}[/green]"
|
|
395
|
+
)
|
|
396
|
+
console.print(
|
|
397
|
+
f" Savings: [green]${f.monthly_savings:.0f}/mo[/green] | {f.reasoning}"
|
|
398
|
+
)
|
|
399
|
+
console.print()
|
|
400
|
+
|
|
401
|
+
if caching:
|
|
402
|
+
console.print("[bold yellow]PROMPT CACHING OPPORTUNITIES[/bold yellow]")
|
|
403
|
+
for f in caching:
|
|
404
|
+
console.print(
|
|
405
|
+
f" System prompt in {len(f.locations)} sites — save ~[green]${f.monthly_savings:.0f}/mo[/green]"
|
|
406
|
+
)
|
|
407
|
+
console.print()
|
|
408
|
+
|
|
409
|
+
if batch:
|
|
410
|
+
console.print("[bold yellow]BATCH API OPPORTUNITIES[/bold yellow]")
|
|
411
|
+
for f in batch:
|
|
412
|
+
console.print(
|
|
413
|
+
f" [cyan]{f.file}[/cyan]: {f.model} × {f.call_count} calls — save ~[green]${f.monthly_savings:.0f}/mo[/green]"
|
|
414
|
+
)
|
|
415
|
+
console.print()
|
|
416
|
+
|
|
417
|
+
console.print(
|
|
418
|
+
f"[bold]Total potential savings: [green]${total_savings:.0f}/mo[/green][/bold]"
|
|
419
|
+
)
|
|
420
|
+
|
|
421
|
+
|
|
422
|
+
@app.command()
|
|
423
|
+
def fix(
|
|
424
|
+
path: str = typer.Argument(".", help="Directory to scan."),
|
|
425
|
+
volume: str | None = typer.Option(None, "--volume", "-n", help="Calls/month."),
|
|
426
|
+
config: str | None = typer.Option(None, "--config", "-c", help="Path to config file."),
|
|
427
|
+
dry_run: bool = typer.Option(False, "--dry-run", help="Preview changes without applying."),
|
|
428
|
+
provider: str | None = typer.Option(None, "--provider", help="Only fix this provider."),
|
|
429
|
+
min_savings: float = typer.Option(0, "--min-savings", help="Minimum monthly savings (USD)."),
|
|
430
|
+
format: str = typer.Option("table", "--format", "-f", help="Output format."),
|
|
431
|
+
) -> None:
|
|
432
|
+
"""Auto-apply model swap recommendations from audit."""
|
|
433
|
+
from tokenable.config import getEnvVolume, load_config, parseVolume
|
|
434
|
+
from tokenable.fixer import apply_recommendations
|
|
435
|
+
from tokenable.models import ModelSwap
|
|
436
|
+
from tokenable.recommender import detect_smart_alternatives
|
|
437
|
+
from tokenable.scanner import scan_directory
|
|
438
|
+
|
|
439
|
+
cfg = load_config(config)
|
|
440
|
+
vol = parseVolume(volume) if volume else (getEnvVolume() or 1000)
|
|
441
|
+
results = scan_directory(path, cfg.ignore if cfg else [])
|
|
442
|
+
|
|
443
|
+
if not results:
|
|
444
|
+
console.print("[yellow]No LLM API calls detected.[/yellow]")
|
|
445
|
+
raise typer.Exit()
|
|
446
|
+
|
|
447
|
+
findings = detect_smart_alternatives(results, vol)
|
|
448
|
+
if provider:
|
|
449
|
+
findings = [f for f in findings if f.current_provider.value == provider]
|
|
450
|
+
if min_savings > 0:
|
|
451
|
+
findings = [f for f in findings if f.monthly_savings >= min_savings]
|
|
452
|
+
|
|
453
|
+
if not findings:
|
|
454
|
+
console.print("[yellow]No model swap recommendations match your filters.[/yellow]")
|
|
455
|
+
raise typer.Exit()
|
|
456
|
+
|
|
457
|
+
swaps = [
|
|
458
|
+
ModelSwap(
|
|
459
|
+
file=f.file,
|
|
460
|
+
line=f.line,
|
|
461
|
+
current_model=f.current_model,
|
|
462
|
+
suggested_model=f.suggested_model,
|
|
463
|
+
monthly_savings=f.monthly_savings,
|
|
464
|
+
)
|
|
465
|
+
for f in findings
|
|
466
|
+
]
|
|
467
|
+
result = apply_recommendations(swaps, path, dry_run)
|
|
468
|
+
|
|
469
|
+
if format == "json":
|
|
470
|
+
import json as json_mod
|
|
471
|
+
|
|
472
|
+
typer.echo(json_mod.dumps(result.model_dump(), indent=2))
|
|
473
|
+
else:
|
|
474
|
+
verb = "Would apply" if dry_run else "Applied"
|
|
475
|
+
if result.applied:
|
|
476
|
+
console.print(f"[green]{verb} {result.total_applied} model swap(s):[/green]\n")
|
|
477
|
+
for s in result.applied:
|
|
478
|
+
console.print(
|
|
479
|
+
f" [cyan]{s.file}[/cyan]:{s.line} [red]{s.from_model}[/red] → [green]{s.to_model}[/green]"
|
|
480
|
+
)
|
|
481
|
+
if result.skipped:
|
|
482
|
+
console.print(f"\n[yellow]Skipped {result.total_skipped}:[/yellow]")
|
|
483
|
+
for s in result.skipped:
|
|
484
|
+
console.print(f" [cyan]{s.file}[/cyan]:{s.line} [dim]{s.reason}[/dim]")
|
|
485
|
+
if result.estimated_monthly_savings > 0:
|
|
486
|
+
console.print(
|
|
487
|
+
f"\n[bold]Estimated savings: [green]${result.estimated_monthly_savings:.0f}/mo[/green][/bold]"
|
|
488
|
+
)
|
|
489
|
+
if dry_run and result.total_applied > 0:
|
|
490
|
+
console.print("[dim]\nDry run — no files were modified.[/dim]")
|
|
491
|
+
|
|
492
|
+
|
|
493
|
+
@app.command()
|
|
494
|
+
def price(
|
|
495
|
+
provider: str | None = typer.Argument(None, help="Provider name."),
|
|
496
|
+
model: str | None = typer.Argument(None, help="Model ID."),
|
|
497
|
+
input_tokens: int | None = typer.Option(None, "--input-tokens", help="Input token count."),
|
|
498
|
+
output_tokens: int | None = typer.Option(None, "--output-tokens", help="Output token count."),
|
|
499
|
+
volume: str | None = typer.Option(
|
|
500
|
+
None, "--volume", "-n", help="Calls/day for monthly projection."
|
|
501
|
+
),
|
|
502
|
+
list_models: bool = typer.Option(False, "--list", help="List current models for a provider."),
|
|
503
|
+
list_all: bool = typer.Option(False, "--list-all", help="List all models across providers."),
|
|
504
|
+
format: str = typer.Option("table", "--format", "-f", help="Output format."),
|
|
505
|
+
compare: str | None = typer.Option(None, "--compare", help="Compare with provider/model."),
|
|
506
|
+
) -> None:
|
|
507
|
+
"""Look up model pricing."""
|
|
508
|
+
from tokenable.config import parseVolume
|
|
509
|
+
from tokenable.models import Provider as ProviderEnum
|
|
510
|
+
from tokenable.providers import calculate_cost, get_all_models, get_model, get_provider_models
|
|
511
|
+
|
|
512
|
+
if list_all:
|
|
513
|
+
models = get_all_models()
|
|
514
|
+
table = Table(title="All Models")
|
|
515
|
+
table.add_column("Provider")
|
|
516
|
+
table.add_column("Model")
|
|
517
|
+
table.add_column("Input $/M", justify="right")
|
|
518
|
+
table.add_column("Output $/M", justify="right")
|
|
519
|
+
table.add_column("Context", justify="right")
|
|
520
|
+
for m in sorted(models, key=lambda x: (x.provider.value, x.output_cost_per_million)):
|
|
521
|
+
if m.status.value != "current":
|
|
522
|
+
continue
|
|
523
|
+
table.add_row(
|
|
524
|
+
m.provider.value,
|
|
525
|
+
m.id,
|
|
526
|
+
f"${m.input_cost_per_million:.2f}",
|
|
527
|
+
f"${m.output_cost_per_million:.2f}",
|
|
528
|
+
f"{m.context_window:,}",
|
|
529
|
+
)
|
|
530
|
+
console.print(table)
|
|
531
|
+
return
|
|
532
|
+
|
|
533
|
+
if list_models and provider:
|
|
534
|
+
try:
|
|
535
|
+
prov = ProviderEnum(provider)
|
|
536
|
+
except ValueError:
|
|
537
|
+
console.print(f"[red]Unknown provider: {provider}[/red]")
|
|
538
|
+
raise typer.Exit(1)
|
|
539
|
+
models = get_provider_models(prov)
|
|
540
|
+
table = Table(title=f"{provider} Models")
|
|
541
|
+
table.add_column("Model")
|
|
542
|
+
table.add_column("Input $/M", justify="right")
|
|
543
|
+
table.add_column("Output $/M", justify="right")
|
|
544
|
+
table.add_column("Context", justify="right")
|
|
545
|
+
table.add_column("Status")
|
|
546
|
+
for m in models:
|
|
547
|
+
table.add_row(
|
|
548
|
+
m.id,
|
|
549
|
+
f"${m.input_cost_per_million:.2f}",
|
|
550
|
+
f"${m.output_cost_per_million:.2f}",
|
|
551
|
+
f"{m.context_window:,}",
|
|
552
|
+
m.status.value,
|
|
553
|
+
)
|
|
554
|
+
console.print(table)
|
|
555
|
+
return
|
|
556
|
+
|
|
557
|
+
if not provider or not model:
|
|
558
|
+
console.print(
|
|
559
|
+
"[yellow]Usage: tokenable price <provider> <model> [--input-tokens N --output-tokens N][/yellow]"
|
|
560
|
+
)
|
|
561
|
+
console.print("[dim]Or: tokenable price --list-all[/dim]")
|
|
562
|
+
raise typer.Exit(1)
|
|
563
|
+
|
|
564
|
+
try:
|
|
565
|
+
prov = ProviderEnum(provider)
|
|
566
|
+
except ValueError:
|
|
567
|
+
console.print(f"[red]Unknown provider: {provider}[/red]")
|
|
568
|
+
raise typer.Exit(1)
|
|
569
|
+
|
|
570
|
+
pricing = get_model(prov, model)
|
|
571
|
+
if not pricing:
|
|
572
|
+
console.print(f"[red]Unknown model: {model} for {provider}[/red]")
|
|
573
|
+
raise typer.Exit(1)
|
|
574
|
+
|
|
575
|
+
console.print(f"[bold]{pricing.name}[/bold] ({pricing.provider.value}/{pricing.id})")
|
|
576
|
+
console.print(f" Input: ${pricing.input_cost_per_million:.2f}/M tokens")
|
|
577
|
+
console.print(f" Output: ${pricing.output_cost_per_million:.2f}/M tokens")
|
|
578
|
+
console.print(
|
|
579
|
+
f" Context: {pricing.context_window:,} | Max output: {pricing.max_output_tokens:,}"
|
|
580
|
+
)
|
|
581
|
+
|
|
582
|
+
if input_tokens is not None and output_tokens is not None:
|
|
583
|
+
cost = calculate_cost(pricing, input_tokens, output_tokens)
|
|
584
|
+
console.print(
|
|
585
|
+
f"\n Cost for {input_tokens:,} in / {output_tokens:,} out: [bold]${cost:.6f}[/bold]"
|
|
586
|
+
)
|
|
587
|
+
if volume:
|
|
588
|
+
vol = parseVolume(volume)
|
|
589
|
+
monthly = cost * vol * 30
|
|
590
|
+
console.print(f" Monthly ({vol:,}/day): [bold]${monthly:.2f}/mo[/bold]")
|
|
591
|
+
|
|
592
|
+
|
|
593
|
+
@app.command()
|
|
594
|
+
def calibrate(
|
|
595
|
+
path: str = typer.Argument(".", help="Directory to scan."),
|
|
596
|
+
provider: str | None = typer.Option(None, "--provider", help="Provider to calibrate from."),
|
|
597
|
+
dry_run: bool = typer.Option(False, "--dry-run", help="Preview without saving."),
|
|
598
|
+
days: int = typer.Option(30, "--days", help="Days of usage data to fetch."),
|
|
599
|
+
format: str = typer.Option("table", "--format", "-f", help="Output format."),
|
|
600
|
+
config: str | None = typer.Option(None, "--config", "-c", help="Path to config file."),
|
|
601
|
+
) -> None:
|
|
602
|
+
"""Fetch real usage data from provider APIs and compute correction factors."""
|
|
603
|
+
from tokenable.calibration import fetch_and_calibrate
|
|
604
|
+
|
|
605
|
+
result = fetch_and_calibrate(
|
|
606
|
+
path, provider=provider, days=days, dry_run=dry_run, config_path=config
|
|
607
|
+
)
|
|
608
|
+
|
|
609
|
+
if format == "json":
|
|
610
|
+
import json as json_mod
|
|
611
|
+
|
|
612
|
+
typer.echo(json_mod.dumps(result, indent=2))
|
|
613
|
+
else:
|
|
614
|
+
if "error" in result:
|
|
615
|
+
console.print(f"[red]{result['error']}[/red]")
|
|
616
|
+
raise typer.Exit(1)
|
|
617
|
+
if not result.get("models"):
|
|
618
|
+
console.print("[yellow]No usage data found for calibration.[/yellow]")
|
|
619
|
+
raise typer.Exit()
|
|
620
|
+
|
|
621
|
+
table = Table(title="Calibration Results")
|
|
622
|
+
table.add_column("Model")
|
|
623
|
+
table.add_column("Samples", justify="right")
|
|
624
|
+
table.add_column("Input Ratio", justify="right")
|
|
625
|
+
table.add_column("Output Ratio", justify="right")
|
|
626
|
+
table.add_column("Confidence")
|
|
627
|
+
for key, cal in result.get("models", {}).items():
|
|
628
|
+
table.add_row(
|
|
629
|
+
key,
|
|
630
|
+
str(cal["sample_size"]),
|
|
631
|
+
f"{cal['input_ratio']:.2f}",
|
|
632
|
+
f"{cal['output_ratio']:.2f}",
|
|
633
|
+
cal["confidence"],
|
|
634
|
+
)
|
|
635
|
+
console.print(table)
|
|
636
|
+
|
|
637
|
+
if dry_run:
|
|
638
|
+
console.print("[dim]\nDry run — calibration not saved.[/dim]")
|
|
639
|
+
else:
|
|
640
|
+
console.print("\n[green]Calibration saved to .tokenable/calibration.json[/green]")
|
|
641
|
+
|
|
642
|
+
|
|
643
|
+
@app.command(name="update-pricing")
|
|
644
|
+
def update_pricing(
|
|
645
|
+
dynamic: bool = typer.Option(False, "--dynamic", help="Fetch latest prices from LiteLLM at runtime."),
|
|
646
|
+
provider: str | None = typer.Option(None, "--provider", help="Update only this provider."),
|
|
647
|
+
dry_run: bool = typer.Option(False, "--dry-run", help="Show changes without writing."),
|
|
648
|
+
) -> None:
|
|
649
|
+
"""Check or update pricing data. Use --dynamic to fetch live from LiteLLM."""
|
|
650
|
+
from tokenable.providers import get_all_providers, get_provider_models
|
|
651
|
+
|
|
652
|
+
if not dynamic:
|
|
653
|
+
# Static mode: just show status
|
|
654
|
+
console.print("[bold]Pricing Database Status[/bold]\n")
|
|
655
|
+
table = Table()
|
|
656
|
+
table.add_column("Provider")
|
|
657
|
+
table.add_column("Models")
|
|
658
|
+
table.add_column("Current")
|
|
659
|
+
|
|
660
|
+
for prov in get_all_providers():
|
|
661
|
+
models = get_provider_models(prov)
|
|
662
|
+
current = [m for m in models if m.status.value == "current"]
|
|
663
|
+
table.add_row(prov.value, str(len(models)), str(len(current)))
|
|
664
|
+
|
|
665
|
+
console.print(table)
|
|
666
|
+
console.print("\n[dim]Use --dynamic to fetch latest prices from LiteLLM.[/dim]")
|
|
667
|
+
console.print("[dim]Or upgrade the package: pip install --upgrade tokenable[/dim]")
|
|
668
|
+
return
|
|
669
|
+
|
|
670
|
+
# Dynamic mode: fetch from LiteLLM and update local JSON files
|
|
671
|
+
from tokenable.pricing_sync import sync_pricing
|
|
672
|
+
|
|
673
|
+
console.print("[bold]Fetching latest pricing from LiteLLM...[/bold]\n")
|
|
674
|
+
try:
|
|
675
|
+
result = sync_pricing(provider=provider, dry_run=dry_run)
|
|
676
|
+
except Exception as e:
|
|
677
|
+
console.print(f"[red]Error fetching pricing: {e}[/red]")
|
|
678
|
+
raise typer.Exit(1)
|
|
679
|
+
|
|
680
|
+
if result["total_changes"] == 0:
|
|
681
|
+
console.print("[green]All prices are up to date.[/green]")
|
|
682
|
+
else:
|
|
683
|
+
table = Table(title="Price Updates")
|
|
684
|
+
table.add_column("Model")
|
|
685
|
+
table.add_column("Field")
|
|
686
|
+
table.add_column("Old", justify="right")
|
|
687
|
+
table.add_column("New", justify="right")
|
|
688
|
+
for change in result["changes"]:
|
|
689
|
+
table.add_row(change["model"], change["field"], f"${change['old']}", f"${change['new']}")
|
|
690
|
+
console.print(table)
|
|
691
|
+
|
|
692
|
+
if dry_run:
|
|
693
|
+
console.print(f"\n[dim]Dry run — {result['total_changes']} change(s) would be applied.[/dim]")
|
|
694
|
+
else:
|
|
695
|
+
console.print(f"\n[green]✓ Updated {result['total_changes']} price(s). Changes take effect immediately.[/green]")
|