Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
56 changes: 54 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,10 +16,16 @@
</div>

```bash
pip install cognis-tokenmeter
pip install "git+https://github.com/cognis-digital/tokenmeter.git"
tokenmeter scan . # → prioritized findings in seconds
```

<!-- cognis:layman:start -->
## What is this?

Tokenmeter is a command-line tool that tells you how many tokens your text will use and how much it will cost when sent to an AI model like GPT-4 or Claude. You give it text or a file, choose a model, and it instantly calculates the token count and estimated price in US dollars. It also lets you set a budget limit so your scripts or CI pipelines automatically fail if a request would cost too much. It is designed for developers who want to track and control AI API spending without surprises.
<!-- cognis:layman:end -->

## Contents

- [Why tokenmeter?](#why) · [Features](#features) · [Quick start](#quick-start) · [Example](#example) · [Architecture](#architecture) · [AI stack](#ai-stack) · [How it compares](#how-it-compares) · [Integrations](#integrations) · [Install anywhere](#install-anywhere) · [Related](#related) · [Contributing](#contributing)
Expand Down Expand Up @@ -49,10 +55,56 @@ AI cost control
<div align="right"><a href="#top">↑ back to top</a></div>

<a name="quick-start"></a>
<!-- cognis:domains:start -->
## Domains

**Primary domain:** Finance & Quant · **JTF MERIDIAN division:** BLACKBOOK · ORACLE

**Topics:** `cognis` `finance` `fintech` `quant`

Part of the **Cognis Neural Suite** — 300+ source-available tools organized across 12 domains under the JTF MERIDIAN command structure. See the [suite on GitHub](https://github.com/cognis-digital) and [jtf-meridian](https://github.com/cognis-digital/jtf-meridian) for how the pieces fit together.
<!-- cognis:domains:end -->

<!-- cognis:install:start -->
## Install

`tokenmeter` is source-available (not published to PyPI) — every method below installs
straight from GitHub. Pick whichever you prefer; the one-line scripts auto-detect
the best tool available on your machine.

**One-liner (Linux / macOS):**
```sh
curl -fsSL https://raw.githubusercontent.com/cognis-digital/tokenmeter/HEAD/install.sh | sh
```

**One-liner (Windows PowerShell):**
```powershell
irm https://raw.githubusercontent.com/cognis-digital/tokenmeter/HEAD/install.ps1 | iex
```

**Or install manually — any one of:**
```sh
pipx install "git+https://github.com/cognis-digital/tokenmeter.git" # isolated (recommended)
uv tool install "git+https://github.com/cognis-digital/tokenmeter.git" # uv
pip install "git+https://github.com/cognis-digital/tokenmeter.git" # pip
```

**From source:**
```sh
git clone https://github.com/cognis-digital/tokenmeter.git
cd tokenmeter && pip install .
```

Then run:
```sh
tokenmeter --help
```
<!-- cognis:install:end -->

## Quick start

```bash
pip install cognis-tokenmeter
pip install "git+https://github.com/cognis-digital/tokenmeter.git"
tokenmeter --version
tokenmeter scan . # scan current project
tokenmeter scan . --format json # machine-readable
Expand Down
29 changes: 29 additions & 0 deletions install.ps1
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
# Comprehensive installer for cognis-digital/tokenmeter (Windows PowerShell).
# Tries: pipx -> uv -> pip (git+https) -> from source.
# tokenmeter is source-available and not on PyPI; all paths install from GitHub.
$ErrorActionPreference = "Stop"
$Repo = "tokenmeter"
$Url = "git+https://github.com/cognis-digital/tokenmeter.git"
$Git = "https://github.com/cognis-digital/tokenmeter.git"
function Say($m) { Write-Host "[$Repo] $m" -ForegroundColor Magenta }
function Have($c) { [bool](Get-Command $c -ErrorAction SilentlyContinue) }

if (-not (Have python) -and -not (Have py)) {
Say "Python 3.9+ is required but was not found. Install Python first."; exit 1
}
if (Have pipx) {
Say "Installing with pipx (isolated, recommended)..."
pipx install $Url; if ($LASTEXITCODE -eq 0) { Say "Done. Run: tokenmeter"; exit 0 }
}
if (Have uv) {
Say "Installing with uv..."
uv tool install $Url; if ($LASTEXITCODE -eq 0) { Say "Done. Run: tokenmeter"; exit 0 }
}
if (Have pip) {
Say "Installing with pip (user site)..."
pip install --user $Url; if ($LASTEXITCODE -eq 0) { Say "Done. Run: tokenmeter"; exit 0 }
}
Say "No packaging tool worked; falling back to a source clone."
$Tmp = Join-Path $env:TEMP "$Repo-src"
git clone --depth 1 $Git $Tmp
Say "Cloned to $Tmp - run: cd $Tmp; python -m pip install ."
44 changes: 34 additions & 10 deletions install.sh
Original file line number Diff line number Diff line change
@@ -1,10 +1,34 @@
#!/usr/bin/env sh
# Universal installer for tokenmeter. Prefers uv > pipx > pip; installs from the repo.
set -e
SRC="git+https://github.com/cognis-digital/tokenmeter.git"
echo "Installing tokenmeter ..."
if command -v uv >/dev/null 2>&1; then uv tool install "$SRC"
elif command -v pipx >/dev/null 2>&1; then pipx install "$SRC"
elif command -v python3 >/dev/null 2>&1; then python3 -m pip install --user "$SRC"
else echo "Need uv, pipx, or python3+pip"; exit 1; fi
echo "Done. Run: tokenmeter --help"
#!/usr/bin/env sh
# Comprehensive installer for cognis-digital/tokenmeter (Linux / macOS).
# Tries the best available method: pipx -> uv -> pip (git+https) -> from source.
# tokenmeter is source-available and not on PyPI; all paths install from GitHub.
set -eu

REPO="tokenmeter"
URL="git+https://github.com/cognis-digital/tokenmeter.git"
GITURL="https://github.com/cognis-digital/tokenmeter.git"

say() { printf '\033[1;35m[%s]\033[0m %s\n' "$REPO" "$1"; }
have() { command -v "$1" >/dev/null 2>&1; }

if ! have python3 && ! have python; then
say "Python 3.9+ is required but was not found. Install Python first."; exit 1
fi

if have pipx; then
say "Installing with pipx (isolated, recommended)..."
pipx install "$URL" && { say "Done. Run: tokenmeter"; exit 0; }
fi
if have uv; then
say "Installing with uv..."
uv tool install "$URL" && { say "Done. Run: tokenmeter"; exit 0; }
fi
if have pip3 || have pip; then
PIP="$(command -v pip3 || command -v pip)"
say "Installing with pip (user site)..."
"$PIP" install --user "$URL" && { say "Done. Run: tokenmeter"; exit 0; }
fi

say "No packaging tool worked; falling back to a source clone."
TMP="$(mktemp -d)"; git clone --depth 1 "$GITURL" "$TMP/$REPO"
say "Cloned to $TMP/$REPO — run: cd $TMP/$REPO && python3 -m pip install ."
37 changes: 33 additions & 4 deletions integrations/webhook.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,21 +5,50 @@
Usage: <tool> scan . --format json | python integrations/webhook.py --url URL
"""
from __future__ import annotations
import argparse, json, sys, urllib.request
import argparse
import sys
import urllib.request

def main() -> int:
ap = argparse.ArgumentParser()
ap.add_argument("--url", required=True)
ap = argparse.ArgumentParser(
description="Forward JSON findings from stdin to a webhook URL."
)
ap.add_argument("--url", required=True, help="Destination URL (http/https)")
ap.add_argument("--header", action="append", default=[], help="Key: Value")
ap.add_argument(
"--timeout",
type=float,
default=15.0,
help="Request timeout in seconds (default: 15)",
)
args = ap.parse_args()

if not args.url.startswith(("http://", "https://")):
print(
f"error: --url must start with http:// or https://, got {args.url!r}",
file=sys.stderr,
)
return 2

if args.timeout <= 0:
print("error: --timeout must be > 0", file=sys.stderr)
return 2

payload = sys.stdin.read().encode("utf-8")
if not payload:
print("error: no data on stdin — nothing to post", file=sys.stderr)
return 2

req = urllib.request.Request(args.url, data=payload, method="POST")
req.add_header("Content-Type", "application/json")
for h in args.header:
k, _, v = h.partition(":")
if not k.strip():
print(f"error: malformed --header value {h!r}", file=sys.stderr)
return 2
req.add_header(k.strip(), v.strip())
try:
with urllib.request.urlopen(req, timeout=15) as r:
with urllib.request.urlopen(req, timeout=args.timeout) as r:
print(f"posted {len(payload)} bytes -> {r.status}")
return 0
except Exception as e:
Expand Down
1 change: 1 addition & 0 deletions layman.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Tokenmeter is a command-line tool that tells you how many tokens your text will use and how much it will cost when sent to an AI model like GPT-4 or Claude. You give it text or a file, choose a model, and it instantly calculates the token count and estimated price in US dollars. It also lets you set a budget limit so your scripts or CI pipelines automatically fail if a request would cost too much. It is designed for developers who want to track and control AI API spending without surprises.
121 changes: 105 additions & 16 deletions tests/test_smoke.py
Original file line number Diff line number Diff line change
@@ -1,16 +1,105 @@
"""Smoke tests for TOKENMETER."""
from tokenmeter.core import scan, TOOL_NAME, TOOL_VERSION

def test_identity():
assert TOOL_NAME and TOOL_VERSION

def test_scan_runs(tmp_path):
f = tmp_path / "x.txt"
f.write_text("a TODO here\nFIXME later\n")
res = scan(str(tmp_path))
assert res.score >= 0
assert any("TODO" in fi.title or "FIXME" in fi.title for fi in res.findings)

def test_cli_importable():
from tokenmeter.cli import main
assert callable(main)
"""Smoke tests for TOKENMETER."""
import contextlib
import io
import pytest

from tokenmeter.core import TOOL_NAME, TOOL_VERSION, add_model, count_tokens, estimate
from tokenmeter import cli


def test_identity():
assert TOOL_NAME and TOOL_VERSION


def test_count_tokens_basic(tmp_path):
# Write a file with known text and confirm token counting works on the text.
f = tmp_path / "x.txt"
f.write_text("hello world this is a test\n")
text = f.read_text()
result = count_tokens(text)
assert result >= 1
assert isinstance(result, int)


def test_cli_importable():
assert callable(cli.main)


# ---------------------------------------------------------------------------
# Hardening: edge cases and invalid inputs
# ---------------------------------------------------------------------------

def _run_cli(argv):
"""Run CLI and capture stdout; return (exit_code, stdout_text)."""
out = io.StringIO()
with contextlib.redirect_stdout(out):
code = cli.main(argv)
return code, out.getvalue()


def test_count_tokens_none_safe():
# count_tokens must not crash on None; treat as empty string.
assert count_tokens(None) == 0 # type: ignore[arg-type]


def test_count_tokens_empty():
assert count_tokens("") == 0


def test_add_model_negative_price_raises():
with pytest.raises(ValueError, match="input_per_1k"):
add_model("bad-price", -0.001, 0.002, 4096)


def test_add_model_zero_context_raises():
with pytest.raises(ValueError, match="context_window"):
add_model("bad-ctx", 0.001, 0.002, 0)


def test_add_model_empty_name_raises():
with pytest.raises(ValueError, match="name"):
add_model("", 0.001, 0.002, 4096)


def test_estimate_negative_output_tokens_raises():
with pytest.raises(ValueError, match="output_tokens"):
estimate("hello", model="claude-sonnet", output_tokens=-1)


def test_estimate_negative_input_tokens_raises():
with pytest.raises(ValueError, match="input_tokens"):
estimate("", model="claude-sonnet", input_tokens=-5)


def test_cli_missing_file_returns_exit_2(tmp_path):
# Pointing --file at a nonexistent path should exit 2 (not traceback).
missing = str(tmp_path / "does_not_exist.txt")
with pytest.raises(SystemExit) as exc_info:
_run_cli(["count", "-f", missing])
assert exc_info.value.code == 2


def test_cli_negative_output_tokens_returns_exit_2():
code, _ = _run_cli(["count", "-t", "hello", "-o", "-5"])
assert code == 2


def test_cli_budget_negative_max_cost_returns_exit_2():
code, _ = _run_cli(["budget", "-t", "hello", "--max-cost", "-1.0"])
assert code == 2


def test_cli_budget_negative_max_tokens_returns_exit_2():
code, _ = _run_cli(["budget", "-t", "hello", "--max-tokens", "-10"])
assert code == 2


def test_cli_unknown_model_returns_exit_2():
code, _ = _run_cli(["count", "-t", "hello", "-m", "not-a-real-model"])
assert code == 2


def test_mcp_server_importable():
# mcp_server must import cleanly (the broken scan/to_json import is fixed).
from tokenmeter import mcp_server # noqa: F401
assert callable(mcp_server.serve)
26 changes: 22 additions & 4 deletions tokenmeter/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,6 @@
aggregate,
check_budget,
estimate,
get_pricing,
list_models,
)

Expand All @@ -21,8 +20,12 @@ def _read_input(args: argparse.Namespace) -> str:
if getattr(args, "text", None) is not None:
return args.text
if getattr(args, "file", None):
with open(args.file, "r", encoding="utf-8", errors="replace") as fh:
return fh.read()
try:
with open(args.file, "r", encoding="utf-8", errors="replace") as fh:
return fh.read()
except OSError as exc:
print(f"error: cannot read file {args.file!r}: {exc}", file=sys.stderr)
raise SystemExit(2) from exc
# No explicit source: read stdin if piped.
if not sys.stdin.isatty():
return sys.stdin.read()
Expand All @@ -35,7 +38,10 @@ def _emit(payload: dict, fmt: str, rows: Optional[List[tuple]] = None) -> None:
return
# table
if rows is not None:
widths = [max(len(str(r[i])) for r in rows) for i in range(len(rows[0]))]
if not rows:
return
ncols = len(rows[0])
widths = [max(len(str(r[i])) for r in rows) for i in range(ncols)]
for r in rows:
print(" ".join(str(c).ljust(widths[i]) for i, c in enumerate(r)))
return
Expand All @@ -44,6 +50,9 @@ def _emit(payload: dict, fmt: str, rows: Optional[List[tuple]] = None) -> None:


def _cmd_count(args: argparse.Namespace) -> int:
if args.output_tokens < 0:
print("error: --output-tokens must be >= 0", file=sys.stderr)
return 2
text = _read_input(args)
est = estimate(
text,
Expand All @@ -67,6 +76,15 @@ def _cmd_count(args: argparse.Namespace) -> int:


def _cmd_budget(args: argparse.Namespace) -> int:
if args.output_tokens < 0:
print("error: --output-tokens must be >= 0", file=sys.stderr)
return 2
if args.max_cost is not None and args.max_cost < 0:
print("error: --max-cost must be >= 0", file=sys.stderr)
return 2
if args.max_tokens is not None and args.max_tokens < 0:
print("error: --max-tokens must be >= 0", file=sys.stderr)
return 2
text = _read_input(args)
est = estimate(text, model=args.model, output_tokens=args.output_tokens)
result = check_budget(
Expand Down
Loading
Loading