Skip to content

feat(cli): add litellm-token-count subcommand for offline token counting (Fixes #34772)#34775

Draft
Harsh23Kashyap wants to merge 5 commits into
BerriAI:litellm_internal_stagingfrom
Harsh23Kashyap:litellm_feat_token_count
Draft

feat(cli): add litellm-token-count subcommand for offline token counting (Fixes #34772)#34775
Harsh23Kashyap wants to merge 5 commits into
BerriAI:litellm_internal_stagingfrom
Harsh23Kashyap:litellm_feat_token_count

Conversation

@Harsh23Kashyap

Copy link
Copy Markdown

TLDR

Problem this solves:

  • Operators have no offline way to count tokens for a prompt from a shell
  • litellm cost-estimate and litellm doctor already ship offline CLIs; token counting is the missing third
  • The internal litellm.utils.token_counter() exists but requires a Python import to use

How it solves it:

  • Adds a new litellm token-count subcommand that wraps litellm.token_counter()
  • Supports --text, --file, and --messages (each with stdin via -) as mutually-exclusive inputs
  • Outputs a one-line model=... mode=... count=N by default, or JSON via --json
  • Privacy: input text and messages JSON never appear in stdout, only the count
  • Mirrors the existing litellm-cost-estimate and litellm-doctor pattern in litellm/cli/

Relevant issues

Linear ticket

Pre-Submission checklist

  • I have added meaningful tests
  • My PR passes all CI/CD checks (e.g., lint, format, unit tests)
  • My PR's scope is as isolated as possible; it only solves 1 specific problem
  • I have received a Greptile Confidence Score of at least 4/5 before requesting a maintainer review (Greptile reviews automatically once the PR is opened; only comment @greptileai to re-request a review after pushing changes)

Screenshots / Proof of Fix

Real CLI run against the local install (no proxy, no provider, no mock); commit 2fec98dfdd (HEAD of litellm_feat_token_count):

$ .venv/bin/python -m click.testing CliRunner on litellm.cli.token_count.cli \
    --model gpt-4o-mini --text "hello world"
model=gpt-4o-mini mode=text count=2

$ .venv/bin/python -c "from click.testing import CliRunner; from litellm.cli.token_count import cli; \
    r = CliRunner().invoke(cli, ['--model','gpt-4o-mini','--text','hello world','--json']); print(r.output.strip())"
{"model": "gpt-4o-mini", "mode": "text", "count": 2}

$ .venv/bin/python -c "from click.testing import CliRunner; from litellm.cli.token_count import cli; \
    r = CliRunner().invoke(cli, ['--model','gpt-4o-mini','--messages','[{\"role\":\"user\",\"content\":\"hi\"}]','--json']); print(r.output.strip())"
{"model": "gpt-4o-mini", "mode": "messages", "count": 8}

$ .venv/bin/python -c "from click.testing import CliRunner; from litellm.cli.token_count import cli; \
    r = CliRunner().invoke(cli, ['--model','gpt-4o-mini','--text','a','--file','/tmp/x']); print('exit:', r.exit_code, '|', r.output.strip())"
exit: 2 | Error: --text and --file are mutually exclusive; pass only one.

$ .venv/bin/python -m pytest tests/test_litellm/cli/test_token_count.py -v
tests/test_litellm/cli/test_token_count.py::test_count_tokens_with_text PASSED
... (23 tests total)
============================== 23 passed in 0.27s ==============================

Real count values are non-zero (2 for "hello world", 8 for a single user message with role+content overhead), so a regression that silently returns 0 would fail the tests.

Type

  • New Feature

Changes

Two commits on litellm_feat_token_count (off litellm_internal_staging = 24123269cc):

  • feat(cli): add litellm-token-count subcommand for offline token counting; adds litellm/cli/token_count.py (CLI + count_tokens() helper + TokenCount dataclass + TokenCountMessage TypedDict), the empty litellm/cli/__init__.py, and the litellm-token-count entry point in pyproject.toml.
  • test(cli): cover litellm-token-count pass / fail / invalid-input paths; adds tests/test_litellm/cli/test_token_count.py with 23 tests across the programmatic helper and the CLI surface (pass paths, JSON output, mutually-exclusive flags, missing model, missing file, invalid JSON, non-list messages, privacy: text/messages never echoed to stdout).

Files added: litellm/cli/__init__.py, litellm/cli/token_count.py, tests/test_litellm/cli/test_token_count.py. Files modified: pyproject.toml (one line; new [project.scripts] entry mirroring litellm-cost-estimate and litellm-doctor).

No existing public Python API changes; litellm.utils.token_counter() keeps its current signature. The new count_tokens helper is a thin wrapper that calls the existing function with sensible defaults.

QA runbook

Not a proxy change, so no live-proxy QA needed. The CLI is exercised end-to-end via the CliRunner tests in tests/test_litellm/cli/test_token_count.py (23 tests, all passing locally). To verify locally:

.venv/bin/python -m pytest tests/test_litellm/cli/test_token_count.py -v

Final Attestation

  • The tests check the right things, including the edge cases, and regressions in the respective real-world customer use-cases are not possible after this PR

Counts the tokens a model would see for a given text, file, or
message list, using the public litellm.token_counter helper. Reuses
the same tokenizer selection that litellm.completion uses, so the
count matches what a real call would produce. No network calls are
made and no provider credentials are required.

Three input modes (mutually exclusive):

  --text "..."             raw text string
  --file path/to/file.txt   read from a UTF-8 file (- reads stdin)
  --messages <json-list>    parse an OpenAI-style message list
                            (- reads JSON from stdin)

Output is a single line by default (model=... mode=... count=N) and
--json emits a {model, mode, count} object for scripts. Privacy: the
input text and messages JSON are never echoed to stdout, only the
count.

The programmatic helper count_tokens(model, *, text, file, messages)
returns a TokenCount dataclass and raises ValueError on input
validation errors so callers can catch them separately from runtime
errors. Mirrors the cost-estimate and doctor subcommands in the same
litellm/cli/ package; registers a litellm-token-count entry point in
pyproject.toml.

Refs BerriAI#34772
23 tests across two layers:

count_tokens (programmatic API)
  - happy paths for --text, --file, --messages
  - mutual exclusion of input options
  - missing input source
  - empty model
  - invalid messages JSON
  - non-list messages
  - missing file surfaces as ValueError
  - token_counter failure wraps as RuntimeError
  - to_jsonable round-trip

cli (CLI surface)
  - basic text invocation prints count
  - --json produces valid JSON
  - --file reads from disk
  - --messages parses JSON
  - mutually exclusive flags exit 2
  - missing input exits 2
  - missing --model exits 2
  - invalid JSON in --messages exits 2
  - non-list --messages exits 2
  - --file not found exits 2
  - input text never echoed to stdout (privacy)
  - messages JSON never echoed to stdout (privacy)
…strict TID251 budget

ruff-strict gate flagged 2 new TID251 violations for the cast()
import + call. The TypedDict narrowing was added to satisfy basedpyright
but the downstream token_counter call already takes
list[AllMessageValues] (which absorbs list[Any]), so the TypedDict was
not pulling its weight. Returning list[Any] from the messages resolver
removes both TID251 violations, and converting the remaining Optional[X]
to X | None removes 9 UP045 violations.

Net effect: ruff-strict gate now reports no new violations; 23 tests
still pass; format clean; basedpyright is +3 vs the prior cost_estimate
(11 vs 8), all in the same reportAny noise category that already
exists across the repo.
@codecov

codecov Bot commented Jul 27, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 89.41176% with 9 lines in your changes missing coverage. Please review.

Files with missing lines Patch % Lines
litellm/cli/token_count.py 89.41% 9 Missing ⚠️

📢 Thoughts on this report? Let us know!

LIT009 bans the use of "# type: ignore" comments. The previous
patch on this branch added one when calling
_resolve_messages_payload(messages) at the end of _resolve_input,
because the type checker did not narrow messages to str after the
mutual-exclusion checks.

The replacement is a runtime assert that mirrors the
already-performed mutual-exclusion logic: by the time we reach the
fall-through, the only path that survives is the messages branch, so
messages is not None. The assert also serves as a tripwire if a
future refactor breaks the mutual-exclusion invariants.

ruff-strict gate: OK. type-discipline gate: OK. 23 tests pass.
The CLI test directory was added in prior PRs (litellm-doctor and
litellm-cost-estimate) but never registered in the misc unit-test
workflow, so codecov was reporting 0% patch coverage for the CLI
sources and the CLI tests were not actually being run by CI.

This adds tests/test_litellm/cli to the test-path list in the misc
job so the new token-count tests (and the existing doctor +
cost-estimate tests) run on every PR and patch coverage is reported.
@codspeed-hq

codspeed-hq Bot commented Jul 27, 2026

Copy link
Copy Markdown
Contributor

Merging this PR will not alter performance

✅ 31 untouched benchmarks


Comparing Harsh23Kashyap:litellm_feat_token_count (66d0e79) with litellm_internal_staging (2412326)

Open in CodSpeed

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Feature]: Add litellm token-count CLI subcommand for offline token counting

1 participant