mirror of
https://github.com/BerriAI/litellm.git
synced 2026-10-17 04:34:25 +00:00
* fix(vertex_ai): return 400 with file line number for malformed batch JSONL row Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> * refactor(vertex_ai): keep yield outside the JSON decode guard Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> * fix(http_handler): surface BaseLLMException raised while streaming a request body Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> * refactor(http_handler): only follow __cause__ when unwrapping streamed body errors Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> * fix(http_handler): avoid recursive streamed error unwrapping Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> * fix(vertex_ai): return a 400 naming the line for wrong-shape and non-UTF-8 batch rows Keep main's error path for a directly raised provider error and only unwrap one found behind a transport error. Add the parser column to invalid JSON errors, and cover the real aiohttp chain in the _handle_error test * fix(vertex_ai): report every row conversion error as a 400 naming its line A message with no role, a non-string role, a bad temperature, or a litellm BadRequestError raised while converting a row still returned a 500, and the async upload lost the 400 for litellm's own exceptions. The stream now turns any error from one row's conversion into a 400 naming that line. _handle_error only unwraps an httpx.TransportError, which is what the aiohttp transport raises around a failing request body, so other callers keep their own status. A file that starts with a UTF-8 byte order mark now uploads, and provider messages start with 'Batch input file line N' like the proxy check. * fix(vertex_ai): reject null or missing batch bodies and unparseable rows with a 400 naming the line A chat row whose body is missing or not an object now raises a 400 naming its line instead of uploading a blank prompt, and a non-object body on line 1 no longer crashes the GCS object name lookup. Rows too deeply nested or too long to parse return a 400 from both the SDK and the proxy's own batch file check. JSON error columns now count leading whitespace. * fix(vertex_ai): keep batch rows on their file line through router and guardrail rewrites The Router model rewrite and batch guardrails now keep blank lines and blank out dropped records, so a provider error naming a line still points into the caller's file. The rewrite strips a leading BOM per line, and a non-string body.model on line 1 is reported as a 400 naming the line * test(integration): cover Vertex batch row line errors across routes, rewrites, SDK and worker chaos * fix(router): keep blank lines only for Vertex batch uploads and leave guardrail rewrites compact * test(integration): score the whole OpenAI upload after a guardrail drop and cover a mixed OpenAI and Vertex batch upload --------- Co-authored-by: jesus <jesus@berri.ai> Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Co-authored-by: mateo-berri <277851410+mateo-berri@users.noreply.github.com>
174 lines
6 KiB
Python
174 lines
6 KiB
Python
import json
|
|
import socket
|
|
import threading
|
|
from collections.abc import Callable
|
|
from email.message import Message
|
|
from email.parser import BytesParser
|
|
from email.policy import HTTP
|
|
from typing import Final, Literal
|
|
from urllib.parse import unquote, urlsplit
|
|
|
|
import httpx
|
|
from integration._support.client import Gateway, Scenario
|
|
from integration._support.vertex import service_account_json
|
|
from integration._support.wire import Reply, Request
|
|
from pydantic import JsonValue, TypeAdapter
|
|
|
|
PROJECT: Final = "scripted-project"
|
|
LOCATION: Final = "us-central1"
|
|
BUCKET: Final = "scripted-bucket"
|
|
UPLOAD_PREFIX: Final = f"/upload/storage/v1/b/{BUCKET}/o?uploadType=media&name="
|
|
CHAT_URL: Final = "/v1/chat/completions"
|
|
BOM: Final = b"\xef\xbb\xbf"
|
|
BODY_NOT_OBJECT: Final = "`body` on batch requests must be a JSON object, but was missing or not an object"
|
|
Route = Literal["model", "target_model_names"]
|
|
ROUTES: Final[tuple[Route, ...]] = ("model", "target_model_names")
|
|
_ERROR_BODY: Final = TypeAdapter(dict[str, dict[str, JsonValue]])
|
|
|
|
|
|
def stored_object(request: Request) -> Reply:
|
|
name: Final = unquote(request.target.removeprefix(UPLOAD_PREFIX))
|
|
return Reply(
|
|
body=json.dumps(
|
|
{
|
|
"kind": "storage#object",
|
|
"id": f"{BUCKET}/{name}/1759950000000000",
|
|
"name": name,
|
|
"bucket": BUCKET,
|
|
"size": str(len(request.body)),
|
|
"timeCreated": "2026-10-15T06:00:00.000Z",
|
|
"contentType": "application/octet-stream",
|
|
}
|
|
).encode()
|
|
)
|
|
|
|
|
|
def is_gcs_upload(request: Request) -> bool:
|
|
return request.method == "POST" and request.target.startswith(UPLOAD_PREFIX)
|
|
|
|
|
|
def gcs_peer(request: Request) -> Reply:
|
|
assert is_gcs_upload(request), (request.method, request.target)
|
|
assert request.headers["authorization"] == "Bearer scripted-token"
|
|
return stored_object(request)
|
|
|
|
|
|
def held_gcs_peer(release: threading.Event) -> Callable[[Request], Reply]:
|
|
def respond(request: Request) -> Reply:
|
|
assert release.wait(timeout=120), "the held bucket upload was never released"
|
|
return gcs_peer(request)
|
|
|
|
return respond
|
|
|
|
|
|
def vertex_params(gateway: Gateway, url: str, backend: str = "gemini-2.5-flash") -> dict[str, JsonValue]:
|
|
return {
|
|
"model": f"vertex_ai/{backend}",
|
|
"api_key": None,
|
|
"api_base": url,
|
|
"vertex_project": PROJECT,
|
|
"vertex_location": LOCATION,
|
|
"vertex_credentials": service_account_json(PROJECT, gateway.upstream_url),
|
|
"gcs_bucket_name": BUCKET,
|
|
}
|
|
|
|
|
|
def vertex_model(gateway: Gateway, scenario: Scenario, url: str, backend: str = "gemini-2.5-flash") -> str:
|
|
return scenario.model(
|
|
model=f"vertex_ai/{backend}",
|
|
api_key=None,
|
|
api_base=url,
|
|
vertex_project=PROJECT,
|
|
vertex_location=LOCATION,
|
|
vertex_credentials=service_account_json(PROJECT, gateway.upstream_url),
|
|
gcs_bucket_name=BUCKET,
|
|
)
|
|
|
|
|
|
def chat_row(custom_id: str, model: str, text: str) -> dict[str, JsonValue]:
|
|
return {
|
|
"custom_id": custom_id,
|
|
"method": "POST",
|
|
"url": CHAT_URL,
|
|
"body": {"model": model, "messages": [{"role": "user", "content": text}], "max_tokens": 8},
|
|
}
|
|
|
|
|
|
def roleless_row(custom_id: str, model: str) -> dict[str, JsonValue]:
|
|
return {"custom_id": custom_id, "method": "POST", "url": CHAT_URL, "body": {"model": model, "messages": [{}]}}
|
|
|
|
|
|
def encoded(row: dict[str, JsonValue]) -> bytes:
|
|
return json.dumps(row).encode()
|
|
|
|
|
|
def jsonl(*lines: bytes) -> bytes:
|
|
return b"\n".join(lines) + b"\n"
|
|
|
|
|
|
def upload(
|
|
gateway: Gateway, route: Route | None, model: str, content: bytes, *, client: httpx.Client | None = None, **extra: str
|
|
) -> httpx.Response:
|
|
fields: Final = {"purpose": "batch", **({route: model} if route is not None else {}), **extra}
|
|
return (client or gateway.client).post(
|
|
"/v1/files",
|
|
data=fields,
|
|
files={"file": ("rows.jsonl", content, "application/jsonl")},
|
|
headers={"Authorization": f"Bearer {gateway.key}"},
|
|
)
|
|
|
|
|
|
def error_of(response: httpx.Response) -> dict[str, JsonValue]:
|
|
return _ERROR_BODY.validate_json(response.content)["error"]
|
|
|
|
|
|
def unconvertible_line_message(line: int) -> str:
|
|
return f"Batch input file line {line} could not be converted to a Vertex AI request: "
|
|
|
|
|
|
def stored_custom_ids(request: Request) -> tuple[str, ...]:
|
|
return tuple(
|
|
json.loads(line)["request"]["labels"]["litellm_custom_id"] for line in request.body.decode().split("\n") if line
|
|
)
|
|
|
|
|
|
def closed_port() -> int:
|
|
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as probe:
|
|
probe.bind(("127.0.0.1", 0))
|
|
return int(probe.getsockname()[1])
|
|
|
|
|
|
def multipart_file(request: Request) -> bytes:
|
|
envelope: Final = f"content-type: {request.headers['content-type']}\r\n\r\n".encode() + request.body
|
|
parts: Final[tuple[Message, ...]] = tuple(BytesParser(policy=HTTP).parsebytes(envelope).iter_parts())
|
|
files: Final = tuple(part.get_payload(decode=True) for part in parts if part.get_filename() is not None)
|
|
assert len(files) == 1, [part.get("content-disposition") for part in parts]
|
|
payload: Final = files[0]
|
|
assert isinstance(payload, bytes)
|
|
return payload
|
|
|
|
|
|
def is_openai_upload(request: Request) -> bool:
|
|
return request.method == "POST" and urlsplit(request.target).path == "/v1/files"
|
|
|
|
|
|
def openai_files_peer(request: Request) -> Reply:
|
|
if not is_openai_upload(request):
|
|
return Reply(body=json.dumps({"object": "list", "data": []}).encode())
|
|
return Reply(
|
|
body=json.dumps(
|
|
{
|
|
"id": "file-scripted",
|
|
"object": "file",
|
|
"bytes": len(multipart_file(request)),
|
|
"created_at": 1792040000,
|
|
"filename": "modified_file.jsonl",
|
|
"purpose": "batch",
|
|
"status": "uploaded",
|
|
}
|
|
).encode()
|
|
)
|
|
|
|
|
|
def openai_uploaded_lines(request: Request) -> list[str]:
|
|
return multipart_file(request).decode().split("\n")
|