fix(mistral): accept user_data as the OCR file purpose and keep OCR cost warnings single-line

This commit is contained in:
mateo-berri 2026-09-18 21:56:43 -07:00
parent fb76b67e78
commit f3b198c1b7
4 changed files with 51 additions and 19 deletions

View file

@ -2146,8 +2146,8 @@ def ocr_batch_cost(
if resolved_info is None:
verbose_logger.warning(
"OCR batch cost: model=%s custom_llm_provider=%s has no pricing entry; returning 0.0 cost.",
model,
custom_llm_provider,
_single_log_line(model),
_single_log_line(custom_llm_provider),
)
return 0.0, 0.0
@ -2159,14 +2159,18 @@ def ocr_batch_cost(
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.",
model,
custom_llm_provider,
_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)

View file

@ -307,7 +307,10 @@ def _mask_presigned_request_headers(transformed_request: bytes | str | dict) ->
_get_masked_values, # pyright: ignore[reportPrivateUsage] # the shared header-masking helper has no public name
)
return {**transformed_request, "headers": _get_masked_values(request_headers)}
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]:

View file

@ -8,6 +8,7 @@ Mistral only accepts ``fine-tune``, ``batch`` and ``ocr`` as upload purposes.
import time
from collections.abc import Mapping, Sequence
from types import MappingProxyType
from typing import Final, Literal, TypeAlias
import httpx
@ -33,6 +34,14 @@ from ..common_utils import get_mistral_api_base, get_mistral_auth_headers, mistr
MistralFilePurpose: TypeAlias = Literal["fine-tune", "batch", "ocr"]
_OPENAI_PURPOSE_BY_MISTRAL: Final[Mapping[MistralFilePurpose, OpenAIFilesPurpose]] = MappingProxyType(
{"fine-tune": "fine-tune", "batch": "batch", "ocr": "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]
@ -81,22 +90,18 @@ def _to_openai_file_object(file: MistralFile) -> OpenAIFileObject:
def _to_openai_purpose(purpose: MistralFilePurpose) -> OpenAIFilesPurpose:
match purpose:
case "fine-tune" | "batch":
return purpose
case "ocr":
return "user_data"
return _OPENAI_PURPOSE_BY_MISTRAL[purpose]
def _to_mistral_purpose(purpose: str) -> MistralFilePurpose:
"""Only Mistral's own purposes pass through. Silently mapping anything else to ``batch``
would let an upload skip the proxy's batch-file validation and guardrails, which only
run when the caller says ``purpose=batch``."""
match purpose:
case "batch" | "fine-tune" | "ocr":
return purpose
case _:
raise ValueError(f"Mistral does not support purpose={purpose!r}. Use one of: batch, fine-tune, ocr")
"""``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 ValueError(f"Mistral does not support purpose={purpose!r}. Use one of: {_SUPPORTED_PURPOSES}")
return mistral_purpose
def _api_base_from(litellm_params: Mapping[str, object]) -> str:

View file

@ -102,7 +102,17 @@ def test_upload_request_passes_mistral_purposes_through(config, purpose):
assert body["purpose"] == (None, purpose)
@pytest.mark.parametrize("purpose", ["assistants", "user_data", "vision", "evals"])
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."""
@ -191,6 +201,16 @@ def test_list_request_filters_by_mapped_purpose(config):
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(ValueError, match="purpose='assistants'"):
config.transform_list_files_request(purpose="assistants", optional_params={}, litellm_params={})