fix(hosted_vllm): accept the vLLM production-stack router's file and batch objects (#49068)

* fix(files): default a missing file object type to "file" so OpenAI-compatible servers that omit it stop 500ing

* fix(batches): accept the vLLM production-stack router's batch object

The router leaves out the batch object's "object" field and reports "pending" and "running" statuses, so batch create and retrieve through a hosted_vllm deployment failed validation with a 500. A missing or null object now reads as "batch", pending maps to validating and running to in_progress, and any other unknown value is still rejected

* fix(batches): return OpenAI batch objects from batch list behind the vLLM router

* fix(batches): type the batch list conversion without a TypeVar and refuse an async client on the sync path

* fix(batches): keep batch list items as OpenAI's Batch so an odd item never fails a list

* refactor(types): reuse the shared JSON value alias on the batch type

* fix: return the provider page for an async client in sync batch list and pass through non-string list statuses

* test: inject the SDK's own transport in the sync batch list test so it passes on OpenAI SDK 3.x

---------

Co-authored-by: mateo-berri <277851410+mateo-berri@users.noreply.github.com>
This commit is contained in:
mika-lt 2026-10-30 23:11:34 -07:00 • committed by GitHub
parent afec145edc
commit d1d6d55063
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 349 additions and 10 deletions

View file

