diff --git a/litellm/batches/batch_utils.py b/litellm/batches/batch_utils.py index 6a90b0dd043..7209ac6a1e7 100644 --- a/litellm/batches/batch_utils.py +++ b/litellm/batches/batch_utils.py @@ -9,6 +9,7 @@ import litellm from litellm._logging import verbose_logger from litellm.litellm_core_utils.get_litellm_params import AWS_CREDENTIAL_KWARGS_KEYS from litellm.litellm_core_utils.llm_cost_calc.utils import parse_prompt_tokens_details +from litellm.llms.base_llm.ocr.transformation import OCRUsageInfo from litellm.llms.bedrock.batches.transformation import titan_embedding_usage_from_batch_output from litellm.llms.vertex_ai.batches.transformation import vertex_prompt_tokens_details from litellm.types.llms.openai import Batch @@ -52,7 +53,7 @@ def batch_cost_is_final(batch: Batch) -> bool: async def calculate_batch_cost_and_usage( file_content_dictionary: list[dict], - custom_llm_provider: Literal["openai", "azure", "vertex_ai", "hosted_vllm", "anthropic"], + custom_llm_provider: Literal["openai", "azure", "vertex_ai", "hosted_vllm", "anthropic", "mistral"], model_name: str | None = None, model_info: ModelInfo | None = None, ) -> BatchCostUsageResult: @@ -82,7 +83,7 @@ async def calculate_batch_cost_and_usage( async def _handle_completed_batch( batch: Batch, - custom_llm_provider: Literal["openai", "azure", "vertex_ai", "hosted_vllm", "anthropic"], + custom_llm_provider: Literal["openai", "azure", "vertex_ai", "hosted_vllm", "anthropic", "mistral"], model_name: str | None = None, litellm_params: dict | None = None, model_info: ModelInfo | None = None, @@ -168,7 +169,7 @@ class _BatchOutputLineStats: def _classify_output_line_stats( entries: Iterable[dict], - custom_llm_provider: Literal["openai", "azure", "vertex_ai", "hosted_vllm", "anthropic", "bedrock"], + custom_llm_provider: Literal["openai", "azure", "vertex_ai", "hosted_vllm", "anthropic", "bedrock", "mistral"], model_name: str | None, model_info: ModelInfo | None, ) -> Iterator[_BatchOutputLineStats | _LineOutcome]: @@ -187,7 +188,7 @@ def _classify_output_line_stats( def _safe_output_line_stats( entry: Mapping[str, object], - custom_llm_provider: Literal["openai", "azure", "vertex_ai", "hosted_vllm", "anthropic", "bedrock"], + custom_llm_provider: Literal["openai", "azure", "vertex_ai", "hosted_vllm", "anthropic", "bedrock", "mistral"], model_name: str | None, model_info: ModelInfo | None, ) -> _BatchOutputLineStats | None: @@ -209,7 +210,7 @@ def _safe_output_line_stats( def _compute_output_line_stats( entry: Mapping[str, object], - custom_llm_provider: Literal["openai", "azure", "vertex_ai", "hosted_vllm", "anthropic", "bedrock"], + custom_llm_provider: Literal["openai", "azure", "vertex_ai", "hosted_vllm", "anthropic", "bedrock", "mistral"], model_name: str | None, model_info: ModelInfo | None, ) -> _BatchOutputLineStats: @@ -220,6 +221,7 @@ def _compute_output_line_stats( response_model: Final = raw_model if isinstance(raw_model, str) and raw_model else None completion_details: Final = usage.completion_tokens_details line_prompt_cost, line_completion_cost = _output_line_cost( + response_body=response_body, usage=usage, custom_llm_provider=custom_llm_provider, model_name=model_name, @@ -239,19 +241,36 @@ def _compute_output_line_stats( ) +def _ocr_usage_info_from_response_body(response_body: Mapping[str, object]) -> OCRUsageInfo | None: + """OCR results report ``usage_info`` (pages) instead of ``usage`` (tokens); None for non-OCR lines.""" + raw_usage_info: Final = response_body.get("usage_info") + if not isinstance(raw_usage_info, Mapping): + return None + return OCRUsageInfo.model_validate(raw_usage_info) + + def _output_line_cost( + response_body: Mapping[str, object], usage: Usage, - custom_llm_provider: Literal["openai", "azure", "vertex_ai", "hosted_vllm", "anthropic", "bedrock"], + custom_llm_provider: Literal["openai", "azure", "vertex_ai", "hosted_vllm", "anthropic", "bedrock", "mistral"], model_name: str | None, response_model: str | None, model_info: ModelInfo | None, ) -> tuple[float, float]: """(prompt_cost, completion_cost) for one output line, priced at batch rates.""" - from litellm.cost_calculator import batch_cost_calculator + from litellm.cost_calculator import batch_cost_calculator, ocr_batch_cost cost_model: Final = ( model_name if custom_llm_provider == "bedrock" and model_name else response_model or model_name or "" ) + ocr_usage: Final = _ocr_usage_info_from_response_body(response_body) + if ocr_usage is not None: + return ocr_batch_cost( + model=cost_model, + custom_llm_provider=custom_llm_provider, + usage_info=ocr_usage, + model_info=model_info, + ) return batch_cost_calculator( usage=usage, model=cost_model, @@ -262,7 +281,7 @@ def _output_line_cost( def _aggregate_batch_cost_usage_models( entries: Iterable[dict], - custom_llm_provider: Literal["openai", "azure", "vertex_ai", "hosted_vllm", "anthropic", "bedrock"], + custom_llm_provider: Literal["openai", "azure", "vertex_ai", "hosted_vllm", "anthropic", "bedrock", "mistral"], model_name: str | None = None, model_info: ModelInfo | None = None, ) -> BatchCostUsageResult: @@ -430,7 +449,7 @@ def _provider_output_file_id(output_file_id: str) -> str: async def _fetch_batch_managed_file_content( file_id: str, - custom_llm_provider: Literal["openai", "azure", "vertex_ai", "hosted_vllm", "anthropic"] = "openai", + custom_llm_provider: Literal["openai", "azure", "vertex_ai", "hosted_vllm", "anthropic", "mistral"] = "openai", litellm_params: dict | None = None, ) -> bytes: """ @@ -460,7 +479,7 @@ async def _fetch_batch_managed_file_content( async def _fetch_batch_output_file_content( batch: Batch, - custom_llm_provider: Literal["openai", "azure", "vertex_ai", "hosted_vllm", "anthropic"] = "openai", + custom_llm_provider: Literal["openai", "azure", "vertex_ai", "hosted_vllm", "anthropic", "mistral"] = "openai", litellm_params: dict | None = None, ) -> bytes: """ @@ -482,7 +501,7 @@ async def _fetch_batch_output_file_content( async def count_error_file_failed_requests( batch: Batch, - custom_llm_provider: Literal["openai", "azure", "vertex_ai", "hosted_vllm", "anthropic"], + custom_llm_provider: Literal["openai", "azure", "vertex_ai", "hosted_vllm", "anthropic", "mistral"], litellm_params: dict | None, ) -> int: """Count failed requests reported only in the batch's separate error file. diff --git a/litellm/batches/main.py b/litellm/batches/main.py index 77a4fdebf16..76b6c73b375 100644 --- a/litellm/batches/main.py +++ b/litellm/batches/main.py @@ -105,9 +105,11 @@ def _resolve_timeout( @client async def acreate_batch( completion_window: Literal["24h"], - endpoint: Literal["/v1/chat/completions", "/v1/embeddings", "/v1/completions", "/v1/responses"], + endpoint: Literal["/v1/chat/completions", "/v1/embeddings", "/v1/completions", "/v1/responses", "/v1/ocr"], input_file_id: str, - custom_llm_provider: Literal["openai", "azure", "vertex_ai", "bedrock", "hosted_vllm", "litellm_proxy"] = "openai", + custom_llm_provider: Literal[ + "openai", "azure", "vertex_ai", "bedrock", "hosted_vllm", "litellm_proxy", "mistral" + ] = "openai", metadata: dict[str, str] | None = None, extra_headers: dict[str, str] | None = None, extra_body: dict[str, str] | None = None, @@ -155,9 +157,11 @@ async def acreate_batch( @client def create_batch( completion_window: Literal["24h"], - endpoint: Literal["/v1/chat/completions", "/v1/embeddings", "/v1/completions", "/v1/responses"], + endpoint: Literal["/v1/chat/completions", "/v1/embeddings", "/v1/completions", "/v1/responses", "/v1/ocr"], input_file_id: str, - custom_llm_provider: Literal["openai", "azure", "vertex_ai", "bedrock", "hosted_vllm", "litellm_proxy"] = "openai", + custom_llm_provider: Literal[ + "openai", "azure", "vertex_ai", "bedrock", "hosted_vllm", "litellm_proxy", "mistral" + ] = "openai", metadata: dict[str, str] | None = None, extra_headers: dict[str, str] | None = None, extra_body: dict[str, str] | None = None, @@ -341,7 +345,7 @@ def create_batch( async def aretrieve_batch( batch_id: str, custom_llm_provider: Literal[ - "openai", "azure", "vertex_ai", "bedrock", "hosted_vllm", "litellm_proxy", "anthropic" + "openai", "azure", "vertex_ai", "bedrock", "hosted_vllm", "litellm_proxy", "anthropic", "mistral" ] = "openai", metadata: dict[str, str] | None = None, extra_headers: dict[str, str] | None = None, @@ -389,7 +393,7 @@ def _handle_retrieve_batch_providers_without_provider_config( _retrieve_batch_request: RetrieveBatchRequest, _is_async: bool, custom_llm_provider: Literal[ - "openai", "azure", "vertex_ai", "bedrock", "hosted_vllm", "litellm_proxy", "anthropic" + "openai", "azure", "vertex_ai", "bedrock", "hosted_vllm", "litellm_proxy", "anthropic", "mistral" ] = "openai", logging_obj: LiteLLMLoggingObj | None = None, ): @@ -497,7 +501,7 @@ def _handle_retrieve_batch_providers_without_provider_config( message=( f"LiteLLM doesn't support custom_llm_provider={custom_llm_provider} for 'retrieve_batch' without a `model` kwarg. " "Supported via this path: 'openai', 'azure', 'vertex_ai', 'anthropic'. " - "'bedrock' is supported but requires `model` to be passed so the provider config can be loaded." + "'bedrock' and 'mistral' are supported but require `model` to be passed so the provider config can be loaded." ), model="n/a", llm_provider=custom_llm_provider, @@ -514,7 +518,7 @@ def _handle_retrieve_batch_providers_without_provider_config( def retrieve_batch( batch_id: str, custom_llm_provider: Literal[ - "openai", "azure", "vertex_ai", "bedrock", "hosted_vllm", "litellm_proxy", "anthropic" + "openai", "azure", "vertex_ai", "bedrock", "hosted_vllm", "litellm_proxy", "anthropic", "mistral" ] = "openai", metadata: dict[str, str] | None = None, extra_headers: dict[str, str] | None = None, diff --git a/litellm/cost_calculator.py b/litellm/cost_calculator.py index 088f9e8867c..38758867a11 100644 --- a/litellm/cost_calculator.py +++ b/litellm/cost_calculator.py @@ -141,6 +141,7 @@ if TYPE_CHECKING: from litellm.litellm_core_utils.litellm_logging import ( Logging as LitellmLoggingObject, ) + from litellm.llms.base_llm.ocr.transformation import OCRUsageInfo else: LitellmLoggingObject = Any @@ -2114,6 +2115,87 @@ def ocr_cost( return ocr_pages_cost + annotation_pages_cost, 0.0 +_OCR_BATCH_PAGE_RATE_KEYS: Final = ("ocr_cost_per_page_batches", "ocr_cost_per_page") +_OCR_BATCH_ANNOTATION_RATE_KEYS: Final = ("annotation_cost_per_page_batches", "annotation_cost_per_page") + + +def ocr_batch_cost( + model: str, + custom_llm_provider: str | None, + usage_info: "OCRUsageInfo", + model_info: ModelInfo | None = None, +) -> tuple[float, float]: + """Per-page cost of one OCR result inside a batch output file. + + Batch OCR is billed per page at the ``*_batches`` rate, falling back to the + synchronous per-page rate when a model has no batch price recorded, the same + fallback ``batch_cost_calculator`` applies to per-token batch pricing. Each + per-page family (OCR pages, annotation pages) belongs to the deployment's + ``model_info`` when it prices that family at either rate and to the published + cost map otherwise, so a deployment overriding one family keeps the model's + published rate for the other, and the cost map is only consulted for a family + the deployment leaves out. Returns ``(prompt_cost, completion_cost)`` with the + whole cost in the first slot, like ``ocr_cost``. + """ + pages_processed: Final = usage_info.pages_processed or 0 + annotation_pages: Final = usage_info.pages_processed_annotation or 0 + deployment_page_rate: Final = _first_price(model_info, *_OCR_BATCH_PAGE_RATE_KEYS) + deployment_annotation_rate: Final = _first_price(model_info, *_OCR_BATCH_ANNOTATION_RATE_KEYS) + needs_published_pricing: Final = (pages_processed > 0 and deployment_page_rate is None) or ( + annotation_pages > 0 and deployment_annotation_rate is None + ) + published: Final = ( + _lookup_model_info_or_none(model=model, custom_llm_provider=custom_llm_provider) + if needs_published_pricing + else None + ) + if needs_published_pricing and published is None: + verbose_logger.warning( + "OCR batch cost: model=%s custom_llm_provider=%s has no pricing entry; " + "billing only the per-page families the deployment prices.", + _single_log_line(model), + _single_log_line(custom_llm_provider), + ) + + page_rate: Final = ( + deployment_page_rate + if deployment_page_rate is not None + else _first_price(published, *_OCR_BATCH_PAGE_RATE_KEYS) + ) + annotation_rate: Final = ( + deployment_annotation_rate + if deployment_annotation_rate is not None + else _first_price(published, *_OCR_BATCH_ANNOTATION_RATE_KEYS) + ) + if page_rate is None and pages_processed > 0: + verbose_logger.warning( + "OCR batch cost: model=%s custom_llm_provider=%s reported pages_processed=%s but no " + "ocr_cost_per_page is configured; returning 0.0 cost for those pages.", + _single_log_line(model), + _single_log_line(custom_llm_provider), + pages_processed, + ) + effective_annotation_rate: Final = annotation_rate if annotation_rate is not None else page_rate + return (page_rate or 0.0) * pages_processed + (effective_annotation_rate or 0.0) * annotation_pages, 0.0 + + +def _single_log_line(value: str | None) -> str: + return str(value).replace("\n", "").replace("\r", "") + + +def _lookup_model_info_or_none(model: str, custom_llm_provider: str | None) -> ModelInfo | None: + try: + return litellm.get_model_info(model=model, custom_llm_provider=custom_llm_provider) + except Exception: # noqa: BLE001 # get_model_info raises bare Exception for unmapped models; caller logs and bills 0.0 + return None + + +def _first_price(model_info: ModelInfo | None, *keys: str) -> float | None: + if model_info is None: + return None + return next((price for price in (model_info.get(k) for k in keys) if isinstance(price, (int, float))), None) + + def vector_store_search_cost( model: str | None, custom_llm_provider: str, diff --git a/litellm/files/main.py b/litellm/files/main.py index cdb7e949a9c..e0804244ff7 100644 --- a/litellm/files/main.py +++ b/litellm/files/main.py @@ -27,12 +27,13 @@ FileCreateProvider = Literal[ "litellm_proxy", "manus", "anthropic", + "mistral", ] FileRetrieveProvider = Literal[ - "openai", "azure", "gemini", "vertex_ai", "hosted_vllm", "litellm_proxy", "manus", "anthropic" + "openai", "azure", "gemini", "vertex_ai", "hosted_vllm", "litellm_proxy", "manus", "anthropic", "mistral" ] -FileDeleteProvider = Literal["openai", "azure", "gemini", "bedrock", "litellm_proxy", "manus", "anthropic"] -FileListProvider = Literal["openai", "azure", "litellm_proxy", "manus", "anthropic"] +FileDeleteProvider = Literal["openai", "azure", "gemini", "bedrock", "litellm_proxy", "manus", "anthropic", "mistral"] +FileListProvider = Literal["openai", "azure", "litellm_proxy", "manus", "anthropic", "mistral"] import litellm from litellm import get_secret_str from litellm.files.streaming import FileContentStreamingResponse diff --git a/litellm/files/types.py b/litellm/files/types.py index bcb752237fa..ae29ce2721f 100644 --- a/litellm/files/types.py +++ b/litellm/files/types.py @@ -2,7 +2,7 @@ from collections.abc import AsyncIterator, Iterator, Mapping from typing import Literal, NamedTuple FileContentProvider = Literal[ - "openai", "azure", "vertex_ai", "bedrock", "hosted_vllm", "litellm_proxy", "anthropic", "manus" + "openai", "azure", "vertex_ai", "bedrock", "hosted_vllm", "litellm_proxy", "anthropic", "manus", "mistral" ] diff --git a/litellm/litellm_core_utils/litellm_logging.py b/litellm/litellm_core_utils/litellm_logging.py index 7248c2f3590..f7679b31f69 100644 --- a/litellm/litellm_core_utils/litellm_logging.py +++ b/litellm/litellm_core_utils/litellm_logging.py @@ -371,6 +371,10 @@ _DEPLOYMENT_PRICING_KEYS: Final = ( "output_cost_per_token", "input_cost_per_token_batches", "output_cost_per_token_batches", + "ocr_cost_per_page", + "ocr_cost_per_page_batches", + "annotation_cost_per_page", + "annotation_cost_per_page_batches", ) @@ -386,7 +390,9 @@ def deployment_pricing_model_info(model_id: str | None, deployment_model: str | the model's published rates instead of billing as zero. Ownership is per token direction: declaring either rate for a direction takes that whole direction, so a published batch rate can never displace a standard rate - the deployment configured itself. + the deployment configured itself. OCR per-page rates count as declared + pricing too; they pass through as registered and ``ocr_batch_cost`` layers + the published rate under each per-page family the deployment leaves out. """ if model_id is None: return None @@ -1239,8 +1245,8 @@ class Logging(LiteLLMLoggingBaseClass): return {"error": f"Unable to parse raw request body. Got - {data}"} return data - def _get_masked_api_base(self, api_base: str) -> str: - return str(mask_api_base_credentials(api_base)) + def _get_masked_api_base(self, api_base: str | None) -> str: + return str(mask_api_base_credentials(api_base or "")) def _pre_call(self, input, api_key, model=None, additional_args={}): """ diff --git a/litellm/llms/custom_httpx/llm_http_handler.py b/litellm/llms/custom_httpx/llm_http_handler.py index 3727edce81d..8e0cdf547a2 100644 --- a/litellm/llms/custom_httpx/llm_http_handler.py +++ b/litellm/llms/custom_httpx/llm_http_handler.py @@ -293,6 +293,26 @@ def _has_pre_call_deployment_hook(logging_obj: LiteLLMLoggingObj) -> bool: return False +def _mask_presigned_request_headers(transformed_request: bytes | str | dict) -> bytes | str | dict: + """A pre-signed request carries its auth inside its own ``headers`` key, which + logging treats as request body (only the top-level headers channel gets masked), + so mask it here before the request is handed to ``pre_call``.""" + if not isinstance(transformed_request, dict): + return transformed_request + request_headers: Final = transformed_request.get("headers") + if not isinstance(request_headers, dict): + return transformed_request + + from litellm.litellm_core_utils.litellm_logging import ( + _get_masked_values, # pyright: ignore[reportPrivateUsage] # the shared header-masking helper has no public name + ) + + return { # mutable-ok: logging's curl and raw-request builders take dict + **transformed_request, + "headers": _get_masked_values(request_headers), + } + + def _aws_signing_overrides(optional_params: Mapping[str, Any], litellm_params: Mapping[str, Any]) -> Mapping[str, Any]: return MappingProxyType( { @@ -3734,7 +3754,7 @@ class BaseLLMHTTPHandler: "complete_input_dict": ( "" if isinstance(transformed_request, dict) and "streaming_media_upload" in transformed_request - else transformed_request + else _mask_presigned_request_headers(transformed_request) ), "api_base": api_base, "headers": headers, @@ -4157,7 +4177,7 @@ class BaseLLMHTTPHandler: input="", api_key="", additional_args={ - "complete_input_dict": transformed_request, + "complete_input_dict": _mask_presigned_request_headers(transformed_request), "api_base": api_base, "headers": headers, }, @@ -4236,7 +4256,7 @@ class BaseLLMHTTPHandler: input="", api_key="", additional_args={ - "complete_input_dict": transformed_request, + "complete_input_dict": _mask_presigned_request_headers(transformed_request), "api_base": api_base, "headers": headers, "batch_id": batch_id, diff --git a/litellm/llms/mistral/batches/__init__.py b/litellm/llms/mistral/batches/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/litellm/llms/mistral/batches/transformation.py b/litellm/llms/mistral/batches/transformation.py new file mode 100644 index 00000000000..ef9ee5ff503 --- /dev/null +++ b/litellm/llms/mistral/batches/transformation.py @@ -0,0 +1,220 @@ +""" +Mistral Batch API. Reference: https://docs.mistral.ai/api/#tag/batch + +Mistral runs one model per job (set on the job, not per input line) and accepts +``/v1/ocr`` as a batch endpoint, which is how OCR gets its 50% batch discount. +Output and error files are OpenAI-shaped JSONL (``{custom_id, response: {status_code, body}}``), +so the shared batch cost accounting reads them without a provider branch. +""" + +from collections.abc import Mapping, Sequence +from types import MappingProxyType +from typing import Final, Literal, TypeAlias + +import httpx +from openai.types.batch import BatchRequestCounts +from openai.types.batch import Errors as BatchErrors +from openai.types.batch_error import BatchError +from pydantic import BaseModel, ConfigDict +from typing_extensions import NotRequired, ReadOnly, TypedDict + +from litellm.litellm_core_utils.url_utils import encode_url_path_segment +from litellm.llms.base_llm.batches.transformation import BaseBatchesConfig +from litellm.llms.base_llm.chat.transformation import BaseLLMException +from litellm.types.llms.openai import AllMessageValues, CreateBatchRequest +from litellm.types.utils import LiteLLMBatch, LlmProviders + +from ..common_utils import get_mistral_api_base, get_mistral_auth_headers, mistral_error + +MistralBatchStatus: TypeAlias = Literal[ + "QUEUED", "RUNNING", "SUCCESS", "FAILED", "TIMEOUT_EXCEEDED", "CANCELLATION_REQUESTED", "CANCELLED" +] +OpenAIBatchStatus: TypeAlias = Literal[ + "validating", "failed", "in_progress", "finalizing", "completed", "expired", "cancelling", "cancelled" +] + +_NO_HEADERS: Final[Mapping[str, str]] = MappingProxyType({}) # mutable-ok: frozen at module scope +_STATUS_MAP: Final[MappingProxyType[MistralBatchStatus, OpenAIBatchStatus]] = MappingProxyType( + { + "QUEUED": "validating", + "RUNNING": "in_progress", + "SUCCESS": "completed", + "FAILED": "failed", + "TIMEOUT_EXCEEDED": "expired", + "CANCELLATION_REQUESTED": "cancelling", + "CANCELLED": "cancelled", + } +) + + +class MistralCreateBatchJobRequest(TypedDict): + """Body of ``POST /v1/batch/jobs``.""" + + input_files: ReadOnly[tuple[str, ...]] + endpoint: ReadOnly[str] + model: ReadOnly[str] + metadata: NotRequired[ReadOnly[Mapping[str, str]]] + + +class MistralPresignedRequest(TypedDict): + """A fully-formed request the shared HTTP handler sends as-is (its ``method`` branch).""" + + method: ReadOnly[Literal["GET"]] + url: ReadOnly[str] + headers: ReadOnly[Mapping[str, str]] + + +class MistralBatchError(BaseModel): + model_config = ConfigDict(frozen=True, extra="ignore") + + message: str + count: int = 1 + + +class MistralBatchJob(BaseModel): + model_config = ConfigDict(frozen=True, extra="ignore") + + id: str + input_files: tuple[str, ...] = () + endpoint: str + model: str | None = None + status: MistralBatchStatus + created_at: int + started_at: int | None = None + completed_at: int | None = None + total_requests: int = 0 + completed_requests: int = 0 + succeeded_requests: int = 0 + failed_requests: int = 0 + output_file: str | None = None + error_file: str | None = None + errors: tuple[MistralBatchError, ...] = () + metadata: dict[str, str] | None = None # mutable-ok: LiteLLMBatch.metadata is typed as dict + + +def _to_batch_errors(errors: Sequence[MistralBatchError]) -> BatchErrors | None: + if not errors: + return None + return BatchErrors( + object="list", + data=[ # mutable-ok: openai Batch.Errors.data is typed as list + BatchError(message=f"{e.message} (x{e.count})" if e.count > 1 else e.message) for e in errors + ], + ) + + +def _to_litellm_batch(job: MistralBatchJob) -> LiteLLMBatch: + status: Final = _STATUS_MAP[job.status] + terminal_at: Final = job.completed_at + return LiteLLMBatch( + id=job.id, + object="batch", + endpoint=job.endpoint, + input_file_id=job.input_files[0] if job.input_files else "", + completion_window="24h", + status=status, + created_at=job.created_at, + in_progress_at=job.started_at, + completed_at=terminal_at if status == "completed" else None, + failed_at=terminal_at if status == "failed" else None, + expired_at=terminal_at if status == "expired" else None, + cancelled_at=terminal_at if status == "cancelled" else None, + output_file_id=job.output_file, + error_file_id=job.error_file, + errors=_to_batch_errors(job.errors), + request_counts=BatchRequestCounts( + total=job.total_requests, + completed=job.succeeded_requests, + failed=job.failed_requests, + ), + metadata=job.metadata, + ) + + +class MistralBatchesConfig(BaseBatchesConfig): + @property + def custom_llm_provider(self) -> LlmProviders: + return LlmProviders.MISTRAL + + def validate_environment( + self, + headers: Mapping[str, str], + model: str, + messages: Sequence[AllMessageValues], + optional_params: Mapping[str, object], + litellm_params: Mapping[str, object], + api_key: str | None = None, + api_base: str | None = None, + ) -> dict[str, str]: # mutable-ok: BaseBatchesConfig signature + return get_mistral_auth_headers(headers, api_key) + + def get_complete_batch_url( + self, + api_base: str | None, + api_key: str | None, + model: str, + optional_params: Mapping[str, object], + litellm_params: Mapping[str, object], + data: CreateBatchRequest, + ) -> str: + return f"{get_mistral_api_base(api_base)}/v1/batch/jobs" + + def transform_create_batch_request( + self, + model: str, + create_batch_data: CreateBatchRequest, + optional_params: Mapping[str, object], + litellm_params: Mapping[str, object], + ) -> dict[str, object]: # mutable-ok: BaseBatchesConfig signature + input_file_id: Final = create_batch_data.get("input_file_id") + endpoint: Final = create_batch_data.get("endpoint") + if input_file_id is None or endpoint is None: + raise ValueError("input_file_id and endpoint are required to create a Mistral batch job") + metadata: Final = create_batch_data.get("metadata") + body: Final = ( + MistralCreateBatchJobRequest( + input_files=(input_file_id,), endpoint=endpoint, model=model, metadata=metadata + ) + if metadata + else MistralCreateBatchJobRequest(input_files=(input_file_id,), endpoint=endpoint, model=model) + ) + return dict(body) # mutable-ok: BaseBatchesConfig signature + + def transform_create_batch_response( + self, + model: str | None, + raw_response: httpx.Response, + logging_obj: object, + litellm_params: Mapping[str, object], + ) -> LiteLLMBatch: + return _to_litellm_batch(MistralBatchJob.model_validate(raw_response.json())) + + def transform_retrieve_batch_request( + self, + batch_id: str, + optional_params: Mapping[str, object], + litellm_params: Mapping[str, object], + ) -> dict[str, object]: # mutable-ok: BaseBatchesConfig signature + encoded_batch_id: Final = encode_url_path_segment(batch_id, field_name="batch_id") + api_base: Final = litellm_params.get("api_base") + api_key: Final = litellm_params.get("api_key") + request: Final = MistralPresignedRequest( + method="GET", + url=f"{get_mistral_api_base(api_base if isinstance(api_base, str) else None)}/v1/batch/jobs/{encoded_batch_id}", + headers=get_mistral_auth_headers(_NO_HEADERS, api_key if isinstance(api_key, str) else None), + ) + return dict(request) # mutable-ok: BaseBatchesConfig signature + + def transform_retrieve_batch_response( + self, + model: str | None, + raw_response: httpx.Response, + logging_obj: object, + litellm_params: Mapping[str, object], + ) -> LiteLLMBatch: + return _to_litellm_batch(MistralBatchJob.model_validate(raw_response.json())) + + def get_error_class( + self, error_message: str, status_code: int, headers: Mapping[str, str] | httpx.Headers + ) -> BaseLLMException: + return mistral_error(error_message, status_code, headers) diff --git a/litellm/llms/mistral/common_utils.py b/litellm/llms/mistral/common_utils.py new file mode 100644 index 00000000000..2f14328afdf --- /dev/null +++ b/litellm/llms/mistral/common_utils.py @@ -0,0 +1,41 @@ +from collections.abc import Mapping +from typing import Final + +import httpx + +from litellm.llms.base_llm.chat.transformation import BaseLLMException +from litellm.secret_managers.main import get_secret_str + +MISTRAL_API_BASE: Final = "https://api.mistral.ai" +MISTRAL_API_KEY_ENV_VAR: Final = "MISTRAL_API_KEY" + + +class MistralError(BaseLLMException): + pass + + +def get_mistral_api_base(api_base: str | None) -> str: + """Return the Mistral origin without a trailing ``/v1``, so callers can append ``/v1/``.""" + resolved: Final = (api_base or get_secret_str("MISTRAL_API_BASE") or MISTRAL_API_BASE).rstrip("/") + return resolved.removesuffix("/v1") + + +def get_mistral_auth_headers( + headers: Mapping[str, str], api_key: str | None +) -> dict[str, str]: # mutable-ok: BaseConfig.validate_environment contract returns dict + resolved_key: Final = api_key or get_secret_str(MISTRAL_API_KEY_ENV_VAR) + if resolved_key is None: + raise ValueError( + "Missing Mistral API Key - A call is being made to Mistral but no key is set either in the environment variables or via params" + ) + return dict(headers, Authorization=f"Bearer {resolved_key}") # mutable-ok: BaseConfig contract returns dict + + +def mistral_error(error_message: str, status_code: int, headers: Mapping[str, str] | httpx.Headers) -> MistralError: + return MistralError( + status_code=status_code, + message=error_message, + headers=headers + if isinstance(headers, httpx.Headers) + else httpx.Headers(dict(headers)), # mutable-ok: httpx.Headers takes a dict + ) diff --git a/litellm/llms/mistral/files/__init__.py b/litellm/llms/mistral/files/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/litellm/llms/mistral/files/transformation.py b/litellm/llms/mistral/files/transformation.py new file mode 100644 index 00000000000..6edf188d247 --- /dev/null +++ b/litellm/llms/mistral/files/transformation.py @@ -0,0 +1,267 @@ +""" +Mistral Files API. Reference: https://docs.mistral.ai/api/#tag/files + +Mistral's file objects already carry the OpenAI field names (id, bytes, created_at, +filename, purpose), so this config is URL routing, auth, and a purpose mapping: +Mistral only accepts ``fine-tune``, ``batch`` and ``ocr`` as upload purposes, while files +other Mistral products created read back with purposes outside that set and map onto ``user_data``. +""" + +import time +from collections.abc import Mapping, Sequence +from types import MappingProxyType +from typing import Final, Literal, TypeAlias + +import httpx +from openai.types.file_deleted import FileDeleted +from pydantic import BaseModel, ConfigDict +from typing_extensions import ReadOnly, TypedDict + +from litellm.litellm_core_utils.prompt_templates.common_utils import extract_file_data +from litellm.litellm_core_utils.url_utils import encode_url_path_segment +from litellm.llms.base_llm.chat.transformation import BaseLLMException +from litellm.llms.base_llm.files.transformation import BaseFilesConfig, LiteLLMLoggingObj +from litellm.types.llms.openai import ( + CreateFileRequest, + FileContentRequest, + HttpxBinaryResponseContent, + OpenAICreateFileRequestOptionalParams, + OpenAIFileObject, + OpenAIFilesPurpose, +) +from litellm.types.utils import LlmProviders + +from ..common_utils import get_mistral_api_base, get_mistral_auth_headers, mistral_error + +MistralFilePurpose: TypeAlias = Literal["fine-tune", "batch", "ocr"] + +_OPENAI_PURPOSE_BY_MISTRAL: Final[Mapping[str, OpenAIFilesPurpose]] = MappingProxyType( + {"fine-tune": "fine-tune", "batch": "batch", "ocr": "user_data"} +) +_OPENAI_PURPOSE_FOR_UNMAPPED: Final[OpenAIFilesPurpose] = "user_data" +_MISTRAL_PURPOSE_BY_OPENAI: Final[Mapping[str, MistralFilePurpose]] = MappingProxyType( + {"fine-tune": "fine-tune", "batch": "batch", "ocr": "ocr", "user_data": "ocr"} +) +_SUPPORTED_PURPOSES: Final = ", ".join(_MISTRAL_PURPOSE_BY_OPENAI) + +_NO_QUERY_PARAMS: Final[dict[str, str]] = {} # mutable-ok: BaseFilesConfig request transforms return tuple[str, dict] + + +class MistralMultipartUpload(TypedDict): + """``files=`` payload for ``POST /v1/files``: each value is an httpx multipart tuple.""" + + file: ReadOnly[tuple[str, object, str]] + purpose: ReadOnly[tuple[None, MistralFilePurpose]] + + +class MistralFile(BaseModel): + model_config = ConfigDict(frozen=True, extra="ignore") + + id: str + bytes: int = 0 + created_at: int | None = None + filename: str = "" + purpose: str = "batch" + expires_at: int | None = None + + +class MistralFileList(BaseModel): + model_config = ConfigDict(frozen=True, extra="ignore") + + data: tuple[MistralFile, ...] = () + + +class MistralFileDeleted(BaseModel): + model_config = ConfigDict(frozen=True, extra="ignore") + + id: str + deleted: bool = True + + +def _to_openai_file_object(file: MistralFile) -> OpenAIFileObject: + return OpenAIFileObject( + id=file.id, + bytes=file.bytes, + created_at=file.created_at if file.created_at is not None else int(time.time()), + filename=file.filename, + object="file", + purpose=_to_openai_purpose(file.purpose), + status="uploaded", + expires_at=file.expires_at, + ) + + +def _to_openai_purpose(purpose: str) -> OpenAIFilesPurpose: + return _OPENAI_PURPOSE_BY_MISTRAL.get(purpose, _OPENAI_PURPOSE_FOR_UNMAPPED) + + +def _to_mistral_purpose(purpose: str) -> MistralFilePurpose: + """``user_data`` is what an OCR file reads back as, since OpenAI's purpose literal has no ``ocr``, + so it maps back onto ``ocr``. Every other purpose Mistral lacks is rejected: silently rewriting + it to ``batch`` would let an upload skip the proxy's batch-file validation and guardrails, which + only run when the caller says ``purpose=batch``.""" + mistral_purpose: Final = _MISTRAL_PURPOSE_BY_OPENAI.get(purpose) + if mistral_purpose is None: + raise mistral_error( + f"Mistral does not support purpose={purpose!r}. Use one of: {_SUPPORTED_PURPOSES}", + status_code=400, + headers=httpx.Headers(), + ) + return mistral_purpose + + +def _api_base_from(litellm_params: Mapping[str, object]) -> str: + api_base: Final = litellm_params.get("api_base") + return get_mistral_api_base(api_base if isinstance(api_base, str) else None) + + +class MistralFilesConfig(BaseFilesConfig): + @property + def custom_llm_provider(self) -> LlmProviders: + return LlmProviders.MISTRAL + + def get_complete_url( + self, + api_base: str | None, + api_key: str | None, + model: str, + optional_params: Mapping[str, object], + litellm_params: Mapping[str, object], + stream: bool | None = None, + ) -> str: + return f"{get_mistral_api_base(api_base)}/v1/files" + + def _file_url(self, file_id: str, litellm_params: Mapping[str, object], suffix: str = "") -> str: + encoded_file_id: Final = encode_url_path_segment(file_id, field_name="file_id") + return f"{_api_base_from(litellm_params)}/v1/files/{encoded_file_id}{suffix}" + + def get_error_class( + self, error_message: str, status_code: int, headers: Mapping[str, str] | httpx.Headers + ) -> BaseLLMException: + return mistral_error(error_message, status_code, headers) + + def validate_environment( + self, + headers: Mapping[str, str], + model: str, + messages: Sequence[object], + optional_params: Mapping[str, object], + litellm_params: Mapping[str, object], + api_key: str | None = None, + api_base: str | None = None, + ) -> dict[str, str]: # mutable-ok: BaseFilesConfig signature + return get_mistral_auth_headers(headers, api_key) + + def get_supported_openai_params( + self, model: str + ) -> list[OpenAICreateFileRequestOptionalParams]: # mutable-ok: BaseFilesConfig signature + return ["purpose"] # mutable-ok: BaseFilesConfig signature + + def map_openai_params( + self, + non_default_params: Mapping[str, object], + optional_params: dict[str, object], # mutable-ok: BaseConfig signature, returned as-is + model: str, + drop_params: bool, + ) -> dict[str, object]: # mutable-ok: BaseConfig signature + return optional_params + + def transform_create_file_request( + self, + model: str, + create_file_data: CreateFileRequest, + optional_params: Mapping[str, object], + litellm_params: Mapping[str, object], + ) -> dict[str, object]: # mutable-ok: BaseFilesConfig signature + if "file" not in create_file_data: + raise ValueError("File data is required") + extracted: Final = extract_file_data(create_file_data["file"]) + filename: Final = extracted["filename"] or f"file_{int(time.time())}.jsonl" + content_type: Final = extracted.get("content_type") or "application/octet-stream" + upload: Final = MistralMultipartUpload( + file=(filename, extracted["content"], content_type), + purpose=(None, _to_mistral_purpose(create_file_data.get("purpose") or "batch")), + ) + return dict(upload) # mutable-ok: BaseFilesConfig signature + + def transform_create_file_response( + self, + model: str | None, + raw_response: httpx.Response, + logging_obj: LiteLLMLoggingObj, + litellm_params: Mapping[str, object], + ) -> OpenAIFileObject: + return _to_openai_file_object(MistralFile.model_validate(raw_response.json())) + + def transform_retrieve_file_request( + self, + file_id: str, + optional_params: Mapping[str, object], + litellm_params: Mapping[str, object], + ) -> tuple[str, dict[str, str]]: # mutable-ok: BaseFilesConfig signature + return self._file_url(file_id, litellm_params), _NO_QUERY_PARAMS + + def transform_retrieve_file_response( + self, + raw_response: httpx.Response, + logging_obj: LiteLLMLoggingObj, + litellm_params: Mapping[str, object], + ) -> OpenAIFileObject: + return _to_openai_file_object(MistralFile.model_validate(raw_response.json())) + + def transform_delete_file_request( + self, + file_id: str, + optional_params: Mapping[str, object], + litellm_params: Mapping[str, object], + ) -> tuple[str, dict[str, str]]: # mutable-ok: BaseFilesConfig signature + return self._file_url(file_id, litellm_params), _NO_QUERY_PARAMS + + def transform_delete_file_response( + self, + raw_response: httpx.Response, + logging_obj: LiteLLMLoggingObj, + litellm_params: Mapping[str, object], + ) -> FileDeleted: + deleted: Final = MistralFileDeleted.model_validate(raw_response.json()) + return FileDeleted(id=deleted.id, deleted=deleted.deleted, object="file") + + def transform_list_files_request( + self, + purpose: str | None, + optional_params: Mapping[str, object], + litellm_params: Mapping[str, object], + ) -> tuple[str, dict[str, str]]: # mutable-ok: BaseFilesConfig signature + url: Final = f"{_api_base_from(litellm_params)}/v1/files" + if not purpose: + return url, _NO_QUERY_PARAMS + return url, {"purpose": _to_mistral_purpose(purpose)} # mutable-ok: BaseFilesConfig signature returns dict + + def transform_list_files_response( + self, + raw_response: httpx.Response, + logging_obj: LiteLLMLoggingObj, + litellm_params: Mapping[str, object], + ) -> list[OpenAIFileObject]: # mutable-ok: BaseFilesConfig signature + return [ # mutable-ok: BaseFilesConfig signature + _to_openai_file_object(f) for f in MistralFileList.model_validate(raw_response.json()).data + ] + + def transform_file_content_request( + self, + file_content_request: FileContentRequest, + optional_params: Mapping[str, object], + litellm_params: Mapping[str, object], + ) -> tuple[str, dict[str, str]]: # mutable-ok: BaseFilesConfig signature + file_id: Final = file_content_request.get("file_id") + if file_id is None: + raise ValueError("file_id is required to download file content") + return self._file_url(file_id, litellm_params, suffix="/content"), _NO_QUERY_PARAMS + + def transform_file_content_response( + self, + raw_response: httpx.Response, + logging_obj: LiteLLMLoggingObj, + litellm_params: Mapping[str, object], + ) -> HttpxBinaryResponseContent: + return HttpxBinaryResponseContent(response=raw_response) diff --git a/litellm/model_prices_and_context_window_backup.json b/litellm/model_prices_and_context_window_backup.json index de387552a44..32619a86247 100644 --- a/litellm/model_prices_and_context_window_backup.json +++ b/litellm/model_prices_and_context_window_backup.json @@ -37476,51 +37476,66 @@ "mistral/mistral-ocr-latest": { "litellm_provider": "mistral", "ocr_cost_per_page": 0.004, + "ocr_cost_per_page_batches": 0.002, "annotation_cost_per_page": 0.005, + "annotation_cost_per_page_batches": 0.0025, "mode": "ocr", "supported_endpoints": [ - "/v1/ocr" + "/v1/ocr", + "/v1/batch" ], "source": "https://mistral.ai/pricing#api-pricing" }, "mistral/mistral-ocr-4-0": { "litellm_provider": "mistral", "ocr_cost_per_page": 0.004, + "ocr_cost_per_page_batches": 0.002, "annotation_cost_per_page": 0.005, + "annotation_cost_per_page_batches": 0.0025, "mode": "ocr", "supported_endpoints": [ - "/v1/ocr" + "/v1/ocr", + "/v1/batch" ], "source": "https://mistral.ai/pricing#api-pricing" }, "mistral/mistral-ocr-4-1": { "annotation_cost_per_page": 0.005, + "annotation_cost_per_page_batches": 0.0025, "litellm_provider": "mistral", "mode": "ocr", "ocr_cost_per_page": 0.004, + "ocr_cost_per_page_batches": 0.002, "source": "https://docs.mistral.ai/models/model-cards/ocr-4-1", "supported_endpoints": [ - "/v1/ocr" + "/v1/ocr", + "/v1/batch" ] }, "mistral/mistral-ocr-2505-completion": { "deprecation_date": "2026-05-31", "litellm_provider": "mistral", "ocr_cost_per_page": 0.001, + "ocr_cost_per_page_batches": 0.0005, "annotation_cost_per_page": 0.003, + "annotation_cost_per_page_batches": 0.0015, "mode": "ocr", "supported_endpoints": [ - "/v1/ocr" + "/v1/ocr", + "/v1/batch" ], "source": "https://mistral.ai/pricing#api-pricing" }, "mistral/mistral-ocr-2512": { "litellm_provider": "mistral", "ocr_cost_per_page": 0.002, + "ocr_cost_per_page_batches": 0.001, "annotation_cost_per_page": 0.003, + "annotation_cost_per_page_batches": 0.0015, "mode": "ocr", "supported_endpoints": [ - "/v1/ocr" + "/v1/ocr", + "/v1/batch" ], "source": "https://mistral.ai/pricing#api-pricing" }, @@ -63741,31 +63756,40 @@ "mistral/mistral-ocr-3": { "litellm_provider": "mistral", "ocr_cost_per_page": 0.002, + "ocr_cost_per_page_batches": 0.001, "annotation_cost_per_page": 0.003, + "annotation_cost_per_page_batches": 0.0015, "mode": "ocr", "supported_endpoints": [ - "/v1/ocr" + "/v1/ocr", + "/v1/batch" ], "source": "https://mistral.ai/pricing#api-pricing" }, "mistral/mistral-ocr-3-0": { "litellm_provider": "mistral", "ocr_cost_per_page": 0.002, + "ocr_cost_per_page_batches": 0.001, "annotation_cost_per_page": 0.003, + "annotation_cost_per_page_batches": 0.0015, "mode": "ocr", "supported_endpoints": [ - "/v1/ocr" + "/v1/ocr", + "/v1/batch" ], "source": "https://mistral.ai/pricing#api-pricing" }, "mistral/mistral-ocr-4": { "annotation_cost_per_page": 0.005, + "annotation_cost_per_page_batches": 0.0025, "litellm_provider": "mistral", "mode": "ocr", "ocr_cost_per_page": 0.004, + "ocr_cost_per_page_batches": 0.002, "source": "https://docs.mistral.ai/models/model-cards/ocr-4-1", "supported_endpoints": [ - "/v1/ocr" + "/v1/ocr", + "/v1/batch" ] }, "mistral/voxtral-mini-latest": { diff --git a/litellm/provider_endpoints_support_backup.json b/litellm/provider_endpoints_support_backup.json index dbeaccdda2d..30c1e0b894e 100644 --- a/litellm/provider_endpoints_support_backup.json +++ b/litellm/provider_endpoints_support_backup.json @@ -1462,7 +1462,7 @@ "audio_transcriptions": false, "audio_speech": false, "moderations": false, - "batches": false, + "batches": true, "rerank": false, "ocr": true, "a2a": true, diff --git a/litellm/proxy/batches_endpoints/endpoints.py b/litellm/proxy/batches_endpoints/endpoints.py index 5d9ecddd4c2..0e348fa6e06 100644 --- a/litellm/proxy/batches_endpoints/endpoints.py +++ b/litellm/proxy/batches_endpoints/endpoints.py @@ -32,16 +32,18 @@ from litellm.proxy.common_utils.openai_endpoint_utils import ( from litellm.proxy.openai_files_endpoints.common_utils import ( BATCH_CREATE_HIDDEN_PARAM, _is_base64_encoded_unified_file_id, + add_deployment_model_info, add_internal_model_credentials, apply_team_provider_credentials, + authorize_model_for_key, batch_cost_poller_is_active, decode_model_from_file_id, encode_batch_response_ids, encode_file_id_with_model, ensure_batch_response_managed_file_ids, + get_authorized_credentials_for_model, get_batch_from_database, get_batch_id_from_unified_batch_id, - get_credentials_for_model, get_model_id_from_unified_batch_id, get_models_from_unified_file_id, get_original_file_id, @@ -223,9 +225,10 @@ async def create_batch( # SCENARIO 1: File ID is encoded with model info if model_from_file_id is not None and input_file_id: - credentials = get_credentials_for_model( + credentials = await get_authorized_credentials_for_model( llm_router=llm_router, model_id=model_from_file_id, + user_api_key_dict=user_api_key_dict, operation_context="batch creation (file created with model)", ) @@ -290,6 +293,7 @@ async def create_batch( detail={"error": f"Expected 1 model, got {len(target_model_names)}"}, ) model: Final = target_model_names[0] + await authorize_model_for_key(model_id=model, llm_router=llm_router, user_api_key_dict=user_api_key_dict) _create_batch_data["model"] = model resolved_storage_url: Final = await _resolve_managed_input_file_storage_url(input_file_id) @@ -315,9 +319,10 @@ async def create_batch( # SCENARIO 2 & 3: Model from header/query OR custom_llm_provider fallback if model_param: # SCENARIO 2: Use model-based routing from header/query/body - credentials = get_credentials_for_model( + credentials = await get_authorized_credentials_for_model( llm_router=llm_router, model_id=model_param, + user_api_key_dict=user_api_key_dict, operation_context="batch creation", ) @@ -466,6 +471,17 @@ async def retrieve_batch( route_type="aretrieve_batch", ) + unified_model_id: Final = get_model_id_from_unified_batch_id(unified_batch_id) if unified_batch_id else None + if unified_model_id is not None: + resolved_unified_model: Final = ( + llm_router.resolve_model_name_from_model_id(unified_model_id) if llm_router is not None else None + ) + await authorize_model_for_key( + model_id=resolved_unified_model or unified_model_id, + llm_router=llm_router, + user_api_key_dict=user_api_key_dict, + ) + # FIX: First, try to read from ManagedObjectTable for consistent state managed_files_obj: Final = proxy_logging_obj.get_proxy_hook("managed_files") from litellm.proxy.proxy_server import prisma_client @@ -546,9 +562,10 @@ async def retrieve_batch( # Retrieve from provider (for non-terminal states or if DB lookup failed) # SCENARIO 1: Batch ID is encoded with model info if model_from_id is not None: - credentials: Final = get_credentials_for_model( + credentials: Final = await get_authorized_credentials_for_model( llm_router=llm_router, model_id=model_from_id, + user_api_key_dict=user_api_key_dict, operation_context="batch retrieval (batch created with model)", ) @@ -564,6 +581,7 @@ async def retrieve_batch( # so litellm.aretrieve_batch can load BedrockBatchesConfig. Without # it the call falls into the legacy provider switch and 400s. data["model"] = model_from_id + add_deployment_model_info(data=data, llm_router=llm_router, model_id=model_from_id) # Retrieve batch using model credentials response = await litellm.aretrieve_batch( @@ -588,7 +606,7 @@ async def retrieve_batch( add_internal_model_credentials( data=data, llm_router=llm_router, - model_id=get_model_id_from_unified_batch_id(unified_batch_id), + model_id=unified_model_id, ) response = await llm_router.aretrieve_batch(**data) @@ -772,9 +790,10 @@ async def list_batches( data.get("model") or request.query_params.get("model") or request.headers.get("x-litellm-model") ): # SCENARIO 2: Use model-based routing from header/query/body - credentials: Final = get_credentials_for_model( + credentials: Final = await get_authorized_credentials_for_model( llm_router=llm_router, model_id=model_param, + user_api_key_dict=user_api_key_dict, operation_context="batch listing", ) @@ -961,9 +980,10 @@ async def cancel_batch( # SCENARIO 1: Batch ID is encoded with model info if model_from_id is not None: - credentials: Final = get_credentials_for_model( + credentials: Final = await get_authorized_credentials_for_model( llm_router=llm_router, model_id=model_from_id, + user_api_key_dict=user_api_key_dict, operation_context="batch cancellation (batch created with model)", ) @@ -1002,6 +1022,11 @@ async def cancel_batch( status_code=400, detail={"error": "Invalid LiteLLM managed batch ID. Missing model_id."}, ) + await authorize_model_for_key( + model_id=llm_router.resolve_model_name_from_model_id(model_id_from_batch) or model_id_from_batch, + llm_router=llm_router, + user_api_key_dict=user_api_key_dict, + ) data["model"] = model_id_from_batch data["batch_id"] = get_batch_id_from_unified_batch_id(unified_batch_id) response = await llm_router.acancel_batch(**data) diff --git a/litellm/proxy/openai_files_endpoints/common_utils.py b/litellm/proxy/openai_files_endpoints/common_utils.py index 38a907892b4..3d6c72de09f 100644 --- a/litellm/proxy/openai_files_endpoints/common_utils.py +++ b/litellm/proxy/openai_files_endpoints/common_utils.py @@ -350,6 +350,10 @@ def get_credentials_for_model( """ Retrieve API credentials for a model from the LLM Router. + Does not check whether the caller may use ``model_id``; use + ``get_authorized_credentials_for_model`` for anything driven by a caller-supplied + model name (request body, header, query param, or a model-encoded resource id). + Args: llm_router: LiteLLM Router instance model_id: Model name or deployment ID @@ -380,6 +384,48 @@ def get_credentials_for_model( return credentials +async def authorize_model_for_key( + model_id: str, + llm_router: Optional["Router"], + user_api_key_dict: "UserAPIKeyAuth", +) -> None: + """ + Enforce the caller's model grants on a model name the auth layer never saw. + + The files and batches routes carry their model in a header, query param, or a + model-encoded resource id rather than the request body, so ``user_api_key_auth`` + cannot check it. Run the same key, team (incl. team-member and access-group + fallbacks), org and project allowlist checks a chat request would get, so a + restricted key cannot borrow another deployment's server-side credentials. + + Raises: + ProxyException (403): the caller is not allowed to use ``model_id`` + """ + from litellm.proxy.auth.auth_checks import can_key_call_resolved_model + + await can_key_call_resolved_model( + model=model_id, + llm_model_list=None, + valid_token=user_api_key_dict, + llm_router=llm_router, + ) + + +async def get_authorized_credentials_for_model( + llm_router: Optional["Router"], + model_id: str, + user_api_key_dict: "UserAPIKeyAuth", + operation_context: str = "file operation", +) -> dict: # mutable-ok: same contract as get_credentials_for_model, callers merge it into request data + """``get_credentials_for_model`` gated by ``authorize_model_for_key``.""" + await authorize_model_for_key(model_id=model_id, llm_router=llm_router, user_api_key_dict=user_api_key_dict) + return get_credentials_for_model( + llm_router=llm_router, + model_id=model_id, + operation_context=operation_context, + ) + + def get_team_provider_credentials( llm_router: Optional["Router"], user_api_key_dict: "UserAPIKeyAuth", @@ -547,6 +593,25 @@ def add_internal_model_credentials( data["_litellm_internal_model_credentials"] = MappingProxyType(dict(credentials)) +def add_deployment_model_info( + data: dict, + llm_router: Optional["Router"], + model_id: str, +) -> None: + """ + Stamp the resolved deployment's `model_info` onto a direct (non-router) batch call + (in-place), the way the router does for routed calls, so the completed batch is + priced by its deployment id instead of the published model rate. + """ + deployment: Final = llm_router.get_credential_deployment(model_id=model_id) if llm_router is not None else None + if deployment is None: + return + data["litellm_metadata"] = { + **(data.get("litellm_metadata") or {}), + "model_info": deployment.model_info.model_dump(), + } + + def prepare_data_with_credentials( data: dict, credentials: dict, @@ -572,21 +637,27 @@ def prepare_data_with_credentials( data["file_id"] = file_id -def handle_model_based_routing( +async def handle_model_based_routing( file_id: str, request, # FastAPI Request object llm_router, # Router instance data: dict, + user_api_key_dict: "UserAPIKeyAuth", check_file_id_encoding: bool = True, ) -> tuple[bool, str | None, str | None, dict | None]: """ Orchestrate model-based credential routing for file operations. + The model name comes from the caller (embedded in the file id, or a header, query + param or body field), so it is authorized against the caller's key, team, org and + project grants before any deployment credentials are resolved. + Args: file_id: File ID (may contain embedded model info) request: FastAPI request object llm_router: LiteLLM Router instance data: Request data dictionary + user_api_key_dict: The authenticated caller check_file_id_encoding: Whether to check for embedded model in file_id Returns: @@ -598,6 +669,7 @@ def handle_model_based_routing( Raises: HTTPException: If router unavailable or model not found + ProxyException: If the caller is not allowed to use the model """ model_from_id, model_from_param = extract_model_from_sources( file_id=file_id, @@ -607,9 +679,10 @@ def handle_model_based_routing( # Priority 1: Model embedded in file_id if check_file_id_encoding and model_from_id is not None: - credentials = get_credentials_for_model( + credentials = await get_authorized_credentials_for_model( llm_router=llm_router, model_id=model_from_id, + user_api_key_dict=user_api_key_dict, operation_context=f"file operation (file created with model '{model_from_id}')", ) original_file_id: Final = get_original_file_id(file_id) @@ -617,9 +690,10 @@ def handle_model_based_routing( # Priority 2: Model from header/query/body elif model_from_param is not None: - credentials = get_credentials_for_model( + credentials = await get_authorized_credentials_for_model( llm_router=llm_router, model_id=model_from_param, + user_api_key_dict=user_api_key_dict, operation_context="file operation", ) return True, model_from_param, None, credentials diff --git a/litellm/proxy/openai_files_endpoints/files_endpoints.py b/litellm/proxy/openai_files_endpoints/files_endpoints.py index ae6e222a863..91fce11a871 100644 --- a/litellm/proxy/openai_files_endpoints/files_endpoints.py +++ b/litellm/proxy/openai_files_endpoints/files_endpoints.py @@ -69,7 +69,7 @@ from litellm.proxy.openai_files_endpoints.common_utils import ( apply_team_provider_credentials, encode_file_id_with_model, extract_file_creation_params, - get_credentials_for_model, + get_authorized_credentials_for_model, handle_model_based_routing, prepare_data_with_credentials, validate_file_list_limit, @@ -271,9 +271,10 @@ async def route_create_file( # NEW: Handle model-based routing (no DB required) if model is not None: # Get credentials from model_list via router - credentials: Final = get_credentials_for_model( + credentials: Final = await get_authorized_credentials_for_model( llm_router=llm_router, model_id=model, + user_api_key_dict=user_api_key_dict, operation_context="file upload", ) @@ -916,11 +917,12 @@ async def get_file_content( model_used, original_file_id, credentials, - ) = handle_model_based_routing( + ) = await handle_model_based_routing( file_id=file_id, request=request, llm_router=llm_router, data=data, + user_api_key_dict=user_api_key_dict, check_file_id_encoding=True, ) @@ -1131,15 +1133,16 @@ async def get_file( model_used, original_file_id, credentials, - ) = handle_model_based_routing( + ) = await handle_model_based_routing( file_id=file_id, request=request, llm_router=llm_router, data=data, + user_api_key_dict=user_api_key_dict, check_file_id_encoding=True, ) - if should_route: + if should_route and credentials is not None: # Use model-based routing with credentials from config prepare_data_with_credentials( data=data, @@ -1148,7 +1151,10 @@ async def get_file( include_internal_credentials=True, ) - response = await litellm.afile_retrieve(**data) + response = await litellm.afile_retrieve( + custom_llm_provider=credentials["custom_llm_provider"], + **data, + ) # Keep the encoded ID in response if it was originally encoded if original_file_id and response and hasattr(response, "id") and response.id: @@ -1341,11 +1347,12 @@ async def delete_file( model_used, original_file_id, credentials, - ) = handle_model_based_routing( + ) = await handle_model_based_routing( file_id=file_id, request=request, llm_router=llm_router, data=data, + user_api_key_dict=user_api_key_dict, check_file_id_encoding=True, ) @@ -1534,11 +1541,12 @@ async def list_files( response: Any | None = None # Check for model-based credential routing (no file_id encoding check for list) - should_route, model_used, _, credentials = handle_model_based_routing( + should_route, model_used, _, credentials = await handle_model_based_routing( file_id="", # No file_id for list endpoint request=request, llm_router=llm_router, data=data, + user_api_key_dict=user_api_key_dict, check_file_id_encoding=False, ) @@ -1565,9 +1573,10 @@ async def list_files( status_code=500, detail="LLM Router not initialized. Ensure models added to proxy.", ) - credentials = get_credentials_for_model( + credentials = await get_authorized_credentials_for_model( llm_router=llm_router, model_id=target_model_names_list[0], + user_api_key_dict=user_api_key_dict, operation_context="file list", ) prepare_data_with_credentials(data=data, credentials=credentials, include_internal_credentials=True) diff --git a/litellm/proxy/vector_store_files_endpoints/endpoints.py b/litellm/proxy/vector_store_files_endpoints/endpoints.py index 957ed9fd0b9..97367e59023 100644 --- a/litellm/proxy/vector_store_files_endpoints/endpoints.py +++ b/litellm/proxy/vector_store_files_endpoints/endpoints.py @@ -5,7 +5,6 @@ from fastapi.responses import ORJSONResponse import litellm from litellm.proxy._types import UserAPIKeyAuth -from litellm.proxy.auth.auth_checks import _can_object_call_model, can_key_call_model from litellm.proxy.auth.user_api_key_auth import user_api_key_auth from litellm.proxy.common_request_processing import ProxyBaseLLMRequestProcessing from litellm.proxy.common_utils.openai_endpoint_utils import ( @@ -14,6 +13,8 @@ from litellm.proxy.common_utils.openai_endpoint_utils import ( get_custom_llm_provider_from_request_query, ) from litellm.proxy.openai_files_endpoints.common_utils import ( + authorize_model_for_key, + get_credentials_for_model, handle_model_based_routing, prepare_data_with_credentials, ) @@ -144,11 +145,12 @@ async def _update_request_data_with_managed_file_id( model_used, original_file_id, credentials, - ) = handle_model_based_routing( + ) = await handle_model_based_routing( file_id=file_id, request=request, llm_router=llm_router, data=data, + user_api_key_dict=user_api_key_dict, check_file_id_encoding=True, ) @@ -210,26 +212,7 @@ async def _authorize_model_routing_hint( ) -> None: if user_api_key_dict is None: return - - key_models: Final = getattr(user_api_key_dict, "models", None) - if not (isinstance(key_models, list) and "all-team-models" in key_models): - await can_key_call_model( - model=model, - llm_model_list=None, - valid_token=user_api_key_dict, - llm_router=llm_router, - ) - - team_models: Final = getattr(user_api_key_dict, "team_models", None) - if isinstance(team_models, list) and len(team_models) > 0: - _can_object_call_model( - model=model, - llm_router=llm_router, - models=team_models, - team_model_aliases=user_api_key_dict.team_model_aliases, - team_id=user_api_key_dict.team_id, - object_type="team", - ) + await authorize_model_for_key(model_id=model, llm_router=llm_router, user_api_key_dict=user_api_key_dict) async def _update_request_data_with_model_routing_hint( @@ -261,25 +244,15 @@ async def _update_request_data_with_model_routing_hint( model_id=model_hint, team_id=caller_team_id ) should_route = credentials is not None - else: - if isinstance(model_hint, str) and should_authorize_model_hint: + elif isinstance(model_hint, str): + if should_authorize_model_hint: await _authorize_model_routing_hint( model=model_hint, llm_router=llm_router, user_api_key_dict=user_api_key_dict, ) - ( - should_route, - _model_used, - _original_file_id, - credentials, - ) = handle_model_based_routing( - file_id="", - request=request, - llm_router=llm_router, - data=data, - check_file_id_encoding=False, - ) + credentials = get_credentials_for_model(llm_router=llm_router, model_id=model_hint) + should_route = True if should_route and credentials is not None: prepare_data_with_credentials( diff --git a/litellm/router.py b/litellm/router.py index 74adf6f909d..3f3daaf2eae 100644 --- a/litellm/router.py +++ b/litellm/router.py @@ -10313,6 +10313,55 @@ class Router: return display_name return None + def get_credential_deployment(self, model_id: str, team_id: str | None = None) -> Deployment | None: + """ + The deployment a passthrough endpoint (files, batches, etc.) resolves for a + model id or model name: by deployment id first, then by model_name, then by + the team's exact public model name, then by wildcard pattern (team wildcards + before global ones, so a global "openai/*" never shadows the team's own + entry). Name and wildcard lookups never resolve another team's deployment. + + Returns None when nothing matches or the match is paused via + `LiteLLM_ProxyModelTable.blocked`, so callers cannot bypass an admin pause + by resolving the deployment directly. + """ + deployment: Final = ( + self.get_deployment(model_id=model_id) + or self._get_model_group_deployment_usable_by_team(model_group_name=model_id, team_id=team_id) + or self._get_team_public_name_deployment(model_id=model_id, team_id=team_id) + or self._get_wildcard_deployment_usable_by_team(model_id=model_id, team_id=team_id) + ) + if deployment is None or self._is_deployment_blocked(deployment): + return None + return deployment + + def _get_team_public_name_deployment(self, model_id: str, team_id: str | None) -> Deployment | None: + if team_id is None: + return None + team_indices: Final = self.team_model_to_deployment_indices.get((team_id, model_id)) + if not team_indices: + return None + team_model: Final = self.model_list[team_indices[0]] + return Deployment(**team_model) if isinstance(team_model, dict) else team_model + + def _get_wildcard_deployment_usable_by_team(self, model_id: str, team_id: str | None) -> Deployment | None: + team_pattern_router: Final = self.team_pattern_routers.get(team_id) if team_id is not None else None + team_wildcard_models: Final = team_pattern_router.route(model_id) if team_pattern_router else None + global_wildcard_models: Final = tuple( + wildcard_model + for wildcard_model in (self.pattern_router.route(model_id) or ()) + if self._deployment_usable_by_team(wildcard_model, team_id) + ) + potential_wildcard_models: Final = team_wildcard_models or global_wildcard_models + if not potential_wildcard_models: + return None + wildcard_deployment: Final = potential_wildcard_models[0] + if isinstance(wildcard_deployment, dict): + return Deployment(**wildcard_deployment) + if isinstance(wildcard_deployment, Deployment): + return wildcard_deployment + return None + def get_deployment_credentials_with_provider( self, model_id: str, team_id: str | None = None ) -> dict[str, Any] | None: @@ -10320,8 +10369,8 @@ class Router: Get API credentials and provider info from a model name in model_list. Useful for passthrough endpoints (files, batches, etc.) that need credentials. - This method tries to find a deployment by model_id first, and if not found, - it tries to find by model_group_name (model_name). + Resolves the deployment with `get_credential_deployment` (by deployment id, + then model_name, team public model name, and wildcard pattern). Args: model_id: Model ID or model name from model_list (e.g., "gpt-4o-litellm") @@ -10342,43 +10391,8 @@ class Router: credentials = router.get_deployment_credentials_with_provider("gpt-4o-litellm") # Returns: {"api_key": "sk-...", "custom_llm_provider": "openai", "model": "gpt-4o", ...} """ - # Try to get deployment by model_id first - deployment = self.get_deployment(model_id=model_id) - - # If not found, try by model_group_name + deployment: Final = self.get_credential_deployment(model_id=model_id, team_id=team_id) if deployment is None: - deployment = self._get_model_group_deployment_usable_by_team(model_group_name=model_id, team_id=team_id) - - # If not found, check team-scoped deployments whose team public model - # name exactly matches model_id (wildcard team names are matched via - # team_pattern_routers below). - if deployment is None and team_id is not None: - team_indices: Final = self.team_model_to_deployment_indices.get((team_id, model_id), []) - if team_indices: - team_model: Final = self.model_list[team_indices[0]] - deployment = Deployment(**team_model) if isinstance(team_model, dict) else team_model - - # If still not found, check for wildcard pattern matches. Team wildcard - # matches take priority so a global pattern (e.g. "openai/*") doesn't - # shadow the team's own entry. - if deployment is None: - team_pattern_router: Final = self.team_pattern_routers.get(team_id) if team_id is not None else None - team_wildcard_models: Final = (team_pattern_router.route(model_id) or []) if team_pattern_router else [] - global_wildcard_models: Final = [ - wildcard_model - for wildcard_model in (self.pattern_router.route(model_id) or []) - if self._deployment_usable_by_team(wildcard_model, team_id) - ] - potential_wildcard_models: Final = team_wildcard_models or global_wildcard_models - if potential_wildcard_models: - # Use the first matching wildcard deployment - deployment_dict: Final = potential_wildcard_models[0] - if isinstance(deployment_dict, dict): - deployment = Deployment(**deployment_dict) - elif isinstance(deployment_dict, Deployment): - deployment = deployment_dict - - if deployment is None or self._is_deployment_blocked(deployment): return None # Get basic credentials diff --git a/litellm/types/llms/openai.py b/litellm/types/llms/openai.py index 39280774cda..dd0518237c1 100644 --- a/litellm/types/llms/openai.py +++ b/litellm/types/llms/openai.py @@ -501,7 +501,7 @@ class CreateBatchRequest(TypedDict, total=False): """ completion_window: Literal["24h"] - endpoint: Literal["/v1/chat/completions", "/v1/embeddings", "/v1/completions", "/v1/responses"] + endpoint: Literal["/v1/chat/completions", "/v1/embeddings", "/v1/completions", "/v1/responses", "/v1/ocr"] input_file_id: str metadata: dict[str, str] | None output_expires_after: FileExpiresAfter diff --git a/litellm/types/utils.py b/litellm/types/utils.py index 1ee31ac78de..378f8fec9d5 100644 --- a/litellm/types/utils.py +++ b/litellm/types/utils.py @@ -329,8 +329,10 @@ class ModelInfoBase(ProviderSpecificModelInfo, total=False): output_cost_per_second_720p: ReadOnly[float | None] output_cost_per_second_4k: ReadOnly[float | None] ocr_cost_per_page: float | None # for OCR models + ocr_cost_per_page_batches: ReadOnly[float | None] ocr_cost_per_credit: float | None # for OCR models priced by credit annotation_cost_per_page: float | None # for OCR models + annotation_cost_per_page_batches: ReadOnly[float | None] search_context_cost_per_query: SearchContextCostPerQuery | None # Cost for using web search tool web_search_billing_unit: ( Literal["per_query", "per_prompt"] | None @@ -3669,8 +3671,10 @@ class CustomPricingLiteLLMParams(MirroredPricingParams): output_cost_per_token_above_512k_tokens: float | None = None output_vector_size: int | None = None ocr_cost_per_page: float | None = None + ocr_cost_per_page_batches: float | None = None ocr_cost_per_credit: float | None = None annotation_cost_per_page: float | None = None + annotation_cost_per_page_batches: float | None = None regional_processing_uplift_multiplier_eu: float | None = None regional_processing_uplift_multiplier_us: float | None = None regional_endpoint_uplift_multiplier: float | None = None diff --git a/litellm/utils.py b/litellm/utils.py index 430052b7935..5c5fe8289f3 100644 --- a/litellm/utils.py +++ b/litellm/utils.py @@ -6118,8 +6118,10 @@ def _get_model_info_helper( tpm=_model_info.get("tpm", None), rpm=_model_info.get("rpm", None), ocr_cost_per_page=_model_info.get("ocr_cost_per_page", None), + ocr_cost_per_page_batches=_model_info.get("ocr_cost_per_page_batches", None), ocr_cost_per_credit=_model_info.get("ocr_cost_per_credit", None), annotation_cost_per_page=_model_info.get("annotation_cost_per_page", None), + annotation_cost_per_page_batches=_model_info.get("annotation_cost_per_page_batches", None), provider_specific_entry=_model_info.get("provider_specific_entry", None), uses_embed_content=_model_info.get("uses_embed_content", None), supports_image_size=_model_info.get("supports_image_size", None), @@ -9114,6 +9116,10 @@ class ProviderConfigManager: from litellm.llms.anthropic.files.transformation import AnthropicFilesConfig return AnthropicFilesConfig() + elif LlmProviders.MISTRAL == provider: + from litellm.llms.mistral.files.transformation import MistralFilesConfig + + return MistralFilesConfig() return None @staticmethod @@ -9125,6 +9131,10 @@ class ProviderConfigManager: from litellm.llms.bedrock.batches.transformation import BedrockBatchesConfig return BedrockBatchesConfig() + elif LlmProviders.MISTRAL == provider: + from litellm.llms.mistral.batches.transformation import MistralBatchesConfig + + return MistralBatchesConfig() return None @staticmethod diff --git a/model_prices_and_context_window.json b/model_prices_and_context_window.json index de387552a44..32619a86247 100644 --- a/model_prices_and_context_window.json +++ b/model_prices_and_context_window.json @@ -37476,51 +37476,66 @@ "mistral/mistral-ocr-latest": { "litellm_provider": "mistral", "ocr_cost_per_page": 0.004, + "ocr_cost_per_page_batches": 0.002, "annotation_cost_per_page": 0.005, + "annotation_cost_per_page_batches": 0.0025, "mode": "ocr", "supported_endpoints": [ - "/v1/ocr" + "/v1/ocr", + "/v1/batch" ], "source": "https://mistral.ai/pricing#api-pricing" }, "mistral/mistral-ocr-4-0": { "litellm_provider": "mistral", "ocr_cost_per_page": 0.004, + "ocr_cost_per_page_batches": 0.002, "annotation_cost_per_page": 0.005, + "annotation_cost_per_page_batches": 0.0025, "mode": "ocr", "supported_endpoints": [ - "/v1/ocr" + "/v1/ocr", + "/v1/batch" ], "source": "https://mistral.ai/pricing#api-pricing" }, "mistral/mistral-ocr-4-1": { "annotation_cost_per_page": 0.005, + "annotation_cost_per_page_batches": 0.0025, "litellm_provider": "mistral", "mode": "ocr", "ocr_cost_per_page": 0.004, + "ocr_cost_per_page_batches": 0.002, "source": "https://docs.mistral.ai/models/model-cards/ocr-4-1", "supported_endpoints": [ - "/v1/ocr" + "/v1/ocr", + "/v1/batch" ] }, "mistral/mistral-ocr-2505-completion": { "deprecation_date": "2026-05-31", "litellm_provider": "mistral", "ocr_cost_per_page": 0.001, + "ocr_cost_per_page_batches": 0.0005, "annotation_cost_per_page": 0.003, + "annotation_cost_per_page_batches": 0.0015, "mode": "ocr", "supported_endpoints": [ - "/v1/ocr" + "/v1/ocr", + "/v1/batch" ], "source": "https://mistral.ai/pricing#api-pricing" }, "mistral/mistral-ocr-2512": { "litellm_provider": "mistral", "ocr_cost_per_page": 0.002, + "ocr_cost_per_page_batches": 0.001, "annotation_cost_per_page": 0.003, + "annotation_cost_per_page_batches": 0.0015, "mode": "ocr", "supported_endpoints": [ - "/v1/ocr" + "/v1/ocr", + "/v1/batch" ], "source": "https://mistral.ai/pricing#api-pricing" }, @@ -63741,31 +63756,40 @@ "mistral/mistral-ocr-3": { "litellm_provider": "mistral", "ocr_cost_per_page": 0.002, + "ocr_cost_per_page_batches": 0.001, "annotation_cost_per_page": 0.003, + "annotation_cost_per_page_batches": 0.0015, "mode": "ocr", "supported_endpoints": [ - "/v1/ocr" + "/v1/ocr", + "/v1/batch" ], "source": "https://mistral.ai/pricing#api-pricing" }, "mistral/mistral-ocr-3-0": { "litellm_provider": "mistral", "ocr_cost_per_page": 0.002, + "ocr_cost_per_page_batches": 0.001, "annotation_cost_per_page": 0.003, + "annotation_cost_per_page_batches": 0.0015, "mode": "ocr", "supported_endpoints": [ - "/v1/ocr" + "/v1/ocr", + "/v1/batch" ], "source": "https://mistral.ai/pricing#api-pricing" }, "mistral/mistral-ocr-4": { "annotation_cost_per_page": 0.005, + "annotation_cost_per_page_batches": 0.0025, "litellm_provider": "mistral", "mode": "ocr", "ocr_cost_per_page": 0.004, + "ocr_cost_per_page_batches": 0.002, "source": "https://docs.mistral.ai/models/model-cards/ocr-4-1", "supported_endpoints": [ - "/v1/ocr" + "/v1/ocr", + "/v1/batch" ] }, "mistral/voxtral-mini-latest": { diff --git a/model_prices_and_context_window.schema.json b/model_prices_and_context_window.schema.json index f924df1f1b2..8d79c560175 100644 --- a/model_prices_and_context_window.schema.json +++ b/model_prices_and_context_window.schema.json @@ -53,6 +53,10 @@ "type": "number", "minimum": 0 }, + "annotation_cost_per_page_batches": { + "type": "number", + "minimum": 0 + }, "audio_transcription_config": { "type": "string" }, @@ -449,6 +453,10 @@ "type": "number", "minimum": 0 }, + "ocr_cost_per_page_batches": { + "type": "number", + "minimum": 0 + }, "output_cost_per_audio_token": { "type": "number", "minimum": 0 diff --git a/provider_endpoints_support.json b/provider_endpoints_support.json index c71f4a82a4a..af9b194bbee 100644 --- a/provider_endpoints_support.json +++ b/provider_endpoints_support.json @@ -1577,7 +1577,7 @@ "audio_transcriptions": false, "audio_speech": false, "moderations": false, - "batches": false, + "batches": true, "rerank": false, "ocr": true, "a2a": true, diff --git a/tests/test_litellm/batches/test_batch_utils.py b/tests/test_litellm/batches/test_batch_utils.py index 3684a799a81..708c472939e 100644 --- a/tests/test_litellm/batches/test_batch_utils.py +++ b/tests/test_litellm/batches/test_batch_utils.py @@ -647,9 +647,7 @@ async def test_calculate_vertex_disable_transform_needs_model_name(monkeypatch): lambda content, model: pytest.fail("raw vertex path should not run"), ) - result = await bu.calculate_batch_cost_and_usage( - file_content_dictionary=[], custom_llm_provider="vertex_ai" - ) + result = await bu.calculate_batch_cost_and_usage(file_content_dictionary=[], custom_llm_provider="vertex_ai") assert result.cost == 0.0 assert result.usage.total_tokens == 0 assert result.models == [] @@ -1284,6 +1282,7 @@ async def test_handle_completed_batch_no_output_file_is_zero(monkeypatch): result set - zero cost, zero usage, no models - instead of letting the file fetch raise "Output file id is None" on every aretrieve_batch logging poll. """ + # The output-file fetch must not even be attempted when there is no output file. async def _must_not_fetch(*args, **kwargs): pytest.fail("_fetch_batch_output_file_content should not be called") @@ -1410,7 +1409,10 @@ def test_anthropic_response_body_is_result_message(): def test_anthropic_usage_conversion_includes_cache_tokens(): - body = {"model": "claude-sonnet-4-5-20250929", "usage": _anthropic_usage(1000, 200, cache_creation=2000, cache_read=8000)} + body = { + "model": "claude-sonnet-4-5-20250929", + "usage": _anthropic_usage(1000, 200, cache_creation=2000, cache_read=8000), + } usage = bu._get_batch_job_usage_from_response_body(body, custom_llm_provider="anthropic") assert usage.prompt_tokens == 11000 assert usage.completion_tokens == 200 @@ -1425,7 +1427,9 @@ def test_bedrock_model_output_line_success_check(): "modelOutput": {"model": "claude-sonnet-4-6", "usage": {"input_tokens": 13, "output_tokens": 5}}, } assert bu._batch_response_was_successful(row, custom_llm_provider="bedrock") is True - assert bu._get_response_from_batch_job_output_file(row, custom_llm_provider="bedrock")["model"] == "claude-sonnet-4-6" + assert ( + bu._get_response_from_batch_job_output_file(row, custom_llm_provider="bedrock")["model"] == "claude-sonnet-4-6" + ) def test_bedrock_cost_uses_deployment_model_name(): @@ -1479,7 +1483,13 @@ def test_total_usage_without_cache_tokens_has_no_prompt_details(monkeypatch): rows = [ { "custom_id": "req-1", - "response": {"status_code": 200, "body": {"model": "gpt-5.2", "usage": {"prompt_tokens": 10, "completion_tokens": 5, "total_tokens": 15}}}, + "response": { + "status_code": 200, + "body": { + "model": "gpt-5.2", + "usage": {"prompt_tokens": 10, "completion_tokens": 5, "total_tokens": 15}, + }, + }, } ] result = bu._aggregate_batch_cost_usage_models(entries=rows, custom_llm_provider="openai") @@ -1521,7 +1531,9 @@ def test_anthropic_cost_without_model_info_uses_batch_cost_calculator(monkeypatc lambda **kw: pytest.fail("anthropic rows must not go through completion_cost"), ) - result = bu._aggregate_batch_cost_usage_models(entries=[_anthropic_succeeded_row()], custom_llm_provider="anthropic") + result = bu._aggregate_batch_cost_usage_models( + entries=[_anthropic_succeeded_row()], custom_llm_provider="anthropic" + ) assert result.cost == pytest.approx(0.3) assert seen[0]["model"] == "claude-sonnet-4-5-20250929" @@ -1556,7 +1568,11 @@ async def test_calculate_batch_cost_and_usage_anthropic_end_to_end(): ) assert result.cost == pytest.approx(1000 * 3e-6 / 2 + 8000 * 3e-7 / 2 + 2000 * 3.75e-6 / 2 + 200 * 15e-6 / 2) - assert (result.usage.prompt_tokens, result.usage.completion_tokens, result.usage.total_tokens) == (11000, 200, 11200) + assert (result.usage.prompt_tokens, result.usage.completion_tokens, result.usage.total_tokens) == ( + 11000, + 200, + 11200, + ) assert result.models == ["claude-sonnet-4-5"] @@ -1721,7 +1737,10 @@ async def test_handle_completed_batch_honors_deployment_pricing(monkeypatch) -> def test_bedrock_converse_shaped_batch_usage_is_parsed(): - body = {"model": "us.amazon.nova-lite-v1:0", "usage": {"inputTokens": 2202, "outputTokens": 540, "totalTokens": 2742}} + body = { + "model": "us.amazon.nova-lite-v1:0", + "usage": {"inputTokens": 2202, "outputTokens": 540, "totalTokens": 2742}, + } usage = bu._get_batch_job_usage_from_response_body(body, custom_llm_provider="bedrock") assert (usage.prompt_tokens, usage.completion_tokens, usage.total_tokens) == (2202, 540, 2742) @@ -1809,6 +1828,7 @@ def test_unparsable_bedrock_batch_usage_warns(caplog): # batch_cost_is_final # --------------------------------------------------------------------------- # + def _retrieved_batch( status: str, output_file_id: str | None = None, counts: BatchRequestCounts | None = None ) -> LiteLLMBatch: @@ -1857,3 +1877,127 @@ class TestBatchCostIsFinal: @pytest.mark.parametrize("status", ["failed", "expired", "cancelled"]) def test_other_terminal_statuses_are_final(self, status): assert bu.batch_cost_is_final(_retrieved_batch(status)) is True + + +def _ocr_row(pages_processed, annotation_pages=None, model="mistral-ocr-latest"): + usage_info = {"pages_processed": pages_processed, "doc_size_bytes": 4096} + if annotation_pages is not None: + usage_info["pages_processed_annotation"] = annotation_pages + return _success_row( + model=model, pages=[{"index": i, "markdown": "x"} for i in range(pages_processed)], usage_info=usage_info + ) + + +def test_ocr_rows_are_priced_per_page_at_batch_rate(monkeypatch): + monkeypatch.setattr( + litellm, + "get_model_info", + lambda model, custom_llm_provider=None: {"ocr_cost_per_page": 0.004, "ocr_cost_per_page_batches": 0.002}, + ) + result = bu._aggregate_batch_cost_usage_models( + entries=[_ocr_row(3), _ocr_row(5), _failed_row(model="mistral-ocr-latest")], + custom_llm_provider="mistral", + model_name="mistral/mistral-ocr-latest", + ) + assert result.cost == pytest.approx(8 * 0.002) + assert result.prompt_cost == pytest.approx(8 * 0.002) + assert result.completion_cost == 0.0 + assert (result.successful_requests, result.failed_requests) == (2, 1) + assert result.usage.total_tokens == 0 + assert result.models == ["mistral/mistral-ocr-latest"] + + +def test_ocr_rows_fall_back_to_sync_page_rate_without_batch_price(monkeypatch): + monkeypatch.setattr(litellm, "get_model_info", lambda model, custom_llm_provider=None: {"ocr_cost_per_page": 0.004}) + result = bu._aggregate_batch_cost_usage_models(entries=[_ocr_row(2)], custom_llm_provider="mistral") + assert result.cost == pytest.approx(2 * 0.004) + + +def test_ocr_rows_bill_annotation_pages_separately(monkeypatch): + monkeypatch.setattr( + litellm, + "get_model_info", + lambda model, custom_llm_provider=None: { + "ocr_cost_per_page_batches": 0.002, + "annotation_cost_per_page_batches": 0.0025, + }, + ) + result = bu._aggregate_batch_cost_usage_models( + entries=[_ocr_row(4, annotation_pages=4)], custom_llm_provider="mistral" + ) + assert result.cost == pytest.approx(4 * 0.002 + 4 * 0.0025) + + +def test_ocr_rows_use_deployment_model_info_pricing_over_cost_map(monkeypatch): + monkeypatch.setattr( + litellm, "get_model_info", lambda model, custom_llm_provider=None: pytest.fail("cost map must not be consulted") + ) + result = bu._aggregate_batch_cost_usage_models( + entries=[_ocr_row(10)], + custom_llm_provider="mistral", + model_info={"ocr_cost_per_page_batches": 0.001}, + ) + assert result.cost == pytest.approx(0.01) + + +def test_ocr_rows_keep_the_published_page_rate_when_the_deployment_prices_only_annotations(monkeypatch): + monkeypatch.setattr( + litellm, + "get_model_info", + lambda model, custom_llm_provider=None: { + "ocr_cost_per_page_batches": 0.002, + "annotation_cost_per_page_batches": 0.0025, + }, + ) + result = bu._aggregate_batch_cost_usage_models( + entries=[_ocr_row(4, annotation_pages=4)], + custom_llm_provider="mistral", + model_info={"annotation_cost_per_page_batches": 0.01}, + ) + assert result.cost == pytest.approx(4 * 0.002 + 4 * 0.01) + + +def test_ocr_rows_keep_the_deployment_page_rate_when_the_unmapped_model_has_no_annotation_price(monkeypatch): + def _unmapped(model, custom_llm_provider=None): + raise Exception(f"This model isn't mapped yet: {model}") + + monkeypatch.setattr(litellm, "get_model_info", _unmapped) + result = bu._aggregate_batch_cost_usage_models( + entries=[_ocr_row(4, annotation_pages=4, model="my-private-ocr-model")], + custom_llm_provider="mistral", + model_info={"ocr_cost_per_page_batches": 0.001}, + ) + assert result.cost == pytest.approx(4 * 0.001 + 4 * 0.001) + + +def test_ocr_rows_bill_the_deployment_sync_page_rate_over_the_published_batch_rate(monkeypatch): + monkeypatch.setattr( + litellm, "get_model_info", lambda model, custom_llm_provider=None: pytest.fail("cost map must not be consulted") + ) + result = bu._aggregate_batch_cost_usage_models( + entries=[_ocr_row(3)], + custom_llm_provider="mistral", + model_info={"ocr_cost_per_page": 0.0912}, + ) + assert result.cost == pytest.approx(3 * 0.0912) + + +def test_ocr_rows_without_pricing_bill_zero_but_count_as_successful(monkeypatch): + monkeypatch.setattr(litellm, "get_model_info", lambda model, custom_llm_provider=None: {"mode": "ocr"}) + result = bu._aggregate_batch_cost_usage_models(entries=[_ocr_row(3)], custom_llm_provider="mistral") + assert result.cost == 0.0 + assert (result.successful_requests, result.failed_requests) == (1, 0) + + +def test_chat_rows_from_mistral_still_use_token_pricing(monkeypatch): + monkeypatch.setattr( + litellm, + "get_model_info", + lambda model, custom_llm_provider=None: {"input_cost_per_token": 0.001, "output_cost_per_token": 0.002}, + ) + result = bu._aggregate_batch_cost_usage_models( + entries=[_success_row(model="mistral-small-latest", usage=_usage(10, 5))], + custom_llm_provider="mistral", + ) + assert result.cost == pytest.approx((10 * 0.001 + 5 * 0.002) / 2) + assert result.usage.total_tokens == 15 diff --git a/tests/test_litellm/batches/test_main.py b/tests/test_litellm/batches/test_main.py index b87f9489250..26dc4083b0b 100644 --- a/tests/test_litellm/batches/test_main.py +++ b/tests/test_litellm/batches/test_main.py @@ -66,9 +66,7 @@ def seams(): stack.enter_context(patch.object(bm, "openai_batches_instance", openai_i)) stack.enter_context(patch.object(bm, "azure_batches_instance", azure_i)) stack.enter_context(patch.object(bm, "vertex_ai_batches_instance", vertex_i)) - stack.enter_context( - patch.object(bm, "anthropic_batches_instance", anthropic_i) - ) + stack.enter_context(patch.object(bm, "anthropic_batches_instance", anthropic_i)) stack.enter_context(patch.object(bm, "base_llm_http_handler", base_http)) stack.enter_context(patch.object(bm, "BedrockBatchesHandler", bedrock_arn)) yield Seams( @@ -174,9 +172,7 @@ def test_create__provider_config_routes_to_base_http_handler(seams): "get_provider_batches_config", return_value=MagicMock(name="provider_config"), ): - result = bm.create_batch( - **CREATE_KW, custom_llm_provider="bedrock", model="bedrock/my-batch-model" - ) + result = bm.create_batch(**CREATE_KW, custom_llm_provider="bedrock", model="bedrock/my-batch-model") assert result is seams.base_http.create_batch.return_value _assert_only(seams.base_http.create_batch, seams, "create_batch") @@ -281,9 +277,7 @@ def test_retrieve__bedrock_model_invocation_job_arn(seams): result = bm.retrieve_batch(batch_id=arn, custom_llm_provider="bedrock") seams.bedrock_arn._handle_model_invocation_job_status.assert_called_once() - assert ( - result is seams.bedrock_arn._handle_model_invocation_job_status.return_value - ) + assert result is seams.bedrock_arn._handle_model_invocation_job_status.return_value seams.bedrock_arn._handle_async_invoke_status.assert_not_called() @@ -385,9 +379,7 @@ def test_cancel__unsupported_provider_raises_badrequest(seams): def test_cancel__async_flag_propagates_is_async(seams): - bm.cancel_batch( - batch_id="batch-1", custom_llm_provider="openai", acancel_batch=True - ) + bm.cancel_batch(batch_id="batch-1", custom_llm_provider="openai", acancel_batch=True) assert seams.openai.cancel_batch.call_args.kwargs["_is_async"] is True @@ -415,9 +407,7 @@ async def test_acreate_batch_delegates_to_create_batch(): @pytest.mark.asyncio async def test_aretrieve_batch_delegates_to_retrieve_batch(): with patch.object(bm, "retrieve_batch", MagicMock(return_value="SENTINEL")) as m: - result = await bm.aretrieve_batch( - batch_id="batch-1", custom_llm_provider="azure" - ) + result = await bm.aretrieve_batch(batch_id="batch-1", custom_llm_provider="azure") assert result == "SENTINEL" assert m.call_count == 1 @@ -429,9 +419,7 @@ async def test_aretrieve_batch_delegates_to_retrieve_batch(): @pytest.mark.asyncio async def test_alist_batches_delegates_to_list_batches(): with patch.object(bm, "list_batches", MagicMock(return_value="SENTINEL")) as m: - result = await bm.alist_batches( - after="cur", limit=3, custom_llm_provider="vertex_ai" - ) + result = await bm.alist_batches(after="cur", limit=3, custom_llm_provider="vertex_ai") assert result == "SENTINEL" assert m.call_count == 1 @@ -444,9 +432,7 @@ async def test_alist_batches_delegates_to_list_batches(): @pytest.mark.asyncio async def test_acancel_batch_delegates_to_cancel_batch(): with patch.object(bm, "cancel_batch", MagicMock(return_value="SENTINEL")) as m: - result = await bm.acancel_batch( - batch_id="batch-1", custom_llm_provider="openai" - ) + result = await bm.acancel_batch(batch_id="batch-1", custom_llm_provider="openai") assert result == "SENTINEL" assert m.call_count == 1 @@ -499,9 +485,7 @@ def _sent(mock_method, *keys): def test_create__openai_credentials_passthrough(seams): bm.create_batch(**CREATE_KW, custom_llm_provider="openai", **OPENAI_CREDS) - assert _sent( - seams.openai.create_batch, "api_key", "api_base", "organization", "max_retries" - ) == { + assert _sent(seams.openai.create_batch, "api_key", "api_base", "organization", "max_retries") == { "api_key": "sk-user-openai", "api_base": "https://openai.user.test", "organization": "org-user-123", @@ -512,9 +496,7 @@ def test_create__openai_credentials_passthrough(seams): def test_create__azure_credentials_passthrough(seams): bm.create_batch(**CREATE_KW, custom_llm_provider="azure", **AZURE_CREDS) - assert _sent( - seams.azure.create_batch, "api_key", "api_base", "api_version" - ) == { + assert _sent(seams.azure.create_batch, "api_key", "api_base", "api_version") == { "api_key": "sk-user-azure", "api_base": "https://azure.user.test", "api_version": "2024-12-99", @@ -564,9 +546,7 @@ def test_create__provider_config_credentials_passthrough(seams): def test_retrieve__openai_credentials_passthrough(seams): bm.retrieve_batch(batch_id="b1", custom_llm_provider="openai", **OPENAI_CREDS) - assert _sent( - seams.openai.retrieve_batch, "api_key", "api_base", "organization" - ) == { + assert _sent(seams.openai.retrieve_batch, "api_key", "api_base", "organization") == { "api_key": "sk-user-openai", "api_base": "https://openai.user.test", "organization": "org-user-123", @@ -576,9 +556,7 @@ def test_retrieve__openai_credentials_passthrough(seams): def test_retrieve__azure_credentials_passthrough(seams): bm.retrieve_batch(batch_id="b1", custom_llm_provider="azure", **AZURE_CREDS) - assert _sent( - seams.azure.retrieve_batch, "api_key", "api_base", "api_version" - ) == { + assert _sent(seams.azure.retrieve_batch, "api_key", "api_base", "api_version") == { "api_key": "sk-user-azure", "api_base": "https://azure.user.test", "api_version": "2024-12-99", @@ -640,9 +618,7 @@ def test_retrieve__provider_config_credentials_passthrough(seams): def test_list__openai_credentials_passthrough(seams): bm.list_batches(custom_llm_provider="openai", **OPENAI_CREDS) - assert _sent( - seams.openai.list_batches, "api_key", "api_base", "organization" - ) == { + assert _sent(seams.openai.list_batches, "api_key", "api_base", "organization") == { "api_key": "sk-user-openai", "api_base": "https://openai.user.test", "organization": "org-user-123", @@ -652,9 +628,7 @@ def test_list__openai_credentials_passthrough(seams): def test_list__azure_credentials_passthrough(seams): bm.list_batches(custom_llm_provider="azure", **AZURE_CREDS) - assert _sent( - seams.azure.list_batches, "api_key", "api_base", "api_version" - ) == { + assert _sent(seams.azure.list_batches, "api_key", "api_base", "api_version") == { "api_key": "sk-user-azure", "api_base": "https://azure.user.test", "api_version": "2024-12-99", @@ -682,9 +656,7 @@ def test_list__vertex_credentials_passthrough(seams): def test_cancel__openai_credentials_passthrough(seams): bm.cancel_batch(batch_id="b1", custom_llm_provider="openai", **OPENAI_CREDS) - assert _sent( - seams.openai.cancel_batch, "api_key", "api_base", "organization" - ) == { + assert _sent(seams.openai.cancel_batch, "api_key", "api_base", "organization") == { "api_key": "sk-user-openai", "api_base": "https://openai.user.test", "organization": "org-user-123", @@ -694,9 +666,7 @@ def test_cancel__openai_credentials_passthrough(seams): def test_cancel__azure_credentials_passthrough(seams): bm.cancel_batch(batch_id="b1", custom_llm_provider="azure", **AZURE_CREDS) - assert _sent( - seams.azure.cancel_batch, "api_key", "api_base", "api_version" - ) == { + assert _sent(seams.azure.cancel_batch, "api_key", "api_base", "api_version") == { "api_key": "sk-user-azure", "api_base": "https://azure.user.test", "api_version": "2024-12-99", @@ -778,3 +748,43 @@ def test_retrieve__omits_trusted_model_credentials_when_not_supplied(seams): litellm_params = logging_obj.update_from_kwargs.call_args.kwargs["litellm_params"] assert "_litellm_internal_model_credentials" not in litellm_params + + +# =========================================================================== # +# mistral - a provider-config provider, like bedrock, so it requires `model` +# =========================================================================== # + + +def test_create__mistral_ocr_routes_to_base_http_handler_with_mistral_config(seams): + result = bm.create_batch( + completion_window="24h", + endpoint="/v1/ocr", + input_file_id="file-abc", + custom_llm_provider="mistral", + model="mistral/mistral-ocr-latest", + ) + + assert result is seams.base_http.create_batch.return_value + _assert_only(seams.base_http.create_batch, seams, "create_batch") + forwarded = seams.base_http.create_batch.call_args.kwargs + assert type(forwarded["provider_config"]).__name__ == "MistralBatchesConfig" + assert forwarded["model"] == "mistral-ocr-latest" + assert forwarded["create_batch_data"]["endpoint"] == "/v1/ocr" + + +def test_create__mistral_without_model_raises_badrequest(seams): + with pytest.raises(litellm.exceptions.BadRequestError): + bm.create_batch(**CREATE_KW, custom_llm_provider="mistral") + + for m in _all_seam_methods(seams, "create_batch"): + m.assert_not_called() + + +def test_retrieve__mistral_routes_to_base_http_handler_with_mistral_config(seams): + result = bm.retrieve_batch(batch_id="job-1", custom_llm_provider="mistral", model="mistral/mistral-ocr-latest") + + assert result is seams.base_http.retrieve_batch.return_value + _assert_only(seams.base_http.retrieve_batch, seams, "retrieve_batch") + forwarded = seams.base_http.retrieve_batch.call_args.kwargs + assert type(forwarded["provider_config"]).__name__ == "MistralBatchesConfig" + assert forwarded["batch_id"] == "job-1" diff --git a/tests/test_litellm/litellm_core_utils/test_litellm_logging.py b/tests/test_litellm/litellm_core_utils/test_litellm_logging.py index 836ac42e1f5..999adbdd935 100644 --- a/tests/test_litellm/litellm_core_utils/test_litellm_logging.py +++ b/tests/test_litellm/litellm_core_utils/test_litellm_logging.py @@ -17,12 +17,14 @@ from openai._legacy_response import HttpxBinaryResponseContent import litellm from litellm._logging import session_id_var, trace_id_var from litellm.constants import SENTRY_DENYLIST, SENTRY_PII_DENYLIST +from litellm.cost_calculator import ocr_batch_cost from litellm.integrations.custom_logger import CustomLogger from litellm.litellm_core_utils.litellm_logging import Logging as LitellmLogging from litellm.litellm_core_utils.litellm_logging import ( _get_status_fields, set_callbacks, ) +from litellm.llms.base_llm.ocr.transformation import OCRUsageInfo from litellm.types.llms.openai import ResponseAPIUsage, ResponsesAPIResponse from litellm.types.utils import ( CallTypes, @@ -59,6 +61,16 @@ def test_get_masked_api_base(logging_obj): assert type(masked_api_base) == str +def test_pre_call_tolerates_missing_api_base(logging_obj): + """Presigned batch retrieves (Mistral, Bedrock) build their own URL and pass api_base=None + to pre_call; masking must not raise or the request's pre-call logging is silently lost.""" + logging_obj.update_environment_variables(litellm_params={}, optional_params={}) + + logging_obj.pre_call(input="", api_key="", additional_args={"api_base": None, "headers": {}}) + + assert logging_obj.model_call_details["litellm_params"]["api_base"] == "" + + def test_post_call_serializes_dict_with_datetime(logging_obj): import datetime @@ -519,6 +531,36 @@ class TestGetRouterDeploymentModelInfo: finally: litellm.model_cost.pop(deployment_id, None) + def test_ocr_only_deployment_pricing_reaches_batch_ocr_cost(self, logging_obj) -> None: + """Regression: a deployment priced only per page was treated as unpriced, so a retrieved OCR batch + billed at the published rate while the same deployment's synchronous OCR calls billed at its own.""" + deployment_id = "deploy-ocr-only-pricing-1" + litellm.model_cost[deployment_id] = { + "id": deployment_id, + "litellm_provider": "mistral", + "mode": "ocr", + "ocr_cost_per_page": 0.0456, + "ocr_cost_per_page_batches": 0.0123, + } + logging_obj.litellm_params = { + "litellm_metadata": {"model_info": {"id": deployment_id}}, + "model": "mistral/mistral-ocr-latest", + } + logging_obj.model_call_details["model"] = "mistral/mistral-ocr-latest" + published_annotation_rate = litellm.model_cost["mistral/mistral-ocr-latest"]["annotation_cost_per_page_batches"] + try: + info = logging_obj.get_router_deployment_model_info() + assert info is not None + assert info["ocr_cost_per_page_batches"] == 0.0123 + pages_only = OCRUsageInfo(pages_processed=3) + assert ocr_batch_cost("mistral-ocr-latest", "mistral", pages_only, info)[0] == pytest.approx(3 * 0.0123) + with_annotations = OCRUsageInfo(pages_processed=3, pages_processed_annotation=2) + assert ocr_batch_cost("mistral-ocr-latest", "mistral", with_annotations, info)[0] == pytest.approx( + 3 * 0.0123 + 2 * published_annotation_rate + ) + finally: + litellm.model_cost.pop(deployment_id, None) + class TestRetrieveBatchCostPassesModelIdentity: """Regression: retrieving a batch priced it with no model identity at all. @@ -3958,9 +4000,7 @@ def test_get_standard_logging_object_payload_carries_matched_access_groups(loggi "model": "gpt-4o", "messages": [], "litellm_params": { - "metadata": { - "user_api_key_matched_model_access_groups": ["premium-pool", "shared-pool"] - }, + "metadata": {"user_api_key_matched_model_access_groups": ["premium-pool", "shared-pool"]}, "proxy_server_request": {"body": {}}, }, }, @@ -4044,9 +4084,7 @@ def _model_router_response(selected_model: str, stamp: bool): from litellm.types.utils import ModelResponse response = ModelResponse(model=selected_model) - response._hidden_params = ( - {AZURE_MODEL_ROUTER_SELECTED_MODEL_KEY: selected_model} if stamp else {} - ) + response._hidden_params = {AZURE_MODEL_ROUTER_SELECTED_MODEL_KEY: selected_model} if stamp else {} return response @@ -4070,9 +4108,7 @@ def test_standard_logging_payload_uses_stamped_model_router_model(logging_obj): "messages": [], "litellm_params": {"metadata": {}}, }, - init_response_obj=_model_router_response( - "azure_ai/grok-4-1-fast-reasoning", stamp=True - ), + init_response_obj=_model_router_response("azure_ai/grok-4-1-fast-reasoning", stamp=True), start_time=now, end_time=now, logging_obj=logging_obj, @@ -4104,9 +4140,7 @@ def test_standard_logging_payload_keeps_requested_model_without_router_stamp( "messages": [], "litellm_params": {"metadata": {}}, }, - init_response_obj=_model_router_response( - "azure_ai/grok-4-1-fast-reasoning", stamp=False - ), + init_response_obj=_model_router_response("azure_ai/grok-4-1-fast-reasoning", stamp=False), start_time=now, end_time=now, logging_obj=logging_obj, @@ -5594,9 +5628,7 @@ class TestNonInferenceCallTypesAreNotBilled: init_response_obj=self._retrieved_response(), start_time=now, end_time=now, - logging_obj=self._logging_obj( - "aget_responses", litellm_metadata=self.BACKGROUND_POLL_METADATA - ), + logging_obj=self._logging_obj("aget_responses", litellm_metadata=self.BACKGROUND_POLL_METADATA), status="success", ) @@ -5842,9 +5874,7 @@ async def test_streaming_success_callbacks_survive_cost_calculation_failure(): releasing.async_log_success_event = AsyncMock() patcher, logging_obj = _streaming_logging_obj_with_callbacks([releasing]) - with patcher, patch.object( - logging_obj, "_response_cost_calculator", side_effect=ValueError("bad usage block") - ): + with patcher, patch.object(logging_obj, "_response_cost_calculator", side_effect=ValueError("bad usage block")): await logging_obj.async_success_handler(result=_assembled_stream_result()) assert logging_obj.model_call_details["response_cost"] is None @@ -5857,8 +5887,9 @@ async def test_streaming_success_callbacks_survive_standard_logging_payload_fail releasing.async_log_success_event = AsyncMock() patcher, logging_obj = _streaming_logging_obj_with_callbacks([releasing]) - with patcher, patch.object( - logging_obj, "_build_standard_logging_payload", side_effect=ValueError("incomplete stream") + with ( + patcher, + patch.object(logging_obj, "_build_standard_logging_payload", side_effect=ValueError("incomplete stream")), ): await logging_obj.async_success_handler(result=_assembled_stream_result()) @@ -6206,6 +6237,8 @@ def test_prompt_hooks_skip_prompt_managers_when_no_prompt_id(logging_obj, tmp_pa ) for hook in [cb for cb in litellm.callbacks if isinstance(cb, VectorStorePreCallHook)]: litellm.logging_callback_manager.remove_callback_from_list_by_object(litellm.callbacks, hook) + + def test_newrelic_dispatch_prefers_otel_v2_when_flag_on(monkeypatch): """With LITELLM_OTEL_V2 on and operator credentials present, the "newrelic" callback builds the OTel v2 logger (per-team credential routing); with the @@ -6361,7 +6394,9 @@ def test_get_error_information_skips_traceback_for_budget_rejection_with_provide from litellm.litellm_core_utils.litellm_logging import StandardLoggingPayloadSetup assert litellm.log_client_error_tracebacks is False - over_budget = _raise_and_catch(litellm.BudgetExceededError(current_cost=0.01, max_budget=0.0, llm_provider="anthropic")) + over_budget = _raise_and_catch( + litellm.BudgetExceededError(current_cost=0.01, max_budget=0.0, llm_provider="anthropic") + ) result = StandardLoggingPayloadSetup.get_error_information(over_budget) assert result["error_code"] == "429" assert result["llm_provider"] == "anthropic" @@ -6934,9 +6969,7 @@ def test_passthrough_embeddings_result_swapped_for_callbacks(): ], "model": "EmbeddingsGigaR", }, - request=httpx.Request( - "POST", "https://gigachat.devices.sberbank.ru/api/v1/embeddings" - ), + request=httpx.Request("POST", "https://gigachat.devices.sberbank.ru/api/v1/embeddings"), ) _, _, swapped_result = logging_obj._success_handler_helper_fn( @@ -6955,12 +6988,14 @@ def test_get_status_fields_ranks_guardrail_flagged_between_success_and_intervene request-level guardrail_status but never mask an intervention.""" flagged = {"guardrail_status": "guardrail_flagged"} - assert _get_status_fields( - "success", [{"guardrail_status": "success"}, flagged], None - )["guardrail_status"] == "guardrail_flagged" - assert _get_status_fields( - "success", [flagged, {"guardrail_status": "guardrail_intervened"}], None - )["guardrail_status"] == "guardrail_intervened" + assert ( + _get_status_fields("success", [{"guardrail_status": "success"}, flagged], None)["guardrail_status"] + == "guardrail_flagged" + ) + assert ( + _get_status_fields("success", [flagged, {"guardrail_status": "guardrail_intervened"}], None)["guardrail_status"] + == "guardrail_intervened" + ) def test_get_error_information_redacts_provider_key_from_upstream_url(): diff --git a/tests/test_litellm/llms/custom_httpx/test_llm_http_handler.py b/tests/test_litellm/llms/custom_httpx/test_llm_http_handler.py index 9fa2c47f896..fff1372f271 100644 --- a/tests/test_litellm/llms/custom_httpx/test_llm_http_handler.py +++ b/tests/test_litellm/llms/custom_httpx/test_llm_http_handler.py @@ -2853,6 +2853,68 @@ def test_direct_vector_store_search_debug_log_omits_stored_credentials(caplog, i assert "sk-embedding-s3cret" not in logged +@pytest.mark.asyncio +async def test_async_retrieve_batch_masks_presigned_auth_header_in_raw_request_log(): + """Regression: a pre-signed retrieve-batch request (Mistral, Bedrock) embeds its auth + header inside the transformed request, which pre_call logs verbatim as the raw request + body, so the provider key landed unmasked in raw_request_typed_dict and every + raw-request callback.""" + from litellm.litellm_core_utils.litellm_logging import Logging as LitellmLogging + from litellm.llms.mistral.batches.transformation import MistralBatchesConfig + + provider_key = "mistral-s3cret-provider-key-123456" + job_payload = { + "id": "batch-1", + "input_files": ["file-1"], + "endpoint": "/v1/ocr", + "model": "mistral-ocr-latest", + "status": "SUCCESS", + "created_at": 1_757_400_000, + } + sent_requests = [] + + def _capture(request: httpx.Request) -> httpx.Response: + sent_requests.append(request) + return httpx.Response(200, json=job_payload) + + client = AsyncHTTPHandler() + client.client = httpx.AsyncClient(transport=httpx.MockTransport(_capture)) + + logging_obj = LitellmLogging( + model="mistral/mistral-ocr-latest", + messages=[], + stream=False, + call_type="batch_retrieve", + start_time=time.time(), + litellm_call_id="batch-retrieve-call-id", + function_id="batch-retrieve-function-id", + log_raw_request_response=True, + ) + logging_obj.update_environment_variables( + model="mistral/mistral-ocr-latest", + optional_params={}, + litellm_params={"litellm_call_id": "batch-retrieve-call-id", "metadata": {}}, + ) + + result = await BaseLLMHTTPHandler().retrieve_batch( + batch_id="batch-1", + litellm_params={"api_key": provider_key}, + provider_config=MistralBatchesConfig(), + headers={}, + api_base=None, + api_key=provider_key, + logging_obj=logging_obj, + _is_async=True, + client=client, + model="mistral/mistral-ocr-latest", + ) + + assert result.id == "batch-1" + assert sent_requests[0].headers["Authorization"] == f"Bearer {provider_key}" + raw_request_body = logging_obj.model_call_details["raw_request_typed_dict"]["raw_request_body"] + assert provider_key not in json.dumps(raw_request_body) + + @pytest.mark.asyncio async def test_async_anthropic_messages_handler_carries_deployment_vertex_location_for_pricing(monkeypatch): """ diff --git a/tests/test_litellm/llms/mistral/batches/__init__.py b/tests/test_litellm/llms/mistral/batches/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/tests/test_litellm/llms/mistral/batches/test_mistral_batches_transformation.py b/tests/test_litellm/llms/mistral/batches/test_mistral_batches_transformation.py new file mode 100644 index 00000000000..4073879e3b8 --- /dev/null +++ b/tests/test_litellm/llms/mistral/batches/test_mistral_batches_transformation.py @@ -0,0 +1,258 @@ +""" +Regression tests for ``MistralBatchesConfig``, the BaseBatchesConfig implementation +behind ``custom_llm_provider="mistral"`` on /v1/batches. + +Locks the request shape Mistral's ``POST /v1/batch/jobs`` accepts (input_files list, +model set on the job, endpoint passed through untouched so ``/v1/ocr`` batches work), +the Mistral -> OpenAI status mapping, request-count and file-id mapping, and auth. +Everything runs for real against canned httpx responses; only the API key env var is +set. +""" + +import json + +import httpx +import pytest + +from litellm.llms.mistral.batches.transformation import MistralBatchesConfig +from litellm.llms.mistral.common_utils import MistralError +from litellm.types.llms.openai import CreateBatchRequest +from litellm.types.utils import LiteLLMBatch, LlmProviders + +STATUS_MAP = { + "QUEUED": "validating", + "RUNNING": "in_progress", + "SUCCESS": "completed", + "FAILED": "failed", + "TIMEOUT_EXCEEDED": "expired", + "CANCELLATION_REQUESTED": "cancelling", + "CANCELLED": "cancelled", +} + + +def _job(**overrides): + base = { + "id": "8ff5e0d1-6bc2-4c3a-9f7d-0d1c2e3f4a5b", + "object": "batch", + "input_files": ["c1a2b3d4-0000-4000-8000-000000000001"], + "endpoint": "/v1/ocr", + "model": "mistral-ocr-latest", + "status": "SUCCESS", + "created_at": 1_757_400_000, + "started_at": 1_757_400_010, + "completed_at": 1_757_400_500, + "total_requests": 3, + "completed_requests": 3, + "succeeded_requests": 2, + "failed_requests": 1, + "output_file": "out-0000-4000-8000-000000000002", + "error_file": "err-0000-4000-8000-000000000003", + "errors": [], + "metadata": {"job_type": "testing"}, + } + return {**base, **overrides} + + +def _response(payload: dict, status_code: int = 200) -> httpx.Response: + return httpx.Response( + status_code=status_code, + content=json.dumps(payload).encode(), + request=httpx.Request("GET", "https://api.mistral.ai/v1/batch/jobs/x"), + ) + + +@pytest.fixture +def config() -> MistralBatchesConfig: + return MistralBatchesConfig() + + +@pytest.fixture +def api_key(monkeypatch) -> str: + monkeypatch.setenv("MISTRAL_API_KEY", "sk-mistral-test") + return "sk-mistral-test" + + +def test_custom_llm_provider(config): + assert config.custom_llm_provider == LlmProviders.MISTRAL + + +def test_create_request_maps_openai_fields_onto_mistral_job(config): + data = CreateBatchRequest( + completion_window="24h", + endpoint="/v1/ocr", + input_file_id="file-123", + metadata={"team": "docs"}, + ) + body = config.transform_create_batch_request( + model="mistral-ocr-latest", create_batch_data=data, optional_params={}, litellm_params={} + ) + assert body == { + "input_files": ("file-123",), + "endpoint": "/v1/ocr", + "model": "mistral-ocr-latest", + "metadata": {"team": "docs"}, + } + + +def test_create_request_omits_empty_metadata(config): + data = CreateBatchRequest( + completion_window="24h", + endpoint="/v1/chat/completions", + input_file_id="file-123", + metadata=None, + ) + body = config.transform_create_batch_request( + model="mistral-small-latest", create_batch_data=data, optional_params={}, litellm_params={} + ) + assert "metadata" not in body + + +def test_create_request_requires_input_file_and_endpoint(config): + with pytest.raises(ValueError, match="input_file_id and endpoint are required"): + config.transform_create_batch_request( + model="m", + create_batch_data=CreateBatchRequest(completion_window="24h"), + optional_params={}, + litellm_params={}, + ) + + +@pytest.mark.parametrize( + "api_base,expected", + [ + (None, "https://api.mistral.ai/v1/batch/jobs"), + ("https://api.mistral.ai/v1", "https://api.mistral.ai/v1/batch/jobs"), + ("https://proxy.example.com/", "https://proxy.example.com/v1/batch/jobs"), + ], +) +def test_create_url(config, api_base, expected): + url = config.get_complete_batch_url( + api_base=api_base, api_key="k", model="m", optional_params={}, litellm_params={}, data={} + ) + assert url == expected + + +def test_validate_environment_uses_bearer_auth(config, api_key): + headers = config.validate_environment( + headers={"x-extra": "1"}, model="m", messages=[], optional_params={}, litellm_params={} + ) + assert headers == {"x-extra": "1", "Authorization": f"Bearer {api_key}"} + + +def test_validate_environment_explicit_key_wins(config, api_key): + headers = config.validate_environment( + headers={}, model="m", messages=[], optional_params={}, litellm_params={}, api_key="sk-explicit" + ) + assert headers["Authorization"] == "Bearer sk-explicit" + + +def test_validate_environment_without_key_raises(config, monkeypatch): + monkeypatch.delenv("MISTRAL_API_KEY", raising=False) + with pytest.raises(ValueError, match="Missing Mistral API Key"): + config.validate_environment(headers={}, model="m", messages=[], optional_params={}, litellm_params={}) + + +def test_create_response_maps_job_onto_openai_batch(config): + batch = config.transform_create_batch_response( + model="mistral-ocr-latest", + raw_response=_response(_job(status="QUEUED", started_at=None, completed_at=None)), + logging_obj=None, + litellm_params={}, + ) + assert isinstance(batch, LiteLLMBatch) + assert batch.id == "8ff5e0d1-6bc2-4c3a-9f7d-0d1c2e3f4a5b" + assert batch.endpoint == "/v1/ocr" + assert batch.input_file_id == "c1a2b3d4-0000-4000-8000-000000000001" + assert batch.status == "validating" + assert batch.created_at == 1_757_400_000 + assert batch.in_progress_at is None + assert batch.completed_at is None + assert batch.metadata == {"job_type": "testing"} + + +def test_retrieve_request_is_presigned_get_with_auth(config, api_key): + req = config.transform_retrieve_batch_request( + batch_id="job/with slash", optional_params={}, litellm_params={"api_base": "https://api.mistral.ai"} + ) + assert req["method"] == "GET" + assert req["url"] == "https://api.mistral.ai/v1/batch/jobs/job%2Fwith%20slash" + assert req["headers"] == {"Authorization": f"Bearer {api_key}"} + + +def test_retrieve_request_prefers_litellm_params_api_key(config, api_key): + req = config.transform_retrieve_batch_request( + batch_id="job-1", optional_params={}, litellm_params={"api_key": "sk-from-deployment"} + ) + assert req["headers"]["Authorization"] == "Bearer sk-from-deployment" + + +@pytest.mark.parametrize("mistral_status,openai_status", sorted(STATUS_MAP.items())) +def test_retrieve_response_status_mapping(config, mistral_status, openai_status): + batch = config.transform_retrieve_batch_response( + model=None, raw_response=_response(_job(status=mistral_status)), logging_obj=None, litellm_params={} + ) + assert batch.status == openai_status + + +@pytest.mark.parametrize( + "mistral_status,populated_field", + [ + ("SUCCESS", "completed_at"), + ("FAILED", "failed_at"), + ("TIMEOUT_EXCEEDED", "expired_at"), + ("CANCELLED", "cancelled_at"), + ], +) +def test_retrieve_response_terminal_timestamp_lands_on_matching_field(config, mistral_status, populated_field): + batch = config.transform_retrieve_batch_response( + model=None, raw_response=_response(_job(status=mistral_status)), logging_obj=None, litellm_params={} + ) + terminal_fields = {"completed_at", "failed_at", "expired_at", "cancelled_at"} + assert getattr(batch, populated_field) == 1_757_400_500 + for other in terminal_fields - {populated_field}: + assert getattr(batch, other) is None + assert batch.in_progress_at == 1_757_400_010 + + +def test_retrieve_response_maps_counts_and_files(config): + batch = config.transform_retrieve_batch_response( + model=None, raw_response=_response(_job()), logging_obj=None, litellm_params={} + ) + assert batch.request_counts.total == 3 + assert batch.request_counts.completed == 2 + assert batch.request_counts.failed == 1 + assert batch.output_file_id == "out-0000-4000-8000-000000000002" + assert batch.error_file_id == "err-0000-4000-8000-000000000003" + assert batch.errors is None + + +def test_retrieve_response_surfaces_job_errors(config): + batch = config.transform_retrieve_batch_response( + model=None, + raw_response=_response( + _job(status="FAILED", errors=[{"message": "invalid document", "count": 2}, {"message": "timeout"}]) + ), + logging_obj=None, + litellm_params={}, + ) + assert [e.message for e in batch.errors.data] == ["invalid document (x2)", "timeout"] + + +def test_retrieve_response_without_files_or_input(config): + batch = config.transform_retrieve_batch_response( + model=None, + raw_response=_response(_job(input_files=[], output_file=None, error_file=None, metadata=None)), + logging_obj=None, + litellm_params={}, + ) + assert batch.input_file_id == "" + assert batch.output_file_id is None + assert batch.error_file_id is None + assert batch.metadata is None + + +def test_get_error_class(config): + err = config.get_error_class("nope", 401, {"x-request-id": "r1"}) + assert isinstance(err, MistralError) + assert err.status_code == 401 + assert err.message == "nope" diff --git a/tests/test_litellm/llms/mistral/files/__init__.py b/tests/test_litellm/llms/mistral/files/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/tests/test_litellm/llms/mistral/files/test_mistral_files_transformation.py b/tests/test_litellm/llms/mistral/files/test_mistral_files_transformation.py new file mode 100644 index 00000000000..303afe99a2c --- /dev/null +++ b/tests/test_litellm/llms/mistral/files/test_mistral_files_transformation.py @@ -0,0 +1,249 @@ +""" +Regression tests for ``MistralFilesConfig``, the BaseFilesConfig implementation behind +``custom_llm_provider="mistral"`` on /v1/files. + +Locks the URL routing for each file operation, the multipart upload shape Mistral's +``POST /v1/files`` accepts (purpose restricted to fine-tune/batch/ocr), and the +Mistral -> OpenAI file object mapping. Runs against canned httpx responses. +""" + +import json + +import httpx +import pytest +from openai.types.file_deleted import FileDeleted + +from litellm.llms.base_llm.chat.transformation import BaseLLMException +from litellm.llms.mistral.files.transformation import MistralFilesConfig +from litellm.types.llms.openai import CreateFileRequest, FileContentRequest, OpenAIFileObject +from litellm.types.utils import LlmProviders + +FILE_ID = "497f6eca-6276-4993-bfeb-53cbbbba6f09" + + +def _file(**overrides): + base = { + "id": FILE_ID, + "object": "file", + "bytes": 13000, + "created_at": 1_716_963_433, + "filename": "batch_input.jsonl", + "purpose": "batch", + "sample_type": "batch_request", + "num_lines": 3, + "source": "upload", + } + return {**base, **overrides} + + +def _response(payload) -> httpx.Response: + return httpx.Response( + status_code=200, + content=json.dumps(payload).encode(), + request=httpx.Request("GET", "https://api.mistral.ai/v1/files"), + ) + + +@pytest.fixture +def config() -> MistralFilesConfig: + return MistralFilesConfig() + + +@pytest.fixture +def api_key(monkeypatch) -> str: + monkeypatch.setenv("MISTRAL_API_KEY", "sk-mistral-test") + return "sk-mistral-test" + + +def test_custom_llm_provider(config): + assert config.custom_llm_provider == LlmProviders.MISTRAL + + +@pytest.mark.parametrize( + "api_base,expected", + [ + (None, "https://api.mistral.ai/v1/files"), + ("https://api.mistral.ai/v1/", "https://api.mistral.ai/v1/files"), + ("https://proxy.example.com", "https://proxy.example.com/v1/files"), + ], +) +def test_upload_url(config, api_base, expected): + url = config.get_complete_url(api_base=api_base, api_key="k", model="", optional_params={}, litellm_params={}) + assert url == expected + + +def test_validate_environment_uses_bearer_auth(config, api_key): + headers = config.validate_environment(headers={}, model="", messages=[], optional_params={}, litellm_params={}) + assert headers == {"Authorization": f"Bearer {api_key}"} + + +def test_upload_request_is_multipart_with_batch_purpose(config): + body = config.transform_create_file_request( + model="", + create_file_data=CreateFileRequest( + file=("in.jsonl", b'{"custom_id":"0"}\n', "application/jsonl"), purpose="batch" + ), + optional_params={}, + litellm_params={}, + ) + assert body == { + "file": ("in.jsonl", b'{"custom_id":"0"}\n', "application/jsonl"), + "purpose": (None, "batch"), + } + + +@pytest.mark.parametrize("purpose", ["batch", "fine-tune", "ocr"]) +def test_upload_request_passes_mistral_purposes_through(config, purpose): + body = config.transform_create_file_request( + model="", + create_file_data=CreateFileRequest(file=("f.bin", b"x"), purpose=purpose), + optional_params={}, + litellm_params={}, + ) + assert body["purpose"] == (None, purpose) + + +def test_upload_request_maps_user_data_onto_ocr(config): + body = config.transform_create_file_request( + model="", + create_file_data=CreateFileRequest(file=("scan.pdf", b"%PDF"), purpose="user_data"), + optional_params={}, + litellm_params={}, + ) + assert body["purpose"] == (None, "ocr") + + +@pytest.mark.parametrize("purpose", ["assistants", "vision", "evals"]) +def test_upload_request_rejects_purposes_mistral_lacks(config, purpose): + """Regression: these used to be silently rewritten to ``batch``, so an upload that skipped the + proxy's batch-only validation and guardrails still landed on Mistral as a batch input file. The + rejection is a 400 provider error, so the proxy answers invalid_request_error instead of a 500.""" + with pytest.raises(BaseLLMException, match=f"purpose={purpose!r}") as exc_info: + config.transform_create_file_request( + model="", + create_file_data=CreateFileRequest(file=("f.bin", b"x"), purpose=purpose), + optional_params={}, + litellm_params={}, + ) + assert exc_info.value.status_code == 400 + + +def test_upload_request_requires_file(config): + with pytest.raises(ValueError, match="File data is required"): + config.transform_create_file_request( + model="", create_file_data=CreateFileRequest(purpose="batch"), optional_params={}, litellm_params={} + ) + + +def test_upload_response_maps_onto_openai_file_object(config): + obj = config.transform_create_file_response( + model=None, raw_response=_response(_file()), logging_obj=None, litellm_params={} + ) + assert obj == OpenAIFileObject( + id=FILE_ID, + bytes=13000, + created_at=1_716_963_433, + filename="batch_input.jsonl", + object="file", + purpose="batch", + status="uploaded", + ) + + +def test_file_response_with_ocr_purpose_maps_onto_user_data(config): + obj = config.transform_retrieve_file_response( + raw_response=_response(_file(purpose="ocr", expires_at=1_800_000_000)), logging_obj=None, litellm_params={} + ) + assert obj.purpose == "user_data" + assert obj.expires_at == 1_800_000_000 + + +@pytest.mark.parametrize("purpose", ["playground", "audio", "code_interpreter"]) +def test_files_with_purposes_mistral_never_lets_us_upload_still_read_back(config, purpose): + """Regression: Mistral's live API returns purposes its upload endpoint rejects for files + other Mistral products created, and both the unfiltered list and a retrieve of such a file + used to fail validation, so one playground file 500'd ``GET /v1/files`` for the whole key.""" + retrieved = config.transform_retrieve_file_response( + raw_response=_response(_file(purpose=purpose)), logging_obj=None, litellm_params={} + ) + assert retrieved.purpose == "user_data" + listed = config.transform_list_files_response( + raw_response=_response({"data": [_file(purpose=purpose), _file(id="second")], "object": "list", "total": 2}), + logging_obj=None, + litellm_params={}, + ) + assert [(f.id, f.purpose) for f in listed] == [(FILE_ID, "user_data"), ("second", "batch")] + + +@pytest.mark.parametrize( + "method,suffix", + [ + ("transform_retrieve_file_request", ""), + ("transform_delete_file_request", ""), + ], +) +def test_single_file_urls_encode_id_and_honor_api_base(config, method, suffix): + url, params = getattr(config, method)( + file_id="id/with slash", optional_params={}, litellm_params={"api_base": "https://mistral.internal/v1"} + ) + assert url == f"https://mistral.internal/v1/files/id%2Fwith%20slash{suffix}" + assert params == {} + + +def test_file_content_url(config): + url, params = config.transform_file_content_request( + file_content_request=FileContentRequest(file_id=FILE_ID), optional_params={}, litellm_params={} + ) + assert url == f"https://api.mistral.ai/v1/files/{FILE_ID}/content" + assert params == {} + + +def test_file_content_response_is_binary_passthrough(config): + raw = httpx.Response( + 200, content=b'{"custom_id":"0","response":{"status_code":200}}\n', request=httpx.Request("GET", "https://x") + ) + out = config.transform_file_content_response(raw_response=raw, logging_obj=None, litellm_params={}) + assert out.content == b'{"custom_id":"0","response":{"status_code":200}}\n' + + +def test_delete_response(config): + out = config.transform_delete_file_response( + raw_response=_response({"id": FILE_ID, "object": "file", "deleted": True}), logging_obj=None, litellm_params={} + ) + assert out == FileDeleted(id=FILE_ID, deleted=True, object="file") + + +def test_list_request_filters_by_mapped_purpose(config): + url, params = config.transform_list_files_request(purpose="batch", optional_params={}, litellm_params={}) + assert url == "https://api.mistral.ai/v1/files" + assert params == {"purpose": "batch"} + _, no_params = config.transform_list_files_request(purpose=None, optional_params={}, litellm_params={}) + assert no_params == {} + + +def test_list_request_accepts_the_purpose_an_ocr_file_reads_back_as(config): + """Regression: an OCR file reads back as ``purpose=user_data``, and listing with that purpose + used to raise, so ``files.list(purpose=file.purpose)`` could never find OCR files.""" + ocr_file = config.transform_retrieve_file_response( + raw_response=_response(_file(purpose="ocr")), logging_obj=None, litellm_params={} + ) + _, params = config.transform_list_files_request(purpose=ocr_file.purpose, optional_params={}, litellm_params={}) + assert params == {"purpose": "ocr"} + + +def test_list_request_rejects_purposes_mistral_lacks(config): + with pytest.raises(BaseLLMException, match="purpose='assistants'") as exc_info: + config.transform_list_files_request(purpose="assistants", optional_params={}, litellm_params={}) + assert exc_info.value.status_code == 400 + + +def test_list_response(config): + out = config.transform_list_files_response( + raw_response=_response( + {"data": [_file(), _file(id="second", filename="b.jsonl")], "object": "list", "total": 2} + ), + logging_obj=None, + litellm_params={}, + ) + assert [f.id for f in out] == [FILE_ID, "second"] + assert out[1].filename == "b.jsonl" diff --git a/tests/test_litellm/proxy/batches_endpoints/test_endpoints.py b/tests/test_litellm/proxy/batches_endpoints/test_endpoints.py index d9bfb3fe3da..1b3f3806d79 100644 --- a/tests/test_litellm/proxy/batches_endpoints/test_endpoints.py +++ b/tests/test_litellm/proxy/batches_endpoints/test_endpoints.py @@ -51,6 +51,7 @@ from litellm.proxy.openai_files_endpoints.common_utils import ( from litellm.proxy.utils import ProxyLogging from litellm.router import Router from litellm.types.llms.openai import BatchJobStatus +from litellm.types.router import Deployment, LiteLLM_Params, ModelInfo from litellm.types.utils import CredentialItem, LiteLLMBatch from fastapi import Request, Response @@ -178,6 +179,10 @@ def harness(): logging.get_proxy_hook = MagicMock(return_value=None) router = MagicMock(spec=Router) + router.model_group_alias = {} + router.get_model_access_groups = MagicMock(return_value={}) + router.resolve_model_name_from_model_id = MagicMock(side_effect=lambda model_id: model_id) + router.model_list = [] router.acreate_batch = AsyncMock(return_value=make_batch()) router.get_deployment_credentials_with_provider = MagicMock(side_effect=_creds_lookup) @@ -1184,8 +1189,13 @@ def retrieve_harness(): logging.get_proxy_hook = MagicMock(return_value=None) router = MagicMock(spec=Router) + router.model_group_alias = {} + router.get_model_access_groups = MagicMock(return_value={}) + router.resolve_model_name_from_model_id = MagicMock(side_effect=lambda model_id: model_id) + router.model_list = [] router.aretrieve_batch = AsyncMock(return_value=make_batch()) router.get_deployment_credentials_with_provider = MagicMock(side_effect=_creds_lookup) + router.get_credential_deployment = MagicMock(return_value=None) pre_call = AsyncMock(side_effect=lambda **kw: (data_holder["data"], MagicMock())) get_headers = MagicMock(return_value={}) @@ -1304,6 +1314,30 @@ async def test_retrieve__model_encoded_id(retrieve_harness): assert retrieve_harness.update_batch_in_db.call_args.kwargs["operation"] == "retrieve" +@pytest.mark.asyncio +async def test_retrieve__model_encoded_id__stamps_deployment_model_info_for_cost(retrieve_harness): + """Regression: this path calls litellm.aretrieve_batch directly, so nothing stamped the + deployment's model_info the way the router does for routed calls. Cost tracking then never + saw the deployment id, and a completed batch on a deployment with its own per-page pricing + was billed at the published rate with an empty model_id on the spend row.""" + retrieve_harness.router.get_credential_deployment.return_value = Deployment( + model_name="azure-gpt", + litellm_params=LiteLLM_Params(model="azure/gpt-4o"), + model_info=ModelInfo(id="dep-123"), + ) + retrieve_harness.pre_call.side_effect = lambda **kw: ( + {**retrieve_harness.data["data"], "litellm_metadata": {"user_api_key_alias": "qa-key"}}, + MagicMock(), + ) + + await call_retrieve(retrieve_harness, AZURE_BATCH_ID) + + retrieve_harness.router.get_credential_deployment.assert_called_once_with(model_id="azure/gpt-4o") + litellm_metadata = retrieve_harness.aretrieve_kwargs()["litellm_metadata"] + assert litellm_metadata["model_info"]["id"] == "dep-123" + assert litellm_metadata["user_api_key_alias"] == "qa-key" + + @pytest.mark.asyncio async def test_retrieve__model_encoded_id__forwards_decoded_model_not_deployment( retrieve_harness, @@ -1639,6 +1673,10 @@ def list_harness(): logging.get_proxy_hook = MagicMock(return_value=None) router = MagicMock(spec=Router) + router.model_group_alias = {} + router.get_model_access_groups = MagicMock(return_value={}) + router.resolve_model_name_from_model_id = MagicMock(side_effect=lambda model_id: model_id) + router.model_list = [] router.alist_batches = AsyncMock(return_value=FakeListPage([])) router.get_deployment_credentials_with_provider = MagicMock(side_effect=_creds_lookup) @@ -2053,6 +2091,10 @@ def cancel_harness(): logging.get_proxy_hook = MagicMock(return_value=None) router = MagicMock(spec=Router) + router.model_group_alias = {} + router.get_model_access_groups = MagicMock(return_value={}) + router.resolve_model_name_from_model_id = MagicMock(side_effect=lambda model_id: model_id) + router.model_list = [] router.acancel_batch = AsyncMock(return_value=make_batch()) router.get_deployment_credentials_with_provider = MagicMock(side_effect=_creds_lookup) @@ -2774,8 +2816,6 @@ async def test_cancel__unified_batch_id_allowed_when_managed_files_required(canc assert cancel_harness.router_acancel.call_count == 1 - - @pytest.mark.asyncio async def test_retrieve__managed_batch_defers_cost_to_the_poller_when_it_is_running(retrieve_harness): with patch.object(endpoints, "batch_cost_poller_is_active", MagicMock(return_value=True)): @@ -2803,3 +2843,116 @@ async def test_retrieve__raw_batch_id_is_untouched_by_the_poller_handoff(retriev metadata = retrieve_harness.litellm_aretrieve.await_args.kwargs.get("litellm_metadata") or {} assert metadata.get("batch_ignore_default_logging") is None + + +def _key_restricted_to(*models: str) -> UserAPIKeyAuth: + return UserAPIKeyAuth(api_key="sk-restricted", team_id="team-a", team_models=list(models), models=list(models)) + + +@pytest.mark.asyncio +async def test_create__header_model_rejects_key_without_model_grant(harness): + """A key not granted the model named in x-litellm-model must not receive that deployment's credentials.""" + set_body(harness, {"input_file_id": "file-plain", "endpoint": "/v1/chat/completions", "completion_window": "24h"}) + + with pytest.raises(ProxyException) as exc_info: + await call_create(harness, user=_key_restricted_to("azure/gpt-4o"), headers={"x-litellm-model": "vertex-model"}) + + assert exc_info.value.code == "403" + harness.creds_resolver.assert_not_called() + harness.litellm_acreate.assert_not_called() + + +@pytest.mark.asyncio +async def test_create__header_model_allows_key_with_model_grant(harness): + set_body(harness, {"input_file_id": "file-plain", "endpoint": "/v1/chat/completions", "completion_window": "24h"}) + + await call_create(harness, user=_key_restricted_to("vertex-model"), headers={"x-litellm-model": "vertex-model"}) + + harness.creds_resolver.assert_called_once_with(model_id="vertex-model") + assert harness.acreate_kwargs()["custom_llm_provider"] == "vertex_ai" + + +@pytest.mark.asyncio +async def test_retrieve__model_encoded_id_rejects_key_without_model_grant(retrieve_harness): + """The model embedded in a batch id is caller-controlled, so it is checked against the key's grants too.""" + with pytest.raises(ProxyException) as exc_info: + await call_retrieve(retrieve_harness, AZURE_BATCH_ID, user=_key_restricted_to("vertex-model")) + + assert exc_info.value.code == "403" + retrieve_harness.creds_resolver.assert_not_called() + retrieve_harness.litellm_aretrieve.assert_not_called() + + +@pytest.mark.asyncio +async def test_cancel__model_encoded_id_rejects_key_without_model_grant(cancel_harness): + with pytest.raises(ProxyException) as exc_info: + await call_cancel(cancel_harness, AZURE_BATCH_ID, user=_key_restricted_to("vertex-model")) + + assert exc_info.value.code == "403" + cancel_harness.creds_resolver.assert_not_called() + cancel_harness.litellm_acancel.assert_not_called() + + +def _b64_unified_id(decoded: str) -> str: + return base64.urlsafe_b64encode(decoded.encode()).decode().rstrip("=") + + +UNIFIED_FILE_ID_FOR_GPT4O_MINI = _b64_unified_id( + "litellm_proxy:application/octet-stream;unified_id,c4843482-b176-4901-8292-7523fd0f2c6e;" + "target_model_names,gpt-4o-mini;llm_output_file_id,file-provider;llm_output_file_model_id,dep-1" +) +UNIFIED_BATCH_ID_FOR_GPT4O_MINI = _b64_unified_id(UNIFIED_BATCH_ID) + + +@pytest.mark.asyncio +async def test_create__unified_file_id_rejects_key_without_model_grant(harness): + """The model carried inside a unified file id is caller-controlled too, so it is checked against the key's grants.""" + set_body( + harness, + { + "input_file_id": UNIFIED_FILE_ID_FOR_GPT4O_MINI, + "endpoint": "/v1/chat/completions", + "completion_window": "24h", + }, + ) + + with pytest.raises(ProxyException) as exc_info: + await call_create(harness, user=_key_restricted_to("vertex-model")) + + assert exc_info.value.code == "403" + harness.router_acreate.assert_not_called() + harness.litellm_acreate.assert_not_called() + + +@pytest.mark.asyncio +async def test_retrieve__unified_batch_id_rejects_key_without_model_grant(retrieve_harness): + with pytest.raises(ProxyException) as exc_info: + await call_retrieve(retrieve_harness, UNIFIED_BATCH_ID_FOR_GPT4O_MINI, user=_key_restricted_to("vertex-model")) + + assert exc_info.value.code == "403" + retrieve_harness.router_aretrieve.assert_not_called() + retrieve_harness.creds_resolver.assert_not_called() + + +@pytest.mark.asyncio +async def test_retrieve__unified_batch_id_rejects_key_without_model_grant_before_db_terminal_shortcut( + retrieve_harness, +): + retrieve_harness.get_batch_from_db.return_value = (MagicMock(), make_batch(id="batch-from-db", status="completed")) + + with pytest.raises(ProxyException) as exc_info: + await call_retrieve(retrieve_harness, UNIFIED_BATCH_ID_FOR_GPT4O_MINI, user=_key_restricted_to("vertex-model")) + + assert exc_info.value.code == "403" + retrieve_harness.logging.post_call_success_hook.assert_not_called() + retrieve_harness.ensure_managed_files.assert_not_called() + retrieve_harness.router_aretrieve.assert_not_called() + + +@pytest.mark.asyncio +async def test_cancel__unified_batch_id_rejects_key_without_model_grant(cancel_harness): + with pytest.raises(ProxyException) as exc_info: + await call_cancel(cancel_harness, UNIFIED_BATCH_ID_FOR_GPT4O_MINI, user=_key_restricted_to("vertex-model")) + + assert exc_info.value.code == "403" + cancel_harness.router_acancel.assert_not_called() diff --git a/tests/test_litellm/proxy/openai_files_endpoint/test_files_endpoint.py b/tests/test_litellm/proxy/openai_files_endpoint/test_files_endpoint.py index aa505c3019b..dd67dc337ea 100644 --- a/tests/test_litellm/proxy/openai_files_endpoint/test_files_endpoint.py +++ b/tests/test_litellm/proxy/openai_files_endpoint/test_files_endpoint.py @@ -1877,7 +1877,7 @@ def test_get_file_content_streams_openai_direct_path( monkeypatch.setattr(litellm, "afile_content", _mock_afile_content) monkeypatch.setattr( "litellm.proxy.openai_files_endpoints.files_endpoints.handle_model_based_routing", - lambda **kwargs: (False, None, None, None), + AsyncMock(return_value=(False, None, None, None)), ) app.dependency_overrides[ps.user_api_key_auth] = lambda: UserAPIKeyAuth( @@ -1942,15 +1942,17 @@ def test_get_file_content_routed_provider_skips_streaming_when_resolved_provider ) monkeypatch.setattr( "litellm.proxy.openai_files_endpoints.files_endpoints.handle_model_based_routing", - lambda **kwargs: ( - True, - "azure-gpt-3-5-turbo", - "file-original-123", - { - "custom_llm_provider": "azure", - "api_key": "azure-key", - "api_base": "https://azure.example.com", - }, + AsyncMock( + return_value=( + True, + "azure-gpt-3-5-turbo", + "file-original-123", + { + "custom_llm_provider": "azure", + "api_key": "azure-key", + "api_base": "https://azure.example.com", + }, + ) ), ) @@ -2015,7 +2017,7 @@ def test_get_file_content_non_openai_provider_skips_streaming_handler( ) monkeypatch.setattr( "litellm.proxy.openai_files_endpoints.files_endpoints.handle_model_based_routing", - lambda **kwargs: (False, None, None, None), + AsyncMock(return_value=(False, None, None, None)), ) app.dependency_overrides[ps.user_api_key_auth] = lambda: UserAPIKeyAuth( @@ -2520,14 +2522,16 @@ def test_list_files_model_routing_does_not_forward_custom_llm_provider_twice( monkeypatch.setattr(litellm, "afile_list", _mock_afile_list) monkeypatch.setattr( "litellm.proxy.openai_files_endpoints.files_endpoints.handle_model_based_routing", - lambda **kwargs: ( - True, - "azure-gpt-4o", - None, - { - "custom_llm_provider": "azure", - "api_key": "azure-key", - }, + AsyncMock( + return_value=( + True, + "azure-gpt-4o", + None, + { + "custom_llm_provider": "azure", + "api_key": "azure-key", + }, + ) ), ) @@ -5224,3 +5228,204 @@ def test_get_file_content_keeps_the_status_of_a_rejection_raised_inside_the_rout error = response.json()["error"] assert error["message"].startswith("Storage backend error") assert (error["type"], error["param"], error["code"]) == ("invalid_request_error", "file_id", "400") + + +def test_get_file_model_routed_id_forwards_deployment_provider(mocker: MockerFixture, monkeypatch): + """ + Regression: a file id encoded with a non-OpenAI deployment (here Mistral) must be + retrieved from that deployment's provider. Before the fix the retrieve path only + forwarded the credentials and let ``custom_llm_provider`` default to openai, so a + Mistral file id was sent to api.openai.com with the Mistral key and 401'd. + """ + import litellm.proxy.proxy_server as ps + from litellm.proxy._types import LitellmUserRoles + from litellm.proxy.openai_files_endpoints.common_utils import encode_file_id_with_model + + router = Router( + model_list=[ + { + "model_name": "mistral-ocr", + "litellm_params": {"model": "mistral/mistral-ocr-latest", "api_key": "mistral-key"}, + "model_info": {"id": "mistral-ocr-id"}, + } + ] + ) + proxy_logging_obj = setup_proxy_logging_object(monkeypatch, router) + monkeypatch.setattr("litellm.proxy.proxy_server.master_key", None) + monkeypatch.setattr("litellm.proxy.proxy_server.prisma_client", None) + monkeypatch.setattr("litellm.proxy.proxy_server.llm_router", router) + proxy_logging_obj.update_request_status = mocker.AsyncMock() + proxy_logging_obj.post_call_failure_hook = mocker.AsyncMock() + + captured_kwargs: dict = {} + + async def _mock_afile_retrieve(**kwargs): + captured_kwargs.update(kwargs) + return OpenAIFileObject( + id="7a13fa8e-fcf8-42c5-aa61-c93c10e2c7df", + object="file", + bytes=2, + created_at=1234567890, + filename="batch.jsonl", + purpose="batch", + status="uploaded", + ) + + monkeypatch.setattr(litellm, "afile_retrieve", _mock_afile_retrieve) + app.dependency_overrides[ps.user_api_key_auth] = lambda: UserAPIKeyAuth( + api_key="test-key", user_role=LitellmUserRoles.PROXY_ADMIN, user_id="test-user" + ) + encoded_id = encode_file_id_with_model("7a13fa8e-fcf8-42c5-aa61-c93c10e2c7df", "mistral-ocr") + + try: + response = client.get(f"/v1/files/{encoded_id}", headers={"Authorization": "Bearer test-key"}) + finally: + app.dependency_overrides.pop(ps.user_api_key_auth, None) + + assert response.status_code == 200, response.text + assert captured_kwargs["custom_llm_provider"] == "mistral" + assert captured_kwargs["api_key"] == "mistral-key" + assert captured_kwargs["file_id"] == "7a13fa8e-fcf8-42c5-aa61-c93c10e2c7df" + assert response.json()["id"] == encoded_id + + +def _mistral_plus_anthropic_router() -> Router: + return Router( + model_list=[ + { + "model_name": "mistral-ocr", + "litellm_params": {"model": "mistral/mistral-ocr-latest", "api_key": "mistral-key"}, + "model_info": {"id": "mistral-ocr-id"}, + }, + { + "model_name": "claude-opus-4-6", + "litellm_params": {"model": "anthropic/claude-opus-4-6", "api_key": "anthropic-key"}, + "model_info": {"id": "claude-id"}, + }, + ] + ) + + +def _restricted_key(key_models: list[str]) -> UserAPIKeyAuth: + from litellm.proxy._types import LitellmUserRoles + + return UserAPIKeyAuth( + api_key="test-key", + user_role=LitellmUserRoles.INTERNAL_USER, + user_id="test-user", + team_id="team-a", + team_models=["claude-opus-4-6", "mistral-ocr"], + models=key_models, + ) + + +@pytest.mark.parametrize( + "http_method, path_suffix, litellm_fn", + [ + ("get", "", "afile_retrieve"), + ("get", "/content", "afile_content"), + ("delete", "", "afile_delete"), + ], +) +def test_model_routed_file_ops_reject_key_without_model_grant( + mocker: MockerFixture, monkeypatch, http_method: str, path_suffix: str, litellm_fn: str +): + """ + Regression: a key whose allowlist does not include the deployment named in a + model-encoded file id must be refused before that deployment's server-side + credentials are resolved. Previously any key could name any deployment via the + id (or the x-litellm-model header) and act on that provider account's files. + """ + import litellm.proxy.proxy_server as ps + from litellm.proxy.openai_files_endpoints.common_utils import encode_file_id_with_model + + router = _mistral_plus_anthropic_router() + proxy_logging_obj = setup_proxy_logging_object(monkeypatch, router) + monkeypatch.setattr("litellm.proxy.proxy_server.master_key", None) + monkeypatch.setattr("litellm.proxy.proxy_server.prisma_client", None) + monkeypatch.setattr("litellm.proxy.proxy_server.llm_router", router) + proxy_logging_obj.update_request_status = mocker.AsyncMock() + proxy_logging_obj.post_call_failure_hook = mocker.AsyncMock() + + upstream = mocker.AsyncMock(side_effect=AssertionError("provider must not be called")) + monkeypatch.setattr(litellm, litellm_fn, upstream) + app.dependency_overrides[ps.user_api_key_auth] = lambda: _restricted_key(["claude-opus-4-6"]) + encoded_id = encode_file_id_with_model("7a13fa8e-fcf8-42c5-aa61-c93c10e2c7df", "mistral-ocr") + + try: + response = getattr(client, http_method)( + f"/v1/files/{encoded_id}{path_suffix}", headers={"Authorization": "Bearer test-key"} + ) + finally: + app.dependency_overrides.pop(ps.user_api_key_auth, None) + + assert response.status_code == 403, response.text + assert response.json()["error"]["type"] == "key_model_access_denied" + upstream.assert_not_called() + + +def test_list_files_header_model_rejects_key_without_model_grant(mocker: MockerFixture, monkeypatch): + import litellm.proxy.proxy_server as ps + + router = _mistral_plus_anthropic_router() + proxy_logging_obj = setup_proxy_logging_object(monkeypatch, router) + monkeypatch.setattr("litellm.proxy.proxy_server.master_key", None) + monkeypatch.setattr("litellm.proxy.proxy_server.prisma_client", None) + monkeypatch.setattr("litellm.proxy.proxy_server.llm_router", router) + proxy_logging_obj.update_request_status = mocker.AsyncMock() + proxy_logging_obj.post_call_failure_hook = mocker.AsyncMock() + + upstream = mocker.AsyncMock(side_effect=AssertionError("provider must not be called")) + monkeypatch.setattr(litellm, "afile_list", upstream) + app.dependency_overrides[ps.user_api_key_auth] = lambda: _restricted_key(["claude-opus-4-6"]) + + try: + response = client.get( + "/v1/files", headers={"Authorization": "Bearer test-key", "x-litellm-model": "mistral-ocr"} + ) + finally: + app.dependency_overrides.pop(ps.user_api_key_auth, None) + + assert response.status_code == 403, response.text + upstream.assert_not_called() + + +def test_model_routed_file_retrieve_allows_key_with_model_grant(mocker: MockerFixture, monkeypatch): + """The grant check must not break the happy path: a key allowed the deployment still resolves its credentials.""" + import litellm.proxy.proxy_server as ps + from litellm.proxy.openai_files_endpoints.common_utils import encode_file_id_with_model + + router = _mistral_plus_anthropic_router() + proxy_logging_obj = setup_proxy_logging_object(monkeypatch, router) + monkeypatch.setattr("litellm.proxy.proxy_server.master_key", None) + monkeypatch.setattr("litellm.proxy.proxy_server.prisma_client", None) + monkeypatch.setattr("litellm.proxy.proxy_server.llm_router", router) + proxy_logging_obj.update_request_status = mocker.AsyncMock() + proxy_logging_obj.post_call_failure_hook = mocker.AsyncMock() + + captured_kwargs: dict = {} + + async def _mock_afile_retrieve(**kwargs): + captured_kwargs.update(kwargs) + return OpenAIFileObject( + id="7a13fa8e-fcf8-42c5-aa61-c93c10e2c7df", + object="file", + bytes=2, + created_at=1234567890, + filename="batch.jsonl", + purpose="batch", + status="uploaded", + ) + + monkeypatch.setattr(litellm, "afile_retrieve", _mock_afile_retrieve) + app.dependency_overrides[ps.user_api_key_auth] = lambda: _restricted_key(["mistral-ocr"]) + encoded_id = encode_file_id_with_model("7a13fa8e-fcf8-42c5-aa61-c93c10e2c7df", "mistral-ocr") + + try: + response = client.get(f"/v1/files/{encoded_id}", headers={"Authorization": "Bearer test-key"}) + finally: + app.dependency_overrides.pop(ps.user_api_key_auth, None) + + assert response.status_code == 200, response.text + assert captured_kwargs["api_key"] == "mistral-key" + assert captured_kwargs["custom_llm_provider"] == "mistral" diff --git a/tests/test_litellm/proxy/test_batch_x_litellm_model_encoding.py b/tests/test_litellm/proxy/test_batch_x_litellm_model_encoding.py index 3d1831bb4cd..3161fe99e68 100644 --- a/tests/test_litellm/proxy/test_batch_x_litellm_model_encoding.py +++ b/tests/test_litellm/proxy/test_batch_x_litellm_model_encoding.py @@ -11,6 +11,7 @@ from unittest.mock import AsyncMock, MagicMock, patch import pytest +from litellm.proxy._types import UserAPIKeyAuth from litellm.proxy.openai_files_endpoints.common_utils import ( decode_model_from_file_id, get_batch_id_from_unified_batch_id, @@ -58,10 +59,7 @@ def _make_batch_response( def test_get_batch_id_from_unified_batch_id_handles_appended_fields(): - decoded_id = ( - "litellm_proxy;model_id:deployment-123;" - "llm_batch_id:batch_openai_123;llm_output_file_id:file-output" - ) + decoded_id = "litellm_proxy;model_id:deployment-123;llm_batch_id:batch_openai_123;llm_output_file_id:file-output" assert get_batch_id_from_unified_batch_id(decoded_id) == "batch_openai_123" @@ -107,12 +105,10 @@ async def test_create_batch_with_x_litellm_model_encodes_batch_id(): } ), ), + patch("litellm.proxy.batches_endpoints.endpoints.ProxyBaseLLMRequestProcessing") as mock_processor_cls, patch( - "litellm.proxy.batches_endpoints.endpoints.ProxyBaseLLMRequestProcessing" - ) as mock_processor_cls, - patch( - "litellm.proxy.batches_endpoints.endpoints.get_credentials_for_model", - return_value=mock_credentials, + "litellm.proxy.batches_endpoints.endpoints.get_authorized_credentials_for_model", + new=AsyncMock(return_value=mock_credentials), ), patch( "litellm.proxy.batches_endpoints.endpoints.prepare_data_with_credentials", @@ -165,23 +161,15 @@ async def test_create_batch_with_x_litellm_model_encodes_batch_id(): ) # The batch_id should be encoded with model info - assert ( - response.id != raw_batch_id - ), f"Expected batch_id to be encoded, but got raw ID: {response.id}" - assert response.id.startswith( - "batch_" - ), f"Encoded batch_id should keep batch_ prefix, got: {response.id}" + assert response.id != raw_batch_id, f"Expected batch_id to be encoded, but got raw ID: {response.id}" + assert response.id.startswith("batch_"), f"Encoded batch_id should keep batch_ prefix, got: {response.id}" # Should be decodable back to the original decoded_model = decode_model_from_file_id(response.id) - assert ( - decoded_model == model_name - ), f"Expected model '{model_name}' from decoded batch_id, got: {decoded_model}" + assert decoded_model == model_name, f"Expected model '{model_name}' from decoded batch_id, got: {decoded_model}" original_id = get_original_file_id(response.id) - assert ( - original_id == raw_batch_id - ), f"Expected original ID '{raw_batch_id}', got: {original_id}" + assert original_id == raw_batch_id, f"Expected original ID '{raw_batch_id}', got: {original_id}" assert mock_create_batch.call_args.kwargs["metadata"] == {"customer_id": "cust-123"} @@ -227,12 +215,10 @@ async def test_create_batch_with_x_litellm_model_encodes_output_and_error_file_i } ), ), + patch("litellm.proxy.batches_endpoints.endpoints.ProxyBaseLLMRequestProcessing") as mock_processor_cls, patch( - "litellm.proxy.batches_endpoints.endpoints.ProxyBaseLLMRequestProcessing" - ) as mock_processor_cls, - patch( - "litellm.proxy.batches_endpoints.endpoints.get_credentials_for_model", - return_value=mock_credentials, + "litellm.proxy.batches_endpoints.endpoints.get_authorized_credentials_for_model", + new=AsyncMock(return_value=mock_credentials), ), patch( "litellm.proxy.batches_endpoints.endpoints.prepare_data_with_credentials", @@ -316,9 +302,7 @@ async def test_create_batch_without_x_litellm_model_returns_raw_ids(monkeypatch) } ), ), - patch( - "litellm.proxy.batches_endpoints.endpoints.ProxyBaseLLMRequestProcessing" - ) as mock_processor_cls, + patch("litellm.proxy.batches_endpoints.endpoints.ProxyBaseLLMRequestProcessing") as mock_processor_cls, patch( "litellm.acreate_batch", new=AsyncMock(return_value=mock_response), @@ -383,9 +367,7 @@ class TestBatchIdRoundTripWithRetrieve: raw_batch_id = "batch_vllm_12345" # What create_batch does: - encoded_id = encode_file_id_with_model( - file_id=raw_batch_id, model=model_name, id_type="batch" - ) + encoded_id = encode_file_id_with_model(file_id=raw_batch_id, model=model_name, id_type="batch") # What retrieve_batch does: decoded_model = decode_model_from_file_id(encoded_id) @@ -410,9 +392,7 @@ class TestBatchIdRoundTripWithRetrieve: ] for raw_id, model in test_cases: - encoded = encode_file_id_with_model( - file_id=raw_id, model=model, id_type="batch" - ) + encoded = encode_file_id_with_model(file_id=raw_id, model=model, id_type="batch") assert encoded.startswith("batch_") assert decode_model_from_file_id(encoded) == model assert get_original_file_id(encoded) == raw_id @@ -433,16 +413,10 @@ async def test_cancel_batch_with_unified_id_routes_with_decoded_model_and_batch_ mock_request.url.path = f"/v1/batches/{unified_batch_id}/cancel" mock_fastapi_response = MagicMock() mock_fastapi_response.headers = {} - mock_user_api_key_dict = MagicMock() - mock_user_api_key_dict.parent_otel_span = None - mock_user_api_key_dict.user_id = "test_user" - mock_user_api_key_dict.allowed_model_region = None - mock_user_api_key_dict.team_metadata = {} + mock_user_api_key_dict = UserAPIKeyAuth(api_key="sk-test", user_id="test_user", team_metadata={}) with ( - patch( - "litellm.proxy.batches_endpoints.endpoints.ProxyBaseLLMRequestProcessing" - ) as mock_processor_cls, + patch("litellm.proxy.batches_endpoints.endpoints.ProxyBaseLLMRequestProcessing") as mock_processor_cls, patch( "litellm.proxy.batches_endpoints.endpoints.update_batch_in_database", new=AsyncMock(), diff --git a/tests/test_litellm/proxy/vector_store_endpoints/test_vector_store_endpoints.py b/tests/test_litellm/proxy/vector_store_endpoints/test_vector_store_endpoints.py index dc445ec007c..20484e787bd 100644 --- a/tests/test_litellm/proxy/vector_store_endpoints/test_vector_store_endpoints.py +++ b/tests/test_litellm/proxy/vector_store_endpoints/test_vector_store_endpoints.py @@ -306,6 +306,40 @@ async def test_vector_store_file_list_resolves_credentials_from_model_query_para ) +@pytest.mark.asyncio +async def test_vector_store_file_list_registry_routed_model_skips_key_model_grant(): + request = MagicMock(spec=Request) + request.query_params = {} + request.headers = {} + + llm_router = MagicMock() + llm_router.get_deployment_credentials_with_provider.return_value = { + "api_key": "sk-team-openai", + "api_base": "https://api.openai.com/v1", + "custom_llm_provider": "openai", + "model": "openai/gpt-4o-mini", + } + + data = {"vector_store_id": "vs_123", "model": "team-openai"} + user_api_key_dict = UserAPIKeyAuth( + models=["restricted-deployment"], + team_models=["restricted-deployment"], + ) + + result = await _update_request_data_with_model_routing_hint( + data=data, + request=request, + llm_router=llm_router, + user_api_key_dict=user_api_key_dict, + ) + + assert result["api_key"] == "sk-team-openai" + assert result["model"] == "openai/gpt-4o-mini" + llm_router.get_deployment_credentials_with_provider.assert_called_once_with( + model_id="team-openai" + ) + + @pytest.mark.asyncio async def test_vector_store_file_list_resolves_single_openai_team_deployment(): request = MagicMock(spec=Request) @@ -575,6 +609,44 @@ async def test_vector_store_file_list_authorizes_model_query_param_before_creden llm_router.get_deployment_credentials_with_provider.assert_not_called() +@pytest.mark.asyncio +async def test_vector_store_file_list_model_query_param_enforces_project_model_grant(): + from litellm.proxy._types import LiteLLM_ProjectTableCachedObj, LiteLLM_TeamTableCachedObj + from litellm.proxy.auth.auth_checks import ProxyException + from litellm.proxy.common_utils.user_api_key_cache import UserApiKeyCache, project_cache_key + + request = MagicMock(spec=Request) + request.query_params = {"model": "team-openai"} + request.headers = {} + + llm_router = MagicMock() + llm_router.model_group_alias = {} + cache = UserApiKeyCache() + await cache.async_set_cache( + key="team_id:team-123", + value=LiteLLM_TeamTableCachedObj(team_id="team-123", models=["team-openai"]), + ) + await cache.async_set_cache( + key=project_cache_key("proj-1"), + value=LiteLLM_ProjectTableCachedObj(project_id="proj-1", models=["other-deployment"]), + ) + user_api_key_dict = UserAPIKeyAuth(team_id="team-123", team_models=["team-openai"], project_id="proj-1") + + with ( + patch("litellm.proxy.proxy_server.prisma_client", MagicMock()), # test-quality-ok: proxy_server global, no seam + patch("litellm.proxy.proxy_server.user_api_key_cache", cache), # test-quality-ok: proxy_server global, no seam + ): + with pytest.raises(ProxyException): + await _update_request_data_with_model_routing_hint( + data={"vector_store_id": "vs_123"}, + request=request, + llm_router=llm_router, + user_api_key_dict=user_api_key_dict, + ) + + llm_router.get_deployment_credentials_with_provider.assert_not_called() + + @pytest.mark.asyncio async def test_update_request_data_with_litellm_managed_vector_store_registry(): """ diff --git a/tests/test_litellm/test_router.py b/tests/test_litellm/test_router.py index 7cc2a9e4c82..c5ae5d4b151 100644 --- a/tests/test_litellm/test_router.py +++ b/tests/test_litellm/test_router.py @@ -5745,6 +5745,93 @@ async def test_router_unknown_model_error_message_renders_model_name_literally() assert " " not in message # no padding run from an expanded format field +def test_get_credential_deployment_is_the_deployment_credentials_resolve_to(): + """Regression: a batch retrieved with credentials resolved by model name was priced + without its deployment id, so per-deployment pricing never applied. The deployment + behind the credentials must be reachable by name and by id, carrying its model_info.""" + router = litellm.Router( + model_list=[ + { + "model_name": "mistral-ocr", + "litellm_params": {"model": "mistral/mistral-ocr-latest", "api_key": "sk-ocr"}, + "model_info": {"id": "ocr-dep", "ocr_cost_per_page_batches": 0.0123}, + } + ] + ) + + by_name = router.get_credential_deployment(model_id="mistral-ocr") + by_id = router.get_credential_deployment(model_id="ocr-dep") + + assert by_name is not None and by_id is not None + assert by_name.model_info.id == by_id.model_info.id == "ocr-dep" + assert by_name.model_info.model_dump()["ocr_cost_per_page_batches"] == 0.0123 + assert router.get_deployment_credentials_with_provider(model_id="mistral-ocr")["api_key"] == "sk-ocr" + assert router.get_credential_deployment(model_id="no-such-model") is None + + +def test_get_credential_deployment_skips_a_paused_deployment(): + router = litellm.Router( + model_list=[ + { + "model_name": "paused-ocr", + "litellm_params": {"model": "mistral/mistral-ocr-latest", "api_key": "sk-ocr"}, + "model_info": {"id": "paused-dep", "blocked": True}, + } + ] + ) + + assert router.get_credential_deployment(model_id="paused-ocr") is None + assert router.get_credential_deployment(model_id="paused-dep") is None + + +def test_get_team_public_name_deployment_only_resolves_the_owning_team(): + router = litellm.Router( + model_list=[ + { + "model_name": "mistral/mistral-ocr-latest", + "litellm_params": {"model": "mistral/mistral-ocr-latest", "api_key": "sk-team-a"}, + "model_info": {"id": "team-a-ocr", "team_id": "team-a", "team_public_model_name": "ocr"}, + } + ] + ) + + owning_team = router._get_team_public_name_deployment(model_id="ocr", team_id="team-a") + + assert owning_team is not None and owning_team.model_info.id == "team-a-ocr" + assert router._get_team_public_name_deployment(model_id="ocr", team_id="team-b") is None + assert router._get_team_public_name_deployment(model_id="ocr", team_id=None) is None + assert router.get_credential_deployment(model_id="ocr", team_id="team-a").model_info.id == "team-a-ocr" + assert router.get_credential_deployment(model_id="ocr", team_id="team-b") is None + + +def test_get_wildcard_deployment_usable_by_team_prefers_the_team_pattern(): + router = litellm.Router( + model_list=[ + { + "model_name": "mistral/*", + "litellm_params": {"model": "mistral/*", "api_key": "sk-shared"}, + "model_info": {"id": "shared-wildcard"}, + }, + { + "model_name": "mistral/*", + "litellm_params": {"model": "mistral/*", "api_key": "sk-team-a"}, + "model_info": {"id": "team-a-wildcard", "team_id": "team-a", "team_public_model_name": "mistral/*"}, + }, + ] + ) + ocr = "mistral/mistral-ocr-latest" + + team_match = router._get_wildcard_deployment_usable_by_team(model_id=ocr, team_id="team-a") + other_team_match = router._get_wildcard_deployment_usable_by_team(model_id=ocr, team_id="team-b") + anonymous_match = router._get_wildcard_deployment_usable_by_team(model_id=ocr, team_id=None) + + assert team_match is not None and team_match.model_info.id == "team-a-wildcard" + assert other_team_match is not None and other_team_match.model_info.id == "shared-wildcard" + assert anonymous_match is not None and anonymous_match.model_info.id == "shared-wildcard" + assert router._get_wildcard_deployment_usable_by_team(model_id="openai/gpt-5.6", team_id="team-a") is None + assert router.get_credential_deployment(model_id=ocr, team_id="team-b").model_info.id == "shared-wildcard" + + def test_get_deployment_credentials_with_provider_aws_bedrock_runtime_endpoint(): """ Test that get_deployment_credentials_with_provider correctly copies diff --git a/tests/test_litellm/test_utils.py b/tests/test_litellm/test_utils.py index 2fda5dfc490..8bb9a7e86a3 100644 --- a/tests/test_litellm/test_utils.py +++ b/tests/test_litellm/test_utils.py @@ -755,7 +755,9 @@ def test_aaamodel_prices_and_context_window_json_is_valid(): "input_cost_per_video_per_second_above_128k_tokens": {"type": "number"}, "input_dbu_cost_per_token": {"type": "number"}, "annotation_cost_per_page": {"type": "number"}, + "annotation_cost_per_page_batches": {"type": "number"}, "ocr_cost_per_page": {"type": "number"}, + "ocr_cost_per_page_batches": {"type": "number"}, "ocr_cost_per_credit": {"type": "number"}, "code_interpreter_cost_per_session": {"type": "number"}, "inference_geo": {"type": "string"}, diff --git a/ui/litellm-dashboard/src/lib/http/schema.d.ts b/ui/litellm-dashboard/src/lib/http/schema.d.ts index 2bb98fa4c66..5a53521d7ef 100644 --- a/ui/litellm-dashboard/src/lib/http/schema.d.ts +++ b/ui/litellm-dashboard/src/lib/http/schema.d.ts @@ -30665,6 +30665,8 @@ export interface components { allow_client_keepalive_override: boolean | null; /** Annotation Cost Per Page */ annotation_cost_per_page?: number | null; + /** Annotation Cost Per Page Batches */ + annotation_cost_per_page_batches?: number | null; /** Api Base */ api_base?: string | null; /** Api Key */ @@ -30894,6 +30896,8 @@ export interface components { ocr_cost_per_credit?: number | null; /** Ocr Cost Per Page */ ocr_cost_per_page?: number | null; + /** Ocr Cost Per Page Batches */ + ocr_cost_per_page_batches?: number | null; /** Organization */ organization?: string | null; /** Otpm */ @@ -41315,6 +41319,8 @@ export interface components { allow_client_keepalive_override: boolean | null; /** Annotation Cost Per Page */ annotation_cost_per_page?: number | null; + /** Annotation Cost Per Page Batches */ + annotation_cost_per_page_batches?: number | null; /** Api Base */ api_base?: string | null; /** Api Key */ @@ -41544,6 +41550,8 @@ export interface components { ocr_cost_per_credit?: number | null; /** Ocr Cost Per Page */ ocr_cost_per_page?: number | null; + /** Ocr Cost Per Page Batches */ + ocr_cost_per_page_batches?: number | null; /** Organization */ organization?: string | null; /** Otpm */