modelcost 0.1.0__tar.gz

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,144 @@
1
+ # Byte-compiled / optimized / DLL files
2
+ __pycache__/
3
+ *.py[cod]
4
+ *$py.class
5
+
6
+ # C extensions
7
+ *.so
8
+
9
+ # Distribution / packaging
10
+ .Python
11
+ build/
12
+ develop-eggs/
13
+ dist/
14
+ downloads/
15
+ eggs/
16
+ .eggs/
17
+ lib/
18
+ lib64/
19
+ parts/
20
+ sdist/
21
+ var/
22
+ wheels/
23
+ share/python-wheels/
24
+ *.egg-info/
25
+ .installed.cfg
26
+ *.egg
27
+ MANIFEST
28
+
29
+ # PyInstaller
30
+ *.manifest
31
+ *.spec
32
+
33
+ # Installer logs
34
+ pip-log.txt
35
+ pip-delete-this-directory.txt
36
+
37
+ # Unit test / coverage reports
38
+ htmlcov/
39
+ .tox/
40
+ .nox/
41
+ .coverage
42
+ .coverage.*
43
+ .cache
44
+ nosetests.xml
45
+ coverage.xml
46
+ *.cover
47
+ *.py,cover
48
+ .hypothesis/
49
+ .pytest_cache/
50
+ cover/
51
+
52
+ # Translations
53
+ *.mo
54
+ *.pot
55
+
56
+ # Django stuff:
57
+ *.log
58
+ local_settings.py
59
+ db.sqlite3
60
+ db.sqlite3-journal
61
+
62
+ # Flask stuff:
63
+ instance/
64
+ .webassets-cache
65
+
66
+ # Scrapy stuff:
67
+ .scrapy
68
+
69
+ # Sphinx documentation
70
+ docs/_build/
71
+
72
+ # PyBuilder
73
+ .pybuilder/
74
+ target/
75
+
76
+ # Jupyter Notebook
77
+ .ipynb_checkpoints
78
+
79
+ # IPython
80
+ profile_default/
81
+ ipython_config.py
82
+
83
+ # pyenv
84
+ .python-version
85
+
86
+ # pipenv
87
+ Pipfile.lock
88
+
89
+ # poetry
90
+ poetry.lock
91
+
92
+ # pdm
93
+ .pdm.toml
94
+
95
+ # PEP 582
96
+ __pypackages__/
97
+
98
+ # Celery stuff
99
+ celerybeat-schedule
100
+ celerybeat.pid
101
+
102
+ # SageMath parsed files
103
+ *.sage.py
104
+
105
+ # Environments
106
+ .env
107
+ .venv
108
+ env/
109
+ venv/
110
+ ENV/
111
+ env.bak/
112
+ venv.bak/
113
+
114
+ # Spyder project settings
115
+ .spyderproject
116
+ .spyproject
117
+
118
+ # Rope project settings
119
+ .ropeproject
120
+
121
+ # mkdocs documentation
122
+ /site
123
+
124
+ # mypy
125
+ .mypy_cache/
126
+ .dmypy.json
127
+ dmypy.json
128
+
129
+ # Pyre type checker
130
+ .pyre/
131
+
132
+ # pytype static type analyzer
133
+ .pytype/
134
+
135
+ # Cython debug symbols
136
+ cython_debug/
137
+
138
+ # IDEs
139
+ .vscode/
140
+ .idea/
141
+ *.swp
142
+ *.swo
143
+ *~
144
+ .DS_Store
@@ -0,0 +1,17 @@
1
+ # Releasing
2
+
3
+ ## First release
4
+
5
+ ```sh
6
+ # Build (generate dist/*.whl and dist/*.tar.gz)
7
+ uv build
8
+
9
+ # Publish in TestPyPI first
10
+ uv publish --publish-url https://test.pypi.org/legacy/ --token TU_TOKEN_TESTPYPI
11
+
12
+ # Verify installation from TestPyPI
13
+ uv pip install --index-url https://test.pypi.org/simple/ modelcost
14
+
15
+ # Publish in real PyPI
16
+ uv publish --token TU_TOKEN_PYPI
17
+ ```
@@ -0,0 +1,106 @@
1
+ Metadata-Version: 2.4
2
+ Name: modelcost
3
+ Version: 0.1.0
4
+ Summary: Calculate LLM API call costs from token usage
5
+ Project-URL: Homepage, https://github.com/rmescandon/modelcost
6
+ Project-URL: Repository, https://github.com/rmescandon/modelcost
7
+ Author-email: Roberto Mier Escandon <rmescandon@gmail.com>
8
+ License: MIT
9
+ Keywords: cost,llm,openai,pricing,tokens
10
+ Classifier: License :: OSI Approved :: MIT License
11
+ Classifier: Operating System :: OS Independent
12
+ Classifier: Programming Language :: Python :: 3
13
+ Requires-Python: >=3.11
14
+ Requires-Dist: click>=8.0
15
+ Requires-Dist: httpx>=0.27
16
+ Requires-Dist: tokencost>=0.1.16
17
+ Description-Content-Type: text/markdown
18
+
19
+ # modelcost
20
+
21
+ Calculate LLM API call costs from token usage using price catalogs from multiple sources.
22
+
23
+ Supported pricing sources:
24
+ - `litellm` (default)
25
+ - `openrouter`
26
+ - `tokencost`
27
+
28
+ ## Install
29
+
30
+ ```bash
31
+ python -m pip install modelcost
32
+ ```
33
+
34
+ ## CLI
35
+
36
+ The default command calculates cost, so you can omit the `cost` subcommand.
37
+
38
+ ```bash
39
+ # Default (cost)
40
+ modelcost gpt-4o 1000 500
41
+
42
+ # Explicit cost (optional)
43
+ modelcost cost gpt-4o 1000 500
44
+
45
+ # All sources in one run
46
+ modelcost --source all gpt-4o 1000 500
47
+
48
+ # JSON output
49
+ modelcost --json gpt-4o 1000 500
50
+ ```
51
+
52
+ List available models:
53
+
54
+ ```bash
55
+ modelcost models
56
+ modelcost models --source openrouter
57
+ modelcost models --filter gpt
58
+ modelcost models --json
59
+ ```
60
+
61
+ CLI help:
62
+
63
+ ```bash
64
+ modelcost --help
65
+ modelcost models --help
66
+ ```
67
+
68
+ ## Library
69
+
70
+ ```python
71
+ from modelcost.calculator import calculate_cost, list_models
72
+
73
+ result = calculate_cost("gpt-4o", 1000, 500)
74
+
75
+ for source in result.available_sources:
76
+ print(f"{source.source}: ${source.total_cost_usd:.6f}")
77
+
78
+ litellm_cost = next(s for s in result.sources if s.source == "litellm")
79
+ print(litellm_cost.price_per_million_input, litellm_cost.price_per_million_output)
80
+
81
+ models = list_models("openrouter")
82
+ ```
83
+
84
+ ## Output details
85
+
86
+ `calculate_cost()` returns a `CostResult` with:
87
+ - `model`, `input_tokens`, `output_tokens`
88
+ - `sources`: list of `SourceCost` objects
89
+ - `available_sources`: only sources with prices found
90
+
91
+ Each `SourceCost` includes:
92
+ - `source`
93
+ - `total_cost_usd`
94
+ - `price_per_million_input`
95
+ - `price_per_million_output`
96
+ - `error` (when not available)
97
+
98
+ ## Caching
99
+
100
+ `openrouter` responses are cached in `~/.modelcost_cache.json` for 1 hour.
101
+
102
+ ## Notes
103
+
104
+ - Prices are fetched at runtime from the upstream catalogs.
105
+ - If a model is missing in a source, that source is marked as unavailable.
106
+ - Network sources are fetched in parallel for the `all` option.
@@ -0,0 +1,88 @@
1
+ # modelcost
2
+
3
+ Calculate LLM API call costs from token usage using price catalogs from multiple sources.
4
+
5
+ Supported pricing sources:
6
+ - `litellm` (default)
7
+ - `openrouter`
8
+ - `tokencost`
9
+
10
+ ## Install
11
+
12
+ ```bash
13
+ python -m pip install modelcost
14
+ ```
15
+
16
+ ## CLI
17
+
18
+ The default command calculates cost, so you can omit the `cost` subcommand.
19
+
20
+ ```bash
21
+ # Default (cost)
22
+ modelcost gpt-4o 1000 500
23
+
24
+ # Explicit cost (optional)
25
+ modelcost cost gpt-4o 1000 500
26
+
27
+ # All sources in one run
28
+ modelcost --source all gpt-4o 1000 500
29
+
30
+ # JSON output
31
+ modelcost --json gpt-4o 1000 500
32
+ ```
33
+
34
+ List available models:
35
+
36
+ ```bash
37
+ modelcost models
38
+ modelcost models --source openrouter
39
+ modelcost models --filter gpt
40
+ modelcost models --json
41
+ ```
42
+
43
+ CLI help:
44
+
45
+ ```bash
46
+ modelcost --help
47
+ modelcost models --help
48
+ ```
49
+
50
+ ## Library
51
+
52
+ ```python
53
+ from modelcost.calculator import calculate_cost, list_models
54
+
55
+ result = calculate_cost("gpt-4o", 1000, 500)
56
+
57
+ for source in result.available_sources:
58
+ print(f"{source.source}: ${source.total_cost_usd:.6f}")
59
+
60
+ litellm_cost = next(s for s in result.sources if s.source == "litellm")
61
+ print(litellm_cost.price_per_million_input, litellm_cost.price_per_million_output)
62
+
63
+ models = list_models("openrouter")
64
+ ```
65
+
66
+ ## Output details
67
+
68
+ `calculate_cost()` returns a `CostResult` with:
69
+ - `model`, `input_tokens`, `output_tokens`
70
+ - `sources`: list of `SourceCost` objects
71
+ - `available_sources`: only sources with prices found
72
+
73
+ Each `SourceCost` includes:
74
+ - `source`
75
+ - `total_cost_usd`
76
+ - `price_per_million_input`
77
+ - `price_per_million_output`
78
+ - `error` (when not available)
79
+
80
+ ## Caching
81
+
82
+ `openrouter` responses are cached in `~/.modelcost_cache.json` for 1 hour.
83
+
84
+ ## Notes
85
+
86
+ - Prices are fetched at runtime from the upstream catalogs.
87
+ - If a model is missing in a source, that source is marked as unavailable.
88
+ - Network sources are fetched in parallel for the `all` option.
File without changes
@@ -0,0 +1,148 @@
1
+ from concurrent.futures import ThreadPoolExecutor, as_completed
2
+ from .models import CostResult, SourceCost
3
+ from .providers.openrouter import fetch_openrouter_prices, find_model
4
+ from .providers.litellm import fetch_litellm_prices
5
+
6
+ VALID_SOURCES = ("litellm", "openrouter", "tokencost", "all")
7
+
8
+
9
+ def list_models(source: str = "litellm") -> list[str]:
10
+ """Return the available models for the given source."""
11
+ if source not in ("litellm", "openrouter", "tokencost"):
12
+ raise ValueError(
13
+ f"Invalid source '{source}'. Valid values: litellm, openrouter, tokencost"
14
+ )
15
+
16
+ if source == "litellm":
17
+ return sorted(fetch_litellm_prices().keys())
18
+
19
+ if source == "openrouter":
20
+ return sorted(fetch_openrouter_prices().keys())
21
+
22
+ if source == "tokencost":
23
+ from tokencost.constants import TOKEN_COSTS
24
+
25
+ return sorted(TOKEN_COSTS.keys())
26
+
27
+
28
+ def calculate_cost(
29
+ model: str,
30
+ input_tokens: int,
31
+ output_tokens: int,
32
+ source: str = "litellm",
33
+ ) -> CostResult:
34
+ if source not in VALID_SOURCES:
35
+ raise ValueError(f"Invalid source '{source}'. Valid values: {VALID_SOURCES}")
36
+
37
+ active = ["litellm", "openrouter", "tokencost"] if source == "all" else [source]
38
+
39
+ sources = _fetch_all(model, input_tokens, output_tokens, active)
40
+
41
+ return CostResult(
42
+ model=model,
43
+ input_tokens=input_tokens,
44
+ output_tokens=output_tokens,
45
+ sources=sources,
46
+ single_source=source != "all", # output formatting flag
47
+ )
48
+
49
+
50
+ def _fetch_all(
51
+ model: str, input_tokens: int, output_tokens: int, active: list[str]
52
+ ) -> list[SourceCost]:
53
+ network_tasks = {
54
+ name: fn
55
+ for name, fn in {
56
+ "litellm": fetch_litellm_prices,
57
+ "openrouter": fetch_openrouter_prices,
58
+ }.items()
59
+ if name in active
60
+ }
61
+
62
+ results: dict[str, SourceCost] = {}
63
+
64
+ if network_tasks:
65
+ with ThreadPoolExecutor(max_workers=len(network_tasks)) as executor:
66
+ futures = {
67
+ executor.submit(
68
+ _compute, name, fn, model, input_tokens, output_tokens
69
+ ): name
70
+ for name, fn in network_tasks.items()
71
+ }
72
+ for future in as_completed(futures):
73
+ results[futures[future]] = future.result()
74
+
75
+ if "tokencost" in active:
76
+ results["tokencost"] = _tokencost_source(model, input_tokens, output_tokens)
77
+
78
+ # Preserve order: litellm -> openrouter -> tokencost
79
+ order = ["litellm", "openrouter", "tokencost"]
80
+ return [results[name] for name in order if name in results]
81
+
82
+
83
+ def _compute(
84
+ source_name: str, fetch_fn, model: str, input_tokens: int, output_tokens: int
85
+ ) -> SourceCost:
86
+ try:
87
+ prices = fetch_fn()
88
+ pricing = (
89
+ find_model(model, prices)
90
+ if source_name == "openrouter"
91
+ else prices.get(model)
92
+ )
93
+ if pricing is None:
94
+ return SourceCost(
95
+ source=source_name,
96
+ total_cost_usd=None,
97
+ price_per_million_input=None,
98
+ price_per_million_output=None,
99
+ error=f"Model '{model}' not found",
100
+ )
101
+ cost = pricing["prompt"] * input_tokens + pricing["completion"] * output_tokens
102
+ return SourceCost(
103
+ source=source_name,
104
+ total_cost_usd=cost,
105
+ price_per_million_input=pricing["prompt"] * 1_000_000,
106
+ price_per_million_output=pricing["completion"] * 1_000_000,
107
+ )
108
+ except Exception as e:
109
+ return SourceCost(
110
+ source=source_name,
111
+ total_cost_usd=None,
112
+ price_per_million_input=None,
113
+ price_per_million_output=None,
114
+ error=str(e),
115
+ )
116
+
117
+
118
+ def _tokencost_source(model: str, input_tokens: int, output_tokens: int) -> SourceCost:
119
+ try:
120
+ from tokencost.costs import calculate_cost_by_tokens
121
+ from tokencost.constants import TOKEN_COSTS
122
+
123
+ input_cost = calculate_cost_by_tokens(input_tokens, model, "input")
124
+ output_cost = calculate_cost_by_tokens(output_tokens, model, "output")
125
+ entry = TOKEN_COSTS[model.lower()]
126
+
127
+ return SourceCost(
128
+ source="tokencost",
129
+ total_cost_usd=float(input_cost + output_cost),
130
+ price_per_million_input=float(entry["input_cost_per_token"]) * 1_000_000,
131
+ price_per_million_output=float(entry["output_cost_per_token"]) * 1_000_000,
132
+ )
133
+ except KeyError as e:
134
+ return SourceCost(
135
+ source="tokencost",
136
+ total_cost_usd=None,
137
+ price_per_million_input=None,
138
+ price_per_million_output=None,
139
+ error=f"Model not found: {e}",
140
+ )
141
+ except Exception as e:
142
+ return SourceCost(
143
+ source="tokencost",
144
+ total_cost_usd=None,
145
+ price_per_million_input=None,
146
+ price_per_million_output=None,
147
+ error=str(e),
148
+ )
@@ -0,0 +1,107 @@
1
+ import sys
2
+ import json as json_mod
3
+ import click
4
+ from .calculator import calculate_cost, list_models, VALID_SOURCES
5
+
6
+
7
+ class DefaultCostGroup(click.Group):
8
+ """A Group that falls back to the 'cost' command when no subcommand is matched."""
9
+
10
+ def parse_args(self, ctx: click.Context, args: list[str]) -> list[str]:
11
+ # If the first non-option token is not a known subcommand, prepend 'cost'
12
+ for i, arg in enumerate(args):
13
+ if not arg.startswith("-"):
14
+ if arg not in self.commands:
15
+ args = ["cost"] + args
16
+ break
17
+ return super().parse_args(ctx, args)
18
+
19
+
20
+ @click.group(cls=DefaultCostGroup)
21
+ def main() -> None:
22
+ """Calculate LLM API call costs from token usage."""
23
+
24
+
25
+ @main.command("cost")
26
+ @click.argument("model")
27
+ @click.argument("input_tokens", type=int)
28
+ @click.argument("output_tokens", type=int)
29
+ @click.option(
30
+ "--source",
31
+ default="litellm",
32
+ show_default=True,
33
+ type=click.Choice(VALID_SOURCES),
34
+ help="Pricing source.",
35
+ )
36
+ @click.option("--json", "as_json", is_flag=True, help="Output as JSON.")
37
+ def cost_cmd(
38
+ model: str, input_tokens: int, output_tokens: int, source: str, as_json: bool
39
+ ) -> None:
40
+ """Calculate the cost for MODEL with INPUT_TOKENS and OUTPUT_TOKENS."""
41
+ # ── Calculation mode ──────────────────────────────────────────────
42
+ try:
43
+ result = calculate_cost(model, input_tokens, output_tokens, source=source)
44
+ except Exception as e:
45
+ click.echo(f"Error: {e}", err=True)
46
+ sys.exit(1)
47
+
48
+ if as_json:
49
+ click.echo(json_mod.dumps(result.to_dict(), indent=2))
50
+ return
51
+
52
+ if result.single_source:
53
+ s = result.sources[0]
54
+ if s.available:
55
+ click.echo(f"${s.total_cost_usd:.6f}")
56
+ else:
57
+ click.echo(f"unavailable — {s.error}", err=True)
58
+ sys.exit(1)
59
+ else:
60
+ click.echo(
61
+ f"Model: {result.model} ({result.input_tokens} in / {result.output_tokens} out)\n"
62
+ )
63
+ for s in result.sources:
64
+ if s.available:
65
+ click.echo(f" [{s.source:<12}] ${s.total_cost_usd:.6f} USD")
66
+ else:
67
+ click.echo(f" [{s.source:<12}] unavailable — {s.error}")
68
+
69
+
70
+ @main.command("models")
71
+ @click.option(
72
+ "--source",
73
+ default="litellm",
74
+ show_default=True,
75
+ type=click.Choice(("litellm", "openrouter", "tokencost")),
76
+ help="Pricing source.",
77
+ )
78
+ @click.option(
79
+ "--filter",
80
+ "filter_term",
81
+ default=None,
82
+ metavar="TERM",
83
+ help="Filter by substring (e.g. gpt).",
84
+ )
85
+ @click.option("--json", "as_json", is_flag=True, help="Output as JSON.")
86
+ def models_cmd(source: str, filter_term: str | None, as_json: bool) -> None:
87
+ """List available models for the given source."""
88
+ # ── List mode ──────────────────────────────────────────────
89
+ try:
90
+ models = list_models(source)
91
+ except Exception as e:
92
+ click.echo(f"Error: {e}", err=True)
93
+ sys.exit(1)
94
+
95
+ if filter_term:
96
+ models = [m for m in models if filter_term.lower() in m.lower()]
97
+
98
+ if as_json:
99
+ click.echo(
100
+ json_mod.dumps(
101
+ {"source": source, "count": len(models), "models": models}, indent=2
102
+ )
103
+ )
104
+ else:
105
+ click.echo(f"Models available in [{source}] ({len(models)} total):\n")
106
+ for m in models:
107
+ click.echo(f" {m}")
@@ -0,0 +1,44 @@
1
+ from dataclasses import dataclass, field
2
+
3
+
4
+ @dataclass
5
+ class SourceCost:
6
+ source: str
7
+ total_cost_usd: float | None
8
+ price_per_million_input: float | None
9
+ price_per_million_output: float | None
10
+ error: str | None = None
11
+
12
+ @property
13
+ def available(self) -> bool:
14
+ return self.total_cost_usd is not None
15
+
16
+
17
+ @dataclass
18
+ class CostResult:
19
+ model: str
20
+ input_tokens: int
21
+ output_tokens: int
22
+ sources: list[SourceCost]
23
+ single_source: bool = True # False when source="all"
24
+
25
+ @property
26
+ def available_sources(self) -> list[SourceCost]:
27
+ return [s for s in self.sources if s.available]
28
+
29
+ def to_dict(self) -> dict:
30
+ return {
31
+ "model": self.model,
32
+ "input_tokens": self.input_tokens,
33
+ "output_tokens": self.output_tokens,
34
+ "costs": [
35
+ {
36
+ "source": s.source,
37
+ "total_cost_usd": s.total_cost_usd,
38
+ "price_per_million_input": s.price_per_million_input,
39
+ "price_per_million_output": s.price_per_million_output,
40
+ "error": s.error,
41
+ }
42
+ for s in self.sources
43
+ ],
44
+ }
File without changes
@@ -0,0 +1,24 @@
1
+ import json
2
+ import time
3
+ from pathlib import Path
4
+
5
+ CACHE_FILE = Path.home() / ".modelcost_cache.json"
6
+ CACHE_TTL = 3600
7
+
8
+
9
+ def load_cache(namespace: str) -> dict:
10
+ if not CACHE_FILE.exists():
11
+ return {}
12
+ data = json.loads(CACHE_FILE.read_text())
13
+ entry = data.get(namespace, {})
14
+ if time.time() - entry.get("_ts", 0) > CACHE_TTL:
15
+ return {}
16
+ return entry.get("models", {})
17
+
18
+
19
+ def save_cache(namespace: str, models: dict) -> None:
20
+ data = {}
21
+ if CACHE_FILE.exists():
22
+ data = json.loads(CACHE_FILE.read_text())
23
+ data[namespace] = {"_ts": time.time(), "models": models}
24
+ CACHE_FILE.write_text(json.dumps(data))
@@ -0,0 +1,18 @@
1
+ import httpx
2
+
3
+ LITELLM_URL = (
4
+ "https://raw.githubusercontent.com/BerriAI/litellm/main/"
5
+ "model_prices_and_context_window.json"
6
+ )
7
+
8
+ def fetch_litellm_prices() -> dict:
9
+ resp = httpx.get(LITELLM_URL, timeout=10)
10
+ resp.raise_for_status()
11
+ return {
12
+ model: {
13
+ "prompt": info["input_cost_per_token"],
14
+ "completion": info["output_cost_per_token"],
15
+ }
16
+ for model, info in resp.json().items()
17
+ if "input_cost_per_token" in info and "output_cost_per_token" in info
18
+ }