aitc 1.0.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.
@@ -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,6 @@
1
+ tokencount.py,sha256=-5s1J_K56wCSlNBBE_j5IzwtPI66sBnpnlhi8PJXYRI,10402
2
+ aitc-1.0.0.dist-info/METADATA,sha256=Z0Cp582YKLkK8fZZbYjg4sJ8naoq6gnlxVwk-3Yin2E,2387
3
+ aitc-1.0.0.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
4
+ aitc-1.0.0.dist-info/entry_points.txt,sha256=cojrp2otVyU9__QnL4e93E7gtVWJN_SxwK6gN8HUZT0,41
5
+ aitc-1.0.0.dist-info/top_level.txt,sha256=8kRhF13Qf9RRz-jix0rkpN3odE4rA_wYvpyc5ScPaCo,11
6
+ aitc-1.0.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (82.0.1)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ aitc = tokencount:main
@@ -0,0 +1 @@
1
+ tokencount
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()