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.
@@ -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
+ [![CI](https://github.com/jmweb-org/tokenmeter/actions/workflows/ci.yml/badge.svg)](https://github.com/jmweb-org/tokenmeter/actions/workflows/ci.yml)
49
+ [![PyPI](https://img.shields.io/pypi/v/tokenmeter-cli.svg)](https://pypi.org/project/tokenmeter-cli/)
50
+ [![Python](https://img.shields.io/badge/python-3.10%2B-blue.svg)](https://www.python.org)
51
+ [![License: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](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
+ [![CI](https://github.com/jmweb-org/tokenmeter/actions/workflows/ci.yml/badge.svg)](https://github.com/jmweb-org/tokenmeter/actions/workflows/ci.yml)
4
+ [![PyPI](https://img.shields.io/pypi/v/tokenmeter-cli.svg)](https://pypi.org/project/tokenmeter-cli/)
5
+ [![Python](https://img.shields.io/badge/python-3.10%2B-blue.svg)](https://www.python.org)
6
+ [![License: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](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,4 @@
1
+ from tokenmeter.cli import entrypoint
2
+
3
+ if __name__ == "__main__":
4
+ entrypoint()
@@ -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