fix(batches): honor deployment OCR page pricing in batch cost and answer 400 for unsupported Mistral file purposes

This commit is contained in:
mateo-berri 2026-09-18 23:57:41 -07:00
parent 5783a38e27
commit f89ca64481
8 changed files with 116 additions and 27 deletions

View file

@ -2115,12 +2115,8 @@ def ocr_cost(
return ocr_pages_cost + annotation_pages_cost, 0.0
_OCR_PRICING_KEYS: Final = (
"ocr_cost_per_page",
"ocr_cost_per_page_batches",
"annotation_cost_per_page",
"annotation_cost_per_page_batches",
)
_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(
@ -2133,17 +2129,27 @@ def ocr_batch_cost(
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. Returns
``(prompt_cost, completion_cost)`` with the whole cost in the first slot, like
``ocr_cost``.
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``.
"""
has_ocr_pricing: Final = model_info is not None and any(model_info.get(k) is not None for k in _OCR_PRICING_KEYS)
resolved_info: Final = (
model_info
if has_ocr_pricing
else _lookup_model_info_or_none(model=model, custom_llm_provider=custom_llm_provider)
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
)
if resolved_info 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; returning 0.0 cost.",
_single_log_line(model),
@ -2151,10 +2157,16 @@ def ocr_batch_cost(
)
return 0.0, 0.0
page_rate: Final = _first_price(resolved_info, "ocr_cost_per_page_batches", "ocr_cost_per_page")
annotation_rate: Final = _first_price(resolved_info, "annotation_cost_per_page_batches", "annotation_cost_per_page")
pages_processed: Final = usage_info.pages_processed or 0
annotation_pages: Final = usage_info.pages_processed_annotation or 0
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 "
@ -2178,7 +2190,9 @@ def _lookup_model_info_or_none(model: str, custom_llm_provider: str | None) -> M
return None
def _first_price(model_info: ModelInfo, *keys: str) -> float | 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)

View file

@ -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

View file

@ -100,7 +100,11 @@ def _to_mistral_purpose(purpose: str) -> MistralFilePurpose:
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}")
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

View file

@ -1462,7 +1462,7 @@
"audio_transcriptions": false,
"audio_speech": false,
"moderations": false,
"batches": false,
"batches": true,
"rerank": false,
"ocr": true,
"a2a": true,

View file

@ -1577,7 +1577,7 @@
"audio_transcriptions": false,
"audio_speech": false,
"moderations": false,
"batches": false,
"batches": true,
"rerank": false,
"ocr": true,
"a2a": true,

View file

@ -1940,6 +1940,35 @@ def test_ocr_rows_use_deployment_model_info_pricing_over_cost_map(monkeypatch):
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_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")

View file

@ -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,
@ -529,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.

View file

@ -13,6 +13,7 @@ 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
@ -115,14 +116,16 @@ def test_upload_request_maps_user_data_onto_ocr(config):
@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."""
with pytest.raises(ValueError, match=f"purpose={purpose!r}"):
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):
@ -212,8 +215,9 @@ def test_list_request_accepts_the_purpose_an_ocr_file_reads_back_as(config):
def test_list_request_rejects_purposes_mistral_lacks(config):
with pytest.raises(ValueError, match="purpose='assistants'"):
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):