feat(count_tokens)!: configurable token_count_framing and token_count_model, count source in the response (#46777)

* feat(count_tokens): configurable token_count_framing and token_count_fallback_model, count source in the response

Count endpoints now say where each number came from in litellm_count_source
(provider, fallback_model, local_estimate) and name the fallback model in
litellm_count_fallback_model. When the provider answers 400 or 404 for a model
it cannot count, the proxy counts the same request with token_count_fallback_model.
token_count_framing adds a per-model constant to local estimates. Both settings
ship in the cost map for Bedrock Claude Sonnet/Haiku 5.5, Bedrock Kimi K3, and
Databricks Kimi K3 and DeepSeek V4 Flash, and can be overridden in model_info.

* test(count_tokens): classify the new cost map keys in the schema generator and expect count_source in exact-shape tests

* test(count_tokens): e2e matrix against real inference and integration wire tests for count source, fallback and framing

* fix(count_tokens): keep the provider error when local counting is disabled, name the fallback error, keep settings in cooldown

Moves the cost map lookup off the event loop

* ci(cost map): bound token_count_framing to non-negative and token_count_fallback_model to non-empty

* fix(count_tokens): keep a failed fallback's own status, ship fallbacks only where they count, add system.ai framing

A fallback model that answers 404 returns the requested model's error unchanged, and any other fallback
failure answers with its own status and message. The eu, apac, au and jp Sonnet and Haiku 5.5 entries
no longer ship the Opus 5.5 fallback, which AWS serves only in us-east-1. A configured empty
token_count_fallback_model turns the shipped fallback off. Config framing accepts whole-number floats and
rejects negatives. The Databricks system.ai Kimi K3 and DeepSeek V4 Flash names carry the same framing as
their serving endpoint names, and the e2e matrix runs on Bedrock Haiku 5.5 too.

* test(databricks): list the system.ai Kimi K3 and DeepSeek V4 Flash names with their published DBU rates

* fix(count_tokens): skip a token count fallback model for an hour after it answers 404 on that deployment

A us. or global. Sonnet 5.5 deployment outside us-east-1 paid two extra Bedrock
calls and about 200 ms on every count, since Opus 5.5 cannot count there either.
The Kimi K3 e2e also runs the large system prompt shape now.

* test(count_tokens): use the shared integration port allocator in the fallback wire test

* fix(cost map): keep the Databricks system.ai entries to framing only, so their spend stays as today

* test(count_tokens): cover the fallback 404 skip, inference profile base_model, shipped framing, and deployment-scoped token count settings

Adds integration cells for a fallback model that answers 404, an application inference profile ARN deployment whose base_model carries the shipped fallback, and the shipped Kimi K3 framing with token_count_framing 0 turning it off. Adds unit tests that keep token count settings off the shared backend cost map key and that store a token count setting on PATCH only when the operator changed it

* fix(count_tokens): keep the counter's own 400 and the provider's error when a framed estimate cannot read the body

A 400 the OpenAI or Gemini counter returns before calling the provider is the caller's answer, so it no longer tries the fallback model or a framed estimate. When the framed local estimate cannot read a content block, the client gets the provider's error instead of a 500. Adds /audit round 2's integration tests for the fallback leg's converted body, the Anthropic envelope on fallback failures, and both framing cases.

* fix(count_tokens): answer the provider's error whenever a framed estimate raises

A tool schema the local estimate cannot format, such as a boolean subschema or an array with no items, raised AttributeError or KeyError and answered a 500 on a framed deployment whose provider had already answered 400. Any estimate failure now answers the provider's own error.

* fix(count_tokens): load the custom tokenizer inside the guarded framed estimate

A framed deployment whose custom_tokenizer could not load answered a 500 or 503 where the provider had already answered 400, because the tokenizer was selected outside the estimate's error handling. Selecting it inside answers the provider's error, logs a warning with the estimate's error, and still raises the Rust extension's fork-guard errors.

* fix(count_tokens): look up framing under the deployment's custom_llm_provider

* fix(count_tokens): share one tokenizer download per worker and bound the wait after a provider error

A framed deployment whose custom_tokenizer is not cached locally made every
count wait out the Hugging Face hub's whole retry cycle when the hub could not
be reached, and a burst of those loads held the default thread limiter that
every count's cost map lookup also needs. Counts on a worker now share one
download per minute, a failed download is reused for the rest of that minute,
and after a provider error a count waits at most 5 s before answering the
provider's error. The swallowed-estimate warning logs only the error type.

* fix(count_tokens): keep one tokenizer download per key running and load the local route per request

* test(count_tokens): type the scripted counter and wait on events instead of wall time where possible

* fix(count_tokens): run framed tokenizer loads on their own capped threads and answer a tokenizer panic with the provider error

Framed loads used anyio's default 40 threads, so a hub that drops packets let about 40 hanging loads stall every count on the worker. Loads now run on a per-worker pool of 4, and a count that finds 4 loads running answers the provider's error at once. Any finished load is reused for a minute after it finishes, so a load that fell back to tiktoken is not retried on every count. A pyo3 PanicException from the tokenizer, while loading or counting, now answers the provider's error instead of a plain 500

* feat(proxy): count tokens with token_count_model in its pinned region

token_count_model {model, region} replaces token_count_fallback_model. Bedrock Sonnet and Haiku 5.5 count with Opus 5.5 in us-east-1, or Melbourne for au. profiles, and a failure names the region to enable

* fix(count_tokens): use the renamed count model constant in the e2e test and list token_count_model with the object keys

* fix(proxy): keep the upstream error envelope when adding the region hint

* test(proxy): annotate the token count setting echo test

* ci(cost map): require non-blank token_count_model strings and test the region rules

* fix(count_tokens): answer the token_count_model's error even when framing is set

* fix(count_tokens): keep the provider error for Google contents instead of a framed estimate

* fix(count_tokens): estimate Google contents the route translated to messages

* fix(count_tokens): keep the provider error for Google contents with non-text parts

* test(proxy): drive tokenizer wait and cost map tests by events and a skippable loop clock

* fix(count_tokens): check Google text-only parts without a KeysView comparison basedpyright flags

* feat(proxy): count Bedrock Kimi K3 tokens exactly with its pinned tokenizer

* test(bedrock): keep the Kimi K3 image check in-process

* fix(count_tokens): join the region hint to an upstream message that already ends with a period

Also pins that a blank token_count_model in config falls through to the cost map default

* fix(count_tokens): count Gemini tool-call turns and Responses tools, and stop tokenizer retries from stalling counts

The Google countTokens route estimates contents with functionCall, functionResponse and thoughtSignature parts again, and still answers the provider error for media, thought and unknown parts

The Kimi K3 exact count renders Responses API and Anthropic tools through the chat tool translation the Vertex count already used, now shared from the prompt factory, and falls back to the estimate when the Converse body cannot carry a tool

A failed Kimi K3 tokenizer load is retried in the background without making counts wait, and a load still running after the wait logs a warning

* fix(count_tokens): count Kimi K3 tools the way Bedrock inference sends them

A flat Responses API function or custom tool becomes the chat tool the
Responses route sends to inference, and every other tool goes to the
Converse body as is, so hosted tools Bedrock drops are left out of the
count instead of being counted or turning it into an estimate.

* test(count_tokens): annotate new token counter test locals with Final

* fix(count_tokens): keep shipped count regions inside the deployment's geography

A plain Bedrock Sonnet or Haiku 5.5 deployment in a non-US region no longer sends its count prompt to us-east-1. Global count models and configured token_count_model keep their pinned region. Also expect litellm_count_source in the Vertex Llama count test main added

* fix(count_tokens): resolve count settings off the event loop
This commit is contained in:
Mateo Wang 2026-10-18 00:11:27 +00:00 • committed by GitHub
parent 3fb95c8fe3
commit 75faaeab64
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
41 changed files with 4732 additions and 158 deletions

View file

@ -6166,3 +6166,25 @@ def resolve_structured_messages(
if structured:
return [msg if isinstance(msg, dict) else msg.model_dump() for msg in structured]
return None
def is_flat_responses_tool(tool: Mapping[str, object]) -> bool:
tool_type: Final = tool.get("type")
return tool_type in ("function", "custom") and tool_type not in tool and "input_schema" not in tool
def chat_completion_tools_for(tool: Mapping[str, object]) -> tuple[Mapping[str, object], ...]:
from litellm.llms.anthropic.pass_through.adapters.transformation import LiteLLMAnthropicMessagesAdapter
from litellm.responses.litellm_completion_transformation.transformation import (
LiteLLMCompletionResponsesConfig,
)
if is_flat_responses_tool(tool):
chat_tools, _ = LiteLLMCompletionResponsesConfig.transform_responses_api_tools_to_chat_completion_tools(
[dict(tool)] # pyright: ignore[reportArgumentType] # a JSON dict in a flat Responses tool shape
)
return tuple(chat_tools)
anthropic_tools, _ = LiteLLMAnthropicMessagesAdapter().translate_anthropic_tools_to_openai(
[dict(tool)] # pyright: ignore[reportArgumentType] # a JSON dict in an Anthropic or chat tool shape
)
return tuple(anthropic_tools)

View file

@ -48,7 +48,7 @@ class BedrockCountTokensHandler(BedrockCountTokensConfig):
verbose_logger.debug("Processing CountTokens request for resolved model: %s", resolved_model)
# Get AWS region using existing LiteLLM function
aws_region_name: Final = self._get_aws_region_name(
aws_region_name: Final = self.get_aws_region_name(
optional_params=litellm_params,
model=resolved_model,
model_id=None,

View file

@ -0,0 +1,502 @@
"""
Exact Bedrock token counts for Kimi K3, which has no CountTokens support.
Renders the Converse body litellm sends through Moonshot's Kimi K3 chat template and counts it with the
official tokenizer, downloaded once from Hugging Face at a pinned revision and checked against its SHA-256.
"""
import asyncio
import base64
import copy
import hashlib
import json
import threading
import time
from collections.abc import Callable, Iterable, Mapping, Sequence
from concurrent.futures import Future
from dataclasses import dataclass
from enum import Enum
from itertools import chain, groupby
from pathlib import Path
from typing import Annotated, Final, Literal, TypeAlias
import regex
import tiktoken
from pydantic import BaseModel, ConfigDict, Field, JsonValue, ValidationError, model_validator
from typing_extensions import Self
import litellm
from litellm._logging import verbose_logger
from litellm.litellm_core_utils.prompt_templates.factory import (
chat_completion_tools_for,
is_flat_responses_tool,
)
from litellm.llms.anthropic.pass_through.adapters.transformation import LiteLLMAnthropicMessagesAdapter
from litellm.types.llms.openai import AllMessageValues, ChatCompletionToolParam
KIMI_K3_REPO: Final = "moonshotai/Kimi-K3"
KIMI_K3_REVISION: Final = "f831ab66814297da540d832a5235f8e904f29d06"
KIMI_K3_RANKS_FILE: Final = "tiktoken.model"
KIMI_K3_RANKS_SHA256: Final = "b6c497a7469b33ced9c38afb1ad6e47f03f5e5dc05f15930799210ec050c5103"
_PATTERN: Final = "|".join(
(
r"[\p{Han}]+",
r"[^\r\n\p{L}\p{N}]?[\p{Lu}\p{Lt}\p{Lm}\p{Lo}\p{M}&&[^\p{Han}]]*[\p{Ll}\p{Lm}\p{Lo}\p{M}&&[^\p{Han}]]+(?i:'s|'t|'re|'ve|'m|'ll|'d)?",
r"[^\r\n\p{L}\p{N}]?[\p{Lu}\p{Lt}\p{Lm}\p{Lo}\p{M}&&[^\p{Han}]]+[\p{Ll}\p{Lm}\p{Lo}\p{M}&&[^\p{Han}]]*(?i:'s|'t|'re|'ve|'m|'ll|'d)?",
r"\p{N}{1,3}",
r" ?[^\s\p{L}\p{N}]+[\r\n]*",
r"\s*[\r\n]+",
r"\s+(?!\S)",
r"\s+",
)
)
# Ends chunks only where the pattern above always splits, so their counts sum to the whole text's count while BPE
# stays linear. Only a run of over 1024 characters with no such split, like 2000 spaces, is cut mid-piece.
_CHUNK: Final = regex.compile(
r"(?s).{1,1024}(?:(?<=\S)(?= \S)|(?<=\p{N})(?=\P{N})|(?<=[^\s\p{N}])(?=\p{N})|(?<=\p{L})(?=[^\p{L}\p{M}'])"
r"|(?<=[\r\n])(?=\S)|\Z)|.{1024}\p{N}*"
)
@dataclass(frozen=True, slots=True)
class KimiK3TokenizerSource:
ranks_path: Callable[[], str]
sha256: str
def _hugging_face_ranks_path() -> str:
import huggingface_hub
return huggingface_hub.hf_hub_download(
repo_id=KIMI_K3_REPO,
filename=KIMI_K3_RANKS_FILE,
revision=KIMI_K3_REVISION,
local_files_only=litellm.disable_hf_tokenizer_download is True,
)
HUGGING_FACE_SOURCE: Final = KimiK3TokenizerSource(ranks_path=_hugging_face_ranks_path, sha256=KIMI_K3_RANKS_SHA256)
def _loaded(source: KimiK3TokenizerSource) -> tiktoken.Encoding | str:
try:
data: Final = Path(source.ranks_path()).read_bytes()
except Exception as e: # noqa: BLE001 # a download or read failure of any kind falls back to the estimate
return type(e).__name__
if hashlib.sha256(data).hexdigest() != source.sha256:
return "SHA-256 mismatch"
rows: Final = (line.split() for line in data.splitlines() if line)
return tiktoken.Encoding(
name="kimi_k3",
pat_str=_PATTERN,
mergeable_ranks={base64.b64decode(token): int(rank) for token, rank in rows},
special_tokens={},
)
def load_kimi_k3_encoding(source: KimiK3TokenizerSource) -> tiktoken.Encoding | None:
loaded: Final = _loaded(source)
if isinstance(loaded, tiktoken.Encoding):
return loaded
verbose_logger.warning(
"Kimi K3 tokenizer unavailable (%s), so Kimi K3 token counts are estimated until a later count loads it. "
"Allow downloads of %s from Hugging Face repo %s at revision %s, or put it in the Hugging Face cache",
loaded,
KIMI_K3_RANKS_FILE,
KIMI_K3_REPO,
KIMI_K3_REVISION,
)
return None
def _started_load(source: KimiK3TokenizerSource) -> "Future[tiktoken.Encoding | None]":
encoding: Final = Future[tiktoken.Encoding | None]()
threading.Thread(
target=lambda: encoding.set_result(load_kimi_k3_encoding(source)), name="litellm-kimi-k3-tokenizer", daemon=True
).start()
return encoding
@dataclass(frozen=True, slots=True)
class _EncodingLoad:
encoding: "Future[tiktoken.Encoding | None]"
started_at: float
is_retry: bool
def retry_due(self, now: float, retry_after_seconds: float) -> bool:
return self.encoding.done() and self.encoding.result() is None and now - self.started_at >= retry_after_seconds
@dataclass(slots=True)
class KimiK3Encodings:
source: KimiK3TokenizerSource
retry_after_seconds: float
clock: Callable[[], float] = time.monotonic
current: _EncodingLoad | None = None
def _load(self) -> _EncodingLoad:
now: Final = self.clock()
current: Final = self.current
if current is not None and not current.retry_due(now, self.retry_after_seconds):
return current
load: Final = _EncodingLoad(encoding=_started_load(self.source), started_at=now, is_retry=current is not None)
self.current = load
return load
async def encoding(self, wait_seconds: float) -> tiktoken.Encoding | None:
load: Final = self._load()
remaining: Final = 0 if load.is_retry else load.started_at + wait_seconds - self.clock()
if load.encoding.done() or remaining <= 0:
return load.encoding.result() if load.encoding.done() else None
await asyncio.wait((asyncio.wrap_future(load.encoding),), timeout=remaining)
if load.encoding.done():
return load.encoding.result()
verbose_logger.warning(
"Kimi K3 tokenizer still loading after %s s, so Kimi K3 token counts are estimated until it loads. "
"Check that %s can be downloaded from Hugging Face repo %s at revision %s, or put it in the Hugging Face cache",
wait_seconds,
KIMI_K3_RANKS_FILE,
KIMI_K3_REPO,
KIMI_K3_REVISION,
)
return None
class _Converse(BaseModel):
model_config = ConfigDict(frozen=True, extra="forbid")
class _Text(_Converse):
text: str
class _CachePoint(_Converse):
cache_point: JsonValue = Field(alias="cachePoint")
class _ToolUse(_Converse):
tool_use_id: str = Field(alias="toolUseId")
name: str
arguments: Mapping[str, JsonValue] = Field(alias="input")
class _ToolUseBlock(_Converse):
tool_use: _ToolUse = Field(alias="toolUse")
class _ToolResult(_Converse):
tool_use_id: str = Field(alias="toolUseId")
content: tuple[_Text, ...]
status: str | None = None
class _ToolResultBlock(_Converse):
tool_result: _ToolResult = Field(alias="toolResult")
class _ReasoningText(_Converse):
text: str
signature: str | None = None
class _Reasoning(_Converse):
reasoning_text: _ReasoningText = Field(alias="reasoningText")
class _ReasoningBlock(_Converse):
reasoning_content: _Reasoning = Field(alias="reasoningContent")
class _UserMessage(_Converse):
role: Literal["user"]
content: tuple[_Text | _ToolResultBlock | _CachePoint, ...]
class _AssistantMessage(_Converse):
role: Literal["assistant"]
content: tuple[_Text | _ToolUseBlock | _ReasoningBlock | _CachePoint, ...]
def calls(self) -> tuple[_ToolUse, ...]:
return tuple(block.tool_use for block in self.content if isinstance(block, _ToolUseBlock))
class _InputSchema(_Converse):
definition: JsonValue = Field(alias="json")
class _ToolSpec(_Converse):
name: str
description: str | None = None
input_schema: _InputSchema = Field(alias="inputSchema")
def declared(self) -> Mapping[str, JsonValue]:
described: Final[Mapping[str, JsonValue]] = (
{} if self.description is None else {"description": self.description}
)
return {"name": self.name, **described, "parameters": self.input_schema.definition}
class _ToolSpecBlock(_Converse):
tool_spec: _ToolSpec = Field(alias="toolSpec")
class _ToolConfig(BaseModel):
model_config = ConfigDict(frozen=True, extra="ignore")
tools: tuple[_ToolSpecBlock | _CachePoint, ...]
_Message: TypeAlias = Annotated[_UserMessage | _AssistantMessage, Field(discriminator="role")]
def _result_ids(message: _Message) -> frozenset[str]:
if isinstance(message, _AssistantMessage):
return frozenset()
return frozenset(block.tool_result.tool_use_id for block in message.content if isinstance(block, _ToolResultBlock))
def _call_ids(message: _Message | None) -> frozenset[str]:
if not isinstance(message, _AssistantMessage):
return frozenset()
return frozenset(call.tool_use_id for call in message.calls())
class _ConverseRequest(BaseModel):
model_config = ConfigDict(frozen=True, extra="ignore")
messages: tuple[_Message, ...]
system: tuple[_Text | _CachePoint, ...] = ()
tool_config: _ToolConfig | None = Field(default=None, alias="toolConfig")
@model_validator(mode="after")
def _results_answer_the_previous_calls(self) -> Self:
pairs: Final = zip((None, *self.messages), self.messages, strict=False)
if not all(_result_ids(message) <= _call_ids(previous) for previous, message in pairs):
raise ValueError("a toolResult does not answer a toolUse of the previous assistant message")
return self
class _Control(Enum):
OPEN = "<|open|>"
CLOSE = "<|close|>"
SEP = "<|sep|>"
END_OF_MSG = "<|end_of_msg|>"
_Segment: TypeAlias = str | _Control
_Segments: TypeAlias = tuple[_Segment, ...]
def _open(tag: str, attrs: Iterable[tuple[str, str]] = ()) -> _Segments:
escaped: Final = (
(f" {key}", '="', value.replace("&", "&amp;").replace('"', "&quot;"), '"') for key, value in attrs
)
return (_Control.OPEN, tag, *chain.from_iterable(escaped), _Control.SEP)
def _close(tag: str) -> _Segments:
return (_Control.CLOSE, tag, _Control.SEP)
def _message(attrs: Iterable[tuple[str, str]], body: Iterable[_Segment]) -> _Segments:
return (*_open("message", attrs), *body, *_close("message"), _Control.END_OF_MSG)
def _tool_declare(specs: Sequence[_ToolSpec]) -> _Segments:
tools: Final = json.dumps(
[{"type": "function", "function": spec.declared()} for spec in specs],
ensure_ascii=False,
separators=(",", ":"),
sort_keys=True,
)
return _message(
(("role", "system"), ("type", "tool-declare")),
(f"# Tools\nHere are the available tools, described in JSONSchema.\n\n```json\n{tools}\n```",),
)
def _text(blocks: Iterable[_Converse]) -> str:
return "".join(block.text for block in blocks if isinstance(block, _Text))
def _json_type(value: JsonValue) -> str:
match value:
case bool():
return "boolean"
case None:
return "null"
case int() | float():
return "number"
case str():
return "string"
case dict():
return "object"
case _:
return "array"
def _argument(key: str, value: JsonValue) -> _Segments:
text: Final = value if isinstance(value, str) else json.dumps(value, ensure_ascii=False)
return (*_open("argument", (("key", key), ("type", _json_type(value)))), text, *_close("argument"))
def _call(index: int, call: _ToolUse) -> _Segments:
arguments: Final = chain.from_iterable(_argument(key, value) for key, value in call.arguments.items())
return (*_open("call", (("tool", call.name), ("index", str(index)))), *arguments, *_close("call"))
def _tool_calls(calls: Sequence[_ToolUse]) -> _Segments:
if not calls:
return ()
rendered: Final = chain.from_iterable(_call(index, call) for index, call in enumerate(calls, start=1))
return (*_open("tools"), *rendered, *_close("tools"))
def _assistant_message(message: _AssistantMessage) -> _Segments:
reasoning: Final = "".join(
block.reasoning_content.reasoning_text.text for block in message.content if isinstance(block, _ReasoningBlock)
)
return _message(
(("role", "assistant"),),
(
*_open("think"),
reasoning,
*_close("think"),
*_open("response"),
_text(message.content),
*_close("response"),
*_tool_calls(message.calls()),
),
)
def _tool_messages(results: Sequence[_ToolResult], previous: _AssistantMessage) -> _Segments:
names: Final = {call.tool_use_id: call.name for call in previous.calls()}
return tuple(
chain.from_iterable(
_message(
(("role", "tool"), ("tool", names[result.tool_use_id]), ("index", str(index))),
(_text(result.content),),
)
for index, result in enumerate(results, start=1)
)
)
def _user_run(blocks: Sequence[_Text | _ToolResultBlock], previous: _Message | None) -> _Segments:
results: Final = tuple(block.tool_result for block in blocks if isinstance(block, _ToolResultBlock))
if results and isinstance(previous, _AssistantMessage):
return _tool_messages(results, previous)
return _message((("role", "user"),), (_text(blocks),))
def _user_messages(message: _UserMessage, previous: _Message | None) -> _Segments:
blocks: Final = tuple(block for block in message.content if not isinstance(block, _CachePoint))
runs: Final = groupby(blocks, key=lambda block: isinstance(block, _ToolResultBlock))
return tuple(chain.from_iterable(_user_run(tuple(run), previous) for _, run in runs))
def _turn(previous: _Message | None, message: _Message) -> _Segments:
if isinstance(message, _AssistantMessage):
return _assistant_message(message)
return _user_messages(message, previous)
def _rendered(request: _ConverseRequest) -> _Segments:
specs: Final = (
()
if request.tool_config is None
else tuple(block.tool_spec for block in request.tool_config.tools if isinstance(block, _ToolSpecBlock))
)
system: Final = _text(request.system)
turns: Final = chain.from_iterable(
_turn(previous, message) for previous, message in zip((None, *request.messages), request.messages, strict=False)
)
return (
*(_tool_declare(specs) if specs else ()),
*(_message((("role", "system"),), (system,)) if system else ()),
*turns,
*_open("message", (("role", "assistant"),)),
*_open("think"),
)
def _text_tokens(encoding: tiktoken.Encoding, text: str) -> int:
spans: Final = (match.span() for match in _CHUNK.finditer(text))
return sum(len(encoding.encode_ordinary(text[start:end])) for start, end in spans)
class _TextPart(BaseModel):
model_config = ConfigDict(frozen=True, extra="ignore")
type: Literal["text"]
class _TextOnlyMessage(BaseModel):
model_config = ConfigDict(frozen=True, extra="ignore")
content: str | tuple[_TextPart, ...] | None = None
def _text_only(message: AllMessageValues) -> bool:
try:
_TextOnlyMessage.model_validate(message)
except ValidationError:
return False
return True
def _as_converse_input(tool: ChatCompletionToolParam) -> tuple[Mapping[str, object], ...]:
return chat_completion_tools_for(tool) if is_flat_responses_tool(tool) else (tool,)
def _converse_request(
model: str, messages: Sequence[AllMessageValues], tools: Sequence[ChatCompletionToolParam] | None
) -> _ConverseRequest | None:
if not all(_text_only(message) for message in messages):
return None
try:
chat_tools: Final = tuple(
chat_tool for tool in tools or () for chat_tool in _as_converse_input(tool)
) # comprehension-ok: flattens each tool's translation
return _ConverseRequest.model_validate(
litellm.AmazonConverseConfig().transform_request(
model=model,
messages=copy.deepcopy(list(messages)),
optional_params={"tools": copy.deepcopy(list(chat_tools))} if chat_tools else {},
litellm_params={},
headers={},
)
)
except Exception: # noqa: BLE001 # a body litellm cannot send to Bedrock, or cannot render, falls back to the estimate
return None
def kimi_k3_tokens(
encoding: tiktoken.Encoding,
model: str,
messages: Sequence[AllMessageValues],
tools: Sequence[ChatCompletionToolParam] | None,
) -> int | None:
request: Final = _converse_request(model, messages, tools)
if request is None:
return None
return sum(
1 if isinstance(segment, _Control) else _text_tokens(encoding, segment) for segment in _rendered(request)
)
def kimi_k3_anthropic_tokens(encoding: tiktoken.Encoding, model: str, fields: Mapping[str, object]) -> int | None:
body: Final = {**fields, "model": model, "max_tokens": 1}
try:
translated, _ = LiteLLMAnthropicMessagesAdapter().translate_anthropic_to_openai(
copy.deepcopy(body), # pyright: ignore[reportArgumentType] # raw /v1/messages fields
custom_llm_provider="bedrock",
)
except Exception: # noqa: BLE001 # a request the adapter cannot translate falls back to the estimate
return None
match translated:
case {"tools": tools}:
return kimi_k3_tokens(encoding, model, translated["messages"], tools)
case _:
return kimi_k3_tokens(encoding, model, translated["messages"], None)
def kimi_k3_template_tokens(message_count: int, has_tools: bool) -> int:
return 14 + 13 * max(message_count - 1, 0) + (40 if has_tools else 0)

View file

@ -52,7 +52,7 @@ class BedrockMantleCountTokensHandler(BaseAWSLLM):
) -> dict[str, JsonValue]:
try:
request: Final = _COUNT_REQUEST.validate_python(request_data)
aws_region_name: Final = self._get_aws_region_name(
aws_region_name: Final = self.get_aws_region_name(
optional_params=litellm_params, model=resolved_model, model_id=None
)
endpoint_url: Final = self.get_mantle_count_tokens_endpoint(aws_region_name)

View file

@ -124,6 +124,7 @@ class OpenAITokenCounter(BaseTokenCounter):
error=True,
error_message=request_error,
status_code=400,
rejected_before_provider_call=True,
)
deployment = deployment or {}

View file

@ -7,6 +7,7 @@ from typing import TYPE_CHECKING, Final, Literal
from pydantic import JsonValue, TypeAdapter, ValidationError
from typing_extensions import assert_never
from litellm.litellm_core_utils.prompt_templates.factory import chat_completion_tools_for
from litellm.types.llms.anthropic import AnthropicMessagesTextParam
from litellm.types.llms.openai import AllMessageValues
from litellm.types.llms.vertex_ai import ContentType
@ -75,27 +76,6 @@ def _is_function_tool(tool: Mapping[str, JsonValue]) -> bool:
return isinstance(tool.get("name"), str) or "input_schema" in tool or "function" in tool
def _is_responses_function_tool(tool: Mapping[str, JsonValue]) -> bool:
return tool.get("type") == "function" and "function" not in tool and "input_schema" not in tool
def _chat_tool(tool: Mapping[str, JsonValue]) -> tuple[Mapping[str, object], ...]:
from litellm.llms.anthropic.pass_through.adapters.transformation import LiteLLMAnthropicMessagesAdapter
from litellm.responses.litellm_completion_transformation.transformation import (
LiteLLMCompletionResponsesConfig,
)
if _is_responses_function_tool(tool):
chat_tools, _ = LiteLLMCompletionResponsesConfig.transform_responses_api_tools_to_chat_completion_tools(
[dict(tool)] # pyright: ignore[reportArgumentType] # validated JSON dict in the Responses function tool shape
)
return tuple(chat_tools)
anthropic_tools, _ = LiteLLMAnthropicMessagesAdapter().translate_anthropic_tools_to_openai(
[dict(tool)] # pyright: ignore[reportArgumentType] # validated JSON dict in an Anthropic or chat tool shape
)
return tuple(anthropic_tools)
def _map_function_tools(
tools: object | None,
) -> tuple[Mapping[str, object], ...] | None | VertexGeminiCountContextError:
@ -112,7 +92,7 @@ def _map_function_tools(
from litellm.llms.vertex_ai.gemini.vertex_and_google_ai_studio_gemini import VertexGeminiConfig
chat_tools: Final = tuple(
chat_tool for tool in function_tools for chat_tool in _chat_tool(tool)
chat_tool for tool in function_tools for chat_tool in chat_completion_tools_for(tool)
) # comprehension-ok: flattens each tool's translation
try:
mapped_tools: Final = VertexGeminiConfig().map_function(
@ -163,6 +143,7 @@ def count_context_error_response(
error=True,
error_message=error.error_message,
status_code=error.status_code,
rejected_before_provider_call=True,
)

View file

@ -21144,6 +21144,7 @@
"input_cost_per_token": 1.4e-07,
"input_dbu_cost_per_token": 2e-06,
"litellm_provider": "databricks",
"token_count_framing": 97,
"max_input_tokens": 1000000,
"max_output_tokens": 393216,
"max_tokens": 393216,
@ -21166,6 +21167,11 @@
"supports_tool_choice": true,
"supports_vision": false
},
"databricks/system.ai.deepseek-v4-flash-0731": {
"litellm_provider": "databricks",
"mode": "chat",
"token_count_framing": 97
},
"databricks/databricks-deepseek-v4-pro-0813": {
"cache_creation_input_token_cost": 1.31999e-06,
"cache_read_input_token_cost": 1.3202e-07,
@ -22134,6 +22140,7 @@
"input_cost_per_token": 2.99999e-06,
"input_dbu_cost_per_token": 4.2857e-05,
"litellm_provider": "databricks",
"token_count_framing": 100,
"max_input_tokens": 1000000,
"max_output_tokens": 1048576,
"max_tokens": 1048576,
@ -22157,6 +22164,11 @@
"supports_tool_choice": true,
"supports_vision": true
},
"databricks/system.ai.kimi-k3": {
"litellm_provider": "databricks",
"mode": "chat",
"token_count_framing": 100
},
"databricks/databricks-llama-4-maverick": {
"cache_creation_input_token_cost": 5.0001e-07,
"cache_read_input_token_cost": 5.0001e-07,
@ -71540,6 +71552,8 @@
"input_cost_per_token_flex": 1.65e-06,
"input_cost_per_token_priority": 5.775e-06,
"litellm_provider": "bedrock_converse",
"token_count_framing": 64,
"token_count_tokenizer": "kimi_k3",
"max_input_tokens": 1000000,
"max_output_tokens": 131072,
"max_tokens": 131072,
@ -71570,6 +71584,8 @@
"input_cost_per_token_flex": 1.5e-06,
"input_cost_per_token_priority": 5.25e-06,
"litellm_provider": "bedrock_converse",
"token_count_framing": 64,
"token_count_tokenizer": "kimi_k3",
"max_input_tokens": 1000000,
"max_output_tokens": 131072,
"max_tokens": 131072,
@ -71600,6 +71616,8 @@
"input_cost_per_token_flex": 1.65e-06,
"input_cost_per_token_priority": 5.775e-06,
"litellm_provider": "bedrock_converse",
"token_count_framing": 64,
"token_count_tokenizer": "kimi_k3",
"max_input_tokens": 1000000,
"max_output_tokens": 131072,
"max_tokens": 131072,
@ -73100,6 +73118,10 @@
"cache_read_input_token_cost": 1e-07,
"input_cost_per_token": 2e-06,
"litellm_provider": "bedrock_converse",
"token_count_model": {
"model": "anthropic.claude-opus-5-5",
"region": "us-east-1"
},
"supports_tool_search": true,
"max_input_tokens": 1000000,
"max_output_tokens": 128000,
@ -73173,6 +73195,10 @@
"source": "https://pricing.us-east-1.amazonaws.com/offers/v1.0/aws/AmazonBedrockFoundationModels/current/index.json"
},
"au.anthropic.claude-sonnet-5-5": {
"token_count_model": {
"model": "au.anthropic.claude-opus-5-5",
"region": "ap-southeast-4"
},
"bedrock_converse_supports_strict_tools": false,
"cache_creation_input_token_cost": 2.75e-06,
"cache_creation_input_token_cost_above_1hr": 4.4e-06,
@ -73373,6 +73399,10 @@
"cache_read_input_token_cost": 1e-07,
"input_cost_per_token": 2e-06,
"litellm_provider": "bedrock_converse",
"token_count_model": {
"model": "global.anthropic.claude-opus-5-5",
"region": "us-east-1"
},
"supports_tool_search": true,
"max_input_tokens": 1000000,
"max_output_tokens": 128000,
@ -73582,6 +73612,10 @@
"cache_read_input_token_cost": 1.1e-07,
"input_cost_per_token": 2.2e-06,
"litellm_provider": "bedrock_converse",
"token_count_model": {
"model": "us.anthropic.claude-opus-5-5",
"region": "us-east-1"
},
"supports_tool_search": true,
"max_input_tokens": 1000000,
"max_output_tokens": 128000,
@ -74382,6 +74416,8 @@
"input_cost_per_token_flex": 1.65e-06,
"input_cost_per_token_priority": 5.775e-06,
"litellm_provider": "bedrock_converse",
"token_count_framing": 64,
"token_count_tokenizer": "kimi_k3",
"max_input_tokens": 1000000,
"max_output_tokens": 131072,
"max_tokens": 131072,
@ -74555,6 +74591,10 @@
"cache_read_input_token_cost": 1e-08,
"input_cost_per_token": 1e-07,
"litellm_provider": "bedrock_converse",
"token_count_model": {
"model": "anthropic.claude-opus-5-5",
"region": "us-east-1"
},
"supports_tool_search": true,
"max_input_tokens": 1000000,
"max_output_tokens": 128000,
@ -74633,6 +74673,10 @@
"source": "https://pricing.us-east-1.amazonaws.com/offers/v1.0/aws/AmazonBedrockFoundationModels/current/index.json"
},
"au.anthropic.claude-haiku-5-5": {
"token_count_model": {
"model": "au.anthropic.claude-opus-5-5",
"region": "ap-southeast-4"
},
"input_cost_per_token_above_100k_tokens": 5.5e-07,
"output_cost_per_token_above_100k_tokens": 2.75e-06,
"cache_creation_input_token_cost_above_100k_tokens": 6.875e-07,
@ -74863,6 +74907,10 @@
"cache_read_input_token_cost": 1e-08,
"input_cost_per_token": 1e-07,
"litellm_provider": "bedrock_converse",
"token_count_model": {
"model": "global.anthropic.claude-opus-5-5",
"region": "us-east-1"
},
"supports_tool_search": true,
"max_input_tokens": 1000000,
"max_output_tokens": 128000,
@ -75010,6 +75058,10 @@
"cache_read_input_token_cost": 1.1e-08,
"input_cost_per_token": 1.1e-07,
"litellm_provider": "bedrock_converse",
"token_count_model": {
"model": "us.anthropic.claude-opus-5-5",
"region": "us-east-1"
},
"supports_tool_search": true,
"max_input_tokens": 1000000,
"max_output_tokens": 128000,

View file

@ -48,6 +48,7 @@ from litellm.proxy.common_utils.openai_error_payload import (
openai_error_type,
with_litellm_call_id,
)
from litellm.proxy.common_utils.token_count_settings import count_source_fields
from litellm.proxy.litellm_pre_call_utils import should_auto_drop_params_for_agentic_cli
from litellm.router import Router
@ -420,7 +421,7 @@ async def count_tokens(
log_llm_api_exception(e, litellm_call_id)
return _anthropic_error_json_response(proxy_exception_from_route_error(e), request)
else:
return {"input_tokens": token_response.total_tokens}
return {"input_tokens": token_response.total_tokens, **count_source_fields(token_response)}
@router.post(

View file

@ -0,0 +1,164 @@
from collections.abc import Callable, Mapping
from dataclasses import dataclass, replace
from types import MappingProxyType
from typing import Final, Literal
from pydantic import BaseModel, ConfigDict, ValidationError
import litellm
from litellm.anthropic_interface.exceptions.exception_mapping_utils import AnthropicExceptionMapping
from litellm.litellm_core_utils.core_helpers import as_str_mapping
from litellm.proxy._types import ProxyException
from litellm.types.token_count import TokenCountSourceFields, TokenCountTokenizer
from litellm.types.utils import TokenCountResponse
CANNOT_COUNT_MODEL_STATUSES: Final = frozenset({400, 404})
TOKENIZER_LOAD_WAIT_SECONDS: Final = 5.0
TOKENIZER_LOAD_REUSE_SECONDS: Final = 60.0
TOKENIZER_LOAD_MAX_RUNNING: Final = 4
class _UpstreamErrorDetail(BaseModel):
model_config = ConfigDict(frozen=True, extra="allow")
message: str
class _UpstreamErrorEnvelope(BaseModel):
model_config = ConfigDict(frozen=True, extra="allow")
type: Literal["error"]
error: _UpstreamErrorDetail
def _joined(message: str, hint: str) -> str:
return f"{message.rstrip().rstrip('.')}. {hint}"
def _with_hint(message: str, hint: str) -> str:
try:
envelope: Final = _UpstreamErrorEnvelope.model_validate_json(message)
except ValidationError:
return _joined(AnthropicExceptionMapping.extract_error_message(message), hint)
detail: Final = envelope.error.model_copy(update={"message": _joined(envelope.error.message, hint)})
return envelope.model_copy(update={"error": detail}).model_dump_json()
@dataclass(frozen=True, slots=True)
class TokenCountModel:
model: str
region: str | None
@dataclass(frozen=True, slots=True)
class TokenCountSettings:
framing: int | None
count_model: TokenCountModel | None
tokenizer: TokenCountTokenizer | None = None
@dataclass(frozen=True, slots=True)
class ProviderCountFailure:
status_code: int
message: str
rejected_before_provider_call: bool = False
@property
def cannot_count_model(self) -> bool:
return not self.rejected_before_provider_call and self.status_code in CANNOT_COUNT_MODEL_STATUSES
def with_hint(self, hint: str) -> "ProviderCountFailure":
return replace(self, message=_with_hint(self.message, hint))
def to_proxy_exception(self) -> ProxyException:
return ProxyException(
message=self.message,
type="token_counting_error",
param="model",
code=self.status_code,
)
def cost_map_entry(model: str, custom_llm_provider: str | None = None) -> Mapping[str, object]:
try:
return litellm.get_model_info(model=model, custom_llm_provider=custom_llm_provider)
except Exception: # noqa: BLE001 # get_model_info raises bare Exception for unmapped models
return MappingProxyType({})
def _framing(value: object) -> int | None:
match value:
case bool():
return None
case int() if value >= 0:
return value
case float() if value.is_integer() and value >= 0:
return int(value)
case _:
return None
def _non_blank(value: object) -> str | None:
return value if isinstance(value, str) and value.strip() else None
def _count_model(value: Mapping[str, object]) -> TokenCountModel | None:
model: Final = _non_blank(value.get("model"))
return None if model is None else TokenCountModel(model=model, region=_non_blank(value.get("region")))
def _geography(region: str) -> str:
return region.rsplit("-", 2)[0]
def _within_deployment_geography(
count_model: TokenCountModel | None, deployment_region: Callable[[], str]
) -> TokenCountModel | None:
if count_model is None or count_model.region is None or count_model.model.startswith("global."):
return count_model
if _geography(deployment_region()) == _geography(count_model.region):
return count_model
return replace(count_model, region=None)
def _resolved_count_model(
configured: object, default: object, deployment_region: Callable[[], str]
) -> TokenCountModel | None:
configured_mapping: Final = as_str_mapping(configured)
if configured_mapping is not None:
return _count_model(configured_mapping)
default_mapping: Final = as_str_mapping(default)
return (
None
if default_mapping is None
else _within_deployment_geography(_count_model(default_mapping), deployment_region)
)
def _tokenizer(value: object) -> TokenCountTokenizer | None:
match value:
case "kimi_k3":
return "kimi_k3"
case _:
return None
def resolve_token_count_settings(
configured: Mapping[str, object], default: Mapping[str, object], deployment_region: Callable[[], str]
) -> TokenCountSettings:
framing: Final = _framing(configured.get("token_count_framing"))
return TokenCountSettings(
framing=framing if framing is not None else _framing(default.get("token_count_framing")),
count_model=_resolved_count_model(
configured.get("token_count_model"), default.get("token_count_model"), deployment_region
),
tokenizer=_tokenizer(default.get("token_count_tokenizer")),
)
def count_source_fields(response: TokenCountResponse) -> TokenCountSourceFields:
match response.count_source:
case None:
return {}
case "token_count_model":
return {"litellm_count_source": "token_count_model", "litellm_count_model": response.model_used}
case source:
return {"litellm_count_source": source}

View file

@ -21,6 +21,7 @@ from litellm.proxy.common_utils.http_parsing_utils import ( # noqa: F401 # leg
_read_request_body, # pyright: ignore[reportPrivateUsage,reportUnusedImport] # backwards-compatible package export
read_request_body,
)
from litellm.proxy.common_utils.token_count_settings import count_source_fields
from litellm.types.llms.vertex_ai import TokenCountDetailsResponse
@ -309,10 +310,12 @@ async def google_count_tokens(request: Request, model_name: str):
ProxyException(message="Internal server error", type="internal_server_error", param=None, code=500),
)
original_response: Final[dict] = token_response.original_response or {}
return TokenCountDetailsResponse(
totalTokens=original_response.get("totalTokens") or token_response.total_tokens or 0,
promptTokensDetails=original_response.get("promptTokensDetails", []),
)
counted: Final[TokenCountDetailsResponse] = {
"totalTokens": original_response.get("totalTokens") or token_response.total_tokens or 0,
"promptTokensDetails": original_response.get("promptTokensDetails", []),
**count_source_fields(token_response),
}
return counted
# ============================================================

View file

@ -55,6 +55,7 @@ from typing import (
)
import anyio
import anyio.lowlevel
import websockets
import websockets.exceptions
from pydantic import BaseModel, ConfigDict, Json, JsonValue, TypeAdapter, ValidationError, with_config
@ -99,6 +100,13 @@ from litellm.litellm_core_utils.litellm_logging import (
from litellm.litellm_core_utils.safe_json_dumps import safe_dumps
from litellm.litellm_core_utils.safe_json_loads import safe_json_loads
from litellm.litellm_core_utils.token_counter import offload_token_count
from litellm.llms.bedrock.count_tokens.kimi_k3 import (
HUGGING_FACE_SOURCE,
KimiK3Encodings,
kimi_k3_anthropic_tokens,
kimi_k3_template_tokens,
kimi_k3_tokens,
)
from litellm.proxy._types import (
UI_TEAM_ID,
AwsErrorTypeProxyException,
@ -149,6 +157,15 @@ from litellm.proxy.common_utils.realtime_utils import ( # noqa: F401, RUF100 #
_realtime_request_body, # pyright: ignore[reportPrivateUsage,reportUnusedImport] # backwards-compatible package export
realtime_request_body,
)
from litellm.proxy.common_utils.token_count_settings import (
TOKENIZER_LOAD_MAX_RUNNING,
TOKENIZER_LOAD_REUSE_SECONDS,
TOKENIZER_LOAD_WAIT_SECONDS,
ProviderCountFailure,
TokenCountSettings,
cost_map_entry,
resolve_token_count_settings,
)
from litellm.proxy.management_helpers.auto_router_availability import AutoRouterCatalogEntry, build_auto_router_catalog
from litellm.router_utils.access_windows import access_windows_config_error
from litellm.router_utils.add_retry_fallback_headers import (
@ -304,6 +321,7 @@ from litellm.constants import (
CLI_SSO_SESSION_TTL_SECONDS,
DAYS_IN_A_MONTH,
DEFAULT_HEALTH_CHECK_INTERVAL,
DEFAULT_MAX_LRU_CACHE_SIZE,
DEFAULT_MODEL_CREATED_AT_TIME,
GLOBAL_PROXY_SPEND_CACHE_KEY,
LITELLM_PROXY_ADMIN_NAME,
@ -337,6 +355,7 @@ from litellm.litellm_core_utils.core_helpers import (
drop_params_flag,
get_litellm_metadata_from_kwargs,
get_parent_otel_span_from_kwargs,
iter_items,
)
from litellm.litellm_core_utils.credential_accessor import CredentialAccessor
from litellm.litellm_core_utils.litellm_logging import Logging as LiteLLMLoggingObj
@ -993,7 +1012,13 @@ from litellm.types.secret_managers.main import (
KeyManagementSettings,
KeyManagementSystem,
)
from litellm.types.utils import CredentialItem, CustomHuggingfaceTokenizer, RawRequestTypedDict, StandardLoggingPayload
from litellm.types.utils import (
CredentialItem,
CustomHuggingfaceTokenizer,
RawRequestTypedDict,
SelectTokenizerResponse,
StandardLoggingPayload,
)
from litellm.types.utils import ModelInfo as ModelMapInfo
from litellm.utils import add_custom_logger_callback_to_specific_event
@ -14673,25 +14698,36 @@ async def _try_provider_token_count(
provider_counter: "BaseTokenCounter",
custom_llm_provider: str | None,
model_to_use: str,
region: str | None,
messages: list | None,
contents: list | None,
deployment: dict[str, object] | None,
deployment: Mapping[str, object] | None,
request_model: str,
tools: list | None = None,
system: str | None = None,
messages_format: TokenCountMessagesFormat = "openai",
output_format: JsonValue = None,
context_management: CountContextManagement | None = None,
) -> Optional["TokenCountResponse"]:
"""Attempt provider-specific token counting. Returns result on success, None to fall through to local counting."""
) -> Union["TokenCountResponse", ProviderCountFailure, None]:
"""Attempt provider-specific token counting. Returns None to fall through to local counting."""
if not provider_counter.should_use_token_counting_api(custom_llm_provider=custom_llm_provider):
return None
deployment_in_region: Final = (
None
if deployment is None
else dict(deployment)
if region is None
else {
**deployment,
"litellm_params": {**(as_str_mapping(deployment.get("litellm_params")) or {}), "aws_region_name": region},
}
)
try:
result: Final = await provider_counter.count_tokens(
model_to_use=model_to_use or "",
messages=messages,
contents=contents,
deployment=deployment,
deployment=deployment_in_region,
request_model=request_model,
tools=tools,
system=system,
@ -14700,24 +14736,325 @@ async def _try_provider_token_count(
context_management=context_management,
)
except httpx.HTTPStatusError as e:
error_message: Final = getattr(e, "message", None) or str(e)
status_code: Final = getattr(e, "status_code", None) or e.response.status_code
raise ProxyException(
message=error_message,
type="token_counting_error",
param="model",
code=status_code,
return ProviderCountFailure(
status_code=getattr(e, "status_code", None) or e.response.status_code,
message=getattr(e, "message", None) or str(e),
)
if result is not None and result.error is True:
raise ProxyException(
return ProviderCountFailure(
status_code=result.status_code or 500,
message=result.error_message or "Token counting failed",
type="token_counting_error",
param="model",
code=result.status_code or 500,
rejected_before_provider_call=result.rejected_before_provider_call,
)
return result
async def _provider_token_count(
count_with: Callable[[str, str | None], Awaitable[Union["TokenCountResponse", ProviderCountFailure, None]]],
model_to_use: str,
settings: TokenCountSettings,
local_estimate_allowed: bool,
) -> Union["TokenCountResponse", ProviderCountFailure, None]:
"""Count with the provider, using the token_count_model in its region when one is set.
Returns None when the count should be estimated locally, or the provider's failure when the framed local
estimate stands in for it and that failure is the answer if the estimate cannot read the request."""
count_model: Final = settings.count_model
counted: Final = await (
count_with(model_to_use, None) if count_model is None else count_with(count_model.model, count_model.region)
)
if counted is None:
return None
if isinstance(counted, TokenCountResponse):
if count_model is None:
return counted.model_copy(update={"count_source": "provider"})
return counted.model_copy(update={"count_source": "token_count_model", "model_used": count_model.model})
failure: Final = (
counted
if count_model is None or count_model.region is None
else counted.with_hint(
f"This model counts tokens with {count_model.model} in {count_model.region}. "
"If that region is not enabled for the AWS account, enable it or set token_count_model in model_info"
)
)
if count_model is None and failure.cannot_count_model and settings.framing is not None and local_estimate_allowed:
return failure
raise failure.to_proxy_exception()
@dataclass(frozen=True, slots=True)
class _LocalTokenEstimate:
total_tokens: int
tokenizer_type: str
def _is_fork_guard_error(error: BaseException) -> bool:
from litellm.rust_bridge.fork_guard import ForkedAfterNativeRuntimeStarted, ProcessReservedForForking
return isinstance(error, (ForkedAfterNativeRuntimeStarted, ProcessReservedForForking))
def _is_tokenizer_panic(error: BaseException) -> bool:
return type(error).__module__ == "pyo3_runtime" and type(error).__name__ == "PanicException"
def _answers_provider_error(error: BaseException) -> bool:
return (isinstance(error, Exception) or _is_tokenizer_panic(error)) and not _is_fork_guard_error(error)
def _provider_error_after_failed_estimate(
provider_failure: ProviderCountFailure, error: BaseException
) -> ProxyException:
verbose_proxy_logger.warning(
"Local token estimate failed after the provider's count error %s, answering that error: %s",
provider_failure.status_code,
type(error).__name__,
)
return provider_failure.to_proxy_exception()
async def _estimate_or_provider_error(estimate: Awaitable[int], provider_failure: ProviderCountFailure) -> int:
try:
return await estimate
except BaseException as e:
if not _answers_provider_error(e):
raise
raise _provider_error_after_failed_estimate(provider_failure, e) from e
@dataclass(frozen=True, slots=True)
class _CustomTokenizerKey:
identifier: str
revision: str
auth_token: str | None
class _TokenizerLoadsBusy(Exception):
pass
@dataclass(frozen=True, slots=True)
class _TokenizerLoadOutcome:
tokenizer: SelectTokenizerResponse | BaseException
finished_at: float
@dataclass(frozen=True, slots=True)
class _SharedTokenizerLoad:
outcome: asyncio.Task[_TokenizerLoadOutcome]
started_at: float
def reusable(self, now: float) -> bool:
return not self.outcome.done() or now - self.outcome.result().finished_at < TOKENIZER_LOAD_REUSE_SECONDS
async def _loaded_tokenizer(
model: str, custom_tokenizer: CustomHuggingfaceTokenizer | None, threads: anyio.CapacityLimiter
) -> SelectTokenizerResponse | BaseException:
try:
return await asyncify(litellm.utils.select_tokenizer, limiter=threads)(
model=model, custom_tokenizer=custom_tokenizer
)
except asyncio.CancelledError:
raise
except BaseException as e: # noqa: BLE001 # every count sharing this load decides what it answers, pyo3 panics included
return e
async def _tokenizer_load_outcome(
model: str, custom_tokenizer: CustomHuggingfaceTokenizer | None, threads: anyio.CapacityLimiter
) -> _TokenizerLoadOutcome:
tokenizer: Final = await _loaded_tokenizer(model, custom_tokenizer, threads)
return _TokenizerLoadOutcome(tokenizer=tokenizer, finished_at=asyncio.get_running_loop().time())
@dataclass(slots=True)
class _TokenizerLoads:
threads: anyio.CapacityLimiter
running: int = 0
def start(
self, model: str, custom_tokenizer: CustomHuggingfaceTokenizer | None, loop: asyncio.AbstractEventLoop
) -> _SharedTokenizerLoad | None:
if self.running >= self.threads.total_tokens:
return None
self.running += 1
outcome: Final = loop.create_task(_tokenizer_load_outcome(model, custom_tokenizer, self.threads))
outcome.add_done_callback(self._finished)
return _SharedTokenizerLoad(outcome=outcome, started_at=loop.time())
def _finished(self, _outcome: asyncio.Task[_TokenizerLoadOutcome]) -> None:
self.running -= 1
_TOKENIZER_LOADS: Final = anyio.lowlevel.RunVar[_TokenizerLoads]("litellm_proxy_tokenizer_loads")
def _tokenizer_loads_for_this_loop() -> _TokenizerLoads:
existing: Final = _TOKENIZER_LOADS.get(None)
if existing is not None:
return existing
created: Final = _TokenizerLoads(threads=anyio.CapacityLimiter(TOKENIZER_LOAD_MAX_RUNNING))
_TOKENIZER_LOADS.set(created)
return created
@dataclass(slots=True)
class _TokenizerLoadSlot:
model: str
custom_tokenizer: CustomHuggingfaceTokenizer | None
current: _SharedTokenizerLoad | None = None
def load(self, loads: _TokenizerLoads, loop: asyncio.AbstractEventLoop) -> _SharedTokenizerLoad | None:
current: Final = self.current
if current is not None and current.reusable(loop.time()):
return current
self.current = loads.start(self.model, self.custom_tokenizer, loop)
return self.current
@lru_cache(maxsize=DEFAULT_MAX_LRU_CACHE_SIZE)
def _tokenizer_load_slot(
model: str, custom_tokenizer: _CustomTokenizerKey | None, loop: asyncio.AbstractEventLoop
) -> _TokenizerLoadSlot:
return _TokenizerLoadSlot(
model=model,
custom_tokenizer=None
if custom_tokenizer is None
else CustomHuggingfaceTokenizer(
identifier=custom_tokenizer.identifier,
revision=custom_tokenizer.revision,
auth_token=custom_tokenizer.auth_token,
),
)
async def _tokenizer_after_provider_error(
model: str, custom_tokenizer: CustomHuggingfaceTokenizer | None
) -> SelectTokenizerResponse | BaseException:
loop: Final = asyncio.get_running_loop()
try:
load: Final = _tokenizer_load_slot(
model,
None
if custom_tokenizer is None
else _CustomTokenizerKey(
identifier=custom_tokenizer["identifier"],
revision=custom_tokenizer["revision"],
auth_token=custom_tokenizer["auth_token"],
),
loop,
).load(_tokenizer_loads_for_this_loop(), loop)
if load is None:
return _TokenizerLoadsBusy()
outcome: Final = await asyncio.wait_for(
asyncio.shield(load.outcome),
timeout=max(load.started_at + TOKENIZER_LOAD_WAIT_SECONDS - loop.time(), 0),
)
except Exception as e: # noqa: BLE001 # a tokenizer that cannot be read, loaded in time, or loaded at all answers the provider's error
return e
return outcome.tokenizer
async def _tokenizer_or_provider_error(
model: str, custom_tokenizer: CustomHuggingfaceTokenizer | None, provider_failure: ProviderCountFailure
) -> SelectTokenizerResponse:
tokenizer: Final = await _tokenizer_after_provider_error(model, custom_tokenizer)
if not isinstance(tokenizer, BaseException):
return tokenizer
if not _answers_provider_error(tokenizer):
raise tokenizer.with_traceback(None)
raise _provider_error_after_failed_estimate(provider_failure, tokenizer) from tokenizer
async def _local_token_estimate(
model: str,
prompt: str | None,
messages: Sequence[AllMessageValues] | None,
tools: list[ChatCompletionToolParam] | None,
custom_tokenizer: CustomHuggingfaceTokenizer | None,
provider_failure: ProviderCountFailure | None,
) -> _LocalTokenEstimate:
tokenizer: Final = (
await asyncify(litellm.utils.select_tokenizer)(model=model, custom_tokenizer=custom_tokenizer)
if provider_failure is None
else await _tokenizer_or_provider_error(model, custom_tokenizer, provider_failure)
)
count: Final = offload_token_count(litellm.token_counter)(
model=model,
text=prompt,
messages=messages,
tools=tools,
custom_tokenizer=tokenizer,
)
total_tokens: Final = await (
count if provider_failure is None else _estimate_or_provider_error(count, provider_failure)
)
return _LocalTokenEstimate(total_tokens=total_tokens, tokenizer_type=str(tokenizer["type"]))
_KIMI_K3_ENCODINGS: Final = KimiK3Encodings(
source=HUGGING_FACE_SOURCE, retry_after_seconds=TOKENIZER_LOAD_REUSE_SECONDS
)
async def _kimi_k3_count(
request: TokenCountRequest,
messages_format: TokenCountMessagesFormat,
model: str,
messages: Sequence[AllMessageValues],
tools: Sequence[ChatCompletionToolParam] | 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":
return await offload_token_count(kimi_k3_anthropic_tokens)(
encoding, model, request.model_dump(include={"messages", "system", "tools"}, exclude_none=True)
)
case "openai":
return await offload_token_count(kimi_k3_tokens)(encoding, model, messages, tools)
class _TokenCountDeployment(TypedDict, total=False):
litellm_params: ReadOnly[Mapping[str, object]]
model_info: ReadOnly[Mapping[str, object]]
_TOKEN_COUNT_DEPLOYMENT: Final = TypeAdapter(_TokenCountDeployment)
def _model_group_deployment(router: Router | None, model_group: str) -> Mapping[str, object] | None:
group: Final = () if router is None else router.get_all_deployments(model_name=model_group)
return group[0] if group else None
def _non_empty_str(value: object) -> str | None:
return value if isinstance(value, str) and value else None
def _deployment_aws_region(deployment: _TokenCountDeployment) -> str:
from litellm.llms.bedrock.base_aws_llm import BaseAWSLLM
params: Final = deployment.get("litellm_params", {})
return BaseAWSLLM().get_aws_region_name(
{"aws_region_name": params.get("aws_region_name")}, model=_non_empty_str(params.get("model"))
)
def _token_count_settings_model(deployment: _TokenCountDeployment, request_model: str) -> tuple[str, str | None]:
model_info: Final = deployment.get("model_info", {})
params: Final = deployment.get("litellm_params", {})
base_model: Final = _non_empty_str(model_info.get("base_model")) or _non_empty_str(params.get("base_model"))
if base_model is not None:
return base_model, None
model: Final = _non_empty_str(params.get("model"))
if model is None:
return request_model, None
return model, _non_empty_str(params.get("custom_llm_provider"))
_PROVIDERS_COUNTING_WITHOUT_MESSAGES: Final = frozenset(
{litellm.LlmProviders.GEMINI.value, litellm.LlmProviders.VERTEX_AI.value}
)
@ -14728,6 +15065,14 @@ def _drops_params(deployment: Mapping[str, object] | None) -> bool:
return litellm.drop_params is True or (litellm_params is not None and litellm_params.get("drop_params") is True)
_ESTIMABLE_GOOGLE_PART_KEYS: Final = frozenset({"text", "functionCall", "functionResponse", "thoughtSignature"})
def _estimable_contents(contents: Sequence[Mapping[str, object]]) -> bool:
parts: Final = chain.from_iterable(iter_items(content.get("parts") or ()) for content in contents)
return all(set(as_str_mapping(part) or ()) <= _ESTIMABLE_GOOGLE_PART_KEYS for part in parts)
def _router_default_litellm_params(router: Router) -> Mapping[str, object]:
defaults: Final[Mapping[str, object] | None] = getattr(router, "default_litellm_params", None)
if defaults is None:
@ -14831,6 +15176,14 @@ async def count_request_tokens(
model_to_use: str = (
litellm_model_name if isinstance(litellm_model_name, str) and litellm_model_name else request.model
)
deployment_view: Final = _TOKEN_COUNT_DEPLOYMENT.validate_python(
deployment or _model_group_deployment(llm_router, request.model) or {}
)
settings: Final = await asyncify(resolve_token_count_settings)(
configured=deployment_view.get("model_info", {}),
default=await asyncify(cost_map_entry)(*_token_count_settings_model(deployment_view, request.model)),
deployment_region=partial(_deployment_aws_region, deployment_view),
)
# Try provider-specific token counting first - only for non-direct requests (from provider endpoints)
provider_counter: BaseTokenCounter | None = None
@ -14850,24 +15203,33 @@ async def count_request_tokens(
code=400,
)
prompt_only: Final = bool(prompt) and conversation_empty
if provider_counter is not None:
prompt_as_message: Final = prompt_only and custom_llm_provider in _PROVIDERS_COUNTING_WITHOUT_MESSAGES
result: Final = await _try_provider_token_count(
provider_counter=provider_counter,
custom_llm_provider=custom_llm_provider,
contents_estimable: Final = not contents or (bool(messages) and _estimable_contents(contents))
prompt_as_message: Final = prompt_only and custom_llm_provider in _PROVIDERS_COUNTING_WITHOUT_MESSAGES
ladder: Final = (
None
if provider_counter is None
else await _provider_token_count(
count_with=partial(
_try_provider_token_count,
provider_counter,
custom_llm_provider,
messages=[{"role": "user", "content": prompt}] if prompt_as_message else messages,
contents=None if prompt_as_message else contents,
deployment=deployment,
request_model=request.model,
tools=tools,
system=system,
messages_format=messages_format,
output_format=output_format,
context_management=_count_context_management(context_management, request.model, request_litellm_params),
),
model_to_use=model_to_use,
messages=[{"role": "user", "content": prompt}] if prompt_as_message else messages,
contents=None if prompt_as_message else contents,
deployment=deployment,
request_model=request.model,
tools=tools,
system=system,
messages_format=messages_format,
output_format=output_format,
context_management=_count_context_management(context_management, request.model, request_litellm_params),
settings=settings,
local_estimate_allowed=litellm.disable_token_counter is not True and contents_estimable,
)
if result is not None:
return result
)
if isinstance(ladder, TokenCountResponse):
return ladder
# Check if token counter is disabled before fallback
if litellm.disable_token_counter is True:
@ -14885,11 +15247,6 @@ async def count_request_tokens(
CustomHuggingfaceTokenizer | None,
model_info.get("custom_tokenizer", None),
)
_tokenizer_used: Final = await asyncify(litellm.utils.select_tokenizer)(
model=model_to_use, custom_tokenizer=custom_tokenizer
)
tokenizer_used: Final = str(_tokenizer_used["type"])
system_message: Final = _system_message(system)
prompt_turn: Final = prompt_only and not (system_message is None and not tools)
typed_messages: Final = cast( # cast-ok: request messages are raw chat-shaped dicts that token_counter normalizes
@ -14902,18 +15259,38 @@ async def count_request_tokens(
counted_tools: Final = cast( # cast-ok: raw OpenAI or Anthropic tool dicts, both of which token_counter formats
list[ChatCompletionToolParam] | None, tools if counted_messages is not None else None
)
total_tokens: Final = await offload_token_count(litellm.token_counter)(
framing: Final = settings.framing if counted_messages is not None and settings.framing is not None else 0
kimi_k3_messages: Final = counted_messages if settings.tokenizer == "kimi_k3" else None
exact: Final = (
await _kimi_k3_count(request, messages_format, model_to_use, kimi_k3_messages, counted_tools)
if kimi_k3_messages is not None and custom_tokenizer is None
else None
)
if exact is not None:
return TokenCountResponse(
total_tokens=exact + framing,
request_model=request.model,
model_used=model_to_use,
tokenizer_type="huggingface_tokenizer",
count_source="local_estimate",
)
estimate: Final = await _local_token_estimate(
model=model_to_use,
text=prompt if counted_messages is None else None,
prompt=prompt if counted_messages is None else None,
messages=counted_messages,
tools=counted_tools,
custom_tokenizer=_tokenizer_used,
custom_tokenizer=custom_tokenizer,
provider_failure=ladder,
)
template: Final = (
0 if kimi_k3_messages is None else kimi_k3_template_tokens(len(kimi_k3_messages), bool(counted_tools))
)
return TokenCountResponse(
total_tokens=total_tokens,
total_tokens=estimate.total_tokens + template + framing,
request_model=request.model,
model_used=model_to_use,
tokenizer_type=tokenizer_used,
tokenizer_type=estimate.tokenizer_type,
count_source="local_estimate",
)

View file

@ -67,6 +67,7 @@ from litellm.proxy.common_utils.openai_error_payload import (
error_status_code,
openai_error_type_for_status,
)
from litellm.proxy.common_utils.token_count_settings import count_source_fields
from litellm.proxy.hooks.proxy_track_cost_callback import ProxyDBLogger
from litellm.proxy.hooks.responses_id_security import ResponsesIDSecurity, decrypted_response_id
from litellm.proxy.litellm_pre_call_utils import (
@ -86,6 +87,7 @@ from litellm.types.llms.openai import (
ResponsesAPIResponse,
)
from litellm.types.responses.main import DeleteResponseResult
from litellm.types.token_count import TokenCountSourceFields
from litellm.types.utils import TokenCountResponse
if TYPE_CHECKING:
@ -1373,7 +1375,7 @@ class _ResponsesApiErrorBody(TypedDict):
error: ReadOnly[_ResponsesApiErrorDetail]
class _ResponsesInputTokensResult(TypedDict):
class _ResponsesInputTokensResult(TokenCountSourceFields):
object: ReadOnly[str]
input_tokens: ReadOnly[int]
@ -1610,6 +1612,7 @@ async def responses_input_tokens(
result: Final[_ResponsesInputTokensResult] = {
"object": "response.input_tokens",
"input_tokens": token_response.total_tokens,
**count_source_fields(token_response),
}
return result

View file

@ -9,6 +9,7 @@ from typing_extensions import (
)
from litellm.types.llms.openai import ChatCompletionFileObject, EmbeddingInput
from litellm.types.token_count import TokenCountSourceFields
# Gemini supports nested-list inputs (e.g. [["text", "image"]]) as an explicit
# opt-in for combined embeddings — a provider-specific extension of the
@ -320,12 +321,13 @@ class UsageMetadata(TypedDict, total=False):
candidatesTokensDetails: list[PromptTokensDetails] # Alternative key name used in some responses
class TokenCountDetailsResponse(TypedDict):
class TokenCountDetailsResponse(TokenCountSourceFields):
"""
Response structure for token count details with modality breakdown.
Example:
{'totalTokens': 12, 'promptTokensDetails': [{'modality': 'TEXT', 'tokenCount': 12}]}
{'totalTokens': 12, 'promptTokensDetails': [{'modality': 'TEXT', 'tokenCount': 12}],
'litellm_count_source': 'provider'}
"""
totalTokens: int

View file

@ -0,0 +1,16 @@
from typing import Literal, TypeAlias
from typing_extensions import NotRequired, ReadOnly, TypedDict
TokenCountSource: TypeAlias = Literal["provider", "token_count_model", "local_estimate"]
TokenCountTokenizer: TypeAlias = Literal["kimi_k3"]
class TokenCountModelInfo(TypedDict):
model: ReadOnly[str]
region: ReadOnly[NotRequired[str]]
class TokenCountSourceFields(TypedDict, total=False):
litellm_count_source: ReadOnly[TokenCountSource]
litellm_count_model: ReadOnly[str]

View file

@ -58,6 +58,7 @@ from litellm.types.llms.base import (
LiteLLMPydanticObjectBase,
)
from litellm.types.mcp import MCPServerCostInfo
from litellm.types.token_count import TokenCountModelInfo, TokenCountSource, TokenCountTokenizer
from litellm.types.workload_identity import (
ANTHROPIC_WIF_KWARGS_KEYS,
OPENAI_WIF_KWARGS_KEYS,
@ -230,6 +231,9 @@ class ProviderSpecificModelInfo(TypedDict, total=False):
bedrock_output_config_effort_ceiling: Literal["low", "medium", "high", "max", "xhigh"] | None
bedrock_converse_supports_strict_tools: bool | None
supports_regex_lookaround: ReadOnly[bool | None]
token_count_framing: ReadOnly[int | None]
token_count_model: ReadOnly[TokenCountModelInfo | None]
token_count_tokenizer: ReadOnly[TokenCountTokenizer | None]
class SearchContextCostPerQuery(TypedDict, total=False):
@ -4051,11 +4055,14 @@ DEPLOYMENT_SCOPED_CAPABILITY_FIELDS: Final[frozenset[str]] = frozenset(
{"supports_regex_lookaround", "supports_assistant_prefill", "supports_bedrock_native_structured_output"}
)
DEPLOYMENT_SCOPED_TOKEN_COUNT_FIELDS: Final[frozenset[str]] = frozenset({"token_count_framing", "token_count_model"})
SHARED_BACKEND_MODEL_INFO_FIELDS: Final[frozenset[str]] = (
frozenset(ModelInfoBase.__required_keys__ | ModelInfoBase.__optional_keys__)
- frozenset(CustomPricingLiteLLMParams.model_fields)
- DEPLOYMENT_SCOPED_PRICING_FIELDS
- DEPLOYMENT_SCOPED_CAPABILITY_FIELDS
- DEPLOYMENT_SCOPED_TOKEN_COUNT_FIELDS
)
@ -4492,6 +4499,7 @@ class TokenCountResponse(LiteLLMPydanticObjectBase):
request_model: str
model_used: str
tokenizer_type: str
count_source: TokenCountSource | None = None
original_response: dict | None = None
"""
Original Response from upstream API call - if an API call was made for token counting
@ -4502,6 +4510,7 @@ class TokenCountResponse(LiteLLMPydanticObjectBase):
HTTP status code from the token counting API (e.g., 200 for success, 429 for rate limit, 400 for bad request)
"""
status_code: int | None = None
rejected_before_provider_call: bool = Field(default=False, exclude=True)
class CustomHuggingfaceTokenizer(TypedDict):

View file

@ -6694,6 +6694,9 @@ def get_model_info_helper(
uses_embed_content=_model_info.get("uses_embed_content", None),
supports_image_size=_model_info.get("supports_image_size", None),
supported_audio_formats=_model_info.get("supported_audio_formats", None),
token_count_framing=_model_info.get("token_count_framing", None),
token_count_model=_model_info.get("token_count_model", None),
token_count_tokenizer=_model_info.get("token_count_tokenizer", None),
vertex_ai_audio_api=_model_info.get("vertex_ai_audio_api", None),
)
for cost_key, cost_value in _model_info.items():

View file

@ -21144,6 +21144,7 @@
"input_cost_per_token": 1.4e-07,
"input_dbu_cost_per_token": 2e-06,
"litellm_provider": "databricks",
"token_count_framing": 97,
"max_input_tokens": 1000000,
"max_output_tokens": 393216,
"max_tokens": 393216,
@ -21166,6 +21167,11 @@
"supports_tool_choice": true,
"supports_vision": false
},
"databricks/system.ai.deepseek-v4-flash-0731": {
"litellm_provider": "databricks",
"mode": "chat",
"token_count_framing": 97
},
"databricks/databricks-deepseek-v4-pro-0813": {
"cache_creation_input_token_cost": 1.31999e-06,
"cache_read_input_token_cost": 1.3202e-07,
@ -22134,6 +22140,7 @@
"input_cost_per_token": 2.99999e-06,
"input_dbu_cost_per_token": 4.2857e-05,
"litellm_provider": "databricks",
"token_count_framing": 100,
"max_input_tokens": 1000000,
"max_output_tokens": 1048576,
"max_tokens": 1048576,
@ -22157,6 +22164,11 @@
"supports_tool_choice": true,
"supports_vision": true
},
"databricks/system.ai.kimi-k3": {
"litellm_provider": "databricks",
"mode": "chat",
"token_count_framing": 100
},
"databricks/databricks-llama-4-maverick": {
"cache_creation_input_token_cost": 5.0001e-07,
"cache_read_input_token_cost": 5.0001e-07,
@ -71540,6 +71552,8 @@
"input_cost_per_token_flex": 1.65e-06,
"input_cost_per_token_priority": 5.775e-06,
"litellm_provider": "bedrock_converse",
"token_count_framing": 64,
"token_count_tokenizer": "kimi_k3",
"max_input_tokens": 1000000,
"max_output_tokens": 131072,
"max_tokens": 131072,
@ -71570,6 +71584,8 @@
"input_cost_per_token_flex": 1.5e-06,
"input_cost_per_token_priority": 5.25e-06,
"litellm_provider": "bedrock_converse",
"token_count_framing": 64,
"token_count_tokenizer": "kimi_k3",
"max_input_tokens": 1000000,
"max_output_tokens": 131072,
"max_tokens": 131072,
@ -71600,6 +71616,8 @@
"input_cost_per_token_flex": 1.65e-06,
"input_cost_per_token_priority": 5.775e-06,
"litellm_provider": "bedrock_converse",
"token_count_framing": 64,
"token_count_tokenizer": "kimi_k3",
"max_input_tokens": 1000000,
"max_output_tokens": 131072,
"max_tokens": 131072,
@ -73100,6 +73118,10 @@
"cache_read_input_token_cost": 1e-07,
"input_cost_per_token": 2e-06,
"litellm_provider": "bedrock_converse",
"token_count_model": {
"model": "anthropic.claude-opus-5-5",
"region": "us-east-1"
},
"supports_tool_search": true,
"max_input_tokens": 1000000,
"max_output_tokens": 128000,
@ -73173,6 +73195,10 @@
"source": "https://pricing.us-east-1.amazonaws.com/offers/v1.0/aws/AmazonBedrockFoundationModels/current/index.json"
},
"au.anthropic.claude-sonnet-5-5": {
"token_count_model": {
"model": "au.anthropic.claude-opus-5-5",
"region": "ap-southeast-4"
},
"bedrock_converse_supports_strict_tools": false,
"cache_creation_input_token_cost": 2.75e-06,
"cache_creation_input_token_cost_above_1hr": 4.4e-06,
@ -73373,6 +73399,10 @@
"cache_read_input_token_cost": 1e-07,
"input_cost_per_token": 2e-06,
"litellm_provider": "bedrock_converse",
"token_count_model": {
"model": "global.anthropic.claude-opus-5-5",
"region": "us-east-1"
},
"supports_tool_search": true,
"max_input_tokens": 1000000,
"max_output_tokens": 128000,
@ -73582,6 +73612,10 @@
"cache_read_input_token_cost": 1.1e-07,
"input_cost_per_token": 2.2e-06,
"litellm_provider": "bedrock_converse",
"token_count_model": {
"model": "us.anthropic.claude-opus-5-5",
"region": "us-east-1"
},
"supports_tool_search": true,
"max_input_tokens": 1000000,
"max_output_tokens": 128000,
@ -74382,6 +74416,8 @@
"input_cost_per_token_flex": 1.65e-06,
"input_cost_per_token_priority": 5.775e-06,
"litellm_provider": "bedrock_converse",
"token_count_framing": 64,
"token_count_tokenizer": "kimi_k3",
"max_input_tokens": 1000000,
"max_output_tokens": 131072,
"max_tokens": 131072,
@ -74555,6 +74591,10 @@
"cache_read_input_token_cost": 1e-08,
"input_cost_per_token": 1e-07,
"litellm_provider": "bedrock_converse",
"token_count_model": {
"model": "anthropic.claude-opus-5-5",
"region": "us-east-1"
},
"supports_tool_search": true,
"max_input_tokens": 1000000,
"max_output_tokens": 128000,
@ -74633,6 +74673,10 @@
"source": "https://pricing.us-east-1.amazonaws.com/offers/v1.0/aws/AmazonBedrockFoundationModels/current/index.json"
},
"au.anthropic.claude-haiku-5-5": {
"token_count_model": {
"model": "au.anthropic.claude-opus-5-5",
"region": "ap-southeast-4"
},
"input_cost_per_token_above_100k_tokens": 5.5e-07,
"output_cost_per_token_above_100k_tokens": 2.75e-06,
"cache_creation_input_token_cost_above_100k_tokens": 6.875e-07,
@ -74863,6 +74907,10 @@
"cache_read_input_token_cost": 1e-08,
"input_cost_per_token": 1e-07,
"litellm_provider": "bedrock_converse",
"token_count_model": {
"model": "global.anthropic.claude-opus-5-5",
"region": "us-east-1"
},
"supports_tool_search": true,
"max_input_tokens": 1000000,
"max_output_tokens": 128000,
@ -75010,6 +75058,10 @@
"cache_read_input_token_cost": 1.1e-08,
"input_cost_per_token": 1.1e-07,
"litellm_provider": "bedrock_converse",
"token_count_model": {
"model": "us.anthropic.claude-opus-5-5",
"region": "us-east-1"
},
"supports_tool_search": true,
"max_input_tokens": 1000000,
"max_output_tokens": 128000,

View file

@ -1210,6 +1210,36 @@
"additionalProperties": false
}
},
"token_count_framing": {
"type": "integer",
"minimum": 0,
"description": "Tokens added once per request to a local count estimate, for the provider's chat framing."
},
"token_count_model": {
"type": "object",
"required": [
"model"
],
"additionalProperties": false,
"properties": {
"model": {
"type": "string",
"pattern": "\\S"
},
"region": {
"type": "string",
"pattern": "\\S"
}
},
"description": "Model that counts tokens for this one, and the AWS region its count API runs in."
},
"token_count_tokenizer": {
"type": "string",
"description": "Tokenizer litellm counts this model's requests with when the provider cannot count them.",
"enum": [
"kimi_k3"
]
},
"tpm": {
"type": "integer",
"minimum": 0,

View file

@ -0,0 +1,362 @@
from __future__ import annotations
import base64
import io
from collections.abc import Iterator
from dataclasses import dataclass
from typing import Final
import pytest
from anthropic import Anthropic, BadRequestError, Omit, omit
from anthropic.types import DocumentBlockParam, MessageParam, TextBlockParam, ToolParam
from e2e_config import unique_marker
from e2e_http import unwrap
from e2e_metadata import Capability, Domain, Mode, Provider, Route, Subject, meta
from lifecycle import ResourceManager
from models import (
ChatMessage,
CountTokensResponse,
LiteLLMParamsBody,
ModelInfoBody,
ModelNewBody,
ResponsesInputTokensResponse,
TokenCounterBody,
TokenCountModelBody,
)
from proxy_client import ProxyClient
from reportlab.lib.pagesizes import LETTER
from reportlab.pdfgen.canvas import Canvas
from sdk_clients import NO_PROXY_CACHE, SdkClients
pytestmark = pytest.mark.e2e
BEDROCK_SONNET: Final = "bedrock/us.anthropic.claude-sonnet-5-5"
BEDROCK_HAIKU: Final = "bedrock/us.anthropic.claude-haiku-5-5"
COST_MAP_COUNT_MODEL: Final = "us.anthropic.claude-opus-5-5"
CONFIGURED_COUNT_MODEL: Final = "us.anthropic.claude-sonnet-5"
ANTHROPIC_SONNET: Final = "anthropic/claude-sonnet-5-5"
BEDROCK_KIMI: Final = "bedrock/us.moonshotai.kimi-k3"
BEDROCK_NOVA: Final = "bedrock/us.amazon.nova-pro-v1:0"
OPENAI_GPT: Final = "openai/gpt-5.6"
COUNT_TOLERANCE: Final = 0.05
MAX_TOKENS: Final = 16
PDF_LINES_PER_PAGE: Final = 45
LARGE_SYSTEM: Final = " ".join(
f"Rule {n}: answer in plain English, cite clause {n} when it applies, and never invent a figure."
for n in range(1, 301)
)
LARGE_TOOL: Final[ToolParam] = {
"name": "search_orders",
"description": " ".join(
f"Filter {n} narrows the order search by the attribute recorded in field_{n}." for n in range(1, 151)
),
"input_schema": {
"type": "object",
"properties": {
f"field_{n}": {"type": "string", "description": f"Order attribute number {n}, matched exactly."}
for n in range(1, 61)
},
"required": ["field_1"],
},
}
@dataclass(frozen=True, slots=True)
class Served:
model: str
key: str
@dataclass(frozen=True, slots=True)
class PromptShape:
name: str
system: str | None = None
tool: ToolParam | None = None
pdf_pages: int = 0
SHAPES: Final = (
PromptShape(name="baseline"),
PromptShape(name="large_system", system=LARGE_SYSTEM),
PromptShape(name="large_tool", tool=LARGE_TOOL),
PromptShape(name="system_and_tool", system=LARGE_SYSTEM, tool=LARGE_TOOL),
PromptShape(name="pdf_1_page", pdf_pages=1),
PromptShape(name="pdf_12_pages", pdf_pages=12),
)
SHAPE_IDS: Final = tuple(shape.name for shape in SHAPES)
TEXT_SHAPES: Final = SHAPES[:2]
TEXT_SHAPE_IDS: Final = SHAPE_IDS[:2]
def _bedrock(model: str) -> LiteLLMParamsBody:
return LiteLLMParamsBody(
model=model,
aws_access_key_id="os.environ/AWS_ACCESS_KEY_ID",
aws_secret_access_key="os.environ/AWS_SECRET_ACCESS_KEY",
aws_region_name="os.environ/AWS_REGION",
)
def _serve(proxy: ProxyClient, backend: LiteLLMParamsBody, model_info: ModelInfoBody | None = None) -> Iterator[Served]:
resources: Final = ResourceManager(client=proxy)
try:
model: Final = f"e2e-count-source-{unique_marker()}"
model_id: Final = proxy.register_model(
ModelNewBody(model_name=model, litellm_params=backend, model_info=model_info or ModelInfoBody())
)
resources.defer(lambda: proxy.delete_model(model_id))
yield Served(model=model, key=resources.key())
finally:
resources.teardown()
@pytest.fixture(scope="module")
def bedrock_sonnet(proxy: ProxyClient) -> Iterator[Served]:
yield from _serve(proxy, _bedrock(BEDROCK_SONNET))
@pytest.fixture(scope="module")
def bedrock_haiku(proxy: ProxyClient) -> Iterator[Served]:
yield from _serve(proxy, _bedrock(BEDROCK_HAIKU))
@pytest.fixture(scope="module")
def bedrock_sonnet_with_configured_count_model(proxy: ProxyClient) -> Iterator[Served]:
yield from _serve(
proxy,
_bedrock(BEDROCK_SONNET),
ModelInfoBody(token_count_model=TokenCountModelBody(model=CONFIGURED_COUNT_MODEL)),
)
@pytest.fixture(scope="module")
def anthropic_sonnet(proxy: ProxyClient) -> Iterator[Served]:
yield from _serve(proxy, LiteLLMParamsBody(model=ANTHROPIC_SONNET, api_key="os.environ/ANTHROPIC_API_KEY"))
@pytest.fixture(scope="module")
def bedrock_kimi(proxy: ProxyClient) -> Iterator[Served]:
yield from _serve(proxy, _bedrock(BEDROCK_KIMI))
@pytest.fixture(scope="module")
def bedrock_nova(proxy: ProxyClient) -> Iterator[Served]:
yield from _serve(proxy, _bedrock(BEDROCK_NOVA))
@pytest.fixture(scope="module")
def openai_gpt(proxy: ProxyClient) -> Iterator[Served]:
yield from _serve(proxy, LiteLLMParamsBody(model=OPENAI_GPT, api_key="os.environ/OPENAI_API_KEY"))
def _pdf(pages: int) -> str:
buffer: Final = io.BytesIO()
canvas: Final = Canvas(buffer, pagesize=LETTER, invariant=True)
for page in range(1, pages + 1):
for line in range(1, PDF_LINES_PER_PAGE + 1):
canvas.drawString(
72, 740 - 14 * line, f"Page {page}, line {line}: revenue, costs and headcount for region {line}."
)
canvas.showPage()
canvas.save()
return base64.b64encode(buffer.getvalue()).decode()
def _user_turn(shape: PromptShape) -> MessageParam:
question: Final[TextBlockParam] = {"type": "text", "text": f"Reply with one word. {unique_marker()}"}
if shape.pdf_pages == 0:
return {"role": "user", "content": [question]}
document: Final[DocumentBlockParam] = {
"type": "document",
"source": {"type": "base64", "media_type": "application/pdf", "data": _pdf(shape.pdf_pages)},
}
return {"role": "user", "content": [document, question]}
def _count_and_bill(client: Anthropic, model: str, shape: PromptShape) -> tuple[CountTokensResponse, int]:
messages: Final = [_user_turn(shape)]
system: Final[str | Omit] = omit if shape.system is None else shape.system
tools: Final[list[ToolParam] | Omit] = omit if shape.tool is None else [shape.tool]
counted: Final = CountTokensResponse.model_validate_json(
client.messages.with_raw_response.count_tokens(model=model, messages=messages, system=system, tools=tools).text
)
usage: Final = client.messages.create(
model=model,
max_tokens=MAX_TOKENS,
messages=messages,
system=system,
tools=tools,
extra_body=NO_PROXY_CACHE,
).usage
billed: Final = usage.input_tokens + (usage.cache_creation_input_tokens or 0) + (usage.cache_read_input_tokens or 0)
return counted, billed
def _within_tolerance(counted: int, billed: int) -> bool:
return abs(counted - billed) <= billed * COUNT_TOLERANCE
def _assert_counted_by_the_cost_map_count_model(served: Served, sdk: SdkClients, shape: PromptShape) -> None:
counted, billed = _count_and_bill(sdk.anthropic(served.key), served.model, shape)
assert counted.litellm_count_source == "token_count_model", counted
assert counted.litellm_count_model == COST_MAP_COUNT_MODEL, counted
assert _within_tolerance(counted.input_tokens, billed), (counted, billed)
class TestBedrockClaudeCountsWithCostMapCountModel:
@pytest.mark.parametrize("shape", SHAPES, ids=SHAPE_IDS)
@meta(
Subject(
domain=Domain.LLM_TRANSLATION,
route=Route.COUNT_TOKENS,
providers=(Provider.BEDROCK,),
models=(BEDROCK_SONNET, COST_MAP_COUNT_MODEL),
capabilities=(Capability.FUNCTION_CALLING, Capability.PDF_INPUT),
mode=Mode.NONSTREAM,
)
)
def test_sonnet_count_names_the_count_model_and_matches_billed_input(
self, bedrock_sonnet: Served, sdk: SdkClients, shape: PromptShape
) -> None:
_assert_counted_by_the_cost_map_count_model(bedrock_sonnet, sdk, shape)
@pytest.mark.parametrize("shape", SHAPES, ids=SHAPE_IDS)
@meta(
Subject(
domain=Domain.LLM_TRANSLATION,
route=Route.COUNT_TOKENS,
providers=(Provider.BEDROCK,),
models=(BEDROCK_HAIKU, COST_MAP_COUNT_MODEL),
capabilities=(Capability.FUNCTION_CALLING, Capability.PDF_INPUT),
mode=Mode.NONSTREAM,
)
)
def test_haiku_count_names_the_count_model_and_matches_billed_input(
self, bedrock_haiku: Served, sdk: SdkClients, shape: PromptShape
) -> None:
_assert_counted_by_the_cost_map_count_model(bedrock_haiku, sdk, shape)
class TestAnthropicClaudeCountsWithProvider:
@pytest.mark.parametrize("shape", SHAPES, ids=SHAPE_IDS)
@meta(
Subject(
domain=Domain.LLM_TRANSLATION,
route=Route.COUNT_TOKENS,
providers=(Provider.ANTHROPIC,),
models=(ANTHROPIC_SONNET,),
capabilities=(Capability.FUNCTION_CALLING, Capability.PDF_INPUT),
mode=Mode.NONSTREAM,
)
)
def test_count_comes_from_the_provider_and_matches_billed_input(
self, anthropic_sonnet: Served, sdk: SdkClients, shape: PromptShape
) -> None:
counted, billed = _count_and_bill(sdk.anthropic(anthropic_sonnet.key), anthropic_sonnet.model, shape)
assert counted.litellm_count_source == "provider", counted
assert counted.litellm_count_model is None, counted
assert _within_tolerance(counted.input_tokens, billed), (counted, billed)
class TestBedrockKimiCountsWithFramedLocalEstimate:
@pytest.mark.parametrize("shape", TEXT_SHAPES, ids=TEXT_SHAPE_IDS)
@meta(
Subject(
domain=Domain.LLM_TRANSLATION,
route=Route.COUNT_TOKENS,
providers=(Provider.BEDROCK,),
models=(BEDROCK_KIMI,),
mode=Mode.NONSTREAM,
)
)
def test_text_estimate_matches_billed_input(self, bedrock_kimi: Served, sdk: SdkClients, shape: PromptShape) -> None:
counted, billed = _count_and_bill(sdk.anthropic(bedrock_kimi.key), bedrock_kimi.model, shape)
assert counted.litellm_count_source == "local_estimate", counted
assert _within_tolerance(counted.input_tokens, billed), (counted, billed)
class TestConfiguredCountModelOverridesCostMap:
@meta(
Subject(
domain=Domain.LLM_TRANSLATION,
route=Route.COUNT_TOKENS,
providers=(Provider.BEDROCK,),
models=(BEDROCK_SONNET, CONFIGURED_COUNT_MODEL),
)
)
def test_count_names_the_model_info_count_model(
self, bedrock_sonnet_with_configured_count_model: Served, sdk: SdkClients
) -> None:
served: Final = bedrock_sonnet_with_configured_count_model
raw: Final = sdk.anthropic(served.key).messages.with_raw_response.count_tokens(
model=served.model, messages=[_user_turn(SHAPES[0])]
)
counted: Final = CountTokensResponse.model_validate_json(raw.text)
assert counted.litellm_count_source == "token_count_model", counted
assert counted.litellm_count_model == CONFIGURED_COUNT_MODEL, counted
assert counted.input_tokens > 0, counted
class TestProviderCountErrorWithoutCountModel:
@meta(
Subject(
domain=Domain.LLM_TRANSLATION,
route=Route.COUNT_TOKENS,
providers=(Provider.BEDROCK,),
models=(BEDROCK_NOVA,),
)
)
def test_count_keeps_the_provider_400_instead_of_estimating(self, bedrock_nova: Served, sdk: SdkClients) -> None:
with pytest.raises(BadRequestError) as rejected:
_ = sdk.anthropic(bedrock_nova.key).messages.count_tokens(
model=bedrock_nova.model, messages=[_user_turn(SHAPES[0])]
)
assert rejected.value.status_code == 400, rejected.value
class TestCountSourceOnOtherCountEndpoints:
@meta(
Subject(
domain=Domain.LLM_TRANSLATION,
route=Route.COUNT_TOKENS,
providers=(Provider.OPENAI,),
models=(OPENAI_GPT,),
)
)
def test_responses_input_tokens_says_the_provider_counted(self, openai_gpt: Served, sdk: SdkClients) -> None:
raw: Final = sdk.openai(openai_gpt.key).responses.input_tokens.with_raw_response.count(
model=openai_gpt.model, input=f"Reply with one word. {unique_marker()}"
)
counted: Final = ResponsesInputTokensResponse.model_validate_json(raw.text)
assert counted.litellm_count_source == "provider", counted
assert counted.input_tokens > 0, counted
@meta(
Subject(
domain=Domain.LLM_TRANSLATION,
route=Route.COUNT_TOKENS,
providers=(Provider.BEDROCK,),
models=(BEDROCK_KIMI,),
)
)
def test_token_counter_says_kimi_was_estimated_locally(self, proxy: ProxyClient, bedrock_kimi: Served) -> None:
counted: Final = unwrap(
proxy.token_counter(
bedrock_kimi.key,
TokenCounterBody(
model=bedrock_kimi.model,
messages=[ChatMessage(role="user", content=f"Reply with one word. {unique_marker()}")],
),
)
)
assert counted.count_source == "local_estimate", counted
assert counted.total_tokens > 0, counted

View file

@ -677,13 +677,39 @@ class AnthropicMessagesResponse(BaseModel):
usage: Usage | None = None
class CountTokensResponse(BaseModel):
TokenCountSource = Literal["provider", "token_count_model", "local_estimate"]
class LitellmCountSource(BaseModel, frozen=True):
litellm_count_source: TokenCountSource | None = None
litellm_count_model: str | None = None
class CountTokensResponse(LitellmCountSource, frozen=True):
"""`/v1/messages/count_tokens` answer. `input_tokens` is required so a 200
whose body lacks it fails validation instead of passing vacuously."""
input_tokens: int
class ResponsesInputTokensResponse(LitellmCountSource, frozen=True):
object: str
input_tokens: int
class TokenCounterBody(BaseModel, frozen=True):
model: str
messages: list[ChatMessage]
class TokenCounterResponse(BaseModel, frozen=True):
total_tokens: int
request_model: str
model_used: str
tokenizer_type: str
count_source: TokenCountSource | None = None
class AnthropicErrorBody(BaseModel):
type: str
message: str
@ -1392,6 +1418,11 @@ class LiteLLMParamsBody(BaseModel):
ModelMode = Literal["chat", "batch", "realtime", "image_generation"]
class TokenCountModelBody(BaseModel, frozen=True):
model: str
region: str | None = None
class ModelInfoBody(BaseModel):
# id is left unset so the proxy assigns a unique model_id per deployment.
# Pinning it to the model_name made re-registrations of a fixed-name model
@ -1404,6 +1435,7 @@ class ModelInfoBody(BaseModel):
team_id: str | None = None
allowed_fails: int | None = None
allowed_fails_policy: dict[str, int] | None = None
token_count_model: TokenCountModelBody | None = None
class ModelNewBody(BaseModel):

View file

@ -100,6 +100,8 @@ from models import (
TeamNewBody,
TeamNewResponse,
TeamUpdateBody,
TokenCounterBody,
TokenCounterResponse,
ToolsetCreateBody,
ToolsetRow,
ToolsetUpdateBody,
@ -1049,6 +1051,15 @@ class ProxyClient:
response_type=CountTokensResponse,
)
@step("Count tokens with /utils/token_counter for {body.model}")
def token_counter(self, key: str, body: TokenCounterBody) -> Result[TokenCounterResponse]:
return self.transport.post(
"/utils/token_counter",
headers=self.transport.bearer(key),
json=body,
response_type=TokenCounterResponse,
)
@step("Send a /v1/messages request to {body.model}")
def messages(
self, key: str, body: AnthropicMessagesBody, *, session_id: str | None = None

View file

@ -104,7 +104,7 @@ def test_anthropic_count_tokens_forwards_body_and_refuses_a_key_without_model_ac
client: Final = _client(gateway, gateway.key)
result: Final = _sdk_count(client, model, spelling, _COUNT_BETA)
assert result.model_dump() == {"input_tokens": _COUNTED_INPUT_TOKENS}
assert result.model_dump() == {"input_tokens": _COUNTED_INPUT_TOKENS, "litellm_count_source": "provider"}
requests: Final = wire.drain()
assert len(requests) == 1, requests
@ -236,5 +236,5 @@ def test_anthropic_count_tokens_merges_the_client_beta_with_the_token_counting_b
num_retries=0,
)
result: Final = _sdk_count(_client(gateway, gateway.key), model, spelling, _CLIENT_BETA)
assert result.model_dump() == {"input_tokens": _COUNTED_INPUT_TOKENS}
assert result.model_dump() == {"input_tokens": _COUNTED_INPUT_TOKENS, "litellm_count_source": "provider"}
assert len(wire.drain()) == 1

View file

@ -245,7 +245,10 @@ def test_enabled_gateway_count_tokens_alias_matches_native_count_tokens_route(en
"/claude_code_gateway/v1/messages/count_tokens?beta=true", json=body, headers=headers
)
assert gateway_response.status_code == 200, gateway_response.text
assert _JSON_OBJECT.validate_json(gateway_response.content) == {"input_tokens": 41}, gateway_response.text
assert _JSON_OBJECT.validate_json(gateway_response.content) == {
"input_tokens": 41,
"litellm_count_source": "provider",
}, gateway_response.text
native_response: Final = enabled_gateway.client.post(
"/v1/messages/count_tokens?beta=true", json=body, headers=headers
)

View file

@ -274,7 +274,7 @@ def test_messages_count_tokens_lifts_the_leading_system_run(
) -> None:
with _listening(deployment) as peer:
response: Final = _count(gateway, count_request(deployment.model, case))
assert _payload(response) == {"input_tokens": _COUNT}, response.text
assert _payload(response) == {"input_tokens": _COUNT, "litellm_count_source": "provider"}, response.text
assert _bodies(peer, deployment.provider) == (expected_count_body(_MODEL, case),)
@ -329,7 +329,11 @@ def test_responses_input_tokens_lifts_instructions(deployment: _Deployment, gate
"/v1/responses/input_tokens",
{"model": deployment.model, "input": USER_TEXT, "instructions": INSTRUCTION},
)
assert _payload(response) == {"object": "response.input_tokens", "input_tokens": _COUNT}, response.text
assert _payload(response) == {
"object": "response.input_tokens",
"input_tokens": _COUNT,
"litellm_count_source": "provider",
}, response.text
assert _bodies(peer, deployment.provider) == (_STRING_BODY,)
@ -342,7 +346,11 @@ def test_responses_input_tokens_lifts_instructions_ahead_of_a_leading_system_ite
"/v1/responses/input_tokens",
{"model": deployment.model, "input": [MID_SYSTEM, USER], "instructions": INSTRUCTION},
)
assert _payload(response) == {"object": "response.input_tokens", "input_tokens": _COUNT}, response.text
assert _payload(response) == {
"object": "response.input_tokens",
"input_tokens": _COUNT,
"litellm_count_source": "provider",
}, response.text
assert _bodies(peer, deployment.provider) == (
{"model": _MODEL, "messages": [USER], "system": [LIFTED, {"type": "text", "text": REMINDER}]},
)
@ -373,7 +381,7 @@ def test_messages_count_tokens_forwards_tools_beside_the_lifted_system(
) -> None:
with _listening(deployment) as peer:
response: Final = _count(gateway, {**count_request(deployment.model, STRING_CASE), "tools": TOOLS})
assert _payload(response) == {"input_tokens": _COUNT}, response.text
assert _payload(response) == {"input_tokens": _COUNT, "litellm_count_source": "provider"}, response.text
assert _bodies(peer, deployment.provider) == ({**_STRING_BODY, "tools": TOOLS},)
@ -386,7 +394,7 @@ def test_messages_count_tokens_keeps_a_mid_conversation_system_in_place(
"messages": [LEADING, USER, MID_SYSTEM, ASSISTANT, FOLLOW_UP],
}
response: Final = _count(gateway, body)
assert _payload(response) == {"input_tokens": _COUNT}, response.text
assert _payload(response) == {"input_tokens": _COUNT, "litellm_count_source": "provider"}, response.text
assert _bodies(peer, deployment.provider) == (
{"model": _MODEL, "messages": [USER, MID_SYSTEM, ASSISTANT, FOLLOW_UP], "system": [LIFTED]},
)
@ -418,7 +426,7 @@ def test_messages_count_tokens_repeated_request_lifts_each_time(deployment: _Dep
answers: Final = tuple(
_payload(_count(gateway, count_request(deployment.model, STRING_CASE))) for _ in range(2)
)
assert answers == ({"input_tokens": _COUNT},) * 2
assert answers == ({"input_tokens": _COUNT, "litellm_count_source": "provider"},) * 2
assert _bodies(peer, deployment.provider) == (_STRING_BODY,) * 2
@ -432,7 +440,7 @@ def test_messages_count_tokens_surfaces_the_counting_error_for_a_leading_system_
assert "input_tokens" not in response.text, response.text
assert peer.drain() == ()
follow_up: Final = _count(gateway, count_request(deployment.model, STRING_CASE))
assert _payload(follow_up) == {"input_tokens": _COUNT}, follow_up.text
assert _payload(follow_up) == {"input_tokens": _COUNT, "litellm_count_source": "provider"}, follow_up.text
assert _bodies(peer, deployment.provider) == (_STRING_BODY,)
@ -447,7 +455,7 @@ def test_messages_count_tokens_duplicate_messages_key_lifts_the_last_value(
content=f'{{"model": "{deployment.model}", "messages": {first}, "messages": {last}}}',
headers={"Authorization": f"Bearer {gateway.key}", "Content-Type": "application/json"},
)
assert _payload(response) == {"input_tokens": _COUNT}, response.text
assert _payload(response) == {"input_tokens": _COUNT, "litellm_count_source": "provider"}, response.text
assert _bodies(peer, deployment.provider) == (_STRING_BODY,)

View file

@ -378,7 +378,7 @@ def test_messages_count_tokens_counts_through_mantle_when_the_runtime_cannot(
):
model: Final = _deployment(scenario, runtime.url)
response: Final = _count(gateway, {**_bare(model), "system": system, "tools": _TOOLS})
assert _payload(response) == {"input_tokens": _MANTLE_COUNT}, response.text
assert _payload(response) == {"input_tokens": _MANTLE_COUNT, "litellm_count_source": "provider"}, response.text
assert _runtime_count_targets(runtime) == (f"/model/{_OPUS_BASE}/count-tokens",)
assert _mantle_bodies(mantle) == (_mantle_body(system=system, tools=_TOOLS),)
@ -394,7 +394,7 @@ def test_messages_count_tokens_without_system_or_tools_sends_a_bare_body_to_mant
):
model: Final = _deployment(scenario, runtime.url)
response: Final = _count(gateway, _bare(model))
assert _payload(response) == {"input_tokens": _MANTLE_COUNT}, response.text
assert _payload(response) == {"input_tokens": _MANTLE_COUNT, "litellm_count_source": "provider"}, response.text
assert len(_runtime_count_targets(runtime)) == 1
assert _mantle_bodies(mantle) == (_MANTLE_BARE,)
@ -429,7 +429,11 @@ def test_gemini_count_tokens_route_reports_the_mantle_total(counting_proxy: Owne
):
model: Final = _deployment(scenario, runtime.url)
response: Final = gateway.request("POST", f"/v1beta/models/{model}:countTokens", _GEMINI_BODY)
assert _payload(response) == {"totalTokens": _MANTLE_COUNT, "promptTokensDetails": []}, response.text
assert _payload(response) == {
"totalTokens": _MANTLE_COUNT,
"promptTokensDetails": [],
"litellm_count_source": "provider",
}, response.text
assert len(_runtime_count_targets(runtime)) == 1
assert _mantle_bodies(mantle) == ({"model": _OPUS_BASE, "messages": _GEMINI_MESSAGES},)
@ -445,7 +449,11 @@ def test_gemini_count_tokens_route_reports_the_runtime_total_for_a_model_bedrock
):
model: Final = _deployment(scenario, runtime.url, _SONNET)
response: Final = gateway.request("POST", f"/v1beta/models/{model}:countTokens", _GEMINI_BODY)
assert _payload(response) == {"totalTokens": _RUNTIME_COUNT, "promptTokensDetails": []}, response.text
assert _payload(response) == {
"totalTokens": _RUNTIME_COUNT,
"promptTokensDetails": [],
"litellm_count_source": "provider",
}, response.text
assert _runtime_count_targets(runtime) == (f"/model/{_SONNET}/count-tokens",)
assert mantle.drain() == ()
@ -460,7 +468,7 @@ def test_runtime_404_on_claude_falls_back_to_mantle(counting_proxy: OwnedProxy,
):
model: Final = _deployment(scenario, runtime.url)
response: Final = _count(gateway, {**_bare(model), "system": system, "tools": _TOOLS})
assert _payload(response) == {"input_tokens": _MANTLE_COUNT}, response.text
assert _payload(response) == {"input_tokens": _MANTLE_COUNT, "litellm_count_source": "provider"}, response.text
assert len(_runtime_count_targets(runtime)) == 1
assert _mantle_bodies(mantle) == (_mantle_body(system=system, tools=_TOOLS),)
@ -520,7 +528,11 @@ def test_google_count_tokens_forwards_system_and_tools_to_mantle(
):
model: Final = _deployment(scenario, runtime.url)
response: Final = gateway.request("POST", f"/v1beta/models/{model}:countTokens", body)
assert _payload(response) == {"totalTokens": _MANTLE_COUNT, "promptTokensDetails": []}, response.text
assert _payload(response) == {
"totalTokens": _MANTLE_COUNT,
"promptTokensDetails": [],
"litellm_count_source": "provider",
}, response.text
assert len(_runtime_count_targets(runtime)) == 1
assert _mantle_bodies(mantle) == (
_mantle_body(
@ -565,7 +577,11 @@ def test_google_count_tokens_normalizes_uppercase_parameters_for_mantle(
):
model: Final = _deployment(scenario, runtime.url)
response: Final = gateway.request("POST", f"/v1beta/models/{model}:countTokens", body)
assert _payload(response) == {"totalTokens": _MANTLE_COUNT, "promptTokensDetails": []}, response.text
assert _payload(response) == {
"totalTokens": _MANTLE_COUNT,
"promptTokensDetails": [],
"litellm_count_source": "provider",
}, response.text
assert len(_runtime_count_targets(runtime)) == 1
assert _mantle_bodies(mantle) == (
_mantle_body(
@ -595,7 +611,11 @@ def test_responses_input_tokens_counts_through_mantle(counting_proxy: OwnedProxy
response: Final = gateway.request(
"POST", "/v1/responses/input_tokens", {"model": model, "input": "Count this message"}
)
assert _payload(response) == {"object": "response.input_tokens", "input_tokens": _MANTLE_COUNT}, response.text
assert _payload(response) == {
"object": "response.input_tokens",
"input_tokens": _MANTLE_COUNT,
"litellm_count_source": "provider",
}, response.text
assert len(_runtime_count_targets(runtime)) == 1
assert _mantle_bodies(mantle) == (_MANTLE_BARE,)
@ -640,7 +660,10 @@ def test_model_the_runtime_counts_never_reaches_mantle(counting_proxy: OwnedProx
gateway.scenario() as scenario,
):
model: Final = _deployment(scenario, runtime.url, _SONNET)
assert _payload(_count(gateway, _full(model))) == {"input_tokens": _RUNTIME_COUNT}
assert _payload(_count(gateway, _full(model))) == {
"input_tokens": _RUNTIME_COUNT,
"litellm_count_source": "provider",
}
detailed: Final = gateway.request(
"POST", "/utils/token_counter", _full(model), params={"call_endpoint": "true"}
)
@ -728,7 +751,11 @@ def test_responses_input_tokens_with_instructions_counts_through_mantle(
"/v1/responses/input_tokens",
{"model": model, "input": "Count this message", "instructions": "Be terse"},
)
assert _payload(response) == {"object": "response.input_tokens", "input_tokens": _MANTLE_COUNT}, response.text
assert _payload(response) == {
"object": "response.input_tokens",
"input_tokens": _MANTLE_COUNT,
"litellm_count_source": "provider",
}, response.text
assert len(_runtime_count_targets(runtime)) == 1
assert _mantle_bodies(mantle) == (_mantle_body(system=[{"type": "text", "text": "Be terse"}]),)
@ -745,7 +772,7 @@ def test_messages_count_tokens_lifts_the_leading_system_run_for_mantle(
):
model: Final = _deployment(scenario, runtime.url)
response: Final = _count(gateway, count_request(model, case))
assert _payload(response) == {"input_tokens": _MANTLE_COUNT}, response.text
assert _payload(response) == {"input_tokens": _MANTLE_COUNT, "litellm_count_source": "provider"}, response.text
assert len(_runtime_count_targets(runtime)) == 1
assert _mantle_bodies(mantle) == (expected_count_body(_OPUS_BASE, case),)
@ -858,7 +885,11 @@ def test_responses_input_tokens_lifts_instructions_ahead_of_a_leading_system_ite
"/v1/responses/input_tokens",
{"model": model, "input": [MID_SYSTEM, USER], "instructions": INSTRUCTION},
)
assert _payload(response) == {"object": "response.input_tokens", "input_tokens": _MANTLE_COUNT}, response.text
assert _payload(response) == {
"object": "response.input_tokens",
"input_tokens": _MANTLE_COUNT,
"litellm_count_source": "provider",
}, response.text
assert len(_runtime_count_targets(runtime)) == 1
assert _mantle_bodies(mantle) == (_mantle_body(system=[LIFTED, {"type": "text", "text": REMINDER}]),)
@ -874,7 +905,7 @@ def test_messages_count_tokens_forwards_tools_beside_the_lifted_system_to_mantle
):
model: Final = _deployment(scenario, runtime.url)
response: Final = _count(gateway, {**count_request(model, STRING_CASE), "tools": _TOOLS})
assert _payload(response) == {"input_tokens": _MANTLE_COUNT}, response.text
assert _payload(response) == {"input_tokens": _MANTLE_COUNT, "litellm_count_source": "provider"}, response.text
assert len(_runtime_count_targets(runtime)) == 1
assert _mantle_bodies(mantle) == (_mantle_body(system=[LIFTED], tools=_TOOLS),)
@ -894,7 +925,7 @@ def test_messages_count_tokens_keeps_a_mid_conversation_system_in_place_for_mant
"messages": [LEADING, USER, MID_SYSTEM, ASSISTANT, FOLLOW_UP],
}
response: Final = _count(gateway, body)
assert _payload(response) == {"input_tokens": _MANTLE_COUNT}, response.text
assert _payload(response) == {"input_tokens": _MANTLE_COUNT, "litellm_count_source": "provider"}, response.text
assert len(_runtime_count_targets(runtime)) == 1
assert _mantle_bodies(mantle) == (
_mantle_body(messages=[USER, MID_SYSTEM, ASSISTANT, FOLLOW_UP], system=[LIFTED]),
@ -947,7 +978,7 @@ def test_messages_count_tokens_repeated_request_lifts_the_leading_system_each_ti
):
model: Final = _deployment(scenario, runtime.url)
answers: Final = tuple(_payload(_count(gateway, count_request(model, STRING_CASE))) for _ in range(2))
assert answers == ({"input_tokens": _MANTLE_COUNT},) * 2
assert answers == ({"input_tokens": _MANTLE_COUNT, "litellm_count_source": "provider"},) * 2
assert _runtime_count_targets(runtime) == (f"/model/{_OPUS_BASE}/count-tokens",) * 2
assert _mantle_bodies(mantle) == (_MANTLE_LIFTED,) * 2
@ -968,7 +999,9 @@ def test_messages_count_tokens_surfaces_the_counting_error_for_a_leading_system_
assert "input_tokens" not in response.text, response.text
assert mantle.drain() == ()
follow_up: Final = _count(gateway, count_request(model, STRING_CASE))
assert _payload(follow_up) == {"input_tokens": _MANTLE_COUNT}, follow_up.text
assert _payload(follow_up) == {"input_tokens": _MANTLE_COUNT, "litellm_count_source": "provider"}, (
follow_up.text
)
assert len(_runtime_count_targets(runtime)) == 1
assert _mantle_bodies(mantle) == (_MANTLE_LIFTED,)
@ -990,7 +1023,7 @@ def test_messages_count_tokens_duplicate_messages_key_lifts_the_last_value_for_m
content=f'{{"model": "{model}", "messages": {first}, "messages": {last}}}',
headers={"Authorization": f"Bearer {gateway.key}", "Content-Type": "application/json"},
)
assert _payload(response) == {"input_tokens": _MANTLE_COUNT}, response.text
assert _payload(response) == {"input_tokens": _MANTLE_COUNT, "litellm_count_source": "provider"}, response.text
assert len(_runtime_count_targets(runtime)) == 1
assert _mantle_bodies(mantle) == (_MANTLE_LIFTED,)
@ -1015,7 +1048,9 @@ def test_disabled_token_counter_counts_a_leading_system_through_mantle(
)
with wire_server(_mantle(_strict), port=mantle_port) as strict:
counted: Final = _count(owned.gateway, count_request(_OWNED_OPUS, STRING_CASE))
assert _payload(counted) == {"input_tokens": _MANTLE_COUNT}, counted.text
assert _payload(counted) == {"input_tokens": _MANTLE_COUNT, "litellm_count_source": "provider"}, (
counted.text
)
assert _mantle_bodies(strict) == (_MANTLE_LIFTED,)
assert len(_runtime_count_targets(runtime)) == 1
@ -1220,7 +1255,7 @@ def test_messages_count_tokens_forwards_a_5kb_system_verbatim_to_mantle(
):
model: Final = _deployment(scenario, runtime.url)
response: Final = _count(gateway, {**_bare(model), "system": system})
assert _payload(response) == {"input_tokens": _MANTLE_COUNT}, response.text
assert _payload(response) == {"input_tokens": _MANTLE_COUNT, "litellm_count_source": "provider"}, response.text
assert len(_runtime_count_targets(runtime)) == 1
assert _mantle_bodies(mantle) == (_mantle_body(system=system),)
@ -1241,7 +1276,7 @@ def test_messages_count_tokens_duplicate_system_and_tools_keys_forward_one_value
content=f'{{"model": "{model}", "messages": {json.dumps(_MESSAGES)}, {fields}, {fields}}}',
headers={"Authorization": f"Bearer {gateway.key}", "Content-Type": "application/json"},
)
assert _payload(response) == {"input_tokens": _MANTLE_COUNT}, response.text
assert _payload(response) == {"input_tokens": _MANTLE_COUNT, "litellm_count_source": "provider"}, response.text
assert len(_runtime_count_targets(runtime)) == 1
(sent,) = mantle.drain()
assert _mantle_requests((sent,)) == (_MANTLE_FULL,)
@ -1259,7 +1294,7 @@ def test_messages_count_tokens_leaves_empty_tools_and_system_out_of_the_mantle_b
):
model: Final = _deployment(scenario, runtime.url)
response: Final = _count(gateway, {**_bare(model), "tools": [], "system": ""})
assert _payload(response) == {"input_tokens": _MANTLE_COUNT}, response.text
assert _payload(response) == {"input_tokens": _MANTLE_COUNT, "litellm_count_source": "provider"}, response.text
assert len(_runtime_count_targets(runtime)) == 1
assert _mantle_bodies(mantle) == (_MANTLE_BARE,)
@ -1275,7 +1310,7 @@ def test_messages_count_tokens_repeated_request_reaches_both_peers_each_time(
):
model: Final = _deployment(scenario, runtime.url)
answers: Final = tuple(_payload(_count(gateway, _bare(model))) for _ in range(2))
assert answers == ({"input_tokens": _MANTLE_COUNT},) * 2
assert answers == ({"input_tokens": _MANTLE_COUNT, "litellm_count_source": "provider"},) * 2
assert _runtime_count_targets(runtime) == (f"/model/{_OPUS_BASE}/count-tokens",) * 2
assert _mantle_bodies(mantle) == (_MANTLE_BARE,) * 2
@ -1304,9 +1339,15 @@ def test_disabled_token_counter_surfaces_the_mantle_error_instead_of_counting_lo
assert _REJECTION in refused.text and "input_tokens" not in refused.text, refused.text
assert _mantle_bodies(refusing) == (_MANTLE_FULL,)
with wire_server(_mantle(), port=mantle_port) as counting:
assert _payload(_count(owned.gateway, _full(_OWNED_OPUS))) == {"input_tokens": _MANTLE_COUNT}
assert _payload(_count(owned.gateway, _full(_OWNED_OPUS))) == {
"input_tokens": _MANTLE_COUNT,
"litellm_count_source": "provider",
}
assert _mantle_bodies(counting) == (_MANTLE_FULL,)
assert _payload(_count(owned.gateway, _full(_OWNED_SONNET))) == {"input_tokens": _RUNTIME_COUNT}
assert _payload(_count(owned.gateway, _full(_OWNED_SONNET))) == {
"input_tokens": _RUNTIME_COUNT,
"litellm_count_source": "provider",
}
rejected: Final = _count(owned.gateway, _full(_OWNED_NOVA))
assert rejected.status_code == 400, rejected.text
assert _UNSUPPORTED in rejected.text and "input_tokens" not in rejected.text, rejected.text

View file

@ -0,0 +1,719 @@
import json
import re
from collections.abc import Callable, Iterator, Mapping
from itertools import chain
from pathlib import Path
from typing import Final
from uuid import uuid4
import anthropic
import httpx
import pytest
from anthropic.types import MessageCountTokensToolParam, MessageTokensCount
from integration._support.bedrock_runtime_peer import target_of
from integration._support.client import Gateway, Scenario, gateway_from_environment
from integration._support.ports import allocate_port
from integration._support.process import OwnedProxy, graceful_stop_seconds, owned_proxy_process
from integration._support.vertex import service_account_json
from integration._support.wire import Reply, Request, Wire, wire_server
from pydantic import JsonValue, TypeAdapter
pytestmark = pytest.mark.timeout(2 * graceful_stop_seconds() + 120)
_SONNET: Final = "us.anthropic.claude-sonnet-5-5"
_SONNET_BASE: Final = "anthropic.claude-sonnet-5-5"
_OPUS: Final = "us.anthropic.claude-opus-5-5"
_OPUS_BASE: Final = "anthropic.claude-opus-5-5"
_NOVA: Final = "amazon.nova-lite-v1:0"
_KIMI: Final = "us.moonshotai.kimi-k3"
_KIMI_BASE: Final = "moonshotai.kimi-k3"
_PROFILE_ID: Final = "lit9563count"
_PROFILE_ARN: Final = f"arn:aws:bedrock:us-east-1:123456789012:application-inference-profile/{_PROFILE_ID}"
_COST_MAP_PATH: Final = Path(__file__).resolve().parents[3] / "model_prices_and_context_window.json"
_REGION: Final = "us-west-2"
_COUNT_REGION: Final = "us-east-1"
_ACCESS_KEY: Final = "AKIAINTEGRATIONCOUNT"
_SECRET_KEY: Final = "integration-count-secret"
_PROVIDER_COUNT: Final = 1345
_COUNT_MODEL_COUNT: Final = 2718
_FRAMING: Final = 50
_UNSUPPORTED: Final = "The provided model doesn't support counting tokens."
_COUNT_TARGET: Final = re.compile(r"^/model/(.+)/count-tokens$")
_SCRIPTED_STATUS: Final = re.compile(r"status=(\d{3})")
_SCRIPTED_COUNT_MODEL_STATUS: Final = re.compile(r"count_model=(\d{3})")
_CREDENTIAL: Final = re.compile(r"Credential=([^/]+)/\d+/([^/]+)/")
_JSON_OBJECT: Final = TypeAdapter(dict[str, JsonValue])
_COST_MAP: Final = TypeAdapter(dict[str, dict[str, JsonValue]])
_SYSTEM: Final = "You are a terse assistant that answers in one sentence"
_WEATHER_SCHEMA: Final[dict[str, JsonValue]] = {
"type": "object",
"properties": {"city": {"type": "string", "description": "City to look up"}},
"required": ["city"],
}
_SDK_TOOLS: Final[list[MessageCountTokensToolParam]] = [
{
"name": "get_weather",
"description": "Look up the current weather for a city",
"input_schema": {
"type": "object",
"properties": {"city": {"type": "string", "description": "City to look up"}},
"required": ["city"],
},
}
]
_COUNT_MODEL_SOURCE: Final = {"litellm_count_source": "token_count_model", "litellm_count_model": _OPUS}
_OPENAI_PRIMARY: Final = "gpt-count-primary"
_OPENAI_COUNT_MODEL: Final = "gpt-count-model"
_OPENAI_COUNT_TARGET: Final = "/v1/responses/input_tokens"
_COUNTED_FIELDS: Final = ("input", "instructions", "tools", "text")
_ANSWER_SCHEMA: Final[dict[str, JsonValue]] = {"type": "object", "properties": {"answer": {"type": "string"}}}
_ANTHROPIC_TOOL: Final[dict[str, JsonValue]] = {
"name": "get_weather",
"description": "Look up the current weather for a city",
"input_schema": _WEATHER_SCHEMA,
}
_TOOL_TURNS: Final[list[JsonValue]] = [
{"role": "user", "content": "What is the weather in Paris?"},
{
"role": "assistant",
"content": [{"type": "tool_use", "id": "toolu_01", "name": "get_weather", "input": {"city": "Paris"}}],
},
{"role": "user", "content": [{"type": "tool_result", "tool_use_id": "toolu_01", "content": "Sunny"}]},
]
_LOCAL_SOURCE: Final = {"litellm_count_source": "local_estimate"}
def _prompt(status: int, count_model_status: int | None = None) -> str:
scripted: Final = "" if count_model_status is None else f" count_model={count_model_status}"
return f"Count this message status={status}{scripted}"
def _converse_body(prompt: str) -> dict[str, JsonValue]:
return {
"input": {
"converse": {
"messages": [{"role": "user", "content": [{"text": prompt}]}],
"system": [{"text": _SYSTEM}],
"toolConfig": {
"tools": [
{
"toolSpec": {
"name": "get_weather",
"description": "Look up the current weather for a city",
"inputSchema": {"json": _WEATHER_SCHEMA},
}
}
]
},
}
}
}
def _json_reply(status: int, payload: Mapping[str, JsonValue]) -> Reply:
return Reply(status=status, body=json.dumps(payload).encode())
def _counted_model(request: Request) -> str:
counted: Final = _COUNT_TARGET.match(target_of(request))
return target_of(request) if counted is None else counted.group(1)
def _scripted(pattern: re.Pattern[str], request: Request, default: int) -> int:
scripted: Final = pattern.search(request.body.decode(errors="replace"))
return default if scripted is None else int(scripted.group(1))
def _runtime(request: Request) -> Reply:
model: Final = _counted_model(request)
if model == _OPUS_BASE:
count_model_status: Final = _scripted(_SCRIPTED_COUNT_MODEL_STATUS, request, 200)
if count_model_status == 200:
return _json_reply(200, {"inputTokens": _COUNT_MODEL_COUNT})
return _json_reply(count_model_status, {"message": f"scripted runtime {count_model_status} for {model}"})
status: Final = _scripted(_SCRIPTED_STATUS, request, 500)
if status == 200:
return _json_reply(200, {"inputTokens": _PROVIDER_COUNT})
return _json_reply(status, {"message": _UNSUPPORTED if status == 400 else f"scripted runtime {status}"})
def _mantle(request: Request) -> Reply:
model: Final = _JSON_OBJECT.validate_json(request.body).get("model")
return _json_reply(
404, {"type": "error", "error": {"type": "not_found_error", "message": f"scripted mantle 404 for {model}"}}
)
def _signer(request: Request) -> tuple[str, str]:
credential: Final = _CREDENTIAL.search(request.headers.get("authorization", ""))
return ("", "") if credential is None else (credential.group(1), credential.group(2))
def _runtime_counts(wire: Wire) -> tuple[tuple[str, tuple[str, str], dict[str, JsonValue]], ...]:
return tuple(
(_counted_model(request), _signer(request), _JSON_OBJECT.validate_json(request.body))
for request in wire.drain()
)
def _runtime_models(wire: Wire) -> tuple[str, ...]:
return tuple(_counted_model(request) for request in wire.drain())
def _mantle_models(wire: Wire) -> tuple[JsonValue, ...]:
return tuple(_JSON_OBJECT.validate_json(request.body).get("model") for request in wire.drain())
def _url(port: int) -> str:
return f"http://127.0.0.1:{port}"
def _litellm_params(model: str, api_base: str) -> dict[str, JsonValue]:
return {
"model": f"bedrock/{model}",
"api_base": api_base,
"aws_access_key_id": _ACCESS_KEY,
"aws_secret_access_key": _SECRET_KEY,
"aws_region_name": _REGION,
}
@pytest.fixture(scope="module")
def runtime_port() -> int:
return allocate_port()
@pytest.fixture(scope="module")
def mantle_port() -> int:
return allocate_port()
@pytest.fixture(scope="module")
def counting_proxy(tmp_path_factory: pytest.TempPathFactory, mantle_port: int) -> Iterator[OwnedProxy]:
with (
gateway_from_environment() as gateway,
owned_proxy_process(
gateway,
tmp_path_factory.mktemp("count-model"),
{
"BEDROCK_MANTLE_API_BASE": _url(mantle_port),
"LITELLM_LOCAL_MODEL_COST_MAP": "True",
"HF_HUB_OFFLINE": "1",
},
workers=1,
remove_environment=("AWS_BEARER_TOKEN_BEDROCK",),
) as owned,
):
yield owned
def _deployment(
scenario: Scenario, runtime_port: int, model: str = _SONNET, model_info: Mapping[str, JsonValue] | None = None
) -> str:
return scenario.model(model_info=model_info, api_key=None, **_litellm_params(model, _url(runtime_port)))
def _count(gateway: Gateway, model: str, status: int, count_model_status: int | None = None) -> MessageTokensCount:
with anthropic.Anthropic(base_url=str(gateway.client.base_url), api_key=gateway.key, max_retries=0) as client:
return client.messages.count_tokens(
model=model,
messages=[{"role": "user", "content": _prompt(status, count_model_status)}],
system=_SYSTEM,
tools=_SDK_TOOLS,
)
def _messages_estimate(gateway: Gateway, model: str) -> tuple[int, JsonValue]:
counted: Final = _count(gateway, model, 400)
return counted.input_tokens, (counted.model_extra or {}).get("litellm_count_source")
def _utils_estimate(gateway: Gateway, model: str) -> tuple[int, JsonValue]:
response: Final = gateway.request(
"POST", "/utils/token_counter", {"model": model, "messages": [{"role": "user", "content": _prompt(400)}]}
)
assert response.status_code == 200, response.text
body: Final = _JSON_OBJECT.validate_json(response.content)
total: Final = body["total_tokens"]
assert isinstance(total, int), body
return total, body.get("count_source")
def _shipped_framing(model: str) -> JsonValue:
return _COST_MAP.validate_json(_COST_MAP_PATH.read_bytes())[model].get("token_count_framing")
def test_shipped_token_count_model_counts_the_request_in_its_region_without_trying_the_model(
counting_proxy: OwnedProxy, runtime_port: int, mantle_port: int
) -> None:
gateway: Final = counting_proxy.gateway
with (
wire_server(_runtime, port=runtime_port) as runtime,
wire_server(_mantle, port=mantle_port) as mantle,
gateway.scenario() as scenario,
):
counted: Final = _count(gateway, _deployment(scenario, runtime_port), 404)
assert (counted.input_tokens, counted.model_extra) == (_COUNT_MODEL_COUNT, _COUNT_MODEL_SOURCE), counted
assert _runtime_counts(runtime) == ((_OPUS_BASE, (_ACCESS_KEY, _COUNT_REGION), _converse_body(_prompt(404))),)
assert _mantle_models(mantle) == ()
@pytest.mark.parametrize(
("count_model_status", "error", "message"),
[
(401, anthropic.AuthenticationError, f"scripted runtime 401 for {_OPUS_BASE}"),
(429, anthropic.RateLimitError, f"scripted runtime 429 for {_OPUS_BASE}"),
(404, anthropic.NotFoundError, f"scripted mantle 404 for {_OPUS_BASE}"),
],
)
def test_token_count_model_failure_answers_its_error_and_names_the_region_to_enable(
counting_proxy: OwnedProxy,
runtime_port: int,
mantle_port: int,
count_model_status: int,
error: type[anthropic.APIStatusError],
message: str,
) -> None:
gateway: Final = counting_proxy.gateway
with (
wire_server(_runtime, port=runtime_port) as runtime,
wire_server(_mantle, port=mantle_port) as mantle,
gateway.scenario() as scenario,
):
with pytest.raises(error) as refused:
_count(gateway, _deployment(scenario, runtime_port), 200, count_model_status=count_model_status)
assert message in str(refused.value) and f"{_OPUS} in {_COUNT_REGION}" in str(refused.value), refused.value
assert _runtime_models(runtime) == (_OPUS_BASE,)
assert _mantle_models(mantle) == ((_OPUS_BASE,) if count_model_status == 404 else ())
def test_framing_is_added_to_the_local_estimate_when_the_runtime_cannot_count(
counting_proxy: OwnedProxy, runtime_port: int, mantle_port: int
) -> None:
gateway: Final = counting_proxy.gateway
with (
wire_server(_runtime, port=runtime_port) as runtime,
wire_server(_mantle, port=mantle_port) as mantle,
gateway.scenario() as scenario,
):
framed: Final = _count(
gateway, _deployment(scenario, runtime_port, _NOVA, {"token_count_framing": _FRAMING}), 400
)
unframed: Final = _count(gateway, _deployment(scenario, runtime_port, _NOVA, {"token_count_framing": 0}), 400)
assert (framed.model_extra, unframed.model_extra) == (_LOCAL_SOURCE, _LOCAL_SOURCE), (framed, unframed)
assert framed.input_tokens - unframed.input_tokens == _FRAMING, (framed, unframed)
assert _runtime_models(runtime) == (_NOVA, _NOVA)
assert _mantle_models(mantle) == ()
def test_framing_on_one_deployment_leaves_a_sibling_of_the_same_model_raising_the_400(
counting_proxy: OwnedProxy, runtime_port: int, mantle_port: int
) -> None:
gateway: Final = counting_proxy.gateway
with (
wire_server(_runtime, port=runtime_port) as runtime,
wire_server(_mantle, port=mantle_port) as mantle,
gateway.scenario() as scenario,
):
framed: Final = _deployment(scenario, runtime_port, _NOVA, {"token_count_framing": _FRAMING})
sibling: Final = _deployment(scenario, runtime_port, _NOVA)
assert _count(gateway, framed, 400).model_extra == _LOCAL_SOURCE
with pytest.raises(anthropic.BadRequestError, match=re.escape(_UNSUPPORTED)):
_count(gateway, sibling, 400)
assert _runtime_models(runtime) == (_NOVA, _NOVA)
assert _mantle_models(mantle) == ()
def test_empty_configured_token_count_model_counts_the_model_itself_in_the_deployment_region(
counting_proxy: OwnedProxy, runtime_port: int, mantle_port: int
) -> None:
gateway: Final = counting_proxy.gateway
with (
wire_server(_runtime, port=runtime_port) as runtime,
wire_server(_mantle, port=mantle_port) as mantle,
gateway.scenario() as scenario,
):
counted: Final = _count(gateway, _deployment(scenario, runtime_port, model_info={"token_count_model": {}}), 200)
assert (counted.input_tokens, counted.model_extra) == (_PROVIDER_COUNT, {"litellm_count_source": "provider"})
assert _runtime_counts(runtime) == ((_SONNET_BASE, (_ACCESS_KEY, _REGION), _converse_body(_prompt(200))),)
assert _mantle_models(mantle) == ()
@pytest.mark.parametrize("base_model_in", ["model_info", "litellm_params"])
def test_inference_profile_arn_counts_with_the_token_count_model_of_its_base_model(
counting_proxy: OwnedProxy, runtime_port: int, mantle_port: int, base_model_in: str
) -> None:
gateway: Final = counting_proxy.gateway
base_model: Final[dict[str, JsonValue]] = {"base_model": f"bedrock/{_SONNET}"}
with (
wire_server(_runtime, port=runtime_port) as runtime,
wire_server(_mantle, port=mantle_port) as mantle,
gateway.scenario() as scenario,
):
model: Final = scenario.model(
model_info=base_model if base_model_in == "model_info" else None,
api_key=None,
**_litellm_params(_PROFILE_ARN, _url(runtime_port)),
**(base_model if base_model_in == "litellm_params" else {}),
)
counted: Final = _count(gateway, model, 404)
assert (counted.input_tokens, counted.model_extra) == (_COUNT_MODEL_COUNT, _COUNT_MODEL_SOURCE), counted
assert _runtime_models(runtime) == (_OPUS_BASE,)
assert _mantle_models(mantle) == ()
@pytest.mark.parametrize(
("estimate", "runtime_calls"),
[(_messages_estimate, (_KIMI_BASE, _KIMI_BASE)), (_utils_estimate, ())],
ids=["messages_count_tokens", "utils_token_counter"],
)
def test_shipped_framing_is_added_to_the_estimate_and_a_configured_zero_turns_it_off(
counting_proxy: OwnedProxy,
runtime_port: int,
mantle_port: int,
estimate: Callable[[Gateway, str], tuple[int, JsonValue]],
runtime_calls: tuple[str, ...],
) -> None:
gateway: Final = counting_proxy.gateway
with (
wire_server(_runtime, port=runtime_port) as runtime,
wire_server(_mantle, port=mantle_port) as mantle,
gateway.scenario() as scenario,
):
shipped: Final = estimate(gateway, _deployment(scenario, runtime_port, _KIMI))
unframed: Final = estimate(gateway, _deployment(scenario, runtime_port, _KIMI, {"token_count_framing": 0}))
assert (shipped[1], unframed[1]) == ("local_estimate", "local_estimate"), (shipped, unframed)
framing: Final = _shipped_framing(_KIMI)
assert isinstance(framing, int) and framing > 0, framing
assert shipped[0] - unframed[0] == framing, (shipped, unframed)
assert _runtime_models(runtime) == runtime_calls
assert _mantle_models(mantle) == ()
def _openai_count(request: Request) -> Reply:
model: Final = _JSON_OBJECT.validate_json(request.body).get("model")
if request.target == _OPENAI_COUNT_TARGET and model == _OPENAI_COUNT_MODEL:
return _json_reply(200, {"object": "response.input_tokens", "input_tokens": _COUNT_MODEL_COUNT})
return _json_reply(
404,
{"error": {"message": f"scripted openai 404 for {model}", "type": "invalid_request_error", "code": None}},
)
def _counted_fields(body: Mapping[str, JsonValue]) -> dict[str, JsonValue]:
return {key: body[key] for key in _COUNTED_FIELDS if key in body}
def _openai_counts(wire: Wire) -> tuple[tuple[str, JsonValue, dict[str, JsonValue]], ...]:
bodies: Final = tuple((request.target, _JSON_OBJECT.validate_json(request.body)) for request in wire.drain())
return tuple((target, body.get("model"), _counted_fields(body)) for target, body in bodies)
def _openai_deployment(scenario: Scenario, wire: Wire, model_info: Mapping[str, JsonValue]) -> str:
return scenario.model(model=f"openai/{_OPENAI_PRIMARY}", api_base=f"{wire.url}/v1", model_info=model_info)
def test_token_count_model_counts_the_anthropic_body_converted_like_the_provider_count(
counting_proxy: OwnedProxy,
) -> None:
gateway: Final = counting_proxy.gateway
with wire_server(_openai_count) as openai, gateway.scenario() as scenario:
model: Final = _openai_deployment(scenario, openai, {"token_count_model": {"model": _OPENAI_COUNT_MODEL}})
response: Final = gateway.request(
"POST",
"/v1/messages/count_tokens",
{
"model": model,
"system": _SYSTEM,
"tools": [_ANTHROPIC_TOOL],
"output_config": {"format": {"type": "json_schema", "schema": _ANSWER_SCHEMA}},
"messages": _TOOL_TURNS,
},
)
assert response.status_code == 200, response.text
assert _JSON_OBJECT.validate_json(response.content) == {
"input_tokens": _COUNT_MODEL_COUNT,
"litellm_count_source": "token_count_model",
"litellm_count_model": _OPENAI_COUNT_MODEL,
}
counts: Final = _openai_counts(openai)
assert tuple((target, counted) for target, counted, _ in counts) == (
(_OPENAI_COUNT_TARGET, _OPENAI_COUNT_MODEL),
), counts
primary: Final = counts[0][2]
assert primary["instructions"] == _SYSTEM, primary
text: Final = primary.get("text")
assert isinstance(text, dict) and isinstance(text.get("format"), dict), primary
input_items: Final = primary["input"]
assert isinstance(input_items, list), primary
item_types: Final = {item.get("type") for item in input_items if isinstance(item, dict)}
assert {"function_call", "function_call_output"} <= item_types, input_items
@pytest.mark.parametrize(
("body", "named"),
[
(
{
"messages": [
{
"role": "user",
"content": [
{"type": "text", "text": "Summarize the notes."},
{
"type": "search_result",
"source": "https://example.com/notes",
"title": "Notes",
"content": [],
},
],
}
]
},
"search_result",
),
(
{"messages": [{"role": "user", "content": "hello"}, {"content": "no role"}]},
"messages.1.role: Field required",
),
],
ids=["unconvertible_block", "missing_role"],
)
def test_framing_keeps_the_400_for_a_body_the_openai_count_rejects_before_calling_openai(
counting_proxy: OwnedProxy, body: Mapping[str, JsonValue], named: str
) -> None:
gateway: Final = counting_proxy.gateway
with wire_server(_openai_count) as openai, gateway.scenario() as scenario:
model: Final = _openai_deployment(scenario, openai, {"token_count_framing": _FRAMING})
rejected: Final = gateway.request("POST", "/v1/messages/count_tokens", {"model": model, **body})
assert rejected.status_code == 400, rejected.text
error: Final = _JSON_OBJECT.validate_json(rejected.content)["error"]
assert isinstance(error, dict) and named in str(error.get("message")), error
assert openai.drain() == ()
estimated: Final = gateway.request(
"POST", "/v1/messages/count_tokens", {"model": model, "messages": [{"role": "user", "content": "hello"}]}
)
assert estimated.status_code == 200, estimated.text
assert _JSON_OBJECT.validate_json(estimated.content).get("litellm_count_source") == "local_estimate"
assert tuple(counted for _, counted, _ in _openai_counts(openai)) == (_OPENAI_PRIMARY,)
def _runtime_cannot_count(_request: Request) -> Reply:
return _json_reply(400, {"message": _UNSUPPORTED})
_REPLAYED_WEB_SEARCH: Final[list[JsonValue]] = [
{"role": "user", "content": "What is the newest LiteLLM release?"},
{
"role": "assistant",
"content": [
{"type": "server_tool_use", "id": "srvtoolu_01", "name": "web_search", "input": {"query": "LiteLLM"}},
{
"type": "web_search_tool_result",
"tool_use_id": "srvtoolu_01",
"content": [
{
"type": "web_search_result",
"url": "https://docs.litellm.ai/release_notes",
"title": "Release notes",
"encrypted_content": "",
}
],
},
{"type": "text", "text": "The newest release is in the release notes."},
],
},
{"role": "user", "content": "Thanks."},
]
_BOOLEAN_SUBSCHEMA_TOOL: Final[dict[str, JsonValue]] = {
"name": "lookup",
"description": "Look up a value",
"input_schema": {"type": "object", "properties": {"query": True}},
}
_UNREADABLE_BODIES: Final[dict[str, dict[str, JsonValue]]] = {
"web_search_replay": {"messages": _REPLAYED_WEB_SEARCH},
"boolean_subschema_tool": {
"messages": [{"role": "user", "content": "Look up the value."}],
"tools": [_BOOLEAN_SUBSCHEMA_TOOL],
},
}
_COUNT_ENDPOINTS: Final[dict[str, tuple[str, str]]] = {
"messages_count_tokens": ("/v1/messages/count_tokens", "litellm_count_source"),
"utils_token_counter": ("/utils/token_counter?call_endpoint=true", "count_source"),
}
def _count_answer(response: httpx.Response, source_key: str) -> tuple[int, str]:
if response.status_code not in (200, 400):
return response.status_code, response.text
body: Final = _JSON_OBJECT.validate_json(response.content)
error: Final = body.get("error")
if response.status_code == 400 and isinstance(error, dict):
return 400, str(error.get("message"))
return response.status_code, str(body.get(source_key))
@pytest.mark.parametrize("endpoint", sorted(_COUNT_ENDPOINTS))
@pytest.mark.parametrize("unreadable", sorted(_UNREADABLE_BODIES))
def test_shipped_framing_never_answers_a_500_for_a_body_the_local_count_cannot_read(
counting_proxy: OwnedProxy, runtime_port: int, mantle_port: int, unreadable: str, endpoint: str
) -> None:
gateway: Final = counting_proxy.gateway
path, source_key = _COUNT_ENDPOINTS[endpoint]
with (
wire_server(_runtime_cannot_count, port=runtime_port) as runtime,
wire_server(_mantle, port=mantle_port) as mantle,
gateway.scenario() as scenario,
):
response: Final = gateway.request(
"POST", path, {"model": _deployment(scenario, runtime_port, _KIMI), **_UNREADABLE_BODIES[unreadable]}
)
status, answer = _count_answer(response, source_key)
assert (status, answer) == (200, "local_estimate") or (status == 400 and _UNSUPPORTED in answer), (
response.status_code,
response.text,
)
assert _runtime_models(runtime) == (_KIMI_BASE,)
assert _mantle_models(mantle) == ()
_GEMINI_MODEL: Final = "gemini-4-argon"
_GEMINI_COUNT_MODEL: Final = "gemini-3.8-flash"
_VERTEX_PROJECT: Final = "scripted-project"
_COMPACT_UNDER_THE_MINIMUM_TRIGGER: Final[dict[str, JsonValue]] = {
"edits": [{"type": "compact_20260112", "trigger": {"type": "input_tokens", "value": 1000}}]
}
_CLEAR_OLDER_TOOL_RESULTS: Final[dict[str, JsonValue]] = {
"edits": [
{
"type": "clear_tool_uses_20250919",
"trigger": {"type": "input_tokens", "value": 500},
"keep": {"type": "tool_uses", "value": 1},
}
]
}
_READ_FILE_TOOL: Final[dict[str, JsonValue]] = {
"name": "read_file",
"description": "Read a file",
"input_schema": {"type": "object", "properties": {"path": {"type": "string"}}},
}
_FILE_TEXT: Final = "line of a long file that the agent read earlier. " * 180
_FILES: Final = 3
def _google_cannot_count(_request: Request) -> Reply:
return _json_reply(400, {"error": {"code": 400, "message": "scripted count 400", "status": "INVALID_ARGUMENT"}})
def _google_counts_only_the_count_model(request: Request) -> Reply:
if request.target.endswith(f"/{_GEMINI_COUNT_MODEL}:countTokens"):
return _json_reply(200, {"totalTokens": _COUNT_MODEL_COUNT})
return _google_cannot_count(request)
def _google_deployment(
gateway: Gateway, scenario: Scenario, wire: Wire, provider: str, model_info: dict[str, JsonValue]
) -> str:
if provider == "gemini":
return scenario.model(
model=f"gemini/{_GEMINI_MODEL}", api_base=wire.url, api_key="scripted-gemini-key", model_info=model_info
)
return scenario.model(
model=f"vertex_ai/{_GEMINI_MODEL}",
api_base=wire.url,
api_key=None,
vertex_project=_VERTEX_PROJECT,
vertex_location="us-east5",
vertex_credentials=service_account_json(_VERTEX_PROJECT, gateway.upstream_url.rstrip("/")),
model_info=model_info,
)
def _google_count_targets(wire: Wire) -> tuple[bool, ...]:
return tuple(request.target.endswith(":countTokens") for request in wire.drain())
def _read_file_round(marker: str, index: int) -> tuple[JsonValue, ...]:
return (
{"role": "user", "content": f"Read file {index}."},
{
"role": "assistant",
"content": [
{"type": "tool_use", "id": f"toolu_{index}", "name": "read_file", "input": {"path": f"{index}.txt"}}
],
},
{
"role": "user",
"content": [
{
"type": "tool_result",
"tool_use_id": f"toolu_{index}",
"content": f"<file {marker}-{index}>{_FILE_TEXT}",
}
],
},
)
def _agent_messages(marker: str) -> list[JsonValue]:
rounds: Final = tuple(chain.from_iterable(_read_file_round(marker, index) for index in range(_FILES)))
return [*rounds, {"role": "user", "content": "Summarize what you read."}]
def _counted_files(request: Request, marker: str) -> tuple[bool, tuple[bool, ...]]:
sent: Final = json.dumps(_JSON_OBJECT.validate_json(request.body))
return (
request.target.endswith(f"/{_GEMINI_COUNT_MODEL}:countTokens"),
tuple(f"<file {marker}-{index}>" in sent for index in range(_FILES)),
)
@pytest.mark.parametrize("provider", ["gemini", "vertex_ai"])
def test_framing_keeps_the_400_for_a_context_edit_the_gemini_count_rejects_before_calling_google(
counting_proxy: OwnedProxy, provider: str
) -> None:
gateway: Final = counting_proxy.gateway
hello: Final[list[JsonValue]] = [{"role": "user", "content": "hello"}]
with wire_server(_google_cannot_count) as google, gateway.scenario() as scenario:
model: Final = _google_deployment(gateway, scenario, google, provider, {"token_count_framing": _FRAMING})
rejected: Final = gateway.request(
"POST",
"/v1/messages/count_tokens",
{"model": model, "messages": hello, "context_management": _COMPACT_UNDER_THE_MINIMUM_TRIGGER},
)
assert rejected.status_code == 400, rejected.text
assert _google_count_targets(google) == ()
estimated: Final = gateway.request("POST", "/v1/messages/count_tokens", {"model": model, "messages": hello})
assert estimated.status_code == 200, estimated.text
assert _JSON_OBJECT.validate_json(estimated.content).get("litellm_count_source") == "local_estimate"
assert _google_count_targets(google) == (True,)
@pytest.mark.parametrize("provider", ["gemini", "vertex_ai"])
def test_token_count_model_counts_the_body_left_after_the_context_management_edits(
counting_proxy: OwnedProxy, provider: str
) -> None:
gateway: Final = counting_proxy.gateway
marker: Final = uuid4().hex
with wire_server(_google_counts_only_the_count_model) as google, gateway.scenario() as scenario:
model: Final = _google_deployment(
gateway, scenario, google, provider, {"token_count_model": {"model": _GEMINI_COUNT_MODEL}}
)
counted: Final = gateway.request(
"POST",
"/v1/messages/count_tokens",
{
"model": model,
"tools": [_READ_FILE_TOOL],
"messages": _agent_messages(marker),
"context_management": _CLEAR_OLDER_TOOL_RESULTS,
},
)
assert counted.status_code == 200, counted.text
assert _JSON_OBJECT.validate_json(counted.content) == {
"input_tokens": _COUNT_MODEL_COUNT,
"litellm_count_source": "token_count_model",
"litellm_count_model": _GEMINI_COUNT_MODEL,
}
assert tuple(_counted_files(request, marker) for request in google.drain()) == ((True, (False, False, True)),)

View file

@ -46,6 +46,7 @@ _CLAUDE_BACKEND: Final = "claude-haiku-4-5"
_GEMINI_BACKEND: Final = "gemini-2.5-flash"
_PROVIDER_KEY: Final = "scripted-malformed-tools-key"
_COUNT: Final = 37
_PROVIDER_SOURCE: Final[Mapping[str, JsonValue]] = MappingProxyType({"litellm_count_source": "provider"})
_CONFIG_STORE: Final = "vs_integration_config_store"
_OTHER_STORE: Final = "vs_malformed_tools_other_store"
_CLAUDE_CODE_AGENT: Final = "claude-cli/2.1.0 (external, cli)"
@ -821,7 +822,7 @@ def test_count_tokens_forwards_well_formed_tools(rig: _Rig) -> None:
"POST", "/v1/messages/count_tokens", _count_body(rig, marker, tools=[MESSAGES.tool])
)
assert response.status_code == 200, response.text
assert _JSON_OBJECT.validate_json(response.content) == {"input_tokens": _COUNT}, response.text
assert _JSON_OBJECT.validate_json(response.content) == {"input_tokens": _COUNT, **_PROVIDER_SOURCE}, response.text
assert _one_delivery(rig, marker, "/v1/messages/count_tokens")["tools"] == [MESSAGES.tool]
@ -853,6 +854,7 @@ def test_responses_input_tokens_counts_well_formed_tools(rig: _Rig) -> None:
assert _JSON_OBJECT.validate_json(response.content) == {
"object": "response.input_tokens",
"input_tokens": _COUNT,
**_PROVIDER_SOURCE,
}, response.text
sent: Final = _one_delivery(rig, marker, "/v1/messages/count_tokens")
assert "get_weather" in json.dumps(sent["tools"]), sent
@ -1396,7 +1398,7 @@ def test_count_tokens_drops_the_bad_entry_a_deployment_list_names(rig: _Rig, fie
{**_count_body(rig, marker, tools=[MESSAGES.tool, 5]), "model": field_groups.anthropic},
)
assert response.status_code == 200, response.text
assert _JSON_OBJECT.validate_json(response.content) == {"input_tokens": _COUNT}, response.text
assert _JSON_OBJECT.validate_json(response.content) == {"input_tokens": _COUNT, **_PROVIDER_SOURCE}, response.text
assert _one_delivery(rig, marker, "/v1/messages/count_tokens")["tools"] == [MESSAGES.tool]
@ -1656,7 +1658,7 @@ _CHECKED: Final[Mapping[str, _Checked]] = MappingProxyType(
named_as=MESSAGES,
send=_count_tokens,
target="/v1/messages/count_tokens",
answered=_counted({"input_tokens": _COUNT}),
answered=_counted({"input_tokens": _COUNT, **_PROVIDER_SOURCE}),
kept_by_a_tools_list=("get_weather",),
bad_entry=_MESSAGES_BAD_ENTRY,
),
@ -1666,7 +1668,7 @@ _CHECKED: Final[Mapping[str, _Checked]] = MappingProxyType(
named_as=MESSAGES,
send=_input_tokens,
target="/v1/messages/count_tokens",
answered=_counted({"object": "response.input_tokens", "input_tokens": _COUNT}),
answered=_counted({"object": "response.input_tokens", "input_tokens": _COUNT, **_PROVIDER_SOURCE}),
kept_by_a_tools_list=("get_weather",),
bad_entry=_RESPONSES_BAD_ENTRY,
),

View file

@ -1118,6 +1118,7 @@ def test_input_tokens_counts_marked_items_without_their_marks(gateway: Gateway)
assert _JSON_OBJECT.validate_json(response.text) == {
"object": "response.input_tokens",
"input_tokens": _COUNTED_TOKENS,
"litellm_count_source": "provider",
}
assert counted.target.endswith("/count_tokens"), counted.target
assert anthropic_labels(counted) == []
@ -1199,6 +1200,7 @@ def test_input_tokens_reads_each_input_shape_without_item_marks(gateway: Gateway
assert _JSON_OBJECT.validate_json(response.text) == {
"object": "response.input_tokens",
"input_tokens": _COUNTED_TOKENS,
"litellm_count_source": "provider",
}
assert [anthropic_labels(request) for request in counted] == [[]]

View file

@ -396,7 +396,7 @@ def test_messages_count_tokens_forwards_system_and_tools(gateway: Gateway, syste
with wire_server(_peer()) as wire, gateway.scenario() as scenario:
model: Final = _deployment(gateway, scenario, wire.url)
response: Final = _count(gateway, {**_bare(model), "system": system, "tools": _TOOLS})
assert _payload(response) == {"input_tokens": _PEER_COUNT}, response.text
assert _payload(response) == {"input_tokens": _PEER_COUNT, "litellm_count_source": "provider"}, response.text
assert _counted_bodies(wire) == ({**_PEER_BARE, "system": system, "tools": _TOOLS},)
@ -404,7 +404,7 @@ def test_messages_count_tokens_without_system_or_tools_sends_bare_body(gateway:
with wire_server(_peer()) as wire, gateway.scenario() as scenario:
model: Final = _deployment(gateway, scenario, wire.url)
response: Final = _count(gateway, _bare(model))
assert _payload(response) == {"input_tokens": _PEER_COUNT}, response.text
assert _payload(response) == {"input_tokens": _PEER_COUNT, "litellm_count_source": "provider"}, response.text
assert _counted_bodies(wire) == (_PEER_BARE,)
@ -443,7 +443,11 @@ def test_responses_input_tokens_counts_through_the_partner_peer(gateway: Gateway
response: Final = gateway.request(
"POST", "/v1/responses/input_tokens", {"model": model, "input": "Count this message"}
)
assert _payload(response) == {"object": "response.input_tokens", "input_tokens": _PEER_COUNT}, response.text
assert _payload(response) == {
"object": "response.input_tokens",
"input_tokens": _PEER_COUNT,
"litellm_count_source": "provider",
}, response.text
assert _counted_bodies(wire) == (_PEER_BARE,)
@ -498,7 +502,7 @@ def test_llama_count_still_reaches_the_meta_count_route(gateway: Gateway) -> Non
with wire_server(_peer()) as wire, gateway.scenario() as scenario:
model: Final = _deployment(gateway, scenario, wire.url, model=f"vertex_ai/{_LLAMA_BACKEND}")
response: Final = _count(gateway, _bare(model))
assert _payload(response) == {"input_tokens": _PEER_COUNT}, response.text
assert _payload(response) == {"input_tokens": _PEER_COUNT, "litellm_count_source": "provider"}, response.text
(sent,) = _count_bodies(wire.drain(), _LLAMA_COUNT_TARGET)
assert sent["messages"] == _MESSAGES, sent
@ -616,7 +620,7 @@ def test_messages_count_tokens_forwards_an_empty_tools_list(gateway: Gateway) ->
with wire_server(_peer()) as wire, gateway.scenario() as scenario:
model: Final = _deployment(gateway, scenario, wire.url)
response: Final = _count(gateway, {**_bare(model), "tools": []})
assert _payload(response) == {"input_tokens": _PEER_COUNT}, response.text
assert _payload(response) == {"input_tokens": _PEER_COUNT, "litellm_count_source": "provider"}, response.text
assert _counted_bodies(wire) == ({**_PEER_BARE, "tools": []},)
@ -624,7 +628,7 @@ def test_messages_count_tokens_forwards_an_empty_system_string(gateway: Gateway)
with wire_server(_peer()) as wire, gateway.scenario() as scenario:
model: Final = _deployment(gateway, scenario, wire.url)
response: Final = _count(gateway, {**_bare(model), "system": ""})
assert _payload(response) == {"input_tokens": _PEER_COUNT}, response.text
assert _payload(response) == {"input_tokens": _PEER_COUNT, "litellm_count_source": "provider"}, response.text
assert _counted_bodies(wire) == ({**_PEER_BARE, "system": ""},)
@ -632,7 +636,7 @@ def test_messages_count_tokens_leaves_null_system_and_tools_out(gateway: Gateway
with wire_server(_peer()) as wire, gateway.scenario() as scenario:
model: Final = _deployment(gateway, scenario, wire.url)
response: Final = _count(gateway, {**_bare(model), "system": None, "tools": None})
assert _payload(response) == {"input_tokens": _PEER_COUNT}, response.text
assert _payload(response) == {"input_tokens": _PEER_COUNT, "litellm_count_source": "provider"}, response.text
assert _counted_bodies(wire) == (_PEER_BARE,)
@ -650,7 +654,7 @@ def test_messages_count_tokens_forwards_a_5kb_system_verbatim(gateway: Gateway)
with wire_server(_peer()) as wire, gateway.scenario() as scenario:
model: Final = _deployment(gateway, scenario, wire.url)
response: Final = _count(gateway, {**_bare(model), "system": system})
assert _payload(response) == {"input_tokens": _PEER_COUNT}, response.text
assert _payload(response) == {"input_tokens": _PEER_COUNT, "litellm_count_source": "provider"}, response.text
assert _counted_bodies(wire) == ({**_PEER_BARE, "system": system},)
@ -663,7 +667,7 @@ def test_messages_count_tokens_duplicate_system_and_tools_keys_forward_one_value
content=f'{{"model": "{model}", "messages": {json.dumps(_MESSAGES)}, {fields}, {fields}}}',
headers={"Authorization": f"Bearer {gateway.key}", "Content-Type": "application/json"},
)
assert _payload(response) == {"input_tokens": _PEER_COUNT}, response.text
assert _payload(response) == {"input_tokens": _PEER_COUNT, "litellm_count_source": "provider"}, response.text
(sent,) = _count_requests(wire.drain())
assert _count_bodies((sent,)) == (_PEER_FULL,)
assert (sent.body.count(b'"system"'), sent.body.count(b'"tools"')) == (1, 1), sent.body
@ -696,7 +700,7 @@ def test_count_tokens_location_override_targets_the_count_region(gateway: Gatewa
gateway, scenario, wire.url, vertex_location="global", vertex_count_tokens_location="europe-west1"
)
response: Final = _count(gateway, _bare(model))
assert _payload(response) == {"input_tokens": _PEER_COUNT}, response.text
assert _payload(response) == {"input_tokens": _PEER_COUNT, "litellm_count_source": "provider"}, response.text
target: Final = _COUNT_TARGET.replace(f"/locations/{_LOCATION}/", "/locations/europe-west1/")
assert _count_bodies(wire.drain(), target) == (_PEER_BARE,)
@ -705,7 +709,7 @@ def test_messages_count_tokens_repeated_request_reaches_the_peer_each_time(gatew
with wire_server(_peer()) as wire, gateway.scenario() as scenario:
model: Final = _deployment(gateway, scenario, wire.url)
answers: Final = tuple(_payload(_count(gateway, _bare(model))) for _ in range(2))
assert answers == ({"input_tokens": _PEER_COUNT},) * 2
assert answers == ({"input_tokens": _PEER_COUNT, "litellm_count_source": "provider"},) * 2
assert _counted_bodies(wire) == (_PEER_BARE,) * 2
@ -722,7 +726,7 @@ def test_disabled_token_counter_surfaces_provider_failures_instead_of_counting_l
)
with owned_proxy_process(gateway, tmp_path, {}, config=config, workers=2) as owned:
counted: Final = _count(owned.gateway, _full(_OWNED_MODEL))
assert _payload(counted) == {"input_tokens": _PEER_COUNT}, counted.text
assert _payload(counted) == {"input_tokens": _PEER_COUNT, "litellm_count_source": "provider"}, counted.text
rejected: Final = _count(
owned.gateway, {**_full(_OWNED_MODEL), "messages": [{"role": "user", "content": _REJECT_TEXT}]}
)

View file

@ -453,9 +453,11 @@ def test_in_flight_count_tokens_does_not_reserve_key_budget_away_from_a_completi
[{"role": "user", "content": prompt}]
]
assert count.status_code == 200, count.text
assert count.json() == {"totalTokens": 12, "promptTokensDetails": [{"modality": "TEXT", "tokenCount": 12}]}, (
count.text
)
assert count.json() == {
"totalTokens": 12,
"promptTokensDetails": [{"modality": "TEXT", "tokenCount": 12}],
"litellm_count_source": "provider",
}, count.text
provider_calls: Final = wire.drain()
assert [(call.method, call.target) for call in provider_calls] == [
("POST", "/v1beta/models/gemini-3.8-flash:countTokens")

View file

@ -54,6 +54,7 @@ async def test_count_reports_upstream_input_tokens_and_keeps_the_raw_payload(
"request_model": "azure_ai/claude-sonnet-4-5",
"model_used": "claude-sonnet-4-5",
"tokenizer_type": "azure_ai_anthropic_api",
"count_source": None,
"original_response": payload,
"error": False,
"error_message": None,
@ -98,6 +99,7 @@ async def test_non_integer_input_tokens_becomes_an_error_response_carrying_the_v
"request_model": "azure_ai/claude-sonnet-4-5",
"model_used": "claude-sonnet-4-5",
"tokenizer_type": "azure_ai_anthropic_api",
"count_source": None,
"original_response": None,
"error": True,
"error_message": str(rejection.value),
@ -126,6 +128,7 @@ async def test_count_payload_that_is_not_an_object_becomes_an_error_response_nam
"request_model": "azure_ai/claude-sonnet-4-5",
"model_used": "claude-sonnet-4-5",
"tokenizer_type": "azure_ai_anthropic_api",
"count_source": None,
"original_response": None,
"error": True,
"error_message": str(failed_lookup.value),
@ -156,6 +159,7 @@ async def test_upstream_error_status_becomes_an_error_response_with_that_status_
"request_model": "azure_ai/claude-sonnet-4-5",
"model_used": "claude-sonnet-4-5",
"tokenizer_type": "azure_ai_anthropic_api",
"count_source": None,
"original_response": None,
"error": True,
"error_message": upstream_text,
@ -180,6 +184,7 @@ async def test_undecodable_count_body_becomes_a_500_error_response_carrying_the_
"request_model": "azure_ai/claude-sonnet-4-5",
"model_used": "claude-sonnet-4-5",
"tokenizer_type": "azure_ai_anthropic_api",
"count_source": None,
"original_response": None,
"error": True,
"error_message": f"CountTokens processing error: {undecodable.value}",

View file

@ -0,0 +1,524 @@
import asyncio
import base64
import hashlib
import json
import logging
import re
import threading
from collections.abc import Iterable
from itertools import product
from pathlib import Path
from typing import Final
import httpx
import pytest
import respx
import tiktoken
import litellm
from litellm._logging import verbose_logger
from litellm.llms.bedrock.count_tokens.kimi_k3 import (
KimiK3Encodings,
KimiK3TokenizerSource,
kimi_k3_anthropic_tokens,
kimi_k3_tokens,
load_kimi_k3_encoding,
)
from litellm.responses.litellm_completion_transformation.transformation import LiteLLMCompletionResponsesConfig
MODEL: Final = "us.moonshotai.kimi-k3"
_CONTROL: Final = re.compile(r"<\|(?:open|close|sep|end_of_msg)\|>")
_WEATHER: Final = {
"type": "function",
"function": {
"name": "get_weather",
"description": "Get the weather in °C",
"parameters": {
"type": "object",
"properties": {"city": {"type": "string"}, "days": {"type": "integer"}},
"required": ["city"],
},
},
}
def _call(call_id: str, arguments: dict[str, object]) -> dict[str, object]:
return {"id": call_id, "type": "function", "function": {"name": "get_weather", "arguments": json.dumps(arguments)}}
def _written_ranks(path: Path, tokens: Iterable[bytes]) -> KimiK3TokenizerSource:
path.write_bytes(b"\n".join(base64.b64encode(token) + b" %d" % rank for rank, token in enumerate(tokens)))
return KimiK3TokenizerSource(ranks_path=lambda: str(path), sha256=hashlib.sha256(path.read_bytes()).hexdigest())
def _single_bytes() -> tuple[bytes, ...]:
return tuple(bytes((value,)) for value in range(256))
@pytest.fixture(scope="module")
def byte_source(tmp_path_factory: pytest.TempPathFactory) -> KimiK3TokenizerSource:
return _written_ranks(tmp_path_factory.mktemp("bytes") / "tiktoken.model", _single_bytes())
@pytest.fixture(scope="module")
def byte_encoding(byte_source: KimiK3TokenizerSource) -> tiktoken.Encoding:
loaded: Final = load_kimi_k3_encoding(byte_source)
assert loaded is not None
return loaded
@pytest.fixture(scope="module")
def pair_encoding(tmp_path_factory: pytest.TempPathFactory) -> tiktoken.Encoding:
pairs: Final = (bytes(pair) for pair in product(range(256), repeat=2))
source: Final = _written_ranks(tmp_path_factory.mktemp("pairs") / "tiktoken.model", (*_single_bytes(), *pairs))
loaded: Final = load_kimi_k3_encoding(source)
assert loaded is not None
return loaded
def _template_count(encoding: tiktoken.Encoding, rendered: str) -> int:
texts: Final = _CONTROL.split(rendered)
return len(_CONTROL.findall(rendered)) + sum(len(encoding.encode_ordinary(text)) for text in texts)
_GENERATION_PROMPT: Final = '<|open|>message role="assistant"<|sep|><|open|>think<|sep|>'
# Expected renders follow the Kimi K3 chat template in moonshotai/Kimi-K3 at revision f831ab6, which matched
# Bedrock's prompt_tokens for us.moonshotai.kimi-k3 in 19 live calls on 2026-10-16
@pytest.mark.parametrize(
("messages", "tools", "rendered"),
[
pytest.param(
[{"role": "user", "content": "Hello there"}],
None,
'<|open|>message role="user"<|sep|>Hello there<|close|>message<|sep|><|end_of_msg|>' + _GENERATION_PROMPT,
id="user",
),
pytest.param(
[
{
"role": "system",
"content": [{"type": "text", "text": "Be terse."}, {"type": "text", "text": "Answer in French."}],
},
{"role": "user", "content": [{"type": "text", "text": "Hi"}, {"type": "text", "text": " there"}]},
],
None,
'<|open|>message role="system"<|sep|>Be terse.Answer in French.<|close|>message<|sep|><|end_of_msg|>'
'<|open|>message role="user"<|sep|>Hi there<|close|>message<|sep|><|end_of_msg|>' + _GENERATION_PROMPT,
id="text_parts_join",
),
pytest.param(
[
{"role": "user", "content": "What is 2+2?"},
{"role": "assistant", "content": "4"},
{"role": "user", "content": "And 3+3?"},
],
None,
'<|open|>message role="user"<|sep|>What is 2+2?<|close|>message<|sep|><|end_of_msg|>'
'<|open|>message role="assistant"<|sep|><|open|>think<|sep|><|close|>think<|sep|>'
"<|open|>response<|sep|>4<|close|>response<|sep|><|close|>message<|sep|><|end_of_msg|>"
'<|open|>message role="user"<|sep|>And 3+3?<|close|>message<|sep|><|end_of_msg|>' + _GENERATION_PROMPT,
id="assistant_turn",
),
pytest.param(
[
{"role": "user", "content": "Weather in Paris and Oslo?"},
{
"role": "assistant",
"content": None,
"tool_calls": [_call("c1", {"city": "Paris", "days": 3}), _call("c2", {"city": "Oslo"})],
},
{"role": "tool", "tool_call_id": "c2", "content": "Snow"},
{"role": "tool", "tool_call_id": "c1", "content": "Sun"},
],
[_WEATHER],
'<|open|>message role="system" type="tool-declare"<|sep|># Tools\n'
"Here are the available tools, described in JSONSchema.\n\n```json\n"
'[{"function":{"description":"Get the weather in °C","name":"get_weather","parameters":{"properties":'
'{"city":{"type":"string"},"days":{"type":"integer"}},"required":["city"],"type":"object"}},'
'"type":"function"}]\n```<|close|>message<|sep|><|end_of_msg|>'
'<|open|>message role="user"<|sep|>Weather in Paris and Oslo?<|close|>message<|sep|><|end_of_msg|>'
'<|open|>message role="assistant"<|sep|><|open|>think<|sep|><|close|>think<|sep|>'
"<|open|>response<|sep|><|close|>response<|sep|><|open|>tools<|sep|>"
'<|open|>call tool="get_weather" index="1"<|sep|>'
'<|open|>argument key="city" type="string"<|sep|>Paris<|close|>argument<|sep|>'
'<|open|>argument key="days" type="number"<|sep|>3<|close|>argument<|sep|><|close|>call<|sep|>'
'<|open|>call tool="get_weather" index="2"<|sep|>'
'<|open|>argument key="city" type="string"<|sep|>Oslo<|close|>argument<|sep|><|close|>call<|sep|>'
"<|close|>tools<|sep|><|close|>message<|sep|><|end_of_msg|>"
'<|open|>message role="tool" tool="get_weather" index="1"<|sep|>Sun<|close|>message<|sep|><|end_of_msg|>'
'<|open|>message role="tool" tool="get_weather" index="2"<|sep|>Snow<|close|>message<|sep|><|end_of_msg|>'
+ _GENERATION_PROMPT,
id="tool_results_out_of_call_order",
),
pytest.param(
[
{"role": "user", "content": "Weather?"},
{
"role": "assistant",
"content": None,
"tool_calls": [
_call(
"c1", {"a&b": 'x"y', "flag": True, "none": None, "list": [1, "zé"], "object": {"k": True}}
)
],
},
{"role": "tool", "tool_call_id": "c1", "content": "Rain"},
],
[_WEATHER],
'<|open|>message role="system" type="tool-declare"<|sep|># Tools\n'
"Here are the available tools, described in JSONSchema.\n\n```json\n"
'[{"function":{"description":"Get the weather in °C","name":"get_weather","parameters":{"properties":'
'{"city":{"type":"string"},"days":{"type":"integer"}},"required":["city"],"type":"object"}},'
'"type":"function"}]\n```<|close|>message<|sep|><|end_of_msg|>'
'<|open|>message role="user"<|sep|>Weather?<|close|>message<|sep|><|end_of_msg|>'
'<|open|>message role="assistant"<|sep|><|open|>think<|sep|><|close|>think<|sep|>'
"<|open|>response<|sep|><|close|>response<|sep|><|open|>tools<|sep|>"
'<|open|>call tool="get_weather" index="1"<|sep|>'
'<|open|>argument key="a&amp;b" type="string"<|sep|>x"y<|close|>argument<|sep|>'
'<|open|>argument key="flag" type="boolean"<|sep|>true<|close|>argument<|sep|>'
'<|open|>argument key="none" type="null"<|sep|>null<|close|>argument<|sep|>'
'<|open|>argument key="list" type="array"<|sep|>[1, "zé"]<|close|>argument<|sep|>'
'<|open|>argument key="object" type="object"<|sep|>{"k": true}<|close|>argument<|sep|>'
"<|close|>call<|sep|><|close|>tools<|sep|><|close|>message<|sep|><|end_of_msg|>"
'<|open|>message role="tool" tool="get_weather" index="1"<|sep|>Rain<|close|>message<|sep|><|end_of_msg|>'
+ _GENERATION_PROMPT,
id="argument_types_and_escaping",
),
],
)
@pytest.mark.parametrize("encoding_fixture", ["byte_encoding", "pair_encoding"])
def test_count_is_the_kimi_k3_chat_template_render_of_the_bedrock_request(
request: pytest.FixtureRequest,
encoding_fixture: str,
messages: list[dict[str, object]],
tools: list[dict[str, object]] | None,
rendered: str,
):
encoding: Final[tiktoken.Encoding] = request.getfixturevalue(encoding_fixture)
assert kimi_k3_tokens(encoding, MODEL, messages, tools) == _template_count(encoding, rendered)
def _parallel_calls(count: int) -> list[dict[str, object]]:
ids: Final = tuple(f"c{number}" for number in range(1, count + 1))
return [
{"role": "user", "content": "Weather?"},
{"role": "assistant", "content": None, "tool_calls": [_call(call_id, {"city": "Paris"}) for call_id in ids]},
*({"role": "tool", "tool_call_id": call_id, "content": "Sun"} for call_id in ids),
]
def test_tenth_parallel_call_and_its_result_are_numbered_ten(byte_encoding: tiktoken.Encoding):
nine: Final = kimi_k3_tokens(byte_encoding, MODEL, _parallel_calls(9), [_WEATHER])
ten: Final = kimi_k3_tokens(byte_encoding, MODEL, _parallel_calls(10), [_WEATHER])
assert nine is not None and ten is not None
assert ten - nine == _template_count(
byte_encoding,
'<|open|>call tool="get_weather" index="10"<|sep|>'
'<|open|>argument key="city" type="string"<|sep|>Paris<|close|>argument<|sep|><|close|>call<|sep|>'
'<|open|>message role="tool" tool="get_weather" index="10"<|sep|>Sun<|close|>message<|sep|><|end_of_msg|>',
)
@pytest.mark.parametrize(
"text",
[
pytest.param("The quick brown fox, jumps over 12345 lazy dogs. " * 60, id="prose"),
pytest.param("a" + "1234567890" * 250 + "1", id="long_digit_run"),
pytest.param("--- " * 600, id="symbols"),
pytest.param("def f(x):\n return x # it's 42\n\n" * 80, id="code"),
pytest.param("word next\t\ttab\n \n" * 120, id="whitespace"),
pytest.param("naïve café, “quotes” & <tags> 中文汉字,日本語のテキスト 😀🎉\r\n" * 40, id="unicode"),
pytest.param("We're sure they'd say it's DON'T or I'M " * 60, id="contractions"),
pytest.param("abc123def4567ghi89 " * 120, id="letters_and_digits"),
],
)
def test_long_text_counts_what_the_tokenizer_counts_for_the_whole_text(pair_encoding: tiktoken.Encoding, text: str):
def counted(content: str) -> int | None:
return kimi_k3_tokens(pair_encoding, MODEL, [{"role": "user", "content": content}], None)
short: Final = counted("x")
assert short is not None
assert counted(text) == short - len(pair_encoding.encode_ordinary("x")) + len(pair_encoding.encode_ordinary(text))
@pytest.mark.parametrize(
("messages", "tools"),
[
pytest.param(
[
{
"role": "user",
"content": [
{"type": "text", "text": "What is this?"},
{"type": "image_url", "image_url": {"url": "data:image/png;base64,iVBORw0KGgo="}},
],
}
],
None,
id="image",
),
pytest.param(
[{"role": "user", "content": "Weather?"}, {"role": "tool", "tool_call_id": "c1", "content": "Sun"}],
[_WEATHER],
id="tool_result_without_its_call",
),
],
)
def test_request_the_template_cannot_render_is_not_counted(
pair_encoding: tiktoken.Encoding, messages: list[dict[str, object]], tools: list[dict[str, object]] | None
):
assert kimi_k3_tokens(pair_encoding, MODEL, messages, tools) is None
@pytest.mark.parametrize(
"tool",
[
pytest.param(
{
"type": "function",
"name": "get_weather",
"description": "Get the weather in °C",
"parameters": _WEATHER["function"]["parameters"],
},
id="responses_api",
),
pytest.param(
{
"name": "get_weather",
"description": "Get the weather in °C",
"input_schema": _WEATHER["function"]["parameters"],
},
id="anthropic_messages",
),
],
)
def test_tool_in_another_api_shape_counts_like_the_same_chat_completions_tool(
pair_encoding: tiktoken.Encoding, tool: dict[str, object]
):
messages: Final = [{"role": "user", "content": "Weather in Paris?"}]
counted: Final = kimi_k3_tokens(pair_encoding, MODEL, messages, [tool])
assert counted is not None
assert counted == kimi_k3_tokens(pair_encoding, MODEL, messages, [_WEATHER])
@pytest.mark.parametrize(
"tool",
[
pytest.param({"type": "web_search_20250305", "name": "web_search"}, id="anthropic_web_search"),
pytest.param({"type": "web_search"}, id="responses_web_search"),
pytest.param(
{
"type": "mcp",
"server_label": "docs",
"server_url": "https://mcp.example/mcp",
"require_approval": "never",
},
id="mcp",
),
],
)
def test_tool_bedrock_inference_drops_counts_like_the_function_tools_alone(
pair_encoding: tiktoken.Encoding, tool: dict[str, object]
):
messages: Final = [{"role": "user", "content": "Weather in Paris?"}]
counted: Final = kimi_k3_tokens(pair_encoding, MODEL, messages, [_WEATHER, tool])
assert counted is not None
assert counted == kimi_k3_tokens(pair_encoding, MODEL, messages, [_WEATHER])
def test_responses_custom_tool_counts_like_the_function_tool_inference_sends(pair_encoding: tiktoken.Encoding):
messages: Final = [{"role": "user", "content": "Weather in Paris?"}]
custom_tool: Final = {"type": "custom", "name": "grammar", "description": "Free-form SQL"}
sent_to_inference, _ = LiteLLMCompletionResponsesConfig.transform_responses_api_tools_to_chat_completion_tools(
[dict(custom_tool)]
)
without_it: Final = kimi_k3_tokens(pair_encoding, MODEL, messages, [_WEATHER])
counted: Final = kimi_k3_tokens(pair_encoding, MODEL, messages, [_WEATHER, custom_tool])
assert counted is not None and without_it is not None
assert counted > without_it
assert counted == kimi_k3_tokens(pair_encoding, MODEL, messages, [_WEATHER, *sent_to_inference])
def test_counting_a_request_with_an_image_url_never_downloads_the_image(
pair_encoding: tiktoken.Encoding, respx_mock: respx.MockRouter, monkeypatch: pytest.MonkeyPatch
):
monkeypatch.setattr(litellm, "user_url_validation", False)
image: Final = respx_mock.get("https://images.example/cat.png").mock(return_value=httpx.Response(200))
messages: Final = [
{
"role": "user",
"content": [
{"type": "text", "text": "What is this?"},
{"type": "image_url", "image_url": {"url": "https://images.example/cat.png"}},
],
}
]
assert kimi_k3_tokens(pair_encoding, MODEL, messages, None) is None
assert not image.called
def test_anthropic_request_counts_like_the_same_openai_request(pair_encoding: tiktoken.Encoding):
anthropic: Final = kimi_k3_anthropic_tokens(
pair_encoding,
MODEL,
{
"system": "Be terse.",
"messages": [
{"role": "user", "content": "Weather in Paris?"},
{
"role": "assistant",
"content": [{"type": "tool_use", "id": "c1", "name": "get_weather", "input": {"city": "Paris"}}],
},
{"role": "user", "content": [{"type": "tool_result", "tool_use_id": "c1", "content": "Sun"}]},
],
"tools": [
{
"name": "get_weather",
"description": "Get the weather in °C",
"input_schema": _WEATHER["function"]["parameters"],
}
],
},
)
openai: Final = kimi_k3_tokens(
pair_encoding,
MODEL,
[
{"role": "system", "content": "Be terse."},
{"role": "user", "content": "Weather in Paris?"},
{"role": "assistant", "content": None, "tool_calls": [_call("c1", {"city": "Paris"})]},
{"role": "tool", "tool_call_id": "c1", "content": "Sun"},
],
[_WEATHER],
)
assert openai is not None
assert anthropic == openai
@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
):
source: Final = (
KimiK3TokenizerSource(ranks_path=byte_source.ranks_path, sha256=hashlib.sha256(b"other").hexdigest())
if failure == "checksum"
else KimiK3TokenizerSource(ranks_path=lambda: str(tmp_path / "missing"), sha256=byte_source.sha256)
)
verbose_logger.addHandler(caplog.handler)
try:
with caplog.at_level(logging.WARNING, logger=verbose_logger.name):
loaded: Final = load_kimi_k3_encoding(source)
finally:
verbose_logger.removeHandler(caplog.handler)
assert loaded is None
assert any("Kimi K3 tokenizer unavailable" in record.getMessage() for record in caplog.records)
@pytest.mark.asyncio
async def test_slow_tokenizer_load_is_not_waited_on_past_the_wait_and_is_shared_once_done(
byte_source: KimiK3TokenizerSource,
):
released: Final = threading.Event()
def slow_ranks_path() -> str:
released.wait(10)
return byte_source.ranks_path()
encodings: Final = KimiK3Encodings(
source=KimiK3TokenizerSource(ranks_path=slow_ranks_path, sha256=byte_source.sha256),
retry_after_seconds=60,
clock=lambda: 0.0,
)
assert await encodings.encoding(wait_seconds=0) is None
released.set()
loaded: Final = await encodings.encoding(wait_seconds=5)
assert loaded is not None
assert await encodings.encoding(wait_seconds=0) is loaded
class _Clock:
def __init__(self) -> None:
self.now = 0.0
def __call__(self) -> float:
return self.now
async def _loaded_eventually(encodings: KimiK3Encodings) -> tiktoken.Encoding | None:
for _ in range(500):
if (loaded := await encodings.encoding(wait_seconds=0)) is not None:
return loaded
await asyncio.sleep(0.01)
return None
@pytest.mark.asyncio
async def test_failed_tokenizer_load_is_retried_in_the_background_only_after_the_retry_window(
byte_source: KimiK3TokenizerSource, tmp_path: Path
):
clock: Final = _Clock()
reads: Final[list[float]] = []
released: Final = threading.Event()
def ranks_path() -> str:
reads.append(clock.now)
if len(reads) == 1:
return str(tmp_path / "missing")
released.wait(10)
return byte_source.ranks_path()
encodings: Final = KimiK3Encodings(
source=KimiK3TokenizerSource(ranks_path=ranks_path, sha256=byte_source.sha256),
retry_after_seconds=60,
clock=clock,
)
assert await encodings.encoding(wait_seconds=5) is None
clock.now = 59.0
assert await encodings.encoding(wait_seconds=5) is None
clock.now = 60.0
assert await asyncio.wait_for(encodings.encoding(wait_seconds=5), timeout=1) is None
released.set()
assert await _loaded_eventually(encodings) is not None
assert reads == [0.0, 60.0]
@pytest.mark.asyncio
async def test_tokenizer_load_still_running_after_the_wait_is_logged(
byte_source: KimiK3TokenizerSource, caplog: pytest.LogCaptureFixture
):
released: Final = threading.Event()
def hung_ranks_path() -> str:
released.wait(10)
return byte_source.ranks_path()
encodings: Final = KimiK3Encodings(
source=KimiK3TokenizerSource(ranks_path=hung_ranks_path, sha256=byte_source.sha256), retry_after_seconds=60
)
verbose_logger.addHandler(caplog.handler)
try:
with caplog.at_level(logging.WARNING, logger=verbose_logger.name):
loaded: Final = await encodings.encoding(wait_seconds=0.05)
finally:
verbose_logger.removeHandler(caplog.handler)
released.set()
assert loaded is None
assert any("Kimi K3 tokenizer still loading" in record.getMessage() for record in caplog.records)

View file

@ -54,6 +54,7 @@ async def test_count_reports_upstream_input_tokens_and_keeps_the_raw_payload(
"request_model": "openai/gpt-4o",
"model_used": "gpt-4o",
"tokenizer_type": "openai_api",
"count_source": None,
"original_response": payload,
"error": False,
"error_message": None,
@ -98,6 +99,7 @@ async def test_non_integer_input_tokens_becomes_an_error_response_carrying_the_v
"request_model": "openai/gpt-4o",
"model_used": "gpt-4o",
"tokenizer_type": "openai_api",
"count_source": None,
"original_response": None,
"error": True,
"error_message": str(rejection.value),
@ -126,6 +128,7 @@ async def test_count_payload_that_is_not_an_object_becomes_an_error_response_nam
"request_model": "openai/gpt-4o",
"model_used": "gpt-4o",
"tokenizer_type": "openai_api",
"count_source": None,
"original_response": None,
"error": True,
"error_message": str(failed_lookup.value),
@ -156,6 +159,7 @@ async def test_upstream_error_status_becomes_an_error_response_with_that_status_
"request_model": "openai/gpt-4o",
"model_used": "gpt-4o",
"tokenizer_type": "openai_api",
"count_source": None,
"original_response": None,
"error": True,
"error_message": upstream_text,
@ -382,6 +386,7 @@ async def test_undecodable_count_body_becomes_a_500_error_response_carrying_the_
"request_model": "openai/gpt-4o",
"model_used": "gpt-4o",
"tokenizer_type": "openai_api",
"count_source": None,
"original_response": None,
"error": True,
"error_message": f"CountTokens processing error: {undecodable.value}",

View file

@ -0,0 +1,209 @@
import json
from pathlib import Path
from typing import Final
import pytest
from litellm.proxy.common_utils.token_count_settings import (
ProviderCountFailure,
TokenCountModel,
TokenCountSettings,
count_source_fields,
resolve_token_count_settings,
)
from litellm.types.utils import TokenCountResponse
_OPUS_US_EAST_1: Final = TokenCountModel(model="anthropic.claude-opus-5-5", region="us-east-1")
_DEFAULT: Final = {
"token_count_framing": 97,
"token_count_model": {"model": "anthropic.claude-opus-5-5", "region": "us-east-1"},
}
_COST_MAP: Final = Path(__file__).parents[4] / "model_prices_and_context_window.json"
def _in_us_west_2() -> str:
return "us-west-2"
@pytest.mark.parametrize(
("configured", "expected"),
[
({}, TokenCountSettings(framing=97, count_model=_OPUS_US_EAST_1)),
(
{"token_count_framing": 5, "token_count_model": {"model": "custom-model", "region": "ap-southeast-4"}},
TokenCountSettings(framing=5, count_model=TokenCountModel(model="custom-model", region="ap-southeast-4")),
),
(
{"token_count_model": {"model": "custom-model"}},
TokenCountSettings(framing=97, count_model=TokenCountModel(model="custom-model", region=None)),
),
(
{"token_count_model": {"model": "custom-model", "region": " "}},
TokenCountSettings(framing=97, count_model=TokenCountModel(model="custom-model", region=None)),
),
({"token_count_framing": 0}, TokenCountSettings(framing=0, count_model=_OPUS_US_EAST_1)),
(
{"token_count_framing": None, "token_count_model": None},
TokenCountSettings(framing=97, count_model=_OPUS_US_EAST_1),
),
(
{"token_count_framing": True, "token_count_model": "custom-model"},
TokenCountSettings(framing=97, count_model=_OPUS_US_EAST_1),
),
({"token_count_framing": 78.0}, TokenCountSettings(framing=78, count_model=_OPUS_US_EAST_1)),
({"token_count_framing": 1.5}, TokenCountSettings(framing=97, count_model=_OPUS_US_EAST_1)),
({"token_count_framing": -1}, TokenCountSettings(framing=97, count_model=_OPUS_US_EAST_1)),
({"token_count_framing": "78"}, TokenCountSettings(framing=97, count_model=_OPUS_US_EAST_1)),
({"token_count_model": {}}, TokenCountSettings(framing=97, count_model=None)),
({"token_count_model": ""}, TokenCountSettings(framing=97, count_model=_OPUS_US_EAST_1)),
(
{"token_count_model": {"model": " ", "region": "us-east-1"}},
TokenCountSettings(framing=97, count_model=None),
),
],
)
def test_config_overrides_the_cost_map_default_key_by_key(
configured: dict[str, object], expected: TokenCountSettings
) -> None:
assert (
resolve_token_count_settings(configured=configured, default=_DEFAULT, deployment_region=_in_us_west_2)
== expected
)
@pytest.mark.parametrize(
("deployment_region", "configured", "expected_region"),
[
("us-west-2", {}, "us-east-1"),
("eu-central-1", {}, None),
("ca-central-1", {}, None),
("us-gov-west-1", {}, None),
(
"eu-central-1",
{"token_count_model": {"model": "anthropic.claude-opus-5-5", "region": "us-east-1"}},
"us-east-1",
),
],
)
def test_shipped_count_region_never_takes_the_prompt_out_of_the_deployment_geography(
deployment_region: str, configured: dict[str, object], expected_region: str | None
) -> None:
settings: Final = resolve_token_count_settings(
configured=configured, default=_DEFAULT, deployment_region=lambda: deployment_region
)
assert settings.count_model == TokenCountModel(model="anthropic.claude-opus-5-5", region=expected_region)
def test_shipped_global_count_model_keeps_its_region_for_a_deployment_in_any_geography() -> None:
settings: Final = resolve_token_count_settings(
configured={},
default={"token_count_model": {"model": "global.anthropic.claude-opus-5-5", "region": "us-east-1"}},
deployment_region=lambda: "eu-west-1",
)
assert settings.count_model == TokenCountModel(model="global.anthropic.claude-opus-5-5", region="us-east-1")
def test_shipped_australian_count_region_stays_with_an_australian_deployment() -> None:
settings: Final = resolve_token_count_settings(
configured={},
default={"token_count_model": {"model": "au.anthropic.claude-opus-5-5", "region": "ap-southeast-4"}},
deployment_region=lambda: "ap-southeast-2",
)
assert settings.count_model == TokenCountModel(model="au.anthropic.claude-opus-5-5", region="ap-southeast-4")
def test_no_setting_anywhere_means_the_model_counts_itself_without_framing() -> None:
assert resolve_token_count_settings(
configured={}, default={}, deployment_region=_in_us_west_2
) == TokenCountSettings(framing=None, count_model=None)
@pytest.mark.parametrize(
("value", "expected"),
[("kimi_k3", "kimi_k3"), ("cl100k_base", None), (None, None), (True, None)],
)
def test_only_a_tokenizer_litellm_ships_is_taken_from_the_cost_map(value: object, expected: str | None) -> None:
settings: Final = resolve_token_count_settings(
configured={}, default={"token_count_tokenizer": value}, deployment_region=_in_us_west_2
)
assert settings.tokenizer == expected
def _counted(source: str | None, model_used: str = "us.anthropic.claude-opus-5-5") -> TokenCountResponse:
return TokenCountResponse(
total_tokens=7,
request_model="sonnet",
model_used=model_used,
tokenizer_type="bedrock_api",
count_source=source,
)
@pytest.mark.parametrize(
("source", "expected"),
[
(None, {}),
("provider", {"litellm_count_source": "provider"}),
("local_estimate", {"litellm_count_source": "local_estimate"}),
(
"token_count_model",
{"litellm_count_source": "token_count_model", "litellm_count_model": "us.anthropic.claude-opus-5-5"},
),
],
)
def test_count_source_fields_name_the_counting_model_only_when_token_count_model_counted(
source: str | None, expected: dict[str, str]
) -> None:
assert count_source_fields(_counted(source)) == expected
def test_every_shipped_token_count_model_is_another_mapped_model_of_the_same_provider_in_a_named_region() -> None:
cost_map: Final = json.loads(_COST_MAP.read_text())
count_models: Final = {
name: entry["token_count_model"]
for name, entry in cost_map.items()
if isinstance(entry, dict) and "token_count_model" in entry
}
assert count_models
assert {
name: count_model
for name, count_model in count_models.items()
if cost_map.get(count_model["model"], {}).get("litellm_provider") != cost_map[name]["litellm_provider"]
or count_model["model"] == name
or not count_model.get("region")
} == {}
_UPSTREAM_404: Final = {
"type": "error",
"request_id": "req_upstream",
"error": {"type": "not_found_error", "message": "The model 'anthropic.claude-opus-5-5' does not exist"},
}
@pytest.mark.parametrize(
("message", "expected"),
[
(
json.dumps(_UPSTREAM_404),
{
**_UPSTREAM_404,
"error": {
"type": "not_found_error",
"message": "The model 'anthropic.claude-opus-5-5' does not exist. Enable eu-west-1",
},
},
),
(json.dumps({"message": "Too many tokens"}), "Too many tokens. Enable eu-west-1"),
("scripted 404", "scripted 404. Enable eu-west-1"),
(
"Credential should be scoped to a valid region. ",
"Credential should be scoped to a valid region. Enable eu-west-1",
),
],
)
def test_hint_lands_in_the_upstream_error_message_and_keeps_its_envelope(message: str, expected: object) -> None:
hinted: Final = ProviderCountFailure(status_code=404, message=message).with_hint("Enable eu-west-1")
assert (json.loads(hinted.message) if isinstance(expected, dict) else hinted.message) == expected
assert hinted.status_code == 404

View file

@ -4,10 +4,11 @@ Test to verify the Google GenAI proxy API endpoints
"""
from typing import Final
from unittest.mock import AsyncMock, MagicMock, patch
from unittest.mock import AsyncMock, patch
import pytest
from litellm.types.utils import TokenCountResponse
def _build_test_client():
@ -88,9 +89,7 @@ def test_google_stream_generate_content_endpoint():
assert init_kwargs["data"]["_litellm_raw_sse_stream"] is True
assert init_kwargs["data"]["_litellm_skip_openai_stream_done"] is True
assert init_kwargs["data"]["model"] == "test-model"
assert init_kwargs["data"]["contents"] == [
{"role": "user", "parts": [{"text": "Hello"}]}
]
assert init_kwargs["data"]["contents"] == [{"role": "user", "parts": [{"text": "Hello"}]}]
def test_google_generate_content_data_flows_through_processor():
@ -123,9 +122,7 @@ def test_google_generate_content_data_flows_through_processor():
data = mock_init.call_args.kwargs["data"]
assert data["model"] == "test-model"
assert data["contents"] == [{"role": "user", "parts": [{"text": "Hello"}]}]
assert data["systemInstruction"] == {
"parts": [{"text": "Your name is Doodle."}]
}
assert data["systemInstruction"] == {"parts": [{"text": "Your name is Doodle."}]}
# generationConfig arrives intact here; the rename to `config` is
# done downstream in route_request (see test_route_llm_request).
assert data["generationConfig"]["responseModalities"] == ["TEXT", "IMAGE"]
@ -160,12 +157,17 @@ def test_google_count_tokens_unchanged():
except ImportError as e:
pytest.skip(f"Skipping test due to missing dependency: {e}")
fake_response = MagicMock()
fake_response.original_response = {
"totalTokens": 7,
"promptTokensDetails": [{"modality": "TEXT", "tokenCount": 7}],
}
fake_response.total_tokens = 7
fake_response = TokenCountResponse(
total_tokens=7,
request_model="test-model",
model_used="test-model",
tokenizer_type="gemini_api",
original_response={
"totalTokens": 7,
"promptTokensDetails": [{"modality": "TEXT", "tokenCount": 7}],
},
count_source="provider",
)
with patch(
"litellm.proxy.proxy_server.token_counter",
@ -180,6 +182,7 @@ def test_google_count_tokens_unchanged():
assert response.status_code == 200
body = response.json()
assert body["totalTokens"] == 7
assert body["litellm_count_source"] == "provider"
def test_google_count_tokens_unexpected_failure_uses_the_google_error_envelope():
@ -212,9 +215,14 @@ def test_google_count_tokens_uses_normalized_total_when_provider_shape_differs()
except ImportError as e:
pytest.skip(f"Skipping test due to missing dependency: {e}")
fake_response: Final = MagicMock()
fake_response.original_response = {"input_tokens": 2167}
fake_response.total_tokens = 2167
fake_response: Final = TokenCountResponse(
total_tokens=2167,
request_model="claude-opus-4-8",
model_used="us.anthropic.claude-opus-5-5",
tokenizer_type="bedrock_api",
original_response={"input_tokens": 2167},
count_source="token_count_model",
)
with patch(
"litellm.proxy.proxy_server.token_counter",
@ -230,3 +238,5 @@ def test_google_count_tokens_uses_normalized_total_when_provider_shape_differs()
body: Final = response.json()
assert body["totalTokens"] == 2167
assert body["promptTokensDetails"] == []
assert body["litellm_count_source"] == "token_count_model"
assert body["litellm_count_model"] == "us.anthropic.claude-opus-5-5"

View file

@ -4184,6 +4184,39 @@ class TestModelInfoCostMapEchoFilter:
assert "litellm_provider" not in info
assert "supported_openai_params" not in info
@pytest.mark.parametrize(
("model", "field", "edited"),
[
("bedrock/us.anthropic.claude-sonnet-5-5", "token_count_model", {}),
("bedrock/us.moonshotai.kimi-k3", "token_count_framing", 0),
],
)
def test_token_count_setting_is_stored_only_when_the_operator_changed_it(
self, model: str, field: str, edited: object
) -> None:
import litellm
from litellm.proxy.management_endpoints.model_management_endpoints import update_db_model
entry: Final = litellm.get_model_info(model)
assert entry[field] not in (None, edited), entry
db_model: Final = Deployment(
model_name="count-settings",
litellm_params=LiteLLM_Params(model=model),
model_info=ModelInfo(id="dep-echo-count"),
)
echoed: Final = update_db_model(
db_model=db_model,
updated_patch=updateDeployment(model_info=ModelInfo(**{**entry, "id": "dep-echo-count"})),
)
changed: Final = update_db_model(
db_model=db_model,
updated_patch=updateDeployment(model_info=ModelInfo(**{**entry, "id": "dep-echo-count", field: edited})),
)
assert field not in json.loads(echoed["model_info"])
assert json.loads(changed["model_info"])[field] == edited
def test_metadata_without_a_cost_map_key_is_persisted(self):
import litellm

View file

@ -54,6 +54,7 @@ def test_token_counter_happy_path(client, auth_as, patched_token_counter):
"request_model": "gpt-4",
"model_used": "gpt-4",
"tokenizer_type": "openai_tokenizer",
"count_source": "local_estimate",
"original_response": None,
"error": False,
"error_message": None,

File diff suppressed because it is too large Load diff

View file

@ -584,6 +584,40 @@ def test_capability_flag_stays_on_the_deployment_that_set_it(flag: str) -> None:
_restore_model_cost_entries(model_keys)
def test_token_count_settings_stay_on_the_deployment_that_set_them() -> None:
"""A deployment's ``token_count_model`` and ``token_count_framing`` must not land on
the shared ``{provider}/{model}`` key, or a sibling deployment of that model would count with them."""
from litellm.proxy.common_utils.token_count_settings import cost_map_entry
backend_model: Final = "bedrock/us.anthropic.claude-sonnet-5-5"
deploy_id: Final = "sonnet-deploy-keep-token-count"
configured: Final = {
"token_count_model": {"model": "anthropic.claude-opus-5-5", "region": "us-west-2"},
"token_count_framing": 7,
}
builtin: Final = {field: cost_map_entry(backend_model).get(field) for field in configured}
assert all(builtin[field] != value for field, value in configured.items()), builtin
model_keys: Final = {
key: copy.deepcopy(litellm.model_cost.get(key))
for key in (deploy_id, backend_model, backend_model.removeprefix("bedrock/"))
}
try:
Router(
model_list=[
{
"model_name": "sonnet-keep-token-count",
"litellm_params": {"model": backend_model},
"model_info": {"id": deploy_id, **configured},
}
],
)
assert {field: litellm.model_cost[deploy_id][field] for field in configured} == configured
assert {field: cost_map_entry(backend_model).get(field) for field in configured} == builtin
finally:
_restore_model_cost_entries(model_keys)
def test_should_store_full_pricing_under_deployment_model_id():
"""
Per-deployment pricing (including zero) should be stored and
@ -3884,9 +3918,7 @@ def test_router_capacity_widens_to_a_larger_base_model_window(
)
_invalidate_model_cost_lowercase_map()
router: Final = _capacity_test_router(route, base, base_location, explicit_limit)
expected: Final = (
explicit_limit if explicit_limit is not None else (1024 if base_location is None else 8192)
)
expected: Final = explicit_limit if explicit_limit is not None else (1024 if base_location is None else 8192)
assert _effective_max_input_tokens(router) == expected
assert _deployments_allowed_for_2048_input_tokens(router) == (1 if expected >= 2048 else 0)
_invalidate_model_cost_lowercase_map()

View file

@ -1138,6 +1138,13 @@ def test_aaamodel_prices_and_context_window_json_is_valid():
"supports_box_questions": {"type": "boolean"},
"supports_vision": {"type": "boolean"},
"supports_web_search": {"type": "boolean"},
"token_count_framing": {"type": "integer"},
"token_count_model": {
"type": "object",
"properties": {"model": {"type": "string"}, "region": {"type": "string"}},
"required": ["model"],
},
"token_count_tokenizer": {"type": "string", "enum": ["kimi_k3"]},
"supports_bedrock_runtime_chat_completions_tools_with_reasoning": {"type": "boolean"},
"supports_bedrock_runtime_chat_completions_response_format": {"type": "boolean"},
"supports_bedrock_runtime_chat_completions_inline_reasoning": {"type": "boolean"},

View file

@ -46847,9 +46847,17 @@ export interface components {
* @description Response structure for token count details with modality breakdown.
*
* Example:
* {'totalTokens': 12, 'promptTokensDetails': [{'modality': 'TEXT', 'tokenCount': 12}]}
* {'totalTokens': 12, 'promptTokensDetails': [{'modality': 'TEXT', 'tokenCount': 12}],
* 'litellm_count_source': 'provider'}
*/
TokenCountDetailsResponse: {
/** Litellm Count Model */
litellm_count_model?: string;
/**
* Litellm Count Source
* @enum {string}
*/
litellm_count_source?: "provider" | "token_count_model" | "local_estimate";
/** Prompttokensdetails */
promptTokensDetails: components["schemas"]["PromptTokensDetails"][];
/** Totaltokens */
@ -46878,6 +46886,8 @@ export interface components {
};
/** TokenCountResponse */
TokenCountResponse: {
/** Count Source */
count_source?: ("provider" | "token_count_model" | "local_estimate") | null;
/**
* Error
* @default false