@ -16,6 +16,7 @@ from openai._base_client import make_request_options
from openai._constants import RAW_RESPONSE_HEADER
from openai._legacy_response import LegacyAPIResponse
from openai._types import RequestOptions
from openai.pagination import SyncCursorPage
from openai.types import CreateEmbeddingResponse
from openai.types.beta.assistant_deleted import AssistantDeleted
from openai.types.chat import ChatCompletion, ChatCompletionChunk
@ -40,6 +41,7 @@ from litellm.types.utils import (
LiteLLMBatch,
ModelResponse,
ModelResponseStream,
openai_batch_status,
)
from litellm.utils import (
CustomStreamWrapper,
@ -2182,6 +2184,18 @@ class OpenAIFilesAPI(BaseLLM):
return response
def _with_openai_batch_fields(batches: Sequence[Batch]) -> Sequence[Batch]:
return [
batch.model_copy(
update={
"object": batch.object or "batch",
"status": openai_batch_status(batch.status),
}
)
for batch in batches
]
class OpenAIBatchesAPI(BaseLLM):
"""
OpenAI methods to support for batches
@ -2372,7 +2386,7 @@ class OpenAIBatchesAPI(BaseLLM):
):
verbose_logger.debug("listing batches, after= %s, limit= %s", after, limit)
response: Final = await openai_client.batches.list(after=after, limit=limit)
return response
return response.model_copy(update={"data": _with_openai_batch_fields(response.data)})
def list_batches(
self,
@ -2384,7 +2398,7 @@ class OpenAIBatchesAPI(BaseLLM):
organization: str | None,
after: str | None = None,
limit: int | None = None,
client: OpenAI | None = None,
client: OpenAI | AsyncOpenAI | None = None,
litellm_params: Mapping[str, object] | None = None,
):
openai_client: Final[OpenAI | AsyncOpenAI | None] = self.get_openai_client(
@ -2409,7 +2423,9 @@ class OpenAIBatchesAPI(BaseLLM):
)
return self.alist_batches(openai_client=openai_client, after=after, limit=limit)
response: Final = openai_client.batches.list(after=after, limit=limit)
return response
if not isinstance(response, SyncCursorPage):
return response
return response.model_copy(update={"data": _with_openai_batch_fields(response.data)})
class OpenAIAssistantsAPI(BaseLLM):

View file

@ -402,7 +402,7 @@ class OpenAIFileObject(LiteLLMBaseModel):
filename: str
"""The name of the file."""
object: Literal["file"]
object: Literal["file"] = "file"
"""The object type, which is always `file`."""
purpose: OpenAIFilesPurpose
@ -440,6 +440,11 @@ class OpenAIFileObject(LiteLLMBaseModel):
_hidden_params: dict = PrivateAttr(default={"response_cost": 0.0}) # no cost for writing a file
@field_validator("object", mode="before")
@classmethod
def _default_missing_object_type(cls, value: _JsonValue) -> _JsonValue:
return "file" if value is None else value
@property
def hidden_params(self) -> dict[str, builtins.object]: # mutable-ok: API requires mutation
return self._hidden_params

View file

@ -106,6 +106,7 @@ from .llms.openai import (
OpenAIRealtimeStreamList,
ResponsesAPIResponse,
WebSearchOptions,
_JsonValue,
)
from .rerank import RerankResponse as RerankResponse
@ -4963,29 +4964,50 @@ def _batch_usage_from_chat_usage(usage: Usage) -> BatchUsage:
)
OPENAI_BATCH_STATUS_FOR_VLLM_ROUTER_STATUS: Final = MappingProxyType(
{"pending": "validating", "running": "in_progress"}
)
def openai_batch_status(status: _JsonValue) -> _JsonValue:
return OPENAI_BATCH_STATUS_FOR_VLLM_ROUTER_STATUS.get(status, status) if isinstance(status, str) else status
class LiteLLMBatch(Batch):
_hidden_params: dict = {}
object: Literal["batch"] = "batch"
@field_validator("object", mode="before")
@classmethod
def _default_missing_object_type(cls, value: _JsonValue) -> _JsonValue:
return "batch" if value is None else value
@field_validator("status", mode="before")
@classmethod
def _map_vllm_router_status(cls, value: _JsonValue) -> _JsonValue:
return openai_batch_status(value)
@property
def hidden_params(self) -> dict[str, object]: # mutable-ok: API requires mutation
def hidden_params(self) -> dict[str, _JsonValue]: # mutable-ok: API requires mutation
return self._hidden_params
@hidden_params.setter
def hidden_params(self, hidden_params: dict[str, object]) -> None: # mutable-ok: API requires mutation
def hidden_params(self, hidden_params: dict[str, _JsonValue]) -> None: # mutable-ok: API requires mutation
self._hidden_params = hidden_params
usage: Usage | None = None
@field_validator("usage", mode="before")
@classmethod
def _usage_from_batch_token_counts(cls, value: object) -> object:
def _usage_from_batch_token_counts(cls, value: _JsonValue) -> _JsonValue:
counts: Final = _parse_batch_token_counts(value)
return value if counts is None else _usage_with_batch_token_counts(Usage.model_validate(value), counts)
@field_serializer("usage")
def _serialize_usage_with_batch_fields(
self, usage: Usage | None, info: FieldSerializationInfo
) -> Mapping[str, object] | None:
) -> Mapping[str, _JsonValue] | None:
if not isinstance(usage, Usage):
return usage
chat_fields: Final = usage.model_dump(

View file

@ -37,13 +37,15 @@ import litellm.batches.main as bm
import asyncio
import datetime
import json
from collections.abc import Mapping
from collections.abc import Awaitable, Callable, Mapping, Sequence
from typing import Final
import httpx
import respx
from pydantic import TypeAdapter
from pydantic import TypeAdapter, ValidationError
from typing_extensions import ReadOnly, TypedDict
from litellm.integrations.custom_logger import CustomLogger
from openai.types.batch import Batch
from litellm.types.utils import LiteLLMBatch
# --------------------------------------------------------------------------- #
@ -1204,3 +1206,183 @@ async def test_delete_batch_output_file(respx_mock: respx.MockRouter, monkeypatc
assert delete_route.call_count == 1
assert delete_response.id == "file-output123"
assert delete_response.deleted is True
VLLM_ROUTER_API_BASE: Final = "http://vllm-router.test/v1"
VLLM_ROUTER_BATCH: Final = MappingProxyType(
{
"id": "batch_5f8dac",
"status": "pending",
"input_file_id": "file-b73f4c",
"created_at": 1793239207,
"endpoint": "/v1/chat/completions",
"completion_window": "24h",
"output_file_id": None,
"total_requests": None,
"completed_requests": 0,
"failed_requests": 0,
"metadata": {},
}
)
async def _create_on_vllm_router() -> LiteLLMBatch:
return await litellm.acreate_batch(
completion_window="24h",
endpoint="/v1/chat/completions",
input_file_id="file-b73f4c",
custom_llm_provider="hosted_vllm",
api_key="sk-test",
api_base=VLLM_ROUTER_API_BASE,
)
async def _retrieve_from_vllm_router() -> LiteLLMBatch:
return await litellm.aretrieve_batch(
batch_id="batch_5f8dac", custom_llm_provider="hosted_vllm", api_key="sk-test", api_base=VLLM_ROUTER_API_BASE
)
VLLM_ROUTER_BATCH_CALLS: Final = (
pytest.param("POST", "/batches", _create_on_vllm_router, id="acreate_batch"),
pytest.param("GET", "/batches/batch_5f8dac", _retrieve_from_vllm_router, id="aretrieve_batch"),
)
@pytest.mark.parametrize("method, path, call_router", VLLM_ROUTER_BATCH_CALLS)
@pytest.mark.parametrize(
"router_status, openai_status",
(
("pending", "validating"),
("running", "in_progress"),
("completed", "completed"),
("failed", "failed"),
("cancelled", "cancelled"),
),
)
async def test_hosted_vllm_router_batch_comes_back_as_an_openai_batch(
respx_mock: respx.MockRouter,
method: str,
path: str,
call_router: Callable[[], Awaitable[LiteLLMBatch]],
router_status: str,
openai_status: str,
):
respx_mock.route(method=method, url=f"{VLLM_ROUTER_API_BASE}{path}").respond(
200, json={**VLLM_ROUTER_BATCH, "status": router_status}
)
batch: Final = await call_router()
assert (batch.id, batch.object, batch.status) == ("batch_5f8dac", "batch", openai_status)
@pytest.mark.parametrize("method, path, call_router", VLLM_ROUTER_BATCH_CALLS)
@pytest.mark.parametrize("field, value", (("status", "queued"), ("object", "file")))
async def test_hosted_vllm_router_batch_with_an_unknown_value_is_rejected(
respx_mock: respx.MockRouter,
method: str,
path: str,
call_router: Callable[[], Awaitable[LiteLLMBatch]],
field: str,
value: str,
):
respx_mock.route(method=method, url=f"{VLLM_ROUTER_API_BASE}{path}").respond(
200, json={**VLLM_ROUTER_BATCH, field: value}
)
with pytest.raises(ValidationError) as exc_info:
await call_router()
assert [error["loc"] for error in exc_info.value.errors()] == [(field,)]
async def _list_on_vllm_router() -> Sequence[Batch]:
page: Final = await litellm.alist_batches(
custom_llm_provider="hosted_vllm", api_key="sk-test", api_base=VLLM_ROUTER_API_BASE
)
return page.data
async def _list_on_vllm_router_sync() -> Sequence[Batch]:
return litellm.list_batches(custom_llm_provider="hosted_vllm", api_key="sk-test", api_base=VLLM_ROUTER_API_BASE).data
@pytest.mark.parametrize("list_router", (_list_on_vllm_router, _list_on_vllm_router_sync))
async def test_hosted_vllm_router_batch_list_comes_back_as_openai_batches(
respx_mock: respx.MockRouter, list_router: Callable[[], Awaitable[Sequence[Batch]]]
):
respx_mock.get(f"{VLLM_ROUTER_API_BASE}/batches").respond(
200,
json={
"object": "list",
"data": [
{**VLLM_ROUTER_BATCH, "id": "batch_5f8dac", "status": "pending"},
{**VLLM_ROUTER_BATCH, "id": "batch_c74802", "status": "running"},
],
"first_id": "batch_5f8dac",
"last_id": "batch_c74802",
"has_more": False,
},
)
batches: Final = await list_router()
assert [(batch.id, batch.object, batch.status) for batch in batches] == [
("batch_5f8dac", "batch", "validating"),
("batch_c74802", "batch", "in_progress"),
]
async def test_hosted_vllm_router_batch_list_passes_through_a_status_that_is_not_a_string(
respx_mock: respx.MockRouter,
):
respx_mock.get(f"{VLLM_ROUTER_API_BASE}/batches").respond(
200,
json={
"object": "list",
"data": [{**VLLM_ROUTER_BATCH, "status": ["pending"]}],
"first_id": "batch_5f8dac",
"last_id": "batch_5f8dac",
"has_more": False,
},
)
batches: Final = await _list_on_vllm_router()
assert [(batch.id, batch.object, batch.status) for batch in batches] == [("batch_5f8dac", "batch", ["pending"])]
async def test_openai_batch_list_items_come_back_as_openai_sent_them(respx_mock: respx.MockRouter):
respx_mock.get("http://openai.test/v1/batches").respond(
200,
json={
"object": "list",
"data": [
{
**_OPENAI_BATCH_JSON,
"status": "completed",
"usage": {
"input_tokens": 10,
"input_tokens_details": {"cached_tokens": 4},
"output_tokens": 5,
"output_tokens_details": {"reasoning_tokens": 2},
"total_tokens": 15,
},
},
{**_OPENAI_BATCH_JSON, "id": "batch_def456", "status": "queued"},
],
"first_id": "batch_abc123",
"last_id": "batch_def456",
"has_more": False,
},
)
page: Final = await litellm.alist_batches(
custom_llm_provider="openai", api_key="sk-test", api_base="http://openai.test/v1"
)
first, second = page.data
assert first.usage is not None
assert (first.usage.input_tokens_details.cached_tokens, first.usage.output_tokens_details.reasoning_tokens) == (4, 2)
assert (second.id, second.object, second.status) == ("batch_def456", "batch", "queued")

View file

@ -1,3 +1,4 @@
from collections.abc import Awaitable, Callable
from types import MappingProxyType
from typing import Final
from urllib.parse import parse_qs, urlparse
@ -124,6 +125,61 @@ async def test_afile_retrieve_rejects_a_provider_file_without_its_size():
assert [error["loc"] for error in exc_info.value.errors()] == [("bytes",)]
VLLM_ROUTER_API_BASE: Final = "http://vllm-router.test/v1"
VLLM_ROUTER_FILE: Final = MappingProxyType(
{"id": "file-b73f4c", "bytes": 156, "created_at": 1793238707, "filename": "batch_requests.jsonl", "purpose": "batch"}
)
async def _upload_to_vllm_router() -> OpenAIFileObject:
return await litellm.acreate_file(
file=("batch_requests.jsonl", b'{"custom_id": "request-1"}'),
purpose="batch",
custom_llm_provider="hosted_vllm",
api_key="sk-test",
api_base=VLLM_ROUTER_API_BASE,
)
async def _retrieve_from_vllm_router() -> OpenAIFileObject:
return await litellm.afile_retrieve(
file_id="file-b73f4c", custom_llm_provider="hosted_vllm", api_key="sk-test", api_base=VLLM_ROUTER_API_BASE
)
VLLM_ROUTER_FILE_CALLS: Final = (
pytest.param("POST", "/files", _upload_to_vllm_router, id="acreate_file"),
pytest.param("GET", "/files/file-b73f4c", _retrieve_from_vllm_router, id="afile_retrieve"),
)
@pytest.mark.parametrize("method, path, call_router", VLLM_ROUTER_FILE_CALLS)
@respx.mock
async def test_hosted_vllm_file_without_an_object_type_comes_back_as_a_file(
method: str, path: str, call_router: Callable[[], Awaitable[OpenAIFileObject]]
):
respx.route(method=method, url=f"{VLLM_ROUTER_API_BASE}{path}").respond(200, json=dict(VLLM_ROUTER_FILE))
file_object: Final = await call_router()
assert file_object.model_dump(exclude_none=True) == {**VLLM_ROUTER_FILE, "object": "file"}
@pytest.mark.parametrize("method, path, call_router", VLLM_ROUTER_FILE_CALLS)
@respx.mock
async def test_hosted_vllm_file_with_a_different_object_type_is_rejected(
method: str, path: str, call_router: Callable[[], Awaitable[OpenAIFileObject]]
):
respx.route(method=method, url=f"{VLLM_ROUTER_API_BASE}{path}").respond(
200, json={**VLLM_ROUTER_FILE, "object": "batch"}
)
with pytest.raises(ValidationError) as exc_info:
await call_router()
assert [error["loc"] for error in exc_info.value.errors()] == [("object",)]
_FILE_BODY: Final = b'{"prompt": "Hello", "completion": "Hi"}'
_FINE_TUNE_FILE_JSON: Final = MappingProxyType(
{

View file

@ -24,6 +24,7 @@ from litellm.llms.openai.openai import (
AsyncAssistantEventHandler,
AsyncCursorPage,
MessageData,
OpenAIBatchesAPI,
OpenAIChatCompletion,
OpenAIConfig,
OpenAIMessage as Message,
@ -2320,3 +2321,43 @@ def test_openai_config_hoists_a_later_developer_message_only_for_a_model_off_ope
model_info=None,
)
assert [message["role"] for message in messages] == expected_roles
@pytest.mark.asyncio
async def test_sync_batch_list_given_an_async_client_returns_a_page_the_caller_can_await(
http_backend: ModuleType,
):
def respond(request: _Request) -> httpx.Response | SDKResponse:
assert str(request.url).startswith("http://vllm-router.test/v1/batches")
return http_backend.Response(
200,
json={
"object": "list",
"data": [
{
"id": "batch_5f8dac",
"status": "pending",
"input_file_id": "file-b73f4c",
"created_at": 1793239207,
"endpoint": "/v1/chat/completions",
"completion_window": "24h",
}
],
"first_id": "batch_5f8dac",
"last_id": "batch_5f8dac",
"has_more": False,
},
)
async with http_backend.AsyncClient(transport=http_backend.MockTransport(respond)) as http_client:
page: Final = OpenAIBatchesAPI().list_batches(
_is_async=False,
api_key="sk-test",
api_base="http://vllm-router.test/v1",
timeout=5.0,
max_retries=0,
organization=None,
client=AsyncOpenAI(api_key="sk-test", base_url="http://vllm-router.test/v1", http_client=http_client),
)
assert [(batch.id, batch.status) for batch in (await page).data] == [("batch_5f8dac", "pending")]

View file

@ -607,6 +607,23 @@ def test_openai_file_object_accepts_pending_status():
assert file_obj.status == "pending"
@pytest.mark.parametrize("object_type", ({}, {"object": None}), ids=["missing", "null"])
def test_openai_file_object_without_an_object_type_validates_as_a_file(object_type: dict[str, None]) -> None:
from litellm.types.llms.openai import OpenAIFileObject
server_file: Final = {
"id": "file-b73f4c",
"bytes": 156,
"created_at": 1793238707,
"filename": "batch_requests.jsonl",
"purpose": "batch",
}
file_obj: Final = OpenAIFileObject.model_validate({**server_file, **object_type})
assert file_obj.model_dump(exclude_none=True) == {**server_file, "object": "file"}
class TestOpenAIFileObjectBatchGuardrailSerialization:
"""The proxy-only `litellm_batch_guardrail` key must reach the wire only when something set it."""