From 9dd23316b2bc4ae182ae3ac79acfd64b45762db2 Mon Sep 17 00:00:00 2001 From: Harsh23Kashyap <55448981+Harsh23Kashyap@users.noreply.github.com> Date: Mon, 27 Jul 2026 16:50:33 +0530 Subject: [PATCH 1/5] feat(cli): add litellm-token-count subcommand for offline token counting 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 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 #34772 --- litellm/cli/__init__.py | 1 + litellm/cli/token_count.py | 211 +++++++++++++++++++++++++++++++++++++ pyproject.toml | 1 + 3 files changed, 213 insertions(+) create mode 100644 litellm/cli/__init__.py create mode 100644 litellm/cli/token_count.py diff --git a/litellm/cli/__init__.py b/litellm/cli/__init__.py new file mode 100644 index 00000000000..a5fc901adf2 --- /dev/null +++ b/litellm/cli/__init__.py @@ -0,0 +1 @@ +"""CLI utilities for the litellm SDK (separate from the proxy-management CLI).""" diff --git a/litellm/cli/token_count.py b/litellm/cli/token_count.py new file mode 100644 index 00000000000..22ac8415393 --- /dev/null +++ b/litellm/cli/token_count.py @@ -0,0 +1,211 @@ +"""``litellm token-count`` — offline token counting for a single prompt. + +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. +""" + +from __future__ import annotations + +import json +import sys +from dataclasses import asdict, dataclass +from typing import Any, Literal, Optional, TypedDict, cast + +import click + +Mode = Literal["text", "file", "messages"] + + +class TokenCountMessage(TypedDict, total=False): + """A single message accepted by ``litellm.token_counter(messages=...)``. + + Only ``role`` and ``content`` are required by the underlying + counter; other fields are passed through and may be ignored for + counting purposes. Marked ``total=False`` so callers can omit + fields they don't need. + """ + + role: str + content: Any + + +@dataclass(frozen=True) +class TokenCount: + """Result of a token-count run, suitable for both human and JSON output.""" + + model: str + mode: Mode + count: int + + def to_jsonable(self) -> dict[str, Any]: + return asdict(self) + + +def _read_stdin() -> str: + return sys.stdin.read() + + +def _read_text_file(path: str) -> str: + with open(path, "r", encoding="utf-8") as f: + return f.read() + + +def _resolve_messages_payload(value: str) -> list[TokenCountMessage]: + """Parse the --messages argument (raw JSON string or '-' for stdin) into a list. + + Returns a typed list of message dicts so the downstream + ``token_counter(messages=...)`` call does not propagate + ``Unknown`` into the call site. + """ + raw = _read_stdin() if value == "-" else value + try: + parsed = json.loads(raw) + except json.JSONDecodeError as exc: + raise click.UsageError(f"--messages must be valid JSON: {exc}") from exc + if not isinstance(parsed, list): + raise click.UsageError("--messages must be a JSON list of message objects.") + return cast(list[TokenCountMessage], parsed) + + +def _resolve_input( + *, + text: Optional[str], + file: Optional[str], + messages: Optional[str], +) -> tuple[Mode, Any]: + """Pick the right input-source option and return (mode, payload). + + Exactly one of ``text``, ``file``, or ``messages`` must be provided. + The payload is whatever the chosen mode needs: + + - ``text`` -> the raw string + - ``file`` -> the file contents (already read) + - ``messages`` -> the parsed JSON list (typed as ``list[TokenCountMessage]``) + + Raises ``click.UsageError`` on invalid combinations so the CLI + surfaces exit 2 instead of a Python traceback. + """ + provided = [name for name, val in (("text", text), ("file", file), ("messages", messages)) if val is not None] + if len(provided) == 0: + raise click.UsageError( + "Provide one of --text, --file, or --messages (as a JSON list, or '-' to read from stdin)." + ) + if len(provided) > 1: + raise click.UsageError(f"--{provided[0]} and --{provided[1]} are mutually exclusive; pass only one.") + if text is not None: + return "text", text + if file is not None: + if file == "-": + return "file", _read_stdin() + try: + return "file", _read_text_file(file) + except FileNotFoundError as exc: + raise click.UsageError(f"--file {file!r} does not exist.") from exc + except OSError as exc: + raise click.UsageError(f"--file {file!r} could not be read: {exc}") from exc + parsed = _resolve_messages_payload(messages) # type: ignore[arg-type] + return "messages", parsed + + +def count_tokens( + model: str, + *, + text: Optional[str] = None, + file: Optional[str] = None, + messages: Optional[str] = None, +) -> TokenCount: + """Public helper for programmatic use. Returns a ``TokenCount``. + + Raises :class:`ValueError` for invalid input (no input source, two + input sources, malformed messages JSON) so callers can catch input + validation separately from a missing model. The model is required; + ``litellm.token_counter`` falls back to a generic encoder for + unregistered model names, so unknown models do not raise here. + """ + if not model: + raise ValueError("model is required") + try: + mode, payload = _resolve_input(text=text, file=file, messages=messages) + except click.UsageError as exc: + raise ValueError(str(exc)) from exc + + from litellm import token_counter + + try: + if mode == "text": + count = int(token_counter(model=model, text=payload)) + elif mode == "file": + count = int(token_counter(model=model, text=payload)) + else: + count = int(token_counter(model=model, messages=payload)) + except Exception as exc: + raise RuntimeError(f"token_counter raised {type(exc).__name__}: {exc}") from exc + + return TokenCount(model=model, mode=mode, count=count) + + +def _render_line(result: TokenCount) -> str: + return f"model={result.model} mode={result.mode} count={result.count}" + + +@click.command() +@click.option( + "--model", + "model", + required=True, + help="Model name to use for tokenizer selection (e.g. gpt-4o, claude-sonnet-4-6, ollama/llama3).", +) +@click.option( + "--text", + "text", + default=None, + help="Raw text string to count tokens for. Mutually exclusive with --file and --messages.", +) +@click.option( + "--file", + "file_path", + default=None, + help="Path to a UTF-8 text file to read and count tokens for. Pass '-' to read from stdin. Mutually exclusive with --text and --messages.", +) +@click.option( + "--messages", + "messages_json", + default=None, + help="JSON list of OpenAI-style message objects to count tokens for. Pass '-' to read from stdin. Mutually exclusive with --text and --file.", +) +@click.option( + "--json", + "output_json", + is_flag=True, + help="Emit a JSON object instead of a single-line result.", +) +def cli( + model: str, + text: Optional[str], + file_path: Optional[str], + messages_json: Optional[str], + output_json: bool, +) -> None: + """Count tokens for a single prompt against a model tokenizer.""" + try: + result = count_tokens( + model, + text=text, + file=file_path, + messages=messages_json, + ) + except ValueError as exc: + raise click.UsageError(str(exc)) from exc + except click.UsageError: + raise + except Exception as exc: + click.echo(f"token-count failed: {type(exc).__name__}: {exc}", err=True) + sys.exit(1) + + if output_json: + click.echo(json.dumps(result.to_jsonable())) + else: + click.echo(_render_line(result)) diff --git a/pyproject.toml b/pyproject.toml index e15bf1351dd..682853a1cd1 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -157,6 +157,7 @@ proxy-runtime = [ litellm = "litellm:run_server" lite = "litellm.proxy.client.cli:cli" litellm-proxy = "litellm.proxy.client.cli:cli" +litellm-token-count = "litellm.cli.token_count:cli" [dependency-groups] dev = [ From 2fec98dfdd75c7a3561f077da49c529f986c4181 Mon Sep 17 00:00:00 2001 From: Harsh23Kashyap <55448981+Harsh23Kashyap@users.noreply.github.com> Date: Mon, 27 Jul 2026 16:50:37 +0530 Subject: [PATCH 2/5] test(cli): cover litellm-token-count pass / fail / invalid-input paths 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) --- tests/test_litellm/cli/test_token_count.py | 248 +++++++++++++++++++++ 1 file changed, 248 insertions(+) create mode 100644 tests/test_litellm/cli/test_token_count.py diff --git a/tests/test_litellm/cli/test_token_count.py b/tests/test_litellm/cli/test_token_count.py new file mode 100644 index 00000000000..9d22c383a09 --- /dev/null +++ b/tests/test_litellm/cli/test_token_count.py @@ -0,0 +1,248 @@ +"""Tests for the ``litellm token-count`` CLI subcommand.""" + +from __future__ import annotations + +import json +from pathlib import Path + +import pytest +from click.testing import CliRunner + +from litellm.cli.token_count import TokenCount, cli, count_tokens + + +def test_count_tokens_with_text(monkeypatch): + """--text passes the raw string to token_counter.""" + captured: dict = {} + + def fake_token_counter(**kw): + captured.update(kw) + return 7 + + monkeypatch.setattr("litellm.token_counter", fake_token_counter) + result = count_tokens(model="gpt-4o", text="hello world") + assert isinstance(result, TokenCount) + assert result.model == "gpt-4o" + assert result.mode == "text" + assert result.count == 7 + assert captured["model"] == "gpt-4o" + assert captured["text"] == "hello world" + + +def test_count_tokens_with_file(monkeypatch, tmp_path: Path): + """--file reads from disk and counts the file's tokens.""" + p = tmp_path / "prompt.txt" + p.write_text("file contents here", encoding="utf-8") + captured: dict = {} + + def fake_token_counter(**kw): + captured.update(kw) + return 11 + + monkeypatch.setattr("litellm.token_counter", fake_token_counter) + result = count_tokens(model="claude-sonnet-4-6", file=str(p)) + assert result.mode == "file" + assert result.count == 11 + assert captured["text"] == "file contents here" + + +def test_count_tokens_with_messages(monkeypatch): + """--messages parses the JSON list and forwards to token_counter.""" + captured: dict = {} + + def fake_token_counter(**kw): + captured.update(kw) + return 19 + + monkeypatch.setattr("litellm.token_counter", fake_token_counter) + messages = '[{"role": "user", "content": "hi"}, {"role": "assistant", "content": "hello"}]' + result = count_tokens(model="gpt-4o", messages=messages) + assert result.mode == "messages" + assert result.count == 19 + assert captured["messages"] == [ + {"role": "user", "content": "hi"}, + {"role": "assistant", "content": "hello"}, + ] + + +def test_count_tokens_rejects_mutually_exclusive_input(monkeypatch): + """Passing two input sources at once is a usage error.""" + monkeypatch.setattr("litellm.token_counter", lambda **kw: 0) + with pytest.raises(ValueError) as exc: + count_tokens(model="gpt-4o", text="hi", file="/tmp/x") + assert "mutually exclusive" in str(exc.value).lower() + + +def test_count_tokens_requires_some_input(): + """No input source at all is a usage error.""" + with pytest.raises(ValueError) as exc: + count_tokens(model="gpt-4o") + assert "--text" in str(exc.value) + + +def test_count_tokens_requires_model(): + """Empty model is rejected at the helper level.""" + with pytest.raises(ValueError) as exc: + count_tokens(model="", text="hi") + assert "model is required" in str(exc.value) + + +def test_count_tokens_rejects_invalid_messages_json(monkeypatch): + """Malformed JSON in --messages surfaces as ValueError.""" + monkeypatch.setattr("litellm.token_counter", lambda **kw: 0) + with pytest.raises(ValueError) as exc: + count_tokens(model="gpt-4o", messages="{not json") + assert "json" in str(exc.value).lower() + + +def test_count_tokens_rejects_non_list_messages(monkeypatch): + """A JSON object (not a list) for --messages is rejected.""" + monkeypatch.setattr("litellm.token_counter", lambda **kw: 0) + with pytest.raises(ValueError) as exc: + count_tokens(model="gpt-4o", messages='{"role": "user"}') + assert "list" in str(exc.value).lower() + + +def test_count_tokens_missing_file(monkeypatch, tmp_path: Path): + """A --file path that does not exist surfaces as ValueError.""" + missing = tmp_path / "does-not-exist.txt" + monkeypatch.setattr("litellm.token_counter", lambda **kw: 0) + with pytest.raises(ValueError) as exc: + count_tokens(model="gpt-4o", file=str(missing)) + assert "does not exist" in str(exc.value) + + +def test_count_tokens_wraps_token_counter_failure(monkeypatch): + """Underlying token_counter failures surface as RuntimeError.""" + + def _raise(**kw): + raise RuntimeError("simulated tokenizer failure") + + monkeypatch.setattr("litellm.token_counter", _raise) + with pytest.raises(RuntimeError) as exc: + count_tokens(model="gpt-4o", text="hi") + assert "simulated tokenizer failure" in str(exc.value) + + +def test_to_jsonable_round_trip(): + result = TokenCount(model="gpt-4o", mode="text", count=42) + assert json.loads(json.dumps(result.to_jsonable())) == { + "model": "gpt-4o", + "mode": "text", + "count": 42, + } + + +def test_cli_text_runs_and_prints_count(monkeypatch): + monkeypatch.setattr("litellm.token_counter", lambda **kw: 5) + runner = CliRunner() + result = runner.invoke(cli, ["--model", "gpt-4o", "--text", "hello world"]) + assert result.exit_code == 0, result.output + assert "count=5" in result.output + assert "model=gpt-4o" in result.output + assert "mode=text" in result.output + + +def test_cli_json_output_is_valid_json(monkeypatch): + monkeypatch.setattr("litellm.token_counter", lambda **kw: 12) + runner = CliRunner() + result = runner.invoke(cli, ["--model", "gpt-4o", "--text", "hi", "--json"]) + assert result.exit_code == 0, result.output + payload = json.loads(result.output.strip()) + assert payload == {"model": "gpt-4o", "mode": "text", "count": 12} + + +def test_cli_file_reads_from_disk(monkeypatch, tmp_path: Path): + p = tmp_path / "p.txt" + p.write_text("disk contents", encoding="utf-8") + monkeypatch.setattr("litellm.token_counter", lambda **kw: 9) + runner = CliRunner() + result = runner.invoke(cli, ["--model", "gpt-4o", "--file", str(p)]) + assert result.exit_code == 0, result.output + assert "count=9" in result.output + assert "mode=file" in result.output + + +def test_cli_messages_parses_json(monkeypatch): + monkeypatch.setattr("litellm.token_counter", lambda **kw: 3) + runner = CliRunner() + messages = '[{"role":"user","content":"hi"}]' + result = runner.invoke(cli, ["--model", "gpt-4o", "--messages", messages, "--json"]) + assert result.exit_code == 0, result.output + payload = json.loads(result.output.strip()) + assert payload == {"model": "gpt-4o", "mode": "messages", "count": 3} + + +def test_cli_mutually_exclusive_flags_exit_2(monkeypatch): + monkeypatch.setattr("litellm.token_counter", lambda **kw: 0) + runner = CliRunner() + result = runner.invoke(cli, ["--model", "gpt-4o", "--text", "hi", "--file", "/tmp/x"]) + assert result.exit_code == 2 + assert "mutually exclusive" in result.output.lower() + + +def test_cli_missing_input_exits_2(monkeypatch): + monkeypatch.setattr("litellm.token_counter", lambda **kw: 0) + runner = CliRunner() + result = runner.invoke(cli, ["--model", "gpt-4o"]) + assert result.exit_code == 2 + assert "--text" in result.output + + +def test_cli_missing_model_exits_2(monkeypatch): + monkeypatch.setattr("litellm.token_counter", lambda **kw: 0) + runner = CliRunner() + result = runner.invoke(cli, ["--text", "hi"]) + assert result.exit_code == 2 + + +def test_cli_invalid_messages_json_exits_2(monkeypatch): + monkeypatch.setattr("litellm.token_counter", lambda **kw: 0) + runner = CliRunner() + result = runner.invoke(cli, ["--model", "gpt-4o", "--messages", "{not json"]) + assert result.exit_code == 2 + assert "json" in result.output.lower() + + +def test_cli_non_list_messages_exits_2(monkeypatch): + monkeypatch.setattr("litellm.token_counter", lambda **kw: 0) + runner = CliRunner() + result = runner.invoke(cli, ["--model", "gpt-4o", "--messages", '{"role":"user"}']) + assert result.exit_code == 2 + assert "list" in result.output.lower() + + +def test_cli_missing_file_exits_2(monkeypatch, tmp_path: Path): + monkeypatch.setattr("litellm.token_counter", lambda **kw: 0) + missing = tmp_path / "absent.txt" + runner = CliRunner() + result = runner.invoke(cli, ["--model", "gpt-4o", "--file", str(missing)]) + assert result.exit_code == 2 + assert "does not exist" in result.output + + +def test_cli_does_not_echo_input_text(monkeypatch): + """Privacy property: the input text must never appear in stdout.""" + monkeypatch.setattr("litellm.token_counter", lambda **kw: 1) + secret = "patient-zero-secret-marker-XYZ-9182" + runner = CliRunner() + result = runner.invoke(cli, ["--model", "gpt-4o", "--text", secret, "--json"]) + assert result.exit_code == 0, result.output + assert secret not in result.output, ( + "token-count printed the input text to stdout; the CLI should only print the count" + ) + payload = json.loads(result.output.strip()) + assert payload["count"] == 1 + assert payload["mode"] == "text" + + +def test_cli_does_not_echo_messages_payload(monkeypatch): + """Privacy property: the messages JSON must never appear in stdout.""" + monkeypatch.setattr("litellm.token_counter", lambda **kw: 2) + secret = "ssn-123-45-6789-dont-leak" + messages = json.dumps([{"role": "user", "content": secret}]) + runner = CliRunner() + result = runner.invoke(cli, ["--model", "gpt-4o", "--messages", messages, "--json"]) + assert result.exit_code == 0, result.output + assert secret not in result.output + assert messages not in result.output From de6c143739677340819bbe17b3502d74eca92820 Mon Sep 17 00:00:00 2001 From: Harsh23Kashyap <55448981+Harsh23Kashyap@users.noreply.github.com> Date: Mon, 27 Jul 2026 17:00:45 +0530 Subject: [PATCH 3/5] fix(cli): drop TypedDict + cast from token_count to stay within ruff-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. --- litellm/cli/token_count.py | 48 ++++++++++++++------------------------ 1 file changed, 17 insertions(+), 31 deletions(-) diff --git a/litellm/cli/token_count.py b/litellm/cli/token_count.py index 22ac8415393..86add8896a8 100644 --- a/litellm/cli/token_count.py +++ b/litellm/cli/token_count.py @@ -12,26 +12,13 @@ import json import sys from dataclasses import asdict, dataclass -from typing import Any, Literal, Optional, TypedDict, cast +from typing import Any, Literal import click Mode = Literal["text", "file", "messages"] -class TokenCountMessage(TypedDict, total=False): - """A single message accepted by ``litellm.token_counter(messages=...)``. - - Only ``role`` and ``content`` are required by the underlying - counter; other fields are passed through and may be ignored for - counting purposes. Marked ``total=False`` so callers can omit - fields they don't need. - """ - - role: str - content: Any - - @dataclass(frozen=True) class TokenCount: """Result of a token-count run, suitable for both human and JSON output.""" @@ -53,12 +40,12 @@ def _read_text_file(path: str) -> str: return f.read() -def _resolve_messages_payload(value: str) -> list[TokenCountMessage]: +def _resolve_messages_payload(value: str) -> list[Any]: """Parse the --messages argument (raw JSON string or '-' for stdin) into a list. - Returns a typed list of message dicts so the downstream - ``token_counter(messages=...)`` call does not propagate - ``Unknown`` into the call site. + The downstream ``token_counter(messages=...)`` call accepts a + list of OpenAI-style message dicts; the JSON-decoded list is + structurally compatible without further validation. """ raw = _read_stdin() if value == "-" else value try: @@ -67,14 +54,14 @@ def _resolve_messages_payload(value: str) -> list[TokenCountMessage]: raise click.UsageError(f"--messages must be valid JSON: {exc}") from exc if not isinstance(parsed, list): raise click.UsageError("--messages must be a JSON list of message objects.") - return cast(list[TokenCountMessage], parsed) + return parsed def _resolve_input( *, - text: Optional[str], - file: Optional[str], - messages: Optional[str], + text: str | None, + file: str | None, + messages: str | None, ) -> tuple[Mode, Any]: """Pick the right input-source option and return (mode, payload). @@ -83,7 +70,7 @@ def _resolve_input( - ``text`` -> the raw string - ``file`` -> the file contents (already read) - - ``messages`` -> the parsed JSON list (typed as ``list[TokenCountMessage]``) + - ``messages`` -> the parsed JSON list Raises ``click.UsageError`` on invalid combinations so the CLI surfaces exit 2 instead of a Python traceback. @@ -106,16 +93,15 @@ def _resolve_input( raise click.UsageError(f"--file {file!r} does not exist.") from exc except OSError as exc: raise click.UsageError(f"--file {file!r} could not be read: {exc}") from exc - parsed = _resolve_messages_payload(messages) # type: ignore[arg-type] - return "messages", parsed + return "messages", _resolve_messages_payload(messages) # type: ignore[arg-type] def count_tokens( model: str, *, - text: Optional[str] = None, - file: Optional[str] = None, - messages: Optional[str] = None, + text: str | None = None, + file: str | None = None, + messages: str | None = None, ) -> TokenCount: """Public helper for programmatic use. Returns a ``TokenCount``. @@ -184,9 +170,9 @@ def _render_line(result: TokenCount) -> str: ) def cli( model: str, - text: Optional[str], - file_path: Optional[str], - messages_json: Optional[str], + text: str | None, + file_path: str | None, + messages_json: str | None, output_json: bool, ) -> None: """Count tokens for a single prompt against a model tokenizer.""" From c347e022e14446374a5d4c1720b9c4bee46c188d Mon Sep 17 00:00:00 2001 From: Harsh23Kashyap <55448981+Harsh23Kashyap@users.noreply.github.com> Date: Mon, 27 Jul 2026 17:06:58 +0530 Subject: [PATCH 4/5] fix(cli): replace banned type: ignore in token_count with runtime assert 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. --- litellm/cli/token_count.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/litellm/cli/token_count.py b/litellm/cli/token_count.py index 86add8896a8..765e04499b3 100644 --- a/litellm/cli/token_count.py +++ b/litellm/cli/token_count.py @@ -93,7 +93,9 @@ def _resolve_input( raise click.UsageError(f"--file {file!r} does not exist.") from exc except OSError as exc: raise click.UsageError(f"--file {file!r} could not be read: {exc}") from exc - return "messages", _resolve_messages_payload(messages) # type: ignore[arg-type] + # by this point `provided` contains "messages" and text/file are None + assert messages is not None + return "messages", _resolve_messages_payload(messages) def count_tokens( From 66d0e7965aeb5099f6274ea2575178dcbdab2de7 Mon Sep 17 00:00:00 2001 From: Harsh23Kashyap <55448981+Harsh23Kashyap@users.noreply.github.com> Date: Mon, 27 Jul 2026 17:19:07 +0530 Subject: [PATCH 5/5] ci(misc): include tests/test_litellm/cli in the misc unit-test job 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. --- .github/workflows/test-unit-misc.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/test-unit-misc.yml b/.github/workflows/test-unit-misc.yml index 7c3b195f0ad..55c9d56a206 100644 --- a/.github/workflows/test-unit-misc.yml +++ b/.github/workflows/test-unit-misc.yml @@ -23,6 +23,7 @@ jobs: with: test-path: >- tests/test_litellm/batches + tests/test_litellm/cli tests/test_litellm/secret_managers tests/test_litellm/a2a_protocol tests/test_litellm/anthropic_interface