mirror of
https://github.com/BerriAI/litellm.git
synced 2026-10-16 04:29:31 +00:00
* 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>
229 lines
8 KiB
Python
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)
|