tokenmeter-cli 0.2.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.
- tokenmeter_cli-0.2.0/.github/workflows/ci.yml +28 -0
- tokenmeter_cli-0.2.0/.github/workflows/publish.yml +21 -0
- tokenmeter_cli-0.2.0/.gitignore +26 -0
- tokenmeter_cli-0.2.0/.pre-commit-config.yaml +15 -0
- tokenmeter_cli-0.2.0/CHANGELOG.md +25 -0
- tokenmeter_cli-0.2.0/Dockerfile +19 -0
- tokenmeter_cli-0.2.0/LICENSE +21 -0
- tokenmeter_cli-0.2.0/PKG-INFO +118 -0
- tokenmeter_cli-0.2.0/README.md +73 -0
- tokenmeter_cli-0.2.0/pyproject.toml +69 -0
- tokenmeter_cli-0.2.0/src/tokenmeter/__init__.py +19 -0
- tokenmeter_cli-0.2.0/src/tokenmeter/__main__.py +4 -0
- tokenmeter_cli-0.2.0/src/tokenmeter/cli.py +135 -0
- tokenmeter_cli-0.2.0/src/tokenmeter/encoder.py +52 -0
- tokenmeter_cli-0.2.0/src/tokenmeter/inputs.py +41 -0
- tokenmeter_cli-0.2.0/src/tokenmeter/meter.py +57 -0
- tokenmeter_cli-0.2.0/src/tokenmeter/pricing.py +67 -0
- tokenmeter_cli-0.2.0/src/tokenmeter/render.py +51 -0
- tokenmeter_cli-0.2.0/tests/conftest.py +21 -0
- tokenmeter_cli-0.2.0/tests/test_cli.py +62 -0
- tokenmeter_cli-0.2.0/tests/test_inputs.py +36 -0
- tokenmeter_cli-0.2.0/tests/test_meter.py +35 -0
- tokenmeter_cli-0.2.0/tests/test_pricing.py +43 -0
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
name: ci
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
push:
|
|
5
|
+
branches: [main]
|
|
6
|
+
pull_request:
|
|
7
|
+
|
|
8
|
+
jobs:
|
|
9
|
+
test:
|
|
10
|
+
runs-on: ubuntu-latest
|
|
11
|
+
strategy:
|
|
12
|
+
fail-fast: false
|
|
13
|
+
matrix:
|
|
14
|
+
python-version: ["3.10", "3.11", "3.12"]
|
|
15
|
+
steps:
|
|
16
|
+
- uses: actions/checkout@v4
|
|
17
|
+
- name: Install uv
|
|
18
|
+
uses: astral-sh/setup-uv@v3
|
|
19
|
+
with:
|
|
20
|
+
python-version: ${{ matrix.python-version }}
|
|
21
|
+
- name: Sync dependencies
|
|
22
|
+
run: uv sync --all-extras --dev
|
|
23
|
+
- name: Lint
|
|
24
|
+
run: uv run ruff check .
|
|
25
|
+
- name: Format check
|
|
26
|
+
run: uv run ruff format --check .
|
|
27
|
+
- name: Test
|
|
28
|
+
run: uv run pytest --cov --cov-report=term-missing
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
name: publish
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
release:
|
|
5
|
+
types: [published]
|
|
6
|
+
workflow_dispatch:
|
|
7
|
+
|
|
8
|
+
jobs:
|
|
9
|
+
pypi:
|
|
10
|
+
runs-on: ubuntu-latest
|
|
11
|
+
environment: pypi
|
|
12
|
+
permissions:
|
|
13
|
+
id-token: write
|
|
14
|
+
steps:
|
|
15
|
+
- uses: actions/checkout@v4
|
|
16
|
+
- name: Install uv
|
|
17
|
+
uses: astral-sh/setup-uv@v3
|
|
18
|
+
- name: Build
|
|
19
|
+
run: uv build
|
|
20
|
+
- name: Publish to PyPI
|
|
21
|
+
run: uv publish
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
# Python
|
|
2
|
+
__pycache__/
|
|
3
|
+
*.py[cod]
|
|
4
|
+
*.egg-info/
|
|
5
|
+
.eggs/
|
|
6
|
+
build/
|
|
7
|
+
dist/
|
|
8
|
+
|
|
9
|
+
# Virtual environments
|
|
10
|
+
.venv/
|
|
11
|
+
venv/
|
|
12
|
+
|
|
13
|
+
# uv
|
|
14
|
+
uv.lock
|
|
15
|
+
|
|
16
|
+
# Test and coverage
|
|
17
|
+
.pytest_cache/
|
|
18
|
+
.coverage
|
|
19
|
+
.coverage.*
|
|
20
|
+
htmlcov/
|
|
21
|
+
.ruff_cache/
|
|
22
|
+
|
|
23
|
+
# Editor / OS
|
|
24
|
+
.vscode/
|
|
25
|
+
.idea/
|
|
26
|
+
.DS_Store
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
repos:
|
|
2
|
+
- repo: https://github.com/astral-sh/ruff-pre-commit
|
|
3
|
+
rev: v0.6.9
|
|
4
|
+
hooks:
|
|
5
|
+
- id: ruff
|
|
6
|
+
args: [--fix]
|
|
7
|
+
- id: ruff-format
|
|
8
|
+
- repo: https://github.com/pre-commit/pre-commit-hooks
|
|
9
|
+
rev: v4.6.0
|
|
10
|
+
hooks:
|
|
11
|
+
- id: end-of-file-fixer
|
|
12
|
+
- id: trailing-whitespace
|
|
13
|
+
- id: check-yaml
|
|
14
|
+
- id: check-toml
|
|
15
|
+
- id: check-merge-conflict
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
All notable changes to this project are documented here. The format is based
|
|
4
|
+
on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project
|
|
5
|
+
adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
6
|
+
|
|
7
|
+
## [0.2.0] - 2026-03-26
|
|
8
|
+
|
|
9
|
+
### Added
|
|
10
|
+
- Docker image and a published container entry point.
|
|
11
|
+
- Continuous integration across Python 3.10, 3.11 and 3.12.
|
|
12
|
+
- Expanded documentation and usage examples.
|
|
13
|
+
|
|
14
|
+
## [0.1.0] - 2026-03-23
|
|
15
|
+
|
|
16
|
+
### Added
|
|
17
|
+
- `count` command: exact token counts for files, directories or stdin, with a
|
|
18
|
+
per-model cost estimate.
|
|
19
|
+
- `budget` command: fail when the estimated cost exceeds a limit, for use as a
|
|
20
|
+
CI gate.
|
|
21
|
+
- `models` command: list the known models and their dated prices.
|
|
22
|
+
- Token counting via tiktoken for the supported OpenAI encodings.
|
|
23
|
+
|
|
24
|
+
[0.2.0]: https://github.com/jmweb-org/tokenmeter/releases/tag/v0.2.0
|
|
25
|
+
[0.1.0]: https://github.com/jmweb-org/tokenmeter/releases/tag/v0.1.0
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
# Count tokens from inside a container by mounting your prompts:
|
|
2
|
+
#
|
|
3
|
+
# docker build -t tokenmeter .
|
|
4
|
+
# docker run --rm -v "$PWD:/w" -w /w tokenmeter count prompts/ --model gpt-4o
|
|
5
|
+
#
|
|
6
|
+
FROM python:3.12-slim
|
|
7
|
+
|
|
8
|
+
LABEL org.opencontainers.image.source="https://github.com/jmweb-org/tokenmeter"
|
|
9
|
+
LABEL org.opencontainers.image.description="Count tokens and estimate cost for prompts before you send them."
|
|
10
|
+
LABEL org.opencontainers.image.licenses="MIT"
|
|
11
|
+
|
|
12
|
+
WORKDIR /app
|
|
13
|
+
COPY pyproject.toml README.md LICENSE ./
|
|
14
|
+
COPY src ./src
|
|
15
|
+
|
|
16
|
+
RUN pip install --no-cache-dir .
|
|
17
|
+
|
|
18
|
+
ENTRYPOINT ["tokenmeter"]
|
|
19
|
+
CMD ["--help"]
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 José del Río
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: tokenmeter-cli
|
|
3
|
+
Version: 0.2.0
|
|
4
|
+
Summary: Count tokens and estimate cost for prompts before you send them.
|
|
5
|
+
Project-URL: Homepage, https://github.com/jmweb-org/tokenmeter
|
|
6
|
+
Project-URL: Repository, https://github.com/jmweb-org/tokenmeter
|
|
7
|
+
Project-URL: Issues, https://github.com/jmweb-org/tokenmeter/issues
|
|
8
|
+
Author: José del Río
|
|
9
|
+
License: MIT License
|
|
10
|
+
|
|
11
|
+
Copyright (c) 2026 José del Río
|
|
12
|
+
|
|
13
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
14
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
15
|
+
in the Software without restriction, including without limitation the rights
|
|
16
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
17
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
18
|
+
furnished to do so, subject to the following conditions:
|
|
19
|
+
|
|
20
|
+
The above copyright notice and this permission notice shall be included in all
|
|
21
|
+
copies or substantial portions of the Software.
|
|
22
|
+
|
|
23
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
24
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
25
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
26
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
27
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
28
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
29
|
+
SOFTWARE.
|
|
30
|
+
License-File: LICENSE
|
|
31
|
+
Keywords: budget,cli,cost,llm,openai,tiktoken,tokens
|
|
32
|
+
Classifier: Development Status :: 4 - Beta
|
|
33
|
+
Classifier: Intended Audience :: Developers
|
|
34
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
35
|
+
Classifier: Operating System :: OS Independent
|
|
36
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
37
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
38
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
39
|
+
Classifier: Topic :: Utilities
|
|
40
|
+
Requires-Python: >=3.10
|
|
41
|
+
Requires-Dist: rich>=13.0
|
|
42
|
+
Requires-Dist: tiktoken>=0.7
|
|
43
|
+
Requires-Dist: typer>=0.12
|
|
44
|
+
Description-Content-Type: text/markdown
|
|
45
|
+
|
|
46
|
+
# tokenmeter
|
|
47
|
+
|
|
48
|
+
[](https://github.com/jmweb-org/tokenmeter/actions/workflows/ci.yml)
|
|
49
|
+
[](https://pypi.org/project/tokenmeter-cli/)
|
|
50
|
+
[](https://www.python.org)
|
|
51
|
+
[](LICENSE)
|
|
52
|
+
|
|
53
|
+
Count tokens and estimate cost for prompts before you send them, from the
|
|
54
|
+
command line or as a CI budget gate.
|
|
55
|
+
|
|
56
|
+
Prompt templates grow, a few-shot example gets added, a retrieved context
|
|
57
|
+
balloons, and suddenly every call costs more than you thought. `tokenmeter`
|
|
58
|
+
gives you the exact token count and a dollar estimate up front, for a single
|
|
59
|
+
prompt or a whole directory of templates.
|
|
60
|
+
|
|
61
|
+
```console
|
|
62
|
+
$ tokenmeter count prompts/system.txt --model gpt-4o
|
|
63
|
+
input in tok out tok cost (USD)
|
|
64
|
+
prompts/system.txt 812 0 $0.002030
|
|
65
|
+
|
|
66
|
+
$ tokenmeter count prompts/ --model gpt-4o-mini --json
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
## Install
|
|
70
|
+
|
|
71
|
+
```console
|
|
72
|
+
$ pip install tokenmeter-cli # from PyPI, once released
|
|
73
|
+
$ pip install git+https://github.com/jmweb-org/tokenmeter # latest, available now
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
Token counting is exact for the supported OpenAI encodings via `tiktoken`.
|
|
77
|
+
|
|
78
|
+
## Usage
|
|
79
|
+
|
|
80
|
+
```console
|
|
81
|
+
$ tokenmeter count system.txt -m gpt-4o # one file
|
|
82
|
+
$ tokenmeter count prompts/ -m gpt-4o-mini # every text file in a directory
|
|
83
|
+
$ cat prompt.txt | tokenmeter count - -m gpt-4o # standard input
|
|
84
|
+
$ tokenmeter count p.txt --output-tokens 500 # include an assumed completion
|
|
85
|
+
$ tokenmeter models # list models and prices
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
### As a budget gate
|
|
89
|
+
|
|
90
|
+
Fail a build when a prompt set would cost more than you allow:
|
|
91
|
+
|
|
92
|
+
```console
|
|
93
|
+
$ tokenmeter budget prompts/ --model gpt-4o --max-cost 0.05
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
```yaml
|
|
97
|
+
- run: tokenmeter budget prompts/ --model gpt-4o --max-cost 0.05
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
## Cost model
|
|
101
|
+
|
|
102
|
+
Counts are real tokens. Cost multiplies tokens by a per-model rate from a small,
|
|
103
|
+
dated price table (`tokenmeter models` prints it with its "as of" date). By
|
|
104
|
+
default only input tokens are counted; pass `--output-tokens N` to add an
|
|
105
|
+
assumed completion length to the estimate. Prices change, so treat the dollar
|
|
106
|
+
figures as estimates and update the table when they move.
|
|
107
|
+
|
|
108
|
+
## Exit codes
|
|
109
|
+
|
|
110
|
+
| Code | Meaning |
|
|
111
|
+
| --- | --- |
|
|
112
|
+
| 0 | Counted; under budget (or `count` was used) |
|
|
113
|
+
| 1 | `budget` estimate exceeded `--max-cost` |
|
|
114
|
+
| 2 | An input was missing, or the model is unknown |
|
|
115
|
+
|
|
116
|
+
## License
|
|
117
|
+
|
|
118
|
+
MIT. See [LICENSE](LICENSE).
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
# tokenmeter
|
|
2
|
+
|
|
3
|
+
[](https://github.com/jmweb-org/tokenmeter/actions/workflows/ci.yml)
|
|
4
|
+
[](https://pypi.org/project/tokenmeter-cli/)
|
|
5
|
+
[](https://www.python.org)
|
|
6
|
+
[](LICENSE)
|
|
7
|
+
|
|
8
|
+
Count tokens and estimate cost for prompts before you send them, from the
|
|
9
|
+
command line or as a CI budget gate.
|
|
10
|
+
|
|
11
|
+
Prompt templates grow, a few-shot example gets added, a retrieved context
|
|
12
|
+
balloons, and suddenly every call costs more than you thought. `tokenmeter`
|
|
13
|
+
gives you the exact token count and a dollar estimate up front, for a single
|
|
14
|
+
prompt or a whole directory of templates.
|
|
15
|
+
|
|
16
|
+
```console
|
|
17
|
+
$ tokenmeter count prompts/system.txt --model gpt-4o
|
|
18
|
+
input in tok out tok cost (USD)
|
|
19
|
+
prompts/system.txt 812 0 $0.002030
|
|
20
|
+
|
|
21
|
+
$ tokenmeter count prompts/ --model gpt-4o-mini --json
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
## Install
|
|
25
|
+
|
|
26
|
+
```console
|
|
27
|
+
$ pip install tokenmeter-cli # from PyPI, once released
|
|
28
|
+
$ pip install git+https://github.com/jmweb-org/tokenmeter # latest, available now
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
Token counting is exact for the supported OpenAI encodings via `tiktoken`.
|
|
32
|
+
|
|
33
|
+
## Usage
|
|
34
|
+
|
|
35
|
+
```console
|
|
36
|
+
$ tokenmeter count system.txt -m gpt-4o # one file
|
|
37
|
+
$ tokenmeter count prompts/ -m gpt-4o-mini # every text file in a directory
|
|
38
|
+
$ cat prompt.txt | tokenmeter count - -m gpt-4o # standard input
|
|
39
|
+
$ tokenmeter count p.txt --output-tokens 500 # include an assumed completion
|
|
40
|
+
$ tokenmeter models # list models and prices
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
### As a budget gate
|
|
44
|
+
|
|
45
|
+
Fail a build when a prompt set would cost more than you allow:
|
|
46
|
+
|
|
47
|
+
```console
|
|
48
|
+
$ tokenmeter budget prompts/ --model gpt-4o --max-cost 0.05
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
```yaml
|
|
52
|
+
- run: tokenmeter budget prompts/ --model gpt-4o --max-cost 0.05
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
## Cost model
|
|
56
|
+
|
|
57
|
+
Counts are real tokens. Cost multiplies tokens by a per-model rate from a small,
|
|
58
|
+
dated price table (`tokenmeter models` prints it with its "as of" date). By
|
|
59
|
+
default only input tokens are counted; pass `--output-tokens N` to add an
|
|
60
|
+
assumed completion length to the estimate. Prices change, so treat the dollar
|
|
61
|
+
figures as estimates and update the table when they move.
|
|
62
|
+
|
|
63
|
+
## Exit codes
|
|
64
|
+
|
|
65
|
+
| Code | Meaning |
|
|
66
|
+
| --- | --- |
|
|
67
|
+
| 0 | Counted; under budget (or `count` was used) |
|
|
68
|
+
| 1 | `budget` estimate exceeded `--max-cost` |
|
|
69
|
+
| 2 | An input was missing, or the model is unknown |
|
|
70
|
+
|
|
71
|
+
## License
|
|
72
|
+
|
|
73
|
+
MIT. See [LICENSE](LICENSE).
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "tokenmeter-cli"
|
|
7
|
+
version = "0.2.0"
|
|
8
|
+
description = "Count tokens and estimate cost for prompts before you send them."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.10"
|
|
11
|
+
license = { file = "LICENSE" }
|
|
12
|
+
authors = [{ name = "José del Río" }]
|
|
13
|
+
keywords = ["llm", "tokens", "tiktoken", "cost", "openai", "budget", "cli"]
|
|
14
|
+
classifiers = [
|
|
15
|
+
"Development Status :: 4 - Beta",
|
|
16
|
+
"Intended Audience :: Developers",
|
|
17
|
+
"License :: OSI Approved :: MIT License",
|
|
18
|
+
"Operating System :: OS Independent",
|
|
19
|
+
"Programming Language :: Python :: 3.10",
|
|
20
|
+
"Programming Language :: Python :: 3.11",
|
|
21
|
+
"Programming Language :: Python :: 3.12",
|
|
22
|
+
"Topic :: Utilities",
|
|
23
|
+
]
|
|
24
|
+
dependencies = [
|
|
25
|
+
"typer>=0.12",
|
|
26
|
+
"rich>=13.0",
|
|
27
|
+
"tiktoken>=0.7",
|
|
28
|
+
]
|
|
29
|
+
|
|
30
|
+
[project.urls]
|
|
31
|
+
Homepage = "https://github.com/jmweb-org/tokenmeter"
|
|
32
|
+
Repository = "https://github.com/jmweb-org/tokenmeter"
|
|
33
|
+
Issues = "https://github.com/jmweb-org/tokenmeter/issues"
|
|
34
|
+
|
|
35
|
+
[project.scripts]
|
|
36
|
+
tokenmeter = "tokenmeter.cli:entrypoint"
|
|
37
|
+
|
|
38
|
+
[dependency-groups]
|
|
39
|
+
dev = [
|
|
40
|
+
"pytest>=8.0",
|
|
41
|
+
"pytest-cov>=5.0",
|
|
42
|
+
"ruff>=0.6",
|
|
43
|
+
]
|
|
44
|
+
|
|
45
|
+
[tool.hatch.build.targets.wheel]
|
|
46
|
+
packages = ["src/tokenmeter"]
|
|
47
|
+
|
|
48
|
+
[tool.pytest.ini_options]
|
|
49
|
+
addopts = "-q"
|
|
50
|
+
testpaths = ["tests"]
|
|
51
|
+
pythonpath = ["."]
|
|
52
|
+
|
|
53
|
+
[tool.ruff]
|
|
54
|
+
line-length = 100
|
|
55
|
+
target-version = "py310"
|
|
56
|
+
src = ["src", "tests"]
|
|
57
|
+
|
|
58
|
+
[tool.ruff.lint]
|
|
59
|
+
select = ["E", "F", "I", "UP", "B", "S", "C4", "RUF"]
|
|
60
|
+
|
|
61
|
+
[tool.ruff.lint.flake8-bugbear]
|
|
62
|
+
extend-immutable-calls = ["typer.Argument", "typer.Option"]
|
|
63
|
+
|
|
64
|
+
[tool.ruff.lint.per-file-ignores]
|
|
65
|
+
"tests/*" = ["S101"]
|
|
66
|
+
|
|
67
|
+
[tool.coverage.run]
|
|
68
|
+
source = ["tokenmeter"]
|
|
69
|
+
branch = true
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
"""tokenmeter: count tokens and estimate cost for prompts before sending them."""
|
|
2
|
+
|
|
3
|
+
from tokenmeter.meter import Measurement, measure, over_budget, total_cost
|
|
4
|
+
from tokenmeter.pricing import ModelPrice, known_models, price_for
|
|
5
|
+
from tokenmeter.pricing import total_cost as cost
|
|
6
|
+
|
|
7
|
+
__version__ = "0.2.0"
|
|
8
|
+
|
|
9
|
+
__all__ = [
|
|
10
|
+
"Measurement",
|
|
11
|
+
"ModelPrice",
|
|
12
|
+
"__version__",
|
|
13
|
+
"cost",
|
|
14
|
+
"known_models",
|
|
15
|
+
"measure",
|
|
16
|
+
"over_budget",
|
|
17
|
+
"price_for",
|
|
18
|
+
"total_cost",
|
|
19
|
+
]
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
"""Command-line interface for tokenmeter."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import sys
|
|
7
|
+
|
|
8
|
+
import typer
|
|
9
|
+
from rich.console import Console
|
|
10
|
+
from rich.table import Table
|
|
11
|
+
|
|
12
|
+
from tokenmeter import __version__
|
|
13
|
+
from tokenmeter.encoder import EncoderError, encoder_for_model
|
|
14
|
+
from tokenmeter.inputs import read_inputs
|
|
15
|
+
from tokenmeter.meter import measure, over_budget, total_cost
|
|
16
|
+
from tokenmeter.pricing import (
|
|
17
|
+
PRICES_AS_OF,
|
|
18
|
+
UnknownModel,
|
|
19
|
+
known_models,
|
|
20
|
+
price_for,
|
|
21
|
+
)
|
|
22
|
+
from tokenmeter.render import measurements_to_json, render_table
|
|
23
|
+
|
|
24
|
+
app = typer.Typer(
|
|
25
|
+
add_completion=False,
|
|
26
|
+
no_args_is_help=True,
|
|
27
|
+
help="Count tokens and estimate cost for prompts before you send them.",
|
|
28
|
+
)
|
|
29
|
+
_out = Console()
|
|
30
|
+
_err = Console(stderr=True)
|
|
31
|
+
|
|
32
|
+
EXIT_OK = 0
|
|
33
|
+
EXIT_OVER_BUDGET = 1
|
|
34
|
+
EXIT_BAD_INPUT = 2
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _version_callback(value: bool) -> None:
|
|
38
|
+
if value:
|
|
39
|
+
_out.print(f"tokenmeter {__version__}")
|
|
40
|
+
raise typer.Exit()
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
@app.callback()
|
|
44
|
+
def main(
|
|
45
|
+
_version: bool = typer.Option(
|
|
46
|
+
False,
|
|
47
|
+
"--version",
|
|
48
|
+
callback=_version_callback,
|
|
49
|
+
is_eager=True,
|
|
50
|
+
help="Show the version and exit.",
|
|
51
|
+
),
|
|
52
|
+
) -> None:
|
|
53
|
+
"""tokenmeter command-line interface."""
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _collect(paths, model, output_tokens):
|
|
57
|
+
inputs = read_inputs(paths)
|
|
58
|
+
encoder = encoder_for_model(model)
|
|
59
|
+
return [
|
|
60
|
+
measure(encoder, model, name, text, output_tokens=output_tokens) for name, text in inputs
|
|
61
|
+
]
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
@app.command("count")
|
|
65
|
+
def count(
|
|
66
|
+
paths: list[str] = typer.Argument(..., help="Files, directories, or - for stdin."),
|
|
67
|
+
model: str = typer.Option("gpt-4o", "--model", "-m", help="Model to price against."),
|
|
68
|
+
output_tokens: int = typer.Option(
|
|
69
|
+
0, "--output-tokens", help="Assumed completion tokens, for cost only."
|
|
70
|
+
),
|
|
71
|
+
as_json: bool = typer.Option(False, "--json", help="Emit JSON."),
|
|
72
|
+
) -> None:
|
|
73
|
+
"""Count tokens and estimate cost for one or more inputs."""
|
|
74
|
+
|
|
75
|
+
try:
|
|
76
|
+
measurements = _collect(paths, model, output_tokens)
|
|
77
|
+
except UnknownModel as exc:
|
|
78
|
+
_err.print(f"tokenmeter: {exc}; try 'tokenmeter models'")
|
|
79
|
+
raise typer.Exit(EXIT_BAD_INPUT) from exc
|
|
80
|
+
except (OSError, EncoderError) as exc:
|
|
81
|
+
_err.print(f"tokenmeter: {exc}")
|
|
82
|
+
raise typer.Exit(EXIT_BAD_INPUT) from exc
|
|
83
|
+
|
|
84
|
+
if as_json:
|
|
85
|
+
_out.print_json(json.dumps(measurements_to_json(measurements)))
|
|
86
|
+
else:
|
|
87
|
+
_out.print(render_table(measurements))
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
@app.command("budget")
|
|
91
|
+
def budget(
|
|
92
|
+
paths: list[str] = typer.Argument(..., help="Files, directories, or - for stdin."),
|
|
93
|
+
max_cost: float = typer.Option(..., "--max-cost", help="Fail above this USD cost."),
|
|
94
|
+
model: str = typer.Option("gpt-4o", "--model", "-m", help="Model to price against."),
|
|
95
|
+
output_tokens: int = typer.Option(0, "--output-tokens", help="Assumed completion tokens."),
|
|
96
|
+
) -> None:
|
|
97
|
+
"""Fail when the estimated cost of the inputs exceeds a budget."""
|
|
98
|
+
|
|
99
|
+
try:
|
|
100
|
+
measurements = _collect(paths, model, output_tokens)
|
|
101
|
+
except UnknownModel as exc:
|
|
102
|
+
_err.print(f"tokenmeter: {exc}; try 'tokenmeter models'")
|
|
103
|
+
raise typer.Exit(EXIT_BAD_INPUT) from exc
|
|
104
|
+
except (OSError, EncoderError) as exc:
|
|
105
|
+
_err.print(f"tokenmeter: {exc}")
|
|
106
|
+
raise typer.Exit(EXIT_BAD_INPUT) from exc
|
|
107
|
+
|
|
108
|
+
cost = total_cost(measurements)
|
|
109
|
+
_err.print(f"tokenmeter: estimated ${cost:.6f} against a ${max_cost:.6f} budget")
|
|
110
|
+
if over_budget(measurements, max_cost):
|
|
111
|
+
raise typer.Exit(EXIT_OVER_BUDGET)
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
@app.command("models")
|
|
115
|
+
def models() -> None:
|
|
116
|
+
"""List the known models and their prices."""
|
|
117
|
+
|
|
118
|
+
title = f"prices as of {PRICES_AS_OF} (USD per 1M tokens)"
|
|
119
|
+
table = Table(box=None, pad_edge=False, title=title)
|
|
120
|
+
table.add_column("model")
|
|
121
|
+
table.add_column("encoding")
|
|
122
|
+
table.add_column("input", justify="right")
|
|
123
|
+
table.add_column("output", justify="right")
|
|
124
|
+
for name in known_models():
|
|
125
|
+
p = price_for(name)
|
|
126
|
+
table.add_row(p.model, p.encoding, f"${p.input_per_mtok:g}", f"${p.output_per_mtok:g}")
|
|
127
|
+
_out.print(table)
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def entrypoint() -> None:
|
|
131
|
+
try:
|
|
132
|
+
app()
|
|
133
|
+
except KeyboardInterrupt: # pragma: no cover - interactive only
|
|
134
|
+
print("tokenmeter: interrupted", file=sys.stderr)
|
|
135
|
+
raise SystemExit(130) from None
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
"""Token counting behind a small interface.
|
|
2
|
+
|
|
3
|
+
The real encoder uses ``tiktoken``, imported lazily so the package installs and
|
|
4
|
+
imports without it and so the test suite can run with a fake encoder and no
|
|
5
|
+
network access. Counting is therefore exact for the supported OpenAI encodings
|
|
6
|
+
at run time, and deterministic in tests.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from typing import Protocol
|
|
12
|
+
|
|
13
|
+
from tokenmeter.pricing import price_for
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class Encoder(Protocol):
|
|
17
|
+
def count(self, text: str) -> int: ...
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class EncoderError(RuntimeError):
|
|
21
|
+
"""Raised when an encoder cannot be constructed."""
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class TiktokenEncoder:
|
|
25
|
+
"""Count tokens with tiktoken for a given encoding name."""
|
|
26
|
+
|
|
27
|
+
def __init__(self, encoding: str) -> None:
|
|
28
|
+
self.encoding = encoding
|
|
29
|
+
self._enc = None
|
|
30
|
+
|
|
31
|
+
def _ensure(self):
|
|
32
|
+
if self._enc is not None:
|
|
33
|
+
return self._enc
|
|
34
|
+
try:
|
|
35
|
+
import tiktoken
|
|
36
|
+
except ImportError as exc: # pragma: no cover - import guard
|
|
37
|
+
raise EncoderError(
|
|
38
|
+
"tiktoken is not installed; install tokenmeter with its default "
|
|
39
|
+
"dependencies to count tokens"
|
|
40
|
+
) from exc
|
|
41
|
+
try:
|
|
42
|
+
self._enc = tiktoken.get_encoding(self.encoding)
|
|
43
|
+
except Exception as exc: # pragma: no cover - needs network on first use
|
|
44
|
+
raise EncoderError(f"could not load encoding {self.encoding!r}") from exc
|
|
45
|
+
return self._enc
|
|
46
|
+
|
|
47
|
+
def count(self, text: str) -> int:
|
|
48
|
+
return len(self._ensure().encode(text))
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def encoder_for_model(model: str) -> Encoder:
|
|
52
|
+
return TiktokenEncoder(price_for(model).encoding)
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
"""Gather text inputs from files, directories, or standard input."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import sys
|
|
6
|
+
from collections.abc import Iterable, Sequence
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
TEXT_SUFFIXES = {".txt", ".md", ".prompt", ".jinja", ".j2", ".tmpl"}
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def read_inputs(
|
|
13
|
+
paths: Sequence[str | Path],
|
|
14
|
+
*,
|
|
15
|
+
stdin_text: str | None = None,
|
|
16
|
+
) -> list[tuple[str, str]]:
|
|
17
|
+
"""Return ``(name, text)`` pairs for every requested input.
|
|
18
|
+
|
|
19
|
+
A path of ``-`` reads standard input. Directories are expanded to their
|
|
20
|
+
text-like files, sorted for stable output.
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
out: list[tuple[str, str]] = []
|
|
24
|
+
for raw in paths:
|
|
25
|
+
if str(raw) == "-":
|
|
26
|
+
text = stdin_text if stdin_text is not None else sys.stdin.read()
|
|
27
|
+
out.append(("<stdin>", text))
|
|
28
|
+
continue
|
|
29
|
+
path = Path(raw)
|
|
30
|
+
if path.is_dir():
|
|
31
|
+
for child in _text_files(path):
|
|
32
|
+
out.append((str(child), child.read_text(encoding="utf-8")))
|
|
33
|
+
else:
|
|
34
|
+
out.append((str(path), path.read_text(encoding="utf-8")))
|
|
35
|
+
return out
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _text_files(directory: Path) -> Iterable[Path]:
|
|
39
|
+
return sorted(
|
|
40
|
+
p for p in directory.rglob("*") if p.is_file() and p.suffix.lower() in TEXT_SUFFIXES
|
|
41
|
+
)
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
"""Combine token counts with prices into measurements and a budget gate."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
|
|
7
|
+
from tokenmeter.encoder import Encoder
|
|
8
|
+
from tokenmeter.pricing import input_cost, output_cost
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
@dataclass(frozen=True, slots=True)
|
|
12
|
+
class Measurement:
|
|
13
|
+
name: str
|
|
14
|
+
model: str
|
|
15
|
+
input_tokens: int
|
|
16
|
+
output_tokens: int
|
|
17
|
+
input_cost: float
|
|
18
|
+
output_cost: float
|
|
19
|
+
|
|
20
|
+
@property
|
|
21
|
+
def total_cost(self) -> float:
|
|
22
|
+
return self.input_cost + self.output_cost
|
|
23
|
+
|
|
24
|
+
@property
|
|
25
|
+
def total_tokens(self) -> int:
|
|
26
|
+
return self.input_tokens + self.output_tokens
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def measure(
|
|
30
|
+
encoder: Encoder,
|
|
31
|
+
model: str,
|
|
32
|
+
name: str,
|
|
33
|
+
text: str,
|
|
34
|
+
*,
|
|
35
|
+
output_tokens: int = 0,
|
|
36
|
+
) -> Measurement:
|
|
37
|
+
input_tokens = encoder.count(text)
|
|
38
|
+
return Measurement(
|
|
39
|
+
name=name,
|
|
40
|
+
model=model,
|
|
41
|
+
input_tokens=input_tokens,
|
|
42
|
+
output_tokens=output_tokens,
|
|
43
|
+
input_cost=input_cost(model, input_tokens),
|
|
44
|
+
output_cost=output_cost(model, output_tokens),
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def total_cost(measurements: list[Measurement]) -> float:
|
|
49
|
+
return sum(m.total_cost for m in measurements)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def total_tokens(measurements: list[Measurement]) -> int:
|
|
53
|
+
return sum(m.total_tokens for m in measurements)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def over_budget(measurements: list[Measurement], max_cost: float) -> bool:
|
|
57
|
+
return total_cost(measurements) > max_cost
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
"""Per-model token prices and the cost arithmetic on top of them.
|
|
2
|
+
|
|
3
|
+
Prices are expressed in US dollars per million tokens and carry an "as of"
|
|
4
|
+
date so a stale table is obvious. The numbers are easy to override or extend;
|
|
5
|
+
the cost functions are pure and do not care where the rates came from.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from dataclasses import dataclass
|
|
11
|
+
|
|
12
|
+
PRICES_AS_OF = "2025-08-01"
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@dataclass(frozen=True, slots=True)
|
|
16
|
+
class ModelPrice:
|
|
17
|
+
"""Input and output price in USD per million tokens."""
|
|
18
|
+
|
|
19
|
+
model: str
|
|
20
|
+
encoding: str
|
|
21
|
+
input_per_mtok: float
|
|
22
|
+
output_per_mtok: float
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
# A small, explicit table. Values are USD per 1,000,000 tokens.
|
|
26
|
+
_PRICES: dict[str, ModelPrice] = {
|
|
27
|
+
"gpt-4o": ModelPrice("gpt-4o", "o200k_base", 2.50, 10.00),
|
|
28
|
+
"gpt-4o-mini": ModelPrice("gpt-4o-mini", "o200k_base", 0.15, 0.60),
|
|
29
|
+
"gpt-4-turbo": ModelPrice("gpt-4-turbo", "cl100k_base", 10.00, 30.00),
|
|
30
|
+
"gpt-3.5-turbo": ModelPrice("gpt-3.5-turbo", "cl100k_base", 0.50, 1.50),
|
|
31
|
+
"text-embedding-3-small": ModelPrice("text-embedding-3-small", "cl100k_base", 0.02, 0.0),
|
|
32
|
+
"text-embedding-3-large": ModelPrice("text-embedding-3-large", "cl100k_base", 0.13, 0.0),
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class UnknownModel(KeyError):
|
|
37
|
+
"""Raised when a model has no entry in the price table."""
|
|
38
|
+
|
|
39
|
+
def __init__(self, model: str) -> None:
|
|
40
|
+
self.model = model
|
|
41
|
+
super().__init__(model)
|
|
42
|
+
|
|
43
|
+
def __str__(self) -> str:
|
|
44
|
+
return f"unknown model: {self.model}"
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def known_models() -> list[str]:
|
|
48
|
+
return sorted(_PRICES)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def price_for(model: str) -> ModelPrice:
|
|
52
|
+
try:
|
|
53
|
+
return _PRICES[model]
|
|
54
|
+
except KeyError as exc:
|
|
55
|
+
raise UnknownModel(model) from exc
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def input_cost(model: str, tokens: int) -> float:
|
|
59
|
+
return price_for(model).input_per_mtok * tokens / 1_000_000
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def output_cost(model: str, tokens: int) -> float:
|
|
63
|
+
return price_for(model).output_per_mtok * tokens / 1_000_000
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def total_cost(model: str, input_tokens: int, output_tokens: int = 0) -> float:
|
|
67
|
+
return input_cost(model, input_tokens) + output_cost(model, output_tokens)
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
"""Render measurements for the terminal and as JSON."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from rich.console import Group
|
|
6
|
+
from rich.table import Table
|
|
7
|
+
|
|
8
|
+
from tokenmeter.meter import Measurement, total_cost, total_tokens
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def measurements_to_json(measurements: list[Measurement]) -> dict:
|
|
12
|
+
return {
|
|
13
|
+
"inputs": [
|
|
14
|
+
{
|
|
15
|
+
"name": m.name,
|
|
16
|
+
"model": m.model,
|
|
17
|
+
"input_tokens": m.input_tokens,
|
|
18
|
+
"output_tokens": m.output_tokens,
|
|
19
|
+
"input_cost": round(m.input_cost, 6),
|
|
20
|
+
"output_cost": round(m.output_cost, 6),
|
|
21
|
+
"total_cost": round(m.total_cost, 6),
|
|
22
|
+
}
|
|
23
|
+
for m in measurements
|
|
24
|
+
],
|
|
25
|
+
"total_tokens": total_tokens(measurements),
|
|
26
|
+
"total_cost": round(total_cost(measurements), 6),
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def render_table(measurements: list[Measurement]) -> Group:
|
|
31
|
+
table = Table(box=None, pad_edge=False)
|
|
32
|
+
table.add_column("input")
|
|
33
|
+
table.add_column("in tok", justify="right")
|
|
34
|
+
table.add_column("out tok", justify="right")
|
|
35
|
+
table.add_column("cost (USD)", justify="right")
|
|
36
|
+
for m in measurements:
|
|
37
|
+
table.add_row(
|
|
38
|
+
m.name,
|
|
39
|
+
f"{m.input_tokens}",
|
|
40
|
+
f"{m.output_tokens}",
|
|
41
|
+
f"${m.total_cost:.6f}",
|
|
42
|
+
)
|
|
43
|
+
if len(measurements) != 1:
|
|
44
|
+
table.add_section()
|
|
45
|
+
table.add_row(
|
|
46
|
+
"total",
|
|
47
|
+
f"{sum(m.input_tokens for m in measurements)}",
|
|
48
|
+
f"{sum(m.output_tokens for m in measurements)}",
|
|
49
|
+
f"${total_cost(measurements):.6f}",
|
|
50
|
+
)
|
|
51
|
+
return Group(table)
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import pytest
|
|
4
|
+
|
|
5
|
+
from tokenmeter.encoder import Encoder
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class WordEncoder(Encoder):
|
|
9
|
+
"""A deterministic fake encoder: one token per whitespace-separated word.
|
|
10
|
+
|
|
11
|
+
Keeps tests free of tiktoken and network access while exercising all the
|
|
12
|
+
counting, pricing and budget logic.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
def count(self, text: str) -> int:
|
|
16
|
+
return len(text.split())
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@pytest.fixture
|
|
20
|
+
def encoder():
|
|
21
|
+
return WordEncoder()
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
|
|
5
|
+
import pytest
|
|
6
|
+
from tests.conftest import WordEncoder
|
|
7
|
+
from typer.testing import CliRunner
|
|
8
|
+
|
|
9
|
+
from tokenmeter import __version__
|
|
10
|
+
from tokenmeter import cli as cli_module
|
|
11
|
+
from tokenmeter import encoder as encoder_module
|
|
12
|
+
|
|
13
|
+
runner = CliRunner()
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@pytest.fixture(autouse=True)
|
|
17
|
+
def patch_encoder(monkeypatch):
|
|
18
|
+
monkeypatch.setattr(cli_module, "encoder_for_model", lambda model: WordEncoder())
|
|
19
|
+
monkeypatch.setattr(encoder_module, "encoder_for_model", lambda model: WordEncoder())
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def test_version():
|
|
23
|
+
result = runner.invoke(cli_module.app, ["--version"])
|
|
24
|
+
assert result.exit_code == 0
|
|
25
|
+
assert __version__ in result.stdout
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def test_count_file_json(tmp_path):
|
|
29
|
+
f = tmp_path / "p.txt"
|
|
30
|
+
f.write_text("one two three four")
|
|
31
|
+
result = runner.invoke(cli_module.app, ["count", str(f), "--json"])
|
|
32
|
+
assert result.exit_code == 0
|
|
33
|
+
payload = json.loads(result.stdout)
|
|
34
|
+
assert payload["total_tokens"] == 4
|
|
35
|
+
assert payload["inputs"][0]["input_tokens"] == 4
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def test_count_unknown_model_is_bad_input(tmp_path):
|
|
39
|
+
f = tmp_path / "p.txt"
|
|
40
|
+
f.write_text("hello")
|
|
41
|
+
result = runner.invoke(cli_module.app, ["count", str(f), "--model", "nope"])
|
|
42
|
+
assert result.exit_code == cli_module.EXIT_BAD_INPUT
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def test_budget_passes_under_limit(tmp_path):
|
|
46
|
+
f = tmp_path / "p.txt"
|
|
47
|
+
f.write_text("one two three")
|
|
48
|
+
result = runner.invoke(cli_module.app, ["budget", str(f), "--max-cost", "1.0"])
|
|
49
|
+
assert result.exit_code == 0
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def test_budget_fails_over_limit(tmp_path):
|
|
53
|
+
f = tmp_path / "p.txt"
|
|
54
|
+
f.write_text("word " * 100000)
|
|
55
|
+
result = runner.invoke(cli_module.app, ["budget", str(f), "--max-cost", "0.0001"])
|
|
56
|
+
assert result.exit_code == cli_module.EXIT_OVER_BUDGET
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def test_models_lists_prices():
|
|
60
|
+
result = runner.invoke(cli_module.app, ["models"])
|
|
61
|
+
assert result.exit_code == 0
|
|
62
|
+
assert "gpt-4o" in result.stdout
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from tokenmeter.inputs import read_inputs
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def test_read_single_file(tmp_path):
|
|
7
|
+
f = tmp_path / "p.txt"
|
|
8
|
+
f.write_text("hello world")
|
|
9
|
+
assert read_inputs([f]) == [(str(f), "hello world")]
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def test_read_stdin_marker():
|
|
13
|
+
assert read_inputs(["-"], stdin_text="piped text") == [("<stdin>", "piped text")]
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def test_read_directory_expands_text_files(tmp_path):
|
|
17
|
+
(tmp_path / "a.txt").write_text("a")
|
|
18
|
+
(tmp_path / "b.md").write_text("b")
|
|
19
|
+
(tmp_path / "skip.bin").write_text("nope")
|
|
20
|
+
sub = tmp_path / "sub"
|
|
21
|
+
sub.mkdir()
|
|
22
|
+
(sub / "c.prompt").write_text("c")
|
|
23
|
+
|
|
24
|
+
results = read_inputs([tmp_path])
|
|
25
|
+
names = [name for name, _ in results]
|
|
26
|
+
assert any(n.endswith("a.txt") for n in names)
|
|
27
|
+
assert any(n.endswith("b.md") for n in names)
|
|
28
|
+
assert any(n.endswith("c.prompt") for n in names)
|
|
29
|
+
assert not any(n.endswith("skip.bin") for n in names)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def test_directory_results_are_sorted(tmp_path):
|
|
33
|
+
for name in ["z.txt", "a.txt", "m.txt"]:
|
|
34
|
+
(tmp_path / name).write_text(name)
|
|
35
|
+
names = [n for n, _ in read_inputs([tmp_path])]
|
|
36
|
+
assert names == sorted(names)
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import pytest
|
|
4
|
+
|
|
5
|
+
from tokenmeter.meter import measure, over_budget, total_cost, total_tokens
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def test_measure_counts_input_tokens(encoder):
|
|
9
|
+
m = measure(encoder, "gpt-4o", "p", "one two three four five")
|
|
10
|
+
assert m.input_tokens == 5
|
|
11
|
+
assert m.output_tokens == 0
|
|
12
|
+
assert m.input_cost == pytest.approx(2.50 * 5 / 1_000_000)
|
|
13
|
+
assert m.total_cost == m.input_cost
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def test_measure_includes_output_tokens_in_cost(encoder):
|
|
17
|
+
m = measure(encoder, "gpt-4o", "p", "one two", output_tokens=1_000)
|
|
18
|
+
assert m.output_tokens == 1_000
|
|
19
|
+
assert m.output_cost == pytest.approx(10.00 * 1_000 / 1_000_000)
|
|
20
|
+
assert m.total_tokens == 1_002
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def test_totals_across_measurements(encoder):
|
|
24
|
+
ms = [
|
|
25
|
+
measure(encoder, "gpt-4o", "a", "one two three"),
|
|
26
|
+
measure(encoder, "gpt-4o", "b", "four five"),
|
|
27
|
+
]
|
|
28
|
+
assert total_tokens(ms) == 5
|
|
29
|
+
assert total_cost(ms) == pytest.approx(2.50 * 5 / 1_000_000)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def test_over_budget(encoder):
|
|
33
|
+
ms = [measure(encoder, "gpt-4o", "a", "word " * 1_000_000)]
|
|
34
|
+
assert over_budget(ms, max_cost=1.0) is True
|
|
35
|
+
assert over_budget(ms, max_cost=100.0) is False
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import pytest
|
|
4
|
+
|
|
5
|
+
from tokenmeter.pricing import (
|
|
6
|
+
UnknownModel,
|
|
7
|
+
input_cost,
|
|
8
|
+
known_models,
|
|
9
|
+
output_cost,
|
|
10
|
+
price_for,
|
|
11
|
+
total_cost,
|
|
12
|
+
)
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def test_known_models_sorted_and_nonempty():
|
|
16
|
+
models = known_models()
|
|
17
|
+
assert models == sorted(models)
|
|
18
|
+
assert "gpt-4o" in models
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def test_price_for_unknown_model_raises():
|
|
22
|
+
with pytest.raises(UnknownModel) as info:
|
|
23
|
+
price_for("does-not-exist")
|
|
24
|
+
assert "does-not-exist" in str(info.value)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def test_input_cost_scales_with_tokens():
|
|
28
|
+
# gpt-4o input is $2.50 per million tokens.
|
|
29
|
+
assert input_cost("gpt-4o", 1_000_000) == pytest.approx(2.50)
|
|
30
|
+
assert input_cost("gpt-4o", 500_000) == pytest.approx(1.25)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def test_output_cost_uses_output_rate():
|
|
34
|
+
assert output_cost("gpt-4o", 1_000_000) == pytest.approx(10.00)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def test_total_cost_adds_input_and_output():
|
|
38
|
+
cost = total_cost("gpt-4o", 1_000_000, 1_000_000)
|
|
39
|
+
assert cost == pytest.approx(12.50)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def test_embeddings_have_no_output_cost():
|
|
43
|
+
assert output_cost("text-embedding-3-small", 1_000_000) == 0.0
|