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.
- modelcost-0.1.0/.gitignore +144 -0
- modelcost-0.1.0/HACKING.md +17 -0
- modelcost-0.1.0/PKG-INFO +106 -0
- modelcost-0.1.0/README.md +88 -0
- modelcost-0.1.0/modelcost/__init__.py +0 -0
- modelcost-0.1.0/modelcost/calculator.py +148 -0
- modelcost-0.1.0/modelcost/cli.py +107 -0
- modelcost-0.1.0/modelcost/models.py +44 -0
- modelcost-0.1.0/modelcost/providers/__init__.py +0 -0
- modelcost-0.1.0/modelcost/providers/cache.py +24 -0
- modelcost-0.1.0/modelcost/providers/litellm.py +18 -0
- modelcost-0.1.0/modelcost/providers/openrouter.py +30 -0
- modelcost-0.1.0/pyproject.toml +30 -0
- modelcost-0.1.0/uv.lock +1227 -0
|
@@ -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
|
+
```
|
modelcost-0.1.0/PKG-INFO
ADDED
|
@@ -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
|
+
}
|