litellm/tests/unit/proxy/ocr_endpoints/test_endpoints.py
yucheng-berri 784581bace
fix(proxy)!: return provider-matching 4xx for the LIT-8062 status code follow-ups (#45537)
* fix(proxy): return provider-matching 4xx for the LIT-8062 status code follow-ups

- OCR multipart errors are 400 invalid_request_error on the file param
- a missing model is a 400 on chat, responses and messages even with a wildcard deployment
- responses max_output_tokens <= 0 reaches the provider instead of being raised to 16
- daily activity rejects end_date before start_date with a 400
- vector store file attributes are sent in full so the provider enforces its key limit
- router-wide defaults no longer satisfy a param the Router takes positionally, or a model the router does not serve
- a JSON null user_config is treated as absent

* chore: keep the type gates at their merge-base counts

* fix(proxy): keep fixed-target wildcards serving a missing model and close router-default gaps

- a missing model is rejected only when the wildcard would forward it into the target
- moderation only counts router defaults for models get_model_list lists
- user_config defaults and deployment params never satisfy a positionally bound param

* test(integration): pin the fixed-target wildcard path and no provider call on positional-param 400s

* chore: validate pattern-matched deployments instead of reading untyped dicts

* fix(proxy): keep router defaults counting for a2a agent models

* test: drop the signature-walk test and type the new test helpers

* test(integration): audit matrix for the status-code follow-ups

Drives every mode the fix branches on through a real proxy, Postgres, Redis and
the scripted upstream: positional params against router, deployment and
user_config defaults; moderation listed vs unlisted; unknown and a2a models;
every user_config shape on chat, responses and messages, streaming and SDK
clients; forwarding vs fixed-target wildcards for missing, null and empty
models; Responses max_output_tokens edges; OCR parse errors; vector store file
attributes; every daily activity route; and a provider outage mid burst plus a
killed worker on a two-worker proxy. Product gaps found are skipped as BUG.

* style: ruff format the audit test files

* test: name the auth pre-check as the source of the JSON array 500

* test: build the audit helpers without mutation and share one sentinel

* test: build every audit request body as one literal

* fix(proxy): keep router defaults and model-less dispatch for requests a fallback chain serves

* fix(proxy): count request, key and team fallbacks when an unlisted model inherits router defaults

* fix(proxy): skip key and team fallbacks on the client-credential dispatch path

* fix(proxy): keep router defaults when the router's own * fallback serves an unlisted model

* fix(proxy): count client-style fallbacks, forward an empty model, report invalid usage chat dates

- The router-default pre-check now treats client-style request, key and team fallbacks
  ([{"model": ...}]) as serving an unlisted model, as the router does
- The forwarding-wildcard gate only rejects a missing or null model; an empty model
  reaches the provider as before (vLLM serves its loaded model for it)
- The /usage/ai/chat tools hand the model the date-range error instead of an empty
  dataset or a generic fetch error

* test(usage): type the invalid-date tool test

* fix(proxy): keep rejecting an empty model behind a forwarding wildcard

Key and team model checks skip an empty model, so forwarding it let a key
restricted to another model reach a server's default model (vLLM serves
its loaded model for ""). Pin that with a restricted-key integration cell.

* fix(usage): report the query layer's date 400 to the usage chat model

Checking dates before the fetch refused formats the global usage tool's query
accepts (2026/10/01 returned spend on main). Catch the daily activity query's
own 400 instead, so each tool rejects exactly what it rejected before and only
the message changes.

* fix(proxy): restore three model-less and unlisted-model paths main served

