test: add regression coverage for twelve closed issues (#37974)

* test: add regression coverage for twelve closed issues

Adds targeted regression tests for behavior that was fixed but left ungated,
so the fixes cannot silently regress:

- #33772 openai cache_write_tokens cost
- #34309 Responses API cache cost_breakdown
- #35363 /v1/responses batch spend
- #36619 auto-router api_base/api_key leak on a shared model name
- #35359 batch fallbacks within the owning model group
- #36523 passthrough streamed Responses spend log
- #36646 passthrough embeddings spend log
- #37147 non-object metadata on create_batch is a 400
- #35362 unscoped list files reads the managed-file store
- #33221 gpt-5.6 bridges to Responses on function tools alone
- #34487 LLM complexity classifier runs for every caller metadata shape
- #35124 streamed /v1/messages emits success logging on both bridges

Cost assertions read rates from litellm.model_cost rather than hardcoding
dollar amounts, so they do not drift on repricing.

* fix: stop the new regression tests polluting and tripping over shared global state

Two shard failures, both from global state the new tests share with their
neighbours rather than from the behaviour under test.

test_main.py's local_cost_map pinned litellm.model_cost but left the
get_model_info lru_cache warm, so completion_cost billed at whatever prices
were cached earlier in the process while the assertions read the pinned map.
Clear the cache on both sides of the fixture, matching the local_model_cost_map
fixture in tests/test_litellm/conftest.py.

The anthropic messages streaming tests called GLOBAL_LOGGING_WORKER.flush()
on whatever queue happened to be around. A queue left non-empty by an earlier
test is still bound to that test's loop, so join() either hangs or raises
"bound to a different event loop". Rebind to the running loop before the call
and wait for the captured payload instead of a fixed sleep.
This commit is contained in:
Mateo Wang 2026-08-22 15:24:05 -07:00 committed by GitHub
parent ba07340964
commit 75613bf22f
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
10 changed files with 1242 additions and 4 deletions

View file

@ -0,0 +1,119 @@
"""Token and spend reconciliation for /v1/responses batches.
Regression guard for https://github.com/BerriAI/litellm/issues/35363: a batch
output line built by the Responses API reports ``input_tokens`` /
``output_tokens`` where a chat line reports ``prompt_tokens`` /
``completion_tokens``. The usage object was constructed straight from the raw
dict, which accepts the unrecognized names without raising and yields zeros, so
a completed Responses batch reconciled to 0 tokens and $0.00 spend with no
error, and per-key budgets were never charged for it.
Line shape decides the parse, not the batch's declared endpoint, so an output
file mixing Responses-shaped and chat-shaped lines sums across both.
"""
from typing import Literal, get_args, get_type_hints
import pytest
import litellm
import litellm.batches.batch_utils as bu
from litellm.types.llms.openai import CreateBatchRequest
MODEL = "gpt-5.6"
def _responses_line(input_tokens: int, output_tokens: int) -> dict:
return {
"response": {
"status_code": 200,
"body": {
"model": MODEL,
"usage": {
"input_tokens": input_tokens,
"output_tokens": output_tokens,
"total_tokens": input_tokens + output_tokens,
},
},
}
}
def _chat_line(prompt_tokens: int, completion_tokens: int) -> dict:
return {
"response": {
"status_code": 200,
"body": {
"model": MODEL,
"usage": {
"prompt_tokens": prompt_tokens,
"completion_tokens": completion_tokens,
"total_tokens": prompt_tokens + completion_tokens,
},
},
}
}
def test_responses_shaped_usage_maps_onto_prompt_and_completion_tokens():
"""The Responses names land on the chat-shaped counters instead of being
dropped for unrecognized keys."""
usage = bu._get_batch_job_usage_from_response_body(
{"usage": {"input_tokens": 100, "output_tokens": 50, "total_tokens": 150}}
)
assert (usage.prompt_tokens, usage.completion_tokens, usage.total_tokens) == (100, 50, 150)
async def test_responses_batch_reconciles_to_real_tokens_and_spend(local_model_cost_map):
"""A completed Responses batch records the provider's token counts and a
non-zero spend at the model's batch rates."""
model_info = litellm.get_model_info(model=MODEL, custom_llm_provider="openai")
input_tokens = 33
output_tokens = 57
cost, usage, models = await bu.calculate_batch_cost_and_usage(
file_content_dictionary=[_responses_line(input_tokens, output_tokens)],
custom_llm_provider="openai",
model_name=MODEL,
model_info=model_info,
)
assert (usage.prompt_tokens, usage.completion_tokens, usage.total_tokens) == (
input_tokens,
output_tokens,
input_tokens + output_tokens,
)
assert models == [MODEL]
assert cost == pytest.approx(
input_tokens * model_info["input_cost_per_token_batches"]
+ output_tokens * model_info["output_cost_per_token_batches"]
)
assert cost > 0.0
async def test_mixed_shape_batch_output_sums_across_both_line_shapes(local_model_cost_map):
"""An output file carrying both line shapes sums both. A fix keyed off the
batch's declared endpoint rather than each line's shape would miss this."""
model_info = litellm.get_model_info(model=MODEL, custom_llm_provider="openai")
cost, usage, _ = await bu.calculate_batch_cost_and_usage(
file_content_dictionary=[_responses_line(100, 50), _chat_line(33, 57)],
custom_llm_provider="openai",
model_name=MODEL,
model_info=model_info,
)
assert (usage.prompt_tokens, usage.completion_tokens, usage.total_tokens) == (133, 107, 240)
assert cost == pytest.approx(
133 * model_info["input_cost_per_token_batches"] + 107 * model_info["output_cost_per_token_batches"]
)
def test_create_batch_endpoint_accepts_v1_responses():
"""A type-checked caller can pass endpoint="/v1/responses", which the runtime
already forwarded correctly."""
endpoint_annotation = get_type_hints(CreateBatchRequest)["endpoint"]
assert "/v1/responses" in get_args(endpoint_annotation)
for create_fn in (litellm.create_batch, litellm.acreate_batch):
assert "/v1/responses" in get_args(get_type_hints(create_fn)["endpoint"])

View file

@ -0,0 +1,136 @@
"""Cache-write pricing for OpenAI models, on both the chat and Responses paths.
Regression guard for https://github.com/BerriAI/litellm/issues/33772: OpenAI
reports cache-write tokens under ``prompt_tokens_details.cache_write_tokens``
(chat) / ``input_tokens_details.cache_write_tokens`` (responses), where
Anthropic reports ``cache_creation_tokens``. The cost path read only the
Anthropic name, so cache-write tokens were billed at the plain input rate,
the Responses transform dropped the split before cost ran, and the tiered
``cache_creation_input_token_cost_{priority,flex,above_272k_tokens}`` keys were
discarded by ``get_model_info``.
"""
import pytest
import litellm
from litellm.litellm_core_utils.llm_cost_calc.utils import generic_cost_per_token
from litellm.responses.utils import ResponseAPILoggingUtils
from litellm.types.utils import Usage
MODEL = "gpt-5.6"
def _openai_chat_usage(prompt_tokens: int, cache_write_tokens: int, completion_tokens: int) -> Usage:
return Usage(
prompt_tokens=prompt_tokens,
completion_tokens=completion_tokens,
total_tokens=prompt_tokens + completion_tokens,
prompt_tokens_details={
"cached_tokens": 0,
"cache_write_tokens": cache_write_tokens,
},
)
def test_openai_cache_write_tokens_billed_at_the_cache_creation_rate(local_model_cost_map):
"""A cache-write request costs the cache-creation rate on the written tokens,
not the plain input rate."""
rates = litellm.model_cost[MODEL]
input_rate = rates["input_cost_per_token"]
cache_write_rate = rates["cache_creation_input_token_cost"]
output_rate = rates["output_cost_per_token"]
assert cache_write_rate == pytest.approx(input_rate * 1.25)
prompt_tokens = 12317
cache_write_tokens = 12314
fresh_tokens = prompt_tokens - cache_write_tokens
completion_tokens = 5
prompt_cost, completion_cost = generic_cost_per_token(
model=MODEL,
usage=_openai_chat_usage(prompt_tokens, cache_write_tokens, completion_tokens),
custom_llm_provider="openai",
)
assert prompt_cost == pytest.approx(fresh_tokens * input_rate + cache_write_tokens * cache_write_rate)
assert completion_cost == pytest.approx(completion_tokens * output_rate)
assert prompt_cost > prompt_tokens * input_rate
def test_responses_api_cache_write_costs_the_same_as_chat(local_model_cost_map):
"""The Responses usage transform carries the cache-write split through, so the
same request costs the same whichever route it took."""
prompt_tokens = 12317
cache_write_tokens = 12314
completion_tokens = 5
responses_usage = ResponseAPILoggingUtils._transform_response_api_usage_to_chat_usage(
{
"input_tokens": prompt_tokens,
"output_tokens": completion_tokens,
"total_tokens": prompt_tokens + completion_tokens,
"input_tokens_details": {
"cached_tokens": 0,
"cache_write_tokens": cache_write_tokens,
},
}
)
assert responses_usage.prompt_tokens_details.cache_write_tokens == cache_write_tokens
responses_prompt_cost, responses_completion_cost = generic_cost_per_token(
model=MODEL,
usage=responses_usage,
custom_llm_provider="openai",
)
chat_prompt_cost, chat_completion_cost = generic_cost_per_token(
model=MODEL,
usage=_openai_chat_usage(prompt_tokens, cache_write_tokens, completion_tokens),
custom_llm_provider="openai",
)
rates = litellm.model_cost[MODEL]
fresh_tokens = prompt_tokens - cache_write_tokens
assert responses_prompt_cost == pytest.approx(
fresh_tokens * rates["input_cost_per_token"]
+ cache_write_tokens * rates["cache_creation_input_token_cost"]
)
assert responses_prompt_cost == pytest.approx(chat_prompt_cost)
assert responses_completion_cost == pytest.approx(chat_completion_cost)
@pytest.mark.parametrize(
"service_tier,prompt_tokens,rate_key",
[
(None, 100000, "cache_creation_input_token_cost"),
("priority", 100000, "cache_creation_input_token_cost_priority"),
("flex", 100000, "cache_creation_input_token_cost_flex"),
(None, 300000, "cache_creation_input_token_cost_above_272k_tokens"),
],
)
def test_tiered_cache_creation_rates_are_registered_and_billed(
local_model_cost_map, service_tier, prompt_tokens, rate_key
):
"""The tiered cache-creation keys survive ``get_model_info`` and are the rate the
cost path actually charges for priority, flex, and >272k requests."""
model_info = litellm.get_model_info(model=MODEL, custom_llm_provider="openai")
tiered_rate = litellm.model_cost[MODEL][rate_key]
assert model_info.get(rate_key) == tiered_rate
cache_write_tokens = prompt_tokens - 1000
fresh_tokens = 1000
input_rate_key = {
"cache_creation_input_token_cost": "input_cost_per_token",
"cache_creation_input_token_cost_priority": "input_cost_per_token_priority",
"cache_creation_input_token_cost_flex": "input_cost_per_token_flex",
"cache_creation_input_token_cost_above_272k_tokens": "input_cost_per_token_above_272k_tokens",
}[rate_key]
input_rate = litellm.model_cost[MODEL][input_rate_key]
prompt_cost, _ = generic_cost_per_token(
model=MODEL,
usage=_openai_chat_usage(prompt_tokens, cache_write_tokens, completion_tokens=100),
custom_llm_provider="openai",
service_tier=service_tier,
)
assert prompt_cost == pytest.approx(fresh_tokens * input_rate + cache_write_tokens * tiered_rate)

View file

@ -0,0 +1,113 @@
"""Itemized cache costs in the logged cost breakdown for the OpenAI Responses API.
Regression guard for https://github.com/BerriAI/litellm/issues/34309: the
breakdown block derived its cache token counts only from the Anthropic-style
top-level ``cache_read_input_tokens`` / ``cache_creation_input_tokens``. OpenAI's
Responses API reports them under ``input_tokens_details.{cached_tokens,
cache_write_tokens}`` instead, so ``cost_breakdown.cache_read_cost`` and
``cache_creation_cost`` serialized as null for every OpenAI request while the
grand total stayed correct.
``input_cost`` is the full prompt-side cost and the two cache fields are additive
break-outs that overlap it, matching the Anthropic path. A request with no cache
activity leaves both fields unset, which is the documented behavior.
"""
from datetime import datetime
import pytest
import litellm
from litellm.litellm_core_utils.litellm_logging import Logging
from litellm.responses.utils import ResponseAPILoggingUtils
from litellm.types.utils import Choices, Message, ModelResponse
MODEL = "gpt-5.6"
def _logging_obj() -> Logging:
return Logging(
model=MODEL,
messages=[{"role": "user", "content": "hi"}],
stream=False,
call_type="completion",
start_time=datetime.now(),
litellm_call_id="responses-cache-breakdown",
function_id="f",
)
def _responses_completion(cached_tokens: int, cache_write_tokens: int, fresh_tokens: int, output_tokens: int):
input_tokens = cached_tokens + cache_write_tokens + fresh_tokens
usage = ResponseAPILoggingUtils._transform_response_api_usage_to_chat_usage(
{
"input_tokens": input_tokens,
"output_tokens": output_tokens,
"total_tokens": input_tokens + output_tokens,
"input_tokens_details": {
"cached_tokens": cached_tokens,
"cache_write_tokens": cache_write_tokens,
},
}
)
return ModelResponse(
id="x",
created=1,
model=MODEL,
object="chat.completion",
choices=[Choices(index=0, message=Message(role="assistant", content="hi"), finish_reason="stop")],
usage=usage,
)
def test_responses_api_cache_costs_are_itemized_in_the_breakdown(local_model_cost_map):
"""Cache-read and cache-write dollars are broken out for an OpenAI Responses
request, at the model's own cache rates."""
rates = litellm.model_cost[MODEL]
cached_tokens = 4012
cache_write_tokens = 5000
fresh_tokens = 1000
output_tokens = 200
logging_obj = _logging_obj()
total = litellm.completion_cost(
completion_response=_responses_completion(cached_tokens, cache_write_tokens, fresh_tokens, output_tokens),
model=MODEL,
custom_llm_provider="openai",
litellm_logging_obj=logging_obj,
)
breakdown = logging_obj.cost_breakdown
assert breakdown is not None
expected_cache_read = cached_tokens * rates["cache_read_input_token_cost"]
expected_cache_creation = cache_write_tokens * rates["cache_creation_input_token_cost"]
expected_input = fresh_tokens * rates["input_cost_per_token"] + expected_cache_read + expected_cache_creation
expected_output = output_tokens * rates["output_cost_per_token"]
assert breakdown["cache_read_cost"] == pytest.approx(expected_cache_read)
assert breakdown["cache_creation_cost"] == pytest.approx(expected_cache_creation)
assert breakdown["input_cost"] == pytest.approx(expected_input)
assert breakdown["output_cost"] == pytest.approx(expected_output)
assert breakdown["total_cost"] == pytest.approx(expected_input + expected_output)
assert total == pytest.approx(breakdown["total_cost"])
def test_cache_fields_stay_unset_when_there_was_no_cache_activity(local_model_cost_map):
"""A request that neither read nor wrote the cache leaves both break-out fields
off the breakdown, so they serialize as null rather than a misleading $0."""
logging_obj = _logging_obj()
litellm.completion_cost(
completion_response=_responses_completion(
cached_tokens=0, cache_write_tokens=0, fresh_tokens=1000, output_tokens=200
),
model=MODEL,
custom_llm_provider="openai",
litellm_logging_obj=logging_obj,
)
breakdown = logging_obj.cost_breakdown
assert breakdown is not None
assert "cache_read_cost" not in breakdown
assert "cache_creation_cost" not in breakdown
assert breakdown["input_cost"] == pytest.approx(1000 * litellm.model_cost[MODEL]["input_cost_per_token"])

View file

@ -1,5 +1,8 @@
import asyncio
import json
import os
import uuid
from typing import Any, Dict, List
import httpx
import pytest
@ -10,6 +13,8 @@ from unittest.mock import AsyncMock, MagicMock, patch
import litellm
from litellm.anthropic_interface import messages
from litellm.integrations.custom_logger import CustomLogger
from litellm.litellm_core_utils.logging_worker import GLOBAL_LOGGING_WORKER
from litellm.llms.custom_httpx.http_handler import AsyncHTTPHandler
from litellm.types.utils import Delta, ModelResponse, StreamingChoices
@ -1103,3 +1108,181 @@ def test_messages_sync_streaming_reports_provider_local_model():
first_event = next(iter(stream))
assert json.loads(first_event.decode().split("data: ", 1)[1])["message"]["model"] == "perplexity/kimi-k3"
_RESPONSES_COMPLETED_BODY: Dict[str, Any] = {
"id": "resp-1",
"object": "response",
"created_at": 0,
"model": "gpt-4o-mini",
"status": "completed",
"output": [
{
"type": "message",
"id": "msg-1",
"status": "completed",
"role": "assistant",
"content": [{"type": "output_text", "text": "hello there"}],
}
],
"usage": {"input_tokens": 11, "output_tokens": 7, "total_tokens": 18},
}
_RESPONSES_SSE_EVENTS: List[Dict[str, Any]] = [
{
"type": "response.created",
"response": {
**_RESPONSES_COMPLETED_BODY,
"status": "in_progress",
"output": [],
"usage": None,
},
},
{
"type": "response.output_text.delta",
"item_id": "msg-1",
"output_index": 0,
"content_index": 0,
"delta": "hello there",
},
{"type": "response.completed", "response": _RESPONSES_COMPLETED_BODY},
]
def _sse_body(events: List[Dict[str, Any]]) -> bytes:
return b"".join(f"data: {json.dumps(event)}\n\n".encode() for event in events)
class _SuccessPayloadCapture(CustomLogger):
def __init__(self, tracking_id: str):
super().__init__()
self.tracking_id = tracking_id
self.payloads: List[Dict[str, Any]] = []
async def async_log_success_event(self, kwargs, response_obj, start_time, end_time):
if kwargs.get("litellm_call_id") == self.tracking_id:
self.payloads.append(kwargs.get("standard_logging_object") or {})
@pytest.fixture
def capture_success_payloads(monkeypatch):
monkeypatch.setenv("OPENAI_API_KEY", "sk-test")
capture = _SuccessPayloadCapture(tracking_id=f"messages-stream-{uuid.uuid4()}")
monkeypatch.setattr(litellm, "callbacks", [capture])
return capture
async def _drain(sse_stream) -> List[bytes]:
return [chunk async for chunk in sse_stream]
def _bind_logging_worker_to_running_loop() -> None:
"""The worker's queue keeps the loop it was built on, so one left over from an earlier
test makes ``flush`` raise "bound to a different event loop". ``start`` runs
``_ensure_queue``, which rebinds the queue when the running loop has changed."""
GLOBAL_LOGGING_WORKER.start()
async def _flush_logging_worker(capture: "_SuccessPayloadCapture") -> None:
await asyncio.sleep(0)
try:
await asyncio.wait_for(GLOBAL_LOGGING_WORKER.flush(), timeout=10.0)
except (asyncio.TimeoutError, RuntimeError):
pass
deadline = asyncio.get_running_loop().time() + 10.0
while not capture.payloads and asyncio.get_running_loop().time() < deadline:
await asyncio.sleep(0.05)
def _assert_anthropic_sse(chunks: List[bytes]) -> None:
body = b"".join(chunks).decode()
assert "message_start" in body
assert "message_stop" in body
class TestMessagesStreamingSuccessLogging:
"""A streamed /v1/messages call routed to an ``openai/`` backend must still reach
success logging once the SSE stream is drained. The client gets a correct Anthropic
SSE body and the provider bills the call, but no StandardLoggingPayload is produced,
so the request is invisible to spend tracking and every success-logging integration.
Logging is fired by the inner CustomStreamWrapper / ResponsesAPIStreamingIterator the
Anthropic wrappers drain, not by the wrappers themselves, so these assert on the
callback that actually reaches an integration rather than on a wrapper attribute.
"""
MESSAGES = [{"role": "user", "content": "hi"}]
@pytest.mark.asyncio
async def test_responses_bridge_streaming_emits_success_logging(self, capture_success_payloads):
"""The Responses bridge, which is the default for openai/ deployments."""
from litellm.llms.anthropic.experimental_pass_through.responses_adapters.handler import (
LiteLLMMessagesToResponsesAPIHandler,
)
_bind_logging_worker_to_running_loop()
with patch(
"litellm.llms.custom_httpx.http_handler.AsyncHTTPHandler.post",
new_callable=AsyncMock,
) as mock_post:
mock_post.return_value = httpx.Response(
200,
content=_sse_body(_RESPONSES_SSE_EVENTS),
headers={"content-type": "text/event-stream"},
)
sse_stream = await LiteLLMMessagesToResponsesAPIHandler.async_anthropic_messages_handler(
max_tokens=100,
messages=self.MESSAGES,
model="openai/gpt-4o-mini",
stream=True,
custom_llm_provider="openai",
litellm_call_id=capture_success_payloads.tracking_id,
)
chunks = await _drain(sse_stream)
await _flush_logging_worker(capture_success_payloads)
_assert_anthropic_sse(chunks)
assert len(capture_success_payloads.payloads) == 1
payload = capture_success_payloads.payloads[0]
assert payload["call_type"] == "aresponses"
assert payload["prompt_tokens"] == 11
assert payload["completion_tokens"] == 7
assert payload["total_tokens"] == 18
assert payload["response_cost"] > 0
@pytest.mark.asyncio
async def test_chat_completions_bridge_streaming_emits_success_logging(self, capture_success_payloads):
"""The chat-completions bridge, reached via
litellm.use_chat_completions_url_for_anthropic_messages. Its router lookup is
stubbed to what an SDK caller with no proxy running already resolves to."""
from litellm.llms.anthropic.experimental_pass_through.adapters.handler import (
LiteLLMMessagesToCompletionTransformationHandler,
)
_bind_logging_worker_to_running_loop()
with patch(
"litellm.llms.anthropic.experimental_pass_through.adapters.handler._proxy_router_fallback",
return_value=None,
):
sse_stream = await LiteLLMMessagesToCompletionTransformationHandler.async_anthropic_messages_handler(
max_tokens=100,
messages=self.MESSAGES,
model="openai/gpt-4o-mini",
stream=True,
custom_llm_provider="openai",
mock_response="hello there",
litellm_call_id=capture_success_payloads.tracking_id,
)
chunks = await _drain(sse_stream)
await _flush_logging_worker(capture_success_payloads)
_assert_anthropic_sse(chunks)
assert len(capture_success_payloads.payloads) == 1
payload = capture_success_payloads.payloads[0]
assert payload["call_type"] == "acompletion"
assert payload["total_tokens"] > 0
assert payload["response_cost"] > 0

View file

@ -29,6 +29,7 @@ added to this layer raises instead of silently passing - the inventory of seams
cannot drift without a test failure.
"""
import json
from contextlib import ExitStack
from dataclasses import dataclass
from typing import Any, Dict, Optional
@ -50,7 +51,7 @@ from litellm.router import Router
from litellm.types.llms.openai import BatchJobStatus
from litellm.types.utils import CredentialItem, LiteLLMBatch
from fastapi import Response
from fastapi import Request, Response
# --------------------------------------------------------------------------- #
# Fixtures: distinguishable credentials per model so a wrong/hardcoded model_id
@ -851,6 +852,48 @@ async def test_create__missing_required_param_is_400(harness, body, missing_para
harness.router_acreate.assert_not_called()
def _raw_batches_request(body: Dict[str, Any]) -> MagicMock:
"""A request that reaches the real pre-call logic, which the `harness` fixture
mocks out. Metadata validation lives there, so it cannot be seen through the seam."""
request = MagicMock(spec=Request)
request.url = MagicMock()
request.url.__str__.return_value = "http://localhost/v1/batches"
request.url.path = "/v1/batches"
request.method = "POST"
request.query_params = {}
request.headers = {"Content-Type": "application/json"}
request.client = MagicMock()
request.client.host = "127.0.0.1"
request.body = AsyncMock(return_value=json.dumps(body).encode())
return request
@pytest.mark.asyncio
@pytest.mark.parametrize("field", ["metadata", "litellm_metadata"])
async def test_create__non_object_metadata_is_400(field):
"""A non-object metadata field is rejected with a 400 naming it (#37147), rather
than being coerced to None and silently dropped, or crashing behind a 500 with
"'str' object has no attribute 'update'"."""
body = {
"input_file_id": "file-abc",
"endpoint": "/v1/chat/completions",
"completion_window": "24h",
field: "abc",
}
with pytest.raises(ProxyException) as exc_info:
await endpoints.create_batch(
request=_raw_batches_request(body),
fastapi_response=Response(),
provider=None,
user_api_key_dict=UserAPIKeyAuth(api_key="sk-test"),
)
assert exc_info.value.code == "400"
assert exc_info.value.param == field
assert "has no attribute 'update'" not in exc_info.value.message
# =========================================================================== #
# Team-level batch expiry enforcement (independent of routing).
# =========================================================================== #

View file

@ -1,6 +1,6 @@
import json
from typing import Final, List
from unittest.mock import ANY, AsyncMock
from typing import Final, List, Optional
from unittest.mock import ANY, AsyncMock, MagicMock
import pytest
import respx
@ -4302,3 +4302,177 @@ def test_batch_upload_closes_the_spools_it_opened(monkeypatch, llm_router: Route
finally:
app.dependency_overrides.pop(ps.user_api_key_auth, None)
ProxyLogging._callback_capabilities_cache.clear()
def _managed_file_row(
unified_file_id: str,
stored_file_id: Optional[str] = None,
created_by: str = "test-user",
team_id: Optional[str] = None,
) -> MagicMock:
file_object = OpenAIFileObject(
id=stored_file_id or unified_file_id,
bytes=100,
created_at=1700000000,
filename="batch.jsonl",
object="file",
purpose="batch",
status="processed",
)
return MagicMock(
unified_file_id=unified_file_id,
file_object=file_object.model_dump(),
created_by=created_by,
team_id=team_id,
)
def _row_matches_where(row, where) -> bool:
for field, expected in where.items():
if field == "OR":
if not any(_row_matches_where(row, clause) for clause in expected):
return False
elif getattr(row, field) != expected:
return False
return True
class _ManagedFileTableOverRows:
"""Enough of the Prisma table for the real hook's owner-scoped keyset query."""
def __init__(self, rows):
self.rows = list(rows)
def _owned_rows(self, where):
return [row for row in self.rows if _row_matches_where(row, where)]
async def find_first(self, where):
return next(iter(self._owned_rows(where)), None)
async def find_many(self, where, take=None, order=None, cursor=None, skip=0):
rows = self._owned_rows(where)
if cursor is not None:
start = next(
index
for index, row in enumerate(rows)
if row.unified_file_id == cursor["unified_file_id"]
)
rows = rows[start + skip :]
return rows if take is None else rows[:take]
def _setup_unscoped_list_files_route_over_real_hook(
mocker, monkeypatch, llm_router: Router, rows
):
"""Wire GET /v1/files to a real managed-files hook over `rows`, with no provider
credentials in the process. The neighbouring setup stubs the hook with a
MagicMock, so it cannot see anything past the route's argument plumbing."""
import litellm.proxy.proxy_server as ps
from litellm.proxy._types import LitellmUserRoles
from litellm_enterprise.proxy.hooks.managed_files import (
_PROXY_LiteLLMManagedFiles,
)
for env_var in ("OPENAI_API_KEY", "OPENAI_ADMIN_KEY", "OPENAI_ORGANIZATION"):
monkeypatch.delenv(env_var, raising=False)
monkeypatch.setattr(litellm, "api_key", None, raising=False)
monkeypatch.setattr(litellm, "openai_key", None, raising=False)
managed_files = _PROXY_LiteLLMManagedFiles(
internal_usage_cache=MagicMock(), prisma_client=MagicMock()
)
managed_files.prisma_client.db.litellm_managedfiletable = _ManagedFileTableOverRows(rows)
proxy_logging_obj = setup_proxy_logging_object(monkeypatch, llm_router)
proxy_logging_obj.proxy_hook_mapping["managed_files"] = managed_files
proxy_logging_obj.update_request_status = mocker.AsyncMock()
proxy_logging_obj.post_call_success_hook = mocker.AsyncMock(return_value=None)
proxy_logging_obj.post_call_failure_hook = mocker.AsyncMock()
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", llm_router)
provider_list = mocker.patch.object(litellm, "afile_list", new=mocker.AsyncMock())
app.dependency_overrides[ps.user_api_key_auth] = lambda: UserAPIKeyAuth(
api_key="test-key",
user_role=LitellmUserRoles.INTERNAL_USER,
user_id="test-user",
)
return proxy_logging_obj, provider_list
def test_unscoped_list_files_reads_the_store_without_any_provider_key(
mocker: MockerFixture, monkeypatch, llm_router: Router
):
"""The exact shape `client.files.list()` produces, with no target_model_names, no
provider and no OPENAI_API_KEY in the process, must return the caller's managed
files instead of falling through to a keyless provider client (#35362)."""
proxy_logging_obj, provider_list = _setup_unscoped_list_files_route_over_real_hook(
mocker, monkeypatch, llm_router, [_managed_file_row("unified-file-1")]
)
response = _get_unscoped_list_files("")
assert response.status_code == 200, response.text
assert [file["id"] for file in response.json()["data"]] == ["unified-file-1"]
provider_list.assert_not_awaited()
proxy_logging_obj.post_call_failure_hook.assert_not_called()
def test_unscoped_list_files_returns_unified_ids_for_rows_storing_a_raw_provider_id(
mocker: MockerFixture, monkeypatch, llm_router: Router
):
"""Batch output rows store the provider's file object, whose id is a raw `file-`
the caller cannot act on. The listing hands back the row's unified id so
files.retrieve and files.content work on what it returned."""
_setup_unscoped_list_files_route_over_real_hook(
mocker,
monkeypatch,
llm_router,
[_managed_file_row("unified-batch-output", stored_file_id="file-raw-provider-123")],
)
response = _get_unscoped_list_files("")
assert response.status_code == 200, response.text
assert [file["id"] for file in response.json()["data"]] == ["unified-batch-output"]
assert response.json()["data"][0]["filename"] == "batch.jsonl"
def test_unscoped_list_files_does_not_leak_another_callers_files(
mocker: MockerFixture, monkeypatch, llm_router: Router
):
"""Listing without a model pinned is still owner-scoped."""
_setup_unscoped_list_files_route_over_real_hook(
mocker,
monkeypatch,
llm_router,
[
_managed_file_row("unified-mine"),
_managed_file_row("unified-theirs", created_by="other-user"),
],
)
response = _get_unscoped_list_files("")
assert response.status_code == 200, response.text
assert [file["id"] for file in response.json()["data"]] == ["unified-mine"]
def test_scoped_list_files_still_resolves_deployment_credentials(
mocker: MockerFixture, monkeypatch, llm_router: Router
):
"""target_model_names keeps routing to the provider on deployment credentials, so
the unscoped path does not swallow that route."""
_, provider_list = _setup_unscoped_list_files_route_over_real_hook(
mocker, monkeypatch, llm_router, [_managed_file_row("unified-file-1")]
)
provider_list.return_value = []
response = _get_unscoped_list_files("?target_model_names=gpt-3.5-turbo")
assert response.status_code == 200, response.text
provider_list.assert_awaited_once()
assert provider_list.await_args.kwargs["custom_llm_provider"] == "openai"
assert provider_list.await_args.kwargs["api_key"] == "openai_api_key"

View file

@ -7,6 +7,7 @@ import httpx
import pytest
import litellm
from litellm.litellm_core_utils.litellm_logging import Logging as LiteLLMLoggingObj
from litellm.proxy.pass_through_endpoints.llm_provider_handlers.openai_passthrough_logging_handler import (
OpenAIPassthroughLoggingHandler,
@ -14,6 +15,7 @@ from litellm.proxy.pass_through_endpoints.llm_provider_handlers.openai_passthrou
from litellm.proxy.pass_through_endpoints.success_handler import (
PassThroughEndpointLogging,
)
from litellm.proxy.spend_tracking.spend_tracking_utils import get_logging_payload
from litellm.types.passthrough_endpoints.pass_through_endpoints import (
PassthroughStandardLoggingPayload,
)
@ -1809,5 +1811,229 @@ class TestOpenAIPassthroughIntegration:
assert mock_logging_obj.model_call_details["custom_llm_provider"] == "openai"
class TestOpenAIPassthroughResponsesStreamingSpendLog:
"""A streamed OpenAI-passthrough `/v1/responses` call must write a priced spend
log row (#36523).
`_handle_logging_openai_collected_chunks` received `url_route` and ignored it, so
a Responses SSE stream was reassembled by the chat-completions chunk parser. The
assembled object carried a synthetic `chatcmpl-` id, `model=None` and zero usage,
and the spend row landed at zero tokens and zero spend while the identical
buffered call priced exactly.
"""
RESPONSE_ID = "resp_0c72ddebf05f8751"
MODEL_MAP_KEY = "gpt-4o-mini-2024-07-18"
INPUT_TOKENS = 14
OUTPUT_TOKENS = 2
def setup_method(self):
self.start_time = datetime.now()
self.end_time = datetime.now()
rates = litellm.model_cost[self.MODEL_MAP_KEY]
self.expected_spend = (
self.INPUT_TOKENS * rates["input_cost_per_token"]
+ self.OUTPUT_TOKENS * rates["output_cost_per_token"]
)
def _responses_stream_chunks(self) -> List[str]:
"""Usage arrives only on the terminal `response.completed` event, nested under
`response` as `input_tokens` / `output_tokens`. No event carries a top-level
`id`, `model` or `usage`."""
response_body = {
"id": self.RESPONSE_ID,
"object": "response",
"created_at": 1786374786,
"model": self.MODEL_MAP_KEY,
"error": None,
"incomplete_details": None,
"instructions": None,
"metadata": {},
"parallel_tool_calls": True,
"temperature": 1.0,
"tool_choice": "auto",
"tools": [],
"top_p": 1.0,
}
created_event = {
"type": "response.created",
"sequence_number": 0,
"response": {**response_body, "status": "in_progress", "output": [], "usage": None},
}
delta_event = {
"type": "response.output_text.delta",
"sequence_number": 3,
"item_id": "msg_abc",
"output_index": 0,
"content_index": 0,
"delta": "OK",
}
completed_event = {
"type": "response.completed",
"sequence_number": 8,
"response": {
**response_body,
"status": "completed",
"output": [
{
"id": "msg_abc",
"type": "message",
"status": "completed",
"role": "assistant",
"content": [{"type": "output_text", "text": "OK", "annotations": []}],
}
],
"usage": {
"input_tokens": self.INPUT_TOKENS,
"input_tokens_details": {"cached_tokens": 0},
"output_tokens": self.OUTPUT_TOKENS,
"output_tokens_details": {"reasoning_tokens": 0},
"total_tokens": self.INPUT_TOKENS + self.OUTPUT_TOKENS,
},
},
}
return [
f"data: {json.dumps(created_event)}",
f"data: {json.dumps(delta_event)}",
f"data: {json.dumps(completed_event)}",
"data: [DONE]",
]
def _logging_obj(self) -> LiteLLMLoggingObj:
logging_obj = LiteLLMLoggingObj(
model="gpt-4o-mini",
messages=[],
stream=True,
call_type="pass_through_endpoint",
start_time=self.start_time,
litellm_call_id="323dfe4f-2741-4473-b6e2-000000000000",
function_id="1234",
)
logging_obj.model_call_details["custom_llm_provider"] = "openai"
return logging_obj
def test_streamed_responses_passthrough_spend_log_is_priced(self):
"""The spend row books the same tokens, spend and `resp_` id as the buffered call."""
result = OpenAIPassthroughLoggingHandler._handle_logging_openai_collected_chunks(
litellm_logging_obj=self._logging_obj(),
passthrough_success_handler_obj=None,
url_route="https://api.openai.com/v1/responses",
request_body={
"model": "gpt-4o-mini",
"input": "Say hi in one word.",
"max_output_tokens": 16,
"stream": True,
},
endpoint_type=None,
start_time=self.start_time,
all_chunks=self._responses_stream_chunks(),
end_time=self.end_time,
)
spend_log_row = get_logging_payload(
kwargs=result["kwargs"],
response_obj=result["result"],
start_time=self.start_time,
end_time=self.end_time,
)
assert spend_log_row["prompt_tokens"] == self.INPUT_TOKENS
assert spend_log_row["completion_tokens"] == self.OUTPUT_TOKENS
assert spend_log_row["total_tokens"] == self.INPUT_TOKENS + self.OUTPUT_TOKENS
assert spend_log_row["spend"] == self.expected_spend
assert spend_log_row["request_id"] == self.RESPONSE_ID
assert spend_log_row["model"] == "gpt-4o-mini"
row_metadata = json.loads(spend_log_row["metadata"])
assert row_metadata["model_map_information"]["model_map_key"] == self.MODEL_MAP_KEY
class TestOpenAIPassthroughEmbeddingsSpendLog:
"""An OpenAI-passthrough `/v1/embeddings` call must write a priced spend log row
(#36646).
`_is_supported_openai_endpoint` ORed four route predicates, none of which matched
`/v1/embeddings`, so the dispatcher never entered the OpenAI handler and billable
embedding tokens produced no spend row at all, under-enforcing key budgets.
"""
EMBEDDINGS_URL = "https://api.openai.com/v1/embeddings"
MODEL = "text-embedding-3-small"
PROMPT_TOKENS = 14
CALL_ID = "11024cc3-b143-4a63-954a-ec06081df768"
def setup_method(self):
self.start_time = datetime.now()
self.end_time = datetime.now()
self.expected_spend = self.PROMPT_TOKENS * litellm.model_cost[self.MODEL]["input_cost_per_token"]
self.response_body = {
"object": "list",
"data": [{"object": "embedding", "index": 0, "embedding": [0.0, 1.0]}],
"model": self.MODEL,
"usage": {"prompt_tokens": self.PROMPT_TOKENS, "total_tokens": self.PROMPT_TOKENS},
}
self.request_body = {"model": self.MODEL, "input": "hello"}
def _create_mock_httpx_response(self) -> httpx.Response:
mock_response = MagicMock(spec=httpx.Response)
mock_response.status_code = 200
mock_response.text = json.dumps(self.response_body)
mock_response.json.return_value = self.response_body
mock_response.headers = {"content-type": "application/json"}
return mock_response
def _logging_obj(self) -> LiteLLMLoggingObj:
logging_obj = LiteLLMLoggingObj(
model=self.MODEL,
messages=[],
stream=False,
call_type="pass_through_endpoint",
start_time=self.start_time,
litellm_call_id=self.CALL_ID,
function_id="1234",
)
logging_obj.model_call_details["passthrough_logging_payload"] = PassthroughStandardLoggingPayload(
url=self.EMBEDDINGS_URL,
request_body=self.request_body,
request_method="POST",
)
return logging_obj
def test_embeddings_passthrough_spend_log_is_priced(self):
"""The dispatched call books prompt tokens and cost onto the spend row."""
dispatched = PassThroughEndpointLogging().normalize_llm_passthrough_logging_payload(
httpx_response=self._create_mock_httpx_response(),
response_body=self.response_body,
request_body=self.request_body,
logging_obj=self._logging_obj(),
url_route=self.EMBEDDINGS_URL,
result="",
start_time=self.start_time,
end_time=self.end_time,
cache_hit=False,
custom_llm_provider="openai",
litellm_call_id=self.CALL_ID,
litellm_params={},
call_type="pass_through_endpoint",
)
assert dispatched["standard_logging_response_object"] is not None
assert dispatched["kwargs"]["response_cost"] == self.expected_spend
spend_log_row = get_logging_payload(
kwargs=dispatched["kwargs"],
response_obj=dispatched["standard_logging_response_object"],
start_time=self.start_time,
end_time=self.end_time,
)
assert spend_log_row["prompt_tokens"] == self.PROMPT_TOKENS
assert spend_log_row["total_tokens"] == self.PROMPT_TOKENS
assert spend_log_row["spend"] == self.expected_spend
assert spend_log_row["model"] == self.MODEL
assert spend_log_row["custom_llm_provider"] == "openai"
assert spend_log_row["request_id"] == self.CALL_ID
if __name__ == "__main__":
pytest.main([__file__])

View file

@ -1805,6 +1805,51 @@ class TestLLMClassifier:
call_kwargs = mock_router_instance.acompletion.call_args.kwargs
assert call_kwargs["metadata"] == {**request_metadata, "internal_call_origin": "autorouter_classifier"}
@pytest.mark.asyncio
@pytest.mark.parametrize(
"request_kwargs",
[
pytest.param({"metadata": {"user_api_key": "sk-abc"}}, id="metadata-bucket"),
pytest.param(
{"litellm_metadata": {"user_api_key": "sk-abc"}}, id="litellm-metadata-bucket"
),
pytest.param({}, id="no-caller-context"),
pytest.param(None, id="no-request-kwargs"),
],
)
async def test_aclassify_reaches_the_llm_for_every_caller_metadata_shape(
self, llm_classifier_config, request_kwargs
):
"""Whatever the caller's metadata bucket looks like, the configured classifier must
actually run. The forwarded metadata reaches litellm's own metadata handling, which
raises "'NoneType' object has no attribute 'update'" on a shape it does not expect;
aclassify catches that and silently degrades to heuristic scoring, so the tier is
decided by word counting while the config says otherwise. A real Router is used here
because a mocked acompletion accepts any shape and never reaches that handling.
"""
real_router = Router(
model_list=[
{
"model_name": "haiku-classifier",
"litellm_params": {
"model": "openai/haiku-classifier",
"api_key": "sk-classifier",
"mock_response": '{"tier": "COMPLEX"}',
},
}
]
)
router = ComplexityRouter(
model_name="test-complexity-router",
litellm_router_instance=real_router,
complexity_router_config=llm_classifier_config,
)
outcome = await router.aclassify("hi", request_kwargs=request_kwargs)
assert outcome.cause == "llm_classifier"
assert outcome.tier == ComplexityTier.COMPLEX
@pytest.mark.asyncio
async def test_aclassify_captures_request_body_in_proxy_server_request(
self, llm_complexity_router, mock_router_instance

View file

@ -850,6 +850,36 @@ def test_responses_api_bridge_check_gpt_5_4_tools_with_default_reasoning_routes_
assert model_info.get("mode") == "responses"
@pytest.mark.parametrize("model_name", ["gpt-5.6-sol", "gpt-5.6-luna", "gpt-5.6-terra"])
def test_responses_api_bridge_check_gpt_5_6_tools_with_default_reasoning_routes_to_responses(
monkeypatch, model_name
):
"""
The whole gpt-5.6 family must bridge on function tools alone. The bridge used to
require an explicit reasoning_effort, so a gpt-5.6 call carrying tools and no effort
was rejected with "Function tools with reasoning_effort are not supported for
gpt-5.6-sol in /v1/chat/completions".
"""
import litellm
from litellm.main import responses_api_bridge_check
monkeypatch.delenv("OPENAI_BASE_URL", raising=False)
monkeypatch.delenv("OPENAI_API_BASE", raising=False)
monkeypatch.setattr(litellm, "api_base", None)
with patch("litellm.main._get_model_info_helper") as mock_get_model_info:
mock_get_model_info.return_value = {"max_tokens": 128000}
model_info, model = responses_api_bridge_check(
model=model_name,
custom_llm_provider="openai",
tools=[{"type": "function", "function": {"name": "get_capital"}}],
reasoning_effort=None,
)
assert model == model_name
assert model_info.get("mode") == "responses"
def test_responses_api_bridge_check_gpt_5_4_tools_with_reasoning_none_stays_chat():
"""
Explicit reasoning_effort "none" is OpenAI's documented escape hatch that keeps
@ -2833,9 +2863,17 @@ def _priced_at(prompt_tokens, completion_tokens):
@pytest.fixture
def local_cost_map(monkeypatch):
"""The prices these tests assert are the checked-in ones. Setting the environment
variable alone does not reload the map, so pin the map itself."""
variable alone does not reload the map, so pin the map itself.
``get_model_info`` is lru_cached, so pinning ``model_cost`` is not enough on its
own: a cached entry warmed against the network-fetched map keeps its old prices
and ``completion_cost`` bills at those while the assertions read the pinned map.
Clear on the way in and out so entries never leak across tests in either direction."""
monkeypatch.setenv("LITELLM_LOCAL_MODEL_COST_MAP", "True")
monkeypatch.setattr(litellm, "model_cost", litellm.get_model_cost_map(url=""))
litellm.get_model_info.cache_clear()
yield
litellm.get_model_info.cache_clear()
def test_a_streamed_response_bills_the_usage_the_provider_reported(local_cost_map):

View file

@ -6,6 +6,7 @@ import os
import threading
from unittest.mock import AsyncMock, MagicMock, patch
import httpx
import pytest
@ -7304,6 +7305,78 @@ async def test_acreate_batch_surfaces_owning_provider_error_without_disable_fall
assert attempted_models == ["owning-model"]
@pytest.mark.asyncio
async def test_acreate_batch_still_falls_back_within_the_owning_model_group():
"""Holding a batch inside the model group that owns its input file must not
disable fallbacks outright (#35359): the owning group's second deployment is
still tried in `order`, and only the cross-group target is skipped."""
completion_window_error = "Invalid value: '5m'. Supported values are: '24h'."
attempted_models = []
async def _acreate_batch(**kwargs):
model = kwargs["model"]
attempted_models.append(model)
if model.startswith("azure/"):
raise litellm.BadRequestError(
message="Error code: 400 - {'error': {'code': 'quotaExceeded'}}",
model=model,
llm_provider="azure",
)
raise litellm.BadRequestError(
message=completion_window_error,
model=model,
llm_provider="openai",
)
router = litellm.Router(
model_list=[
{
"model_name": "my-gpt",
"litellm_params": {
"model": "openai/gpt-4o-mini",
"api_key": "sk-owning",
"order": 1,
},
"model_info": {"id": "my-gpt-1"},
},
{
"model_name": "my-gpt",
"litellm_params": {
"model": "openai/gpt-4o-mini-backup",
"api_key": "sk-owning",
"order": 2,
},
"model_info": {"id": "my-gpt-2"},
},
{
"model_name": "my-azure-gpt",
"litellm_params": {
"model": "azure/gpt-4o-mini",
"api_key": "sk-fallback",
"api_base": "https://fallback.openai.azure.com",
"api_version": "2024-08-01-preview",
},
"model_info": {"id": "my-azure-gpt-1"},
},
],
fallbacks=[{"my-gpt": ["my-azure-gpt"]}],
num_retries=0,
)
with patch.object(litellm, "acreate_batch", new=_acreate_batch):
with pytest.raises(litellm.BadRequestError) as raised:
await router.acreate_batch(
model="my-gpt",
input_file_id="file-owned-by-my-gpt",
endpoint="/v1/chat/completions",
completion_window="5m",
)
assert "24h" in str(raised.value)
assert "quotaExceeded" not in str(raised.value)
assert attempted_models == ["openai/gpt-4o-mini", "openai/gpt-4o-mini-backup"]
@pytest.mark.asyncio
async def test_acreate_batch_request_bedrock_tags_override_deployment_tags():
import httpx
@ -8264,6 +8337,94 @@ class TestTaggedAutoRouterOnSharedModelName:
assert marker_only._model_name_has_plain_deployments("gpt4o") is False
class TestAutoRouterSharedModelNameConnectionParams:
"""A plain deployment sharing its model_name with an `auto_router/` marker must not have
its api_base and api_key grafted onto the routed tier's outbound call (#36619)."""
PLAIN_API_BASE = "https://plain-sibling.openai.example/v1"
PLAIN_API_KEY = "sk-plain-sibling-secret"
class _FixedRouteLayer:
def __call__(self, text: str):
from semantic_router.schema import RouteChoice
return RouteChoice(name="gemini-flash")
@classmethod
def _router(cls, plain_entry_first: bool) -> "litellm.Router":
pytest.importorskip("semantic_router", reason="auto-router needs the semantic-router extra")
plain = {
"model_name": "gpt4o",
"litellm_params": {
"model": "openai/gpt-4o",
"api_key": cls.PLAIN_API_KEY,
"api_base": cls.PLAIN_API_BASE,
},
}
marker = {
"model_name": "gpt4o",
"litellm_params": {
"model": "auto_router/gpt4o-router",
"auto_router_config": json.dumps(
{"routes": [{"name": "gemini-flash", "utterances": ["capital city questions"]}]}
),
"auto_router_default_model": "gemini-flash",
"auto_router_embedding_model": "text-embedding-3-small",
"drop_params": True,
},
}
tier = {
"model_name": "gemini-flash",
"litellm_params": {"model": "gemini/gemini-3.6-flash", "api_key": "sk-tier-key"},
}
shared_name_entries = [plain, marker] if plain_entry_first else [marker, plain]
router = litellm.Router(model_list=[*shared_name_entries, tier])
router.auto_routers["gpt4o"][0].strategy.routelayer = cls._FixedRouteLayer()
return router
@staticmethod
def _gemini_response() -> httpx.Response:
return httpx.Response(
status_code=200,
json={
"candidates": [
{"content": {"parts": [{"text": "Paris"}], "role": "model"}, "finishReason": "STOP"}
],
"usageMetadata": {"promptTokenCount": 5, "candidatesTokenCount": 1, "totalTokenCount": 6},
"modelVersion": "gemini-3.6-flash",
},
request=httpx.Request("POST", "https://generativelanguage.googleapis.com"),
)
@pytest.mark.parametrize(
"plain_entry_first", [True, False], ids=["plain_entry_first", "marker_entry_first"]
)
async def test_routed_tier_call_goes_out_on_its_own_endpoint_and_credentials(self, plain_entry_first):
"""The outbound provider request for the routed tier hits the tier's own Gemini host
with the tier's own key, never the plain sibling's api_base or api_key."""
from litellm.llms.custom_httpx.http_handler import AsyncHTTPHandler
router = self._router(plain_entry_first)
with patch.object(
AsyncHTTPHandler, "post", new_callable=AsyncMock, return_value=self._gemini_response()
) as mock_post:
await router.acompletion(
model="gpt4o",
messages=[{"role": "user", "content": "What is the capital of France?"}],
)
call = mock_post.call_args
outbound_url = str(call.kwargs["url"] if "url" in call.kwargs else call.args[0])
outbound_headers = dict(call.kwargs.get("headers") or {})
assert "generativelanguage.googleapis.com" in outbound_url
assert "gemini-3.6-flash" in outbound_url
assert self.PLAIN_API_BASE not in outbound_url
assert self.PLAIN_API_KEY not in outbound_url
assert self.PLAIN_API_KEY not in json.dumps(outbound_headers)
class TestGetAllowedFailsFromPolicy:
def _make_router(self, **policy_kwargs) -> litellm.Router:
from litellm.types.router import AllowedFailsPolicy