tokenmeter-cli 0.2.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- tokenmeter/__init__.py +19 -0
- tokenmeter/__main__.py +4 -0
- tokenmeter/cli.py +135 -0
- tokenmeter/encoder.py +52 -0
- tokenmeter/inputs.py +41 -0
- tokenmeter/meter.py +57 -0
- tokenmeter/pricing.py +67 -0
- tokenmeter/render.py +51 -0
- tokenmeter_cli-0.2.0.dist-info/METADATA +118 -0
- tokenmeter_cli-0.2.0.dist-info/RECORD +13 -0
- tokenmeter_cli-0.2.0.dist-info/WHEEL +4 -0
- tokenmeter_cli-0.2.0.dist-info/entry_points.txt +2 -0
- tokenmeter_cli-0.2.0.dist-info/licenses/LICENSE +21 -0
tokenmeter/__init__.py
ADDED
|
@@ -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
|
+
]
|
tokenmeter/__main__.py
ADDED
tokenmeter/cli.py
ADDED
|
@@ -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
|
tokenmeter/encoder.py
ADDED
|
@@ -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)
|
tokenmeter/inputs.py
ADDED
|
@@ -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
|
+
)
|
tokenmeter/meter.py
ADDED
|
@@ -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
|
tokenmeter/pricing.py
ADDED
|
@@ -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)
|
tokenmeter/render.py
ADDED
|
@@ -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,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,13 @@
|
|
|
1
|
+
tokenmeter/__init__.py,sha256=ldifpdHQhVMiQ8V9qi91UJkjDto7XYyhhR7L9I7XxM0,470
|
|
2
|
+
tokenmeter/__main__.py,sha256=5uZgM542ygj2c6D6uKvQmqmQzo7uvG3qu5cLMb_zc68,83
|
|
3
|
+
tokenmeter/cli.py,sha256=4PDR9KLvZprG2P4aSSC48iZiZKZcoNf822_SJlj3F8Q,4234
|
|
4
|
+
tokenmeter/encoder.py,sha256=1CPu9ZHLQAaL8-3-wAOsSAOvteD8TzEl0_oeq3zCGkU,1628
|
|
5
|
+
tokenmeter/inputs.py,sha256=mTH9LgUqqxP5Wvl87-P6lGyG35F09C3ygg-12NEJ4Bg,1247
|
|
6
|
+
tokenmeter/meter.py,sha256=F7t7Ed64hMmTPckiEASYOGB5J5OHBVs2B3k8VLoxx7Y,1394
|
|
7
|
+
tokenmeter/pricing.py,sha256=H4ovbPi1gzlDMTbBkWxOSCtnDAgGpP1Q3ftchOo625E,2062
|
|
8
|
+
tokenmeter/render.py,sha256=a3Xx9HGeD_8-lP-HeRcE0iwUiRa8d-t15lTMWLjC-fk,1614
|
|
9
|
+
tokenmeter_cli-0.2.0.dist-info/METADATA,sha256=v3NRDEch9Cl66SATXhhLXIYoqp8sIVXpJRkcM0bHG0I,4733
|
|
10
|
+
tokenmeter_cli-0.2.0.dist-info/WHEEL,sha256=mffPy8wBnZQn2VnJUU5jE99KsxaSfiyMHV9Yt0aLVxs,87
|
|
11
|
+
tokenmeter_cli-0.2.0.dist-info/entry_points.txt,sha256=6NQkcMw35tZQW5WpX-MSx-PYG2WIyLwJJkIPFqqpPq8,57
|
|
12
|
+
tokenmeter_cli-0.2.0.dist-info/licenses/LICENSE,sha256=N4nJy_wSxYwULjDvuE2GupQWZSSwgOOU_HJSzuxHBsI,1071
|
|
13
|
+
tokenmeter_cli-0.2.0.dist-info/RECORD,,
|
|
@@ -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.
|