fix(proxy): count the request's tool_choice on the token count routes (#51133)

* fix(proxy): count the request's tool_choice on the token count routes

/utils/token_counter, /v1/messages/count_tokens and /v1/responses/input_tokens now pass tool_choice into the local estimate, and every DeepSeek model counts it as 0, as its providers bill

* fix(token_counter): count a request with no model name again

* fix(token_counter): count grok tool_choice the way xAI bills it

xAI bills grok-4.3 and grok-4-1-fast-reasoning 21 tokens for "none", 11 for
"required" and 12 plus the name for a named tool, where the OpenAI costs
counted 1, 0 and 7 plus the name. The grok-4-1 and grok-4.3 chat template
tool sections now carry those costs, so a grok count matches its bill for
every tool_choice

* fix(token_counter): count xAI's grok tool_choice costs only where xAI serves grok

Azure AI Foundry bills grok-4-1's tool_choice differently, so grok on other hosts keeps counting it as 0. A tool_choice sent without tools counts nothing, since OpenAI and xAI reject it

* fix(token_counter): key xAI's grok tool_choice costs on the provider that serves the deployment

The count routes strip the provider prefix before counting, so Azure, Vertex AI and Perplexity grok matched the xAI name pattern. The routes now resolve the serving provider with get_llm_provider and drop xAI's costs when it is not xAI or OpenRouter's x-ai route. The costs move onto the grok tool sections so one pattern decides them

* fix(token_counter): count no tool_choice for Gemini and none without tools in the Rust counter

Gemini bills the same prompt tokens for every tool_choice, so it skips the
OpenAI choice costs the way DeepSeek does. The Rust reservation counter now
adds tool_choice tokens only when tools are present, matching the Python counter

* fix(proxy): count no tool_choice a deployment drops, and treat an api.x.ai api_base as xAI

A deployment that lists tool_choice in additional_drop_params never sends it, so the count routes no longer add its cost. An openai/ model whose api_base is on api.x.ai is served by xAI, so it gets xAI's tool_choice costs like xai/ grok

* fix(proxy): count no tool_choice a request drops in its body

/v1/messages/count_tokens takes additional_drop_params from the request body, and inference drops tool_choice when it is listed there, so the count no longer adds its cost either

* fix(proxy): take additional_drop_params on /utils/token_counter and treat a malformed api_base as not xAI

/utils/token_counter now passes a request body's additional_drop_params into the count, so a tool_choice the request drops counts as 0 there like on the other two count routes. An api_base that does not parse falls back to the deployment's provider instead of failing the count with a 500

* fix(proxy): take chatgpt and github_copilot providers from the model name in the count host check

Resolving them through get_llm_provider read the saved login, logged a warning and could refresh a token on every count with a tool_choice

* fix(proxy): skip a tool_choice or additional_drop_params too deeply nested to validate on the count routes

A value nested 300 levels deep failed TokenCountRequest validation, so the count routes answered 422 or 400 where they used to count the request without it

* fix(bedrock): count Kimi K3's forced tool_choice message on the exact count path

Kimi K3's chat template adds a tool-choice system message when Bedrock gets toolChoice any or a named tool, and Bedrock bills it (170 input tokens unforced, 206 forced). The exact count path now passes tool_choice through the same Converse mapping inference uses and renders that message

* test(proxy): type the Kimi K3 count test's tools parameter

* perf(count_tokens): validate each kept count option once instead of twice

* fix(dot_notation): parse drop paths in linear time when brackets never close

* test(count_tokens): benchmark the unclosed-bracket drop path instead of timing it in a unit test

---------

Co-authored-by: mateo-berri <277851410+mateo-berri@users.noreply.github.com>
This commit is contained in:
tavren27 2026-11-14 04:53:27 -08:00 • committed by GitHub
parent 8b28818fa2
commit 27c796c412
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
18 changed files with 785 additions and 70 deletions

View file

@ -102,8 +102,8 @@ def _parse_path_segments(path: str) -> list:
import re
# Match field names OR bracket expressions
# Pattern: field_name (anything except . or [) | [anything_in_brackets]
pattern: Final = r"[^\.\[]+|\[[^\]]*\]"
# Pattern: field_name (anything except . or [) | [anything_in_brackets except [ or ]]
pattern: Final = r"[^\.\[]+|\[[^\]\[]*\]"
segments: Final = re.findall(pattern, path)
return segments

View file

@ -5,13 +5,25 @@ from dataclasses import dataclass
from typing import Final
@dataclass(frozen=True, slots=True)
class ToolChoiceTokens:
none: int
required: int
named: int
OPENAI_TOOL_CHOICE_TOKENS: Final = ToolChoiceTokens(none=1, required=0, named=7)
_XAI_GROK_TOOL_CHOICE_TOKENS: Final = ToolChoiceTokens(none=21, required=11, named=12)
_SERVED_BY_XAI: Final = re.compile(r"^(?:xai/|(?:openrouter/)?x-ai/)?[^/]+$", re.IGNORECASE)
@dataclass(frozen=True, slots=True)
class ChatTemplateToolSection:
preamble_tokens: int
implicit_system_turn_tokens: int
render_tool_schemas: Callable[[Sequence[Mapping[str, object]]], str]
counts_tool_choice: bool = False
renders_strict_only_when_true: bool = False
xai_tool_choice_tokens: ToolChoiceTokens | None = None
def _json_line_tool_schemas(functions: Sequence[Mapping[str, object]]) -> str:
@ -43,13 +55,21 @@ _TOOL_SECTIONS: Final = (
(
re.compile(r"grok-4[-.]1(?![.\d])", re.IGNORECASE),
ChatTemplateToolSection(
0, 0, _compact_json_line_tool_schemas, counts_tool_choice=True, renders_strict_only_when_true=True
0,
0,
_compact_json_line_tool_schemas,
renders_strict_only_when_true=True,
xai_tool_choice_tokens=_XAI_GROK_TOOL_CHOICE_TOKENS,
),
),
(
re.compile(r"grok-4[-.]3(?![.\d])", re.IGNORECASE),
ChatTemplateToolSection(
0, 0, _json_line_tool_schemas, counts_tool_choice=True, renders_strict_only_when_true=True
0,
0,
_json_line_tool_schemas,
renders_strict_only_when_true=True,
xai_tool_choice_tokens=_XAI_GROK_TOOL_CHOICE_TOKENS,
),
),
)
@ -59,3 +79,7 @@ def chat_template_tool_section(model: str | None) -> ChatTemplateToolSection | N
if not model:
return None
return next((section for pattern, section in _TOOL_SECTIONS if pattern.search(model)), None)
def chat_template_tool_choice_tokens(model: str, section: ChatTemplateToolSection) -> ToolChoiceTokens | None:
return section.xai_tool_choice_tokens if _SERVED_BY_XAI.match(model) else None

View file