An empty model is looked up as sent, so next to a forwarding */* wildcard it
still reaches the fixed * deployment it matches. A null model with a prompt_id
is left to the prompt manager, which may pick the model. A Claude Code
subagent turn on a session-bound auto/complexity router counts as served, so
the router default max_tokens still applies.

* fix(proxy): only skip the model check for a prompt_id when a prompt manager is registered

Without a callback prompt manager nothing can pick the model, so a null model
with a prompt_id behind a forwarding wildcard gets the same 400 as any other
null model.

* fix(proxy): leave a model-less Claude Code subagent turn to its session router

A subagent turn on a session bound to an auto, complexity, adaptive or
quality router is answered by that router whatever model it names, so a
null or missing model behind a forwarding wildcard is no longer rejected.
An empty model stays rejected.

* fix(proxy): skip the wildcard model check only for a bound Claude Code session

Read the session's router binding, as the router does, instead of only
checking that the request could resume one, so a subagent turn without a
model on an unbound session is still rejected before a forwarding
wildcard sends it upstream.

* refactor(proxy): stop predicting routing in the proxy pre-checks

- drop the wildcard missing-model gate: a model-less request behind a forwarding wildcard goes to the router and gets the provider's own status, as on main
- drop the "model the router serves" check: router defaults count for any named model again, except moderation, where the Router merges them only for listed models
- keep the positional-param, moderation and null user_config fixes

* test(ocr): cover the form, size, empty body, invalid JSON and file-document 400s

---------

Co-authored-by: yucheng-berri <293926326+yucheng-berri@users.noreply.github.com>
2026-10-13 11:42:39 -07:00

229 lines
8 KiB
Python

"""
Tests for the proxy OCR endpoint helpers that select the response format
(`x-req-format: native | litellm`) and return the provider's native payload.
"""
import io
from typing import Final
from unittest.mock import AsyncMock, MagicMock
import orjson
import pytest
from fastapi import HTTPException, UploadFile
from litellm.llms.base_llm.ocr.transformation import OCRPage, OCRResponse
from litellm.proxy.ocr_endpoints.endpoints import OCRRequestError, _native_response, _parse_ocr_request
AZURE_NATIVE_OPERATION = {
"status": "succeeded",
"createdDateTime": "2026-07-02T00:00:00Z",
"analyzeResult": {
"content": "Invoice",
"pages": [{"pageNumber": 1, "words": [{"content": "Invoice", "confidence": 0.99}]}],
"paragraphs": [{"content": "Invoice"}],
},
}
def _json_request(body: dict, headers: dict[str, str]) -> MagicMock:
request = MagicMock()
request.headers = {"content-type": "application/json", **headers}
request.body = AsyncMock(return_value=orjson.dumps(body))
request._form = None
return request
def _ocr_response(native_payload: dict[str, object] | None) -> OCRResponse:
response = OCRResponse(pages=[OCRPage(index=0, markdown="Invoice")], model="azure-prebuilt-layout")
if native_payload is not None:
response.set_provider_native_response(native_payload)
return response
@pytest.mark.asyncio
@pytest.mark.parametrize("header_value", ["native", "NATIVE", " native "])
async def test_should_read_req_format_from_header(header_value):
request = _json_request(
{"model": "azure-prebuilt-layout", "document": {"type": "document_url", "document_url": "https://x/y.pdf"}},
{"x-req-format": header_value},
)
assert (await _parse_ocr_request(request))["req_format"] == "native"
@pytest.mark.asyncio
async def test_should_prefer_body_req_format_over_header():
request = _json_request(
{
"model": "azure-prebuilt-layout",
"document": {"type": "document_url", "document_url": "https://x/y.pdf"},
"req_format": "litellm",
},
{"x-req-format": "native"},
)
assert (await _parse_ocr_request(request))["req_format"] == "litellm"
@pytest.mark.asyncio
async def test_should_omit_req_format_when_header_absent():
request = _json_request(
{"model": "azure-prebuilt-layout", "document": {"type": "document_url", "document_url": "https://x/y.pdf"}},
{},
)
assert "req_format" not in await _parse_ocr_request(request)
@pytest.mark.asyncio
@pytest.mark.parametrize(
"body_format, headers",
[
(None, {"x-req-format": "azure"}),
("azure", {}),
("azure", {"x-req-format": "native"}),
],
)
async def test_should_reject_unknown_req_format(body_format, headers):
body = {"model": "azure-prebuilt-layout", "document": {"type": "document_url", "document_url": "https://x/y.pdf"}}
request = _json_request(
body if body_format is None else {**body, "req_format": body_format},
headers,
)
with pytest.raises(HTTPException) as exc_info:
await _parse_ocr_request(request)
assert exc_info.value.status_code == 400
assert "Invalid `req_format`" in f"{exc_info.value.detail}"
def test_should_return_native_payload_with_litellm_response_headers():
fastapi_response = MagicMock()
fastapi_response.headers = {"x-litellm-response-cost": "0.0015"}
native = _native_response(_ocr_response(AZURE_NATIVE_OPERATION), fastapi_response)
assert native is not None
assert orjson.loads(native.body) == AZURE_NATIVE_OPERATION
assert native.headers["x-litellm-response-cost"] == "0.0015"
def test_should_return_normalized_response_when_no_native_payload():
assert _native_response(_ocr_response(None), MagicMock()) is None
def test_upload_builds_a_file_document_for_rust_mime_inference():
from litellm.proxy.ocr_endpoints.endpoints import (
_build_document_from_upload, # pyright: ignore[reportPrivateUsage] # tests the upload projection
)
document = _build_document_from_upload(b"%PDF-1.4", "receipt.pdf", None)
assert document["type"] == "file"
upload = document["file"]
assert upload.read() == b"%PDF-1.4"
assert upload.name == "receipt.pdf"
assert "mime_type" not in document
def test_upload_keeps_the_supplied_content_type_over_filename_inference():
from litellm.proxy.ocr_endpoints.endpoints import (
_build_document_from_upload, # pyright: ignore[reportPrivateUsage] # tests the upload projection
)
document = _build_document_from_upload(b"data", "photo.bin", "image/png; charset=binary")
assert document["mime_type"] == "image/png"
assert document["file"].name == "photo.bin"
def test_upload_octet_stream_content_type_falls_back_to_filename_inference():
from litellm.proxy.ocr_endpoints.endpoints import (
_build_document_from_upload, # pyright: ignore[reportPrivateUsage] # tests the upload projection
)
document = _build_document_from_upload(b"%PDF-1.4", "receipt.pdf", "application/octet-stream")
assert "mime_type" not in document
assert document["file"].name == "receipt.pdf"
def _multipart_request(form: dict[str, object]) -> MagicMock:
request: Final = MagicMock()
request.headers = {"content-type": "multipart/form-data; boundary=x"}
request.form = AsyncMock(return_value=form)
return request
@pytest.mark.asyncio
@pytest.mark.parametrize(
"form, message",
[
({"model": "mistral-ocr"}, "must include a 'file' field"),
(
{"model": "mistral-ocr", "file": UploadFile(file=io.BytesIO(b""), filename="empty.pdf")},
"Uploaded file is empty",
),
],
ids=["missing-file", "empty-file"],
)
async def test_malformed_multipart_upload_is_a_400_on_the_file_param(form: dict[str, object], message: str) -> None:
with pytest.raises(OCRRequestError) as exc_info:
await _parse_ocr_request(_multipart_request(form))
assert exc_info.value.code == "400"
assert exc_info.value.type == "invalid_request_error"
assert exc_info.value.param == "file"
assert message in exc_info.value.message
@pytest.mark.asyncio
async def test_unparseable_multipart_form_is_a_400() -> None:
request: Final = _multipart_request({})
request.form = AsyncMock(side_effect=ValueError("missing boundary"))
with pytest.raises(OCRRequestError) as exc_info:
await _parse_ocr_request(request)
assert exc_info.value.code == "400"
assert exc_info.value.type == "invalid_request_error"
assert "Failed to parse multipart form data: missing boundary" in exc_info.value.message
@pytest.mark.asyncio
async def test_multipart_file_over_the_size_limit_is_a_400_on_the_file_param(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr("litellm.proxy.ocr_endpoints.endpoints._MAX_FILE_BYTES", 4)
form: Final = {"model": "mistral-ocr", "file": UploadFile(file=io.BytesIO(b"12345"), filename="big.pdf")}
with pytest.raises(OCRRequestError) as exc_info:
await _parse_ocr_request(_multipart_request(form))
assert exc_info.value.code == "400"
assert exc_info.value.param == "file"
assert exc_info.value.message == "OCR file exceeds the size limit"
@pytest.mark.asyncio
@pytest.mark.parametrize(
"body, message",
[
(b"", "Empty request body."),
(b"{not json", "Invalid JSON in request body:"),
(
orjson.dumps({"model": "mistral-ocr", "document": {"type": "file", "file": "/etc/passwd"}}),
"document type 'file' is not supported through the JSON API.",
),
],
ids=["empty-body", "invalid-json", "document-type-file"],
)
async def test_malformed_json_ocr_request_is_a_400(body: bytes, message: str) -> None:
request: Final = _json_request({}, {})
request.body = AsyncMock(return_value=body)
with pytest.raises(OCRRequestError) as exc_info:
await _parse_ocr_request(request)
assert exc_info.value.code == "400"
assert exc_info.value.type == "invalid_request_error"
assert exc_info.value.message.startswith(message)