litellm/tests/unit/proxy/proxy_server/test_routes_utils.py
Mateo Wang 75faaeab64
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
2026-10-17 17:11:27 -07:00

417 lines
16 KiB
Python

"""Behavior pins for ``proxy_server.py`` llm-utils routes.
Pins (PR2):
- POST /utils/token_counter
- GET /utils/supported_openai_params
- GET /utils/model_info
- POST /utils/transform_request
"""
from __future__ import annotations
import asyncio
import json
import httpx
import pytest
import respx
import litellm
from litellm.litellm_core_utils import get_llm_provider_logic
from litellm.proxy import proxy_server
from .conftest import normalize # type: ignore[import-not-found]
# ---------------------------------------------------------------------------
# POST /utils/token_counter
# ---------------------------------------------------------------------------
@pytest.fixture
def patched_token_counter(monkeypatch):
monkeypatch.setattr(proxy_server, "llm_router", None)
monkeypatch.setattr(litellm, "disable_token_counter", False, raising=False)
monkeypatch.setattr(
litellm.utils,
"select_tokenizer",
lambda model, custom_tokenizer=None: {
"type": "openai_tokenizer",
"tokenizer": None,
},
)
monkeypatch.setattr(litellm, "token_counter", lambda **kwargs: 7)
yield
def test_token_counter_happy_path(client, auth_as, patched_token_counter):
"""Pins ``POST /utils/token_counter``."""
payload = {"model": "gpt-4", "prompt": "Hi there"}
with auth_as():
response = client.post("/utils/token_counter", json=payload)
assert response.status_code == 200
assert normalize(response.json()) == {
"total_tokens": 7,
"request_model": "gpt-4",
"model_used": "gpt-4",
"tokenizer_type": "openai_tokenizer",
"count_source": "local_estimate",
"original_response": None,
"error": False,
"error_message": None,
"status_code": None,
}
def test_token_counter_counts_off_the_event_loop(client, auth_as, patched_token_counter, monkeypatch):
"""
A large prompt must not stall the proxy: the count runs in a worker thread, where there
is no running event loop, rather than on the loop serving other requests.
"""
counted_off_loop = []
def recording_counter(**kwargs):
try:
asyncio.get_running_loop()
counted_off_loop.append(False)
except RuntimeError:
counted_off_loop.append(True)
return 7
monkeypatch.setattr(litellm, "token_counter", recording_counter)
with auth_as():
response = client.post("/utils/token_counter", json={"model": "gpt-4", "prompt": "Hi there"})
assert response.status_code == 200
assert response.json()["total_tokens"] == 7
assert counted_off_loop == [True]
def test_token_counter_missing_input_returns_400(
client, auth_as, patched_token_counter
):
"""Pins ``POST /utils/token_counter`` (error: missing input)."""
with auth_as():
response = client.post("/utils/token_counter", json={"model": "gpt-4"})
assert response.status_code == 400
assert "prompt or messages or contents" in response.text
# ---------------------------------------------------------------------------
# GET /utils/supported_openai_params
# ---------------------------------------------------------------------------
@pytest.fixture
def patched_supported_params(monkeypatch):
monkeypatch.setattr(proxy_server, "llm_router", None)
monkeypatch.setattr(
litellm,
"get_llm_provider",
lambda model: (model, "openai", None, None),
)
monkeypatch.setattr(
litellm,
"get_supported_openai_params",
lambda model, custom_llm_provider=None: ["max_tokens", "temperature", "top_p"],
)
yield
def test_supported_openai_params_happy_path(client, auth_as, patched_supported_params):
"""Pins ``GET /utils/supported_openai_params``."""
with auth_as():
response = client.get(
"/utils/supported_openai_params", params={"model": "gpt-4"}
)
assert response.status_code == 200
assert normalize(response.json()) == {
"supported_openai_params": ["max_tokens", "temperature", "top_p"],
}
def test_supported_openai_params_resolves_router_alias(client, auth_as, monkeypatch):
"""A router alias absent from the cost map resolves through the deployment's underlying model."""
router = litellm.Router(
model_list=[
{
"model_name": "claude-opus-4-6-cached",
"litellm_params": {"model": "anthropic/claude-opus-4-6", "api_key": "sk-test"},
}
]
)
monkeypatch.setattr(proxy_server, "llm_router", router)
with auth_as():
response = client.get("/utils/supported_openai_params", params={"model": "claude-opus-4-6-cached"})
assert response.status_code == 200
expected = litellm.get_supported_openai_params(model="claude-opus-4-6", custom_llm_provider="anthropic")
assert response.json() == {"supported_openai_params": expected}
assert "max_tokens" in response.json()["supported_openai_params"]
def test_supported_openai_params_declared_prefix_alias_resolves_through_router(client, auth_as, monkeypatch):
"""Regression: an alias whose name starts with an authenticating provider's prefix skipped
router resolution and answered with that provider's params instead of the deployment's."""
router = litellm.Router(
model_list=[
{
"model_name": "github_copilot/gpt-4o",
"litellm_params": {"model": "anthropic/claude-opus-4-6", "api_key": "sk-test"},
}
]
)
monkeypatch.setattr(proxy_server, "llm_router", router)
with auth_as():
response = client.get("/utils/supported_openai_params", params={"model": "github_copilot/gpt-4o"})
assert response.status_code == 200
expected = litellm.get_supported_openai_params(model="claude-opus-4-6", custom_llm_provider="anthropic")
assert response.json() == {"supported_openai_params": expected}
def test_supported_openai_params_never_runs_oauth_for_authenticating_providers(client, auth_as, monkeypatch, tmp_path):
"""Regression: github_copilot/chatgpt names answer from their declaration; resolving them
through ``get_llm_provider`` would run the provider's OAuth device flow and block the event loop."""
monkeypatch.setenv("GITHUB_COPILOT_TOKEN_DIR", str(tmp_path))
(tmp_path / "access-token").write_text("fake-access-token")
(tmp_path / "api-key.json").write_text(
json.dumps(
{
"token": "fake-api-key",
"expires_at": 4102444800,
"endpoints": {"api": "https://api.githubcopilot.com"},
}
)
)
router = litellm.Router(
model_list=[
{
"model_name": "copilot-alias",
"litellm_params": {"model": "github_copilot/gpt-4o"},
},
{
"model_name": "openai/*",
"litellm_params": {"model": "openai/*"},
},
]
)
monkeypatch.setattr(proxy_server, "llm_router", router)
resolution_attempts: list[str] = []
def _oauth_tripwire(model, *args, **kwargs):
resolution_attempts.append(model)
raise AssertionError("get_llm_provider would run the OAuth device flow")
monkeypatch.setattr(litellm, "get_llm_provider", _oauth_tripwire)
monkeypatch.setattr(get_llm_provider_logic, "get_llm_provider", _oauth_tripwire)
expected = litellm.get_supported_openai_params(model="gpt-4o", custom_llm_provider="github_copilot")
with auth_as():
via_alias = client.get("/utils/supported_openai_params", params={"model": "copilot-alias"})
via_direct_name = client.get("/utils/supported_openai_params", params={"model": "github_copilot/gpt-4o"})
assert via_alias.status_code == 200
assert via_alias.json() == {"supported_openai_params": expected}
assert via_direct_name.status_code == 200
assert via_direct_name.json() == {"supported_openai_params": expected}
assert resolution_attempts == []
def test_supported_openai_params_invalid_model(client, auth_as, monkeypatch):
"""Pins ``GET /utils/supported_openai_params`` (error: unknown model)."""
def _raise(model):
raise Exception("unknown")
monkeypatch.setattr(proxy_server, "llm_router", None)
monkeypatch.setattr(litellm, "get_llm_provider", _raise)
with auth_as():
response = client.get("/utils/supported_openai_params", params={"model": "??"})
assert response.status_code == 400
assert "Could not map model" in response.text
# ---------------------------------------------------------------------------
# GET /utils/model_info
# ---------------------------------------------------------------------------
@pytest.fixture
def lookup_fixture_model(monkeypatch):
entry = {
"litellm_provider": "openai",
"mode": "chat",
"max_input_tokens": 1234,
"max_output_tokens": 56,
"input_cost_per_token": 1e-6,
"output_cost_per_token": 2e-6,
"supports_vision": True,
"deprecation_date": "2099-01-01",
"supports_lookup_fixture_edit": True,
}
monkeypatch.setattr(proxy_server, "llm_router", None)
monkeypatch.setitem(litellm.model_cost, "lookup-fixture-model", entry)
litellm.get_model_info.cache_clear()
litellm.utils.cached_get_model_info_helper.cache_clear()
yield entry
litellm.get_model_info.cache_clear()
litellm.utils.cached_get_model_info_helper.cache_clear()
def test_model_info_lookup_returns_full_cost_map_entry_for_unregistered_model(client, auth_as, lookup_fixture_model):
"""Every raw cost map field comes back, including ones outside ``ModelInfoBase`` that ``get_model_info`` drops."""
with auth_as():
response = client.get(
"/utils/model_info", params={"model": "lookup-fixture-model", "custom_llm_provider": "openai"}
)
assert response.status_code == 200, response.text
body = response.json()
assert body["model"] == "lookup-fixture-model"
assert body["custom_llm_provider"] == "openai"
assert body["model_info"]["key"] == "lookup-fixture-model"
assert isinstance(body["model_info"]["supported_openai_params"], list)
assert {k: body["model_info"][k] for k in lookup_fixture_model} == lookup_fixture_model
def test_model_info_lookup_unknown_model_returns_404(client, auth_as, monkeypatch):
monkeypatch.setattr(proxy_server, "llm_router", None)
with auth_as():
response = client.get("/utils/model_info", params={"model": "no-such-model-lit-7476"})
assert response.status_code == 404, response.text
assert "is not in the model cost map" in response.text
@respx.mock
def test_model_info_lookup_returns_404_when_typed_info_has_no_cost_map_entry(
client, auth_as, monkeypatch, local_model_cost_map
):
"""``get_model_info`` synthesizes info for huggingface fallbacks absent from ``model_cost``;
with no raw entry the route must 404 rather than answer 200 with typed fields only."""
monkeypatch.setattr(proxy_server, "llm_router", None)
respx.get("https://huggingface.co/not-in-map-org/not-in-map-model/raw/main/config.json").mock(
return_value=httpx.Response(404)
)
with auth_as():
response = client.get("/utils/model_info", params={"model": "huggingface/not-in-map-org/not-in-map-model"})
assert response.status_code == 404, response.text
assert "is not in the model cost map" in response.text
# ---------------------------------------------------------------------------
# POST /utils/transform_request
# ---------------------------------------------------------------------------
@pytest.fixture
def patched_transform(monkeypatch):
monkeypatch.setattr(proxy_server, "llm_router", None)
monkeypatch.setattr(proxy_server, "is_request_body_safe", lambda **kwargs: True)
def _fake_return_raw_request(endpoint, kwargs):
return {
"raw_request_api_base": "https://api.openai.com/v1/chat/completions",
"raw_request_body": kwargs,
"raw_request_headers": {"Authorization": "Bearer redacted"},
}
monkeypatch.setattr("litellm.utils.return_raw_request", _fake_return_raw_request)
yield
def test_transform_request_happy_path(client, auth_as, patched_transform):
"""Pins ``POST /utils/transform_request``."""
payload = {"call_type": "completion", "request_body": {"model": "gpt-4"}}
with auth_as():
response = client.post("/utils/transform_request", json=payload)
assert response.status_code == 200
assert normalize(response.json()) == {
"raw_request_api_base": "https://api.openai.com/v1/chat/completions",
"raw_request_body": {"model": "gpt-4"},
"raw_request_headers": {"Authorization": "Bearer redacted"},
}
def test_transform_request_unsafe_body(client, auth_as, monkeypatch):
"""Pins ``POST /utils/transform_request`` (error: unsafe body)."""
monkeypatch.setattr(proxy_server, "llm_router", None)
def _raise(**kwargs):
raise ValueError("unsafe model")
monkeypatch.setattr(proxy_server, "is_request_body_safe", _raise)
payload = {"call_type": "completion", "request_body": {"model": "evil"}}
with auth_as():
response = client.post("/utils/transform_request", json=payload)
assert response.status_code == 400
assert "unsafe" in response.text or "error" in response.text
def test_token_counter_fallback_counts_tools_system_and_anthropic_blocks(client, auth_as, monkeypatch):
"""The ``litellm.token_counter`` fallback counts the request's tools and system prompt, and Anthropic ``image``/``document`` blocks, instead of 500ing."""
monkeypatch.setattr(proxy_server, "llm_router", None)
monkeypatch.setattr(litellm, "disable_token_counter", False, raising=False)
system = [{"type": "text", "text": "You are a terse assistant. Answer in one sentence."}]
tools = [
{
"name": "get_weather",
"description": "Look up the current weather for a city",
"input_schema": {"type": "object", "properties": {"city": {"type": "string"}}, "required": ["city"]},
}
]
messages = [
{
"role": "user",
"content": [
{"type": "text", "text": "What is in this file?"},
{"type": "image", "source": {"type": "base64", "media_type": "image/png", "data": "iVBORw0KGgo="}},
{"type": "document", "source": {"type": "base64", "media_type": "application/pdf", "data": "JVBERi0xLjQK"}},
],
}
]
def count(payload: dict) -> int:
with auth_as():
response = client.post("/utils/token_counter", json={"model": "claude-fable-5", **payload})
assert response.status_code == 200, response.text
return response.json()["total_tokens"]
bare = count({"messages": messages})
full = count({"messages": messages, "tools": tools, "system": system})
assert bare == litellm.token_counter(model="claude-fable-5", messages=messages)
assert full == litellm.token_counter(
model="claude-fable-5",
messages=[{"role": "system", "content": system}, *messages],
tools=tools,
)
assert full > bare
def test_token_counter_fallback_counts_a_prompt_with_tools_like_its_messages_form(client, auth_as, monkeypatch):
monkeypatch.setattr(proxy_server, "llm_router", None)
monkeypatch.setattr(litellm, "disable_token_counter", False, raising=False)
prompt = "count the tokens in this sentence please"
tools = [
{
"type": "function",
"function": {
"name": "get_weather",
"description": "Look up the current weather for a city",
"parameters": {"type": "object", "properties": {"city": {"type": "string"}}, "required": ["city"]},
},
}
]
with auth_as():
response = client.post(
"/utils/token_counter", json={"model": "claude-fable-5", "prompt": prompt, "tools": tools}
)
assert response.status_code == 200, response.text
assert response.json()["total_tokens"] == litellm.token_counter(
model="claude-fable-5", messages=[{"role": "user", "content": prompt}], tools=tools
)