fix(vertex_ai): batch Claude models in Anthropic's native row shape (#45715)

Backport of #45715 to lts/1.108.x.
Cherry-picked from a7030a5a2c (main).

Adapted to the line: the Gemini row mapper keeps the line's signature, since
deployment_drop_params on it comes from #31915, which is not on the line, and
the Claude row reads drop_params with the line's normalize_drop_params, since
drops_unsupported_params is not in core_helpers here.
This commit is contained in:
Gopal Kamineni 2026-11-05 21:32:19 -08:00 • committed by mateo-berri
parent bf06bf1edb
commit 32469bcab9
7 changed files with 860 additions and 69 deletions

View file

@ -14,8 +14,10 @@ from litellm.litellm_core_utils.url_utils import (
)
from litellm.llms.custom_httpx.http_handler import (
HTTPHandler,
MaskedHTTPStatusError,
get_async_httpx_client,
get_httpx_client,
mask_sensitive_info,
)
from litellm.llms.vertex_ai.common_utils import VertexAIError, get_vertex_base_url
from litellm.llms.vertex_ai.gemini.vertex_and_google_ai_studio_gemini import VertexLLM
@ -80,6 +82,15 @@ def _vertex_batch_list_payload(response: _VertexBatchListJsonSource) -> dict[str
return response.json()
def _vertex_batch_create_error(error: httpx.HTTPStatusError) -> MaskedHTTPStatusError:
body: Final = mask_sensitive_info(error.response.text)
return MaskedHTTPStatusError(
httpx.HTTPStatusError(f"{error}\n{body}", request=error.request, response=error.response),
message=body,
text=body,
)
class VertexAIBatchPrediction(VertexLLM):
def __init__(self, gcs_bucket_name: str, *args, **kwargs):
super().__init__(*args, **kwargs)
@ -165,11 +176,19 @@ class VertexAIBatchPrediction(VertexLLM):
headers=headers,
)
response: Final = sync_handler.post(
url=api_base,
headers=headers,
data=json.dumps(vertex_batch_request),
)
try:
response: Final = sync_handler.post(
url=api_base,
headers=headers,
data=json.dumps(vertex_batch_request),
)
except httpx.HTTPStatusError as e:
litellm.verbose_logger.error(
"Vertex AI batch create failed: status=%s, body=%s",
e.response.status_code,
e.response.text[:1000],
)
raise _vertex_batch_create_error(e) from e
payload_view: Final[_VertexBatchPayloadView] = {"payload": response.json()}
_json_response: Final = payload_view["payload"]
@ -265,13 +284,12 @@ class VertexAIBatchPrediction(VertexLLM):
data=json.dumps(vertex_batch_request),
)
except httpx.HTTPStatusError as e:
error_body: Final = e.response.text
litellm.verbose_logger.error(
"Vertex AI batch create failed: status=%s, body=%s",
e.response.status_code,
error_body[:1000],
e.response.text[:1000],
)
raise
raise _vertex_batch_create_error(e) from e
payload_view: Final[_VertexBatchPayloadView] = {"payload": response.json()}
_json_response: Final = payload_view["payload"]

View file

