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.
@@ -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]")