@ -50,7 +50,10 @@ from litellm.constants import (
from litellm.litellm_core_utils.asyncify import asyncify
from litellm.litellm_core_utils.core_helpers import as_object_list, as_str_mapping
from litellm.litellm_core_utils.prompt_templates.chat_template_tools import (
OPENAI_TOOL_CHOICE_TOKENS,
ChatTemplateToolSection,
ToolChoiceTokens,
chat_template_tool_choice_tokens,
chat_template_tool_section,
)
from litellm.litellm_core_utils.tokenizer import HuggingFaceTokenizer, OpenAIEncoding
@ -816,7 +819,7 @@ class _MessageCountParams:
model: str,
custom_tokenizer: dict | SelectTokenizerResponse | None,
):
from litellm.utils import print_verbose
from litellm.utils import huggingface_tokenizer_kind, print_verbose
actual_model: Final = _fix_model_name(model)
if uses_legacy_message_accounting(model):
@ -831,6 +834,12 @@ class _MessageCountParams:
self.tokens_per_name = 1
self.count_function = _get_count_function(model, custom_tokenizer)
self.tool_section = chat_template_tool_section(model)
self.tool_choice_tokens = _tool_choice_tokens(
model or "",
self.tool_section,
bills_no_tool_choice=huggingface_tokenizer_kind(model or "") == "deepseek"
or "gemini" in (model or "").lower(),
)
self._model = model
@cached_property
@ -838,6 +847,16 @@ class _MessageCountParams:
return pdf_page_pricing(self._model)
def _tool_choice_tokens(
model: str, tool_section: ChatTemplateToolSection | None, bills_no_tool_choice: bool
) -> ToolChoiceTokens | None:
if tool_section is not None:
return chat_template_tool_choice_tokens(model, tool_section)
if bills_no_tool_choice:
return None
return OPENAI_TOOL_CHOICE_TOKENS
def _is_openai_chat_model(model: str) -> bool:
return model in litellm.open_ai_chat_completion_models or model in litellm.azure_llms
@ -951,7 +970,12 @@ def token_counter(
if count_response_tokens is False:
includes_system_message: Final = any(message.get("role", None) == "system" for message in new_messages)
num_tokens += _count_extra(
params.count_function, tools, tool_choice, includes_system_message, params.tool_section
params.count_function,
tools,
tool_choice,
includes_system_message,
params.tool_section,
params.tool_choice_tokens,
)
else:
@ -987,7 +1011,9 @@ def messages_reach_token_count(
)
running_totals: Final = accumulate(
per_message_counts,
initial=_count_extra(params.count_function, tools, None, includes_system_message, params.tool_section),
initial=_count_extra(
params.count_function, tools, None, includes_system_message, params.tool_section, params.tool_choice_tokens
),
)
return any(total >= threshold for total in running_totals)
@ -1101,6 +1127,7 @@ def _count_extra(
tool_choice: str | Mapping[str, object] | None,
includes_system_message: bool,
tool_section: ChatTemplateToolSection | None,
tool_choice_tokens: ToolChoiceTokens | None,
) -> int:
"""Count extra tokens for function definitions and tool choices.
Args:
@ -1109,17 +1136,13 @@ def _count_extra(
tool_choice (Optional[ChatCompletionNamedToolChoiceParam]): The tool choice.
includes_system_message (bool): Whether the messages include a system message.
tool_section (Optional[ChatTemplateToolSection]): How the model's chat template renders tools, if known.
tool_choice_tokens (Optional[ToolChoiceTokens]): What the model's chat template adds per tool choice, if any.
"""
reply_primer_tokens: Final = 3
tool_choice_tokens: Final = (
_count_tool_choice(count_function, tool_choice)
if tool_section is None or tool_section.counts_tool_choice
else 0
)
return (
reply_primer_tokens
+ _count_tool_definitions(count_function, tools, includes_system_message, tool_section)
+ tool_choice_tokens
+ (_count_tool_choice(count_function, tool_choice, tool_choice_tokens) if tools else 0)
)
@ -1140,16 +1163,17 @@ def _count_tool_definitions(
def _count_tool_choice(
count_function: TokenCounterFunction,
tool_choice: str | Mapping[str, object] | None,
tool_choice_tokens: ToolChoiceTokens | None,
) -> int:
if tool_choice_tokens is None:
return 0
match tool_choice:
case "none":
return 1
case {"function": {"name": str() as name}}:
return 7 + count_function(name)
case {"type": "none"}:
return 1
case {"name": str() as name}:
return 7 + count_function(name)
case "none" | {"type": "none"}:
return tool_choice_tokens.none
case "required" | {"type": "any"}:
return tool_choice_tokens.required
case {"function": {"name": str() as name}} | {"name": str() as name}:
return tool_choice_tokens.named + count_function(name)
case _:
return 0

View file

@ -5,7 +5,7 @@ This module handles the transformation of requests to Anthropic's CountTokens AP
"""
import copy
from collections.abc import Callable, Mapping, Sequence
from collections.abc import Callable, Iterator, Mapping, Sequence
from dataclasses import dataclass
from functools import partial
from itertools import chain
@ -18,7 +18,7 @@ from typing import (
cast, # noqa: TID251 # caller chat messages go to chat inference translation as-is
)
from pydantic import ConfigDict, JsonValue, TypeAdapter, with_config
from pydantic import ConfigDict, JsonValue, TypeAdapter, ValidationError, with_config
from typing_extensions import NotRequired, ReadOnly, TypedDict
from litellm.constants import ANTHROPIC_TOKEN_COUNTING_BETA_VERSION, DEFAULT_ANTHROPIC_CHAT_MAX_TOKENS
@ -48,6 +48,7 @@ if TYPE_CHECKING:
from litellm.llms.anthropic.messages.native.transformation import AnthropicMessagesConfig
_COUNT_REQUEST: Final = TypeAdapter(dict[str, JsonValue])
_COUNT_OPTION: Final[TypeAdapter[JsonValue]] = TypeAdapter(JsonValue)
_COUNTED_MESSAGES: Final = TypeAdapter(list[dict[str, JsonValue]])
_TRANSFORMED_REQUEST: Final = TypeAdapter(dict[str, object])
_DROPPED_PATHS: Final = TypeAdapter(tuple[object, ...])
@ -417,19 +418,26 @@ def _max_output_tokens(model: str, custom_llm_provider: str | None) -> int:
)
def _json_options(options: Mapping[str, object]) -> Iterator[tuple[str, JsonValue]]:
for name, value in options.items():
try:
yield name, _COUNT_OPTION.validate_python(value)
except ValidationError:
continue
def without_dropped_paths(options: Mapping[str, JsonValue], additional_drop_params: object) -> Mapping[str, JsonValue]:
return _COUNT_REQUEST.validate_python(
delete_nested_values(
options,
(
path
for path in _DROPPED_PATHS.validate_python(
additional_drop_params if isinstance(additional_drop_params, (list, tuple)) else ()
)
if isinstance(path, str)
),
)
kept: Final = delete_nested_values(
options,
(
path
for path in _DROPPED_PATHS.validate_python(
additional_drop_params if isinstance(additional_drop_params, (list, tuple)) else ()
)
if isinstance(path, str)
),
)
return dict(_json_options(kept))
def messages_count_options(

View file

@ -246,6 +246,10 @@ class _ToolSpecBlock(_Converse):
class _ToolConfig(BaseModel):
model_config = ConfigDict(frozen=True, extra="ignore")
tools: tuple[_ToolSpecBlock | _CachePoint, ...]
tool_choice: Mapping[str, JsonValue] | None = Field(default=None, alias="toolChoice")
def forces_a_tool(self) -> bool:
return self.tool_choice is not None and not self.tool_choice.keys().isdisjoint({"any", "tool"})
_Message: TypeAlias = Annotated[_UserMessage | _AssistantMessage, Field(discriminator="role")]
@ -303,6 +307,12 @@ def _message(attrs: Iterable[tuple[str, str]], body: Iterable[_Segment]) -> _Seg
return (*_open("message", attrs), *body, *_close("message"), _Control.END_OF_MSG)
_FORCED_TOOL_CHOICE: Final = _message(
(("role", "system"), ("type", "tool-choice")),
("The system is invoked with `tool_choice=required`.\nYou MUST call tools in the next message.",),
)
def _tool_declare(specs: Sequence[_ToolSpec]) -> _Segments:
tools: Final = json.dumps(
[{"type": "function", "function": spec.declared()} for spec in specs],
@ -413,10 +423,12 @@ def _rendered(request: _ConverseRequest) -> _Segments:
turns: Final = chain.from_iterable(
_turn(previous, message) for previous, message in zip((None, *request.messages), request.messages, strict=False)
)
forces_a_tool: Final = request.tool_config is not None and request.tool_config.forces_a_tool()
return (
*(_tool_declare(specs) if specs else ()),
*(_message((("role", "system"),), (system,)) if system else ()),
*turns,
*(_FORCED_TOOL_CHOICE if forces_a_tool else ()),
*_open("message", (("role", "assistant"),)),
*_open("think"),
)
@ -450,17 +462,29 @@ def _as_converse_input(tool: ChatCompletionToolParam) -> tuple[Mapping[str, obje
def _converse_request(
model: str, messages: Sequence[AllMessageValues], tools: Sequence[ChatCompletionToolParam] | None
model: str,
messages: Sequence[AllMessageValues],
tools: Sequence[ChatCompletionToolParam] | None,
tool_choice: str | Mapping[str, object] | None,
) -> _ConverseRequest | None:
if not all(_text_only(message) for message in messages):
return None
try:
config: Final = litellm.AmazonConverseConfig()
chat_tools: Final = tuple(chain.from_iterable(_as_converse_input(tool) for tool in tools or ()))
choice: Final = (
config.map_tool_choice_values(model, tool_choice, drop_params=True)
if chat_tools and tool_choice is not None
else None
)
return _ConverseRequest.model_validate(
litellm.AmazonConverseConfig().transform_request(
config.transform_request(
model=model,
messages=copy.deepcopy(list(messages)),
optional_params={"tools": copy.deepcopy(list(chat_tools))} if chat_tools else {},
optional_params={
**({"tools": copy.deepcopy(list(chat_tools))} if chat_tools else {}),
**({"tool_choice": choice} if choice is not None else {}),
},
litellm_params={},
headers={},
)
@ -474,8 +498,9 @@ def kimi_k3_tokens(
model: str,
messages: Sequence[AllMessageValues],
tools: Sequence[ChatCompletionToolParam] | None,
tool_choice: str | Mapping[str, object] | None = None,
) -> int | None:
request: Final = _converse_request(model, messages, tools)
request: Final = _converse_request(model, messages, tools, tool_choice)
if request is None:
return None
return sum(
@ -493,6 +518,8 @@ def kimi_k3_anthropic_tokens(encoding: tiktoken.Encoding, model: str, fields: Ma
except Exception: # noqa: BLE001 # a request the adapter cannot translate falls back to the estimate
return None
match translated:
case {"tools": tools, "tool_choice": tool_choice}:
return kimi_k3_tokens(encoding, model, translated["messages"], tools, tool_choice)
case {"tools": tools}:
return kimi_k3_tokens(encoding, model, translated["messages"], tools)
case _:

View file

@ -18,6 +18,9 @@ from pydantic import (
NonNegativeInt,
PositiveInt,
TypeAdapter,
ValidationError,
ValidatorFunctionWrapHandler,
WrapValidator,
field_validator,
model_validator,
)
@ -3617,6 +3620,13 @@ class LiteLLM_SpendLogs_ResponseObject(LiteLLMPydanticObjectBase):
response: list[LiteLLM_SpendLogs | Any] | None = None
def _none_when_invalid(value: object, handler: ValidatorFunctionWrapHandler) -> object:
try:
return handler(value)
except ValidationError:
return None
class TokenCountRequest(LiteLLMPydanticObjectBase):
model: str
prompt: str | None = None
@ -3631,10 +3641,11 @@ class TokenCountRequest(LiteLLMPydanticObjectBase):
"""
tools: list[dict] | None = None
tool_choice: Annotated[JsonValue, WrapValidator(_none_when_invalid)] = None
additional_drop_params: Annotated[JsonValue, WrapValidator(_none_when_invalid)] = None
system: Any | None = None
thinking: JsonValue = None
reasoning_effort: JsonValue = None
tool_choice: JsonValue = None
parallel_tool_calls: JsonValue = None
response_format: JsonValue = None
web_search_options: JsonValue = None

View file

@ -70,7 +70,7 @@ def _anthropic_error_json_response(exc: ProxyException, request: Request) -> JSO
return anthropic_handler_error_json_response(exc, request)
_TOKEN_COUNT_FIELDS: Final = ("model", "messages", "tools", "system")
_TOKEN_COUNT_FIELDS: Final = ("model", "messages", "tools", "tool_choice", "system")
def _token_count_request(fields: Mapping[str, object]) -> TokenCountRequest | ProxyException:

View file

@ -57,6 +57,7 @@ from typing import (
get_origin,
get_type_hints,
)
from urllib.parse import urlparse
import anyio
import anyio.lowlevel
@ -108,12 +109,17 @@ from litellm.litellm_core_utils.bug_report import (
bug_report_notice,
should_report_bug,
)
from litellm.litellm_core_utils.get_llm_provider_logic import declared_authenticating_provider
from litellm.litellm_core_utils.litellm_logging import (
_init_custom_logger_compatible_class,
initialize_sentry,
)
from litellm.litellm_core_utils.logging_worker import GLOBAL_LOGGING_WORKER, LoggingWorker
from litellm.litellm_core_utils.prompt_templates.chat_template_tools import chat_template_tool_section
from litellm.litellm_core_utils.prompt_templates.chat_template_tools import (
ChatTemplateToolSection,
chat_template_tool_choice_tokens,
chat_template_tool_section,
)
from litellm.litellm_core_utils.routing_common_utils import resolve_model_group_alias
from litellm.litellm_core_utils.routing_groups import parse_routing_groups
from litellm.litellm_core_utils.safe_json_dumps import safe_dumps
@ -373,6 +379,7 @@ from litellm.constants import (
SPEND_CAPTURE_RATE_CHECK_JOB_ID,
USER_SPEND_ALERTS_JOB_ID,
WEEKLY_SPEND_REPORT_JOB_ID,
XAI_API_BASE,
)
from litellm.exceptions import RejectedRequestError
from litellm.integrations.custom_guardrail import CustomGuardrail, ModifyResponseException
@ -15895,6 +15902,7 @@ async def _local_token_estimate(
prompt: str | None,
messages: Sequence[AllMessageValues] | None,
tools: list[ChatCompletionToolParam] | None,
tool_choice: str | Mapping[str, object] | None,
custom_tokenizer: CustomHuggingfaceTokenizer | None,
provider_failure: ProviderCountFailure | None,
) -> _LocalTokenEstimate:
@ -15908,6 +15916,7 @@ async def _local_token_estimate(
text=prompt,
messages=messages,
tools=tools,
tool_choice=tool_choice,
custom_tokenizer=tokenizer,
)
total_tokens: Final = await (
@ -15927,17 +15936,19 @@ async def _kimi_k3_count(
model: str,
messages: Sequence[AllMessageValues],
tools: Sequence[ChatCompletionToolParam] | None,
tool_choice: str | Mapping[str, object] | None,
) -> int | None:
encoding: Final = await _KIMI_K3_ENCODINGS.encoding(TOKENIZER_LOAD_WAIT_SECONDS)
if encoding is None:
return None
match messages_format:
case "anthropic":
fields: Final = request.model_dump(include={"messages", "system", "tools"}, exclude_none=True)
return await offload_token_count(kimi_k3_anthropic_tokens)(
encoding, model, request.model_dump(include={"messages", "system", "tools"}, exclude_none=True)
encoding, model, fields if tool_choice is None else {**fields, "tool_choice": tool_choice}
)
case "openai":
return await offload_token_count(kimi_k3_tokens)(encoding, model, messages, tools)
return await offload_token_count(kimi_k3_tokens)(encoding, model, messages, tools, tool_choice)
class _TokenCountDeployment(TypedDict, total=False):
@ -16180,15 +16191,59 @@ def _deployment_api_base(
def _deployment_provider(
deployment: _TokenCountDeployment, request_model: str, responses_request: Mapping[str, object] | None
) -> str | None:
params: Final = deployment.get("litellm_params", {})
resolved: Final = _deployment_llm_provider(deployment, request_model, responses_request)
return None if resolved is None else resolved[1]
def _deployment_served_model(
deployment: _TokenCountDeployment, request_model: str, responses_request: Mapping[str, object] | None
) -> str | None:
resolved: Final = _deployment_llm_provider(deployment, request_model, responses_request)
if resolved is None:
return None
model, provider = resolved
served_by: Final = "xai" if _is_xai_api_base(_deployment_api_base(deployment, responses_request)) else provider
return f"{served_by}/{model}"
def _is_xai_api_base(api_base: str | None) -> bool:
if api_base is None:
return False
try:
return litellm.get_llm_provider(
model=_non_empty_str(params.get("model")) or request_model,
custom_llm_provider=_non_empty_str(params.get("custom_llm_provider")),
return urlparse(api_base).hostname == urlparse(XAI_API_BASE).hostname
except ValueError:
return False
def _deployment_llm_provider(
deployment: _TokenCountDeployment, request_model: str, responses_request: Mapping[str, object] | None
) -> tuple[str, str] | None:
params: Final = deployment.get("litellm_params", {})
model: Final = _non_empty_str(params.get("model")) or request_model
custom_llm_provider: Final = _non_empty_str(params.get("custom_llm_provider"))
declared: Final = declared_authenticating_provider(model, custom_llm_provider)
if declared is not None:
return model.removeprefix(f"{declared}/"), declared
try:
resolved_model, provider, _, _ = litellm.get_llm_provider(
model=model,
custom_llm_provider=custom_llm_provider,
api_base=_deployment_api_base(deployment, responses_request),
)[1]
)
except litellm.BadRequestError:
return None
return resolved_model, provider
def _xai_tool_choice_off_xai(
template_model: str, tool_section: ChatTemplateToolSection | None, served_model: str | None
) -> bool:
return (
tool_section is not None
and served_model is not None
and chat_template_tool_choice_tokens(template_model, tool_section) is not None
and chat_template_tool_choice_tokens(served_model, tool_section) is None
)
def _serves_native_responses(
@ -16253,7 +16308,16 @@ async def token_counter(
"""
call_id: Final = resolve_litellm_call_id(litellm_call_id)
return await answered_with_litellm_call_id(
count_request_tokens(request=request, call_endpoint=call_endpoint, litellm_call_id=call_id),
count_request_tokens(
request=request,
call_endpoint=call_endpoint,
request_litellm_params=MappingProxyType(
{}
if request.additional_drop_params is None
else {"additional_drop_params": request.additional_drop_params}
),
litellm_call_id=call_id,
),
call_id,
http_request,
fastapi_response,
@ -16578,6 +16642,17 @@ async def _count_request_tokens(
openai_model: Final = deployment_openai_model(deployment_view)
template_model: Final = openai_model or _deployment_base_model(deployment_view) or model_to_use
tool_section: Final = chat_template_tool_section(template_model)
count_policy: Final = _count_drop_policy(deployment_view, {**request_litellm_params, **(responses_request or {})})
counted_tool_choice: Final = (
request.tool_choice
if counted_messages is not None
and isinstance(request.tool_choice, (str, Mapping))
and "tool_choice" not in count_policy.additional_drop_params
and not _xai_tool_choice_off_xai(
template_model, tool_section, _deployment_served_model(deployment_view, request.model, responses_request)
)
else None
)
counted_tools: Final = (
cast(list[ChatCompletionToolParam], list(_with_strict_by_default(sent_tools))) # cast-ok: strict added
if sent_tools is not None
@ -16604,6 +16679,7 @@ async def _count_request_tokens(
model_to_use,
kimi_k3_messages,
counted_tools,
counted_tool_choice,
)
if kimi_k3_messages is not None and custom_tokenizer is None
else None
@ -16622,6 +16698,7 @@ async def _count_request_tokens(
prompt=prompt if counted_messages is None else None,
messages=counted_messages,
tools=counted_tools,
tool_choice=counted_tool_choice,
custom_tokenizer=custom_tokenizer,
provider_failure=ladder,
)
@ -16654,8 +16731,6 @@ async def supported_openai_params(model: str):
--header "Authorization: Bearer $LITELLM_MASTER_KEY"
```
"""
from litellm.litellm_core_utils.get_llm_provider_logic import declared_authenticating_provider
global llm_router
try:
resolved_models: Final = (

View file

@ -1518,6 +1518,7 @@ class _TokenCountPayload(TypedDict):
model: ReadOnly[str]
messages: ReadOnly[tuple[Mapping[str, object], ...]]
tools: ReadOnly[object]
tool_choice: ReadOnly[object]
class _TokenCounter(Protocol):
@ -1850,6 +1851,7 @@ async def _responses_input_tokens(
"model": model_name,
"messages": _responses_input_as_token_count_messages(input_value=input_value, request_params=data),
"tools": token_fields.get("tools"),
"tool_choice": token_fields.get("tool_choice"),
}
token_request: Final = TokenCountRequest.model_validate(payload)
except ValidationError as e:

View file

@ -167,6 +167,7 @@ impl TokenCounter {
definitions + TOOL_DEFINITIONS_TOKENS - discount
};
let choice_tokens = match tool_choice {
_ if tools.is_empty() => 0,
Some(ToolChoice::Mode(mode)) if mode == "none" => TOOL_CHOICE_NONE_TOKENS,
Some(ToolChoice::Mode(_)) | None => 0,
Some(ToolChoice::Named(named)) => {

View file

@ -122,17 +122,29 @@ mod json {
count(
r#"{"model":"m","messages":[{"role":"user","content":"hi"}],"tool_choice":"none"}"#
),
base + 1
base
);
assert_eq!(
count(
r#"{"model":"m","messages":[{"role":"user","content":"hi"}],"tool_choice":"auto"}"#
r#"{"model":"m","messages":[{"role":"user","content":"hi"}],"tool_choice":{"type":"function","function":{"name":"f"}}}"#
),
base
);
let with_tools = count(
r#"{"model":"m","messages":[{"role":"user","content":"hi"}],"tools":[{"name":"f"}]}"#,
);
assert_eq!(
count(
r#"{"model":"m","messages":[{"role":"user","content":"hi"}],"tools":[{"name":"f"}],"tool_choice":"none"}"#
),
with_tools + 1
);
assert_eq!(
count(
r#"{"model":"m","messages":[{"role":"user","content":"hi"}],"tools":[{"name":"f"}],"tool_choice":"auto"}"#
),
with_tools
);
let with_tools_and_system = count(
r#"{"model":"m","messages":[{"role":"system","content":"hi"}],"tools":[{"name":"f"}]}"#,
);
@ -433,7 +445,7 @@ mod tiktoken {
count(
r#"{"model":"m","messages":[{"role":"user","content":"hi"}],"tool_choice":"none"}"#
),
base + 1
base
);
}

View file

@ -50864,6 +50864,7 @@ export interface components {
};
/** TokenCountRequest */
TokenCountRequest: {
additional_drop_params?: components["schemas"]["JsonValue"];
/** Contents */
contents?: {
[key: string]: unknown;

View file

@ -241,7 +241,6 @@ from litellm.types.llms.openai import (
AllMessageValues,
AllPromptValues,
ChatCompletionAssistantToolCall,
ChatCompletionNamedToolChoiceParam,
ChatCompletionToolParam,
ChatCompletionToolParamFunctionChunk,
OpenAITextCompletionUserMessage,
@ -3287,7 +3286,7 @@ def token_counter(
messages: Sequence | None = None,
count_response_tokens: bool | None = False,
tools: list[ChatCompletionToolParam] | None = None,
tool_choice: ChatCompletionNamedToolChoiceParam | None = None,
tool_choice: str | Mapping[str, object] | None = None,
use_default_image_token_count: bool | None = False,
default_token_count: int | None = None,
) -> int:

View file

@ -25,3 +25,10 @@ def test_without_dropped_paths_many_client_paths_over_a_large_schema() -> None:
result: Final = without_dropped_paths(_LARGE_SCHEMA_OPTIONS, _MANY_DROP_PATHS)
assert result == {"output_config": _LARGE_SCHEMA_OPTIONS["output_config"]}
@pytest.mark.benchmark
def test_without_dropped_paths_one_client_path_of_unclosed_brackets() -> None:
options: Final[dict[str, JsonValue]] = {"thinking": {"type": "enabled", "budget_tokens": 2048}}
assert without_dropped_paths(options, ("[" * 16_000,)) == options

View file

@ -705,26 +705,87 @@ def test_token_counter_counts_function_tools_like_chat_completions_tools_for_cha
# Azure AI Foundry DeepSeek-V4-Flash-0731 billed 295 prompt tokens for the weather tool with each of
# these tool_choice values on 2026-10-17, the same as without one
# these tool_choice values on 2026-10-17, the same as without one. On 2026-10-29 aiand's
# deepseek-v4.1-flash billed 296 for each, and Fireworks' deepseek-v4p1-flash and OpenRouter's
# deepseek-v3.2 billed the same for "required" and the named tool as without one
@pytest.mark.parametrize(
"model",
[
"DeepSeek-V4-Flash-0731",
"aiand/deepseek-ai/deepseek-v4.1-flash",
"fireworks_ai/accounts/fireworks/models/deepseek-v4p1-flash",
"openrouter/deepseek/deepseek-v3.2",
],
)
@pytest.mark.parametrize("tool_choice", ["none", "required", {"type": "function", "function": {"name": "get_weather"}}])
def test_token_counter_tool_choice_adds_nothing_for_deepseek_v4(tool_choice: object):
def test_token_counter_tool_choice_adds_nothing_for_deepseek_v3_and_v4(model: str, tool_choice: object):
tools: Final = [_openai_tool(_WEATHER_FUNCTION)]
assert token_counter_new(
model="DeepSeek-V4-Flash-0731", messages=list(_USER_MESSAGES), tools=tools, tool_choice=tool_choice
) == token_counter_new(model="DeepSeek-V4-Flash-0731", messages=list(_USER_MESSAGES), tools=tools)
model=model, messages=list(_USER_MESSAGES), tools=tools, tool_choice=tool_choice
) == token_counter_new(model=model, messages=list(_USER_MESSAGES), tools=tools)
# A request with a one-function tool billed 283 prompt tokens on xAI grok-4.3 without tool_choice and 297 with a
# named one, and 357 and 358 on Azure AI Foundry grok-4-1-fast-reasoning, on 2026-10-29
@pytest.mark.parametrize("model", ["grok-4.3", "grok-4-1-fast-reasoning"])
def test_token_counter_counts_a_named_tool_choice_for_grok(model: str):
# Gemini API gemini-3.8-flash, gemini-3.1-pro-preview and gemini-3.5-flash-lite each billed 51 prompt tokens for the
# weather tool with no toolConfig, NONE, ANY and ANY limited to the tool on 2026-11-11
@pytest.mark.parametrize("model", ["gemini/gemini-3.8-flash", "gemini/gemini-3.1-pro-preview", "gemini-3.5-flash-lite"])
@pytest.mark.parametrize("tool_choice", ["none", "required", {"type": "function", "function": {"name": "get_weather"}}])
def test_token_counter_tool_choice_adds_nothing_for_gemini(model: str, tool_choice: object):
tools: Final = [_openai_tool(_WEATHER_FUNCTION)]
named: Final = {"type": "function", "function": {"name": "get_weather"}}
assert token_counter_new(
model=model, messages=list(_USER_MESSAGES), tools=tools, tool_choice=named
) > token_counter_new(model=model, messages=list(_USER_MESSAGES), tools=tools)
model=model, messages=list(_USER_MESSAGES), tools=tools, tool_choice=tool_choice
) == token_counter_new(model=model, messages=list(_USER_MESSAGES), tools=tools)
# xAI grok-4.3 and grok-4-1-fast-reasoning each billed 275 prompt tokens for the weather tool with no tool_choice,
# 296 with "none", 286 with "required" and 289 with the named tool on 2026-11-10, and OpenRouter, which served
# x-ai/grok-4.3 from xAI, billed the same on 2026-11-11
@pytest.mark.parametrize("model", ["xai/grok-4.3", "grok-4-1-fast-reasoning", "openrouter/x-ai/grok-4.3"])
@pytest.mark.parametrize(
("tool_choice", "billed_over_unset"),
[
pytest.param("none", 21, id="none"),
pytest.param({"type": "none"}, 21, id="anthropic-none"),
pytest.param("required", 11, id="required"),
pytest.param({"type": "any"}, 11, id="anthropic-any"),
pytest.param({"type": "function", "function": {"name": "get_weather"}}, 14, id="named"),
pytest.param({"type": "tool", "name": "get_weather"}, 14, id="anthropic-tool"),
pytest.param({"type": "function", "name": "get_weather"}, 14, id="responses-function"),
pytest.param("auto", 0, id="auto"),
],
)
def test_token_counter_counts_each_grok_tool_choice_the_way_xai_bills_it(
model: str, tool_choice: str | Mapping[str, object], billed_over_unset: int
):
tools: Final = [_openai_tool(_WEATHER_FUNCTION)]
assert (
token_counter_new(model=model, messages=list(_USER_MESSAGES), tools=tools, tool_choice=tool_choice)
- token_counter_new(model=model, messages=list(_USER_MESSAGES), tools=tools)
== billed_over_unset
)
# Azure AI Foundry grok-4-1 billed 351 prompt tokens for the weather tool with no tool_choice, 163 with "none" and
# 352 with "required" or the named tool on 2026-11-10, so xAI's costs would count it 10 to 21 over
@pytest.mark.parametrize("model", ["azure_ai/grok-4-1-fast-reasoning", "azure_ai/grok-4.3", "vertex_ai/xai/grok-4.3"])
@pytest.mark.parametrize("tool_choice", ["none", "required", {"type": "function", "function": {"name": "get_weather"}}])
def test_token_counter_tool_choice_adds_nothing_for_grok_served_off_xai(model: str, tool_choice: object):
tools: Final = [_openai_tool(_WEATHER_FUNCTION)]
assert token_counter_new(
model=model, messages=list(_USER_MESSAGES), tools=tools, tool_choice=tool_choice
) == token_counter_new(model=model, messages=list(_USER_MESSAGES), tools=tools)
# xAI and OpenAI both answered 400 to a tool_choice sent without tools on 2026-11-11
@pytest.mark.parametrize("model", ["xai/grok-4.3", "gpt-4.1"])
@pytest.mark.parametrize("tool_choice", ["none", {"type": "function", "function": {"name": "get_weather"}}])
def test_token_counter_tool_choice_without_tools_adds_nothing(model: str, tool_choice: object):
assert token_counter_new(model=model, messages=list(_USER_MESSAGES), tool_choice=tool_choice) == token_counter_new(
model=model, messages=list(_USER_MESSAGES)
)
def _counted_with_strict(model: str, **strict: bool) -> int:
@ -761,7 +822,7 @@ def test_token_counter_renders_a_false_strict_like_a_true_one_for_other_chat_tem
("tool_choice", "expected_tool_choice"),
[
pytest.param({"type": "auto"}, None, id="anthropic-auto"),
pytest.param({"type": "any"}, None, id="anthropic-any"),
pytest.param({"type": "any"}, "required", id="anthropic-any"),
pytest.param({"type": "none"}, "none", id="anthropic-none"),
pytest.param(
{"type": "tool", "name": "get_weather"},

View file

@ -413,6 +413,68 @@ def test_anthropic_request_counts_like_the_same_openai_request(pair_encoding: ti
assert anthropic == openai
_FORCED_TOOL_CHOICE: Final = (
'<|open|>message role="system" type="tool-choice"<|sep|>The system is invoked with `tool_choice=required`.\n'
"You MUST call tools in the next message.<|close|>message<|sep|><|end_of_msg|>"
)
_WEATHER_QUESTION: Final = [{"role": "user", "content": "Weather in Paris?"}]
_ANTHROPIC_WEATHER: Final = {
"name": "get_weather",
"description": "Get the weather in °C",
"input_schema": _WEATHER["function"]["parameters"],
}
# Bedrock billed us.moonshotai.kimi-k3 and kimi-k3.1 206 input tokens for an any or named tool choice and 170 for an
# auto or unset one on 2026-11-12, and the template above renders this message for tool_choice=required only
@pytest.mark.parametrize(
("tools", "tool_choice", "added"),
[
pytest.param([_WEATHER], "required", _FORCED_TOOL_CHOICE, id="required"),
pytest.param(
[_WEATHER], {"type": "function", "function": {"name": "get_weather"}}, _FORCED_TOOL_CHOICE, id="named"
),
pytest.param([_WEATHER], "auto", "", id="auto"),
pytest.param([_WEATHER], "none", "", id="none_dropped_by_bedrock"),
pytest.param(None, "required", "", id="no_tools"),
],
)
def test_tool_choice_adds_the_template_message_bedrock_bills_only_for_a_forced_tool(
pair_encoding: tiktoken.Encoding,
tools: list[dict[str, object]] | None,
tool_choice: str | dict[str, object],
added: str,
):
unset: Final = kimi_k3_tokens(pair_encoding, MODEL, _WEATHER_QUESTION, tools)
assert unset is not None
assert kimi_k3_tokens(pair_encoding, MODEL, _WEATHER_QUESTION, tools, tool_choice) == unset + _template_count(
pair_encoding, added
)
@pytest.mark.parametrize(
("anthropic_choice", "openai_choice"),
[
pytest.param({"type": "any"}, "required", id="any"),
pytest.param({"type": "tool", "name": "get_weather"}, "required", id="tool"),
pytest.param({"type": "auto"}, None, id="auto"),
pytest.param({"type": "none"}, None, id="none"),
],
)
def test_anthropic_tool_choice_counts_like_the_openai_tool_choice_it_translates_to(
pair_encoding: tiktoken.Encoding, anthropic_choice: dict[str, object], openai_choice: str | None
):
anthropic: Final = kimi_k3_anthropic_tokens(
pair_encoding,
MODEL,
{"messages": _WEATHER_QUESTION, "tools": [_ANTHROPIC_WEATHER], "tool_choice": anthropic_choice},
)
assert anthropic is not None
assert anthropic == kimi_k3_tokens(pair_encoding, MODEL, _WEATHER_QUESTION, [_WEATHER], openai_choice)
@pytest.mark.parametrize("failure", ["checksum", "missing_file"])
def test_tokenizer_that_fails_its_checksum_or_cannot_be_read_is_not_loaded(
byte_source: KimiK3TokenizerSource, tmp_path: Path, caplog: pytest.LogCaptureFixture, failure: str

View file

@ -44,7 +44,7 @@ from starlette.types import Message
import litellm
from litellm import Router
from litellm._logging import JsonFormatter, verbose_proxy_logger
from litellm._logging import JsonFormatter, verbose_logger, verbose_proxy_logger
from litellm.llms.anthropic.count_tokens import handler as anthropic_count_tokens_handler
from litellm.llms.azure_ai.anthropic.count_tokens import handler as azure_ai_count_tokens_http
from litellm.llms.base_llm.base_utils import BaseTokenCounter
@ -83,6 +83,7 @@ from litellm.proxy.proxy_server import (
_provider_token_count,
_try_provider_token_count,
count_request_tokens,
token_counter,
)
from litellm.responses.litellm_completion_transformation.session_handler import ResponsesSessionHandler
from litellm.responses.utils import ResponsesAPIRequestUtils
@ -3454,6 +3455,78 @@ async def test_bedrock_kimi_k3_is_counted_with_its_tokenizer_plus_the_shipped_fr
assert (anthropic.total_tokens, anthropic.tokenizer_type) == (openai.total_tokens, "huggingface_tokenizer")
@pytest.mark.asyncio
@pytest.mark.parametrize(
("messages_format", "tools", "forced", "unforced"),
[
pytest.param(
"openai", [_KIMI_WEATHER], {"type": "function", "function": {"name": "get_weather"}}, "auto", id="openai"
),
pytest.param(
"anthropic",
[
{
"name": "get_weather",
"description": "Get the weather",
"input_schema": _KIMI_WEATHER["function"]["parameters"],
}
],
{"type": "tool", "name": "get_weather"},
{"type": "auto"},
id="anthropic",
),
],
)
@pytest.mark.parametrize("drops_tool_choice", [False, True])
async def test_bedrock_kimi_k3_count_adds_the_template_message_of_a_forced_tool_choice_inference_sends(
monkeypatch: pytest.MonkeyPatch,
tmp_path: Path,
messages_format: TokenCountMessagesFormat,
tools: list[dict[str, object]],
forced: dict[str, object],
unforced: str | dict[str, object],
drops_tool_choice: bool,
):
_framed_kimi(monkeypatch)
if drops_tool_choice:
monkeypatch.setattr(
litellm.proxy.proxy_server,
"llm_router",
Router(
model_list=[
{
"model_name": "kimi",
"litellm_params": {
"model": "bedrock/us.moonshotai.kimi-k3",
"additional_drop_params": ["tool_choice"],
},
}
]
),
)
encoding: Final = _available_kimi_k3(monkeypatch, tmp_path)
question: Final = [{"role": "user", "content": "Weather in Paris?"}]
async def count(tool_choice: str | dict[str, object] | None) -> int:
counted: Final = await count_request_tokens(
request=TokenCountRequest(model="kimi", messages=question, tools=tools, tool_choice=tool_choice),
call_endpoint=True,
messages_format=messages_format,
)
assert counted.tokenizer_type == "huggingface_tokenizer"
return counted.total_tokens
plain: Final = kimi_k3_tokens(encoding, "us.moonshotai.kimi-k3", question, [_KIMI_WEATHER])
required: Final = kimi_k3_tokens(encoding, "us.moonshotai.kimi-k3", question, [_KIMI_WEATHER], "required")
unset: Final = await count(None)
assert plain is not None and required is not None and required > plain
assert (await count(unforced), await count(forced)) == (
unset,
unset if drops_tool_choice else unset + required - plain,
)
@pytest.mark.asyncio
async def test_bedrock_kimi_k3_deployment_with_a_custom_tokenizer_keeps_counting_with_it(
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
@ -6361,3 +6434,330 @@ async def test_messages_count_tokens_drops_what_the_deployment_drops_unless_the_
sent: Final = json.loads(route.calls.last.request.content)
assert ("tool_choice" in sent, (sent.get("thinking") or {}).get("type")) == sent_options
_TOOL_CHOICE_MODEL: Final = "qwen/qwen3.8-27b"
_TOOL_CHOICE_QUESTION: Final = "What is the weather in Paris?"
_CITY_SCHEMA: Final[dict[str, JsonValue]] = {
"type": "object",
"properties": {"city": {"type": "string"}},
"required": ["city"],
}
_CHAT_WEATHER_TOOL: Final[dict[str, JsonValue]] = {
"type": "function",
"function": {"name": "get_weather", "description": "Get the weather for a city", "parameters": _CITY_SCHEMA},
}
def _tool_choice_router() -> Router:
return Router(
model_list=[
{
"model_name": "no-count-api",
"litellm_params": {"model": f"groq/{_TOOL_CHOICE_MODEL}", "api_key": "sk-test"},
}
]
)
async def _utils_route_count(tool_choice: Mapping[str, JsonValue], model: str = "no-count-api") -> int:
response: Final = await token_counter(
request=TokenCountRequest(
model=model,
messages=[{"role": "user", "content": _TOOL_CHOICE_QUESTION}],
tools=[_CHAT_WEATHER_TOOL],
**tool_choice,
),
http_request=_json_request({}, "python-httpx/0.28.1"),
fastapi_response=Response(),
)
return response.total_tokens
async def _messages_route_count(tool_choice: Mapping[str, JsonValue]) -> int:
body: Final = {
"model": "no-count-api",
"messages": [{"role": "user", "content": _TOOL_CHOICE_QUESTION}],
"tools": [{"name": "get_weather", "description": "Get the weather for a city", "input_schema": _CITY_SCHEMA}],
**tool_choice,
}
response: Final = await anthropic_count_tokens(
_json_request(body, "python-httpx/0.28.1"), Response(), UserAPIKeyAuth()
)
assert isinstance(response, dict)
return response["input_tokens"]
async def _responses_route_count(tool_choice: Mapping[str, JsonValue]) -> int:
from litellm.proxy.response_api_endpoints.endpoints import responses_input_tokens
body: Final = {
"model": "no-count-api",
"input": _TOOL_CHOICE_QUESTION,
"tools": [
{
"type": "function",
"name": "get_weather",
"description": "Get the weather for a city",
"parameters": _CITY_SCHEMA,
}
],
**tool_choice,
}
response: Final = await responses_input_tokens(
request=_json_request(body, "python-httpx/0.28.1"),
fastapi_response=Response(),
general_settings=MappingProxyType({}),
token_counter=count_request_tokens,
llm_router=litellm.proxy.proxy_server.llm_router,
user_api_key_dict=UserAPIKeyAuth(),
response_id_security=None,
managed_files=None,
)
assert isinstance(response, dict)
return response["input_tokens"]
@pytest.mark.asyncio
@pytest.mark.parametrize(
("route_count", "tool_choice"),
[
(_utils_route_count, {"type": "function", "function": {"name": "get_weather"}}),
(_utils_route_count, "none"),
(_messages_route_count, {"type": "tool", "name": "get_weather"}),
(_messages_route_count, {"type": "none"}),
(_responses_route_count, {"type": "function", "name": "get_weather"}),
(_responses_route_count, "none"),
],
ids=["utils-named", "utils-none", "messages-named", "messages-none", "responses-named", "responses-none"],
)
async def test_count_routes_without_a_count_api_count_the_tool_choice_like_token_counter(
monkeypatch: pytest.MonkeyPatch,
route_count: Callable[[Mapping[str, JsonValue]], Coroutine[None, None, int]],
tool_choice: JsonValue,
) -> None:
monkeypatch.setattr(litellm.proxy.proxy_server, "llm_router", _tool_choice_router())
monkeypatch.setattr(litellm.proxy.proxy_server, "proxy_config", litellm.proxy.proxy_server.ProxyConfig())
question: Final = [{"role": "user", "content": _TOOL_CHOICE_QUESTION}]
sdk_added: Final = litellm.token_counter(
model=_TOOL_CHOICE_MODEL, messages=question, tools=[_CHAT_WEATHER_TOOL], tool_choice=tool_choice
) - litellm.token_counter(model=_TOOL_CHOICE_MODEL, messages=question, tools=[_CHAT_WEATHER_TOOL])
route_added: Final = await route_count({"tool_choice": tool_choice}) - await route_count({})
assert route_added == sdk_added > 0
_AZURE_GROK_PARAMS: Final = {"api_base": "https://example.services.ai.azure.com", "api_key": "sk-test"}
@pytest.mark.asyncio
@pytest.mark.parametrize(
("deployment", "served_by_xai"),
[
({"litellm_params": {"model": "xai/grok-4-1-fast-reasoning", "api_key": "sk-test"}}, True),
({"litellm_params": {"model": "openrouter/x-ai/grok-4.3", "api_key": "sk-test"}}, True),
(
{"litellm_params": {"model": "openai/grok-4.3", "api_base": "https://api.x.ai/v1", "api_key": "sk-test"}},
True,
),
({"litellm_params": {"model": "openai/grok-4.3", "api_base": "http://[bad", "api_key": "sk-test"}}, False),
(
{
"litellm_params": {"model": "azure_ai/grok-4-1", **_AZURE_GROK_PARAMS},
"model_info": {"base_model": "azure_ai/grok-4-1-fast-reasoning"},
},
False,
),
({"litellm_params": {"model": "azure_ai/grok-4-1-fast-reasoning", **_AZURE_GROK_PARAMS}}, False),
({"litellm_params": {"model": "grok-4.3", "custom_llm_provider": "azure_ai", **_AZURE_GROK_PARAMS}}, False),
(
{
"litellm_params": {
"model": "vertex_ai/xai/grok-4.3",
"vertex_project": "test-project",
"vertex_location": "global",
}
},
False,
),
],
)
async def test_utils_token_counter_counts_xai_tool_choice_costs_only_on_grok_that_xai_serves(
monkeypatch: pytest.MonkeyPatch, deployment: dict[str, dict[str, str]], served_by_xai: bool
) -> None:
monkeypatch.setattr(
litellm.proxy.proxy_server, "llm_router", Router(model_list=[{"model_name": "grok", **deployment}])
)
monkeypatch.setattr(litellm.proxy.proxy_server, "proxy_config", litellm.proxy.proxy_server.ProxyConfig())
named: Final = {"tool_choice": {"type": "function", "function": {"name": "get_weather"}}}
question: Final = [{"role": "user", "content": _TOOL_CHOICE_QUESTION}]
sdk_added: Final = litellm.token_counter(
model="xai/grok-4.3", messages=question, tools=[_CHAT_WEATHER_TOOL], **named
) - litellm.token_counter(model="xai/grok-4.3", messages=question, tools=[_CHAT_WEATHER_TOOL])
route_added: Final = await _utils_route_count(named, "grok") - await _utils_route_count({}, "grok")
assert route_added == (sdk_added if served_by_xai else 0)
assert sdk_added > 0
def _litellm_warnings(caplog: pytest.LogCaptureFixture) -> tuple[str, ...]:
return tuple(record.getMessage() for record in caplog.records if record.name == verbose_logger.name)
@pytest.mark.asyncio
@pytest.mark.parametrize(
("route_count", "tool_choice"),
[(_utils_route_count, "none"), (_messages_route_count, {"type": "none"}), (_responses_route_count, "none")],
ids=["utils", "messages", "responses"],
)
@pytest.mark.parametrize("model", ["chatgpt/gpt-6.1-sol", "github_copilot/kimi-k3"])
async def test_count_routes_leave_the_provider_login_alone_when_counting_a_tool_choice(
monkeypatch: pytest.MonkeyPatch,
tmp_path: Path,
caplog: pytest.LogCaptureFixture,
route_count: Callable[[Mapping[str, JsonValue]], Coroutine[None, None, int]],
tool_choice: JsonValue,
model: str,
) -> None:
(tmp_path / "auth.json").write_text("not a saved login")
monkeypatch.setenv("CHATGPT_TOKEN_DIR", str(tmp_path))
monkeypatch.setenv("GITHUB_COPILOT_TOKEN_DIR", str(tmp_path))
monkeypatch.setattr(
litellm.proxy.proxy_server,
"llm_router",
Router(model_list=[{"model_name": "no-count-api", "litellm_params": {"model": model}}]),
)
monkeypatch.setattr(litellm.proxy.proxy_server, "proxy_config", litellm.proxy.proxy_server.ProxyConfig())
with caplog.at_level(logging.WARNING, logger=verbose_logger.name):
without_choice: Final = await route_count({})
warnings_without_choice: Final = _litellm_warnings(caplog)
caplog.clear()
with_choice: Final = await route_count({"tool_choice": tool_choice})
assert with_choice > without_choice
assert _litellm_warnings(caplog) == warnings_without_choice
def _nested_json(depth: int) -> JsonValue:
return "none" if depth == 0 else {"nested": _nested_json(depth - 1)}
@pytest.mark.asyncio
@pytest.mark.parametrize(
("route_count", "field"),
[
(_utils_route_count, "tool_choice"),
(_utils_route_count, "additional_drop_params"),
(_messages_route_count, "tool_choice"),
(_messages_route_count, "thinking"),
(_responses_route_count, "tool_choice"),
],
ids=["utils-tool-choice", "utils-drop-params", "messages-tool-choice", "messages-thinking", "responses-tool-choice"],
)
async def test_count_routes_skip_a_value_nested_too_deep_to_validate(
monkeypatch: pytest.MonkeyPatch,
route_count: Callable[[Mapping[str, JsonValue]], Coroutine[None, None, int]],
field: str,
) -> None:
monkeypatch.setattr(litellm.proxy.proxy_server, "llm_router", _tool_choice_router())
monkeypatch.setattr(litellm.proxy.proxy_server, "proxy_config", litellm.proxy.proxy_server.ProxyConfig())
assert await route_count({field: _nested_json(300)}) == await route_count({})
@pytest.mark.asyncio
@pytest.mark.parametrize(
("route_count", "tool_choice"),
[(_utils_route_count, "none"), (_messages_route_count, {"type": "none"}), (_responses_route_count, "none")],
ids=["utils", "messages", "responses"],
)
@pytest.mark.parametrize(
("drop_params", "counts_choice"),
[({"additional_drop_params": ["tool_choice"]}, False), ({"drop_params": True}, True)],
ids=["tool-choice-dropped", "only-unsupported-dropped"],
)
async def test_count_routes_count_no_tool_choice_the_deployment_drops(
monkeypatch: pytest.MonkeyPatch,
route_count: Callable[[Mapping[str, JsonValue]], Coroutine[None, None, int]],
tool_choice: JsonValue,
drop_params: dict[str, JsonValue],
counts_choice: bool,
) -> None:
monkeypatch.setattr(
litellm.proxy.proxy_server,
"llm_router",
Router(
model_list=[
{
"model_name": "no-count-api",
"litellm_params": {"model": f"groq/{_TOOL_CHOICE_MODEL}", "api_key": "sk-test", **drop_params},
}
]
),
)
monkeypatch.setattr(litellm.proxy.proxy_server, "proxy_config", litellm.proxy.proxy_server.ProxyConfig())
route_added: Final = await route_count({"tool_choice": tool_choice}) - await route_count({})
assert (route_added > 0) == counts_choice
@pytest.mark.asyncio
@pytest.mark.parametrize(
("route_count", "tool_choice"),
[(_utils_route_count, "none"), (_messages_route_count, {"type": "none"}), (_responses_route_count, "none")],
ids=["utils", "messages", "responses"],
)
@pytest.mark.parametrize(
("dropped", "counts_choice"),
[(["tool_choice"], False), (["temperature"], True)],
ids=["tool-choice-dropped", "other-param-dropped"],
)
async def test_count_routes_count_no_tool_choice_the_request_drops(
monkeypatch: pytest.MonkeyPatch,
route_count: Callable[[Mapping[str, JsonValue]], Coroutine[None, None, int]],
tool_choice: JsonValue,
dropped: list[JsonValue],
counts_choice: bool,
) -> None:
monkeypatch.setattr(
litellm.proxy.proxy_server,
"llm_router",
Router(
model_list=[
{
"model_name": "no-count-api",
"litellm_params": {"model": f"groq/{_TOOL_CHOICE_MODEL}", "api_key": "sk-test"},
}
]
),
)
monkeypatch.setattr(litellm.proxy.proxy_server, "proxy_config", litellm.proxy.proxy_server.ProxyConfig())
drop_list: Final = {"additional_drop_params": dropped}
route_added: Final = await route_count({"tool_choice": tool_choice, **drop_list}) - await route_count(drop_list)
assert (route_added > 0) == counts_choice
@pytest.mark.asyncio
async def test_utils_token_counter_counts_a_bare_prompt_with_a_tool_choice_like_the_prompt_alone(
monkeypatch: pytest.MonkeyPatch,
) -> None:
monkeypatch.setattr(litellm.proxy.proxy_server, "llm_router", _tool_choice_router())
with_choice: Final = await token_counter(
request=TokenCountRequest(model="no-count-api", prompt=_TOOL_CHOICE_QUESTION, tool_choice="required"),
http_request=_json_request({}, "python-httpx/0.28.1"),
fastapi_response=Response(),
)
prompt_alone: Final = await token_counter(
request=TokenCountRequest(model="no-count-api", prompt=_TOOL_CHOICE_QUESTION),
http_request=_json_request({}, "python-httpx/0.28.1"),
fastapi_response=Response(),
)
assert (with_choice.error, with_choice.total_tokens) == (False, prompt_alone.total_tokens)

View file

@ -240,6 +240,7 @@ def test_disabled_token_counter_declines_every_model(monkeypatch: pytest.MonkeyP
PARITY_REQUESTS: Final[tuple[dict[str, object], ...]] = (
{"model": MODEL, "messages": [{"role": "user", "content": "Hello, how are you today?"}]},
{"model": MODEL, "messages": [{"role": "user", "content": "Hello, how are you today?"}], "tool_choice": "none"},
{
"model": MODEL,
"messages": [