From 6f092c0b6b1693f41cb5766f7deef22317204fbb Mon Sep 17 00:00:00 2001 From: Delweng Date: Tue, 13 Oct 2026 14:04:20 +0800 Subject: [PATCH 1/3] test(token_counter): cover array tool params with no items schema An MCP tool can declare {"type": "array"} with no items sub-schema, and JSON Schema permits it, so token_counter must not index a missing key. Parametrizes the three tool shapes that reach _format_type and asserts the count matches the equivalent empty-items tool, plus the rendering itself and the non-object items forms draft 4 and draft 6 allow Signed-off-by: Delweng --- .../litellm_core_utils/test_token_counter.py | 68 ++++++++++++++++++- 1 file changed, 67 insertions(+), 1 deletion(-) diff --git a/tests/unit/litellm_core_utils/test_token_counter.py b/tests/unit/litellm_core_utils/test_token_counter.py index 84c139e867b..07628745246 100644 --- a/tests/unit/litellm_core_utils/test_token_counter.py +++ b/tests/unit/litellm_core_utils/test_token_counter.py @@ -9,7 +9,7 @@ import subprocess import sys import threading import time -from collections.abc import Mapping +from collections.abc import Callable, Mapping, Sequence from concurrent.futures import Future, wait from itertools import accumulate, chain from pathlib import Path @@ -31,6 +31,7 @@ from litellm.constants import TOKEN_COUNTER_MAX_CONCURRENT_COUNTS from litellm.litellm_core_utils.asyncify import asyncify from litellm.litellm_core_utils.token_counter import ( _encoding_count, + _format_type, _get_exact_count_function, _get_extrapolating_count_function, _get_tiktoken_count_function, @@ -515,6 +516,71 @@ def test_token_counter_skips_non_mapping_tools(): assert mixed_tokens == valid_tokens +def _openai_tool_with_properties(properties: Mapping[str, object]) -> Sequence[Mapping[str, object]]: + return ( + { + "type": "function", + "function": { + "name": "list_things", + "description": "List the things", + "parameters": {"type": "object", "properties": properties}, + }, + }, + ) + + +def _anthropic_tool_with_properties(properties: Mapping[str, object]) -> Sequence[Mapping[str, object]]: + return ( + { + "name": "list_things", + "description": "List the things", + "input_schema": {"type": "object", "properties": properties}, + }, + ) + + +def _object_nested_tool_with_properties(properties: Mapping[str, object]) -> Sequence[Mapping[str, object]]: + return _openai_tool_with_properties({"group": {"type": "object", "properties": properties}}) + + +@pytest.mark.parametrize( + "tool_shape", + [ + pytest.param(_openai_tool_with_properties, id="openai_function_parameters"), + pytest.param(_anthropic_tool_with_properties, id="anthropic_input_schema"), + pytest.param(_object_nested_tool_with_properties, id="array_nested_in_object_property"), + ], +) +def test_token_counter_counts_array_parameter_without_items_schema( + tool_shape: Callable[[Mapping[str, object]], Sequence[Mapping[str, object]]], +): + messages: Final = [{"role": "user", "content": "What's on the list?"}] + omitted_items: Final = tool_shape({"things": {"type": "array", "description": "The things on the list"}}) + empty_items: Final = tool_shape( + {"things": {"type": "array", "description": "The things on the list", "items": {}}} + ) + + assert token_counter_new( + model="gpt-4o", messages=messages, tools=omitted_items + ) == token_counter_new(model="gpt-4o", messages=messages, tools=empty_items) + + +def test_format_type_renders_array_without_items_like_empty_items(): + assert _format_type({"type": "array"}, 0) == _format_type({"type": "array", "items": {}}, 0) == "any[]" + + +@pytest.mark.parametrize( + "items", + [ + pytest.param([{"type": "string"}], id="draft4_tuple_validation"), + pytest.param(True, id="draft6_boolean_true_schema"), + pytest.param(False, id="draft6_boolean_false_schema"), + ], +) +def test_format_type_renders_non_object_array_items_as_any_array(items: object): + assert _format_type({"type": "array", "items": items}, 0) == "any[]" + + class NeedsToleranceUpdateError(Exception): """Custom exception to mark tests that have improved""" From f1efc877406e85c3e774c3d5ac5b1771c361c725 Mon Sep 17 00:00:00 2001 From: Delweng Date: Tue, 13 Oct 2026 15:15:29 +0800 Subject: [PATCH 2/3] fix(token_counter): count array tool params without an items schema JSON Schema lets an array omit items and MCP tool definitions do exactly that, so indexing props['items'] raised KeyError and turned a token count into a 500 on POST /v1/messages/count_tokens. Render a missing items as the same any[] an empty items already produced, and drop the comment claiming items was required. The new guard keeps a non-object items, which draft 4 tuple validation and draft 6 boolean schemas both allow, rendering as any instead of raising AttributeError Signed-off-by: Delweng --- litellm/litellm_core_utils/token_counter.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/litellm/litellm_core_utils/token_counter.py b/litellm/litellm_core_utils/token_counter.py index 7ac3e9c80c5..813b03ba24b 100644 --- a/litellm/litellm_core_utils/token_counter.py +++ b/litellm/litellm_core_utils/token_counter.py @@ -1104,13 +1104,16 @@ def _format_object_parameters(parameters, indent): def _format_type(props, indent): + if not isinstance(props, Mapping): + return "any" type: Final = props.get("type") if type == "string": if "enum" in props: return " | ".join([f'"{item}"' for item in props["enum"]]) return "string" elif type == "array": - # items is required, OpenAI throws an error if it's missing + if "items" not in props: + return f"{_format_type({}, indent)}[]" return f"{_format_type(props['items'], indent)}[]" elif type == "object": return f"{{\n{_format_object_parameters(props, indent + 2)}\n}}" From 8e721f0647f7338ffe5b6af9579470c5ed5dc5da Mon Sep 17 00:00:00 2001 From: Delweng Date: Tue, 13 Oct 2026 15:46:28 +0800 Subject: [PATCH 3/3] fix(token_counter): return a constant for an array param with no items The previous commit reached the missing items case by recursing on an empty dict. That dict has no type key, so _format_type falls through to its else branch and returns the literal any, which makes the recursive call indistinguishable from the constant while handing an untyped empty dict to an unannotated parameter basedpyright counts that call as reportUnknownArgumentType, and scripts/type_check_gate.py fails any rule whose codebase-wide total grows past its merge-base count, with only reportAny and reportExplicitAny exempt. The codebase-wide total for this file now sits one below the merge-base count at 9621a60d27 instead of matching it, and the self-recursion is gone Signed-off-by: Delweng --- litellm/litellm_core_utils/token_counter.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/litellm/litellm_core_utils/token_counter.py b/litellm/litellm_core_utils/token_counter.py index 813b03ba24b..8d470345e39 100644 --- a/litellm/litellm_core_utils/token_counter.py +++ b/litellm/litellm_core_utils/token_counter.py @@ -1113,7 +1113,7 @@ def _format_type(props, indent): return "string" elif type == "array": if "items" not in props: - return f"{_format_type({}, indent)}[]" + return "any[]" return f"{_format_type(props['items'], indent)}[]" elif type == "object": return f"{{\n{_format_object_parameters(props, indent + 2)}\n}}"