aitc 1.0.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.
- aitc-1.0.0/PKG-INFO +119 -0
- aitc-1.0.0/README.md +95 -0
- aitc-1.0.0/aitc.egg-info/PKG-INFO +119 -0
- aitc-1.0.0/aitc.egg-info/SOURCES.txt +9 -0
- aitc-1.0.0/aitc.egg-info/dependency_links.txt +1 -0
- aitc-1.0.0/aitc.egg-info/entry_points.txt +2 -0
- aitc-1.0.0/aitc.egg-info/requires.txt +2 -0
- aitc-1.0.0/aitc.egg-info/top_level.txt +1 -0
- aitc-1.0.0/pyproject.toml +83 -0
- aitc-1.0.0/setup.cfg +4 -0
- aitc-1.0.0/tokencount.py +382 -0
aitc-1.0.0/PKG-INFO
ADDED
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: aitc
|
|
3
|
+
Version: 1.0.0
|
|
4
|
+
Summary: Add your description here
|
|
5
|
+
Author-email: Arian Omrani <arian24b@gmail.com>
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/arian24b/aitc
|
|
8
|
+
Project-URL: Repository, https://github.com/arian24b/aitc
|
|
9
|
+
Project-URL: Issues, https://github.com/arian24b/aitc/issues
|
|
10
|
+
Keywords: throne,cli,installer,hotspot,proxy
|
|
11
|
+
Classifier: Development Status :: 5 - Production/Stable
|
|
12
|
+
Classifier: Intended Audience :: Developers
|
|
13
|
+
Classifier: Operating System :: OS Independent
|
|
14
|
+
Classifier: Programming Language :: Python :: 3
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
20
|
+
Requires-Python: >=3.10
|
|
21
|
+
Description-Content-Type: text/markdown
|
|
22
|
+
Requires-Dist: requests>=2.34.2
|
|
23
|
+
Requires-Dist: tiktoken>=0.13.0
|
|
24
|
+
|
|
25
|
+
# aitc
|
|
26
|
+
|
|
27
|
+
**AITC** — AI Token Counter. A CLI tool for counting tokens in text/files and estimating API costs across popular LLM models. Supports a diff mode comparing two files.
|
|
28
|
+
|
|
29
|
+
## Features
|
|
30
|
+
|
|
31
|
+
- Count tokens in inline text, files, or piped stdin
|
|
32
|
+
- Estimate API costs for GPT-4o, GPT-4o mini, Claude Sonnet 4.5, Claude Haiku 4.5, Gemini 1.5 Pro, and Gemini 1.5 Flash
|
|
33
|
+
- Live pricing via OpenRouter API (`--live-prices`)
|
|
34
|
+
- Diff mode to compare token counts between two files (`--diff`)
|
|
35
|
+
|
|
36
|
+
## Installation
|
|
37
|
+
|
|
38
|
+
### Via pip
|
|
39
|
+
|
|
40
|
+
```bash
|
|
41
|
+
pip install aitc
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
### Via uv
|
|
45
|
+
|
|
46
|
+
```bash
|
|
47
|
+
uv tool install aitc
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
### From source
|
|
51
|
+
|
|
52
|
+
```bash
|
|
53
|
+
# Clone the repository
|
|
54
|
+
git clone https://github.com/arian24b/aitc.git
|
|
55
|
+
cd aitc
|
|
56
|
+
|
|
57
|
+
# Sync dependencies with uv
|
|
58
|
+
uv sync
|
|
59
|
+
|
|
60
|
+
# Run directly
|
|
61
|
+
uv run aitc --text "Hello, world!"
|
|
62
|
+
|
|
63
|
+
# Or install in development mode
|
|
64
|
+
uv tool install -e .
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
## Usage
|
|
68
|
+
|
|
69
|
+
### Count tokens from inline text
|
|
70
|
+
|
|
71
|
+
```bash
|
|
72
|
+
aitc --text "Your text here"
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
### Count tokens from a file
|
|
76
|
+
|
|
77
|
+
```bash
|
|
78
|
+
aitc --file README.md
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
### Pipe text via stdin
|
|
82
|
+
|
|
83
|
+
```bash
|
|
84
|
+
echo "Your text here" | aitc
|
|
85
|
+
cat somefile.txt | aitc
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
### Compare two files (diff mode)
|
|
89
|
+
|
|
90
|
+
```bash
|
|
91
|
+
aitc --diff file1.txt file2.txt
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
### Fetch live pricing
|
|
95
|
+
|
|
96
|
+
```bash
|
|
97
|
+
aitc --text "Hello" --live-prices
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
## Development
|
|
101
|
+
|
|
102
|
+
```bash
|
|
103
|
+
# Clone
|
|
104
|
+
git clone https://github.com/arian24b/aitc.git
|
|
105
|
+
cd aitc
|
|
106
|
+
|
|
107
|
+
# Set up environment with uv
|
|
108
|
+
uv sync
|
|
109
|
+
|
|
110
|
+
# Run linting
|
|
111
|
+
uv run ruff check .
|
|
112
|
+
|
|
113
|
+
# Build
|
|
114
|
+
uv build
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
## License
|
|
118
|
+
|
|
119
|
+
MIT
|
aitc-1.0.0/README.md
ADDED
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
# aitc
|
|
2
|
+
|
|
3
|
+
**AITC** — AI Token Counter. A CLI tool for counting tokens in text/files and estimating API costs across popular LLM models. Supports a diff mode comparing two files.
|
|
4
|
+
|
|
5
|
+
## Features
|
|
6
|
+
|
|
7
|
+
- Count tokens in inline text, files, or piped stdin
|
|
8
|
+
- Estimate API costs for GPT-4o, GPT-4o mini, Claude Sonnet 4.5, Claude Haiku 4.5, Gemini 1.5 Pro, and Gemini 1.5 Flash
|
|
9
|
+
- Live pricing via OpenRouter API (`--live-prices`)
|
|
10
|
+
- Diff mode to compare token counts between two files (`--diff`)
|
|
11
|
+
|
|
12
|
+
## Installation
|
|
13
|
+
|
|
14
|
+
### Via pip
|
|
15
|
+
|
|
16
|
+
```bash
|
|
17
|
+
pip install aitc
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
### Via uv
|
|
21
|
+
|
|
22
|
+
```bash
|
|
23
|
+
uv tool install aitc
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
### From source
|
|
27
|
+
|
|
28
|
+
```bash
|
|
29
|
+
# Clone the repository
|
|
30
|
+
git clone https://github.com/arian24b/aitc.git
|
|
31
|
+
cd aitc
|
|
32
|
+
|
|
33
|
+
# Sync dependencies with uv
|
|
34
|
+
uv sync
|
|
35
|
+
|
|
36
|
+
# Run directly
|
|
37
|
+
uv run aitc --text "Hello, world!"
|
|
38
|
+
|
|
39
|
+
# Or install in development mode
|
|
40
|
+
uv tool install -e .
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
## Usage
|
|
44
|
+
|
|
45
|
+
### Count tokens from inline text
|
|
46
|
+
|
|
47
|
+
```bash
|
|
48
|
+
aitc --text "Your text here"
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
### Count tokens from a file
|
|
52
|
+
|
|
53
|
+
```bash
|
|
54
|
+
aitc --file README.md
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
### Pipe text via stdin
|
|
58
|
+
|
|
59
|
+
```bash
|
|
60
|
+
echo "Your text here" | aitc
|
|
61
|
+
cat somefile.txt | aitc
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
### Compare two files (diff mode)
|
|
65
|
+
|
|
66
|
+
```bash
|
|
67
|
+
aitc --diff file1.txt file2.txt
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
### Fetch live pricing
|
|
71
|
+
|
|
72
|
+
```bash
|
|
73
|
+
aitc --text "Hello" --live-prices
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
## Development
|
|
77
|
+
|
|
78
|
+
```bash
|
|
79
|
+
# Clone
|
|
80
|
+
git clone https://github.com/arian24b/aitc.git
|
|
81
|
+
cd aitc
|
|
82
|
+
|
|
83
|
+
# Set up environment with uv
|
|
84
|
+
uv sync
|
|
85
|
+
|
|
86
|
+
# Run linting
|
|
87
|
+
uv run ruff check .
|
|
88
|
+
|
|
89
|
+
# Build
|
|
90
|
+
uv build
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
## License
|
|
94
|
+
|
|
95
|
+
MIT
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: aitc
|
|
3
|
+
Version: 1.0.0
|
|
4
|
+
Summary: Add your description here
|
|
5
|
+
Author-email: Arian Omrani <arian24b@gmail.com>
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/arian24b/aitc
|
|
8
|
+
Project-URL: Repository, https://github.com/arian24b/aitc
|
|
9
|
+
Project-URL: Issues, https://github.com/arian24b/aitc/issues
|
|
10
|
+
Keywords: throne,cli,installer,hotspot,proxy
|
|
11
|
+
Classifier: Development Status :: 5 - Production/Stable
|
|
12
|
+
Classifier: Intended Audience :: Developers
|
|
13
|
+
Classifier: Operating System :: OS Independent
|
|
14
|
+
Classifier: Programming Language :: Python :: 3
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
20
|
+
Requires-Python: >=3.10
|
|
21
|
+
Description-Content-Type: text/markdown
|
|
22
|
+
Requires-Dist: requests>=2.34.2
|
|
23
|
+
Requires-Dist: tiktoken>=0.13.0
|
|
24
|
+
|
|
25
|
+
# aitc
|
|
26
|
+
|
|
27
|
+
**AITC** — AI Token Counter. A CLI tool for counting tokens in text/files and estimating API costs across popular LLM models. Supports a diff mode comparing two files.
|
|
28
|
+
|
|
29
|
+
## Features
|
|
30
|
+
|
|
31
|
+
- Count tokens in inline text, files, or piped stdin
|
|
32
|
+
- Estimate API costs for GPT-4o, GPT-4o mini, Claude Sonnet 4.5, Claude Haiku 4.5, Gemini 1.5 Pro, and Gemini 1.5 Flash
|
|
33
|
+
- Live pricing via OpenRouter API (`--live-prices`)
|
|
34
|
+
- Diff mode to compare token counts between two files (`--diff`)
|
|
35
|
+
|
|
36
|
+
## Installation
|
|
37
|
+
|
|
38
|
+
### Via pip
|
|
39
|
+
|
|
40
|
+
```bash
|
|
41
|
+
pip install aitc
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
### Via uv
|
|
45
|
+
|
|
46
|
+
```bash
|
|
47
|
+
uv tool install aitc
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
### From source
|
|
51
|
+
|
|
52
|
+
```bash
|
|
53
|
+
# Clone the repository
|
|
54
|
+
git clone https://github.com/arian24b/aitc.git
|
|
55
|
+
cd aitc
|
|
56
|
+
|
|
57
|
+
# Sync dependencies with uv
|
|
58
|
+
uv sync
|
|
59
|
+
|
|
60
|
+
# Run directly
|
|
61
|
+
uv run aitc --text "Hello, world!"
|
|
62
|
+
|
|
63
|
+
# Or install in development mode
|
|
64
|
+
uv tool install -e .
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
## Usage
|
|
68
|
+
|
|
69
|
+
### Count tokens from inline text
|
|
70
|
+
|
|
71
|
+
```bash
|
|
72
|
+
aitc --text "Your text here"
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
### Count tokens from a file
|
|
76
|
+
|
|
77
|
+
```bash
|
|
78
|
+
aitc --file README.md
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
### Pipe text via stdin
|
|
82
|
+
|
|
83
|
+
```bash
|
|
84
|
+
echo "Your text here" | aitc
|
|
85
|
+
cat somefile.txt | aitc
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
### Compare two files (diff mode)
|
|
89
|
+
|
|
90
|
+
```bash
|
|
91
|
+
aitc --diff file1.txt file2.txt
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
### Fetch live pricing
|
|
95
|
+
|
|
96
|
+
```bash
|
|
97
|
+
aitc --text "Hello" --live-prices
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
## Development
|
|
101
|
+
|
|
102
|
+
```bash
|
|
103
|
+
# Clone
|
|
104
|
+
git clone https://github.com/arian24b/aitc.git
|
|
105
|
+
cd aitc
|
|
106
|
+
|
|
107
|
+
# Set up environment with uv
|
|
108
|
+
uv sync
|
|
109
|
+
|
|
110
|
+
# Run linting
|
|
111
|
+
uv run ruff check .
|
|
112
|
+
|
|
113
|
+
# Build
|
|
114
|
+
uv build
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
## License
|
|
118
|
+
|
|
119
|
+
MIT
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
tokencount
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "aitc"
|
|
3
|
+
version = "1.0.0"
|
|
4
|
+
description = "Add your description here"
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
requires-python = ">=3.10"
|
|
7
|
+
authors = [
|
|
8
|
+
{name = "Arian Omrani", email = "arian24b@gmail.com"}
|
|
9
|
+
]
|
|
10
|
+
license = "MIT"
|
|
11
|
+
classifiers = [
|
|
12
|
+
"Development Status :: 5 - Production/Stable",
|
|
13
|
+
"Intended Audience :: Developers",
|
|
14
|
+
"Operating System :: OS Independent",
|
|
15
|
+
"Programming Language :: Python :: 3",
|
|
16
|
+
"Programming Language :: Python :: 3.10",
|
|
17
|
+
"Programming Language :: Python :: 3.11",
|
|
18
|
+
"Programming Language :: Python :: 3.12",
|
|
19
|
+
"Programming Language :: Python :: 3.13",
|
|
20
|
+
"Programming Language :: Python :: 3.14",
|
|
21
|
+
]
|
|
22
|
+
keywords = ["throne", "cli", "installer", "hotspot", "proxy"]
|
|
23
|
+
dependencies = [
|
|
24
|
+
"requests>=2.34.2",
|
|
25
|
+
"tiktoken>=0.13.0",
|
|
26
|
+
]
|
|
27
|
+
|
|
28
|
+
[project.scripts]
|
|
29
|
+
aitc = "tokencount:main"
|
|
30
|
+
|
|
31
|
+
[project.urls]
|
|
32
|
+
Homepage = "https://github.com/arian24b/aitc"
|
|
33
|
+
Repository = "https://github.com/arian24b/aitc"
|
|
34
|
+
Issues = "https://github.com/arian24b/aitc/issues"
|
|
35
|
+
|
|
36
|
+
[build-system]
|
|
37
|
+
requires = ["setuptools"]
|
|
38
|
+
build-backend = "setuptools.build_meta"
|
|
39
|
+
|
|
40
|
+
[tool.semantic_release]
|
|
41
|
+
commit_parser = "conventional"
|
|
42
|
+
commit_author = "Arian Omrani <arian24b@gmail.com>"
|
|
43
|
+
branch = "main"
|
|
44
|
+
version_toml = ["pyproject.toml:project.version"]
|
|
45
|
+
upload_to_vcs_release = false
|
|
46
|
+
build_command = "uv build"
|
|
47
|
+
|
|
48
|
+
[tool.ruff]
|
|
49
|
+
line-length = 120
|
|
50
|
+
indent-width = 4
|
|
51
|
+
respect-gitignore = true
|
|
52
|
+
fix = true
|
|
53
|
+
show-fixes = true
|
|
54
|
+
|
|
55
|
+
[tool.ruff.format]
|
|
56
|
+
docstring-code-format = true
|
|
57
|
+
docstring-code-line-length = "dynamic"
|
|
58
|
+
|
|
59
|
+
[tool.ruff.lint]
|
|
60
|
+
select = [
|
|
61
|
+
"E", # pycodestyle errors
|
|
62
|
+
"W", # pycodestyle warnings
|
|
63
|
+
"F", # Pyflakes
|
|
64
|
+
"I", # isort
|
|
65
|
+
"N", # pep8-naming
|
|
66
|
+
"UP", # pyupgrade
|
|
67
|
+
"S", # bandit
|
|
68
|
+
"B", # flake8-bugbear
|
|
69
|
+
"A", # flake8-builtins
|
|
70
|
+
"C4", # flake8-comprehensions
|
|
71
|
+
"DTZ", # flake8-datetimez
|
|
72
|
+
"T20", # flake8-print
|
|
73
|
+
"SIM", # flake8-simplify
|
|
74
|
+
"LOG", # flake8-logging
|
|
75
|
+
"RUF", # Ruff-specific rules
|
|
76
|
+
]
|
|
77
|
+
ignore = [
|
|
78
|
+
"S101", # Use of assert detected (needed for tests)
|
|
79
|
+
"T20", # print statements (CLI tool)
|
|
80
|
+
"A003", # Class attribute shadows builtin
|
|
81
|
+
"B008", # Function calls in arg defaults (Typer/Click idiom)
|
|
82
|
+
"SIM115", # Open without context manager (intentional in follow_log)
|
|
83
|
+
]
|
aitc-1.0.0/setup.cfg
ADDED
aitc-1.0.0/tokencount.py
ADDED
|
@@ -0,0 +1,382 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Token counting and cost estimation CLI tool.
|
|
3
|
+
|
|
4
|
+
Counts tokens in text/files and estimates API cost across popular LLM models.
|
|
5
|
+
Supports a diff mode comparing two files.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import argparse
|
|
9
|
+
import os
|
|
10
|
+
import sys
|
|
11
|
+
from typing import Any
|
|
12
|
+
|
|
13
|
+
import requests
|
|
14
|
+
import tiktoken
|
|
15
|
+
|
|
16
|
+
# ---------------------------------------------------------------------------
|
|
17
|
+
# Model definitions
|
|
18
|
+
# ---------------------------------------------------------------------------
|
|
19
|
+
|
|
20
|
+
MODELS: list[dict[str, Any]] = [
|
|
21
|
+
{
|
|
22
|
+
"display_name": "GPT-4o",
|
|
23
|
+
"openrouter_id": "openai/gpt-4o",
|
|
24
|
+
"hardcoded_price_per_1m": 2.50,
|
|
25
|
+
},
|
|
26
|
+
{
|
|
27
|
+
"display_name": "GPT-4o mini",
|
|
28
|
+
"openrouter_id": "openai/gpt-4o-mini",
|
|
29
|
+
"hardcoded_price_per_1m": 0.15,
|
|
30
|
+
},
|
|
31
|
+
{
|
|
32
|
+
"display_name": "Claude Sonnet 4.5",
|
|
33
|
+
"openrouter_id": "anthropic/claude-sonnet-4-5",
|
|
34
|
+
"hardcoded_price_per_1m": 3.00,
|
|
35
|
+
},
|
|
36
|
+
{
|
|
37
|
+
"display_name": "Claude Haiku 4.5",
|
|
38
|
+
"openrouter_id": "anthropic/claude-haiku-4-5",
|
|
39
|
+
"hardcoded_price_per_1m": 0.80,
|
|
40
|
+
},
|
|
41
|
+
{
|
|
42
|
+
"display_name": "Gemini 1.5 Pro",
|
|
43
|
+
"openrouter_id": "google/gemini-pro-1.5",
|
|
44
|
+
"hardcoded_price_per_1m": 1.25,
|
|
45
|
+
},
|
|
46
|
+
{
|
|
47
|
+
"display_name": "Gemini 1.5 Flash",
|
|
48
|
+
"openrouter_id": "google/gemini-flash-1.5",
|
|
49
|
+
"hardcoded_price_per_1m": 0.075,
|
|
50
|
+
},
|
|
51
|
+
]
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
# ---------------------------------------------------------------------------
|
|
55
|
+
# Tokenizer
|
|
56
|
+
# ---------------------------------------------------------------------------
|
|
57
|
+
|
|
58
|
+
_ENCODING = "cl100k_base"
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def count_tokens(text: str) -> int:
|
|
62
|
+
"""Count the number of tokens in a text string using cl100k_base encoding.
|
|
63
|
+
|
|
64
|
+
Args:
|
|
65
|
+
text: The input text to tokenize.
|
|
66
|
+
|
|
67
|
+
Returns:
|
|
68
|
+
The number of tokens in the text.
|
|
69
|
+
"""
|
|
70
|
+
encoder = tiktoken.get_encoding(_ENCODING)
|
|
71
|
+
return len(encoder.encode(text))
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
# ---------------------------------------------------------------------------
|
|
75
|
+
# Pricing
|
|
76
|
+
# ---------------------------------------------------------------------------
|
|
77
|
+
|
|
78
|
+
_OPENROUTER_URL = "https://openrouter.ai/api/v1/models"
|
|
79
|
+
_LIVE_TIMEOUT_SECONDS = 3
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def fetch_live_prices() -> dict[str, float] | None:
|
|
83
|
+
"""Fetch live model pricing from the OpenRouter API.
|
|
84
|
+
|
|
85
|
+
Returns:
|
|
86
|
+
A dict mapping openrouter_id -> price_per_1m_tokens, or None if the
|
|
87
|
+
fetch fails for any reason (timeout, network error, bad JSON, etc.).
|
|
88
|
+
"""
|
|
89
|
+
try:
|
|
90
|
+
response = requests.get(_OPENROUTER_URL, timeout=_LIVE_TIMEOUT_SECONDS)
|
|
91
|
+
response.raise_for_status()
|
|
92
|
+
data = response.json()
|
|
93
|
+
except Exception:
|
|
94
|
+
return None
|
|
95
|
+
|
|
96
|
+
if not isinstance(data, dict):
|
|
97
|
+
return None
|
|
98
|
+
models_list = data.get("data")
|
|
99
|
+
if not isinstance(models_list, list):
|
|
100
|
+
return None
|
|
101
|
+
|
|
102
|
+
prices: dict[str, float] = {}
|
|
103
|
+
for entry in models_list:
|
|
104
|
+
model_id = entry.get("id")
|
|
105
|
+
pricing = entry.get("pricing")
|
|
106
|
+
if not isinstance(model_id, str) or not isinstance(pricing, dict):
|
|
107
|
+
continue
|
|
108
|
+
prompt_price = pricing.get("prompt")
|
|
109
|
+
# OpenRouter pricing.prompt is price-per-token; convert to per-1M.
|
|
110
|
+
if isinstance(prompt_price, (int, float)):
|
|
111
|
+
prices[model_id] = prompt_price * 1_000_000
|
|
112
|
+
return prices
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
# ---------------------------------------------------------------------------
|
|
116
|
+
# Formatting helpers
|
|
117
|
+
# ---------------------------------------------------------------------------
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def format_number(n: int) -> str:
|
|
121
|
+
"""Format an integer with comma separators.
|
|
122
|
+
|
|
123
|
+
Args:
|
|
124
|
+
n: The integer to format.
|
|
125
|
+
|
|
126
|
+
Returns:
|
|
127
|
+
A string like "1,234".
|
|
128
|
+
"""
|
|
129
|
+
return f"{n:,}"
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def format_cost(cost: float) -> str:
|
|
133
|
+
"""Format a cost value to 6 decimal places.
|
|
134
|
+
|
|
135
|
+
Args:
|
|
136
|
+
cost: The cost value to format.
|
|
137
|
+
|
|
138
|
+
Returns:
|
|
139
|
+
A string like "0.003085".
|
|
140
|
+
"""
|
|
141
|
+
return f"{cost:.6f}"
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
# ---------------------------------------------------------------------------
|
|
145
|
+
# Input reading
|
|
146
|
+
# ---------------------------------------------------------------------------
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def read_file(path: str) -> str:
|
|
150
|
+
"""Read text content from a file.
|
|
151
|
+
|
|
152
|
+
Args:
|
|
153
|
+
path: Path to the file.
|
|
154
|
+
|
|
155
|
+
Returns:
|
|
156
|
+
The file contents as a string.
|
|
157
|
+
|
|
158
|
+
Raises:
|
|
159
|
+
FileNotFoundError: If the file does not exist.
|
|
160
|
+
"""
|
|
161
|
+
if not os.path.isfile(path):
|
|
162
|
+
raise FileNotFoundError(f"File not found: {path}")
|
|
163
|
+
with open(path) as fh:
|
|
164
|
+
return fh.read()
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def read_stdin() -> str | None:
|
|
168
|
+
"""Read all text from standard input (non-interactive / pipe).
|
|
169
|
+
|
|
170
|
+
Returns:
|
|
171
|
+
The piped text, or None if stdin is a terminal (tty).
|
|
172
|
+
"""
|
|
173
|
+
if sys.stdin.isatty():
|
|
174
|
+
return None
|
|
175
|
+
return sys.stdin.read()
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
# ---------------------------------------------------------------------------
|
|
179
|
+
# Output
|
|
180
|
+
# ---------------------------------------------------------------------------
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def _price_label(prices_live: bool, live_prices: dict | None) -> str | None:
|
|
184
|
+
"""Build the bracketed price-source annotation line.
|
|
185
|
+
|
|
186
|
+
Args:
|
|
187
|
+
prices_live: Whether --live-prices was requested.
|
|
188
|
+
live_prices: The result of the live fetch (None means failed).
|
|
189
|
+
|
|
190
|
+
Returns:
|
|
191
|
+
A label string when --live-prices was used, None otherwise.
|
|
192
|
+
"""
|
|
193
|
+
if not prices_live:
|
|
194
|
+
return None
|
|
195
|
+
if live_prices is None:
|
|
196
|
+
return "[prices: fallback (live fetch failed)]"
|
|
197
|
+
return "[prices: live via OpenRouter]"
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def _get_prices(
|
|
201
|
+
prices_live: bool,
|
|
202
|
+
) -> tuple[dict[str, float], dict[str, float] | None]:
|
|
203
|
+
"""Resolve per-model prices.
|
|
204
|
+
|
|
205
|
+
Args:
|
|
206
|
+
prices_live: Whether to attempt a live fetch.
|
|
207
|
+
|
|
208
|
+
Returns:
|
|
209
|
+
Tuple of (price_per_1m_by_openrouter_id, raw_prices_dict_or_None).
|
|
210
|
+
"""
|
|
211
|
+
live_prices: dict[str, float] | None = None
|
|
212
|
+
if prices_live:
|
|
213
|
+
live_prices = fetch_live_prices()
|
|
214
|
+
price_map: dict[str, float] = {}
|
|
215
|
+
for m in MODELS:
|
|
216
|
+
oid = str(m["openrouter_id"])
|
|
217
|
+
if live_prices is not None and oid in live_prices:
|
|
218
|
+
price_map[oid] = live_prices[oid]
|
|
219
|
+
else:
|
|
220
|
+
price_map[oid] = float(m["hardcoded_price_per_1m"])
|
|
221
|
+
return price_map, live_prices
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
def output_basic(
|
|
225
|
+
token_count: int,
|
|
226
|
+
prices_live: bool,
|
|
227
|
+
) -> None:
|
|
228
|
+
"""Print basic mode output (tokens + cost estimates)."""
|
|
229
|
+
price_map, live_prices = _get_prices(prices_live)
|
|
230
|
+
|
|
231
|
+
print(f"Tokens: {format_number(token_count)}")
|
|
232
|
+
label = _price_label(prices_live, live_prices)
|
|
233
|
+
if label is not None:
|
|
234
|
+
print(label)
|
|
235
|
+
print()
|
|
236
|
+
print("Cost estimates (input tokens):")
|
|
237
|
+
|
|
238
|
+
for m in MODELS:
|
|
239
|
+
oid = str(m["openrouter_id"])
|
|
240
|
+
ppm = price_map[oid]
|
|
241
|
+
cost = (token_count / 1_000_000) * ppm
|
|
242
|
+
print(f" {m['display_name']:<20} ${format_cost(cost)}")
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
def output_diff(
|
|
246
|
+
path1: str,
|
|
247
|
+
path2: str,
|
|
248
|
+
prices_live: bool,
|
|
249
|
+
) -> None:
|
|
250
|
+
"""Print diff mode output (two-file comparison)."""
|
|
251
|
+
try:
|
|
252
|
+
text1 = read_file(path1)
|
|
253
|
+
text2 = read_file(path2)
|
|
254
|
+
except FileNotFoundError as exc:
|
|
255
|
+
print(f"Error: {exc}", file=sys.stderr)
|
|
256
|
+
sys.exit(1)
|
|
257
|
+
|
|
258
|
+
count1 = count_tokens(text1)
|
|
259
|
+
count2 = count_tokens(text2)
|
|
260
|
+
delta = count2 - count1
|
|
261
|
+
delta_pct = (delta / count1 * 100) if count1 > 0 else 0.0
|
|
262
|
+
delta_sign = "+" if delta >= 0 else ""
|
|
263
|
+
|
|
264
|
+
price_map, live_prices = _get_prices(prices_live)
|
|
265
|
+
|
|
266
|
+
print(f"File 1: {path1:30s} → {format_number(count1):>8} tokens")
|
|
267
|
+
print(f"File 2: {path2:30s} → {format_number(count2):>8} tokens")
|
|
268
|
+
print()
|
|
269
|
+
print(f"Delta: {delta_sign}{format_number(delta)} tokens ({delta_sign}{delta_pct:.1f}%)")
|
|
270
|
+
print()
|
|
271
|
+
label = _price_label(prices_live, live_prices)
|
|
272
|
+
if label is not None:
|
|
273
|
+
print(label)
|
|
274
|
+
print()
|
|
275
|
+
print("Cost delta per model:")
|
|
276
|
+
|
|
277
|
+
for m in MODELS:
|
|
278
|
+
oid = str(m["openrouter_id"])
|
|
279
|
+
ppm = price_map[oid]
|
|
280
|
+
cost1 = (count1 / 1_000_000) * ppm
|
|
281
|
+
cost2 = (count2 / 1_000_000) * ppm
|
|
282
|
+
cost_delta = cost2 - cost1
|
|
283
|
+
delta_sign_cost = "+" if cost_delta >= 0 else ""
|
|
284
|
+
print(f" {m['display_name']:<20} {delta_sign_cost}${format_cost(cost_delta)}")
|
|
285
|
+
|
|
286
|
+
|
|
287
|
+
# ---------------------------------------------------------------------------
|
|
288
|
+
# CLI
|
|
289
|
+
# ---------------------------------------------------------------------------
|
|
290
|
+
|
|
291
|
+
|
|
292
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
293
|
+
"""Build the argument parser.
|
|
294
|
+
|
|
295
|
+
Returns:
|
|
296
|
+
The configured ArgumentParser instance.
|
|
297
|
+
"""
|
|
298
|
+
parser = argparse.ArgumentParser(
|
|
299
|
+
description="Count tokens and estimate LLM API costs.",
|
|
300
|
+
)
|
|
301
|
+
parser.add_argument(
|
|
302
|
+
"--text",
|
|
303
|
+
"-t",
|
|
304
|
+
type=str,
|
|
305
|
+
default=None,
|
|
306
|
+
help="Inline text to count tokens for.",
|
|
307
|
+
)
|
|
308
|
+
parser.add_argument(
|
|
309
|
+
"--file",
|
|
310
|
+
"-f",
|
|
311
|
+
type=str,
|
|
312
|
+
default=None,
|
|
313
|
+
help="Path to a file whose content should be tokenized.",
|
|
314
|
+
)
|
|
315
|
+
parser.add_argument(
|
|
316
|
+
"--diff",
|
|
317
|
+
"-d",
|
|
318
|
+
type=str,
|
|
319
|
+
nargs=2,
|
|
320
|
+
metavar=("FILE1", "FILE2"),
|
|
321
|
+
default=None,
|
|
322
|
+
help="Diff mode: compare token counts of two files.",
|
|
323
|
+
)
|
|
324
|
+
parser.add_argument(
|
|
325
|
+
"--live-prices",
|
|
326
|
+
action="store_true",
|
|
327
|
+
default=False,
|
|
328
|
+
help="Fetch real-time pricing from OpenRouter API.",
|
|
329
|
+
)
|
|
330
|
+
return parser
|
|
331
|
+
|
|
332
|
+
|
|
333
|
+
def get_input_text(args: argparse.Namespace) -> str:
|
|
334
|
+
"""Resolve the input text from the provided arguments or stdin.
|
|
335
|
+
|
|
336
|
+
Priority: --text > --file > stdin pipe.
|
|
337
|
+
|
|
338
|
+
Args:
|
|
339
|
+
args: Parsed command-line arguments.
|
|
340
|
+
|
|
341
|
+
Returns:
|
|
342
|
+
The input text string.
|
|
343
|
+
|
|
344
|
+
Raises:
|
|
345
|
+
SystemExit: If no input source is available.
|
|
346
|
+
"""
|
|
347
|
+
if args.text is not None:
|
|
348
|
+
return args.text
|
|
349
|
+
|
|
350
|
+
if args.file is not None:
|
|
351
|
+
try:
|
|
352
|
+
return read_file(args.file)
|
|
353
|
+
except FileNotFoundError as exc:
|
|
354
|
+
print(f"Error: {exc}", file=sys.stderr)
|
|
355
|
+
sys.exit(1)
|
|
356
|
+
|
|
357
|
+
stdin_text = read_stdin()
|
|
358
|
+
if stdin_text is not None:
|
|
359
|
+
return stdin_text
|
|
360
|
+
|
|
361
|
+
print(
|
|
362
|
+
"Error: no input provided. Use --text, --file, or pipe data via stdin.",
|
|
363
|
+
file=sys.stderr,
|
|
364
|
+
)
|
|
365
|
+
sys.exit(1)
|
|
366
|
+
|
|
367
|
+
|
|
368
|
+
def main() -> None:
|
|
369
|
+
"""Main entry point."""
|
|
370
|
+
parser = build_parser()
|
|
371
|
+
args = parser.parse_args()
|
|
372
|
+
|
|
373
|
+
if args.diff is not None:
|
|
374
|
+
output_diff(args.diff[0], args.diff[1], args.live_prices)
|
|
375
|
+
else:
|
|
376
|
+
text = get_input_text(args)
|
|
377
|
+
token_count = count_tokens(text)
|
|
378
|
+
output_basic(token_count, args.live_prices)
|
|
379
|
+
|
|
380
|
+
|
|
381
|
+
if __name__ == "__main__":
|
|
382
|
+
main()
|