@ -9,14 +9,41 @@ from litellm._logging import verbose_logger
from litellm._uuid import uuid
from litellm.llms.vertex_ai.common_utils import (
VertexAIError,
VertexAIModelRoute,
convert_vertex_datetime_to_openai_datetime,
get_vertex_ai_model_route,
)
from litellm.llms.vertex_ai.vertex_ai_partner_models.anthropic.transformation import VertexAIAnthropicConfig
from litellm.types.llms.openai import BatchJobStatus, CreateBatchRequest
from litellm.types.llms.vertex_ai import *
from litellm.types.llms.vertex_ai import GenerateContentResponseBody
from litellm.types.llms.vertex_ai import GenerateContentResponseBody, VertexPartnerProvider
from litellm.types.utils import LiteLLMBatch, ModelInfo, Usage
_NATIVE_VERTEX_RESPONSE: Final = TypeAdapter(GenerateContentResponseBody)
_JSON_OBJECT: Final = TypeAdapter(Mapping[str, object])
VERTEX_ANTHROPIC_VERSION: Final = "vertex-2023-10-16"
_ANTHROPIC_PUBLISHER_MODEL_PATH: Final = "publishers/anthropic/models/"
def is_vertex_anthropic_batch_model(model: str) -> bool:
"""
Whether a Vertex batch model (bare, `vertex_ai/`-prefixed or a publisher path) is a Claude model, decided the
way Vertex chat routes Claude. Vertex batches Claude only as `publishers/anthropic/models/<model>` with rows in
Anthropic's Messages shape
"""
bare_model: Final = model.rsplit("/", 1)[-1]
return _ANTHROPIC_PUBLISHER_MODEL_PATH in model or (
get_vertex_ai_model_route(bare_model) is VertexAIModelRoute.PARTNER_MODELS
and VertexPartnerProvider.claude.value in bare_model
)
def as_json_object(value: object) -> Mapping[str, object] | None:
return _JSON_OBJECT.validate_python(value) if isinstance(value, dict) else None
def is_vertex_anthropic_batch_output_response(response_body: Mapping[str, object]) -> bool:
return response_body.get("type") == "message" and isinstance(response_body.get("usage"), dict)
def _int_field(mapping: Mapping[str, object], key: str) -> int:
@ -43,8 +70,22 @@ def is_vertex_embedding_batch_output_response(response_body: Mapping[str, object
return isinstance(response_body.get("embedding"), dict)
def is_vertex_anthropic_batch_output_row(vertex_output_row: Mapping[str, object]) -> bool:
"""
Whether a Vertex batch output row came from a Claude (Anthropic Messages) request. Vertex echoes only the
request's top-level `custom_id` on these rows, with an Anthropic `message` response on success and an empty
response plus a `status` on failure, so a Claude input file read back is never mistaken for output
"""
if not ("custom_id" in vertex_output_row and "processed_time" in vertex_output_row):
return False
response: Final = as_json_object(vertex_output_row.get("response"))
return (response is not None and is_vertex_anthropic_batch_output_response(response)) or bool(
vertex_output_row.get("status")
)
def is_native_vertex_batch_output_row(row: Mapping[str, object]) -> bool:
return isinstance(row.get("request"), dict)
return isinstance(row.get("request"), dict) or is_vertex_anthropic_batch_output_row(row)
class NativeVertexBatchCostCalculator(Protocol):
@ -72,6 +113,9 @@ def _native_vertex_row_usage(
response_body: Mapping[str, object],
calculate_usage: Callable[[GenerateContentResponseBody], Usage],
) -> Usage | None:
anthropic_usage: Final = as_json_object(response_body.get("usage"))
if is_vertex_anthropic_batch_output_response(response_body) and anthropic_usage is not None:
return VertexAIAnthropicConfig().calculate_usage(usage_object=anthropic_usage, reasoning_content=None)
if "usageMetadata" not in response_body:
if not is_vertex_embedding_batch_output_response(response_body):
return None
@ -97,21 +141,24 @@ def native_vertex_batch_row_stats(
"""
Usage and cost of one native Vertex predictions.jsonl row, a
`{"request": ..., "response": {"candidates": [...], "usageMetadata": {...}, "modelVersion": ...}}`
generateContent object or a `{"request": ..., "response": {"embedding": {...}, "usageMetadata": {...}}}`
embedding object (an embedding row without `usageMetadata` is billed from its documented `tokenCount`).
`model_name` (the deployment model) prices the row unless it is a wildcard, else its own `modelVersion`
does, else the wildcard name so explicit deployment prices still apply; a row without a response, a
generateContent row without `response.usageMetadata`, and a row whose response fails validation are
generateContent object, a `{"request": ..., "response": {"embedding": {...}, "usageMetadata": {...}}}`
embedding object (an embedding row without `usageMetadata` is billed from its documented `tokenCount`) or a
`{"custom_id": ..., "request": ..., "response": {"type": "message", "model": ..., "usage": {...}}}` Claude object.
`model_name` (the deployment model) prices the row unless it is a wildcard, else its own `modelVersion` (Claude
rows: `model`) does, else the wildcard name so explicit deployment prices still apply; a row without a response,
a generateContent row without `response.usageMetadata`, and a row whose response fails validation are
None (failed).
"""
response_body: Final = row.get("response")
if not isinstance(response_body, dict):
response_body: Final = as_json_object(row.get("response"))
if response_body is None:
return None
usage: Final = _native_vertex_row_usage(response_body, calculate_usage)
if usage is None:
return None
total_tokens: Final = usage.total_tokens or (usage.prompt_tokens + usage.completion_tokens)
model_version: Final = response_body.get("modelVersion")
model_version: Final = response_body.get(
"model" if is_vertex_anthropic_batch_output_response(response_body) else "modelVersion"
)
deployment_model: Final = model_name if model_name and "*" not in model_name else None
model: Final = deployment_model or (model_version if isinstance(model_version, str) else model_name)
if model is None:

View file

@ -19,6 +19,7 @@ from typing_extensions import ReadOnly, Required
import litellm
from litellm._uuid import uuid
from litellm.constants import DEFAULT_CHAT_COMPLETION_PARAM_VALUES, RESPONSE_FORMAT_TOOL_NAME
from litellm.files.types import FileContentStreamingResult
from litellm.files.utils import FilesAPIUtils
from litellm.litellm_core_utils.cloud_storage_security import (
@ -30,11 +31,13 @@ from litellm.litellm_core_utils.cloud_storage_security import (
split_configured_cloud_bucket_name,
validate_managed_cloud_file_id,
)
from litellm.litellm_core_utils.core_helpers import as_str_mapping, iter_items, normalize_drop_params
from litellm.litellm_core_utils.litellm_logging import Logging
from litellm.litellm_core_utils.prompt_templates.common_utils import (
extract_file_data,
extract_file_metadata,
)
from litellm.llms.anthropic.chat.transformation import ANTHROPIC_TOOL_NAME_REVERSE_MAP_KEY
from litellm.llms.base_llm.base_utils import map_developer_role_to_system_role
from litellm.llms.base_llm.chat.transformation import BaseLLMException
from litellm.llms.base_llm.files.batch_records import responses_batch_body_to_chat_body
@ -43,7 +46,14 @@ from litellm.llms.base_llm.files.transformation import (
BaseFileUploadStream,
LiteLLMLoggingObj,
)
from litellm.llms.vertex_ai.batches.transformation import vertex_embedding_prompt_token_count
from litellm.llms.vertex_ai.batches.transformation import (
VERTEX_ANTHROPIC_VERSION,
as_json_object,
is_vertex_anthropic_batch_model,
is_vertex_anthropic_batch_output_response,
is_vertex_anthropic_batch_output_row,
vertex_embedding_prompt_token_count,
)
from litellm.llms.vertex_ai.common_utils import (
convert_vertex_datetime_to_openai_datetime,
get_vertex_ai_fine_tuned_endpoint_id,
@ -55,6 +65,7 @@ from litellm.llms.vertex_ai.gemini.vertex_and_google_ai_studio_gemini import (
from litellm.llms.vertex_ai.gemini_embeddings.batch_embed_content_transformation import (
transform_openai_input_gemini_embed_content,
)
from litellm.llms.vertex_ai.vertex_ai_partner_models.anthropic.transformation import VertexAIAnthropicConfig
from litellm.types.files import StreamingMediaUploadConfig
from litellm.types.llms.openai import (
AllMessageValues,
@ -75,6 +86,7 @@ from litellm.types.utils import (
ModelResponse,
Usage,
)
from litellm.utils import get_non_default_completion_params, get_optional_params
from ..common_utils import VertexAIError
from ..vertex_llm_base import VertexBase
@ -83,6 +95,7 @@ _GCP_LABEL_VALUE_MAX_LEN: Final = 63
_CUSTOM_ID_RAW_LABEL_PREFIX: Final = "b32_"
_VERTEX_BATCH_KEY_FIELD: Final = "key"
_MANAGED_GCS_MODEL_PATH_PATTERN: Final = re.compile(r"publishers/[^/]+/models/([^/?]+)")
_PUBLISHER_MODEL_PATH_PATTERN: Final = re.compile(r"publishers/[^/]+/models/")
_EMBED_REQUEST_FIELD_BY_GEMINI_PARAM: Final = (
("outputDimensionality", "output_dimensionality"),
("taskType", "task_type"),
@ -220,10 +233,14 @@ def _get_litellm_batch_custom_id(vertex_output_row: Mapping[str, object]) -> str
"""
Resolve the OpenAI `custom_id` for a Vertex batch output row.
Embedding rows carry it in the top-level `key` field that Vertex echoes back;
`generateContent` rows have no such field, so it is smuggled through request
labels instead (see `_set_litellm_batch_custom_id_labels`).
Claude rows carry it in their own top-level `custom_id` field and embedding rows in
the top-level `key` field that Vertex echoes back; `generateContent` rows have no
such field, so it is smuggled through request labels instead (see
`_set_litellm_batch_custom_id_labels`).
"""
anthropic_custom_id: Final = vertex_output_row.get("custom_id")
if anthropic_custom_id is not None:
return str(anthropic_custom_id)
key = vertex_output_row.get(_VERTEX_BATCH_KEY_FIELD)
if key is not None:
return unquote(str(key))
@ -284,6 +301,12 @@ def _is_vertex_generate_content_batch_output_row(vertex_output_row: Mapping[str,
)
def _is_vertex_chat_batch_output_row(vertex_output_row: Mapping[str, object]) -> bool:
return _is_vertex_generate_content_batch_output_row(vertex_output_row) or is_vertex_anthropic_batch_output_row(
vertex_output_row
)
def _try_parse_vertex_batch_output_row(line: bytes) -> _VertexBatchRow | None:
try:
row: Final = _parse_vertex_batch_output_row(line.decode("utf-8"))
@ -405,6 +428,37 @@ def _openai_batch_output_row(
}
def _vertex_anthropic_batch_output_row_to_openai(
custom_id: str,
response: object,
mock_httpx_response: httpx.Response,
) -> _OpenAIBatchOutputRow:
"""
One OpenAI batch output row from a Vertex Claude row's Anthropic `message` response
"""
anthropic_response: Final = as_json_object(response)
if anthropic_response is None or not is_vertex_anthropic_batch_output_response(anthropic_response):
return _openai_batch_output_row(
custom_id=custom_id,
error_code="vertex_ai_error",
error_message="Claude batch row has no message response",
)
try:
model_response: Final = VertexAIAnthropicConfig().transform_parsed_response(
completion_response=dict(anthropic_response),
raw_response=mock_httpx_response,
model_response=ModelResponse(),
json_mode=True,
)
except Exception as e: # noqa: BLE001 # one malformed row must fail alone, not the whole output file
return _openai_batch_output_row(
custom_id=custom_id,
error_code="transformation_error",
error_message=f"Failed to transform response: {e}",
)
return _openai_batch_output_row(custom_id=custom_id, body=model_response.model_dump())
def _split_vertex_batch_key(vertex_output_row: Mapping[str, object]) -> tuple[str, int, int]:
"""
Resolve `(custom_id, index within that custom_id, group size)` for a Vertex batch
@ -679,21 +733,123 @@ def _openai_batch_jsonl_entry_to_vertex_embeddings_rows(
)
_CLAUDE_BATCH_ROW_FIXED_PARAMS: Final = frozenset(
("model", "messages", "custom_llm_provider", "drop_params", "stream", "stream_options")
)
_CLAUDE_BATCH_OPENAI_PARAMS: Final = frozenset(DEFAULT_CHAT_COMPLETION_PARAM_VALUES) - {"metadata"}
_CLAUDE_BATCH_DEPLOYMENT_PARAMS: Final = (
_CLAUDE_BATCH_OPENAI_PARAMS - _CLAUDE_BATCH_ROW_FIXED_PARAMS - {"extra_headers"}
) | {"drop_params"}
def _claude_batch_deployment_params(litellm_params: Mapping[str, object]) -> Mapping[str, object]:
return MappingProxyType(
{key: value for key, value in litellm_params.items() if key in _CLAUDE_BATCH_DEPLOYMENT_PARAMS}
)
def _names_the_response_format_tool(tool: object) -> bool:
match tool:
case {"function": {"name": str(name)}}:
return name == RESPONSE_FORMAT_TOOL_NAME
case _:
return False
def _openai_batch_jsonl_entry_to_vertex_anthropic_row(
custom_id: object,
messages: list[AllMessageValues],
request_params: Mapping[str, object],
model: str,
deployment_params: Mapping[str, object],
) -> Mapping[str, object]:
"""
Transforms an OpenAI chat batch entry into the Anthropic Messages row Vertex batches Claude with,
`{"custom_id": ..., "request": {"messages": [...], "anthropic_version": "vertex-2023-10-16", "max_tokens": ...}}`
"""
if custom_id is None:
raise ValueError("custom_id is required on Claude lines, since Claude output rows are matched back only by it")
config: Final = VertexAIAnthropicConfig()
bare_model: Final = model.rsplit("/", 1)[-1]
params: Final = {**deployment_params, **request_params}
drop_params: Final = litellm.drop_params is True or normalize_drop_params(params.get("drop_params")) is True
chat_params: Final = {
**get_non_default_completion_params(params),
**{key: value for key, value in params.items() if key in _CLAUDE_BATCH_OPENAI_PARAMS},
}
if any(map(_names_the_response_format_tool, iter_items(chat_params.get("tools") or ()))):
raise ValueError(
f"tool name {RESPONSE_FORMAT_TOOL_NAME!r} is reserved for response_format on Claude, "
"and batch output cannot tell a user tool by that name apart, so rename it"
)
optional_params: Final[Mapping[str, object]] = get_optional_params(
model=bare_model,
custom_llm_provider="vertex_ai",
drop_params=drop_params,
messages=messages,
**{ # pyright: ignore[reportArgumentType] # batch JSONL bodies are untyped; get_optional_params checks each param as chat does
key: value for key, value in chat_params.items() if key not in _CLAUDE_BATCH_ROW_FIXED_PARAMS
},
)
request_state: Final[dict[str, object]] = {"drop_params": drop_params}
anthropic_request: Final[Mapping[str, object]] = config.transform_request(
model=bare_model,
messages=messages,
optional_params={
**{key: value for key, value in optional_params.items() if key not in ("json_mode", "extra_body")},
"anthropic_version": VERTEX_ANTHROPIC_VERSION,
},
litellm_params=request_state,
headers={},
)
renamed_tools: Final = as_str_mapping(request_state.get(ANTHROPIC_TOOL_NAME_REVERSE_MAP_KEY))
if renamed_tools:
raise ValueError(
f"tool names {sorted(map(str, renamed_tools.values()))} must match ^[a-zA-Z0-9_-]{{1,128}}$ for Claude on Vertex, "
"and batch output cannot map a renamed tool back, so rename these tools"
)
extra_body: Final = as_json_object(optional_params.get("extra_body"))
request: Final[Mapping[str, object]] = (
anthropic_request
if extra_body is None
else {
**anthropic_request,
**config.transform_extra_body(
extra_body=extra_body, request=anthropic_request, model=bare_model, litellm_params={}
),
}
)
return {"custom_id": str(custom_id), "request": request}
def _openai_batch_jsonl_entry_to_vertex_rows(
openai_entry: dict[str, Any],
map_openai_to_vertex_params: Callable[[dict[str, object]], dict[str, object]],
batch_model: str | None = None,
deployment_params: Mapping[str, object] = MappingProxyType({}),
) -> tuple[Mapping[str, object], ...]:
"""
Transforms a single OpenAI JSONL batch entry into the Vertex rows it maps to.
`batch_model` is the deployment model the batch runs against and wins over the entry's own `body.model`,
which picks the row shape: Anthropic Messages for Claude, `GenerateContentRequest` otherwise.
jsonl body for vertex is {"request": <request_body>}
Example Vertex jsonl
{"request":{"contents": [{"role": "user", "parts": [{"text": "What is the relation between the following video and image samples?"}, {"fileData": {"fileUri": "gs://cloud-samples-data/generative-ai/video/animals.mp4", "mimeType": "video/mp4"}}, {"fileData": {"fileUri": "gs://cloud-samples-data/generative-ai/image/cricket.jpeg", "mimeType": "image/jpeg"}}]}]}}
"""
entry_body: Final = openai_entry.get("body")
model: Final = batch_model or (str(entry_body.get("model") or "") if isinstance(entry_body, dict) else "")
is_anthropic_model: Final = is_vertex_anthropic_batch_model(model)
if _is_embeddings_batch_entry(openai_entry):
if is_anthropic_model:
raise VertexAIError(
status_code=400,
message=f"Vertex AI batch embeddings are not supported for Claude models ('{model}')",
)
return _openai_batch_jsonl_entry_to_vertex_embeddings_rows(openai_entry)
if not isinstance(openai_entry.get("body"), dict):
if not isinstance(entry_body, dict):
raise TypeError("`body` on batch requests must be a JSON object, but was missing or not an object")
openai_request_body: Final = openai_entry["body"]
chat_request_body: Final = (
@ -701,8 +857,21 @@ def _openai_batch_jsonl_entry_to_vertex_rows(
if _is_responses_batch_entry(openai_entry)
else openai_request_body
)
chat_messages: Final = map_developer_role_to_system_role(chat_request_body.get("messages", []))
if is_anthropic_model:
return (
_openai_batch_jsonl_entry_to_vertex_anthropic_row(
custom_id=openai_entry.get("custom_id"),
messages=chat_messages,
request_params={
key: value for key, value in chat_request_body.items() if key not in ("model", "messages")
},
model=model,
deployment_params=deployment_params,
),
)
vertex_request_body: Final = transform_request_body(
messages=map_developer_role_to_system_role(chat_request_body.get("messages", [])),
messages=chat_messages,
model=chat_request_body.get("model", ""),
optional_params=map_openai_to_vertex_params(chat_request_body),
custom_llm_provider="vertex_ai",
@ -854,15 +1023,24 @@ class _OpenAIToVertexBatchUploadStream(BaseFileUploadStream):
self,
openai_file_content: FileTypes,
map_openai_to_vertex_params: Callable[[dict[str, object]], dict[str, object]],
batch_model: str | None = None,
deployment_params: Mapping[str, object] = MappingProxyType({}),
) -> None:
self._openai_file_content = openai_file_content
self._map_openai_to_vertex_params = map_openai_to_vertex_params
self._batch_model = batch_model
self._deployment_params = deployment_params
def _iter_vertex_jsonl_chunks(self) -> Iterator[bytes]:
first = True
for lineno, entry in _iter_openai_jsonl_entries(self._openai_file_content):
try:
vertex_rows = _openai_batch_jsonl_entry_to_vertex_rows(entry, self._map_openai_to_vertex_params)
vertex_rows = _openai_batch_jsonl_entry_to_vertex_rows(
entry,
self._map_openai_to_vertex_params,
batch_model=self._batch_model,
deployment_params=self._deployment_params,
)
except Exception as e:
raise VertexAIError(
status_code=400,
@ -918,16 +1096,31 @@ class _RawFileUploadStream(BaseFileUploadStream):
return _iter_raw_file_chunks(self._file_content)
def _deployment_batch_model(litellm_params: Mapping[str, object]) -> str | None:
model: Final = litellm_params.get("model")
return model.removeprefix("vertex_ai/") if isinstance(model, str) and model else None
def _publisher_model_path(raw_model: str) -> str:
if _PUBLISHER_MODEL_PATH_PATTERN.search(raw_model):
return raw_model
publisher: Final = "anthropic" if is_vertex_anthropic_batch_model(raw_model) else "google"
return f"publishers/{publisher}/models/{raw_model}"
def _managed_batch_object_name(raw_model: str, *, passthrough: bool) -> str:
endpoint_id: Final = get_vertex_ai_fine_tuned_endpoint_id(raw_model)
model_path: Final = (
f"endpoints/{endpoint_id}"
if endpoint_id is not None
else (raw_model if "publishers/google/models" in raw_model else f"publishers/google/models/{raw_model}")
)
safe_model_path: Final = sanitize_cloud_object_path(model_path, fallback="model")
model_path: Final = f"endpoints/{endpoint_id}" if endpoint_id is not None else _publisher_model_path(raw_model)
prefix: Final = _PASSTHROUGH_MANAGED_GCS_PREFIX if passthrough else VERTEX_AI_MANAGED_GCS_PREFIX
return f"{prefix}{safe_model_path}/{uuid.uuid4()}"
return f"{prefix}{_safe_managed_model_path(model_path)}/{uuid.uuid4()}"
def _safe_managed_model_path(model_path: str) -> str:
if not is_vertex_anthropic_batch_model(model_path):
return sanitize_cloud_object_path(model_path, fallback="model")
return "@".join(
sanitize_cloud_object_path(version_part, fallback="model") for version_part in model_path.split("@")
)
class VertexAIFilesConfig(VertexBase, BaseFilesConfig):
@ -1030,10 +1223,10 @@ class VertexAIFilesConfig(VertexBase, BaseFilesConfig):
fallback_filename="file",
)
def _get_configured_bucket_name(self, litellm_params: dict) -> str:
def _get_configured_bucket_name(self, litellm_params: Mapping[str, object]) -> str:
configured_bucket_name: Final = litellm_params.get("gcs_bucket_name") or litellm_params.get("bucket_name")
bucket_name: Final = (
litellm_params.get("gcs_bucket_name")
or litellm_params.get("bucket_name")
(configured_bucket_name if isinstance(configured_bucket_name, str) else None)
or os.getenv("GCS_BATCH_BUCKET_NAME")
or os.getenv("GCS_BUCKET_NAME")
)
@ -1047,7 +1240,7 @@ class VertexAIFilesConfig(VertexBase, BaseFilesConfig):
api_key: str | None,
model: str,
optional_params: dict,
litellm_params: dict,
litellm_params: Mapping[str, object],
data: CreateFileRequest,
) -> str:
"""
@ -1071,11 +1264,10 @@ class VertexAIFilesConfig(VertexBase, BaseFilesConfig):
raise ValueError("file is required")
if purpose is None:
raise ValueError("purpose is required")
configured_model: Final = litellm_params.get("model")
object_name = self.get_object_name(
file_data,
purpose,
deployment_model=configured_model if isinstance(configured_model, str) else None,
deployment_model=_deployment_batch_model(litellm_params),
passthrough=is_passthrough_batch_upload(data, litellm_params),
)
if object_prefix:
@ -1126,7 +1318,7 @@ class VertexAIFilesConfig(VertexBase, BaseFilesConfig):
model: str,
create_file_data: CreateFileRequest,
optional_params: dict,
litellm_params: dict,
litellm_params: Mapping[str, object],
) -> bytes | str | dict:
"""
2 Cases:
@ -1157,6 +1349,8 @@ class VertexAIFilesConfig(VertexBase, BaseFilesConfig):
body_stream=_OpenAIToVertexBatchUploadStream(
file_data,
self._map_openai_to_vertex_params,
batch_model=_deployment_batch_model(litellm_params),
deployment_params=_claude_batch_deployment_params(litellm_params),
),
content_type="application/json",
)
@ -1207,7 +1401,7 @@ class VertexAIFilesConfig(VertexBase, BaseFilesConfig):
def get_error_class(self, error_message: str, status_code: int, headers: dict | Headers) -> BaseLLMException:
return VertexAIError(status_code=status_code, message=error_message, headers=headers)
def _parse_gcs_uri(self, file_id: str, litellm_params: dict | None = None) -> tuple[str, str]:
def _parse_gcs_uri(self, file_id: str, litellm_params: Mapping[str, object] | None = None) -> tuple[str, str]:
"""
Validate a managed GCS file_id and return (bucket, url-encoded-object-path).
"""
@ -1225,7 +1419,7 @@ class VertexAIFilesConfig(VertexBase, BaseFilesConfig):
self,
file_id: str,
optional_params: dict,
litellm_params: dict,
litellm_params: Mapping[str, object],
) -> tuple[str, dict]:
bucket, encoded_object = self._parse_gcs_uri(file_id, litellm_params)
url: Final = f"https://storage.googleapis.com/storage/v1/b/{bucket}/o/{encoded_object}"
@ -1255,7 +1449,7 @@ class VertexAIFilesConfig(VertexBase, BaseFilesConfig):
self,
file_id: str,
optional_params: dict,
litellm_params: dict,
litellm_params: Mapping[str, object],
) -> tuple[str, dict]:
bucket, encoded_object = self._parse_gcs_uri(file_id, litellm_params)
url: Final = f"https://storage.googleapis.com/storage/v1/b/{bucket}/o/{encoded_object}"
@ -1298,7 +1492,7 @@ class VertexAIFilesConfig(VertexBase, BaseFilesConfig):
self,
file_content_request,
optional_params: dict,
litellm_params: dict,
litellm_params: Mapping[str, object],
) -> tuple[str, dict]:
file_id: Final = file_content_request.get("file_id", "")
bucket, encoded_object = self._parse_gcs_uri(file_id, litellm_params)
@ -1395,7 +1589,7 @@ class VertexAIFilesConfig(VertexBase, BaseFilesConfig):
headers=MappingProxyType({**headers, "content-length": str(len(transformed_content))}),
)
if not _is_vertex_generate_content_batch_output_row(first_row):
if not _is_vertex_chat_batch_output_row(first_row):
return FileContentStreamingResult(stream_iterator=replayed_stream, headers=headers)
return FileContentStreamingResult(
@ -1479,10 +1673,7 @@ class VertexAIFilesConfig(VertexBase, BaseFilesConfig):
# first line is not valid UTF-8/JSON) raises and falls through to the
# passthrough below, leaving the content untouched.
first_row: Final = _parse_vertex_batch_output_row(first_line)
if not (
_is_vertex_embeddings_batch_output_row(first_row)
or _is_vertex_generate_content_batch_output_row(first_row)
):
if not (_is_vertex_embeddings_batch_output_row(first_row) or _is_vertex_chat_batch_output_row(first_row)):
return content
context: Final = _new_vertex_batch_output_row_transform_context()
@ -1547,6 +1738,13 @@ class VertexAIFilesConfig(VertexBase, BaseFilesConfig):
error_message=status,
)
if is_vertex_anthropic_batch_output_row(vertex_output):
return _vertex_anthropic_batch_output_row_to_openai(
custom_id=custom_id,
response=vertex_output.get("response"),
mock_httpx_response=mock_httpx_response,
)
# Transform successful response using existing transformation
vertex_response: Final = vertex_output.get("response", {})

View file

@ -2296,6 +2296,36 @@ async def test_native_vertex_rows_route_to_vertex_cost_path_without_flag(monkeyp
assert {(call["model"], call["custom_llm_provider"]) for call in calls} == {("gemini-2.5-flash", "vertex_ai")}
@pytest.mark.asyncio
async def test_raw_vertex_claude_rows_are_priced_like_with_the_native_output_flag(monkeypatch):
claude_message: Final = {
"id": "msg_1",
"type": "message",
"role": "assistant",
"model": "claude-sonnet-4-6",
"content": [{"type": "text", "text": "Blue."}],
"stop_reason": "end_turn",
"usage": {"input_tokens": 12, "output_tokens": 5},
}
rows: Final = [
{"custom_id": "ok", "processed_time": "2026-10-29T03:00:00Z", "response": claude_message, "status": ""},
{"custom_id": "bad", "processed_time": "2026-10-29T03:00:00Z", "response": {}, "status": "Bad Request"},
]
async def cost_with_flag(flag: bool) -> bu.BatchCostUsageResult:
monkeypatch.setattr(litellm, "disable_vertex_batch_output_transformation", flag, raising=False)
return await bu.calculate_batch_cost_and_usage(
file_content_dictionary=rows, custom_llm_provider="vertex_ai", model_name="vertex_ai/claude-sonnet-4-6"
)
unflagged: Final = await cost_with_flag(False)
flagged: Final = await cost_with_flag(True)
assert unflagged.cost > 0
assert (unflagged.successful_requests, unflagged.failed_requests) == (1, 1)
assert (unflagged.cost, unflagged.usage.total_tokens) == (flagged.cost, flagged.usage.total_tokens)
@pytest.mark.asyncio
async def test_openai_shaped_vertex_rows_keep_the_generic_path_without_flag(monkeypatch):
monkeypatch.setattr(litellm, "disable_vertex_batch_output_transformation", False, raising=False)

View file

@ -30,6 +30,7 @@ from __future__ import annotations
import asyncio
import json
from typing import Final
from unittest.mock import AsyncMock, MagicMock, patch
import httpx
@ -41,9 +42,11 @@ from litellm.llms.vertex_ai.batches.handler import ( # noqa: E402
VertexAIBatchPrediction,
)
from litellm.llms.vertex_ai.common_utils import VertexAIError # noqa: E402
from litellm.proxy.utils import handle_exception_on_proxy # noqa: E402
from litellm.types.utils import LiteLLMBatch # noqa: E402
HMOD = "litellm.llms.vertex_ai.batches.handler"
_CLIENT_ERROR_429: Final = "Client error '429 Too Many Requests' for url 'https://x/batchPredictionJobs'"
TOKEN = "ya29.fake-access-token"
PROJECT = "my-project"
LOCATION = "us-central1"
@ -370,19 +373,19 @@ def test_create_batch_sync_endpoint_without_deployed_model_raises_400():
client.post.assert_not_called()
def test_create_batch_sync_httpstatuserror_propagates():
"""``HTTPHandler.post`` raises for non-2xx via ``raise_for_status``; the
sync create path must surface that error, not swallow it."""
def test_create_batch_sync_httpstatuserror_carries_vertex_status_body_and_headers():
h = _make_handler()
client = MagicMock()
request = httpx.Request("POST", "https://x/batchPredictionJobs")
err_response = httpx.Response(status_code=500, request=request, text="boom")
err_response: Final = httpx.Response(
status_code=429, request=request, text="boom", headers={"retry-after": "30"}
)
client.post.side_effect = httpx.HTTPStatusError(
"boom", request=request, response=err_response
_CLIENT_ERROR_429, request=request, response=err_response
)
with patch(f"{HMOD}.get_httpx_client", return_value=client):
with pytest.raises(httpx.HTTPStatusError):
with pytest.raises(httpx.HTTPStatusError) as exc_info:
h.create_batch(
_is_async=False,
create_batch_data=CREATE_DATA,
@ -393,6 +396,11 @@ def test_create_batch_sync_httpstatuserror_propagates():
timeout=600.0,
max_retries=None,
)
assert exc_info.value.response.status_code == 429
assert exc_info.value.response.headers["retry-after"] == "30"
assert handle_exception_on_proxy(exc_info.value).code == "429"
assert (getattr(exc_info.value, "message", None), getattr(exc_info.value, "text", None)) == ("boom", "boom")
assert str(exc_info.value).splitlines() == [_CLIENT_ERROR_429, "boom"]
def test_create_batch_input_file_id_without_model_raises_400_before_post():
@ -800,14 +808,16 @@ def test_cancel_batch_sync_httpstatuserror_logged_and_reraised():
client.get.assert_not_called()
def test_create_batch_async_httpstatuserror_logged_and_reraised():
def test_create_batch_async_httpstatuserror_carries_vertex_status_body_and_headers():
h = _make_handler()
async_client = MagicMock()
request = httpx.Request("POST", "https://x/batchPredictionJobs")
err_response = httpx.Response(status_code=500, request=request, text="boom")
err_response: Final = httpx.Response(
status_code=429, request=request, text="boom", headers={"retry-after": "30"}
)
async_client.post = AsyncMock(
side_effect=httpx.HTTPStatusError(
"boom", request=request, response=err_response
_CLIENT_ERROR_429, request=request, response=err_response
)
)
@ -825,8 +835,13 @@ def test_create_batch_async_httpstatuserror_logged_and_reraised():
timeout=600.0,
max_retries=None,
)
with pytest.raises(httpx.HTTPStatusError):
with pytest.raises(httpx.HTTPStatusError) as exc_info:
_run(coro)
assert exc_info.value.response.status_code == 429
assert exc_info.value.response.headers["retry-after"] == "30"
assert handle_exception_on_proxy(exc_info.value).code == "429"
assert (getattr(exc_info.value, "message", None), getattr(exc_info.value, "text", None)) == ("boom", "boom")
assert str(exc_info.value).splitlines() == [_CLIENT_ERROR_429, "boom"]
def test_async_retrieve_batch_non_200_raises():
@ -1043,15 +1058,7 @@ async def test_vertex_list_batches(monkeypatch):
@pytest.mark.asyncio
async def test_vertex_async_create_batch_logs_error_body_on_http_error():
"""
When Vertex AI returns an HTTP error (e.g. 400), _async_create_batch should
re-raise httpx.HTTPStatusError (not swallow it) and log the response body.
Before the fix the error body was lost because AsyncHTTPHandler.post()
calls raise_for_status() internally, raising before the handler's own
status-code check could log the body.
"""
async def test_vertex_async_create_batch_httpstatuserror_carries_vertex_error_body():
from litellm.llms.vertex_ai.batches.handler import VertexAIBatchPrediction
handler = VertexAIBatchPrediction(gcs_bucket_name="test-bucket")
@ -1081,4 +1088,4 @@ async def test_vertex_async_create_batch_logs_error_body_on_http_error():
)
assert exc_info.value.response.status_code == 400
assert "gemini-2.0-flash" in exc_info.value.response.text
assert "Do not support publisher model gemini-2.0-flash" in str(exc_info.value)

View file

@ -21,6 +21,8 @@ import pytest
from litellm.llms.vertex_ai.batches.transformation import ( # noqa: E402
VertexAIBatchTransformation,
is_vertex_anthropic_batch_model,
native_vertex_batch_row_stats,
)
from litellm.llms.vertex_ai.common_utils import VertexAIError # noqa: E402
from litellm.types.utils import LiteLLMBatch # noqa: E402
@ -493,3 +495,61 @@ def test_get_gcs_uri_prefix_keeps_passthrough_segment_so_output_lands_beside_inp
T._get_gcs_uri_prefix_from_file(PASSTHROUGH_INPUT_FILE)
== "gs://litellm-testing-bucket/litellm-vertex-files/passthrough/publishers/google/models/gemini-2.5-flash"
)
CLAUDE_INPUT_FILE = (
"gs://litellm-testing-bucket/litellm-vertex-files/publishers/anthropic/models/claude-sonnet-4-6/uuid-1"
)
def test_claude_batch_job_targets_the_anthropic_publisher_model():
job: Final = T.transform_openai_batch_request_to_vertex_ai_batch_request(
{"input_file_id": CLAUDE_INPUT_FILE}, vertex_project="p", vertex_location="us-east5"
)
assert job["model"] == "publishers/anthropic/models/claude-sonnet-4-6"
@pytest.mark.parametrize(
("model", "expected"),
[
("claude-sonnet-4-6", True),
("vertex_ai/claude-opus-4-1@20250805", True),
("publishers/anthropic/models/claude-haiku-4-5", True),
("gemini-2.5-flash", False),
("publishers/google/models/gemini-2.5-flash", False),
],
)
def test_is_vertex_anthropic_batch_model(model: str, expected: bool):
assert is_vertex_anthropic_batch_model(model) is expected
def test_claude_output_row_is_costed_from_anthropic_usage_and_its_own_model():
def cost_calculator(usage, model, custom_llm_provider=None, model_info=None, vertex_location=None) -> tuple[float, float]:
if model != "claude-sonnet-4-6":
return 0.0, 0.0
return usage.prompt_tokens * 0.001, usage.completion_tokens * 0.002
stats: Final = native_vertex_batch_row_stats(
{
"custom_id": "r1",
"request": {"messages": [{"role": "user", "content": "hi"}], "anthropic_version": "vertex-2023-10-16"},
"response": {
"id": "msg_vrtx_1",
"type": "message",
"role": "assistant",
"model": "claude-sonnet-4-6",
"content": [{"type": "text", "text": "hello"}],
"stop_reason": "end_turn",
"usage": {"input_tokens": 12, "output_tokens": 5},
},
},
None,
model_info=None,
calculate_usage=lambda _: pytest.fail("Claude rows must not go through the Gemini usage parser"),
cost_calculator=cost_calculator,
)
assert stats is not None, "a successful Claude row must be costed, not counted as failed"
assert (stats.usage.prompt_tokens, stats.usage.completion_tokens, stats.total_tokens) == (12, 5, 17)
assert (stats.prompt_cost, stats.completion_cost) == pytest.approx((0.012, 0.010))
assert (stats.prompt_cost, stats.completion_cost) == pytest.approx((12 * 0.001, 5 * 0.002))

View file

@ -5,7 +5,8 @@ Includes tests for Vertex AI batch output transformation to OpenAI format.
import json
import urllib.parse
from collections.abc import Mapping
from collections.abc import Mapping, Sequence
from typing import Final
from types import MappingProxyType
from urllib.parse import parse_qs, urlparse
@ -13,6 +14,8 @@ import httpx
import pytest
from unittest.mock import MagicMock
import litellm
from litellm.llms.base_llm.files.transformation import BaseFileUploadStream
from litellm.llms.vertex_ai.files.transformation import (
VertexAIFilesConfig,
_get_litellm_batch_custom_id_from_labels,
@ -165,6 +168,17 @@ class TestBatchObjectNaming:
object_name = config._get_gcs_object_name_from_batch_jsonl([{"body": {"model": "gemini-2.5-flash"}}])
assert object_name.startswith("litellm-vertex-files/publishers/google/models/gemini-2.5-flash/")
@pytest.mark.parametrize(
("model", "stored_model"),
[
("multimodalembedding@001", "publishers/google/models/multimodalembedding_001/"),
("claude-haiku-4-5@20251001", "publishers/anthropic/models/claude-haiku-4-5@20251001/"),
],
)
def test_only_claude_models_keep_the_at_version_in_their_object_name(self, config, model, stored_model):
object_name: Final = config._get_gcs_object_name_from_batch_jsonl([{"body": {"model": model}}])
assert object_name.startswith(f"litellm-vertex-files/{stored_model}")
def test_should_store_fine_tuned_endpoint_under_endpoints_path(self, config):
"""A numeric endpoint id must not be filed under publishers/google/models/gemini/<id>,
which the batch transformation later mangles into a nonexistent publisher model (LIT-6899)."""
@ -1948,3 +1962,420 @@ class TestVertexEmbeddingsBatchOutputTranslation:
content = json.dumps(legacy_row).encode("utf-8")
assert config._try_transform_vertex_batch_output_to_openai(content) == content
CLAUDE_DEPLOYMENT = {"gcs_bucket_name": "my-bucket", "model": "vertex_ai/claude-sonnet-4-6"}
def _claude_chat_entry(custom_id: str | None, content: str, **body: object) -> dict[str, object]:
entry: Final[dict[str, object]] = {
"method": "POST",
"url": "/v1/chat/completions",
"body": {"model": "claude-sonnet-4-6", "messages": [{"role": "user", "content": content}], **body},
}
return entry if custom_id is None else {"custom_id": custom_id, **entry}
def _uploaded_rows(
config: VertexAIFilesConfig, litellm_params: Mapping[str, object], entries: Sequence[Mapping[str, object]]
) -> list:
upload: Final = config.transform_create_file_request(
model="",
create_file_data={
"file": ("batch.jsonl", "\n".join(json.dumps(entry) for entry in entries).encode(), "application/jsonl"),
"purpose": "batch",
},
optional_params={},
litellm_params=dict(litellm_params),
)
match upload:
case {"streaming_media_upload": {"body_stream": BaseFileUploadStream() as stream}}:
return [json.loads(line) for line in b"".join(stream.iter_bytes()).splitlines()]
case _:
pytest.fail(f"expected a streamed upload, got {upload!r}")
def _claude_output_row(custom_id: str, text: str, content: object = None) -> dict[str, object]:
return {
"custom_id": custom_id,
"status": "",
"processed_time": "2026-10-28T20:37:47.634+00:00",
"response": {
"id": "msg_vrtx_01",
"type": "message",
"role": "assistant",
"model": "claude-sonnet-4-6",
"content": [{"type": "text", "text": text}] if content is None else content,
"stop_reason": "end_turn",
"stop_sequence": None,
"usage": {"input_tokens": 12, "output_tokens": 5},
},
}
class TestVertexClaudeBatch:
"""Claude on Vertex batches only as `publishers/anthropic/models/<model>` with Anthropic Messages rows
(https://github.com/BerriAI/litellm/issues/45671)"""
@pytest.mark.parametrize("claude_model", ["claude-sonnet-4-6", "claude-haiku-4-5@20251001"])
def test_claude_upload_creates_a_batch_job_for_the_anthropic_publisher_model(self, config, claude_model):
from litellm.llms.vertex_ai.batches.transformation import VertexAIBatchTransformation
url: Final = config.get_complete_file_url(
api_base=None,
api_key=None,
model="",
optional_params={},
litellm_params={**CLAUDE_DEPLOYMENT, "model": f"vertex_ai/{claude_model}"},
data={"file": ("batch.jsonl", json.dumps(_claude_chat_entry("r1", "hi")).encode()), "purpose": "batch"},
)
object_name: Final = parse_qs(urlparse(url).query)["name"][0]
job: Final = VertexAIBatchTransformation.transform_openai_batch_request_to_vertex_ai_batch_request(
{"input_file_id": f"gs://my-bucket/{object_name}"}, vertex_project="p", vertex_location="us-east5"
)
assert job["model"] == f"publishers/anthropic/models/{claude_model}", object_name
def test_claude_body_model_without_deployment_is_filed_under_anthropic_publisher(self, config):
object_name: Final = config._get_gcs_object_name_from_batch_jsonl([{"body": {"model": "claude-haiku-4-5"}}])
assert object_name.startswith("litellm-vertex-files/publishers/anthropic/models/claude-haiku-4-5/")
def test_publisher_path_model_is_not_nested_under_google(self, config):
object_name: Final = config._get_gcs_object_name_from_batch_jsonl(
[{"body": {}}], deployment_model="vertex_ai/publishers/anthropic/models/claude-sonnet-4-6"
)
assert object_name.startswith("litellm-vertex-files/publishers/anthropic/models/claude-sonnet-4-6/")
def test_claude_rows_are_uploaded_in_anthropic_messages_shape(self, config):
rows: Final = _uploaded_rows(
config,
CLAUDE_DEPLOYMENT,
[_claude_chat_entry("request-1", "Hello!", max_tokens=50, temperature=0.2)],
)
assert rows == [
{
"custom_id": "request-1",
"request": {
"messages": [{"role": "user", "content": [{"type": "text", "text": "Hello!"}]}],
"max_tokens": 50,
"temperature": 0.2,
"anthropic_version": "vertex-2023-10-16",
},
}
]
def test_claude_system_message_becomes_the_anthropic_system_field(self, config):
entry: Final = _claude_chat_entry(
"r1",
"unused",
max_tokens=10,
messages=[{"role": "system", "content": "be brief"}, {"role": "user", "content": "Hello!"}],
)
(row,) = _uploaded_rows(config, CLAUDE_DEPLOYMENT, [entry])
assert row["request"]["system"] == [{"type": "text", "text": "be brief"}]
assert [message["role"] for message in row["request"]["messages"]] == ["user"]
def test_claude_row_drops_an_unsupported_param_under_deployment_drop_params(self, config, monkeypatch):
monkeypatch.setitem(litellm.model_cost, "claude-zeta-9", {"supports_sampling_params": False})
monkeypatch.setattr(litellm, "drop_params", False)
entry: Final = _claude_chat_entry("r1", "hi", max_tokens=10, top_p=0.5)
(row,) = _uploaded_rows(
config, {**CLAUDE_DEPLOYMENT, "model": "vertex_ai/claude-zeta-9", "drop_params": True}, [entry]
)
assert row["request"]["max_tokens"] == 10
assert "top_p" not in row["request"]
def test_claude_row_drops_a_param_the_deployment_lists_in_additional_drop_params(self, config, monkeypatch):
monkeypatch.setitem(litellm.model_cost, "claude-zeta-9", {"supports_sampling_params": False})
monkeypatch.setattr(litellm, "drop_params", False)
entry: Final = _claude_chat_entry("r1", "hi", max_tokens=10, top_p=0.5)
(row,) = _uploaded_rows(
config,
{**CLAUDE_DEPLOYMENT, "model": "vertex_ai/claude-zeta-9", "additional_drop_params": ["top_p"]},
[entry],
)
assert row["request"]["max_tokens"] == 10
assert "top_p" not in row["request"]
def test_claude_rows_take_deployment_params_as_defaults_the_row_overrides(self, config):
rows: Final = _uploaded_rows(
config,
{**CLAUDE_DEPLOYMENT, "max_tokens": 64, "temperature": 0.3},
[_claude_chat_entry("r1", "hi"), _claude_chat_entry("r2", "hi", max_tokens=10)],
)
assert [(row["request"]["max_tokens"], row["request"]["temperature"]) for row in rows] == [(64, 0.3), (10, 0.3)]
def test_claude_row_with_an_unsupported_param_is_refused_without_drop_params(self, config, monkeypatch):
from litellm.llms.vertex_ai.common_utils import VertexAIError
monkeypatch.setitem(litellm.model_cost, "claude-zeta-9", {"supports_sampling_params": False})
monkeypatch.setattr(litellm, "drop_params", False)
entry: Final = _claude_chat_entry("r1", "hi", max_tokens=10, top_p=0.5)
with pytest.raises(VertexAIError, match=r"line 1 .*top_p=0.5.*drop_params") as exc_info:
_uploaded_rows(config, {**CLAUDE_DEPLOYMENT, "model": "vertex_ai/claude-zeta-9"}, [entry])
assert exc_info.value.status_code == 400
def test_claude_deployment_wins_over_a_gemini_body_model(self, config):
entry: Final = _claude_chat_entry("r1", "hi", max_tokens=10, model="gemini-2.5-flash")
(row,) = _uploaded_rows(config, CLAUDE_DEPLOYMENT, [entry])
assert row["request"]["anthropic_version"] == "vertex-2023-10-16"
assert "contents" not in row["request"]
def test_gemini_deployment_still_uploads_generate_content_rows(self, config):
(row,) = _uploaded_rows(
config,
{"gcs_bucket_name": "my-bucket", "model": "vertex_ai/gemini-2.5-flash"},
[_claude_chat_entry("r1", "hi")],
)
assert row["request"]["contents"] == [{"role": "user", "parts": [{"text": "hi"}]}]
assert "custom_id" not in row
def test_claude_embeddings_batch_line_is_rejected_with_400(self):
from litellm.llms.vertex_ai.common_utils import VertexAIError
entry: Final = {
"custom_id": "e1",
"url": "/v1/embeddings",
"body": {"model": "claude-sonnet-4-6", "input": "hi"},
}
with pytest.raises(VertexAIError) as exc_info:
_openai_batch_jsonl_entry_to_vertex_rows(entry, VertexAIFilesConfig()._map_openai_to_vertex_params)
assert exc_info.value.status_code == 400
def test_claude_output_rows_become_openai_batch_rows(self, config):
content: Final = b"\n".join(
json.dumps(row).encode() for row in (_claude_output_row("a", "first"), _claude_output_row("b", "second"))
)
rows: Final = [
json.loads(line) for line in config._try_transform_vertex_batch_output_to_openai(content).splitlines()
]
assert [row["custom_id"] for row in rows] == ["a", "b"]
assert [row["error"] for row in rows] == [None, None]
bodies: Final = [row["response"]["body"] for row in rows]
assert [body["choices"][0]["message"]["content"] for body in bodies] == ["first", "second"]
assert [body["choices"][0]["finish_reason"] for body in bodies] == ["stop", "stop"]
assert [(body["usage"]["prompt_tokens"], body["usage"]["completion_tokens"]) for body in bodies] == [
(12, 5),
(12, 5),
]
assert {body["model"] for body in bodies} == {"claude-sonnet-4-6"}
@pytest.mark.parametrize("failed_first", [False, True])
def test_failed_claude_output_row_is_an_openai_error_row(self, config, failed_first):
failed_row: Final = {
"custom_id": "bad",
"status": 'Bad Request: {"type": "error", "error": {"type": "invalid_request_error", "message": "too long"}}',
"processed_time": "2026-10-28T20:37:48.413+00:00",
"response": {},
}
rows_in: Final = (failed_row, _claude_output_row("ok", "fine"))
content: Final = b"\n".join(json.dumps(row).encode() for row in (rows_in if failed_first else rows_in[::-1]))
rows_out: Final = {
row["custom_id"]: row
for row in (
json.loads(line) for line in config._try_transform_vertex_batch_output_to_openai(content).splitlines()
)
}
assert rows_out["ok"]["response"]["body"]["choices"][0]["message"]["content"] == "fine"
assert rows_out["bad"]["response"] is None
assert rows_out["bad"]["error"]["code"] == "vertex_ai_error"
assert "too long" in rows_out["bad"]["error"]["message"]
def test_claude_row_keeps_provider_only_params_and_leaves_out_litellm_ones(self, config):
(row,) = _uploaded_rows(
config,
CLAUDE_DEPLOYMENT,
[_claude_chat_entry("r1", "hi", max_tokens=10, top_k=5, drop_params=True, metadata={"team": "a"})],
)
assert row["request"]["top_k"] == 5
assert not {"drop_params", "metadata", "extra_body"} & set(row["request"])
def test_claude_row_top_k_follows_drop_params_on_a_model_without_sampling_params(self, config, monkeypatch):
from litellm.llms.vertex_ai.common_utils import VertexAIError
monkeypatch.setitem(litellm.model_cost, "claude-zeta-9", {"supports_sampling_params": False})
monkeypatch.setattr(litellm, "drop_params", False)
deployment: Final = {**CLAUDE_DEPLOYMENT, "model": "vertex_ai/claude-zeta-9"}
entry: Final = _claude_chat_entry("r1", "hi", max_tokens=10, top_k=5)
with pytest.raises(VertexAIError, match=r"line 1 .*top_k") as exc_info:
_uploaded_rows(config, deployment, [entry])
(row,) = _uploaded_rows(config, {**deployment, "drop_params": True}, [entry])
assert exc_info.value.status_code == 400
assert "top_k" not in row["request"]
@pytest.mark.parametrize("param", [{"n": 2}, {"seed": 7}, {"presence_penalty": 0.5}, {"logprobs": True}])
def test_claude_row_param_claude_does_not_support_follows_drop_params(self, config, monkeypatch, param):
from litellm.llms.vertex_ai.common_utils import VertexAIError
monkeypatch.setattr(litellm, "drop_params", False)
(name,) = param
entry: Final = _claude_chat_entry("r1", "hi", max_tokens=10, **param)
with pytest.raises(VertexAIError, match=rf"line 1 .*{name}") as exc_info:
_uploaded_rows(config, CLAUDE_DEPLOYMENT, [entry])
(row,) = _uploaded_rows(config, {**CLAUDE_DEPLOYMENT, "drop_params": True}, [entry])
assert exc_info.value.status_code == 400
assert name not in row["request"]
def test_claude_row_refuses_a_tool_name_claude_would_rename(self, config):
from litellm.llms.vertex_ai.common_utils import VertexAIError
def tool(name: str) -> dict[str, object]:
return {"type": "function", "function": {"name": name, "parameters": {"type": "object", "properties": {}}}}
renamed: Final = _claude_chat_entry("r1", "hi", max_tokens=10, tools=[tool("get.weather")])
kept: Final = _claude_chat_entry("r2", "hi", max_tokens=10, tools=[tool("get_weather")])
with pytest.raises(VertexAIError, match=r"line 1 .*get\.weather") as exc_info:
_uploaded_rows(config, CLAUDE_DEPLOYMENT, [renamed])
(row,) = _uploaded_rows(config, CLAUDE_DEPLOYMENT, [kept])
assert exc_info.value.status_code == 400
assert [t["name"] for t in row["request"]["tools"]] == ["get_weather"]
def test_claude_row_refuses_a_user_tool_named_like_the_response_format_tool(self, config):
from litellm.llms.vertex_ai.common_utils import VertexAIError
tool: Final = {"type": "function", "function": {"name": "json_tool_call", "parameters": {"type": "object"}}}
entry: Final = _claude_chat_entry("r1", "hi", max_tokens=10, tools=[tool])
with pytest.raises(VertexAIError, match=r"line 1 .*json_tool_call") as exc_info:
_uploaded_rows(config, CLAUDE_DEPLOYMENT, [entry])
assert exc_info.value.status_code == 400
def test_claude_row_leaves_out_stream(self, config):
entry: Final = _claude_chat_entry(
"r1", "hi", max_tokens=10, stream=True, stream_options={"include_usage": True}
)
(row,) = _uploaded_rows(config, CLAUDE_DEPLOYMENT, [entry])
assert "stream" not in row["request"]
assert "stream_options" not in row["request"]
def test_claude_row_merges_extra_body_into_the_request(self, config):
(row,) = _uploaded_rows(
config, CLAUDE_DEPLOYMENT, [_claude_chat_entry("r1", "hi", max_tokens=10, extra_body={"top_k": 3})]
)
assert row["request"]["top_k"] == 3
assert "extra_body" not in row["request"]
def test_claude_response_format_row_round_trips_as_json_content(self, config):
schema: Final = {"type": "object", "properties": {"color": {"type": "string"}}, "required": ["color"]}
(row,) = _uploaded_rows(
config,
CLAUDE_DEPLOYMENT,
[
_claude_chat_entry(
"r1",
"sky color?",
max_tokens=50,
response_format={"type": "json_schema", "json_schema": {"name": "sky", "schema": schema}},
)
],
)
assert "json_mode" not in row["request"]
(json_tool,) = row["request"]["tools"]
output_row: Final = _claude_output_row(
"r1",
"unused",
content=[{"type": "tool_use", "id": "toolu_1", "name": json_tool["name"], "input": {"color": "blue"}}],
)
(out,) = (
json.loads(line)
for line in config._try_transform_vertex_batch_output_to_openai(
json.dumps(output_row).encode()
).splitlines()
)
message: Final = out["response"]["body"]["choices"][0]["message"]
assert json.loads(message["content"]) == {"color": "blue"}
assert not message.get("tool_calls")
def test_claude_output_keeps_a_user_tool_call_as_a_tool_call(self, config):
output_row: Final = _claude_output_row(
"r1",
"unused",
content=[{"type": "tool_use", "id": "toolu_1", "name": "get_weather", "input": {"city": "Paris"}}],
)
(out,) = (
json.loads(line)
for line in config._try_transform_vertex_batch_output_to_openai(
json.dumps(output_row).encode()
).splitlines()
)
(tool_call,) = out["response"]["body"]["choices"][0]["message"]["tool_calls"]
assert tool_call["function"]["name"] == "get_weather"
assert json.loads(tool_call["function"]["arguments"]) == {"city": "Paris"}
def test_claude_input_file_read_back_is_returned_unchanged(self, config):
(row,) = _uploaded_rows(config, CLAUDE_DEPLOYMENT, [_claude_chat_entry("r1", "hi", max_tokens=10)])
content: Final = json.dumps(row).encode()
assert config._try_transform_vertex_batch_output_to_openai(content) == content
def test_malformed_claude_message_fails_only_its_own_row(self, config):
malformed: Final = _claude_output_row("bad", "unused", content=5)
content: Final = json.dumps(_claude_output_row("ok", "fine")).encode() + b"\n" + json.dumps(malformed).encode()
ok_row, bad_row = (
json.loads(line) for line in config._try_transform_vertex_batch_output_to_openai(content).splitlines()
)
assert ok_row["response"]["body"]["choices"][0]["message"]["content"] == "fine"
assert bad_row["custom_id"] == "bad"
assert bad_row["response"] is None
assert bad_row["error"]["code"] == "transformation_error"
def test_claude_line_without_custom_id_is_a_400_at_upload(self, config):
from litellm.llms.vertex_ai.common_utils import VertexAIError
with pytest.raises(VertexAIError, match=r"line 2 .*custom_id") as exc_info:
_uploaded_rows(
config, CLAUDE_DEPLOYMENT, [_claude_chat_entry("r1", "hi", max_tokens=10), _claude_chat_entry(None, "hi")]
)
assert exc_info.value.status_code == 400
@pytest.mark.asyncio
async def test_streamed_claude_output_is_transformed(self, config):
async def chunks():
yield json.dumps(_claude_output_row("a", "first")).encode() + b"\n"
result: Final = await config.transform_file_content_stream(
stream_iterator=chunks(),
headers={},
request_url="https://storage.googleapis.com/storage/v1/b/my-bucket/o/x?alt=media",
logging_obj=MagicMock(),
litellm_params={},
)
body: Final = b"".join([chunk async for chunk in result.stream_iterator])
(row,) = (json.loads(line) for line in body.splitlines())
assert row["custom_id"] == "a"
assert row["response"]["body"]["choices"][0]["message"]["content"] == "first"