Files
litellm/tests/test_litellm/proxy/hooks/test_batch_file_validation.py
T
stuxf b80246971b fix(batches): count non-chat tokens, validate batch-file model access (VERIA-39) (#27015)
* fix(batches): count non-chat tokens and validate every model in batch file

Two security control bypasses on POST /v1/batches:

1. `_get_batch_job_input_file_usage` only summed tokens for
   `body.messages` (chat completions). Embedding (`input`) and text
   completion (`prompt`) batches reported zero, letting massive
   non-chat workloads slip past TPM rate limits. Extend the counter
   to handle string and list shapes for both fields.

2. The batch input file was forwarded to the upstream provider
   without inspecting the models named inside the JSONL — only the
   outer `model` query parameter was checked against the caller's
   allowlist. A caller restricted to gpt-3.5 could submit a batch
   targeting gpt-4o and the upstream would execute it under the
   proxy's shared API key.

Add `_get_models_from_batch_input_file_content` (returns the
distinct `body.model` values) and call it from
`_enforce_batch_file_model_access` in the pre-call hook, which runs
each model through `can_key_call_model` so the same allowlist
semantics (wildcards, access groups, all-proxy-models, team aliases)
the proxy enforces on `/chat/completions` apply here too. Any
unauthorized model raises a 403 before the file is forwarded.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(batches): count pre-tokenized prompt/input shapes, classify 403 logs

Two follow-ups from the Greptile review on the batch validation PR:

1. P1 TPM bypass via integer token arrays. The OpenAI batch schema
   accepts ``prompt`` and ``input`` as ``list[int]`` (a single
   pre-tokenized prompt) or ``list[list[int]]`` (multiple) in addition
   to the string and ``list[str]`` shapes. Pre-fix only the string
   shapes were counted, so a caller could submit a batch with hundreds
   of millions of pre-tokenized tokens and the rate limiter would
   record zero. Extract the per-field logic into
   ``_count_prompt_or_input_tokens`` and count each int as one token.

2. P2 access-denial logs were indistinguishable from I/O failures.
   ``count_input_file_usage`` caught every exception under a generic
   "Error counting input file usage" message, so an intentional 403
   from ``_enforce_batch_file_model_access`` looked the same in the
   logs as a missing file or a Prisma timeout. Catch ``HTTPException``
   separately and log 403s at WARNING level with a security-relevant
   message before re-raising.

Tests cover the new shapes: single ``list[int]``, ``list[list[int]]``
(the worst-case bypass vector), and embeddings ``input`` with
pre-tokenized arrays.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 17:36:12 -07:00

286 lines
9.1 KiB
Python

"""
VERIA-39 regression tests:
- The batch input-file token counter must measure embeddings (`input`)
and text-completion (`prompt`) payloads, not only chat (`messages`).
- The batch rate-limiter pre-call hook must reject batch files that name
models the caller is not authorized to use.
"""
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from fastapi import HTTPException
from litellm.proxy._types import LitellmUserRoles, UserAPIKeyAuth
# ---------------------------------------------------------------------------
# Token counter — covers all three batch payload shapes
# ---------------------------------------------------------------------------
def test_token_counter_counts_chat_messages():
from litellm.batches.batch_utils import _get_batch_job_input_file_usage
usage = _get_batch_job_input_file_usage(
file_content_dictionary=[
{
"body": {
"model": "gpt-4o-mini",
"messages": [{"role": "user", "content": "hello"}],
}
}
]
)
assert usage.prompt_tokens > 0
def test_token_counter_counts_text_completion_prompt():
"""Pre-fix this returned 0 tokens (the function only inspected
`messages`), letting `prompt`-style batches slip past TPM limits."""
from litellm.batches.batch_utils import _get_batch_job_input_file_usage
usage = _get_batch_job_input_file_usage(
file_content_dictionary=[
{"body": {"model": "gpt-3.5-turbo-instruct", "prompt": "hello world"}}
]
)
assert usage.prompt_tokens > 0
def test_token_counter_counts_embedding_input_string():
from litellm.batches.batch_utils import _get_batch_job_input_file_usage
usage = _get_batch_job_input_file_usage(
file_content_dictionary=[
{"body": {"model": "text-embedding-3-small", "input": "hello world"}}
]
)
assert usage.prompt_tokens > 0
def test_token_counter_counts_embedding_input_list():
from litellm.batches.batch_utils import _get_batch_job_input_file_usage
usage = _get_batch_job_input_file_usage(
file_content_dictionary=[
{
"body": {
"model": "text-embedding-3-small",
"input": ["hello", "world"],
}
}
]
)
assert usage.prompt_tokens > 0
def test_token_counter_counts_text_completion_prompt_list():
from litellm.batches.batch_utils import _get_batch_job_input_file_usage
usage = _get_batch_job_input_file_usage(
file_content_dictionary=[
{
"body": {
"model": "gpt-3.5-turbo-instruct",
"prompt": ["alpha", "beta"],
}
}
]
)
assert usage.prompt_tokens > 0
def test_token_counter_counts_pre_tokenized_prompt_int_list():
"""OpenAI's text-completion API accepts a single pre-tokenized prompt as
a list of ints. Each int is one token; pre-fix this shape was silently
counted as zero, leaving a TPM bypass."""
from litellm.batches.batch_utils import _get_batch_job_input_file_usage
usage = _get_batch_job_input_file_usage(
file_content_dictionary=[
{
"body": {
"model": "gpt-3.5-turbo-instruct",
"prompt": [1, 2, 3, 4, 5],
}
}
]
)
assert usage.prompt_tokens == 5
def test_token_counter_counts_pre_tokenized_prompt_list_of_int_lists():
"""Multiple pre-tokenized prompts (`list[list[int]]`) — the most
important bypass shape. A 1000-token batch must report 1000 tokens,
not zero."""
from litellm.batches.batch_utils import _get_batch_job_input_file_usage
usage = _get_batch_job_input_file_usage(
file_content_dictionary=[
{
"body": {
"model": "gpt-3.5-turbo-instruct",
"prompt": [[1] * 250, [2] * 250, [3] * 500],
}
}
]
)
assert usage.prompt_tokens == 1000
def test_token_counter_counts_pre_tokenized_input_for_embeddings():
"""Same shape applies to embeddings (`input`)."""
from litellm.batches.batch_utils import _get_batch_job_input_file_usage
usage = _get_batch_job_input_file_usage(
file_content_dictionary=[
{
"body": {
"model": "text-embedding-3-small",
"input": [[1, 2, 3], [4, 5, 6]],
}
}
]
)
assert usage.prompt_tokens == 6
# ---------------------------------------------------------------------------
# Model extractor
# ---------------------------------------------------------------------------
def test_model_extractor_returns_distinct_models():
from litellm.batches.batch_utils import _get_models_from_batch_input_file_content
models = _get_models_from_batch_input_file_content(
[
{"body": {"model": "gpt-4o", "messages": []}},
{"body": {"model": "gpt-4o", "messages": []}}, # duplicate
{"body": {"model": "gpt-4o-mini", "messages": []}},
{"body": {}}, # missing model
]
)
assert models == ["gpt-4o", "gpt-4o-mini"]
# ---------------------------------------------------------------------------
# Pre-call hook model validation
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_pre_call_rejects_unauthorized_model_in_batch_file():
"""Pre-fix the hook only validated the outer `model` parameter and
forwarded the file as-is. With this fix, a model named inside the
JSONL that the caller cannot use must trigger a 403."""
from litellm.proxy.hooks.batch_rate_limiter import _PROXY_BatchRateLimiter
rate_limiter = _PROXY_BatchRateLimiter(
internal_usage_cache=MagicMock(),
parallel_request_limiter=MagicMock(),
)
# Simulated decoded batch file: caller is restricted to gpt-3.5
# but the JSONL points at gpt-4o.
file_dict = [
{"body": {"model": "gpt-4o", "messages": [{"role": "user", "content": "x"}]}}
]
user = UserAPIKeyAuth(
api_key="sk-restricted",
user_id="alice",
models=["gpt-3.5-turbo"],
user_role=LitellmUserRoles.INTERNAL_USER.value,
)
# `can_key_call_model` raises a ProxyException for non-allowed models.
async def _raise_unauthorized(**kwargs):
raise Exception(
f"Key not allowed to access model. This key only has access to models={kwargs['valid_token'].models}"
)
with (
patch(
"litellm.proxy.auth.auth_checks.can_key_call_model",
new=AsyncMock(side_effect=_raise_unauthorized),
),
patch("litellm.proxy.proxy_server.llm_router", None),
):
with pytest.raises(HTTPException) as exc:
await rate_limiter._enforce_batch_file_model_access(
user_api_key_dict=user,
file_content_as_dict=file_dict,
)
assert exc.value.status_code == 403
assert "gpt-4o" in str(exc.value.detail)
@pytest.mark.asyncio
async def test_pre_call_allows_authorized_model_in_batch_file():
"""If every model in the JSONL is on the caller's allowlist, the hook
must not raise."""
from litellm.proxy.hooks.batch_rate_limiter import _PROXY_BatchRateLimiter
rate_limiter = _PROXY_BatchRateLimiter(
internal_usage_cache=MagicMock(),
parallel_request_limiter=MagicMock(),
)
file_dict = [
{
"body": {
"model": "gpt-3.5-turbo",
"messages": [{"role": "user", "content": "x"}],
}
}
]
user = UserAPIKeyAuth(
api_key="sk-ok",
user_id="alice",
models=["gpt-3.5-turbo"],
user_role=LitellmUserRoles.INTERNAL_USER.value,
)
with (
patch(
"litellm.proxy.auth.auth_checks.can_key_call_model",
new=AsyncMock(return_value=True),
),
patch("litellm.proxy.proxy_server.llm_router", None),
):
# Should not raise
await rate_limiter._enforce_batch_file_model_access(
user_api_key_dict=user,
file_content_as_dict=file_dict,
)
@pytest.mark.asyncio
async def test_pre_call_skips_check_when_no_models_present():
"""Files without any `body.model` (corrupt or empty) must not 500;
the rate limiter logs a warning elsewhere and proceeds."""
from litellm.proxy.hooks.batch_rate_limiter import _PROXY_BatchRateLimiter
rate_limiter = _PROXY_BatchRateLimiter(
internal_usage_cache=MagicMock(),
parallel_request_limiter=MagicMock(),
)
user = UserAPIKeyAuth(api_key="sk-ok", user_id="alice")
# Should not raise even though `can_key_call_model` is the default
# (would fail). The early-return on empty models keeps the call out
# entirely.
await rate_limiter._enforce_batch_file_model_access(
user_api_key_dict=user,
file_content_as_dict=[],
)
await rate_limiter._enforce_batch_file_model_access(
user_api_key_dict=user,
file_content_as_dict=[{"body": {}}],
)