diff --git a/litellm/litellm_core_utils/get_litellm_params.py b/litellm/litellm_core_utils/get_litellm_params.py index b32803b5dfc..34951a1424e 100644 --- a/litellm/litellm_core_utils/get_litellm_params.py +++ b/litellm/litellm_core_utils/get_litellm_params.py @@ -14,7 +14,7 @@ _OPTIONAL_KWARGS_KEYS = frozenset( "azure_password", "azure_scope", "timeout", - "bucket_name", + "gcs_bucket_name", "vertex_credentials", "vertex_project", "vertex_location", diff --git a/litellm/litellm_core_utils/prompt_templates/common_utils.py b/litellm/litellm_core_utils/prompt_templates/common_utils.py index aa65cdc5fe0..bf9ce3b0acb 100644 --- a/litellm/litellm_core_utils/prompt_templates/common_utils.py +++ b/litellm/litellm_core_utils/prompt_templates/common_utils.py @@ -785,10 +785,10 @@ def extract_file_metadata(file_data: FileTypes) -> Tuple[Optional[str], Optional if filename is None: if isinstance(file_content, PathLike): filename = Path(file_content).name - elif isinstance(file_content, io.IOBase) and isinstance( - getattr(file_content, "name", None), str - ): - filename = Path(file_content.name).name + elif isinstance(file_content, io.IOBase): + name_attr = getattr(file_content, "name", None) + if isinstance(name_attr, str): + filename = Path(name_attr).name if not content_type: guessed = mimetypes.guess_type(filename)[0] if filename else None diff --git a/litellm/llms/vertex_ai/files/transformation.py b/litellm/llms/vertex_ai/files/transformation.py index 09299b366f4..f6a8873a60d 100644 --- a/litellm/llms/vertex_ai/files/transformation.py +++ b/litellm/llms/vertex_ai/files/transformation.py @@ -231,13 +231,23 @@ def _iter_openai_jsonl_lines(openai_file_content: FileTypes) -> Iterator[str]: return if hasattr(content, "read"): - # Rewind seekable handles (BytesIO, temp files) so the body can be - # replayed on a retry; a non-seekable stream cannot be re-read. - if hasattr(content, "seek"): - try: - content.seek(0) - except (OSError, ValueError): - pass + # The handle is read twice per upload (first-row probe for the GCS + # object name, then the body stream), so it must rewind to 0. A + # non-seekable handle would silently resume mid-stream and drop the + # already-consumed first row, so reject it loudly instead. + seek = getattr(content, "seek", None) + if seek is None: + raise ValueError( + "Batch upload file handle must be seekable; got a non-seekable " + "stream. Pass bytes, a path, or a seekable handle." + ) + try: + seek(0) + except (OSError, ValueError) as e: + raise ValueError( + "Batch upload file handle must be seekable so it can be re-read " + "for the GCS object name and the upload body." + ) from e for raw in content: line = raw.decode("utf-8") if isinstance(raw, bytes) else raw line = line.strip() @@ -396,7 +406,7 @@ class VertexAIFilesConfig(VertexBase, BaseFilesConfig): ) def _get_configured_bucket_name(self, litellm_params: Dict) -> str: - bucket_name = litellm_params.get("bucket_name") or os.getenv("GCS_BUCKET_NAME") + bucket_name = litellm_params.get("gcs_bucket_name") or os.getenv("GCS_BUCKET_NAME") if not bucket_name: raise ValueError("GCS bucket_name is required") return bucket_name diff --git a/litellm/types/router.py b/litellm/types/router.py index ca7a9295450..c36f064a91e 100644 --- a/litellm/types/router.py +++ b/litellm/types/router.py @@ -174,7 +174,7 @@ class CredentialLiteLLMParams(BaseModel): region_name: Optional[str] = None ## OBJECT STORAGE (files / batches) ## - bucket_name: Optional[str] = None + gcs_bucket_name: Optional[str] = None ## AWS BEDROCK / SAGEMAKER ## aws_access_key_id: Optional[str] = None diff --git a/tests/test_litellm/llms/vertex_ai/files/test_vertex_ai_files_streaming.py b/tests/test_litellm/llms/vertex_ai/files/test_vertex_ai_files_streaming.py index 32f4eaaaa7b..1c8408766cf 100644 --- a/tests/test_litellm/llms/vertex_ai/files/test_vertex_ai_files_streaming.py +++ b/tests/test_litellm/llms/vertex_ai/files/test_vertex_ai_files_streaming.py @@ -152,7 +152,7 @@ class TestFileLikeInputNotPartiallyConsumed: api_key=None, model="", optional_params={}, - litellm_params={"bucket_name": "test-bucket"}, + litellm_params={"gcs_bucket_name": "test-bucket"}, data=create_file_data, ) out = cfg.transform_create_file_request( @@ -206,6 +206,29 @@ class TestStreamingLineIterator: with pytest.raises(ValueError, match="Unsupported file content type"): list(_iter_openai_jsonl_lines(12345)) # type: ignore[arg-type] + def test_non_seekable_handle_raises_instead_of_dropping_first_row(self): + # The handle is read twice (object-name probe, then body). A non-seekable + # handle can't rewind, so it must fail loudly rather than silently resume + # mid-stream and omit the opening batch request. + class _NonSeekable: + def __init__(self, raw: bytes): + self._buf = io.BytesIO(raw) + + def read(self, *args): + return self._buf.read(*args) + + def __iter__(self): + return iter(self._buf) + + def seek(self, *args): + raise io.UnsupportedOperation("not seekable") + + handle = _NonSeekable( + b'{"custom_id": "request-0"}\n{"custom_id": "request-1"}\n' + ) + with pytest.raises(ValueError, match="seekable"): + list(_iter_openai_jsonl_lines(handle)) + def test_is_lazy_does_not_parse_past_first_entry(self): # Second row is invalid JSON; pulling only the first entry must not raise. content = b'{"custom_id": "first"}\nnot-json-at-all\n' @@ -337,7 +360,7 @@ class TestPathSourcedStreaming: api_key=None, model="", optional_params={}, - litellm_params={"bucket_name": "test-bucket"}, + litellm_params={"gcs_bucket_name": "test-bucket"}, data=data, ) assert "uploadType=resumable" in url @@ -364,7 +387,7 @@ class TestPathSourcedStreaming: api_key=None, model="", optional_params={}, - litellm_params={"bucket_name": "test-bucket"}, + litellm_params={"gcs_bucket_name": "test-bucket"}, data=data, ) out = cfg.transform_create_file_request( @@ -476,7 +499,7 @@ class TestResumableUploadUrl: api_key=None, model="", optional_params={}, - litellm_params={"bucket_name": "test-bucket"}, + litellm_params={"gcs_bucket_name": "test-bucket"}, data=request, ) assert "uploadType=resumable" in url @@ -493,7 +516,7 @@ class TestResumableUploadUrl: api_key=None, model="", optional_params={}, - litellm_params={"bucket_name": "test-bucket"}, + litellm_params={"gcs_bucket_name": "test-bucket"}, data=request, ) assert "uploadType=media" in url @@ -581,7 +604,7 @@ class TestResumableUploadProtocol: api_key=None, model="", optional_params={}, - litellm_params={"bucket_name": "test-bucket"}, + litellm_params={"gcs_bucket_name": "test-bucket"}, data=request, ) transformed = cfg.transform_create_file_request( @@ -642,7 +665,6 @@ class TestResumableUploadProtocol: async def test_exact_multiple_finalizes_with_empty_chunk(self): # Build a body that is an exact multiple of the chunk size so the stream # ends on a chunk boundary; the upload must still finalize (bytes */TOTAL). - cfg = VertexAIFilesConfig() chunk_size = 256 stream = _FixedBytesStream(b"a" * (chunk_size * 3)) config = {"body_stream": stream, "chunk_size": chunk_size}