From 8a9ff04b4d369c227645c5b361ec1f0b65c94e27 Mon Sep 17 00:00:00 2001 From: milan Date: Thu, 23 Jul 2026 20:48:01 +0000 Subject: [PATCH 001/133] fix(rag): forward retrieval_filter from retrieval_config to Bedrock KB search Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- litellm/rag/main.py | 2 + litellm/types/rag.py | 1 + tests/test_litellm/rag/test_main.py | 77 ++++++++++++++++++++++++++++- 3 files changed, 79 insertions(+), 1 deletion(-) diff --git a/litellm/rag/main.py b/litellm/rag/main.py index 29891ccfd24..a1a5bbd38e9 100644 --- a/litellm/rag/main.py +++ b/litellm/rag/main.py @@ -233,10 +233,12 @@ async def _execute_query_pipeline( raise ValueError("No query found in messages for RAG query") # 2. Search vector store + filters = retrieval_config.get("retrieval_filter") or retrieval_config.get("filters") with _suppressed_sub_call_billing(): search_response = await litellm.vector_stores.asearch( vector_store_id=retrieval_config["vector_store_id"], query=query_text, + filters=filters, max_num_results=retrieval_config.get("top_k", 10), custom_llm_provider=retrieval_config.get("custom_llm_provider", "openai"), **kwargs, diff --git a/litellm/types/rag.py b/litellm/types/rag.py index 802bf7a6e9e..216c5a611ca 100644 --- a/litellm/types/rag.py +++ b/litellm/types/rag.py @@ -244,6 +244,7 @@ class RAGRetrievalConfig(TypedDict, total=False): custom_llm_provider: str top_k: int # max results from vector store filters: Optional[Dict[str, Any]] # optional - vector store filters + retrieval_filter: Optional[Dict[str, Any]] # optional - alias forwarded as vector store filters class RAGRerankConfig(TypedDict, total=False): diff --git a/tests/test_litellm/rag/test_main.py b/tests/test_litellm/rag/test_main.py index 584124ba06a..30ff0b76cd8 100644 --- a/tests/test_litellm/rag/test_main.py +++ b/tests/test_litellm/rag/test_main.py @@ -11,7 +11,7 @@ aquery carries the completion response with real usage and cost. """ import asyncio -from unittest.mock import patch +from unittest.mock import AsyncMock, patch import pytest @@ -254,6 +254,81 @@ async def test_aquery_streaming_bills_sub_call_costs_into_final_event(): assert standard_logging_object["response_cost"] >= 0.003 +@pytest.mark.asyncio +@pytest.mark.parametrize("filter_key", ["retrieval_filter", "filters"]) +async def test_aquery_forwards_retrieval_filter_to_vector_store_search(filter_key): + """ + The retrieval_config filter (AWS Bedrock KB metadata filter) must reach the + vector store search call. Before the fix it was dropped, so Bedrock ran an + unfiltered Retrieve and returned documents from the wrong metadata partition. + Both the customer-facing `retrieval_filter` key and the typed `filters` alias + must be forwarded as the search `filters` argument. + """ + from litellm.types.vector_stores import VectorStoreSearchResponse + + retrieval_filter = { + "andAll": [ + {"equals": {"key": "Technology", "value": "Blade"}}, + {"equals": {"key": "Parameter", "value": "Nicotine"}}, + ] + } + + fake_search = AsyncMock( + return_value=VectorStoreSearchResponse( + object="vector_store.search_results.page", + search_query="q", + data=[], + ) + ) + + with patch("litellm.vector_stores.asearch", new=fake_search): + response = await litellm.aquery( + model="gpt-4o-mini", + messages=[{"role": "user", "content": "most frequent causes of low nicotine"}], + retrieval_config={ + "vector_store_id": "CBVFYF3MYF", + "custom_llm_provider": "bedrock", + "top_k": 50, + filter_key: retrieval_filter, + }, + mock_response="answer", + ) + + assert isinstance(response, ModelResponse) + fake_search.assert_awaited_once() + assert fake_search.await_args.kwargs["filters"] == retrieval_filter + assert fake_search.await_args.kwargs["vector_store_id"] == "CBVFYF3MYF" + assert fake_search.await_args.kwargs["max_num_results"] == 50 + + +@pytest.mark.asyncio +async def test_aquery_without_filter_forwards_none(): + """ + When no filter is provided, the search call must receive filters=None rather + than a truthy default that would silently constrain an unfiltered query. + """ + from litellm.types.vector_stores import VectorStoreSearchResponse + + fake_search = AsyncMock( + return_value=VectorStoreSearchResponse( + object="vector_store.search_results.page", + search_query="q", + data=[], + ) + ) + + with patch("litellm.vector_stores.asearch", new=fake_search): + await litellm.aquery( + model="gpt-4o-mini", + messages=[{"role": "user", "content": "hello"}], + retrieval_config={"vector_store_id": "vs_test_123", "custom_llm_provider": "openai"}, + mock_response="hi", + ) + + fake_search.assert_awaited_once() + assert fake_search.await_args.kwargs["filters"] is None + + def test_rag_call_types_are_registered(): """ query/aquery/ingest/aingest are @client-decorated entry points, so their From 232b9e8e78ff5b7d14376bf7d72173cb469a05f6 Mon Sep 17 00:00:00 2001 From: milan Date: Thu, 23 Jul 2026 21:04:42 +0000 Subject: [PATCH 002/133] fix(rag): consume top-level filters kwarg to avoid duplicate keyword in search Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- litellm/rag/main.py | 3 ++- tests/test_litellm/rag/test_main.py | 35 +++++++++++++++++++++++++++++ 2 files changed, 37 insertions(+), 1 deletion(-) diff --git a/litellm/rag/main.py b/litellm/rag/main.py index a1a5bbd38e9..628680e8a62 100644 --- a/litellm/rag/main.py +++ b/litellm/rag/main.py @@ -233,7 +233,8 @@ async def _execute_query_pipeline( raise ValueError("No query found in messages for RAG query") # 2. Search vector store - filters = retrieval_config.get("retrieval_filter") or retrieval_config.get("filters") + kwargs_filters = kwargs.pop("filters", None) + filters = retrieval_config.get("retrieval_filter") or retrieval_config.get("filters") or kwargs_filters with _suppressed_sub_call_billing(): search_response = await litellm.vector_stores.asearch( vector_store_id=retrieval_config["vector_store_id"], diff --git a/tests/test_litellm/rag/test_main.py b/tests/test_litellm/rag/test_main.py index 30ff0b76cd8..796f8c38750 100644 --- a/tests/test_litellm/rag/test_main.py +++ b/tests/test_litellm/rag/test_main.py @@ -301,6 +301,41 @@ async def test_aquery_forwards_retrieval_filter_to_vector_store_search(filter_ke assert fake_search.await_args.kwargs["max_num_results"] == 50 +@pytest.mark.asyncio +async def test_aquery_top_level_filters_kwarg_does_not_collide(): + """ + An SDK caller may pass a top-level `filters` kwarg (it used to flow to the + search via **kwargs). Now that the pipeline passes `filters` explicitly, the + top-level kwarg must be consumed rather than forwarded twice, otherwise + asearch raises TypeError for a duplicate keyword before any search runs. + """ + from litellm.types.vector_stores import VectorStoreSearchResponse + + top_level_filter = {"equals": {"key": "tenant", "value": "a"}} + + fake_search = AsyncMock( + return_value=VectorStoreSearchResponse( + object="vector_store.search_results.page", + search_query="q", + data=[], + ) + ) + + with patch("litellm.vector_stores.asearch", new=fake_search): + response = await litellm.aquery( + model="gpt-4o-mini", + messages=[{"role": "user", "content": "hello"}], + retrieval_config={"vector_store_id": "vs_test_123", "custom_llm_provider": "openai"}, + filters=top_level_filter, + mock_response="hi", + ) + + assert isinstance(response, ModelResponse) + fake_search.assert_awaited_once() + assert fake_search.await_args.kwargs["filters"] == top_level_filter + assert "filters" not in fake_search.await_args.kwargs.get("kwargs", {}) + + @pytest.mark.asyncio async def test_aquery_without_filter_forwards_none(): """ From c6a5aab74c9a2c11b2d55ba289659a4ff3942ad5 Mon Sep 17 00:00:00 2001 From: milan Date: Thu, 23 Jul 2026 21:27:25 +0000 Subject: [PATCH 003/133] ci: run orphaned tests/test_litellm/rag suite in misc shard Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- .github/workflows/test-unit-misc.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/test-unit-misc.yml b/.github/workflows/test-unit-misc.yml index 7c3b195f0ad..34d5eef6caa 100644 --- a/.github/workflows/test-unit-misc.yml +++ b/.github/workflows/test-unit-misc.yml @@ -35,6 +35,7 @@ jobs: tests/test_litellm/interactions tests/test_litellm/ocr tests/test_litellm/passthrough + tests/test_litellm/rag tests/test_litellm/sandbox tests/test_litellm/vector_stores tests/test_litellm/videos From 317bb1ea4d86a90dbaac47b77fe16129fdc1fe68 Mon Sep 17 00:00:00 2001 From: shivam Date: Fri, 24 Jul 2026 01:34:10 +0000 Subject: [PATCH 004/133] fix(streaming): guard empty-choices chunks in Responses bridge and Anthropic adapter iterators Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- .../adapters/streaming_iterator.py | 6 +- .../adapters/transformation.py | 2 +- .../streaming_iterator.py | 6 ++ .../test_streaming_iterator_combined_chunk.py | 54 +++++++++++ .../test_empty_choices_streaming_iterator.py | 91 +++++++++++++++++++ 5 files changed, 156 insertions(+), 3 deletions(-) create mode 100644 tests/test_litellm/responses/litellm_completion_transformation/test_empty_choices_streaming_iterator.py diff --git a/litellm/llms/anthropic/experimental_pass_through/adapters/streaming_iterator.py b/litellm/llms/anthropic/experimental_pass_through/adapters/streaming_iterator.py index f02333c34c8..367f2585a9b 100644 --- a/litellm/llms/anthropic/experimental_pass_through/adapters/streaming_iterator.py +++ b/litellm/llms/anthropic/experimental_pass_through/adapters/streaming_iterator.py @@ -423,7 +423,7 @@ class AnthropicStreamWrapper(AdapterCompletionStreamWrapper): will_merge_into_held = ( self.holding_stop_reason_chunk is not None and getattr(chunk, "usage", None) is not None ) - is_final_chunk = chunk.choices[0].finish_reason is not None + is_final_chunk = bool(chunk.choices) and chunk.choices[0].finish_reason is not None processed_chunk = LiteLLMAnthropicMessagesAdapter().translate_streaming_openai_response_to_anthropic( response=chunk, current_content_block_index=self.current_content_block_index, @@ -646,7 +646,7 @@ class AnthropicStreamWrapper(AdapterCompletionStreamWrapper): will_merge_into_held = ( self.holding_stop_reason_chunk is not None and getattr(chunk, "usage", None) is not None ) - is_final_chunk = chunk.choices[0].finish_reason is not None + is_final_chunk = bool(chunk.choices) and chunk.choices[0].finish_reason is not None processed_chunk = LiteLLMAnthropicMessagesAdapter().translate_streaming_openai_response_to_anthropic( response=chunk, current_content_block_index=self.current_content_block_index, @@ -889,6 +889,8 @@ class AnthropicStreamWrapper(AdapterCompletionStreamWrapper): # Example logic - customize based on your needs: # If chunk indicates a tool call + if not chunk.choices: + return False if chunk.choices[0].finish_reason is not None: return False diff --git a/litellm/llms/anthropic/experimental_pass_through/adapters/transformation.py b/litellm/llms/anthropic/experimental_pass_through/adapters/transformation.py index 4b6617fbeac..91efc39670e 100644 --- a/litellm/llms/anthropic/experimental_pass_through/adapters/transformation.py +++ b/litellm/llms/anthropic/experimental_pass_through/adapters/transformation.py @@ -1476,7 +1476,7 @@ class LiteLLMAnthropicMessagesAdapter: applied_edits: Optional[List[AppliedEdit]] = None, ) -> Union[ContentBlockDelta, MessageBlockDelta]: ## base case - final chunk w/ finish reason - if response.choices[0].finish_reason is not None: + if response.choices and response.choices[0].finish_reason is not None: delta = MessageDelta( stop_reason=self._translate_openai_finish_reason_to_anthropic(response.choices[0].finish_reason), ) diff --git a/litellm/responses/litellm_completion_transformation/streaming_iterator.py b/litellm/responses/litellm_completion_transformation/streaming_iterator.py index cf69654d15d..439c4715506 100644 --- a/litellm/responses/litellm_completion_transformation/streaming_iterator.py +++ b/litellm/responses/litellm_completion_transformation/streaming_iterator.py @@ -125,6 +125,8 @@ class LiteLLMCompletionStreamingIterator(ResponsesAPIStreamingIterator): return None def _is_reasoning_end(self, chunk): + if not chunk.choices: + return False delta = chunk.choices[0].delta # if this indicates reasoning content, don't consider reasoning ended @@ -722,6 +724,8 @@ class LiteLLMCompletionStreamingIterator(ResponsesAPIStreamingIterator): # Change: Never return a value, just enqueue output item events if self.sent_output_item_added_event: return + if not chunk.choices: + return delta = chunk.choices[0].delta self._sequence_number += 1 @@ -1033,6 +1037,8 @@ class LiteLLMCompletionStreamingIterator(ResponsesAPIStreamingIterator): It's unclear how users expect litellm to translate multiple-choices-per-chunk to the responses API output. """ + if not choices: + return "" choice = choices[0] chat_completion_delta: ChatCompletionDelta = choice.delta return chat_completion_delta.content or "" diff --git a/tests/test_litellm/llms/anthropic/experimental_pass_through/adapters/test_streaming_iterator_combined_chunk.py b/tests/test_litellm/llms/anthropic/experimental_pass_through/adapters/test_streaming_iterator_combined_chunk.py index 6973340101e..a6523f5a39d 100644 --- a/tests/test_litellm/llms/anthropic/experimental_pass_through/adapters/test_streaming_iterator_combined_chunk.py +++ b/tests/test_litellm/llms/anthropic/experimental_pass_through/adapters/test_streaming_iterator_combined_chunk.py @@ -143,6 +143,60 @@ def test_delayed_usage_chunk_preserves_cache_tokens(): assert message_delta["usage"]["cache_creation_input_tokens"] == 20 +def test_trailing_empty_choices_usage_chunk_emits_message_delta_usage(): + """Regression for LIT-4767. + + The trailing usage-only chunk an OpenAI-compatible provider sends when + ``include_usage`` is set has ``choices: []``. The adapter used to index + ``choices[0]`` unguarded (``is_final_chunk`` / ``_should_start_new_content_block``) + and crash with IndexError. It must instead merge the usage into the held + stop-reason chunk so ``message_delta`` still reports it. + """ + chunks = [ + ModelResponseStream( + choices=[StreamingChoices(index=0, delta=Delta(content="Two."), finish_reason=None)], + ), + ModelResponseStream( + choices=[StreamingChoices(index=0, delta=Delta(), finish_reason="stop")], + ), + ModelResponseStream( + choices=[], + usage=Usage(prompt_tokens=10, completion_tokens=5, total_tokens=15), + ), + ] + wrapper = AnthropicStreamWrapper(completion_stream=iter(chunks), model="gpt-4o") + events = list(wrapper) + + message_delta = next(event for event in events if event.get("type") == "message_delta") + assert message_delta["usage"]["input_tokens"] == 10 + assert message_delta["usage"]["output_tokens"] == 5 + + +def test_leading_empty_choices_chunk_does_not_crash_stream(): + """Azure emits a leading ``prompt_filter_results`` chunk with ``choices: []`` + before any content. It must be tolerated and the following content emitted.""" + chunks = [ + ModelResponseStream(choices=[]), + ModelResponseStream( + choices=[StreamingChoices(index=0, delta=Delta(content="Hi"), finish_reason=None)], + ), + ModelResponseStream( + choices=[StreamingChoices(index=0, delta=Delta(), finish_reason="stop")], + usage=Usage(prompt_tokens=3, completion_tokens=1, total_tokens=4), + ), + ] + + async def _aiter() -> "AsyncIterator[ModelResponseStream]": + for chunk in chunks: + yield chunk + + wrapper = AnthropicStreamWrapper(completion_stream=_aiter(), model="gpt-4o") + sse = _collect_async(wrapper) + + assert "Hi" in sse + assert "message_stop" in sse + + def test_splitter_passes_through_non_combined_chunks(): """A chunk with content but no finish_reason is not split.""" chunk = ModelResponseStream( diff --git a/tests/test_litellm/responses/litellm_completion_transformation/test_empty_choices_streaming_iterator.py b/tests/test_litellm/responses/litellm_completion_transformation/test_empty_choices_streaming_iterator.py new file mode 100644 index 00000000000..b7a3611501d --- /dev/null +++ b/tests/test_litellm/responses/litellm_completion_transformation/test_empty_choices_streaming_iterator.py @@ -0,0 +1,91 @@ +""" +Regression tests for LIT-4767. + +When an upstream OpenAI-compatible provider emits a chunk with ``choices: []`` +(the trailing usage-only chunk every provider sends when ``include_usage`` is +set, or Azure's leading ``prompt_filter_results`` chunk), the Responses bridge +iterator used to index ``choices[0]`` unguarded and die with +``IndexError: list index out of range``, killing the whole stream. + +The empty-choices chunk must be tolerated without crashing, and the usage it +carries must still reach ``response.completed``. +""" + +from unittest.mock import AsyncMock + +from litellm.responses.litellm_completion_transformation.streaming_iterator import ( + LiteLLMCompletionStreamingIterator, +) +from litellm.types.llms.openai import ResponsesAPIStreamEvents +from litellm.types.utils import ( + Delta, + ModelResponseStream, + StreamingChoices, + Usage, +) + + +def _iterator() -> LiteLLMCompletionStreamingIterator: + return LiteLLMCompletionStreamingIterator( + model="gpt-4o", + litellm_custom_stream_wrapper=AsyncMock(), + request_input="hi", + responses_api_request={}, + custom_llm_provider="openai", + ) + + +def _empty_choices_usage_chunk() -> ModelResponseStream: + chunk = ModelResponseStream(id="chunk-usage", model="gpt-4o", choices=[]) + chunk.usage = Usage(prompt_tokens=10, completion_tokens=5, total_tokens=15) + return chunk + + +def test_ensure_output_item_for_empty_choices_chunk_does_not_crash(): + """First chunk with no choices must not raise (traceback frame in the ticket).""" + iterator = _iterator() + # Would raise IndexError before the fix. + assert iterator._ensure_output_item_for_chunk(_empty_choices_usage_chunk()) is None + assert iterator.sent_output_item_added_event is False + + +def test_transform_empty_choices_chunk_returns_no_delta(): + """The mid/trailing usage chunk flows through transform without crashing.""" + iterator = _iterator() + # Would raise IndexError in _get_delta_string_from_streaming_choices before the fix. + assert iterator._transform_chat_completion_chunk_to_response_api_chunk(_empty_choices_usage_chunk()) is None + + +def test_is_reasoning_end_false_for_empty_choices_chunk(): + iterator = _iterator() + assert iterator._is_reasoning_end(_empty_choices_usage_chunk()) is False + + +def test_empty_choices_usage_chunk_still_reaches_response_completed(): + """End-to-end: a text chunk followed by a choices=[] usage chunk must emit + response.completed carrying the usage rather than dying mid-stream.""" + + class _SyncWrapper: + def __init__(self, chunks): + self._it = iter(chunks) + self.logging_obj = None + self.stream_options = {"include_usage": True} + + def __next__(self): + return next(self._it) + + text_chunk = ModelResponseStream( + id="chunk-1", + model="gpt-4o", + choices=[StreamingChoices(index=0, delta=Delta(role="assistant", content="Hi"), finish_reason=None)], + ) + iterator = _iterator() + iterator.litellm_logging_obj = None + iterator.litellm_custom_stream_wrapper = _SyncWrapper([text_chunk, _empty_choices_usage_chunk()]) + + events = list(iterator) + + completed = [e for e in events if getattr(e, "type", None) == ResponsesAPIStreamEvents.RESPONSE_COMPLETED] + assert len(completed) == 1 + assert completed[0].response.usage is not None + assert completed[0].response.usage.total_tokens == 15 From 250816eaebc650a2c16a95de429ccba610549051 Mon Sep 17 00:00:00 2001 From: Maximilian Roos Date: Mon, 27 Jul 2026 13:30:09 -0700 Subject: [PATCH 005/133] fix(http_handler): keep a handler alive while its responses are still streaming Closing an httpx client tears down the connection pool every response from that client is streaming through. litellm caches HTTP handlers for one hour, and nothing in a streaming response's reference graph points back at the handler -- httpx binds the response to the transport stream and the pool, never to the client's wrapper. So when the cache entry expires, the handler's last reference goes with it while its responses are still being read, GC runs `__del__`, and every in-flight body on that pool is cut. The clock is per process and anchored at first client construction, which is why the failures arrive in hourly bursts. Rather than guard the finalizer, make it unreachable while the client is in use. `create_client` installs one httpx `response` event hook that stamps the owning handler onto `response.extensions`, so a response keeps its handler alive; the handler is collected once its last response is gone and the existing `__del__` then closes the client exactly as it does today. Both `__del__` bodies are unchanged -- only the timing moves, so the disposal path cannot regress. The hook closes over a weak reference to the handler, never a strong one: a strong one would make the handler immortal and its client would never be closed at all. There is a test for that specific invariant. This reaches the paths a fix in `post()` cannot: the proxy passthrough routes take `.client` off the handler and stream from the raw client, so the handler is a function local that dies on return. An event hook on the client still sees those responses. Everything used is public httpx surface -- the `event_hooks` setter and `Response.extensions`. That matters because litellm's default transport is aiohttp, whose connector exposes no public way to ask whether a connection is in use, so a fix built on httpcore's pool would pass under DISABLE_AIOHTTP_TRANSPORT=true and silently do nothing by default. The regression test is parameterized over both transports. The sync twin in `HTTPHandler` has the same defect and gets the same treatment. Fixes #24929 --- litellm/llms/custom_httpx/http_handler.py | 41 +++++- .../llms/custom_httpx/test_http_handler.py | 135 ++++++++++++++++++ 2 files changed, 175 insertions(+), 1 deletion(-) diff --git a/litellm/llms/custom_httpx/http_handler.py b/litellm/llms/custom_httpx/http_handler.py index 5cec763bb5d..42f1d7b74da 100644 --- a/litellm/llms/custom_httpx/http_handler.py +++ b/litellm/llms/custom_httpx/http_handler.py @@ -6,6 +6,7 @@ import socket import ssl import sys import time +import weakref from typing import ( TYPE_CHECKING, Any, @@ -506,6 +507,41 @@ class MaskedHTTPStatusError(httpx.HTTPStatusError): self.status_code = original_error.response.status_code +_CLIENT_OWNER_EXTENSION = "litellm_client_owner" + + +def _pin_owner_on_async_client(owner: "AsyncHTTPHandler", client: httpx.AsyncClient) -> None: + """Record the handler that owns `client` on every response that client returns.""" + owner_ref = weakref.ref(owner) + existing_hooks = client.event_hooks + + async def _pin_owner(response: httpx.Response) -> None: + handler = owner_ref() + if handler is not None: + response.extensions[_CLIENT_OWNER_EXTENSION] = handler + + client.event_hooks = { + "request": list(existing_hooks.get("request", [])), + "response": [_pin_owner, *existing_hooks.get("response", [])], + } + + +def _pin_owner_on_sync_client(owner: "HTTPHandler", client: httpx.Client) -> None: + """Record the handler that owns `client` on every response that client returns.""" + owner_ref = weakref.ref(owner) + existing_hooks = client.event_hooks + + def _pin_owner(response: httpx.Response) -> None: + handler = owner_ref() + if handler is not None: + response.extensions[_CLIENT_OWNER_EXTENSION] = handler + + client.event_hooks = { + "request": list(existing_hooks.get("request", [])), + "response": [_pin_owner, *existing_hooks.get("response", [])], + } + + class AsyncHTTPHandler: def __init__( self, @@ -553,7 +589,7 @@ class AsyncHTTPHandler: # Get default headers (User-Agent, overridable via LITELLM_USER_AGENT) default_headers = get_default_headers() - return httpx.AsyncClient( + client = httpx.AsyncClient( transport=transport, event_hooks=event_hooks, timeout=timeout, @@ -562,6 +598,8 @@ class AsyncHTTPHandler: headers=default_headers, follow_redirects=True, ) + _pin_owner_on_async_client(self, client) + return client async def close(self): # Close the client when you're done with it @@ -1099,6 +1137,7 @@ class HTTPHandler: headers=default_headers, follow_redirects=True, ) + _pin_owner_on_sync_client(self, self.client) else: self.client = client diff --git a/tests/test_litellm/llms/custom_httpx/test_http_handler.py b/tests/test_litellm/llms/custom_httpx/test_http_handler.py index 87d67e0e8b7..9f9513689fa 100644 --- a/tests/test_litellm/llms/custom_httpx/test_http_handler.py +++ b/tests/test_litellm/llms/custom_httpx/test_http_handler.py @@ -1,10 +1,14 @@ import asyncio +import gc import io import os import pathlib import ssl import sys import threading +import time +import weakref +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer from unittest.mock import MagicMock, patch import certifi @@ -793,3 +797,134 @@ class TestDefaultCachedClientTimeoutHonorsRequestTimeout: litellm.in_memory_llm_clients_cache = LLMClientCache() client = get_async_httpx_client(llm_provider=LlmProviders.BEDROCK) assert client.timeout.read == 300.0 + + +class _ChunkedSSEServer: + """In-process HTTP server that answers every request with chunked SSE frames.""" + + def __init__(self, frame_count: int = 6, frame_delay: float = 0.05) -> None: + self.frame_count = frame_count + self.frame_delay = frame_delay + parent = self + + class _Handler(BaseHTTPRequestHandler): + protocol_version = "HTTP/1.1" + + def _stream(self): + self.send_response(200) + self.send_header("Content-Type", "text/event-stream") + self.send_header("Transfer-Encoding", "chunked") + self.end_headers() + try: + for index in range(parent.frame_count): + frame = f"data: frame-{index}\n\n".encode() + self.wfile.write(b"%x\r\n" % len(frame) + frame + b"\r\n") + self.wfile.flush() + time.sleep(parent.frame_delay) + self.wfile.write(b"0\r\n\r\n") + self.wfile.flush() + except (BrokenPipeError, ConnectionResetError): + pass + + do_GET = _stream + do_POST = _stream + + def log_message(self, *args): + pass + + self._server = ThreadingHTTPServer(("127.0.0.1", 0), _Handler) + self.url = f"http://127.0.0.1:{self._server.server_address[1]}/stream" + + def __enter__(self): + threading.Thread(target=self._server.serve_forever, daemon=True).start() + return self + + def __exit__(self, *exc_info): + self._server.shutdown() + self._server.server_close() + + +class TestHandlerCollectionDoesNotAbortInFlightStreams: + """Regression guard for https://github.com/BerriAI/litellm/issues/24929""" + + @pytest.mark.asyncio + @pytest.mark.parametrize("disable_aiohttp_transport", [False, True]) + async def test_async_stream_survives_handler_collection(self, monkeypatch, disable_aiohttp_transport): + monkeypatch.delenv("DISABLE_AIOHTTP_TRANSPORT", raising=False) + monkeypatch.setattr(litellm, "disable_aiohttp_transport", disable_aiohttp_transport) + monkeypatch.setattr(litellm, "force_ipv4", False) + + with _ChunkedSSEServer() as server: + handler = AsyncHTTPHandler(timeout=httpx.Timeout(10.0, connect=5.0)) + client = handler.client + response = await client.send(client.build_request("GET", server.url), stream=True) + del handler + + async def read_frames(body: httpx.Response) -> int: + total = 0 + async for chunk in body.aiter_bytes(): + total += chunk.count(b"data: frame-") + gc.collect() + return total + + frames = await asyncio.wait_for(read_frames(response), timeout=20) + + assert frames == 6 + assert client.is_closed is False + + del response + gc.collect() + for _ in range(200): + if client.is_closed: + break + await asyncio.sleep(0.01) + assert client.is_closed is True + + def test_sync_stream_survives_handler_collection(self, monkeypatch): + monkeypatch.setattr(litellm, "force_ipv4", False) + + with _ChunkedSSEServer() as server: + handler = HTTPHandler(timeout=httpx.Timeout(10.0, connect=5.0)) + client = handler.client + response = client.send(client.build_request("GET", server.url), stream=True) + del handler + + frames = 0 + for chunk in response.iter_bytes(): + frames += chunk.count(b"data: frame-") + gc.collect() + + assert frames == 6 + assert client.is_closed is False + + del response + gc.collect() + assert client.is_closed is True + + def test_client_does_not_keep_its_handler_alive(self, monkeypatch): + monkeypatch.setattr(litellm, "disable_aiohttp_transport", True) + handler = AsyncHTTPHandler() + client = handler.client + handler_ref = weakref.ref(handler) + + del handler + gc.collect() + + assert handler_ref() is None + assert isinstance(client, httpx.AsyncClient) + + @pytest.mark.asyncio + async def test_caller_supplied_event_hooks_still_fire(self): + seen = [] + + async def user_response_hook(response: httpx.Response) -> None: + seen.append(response.status_code) + + with _ChunkedSSEServer(frame_count=1, frame_delay=0.0) as server: + handler = AsyncHTTPHandler(event_hooks={"request": [], "response": [user_response_hook]}) + try: + response = await handler.client.get(server.url) + assert response.status_code == 200 + assert seen == [200] + finally: + await handler.close() From 99ae409fd308b0de1a42b74e705d88ea9c23bfce Mon Sep 17 00:00:00 2001 From: Maximilian Roos Date: Tue, 28 Jul 2026 09:18:38 -0700 Subject: [PATCH 006/133] Revert "fix(http_handler): keep a handler alive while its responses are still streaming" This reverts commit 250816eaebc650a2c16a95de429ccba610549051. That approach kept both `__del__` finalizers and made them unreachable while a response was live, by stamping the owning handler onto `response.extensions` from a client event hook. It only covers the shapes where a response is in flight. A caller that keeps just `handler.client`, and a throwaway handler wrapped around a client someone else owns, have no response to pin the handler to, so the client is still closed underneath them. The following commit deletes the finalizers instead, which covers those shapes and removes code rather than adding it. --- litellm/llms/custom_httpx/http_handler.py | 41 +----- .../llms/custom_httpx/test_http_handler.py | 135 ------------------ 2 files changed, 1 insertion(+), 175 deletions(-) diff --git a/litellm/llms/custom_httpx/http_handler.py b/litellm/llms/custom_httpx/http_handler.py index 42f1d7b74da..5cec763bb5d 100644 --- a/litellm/llms/custom_httpx/http_handler.py +++ b/litellm/llms/custom_httpx/http_handler.py @@ -6,7 +6,6 @@ import socket import ssl import sys import time -import weakref from typing import ( TYPE_CHECKING, Any, @@ -507,41 +506,6 @@ class MaskedHTTPStatusError(httpx.HTTPStatusError): self.status_code = original_error.response.status_code -_CLIENT_OWNER_EXTENSION = "litellm_client_owner" - - -def _pin_owner_on_async_client(owner: "AsyncHTTPHandler", client: httpx.AsyncClient) -> None: - """Record the handler that owns `client` on every response that client returns.""" - owner_ref = weakref.ref(owner) - existing_hooks = client.event_hooks - - async def _pin_owner(response: httpx.Response) -> None: - handler = owner_ref() - if handler is not None: - response.extensions[_CLIENT_OWNER_EXTENSION] = handler - - client.event_hooks = { - "request": list(existing_hooks.get("request", [])), - "response": [_pin_owner, *existing_hooks.get("response", [])], - } - - -def _pin_owner_on_sync_client(owner: "HTTPHandler", client: httpx.Client) -> None: - """Record the handler that owns `client` on every response that client returns.""" - owner_ref = weakref.ref(owner) - existing_hooks = client.event_hooks - - def _pin_owner(response: httpx.Response) -> None: - handler = owner_ref() - if handler is not None: - response.extensions[_CLIENT_OWNER_EXTENSION] = handler - - client.event_hooks = { - "request": list(existing_hooks.get("request", [])), - "response": [_pin_owner, *existing_hooks.get("response", [])], - } - - class AsyncHTTPHandler: def __init__( self, @@ -589,7 +553,7 @@ class AsyncHTTPHandler: # Get default headers (User-Agent, overridable via LITELLM_USER_AGENT) default_headers = get_default_headers() - client = httpx.AsyncClient( + return httpx.AsyncClient( transport=transport, event_hooks=event_hooks, timeout=timeout, @@ -598,8 +562,6 @@ class AsyncHTTPHandler: headers=default_headers, follow_redirects=True, ) - _pin_owner_on_async_client(self, client) - return client async def close(self): # Close the client when you're done with it @@ -1137,7 +1099,6 @@ class HTTPHandler: headers=default_headers, follow_redirects=True, ) - _pin_owner_on_sync_client(self, self.client) else: self.client = client diff --git a/tests/test_litellm/llms/custom_httpx/test_http_handler.py b/tests/test_litellm/llms/custom_httpx/test_http_handler.py index 9f9513689fa..87d67e0e8b7 100644 --- a/tests/test_litellm/llms/custom_httpx/test_http_handler.py +++ b/tests/test_litellm/llms/custom_httpx/test_http_handler.py @@ -1,14 +1,10 @@ import asyncio -import gc import io import os import pathlib import ssl import sys import threading -import time -import weakref -from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer from unittest.mock import MagicMock, patch import certifi @@ -797,134 +793,3 @@ class TestDefaultCachedClientTimeoutHonorsRequestTimeout: litellm.in_memory_llm_clients_cache = LLMClientCache() client = get_async_httpx_client(llm_provider=LlmProviders.BEDROCK) assert client.timeout.read == 300.0 - - -class _ChunkedSSEServer: - """In-process HTTP server that answers every request with chunked SSE frames.""" - - def __init__(self, frame_count: int = 6, frame_delay: float = 0.05) -> None: - self.frame_count = frame_count - self.frame_delay = frame_delay - parent = self - - class _Handler(BaseHTTPRequestHandler): - protocol_version = "HTTP/1.1" - - def _stream(self): - self.send_response(200) - self.send_header("Content-Type", "text/event-stream") - self.send_header("Transfer-Encoding", "chunked") - self.end_headers() - try: - for index in range(parent.frame_count): - frame = f"data: frame-{index}\n\n".encode() - self.wfile.write(b"%x\r\n" % len(frame) + frame + b"\r\n") - self.wfile.flush() - time.sleep(parent.frame_delay) - self.wfile.write(b"0\r\n\r\n") - self.wfile.flush() - except (BrokenPipeError, ConnectionResetError): - pass - - do_GET = _stream - do_POST = _stream - - def log_message(self, *args): - pass - - self._server = ThreadingHTTPServer(("127.0.0.1", 0), _Handler) - self.url = f"http://127.0.0.1:{self._server.server_address[1]}/stream" - - def __enter__(self): - threading.Thread(target=self._server.serve_forever, daemon=True).start() - return self - - def __exit__(self, *exc_info): - self._server.shutdown() - self._server.server_close() - - -class TestHandlerCollectionDoesNotAbortInFlightStreams: - """Regression guard for https://github.com/BerriAI/litellm/issues/24929""" - - @pytest.mark.asyncio - @pytest.mark.parametrize("disable_aiohttp_transport", [False, True]) - async def test_async_stream_survives_handler_collection(self, monkeypatch, disable_aiohttp_transport): - monkeypatch.delenv("DISABLE_AIOHTTP_TRANSPORT", raising=False) - monkeypatch.setattr(litellm, "disable_aiohttp_transport", disable_aiohttp_transport) - monkeypatch.setattr(litellm, "force_ipv4", False) - - with _ChunkedSSEServer() as server: - handler = AsyncHTTPHandler(timeout=httpx.Timeout(10.0, connect=5.0)) - client = handler.client - response = await client.send(client.build_request("GET", server.url), stream=True) - del handler - - async def read_frames(body: httpx.Response) -> int: - total = 0 - async for chunk in body.aiter_bytes(): - total += chunk.count(b"data: frame-") - gc.collect() - return total - - frames = await asyncio.wait_for(read_frames(response), timeout=20) - - assert frames == 6 - assert client.is_closed is False - - del response - gc.collect() - for _ in range(200): - if client.is_closed: - break - await asyncio.sleep(0.01) - assert client.is_closed is True - - def test_sync_stream_survives_handler_collection(self, monkeypatch): - monkeypatch.setattr(litellm, "force_ipv4", False) - - with _ChunkedSSEServer() as server: - handler = HTTPHandler(timeout=httpx.Timeout(10.0, connect=5.0)) - client = handler.client - response = client.send(client.build_request("GET", server.url), stream=True) - del handler - - frames = 0 - for chunk in response.iter_bytes(): - frames += chunk.count(b"data: frame-") - gc.collect() - - assert frames == 6 - assert client.is_closed is False - - del response - gc.collect() - assert client.is_closed is True - - def test_client_does_not_keep_its_handler_alive(self, monkeypatch): - monkeypatch.setattr(litellm, "disable_aiohttp_transport", True) - handler = AsyncHTTPHandler() - client = handler.client - handler_ref = weakref.ref(handler) - - del handler - gc.collect() - - assert handler_ref() is None - assert isinstance(client, httpx.AsyncClient) - - @pytest.mark.asyncio - async def test_caller_supplied_event_hooks_still_fire(self): - seen = [] - - async def user_response_hook(response: httpx.Response) -> None: - seen.append(response.status_code) - - with _ChunkedSSEServer(frame_count=1, frame_delay=0.0) as server: - handler = AsyncHTTPHandler(event_hooks={"request": [], "response": [user_response_hook]}) - try: - response = await handler.client.get(server.url) - assert response.status_code == 200 - assert seen == [200] - finally: - await handler.close() From 3f00e6fa46eefd22e2cebb1fa4c8f63c8863d412 Mon Sep 17 00:00:00 2001 From: Maximilian Roos Date: Tue, 28 Jul 2026 09:18:51 -0700 Subject: [PATCH 007/133] fix(http_handler): don't close the httpx client when a handler is collected `AsyncHTTPHandler` and `HTTPHandler` each closed their httpx client from `__del__`. Closing an httpx client tears down the connection pool every in-flight response is streaming through, and it permanently invalidates the client for later requests. Neither is safe on garbage collection, because a handler is routinely collected while its client is still in use: - Streaming. Nothing in a response's reference graph points back at the handler: httpx binds the response to the transport stream and the pool, never to the wrapper that built them. A cached handler therefore becomes collectable mid-stream when its one-hour TTL expires, and every body still being read off that pool is cut. That is #24929; the window recurs hourly per process, because the TTL clock starts at first client construction. - Borrowed clients. `litellm/a2a_protocol/main.py` and `litellm/proxy/pass_through_endpoints/pass_through_endpoints.py` keep only `handler.client`. Both take the handler from `get_async_httpx_client`, so the cache pins it for the same one-hour TTL and then lets it go on eviction, while the borrowed client is still serving a longer-lived consumer: `create_a2a_client` hands its client to the a2a SDK and documents it as "create client once, reuse for multiple requests". The next request on that client raises `RuntimeError: Cannot send a request, as the client has been closed.` - Someone else's client. `litellm/llms/azure/azure.py` wraps `litellm.client_session` in a throwaway `HTTPHandler` for a single image generation, and `HTTPHandler.close()` closes whatever client it was handed. One such call left the user's shared session closed for the rest of the process. `LLMClientCache` already documents the invariant these finalizers broke: evicted clients "may still be in use by in-flight requests", so the cache deliberately does not close them and leaves them to normal garbage collection. Closing on collection is precisely what that rules out. Deleting both finalizers removes the cause instead of narrowing the window. The async one was never dependable regardless: it needs a running event loop, so collection outside one silently did nothing. Explicit `close()`/`aclose()` is untouched, and `close_litellm_async_clients()` still closes cached async clients at exit. The cost is that an evicted client is no longer closed eagerly. On the default aiohttp transport that shows up as five lines of asyncio ERROR output per evicted handler ("Unclosed client session", "Unclosed connector", and their context keys) with no change in descriptors or RSS, since aiohttp's own connector finalizer closes the connections as it warns. On the httpcore transport there is no log output, but idle keep-alive sockets from evicted pools are held until a generation-2 collection reclaims them: over 5000 evictions that plateaus at 56 descriptors and ~1 MB of RSS, and with 20 concurrent requests per pool it peaks around 650 descriptors before each gen-2 pass returns it to baseline. Bounded by the GC cycle, not by the eviction count. Fixes #24929 --- litellm/llms/custom_httpx/http_handler.py | 12 - .../test_handler_gc_does_not_close_client.py | 284 ++++++++++++++++++ 2 files changed, 284 insertions(+), 12 deletions(-) create mode 100644 tests/test_litellm/llms/custom_httpx/test_handler_gc_does_not_close_client.py diff --git a/litellm/llms/custom_httpx/http_handler.py b/litellm/llms/custom_httpx/http_handler.py index 5cec763bb5d..d510432b3b4 100644 --- a/litellm/llms/custom_httpx/http_handler.py +++ b/litellm/llms/custom_httpx/http_handler.py @@ -879,12 +879,6 @@ class AsyncHTTPHandler: response.raise_for_status() return response - def __del__(self) -> None: - try: - asyncio.get_running_loop().create_task(self.close()) - except Exception: - pass - @staticmethod def _create_async_transport( ssl_context: Optional[ssl.SSLContext] = None, @@ -1345,12 +1339,6 @@ class HTTPHandler: except Exception as e: raise e - def __del__(self) -> None: - try: - self.close() - except Exception: - pass - def _create_sync_transport(self) -> Optional[HTTPTransport]: """ Create an HTTP transport with IPv4 only if litellm.force_ipv4 is True. diff --git a/tests/test_litellm/llms/custom_httpx/test_handler_gc_does_not_close_client.py b/tests/test_litellm/llms/custom_httpx/test_handler_gc_does_not_close_client.py new file mode 100644 index 00000000000..8c95691a9c9 --- /dev/null +++ b/tests/test_litellm/llms/custom_httpx/test_handler_gc_does_not_close_client.py @@ -0,0 +1,284 @@ +""" +Garbage-collecting an HTTP handler must not close the httpx client it holds. + +``HTTPHandler`` and ``AsyncHTTPHandler`` used to close their client from +``__del__``. Closing an httpx client tears down the connection pool that every +in-flight response is streaming through, and it permanently invalidates the +client for future requests -- including for callers who only ever borrowed +``handler.client``. Since nothing in a response's reference graph points back +at the handler, and since litellm caches handlers behind a one-hour TTL, the +handler routinely became collectable while its client was still in use. + +``LLMClientCache`` documents the invariant this broke: evicted clients "may +still be in use by in-flight requests", so they are left to normal garbage +collection rather than closed eagerly. A finalizer that closes on collection +defeats exactly that. + +Each test below is one shape the finalizers broke; all of them fail if either +``__del__`` comes back. Async cases run on both transports, because litellm +defaults to aiohttp and only uses httpcore when aiohttp is disabled. + +Unlike the rest of ``tests/test_litellm/``, these tests need a real connection +pool rather than a mock: a mocked transport goes on yielding chunks after its +client is closed, so the very teardown under test is what a mock cannot +reproduce. The server here is the hermetic, credential-free +``ThreadingHTTPServer`` on an ephemeral loopback port already used by +``tests/test_litellm/llms/anthropic/chat/test_anthropic_chat_handler.py``. + +Related: https://github.com/BerriAI/litellm/issues/24929 +""" + +import asyncio +import gc +import os +import sys +import threading +import time +import weakref +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer + +import httpx +import pytest + +sys.path.insert(0, os.path.abspath("../../../..")) + +import litellm +from litellm.caching.llm_caching_handler import LLMClientCache +from litellm.llms.custom_httpx.http_handler import ( + AsyncHTTPHandler, + HTTPHandler, + get_async_httpx_client, +) +from litellm.types.utils import LlmProviders + +FRAME_COUNT = 6 +# Generous: the server emits all frames in ~0.3s. A client whose pool was torn +# down mid-stream can stall silently instead of raising, so reads are bounded. +READ_TIMEOUT_SECONDS = 15.0 + +BOTH_TRANSPORTS = pytest.mark.parametrize("disable_aiohttp_transport", [False, True], ids=["aiohttp", "httpcore"]) + +# Every test here rests on the handler actually being collected at the ``del``. +# If something ever pins it, the test would pass while guarding nothing, so each +# one checks the premise. The check comes last: a reintroduced finalizer also +# fails it, by resurrecting the handler into the task it creates for ``close()``, +# and the transport error is the more useful thing to see first. +HANDLER_NOT_COLLECTED = "handler was not collected; this test no longer exercises the finalizer path" + + +class _ChunkedSSEServer: + """In-process HTTP/1.1 server that answers every request with chunked SSE frames.""" + + def __init__(self, frame_count: int = FRAME_COUNT, frame_delay: float = 0.05) -> None: + self.frame_count = frame_count + self.frame_delay = frame_delay + parent = self + + class _Handler(BaseHTTPRequestHandler): + protocol_version = "HTTP/1.1" + + def _stream(self): + self.send_response(200) + self.send_header("Content-Type", "text/event-stream") + self.send_header("Transfer-Encoding", "chunked") + self.end_headers() + try: + for index in range(parent.frame_count): + frame = f"data: frame-{index}\n\n".encode() + self.wfile.write(b"%x\r\n" % len(frame) + frame + b"\r\n") + self.wfile.flush() + time.sleep(parent.frame_delay) + self.wfile.write(b"0\r\n\r\n") + self.wfile.flush() + except (BrokenPipeError, ConnectionResetError): + pass + + do_GET = _stream + do_POST = _stream + + def log_message(self, *args): + pass + + self._server = ThreadingHTTPServer(("127.0.0.1", 0), _Handler) + self.url = f"http://127.0.0.1:{self._server.server_address[1]}/stream" + + def __enter__(self): + threading.Thread(target=self._server.serve_forever, daemon=True).start() + return self + + def __exit__(self, *exc_info): + self._server.shutdown() + self._server.server_close() + + +def _select_transport(monkeypatch, disable_aiohttp_transport: bool) -> None: + monkeypatch.delenv("DISABLE_AIOHTTP_TRANSPORT", raising=False) + monkeypatch.setattr(litellm, "disable_aiohttp_transport", disable_aiohttp_transport) + monkeypatch.setattr(litellm, "force_ipv4", False) + + +async def _read_frames(response: httpx.Response) -> int: + """Count SSE frames, collecting garbage between chunks so a finalizer has every chance to fire. + + The body is joined before counting: a chunk boundary can fall inside the + marker, which a per-chunk count would miss. + """ + chunks = [] + async for chunk in response.aiter_bytes(): + chunks.append(chunk) + gc.collect() + return b"".join(chunks).count(b"data: frame-") + + +@pytest.mark.asyncio +@BOTH_TRANSPORTS +async def test_async_stream_survives_handler_collection(monkeypatch, disable_aiohttp_transport): + """A response being streamed keeps working after its handler is collected.""" + _select_transport(monkeypatch, disable_aiohttp_transport) + + with _ChunkedSSEServer() as server: + handler = AsyncHTTPHandler(timeout=httpx.Timeout(10.0, connect=5.0)) + client = handler.client + try: + response = await client.send(client.build_request("GET", server.url), stream=True) + + # The handler loses its last reference while the body is still streaming. + ref = weakref.ref(handler) + del handler + gc.collect() + + frames = await asyncio.wait_for(_read_frames(response), timeout=READ_TIMEOUT_SECONDS) + assert frames == FRAME_COUNT + assert client.is_closed is False + assert ref() is None, HANDLER_NOT_COLLECTED + finally: + await client.aclose() + + +@pytest.mark.asyncio +@BOTH_TRANSPORTS +async def test_borrowed_async_client_outlives_its_handler(monkeypatch, disable_aiohttp_transport): + """A caller that keeps only ``handler.client`` can still send requests once the handler is gone. + + This is the shape at litellm/a2a_protocol/main.py (``httpx_client = + _async_handler.client``, handed to the a2a SDK) and at + litellm/proxy/pass_through_endpoints/pass_through_endpoints.py (``async_client + = async_client_obj.client``). Both take the handler from + ``get_async_httpx_client``, so the cache pins it for + ``_DEFAULT_TTL_FOR_HTTPX_CLIENTS`` (one hour) and then lets it go on eviction, + at which point it is collected while the borrowed client is still serving a + longer-lived consumer: ``create_a2a_client`` hands its client to the a2a SDK + and documents it as "create client once, reuse for multiple requests". + """ + _select_transport(monkeypatch, disable_aiohttp_transport) + + with _ChunkedSSEServer(frame_count=1, frame_delay=0.0) as server: + handler = AsyncHTTPHandler(timeout=httpx.Timeout(10.0, connect=5.0)) + client = handler.client + try: + ref = weakref.ref(handler) + del handler + gc.collect() + # A finalizer would close the client from a task, so let the loop turn. + await asyncio.sleep(0.05) + + assert client.is_closed is False + response = await client.get(server.url) + assert response.status_code == 200 + assert ref() is None, HANDLER_NOT_COLLECTED + finally: + await client.aclose() + + +def test_sync_handler_collection_does_not_close_a_caller_owned_client(monkeypatch): + """A throwaway handler wrapped around someone else's client must not close it. + + litellm/llms/azure/azure.py builds ``HTTPHandler(client=litellm.client_session)`` + for a single image generation and drops it. With a finalizer, that one call + left the user's shared session closed for the rest of the process. + """ + monkeypatch.setattr(litellm, "force_ipv4", False) + + with _ChunkedSSEServer(frame_count=1, frame_delay=0.0) as server: + caller_client = httpx.Client(timeout=httpx.Timeout(10.0, connect=5.0)) + monkeypatch.setattr(litellm, "client_session", caller_client) + try: + handler = HTTPHandler(client=litellm.client_session) + ref = weakref.ref(handler) + del handler + gc.collect() + + assert caller_client.is_closed is False + assert caller_client.get(server.url).status_code == 200 + assert ref() is None, HANDLER_NOT_COLLECTED + finally: + caller_client.close() + + +def test_sync_stream_survives_handler_collection(monkeypatch): + """A sync response being streamed keeps working after its handler is collected. + + litellm/main.py builds a sync handler only for non-streaming calls, commented + "Keep this here, otherwise, the httpx.client closes and streaming is + impossible" -- a workaround for this finalizer rather than a fix for it. + """ + monkeypatch.setattr(litellm, "force_ipv4", False) + + with _ChunkedSSEServer() as server: + handler = HTTPHandler(timeout=httpx.Timeout(10.0, connect=5.0)) + client = handler.client + try: + response = client.send(client.build_request("GET", server.url), stream=True) + + # The handler loses its last reference while the body is still streaming. + ref = weakref.ref(handler) + del handler + gc.collect() + + # Joined before counting, as in ``_read_frames``. + chunks = [] + for chunk in response.iter_bytes(): + chunks.append(chunk) + gc.collect() + + assert b"".join(chunks).count(b"data: frame-") == FRAME_COUNT + assert client.is_closed is False + assert ref() is None, HANDLER_NOT_COLLECTED + finally: + client.close() + + +@pytest.mark.asyncio +@BOTH_TRANSPORTS +async def test_cached_handler_eviction_does_not_abort_an_in_flight_stream(monkeypatch, disable_aiohttp_transport): + """Evicting a cached handler mid-stream leaves the stream alone. + + ``get_async_httpx_client`` caches handlers for an hour. When that TTL + expires the cache drops the only reference to a handler whose client is + still streaming -- the production shape of #24929, and the case + ``LLMClientCache`` documents as "may still be in use by in-flight requests". + """ + _select_transport(monkeypatch, disable_aiohttp_transport) + monkeypatch.setattr(litellm, "in_memory_llm_clients_cache", LLMClientCache()) + + with _ChunkedSSEServer() as server: + handler = get_async_httpx_client(llm_provider=LlmProviders.OPENAI) + client = handler.client + try: + response = await client.send(client.build_request("GET", server.url), stream=True) + + # An hour passes: the TTL expires and the cache lets the handler go. + ref = weakref.ref(handler) + litellm.in_memory_llm_clients_cache.flush_cache() + del handler + gc.collect() + + frames = await asyncio.wait_for(_read_frames(response), timeout=READ_TIMEOUT_SECONDS) + assert frames == FRAME_COUNT + assert client.is_closed is False + + # And the evicted client is still usable for the next request. + assert (await client.get(server.url)).status_code == 200 + assert ref() is None, HANDLER_NOT_COLLECTED + finally: + await client.aclose() From c44f62abb50218477c73b72cc2493dc110eb712c Mon Sep 17 00:00:00 2001 From: Maximilian Roos Date: Tue, 28 Jul 2026 19:08:06 -0700 Subject: [PATCH 008/133] test(http_handler): move the GC lifecycle tests to tests/local_testing tests/test_litellm/readme.md states that directory can only contain mocked tests. These need a real connection pool, because a mocked transport goes on yielding chunks after its client is closed, which is the teardown under test. tests/local_testing is the tree for tests that open a real socket, and CircleCI's local_testing_part1 job picks the file up by its name. No change to the tests themselves beyond the sys.path depth and a docstring line naming the new location. --- .../test_handler_gc_does_not_close_client.py | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) rename tests/{test_litellm/llms/custom_httpx => local_testing}/test_handler_gc_does_not_close_client.py (96%) diff --git a/tests/test_litellm/llms/custom_httpx/test_handler_gc_does_not_close_client.py b/tests/local_testing/test_handler_gc_does_not_close_client.py similarity index 96% rename from tests/test_litellm/llms/custom_httpx/test_handler_gc_does_not_close_client.py rename to tests/local_testing/test_handler_gc_does_not_close_client.py index 8c95691a9c9..3874405a261 100644 --- a/tests/test_litellm/llms/custom_httpx/test_handler_gc_does_not_close_client.py +++ b/tests/local_testing/test_handler_gc_does_not_close_client.py @@ -18,12 +18,11 @@ Each test below is one shape the finalizers broke; all of them fail if either ``__del__`` comes back. Async cases run on both transports, because litellm defaults to aiohttp and only uses httpcore when aiohttp is disabled. -Unlike the rest of ``tests/test_litellm/``, these tests need a real connection -pool rather than a mock: a mocked transport goes on yielding chunks after its +These live here rather than under ``tests/test_litellm/`` because they need a +real connection pool: a mocked transport goes on yielding chunks after its client is closed, so the very teardown under test is what a mock cannot -reproduce. The server here is the hermetic, credential-free -``ThreadingHTTPServer`` on an ephemeral loopback port already used by -``tests/test_litellm/llms/anthropic/chat/test_anthropic_chat_handler.py``. +reproduce. The server is a hermetic, credential-free ``ThreadingHTTPServer`` +on an ephemeral loopback port, and needs no network access beyond it. Related: https://github.com/BerriAI/litellm/issues/24929 """ @@ -40,7 +39,7 @@ from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer import httpx import pytest -sys.path.insert(0, os.path.abspath("../../../..")) +sys.path.insert(0, os.path.abspath("../..")) import litellm from litellm.caching.llm_caching_handler import LLMClientCache From d2c574608ad430bcc5f2f9bd00fd603a0c3aacbd Mon Sep 17 00:00:00 2001 From: milan Date: Tue, 25 Aug 2026 14:56:22 +0000 Subject: [PATCH 009/133] test(rag): validate retrieval filters at HTTP boundary Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- litellm/types/rag.py | 13 +- tests/test_litellm/rag/test_main.py | 176 ++++++++++++---------------- 2 files changed, 82 insertions(+), 107 deletions(-) diff --git a/litellm/types/rag.py b/litellm/types/rag.py index bed3fdfa7e9..629979afde9 100644 --- a/litellm/types/rag.py +++ b/litellm/types/rag.py @@ -2,10 +2,11 @@ Type definitions for RAG (Retrieval Augmented Generation) Ingest API. """ +from collections.abc import Mapping from typing import Any, Literal from pydantic import BaseModel, ConfigDict -from typing_extensions import TypedDict +from typing_extensions import ReadOnly, TypedDict from litellm.types.utils import ModelResponse @@ -237,11 +238,11 @@ class RAGIngestRequest(BaseModel): class RAGRetrievalConfig(TypedDict, total=False): """Configuration for vector store retrieval.""" - vector_store_id: str - custom_llm_provider: str - top_k: int # max results from vector store - filters: dict[str, Any] | None # optional - vector store filters - retrieval_filter: dict[str, Any] | None # optional - alias forwarded as vector store filters + vector_store_id: ReadOnly[str] + custom_llm_provider: ReadOnly[str] + top_k: ReadOnly[int] + filters: ReadOnly[Mapping[str, object] | None] + retrieval_filter: ReadOnly[Mapping[str, object] | None] class RAGRerankConfig(TypedDict, total=False): diff --git a/tests/test_litellm/rag/test_main.py b/tests/test_litellm/rag/test_main.py index 796f8c38750..ab0a9c0f002 100644 --- a/tests/test_litellm/rag/test_main.py +++ b/tests/test_litellm/rag/test_main.py @@ -11,9 +11,13 @@ aquery carries the completion response with real usage and cost. """ import asyncio -from unittest.mock import AsyncMock, patch +import json +from typing import Final +from unittest.mock import patch +import httpx import pytest +import respx import litellm from litellm._internal_context import is_internal_call @@ -255,113 +259,83 @@ async def test_aquery_streaming_bills_sub_call_costs_into_final_event(): @pytest.mark.asyncio -@pytest.mark.parametrize("filter_key", ["retrieval_filter", "filters"]) -async def test_aquery_forwards_retrieval_filter_to_vector_store_search(filter_key): - """ - The retrieval_config filter (AWS Bedrock KB metadata filter) must reach the - vector store search call. Before the fix it was dropped, so Bedrock ran an - unfiltered Retrieve and returned documents from the wrong metadata partition. - Both the customer-facing `retrieval_filter` key and the typed `filters` alias - must be forwarded as the search `filters` argument. - """ - from litellm.types.vector_stores import VectorStoreSearchResponse +@pytest.mark.parametrize( + ("retrieval_config_json", "top_level_filter_json", "expected_filter_json"), + ( + ( + '{"vector_store_id":"vs_test_123","custom_llm_provider":"openai","top_k":50,' + '"retrieval_filter":{"equals":{"key":"tenant","value":"retrieval"}}}', + None, + '{"equals":{"key":"tenant","value":"retrieval"}}', + ), + ( + '{"vector_store_id":"vs_test_123","custom_llm_provider":"openai","top_k":50,' + '"filters":{"equals":{"key":"tenant","value":"alias"}}}', + None, + '{"equals":{"key":"tenant","value":"alias"}}', + ), + ( + '{"vector_store_id":"vs_test_123","custom_llm_provider":"openai","top_k":50}', + '{"equals":{"key":"tenant","value":"top-level"}}', + '{"equals":{"key":"tenant","value":"top-level"}}', + ), + ( + '{"vector_store_id":"vs_test_123","custom_llm_provider":"openai","top_k":50,' + '"retrieval_filter":{"equals":{"key":"tenant","value":"retrieval"}},' + '"filters":{"equals":{"key":"tenant","value":"alias"}}}', + '{"equals":{"key":"tenant","value":"top-level"}}', + '{"equals":{"key":"tenant","value":"retrieval"}}', + ), + ( + '{"vector_store_id":"vs_test_123","custom_llm_provider":"openai","top_k":50}', + None, + None, + ), + ), +) +async def test_aquery_forwards_filters_to_vector_store_search( + retrieval_config_json: str, + top_level_filter_json: str | None, + expected_filter_json: str | None, + monkeypatch, +): + monkeypatch.setattr(litellm, "disable_aiohttp_transport", True) + retrieval_config: Final = json.loads(retrieval_config_json) + top_level_filter: Final = json.loads(top_level_filter_json) if top_level_filter_json is not None else None + expected_filter: Final = json.loads(expected_filter_json) if expected_filter_json is not None else None - retrieval_filter = { - "andAll": [ - {"equals": {"key": "Technology", "value": "Blade"}}, - {"equals": {"key": "Parameter", "value": "Nicotine"}}, - ] - } - - fake_search = AsyncMock( - return_value=VectorStoreSearchResponse( - object="vector_store.search_results.page", - search_query="q", - data=[], + with respx.mock(assert_all_called=True) as respx_mock: + search_route: Final = respx_mock.post("https://example.com/v1/vector_stores/vs_test_123/search").mock( + return_value=httpx.Response( + 200, + content='{"object":"vector_store.search_results.page","search_query":"q","data":[]}', + ) ) - ) - - with patch("litellm.vector_stores.asearch", new=fake_search): - response = await litellm.aquery( - model="gpt-4o-mini", - messages=[{"role": "user", "content": "most frequent causes of low nicotine"}], - retrieval_config={ - "vector_store_id": "CBVFYF3MYF", - "custom_llm_provider": "bedrock", - "top_k": 50, - filter_key: retrieval_filter, - }, - mock_response="answer", + respx_mock.post("https://example.com/v1/chat/completions").mock( + return_value=httpx.Response( + 200, + content=( + '{"id":"chatcmpl-test","object":"chat.completion","created":1,"model":"gpt-4o-mini",' + '"choices":[{"index":0,"message":{"role":"assistant","content":"answer"},"finish_reason":"stop"}],' + '"usage":{"prompt_tokens":1,"completion_tokens":1,"total_tokens":2}}' + ), + ) ) - - assert isinstance(response, ModelResponse) - fake_search.assert_awaited_once() - assert fake_search.await_args.kwargs["filters"] == retrieval_filter - assert fake_search.await_args.kwargs["vector_store_id"] == "CBVFYF3MYF" - assert fake_search.await_args.kwargs["max_num_results"] == 50 - - -@pytest.mark.asyncio -async def test_aquery_top_level_filters_kwarg_does_not_collide(): - """ - An SDK caller may pass a top-level `filters` kwarg (it used to flow to the - search via **kwargs). Now that the pipeline passes `filters` explicitly, the - top-level kwarg must be consumed rather than forwarded twice, otherwise - asearch raises TypeError for a duplicate keyword before any search runs. - """ - from litellm.types.vector_stores import VectorStoreSearchResponse - - top_level_filter = {"equals": {"key": "tenant", "value": "a"}} - - fake_search = AsyncMock( - return_value=VectorStoreSearchResponse( - object="vector_store.search_results.page", - search_query="q", - data=[], - ) - ) - - with patch("litellm.vector_stores.asearch", new=fake_search): - response = await litellm.aquery( - model="gpt-4o-mini", - messages=[{"role": "user", "content": "hello"}], - retrieval_config={"vector_store_id": "vs_test_123", "custom_llm_provider": "openai"}, + response: Final = await litellm.aquery( + model="openai/gpt-4o-mini", + messages=json.loads('[{"role":"user","content":"most frequent causes of low nicotine"}]'), + retrieval_config=retrieval_config, filters=top_level_filter, - mock_response="hi", + api_key="sk-test", + api_base="https://example.com/v1", ) + request_body: Final = json.loads(search_route.calls.last.request.content) assert isinstance(response, ModelResponse) - fake_search.assert_awaited_once() - assert fake_search.await_args.kwargs["filters"] == top_level_filter - assert "filters" not in fake_search.await_args.kwargs.get("kwargs", {}) - - -@pytest.mark.asyncio -async def test_aquery_without_filter_forwards_none(): - """ - When no filter is provided, the search call must receive filters=None rather - than a truthy default that would silently constrain an unfiltered query. - """ - from litellm.types.vector_stores import VectorStoreSearchResponse - - fake_search = AsyncMock( - return_value=VectorStoreSearchResponse( - object="vector_store.search_results.page", - search_query="q", - data=[], - ) - ) - - with patch("litellm.vector_stores.asearch", new=fake_search): - await litellm.aquery( - model="gpt-4o-mini", - messages=[{"role": "user", "content": "hello"}], - retrieval_config={"vector_store_id": "vs_test_123", "custom_llm_provider": "openai"}, - mock_response="hi", - ) - - fake_search.assert_awaited_once() - assert fake_search.await_args.kwargs["filters"] is None + assert response.choices[0].message.content == "answer" + assert request_body["query"] == "most frequent causes of low nicotine" + assert request_body["filters"] == expected_filter + assert request_body["max_num_results"] == 50 def test_rag_call_types_are_registered(): From 5972227b6d2bbe01d43e373ec5931c7cc6821658 Mon Sep 17 00:00:00 2001 From: Maximilian Roos Date: Mon, 7 Sep 2026 22:07:12 -0700 Subject: [PATCH 010/133] fix(http_handler): keep a handler alive while a response it issued is still reading _handler_may_close_client withholds the finalizer's close from a client that someone else references. A streaming response is not one of those referrers: it holds the connection it reads from, never the client, so the refcount says "sole referrer" for exactly the client that is busiest, and __del__ tears the pool down mid-body. Both handlers, both transports, and it is the shape #24929 reports: a provider's streaming call returns the response and drops the handler, and get_async_httpx_client lets a cached handler go after an hour. A streaming send now anchors the handler to the response it returns, so the handler is finalized once the caller is done with the body and the ordinary close applies with nothing in flight. The anchor is a weakref.finalize, which holds the handler in its own registry rather than on the response. That keeps the handler out of the response's reference cycle, so it is finalized by refcount and can still schedule an async close, rather than inside a cyclic collection that reaps its aiohttp session in the same pass; and a handler serving several streams is released only once all of them are done, because each anchor holds it separately. Only a streaming send anchors. A non-streaming response has been read in full by the time the method returns, so pinning the handler to it would delay every client close behind whatever the caller does with the response. The alternative was to ask the connection pool whether a request was in flight, reusing EvictedClientCloser's check. It reads client._transport, so it answers "idle" for any client with a proxy configured, where httpx routes through client._mounts; and having found the client busy it can only poll, which never terminates for a response the caller abandons unread, since httpx leaves that connection checked out. The response's own lifetime is the condition both were approximating. --- litellm/llms/custom_httpx/http_handler.py | 40 +++ tests/local_testing/conftest.py | 3 + .../test_handler_gc_does_not_close_client.py | 302 ++++++++++-------- 3 files changed, 210 insertions(+), 135 deletions(-) diff --git a/litellm/llms/custom_httpx/http_handler.py b/litellm/llms/custom_httpx/http_handler.py index e1f0fc9e7d3..612d5997ec5 100644 --- a/litellm/llms/custom_httpx/http_handler.py +++ b/litellm/llms/custom_httpx/http_handler.py @@ -7,6 +7,7 @@ import ssl import sys import threading import time +import weakref from collections.abc import AsyncIterable, Callable, Iterable, Mapping from http.cookiejar import CookieJar, DefaultCookiePolicy from types import MappingProxyType @@ -178,6 +179,33 @@ def _handler_may_close_client(client_refcount: int, owns_client: bool) -> bool: return owns_client and client_refcount <= _CLIENT_REFCOUNT_WHEN_HANDLER_IS_SOLE_REFERRER +def _drop_streaming_anchor(_handler: object) -> None: + """Release a handler anchored to a streaming response. See ``_anchor_handler_to``. + + The work is the reference held until this point, so there is nothing to do here. + """ + + +def _anchor_handler_to(response: httpx.Response, handler: object) -> None: + """Keep the handler alive for as long as a streaming response can still read. + + A body still arriving reads through the handler's connection pool, and closing + the client tears that pool down. The refcount ``_handler_may_close_client`` + reads cannot see that body: the reference graph runs response -> stream -> + connection and stops there, so a client carrying one looks exactly like an + unreferenced client, and the finalizer closes it mid-body. + + ``weakref.finalize`` holds the handler in its own registry rather than on the + response, which matters twice. The handler stays out of the response's + reference cycle, so it is finalized by refcount once the anchor drops and can + still schedule an async close, instead of being finalized inside a cyclic + collection that reaps its aiohttp session in the same pass. And a handler + serving several streams collects only once every one of them is done, because + each anchor holds it separately. + """ + weakref.finalize(response, _drop_streaming_anchor, handler) + + def blocked_cookie_jar() -> CookieJar: """A jar that stores no response cookie and sends none, for httpx clients. @@ -704,6 +732,8 @@ class AsyncHTTPHandler: content=request_content, ) response: Final = await self.client.send(req, stream=stream) + if stream: + _anchor_handler_to(response, self) response.raise_for_status() return response except (httpx.RemoteProtocolError, httpx.ConnectError): @@ -898,6 +928,8 @@ class AsyncHTTPHandler: content=request_content, ) response: Final = await self.client.send(req, stream=stream) + if stream: + _anchor_handler_to(response, self) response.raise_for_status() return response except (httpx.RemoteProtocolError, httpx.ConnectError): @@ -1362,6 +1394,8 @@ class HTTPHandler: content=request_content, ) response: Final = self.client.send(req, stream=stream) + if stream: + _anchor_handler_to(response, self) response.raise_for_status() return response except httpx.TimeoutException: @@ -1412,6 +1446,8 @@ class HTTPHandler: content=request_content, ) response: Final = self.client.send(req, stream=stream) + if stream: + _anchor_handler_to(response, self) response.raise_for_status() return response except httpx.TimeoutException: @@ -1462,6 +1498,8 @@ class HTTPHandler: content=request_content, ) response: Final = self.client.send(req, stream=stream) + if stream: + _anchor_handler_to(response, self) return response except httpx.TimeoutException: raise litellm.Timeout( @@ -1511,6 +1549,8 @@ class HTTPHandler: content=request_content, ) response: Final = self.client.send(req, stream=stream) + if stream: + _anchor_handler_to(response, self) response.raise_for_status() return response except httpx.TimeoutException: diff --git a/tests/local_testing/conftest.py b/tests/local_testing/conftest.py index 5535a62bb81..228457f4d55 100644 --- a/tests/local_testing/conftest.py +++ b/tests/local_testing/conftest.py @@ -75,6 +75,9 @@ _VCR_INCOMPATIBLE_FILES = frozenset( "test_router_caching.py", # Hits the local fake OpenAI endpoint on 127.0.0.1; nothing to record. "test_fake_openai_endpoint.py", + # Needs the real connection pool a collected handler tears down; vcrpy + # patches the transport that pool lives in. + "test_handler_gc_does_not_close_client.py", } ) diff --git a/tests/local_testing/test_handler_gc_does_not_close_client.py b/tests/local_testing/test_handler_gc_does_not_close_client.py index 3874405a261..1a6ab1b1827 100644 --- a/tests/local_testing/test_handler_gc_does_not_close_client.py +++ b/tests/local_testing/test_handler_gc_does_not_close_client.py @@ -1,36 +1,39 @@ """ -Garbage-collecting an HTTP handler must not close the httpx client it holds. +Collecting an HTTP handler must not abort a response that is still on the wire. -``HTTPHandler`` and ``AsyncHTTPHandler`` used to close their client from -``__del__``. Closing an httpx client tears down the connection pool that every -in-flight response is streaming through, and it permanently invalidates the -client for future requests -- including for callers who only ever borrowed -``handler.client``. Since nothing in a response's reference graph points back -at the handler, and since litellm caches handlers behind a one-hour TTL, the -handler routinely became collectable while its client was still in use. +``HTTPHandler`` and ``AsyncHTTPHandler`` close their client from ``__del__``. +Closing a client tears down the connection pool, which aborts every response +still streaming through it. ``_handler_may_close_client`` already withholds the +close from a client someone else holds, but a streaming response holds the +connection it is reading from and never the client, so the refcount it reads +says "sole referrer" for exactly the client that is busiest. The handler is +routinely collectable at that moment: a provider's streaming call returns the +response and drops the handler, and ``get_async_httpx_client`` caches handlers +behind a one-hour TTL and then lets them go. -``LLMClientCache`` documents the invariant this broke: evicted clients "may -still be in use by in-flight requests", so they are left to normal garbage -collection rather than closed eagerly. A finalizer that closes on collection -defeats exactly that. +The fix anchors the handler to the streaming response, so these tests turn on +*when* the handler is collected rather than on whether it is: pinned while the +body can still arrive, released once the caller is done with the response. -Each test below is one shape the finalizers broke; all of them fail if either -``__del__`` comes back. Async cases run on both transports, because litellm -defaults to aiohttp and only uses httpcore when aiohttp is disabled. +Nothing here re-tests the shapes ``_handler_may_close_client`` covers -- a +borrowed ``handler.client``, a caller-supplied client, an evicted-but-held +client. Those are pinned in ``tests/test_litellm/llms/custom_httpx/ +test_http_handler.py``. What is uncovered there is the in-flight response, so no +test here may keep the client in a local: that inflates the very refcount under +test, and the test then passes on a broken handler. They hold weak references +instead, which the refcount does not count. These live here rather than under ``tests/test_litellm/`` because they need a real connection pool: a mocked transport goes on yielding chunks after its client is closed, so the very teardown under test is what a mock cannot -reproduce. The server is a hermetic, credential-free ``ThreadingHTTPServer`` -on an ephemeral loopback port, and needs no network access beyond it. +reproduce. The server is a hermetic, credential-free ``ThreadingHTTPServer`` on +an ephemeral loopback port, and needs no network access beyond it. Related: https://github.com/BerriAI/litellm/issues/24929 """ import asyncio import gc -import os -import sys import threading import time import weakref @@ -39,8 +42,6 @@ from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer import httpx import pytest -sys.path.insert(0, os.path.abspath("../..")) - import litellm from litellm.caching.llm_caching_handler import LLMClientCache from litellm.llms.custom_httpx.http_handler import ( @@ -54,15 +55,12 @@ FRAME_COUNT = 6 # Generous: the server emits all frames in ~0.3s. A client whose pool was torn # down mid-stream can stall silently instead of raising, so reads are bounded. READ_TIMEOUT_SECONDS = 15.0 +RELEASE_TIMEOUT_SECONDS = 3.0 BOTH_TRANSPORTS = pytest.mark.parametrize("disable_aiohttp_transport", [False, True], ids=["aiohttp", "httpcore"]) -# Every test here rests on the handler actually being collected at the ``del``. -# If something ever pins it, the test would pass while guarding nothing, so each -# one checks the premise. The check comes last: a reintroduced finalizer also -# fails it, by resurrecting the handler into the task it creates for ``close()``, -# and the transport error is the more useful thing to see first. -HANDLER_NOT_COLLECTED = "handler was not collected; this test no longer exercises the finalizer path" +STILL_PINNED = "the handler was released while its response could still read" +NOT_RELEASED = "the handler outlived the response that was holding it" class _ChunkedSSEServer: @@ -129,93 +127,44 @@ async def _read_frames(response: httpx.Response) -> int: return b"".join(chunks).count(b"data: frame-") +async def _wait_until(is_done, failure: str) -> None: + deadline = time.monotonic() + RELEASE_TIMEOUT_SECONDS + while time.monotonic() < deadline: + if is_done(): + return + await asyncio.sleep(0.05) + pytest.fail(failure) + + @pytest.mark.asyncio @BOTH_TRANSPORTS async def test_async_stream_survives_handler_collection(monkeypatch, disable_aiohttp_transport): - """A response being streamed keeps working after its handler is collected.""" + """A response still streaming keeps working after its handler goes out of scope. + + The caller holds the response and nothing else, which is what a provider's + streaming path is left with once ``post(..., stream=True)`` has returned. + """ _select_transport(monkeypatch, disable_aiohttp_transport) with _ChunkedSSEServer() as server: handler = AsyncHTTPHandler(timeout=httpx.Timeout(10.0, connect=5.0)) - client = handler.client - try: - response = await client.send(client.build_request("GET", server.url), stream=True) + response = await handler.post(server.url, stream=True) - # The handler loses its last reference while the body is still streaming. - ref = weakref.ref(handler) - del handler - gc.collect() + ref = weakref.ref(handler) + del handler + gc.collect() + await asyncio.sleep(0) # let any close the finalizer scheduled run - frames = await asyncio.wait_for(_read_frames(response), timeout=READ_TIMEOUT_SECONDS) - assert frames == FRAME_COUNT - assert client.is_closed is False - assert ref() is None, HANDLER_NOT_COLLECTED - finally: - await client.aclose() + assert ref() is not None, STILL_PINNED + assert await asyncio.wait_for(_read_frames(response), timeout=READ_TIMEOUT_SECONDS) == FRAME_COUNT - -@pytest.mark.asyncio -@BOTH_TRANSPORTS -async def test_borrowed_async_client_outlives_its_handler(monkeypatch, disable_aiohttp_transport): - """A caller that keeps only ``handler.client`` can still send requests once the handler is gone. - - This is the shape at litellm/a2a_protocol/main.py (``httpx_client = - _async_handler.client``, handed to the a2a SDK) and at - litellm/proxy/pass_through_endpoints/pass_through_endpoints.py (``async_client - = async_client_obj.client``). Both take the handler from - ``get_async_httpx_client``, so the cache pins it for - ``_DEFAULT_TTL_FOR_HTTPX_CLIENTS`` (one hour) and then lets it go on eviction, - at which point it is collected while the borrowed client is still serving a - longer-lived consumer: ``create_a2a_client`` hands its client to the a2a SDK - and documents it as "create client once, reuse for multiple requests". - """ - _select_transport(monkeypatch, disable_aiohttp_transport) - - with _ChunkedSSEServer(frame_count=1, frame_delay=0.0) as server: - handler = AsyncHTTPHandler(timeout=httpx.Timeout(10.0, connect=5.0)) - client = handler.client - try: - ref = weakref.ref(handler) - del handler - gc.collect() - # A finalizer would close the client from a task, so let the loop turn. - await asyncio.sleep(0.05) - - assert client.is_closed is False - response = await client.get(server.url) - assert response.status_code == 200 - assert ref() is None, HANDLER_NOT_COLLECTED - finally: - await client.aclose() - - -def test_sync_handler_collection_does_not_close_a_caller_owned_client(monkeypatch): - """A throwaway handler wrapped around someone else's client must not close it. - - litellm/llms/azure/azure.py builds ``HTTPHandler(client=litellm.client_session)`` - for a single image generation and drops it. With a finalizer, that one call - left the user's shared session closed for the rest of the process. - """ - monkeypatch.setattr(litellm, "force_ipv4", False) - - with _ChunkedSSEServer(frame_count=1, frame_delay=0.0) as server: - caller_client = httpx.Client(timeout=httpx.Timeout(10.0, connect=5.0)) - monkeypatch.setattr(litellm, "client_session", caller_client) - try: - handler = HTTPHandler(client=litellm.client_session) - ref = weakref.ref(handler) - del handler - gc.collect() - - assert caller_client.is_closed is False - assert caller_client.get(server.url).status_code == 200 - assert ref() is None, HANDLER_NOT_COLLECTED - finally: - caller_client.close() + del response + gc.collect() + assert ref() is None, NOT_RELEASED def test_sync_stream_survives_handler_collection(monkeypatch): - """A sync response being streamed keeps working after its handler is collected. + """The sync handler closes inline from its finalizer, so a stream must hold it off. litellm/main.py builds a sync handler only for non-streaming calls, commented "Keep this here, otherwise, the httpx.client closes and streaming is @@ -225,26 +174,115 @@ def test_sync_stream_survives_handler_collection(monkeypatch): with _ChunkedSSEServer() as server: handler = HTTPHandler(timeout=httpx.Timeout(10.0, connect=5.0)) - client = handler.client - try: - response = client.send(client.build_request("GET", server.url), stream=True) + response = handler.post(server.url, stream=True) - # The handler loses its last reference while the body is still streaming. - ref = weakref.ref(handler) - del handler + ref = weakref.ref(handler) + del handler + gc.collect() + assert ref() is not None, STILL_PINNED + + # Joined before counting, as in ``_read_frames``. + chunks = [] + for chunk in response.iter_bytes(): + chunks.append(chunk) gc.collect() + assert b"".join(chunks).count(b"data: frame-") == FRAME_COUNT - # Joined before counting, as in ``_read_frames``. - chunks = [] - for chunk in response.iter_bytes(): - chunks.append(chunk) - gc.collect() + del response + gc.collect() + assert ref() is None, NOT_RELEASED - assert b"".join(chunks).count(b"data: frame-") == FRAME_COUNT - assert client.is_closed is False - assert ref() is None, HANDLER_NOT_COLLECTED - finally: - client.close() + +@pytest.mark.asyncio +@BOTH_TRANSPORTS +async def test_an_abandoned_stream_still_releases_its_handler(monkeypatch, disable_aiohttp_transport): + """A caller that drops a stream unread must not pin the handler for good. + + Tying the handler to the response's own lifetime is what bounds this. No + deadline, and no poll of the connection's state, can tell an abandoned body + from one the upstream is merely slow to finish: httpx leaves the connection + checked out until the response is read or closed, and a legitimate stream is + bounded only by how long the upstream keeps sending. + """ + _select_transport(monkeypatch, disable_aiohttp_transport) + + with _ChunkedSSEServer() as server: + handler = AsyncHTTPHandler(timeout=httpx.Timeout(10.0, connect=5.0)) + client_ref = weakref.ref(handler.client) + response = await handler.post(server.url, stream=True) + + ref = weakref.ref(handler) + del handler, response + gc.collect() + + assert ref() is None, NOT_RELEASED + await _wait_until( + lambda: client_ref() is None or client_ref().is_closed, + "the client outlived the abandoned stream without being closed", + ) + + +@pytest.mark.asyncio +@BOTH_TRANSPORTS +async def test_the_pool_is_released_once_the_stream_it_carried_ends(monkeypatch, disable_aiohttp_transport): + """Holding the finalizer off must defer the close, not drop it. + + Otherwise a collected handler leaks its pool for every streaming request it + was carrying, and on aiohttp warns "Unclosed client session" when the + collector eventually takes it. The pool and the session are children of the + client, so keeping one here does not inflate the refcount the finalizer + reads, the way keeping the client would. + """ + _select_transport(monkeypatch, disable_aiohttp_transport) + + with _ChunkedSSEServer() as server: + handler = AsyncHTTPHandler(timeout=httpx.Timeout(10.0, connect=5.0)) + transport = handler.client._transport + if disable_aiohttp_transport: + pool = transport._pool + + def is_released() -> bool: + return pool.connections == [] + else: + session = transport._get_valid_client_session() + + def is_released() -> bool: + return session.closed + + response = await handler.post(server.url, stream=True) + + del handler, transport + gc.collect() + assert not is_released(), "the pool was torn down while it was still carrying a body" + + assert await asyncio.wait_for(_read_frames(response), timeout=READ_TIMEOUT_SECONDS) == FRAME_COUNT + del response + gc.collect() + + await _wait_until(is_released, "the pool outlived the stream it carried, unclosed") + + +@pytest.mark.asyncio +@BOTH_TRANSPORTS +async def test_a_non_streaming_response_does_not_pin_its_handler(monkeypatch, disable_aiohttp_transport): + """Only a body that can still arrive holds the handler. + + A non-streaming response has been read in full by the time ``post`` returns, + so pinning the handler to it would delay every client close behind whatever + the caller goes on to do with the response. + """ + _select_transport(monkeypatch, disable_aiohttp_transport) + + with _ChunkedSSEServer(frame_count=1, frame_delay=0.0) as server: + handler = AsyncHTTPHandler(timeout=httpx.Timeout(10.0, connect=5.0)) + response = await handler.post(server.url) + assert response.status_code == 200 + + ref = weakref.ref(handler) + del handler + gc.collect() + + assert ref() is None, "a fully-read response pinned its handler" @pytest.mark.asyncio @@ -254,30 +292,24 @@ async def test_cached_handler_eviction_does_not_abort_an_in_flight_stream(monkey ``get_async_httpx_client`` caches handlers for an hour. When that TTL expires the cache drops the only reference to a handler whose client is - still streaming -- the production shape of #24929, and the case - ``LLMClientCache`` documents as "may still be in use by in-flight requests". + still streaming -- the production shape of #24929. """ _select_transport(monkeypatch, disable_aiohttp_transport) monkeypatch.setattr(litellm, "in_memory_llm_clients_cache", LLMClientCache()) with _ChunkedSSEServer() as server: handler = get_async_httpx_client(llm_provider=LlmProviders.OPENAI) - client = handler.client - try: - response = await client.send(client.build_request("GET", server.url), stream=True) + response = await handler.post(server.url, stream=True) - # An hour passes: the TTL expires and the cache lets the handler go. - ref = weakref.ref(handler) - litellm.in_memory_llm_clients_cache.flush_cache() - del handler - gc.collect() + # An hour passes: the TTL expires and the cache lets the handler go. + ref = weakref.ref(handler) + litellm.in_memory_llm_clients_cache.flush_cache() + del handler + gc.collect() - frames = await asyncio.wait_for(_read_frames(response), timeout=READ_TIMEOUT_SECONDS) - assert frames == FRAME_COUNT - assert client.is_closed is False + assert ref() is not None, STILL_PINNED + assert await asyncio.wait_for(_read_frames(response), timeout=READ_TIMEOUT_SECONDS) == FRAME_COUNT - # And the evicted client is still usable for the next request. - assert (await client.get(server.url)).status_code == 200 - assert ref() is None, HANDLER_NOT_COLLECTED - finally: - await client.aclose() + del response + gc.collect() + assert ref() is None, NOT_RELEASED From 6e6ab5c0ebafd01700ef23bf02368a70638323ef Mon Sep 17 00:00:00 2001 From: Maximilian Roos Date: Mon, 7 Sep 2026 22:38:32 -0700 Subject: [PATCH 011/133] test(http_handler): cover the anchor in the tree Codecov measures tests/local_testing runs on CircleCI, which does not feed the patch-coverage check, so the anchor read as half-uncovered. These three drive it over a mock transport, which is enough for the lifetime contract even though it cannot reproduce the teardown the loopback tests exist for. --- .../llms/custom_httpx/test_http_handler.py | 68 +++++++++++++++++++ 1 file changed, 68 insertions(+) diff --git a/tests/test_litellm/llms/custom_httpx/test_http_handler.py b/tests/test_litellm/llms/custom_httpx/test_http_handler.py index 9e64bfafa54..4590a361026 100644 --- a/tests/test_litellm/llms/custom_httpx/test_http_handler.py +++ b/tests/test_litellm/llms/custom_httpx/test_http_handler.py @@ -1025,6 +1025,74 @@ def test_handed_out_sync_client_pool_survives_handler_collection(keepalive_serve consumer_client.close() +def _mock_transport() -> httpx.MockTransport: + """Answers anything with a short body, left unread when the caller asked to stream.""" + + def respond(request: httpx.Request) -> httpx.Response: + return httpx.Response(200, request=request, content=b"ab") + + return httpx.MockTransport(respond) + + +@pytest.mark.asyncio +async def test_a_streaming_response_holds_its_handler_until_it_is_released(): + """The finalizer must not run while a body this handler issued can still arrive. + + ``_handler_may_close_client`` cannot see that body: it holds the connection it + reads from and never the client. Anchoring the handler to the response is what + withholds the close, and releasing it is what still delivers one. + """ + handler = AsyncHTTPHandler() + handler.client._transport = _mock_transport() + ref = weakref.ref(handler) + response = await handler.post("https://example.invalid/stream", stream=True) + + del handler + gc.collect() + assert ref() is not None, "the handler was released while its response could still read" + + assert await response.aread() == b"ab" + del response + gc.collect() + assert ref() is None, "the handler outlived the response that was holding it" + + +@pytest.mark.asyncio +async def test_a_fully_read_response_does_not_hold_its_handler(): + """A non-streaming response is complete when ``post`` returns, so it anchors nothing. + + Otherwise every client close would wait on whatever the caller does next with + a response it has already read. + """ + handler = AsyncHTTPHandler() + handler.client._transport = _mock_transport() + ref = weakref.ref(handler) + response = await handler.post("https://example.invalid/whole") + assert response.content == b"ab" + + del handler + gc.collect() + + assert ref() is None, "a fully-read response pinned its handler" + + +def test_a_sync_streaming_response_holds_its_handler_until_it_is_released(): + """The sync finalizer closes inline, so the same anchor has to hold it off.""" + handler = HTTPHandler() + handler.client._transport = _mock_transport() + ref = weakref.ref(handler) + response = handler.post("https://example.invalid/stream", stream=True) + + del handler + gc.collect() + assert ref() is not None, "the handler was released while its response could still read" + + assert response.read() == b"ab" + del response + gc.collect() + assert ref() is None, "the handler outlived the response that was holding it" + + def test_sync_close_leaves_caller_supplied_client_open(): supplied = httpx.Client() handler = HTTPHandler(client=supplied) From ffa08e16d6fb575589309e008c9bbf2ffe96ee63 Mon Sep 17 00:00:00 2001 From: Maximilian Roos Date: Mon, 7 Sep 2026 23:02:02 -0700 Subject: [PATCH 012/133] test(http_handler): parametrize the anchor tests over every streaming send post was the only method the mock-transport tests reached, leaving the anchor in async delete and in sync patch/put/delete uncovered in the tree Codecov measures. Parametrizing also means a method added later is covered here rather than being the one that forgets to anchor. --- .../llms/custom_httpx/test_http_handler.py | 56 +++++++++++-------- 1 file changed, 34 insertions(+), 22 deletions(-) diff --git a/tests/test_litellm/llms/custom_httpx/test_http_handler.py b/tests/test_litellm/llms/custom_httpx/test_http_handler.py index 4590a361026..40420bebee4 100644 --- a/tests/test_litellm/llms/custom_httpx/test_http_handler.py +++ b/tests/test_litellm/llms/custom_httpx/test_http_handler.py @@ -1034,27 +1034,56 @@ def _mock_transport() -> httpx.MockTransport: return httpx.MockTransport(respond) +RELEASED_TOO_EARLY = "the handler was released while its response could still read" +NEVER_RELEASED = "the handler outlived the response that was holding it" + +# Every method that can hand back a body the caller has not read yet, which is +# every one that passes stream= down to send(). Parametrized so a method added +# later is covered here rather than being the one that forgets to anchor. +ASYNC_STREAMING_SENDS = ["post", "delete"] +SYNC_STREAMING_SENDS = ["post", "patch", "put", "delete"] + + @pytest.mark.asyncio -async def test_a_streaming_response_holds_its_handler_until_it_is_released(): +@pytest.mark.parametrize("method", ASYNC_STREAMING_SENDS) +async def test_a_streaming_response_holds_its_handler_until_it_is_released(method): """The finalizer must not run while a body this handler issued can still arrive. ``_handler_may_close_client`` cannot see that body: it holds the connection it reads from and never the client. Anchoring the handler to the response is what - withholds the close, and releasing it is what still delivers one. + withholds the close, and releasing the anchor is what still delivers one. """ handler = AsyncHTTPHandler() handler.client._transport = _mock_transport() ref = weakref.ref(handler) - response = await handler.post("https://example.invalid/stream", stream=True) + response = await getattr(handler, method)("https://example.invalid/stream", stream=True) del handler gc.collect() - assert ref() is not None, "the handler was released while its response could still read" + assert ref() is not None, RELEASED_TOO_EARLY assert await response.aread() == b"ab" del response gc.collect() - assert ref() is None, "the handler outlived the response that was holding it" + assert ref() is None, NEVER_RELEASED + + +@pytest.mark.parametrize("method", SYNC_STREAMING_SENDS) +def test_a_sync_streaming_response_holds_its_handler_until_it_is_released(method): + """The sync finalizer closes inline, so the same anchor has to hold it off.""" + handler = HTTPHandler() + handler.client._transport = _mock_transport() + ref = weakref.ref(handler) + response = getattr(handler, method)("https://example.invalid/stream", stream=True) + + del handler + gc.collect() + assert ref() is not None, RELEASED_TOO_EARLY + + assert response.read() == b"ab" + del response + gc.collect() + assert ref() is None, NEVER_RELEASED @pytest.mark.asyncio @@ -1076,23 +1105,6 @@ async def test_a_fully_read_response_does_not_hold_its_handler(): assert ref() is None, "a fully-read response pinned its handler" -def test_a_sync_streaming_response_holds_its_handler_until_it_is_released(): - """The sync finalizer closes inline, so the same anchor has to hold it off.""" - handler = HTTPHandler() - handler.client._transport = _mock_transport() - ref = weakref.ref(handler) - response = handler.post("https://example.invalid/stream", stream=True) - - del handler - gc.collect() - assert ref() is not None, "the handler was released while its response could still read" - - assert response.read() == b"ab" - del response - gc.collect() - assert ref() is None, "the handler outlived the response that was holding it" - - def test_sync_close_leaves_caller_supplied_client_open(): supplied = httpx.Client() handler = HTTPHandler(client=supplied) From dc3a0399f23dfb2b34910868908887c9879f8ccc Mon Sep 17 00:00:00 2001 From: "berriai-litellm-provider-info-sync[bot]" <328147090+berriai-litellm-provider-info-sync[bot]@users.noreply.github.com> Date: Tue, 15 Sep 2026 00:36:08 +0000 Subject: [PATCH 013/133] chore(prices): sync Vertex AI prices: 4 models gemini-2.5-flash-preview-tts: output_cost_per_audio_token, input_cost_per_token_batches gemini/gemini-2.5-flash-preview-tts: output_cost_per_audio_token, input_cost_per_token_batches gemini-3.1-flash-live-preview: input_cost_per_second gemini/gemini-3.1-flash-live-preview: input_cost_per_second --- ...model_prices_and_context_window_backup.json | 18 ++++++++++++------ model_prices_and_context_window.json | 18 ++++++++++++------ 2 files changed, 24 insertions(+), 12 deletions(-) diff --git a/litellm/model_prices_and_context_window_backup.json b/litellm/model_prices_and_context_window_backup.json index 9f91cf82f41..54f19419e47 100644 --- a/litellm/model_prices_and_context_window_backup.json +++ b/litellm/model_prices_and_context_window_backup.json @@ -26553,10 +26553,12 @@ }, "gemini/gemini-2.5-flash-preview-tts": { "input_cost_per_token": 5e-07, + "input_cost_per_token_batches": 2.5e-07, "litellm_provider": "gemini", "mode": "audio_speech", + "output_cost_per_audio_token": 1e-05, "output_cost_per_token": 1e-05, - "source": "https://ai.google.dev/gemini-api/docs/pricing", + "source": "https://cloud.google.com/gemini-enterprise-agent-platform/generative-ai/pricing", "supported_endpoints": [ "/v1/audio/speech" ], @@ -55456,7 +55458,7 @@ "mode": "realtime", "output_cost_per_audio_token": 1.2e-05, "output_cost_per_token": 4.5e-06, - "source": "https://ai.google.dev/gemini-api/docs/pricing", + "source": "https://cloud.google.com/gemini-enterprise-agent-platform/generative-ai/pricing", "supported_endpoints": [ "/v1/realtime" ], @@ -55475,7 +55477,8 @@ "supports_function_calling": true, "supports_vision": true, "supports_web_search": true, - "gemini_audio_only_live": true + "gemini_audio_only_live": true, + "input_cost_per_second": 8.33333333333e-05 }, "gemini/gemini-2.5-flash-native-audio-latest": { "input_cost_per_audio_token": 3e-06, @@ -55573,7 +55576,7 @@ "mode": "realtime", "output_cost_per_audio_token": 1.2e-05, "output_cost_per_token": 4.5e-06, - "source": "https://ai.google.dev/gemini-api/docs/pricing", + "source": "https://cloud.google.com/gemini-enterprise-agent-platform/generative-ai/pricing", "supported_endpoints": [ "/v1/realtime" ], @@ -55594,7 +55597,8 @@ "supports_web_search": true, "tpm": 250000, "rpm": 10, - "gemini_audio_only_live": true + "gemini_audio_only_live": true, + "input_cost_per_second": 8.33333333333e-05 }, "gemini/gemini-3.1-flash-tts-preview": { "input_cost_per_token": 1e-06, @@ -55613,10 +55617,12 @@ }, "gemini-2.5-flash-preview-tts": { "input_cost_per_token": 5e-07, + "input_cost_per_token_batches": 2.5e-07, "litellm_provider": "gemini", "mode": "audio_speech", + "output_cost_per_audio_token": 1e-05, "output_cost_per_token": 1e-05, - "source": "https://ai.google.dev/gemini-api/docs/pricing", + "source": "https://cloud.google.com/gemini-enterprise-agent-platform/generative-ai/pricing", "supported_endpoints": [ "/v1/audio/speech" ] diff --git a/model_prices_and_context_window.json b/model_prices_and_context_window.json index 9f91cf82f41..54f19419e47 100644 --- a/model_prices_and_context_window.json +++ b/model_prices_and_context_window.json @@ -26553,10 +26553,12 @@ }, "gemini/gemini-2.5-flash-preview-tts": { "input_cost_per_token": 5e-07, + "input_cost_per_token_batches": 2.5e-07, "litellm_provider": "gemini", "mode": "audio_speech", + "output_cost_per_audio_token": 1e-05, "output_cost_per_token": 1e-05, - "source": "https://ai.google.dev/gemini-api/docs/pricing", + "source": "https://cloud.google.com/gemini-enterprise-agent-platform/generative-ai/pricing", "supported_endpoints": [ "/v1/audio/speech" ], @@ -55456,7 +55458,7 @@ "mode": "realtime", "output_cost_per_audio_token": 1.2e-05, "output_cost_per_token": 4.5e-06, - "source": "https://ai.google.dev/gemini-api/docs/pricing", + "source": "https://cloud.google.com/gemini-enterprise-agent-platform/generative-ai/pricing", "supported_endpoints": [ "/v1/realtime" ], @@ -55475,7 +55477,8 @@ "supports_function_calling": true, "supports_vision": true, "supports_web_search": true, - "gemini_audio_only_live": true + "gemini_audio_only_live": true, + "input_cost_per_second": 8.33333333333e-05 }, "gemini/gemini-2.5-flash-native-audio-latest": { "input_cost_per_audio_token": 3e-06, @@ -55573,7 +55576,7 @@ "mode": "realtime", "output_cost_per_audio_token": 1.2e-05, "output_cost_per_token": 4.5e-06, - "source": "https://ai.google.dev/gemini-api/docs/pricing", + "source": "https://cloud.google.com/gemini-enterprise-agent-platform/generative-ai/pricing", "supported_endpoints": [ "/v1/realtime" ], @@ -55594,7 +55597,8 @@ "supports_web_search": true, "tpm": 250000, "rpm": 10, - "gemini_audio_only_live": true + "gemini_audio_only_live": true, + "input_cost_per_second": 8.33333333333e-05 }, "gemini/gemini-3.1-flash-tts-preview": { "input_cost_per_token": 1e-06, @@ -55613,10 +55617,12 @@ }, "gemini-2.5-flash-preview-tts": { "input_cost_per_token": 5e-07, + "input_cost_per_token_batches": 2.5e-07, "litellm_provider": "gemini", "mode": "audio_speech", + "output_cost_per_audio_token": 1e-05, "output_cost_per_token": 1e-05, - "source": "https://ai.google.dev/gemini-api/docs/pricing", + "source": "https://cloud.google.com/gemini-enterprise-agent-platform/generative-ai/pricing", "supported_endpoints": [ "/v1/audio/speech" ] From 01b069eb6c1cb8280f9600f1a2d1ec22cb257e29 Mon Sep 17 00:00:00 2001 From: "berriai-litellm-provider-info-sync[bot]" <328147090+berriai-litellm-provider-info-sync[bot]@users.noreply.github.com> Date: Tue, 15 Sep 2026 01:36:09 +0000 Subject: [PATCH 014/133] chore(prices): sync AWS Bedrock prices: 25 models anthropic.claude-fable-5: anthropic.claude-fable-5-1: anthropic.claude-opus-4-7: anthropic.claude-opus-4-8: anthropic.claude-opus-5: anthropic.claude-sonnet-4-6: anthropic.claude-sonnet-5: global.anthropic.claude-fable-5: global.anthropic.claude-fable-5-1: global.anthropic.claude-opus-4-7: global.anthropic.claude-opus-4-8: global.anthropic.claude-opus-5: global.anthropic.claude-sonnet-4-6: global.anthropic.claude-sonnet-5: us-gov.anthropic.claude-fable-5-1: us-gov.anthropic.claude-opus-4-8: us-gov.anthropic.claude-opus-5: us-gov.anthropic.claude-sonnet-5: us.anthropic.claude-fable-5: us.anthropic.claude-fable-5-1: us.anthropic.claude-opus-4-7: us.anthropic.claude-opus-4-8: us.anthropic.claude-opus-5: us.anthropic.claude-sonnet-4-6: us.anthropic.claude-sonnet-5: --- ...odel_prices_and_context_window_backup.json | 69 +++++++++++++------ model_prices_and_context_window.json | 69 +++++++++++++------ 2 files changed, 94 insertions(+), 44 deletions(-) diff --git a/litellm/model_prices_and_context_window_backup.json b/litellm/model_prices_and_context_window_backup.json index 54f19419e47..47cd6faf022 100644 --- a/litellm/model_prices_and_context_window_backup.json +++ b/litellm/model_prices_and_context_window_backup.json @@ -1312,7 +1312,8 @@ "supports_output_config": true, "bedrock_output_config_effort_ceiling": "xhigh", "supports_parallel_tool_use_config": true, - "prompt_cache_min_tokens": 2048 + "prompt_cache_min_tokens": 2048, + "source": "https://pricing.us-east-1.amazonaws.com/offers/v1.0/aws/AmazonBedrockFoundationModels/current/index.json" }, "anthropic.claude-mythos-preview": { "input_cost_per_token": 0, @@ -1365,7 +1366,8 @@ "supports_output_config": true, "bedrock_output_config_effort_ceiling": "xhigh", "supports_parallel_tool_use_config": true, - "prompt_cache_min_tokens": 2048 + "prompt_cache_min_tokens": 2048, + "source": "https://pricing.us-east-1.amazonaws.com/offers/v1.0/aws/AmazonBedrockFoundationModels/current/index.json" }, "us.anthropic.claude-opus-4-7": { "bedrock_converse_supports_strict_tools": false, @@ -1402,7 +1404,8 @@ "supports_output_config": true, "bedrock_output_config_effort_ceiling": "xhigh", "supports_parallel_tool_use_config": true, - "prompt_cache_min_tokens": 2048 + "prompt_cache_min_tokens": 2048, + "source": "https://pricing.us-east-1.amazonaws.com/offers/v1.0/aws/AmazonBedrockFoundationModels/current/index.json" }, "eu.anthropic.claude-opus-4-7": { "bedrock_converse_supports_strict_tools": false, @@ -1513,7 +1516,8 @@ "supports_output_config": true, "bedrock_output_config_effort_ceiling": "xhigh", "supports_parallel_tool_use_config": true, - "prompt_cache_min_tokens": 512 + "prompt_cache_min_tokens": 512, + "source": "https://pricing.us-east-1.amazonaws.com/offers/v1.0/aws/AmazonBedrockFoundationModels/current/index.json" }, "anthropic.claude-fable-5-1": { "cache_creation_input_token_cost": 1.25e-05, @@ -1551,7 +1555,8 @@ "supports_output_config": true, "bedrock_output_config_effort_ceiling": "xhigh", "supports_parallel_tool_use_config": true, - "prompt_cache_min_tokens": 512 + "prompt_cache_min_tokens": 512, + "source": "https://pricing.us-east-1.amazonaws.com/offers/v1.0/aws/AmazonBedrockFoundationModels/current/index.json" }, "global.anthropic.claude-fable-5": { "cache_creation_input_token_cost": 1.25e-05, @@ -1588,7 +1593,8 @@ "supports_output_config": true, "bedrock_output_config_effort_ceiling": "xhigh", "supports_parallel_tool_use_config": true, - "prompt_cache_min_tokens": 512 + "prompt_cache_min_tokens": 512, + "source": "https://pricing.us-east-1.amazonaws.com/offers/v1.0/aws/AmazonBedrockFoundationModels/current/index.json" }, "global.anthropic.claude-fable-5-1": { "cache_creation_input_token_cost": 1.25e-05, @@ -1626,7 +1632,8 @@ "supports_output_config": true, "bedrock_output_config_effort_ceiling": "xhigh", "supports_parallel_tool_use_config": true, - "prompt_cache_min_tokens": 512 + "prompt_cache_min_tokens": 512, + "source": "https://pricing.us-east-1.amazonaws.com/offers/v1.0/aws/AmazonBedrockFoundationModels/current/index.json" }, "us.anthropic.claude-fable-5": { "cache_creation_input_token_cost": 1.375e-05, @@ -1663,7 +1670,8 @@ "supports_output_config": true, "bedrock_output_config_effort_ceiling": "xhigh", "supports_parallel_tool_use_config": true, - "prompt_cache_min_tokens": 512 + "prompt_cache_min_tokens": 512, + "source": "https://pricing.us-east-1.amazonaws.com/offers/v1.0/aws/AmazonBedrockFoundationModels/current/index.json" }, "us.anthropic.claude-fable-5-1": { "cache_creation_input_token_cost": 1.375e-05, @@ -1701,7 +1709,8 @@ "supports_output_config": true, "bedrock_output_config_effort_ceiling": "xhigh", "supports_parallel_tool_use_config": true, - "prompt_cache_min_tokens": 512 + "prompt_cache_min_tokens": 512, + "source": "https://pricing.us-east-1.amazonaws.com/offers/v1.0/aws/AmazonBedrockFoundationModels/current/index.json" }, "eu.anthropic.claude-fable-5": { "cache_creation_input_token_cost": 1.375e-05, @@ -1812,7 +1821,8 @@ "supports_max_reasoning_effort": true, "supports_output_config": true, "supports_parallel_tool_use_config": true, - "prompt_cache_min_tokens": 512 + "prompt_cache_min_tokens": 512, + "source": "https://pricing.us-east-1.amazonaws.com/offers/v1.0/aws/AmazonBedrockFoundationModels/current/index.json" }, "global.anthropic.claude-opus-5": { "bedrock_converse_supports_strict_tools": false, @@ -1848,7 +1858,8 @@ "supports_max_reasoning_effort": true, "supports_output_config": true, "supports_parallel_tool_use_config": true, - "prompt_cache_min_tokens": 512 + "prompt_cache_min_tokens": 512, + "source": "https://pricing.us-east-1.amazonaws.com/offers/v1.0/aws/AmazonBedrockFoundationModels/current/index.json" }, "us.anthropic.claude-opus-5": { "bedrock_converse_supports_strict_tools": false, @@ -1884,7 +1895,8 @@ "supports_max_reasoning_effort": true, "supports_output_config": true, "supports_parallel_tool_use_config": true, - "prompt_cache_min_tokens": 512 + "prompt_cache_min_tokens": 512, + "source": "https://pricing.us-east-1.amazonaws.com/offers/v1.0/aws/AmazonBedrockFoundationModels/current/index.json" }, "eu.anthropic.claude-opus-5": { "bedrock_converse_supports_strict_tools": false, @@ -2029,7 +2041,8 @@ "supports_output_config": true, "bedrock_output_config_effort_ceiling": "xhigh", "supports_parallel_tool_use_config": true, - "prompt_cache_min_tokens": 1024 + "prompt_cache_min_tokens": 1024, + "source": "https://pricing.us-east-1.amazonaws.com/offers/v1.0/aws/AmazonBedrockFoundationModels/current/index.json" }, "global.anthropic.claude-opus-4-8": { "bedrock_converse_supports_strict_tools": false, @@ -2066,7 +2079,8 @@ "supports_output_config": true, "bedrock_output_config_effort_ceiling": "xhigh", "supports_parallel_tool_use_config": true, - "prompt_cache_min_tokens": 1024 + "prompt_cache_min_tokens": 1024, + "source": "https://pricing.us-east-1.amazonaws.com/offers/v1.0/aws/AmazonBedrockFoundationModels/current/index.json" }, "us.anthropic.claude-opus-4-8": { "bedrock_converse_supports_strict_tools": false, @@ -2103,7 +2117,8 @@ "supports_output_config": true, "bedrock_output_config_effort_ceiling": "xhigh", "supports_parallel_tool_use_config": true, - "prompt_cache_min_tokens": 1024 + "prompt_cache_min_tokens": 1024, + "source": "https://pricing.us-east-1.amazonaws.com/offers/v1.0/aws/AmazonBedrockFoundationModels/current/index.json" }, "eu.anthropic.claude-opus-4-8": { "bedrock_converse_supports_strict_tools": false, @@ -2286,7 +2301,8 @@ "supports_output_config": true, "bedrock_output_config_effort_ceiling": "xhigh", "supports_parallel_tool_use_config": true, - "prompt_cache_min_tokens": 1024 + "prompt_cache_min_tokens": 1024, + "source": "https://pricing.us-east-1.amazonaws.com/offers/v1.0/aws/AmazonBedrockFoundationModels/current/index.json" }, "global.anthropic.claude-sonnet-5": { "bedrock_converse_supports_strict_tools": false, @@ -2323,7 +2339,8 @@ "supports_output_config": true, "bedrock_output_config_effort_ceiling": "xhigh", "supports_parallel_tool_use_config": true, - "prompt_cache_min_tokens": 1024 + "prompt_cache_min_tokens": 1024, + "source": "https://pricing.us-east-1.amazonaws.com/offers/v1.0/aws/AmazonBedrockFoundationModels/current/index.json" }, "us.anthropic.claude-sonnet-5": { "bedrock_converse_supports_strict_tools": false, @@ -2360,7 +2377,8 @@ "supports_output_config": true, "bedrock_output_config_effort_ceiling": "xhigh", "supports_parallel_tool_use_config": true, - "prompt_cache_min_tokens": 1024 + "prompt_cache_min_tokens": 1024, + "source": "https://pricing.us-east-1.amazonaws.com/offers/v1.0/aws/AmazonBedrockFoundationModels/current/index.json" }, "eu.anthropic.claude-sonnet-5": { "bedrock_converse_supports_strict_tools": false, @@ -2505,7 +2523,8 @@ "supports_native_structured_output": true, "supports_output_config": true, "supports_parallel_tool_use_config": true, - "prompt_cache_min_tokens": 1024 + "prompt_cache_min_tokens": 1024, + "source": "https://pricing.us-east-1.amazonaws.com/offers/v1.0/aws/AmazonBedrockFoundationModels/current/index.json" }, "global.anthropic.claude-sonnet-4-6": { "supports_adaptive_thinking": true, @@ -2539,7 +2558,8 @@ "supports_native_structured_output": true, "supports_output_config": true, "supports_parallel_tool_use_config": true, - "prompt_cache_min_tokens": 1024 + "prompt_cache_min_tokens": 1024, + "source": "https://pricing.us-east-1.amazonaws.com/offers/v1.0/aws/AmazonBedrockFoundationModels/current/index.json" }, "us.anthropic.claude-sonnet-4-6": { "supports_adaptive_thinking": true, @@ -2573,7 +2593,8 @@ "supports_native_structured_output": true, "supports_output_config": true, "supports_parallel_tool_use_config": true, - "prompt_cache_min_tokens": 1024 + "prompt_cache_min_tokens": 1024, + "source": "https://pricing.us-east-1.amazonaws.com/offers/v1.0/aws/AmazonBedrockFoundationModels/current/index.json" }, "eu.anthropic.claude-sonnet-4-6": { "supports_adaptive_thinking": true, @@ -44977,6 +44998,7 @@ "mode": "chat", "output_cost_per_token": 1.2e-05, "prompt_cache_min_tokens": 1024, + "source": "https://pricing.us-east-1.amazonaws.com/offers/v1.0/aws/AmazonBedrockFoundationModels/current/index.json", "supports_adaptive_thinking": true, "supports_assistant_prefill": false, "supports_computer_use": true, @@ -45009,6 +45031,7 @@ "mode": "chat", "output_cost_per_token": 3e-05, "prompt_cache_min_tokens": 1024, + "source": "https://pricing.us-east-1.amazonaws.com/offers/v1.0/aws/AmazonBedrockFoundationModels/current/index.json", "supports_adaptive_thinking": true, "supports_assistant_prefill": false, "supports_computer_use": true, @@ -45040,6 +45063,7 @@ "mode": "chat", "output_cost_per_token": 3e-05, "prompt_cache_min_tokens": 512, + "source": "https://pricing.us-east-1.amazonaws.com/offers/v1.0/aws/AmazonBedrockFoundationModels/current/index.json", "supports_adaptive_thinking": true, "supports_assistant_prefill": false, "supports_computer_use": true, @@ -45089,7 +45113,8 @@ "supports_output_config": true, "bedrock_output_config_effort_ceiling": "xhigh", "supports_parallel_tool_use_config": true, - "prompt_cache_min_tokens": 512 + "prompt_cache_min_tokens": 512, + "source": "https://pricing.us-east-1.amazonaws.com/offers/v1.0/aws/AmazonBedrockFoundationModels/current/index.json" }, "us-gov.nvidia.nemotron-nano-3-30b": { "input_cost_per_token": 7.2e-08, diff --git a/model_prices_and_context_window.json b/model_prices_and_context_window.json index 54f19419e47..47cd6faf022 100644 --- a/model_prices_and_context_window.json +++ b/model_prices_and_context_window.json @@ -1312,7 +1312,8 @@ "supports_output_config": true, "bedrock_output_config_effort_ceiling": "xhigh", "supports_parallel_tool_use_config": true, - "prompt_cache_min_tokens": 2048 + "prompt_cache_min_tokens": 2048, + "source": "https://pricing.us-east-1.amazonaws.com/offers/v1.0/aws/AmazonBedrockFoundationModels/current/index.json" }, "anthropic.claude-mythos-preview": { "input_cost_per_token": 0, @@ -1365,7 +1366,8 @@ "supports_output_config": true, "bedrock_output_config_effort_ceiling": "xhigh", "supports_parallel_tool_use_config": true, - "prompt_cache_min_tokens": 2048 + "prompt_cache_min_tokens": 2048, + "source": "https://pricing.us-east-1.amazonaws.com/offers/v1.0/aws/AmazonBedrockFoundationModels/current/index.json" }, "us.anthropic.claude-opus-4-7": { "bedrock_converse_supports_strict_tools": false, @@ -1402,7 +1404,8 @@ "supports_output_config": true, "bedrock_output_config_effort_ceiling": "xhigh", "supports_parallel_tool_use_config": true, - "prompt_cache_min_tokens": 2048 + "prompt_cache_min_tokens": 2048, + "source": "https://pricing.us-east-1.amazonaws.com/offers/v1.0/aws/AmazonBedrockFoundationModels/current/index.json" }, "eu.anthropic.claude-opus-4-7": { "bedrock_converse_supports_strict_tools": false, @@ -1513,7 +1516,8 @@ "supports_output_config": true, "bedrock_output_config_effort_ceiling": "xhigh", "supports_parallel_tool_use_config": true, - "prompt_cache_min_tokens": 512 + "prompt_cache_min_tokens": 512, + "source": "https://pricing.us-east-1.amazonaws.com/offers/v1.0/aws/AmazonBedrockFoundationModels/current/index.json" }, "anthropic.claude-fable-5-1": { "cache_creation_input_token_cost": 1.25e-05, @@ -1551,7 +1555,8 @@ "supports_output_config": true, "bedrock_output_config_effort_ceiling": "xhigh", "supports_parallel_tool_use_config": true, - "prompt_cache_min_tokens": 512 + "prompt_cache_min_tokens": 512, + "source": "https://pricing.us-east-1.amazonaws.com/offers/v1.0/aws/AmazonBedrockFoundationModels/current/index.json" }, "global.anthropic.claude-fable-5": { "cache_creation_input_token_cost": 1.25e-05, @@ -1588,7 +1593,8 @@ "supports_output_config": true, "bedrock_output_config_effort_ceiling": "xhigh", "supports_parallel_tool_use_config": true, - "prompt_cache_min_tokens": 512 + "prompt_cache_min_tokens": 512, + "source": "https://pricing.us-east-1.amazonaws.com/offers/v1.0/aws/AmazonBedrockFoundationModels/current/index.json" }, "global.anthropic.claude-fable-5-1": { "cache_creation_input_token_cost": 1.25e-05, @@ -1626,7 +1632,8 @@ "supports_output_config": true, "bedrock_output_config_effort_ceiling": "xhigh", "supports_parallel_tool_use_config": true, - "prompt_cache_min_tokens": 512 + "prompt_cache_min_tokens": 512, + "source": "https://pricing.us-east-1.amazonaws.com/offers/v1.0/aws/AmazonBedrockFoundationModels/current/index.json" }, "us.anthropic.claude-fable-5": { "cache_creation_input_token_cost": 1.375e-05, @@ -1663,7 +1670,8 @@ "supports_output_config": true, "bedrock_output_config_effort_ceiling": "xhigh", "supports_parallel_tool_use_config": true, - "prompt_cache_min_tokens": 512 + "prompt_cache_min_tokens": 512, + "source": "https://pricing.us-east-1.amazonaws.com/offers/v1.0/aws/AmazonBedrockFoundationModels/current/index.json" }, "us.anthropic.claude-fable-5-1": { "cache_creation_input_token_cost": 1.375e-05, @@ -1701,7 +1709,8 @@ "supports_output_config": true, "bedrock_output_config_effort_ceiling": "xhigh", "supports_parallel_tool_use_config": true, - "prompt_cache_min_tokens": 512 + "prompt_cache_min_tokens": 512, + "source": "https://pricing.us-east-1.amazonaws.com/offers/v1.0/aws/AmazonBedrockFoundationModels/current/index.json" }, "eu.anthropic.claude-fable-5": { "cache_creation_input_token_cost": 1.375e-05, @@ -1812,7 +1821,8 @@ "supports_max_reasoning_effort": true, "supports_output_config": true, "supports_parallel_tool_use_config": true, - "prompt_cache_min_tokens": 512 + "prompt_cache_min_tokens": 512, + "source": "https://pricing.us-east-1.amazonaws.com/offers/v1.0/aws/AmazonBedrockFoundationModels/current/index.json" }, "global.anthropic.claude-opus-5": { "bedrock_converse_supports_strict_tools": false, @@ -1848,7 +1858,8 @@ "supports_max_reasoning_effort": true, "supports_output_config": true, "supports_parallel_tool_use_config": true, - "prompt_cache_min_tokens": 512 + "prompt_cache_min_tokens": 512, + "source": "https://pricing.us-east-1.amazonaws.com/offers/v1.0/aws/AmazonBedrockFoundationModels/current/index.json" }, "us.anthropic.claude-opus-5": { "bedrock_converse_supports_strict_tools": false, @@ -1884,7 +1895,8 @@ "supports_max_reasoning_effort": true, "supports_output_config": true, "supports_parallel_tool_use_config": true, - "prompt_cache_min_tokens": 512 + "prompt_cache_min_tokens": 512, + "source": "https://pricing.us-east-1.amazonaws.com/offers/v1.0/aws/AmazonBedrockFoundationModels/current/index.json" }, "eu.anthropic.claude-opus-5": { "bedrock_converse_supports_strict_tools": false, @@ -2029,7 +2041,8 @@ "supports_output_config": true, "bedrock_output_config_effort_ceiling": "xhigh", "supports_parallel_tool_use_config": true, - "prompt_cache_min_tokens": 1024 + "prompt_cache_min_tokens": 1024, + "source": "https://pricing.us-east-1.amazonaws.com/offers/v1.0/aws/AmazonBedrockFoundationModels/current/index.json" }, "global.anthropic.claude-opus-4-8": { "bedrock_converse_supports_strict_tools": false, @@ -2066,7 +2079,8 @@ "supports_output_config": true, "bedrock_output_config_effort_ceiling": "xhigh", "supports_parallel_tool_use_config": true, - "prompt_cache_min_tokens": 1024 + "prompt_cache_min_tokens": 1024, + "source": "https://pricing.us-east-1.amazonaws.com/offers/v1.0/aws/AmazonBedrockFoundationModels/current/index.json" }, "us.anthropic.claude-opus-4-8": { "bedrock_converse_supports_strict_tools": false, @@ -2103,7 +2117,8 @@ "supports_output_config": true, "bedrock_output_config_effort_ceiling": "xhigh", "supports_parallel_tool_use_config": true, - "prompt_cache_min_tokens": 1024 + "prompt_cache_min_tokens": 1024, + "source": "https://pricing.us-east-1.amazonaws.com/offers/v1.0/aws/AmazonBedrockFoundationModels/current/index.json" }, "eu.anthropic.claude-opus-4-8": { "bedrock_converse_supports_strict_tools": false, @@ -2286,7 +2301,8 @@ "supports_output_config": true, "bedrock_output_config_effort_ceiling": "xhigh", "supports_parallel_tool_use_config": true, - "prompt_cache_min_tokens": 1024 + "prompt_cache_min_tokens": 1024, + "source": "https://pricing.us-east-1.amazonaws.com/offers/v1.0/aws/AmazonBedrockFoundationModels/current/index.json" }, "global.anthropic.claude-sonnet-5": { "bedrock_converse_supports_strict_tools": false, @@ -2323,7 +2339,8 @@ "supports_output_config": true, "bedrock_output_config_effort_ceiling": "xhigh", "supports_parallel_tool_use_config": true, - "prompt_cache_min_tokens": 1024 + "prompt_cache_min_tokens": 1024, + "source": "https://pricing.us-east-1.amazonaws.com/offers/v1.0/aws/AmazonBedrockFoundationModels/current/index.json" }, "us.anthropic.claude-sonnet-5": { "bedrock_converse_supports_strict_tools": false, @@ -2360,7 +2377,8 @@ "supports_output_config": true, "bedrock_output_config_effort_ceiling": "xhigh", "supports_parallel_tool_use_config": true, - "prompt_cache_min_tokens": 1024 + "prompt_cache_min_tokens": 1024, + "source": "https://pricing.us-east-1.amazonaws.com/offers/v1.0/aws/AmazonBedrockFoundationModels/current/index.json" }, "eu.anthropic.claude-sonnet-5": { "bedrock_converse_supports_strict_tools": false, @@ -2505,7 +2523,8 @@ "supports_native_structured_output": true, "supports_output_config": true, "supports_parallel_tool_use_config": true, - "prompt_cache_min_tokens": 1024 + "prompt_cache_min_tokens": 1024, + "source": "https://pricing.us-east-1.amazonaws.com/offers/v1.0/aws/AmazonBedrockFoundationModels/current/index.json" }, "global.anthropic.claude-sonnet-4-6": { "supports_adaptive_thinking": true, @@ -2539,7 +2558,8 @@ "supports_native_structured_output": true, "supports_output_config": true, "supports_parallel_tool_use_config": true, - "prompt_cache_min_tokens": 1024 + "prompt_cache_min_tokens": 1024, + "source": "https://pricing.us-east-1.amazonaws.com/offers/v1.0/aws/AmazonBedrockFoundationModels/current/index.json" }, "us.anthropic.claude-sonnet-4-6": { "supports_adaptive_thinking": true, @@ -2573,7 +2593,8 @@ "supports_native_structured_output": true, "supports_output_config": true, "supports_parallel_tool_use_config": true, - "prompt_cache_min_tokens": 1024 + "prompt_cache_min_tokens": 1024, + "source": "https://pricing.us-east-1.amazonaws.com/offers/v1.0/aws/AmazonBedrockFoundationModels/current/index.json" }, "eu.anthropic.claude-sonnet-4-6": { "supports_adaptive_thinking": true, @@ -44977,6 +44998,7 @@ "mode": "chat", "output_cost_per_token": 1.2e-05, "prompt_cache_min_tokens": 1024, + "source": "https://pricing.us-east-1.amazonaws.com/offers/v1.0/aws/AmazonBedrockFoundationModels/current/index.json", "supports_adaptive_thinking": true, "supports_assistant_prefill": false, "supports_computer_use": true, @@ -45009,6 +45031,7 @@ "mode": "chat", "output_cost_per_token": 3e-05, "prompt_cache_min_tokens": 1024, + "source": "https://pricing.us-east-1.amazonaws.com/offers/v1.0/aws/AmazonBedrockFoundationModels/current/index.json", "supports_adaptive_thinking": true, "supports_assistant_prefill": false, "supports_computer_use": true, @@ -45040,6 +45063,7 @@ "mode": "chat", "output_cost_per_token": 3e-05, "prompt_cache_min_tokens": 512, + "source": "https://pricing.us-east-1.amazonaws.com/offers/v1.0/aws/AmazonBedrockFoundationModels/current/index.json", "supports_adaptive_thinking": true, "supports_assistant_prefill": false, "supports_computer_use": true, @@ -45089,7 +45113,8 @@ "supports_output_config": true, "bedrock_output_config_effort_ceiling": "xhigh", "supports_parallel_tool_use_config": true, - "prompt_cache_min_tokens": 512 + "prompt_cache_min_tokens": 512, + "source": "https://pricing.us-east-1.amazonaws.com/offers/v1.0/aws/AmazonBedrockFoundationModels/current/index.json" }, "us-gov.nvidia.nemotron-nano-3-30b": { "input_cost_per_token": 7.2e-08, From 8a9a08caf0b4be092df6bc5da5759a0b699edb8e Mon Sep 17 00:00:00 2001 From: "berriai-litellm-provider-info-sync[bot]" <328147090+berriai-litellm-provider-info-sync[bot]@users.noreply.github.com> Date: Tue, 15 Sep 2026 16:31:04 +0000 Subject: [PATCH 015/133] chore(prices): sync prices for 2 providers: 14 models chatgpt-image-latest: input_cost_per_image_token_batches gemini-2.0-flash: input_cost_per_audio_token_batches gemini-2.0-flash-lite: input_cost_per_audio_token_batches gemini-2.5-flash: input_cost_per_audio_token_batches gemini-2.5-flash-lite: input_cost_per_audio_token_batches gemini-3-flash-preview: input_cost_per_audio_token_batches vertex_ai/gemini-3-flash-preview: input_cost_per_audio_token_batches gemini-3.1-flash-lite: input_cost_per_audio_token_batches vertex_ai/gemini-3.1-flash-lite: input_cost_per_audio_token_batches gpt-image-1: input_cost_per_image_token_batches gpt-image-1-mini: input_cost_per_image_token_batches gpt-image-1.5: input_cost_per_image_token_batches gpt-image-1.5-2025-12-16: input_cost_per_image_token_batches gpt-image-2: input_cost_per_image_token_batches --- ...model_prices_and_context_window_backup.json | 18 ++++++++++++++++-- model_prices_and_context_window.json | 18 ++++++++++++++++-- 2 files changed, 32 insertions(+), 4 deletions(-) diff --git a/litellm/model_prices_and_context_window_backup.json b/litellm/model_prices_and_context_window_backup.json index 47cd6faf022..e63e2e07216 100644 --- a/litellm/model_prices_and_context_window_backup.json +++ b/litellm/model_prices_and_context_window_backup.json @@ -23683,6 +23683,7 @@ "cache_read_input_token_cost": 2.5e-08, "deprecation_date": "2026-06-01", "input_cost_per_audio_token": 1e-06, + "input_cost_per_audio_token_batches": 5e-07, "input_cost_per_character": 3.75e-08, "input_cost_per_token": 1.5e-07, "input_cost_per_token_batches": 7.5e-08, @@ -23762,6 +23763,7 @@ "cache_read_input_token_cost": 1.875e-08, "deprecation_date": "2026-06-01", "input_cost_per_audio_token": 7.5e-08, + "input_cost_per_audio_token_batches": 3.75e-08, "input_cost_per_character": 1.875e-08, "input_cost_per_token": 7.5e-08, "input_cost_per_token_batches": 3.75e-08, @@ -23879,6 +23881,7 @@ "search_context_size_high": 0.035 }, "google_maps_grounding_cost_per_query": 0.025, + "input_cost_per_audio_token_batches": 5e-07, "input_cost_per_token_batches": 1.5e-07, "input_cost_per_token_flex": 1.5e-07, "input_cost_per_token_priority": 5.4e-07, @@ -24261,7 +24264,8 @@ "search_context_size_high": 0.014 }, "web_search_billing_unit": "per_query", - "google_maps_grounding_cost_per_query": 0.014 + "google_maps_grounding_cost_per_query": 0.014, + "input_cost_per_audio_token_batches": 2.5e-07 }, "gemini-3.5-flash-lite": { "deprecation_date": "2027-07-21", @@ -24403,6 +24407,7 @@ "search_context_size_high": 0.035 }, "google_maps_grounding_cost_per_query": 0.025, + "input_cost_per_audio_token_batches": 5e-08, "input_cost_per_token_batches": 5e-08, "input_cost_per_token_flex": 5e-08, "input_cost_per_token_priority": 1.8e-07, @@ -25020,6 +25025,7 @@ }, "web_search_billing_unit": "per_query", "google_maps_grounding_cost_per_query": 0.014, + "input_cost_per_audio_token_batches": 5e-07, "input_cost_per_token_batches": 2.5e-07, "input_cost_per_token_flex": 2.5e-07, "output_cost_per_token_batches": 1.5e-06, @@ -27387,6 +27393,7 @@ }, "web_search_billing_unit": "per_query", "google_maps_grounding_cost_per_query": 0.014, + "input_cost_per_audio_token_batches": 5e-07, "input_cost_per_token_batches": 2.5e-07, "input_cost_per_token_flex": 2.5e-07, "output_cost_per_token_batches": 1.5e-06, @@ -30244,6 +30251,7 @@ "mode": "image_generation", "output_cost_per_token": 1e-05, "input_cost_per_image_token": 8e-06, + "input_cost_per_image_token_batches": 4e-06, "input_cost_per_token_batches": 2.5e-06, "output_cost_per_image_token": 3.2e-05, "output_cost_per_token_batches": 5e-06, @@ -30262,6 +30270,7 @@ "mode": "image_generation", "output_cost_per_token": 1e-05, "input_cost_per_image_token": 8e-06, + "input_cost_per_image_token_batches": 4e-06, "input_cost_per_token_batches": 2.5e-06, "output_cost_per_image_token": 3.2e-05, "output_cost_per_token_batches": 5e-06, @@ -30278,6 +30287,7 @@ "litellm_provider": "openai", "mode": "image_generation", "input_cost_per_image_token": 8e-06, + "input_cost_per_image_token_batches": 4e-06, "input_cost_per_token_batches": 2.5e-06, "output_cost_per_image_token": 3e-05, "source": "https://developers.openai.com/api/docs/pricing", @@ -33054,6 +33064,7 @@ "cache_read_input_token_cost": 1.25e-06, "deprecation_date": "2026-10-23", "input_cost_per_image_token": 1e-05, + "input_cost_per_image_token_batches": 5e-06, "input_cost_per_token": 5e-06, "input_cost_per_token_batches": 2.5e-06, "litellm_provider": "openai", @@ -33069,6 +33080,7 @@ "cache_read_input_token_cost": 2e-07, "deprecation_date": "2026-12-01", "input_cost_per_image_token": 2.5e-06, + "input_cost_per_image_token_batches": 1.25e-06, "input_cost_per_token": 2e-06, "input_cost_per_token_batches": 1e-06, "litellm_provider": "openai", @@ -48246,7 +48258,8 @@ "search_context_size_high": 0.014 }, "web_search_billing_unit": "per_query", - "google_maps_grounding_cost_per_query": 0.014 + "google_maps_grounding_cost_per_query": 0.014, + "input_cost_per_audio_token_batches": 2.5e-07 }, "vertex_ai/gemini-3.5-flash-lite": { "deprecation_date": "2027-07-21", @@ -55302,6 +55315,7 @@ "cache_read_input_token_cost": 1.25e-06, "deprecation_date": "2026-12-01", "input_cost_per_image_token": 8e-06, + "input_cost_per_image_token_batches": 4e-06, "input_cost_per_token": 5e-06, "input_cost_per_token_batches": 2.5e-06, "litellm_provider": "openai", diff --git a/model_prices_and_context_window.json b/model_prices_and_context_window.json index 47cd6faf022..e63e2e07216 100644 --- a/model_prices_and_context_window.json +++ b/model_prices_and_context_window.json @@ -23683,6 +23683,7 @@ "cache_read_input_token_cost": 2.5e-08, "deprecation_date": "2026-06-01", "input_cost_per_audio_token": 1e-06, + "input_cost_per_audio_token_batches": 5e-07, "input_cost_per_character": 3.75e-08, "input_cost_per_token": 1.5e-07, "input_cost_per_token_batches": 7.5e-08, @@ -23762,6 +23763,7 @@ "cache_read_input_token_cost": 1.875e-08, "deprecation_date": "2026-06-01", "input_cost_per_audio_token": 7.5e-08, + "input_cost_per_audio_token_batches": 3.75e-08, "input_cost_per_character": 1.875e-08, "input_cost_per_token": 7.5e-08, "input_cost_per_token_batches": 3.75e-08, @@ -23879,6 +23881,7 @@ "search_context_size_high": 0.035 }, "google_maps_grounding_cost_per_query": 0.025, + "input_cost_per_audio_token_batches": 5e-07, "input_cost_per_token_batches": 1.5e-07, "input_cost_per_token_flex": 1.5e-07, "input_cost_per_token_priority": 5.4e-07, @@ -24261,7 +24264,8 @@ "search_context_size_high": 0.014 }, "web_search_billing_unit": "per_query", - "google_maps_grounding_cost_per_query": 0.014 + "google_maps_grounding_cost_per_query": 0.014, + "input_cost_per_audio_token_batches": 2.5e-07 }, "gemini-3.5-flash-lite": { "deprecation_date": "2027-07-21", @@ -24403,6 +24407,7 @@ "search_context_size_high": 0.035 }, "google_maps_grounding_cost_per_query": 0.025, + "input_cost_per_audio_token_batches": 5e-08, "input_cost_per_token_batches": 5e-08, "input_cost_per_token_flex": 5e-08, "input_cost_per_token_priority": 1.8e-07, @@ -25020,6 +25025,7 @@ }, "web_search_billing_unit": "per_query", "google_maps_grounding_cost_per_query": 0.014, + "input_cost_per_audio_token_batches": 5e-07, "input_cost_per_token_batches": 2.5e-07, "input_cost_per_token_flex": 2.5e-07, "output_cost_per_token_batches": 1.5e-06, @@ -27387,6 +27393,7 @@ }, "web_search_billing_unit": "per_query", "google_maps_grounding_cost_per_query": 0.014, + "input_cost_per_audio_token_batches": 5e-07, "input_cost_per_token_batches": 2.5e-07, "input_cost_per_token_flex": 2.5e-07, "output_cost_per_token_batches": 1.5e-06, @@ -30244,6 +30251,7 @@ "mode": "image_generation", "output_cost_per_token": 1e-05, "input_cost_per_image_token": 8e-06, + "input_cost_per_image_token_batches": 4e-06, "input_cost_per_token_batches": 2.5e-06, "output_cost_per_image_token": 3.2e-05, "output_cost_per_token_batches": 5e-06, @@ -30262,6 +30270,7 @@ "mode": "image_generation", "output_cost_per_token": 1e-05, "input_cost_per_image_token": 8e-06, + "input_cost_per_image_token_batches": 4e-06, "input_cost_per_token_batches": 2.5e-06, "output_cost_per_image_token": 3.2e-05, "output_cost_per_token_batches": 5e-06, @@ -30278,6 +30287,7 @@ "litellm_provider": "openai", "mode": "image_generation", "input_cost_per_image_token": 8e-06, + "input_cost_per_image_token_batches": 4e-06, "input_cost_per_token_batches": 2.5e-06, "output_cost_per_image_token": 3e-05, "source": "https://developers.openai.com/api/docs/pricing", @@ -33054,6 +33064,7 @@ "cache_read_input_token_cost": 1.25e-06, "deprecation_date": "2026-10-23", "input_cost_per_image_token": 1e-05, + "input_cost_per_image_token_batches": 5e-06, "input_cost_per_token": 5e-06, "input_cost_per_token_batches": 2.5e-06, "litellm_provider": "openai", @@ -33069,6 +33080,7 @@ "cache_read_input_token_cost": 2e-07, "deprecation_date": "2026-12-01", "input_cost_per_image_token": 2.5e-06, + "input_cost_per_image_token_batches": 1.25e-06, "input_cost_per_token": 2e-06, "input_cost_per_token_batches": 1e-06, "litellm_provider": "openai", @@ -48246,7 +48258,8 @@ "search_context_size_high": 0.014 }, "web_search_billing_unit": "per_query", - "google_maps_grounding_cost_per_query": 0.014 + "google_maps_grounding_cost_per_query": 0.014, + "input_cost_per_audio_token_batches": 2.5e-07 }, "vertex_ai/gemini-3.5-flash-lite": { "deprecation_date": "2027-07-21", @@ -55302,6 +55315,7 @@ "cache_read_input_token_cost": 1.25e-06, "deprecation_date": "2026-12-01", "input_cost_per_image_token": 8e-06, + "input_cost_per_image_token_batches": 4e-06, "input_cost_per_token": 5e-06, "input_cost_per_token_batches": 2.5e-06, "litellm_provider": "openai", From 68f97321dd1831dce82e9fbff926309057a24f9c Mon Sep 17 00:00:00 2001 From: kerry Date: Tue, 15 Sep 2026 16:45:03 +0000 Subject: [PATCH 016/133] chore(prices): drop source from us-gov rows to match usgov pricing test Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- litellm/model_prices_and_context_window_backup.json | 6 +----- model_prices_and_context_window.json | 6 +----- 2 files changed, 2 insertions(+), 10 deletions(-) diff --git a/litellm/model_prices_and_context_window_backup.json b/litellm/model_prices_and_context_window_backup.json index e63e2e07216..7a85c332d1a 100644 --- a/litellm/model_prices_and_context_window_backup.json +++ b/litellm/model_prices_and_context_window_backup.json @@ -45010,7 +45010,6 @@ "mode": "chat", "output_cost_per_token": 1.2e-05, "prompt_cache_min_tokens": 1024, - "source": "https://pricing.us-east-1.amazonaws.com/offers/v1.0/aws/AmazonBedrockFoundationModels/current/index.json", "supports_adaptive_thinking": true, "supports_assistant_prefill": false, "supports_computer_use": true, @@ -45043,7 +45042,6 @@ "mode": "chat", "output_cost_per_token": 3e-05, "prompt_cache_min_tokens": 1024, - "source": "https://pricing.us-east-1.amazonaws.com/offers/v1.0/aws/AmazonBedrockFoundationModels/current/index.json", "supports_adaptive_thinking": true, "supports_assistant_prefill": false, "supports_computer_use": true, @@ -45075,7 +45073,6 @@ "mode": "chat", "output_cost_per_token": 3e-05, "prompt_cache_min_tokens": 512, - "source": "https://pricing.us-east-1.amazonaws.com/offers/v1.0/aws/AmazonBedrockFoundationModels/current/index.json", "supports_adaptive_thinking": true, "supports_assistant_prefill": false, "supports_computer_use": true, @@ -45125,8 +45122,7 @@ "supports_output_config": true, "bedrock_output_config_effort_ceiling": "xhigh", "supports_parallel_tool_use_config": true, - "prompt_cache_min_tokens": 512, - "source": "https://pricing.us-east-1.amazonaws.com/offers/v1.0/aws/AmazonBedrockFoundationModels/current/index.json" + "prompt_cache_min_tokens": 512 }, "us-gov.nvidia.nemotron-nano-3-30b": { "input_cost_per_token": 7.2e-08, diff --git a/model_prices_and_context_window.json b/model_prices_and_context_window.json index e63e2e07216..7a85c332d1a 100644 --- a/model_prices_and_context_window.json +++ b/model_prices_and_context_window.json @@ -45010,7 +45010,6 @@ "mode": "chat", "output_cost_per_token": 1.2e-05, "prompt_cache_min_tokens": 1024, - "source": "https://pricing.us-east-1.amazonaws.com/offers/v1.0/aws/AmazonBedrockFoundationModels/current/index.json", "supports_adaptive_thinking": true, "supports_assistant_prefill": false, "supports_computer_use": true, @@ -45043,7 +45042,6 @@ "mode": "chat", "output_cost_per_token": 3e-05, "prompt_cache_min_tokens": 1024, - "source": "https://pricing.us-east-1.amazonaws.com/offers/v1.0/aws/AmazonBedrockFoundationModels/current/index.json", "supports_adaptive_thinking": true, "supports_assistant_prefill": false, "supports_computer_use": true, @@ -45075,7 +45073,6 @@ "mode": "chat", "output_cost_per_token": 3e-05, "prompt_cache_min_tokens": 512, - "source": "https://pricing.us-east-1.amazonaws.com/offers/v1.0/aws/AmazonBedrockFoundationModels/current/index.json", "supports_adaptive_thinking": true, "supports_assistant_prefill": false, "supports_computer_use": true, @@ -45125,8 +45122,7 @@ "supports_output_config": true, "bedrock_output_config_effort_ceiling": "xhigh", "supports_parallel_tool_use_config": true, - "prompt_cache_min_tokens": 512, - "source": "https://pricing.us-east-1.amazonaws.com/offers/v1.0/aws/AmazonBedrockFoundationModels/current/index.json" + "prompt_cache_min_tokens": 512 }, "us-gov.nvidia.nemotron-nano-3-30b": { "input_cost_per_token": 7.2e-08, From 1b69cfcf3cbf259c97d8a27bd966f6154889acfe Mon Sep 17 00:00:00 2001 From: "berriai-litellm-provider-info-sync[bot]" <328147090+berriai-litellm-provider-info-sync[bot]@users.noreply.github.com> Date: Tue, 15 Sep 2026 16:51:09 +0000 Subject: [PATCH 017/133] chore(prices): sync AWS Bedrock prices: 4 models us-gov.anthropic.claude-fable-5-1: us-gov.anthropic.claude-opus-4-8: us-gov.anthropic.claude-opus-5: us-gov.anthropic.claude-sonnet-5: --- litellm/model_prices_and_context_window_backup.json | 6 +++++- model_prices_and_context_window.json | 6 +++++- 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/litellm/model_prices_and_context_window_backup.json b/litellm/model_prices_and_context_window_backup.json index 7a85c332d1a..e63e2e07216 100644 --- a/litellm/model_prices_and_context_window_backup.json +++ b/litellm/model_prices_and_context_window_backup.json @@ -45010,6 +45010,7 @@ "mode": "chat", "output_cost_per_token": 1.2e-05, "prompt_cache_min_tokens": 1024, + "source": "https://pricing.us-east-1.amazonaws.com/offers/v1.0/aws/AmazonBedrockFoundationModels/current/index.json", "supports_adaptive_thinking": true, "supports_assistant_prefill": false, "supports_computer_use": true, @@ -45042,6 +45043,7 @@ "mode": "chat", "output_cost_per_token": 3e-05, "prompt_cache_min_tokens": 1024, + "source": "https://pricing.us-east-1.amazonaws.com/offers/v1.0/aws/AmazonBedrockFoundationModels/current/index.json", "supports_adaptive_thinking": true, "supports_assistant_prefill": false, "supports_computer_use": true, @@ -45073,6 +45075,7 @@ "mode": "chat", "output_cost_per_token": 3e-05, "prompt_cache_min_tokens": 512, + "source": "https://pricing.us-east-1.amazonaws.com/offers/v1.0/aws/AmazonBedrockFoundationModels/current/index.json", "supports_adaptive_thinking": true, "supports_assistant_prefill": false, "supports_computer_use": true, @@ -45122,7 +45125,8 @@ "supports_output_config": true, "bedrock_output_config_effort_ceiling": "xhigh", "supports_parallel_tool_use_config": true, - "prompt_cache_min_tokens": 512 + "prompt_cache_min_tokens": 512, + "source": "https://pricing.us-east-1.amazonaws.com/offers/v1.0/aws/AmazonBedrockFoundationModels/current/index.json" }, "us-gov.nvidia.nemotron-nano-3-30b": { "input_cost_per_token": 7.2e-08, diff --git a/model_prices_and_context_window.json b/model_prices_and_context_window.json index 7a85c332d1a..e63e2e07216 100644 --- a/model_prices_and_context_window.json +++ b/model_prices_and_context_window.json @@ -45010,6 +45010,7 @@ "mode": "chat", "output_cost_per_token": 1.2e-05, "prompt_cache_min_tokens": 1024, + "source": "https://pricing.us-east-1.amazonaws.com/offers/v1.0/aws/AmazonBedrockFoundationModels/current/index.json", "supports_adaptive_thinking": true, "supports_assistant_prefill": false, "supports_computer_use": true, @@ -45042,6 +45043,7 @@ "mode": "chat", "output_cost_per_token": 3e-05, "prompt_cache_min_tokens": 1024, + "source": "https://pricing.us-east-1.amazonaws.com/offers/v1.0/aws/AmazonBedrockFoundationModels/current/index.json", "supports_adaptive_thinking": true, "supports_assistant_prefill": false, "supports_computer_use": true, @@ -45073,6 +45075,7 @@ "mode": "chat", "output_cost_per_token": 3e-05, "prompt_cache_min_tokens": 512, + "source": "https://pricing.us-east-1.amazonaws.com/offers/v1.0/aws/AmazonBedrockFoundationModels/current/index.json", "supports_adaptive_thinking": true, "supports_assistant_prefill": false, "supports_computer_use": true, @@ -45122,7 +45125,8 @@ "supports_output_config": true, "bedrock_output_config_effort_ceiling": "xhigh", "supports_parallel_tool_use_config": true, - "prompt_cache_min_tokens": 512 + "prompt_cache_min_tokens": 512, + "source": "https://pricing.us-east-1.amazonaws.com/offers/v1.0/aws/AmazonBedrockFoundationModels/current/index.json" }, "us-gov.nvidia.nemotron-nano-3-30b": { "input_cost_per_token": 7.2e-08, From a8dd1394f941cf930107f3f617676ae7c10f2705 Mon Sep 17 00:00:00 2001 From: kerry Date: Tue, 15 Sep 2026 17:04:09 +0000 Subject: [PATCH 018/133] chore(prices): drop source from us-gov rows again Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- litellm/model_prices_and_context_window_backup.json | 6 +----- model_prices_and_context_window.json | 6 +----- 2 files changed, 2 insertions(+), 10 deletions(-) diff --git a/litellm/model_prices_and_context_window_backup.json b/litellm/model_prices_and_context_window_backup.json index e63e2e07216..7a85c332d1a 100644 --- a/litellm/model_prices_and_context_window_backup.json +++ b/litellm/model_prices_and_context_window_backup.json @@ -45010,7 +45010,6 @@ "mode": "chat", "output_cost_per_token": 1.2e-05, "prompt_cache_min_tokens": 1024, - "source": "https://pricing.us-east-1.amazonaws.com/offers/v1.0/aws/AmazonBedrockFoundationModels/current/index.json", "supports_adaptive_thinking": true, "supports_assistant_prefill": false, "supports_computer_use": true, @@ -45043,7 +45042,6 @@ "mode": "chat", "output_cost_per_token": 3e-05, "prompt_cache_min_tokens": 1024, - "source": "https://pricing.us-east-1.amazonaws.com/offers/v1.0/aws/AmazonBedrockFoundationModels/current/index.json", "supports_adaptive_thinking": true, "supports_assistant_prefill": false, "supports_computer_use": true, @@ -45075,7 +45073,6 @@ "mode": "chat", "output_cost_per_token": 3e-05, "prompt_cache_min_tokens": 512, - "source": "https://pricing.us-east-1.amazonaws.com/offers/v1.0/aws/AmazonBedrockFoundationModels/current/index.json", "supports_adaptive_thinking": true, "supports_assistant_prefill": false, "supports_computer_use": true, @@ -45125,8 +45122,7 @@ "supports_output_config": true, "bedrock_output_config_effort_ceiling": "xhigh", "supports_parallel_tool_use_config": true, - "prompt_cache_min_tokens": 512, - "source": "https://pricing.us-east-1.amazonaws.com/offers/v1.0/aws/AmazonBedrockFoundationModels/current/index.json" + "prompt_cache_min_tokens": 512 }, "us-gov.nvidia.nemotron-nano-3-30b": { "input_cost_per_token": 7.2e-08, diff --git a/model_prices_and_context_window.json b/model_prices_and_context_window.json index e63e2e07216..7a85c332d1a 100644 --- a/model_prices_and_context_window.json +++ b/model_prices_and_context_window.json @@ -45010,7 +45010,6 @@ "mode": "chat", "output_cost_per_token": 1.2e-05, "prompt_cache_min_tokens": 1024, - "source": "https://pricing.us-east-1.amazonaws.com/offers/v1.0/aws/AmazonBedrockFoundationModels/current/index.json", "supports_adaptive_thinking": true, "supports_assistant_prefill": false, "supports_computer_use": true, @@ -45043,7 +45042,6 @@ "mode": "chat", "output_cost_per_token": 3e-05, "prompt_cache_min_tokens": 1024, - "source": "https://pricing.us-east-1.amazonaws.com/offers/v1.0/aws/AmazonBedrockFoundationModels/current/index.json", "supports_adaptive_thinking": true, "supports_assistant_prefill": false, "supports_computer_use": true, @@ -45075,7 +45073,6 @@ "mode": "chat", "output_cost_per_token": 3e-05, "prompt_cache_min_tokens": 512, - "source": "https://pricing.us-east-1.amazonaws.com/offers/v1.0/aws/AmazonBedrockFoundationModels/current/index.json", "supports_adaptive_thinking": true, "supports_assistant_prefill": false, "supports_computer_use": true, @@ -45125,8 +45122,7 @@ "supports_output_config": true, "bedrock_output_config_effort_ceiling": "xhigh", "supports_parallel_tool_use_config": true, - "prompt_cache_min_tokens": 512, - "source": "https://pricing.us-east-1.amazonaws.com/offers/v1.0/aws/AmazonBedrockFoundationModels/current/index.json" + "prompt_cache_min_tokens": 512 }, "us-gov.nvidia.nemotron-nano-3-30b": { "input_cost_per_token": 7.2e-08, From 363b7835a3cc17310c5910a0e74784fd839322ab Mon Sep 17 00:00:00 2001 From: "berriai-litellm-provider-info-sync[bot]" <328147090+berriai-litellm-provider-info-sync[bot]@users.noreply.github.com> Date: Tue, 15 Sep 2026 17:06:11 +0000 Subject: [PATCH 019/133] chore(prices): sync AWS Bedrock prices: 4 models us-gov.anthropic.claude-fable-5-1: us-gov.anthropic.claude-opus-4-8: us-gov.anthropic.claude-opus-5: us-gov.anthropic.claude-sonnet-5: --- litellm/model_prices_and_context_window_backup.json | 6 +++++- model_prices_and_context_window.json | 6 +++++- 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/litellm/model_prices_and_context_window_backup.json b/litellm/model_prices_and_context_window_backup.json index 7a85c332d1a..e63e2e07216 100644 --- a/litellm/model_prices_and_context_window_backup.json +++ b/litellm/model_prices_and_context_window_backup.json @@ -45010,6 +45010,7 @@ "mode": "chat", "output_cost_per_token": 1.2e-05, "prompt_cache_min_tokens": 1024, + "source": "https://pricing.us-east-1.amazonaws.com/offers/v1.0/aws/AmazonBedrockFoundationModels/current/index.json", "supports_adaptive_thinking": true, "supports_assistant_prefill": false, "supports_computer_use": true, @@ -45042,6 +45043,7 @@ "mode": "chat", "output_cost_per_token": 3e-05, "prompt_cache_min_tokens": 1024, + "source": "https://pricing.us-east-1.amazonaws.com/offers/v1.0/aws/AmazonBedrockFoundationModels/current/index.json", "supports_adaptive_thinking": true, "supports_assistant_prefill": false, "supports_computer_use": true, @@ -45073,6 +45075,7 @@ "mode": "chat", "output_cost_per_token": 3e-05, "prompt_cache_min_tokens": 512, + "source": "https://pricing.us-east-1.amazonaws.com/offers/v1.0/aws/AmazonBedrockFoundationModels/current/index.json", "supports_adaptive_thinking": true, "supports_assistant_prefill": false, "supports_computer_use": true, @@ -45122,7 +45125,8 @@ "supports_output_config": true, "bedrock_output_config_effort_ceiling": "xhigh", "supports_parallel_tool_use_config": true, - "prompt_cache_min_tokens": 512 + "prompt_cache_min_tokens": 512, + "source": "https://pricing.us-east-1.amazonaws.com/offers/v1.0/aws/AmazonBedrockFoundationModels/current/index.json" }, "us-gov.nvidia.nemotron-nano-3-30b": { "input_cost_per_token": 7.2e-08, diff --git a/model_prices_and_context_window.json b/model_prices_and_context_window.json index 7a85c332d1a..e63e2e07216 100644 --- a/model_prices_and_context_window.json +++ b/model_prices_and_context_window.json @@ -45010,6 +45010,7 @@ "mode": "chat", "output_cost_per_token": 1.2e-05, "prompt_cache_min_tokens": 1024, + "source": "https://pricing.us-east-1.amazonaws.com/offers/v1.0/aws/AmazonBedrockFoundationModels/current/index.json", "supports_adaptive_thinking": true, "supports_assistant_prefill": false, "supports_computer_use": true, @@ -45042,6 +45043,7 @@ "mode": "chat", "output_cost_per_token": 3e-05, "prompt_cache_min_tokens": 1024, + "source": "https://pricing.us-east-1.amazonaws.com/offers/v1.0/aws/AmazonBedrockFoundationModels/current/index.json", "supports_adaptive_thinking": true, "supports_assistant_prefill": false, "supports_computer_use": true, @@ -45073,6 +45075,7 @@ "mode": "chat", "output_cost_per_token": 3e-05, "prompt_cache_min_tokens": 512, + "source": "https://pricing.us-east-1.amazonaws.com/offers/v1.0/aws/AmazonBedrockFoundationModels/current/index.json", "supports_adaptive_thinking": true, "supports_assistant_prefill": false, "supports_computer_use": true, @@ -45122,7 +45125,8 @@ "supports_output_config": true, "bedrock_output_config_effort_ceiling": "xhigh", "supports_parallel_tool_use_config": true, - "prompt_cache_min_tokens": 512 + "prompt_cache_min_tokens": 512, + "source": "https://pricing.us-east-1.amazonaws.com/offers/v1.0/aws/AmazonBedrockFoundationModels/current/index.json" }, "us-gov.nvidia.nemotron-nano-3-30b": { "input_cost_per_token": 7.2e-08, From a426df108af97e0ea353c34a3b1098353bbc8a72 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Tue, 15 Sep 2026 17:54:03 +0000 Subject: [PATCH 020/133] feat(http): opt-in outbound HTTP/2 for httpx clients Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- litellm/__init__.py | 1 + litellm/llms/custom_httpx/http_handler.py | 22 +++- litellm/llms/openai/common_utils.py | 3 + pyproject.toml | 2 +- tests/test_litellm/llms/conftest.py | 124 ++++++++++++++++++ .../llms/custom_httpx/test_http_handler.py | 68 ++++++++++ .../llms/openai/test_openai_common_utils.py | 17 +++ uv.lock | 9 +- 8 files changed, 239 insertions(+), 7 deletions(-) create mode 100644 tests/test_litellm/llms/conftest.py diff --git a/litellm/__init__.py b/litellm/__init__.py index ccfbf80369f..5e4a76503cd 100644 --- a/litellm/__init__.py +++ b/litellm/__init__.py @@ -524,6 +524,7 @@ aiohttp_trust_env: bool = False # set to true to use HTTP_ Proxy settings disable_aiohttp_transport: bool = False # Set this to true to use httpx instead disable_aiohttp_trust_env: bool = False # When False, aiohttp will respect HTTP(S)_PROXY env vars force_ipv4: bool = False # when True, litellm will force ipv4 for all LLM requests. Some users have seen httpx ConnectionError when using ipv6. +http2: bool = False # when True, LiteLLM-built httpx clients negotiate HTTP/2 over TLS (falls back to HTTP/1.1); bypasses aiohttp transport network_mock: bool = False # When True, use mock transport — no real network calls ####### STOP SEQUENCE LIMIT ####### diff --git a/litellm/llms/custom_httpx/http_handler.py b/litellm/llms/custom_httpx/http_handler.py index f4883b57fbc..cc37d1d9eca 100644 --- a/litellm/llms/custom_httpx/http_handler.py +++ b/litellm/llms/custom_httpx/http_handler.py @@ -74,6 +74,12 @@ _IPV4_LOCAL_ADDRESS: Final = "0.0.0.0" _HttpxTransportT = TypeVar("_HttpxTransportT", HTTPTransport, AsyncHTTPTransport) +def http2_enabled() -> bool: + from litellm.secret_managers.main import str_to_bool + + return litellm.http2 is True or str_to_bool(os.getenv("LITELLM_HTTP2", "False")) is True + + def _environment_proxy_mounts( build_proxy_transport: Callable[[str], _HttpxTransportT], ) -> Mapping[str, _HttpxTransportT | None]: @@ -638,6 +644,7 @@ class AsyncHTTPHandler: headers=default_headers, cookies=blocked_cookie_jar(), follow_redirects=True, + http2=http2_enabled(), ) async def close(self): @@ -1157,6 +1164,10 @@ class AsyncHTTPHandler: from litellm.secret_managers.main import str_to_bool + if http2_enabled(): + verbose_logger.debug("LITELLM_HTTP2 enabled, using httpx transport (aiohttp has no HTTP/2 support)") + return False + ######################################################### # Check if user disabled aiohttp transport ######################################################## @@ -1287,7 +1298,7 @@ class AsyncHTTPHandler: - [Default] If force_ipv4 is False, it will return None """ if litellm.force_ipv4: - return AsyncHTTPTransport(local_address=_IPV4_LOCAL_ADDRESS) + return AsyncHTTPTransport(local_address=_IPV4_LOCAL_ADDRESS, http2=http2_enabled()) else: return None @@ -1300,7 +1311,7 @@ class AsyncHTTPHandler: if not isinstance(transport, AsyncHTTPTransport): return None return _environment_proxy_mounts( - lambda proxy_url: AsyncHTTPTransport(proxy=proxy_url, verify=verify, cert=cert) + lambda proxy_url: AsyncHTTPTransport(proxy=proxy_url, verify=verify, cert=cert, http2=http2_enabled()) ) @@ -1342,6 +1353,7 @@ class HTTPHandler: headers=default_headers, cookies=blocked_cookie_jar(), follow_redirects=True, + http2=http2_enabled(), ) @property @@ -1616,7 +1628,7 @@ class HTTPHandler: Some users have seen httpx ConnectionError when using ipv6 - forcing ipv4 resolves the issue for them """ if litellm.force_ipv4: - return HTTPTransport(local_address=_IPV4_LOCAL_ADDRESS) + return HTTPTransport(local_address=_IPV4_LOCAL_ADDRESS, http2=http2_enabled()) else: return getattr(litellm, "sync_transport", None) @@ -1627,7 +1639,9 @@ class HTTPHandler: ) -> Mapping[str, HTTPTransport | None] | None: if not litellm.force_ipv4: return None - return _environment_proxy_mounts(lambda proxy_url: HTTPTransport(proxy=proxy_url, verify=verify, cert=cert)) + return _environment_proxy_mounts( + lambda proxy_url: HTTPTransport(proxy=proxy_url, verify=verify, cert=cert, http2=http2_enabled()) + ) def get_async_httpx_client( diff --git a/litellm/llms/openai/common_utils.py b/litellm/llms/openai/common_utils.py index 2db6d78a218..cb6a5e4e96a 100644 --- a/litellm/llms/openai/common_utils.py +++ b/litellm/llms/openai/common_utils.py @@ -32,6 +32,7 @@ from litellm.llms.custom_httpx.http_handler import ( _DEFAULT_TTL_FOR_HTTPX_CLIENTS, AsyncHTTPHandler, get_ssl_configuration, + http2_enabled, ) @@ -325,6 +326,7 @@ class BaseOpenAILLM: transport=transport, mounts=AsyncHTTPHandler._create_httpx_proxy_mounts(transport, verify=ssl_config, cert=None), follow_redirects=True, + http2=http2_enabled(), ) @staticmethod @@ -343,6 +345,7 @@ class BaseOpenAILLM: return httpx.Client( verify=ssl_config, follow_redirects=True, + http2=http2_enabled(), ) diff --git a/pyproject.toml b/pyproject.toml index 62ce4b4fd61..de6a6e9c9ba 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -15,7 +15,7 @@ dependencies = [ # When changing a floor, verify it installs + imports on every supported # Python with: `uv pip install --resolution=lowest-direct .` "fastuuid>=0.14.0,<1.0", - "httpx>=0.28.0,<1.0", + "httpx[http2]>=0.28.0,<1.0", "openai>=2.20.0,<3.0.0", "python-dotenv>=1.0.0,<2.0", "tiktoken>=0.8.0,<1.0", diff --git a/tests/test_litellm/llms/conftest.py b/tests/test_litellm/llms/conftest.py new file mode 100644 index 00000000000..2905b606e51 --- /dev/null +++ b/tests/test_litellm/llms/conftest.py @@ -0,0 +1,124 @@ +import asyncio +import datetime +import ipaddress +import socket +import threading +import time + +import pytest + + +def _write_self_signed_cert(cert_dir): + from cryptography import x509 + from cryptography.hazmat.primitives import hashes, serialization + from cryptography.hazmat.primitives.asymmetric import rsa + + key = rsa.generate_private_key(public_exponent=65537, key_size=2048) + now = datetime.datetime.now(datetime.timezone.utc) + cert = ( + x509.CertificateBuilder() + .subject_name(x509.Name([x509.NameAttribute(x509.oid.NameOID.COMMON_NAME, "localhost")])) + .issuer_name(x509.Name([x509.NameAttribute(x509.oid.NameOID.COMMON_NAME, "localhost")])) + .public_key(key.public_key()) + .serial_number(x509.random_serial_number()) + .not_valid_before(now - datetime.timedelta(days=1)) + .not_valid_after(now + datetime.timedelta(days=7)) + .add_extension( + x509.SubjectAlternativeName( + [x509.DNSName("localhost"), x509.IPAddress(ipaddress.ip_address("127.0.0.1"))] + ), + critical=False, + ) + .sign(key, hashes.SHA256()) + ) + cert_file = cert_dir / "cert.pem" + key_file = cert_dir / "key.pem" + cert_file.write_bytes(cert.public_bytes(serialization.Encoding.PEM)) + key_file.write_bytes( + key.private_bytes( + serialization.Encoding.PEM, + serialization.PrivateFormat.TraditionalOpenSSL, + serialization.NoEncryption(), + ) + ) + return cert_file, key_file + + +async def _asgi_app(scope, receive, send): + if scope["type"] != "http": + return + while True: + message = await receive() + if message["type"] == "http.request" and not message.get("more_body"): + break + if message["type"] == "http.disconnect": + return + if scope["path"] == "/stream": + await send( + { + "type": "http.response.start", + "status": 200, + "headers": [(b"content-type", b"text/event-stream")], + } + ) + for index in range(3): + await send( + { + "type": "http.response.body", + "body": f"data: chunk-{index}\n\n".encode(), + "more_body": True, + } + ) + await send({"type": "http.response.body", "body": b"", "more_body": False}) + return + await send( + { + "type": "http.response.start", + "status": 200, + "headers": [(b"content-type", b"application/json")], + } + ) + await send({"type": "http.response.body", "body": b'{"ok": true}'}) + + +@pytest.fixture(scope="module") +def http2_tls_server(tmp_path_factory): + """Hypercorn TLS server on an ephemeral port that negotiates h2 or http/1.1 via ALPN.""" + from hypercorn.asyncio import serve + from hypercorn.config import Config + + cert_dir = tmp_path_factory.mktemp("h2certs") + cert_file, key_file = _write_self_signed_cert(cert_dir) + + with socket.socket() as sock: + sock.bind(("127.0.0.1", 0)) + port = sock.getsockname()[1] + + shutdown = threading.Event() + + def _serve() -> None: + loop = asyncio.new_event_loop() + config = Config() + config.bind = [f"127.0.0.1:{port}"] + config.certfile = str(cert_file) + config.keyfile = str(key_file) + config.alpn_protocols = ["h2", "http/1.1"] + loop.run_until_complete(serve(_asgi_app, config, shutdown_trigger=lambda: asyncio.to_thread(shutdown.wait))) + loop.close() + + thread = threading.Thread(target=_serve, daemon=True) + thread.start() + + for _ in range(100): + try: + with socket.create_connection(("127.0.0.1", port), timeout=0.2): + break + except OSError: + time.sleep(0.05) + else: + pytest.fail("hypercorn test server did not start") + + yield f"https://127.0.0.1:{port}" + + shutdown.set() + thread.join(timeout=10) diff --git a/tests/test_litellm/llms/custom_httpx/test_http_handler.py b/tests/test_litellm/llms/custom_httpx/test_http_handler.py index f8868cfaf83..e170a7f7a78 100644 --- a/tests/test_litellm/llms/custom_httpx/test_http_handler.py +++ b/tests/test_litellm/llms/custom_httpx/test_http_handler.py @@ -1675,3 +1675,71 @@ async def test_bounded_get_closes_stream_on_cancellation(respx_mock, monkeypatch finally: await handler.close() assert closed.is_set() + + +@pytest.mark.asyncio +async def test_http2_flag_bypasses_aiohttp_transport(monkeypatch: pytest.MonkeyPatch): + monkeypatch.setattr(litellm, "disable_aiohttp_transport", False) + monkeypatch.setattr(litellm, "force_ipv4", False) + monkeypatch.delenv("LITELLM_HTTP2", raising=False) + monkeypatch.delenv("DISABLE_AIOHTTP_TRANSPORT", raising=False) + + monkeypatch.setattr(litellm, "http2", True) + assert AsyncHTTPHandler._should_use_aiohttp_transport() is False + assert AsyncHTTPHandler._create_async_transport() is None + + monkeypatch.setattr(litellm, "http2", False) + monkeypatch.setenv("LITELLM_HTTP2", "True") + assert AsyncHTTPHandler._should_use_aiohttp_transport() is False + assert AsyncHTTPHandler._create_async_transport() is None + + +@pytest.mark.asyncio +async def test_http2_disabled_by_default(monkeypatch: pytest.MonkeyPatch): + monkeypatch.setattr(litellm, "http2", False) + monkeypatch.delenv("LITELLM_HTTP2", raising=False) + monkeypatch.delenv("DISABLE_AIOHTTP_TRANSPORT", raising=False) + monkeypatch.setattr(litellm, "disable_aiohttp_transport", False) + + assert AsyncHTTPHandler._should_use_aiohttp_transport() is True + + +@pytest.mark.asyncio +@pytest.mark.parametrize("use_http2", [True, False]) +@pytest.mark.parametrize("handler_kind", ["async", "sync"]) +async def test_http_version_negotiated_over_tls(monkeypatch, http2_tls_server, handler_kind, use_http2): + monkeypatch.setattr(litellm, "http2", use_http2) + monkeypatch.delenv("LITELLM_HTTP2", raising=False) + monkeypatch.setattr(litellm, "disable_aiohttp_transport", True) + monkeypatch.setattr(litellm, "force_ipv4", False) + + expected_version = "HTTP/2" if use_http2 else "HTTP/1.1" + + if handler_kind == "async": + handler = AsyncHTTPHandler(ssl_verify=False) + try: + response = await handler.post(f"{http2_tls_server}/echo", json={"ping": "pong"}) + assert response.status_code == 200 + assert response.http_version == expected_version + + stream_response = await handler.post(f"{http2_tls_server}/stream", stream=True) + assert stream_response.http_version == expected_version + chunks = [chunk async for chunk in stream_response.aiter_bytes()] + assert chunks + await stream_response.aclose() + finally: + await handler.close() + else: + handler = HTTPHandler(ssl_verify=False) + try: + response = handler.post(f"{http2_tls_server}/echo", json={"ping": "pong"}) + assert response.status_code == 200 + assert response.http_version == expected_version + + stream_response = handler.post(f"{http2_tls_server}/stream", stream=True) + assert stream_response.http_version == expected_version + chunks = list(stream_response.iter_bytes()) + assert chunks + stream_response.close() + finally: + handler.close() diff --git a/tests/test_litellm/llms/openai/test_openai_common_utils.py b/tests/test_litellm/llms/openai/test_openai_common_utils.py index d3c21c5bd5a..3dc37c21e7a 100644 --- a/tests/test_litellm/llms/openai/test_openai_common_utils.py +++ b/tests/test_litellm/llms/openai/test_openai_common_utils.py @@ -411,3 +411,20 @@ async def test_async_genuine_bad_request_still_raises(provider, stream): ) def test_is_openai_backed_api_base_decides_by_hostname_only(api_base, expected): assert is_openai_backed_api_base(api_base) is expected + + +def test_litellm_built_http_clients_negotiate_http2_only_when_enabled(monkeypatch): + monkeypatch.delenv("LITELLM_HTTP2", raising=False) + monkeypatch.setattr(litellm, "disable_aiohttp_transport", True) + + monkeypatch.setattr(litellm, "http2", False) + async_client = BaseOpenAILLM._get_async_http_client() + sync_client = BaseOpenAILLM._get_sync_http_client() + assert async_client is not None and async_client._transport._pool._http2 is False + assert sync_client is not None and sync_client._transport._pool._http2 is False + + monkeypatch.setattr(litellm, "http2", True) + async_client = BaseOpenAILLM._get_async_http_client() + sync_client = BaseOpenAILLM._get_sync_http_client() + assert async_client is not None and async_client._transport._pool._http2 is True + assert sync_client is not None and sync_client._transport._pool._http2 is True diff --git a/uv.lock b/uv.lock index eb4cdef76f1..4917dd2f5cb 100644 --- a/uv.lock +++ b/uv.lock @@ -3295,6 +3295,11 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" }, ] +[package.optional-dependencies] +http2 = [ + { name = "h2" }, +] + [[package]] name = "httpx-sse" version = "0.4.3" @@ -4365,7 +4370,7 @@ dependencies = [ { name = "boto3" }, { name = "click" }, { name = "fastuuid" }, - { name = "httpx" }, + { name = "httpx", extra = ["http2"] }, { name = "importlib-metadata" }, { name = "jinja2" }, { name = "jsonschema" }, @@ -4617,7 +4622,7 @@ requires-dist = [ { name = "grpcio", marker = "extra == 'proxy-runtime'", specifier = "==1.78.0" }, { name = "gunicorn", marker = "extra == 'proxy'", specifier = ">=23.0.0,<24.0" }, { name = "hiredis", marker = "extra == 'proxy'", specifier = ">=3.0.0,<4.0" }, - { name = "httpx", specifier = ">=0.28.0,<1.0" }, + { name = "httpx", extras = ["http2"], specifier = ">=0.28.0,<1.0" }, { name = "importlib-metadata", specifier = ">=8.0.0,<9.0" }, { name = "inquirerpy", marker = "extra == 'cli'", specifier = ">=0.3.4,<1.0" }, { name = "inquirerpy", marker = "extra == 'proxy'", specifier = ">=0.3.4,<1.0" }, From fd2fb4c44e5d62f2fab08e748b46d8dd12a4792f Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Tue, 15 Sep 2026 18:25:26 +0000 Subject: [PATCH 021/133] fix(http): address review on outbound HTTP/2 Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- litellm/__init__.py | 2 +- litellm/main.py | 6 +- .../test_outbound_http2_e2e.py | 208 ++++++++++++++++++ tests/test_litellm/llms/conftest.py | 124 ----------- .../llms/custom_httpx/test_http_handler.py | 41 ---- .../llms/openai/test_openai_common_utils.py | 15 -- tests/test_litellm/test_main.py | 127 +++++------ 7 files changed, 269 insertions(+), 254 deletions(-) create mode 100644 tests/e2e/llm_translation/test_outbound_http2_e2e.py delete mode 100644 tests/test_litellm/llms/conftest.py diff --git a/litellm/__init__.py b/litellm/__init__.py index 5e4a76503cd..56841ac0010 100644 --- a/litellm/__init__.py +++ b/litellm/__init__.py @@ -524,7 +524,7 @@ aiohttp_trust_env: bool = False # set to true to use HTTP_ Proxy settings disable_aiohttp_transport: bool = False # Set this to true to use httpx instead disable_aiohttp_trust_env: bool = False # When False, aiohttp will respect HTTP(S)_PROXY env vars force_ipv4: bool = False # when True, litellm will force ipv4 for all LLM requests. Some users have seen httpx ConnectionError when using ipv6. -http2: bool = False # when True, LiteLLM-built httpx clients negotiate HTTP/2 over TLS (falls back to HTTP/1.1); bypasses aiohttp transport +http2: bool = False network_mock: bool = False # When True, use mock transport — no real network calls ####### STOP SEQUENCE LIMIT ####### diff --git a/litellm/main.py b/litellm/main.py index f6f4ec1bf63..3b0b553e6e5 100644 --- a/litellm/main.py +++ b/litellm/main.py @@ -105,7 +105,7 @@ from litellm.llms.base_llm.base_model_iterator import ( ) from litellm.llms.bedrock.common_utils import BedrockModelInfo from litellm.llms.cohere.common_utils import CohereModelInfo -from litellm.llms.custom_httpx.http_handler import AsyncHTTPHandler, HTTPHandler +from litellm.llms.custom_httpx.http_handler import AsyncHTTPHandler, HTTPHandler, http2_enabled from litellm.llms.openai.chat.gpt_5_transformation import OpenAIGPT5Config from litellm.llms.openai_like.json_loader import JSONProviderRegistry from litellm.llms.vertex_ai.common_utils import ( @@ -2341,6 +2341,10 @@ def _complete_sap(ctx: _CompletionDispatchContext) -> _CompletionDispatchResult: def _complete_aiohttp_openai( ctx: _CompletionDispatchContext, ) -> _CompletionDispatchResult: + if http2_enabled(): + verbose_logger.warning( + "litellm.http2 is enabled but aiohttp_openai/ always uses aiohttp, which has no HTTP/2 client; this request stays on HTTP/1.1" + ) acompletion: Final = ctx.acompletion api_base = ctx.api_base api_key = ctx.api_key diff --git a/tests/e2e/llm_translation/test_outbound_http2_e2e.py b/tests/e2e/llm_translation/test_outbound_http2_e2e.py new file mode 100644 index 00000000000..cb2182ffd62 --- /dev/null +++ b/tests/e2e/llm_translation/test_outbound_http2_e2e.py @@ -0,0 +1,208 @@ +"""Outbound HTTP/2 negotiation for LiteLLM-built httpx clients. + +Spins up a local hypercorn TLS server that offers h2 and http/1.1 over ALPN and +drives the real AsyncHTTPHandler / HTTPHandler at it, so the negotiated protocol +on the wire is the assertion. No running proxy or provider credentials needed, +which is why these tests carry no `e2e` marker (same shape as the markerless +harness checks under tests/e2e/load/). +""" + +from __future__ import annotations + +import asyncio +import datetime +import ipaddress +import socket +import threading +import time +from collections.abc import Iterator +from pathlib import Path +from typing import Final, cast + +import pytest +from cryptography import x509 +from cryptography.hazmat.primitives import hashes, serialization +from cryptography.hazmat.primitives.asymmetric import rsa +from cryptography.x509.oid import NameOID +from hypercorn.asyncio import ( + serve, # pyright: ignore[reportUnknownVariableType] # hypercorn's serve signature passes through untyped worker hooks +) +from hypercorn.config import Config +from hypercorn.typing import ( + ASGIReceiveCallable, + ASGISendCallable, + HTTPResponseBodyEvent, + HTTPResponseStartEvent, + Scope, +) + +import litellm +from litellm.llms.custom_httpx.http_handler import AsyncHTTPHandler, HTTPHandler + + +def _write_self_signed_cert(cert_dir: Path) -> tuple[Path, Path]: + key: Final = rsa.generate_private_key(public_exponent=65537, key_size=2048) + now: Final = datetime.datetime.now(datetime.timezone.utc) + name: Final = x509.Name([x509.NameAttribute(NameOID.COMMON_NAME, "localhost")]) + cert: Final = ( + x509.CertificateBuilder() + .subject_name(name) + .issuer_name(name) + .public_key(key.public_key()) + .serial_number(x509.random_serial_number()) + .not_valid_before(now - datetime.timedelta(days=1)) + .not_valid_after(now + datetime.timedelta(days=7)) + .add_extension( + x509.SubjectAlternativeName([x509.DNSName("localhost"), x509.IPAddress(ipaddress.ip_address("127.0.0.1"))]), + critical=False, + ) + .sign(key, hashes.SHA256()) + ) + cert_file: Final = cert_dir / "cert.pem" + key_file: Final = cert_dir / "key.pem" + cert_file.write_bytes(cert.public_bytes(serialization.Encoding.PEM)) + key_file.write_bytes( + key.private_bytes( + serialization.Encoding.PEM, + serialization.PrivateFormat.TraditionalOpenSSL, + serialization.NoEncryption(), + ) + ) + return cert_file, key_file + + +async def _asgi_app(scope: Scope, receive: ASGIReceiveCallable, send: ASGISendCallable) -> None: + if scope["type"] != "http": + return + while True: + message = await receive() + if message["type"] == "http.disconnect": + return + if message["type"] == "http.request" and not message["more_body"]: + break + if scope["path"] == "/stream": + await send( + HTTPResponseStartEvent( + type="http.response.start", status=200, headers=[(b"content-type", b"text/event-stream")] + ) + ) + for index in range(3): + await send( + HTTPResponseBodyEvent( + type="http.response.body", body=f"data: chunk-{index}\n\n".encode(), more_body=True + ) + ) + await send(HTTPResponseBodyEvent(type="http.response.body", body=b"", more_body=False)) + return + await send( + HTTPResponseStartEvent(type="http.response.start", status=200, headers=[(b"content-type", b"application/json")]) + ) + await send(HTTPResponseBodyEvent(type="http.response.body", body=b'{"ok": true}', more_body=False)) + + +@pytest.fixture(scope="module") +def http2_tls_server(tmp_path_factory: pytest.TempPathFactory) -> Iterator[str]: + cert_dir: Final = tmp_path_factory.mktemp("h2certs") + cert_file, key_file = _write_self_signed_cert(cert_dir) + + with socket.socket() as sock: + sock.bind(("127.0.0.1", 0)) + port: Final = cast(int, sock.getsockname()[1]) + + shutdown: Final = threading.Event() + + def _serve() -> None: + loop: Final = asyncio.new_event_loop() + config: Final = Config() + config.bind = [f"127.0.0.1:{port}"] + config.certfile = str(cert_file) + config.keyfile = str(key_file) + config.alpn_protocols = ["h2", "http/1.1"] + loop.run_until_complete(serve(_asgi_app, config, shutdown_trigger=lambda: asyncio.to_thread(shutdown.wait))) + loop.close() + + thread: Final = threading.Thread(target=_serve, daemon=True) + thread.start() + + for _ in range(100): + try: + with socket.create_connection(("127.0.0.1", port), timeout=0.2): + break + except OSError: + time.sleep(0.05) + else: + pytest.fail("hypercorn test server did not start") + + yield f"https://127.0.0.1:{port}" + + shutdown.set() + thread.join(timeout=10) + + +def _async_exchange(base_url: str) -> tuple[str, str, bytes]: + async def _run() -> tuple[str, str, bytes]: + handler: Final = AsyncHTTPHandler(ssl_verify=False) + try: + response: Final = await handler.client.post(f"{base_url}/echo", json={"ping": "pong"}) + post_version: Final = response.http_version + async with handler.client.stream("POST", f"{base_url}/stream", json={}) as stream_response: + stream_version: Final = stream_response.http_version + body: Final = b"".join([chunk async for chunk in stream_response.aiter_bytes()]) + return post_version, stream_version, body + finally: + await handler.close() + + return asyncio.run(_run()) + + +def _sync_exchange(base_url: str) -> tuple[str, str, bytes]: + handler: Final = HTTPHandler(ssl_verify=False) + try: + response: Final = handler.client.post(f"{base_url}/echo", json={"ping": "pong"}) + post_version: Final = response.http_version + with handler.client.stream("POST", f"{base_url}/stream", json={}) as stream_response: + stream_version: Final = stream_response.http_version + body: Final = b"".join(stream_response.iter_bytes()) + return post_version, stream_version, body + finally: + handler.close() + + +class TestOutboundHttp2: + @pytest.mark.parametrize("use_http2, expected_version", [(True, "HTTP/2"), (False, "HTTP/1.1")]) + def test_async_handler_negotiates_http2_only_when_enabled( + self, + monkeypatch: pytest.MonkeyPatch, + http2_tls_server: str, + use_http2: bool, + expected_version: str, + ) -> None: + monkeypatch.setattr(litellm, "http2", use_http2) + monkeypatch.delenv("LITELLM_HTTP2", raising=False) + monkeypatch.setattr(litellm, "disable_aiohttp_transport", True) + monkeypatch.setattr(litellm, "force_ipv4", False) + + post_version, stream_version, body = _async_exchange(http2_tls_server) + + assert post_version == expected_version + assert stream_version == expected_version + assert b"data: chunk-0" in body + + @pytest.mark.parametrize("use_http2, expected_version", [(True, "HTTP/2"), (False, "HTTP/1.1")]) + def test_sync_handler_negotiates_http2_only_when_enabled( + self, + monkeypatch: pytest.MonkeyPatch, + http2_tls_server: str, + use_http2: bool, + expected_version: str, + ) -> None: + monkeypatch.setattr(litellm, "http2", use_http2) + monkeypatch.delenv("LITELLM_HTTP2", raising=False) + monkeypatch.setattr(litellm, "disable_aiohttp_transport", True) + monkeypatch.setattr(litellm, "force_ipv4", False) + + post_version, stream_version, body = _sync_exchange(http2_tls_server) + + assert post_version == expected_version + assert stream_version == expected_version + assert b"data: chunk-0" in body diff --git a/tests/test_litellm/llms/conftest.py b/tests/test_litellm/llms/conftest.py deleted file mode 100644 index 2905b606e51..00000000000 --- a/tests/test_litellm/llms/conftest.py +++ /dev/null @@ -1,124 +0,0 @@ -import asyncio -import datetime -import ipaddress -import socket -import threading -import time - -import pytest - - -def _write_self_signed_cert(cert_dir): - from cryptography import x509 - from cryptography.hazmat.primitives import hashes, serialization - from cryptography.hazmat.primitives.asymmetric import rsa - - key = rsa.generate_private_key(public_exponent=65537, key_size=2048) - now = datetime.datetime.now(datetime.timezone.utc) - cert = ( - x509.CertificateBuilder() - .subject_name(x509.Name([x509.NameAttribute(x509.oid.NameOID.COMMON_NAME, "localhost")])) - .issuer_name(x509.Name([x509.NameAttribute(x509.oid.NameOID.COMMON_NAME, "localhost")])) - .public_key(key.public_key()) - .serial_number(x509.random_serial_number()) - .not_valid_before(now - datetime.timedelta(days=1)) - .not_valid_after(now + datetime.timedelta(days=7)) - .add_extension( - x509.SubjectAlternativeName( - [x509.DNSName("localhost"), x509.IPAddress(ipaddress.ip_address("127.0.0.1"))] - ), - critical=False, - ) - .sign(key, hashes.SHA256()) - ) - cert_file = cert_dir / "cert.pem" - key_file = cert_dir / "key.pem" - cert_file.write_bytes(cert.public_bytes(serialization.Encoding.PEM)) - key_file.write_bytes( - key.private_bytes( - serialization.Encoding.PEM, - serialization.PrivateFormat.TraditionalOpenSSL, - serialization.NoEncryption(), - ) - ) - return cert_file, key_file - - -async def _asgi_app(scope, receive, send): - if scope["type"] != "http": - return - while True: - message = await receive() - if message["type"] == "http.request" and not message.get("more_body"): - break - if message["type"] == "http.disconnect": - return - if scope["path"] == "/stream": - await send( - { - "type": "http.response.start", - "status": 200, - "headers": [(b"content-type", b"text/event-stream")], - } - ) - for index in range(3): - await send( - { - "type": "http.response.body", - "body": f"data: chunk-{index}\n\n".encode(), - "more_body": True, - } - ) - await send({"type": "http.response.body", "body": b"", "more_body": False}) - return - await send( - { - "type": "http.response.start", - "status": 200, - "headers": [(b"content-type", b"application/json")], - } - ) - await send({"type": "http.response.body", "body": b'{"ok": true}'}) - - -@pytest.fixture(scope="module") -def http2_tls_server(tmp_path_factory): - """Hypercorn TLS server on an ephemeral port that negotiates h2 or http/1.1 via ALPN.""" - from hypercorn.asyncio import serve - from hypercorn.config import Config - - cert_dir = tmp_path_factory.mktemp("h2certs") - cert_file, key_file = _write_self_signed_cert(cert_dir) - - with socket.socket() as sock: - sock.bind(("127.0.0.1", 0)) - port = sock.getsockname()[1] - - shutdown = threading.Event() - - def _serve() -> None: - loop = asyncio.new_event_loop() - config = Config() - config.bind = [f"127.0.0.1:{port}"] - config.certfile = str(cert_file) - config.keyfile = str(key_file) - config.alpn_protocols = ["h2", "http/1.1"] - loop.run_until_complete(serve(_asgi_app, config, shutdown_trigger=lambda: asyncio.to_thread(shutdown.wait))) - loop.close() - - thread = threading.Thread(target=_serve, daemon=True) - thread.start() - - for _ in range(100): - try: - with socket.create_connection(("127.0.0.1", port), timeout=0.2): - break - except OSError: - time.sleep(0.05) - else: - pytest.fail("hypercorn test server did not start") - - yield f"https://127.0.0.1:{port}" - - shutdown.set() - thread.join(timeout=10) diff --git a/tests/test_litellm/llms/custom_httpx/test_http_handler.py b/tests/test_litellm/llms/custom_httpx/test_http_handler.py index e170a7f7a78..a52bf58e944 100644 --- a/tests/test_litellm/llms/custom_httpx/test_http_handler.py +++ b/tests/test_litellm/llms/custom_httpx/test_http_handler.py @@ -1702,44 +1702,3 @@ async def test_http2_disabled_by_default(monkeypatch: pytest.MonkeyPatch): monkeypatch.setattr(litellm, "disable_aiohttp_transport", False) assert AsyncHTTPHandler._should_use_aiohttp_transport() is True - - -@pytest.mark.asyncio -@pytest.mark.parametrize("use_http2", [True, False]) -@pytest.mark.parametrize("handler_kind", ["async", "sync"]) -async def test_http_version_negotiated_over_tls(monkeypatch, http2_tls_server, handler_kind, use_http2): - monkeypatch.setattr(litellm, "http2", use_http2) - monkeypatch.delenv("LITELLM_HTTP2", raising=False) - monkeypatch.setattr(litellm, "disable_aiohttp_transport", True) - monkeypatch.setattr(litellm, "force_ipv4", False) - - expected_version = "HTTP/2" if use_http2 else "HTTP/1.1" - - if handler_kind == "async": - handler = AsyncHTTPHandler(ssl_verify=False) - try: - response = await handler.post(f"{http2_tls_server}/echo", json={"ping": "pong"}) - assert response.status_code == 200 - assert response.http_version == expected_version - - stream_response = await handler.post(f"{http2_tls_server}/stream", stream=True) - assert stream_response.http_version == expected_version - chunks = [chunk async for chunk in stream_response.aiter_bytes()] - assert chunks - await stream_response.aclose() - finally: - await handler.close() - else: - handler = HTTPHandler(ssl_verify=False) - try: - response = handler.post(f"{http2_tls_server}/echo", json={"ping": "pong"}) - assert response.status_code == 200 - assert response.http_version == expected_version - - stream_response = handler.post(f"{http2_tls_server}/stream", stream=True) - assert stream_response.http_version == expected_version - chunks = list(stream_response.iter_bytes()) - assert chunks - stream_response.close() - finally: - handler.close() diff --git a/tests/test_litellm/llms/openai/test_openai_common_utils.py b/tests/test_litellm/llms/openai/test_openai_common_utils.py index 3dc37c21e7a..b54ec10ef17 100644 --- a/tests/test_litellm/llms/openai/test_openai_common_utils.py +++ b/tests/test_litellm/llms/openai/test_openai_common_utils.py @@ -413,18 +413,3 @@ def test_is_openai_backed_api_base_decides_by_hostname_only(api_base, expected): assert is_openai_backed_api_base(api_base) is expected -def test_litellm_built_http_clients_negotiate_http2_only_when_enabled(monkeypatch): - monkeypatch.delenv("LITELLM_HTTP2", raising=False) - monkeypatch.setattr(litellm, "disable_aiohttp_transport", True) - - monkeypatch.setattr(litellm, "http2", False) - async_client = BaseOpenAILLM._get_async_http_client() - sync_client = BaseOpenAILLM._get_sync_http_client() - assert async_client is not None and async_client._transport._pool._http2 is False - assert sync_client is not None and sync_client._transport._pool._http2 is False - - monkeypatch.setattr(litellm, "http2", True) - async_client = BaseOpenAILLM._get_async_http_client() - sync_client = BaseOpenAILLM._get_sync_http_client() - assert async_client is not None and async_client._transport._pool._http2 is True - assert sync_client is not None and sync_client._transport._pool._http2 is True diff --git a/tests/test_litellm/test_main.py b/tests/test_litellm/test_main.py index 3dccb2b35bf..15ec440b0bb 100644 --- a/tests/test_litellm/test_main.py +++ b/tests/test_litellm/test_main.py @@ -190,9 +190,7 @@ async def test_url_with_format_param(model, sync_mode, monkeypatch): # URL->image conversion helpers so suite-level network/client state from # earlier tests cannot prevent the mocked provider client from being hit. fake_base64_image = "data:image/png;base64,ZmFrZS1pbWFnZQ==" - monkeypatch.setattr( - prompt_factory, "convert_url_to_base64", lambda url: fake_base64_image - ) + monkeypatch.setattr(prompt_factory, "convert_url_to_base64", lambda url: fake_base64_image) monkeypatch.setattr( prompt_factory.BedrockImageProcessor, "get_image_details", @@ -307,9 +305,7 @@ async def test_url_with_format_param_openai(model, sync_mode): } ], } - with patch.object( - client.chat.completions.with_raw_response, "create" - ) as mock_client: + with patch.object(client.chat.completions.with_raw_response, "create") as mock_client: try: if sync_mode: response = completion(**args, client=client) @@ -361,9 +357,7 @@ def test_strip_input_examples_for_non_anthropic_providers(): } ] - assert not litellm_main._should_allow_input_examples( - custom_llm_provider="openai", model="gpt-4o-mini" - ) + assert not litellm_main._should_allow_input_examples(custom_llm_provider="openai", model="gpt-4o-mini") cleaned = litellm_main._drop_input_examples_from_tools(tools=tools) @@ -375,9 +369,7 @@ def test_strip_input_examples_for_non_anthropic_providers(): def test_custom_provider_with_extra_headers(): from litellm.llms.custom_httpx.http_handler import HTTPHandler - with patch.object( - litellm.llms.custom_httpx.http_handler.HTTPHandler, "post" - ) as mock_post: + with patch.object(litellm.llms.custom_httpx.http_handler.HTTPHandler, "post") as mock_post: response = litellm.completion( model="custom/custom", messages=[{"role": "user", "content": "Hello, how are you?"}], @@ -392,9 +384,7 @@ def test_custom_provider_with_extra_headers(): def test_custom_provider_with_extra_body(): from litellm.llms.custom_httpx.http_handler import HTTPHandler - with patch.object( - litellm.llms.custom_httpx.http_handler.HTTPHandler, "post" - ) as mock_post: + with patch.object(litellm.llms.custom_httpx.http_handler.HTTPHandler, "post") as mock_post: response = litellm.completion( model="custom/custom", messages=[{"role": "user", "content": "Hello, how are you?"}], @@ -421,9 +411,7 @@ def test_custom_provider_with_extra_body(): } # test that extra_body is not passed if not provided - with patch.object( - litellm.llms.custom_httpx.http_handler.HTTPHandler, "post" - ) as mock_post: + with patch.object(litellm.llms.custom_httpx.http_handler.HTTPHandler, "post") as mock_post: response = litellm.completion( model="custom/custom", messages=[{"role": "user", "content": "Hello, how are you?"}], @@ -454,9 +442,7 @@ def set_openrouter_api_key(): @pytest.mark.asyncio -async def test_extra_body_with_fallback( - respx_mock: respx.MockRouter, set_openrouter_api_key, monkeypatch -): +async def test_extra_body_with_fallback(respx_mock: respx.MockRouter, set_openrouter_api_key, monkeypatch): """ test regression for https://github.com/BerriAI/litellm/issues/8425. @@ -524,9 +510,7 @@ async def test_extra_body_with_fallback( # Verify the response assert response is not None - assert ( - len(respx_mock.calls) > 0 - ), "Mock was not called - check if aiohttp transport is properly disabled" + assert len(respx_mock.calls) > 0, "Mock was not called - check if aiohttp transport is properly disabled" # Get the request from the mock request: httpx.Request = respx_mock.calls[0].request @@ -550,9 +534,7 @@ async def test_extra_body_with_fallback( @pytest.mark.parametrize("env_base", ["OPENAI_BASE_URL", "OPENAI_API_BASE"]) @pytest.mark.asyncio @pytest.mark.flaky(retries=3, delay=1) -async def test_openai_env_base( - respx_mock: respx.MockRouter, env_base, openai_api_response, monkeypatch -): +async def test_openai_env_base(respx_mock: respx.MockRouter, env_base, openai_api_response, monkeypatch): "This tests OpenAI env variables are honored, including legacy OPENAI_API_BASE" # Ensure aiohttp transport is disabled to use httpx which respx can mock litellm.disable_aiohttp_transport = True @@ -567,9 +549,7 @@ async def test_openai_env_base( messages = [{"role": "user", "content": "Hello, how are you?"}] # Configure respx mock to intercept the request - mock_route = respx_mock.post( - url__regex=r"http://localhost:12345/v1/chat/completions.*" - ).mock( + mock_route = respx_mock.post(url__regex=r"http://localhost:12345/v1/chat/completions.*").mock( return_value=httpx.Response( status_code=200, json={ @@ -603,9 +583,7 @@ async def test_openai_env_base( assert response.choices[0].message.content == "Hello from mocked response!" # Verify the mock was called - assert ( - mock_route.called - ), "Mock route was not called - request may have bypassed respx" + assert mock_route.called, "Mock route was not called - request may have bypassed respx" finally: # Clean up to avoid affecting other tests litellm.disable_aiohttp_transport = False @@ -681,9 +659,7 @@ def test_completion_forwards_verbosity_in_raw_request(respx_mock: respx.MockRout model = "gpt-5.2" messages = [{"role": "user", "content": "hi"}] - respx_mock.post("https://api.openai.com/v1/chat/completions").mock( - return_value=_mocked_openai_chat_response(model) - ) + respx_mock.post("https://api.openai.com/v1/chat/completions").mock(return_value=_mocked_openai_chat_response(model)) request = return_raw_request( endpoint=CallTypes.completion, @@ -700,9 +676,7 @@ def test_completion_forwards_verbosity_in_raw_request(respx_mock: respx.MockRout @pytest.mark.asyncio -async def test_acompletion_forwards_verbosity_to_provider_request( - respx_mock: respx.MockRouter, monkeypatch -): +async def test_acompletion_forwards_verbosity_to_provider_request(respx_mock: respx.MockRouter, monkeypatch): """Regression test: acompletion() must forward the verbosity param to the provider request body.""" original_disable_aiohttp = litellm.disable_aiohttp_transport try: @@ -763,9 +737,9 @@ def test_responses_api_bridge_check_gpt_5_4_pro(): model=model_name, custom_llm_provider="openai", ) - assert ( - model_info.get("mode") == "responses" - ), f"{model_name} should have mode='responses', got '{model_info.get('mode')}'" + assert model_info.get("mode") == "responses", ( + f"{model_name} should have mode='responses', got '{model_info.get('mode')}'" + ) def test_responses_api_bridge_check_gpt_5_4_tools_plus_reasoning_routes_to_responses(): @@ -1182,7 +1156,7 @@ def test_responses_api_bridge_check_openai_backed_custom_api_base_with_unset_eff tools=[{"type": "function", "function": {"name": "get_capital"}}], reasoning_effort=None, api_base=api_base, - ) + ) assert model == "gpt-5.6" assert model_info.get("mode") == "responses" @@ -1207,7 +1181,7 @@ def test_responses_api_bridge_check_lookalike_custom_api_base_with_unset_effort_ tools=[{"type": "function", "function": {"name": "get_capital"}}], reasoning_effort=None, api_base=api_base, - ) + ) assert model == "gpt-5.6" assert model_info.get("mode") != "responses" @@ -1227,7 +1201,7 @@ def test_responses_api_bridge_check_privatelink_api_base_via_env_with_unset_effo tools=[{"type": "function", "function": {"name": "get_capital"}}], reasoning_effort=None, api_base=None, - ) + ) assert model == "gpt-5.6" assert model_info.get("mode") == "responses" @@ -1588,9 +1562,7 @@ def test_responses_api_bridge_check_handles_exception(): with patch("litellm.main._get_model_info_helper") as mock_get_model_info: mock_get_model_info.side_effect = Exception("Model not found") - model_info, model = responses_api_bridge_check( - model="responses/custom-model", custom_llm_provider="custom" - ) + model_info, model = responses_api_bridge_check(model="responses/custom-model", custom_llm_provider="custom") assert model == "custom-model" assert model_info["mode"] == "responses" @@ -2371,9 +2343,7 @@ def test_image_edit_merges_headers_and_extra_headers(): mock_image_edit_config = MagicMock() mock_image_edit_config.get_supported_openai_params.return_value = set() - mock_image_edit_config.map_openai_params.side_effect = lambda **kwargs: dict( - kwargs["image_edit_optional_params"] - ) + mock_image_edit_config.map_openai_params.side_effect = lambda **kwargs: dict(kwargs["image_edit_optional_params"]) with ( patch( @@ -2729,10 +2699,7 @@ def test_mock_completion_stream_with_model_response(): # Verify the content is streamed correctly accumulated_content = "" for chunk in chunks: - if ( - hasattr(chunk.choices[0].delta, "content") - and chunk.choices[0].delta.content - ): + if hasattr(chunk.choices[0].delta, "content") and chunk.choices[0].delta.content: accumulated_content += chunk.choices[0].delta.content assert "This is a test response" in accumulated_content or len(chunks) > 0 @@ -2790,10 +2757,7 @@ async def test_async_mock_completion_stream_with_model_response(): # Verify the content is streamed correctly accumulated_content = "" for chunk in chunks: - if ( - hasattr(chunk.choices[0].delta, "content") - and chunk.choices[0].delta.content - ): + if hasattr(chunk.choices[0].delta, "content") and chunk.choices[0].delta.content: accumulated_content += chunk.choices[0].delta.content assert "This is an async test response" in accumulated_content or len(chunks) > 0 @@ -2860,9 +2824,7 @@ def test_stream_chunk_builder_text_completion_combines_text_and_usage(): ), ] - response = stream_chunk_builder_text_completion( - chunks=chunks, messages=[{"role": "user", "content": "say hello"}] - ) + response = stream_chunk_builder_text_completion(chunks=chunks, messages=[{"role": "user", "content": "say hello"}]) assert response.choices[0].text == "Hello world" assert response.choices[0].finish_reason == "stop" @@ -3310,10 +3272,7 @@ def _text_chunk(content, finish_reason=None, usage=None): def _priced_at(prompt_tokens, completion_tokens): prices = litellm.model_cost[STREAM_COST_MODEL] - return ( - prompt_tokens * prices["input_cost_per_token"] - + completion_tokens * prices["output_cost_per_token"] - ) + return prompt_tokens * prices["input_cost_per_token"] + completion_tokens * prices["output_cost_per_token"] @pytest.fixture @@ -3380,9 +3339,9 @@ def test_streaming_and_not_streaming_bill_the_same_usage_the_same(local_cost_map usage=STREAMED_USAGE, ) - assert litellm.completion_cost( - completion_response=rebuilt, model=STREAM_COST_MODEL - ) == pytest.approx(litellm.completion_cost(completion_response=whole, model=STREAM_COST_MODEL)) + assert litellm.completion_cost(completion_response=rebuilt, model=STREAM_COST_MODEL) == pytest.approx( + litellm.completion_cost(completion_response=whole, model=STREAM_COST_MODEL) + ) def test_a_stream_that_reported_no_usage_is_still_billed(local_cost_map): @@ -3401,9 +3360,7 @@ def test_a_stream_that_reported_no_usage_is_still_billed(local_cost_map): cost = litellm.completion_cost(completion_response=rebuilt, model=STREAM_COST_MODEL) assert cost > 0 - assert cost == pytest.approx( - _priced_at(rebuilt.usage.prompt_tokens, rebuilt.usage.completion_tokens) - ) + assert cost == pytest.approx(_priced_at(rebuilt.usage.prompt_tokens, rebuilt.usage.completion_tokens)) @pytest.mark.asyncio @@ -3850,3 +3807,29 @@ def test_bridged_responses_with_openai_http_handler_keeps_forwarded_headers_out_ assert "extra_headers" not in body assert body["model"] == "gpt-5.4" assert {k: request.headers[k] for k in FORWARDED_CLIENT_HEADERS} == FORWARDED_CLIENT_HEADERS + + +@pytest.mark.parametrize("http2_on", [True, False]) +def test_aiohttp_openai_warns_only_when_http2_enabled( + monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture, http2_on: bool +): + import logging + + from litellm.main import base_llm_aiohttp_handler + + monkeypatch.setattr(litellm, "http2", http2_on) + monkeypatch.delenv("LITELLM_HTTP2", raising=False) + + handler_completion: Final = MagicMock(return_value=MagicMock()) + monkeypatch.setattr(base_llm_aiohttp_handler, "completion", handler_completion) + + with caplog.at_level(logging.WARNING, logger="LiteLLM"): + litellm.completion( + model="aiohttp_openai/gpt-4o", + messages=[{"role": "user", "content": "hi"}], + api_key="sk-test", + ) + + assert handler_completion.called + warned: Final = "aiohttp_openai/ always uses aiohttp" in caplog.text + assert warned is http2_on From 8978b4562f23b7f0ac69f41488389c3088f2ba56 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Tue, 15 Sep 2026 18:26:54 +0000 Subject: [PATCH 022/133] test(main): drop unrelated reformatting Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- tests/test_litellm/test_main.py | 104 ++++++++++++++++++++++---------- 1 file changed, 73 insertions(+), 31 deletions(-) diff --git a/tests/test_litellm/test_main.py b/tests/test_litellm/test_main.py index 15ec440b0bb..7fcdc8473d7 100644 --- a/tests/test_litellm/test_main.py +++ b/tests/test_litellm/test_main.py @@ -4,6 +4,7 @@ from datetime import datetime import contextlib import copy import json +import logging import os from collections.abc import Mapping from dataclasses import dataclass @@ -190,7 +191,9 @@ async def test_url_with_format_param(model, sync_mode, monkeypatch): # URL->image conversion helpers so suite-level network/client state from # earlier tests cannot prevent the mocked provider client from being hit. fake_base64_image = "data:image/png;base64,ZmFrZS1pbWFnZQ==" - monkeypatch.setattr(prompt_factory, "convert_url_to_base64", lambda url: fake_base64_image) + monkeypatch.setattr( + prompt_factory, "convert_url_to_base64", lambda url: fake_base64_image + ) monkeypatch.setattr( prompt_factory.BedrockImageProcessor, "get_image_details", @@ -305,7 +308,9 @@ async def test_url_with_format_param_openai(model, sync_mode): } ], } - with patch.object(client.chat.completions.with_raw_response, "create") as mock_client: + with patch.object( + client.chat.completions.with_raw_response, "create" + ) as mock_client: try: if sync_mode: response = completion(**args, client=client) @@ -357,7 +362,9 @@ def test_strip_input_examples_for_non_anthropic_providers(): } ] - assert not litellm_main._should_allow_input_examples(custom_llm_provider="openai", model="gpt-4o-mini") + assert not litellm_main._should_allow_input_examples( + custom_llm_provider="openai", model="gpt-4o-mini" + ) cleaned = litellm_main._drop_input_examples_from_tools(tools=tools) @@ -369,7 +376,9 @@ def test_strip_input_examples_for_non_anthropic_providers(): def test_custom_provider_with_extra_headers(): from litellm.llms.custom_httpx.http_handler import HTTPHandler - with patch.object(litellm.llms.custom_httpx.http_handler.HTTPHandler, "post") as mock_post: + with patch.object( + litellm.llms.custom_httpx.http_handler.HTTPHandler, "post" + ) as mock_post: response = litellm.completion( model="custom/custom", messages=[{"role": "user", "content": "Hello, how are you?"}], @@ -384,7 +393,9 @@ def test_custom_provider_with_extra_headers(): def test_custom_provider_with_extra_body(): from litellm.llms.custom_httpx.http_handler import HTTPHandler - with patch.object(litellm.llms.custom_httpx.http_handler.HTTPHandler, "post") as mock_post: + with patch.object( + litellm.llms.custom_httpx.http_handler.HTTPHandler, "post" + ) as mock_post: response = litellm.completion( model="custom/custom", messages=[{"role": "user", "content": "Hello, how are you?"}], @@ -411,7 +422,9 @@ def test_custom_provider_with_extra_body(): } # test that extra_body is not passed if not provided - with patch.object(litellm.llms.custom_httpx.http_handler.HTTPHandler, "post") as mock_post: + with patch.object( + litellm.llms.custom_httpx.http_handler.HTTPHandler, "post" + ) as mock_post: response = litellm.completion( model="custom/custom", messages=[{"role": "user", "content": "Hello, how are you?"}], @@ -442,7 +455,9 @@ def set_openrouter_api_key(): @pytest.mark.asyncio -async def test_extra_body_with_fallback(respx_mock: respx.MockRouter, set_openrouter_api_key, monkeypatch): +async def test_extra_body_with_fallback( + respx_mock: respx.MockRouter, set_openrouter_api_key, monkeypatch +): """ test regression for https://github.com/BerriAI/litellm/issues/8425. @@ -510,7 +525,9 @@ async def test_extra_body_with_fallback(respx_mock: respx.MockRouter, set_openro # Verify the response assert response is not None - assert len(respx_mock.calls) > 0, "Mock was not called - check if aiohttp transport is properly disabled" + assert ( + len(respx_mock.calls) > 0 + ), "Mock was not called - check if aiohttp transport is properly disabled" # Get the request from the mock request: httpx.Request = respx_mock.calls[0].request @@ -534,7 +551,9 @@ async def test_extra_body_with_fallback(respx_mock: respx.MockRouter, set_openro @pytest.mark.parametrize("env_base", ["OPENAI_BASE_URL", "OPENAI_API_BASE"]) @pytest.mark.asyncio @pytest.mark.flaky(retries=3, delay=1) -async def test_openai_env_base(respx_mock: respx.MockRouter, env_base, openai_api_response, monkeypatch): +async def test_openai_env_base( + respx_mock: respx.MockRouter, env_base, openai_api_response, monkeypatch +): "This tests OpenAI env variables are honored, including legacy OPENAI_API_BASE" # Ensure aiohttp transport is disabled to use httpx which respx can mock litellm.disable_aiohttp_transport = True @@ -549,7 +568,9 @@ async def test_openai_env_base(respx_mock: respx.MockRouter, env_base, openai_ap messages = [{"role": "user", "content": "Hello, how are you?"}] # Configure respx mock to intercept the request - mock_route = respx_mock.post(url__regex=r"http://localhost:12345/v1/chat/completions.*").mock( + mock_route = respx_mock.post( + url__regex=r"http://localhost:12345/v1/chat/completions.*" + ).mock( return_value=httpx.Response( status_code=200, json={ @@ -583,7 +604,9 @@ async def test_openai_env_base(respx_mock: respx.MockRouter, env_base, openai_ap assert response.choices[0].message.content == "Hello from mocked response!" # Verify the mock was called - assert mock_route.called, "Mock route was not called - request may have bypassed respx" + assert ( + mock_route.called + ), "Mock route was not called - request may have bypassed respx" finally: # Clean up to avoid affecting other tests litellm.disable_aiohttp_transport = False @@ -659,7 +682,9 @@ def test_completion_forwards_verbosity_in_raw_request(respx_mock: respx.MockRout model = "gpt-5.2" messages = [{"role": "user", "content": "hi"}] - respx_mock.post("https://api.openai.com/v1/chat/completions").mock(return_value=_mocked_openai_chat_response(model)) + respx_mock.post("https://api.openai.com/v1/chat/completions").mock( + return_value=_mocked_openai_chat_response(model) + ) request = return_raw_request( endpoint=CallTypes.completion, @@ -676,7 +701,9 @@ def test_completion_forwards_verbosity_in_raw_request(respx_mock: respx.MockRout @pytest.mark.asyncio -async def test_acompletion_forwards_verbosity_to_provider_request(respx_mock: respx.MockRouter, monkeypatch): +async def test_acompletion_forwards_verbosity_to_provider_request( + respx_mock: respx.MockRouter, monkeypatch +): """Regression test: acompletion() must forward the verbosity param to the provider request body.""" original_disable_aiohttp = litellm.disable_aiohttp_transport try: @@ -737,9 +764,9 @@ def test_responses_api_bridge_check_gpt_5_4_pro(): model=model_name, custom_llm_provider="openai", ) - assert model_info.get("mode") == "responses", ( - f"{model_name} should have mode='responses', got '{model_info.get('mode')}'" - ) + assert ( + model_info.get("mode") == "responses" + ), f"{model_name} should have mode='responses', got '{model_info.get('mode')}'" def test_responses_api_bridge_check_gpt_5_4_tools_plus_reasoning_routes_to_responses(): @@ -1156,7 +1183,7 @@ def test_responses_api_bridge_check_openai_backed_custom_api_base_with_unset_eff tools=[{"type": "function", "function": {"name": "get_capital"}}], reasoning_effort=None, api_base=api_base, - ) + ) assert model == "gpt-5.6" assert model_info.get("mode") == "responses" @@ -1181,7 +1208,7 @@ def test_responses_api_bridge_check_lookalike_custom_api_base_with_unset_effort_ tools=[{"type": "function", "function": {"name": "get_capital"}}], reasoning_effort=None, api_base=api_base, - ) + ) assert model == "gpt-5.6" assert model_info.get("mode") != "responses" @@ -1201,7 +1228,7 @@ def test_responses_api_bridge_check_privatelink_api_base_via_env_with_unset_effo tools=[{"type": "function", "function": {"name": "get_capital"}}], reasoning_effort=None, api_base=None, - ) + ) assert model == "gpt-5.6" assert model_info.get("mode") == "responses" @@ -1562,7 +1589,9 @@ def test_responses_api_bridge_check_handles_exception(): with patch("litellm.main._get_model_info_helper") as mock_get_model_info: mock_get_model_info.side_effect = Exception("Model not found") - model_info, model = responses_api_bridge_check(model="responses/custom-model", custom_llm_provider="custom") + model_info, model = responses_api_bridge_check( + model="responses/custom-model", custom_llm_provider="custom" + ) assert model == "custom-model" assert model_info["mode"] == "responses" @@ -2343,7 +2372,9 @@ def test_image_edit_merges_headers_and_extra_headers(): mock_image_edit_config = MagicMock() mock_image_edit_config.get_supported_openai_params.return_value = set() - mock_image_edit_config.map_openai_params.side_effect = lambda **kwargs: dict(kwargs["image_edit_optional_params"]) + mock_image_edit_config.map_openai_params.side_effect = lambda **kwargs: dict( + kwargs["image_edit_optional_params"] + ) with ( patch( @@ -2699,7 +2730,10 @@ def test_mock_completion_stream_with_model_response(): # Verify the content is streamed correctly accumulated_content = "" for chunk in chunks: - if hasattr(chunk.choices[0].delta, "content") and chunk.choices[0].delta.content: + if ( + hasattr(chunk.choices[0].delta, "content") + and chunk.choices[0].delta.content + ): accumulated_content += chunk.choices[0].delta.content assert "This is a test response" in accumulated_content or len(chunks) > 0 @@ -2757,7 +2791,10 @@ async def test_async_mock_completion_stream_with_model_response(): # Verify the content is streamed correctly accumulated_content = "" for chunk in chunks: - if hasattr(chunk.choices[0].delta, "content") and chunk.choices[0].delta.content: + if ( + hasattr(chunk.choices[0].delta, "content") + and chunk.choices[0].delta.content + ): accumulated_content += chunk.choices[0].delta.content assert "This is an async test response" in accumulated_content or len(chunks) > 0 @@ -2824,7 +2861,9 @@ def test_stream_chunk_builder_text_completion_combines_text_and_usage(): ), ] - response = stream_chunk_builder_text_completion(chunks=chunks, messages=[{"role": "user", "content": "say hello"}]) + response = stream_chunk_builder_text_completion( + chunks=chunks, messages=[{"role": "user", "content": "say hello"}] + ) assert response.choices[0].text == "Hello world" assert response.choices[0].finish_reason == "stop" @@ -3272,7 +3311,10 @@ def _text_chunk(content, finish_reason=None, usage=None): def _priced_at(prompt_tokens, completion_tokens): prices = litellm.model_cost[STREAM_COST_MODEL] - return prompt_tokens * prices["input_cost_per_token"] + completion_tokens * prices["output_cost_per_token"] + return ( + prompt_tokens * prices["input_cost_per_token"] + + completion_tokens * prices["output_cost_per_token"] + ) @pytest.fixture @@ -3339,9 +3381,9 @@ def test_streaming_and_not_streaming_bill_the_same_usage_the_same(local_cost_map usage=STREAMED_USAGE, ) - assert litellm.completion_cost(completion_response=rebuilt, model=STREAM_COST_MODEL) == pytest.approx( - litellm.completion_cost(completion_response=whole, model=STREAM_COST_MODEL) - ) + assert litellm.completion_cost( + completion_response=rebuilt, model=STREAM_COST_MODEL + ) == pytest.approx(litellm.completion_cost(completion_response=whole, model=STREAM_COST_MODEL)) def test_a_stream_that_reported_no_usage_is_still_billed(local_cost_map): @@ -3360,7 +3402,9 @@ def test_a_stream_that_reported_no_usage_is_still_billed(local_cost_map): cost = litellm.completion_cost(completion_response=rebuilt, model=STREAM_COST_MODEL) assert cost > 0 - assert cost == pytest.approx(_priced_at(rebuilt.usage.prompt_tokens, rebuilt.usage.completion_tokens)) + assert cost == pytest.approx( + _priced_at(rebuilt.usage.prompt_tokens, rebuilt.usage.completion_tokens) + ) @pytest.mark.asyncio @@ -3813,8 +3857,6 @@ def test_bridged_responses_with_openai_http_handler_keeps_forwarded_headers_out_ def test_aiohttp_openai_warns_only_when_http2_enabled( monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture, http2_on: bool ): - import logging - from litellm.main import base_llm_aiohttp_handler monkeypatch.setattr(litellm, "http2", http2_on) From 1517f1205c23bc2dbed020f8c69210883bb19de2 Mon Sep 17 00:00:00 2001 From: "berriai-litellm-provider-info-sync[bot]" <328147090+berriai-litellm-provider-info-sync[bot]@users.noreply.github.com> Date: Tue, 15 Sep 2026 18:36:52 +0000 Subject: [PATCH 023/133] chore(prices): sync Google Gemini prices: 10 models [enrichment failed: Google Gemini, 177 held] gemini/gemini-2.5-computer-use-preview-10-2025: gemini/gemini-2.5-flash: input_cost_per_token_flex, output_cost_per_token_flex, input_cost_per_token_batches, input_cost_per_token_priority, output_cost_per_token_batches, output_cost_per_token_priority, cache_read_input_token_cost_flex, cache_read_input_audio_token_cost, input_cost_per_audio_token_batches, cache_read_input_token_cost_priority gemini/gemini-2.5-flash-image: input_cost_per_token_flex, input_cost_per_token_batches, input_cost_per_token_priority gemini/gemini-2.5-flash-lite: input_cost_per_token_flex, output_cost_per_token_flex, input_cost_per_token_batches, input_cost_per_token_priority, output_cost_per_token_batches, output_cost_per_token_priority, cache_read_input_token_cost_flex, cache_read_input_audio_token_cost, input_cost_per_audio_token_batches, cache_read_input_token_cost_priority gemini-2.5-flash-preview-tts: gemini/gemini-2.5-flash-preview-tts: gemini/gemini-2.5-pro: input_cost_per_token_flex, output_cost_per_token_flex, input_cost_per_token_batches, input_cost_per_token_priority, output_cost_per_token_batches, output_cost_per_token_priority, cache_read_input_token_cost_flex, cache_read_input_token_cost_priority, input_cost_per_token_above_200k_tokens_priority, output_cost_per_token_above_200k_tokens_priority, cache_read_input_token_cost_above_200k_tokens_priority gemini/gemini-2.5-pro-preview-tts: output_cost_per_audio_token, input_cost_per_token_batches gemini/gemini-3-flash-preview: input_cost_per_token_flex, output_cost_per_token_flex, input_cost_per_token_batches, output_cost_per_token_batches, cache_read_input_token_cost_flex, cache_read_input_audio_token_cost, input_cost_per_audio_token_batches gemini/gemini-3-pro-image: input_cost_per_token_flex, output_cost_per_token_flex, input_cost_per_token_priority, output_cost_per_token_priority --- ...odel_prices_and_context_window_backup.json | 71 +++++++++++++++---- model_prices_and_context_window.json | 71 +++++++++++++++---- 2 files changed, 114 insertions(+), 28 deletions(-) diff --git a/litellm/model_prices_and_context_window_backup.json b/litellm/model_prices_and_context_window_backup.json index e63e2e07216..12ee3833ea0 100644 --- a/litellm/model_prices_and_context_window_backup.json +++ b/litellm/model_prices_and_context_window_backup.json @@ -25899,7 +25899,10 @@ } }, "gemini/gemini-2.5-flash": { + "cache_read_input_audio_token_cost": 1e-07, "cache_read_input_token_cost": 3e-08, + "cache_read_input_token_cost_flex": 3e-08, + "cache_read_input_token_cost_priority": 5.4e-08, "input_cost_per_audio_token": 1e-06, "input_cost_per_token": 3e-07, "litellm_provider": "gemini", @@ -25910,7 +25913,7 @@ "output_cost_per_reasoning_token": 2.5e-06, "output_cost_per_token": 2.5e-06, "rpm": 100000, - "source": "https://ai.google.dev/gemini-api/docs/models#gemini-2.5-flash-preview", + "source": "https://ai.google.dev/gemini-api/docs/pricing", "supported_endpoints": [ "/v1/chat/completions", "/v1/completions", @@ -25944,6 +25947,13 @@ "search_context_size_high": 0.035 }, "google_maps_grounding_cost_per_query": 0.025, + "input_cost_per_audio_token_batches": 5e-07, + "input_cost_per_token_batches": 1.5e-07, + "input_cost_per_token_flex": 1.5e-07, + "input_cost_per_token_priority": 5.4e-07, + "output_cost_per_token_batches": 1.25e-06, + "output_cost_per_token_flex": 1.25e-06, + "output_cost_per_token_priority": 4.5e-06, "supports_image_size": false }, "gemini/gemini-2.5-flash-image": { @@ -25951,6 +25961,9 @@ "deprecation_date": "2026-10-02", "input_cost_per_audio_token": 1e-06, "input_cost_per_token": 3e-07, + "input_cost_per_token_batches": 1.5e-07, + "input_cost_per_token_flex": 1.5e-07, + "input_cost_per_token_priority": 5.4e-07, "litellm_provider": "gemini", "supports_reasoning": false, "max_input_tokens": 32768, @@ -25962,7 +25975,7 @@ "output_cost_per_reasoning_token": 2.5e-06, "output_cost_per_token": 2.5e-06, "rpm": 100000, - "source": "https://ai.google.dev/gemini-api/docs/pricing#gemini-2.5-flash-image", + "source": "https://ai.google.dev/gemini-api/docs/pricing", "supported_endpoints": [ "/v1/chat/completions", "/v1/completions", @@ -26001,6 +26014,8 @@ "input_cost_per_image": 0.0011, "input_cost_per_token": 2e-06, "input_cost_per_token_batches": 1e-06, + "input_cost_per_token_flex": 1e-06, + "input_cost_per_token_priority": 3.6e-06, "litellm_provider": "gemini", "max_input_tokens": 65536, "max_output_tokens": 32768, @@ -26012,7 +26027,9 @@ "rpm": 1000, "tpm": 4000000, "output_cost_per_token_batches": 6e-06, - "source": "https://ai.google.dev/gemini-api/docs/pricing#gemini-3-pro-image", + "output_cost_per_token_flex": 6e-06, + "output_cost_per_token_priority": 2.16e-05, + "source": "https://ai.google.dev/gemini-api/docs/pricing", "supported_endpoints": [ "/v1/chat/completions", "/v1/completions", @@ -26290,7 +26307,10 @@ } }, "gemini/gemini-2.5-flash-lite": { + "cache_read_input_audio_token_cost": 3e-08, "cache_read_input_token_cost": 1e-08, + "cache_read_input_token_cost_flex": 1e-08, + "cache_read_input_token_cost_priority": 1.8e-08, "input_cost_per_audio_token": 3e-07, "input_cost_per_token": 1e-07, "litellm_provider": "gemini", @@ -26301,7 +26321,7 @@ "output_cost_per_reasoning_token": 4e-07, "output_cost_per_token": 4e-07, "rpm": 15, - "source": "https://ai.google.dev/gemini-api/docs/models#gemini-2.5-flash-lite", + "source": "https://ai.google.dev/gemini-api/docs/pricing", "supported_endpoints": [ "/v1/chat/completions", "/v1/completions", @@ -26335,6 +26355,13 @@ "search_context_size_high": 0.035 }, "google_maps_grounding_cost_per_query": 0.025, + "input_cost_per_audio_token_batches": 1.5e-07, + "input_cost_per_token_batches": 5e-08, + "input_cost_per_token_flex": 5e-08, + "input_cost_per_token_priority": 1.8e-07, + "output_cost_per_token_batches": 2e-07, + "output_cost_per_token_flex": 2e-07, + "output_cost_per_token_priority": 7.2e-07, "supports_image_size": false }, "gemini/gemini-2.5-flash-lite-preview-09-2025": { @@ -26585,7 +26612,7 @@ "mode": "audio_speech", "output_cost_per_audio_token": 1e-05, "output_cost_per_token": 1e-05, - "source": "https://cloud.google.com/gemini-enterprise-agent-platform/generative-ai/pricing", + "source": "https://ai.google.dev/gemini-api/docs/pricing", "supported_endpoints": [ "/v1/audio/speech" ], @@ -26595,10 +26622,13 @@ "gemini/gemini-2.5-pro": { "cache_read_input_token_cost": 1.25e-07, "cache_read_input_token_cost_above_200k_tokens": 2.5e-07, + "cache_read_input_token_cost_above_200k_tokens_priority": 4.5e-07, + "cache_read_input_token_cost_flex": 1.25e-07, + "cache_read_input_token_cost_priority": 2.25e-07, "input_cost_per_token": 1.25e-06, "input_cost_per_token_above_200k_tokens": 2.5e-06, - "input_cost_per_token_priority": 1.25e-06, - "input_cost_per_token_above_200k_tokens_priority": 2.5e-06, + "input_cost_per_token_priority": 2.25e-06, + "input_cost_per_token_above_200k_tokens_priority": 4.5e-06, "litellm_provider": "gemini", "max_input_tokens": 1048576, "max_output_tokens": 65535, @@ -26606,10 +26636,10 @@ "mode": "chat", "output_cost_per_token": 1e-05, "output_cost_per_token_above_200k_tokens": 1.5e-05, - "output_cost_per_token_priority": 1e-05, - "output_cost_per_token_above_200k_tokens_priority": 1.5e-05, + "output_cost_per_token_priority": 1.8e-05, + "output_cost_per_token_above_200k_tokens_priority": 2.7e-05, "rpm": 2000, - "source": "https://cloud.google.com/vertex-ai/generative-ai/pricing", + "source": "https://ai.google.dev/gemini-api/docs/pricing", "supported_endpoints": [ "/v1/chat/completions", "/v1/completions" @@ -26640,7 +26670,11 @@ "search_context_size_medium": 0.035, "search_context_size_high": 0.035 }, - "google_maps_grounding_cost_per_query": 0.025 + "google_maps_grounding_cost_per_query": 0.025, + "input_cost_per_token_batches": 6.25e-07, + "input_cost_per_token_flex": 6.25e-07, + "output_cost_per_token_batches": 5e-06, + "output_cost_per_token_flex": 5e-06 }, "gemini/gemini-2.5-computer-use-preview-10-2025": { "input_cost_per_token": 1.25e-06, @@ -26653,7 +26687,7 @@ "output_cost_per_token": 1e-05, "output_cost_per_token_above_200k_tokens": 1.5e-05, "rpm": 2000, - "source": "https://ai.google.dev/gemini-api/docs/computer-use", + "source": "https://ai.google.dev/gemini-api/docs/pricing", "supported_endpoints": [ "/v1/chat/completions", "/v1/completions" @@ -26899,7 +26933,9 @@ "google_maps_grounding_cost_per_query": 0.014 }, "gemini/gemini-3-flash-preview": { + "cache_read_input_audio_token_cost": 1e-07, "cache_read_input_token_cost": 5e-08, + "cache_read_input_token_cost_flex": 5e-08, "input_cost_per_audio_token": 1e-06, "input_cost_per_token": 5e-07, "litellm_provider": "gemini", @@ -26949,7 +26985,12 @@ "search_context_size_high": 0.014 }, "web_search_billing_unit": "per_query", - "google_maps_grounding_cost_per_query": 0.014 + "google_maps_grounding_cost_per_query": 0.014, + "input_cost_per_audio_token_batches": 5e-07, + "input_cost_per_token_batches": 2.5e-07, + "input_cost_per_token_flex": 2.5e-07, + "output_cost_per_token_batches": 1.5e-06, + "output_cost_per_token_flex": 1.5e-06 }, "gemini/gemini-3.5-flash": { "prompt_cache_min_tokens": 4096, @@ -27665,11 +27706,13 @@ "cache_read_input_token_cost": 1.25e-07, "input_cost_per_audio_token": 7e-07, "input_cost_per_token": 1e-06, + "input_cost_per_token_batches": 5e-07, "litellm_provider": "gemini", "max_input_tokens": 1048576, "max_output_tokens": 65535, "max_tokens": 65535, "mode": "chat", + "output_cost_per_audio_token": 2e-05, "output_cost_per_token": 2e-05, "rpm": 10000, "source": "https://ai.google.dev/gemini-api/docs/pricing", @@ -55661,7 +55704,7 @@ "mode": "audio_speech", "output_cost_per_audio_token": 1e-05, "output_cost_per_token": 1e-05, - "source": "https://cloud.google.com/gemini-enterprise-agent-platform/generative-ai/pricing", + "source": "https://ai.google.dev/gemini-api/docs/pricing", "supported_endpoints": [ "/v1/audio/speech" ] diff --git a/model_prices_and_context_window.json b/model_prices_and_context_window.json index e63e2e07216..12ee3833ea0 100644 --- a/model_prices_and_context_window.json +++ b/model_prices_and_context_window.json @@ -25899,7 +25899,10 @@ } }, "gemini/gemini-2.5-flash": { + "cache_read_input_audio_token_cost": 1e-07, "cache_read_input_token_cost": 3e-08, + "cache_read_input_token_cost_flex": 3e-08, + "cache_read_input_token_cost_priority": 5.4e-08, "input_cost_per_audio_token": 1e-06, "input_cost_per_token": 3e-07, "litellm_provider": "gemini", @@ -25910,7 +25913,7 @@ "output_cost_per_reasoning_token": 2.5e-06, "output_cost_per_token": 2.5e-06, "rpm": 100000, - "source": "https://ai.google.dev/gemini-api/docs/models#gemini-2.5-flash-preview", + "source": "https://ai.google.dev/gemini-api/docs/pricing", "supported_endpoints": [ "/v1/chat/completions", "/v1/completions", @@ -25944,6 +25947,13 @@ "search_context_size_high": 0.035 }, "google_maps_grounding_cost_per_query": 0.025, + "input_cost_per_audio_token_batches": 5e-07, + "input_cost_per_token_batches": 1.5e-07, + "input_cost_per_token_flex": 1.5e-07, + "input_cost_per_token_priority": 5.4e-07, + "output_cost_per_token_batches": 1.25e-06, + "output_cost_per_token_flex": 1.25e-06, + "output_cost_per_token_priority": 4.5e-06, "supports_image_size": false }, "gemini/gemini-2.5-flash-image": { @@ -25951,6 +25961,9 @@ "deprecation_date": "2026-10-02", "input_cost_per_audio_token": 1e-06, "input_cost_per_token": 3e-07, + "input_cost_per_token_batches": 1.5e-07, + "input_cost_per_token_flex": 1.5e-07, + "input_cost_per_token_priority": 5.4e-07, "litellm_provider": "gemini", "supports_reasoning": false, "max_input_tokens": 32768, @@ -25962,7 +25975,7 @@ "output_cost_per_reasoning_token": 2.5e-06, "output_cost_per_token": 2.5e-06, "rpm": 100000, - "source": "https://ai.google.dev/gemini-api/docs/pricing#gemini-2.5-flash-image", + "source": "https://ai.google.dev/gemini-api/docs/pricing", "supported_endpoints": [ "/v1/chat/completions", "/v1/completions", @@ -26001,6 +26014,8 @@ "input_cost_per_image": 0.0011, "input_cost_per_token": 2e-06, "input_cost_per_token_batches": 1e-06, + "input_cost_per_token_flex": 1e-06, + "input_cost_per_token_priority": 3.6e-06, "litellm_provider": "gemini", "max_input_tokens": 65536, "max_output_tokens": 32768, @@ -26012,7 +26027,9 @@ "rpm": 1000, "tpm": 4000000, "output_cost_per_token_batches": 6e-06, - "source": "https://ai.google.dev/gemini-api/docs/pricing#gemini-3-pro-image", + "output_cost_per_token_flex": 6e-06, + "output_cost_per_token_priority": 2.16e-05, + "source": "https://ai.google.dev/gemini-api/docs/pricing", "supported_endpoints": [ "/v1/chat/completions", "/v1/completions", @@ -26290,7 +26307,10 @@ } }, "gemini/gemini-2.5-flash-lite": { + "cache_read_input_audio_token_cost": 3e-08, "cache_read_input_token_cost": 1e-08, + "cache_read_input_token_cost_flex": 1e-08, + "cache_read_input_token_cost_priority": 1.8e-08, "input_cost_per_audio_token": 3e-07, "input_cost_per_token": 1e-07, "litellm_provider": "gemini", @@ -26301,7 +26321,7 @@ "output_cost_per_reasoning_token": 4e-07, "output_cost_per_token": 4e-07, "rpm": 15, - "source": "https://ai.google.dev/gemini-api/docs/models#gemini-2.5-flash-lite", + "source": "https://ai.google.dev/gemini-api/docs/pricing", "supported_endpoints": [ "/v1/chat/completions", "/v1/completions", @@ -26335,6 +26355,13 @@ "search_context_size_high": 0.035 }, "google_maps_grounding_cost_per_query": 0.025, + "input_cost_per_audio_token_batches": 1.5e-07, + "input_cost_per_token_batches": 5e-08, + "input_cost_per_token_flex": 5e-08, + "input_cost_per_token_priority": 1.8e-07, + "output_cost_per_token_batches": 2e-07, + "output_cost_per_token_flex": 2e-07, + "output_cost_per_token_priority": 7.2e-07, "supports_image_size": false }, "gemini/gemini-2.5-flash-lite-preview-09-2025": { @@ -26585,7 +26612,7 @@ "mode": "audio_speech", "output_cost_per_audio_token": 1e-05, "output_cost_per_token": 1e-05, - "source": "https://cloud.google.com/gemini-enterprise-agent-platform/generative-ai/pricing", + "source": "https://ai.google.dev/gemini-api/docs/pricing", "supported_endpoints": [ "/v1/audio/speech" ], @@ -26595,10 +26622,13 @@ "gemini/gemini-2.5-pro": { "cache_read_input_token_cost": 1.25e-07, "cache_read_input_token_cost_above_200k_tokens": 2.5e-07, + "cache_read_input_token_cost_above_200k_tokens_priority": 4.5e-07, + "cache_read_input_token_cost_flex": 1.25e-07, + "cache_read_input_token_cost_priority": 2.25e-07, "input_cost_per_token": 1.25e-06, "input_cost_per_token_above_200k_tokens": 2.5e-06, - "input_cost_per_token_priority": 1.25e-06, - "input_cost_per_token_above_200k_tokens_priority": 2.5e-06, + "input_cost_per_token_priority": 2.25e-06, + "input_cost_per_token_above_200k_tokens_priority": 4.5e-06, "litellm_provider": "gemini", "max_input_tokens": 1048576, "max_output_tokens": 65535, @@ -26606,10 +26636,10 @@ "mode": "chat", "output_cost_per_token": 1e-05, "output_cost_per_token_above_200k_tokens": 1.5e-05, - "output_cost_per_token_priority": 1e-05, - "output_cost_per_token_above_200k_tokens_priority": 1.5e-05, + "output_cost_per_token_priority": 1.8e-05, + "output_cost_per_token_above_200k_tokens_priority": 2.7e-05, "rpm": 2000, - "source": "https://cloud.google.com/vertex-ai/generative-ai/pricing", + "source": "https://ai.google.dev/gemini-api/docs/pricing", "supported_endpoints": [ "/v1/chat/completions", "/v1/completions" @@ -26640,7 +26670,11 @@ "search_context_size_medium": 0.035, "search_context_size_high": 0.035 }, - "google_maps_grounding_cost_per_query": 0.025 + "google_maps_grounding_cost_per_query": 0.025, + "input_cost_per_token_batches": 6.25e-07, + "input_cost_per_token_flex": 6.25e-07, + "output_cost_per_token_batches": 5e-06, + "output_cost_per_token_flex": 5e-06 }, "gemini/gemini-2.5-computer-use-preview-10-2025": { "input_cost_per_token": 1.25e-06, @@ -26653,7 +26687,7 @@ "output_cost_per_token": 1e-05, "output_cost_per_token_above_200k_tokens": 1.5e-05, "rpm": 2000, - "source": "https://ai.google.dev/gemini-api/docs/computer-use", + "source": "https://ai.google.dev/gemini-api/docs/pricing", "supported_endpoints": [ "/v1/chat/completions", "/v1/completions" @@ -26899,7 +26933,9 @@ "google_maps_grounding_cost_per_query": 0.014 }, "gemini/gemini-3-flash-preview": { + "cache_read_input_audio_token_cost": 1e-07, "cache_read_input_token_cost": 5e-08, + "cache_read_input_token_cost_flex": 5e-08, "input_cost_per_audio_token": 1e-06, "input_cost_per_token": 5e-07, "litellm_provider": "gemini", @@ -26949,7 +26985,12 @@ "search_context_size_high": 0.014 }, "web_search_billing_unit": "per_query", - "google_maps_grounding_cost_per_query": 0.014 + "google_maps_grounding_cost_per_query": 0.014, + "input_cost_per_audio_token_batches": 5e-07, + "input_cost_per_token_batches": 2.5e-07, + "input_cost_per_token_flex": 2.5e-07, + "output_cost_per_token_batches": 1.5e-06, + "output_cost_per_token_flex": 1.5e-06 }, "gemini/gemini-3.5-flash": { "prompt_cache_min_tokens": 4096, @@ -27665,11 +27706,13 @@ "cache_read_input_token_cost": 1.25e-07, "input_cost_per_audio_token": 7e-07, "input_cost_per_token": 1e-06, + "input_cost_per_token_batches": 5e-07, "litellm_provider": "gemini", "max_input_tokens": 1048576, "max_output_tokens": 65535, "max_tokens": 65535, "mode": "chat", + "output_cost_per_audio_token": 2e-05, "output_cost_per_token": 2e-05, "rpm": 10000, "source": "https://ai.google.dev/gemini-api/docs/pricing", @@ -55661,7 +55704,7 @@ "mode": "audio_speech", "output_cost_per_audio_token": 1e-05, "output_cost_per_token": 1e-05, - "source": "https://cloud.google.com/gemini-enterprise-agent-platform/generative-ai/pricing", + "source": "https://ai.google.dev/gemini-api/docs/pricing", "supported_endpoints": [ "/v1/audio/speech" ] From a511c9d45dffdea21e5b974499631ba029a62414 Mon Sep 17 00:00:00 2001 From: "berriai-litellm-provider-info-sync[bot]" <328147090+berriai-litellm-provider-info-sync[bot]@users.noreply.github.com> Date: Tue, 15 Sep 2026 18:41:29 +0000 Subject: [PATCH 024/133] chore(prices): sync Google Gemini prices: 7 models [enrichment failed: Google Gemini, 86 held] gemini/gemini-3.1-flash-image: gemini/gemini-3.1-flash-lite: cache_read_input_audio_token_cost, input_cost_per_audio_token_batches gemini/gemini-3.1-flash-lite-image: gemini-3.1-flash-live-preview: gemini/gemini-3.1-flash-live-preview: gemini/gemini-3.1-flash-tts-preview: output_cost_per_audio_token, input_cost_per_token_batches gemini/gemini-3.1-pro-preview: input_cost_per_token_flex, output_cost_per_token_flex, cache_read_input_token_cost_flex --- ...odel_prices_and_context_window_backup.json | 25 ++++++++++++------- model_prices_and_context_window.json | 25 ++++++++++++------- 2 files changed, 32 insertions(+), 18 deletions(-) diff --git a/litellm/model_prices_and_context_window_backup.json b/litellm/model_prices_and_context_window_backup.json index 12ee3833ea0..02adbbed736 100644 --- a/litellm/model_prices_and_context_window_backup.json +++ b/litellm/model_prices_and_context_window_backup.json @@ -26158,7 +26158,7 @@ "output_cost_per_token_batches": 1.5e-06, "rpm": 1000, "tpm": 4000000, - "source": "https://ai.google.dev/gemini-api/docs/pricing#gemini-3.1-flash-image", + "source": "https://ai.google.dev/gemini-api/docs/pricing", "supported_endpoints": [ "/v1/chat/completions", "/v1/completions", @@ -26243,7 +26243,7 @@ "output_cost_per_token": 1.5e-06, "output_cost_per_token_batches": 7.5e-07, "rpm": 1000, - "source": "https://ai.google.dev/gemini-api/docs/pricing#gemini-3.1-flash-lite-image", + "source": "https://ai.google.dev/gemini-api/docs/pricing", "supported_endpoints": [ "/v1/chat/completions", "/v1/completions", @@ -26815,6 +26815,7 @@ "google_maps_grounding_cost_per_query": 0.014 }, "gemini/gemini-3.1-flash-lite": { + "cache_read_input_audio_token_cost": 5e-08, "cache_read_input_token_cost": 2.5e-08, "cache_read_input_token_cost_flex": 1.25e-08, "cache_read_input_token_cost_priority": 4.5e-08, @@ -26835,7 +26836,7 @@ "output_cost_per_token_flex": 7.5e-07, "output_cost_per_token_priority": 2.7e-06, "rpm": 15, - "source": "https://ai.google.dev/gemini-api/docs/pricing#gemini-3.1-flash-lite", + "source": "https://ai.google.dev/gemini-api/docs/pricing", "supported_endpoints": [ "/v1/chat/completions", "/v1/completions", @@ -26872,7 +26873,8 @@ "search_context_size_high": 0.014 }, "web_search_billing_unit": "per_query", - "google_maps_grounding_cost_per_query": 0.014 + "google_maps_grounding_cost_per_query": 0.014, + "input_cost_per_audio_token_batches": 2.5e-07 }, "gemini/gemini-3.5-flash-lite": { "cache_read_input_token_cost": 3e-08, @@ -27280,7 +27282,7 @@ "output_cost_per_token_above_200k_tokens": 1.8e-05, "output_cost_per_token_batches": 6e-06, "rpm": 2000, - "source": "https://ai.google.dev/gemini-api/docs/models#gemini-3.1-pro-preview", + "source": "https://ai.google.dev/gemini-api/docs/pricing", "supported_endpoints": [ "/v1/chat/completions", "/v1/completions", @@ -27315,13 +27317,16 @@ "output_cost_per_token_above_200k_tokens_priority": 3.24e-05, "cache_read_input_token_cost_priority": 3.6e-07, "cache_read_input_token_cost_above_200k_tokens_priority": 7.2e-07, + "cache_read_input_token_cost_flex": 2e-07, "search_context_cost_per_query": { "search_context_size_low": 0.014, "search_context_size_medium": 0.014, "search_context_size_high": 0.014 }, "web_search_billing_unit": "per_query", - "google_maps_grounding_cost_per_query": 0.014 + "google_maps_grounding_cost_per_query": 0.014, + "input_cost_per_token_flex": 1e-06, + "output_cost_per_token_flex": 6e-06 }, "gemini/gemini-3.1-pro-preview-customtools": { "prompt_cache_min_tokens": 4096, @@ -55540,7 +55545,7 @@ "mode": "realtime", "output_cost_per_audio_token": 1.2e-05, "output_cost_per_token": 4.5e-06, - "source": "https://cloud.google.com/gemini-enterprise-agent-platform/generative-ai/pricing", + "source": "https://ai.google.dev/gemini-api/docs/pricing", "supported_endpoints": [ "/v1/realtime" ], @@ -55658,7 +55663,7 @@ "mode": "realtime", "output_cost_per_audio_token": 1.2e-05, "output_cost_per_token": 4.5e-06, - "source": "https://cloud.google.com/gemini-enterprise-agent-platform/generative-ai/pricing", + "source": "https://ai.google.dev/gemini-api/docs/pricing", "supported_endpoints": [ "/v1/realtime" ], @@ -55684,13 +55689,15 @@ }, "gemini/gemini-3.1-flash-tts-preview": { "input_cost_per_token": 1e-06, + "input_cost_per_token_batches": 5e-07, "litellm_provider": "gemini", "max_input_tokens": 8192, "max_output_tokens": 16384, "max_tokens": 16384, "mode": "audio_speech", + "output_cost_per_audio_token": 2e-05, "output_cost_per_token": 2e-05, - "source": "https://ai.google.dev/gemini-api/docs/models/gemini-3.1-flash-tts-preview", + "source": "https://ai.google.dev/gemini-api/docs/pricing", "supported_endpoints": [ "/v1/audio/speech" ], diff --git a/model_prices_and_context_window.json b/model_prices_and_context_window.json index 12ee3833ea0..02adbbed736 100644 --- a/model_prices_and_context_window.json +++ b/model_prices_and_context_window.json @@ -26158,7 +26158,7 @@ "output_cost_per_token_batches": 1.5e-06, "rpm": 1000, "tpm": 4000000, - "source": "https://ai.google.dev/gemini-api/docs/pricing#gemini-3.1-flash-image", + "source": "https://ai.google.dev/gemini-api/docs/pricing", "supported_endpoints": [ "/v1/chat/completions", "/v1/completions", @@ -26243,7 +26243,7 @@ "output_cost_per_token": 1.5e-06, "output_cost_per_token_batches": 7.5e-07, "rpm": 1000, - "source": "https://ai.google.dev/gemini-api/docs/pricing#gemini-3.1-flash-lite-image", + "source": "https://ai.google.dev/gemini-api/docs/pricing", "supported_endpoints": [ "/v1/chat/completions", "/v1/completions", @@ -26815,6 +26815,7 @@ "google_maps_grounding_cost_per_query": 0.014 }, "gemini/gemini-3.1-flash-lite": { + "cache_read_input_audio_token_cost": 5e-08, "cache_read_input_token_cost": 2.5e-08, "cache_read_input_token_cost_flex": 1.25e-08, "cache_read_input_token_cost_priority": 4.5e-08, @@ -26835,7 +26836,7 @@ "output_cost_per_token_flex": 7.5e-07, "output_cost_per_token_priority": 2.7e-06, "rpm": 15, - "source": "https://ai.google.dev/gemini-api/docs/pricing#gemini-3.1-flash-lite", + "source": "https://ai.google.dev/gemini-api/docs/pricing", "supported_endpoints": [ "/v1/chat/completions", "/v1/completions", @@ -26872,7 +26873,8 @@ "search_context_size_high": 0.014 }, "web_search_billing_unit": "per_query", - "google_maps_grounding_cost_per_query": 0.014 + "google_maps_grounding_cost_per_query": 0.014, + "input_cost_per_audio_token_batches": 2.5e-07 }, "gemini/gemini-3.5-flash-lite": { "cache_read_input_token_cost": 3e-08, @@ -27280,7 +27282,7 @@ "output_cost_per_token_above_200k_tokens": 1.8e-05, "output_cost_per_token_batches": 6e-06, "rpm": 2000, - "source": "https://ai.google.dev/gemini-api/docs/models#gemini-3.1-pro-preview", + "source": "https://ai.google.dev/gemini-api/docs/pricing", "supported_endpoints": [ "/v1/chat/completions", "/v1/completions", @@ -27315,13 +27317,16 @@ "output_cost_per_token_above_200k_tokens_priority": 3.24e-05, "cache_read_input_token_cost_priority": 3.6e-07, "cache_read_input_token_cost_above_200k_tokens_priority": 7.2e-07, + "cache_read_input_token_cost_flex": 2e-07, "search_context_cost_per_query": { "search_context_size_low": 0.014, "search_context_size_medium": 0.014, "search_context_size_high": 0.014 }, "web_search_billing_unit": "per_query", - "google_maps_grounding_cost_per_query": 0.014 + "google_maps_grounding_cost_per_query": 0.014, + "input_cost_per_token_flex": 1e-06, + "output_cost_per_token_flex": 6e-06 }, "gemini/gemini-3.1-pro-preview-customtools": { "prompt_cache_min_tokens": 4096, @@ -55540,7 +55545,7 @@ "mode": "realtime", "output_cost_per_audio_token": 1.2e-05, "output_cost_per_token": 4.5e-06, - "source": "https://cloud.google.com/gemini-enterprise-agent-platform/generative-ai/pricing", + "source": "https://ai.google.dev/gemini-api/docs/pricing", "supported_endpoints": [ "/v1/realtime" ], @@ -55658,7 +55663,7 @@ "mode": "realtime", "output_cost_per_audio_token": 1.2e-05, "output_cost_per_token": 4.5e-06, - "source": "https://cloud.google.com/gemini-enterprise-agent-platform/generative-ai/pricing", + "source": "https://ai.google.dev/gemini-api/docs/pricing", "supported_endpoints": [ "/v1/realtime" ], @@ -55684,13 +55689,15 @@ }, "gemini/gemini-3.1-flash-tts-preview": { "input_cost_per_token": 1e-06, + "input_cost_per_token_batches": 5e-07, "litellm_provider": "gemini", "max_input_tokens": 8192, "max_output_tokens": 16384, "max_tokens": 16384, "mode": "audio_speech", + "output_cost_per_audio_token": 2e-05, "output_cost_per_token": 2e-05, - "source": "https://ai.google.dev/gemini-api/docs/models/gemini-3.1-flash-tts-preview", + "source": "https://ai.google.dev/gemini-api/docs/pricing", "supported_endpoints": [ "/v1/audio/speech" ], From 41caaa301c427088eb836732dc4877b0f00442eb Mon Sep 17 00:00:00 2001 From: "berriai-litellm-provider-info-sync[bot]" <328147090+berriai-litellm-provider-info-sync[bot]@users.noreply.github.com> Date: Tue, 15 Sep 2026 18:46:36 +0000 Subject: [PATCH 025/133] chore(prices): sync prices for 2 providers: 235 models, 59 new [enrichment failed: Google Gemini, 34 held] azure_ai/Codestral-2501: azure_ai/cohere-command-a: azure_ai/deepseek-r1: azure_ai/deepseek-v3: azure_ai/deepseek-v3-0324: azure_ai/deepseek-v3.1: azure_ai/deepseek-v3.2: azure_ai/deepseek-v3.2-speciale: azure_ai/deepseek-v4-flash: azure_ai/DeepSeek-V4-Flash-0731: azure_ai/deepseek-v4-pro: azure_ai/embed-v-4-0: azure_ai/FW-DeepSeek-V3.2: azure_ai/FW-DeepSeek-V4-Pro: azure_ai/FW-GLM-5: azure_ai/FW-GLM-5.1: azure_ai/FW-GLM-5.2: azure_ai/FW-GLM-5.2-Fast: input_cost_per_token, output_cost_per_token, cache_read_input_token_cost azure_ai/FW-Inkling: input_cost_per_token, output_cost_per_token, cache_read_input_token_cost azure_ai/FW-Kimi-K2.5: azure_ai/FW-Kimi-K2.6: azure_ai/FW-Kimi-K2.7-Code: azure_ai/FW-Kimi-K3: azure_ai/FW-MiniMax-M2.5: azure_ai/FW-MiniMax-M3: azure_ai/FW-Nemotron-3-Ultra-NVFP4: input_cost_per_token, output_cost_per_token, cache_read_input_token_cost azure_ai/FW-Nemotron-Lightning-3.5-30B-A3B: azure_ai/gpt-oss-120b: azure_ai/grok-3: azure_ai/global/grok-3: azure_ai/grok-3-mini: azure_ai/global/grok-3-mini: azure_ai/grok-4: azure_ai/grok-4-1-fast-non-reasoning: azure_ai/grok-4-1-fast-reasoning: azure_ai/grok-4-20-non-reasoning: azure_ai/grok-4-20-reasoning: azure_ai/grok-4-fast-non-reasoning: azure_ai/grok-4-fast-reasoning: azure_ai/grok-4.3: azure_ai/grok-4.6: azure_ai/grok-code-fast-1: azure_ai/kimi-k2.5: azure_ai/kimi-k2.6: azure_ai/kimi-k2.7-code: azure_ai/Llama-3.3-70B-Instruct: azure_ai/Llama-4-Maverick-17B-128E-Instruct-FP8: input_cost_per_token, output_cost_per_token azure_ai/MAI-DS-R1: azure_ai/MAI-Image-2.5: azure_ai/MAI-Image-2.5-Flash: azure_ai/MAI-Image-2e: azure_ai/MAI-Thinking-1: azure_ai/mistral-large-3: azure_ai/Phi-3-medium-128k-instruct: azure_ai/Phi-3-medium-4k-instruct: azure_ai/Phi-3-mini-128k-instruct: azure_ai/Phi-3-mini-4k-instruct: azure_ai/Phi-3-small-128k-instruct: azure_ai/Phi-3-small-8k-instruct: azure_ai/Phi-3.5-mini-instruct: --- ...odel_prices_and_context_window_backup.json | 1283 ++++++++++++++--- model_prices_and_context_window.json | 1283 ++++++++++++++--- 2 files changed, 2206 insertions(+), 360 deletions(-) diff --git a/litellm/model_prices_and_context_window_backup.json b/litellm/model_prices_and_context_window_backup.json index 02adbbed736..1d863977e1e 100644 --- a/litellm/model_prices_and_context_window_backup.json +++ b/litellm/model_prices_and_context_window_backup.json @@ -3144,6 +3144,7 @@ "max_tokens": 100000, "mode": "responses", "output_cost_per_token": 6e-06, + "source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus2'%20and%20priceType%20eq%20'Consumption'", "supported_endpoints": [ "/v1/responses" ], @@ -3535,6 +3536,7 @@ "max_tokens": 1024, "mode": "chat", "output_cost_per_token": 1.2e-05, + "source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus2'%20and%20priceType%20eq%20'Consumption'", "supported_endpoints": [ "/v1/responses" ], @@ -3567,7 +3569,7 @@ "max_output_tokens": 131072, "max_tokens": 131072, "mode": "chat", - "source": "https://azure.microsoft.com/en-us/pricing/details/cognitive-services/openai-service/", + "source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus2'%20and%20priceType%20eq%20'Consumption'", "supports_function_calling": true, "supports_parallel_function_calling": true, "supports_response_schema": true, @@ -4172,12 +4174,15 @@ "deprecation_date": "2027-04-14", "cache_read_input_token_cost": 1.375e-06, "input_cost_per_token": 2.75e-06, + "input_cost_per_token_batches": 1.375e-06, "litellm_provider": "azure", "max_input_tokens": 128000, "max_output_tokens": 16384, "max_tokens": 16384, "mode": "chat", "output_cost_per_token": 1.1e-05, + "output_cost_per_token_batches": 5.5e-06, + "source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus2'%20and%20priceType%20eq%20'Consumption'", "supports_function_calling": true, "supports_parallel_function_calling": true, "supports_prompt_caching": true, @@ -4188,13 +4193,17 @@ "azure/eu/gpt-4o-2024-11-20": { "deprecation_date": "2027-04-14", "cache_creation_input_token_cost": 1.38e-06, + "cache_read_input_token_cost": 1.375e-06, "input_cost_per_token": 2.75e-06, + "input_cost_per_token_batches": 1.375e-06, "litellm_provider": "azure", "max_input_tokens": 128000, "max_output_tokens": 16384, "max_tokens": 16384, "mode": "chat", "output_cost_per_token": 1.1e-05, + "output_cost_per_token_batches": 5.5e-06, + "source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus2'%20and%20priceType%20eq%20'Consumption'", "supports_function_calling": true, "supports_parallel_function_calling": true, "supports_response_schema": true, @@ -4205,12 +4214,14 @@ "cache_read_input_token_cost": 8.3e-08, "deprecation_date": "2027-04-14", "input_cost_per_token": 1.65e-07, + "input_cost_per_token_batches": 8.3e-08, "litellm_provider": "azure", "max_input_tokens": 128000, "max_output_tokens": 16384, "max_tokens": 16384, "mode": "chat", "output_cost_per_token": 6.6e-07, + "source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus2'%20and%20priceType%20eq%20'Consumption'", "supports_function_calling": true, "supports_parallel_function_calling": true, "supports_prompt_caching": true, @@ -4351,8 +4362,9 @@ }, "azure/eu/gpt-5.1": { "deprecation_date": "2027-05-15", - "cache_read_input_token_cost": 1.4e-07, - "input_cost_per_token": 1.38e-06, + "cache_read_input_token_cost": 1.375e-07, + "cache_read_input_token_cost_priority": 2.75e-07, + "input_cost_per_token": 1.375e-06, "litellm_provider": "azure", "max_input_tokens": 272000, "max_output_tokens": 128000, @@ -4383,12 +4395,17 @@ "supports_tool_choice": true, "supports_vision": true, "supports_none_reasoning_effort": true, - "default_reasoning_effort": "none" + "default_reasoning_effort": "none", + "input_cost_per_token_batches": 6.875e-07, + "input_cost_per_token_priority": 2.75e-06, + "output_cost_per_token_batches": 5.5e-06, + "output_cost_per_token_priority": 2.2e-05, + "source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus2'%20and%20priceType%20eq%20'Consumption'" }, "azure/eu/gpt-5.1-chat": { - "cache_read_input_token_cost": 1.4e-07, + "cache_read_input_token_cost": 1.375e-07, "deprecation_date": "2026-06-29", - "input_cost_per_token": 1.38e-06, + "input_cost_per_token": 1.375e-06, "litellm_provider": "azure", "max_input_tokens": 128000, "max_output_tokens": 128000, @@ -4419,18 +4436,20 @@ "supports_tool_choice": true, "supports_vision": true, "supports_none_reasoning_effort": true, - "default_reasoning_effort": "none" + "default_reasoning_effort": "none", + "source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus2'%20and%20priceType%20eq%20'Consumption'" }, "azure/eu/gpt-5.1-codex": { "deprecation_date": "2027-05-15", - "cache_read_input_token_cost": 1.4e-07, - "input_cost_per_token": 1.38e-06, + "cache_read_input_token_cost": 1.375e-07, + "input_cost_per_token": 1.375e-06, "litellm_provider": "azure", "max_input_tokens": 272000, "max_output_tokens": 128000, "max_tokens": 128000, "mode": "responses", "output_cost_per_token": 1.1e-05, + "source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus2'%20and%20priceType%20eq%20'Consumption'", "supported_endpoints": [ "/v1/responses" ], @@ -4454,7 +4473,7 @@ }, "azure/eu/gpt-5.1-codex-mini": { "deprecation_date": "2027-05-15", - "cache_read_input_token_cost": 2.8e-08, + "cache_read_input_token_cost": 2.75e-08, "input_cost_per_token": 2.75e-07, "litellm_provider": "azure", "max_input_tokens": 272000, @@ -4462,6 +4481,7 @@ "max_tokens": 128000, "mode": "responses", "output_cost_per_token": 2.2e-06, + "source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus2'%20and%20priceType%20eq%20'Consumption'", "supported_endpoints": [ "/v1/responses" ], @@ -4520,12 +4540,15 @@ "cache_read_input_token_cost": 8.25e-06, "deprecation_date": "2026-11-19", "input_cost_per_token": 1.65e-05, + "input_cost_per_token_batches": 8.25e-06, "litellm_provider": "azure", "max_input_tokens": 200000, "max_output_tokens": 100000, "max_tokens": 100000, "mode": "chat", "output_cost_per_token": 6.6e-05, + "output_cost_per_token_batches": 3.3e-05, + "source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus2'%20and%20priceType%20eq%20'Consumption'", "supports_function_calling": true, "supports_parallel_function_calling": true, "supports_prompt_caching": true, @@ -4574,6 +4597,7 @@ "mode": "chat", "output_cost_per_token": 4.84e-06, "output_cost_per_token_batches": 2.42e-06, + "source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus2'%20and%20priceType%20eq%20'Consumption'", "supports_prompt_caching": true, "supports_reasoning": true, "supports_tool_choice": true, @@ -4583,12 +4607,15 @@ "cache_read_input_token_cost": 1.25e-06, "deprecation_date": "2027-04-14", "input_cost_per_token": 2.5e-06, + "input_cost_per_token_batches": 1.25e-06, "litellm_provider": "azure", "max_input_tokens": 128000, "max_output_tokens": 16384, "max_tokens": 16384, "mode": "chat", "output_cost_per_token": 1e-05, + "output_cost_per_token_batches": 5e-06, + "source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus2'%20and%20priceType%20eq%20'Consumption'", "supports_function_calling": true, "supports_parallel_function_calling": true, "supports_prompt_caching": true, @@ -4600,12 +4627,15 @@ "cache_read_input_token_cost": 1.25e-06, "deprecation_date": "2027-04-14", "input_cost_per_token": 2.5e-06, + "input_cost_per_token_batches": 1.25e-06, "litellm_provider": "azure", "max_input_tokens": 128000, "max_output_tokens": 16384, "max_tokens": 16384, "mode": "chat", "output_cost_per_token": 1e-05, + "output_cost_per_token_batches": 5e-06, + "source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus2'%20and%20priceType%20eq%20'Consumption'", "supports_function_calling": true, "supports_parallel_function_calling": true, "supports_response_schema": true, @@ -4631,12 +4661,15 @@ "deprecation_date": "2027-04-14", "cache_read_input_token_cost": 1.25e-06, "input_cost_per_token": 2.5e-06, + "input_cost_per_token_batches": 1.25e-06, "litellm_provider": "azure", "max_input_tokens": 128000, "max_output_tokens": 16384, "max_tokens": 16384, "mode": "chat", "output_cost_per_token": 1e-05, + "output_cost_per_token_batches": 5e-06, + "source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus2'%20and%20priceType%20eq%20'Consumption'", "supports_function_calling": true, "supports_parallel_function_calling": true, "supports_prompt_caching": true, @@ -4648,12 +4681,15 @@ "deprecation_date": "2027-04-14", "cache_read_input_token_cost": 1.25e-06, "input_cost_per_token": 2.5e-06, + "input_cost_per_token_batches": 1.25e-06, "litellm_provider": "azure", "max_input_tokens": 128000, "max_output_tokens": 16384, "max_tokens": 16384, "mode": "chat", "output_cost_per_token": 1e-05, + "output_cost_per_token_batches": 5e-06, + "source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus2'%20and%20priceType%20eq%20'Consumption'", "supports_function_calling": true, "supports_parallel_function_calling": true, "supports_prompt_caching": true, @@ -4664,6 +4700,7 @@ "azure/global/gpt-5.1": { "deprecation_date": "2027-05-15", "cache_read_input_token_cost": 1.25e-07, + "cache_read_input_token_cost_priority": 2.5e-07, "input_cost_per_token": 1.25e-06, "litellm_provider": "azure", "max_input_tokens": 272000, @@ -4695,7 +4732,12 @@ "supports_tool_choice": true, "supports_vision": true, "supports_none_reasoning_effort": true, - "default_reasoning_effort": "none" + "default_reasoning_effort": "none", + "input_cost_per_token_batches": 6.25e-07, + "input_cost_per_token_priority": 2.5e-06, + "output_cost_per_token_batches": 5e-06, + "output_cost_per_token_priority": 2e-05, + "source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus2'%20and%20priceType%20eq%20'Consumption'" }, "azure/global/gpt-5.1-chat": { "cache_read_input_token_cost": 1.25e-07, @@ -4731,7 +4773,8 @@ "supports_tool_choice": true, "supports_vision": true, "supports_none_reasoning_effort": true, - "default_reasoning_effort": "none" + "default_reasoning_effort": "none", + "source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus2'%20and%20priceType%20eq%20'Consumption'" }, "azure/global/gpt-5.1-codex": { "deprecation_date": "2027-05-15", @@ -4743,6 +4786,7 @@ "max_tokens": 128000, "mode": "responses", "output_cost_per_token": 1e-05, + "source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus2'%20and%20priceType%20eq%20'Consumption'", "supported_endpoints": [ "/v1/responses" ], @@ -4774,6 +4818,7 @@ "max_tokens": 128000, "mode": "responses", "output_cost_per_token": 2e-06, + "source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus2'%20and%20priceType%20eq%20'Consumption'", "supported_endpoints": [ "/v1/responses" ], @@ -5006,8 +5051,10 @@ "azure/gpt-4.1": { "deprecation_date": "2027-04-14", "cache_read_input_token_cost": 5e-07, + "cache_read_input_token_cost_priority": 8.75e-07, "input_cost_per_token": 2e-06, "input_cost_per_token_batches": 1e-06, + "input_cost_per_token_priority": 3.5e-06, "litellm_provider": "azure", "max_input_tokens": 1047576, "max_output_tokens": 32768, @@ -5015,6 +5062,8 @@ "mode": "chat", "output_cost_per_token": 8e-06, "output_cost_per_token_batches": 4e-06, + "output_cost_per_token_priority": 1.4e-05, + "source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus2'%20and%20priceType%20eq%20'Consumption'", "supported_endpoints": [ "/v1/chat/completions", "/v1/batch", @@ -5040,8 +5089,10 @@ "azure/gpt-4.1-2025-04-14": { "deprecation_date": "2027-04-14", "cache_read_input_token_cost": 5e-07, + "cache_read_input_token_cost_priority": 8.75e-07, "input_cost_per_token": 2e-06, "input_cost_per_token_batches": 1e-06, + "input_cost_per_token_priority": 3.5e-06, "litellm_provider": "azure", "max_input_tokens": 1047576, "max_output_tokens": 32768, @@ -5049,6 +5100,8 @@ "mode": "chat", "output_cost_per_token": 8e-06, "output_cost_per_token_batches": 4e-06, + "output_cost_per_token_priority": 1.4e-05, + "source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus2'%20and%20priceType%20eq%20'Consumption'", "supported_endpoints": [ "/v1/chat/completions", "/v1/batch", @@ -5074,8 +5127,10 @@ "azure/gpt-4.1-mini": { "deprecation_date": "2027-04-14", "cache_read_input_token_cost": 1e-07, + "cache_read_input_token_cost_priority": 1.75e-07, "input_cost_per_token": 4e-07, "input_cost_per_token_batches": 2e-07, + "input_cost_per_token_priority": 7e-07, "litellm_provider": "azure", "max_input_tokens": 1047576, "max_output_tokens": 32768, @@ -5083,6 +5138,8 @@ "mode": "chat", "output_cost_per_token": 1.6e-06, "output_cost_per_token_batches": 8e-07, + "output_cost_per_token_priority": 2.8e-06, + "source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus2'%20and%20priceType%20eq%20'Consumption'", "supported_endpoints": [ "/v1/chat/completions", "/v1/batch", @@ -5108,8 +5165,10 @@ "azure/gpt-4.1-mini-2025-04-14": { "deprecation_date": "2027-04-14", "cache_read_input_token_cost": 1e-07, + "cache_read_input_token_cost_priority": 1.75e-07, "input_cost_per_token": 4e-07, "input_cost_per_token_batches": 2e-07, + "input_cost_per_token_priority": 7e-07, "litellm_provider": "azure", "max_input_tokens": 1047576, "max_output_tokens": 32768, @@ -5117,6 +5176,8 @@ "mode": "chat", "output_cost_per_token": 1.6e-06, "output_cost_per_token_batches": 8e-07, + "output_cost_per_token_priority": 2.8e-06, + "source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus2'%20and%20priceType%20eq%20'Consumption'", "supported_endpoints": [ "/v1/chat/completions", "/v1/batch", @@ -5151,6 +5212,7 @@ "mode": "chat", "output_cost_per_token": 4e-07, "output_cost_per_token_batches": 2e-07, + "source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus2'%20and%20priceType%20eq%20'Consumption'", "supported_endpoints": [ "/v1/chat/completions", "/v1/batch", @@ -5184,6 +5246,7 @@ "mode": "chat", "output_cost_per_token": 4e-07, "output_cost_per_token_batches": 2e-07, + "source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus2'%20and%20priceType%20eq%20'Consumption'", "supported_endpoints": [ "/v1/chat/completions", "/v1/batch", @@ -5243,12 +5306,15 @@ "azure/gpt-4o-2024-05-13": { "deprecation_date": "2026-10-01", "input_cost_per_token": 5e-06, + "input_cost_per_token_batches": 2.5e-06, "litellm_provider": "azure", "max_input_tokens": 128000, "max_output_tokens": 4096, "max_tokens": 4096, "mode": "chat", "output_cost_per_token": 1.5e-05, + "output_cost_per_token_batches": 7.5e-06, + "source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus2'%20and%20priceType%20eq%20'Consumption'", "supports_function_calling": true, "supports_parallel_function_calling": true, "supports_prompt_caching": true, @@ -5259,12 +5325,15 @@ "deprecation_date": "2027-04-14", "cache_read_input_token_cost": 1.25e-06, "input_cost_per_token": 2.5e-06, + "input_cost_per_token_batches": 1.25e-06, "litellm_provider": "azure", "max_input_tokens": 128000, "max_output_tokens": 16384, "max_tokens": 16384, "mode": "chat", "output_cost_per_token": 1e-05, + "output_cost_per_token_batches": 5e-06, + "source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus2'%20and%20priceType%20eq%20'Consumption'", "supports_function_calling": true, "supports_parallel_function_calling": true, "supports_prompt_caching": true, @@ -5275,13 +5344,16 @@ "azure/gpt-4o-2024-11-20": { "deprecation_date": "2027-04-14", "cache_read_input_token_cost": 1.25e-06, - "input_cost_per_token": 2.75e-06, + "input_cost_per_token": 2.5e-06, + "input_cost_per_token_batches": 1.25e-06, "litellm_provider": "azure", "max_input_tokens": 128000, "max_output_tokens": 16384, "max_tokens": 16384, "mode": "chat", - "output_cost_per_token": 1.1e-05, + "output_cost_per_token": 1e-05, + "output_cost_per_token_batches": 5e-06, + "source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus2'%20and%20priceType%20eq%20'Consumption'", "supports_function_calling": true, "supports_parallel_function_calling": true, "supports_prompt_caching": true, @@ -5468,13 +5540,16 @@ "azure/gpt-4o-mini-2024-07-18": { "cache_read_input_token_cost": 7.5e-08, "deprecation_date": "2027-04-14", - "input_cost_per_token": 1.65e-07, + "input_cost_per_token": 1.5e-07, + "input_cost_per_token_batches": 7.5e-08, "litellm_provider": "azure", "max_input_tokens": 128000, "max_output_tokens": 16384, "max_tokens": 16384, "mode": "chat", - "output_cost_per_token": 6.6e-07, + "output_cost_per_token": 6e-07, + "output_cost_per_token_batches": 3e-07, + "source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus2'%20and%20priceType%20eq%20'Consumption'", "supports_function_calling": true, "supports_parallel_function_calling": true, "supports_prompt_caching": true, @@ -5925,6 +6000,9 @@ "supports_vision": true, "supports_none_reasoning_effort": true, "default_reasoning_effort": "none", + "input_cost_per_token_batches": 6.25e-07, + "output_cost_per_token_batches": 5e-06, + "source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus2'%20and%20priceType%20eq%20'Consumption'", "supports_minimal_reasoning_effort": true }, "azure/gpt-5.1-chat-2025-11-13": { @@ -5963,7 +6041,8 @@ "supports_tool_choice": false, "supports_vision": true, "supports_none_reasoning_effort": true, - "default_reasoning_effort": "none" + "default_reasoning_effort": "none", + "source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus2'%20and%20priceType%20eq%20'Consumption'" }, "azure/gpt-5.1-codex-2025-11-13": { "cache_read_input_token_cost": 1.25e-07, @@ -5978,6 +6057,7 @@ "mode": "responses", "output_cost_per_token": 1e-05, "output_cost_per_token_priority": 2e-05, + "source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus2'%20and%20priceType%20eq%20'Consumption'", "supported_endpoints": [ "/v1/responses" ], @@ -6012,6 +6092,7 @@ "mode": "responses", "output_cost_per_token": 2e-06, "output_cost_per_token_priority": 3.6e-06, + "source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus2'%20and%20priceType%20eq%20'Consumption'", "supported_endpoints": [ "/v1/responses" ], @@ -6036,13 +6117,19 @@ "azure/gpt-5": { "deprecation_date": "2027-02-09", "cache_read_input_token_cost": 1.25e-07, + "cache_read_input_token_cost_priority": 2.5e-07, "input_cost_per_token": 1.25e-06, + "input_cost_per_token_batches": 6.25e-07, + "input_cost_per_token_priority": 2.5e-06, "litellm_provider": "azure", "max_input_tokens": 272000, "max_output_tokens": 128000, "max_tokens": 128000, "mode": "chat", "output_cost_per_token": 1e-05, + "output_cost_per_token_batches": 5e-06, + "output_cost_per_token_priority": 2e-05, + "source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus2'%20and%20priceType%20eq%20'Consumption'", "supported_endpoints": [ "/v1/chat/completions", "/v1/batch", @@ -6068,14 +6155,20 @@ }, "azure/gpt-5-2025-08-07": { "cache_read_input_token_cost": 1.25e-07, + "cache_read_input_token_cost_priority": 2.5e-07, "deprecation_date": "2027-02-09", "input_cost_per_token": 1.25e-06, + "input_cost_per_token_batches": 6.25e-07, + "input_cost_per_token_priority": 2.5e-06, "litellm_provider": "azure", "max_input_tokens": 272000, "max_output_tokens": 128000, "max_tokens": 128000, "mode": "chat", "output_cost_per_token": 1e-05, + "output_cost_per_token_batches": 5e-06, + "output_cost_per_token_priority": 2e-05, + "source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus2'%20and%20priceType%20eq%20'Consumption'", "supported_endpoints": [ "/v1/chat/completions", "/v1/batch", @@ -6109,7 +6202,7 @@ "max_tokens": 16384, "mode": "chat", "output_cost_per_token": 1e-05, - "source": "https://azure.microsoft.com/en-us/blog/gpt-5-in-azure-ai-foundry-the-future-of-ai-apps-and-agents-starts-here/", + "source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus2'%20and%20priceType%20eq%20'Consumption'", "supported_endpoints": [ "/v1/chat/completions", "/v1/batch", @@ -6176,6 +6269,7 @@ "max_tokens": 128000, "mode": "responses", "output_cost_per_token": 1e-05, + "source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus2'%20and%20priceType%20eq%20'Consumption'", "supported_endpoints": [ "/v1/responses" ], @@ -6200,13 +6294,19 @@ "azure/gpt-5-mini": { "deprecation_date": "2027-02-09", "cache_read_input_token_cost": 2.5e-08, + "cache_read_input_token_cost_priority": 4.5e-08, "input_cost_per_token": 2.5e-07, + "input_cost_per_token_batches": 1.25e-07, + "input_cost_per_token_priority": 4.5e-07, "litellm_provider": "azure", "max_input_tokens": 272000, "max_output_tokens": 128000, "max_tokens": 128000, "mode": "chat", "output_cost_per_token": 2e-06, + "output_cost_per_token_batches": 1e-06, + "output_cost_per_token_priority": 3.6e-06, + "source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus2'%20and%20priceType%20eq%20'Consumption'", "supported_endpoints": [ "/v1/chat/completions", "/v1/batch", @@ -6232,14 +6332,20 @@ }, "azure/gpt-5-mini-2025-08-07": { "cache_read_input_token_cost": 2.5e-08, + "cache_read_input_token_cost_priority": 4.5e-08, "deprecation_date": "2027-02-09", "input_cost_per_token": 2.5e-07, + "input_cost_per_token_batches": 1.25e-07, + "input_cost_per_token_priority": 4.5e-07, "litellm_provider": "azure", "max_input_tokens": 272000, "max_output_tokens": 128000, "max_tokens": 128000, "mode": "chat", "output_cost_per_token": 2e-06, + "output_cost_per_token_batches": 1e-06, + "output_cost_per_token_priority": 3.6e-06, + "source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus2'%20and%20priceType%20eq%20'Consumption'", "supported_endpoints": [ "/v1/chat/completions", "/v1/batch", @@ -6267,12 +6373,15 @@ "deprecation_date": "2027-02-09", "cache_read_input_token_cost": 5e-09, "input_cost_per_token": 5e-08, + "input_cost_per_token_batches": 2.5e-08, "litellm_provider": "azure", "max_input_tokens": 272000, "max_output_tokens": 128000, "max_tokens": 128000, "mode": "chat", "output_cost_per_token": 4e-07, + "output_cost_per_token_batches": 2e-07, + "source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus2'%20and%20priceType%20eq%20'Consumption'", "supported_endpoints": [ "/v1/chat/completions", "/v1/batch", @@ -6300,12 +6409,15 @@ "cache_read_input_token_cost": 5e-09, "deprecation_date": "2027-02-09", "input_cost_per_token": 5e-08, + "input_cost_per_token_batches": 2.5e-08, "litellm_provider": "azure", "max_input_tokens": 272000, "max_output_tokens": 128000, "max_tokens": 128000, "mode": "chat", "output_cost_per_token": 4e-07, + "output_cost_per_token_batches": 2e-07, + "source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus2'%20and%20priceType%20eq%20'Consumption'", "supported_endpoints": [ "/v1/chat/completions", "/v1/batch", @@ -6332,13 +6444,15 @@ "azure/gpt-5-pro": { "deprecation_date": "2027-04-07", "input_cost_per_token": 1.5e-05, + "input_cost_per_token_batches": 7.5e-06, "litellm_provider": "azure", "max_input_tokens": 272000, "max_output_tokens": 128000, "max_tokens": 128000, "mode": "responses", "output_cost_per_token": 0.00012, - "source": "https://learn.microsoft.com/en-us/azure/ai-foundry/foundry-models/concepts/models-sold-directly-by-azure?pivots=azure-openai&tabs=global-standard-aoai%2Cstandard-chat-completions%2Cglobal-standard#gpt-5", + "output_cost_per_token_batches": 6e-05, + "source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus2'%20and%20priceType%20eq%20'Consumption'", "supported_endpoints": [ "/v1/responses" ], @@ -6362,6 +6476,7 @@ "azure/gpt-5.1": { "deprecation_date": "2027-05-15", "cache_read_input_token_cost": 1.25e-07, + "cache_read_input_token_cost_priority": 2.5e-07, "input_cost_per_token": 1.25e-06, "litellm_provider": "azure", "max_input_tokens": 272000, @@ -6393,7 +6508,12 @@ "supports_tool_choice": true, "supports_vision": true, "supports_none_reasoning_effort": true, - "default_reasoning_effort": "none" + "default_reasoning_effort": "none", + "input_cost_per_token_batches": 6.25e-07, + "input_cost_per_token_priority": 2.5e-06, + "output_cost_per_token_batches": 5e-06, + "output_cost_per_token_priority": 2e-05, + "source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus2'%20and%20priceType%20eq%20'Consumption'" }, "azure/gpt-5.1-chat": { "cache_read_input_token_cost": 1.25e-07, @@ -6429,7 +6549,8 @@ "supports_tool_choice": true, "supports_vision": true, "supports_none_reasoning_effort": true, - "default_reasoning_effort": "none" + "default_reasoning_effort": "none", + "source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus2'%20and%20priceType%20eq%20'Consumption'" }, "azure/gpt-5.1-codex": { "deprecation_date": "2027-05-15", @@ -6441,6 +6562,7 @@ "max_tokens": 128000, "mode": "responses", "output_cost_per_token": 1e-05, + "source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus2'%20and%20priceType%20eq%20'Consumption'", "supported_endpoints": [ "/v1/responses" ], @@ -6472,6 +6594,7 @@ "max_tokens": 128000, "mode": "responses", "output_cost_per_token": 1e-05, + "source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus2'%20and%20priceType%20eq%20'Consumption'", "supported_endpoints": [ "/v1/responses" ], @@ -6503,6 +6626,7 @@ "max_tokens": 128000, "mode": "responses", "output_cost_per_token": 2e-06, + "source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus2'%20and%20priceType%20eq%20'Consumption'", "supported_endpoints": [ "/v1/responses" ], @@ -6527,13 +6651,19 @@ "azure/gpt-5.2": { "deprecation_date": "2027-06-08", "cache_read_input_token_cost": 1.75e-07, + "cache_read_input_token_cost_priority": 3.5e-07, "input_cost_per_token": 1.75e-06, + "input_cost_per_token_batches": 8.75e-07, + "input_cost_per_token_priority": 3.5e-06, "litellm_provider": "azure", "max_input_tokens": 272000, "max_output_tokens": 128000, "max_tokens": 128000, "mode": "chat", "output_cost_per_token": 1.4e-05, + "output_cost_per_token_batches": 7e-06, + "output_cost_per_token_priority": 2.8e-05, + "source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus2'%20and%20priceType%20eq%20'Consumption'", "supported_endpoints": [ "/v1/chat/completions", "/v1/batch", @@ -6563,6 +6693,7 @@ "cache_read_input_token_cost_priority": 3.5e-07, "deprecation_date": "2027-06-08", "input_cost_per_token": 1.75e-06, + "input_cost_per_token_batches": 8.75e-07, "input_cost_per_token_priority": 3.5e-06, "litellm_provider": "azure", "max_input_tokens": 272000, @@ -6570,7 +6701,9 @@ "max_tokens": 128000, "mode": "chat", "output_cost_per_token": 1.4e-05, + "output_cost_per_token_batches": 7e-06, "output_cost_per_token_priority": 2.8e-05, + "source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus2'%20and%20priceType%20eq%20'Consumption'", "supported_endpoints": [ "/v1/chat/completions", "/v1/batch", @@ -6608,6 +6741,7 @@ "mode": "chat", "output_cost_per_token": 1.4e-05, "output_cost_per_token_priority": 2.8e-05, + "source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus2'%20and%20priceType%20eq%20'Consumption'", "supported_endpoints": [ "/v1/chat/completions", "/v1/responses" @@ -6643,6 +6777,7 @@ "mode": "chat", "output_cost_per_token": 1.4e-05, "output_cost_per_token_priority": 2.8e-05, + "source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus2'%20and%20priceType%20eq%20'Consumption'", "supported_endpoints": [ "/v1/chat/completions", "/v1/responses" @@ -6675,6 +6810,7 @@ "max_tokens": 128000, "mode": "responses", "output_cost_per_token": 1.4e-05, + "source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus2'%20and%20priceType%20eq%20'Consumption'", "supported_endpoints": [ "/v1/responses" ], @@ -6709,6 +6845,7 @@ "mode": "chat", "output_cost_per_token": 1.4e-05, "output_cost_per_token_priority": 2.8e-05, + "source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus2'%20and%20priceType%20eq%20'Consumption'", "supported_endpoints": [ "/v1/chat/completions", "/v1/responses" @@ -6733,14 +6870,18 @@ }, "azure/gpt-5.3-codex": { "cache_read_input_token_cost": 1.75e-07, + "cache_read_input_token_cost_priority": 3.5e-07, "deprecation_date": "2027-08-24", "input_cost_per_token": 1.75e-06, + "input_cost_per_token_priority": 3.5e-06, "litellm_provider": "azure", "max_input_tokens": 272000, "max_output_tokens": 128000, "max_tokens": 128000, "mode": "responses", "output_cost_per_token": 1.4e-05, + "output_cost_per_token_priority": 2.8e-05, + "source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus2'%20and%20priceType%20eq%20'Consumption'", "supported_endpoints": [ "/v1/responses" ], @@ -6764,17 +6905,20 @@ }, "azure/gpt-5.2-pro": { "input_cost_per_token": 2.1e-05, + "input_cost_per_token_batches": 1.05e-05, "litellm_provider": "azure", "max_input_tokens": 272000, "max_output_tokens": 128000, "max_tokens": 128000, "mode": "responses", "output_cost_per_token": 0.000168, + "output_cost_per_token_batches": 8.4e-05, "search_context_cost_per_query": { "search_context_size_high": 0.01, "search_context_size_low": 0.01, "search_context_size_medium": 0.01 }, + "source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus2'%20and%20priceType%20eq%20'Consumption'", "supported_endpoints": [ "/v1/batch", "/v1/responses" @@ -6800,17 +6944,20 @@ }, "azure/gpt-5.2-pro-2025-12-11": { "input_cost_per_token": 2.1e-05, + "input_cost_per_token_batches": 1.05e-05, "litellm_provider": "azure", "max_input_tokens": 272000, "max_output_tokens": 128000, "max_tokens": 128000, "mode": "responses", "output_cost_per_token": 0.000168, + "output_cost_per_token_batches": 8.4e-05, "search_context_cost_per_query": { "search_context_size_high": 0.01, "search_context_size_low": 0.01, "search_context_size_medium": 0.01 }, + "source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus2'%20and%20priceType%20eq%20'Consumption'", "supported_endpoints": [ "/v1/batch", "/v1/responses" @@ -6838,6 +6985,7 @@ "deprecation_date": "2027-09-02", "cache_read_input_token_cost": 2.5e-07, "cache_read_input_token_cost_above_272k_tokens": 5e-07, + "cache_read_input_token_cost_above_272k_tokens_flex": 2.5e-07, "cache_read_input_token_cost_priority": 5e-07, "cache_read_input_token_cost_above_272k_tokens_priority": 1e-06, "input_cost_per_token": 2.5e-06, @@ -6877,12 +7025,20 @@ "supports_vision": true, "supports_none_reasoning_effort": true, "default_reasoning_effort": "none", + "input_cost_per_token_above_272k_tokens_flex": 2.5e-06, + "input_cost_per_token_batches": 1.25e-06, + "input_cost_per_token_flex": 1.25e-06, + "output_cost_per_token_above_272k_tokens_flex": 1.125e-05, + "output_cost_per_token_batches": 7.5e-06, + "output_cost_per_token_flex": 7.5e-06, + "source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus2'%20and%20priceType%20eq%20'Consumption'", "supports_xhigh_reasoning_effort": true, "supports_minimal_reasoning_effort": true }, "azure/us/gpt-5.4": { "deprecation_date": "2027-09-02", - "cache_read_input_token_cost": 2.8e-07, + "cache_read_input_token_cost": 2.75e-07, + "cache_read_input_token_cost_above_272k_tokens": 5.5e-07, "cache_read_input_token_cost_priority": 5.5e-07, "input_cost_per_token": 2.75e-06, "input_cost_per_token_priority": 5.5e-06, @@ -6917,12 +7073,18 @@ "supports_vision": true, "supports_none_reasoning_effort": true, "default_reasoning_effort": "none", + "input_cost_per_token_above_272k_tokens": 5.5e-06, + "input_cost_per_token_batches": 1.375e-06, + "output_cost_per_token_above_272k_tokens": 2.475e-05, + "output_cost_per_token_batches": 8.25e-06, + "source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus2'%20and%20priceType%20eq%20'Consumption'", "supports_xhigh_reasoning_effort": true, "supports_minimal_reasoning_effort": true }, "azure/eu/gpt-5.4": { "deprecation_date": "2027-09-02", - "cache_read_input_token_cost": 2.8e-07, + "cache_read_input_token_cost": 2.75e-07, + "cache_read_input_token_cost_above_272k_tokens": 5.5e-07, "cache_read_input_token_cost_priority": 5.5e-07, "input_cost_per_token": 2.75e-06, "input_cost_per_token_priority": 5.5e-06, @@ -6957,12 +7119,18 @@ "supports_vision": true, "supports_none_reasoning_effort": true, "default_reasoning_effort": "none", + "input_cost_per_token_above_272k_tokens": 5.5e-06, + "input_cost_per_token_batches": 1.375e-06, + "output_cost_per_token_above_272k_tokens": 2.475e-05, + "output_cost_per_token_batches": 8.25e-06, + "source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus2'%20and%20priceType%20eq%20'Consumption'", "supports_xhigh_reasoning_effort": true, "supports_minimal_reasoning_effort": true }, "azure/gpt-5.4-2026-03-05": { "cache_read_input_token_cost": 2.5e-07, "cache_read_input_token_cost_above_272k_tokens": 5e-07, + "cache_read_input_token_cost_above_272k_tokens_flex": 2.5e-07, "cache_read_input_token_cost_priority": 5e-07, "cache_read_input_token_cost_above_272k_tokens_priority": 1e-06, "deprecation_date": "2027-09-02", @@ -7003,11 +7171,19 @@ "supports_vision": true, "supports_none_reasoning_effort": true, "default_reasoning_effort": "none", + "input_cost_per_token_above_272k_tokens_flex": 2.5e-06, + "input_cost_per_token_batches": 1.25e-06, + "input_cost_per_token_flex": 1.25e-06, + "output_cost_per_token_above_272k_tokens_flex": 1.125e-05, + "output_cost_per_token_batches": 7.5e-06, + "output_cost_per_token_flex": 7.5e-06, + "source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus2'%20and%20priceType%20eq%20'Consumption'", "supports_xhigh_reasoning_effort": true, "supports_minimal_reasoning_effort": true }, "azure/us/gpt-5.4-2026-03-05": { - "cache_read_input_token_cost": 2.8e-07, + "cache_read_input_token_cost": 2.75e-07, + "cache_read_input_token_cost_above_272k_tokens": 5.5e-07, "cache_read_input_token_cost_priority": 5.5e-07, "deprecation_date": "2027-09-02", "input_cost_per_token": 2.75e-06, @@ -7043,11 +7219,17 @@ "supports_vision": true, "supports_none_reasoning_effort": true, "default_reasoning_effort": "none", + "input_cost_per_token_above_272k_tokens": 5.5e-06, + "input_cost_per_token_batches": 1.375e-06, + "output_cost_per_token_above_272k_tokens": 2.475e-05, + "output_cost_per_token_batches": 8.25e-06, + "source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus2'%20and%20priceType%20eq%20'Consumption'", "supports_xhigh_reasoning_effort": true, "supports_minimal_reasoning_effort": true }, "azure/eu/gpt-5.4-2026-03-05": { - "cache_read_input_token_cost": 2.8e-07, + "cache_read_input_token_cost": 2.75e-07, + "cache_read_input_token_cost_above_272k_tokens": 5.5e-07, "cache_read_input_token_cost_priority": 5.5e-07, "deprecation_date": "2027-09-02", "input_cost_per_token": 2.75e-06, @@ -7083,6 +7265,11 @@ "supports_vision": true, "supports_none_reasoning_effort": true, "default_reasoning_effort": "none", + "input_cost_per_token_above_272k_tokens": 5.5e-06, + "input_cost_per_token_batches": 1.375e-06, + "output_cost_per_token_above_272k_tokens": 2.475e-05, + "output_cost_per_token_batches": 8.25e-06, + "source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus2'%20and%20priceType%20eq%20'Consumption'", "supports_xhigh_reasoning_effort": true, "supports_minimal_reasoning_effort": true }, @@ -7092,6 +7279,9 @@ "cache_read_input_token_cost_above_272k_tokens": 6e-06, "input_cost_per_token": 3e-05, "input_cost_per_token_above_272k_tokens": 6e-05, + "input_cost_per_token_above_272k_tokens_flex": 3e-05, + "input_cost_per_token_batches": 1.5e-05, + "input_cost_per_token_flex": 1.5e-05, "litellm_provider": "azure", "max_input_tokens": 1050000, "max_output_tokens": 128000, @@ -7099,11 +7289,15 @@ "mode": "responses", "output_cost_per_token": 0.00018, "output_cost_per_token_above_272k_tokens": 0.00027, + "output_cost_per_token_above_272k_tokens_flex": 0.000135, + "output_cost_per_token_batches": 9e-05, + "output_cost_per_token_flex": 9e-05, "search_context_cost_per_query": { "search_context_size_high": 0.01, "search_context_size_low": 0.01, "search_context_size_medium": 0.01 }, + "source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus2'%20and%20priceType%20eq%20'Consumption'", "supported_endpoints": [ "/v1/batch", "/v1/responses" @@ -7133,6 +7327,9 @@ "deprecation_date": "2027-09-07", "input_cost_per_token": 3e-05, "input_cost_per_token_above_272k_tokens": 6e-05, + "input_cost_per_token_above_272k_tokens_flex": 3e-05, + "input_cost_per_token_batches": 1.5e-05, + "input_cost_per_token_flex": 1.5e-05, "litellm_provider": "azure", "max_input_tokens": 1050000, "max_output_tokens": 128000, @@ -7140,11 +7337,15 @@ "mode": "responses", "output_cost_per_token": 0.00018, "output_cost_per_token_above_272k_tokens": 0.00027, + "output_cost_per_token_above_272k_tokens_flex": 0.000135, + "output_cost_per_token_batches": 9e-05, + "output_cost_per_token_flex": 9e-05, "search_context_cost_per_query": { "search_context_size_high": 0.01, "search_context_size_low": 0.01, "search_context_size_medium": 0.01 }, + "source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus2'%20and%20priceType%20eq%20'Consumption'", "supported_endpoints": [ "/v1/batch", "/v1/responses" @@ -7223,33 +7424,42 @@ "supports_minimal_reasoning_effort": false }, "azure/gpt-5.6-sol": { - "cache_creation_input_token_cost": 6.25e-06, - "cache_creation_input_token_cost_above_272k_tokens": 1.25e-05, - "cache_creation_input_token_cost_priority": 1.25e-05, - "cache_creation_input_token_cost_above_272k_tokens_priority": 2.5e-05, - "cache_read_input_token_cost": 5e-07, - "cache_read_input_token_cost_above_272k_tokens": 1e-06, - "cache_read_input_token_cost_priority": 1e-06, - "cache_read_input_token_cost_above_272k_tokens_priority": 2e-06, + "cache_creation_input_token_cost": 5e-06, + "cache_creation_input_token_cost_above_272k_tokens": 1e-05, + "cache_creation_input_token_cost_above_272k_tokens_flex": 5e-06, + "cache_creation_input_token_cost_priority": 1e-05, + "cache_creation_input_token_cost_above_272k_tokens_priority": 2e-05, + "cache_creation_input_token_cost_flex": 2.5e-06, + "cache_read_input_token_cost": 4e-07, + "cache_read_input_token_cost_above_272k_tokens": 8e-07, + "cache_read_input_token_cost_above_272k_tokens_flex": 4e-07, + "cache_read_input_token_cost_priority": 8e-07, + "cache_read_input_token_cost_above_272k_tokens_priority": 1.6e-06, + "cache_read_input_token_cost_flex": 2e-07, "deprecation_date": "2028-01-11", - "input_cost_per_token": 5e-06, - "input_cost_per_token_above_272k_tokens": 1e-05, - "input_cost_per_token_priority": 1e-05, - "input_cost_per_token_above_272k_tokens_priority": 2e-05, + "input_cost_per_token": 4e-06, + "input_cost_per_token_above_272k_tokens": 8e-06, + "input_cost_per_token_above_272k_tokens_flex": 4e-06, + "input_cost_per_token_priority": 8e-06, + "input_cost_per_token_above_272k_tokens_priority": 1.6e-05, + "input_cost_per_token_flex": 2e-06, "litellm_provider": "azure", "max_input_tokens": 922000, "max_output_tokens": 128000, "max_tokens": 128000, "mode": "chat", - "output_cost_per_token": 3e-05, - "output_cost_per_token_above_272k_tokens": 4.5e-05, - "output_cost_per_token_priority": 6e-05, - "output_cost_per_token_above_272k_tokens_priority": 9e-05, + "output_cost_per_token": 2e-05, + "output_cost_per_token_above_272k_tokens": 3e-05, + "output_cost_per_token_above_272k_tokens_flex": 1.5e-05, + "output_cost_per_token_priority": 4e-05, + "output_cost_per_token_above_272k_tokens_priority": 6e-05, + "output_cost_per_token_flex": 1e-05, "search_context_cost_per_query": { "search_context_size_high": 0.01, "search_context_size_low": 0.01, "search_context_size_medium": 0.01 }, + "source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus2'%20and%20priceType%20eq%20'Consumption'", "supported_endpoints": [ "/v1/chat/completions", "/v1/batch", @@ -7280,17 +7490,23 @@ "azure/gpt-5.6-terra": { "cache_creation_input_token_cost": 2.5e-06, "cache_creation_input_token_cost_above_272k_tokens": 5e-06, + "cache_creation_input_token_cost_above_272k_tokens_flex": 2.5e-06, "cache_creation_input_token_cost_priority": 5e-06, "cache_creation_input_token_cost_above_272k_tokens_priority": 1e-05, + "cache_creation_input_token_cost_flex": 1.25e-06, "cache_read_input_token_cost": 2e-07, "cache_read_input_token_cost_above_272k_tokens": 4e-07, + "cache_read_input_token_cost_above_272k_tokens_flex": 2e-07, "cache_read_input_token_cost_priority": 4e-07, "cache_read_input_token_cost_above_272k_tokens_priority": 8e-07, + "cache_read_input_token_cost_flex": 1e-07, "deprecation_date": "2028-01-11", "input_cost_per_token": 2e-06, "input_cost_per_token_above_272k_tokens": 4e-06, + "input_cost_per_token_above_272k_tokens_flex": 2e-06, "input_cost_per_token_priority": 4e-06, "input_cost_per_token_above_272k_tokens_priority": 8e-06, + "input_cost_per_token_flex": 1e-06, "litellm_provider": "azure", "max_input_tokens": 922000, "max_output_tokens": 128000, @@ -7298,13 +7514,16 @@ "mode": "chat", "output_cost_per_token": 1.2e-05, "output_cost_per_token_above_272k_tokens": 1.8e-05, + "output_cost_per_token_above_272k_tokens_flex": 9e-06, "output_cost_per_token_priority": 2.4e-05, "output_cost_per_token_above_272k_tokens_priority": 3.6e-05, + "output_cost_per_token_flex": 6e-06, "search_context_cost_per_query": { "search_context_size_high": 0.01, "search_context_size_low": 0.01, "search_context_size_medium": 0.01 }, + "source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus2'%20and%20priceType%20eq%20'Consumption'", "supported_endpoints": [ "/v1/chat/completions", "/v1/batch", @@ -7335,17 +7554,23 @@ "azure/gpt-5.6-luna": { "cache_creation_input_token_cost": 2.5e-07, "cache_creation_input_token_cost_above_272k_tokens": 5e-07, + "cache_creation_input_token_cost_above_272k_tokens_flex": 2.5e-07, "cache_creation_input_token_cost_priority": 5e-07, "cache_creation_input_token_cost_above_272k_tokens_priority": 1e-06, + "cache_creation_input_token_cost_flex": 1.25e-07, "cache_read_input_token_cost": 2e-08, "cache_read_input_token_cost_above_272k_tokens": 4e-08, + "cache_read_input_token_cost_above_272k_tokens_flex": 2e-08, "cache_read_input_token_cost_priority": 4e-08, "cache_read_input_token_cost_above_272k_tokens_priority": 8e-08, + "cache_read_input_token_cost_flex": 1e-08, "deprecation_date": "2028-01-11", "input_cost_per_token": 2e-07, "input_cost_per_token_above_272k_tokens": 4e-07, + "input_cost_per_token_above_272k_tokens_flex": 2e-07, "input_cost_per_token_priority": 4e-07, "input_cost_per_token_above_272k_tokens_priority": 8e-07, + "input_cost_per_token_flex": 1e-07, "litellm_provider": "azure", "max_input_tokens": 922000, "max_output_tokens": 128000, @@ -7353,13 +7578,16 @@ "mode": "chat", "output_cost_per_token": 1.2e-06, "output_cost_per_token_above_272k_tokens": 1.8e-06, + "output_cost_per_token_above_272k_tokens_flex": 9e-07, "output_cost_per_token_priority": 2.4e-06, "output_cost_per_token_above_272k_tokens_priority": 3.6e-06, + "output_cost_per_token_flex": 6e-07, "search_context_cost_per_query": { "search_context_size_high": 0.01, "search_context_size_low": 0.01, "search_context_size_medium": 0.01 }, + "source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus2'%20and%20priceType%20eq%20'Consumption'", "supported_endpoints": [ "/v1/chat/completions", "/v1/batch", @@ -7406,6 +7634,7 @@ "search_context_size_low": 0.01, "search_context_size_medium": 0.01 }, + "source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus2'%20and%20priceType%20eq%20'Consumption'", "supported_endpoints": [ "/v1/chat/completions", "/v1/responses" @@ -7563,33 +7792,34 @@ "supports_minimal_reasoning_effort": false }, "azure/us/gpt-5.6-sol": { - "cache_creation_input_token_cost": 6.875e-06, - "cache_creation_input_token_cost_above_272k_tokens": 1.375e-05, - "cache_creation_input_token_cost_above_272k_tokens_priority": 2.75e-05, - "cache_creation_input_token_cost_priority": 1.375e-05, - "cache_read_input_token_cost": 5.5e-07, - "cache_read_input_token_cost_above_272k_tokens": 1.1e-06, - "cache_read_input_token_cost_above_272k_tokens_priority": 2.2e-06, - "cache_read_input_token_cost_priority": 1.1e-06, + "cache_creation_input_token_cost": 5.5e-06, + "cache_creation_input_token_cost_above_272k_tokens": 1.1e-05, + "cache_creation_input_token_cost_above_272k_tokens_priority": 2.2e-05, + "cache_creation_input_token_cost_priority": 1.1e-05, + "cache_read_input_token_cost": 4.4e-07, + "cache_read_input_token_cost_above_272k_tokens": 8.8e-07, + "cache_read_input_token_cost_above_272k_tokens_priority": 1.76e-06, + "cache_read_input_token_cost_priority": 8.8e-07, "deprecation_date": "2028-01-11", - "input_cost_per_token": 5.5e-06, - "input_cost_per_token_above_272k_tokens": 1.1e-05, - "input_cost_per_token_above_272k_tokens_priority": 2.2e-05, - "input_cost_per_token_priority": 1.1e-05, + "input_cost_per_token": 4.4e-06, + "input_cost_per_token_above_272k_tokens": 8.8e-06, + "input_cost_per_token_above_272k_tokens_priority": 1.76e-05, + "input_cost_per_token_priority": 8.8e-06, "litellm_provider": "azure", "max_input_tokens": 922000, "max_output_tokens": 128000, "max_tokens": 128000, "mode": "chat", - "output_cost_per_token": 3.3e-05, - "output_cost_per_token_above_272k_tokens": 4.95e-05, - "output_cost_per_token_above_272k_tokens_priority": 9.9e-05, - "output_cost_per_token_priority": 6.6e-05, + "output_cost_per_token": 2.2e-05, + "output_cost_per_token_above_272k_tokens": 3.3e-05, + "output_cost_per_token_above_272k_tokens_priority": 6.6e-05, + "output_cost_per_token_priority": 4.4e-05, "search_context_cost_per_query": { "search_context_size_high": 0.01, "search_context_size_low": 0.01, "search_context_size_medium": 0.01 }, + "source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus2'%20and%20priceType%20eq%20'Consumption'", "supported_endpoints": [ "/v1/chat/completions", "/v1/batch", @@ -7645,6 +7875,7 @@ "search_context_size_low": 0.01, "search_context_size_medium": 0.01 }, + "source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus2'%20and%20priceType%20eq%20'Consumption'", "supported_endpoints": [ "/v1/chat/completions", "/v1/batch", @@ -7700,6 +7931,7 @@ "search_context_size_low": 0.01, "search_context_size_medium": 0.01 }, + "source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus2'%20and%20priceType%20eq%20'Consumption'", "supported_endpoints": [ "/v1/chat/completions", "/v1/batch", @@ -7746,6 +7978,7 @@ "search_context_size_low": 0.01, "search_context_size_medium": 0.01 }, + "source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus2'%20and%20priceType%20eq%20'Consumption'", "supported_endpoints": [ "/v1/chat/completions", "/v1/responses" @@ -7866,33 +8099,34 @@ "supports_minimal_reasoning_effort": false }, "azure/eu/gpt-5.6-sol": { - "cache_creation_input_token_cost": 6.875e-06, - "cache_creation_input_token_cost_above_272k_tokens": 1.375e-05, - "cache_creation_input_token_cost_above_272k_tokens_priority": 2.75e-05, - "cache_creation_input_token_cost_priority": 1.375e-05, - "cache_read_input_token_cost": 5.5e-07, - "cache_read_input_token_cost_above_272k_tokens": 1.1e-06, - "cache_read_input_token_cost_above_272k_tokens_priority": 2.2e-06, - "cache_read_input_token_cost_priority": 1.1e-06, + "cache_creation_input_token_cost": 5.5e-06, + "cache_creation_input_token_cost_above_272k_tokens": 1.1e-05, + "cache_creation_input_token_cost_above_272k_tokens_priority": 2.2e-05, + "cache_creation_input_token_cost_priority": 1.1e-05, + "cache_read_input_token_cost": 4.4e-07, + "cache_read_input_token_cost_above_272k_tokens": 8.8e-07, + "cache_read_input_token_cost_above_272k_tokens_priority": 1.76e-06, + "cache_read_input_token_cost_priority": 8.8e-07, "deprecation_date": "2028-01-11", - "input_cost_per_token": 5.5e-06, - "input_cost_per_token_above_272k_tokens": 1.1e-05, - "input_cost_per_token_above_272k_tokens_priority": 2.2e-05, - "input_cost_per_token_priority": 1.1e-05, + "input_cost_per_token": 4.4e-06, + "input_cost_per_token_above_272k_tokens": 8.8e-06, + "input_cost_per_token_above_272k_tokens_priority": 1.76e-05, + "input_cost_per_token_priority": 8.8e-06, "litellm_provider": "azure", "max_input_tokens": 922000, "max_output_tokens": 128000, "max_tokens": 128000, "mode": "chat", - "output_cost_per_token": 3.3e-05, - "output_cost_per_token_above_272k_tokens": 4.95e-05, - "output_cost_per_token_above_272k_tokens_priority": 9.9e-05, - "output_cost_per_token_priority": 6.6e-05, + "output_cost_per_token": 2.2e-05, + "output_cost_per_token_above_272k_tokens": 3.3e-05, + "output_cost_per_token_above_272k_tokens_priority": 6.6e-05, + "output_cost_per_token_priority": 4.4e-05, "search_context_cost_per_query": { "search_context_size_high": 0.01, "search_context_size_low": 0.01, "search_context_size_medium": 0.01 }, + "source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus2'%20and%20priceType%20eq%20'Consumption'", "supported_endpoints": [ "/v1/chat/completions", "/v1/batch", @@ -7948,6 +8182,7 @@ "search_context_size_low": 0.01, "search_context_size_medium": 0.01 }, + "source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus2'%20and%20priceType%20eq%20'Consumption'", "supported_endpoints": [ "/v1/chat/completions", "/v1/batch", @@ -8003,6 +8238,7 @@ "search_context_size_low": 0.01, "search_context_size_medium": 0.01 }, + "source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus2'%20and%20priceType%20eq%20'Consumption'", "supported_endpoints": [ "/v1/chat/completions", "/v1/batch", @@ -8034,12 +8270,16 @@ "deprecation_date": "2027-10-26", "cache_read_input_token_cost": 5e-07, "cache_read_input_token_cost_above_272k_tokens": 1e-06, - "cache_read_input_token_cost_priority": 1e-06, + "cache_read_input_token_cost_priority": 1.25e-06, "cache_read_input_token_cost_above_272k_tokens_priority": 2e-06, + "cache_read_input_token_cost_flex": 2.5e-07, "input_cost_per_token": 5e-06, "input_cost_per_token_above_272k_tokens": 1e-05, - "input_cost_per_token_priority": 1e-05, + "input_cost_per_token_above_272k_tokens_flex": 5e-06, + "input_cost_per_token_priority": 1.25e-05, "input_cost_per_token_above_272k_tokens_priority": 2e-05, + "input_cost_per_token_batches": 2.5e-06, + "input_cost_per_token_flex": 2.5e-06, "litellm_provider": "azure", "max_input_tokens": 1050000, "max_output_tokens": 128000, @@ -8047,13 +8287,15 @@ "mode": "chat", "output_cost_per_token": 3e-05, "output_cost_per_token_above_272k_tokens": 4.5e-05, - "output_cost_per_token_priority": 6e-05, + "output_cost_per_token_priority": 7.5e-05, "output_cost_per_token_above_272k_tokens_priority": 9e-05, + "output_cost_per_token_batches": 1.5e-05, "search_context_cost_per_query": { "search_context_size_high": 0.01, "search_context_size_low": 0.01, "search_context_size_medium": 0.01 }, + "source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus2'%20and%20priceType%20eq%20'Consumption'", "supported_endpoints": [ "/v1/chat/completions", "/v1/batch", @@ -8085,9 +8327,10 @@ "deprecation_date": "2027-10-26", "cache_read_input_token_cost": 5.5e-07, "cache_read_input_token_cost_above_272k_tokens": 1.1e-06, - "cache_read_input_token_cost_priority": 1.38e-06, + "cache_read_input_token_cost_priority": 1.375e-06, "input_cost_per_token": 5.5e-06, "input_cost_per_token_above_272k_tokens": 1.1e-05, + "input_cost_per_token_batches": 2.75e-06, "input_cost_per_token_priority": 1.375e-05, "output_cost_per_token": 3.3e-05, "output_cost_per_token_above_272k_tokens": 4.95e-05, @@ -8097,11 +8340,13 @@ "max_output_tokens": 128000, "max_tokens": 128000, "mode": "chat", + "output_cost_per_token_batches": 1.65e-05, "search_context_cost_per_query": { "search_context_size_high": 0.01, "search_context_size_low": 0.01, "search_context_size_medium": 0.01 }, + "source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus2'%20and%20priceType%20eq%20'Consumption'", "supported_endpoints": [ "/v1/chat/completions", "/v1/batch", @@ -8133,9 +8378,10 @@ "deprecation_date": "2027-10-26", "cache_read_input_token_cost": 5.5e-07, "cache_read_input_token_cost_above_272k_tokens": 1.1e-06, - "cache_read_input_token_cost_priority": 1.38e-06, + "cache_read_input_token_cost_priority": 1.375e-06, "input_cost_per_token": 5.5e-06, "input_cost_per_token_above_272k_tokens": 1.1e-05, + "input_cost_per_token_batches": 2.75e-06, "input_cost_per_token_priority": 1.375e-05, "output_cost_per_token": 3.3e-05, "output_cost_per_token_above_272k_tokens": 4.95e-05, @@ -8145,11 +8391,13 @@ "max_output_tokens": 128000, "max_tokens": 128000, "mode": "chat", + "output_cost_per_token_batches": 1.65e-05, "search_context_cost_per_query": { "search_context_size_high": 0.01, "search_context_size_low": 0.01, "search_context_size_medium": 0.01 }, + "source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus2'%20and%20priceType%20eq%20'Consumption'", "supported_endpoints": [ "/v1/chat/completions", "/v1/batch", @@ -8180,11 +8428,12 @@ "azure/gpt-5.5-2026-04-23": { "cache_read_input_token_cost": 5e-07, "cache_read_input_token_cost_above_272k_tokens": 1e-06, - "cache_read_input_token_cost_priority": 1e-06, + "cache_read_input_token_cost_priority": 1.25e-06, "cache_read_input_token_cost_above_272k_tokens_priority": 2e-06, + "cache_read_input_token_cost_flex": 2.5e-07, "input_cost_per_token": 5e-06, "input_cost_per_token_above_272k_tokens": 1e-05, - "input_cost_per_token_priority": 1e-05, + "input_cost_per_token_priority": 1.25e-05, "input_cost_per_token_above_272k_tokens_priority": 2e-05, "litellm_provider": "azure", "max_input_tokens": 1050000, @@ -8193,7 +8442,7 @@ "mode": "chat", "output_cost_per_token": 3e-05, "output_cost_per_token_above_272k_tokens": 4.5e-05, - "output_cost_per_token_priority": 6e-05, + "output_cost_per_token_priority": 7.5e-05, "output_cost_per_token_above_272k_tokens_priority": 9e-05, "search_context_cost_per_query": { "search_context_size_high": 0.01, @@ -8223,12 +8472,17 @@ "supports_tool_choice": true, "supports_vision": true, "supports_web_search": true, - "deprecation_date": "2027-10-26" + "deprecation_date": "2027-10-26", + "input_cost_per_token_above_272k_tokens_flex": 5e-06, + "input_cost_per_token_batches": 2.5e-06, + "input_cost_per_token_flex": 2.5e-06, + "output_cost_per_token_batches": 1.5e-05, + "source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus2'%20and%20priceType%20eq%20'Consumption'" }, "azure/us/gpt-5.5-2026-04-23": { "cache_read_input_token_cost": 5.5e-07, "cache_read_input_token_cost_above_272k_tokens": 1.1e-06, - "cache_read_input_token_cost_priority": 1.38e-06, + "cache_read_input_token_cost_priority": 1.375e-06, "input_cost_per_token": 5.5e-06, "input_cost_per_token_above_272k_tokens": 1.1e-05, "input_cost_per_token_priority": 1.375e-05, @@ -8268,12 +8522,15 @@ "supports_tool_choice": true, "supports_vision": true, "supports_web_search": true, - "deprecation_date": "2027-10-26" + "deprecation_date": "2027-10-26", + "input_cost_per_token_batches": 2.75e-06, + "output_cost_per_token_batches": 1.65e-05, + "source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus2'%20and%20priceType%20eq%20'Consumption'" }, "azure/eu/gpt-5.5-2026-04-23": { "cache_read_input_token_cost": 5.5e-07, "cache_read_input_token_cost_above_272k_tokens": 1.1e-06, - "cache_read_input_token_cost_priority": 1.38e-06, + "cache_read_input_token_cost_priority": 1.375e-06, "input_cost_per_token": 5.5e-06, "input_cost_per_token_above_272k_tokens": 1.1e-05, "input_cost_per_token_priority": 1.375e-05, @@ -8313,7 +8570,10 @@ "supports_tool_choice": true, "supports_vision": true, "supports_web_search": true, - "deprecation_date": "2027-10-26" + "deprecation_date": "2027-10-26", + "input_cost_per_token_batches": 2.75e-06, + "output_cost_per_token_batches": 1.65e-05, + "source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus2'%20and%20priceType%20eq%20'Consumption'" }, "azure/gpt-5.5-pro": { "cache_read_input_token_cost": 3e-06, @@ -8402,6 +8662,8 @@ "azure/gpt-5.4-mini": { "deprecation_date": "2027-09-21", "cache_read_input_token_cost": 7.5e-08, + "cache_read_input_token_cost_flex": 3.75e-08, + "cache_read_input_token_cost_priority": 1.5e-07, "input_cost_per_token": 7.5e-07, "litellm_provider": "azure", "max_input_tokens": 272000, @@ -8439,10 +8701,19 @@ "supports_web_search": true, "supports_none_reasoning_effort": true, "default_reasoning_effort": "none", + "input_cost_per_token_batches": 3.75e-07, + "input_cost_per_token_flex": 3.75e-07, + "input_cost_per_token_priority": 1.5e-06, + "output_cost_per_token_batches": 2.25e-06, + "output_cost_per_token_flex": 2.25e-06, + "output_cost_per_token_priority": 9e-06, + "source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus2'%20and%20priceType%20eq%20'Consumption'", "supports_xhigh_reasoning_effort": true }, "azure/gpt-5.4-mini-2026-03-17": { "cache_read_input_token_cost": 7.5e-08, + "cache_read_input_token_cost_flex": 3.75e-08, + "cache_read_input_token_cost_priority": 1.5e-07, "deprecation_date": "2027-09-21", "input_cost_per_token": 7.5e-07, "litellm_provider": "azure", @@ -8481,11 +8752,19 @@ "supports_web_search": true, "supports_none_reasoning_effort": true, "default_reasoning_effort": "none", + "input_cost_per_token_batches": 3.75e-07, + "input_cost_per_token_flex": 3.75e-07, + "input_cost_per_token_priority": 1.5e-06, + "output_cost_per_token_batches": 2.25e-06, + "output_cost_per_token_flex": 2.25e-06, + "output_cost_per_token_priority": 9e-06, + "source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus2'%20and%20priceType%20eq%20'Consumption'", "supports_xhigh_reasoning_effort": true }, "azure/gpt-5.4-nano": { "deprecation_date": "2027-09-21", "cache_read_input_token_cost": 2e-08, + "cache_read_input_token_cost_flex": 1e-08, "input_cost_per_token": 2e-07, "litellm_provider": "azure", "max_input_tokens": 272000, @@ -8523,10 +8802,16 @@ "supports_web_search": true, "supports_none_reasoning_effort": true, "default_reasoning_effort": "none", + "input_cost_per_token_batches": 1e-07, + "input_cost_per_token_flex": 1e-07, + "output_cost_per_token_batches": 6.25e-07, + "output_cost_per_token_flex": 6.25e-07, + "source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus2'%20and%20priceType%20eq%20'Consumption'", "supports_xhigh_reasoning_effort": true }, "azure/gpt-5.4-nano-2026-03-17": { "cache_read_input_token_cost": 2e-08, + "cache_read_input_token_cost_flex": 1e-08, "deprecation_date": "2027-09-21", "input_cost_per_token": 2e-07, "litellm_provider": "azure", @@ -8565,6 +8850,11 @@ "supports_web_search": true, "supports_none_reasoning_effort": true, "default_reasoning_effort": "none", + "input_cost_per_token_batches": 1e-07, + "input_cost_per_token_flex": 1e-07, + "output_cost_per_token_batches": 6.25e-07, + "output_cost_per_token_flex": 6.25e-07, + "source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus2'%20and%20priceType%20eq%20'Consumption'", "supports_xhigh_reasoning_effort": true }, "azure/gpt-image-1": { @@ -8886,12 +9176,15 @@ "cache_read_input_token_cost": 7.5e-06, "deprecation_date": "2026-11-19", "input_cost_per_token": 1.5e-05, + "input_cost_per_token_batches": 7.5e-06, "litellm_provider": "azure", "max_input_tokens": 200000, "max_output_tokens": 100000, "max_tokens": 100000, "mode": "chat", "output_cost_per_token": 6e-05, + "output_cost_per_token_batches": 3e-05, + "source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus2'%20and%20priceType%20eq%20'Consumption'", "supports_function_calling": true, "supports_parallel_function_calling": true, "supports_prompt_caching": true, @@ -8900,14 +9193,17 @@ "supports_vision": true }, "azure/o1-mini": { - "cache_read_input_token_cost": 6.05e-07, - "input_cost_per_token": 1.21e-06, + "cache_read_input_token_cost": 5.5e-07, + "input_cost_per_token": 1.1e-06, + "input_cost_per_token_batches": 5.5e-07, "litellm_provider": "azure", "max_input_tokens": 128000, "max_output_tokens": 65536, "max_tokens": 65536, "mode": "chat", - "output_cost_per_token": 4.84e-06, + "output_cost_per_token": 4.4e-06, + "output_cost_per_token_batches": 2.2e-06, + "source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus2'%20and%20priceType%20eq%20'Consumption'", "supports_function_calling": true, "supports_parallel_function_calling": true, "supports_prompt_caching": true, @@ -8938,6 +9234,7 @@ "max_tokens": 32768, "mode": "chat", "output_cost_per_token": 6e-05, + "source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus2'%20and%20priceType%20eq%20'Consumption'", "supports_function_calling": true, "supports_parallel_function_calling": true, "supports_prompt_caching": true, @@ -8953,6 +9250,7 @@ "max_tokens": 32768, "mode": "chat", "output_cost_per_token": 6e-05, + "source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus2'%20and%20priceType%20eq%20'Consumption'", "supports_function_calling": true, "supports_parallel_function_calling": true, "supports_pdf_input": true, @@ -8994,12 +9292,15 @@ "deprecation_date": "2026-11-19", "cache_read_input_token_cost": 5e-07, "input_cost_per_token": 2e-06, + "input_cost_per_token_batches": 1e-06, "litellm_provider": "azure", "max_input_tokens": 200000, "max_output_tokens": 100000, "max_tokens": 100000, "mode": "chat", "output_cost_per_token": 8e-06, + "output_cost_per_token_batches": 4e-06, + "source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus2'%20and%20priceType%20eq%20'Consumption'", "supported_endpoints": [ "/v1/chat/completions", "/v1/batch", @@ -9035,6 +9336,7 @@ "search_context_size_low": 0.01, "search_context_size_medium": 0.01 }, + "source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus2'%20and%20priceType%20eq%20'Consumption'", "supported_endpoints": [ "/v1/chat/completions", "/v1/batch", @@ -9078,12 +9380,15 @@ "cache_read_input_token_cost": 5.5e-07, "deprecation_date": "2026-11-19", "input_cost_per_token": 1.1e-06, + "input_cost_per_token_batches": 5.5e-07, "litellm_provider": "azure", "max_input_tokens": 200000, "max_output_tokens": 100000, "max_tokens": 100000, "mode": "chat", "output_cost_per_token": 4.4e-06, + "output_cost_per_token_batches": 2.2e-06, + "source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus2'%20and%20priceType%20eq%20'Consumption'", "supports_prompt_caching": true, "supports_reasoning": true, "supports_tool_choice": true, @@ -9100,6 +9405,7 @@ "mode": "responses", "output_cost_per_token": 8e-05, "output_cost_per_token_batches": 4e-05, + "source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus2'%20and%20priceType%20eq%20'Consumption'", "supported_endpoints": [ "/v1/chat/completions", "/v1/batch", @@ -9131,6 +9437,7 @@ "mode": "responses", "output_cost_per_token": 8e-05, "output_cost_per_token_batches": 4e-05, + "source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus2'%20and%20priceType%20eq%20'Consumption'", "supported_endpoints": [ "/v1/chat/completions", "/v1/batch", @@ -9185,12 +9492,15 @@ "cache_read_input_token_cost": 2.75e-07, "deprecation_date": "2026-11-19", "input_cost_per_token": 1.1e-06, + "input_cost_per_token_batches": 5.5e-07, "litellm_provider": "azure", "max_input_tokens": 200000, "max_output_tokens": 100000, "max_tokens": 100000, "mode": "chat", "output_cost_per_token": 4.4e-06, + "output_cost_per_token_batches": 2.2e-06, + "source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus2'%20and%20priceType%20eq%20'Consumption'", "supports_function_calling": true, "supports_parallel_function_calling": false, "supports_prompt_caching": true, @@ -9230,7 +9540,8 @@ "max_input_tokens": 8191, "max_tokens": 8191, "mode": "embedding", - "output_cost_per_token": 0.0 + "output_cost_per_token": 0.0, + "source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus2'%20and%20priceType%20eq%20'Consumption'" }, "azure/text-embedding-3-small": { "deprecation_date": "2028-02-09", @@ -9239,7 +9550,8 @@ "max_input_tokens": 8191, "max_tokens": 8191, "mode": "embedding", - "output_cost_per_token": 0.0 + "output_cost_per_token": 0.0, + "source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus2'%20and%20priceType%20eq%20'Consumption'" }, "azure/text-embedding-ada-002": { "deprecation_date": "2028-02-09", @@ -9248,7 +9560,8 @@ "max_input_tokens": 8191, "max_tokens": 8191, "mode": "embedding", - "output_cost_per_token": 0.0 + "output_cost_per_token": 0.0, + "source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus2'%20and%20priceType%20eq%20'Consumption'" }, "azure/speech/azure-tts": { "input_cost_per_character": 1.5e-05, @@ -9390,12 +9703,15 @@ "deprecation_date": "2027-04-14", "cache_read_input_token_cost": 1.375e-06, "input_cost_per_token": 2.75e-06, + "input_cost_per_token_batches": 1.375e-06, "litellm_provider": "azure", "max_input_tokens": 128000, "max_output_tokens": 16384, "max_tokens": 16384, "mode": "chat", "output_cost_per_token": 1.1e-05, + "output_cost_per_token_batches": 5.5e-06, + "source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus2'%20and%20priceType%20eq%20'Consumption'", "supports_function_calling": true, "supports_parallel_function_calling": true, "supports_prompt_caching": true, @@ -9406,13 +9722,17 @@ "azure/us/gpt-4o-2024-11-20": { "deprecation_date": "2027-04-14", "cache_creation_input_token_cost": 1.38e-06, + "cache_read_input_token_cost": 1.375e-06, "input_cost_per_token": 2.75e-06, + "input_cost_per_token_batches": 1.375e-06, "litellm_provider": "azure", "max_input_tokens": 128000, "max_output_tokens": 16384, "max_tokens": 16384, "mode": "chat", "output_cost_per_token": 1.1e-05, + "output_cost_per_token_batches": 5.5e-06, + "source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus2'%20and%20priceType%20eq%20'Consumption'", "supports_function_calling": true, "supports_parallel_function_calling": true, "supports_response_schema": true, @@ -9423,12 +9743,14 @@ "cache_read_input_token_cost": 8.3e-08, "deprecation_date": "2027-04-14", "input_cost_per_token": 1.65e-07, + "input_cost_per_token_batches": 8.3e-08, "litellm_provider": "azure", "max_input_tokens": 128000, "max_output_tokens": 16384, "max_tokens": 16384, "mode": "chat", "output_cost_per_token": 6.6e-07, + "source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus2'%20and%20priceType%20eq%20'Consumption'", "supports_function_calling": true, "supports_parallel_function_calling": true, "supports_prompt_caching": true, @@ -9602,8 +9924,9 @@ }, "azure/us/gpt-5.1": { "deprecation_date": "2027-05-15", - "cache_read_input_token_cost": 1.4e-07, - "input_cost_per_token": 1.38e-06, + "cache_read_input_token_cost": 1.375e-07, + "cache_read_input_token_cost_priority": 2.75e-07, + "input_cost_per_token": 1.375e-06, "litellm_provider": "azure", "max_input_tokens": 272000, "max_output_tokens": 128000, @@ -9634,12 +9957,17 @@ "supports_tool_choice": true, "supports_vision": true, "supports_none_reasoning_effort": true, - "default_reasoning_effort": "none" + "default_reasoning_effort": "none", + "input_cost_per_token_batches": 6.875e-07, + "input_cost_per_token_priority": 2.75e-06, + "output_cost_per_token_batches": 5.5e-06, + "output_cost_per_token_priority": 2.2e-05, + "source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus2'%20and%20priceType%20eq%20'Consumption'" }, "azure/us/gpt-5.1-chat": { - "cache_read_input_token_cost": 1.4e-07, + "cache_read_input_token_cost": 1.375e-07, "deprecation_date": "2026-06-29", - "input_cost_per_token": 1.38e-06, + "input_cost_per_token": 1.375e-06, "litellm_provider": "azure", "max_input_tokens": 128000, "max_output_tokens": 128000, @@ -9670,18 +9998,20 @@ "supports_tool_choice": true, "supports_vision": true, "supports_none_reasoning_effort": true, - "default_reasoning_effort": "none" + "default_reasoning_effort": "none", + "source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus2'%20and%20priceType%20eq%20'Consumption'" }, "azure/us/gpt-5.1-codex": { "deprecation_date": "2027-05-15", - "cache_read_input_token_cost": 1.4e-07, - "input_cost_per_token": 1.38e-06, + "cache_read_input_token_cost": 1.375e-07, + "input_cost_per_token": 1.375e-06, "litellm_provider": "azure", "max_input_tokens": 272000, "max_output_tokens": 128000, "max_tokens": 128000, "mode": "responses", "output_cost_per_token": 1.1e-05, + "source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus2'%20and%20priceType%20eq%20'Consumption'", "supported_endpoints": [ "/v1/responses" ], @@ -9705,7 +10035,7 @@ }, "azure/us/gpt-5.1-codex-mini": { "deprecation_date": "2027-05-15", - "cache_read_input_token_cost": 2.8e-08, + "cache_read_input_token_cost": 2.75e-08, "input_cost_per_token": 2.75e-07, "litellm_provider": "azure", "max_input_tokens": 272000, @@ -9713,6 +10043,7 @@ "max_tokens": 128000, "mode": "responses", "output_cost_per_token": 2.2e-06, + "source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus2'%20and%20priceType%20eq%20'Consumption'", "supported_endpoints": [ "/v1/responses" ], @@ -9738,12 +10069,15 @@ "cache_read_input_token_cost": 8.25e-06, "deprecation_date": "2026-11-19", "input_cost_per_token": 1.65e-05, + "input_cost_per_token_batches": 8.25e-06, "litellm_provider": "azure", "max_input_tokens": 200000, "max_output_tokens": 100000, "max_tokens": 100000, "mode": "chat", "output_cost_per_token": 6.6e-05, + "output_cost_per_token_batches": 3.3e-05, + "source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus2'%20and%20priceType%20eq%20'Consumption'", "supports_function_calling": true, "supports_parallel_function_calling": true, "supports_prompt_caching": true, @@ -9784,12 +10118,15 @@ "deprecation_date": "2026-11-19", "cache_read_input_token_cost": 5.5e-07, "input_cost_per_token": 2.2e-06, + "input_cost_per_token_batches": 1.1e-06, "litellm_provider": "azure", "max_input_tokens": 200000, "max_output_tokens": 100000, "max_tokens": 100000, "mode": "chat", "output_cost_per_token": 8.8e-06, + "output_cost_per_token_batches": 4.4e-06, + "source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus2'%20and%20priceType%20eq%20'Consumption'", "supported_endpoints": [ "/v1/chat/completions", "/v1/batch", @@ -9822,21 +10159,25 @@ "mode": "chat", "output_cost_per_token": 4.84e-06, "output_cost_per_token_batches": 2.42e-06, + "source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus2'%20and%20priceType%20eq%20'Consumption'", "supports_prompt_caching": true, "supports_reasoning": true, "supports_tool_choice": true, "supports_vision": false }, "azure/us/o4-mini-2025-04-16": { - "cache_read_input_token_cost": 3.1e-07, + "cache_read_input_token_cost": 3.03e-07, "deprecation_date": "2026-11-19", "input_cost_per_token": 1.21e-06, + "input_cost_per_token_batches": 6.05e-07, "litellm_provider": "azure", "max_input_tokens": 200000, "max_output_tokens": 100000, "max_tokens": 100000, "mode": "chat", "output_cost_per_token": 4.84e-06, + "output_cost_per_token_batches": 2.42e-06, + "source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus2'%20and%20priceType%20eq%20'Consumption'", "supports_function_calling": true, "supports_parallel_function_calling": false, "supports_prompt_caching": true, @@ -9882,7 +10223,7 @@ "max_tokens": 4096, "mode": "chat", "output_cost_per_token": 9e-07, - "source": "https://azure.microsoft.com/en-us/pricing/details/ai-foundry-models/mistral/", + "source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus2'%20and%20priceType%20eq%20'Consumption'", "supported_endpoints": [ "/v1/chat/completions" ], @@ -9931,7 +10272,7 @@ "max_tokens": 163840, "mode": "chat", "output_cost_per_token": 1.85e-06, - "source": "https://azure.microsoft.com/en-us/pricing/details/ai-foundry-models/fireworks/", + "source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus2'%20and%20priceType%20eq%20'Consumption'", "supports_function_calling": true, "supports_prompt_caching": true, "supports_reasoning": true, @@ -9946,7 +10287,7 @@ "max_tokens": 384000, "mode": "chat", "output_cost_per_token": 3.828e-06, - "source": "https://azure.microsoft.com/en-us/pricing/details/ai-foundry-models/fireworks/", + "source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus2'%20and%20priceType%20eq%20'Consumption'", "supports_function_calling": true, "supports_prompt_caching": true, "supports_reasoning": true, @@ -9962,7 +10303,7 @@ "max_tokens": 128000, "mode": "chat", "output_cost_per_token": 3.52e-06, - "source": "https://azure.microsoft.com/en-us/pricing/details/ai-foundry-models/fireworks/", + "source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus2'%20and%20priceType%20eq%20'Consumption'", "supports_function_calling": true, "supports_prompt_caching": true, "supports_reasoning": true, @@ -9978,7 +10319,7 @@ "max_tokens": 131072, "mode": "chat", "output_cost_per_token": 4.84e-06, - "source": "https://azure.microsoft.com/en-us/pricing/details/ai-foundry-models/fireworks/", + "source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus2'%20and%20priceType%20eq%20'Consumption'", "supports_function_calling": true, "supports_prompt_caching": true, "supports_reasoning": true, @@ -9993,37 +10334,37 @@ "max_tokens": 131072, "mode": "chat", "output_cost_per_token": 4.84e-06, - "source": "https://azure.microsoft.com/en-us/pricing/details/ai-foundry-models/fireworks/", + "source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus2'%20and%20priceType%20eq%20'Consumption'", "supports_function_calling": true, "supports_prompt_caching": true, "supports_reasoning": true, "supports_tool_choice": true }, "azure_ai/FW-GLM-5.2-Fast": { - "cache_read_input_token_cost": 2.1e-07, - "input_cost_per_token": 2.1e-06, + "cache_read_input_token_cost": 2.31e-07, + "input_cost_per_token": 2.31e-06, "litellm_provider": "azure_ai", "max_input_tokens": 1048576, "max_output_tokens": 131072, "max_tokens": 131072, "mode": "chat", - "output_cost_per_token": 6.6e-06, - "source": "https://docs.fireworks.ai/serverless/pricing", + "output_cost_per_token": 7.26e-06, + "source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus2'%20and%20priceType%20eq%20'Consumption'", "supports_function_calling": true, "supports_prompt_caching": true, "supports_reasoning": true, "supports_tool_choice": true }, "azure_ai/FW-Inkling": { - "cache_read_input_token_cost": 1.7e-07, - "input_cost_per_token": 1e-06, + "cache_read_input_token_cost": 1.9e-07, + "input_cost_per_token": 1.1e-06, "litellm_provider": "azure_ai", "max_input_tokens": 1048576, "max_output_tokens": 1048576, "max_tokens": 1048576, "mode": "chat", - "output_cost_per_token": 4.05e-06, - "source": "https://fireworks.ai/models/fireworks/inkling", + "output_cost_per_token": 4.46e-06, + "source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus2'%20and%20priceType%20eq%20'Consumption'", "supported_modalities": [ "text" ], @@ -10045,7 +10386,7 @@ "max_tokens": 262144, "mode": "chat", "output_cost_per_token": 3.3e-06, - "source": "https://azure.microsoft.com/en-us/pricing/details/ai-foundry-models/fireworks/", + "source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus2'%20and%20priceType%20eq%20'Consumption'", "supported_modalities": [ "text", "image" @@ -10068,7 +10409,7 @@ "max_tokens": 262144, "mode": "chat", "output_cost_per_token": 4.4e-06, - "source": "https://azure.microsoft.com/en-us/pricing/details/ai-foundry-models/fireworks/", + "source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus2'%20and%20priceType%20eq%20'Consumption'", "supported_modalities": [ "text", "image" @@ -10091,7 +10432,7 @@ "max_tokens": 262144, "mode": "chat", "output_cost_per_token": 4.4e-06, - "source": "https://azure.microsoft.com/en-us/pricing/details/ai-foundry-models/fireworks/", + "source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus2'%20and%20priceType%20eq%20'Consumption'", "supported_modalities": [ "text", "image" @@ -10119,7 +10460,7 @@ "high", "max" ], - "source": "https://techcommunity.microsoft.com/blog/azure-ai-foundry-blog/introducing-kimi-k3-through-fireworks-ai-on-microsoft-foundry/4540187", + "source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus2'%20and%20priceType%20eq%20'Consumption'", "supported_modalities": [ "text", "image" @@ -10143,7 +10484,7 @@ "max_tokens": 1000000, "mode": "chat", "output_cost_per_token": 1.32e-06, - "source": "https://azure.microsoft.com/en-us/pricing/details/ai-foundry-models/fireworks/", + "source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus2'%20and%20priceType%20eq%20'Consumption'", "supports_function_calling": true, "supports_prompt_caching": true, "supports_reasoning": true, @@ -10158,7 +10499,7 @@ "max_tokens": 512000, "mode": "chat", "output_cost_per_token": 1.32e-06, - "source": "https://azure.microsoft.com/en-us/pricing/details/ai-foundry-models/fireworks/", + "source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus2'%20and%20priceType%20eq%20'Consumption'", "supported_modalities": [ "text", "image" @@ -10179,7 +10520,7 @@ "max_input_tokens": 262144, "mode": "chat", "output_cost_per_token": 2.2e-07, - "source": "https://azure.microsoft.com/en-us/pricing/details/ai-foundry-models/fireworks/", + "source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus2'%20and%20priceType%20eq%20'Consumption'", "supported_modalities": [ "text" ], @@ -10193,15 +10534,15 @@ "supports_vision": false }, "azure_ai/FW-Nemotron-3-Ultra-NVFP4": { - "cache_read_input_token_cost": 1.19e-07, - "input_cost_per_token": 6e-07, + "cache_read_input_token_cost": 1.3e-07, + "input_cost_per_token": 6.6e-07, "litellm_provider": "azure_ai", "max_input_tokens": 262144, "max_output_tokens": 262144, "max_tokens": 262144, "mode": "chat", - "output_cost_per_token": 2.4e-06, - "source": "https://fireworks.ai/models/fireworks/nemotron-3-ultra-nvfp4", + "output_cost_per_token": 2.64e-06, + "source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus2'%20and%20priceType%20eq%20'Consumption'", "supported_modalities": [ "text" ], @@ -10220,7 +10561,7 @@ "mode": "image_generation", "output_cost_per_image": 0.05, "output_cost_per_image_token": 4.7e-05, - "source": "https://techcommunity.microsoft.com/blog/azure-ai-foundry-blog/new-mai-models-in-microsoft-foundry-across-text-image-voice-and-speech/4524632", + "source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus2'%20and%20priceType%20eq%20'Consumption'", "supported_endpoints": [ "/v1/images/generations", "/v1/images/edits" @@ -10234,7 +10575,7 @@ "mode": "image_generation", "output_cost_per_image": 0.0338, "output_cost_per_image_token": 3.3e-05, - "source": "https://techcommunity.microsoft.com/blog/azure-ai-foundry-blog/new-mai-models-in-microsoft-foundry-across-text-image-voice-and-speech/4524632", + "source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus2'%20and%20priceType%20eq%20'Consumption'", "supported_endpoints": [ "/v1/images/generations", "/v1/images/edits" @@ -10248,7 +10589,7 @@ "mode": "image_generation", "output_cost_per_image": 0.02, "output_cost_per_image_token": 1.95e-05, - "source": "https://aka.ms/mai-image-2e-foundryblog", + "source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus2'%20and%20priceType%20eq%20'Consumption'", "supported_endpoints": [ "/v1/images/generations" ] @@ -10262,7 +10603,7 @@ "max_tokens": 64000, "mode": "chat", "output_cost_per_token": 8e-06, - "source": "https://learn.microsoft.com/en-us/azure/foundry/foundry-models/concepts/models-sold-directly-by-azure", + "source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus2'%20and%20priceType%20eq%20'Consumption'", "supported_endpoints": [ "/v1/chat/completions" ], @@ -10313,19 +10654,19 @@ "max_tokens": 2048, "mode": "chat", "output_cost_per_token": 7.1e-07, - "source": "https://marketplace.microsoft.com/en/marketplace/apps/metagenai.llama-3-3-70b-instruct-offer?tab=Overview", + "source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus2'%20and%20priceType%20eq%20'Consumption'", "supports_function_calling": true, "supports_tool_choice": true }, "azure_ai/Llama-4-Maverick-17B-128E-Instruct-FP8": { - "input_cost_per_token": 1.41e-06, + "input_cost_per_token": 2.5e-07, "litellm_provider": "azure_ai", "max_input_tokens": 1000000, "max_output_tokens": 16384, "max_tokens": 16384, "mode": "chat", - "output_cost_per_token": 3.5e-07, - "source": "https://azure.microsoft.com/en-us/blog/introducing-the-llama-4-herd-in-azure-ai-foundry-and-azure-databricks/", + "output_cost_per_token": 1e-06, + "source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus2'%20and%20priceType%20eq%20'Consumption'", "supports_function_calling": true, "supports_tool_choice": true, "supports_vision": true @@ -10396,7 +10737,7 @@ "max_tokens": 4096, "mode": "chat", "output_cost_per_token": 6.8e-07, - "source": "https://azure.microsoft.com/en-us/pricing/details/phi-3/", + "source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus2'%20and%20priceType%20eq%20'Consumption'", "supports_tool_choice": true, "supports_vision": false }, @@ -10408,7 +10749,7 @@ "max_tokens": 4096, "mode": "chat", "output_cost_per_token": 6.8e-07, - "source": "https://azure.microsoft.com/en-us/pricing/details/phi-3/", + "source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus2'%20and%20priceType%20eq%20'Consumption'", "supports_tool_choice": true, "supports_vision": false }, @@ -10420,7 +10761,7 @@ "max_tokens": 4096, "mode": "chat", "output_cost_per_token": 5.2e-07, - "source": "https://azure.microsoft.com/en-us/pricing/details/phi-3/", + "source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus2'%20and%20priceType%20eq%20'Consumption'", "supports_tool_choice": true, "supports_vision": false }, @@ -10432,7 +10773,7 @@ "max_tokens": 4096, "mode": "chat", "output_cost_per_token": 5.2e-07, - "source": "https://azure.microsoft.com/en-us/pricing/details/phi-3/", + "source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus2'%20and%20priceType%20eq%20'Consumption'", "supports_tool_choice": true, "supports_vision": false }, @@ -10444,7 +10785,7 @@ "max_tokens": 4096, "mode": "chat", "output_cost_per_token": 6e-07, - "source": "https://azure.microsoft.com/en-us/pricing/details/phi-3/", + "source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus2'%20and%20priceType%20eq%20'Consumption'", "supports_tool_choice": true, "supports_vision": false }, @@ -10456,7 +10797,7 @@ "max_tokens": 4096, "mode": "chat", "output_cost_per_token": 6e-07, - "source": "https://azure.microsoft.com/en-us/pricing/details/phi-3/", + "source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus2'%20and%20priceType%20eq%20'Consumption'", "supports_tool_choice": true, "supports_vision": false }, @@ -10468,7 +10809,7 @@ "max_tokens": 4096, "mode": "chat", "output_cost_per_token": 6.4e-07, - "source": "https://azure.microsoft.com/en-us/pricing/details/phi-3/", + "source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus2'%20and%20priceType%20eq%20'Consumption'", "supports_tool_choice": true, "supports_vision": false }, @@ -10480,7 +10821,7 @@ "max_tokens": 4096, "mode": "chat", "output_cost_per_token": 5.2e-07, - "source": "https://azure.microsoft.com/en-us/pricing/details/phi-3/", + "source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus2'%20and%20priceType%20eq%20'Consumption'", "supports_tool_choice": true, "supports_vision": false }, @@ -10492,7 +10833,7 @@ "max_tokens": 4096, "mode": "chat", "output_cost_per_token": 5.2e-07, - "source": "https://azure.microsoft.com/en-us/pricing/details/phi-3/", + "source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus2'%20and%20priceType%20eq%20'Consumption'", "supports_tool_choice": true, "supports_vision": true }, @@ -10504,7 +10845,7 @@ "max_tokens": 16384, "mode": "chat", "output_cost_per_token": 5e-07, - "source": "https://techcommunity.microsoft.com/blog/machinelearningblog/affordable-innovation-unveiling-the-pricing-of-phi-3-slms-on-models-as-a-service/4156495", + "source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus2'%20and%20priceType%20eq%20'Consumption'", "supports_function_calling": true, "supports_tool_choice": true, "supports_vision": false @@ -10517,7 +10858,7 @@ "max_tokens": 4096, "mode": "chat", "output_cost_per_token": 3e-07, - "source": "https://techcommunity.microsoft.com/blog/Azure-AI-Services-blog/announcing-new-phi-pricing-empowering-your-business-with-small-language-models/4395112", + "source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus2'%20and%20priceType%20eq%20'Consumption'", "supports_function_calling": true }, "azure_ai/Phi-4-multimodal-instruct": { @@ -10529,20 +10870,20 @@ "max_tokens": 4096, "mode": "chat", "output_cost_per_token": 3.2e-07, - "source": "https://techcommunity.microsoft.com/blog/Azure-AI-Services-blog/announcing-new-phi-pricing-empowering-your-business-with-small-language-models/4395112", + "source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus2'%20and%20priceType%20eq%20'Consumption'", "supports_audio_input": true, "supports_function_calling": true, "supports_vision": true }, "azure_ai/Phi-4-mini-reasoning": { - "input_cost_per_token": 8e-08, + "input_cost_per_token": 7.5e-08, "litellm_provider": "azure_ai", "max_input_tokens": 131072, "max_output_tokens": 4096, "max_tokens": 4096, "mode": "chat", - "output_cost_per_token": 3.2e-07, - "source": "https://azure.microsoft.com/en-us/pricing/details/ai-foundry-models/microsoft/", + "output_cost_per_token": 3e-07, + "source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus2'%20and%20priceType%20eq%20'Consumption'", "supports_function_calling": true }, "azure_ai/Phi-4-reasoning": { @@ -10553,7 +10894,7 @@ "max_tokens": 4096, "mode": "chat", "output_cost_per_token": 5e-07, - "source": "https://azure.microsoft.com/en-us/pricing/details/ai-foundry-models/microsoft/", + "source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus2'%20and%20priceType%20eq%20'Consumption'", "supports_function_calling": true, "supports_tool_choice": true, "supports_reasoning": true @@ -10605,7 +10946,7 @@ "max_tokens": 8182, "mode": "chat", "output_cost_per_token": 1e-05, - "source": "https://azure.microsoft.com/en-us/pricing/details/ai-foundry-models/cohere/", + "source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus2'%20and%20priceType%20eq%20'Consumption'", "supports_function_calling": true, "supports_tool_choice": true }, @@ -10644,7 +10985,7 @@ "max_tokens": 8192, "mode": "chat", "output_cost_per_token": 5.4e-06, - "source": "https://azure.microsoft.com/en-us/pricing/details/ai-foundry-models/microsoft/", + "source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus2'%20and%20priceType%20eq%20'Consumption'", "supports_reasoning": true, "supports_tool_choice": true }, @@ -10709,7 +11050,7 @@ "max_tokens": 163840, "mode": "chat", "output_cost_per_token": 1.68e-06, - "source": "https://techcommunity.microsoft.com/blog/azure-ai-foundry-blog/introducing-deepseek-v3-2-and-deepseek-v3-2-speciale-in-microsoft-foundry/4477549", + "source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus2'%20and%20priceType%20eq%20'Consumption'", "supports_assistant_prefill": true, "supports_function_calling": true, "supports_prompt_caching": true, @@ -10724,7 +11065,7 @@ "max_tokens": 163840, "mode": "chat", "output_cost_per_token": 1.68e-06, - "source": "https://techcommunity.microsoft.com/blog/azure-ai-foundry-blog/introducing-deepseek-v3-2-and-deepseek-v3-2-speciale-in-microsoft-foundry/4477549", + "source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus2'%20and%20priceType%20eq%20'Consumption'", "supports_assistant_prefill": true, "supports_function_calling": true, "supports_prompt_caching": true, @@ -10740,7 +11081,7 @@ "max_tokens": 8192, "mode": "chat", "output_cost_per_token": 5.4e-06, - "source": "https://techcommunity.microsoft.com/blog/machinelearningblog/deepseek-r1-improved-performance-higher-limits-and-transparent-pricing/4386367", + "source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus2'%20and%20priceType%20eq%20'Consumption'", "supports_reasoning": true, "supports_tool_choice": true }, @@ -10752,7 +11093,7 @@ "max_tokens": 8192, "mode": "chat", "output_cost_per_token": 4.56e-06, - "source": "https://techcommunity.microsoft.com/blog/machinelearningblog/announcing-deepseek-v3-on-azure-ai-foundry-and-github/4390438", + "source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus2'%20and%20priceType%20eq%20'Consumption'", "supports_tool_choice": true }, "azure_ai/deepseek-v3-0324": { @@ -10764,7 +11105,7 @@ "max_tokens": 8192, "mode": "chat", "output_cost_per_token": 4.56e-06, - "source": "https://techcommunity.microsoft.com/blog/machinelearningblog/announcing-deepseek-v3-on-azure-ai-foundry-and-github/4390438", + "source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus2'%20and%20priceType%20eq%20'Consumption'", "supports_function_calling": true, "supports_tool_choice": true }, @@ -10777,7 +11118,7 @@ "max_tokens": 131072, "mode": "chat", "output_cost_per_token": 4.94e-06, - "source": "https://azure.microsoft.com/en-us/pricing/details/ai-foundry-models/deepseek/", + "source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus2'%20and%20priceType%20eq%20'Consumption'", "supports_function_calling": true, "supports_reasoning": true, "supports_tool_choice": true @@ -10791,7 +11132,7 @@ "max_tokens": 384000, "mode": "chat", "output_cost_per_token": 3.48e-06, - "source": "https://azure.microsoft.com/en-us/pricing/details/ai-foundry-models/deepseek/", + "source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus2'%20and%20priceType%20eq%20'Consumption'", "supports_function_calling": true, "supports_reasoning": true, "supports_tool_choice": true, @@ -10807,7 +11148,7 @@ "max_tokens": 384000, "mode": "chat", "output_cost_per_token": 5.1e-07, - "source": "https://azure.microsoft.com/en-us/pricing/details/ai-foundry-models/deepseek/", + "source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus2'%20and%20priceType%20eq%20'Consumption'", "supports_function_calling": true, "supports_reasoning": true, "supports_tool_choice": true, @@ -10824,7 +11165,7 @@ "max_tokens": 128000, "mode": "chat", "output_cost_per_token": 1.32e-06, - "source": "https://azure.microsoft.com/en-us/pricing/details/ai-foundry-models/deepseek/", + "source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus2'%20and%20priceType%20eq%20'Consumption'", "supports_function_calling": true, "supports_prompt_caching": true, "supports_reasoning": true, @@ -10838,7 +11179,7 @@ "mode": "embedding", "output_cost_per_token": 0.0, "output_vector_size": 3072, - "source": "https://marketplace.microsoft.com/pt-br/marketplace/apps/cohere.cohere-embed-4-offer?tab=PlansAndPrice", + "source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus2'%20and%20priceType%20eq%20'Consumption'", "supported_endpoints": [ "/v1/embeddings" ], @@ -10857,7 +11198,7 @@ "max_tokens": 131072, "mode": "chat", "output_cost_per_token": 1.5e-05, - "source": "https://devblogs.microsoft.com/foundry/announcing-grok-3-and-grok-3-mini-on-azure-ai-foundry/", + "source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus2'%20and%20priceType%20eq%20'Consumption'", "supports_function_calling": true, "supports_response_schema": false, "supports_tool_choice": true, @@ -10872,7 +11213,7 @@ "max_tokens": 131072, "mode": "chat", "output_cost_per_token": 1.27e-06, - "source": "https://devblogs.microsoft.com/foundry/announcing-grok-3-and-grok-3-mini-on-azure-ai-foundry/", + "source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus2'%20and%20priceType%20eq%20'Consumption'", "supports_function_calling": true, "supports_reasoning": true, "supports_response_schema": false, @@ -10888,7 +11229,7 @@ "max_tokens": 131072, "mode": "chat", "output_cost_per_token": 1.5e-05, - "source": "https://azure.microsoft.com/en-us/pricing/details/ai-foundry-models/grok/", + "source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus2'%20and%20priceType%20eq%20'Consumption'", "supports_function_calling": true, "supports_response_schema": false, "supports_tool_choice": true, @@ -10903,7 +11244,7 @@ "max_tokens": 131072, "mode": "chat", "output_cost_per_token": 1.27e-06, - "source": "https://azure.microsoft.com/en-us/pricing/details/ai-foundry-models/grok/", + "source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus2'%20and%20priceType%20eq%20'Consumption'", "supports_function_calling": true, "supports_reasoning": true, "supports_response_schema": false, @@ -10918,7 +11259,7 @@ "max_tokens": 131072, "mode": "chat", "output_cost_per_token": 1.5e-05, - "source": "https://azure.microsoft.com/en-us/pricing/details/ai-foundry-models/grok/", + "source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus2'%20and%20priceType%20eq%20'Consumption'", "supports_function_calling": true, "supports_response_schema": true, "supports_tool_choice": true, @@ -10933,7 +11274,7 @@ "max_tokens": 200000, "mode": "chat", "output_cost_per_token": 2.5e-06, - "source": "https://techcommunity.microsoft.com/blog/azure-ai-foundry-blog/introducing-grok-4-3-on-microsoft-foundry-latest-generation-agentic-capabilities/4517096", + "source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus2'%20and%20priceType%20eq%20'Consumption'", "supports_function_calling": true, "supports_prompt_caching": true, "supports_reasoning": true, @@ -10951,7 +11292,7 @@ "max_tokens": 128000, "mode": "chat", "output_cost_per_token": 6e-06, - "source": "https://techcommunity.microsoft.com/blog/azure-ai-foundry-blog/grok-4-6-comes-to-microsoft-foundry-models-built-for-long-horizon-reasoning-and-/4547578", + "source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus2'%20and%20priceType%20eq%20'Consumption'", "supports_function_calling": true, "supports_prompt_caching": true, "supports_reasoning": true, @@ -10970,7 +11311,7 @@ "max_tokens": 8192, "mode": "chat", "output_cost_per_token": 2.5e-06, - "source": "https://azure.microsoft.com/en-us/pricing/details/ai-foundry-models/grok/", + "source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus2'%20and%20priceType%20eq%20'Consumption'", "supports_function_calling": true, "supports_response_schema": true, "supports_tool_choice": true, @@ -10988,7 +11329,7 @@ "max_tokens": 8192, "mode": "chat", "output_cost_per_token": 2.5e-06, - "source": "https://azure.microsoft.com/en-us/pricing/details/ai-foundry-models/grok/", + "source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus2'%20and%20priceType%20eq%20'Consumption'", "supports_function_calling": true, "supports_response_schema": true, "supports_tool_choice": true, @@ -11004,6 +11345,7 @@ "max_output_tokens": 131072, "max_tokens": 131072, "mode": "chat", + "source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus2'%20and%20priceType%20eq%20'Consumption'", "supports_function_calling": true, "supports_response_schema": true, "supports_tool_choice": true, @@ -11018,7 +11360,7 @@ "max_output_tokens": 131072, "max_tokens": 131072, "mode": "chat", - "source": "https://azure.microsoft.com/en-us/pricing/details/ai-foundry-models/grok/", + "source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus2'%20and%20priceType%20eq%20'Consumption'", "supports_function_calling": true, "supports_response_schema": true, "supports_tool_choice": true, @@ -11032,7 +11374,7 @@ "max_output_tokens": 131072, "max_tokens": 131072, "mode": "chat", - "source": "https://techcommunity.microsoft.com/t5/Azure-AI-Foundry-Blog/Grok-4-0-Goes-GA-in-Microsoft-Foundry-and-Grok-4-1-Fast-Arrives/ba-p/4497964", + "source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus2'%20and%20priceType%20eq%20'Consumption'", "supports_function_calling": true, "supports_response_schema": true, "supports_tool_choice": true, @@ -11046,7 +11388,7 @@ "max_output_tokens": 131072, "max_tokens": 131072, "mode": "chat", - "source": "https://techcommunity.microsoft.com/t5/Azure-AI-Foundry-Blog/Grok-4-0-Goes-GA-in-Microsoft-Foundry-and-Grok-4-1-Fast-Arrives/ba-p/4497964", + "source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus2'%20and%20priceType%20eq%20'Consumption'", "supports_function_calling": true, "supports_reasoning": true, "supports_response_schema": true, @@ -11061,7 +11403,7 @@ "max_tokens": 131072, "mode": "chat", "output_cost_per_token": 1.5e-06, - "source": "https://azure.microsoft.com/en-us/pricing/details/ai-foundry-models/grok/", + "source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus2'%20and%20priceType%20eq%20'Consumption'", "supports_function_calling": true, "supports_response_schema": true, "supports_tool_choice": true, @@ -11096,7 +11438,7 @@ "max_tokens": 262144, "mode": "chat", "output_cost_per_token": 3e-06, - "source": "https://azure.microsoft.com/en-us/pricing/details/ai-foundry-models/kimi/", + "source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus2'%20and%20priceType%20eq%20'Consumption'", "supports_function_calling": true, "supports_tool_choice": true, "supports_video_input": true, @@ -11113,7 +11455,7 @@ "max_tokens": 262144, "mode": "chat", "output_cost_per_token": 4e-06, - "source": "https://azure.microsoft.com/en-us/pricing/details/ai-foundry-models/kimi/", + "source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus2'%20and%20priceType%20eq%20'Consumption'", "supported_modalities": [ "text", "image" @@ -11183,7 +11525,7 @@ "max_tokens": 8191, "mode": "chat", "output_cost_per_token": 1.5e-06, - "source": "https://azure.microsoft.com/en-us/blog/introducing-mistral-large-3-in-microsoft-foundry-open-capable-and-ready-for-production-workloads/", + "source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus2'%20and%20priceType%20eq%20'Consumption'", "supports_function_calling": true, "supports_tool_choice": true, "supports_vision": true @@ -61814,7 +62156,7 @@ "max_tokens": 262144, "mode": "chat", "output_cost_per_token": 4e-06, - "source": "https://azure.microsoft.com/en-us/pricing/details/ai-foundry-models/kimi/", + "source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus2'%20and%20priceType%20eq%20'Consumption'", "supported_modalities": [ "text", "image" @@ -66133,5 +66475,586 @@ "mode": "chat", "output_cost_per_token": 8e-06, "source": "https://api.together.ai/v1/models" + }, + "azure/eu/codex-mini": { + "cache_read_input_token_cost": 4.13e-07, + "input_cost_per_token": 1.65e-06, + "litellm_provider": "azure", + "mode": "chat", + "output_cost_per_token": 6.6e-06, + "source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus2'%20and%20priceType%20eq%20'Consumption'" + }, + "azure/eu/computer-use-preview": { + "input_cost_per_token": 3.3e-06, + "litellm_provider": "azure", + "mode": "chat", + "output_cost_per_token": 1.32e-05, + "source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus2'%20and%20priceType%20eq%20'Consumption'" + }, + "azure/eu/gpt-4.1": { + "cache_read_input_token_cost": 5.5e-07, + "cache_read_input_token_cost_priority": 9.63e-07, + "input_cost_per_token": 2.2e-06, + "input_cost_per_token_batches": 1.1e-06, + "input_cost_per_token_priority": 3.85e-06, + "litellm_provider": "azure", + "mode": "chat", + "output_cost_per_token": 8.8e-06, + "output_cost_per_token_batches": 4.4e-06, + "output_cost_per_token_priority": 1.54e-05, + "source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus2'%20and%20priceType%20eq%20'Consumption'" + }, + "azure/eu/gpt-4.1-mini": { + "cache_read_input_token_cost": 1.1e-07, + "cache_read_input_token_cost_priority": 1.93e-07, + "input_cost_per_token": 4.4e-07, + "input_cost_per_token_batches": 2.2e-07, + "input_cost_per_token_priority": 7.7e-07, + "litellm_provider": "azure", + "mode": "chat", + "output_cost_per_token": 1.76e-06, + "output_cost_per_token_batches": 8.8e-07, + "output_cost_per_token_priority": 3.08e-06, + "source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus2'%20and%20priceType%20eq%20'Consumption'" + }, + "azure/eu/gpt-4.1-nano": { + "cache_read_input_token_cost": 2.8e-08, + "input_cost_per_token": 1.1e-07, + "input_cost_per_token_batches": 5.5e-08, + "litellm_provider": "azure", + "mode": "chat", + "output_cost_per_token": 4.4e-07, + "output_cost_per_token_batches": 2.2e-07, + "source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus2'%20and%20priceType%20eq%20'Consumption'" + }, + "azure/eu/gpt-4o-2024-05-13": { + "input_cost_per_token": 5.5e-06, + "input_cost_per_token_batches": 2.75e-06, + "litellm_provider": "azure", + "mode": "chat", + "output_cost_per_token": 1.65e-05, + "output_cost_per_token_batches": 8.25e-06, + "source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus2'%20and%20priceType%20eq%20'Consumption'" + }, + "azure/eu/gpt-5": { + "cache_read_input_token_cost": 1.375e-07, + "cache_read_input_token_cost_priority": 2.75e-07, + "input_cost_per_token": 1.375e-06, + "input_cost_per_token_batches": 6.875e-07, + "input_cost_per_token_priority": 2.75e-06, + "litellm_provider": "azure", + "mode": "chat", + "output_cost_per_token": 1.1e-05, + "output_cost_per_token_batches": 5.5e-06, + "output_cost_per_token_priority": 2.2e-05, + "source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus2'%20and%20priceType%20eq%20'Consumption'" + }, + "azure/eu/gpt-5-codex": { + "cache_read_input_token_cost": 1.38e-07, + "input_cost_per_token": 1.375e-06, + "litellm_provider": "azure", + "mode": "chat", + "output_cost_per_token": 1.1e-05, + "source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus2'%20and%20priceType%20eq%20'Consumption'" + }, + "azure/eu/gpt-5-mini": { + "cache_read_input_token_cost": 2.75e-08, + "cache_read_input_token_cost_priority": 4.95e-08, + "input_cost_per_token": 2.75e-07, + "input_cost_per_token_batches": 1.375e-07, + "input_cost_per_token_priority": 4.95e-07, + "litellm_provider": "azure", + "mode": "chat", + "output_cost_per_token": 2.2e-06, + "output_cost_per_token_batches": 1.1e-06, + "output_cost_per_token_priority": 3.96e-06, + "source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus2'%20and%20priceType%20eq%20'Consumption'" + }, + "azure/eu/gpt-5-nano": { + "cache_read_input_token_cost": 5.5e-09, + "input_cost_per_token": 5.5e-08, + "input_cost_per_token_batches": 2.75e-08, + "litellm_provider": "azure", + "mode": "chat", + "output_cost_per_token": 4.4e-07, + "output_cost_per_token_batches": 2.2e-07, + "source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus2'%20and%20priceType%20eq%20'Consumption'" + }, + "azure/eu/gpt-5-pro": { + "input_cost_per_token": 1.65e-05, + "input_cost_per_token_batches": 8.25e-06, + "litellm_provider": "azure", + "mode": "chat", + "output_cost_per_token": 0.000132, + "output_cost_per_token_batches": 6.6e-05, + "source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus2'%20and%20priceType%20eq%20'Consumption'" + }, + "azure/eu/gpt-5.1-codex-max": { + "cache_read_input_token_cost": 1.375e-07, + "input_cost_per_token": 1.375e-06, + "litellm_provider": "azure", + "mode": "chat", + "output_cost_per_token": 1.1e-05, + "source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus2'%20and%20priceType%20eq%20'Consumption'" + }, + "azure/eu/gpt-5.2": { + "cache_read_input_token_cost": 1.925e-07, + "cache_read_input_token_cost_priority": 3.85e-07, + "input_cost_per_token": 1.925e-06, + "input_cost_per_token_batches": 9.625e-07, + "input_cost_per_token_priority": 3.85e-06, + "litellm_provider": "azure", + "mode": "chat", + "output_cost_per_token": 1.54e-05, + "output_cost_per_token_batches": 7.7e-06, + "output_cost_per_token_priority": 3.08e-05, + "source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus2'%20and%20priceType%20eq%20'Consumption'" + }, + "azure/eu/gpt-5.2-chat": { + "cache_read_input_token_cost": 1.925e-07, + "input_cost_per_token": 1.925e-06, + "litellm_provider": "azure", + "mode": "chat", + "output_cost_per_token": 1.54e-05, + "source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus2'%20and%20priceType%20eq%20'Consumption'" + }, + "azure/eu/gpt-5.2-codex": { + "cache_read_input_token_cost": 1.925e-07, + "input_cost_per_token": 1.925e-06, + "litellm_provider": "azure", + "mode": "chat", + "output_cost_per_token": 1.54e-05, + "source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus2'%20and%20priceType%20eq%20'Consumption'" + }, + "azure/eu/gpt-5.2-pro": { + "input_cost_per_token": 2.31e-05, + "input_cost_per_token_batches": 1.155e-05, + "litellm_provider": "azure", + "mode": "chat", + "output_cost_per_token": 0.0001848, + "output_cost_per_token_batches": 9.24e-05, + "source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus2'%20and%20priceType%20eq%20'Consumption'" + }, + "azure/eu/gpt-5.3-chat": { + "cache_read_input_token_cost": 1.925e-07, + "input_cost_per_token": 1.925e-06, + "litellm_provider": "azure", + "mode": "chat", + "output_cost_per_token": 1.54e-05, + "source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus2'%20and%20priceType%20eq%20'Consumption'" + }, + "azure/eu/gpt-5.3-codex": { + "cache_read_input_token_cost": 1.925e-07, + "cache_read_input_token_cost_priority": 3.85e-07, + "input_cost_per_token": 1.925e-06, + "input_cost_per_token_priority": 3.85e-06, + "litellm_provider": "azure", + "mode": "chat", + "output_cost_per_token": 1.54e-05, + "output_cost_per_token_priority": 3.08e-05, + "source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus2'%20and%20priceType%20eq%20'Consumption'" + }, + "azure/eu/gpt-5.4-mini": { + "cache_read_input_token_cost": 8.25e-08, + "cache_read_input_token_cost_priority": 1.65e-07, + "input_cost_per_token": 8.25e-07, + "input_cost_per_token_batches": 4.125e-07, + "input_cost_per_token_priority": 1.65e-06, + "litellm_provider": "azure", + "mode": "chat", + "output_cost_per_token": 4.95e-06, + "output_cost_per_token_batches": 2.475e-06, + "output_cost_per_token_priority": 9.9e-06, + "source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus2'%20and%20priceType%20eq%20'Consumption'" + }, + "azure/eu/gpt-5.4-nano": { + "cache_read_input_token_cost": 2.2e-08, + "input_cost_per_token": 2.2e-07, + "input_cost_per_token_batches": 1.1e-07, + "litellm_provider": "azure", + "mode": "chat", + "output_cost_per_token": 1.375e-06, + "output_cost_per_token_batches": 6.875e-07, + "source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus2'%20and%20priceType%20eq%20'Consumption'" + }, + "azure/eu/gpt-5.4-pro": { + "input_cost_per_token": 3.3e-05, + "input_cost_per_token_above_272k_tokens": 6.6e-05, + "input_cost_per_token_batches": 1.65e-05, + "litellm_provider": "azure", + "mode": "chat", + "output_cost_per_token": 0.000198, + "output_cost_per_token_above_272k_tokens": 0.000297, + "output_cost_per_token_batches": 9.9e-05, + "source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus2'%20and%20priceType%20eq%20'Consumption'" + }, + "azure/eu/gpt-6-astra": { + "cache_creation_input_token_cost": 1.375e-05, + "cache_creation_input_token_cost_above_272k_tokens": 2.75e-05, + "cache_read_input_token_cost": 1.1e-06, + "cache_read_input_token_cost_above_272k_tokens": 2.2e-06, + "input_cost_per_token": 1.1e-05, + "input_cost_per_token_above_272k_tokens": 2.2e-05, + "litellm_provider": "azure", + "mode": "chat", + "output_cost_per_token": 5.5e-05, + "output_cost_per_token_above_272k_tokens": 8.25e-05, + "source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus2'%20and%20priceType%20eq%20'Consumption'" + }, + "azure/eu/o1-mini": { + "cache_read_input_token_cost": 6.05e-07, + "input_cost_per_token": 1.21e-06, + "input_cost_per_token_batches": 6.05e-07, + "litellm_provider": "azure", + "mode": "chat", + "output_cost_per_token": 4.84e-06, + "output_cost_per_token_batches": 2.42e-06, + "source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus2'%20and%20priceType%20eq%20'Consumption'" + }, + "azure/eu/o1-preview": { + "cache_read_input_token_cost": 8.25e-06, + "input_cost_per_token": 1.65e-05, + "litellm_provider": "azure", + "mode": "chat", + "output_cost_per_token": 6.6e-05, + "source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus2'%20and%20priceType%20eq%20'Consumption'" + }, + "azure/eu/o3-2025-04-16": { + "cache_read_input_token_cost": 5.5e-07, + "input_cost_per_token": 2.2e-06, + "input_cost_per_token_batches": 1.1e-06, + "litellm_provider": "azure", + "mode": "chat", + "output_cost_per_token": 8.8e-06, + "output_cost_per_token_batches": 4.4e-06, + "source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus2'%20and%20priceType%20eq%20'Consumption'" + }, + "azure/eu/o3-deep-research": { + "cache_read_input_token_cost": 2.75e-06, + "input_cost_per_token": 1.1e-05, + "litellm_provider": "azure", + "mode": "chat", + "output_cost_per_token": 4.4e-05, + "source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus2'%20and%20priceType%20eq%20'Consumption'" + }, + "azure/eu/o4-mini-2025-04-16": { + "cache_read_input_token_cost": 3.03e-07, + "input_cost_per_token": 1.21e-06, + "input_cost_per_token_batches": 6.05e-07, + "litellm_provider": "azure", + "mode": "chat", + "output_cost_per_token": 4.84e-06, + "output_cost_per_token_batches": 2.42e-06, + "source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus2'%20and%20priceType%20eq%20'Consumption'" + }, + "azure/eu/text-embedding-3-large": { + "input_cost_per_token": 1.43e-07, + "litellm_provider": "azure", + "mode": "embedding", + "source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus2'%20and%20priceType%20eq%20'Consumption'" + }, + "azure/eu/text-embedding-3-small": { + "input_cost_per_token": 2.2e-08, + "litellm_provider": "azure", + "mode": "embedding", + "source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus2'%20and%20priceType%20eq%20'Consumption'" + }, + "azure/eu/text-embedding-ada-002": { + "input_cost_per_token": 1.1e-07, + "litellm_provider": "azure", + "mode": "embedding", + "source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus2'%20and%20priceType%20eq%20'Consumption'" + }, + "gemini/gemini-3.8-live": { + "input_cost_per_audio_token": 3e-06, + "input_cost_per_image_token": 1e-06, + "input_cost_per_token": 7.5e-07, + "litellm_provider": "gemini", + "max_input_tokens": 131072, + "max_output_tokens": 65536, + "max_tokens": 65536, + "mode": "realtime", + "output_cost_per_audio_token": 1.2e-05, + "output_cost_per_token": 4.5e-06, + "source": "https://ai.google.dev/gemini-api/docs/pricing", + "supports_audio_input": true, + "supports_function_calling": true, + "supports_response_schema": false, + "supports_vision": true, + "supports_web_search": true + }, + "gemini/gemini-3.8-live-extended-thinking": { + "input_cost_per_audio_token": 3e-06, + "input_cost_per_image_token": 1e-06, + "input_cost_per_token": 7.5e-07, + "litellm_provider": "gemini", + "max_input_tokens": 131072, + "max_output_tokens": 65536, + "max_tokens": 65536, + "mode": "realtime", + "output_cost_per_audio_token": 1.2e-05, + "output_cost_per_token": 4.5e-06, + "source": "https://ai.google.dev/gemini-api/docs/pricing", + "supports_audio_input": true, + "supports_function_calling": true, + "supports_response_schema": false, + "supports_vision": true, + "supports_web_search": true + }, + "azure/us/codex-mini": { + "cache_read_input_token_cost": 4.13e-07, + "input_cost_per_token": 1.65e-06, + "litellm_provider": "azure", + "mode": "chat", + "output_cost_per_token": 6.6e-06, + "source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus2'%20and%20priceType%20eq%20'Consumption'" + }, + "azure/us/computer-use-preview": { + "input_cost_per_token": 3.3e-06, + "litellm_provider": "azure", + "mode": "chat", + "output_cost_per_token": 1.32e-05, + "source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus2'%20and%20priceType%20eq%20'Consumption'" + }, + "azure/us/gpt-4.1": { + "cache_read_input_token_cost": 5.5e-07, + "cache_read_input_token_cost_priority": 9.63e-07, + "input_cost_per_token": 2.2e-06, + "input_cost_per_token_batches": 1.1e-06, + "input_cost_per_token_priority": 3.85e-06, + "litellm_provider": "azure", + "mode": "chat", + "output_cost_per_token": 8.8e-06, + "output_cost_per_token_batches": 4.4e-06, + "output_cost_per_token_priority": 1.54e-05, + "source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus2'%20and%20priceType%20eq%20'Consumption'" + }, + "azure/us/gpt-4.1-mini": { + "cache_read_input_token_cost": 1.1e-07, + "cache_read_input_token_cost_priority": 1.93e-07, + "input_cost_per_token": 4.4e-07, + "input_cost_per_token_batches": 2.2e-07, + "input_cost_per_token_priority": 7.7e-07, + "litellm_provider": "azure", + "mode": "chat", + "output_cost_per_token": 1.76e-06, + "output_cost_per_token_batches": 8.8e-07, + "output_cost_per_token_priority": 3.08e-06, + "source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus2'%20and%20priceType%20eq%20'Consumption'" + }, + "azure/us/gpt-4.1-nano": { + "cache_read_input_token_cost": 2.8e-08, + "input_cost_per_token": 1.1e-07, + "input_cost_per_token_batches": 5.5e-08, + "litellm_provider": "azure", + "mode": "chat", + "output_cost_per_token": 4.4e-07, + "output_cost_per_token_batches": 2.2e-07, + "source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus2'%20and%20priceType%20eq%20'Consumption'" + }, + "azure/us/gpt-4o-2024-05-13": { + "input_cost_per_token": 5.5e-06, + "input_cost_per_token_batches": 2.75e-06, + "litellm_provider": "azure", + "mode": "chat", + "output_cost_per_token": 1.65e-05, + "output_cost_per_token_batches": 8.25e-06, + "source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus2'%20and%20priceType%20eq%20'Consumption'" + }, + "azure/us/gpt-5": { + "cache_read_input_token_cost": 1.375e-07, + "cache_read_input_token_cost_priority": 2.75e-07, + "input_cost_per_token": 1.375e-06, + "input_cost_per_token_batches": 6.875e-07, + "input_cost_per_token_priority": 2.75e-06, + "litellm_provider": "azure", + "mode": "chat", + "output_cost_per_token": 1.1e-05, + "output_cost_per_token_batches": 5.5e-06, + "output_cost_per_token_priority": 2.2e-05, + "source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus2'%20and%20priceType%20eq%20'Consumption'" + }, + "azure/us/gpt-5-codex": { + "cache_read_input_token_cost": 1.38e-07, + "input_cost_per_token": 1.375e-06, + "litellm_provider": "azure", + "mode": "chat", + "output_cost_per_token": 1.1e-05, + "source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus2'%20and%20priceType%20eq%20'Consumption'" + }, + "azure/us/gpt-5-mini": { + "cache_read_input_token_cost": 2.75e-08, + "cache_read_input_token_cost_priority": 4.95e-08, + "input_cost_per_token": 2.75e-07, + "input_cost_per_token_batches": 1.375e-07, + "input_cost_per_token_priority": 4.95e-07, + "litellm_provider": "azure", + "mode": "chat", + "output_cost_per_token": 2.2e-06, + "output_cost_per_token_batches": 1.1e-06, + "output_cost_per_token_priority": 3.96e-06, + "source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus2'%20and%20priceType%20eq%20'Consumption'" + }, + "azure/us/gpt-5-nano": { + "cache_read_input_token_cost": 5.5e-09, + "input_cost_per_token": 5.5e-08, + "input_cost_per_token_batches": 2.75e-08, + "litellm_provider": "azure", + "mode": "chat", + "output_cost_per_token": 4.4e-07, + "output_cost_per_token_batches": 2.2e-07, + "source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus2'%20and%20priceType%20eq%20'Consumption'" + }, + "azure/us/gpt-5-pro": { + "input_cost_per_token": 1.65e-05, + "input_cost_per_token_batches": 8.25e-06, + "litellm_provider": "azure", + "mode": "chat", + "output_cost_per_token": 0.000132, + "output_cost_per_token_batches": 6.6e-05, + "source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus2'%20and%20priceType%20eq%20'Consumption'" + }, + "azure/us/gpt-5.1-codex-max": { + "cache_read_input_token_cost": 1.375e-07, + "input_cost_per_token": 1.375e-06, + "litellm_provider": "azure", + "mode": "chat", + "output_cost_per_token": 1.1e-05, + "source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus2'%20and%20priceType%20eq%20'Consumption'" + }, + "azure/us/gpt-5.2": { + "cache_read_input_token_cost": 1.925e-07, + "cache_read_input_token_cost_priority": 3.85e-07, + "input_cost_per_token": 1.925e-06, + "input_cost_per_token_batches": 9.625e-07, + "input_cost_per_token_priority": 3.85e-06, + "litellm_provider": "azure", + "mode": "chat", + "output_cost_per_token": 1.54e-05, + "output_cost_per_token_batches": 7.7e-06, + "output_cost_per_token_priority": 3.08e-05, + "source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus2'%20and%20priceType%20eq%20'Consumption'" + }, + "azure/us/gpt-5.2-chat": { + "cache_read_input_token_cost": 1.925e-07, + "input_cost_per_token": 1.925e-06, + "litellm_provider": "azure", + "mode": "chat", + "output_cost_per_token": 1.54e-05, + "source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus2'%20and%20priceType%20eq%20'Consumption'" + }, + "azure/us/gpt-5.2-codex": { + "cache_read_input_token_cost": 1.925e-07, + "input_cost_per_token": 1.925e-06, + "litellm_provider": "azure", + "mode": "chat", + "output_cost_per_token": 1.54e-05, + "source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus2'%20and%20priceType%20eq%20'Consumption'" + }, + "azure/us/gpt-5.2-pro": { + "input_cost_per_token": 2.31e-05, + "input_cost_per_token_batches": 1.155e-05, + "litellm_provider": "azure", + "mode": "chat", + "output_cost_per_token": 0.0001848, + "output_cost_per_token_batches": 9.24e-05, + "source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus2'%20and%20priceType%20eq%20'Consumption'" + }, + "azure/us/gpt-5.3-chat": { + "cache_read_input_token_cost": 1.925e-07, + "input_cost_per_token": 1.925e-06, + "litellm_provider": "azure", + "mode": "chat", + "output_cost_per_token": 1.54e-05, + "source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus2'%20and%20priceType%20eq%20'Consumption'" + }, + "azure/us/gpt-5.3-codex": { + "cache_read_input_token_cost": 1.925e-07, + "cache_read_input_token_cost_priority": 3.85e-07, + "input_cost_per_token": 1.925e-06, + "input_cost_per_token_priority": 3.85e-06, + "litellm_provider": "azure", + "mode": "chat", + "output_cost_per_token": 1.54e-05, + "output_cost_per_token_priority": 3.08e-05, + "source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus2'%20and%20priceType%20eq%20'Consumption'" + }, + "azure/us/gpt-5.4-mini": { + "cache_read_input_token_cost": 8.25e-08, + "cache_read_input_token_cost_priority": 1.65e-07, + "input_cost_per_token": 8.25e-07, + "input_cost_per_token_batches": 4.125e-07, + "input_cost_per_token_priority": 1.65e-06, + "litellm_provider": "azure", + "mode": "chat", + "output_cost_per_token": 4.95e-06, + "output_cost_per_token_batches": 2.475e-06, + "output_cost_per_token_priority": 9.9e-06, + "source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus2'%20and%20priceType%20eq%20'Consumption'" + }, + "azure/us/gpt-5.4-nano": { + "cache_read_input_token_cost": 2.2e-08, + "input_cost_per_token": 2.2e-07, + "input_cost_per_token_batches": 1.1e-07, + "litellm_provider": "azure", + "mode": "chat", + "output_cost_per_token": 1.375e-06, + "output_cost_per_token_batches": 6.875e-07, + "source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus2'%20and%20priceType%20eq%20'Consumption'" + }, + "azure/us/gpt-5.4-pro": { + "input_cost_per_token": 3.3e-05, + "input_cost_per_token_above_272k_tokens": 6.6e-05, + "input_cost_per_token_batches": 1.65e-05, + "litellm_provider": "azure", + "mode": "chat", + "output_cost_per_token": 0.000198, + "output_cost_per_token_above_272k_tokens": 0.000297, + "output_cost_per_token_batches": 9.9e-05, + "source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus2'%20and%20priceType%20eq%20'Consumption'" + }, + "azure/us/o1-mini": { + "cache_read_input_token_cost": 6.05e-07, + "input_cost_per_token": 1.21e-06, + "input_cost_per_token_batches": 6.05e-07, + "litellm_provider": "azure", + "mode": "chat", + "output_cost_per_token": 4.84e-06, + "output_cost_per_token_batches": 2.42e-06, + "source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus2'%20and%20priceType%20eq%20'Consumption'" + }, + "azure/us/o1-preview": { + "cache_read_input_token_cost": 8.25e-06, + "input_cost_per_token": 1.65e-05, + "litellm_provider": "azure", + "mode": "chat", + "output_cost_per_token": 6.6e-05, + "source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus2'%20and%20priceType%20eq%20'Consumption'" + }, + "azure/us/o3-deep-research": { + "cache_read_input_token_cost": 2.75e-06, + "input_cost_per_token": 1.1e-05, + "litellm_provider": "azure", + "mode": "chat", + "output_cost_per_token": 4.4e-05, + "source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus2'%20and%20priceType%20eq%20'Consumption'" + }, + "azure/us/text-embedding-3-large": { + "input_cost_per_token": 1.43e-07, + "litellm_provider": "azure", + "mode": "embedding", + "source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus2'%20and%20priceType%20eq%20'Consumption'" + }, + "azure/us/text-embedding-3-small": { + "input_cost_per_token": 2.2e-08, + "litellm_provider": "azure", + "mode": "embedding", + "source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus2'%20and%20priceType%20eq%20'Consumption'" + }, + "azure/us/text-embedding-ada-002": { + "input_cost_per_token": 1.1e-07, + "litellm_provider": "azure", + "mode": "embedding", + "source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus2'%20and%20priceType%20eq%20'Consumption'" } } diff --git a/model_prices_and_context_window.json b/model_prices_and_context_window.json index 02adbbed736..1d863977e1e 100644 --- a/model_prices_and_context_window.json +++ b/model_prices_and_context_window.json @@ -3144,6 +3144,7 @@ "max_tokens": 100000, "mode": "responses", "output_cost_per_token": 6e-06, + "source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus2'%20and%20priceType%20eq%20'Consumption'", "supported_endpoints": [ "/v1/responses" ], @@ -3535,6 +3536,7 @@ "max_tokens": 1024, "mode": "chat", "output_cost_per_token": 1.2e-05, + "source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus2'%20and%20priceType%20eq%20'Consumption'", "supported_endpoints": [ "/v1/responses" ], @@ -3567,7 +3569,7 @@ "max_output_tokens": 131072, "max_tokens": 131072, "mode": "chat", - "source": "https://azure.microsoft.com/en-us/pricing/details/cognitive-services/openai-service/", + "source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus2'%20and%20priceType%20eq%20'Consumption'", "supports_function_calling": true, "supports_parallel_function_calling": true, "supports_response_schema": true, @@ -4172,12 +4174,15 @@ "deprecation_date": "2027-04-14", "cache_read_input_token_cost": 1.375e-06, "input_cost_per_token": 2.75e-06, + "input_cost_per_token_batches": 1.375e-06, "litellm_provider": "azure", "max_input_tokens": 128000, "max_output_tokens": 16384, "max_tokens": 16384, "mode": "chat", "output_cost_per_token": 1.1e-05, + "output_cost_per_token_batches": 5.5e-06, + "source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus2'%20and%20priceType%20eq%20'Consumption'", "supports_function_calling": true, "supports_parallel_function_calling": true, "supports_prompt_caching": true, @@ -4188,13 +4193,17 @@ "azure/eu/gpt-4o-2024-11-20": { "deprecation_date": "2027-04-14", "cache_creation_input_token_cost": 1.38e-06, + "cache_read_input_token_cost": 1.375e-06, "input_cost_per_token": 2.75e-06, + "input_cost_per_token_batches": 1.375e-06, "litellm_provider": "azure", "max_input_tokens": 128000, "max_output_tokens": 16384, "max_tokens": 16384, "mode": "chat", "output_cost_per_token": 1.1e-05, + "output_cost_per_token_batches": 5.5e-06, + "source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus2'%20and%20priceType%20eq%20'Consumption'", "supports_function_calling": true, "supports_parallel_function_calling": true, "supports_response_schema": true, @@ -4205,12 +4214,14 @@ "cache_read_input_token_cost": 8.3e-08, "deprecation_date": "2027-04-14", "input_cost_per_token": 1.65e-07, + "input_cost_per_token_batches": 8.3e-08, "litellm_provider": "azure", "max_input_tokens": 128000, "max_output_tokens": 16384, "max_tokens": 16384, "mode": "chat", "output_cost_per_token": 6.6e-07, + "source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus2'%20and%20priceType%20eq%20'Consumption'", "supports_function_calling": true, "supports_parallel_function_calling": true, "supports_prompt_caching": true, @@ -4351,8 +4362,9 @@ }, "azure/eu/gpt-5.1": { "deprecation_date": "2027-05-15", - "cache_read_input_token_cost": 1.4e-07, - "input_cost_per_token": 1.38e-06, + "cache_read_input_token_cost": 1.375e-07, + "cache_read_input_token_cost_priority": 2.75e-07, + "input_cost_per_token": 1.375e-06, "litellm_provider": "azure", "max_input_tokens": 272000, "max_output_tokens": 128000, @@ -4383,12 +4395,17 @@ "supports_tool_choice": true, "supports_vision": true, "supports_none_reasoning_effort": true, - "default_reasoning_effort": "none" + "default_reasoning_effort": "none", + "input_cost_per_token_batches": 6.875e-07, + "input_cost_per_token_priority": 2.75e-06, + "output_cost_per_token_batches": 5.5e-06, + "output_cost_per_token_priority": 2.2e-05, + "source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus2'%20and%20priceType%20eq%20'Consumption'" }, "azure/eu/gpt-5.1-chat": { - "cache_read_input_token_cost": 1.4e-07, + "cache_read_input_token_cost": 1.375e-07, "deprecation_date": "2026-06-29", - "input_cost_per_token": 1.38e-06, + "input_cost_per_token": 1.375e-06, "litellm_provider": "azure", "max_input_tokens": 128000, "max_output_tokens": 128000, @@ -4419,18 +4436,20 @@ "supports_tool_choice": true, "supports_vision": true, "supports_none_reasoning_effort": true, - "default_reasoning_effort": "none" + "default_reasoning_effort": "none", + "source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus2'%20and%20priceType%20eq%20'Consumption'" }, "azure/eu/gpt-5.1-codex": { "deprecation_date": "2027-05-15", - "cache_read_input_token_cost": 1.4e-07, - "input_cost_per_token": 1.38e-06, + "cache_read_input_token_cost": 1.375e-07, + "input_cost_per_token": 1.375e-06, "litellm_provider": "azure", "max_input_tokens": 272000, "max_output_tokens": 128000, "max_tokens": 128000, "mode": "responses", "output_cost_per_token": 1.1e-05, + "source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus2'%20and%20priceType%20eq%20'Consumption'", "supported_endpoints": [ "/v1/responses" ], @@ -4454,7 +4473,7 @@ }, "azure/eu/gpt-5.1-codex-mini": { "deprecation_date": "2027-05-15", - "cache_read_input_token_cost": 2.8e-08, + "cache_read_input_token_cost": 2.75e-08, "input_cost_per_token": 2.75e-07, "litellm_provider": "azure", "max_input_tokens": 272000, @@ -4462,6 +4481,7 @@ "max_tokens": 128000, "mode": "responses", "output_cost_per_token": 2.2e-06, + "source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus2'%20and%20priceType%20eq%20'Consumption'", "supported_endpoints": [ "/v1/responses" ], @@ -4520,12 +4540,15 @@ "cache_read_input_token_cost": 8.25e-06, "deprecation_date": "2026-11-19", "input_cost_per_token": 1.65e-05, + "input_cost_per_token_batches": 8.25e-06, "litellm_provider": "azure", "max_input_tokens": 200000, "max_output_tokens": 100000, "max_tokens": 100000, "mode": "chat", "output_cost_per_token": 6.6e-05, + "output_cost_per_token_batches": 3.3e-05, + "source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus2'%20and%20priceType%20eq%20'Consumption'", "supports_function_calling": true, "supports_parallel_function_calling": true, "supports_prompt_caching": true, @@ -4574,6 +4597,7 @@ "mode": "chat", "output_cost_per_token": 4.84e-06, "output_cost_per_token_batches": 2.42e-06, + "source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus2'%20and%20priceType%20eq%20'Consumption'", "supports_prompt_caching": true, "supports_reasoning": true, "supports_tool_choice": true, @@ -4583,12 +4607,15 @@ "cache_read_input_token_cost": 1.25e-06, "deprecation_date": "2027-04-14", "input_cost_per_token": 2.5e-06, + "input_cost_per_token_batches": 1.25e-06, "litellm_provider": "azure", "max_input_tokens": 128000, "max_output_tokens": 16384, "max_tokens": 16384, "mode": "chat", "output_cost_per_token": 1e-05, + "output_cost_per_token_batches": 5e-06, + "source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus2'%20and%20priceType%20eq%20'Consumption'", "supports_function_calling": true, "supports_parallel_function_calling": true, "supports_prompt_caching": true, @@ -4600,12 +4627,15 @@ "cache_read_input_token_cost": 1.25e-06, "deprecation_date": "2027-04-14", "input_cost_per_token": 2.5e-06, + "input_cost_per_token_batches": 1.25e-06, "litellm_provider": "azure", "max_input_tokens": 128000, "max_output_tokens": 16384, "max_tokens": 16384, "mode": "chat", "output_cost_per_token": 1e-05, + "output_cost_per_token_batches": 5e-06, + "source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus2'%20and%20priceType%20eq%20'Consumption'", "supports_function_calling": true, "supports_parallel_function_calling": true, "supports_response_schema": true, @@ -4631,12 +4661,15 @@ "deprecation_date": "2027-04-14", "cache_read_input_token_cost": 1.25e-06, "input_cost_per_token": 2.5e-06, + "input_cost_per_token_batches": 1.25e-06, "litellm_provider": "azure", "max_input_tokens": 128000, "max_output_tokens": 16384, "max_tokens": 16384, "mode": "chat", "output_cost_per_token": 1e-05, + "output_cost_per_token_batches": 5e-06, + "source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus2'%20and%20priceType%20eq%20'Consumption'", "supports_function_calling": true, "supports_parallel_function_calling": true, "supports_prompt_caching": true, @@ -4648,12 +4681,15 @@ "deprecation_date": "2027-04-14", "cache_read_input_token_cost": 1.25e-06, "input_cost_per_token": 2.5e-06, + "input_cost_per_token_batches": 1.25e-06, "litellm_provider": "azure", "max_input_tokens": 128000, "max_output_tokens": 16384, "max_tokens": 16384, "mode": "chat", "output_cost_per_token": 1e-05, + "output_cost_per_token_batches": 5e-06, + "source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus2'%20and%20priceType%20eq%20'Consumption'", "supports_function_calling": true, "supports_parallel_function_calling": true, "supports_prompt_caching": true, @@ -4664,6 +4700,7 @@ "azure/global/gpt-5.1": { "deprecation_date": "2027-05-15", "cache_read_input_token_cost": 1.25e-07, + "cache_read_input_token_cost_priority": 2.5e-07, "input_cost_per_token": 1.25e-06, "litellm_provider": "azure", "max_input_tokens": 272000, @@ -4695,7 +4732,12 @@ "supports_tool_choice": true, "supports_vision": true, "supports_none_reasoning_effort": true, - "default_reasoning_effort": "none" + "default_reasoning_effort": "none", + "input_cost_per_token_batches": 6.25e-07, + "input_cost_per_token_priority": 2.5e-06, + "output_cost_per_token_batches": 5e-06, + "output_cost_per_token_priority": 2e-05, + "source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus2'%20and%20priceType%20eq%20'Consumption'" }, "azure/global/gpt-5.1-chat": { "cache_read_input_token_cost": 1.25e-07, @@ -4731,7 +4773,8 @@ "supports_tool_choice": true, "supports_vision": true, "supports_none_reasoning_effort": true, - "default_reasoning_effort": "none" + "default_reasoning_effort": "none", + "source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus2'%20and%20priceType%20eq%20'Consumption'" }, "azure/global/gpt-5.1-codex": { "deprecation_date": "2027-05-15", @@ -4743,6 +4786,7 @@ "max_tokens": 128000, "mode": "responses", "output_cost_per_token": 1e-05, + "source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus2'%20and%20priceType%20eq%20'Consumption'", "supported_endpoints": [ "/v1/responses" ], @@ -4774,6 +4818,7 @@ "max_tokens": 128000, "mode": "responses", "output_cost_per_token": 2e-06, + "source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus2'%20and%20priceType%20eq%20'Consumption'", "supported_endpoints": [ "/v1/responses" ], @@ -5006,8 +5051,10 @@ "azure/gpt-4.1": { "deprecation_date": "2027-04-14", "cache_read_input_token_cost": 5e-07, + "cache_read_input_token_cost_priority": 8.75e-07, "input_cost_per_token": 2e-06, "input_cost_per_token_batches": 1e-06, + "input_cost_per_token_priority": 3.5e-06, "litellm_provider": "azure", "max_input_tokens": 1047576, "max_output_tokens": 32768, @@ -5015,6 +5062,8 @@ "mode": "chat", "output_cost_per_token": 8e-06, "output_cost_per_token_batches": 4e-06, + "output_cost_per_token_priority": 1.4e-05, + "source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus2'%20and%20priceType%20eq%20'Consumption'", "supported_endpoints": [ "/v1/chat/completions", "/v1/batch", @@ -5040,8 +5089,10 @@ "azure/gpt-4.1-2025-04-14": { "deprecation_date": "2027-04-14", "cache_read_input_token_cost": 5e-07, + "cache_read_input_token_cost_priority": 8.75e-07, "input_cost_per_token": 2e-06, "input_cost_per_token_batches": 1e-06, + "input_cost_per_token_priority": 3.5e-06, "litellm_provider": "azure", "max_input_tokens": 1047576, "max_output_tokens": 32768, @@ -5049,6 +5100,8 @@ "mode": "chat", "output_cost_per_token": 8e-06, "output_cost_per_token_batches": 4e-06, + "output_cost_per_token_priority": 1.4e-05, + "source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus2'%20and%20priceType%20eq%20'Consumption'", "supported_endpoints": [ "/v1/chat/completions", "/v1/batch", @@ -5074,8 +5127,10 @@ "azure/gpt-4.1-mini": { "deprecation_date": "2027-04-14", "cache_read_input_token_cost": 1e-07, + "cache_read_input_token_cost_priority": 1.75e-07, "input_cost_per_token": 4e-07, "input_cost_per_token_batches": 2e-07, + "input_cost_per_token_priority": 7e-07, "litellm_provider": "azure", "max_input_tokens": 1047576, "max_output_tokens": 32768, @@ -5083,6 +5138,8 @@ "mode": "chat", "output_cost_per_token": 1.6e-06, "output_cost_per_token_batches": 8e-07, + "output_cost_per_token_priority": 2.8e-06, + "source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus2'%20and%20priceType%20eq%20'Consumption'", "supported_endpoints": [ "/v1/chat/completions", "/v1/batch", @@ -5108,8 +5165,10 @@ "azure/gpt-4.1-mini-2025-04-14": { "deprecation_date": "2027-04-14", "cache_read_input_token_cost": 1e-07, + "cache_read_input_token_cost_priority": 1.75e-07, "input_cost_per_token": 4e-07, "input_cost_per_token_batches": 2e-07, + "input_cost_per_token_priority": 7e-07, "litellm_provider": "azure", "max_input_tokens": 1047576, "max_output_tokens": 32768, @@ -5117,6 +5176,8 @@ "mode": "chat", "output_cost_per_token": 1.6e-06, "output_cost_per_token_batches": 8e-07, + "output_cost_per_token_priority": 2.8e-06, + "source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus2'%20and%20priceType%20eq%20'Consumption'", "supported_endpoints": [ "/v1/chat/completions", "/v1/batch", @@ -5151,6 +5212,7 @@ "mode": "chat", "output_cost_per_token": 4e-07, "output_cost_per_token_batches": 2e-07, + "source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus2'%20and%20priceType%20eq%20'Consumption'", "supported_endpoints": [ "/v1/chat/completions", "/v1/batch", @@ -5184,6 +5246,7 @@ "mode": "chat", "output_cost_per_token": 4e-07, "output_cost_per_token_batches": 2e-07, + "source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus2'%20and%20priceType%20eq%20'Consumption'", "supported_endpoints": [ "/v1/chat/completions", "/v1/batch", @@ -5243,12 +5306,15 @@ "azure/gpt-4o-2024-05-13": { "deprecation_date": "2026-10-01", "input_cost_per_token": 5e-06, + "input_cost_per_token_batches": 2.5e-06, "litellm_provider": "azure", "max_input_tokens": 128000, "max_output_tokens": 4096, "max_tokens": 4096, "mode": "chat", "output_cost_per_token": 1.5e-05, + "output_cost_per_token_batches": 7.5e-06, + "source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus2'%20and%20priceType%20eq%20'Consumption'", "supports_function_calling": true, "supports_parallel_function_calling": true, "supports_prompt_caching": true, @@ -5259,12 +5325,15 @@ "deprecation_date": "2027-04-14", "cache_read_input_token_cost": 1.25e-06, "input_cost_per_token": 2.5e-06, + "input_cost_per_token_batches": 1.25e-06, "litellm_provider": "azure", "max_input_tokens": 128000, "max_output_tokens": 16384, "max_tokens": 16384, "mode": "chat", "output_cost_per_token": 1e-05, + "output_cost_per_token_batches": 5e-06, + "source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus2'%20and%20priceType%20eq%20'Consumption'", "supports_function_calling": true, "supports_parallel_function_calling": true, "supports_prompt_caching": true, @@ -5275,13 +5344,16 @@ "azure/gpt-4o-2024-11-20": { "deprecation_date": "2027-04-14", "cache_read_input_token_cost": 1.25e-06, - "input_cost_per_token": 2.75e-06, + "input_cost_per_token": 2.5e-06, + "input_cost_per_token_batches": 1.25e-06, "litellm_provider": "azure", "max_input_tokens": 128000, "max_output_tokens": 16384, "max_tokens": 16384, "mode": "chat", - "output_cost_per_token": 1.1e-05, + "output_cost_per_token": 1e-05, + "output_cost_per_token_batches": 5e-06, + "source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus2'%20and%20priceType%20eq%20'Consumption'", "supports_function_calling": true, "supports_parallel_function_calling": true, "supports_prompt_caching": true, @@ -5468,13 +5540,16 @@ "azure/gpt-4o-mini-2024-07-18": { "cache_read_input_token_cost": 7.5e-08, "deprecation_date": "2027-04-14", - "input_cost_per_token": 1.65e-07, + "input_cost_per_token": 1.5e-07, + "input_cost_per_token_batches": 7.5e-08, "litellm_provider": "azure", "max_input_tokens": 128000, "max_output_tokens": 16384, "max_tokens": 16384, "mode": "chat", - "output_cost_per_token": 6.6e-07, + "output_cost_per_token": 6e-07, + "output_cost_per_token_batches": 3e-07, + "source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus2'%20and%20priceType%20eq%20'Consumption'", "supports_function_calling": true, "supports_parallel_function_calling": true, "supports_prompt_caching": true, @@ -5925,6 +6000,9 @@ "supports_vision": true, "supports_none_reasoning_effort": true, "default_reasoning_effort": "none", + "input_cost_per_token_batches": 6.25e-07, + "output_cost_per_token_batches": 5e-06, + "source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus2'%20and%20priceType%20eq%20'Consumption'", "supports_minimal_reasoning_effort": true }, "azure/gpt-5.1-chat-2025-11-13": { @@ -5963,7 +6041,8 @@ "supports_tool_choice": false, "supports_vision": true, "supports_none_reasoning_effort": true, - "default_reasoning_effort": "none" + "default_reasoning_effort": "none", + "source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus2'%20and%20priceType%20eq%20'Consumption'" }, "azure/gpt-5.1-codex-2025-11-13": { "cache_read_input_token_cost": 1.25e-07, @@ -5978,6 +6057,7 @@ "mode": "responses", "output_cost_per_token": 1e-05, "output_cost_per_token_priority": 2e-05, + "source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus2'%20and%20priceType%20eq%20'Consumption'", "supported_endpoints": [ "/v1/responses" ], @@ -6012,6 +6092,7 @@ "mode": "responses", "output_cost_per_token": 2e-06, "output_cost_per_token_priority": 3.6e-06, + "source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus2'%20and%20priceType%20eq%20'Consumption'", "supported_endpoints": [ "/v1/responses" ], @@ -6036,13 +6117,19 @@ "azure/gpt-5": { "deprecation_date": "2027-02-09", "cache_read_input_token_cost": 1.25e-07, + "cache_read_input_token_cost_priority": 2.5e-07, "input_cost_per_token": 1.25e-06, + "input_cost_per_token_batches": 6.25e-07, + "input_cost_per_token_priority": 2.5e-06, "litellm_provider": "azure", "max_input_tokens": 272000, "max_output_tokens": 128000, "max_tokens": 128000, "mode": "chat", "output_cost_per_token": 1e-05, + "output_cost_per_token_batches": 5e-06, + "output_cost_per_token_priority": 2e-05, + "source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus2'%20and%20priceType%20eq%20'Consumption'", "supported_endpoints": [ "/v1/chat/completions", "/v1/batch", @@ -6068,14 +6155,20 @@ }, "azure/gpt-5-2025-08-07": { "cache_read_input_token_cost": 1.25e-07, + "cache_read_input_token_cost_priority": 2.5e-07, "deprecation_date": "2027-02-09", "input_cost_per_token": 1.25e-06, + "input_cost_per_token_batches": 6.25e-07, + "input_cost_per_token_priority": 2.5e-06, "litellm_provider": "azure", "max_input_tokens": 272000, "max_output_tokens": 128000, "max_tokens": 128000, "mode": "chat", "output_cost_per_token": 1e-05, + "output_cost_per_token_batches": 5e-06, + "output_cost_per_token_priority": 2e-05, + "source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus2'%20and%20priceType%20eq%20'Consumption'", "supported_endpoints": [ "/v1/chat/completions", "/v1/batch", @@ -6109,7 +6202,7 @@ "max_tokens": 16384, "mode": "chat", "output_cost_per_token": 1e-05, - "source": "https://azure.microsoft.com/en-us/blog/gpt-5-in-azure-ai-foundry-the-future-of-ai-apps-and-agents-starts-here/", + "source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus2'%20and%20priceType%20eq%20'Consumption'", "supported_endpoints": [ "/v1/chat/completions", "/v1/batch", @@ -6176,6 +6269,7 @@ "max_tokens": 128000, "mode": "responses", "output_cost_per_token": 1e-05, + "source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus2'%20and%20priceType%20eq%20'Consumption'", "supported_endpoints": [ "/v1/responses" ], @@ -6200,13 +6294,19 @@ "azure/gpt-5-mini": { "deprecation_date": "2027-02-09", "cache_read_input_token_cost": 2.5e-08, + "cache_read_input_token_cost_priority": 4.5e-08, "input_cost_per_token": 2.5e-07, + "input_cost_per_token_batches": 1.25e-07, + "input_cost_per_token_priority": 4.5e-07, "litellm_provider": "azure", "max_input_tokens": 272000, "max_output_tokens": 128000, "max_tokens": 128000, "mode": "chat", "output_cost_per_token": 2e-06, + "output_cost_per_token_batches": 1e-06, + "output_cost_per_token_priority": 3.6e-06, + "source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus2'%20and%20priceType%20eq%20'Consumption'", "supported_endpoints": [ "/v1/chat/completions", "/v1/batch", @@ -6232,14 +6332,20 @@ }, "azure/gpt-5-mini-2025-08-07": { "cache_read_input_token_cost": 2.5e-08, + "cache_read_input_token_cost_priority": 4.5e-08, "deprecation_date": "2027-02-09", "input_cost_per_token": 2.5e-07, + "input_cost_per_token_batches": 1.25e-07, + "input_cost_per_token_priority": 4.5e-07, "litellm_provider": "azure", "max_input_tokens": 272000, "max_output_tokens": 128000, "max_tokens": 128000, "mode": "chat", "output_cost_per_token": 2e-06, + "output_cost_per_token_batches": 1e-06, + "output_cost_per_token_priority": 3.6e-06, + "source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus2'%20and%20priceType%20eq%20'Consumption'", "supported_endpoints": [ "/v1/chat/completions", "/v1/batch", @@ -6267,12 +6373,15 @@ "deprecation_date": "2027-02-09", "cache_read_input_token_cost": 5e-09, "input_cost_per_token": 5e-08, + "input_cost_per_token_batches": 2.5e-08, "litellm_provider": "azure", "max_input_tokens": 272000, "max_output_tokens": 128000, "max_tokens": 128000, "mode": "chat", "output_cost_per_token": 4e-07, + "output_cost_per_token_batches": 2e-07, + "source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus2'%20and%20priceType%20eq%20'Consumption'", "supported_endpoints": [ "/v1/chat/completions", "/v1/batch", @@ -6300,12 +6409,15 @@ "cache_read_input_token_cost": 5e-09, "deprecation_date": "2027-02-09", "input_cost_per_token": 5e-08, + "input_cost_per_token_batches": 2.5e-08, "litellm_provider": "azure", "max_input_tokens": 272000, "max_output_tokens": 128000, "max_tokens": 128000, "mode": "chat", "output_cost_per_token": 4e-07, + "output_cost_per_token_batches": 2e-07, + "source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus2'%20and%20priceType%20eq%20'Consumption'", "supported_endpoints": [ "/v1/chat/completions", "/v1/batch", @@ -6332,13 +6444,15 @@ "azure/gpt-5-pro": { "deprecation_date": "2027-04-07", "input_cost_per_token": 1.5e-05, + "input_cost_per_token_batches": 7.5e-06, "litellm_provider": "azure", "max_input_tokens": 272000, "max_output_tokens": 128000, "max_tokens": 128000, "mode": "responses", "output_cost_per_token": 0.00012, - "source": "https://learn.microsoft.com/en-us/azure/ai-foundry/foundry-models/concepts/models-sold-directly-by-azure?pivots=azure-openai&tabs=global-standard-aoai%2Cstandard-chat-completions%2Cglobal-standard#gpt-5", + "output_cost_per_token_batches": 6e-05, + "source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus2'%20and%20priceType%20eq%20'Consumption'", "supported_endpoints": [ "/v1/responses" ], @@ -6362,6 +6476,7 @@ "azure/gpt-5.1": { "deprecation_date": "2027-05-15", "cache_read_input_token_cost": 1.25e-07, + "cache_read_input_token_cost_priority": 2.5e-07, "input_cost_per_token": 1.25e-06, "litellm_provider": "azure", "max_input_tokens": 272000, @@ -6393,7 +6508,12 @@ "supports_tool_choice": true, "supports_vision": true, "supports_none_reasoning_effort": true, - "default_reasoning_effort": "none" + "default_reasoning_effort": "none", + "input_cost_per_token_batches": 6.25e-07, + "input_cost_per_token_priority": 2.5e-06, + "output_cost_per_token_batches": 5e-06, + "output_cost_per_token_priority": 2e-05, + "source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus2'%20and%20priceType%20eq%20'Consumption'" }, "azure/gpt-5.1-chat": { "cache_read_input_token_cost": 1.25e-07, @@ -6429,7 +6549,8 @@ "supports_tool_choice": true, "supports_vision": true, "supports_none_reasoning_effort": true, - "default_reasoning_effort": "none" + "default_reasoning_effort": "none", + "source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus2'%20and%20priceType%20eq%20'Consumption'" }, "azure/gpt-5.1-codex": { "deprecation_date": "2027-05-15", @@ -6441,6 +6562,7 @@ "max_tokens": 128000, "mode": "responses", "output_cost_per_token": 1e-05, + "source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus2'%20and%20priceType%20eq%20'Consumption'", "supported_endpoints": [ "/v1/responses" ], @@ -6472,6 +6594,7 @@ "max_tokens": 128000, "mode": "responses", "output_cost_per_token": 1e-05, + "source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus2'%20and%20priceType%20eq%20'Consumption'", "supported_endpoints": [ "/v1/responses" ], @@ -6503,6 +6626,7 @@ "max_tokens": 128000, "mode": "responses", "output_cost_per_token": 2e-06, + "source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus2'%20and%20priceType%20eq%20'Consumption'", "supported_endpoints": [ "/v1/responses" ], @@ -6527,13 +6651,19 @@ "azure/gpt-5.2": { "deprecation_date": "2027-06-08", "cache_read_input_token_cost": 1.75e-07, + "cache_read_input_token_cost_priority": 3.5e-07, "input_cost_per_token": 1.75e-06, + "input_cost_per_token_batches": 8.75e-07, + "input_cost_per_token_priority": 3.5e-06, "litellm_provider": "azure", "max_input_tokens": 272000, "max_output_tokens": 128000, "max_tokens": 128000, "mode": "chat", "output_cost_per_token": 1.4e-05, + "output_cost_per_token_batches": 7e-06, + "output_cost_per_token_priority": 2.8e-05, + "source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus2'%20and%20priceType%20eq%20'Consumption'", "supported_endpoints": [ "/v1/chat/completions", "/v1/batch", @@ -6563,6 +6693,7 @@ "cache_read_input_token_cost_priority": 3.5e-07, "deprecation_date": "2027-06-08", "input_cost_per_token": 1.75e-06, + "input_cost_per_token_batches": 8.75e-07, "input_cost_per_token_priority": 3.5e-06, "litellm_provider": "azure", "max_input_tokens": 272000, @@ -6570,7 +6701,9 @@ "max_tokens": 128000, "mode": "chat", "output_cost_per_token": 1.4e-05, + "output_cost_per_token_batches": 7e-06, "output_cost_per_token_priority": 2.8e-05, + "source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus2'%20and%20priceType%20eq%20'Consumption'", "supported_endpoints": [ "/v1/chat/completions", "/v1/batch", @@ -6608,6 +6741,7 @@ "mode": "chat", "output_cost_per_token": 1.4e-05, "output_cost_per_token_priority": 2.8e-05, + "source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus2'%20and%20priceType%20eq%20'Consumption'", "supported_endpoints": [ "/v1/chat/completions", "/v1/responses" @@ -6643,6 +6777,7 @@ "mode": "chat", "output_cost_per_token": 1.4e-05, "output_cost_per_token_priority": 2.8e-05, + "source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus2'%20and%20priceType%20eq%20'Consumption'", "supported_endpoints": [ "/v1/chat/completions", "/v1/responses" @@ -6675,6 +6810,7 @@ "max_tokens": 128000, "mode": "responses", "output_cost_per_token": 1.4e-05, + "source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus2'%20and%20priceType%20eq%20'Consumption'", "supported_endpoints": [ "/v1/responses" ], @@ -6709,6 +6845,7 @@ "mode": "chat", "output_cost_per_token": 1.4e-05, "output_cost_per_token_priority": 2.8e-05, + "source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus2'%20and%20priceType%20eq%20'Consumption'", "supported_endpoints": [ "/v1/chat/completions", "/v1/responses" @@ -6733,14 +6870,18 @@ }, "azure/gpt-5.3-codex": { "cache_read_input_token_cost": 1.75e-07, + "cache_read_input_token_cost_priority": 3.5e-07, "deprecation_date": "2027-08-24", "input_cost_per_token": 1.75e-06, + "input_cost_per_token_priority": 3.5e-06, "litellm_provider": "azure", "max_input_tokens": 272000, "max_output_tokens": 128000, "max_tokens": 128000, "mode": "responses", "output_cost_per_token": 1.4e-05, + "output_cost_per_token_priority": 2.8e-05, + "source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus2'%20and%20priceType%20eq%20'Consumption'", "supported_endpoints": [ "/v1/responses" ], @@ -6764,17 +6905,20 @@ }, "azure/gpt-5.2-pro": { "input_cost_per_token": 2.1e-05, + "input_cost_per_token_batches": 1.05e-05, "litellm_provider": "azure", "max_input_tokens": 272000, "max_output_tokens": 128000, "max_tokens": 128000, "mode": "responses", "output_cost_per_token": 0.000168, + "output_cost_per_token_batches": 8.4e-05, "search_context_cost_per_query": { "search_context_size_high": 0.01, "search_context_size_low": 0.01, "search_context_size_medium": 0.01 }, + "source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus2'%20and%20priceType%20eq%20'Consumption'", "supported_endpoints": [ "/v1/batch", "/v1/responses" @@ -6800,17 +6944,20 @@ }, "azure/gpt-5.2-pro-2025-12-11": { "input_cost_per_token": 2.1e-05, + "input_cost_per_token_batches": 1.05e-05, "litellm_provider": "azure", "max_input_tokens": 272000, "max_output_tokens": 128000, "max_tokens": 128000, "mode": "responses", "output_cost_per_token": 0.000168, + "output_cost_per_token_batches": 8.4e-05, "search_context_cost_per_query": { "search_context_size_high": 0.01, "search_context_size_low": 0.01, "search_context_size_medium": 0.01 }, + "source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus2'%20and%20priceType%20eq%20'Consumption'", "supported_endpoints": [ "/v1/batch", "/v1/responses" @@ -6838,6 +6985,7 @@ "deprecation_date": "2027-09-02", "cache_read_input_token_cost": 2.5e-07, "cache_read_input_token_cost_above_272k_tokens": 5e-07, + "cache_read_input_token_cost_above_272k_tokens_flex": 2.5e-07, "cache_read_input_token_cost_priority": 5e-07, "cache_read_input_token_cost_above_272k_tokens_priority": 1e-06, "input_cost_per_token": 2.5e-06, @@ -6877,12 +7025,20 @@ "supports_vision": true, "supports_none_reasoning_effort": true, "default_reasoning_effort": "none", + "input_cost_per_token_above_272k_tokens_flex": 2.5e-06, + "input_cost_per_token_batches": 1.25e-06, + "input_cost_per_token_flex": 1.25e-06, + "output_cost_per_token_above_272k_tokens_flex": 1.125e-05, + "output_cost_per_token_batches": 7.5e-06, + "output_cost_per_token_flex": 7.5e-06, + "source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus2'%20and%20priceType%20eq%20'Consumption'", "supports_xhigh_reasoning_effort": true, "supports_minimal_reasoning_effort": true }, "azure/us/gpt-5.4": { "deprecation_date": "2027-09-02", - "cache_read_input_token_cost": 2.8e-07, + "cache_read_input_token_cost": 2.75e-07, + "cache_read_input_token_cost_above_272k_tokens": 5.5e-07, "cache_read_input_token_cost_priority": 5.5e-07, "input_cost_per_token": 2.75e-06, "input_cost_per_token_priority": 5.5e-06, @@ -6917,12 +7073,18 @@ "supports_vision": true, "supports_none_reasoning_effort": true, "default_reasoning_effort": "none", + "input_cost_per_token_above_272k_tokens": 5.5e-06, + "input_cost_per_token_batches": 1.375e-06, + "output_cost_per_token_above_272k_tokens": 2.475e-05, + "output_cost_per_token_batches": 8.25e-06, + "source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus2'%20and%20priceType%20eq%20'Consumption'", "supports_xhigh_reasoning_effort": true, "supports_minimal_reasoning_effort": true }, "azure/eu/gpt-5.4": { "deprecation_date": "2027-09-02", - "cache_read_input_token_cost": 2.8e-07, + "cache_read_input_token_cost": 2.75e-07, + "cache_read_input_token_cost_above_272k_tokens": 5.5e-07, "cache_read_input_token_cost_priority": 5.5e-07, "input_cost_per_token": 2.75e-06, "input_cost_per_token_priority": 5.5e-06, @@ -6957,12 +7119,18 @@ "supports_vision": true, "supports_none_reasoning_effort": true, "default_reasoning_effort": "none", + "input_cost_per_token_above_272k_tokens": 5.5e-06, + "input_cost_per_token_batches": 1.375e-06, + "output_cost_per_token_above_272k_tokens": 2.475e-05, + "output_cost_per_token_batches": 8.25e-06, + "source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus2'%20and%20priceType%20eq%20'Consumption'", "supports_xhigh_reasoning_effort": true, "supports_minimal_reasoning_effort": true }, "azure/gpt-5.4-2026-03-05": { "cache_read_input_token_cost": 2.5e-07, "cache_read_input_token_cost_above_272k_tokens": 5e-07, + "cache_read_input_token_cost_above_272k_tokens_flex": 2.5e-07, "cache_read_input_token_cost_priority": 5e-07, "cache_read_input_token_cost_above_272k_tokens_priority": 1e-06, "deprecation_date": "2027-09-02", @@ -7003,11 +7171,19 @@ "supports_vision": true, "supports_none_reasoning_effort": true, "default_reasoning_effort": "none", + "input_cost_per_token_above_272k_tokens_flex": 2.5e-06, + "input_cost_per_token_batches": 1.25e-06, + "input_cost_per_token_flex": 1.25e-06, + "output_cost_per_token_above_272k_tokens_flex": 1.125e-05, + "output_cost_per_token_batches": 7.5e-06, + "output_cost_per_token_flex": 7.5e-06, + "source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus2'%20and%20priceType%20eq%20'Consumption'", "supports_xhigh_reasoning_effort": true, "supports_minimal_reasoning_effort": true }, "azure/us/gpt-5.4-2026-03-05": { - "cache_read_input_token_cost": 2.8e-07, + "cache_read_input_token_cost": 2.75e-07, + "cache_read_input_token_cost_above_272k_tokens": 5.5e-07, "cache_read_input_token_cost_priority": 5.5e-07, "deprecation_date": "2027-09-02", "input_cost_per_token": 2.75e-06, @@ -7043,11 +7219,17 @@ "supports_vision": true, "supports_none_reasoning_effort": true, "default_reasoning_effort": "none", + "input_cost_per_token_above_272k_tokens": 5.5e-06, + "input_cost_per_token_batches": 1.375e-06, + "output_cost_per_token_above_272k_tokens": 2.475e-05, + "output_cost_per_token_batches": 8.25e-06, + "source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus2'%20and%20priceType%20eq%20'Consumption'", "supports_xhigh_reasoning_effort": true, "supports_minimal_reasoning_effort": true }, "azure/eu/gpt-5.4-2026-03-05": { - "cache_read_input_token_cost": 2.8e-07, + "cache_read_input_token_cost": 2.75e-07, + "cache_read_input_token_cost_above_272k_tokens": 5.5e-07, "cache_read_input_token_cost_priority": 5.5e-07, "deprecation_date": "2027-09-02", "input_cost_per_token": 2.75e-06, @@ -7083,6 +7265,11 @@ "supports_vision": true, "supports_none_reasoning_effort": true, "default_reasoning_effort": "none", + "input_cost_per_token_above_272k_tokens": 5.5e-06, + "input_cost_per_token_batches": 1.375e-06, + "output_cost_per_token_above_272k_tokens": 2.475e-05, + "output_cost_per_token_batches": 8.25e-06, + "source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus2'%20and%20priceType%20eq%20'Consumption'", "supports_xhigh_reasoning_effort": true, "supports_minimal_reasoning_effort": true }, @@ -7092,6 +7279,9 @@ "cache_read_input_token_cost_above_272k_tokens": 6e-06, "input_cost_per_token": 3e-05, "input_cost_per_token_above_272k_tokens": 6e-05, + "input_cost_per_token_above_272k_tokens_flex": 3e-05, + "input_cost_per_token_batches": 1.5e-05, + "input_cost_per_token_flex": 1.5e-05, "litellm_provider": "azure", "max_input_tokens": 1050000, "max_output_tokens": 128000, @@ -7099,11 +7289,15 @@ "mode": "responses", "output_cost_per_token": 0.00018, "output_cost_per_token_above_272k_tokens": 0.00027, + "output_cost_per_token_above_272k_tokens_flex": 0.000135, + "output_cost_per_token_batches": 9e-05, + "output_cost_per_token_flex": 9e-05, "search_context_cost_per_query": { "search_context_size_high": 0.01, "search_context_size_low": 0.01, "search_context_size_medium": 0.01 }, + "source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus2'%20and%20priceType%20eq%20'Consumption'", "supported_endpoints": [ "/v1/batch", "/v1/responses" @@ -7133,6 +7327,9 @@ "deprecation_date": "2027-09-07", "input_cost_per_token": 3e-05, "input_cost_per_token_above_272k_tokens": 6e-05, + "input_cost_per_token_above_272k_tokens_flex": 3e-05, + "input_cost_per_token_batches": 1.5e-05, + "input_cost_per_token_flex": 1.5e-05, "litellm_provider": "azure", "max_input_tokens": 1050000, "max_output_tokens": 128000, @@ -7140,11 +7337,15 @@ "mode": "responses", "output_cost_per_token": 0.00018, "output_cost_per_token_above_272k_tokens": 0.00027, + "output_cost_per_token_above_272k_tokens_flex": 0.000135, + "output_cost_per_token_batches": 9e-05, + "output_cost_per_token_flex": 9e-05, "search_context_cost_per_query": { "search_context_size_high": 0.01, "search_context_size_low": 0.01, "search_context_size_medium": 0.01 }, + "source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus2'%20and%20priceType%20eq%20'Consumption'", "supported_endpoints": [ "/v1/batch", "/v1/responses" @@ -7223,33 +7424,42 @@ "supports_minimal_reasoning_effort": false }, "azure/gpt-5.6-sol": { - "cache_creation_input_token_cost": 6.25e-06, - "cache_creation_input_token_cost_above_272k_tokens": 1.25e-05, - "cache_creation_input_token_cost_priority": 1.25e-05, - "cache_creation_input_token_cost_above_272k_tokens_priority": 2.5e-05, - "cache_read_input_token_cost": 5e-07, - "cache_read_input_token_cost_above_272k_tokens": 1e-06, - "cache_read_input_token_cost_priority": 1e-06, - "cache_read_input_token_cost_above_272k_tokens_priority": 2e-06, + "cache_creation_input_token_cost": 5e-06, + "cache_creation_input_token_cost_above_272k_tokens": 1e-05, + "cache_creation_input_token_cost_above_272k_tokens_flex": 5e-06, + "cache_creation_input_token_cost_priority": 1e-05, + "cache_creation_input_token_cost_above_272k_tokens_priority": 2e-05, + "cache_creation_input_token_cost_flex": 2.5e-06, + "cache_read_input_token_cost": 4e-07, + "cache_read_input_token_cost_above_272k_tokens": 8e-07, + "cache_read_input_token_cost_above_272k_tokens_flex": 4e-07, + "cache_read_input_token_cost_priority": 8e-07, + "cache_read_input_token_cost_above_272k_tokens_priority": 1.6e-06, + "cache_read_input_token_cost_flex": 2e-07, "deprecation_date": "2028-01-11", - "input_cost_per_token": 5e-06, - "input_cost_per_token_above_272k_tokens": 1e-05, - "input_cost_per_token_priority": 1e-05, - "input_cost_per_token_above_272k_tokens_priority": 2e-05, + "input_cost_per_token": 4e-06, + "input_cost_per_token_above_272k_tokens": 8e-06, + "input_cost_per_token_above_272k_tokens_flex": 4e-06, + "input_cost_per_token_priority": 8e-06, + "input_cost_per_token_above_272k_tokens_priority": 1.6e-05, + "input_cost_per_token_flex": 2e-06, "litellm_provider": "azure", "max_input_tokens": 922000, "max_output_tokens": 128000, "max_tokens": 128000, "mode": "chat", - "output_cost_per_token": 3e-05, - "output_cost_per_token_above_272k_tokens": 4.5e-05, - "output_cost_per_token_priority": 6e-05, - "output_cost_per_token_above_272k_tokens_priority": 9e-05, + "output_cost_per_token": 2e-05, + "output_cost_per_token_above_272k_tokens": 3e-05, + "output_cost_per_token_above_272k_tokens_flex": 1.5e-05, + "output_cost_per_token_priority": 4e-05, + "output_cost_per_token_above_272k_tokens_priority": 6e-05, + "output_cost_per_token_flex": 1e-05, "search_context_cost_per_query": { "search_context_size_high": 0.01, "search_context_size_low": 0.01, "search_context_size_medium": 0.01 }, + "source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus2'%20and%20priceType%20eq%20'Consumption'", "supported_endpoints": [ "/v1/chat/completions", "/v1/batch", @@ -7280,17 +7490,23 @@ "azure/gpt-5.6-terra": { "cache_creation_input_token_cost": 2.5e-06, "cache_creation_input_token_cost_above_272k_tokens": 5e-06, + "cache_creation_input_token_cost_above_272k_tokens_flex": 2.5e-06, "cache_creation_input_token_cost_priority": 5e-06, "cache_creation_input_token_cost_above_272k_tokens_priority": 1e-05, + "cache_creation_input_token_cost_flex": 1.25e-06, "cache_read_input_token_cost": 2e-07, "cache_read_input_token_cost_above_272k_tokens": 4e-07, + "cache_read_input_token_cost_above_272k_tokens_flex": 2e-07, "cache_read_input_token_cost_priority": 4e-07, "cache_read_input_token_cost_above_272k_tokens_priority": 8e-07, + "cache_read_input_token_cost_flex": 1e-07, "deprecation_date": "2028-01-11", "input_cost_per_token": 2e-06, "input_cost_per_token_above_272k_tokens": 4e-06, + "input_cost_per_token_above_272k_tokens_flex": 2e-06, "input_cost_per_token_priority": 4e-06, "input_cost_per_token_above_272k_tokens_priority": 8e-06, + "input_cost_per_token_flex": 1e-06, "litellm_provider": "azure", "max_input_tokens": 922000, "max_output_tokens": 128000, @@ -7298,13 +7514,16 @@ "mode": "chat", "output_cost_per_token": 1.2e-05, "output_cost_per_token_above_272k_tokens": 1.8e-05, + "output_cost_per_token_above_272k_tokens_flex": 9e-06, "output_cost_per_token_priority": 2.4e-05, "output_cost_per_token_above_272k_tokens_priority": 3.6e-05, + "output_cost_per_token_flex": 6e-06, "search_context_cost_per_query": { "search_context_size_high": 0.01, "search_context_size_low": 0.01, "search_context_size_medium": 0.01 }, + "source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus2'%20and%20priceType%20eq%20'Consumption'", "supported_endpoints": [ "/v1/chat/completions", "/v1/batch", @@ -7335,17 +7554,23 @@ "azure/gpt-5.6-luna": { "cache_creation_input_token_cost": 2.5e-07, "cache_creation_input_token_cost_above_272k_tokens": 5e-07, + "cache_creation_input_token_cost_above_272k_tokens_flex": 2.5e-07, "cache_creation_input_token_cost_priority": 5e-07, "cache_creation_input_token_cost_above_272k_tokens_priority": 1e-06, + "cache_creation_input_token_cost_flex": 1.25e-07, "cache_read_input_token_cost": 2e-08, "cache_read_input_token_cost_above_272k_tokens": 4e-08, + "cache_read_input_token_cost_above_272k_tokens_flex": 2e-08, "cache_read_input_token_cost_priority": 4e-08, "cache_read_input_token_cost_above_272k_tokens_priority": 8e-08, + "cache_read_input_token_cost_flex": 1e-08, "deprecation_date": "2028-01-11", "input_cost_per_token": 2e-07, "input_cost_per_token_above_272k_tokens": 4e-07, + "input_cost_per_token_above_272k_tokens_flex": 2e-07, "input_cost_per_token_priority": 4e-07, "input_cost_per_token_above_272k_tokens_priority": 8e-07, + "input_cost_per_token_flex": 1e-07, "litellm_provider": "azure", "max_input_tokens": 922000, "max_output_tokens": 128000, @@ -7353,13 +7578,16 @@ "mode": "chat", "output_cost_per_token": 1.2e-06, "output_cost_per_token_above_272k_tokens": 1.8e-06, + "output_cost_per_token_above_272k_tokens_flex": 9e-07, "output_cost_per_token_priority": 2.4e-06, "output_cost_per_token_above_272k_tokens_priority": 3.6e-06, + "output_cost_per_token_flex": 6e-07, "search_context_cost_per_query": { "search_context_size_high": 0.01, "search_context_size_low": 0.01, "search_context_size_medium": 0.01 }, + "source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus2'%20and%20priceType%20eq%20'Consumption'", "supported_endpoints": [ "/v1/chat/completions", "/v1/batch", @@ -7406,6 +7634,7 @@ "search_context_size_low": 0.01, "search_context_size_medium": 0.01 }, + "source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus2'%20and%20priceType%20eq%20'Consumption'", "supported_endpoints": [ "/v1/chat/completions", "/v1/responses" @@ -7563,33 +7792,34 @@ "supports_minimal_reasoning_effort": false }, "azure/us/gpt-5.6-sol": { - "cache_creation_input_token_cost": 6.875e-06, - "cache_creation_input_token_cost_above_272k_tokens": 1.375e-05, - "cache_creation_input_token_cost_above_272k_tokens_priority": 2.75e-05, - "cache_creation_input_token_cost_priority": 1.375e-05, - "cache_read_input_token_cost": 5.5e-07, - "cache_read_input_token_cost_above_272k_tokens": 1.1e-06, - "cache_read_input_token_cost_above_272k_tokens_priority": 2.2e-06, - "cache_read_input_token_cost_priority": 1.1e-06, + "cache_creation_input_token_cost": 5.5e-06, + "cache_creation_input_token_cost_above_272k_tokens": 1.1e-05, + "cache_creation_input_token_cost_above_272k_tokens_priority": 2.2e-05, + "cache_creation_input_token_cost_priority": 1.1e-05, + "cache_read_input_token_cost": 4.4e-07, + "cache_read_input_token_cost_above_272k_tokens": 8.8e-07, + "cache_read_input_token_cost_above_272k_tokens_priority": 1.76e-06, + "cache_read_input_token_cost_priority": 8.8e-07, "deprecation_date": "2028-01-11", - "input_cost_per_token": 5.5e-06, - "input_cost_per_token_above_272k_tokens": 1.1e-05, - "input_cost_per_token_above_272k_tokens_priority": 2.2e-05, - "input_cost_per_token_priority": 1.1e-05, + "input_cost_per_token": 4.4e-06, + "input_cost_per_token_above_272k_tokens": 8.8e-06, + "input_cost_per_token_above_272k_tokens_priority": 1.76e-05, + "input_cost_per_token_priority": 8.8e-06, "litellm_provider": "azure", "max_input_tokens": 922000, "max_output_tokens": 128000, "max_tokens": 128000, "mode": "chat", - "output_cost_per_token": 3.3e-05, - "output_cost_per_token_above_272k_tokens": 4.95e-05, - "output_cost_per_token_above_272k_tokens_priority": 9.9e-05, - "output_cost_per_token_priority": 6.6e-05, + "output_cost_per_token": 2.2e-05, + "output_cost_per_token_above_272k_tokens": 3.3e-05, + "output_cost_per_token_above_272k_tokens_priority": 6.6e-05, + "output_cost_per_token_priority": 4.4e-05, "search_context_cost_per_query": { "search_context_size_high": 0.01, "search_context_size_low": 0.01, "search_context_size_medium": 0.01 }, + "source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus2'%20and%20priceType%20eq%20'Consumption'", "supported_endpoints": [ "/v1/chat/completions", "/v1/batch", @@ -7645,6 +7875,7 @@ "search_context_size_low": 0.01, "search_context_size_medium": 0.01 }, + "source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus2'%20and%20priceType%20eq%20'Consumption'", "supported_endpoints": [ "/v1/chat/completions", "/v1/batch", @@ -7700,6 +7931,7 @@ "search_context_size_low": 0.01, "search_context_size_medium": 0.01 }, + "source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus2'%20and%20priceType%20eq%20'Consumption'", "supported_endpoints": [ "/v1/chat/completions", "/v1/batch", @@ -7746,6 +7978,7 @@ "search_context_size_low": 0.01, "search_context_size_medium": 0.01 }, + "source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus2'%20and%20priceType%20eq%20'Consumption'", "supported_endpoints": [ "/v1/chat/completions", "/v1/responses" @@ -7866,33 +8099,34 @@ "supports_minimal_reasoning_effort": false }, "azure/eu/gpt-5.6-sol": { - "cache_creation_input_token_cost": 6.875e-06, - "cache_creation_input_token_cost_above_272k_tokens": 1.375e-05, - "cache_creation_input_token_cost_above_272k_tokens_priority": 2.75e-05, - "cache_creation_input_token_cost_priority": 1.375e-05, - "cache_read_input_token_cost": 5.5e-07, - "cache_read_input_token_cost_above_272k_tokens": 1.1e-06, - "cache_read_input_token_cost_above_272k_tokens_priority": 2.2e-06, - "cache_read_input_token_cost_priority": 1.1e-06, + "cache_creation_input_token_cost": 5.5e-06, + "cache_creation_input_token_cost_above_272k_tokens": 1.1e-05, + "cache_creation_input_token_cost_above_272k_tokens_priority": 2.2e-05, + "cache_creation_input_token_cost_priority": 1.1e-05, + "cache_read_input_token_cost": 4.4e-07, + "cache_read_input_token_cost_above_272k_tokens": 8.8e-07, + "cache_read_input_token_cost_above_272k_tokens_priority": 1.76e-06, + "cache_read_input_token_cost_priority": 8.8e-07, "deprecation_date": "2028-01-11", - "input_cost_per_token": 5.5e-06, - "input_cost_per_token_above_272k_tokens": 1.1e-05, - "input_cost_per_token_above_272k_tokens_priority": 2.2e-05, - "input_cost_per_token_priority": 1.1e-05, + "input_cost_per_token": 4.4e-06, + "input_cost_per_token_above_272k_tokens": 8.8e-06, + "input_cost_per_token_above_272k_tokens_priority": 1.76e-05, + "input_cost_per_token_priority": 8.8e-06, "litellm_provider": "azure", "max_input_tokens": 922000, "max_output_tokens": 128000, "max_tokens": 128000, "mode": "chat", - "output_cost_per_token": 3.3e-05, - "output_cost_per_token_above_272k_tokens": 4.95e-05, - "output_cost_per_token_above_272k_tokens_priority": 9.9e-05, - "output_cost_per_token_priority": 6.6e-05, + "output_cost_per_token": 2.2e-05, + "output_cost_per_token_above_272k_tokens": 3.3e-05, + "output_cost_per_token_above_272k_tokens_priority": 6.6e-05, + "output_cost_per_token_priority": 4.4e-05, "search_context_cost_per_query": { "search_context_size_high": 0.01, "search_context_size_low": 0.01, "search_context_size_medium": 0.01 }, + "source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus2'%20and%20priceType%20eq%20'Consumption'", "supported_endpoints": [ "/v1/chat/completions", "/v1/batch", @@ -7948,6 +8182,7 @@ "search_context_size_low": 0.01, "search_context_size_medium": 0.01 }, + "source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus2'%20and%20priceType%20eq%20'Consumption'", "supported_endpoints": [ "/v1/chat/completions", "/v1/batch", @@ -8003,6 +8238,7 @@ "search_context_size_low": 0.01, "search_context_size_medium": 0.01 }, + "source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus2'%20and%20priceType%20eq%20'Consumption'", "supported_endpoints": [ "/v1/chat/completions", "/v1/batch", @@ -8034,12 +8270,16 @@ "deprecation_date": "2027-10-26", "cache_read_input_token_cost": 5e-07, "cache_read_input_token_cost_above_272k_tokens": 1e-06, - "cache_read_input_token_cost_priority": 1e-06, + "cache_read_input_token_cost_priority": 1.25e-06, "cache_read_input_token_cost_above_272k_tokens_priority": 2e-06, + "cache_read_input_token_cost_flex": 2.5e-07, "input_cost_per_token": 5e-06, "input_cost_per_token_above_272k_tokens": 1e-05, - "input_cost_per_token_priority": 1e-05, + "input_cost_per_token_above_272k_tokens_flex": 5e-06, + "input_cost_per_token_priority": 1.25e-05, "input_cost_per_token_above_272k_tokens_priority": 2e-05, + "input_cost_per_token_batches": 2.5e-06, + "input_cost_per_token_flex": 2.5e-06, "litellm_provider": "azure", "max_input_tokens": 1050000, "max_output_tokens": 128000, @@ -8047,13 +8287,15 @@ "mode": "chat", "output_cost_per_token": 3e-05, "output_cost_per_token_above_272k_tokens": 4.5e-05, - "output_cost_per_token_priority": 6e-05, + "output_cost_per_token_priority": 7.5e-05, "output_cost_per_token_above_272k_tokens_priority": 9e-05, + "output_cost_per_token_batches": 1.5e-05, "search_context_cost_per_query": { "search_context_size_high": 0.01, "search_context_size_low": 0.01, "search_context_size_medium": 0.01 }, + "source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus2'%20and%20priceType%20eq%20'Consumption'", "supported_endpoints": [ "/v1/chat/completions", "/v1/batch", @@ -8085,9 +8327,10 @@ "deprecation_date": "2027-10-26", "cache_read_input_token_cost": 5.5e-07, "cache_read_input_token_cost_above_272k_tokens": 1.1e-06, - "cache_read_input_token_cost_priority": 1.38e-06, + "cache_read_input_token_cost_priority": 1.375e-06, "input_cost_per_token": 5.5e-06, "input_cost_per_token_above_272k_tokens": 1.1e-05, + "input_cost_per_token_batches": 2.75e-06, "input_cost_per_token_priority": 1.375e-05, "output_cost_per_token": 3.3e-05, "output_cost_per_token_above_272k_tokens": 4.95e-05, @@ -8097,11 +8340,13 @@ "max_output_tokens": 128000, "max_tokens": 128000, "mode": "chat", + "output_cost_per_token_batches": 1.65e-05, "search_context_cost_per_query": { "search_context_size_high": 0.01, "search_context_size_low": 0.01, "search_context_size_medium": 0.01 }, + "source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus2'%20and%20priceType%20eq%20'Consumption'", "supported_endpoints": [ "/v1/chat/completions", "/v1/batch", @@ -8133,9 +8378,10 @@ "deprecation_date": "2027-10-26", "cache_read_input_token_cost": 5.5e-07, "cache_read_input_token_cost_above_272k_tokens": 1.1e-06, - "cache_read_input_token_cost_priority": 1.38e-06, + "cache_read_input_token_cost_priority": 1.375e-06, "input_cost_per_token": 5.5e-06, "input_cost_per_token_above_272k_tokens": 1.1e-05, + "input_cost_per_token_batches": 2.75e-06, "input_cost_per_token_priority": 1.375e-05, "output_cost_per_token": 3.3e-05, "output_cost_per_token_above_272k_tokens": 4.95e-05, @@ -8145,11 +8391,13 @@ "max_output_tokens": 128000, "max_tokens": 128000, "mode": "chat", + "output_cost_per_token_batches": 1.65e-05, "search_context_cost_per_query": { "search_context_size_high": 0.01, "search_context_size_low": 0.01, "search_context_size_medium": 0.01 }, + "source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus2'%20and%20priceType%20eq%20'Consumption'", "supported_endpoints": [ "/v1/chat/completions", "/v1/batch", @@ -8180,11 +8428,12 @@ "azure/gpt-5.5-2026-04-23": { "cache_read_input_token_cost": 5e-07, "cache_read_input_token_cost_above_272k_tokens": 1e-06, - "cache_read_input_token_cost_priority": 1e-06, + "cache_read_input_token_cost_priority": 1.25e-06, "cache_read_input_token_cost_above_272k_tokens_priority": 2e-06, + "cache_read_input_token_cost_flex": 2.5e-07, "input_cost_per_token": 5e-06, "input_cost_per_token_above_272k_tokens": 1e-05, - "input_cost_per_token_priority": 1e-05, + "input_cost_per_token_priority": 1.25e-05, "input_cost_per_token_above_272k_tokens_priority": 2e-05, "litellm_provider": "azure", "max_input_tokens": 1050000, @@ -8193,7 +8442,7 @@ "mode": "chat", "output_cost_per_token": 3e-05, "output_cost_per_token_above_272k_tokens": 4.5e-05, - "output_cost_per_token_priority": 6e-05, + "output_cost_per_token_priority": 7.5e-05, "output_cost_per_token_above_272k_tokens_priority": 9e-05, "search_context_cost_per_query": { "search_context_size_high": 0.01, @@ -8223,12 +8472,17 @@ "supports_tool_choice": true, "supports_vision": true, "supports_web_search": true, - "deprecation_date": "2027-10-26" + "deprecation_date": "2027-10-26", + "input_cost_per_token_above_272k_tokens_flex": 5e-06, + "input_cost_per_token_batches": 2.5e-06, + "input_cost_per_token_flex": 2.5e-06, + "output_cost_per_token_batches": 1.5e-05, + "source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus2'%20and%20priceType%20eq%20'Consumption'" }, "azure/us/gpt-5.5-2026-04-23": { "cache_read_input_token_cost": 5.5e-07, "cache_read_input_token_cost_above_272k_tokens": 1.1e-06, - "cache_read_input_token_cost_priority": 1.38e-06, + "cache_read_input_token_cost_priority": 1.375e-06, "input_cost_per_token": 5.5e-06, "input_cost_per_token_above_272k_tokens": 1.1e-05, "input_cost_per_token_priority": 1.375e-05, @@ -8268,12 +8522,15 @@ "supports_tool_choice": true, "supports_vision": true, "supports_web_search": true, - "deprecation_date": "2027-10-26" + "deprecation_date": "2027-10-26", + "input_cost_per_token_batches": 2.75e-06, + "output_cost_per_token_batches": 1.65e-05, + "source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus2'%20and%20priceType%20eq%20'Consumption'" }, "azure/eu/gpt-5.5-2026-04-23": { "cache_read_input_token_cost": 5.5e-07, "cache_read_input_token_cost_above_272k_tokens": 1.1e-06, - "cache_read_input_token_cost_priority": 1.38e-06, + "cache_read_input_token_cost_priority": 1.375e-06, "input_cost_per_token": 5.5e-06, "input_cost_per_token_above_272k_tokens": 1.1e-05, "input_cost_per_token_priority": 1.375e-05, @@ -8313,7 +8570,10 @@ "supports_tool_choice": true, "supports_vision": true, "supports_web_search": true, - "deprecation_date": "2027-10-26" + "deprecation_date": "2027-10-26", + "input_cost_per_token_batches": 2.75e-06, + "output_cost_per_token_batches": 1.65e-05, + "source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus2'%20and%20priceType%20eq%20'Consumption'" }, "azure/gpt-5.5-pro": { "cache_read_input_token_cost": 3e-06, @@ -8402,6 +8662,8 @@ "azure/gpt-5.4-mini": { "deprecation_date": "2027-09-21", "cache_read_input_token_cost": 7.5e-08, + "cache_read_input_token_cost_flex": 3.75e-08, + "cache_read_input_token_cost_priority": 1.5e-07, "input_cost_per_token": 7.5e-07, "litellm_provider": "azure", "max_input_tokens": 272000, @@ -8439,10 +8701,19 @@ "supports_web_search": true, "supports_none_reasoning_effort": true, "default_reasoning_effort": "none", + "input_cost_per_token_batches": 3.75e-07, + "input_cost_per_token_flex": 3.75e-07, + "input_cost_per_token_priority": 1.5e-06, + "output_cost_per_token_batches": 2.25e-06, + "output_cost_per_token_flex": 2.25e-06, + "output_cost_per_token_priority": 9e-06, + "source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus2'%20and%20priceType%20eq%20'Consumption'", "supports_xhigh_reasoning_effort": true }, "azure/gpt-5.4-mini-2026-03-17": { "cache_read_input_token_cost": 7.5e-08, + "cache_read_input_token_cost_flex": 3.75e-08, + "cache_read_input_token_cost_priority": 1.5e-07, "deprecation_date": "2027-09-21", "input_cost_per_token": 7.5e-07, "litellm_provider": "azure", @@ -8481,11 +8752,19 @@ "supports_web_search": true, "supports_none_reasoning_effort": true, "default_reasoning_effort": "none", + "input_cost_per_token_batches": 3.75e-07, + "input_cost_per_token_flex": 3.75e-07, + "input_cost_per_token_priority": 1.5e-06, + "output_cost_per_token_batches": 2.25e-06, + "output_cost_per_token_flex": 2.25e-06, + "output_cost_per_token_priority": 9e-06, + "source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus2'%20and%20priceType%20eq%20'Consumption'", "supports_xhigh_reasoning_effort": true }, "azure/gpt-5.4-nano": { "deprecation_date": "2027-09-21", "cache_read_input_token_cost": 2e-08, + "cache_read_input_token_cost_flex": 1e-08, "input_cost_per_token": 2e-07, "litellm_provider": "azure", "max_input_tokens": 272000, @@ -8523,10 +8802,16 @@ "supports_web_search": true, "supports_none_reasoning_effort": true, "default_reasoning_effort": "none", + "input_cost_per_token_batches": 1e-07, + "input_cost_per_token_flex": 1e-07, + "output_cost_per_token_batches": 6.25e-07, + "output_cost_per_token_flex": 6.25e-07, + "source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus2'%20and%20priceType%20eq%20'Consumption'", "supports_xhigh_reasoning_effort": true }, "azure/gpt-5.4-nano-2026-03-17": { "cache_read_input_token_cost": 2e-08, + "cache_read_input_token_cost_flex": 1e-08, "deprecation_date": "2027-09-21", "input_cost_per_token": 2e-07, "litellm_provider": "azure", @@ -8565,6 +8850,11 @@ "supports_web_search": true, "supports_none_reasoning_effort": true, "default_reasoning_effort": "none", + "input_cost_per_token_batches": 1e-07, + "input_cost_per_token_flex": 1e-07, + "output_cost_per_token_batches": 6.25e-07, + "output_cost_per_token_flex": 6.25e-07, + "source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus2'%20and%20priceType%20eq%20'Consumption'", "supports_xhigh_reasoning_effort": true }, "azure/gpt-image-1": { @@ -8886,12 +9176,15 @@ "cache_read_input_token_cost": 7.5e-06, "deprecation_date": "2026-11-19", "input_cost_per_token": 1.5e-05, + "input_cost_per_token_batches": 7.5e-06, "litellm_provider": "azure", "max_input_tokens": 200000, "max_output_tokens": 100000, "max_tokens": 100000, "mode": "chat", "output_cost_per_token": 6e-05, + "output_cost_per_token_batches": 3e-05, + "source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus2'%20and%20priceType%20eq%20'Consumption'", "supports_function_calling": true, "supports_parallel_function_calling": true, "supports_prompt_caching": true, @@ -8900,14 +9193,17 @@ "supports_vision": true }, "azure/o1-mini": { - "cache_read_input_token_cost": 6.05e-07, - "input_cost_per_token": 1.21e-06, + "cache_read_input_token_cost": 5.5e-07, + "input_cost_per_token": 1.1e-06, + "input_cost_per_token_batches": 5.5e-07, "litellm_provider": "azure", "max_input_tokens": 128000, "max_output_tokens": 65536, "max_tokens": 65536, "mode": "chat", - "output_cost_per_token": 4.84e-06, + "output_cost_per_token": 4.4e-06, + "output_cost_per_token_batches": 2.2e-06, + "source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus2'%20and%20priceType%20eq%20'Consumption'", "supports_function_calling": true, "supports_parallel_function_calling": true, "supports_prompt_caching": true, @@ -8938,6 +9234,7 @@ "max_tokens": 32768, "mode": "chat", "output_cost_per_token": 6e-05, + "source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus2'%20and%20priceType%20eq%20'Consumption'", "supports_function_calling": true, "supports_parallel_function_calling": true, "supports_prompt_caching": true, @@ -8953,6 +9250,7 @@ "max_tokens": 32768, "mode": "chat", "output_cost_per_token": 6e-05, + "source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus2'%20and%20priceType%20eq%20'Consumption'", "supports_function_calling": true, "supports_parallel_function_calling": true, "supports_pdf_input": true, @@ -8994,12 +9292,15 @@ "deprecation_date": "2026-11-19", "cache_read_input_token_cost": 5e-07, "input_cost_per_token": 2e-06, + "input_cost_per_token_batches": 1e-06, "litellm_provider": "azure", "max_input_tokens": 200000, "max_output_tokens": 100000, "max_tokens": 100000, "mode": "chat", "output_cost_per_token": 8e-06, + "output_cost_per_token_batches": 4e-06, + "source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus2'%20and%20priceType%20eq%20'Consumption'", "supported_endpoints": [ "/v1/chat/completions", "/v1/batch", @@ -9035,6 +9336,7 @@ "search_context_size_low": 0.01, "search_context_size_medium": 0.01 }, + "source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus2'%20and%20priceType%20eq%20'Consumption'", "supported_endpoints": [ "/v1/chat/completions", "/v1/batch", @@ -9078,12 +9380,15 @@ "cache_read_input_token_cost": 5.5e-07, "deprecation_date": "2026-11-19", "input_cost_per_token": 1.1e-06, + "input_cost_per_token_batches": 5.5e-07, "litellm_provider": "azure", "max_input_tokens": 200000, "max_output_tokens": 100000, "max_tokens": 100000, "mode": "chat", "output_cost_per_token": 4.4e-06, + "output_cost_per_token_batches": 2.2e-06, + "source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus2'%20and%20priceType%20eq%20'Consumption'", "supports_prompt_caching": true, "supports_reasoning": true, "supports_tool_choice": true, @@ -9100,6 +9405,7 @@ "mode": "responses", "output_cost_per_token": 8e-05, "output_cost_per_token_batches": 4e-05, + "source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus2'%20and%20priceType%20eq%20'Consumption'", "supported_endpoints": [ "/v1/chat/completions", "/v1/batch", @@ -9131,6 +9437,7 @@ "mode": "responses", "output_cost_per_token": 8e-05, "output_cost_per_token_batches": 4e-05, + "source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus2'%20and%20priceType%20eq%20'Consumption'", "supported_endpoints": [ "/v1/chat/completions", "/v1/batch", @@ -9185,12 +9492,15 @@ "cache_read_input_token_cost": 2.75e-07, "deprecation_date": "2026-11-19", "input_cost_per_token": 1.1e-06, + "input_cost_per_token_batches": 5.5e-07, "litellm_provider": "azure", "max_input_tokens": 200000, "max_output_tokens": 100000, "max_tokens": 100000, "mode": "chat", "output_cost_per_token": 4.4e-06, + "output_cost_per_token_batches": 2.2e-06, + "source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus2'%20and%20priceType%20eq%20'Consumption'", "supports_function_calling": true, "supports_parallel_function_calling": false, "supports_prompt_caching": true, @@ -9230,7 +9540,8 @@ "max_input_tokens": 8191, "max_tokens": 8191, "mode": "embedding", - "output_cost_per_token": 0.0 + "output_cost_per_token": 0.0, + "source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus2'%20and%20priceType%20eq%20'Consumption'" }, "azure/text-embedding-3-small": { "deprecation_date": "2028-02-09", @@ -9239,7 +9550,8 @@ "max_input_tokens": 8191, "max_tokens": 8191, "mode": "embedding", - "output_cost_per_token": 0.0 + "output_cost_per_token": 0.0, + "source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus2'%20and%20priceType%20eq%20'Consumption'" }, "azure/text-embedding-ada-002": { "deprecation_date": "2028-02-09", @@ -9248,7 +9560,8 @@ "max_input_tokens": 8191, "max_tokens": 8191, "mode": "embedding", - "output_cost_per_token": 0.0 + "output_cost_per_token": 0.0, + "source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus2'%20and%20priceType%20eq%20'Consumption'" }, "azure/speech/azure-tts": { "input_cost_per_character": 1.5e-05, @@ -9390,12 +9703,15 @@ "deprecation_date": "2027-04-14", "cache_read_input_token_cost": 1.375e-06, "input_cost_per_token": 2.75e-06, + "input_cost_per_token_batches": 1.375e-06, "litellm_provider": "azure", "max_input_tokens": 128000, "max_output_tokens": 16384, "max_tokens": 16384, "mode": "chat", "output_cost_per_token": 1.1e-05, + "output_cost_per_token_batches": 5.5e-06, + "source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus2'%20and%20priceType%20eq%20'Consumption'", "supports_function_calling": true, "supports_parallel_function_calling": true, "supports_prompt_caching": true, @@ -9406,13 +9722,17 @@ "azure/us/gpt-4o-2024-11-20": { "deprecation_date": "2027-04-14", "cache_creation_input_token_cost": 1.38e-06, + "cache_read_input_token_cost": 1.375e-06, "input_cost_per_token": 2.75e-06, + "input_cost_per_token_batches": 1.375e-06, "litellm_provider": "azure", "max_input_tokens": 128000, "max_output_tokens": 16384, "max_tokens": 16384, "mode": "chat", "output_cost_per_token": 1.1e-05, + "output_cost_per_token_batches": 5.5e-06, + "source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus2'%20and%20priceType%20eq%20'Consumption'", "supports_function_calling": true, "supports_parallel_function_calling": true, "supports_response_schema": true, @@ -9423,12 +9743,14 @@ "cache_read_input_token_cost": 8.3e-08, "deprecation_date": "2027-04-14", "input_cost_per_token": 1.65e-07, + "input_cost_per_token_batches": 8.3e-08, "litellm_provider": "azure", "max_input_tokens": 128000, "max_output_tokens": 16384, "max_tokens": 16384, "mode": "chat", "output_cost_per_token": 6.6e-07, + "source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus2'%20and%20priceType%20eq%20'Consumption'", "supports_function_calling": true, "supports_parallel_function_calling": true, "supports_prompt_caching": true, @@ -9602,8 +9924,9 @@ }, "azure/us/gpt-5.1": { "deprecation_date": "2027-05-15", - "cache_read_input_token_cost": 1.4e-07, - "input_cost_per_token": 1.38e-06, + "cache_read_input_token_cost": 1.375e-07, + "cache_read_input_token_cost_priority": 2.75e-07, + "input_cost_per_token": 1.375e-06, "litellm_provider": "azure", "max_input_tokens": 272000, "max_output_tokens": 128000, @@ -9634,12 +9957,17 @@ "supports_tool_choice": true, "supports_vision": true, "supports_none_reasoning_effort": true, - "default_reasoning_effort": "none" + "default_reasoning_effort": "none", + "input_cost_per_token_batches": 6.875e-07, + "input_cost_per_token_priority": 2.75e-06, + "output_cost_per_token_batches": 5.5e-06, + "output_cost_per_token_priority": 2.2e-05, + "source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus2'%20and%20priceType%20eq%20'Consumption'" }, "azure/us/gpt-5.1-chat": { - "cache_read_input_token_cost": 1.4e-07, + "cache_read_input_token_cost": 1.375e-07, "deprecation_date": "2026-06-29", - "input_cost_per_token": 1.38e-06, + "input_cost_per_token": 1.375e-06, "litellm_provider": "azure", "max_input_tokens": 128000, "max_output_tokens": 128000, @@ -9670,18 +9998,20 @@ "supports_tool_choice": true, "supports_vision": true, "supports_none_reasoning_effort": true, - "default_reasoning_effort": "none" + "default_reasoning_effort": "none", + "source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus2'%20and%20priceType%20eq%20'Consumption'" }, "azure/us/gpt-5.1-codex": { "deprecation_date": "2027-05-15", - "cache_read_input_token_cost": 1.4e-07, - "input_cost_per_token": 1.38e-06, + "cache_read_input_token_cost": 1.375e-07, + "input_cost_per_token": 1.375e-06, "litellm_provider": "azure", "max_input_tokens": 272000, "max_output_tokens": 128000, "max_tokens": 128000, "mode": "responses", "output_cost_per_token": 1.1e-05, + "source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus2'%20and%20priceType%20eq%20'Consumption'", "supported_endpoints": [ "/v1/responses" ], @@ -9705,7 +10035,7 @@ }, "azure/us/gpt-5.1-codex-mini": { "deprecation_date": "2027-05-15", - "cache_read_input_token_cost": 2.8e-08, + "cache_read_input_token_cost": 2.75e-08, "input_cost_per_token": 2.75e-07, "litellm_provider": "azure", "max_input_tokens": 272000, @@ -9713,6 +10043,7 @@ "max_tokens": 128000, "mode": "responses", "output_cost_per_token": 2.2e-06, + "source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus2'%20and%20priceType%20eq%20'Consumption'", "supported_endpoints": [ "/v1/responses" ], @@ -9738,12 +10069,15 @@ "cache_read_input_token_cost": 8.25e-06, "deprecation_date": "2026-11-19", "input_cost_per_token": 1.65e-05, + "input_cost_per_token_batches": 8.25e-06, "litellm_provider": "azure", "max_input_tokens": 200000, "max_output_tokens": 100000, "max_tokens": 100000, "mode": "chat", "output_cost_per_token": 6.6e-05, + "output_cost_per_token_batches": 3.3e-05, + "source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus2'%20and%20priceType%20eq%20'Consumption'", "supports_function_calling": true, "supports_parallel_function_calling": true, "supports_prompt_caching": true, @@ -9784,12 +10118,15 @@ "deprecation_date": "2026-11-19", "cache_read_input_token_cost": 5.5e-07, "input_cost_per_token": 2.2e-06, + "input_cost_per_token_batches": 1.1e-06, "litellm_provider": "azure", "max_input_tokens": 200000, "max_output_tokens": 100000, "max_tokens": 100000, "mode": "chat", "output_cost_per_token": 8.8e-06, + "output_cost_per_token_batches": 4.4e-06, + "source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus2'%20and%20priceType%20eq%20'Consumption'", "supported_endpoints": [ "/v1/chat/completions", "/v1/batch", @@ -9822,21 +10159,25 @@ "mode": "chat", "output_cost_per_token": 4.84e-06, "output_cost_per_token_batches": 2.42e-06, + "source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus2'%20and%20priceType%20eq%20'Consumption'", "supports_prompt_caching": true, "supports_reasoning": true, "supports_tool_choice": true, "supports_vision": false }, "azure/us/o4-mini-2025-04-16": { - "cache_read_input_token_cost": 3.1e-07, + "cache_read_input_token_cost": 3.03e-07, "deprecation_date": "2026-11-19", "input_cost_per_token": 1.21e-06, + "input_cost_per_token_batches": 6.05e-07, "litellm_provider": "azure", "max_input_tokens": 200000, "max_output_tokens": 100000, "max_tokens": 100000, "mode": "chat", "output_cost_per_token": 4.84e-06, + "output_cost_per_token_batches": 2.42e-06, + "source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus2'%20and%20priceType%20eq%20'Consumption'", "supports_function_calling": true, "supports_parallel_function_calling": false, "supports_prompt_caching": true, @@ -9882,7 +10223,7 @@ "max_tokens": 4096, "mode": "chat", "output_cost_per_token": 9e-07, - "source": "https://azure.microsoft.com/en-us/pricing/details/ai-foundry-models/mistral/", + "source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus2'%20and%20priceType%20eq%20'Consumption'", "supported_endpoints": [ "/v1/chat/completions" ], @@ -9931,7 +10272,7 @@ "max_tokens": 163840, "mode": "chat", "output_cost_per_token": 1.85e-06, - "source": "https://azure.microsoft.com/en-us/pricing/details/ai-foundry-models/fireworks/", + "source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus2'%20and%20priceType%20eq%20'Consumption'", "supports_function_calling": true, "supports_prompt_caching": true, "supports_reasoning": true, @@ -9946,7 +10287,7 @@ "max_tokens": 384000, "mode": "chat", "output_cost_per_token": 3.828e-06, - "source": "https://azure.microsoft.com/en-us/pricing/details/ai-foundry-models/fireworks/", + "source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus2'%20and%20priceType%20eq%20'Consumption'", "supports_function_calling": true, "supports_prompt_caching": true, "supports_reasoning": true, @@ -9962,7 +10303,7 @@ "max_tokens": 128000, "mode": "chat", "output_cost_per_token": 3.52e-06, - "source": "https://azure.microsoft.com/en-us/pricing/details/ai-foundry-models/fireworks/", + "source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus2'%20and%20priceType%20eq%20'Consumption'", "supports_function_calling": true, "supports_prompt_caching": true, "supports_reasoning": true, @@ -9978,7 +10319,7 @@ "max_tokens": 131072, "mode": "chat", "output_cost_per_token": 4.84e-06, - "source": "https://azure.microsoft.com/en-us/pricing/details/ai-foundry-models/fireworks/", + "source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus2'%20and%20priceType%20eq%20'Consumption'", "supports_function_calling": true, "supports_prompt_caching": true, "supports_reasoning": true, @@ -9993,37 +10334,37 @@ "max_tokens": 131072, "mode": "chat", "output_cost_per_token": 4.84e-06, - "source": "https://azure.microsoft.com/en-us/pricing/details/ai-foundry-models/fireworks/", + "source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus2'%20and%20priceType%20eq%20'Consumption'", "supports_function_calling": true, "supports_prompt_caching": true, "supports_reasoning": true, "supports_tool_choice": true }, "azure_ai/FW-GLM-5.2-Fast": { - "cache_read_input_token_cost": 2.1e-07, - "input_cost_per_token": 2.1e-06, + "cache_read_input_token_cost": 2.31e-07, + "input_cost_per_token": 2.31e-06, "litellm_provider": "azure_ai", "max_input_tokens": 1048576, "max_output_tokens": 131072, "max_tokens": 131072, "mode": "chat", - "output_cost_per_token": 6.6e-06, - "source": "https://docs.fireworks.ai/serverless/pricing", + "output_cost_per_token": 7.26e-06, + "source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus2'%20and%20priceType%20eq%20'Consumption'", "supports_function_calling": true, "supports_prompt_caching": true, "supports_reasoning": true, "supports_tool_choice": true }, "azure_ai/FW-Inkling": { - "cache_read_input_token_cost": 1.7e-07, - "input_cost_per_token": 1e-06, + "cache_read_input_token_cost": 1.9e-07, + "input_cost_per_token": 1.1e-06, "litellm_provider": "azure_ai", "max_input_tokens": 1048576, "max_output_tokens": 1048576, "max_tokens": 1048576, "mode": "chat", - "output_cost_per_token": 4.05e-06, - "source": "https://fireworks.ai/models/fireworks/inkling", + "output_cost_per_token": 4.46e-06, + "source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus2'%20and%20priceType%20eq%20'Consumption'", "supported_modalities": [ "text" ], @@ -10045,7 +10386,7 @@ "max_tokens": 262144, "mode": "chat", "output_cost_per_token": 3.3e-06, - "source": "https://azure.microsoft.com/en-us/pricing/details/ai-foundry-models/fireworks/", + "source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus2'%20and%20priceType%20eq%20'Consumption'", "supported_modalities": [ "text", "image" @@ -10068,7 +10409,7 @@ "max_tokens": 262144, "mode": "chat", "output_cost_per_token": 4.4e-06, - "source": "https://azure.microsoft.com/en-us/pricing/details/ai-foundry-models/fireworks/", + "source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus2'%20and%20priceType%20eq%20'Consumption'", "supported_modalities": [ "text", "image" @@ -10091,7 +10432,7 @@ "max_tokens": 262144, "mode": "chat", "output_cost_per_token": 4.4e-06, - "source": "https://azure.microsoft.com/en-us/pricing/details/ai-foundry-models/fireworks/", + "source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus2'%20and%20priceType%20eq%20'Consumption'", "supported_modalities": [ "text", "image" @@ -10119,7 +10460,7 @@ "high", "max" ], - "source": "https://techcommunity.microsoft.com/blog/azure-ai-foundry-blog/introducing-kimi-k3-through-fireworks-ai-on-microsoft-foundry/4540187", + "source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus2'%20and%20priceType%20eq%20'Consumption'", "supported_modalities": [ "text", "image" @@ -10143,7 +10484,7 @@ "max_tokens": 1000000, "mode": "chat", "output_cost_per_token": 1.32e-06, - "source": "https://azure.microsoft.com/en-us/pricing/details/ai-foundry-models/fireworks/", + "source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus2'%20and%20priceType%20eq%20'Consumption'", "supports_function_calling": true, "supports_prompt_caching": true, "supports_reasoning": true, @@ -10158,7 +10499,7 @@ "max_tokens": 512000, "mode": "chat", "output_cost_per_token": 1.32e-06, - "source": "https://azure.microsoft.com/en-us/pricing/details/ai-foundry-models/fireworks/", + "source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus2'%20and%20priceType%20eq%20'Consumption'", "supported_modalities": [ "text", "image" @@ -10179,7 +10520,7 @@ "max_input_tokens": 262144, "mode": "chat", "output_cost_per_token": 2.2e-07, - "source": "https://azure.microsoft.com/en-us/pricing/details/ai-foundry-models/fireworks/", + "source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus2'%20and%20priceType%20eq%20'Consumption'", "supported_modalities": [ "text" ], @@ -10193,15 +10534,15 @@ "supports_vision": false }, "azure_ai/FW-Nemotron-3-Ultra-NVFP4": { - "cache_read_input_token_cost": 1.19e-07, - "input_cost_per_token": 6e-07, + "cache_read_input_token_cost": 1.3e-07, + "input_cost_per_token": 6.6e-07, "litellm_provider": "azure_ai", "max_input_tokens": 262144, "max_output_tokens": 262144, "max_tokens": 262144, "mode": "chat", - "output_cost_per_token": 2.4e-06, - "source": "https://fireworks.ai/models/fireworks/nemotron-3-ultra-nvfp4", + "output_cost_per_token": 2.64e-06, + "source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus2'%20and%20priceType%20eq%20'Consumption'", "supported_modalities": [ "text" ], @@ -10220,7 +10561,7 @@ "mode": "image_generation", "output_cost_per_image": 0.05, "output_cost_per_image_token": 4.7e-05, - "source": "https://techcommunity.microsoft.com/blog/azure-ai-foundry-blog/new-mai-models-in-microsoft-foundry-across-text-image-voice-and-speech/4524632", + "source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus2'%20and%20priceType%20eq%20'Consumption'", "supported_endpoints": [ "/v1/images/generations", "/v1/images/edits" @@ -10234,7 +10575,7 @@ "mode": "image_generation", "output_cost_per_image": 0.0338, "output_cost_per_image_token": 3.3e-05, - "source": "https://techcommunity.microsoft.com/blog/azure-ai-foundry-blog/new-mai-models-in-microsoft-foundry-across-text-image-voice-and-speech/4524632", + "source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus2'%20and%20priceType%20eq%20'Consumption'", "supported_endpoints": [ "/v1/images/generations", "/v1/images/edits" @@ -10248,7 +10589,7 @@ "mode": "image_generation", "output_cost_per_image": 0.02, "output_cost_per_image_token": 1.95e-05, - "source": "https://aka.ms/mai-image-2e-foundryblog", + "source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus2'%20and%20priceType%20eq%20'Consumption'", "supported_endpoints": [ "/v1/images/generations" ] @@ -10262,7 +10603,7 @@ "max_tokens": 64000, "mode": "chat", "output_cost_per_token": 8e-06, - "source": "https://learn.microsoft.com/en-us/azure/foundry/foundry-models/concepts/models-sold-directly-by-azure", + "source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus2'%20and%20priceType%20eq%20'Consumption'", "supported_endpoints": [ "/v1/chat/completions" ], @@ -10313,19 +10654,19 @@ "max_tokens": 2048, "mode": "chat", "output_cost_per_token": 7.1e-07, - "source": "https://marketplace.microsoft.com/en/marketplace/apps/metagenai.llama-3-3-70b-instruct-offer?tab=Overview", + "source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus2'%20and%20priceType%20eq%20'Consumption'", "supports_function_calling": true, "supports_tool_choice": true }, "azure_ai/Llama-4-Maverick-17B-128E-Instruct-FP8": { - "input_cost_per_token": 1.41e-06, + "input_cost_per_token": 2.5e-07, "litellm_provider": "azure_ai", "max_input_tokens": 1000000, "max_output_tokens": 16384, "max_tokens": 16384, "mode": "chat", - "output_cost_per_token": 3.5e-07, - "source": "https://azure.microsoft.com/en-us/blog/introducing-the-llama-4-herd-in-azure-ai-foundry-and-azure-databricks/", + "output_cost_per_token": 1e-06, + "source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus2'%20and%20priceType%20eq%20'Consumption'", "supports_function_calling": true, "supports_tool_choice": true, "supports_vision": true @@ -10396,7 +10737,7 @@ "max_tokens": 4096, "mode": "chat", "output_cost_per_token": 6.8e-07, - "source": "https://azure.microsoft.com/en-us/pricing/details/phi-3/", + "source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus2'%20and%20priceType%20eq%20'Consumption'", "supports_tool_choice": true, "supports_vision": false }, @@ -10408,7 +10749,7 @@ "max_tokens": 4096, "mode": "chat", "output_cost_per_token": 6.8e-07, - "source": "https://azure.microsoft.com/en-us/pricing/details/phi-3/", + "source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus2'%20and%20priceType%20eq%20'Consumption'", "supports_tool_choice": true, "supports_vision": false }, @@ -10420,7 +10761,7 @@ "max_tokens": 4096, "mode": "chat", "output_cost_per_token": 5.2e-07, - "source": "https://azure.microsoft.com/en-us/pricing/details/phi-3/", + "source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus2'%20and%20priceType%20eq%20'Consumption'", "supports_tool_choice": true, "supports_vision": false }, @@ -10432,7 +10773,7 @@ "max_tokens": 4096, "mode": "chat", "output_cost_per_token": 5.2e-07, - "source": "https://azure.microsoft.com/en-us/pricing/details/phi-3/", + "source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus2'%20and%20priceType%20eq%20'Consumption'", "supports_tool_choice": true, "supports_vision": false }, @@ -10444,7 +10785,7 @@ "max_tokens": 4096, "mode": "chat", "output_cost_per_token": 6e-07, - "source": "https://azure.microsoft.com/en-us/pricing/details/phi-3/", + "source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus2'%20and%20priceType%20eq%20'Consumption'", "supports_tool_choice": true, "supports_vision": false }, @@ -10456,7 +10797,7 @@ "max_tokens": 4096, "mode": "chat", "output_cost_per_token": 6e-07, - "source": "https://azure.microsoft.com/en-us/pricing/details/phi-3/", + "source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus2'%20and%20priceType%20eq%20'Consumption'", "supports_tool_choice": true, "supports_vision": false }, @@ -10468,7 +10809,7 @@ "max_tokens": 4096, "mode": "chat", "output_cost_per_token": 6.4e-07, - "source": "https://azure.microsoft.com/en-us/pricing/details/phi-3/", + "source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus2'%20and%20priceType%20eq%20'Consumption'", "supports_tool_choice": true, "supports_vision": false }, @@ -10480,7 +10821,7 @@ "max_tokens": 4096, "mode": "chat", "output_cost_per_token": 5.2e-07, - "source": "https://azure.microsoft.com/en-us/pricing/details/phi-3/", + "source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus2'%20and%20priceType%20eq%20'Consumption'", "supports_tool_choice": true, "supports_vision": false }, @@ -10492,7 +10833,7 @@ "max_tokens": 4096, "mode": "chat", "output_cost_per_token": 5.2e-07, - "source": "https://azure.microsoft.com/en-us/pricing/details/phi-3/", + "source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus2'%20and%20priceType%20eq%20'Consumption'", "supports_tool_choice": true, "supports_vision": true }, @@ -10504,7 +10845,7 @@ "max_tokens": 16384, "mode": "chat", "output_cost_per_token": 5e-07, - "source": "https://techcommunity.microsoft.com/blog/machinelearningblog/affordable-innovation-unveiling-the-pricing-of-phi-3-slms-on-models-as-a-service/4156495", + "source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus2'%20and%20priceType%20eq%20'Consumption'", "supports_function_calling": true, "supports_tool_choice": true, "supports_vision": false @@ -10517,7 +10858,7 @@ "max_tokens": 4096, "mode": "chat", "output_cost_per_token": 3e-07, - "source": "https://techcommunity.microsoft.com/blog/Azure-AI-Services-blog/announcing-new-phi-pricing-empowering-your-business-with-small-language-models/4395112", + "source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus2'%20and%20priceType%20eq%20'Consumption'", "supports_function_calling": true }, "azure_ai/Phi-4-multimodal-instruct": { @@ -10529,20 +10870,20 @@ "max_tokens": 4096, "mode": "chat", "output_cost_per_token": 3.2e-07, - "source": "https://techcommunity.microsoft.com/blog/Azure-AI-Services-blog/announcing-new-phi-pricing-empowering-your-business-with-small-language-models/4395112", + "source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus2'%20and%20priceType%20eq%20'Consumption'", "supports_audio_input": true, "supports_function_calling": true, "supports_vision": true }, "azure_ai/Phi-4-mini-reasoning": { - "input_cost_per_token": 8e-08, + "input_cost_per_token": 7.5e-08, "litellm_provider": "azure_ai", "max_input_tokens": 131072, "max_output_tokens": 4096, "max_tokens": 4096, "mode": "chat", - "output_cost_per_token": 3.2e-07, - "source": "https://azure.microsoft.com/en-us/pricing/details/ai-foundry-models/microsoft/", + "output_cost_per_token": 3e-07, + "source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus2'%20and%20priceType%20eq%20'Consumption'", "supports_function_calling": true }, "azure_ai/Phi-4-reasoning": { @@ -10553,7 +10894,7 @@ "max_tokens": 4096, "mode": "chat", "output_cost_per_token": 5e-07, - "source": "https://azure.microsoft.com/en-us/pricing/details/ai-foundry-models/microsoft/", + "source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus2'%20and%20priceType%20eq%20'Consumption'", "supports_function_calling": true, "supports_tool_choice": true, "supports_reasoning": true @@ -10605,7 +10946,7 @@ "max_tokens": 8182, "mode": "chat", "output_cost_per_token": 1e-05, - "source": "https://azure.microsoft.com/en-us/pricing/details/ai-foundry-models/cohere/", + "source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus2'%20and%20priceType%20eq%20'Consumption'", "supports_function_calling": true, "supports_tool_choice": true }, @@ -10644,7 +10985,7 @@ "max_tokens": 8192, "mode": "chat", "output_cost_per_token": 5.4e-06, - "source": "https://azure.microsoft.com/en-us/pricing/details/ai-foundry-models/microsoft/", + "source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus2'%20and%20priceType%20eq%20'Consumption'", "supports_reasoning": true, "supports_tool_choice": true }, @@ -10709,7 +11050,7 @@ "max_tokens": 163840, "mode": "chat", "output_cost_per_token": 1.68e-06, - "source": "https://techcommunity.microsoft.com/blog/azure-ai-foundry-blog/introducing-deepseek-v3-2-and-deepseek-v3-2-speciale-in-microsoft-foundry/4477549", + "source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus2'%20and%20priceType%20eq%20'Consumption'", "supports_assistant_prefill": true, "supports_function_calling": true, "supports_prompt_caching": true, @@ -10724,7 +11065,7 @@ "max_tokens": 163840, "mode": "chat", "output_cost_per_token": 1.68e-06, - "source": "https://techcommunity.microsoft.com/blog/azure-ai-foundry-blog/introducing-deepseek-v3-2-and-deepseek-v3-2-speciale-in-microsoft-foundry/4477549", + "source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus2'%20and%20priceType%20eq%20'Consumption'", "supports_assistant_prefill": true, "supports_function_calling": true, "supports_prompt_caching": true, @@ -10740,7 +11081,7 @@ "max_tokens": 8192, "mode": "chat", "output_cost_per_token": 5.4e-06, - "source": "https://techcommunity.microsoft.com/blog/machinelearningblog/deepseek-r1-improved-performance-higher-limits-and-transparent-pricing/4386367", + "source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus2'%20and%20priceType%20eq%20'Consumption'", "supports_reasoning": true, "supports_tool_choice": true }, @@ -10752,7 +11093,7 @@ "max_tokens": 8192, "mode": "chat", "output_cost_per_token": 4.56e-06, - "source": "https://techcommunity.microsoft.com/blog/machinelearningblog/announcing-deepseek-v3-on-azure-ai-foundry-and-github/4390438", + "source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus2'%20and%20priceType%20eq%20'Consumption'", "supports_tool_choice": true }, "azure_ai/deepseek-v3-0324": { @@ -10764,7 +11105,7 @@ "max_tokens": 8192, "mode": "chat", "output_cost_per_token": 4.56e-06, - "source": "https://techcommunity.microsoft.com/blog/machinelearningblog/announcing-deepseek-v3-on-azure-ai-foundry-and-github/4390438", + "source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus2'%20and%20priceType%20eq%20'Consumption'", "supports_function_calling": true, "supports_tool_choice": true }, @@ -10777,7 +11118,7 @@ "max_tokens": 131072, "mode": "chat", "output_cost_per_token": 4.94e-06, - "source": "https://azure.microsoft.com/en-us/pricing/details/ai-foundry-models/deepseek/", + "source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus2'%20and%20priceType%20eq%20'Consumption'", "supports_function_calling": true, "supports_reasoning": true, "supports_tool_choice": true @@ -10791,7 +11132,7 @@ "max_tokens": 384000, "mode": "chat", "output_cost_per_token": 3.48e-06, - "source": "https://azure.microsoft.com/en-us/pricing/details/ai-foundry-models/deepseek/", + "source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus2'%20and%20priceType%20eq%20'Consumption'", "supports_function_calling": true, "supports_reasoning": true, "supports_tool_choice": true, @@ -10807,7 +11148,7 @@ "max_tokens": 384000, "mode": "chat", "output_cost_per_token": 5.1e-07, - "source": "https://azure.microsoft.com/en-us/pricing/details/ai-foundry-models/deepseek/", + "source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus2'%20and%20priceType%20eq%20'Consumption'", "supports_function_calling": true, "supports_reasoning": true, "supports_tool_choice": true, @@ -10824,7 +11165,7 @@ "max_tokens": 128000, "mode": "chat", "output_cost_per_token": 1.32e-06, - "source": "https://azure.microsoft.com/en-us/pricing/details/ai-foundry-models/deepseek/", + "source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus2'%20and%20priceType%20eq%20'Consumption'", "supports_function_calling": true, "supports_prompt_caching": true, "supports_reasoning": true, @@ -10838,7 +11179,7 @@ "mode": "embedding", "output_cost_per_token": 0.0, "output_vector_size": 3072, - "source": "https://marketplace.microsoft.com/pt-br/marketplace/apps/cohere.cohere-embed-4-offer?tab=PlansAndPrice", + "source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus2'%20and%20priceType%20eq%20'Consumption'", "supported_endpoints": [ "/v1/embeddings" ], @@ -10857,7 +11198,7 @@ "max_tokens": 131072, "mode": "chat", "output_cost_per_token": 1.5e-05, - "source": "https://devblogs.microsoft.com/foundry/announcing-grok-3-and-grok-3-mini-on-azure-ai-foundry/", + "source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus2'%20and%20priceType%20eq%20'Consumption'", "supports_function_calling": true, "supports_response_schema": false, "supports_tool_choice": true, @@ -10872,7 +11213,7 @@ "max_tokens": 131072, "mode": "chat", "output_cost_per_token": 1.27e-06, - "source": "https://devblogs.microsoft.com/foundry/announcing-grok-3-and-grok-3-mini-on-azure-ai-foundry/", + "source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus2'%20and%20priceType%20eq%20'Consumption'", "supports_function_calling": true, "supports_reasoning": true, "supports_response_schema": false, @@ -10888,7 +11229,7 @@ "max_tokens": 131072, "mode": "chat", "output_cost_per_token": 1.5e-05, - "source": "https://azure.microsoft.com/en-us/pricing/details/ai-foundry-models/grok/", + "source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus2'%20and%20priceType%20eq%20'Consumption'", "supports_function_calling": true, "supports_response_schema": false, "supports_tool_choice": true, @@ -10903,7 +11244,7 @@ "max_tokens": 131072, "mode": "chat", "output_cost_per_token": 1.27e-06, - "source": "https://azure.microsoft.com/en-us/pricing/details/ai-foundry-models/grok/", + "source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus2'%20and%20priceType%20eq%20'Consumption'", "supports_function_calling": true, "supports_reasoning": true, "supports_response_schema": false, @@ -10918,7 +11259,7 @@ "max_tokens": 131072, "mode": "chat", "output_cost_per_token": 1.5e-05, - "source": "https://azure.microsoft.com/en-us/pricing/details/ai-foundry-models/grok/", + "source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus2'%20and%20priceType%20eq%20'Consumption'", "supports_function_calling": true, "supports_response_schema": true, "supports_tool_choice": true, @@ -10933,7 +11274,7 @@ "max_tokens": 200000, "mode": "chat", "output_cost_per_token": 2.5e-06, - "source": "https://techcommunity.microsoft.com/blog/azure-ai-foundry-blog/introducing-grok-4-3-on-microsoft-foundry-latest-generation-agentic-capabilities/4517096", + "source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus2'%20and%20priceType%20eq%20'Consumption'", "supports_function_calling": true, "supports_prompt_caching": true, "supports_reasoning": true, @@ -10951,7 +11292,7 @@ "max_tokens": 128000, "mode": "chat", "output_cost_per_token": 6e-06, - "source": "https://techcommunity.microsoft.com/blog/azure-ai-foundry-blog/grok-4-6-comes-to-microsoft-foundry-models-built-for-long-horizon-reasoning-and-/4547578", + "source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus2'%20and%20priceType%20eq%20'Consumption'", "supports_function_calling": true, "supports_prompt_caching": true, "supports_reasoning": true, @@ -10970,7 +11311,7 @@ "max_tokens": 8192, "mode": "chat", "output_cost_per_token": 2.5e-06, - "source": "https://azure.microsoft.com/en-us/pricing/details/ai-foundry-models/grok/", + "source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus2'%20and%20priceType%20eq%20'Consumption'", "supports_function_calling": true, "supports_response_schema": true, "supports_tool_choice": true, @@ -10988,7 +11329,7 @@ "max_tokens": 8192, "mode": "chat", "output_cost_per_token": 2.5e-06, - "source": "https://azure.microsoft.com/en-us/pricing/details/ai-foundry-models/grok/", + "source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus2'%20and%20priceType%20eq%20'Consumption'", "supports_function_calling": true, "supports_response_schema": true, "supports_tool_choice": true, @@ -11004,6 +11345,7 @@ "max_output_tokens": 131072, "max_tokens": 131072, "mode": "chat", + "source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus2'%20and%20priceType%20eq%20'Consumption'", "supports_function_calling": true, "supports_response_schema": true, "supports_tool_choice": true, @@ -11018,7 +11360,7 @@ "max_output_tokens": 131072, "max_tokens": 131072, "mode": "chat", - "source": "https://azure.microsoft.com/en-us/pricing/details/ai-foundry-models/grok/", + "source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus2'%20and%20priceType%20eq%20'Consumption'", "supports_function_calling": true, "supports_response_schema": true, "supports_tool_choice": true, @@ -11032,7 +11374,7 @@ "max_output_tokens": 131072, "max_tokens": 131072, "mode": "chat", - "source": "https://techcommunity.microsoft.com/t5/Azure-AI-Foundry-Blog/Grok-4-0-Goes-GA-in-Microsoft-Foundry-and-Grok-4-1-Fast-Arrives/ba-p/4497964", + "source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus2'%20and%20priceType%20eq%20'Consumption'", "supports_function_calling": true, "supports_response_schema": true, "supports_tool_choice": true, @@ -11046,7 +11388,7 @@ "max_output_tokens": 131072, "max_tokens": 131072, "mode": "chat", - "source": "https://techcommunity.microsoft.com/t5/Azure-AI-Foundry-Blog/Grok-4-0-Goes-GA-in-Microsoft-Foundry-and-Grok-4-1-Fast-Arrives/ba-p/4497964", + "source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus2'%20and%20priceType%20eq%20'Consumption'", "supports_function_calling": true, "supports_reasoning": true, "supports_response_schema": true, @@ -11061,7 +11403,7 @@ "max_tokens": 131072, "mode": "chat", "output_cost_per_token": 1.5e-06, - "source": "https://azure.microsoft.com/en-us/pricing/details/ai-foundry-models/grok/", + "source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus2'%20and%20priceType%20eq%20'Consumption'", "supports_function_calling": true, "supports_response_schema": true, "supports_tool_choice": true, @@ -11096,7 +11438,7 @@ "max_tokens": 262144, "mode": "chat", "output_cost_per_token": 3e-06, - "source": "https://azure.microsoft.com/en-us/pricing/details/ai-foundry-models/kimi/", + "source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus2'%20and%20priceType%20eq%20'Consumption'", "supports_function_calling": true, "supports_tool_choice": true, "supports_video_input": true, @@ -11113,7 +11455,7 @@ "max_tokens": 262144, "mode": "chat", "output_cost_per_token": 4e-06, - "source": "https://azure.microsoft.com/en-us/pricing/details/ai-foundry-models/kimi/", + "source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus2'%20and%20priceType%20eq%20'Consumption'", "supported_modalities": [ "text", "image" @@ -11183,7 +11525,7 @@ "max_tokens": 8191, "mode": "chat", "output_cost_per_token": 1.5e-06, - "source": "https://azure.microsoft.com/en-us/blog/introducing-mistral-large-3-in-microsoft-foundry-open-capable-and-ready-for-production-workloads/", + "source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus2'%20and%20priceType%20eq%20'Consumption'", "supports_function_calling": true, "supports_tool_choice": true, "supports_vision": true @@ -61814,7 +62156,7 @@ "max_tokens": 262144, "mode": "chat", "output_cost_per_token": 4e-06, - "source": "https://azure.microsoft.com/en-us/pricing/details/ai-foundry-models/kimi/", + "source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus2'%20and%20priceType%20eq%20'Consumption'", "supported_modalities": [ "text", "image" @@ -66133,5 +66475,586 @@ "mode": "chat", "output_cost_per_token": 8e-06, "source": "https://api.together.ai/v1/models" + }, + "azure/eu/codex-mini": { + "cache_read_input_token_cost": 4.13e-07, + "input_cost_per_token": 1.65e-06, + "litellm_provider": "azure", + "mode": "chat", + "output_cost_per_token": 6.6e-06, + "source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus2'%20and%20priceType%20eq%20'Consumption'" + }, + "azure/eu/computer-use-preview": { + "input_cost_per_token": 3.3e-06, + "litellm_provider": "azure", + "mode": "chat", + "output_cost_per_token": 1.32e-05, + "source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus2'%20and%20priceType%20eq%20'Consumption'" + }, + "azure/eu/gpt-4.1": { + "cache_read_input_token_cost": 5.5e-07, + "cache_read_input_token_cost_priority": 9.63e-07, + "input_cost_per_token": 2.2e-06, + "input_cost_per_token_batches": 1.1e-06, + "input_cost_per_token_priority": 3.85e-06, + "litellm_provider": "azure", + "mode": "chat", + "output_cost_per_token": 8.8e-06, + "output_cost_per_token_batches": 4.4e-06, + "output_cost_per_token_priority": 1.54e-05, + "source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus2'%20and%20priceType%20eq%20'Consumption'" + }, + "azure/eu/gpt-4.1-mini": { + "cache_read_input_token_cost": 1.1e-07, + "cache_read_input_token_cost_priority": 1.93e-07, + "input_cost_per_token": 4.4e-07, + "input_cost_per_token_batches": 2.2e-07, + "input_cost_per_token_priority": 7.7e-07, + "litellm_provider": "azure", + "mode": "chat", + "output_cost_per_token": 1.76e-06, + "output_cost_per_token_batches": 8.8e-07, + "output_cost_per_token_priority": 3.08e-06, + "source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus2'%20and%20priceType%20eq%20'Consumption'" + }, + "azure/eu/gpt-4.1-nano": { + "cache_read_input_token_cost": 2.8e-08, + "input_cost_per_token": 1.1e-07, + "input_cost_per_token_batches": 5.5e-08, + "litellm_provider": "azure", + "mode": "chat", + "output_cost_per_token": 4.4e-07, + "output_cost_per_token_batches": 2.2e-07, + "source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus2'%20and%20priceType%20eq%20'Consumption'" + }, + "azure/eu/gpt-4o-2024-05-13": { + "input_cost_per_token": 5.5e-06, + "input_cost_per_token_batches": 2.75e-06, + "litellm_provider": "azure", + "mode": "chat", + "output_cost_per_token": 1.65e-05, + "output_cost_per_token_batches": 8.25e-06, + "source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus2'%20and%20priceType%20eq%20'Consumption'" + }, + "azure/eu/gpt-5": { + "cache_read_input_token_cost": 1.375e-07, + "cache_read_input_token_cost_priority": 2.75e-07, + "input_cost_per_token": 1.375e-06, + "input_cost_per_token_batches": 6.875e-07, + "input_cost_per_token_priority": 2.75e-06, + "litellm_provider": "azure", + "mode": "chat", + "output_cost_per_token": 1.1e-05, + "output_cost_per_token_batches": 5.5e-06, + "output_cost_per_token_priority": 2.2e-05, + "source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus2'%20and%20priceType%20eq%20'Consumption'" + }, + "azure/eu/gpt-5-codex": { + "cache_read_input_token_cost": 1.38e-07, + "input_cost_per_token": 1.375e-06, + "litellm_provider": "azure", + "mode": "chat", + "output_cost_per_token": 1.1e-05, + "source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus2'%20and%20priceType%20eq%20'Consumption'" + }, + "azure/eu/gpt-5-mini": { + "cache_read_input_token_cost": 2.75e-08, + "cache_read_input_token_cost_priority": 4.95e-08, + "input_cost_per_token": 2.75e-07, + "input_cost_per_token_batches": 1.375e-07, + "input_cost_per_token_priority": 4.95e-07, + "litellm_provider": "azure", + "mode": "chat", + "output_cost_per_token": 2.2e-06, + "output_cost_per_token_batches": 1.1e-06, + "output_cost_per_token_priority": 3.96e-06, + "source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus2'%20and%20priceType%20eq%20'Consumption'" + }, + "azure/eu/gpt-5-nano": { + "cache_read_input_token_cost": 5.5e-09, + "input_cost_per_token": 5.5e-08, + "input_cost_per_token_batches": 2.75e-08, + "litellm_provider": "azure", + "mode": "chat", + "output_cost_per_token": 4.4e-07, + "output_cost_per_token_batches": 2.2e-07, + "source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus2'%20and%20priceType%20eq%20'Consumption'" + }, + "azure/eu/gpt-5-pro": { + "input_cost_per_token": 1.65e-05, + "input_cost_per_token_batches": 8.25e-06, + "litellm_provider": "azure", + "mode": "chat", + "output_cost_per_token": 0.000132, + "output_cost_per_token_batches": 6.6e-05, + "source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus2'%20and%20priceType%20eq%20'Consumption'" + }, + "azure/eu/gpt-5.1-codex-max": { + "cache_read_input_token_cost": 1.375e-07, + "input_cost_per_token": 1.375e-06, + "litellm_provider": "azure", + "mode": "chat", + "output_cost_per_token": 1.1e-05, + "source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus2'%20and%20priceType%20eq%20'Consumption'" + }, + "azure/eu/gpt-5.2": { + "cache_read_input_token_cost": 1.925e-07, + "cache_read_input_token_cost_priority": 3.85e-07, + "input_cost_per_token": 1.925e-06, + "input_cost_per_token_batches": 9.625e-07, + "input_cost_per_token_priority": 3.85e-06, + "litellm_provider": "azure", + "mode": "chat", + "output_cost_per_token": 1.54e-05, + "output_cost_per_token_batches": 7.7e-06, + "output_cost_per_token_priority": 3.08e-05, + "source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus2'%20and%20priceType%20eq%20'Consumption'" + }, + "azure/eu/gpt-5.2-chat": { + "cache_read_input_token_cost": 1.925e-07, + "input_cost_per_token": 1.925e-06, + "litellm_provider": "azure", + "mode": "chat", + "output_cost_per_token": 1.54e-05, + "source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus2'%20and%20priceType%20eq%20'Consumption'" + }, + "azure/eu/gpt-5.2-codex": { + "cache_read_input_token_cost": 1.925e-07, + "input_cost_per_token": 1.925e-06, + "litellm_provider": "azure", + "mode": "chat", + "output_cost_per_token": 1.54e-05, + "source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus2'%20and%20priceType%20eq%20'Consumption'" + }, + "azure/eu/gpt-5.2-pro": { + "input_cost_per_token": 2.31e-05, + "input_cost_per_token_batches": 1.155e-05, + "litellm_provider": "azure", + "mode": "chat", + "output_cost_per_token": 0.0001848, + "output_cost_per_token_batches": 9.24e-05, + "source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus2'%20and%20priceType%20eq%20'Consumption'" + }, + "azure/eu/gpt-5.3-chat": { + "cache_read_input_token_cost": 1.925e-07, + "input_cost_per_token": 1.925e-06, + "litellm_provider": "azure", + "mode": "chat", + "output_cost_per_token": 1.54e-05, + "source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus2'%20and%20priceType%20eq%20'Consumption'" + }, + "azure/eu/gpt-5.3-codex": { + "cache_read_input_token_cost": 1.925e-07, + "cache_read_input_token_cost_priority": 3.85e-07, + "input_cost_per_token": 1.925e-06, + "input_cost_per_token_priority": 3.85e-06, + "litellm_provider": "azure", + "mode": "chat", + "output_cost_per_token": 1.54e-05, + "output_cost_per_token_priority": 3.08e-05, + "source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus2'%20and%20priceType%20eq%20'Consumption'" + }, + "azure/eu/gpt-5.4-mini": { + "cache_read_input_token_cost": 8.25e-08, + "cache_read_input_token_cost_priority": 1.65e-07, + "input_cost_per_token": 8.25e-07, + "input_cost_per_token_batches": 4.125e-07, + "input_cost_per_token_priority": 1.65e-06, + "litellm_provider": "azure", + "mode": "chat", + "output_cost_per_token": 4.95e-06, + "output_cost_per_token_batches": 2.475e-06, + "output_cost_per_token_priority": 9.9e-06, + "source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus2'%20and%20priceType%20eq%20'Consumption'" + }, + "azure/eu/gpt-5.4-nano": { + "cache_read_input_token_cost": 2.2e-08, + "input_cost_per_token": 2.2e-07, + "input_cost_per_token_batches": 1.1e-07, + "litellm_provider": "azure", + "mode": "chat", + "output_cost_per_token": 1.375e-06, + "output_cost_per_token_batches": 6.875e-07, + "source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus2'%20and%20priceType%20eq%20'Consumption'" + }, + "azure/eu/gpt-5.4-pro": { + "input_cost_per_token": 3.3e-05, + "input_cost_per_token_above_272k_tokens": 6.6e-05, + "input_cost_per_token_batches": 1.65e-05, + "litellm_provider": "azure", + "mode": "chat", + "output_cost_per_token": 0.000198, + "output_cost_per_token_above_272k_tokens": 0.000297, + "output_cost_per_token_batches": 9.9e-05, + "source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus2'%20and%20priceType%20eq%20'Consumption'" + }, + "azure/eu/gpt-6-astra": { + "cache_creation_input_token_cost": 1.375e-05, + "cache_creation_input_token_cost_above_272k_tokens": 2.75e-05, + "cache_read_input_token_cost": 1.1e-06, + "cache_read_input_token_cost_above_272k_tokens": 2.2e-06, + "input_cost_per_token": 1.1e-05, + "input_cost_per_token_above_272k_tokens": 2.2e-05, + "litellm_provider": "azure", + "mode": "chat", + "output_cost_per_token": 5.5e-05, + "output_cost_per_token_above_272k_tokens": 8.25e-05, + "source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus2'%20and%20priceType%20eq%20'Consumption'" + }, + "azure/eu/o1-mini": { + "cache_read_input_token_cost": 6.05e-07, + "input_cost_per_token": 1.21e-06, + "input_cost_per_token_batches": 6.05e-07, + "litellm_provider": "azure", + "mode": "chat", + "output_cost_per_token": 4.84e-06, + "output_cost_per_token_batches": 2.42e-06, + "source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus2'%20and%20priceType%20eq%20'Consumption'" + }, + "azure/eu/o1-preview": { + "cache_read_input_token_cost": 8.25e-06, + "input_cost_per_token": 1.65e-05, + "litellm_provider": "azure", + "mode": "chat", + "output_cost_per_token": 6.6e-05, + "source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus2'%20and%20priceType%20eq%20'Consumption'" + }, + "azure/eu/o3-2025-04-16": { + "cache_read_input_token_cost": 5.5e-07, + "input_cost_per_token": 2.2e-06, + "input_cost_per_token_batches": 1.1e-06, + "litellm_provider": "azure", + "mode": "chat", + "output_cost_per_token": 8.8e-06, + "output_cost_per_token_batches": 4.4e-06, + "source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus2'%20and%20priceType%20eq%20'Consumption'" + }, + "azure/eu/o3-deep-research": { + "cache_read_input_token_cost": 2.75e-06, + "input_cost_per_token": 1.1e-05, + "litellm_provider": "azure", + "mode": "chat", + "output_cost_per_token": 4.4e-05, + "source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus2'%20and%20priceType%20eq%20'Consumption'" + }, + "azure/eu/o4-mini-2025-04-16": { + "cache_read_input_token_cost": 3.03e-07, + "input_cost_per_token": 1.21e-06, + "input_cost_per_token_batches": 6.05e-07, + "litellm_provider": "azure", + "mode": "chat", + "output_cost_per_token": 4.84e-06, + "output_cost_per_token_batches": 2.42e-06, + "source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus2'%20and%20priceType%20eq%20'Consumption'" + }, + "azure/eu/text-embedding-3-large": { + "input_cost_per_token": 1.43e-07, + "litellm_provider": "azure", + "mode": "embedding", + "source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus2'%20and%20priceType%20eq%20'Consumption'" + }, + "azure/eu/text-embedding-3-small": { + "input_cost_per_token": 2.2e-08, + "litellm_provider": "azure", + "mode": "embedding", + "source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus2'%20and%20priceType%20eq%20'Consumption'" + }, + "azure/eu/text-embedding-ada-002": { + "input_cost_per_token": 1.1e-07, + "litellm_provider": "azure", + "mode": "embedding", + "source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus2'%20and%20priceType%20eq%20'Consumption'" + }, + "gemini/gemini-3.8-live": { + "input_cost_per_audio_token": 3e-06, + "input_cost_per_image_token": 1e-06, + "input_cost_per_token": 7.5e-07, + "litellm_provider": "gemini", + "max_input_tokens": 131072, + "max_output_tokens": 65536, + "max_tokens": 65536, + "mode": "realtime", + "output_cost_per_audio_token": 1.2e-05, + "output_cost_per_token": 4.5e-06, + "source": "https://ai.google.dev/gemini-api/docs/pricing", + "supports_audio_input": true, + "supports_function_calling": true, + "supports_response_schema": false, + "supports_vision": true, + "supports_web_search": true + }, + "gemini/gemini-3.8-live-extended-thinking": { + "input_cost_per_audio_token": 3e-06, + "input_cost_per_image_token": 1e-06, + "input_cost_per_token": 7.5e-07, + "litellm_provider": "gemini", + "max_input_tokens": 131072, + "max_output_tokens": 65536, + "max_tokens": 65536, + "mode": "realtime", + "output_cost_per_audio_token": 1.2e-05, + "output_cost_per_token": 4.5e-06, + "source": "https://ai.google.dev/gemini-api/docs/pricing", + "supports_audio_input": true, + "supports_function_calling": true, + "supports_response_schema": false, + "supports_vision": true, + "supports_web_search": true + }, + "azure/us/codex-mini": { + "cache_read_input_token_cost": 4.13e-07, + "input_cost_per_token": 1.65e-06, + "litellm_provider": "azure", + "mode": "chat", + "output_cost_per_token": 6.6e-06, + "source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus2'%20and%20priceType%20eq%20'Consumption'" + }, + "azure/us/computer-use-preview": { + "input_cost_per_token": 3.3e-06, + "litellm_provider": "azure", + "mode": "chat", + "output_cost_per_token": 1.32e-05, + "source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus2'%20and%20priceType%20eq%20'Consumption'" + }, + "azure/us/gpt-4.1": { + "cache_read_input_token_cost": 5.5e-07, + "cache_read_input_token_cost_priority": 9.63e-07, + "input_cost_per_token": 2.2e-06, + "input_cost_per_token_batches": 1.1e-06, + "input_cost_per_token_priority": 3.85e-06, + "litellm_provider": "azure", + "mode": "chat", + "output_cost_per_token": 8.8e-06, + "output_cost_per_token_batches": 4.4e-06, + "output_cost_per_token_priority": 1.54e-05, + "source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus2'%20and%20priceType%20eq%20'Consumption'" + }, + "azure/us/gpt-4.1-mini": { + "cache_read_input_token_cost": 1.1e-07, + "cache_read_input_token_cost_priority": 1.93e-07, + "input_cost_per_token": 4.4e-07, + "input_cost_per_token_batches": 2.2e-07, + "input_cost_per_token_priority": 7.7e-07, + "litellm_provider": "azure", + "mode": "chat", + "output_cost_per_token": 1.76e-06, + "output_cost_per_token_batches": 8.8e-07, + "output_cost_per_token_priority": 3.08e-06, + "source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus2'%20and%20priceType%20eq%20'Consumption'" + }, + "azure/us/gpt-4.1-nano": { + "cache_read_input_token_cost": 2.8e-08, + "input_cost_per_token": 1.1e-07, + "input_cost_per_token_batches": 5.5e-08, + "litellm_provider": "azure", + "mode": "chat", + "output_cost_per_token": 4.4e-07, + "output_cost_per_token_batches": 2.2e-07, + "source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus2'%20and%20priceType%20eq%20'Consumption'" + }, + "azure/us/gpt-4o-2024-05-13": { + "input_cost_per_token": 5.5e-06, + "input_cost_per_token_batches": 2.75e-06, + "litellm_provider": "azure", + "mode": "chat", + "output_cost_per_token": 1.65e-05, + "output_cost_per_token_batches": 8.25e-06, + "source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus2'%20and%20priceType%20eq%20'Consumption'" + }, + "azure/us/gpt-5": { + "cache_read_input_token_cost": 1.375e-07, + "cache_read_input_token_cost_priority": 2.75e-07, + "input_cost_per_token": 1.375e-06, + "input_cost_per_token_batches": 6.875e-07, + "input_cost_per_token_priority": 2.75e-06, + "litellm_provider": "azure", + "mode": "chat", + "output_cost_per_token": 1.1e-05, + "output_cost_per_token_batches": 5.5e-06, + "output_cost_per_token_priority": 2.2e-05, + "source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus2'%20and%20priceType%20eq%20'Consumption'" + }, + "azure/us/gpt-5-codex": { + "cache_read_input_token_cost": 1.38e-07, + "input_cost_per_token": 1.375e-06, + "litellm_provider": "azure", + "mode": "chat", + "output_cost_per_token": 1.1e-05, + "source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus2'%20and%20priceType%20eq%20'Consumption'" + }, + "azure/us/gpt-5-mini": { + "cache_read_input_token_cost": 2.75e-08, + "cache_read_input_token_cost_priority": 4.95e-08, + "input_cost_per_token": 2.75e-07, + "input_cost_per_token_batches": 1.375e-07, + "input_cost_per_token_priority": 4.95e-07, + "litellm_provider": "azure", + "mode": "chat", + "output_cost_per_token": 2.2e-06, + "output_cost_per_token_batches": 1.1e-06, + "output_cost_per_token_priority": 3.96e-06, + "source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus2'%20and%20priceType%20eq%20'Consumption'" + }, + "azure/us/gpt-5-nano": { + "cache_read_input_token_cost": 5.5e-09, + "input_cost_per_token": 5.5e-08, + "input_cost_per_token_batches": 2.75e-08, + "litellm_provider": "azure", + "mode": "chat", + "output_cost_per_token": 4.4e-07, + "output_cost_per_token_batches": 2.2e-07, + "source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus2'%20and%20priceType%20eq%20'Consumption'" + }, + "azure/us/gpt-5-pro": { + "input_cost_per_token": 1.65e-05, + "input_cost_per_token_batches": 8.25e-06, + "litellm_provider": "azure", + "mode": "chat", + "output_cost_per_token": 0.000132, + "output_cost_per_token_batches": 6.6e-05, + "source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus2'%20and%20priceType%20eq%20'Consumption'" + }, + "azure/us/gpt-5.1-codex-max": { + "cache_read_input_token_cost": 1.375e-07, + "input_cost_per_token": 1.375e-06, + "litellm_provider": "azure", + "mode": "chat", + "output_cost_per_token": 1.1e-05, + "source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus2'%20and%20priceType%20eq%20'Consumption'" + }, + "azure/us/gpt-5.2": { + "cache_read_input_token_cost": 1.925e-07, + "cache_read_input_token_cost_priority": 3.85e-07, + "input_cost_per_token": 1.925e-06, + "input_cost_per_token_batches": 9.625e-07, + "input_cost_per_token_priority": 3.85e-06, + "litellm_provider": "azure", + "mode": "chat", + "output_cost_per_token": 1.54e-05, + "output_cost_per_token_batches": 7.7e-06, + "output_cost_per_token_priority": 3.08e-05, + "source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus2'%20and%20priceType%20eq%20'Consumption'" + }, + "azure/us/gpt-5.2-chat": { + "cache_read_input_token_cost": 1.925e-07, + "input_cost_per_token": 1.925e-06, + "litellm_provider": "azure", + "mode": "chat", + "output_cost_per_token": 1.54e-05, + "source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus2'%20and%20priceType%20eq%20'Consumption'" + }, + "azure/us/gpt-5.2-codex": { + "cache_read_input_token_cost": 1.925e-07, + "input_cost_per_token": 1.925e-06, + "litellm_provider": "azure", + "mode": "chat", + "output_cost_per_token": 1.54e-05, + "source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus2'%20and%20priceType%20eq%20'Consumption'" + }, + "azure/us/gpt-5.2-pro": { + "input_cost_per_token": 2.31e-05, + "input_cost_per_token_batches": 1.155e-05, + "litellm_provider": "azure", + "mode": "chat", + "output_cost_per_token": 0.0001848, + "output_cost_per_token_batches": 9.24e-05, + "source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus2'%20and%20priceType%20eq%20'Consumption'" + }, + "azure/us/gpt-5.3-chat": { + "cache_read_input_token_cost": 1.925e-07, + "input_cost_per_token": 1.925e-06, + "litellm_provider": "azure", + "mode": "chat", + "output_cost_per_token": 1.54e-05, + "source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus2'%20and%20priceType%20eq%20'Consumption'" + }, + "azure/us/gpt-5.3-codex": { + "cache_read_input_token_cost": 1.925e-07, + "cache_read_input_token_cost_priority": 3.85e-07, + "input_cost_per_token": 1.925e-06, + "input_cost_per_token_priority": 3.85e-06, + "litellm_provider": "azure", + "mode": "chat", + "output_cost_per_token": 1.54e-05, + "output_cost_per_token_priority": 3.08e-05, + "source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus2'%20and%20priceType%20eq%20'Consumption'" + }, + "azure/us/gpt-5.4-mini": { + "cache_read_input_token_cost": 8.25e-08, + "cache_read_input_token_cost_priority": 1.65e-07, + "input_cost_per_token": 8.25e-07, + "input_cost_per_token_batches": 4.125e-07, + "input_cost_per_token_priority": 1.65e-06, + "litellm_provider": "azure", + "mode": "chat", + "output_cost_per_token": 4.95e-06, + "output_cost_per_token_batches": 2.475e-06, + "output_cost_per_token_priority": 9.9e-06, + "source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus2'%20and%20priceType%20eq%20'Consumption'" + }, + "azure/us/gpt-5.4-nano": { + "cache_read_input_token_cost": 2.2e-08, + "input_cost_per_token": 2.2e-07, + "input_cost_per_token_batches": 1.1e-07, + "litellm_provider": "azure", + "mode": "chat", + "output_cost_per_token": 1.375e-06, + "output_cost_per_token_batches": 6.875e-07, + "source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus2'%20and%20priceType%20eq%20'Consumption'" + }, + "azure/us/gpt-5.4-pro": { + "input_cost_per_token": 3.3e-05, + "input_cost_per_token_above_272k_tokens": 6.6e-05, + "input_cost_per_token_batches": 1.65e-05, + "litellm_provider": "azure", + "mode": "chat", + "output_cost_per_token": 0.000198, + "output_cost_per_token_above_272k_tokens": 0.000297, + "output_cost_per_token_batches": 9.9e-05, + "source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus2'%20and%20priceType%20eq%20'Consumption'" + }, + "azure/us/o1-mini": { + "cache_read_input_token_cost": 6.05e-07, + "input_cost_per_token": 1.21e-06, + "input_cost_per_token_batches": 6.05e-07, + "litellm_provider": "azure", + "mode": "chat", + "output_cost_per_token": 4.84e-06, + "output_cost_per_token_batches": 2.42e-06, + "source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus2'%20and%20priceType%20eq%20'Consumption'" + }, + "azure/us/o1-preview": { + "cache_read_input_token_cost": 8.25e-06, + "input_cost_per_token": 1.65e-05, + "litellm_provider": "azure", + "mode": "chat", + "output_cost_per_token": 6.6e-05, + "source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus2'%20and%20priceType%20eq%20'Consumption'" + }, + "azure/us/o3-deep-research": { + "cache_read_input_token_cost": 2.75e-06, + "input_cost_per_token": 1.1e-05, + "litellm_provider": "azure", + "mode": "chat", + "output_cost_per_token": 4.4e-05, + "source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus2'%20and%20priceType%20eq%20'Consumption'" + }, + "azure/us/text-embedding-3-large": { + "input_cost_per_token": 1.43e-07, + "litellm_provider": "azure", + "mode": "embedding", + "source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus2'%20and%20priceType%20eq%20'Consumption'" + }, + "azure/us/text-embedding-3-small": { + "input_cost_per_token": 2.2e-08, + "litellm_provider": "azure", + "mode": "embedding", + "source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus2'%20and%20priceType%20eq%20'Consumption'" + }, + "azure/us/text-embedding-ada-002": { + "input_cost_per_token": 1.1e-07, + "litellm_provider": "azure", + "mode": "embedding", + "source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus2'%20and%20priceType%20eq%20'Consumption'" } } From 9f4fbcfe4142de1c5752dd61e7742482331612ad Mon Sep 17 00:00:00 2001 From: "berriai-litellm-provider-info-sync[bot]" <328147090+berriai-litellm-provider-info-sync[bot]@users.noreply.github.com> Date: Tue, 15 Sep 2026 18:51:21 +0000 Subject: [PATCH 026/133] chore(prices): sync Azure prices: 14 models [enrichment failed: Google Gemini, 34 held] azure/eu/gpt-5-2025-08-07: input_cost_per_token_batches, input_cost_per_token_priority, output_cost_per_token_batches, output_cost_per_token_priority, cache_read_input_token_cost_priority azure/eu/gpt-5-mini-2025-08-07: input_cost_per_token_batches, input_cost_per_token_priority, output_cost_per_token_batches, output_cost_per_token_priority, cache_read_input_token_cost_priority azure/eu/gpt-5-nano-2025-08-07: input_cost_per_token_batches, output_cost_per_token_batches azure/eu/o1-mini-2024-09-12: azure/eu/o1-preview-2024-09-12: azure/o1-mini-2024-09-12: input_cost_per_token_batches, output_cost_per_token_batches azure/us/gpt-4.1-2025-04-14: input_cost_per_token_priority, output_cost_per_token_priority, cache_read_input_token_cost_priority azure/us/gpt-4.1-mini-2025-04-14: input_cost_per_token_priority, output_cost_per_token_priority, cache_read_input_token_cost_priority azure/us/gpt-4.1-nano-2025-04-14: cache_read_input_token_cost, input_cost_per_token_batches azure/us/gpt-5-2025-08-07: input_cost_per_token_batches, input_cost_per_token_priority, output_cost_per_token_batches, output_cost_per_token_priority, cache_read_input_token_cost_priority azure/us/gpt-5-mini-2025-08-07: input_cost_per_token_batches, input_cost_per_token_priority, output_cost_per_token_batches, output_cost_per_token_priority, cache_read_input_token_cost_priority azure/us/gpt-5-nano-2025-08-07: input_cost_per_token_batches, output_cost_per_token_batches azure/us/o1-mini-2024-09-12: azure/us/o1-preview-2024-09-12: --- ...odel_prices_and_context_window_backup.json | 50 ++++++++++++++++++- model_prices_and_context_window.json | 50 ++++++++++++++++++- 2 files changed, 96 insertions(+), 4 deletions(-) diff --git a/litellm/model_prices_and_context_window_backup.json b/litellm/model_prices_and_context_window_backup.json index 1d863977e1e..372ce19e740 100644 --- a/litellm/model_prices_and_context_window_backup.json +++ b/litellm/model_prices_and_context_window_backup.json @@ -4296,14 +4296,20 @@ }, "azure/eu/gpt-5-2025-08-07": { "cache_read_input_token_cost": 1.375e-07, + "cache_read_input_token_cost_priority": 2.75e-07, "deprecation_date": "2027-02-09", "input_cost_per_token": 1.375e-06, + "input_cost_per_token_batches": 6.875e-07, + "input_cost_per_token_priority": 2.75e-06, "litellm_provider": "azure", "max_input_tokens": 272000, "max_output_tokens": 128000, "max_tokens": 128000, "mode": "chat", "output_cost_per_token": 1.1e-05, + "output_cost_per_token_batches": 5.5e-06, + "output_cost_per_token_priority": 2.2e-05, + "source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus2'%20and%20priceType%20eq%20'Consumption'", "supported_endpoints": [ "/v1/chat/completions", "/v1/batch", @@ -4329,14 +4335,20 @@ }, "azure/eu/gpt-5-mini-2025-08-07": { "cache_read_input_token_cost": 2.75e-08, + "cache_read_input_token_cost_priority": 4.95e-08, "deprecation_date": "2027-02-09", "input_cost_per_token": 2.75e-07, + "input_cost_per_token_batches": 1.375e-07, + "input_cost_per_token_priority": 4.95e-07, "litellm_provider": "azure", "max_input_tokens": 272000, "max_output_tokens": 128000, "max_tokens": 128000, "mode": "chat", "output_cost_per_token": 2.2e-06, + "output_cost_per_token_batches": 1.1e-06, + "output_cost_per_token_priority": 3.96e-06, + "source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus2'%20and%20priceType%20eq%20'Consumption'", "supported_endpoints": [ "/v1/chat/completions", "/v1/batch", @@ -4507,12 +4519,15 @@ "cache_read_input_token_cost": 5.5e-09, "deprecation_date": "2027-02-09", "input_cost_per_token": 5.5e-08, + "input_cost_per_token_batches": 2.75e-08, "litellm_provider": "azure", "max_input_tokens": 272000, "max_output_tokens": 128000, "max_tokens": 128000, "mode": "chat", "output_cost_per_token": 4.4e-07, + "output_cost_per_token_batches": 2.2e-07, + "source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus2'%20and%20priceType%20eq%20'Consumption'", "supported_endpoints": [ "/v1/chat/completions", "/v1/batch", @@ -4566,6 +4581,7 @@ "mode": "chat", "output_cost_per_token": 4.84e-06, "output_cost_per_token_batches": 2.42e-06, + "source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus2'%20and%20priceType%20eq%20'Consumption'", "supports_function_calling": true, "supports_parallel_function_calling": true, "supports_prompt_caching": true, @@ -4580,6 +4596,7 @@ "max_tokens": 32768, "mode": "chat", "output_cost_per_token": 6.6e-05, + "source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus2'%20and%20priceType%20eq%20'Consumption'", "supports_function_calling": true, "supports_parallel_function_calling": true, "supports_prompt_caching": true, @@ -9213,12 +9230,15 @@ "azure/o1-mini-2024-09-12": { "cache_read_input_token_cost": 5.5e-07, "input_cost_per_token": 1.1e-06, + "input_cost_per_token_batches": 5.5e-07, "litellm_provider": "azure", "max_input_tokens": 128000, "max_output_tokens": 65536, "max_tokens": 65536, "mode": "chat", "output_cost_per_token": 4.4e-06, + "output_cost_per_token_batches": 2.2e-06, + "source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus2'%20and%20priceType%20eq%20'Consumption'", "supports_function_calling": true, "supports_parallel_function_calling": true, "supports_prompt_caching": true, @@ -9601,8 +9621,10 @@ "azure/us/gpt-4.1-2025-04-14": { "deprecation_date": "2027-04-14", "cache_read_input_token_cost": 5.5e-07, + "cache_read_input_token_cost_priority": 9.63e-07, "input_cost_per_token": 2.2e-06, "input_cost_per_token_batches": 1.1e-06, + "input_cost_per_token_priority": 3.85e-06, "litellm_provider": "azure", "max_input_tokens": 1047576, "max_output_tokens": 32768, @@ -9610,6 +9632,8 @@ "mode": "chat", "output_cost_per_token": 8.8e-06, "output_cost_per_token_batches": 4.4e-06, + "output_cost_per_token_priority": 1.54e-05, + "source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus2'%20and%20priceType%20eq%20'Consumption'", "supported_endpoints": [ "/v1/chat/completions", "/v1/batch", @@ -9635,8 +9659,10 @@ "azure/us/gpt-4.1-mini-2025-04-14": { "deprecation_date": "2027-04-14", "cache_read_input_token_cost": 1.1e-07, + "cache_read_input_token_cost_priority": 1.93e-07, "input_cost_per_token": 4.4e-07, "input_cost_per_token_batches": 2.2e-07, + "input_cost_per_token_priority": 7.7e-07, "litellm_provider": "azure", "max_input_tokens": 1047576, "max_output_tokens": 32768, @@ -9644,6 +9670,8 @@ "mode": "chat", "output_cost_per_token": 1.76e-06, "output_cost_per_token_batches": 8.8e-07, + "output_cost_per_token_priority": 3.08e-06, + "source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus2'%20and%20priceType%20eq%20'Consumption'", "supported_endpoints": [ "/v1/chat/completions", "/v1/batch", @@ -9668,9 +9696,9 @@ }, "azure/us/gpt-4.1-nano-2025-04-14": { "deprecation_date": "2027-04-14", - "cache_read_input_token_cost": 2.5e-08, + "cache_read_input_token_cost": 2.8e-08, "input_cost_per_token": 1.1e-07, - "input_cost_per_token_batches": 6e-08, + "input_cost_per_token_batches": 5.5e-08, "litellm_provider": "azure", "max_input_tokens": 1047576, "max_output_tokens": 32768, @@ -9678,6 +9706,7 @@ "mode": "chat", "output_cost_per_token": 4.4e-07, "output_cost_per_token_batches": 2.2e-07, + "source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus2'%20and%20priceType%20eq%20'Consumption'", "supported_endpoints": [ "/v1/chat/completions", "/v1/batch", @@ -9825,14 +9854,20 @@ }, "azure/us/gpt-5-2025-08-07": { "cache_read_input_token_cost": 1.375e-07, + "cache_read_input_token_cost_priority": 2.75e-07, "deprecation_date": "2027-02-09", "input_cost_per_token": 1.375e-06, + "input_cost_per_token_batches": 6.875e-07, + "input_cost_per_token_priority": 2.75e-06, "litellm_provider": "azure", "max_input_tokens": 272000, "max_output_tokens": 128000, "max_tokens": 128000, "mode": "chat", "output_cost_per_token": 1.1e-05, + "output_cost_per_token_batches": 5.5e-06, + "output_cost_per_token_priority": 2.2e-05, + "source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus2'%20and%20priceType%20eq%20'Consumption'", "supported_endpoints": [ "/v1/chat/completions", "/v1/batch", @@ -9858,14 +9893,20 @@ }, "azure/us/gpt-5-mini-2025-08-07": { "cache_read_input_token_cost": 2.75e-08, + "cache_read_input_token_cost_priority": 4.95e-08, "deprecation_date": "2027-02-09", "input_cost_per_token": 2.75e-07, + "input_cost_per_token_batches": 1.375e-07, + "input_cost_per_token_priority": 4.95e-07, "litellm_provider": "azure", "max_input_tokens": 272000, "max_output_tokens": 128000, "max_tokens": 128000, "mode": "chat", "output_cost_per_token": 2.2e-06, + "output_cost_per_token_batches": 1.1e-06, + "output_cost_per_token_priority": 3.96e-06, + "source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus2'%20and%20priceType%20eq%20'Consumption'", "supported_endpoints": [ "/v1/chat/completions", "/v1/batch", @@ -9893,12 +9934,15 @@ "cache_read_input_token_cost": 5.5e-09, "deprecation_date": "2027-02-09", "input_cost_per_token": 5.5e-08, + "input_cost_per_token_batches": 2.75e-08, "litellm_provider": "azure", "max_input_tokens": 272000, "max_output_tokens": 128000, "max_tokens": 128000, "mode": "chat", "output_cost_per_token": 4.4e-07, + "output_cost_per_token_batches": 2.2e-07, + "source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus2'%20and%20priceType%20eq%20'Consumption'", "supported_endpoints": [ "/v1/chat/completions", "/v1/batch", @@ -10095,6 +10139,7 @@ "mode": "chat", "output_cost_per_token": 4.84e-06, "output_cost_per_token_batches": 2.42e-06, + "source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus2'%20and%20priceType%20eq%20'Consumption'", "supports_function_calling": true, "supports_parallel_function_calling": true, "supports_prompt_caching": true, @@ -10109,6 +10154,7 @@ "max_tokens": 32768, "mode": "chat", "output_cost_per_token": 6.6e-05, + "source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus2'%20and%20priceType%20eq%20'Consumption'", "supports_function_calling": true, "supports_parallel_function_calling": true, "supports_prompt_caching": true, diff --git a/model_prices_and_context_window.json b/model_prices_and_context_window.json index 1d863977e1e..372ce19e740 100644 --- a/model_prices_and_context_window.json +++ b/model_prices_and_context_window.json @@ -4296,14 +4296,20 @@ }, "azure/eu/gpt-5-2025-08-07": { "cache_read_input_token_cost": 1.375e-07, + "cache_read_input_token_cost_priority": 2.75e-07, "deprecation_date": "2027-02-09", "input_cost_per_token": 1.375e-06, + "input_cost_per_token_batches": 6.875e-07, + "input_cost_per_token_priority": 2.75e-06, "litellm_provider": "azure", "max_input_tokens": 272000, "max_output_tokens": 128000, "max_tokens": 128000, "mode": "chat", "output_cost_per_token": 1.1e-05, + "output_cost_per_token_batches": 5.5e-06, + "output_cost_per_token_priority": 2.2e-05, + "source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus2'%20and%20priceType%20eq%20'Consumption'", "supported_endpoints": [ "/v1/chat/completions", "/v1/batch", @@ -4329,14 +4335,20 @@ }, "azure/eu/gpt-5-mini-2025-08-07": { "cache_read_input_token_cost": 2.75e-08, + "cache_read_input_token_cost_priority": 4.95e-08, "deprecation_date": "2027-02-09", "input_cost_per_token": 2.75e-07, + "input_cost_per_token_batches": 1.375e-07, + "input_cost_per_token_priority": 4.95e-07, "litellm_provider": "azure", "max_input_tokens": 272000, "max_output_tokens": 128000, "max_tokens": 128000, "mode": "chat", "output_cost_per_token": 2.2e-06, + "output_cost_per_token_batches": 1.1e-06, + "output_cost_per_token_priority": 3.96e-06, + "source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus2'%20and%20priceType%20eq%20'Consumption'", "supported_endpoints": [ "/v1/chat/completions", "/v1/batch", @@ -4507,12 +4519,15 @@ "cache_read_input_token_cost": 5.5e-09, "deprecation_date": "2027-02-09", "input_cost_per_token": 5.5e-08, + "input_cost_per_token_batches": 2.75e-08, "litellm_provider": "azure", "max_input_tokens": 272000, "max_output_tokens": 128000, "max_tokens": 128000, "mode": "chat", "output_cost_per_token": 4.4e-07, + "output_cost_per_token_batches": 2.2e-07, + "source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus2'%20and%20priceType%20eq%20'Consumption'", "supported_endpoints": [ "/v1/chat/completions", "/v1/batch", @@ -4566,6 +4581,7 @@ "mode": "chat", "output_cost_per_token": 4.84e-06, "output_cost_per_token_batches": 2.42e-06, + "source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus2'%20and%20priceType%20eq%20'Consumption'", "supports_function_calling": true, "supports_parallel_function_calling": true, "supports_prompt_caching": true, @@ -4580,6 +4596,7 @@ "max_tokens": 32768, "mode": "chat", "output_cost_per_token": 6.6e-05, + "source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus2'%20and%20priceType%20eq%20'Consumption'", "supports_function_calling": true, "supports_parallel_function_calling": true, "supports_prompt_caching": true, @@ -9213,12 +9230,15 @@ "azure/o1-mini-2024-09-12": { "cache_read_input_token_cost": 5.5e-07, "input_cost_per_token": 1.1e-06, + "input_cost_per_token_batches": 5.5e-07, "litellm_provider": "azure", "max_input_tokens": 128000, "max_output_tokens": 65536, "max_tokens": 65536, "mode": "chat", "output_cost_per_token": 4.4e-06, + "output_cost_per_token_batches": 2.2e-06, + "source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus2'%20and%20priceType%20eq%20'Consumption'", "supports_function_calling": true, "supports_parallel_function_calling": true, "supports_prompt_caching": true, @@ -9601,8 +9621,10 @@ "azure/us/gpt-4.1-2025-04-14": { "deprecation_date": "2027-04-14", "cache_read_input_token_cost": 5.5e-07, + "cache_read_input_token_cost_priority": 9.63e-07, "input_cost_per_token": 2.2e-06, "input_cost_per_token_batches": 1.1e-06, + "input_cost_per_token_priority": 3.85e-06, "litellm_provider": "azure", "max_input_tokens": 1047576, "max_output_tokens": 32768, @@ -9610,6 +9632,8 @@ "mode": "chat", "output_cost_per_token": 8.8e-06, "output_cost_per_token_batches": 4.4e-06, + "output_cost_per_token_priority": 1.54e-05, + "source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus2'%20and%20priceType%20eq%20'Consumption'", "supported_endpoints": [ "/v1/chat/completions", "/v1/batch", @@ -9635,8 +9659,10 @@ "azure/us/gpt-4.1-mini-2025-04-14": { "deprecation_date": "2027-04-14", "cache_read_input_token_cost": 1.1e-07, + "cache_read_input_token_cost_priority": 1.93e-07, "input_cost_per_token": 4.4e-07, "input_cost_per_token_batches": 2.2e-07, + "input_cost_per_token_priority": 7.7e-07, "litellm_provider": "azure", "max_input_tokens": 1047576, "max_output_tokens": 32768, @@ -9644,6 +9670,8 @@ "mode": "chat", "output_cost_per_token": 1.76e-06, "output_cost_per_token_batches": 8.8e-07, + "output_cost_per_token_priority": 3.08e-06, + "source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus2'%20and%20priceType%20eq%20'Consumption'", "supported_endpoints": [ "/v1/chat/completions", "/v1/batch", @@ -9668,9 +9696,9 @@ }, "azure/us/gpt-4.1-nano-2025-04-14": { "deprecation_date": "2027-04-14", - "cache_read_input_token_cost": 2.5e-08, + "cache_read_input_token_cost": 2.8e-08, "input_cost_per_token": 1.1e-07, - "input_cost_per_token_batches": 6e-08, + "input_cost_per_token_batches": 5.5e-08, "litellm_provider": "azure", "max_input_tokens": 1047576, "max_output_tokens": 32768, @@ -9678,6 +9706,7 @@ "mode": "chat", "output_cost_per_token": 4.4e-07, "output_cost_per_token_batches": 2.2e-07, + "source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus2'%20and%20priceType%20eq%20'Consumption'", "supported_endpoints": [ "/v1/chat/completions", "/v1/batch", @@ -9825,14 +9854,20 @@ }, "azure/us/gpt-5-2025-08-07": { "cache_read_input_token_cost": 1.375e-07, + "cache_read_input_token_cost_priority": 2.75e-07, "deprecation_date": "2027-02-09", "input_cost_per_token": 1.375e-06, + "input_cost_per_token_batches": 6.875e-07, + "input_cost_per_token_priority": 2.75e-06, "litellm_provider": "azure", "max_input_tokens": 272000, "max_output_tokens": 128000, "max_tokens": 128000, "mode": "chat", "output_cost_per_token": 1.1e-05, + "output_cost_per_token_batches": 5.5e-06, + "output_cost_per_token_priority": 2.2e-05, + "source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus2'%20and%20priceType%20eq%20'Consumption'", "supported_endpoints": [ "/v1/chat/completions", "/v1/batch", @@ -9858,14 +9893,20 @@ }, "azure/us/gpt-5-mini-2025-08-07": { "cache_read_input_token_cost": 2.75e-08, + "cache_read_input_token_cost_priority": 4.95e-08, "deprecation_date": "2027-02-09", "input_cost_per_token": 2.75e-07, + "input_cost_per_token_batches": 1.375e-07, + "input_cost_per_token_priority": 4.95e-07, "litellm_provider": "azure", "max_input_tokens": 272000, "max_output_tokens": 128000, "max_tokens": 128000, "mode": "chat", "output_cost_per_token": 2.2e-06, + "output_cost_per_token_batches": 1.1e-06, + "output_cost_per_token_priority": 3.96e-06, + "source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus2'%20and%20priceType%20eq%20'Consumption'", "supported_endpoints": [ "/v1/chat/completions", "/v1/batch", @@ -9893,12 +9934,15 @@ "cache_read_input_token_cost": 5.5e-09, "deprecation_date": "2027-02-09", "input_cost_per_token": 5.5e-08, + "input_cost_per_token_batches": 2.75e-08, "litellm_provider": "azure", "max_input_tokens": 272000, "max_output_tokens": 128000, "max_tokens": 128000, "mode": "chat", "output_cost_per_token": 4.4e-07, + "output_cost_per_token_batches": 2.2e-07, + "source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus2'%20and%20priceType%20eq%20'Consumption'", "supported_endpoints": [ "/v1/chat/completions", "/v1/batch", @@ -10095,6 +10139,7 @@ "mode": "chat", "output_cost_per_token": 4.84e-06, "output_cost_per_token_batches": 2.42e-06, + "source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus2'%20and%20priceType%20eq%20'Consumption'", "supports_function_calling": true, "supports_parallel_function_calling": true, "supports_prompt_caching": true, @@ -10109,6 +10154,7 @@ "max_tokens": 32768, "mode": "chat", "output_cost_per_token": 6.6e-05, + "source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus2'%20and%20priceType%20eq%20'Consumption'", "supports_function_calling": true, "supports_parallel_function_calling": true, "supports_prompt_caching": true, From 92e182b898a557849f374ba66af708999626806d Mon Sep 17 00:00:00 2001 From: Joshua Valluru <326636767+joshua-berri@users.noreply.github.com> Date: Tue, 15 Sep 2026 12:29:03 -0700 Subject: [PATCH 027/133] fix(mcp): persist OAuth credentials for validated JWT users --- .../mcp_server/bridge_token_flow.py | 56 ++++ .../mcp_server/discoverable_endpoints.py | 5 +- .../mcp_server/test_discoverable_endpoints.py | 277 +++++++++++++++++- 3 files changed, 335 insertions(+), 3 deletions(-) diff --git a/litellm/proxy/_experimental/mcp_server/bridge_token_flow.py b/litellm/proxy/_experimental/mcp_server/bridge_token_flow.py index 35a30127e27..8dcc49c2fd8 100644 --- a/litellm/proxy/_experimental/mcp_server/bridge_token_flow.py +++ b/litellm/proxy/_experimental/mcp_server/bridge_token_flow.py @@ -306,12 +306,68 @@ async def _extract_user_id_from_request(request: Request) -> str | None: (including a transient DB outage) collapses to ``None`` here and the caller simply skips the store; the bridge mint, which must status those outcomes differently, consumes :func:`_resolve_active_litellm_key` directly.""" + from litellm.proxy.auth.handle_jwt import JWTHandler # noqa: PLC0415 # proxy import cycle + + token: Final = _litellm_key_from_request(request) + if token is not None and JWTHandler.is_jwt(token): + return await _extract_jwt_user_id(token) resolved: Final = await _resolve_active_litellm_key(request) if not isinstance(resolved, _ResolvedKey): return None return _active_key_user_id(resolved.key) +async def _extract_jwt_user_id(token: str) -> str | None: + from litellm.proxy._types import LitellmUserRoles, UserAPIKeyAuth # noqa: PLC0415 # proxy import cycle + from litellm.proxy.auth.handle_jwt import JWTAuthManager # noqa: PLC0415 # proxy import cycle + from litellm.proxy.auth.user_api_key_auth import ( # noqa: PLC0415 # proxy import cycle + _resolve_jwt_to_virtual_key, # pyright: ignore[reportPrivateUsage] # reuse admission mapping policy without provisioning a new key + ) + from litellm.proxy.proxy_server import ( # noqa: PLC0415 # proxy globals initialized at startup + general_settings, + jwt_handler, + premium_user, + prisma_client, + proxy_logging_obj, + user_api_key_cache, + ) + + if general_settings.get("enable_jwt_auth") is not True or premium_user is not True: + return None + try: + claims: Final = await jwt_handler.auth_jwt(token=token) + validate: Final = jwt_handler.litellm_jwtauth.custom_validate + if validate is not None and not validate(claims): + return None + if jwt_handler.litellm_jwtauth.is_virtual_key_mapping_configured(): + mapped: Final = await _resolve_jwt_to_virtual_key( + jwt_claims=claims, + jwt_handler=jwt_handler, + prisma_client=prisma_client, + user_api_key_cache=user_api_key_cache, + parent_otel_span=None, + proxy_logging_obj=proxy_logging_obj, + ) + if isinstance(mapped, UserAPIKeyAuth): + return None if await _key_owner_scim_deactivated(mapped) else _active_key_user_id(mapped) + if mapped is not None: + return None + user_id, _, valid_email = await JWTAuthManager.get_user_info(jwt_handler, claims) + object_id: Final = jwt_handler.get_object_id(token=claims, default_value=None) + owner_id: Final = ( + object_id + if jwt_handler.get_rbac_role(token=claims) == LitellmUserRoles.INTERNAL_USER and object_id + else user_id + ) + if not owner_id or valid_email is False: + return None + owner: Final = await load_active_user_by_id(owner_id) + return None if isinstance(owner, str) else owner.user_id + except Exception as exc: # noqa: BLE001 # public OAuth exchange stays available; unvalidated identities never write credentials + verbose_logger.debug("OAuth JWT identity could not be validated (%s)", type(exc).__name__) + return None + + _UpstreamGrantRejection = Literal["no_access_token", "expired_lifetime"] """Why an upstream token response cannot back a bridge envelope: - ``no_access_token``: the response carries no usable ``access_token`` diff --git a/litellm/proxy/_experimental/mcp_server/discoverable_endpoints.py b/litellm/proxy/_experimental/mcp_server/discoverable_endpoints.py index bafe33d0a6b..94b6348b0f0 100644 --- a/litellm/proxy/_experimental/mcp_server/discoverable_endpoints.py +++ b/litellm/proxy/_experimental/mcp_server/discoverable_endpoints.py @@ -1236,8 +1236,9 @@ async def exchange_token_with_server( "exchange_token_with_server: could not resolve a LiteLLM user_id for the request, " "so the per-user token for server=%s was NOT stored. The authorization_code egress " "requires the stored token, so the client will be challenged with 401 on reconnect. " - "Ensure the request carries a valid LiteLLM key (x-litellm-api-key or Authorization), " - "or store it via POST /mcp/server/{id}/oauth-user-credential.", + "Ensure the request carries a valid LiteLLM key or enabled JWT identity " + "(x-litellm-api-key or Authorization), " + "or store it via POST /v1/mcp/server/{id}/oauth-user-credential.", resolved_server.server_id, ) diff --git a/tests/test_litellm/proxy/_experimental/mcp_server/test_discoverable_endpoints.py b/tests/test_litellm/proxy/_experimental/mcp_server/test_discoverable_endpoints.py index 9ea870d3210..af4c9eea770 100644 --- a/tests/test_litellm/proxy/_experimental/mcp_server/test_discoverable_endpoints.py +++ b/tests/test_litellm/proxy/_experimental/mcp_server/test_discoverable_endpoints.py @@ -5,7 +5,7 @@ import json import time from base64 import urlsafe_b64encode from datetime import datetime, timedelta, timezone -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, Final from unittest.mock import AsyncMock, MagicMock, patch import pytest @@ -15,6 +15,9 @@ from litellm.types.mcp import MCPAuth if TYPE_CHECKING: import httpx + from cryptography.hazmat.primitives.asymmetric.rsa import RSAPrivateKey + + from litellm.proxy.auth.handle_jwt import JWTHandler from litellm.types.mcp_server.mcp_server_manager import MCPServer @@ -11374,3 +11377,275 @@ with TestClient(app) as client: assert responses[path]["status"] == 200, responses[path] assert responses[path]["body"]["issuer"] == f"http://testserver/gateway/{path}" assert responses["example/mcp"]["body"]["token_endpoint"] == "http://testserver/gateway/example/token" + + +@pytest.fixture +def jwt_oauth_identity(monkeypatch: pytest.MonkeyPatch) -> tuple["JWTHandler", "RSAPrivateKey"]: + import jwt + from cryptography.hazmat.primitives.asymmetric import rsa + + from litellm.models.user import LiteLLM_UserTable + from litellm.proxy import proxy_server + from litellm.proxy._types import LiteLLM_JWTAuth + from litellm.proxy.auth.handle_jwt import JWTHandler + from litellm.proxy.common_utils.user_api_key_cache import UserApiKeyCache + + signing_key: Final = rsa.generate_private_key(public_exponent=65537, key_size=2048) + cache: Final = UserApiKeyCache() + cache.set_cache( + "litellm_jwt_auth_keys_https://idp.example.test/jwks", + [json.loads(jwt.algorithms.RSAAlgorithm.to_jwk(signing_key.public_key()))], + ) + cache.set_cache("jwt-owner", LiteLLM_UserTable(user_id="jwt-owner", user_email="owner@example.test")) + handler: Final = JWTHandler() + handler.update_environment( + prisma_client=None, + user_api_key_cache=cache, + litellm_jwtauth=LiteLLM_JWTAuth(user_id_jwt_field="identity.user_id"), + ) + monkeypatch.setenv("JWT_PUBLIC_KEY_URL", "https://idp.example.test/jwks") + monkeypatch.setenv("JWT_ISSUER", "https://idp.example.test") + monkeypatch.setenv("JWT_AUDIENCE", "litellm-proxy") + monkeypatch.setattr(proxy_server, "jwt_handler", handler) + monkeypatch.setattr(proxy_server, "general_settings", {"enable_jwt_auth": True}) + monkeypatch.setattr(proxy_server, "premium_user", True) + monkeypatch.setattr(proxy_server, "user_api_key_cache", cache) + monkeypatch.setattr(proxy_server, "prisma_client", MagicMock()) + return handler, signing_key + + +def _oauth_identity_jwt( + signing_key: "RSAPrivateKey", + *, + expires_in: int = 300, + audience: str = "litellm-proxy", + issuer: str = "https://idp.example.test", + owner: str | None = "jwt-owner", +) -> str: + import jwt + + return jwt.encode( + { + "sub": "not-the-configured-user-id", + "identity": {"user_id": owner}, + "iss": issuer, + "aud": audience, + "exp": int(time.time()) + expires_in, + }, + signing_key, + algorithm="RS256", + ) + + +@pytest.mark.asyncio +@pytest.mark.parametrize("header", ["Authorization", "x-litellm-api-key"]) +async def test_oauth_exchange_stores_token_for_validated_jwt_user( + jwt_oauth_identity: tuple["JWTHandler", "RSAPrivateKey"], + header: str, + monkeypatch: pytest.MonkeyPatch, +) -> None: + import httpx + + from litellm.proxy._experimental.mcp_server import discoverable_endpoints + from litellm.proxy._types import MCPTransport + from litellm.types.mcp_server.mcp_server_manager import MCPServer + + _, signing_key = jwt_oauth_identity + bearer: Final = _oauth_identity_jwt(signing_key) + request: Final = _token_request({header: f"Bearer {bearer}"}) + server: Final = MCPServer( + server_id="jwt-oauth-server", + name="jwt-oauth-server", + transport=MCPTransport.http, + auth_type=MCPAuth.oauth2, + oauth2_flow="authorization_code", + authorization_url="https://upstream.example.test/authorize", + token_url="https://upstream.example.test/token", + client_id="registered-client", + ) + import litellm + from litellm.caching.llm_caching_handler import LLMClientCache + from litellm.llms.custom_httpx.http_handler import AsyncHTTPHandler + from litellm.proxy import proxy_server + from litellm.proxy.common_utils.encrypt_decrypt_utils import decrypt_value_helper + from litellm.types.llms.custom_http import httpxSpecialProvider + + def upstream_response(outbound: httpx.Request) -> httpx.Response: + assert outbound.url == server.token_url + assert bearer not in str(outbound.headers) + assert bearer.encode() not in outbound.content + return httpx.Response(200, json={"access_token": "upstream-token", "token_type": "Bearer"}) + + database: Final = MagicMock() + table: Final = database.db.litellm_mcpusercredentials + table.find_unique = AsyncMock(return_value=None) + table.upsert = AsyncMock() + monkeypatch.setattr(proxy_server, "prisma_client", database) + monkeypatch.setenv("LITELLM_SALT_KEY", "oauth-jwt-test-encryption-key") + clients: Final = LLMClientCache() + monkeypatch.setattr(litellm, "in_memory_llm_clients_cache", clients) + async with httpx.AsyncClient(transport=httpx.MockTransport(upstream_response)) as transport: + upstream: Final = AsyncHTTPHandler() + await upstream.client.aclose() + upstream.client = transport + clients.set_cache("async_httpx_client" + httpxSpecialProvider.Oauth2Check, upstream) + response: Final = await discoverable_endpoints.exchange_token_with_server( + request=request, + mcp_server=server, + grant_type="authorization_code", + code="upstream-code", + redirect_uri="http://localhost/callback", + client_id="registered-client", + client_secret=None, + code_verifier=None, + ) + assert response.status_code == 200 + table.upsert.assert_awaited_once() + stored: Final = table.upsert.call_args.kwargs + assert stored["where"] == {"user_id_server_id": {"user_id": "jwt-owner", "server_id": server.server_id}} + credential: Final = stored["data"]["create"]["credential_b64"] + assert "upstream-token" not in credential + decoded: Final = decrypt_value_helper(credential, key="mcp_user_credential") + assert json.loads(decoded)["access_token"] == "upstream-token" + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + "rejection", + [ + "expired", + "audience", + "issuer", + "signature", + "missing_user", + "unknown_user", + "disabled", + "not_premium", + "scim_inactive", + "custom_validate", + "missing_database", + ], +) +async def test_oauth_jwt_identity_rejects_untrusted_or_inactive_owner( + jwt_oauth_identity: tuple["JWTHandler", "RSAPrivateKey"], + monkeypatch: pytest.MonkeyPatch, + rejection: str, +) -> None: + from cryptography.hazmat.primitives.asymmetric import rsa + + from litellm.models.user import LiteLLM_UserTable + from litellm.proxy import proxy_server + from litellm.proxy._experimental.mcp_server.bridge_token_flow import _extract_user_id_from_request + + handler, signing_key = jwt_oauth_identity + key: Final = ( + rsa.generate_private_key(public_exponent=65537, key_size=2048) if rejection == "signature" else signing_key + ) + bearer: Final = _oauth_identity_jwt( + key, + expires_in=-60 if rejection == "expired" else 300, + audience="upstream-only" if rejection == "audience" else "litellm-proxy", + issuer="https://untrusted.example.test" if rejection == "issuer" else "https://idp.example.test", + owner=None if rejection == "missing_user" else "unknown" if rejection == "unknown_user" else "jwt-owner", + ) + if rejection == "disabled": + monkeypatch.setattr(proxy_server, "general_settings", {"enable_jwt_auth": False}) + if rejection == "not_premium": + monkeypatch.setattr(proxy_server, "premium_user", False) + if rejection == "missing_database": + monkeypatch.setattr(proxy_server, "prisma_client", None) + if rejection == "scim_inactive": + handler.user_api_key_cache.set_cache( + "jwt-owner", LiteLLM_UserTable(user_id="jwt-owner", metadata={"scim_active": False}) + ) + if rejection == "custom_validate": + handler.litellm_jwtauth.custom_validate = lambda claims: False + assert await _extract_user_id_from_request(_token_request({"Authorization": f"Bearer {bearer}"})) is None + + +@pytest.mark.asyncio +@pytest.mark.parametrize("blocked", [False, True]) +async def test_oauth_jwt_cannot_override_explicit_litellm_key( + jwt_oauth_identity: tuple["JWTHandler", "RSAPrivateKey"], + blocked: bool, +) -> None: + from litellm.proxy._experimental.mcp_server.bridge_token_flow import _extract_user_id_from_request + from litellm.proxy._types import UserAPIKeyAuth, hash_token + + handler, signing_key = jwt_oauth_identity + key: Final = "sk-explicit-key" + handler.user_api_key_cache.set_cache(hash_token(key), UserAPIKeyAuth(user_id="key-owner", blocked=blocked)) + request: Final = _token_request( + { + "Authorization": f"Bearer {_oauth_identity_jwt(signing_key)}", + "x-litellm-api-key": key, + } + ) + assert await _extract_user_id_from_request(request) == (None if blocked else "key-owner") + + +@pytest.mark.asyncio +@pytest.mark.parametrize("mapping", ["active", "blocked", "inactive_owner", "fallback", "pending", "reject"]) +async def test_oauth_jwt_uses_configured_virtual_key_owner( + jwt_oauth_identity: tuple["JWTHandler", "RSAPrivateKey"], + mapping: str, +) -> None: + from litellm.models.user import LiteLLM_UserTable + from litellm.proxy._experimental.mcp_server.bridge_token_flow import _extract_user_id_from_request + from litellm.proxy._types import UserAPIKeyAuth, UnregisteredJWTClientBehavior, hash_token + from litellm.proxy.auth.auth_checks import jwt_key_mapping_cache_key + + handler, signing_key = jwt_oauth_identity + handler.litellm_jwtauth.virtual_key_claim_field = "sub" + handler.litellm_jwtauth.unregistered_jwt_client_behavior = ( + UnregisteredJWTClientBehavior.AUTO_REGISTER + if mapping == "pending" + else UnregisteredJWTClientBehavior.REJECT + if mapping == "reject" + else UnregisteredJWTClientBehavior.FALLBACK_TEAM_MAPPING + ) + key_hash: Final = hash_token("sk-mapped-oauth-owner") + handler.user_api_key_cache.set_cache( + jwt_key_mapping_cache_key("sub", "not-the-configured-user-id"), + "__NO_MAPPING__" if mapping in ("fallback", "pending", "reject") else key_hash, + ) + handler.user_api_key_cache.set_cache( + key_hash, UserAPIKeyAuth(token=key_hash, user_id="mapped-owner", blocked=mapping == "blocked") + ) + handler.user_api_key_cache.set_cache( + "mapped-owner", LiteLLM_UserTable(user_id="mapped-owner", metadata={"scim_active": mapping != "inactive_owner"}) + ) + request: Final = _token_request({"Authorization": f"Bearer {_oauth_identity_jwt(signing_key)}"}) + expected: Final = "jwt-owner" if mapping == "fallback" else "mapped-owner" if mapping == "active" else None + assert await _extract_user_id_from_request(request) == expected + + +@pytest.mark.asyncio +@pytest.mark.parametrize("allowed_domain", [None, "allowed.example.test"]) +async def test_oauth_jwt_respects_custom_validation_and_email_policy( + jwt_oauth_identity: tuple["JWTHandler", "RSAPrivateKey"], + allowed_domain: str | None, +) -> None: + from litellm.proxy._experimental.mcp_server.bridge_token_flow import _extract_user_id_from_request + + handler, signing_key = jwt_oauth_identity + handler.litellm_jwtauth.custom_validate = lambda claims: True + handler.litellm_jwtauth.user_allowed_email_domain = allowed_domain + request: Final = _token_request({"Authorization": f"Bearer {_oauth_identity_jwt(signing_key)}"}) + assert await _extract_user_id_from_request(request) == (None if allowed_domain else "jwt-owner") + + +@pytest.mark.asyncio +async def test_oauth_jwt_uses_rbac_user_object_id(jwt_oauth_identity: tuple["JWTHandler", "RSAPrivateKey"]) -> None: + from litellm.proxy._experimental.mcp_server.bridge_token_flow import _extract_user_id_from_request + from litellm.proxy._types import LitellmUserRoles, RoleMapping + + handler, signing_key = jwt_oauth_identity + handler.litellm_jwtauth.user_id_jwt_field = "sub" + handler.litellm_jwtauth.roles_jwt_field = "aud" + handler.litellm_jwtauth.object_id_jwt_field = "identity.user_id" + handler.litellm_jwtauth.role_mappings = [ + RoleMapping(role="litellm-proxy", internal_role=LitellmUserRoles.INTERNAL_USER) + ] + request: Final = _token_request({"Authorization": f"Bearer {_oauth_identity_jwt(signing_key)}"}) + assert await _extract_user_id_from_request(request) == "jwt-owner" From eda98f38d940ecd065aee71d4b0b34ba1be98a06 Mon Sep 17 00:00:00 2001 From: Joshua Valluru <326636767+joshua-berri@users.noreply.github.com> Date: Tue, 15 Sep 2026 12:41:12 -0700 Subject: [PATCH 028/133] fix(mcp): preserve canonical JWT owner lookup without cached identity --- .../mcp_server/bridge_token_flow.py | 10 ++++-- .../mcp_server/test_discoverable_endpoints.py | 36 +++++++++++++++++++ 2 files changed, 43 insertions(+), 3 deletions(-) diff --git a/litellm/proxy/_experimental/mcp_server/bridge_token_flow.py b/litellm/proxy/_experimental/mcp_server/bridge_token_flow.py index 8dcc49c2fd8..3817935bf71 100644 --- a/litellm/proxy/_experimental/mcp_server/bridge_token_flow.py +++ b/litellm/proxy/_experimental/mcp_server/bridge_token_flow.py @@ -198,7 +198,9 @@ async def _reload_active_user_by_id(user_id: str) -> "_KeyResolutionFailure | No return loaded if isinstance(loaded, str) else None -async def load_active_user_by_id(user_id: str) -> "LiteLLM_UserTable | _KeyResolutionFailure": +async def load_active_user_by_id( + user_id: str, *, sso_user_id: str | None = None, user_email: str | None = None +) -> "LiteLLM_UserTable | _KeyResolutionFailure": """Load a live litellm user by id, returning the record when the user is active or a precise failure otherwise. The interactive DCR client authenticates via SSO, so its refresh envelope seals a user subject; renewing it must re-check the user is still live (present and not SCIM-deactivated) so a @@ -232,6 +234,8 @@ async def load_active_user_by_id(user_id: str) -> "LiteLLM_UserTable | _KeyResol prisma_client=prisma_client, user_api_key_cache=user_api_key_cache, user_id_upsert=False, + sso_user_id=sso_user_id, + user_email=user_email, ) except (ProxyException, HTTPException): return "no_active_key" @@ -352,7 +356,7 @@ async def _extract_jwt_user_id(token: str) -> str | None: return None if await _key_owner_scim_deactivated(mapped) else _active_key_user_id(mapped) if mapped is not None: return None - user_id, _, valid_email = await JWTAuthManager.get_user_info(jwt_handler, claims) + user_id, user_email, valid_email = await JWTAuthManager.get_user_info(jwt_handler, claims) object_id: Final = jwt_handler.get_object_id(token=claims, default_value=None) owner_id: Final = ( object_id @@ -361,7 +365,7 @@ async def _extract_jwt_user_id(token: str) -> str | None: ) if not owner_id or valid_email is False: return None - owner: Final = await load_active_user_by_id(owner_id) + owner: Final = await load_active_user_by_id(owner_id, sso_user_id=owner_id, user_email=user_email) return None if isinstance(owner, str) else owner.user_id except Exception as exc: # noqa: BLE001 # public OAuth exchange stays available; unvalidated identities never write credentials verbose_logger.debug("OAuth JWT identity could not be validated (%s)", type(exc).__name__) diff --git a/tests/test_litellm/proxy/_experimental/mcp_server/test_discoverable_endpoints.py b/tests/test_litellm/proxy/_experimental/mcp_server/test_discoverable_endpoints.py index af4c9eea770..ce985064eb0 100644 --- a/tests/test_litellm/proxy/_experimental/mcp_server/test_discoverable_endpoints.py +++ b/tests/test_litellm/proxy/_experimental/mcp_server/test_discoverable_endpoints.py @@ -11428,6 +11428,7 @@ def _oauth_identity_jwt( { "sub": "not-the-configured-user-id", "identity": {"user_id": owner}, + "email": "owner@example.test", "iss": issuer, "aud": audience, "exp": int(time.time()) + expires_in, @@ -11649,3 +11650,38 @@ async def test_oauth_jwt_uses_rbac_user_object_id(jwt_oauth_identity: tuple["JWT ] request: Final = _token_request({"Authorization": f"Bearer {_oauth_identity_jwt(signing_key)}"}) assert await _extract_user_id_from_request(request) == "jwt-owner" + + +@pytest.mark.asyncio +@pytest.mark.parametrize("identity", ["sso", "email"]) +@pytest.mark.parametrize("inactive", [False, True]) +async def test_oauth_jwt_resolves_canonical_owner_without_cached_identity( + jwt_oauth_identity: tuple["JWTHandler", "RSAPrivateKey"], + monkeypatch: pytest.MonkeyPatch, + identity: str, + inactive: bool, +) -> None: + from litellm.models.user import LiteLLM_UserTable + from litellm.proxy import proxy_server + from litellm.proxy._experimental.mcp_server.bridge_token_flow import _extract_user_id_from_request + + handler, signing_key = jwt_oauth_identity + external_id: Final = f"external-{identity}-{inactive}" + handler.litellm_jwtauth.user_email_jwt_field = "email" + owner: Final = LiteLLM_UserTable( + user_id="canonical-oauth-owner", + user_email="owner@example.test", + metadata={"scim_active": not inactive}, + organization_memberships=[], + ) + database: Final = MagicMock() + table: Final = database.db.litellm_usertable + table.find_unique = AsyncMock(side_effect=[None, owner if identity == "sso" else None]) + table.find_first = AsyncMock(return_value=owner) + table.update = AsyncMock(return_value=owner) + monkeypatch.setattr(proxy_server, "prisma_client", database) + request: Final = _token_request({"Authorization": f"Bearer {_oauth_identity_jwt(signing_key, owner=external_id)}"}) + assert await _extract_user_id_from_request(request) == (None if inactive else "canonical-oauth-owner") + assert table.find_unique.await_count == 2 + if identity == "email": + table.find_first.assert_awaited_once() From 31052629d6626d9f2e605b2d46ff71ff808d16b4 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Tue, 15 Sep 2026 19:46:30 +0000 Subject: [PATCH 029/133] ci: rerun checks after docs for LITELLM_HTTP2 merged Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> From 168b5bc4fbaaeae00ae88d8173f55944a12ef84e Mon Sep 17 00:00:00 2001 From: yassin Date: Tue, 15 Sep 2026 22:05:08 +0000 Subject: [PATCH 030/133] feat(proxy): configurable client-facing model access denied message Add litellm_settings.model_access_denied_message, a template ({model} placeholder) returned to clients instead of the detailed "can only access models=[...]" text on key/team/user/org/project and team-member model access denials. The full denial reason is still written to the proxy logs at WARNING. Unset keeps the existing detailed message, status codes and error types are unchanged. Expose the new setting and the existing expose_router_debug_in_errors flag in the Admin UI general settings (String editor, Boolean toggle with an explicit True default) and allow both as safe DB overrides so they persist and propagate across workers. Resolves LIT-5283 Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- litellm/__init__.py | 1 + litellm/constants.py | 3 + litellm/proxy/auth/auth_checks.py | 24 ++++- litellm/proxy/proxy_server.py | 31 +++++- .../proxy/auth/test_auth_checks.py | 94 +++++++++++++++++++ tests/test_litellm/proxy/test_proxy_server.py | 66 +++++++++++++ .../general_settings.integration.test.tsx | 33 ++++++- .../_components/general_settings.tsx | 14 ++- 8 files changed, 259 insertions(+), 7 deletions(-) diff --git a/litellm/__init__.py b/litellm/__init__.py index ccfbf80369f..97457f3e3cc 100644 --- a/litellm/__init__.py +++ b/litellm/__init__.py @@ -219,6 +219,7 @@ redact_user_api_key_info: Optional[bool] = False # major release; opt in early with `litellm.expose_router_debug_in_errors # = False`. expose_router_debug_in_errors: bool = True +model_access_denied_message: str | None = None filter_invalid_headers: Optional[bool] = False add_user_information_to_llm_headers: Optional[bool] = ( None # adds user_id, team_id, token hash (params from StandardLoggingMetadata) to request headers diff --git a/litellm/constants.py b/litellm/constants.py index ba5ec73d435..9a9bb59d8b2 100644 --- a/litellm/constants.py +++ b/litellm/constants.py @@ -98,6 +98,7 @@ BASE64_TRUNCATION_OFFLOAD_THRESHOLD_CHARS: Final = 256 * 1024 REDACTED_BY_LITELLM: Final = "redacted-by-litellm" # in-memory stand-in handed to provider converters for redacted arguments; never stored REDACTED_TOOL_CALL_ARGUMENTS_PLACEHOLDER: Final = "{}" +MODEL_ACCESS_DENIED_MESSAGE_MODEL_PLACEHOLDER: Final = "{model}" MAX_STRING_LENGTH_STDOUT_LOG: Final = get_env_int("MAX_STRING_LENGTH_STDOUT_LOG", 4096) @@ -1796,6 +1797,8 @@ LITELLM_SETTINGS_SAFE_DB_OVERRIDES: Final = [ "max_ui_session_budget", "budget_rollover", "mcp_tool_search", + "model_access_denied_message", + "expose_router_debug_in_errors", ] SPECIAL_LITELLM_AUTH_TOKEN: Final = ["ui-token"] DEFAULT_MANAGEMENT_OBJECT_IN_MEMORY_CACHE_TTL = int(os.getenv("DEFAULT_MANAGEMENT_OBJECT_IN_MEMORY_CACHE_TTL", 60)) diff --git a/litellm/proxy/auth/auth_checks.py b/litellm/proxy/auth/auth_checks.py index 355fc3f6a21..6435ce0d12f 100644 --- a/litellm/proxy/auth/auth_checks.py +++ b/litellm/proxy/auth/auth_checks.py @@ -32,6 +32,7 @@ from litellm.constants import ( DEFAULT_MAX_RECURSE_DEPTH, EMAIL_BUDGET_ALERT_MAX_SPEND_ALERT_PERCENTAGE, END_USER_RESTRICTED_REGISTRY_MAX_SIZE, + MODEL_ACCESS_DENIED_MESSAGE_MODEL_PLACEHOLDER, MODEL_ACCESS_GROUP_REGISTRY_MAX_SIZE, REGISTRY_ERROR_NEGATIVE_CACHE_TTL, TAG_REGISTRY_MAX_SIZE, @@ -4033,6 +4034,14 @@ async def _get_agent_ids_from_access_groups( ) +def _client_facing_model_access_denied_message(internal_message: str, model: str | list[str]) -> str: + template: Final = litellm.model_access_denied_message + if not template: + return internal_message + verbose_proxy_logger.warning(internal_message) + return template.replace(MODEL_ACCESS_DENIED_MESSAGE_MODEL_PLACEHOLDER, str(model)) + + def _resolve_all_team_model_sentinel_for_auth_check( models: list[str], llm_router: Router | None, @@ -4155,7 +4164,10 @@ def _can_object_call_model( return True raise ProxyException( - message=f"{object_type} not allowed to access model. This {object_type} can only access models={models}. Tried to access {model}", + message=_client_facing_model_access_denied_message( + internal_message=f"{object_type} not allowed to access model. This {object_type} can only access models={models}. Tried to access {model}", + model=model, + ), type=ProxyErrorTypes.get_model_access_error_type_for_object(object_type=object_type), param="model", code=status.HTTP_403_FORBIDDEN, @@ -4781,7 +4793,10 @@ async def can_user_call_model( if SpecialModelNames.no_default_models.value in user_object.models: raise ProxyException( - message=f"User not allowed to access model. No default model access, only team models allowed. Tried to access {model}", + message=_client_facing_model_access_denied_message( + internal_message=f"User not allowed to access model. No default model access, only team models allowed. Tried to access {model}", + model=model, + ), type=ProxyErrorTypes.key_model_access_denied, param="model", code=status.HTTP_403_FORBIDDEN, @@ -5383,7 +5398,10 @@ async def _check_team_member_model_access( ) except ProxyException: raise ProxyException( - message=f"Team member not allowed to access model. User={valid_token.user_id}, Team={team_object.team_id}, Model={model}. Allowed member models = {member_allowed_models}", + message=_client_facing_model_access_denied_message( + internal_message=f"Team member not allowed to access model. User={valid_token.user_id}, Team={team_object.team_id}, Model={model}. Allowed member models = {member_allowed_models}", + model=model, + ), type=ProxyErrorTypes.team_model_access_denied, param="model", code=status.HTTP_403_FORBIDDEN, diff --git a/litellm/proxy/proxy_server.py b/litellm/proxy/proxy_server.py index f63e088ebf7..f758628c02a 100644 --- a/litellm/proxy/proxy_server.py +++ b/litellm/proxy/proxy_server.py @@ -17435,11 +17435,13 @@ GeneralSettingsUILiteLLMValue = float | bool | str | None class GeneralSettingsUILiteLLMFieldSpec(TypedDict): - type: Literal["Float", "Dollar", "Boolean", "Select"] + type: Literal["Float", "Dollar", "Boolean", "Select", "String"] description: str options: NotRequired[tuple[str, ...]] tab: NotRequired[str] # Admin UI sub-tab this field renders under; None groups it with the rest - default: NotRequired[float] # reset/clear restores this instead of None; fields whose None means fail-open set it + default: NotRequired[ + float | bool + ] # reset/clear restores this instead of None; fields whose None means fail-open set it _GENERAL_SETTINGS_UI_LITELLM_FIELDS: Final[dict[str, GeneralSettingsUILiteLLMFieldSpec]] = { @@ -17481,6 +17483,24 @@ _GENERAL_SETTINGS_UI_LITELLM_FIELDS: Final[dict[str, GeneralSettingsUILiteLLMFie "with this budget. Clearing restores the $1 default." ), }, + "model_access_denied_message": { + "type": "String", + "description": ( + "Client-facing error message returned when a key, team, user, org or project is not allowed " + "to call the requested model. {model} is replaced with the requested model name. The full " + "denial reason (allowed models and access groups) is still written to the proxy logs. " + "Leave empty to return the detailed message to clients." + ), + }, + "expose_router_debug_in_errors": { + "type": "Boolean", + "default": True, + "description": ( + "Append router debug details (model group, configured fallbacks, fallback errors, cooldown " + "info) to error messages returned to clients. Turn off to keep those details in the proxy " + "logs only." + ), + }, } @@ -17528,6 +17548,13 @@ def _validate_general_settings_ui_litellm_value(field_name: str, value: object) detail={"error": f"{field_name} must be a positive dollar amount or empty"}, ) return float(value) + case "String": + if not isinstance(value, str): + raise HTTPException( + status_code=400, + detail={"error": f"{field_name} must be a string or empty"}, + ) + return value case _: assert_never(field_type) diff --git a/tests/test_litellm/proxy/auth/test_auth_checks.py b/tests/test_litellm/proxy/auth/test_auth_checks.py index 8c8b755195f..e99e92fcd02 100644 --- a/tests/test_litellm/proxy/auth/test_auth_checks.py +++ b/tests/test_litellm/proxy/auth/test_auth_checks.py @@ -1679,6 +1679,100 @@ def test_can_object_call_model_no_access_to_alias_or_underlying(): assert "my-fake-gpt" in str(exc_info.value.message) +_DENIED_MESSAGE_TEMPLATE: Final = "The model `{model}` is unavailable for this API key or does not exist." + + +def test_can_object_call_model_denial_uses_configured_message_and_logs_detail(monkeypatch, caplog): + """LIT-5283: with model_access_denied_message set, the client sees only the template with + {model} filled in, while the allowed models / access groups stay in the proxy log.""" + monkeypatch.setattr(litellm, "model_access_denied_message", _DENIED_MESSAGE_TEMPLATE) + + with caplog.at_level("WARNING", logger="LiteLLM Proxy"): + with pytest.raises(ProxyException) as exc_info: + _can_object_call_model( + model="anthropic-sonnet-4-5", + llm_router=None, + models=["internal-models"], + object_type="key", + ) + + assert ( + exc_info.value.message == "The model `anthropic-sonnet-4-5` is unavailable for this API key or does not exist." + ) + assert exc_info.value.type == ProxyErrorTypes.key_model_access_denied + assert exc_info.value.param == "model" + assert int(exc_info.value.code) == status.HTTP_403_FORBIDDEN + assert "internal-models" in caplog.text + assert "anthropic-sonnet-4-5" in caplog.text + + +@pytest.mark.parametrize("unset_value", [None, ""]) +def test_can_object_call_model_denial_unchanged_when_message_not_configured(monkeypatch, unset_value): + monkeypatch.setattr(litellm, "model_access_denied_message", unset_value) + + with pytest.raises(ProxyException) as exc_info: + _can_object_call_model( + model="anthropic-sonnet-4-5", + llm_router=None, + models=["internal-models"], + object_type="team", + ) + + assert exc_info.value.message == ( + "team not allowed to access model. This team can only access models=['internal-models']. " + "Tried to access anthropic-sonnet-4-5" + ) + + +@pytest.mark.asyncio +async def test_can_user_call_model_no_default_models_uses_configured_message(monkeypatch): + from litellm.proxy._types import SpecialModelNames + from litellm.proxy.auth.auth_checks import can_user_call_model + + monkeypatch.setattr(litellm, "model_access_denied_message", _DENIED_MESSAGE_TEMPLATE) + user_object = LiteLLM_UserTable(user_id="test-user", models=[SpecialModelNames.no_default_models.value]) + + with pytest.raises(ProxyException) as exc_info: + await can_user_call_model(model="restricted-model", llm_router=None, user_object=user_object) + + assert exc_info.value.message == "The model `restricted-model` is unavailable for this API key or does not exist." + assert int(exc_info.value.code) == status.HTTP_403_FORBIDDEN + + +@pytest.mark.asyncio +async def test_check_team_member_model_access_denied_uses_configured_message(monkeypatch): + from litellm.proxy._types import LiteLLM_TeamMembership + from litellm.proxy.auth.auth_checks import _check_team_member_model_access + from litellm.proxy.common_utils.user_api_key_cache import team_membership_reservation_cache_key + + monkeypatch.setattr(litellm, "model_access_denied_message", _DENIED_MESSAGE_TEMPLATE) + membership = LiteLLM_TeamMembership( + user_id="alice", + team_id="team-a", + litellm_budget_table=LiteLLM_BudgetTable(allowed_models=["fast-models"]), + ) + cache = UserApiKeyCache() + await cache.async_set_cache( + key=team_membership_reservation_cache_key(user_id="alice", team_id="team-a"), + value=membership, + model_type=LiteLLM_TeamMembership, + ) + + with pytest.raises(ProxyException) as exc_info: + await _check_team_member_model_access( + model="mock-vision", + team_object=LiteLLM_TeamTable(team_id="team-a"), + valid_token=UserAPIKeyAuth(token="sk-test", user_id="alice", team_id="team-a"), + llm_router=_make_team_scoped_router(), + prisma_client=None, + user_api_key_cache=cache, + proxy_logging_obj=MagicMock(), + ) + + assert exc_info.value.message == "The model `mock-vision` is unavailable for this API key or does not exist." + assert exc_info.value.type == ProxyErrorTypes.team_model_access_denied + + # -- Team-member access-group resolution with team-scoped DB models ----------- diff --git a/tests/test_litellm/proxy/test_proxy_server.py b/tests/test_litellm/proxy/test_proxy_server.py index 42af8e0af21..17847575de1 100644 --- a/tests/test_litellm/proxy/test_proxy_server.py +++ b/tests/test_litellm/proxy/test_proxy_server.py @@ -10860,6 +10860,72 @@ def test_validate_max_ui_session_budget_empty_restores_default(empty_value): assert _validate_general_settings_ui_litellm_value("max_ui_session_budget", empty_value) == 1.0 +@pytest.mark.asyncio +async def test_update_config_field_model_access_denied_message_sets_live_value(monkeypatch): + """LIT-5283: the client-facing model access denial message is editable from the Admin UI + General tab as a String field, applies live via setattr, and persists under litellm_settings.""" + from unittest.mock import MagicMock + + import litellm.proxy.proxy_server as ps + from litellm.proxy._types import ConfigFieldUpdate, LitellmUserRoles, UserAPIKeyAuth + from litellm.proxy.proxy_server import update_config_general_settings + + saved: dict = {} + + async def fake_get_config(): + return {"litellm_settings": {}} + + async def fake_save_config(new_config=None): + saved.update(new_config or {}) + + monkeypatch.setattr(ps.proxy_config, "get_config", fake_get_config) + monkeypatch.setattr(ps.proxy_config, "save_config", fake_save_config) + monkeypatch.setattr(ps, "prisma_client", MagicMock()) + monkeypatch.setattr(litellm, "store_audit_logs", False) + monkeypatch.setattr(litellm, "model_access_denied_message", None) + + admin = UserAPIKeyAuth(api_key="k", user_id="a", user_role=LitellmUserRoles.PROXY_ADMIN) + await update_config_general_settings( + data=ConfigFieldUpdate( + field_name="model_access_denied_message", + field_value="Model `{model}` is unavailable for this key.", + config_type="general_settings", + ), + user_api_key_dict=admin, + ) + + assert litellm.model_access_denied_message == "Model `{model}` is unavailable for this key." + assert saved["litellm_settings"]["model_access_denied_message"] == "Model `{model}` is unavailable for this key." + + +@pytest.mark.parametrize("bad_value", [True, 3, 1.5, ["x"], {"a": "b"}]) +def test_validate_model_access_denied_message_rejects_non_strings(bad_value): + from fastapi import HTTPException + + from litellm.proxy.proxy_server import _validate_general_settings_ui_litellm_value + + with pytest.raises(HTTPException) as exc_info: + _validate_general_settings_ui_litellm_value("model_access_denied_message", bad_value) + assert exc_info.value.status_code == 400 + + +@pytest.mark.parametrize("empty_value", [None, ""]) +def test_validate_model_access_denied_message_empty_restores_detailed_default(empty_value): + from litellm.proxy.proxy_server import _validate_general_settings_ui_litellm_value + + assert _validate_general_settings_ui_litellm_value("model_access_denied_message", empty_value) is None + + +@pytest.mark.parametrize("empty_value", [None, ""]) +def test_validate_expose_router_debug_in_errors_empty_restores_true_default(empty_value): + """Clearing the field from the Admin UI must restore the historical default (debug details + exposed), not the generic Boolean fallback of False.""" + from litellm.proxy.proxy_server import _validate_general_settings_ui_litellm_value + + assert _validate_general_settings_ui_litellm_value("expose_router_debug_in_errors", empty_value) is True + assert _validate_general_settings_ui_litellm_value("expose_router_debug_in_errors", False) is False + + def test_general_settings_ui_defaults_unchanged_for_existing_fields(): """The spec-default mechanism added for max_ui_session_budget must not change what clearing the pre-existing fields restores (None for Float/Select, False for Boolean).""" diff --git a/ui/litellm-dashboard/src/app/(dashboard)/router-settings/_components/general_settings.integration.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/router-settings/_components/general_settings.integration.test.tsx index 9cd1444b0b9..1e29b181b78 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/router-settings/_components/general_settings.integration.test.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/router-settings/_components/general_settings.integration.test.tsx @@ -1,4 +1,4 @@ -import { renderWithProviders, screen, within } from "../../../../../tests/test-utils"; +import { fireEvent, renderWithProviders, screen, within } from "../../../../../tests/test-utils"; import userEvent from "@testing-library/user-event"; import { beforeEach, describe, expect, it, vi } from "vitest"; import GeneralSettings from "./general_settings"; @@ -53,6 +53,14 @@ const SETTINGS_FIXTURE = [ stored_in_db: true, field_default_value: 1.0, }, + { + field_name: "model_access_denied_message", + field_type: "String", + field_value: null, + field_description: "client-facing denial message", + stored_in_db: null, + field_default_value: null, + }, ]; const settingsRow = async (fieldName: string) => { @@ -99,6 +107,29 @@ describe("GeneralSettings General tab", () => { expect(deleteConfigFieldSetting).toHaveBeenCalledWith("token", "max_ui_session_budget"); expect(numericValueIn(row)).toBe(1); }); + + it("saves a typed model_access_denied_message and resets it when cleared", async () => { + const user = userEvent.setup(); + renderWithProviders(); + + await user.click(screen.getByText("General")); + const row = await settingsRow("model_access_denied_message"); + const input = within(row).getByRole("textbox") as HTMLInputElement; + expect(input.value).toBe(""); + + fireEvent.change(input, { target: { value: "Model `{model}` is unavailable for this key." } }); + await user.click(within(row).getByRole("button", { name: /update/i })); + expect(updateConfigFieldSetting).toHaveBeenCalledWith( + "token", + "model_access_denied_message", + "Model `{model}` is unavailable for this key.", + ); + + fireEvent.change(input, { target: { value: "" } }); + await user.click(within(row).getByRole("button", { name: /update/i })); + expect(deleteConfigFieldSetting).toHaveBeenCalledWith("token", "model_access_denied_message"); + expect(vi.mocked(updateConfigFieldSetting).mock.calls).toHaveLength(1); + }); }); // The five tabs here are proxy-wide settings. Auto-routers moved to Models + Endpoints. diff --git a/ui/litellm-dashboard/src/app/(dashboard)/router-settings/_components/general_settings.tsx b/ui/litellm-dashboard/src/app/(dashboard)/router-settings/_components/general_settings.tsx index df9e328ec3b..040b1f3c375 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/router-settings/_components/general_settings.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/router-settings/_components/general_settings.tsx @@ -39,6 +39,8 @@ export interface generalSettingsItem { const NUMERIC_INPUT_WIDTH = "w-36"; const toNumericValue = (raw: string): number | null => (raw === "" ? null : Number(raw)); +const toStringValue = (raw: string): string | null => (raw === "" ? null : raw); +const RESETS_WHEN_CLEARED = new Set(["Select", "String"]); const SettingValueEditor: React.FC<{ setting: generalSettingsItem; @@ -107,6 +109,16 @@ const SettingValueEditor: React.FC<{ ); } + if (setting.field_type === "String") { + return ( + onChange(setting.field_name, toStringValue(event.target.value))} + /> + ); + } return null; }; @@ -210,7 +222,7 @@ const GeneralSettings: React.FC = ({ accessToken, user const fieldValue = setting?.field_value; if (fieldValue == null) { - if (setting?.field_type === "Select") handleResetField(fieldName); + if (setting && RESETS_WHEN_CLEARED.has(setting.field_type)) handleResetField(fieldName); return; } try { From 69e9edb91a9bc8efcadf876917f11cce1b31eae6 Mon Sep 17 00:00:00 2001 From: mateo-berri <277851410+mateo-berri@users.noreply.github.com> Date: Tue, 15 Sep 2026 15:31:38 -0700 Subject: [PATCH 031/133] test(responses): fold the empty-choices regression tests into the mapped streaming iterator test file --- .../test_empty_choices_streaming_iterator.py | 91 ------------------- .../test_streaming_iterator_transformation.py | 43 +++++++++ 2 files changed, 43 insertions(+), 91 deletions(-) delete mode 100644 tests/test_litellm/responses/litellm_completion_transformation/test_empty_choices_streaming_iterator.py diff --git a/tests/test_litellm/responses/litellm_completion_transformation/test_empty_choices_streaming_iterator.py b/tests/test_litellm/responses/litellm_completion_transformation/test_empty_choices_streaming_iterator.py deleted file mode 100644 index b7a3611501d..00000000000 --- a/tests/test_litellm/responses/litellm_completion_transformation/test_empty_choices_streaming_iterator.py +++ /dev/null @@ -1,91 +0,0 @@ -""" -Regression tests for LIT-4767. - -When an upstream OpenAI-compatible provider emits a chunk with ``choices: []`` -(the trailing usage-only chunk every provider sends when ``include_usage`` is -set, or Azure's leading ``prompt_filter_results`` chunk), the Responses bridge -iterator used to index ``choices[0]`` unguarded and die with -``IndexError: list index out of range``, killing the whole stream. - -The empty-choices chunk must be tolerated without crashing, and the usage it -carries must still reach ``response.completed``. -""" - -from unittest.mock import AsyncMock - -from litellm.responses.litellm_completion_transformation.streaming_iterator import ( - LiteLLMCompletionStreamingIterator, -) -from litellm.types.llms.openai import ResponsesAPIStreamEvents -from litellm.types.utils import ( - Delta, - ModelResponseStream, - StreamingChoices, - Usage, -) - - -def _iterator() -> LiteLLMCompletionStreamingIterator: - return LiteLLMCompletionStreamingIterator( - model="gpt-4o", - litellm_custom_stream_wrapper=AsyncMock(), - request_input="hi", - responses_api_request={}, - custom_llm_provider="openai", - ) - - -def _empty_choices_usage_chunk() -> ModelResponseStream: - chunk = ModelResponseStream(id="chunk-usage", model="gpt-4o", choices=[]) - chunk.usage = Usage(prompt_tokens=10, completion_tokens=5, total_tokens=15) - return chunk - - -def test_ensure_output_item_for_empty_choices_chunk_does_not_crash(): - """First chunk with no choices must not raise (traceback frame in the ticket).""" - iterator = _iterator() - # Would raise IndexError before the fix. - assert iterator._ensure_output_item_for_chunk(_empty_choices_usage_chunk()) is None - assert iterator.sent_output_item_added_event is False - - -def test_transform_empty_choices_chunk_returns_no_delta(): - """The mid/trailing usage chunk flows through transform without crashing.""" - iterator = _iterator() - # Would raise IndexError in _get_delta_string_from_streaming_choices before the fix. - assert iterator._transform_chat_completion_chunk_to_response_api_chunk(_empty_choices_usage_chunk()) is None - - -def test_is_reasoning_end_false_for_empty_choices_chunk(): - iterator = _iterator() - assert iterator._is_reasoning_end(_empty_choices_usage_chunk()) is False - - -def test_empty_choices_usage_chunk_still_reaches_response_completed(): - """End-to-end: a text chunk followed by a choices=[] usage chunk must emit - response.completed carrying the usage rather than dying mid-stream.""" - - class _SyncWrapper: - def __init__(self, chunks): - self._it = iter(chunks) - self.logging_obj = None - self.stream_options = {"include_usage": True} - - def __next__(self): - return next(self._it) - - text_chunk = ModelResponseStream( - id="chunk-1", - model="gpt-4o", - choices=[StreamingChoices(index=0, delta=Delta(role="assistant", content="Hi"), finish_reason=None)], - ) - iterator = _iterator() - iterator.litellm_logging_obj = None - iterator.litellm_custom_stream_wrapper = _SyncWrapper([text_chunk, _empty_choices_usage_chunk()]) - - events = list(iterator) - - completed = [e for e in events if getattr(e, "type", None) == ResponsesAPIStreamEvents.RESPONSE_COMPLETED] - assert len(completed) == 1 - assert completed[0].response.usage is not None - assert completed[0].response.usage.total_tokens == 15 diff --git a/tests/test_litellm/responses/litellm_completion_transformation/test_streaming_iterator_transformation.py b/tests/test_litellm/responses/litellm_completion_transformation/test_streaming_iterator_transformation.py index 5d97b0531d6..343fc873fa4 100644 --- a/tests/test_litellm/responses/litellm_completion_transformation/test_streaming_iterator_transformation.py +++ b/tests/test_litellm/responses/litellm_completion_transformation/test_streaming_iterator_transformation.py @@ -752,6 +752,49 @@ def test_completed_event_restores_usage_hidden_by_stream_options_none(): assert completed.response.usage.output_tokens == 5 +def _empty_choices_chunk(usage: Usage | None = None) -> ModelResponseStream: + return ModelResponseStream(id=CHAT_COMPLETION_ID, model="claude-haiku-4-5", choices=[], usage=usage) + + +@pytest.mark.asyncio +async def test_leading_empty_choices_chunk_does_not_kill_the_stream(): + """ + Azure leads some streams with a `prompt_filter_results` chunk whose `choices` is empty. + The bridge used to index `choices[0]` on it and die before the first token. + """ + iterator = _build_iterator([_empty_choices_chunk(), _chunk("Hello"), _chunk("!", finish_reason="stop")]) + + events = [event async for event in iterator] + + event_types = [getattr(event, "type", None) for event in events] + assert event_types.count(ResponsesAPIStreamEvents.OUTPUT_ITEM_ADDED) == 1 + assert "".join(event.delta for event in events if event.type == ResponsesAPIStreamEvents.OUTPUT_TEXT_DELTA) == "Hello!" + assert event_types[-1] == ResponsesAPIStreamEvents.RESPONSE_COMPLETED + + +@pytest.mark.asyncio +async def test_trailing_empty_choices_usage_chunk_reaches_response_completed(): + """ + With `stream_options.include_usage` (which the bridge always sets) the last upstream chunk + carries only usage and an empty `choices`. It must not crash the stream, and its usage must + still land on `response.completed`. + """ + usage: Final = Usage(prompt_tokens=10, completion_tokens=5, total_tokens=15) + iterator = _build_iterator([_chunk("Hello"), _chunk("", finish_reason="stop"), _empty_choices_chunk(usage)]) + + events = [event async for event in iterator] + + completed = next( + event for event in events if getattr(event, "type", None) == ResponsesAPIStreamEvents.RESPONSE_COMPLETED + ) + assert completed.response.usage.input_tokens == 10 + assert completed.response.usage.output_tokens == 5 + + +def test_is_reasoning_end_ignores_empty_choices_chunk(): + assert _build_iterator([])._is_reasoning_end(_empty_choices_chunk()) is False + + def test_object_tool_call_arguments_stream_as_valid_json(): """A provider that sends decoded object arguments must still stream valid JSON. From d4d8cc9092c5a825dc2972e124101198f9fe6a90 Mon Sep 17 00:00:00 2001 From: yassin Date: Tue, 15 Sep 2026 22:44:03 +0000 Subject: [PATCH 032/133] fix(proxy): apply access denied message to JWT paths, sanitize denial log, await dashboard saves Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- litellm/proxy/auth/auth_checks.py | 10 ++-- litellm/proxy/auth/handle_jwt.py | 13 ++++- litellm/proxy/proxy_server.py | 12 ++-- .../proxy/auth/test_auth_checks.py | 22 ++++++- .../proxy/auth/test_handle_jwt.py | 57 +++++++++++++++++++ tests/test_litellm/proxy/test_proxy_server.py | 54 ++++++++++++------ .../general_settings.integration.test.tsx | 25 ++++++++ .../_components/general_settings.tsx | 32 +++++------ 8 files changed, 176 insertions(+), 49 deletions(-) diff --git a/litellm/proxy/auth/auth_checks.py b/litellm/proxy/auth/auth_checks.py index 6435ce0d12f..b13214eaf5e 100644 --- a/litellm/proxy/auth/auth_checks.py +++ b/litellm/proxy/auth/auth_checks.py @@ -4034,11 +4034,11 @@ async def _get_agent_ids_from_access_groups( ) -def _client_facing_model_access_denied_message(internal_message: str, model: str | list[str]) -> str: +def client_facing_model_access_denied_message(internal_message: str, model: str | list[str]) -> str: template: Final = litellm.model_access_denied_message if not template: return internal_message - verbose_proxy_logger.warning(internal_message) + verbose_proxy_logger.warning(internal_message.replace("\r", "").replace("\n", "")) return template.replace(MODEL_ACCESS_DENIED_MESSAGE_MODEL_PLACEHOLDER, str(model)) @@ -4164,7 +4164,7 @@ def _can_object_call_model( return True raise ProxyException( - message=_client_facing_model_access_denied_message( + message=client_facing_model_access_denied_message( internal_message=f"{object_type} not allowed to access model. This {object_type} can only access models={models}. Tried to access {model}", model=model, ), @@ -4793,7 +4793,7 @@ async def can_user_call_model( if SpecialModelNames.no_default_models.value in user_object.models: raise ProxyException( - message=_client_facing_model_access_denied_message( + message=client_facing_model_access_denied_message( internal_message=f"User not allowed to access model. No default model access, only team models allowed. Tried to access {model}", model=model, ), @@ -5398,7 +5398,7 @@ async def _check_team_member_model_access( ) except ProxyException: raise ProxyException( - message=_client_facing_model_access_denied_message( + message=client_facing_model_access_denied_message( internal_message=f"Team member not allowed to access model. User={valid_token.user_id}, Team={team_object.team_id}, Model={model}. Allowed member models = {member_allowed_models}", model=model, ), diff --git a/litellm/proxy/auth/handle_jwt.py b/litellm/proxy/auth/handle_jwt.py index 94ca3047f45..6e5e75a5e20 100644 --- a/litellm/proxy/auth/handle_jwt.py +++ b/litellm/proxy/auth/handle_jwt.py @@ -66,6 +66,7 @@ from litellm.types.agents import AgentResponse from .auth_checks import ( _allowed_routes_check, allowed_routes_check, + client_facing_model_access_denied_message, get_actual_routes, get_end_user_object, get_org_object, @@ -1339,7 +1340,10 @@ class JWTAuthManager: if model not in role_based_models: raise HTTPException( status_code=403, - detail=f"Role={rbac_role} not allowed to call model={model}. Allowed models={role_based_models}", + detail=client_facing_model_access_denied_message( + internal_message=f"Role={rbac_role} not allowed to call model={model}. Allowed models={role_based_models}", + model=model, + ), ) return True @@ -1370,7 +1374,12 @@ class JWTAuthManager: if requested_model not in allowed_models: raise HTTPException( status_code=403, - detail={"error": f"model={requested_model} not allowed. Allowed_models={allowed_models}"}, + detail={ + "error": client_facing_model_access_denied_message( + internal_message=f"model={requested_model} not allowed. Allowed_models={allowed_models}", + model=requested_model, + ) + }, ) return diff --git a/litellm/proxy/proxy_server.py b/litellm/proxy/proxy_server.py index f758628c02a..b0e1cc2e0fe 100644 --- a/litellm/proxy/proxy_server.py +++ b/litellm/proxy/proxy_server.py @@ -17435,12 +17435,12 @@ GeneralSettingsUILiteLLMValue = float | bool | str | None class GeneralSettingsUILiteLLMFieldSpec(TypedDict): - type: Literal["Float", "Dollar", "Boolean", "Select", "String"] - description: str - options: NotRequired[tuple[str, ...]] - tab: NotRequired[str] # Admin UI sub-tab this field renders under; None groups it with the rest - default: NotRequired[ - float | bool + type: ReadOnly[Literal["Float", "Dollar", "Boolean", "Select", "String"]] + description: ReadOnly[str] + options: ReadOnly[NotRequired[tuple[str, ...]]] + tab: ReadOnly[NotRequired[str]] # Admin UI sub-tab this field renders under; None groups it with the rest + default: ReadOnly[ + NotRequired[float | bool] ] # reset/clear restores this instead of None; fields whose None means fail-open set it diff --git a/tests/test_litellm/proxy/auth/test_auth_checks.py b/tests/test_litellm/proxy/auth/test_auth_checks.py index e99e92fcd02..0052cfb66d3 100644 --- a/tests/test_litellm/proxy/auth/test_auth_checks.py +++ b/tests/test_litellm/proxy/auth/test_auth_checks.py @@ -1683,8 +1683,6 @@ _DENIED_MESSAGE_TEMPLATE: Final = "The model `{model}` is unavailable for this A def test_can_object_call_model_denial_uses_configured_message_and_logs_detail(monkeypatch, caplog): - """LIT-5283: with model_access_denied_message set, the client sees only the template with - {model} filled in, while the allowed models / access groups stay in the proxy log.""" monkeypatch.setattr(litellm, "model_access_denied_message", _DENIED_MESSAGE_TEMPLATE) with caplog.at_level("WARNING", logger="LiteLLM Proxy"): @@ -1706,6 +1704,26 @@ def test_can_object_call_model_denial_uses_configured_message_and_logs_detail(mo assert "anthropic-sonnet-4-5" in caplog.text +def test_can_object_call_model_denial_log_strips_newlines_from_requested_model(monkeypatch, caplog): + monkeypatch.setattr(litellm, "model_access_denied_message", _DENIED_MESSAGE_TEMPLATE) + + with caplog.at_level("WARNING", logger="LiteLLM Proxy"): + with pytest.raises(ProxyException): + _can_object_call_model( + model="gpt-5.6\r\nWARNING forged log line", + llm_router=None, + models=["internal-models"], + object_type="key", + ) + + denial_records = [r for r in caplog.records if "not allowed to access model" in r.getMessage()] + assert len(denial_records) == 1 + assert denial_records[0].getMessage() == ( + "key not allowed to access model. This key can only access models=['internal-models']. " + "Tried to access gpt-5.6WARNING forged log line" + ) + + @pytest.mark.parametrize("unset_value", [None, ""]) def test_can_object_call_model_denial_unchanged_when_message_not_configured(monkeypatch, unset_value): monkeypatch.setattr(litellm, "model_access_denied_message", unset_value) diff --git a/tests/test_litellm/proxy/auth/test_handle_jwt.py b/tests/test_litellm/proxy/auth/test_handle_jwt.py index 814e31535e0..921472f2f43 100644 --- a/tests/test_litellm/proxy/auth/test_handle_jwt.py +++ b/tests/test_litellm/proxy/auth/test_handle_jwt.py @@ -9,6 +9,8 @@ from fastapi import HTTPException import httpx import pytest +import litellm + from litellm.proxy._types import ( DEFAULT_JWKS_STALE_TTL, JWTLiteLLMRoleMap, @@ -21,6 +23,8 @@ from litellm.proxy._types import ( Member, ProxyErrorTypes, ProxyException, + RoleBasedPermissions, + ScopeMapping, ) from litellm.caching.dual_cache import DualCache from litellm.proxy.agent_endpoints.agent_registry import AgentRegistry @@ -6965,3 +6969,56 @@ async def test_auth_builder_denies_jwt_naming_unregistered_agent_before_admin_ch ) assert exc_info.value.status_code == 403 + + +_JWT_DENIED_MESSAGE_TEMPLATE = "The model `{model}` is unavailable for this identity." + + +@pytest.mark.parametrize( + "configured_message, expected_detail", + [ + (None, "Role=internal_user not allowed to call model=gpt-5.6. Allowed models=['gpt-5.6-mini']"), + ("", "Role=internal_user not allowed to call model=gpt-5.6. Allowed models=['gpt-5.6-mini']"), + (_JWT_DENIED_MESSAGE_TEMPLATE, "The model `gpt-5.6` is unavailable for this identity."), + ], +) +def test_can_rbac_role_call_model_denial_honors_configured_message(monkeypatch, configured_message, expected_detail): + monkeypatch.setattr(litellm, "model_access_denied_message", configured_message) + general_settings = { + "role_permissions": [ + RoleBasedPermissions(role=LitellmUserRoles.INTERNAL_USER, models=["gpt-5.6-mini"]), + ] + } + + with pytest.raises(HTTPException) as exc_info: + JWTAuthManager.can_rbac_role_call_model( + rbac_role=LitellmUserRoles.INTERNAL_USER, + general_settings=general_settings, + model="gpt-5.6", + ) + + assert exc_info.value.status_code == 403 + assert exc_info.value.detail == expected_detail + + +@pytest.mark.parametrize( + "configured_message, expected_error", + [ + (None, "model=gpt-5.6 not allowed. Allowed_models=['gpt-5.6-mini']"), + ("", "model=gpt-5.6 not allowed. Allowed_models=['gpt-5.6-mini']"), + (_JWT_DENIED_MESSAGE_TEMPLATE, "The model `gpt-5.6` is unavailable for this identity."), + ], +) +def test_check_scope_based_access_denial_honors_configured_message(monkeypatch, configured_message, expected_error): + monkeypatch.setattr(litellm, "model_access_denied_message", configured_message) + + with pytest.raises(HTTPException) as exc_info: + JWTAuthManager.check_scope_based_access( + scope_mappings=[ScopeMapping(scope="litellm.api.consumer", models=["gpt-5.6-mini"])], + scopes=["litellm.api.consumer"], + request_data={"model": "gpt-5.6"}, + general_settings={}, + ) + + assert exc_info.value.status_code == 403 + assert exc_info.value.detail == {"error": expected_error} diff --git a/tests/test_litellm/proxy/test_proxy_server.py b/tests/test_litellm/proxy/test_proxy_server.py index 17847575de1..dd05b90bb67 100644 --- a/tests/test_litellm/proxy/test_proxy_server.py +++ b/tests/test_litellm/proxy/test_proxy_server.py @@ -10862,24 +10862,15 @@ def test_validate_max_ui_session_budget_empty_restores_default(empty_value): @pytest.mark.asyncio async def test_update_config_field_model_access_denied_message_sets_live_value(monkeypatch): - """LIT-5283: the client-facing model access denial message is editable from the Admin UI - General tab as a String field, applies live via setattr, and persists under litellm_settings.""" - from unittest.mock import MagicMock + from unittest.mock import AsyncMock, MagicMock import litellm.proxy.proxy_server as ps from litellm.proxy._types import ConfigFieldUpdate, LitellmUserRoles, UserAPIKeyAuth from litellm.proxy.proxy_server import update_config_general_settings - saved: dict = {} - - async def fake_get_config(): - return {"litellm_settings": {}} - - async def fake_save_config(new_config=None): - saved.update(new_config or {}) - - monkeypatch.setattr(ps.proxy_config, "get_config", fake_get_config) - monkeypatch.setattr(ps.proxy_config, "save_config", fake_save_config) + save_config = AsyncMock() + monkeypatch.setattr(ps.proxy_config, "get_config", AsyncMock(return_value={"litellm_settings": {}})) + monkeypatch.setattr(ps.proxy_config, "save_config", save_config) monkeypatch.setattr(ps, "prisma_client", MagicMock()) monkeypatch.setattr(litellm, "store_audit_logs", False) monkeypatch.setattr(litellm, "model_access_denied_message", None) @@ -10895,7 +10886,11 @@ async def test_update_config_field_model_access_denied_message_sets_live_value(m ) assert litellm.model_access_denied_message == "Model `{model}` is unavailable for this key." - assert saved["litellm_settings"]["model_access_denied_message"] == "Model `{model}` is unavailable for this key." + save_config.assert_awaited_once() + saved_config = save_config.await_args.kwargs["new_config"] + assert saved_config["litellm_settings"]["model_access_denied_message"] == ( + "Model `{model}` is unavailable for this key." + ) @pytest.mark.parametrize("bad_value", [True, 3, 1.5, ["x"], {"a": "b"}]) @@ -10918,14 +10913,41 @@ def test_validate_model_access_denied_message_empty_restores_detailed_default(em @pytest.mark.parametrize("empty_value", [None, ""]) def test_validate_expose_router_debug_in_errors_empty_restores_true_default(empty_value): - """Clearing the field from the Admin UI must restore the historical default (debug details - exposed), not the generic Boolean fallback of False.""" from litellm.proxy.proxy_server import _validate_general_settings_ui_litellm_value assert _validate_general_settings_ui_litellm_value("expose_router_debug_in_errors", empty_value) is True assert _validate_general_settings_ui_litellm_value("expose_router_debug_in_errors", False) is False +@pytest.mark.parametrize( + "field_name, booted_value, db_value, read_setting", + [ + ( + "model_access_denied_message", + None, + "Model `{model}` is unavailable for this key.", + lambda: litellm.model_access_denied_message, + ), + ("expose_router_debug_in_errors", True, False, lambda: litellm.expose_router_debug_in_errors), + ], +) +def test_model_access_denied_settings_propagate_on_config_reload( + monkeypatch, field_name, booted_value, db_value, read_setting +): + import litellm.proxy.proxy_server as ps + + monkeypatch.setattr(litellm, field_name, booted_value) + assert read_setting() == booted_value + + ps.ProxyConfig()._update_config_fields( + current_config={"litellm_settings": {}}, + param_name="litellm_settings", + db_param_value={field_name: db_value}, + ) + + assert read_setting() == db_value + + def test_general_settings_ui_defaults_unchanged_for_existing_fields(): """The spec-default mechanism added for max_ui_session_budget must not change what clearing the pre-existing fields restores (None for Float/Select, False for Boolean).""" diff --git a/ui/litellm-dashboard/src/app/(dashboard)/router-settings/_components/general_settings.integration.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/router-settings/_components/general_settings.integration.test.tsx index 1e29b181b78..d002d7c116f 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/router-settings/_components/general_settings.integration.test.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/router-settings/_components/general_settings.integration.test.tsx @@ -130,6 +130,31 @@ describe("GeneralSettings General tab", () => { expect(deleteConfigFieldSetting).toHaveBeenCalledWith("token", "model_access_denied_message"); expect(vi.mocked(updateConfigFieldSetting).mock.calls).toHaveLength(1); }); + + it("keeps the stored value visible when the reset request fails", async () => { + vi.mocked(getGeneralSettingsCall).mockResolvedValue( + SETTINGS_FIXTURE.map((s) => + s.field_name === "model_access_denied_message" + ? { ...s, field_value: "Model `{model}` is unavailable.", stored_in_db: true } + : { ...s }, + ), + ); + vi.mocked(deleteConfigFieldSetting).mockRejectedValueOnce(new Error("proxy unreachable")); + const user = userEvent.setup(); + renderWithProviders(); + + await user.click(screen.getByText("General")); + const row = await settingsRow("model_access_denied_message"); + const input = within(row).getByRole("textbox") as HTMLInputElement; + expect(within(row).getByText("In DB")).toBeInTheDocument(); + + fireEvent.change(input, { target: { value: "" } }); + await user.click(within(row).getByRole("button", { name: /update/i })); + + expect(deleteConfigFieldSetting).toHaveBeenCalledWith("token", "model_access_denied_message"); + expect(within(row).getByText("In DB")).toBeInTheDocument(); + expect(within(row).queryByText("Not Set")).not.toBeInTheDocument(); + }); }); // The five tabs here are proxy-wide settings. Auto-routers moved to Models + Endpoints. diff --git a/ui/litellm-dashboard/src/app/(dashboard)/router-settings/_components/general_settings.tsx b/ui/litellm-dashboard/src/app/(dashboard)/router-settings/_components/general_settings.tsx index 040b1f3c375..38aa85b0f1e 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/router-settings/_components/general_settings.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/router-settings/_components/general_settings.tsx @@ -40,7 +40,7 @@ const NUMERIC_INPUT_WIDTH = "w-36"; const toNumericValue = (raw: string): number | null => (raw === "" ? null : Number(raw)); const toStringValue = (raw: string): string | null => (raw === "" ? null : raw); -const RESETS_WHEN_CLEARED = new Set(["Select", "String"]); +const RESETS_WHEN_CLEARED: ReadonlySet = new Set(["Select", "String"]); const SettingValueEditor: React.FC<{ setting: generalSettingsItem; @@ -213,7 +213,7 @@ const GeneralSettings: React.FC = ({ accessToken, user setGeneralSettings(updatedSettings); }; - const handleUpdateField = (fieldName: string) => { + const handleUpdateField = async (fieldName: string) => { if (!accessToken) { return; } @@ -222,37 +222,33 @@ const GeneralSettings: React.FC = ({ accessToken, user const fieldValue = setting?.field_value; if (fieldValue == null) { - if (setting && RESETS_WHEN_CLEARED.has(setting.field_type)) handleResetField(fieldName); + if (setting && RESETS_WHEN_CLEARED.has(setting.field_type)) await handleResetField(fieldName); return; } try { - updateConfigFieldSetting(accessToken, fieldName, fieldValue); - // update value in state - - const updatedSettings = generalSettings.map((setting) => - setting.field_name === fieldName ? { ...setting, stored_in_db: true } : setting, + await updateConfigFieldSetting(accessToken, fieldName, fieldValue); + setGeneralSettings((current) => + current.map((setting) => (setting.field_name === fieldName ? { ...setting, stored_in_db: true } : setting)), ); - setGeneralSettings(updatedSettings); } catch (error) { // do something } }; - const handleResetField = (fieldName: string) => { + const handleResetField = async (fieldName: string) => { if (!accessToken) { return; } try { - deleteConfigFieldSetting(accessToken, fieldName); - // update value in state - - const updatedSettings = generalSettings.map((setting) => - setting.field_name === fieldName - ? { ...setting, stored_in_db: null, field_value: setting.field_default_value ?? null } - : setting, + await deleteConfigFieldSetting(accessToken, fieldName); + setGeneralSettings((current) => + current.map((setting) => + setting.field_name === fieldName + ? { ...setting, stored_in_db: null, field_value: setting.field_default_value ?? null } + : setting, + ), ); - setGeneralSettings(updatedSettings); } catch (error) { // do something } From 083ecb3c0b6a732c642bf16db80be87357e9cd41 Mon Sep 17 00:00:00 2001 From: "berriai-litellm-provider-info-sync[bot]" <328147090+berriai-litellm-provider-info-sync[bot]@users.noreply.github.com> Date: Tue, 15 Sep 2026 23:31:18 +0000 Subject: [PATCH 033/133] chore(prices): sync prices for 3 providers: 26 models, 26 deprecated [enrichment failed: Google Gemini, sync failed: AWS Bedrock, 34 held] fireworks_ai/accounts/fireworks/models/deepseek-v4-pro: deprecation_date fireworks_ai/deepseek-v4-pro: deprecation_date fireworks_ai/accounts/fireworks/models/minimax-m2p7: deprecation_date fireworks_ai/minimax-m2p7: deprecation_date together_ai/deepseek-ai/deepseek-coder-33b-instruct: deprecation_date together_ai/deepseek-ai/DeepSeek-R1-Distill-Llama-70B: deprecation_date together_ai/deepseek-ai/DeepSeek-R1-Distill-Qwen-1.5B: deprecation_date together_ai/deepseek-ai/DeepSeek-R1-Distill-Qwen-14B: deprecation_date together_ai/google/gemma-2-27b-it: deprecation_date vertex_ai/imagegeneration@006: deprecation_date vertex_ai/imagen-3.0-capability-001: deprecation_date vertex_ai/imagen-3.0-fast-generate-001: deprecation_date vertex_ai/imagen-3.0-generate-001: deprecation_date vertex_ai/imagen-3.0-generate-002: deprecation_date vertex_ai/imagen-4.0-fast-generate-001: deprecation_date vertex_ai/imagen-4.0-generate-001: deprecation_date vertex_ai/imagen-4.0-ultra-generate-001: deprecation_date together_ai/meta-llama/Llama-3-8b-chat-hf: deprecation_date together_ai/meta-llama/Meta-Llama-3-70B-Instruct-Turbo: deprecation_date together_ai/meta-llama/Meta-Llama-3-8B-Instruct: deprecation_date together_ai/NousResearch/Nous-Hermes-2-Mixtral-8x7B-DPO: deprecation_date together_ai/nvidia/Llama-3.1-Nemotron-70B-Instruct-HF: deprecation_date together_ai/Qwen/Qwen2-72B-Instruct: deprecation_date together_ai/Qwen/Qwen2-VL-72B-Instruct: deprecation_date together_ai/Qwen/Qwen2.5-Coder-32B-Instruct: deprecation_date together_ai/Qwen/Qwen2.5-VL-72B-Instruct: deprecation_date --- ...odel_prices_and_context_window_backup.json | 27 ++++++++++++++++++- model_prices_and_context_window.json | 27 ++++++++++++++++++- 2 files changed, 52 insertions(+), 2 deletions(-) diff --git a/litellm/model_prices_and_context_window_backup.json b/litellm/model_prices_and_context_window_backup.json index 372ce19e740..000615bea11 100644 --- a/litellm/model_prices_and_context_window_backup.json +++ b/litellm/model_prices_and_context_window_backup.json @@ -22839,6 +22839,7 @@ "fireworks_ai/accounts/fireworks/models/deepseek-v4-pro": { "cache_read_input_token_cost": 6e-07, "cache_read_input_token_cost_priority": 6e-07, + "deprecation_date": "2026-08-27", "input_cost_per_token": 1.2e-06, "input_cost_per_token_priority": 1.2e-06, "litellm_provider": "fireworks_ai", @@ -23226,6 +23227,7 @@ "fireworks_ai/accounts/fireworks/models/minimax-m2p7": { "cache_read_input_token_cost": 6e-08, "cache_read_input_token_cost_priority": 6e-07, + "deprecation_date": "2026-08-27", "input_cost_per_token": 3e-07, "input_cost_per_token_priority": 1.2e-06, "litellm_provider": "fireworks_ai", @@ -23332,6 +23334,7 @@ "fireworks_ai/deepseek-v4-pro": { "cache_read_input_token_cost": 6e-07, "cache_read_input_token_cost_priority": 6e-07, + "deprecation_date": "2026-08-27", "input_cost_per_token": 1.2e-06, "input_cost_per_token_priority": 1.2e-06, "litellm_provider": "fireworks_ai", @@ -23550,6 +23553,7 @@ "fireworks_ai/minimax-m2p7": { "cache_read_input_token_cost": 6e-08, "cache_read_input_token_cost_priority": 6e-07, + "deprecation_date": "2026-08-27", "input_cost_per_token": 3e-07, "input_cost_per_token_priority": 1.2e-06, "litellm_provider": "fireworks_ai", @@ -48772,49 +48776,56 @@ "source": "https://cloud.google.com/gemini-enterprise-agent-platform/generative-ai/pricing" }, "vertex_ai/imagegeneration@006": { + "deprecation_date": "2025-09-24", "litellm_provider": "vertex_ai-image-models", "mode": "image_generation", "output_cost_per_image": 0.02, "source": "https://cloud.google.com/vertex-ai/generative-ai/pricing" }, "vertex_ai/imagen-3.0-fast-generate-001": { + "deprecation_date": "2026-06-30", "litellm_provider": "vertex_ai-image-models", "mode": "image_generation", "output_cost_per_image": 0.02, "source": "https://cloud.google.com/vertex-ai/generative-ai/pricing" }, "vertex_ai/imagen-3.0-generate-001": { + "deprecation_date": "2026-06-30", "litellm_provider": "vertex_ai-image-models", "mode": "image_generation", "output_cost_per_image": 0.04, "source": "https://cloud.google.com/vertex-ai/generative-ai/pricing" }, "vertex_ai/imagen-3.0-generate-002": { - "deprecation_date": "2025-11-10", + "deprecation_date": "2026-06-30", "litellm_provider": "vertex_ai-image-models", "mode": "image_generation", "output_cost_per_image": 0.04, "source": "https://cloud.google.com/vertex-ai/generative-ai/pricing" }, "vertex_ai/imagen-3.0-capability-001": { + "deprecation_date": "2026-06-30", "litellm_provider": "vertex_ai-image-models", "mode": "image_generation", "output_cost_per_image": 0.04, "source": "https://cloud.google.com/vertex-ai/generative-ai/docs/image/edit-insert-objects" }, "vertex_ai/imagen-4.0-fast-generate-001": { + "deprecation_date": "2026-06-30", "litellm_provider": "vertex_ai-image-models", "mode": "image_generation", "output_cost_per_image": 0.02, "source": "https://cloud.google.com/vertex-ai/generative-ai/pricing" }, "vertex_ai/imagen-4.0-generate-001": { + "deprecation_date": "2026-06-30", "litellm_provider": "vertex_ai-image-models", "mode": "image_generation", "output_cost_per_image": 0.04, "source": "https://cloud.google.com/vertex-ai/generative-ai/pricing" }, "vertex_ai/imagen-4.0-ultra-generate-001": { + "deprecation_date": "2026-06-30", "litellm_provider": "vertex_ai-image-models", "mode": "image_generation", "output_cost_per_image": 0.06, @@ -66280,6 +66291,7 @@ "source": "https://api.together.ai/v1/models" }, "together_ai/deepseek-ai/deepseek-coder-33b-instruct": { + "deprecation_date": "2024-08-22", "input_cost_per_token": 8e-07, "litellm_provider": "together_ai", "mode": "chat", @@ -66287,6 +66299,7 @@ "source": "https://api.together.ai/v1/models" }, "together_ai/deepseek-ai/DeepSeek-R1-Distill-Llama-70B": { + "deprecation_date": "2025-12-23", "input_cost_per_token": 2e-06, "litellm_provider": "together_ai", "mode": "chat", @@ -66294,6 +66307,7 @@ "source": "https://api.together.ai/v1/models" }, "together_ai/deepseek-ai/DeepSeek-R1-Distill-Qwen-1.5B": { + "deprecation_date": "2025-08-28", "input_cost_per_token": 1.8e-07, "litellm_provider": "together_ai", "mode": "chat", @@ -66301,6 +66315,7 @@ "source": "https://api.together.ai/v1/models" }, "together_ai/deepseek-ai/DeepSeek-R1-Distill-Qwen-14B": { + "deprecation_date": "2025-11-13", "input_cost_per_token": 1.6e-06, "litellm_provider": "together_ai", "mode": "chat", @@ -66394,6 +66409,7 @@ "source": "https://cloud.google.com/gemini-enterprise-agent-platform/generative-ai/pricing" }, "together_ai/google/gemma-2-27b-it": { + "deprecation_date": "2025-08-28", "input_cost_per_token": 8e-07, "litellm_provider": "together_ai", "mode": "chat", @@ -66418,6 +66434,7 @@ "source": "https://developers.openai.com/api/docs/pricing" }, "together_ai/meta-llama/Llama-3-8b-chat-hf": { + "deprecation_date": "2025-08-28", "input_cost_per_token": 2e-07, "litellm_provider": "together_ai", "mode": "chat", @@ -66446,6 +66463,7 @@ "source": "https://api.together.ai/v1/models" }, "together_ai/meta-llama/Meta-Llama-3-70B-Instruct-Turbo": { + "deprecation_date": "2025-12-23", "input_cost_per_token": 8.8e-07, "litellm_provider": "together_ai", "mode": "chat", @@ -66453,6 +66471,7 @@ "source": "https://api.together.ai/v1/models" }, "together_ai/meta-llama/Meta-Llama-3-8B-Instruct": { + "deprecation_date": "2025-08-28", "input_cost_per_token": 2e-07, "litellm_provider": "together_ai", "mode": "chat", @@ -66460,6 +66479,7 @@ "source": "https://api.together.ai/v1/models" }, "together_ai/NousResearch/Nous-Hermes-2-Mixtral-8x7B-DPO": { + "deprecation_date": "2025-08-28", "input_cost_per_token": 6e-07, "litellm_provider": "together_ai", "mode": "chat", @@ -66467,6 +66487,7 @@ "source": "https://api.together.ai/v1/models" }, "together_ai/nvidia/Llama-3.1-Nemotron-70B-Instruct-HF": { + "deprecation_date": "2025-08-28", "input_cost_per_token": 8.8e-07, "litellm_provider": "together_ai", "mode": "chat", @@ -66481,6 +66502,7 @@ "source": "https://api.together.ai/v1/models" }, "together_ai/Qwen/Qwen2-72B-Instruct": { + "deprecation_date": "2025-08-28", "input_cost_per_token": 9e-07, "litellm_provider": "together_ai", "mode": "chat", @@ -66488,6 +66510,7 @@ "source": "https://api.together.ai/v1/models" }, "together_ai/Qwen/Qwen2-VL-72B-Instruct": { + "deprecation_date": "2025-08-28", "input_cost_per_token": 1.2e-06, "litellm_provider": "together_ai", "mode": "chat", @@ -66509,6 +66532,7 @@ "source": "https://api.together.ai/v1/models" }, "together_ai/Qwen/Qwen2.5-Coder-32B-Instruct": { + "deprecation_date": "2025-11-13", "input_cost_per_token": 8e-07, "litellm_provider": "together_ai", "mode": "chat", @@ -66516,6 +66540,7 @@ "source": "https://api.together.ai/v1/models" }, "together_ai/Qwen/Qwen2.5-VL-72B-Instruct": { + "deprecation_date": "2026-01-05", "input_cost_per_token": 1.95e-06, "litellm_provider": "together_ai", "mode": "chat", diff --git a/model_prices_and_context_window.json b/model_prices_and_context_window.json index 372ce19e740..000615bea11 100644 --- a/model_prices_and_context_window.json +++ b/model_prices_and_context_window.json @@ -22839,6 +22839,7 @@ "fireworks_ai/accounts/fireworks/models/deepseek-v4-pro": { "cache_read_input_token_cost": 6e-07, "cache_read_input_token_cost_priority": 6e-07, + "deprecation_date": "2026-08-27", "input_cost_per_token": 1.2e-06, "input_cost_per_token_priority": 1.2e-06, "litellm_provider": "fireworks_ai", @@ -23226,6 +23227,7 @@ "fireworks_ai/accounts/fireworks/models/minimax-m2p7": { "cache_read_input_token_cost": 6e-08, "cache_read_input_token_cost_priority": 6e-07, + "deprecation_date": "2026-08-27", "input_cost_per_token": 3e-07, "input_cost_per_token_priority": 1.2e-06, "litellm_provider": "fireworks_ai", @@ -23332,6 +23334,7 @@ "fireworks_ai/deepseek-v4-pro": { "cache_read_input_token_cost": 6e-07, "cache_read_input_token_cost_priority": 6e-07, + "deprecation_date": "2026-08-27", "input_cost_per_token": 1.2e-06, "input_cost_per_token_priority": 1.2e-06, "litellm_provider": "fireworks_ai", @@ -23550,6 +23553,7 @@ "fireworks_ai/minimax-m2p7": { "cache_read_input_token_cost": 6e-08, "cache_read_input_token_cost_priority": 6e-07, + "deprecation_date": "2026-08-27", "input_cost_per_token": 3e-07, "input_cost_per_token_priority": 1.2e-06, "litellm_provider": "fireworks_ai", @@ -48772,49 +48776,56 @@ "source": "https://cloud.google.com/gemini-enterprise-agent-platform/generative-ai/pricing" }, "vertex_ai/imagegeneration@006": { + "deprecation_date": "2025-09-24", "litellm_provider": "vertex_ai-image-models", "mode": "image_generation", "output_cost_per_image": 0.02, "source": "https://cloud.google.com/vertex-ai/generative-ai/pricing" }, "vertex_ai/imagen-3.0-fast-generate-001": { + "deprecation_date": "2026-06-30", "litellm_provider": "vertex_ai-image-models", "mode": "image_generation", "output_cost_per_image": 0.02, "source": "https://cloud.google.com/vertex-ai/generative-ai/pricing" }, "vertex_ai/imagen-3.0-generate-001": { + "deprecation_date": "2026-06-30", "litellm_provider": "vertex_ai-image-models", "mode": "image_generation", "output_cost_per_image": 0.04, "source": "https://cloud.google.com/vertex-ai/generative-ai/pricing" }, "vertex_ai/imagen-3.0-generate-002": { - "deprecation_date": "2025-11-10", + "deprecation_date": "2026-06-30", "litellm_provider": "vertex_ai-image-models", "mode": "image_generation", "output_cost_per_image": 0.04, "source": "https://cloud.google.com/vertex-ai/generative-ai/pricing" }, "vertex_ai/imagen-3.0-capability-001": { + "deprecation_date": "2026-06-30", "litellm_provider": "vertex_ai-image-models", "mode": "image_generation", "output_cost_per_image": 0.04, "source": "https://cloud.google.com/vertex-ai/generative-ai/docs/image/edit-insert-objects" }, "vertex_ai/imagen-4.0-fast-generate-001": { + "deprecation_date": "2026-06-30", "litellm_provider": "vertex_ai-image-models", "mode": "image_generation", "output_cost_per_image": 0.02, "source": "https://cloud.google.com/vertex-ai/generative-ai/pricing" }, "vertex_ai/imagen-4.0-generate-001": { + "deprecation_date": "2026-06-30", "litellm_provider": "vertex_ai-image-models", "mode": "image_generation", "output_cost_per_image": 0.04, "source": "https://cloud.google.com/vertex-ai/generative-ai/pricing" }, "vertex_ai/imagen-4.0-ultra-generate-001": { + "deprecation_date": "2026-06-30", "litellm_provider": "vertex_ai-image-models", "mode": "image_generation", "output_cost_per_image": 0.06, @@ -66280,6 +66291,7 @@ "source": "https://api.together.ai/v1/models" }, "together_ai/deepseek-ai/deepseek-coder-33b-instruct": { + "deprecation_date": "2024-08-22", "input_cost_per_token": 8e-07, "litellm_provider": "together_ai", "mode": "chat", @@ -66287,6 +66299,7 @@ "source": "https://api.together.ai/v1/models" }, "together_ai/deepseek-ai/DeepSeek-R1-Distill-Llama-70B": { + "deprecation_date": "2025-12-23", "input_cost_per_token": 2e-06, "litellm_provider": "together_ai", "mode": "chat", @@ -66294,6 +66307,7 @@ "source": "https://api.together.ai/v1/models" }, "together_ai/deepseek-ai/DeepSeek-R1-Distill-Qwen-1.5B": { + "deprecation_date": "2025-08-28", "input_cost_per_token": 1.8e-07, "litellm_provider": "together_ai", "mode": "chat", @@ -66301,6 +66315,7 @@ "source": "https://api.together.ai/v1/models" }, "together_ai/deepseek-ai/DeepSeek-R1-Distill-Qwen-14B": { + "deprecation_date": "2025-11-13", "input_cost_per_token": 1.6e-06, "litellm_provider": "together_ai", "mode": "chat", @@ -66394,6 +66409,7 @@ "source": "https://cloud.google.com/gemini-enterprise-agent-platform/generative-ai/pricing" }, "together_ai/google/gemma-2-27b-it": { + "deprecation_date": "2025-08-28", "input_cost_per_token": 8e-07, "litellm_provider": "together_ai", "mode": "chat", @@ -66418,6 +66434,7 @@ "source": "https://developers.openai.com/api/docs/pricing" }, "together_ai/meta-llama/Llama-3-8b-chat-hf": { + "deprecation_date": "2025-08-28", "input_cost_per_token": 2e-07, "litellm_provider": "together_ai", "mode": "chat", @@ -66446,6 +66463,7 @@ "source": "https://api.together.ai/v1/models" }, "together_ai/meta-llama/Meta-Llama-3-70B-Instruct-Turbo": { + "deprecation_date": "2025-12-23", "input_cost_per_token": 8.8e-07, "litellm_provider": "together_ai", "mode": "chat", @@ -66453,6 +66471,7 @@ "source": "https://api.together.ai/v1/models" }, "together_ai/meta-llama/Meta-Llama-3-8B-Instruct": { + "deprecation_date": "2025-08-28", "input_cost_per_token": 2e-07, "litellm_provider": "together_ai", "mode": "chat", @@ -66460,6 +66479,7 @@ "source": "https://api.together.ai/v1/models" }, "together_ai/NousResearch/Nous-Hermes-2-Mixtral-8x7B-DPO": { + "deprecation_date": "2025-08-28", "input_cost_per_token": 6e-07, "litellm_provider": "together_ai", "mode": "chat", @@ -66467,6 +66487,7 @@ "source": "https://api.together.ai/v1/models" }, "together_ai/nvidia/Llama-3.1-Nemotron-70B-Instruct-HF": { + "deprecation_date": "2025-08-28", "input_cost_per_token": 8.8e-07, "litellm_provider": "together_ai", "mode": "chat", @@ -66481,6 +66502,7 @@ "source": "https://api.together.ai/v1/models" }, "together_ai/Qwen/Qwen2-72B-Instruct": { + "deprecation_date": "2025-08-28", "input_cost_per_token": 9e-07, "litellm_provider": "together_ai", "mode": "chat", @@ -66488,6 +66510,7 @@ "source": "https://api.together.ai/v1/models" }, "together_ai/Qwen/Qwen2-VL-72B-Instruct": { + "deprecation_date": "2025-08-28", "input_cost_per_token": 1.2e-06, "litellm_provider": "together_ai", "mode": "chat", @@ -66509,6 +66532,7 @@ "source": "https://api.together.ai/v1/models" }, "together_ai/Qwen/Qwen2.5-Coder-32B-Instruct": { + "deprecation_date": "2025-11-13", "input_cost_per_token": 8e-07, "litellm_provider": "together_ai", "mode": "chat", @@ -66516,6 +66540,7 @@ "source": "https://api.together.ai/v1/models" }, "together_ai/Qwen/Qwen2.5-VL-72B-Instruct": { + "deprecation_date": "2026-01-05", "input_cost_per_token": 1.95e-06, "litellm_provider": "together_ai", "mode": "chat", From b99f0c812f0d820242f9ac8988213683cdcf0d2f Mon Sep 17 00:00:00 2001 From: yassin Date: Tue, 15 Sep 2026 23:36:12 +0000 Subject: [PATCH 034/133] fix(proxy): log configured model access denial only at the auth error boundary Move the WARNING that carries the internal denial detail out of the message formatter and into the auth exception handler. The denial exceptions now carry internal_message so access-group probes and fallback paths that catch and recover from the denial no longer log a false denial for an allowed request Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- litellm/proxy/_types.py | 13 ++ litellm/proxy/auth/auth_checks.py | 47 ++++---- litellm/proxy/auth/auth_exception_handler.py | 13 ++ litellm/proxy/auth/handle_jwt.py | 23 ++-- litellm/proxy/auth/model_access_denied.py | 19 +++ .../proxy/auth/test_auth_checks.py | 49 ++++---- .../proxy/auth/test_auth_exception_handler.py | 111 +++++++++++++++++- .../proxy/auth/test_handle_jwt.py | 9 +- 8 files changed, 225 insertions(+), 59 deletions(-) create mode 100644 litellm/proxy/auth/model_access_denied.py diff --git a/litellm/proxy/_types.py b/litellm/proxy/_types.py index d4eda1c9540..8949433dc34 100644 --- a/litellm/proxy/_types.py +++ b/litellm/proxy/_types.py @@ -4030,6 +4030,19 @@ class ProxyException(Exception): return error_dict +class ModelAccessDeniedProxyException(ProxyException): + def __init__( + self, + message: str, + internal_message: str, + type: str, + param: str | None, + code: int | str | None, + ) -> None: + super().__init__(message=message, type=type, param=param, code=code) + self.internal_message: Final = internal_message + + class CommonProxyErrors(str, enum.Enum): db_not_connected_error = ( "DB not connected. This endpoint needs a database; set DATABASE_URL to a " diff --git a/litellm/proxy/auth/auth_checks.py b/litellm/proxy/auth/auth_checks.py index b13214eaf5e..80bb1e3b319 100644 --- a/litellm/proxy/auth/auth_checks.py +++ b/litellm/proxy/auth/auth_checks.py @@ -32,7 +32,6 @@ from litellm.constants import ( DEFAULT_MAX_RECURSE_DEPTH, EMAIL_BUDGET_ALERT_MAX_SPEND_ALERT_PERCENTAGE, END_USER_RESTRICTED_REGISTRY_MAX_SIZE, - MODEL_ACCESS_DENIED_MESSAGE_MODEL_PLACEHOLDER, MODEL_ACCESS_GROUP_REGISTRY_MAX_SIZE, REGISTRY_ERROR_NEGATIVE_CACHE_TTL, TAG_REGISTRY_MAX_SIZE, @@ -61,6 +60,7 @@ from litellm.proxy._types import ( LiteLLM_UserTable, LiteLLMRoutes, LitellmUserRoles, + ModelAccessDeniedProxyException, NewTeamRequest, ProxyErrorTypes, ProxyException, @@ -72,6 +72,7 @@ from litellm.proxy.auth.budget_throttle import ( budget_throttle_percentage, should_throttle_budget_exceeded, ) +from litellm.proxy.auth.model_access_denied import client_facing_model_access_denied_message from litellm.proxy.auth.route_checks import RouteChecks from litellm.proxy.common_utils.auth_cache_invalidation_pubsub import publish_auth_cache_invalidation from litellm.proxy.common_utils.cache_pydantic_utils import CacheCodec @@ -4034,14 +4035,6 @@ async def _get_agent_ids_from_access_groups( ) -def client_facing_model_access_denied_message(internal_message: str, model: str | list[str]) -> str: - template: Final = litellm.model_access_denied_message - if not template: - return internal_message - verbose_proxy_logger.warning(internal_message.replace("\r", "").replace("\n", "")) - return template.replace(MODEL_ACCESS_DENIED_MESSAGE_MODEL_PLACEHOLDER, str(model)) - - def _resolve_all_team_model_sentinel_for_auth_check( models: list[str], llm_router: Router | None, @@ -4163,11 +4156,13 @@ def _can_object_call_model( ): return True - raise ProxyException( - message=client_facing_model_access_denied_message( - internal_message=f"{object_type} not allowed to access model. This {object_type} can only access models={models}. Tried to access {model}", - model=model, - ), + internal_message: Final = ( + f"{object_type} not allowed to access model. This {object_type} can only access models={models}. " + f"Tried to access {model}" + ) + raise ModelAccessDeniedProxyException( + message=client_facing_model_access_denied_message(internal_message=internal_message, model=model), + internal_message=internal_message, type=ProxyErrorTypes.get_model_access_error_type_for_object(object_type=object_type), param="model", code=status.HTTP_403_FORBIDDEN, @@ -4792,11 +4787,13 @@ async def can_user_call_model( return True if SpecialModelNames.no_default_models.value in user_object.models: - raise ProxyException( - message=client_facing_model_access_denied_message( - internal_message=f"User not allowed to access model. No default model access, only team models allowed. Tried to access {model}", - model=model, - ), + internal_message: Final = ( + f"User not allowed to access model. No default model access, only team models allowed. " + f"Tried to access {model}" + ) + raise ModelAccessDeniedProxyException( + message=client_facing_model_access_denied_message(internal_message=internal_message, model=model), + internal_message=internal_message, type=ProxyErrorTypes.key_model_access_denied, param="model", code=status.HTTP_403_FORBIDDEN, @@ -5397,11 +5394,13 @@ async def _check_team_member_model_access( team_id=team_object.team_id, ) except ProxyException: - raise ProxyException( - message=client_facing_model_access_denied_message( - internal_message=f"Team member not allowed to access model. User={valid_token.user_id}, Team={team_object.team_id}, Model={model}. Allowed member models = {member_allowed_models}", - model=model, - ), + internal_message: Final = ( + f"Team member not allowed to access model. User={valid_token.user_id}, Team={team_object.team_id}, " + f"Model={model}. Allowed member models = {member_allowed_models}" + ) + raise ModelAccessDeniedProxyException( + message=client_facing_model_access_denied_message(internal_message=internal_message, model=model), + internal_message=internal_message, type=ProxyErrorTypes.team_model_access_denied, param="model", code=status.HTTP_403_FORBIDDEN, diff --git a/litellm/proxy/auth/auth_exception_handler.py b/litellm/proxy/auth/auth_exception_handler.py index 661b6a83c38..4a764cae6cc 100644 --- a/litellm/proxy/auth/auth_exception_handler.py +++ b/litellm/proxy/auth/auth_exception_handler.py @@ -15,6 +15,7 @@ from litellm.integrations.otel.runtime import seed_request_identity from litellm.litellm_core_utils.core_helpers import is_expected_client_error from litellm.proxy._types import ( LitellmUserRoles, + ModelAccessDeniedProxyException, ProxyErrorTypes, ProxyException, UserAPIKeyAuth, @@ -25,6 +26,7 @@ from litellm.proxy.auth.auth_utils import ( mark_invalid_virtual_key_error, normalize_request_route, ) +from litellm.proxy.auth.model_access_denied import ModelAccessDeniedHTTPException from litellm.proxy.db.exception_handler import PrismaDBExceptionHandler from litellm.types.services import ServiceTypes @@ -75,6 +77,14 @@ def _as_proxy_exception(e: Exception) -> ProxyException: ) +def _model_access_denied_internal_message(e: Exception) -> str | None: + if not litellm.model_access_denied_message: + return None + if not isinstance(e, (ModelAccessDeniedProxyException, ModelAccessDeniedHTTPException)): + return None + return e.internal_message.replace("\r", "").replace("\n", "") + + def _get_user_agent(request: Request) -> str | None: if "headers" not in request.scope: return None @@ -166,6 +176,9 @@ class UserAPIKeyAuthExceptionHandler: # survives a raising callback pipeline. Classify and route malformed virtual-key # rejections to WARNING on stdout (suppressible via LITELLM_LOG=ERROR). log_extra: Final = {"requester_ip": requester_ip} + denied_internal_message: Final = _model_access_denied_internal_message(e) + if denied_internal_message is not None: + verbose_proxy_logger.warning(denied_internal_message, extra=log_extra) is_invalid_virtual_key: Final = is_invalid_virtual_key_error(e) is_quiet_log: Final = is_invalid_virtual_key and not litellm.log_client_error_tracebacks logger: Final = verbose_proxy_stdout_logger if is_quiet_log else verbose_proxy_logger diff --git a/litellm/proxy/auth/handle_jwt.py b/litellm/proxy/auth/handle_jwt.py index 6e5e75a5e20..ea6d52b28f0 100644 --- a/litellm/proxy/auth/handle_jwt.py +++ b/litellm/proxy/auth/handle_jwt.py @@ -52,6 +52,10 @@ from litellm.proxy._types import ( UserAPIKeyAuth, ) from litellm.proxy.auth.auth_checks import can_team_access_model +from litellm.proxy.auth.model_access_denied import ( + ModelAccessDeniedHTTPException, + client_facing_model_access_denied_message, +) from litellm.proxy.auth.resolvers.grants import GrantResolver, UserLookup, canonical_user_id from litellm.proxy.auth.route_checks import RouteChecks from litellm.proxy.auth.team_grants import team_model_aliases @@ -66,7 +70,6 @@ from litellm.types.agents import AgentResponse from .auth_checks import ( _allowed_routes_check, allowed_routes_check, - client_facing_model_access_denied_message, get_actual_routes, get_end_user_object, get_org_object, @@ -1338,12 +1341,13 @@ class JWTAuthManager: return True if model not in role_based_models: - raise HTTPException( + internal_message: Final = ( + f"Role={rbac_role} not allowed to call model={model}. Allowed models={role_based_models}" + ) + raise ModelAccessDeniedHTTPException( + internal_message=internal_message, status_code=403, - detail=client_facing_model_access_denied_message( - internal_message=f"Role={rbac_role} not allowed to call model={model}. Allowed models={role_based_models}", - model=model, - ), + detail=client_facing_model_access_denied_message(internal_message=internal_message, model=model), ) return True @@ -1372,12 +1376,13 @@ class JWTAuthManager: return if requested_model not in allowed_models: - raise HTTPException( + internal_message: Final = f"model={requested_model} not allowed. Allowed_models={allowed_models}" + raise ModelAccessDeniedHTTPException( + internal_message=internal_message, status_code=403, detail={ "error": client_facing_model_access_denied_message( - internal_message=f"model={requested_model} not allowed. Allowed_models={allowed_models}", - model=requested_model, + internal_message=internal_message, model=requested_model ) }, ) diff --git a/litellm/proxy/auth/model_access_denied.py b/litellm/proxy/auth/model_access_denied.py new file mode 100644 index 00000000000..8164e06c42a --- /dev/null +++ b/litellm/proxy/auth/model_access_denied.py @@ -0,0 +1,19 @@ +from typing import Final + +from fastapi import HTTPException + +import litellm +from litellm.constants import MODEL_ACCESS_DENIED_MESSAGE_MODEL_PLACEHOLDER + + +def client_facing_model_access_denied_message(internal_message: str, model: str | list[str]) -> str: + template: Final = litellm.model_access_denied_message + if not template: + return internal_message + return template.replace(MODEL_ACCESS_DENIED_MESSAGE_MODEL_PLACEHOLDER, str(model)) + + +class ModelAccessDeniedHTTPException(HTTPException): + def __init__(self, internal_message: str, status_code: int, detail: str | dict[str, str]) -> None: + super().__init__(status_code=status_code, detail=detail) + self.internal_message: Final = internal_message diff --git a/tests/test_litellm/proxy/auth/test_auth_checks.py b/tests/test_litellm/proxy/auth/test_auth_checks.py index 0052cfb66d3..0677fb8af29 100644 --- a/tests/test_litellm/proxy/auth/test_auth_checks.py +++ b/tests/test_litellm/proxy/auth/test_auth_checks.py @@ -25,6 +25,7 @@ from litellm.proxy._types import ( LiteLLM_TeamTable, LiteLLM_UserTable, LitellmUserRoles, + ModelAccessDeniedProxyException, ProxyErrorTypes, ProxyException, SSOUserDefinedValues, @@ -1682,11 +1683,11 @@ def test_can_object_call_model_no_access_to_alias_or_underlying(): _DENIED_MESSAGE_TEMPLATE: Final = "The model `{model}` is unavailable for this API key or does not exist." -def test_can_object_call_model_denial_uses_configured_message_and_logs_detail(monkeypatch, caplog): +def test_can_object_call_model_denial_uses_configured_message_and_keeps_detail_on_exception(monkeypatch, caplog): monkeypatch.setattr(litellm, "model_access_denied_message", _DENIED_MESSAGE_TEMPLATE) - with caplog.at_level("WARNING", logger="LiteLLM Proxy"): - with pytest.raises(ProxyException) as exc_info: + with caplog.at_level("DEBUG", logger="LiteLLM Proxy"): + with pytest.raises(ModelAccessDeniedProxyException) as exc_info: _can_object_call_model( model="anthropic-sonnet-4-5", llm_router=None, @@ -1700,28 +1701,30 @@ def test_can_object_call_model_denial_uses_configured_message_and_logs_detail(mo assert exc_info.value.type == ProxyErrorTypes.key_model_access_denied assert exc_info.value.param == "model" assert int(exc_info.value.code) == status.HTTP_403_FORBIDDEN - assert "internal-models" in caplog.text - assert "anthropic-sonnet-4-5" in caplog.text - - -def test_can_object_call_model_denial_log_strips_newlines_from_requested_model(monkeypatch, caplog): - monkeypatch.setattr(litellm, "model_access_denied_message", _DENIED_MESSAGE_TEMPLATE) - - with caplog.at_level("WARNING", logger="LiteLLM Proxy"): - with pytest.raises(ProxyException): - _can_object_call_model( - model="gpt-5.6\r\nWARNING forged log line", - llm_router=None, - models=["internal-models"], - object_type="key", - ) - - denial_records = [r for r in caplog.records if "not allowed to access model" in r.getMessage()] - assert len(denial_records) == 1 - assert denial_records[0].getMessage() == ( + assert exc_info.value.internal_message == ( "key not allowed to access model. This key can only access models=['internal-models']. " - "Tried to access gpt-5.6WARNING forged log line" + "Tried to access anthropic-sonnet-4-5" ) + assert "internal-models" not in caplog.text + + +@pytest.mark.asyncio +async def test_access_group_fallback_grant_does_not_log_a_denial(monkeypatch, caplog): + from litellm.proxy.auth.auth_checks import can_team_access_model + + monkeypatch.setattr(litellm, "model_access_denied_message", _DENIED_MESSAGE_TEMPLATE) + team_object = LiteLLM_TeamTable(team_id="team-123", models=["direct-model"], access_group_ids=["ag-1"]) + + with ( + patch( # test-quality-ok: access-group lookup has no dependency-injection seam + "litellm.proxy.auth.auth_checks._get_models_from_access_groups", + new=AsyncMock(return_value=["group-model"]), + ), + caplog.at_level("DEBUG", logger="LiteLLM Proxy"), + ): + assert await can_team_access_model("group-model", team_object, None) is True + + assert "not allowed to access model" not in caplog.text @pytest.mark.parametrize("unset_value", [None, ""]) diff --git a/tests/test_litellm/proxy/auth/test_auth_exception_handler.py b/tests/test_litellm/proxy/auth/test_auth_exception_handler.py index 6e9770bced8..602c074ee66 100644 --- a/tests/test_litellm/proxy/auth/test_auth_exception_handler.py +++ b/tests/test_litellm/proxy/auth/test_auth_exception_handler.py @@ -26,11 +26,18 @@ from prisma.errors import ( ) +import litellm from litellm._logging import verbose_proxy_logger from litellm.constants import INVALID_VIRTUAL_KEY_ERROR_MARKER from litellm.exceptions import BudgetExceededError -from litellm.proxy._types import ProxyErrorTypes, ProxyException, UserAPIKeyAuth +from litellm.proxy._types import ( + ModelAccessDeniedProxyException, + ProxyErrorTypes, + ProxyException, + UserAPIKeyAuth, +) from litellm.proxy.auth.auth_exception_handler import UserAPIKeyAuthExceptionHandler +from litellm.proxy.auth.model_access_denied import ModelAccessDeniedHTTPException class _EngineHttp500: @@ -982,3 +989,105 @@ async def test_handle_authentication_error_traceback_only_for_unexpected_errors( assert records[0].levelname == expect_level expected_logger_name = "LiteLLM Proxy.stdout" if expect_level == "WARNING" else "LiteLLM Proxy" assert records[0].name == expected_logger_name + + +_DENIED_MESSAGE_TEMPLATE = "The model `{model}` is unavailable for this API key or does not exist." + + +def _denied_proxy_exception() -> ModelAccessDeniedProxyException: + return ModelAccessDeniedProxyException( + message="The model `gpt-5.6\r\nWARNING forged log line` is unavailable for this API key or does not exist.", + internal_message="key not allowed to access model. This key can only access models=['internal-models']. " + "Tried to access gpt-5.6\r\nWARNING forged log line", + type=ProxyErrorTypes.key_model_access_denied, + param="model", + code=status.HTTP_403_FORBIDDEN, + ) + + +def _denied_jwt_exception() -> ModelAccessDeniedHTTPException: + return ModelAccessDeniedHTTPException( + internal_message="Role=engineer not allowed to call model=gpt-5.6\r\nWARNING forged log line. " + "Allowed models=['internal-models']", + status_code=status.HTTP_403_FORBIDDEN, + detail="The model `gpt-5.6` is unavailable for this API key or does not exist.", + ) + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + "make_denial", + [ + pytest.param(_denied_proxy_exception, id="proxy_exception"), + pytest.param(_denied_jwt_exception, id="jwt_http_exception"), + ], +) +async def test_handle_authentication_error_logs_sanitized_model_access_denial_once(monkeypatch, make_denial, caplog): + monkeypatch.setattr(litellm, "model_access_denied_message", _DENIED_MESSAGE_TEMPLATE) + handler = UserAPIKeyAuthExceptionHandler() + denial = make_denial() + + with ( + patch( # test-quality-ok: handler reads proxy_server globals at call time + "litellm.proxy.proxy_server.proxy_logging_obj.post_call_failure_hook", + new_callable=AsyncMock, + return_value=None, + ), + patch( # test-quality-ok: handler reads proxy_server globals at call time + "litellm.proxy.auth.auth_exception_handler.seed_request_identity", + ), + patch( # test-quality-ok: handler reads proxy_server globals at call time + "litellm.proxy.proxy_server.general_settings", + {"allow_requests_on_db_unavailable": False}, + ), + caplog.at_level("WARNING", logger="LiteLLM Proxy"), + pytest.raises(ProxyException) as exc_info, + ): + await handler._handle_authentication_error(denial, MagicMock(), {}, "/v1/chat/completions", None, "sk-bad-key") + + assert exc_info.value.code == str(status.HTTP_403_FORBIDDEN) + assert "internal-models" not in str(exc_info.value.message) + denial_records = [r for r in caplog.records if "internal-models" in r.getMessage()] + assert len(denial_records) == 1 + assert denial_records[0].levelname == "WARNING" + assert "\n" not in denial_records[0].getMessage() + assert "gpt-5.6WARNING forged log line" in denial_records[0].getMessage() + + +@pytest.mark.asyncio +@pytest.mark.parametrize("unset_value", [None, ""]) +async def test_handle_authentication_error_no_extra_denial_log_when_message_not_configured( + monkeypatch, unset_value, caplog +): + monkeypatch.setattr(litellm, "model_access_denied_message", unset_value) + handler = UserAPIKeyAuthExceptionHandler() + denial = ModelAccessDeniedProxyException( + message="key not allowed to access model. This key can only access models=['internal-models']. " + "Tried to access gpt-5.6", + internal_message="key not allowed to access model. This key can only access models=['internal-models']. " + "Tried to access gpt-5.6", + type=ProxyErrorTypes.key_model_access_denied, + param="model", + code=status.HTTP_403_FORBIDDEN, + ) + + with ( + patch( # test-quality-ok: handler reads proxy_server globals at call time + "litellm.proxy.proxy_server.proxy_logging_obj.post_call_failure_hook", + new_callable=AsyncMock, + return_value=None, + ), + patch( # test-quality-ok: handler reads proxy_server globals at call time + "litellm.proxy.auth.auth_exception_handler.seed_request_identity", + ), + patch( # test-quality-ok: handler reads proxy_server globals at call time + "litellm.proxy.proxy_server.general_settings", + {"allow_requests_on_db_unavailable": False}, + ), + caplog.at_level("WARNING", logger="LiteLLM Proxy"), + pytest.raises(ProxyException) as exc_info, + ): + await handler._handle_authentication_error(denial, MagicMock(), {}, "/v1/chat/completions", None, "sk-bad-key") + + assert "internal-models" in str(exc_info.value.message) + assert [r for r in caplog.records if r.levelname == "WARNING" and "internal-models" in r.getMessage()] == [] diff --git a/tests/test_litellm/proxy/auth/test_handle_jwt.py b/tests/test_litellm/proxy/auth/test_handle_jwt.py index 921472f2f43..9fab1e1785a 100644 --- a/tests/test_litellm/proxy/auth/test_handle_jwt.py +++ b/tests/test_litellm/proxy/auth/test_handle_jwt.py @@ -37,6 +37,7 @@ from litellm.proxy.auth.handle_jwt import ( JWTHandler, NoMatchingJWTPublicKeyError, ) +from litellm.proxy.auth.model_access_denied import ModelAccessDeniedHTTPException from litellm.types.agents import AgentResponse @@ -6990,7 +6991,7 @@ def test_can_rbac_role_call_model_denial_honors_configured_message(monkeypatch, ] } - with pytest.raises(HTTPException) as exc_info: + with pytest.raises(ModelAccessDeniedHTTPException) as exc_info: JWTAuthManager.can_rbac_role_call_model( rbac_role=LitellmUserRoles.INTERNAL_USER, general_settings=general_settings, @@ -6999,6 +7000,9 @@ def test_can_rbac_role_call_model_denial_honors_configured_message(monkeypatch, assert exc_info.value.status_code == 403 assert exc_info.value.detail == expected_detail + assert exc_info.value.internal_message == ( + "Role=internal_user not allowed to call model=gpt-5.6. Allowed models=['gpt-5.6-mini']" + ) @pytest.mark.parametrize( @@ -7012,7 +7016,7 @@ def test_can_rbac_role_call_model_denial_honors_configured_message(monkeypatch, def test_check_scope_based_access_denial_honors_configured_message(monkeypatch, configured_message, expected_error): monkeypatch.setattr(litellm, "model_access_denied_message", configured_message) - with pytest.raises(HTTPException) as exc_info: + with pytest.raises(ModelAccessDeniedHTTPException) as exc_info: JWTAuthManager.check_scope_based_access( scope_mappings=[ScopeMapping(scope="litellm.api.consumer", models=["gpt-5.6-mini"])], scopes=["litellm.api.consumer"], @@ -7022,3 +7026,4 @@ def test_check_scope_based_access_denial_honors_configured_message(monkeypatch, assert exc_info.value.status_code == 403 assert exc_info.value.detail == {"error": expected_error} + assert exc_info.value.internal_message == "model=gpt-5.6 not allowed. Allowed_models=['gpt-5.6-mini']" From 88d0371a4670f57ca3e4c3824cb6846fe3fdacd7 Mon Sep 17 00:00:00 2001 From: Joshua Valluru <326636767+joshua-berri@users.noreply.github.com> Date: Tue, 15 Sep 2026 16:50:55 -0700 Subject: [PATCH 035/133] fix(mcp): reuse the standard JWT auth builder for OAuth ownership --- .../mcp_server/bridge_token_flow.py | 38 ++++++---- .../mcp_server/test_discoverable_endpoints.py | 74 ++++++++++++++++--- 2 files changed, 88 insertions(+), 24 deletions(-) diff --git a/litellm/proxy/_experimental/mcp_server/bridge_token_flow.py b/litellm/proxy/_experimental/mcp_server/bridge_token_flow.py index 3817935bf71..2f3e50b803f 100644 --- a/litellm/proxy/_experimental/mcp_server/bridge_token_flow.py +++ b/litellm/proxy/_experimental/mcp_server/bridge_token_flow.py @@ -314,15 +314,15 @@ async def _extract_user_id_from_request(request: Request) -> str | None: token: Final = _litellm_key_from_request(request) if token is not None and JWTHandler.is_jwt(token): - return await _extract_jwt_user_id(token) + return await _extract_jwt_user_id(request, token) resolved: Final = await _resolve_active_litellm_key(request) if not isinstance(resolved, _ResolvedKey): return None return _active_key_user_id(resolved.key) -async def _extract_jwt_user_id(token: str) -> str | None: - from litellm.proxy._types import LitellmUserRoles, UserAPIKeyAuth # noqa: PLC0415 # proxy import cycle +async def _extract_jwt_user_id(request: Request, token: str) -> str | None: + from litellm.proxy._types import UserAPIKeyAuth # noqa: PLC0415 # proxy import cycle from litellm.proxy.auth.handle_jwt import JWTAuthManager # noqa: PLC0415 # proxy import cycle from litellm.proxy.auth.user_api_key_auth import ( # noqa: PLC0415 # proxy import cycle _resolve_jwt_to_virtual_key, # pyright: ignore[reportPrivateUsage] # reuse admission mapping policy without provisioning a new key @@ -339,11 +339,11 @@ async def _extract_jwt_user_id(token: str) -> str | None: if general_settings.get("enable_jwt_auth") is not True or premium_user is not True: return None try: - claims: Final = await jwt_handler.auth_jwt(token=token) - validate: Final = jwt_handler.litellm_jwtauth.custom_validate - if validate is not None and not validate(claims): - return None if jwt_handler.litellm_jwtauth.is_virtual_key_mapping_configured(): + claims: Final = await jwt_handler.auth_jwt(token=token) + validate: Final = jwt_handler.litellm_jwtauth.custom_validate + if validate is not None and not validate(claims): + return None mapped: Final = await _resolve_jwt_to_virtual_key( jwt_claims=claims, jwt_handler=jwt_handler, @@ -356,16 +356,24 @@ async def _extract_jwt_user_id(token: str) -> str | None: return None if await _key_owner_scim_deactivated(mapped) else _active_key_user_id(mapped) if mapped is not None: return None - user_id, user_email, valid_email = await JWTAuthManager.get_user_info(jwt_handler, claims) - object_id: Final = jwt_handler.get_object_id(token=claims, default_value=None) - owner_id: Final = ( - object_id - if jwt_handler.get_rbac_role(token=claims) == LitellmUserRoles.INTERNAL_USER and object_id - else user_id + identity: Final = await JWTAuthManager.auth_builder( + api_key=token, + jwt_handler=jwt_handler, + request_data={}, + general_settings=general_settings, + route=request.url.path, + prisma_client=prisma_client, + user_api_key_cache=user_api_key_cache, + parent_otel_span=None, + proxy_logging_obj=proxy_logging_obj, + request_headers=dict(request.headers), + request_method=request.method, ) - if not owner_id or valid_email is False: + owner_id: Final = identity["user_id"] + if not owner_id: return None - owner: Final = await load_active_user_by_id(owner_id, sso_user_id=owner_id, user_email=user_email) + # Admin JWTs can return before auth_builder loads the canonical database user. + owner: Final = await load_active_user_by_id(owner_id, sso_user_id=owner_id, user_email=identity["user_email"]) return None if isinstance(owner, str) else owner.user_id except Exception as exc: # noqa: BLE001 # public OAuth exchange stays available; unvalidated identities never write credentials verbose_logger.debug("OAuth JWT identity could not be validated (%s)", type(exc).__name__) diff --git a/tests/test_litellm/proxy/_experimental/mcp_server/test_discoverable_endpoints.py b/tests/test_litellm/proxy/_experimental/mcp_server/test_discoverable_endpoints.py index ce985064eb0..d1ab8809861 100644 --- a/tests/test_litellm/proxy/_experimental/mcp_server/test_discoverable_endpoints.py +++ b/tests/test_litellm/proxy/_experimental/mcp_server/test_discoverable_endpoints.py @@ -11421,6 +11421,7 @@ def _oauth_identity_jwt( audience: str = "litellm-proxy", issuer: str = "https://idp.example.test", owner: str | None = "jwt-owner", + scope: str = "", ) -> str: import jwt @@ -11432,6 +11433,7 @@ def _oauth_identity_jwt( "iss": issuer, "aud": audience, "exp": int(time.time()) + expires_in, + "scope": scope, }, signing_key, algorithm="RS256", @@ -11440,9 +11442,11 @@ def _oauth_identity_jwt( @pytest.mark.asyncio @pytest.mark.parametrize("header", ["Authorization", "x-litellm-api-key"]) +@pytest.mark.parametrize("policy_allowed", [False, True]) async def test_oauth_exchange_stores_token_for_validated_jwt_user( jwt_oauth_identity: tuple["JWTHandler", "RSAPrivateKey"], header: str, + policy_allowed: bool, monkeypatch: pytest.MonkeyPatch, ) -> None: import httpx @@ -11451,7 +11455,8 @@ async def test_oauth_exchange_stores_token_for_validated_jwt_user( from litellm.proxy._types import MCPTransport from litellm.types.mcp_server.mcp_server_manager import MCPServer - _, signing_key = jwt_oauth_identity + handler, signing_key = jwt_oauth_identity + handler.litellm_jwtauth.enforce_team_based_model_access = not policy_allowed bearer: Final = _oauth_identity_jwt(signing_key) request: Final = _token_request({header: f"Bearer {bearer}"}) server: Final = MCPServer( @@ -11501,6 +11506,10 @@ async def test_oauth_exchange_stores_token_for_validated_jwt_user( code_verifier=None, ) assert response.status_code == 200 + assert json.loads(response.body)["access_token"] == "upstream-token" + if not policy_allowed: + table.upsert.assert_not_awaited() + return table.upsert.assert_awaited_once() stored: Final = table.upsert.call_args.kwargs assert stored["where"] == {"user_id_server_id": {"user_id": "jwt-owner", "server_id": server.server_id}} @@ -11525,6 +11534,8 @@ async def test_oauth_exchange_stores_token_for_validated_jwt_user( "scim_inactive", "custom_validate", "missing_database", + "denied_route", + "required_team", ], ) async def test_oauth_jwt_identity_rejects_untrusted_or_inactive_owner( @@ -11537,6 +11548,7 @@ async def test_oauth_jwt_identity_rejects_untrusted_or_inactive_owner( from litellm.models.user import LiteLLM_UserTable from litellm.proxy import proxy_server from litellm.proxy._experimental.mcp_server.bridge_token_flow import _extract_user_id_from_request + from litellm.proxy._types import LitellmUserRoles, RoleBasedPermissions handler, signing_key = jwt_oauth_identity key: Final = ( @@ -11561,6 +11573,18 @@ async def test_oauth_jwt_identity_rejects_untrusted_or_inactive_owner( ) if rejection == "custom_validate": handler.litellm_jwtauth.custom_validate = lambda claims: False + if rejection == "denied_route": + handler.litellm_jwtauth.enforce_rbac = True + monkeypatch.setattr( + proxy_server, + "general_settings", + { + "enable_jwt_auth": True, + "role_permissions": [RoleBasedPermissions(role=LitellmUserRoles.INTERNAL_USER, routes=["/models"])], + }, + ) + if rejection == "required_team": + handler.litellm_jwtauth.enforce_team_based_model_access = True assert await _extract_user_id_from_request(_token_request({"Authorization": f"Bearer {bearer}"})) is None @@ -11586,7 +11610,9 @@ async def test_oauth_jwt_cannot_override_explicit_litellm_key( @pytest.mark.asyncio -@pytest.mark.parametrize("mapping", ["active", "blocked", "inactive_owner", "fallback", "pending", "reject"]) +@pytest.mark.parametrize( + "mapping", ["active", "blocked", "inactive_owner", "fallback", "pending", "reject", "custom_reject"] +) async def test_oauth_jwt_uses_configured_virtual_key_owner( jwt_oauth_identity: tuple["JWTHandler", "RSAPrivateKey"], mapping: str, @@ -11598,6 +11624,8 @@ async def test_oauth_jwt_uses_configured_virtual_key_owner( handler, signing_key = jwt_oauth_identity handler.litellm_jwtauth.virtual_key_claim_field = "sub" + if mapping == "custom_reject": + handler.litellm_jwtauth.custom_validate = lambda claims: False handler.litellm_jwtauth.unregistered_jwt_client_behavior = ( UnregisteredJWTClientBehavior.AUTO_REGISTER if mapping == "pending" @@ -11637,9 +11665,15 @@ async def test_oauth_jwt_respects_custom_validation_and_email_policy( @pytest.mark.asyncio -async def test_oauth_jwt_uses_rbac_user_object_id(jwt_oauth_identity: tuple["JWTHandler", "RSAPrivateKey"]) -> None: +@pytest.mark.parametrize("route_allowed", [False, True]) +async def test_oauth_jwt_uses_rbac_user_object_id( + jwt_oauth_identity: tuple["JWTHandler", "RSAPrivateKey"], + monkeypatch: pytest.MonkeyPatch, + route_allowed: bool, +) -> None: + from litellm.proxy import proxy_server from litellm.proxy._experimental.mcp_server.bridge_token_flow import _extract_user_id_from_request - from litellm.proxy._types import LitellmUserRoles, RoleMapping + from litellm.proxy._types import LitellmUserRoles, RoleBasedPermissions, RoleMapping handler, signing_key = jwt_oauth_identity handler.litellm_jwtauth.user_id_jwt_field = "sub" @@ -11648,26 +11682,43 @@ async def test_oauth_jwt_uses_rbac_user_object_id(jwt_oauth_identity: tuple["JWT handler.litellm_jwtauth.role_mappings = [ RoleMapping(role="litellm-proxy", internal_role=LitellmUserRoles.INTERNAL_USER) ] + handler.litellm_jwtauth.enforce_rbac = True + monkeypatch.setattr( + proxy_server, + "general_settings", + { + "enable_jwt_auth": True, + "role_permissions": [ + RoleBasedPermissions( + role=LitellmUserRoles.INTERNAL_USER, + routes=["/token"] if route_allowed else ["/models"], + ) + ], + }, + ) request: Final = _token_request({"Authorization": f"Bearer {_oauth_identity_jwt(signing_key)}"}) - assert await _extract_user_id_from_request(request) == "jwt-owner" + assert await _extract_user_id_from_request(request) == ("jwt-owner" if route_allowed else None) @pytest.mark.asyncio @pytest.mark.parametrize("identity", ["sso", "email"]) @pytest.mark.parametrize("inactive", [False, True]) +@pytest.mark.parametrize("admin", [False, True]) async def test_oauth_jwt_resolves_canonical_owner_without_cached_identity( jwt_oauth_identity: tuple["JWTHandler", "RSAPrivateKey"], monkeypatch: pytest.MonkeyPatch, identity: str, inactive: bool, + admin: bool, ) -> None: from litellm.models.user import LiteLLM_UserTable from litellm.proxy import proxy_server from litellm.proxy._experimental.mcp_server.bridge_token_flow import _extract_user_id_from_request handler, signing_key = jwt_oauth_identity - external_id: Final = f"external-{identity}-{inactive}" + external_id: Final = f"external-{identity}-{inactive}-{admin}" handler.litellm_jwtauth.user_email_jwt_field = "email" + handler.litellm_jwtauth.admin_allowed_routes = ["/token"] owner: Final = LiteLLM_UserTable( user_id="canonical-oauth-owner", user_email="owner@example.test", @@ -11676,12 +11727,17 @@ async def test_oauth_jwt_resolves_canonical_owner_without_cached_identity( ) database: Final = MagicMock() table: Final = database.db.litellm_usertable - table.find_unique = AsyncMock(side_effect=[None, owner if identity == "sso" else None]) + table.find_unique = AsyncMock(side_effect=[None, owner if identity == "sso" else None, owner]) table.find_first = AsyncMock(return_value=owner) table.update = AsyncMock(return_value=owner) monkeypatch.setattr(proxy_server, "prisma_client", database) - request: Final = _token_request({"Authorization": f"Bearer {_oauth_identity_jwt(signing_key, owner=external_id)}"}) + bearer: Final = _oauth_identity_jwt( + signing_key, owner=external_id, scope="litellm_proxy_admin" if admin else "" + ) + request: Final = _token_request({"Authorization": f"Bearer {bearer}"}) assert await _extract_user_id_from_request(request) == (None if inactive else "canonical-oauth-owner") - assert table.find_unique.await_count == 2 + assert table.find_unique.await_count == (2 if admin else 3) + if not admin: + assert table.find_unique.call_args.kwargs["where"] == {"user_id": "canonical-oauth-owner"} if identity == "email": table.find_first.assert_awaited_once() From f60a603519cebfdb7b209c353ff64d5bb1880fd4 Mon Sep 17 00:00:00 2001 From: yassin Date: Wed, 16 Sep 2026 00:12:49 +0000 Subject: [PATCH 036/133] fix(proxy): log configured model access denials at the final response boundary Post-auth denials from can_key_call_resolved_model (per-request alias rewrite, MCP sampling, realtime) never reach the auth exception handler, so the internal allowlist reason was dropped when model_access_denied_message was set. Log it once from the ProxyException response handler and the realtime rejection path instead, and convert JWT ModelAccessDeniedHTTPException into the specialized ProxyException so the same boundary covers it Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- litellm/proxy/auth/auth_exception_handler.py | 19 ++- litellm/proxy/proxy_server.py | 9 ++ .../proxy/auth/test_auth_exception_handler.py | 13 +-- tests/test_litellm/proxy/test_proxy_server.py | 109 +++++++++++++++++- 4 files changed, 126 insertions(+), 24 deletions(-) diff --git a/litellm/proxy/auth/auth_exception_handler.py b/litellm/proxy/auth/auth_exception_handler.py index 4a764cae6cc..bbe4b0f5c35 100644 --- a/litellm/proxy/auth/auth_exception_handler.py +++ b/litellm/proxy/auth/auth_exception_handler.py @@ -53,6 +53,14 @@ def _as_proxy_exception(e: Exception) -> ProxyException: param=None, code=getattr(e, "status_code", status.HTTP_429_TOO_MANY_REQUESTS), ) + if isinstance(e, ModelAccessDeniedHTTPException): + return ModelAccessDeniedProxyException( + message=str(e.detail), + internal_message=e.internal_message, + type=ProxyErrorTypes.auth_error, + param="None", + code=e.status_code, + ) if isinstance(e, HTTPException): return ProxyException( message=getattr(e, "detail", f"Authentication Error({e})"), @@ -77,14 +85,6 @@ def _as_proxy_exception(e: Exception) -> ProxyException: ) -def _model_access_denied_internal_message(e: Exception) -> str | None: - if not litellm.model_access_denied_message: - return None - if not isinstance(e, (ModelAccessDeniedProxyException, ModelAccessDeniedHTTPException)): - return None - return e.internal_message.replace("\r", "").replace("\n", "") - - def _get_user_agent(request: Request) -> str | None: if "headers" not in request.scope: return None @@ -176,9 +176,6 @@ class UserAPIKeyAuthExceptionHandler: # survives a raising callback pipeline. Classify and route malformed virtual-key # rejections to WARNING on stdout (suppressible via LITELLM_LOG=ERROR). log_extra: Final = {"requester_ip": requester_ip} - denied_internal_message: Final = _model_access_denied_internal_message(e) - if denied_internal_message is not None: - verbose_proxy_logger.warning(denied_internal_message, extra=log_extra) is_invalid_virtual_key: Final = is_invalid_virtual_key_error(e) is_quiet_log: Final = is_invalid_virtual_key and not litellm.log_client_error_tracebacks logger: Final = verbose_proxy_stdout_logger if is_quiet_log else verbose_proxy_logger diff --git a/litellm/proxy/proxy_server.py b/litellm/proxy/proxy_server.py index 2307b093736..c346115085d 100644 --- a/litellm/proxy/proxy_server.py +++ b/litellm/proxy/proxy_server.py @@ -106,6 +106,7 @@ from litellm.proxy._types import ( LiteLLM_TeamTableCachedObj, LiteLLM_UserTable, LitellmUserRoles, + ModelAccessDeniedProxyException, PassThroughGenericEndpoint, ProxyErrorTypes, ProxyException, @@ -1668,6 +1669,7 @@ class UserAPIKeyCacheTTLEnum(enum.Enum): @app.exception_handler(ProxyException) async def openai_exception_handler(request: Request, exc: ProxyException): # NOTE: DO NOT MODIFY THIS, its crucial to map to Openai exceptions + _log_model_access_denial(exc) headers: Final = exc.headers error_dict: Final = exc.to_dict() status_code: Final = int(exc.code) if exc.code else status.HTTP_500_INTERNAL_SERVER_ERROR @@ -1679,6 +1681,12 @@ async def openai_exception_handler(request: Request, exc: ProxyException): ) +def _log_model_access_denial(exc: ProxyException) -> None: + if not litellm.model_access_denied_message or not isinstance(exc, ModelAccessDeniedProxyException): + return + verbose_proxy_logger.warning(exc.internal_message.replace("\r", "").replace("\n", "")) + + def _close_dangling_otel_server_span(request: Request, status_code: int, exc: Exception | None = None) -> None: parent_otel_span: Final[_Span | None] = getattr(request.state, "parent_otel_span", None) if parent_otel_span is None: @@ -11967,6 +11975,7 @@ async def realtime_websocket_endpoint( llm_router=llm_router, ) except ProxyException as e: + _log_model_access_denial(e) await _reject_realtime_session(websocket, user_api_key_dict, code=1008, reason=e.message[:120]) return await websocket.accept(**accept_kwargs) diff --git a/tests/test_litellm/proxy/auth/test_auth_exception_handler.py b/tests/test_litellm/proxy/auth/test_auth_exception_handler.py index 602c074ee66..4328eac8432 100644 --- a/tests/test_litellm/proxy/auth/test_auth_exception_handler.py +++ b/tests/test_litellm/proxy/auth/test_auth_exception_handler.py @@ -1022,7 +1022,9 @@ def _denied_jwt_exception() -> ModelAccessDeniedHTTPException: pytest.param(_denied_jwt_exception, id="jwt_http_exception"), ], ) -async def test_handle_authentication_error_logs_sanitized_model_access_denial_once(monkeypatch, make_denial, caplog): +async def test_handle_authentication_error_keeps_internal_message_on_model_access_denial( + monkeypatch, make_denial, caplog +): monkeypatch.setattr(litellm, "model_access_denied_message", _DENIED_MESSAGE_TEMPLATE) handler = UserAPIKeyAuthExceptionHandler() denial = make_denial() @@ -1041,17 +1043,14 @@ async def test_handle_authentication_error_logs_sanitized_model_access_denial_on {"allow_requests_on_db_unavailable": False}, ), caplog.at_level("WARNING", logger="LiteLLM Proxy"), - pytest.raises(ProxyException) as exc_info, + pytest.raises(ModelAccessDeniedProxyException) as exc_info, ): await handler._handle_authentication_error(denial, MagicMock(), {}, "/v1/chat/completions", None, "sk-bad-key") assert exc_info.value.code == str(status.HTTP_403_FORBIDDEN) assert "internal-models" not in str(exc_info.value.message) - denial_records = [r for r in caplog.records if "internal-models" in r.getMessage()] - assert len(denial_records) == 1 - assert denial_records[0].levelname == "WARNING" - assert "\n" not in denial_records[0].getMessage() - assert "gpt-5.6WARNING forged log line" in denial_records[0].getMessage() + assert exc_info.value.internal_message == denial.internal_message + assert [r for r in caplog.records if r.levelname == "WARNING" and "internal-models" in r.getMessage()] == [] @pytest.mark.asyncio diff --git a/tests/test_litellm/proxy/test_proxy_server.py b/tests/test_litellm/proxy/test_proxy_server.py index f0e8aecdcf3..c06f572aca5 100644 --- a/tests/test_litellm/proxy/test_proxy_server.py +++ b/tests/test_litellm/proxy/test_proxy_server.py @@ -19,7 +19,7 @@ import fastapi.routing import httpx import pytest import yaml -from fastapi import FastAPI +from fastapi import FastAPI, Request from fastapi.encoders import jsonable_encoder from fastapi.staticfiles import StaticFiles from fastapi.testclient import TestClient @@ -31,10 +31,17 @@ from litellm.caching.caching import RedisCache from litellm.caching.redis_cluster_cache import RedisClusterCache from litellm.litellm_core_utils.get_model_cost_map import ModelCostMapReloaded from litellm.caching.dual_cache import DualCache -from litellm.proxy._types import LitellmUserRoles, TokenCountRequest, UserAPIKeyAuth +from litellm.proxy._types import ( + LitellmUserRoles, + ModelAccessDeniedProxyException, + ProxyErrorTypes, + ProxyException, + TokenCountRequest, + UserAPIKeyAuth, +) from litellm.proxy.auth.user_api_key_auth import user_api_key_auth from litellm.proxy.hooks.parallel_request_limiter_v3 import RequestRateLimiterStash -from litellm.proxy.proxy_server import app, initialize +from litellm.proxy.proxy_server import app, initialize, openai_exception_handler from litellm.utils import _invalidate_model_cost_lowercase_map example_embedding_result = { @@ -10003,6 +10010,7 @@ async def _lit6973_drive_realtime_session( backend_logged_failure: bool = False, phase_one_exit: str | None = None, websocket: MagicMock | None = None, + model_access_exception: ProxyException | None = None, ) -> MagicMock: """Drive realtime_websocket_endpoint through one of its reservation-settling exits. @@ -10035,10 +10043,10 @@ async def _lit6973_drive_realtime_session( if backend_logged_failure: logging_obj.model_call_details[REALTIME_SESSION_FAILURE_LOGGED_KEY] = True - from litellm.proxy._types import ProxyException - model_access_error: Final = ( - ProxyException(message="key cannot access model", type="auth_error", param="model", code=401) + model_access_exception + if model_access_exception is not None + else ProxyException(message="key cannot access model", type="auth_error", param="model", code=401) if phase_one_exit == "model_access" else None ) @@ -10916,6 +10924,95 @@ def test_validate_model_access_denied_message_empty_restores_detailed_default(em assert _validate_general_settings_ui_litellm_value("model_access_denied_message", empty_value) is None +def _model_access_denied_proxy_exception(): + return ModelAccessDeniedProxyException( + message="The model `gpt-5.6\r\nWARNING forged log line` is unavailable for this API key or does not exist.", + internal_message="key not allowed to access model. This key can only access models=['internal-models']. " + "Tried to access gpt-5.6\r\nWARNING forged log line", + type=ProxyErrorTypes.key_model_access_denied, + param="model", + code=403, + ) + + +def _http_request_scope(): + return Request({"type": "http", "method": "POST", "path": "/v1/chat/completions", "headers": []}) + + +@pytest.mark.asyncio +async def test_openai_exception_handler_logs_sanitized_model_access_denial(monkeypatch, caplog): + monkeypatch.setattr( + litellm, "model_access_denied_message", "The model `{model}` is unavailable for this API key or does not exist." + ) + + with caplog.at_level("WARNING", logger="LiteLLM Proxy"): + response = await openai_exception_handler(_http_request_scope(), _model_access_denied_proxy_exception()) + + assert response.status_code == 403 + body = json.loads(response.body) + assert "internal-models" not in body["error"]["message"] + denial_records = [r for r in caplog.records if "internal-models" in r.getMessage()] + assert len(denial_records) == 1 + assert denial_records[0].levelname == "WARNING" + assert "\n" not in denial_records[0].getMessage() + assert "\r" not in denial_records[0].getMessage() + assert "gpt-5.6WARNING forged log line" in denial_records[0].getMessage() + + +@pytest.mark.asyncio +@pytest.mark.parametrize("unset_value", [None, ""]) +async def test_openai_exception_handler_no_denial_log_when_message_not_configured(monkeypatch, unset_value, caplog): + monkeypatch.setattr(litellm, "model_access_denied_message", unset_value) + + with caplog.at_level("WARNING", logger="LiteLLM Proxy"): + response = await openai_exception_handler(_http_request_scope(), _model_access_denied_proxy_exception()) + + assert response.status_code == 403 + assert [r for r in caplog.records if r.levelname == "WARNING" and "internal-models" in r.getMessage()] == [] + + +@pytest.mark.asyncio +async def test_openai_exception_handler_no_denial_log_for_plain_proxy_exception(monkeypatch, caplog): + monkeypatch.setattr( + litellm, "model_access_denied_message", "The model `{model}` is unavailable for this API key or does not exist." + ) + denial = ProxyException( + message="Authentication Error, Invalid proxy server token passed", + type=ProxyErrorTypes.auth_error, + param="None", + code=401, + ) + + with caplog.at_level("WARNING", logger="LiteLLM Proxy"): + response = await openai_exception_handler(_http_request_scope(), denial) + + assert response.status_code == 401 + assert [r for r in caplog.records if r.levelname == "WARNING"] == [] + + +@pytest.mark.asyncio +async def test_realtime_model_access_denial_logs_sanitized_internal_message(monkeypatch, caplog): + monkeypatch.setattr( + litellm, "model_access_denied_message", "The model `{model}` is unavailable for this API key or does not exist." + ) + reservation = {"reserved_cost": 0.0, "input_cost": 0.0, "finalized": False, "entries": []} + + with caplog.at_level("WARNING", logger="LiteLLM Proxy"): + ws = await _lit6973_drive_realtime_session( + reservation, + backend_logged_success=False, + phase_one_exit="model_access", + model_access_exception=_model_access_denied_proxy_exception(), + ) + + ws.close.assert_awaited_once() + assert "internal-models" not in ws.close.await_args.kwargs["reason"] + denial_records = [r for r in caplog.records if "internal-models" in r.getMessage()] + assert len(denial_records) == 1 + assert "\n" not in denial_records[0].getMessage() + assert "gpt-5.6WARNING forged log line" in denial_records[0].getMessage() + + @pytest.mark.parametrize("empty_value", [None, ""]) def test_validate_expose_router_debug_in_errors_empty_restores_true_default(empty_value): from litellm.proxy.proxy_server import _validate_general_settings_ui_litellm_value From 53318796fd27d7e59bb86850f163836dda7a45e8 Mon Sep 17 00:00:00 2001 From: Joshua Valluru <326636767+joshua-berri@users.noreply.github.com> Date: Tue, 15 Sep 2026 17:13:10 -0700 Subject: [PATCH 037/133] fix(mcp): separate JWT identity lookup from request authorization --- .../mcp_server/bridge_token_flow.py | 22 +-- litellm/proxy/auth/handle_jwt.py | 71 ++++++++-- .../mcp_server/test_discoverable_endpoints.py | 126 +++++++++++++----- .../proxy/auth/test_handle_jwt.py | 71 +++++++++- 4 files changed, 237 insertions(+), 53 deletions(-) diff --git a/litellm/proxy/_experimental/mcp_server/bridge_token_flow.py b/litellm/proxy/_experimental/mcp_server/bridge_token_flow.py index 2f3e50b803f..6d11a4d2607 100644 --- a/litellm/proxy/_experimental/mcp_server/bridge_token_flow.py +++ b/litellm/proxy/_experimental/mcp_server/bridge_token_flow.py @@ -198,9 +198,7 @@ async def _reload_active_user_by_id(user_id: str) -> "_KeyResolutionFailure | No return loaded if isinstance(loaded, str) else None -async def load_active_user_by_id( - user_id: str, *, sso_user_id: str | None = None, user_email: str | None = None -) -> "LiteLLM_UserTable | _KeyResolutionFailure": +async def load_active_user_by_id(user_id: str) -> "LiteLLM_UserTable | _KeyResolutionFailure": """Load a live litellm user by id, returning the record when the user is active or a precise failure otherwise. The interactive DCR client authenticates via SSO, so its refresh envelope seals a user subject; renewing it must re-check the user is still live (present and not SCIM-deactivated) so a @@ -234,8 +232,6 @@ async def load_active_user_by_id( prisma_client=prisma_client, user_api_key_cache=user_api_key_cache, user_id_upsert=False, - sso_user_id=sso_user_id, - user_email=user_email, ) except (ProxyException, HTTPException): return "no_active_key" @@ -247,6 +243,10 @@ async def load_active_user_by_id( return "no_active_key" if user_object is None: return "no_active_key" + return _active_user_record(user_object) + + +def _active_user_record(user_object: "LiteLLM_UserTable") -> "LiteLLM_UserTable | Literal['no_active_key']": if isinstance(user_object.metadata, dict) and user_object.metadata.get("scim_active") is False: return "no_active_key" return user_object @@ -336,7 +336,7 @@ async def _extract_jwt_user_id(request: Request, token: str) -> str | None: user_api_key_cache, ) - if general_settings.get("enable_jwt_auth") is not True or premium_user is not True: + if general_settings.get("enable_jwt_auth") is not True or premium_user is not True or prisma_client is None: return None try: if jwt_handler.litellm_jwtauth.is_virtual_key_mapping_configured(): @@ -368,13 +368,13 @@ async def _extract_jwt_user_id(request: Request, token: str) -> str | None: proxy_logging_obj=proxy_logging_obj, request_headers=dict(request.headers), request_method=request.method, + identity_only=True, ) - owner_id: Final = identity["user_id"] - if not owner_id: + resolved_user: Final = identity["user_object"] + if resolved_user is None: return None - # Admin JWTs can return before auth_builder loads the canonical database user. - owner: Final = await load_active_user_by_id(owner_id, sso_user_id=owner_id, user_email=identity["user_email"]) - return None if isinstance(owner, str) else owner.user_id + owner: Final = _active_user_record(resolved_user) + return None if isinstance(owner, str) else identity["user_id"] except Exception as exc: # noqa: BLE001 # public OAuth exchange stays available; unvalidated identities never write credentials verbose_logger.debug("OAuth JWT identity could not be validated (%s)", type(exc).__name__) return None diff --git a/litellm/proxy/auth/handle_jwt.py b/litellm/proxy/auth/handle_jwt.py index 4304542fc83..678769b375b 100644 --- a/litellm/proxy/auth/handle_jwt.py +++ b/litellm/proxy/auth/handle_jwt.py @@ -1674,6 +1674,7 @@ class JWTAuthManager: proxy_logging_obj: ProxyLogging, route: str, org_alias: str | None = None, + user_id_upsert: bool | None = None, ) -> tuple[ LiteLLM_UserTable | None, LiteLLM_OrganizationTable | None, @@ -1737,7 +1738,11 @@ class JWTAuthManager: user_id=user_id, user_email=user_email, sso_user_id=user_id, - upsert=jwt_handler.is_upsert_user_id(valid_user_email=valid_user_email), + upsert=( + jwt_handler.is_upsert_user_id(valid_user_email=valid_user_email) + if user_id_upsert is None + else user_id_upsert + ), ), team_id=team_id, ) @@ -2209,8 +2214,14 @@ class JWTAuthManager: proxy_logging_obj: ProxyLogging, request_headers: dict | None = None, request_method: str | None = None, + identity_only: bool = False, ) -> JWTAuthBuilderResult: - """Main authentication and authorization builder""" + """Build JWT authentication and authorization context. + + Public OAuth endpoints use identity_only to resolve an existing credential owner + without authorizing the OAuth route or provisioning users/teams. The returned + identity does not grant permission to execute an MCP or model request. + """ # Check if OIDC UserInfo endpoint is enabled, but fall back to standard # JWT auth if the token itself is a well-formed JWT (3-part structure). if jwt_handler.litellm_jwtauth.oidc_userinfo_enabled and not jwt_handler.is_jwt(token=api_key): @@ -2231,18 +2242,23 @@ class JWTAuthManager: # Check RBAC rbac_role: Final = jwt_handler.get_rbac_role(token=jwt_valid_token) - await JWTAuthManager.check_rbac_role( - jwt_handler, - jwt_valid_token, - general_settings, - request_data, - route, - rbac_role, - ) + if not identity_only: + await JWTAuthManager.check_rbac_role( + jwt_handler, + jwt_valid_token, + general_settings, + request_data, + route, + rbac_role, + ) # Check Scope Based Access scopes: Final = jwt_handler.get_scopes(token=jwt_valid_token) - if jwt_handler.litellm_jwtauth.enforce_scope_based_access and jwt_handler.litellm_jwtauth.scope_mappings: + if ( + not identity_only + and jwt_handler.litellm_jwtauth.enforce_scope_based_access + and jwt_handler.litellm_jwtauth.scope_mappings + ): JWTAuthManager.check_scope_based_access( scope_mappings=jwt_handler.litellm_jwtauth.scope_mappings, scopes=scopes, @@ -2268,6 +2284,39 @@ class JWTAuthManager: elif rbac_role == LitellmUserRoles.INTERNAL_USER: user_id = object_id + if identity_only: + identity_user, _, _, _, identity_user_id = await JWTAuthManager.get_objects( + user_id=user_id, + user_email=user_email, + org_id=None, + end_user_id=None, + team_id=None, + valid_user_email=valid_user_email, + jwt_handler=jwt_handler, + prisma_client=prisma_client, + user_api_key_cache=user_api_key_cache, + parent_otel_span=parent_otel_span, + proxy_logging_obj=proxy_logging_obj, + route=route, + user_id_upsert=False, + ) + return JWTAuthBuilderResult( + is_proxy_admin=False, + # Admin admission uses the claim ID; other callers use the canonical DB ID. + user_id=user_id if jwt_handler.is_admin(scopes=scopes) else identity_user_id, + user_email=identity_user.user_email if identity_user is not None else user_email, + user_object=identity_user, + team_id=None, + team_object=None, + org_id=None, + org_object=None, + end_user_id=None, + end_user_object=None, + team_membership=None, + token=api_key, + jwt_claims=jwt_valid_token, + ) + # Check admin access admin_result: Final = await JWTAuthManager.check_admin_access( jwt_handler, scopes, route, user_id, org_id, api_key, jwt_valid_token, user_email=user_email diff --git a/tests/test_litellm/proxy/_experimental/mcp_server/test_discoverable_endpoints.py b/tests/test_litellm/proxy/_experimental/mcp_server/test_discoverable_endpoints.py index d1ab8809861..bf557667892 100644 --- a/tests/test_litellm/proxy/_experimental/mcp_server/test_discoverable_endpoints.py +++ b/tests/test_litellm/proxy/_experimental/mcp_server/test_discoverable_endpoints.py @@ -7127,12 +7127,12 @@ async def test_build_oauth_protected_resource_response_obo_end_to_end(): global_mcp_server_manager.registry.clear() -def _token_request(headers): +def _token_request(headers, path="/token"): """A real Starlette request with case-insensitive headers (matches production).""" from starlette.requests import Request raw = [(k.lower().encode(), v.encode()) for k, v in headers.items()] - return Request({"type": "http", "method": "POST", "path": "/token", "headers": raw, "query_string": b""}) + return Request({"type": "http", "method": "POST", "path": path, "headers": raw, "query_string": b""}) @pytest.fixture @@ -11443,10 +11443,12 @@ def _oauth_identity_jwt( @pytest.mark.asyncio @pytest.mark.parametrize("header", ["Authorization", "x-litellm-api-key"]) @pytest.mark.parametrize("policy_allowed", [False, True]) +@pytest.mark.parametrize("admin", [False, True]) async def test_oauth_exchange_stores_token_for_validated_jwt_user( jwt_oauth_identity: tuple["JWTHandler", "RSAPrivateKey"], header: str, policy_allowed: bool, + admin: bool, monkeypatch: pytest.MonkeyPatch, ) -> None: import httpx @@ -11456,9 +11458,9 @@ async def test_oauth_exchange_stores_token_for_validated_jwt_user( from litellm.types.mcp_server.mcp_server_manager import MCPServer handler, signing_key = jwt_oauth_identity - handler.litellm_jwtauth.enforce_team_based_model_access = not policy_allowed - bearer: Final = _oauth_identity_jwt(signing_key) - request: Final = _token_request({header: f"Bearer {bearer}"}) + handler.litellm_jwtauth.custom_validate = lambda claims: policy_allowed + bearer: Final = _oauth_identity_jwt(signing_key, scope="litellm_proxy_admin" if admin else "") + request: Final = _token_request({header: f"Bearer {bearer}"}, path="/jwt-oauth-server/token") server: Final = MCPServer( server_id="jwt-oauth-server", name="jwt-oauth-server", @@ -11534,8 +11536,6 @@ async def test_oauth_exchange_stores_token_for_validated_jwt_user( "scim_inactive", "custom_validate", "missing_database", - "denied_route", - "required_team", ], ) async def test_oauth_jwt_identity_rejects_untrusted_or_inactive_owner( @@ -11548,7 +11548,6 @@ async def test_oauth_jwt_identity_rejects_untrusted_or_inactive_owner( from litellm.models.user import LiteLLM_UserTable from litellm.proxy import proxy_server from litellm.proxy._experimental.mcp_server.bridge_token_flow import _extract_user_id_from_request - from litellm.proxy._types import LitellmUserRoles, RoleBasedPermissions handler, signing_key = jwt_oauth_identity key: Final = ( @@ -11573,18 +11572,6 @@ async def test_oauth_jwt_identity_rejects_untrusted_or_inactive_owner( ) if rejection == "custom_validate": handler.litellm_jwtauth.custom_validate = lambda claims: False - if rejection == "denied_route": - handler.litellm_jwtauth.enforce_rbac = True - monkeypatch.setattr( - proxy_server, - "general_settings", - { - "enable_jwt_auth": True, - "role_permissions": [RoleBasedPermissions(role=LitellmUserRoles.INTERNAL_USER, routes=["/models"])], - }, - ) - if rejection == "required_team": - handler.litellm_jwtauth.enforce_team_based_model_access = True assert await _extract_user_id_from_request(_token_request({"Authorization": f"Bearer {bearer}"})) is None @@ -11666,7 +11653,7 @@ async def test_oauth_jwt_respects_custom_validation_and_email_policy( @pytest.mark.asyncio @pytest.mark.parametrize("route_allowed", [False, True]) -async def test_oauth_jwt_uses_rbac_user_object_id( +async def test_oauth_jwt_identity_preserves_separate_mcp_route_authorization( jwt_oauth_identity: tuple["JWTHandler", "RSAPrivateKey"], monkeypatch: pytest.MonkeyPatch, route_allowed: bool, @@ -11674,6 +11661,7 @@ async def test_oauth_jwt_uses_rbac_user_object_id( from litellm.proxy import proxy_server from litellm.proxy._experimental.mcp_server.bridge_token_flow import _extract_user_id_from_request from litellm.proxy._types import LitellmUserRoles, RoleBasedPermissions, RoleMapping + from litellm.proxy.auth.handle_jwt import JWTAuthManager handler, signing_key = jwt_oauth_identity handler.litellm_jwtauth.user_id_jwt_field = "sub" @@ -11691,13 +11679,32 @@ async def test_oauth_jwt_uses_rbac_user_object_id( "role_permissions": [ RoleBasedPermissions( role=LitellmUserRoles.INTERNAL_USER, - routes=["/token"] if route_allowed else ["/models"], + routes=["mcp_routes"] if route_allowed else ["/models"], ) ], }, ) - request: Final = _token_request({"Authorization": f"Bearer {_oauth_identity_jwt(signing_key)}"}) - assert await _extract_user_id_from_request(request) == ("jwt-owner" if route_allowed else None) + bearer: Final = _oauth_identity_jwt(signing_key) + request: Final = _token_request({"Authorization": f"Bearer {bearer}"}, path="/example/token") + assert await _extract_user_id_from_request(request) == "jwt-owner" + admission: Final = JWTAuthManager.auth_builder( + api_key=bearer, + jwt_handler=handler, + request_data={}, + general_settings=proxy_server.general_settings, + route="/mcp/example", + prisma_client=proxy_server.prisma_client, + user_api_key_cache=handler.user_api_key_cache, + parent_otel_span=None, + proxy_logging_obj=proxy_server.proxy_logging_obj, + request_method="POST", + ) + if route_allowed: + assert (await admission)["user_id"] == "jwt-owner" + else: + with pytest.raises(HTTPException) as denial: + await admission + assert denial.value.status_code == 403 @pytest.mark.asyncio @@ -11714,11 +11721,12 @@ async def test_oauth_jwt_resolves_canonical_owner_without_cached_identity( from litellm.models.user import LiteLLM_UserTable from litellm.proxy import proxy_server from litellm.proxy._experimental.mcp_server.bridge_token_flow import _extract_user_id_from_request + from litellm.proxy.auth.handle_jwt import JWTAuthManager handler, signing_key = jwt_oauth_identity external_id: Final = f"external-{identity}-{inactive}-{admin}" handler.litellm_jwtauth.user_email_jwt_field = "email" - handler.litellm_jwtauth.admin_allowed_routes = ["/token"] + handler.litellm_jwtauth.admin_allowed_routes = ["mcp_routes"] owner: Final = LiteLLM_UserTable( user_id="canonical-oauth-owner", user_email="owner@example.test", @@ -11727,7 +11735,7 @@ async def test_oauth_jwt_resolves_canonical_owner_without_cached_identity( ) database: Final = MagicMock() table: Final = database.db.litellm_usertable - table.find_unique = AsyncMock(side_effect=[None, owner if identity == "sso" else None, owner]) + table.find_unique = AsyncMock(side_effect=[None, owner if identity == "sso" else None]) table.find_first = AsyncMock(return_value=owner) table.update = AsyncMock(return_value=owner) monkeypatch.setattr(proxy_server, "prisma_client", database) @@ -11735,9 +11743,67 @@ async def test_oauth_jwt_resolves_canonical_owner_without_cached_identity( signing_key, owner=external_id, scope="litellm_proxy_admin" if admin else "" ) request: Final = _token_request({"Authorization": f"Bearer {bearer}"}) - assert await _extract_user_id_from_request(request) == (None if inactive else "canonical-oauth-owner") - assert table.find_unique.await_count == (2 if admin else 3) - if not admin: - assert table.find_unique.call_args.kwargs["where"] == {"user_id": "canonical-oauth-owner"} + stored_owner: Final = await _extract_user_id_from_request(request) + assert stored_owner == (None if inactive else external_id if admin else "canonical-oauth-owner") + assert table.find_unique.await_count == 2 if identity == "email": table.find_first.assert_awaited_once() + if not inactive: + admission: Final = await JWTAuthManager.auth_builder( + api_key=bearer, + jwt_handler=handler, + request_data={}, + general_settings=proxy_server.general_settings, + route="/mcp/example", + prisma_client=database, + user_api_key_cache=handler.user_api_key_cache, + parent_otel_span=None, + proxy_logging_obj=proxy_server.proxy_logging_obj, + ) + assert stored_owner == admission["user_id"] + + +@pytest.mark.asyncio +async def test_oauth_jwt_identity_does_not_provision_or_synchronize_teams( + jwt_oauth_identity: tuple["JWTHandler", "RSAPrivateKey"], +) -> None: + from litellm.models.user import LiteLLM_UserTable + from litellm.proxy import proxy_server + from litellm.proxy._experimental.mcp_server.bridge_token_flow import _extract_user_id_from_request + + handler, signing_key = jwt_oauth_identity + handler.litellm_jwtauth.enforce_team_based_model_access = True + handler.litellm_jwtauth.team_id_default = "new-team" + handler.litellm_jwtauth.team_id_upsert = True + handler.litellm_jwtauth.sync_user_role_and_teams = True + owner: Final = LiteLLM_UserTable(user_id="jwt-owner", teams=["existing-team"]) + handler.user_api_key_cache.set_cache("jwt-owner", owner) + request: Final = _token_request( + {"Authorization": f"Bearer {_oauth_identity_jwt(signing_key)}"}, path="/example/token" + ) + assert await _extract_user_id_from_request(request) == "jwt-owner" + assert owner.teams == ["existing-team"] + proxy_server.prisma_client.db.litellm_teamtable.find_unique.assert_not_called() + proxy_server.prisma_client.db.litellm_teamtable.upsert.assert_not_called() + proxy_server.prisma_client.db.litellm_usertable.update.assert_not_called() + + +@pytest.mark.asyncio +@pytest.mark.parametrize("state", ["active", "inactive", "missing_database"]) +async def test_oauth_refresh_revalidates_the_same_active_user_rule( + jwt_oauth_identity: tuple["JWTHandler", "RSAPrivateKey"], + monkeypatch: pytest.MonkeyPatch, + state: str, +) -> None: + from litellm.models.user import LiteLLM_UserTable + from litellm.proxy import proxy_server + from litellm.proxy._experimental.mcp_server.bridge_token_flow import _reload_active_user_by_id + + handler, _ = jwt_oauth_identity + handler.user_api_key_cache.set_cache( + "jwt-owner", LiteLLM_UserTable(user_id="jwt-owner", metadata={"scim_active": state != "inactive"}) + ) + if state == "missing_database": + monkeypatch.setattr(proxy_server, "prisma_client", None) + expected: Final = None if state == "active" else "no_active_key" if state == "inactive" else "unresolvable" + assert await _reload_active_user_by_id("jwt-owner") == expected diff --git a/tests/test_litellm/proxy/auth/test_handle_jwt.py b/tests/test_litellm/proxy/auth/test_handle_jwt.py index 94226b5404d..9e15a442a18 100644 --- a/tests/test_litellm/proxy/auth/test_handle_jwt.py +++ b/tests/test_litellm/proxy/auth/test_handle_jwt.py @@ -2,7 +2,7 @@ import asyncio import re import time from collections.abc import Mapping, Sequence -from typing import Optional +from typing import Final, Optional from unittest.mock import AsyncMock, MagicMock, patch from fastapi import HTTPException @@ -6786,3 +6786,72 @@ async def test_sync_user_role_and_teams_singular_claim_only_recognized_under_fla } assert mock_patch.call_args.kwargs["teams_ids_to_add_user_to"] == [] assert user.teams == [] + + +@pytest.mark.asyncio +@pytest.mark.parametrize("identity_only", [False, True]) +@pytest.mark.parametrize("existing_user", [False, True]) +@pytest.mark.parametrize("model_allowed", [False, True]) +async def test_auth_builder_identity_lookup_does_not_provision_users( + monkeypatch: pytest.MonkeyPatch, identity_only: bool, existing_user: bool, model_allowed: bool +) -> None: + from litellm.proxy._types import ScopeMapping + from litellm.proxy.auth.auth_checks import UserNotFoundError + from litellm.proxy.common_utils.user_api_key_cache import UserApiKeyCache + + private_key, jwk = _get_rsa_key_and_jwk("identity-mode") + cache: Final = UserApiKeyCache() + cache.set_cache("litellm_jwt_auth_keys_https://identity.example/jwks", [jwk]) + user_id: Final = f"identity-mode-{identity_only}-{existing_user}-{model_allowed}" + user: Final = LiteLLM_UserTable(user_id=user_id, organization_memberships=[]) + if existing_user: + cache.set_cache(user_id, user) + database: Final = MagicMock() + users: Final = database.db.litellm_usertable + users.find_unique = AsyncMock(return_value=None) + users.find_first = AsyncMock(return_value=None) + users.create = AsyncMock(return_value=user) + handler: Final = JWTHandler() + handler.update_environment( + prisma_client=database, + user_api_key_cache=cache, + litellm_jwtauth=LiteLLM_JWTAuth( + user_id_jwt_field="sub", + user_id_upsert=True, + enforce_scope_based_access=True, + scope_mappings=[ScopeMapping(scope="allowed", models=["allowed-model"])], + ), + ) + monkeypatch.setenv("JWT_PUBLIC_KEY_URL", "https://identity.example/jwks") + monkeypatch.setenv("JWT_ISSUER", "https://identity.example") + monkeypatch.setenv("JWT_AUDIENCE", "gateway") + token: Final = _encode_rsa_jwt( + private_key, "https://identity.example", "gateway", "identity-mode", {"sub": user_id, "scope": "allowed"} + ) + pending: Final = JWTAuthManager.auth_builder( + api_key=token, + jwt_handler=handler, + request_data={"model": "allowed-model" if model_allowed else "forbidden-model"}, + general_settings={}, + route="/example/token" if identity_only else "/mcp/example", + prisma_client=database, + user_api_key_cache=cache, + parent_otel_span=None, + proxy_logging_obj=MagicMock(), + identity_only=identity_only, + ) + if not identity_only and not model_allowed: + with pytest.raises(HTTPException) as denial: + await pending + assert denial.value.status_code == 403 + users.create.assert_not_awaited() + return + if identity_only and not existing_user: + with pytest.raises(UserNotFoundError): + await pending + else: + result: Final = await pending + assert result["user_id"] == user_id + assert result["user_object"] is not None + assert result["user_object"].user_id == user_id + assert users.create.await_count == (0 if identity_only or existing_user else 1) From 241b177f05a9dfa56621eea65f85ee24294e4d1f Mon Sep 17 00:00:00 2001 From: yassin Date: Wed, 16 Sep 2026 00:22:48 +0000 Subject: [PATCH 038/133] fix(proxy): log the internal model access denial reason for MCP sampling denials MCP sampling catches the denial itself and returns ErrorData, so the central ProxyException handler never sees it. Log the sanitized internal reason there and share the CR/LF stripping through ModelAccessDeniedProxyException.sanitized_internal_message Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- .../mcp_server/sampling_handler.py | 5 ++++- litellm/proxy/_types.py | 3 +++ litellm/proxy/proxy_server.py | 2 +- .../test_mcp_sampling_model_access.py | 18 ++++++++++++++++++ 4 files changed, 26 insertions(+), 2 deletions(-) diff --git a/litellm/proxy/_experimental/mcp_server/sampling_handler.py b/litellm/proxy/_experimental/mcp_server/sampling_handler.py index 125dc3d773d..6072e747bfe 100644 --- a/litellm/proxy/_experimental/mcp_server/sampling_handler.py +++ b/litellm/proxy/_experimental/mcp_server/sampling_handler.py @@ -771,6 +771,7 @@ async def _check_model_access(model: str, user_api_key_auth: "UserAPIKeyAuth | N try: import litellm + from litellm.proxy._types import ModelAccessDeniedProxyException from litellm.proxy.auth.auth_checks import ( _check_team_member_model_access, can_key_call_model, @@ -887,7 +888,9 @@ async def _check_model_access(model: str, user_api_key_auth: "UserAPIKeyAuth | N verbose_logger.warning( "MCP sampling: model access denied for model=%s: %s", model, - access_err, + access_err.sanitized_internal_message() + if isinstance(access_err, ModelAccessDeniedProxyException) + else access_err, ) return ErrorData( code=-1, diff --git a/litellm/proxy/_types.py b/litellm/proxy/_types.py index 89a4f583dcd..e9bfb7ab5ab 100644 --- a/litellm/proxy/_types.py +++ b/litellm/proxy/_types.py @@ -4042,6 +4042,9 @@ class ModelAccessDeniedProxyException(ProxyException): super().__init__(message=message, type=type, param=param, code=code) self.internal_message: Final = internal_message + def sanitized_internal_message(self) -> str: + return self.internal_message.replace("\r", "").replace("\n", "") + class CommonProxyErrors(str, enum.Enum): db_not_connected_error = ( diff --git a/litellm/proxy/proxy_server.py b/litellm/proxy/proxy_server.py index 9deb70a9277..12d62f6620d 100644 --- a/litellm/proxy/proxy_server.py +++ b/litellm/proxy/proxy_server.py @@ -1684,7 +1684,7 @@ async def openai_exception_handler(request: Request, exc: ProxyException): def _log_model_access_denial(exc: ProxyException) -> None: if not litellm.model_access_denied_message or not isinstance(exc, ModelAccessDeniedProxyException): return - verbose_proxy_logger.warning(exc.internal_message.replace("\r", "").replace("\n", "")) + verbose_proxy_logger.warning(exc.sanitized_internal_message()) def _close_dangling_otel_server_span(request: Request, status_code: int, exc: Exception | None = None) -> None: diff --git a/tests/test_litellm/proxy/_experimental/mcp_server/test_mcp_sampling_model_access.py b/tests/test_litellm/proxy/_experimental/mcp_server/test_mcp_sampling_model_access.py index f141cb2e316..d98db5518c3 100644 --- a/tests/test_litellm/proxy/_experimental/mcp_server/test_mcp_sampling_model_access.py +++ b/tests/test_litellm/proxy/_experimental/mcp_server/test_mcp_sampling_model_access.py @@ -137,6 +137,24 @@ class TestCheckModelAccess: assert result.code == -1 assert "claude-3-opus-20240229" in result.message + @pytest.mark.asyncio + async def test_should_log_internal_denial_reason_when_client_message_is_configured(self, monkeypatch, caplog): + import litellm + from litellm.proxy._types import UserAPIKeyAuth + + monkeypatch.setattr(litellm, "model_access_denied_message", "The model `{model}` is unavailable for this API key.") + auth = UserAPIKeyAuth(api_key="sk-test-key", models=["gpt-3.5-turbo"]) + + with caplog.at_level("WARNING", logger="LiteLLM"): + result = await _check_model_access("gpt-4o\r\nforged", user_api_key_auth=auth) + + assert result is not None + assert "gpt-4o\r\nforged" in result.message + assert "gpt-3.5-turbo" not in result.message + denial_records = [r for r in caplog.records if "gpt-3.5-turbo" in r.getMessage()] + assert len(denial_records) == 1 + assert "Tried to access gpt-4oforged" in denial_records[0].getMessage() + @pytest.mark.asyncio async def test_should_deny_empty_oauth_passthrough_placeholder(self): """Regression: process_mcp_request() returns an empty UserAPIKeyAuth() From e62ab285614d934542f8c44f7917030a44ecbbd7 Mon Sep 17 00:00:00 2001 From: mateo Date: Wed, 16 Sep 2026 00:26:34 +0000 Subject: [PATCH 039/133] chore(codeowners): add ryan and kerry as owners of the cost map Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- .github/CODEOWNERS | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index cfa0390e836..70a50d7f06e 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -4,7 +4,7 @@ /ui/nginx.conf /ui/litellm-dashboard/src/lib/http/schema.d.ts /ui/litellm-dashboard/tsconfig.tsbuildinfo -/model_prices_and_context_window.json @mateo-berri -/litellm/model_prices_and_context_window_backup.json @mateo-berri +/model_prices_and_context_window.json @mateo-berri @ryan-crabbe-berri @kerry-berri +/litellm/model_prices_and_context_window_backup.json @mateo-berri @ryan-crabbe-berri @kerry-berri /litellm-proxy-extras/litellm_proxy_extras/migrations/ @yuneng-berri @ryan-crabbe-berri /.github/CODEOWNERS @yuneng-berri From 4657d43fb999972bc9c77f65a4ee5a40bb68b44c Mon Sep 17 00:00:00 2001 From: kerry Date: Wed, 16 Sep 2026 00:27:09 +0000 Subject: [PATCH 040/133] fix(anthropic): tolerate message_delta chunks without usage in streams Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- litellm/llms/anthropic/chat/handler.py | 4 +++- litellm/types/llms/anthropic.py | 2 +- .../anthropic/chat/test_anthropic_chat_handler.py | 15 +++++++++++++++ 3 files changed, 19 insertions(+), 2 deletions(-) diff --git a/litellm/llms/anthropic/chat/handler.py b/litellm/llms/anthropic/chat/handler.py index 5d3ae444b42..4dd0deeb62b 100644 --- a/litellm/llms/anthropic/chat/handler.py +++ b/litellm/llms/anthropic/chat/handler.py @@ -1167,7 +1167,9 @@ class ModelResponseIterator: # (matches OpenAI behavior and non-streaming Anthropic implementation) if self.converted_response_format_tool: finish_reason = "stop" - usage: Final = self._handle_usage(anthropic_usage_chunk=message_delta["usage"]) + usage: Final = ( + self._handle_usage(anthropic_usage_chunk=message_delta["usage"]) if "usage" in message_delta else None + ) container: Final = message_delta["delta"].get("container") return finish_reason, usage, container diff --git a/litellm/types/llms/anthropic.py b/litellm/types/llms/anthropic.py index d56ada07ed5..ecb8dbb2502 100644 --- a/litellm/types/llms/anthropic.py +++ b/litellm/types/llms/anthropic.py @@ -586,7 +586,7 @@ class MessageBlockDelta(TypedDict): type: Literal["message_delta"] delta: MessageDelta - usage: UsageDelta + usage: NotRequired[UsageDelta] context_management: NotRequired[ContextManagementResponse] diff --git a/tests/test_litellm/llms/anthropic/chat/test_anthropic_chat_handler.py b/tests/test_litellm/llms/anthropic/chat/test_anthropic_chat_handler.py index 83201aef143..ac05cdcf251 100644 --- a/tests/test_litellm/llms/anthropic/chat/test_anthropic_chat_handler.py +++ b/tests/test_litellm/llms/anthropic/chat/test_anthropic_chat_handler.py @@ -579,6 +579,21 @@ def test_text_only_streaming_has_index_zero(): ), f"Expected index=0, got {parsed.choices[0].index}" +def test_message_delta_without_usage_returns_chunk_with_no_usage(): + """A message_delta event may carry no usage field; it must not raise.""" + iterator = ModelResponseIterator(None, sync_stream=True) + + model_response = iterator.chunk_parser( + { + "type": "message_delta", + "delta": {"stop_reason": "end_turn", "stop_sequence": None}, + } + ) + + assert model_response.choices[0].finish_reason == "stop" + assert model_response.usage is None + + def test_streaming_thinking_deltas_count_reasoning_tokens_in_usage(): """Anthropic streaming usage should account for emitted thinking deltas.""" chunks = [ From 61e3b5ddae1fdbbf12f6fa087b8bf0ee3e318d12 Mon Sep 17 00:00:00 2001 From: Joshua Valluru <326636767+joshua-berri@users.noreply.github.com> Date: Tue, 15 Sep 2026 17:31:07 -0700 Subject: [PATCH 041/133] fix(mcp): persist OAuth credentials for rowless JWT admins --- .../mcp_server/bridge_token_flow.py | 5 ++- litellm/proxy/auth/handle_jwt.py | 36 +++++++++++-------- .../mcp_server/test_discoverable_endpoints.py | 18 +++++++++- 3 files changed, 40 insertions(+), 19 deletions(-) diff --git a/litellm/proxy/_experimental/mcp_server/bridge_token_flow.py b/litellm/proxy/_experimental/mcp_server/bridge_token_flow.py index 6d11a4d2607..b693cc046c8 100644 --- a/litellm/proxy/_experimental/mcp_server/bridge_token_flow.py +++ b/litellm/proxy/_experimental/mcp_server/bridge_token_flow.py @@ -371,10 +371,9 @@ async def _extract_jwt_user_id(request: Request, token: str) -> str | None: identity_only=True, ) resolved_user: Final = identity["user_object"] - if resolved_user is None: + if resolved_user is not None and isinstance(_active_user_record(resolved_user), str): return None - owner: Final = _active_user_record(resolved_user) - return None if isinstance(owner, str) else identity["user_id"] + return identity["user_id"] except Exception as exc: # noqa: BLE001 # public OAuth exchange stays available; unvalidated identities never write credentials verbose_logger.debug("OAuth JWT identity could not be validated (%s)", type(exc).__name__) return None diff --git a/litellm/proxy/auth/handle_jwt.py b/litellm/proxy/auth/handle_jwt.py index d5fcc578167..2d8cfb614ce 100644 --- a/litellm/proxy/auth/handle_jwt.py +++ b/litellm/proxy/auth/handle_jwt.py @@ -62,6 +62,7 @@ from litellm.proxy.common_utils.user_api_key_cache import ( from litellm.proxy.utils import PrismaClient, ProxyLogging from litellm.repositories.user_repository import UserRepository from litellm.types.agents import AgentResponse +from litellm.types.proxy.auth.auth_checks import UserNotFoundError from .auth_checks import ( _allowed_routes_check, @@ -2343,21 +2344,26 @@ class JWTAuthManager: ) if identity_only: - identity_user, _, _, _, identity_user_id = await JWTAuthManager.get_objects( - user_id=user_id, - user_email=user_email, - org_id=None, - end_user_id=None, - team_id=None, - valid_user_email=valid_user_email, - jwt_handler=jwt_handler, - prisma_client=prisma_client, - user_api_key_cache=user_api_key_cache, - parent_otel_span=parent_otel_span, - proxy_logging_obj=proxy_logging_obj, - route=route, - user_id_upsert=False, - ) + try: + identity_user, _, _, _, identity_user_id = await JWTAuthManager.get_objects( + user_id=user_id, + user_email=user_email, + org_id=None, + end_user_id=None, + team_id=None, + valid_user_email=valid_user_email, + jwt_handler=jwt_handler, + prisma_client=prisma_client, + user_api_key_cache=user_api_key_cache, + parent_otel_span=parent_otel_span, + proxy_logging_obj=proxy_logging_obj, + route=route, + user_id_upsert=False, + ) + except UserNotFoundError: + if not jwt_handler.is_admin(scopes=scopes): + raise + identity_user, identity_user_id = None, user_id return JWTAuthBuilderResult( is_proxy_admin=False, # Admin admission uses the claim ID; other callers use the canonical DB ID. diff --git a/tests/test_litellm/proxy/_experimental/mcp_server/test_discoverable_endpoints.py b/tests/test_litellm/proxy/_experimental/mcp_server/test_discoverable_endpoints.py index bf557667892..f6dc7932e2f 100644 --- a/tests/test_litellm/proxy/_experimental/mcp_server/test_discoverable_endpoints.py +++ b/tests/test_litellm/proxy/_experimental/mcp_server/test_discoverable_endpoints.py @@ -11444,11 +11444,13 @@ def _oauth_identity_jwt( @pytest.mark.parametrize("header", ["Authorization", "x-litellm-api-key"]) @pytest.mark.parametrize("policy_allowed", [False, True]) @pytest.mark.parametrize("admin", [False, True]) +@pytest.mark.parametrize("owner_state", ["active", "missing", "inactive", "database_error"]) async def test_oauth_exchange_stores_token_for_validated_jwt_user( jwt_oauth_identity: tuple["JWTHandler", "RSAPrivateKey"], header: str, policy_allowed: bool, admin: bool, + owner_state: str, monkeypatch: pytest.MonkeyPatch, ) -> None: import httpx @@ -11475,6 +11477,7 @@ async def test_oauth_exchange_stores_token_for_validated_jwt_user( from litellm.caching.llm_caching_handler import LLMClientCache from litellm.llms.custom_httpx.http_handler import AsyncHTTPHandler from litellm.proxy import proxy_server + from litellm.models.user import LiteLLM_UserTable from litellm.proxy.common_utils.encrypt_decrypt_utils import decrypt_value_helper from litellm.types.llms.custom_http import httpxSpecialProvider @@ -11485,6 +11488,18 @@ async def test_oauth_exchange_stores_token_for_validated_jwt_user( return httpx.Response(200, json={"access_token": "upstream-token", "token_type": "Bearer"}) database: Final = MagicMock() + users: Final = database.db.litellm_usertable + users.find_unique = AsyncMock(return_value=None) + users.find_first = AsyncMock(return_value=None) + users.create = AsyncMock() + if owner_state in ("missing", "database_error"): + handler.user_api_key_cache.delete_cache("jwt-owner") + if owner_state == "database_error": + users.find_unique.side_effect = RuntimeError("database unavailable") + if owner_state == "inactive": + handler.user_api_key_cache.set_cache( + "jwt-owner", LiteLLM_UserTable(user_id="jwt-owner", metadata={"scim_active": False}) + ) table: Final = database.db.litellm_mcpusercredentials table.find_unique = AsyncMock(return_value=None) table.upsert = AsyncMock() @@ -11509,7 +11524,8 @@ async def test_oauth_exchange_stores_token_for_validated_jwt_user( ) assert response.status_code == 200 assert json.loads(response.body)["access_token"] == "upstream-token" - if not policy_allowed: + users.create.assert_not_awaited() + if not policy_allowed or owner_state in ("inactive", "database_error") or (owner_state == "missing" and not admin): table.upsert.assert_not_awaited() return table.upsert.assert_awaited_once() From f5c1c82f8175e42491c364dca3208eb5151b815b Mon Sep 17 00:00:00 2001 From: kerry Date: Wed, 16 Sep 2026 00:32:24 +0000 Subject: [PATCH 042/133] fix(responses): estimate usage from text when streamed completed event omits usage Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- litellm/responses/streaming_iterator.py | 15 +++ litellm/responses/utils.py | 16 ++++ .../responses/test_streaming_iterator.py | 96 ++++++++++++++++++- 3 files changed, 124 insertions(+), 3 deletions(-) diff --git a/litellm/responses/streaming_iterator.py b/litellm/responses/streaming_iterator.py index 38874768ca8..52b50167190 100644 --- a/litellm/responses/streaming_iterator.py +++ b/litellm/responses/streaming_iterator.py @@ -420,6 +420,21 @@ class BaseResponsesAPIStreamingIterator: openai_types.ResponsesAPIStreamEvents.RESPONSE_FAILED, ): self.completed_response = openai_responses_api_chunk + _response_obj: Final[ResponsesAPIResponse | None] = getattr( + openai_responses_api_chunk, "response", None + ) + if ( + _chunk_type + in ( + openai_types.ResponsesAPIStreamEvents.RESPONSE_COMPLETED, + openai_types.ResponsesAPIStreamEvents.RESPONSE_INCOMPLETE, + ) + and _response_obj is not None + and _response_obj.usage is None + ): + _response_obj.usage = ResponseAPILoggingUtils.estimate_usage_from_text( + self.model or "", self.request_data.get("input"), self._generated_content + ) _stamp_responses_usage_cost(getattr(openai_responses_api_chunk, "response", None), self.logging_obj) if _chunk_type == openai_types.ResponsesAPIStreamEvents.RESPONSE_FAILED: diff --git a/litellm/responses/utils.py b/litellm/responses/utils.py index 41a3ded7022..e523b3771af 100644 --- a/litellm/responses/utils.py +++ b/litellm/responses/utils.py @@ -1,4 +1,5 @@ import base64 +import json import re from collections.abc import Iterable, Mapping, Sequence from typing import Any, Final, Optional, TypeVar, Union, cast, get_type_hints, overload @@ -1239,3 +1240,18 @@ class ResponseAPILoggingUtils: setattr(chat_usage, "cost", response_api_usage.cost) return chat_usage + + @staticmethod + def estimate_usage_from_text(model: str, request_input: object, generated_text: str) -> ResponseAPIUsage: + input_text: Final = request_input if isinstance(request_input, str) else json.dumps(request_input, default=str) + input_tokens: Final = litellm.token_counter( # pyright: ignore[reportUnknownMemberType] # token_counter's public signature is untyped + model=model, text=input_text + ) + output_tokens: Final = litellm.token_counter( # pyright: ignore[reportUnknownMemberType] # token_counter's public signature is untyped + model=model, text=generated_text, count_response_tokens=True + ) + return ResponseAPIUsage( + input_tokens=input_tokens, + output_tokens=output_tokens, + total_tokens=input_tokens + output_tokens, + ) diff --git a/tests/test_litellm/responses/test_streaming_iterator.py b/tests/test_litellm/responses/test_streaming_iterator.py index 5e0e794d93e..c593946ac4a 100644 --- a/tests/test_litellm/responses/test_streaming_iterator.py +++ b/tests/test_litellm/responses/test_streaming_iterator.py @@ -31,8 +31,15 @@ def _sse_event(payload: dict) -> bytes: def _mock_config() -> Mock: mock_config = Mock(spec=BaseResponsesAPIConfig) - mock_responses_api_response = Mock(spec=ResponsesAPIResponse) - mock_responses_api_response.id = "resp_ttft" + mock_responses_api_response = ResponsesAPIResponse( + id="resp_ttft", + created_at=0, + status="completed", + model="gpt-4o-mini", + object="response", + output=[], + usage=ResponseAPIUsage(input_tokens=1, output_tokens=1, total_tokens=2), + ) def _transform(model, parsed_chunk, logging_obj): evt_type = parsed_chunk.get("type") @@ -54,6 +61,8 @@ def _make_iterator( sse_events: list[bytes], logging_obj: LiteLLMLoggingObj, trailing_error: Optional[Exception] = None, + config: Mock | None = None, + request_data: dict | None = None, ) -> ResponsesAPIStreamingIterator: async def aiter_bytes(): for evt in sse_events: @@ -68,10 +77,11 @@ def _make_iterator( return ResponsesAPIStreamingIterator( response=mock_response, model="gpt-4o-mini", - responses_api_provider_config=_mock_config(), + responses_api_provider_config=config or _mock_config(), logging_obj=logging_obj, litellm_metadata={}, custom_llm_provider="openai", + request_data=request_data, ) @@ -329,6 +339,86 @@ def test_run_post_success_hooks_does_not_report_generation_time_as_overhead(): assert "litellm_overhead_time_ms" not in iterator.completed_response._hidden_params +def _mock_config_with_completed_response(response: ResponsesAPIResponse) -> Mock: + mock_config = Mock(spec=BaseResponsesAPIConfig) + + def _transform(model, parsed_chunk, logging_obj): + evt_type = parsed_chunk.get("type") + if evt_type == "response.completed": + completed = Mock(spec=ResponseCompletedEvent) + completed.type = ResponsesAPIStreamEvents.RESPONSE_COMPLETED + completed.response = response + return completed + stub = Mock() + stub.type = evt_type + if evt_type == "response.output_text.delta": + stub.delta = parsed_chunk.get("delta") + return stub + + mock_config.transform_streaming_response.side_effect = _transform + return mock_config + + +def _responses_api_response_without_usage() -> ResponsesAPIResponse: + return ResponsesAPIResponse( + id="resp_no_usage", + created_at=int(datetime(2025, 1, 1).timestamp()), + status="completed", + model="gpt-4o-mini", + object="response", + output=[], + usage=None, + ) + + +@pytest.mark.asyncio +async def test_completed_event_without_usage_gets_text_estimate(): + """A response.completed event carrying usage: null still bills: the + iterator estimates usage from the request input and generated text.""" + response = _responses_api_response_without_usage() + iterator = _make_iterator( + sse_events=[ + _sse_event({"type": "response.output_text.delta", "delta": "hello world"}), + _sse_event({"type": "response.completed", "response": {}}), + ], + logging_obj=_logging_obj_stub(), + config=_mock_config_with_completed_response(response), + request_data={"input": "count these input tokens please"}, + ) + + async for _ in iterator: + pass + + usage = iterator.completed_response.response.usage + assert usage is not None + assert usage.input_tokens > 0 + assert usage.output_tokens > 0 + assert usage.total_tokens == usage.input_tokens + usage.output_tokens + + +@pytest.mark.asyncio +async def test_completed_event_with_usage_is_left_untouched(): + """Provider-reported usage on response.completed wins over the estimate.""" + response = _responses_api_response_with_usage() + iterator = _make_iterator( + sse_events=[ + _sse_event({"type": "response.output_text.delta", "delta": "hello world"}), + _sse_event({"type": "response.completed", "response": {}}), + ], + logging_obj=_logging_obj_stub(), + config=_mock_config_with_completed_response(response), + request_data={"input": "count these input tokens please"}, + ) + + async for _ in iterator: + pass + + usage = iterator.completed_response.response.usage + assert usage.input_tokens == 20 + assert usage.output_tokens == 60 + assert usage.total_tokens == 80 + + def _responses_api_response_with_usage() -> ResponsesAPIResponse: return ResponsesAPIResponse( id="resp_lit6427", From a7a61db78dcdb71a9b72c8e11806c0cfa4151fc4 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Wed, 16 Sep 2026 00:37:55 +0000 Subject: [PATCH 043/133] fix(fireworks_ai): flatten dict-form reasoning_effort to its effort string Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- .../llms/fireworks_ai/chat/transformation.py | 9 ++++---- .../test_fireworks_ai_chat_transformation.py | 22 +++++++++++++++++++ 2 files changed, 27 insertions(+), 4 deletions(-) diff --git a/litellm/llms/fireworks_ai/chat/transformation.py b/litellm/llms/fireworks_ai/chat/transformation.py index 05160d83c12..54a122f7beb 100644 --- a/litellm/llms/fireworks_ai/chat/transformation.py +++ b/litellm/llms/fireworks_ai/chat/transformation.py @@ -327,12 +327,13 @@ class FireworksAIConfig(FireworksAIMixin, OpenAIGPTConfig): elif param == "max_completion_tokens": optional_params["max_tokens"] = value elif param == "reasoning_effort": - if value is True: + effort: Final = value.get("effort") if isinstance(value, dict) else value + if effort is True: optional_params["reasoning_effort"] = "medium" - elif value is False: + elif effort is False: optional_params["reasoning_effort"] = "none" - elif value != "auto": - optional_params["reasoning_effort"] = value + elif effort is not None and effort != "auto": + optional_params["reasoning_effort"] = effort elif param in supported_openai_params: if value is not None: optional_params[param] = value diff --git a/tests/test_litellm/llms/fireworks_ai/chat/test_fireworks_ai_chat_transformation.py b/tests/test_litellm/llms/fireworks_ai/chat/test_fireworks_ai_chat_transformation.py index fb0311ef39b..7715e7b32ff 100644 --- a/tests/test_litellm/llms/fireworks_ai/chat/test_fireworks_ai_chat_transformation.py +++ b/tests/test_litellm/llms/fireworks_ai/chat/test_fireworks_ai_chat_transformation.py @@ -1189,6 +1189,28 @@ def test_reasoning_effort_integer_passthrough(): assert isinstance(result["reasoning_effort"], int) +def test_reasoning_effort_dict_from_anthropic_adapter_flattened_to_effort_string(): + config = FireworksAIConfig() + result = config.map_openai_params( + {"reasoning_effort": {"effort": "medium", "summary": "detailed"}}, + {}, + _REASONING_MODEL, + drop_params=False, + ) + assert result["reasoning_effort"] == "medium" + + +def test_reasoning_effort_dict_without_effort_key_dropped(): + config = FireworksAIConfig() + result = config.map_openai_params( + {"reasoning_effort": {"summary": "detailed"}}, + {}, + _REASONING_MODEL, + drop_params=False, + ) + assert "reasoning_effort" not in result + + def test_reasoning_effort_auto_dropped_to_model_default(): config = FireworksAIConfig() result = config.map_openai_params( From 232233f654f727fa308f925d012d2cbd7cbfa7a2 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Wed, 16 Sep 2026 00:39:11 +0000 Subject: [PATCH 044/133] refactor(fireworks_ai): extract reasoning_effort mapping into helper Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- .../llms/fireworks_ai/chat/transformation.py | 19 +++++++++++++------ 1 file changed, 13 insertions(+), 6 deletions(-) diff --git a/litellm/llms/fireworks_ai/chat/transformation.py b/litellm/llms/fireworks_ai/chat/transformation.py index 54a122f7beb..b6c2b379d66 100644 --- a/litellm/llms/fireworks_ai/chat/transformation.py +++ b/litellm/llms/fireworks_ai/chat/transformation.py @@ -49,6 +49,17 @@ if TYPE_CHECKING: import tiktoken +def _map_reasoning_effort(value: object) -> object: + effort: Final[object] = cast(Mapping[str, object], value).get("effort") if isinstance(value, Mapping) else value + if effort is True: + return "medium" + if effort is False: + return "none" + if effort == "auto": + return None + return effort + + def _extract_fireworks_hidden_params(payload: dict) -> dict: """ Collect Fireworks-specific response fields (perf_metrics, prompt_token_ids, @@ -327,12 +338,8 @@ class FireworksAIConfig(FireworksAIMixin, OpenAIGPTConfig): elif param == "max_completion_tokens": optional_params["max_tokens"] = value elif param == "reasoning_effort": - effort: Final = value.get("effort") if isinstance(value, dict) else value - if effort is True: - optional_params["reasoning_effort"] = "medium" - elif effort is False: - optional_params["reasoning_effort"] = "none" - elif effort is not None and effort != "auto": + effort = _map_reasoning_effort(value) + if effort is not None: optional_params["reasoning_effort"] = effort elif param in supported_openai_params: if value is not None: From 14d239fc849417d62e3458603a5432a216eb4944 Mon Sep 17 00:00:00 2001 From: yassin Date: Wed, 16 Sep 2026 00:46:59 +0000 Subject: [PATCH 045/133] test(proxy): pin JWT scope denial message shape through auth exception conversion Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- .../proxy/auth/test_auth_exception_handler.py | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) diff --git a/tests/test_litellm/proxy/auth/test_auth_exception_handler.py b/tests/test_litellm/proxy/auth/test_auth_exception_handler.py index 4328eac8432..d5ef71cd1d1 100644 --- a/tests/test_litellm/proxy/auth/test_auth_exception_handler.py +++ b/tests/test_litellm/proxy/auth/test_auth_exception_handler.py @@ -36,7 +36,7 @@ from litellm.proxy._types import ( ProxyException, UserAPIKeyAuth, ) -from litellm.proxy.auth.auth_exception_handler import UserAPIKeyAuthExceptionHandler +from litellm.proxy.auth.auth_exception_handler import UserAPIKeyAuthExceptionHandler, _as_proxy_exception from litellm.proxy.auth.model_access_denied import ModelAccessDeniedHTTPException @@ -1053,6 +1053,21 @@ async def test_handle_authentication_error_keeps_internal_message_on_model_acces assert [r for r in caplog.records if r.levelname == "WARNING" and "internal-models" in r.getMessage()] == [] +def test_as_proxy_exception_keeps_jwt_scope_denial_message_shape(): + detail = {"error": "The model `gpt-5.6` is unavailable for this API key or does not exist."} + denial = ModelAccessDeniedHTTPException( + internal_message="model=gpt-5.6 not allowed. Allowed_models=['internal-models']", + status_code=status.HTTP_403_FORBIDDEN, + detail=detail, + ) + plain = _as_proxy_exception(HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=detail)) + + converted = _as_proxy_exception(denial) + + assert converted.to_dict() == plain.to_dict() + assert converted.internal_message == denial.internal_message + + @pytest.mark.asyncio @pytest.mark.parametrize("unset_value", [None, ""]) async def test_handle_authentication_error_no_extra_denial_log_when_message_not_configured( From b4f9e319fc2a0e55c775db371ce5d09e806e6c4f Mon Sep 17 00:00:00 2001 From: mateo-berri <277851410+mateo-berri@users.noreply.github.com> Date: Tue, 15 Sep 2026 17:53:57 -0700 Subject: [PATCH 046/133] fix(proxy): surface the upstream status code when a RAG query fails --- litellm/proxy/rag_endpoints/endpoints.py | 7 +++- .../proxy/rag_endpoints/test_rag_endpoints.py | 36 +++++++++++++++++++ 2 files changed, 42 insertions(+), 1 deletion(-) diff --git a/litellm/proxy/rag_endpoints/endpoints.py b/litellm/proxy/rag_endpoints/endpoints.py index d6a402e1860..c09f9c755ed 100644 --- a/litellm/proxy/rag_endpoints/endpoints.py +++ b/litellm/proxy/rag_endpoints/endpoints.py @@ -69,6 +69,11 @@ def _response_attr(source: object, name: str) -> object: return getattr(source, name, None) +def _upstream_status_code(error: Exception) -> int: + code: Final = getattr(error, "status_code", None) + return code if isinstance(code, int) else 500 + + def _raise_vector_store_scan_depth_exceeded() -> None: raise HTTPException( status_code=400, @@ -814,6 +819,6 @@ async def rag_query( except Exception as e: verbose_proxy_logger.exception("RAG Query failed: %s", e) raise HTTPException( - status_code=500, + status_code=_upstream_status_code(e), detail={"error": str(e)}, ) diff --git a/tests/test_litellm/proxy/rag_endpoints/test_rag_endpoints.py b/tests/test_litellm/proxy/rag_endpoints/test_rag_endpoints.py index 832435711c6..1cceaf95b09 100644 --- a/tests/test_litellm/proxy/rag_endpoints/test_rag_endpoints.py +++ b/tests/test_litellm/proxy/rag_endpoints/test_rag_endpoints.py @@ -11,6 +11,7 @@ from unittest.mock import AsyncMock, MagicMock, patch import pytest from fastapi.testclient import TestClient +import litellm from litellm.proxy._types import LitellmUserRoles, UserAPIKeyAuth from litellm.proxy.auth.user_api_key_auth import user_api_key_auth from litellm.proxy.proxy_server import app @@ -282,6 +283,41 @@ def test_rag_query_returns_response_cost_header(client_internal_user): assert response.headers.get("x-litellm-response-cost") == "3.45e-06" +@pytest.mark.parametrize( + ("upstream_error", "expected_status"), + [ + (litellm.BadRequestError(message="filter andAll needs two clauses", model="kb", llm_provider="bedrock"), 400), + (litellm.NotFoundError(message="Knowledge Base does not exist", model="kb", llm_provider="bedrock"), 404), + (RuntimeError("pipeline blew up"), 500), + ], +) +def test_rag_query_surfaces_upstream_status_code(client_internal_user, upstream_error, expected_status): + """A vector store rejection must reach the caller with its own status code, never a blanket 500.""" + with ( + patch( # test-quality-ok: the handler calls the module-level litellm.aquery directly; no injection seam + "litellm.proxy.rag_endpoints.endpoints.litellm.aquery", + new=AsyncMock(side_effect=upstream_error), + ), + patch("litellm.vector_store_registry", None), # test-quality-ok: proxy module global, no injection seam + patch("litellm.proxy.proxy_server.prisma_client", None), # test-quality-ok: proxy module global, no injection seam + ): + response = client_internal_user.post( + "/v1/rag/query", + json={ + "model": "bedrock/us.anthropic.claude-sonnet-5", + "messages": [{"role": "user", "content": "How was this document ingested?"}], + "retrieval_config": { + "vector_store_id": "L7INRFMVQT", + "custom_llm_provider": "bedrock", + "retrieval_filter": {"andAll": [{"equals": {"key": "department", "value": "billing"}}]}, + }, + }, + ) + + assert response.status_code == expected_status, response.text + assert str(upstream_error) in response.json()["detail"]["error"] + + def test_rag_query_stream_returns_event_stream(client_internal_user): """ A stream=true /v1/rag/query must return an SSE response. Returning the raw From 3a8679d3d81308a2b6dab32bb210d03df2879534 Mon Sep 17 00:00:00 2001 From: mateo-berri <277851410+mateo-berri@users.noreply.github.com> Date: Tue, 15 Sep 2026 17:54:58 -0700 Subject: [PATCH 047/133] fix(proxy): never forward the LiteLLM virtual key to Anthropic on the /anthropic passthrough The /anthropic/{endpoint} route forwarded every incoming header upstream, so the header carrying the caller's LiteLLM virtual key (Authorization, x-api-key, x-litellm-api-key, or the operator-configured key header) reached Anthropic and was rejected there as an invalid credential, with or without a proxy-side Anthropic key layered on top. Share the Vertex credential-less header filter: drop the proxy-only credential headers by name, drop the value that authenticated the caller (virtual key, master key, or JWT) from Authorization / x-api-key, keep a caller's own Anthropic credential, layer the proxy's Anthropic credential on top, and fail with a clean 401 when neither the proxy nor the caller supplied one. Resolves LIT-3550 --- .../llm_passthrough_endpoints.py | 59 +++- .../test_llm_pass_through_endpoints.py | 293 ++++++++++++++++++ 2 files changed, 339 insertions(+), 13 deletions(-) diff --git a/litellm/proxy/pass_through_endpoints/llm_passthrough_endpoints.py b/litellm/proxy/pass_through_endpoints/llm_passthrough_endpoints.py index 3c2ae02dc52..3c5f7da1859 100644 --- a/litellm/proxy/pass_through_endpoints/llm_passthrough_endpoints.py +++ b/litellm/proxy/pass_through_endpoints/llm_passthrough_endpoints.py @@ -708,8 +708,7 @@ async def anthropic_proxy_route( endpoint_func: Final = create_pass_through_route( endpoint=endpoint, target=str(updated_url), - custom_headers=auth_header if auth_header is not None else {}, - _forward_headers=True, + custom_headers=_upstream_headers_for_anthropic_route(request, user_api_key_dict, auth_header), is_streaming_request=is_streaming_request, ) # dynamically construct pass-through endpoint based on incoming path received_value: Final = await endpoint_func( @@ -1909,6 +1908,19 @@ _HEADERS_NEVER_FORWARDED_TO_VERTEX: Final = frozenset({"content-length", "host"} SpecialHeaders.litellm_credential_header_names() - _VERTEX_UPSTREAM_CREDENTIAL_HEADERS ) +_CREDENTIALLESS_ANTHROPIC_MISSING_CREDENTIAL_DETAIL: Final = ( + "No Anthropic credential is configured on this proxy and the request carried no upstream " + "Anthropic credential. The LiteLLM virtual key is not forwarded to Anthropic. Configure an " + "Anthropic credential (ANTHROPIC_API_KEY or ANTHROPIC_AUTH_TOKEN, or a model with " + "use_in_pass_through: true), or send your own Anthropic API key in the x-api-key header or " + "your own Anthropic OAuth token in the Authorization header." +) + +_ANTHROPIC_UPSTREAM_CREDENTIAL_HEADERS: Final = frozenset({"authorization", "x-api-key"}) +_HEADERS_NEVER_FORWARDED_TO_ANTHROPIC: Final = frozenset({"content-length", "host", "accept-encoding"}) | ( + SpecialHeaders.litellm_credential_header_names() - _ANTHROPIC_UPSTREAM_CREDENTIAL_HEADERS +) + _MAPPED_ROUTE_CALLER_KEY_HEADER: Final = "litellm_user_api_key" @@ -1964,26 +1976,47 @@ def _is_authenticated_caller_secret(value: str, user_api_key_dict: UserAPIKeyAut return hmac.compare_digest(stored_representation.encode(), authenticated_key.encode()) +def _caller_headers_without_litellm_secrets( + request: Request, user_api_key_dict: UserAPIKeyAuth, never_forwarded: frozenset[str] +) -> Mapping[str, str]: + """Incoming headers minus the ones only LiteLLM consumes and minus whatever value authenticated the caller.""" + incoming: Final = _safe_get_request_headers(request) + dropped_by_name: Final = never_forwarded.union( + (_MAPPED_ROUTE_CALLER_KEY_HEADER, *_operator_configured_caller_key_header_names()) + ) + return MappingProxyType( + { + name: value + for name, value in incoming.items() + if name not in dropped_by_name and not _is_authenticated_caller_secret(value, user_api_key_dict) + } + ) + + def _forwarded_headers_for_credentialless_vertex_passthrough( request: Request, user_api_key_dict: UserAPIKeyAuth ) -> Mapping[str, str]: """Caller headers to forward on the bring-your-own-credentials Vertex branch, minus LiteLLM secrets.""" - incoming: Final = _safe_get_request_headers(request) - never_forwarded: Final = _HEADERS_NEVER_FORWARDED_TO_VERTEX.union( - (_MAPPED_ROUTE_CALLER_KEY_HEADER, *_operator_configured_caller_key_header_names()) + forwarded: Final = _caller_headers_without_litellm_secrets( + request, user_api_key_dict, _HEADERS_NEVER_FORWARDED_TO_VERTEX ) - forwarded: Final = MappingProxyType( - { - name: value - for name, value in incoming.items() - if name not in never_forwarded and not _is_authenticated_caller_secret(value, user_api_key_dict) - } - ) - if "authorization" not in forwarded and "x-goog-api-key" not in forwarded: + if _VERTEX_UPSTREAM_CREDENTIAL_HEADERS.isdisjoint(forwarded): raise HTTPException(status_code=401, detail=_CREDENTIALLESS_VERTEX_MISSING_CREDENTIAL_DETAIL) return forwarded +def _upstream_headers_for_anthropic_route( + request: Request, user_api_key_dict: UserAPIKeyAuth, proxy_auth_header: Mapping[str, str] | None +) -> Mapping[str, str]: + """Caller headers minus LiteLLM secrets, with the proxy's own Anthropic credential layered on top.""" + caller_headers: Final = _caller_headers_without_litellm_secrets( + request, user_api_key_dict, _HEADERS_NEVER_FORWARDED_TO_ANTHROPIC + ) + if proxy_auth_header is None and _ANTHROPIC_UPSTREAM_CREDENTIAL_HEADERS.isdisjoint(caller_headers): + raise HTTPException(status_code=401, detail=_CREDENTIALLESS_ANTHROPIC_MISSING_CREDENTIAL_DETAIL) + return MappingProxyType({**caller_headers, **(proxy_auth_header or {})}) + + async def _prepare_vertex_auth_headers( request: Request, vertex_credentials: VertexPassThroughCredentials | None, diff --git a/tests/test_litellm/proxy/pass_through_endpoints/test_llm_pass_through_endpoints.py b/tests/test_litellm/proxy/pass_through_endpoints/test_llm_pass_through_endpoints.py index 7b285674145..8cb9afc1efb 100644 --- a/tests/test_litellm/proxy/pass_through_endpoints/test_llm_pass_through_endpoints.py +++ b/tests/test_litellm/proxy/pass_through_endpoints/test_llm_pass_through_endpoints.py @@ -28,6 +28,7 @@ from litellm.proxy.pass_through_endpoints.llm_passthrough_endpoints import ( BaseOpenAIPassThroughHandler, RouteChecks, _join_url_paths, + anthropic_proxy_route, azure_proxy_route, bedrock_llm_proxy_route, bedrock_proxy_route, @@ -4285,6 +4286,298 @@ class TestVertexCredentiallessPassthroughVirtualKeyLeak: assert "sk-master-1234" not in " ".join(f"{name}:{value}" for name, value in forwarded.items()) +class TestAnthropicPassthroughVirtualKeyLeak: + """Regression coverage for LIT-3550. + + ``/anthropic/{endpoint}`` forwarded every incoming header to Anthropic, so the + header that carried the caller's LiteLLM virtual key (``Authorization``, + ``x-api-key``, ``x-litellm-api-key``, or an operator-configured name) reached + Anthropic and was rejected there as an invalid credential, with or without a + proxy-side Anthropic key layered on top. The virtual key must never leave the + proxy: it is dropped by value from the headers Anthropic reads as credentials + (``Authorization`` / ``x-api-key``), the proxy-only credential headers are + dropped by name, a caller's own Anthropic credential still passes through, and + a request with neither a proxy credential nor a caller credential fails with a + clean 401 instead of reaching ``create_pass_through_route``. + + The forwarded set is rebuilt the way ``pass_through_request`` builds it from + the captured ``create_pass_through_route`` kwargs, so a route that re-enables + ``_forward_headers`` fails these tests the same way the original bug did. + """ + + VKEY = "sk-litellm-victim-key" + PROXY_KEY = "sk-ant-api03-proxy-configured-key" + ENDPOINT = "v1/messages" + + async def _run( + self, + monkeypatch, + headers: list[tuple[bytes, bytes]], + authenticated: UserAPIKeyAuth | None = None, + master_key: str | None = "sk-master-1234", + proxy_api_key: str | None = None, + ) -> tuple[HTTPException | None, dict | None]: + from litellm.proxy.pass_through_endpoints.pass_through_endpoints import HttpPassThroughEndpointHelpers + from litellm.proxy.pass_through_endpoints.passthrough_endpoint_router import ( + PassthroughEndpointRouter, + ) + + monkeypatch.setattr("litellm.proxy.proxy_server.master_key", master_key) + monkeypatch.delenv("ANTHROPIC_AUTH_TOKEN", raising=False) + if proxy_api_key is None: + monkeypatch.delenv("ANTHROPIC_API_KEY", raising=False) + else: + monkeypatch.setenv("ANTHROPIC_API_KEY", proxy_api_key) + caller: Final = authenticated if authenticated is not None else UserAPIKeyAuth(api_key=self.VKEY) + + async def receive(): + return {"type": "http.request", "body": b"{}", "more_body": False} + + request = Request( + { + "type": "http", + "method": "POST", + "path": f"/anthropic/{self.ENDPOINT}", + "headers": headers, + "query_string": b"", + }, + receive=receive, + ) + + captured: dict = {} + + def fake_create_pass_through_route(**kwargs): + captured.update(kwargs) + return AsyncMock(return_value={"status": "success"}) + + module = "litellm.proxy.pass_through_endpoints.llm_passthrough_endpoints" + monkeypatch.setattr(f"{module}.passthrough_endpoint_router", PassthroughEndpointRouter(lambda: None)) + raised: HTTPException | None = None + with ( + mock.patch(f"{module}.create_pass_through_route", side_effect=fake_create_pass_through_route), + mock.patch(f"{module}.user_api_key_auth", new=AsyncMock(return_value=caller)), + ): + try: + await anthropic_proxy_route( + endpoint=self.ENDPOINT, + request=request, + fastapi_response=Response(), + user_api_key_dict=caller, + ) + except HTTPException as exc: + raised = exc + + if not captured: + return raised, None + upstream: Final = HttpPassThroughEndpointHelpers.forward_headers_from_request( + request_headers=dict(request.headers), + headers=dict(captured["custom_headers"] or {}), + forward_headers=captured.get("_forward_headers", False), + ) + return raised, upstream + + @staticmethod + def _blob(forwarded: dict) -> str: + return " ".join(f"{name}:{value}" for name, value in forwarded.items()) + + @pytest.mark.asyncio + async def test_authorization_bearer_virtual_key_is_rejected_not_forwarded(self, monkeypatch): + raised, forwarded = await self._run( + monkeypatch, + [(b"authorization", f"Bearer {self.VKEY}".encode()), (b"content-type", b"application/json")], + ) + assert forwarded is None, "credential-less request must never reach the upstream forwarder" + assert raised is not None and raised.status_code == 401 + assert "ANTHROPIC_API_KEY" in str(raised.detail) and "use_in_pass_through" in str(raised.detail) + + @pytest.mark.asyncio + async def test_x_api_key_virtual_key_is_rejected_not_forwarded(self, monkeypatch): + raised, forwarded = await self._run( + monkeypatch, + [(b"x-api-key", self.VKEY.encode()), (b"content-type", b"application/json")], + ) + assert forwarded is None, "a virtual key that authenticated via x-api-key must be stripped, not forwarded" + assert raised is not None and raised.status_code == 401 + + @pytest.mark.asyncio + async def test_x_litellm_api_key_virtual_key_is_rejected_not_forwarded(self, monkeypatch): + raised, forwarded = await self._run( + monkeypatch, + [(b"x-litellm-api-key", self.VKEY.encode()), (b"content-type", b"application/json")], + ) + assert forwarded is None, "credential-less request must never reach the upstream forwarder" + assert raised is not None and raised.status_code == 401 + + @pytest.mark.asyncio + async def test_master_key_in_authorization_is_rejected_not_forwarded(self, monkeypatch): + raised, forwarded = await self._run( + monkeypatch, + [(b"authorization", b"Bearer sk-master-1234"), (b"content-type", b"application/json")], + authenticated=UserAPIKeyAuth(api_key="sk-master-1234", user_role=LitellmUserRoles.PROXY_ADMIN), + ) + assert forwarded is None, "the master key must never reach Anthropic" + assert raised is not None and raised.status_code == 401 + + @pytest.mark.asyncio + async def test_byo_anthropic_oauth_token_still_forwards_without_virtual_key(self, monkeypatch): + raised, forwarded = await self._run( + monkeypatch, + [ + (b"x-litellm-api-key", self.VKEY.encode()), + (b"authorization", b"Bearer sk-ant-oat01-caller-oauth-token"), + (b"anthropic-version", b"2023-06-01"), + (b"content-type", b"application/json"), + ], + ) + assert raised is None + assert forwarded is not None + assert forwarded.get("authorization") == "Bearer sk-ant-oat01-caller-oauth-token" + assert forwarded.get("anthropic-version") == "2023-06-01" + assert "x-litellm-api-key" not in forwarded + assert self.VKEY not in self._blob(forwarded) + + @pytest.mark.asyncio + async def test_byo_x_api_key_still_forwards_without_virtual_key(self, monkeypatch): + raised, forwarded = await self._run( + monkeypatch, + [ + (b"authorization", f"Bearer {self.VKEY}".encode()), + (b"x-api-key", b"sk-ant-api03-caller-own-key"), + (b"content-type", b"application/json"), + ], + ) + assert raised is None + assert forwarded is not None + assert forwarded.get("x-api-key") == "sk-ant-api03-caller-own-key" + assert "authorization" not in forwarded + assert self.VKEY not in self._blob(forwarded) + + @pytest.mark.asyncio + async def test_custom_auth_caller_keeps_own_authorization_token(self, monkeypatch): + raised, forwarded = await self._run( + monkeypatch, + [(b"authorization", b"Bearer sk-ant-oat01-caller-oauth-token"), (b"content-type", b"application/json")], + authenticated=UserAPIKeyAuth(api_key=None), + master_key=None, + ) + assert raised is None + assert forwarded is not None + assert forwarded.get("authorization") == "Bearer sk-ant-oat01-caller-oauth-token" + + @pytest.mark.asyncio + @pytest.mark.parametrize( + "credential_header", + sorted(SpecialHeaders.litellm_credential_header_names() - {"authorization", "x-api-key", "x-litellm-api-key"}), + ) + async def test_every_non_anthropic_credential_header_is_dropped_by_name(self, monkeypatch, credential_header): + raised, forwarded = await self._run( + monkeypatch, + [ + (b"x-litellm-api-key", self.VKEY.encode()), + (b"x-api-key", b"sk-ant-api03-caller-own-key"), + (credential_header.encode(), b"some-distinct-caller-secret-value"), + (b"content-type", b"application/json"), + ], + ) + assert raised is None + assert forwarded is not None + assert forwarded.get("x-api-key") == "sk-ant-api03-caller-own-key" + assert credential_header not in forwarded + assert "x-litellm-api-key" not in forwarded + assert self.VKEY not in self._blob(forwarded) + assert "some-distinct-caller-secret-value" not in self._blob(forwarded) + + @pytest.mark.asyncio + async def test_virtual_key_in_operator_configured_header_is_stripped(self, monkeypatch): + with mock.patch.dict( # test-quality-ok: general_settings is the real proxy config surface for litellm_key_header_name; no injection seam exists on this route + "litellm.proxy.proxy_server.general_settings", + {"litellm_key_header_name": "x-company-key"}, + ): + raised, forwarded = await self._run( + monkeypatch, + [ + (b"x-company-key", f"Bearer {self.VKEY}".encode()), + (b"x-api-key", b"sk-ant-api03-caller-own-key"), + (b"content-type", b"application/json"), + ], + ) + assert raised is None + assert forwarded is not None + assert forwarded.get("x-api-key") == "sk-ant-api03-caller-own-key" + assert "x-company-key" not in forwarded + assert self.VKEY not in self._blob(forwarded) + + @pytest.mark.asyncio + async def test_proxy_credential_replaces_virtual_key_sent_as_bearer(self, monkeypatch): + raised, forwarded = await self._run( + monkeypatch, + [ + (b"authorization", f"Bearer {self.VKEY}".encode()), + (b"anthropic-version", b"2023-06-01"), + (b"content-type", b"application/json"), + ], + proxy_api_key=self.PROXY_KEY, + ) + assert raised is None + assert forwarded is not None + assert forwarded.get("x-api-key") == self.PROXY_KEY + assert "authorization" not in forwarded + assert forwarded.get("anthropic-version") == "2023-06-01" + assert self.VKEY not in self._blob(forwarded) + + @pytest.mark.asyncio + async def test_proxy_credential_replaces_virtual_key_sent_as_x_api_key(self, monkeypatch): + raised, forwarded = await self._run( + monkeypatch, + [(b"x-api-key", self.VKEY.encode()), (b"content-type", b"application/json")], + proxy_api_key=self.PROXY_KEY, + ) + assert raised is None + assert forwarded is not None + assert forwarded.get("x-api-key") == self.PROXY_KEY + assert self.VKEY not in self._blob(forwarded) + + @pytest.mark.asyncio + async def test_proxy_credential_wins_over_callers_own_x_api_key(self, monkeypatch): + raised, forwarded = await self._run( + monkeypatch, + [ + (b"x-litellm-api-key", self.VKEY.encode()), + (b"x-api-key", b"sk-ant-api03-caller-own-key"), + (b"content-type", b"application/json"), + ], + proxy_api_key=self.PROXY_KEY, + ) + assert raised is None + assert forwarded is not None + assert forwarded.get("x-api-key") == self.PROXY_KEY + assert "sk-ant-api03-caller-own-key" not in self._blob(forwarded) + + @pytest.mark.asyncio + async def test_x_pass_and_hop_by_hop_handling_is_unchanged(self, monkeypatch): + raised, forwarded = await self._run( + monkeypatch, + [ + (b"authorization", f"Bearer {self.VKEY}".encode()), + (b"x-pass-anthropic-beta", b"interleaved-thinking-2025-05-14"), + (b"x-pass-authorization", b"Bearer smuggled"), + (b"content-length", b"2"), + (b"host", b"proxy.internal"), + (b"accept-encoding", b"br"), + (b"user-agent", b"curl/8.7.1"), + ], + proxy_api_key=self.PROXY_KEY, + ) + assert raised is None + assert forwarded is not None + assert forwarded.get("anthropic-beta") == "interleaved-thinking-2025-05-14" + assert forwarded.get("user-agent") == "curl/8.7.1" + assert "authorization" not in forwarded + assert "content-length" not in forwarded + assert "host" not in forwarded + assert "accept-encoding" not in forwarded + + class TestVertexPassthroughDefaultLocationOnShortRoutes: PROJECT = "test-project" SHORT_ROUTE = "publishers/google/models/gemini-2.5-flash:generateContent" From de9aa48cd62e73b5b291d6c5a2a952dd35f09f00 Mon Sep 17 00:00:00 2001 From: kerry Date: Wed, 16 Sep 2026 01:02:29 +0000 Subject: [PATCH 048/133] fix(responses): count multimodal input and tool-call output in the streamed usage fallback Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- litellm/responses/streaming_iterator.py | 38 ++++++++- litellm/responses/utils.py | 16 ---- .../responses/test_streaming_iterator.py | 78 ++++++++++++++++++- 3 files changed, 112 insertions(+), 20 deletions(-) diff --git a/litellm/responses/streaming_iterator.py b/litellm/responses/streaming_iterator.py index 52b50167190..6cb0331620f 100644 --- a/litellm/responses/streaming_iterator.py +++ b/litellm/responses/streaming_iterator.py @@ -32,6 +32,9 @@ from litellm.litellm_core_utils.llm_response_utils.response_metadata import ( ) from litellm.litellm_core_utils.thread_pool_executor import executor from litellm.llms.base_llm.responses.transformation import BaseResponsesAPIConfig +from litellm.responses.litellm_completion_transformation.transformation import ( + LiteLLMCompletionResponsesConfig, +) from litellm.responses.utils import ResponseAPILoggingUtils, ResponsesAPIRequestUtils from litellm.types.integrations.custom_logger import converted_stream_requested from litellm.types.llms.openai import ( @@ -257,6 +260,7 @@ class BaseResponsesAPIStreamingIterator: self._failure_handled = False # Track if failure handler has been called self._yielded_first_chunk = False self._generated_content = "" + self._generated_tool_arguments = "" self._completed_response_cached = False self._completed_response_logged = False self._completed_response_cache_hit: bool | None = None @@ -352,6 +356,10 @@ class BaseResponsesAPIStreamingIterator: _delta: Final = getattr(openai_responses_api_chunk, "delta", None) if isinstance(_delta, str): self._generated_content += _delta + elif _event_type == ResponsesAPIStreamEvents.FUNCTION_CALL_ARGUMENTS_DELTA: + _args_delta: Final = getattr(openai_responses_api_chunk, "delta", None) + if isinstance(_args_delta, str): + self._generated_tool_arguments += _args_delta _stream_model_id: Final = _model_id_from_metadata(self.litellm_metadata) if _event_type in ( ResponsesAPIStreamEvents.OUTPUT_ITEM_ADDED, @@ -432,8 +440,11 @@ class BaseResponsesAPIStreamingIterator: and _response_obj is not None and _response_obj.usage is None ): - _response_obj.usage = ResponseAPILoggingUtils.estimate_usage_from_text( - self.model or "", self.request_data.get("input"), self._generated_content + _response_obj.usage = _estimate_usage_from_text( + self.model or "", + self.request_data.get("input"), + self.request_data, + self._generated_content + self._generated_tool_arguments, ) _stamp_responses_usage_cost(getattr(openai_responses_api_chunk, "response", None), self.logging_obj) @@ -1347,6 +1358,29 @@ def _usage_as_model(usage: object) -> ResponseAPIUsage | None: return None +def _estimate_usage_from_text( + model: str, + request_input: object, + responses_api_request: Mapping[str, object], + generated_text: str, +) -> ResponseAPIUsage: + messages: Final = LiteLLMCompletionResponsesConfig.transform_responses_api_input_to_messages( # pyright: ignore[reportUnknownMemberType] # the transformer's signature is partially untyped + input=request_input, # pyright: ignore[reportArgumentType] # the raw Responses API input is a str or ResponseInputParam list, matching the helper's declared union + responses_api_request=dict(responses_api_request), + ) + input_tokens: Final = litellm.token_counter( # pyright: ignore[reportUnknownMemberType] # token_counter's public signature is untyped + model=model, messages=messages + ) + output_tokens: Final = litellm.token_counter( # pyright: ignore[reportUnknownMemberType] # token_counter's public signature is untyped + model=model, text=generated_text, count_response_tokens=True + ) + return ResponseAPIUsage( + input_tokens=input_tokens, + output_tokens=output_tokens, + total_tokens=input_tokens + output_tokens, + ) + + def _stamp_responses_usage_cost( response_obj: ResponsesAPIResponse | None, logging_obj: LiteLLMLoggingObj | None ) -> None: diff --git a/litellm/responses/utils.py b/litellm/responses/utils.py index e523b3771af..41a3ded7022 100644 --- a/litellm/responses/utils.py +++ b/litellm/responses/utils.py @@ -1,5 +1,4 @@ import base64 -import json import re from collections.abc import Iterable, Mapping, Sequence from typing import Any, Final, Optional, TypeVar, Union, cast, get_type_hints, overload @@ -1240,18 +1239,3 @@ class ResponseAPILoggingUtils: setattr(chat_usage, "cost", response_api_usage.cost) return chat_usage - - @staticmethod - def estimate_usage_from_text(model: str, request_input: object, generated_text: str) -> ResponseAPIUsage: - input_text: Final = request_input if isinstance(request_input, str) else json.dumps(request_input, default=str) - input_tokens: Final = litellm.token_counter( # pyright: ignore[reportUnknownMemberType] # token_counter's public signature is untyped - model=model, text=input_text - ) - output_tokens: Final = litellm.token_counter( # pyright: ignore[reportUnknownMemberType] # token_counter's public signature is untyped - model=model, text=generated_text, count_response_tokens=True - ) - return ResponseAPIUsage( - input_tokens=input_tokens, - output_tokens=output_tokens, - total_tokens=input_tokens + output_tokens, - ) diff --git a/tests/test_litellm/responses/test_streaming_iterator.py b/tests/test_litellm/responses/test_streaming_iterator.py index c593946ac4a..c3721353d42 100644 --- a/tests/test_litellm/responses/test_streaming_iterator.py +++ b/tests/test_litellm/responses/test_streaming_iterator.py @@ -5,12 +5,13 @@ completion_start_time = end_time.""" import json from datetime import datetime -from typing import Optional +from typing import Final, Optional from unittest.mock import Mock, patch import httpx import pytest +import litellm from litellm.litellm_core_utils.litellm_logging import Logging as LiteLLMLoggingObj from litellm.llms.base_llm.responses.transformation import BaseResponsesAPIConfig from litellm.responses.streaming_iterator import ( @@ -351,8 +352,10 @@ def _mock_config_with_completed_response(response: ResponsesAPIResponse) -> Mock return completed stub = Mock() stub.type = evt_type - if evt_type == "response.output_text.delta": + if "delta" in parsed_chunk: stub.delta = parsed_chunk.get("delta") + if "item" in parsed_chunk: + stub.item = parsed_chunk.get("item") return stub mock_config.transform_streaming_response.side_effect = _transform @@ -718,3 +721,74 @@ async def test_streaming_logging_copy_keeps_client_usage_when_response_fails_val assert isinstance(client_usage, ResponseAPIUsage) assert client_usage.input_tokens == 29 assert client_usage.cost == pytest.approx(0.0001) + + +@pytest.mark.asyncio +async def test_completed_event_without_usage_counts_tool_call_arguments(): + """A function-call-only stream still bills output tokens: streamed + function_call_arguments deltas feed the text estimate.""" + response = _responses_api_response_without_usage() + iterator = _make_iterator( + sse_events=[ + _sse_event( + { + "type": "response.output_item.added", + "item": {"type": "function_call", "name": "get_weather", "call_id": "call_1"}, + } + ), + _sse_event( + { + "type": "response.function_call_arguments.delta", + "delta": '{"location": "San Francisco", "unit": "celsius"}', + } + ), + _sse_event({"type": "response.completed", "response": {}}), + ], + logging_obj=_logging_obj_stub(), + config=_mock_config_with_completed_response(response), + request_data={"input": "what is the weather in san francisco"}, + ) + + async for _ in iterator: + pass + + usage = iterator.completed_response.response.usage + assert usage is not None + assert usage.output_tokens > 0 + assert usage.total_tokens == usage.input_tokens + usage.output_tokens + + +@pytest.mark.asyncio +async def test_completed_event_without_usage_counts_multimodal_input_as_messages(): + """Multimodal request input is counted as chat messages, not as a JSON blob: + a huge base64 image must not inflate the estimated input tokens.""" + image_input: Final = [ + { + "role": "user", + "content": [ + {"type": "input_text", "text": "what is in this image"}, + { + "type": "input_image", + "image_url": "data:image/png;base64," + "A" * 4000, + }, + ], + } + ] + json_count: Final = litellm.token_counter(model="gpt-4o-mini", text=json.dumps(image_input)) + response = _responses_api_response_without_usage() + iterator = _make_iterator( + sse_events=[ + _sse_event({"type": "response.output_text.delta", "delta": "it is a cat"}), + _sse_event({"type": "response.completed", "response": {}}), + ], + logging_obj=_logging_obj_stub(), + config=_mock_config_with_completed_response(response), + request_data={"input": image_input}, + ) + + async for _ in iterator: + pass + + usage = iterator.completed_response.response.usage + assert usage is not None + assert usage.input_tokens < json_count / 2 From 1484fd76001fae494c31e186c079c92868ddd0c5 Mon Sep 17 00:00:00 2001 From: kerry Date: Wed, 16 Sep 2026 01:13:26 +0000 Subject: [PATCH 049/133] fix(responses): keep the usage estimate best-effort when token counting raises Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- litellm/responses/streaming_iterator.py | 20 +++++++++++++++- .../responses/test_streaming_iterator.py | 24 +++++++++++++++++++ 2 files changed, 43 insertions(+), 1 deletion(-) diff --git a/litellm/responses/streaming_iterator.py b/litellm/responses/streaming_iterator.py index 6cb0331620f..6cf36e3e660 100644 --- a/litellm/responses/streaming_iterator.py +++ b/litellm/responses/streaming_iterator.py @@ -440,7 +440,7 @@ class BaseResponsesAPIStreamingIterator: and _response_obj is not None and _response_obj.usage is None ): - _response_obj.usage = _estimate_usage_from_text( + _response_obj.usage = _estimate_usage_safely( self.model or "", self.request_data.get("input"), self.request_data, @@ -1381,6 +1381,24 @@ def _estimate_usage_from_text( ) +def _estimate_usage_safely( + model: str, + request_input: object, + responses_api_request: Mapping[str, object], + generated_text: str, +) -> ResponseAPIUsage | None: + try: + return _estimate_usage_from_text( + model=model, + request_input=request_input, + responses_api_request=responses_api_request, + generated_text=generated_text, + ) + except Exception as e: + verbose_logger.debug("Could not estimate usage from stream text, billing $0: %s", e) + return None + + def _stamp_responses_usage_cost( response_obj: ResponsesAPIResponse | None, logging_obj: LiteLLMLoggingObj | None ) -> None: diff --git a/tests/test_litellm/responses/test_streaming_iterator.py b/tests/test_litellm/responses/test_streaming_iterator.py index c3721353d42..234e63ca3be 100644 --- a/tests/test_litellm/responses/test_streaming_iterator.py +++ b/tests/test_litellm/responses/test_streaming_iterator.py @@ -792,3 +792,27 @@ async def test_completed_event_without_usage_counts_multimodal_input_as_messages usage = iterator.completed_response.response.usage assert usage is not None assert usage.input_tokens < json_count / 2 + + +@pytest.mark.asyncio +async def test_completed_event_survives_a_failing_usage_estimate(): + """A raising token_counter must not break a stream that previously completed: + the estimate is best-effort and falls back to usage None.""" + response = _responses_api_response_without_usage() + iterator = _make_iterator( + sse_events=[ + _sse_event({"type": "response.output_text.delta", "delta": "hello world"}), + _sse_event({"type": "response.completed", "response": {}}), + ], + logging_obj=_logging_obj_stub(), + config=_mock_config_with_completed_response(response), + request_data={"input": "count these input tokens please"}, + ) + + with patch.object(litellm, "token_counter", side_effect=RuntimeError("tokenizer exploded")): + yielded: list = [] + async for chunk in iterator: + yielded.append(chunk) + + assert yielded + assert iterator.completed_response.response.usage is None From 4587dbeae98b798be6776e5b2a47d5eebea50705 Mon Sep 17 00:00:00 2001 From: Yuneng Jiang Date: Tue, 15 Sep 2026 18:14:12 -0700 Subject: [PATCH 050/133] feat(e2e): cache exact provider responses for 24 hours --- .github/workflows/test-provider-cache.yml | 63 +++ .../test_provider_cache.py | 409 ++++++++++++++++++ tests/e2e/PROVIDER_CACHE.md | 27 ++ tests/e2e/conftest.py | 6 +- tests/e2e/e2e_http.py | 41 +- .../e2e/llm_translation/test_messages_e2e.py | 3 +- .../llm_translation/test_together_ai_e2e.py | 1 + tests/e2e/provider_cache.py | 297 +++++++++++++ tests/e2e/provider_cache_redis.py | 154 +++++++ tests/e2e/provider_cache_routing.py | 23 + tests/e2e/provider_edge.py | 61 ++- tests/e2e/proxy_client.py | 8 +- 12 files changed, 1077 insertions(+), 16 deletions(-) create mode 100644 .github/workflows/test-provider-cache.yml create mode 100644 tests/code_coverage_tests/test_provider_cache.py create mode 100644 tests/e2e/PROVIDER_CACHE.md create mode 100644 tests/e2e/provider_cache.py create mode 100644 tests/e2e/provider_cache_redis.py create mode 100644 tests/e2e/provider_cache_routing.py diff --git a/.github/workflows/test-provider-cache.yml b/.github/workflows/test-provider-cache.yml new file mode 100644 index 00000000000..29b9d08a267 --- /dev/null +++ b/.github/workflows/test-provider-cache.yml @@ -0,0 +1,63 @@ +name: Provider cache contracts + +on: + pull_request: + paths: + - 'tests/e2e/**' + - 'tests/code_coverage_tests/test_provider_cache.py' + - 'tests/code_coverage_tests/test_provider_replay_harness.py' + - '.github/workflows/test-provider-cache.yml' + - 'pyproject.toml' + - 'uv.lock' + workflow_dispatch: + +permissions: + contents: read + +jobs: + provider-cache: + runs-on: ubuntu-latest + timeout-minutes: 20 + services: + redis: + image: redis@sha256:e2debfb7956fa12c7ddc79d7e645c8cf26b30c99a6e9161ea9bf4171e1668a5f + ports: + - 6379:6379 + options: >- + --health-cmd 'redis-cli ping' + --health-interval 5s + --health-timeout 3s + --health-retries 10 + env: + PYTHONDONTWRITEBYTECODE: '1' + PYTHONPATH: tests/e2e + E2E_CACHE_TEST_REDIS_URL: redis://127.0.0.1:6379/0 + E2E_PROVIDER_CACHE: '0' + E2E_FIXTURE_MODE: live + steps: + - uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 + with: + persist-credentials: false + - uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 + with: + python-version: '3.13' + - uses: ./.github/actions/setup-uv-with-retries + with: + version: '0.10.9' + - uses: ./.github/actions/cache-cargo-build + - name: Install locked dependencies + run: .github/scripts/uv_sync_with_retries.sh --frozen --group proxy-dev --group e2e-dev + - name: Verify cache and existing edge contracts + run: >- + uv run --no-sync pytest -c /dev/null -p no:cacheprovider + tests/code_coverage_tests/test_provider_cache.py + tests/code_coverage_tests/test_provider_replay_harness.py + tests/e2e/test_provider_edge.py + -q --junitxml=provider-cache-results.xml + - name: Save test results + if: always() + uses: actions/upload-artifact@4cec3d8aa04e39d1a68397de0c4cd6fb9dce8ec1 + with: + name: provider-cache-results + path: provider-cache-results.xml + if-no-files-found: warn diff --git a/tests/code_coverage_tests/test_provider_cache.py b/tests/code_coverage_tests/test_provider_cache.py new file mode 100644 index 00000000000..7edca8151af --- /dev/null +++ b/tests/code_coverage_tests/test_provider_cache.py @@ -0,0 +1,409 @@ +from __future__ import annotations + +import os +import shutil +import socket +import subprocess +import threading +import time +import uuid +from collections.abc import Generator +from concurrent.futures import ThreadPoolExecutor +from contextlib import contextmanager +from dataclasses import dataclass, replace +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer +from typing import Final + +import pytest +from e2e_http import NetworkError, PreparedForward, RawResponse, StreamChunk, StreamHead, forward, prepare_forward +from models import LiteLLMParamsBody +from provider_cache import CacheEdge, CacheHit, CaptureLease, exact_key, successful_response +from provider_cache_redis import PUBLISH, RedisCommands, RedisResponseStore, configured_cache, redis_store +from provider_cache_routing import LIVE_PROVIDER_REQUIRED, route_cache_model +from provider_edge import configured_cache_backend, start_provider_edge +from redis.exceptions import ConnectionError as RedisConnectionError + +SECRET: Final = b"synthetic-cache-hmac-key-for-tests" +BODY: Final = b'{"model":"test","messages":[{"role":"user","content":"hello"}]}' +SUCCESS: Final = b'{"id":"provider-fixed-id","choices":[{"message":{"content":"hello"},"finish_reason":"stop"}],"usage":{"prompt_tokens":1,"completion_tokens":1,"total_tokens":2}}' +HEADERS: Final = {"content-type": "application/json", "authorization": "Bearer synthetic-account-one"} + + +class Provider(ThreadingHTTPServer): + hits: tuple[tuple[str, bytes], ...] = () + response: bytes = SUCCESS + status: int = 200 + delay: float = 0 + stream: bool = False + truncated: bool = False + + +class Handler(BaseHTTPRequestHandler): + protocol_version = "HTTP/1.1" + + def do_POST(self) -> None: + server: Final = self.server + assert isinstance(server, Provider) + body: Final = self.rfile.read(int(self.headers.get("content-length", "0"))) + server.hits += ((self.path, body),) + time.sleep(server.delay) + self.send_response(server.status) + if server.stream: + self.send_header("content-type", "text/event-stream") + self.send_header("transfer-encoding", "chunked") + self.end_headers() + self.wfile.write(b"%x\r\n%s\r\n" % (len(server.response), server.response)) + if server.truncated: + self.close_connection = True + return + self.wfile.write(b"0\r\n\r\n") + return + self.send_header("content-type", "application/json") + self.send_header("content-length", str(len(server.response))) + self.end_headers() + self.wfile.write(server.response) + + def log_message(self, format: str, *args: object) -> None: + pass + + +@pytest.fixture +def provider() -> Generator[Provider, None, None]: + server: Final = Provider(("127.0.0.1", 0), Handler) + thread: Final = threading.Thread(target=server.serve_forever, daemon=True) + thread.start() + try: + yield server + finally: + server.shutdown() + server.server_close() + thread.join(timeout=5) + + +@pytest.fixture(scope="module") +def redis_url(tmp_path_factory: pytest.TempPathFactory) -> Generator[str, None, None]: + configured: Final = os.environ.get("E2E_CACHE_TEST_REDIS_URL") + if configured: + yield configured + return + binary: Final = shutil.which("redis-server") + assert binary is not None, "Set E2E_CACHE_TEST_REDIS_URL or install Redis for cache integration checks" + root: Final = tmp_path_factory.mktemp("provider-cache-redis") + with socket.socket() as probe: + probe.bind(("127.0.0.1", 0)) + port: Final = probe.getsockname()[1] + with (root / "redis.log").open("wb") as log: + process: Final = subprocess.Popen( + [binary, "--bind", "127.0.0.1", "--port", str(port), "--save", "", "--appendonly", "no", "--dir", str(root)], + stdout=log, stderr=subprocess.STDOUT, + ) + try: + deadline: Final = time.monotonic() + 5 + while True: + try: + with socket.create_connection(("127.0.0.1", port), timeout=0.1): + break + except OSError: + assert process.poll() is None and time.monotonic() < deadline + time.sleep(0.02) + yield f"redis://127.0.0.1:{port}/0" + finally: + process.terminate() + process.wait(timeout=5) + + +@pytest.fixture +def store(redis_url: str) -> RedisResponseStore: + return redis_store(redis_url, "test-" + uuid.uuid4().hex) + + +@contextmanager +def edge(cache: CacheEdge, provider: Provider) -> Generator[str, None, None]: + upstream: Final = f"http://127.0.0.1:{provider.server_port}" + running: Final = start_provider_edge(cache, mounts={"openai": upstream}) + try: + yield running.edge.api_base("openai") + "/v1/chat/completions" + finally: + running.shutdown() + + +def call(url: str, body: bytes = BODY, headers: dict[str, str] = HEADERS) -> RawResponse: + result: Final = forward("POST", url, headers=headers, body=body, timeout=5) + assert isinstance(result, RawResponse), result + return result + + +def test_success_is_reusable_across_fresh_edges(store: RedisResponseStore, provider: Provider) -> None: + with edge(CacheEdge(store, SECRET), provider) as url: + assert call(url).body == SUCCESS + assert call(url).body == SUCCESS + with edge(CacheEdge(store, SECRET), provider) as other: + assert call(other).body == SUCCESS + assert len(provider.hits) == 1 + + +@pytest.mark.parametrize("body", [BODY + b" ", BODY.replace(b"hello", b"Hello"), BODY.replace(b"test", b"test2")]) +def test_any_body_change_calls_live(store: RedisResponseStore, provider: Provider, body: bytes) -> None: + with edge(CacheEdge(store, SECRET), provider) as url: + call(url) + call(url, body) + call(url, body) + assert len(provider.hits) == 2 + + +@pytest.mark.parametrize("name,value", [("authorization", "Bearer another-account"), ("x-request-id", "one"), ("anthropic-version", "new")]) +def test_changed_header_cannot_reuse(store: RedisResponseStore, provider: Provider, name: str, value: str) -> None: + with edge(CacheEdge(store, SECRET), provider) as url: + call(url) + call(url, headers=HEADERS | {name: value}) + call(url + "?x=1") + assert len(provider.hits) == 3 + + +@pytest.mark.parametrize("status,response", [(429, b'{"error":"rate limited"}'), (500, b'failed'), (200, b'{"error":"bad"}'), (200, b'not json')]) +def test_failed_provider_responses_never_enter_cache(store: RedisResponseStore, provider: Provider, status: int, response: bytes) -> None: + provider.status = status + provider.response = response + with edge(CacheEdge(store, SECRET), provider) as url: + assert call(url).status_code == status + assert call(url).body == response + assert len(provider.hits) == 2 + + +def test_expiry_does_not_slide(store: RedisResponseStore, provider: Provider) -> None: + short: Final = replace(store, lifetime_ms=250) + with edge(CacheEdge(short, SECRET), provider) as url: + call(url) + call(url) + time.sleep(0.3) + call(url) + call(url) + assert len(provider.hits) == 2 + + +def test_concurrent_requests_publish_atomically(store: RedisResponseStore, provider: Provider) -> None: + provider.delay = 0.15 + with edge(CacheEdge(store, SECRET), provider) as url: + with ThreadPoolExecutor(max_workers=5) as executor: + replies: Final = tuple(executor.map(lambda _: call(url).body, range(5))) + assert replies == (SUCCESS,) * 5 + assert len(provider.hits) == 1 + + +@pytest.mark.parametrize("truncated", [False, True]) +def test_stream_completion_controls_publication(store: RedisResponseStore, provider: Provider, truncated: bool) -> None: + provider.stream = True + provider.truncated = truncated + provider.response = b'data: {"choices":[{"index":0,"delta":{"content":"hello"},"finish_reason":"stop"}]}\n\ndata: [DONE]\n\n' + with edge(CacheEdge(store, SECRET), provider) as url: + for _ in range(2): + result: Final = forward("POST", url, headers=HEADERS, body=BODY, timeout=5) + if truncated: + assert isinstance(result, NetworkError) + else: + assert isinstance(result, RawResponse) and result.body == provider.response + assert len(provider.hits) == (2 if truncated else 1) + + +def test_store_outage_preserves_provider_success(provider: Provider) -> None: + with socket.socket() as probe: + probe.bind(("127.0.0.1", 0)) + port: Final = probe.getsockname()[1] + unavailable: Final = redis_store(f"redis://127.0.0.1:{port}/0", "unavailable") + with edge(CacheEdge(unavailable, SECRET), provider) as url: + assert call(url).body == SUCCESS + assert call(url).body == SUCCESS + assert len(provider.hits) == 2 + + +def test_old_lease_cannot_overwrite_new_owner(store: RedisResponseStore) -> None: + short: Final = replace(store, lease_ms=50) + old: Final = short.lookup("key") + assert isinstance(old, CaptureLease) + time.sleep(0.08) + current: Final = short.lookup("key") + assert isinstance(current, CaptureLease) + assert not short.publish("key", old, b"old") + assert short.publish("key", current, b"new") + hit: Final = short.lookup("key") + assert isinstance(hit, CacheHit) and hit.payload == b"new" + + +def test_identity_preserves_values_and_never_contains_credentials() -> None: + variants: Final = (b'{}', b'{"a":null}', b'{"a":false}', b'{"a":0}', b'{"a":0.0}', b'{"a":"0"}', b' { }', None, b'') + keys: Final = tuple(exact_key(SECRET, "POST", "https://example.invalid/v1/chat/completions", HEADERS, body) for body in variants) + assert len(set(keys)) == len(variants) + assert all(len(key) == 64 and "synthetic-account" not in key for key in keys) + + +@pytest.mark.parametrize("payload", [b"corrupt response", '{"response":"{}","signature":"é"}'.encode()]) +def test_corrupt_entry_is_replaced_by_same_successful_request(store: RedisResponseStore, provider: Provider, payload: bytes) -> None: + upstream: Final = f"http://127.0.0.1:{provider.server_port}/v1/chat/completions" + prepared: Final = prepare_forward("POST", upstream, HEADERS, BODY) + assert isinstance(prepared, PreparedForward) + key: Final = exact_key(SECRET, "POST", upstream, prepared.headers, BODY) + lease: Final = store.lookup(key) + assert isinstance(lease, CaptureLease) + assert store.publish(key, lease, payload) + cache: Final = CacheEdge(store, SECRET) + for _ in range(2): + head = cache.forward("POST", upstream, HEADERS, BODY, 5) + assert isinstance(head, StreamHead) + assert b"".join(step.data for step in head.steps if isinstance(step, StreamChunk)) == SUCCESS + assert len(provider.hits) == 1 + assert dict(cache.counters.counts) == { + "corrupt": 1, "misses": 1, "upstream_attempts": 1, "writes": 1, "hits": 1, + } + + +@pytest.mark.parametrize("payload", [ + b'data: {}\n\ndata: [DONE]\n\n', + b'data: {"choices":[{"index":0,"delta":{}}]}\n\ndata: [DONE]\n\n', + b'data: {"choices":[{"index":0,"delta":{},"finish_reason":"stop"}]}\n\ndata: [DONE]', + b'data: {"error":{"message":"failed"}}\n\ndata: [DONE]\n\n', +]) +def test_malformed_success_stream_is_never_cached(store: RedisResponseStore, provider: Provider, payload: bytes) -> None: + provider.stream = True + provider.response = payload + with edge(CacheEdge(store, SECRET), provider) as url: + assert call(url).body == payload + assert call(url).body == payload + assert len(provider.hits) == 2 + + +def test_anthropic_stream_requires_start_finish_and_stop() -> None: + start: Final = b'data: {"type":"message_start","message":{}}\n\n' + finish: Final = b'data: {"type":"message_delta","delta":{"stop_reason":"end_turn"}}\n\n' + stop: Final = b'data: {"type":"message_stop"}\n\n' + url: Final = "https://example.invalid/v1/messages" + headers: Final = {"content-type": "text/event-stream"} + assert successful_response(url, 200, headers, start + finish + stop) + assert not successful_response(url, 200, headers, start + stop) + assert not successful_response(url, 200, headers, finish + stop) + assert not successful_response(url, 200, headers, start + finish) + + +@pytest.mark.parametrize("provider,suffix", [("openai", "/v1"), ("anthropic", "")]) +def test_normal_registration_routes_supported_providers(provider: str, suffix: str) -> None: + params: Final = LiteLLMParamsBody(model=f"{provider}/test", api_key="os.environ/SYNTHETIC_KEY", timeout=12) + routed: Final = route_cache_model(params, lambda mount: f"http://edge.invalid/{mount}", enabled=True) + assert routed.api_base == f"http://edge.invalid/{provider}{suffix}" + assert routed.model_dump(exclude={"api_base"}) == params.model_dump(exclude={"api_base"}) + assert params.api_base is None + + +@pytest.mark.parametrize("params", [ + LiteLLMParamsBody(model="bedrock/test"), + LiteLLMParamsBody(model="azure/test"), + LiteLLMParamsBody(model="openai/test", api_base="https://custom.invalid/v1"), + LiteLLMParamsBody(model="openai/test", api_base=""), + LiteLLMParamsBody(model="openai/test", litellm_credential_name="named-credential"), + LiteLLMParamsBody(model="openai/test", mock_response="synthetic"), +]) +def test_registration_preserves_unsupported_or_explicit_routes(params: LiteLLMParamsBody) -> None: + def unexpected_edge(mount: str) -> str: + pytest.fail(f"should not start edge for {mount}") + assert route_cache_model(params, unexpected_edge, enabled=True) is params + + +def test_rollback_and_live_only_policy_keep_direct_provider_route() -> None: + params: Final = LiteLLMParamsBody(model="openai/test") + assert route_cache_model(params, lambda _: "http://edge.invalid", enabled=False) is params + assert route_cache_model(params, lambda _: "http://edge.invalid", enabled=True, mode="realtime") is params + token: Final = LIVE_PROVIDER_REQUIRED.set(True) + try: + assert route_cache_model(params, lambda _: "http://edge.invalid", enabled=True) is params + finally: + LIVE_PROVIDER_REQUIRED.reset(token) + assert route_cache_model(params, lambda _: "http://edge.invalid", enabled=True).api_base == "http://edge.invalid/v1" + + +@dataclass(frozen=True) +class PublishOutage: + client: RedisCommands + + def eval(self, script: str, numkeys: int, *args: str | bytes | int) -> object: + if script == PUBLISH: + raise RedisConnectionError("synthetic publication outage") + return self.client.eval(script, numkeys, *args) + + +def test_write_outage_preserves_success_without_hidden_retry(store: RedisResponseStore, provider: Provider) -> None: + unavailable: Final = replace(store, client=PublishOutage(store.client)) + cache: Final = CacheEdge(unavailable, SECRET) + with edge(cache, provider) as url: + assert call(url).body == SUCCESS + assert call(url).body == SUCCESS + assert len(provider.hits) == 2 + assert dict(cache.counters.counts)["write_failures"] == 2 + with edge(CacheEdge(store, SECRET), provider) as url: + assert call(url).body == SUCCESS + assert call(url).body == SUCCESS + assert len(provider.hits) == 3 + + +def test_connection_failure_releases_capture_lease(store: RedisResponseStore) -> None: + with socket.socket() as unavailable: + unavailable.bind(("127.0.0.1", 0)) + url: Final = f"http://127.0.0.1:{unavailable.getsockname()[1]}/v1/chat/completions" + cache: Final = CacheEdge(store, SECRET) + assert isinstance(cache.forward("POST", url, HEADERS, BODY, 0.2), NetworkError) + prepared: Final = prepare_forward("POST", url, HEADERS, BODY) + assert isinstance(prepared, PreparedForward) + key: Final = exact_key(SECRET, "POST", url, prepared.headers, BODY) + slot: Final = store.lookup(key) + assert isinstance(slot, CaptureLease) + assert store.release(key, slot) + assert dict(cache.counters.counts)["rejected"] == 1 + + +def test_close_before_first_chunk_releases_lease(store: RedisResponseStore, provider: Provider) -> None: + url: Final = f"http://127.0.0.1:{provider.server_port}/v1/chat/completions" + cache: Final = CacheEdge(store, SECRET) + head: Final = cache.forward("POST", url, HEADERS, BODY, 5) + assert isinstance(head, StreamHead) + head.steps.close() + prepared: Final = prepare_forward("POST", url, HEADERS, BODY) + assert isinstance(prepared, PreparedForward) + key: Final = exact_key(SECRET, "POST", url, prepared.headers, BODY) + slot: Final = store.lookup(key) + assert isinstance(slot, CaptureLease) + assert store.release(key, slot) + + +def test_effective_account_change_cannot_reuse_cache( + store: RedisResponseStore, provider: Provider, monkeypatch: pytest.MonkeyPatch, tmp_path, +) -> None: + url: Final = f"http://127.0.0.1:{provider.server_port}/v1/chat/completions" + cache: Final = CacheEdge(store, SECRET) + for account in ("account-a", "account-b", "account-b"): + netrc = tmp_path / account + netrc.write_text(f"machine 127.0.0.1 login {account} password synthetic\n") + monkeypatch.setenv("NETRC", str(netrc)) + head = cache.forward("POST", url, HEADERS, BODY, 5) + assert isinstance(head, StreamHead) + assert b"".join(step.data for step in head.steps if isinstance(step, StreamChunk)) == SUCCESS + assert len(provider.hits) == 2 + assert dict(cache.counters.counts)["hits"] == 1 + + +def test_enabled_environment_reuses_store_across_fresh_backends( + redis_url: str, provider: Provider, monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setenv("E2E_PROVIDER_CACHE", "1") + monkeypatch.setenv("E2E_PROVIDER_CACHE_REDIS_URL", redis_url) + monkeypatch.setenv("E2E_PROVIDER_CACHE_HMAC_KEY", SECRET.decode()) + monkeypatch.setenv("E2E_PROVIDER_CACHE_NAMESPACE", "environment-" + uuid.uuid4().hex) + configured_cache.cache_clear() + try: + for _ in range(2): + backend = configured_cache_backend() + assert isinstance(backend, CacheEdge) + with edge(backend, provider) as url: + assert call(url).body == SUCCESS + configured_cache.cache_clear() + assert len(provider.hits) == 1 + monkeypatch.setenv("E2E_PROVIDER_CACHE", "0") + assert configured_cache_backend() is None + finally: + configured_cache.cache_clear() diff --git a/tests/e2e/PROVIDER_CACHE.md b/tests/e2e/PROVIDER_CACHE.md new file mode 100644 index 00000000000..280132d0940 --- /dev/null +++ b/tests/e2e/PROVIDER_CACHE.md @@ -0,0 +1,27 @@ +# Shared provider-response cache + +`E2E_PROVIDER_CACHE=1` enables automatic response reuse in the live E2E mode. Standard OpenAI and Anthropic model registrations use the provider edge. Existing custom API bases, named credentials, mocked models and realtime WebSocket deployments keep their existing routing. Other provider protocols remain live + +The edge caches complete successful POST responses for `/v1/chat/completions` and `/v1/messages`, including streams. Unsupported endpoints pass through. It matches the method, original URL, effective outbound headers (including authentication and HTTP-library defaults), body presence and exact body bytes using a full keyed digest. It sends the same prepared request used for matching. No prompts, random markers, JSON values or credentials are normalized away + +An eligible miss calls the provider. A complete successful response is stored immediately even if a later test assertion fails. Provider errors, malformed responses, truncated streams and cancelled captures are not stored. Cache reads, writes and lease failures fall through to normal provider behavior; they introduce no provider retry. An already-started response cannot be restarted after a delivery failure + +Recordings are shared across workers and builds through dedicated Redis, separate from the candidate's own cache. They expire 86,400 seconds after capture starts, based on Redis time. Reads never extend expiry. There is no scheduled recapture: the next miss calls the provider again. Bounded coordination reduces duplicate concurrent calls, but slow or failed captures may lead to extra live calls after the wait expires + +## Configuration + +The trusted runner receives: + +- `E2E_PROVIDER_CACHE`: `1` to enable, `0` to use the normal live path +- `E2E_PROVIDER_CACHE_REDIS_URL`: authenticated dedicated Redis URL +- `E2E_PROVIDER_CACHE_HMAC_KEY`: dedicated secret containing at least 32 bytes +- `E2E_PROVIDER_CACHE_NAMESPACE`: shared environment namespace, independent of build and candidate revision +- `E2E_PROVIDER_CACHE_METRICS_DIR`: optional per-process counter artifact directory + +Do not give cache credentials to candidate deployments. Counter artifacts contain no recorded payloads or credentials. Hits count shared-cache responses; upstream attempts count actual forwards from the edge. Existing application-cache observations still count requests arriving at the edge, including shared-cache hits + +Tests that require real provider timing, limits or state use `@pytest.mark.provider_live`. The marker keeps newly registered models on live routes without weakening their assertions. Ordinary assertion failures still fail E2E. The shared cache does not modify provider response IDs or make the proxy aware of replay + +## Qualification + +`tests/code_coverage_tests/test_provider_cache.py` exercises local HTTP providers and disposable real Redis. CI runs these checks with the existing provider-edge and replay harness tests. These component checks do not establish Buildkite deployment, full-suite cross-build reuse or a genuine 24-hour expiry observation; those require separate runtime evidence diff --git a/tests/e2e/conftest.py b/tests/e2e/conftest.py index b1a75d5f862..829c84910a9 100644 --- a/tests/e2e/conftest.py +++ b/tests/e2e/conftest.py @@ -22,7 +22,6 @@ from typing import Final import pytest import requests - from e2e_config import ( CONTROL_PLANE_BASE_URL, FIXTURE_DIR, @@ -41,6 +40,7 @@ from idp import Identity, Keycloak, keycloak_from_env from junit_properties import attach_result_properties from lifecycle import ProxyClientProvider, ResourceManager from models import TeamNewBody, UserNewBody, UserNewResponse +from provider_cache_routing import LIVE_PROVIDER_REQUIRED from provider_edge import replay_leftover_error from proxy_client import ProxyClient, build_proxy_client @@ -85,6 +85,7 @@ def jwt_identity(idp: Keycloak, resources: ResourceManager, proxy: ProxyClient) def pytest_configure(config: pytest.Config) -> None: + config.addinivalue_line("markers", "provider_live: requires actual provider timing, limits or state; bypass shared cache") config.addinivalue_line( "markers", "e2e: live test that requires a running proxy and real provider keys", @@ -192,11 +193,13 @@ def _proxy_fail_reason() -> str | None: return None +@pytest.hookimpl(tryfirst=True) def pytest_runtest_setup(item: pytest.Item) -> None: """Hard-fail `e2e`-marked tests unless a proxy answers its liveness probe. Unmarked tests (unit coverage of the harness) don't touch the proxy, so they run even when none is up. Never skip for a missing proxy. Replay mode needs the proxy too: only provider-bound traffic replays from the bundle.""" + LIVE_PROVIDER_REQUIRED.set(item.get_closest_marker("provider_live") is not None) if item.get_closest_marker("e2e") is None: return reason = _proxy_fail_reason() @@ -235,6 +238,7 @@ def pytest_runtest_teardown(item: pytest.Item) -> Generator[None, None, None]: yield so fixture finalizers replay their recorded calls first. Failed tests are left alone - their own failure already explains any unconsumed tail.""" result = yield + LIVE_PROVIDER_REQUIRED.set(False) if not item.stash.get(_CALL_PASSED, False): return result reason = replay_leftover_error( diff --git a/tests/e2e/e2e_http.py b/tests/e2e/e2e_http.py index 1992f419823..4184b6cbefc 100644 --- a/tests/e2e/e2e_http.py +++ b/tests/e2e/e2e_http.py @@ -853,6 +853,7 @@ def _stream_steps(resp: requests.Response) -> Generator[StreamStep, None, None]: the chunks already delivered are exactly what makes a mid-stream failure different from a request that never streamed at all.""" try: + yield StreamChunk(b"") for piece in cast("Iterator[bytes]", resp.iter_content(chunk_size=None)): if piece: yield StreamChunk(data=piece) @@ -862,6 +863,44 @@ def _stream_steps(resp: requests.Response) -> Generator[StreamStep, None, None]: resp.close() +def primed_steps(steps: Generator[StreamStep, None, None]) -> Generator[StreamStep, None, None]: + first: Final = next(steps) + assert isinstance(first, StreamChunk) and first.data == b"" + return steps + + +@dataclass(frozen=True, slots=True, repr=False) +class PreparedForward: + request: requests.PreparedRequest + url: str + headers: dict[str, str] + + +def prepare_forward( + method: str, url: str, headers: dict[str, str], body: bytes | None, +) -> PreparedForward | NetworkError: + try: + with requests.Session() as session: + request: Final = session.prepare_request(requests.Request(method, url, headers=headers, data=body)) + except requests.RequestException as exc: + return NetworkError(message=str(exc)) + assert request.url is not None + return PreparedForward(request, request.url, dict(request.headers)) + + +def forward_prepared_stream(prepared: PreparedForward, timeout: float) -> StreamHead | NetworkError: + try: + with requests.Session() as session: + settings: Final = session.merge_environment_settings(prepared.url, {}, True, None, None) + resp: Final = session.send(prepared.request, timeout=timeout, allow_redirects=False, **settings) + except requests.RequestException as exc: + return NetworkError(message=str(exc)) + return StreamHead( + resp.status_code, {name.lower(): value for name, value in resp.headers.items()}, + primed_steps(_stream_steps(resp)), + ) + + def open_stream(url: URL, *, headers: BaseModel, json: BaseModel, timeout: float = 60.0) -> StreamHead | NetworkError: """POST a streaming request and return the moment its response head arrives, leaving the body unread behind ``StreamHead.steps``. For a test that must keep @@ -907,5 +946,5 @@ def forward_stream( return StreamHead( status_code=resp.status_code, headers={name.lower(): value for name, value in resp.headers.items()}, - steps=_stream_steps(resp), + steps=primed_steps(_stream_steps(resp)), ) diff --git a/tests/e2e/llm_translation/test_messages_e2e.py b/tests/e2e/llm_translation/test_messages_e2e.py index ca58c30d40c..44c416a3e78 100644 --- a/tests/e2e/llm_translation/test_messages_e2e.py +++ b/tests/e2e/llm_translation/test_messages_e2e.py @@ -24,10 +24,10 @@ from models import ( AnthropicAssistantTurn, AnthropicContentBlock, AnthropicCustomTool, + AnthropicMessagesBody, AnthropicToolChoice, AnthropicToolResultBlock, AnthropicToolResultTurn, - AnthropicMessagesBody, ChatMessage, JsonSchemaProperty, LiteLLMParamsBody, @@ -165,6 +165,7 @@ class TestAnthropicMessages: ) @pytest.mark.covers("llm.messages.anthropic.basic.stream.works") + @pytest.mark.provider_live def test_messages_streams_completion( self, endpoints_client: EndpointsClient, resources: ResourceManager ) -> None: diff --git a/tests/e2e/llm_translation/test_together_ai_e2e.py b/tests/e2e/llm_translation/test_together_ai_e2e.py index 31e74c22e17..8dd7e7c1a31 100644 --- a/tests/e2e/llm_translation/test_together_ai_e2e.py +++ b/tests/e2e/llm_translation/test_together_ai_e2e.py @@ -744,6 +744,7 @@ class TestTogetherMessages: assert "22" in text, f"the model never saw the tool result: {response.content}" @pytest.mark.covers("llm.messages.together_ai.basic.stream.works") + @pytest.mark.provider_live def test_streams_text_deltas( self, client: PassthroughClient, resources: ResourceManager, reasoning_tool_backend: str ) -> None: diff --git a/tests/e2e/provider_cache.py b/tests/e2e/provider_cache.py new file mode 100644 index 00000000000..50b336f5263 --- /dev/null +++ b/tests/e2e/provider_cache.py @@ -0,0 +1,297 @@ +from __future__ import annotations + +import base64 +import hashlib +import hmac +import io +import threading +import time +from collections.abc import Callable, Generator, Mapping +from contextlib import closing +from dataclasses import dataclass, field +from typing import Final, Literal, Protocol +from urllib.parse import urlsplit + +from e2e_http import ( + NetworkError, + StreamChunk, + StreamHead, + StreamStep, + StreamTruncation, + forward_prepared_stream, + forward_stream, + prepare_forward, + primed_steps, +) +from pydantic import BaseModel, ConfigDict, JsonValue, TypeAdapter, ValidationError + +LIFETIME_SECONDS: Final = 86_400 +MAX_REQUEST_BYTES: Final = 256 * 1024 +MAX_RESPONSE_BYTES: Final = 8 * 1024 * 1024 +JSON_VALUE: Final[TypeAdapter[JsonValue]] = TypeAdapter(JsonValue) + + +@dataclass(frozen=True, slots=True) +class CacheHit: + payload: bytes + valid_until: float + + +@dataclass(frozen=True, slots=True) +class CaptureLease: + token: str + captured_at_ms: int + expires_at_ms: int + + +@dataclass(frozen=True, slots=True) +class CacheBusy: + pass + + +@dataclass(frozen=True, slots=True) +class CacheUnavailable: + pass + + +type CacheLookup = CacheHit | CaptureLease | CacheBusy | CacheUnavailable + + +class ResponseStore(Protocol): + def lookup(self, key: str) -> CacheLookup: ... + + def publish(self, key: str, lease: CaptureLease, payload: bytes) -> bool: ... + + def release(self, key: str, lease: CaptureLease) -> bool: ... + + def discard(self, key: str, payload: bytes) -> bool: ... + + +class CachedResponse(BaseModel): + model_config = ConfigDict(frozen=True, extra="forbid", strict=True) + format_version: Literal[1] = 1 + request_key: str + status_code: int + headers: dict[str, str] + chunks: tuple[str, ...] + + +class SignedResponse(BaseModel): + model_config = ConfigDict(frozen=True, extra="forbid", strict=True) + response: str + signature: str + + +def exact_key(secret: bytes, method: str, url: str, headers: Mapping[str, str], body: bytes | None) -> str: + fields: Final = ( + b"provider-cache-exact-v1", method.encode(), url.encode(), + *(part.encode() for pair in sorted(headers.items()) for part in pair), + b"no-body" if body is None else b"body", b"" if body is None else body, + ) + encoded: Final = b"".join(len(part).to_bytes(8, "big") + part for part in fields) + return hmac.new(secret, encoded, hashlib.sha256).hexdigest() + + +def cacheable_endpoint(method: str, url: str, body: bytes | None) -> bool: + return ( + method == "POST" + and urlsplit(url).path in {"/v1/chat/completions", "/v1/messages"} + and body is not None + and len(body) <= MAX_REQUEST_BYTES + ) + + +def successful_response(url: str, status: int, headers: Mapping[str, str], body: bytes) -> bool: + if not 200 <= status < 300 or len(body) > MAX_RESPONSE_BYTES: + return False + if any(name.lower() == "set-cookie" for name in headers): + return False + streaming: Final = "text/event-stream" in headers.get("content-type", "").lower() + if streaming: + try: + text: Final = body.decode("utf-8").replace("\r\n", "\n") + if not text.endswith("\n\n"): + return False + events: Final = tuple( + "\n".join(line[5:].removeprefix(" ") for line in event.split("\n") if line.startswith("data:")) + for event in text.split("\n\n") if any(line.startswith("data:") for line in event.split("\n")) + ) + values: Final = tuple(JSON_VALUE.validate_json(event) for event in events if event != "[DONE]") + except (UnicodeDecodeError, ValidationError): + return False + if not values or any(not isinstance(value, dict) or "error" in value or value.get("type") == "error" for value in values): + return False + if urlsplit(url).path == "/v1/chat/completions": + return events[-1] == "[DONE]" and "[DONE]" not in events[:-1] and complete_chat_stream(values) + return ( + "[DONE]" not in events + and isinstance(values[0], dict) and values[0].get("type") == "message_start" + and isinstance(values[-1], dict) and values[-1].get("type") == "message_stop" + and any( + isinstance(value, dict) and value.get("type") == "message_delta" + and isinstance(delta := value.get("delta"), dict) and isinstance(delta.get("stop_reason"), str) + for value in values + ) + ) + try: + value: Final = JSON_VALUE.validate_json(body) + except ValidationError: + return False + if not isinstance(value, dict) or "error" in value: + return False + if urlsplit(url).path == "/v1/messages": + return value.get("type") == "message" and isinstance(value.get("content"), list) and isinstance(value.get("stop_reason"), str) + choices: Final = value.get("choices") + return isinstance(choices, list) and bool(choices) and all( + isinstance(choice, dict) and isinstance(choice.get("message"), dict) and isinstance(choice.get("finish_reason"), str) + for choice in choices + ) + + +def complete_chat_stream(values: tuple[JsonValue, ...]) -> bool: + if any(not isinstance(value, dict) or not isinstance(value.get("choices"), list) for value in values): + return False + choices: Final = tuple( + choice for value in values if isinstance(value, dict) + if isinstance(items := value.get("choices"), list) for choice in items + ) + if not choices or any( + not isinstance(choice, dict) or type(choice.get("index")) is not int + or not isinstance(choice.get("delta"), dict) + for choice in choices + ): + return False + indices: Final = frozenset(choice["index"] for choice in choices if isinstance(choice, dict)) + return all( + isinstance(tuple(choice for choice in choices if isinstance(choice, dict) and choice["index"] == index)[-1].get("finish_reason"), str) + for index in indices + ) + + +def encode_response(secret: bytes, response: CachedResponse) -> bytes: + raw: Final = response.model_dump_json() + return SignedResponse(response=raw, signature=hmac.new(secret, raw.encode(), hashlib.sha256).hexdigest()).model_dump_json().encode() + + +def decode_response(secret: bytes, key: str, payload: bytes, url: str) -> CachedResponse | None: + if len(payload) > 2 * MAX_RESPONSE_BYTES: + return None + try: + signed: Final = SignedResponse.model_validate_json(payload) + if not hmac.compare_digest(signed.signature.encode(), hmac.new(secret, signed.response.encode(), hashlib.sha256).hexdigest().encode()): + return None + response: Final = CachedResponse.model_validate_json(signed.response) + chunks: Final = tuple(base64.b64decode(chunk, validate=True) for chunk in response.chunks) + except (ValidationError, ValueError): + return None + if response.request_key != key or not successful_response(url, response.status_code, response.headers, b"".join(chunks)): + return None + return response + + +@dataclass(slots=True) +class CacheCounters: + counts: tuple[tuple[str, int], ...] = () + lock: threading.Lock = field(default_factory=threading.Lock) + + def increment(self, name: str) -> None: + with self.lock: + current: Final = dict(self.counts) + self.counts = tuple((current | {name: current.get(name, 0) + 1}).items()) + + +@dataclass(slots=True) +class ResponseCapture: + buffer: io.BytesIO = field(default_factory=io.BytesIO) + size: int = 0 + eligible: bool = True + + def observe(self, step: StreamStep) -> None: + if not self.eligible: + return + if isinstance(step, StreamTruncation) or self.size + len(step.data) + 8 > MAX_RESPONSE_BYTES: + self.eligible = False + self.buffer.close() + return + self.buffer.write(len(step.data).to_bytes(8, "big")) + self.buffer.write(step.data) + self.size += len(step.data) + 8 + + def chunks(self) -> tuple[bytes, ...]: + self.buffer.seek(0) + return tuple(self.buffer.read(int.from_bytes(size, "big")) for size in iter(lambda: self.buffer.read(8), b"")) + + +def response_steps(response: CachedResponse) -> Generator[StreamStep, None, None]: + for chunk in response.chunks: + yield StreamChunk(base64.b64decode(chunk, validate=True)) + + +@dataclass(frozen=True, slots=True) +class CacheEdge: + store: ResponseStore + secret: bytes = field(repr=False) + counters: CacheCounters = field(default_factory=CacheCounters) + wait_seconds: float = 2.0 + clock: Callable[[], float] = time.monotonic + sleep: Callable[[float], None] = time.sleep + + def lookup(self, key: str) -> CacheLookup: + deadline: Final = self.clock() + self.wait_seconds + while isinstance(result := self.store.lookup(key), CacheBusy) and self.clock() < deadline: + self.sleep(min(0.05, max(0, deadline - self.clock()))) + return result + + def forward(self, method: str, url: str, headers: dict[str, str], body: bytes | None, timeout: float) -> StreamHead | NetworkError: + if not cacheable_endpoint(method, url, body): + self.counters.increment("bypass") + self.counters.increment("upstream_attempts") + return forward_stream(method, url, headers=headers, body=body, timeout=timeout) + prepared: Final = prepare_forward(method, url, headers, body) + if isinstance(prepared, NetworkError): + self.counters.increment("rejected") + return prepared + key: Final = exact_key(self.secret, method, url, prepared.headers, body) + found: Final = self.lookup(key) + if isinstance(found, CacheHit): + response: Final = decode_response(self.secret, key, found.payload, url) + if response is not None and self.clock() < found.valid_until: + self.counters.increment("hits") + return StreamHead(response.status_code, response.headers, response_steps(response)) + self.counters.increment("corrupt" if response is None else "expired") + self.store.discard(key, found.payload) + capture_slot: Final = self.lookup(key) if isinstance(found, CacheHit) else found + self.counters.increment("misses") + if isinstance(capture_slot, CacheUnavailable): + self.counters.increment("cache_errors") + self.counters.increment("upstream_attempts") + head: Final = forward_prepared_stream(prepared, timeout) + if not isinstance(capture_slot, CaptureLease): + return head + if isinstance(head, NetworkError): + self.store.release(key, capture_slot) + self.counters.increment("rejected") + return head + return StreamHead(head.status_code, head.headers, primed_steps(self.capture(key, capture_slot, url, head))) + + def capture(self, key: str, lease: CaptureLease, url: str, head: StreamHead) -> Generator[StreamStep, None, None]: + capture: Final = ResponseCapture() + try: + with closing(head.steps): + yield StreamChunk(b"") + for step in head.steps: + yield step + capture.observe(step) + chunks: Final = capture.chunks() if capture.eligible else () + if not capture.eligible or not successful_response(url, head.status_code, head.headers, b"".join(chunks)): + self.counters.increment("rejected") + return + response: Final = CachedResponse( + request_key=key, status_code=head.status_code, headers=head.headers, + chunks=tuple(base64.b64encode(chunk).decode("ascii") for chunk in chunks), + ) + published: Final = self.store.publish(key, lease, encode_response(self.secret, response)) + self.counters.increment("writes" if published else "write_failures") + finally: + self.store.release(key, lease) + capture.buffer.close() diff --git a/tests/e2e/provider_cache_redis.py b/tests/e2e/provider_cache_redis.py new file mode 100644 index 00000000000..be4e31b2c49 --- /dev/null +++ b/tests/e2e/provider_cache_redis.py @@ -0,0 +1,154 @@ +from __future__ import annotations + +import atexit +import functools +import json +import logging +import os +import re +import time +import uuid +from dataclasses import dataclass +from pathlib import Path +from typing import Final, Protocol, cast + +from provider_cache import LIFETIME_SECONDS, CacheBusy, CacheEdge, CacheHit, CacheLookup, CacheUnavailable, CaptureLease +from pydantic import TypeAdapter, ValidationError +from redis import Redis +from redis.exceptions import RedisError + +REDIS_ARRAY: Final[TypeAdapter[list[bytes]]] = TypeAdapter(list[bytes]) + +LOOKUP: Final = """ +local clock = redis.call('TIME') +local now = clock[1] * 1000 + math.floor(clock[2] / 1000) +local row = redis.call('HMGET', KEYS[1], 'captured', 'expires', 'payload') +if row[3] then + local captured = tonumber(row[1]) + local expires = tonumber(row[2]) + if captured and expires and captured <= now and expires > now + and expires - captured == tonumber(ARGV[2]) then + return {'hit', row[3], tostring(expires - now)} + end + redis.call('DEL', KEYS[1]) +end +if redis.call('SET', KEYS[2], ARGV[1], 'NX', 'PX', ARGV[3]) then + return {'lease', tostring(now), tostring(now + tonumber(ARGV[2]))} +end +return {'busy'} +""" + +PUBLISH: Final = """ +if redis.call('GET', KEYS[2]) ~= ARGV[1] then return 0 end +local clock = redis.call('TIME') +local now = clock[1] * 1000 + math.floor(clock[2] / 1000) +local captured = tonumber(ARGV[2]) +local expires = tonumber(ARGV[3]) +if captured > now or expires <= now or expires - captured ~= tonumber(ARGV[5]) then return 0 end +if redis.call('EXISTS', KEYS[1]) == 1 then return 0 end +redis.call('HSET', KEYS[1], 'captured', ARGV[2], 'expires', ARGV[3], 'payload', ARGV[4]) +redis.call('PEXPIREAT', KEYS[1], expires) +redis.call('DEL', KEYS[2]) +return 1 +""" + +RELEASE: Final = """ +if redis.call('GET', KEYS[1]) ~= ARGV[1] then return 0 end +return redis.call('DEL', KEYS[1]) +""" + +DISCARD: Final = """ +if redis.call('HGET', KEYS[1], 'payload') ~= ARGV[1] then return 0 end +return redis.call('DEL', KEYS[1]) +""" + + +class RedisCommands(Protocol): + def eval(self, script: str, numkeys: int, *args: str | bytes | int) -> object: ... + + +@dataclass(frozen=True, slots=True) +class RedisResponseStore: + client: RedisCommands + namespace: str + lifetime_ms: int = LIFETIME_SECONDS * 1000 + lease_ms: int = 120_000 + + def keys(self, key: str) -> tuple[str, str]: + prefix: Final = f"e2e-provider-cache:v1:{self.namespace}:{{{key}}}" + return prefix + ":response", prefix + ":lease" + + def lookup(self, key: str) -> CacheLookup: + token: Final = uuid.uuid4().hex + started: Final = time.monotonic() + try: + result: Final = self.client.eval(LOOKUP, 2, *self.keys(key), token, self.lifetime_ms, self.lease_ms) + except (RedisError, OSError): + return CacheUnavailable() + try: + parts: Final = tuple(REDIS_ARRAY.validate_python(result, strict=True)) + except ValidationError: + return CacheUnavailable() + if len(parts) == 3 and parts[0] == b"hit" and parts[2].isdigit(): + return CacheHit(parts[1], started + int(parts[2]) / 1000) + if len(parts) == 3 and parts[0] == b"lease" and parts[1].isdigit() and parts[2].isdigit(): + return CaptureLease(token, int(parts[1]), int(parts[2])) + if parts == (b"busy",): + return CacheBusy() + return CacheUnavailable() + + def publish(self, key: str, lease: CaptureLease, payload: bytes) -> bool: + try: + result: Final = self.client.eval( + PUBLISH, 2, *self.keys(key), lease.token, lease.captured_at_ms, lease.expires_at_ms, payload, self.lifetime_ms, + ) + except (RedisError, OSError): + return False + return result == 1 + + def release(self, key: str, lease: CaptureLease) -> bool: + try: + result: Final = self.client.eval(RELEASE, 1, self.keys(key)[1], lease.token) + except (RedisError, OSError): + return False + return result == 1 + + def discard(self, key: str, payload: bytes) -> bool: + try: + result: Final = self.client.eval(DISCARD, 1, self.keys(key)[0], payload) + except (RedisError, OSError): + return False + return result == 1 + + +def redis_store(url: str, namespace: str) -> RedisResponseStore: + client: Final = Redis.from_url(url, socket_timeout=0.25, socket_connect_timeout=0.25, decode_responses=False) + return RedisResponseStore(cast(RedisCommands, client), namespace) + + +def write_metrics(cache: CacheEdge) -> None: + report: Final = json.dumps({"provider_cache": dict(cache.counters.counts)}) + directory: Final = os.environ.get("E2E_PROVIDER_CACHE_METRICS_DIR") + if directory: + try: + root: Final = Path(directory) + root.mkdir(parents=True, exist_ok=True) + (root / f"{os.getpid()}.json").write_text(report + "\n") + except OSError: + logging.getLogger(__name__).warning("provider cache metrics artifact unavailable") + logging.getLogger(__name__).info("%s", report) + + +@functools.lru_cache(maxsize=1) +def configured_cache() -> CacheEdge | None: + if os.environ.get("E2E_PROVIDER_CACHE", "0") == "0": + return None + if os.environ.get("E2E_PROVIDER_CACHE") != "1": + raise ValueError("E2E_PROVIDER_CACHE must be 0 or 1") + secret: Final = os.environ.get("E2E_PROVIDER_CACHE_HMAC_KEY", "").encode() + namespace: Final = os.environ.get("E2E_PROVIDER_CACHE_NAMESPACE", "") + if len(secret) < 32 or re.fullmatch(r"[a-zA-Z0-9_-]{1,64}", namespace) is None: + raise ValueError("provider cache requires a dedicated key and namespace") + cache: Final = CacheEdge(redis_store(os.environ["E2E_PROVIDER_CACHE_REDIS_URL"], namespace), secret) + atexit.register(write_metrics, cache) + return cache diff --git a/tests/e2e/provider_cache_routing.py b/tests/e2e/provider_cache_routing.py new file mode 100644 index 00000000000..24599b5a313 --- /dev/null +++ b/tests/e2e/provider_cache_routing.py @@ -0,0 +1,23 @@ +from __future__ import annotations + +from collections.abc import Callable +from contextvars import ContextVar +from typing import Final + +from models import LiteLLMParamsBody, ModelMode + +LIVE_PROVIDER_REQUIRED: Final[ContextVar[bool]] = ContextVar("live_provider_required", default=False) + + +def route_cache_model( + params: LiteLLMParamsBody, base_for: Callable[[str], str | None], *, enabled: bool, mode: ModelMode | None = None, +) -> LiteLLMParamsBody: + if not enabled or mode == "realtime" or LIVE_PROVIDER_REQUIRED.get() or params.api_base is not None or params.mock_response is not None: + return params + provider: Final = params.model.partition("/")[0] + if provider not in {"openai", "anthropic"} or params.litellm_credential_name is not None: + return params + base: Final = base_for(provider) + if base is None: + return params + return params.model_copy(update={"api_base": f"{base}/v1" if provider == "openai" else base}) diff --git a/tests/e2e/provider_edge.py b/tests/e2e/provider_edge.py index de36895ebb6..49d574ee957 100644 --- a/tests/e2e/provider_edge.py +++ b/tests/e2e/provider_edge.py @@ -42,6 +42,7 @@ import base64 import difflib import functools import hashlib +import os import re import threading from collections import deque @@ -93,6 +94,8 @@ from fixture_mode import ( parse_fixture_mode, ) from fixture_profile import IneligibleRequest, MatchProfile, match_profile, strict_identity +from provider_cache import CacheEdge +from provider_cache_routing import LIVE_PROVIDER_REQUIRED from pydantic import JsonValue, TypeAdapter EDGE_MOUNTS: Final[Mapping[str, str]] = MappingProxyType( @@ -506,7 +509,7 @@ class LiveEdge: pass -type EdgeBackend = RecordEdge | ReplayEdge | LiveEdge +type EdgeBackend = RecordEdge | ReplayEdge | LiveEdge | CacheEdge @dataclass(slots=True) @@ -750,12 +753,16 @@ def _handle_record( def _handle_live( - method: str, url: str, headers: Mapping[str, str], body: bytes | None, timeout: float + method: str, url: str, headers: Mapping[str, str], body: bytes | None, timeout: float, + cache: CacheEdge | None = None, ) -> EdgeOutcome: forwarded: Final = { name: value for name, value in headers.items() if name.lower() not in _REQUEST_DROPPED_HEADERS } - head: Final = forward_stream(method, url, headers=forwarded, body=body, timeout=timeout) + head: Final = ( + forward_stream(method, url, headers=forwarded, body=body, timeout=timeout) + if cache is None else cache.forward(method, url, forwarded, body, timeout) + ) match head: case NetworkError(message=message): return _recorded_outcome(_network_error_response(message)) @@ -821,6 +828,10 @@ def handle_edge_request( else edge_request(method, split.path, split.query, body, _header_value(headers, "content-type")) ) match backend: + case CacheEdge(): + return _handle_live( + method, _upstream_url(upstream_base, upstream_path, split.query), headers, body, timeout, backend, + ) case LiveEdge(): return _handle_live( method, _upstream_url(upstream_base, upstream_path, split.query), headers, body, timeout @@ -871,11 +882,17 @@ class _EdgeHandler(BaseHTTPRequestHandler): or isinstance(edge_server.backend, ReplayEdge) and edge_server.backend.source.bundle.manifest.match_profile == "stateless_v1" ) - if strict and len({name.lower() for name in self.headers.keys()}) != len(self.headers): + if strict and len({name.lower() for name in self.headers}) != len(self.headers): self._write_reply(_text_reply(REPLAY_MISS_STATUS, "stateless_v1 eligibility error: duplicate headers")) return + duplicate_headers: Final = len({name.lower() for name in self.headers}) != len(self.headers) + selected_backend: Final = ( + LiveEdge() if isinstance(edge_server.backend, CacheEdge) and duplicate_headers else edge_server.backend + ) + if isinstance(edge_server.backend, CacheEdge) and duplicate_headers: + edge_server.backend.counters.increment("duplicate_header_bypass") outcome: Final = handle_edge_request( - edge_server.backend, + selected_backend, edge_server.mounts, self.command, self.path, @@ -908,12 +925,12 @@ class _EdgeHandler(BaseHTTPRequestHandler): shuts down write-side first: the proxy sees a graceful close mid-message, which is the incomplete chunked read a provider hanging up produces, and not the reset that could discard the chunks already in flight.""" - self.send_response(stream.status_code) - for name, value in stream.headers.items(): - self.send_header(name, value) - self.send_header("transfer-encoding", "chunked") - self.end_headers() with closing(stream.steps) as steps: + self.send_response(stream.status_code) + for name, value in stream.headers.items(): + self.send_header(name, value) + self.send_header("transfer-encoding", "chunked") + self.end_headers() for step in steps: match step: case StreamChunk(data=data): @@ -923,7 +940,7 @@ class _EdgeHandler(BaseHTTPRequestHandler): return case _: assert_never(step) - self.wfile.write(b"0\r\n\r\n") + self.wfile.write(b"0\r\n\r\n") def log_message(self, format: str, *args: object) -> None: """Silence the per-request stderr line BaseHTTPRequestHandler emits.""" @@ -1056,6 +1073,8 @@ def provider_edge_api_base( case InvalidFixtureMode(value=value): raise ValueError(f"E2E_FIXTURE_MODE={value!r} is not one of {', '.join(FIXTURE_MODES)}") case "live": + if configured_cache_backend() is not None: + return _shared_cache_edge(bind_host, advertise_host, forward_timeout).api_base(mount) return None case "record" | "replay": if mount not in EDGE_MOUNTS: @@ -1073,7 +1092,7 @@ def _observed_backend(mode_raw: str, bundle_dir: Path) -> EdgeBackend: case InvalidFixtureMode(value=value): raise ValueError(f"E2E_FIXTURE_MODE={value!r} is not one of {', '.join(FIXTURE_MODES)}") case "live": - return LiveEdge() + return configured_cache_backend() or LiveEdge() case "record": return RecordEdge(_shared_recorder(bundle_dir, match_profile()), threading.Lock()) case "replay": @@ -1082,6 +1101,24 @@ def _observed_backend(mode_raw: str, bundle_dir: Path) -> EdgeBackend: assert_never(mode) +def configured_cache_backend() -> CacheEdge | None: + if LIVE_PROVIDER_REQUIRED.get() or os.environ.get("E2E_PROVIDER_CACHE", "0") == "0": + return None + from provider_cache_redis import configured_cache + + return configured_cache() + + +@functools.lru_cache(maxsize=8) +def _shared_cache_edge(bind_host: str, advertise_host: str, forward_timeout: float) -> ProviderEdge: + backend: Final = configured_cache_backend() + assert backend is not None + return start_provider_edge( + backend, mounts=EDGE_MOUNTS, bind_host=bind_host, + advertise_host=advertise_host, forward_timeout=forward_timeout, + ).edge + + @contextmanager def observed_provider_edge( observation: ProviderRequestObservation, diff --git a/tests/e2e/proxy_client.py b/tests/e2e/proxy_client.py index 3f7fba5ffec..f8ed8843461 100644 --- a/tests/e2e/proxy_client.py +++ b/tests/e2e/proxy_client.py @@ -8,6 +8,7 @@ ProxyClient's key/customer methods for cleanup. Read-backs are eventually consis from __future__ import annotations +import os import time import warnings from collections.abc import Callable, Mapping @@ -26,6 +27,7 @@ from e2e_config import ( PROXY_REPLICA_URLS, REQUEST_TIMEOUT, SLOW_PROVIDER_TIMEOUT_SECONDS, + provider_edge_base, settle_propagation, ) from e2e_http import ( @@ -93,6 +95,7 @@ from models import ( UserDeleteBody, UserDeleteResponse, ) +from provider_cache_routing import route_cache_model from pydantic import BaseModel from transport import HttpTransport, SplitTransport, Transport, is_control_plane_path @@ -645,7 +648,10 @@ class ProxyClient: self.transport.post( "/model/new", headers=self.management_headers(), - json=body, + json=body.model_copy(update={"litellm_params": route_cache_model( + body.litellm_params, provider_edge_base, + enabled=os.environ.get("E2E_PROVIDER_CACHE", "0") == "1", mode=body.model_info.mode, + )}), response_type=ModelNewResponse, ) ).model_id From f2145550680b85ed7f9b67826467125077cc8e8c Mon Sep 17 00:00:00 2001 From: Yuneng Jiang Date: Tue, 15 Sep 2026 18:21:43 -0700 Subject: [PATCH 051/133] fix(e2e): expect models filters to persist after reload --- .../tests/internal-user/modelsByTeam.spec.ts | 21 ++++++++++++++----- 1 file changed, 16 insertions(+), 5 deletions(-) diff --git a/tests/e2e/ui/tests/internal-user/modelsByTeam.spec.ts b/tests/e2e/ui/tests/internal-user/modelsByTeam.spec.ts index 5e2c80b5845..736c352e3ee 100644 --- a/tests/e2e/ui/tests/internal-user/modelsByTeam.spec.ts +++ b/tests/e2e/ui/tests/internal-user/modelsByTeam.spec.ts @@ -7,6 +7,7 @@ import { import { E2E_TEAM_CRUD_ALIAS, E2E_TEAM_ORG_ALIAS, + E2E_TEAM_ORG_ID, INTERNAL_USER_STORAGE_PATH, } from "../../constants"; import { Page } from "../../fixtures/pages"; @@ -179,18 +180,28 @@ test.describe("Models and Endpoints for an internal user", () => { `switching to ${ALL_MODELS_VIEW} leaves the table populated rather than blanking it`, ).toHaveCount(1, { timeout: 15_000 }); + await expect(page).toHaveURL((url) => + url.searchParams.get("filter_team") === E2E_TEAM_ORG_ID && + url.searchParams.get("view_mode") === "all", + ); await page.reload(); await expect( teamSelector(page), - "the team selection is not persisted across a reload, so the table returns to the personal view", - ).toContainText(PERSONAL_TEAM, { timeout: 15_000 }); + "the selected team is restored from the URL after a reload", + ).toContainText(E2E_TEAM_ORG_ALIAS, { timeout: 15_000 }); await expect( viewSelector(page), - "the view selection is not persisted across a reload either", - ).toContainText(CURRENT_TEAM_VIEW, { timeout: 15_000 }); + "the selected view is restored from the URL after a reload", + ).toContainText(ALL_MODELS_VIEW, { timeout: 15_000 }); + await expect(modelRow(page, CHAT_MODEL_A)).toHaveCount(1, { timeout: 15_000 }); + await expect(page.getByTestId("pagination-range")).toHaveText("Showing 1-1 of 1"); + await expect(modelRow(page, CHAT_MODEL_B)).toHaveCount(0); + await expect(modelRow(page, ungrantedModelName)).toHaveCount(0); + + await chooseOption(page, teamSelector(page), PERSONAL_TEAM); await expect( modelRow(page, ungrantedModelName), - "the personal view still renders models after a reload rather than coming back empty", + "switching back to the personal team restores models outside the selected team", ).toHaveCount(1, { timeout: 30_000 }); }); }); From 5a9fe56aff9ac8e3136f7faeb40891718262e3a3 Mon Sep 17 00:00:00 2001 From: kerry Date: Wed, 16 Sep 2026 01:24:05 +0000 Subject: [PATCH 052/133] fix(responses): count custom-tool and MCP argument deltas in the streamed usage fallback Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- litellm/responses/streaming_iterator.py | 11 +++++++- .../responses/test_streaming_iterator.py | 28 +++++++++++++++++++ 2 files changed, 38 insertions(+), 1 deletion(-) diff --git a/litellm/responses/streaming_iterator.py b/litellm/responses/streaming_iterator.py index 6cf36e3e660..df3c26c1a4f 100644 --- a/litellm/responses/streaming_iterator.py +++ b/litellm/responses/streaming_iterator.py @@ -356,7 +356,7 @@ class BaseResponsesAPIStreamingIterator: _delta: Final = getattr(openai_responses_api_chunk, "delta", None) if isinstance(_delta, str): self._generated_content += _delta - elif _event_type == ResponsesAPIStreamEvents.FUNCTION_CALL_ARGUMENTS_DELTA: + elif _event_type in _TOOL_ARGUMENTS_DELTA_EVENTS: _args_delta: Final = getattr(openai_responses_api_chunk, "delta", None) if isinstance(_args_delta, str): self._generated_tool_arguments += _args_delta @@ -1358,6 +1358,15 @@ def _usage_as_model(usage: object) -> ResponseAPIUsage | None: return None +_TOOL_ARGUMENTS_DELTA_EVENTS: Final = frozenset( + { + ResponsesAPIStreamEvents.FUNCTION_CALL_ARGUMENTS_DELTA, + ResponsesAPIStreamEvents.CUSTOM_TOOL_CALL_INPUT_DELTA, + ResponsesAPIStreamEvents.MCP_CALL_ARGUMENTS_DELTA, + } +) + + def _estimate_usage_from_text( model: str, request_input: object, diff --git a/tests/test_litellm/responses/test_streaming_iterator.py b/tests/test_litellm/responses/test_streaming_iterator.py index 234e63ca3be..7bd03863717 100644 --- a/tests/test_litellm/responses/test_streaming_iterator.py +++ b/tests/test_litellm/responses/test_streaming_iterator.py @@ -816,3 +816,31 @@ async def test_completed_event_survives_a_failing_usage_estimate(): assert yielded assert iterator.completed_response.response.usage is None + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + "tool_delta_event_type", + ["response.custom_tool_call_input.delta", "response.mcp_call_arguments.delta"], +) +async def test_completed_event_without_usage_counts_tool_input_deltas(tool_delta_event_type): + """Custom-tool and MCP argument deltas feed the streamed usage fallback the + same way function_call_arguments deltas do.""" + response = _responses_api_response_without_usage() + iterator = _make_iterator( + sse_events=[ + _sse_event({"type": tool_delta_event_type, "delta": '{"query": "weather in sf"}'}), + _sse_event({"type": "response.completed", "response": {}}), + ], + logging_obj=_logging_obj_stub(), + config=_mock_config_with_completed_response(response), + request_data={"input": "what is the weather in san francisco"}, + ) + + async for _ in iterator: + pass + + usage = iterator.completed_response.response.usage + assert usage is not None + assert usage.output_tokens > 0 + assert usage.total_tokens == usage.input_tokens + usage.output_tokens From a6d2332f7a19822ada77c4fd2cc13b853d1f9cad Mon Sep 17 00:00:00 2001 From: kerry Date: Wed, 16 Sep 2026 01:24:35 +0000 Subject: [PATCH 053/133] test(responses): drive the usage-estimate failure path without patching litellm Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- .../responses/test_streaming_iterator.py | 19 ++++++++++++------- 1 file changed, 12 insertions(+), 7 deletions(-) diff --git a/tests/test_litellm/responses/test_streaming_iterator.py b/tests/test_litellm/responses/test_streaming_iterator.py index 7bd03863717..d3849e6ffce 100644 --- a/tests/test_litellm/responses/test_streaming_iterator.py +++ b/tests/test_litellm/responses/test_streaming_iterator.py @@ -17,6 +17,7 @@ from litellm.llms.base_llm.responses.transformation import BaseResponsesAPIConfi from litellm.responses.streaming_iterator import ( ResponsesAPIStreamingIterator, SyncResponsesAPIStreamingIterator, + _estimate_usage_from_text, ) from litellm.types.llms.openai import ( ResponseAPIUsage, @@ -796,8 +797,13 @@ async def test_completed_event_without_usage_counts_multimodal_input_as_messages @pytest.mark.asyncio async def test_completed_event_survives_a_failing_usage_estimate(): - """A raising token_counter must not break a stream that previously completed: - the estimate is best-effort and falls back to usage None.""" + """A malformed request input that makes the message transformer raise must not + break a stream that previously completed: the estimate is best-effort and + falls back to usage None.""" + malformed_input: Final = [{"type": "message", "role": "user", "content": 42}] + with pytest.raises(ValueError): + _estimate_usage_from_text("gpt-4o-mini", malformed_input, {"input": malformed_input}, "hello world") + response = _responses_api_response_without_usage() iterator = _make_iterator( sse_events=[ @@ -806,13 +812,12 @@ async def test_completed_event_survives_a_failing_usage_estimate(): ], logging_obj=_logging_obj_stub(), config=_mock_config_with_completed_response(response), - request_data={"input": "count these input tokens please"}, + request_data={"input": malformed_input}, ) - with patch.object(litellm, "token_counter", side_effect=RuntimeError("tokenizer exploded")): - yielded: list = [] - async for chunk in iterator: - yielded.append(chunk) + yielded: list = [] + async for chunk in iterator: + yielded.append(chunk) assert yielded assert iterator.completed_response.response.usage is None From 45d5e6b8336fa4383e0615d2c1020ebeca34aab6 Mon Sep 17 00:00:00 2001 From: Yuneng Jiang Date: Tue, 15 Sep 2026 18:26:44 -0700 Subject: [PATCH 054/133] fix(e2e): start cache CI service and count bypass calls --- .github/workflows/test-provider-cache.yml | 8 +++---- .../test_provider_cache.py | 23 +++++++++++++++++++ tests/e2e/provider_edge.py | 1 + 3 files changed, 28 insertions(+), 4 deletions(-) diff --git a/.github/workflows/test-provider-cache.yml b/.github/workflows/test-provider-cache.yml index 29b9d08a267..0507f90cf89 100644 --- a/.github/workflows/test-provider-cache.yml +++ b/.github/workflows/test-provider-cache.yml @@ -24,7 +24,7 @@ jobs: ports: - 6379:6379 options: >- - --health-cmd 'redis-cli ping' + --health-cmd "redis-cli ping" --health-interval 5s --health-timeout 3s --health-retries 10 @@ -35,10 +35,10 @@ jobs: E2E_PROVIDER_CACHE: '0' E2E_FIXTURE_MODE: live steps: - - uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 + - uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0 with: persist-credentials: false - - uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 + - uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0 with: python-version: '3.13' - uses: ./.github/actions/setup-uv-with-retries @@ -56,7 +56,7 @@ jobs: -q --junitxml=provider-cache-results.xml - name: Save test results if: always() - uses: actions/upload-artifact@4cec3d8aa04e39d1a68397de0c4cd6fb9dce8ec1 + uses: actions/upload-artifact@4cec3d8aa04e39d1a68397de0c4cd6fb9dce8ec1 # v4.6.1 with: name: provider-cache-results path: provider-cache-results.xml diff --git a/tests/code_coverage_tests/test_provider_cache.py b/tests/code_coverage_tests/test_provider_cache.py index 7edca8151af..d0e8c5296dc 100644 --- a/tests/code_coverage_tests/test_provider_cache.py +++ b/tests/code_coverage_tests/test_provider_cache.py @@ -11,8 +11,10 @@ from collections.abc import Generator from concurrent.futures import ThreadPoolExecutor from contextlib import contextmanager from dataclasses import dataclass, replace +from http.client import HTTPConnection from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer from typing import Final +from urllib.parse import urlsplit import pytest from e2e_http import NetworkError, PreparedForward, RawResponse, StreamChunk, StreamHead, forward, prepare_forward @@ -407,3 +409,24 @@ def test_enabled_environment_reuses_store_across_fresh_backends( assert configured_cache_backend() is None finally: configured_cache.cache_clear() + + +def test_duplicate_headers_bypass_cache_and_count_live_calls(store: RedisResponseStore, provider: Provider) -> None: + cache: Final = CacheEdge(store, SECRET) + with edge(cache, provider) as url: + parsed: Final = urlsplit(url) + for _ in range(2): + connection = HTTPConnection(str(parsed.hostname), parsed.port, timeout=5) + try: + connection.putrequest("POST", parsed.path) + connection.putheader("content-length", str(len(BODY))) + connection.putheader("content-type", "application/json") + connection.putheader("x-duplicate", "first") + connection.putheader("x-duplicate", "second") + connection.endheaders(BODY) + assert connection.getresponse().read() == SUCCESS + finally: + connection.close() + assert len(provider.hits) == 2 + assert dict(cache.counters.counts)["duplicate_header_bypass"] == 2 + assert dict(cache.counters.counts)["upstream_attempts"] == 2 diff --git a/tests/e2e/provider_edge.py b/tests/e2e/provider_edge.py index 49d574ee957..053a2b5c0d3 100644 --- a/tests/e2e/provider_edge.py +++ b/tests/e2e/provider_edge.py @@ -891,6 +891,7 @@ class _EdgeHandler(BaseHTTPRequestHandler): ) if isinstance(edge_server.backend, CacheEdge) and duplicate_headers: edge_server.backend.counters.increment("duplicate_header_bypass") + edge_server.backend.counters.increment("upstream_attempts") outcome: Final = handle_edge_request( selected_backend, edge_server.mounts, From 10aef224e7acc6a5f47c77d63c7a98f81728f377 Mon Sep 17 00:00:00 2001 From: kerry Date: Wed, 16 Sep 2026 01:34:28 +0000 Subject: [PATCH 055/133] style(anthropic): apply repository conventions to the missing-usage change Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- litellm/types/llms/anthropic.py | 2 +- .../llms/anthropic/chat/test_anthropic_chat_handler.py | 5 ++--- 2 files changed, 3 insertions(+), 4 deletions(-) diff --git a/litellm/types/llms/anthropic.py b/litellm/types/llms/anthropic.py index ecb8dbb2502..bcdee86360e 100644 --- a/litellm/types/llms/anthropic.py +++ b/litellm/types/llms/anthropic.py @@ -586,7 +586,7 @@ class MessageBlockDelta(TypedDict): type: Literal["message_delta"] delta: MessageDelta - usage: NotRequired[UsageDelta] + usage: NotRequired[ReadOnly[UsageDelta]] context_management: NotRequired[ContextManagementResponse] diff --git a/tests/test_litellm/llms/anthropic/chat/test_anthropic_chat_handler.py b/tests/test_litellm/llms/anthropic/chat/test_anthropic_chat_handler.py index ac05cdcf251..32c98a83948 100644 --- a/tests/test_litellm/llms/anthropic/chat/test_anthropic_chat_handler.py +++ b/tests/test_litellm/llms/anthropic/chat/test_anthropic_chat_handler.py @@ -580,10 +580,9 @@ def test_text_only_streaming_has_index_zero(): def test_message_delta_without_usage_returns_chunk_with_no_usage(): - """A message_delta event may carry no usage field; it must not raise.""" - iterator = ModelResponseIterator(None, sync_stream=True) + iterator: Final = ModelResponseIterator(None, sync_stream=True) - model_response = iterator.chunk_parser( + model_response: Final = iterator.chunk_parser( { "type": "message_delta", "delta": {"stop_reason": "end_turn", "stop_sequence": None}, From 8d625d0400befd2043806d71caec103c68489be0 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Tue, 25 Aug 2026 19:43:01 +0000 Subject: [PATCH 056/133] fix(xai): keep 'instructions' on the xAI Responses API so system messages survive web_search bridging Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- litellm/llms/xai/responses/transformation.py | 29 ++-------- .../test_xai_responses_transformation.py | 12 ++-- .../xai/xai_responses/test_transformation.py | 12 ++-- .../test_xai_responses_auto_routing.py | 56 ++++++++++++++++++- 4 files changed, 71 insertions(+), 38 deletions(-) diff --git a/litellm/llms/xai/responses/transformation.py b/litellm/llms/xai/responses/transformation.py index 1f977a66186..2f638da49c8 100644 --- a/litellm/llms/xai/responses/transformation.py +++ b/litellm/llms/xai/responses/transformation.py @@ -49,7 +49,6 @@ class XAIResponsesAPIConfig(OpenAIResponsesAPIConfig): Inherits from OpenAIResponsesAPIConfig since XAI's Responses API is largely compatible with OpenAI's, with a few differences: - - Does not support the 'instructions' parameter - Requires code_interpreter tools to have 'container' field removed - Recommends store=false when sending images @@ -60,20 +59,6 @@ class XAIResponsesAPIConfig(OpenAIResponsesAPIConfig): def custom_llm_provider(self) -> LlmProviders: return LlmProviders.XAI - def get_supported_openai_params(self, model: str) -> list: - """ - Get supported parameters for XAI Responses API. - - XAI supports most OpenAI Responses API params except 'instructions'. - """ - supported_params: Final = super().get_supported_openai_params(model) - - # Remove 'instructions' as it's not supported by XAI - if "instructions" in supported_params: - supported_params.remove("instructions") - - return supported_params - def _transform_web_search_tool(self, tool: Mapping[str, object]) -> Mapping[str, object]: """ Transform web_search tool to XAI format. @@ -158,19 +143,13 @@ class XAIResponsesAPIConfig(OpenAIResponsesAPIConfig): Map parameters for XAI Responses API. Handles XAI-specific transformations: - 1. Drops 'instructions' parameter (not supported) - 2. Transforms code_interpreter tools to remove 'container' field - 3. Transforms web_search tools to XAI format (removes search_context_size, adds filters) - 4. Transforms x_search tools to XAI format - 5. Sets store=false when images are detected (recommended by XAI) + 1. Transforms code_interpreter tools to remove 'container' field + 2. Transforms web_search tools to XAI format (removes search_context_size, adds filters) + 3. Transforms x_search tools to XAI format + 4. Sets store=false when images are detected (recommended by XAI) """ params: Final = dict(response_api_optional_params) - # Drop instructions parameter (not supported by XAI) - if "instructions" in params: - verbose_logger.debug("XAI Responses API does not support 'instructions' parameter. Dropping it.") - params.pop("instructions") - if "metadata" in params: verbose_logger.debug("XAI Responses API does not support 'metadata' parameter. Dropping it.") params.pop("metadata") diff --git a/tests/test_litellm/llms/xai/responses/test_xai_responses_transformation.py b/tests/test_litellm/llms/xai/responses/test_xai_responses_transformation.py index e3feb7d5342..34ad4b9075d 100644 --- a/tests/test_litellm/llms/xai/responses/test_xai_responses_transformation.py +++ b/tests/test_litellm/llms/xai/responses/test_xai_responses_transformation.py @@ -51,23 +51,23 @@ class TestXAIResponsesAPITransformation: assert result["tools"][0]["type"] == "code_interpreter" assert "container" not in result["tools"][0], "Container field should be removed" - def test_instructions_parameter_dropped(self): - """Test that instructions parameter is dropped for XAI""" + def test_instructions_parameter_forwarded(self): + """xAI supports 'instructions' on /v1/responses, so it must survive param mapping""" config = XAIResponsesAPIConfig() params = ResponsesAPIOptionalRequestParams(instructions="You are a helpful assistant.", temperature=0.7) result = config.map_openai_params(response_api_optional_params=params, model="grok-4-fast", drop_params=False) - assert "instructions" not in result, "Instructions should be dropped" + assert result.get("instructions") == "You are a helpful assistant." assert result.get("temperature") == 0.7, "Other params should be preserved" - def test_supported_params_excludes_instructions(self): - """Test that get_supported_openai_params excludes instructions""" + def test_supported_params_includes_instructions(self): + """A system message bridged to 'instructions' must not be rejected for xAI""" config = XAIResponsesAPIConfig() supported = config.get_supported_openai_params("grok-4-fast") - assert "instructions" not in supported, "instructions should not be supported" + assert "instructions" in supported, "instructions should be supported" assert "tools" in supported, "tools should be supported" assert "temperature" in supported, "temperature should be supported" assert "model" in supported, "model should be supported" diff --git a/tests/test_litellm/llms/xai/xai_responses/test_transformation.py b/tests/test_litellm/llms/xai/xai_responses/test_transformation.py index c783918ca06..3ea3fe631bd 100644 --- a/tests/test_litellm/llms/xai/xai_responses/test_transformation.py +++ b/tests/test_litellm/llms/xai/xai_responses/test_transformation.py @@ -53,8 +53,8 @@ class TestXAIResponsesAPITransformation: "container" not in result["tools"][0] ), "Container field should be removed" - def test_instructions_parameter_dropped(self): - """Test that instructions parameter is dropped for XAI""" + def test_instructions_parameter_forwarded(self): + """xAI supports 'instructions' on /v1/responses, so it must survive param mapping""" config = XAIResponsesAPIConfig() params = ResponsesAPIOptionalRequestParams( @@ -65,15 +65,15 @@ class TestXAIResponsesAPITransformation: response_api_optional_params=params, model="grok-4-fast", drop_params=False ) - assert "instructions" not in result, "Instructions should be dropped" + assert result.get("instructions") == "You are a helpful assistant." assert result.get("temperature") == 0.7, "Other params should be preserved" - def test_supported_params_excludes_instructions(self): - """Test that get_supported_openai_params excludes instructions""" + def test_supported_params_includes_instructions(self): + """A system message bridged to 'instructions' must not be rejected for xAI""" config = XAIResponsesAPIConfig() supported = config.get_supported_openai_params("grok-4-fast") - assert "instructions" not in supported, "instructions should not be supported" + assert "instructions" in supported, "instructions should be supported" assert "tools" in supported, "tools should be supported" assert "temperature" in supported, "temperature should be supported" assert "model" in supported, "model should be supported" diff --git a/tests/test_litellm/test_xai_responses_auto_routing.py b/tests/test_litellm/test_xai_responses_auto_routing.py index fbf2453d7fb..d405ea1e6c6 100644 --- a/tests/test_litellm/test_xai_responses_auto_routing.py +++ b/tests/test_litellm/test_xai_responses_auto_routing.py @@ -2,14 +2,30 @@ Test automatic routing to xAI Responses API when tools are present """ +import json +from collections.abc import Mapping +from typing import Final from unittest.mock import MagicMock, patch - +import httpx import pytest import litellm +from litellm.llms.custom_httpx.http_handler import HTTPHandler from litellm.main import responses_api_bridge_check +class _RecordingResponsesHandler: + """MockTransport handler that serves a canned /responses reply and keeps the body xAI would have received""" + + def __init__(self, reply: Mapping[str, object]) -> None: + self.reply: Final = reply + self.request_body: Mapping[str, object] | None = None + + def __call__(self, request: httpx.Request) -> httpx.Response: + self.request_body = json.loads(request.content) + return httpx.Response(200, json=dict(self.reply), request=request) + + class TestXAIResponsesAutoRouting: """Test that xAI requests with tools automatically route to Responses API""" @@ -254,6 +270,44 @@ class TestXAIResponsesAutoRouting: # Note: This test may need adjustment based on actual mock_response behavior # The key is that the responses_api_bridge_check logic routes correctly + def test_system_message_survives_web_search_bridge(self): + """A system message becomes 'instructions' on the bridged /responses call, and xAI accepts it""" + handler: Final = _RecordingResponsesHandler( + reply={ + "id": "resp_test", + "object": "response", + "created_at": 0, + "status": "completed", + "model": "grok-4.6", + "output": [ + { + "type": "message", + "id": "msg_test", + "status": "completed", + "role": "assistant", + "content": [{"type": "output_text", "text": "1.0.0", "annotations": []}], + } + ], + "usage": {"input_tokens": 1, "output_tokens": 1, "total_tokens": 2}, + } + ) + + response: Final = litellm.completion( + model="xai/grok-4.6", + messages=[ + {"role": "system", "content": "Answer briefly."}, + {"role": "user", "content": "newest litellm version?"}, + ], + web_search_options={"search_context_size": "medium"}, + api_key="fake-key", + client=HTTPHandler(client=httpx.Client(transport=httpx.MockTransport(handler))), + ) + + assert response.choices[0].message.content == "1.0.0" + assert handler.request_body is not None + assert handler.request_body["instructions"] == "Answer briefly." + assert handler.request_body["tools"] == [{"type": "web_search"}] + if __name__ == "__main__": pytest.main([__file__, "-v"]) From 091a38cce6b569439e116e8be5f493f06a0974de Mon Sep 17 00:00:00 2001 From: kerry Date: Wed, 16 Sep 2026 01:43:08 +0000 Subject: [PATCH 057/133] test(anthropic): import Final for the annotated locals Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- .../llms/anthropic/chat/test_anthropic_chat_handler.py | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/test_litellm/llms/anthropic/chat/test_anthropic_chat_handler.py b/tests/test_litellm/llms/anthropic/chat/test_anthropic_chat_handler.py index 32c98a83948..c3400dc40c3 100644 --- a/tests/test_litellm/llms/anthropic/chat/test_anthropic_chat_handler.py +++ b/tests/test_litellm/llms/anthropic/chat/test_anthropic_chat_handler.py @@ -1,6 +1,7 @@ import json import threading from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer +from typing import Final from unittest.mock import AsyncMock, MagicMock, patch import httpx From 15f2e25e8af3015b408ceb4a2f057a93bf1bed85 Mon Sep 17 00:00:00 2001 From: yassin Date: Wed, 16 Sep 2026 01:44:36 +0000 Subject: [PATCH 058/133] refactor(proxy): replace configurable model access denied message with a fixed clean client message Drop the model_access_denied_message setting, its {model} template, the DB override entry and the Admin UI field. Model access denials now always return the fixed client message while the allowlist diagnostic is logged at the final HTTP, realtime and MCP boundaries Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- litellm/__init__.py | 1 - litellm/constants.py | 3 - litellm/proxy/auth/auth_checks.py | 8 +- litellm/proxy/auth/handle_jwt.py | 10 +- litellm/proxy/auth/model_access_denied.py | 13 +- litellm/proxy/proxy_server.py | 39 +----- tests/otel_tests/test_e2e_model_access.py | 8 +- tests/proxy_unit_tests/test_auth_checks.py | 4 +- .../test_mcp_sampling_model_access.py | 4 +- .../proxy/auth/test_auth_checks.py | 68 +++++----- .../proxy/auth/test_auth_exception_handler.py | 56 ++------- .../proxy/auth/test_auth_utils.py | 2 +- .../proxy/auth/test_handle_jwt.py | 34 ++--- .../test_realtime_webrtc_endpoints.py | 14 +-- tests/test_litellm/proxy/test_proxy_server.py | 119 +----------------- tests/test_litellm/test_router.py | 6 +- tests/test_openai_endpoints.py | 2 +- .../general_settings.integration.test.tsx | 58 +-------- .../_components/general_settings.tsx | 42 +++---- 19 files changed, 114 insertions(+), 377 deletions(-) diff --git a/litellm/__init__.py b/litellm/__init__.py index 73714cd0c9c..3668e6efb0c 100644 --- a/litellm/__init__.py +++ b/litellm/__init__.py @@ -219,7 +219,6 @@ redact_user_api_key_info: Optional[bool] = False # major release; opt in early with `litellm.expose_router_debug_in_errors # = False`. expose_router_debug_in_errors: bool = True -model_access_denied_message: str | None = None filter_invalid_headers: Optional[bool] = False add_user_information_to_llm_headers: Optional[bool] = ( None # adds user_id, team_id, token hash (params from StandardLoggingMetadata) to request headers diff --git a/litellm/constants.py b/litellm/constants.py index 7d546fdccdf..745a4d9294e 100644 --- a/litellm/constants.py +++ b/litellm/constants.py @@ -98,7 +98,6 @@ BASE64_TRUNCATION_OFFLOAD_THRESHOLD_CHARS: Final = 256 * 1024 REDACTED_BY_LITELLM: Final = "redacted-by-litellm" # in-memory stand-in handed to provider converters for redacted arguments; never stored REDACTED_TOOL_CALL_ARGUMENTS_PLACEHOLDER: Final = "{}" -MODEL_ACCESS_DENIED_MESSAGE_MODEL_PLACEHOLDER: Final = "{model}" MAX_STRING_LENGTH_STDOUT_LOG: Final = get_env_int("MAX_STRING_LENGTH_STDOUT_LOG", 4096) @@ -1801,8 +1800,6 @@ LITELLM_SETTINGS_SAFE_DB_OVERRIDES: Final = [ "max_ui_session_budget", "budget_rollover", "mcp_tool_search", - "model_access_denied_message", - "expose_router_debug_in_errors", ] SPECIAL_LITELLM_AUTH_TOKEN: Final = ["ui-token"] DEFAULT_MANAGEMENT_OBJECT_IN_MEMORY_CACHE_TTL = int(os.getenv("DEFAULT_MANAGEMENT_OBJECT_IN_MEMORY_CACHE_TTL", 60)) diff --git a/litellm/proxy/auth/auth_checks.py b/litellm/proxy/auth/auth_checks.py index 6b67fc0cb28..ba68dc8a17f 100644 --- a/litellm/proxy/auth/auth_checks.py +++ b/litellm/proxy/auth/auth_checks.py @@ -72,7 +72,7 @@ from litellm.proxy.auth.budget_throttle import ( budget_throttle_percentage, should_throttle_budget_exceeded, ) -from litellm.proxy.auth.model_access_denied import client_facing_model_access_denied_message +from litellm.proxy.auth.model_access_denied import model_access_denied_client_message from litellm.proxy.auth.route_checks import RouteChecks from litellm.proxy.common_utils.auth_cache_invalidation_pubsub import publish_auth_cache_invalidation from litellm.proxy.common_utils.cache_pydantic_utils import CacheCodec @@ -4177,7 +4177,7 @@ def _can_object_call_model( f"Tried to access {model}" ) raise ModelAccessDeniedProxyException( - message=client_facing_model_access_denied_message(internal_message=internal_message, model=model), + message=model_access_denied_client_message(model=model), internal_message=internal_message, type=ProxyErrorTypes.get_model_access_error_type_for_object(object_type=object_type), param="model", @@ -4808,7 +4808,7 @@ async def can_user_call_model( f"Tried to access {model}" ) raise ModelAccessDeniedProxyException( - message=client_facing_model_access_denied_message(internal_message=internal_message, model=model), + message=model_access_denied_client_message(model=model), internal_message=internal_message, type=ProxyErrorTypes.key_model_access_denied, param="model", @@ -5415,7 +5415,7 @@ async def _check_team_member_model_access( f"Model={model}. Allowed member models = {member_allowed_models}" ) raise ModelAccessDeniedProxyException( - message=client_facing_model_access_denied_message(internal_message=internal_message, model=model), + message=model_access_denied_client_message(model=model), internal_message=internal_message, type=ProxyErrorTypes.team_model_access_denied, param="model", diff --git a/litellm/proxy/auth/handle_jwt.py b/litellm/proxy/auth/handle_jwt.py index ea6d52b28f0..0389f69cfeb 100644 --- a/litellm/proxy/auth/handle_jwt.py +++ b/litellm/proxy/auth/handle_jwt.py @@ -54,7 +54,7 @@ from litellm.proxy._types import ( from litellm.proxy.auth.auth_checks import can_team_access_model from litellm.proxy.auth.model_access_denied import ( ModelAccessDeniedHTTPException, - client_facing_model_access_denied_message, + model_access_denied_client_message, ) from litellm.proxy.auth.resolvers.grants import GrantResolver, UserLookup, canonical_user_id from litellm.proxy.auth.route_checks import RouteChecks @@ -1347,7 +1347,7 @@ class JWTAuthManager: raise ModelAccessDeniedHTTPException( internal_message=internal_message, status_code=403, - detail=client_facing_model_access_denied_message(internal_message=internal_message, model=model), + detail=model_access_denied_client_message(model=model), ) return True @@ -1380,11 +1380,7 @@ class JWTAuthManager: raise ModelAccessDeniedHTTPException( internal_message=internal_message, status_code=403, - detail={ - "error": client_facing_model_access_denied_message( - internal_message=internal_message, model=requested_model - ) - }, + detail={"error": model_access_denied_client_message(model=requested_model)}, ) return diff --git a/litellm/proxy/auth/model_access_denied.py b/litellm/proxy/auth/model_access_denied.py index 8164e06c42a..ffb73b343cd 100644 --- a/litellm/proxy/auth/model_access_denied.py +++ b/litellm/proxy/auth/model_access_denied.py @@ -2,15 +2,14 @@ from typing import Final from fastapi import HTTPException -import litellm -from litellm.constants import MODEL_ACCESS_DENIED_MESSAGE_MODEL_PLACEHOLDER +MODEL_ACCESS_DENIED_CLIENT_MESSAGE: Final = ( + "The requested model '{model}' is not available for this API key, or the model name is invalid. " + "Check the models available to you and try again." +) -def client_facing_model_access_denied_message(internal_message: str, model: str | list[str]) -> str: - template: Final = litellm.model_access_denied_message - if not template: - return internal_message - return template.replace(MODEL_ACCESS_DENIED_MESSAGE_MODEL_PLACEHOLDER, str(model)) +def model_access_denied_client_message(model: str | list[str]) -> str: + return MODEL_ACCESS_DENIED_CLIENT_MESSAGE.format(model=model) class ModelAccessDeniedHTTPException(HTTPException): diff --git a/litellm/proxy/proxy_server.py b/litellm/proxy/proxy_server.py index 12d62f6620d..23bb8b6225b 100644 --- a/litellm/proxy/proxy_server.py +++ b/litellm/proxy/proxy_server.py @@ -1682,7 +1682,7 @@ async def openai_exception_handler(request: Request, exc: ProxyException): def _log_model_access_denial(exc: ProxyException) -> None: - if not litellm.model_access_denied_message or not isinstance(exc, ModelAccessDeniedProxyException): + if not isinstance(exc, ModelAccessDeniedProxyException): return verbose_proxy_logger.warning(exc.sanitized_internal_message()) @@ -17455,13 +17455,11 @@ GeneralSettingsUILiteLLMValue = float | bool | str | None class GeneralSettingsUILiteLLMFieldSpec(TypedDict): - type: ReadOnly[Literal["Float", "Dollar", "Boolean", "Select", "String"]] - description: ReadOnly[str] - options: ReadOnly[NotRequired[tuple[str, ...]]] - tab: ReadOnly[NotRequired[str]] # Admin UI sub-tab this field renders under; None groups it with the rest - default: ReadOnly[ - NotRequired[float | bool] - ] # reset/clear restores this instead of None; fields whose None means fail-open set it + type: Literal["Float", "Dollar", "Boolean", "Select"] + description: str + options: NotRequired[tuple[str, ...]] + tab: NotRequired[str] # Admin UI sub-tab this field renders under; None groups it with the rest + default: NotRequired[float] # reset/clear restores this instead of None; fields whose None means fail-open set it _GENERAL_SETTINGS_UI_LITELLM_FIELDS: Final[dict[str, GeneralSettingsUILiteLLMFieldSpec]] = { @@ -17513,24 +17511,6 @@ _GENERAL_SETTINGS_UI_LITELLM_FIELDS: Final[dict[str, GeneralSettingsUILiteLLMFie "with this budget. Clearing restores the $1 default." ), }, - "model_access_denied_message": { - "type": "String", - "description": ( - "Client-facing error message returned when a key, team, user, org or project is not allowed " - "to call the requested model. {model} is replaced with the requested model name. The full " - "denial reason (allowed models and access groups) is still written to the proxy logs. " - "Leave empty to return the detailed message to clients." - ), - }, - "expose_router_debug_in_errors": { - "type": "Boolean", - "default": True, - "description": ( - "Append router debug details (model group, configured fallbacks, fallback errors, cooldown " - "info) to error messages returned to clients. Turn off to keep those details in the proxy " - "logs only." - ), - }, } @@ -17578,13 +17558,6 @@ def _validate_general_settings_ui_litellm_value(field_name: str, value: object) detail={"error": f"{field_name} must be a positive dollar amount or empty"}, ) return float(value) - case "String": - if not isinstance(value, str): - raise HTTPException( - status_code=400, - detail={"error": f"{field_name} must be a string or empty"}, - ) - return value case _: assert_never(field_type) diff --git a/tests/otel_tests/test_e2e_model_access.py b/tests/otel_tests/test_e2e_model_access.py index e5e93c0b179..6017a820299 100644 --- a/tests/otel_tests/test_e2e_model_access.py +++ b/tests/otel_tests/test_e2e_model_access.py @@ -101,7 +101,7 @@ async def test_model_access_patterns(key_models, test_model, expect_success): assert _error_body["type"] == "key_model_access_denied" assert _error_body["param"] == "model" assert _error_body["code"] == "403" - assert "key not allowed to access model" in _error_body["message"] + assert "is not available for this API key" in _error_body["message"] @pytest.mark.asyncio @@ -299,7 +299,5 @@ def _validate_model_access_exception( assert _error_body["type"] == expected_type assert _error_body["param"] == "model" assert _error_body["code"] == "403" - if expected_type == "key_model_access_denied": - assert "key not allowed to access model" in _error_body["message"] - elif expected_type == "team_model_access_denied": - assert "eam not allowed to access model" in _error_body["message"] + assert "is not available for this API key" in _error_body["message"] + assert "not allowed to access model" not in _error_body["message"] diff --git a/tests/proxy_unit_tests/test_auth_checks.py b/tests/proxy_unit_tests/test_auth_checks.py index d436c99cd20..2538556d3b5 100644 --- a/tests/proxy_unit_tests/test_auth_checks.py +++ b/tests/proxy_unit_tests/test_auth_checks.py @@ -163,7 +163,7 @@ async def test_can_key_call_model(model, expect_to_work): if expect_to_work: await can_key_call_model(**args) else: - with pytest.raises(Exception, match='key not allowed to access model\\. This key can only access') as e: + with pytest.raises(Exception, match='is not available for this API key') as e: await can_key_call_model(**args) print(e) @@ -943,7 +943,7 @@ async def test_can_key_call_model_with_aliases(model, alias_map, expect_to_work) llm_router=router, ) else: - with pytest.raises(Exception, match='key not allowed to access model\\. This key can only access') as e: + with pytest.raises(Exception, match='is not available for this API key') as e: await can_key_call_model( model=model, llm_model_list=llm_model_list, diff --git a/tests/test_litellm/proxy/_experimental/mcp_server/test_mcp_sampling_model_access.py b/tests/test_litellm/proxy/_experimental/mcp_server/test_mcp_sampling_model_access.py index d98db5518c3..7eebf1eb436 100644 --- a/tests/test_litellm/proxy/_experimental/mcp_server/test_mcp_sampling_model_access.py +++ b/tests/test_litellm/proxy/_experimental/mcp_server/test_mcp_sampling_model_access.py @@ -138,11 +138,9 @@ class TestCheckModelAccess: assert "claude-3-opus-20240229" in result.message @pytest.mark.asyncio - async def test_should_log_internal_denial_reason_when_client_message_is_configured(self, monkeypatch, caplog): - import litellm + async def test_should_log_internal_denial_reason_and_hide_allowlist_from_client(self, caplog): from litellm.proxy._types import UserAPIKeyAuth - monkeypatch.setattr(litellm, "model_access_denied_message", "The model `{model}` is unavailable for this API key.") auth = UserAPIKeyAuth(api_key="sk-test-key", models=["gpt-3.5-turbo"]) with caplog.at_level("WARNING", logger="LiteLLM"): diff --git a/tests/test_litellm/proxy/auth/test_auth_checks.py b/tests/test_litellm/proxy/auth/test_auth_checks.py index 0677fb8af29..26ae28a57d2 100644 --- a/tests/test_litellm/proxy/auth/test_auth_checks.py +++ b/tests/test_litellm/proxy/auth/test_auth_checks.py @@ -531,12 +531,14 @@ async def test_can_team_access_model_error_lists_direct_and_access_group_models( assert await can_team_access_model("direct-model", team_object, None) is True assert await can_team_access_model("group-model", team_object, None) is True - with pytest.raises(ProxyException) as exc_info: + with pytest.raises(ModelAccessDeniedProxyException) as exc_info: await can_team_access_model("blocked-model", team_object, None) assert exc_info.value.type == ProxyErrorTypes.team_model_access_denied - assert "direct-model" in exc_info.value.message - assert "group-model" in exc_info.value.message + assert "direct-model" in exc_info.value.internal_message + assert "group-model" in exc_info.value.internal_message + assert "direct-model" not in exc_info.value.message + assert "group-model" not in exc_info.value.message @pytest.mark.asyncio @@ -1676,16 +1678,17 @@ def test_can_object_call_model_no_access_to_alias_or_underlying(): # Should raise ProxyException with appropriate error type assert exc_info.value.type == ProxyErrorTypes.key_model_access_denied - assert "key not allowed to access model" in str(exc_info.value.message) + assert "is not available for this API key" in str(exc_info.value.message) assert "my-fake-gpt" in str(exc_info.value.message) -_DENIED_MESSAGE_TEMPLATE: Final = "The model `{model}` is unavailable for this API key or does not exist." +_DENIED_MESSAGE_TEMPLATE: Final = ( + "The requested model '{model}' is not available for this API key, or the model name is invalid. " + "Check the models available to you and try again." +) -def test_can_object_call_model_denial_uses_configured_message_and_keeps_detail_on_exception(monkeypatch, caplog): - monkeypatch.setattr(litellm, "model_access_denied_message", _DENIED_MESSAGE_TEMPLATE) - +def test_can_object_call_model_denial_hides_allowlist_and_keeps_detail_on_exception(caplog): with caplog.at_level("DEBUG", logger="LiteLLM Proxy"): with pytest.raises(ModelAccessDeniedProxyException) as exc_info: _can_object_call_model( @@ -1695,9 +1698,8 @@ def test_can_object_call_model_denial_uses_configured_message_and_keeps_detail_o object_type="key", ) - assert ( - exc_info.value.message == "The model `anthropic-sonnet-4-5` is unavailable for this API key or does not exist." - ) + assert exc_info.value.message == _DENIED_MESSAGE_TEMPLATE.format(model="anthropic-sonnet-4-5") + assert "internal-models" not in exc_info.value.message assert exc_info.value.type == ProxyErrorTypes.key_model_access_denied assert exc_info.value.param == "model" assert int(exc_info.value.code) == status.HTTP_403_FORBIDDEN @@ -1709,10 +1711,9 @@ def test_can_object_call_model_denial_uses_configured_message_and_keeps_detail_o @pytest.mark.asyncio -async def test_access_group_fallback_grant_does_not_log_a_denial(monkeypatch, caplog): +async def test_access_group_fallback_grant_does_not_log_a_denial(caplog): from litellm.proxy.auth.auth_checks import can_team_access_model - monkeypatch.setattr(litellm, "model_access_denied_message", _DENIED_MESSAGE_TEMPLATE) team_object = LiteLLM_TeamTable(team_id="team-123", models=["direct-model"], access_group_ids=["ag-1"]) with ( @@ -1727,46 +1728,49 @@ async def test_access_group_fallback_grant_does_not_log_a_denial(monkeypatch, ca assert "not allowed to access model" not in caplog.text -@pytest.mark.parametrize("unset_value", [None, ""]) -def test_can_object_call_model_denial_unchanged_when_message_not_configured(monkeypatch, unset_value): - monkeypatch.setattr(litellm, "model_access_denied_message", unset_value) - - with pytest.raises(ProxyException) as exc_info: +@pytest.mark.parametrize( + "object_type, expected_type", + [ + ("team", ProxyErrorTypes.team_model_access_denied), + ("user", ProxyErrorTypes.user_model_access_denied), + ("org", ProxyErrorTypes.org_model_access_denied), + ], +) +def test_can_object_call_model_denial_same_client_message_for_every_object_type(object_type, expected_type): + with pytest.raises(ModelAccessDeniedProxyException) as exc_info: _can_object_call_model( model="anthropic-sonnet-4-5", llm_router=None, models=["internal-models"], - object_type="team", + object_type=object_type, ) - assert exc_info.value.message == ( - "team not allowed to access model. This team can only access models=['internal-models']. " - "Tried to access anthropic-sonnet-4-5" - ) + assert exc_info.value.message == _DENIED_MESSAGE_TEMPLATE.format(model="anthropic-sonnet-4-5") + assert exc_info.value.type == expected_type + assert f"{object_type} not allowed to access model" in exc_info.value.internal_message @pytest.mark.asyncio -async def test_can_user_call_model_no_default_models_uses_configured_message(monkeypatch): +async def test_can_user_call_model_no_default_models_hides_policy_detail(): from litellm.proxy._types import SpecialModelNames from litellm.proxy.auth.auth_checks import can_user_call_model - monkeypatch.setattr(litellm, "model_access_denied_message", _DENIED_MESSAGE_TEMPLATE) user_object = LiteLLM_UserTable(user_id="test-user", models=[SpecialModelNames.no_default_models.value]) - with pytest.raises(ProxyException) as exc_info: + with pytest.raises(ModelAccessDeniedProxyException) as exc_info: await can_user_call_model(model="restricted-model", llm_router=None, user_object=user_object) - assert exc_info.value.message == "The model `restricted-model` is unavailable for this API key or does not exist." + assert exc_info.value.message == _DENIED_MESSAGE_TEMPLATE.format(model="restricted-model") + assert "only team models allowed" in exc_info.value.internal_message assert int(exc_info.value.code) == status.HTTP_403_FORBIDDEN @pytest.mark.asyncio -async def test_check_team_member_model_access_denied_uses_configured_message(monkeypatch): +async def test_check_team_member_model_access_denied_hides_member_allowlist(): from litellm.proxy._types import LiteLLM_TeamMembership from litellm.proxy.auth.auth_checks import _check_team_member_model_access from litellm.proxy.common_utils.user_api_key_cache import team_membership_reservation_cache_key - monkeypatch.setattr(litellm, "model_access_denied_message", _DENIED_MESSAGE_TEMPLATE) membership = LiteLLM_TeamMembership( user_id="alice", team_id="team-a", @@ -1779,7 +1783,7 @@ async def test_check_team_member_model_access_denied_uses_configured_message(mon model_type=LiteLLM_TeamMembership, ) - with pytest.raises(ProxyException) as exc_info: + with pytest.raises(ModelAccessDeniedProxyException) as exc_info: await _check_team_member_model_access( model="mock-vision", team_object=LiteLLM_TeamTable(team_id="team-a"), @@ -1790,7 +1794,9 @@ async def test_check_team_member_model_access_denied_uses_configured_message(mon proxy_logging_obj=MagicMock(), ) - assert exc_info.value.message == "The model `mock-vision` is unavailable for this API key or does not exist." + assert exc_info.value.message == _DENIED_MESSAGE_TEMPLATE.format(model="mock-vision") + assert "fast-models" not in exc_info.value.message + assert "Allowed member models = ['fast-models']" in exc_info.value.internal_message assert exc_info.value.type == ProxyErrorTypes.team_model_access_denied diff --git a/tests/test_litellm/proxy/auth/test_auth_exception_handler.py b/tests/test_litellm/proxy/auth/test_auth_exception_handler.py index d5ef71cd1d1..125b8862dfc 100644 --- a/tests/test_litellm/proxy/auth/test_auth_exception_handler.py +++ b/tests/test_litellm/proxy/auth/test_auth_exception_handler.py @@ -26,7 +26,6 @@ from prisma.errors import ( ) -import litellm from litellm._logging import verbose_proxy_logger from litellm.constants import INVALID_VIRTUAL_KEY_ERROR_MARKER from litellm.exceptions import BudgetExceededError @@ -991,12 +990,15 @@ async def test_handle_authentication_error_traceback_only_for_unexpected_errors( assert records[0].name == expected_logger_name -_DENIED_MESSAGE_TEMPLATE = "The model `{model}` is unavailable for this API key or does not exist." +_DENIED_CLIENT_MESSAGE = ( + "The requested model 'gpt-5.6' is not available for this API key, or the model name is invalid. " + "Check the models available to you and try again." +) def _denied_proxy_exception() -> ModelAccessDeniedProxyException: return ModelAccessDeniedProxyException( - message="The model `gpt-5.6\r\nWARNING forged log line` is unavailable for this API key or does not exist.", + message=_DENIED_CLIENT_MESSAGE, internal_message="key not allowed to access model. This key can only access models=['internal-models']. " "Tried to access gpt-5.6\r\nWARNING forged log line", type=ProxyErrorTypes.key_model_access_denied, @@ -1010,7 +1012,7 @@ def _denied_jwt_exception() -> ModelAccessDeniedHTTPException: internal_message="Role=engineer not allowed to call model=gpt-5.6\r\nWARNING forged log line. " "Allowed models=['internal-models']", status_code=status.HTTP_403_FORBIDDEN, - detail="The model `gpt-5.6` is unavailable for this API key or does not exist.", + detail=_DENIED_CLIENT_MESSAGE, ) @@ -1022,10 +1024,7 @@ def _denied_jwt_exception() -> ModelAccessDeniedHTTPException: pytest.param(_denied_jwt_exception, id="jwt_http_exception"), ], ) -async def test_handle_authentication_error_keeps_internal_message_on_model_access_denial( - monkeypatch, make_denial, caplog -): - monkeypatch.setattr(litellm, "model_access_denied_message", _DENIED_MESSAGE_TEMPLATE) +async def test_handle_authentication_error_keeps_internal_message_on_model_access_denial(make_denial, caplog): handler = UserAPIKeyAuthExceptionHandler() denial = make_denial() @@ -1054,7 +1053,7 @@ async def test_handle_authentication_error_keeps_internal_message_on_model_acces def test_as_proxy_exception_keeps_jwt_scope_denial_message_shape(): - detail = {"error": "The model `gpt-5.6` is unavailable for this API key or does not exist."} + detail = {"error": _DENIED_CLIENT_MESSAGE} denial = ModelAccessDeniedHTTPException( internal_message="model=gpt-5.6 not allowed. Allowed_models=['internal-models']", status_code=status.HTTP_403_FORBIDDEN, @@ -1066,42 +1065,3 @@ def test_as_proxy_exception_keeps_jwt_scope_denial_message_shape(): assert converted.to_dict() == plain.to_dict() assert converted.internal_message == denial.internal_message - - -@pytest.mark.asyncio -@pytest.mark.parametrize("unset_value", [None, ""]) -async def test_handle_authentication_error_no_extra_denial_log_when_message_not_configured( - monkeypatch, unset_value, caplog -): - monkeypatch.setattr(litellm, "model_access_denied_message", unset_value) - handler = UserAPIKeyAuthExceptionHandler() - denial = ModelAccessDeniedProxyException( - message="key not allowed to access model. This key can only access models=['internal-models']. " - "Tried to access gpt-5.6", - internal_message="key not allowed to access model. This key can only access models=['internal-models']. " - "Tried to access gpt-5.6", - type=ProxyErrorTypes.key_model_access_denied, - param="model", - code=status.HTTP_403_FORBIDDEN, - ) - - with ( - patch( # test-quality-ok: handler reads proxy_server globals at call time - "litellm.proxy.proxy_server.proxy_logging_obj.post_call_failure_hook", - new_callable=AsyncMock, - return_value=None, - ), - patch( # test-quality-ok: handler reads proxy_server globals at call time - "litellm.proxy.auth.auth_exception_handler.seed_request_identity", - ), - patch( # test-quality-ok: handler reads proxy_server globals at call time - "litellm.proxy.proxy_server.general_settings", - {"allow_requests_on_db_unavailable": False}, - ), - caplog.at_level("WARNING", logger="LiteLLM Proxy"), - pytest.raises(ProxyException) as exc_info, - ): - await handler._handle_authentication_error(denial, MagicMock(), {}, "/v1/chat/completions", None, "sk-bad-key") - - assert "internal-models" in str(exc_info.value.message) - assert [r for r in caplog.records if r.levelname == "WARNING" and "internal-models" in r.getMessage()] == [] diff --git a/tests/test_litellm/proxy/auth/test_auth_utils.py b/tests/test_litellm/proxy/auth/test_auth_utils.py index bd6a14cad21..db16da7237c 100644 --- a/tests/test_litellm/proxy/auth/test_auth_utils.py +++ b/tests/test_litellm/proxy/auth/test_auth_utils.py @@ -1053,7 +1053,7 @@ async def test_managed_batch_routes_pass_team_model_access_check(route, request_ is True ) - with pytest.raises(Exception, match="team not allowed to access model"): + with pytest.raises(Exception, match="is not available for this API key"): await can_team_access_model( model=model, team_object=LiteLLM_TeamTable(team_id="team-other", models=["some-other-model"]), diff --git a/tests/test_litellm/proxy/auth/test_handle_jwt.py b/tests/test_litellm/proxy/auth/test_handle_jwt.py index 9fab1e1785a..a8385eadc59 100644 --- a/tests/test_litellm/proxy/auth/test_handle_jwt.py +++ b/tests/test_litellm/proxy/auth/test_handle_jwt.py @@ -6972,19 +6972,13 @@ async def test_auth_builder_denies_jwt_naming_unregistered_agent_before_admin_ch assert exc_info.value.status_code == 403 -_JWT_DENIED_MESSAGE_TEMPLATE = "The model `{model}` is unavailable for this identity." - - -@pytest.mark.parametrize( - "configured_message, expected_detail", - [ - (None, "Role=internal_user not allowed to call model=gpt-5.6. Allowed models=['gpt-5.6-mini']"), - ("", "Role=internal_user not allowed to call model=gpt-5.6. Allowed models=['gpt-5.6-mini']"), - (_JWT_DENIED_MESSAGE_TEMPLATE, "The model `gpt-5.6` is unavailable for this identity."), - ], +_JWT_DENIED_CLIENT_MESSAGE = ( + "The requested model 'gpt-5.6' is not available for this API key, or the model name is invalid. " + "Check the models available to you and try again." ) -def test_can_rbac_role_call_model_denial_honors_configured_message(monkeypatch, configured_message, expected_detail): - monkeypatch.setattr(litellm, "model_access_denied_message", configured_message) + + +def test_can_rbac_role_call_model_denial_hides_role_allowlist_from_client(): general_settings = { "role_permissions": [ RoleBasedPermissions(role=LitellmUserRoles.INTERNAL_USER, models=["gpt-5.6-mini"]), @@ -6999,23 +6993,13 @@ def test_can_rbac_role_call_model_denial_honors_configured_message(monkeypatch, ) assert exc_info.value.status_code == 403 - assert exc_info.value.detail == expected_detail + assert exc_info.value.detail == _JWT_DENIED_CLIENT_MESSAGE assert exc_info.value.internal_message == ( "Role=internal_user not allowed to call model=gpt-5.6. Allowed models=['gpt-5.6-mini']" ) -@pytest.mark.parametrize( - "configured_message, expected_error", - [ - (None, "model=gpt-5.6 not allowed. Allowed_models=['gpt-5.6-mini']"), - ("", "model=gpt-5.6 not allowed. Allowed_models=['gpt-5.6-mini']"), - (_JWT_DENIED_MESSAGE_TEMPLATE, "The model `gpt-5.6` is unavailable for this identity."), - ], -) -def test_check_scope_based_access_denial_honors_configured_message(monkeypatch, configured_message, expected_error): - monkeypatch.setattr(litellm, "model_access_denied_message", configured_message) - +def test_check_scope_based_access_denial_hides_scope_allowlist_from_client(): with pytest.raises(ModelAccessDeniedHTTPException) as exc_info: JWTAuthManager.check_scope_based_access( scope_mappings=[ScopeMapping(scope="litellm.api.consumer", models=["gpt-5.6-mini"])], @@ -7025,5 +7009,5 @@ def test_check_scope_based_access_denial_honors_configured_message(monkeypatch, ) assert exc_info.value.status_code == 403 - assert exc_info.value.detail == {"error": expected_error} + assert exc_info.value.detail == {"error": _JWT_DENIED_CLIENT_MESSAGE} assert exc_info.value.internal_message == "model=gpt-5.6 not allowed. Allowed_models=['gpt-5.6-mini']" diff --git a/tests/test_litellm/proxy/realtime_endpoints/test_realtime_webrtc_endpoints.py b/tests/test_litellm/proxy/realtime_endpoints/test_realtime_webrtc_endpoints.py index 82f2ef097aa..f5c97142dde 100644 --- a/tests/test_litellm/proxy/realtime_endpoints/test_realtime_webrtc_endpoints.py +++ b/tests/test_litellm/proxy/realtime_endpoints/test_realtime_webrtc_endpoints.py @@ -287,7 +287,7 @@ async def test_client_secrets_transcription_rejects_disallowed_nested_model( ) assert response.status_code == 403 - assert "Tried to access gpt-realtime-whisper" in response.text + assert "The requested model 'gpt-realtime-whisper' is not available for this API key" in response.text mock_route_request.assert_not_called() finally: proxy_app.dependency_overrides.pop(user_api_key_auth, None) @@ -611,7 +611,7 @@ async def test_transcription_sessions_rejects_disallowed_resolved_model( ) assert response.status_code == 403 - assert "Tried to access gpt-realtime-whisper" in response.text + assert "The requested model 'gpt-realtime-whisper' is not available for this API key" in response.text mock_route_request.assert_not_called() finally: proxy_app.dependency_overrides.pop(user_api_key_auth, None) @@ -658,7 +658,7 @@ async def test_transcription_sessions_rejects_disallowed_team_model_scope( assert response.status_code == 403 assert "team" in response.text.lower() - assert "Tried to access gpt-realtime-whisper" in response.text + assert "The requested model 'gpt-realtime-whisper' is not available for this API key" in response.text mock_route_request.assert_not_called() finally: proxy_app.dependency_overrides.pop(user_api_key_auth, None) @@ -703,7 +703,7 @@ async def test_transcription_sessions_rejects_disallowed_project_model_scope( assert response.status_code == 403 assert "project" in response.text.lower() - assert "Tried to access gpt-realtime-whisper" in response.text + assert "The requested model 'gpt-realtime-whisper' is not available for this API key" in response.text mock_route_request.assert_not_called() finally: proxy_app.dependency_overrides.pop(user_api_key_auth, None) @@ -757,7 +757,7 @@ async def test_transcription_sessions_rejects_disallowed_team_member_model_scope ) assert response.status_code == 403 - assert "Team member not allowed to access model" in response.text + assert "is not available for this API key" in response.text mock_route_request.assert_not_called() finally: proxy_app.dependency_overrides.pop(user_api_key_auth, None) @@ -783,7 +783,7 @@ async def test_realtime_transcription_websocket_default_model_checks_key_scope() websocket.close.assert_awaited_once() _, close_kwargs = websocket.close.call_args assert close_kwargs["code"] == 1008 - assert "not allowed to access model" in close_kwargs["reason"] + assert "is not available for this API key" in close_kwargs["reason"] @pytest.mark.asyncio @@ -825,7 +825,7 @@ async def test_realtime_transcription_websocket_default_model_checks_team_scope( websocket.close.assert_awaited_once() _, close_kwargs = websocket.close.call_args assert close_kwargs["code"] == 1008 - assert "not allowed to access model" in close_kwargs["reason"] + assert "is not available for this API key" in close_kwargs["reason"] @pytest.mark.asyncio diff --git a/tests/test_litellm/proxy/test_proxy_server.py b/tests/test_litellm/proxy/test_proxy_server.py index ee85c9ba6a5..d1928b9cd52 100644 --- a/tests/test_litellm/proxy/test_proxy_server.py +++ b/tests/test_litellm/proxy/test_proxy_server.py @@ -10955,60 +10955,10 @@ def test_validate_max_ui_session_budget_empty_restores_default(empty_value): assert _validate_general_settings_ui_litellm_value("max_ui_session_budget", empty_value) == 1.0 -@pytest.mark.asyncio -async def test_update_config_field_model_access_denied_message_sets_live_value(monkeypatch): - from unittest.mock import AsyncMock, MagicMock - - import litellm.proxy.proxy_server as ps - from litellm.proxy._types import ConfigFieldUpdate, LitellmUserRoles, UserAPIKeyAuth - from litellm.proxy.proxy_server import update_config_general_settings - - save_config = AsyncMock() - monkeypatch.setattr(ps.proxy_config, "get_config", AsyncMock(return_value={"litellm_settings": {}})) - monkeypatch.setattr(ps.proxy_config, "save_config", save_config) - monkeypatch.setattr(ps, "prisma_client", MagicMock()) - monkeypatch.setattr(litellm, "store_audit_logs", False) - monkeypatch.setattr(litellm, "model_access_denied_message", None) - - admin = UserAPIKeyAuth(api_key="k", user_id="a", user_role=LitellmUserRoles.PROXY_ADMIN) - await update_config_general_settings( - data=ConfigFieldUpdate( - field_name="model_access_denied_message", - field_value="Model `{model}` is unavailable for this key.", - config_type="general_settings", - ), - user_api_key_dict=admin, - ) - - assert litellm.model_access_denied_message == "Model `{model}` is unavailable for this key." - save_config.assert_awaited_once() - saved_config = save_config.await_args.kwargs["new_config"] - assert saved_config["litellm_settings"]["model_access_denied_message"] == ( - "Model `{model}` is unavailable for this key." - ) - - -@pytest.mark.parametrize("bad_value", [True, 3, 1.5, ["x"], {"a": "b"}]) -def test_validate_model_access_denied_message_rejects_non_strings(bad_value): - from fastapi import HTTPException - - from litellm.proxy.proxy_server import _validate_general_settings_ui_litellm_value - - with pytest.raises(HTTPException) as exc_info: - _validate_general_settings_ui_litellm_value("model_access_denied_message", bad_value) - assert exc_info.value.status_code == 400 - - -@pytest.mark.parametrize("empty_value", [None, ""]) -def test_validate_model_access_denied_message_empty_restores_detailed_default(empty_value): - from litellm.proxy.proxy_server import _validate_general_settings_ui_litellm_value - - assert _validate_general_settings_ui_litellm_value("model_access_denied_message", empty_value) is None - - def _model_access_denied_proxy_exception(): return ModelAccessDeniedProxyException( - message="The model `gpt-5.6\r\nWARNING forged log line` is unavailable for this API key or does not exist.", + message="The requested model 'gpt-5.6\r\nWARNING forged log line' is not available for this API key, " + "or the model name is invalid. Check the models available to you and try again.", internal_message="key not allowed to access model. This key can only access models=['internal-models']. " "Tried to access gpt-5.6\r\nWARNING forged log line", type=ProxyErrorTypes.key_model_access_denied, @@ -11022,11 +10972,7 @@ def _http_request_scope(): @pytest.mark.asyncio -async def test_openai_exception_handler_logs_sanitized_model_access_denial(monkeypatch, caplog): - monkeypatch.setattr( - litellm, "model_access_denied_message", "The model `{model}` is unavailable for this API key or does not exist." - ) - +async def test_openai_exception_handler_logs_sanitized_model_access_denial(caplog): with caplog.at_level("WARNING", logger="LiteLLM Proxy"): response = await openai_exception_handler(_http_request_scope(), _model_access_denied_proxy_exception()) @@ -11042,22 +10988,7 @@ async def test_openai_exception_handler_logs_sanitized_model_access_denial(monke @pytest.mark.asyncio -@pytest.mark.parametrize("unset_value", [None, ""]) -async def test_openai_exception_handler_no_denial_log_when_message_not_configured(monkeypatch, unset_value, caplog): - monkeypatch.setattr(litellm, "model_access_denied_message", unset_value) - - with caplog.at_level("WARNING", logger="LiteLLM Proxy"): - response = await openai_exception_handler(_http_request_scope(), _model_access_denied_proxy_exception()) - - assert response.status_code == 403 - assert [r for r in caplog.records if r.levelname == "WARNING" and "internal-models" in r.getMessage()] == [] - - -@pytest.mark.asyncio -async def test_openai_exception_handler_no_denial_log_for_plain_proxy_exception(monkeypatch, caplog): - monkeypatch.setattr( - litellm, "model_access_denied_message", "The model `{model}` is unavailable for this API key or does not exist." - ) +async def test_openai_exception_handler_no_denial_log_for_plain_proxy_exception(caplog): denial = ProxyException( message="Authentication Error, Invalid proxy server token passed", type=ProxyErrorTypes.auth_error, @@ -11073,10 +11004,7 @@ async def test_openai_exception_handler_no_denial_log_for_plain_proxy_exception( @pytest.mark.asyncio -async def test_realtime_model_access_denial_logs_sanitized_internal_message(monkeypatch, caplog): - monkeypatch.setattr( - litellm, "model_access_denied_message", "The model `{model}` is unavailable for this API key or does not exist." - ) +async def test_realtime_model_access_denial_logs_sanitized_internal_message(caplog): reservation = {"reserved_cost": 0.0, "input_cost": 0.0, "finalized": False, "entries": []} with caplog.at_level("WARNING", logger="LiteLLM Proxy"): @@ -11095,43 +11023,6 @@ async def test_realtime_model_access_denial_logs_sanitized_internal_message(monk assert "gpt-5.6WARNING forged log line" in denial_records[0].getMessage() -@pytest.mark.parametrize("empty_value", [None, ""]) -def test_validate_expose_router_debug_in_errors_empty_restores_true_default(empty_value): - from litellm.proxy.proxy_server import _validate_general_settings_ui_litellm_value - - assert _validate_general_settings_ui_litellm_value("expose_router_debug_in_errors", empty_value) is True - assert _validate_general_settings_ui_litellm_value("expose_router_debug_in_errors", False) is False - - -@pytest.mark.parametrize( - "field_name, booted_value, db_value, read_setting", - [ - ( - "model_access_denied_message", - None, - "Model `{model}` is unavailable for this key.", - lambda: litellm.model_access_denied_message, - ), - ("expose_router_debug_in_errors", True, False, lambda: litellm.expose_router_debug_in_errors), - ], -) -def test_model_access_denied_settings_propagate_on_config_reload( - monkeypatch, field_name, booted_value, db_value, read_setting -): - import litellm.proxy.proxy_server as ps - - monkeypatch.setattr(litellm, field_name, booted_value) - assert read_setting() == booted_value - - ps.ProxyConfig()._update_config_fields( - current_config={"litellm_settings": {}}, - param_name="litellm_settings", - db_param_value={field_name: db_value}, - ) - - assert read_setting() == db_value - - def test_general_settings_ui_defaults_unchanged_for_existing_fields(): """The spec-default mechanism added for max_ui_session_budget must not change what clearing the pre-existing fields restores (None for Float/Select, False for Boolean).""" diff --git a/tests/test_litellm/test_router.py b/tests/test_litellm/test_router.py index fb42ab6c893..1e6636ec3d6 100644 --- a/tests/test_litellm/test_router.py +++ b/tests/test_litellm/test_router.py @@ -16424,7 +16424,7 @@ class TestMemberAutoRouterInference: project_id="router-project", team_id="router-team", models=["restricted-model"], ), model_type=LiteLLM_ProjectTableCachedObj, ) - with pytest.raises(ProxyException, match="not allowed to access model"): + with pytest.raises(ProxyException, match="is not available for this API key"): await self._route(self._router(), self._request(actor=self.actor.model_copy(update={ "models": ["member-router"] if ceiling == "key" else self.actor.models, "project_id": "router-project" if ceiling == "project" else None, @@ -16453,7 +16453,7 @@ class TestMemberAutoRouterInference: assert self.database.db.litellm_accessgrouptable.find_unique.await_count == 1 self.database.db.litellm_accessgrouptable.find_unique.return_value = group.model_copy(update={"access_model_names": []}) await evict_and_broadcast(cache_keys=("access_group_id:router-group",), user_api_key_cache=self.cache) - with pytest.raises(ProxyException, match="not allowed to access model"): + with pytest.raises(ProxyException, match="is not available for this API key"): await self._route(router, request) assert self.database.db.litellm_accessgrouptable.find_unique.await_count == 2 @@ -16471,7 +16471,7 @@ class TestMemberAutoRouterInference: key="team_id:router-team", model_type=LiteLLM_TeamTable, value=self.team.model_copy(update={"models": ["member-router"]}), ) - with pytest.raises(ProxyException, match="not allowed to access model"): + with pytest.raises(ProxyException, match="is not available for this API key"): await self._route(router, self._request()) self.database.db.litellm_teamtable.find_unique.reset_mock() admin: Final = self._request(tag="admin") diff --git a/tests/test_openai_endpoints.py b/tests/test_openai_endpoints.py index ab43d1acb00..e8a7732e4cb 100644 --- a/tests/test_openai_endpoints.py +++ b/tests/test_openai_endpoints.py @@ -307,7 +307,7 @@ async def test_chat_completion(): model="gpt-4", messages=[{"role": "user", "content": "Hello!"}], ) - assert "key not allowed to access model." in str(e) + assert "is not available for this API key" in str(e) @pytest.mark.asyncio diff --git a/ui/litellm-dashboard/src/app/(dashboard)/router-settings/_components/general_settings.integration.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/router-settings/_components/general_settings.integration.test.tsx index 2dc6ecf09e5..b4df567e250 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/router-settings/_components/general_settings.integration.test.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/router-settings/_components/general_settings.integration.test.tsx @@ -1,4 +1,4 @@ -import { fireEvent, renderWithProviders, screen, within } from "../../../../../tests/test-utils"; +import { renderWithProviders, screen, within } from "../../../../../tests/test-utils"; import userEvent from "@testing-library/user-event"; import { beforeEach, describe, expect, it, vi } from "vitest"; import GeneralSettings from "./general_settings"; @@ -62,14 +62,6 @@ const SETTINGS_FIXTURE = [ stored_in_db: true, field_default_value: 1.0, }, - { - field_name: "model_access_denied_message", - field_type: "String", - field_value: null, - field_description: "client-facing denial message", - stored_in_db: null, - field_default_value: null, - }, ]; const settingsRow = async (fieldName: string) => { @@ -116,54 +108,6 @@ describe("GeneralSettings General tab", () => { expect(deleteConfigFieldSetting).toHaveBeenCalledWith("token", "max_ui_session_budget"); expect(numericValueIn(row)).toBe(1); }); - - it("saves a typed model_access_denied_message and resets it when cleared", async () => { - const user = userEvent.setup(); - renderWithProviders(); - - await user.click(screen.getByText("General")); - const row = await settingsRow("model_access_denied_message"); - const input = within(row).getByRole("textbox") as HTMLInputElement; - expect(input.value).toBe(""); - - fireEvent.change(input, { target: { value: "Model `{model}` is unavailable for this key." } }); - await user.click(within(row).getByRole("button", { name: /update/i })); - expect(updateConfigFieldSetting).toHaveBeenCalledWith( - "token", - "model_access_denied_message", - "Model `{model}` is unavailable for this key.", - ); - - fireEvent.change(input, { target: { value: "" } }); - await user.click(within(row).getByRole("button", { name: /update/i })); - expect(deleteConfigFieldSetting).toHaveBeenCalledWith("token", "model_access_denied_message"); - expect(vi.mocked(updateConfigFieldSetting).mock.calls).toHaveLength(1); - }); - - it("keeps the stored value visible when the reset request fails", async () => { - vi.mocked(getGeneralSettingsCall).mockResolvedValue( - SETTINGS_FIXTURE.map((s) => - s.field_name === "model_access_denied_message" - ? { ...s, field_value: "Model `{model}` is unavailable.", stored_in_db: true } - : { ...s }, - ), - ); - vi.mocked(deleteConfigFieldSetting).mockRejectedValueOnce(new Error("proxy unreachable")); - const user = userEvent.setup(); - renderWithProviders(); - - await user.click(screen.getByText("General")); - const row = await settingsRow("model_access_denied_message"); - const input = within(row).getByRole("textbox") as HTMLInputElement; - expect(within(row).getByText("In DB")).toBeInTheDocument(); - - fireEvent.change(input, { target: { value: "" } }); - await user.click(within(row).getByRole("button", { name: /update/i })); - - expect(deleteConfigFieldSetting).toHaveBeenCalledWith("token", "model_access_denied_message"); - expect(within(row).getByText("In DB")).toBeInTheDocument(); - expect(within(row).queryByText("Not Set")).not.toBeInTheDocument(); - }); }); describe("GeneralSettings Prompt Caching tab", () => { diff --git a/ui/litellm-dashboard/src/app/(dashboard)/router-settings/_components/general_settings.tsx b/ui/litellm-dashboard/src/app/(dashboard)/router-settings/_components/general_settings.tsx index ca2d80b856a..9a718cbe9b8 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/router-settings/_components/general_settings.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/router-settings/_components/general_settings.tsx @@ -42,8 +42,6 @@ export interface generalSettingsItem { const NUMERIC_INPUT_WIDTH = "w-36"; const toNumericValue = (raw: string): number | null => (raw === "" ? null : Number(raw)); -const toStringValue = (raw: string): string | null => (raw === "" ? null : raw); -const RESETS_WHEN_CLEARED: ReadonlySet = new Set(["Select", "String"]); const SettingValueEditor: React.FC<{ setting: generalSettingsItem; @@ -112,16 +110,6 @@ const SettingValueEditor: React.FC<{ ); } - if (setting.field_type === "String") { - return ( - onChange(setting.field_name, toStringValue(event.target.value))} - /> - ); - } return null; }; @@ -231,7 +219,7 @@ const GeneralSettings: React.FC = ({ accessToken, user setGeneralSettings(updatedSettings); }; - const handleUpdateField = async (fieldName: string) => { + const handleUpdateField = (fieldName: string) => { if (!accessToken) { return; } @@ -240,33 +228,37 @@ const GeneralSettings: React.FC = ({ accessToken, user const fieldValue = setting?.field_value; if (fieldValue == null) { - if (setting && RESETS_WHEN_CLEARED.has(setting.field_type)) await handleResetField(fieldName); + if (setting?.field_type === "Select") handleResetField(fieldName); return; } try { - await updateConfigFieldSetting(accessToken, fieldName, fieldValue); - setGeneralSettings((current) => - current.map((setting) => (setting.field_name === fieldName ? { ...setting, stored_in_db: true } : setting)), + updateConfigFieldSetting(accessToken, fieldName, fieldValue); + // update value in state + + const updatedSettings = generalSettings.map((setting) => + setting.field_name === fieldName ? { ...setting, stored_in_db: true } : setting, ); + setGeneralSettings(updatedSettings); } catch (error) { // do something } }; - const handleResetField = async (fieldName: string) => { + const handleResetField = (fieldName: string) => { if (!accessToken) { return; } try { - await deleteConfigFieldSetting(accessToken, fieldName); - setGeneralSettings((current) => - current.map((setting) => - setting.field_name === fieldName - ? { ...setting, stored_in_db: null, field_value: setting.field_default_value ?? null } - : setting, - ), + deleteConfigFieldSetting(accessToken, fieldName); + // update value in state + + const updatedSettings = generalSettings.map((setting) => + setting.field_name === fieldName + ? { ...setting, stored_in_db: null, field_value: setting.field_default_value ?? null } + : setting, ); + setGeneralSettings(updatedSettings); } catch (error) { // do something } From aa15e9f23f2d5a1fe46ed782d1605c3840a28d04 Mon Sep 17 00:00:00 2001 From: Yuneng Jiang Date: Tue, 15 Sep 2026 18:45:43 -0700 Subject: [PATCH 059/133] test(e2e): reject expired cache entries before physical eviction --- .../test_provider_cache.py | 23 +++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/tests/code_coverage_tests/test_provider_cache.py b/tests/code_coverage_tests/test_provider_cache.py index d0e8c5296dc..9a4cba7f322 100644 --- a/tests/code_coverage_tests/test_provider_cache.py +++ b/tests/code_coverage_tests/test_provider_cache.py @@ -192,6 +192,29 @@ def test_concurrent_requests_publish_atomically(store: RedisResponseStore, provi assert len(provider.hits) == 1 +@pytest.mark.parametrize("age_past_expiry_ms", [0, 1]) +def test_expired_response_is_rejected_without_physical_eviction( + store: RedisResponseStore, age_past_expiry_ms: int, +) -> None: + response_key: Final = store.keys("expired")[0] + retained: Final = store.client.eval( + """ +local clock = redis.call('TIME') +local expires = clock[1] * 1000 + math.floor(clock[2] / 1000) - tonumber(ARGV[1]) +redis.call('HSET', KEYS[1], 'captured', expires - 86400000, 'expires', expires, 'payload', 'old-response') +return redis.call('PTTL', KEYS[1]) +""", + 1, response_key, age_past_expiry_ms, + ) + assert retained == -1 + replacement: Final = store.lookup("expired") + assert isinstance(replacement, CaptureLease) + assert replacement.expires_at_ms - replacement.captured_at_ms == 86_400_000 + assert store.publish("expired", replacement, b"fresh-response") + hit: Final = store.lookup("expired") + assert isinstance(hit, CacheHit) and hit.payload == b"fresh-response" + + @pytest.mark.parametrize("truncated", [False, True]) def test_stream_completion_controls_publication(store: RedisResponseStore, provider: Provider, truncated: bool) -> None: provider.stream = True From c5a388a94cfec0fd06793ddec7012243c0f270bc Mon Sep 17 00:00:00 2001 From: "berriai-litellm-provider-info-sync[bot]" <328147090+berriai-litellm-provider-info-sync[bot]@users.noreply.github.com> Date: Wed, 16 Sep 2026 01:46:17 +0000 Subject: [PATCH 060/133] chore(prices): sync Google Gemini prices: 1 model [enrichment failed: Google Gemini, sync failed: AWS Bedrock, 6 held] gemini/gemini-3.1-pro-preview-customtools: input_cost_per_token_flex, output_cost_per_token_flex, cache_read_input_token_cost_flex --- litellm/model_prices_and_context_window_backup.json | 7 +++++-- model_prices_and_context_window.json | 7 +++++-- 2 files changed, 10 insertions(+), 4 deletions(-) diff --git a/litellm/model_prices_and_context_window_backup.json b/litellm/model_prices_and_context_window_backup.json index 5fb4aad96d2..f6589351cd4 100644 --- a/litellm/model_prices_and_context_window_backup.json +++ b/litellm/model_prices_and_context_window_backup.json @@ -27738,7 +27738,7 @@ "output_cost_per_token_above_200k_tokens": 1.8e-05, "output_cost_per_token_batches": 6e-06, "rpm": 2000, - "source": "https://ai.google.dev/gemini-api/docs/models#gemini-3.1-pro-preview", + "source": "https://ai.google.dev/gemini-api/docs/pricing", "supported_endpoints": [ "/v1/chat/completions", "/v1/completions", @@ -27773,13 +27773,16 @@ "output_cost_per_token_above_200k_tokens_priority": 3.24e-05, "cache_read_input_token_cost_priority": 3.6e-07, "cache_read_input_token_cost_above_200k_tokens_priority": 7.2e-07, + "cache_read_input_token_cost_flex": 2e-07, "search_context_cost_per_query": { "search_context_size_low": 0.014, "search_context_size_medium": 0.014, "search_context_size_high": 0.014 }, "web_search_billing_unit": "per_query", - "google_maps_grounding_cost_per_query": 0.014 + "google_maps_grounding_cost_per_query": 0.014, + "input_cost_per_token_flex": 1e-06, + "output_cost_per_token_flex": 6e-06 }, "gemini-3-flash-preview": { "cache_read_input_audio_token_cost": 1e-07, diff --git a/model_prices_and_context_window.json b/model_prices_and_context_window.json index 5fb4aad96d2..f6589351cd4 100644 --- a/model_prices_and_context_window.json +++ b/model_prices_and_context_window.json @@ -27738,7 +27738,7 @@ "output_cost_per_token_above_200k_tokens": 1.8e-05, "output_cost_per_token_batches": 6e-06, "rpm": 2000, - "source": "https://ai.google.dev/gemini-api/docs/models#gemini-3.1-pro-preview", + "source": "https://ai.google.dev/gemini-api/docs/pricing", "supported_endpoints": [ "/v1/chat/completions", "/v1/completions", @@ -27773,13 +27773,16 @@ "output_cost_per_token_above_200k_tokens_priority": 3.24e-05, "cache_read_input_token_cost_priority": 3.6e-07, "cache_read_input_token_cost_above_200k_tokens_priority": 7.2e-07, + "cache_read_input_token_cost_flex": 2e-07, "search_context_cost_per_query": { "search_context_size_low": 0.014, "search_context_size_medium": 0.014, "search_context_size_high": 0.014 }, "web_search_billing_unit": "per_query", - "google_maps_grounding_cost_per_query": 0.014 + "google_maps_grounding_cost_per_query": 0.014, + "input_cost_per_token_flex": 1e-06, + "output_cost_per_token_flex": 6e-06 }, "gemini-3-flash-preview": { "cache_read_input_audio_token_cost": 1e-07, From 7ed20406d7c2f7df3ebfce179769dd6ce26a5383 Mon Sep 17 00:00:00 2001 From: kerry Date: Wed, 16 Sep 2026 01:47:45 +0000 Subject: [PATCH 061/133] test(responses): narrow the ValueError assertion to satisfy PT011 Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- tests/test_litellm/responses/test_streaming_iterator.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_litellm/responses/test_streaming_iterator.py b/tests/test_litellm/responses/test_streaming_iterator.py index d3849e6ffce..8a76b0bedff 100644 --- a/tests/test_litellm/responses/test_streaming_iterator.py +++ b/tests/test_litellm/responses/test_streaming_iterator.py @@ -801,7 +801,7 @@ async def test_completed_event_survives_a_failing_usage_estimate(): break a stream that previously completed: the estimate is best-effort and falls back to usage None.""" malformed_input: Final = [{"type": "message", "role": "user", "content": 42}] - with pytest.raises(ValueError): + with pytest.raises(ValueError, match="Invalid content type"): _estimate_usage_from_text("gpt-4o-mini", malformed_input, {"input": malformed_input}, "hello world") response = _responses_api_response_without_usage() From b085a3c1517f5db7bacedb74cc552376544d83b6 Mon Sep 17 00:00:00 2001 From: yassin Date: Wed, 16 Sep 2026 01:51:29 +0000 Subject: [PATCH 062/133] fix(mcp): return fixed client message on sampling model access denial Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- .../_experimental/mcp_server/sampling_handler.py | 15 ++++++++------- .../mcp_server/test_mcp_sampling_model_access.py | 4 ++-- 2 files changed, 10 insertions(+), 9 deletions(-) diff --git a/litellm/proxy/_experimental/mcp_server/sampling_handler.py b/litellm/proxy/_experimental/mcp_server/sampling_handler.py index 6072e747bfe..fec2a1f9ee6 100644 --- a/litellm/proxy/_experimental/mcp_server/sampling_handler.py +++ b/litellm/proxy/_experimental/mcp_server/sampling_handler.py @@ -885,13 +885,14 @@ async def _check_model_access(model: str, user_api_key_auth: "UserAPIKeyAuth | N ) return None except Exception as access_err: - verbose_logger.warning( - "MCP sampling: model access denied for model=%s: %s", - model, - access_err.sanitized_internal_message() - if isinstance(access_err, ModelAccessDeniedProxyException) - else access_err, - ) + if isinstance(access_err, ModelAccessDeniedProxyException): + verbose_logger.warning( + "MCP sampling: model access denied for model=%s: %s", + model, + access_err.sanitized_internal_message(), + ) + return ErrorData(code=-1, message=access_err.message) + verbose_logger.warning("MCP sampling: model access denied for model=%s: %s", model, access_err) return ErrorData( code=-1, message=(f"Model access denied: the API key is not authorized to use model '{model}'. {access_err}"), diff --git a/tests/test_litellm/proxy/_experimental/mcp_server/test_mcp_sampling_model_access.py b/tests/test_litellm/proxy/_experimental/mcp_server/test_mcp_sampling_model_access.py index 7eebf1eb436..7c5320ed4f4 100644 --- a/tests/test_litellm/proxy/_experimental/mcp_server/test_mcp_sampling_model_access.py +++ b/tests/test_litellm/proxy/_experimental/mcp_server/test_mcp_sampling_model_access.py @@ -140,6 +140,7 @@ class TestCheckModelAccess: @pytest.mark.asyncio async def test_should_log_internal_denial_reason_and_hide_allowlist_from_client(self, caplog): from litellm.proxy._types import UserAPIKeyAuth + from litellm.proxy.auth.model_access_denied import model_access_denied_client_message auth = UserAPIKeyAuth(api_key="sk-test-key", models=["gpt-3.5-turbo"]) @@ -147,8 +148,7 @@ class TestCheckModelAccess: result = await _check_model_access("gpt-4o\r\nforged", user_api_key_auth=auth) assert result is not None - assert "gpt-4o\r\nforged" in result.message - assert "gpt-3.5-turbo" not in result.message + assert result.message == model_access_denied_client_message(model="gpt-4o\r\nforged") denial_records = [r for r in caplog.records if "gpt-3.5-turbo" in r.getMessage()] assert len(denial_records) == 1 assert "Tried to access gpt-4oforged" in denial_records[0].getMessage() From 97211bc356d47d39214311e7cea432f858d28445 Mon Sep 17 00:00:00 2001 From: Joshua Valluru <326636767+joshua-berri@users.noreply.github.com> Date: Tue, 15 Sep 2026 19:00:47 -0700 Subject: [PATCH 063/133] fix(mcp): authorize per-user OAuth credential writes --- .../mcp_server/bridge_token_flow.py | 64 +++++-- .../mcp_server/discoverable_endpoints.py | 24 ++- .../mcp_server/ui_session_utils.py | 13 ++ litellm/proxy/auth/handle_jwt.py | 172 +++++++++++++----- litellm/proxy/auth/user_api_key_auth.py | 37 +--- .../mcp_management_endpoints.py | 10 +- .../mcp_server/test_discoverable_endpoints.py | 157 +++++++++++++++- .../proxy/auth/test_handle_jwt.py | 8 +- 8 files changed, 369 insertions(+), 116 deletions(-) diff --git a/litellm/proxy/_experimental/mcp_server/bridge_token_flow.py b/litellm/proxy/_experimental/mcp_server/bridge_token_flow.py index b693cc046c8..962e39d7dd6 100644 --- a/litellm/proxy/_experimental/mcp_server/bridge_token_flow.py +++ b/litellm/proxy/_experimental/mcp_server/bridge_token_flow.py @@ -304,24 +304,55 @@ async def _revalidate_active_subject(identity: "EnvelopeIdentity") -> "_KeyResol assert_never(identity.subject_type) -async def _extract_user_id_from_request(request: Request) -> str | None: - """The litellm ``user_id`` for the token request, so a per-user token is stored under the same - identity the egress later reads it by. Storage is best-effort, so every non-resolved outcome - (including a transient DB outage) collapses to ``None`` here and the caller simply skips the store; - the bridge mint, which must status those outcomes differently, consumes - :func:`_resolve_active_litellm_key` directly.""" +async def _extract_user_id_from_request(request: Request, server_id: str | None = None) -> str | None: + """Resolve identity for binding, or authorize the credential-write action for a target server.""" + from litellm.proxy._experimental.mcp_server.mcp_server_manager import ( # noqa: PLC0415 # registry imports auth helpers + global_mcp_server_manager, + ) + from litellm.proxy._experimental.mcp_server.ui_session_utils import ( + can_access_mcp_server, # noqa: PLC0415 # proxy import cycle + ) + from litellm.proxy._types import UserAPIKeyAuth # noqa: PLC0415 # proxy import cycle from litellm.proxy.auth.handle_jwt import JWTHandler # noqa: PLC0415 # proxy import cycle + from litellm.proxy.auth.route_checks import RouteChecks # noqa: PLC0415 # proxy import cycle + from litellm.proxy.auth.user_api_key_auth import ( # noqa: PLC0415 # proxy import cycle + _run_centralized_common_checks, # pyright: ignore[reportPrivateUsage] # reuse admission policy for the credential-write action + ) token: Final = _litellm_key_from_request(request) - if token is not None and JWTHandler.is_jwt(token): - return await _extract_jwt_user_id(request, token) - resolved: Final = await _resolve_active_litellm_key(request) - if not isinstance(resolved, _ResolvedKey): + # The OAuth relay is public; the optional server-side write is the same protected action + # as the direct credential endpoint. Authorize that action without rewriting the Request. + write_route: Final = f"/v1/mcp/server/{server_id}/oauth-user-credential" if server_id is not None else None + resolved: Final = ( + await _resolve_jwt_auth(request, token, write_route) + if token is not None and JWTHandler.is_jwt(token) + else await _resolve_active_litellm_key(request) + ) + auth: Final = resolved.key if isinstance(resolved, _ResolvedKey) else resolved + if not isinstance(auth, UserAPIKeyAuth) or not _active_key_user_id(auth): return None - return _active_key_user_id(resolved.key) + if write_route is not None and server_id is not None: + try: + RouteChecks.is_virtual_key_allowed_to_call_route(route=write_route, valid_token=auth, request=request) + await _run_centralized_common_checks( + user_api_key_auth_obj=auth, + request=request, + request_data={}, + route=write_route, + ) + if not await can_access_mcp_server(auth, server_id, global_mcp_server_manager.get_allowed_mcp_servers): + return None + except Exception as exc: # noqa: BLE001 # authorization failure must never write credentials + verbose_logger.debug("OAuth credential write not authorized (%s)", type(exc).__name__) + return None + return auth.user_id -async def _extract_jwt_user_id(request: Request, token: str) -> str | None: +async def _resolve_jwt_auth( + request: Request, + token: str, + write_route: str | None, +) -> "UserAPIKeyAuth | None": from litellm.proxy._types import UserAPIKeyAuth # noqa: PLC0415 # proxy import cycle from litellm.proxy.auth.handle_jwt import JWTAuthManager # noqa: PLC0415 # proxy import cycle from litellm.proxy.auth.user_api_key_auth import ( # noqa: PLC0415 # proxy import cycle @@ -353,7 +384,7 @@ async def _extract_jwt_user_id(request: Request, token: str) -> str | None: proxy_logging_obj=proxy_logging_obj, ) if isinstance(mapped, UserAPIKeyAuth): - return None if await _key_owner_scim_deactivated(mapped) else _active_key_user_id(mapped) + return None if await _key_owner_scim_deactivated(mapped) or not _active_key_user_id(mapped) else mapped if mapped is not None: return None identity: Final = await JWTAuthManager.auth_builder( @@ -361,19 +392,20 @@ async def _extract_jwt_user_id(request: Request, token: str) -> str | None: jwt_handler=jwt_handler, request_data={}, general_settings=general_settings, - route=request.url.path, + route=write_route or request.url.path, prisma_client=prisma_client, user_api_key_cache=user_api_key_cache, parent_otel_span=None, proxy_logging_obj=proxy_logging_obj, request_headers=dict(request.headers), request_method=request.method, - identity_only=True, + identity_only=write_route is None, + allow_provisioning=False, ) resolved_user: Final = identity["user_object"] if resolved_user is not None and isinstance(_active_user_record(resolved_user), str): return None - return identity["user_id"] + return JWTAuthManager.user_api_key_auth_from_result(identity) except Exception as exc: # noqa: BLE001 # public OAuth exchange stays available; unvalidated identities never write credentials verbose_logger.debug("OAuth JWT identity could not be validated (%s)", type(exc).__name__) return None diff --git a/litellm/proxy/_experimental/mcp_server/discoverable_endpoints.py b/litellm/proxy/_experimental/mcp_server/discoverable_endpoints.py index 94b6348b0f0..56968745ea9 100644 --- a/litellm/proxy/_experimental/mcp_server/discoverable_endpoints.py +++ b/litellm/proxy/_experimental/mcp_server/discoverable_endpoints.py @@ -1218,12 +1218,26 @@ async def exchange_token_with_server( user_id: Final = resolved_user_id if user_id: try: - await _store_per_user_token_server_side( - server=resolved_server, - user_id=user_id, - token_response=token_response, - identity_binding_proof=binding_proof, + # Identity binding above must retain the verified caller even when a write is + # denied. Authorize persistence separately, immediately before its side effect. + can_store: Final = ( + await _user_can_reach_mcp_server(user_id, resolved_server.server_id) + if bridge_identity is not None + else await _extract_user_id_from_request(request, resolved_server.server_id) == user_id ) + if can_store: + await _store_per_user_token_server_side( + server=resolved_server, + user_id=user_id, + token_response=token_response, + identity_binding_proof=binding_proof, + ) + else: + verbose_logger.warning( + "OAuth credential storage not authorized for user=%s server=%s", + user_id, + resolved_server.server_id, + ) except Exception as exc: verbose_logger.warning( "exchange_token_with_server: server-side storage failed for user=%s server=%s: %s", diff --git a/litellm/proxy/_experimental/mcp_server/ui_session_utils.py b/litellm/proxy/_experimental/mcp_server/ui_session_utils.py index 188bfce1484..107a4818de1 100644 --- a/litellm/proxy/_experimental/mcp_server/ui_session_utils.py +++ b/litellm/proxy/_experimental/mcp_server/ui_session_utils.py @@ -2,6 +2,7 @@ from __future__ import annotations +from collections.abc import Awaitable, Callable from typing import Final from fastapi import HTTPException @@ -137,3 +138,15 @@ async def build_effective_auth_contexts( if admitted_context is None: return team_contexts return [*team_contexts, admitted_context] + + +async def can_access_mcp_server( + user_api_key_auth: UserAPIKeyAuth, + server_id: str, + allowed_servers: Callable[[UserAPIKeyAuth], Awaitable[list[str]]], +) -> bool: + """Resolve server access through the same credential contexts as MCP management.""" + for context in await build_effective_auth_contexts(user_api_key_auth): + if server_id in await allowed_servers(context): + return True + return False diff --git a/litellm/proxy/auth/handle_jwt.py b/litellm/proxy/auth/handle_jwt.py index 2d8cfb614ce..f2bdbdd9341 100644 --- a/litellm/proxy/auth/handle_jwt.py +++ b/litellm/proxy/auth/handle_jwt.py @@ -9,6 +9,7 @@ JWT token must have 'litellm_proxy_admin' in scope. from __future__ import annotations import asyncio +import copy import fnmatch import hashlib import os @@ -54,7 +55,7 @@ from litellm.proxy._types import ( from litellm.proxy.auth.auth_checks import can_team_access_model from litellm.proxy.auth.resolvers.grants import GrantResolver, UserLookup, canonical_user_id from litellm.proxy.auth.route_checks import RouteChecks -from litellm.proxy.auth.team_grants import team_model_aliases +from litellm.proxy.auth.team_grants import team_grants, team_model_aliases from litellm.proxy.common_utils.user_api_key_cache import ( UserApiKeyCache, get_management_object_ttl, @@ -2268,36 +2269,49 @@ class JWTAuthManager: request_headers: dict | None = None, request_method: str | None = None, identity_only: bool = False, + allow_provisioning: bool = True, ) -> JWTAuthBuilderResult: """Build JWT authentication and authorization context. - Public OAuth endpoints use identity_only to resolve an existing credential owner - without authorizing the OAuth route or provisioning users/teams. The returned - identity does not grant permission to execute an MCP or model request. + identity_only resolves the caller for OAuth identity binding and grants no permission. + Credential writes use full authorization with allow_provisioning=False: resolve the + existing policy context without creating users/teams or synchronizing membership. + A private handler configuration keeps that restriction out of concurrent normal requests. """ + handler: Final = jwt_handler if allow_provisioning else copy.copy(jwt_handler) + if not allow_provisioning: + handler.update_environment( + prisma_client=jwt_handler.prisma_client, + user_api_key_cache=jwt_handler.user_api_key_cache, + litellm_jwtauth=jwt_handler.litellm_jwtauth.model_copy( + update={"user_id_upsert": False, "team_id_upsert": False, "sync_user_role_and_teams": False} + ), + leeway=jwt_handler.leeway, + ) + # Check if OIDC UserInfo endpoint is enabled, but fall back to standard # JWT auth if the token itself is a well-formed JWT (3-part structure). - if jwt_handler.litellm_jwtauth.oidc_userinfo_enabled and not jwt_handler.is_jwt(token=api_key): + if handler.litellm_jwtauth.oidc_userinfo_enabled and not handler.is_jwt(token=api_key): verbose_proxy_logger.debug("OIDC UserInfo is enabled. Fetching user info from UserInfo endpoint.") # Use the access token to fetch user info from OIDC UserInfo endpoint - jwt_valid_token: dict = await jwt_handler.get_oidc_userinfo(token=api_key) + jwt_valid_token: dict = await handler.get_oidc_userinfo(token=api_key) else: # Default behavior: decode and validate the JWT token - jwt_valid_token = await jwt_handler.auth_jwt(token=api_key) + jwt_valid_token = await handler.auth_jwt(token=api_key) # Check custom validate - if jwt_handler.litellm_jwtauth.custom_validate: - if not jwt_handler.litellm_jwtauth.custom_validate(jwt_valid_token): + if handler.litellm_jwtauth.custom_validate: + if not handler.litellm_jwtauth.custom_validate(jwt_valid_token): raise HTTPException( status_code=403, detail="Invalid JWT token", ) # Check RBAC - rbac_role: Final = jwt_handler.get_rbac_role(token=jwt_valid_token) + rbac_role: Final = handler.get_rbac_role(token=jwt_valid_token) if not identity_only: await JWTAuthManager.check_rbac_role( - jwt_handler, + handler, jwt_valid_token, general_settings, request_data, @@ -2306,30 +2320,30 @@ class JWTAuthManager: ) # Check Scope Based Access - scopes: Final = jwt_handler.get_scopes(token=jwt_valid_token) + scopes: Final = handler.get_scopes(token=jwt_valid_token) if ( not identity_only - and jwt_handler.litellm_jwtauth.enforce_scope_based_access - and jwt_handler.litellm_jwtauth.scope_mappings + and handler.litellm_jwtauth.enforce_scope_based_access + and handler.litellm_jwtauth.scope_mappings ): JWTAuthManager.check_scope_based_access( - scope_mappings=jwt_handler.litellm_jwtauth.scope_mappings, + scope_mappings=handler.litellm_jwtauth.scope_mappings, scopes=scopes, request_data=request_data, general_settings=general_settings, ) - object_id = jwt_handler.get_object_id(token=jwt_valid_token, default_value=None) + object_id = handler.get_object_id(token=jwt_valid_token, default_value=None) # Get basic user info - user_id, user_email, valid_user_email = await JWTAuthManager.get_user_info(jwt_handler, jwt_valid_token) + user_id, user_email, valid_user_email = await JWTAuthManager.get_user_info(handler, jwt_valid_token) # Get IDs - org_id: Final = jwt_handler.get_org_id(token=jwt_valid_token, default_value=None) - end_user_id: Final = jwt_handler.get_end_user_id(token=jwt_valid_token, default_value=None) + org_id: Final = handler.get_org_id(token=jwt_valid_token, default_value=None) + end_user_id: Final = handler.get_end_user_id(token=jwt_valid_token, default_value=None) team_id: str | None = None team_object: LiteLLM_TeamTable | None = None - object_id = jwt_handler.get_object_id(token=jwt_valid_token, default_value=None) + object_id = handler.get_object_id(token=jwt_valid_token, default_value=None) if rbac_role and object_id: if rbac_role == LitellmUserRoles.TEAM: @@ -2338,12 +2352,12 @@ class JWTAuthManager: user_id = object_id agent_id: Final = JWTAuthManager.resolve_agent_id( - jwt_handler=jwt_handler, + jwt_handler=handler, jwt_valid_token=jwt_valid_token, - agent_registry=jwt_handler.agent_lookup, + agent_registry=handler.agent_lookup, ) - if identity_only: + if identity_only or (not allow_provisioning and handler.is_admin(scopes=scopes)): try: identity_user, _, _, _, identity_user_id = await JWTAuthManager.get_objects( user_id=user_id, @@ -2352,7 +2366,7 @@ class JWTAuthManager: end_user_id=None, team_id=None, valid_user_email=valid_user_email, - jwt_handler=jwt_handler, + jwt_handler=handler, prisma_client=prisma_client, user_api_key_cache=user_api_key_cache, parent_otel_span=parent_otel_span, @@ -2361,13 +2375,37 @@ class JWTAuthManager: user_id_upsert=False, ) except UserNotFoundError: - if not jwt_handler.is_admin(scopes=scopes): + if not handler.is_admin(scopes=scopes): raise identity_user, identity_user_id = None, user_id + if not identity_only: + admin: Final = await JWTAuthManager.check_admin_access( + handler, + scopes, + route, + user_id, + org_id, + api_key, + jwt_valid_token, + user_email=user_email, + agent_id=agent_id, + ) + if admin is not None: + await JWTAuthManager._attach_team_from_header_for_admin( + admin_result=admin, + route=route, + request_headers=request_headers, + jwt_handler=handler, + prisma_client=prisma_client, + user_api_key_cache=user_api_key_cache, + parent_otel_span=parent_otel_span, + proxy_logging_obj=proxy_logging_obj, + ) + return {**admin, "user_object": identity_user} return JWTAuthBuilderResult( is_proxy_admin=False, # Admin admission uses the claim ID; other callers use the canonical DB ID. - user_id=user_id if jwt_handler.is_admin(scopes=scopes) else identity_user_id, + user_id=user_id if handler.is_admin(scopes=scopes) else identity_user_id, user_email=identity_user.user_email if identity_user is not None else user_email, user_object=identity_user, team_id=None, @@ -2384,7 +2422,7 @@ class JWTAuthManager: # Check admin access admin_result: Final = await JWTAuthManager.check_admin_access( - jwt_handler, + handler, scopes, route, user_id, @@ -2399,7 +2437,7 @@ class JWTAuthManager: admin_result=admin_result, route=route, request_headers=request_headers, - jwt_handler=jwt_handler, + jwt_handler=handler, prisma_client=prisma_client, user_api_key_cache=user_api_key_cache, parent_otel_span=parent_otel_span, @@ -2409,8 +2447,8 @@ class JWTAuthManager: # Get team with model access ## Check if team_id is specified via x-litellm-team-id header - all_team_ids: Final = JWTAuthManager.get_all_team_ids(jwt_handler, jwt_valid_token) - specific_team_id: Final = jwt_handler.get_team_id(token=jwt_valid_token, default_value=None) + all_team_ids: Final = JWTAuthManager.get_all_team_ids(handler, jwt_valid_token) + specific_team_id: Final = handler.get_team_id(token=jwt_valid_token, default_value=None) # The DB fallback only applies when the token carries no team identity at # all. `get_all_jwt_team_ids` ignores `team_id_default` so a configured @@ -2420,9 +2458,9 @@ class JWTAuthManager: # the RBAC team-role path (which already set `team_id`); otherwise a # provisional x-litellm-team-id header could override an RBAC-asserted team. db_team_fallback: Final = ( - jwt_handler.litellm_jwtauth.fallback_to_db_teams - and not jwt_handler.get_all_jwt_team_ids(token=jwt_valid_token) - and not jwt_handler.get_team_alias(token=jwt_valid_token, default_value=None) + handler.litellm_jwtauth.fallback_to_db_teams + and not handler.get_all_jwt_team_ids(token=jwt_valid_token) + and not handler.get_team_alias(token=jwt_valid_token, default_value=None) and team_id is None ) if specific_team_id and not db_team_fallback: @@ -2447,7 +2485,7 @@ class JWTAuthManager: user_api_key_cache=user_api_key_cache, parent_otel_span=parent_otel_span, proxy_logging_obj=proxy_logging_obj, - team_id_upsert=(jwt_handler.litellm_jwtauth.team_id_upsert and not db_team_fallback), + team_id_upsert=(handler.litellm_jwtauth.team_id_upsert and not db_team_fallback), ) except HTTPException: if not db_team_fallback: @@ -2459,7 +2497,7 @@ class JWTAuthManager: team_id, team_object, ) = await JWTAuthManager.find_and_validate_specific_team_id( - jwt_handler, + handler, jwt_valid_token, prisma_client, user_api_key_cache, @@ -2474,7 +2512,7 @@ class JWTAuthManager: requested_model=request_data.get("model"), route=route, request_method=request_method, - jwt_handler=jwt_handler, + jwt_handler=handler, prisma_client=prisma_client, user_api_key_cache=user_api_key_cache, parent_otel_span=parent_otel_span, @@ -2498,7 +2536,7 @@ class JWTAuthManager: user_api_key_cache=user_api_key_cache, parent_otel_span=parent_otel_span, proxy_logging_obj=proxy_logging_obj, - team_id_upsert=jwt_handler.litellm_jwtauth.team_id_upsert, + team_id_upsert=handler.litellm_jwtauth.team_id_upsert, ) if team_id and not JWTAuthManager._team_has_passthrough_route_access( @@ -2509,7 +2547,7 @@ class JWTAuthManager: JWTAuthManager._raise_team_passthrough_route_denial(route=route) # Extract alias fields for resolution (if configured) - org_alias: Final = jwt_handler.get_org_alias(token=jwt_valid_token, default_value=None) + org_alias: Final = handler.get_org_alias(token=jwt_valid_token, default_value=None) # get_objects returns effective_user_id for downstream spend attribution (GH #26789). ( @@ -2525,7 +2563,7 @@ class JWTAuthManager: end_user_id=end_user_id, team_id=team_id, valid_user_email=valid_user_email, - jwt_handler=jwt_handler, + jwt_handler=handler, prisma_client=prisma_client, user_api_key_cache=user_api_key_cache, parent_otel_span=parent_otel_span, @@ -2538,7 +2576,7 @@ class JWTAuthManager: resolved_org_id: Final = org_object.organization_id if org_object else org_id await JWTAuthManager.sync_user_role_and_teams( - jwt_handler=jwt_handler, + jwt_handler=handler, jwt_valid_token=jwt_valid_token, user_object=user_object, prisma_client=prisma_client, @@ -2556,9 +2594,9 @@ class JWTAuthManager: user_id=user_id, requested_model=request_data.get("model"), route=route, - jwt_handler=jwt_handler, - enforce_team_based_model_access=jwt_handler.litellm_jwtauth.enforce_team_based_model_access, - team_id_upsert=jwt_handler.litellm_jwtauth.team_id_upsert, + jwt_handler=handler, + enforce_team_based_model_access=handler.litellm_jwtauth.enforce_team_based_model_access, + team_id_upsert=handler.litellm_jwtauth.team_id_upsert, prisma_client=prisma_client, user_api_key_cache=user_api_key_cache, parent_otel_span=parent_otel_span, @@ -2586,7 +2624,7 @@ class JWTAuthManager: user_api_key_cache=user_api_key_cache, parent_otel_span=parent_otel_span, proxy_logging_obj=proxy_logging_obj, - team_id_upsert=jwt_handler.litellm_jwtauth.team_id_upsert, + team_id_upsert=handler.litellm_jwtauth.team_id_upsert, ) elif db_team_fallback and team_id == header_team_id: JWTAuthManager._validate_header_team_in_db_membership( @@ -2596,7 +2634,7 @@ class JWTAuthManager: if not JWTAuthManager._is_team_route_allowed( route=route, request_method=request_method, - jwt_handler=jwt_handler, + jwt_handler=handler, ): raise HTTPException( status_code=403, @@ -2606,10 +2644,11 @@ class JWTAuthManager: ) ## MAP USER TO TEAMS - await JWTAuthManager.map_user_to_teams( - user_object=user_object, - team_object=team_object, - ) + if allow_provisioning: + await JWTAuthManager.map_user_to_teams( + user_object=user_object, + team_object=team_object, + ) # Validate that a valid rbac id is returned for spend tracking JWTAuthManager.validate_object_id( @@ -2638,3 +2677,38 @@ class JWTAuthManager: jwt_claims=jwt_valid_token, agent_id=agent_id, ) + + @staticmethod + def user_api_key_auth_from_result( + result: JWTAuthBuilderResult, + parent_otel_span: Span | None = None, + ) -> UserAPIKeyAuth: + """Keep JWT identity and permission attribution identical across consumers.""" + user: Final = result["user_object"] + admin: Final = result["is_proxy_admin"] + return UserAPIKeyAuth( + api_key=None, + user_role=( + LitellmUserRoles.PROXY_ADMIN + if admin + else LitellmUserRoles(user.user_role) + if user is not None and user.user_role is not None + else LitellmUserRoles.INTERNAL_USER + ), + user_id=result["user_id"], + user_email=result["user_email"], + team_id=result["team_id"], + org_id=result["org_id"], + end_user_id=result["end_user_id"], + parent_otel_span=parent_otel_span, + jwt_claims=result["jwt_claims"], + agent_id=result.get("agent_id"), + user_tpm_limit=user.tpm_limit if user is not None and not admin else None, + user_rpm_limit=user.rpm_limit if user is not None and not admin else None, + user_model_max_budget=user.model_max_budget if user is not None and not admin else None, + **team_grants( + team_object=result["team_object"], + team_membership=result.get("team_membership"), + user_id=result["user_id"], + ), + ) diff --git a/litellm/proxy/auth/user_api_key_auth.py b/litellm/proxy/auth/user_api_key_auth.py index c5297ac83dc..ba267114bac 100644 --- a/litellm/proxy/auth/user_api_key_auth.py +++ b/litellm/proxy/auth/user_api_key_auth.py @@ -1669,13 +1669,11 @@ async def _user_api_key_auth_builder( is_proxy_admin: Final = result["is_proxy_admin"] team_id: Final = result["team_id"] - team_object: Final = result["team_object"] user_id: Final = result["user_id"] user_email: Final = result["user_email"] user_object: Final = result["user_object"] end_user_id = result["end_user_id"] org_id: Final = result["org_id"] - team_membership: Final[LiteLLM_TeamMembership | None] = result.get("team_membership", None) jwt_claims = result.get("jwt_claims", None) agent_id: Final[str | None] = result.get("agent_id") @@ -1693,40 +1691,9 @@ async def _user_api_key_auth_builder( value=_JWT_PROXY_ADMIN_SENTINEL, ttl=jwt_handler.litellm_jwtauth.virtual_key_mapping_cache_ttl, ) - return UserAPIKeyAuth( - api_key=None, - user_role=LitellmUserRoles.PROXY_ADMIN, - user_id=user_id, - user_email=user_email, - team_id=team_id, - org_id=org_id, - end_user_id=end_user_id, - parent_otel_span=parent_otel_span, - jwt_claims=jwt_claims, - agent_id=agent_id, - **team_grants(team_object=team_object, team_membership=team_membership, user_id=user_id), - ) + return JWTAuthManager.user_api_key_auth_from_result(result, parent_otel_span) - valid_token = UserAPIKeyAuth( - api_key=None, - team_id=team_id, - user_role=( - LitellmUserRoles(user_object.user_role) - if user_object is not None and user_object.user_role is not None - else LitellmUserRoles.INTERNAL_USER - ), - user_id=user_id, - user_email=user_email, - org_id=org_id, - parent_otel_span=parent_otel_span, - end_user_id=end_user_id, - user_tpm_limit=(user_object.tpm_limit if user_object is not None else None), - user_rpm_limit=(user_object.rpm_limit if user_object is not None else None), - user_model_max_budget=(user_object.model_max_budget if user_object is not None else None), - jwt_claims=jwt_claims, - agent_id=agent_id, - **team_grants(team_object=team_object, team_membership=team_membership, user_id=user_id), - ) + valid_token = JWTAuthManager.user_api_key_auth_from_result(result, parent_otel_span) # AUTO_REGISTER deferred from _resolve_jwt_to_virtual_key. # JWT policy (RBAC, scope, custom_validate, email-domain) diff --git a/litellm/proxy/management_endpoints/mcp_management_endpoints.py b/litellm/proxy/management_endpoints/mcp_management_endpoints.py index 4c97bbaf5de..918a55bb9ce 100644 --- a/litellm/proxy/management_endpoints/mcp_management_endpoints.py +++ b/litellm/proxy/management_endpoints/mcp_management_endpoints.py @@ -170,6 +170,7 @@ if MCP_AVAILABLE: from litellm.proxy._experimental.mcp_server.ui_session_utils import ( admitted_user_context, build_effective_auth_contexts, + can_access_mcp_server, is_ui_session_credential, ) from litellm.proxy._types import ( @@ -2483,10 +2484,11 @@ if MCP_AVAILABLE: ) return server - allowed_server_ids: Final[set[str]] = set() - for auth_context in await build_effective_auth_contexts(user_api_key_dict): - allowed_server_ids.update(await global_mcp_server_manager.get_allowed_mcp_servers(auth_context)) - if server is None or server.server_id not in allowed_server_ids: + if server is None or not await can_access_mcp_server( + user_api_key_dict, + server.server_id, + global_mcp_server_manager.get_allowed_mcp_servers, + ): raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, detail={ diff --git a/tests/test_litellm/proxy/_experimental/mcp_server/test_discoverable_endpoints.py b/tests/test_litellm/proxy/_experimental/mcp_server/test_discoverable_endpoints.py index f6dc7932e2f..8c179eea4cb 100644 --- a/tests/test_litellm/proxy/_experimental/mcp_server/test_discoverable_endpoints.py +++ b/tests/test_litellm/proxy/_experimental/mcp_server/test_discoverable_endpoints.py @@ -11422,6 +11422,7 @@ def _oauth_identity_jwt( issuer: str = "https://idp.example.test", owner: str | None = "jwt-owner", scope: str = "", + claims: dict[str, object] | None = None, ) -> str: import jwt @@ -11434,6 +11435,7 @@ def _oauth_identity_jwt( "aud": audience, "exp": int(time.time()) + expires_in, "scope": scope, + **(claims or {}), }, signing_key, algorithm="RS256", @@ -11443,12 +11445,14 @@ def _oauth_identity_jwt( @pytest.mark.asyncio @pytest.mark.parametrize("header", ["Authorization", "x-litellm-api-key"]) @pytest.mark.parametrize("policy_allowed", [False, True]) +@pytest.mark.parametrize("server_allowed", [False, True]) @pytest.mark.parametrize("admin", [False, True]) @pytest.mark.parametrize("owner_state", ["active", "missing", "inactive", "database_error"]) async def test_oauth_exchange_stores_token_for_validated_jwt_user( jwt_oauth_identity: tuple["JWTHandler", "RSAPrivateKey"], header: str, policy_allowed: bool, + server_allowed: bool, admin: bool, owner_state: str, monkeypatch: pytest.MonkeyPatch, @@ -11461,6 +11465,12 @@ async def test_oauth_exchange_stores_token_for_validated_jwt_user( handler, signing_key = jwt_oauth_identity handler.litellm_jwtauth.custom_validate = lambda claims: policy_allowed + from litellm.proxy._experimental.mcp_server import mcp_server_manager + + manager: Final = MagicMock() + manager.get_allowed_mcp_servers = AsyncMock(return_value=["jwt-oauth-server"] if server_allowed else []) + manager.invalidate_user_oauth_token_cache = AsyncMock() + monkeypatch.setattr(mcp_server_manager, "global_mcp_server_manager", manager) bearer: Final = _oauth_identity_jwt(signing_key, scope="litellm_proxy_admin" if admin else "") request: Final = _token_request({header: f"Bearer {bearer}"}, path="/jwt-oauth-server/token") server: Final = MCPServer( @@ -11525,7 +11535,12 @@ async def test_oauth_exchange_stores_token_for_validated_jwt_user( assert response.status_code == 200 assert json.loads(response.body)["access_token"] == "upstream-token" users.create.assert_not_awaited() - if not policy_allowed or owner_state in ("inactive", "database_error") or (owner_state == "missing" and not admin): + if ( + not server_allowed + or not policy_allowed + or owner_state in ("inactive", "database_error") + or (owner_state == "missing" and not admin) + ): table.upsert.assert_not_awaited() return table.upsert.assert_awaited_once() @@ -11755,9 +11770,7 @@ async def test_oauth_jwt_resolves_canonical_owner_without_cached_identity( table.find_first = AsyncMock(return_value=owner) table.update = AsyncMock(return_value=owner) monkeypatch.setattr(proxy_server, "prisma_client", database) - bearer: Final = _oauth_identity_jwt( - signing_key, owner=external_id, scope="litellm_proxy_admin" if admin else "" - ) + bearer: Final = _oauth_identity_jwt(signing_key, owner=external_id, scope="litellm_proxy_admin" if admin else "") request: Final = _token_request({"Authorization": f"Bearer {bearer}"}) stored_owner: Final = await _extract_user_id_from_request(request) assert stored_owner == (None if inactive else external_id if admin else "canonical-oauth-owner") @@ -11823,3 +11836,139 @@ async def test_oauth_refresh_revalidates_the_same_active_user_rule( monkeypatch.setattr(proxy_server, "prisma_client", None) expected: Final = None if state == "active" else "no_active_key" if state == "inactive" else "unresolvable" assert await _reload_active_user_by_id("jwt-owner") == expected + + +@pytest.mark.asyncio +@pytest.mark.parametrize("mapped", [False, True]) +@pytest.mark.parametrize("state", ["allowed", "route_denied", "server_denied", "blocked", "expired", "lookup_error", "cancelled"]) +async def test_oauth_credential_write_keeps_virtual_key_permissions( + jwt_oauth_identity: tuple["JWTHandler", "RSAPrivateKey"], + monkeypatch: pytest.MonkeyPatch, + mapped: bool, + state: str, +) -> None: + import asyncio + + from litellm.proxy._experimental.mcp_server import mcp_server_manager + from litellm.proxy._experimental.mcp_server.bridge_token_flow import _extract_user_id_from_request + from litellm.proxy._types import UserAPIKeyAuth, hash_token + from litellm.proxy.auth.auth_checks import jwt_key_mapping_cache_key + + handler, signing_key = jwt_oauth_identity + key: Final = "sk-oauth-permission-test" + hashed: Final = hash_token(key) + credential: Final = UserAPIKeyAuth( + token=hashed, + user_id="jwt-owner", + blocked=state == "blocked", + expires=datetime.now(timezone.utc) - timedelta(seconds=60) if state == "expired" else None, + allowed_routes=["openai_routes"] if state == "route_denied" else ["mcp_routes"], + agent_id="agent-scope", + org_id="org-scope", + end_user_id="end-user-scope", + ) + handler.user_api_key_cache.set_cache(hashed, credential) + if mapped: + handler.litellm_jwtauth.virtual_key_claim_field = "sub" + handler.user_api_key_cache.set_cache(jwt_key_mapping_cache_key("sub", "not-the-configured-user-id"), hashed) + manager: Final = MagicMock() + manager.get_allowed_mcp_servers = AsyncMock( + return_value=[] if state == "server_denied" else ["server-a"], + side_effect=(asyncio.CancelledError() if state == "cancelled" else RuntimeError("permission lookup unavailable") if state == "lookup_error" else None), + ) + monkeypatch.setattr(mcp_server_manager, "global_mcp_server_manager", manager) + bearer: Final = _oauth_identity_jwt(signing_key) if mapped else key + request: Final = _token_request({"Authorization": f"Bearer {bearer}"}, path="/server-a/token") + if state == "cancelled": + with pytest.raises(asyncio.CancelledError): + await _extract_user_id_from_request(request, "server-a") + manager.get_allowed_mcp_servers.assert_awaited_once() + return + assert await _extract_user_id_from_request(request, "server-a") == ("jwt-owner" if state == "allowed" else None) + if state in ("allowed", "server_denied", "lookup_error"): + manager.get_allowed_mcp_servers.assert_awaited_once() + writer: Final = manager.get_allowed_mcp_servers.call_args.args[0] + assert (writer.user_id, writer.token, writer.org_id, writer.agent_id, writer.end_user_id) == ( + "jwt-owner", + hashed, + "org-scope", + "agent-scope", + "end-user-scope", + ) + + +@pytest.mark.asyncio +@pytest.mark.parametrize("server_id", ["team-a-server", "team-b-server"]) +async def test_oauth_writer_preserves_claimed_team_instead_of_expanding_user_roster( + jwt_oauth_identity: tuple["JWTHandler", "RSAPrivateKey"], + monkeypatch: pytest.MonkeyPatch, + server_id: str, +) -> None: + from litellm.models.user import LiteLLM_UserTable + from litellm.proxy import proxy_server + from litellm.proxy._experimental.mcp_server import mcp_server_manager + from litellm.proxy._experimental.mcp_server.bridge_token_flow import _extract_user_id_from_request + from litellm.proxy._types import LiteLLM_TeamTable, Member + + handler, signing_key = jwt_oauth_identity + handler.litellm_jwtauth.team_id_jwt_field = "team" + handler.litellm_jwtauth.team_id_upsert = True + handler.litellm_jwtauth.user_id_upsert = True + handler.litellm_jwtauth.sync_user_role_and_teams = True + handler.user_api_key_cache.set_cache("jwt-owner", LiteLLM_UserTable(user_id="jwt-owner", teams=["a", "b"])) + handler.user_api_key_cache.set_cache( + "team_id:a", + LiteLLM_TeamTable(team_id="a", models=[], members_with_roles=[Member(user_id="jwt-owner", role="user")]), + ) + manager: Final = MagicMock() + manager.get_allowed_mcp_servers = AsyncMock(return_value=["team-a-server"]) + monkeypatch.setattr(mcp_server_manager, "global_mcp_server_manager", manager) + bearer: Final = _oauth_identity_jwt(signing_key, claims={"team": "a"}) + request: Final = _token_request({"Authorization": f"Bearer {bearer}"}, path=f"/{server_id}/token") + assert await _extract_user_id_from_request(request, server_id) == ( + "jwt-owner" if server_id == "team-a-server" else None + ) + manager.get_allowed_mcp_servers.assert_awaited_once() + writer: Final = manager.get_allowed_mcp_servers.call_args.args[0] + assert writer.team_id == "a" + assert not writer.mcp_admitted_user_subject + proxy_server.prisma_client.db.litellm_teamtable.create.assert_not_called() + proxy_server.prisma_client.db.litellm_usertable.create.assert_not_called() + proxy_server.prisma_client.db.litellm_usertable.update.assert_not_called() + assert handler.litellm_jwtauth.user_id_upsert and handler.litellm_jwtauth.team_id_upsert + assert handler.litellm_jwtauth.sync_user_role_and_teams + + +@pytest.mark.asyncio +async def test_oauth_write_denial_does_not_erase_identity_binding( + jwt_oauth_identity: tuple["JWTHandler", "RSAPrivateKey"], monkeypatch: pytest.MonkeyPatch, +) -> None: + from litellm.proxy._experimental.mcp_server import discoverable_endpoints, mcp_server_manager + from litellm.proxy._types import MCPTransport + from litellm.types.mcp_server.mcp_server_manager import MCPOAuthIdentityBinding, MCPServer + + _, signing_key = jwt_oauth_identity + monkeypatch.setenv("LITELLM_SALT_KEY", "oauth-identity-binding-test-salt") + manager: Final = MagicMock() + manager.get_allowed_mcp_servers = AsyncMock(return_value=[]) + monkeypatch.setattr(mcp_server_manager, "global_mcp_server_manager", manager) + server: Final = MCPServer( + server_id="bound-server", name="bound-server", transport=MCPTransport.http, + auth_type=MCPAuth.oauth2, oauth2_flow="authorization_code", client_id="client", + token_url="https://upstream.example.test/token", + oauth_identity_binding=MCPOAuthIdentityBinding( + mode="enforce", issuer="https://upstream.example.test", audiences=["client"], + ), + ) + request: Final = _token_request({"Authorization": f"Bearer {_oauth_identity_jwt(signing_key)}"}) + code: Final = discoverable_endpoints.seal_bridge_authorization_code( + "upstream-code", "another-owner", server.server_id, "bound-nonce", + ) + with pytest.raises(HTTPException) as denied: + await discoverable_endpoints.exchange_token_with_server( + request=request, mcp_server=server, grant_type="authorization_code", code=code, + redirect_uri="http://localhost/callback", client_id="client", client_secret=None, code_verifier="verifier", + ) + assert denied.value.status_code == 403 + assert denied.value.detail == {"error": "oauth_principal_mismatch"} + manager.get_allowed_mcp_servers.assert_not_awaited() diff --git a/tests/test_litellm/proxy/auth/test_handle_jwt.py b/tests/test_litellm/proxy/auth/test_handle_jwt.py index 4d86e395358..babbf88dc29 100644 --- a/tests/test_litellm/proxy/auth/test_handle_jwt.py +++ b/tests/test_litellm/proxy/auth/test_handle_jwt.py @@ -6792,10 +6792,11 @@ async def test_sync_user_role_and_teams_singular_claim_only_recognized_under_fla @pytest.mark.asyncio @pytest.mark.parametrize("identity_only", [False, True]) +@pytest.mark.parametrize("allow_provisioning", [False, True]) @pytest.mark.parametrize("existing_user", [False, True]) @pytest.mark.parametrize("model_allowed", [False, True]) async def test_auth_builder_identity_lookup_does_not_provision_users( - monkeypatch: pytest.MonkeyPatch, identity_only: bool, existing_user: bool, model_allowed: bool + monkeypatch: pytest.MonkeyPatch, identity_only: bool, allow_provisioning: bool, existing_user: bool, model_allowed: bool ) -> None: from litellm.proxy._types import ScopeMapping from litellm.proxy.auth.auth_checks import UserNotFoundError @@ -6841,6 +6842,7 @@ async def test_auth_builder_identity_lookup_does_not_provision_users( parent_otel_span=None, proxy_logging_obj=MagicMock(), identity_only=identity_only, + allow_provisioning=allow_provisioning, ) if not identity_only and not model_allowed: with pytest.raises(HTTPException) as denial: @@ -6848,7 +6850,7 @@ async def test_auth_builder_identity_lookup_does_not_provision_users( assert denial.value.status_code == 403 users.create.assert_not_awaited() return - if identity_only and not existing_user: + if (identity_only or not allow_provisioning) and not existing_user: with pytest.raises(UserNotFoundError): await pending else: @@ -6856,7 +6858,7 @@ async def test_auth_builder_identity_lookup_does_not_provision_users( assert result["user_id"] == user_id assert result["user_object"] is not None assert result["user_object"].user_id == user_id - assert users.create.await_count == (0 if identity_only or existing_user else 1) + assert users.create.await_count == (0 if identity_only or not allow_provisioning or existing_user else 1) def _entra_agent_registry() -> AgentRegistry: From a6228fab66f289b9e8014c4fc75646eae821d1aa Mon Sep 17 00:00:00 2001 From: kerry Date: Wed, 16 Sep 2026 02:03:21 +0000 Subject: [PATCH 064/133] fix(anthropic): write usage into copies of the read-only message_delta chunk Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- .../adapters/streaming_iterator.py | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/litellm/llms/anthropic/experimental_pass_through/adapters/streaming_iterator.py b/litellm/llms/anthropic/experimental_pass_through/adapters/streaming_iterator.py index e7179aad25b..4486eb0985a 100644 --- a/litellm/llms/anthropic/experimental_pass_through/adapters/streaming_iterator.py +++ b/litellm/llms/anthropic/experimental_pass_through/adapters/streaming_iterator.py @@ -376,10 +376,9 @@ class AnthropicStreamWrapper(AdapterCompletionStreamWrapper): usage_dict: UsageDelta = LiteLLMAnthropicMessagesAdapter._translate_openai_usage_to_anthropic_usage_delta( chunk.usage ) - merged_chunk["usage"] = usage_dict if self.applied_edits and "context_management" not in merged_chunk: merged_chunk["context_management"] = ContextManagementResponse(applied_edits=list(self.applied_edits)) - return self._augment_message_delta_usage(merged_chunk) + return self._augment_message_delta_usage({**merged_chunk, "usage": usage_dict}) def _handle_choiceless_chunk(self, chunk: "ModelResponseStream") -> bool: """Consume an OpenAI-compatible chunk that carries no ``choices``. @@ -448,8 +447,7 @@ class AnthropicStreamWrapper(AdapterCompletionStreamWrapper): } iterations.append(message_iteration) augmented_usage["iterations"] = iterations - augmented["usage"] = augmented_usage - return augmented + return {**augmented, "usage": augmented_usage} def _next_compaction_event(self) -> dict[str, object] | None: """Return the next compaction content-block SSE event, or ``None``. From 7680d3de863648b500fc605d0eaa0c894d0a6bf4 Mon Sep 17 00:00:00 2001 From: kerry Date: Wed, 16 Sep 2026 02:05:44 +0000 Subject: [PATCH 065/133] fix(responses): tolerate dict terminal responses when estimating usage Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- litellm/responses/streaming_iterator.py | 35 ++++++++-------- .../responses/test_streaming_iterator.py | 42 +++++++++++++++++++ 2 files changed, 60 insertions(+), 17 deletions(-) diff --git a/litellm/responses/streaming_iterator.py b/litellm/responses/streaming_iterator.py index df3c26c1a4f..10ddf07ac7e 100644 --- a/litellm/responses/streaming_iterator.py +++ b/litellm/responses/streaming_iterator.py @@ -428,24 +428,25 @@ class BaseResponsesAPIStreamingIterator: openai_types.ResponsesAPIStreamEvents.RESPONSE_FAILED, ): self.completed_response = openai_responses_api_chunk - _response_obj: Final[ResponsesAPIResponse | None] = getattr( - openai_responses_api_chunk, "response", None - ) - if ( - _chunk_type - in ( - openai_types.ResponsesAPIStreamEvents.RESPONSE_COMPLETED, - openai_types.ResponsesAPIStreamEvents.RESPONSE_INCOMPLETE, - ) - and _response_obj is not None - and _response_obj.usage is None + _response_obj: Final[object] = getattr(openai_responses_api_chunk, "response", None) + if _chunk_type in ( + openai_types.ResponsesAPIStreamEvents.RESPONSE_COMPLETED, + openai_types.ResponsesAPIStreamEvents.RESPONSE_INCOMPLETE, ): - _response_obj.usage = _estimate_usage_safely( - self.model or "", - self.request_data.get("input"), - self.request_data, - self._generated_content + self._generated_tool_arguments, - ) + if isinstance(_response_obj, ResponsesAPIResponse) and _response_obj.usage is None: + _response_obj.usage = _estimate_usage_safely( + self.model or "", + self.request_data.get("input"), + self.request_data, + self._generated_content + self._generated_tool_arguments, + ) + elif isinstance(_response_obj, dict) and _response_obj.get("usage") is None: # pyright: ignore[reportUnknownMemberType] # the model_constructed terminal event leaves response as an untyped dict + _response_obj["usage"] = _estimate_usage_safely( + self.model or "", + self.request_data.get("input"), + self.request_data, + self._generated_content + self._generated_tool_arguments, + ) _stamp_responses_usage_cost(getattr(openai_responses_api_chunk, "response", None), self.logging_obj) if _chunk_type == openai_types.ResponsesAPIStreamEvents.RESPONSE_FAILED: diff --git a/tests/test_litellm/responses/test_streaming_iterator.py b/tests/test_litellm/responses/test_streaming_iterator.py index 8a76b0bedff..8cf9556761e 100644 --- a/tests/test_litellm/responses/test_streaming_iterator.py +++ b/tests/test_litellm/responses/test_streaming_iterator.py @@ -849,3 +849,45 @@ async def test_completed_event_without_usage_counts_tool_input_deltas(tool_delta assert usage is not None assert usage.output_tokens > 0 assert usage.total_tokens == usage.input_tokens + usage.output_tokens + + +@pytest.mark.asyncio +async def test_completed_event_with_a_dict_response_still_gets_the_usage_estimate(): + """transform_streaming_response can model_construct a terminal event whose + response stays a plain dict; the estimate must fill it without raising.""" + dict_response: Final = { + "id": "resp_dict", + "model": "gpt-4o-mini", + "object": "response", + "output": [], + "usage": None, + } + + def _transform(model, parsed_chunk, logging_obj): + if parsed_chunk.get("type") == "response.completed": + return ResponseCompletedEvent.model_construct(type="response.completed", response=dict_response) + stub: Final = Mock() + stub.type = parsed_chunk.get("type") + if "delta" in parsed_chunk: + stub.delta = parsed_chunk.get("delta") + return stub + + config: Final = Mock(spec=BaseResponsesAPIConfig) + config.transform_streaming_response.side_effect = _transform + iterator: Final = _make_iterator( + sse_events=[ + _sse_event({"type": "response.output_text.delta", "delta": "hello world"}), + _sse_event({"type": "response.completed", "response": {}}), + ], + logging_obj=_logging_obj_stub(), + config=config, + request_data={"input": "count these input tokens please"}, + ) + + async for _ in iterator: + pass + + usage: Final = iterator.completed_response.response["usage"] + assert usage is not None + assert usage.input_tokens > 0 + assert usage.output_tokens > 0 From c7e4160ee6ce40493e5e3d896a30ac1668a55c70 Mon Sep 17 00:00:00 2001 From: Joshua Valluru <326636767+joshua-berri@users.noreply.github.com> Date: Tue, 15 Sep 2026 19:15:34 -0700 Subject: [PATCH 066/133] fix(mcp): enforce OAuth write policy across signed callbacks --- .../mcp_server/bridge_token_flow.py | 54 ++++---- .../mcp_server/discoverable_endpoints.py | 58 +++++---- .../test_user_api_key_auth.py | 1 + .../mcp_server/test_discoverable_endpoints.py | 118 +++++++++++++++++- 4 files changed, 181 insertions(+), 50 deletions(-) diff --git a/litellm/proxy/_experimental/mcp_server/bridge_token_flow.py b/litellm/proxy/_experimental/mcp_server/bridge_token_flow.py index 962e39d7dd6..5fe0929773c 100644 --- a/litellm/proxy/_experimental/mcp_server/bridge_token_flow.py +++ b/litellm/proxy/_experimental/mcp_server/bridge_token_flow.py @@ -306,18 +306,8 @@ async def _revalidate_active_subject(identity: "EnvelopeIdentity") -> "_KeyResol async def _extract_user_id_from_request(request: Request, server_id: str | None = None) -> str | None: """Resolve identity for binding, or authorize the credential-write action for a target server.""" - from litellm.proxy._experimental.mcp_server.mcp_server_manager import ( # noqa: PLC0415 # registry imports auth helpers - global_mcp_server_manager, - ) - from litellm.proxy._experimental.mcp_server.ui_session_utils import ( - can_access_mcp_server, # noqa: PLC0415 # proxy import cycle - ) from litellm.proxy._types import UserAPIKeyAuth # noqa: PLC0415 # proxy import cycle from litellm.proxy.auth.handle_jwt import JWTHandler # noqa: PLC0415 # proxy import cycle - from litellm.proxy.auth.route_checks import RouteChecks # noqa: PLC0415 # proxy import cycle - from litellm.proxy.auth.user_api_key_auth import ( # noqa: PLC0415 # proxy import cycle - _run_centralized_common_checks, # pyright: ignore[reportPrivateUsage] # reuse admission policy for the credential-write action - ) token: Final = _litellm_key_from_request(request) # The OAuth relay is public; the optional server-side write is the same protected action @@ -331,23 +321,39 @@ async def _extract_user_id_from_request(request: Request, server_id: str | None auth: Final = resolved.key if isinstance(resolved, _ResolvedKey) else resolved if not isinstance(auth, UserAPIKeyAuth) or not _active_key_user_id(auth): return None - if write_route is not None and server_id is not None: - try: - RouteChecks.is_virtual_key_allowed_to_call_route(route=write_route, valid_token=auth, request=request) - await _run_centralized_common_checks( - user_api_key_auth_obj=auth, - request=request, - request_data={}, - route=write_route, - ) - if not await can_access_mcp_server(auth, server_id, global_mcp_server_manager.get_allowed_mcp_servers): - return None - except Exception as exc: # noqa: BLE001 # authorization failure must never write credentials - verbose_logger.debug("OAuth credential write not authorized (%s)", type(exc).__name__) - return None + if server_id is not None and not await can_store_oauth_credential(request, auth, server_id): + return None return auth.user_id +async def can_store_oauth_credential(request: Request, auth: "UserAPIKeyAuth", server_id: str) -> bool: + """Apply the same write policy to request credentials and verified signed-callback users.""" + from litellm.proxy._experimental.mcp_server.mcp_server_manager import ( # noqa: PLC0415 # registry imports auth helpers + global_mcp_server_manager, + ) + from litellm.proxy._experimental.mcp_server.ui_session_utils import ( + can_access_mcp_server, # noqa: PLC0415 # proxy import cycle + ) + from litellm.proxy.auth.route_checks import RouteChecks # noqa: PLC0415 # proxy import cycle + from litellm.proxy.auth.user_api_key_auth import ( # noqa: PLC0415 # proxy import cycle + _run_centralized_common_checks, # pyright: ignore[reportPrivateUsage] # reuse admission policy for the credential-write action + ) + + write_route: Final = f"/v1/mcp/server/{server_id}/oauth-user-credential" + try: + RouteChecks.is_virtual_key_allowed_to_call_route(route=write_route, valid_token=auth, request=request) + await _run_centralized_common_checks( + user_api_key_auth_obj=auth, + request=request, + request_data={}, + route=write_route, + ) + return await can_access_mcp_server(auth, server_id, global_mcp_server_manager.get_allowed_mcp_servers) + except Exception as exc: # noqa: BLE001 # authorization failure must never write credentials + verbose_logger.debug("OAuth credential write not authorized (%s)", type(exc).__name__) + return False + + async def _resolve_jwt_auth( request: Request, token: str, diff --git a/litellm/proxy/_experimental/mcp_server/discoverable_endpoints.py b/litellm/proxy/_experimental/mcp_server/discoverable_endpoints.py index 56968745ea9..9ba67f966a2 100644 --- a/litellm/proxy/_experimental/mcp_server/discoverable_endpoints.py +++ b/litellm/proxy/_experimental/mcp_server/discoverable_endpoints.py @@ -29,9 +29,11 @@ from litellm.proxy._experimental.mcp_server.bridge_token_flow import ( _BridgeRefreshReady, _extract_user_id_from_request, _finish_bridge_mint, + _litellm_key_from_request, # pyright: ignore[reportPrivateUsage] # shared credential precedence for authorization issuance _prepare_bridge_mint, _prepare_bridge_refresh, _reload_active_user_by_id, + can_store_oauth_credential, ) from litellm.proxy._experimental.mcp_server.faults import ( CallerRejected, @@ -836,16 +838,29 @@ async def _user_can_reach_mcp_server(user_id: str, server_id: str) -> bool: return server_id in await global_mcp_server_manager.get_allowed_mcp_servers(admitted) -async def _bridge_authorize_access_denial( - litellm_user_id: str, +async def _resolve_oauth_authorization_user( + request: Request, mcp_server: MCPServer, redirect_uri: str, state: str, -) -> RedirectResponse | None: - """The denial redirect for a signed-in user who cannot reach the target server, or None to proceed.""" - if await _user_can_reach_mcp_server(litellm_user_id, mcp_server.server_id): - return None - return _bridge_access_denied_redirect(redirect_uri, state, mcp_server) + enforce_binding: bool, +) -> str | RedirectResponse: + """Resolve the authorization subject without replacing denied credentials with cookie grants.""" + from litellm.proxy._experimental.mcp_server.byok_oauth_endpoints import ( # noqa: PLC0415 # proxy import cycle + _user_id_from_session_cookie, + ) + + request_user_id: Final = ( + await _extract_user_id_from_request(request, mcp_server.server_id) if enforce_binding else None + ) + if enforce_binding and request_user_id is None and _litellm_key_from_request(request): + return _bridge_access_denied_redirect(redirect_uri, state, mcp_server) + user_id: Final = request_user_id or _user_id_from_session_cookie(request) + if user_id is None: + return _redirect_to_litellm_login(request) + if not await _user_can_reach_mcp_server(user_id, mcp_server.server_id): + return _bridge_access_denied_redirect(redirect_uri, state, mcp_server) + return user_id async def authorize_with_server( @@ -911,23 +926,12 @@ async def authorize_with_server( # Seal the authenticated caller into state so the token exchange cannot select another credential owner. litellm_user_id: str | None = None if enforce_binding or (resolved_server.is_dcr_bridge and resolved_server.is_oauth_delegate): - from litellm.proxy._experimental.mcp_server.byok_oauth_endpoints import ( # noqa: PLC0415 # inline import avoids a module-load circular import - _user_id_from_session_cookie, + subject: Final = await _resolve_oauth_authorization_user( + request, resolved_server, redirect_uri, state, enforce_binding ) - - litellm_user_id = ( - await _extract_user_id_from_request(request) if enforce_binding else None - ) or _user_id_from_session_cookie(request) - if litellm_user_id is None: - return _redirect_to_litellm_login(request) - denial: Final = await _bridge_authorize_access_denial( - litellm_user_id=litellm_user_id, - mcp_server=resolved_server, - redirect_uri=redirect_uri, - state=state, - ) - if denial is not None: - return denial + if isinstance(subject, RedirectResponse): + return subject + litellm_user_id = subject oauth_nonce: Final = secrets.token_urlsafe(32) if enforce_binding else None encoded_state: Final = encode_state_with_base_url( @@ -1220,8 +1224,14 @@ async def exchange_token_with_server( try: # Identity binding above must retain the verified caller even when a write is # denied. Authorize persistence separately, immediately before its side effect. + from litellm.proxy._experimental.mcp_server.auth.user_api_key_auth_mcp import MCPRequestHandler + + # A sealed code delegates a verified user for this authorized server. Raw + # request credentials retain their own JWT/key restrictions during resolution. can_store: Final = ( - await _user_can_reach_mcp_server(user_id, resolved_server.server_id) + await can_store_oauth_credential( + request, await MCPRequestHandler.reload_admitted_user(user_id), resolved_server.server_id + ) if bridge_identity is not None else await _extract_user_id_from_request(request, resolved_server.server_id) == user_id ) diff --git a/tests/proxy_unit_tests/test_user_api_key_auth.py b/tests/proxy_unit_tests/test_user_api_key_auth.py index 0cdf3500d50..a8fce58c60b 100644 --- a/tests/proxy_unit_tests/test_user_api_key_auth.py +++ b/tests/proxy_unit_tests/test_user_api_key_auth.py @@ -1069,6 +1069,7 @@ async def test_jwt_non_admin_team_route_access(monkeypatch): mock_jwt_response = { "is_proxy_admin": False, + "jwt_claims": {}, "team_id": None, "team_object": None, "user_id": None, diff --git a/tests/test_litellm/proxy/_experimental/mcp_server/test_discoverable_endpoints.py b/tests/test_litellm/proxy/_experimental/mcp_server/test_discoverable_endpoints.py index 8c179eea4cb..1739ac6d743 100644 --- a/tests/test_litellm/proxy/_experimental/mcp_server/test_discoverable_endpoints.py +++ b/tests/test_litellm/proxy/_experimental/mcp_server/test_discoverable_endpoints.py @@ -11171,8 +11171,8 @@ async def test_identity_bound_authorization_carries_nonce_and_caller_through_cal "litellm.proxy._experimental.mcp_server.discoverable_endpoints._extract_user_id_from_request", new=AsyncMock(return_value="alice")), patch( # test-quality-ok: isolate user access lookup while testing nonce and caller preservation - "litellm.proxy._experimental.mcp_server.discoverable_endpoints._bridge_authorize_access_denial", - new=AsyncMock(return_value=None)), + "litellm.proxy._experimental.mcp_server.discoverable_endpoints._user_can_reach_mcp_server", + new=AsyncMock(return_value=True)), ): authorized = await authorize_with_server( request, server, "client", "http://127.0.0.1:6274/callback", state="client-state", @@ -11972,3 +11972,117 @@ async def test_oauth_write_denial_does_not_erase_identity_binding( assert denied.value.status_code == 403 assert denied.value.detail == {"error": "oauth_principal_mismatch"} manager.get_allowed_mcp_servers.assert_not_awaited() + + +@pytest.mark.asyncio +@pytest.mark.parametrize("admin_only", [False, True]) +async def test_signed_oauth_callback_honors_credential_write_policy( + jwt_oauth_identity: tuple["JWTHandler", "RSAPrivateKey"], + monkeypatch: pytest.MonkeyPatch, + admin_only: bool, +) -> None: + import httpx + import litellm + + from litellm.caching.llm_caching_handler import LLMClientCache + from litellm.llms.custom_httpx.http_handler import AsyncHTTPHandler + from litellm.proxy import proxy_server + from litellm.proxy._experimental.mcp_server import discoverable_endpoints, mcp_server_manager + from litellm.proxy._types import MCPTransport + from litellm.types.llms.custom_http import httpxSpecialProvider + from litellm.types.mcp_server.mcp_server_manager import MCPServer + + server: Final = MCPServer( + server_id="signed-server", name="signed-server", transport=MCPTransport.http, + auth_type=MCPAuth.oauth2, oauth2_flow="authorization_code", client_id="client", + token_url="https://upstream.example.test/token", + ) + monkeypatch.setattr(proxy_server, "general_settings", { + "enable_jwt_auth": True, + "admin_only_routes": [f"/v1/mcp/server/{server.server_id}/oauth-user-credential"] if admin_only else [], + }) + monkeypatch.setenv("LITELLM_SALT_KEY", "signed-oauth-test-salt") + manager: Final = MagicMock() + manager.get_allowed_mcp_servers = AsyncMock(return_value=[server.server_id]) + manager.invalidate_user_oauth_token_cache = AsyncMock() + monkeypatch.setattr(mcp_server_manager, "global_mcp_server_manager", manager) + table: Final = proxy_server.prisma_client.db.litellm_mcpusercredentials + table.find_unique = AsyncMock(return_value=None) + table.upsert = AsyncMock() + clients: Final = LLMClientCache() + monkeypatch.setattr(litellm, "in_memory_llm_clients_cache", clients) + + def upstream_response(outbound: httpx.Request) -> httpx.Response: + assert outbound.url == server.token_url + assert b"code=upstream-code" in outbound.content + return httpx.Response(200, json={"access_token": "upstream-token", "token_type": "Bearer"}) + + async with httpx.AsyncClient(transport=httpx.MockTransport(upstream_response)) as transport: + upstream: Final = AsyncHTTPHandler() + await upstream.client.aclose() + upstream.client = transport + clients.set_cache("async_httpx_client" + httpxSpecialProvider.Oauth2Check, upstream) + response: Final = await discoverable_endpoints.exchange_token_with_server( + request=_token_request({}, path="/signed-server/token"), mcp_server=server, + grant_type="authorization_code", + code=discoverable_endpoints.seal_bridge_authorization_code("upstream-code", "jwt-owner", server.server_id), + redirect_uri="http://localhost/callback", client_id="client", client_secret=None, code_verifier=None, + ) + assert response.status_code == 200 + assert json.loads(response.body)["access_token"] == "upstream-token" + if admin_only: + table.upsert.assert_not_awaited() + else: + table.upsert.assert_awaited_once() + assert table.upsert.call_args.kwargs["where"]["user_id_server_id"] == { + "user_id": "jwt-owner", "server_id": server.server_id, + } + + +@pytest.mark.asyncio +@pytest.mark.parametrize("allowed", [False, True]) +async def test_identity_bound_authorize_preserves_presented_jwt_permissions( + jwt_oauth_identity: tuple["JWTHandler", "RSAPrivateKey"], + monkeypatch: pytest.MonkeyPatch, + allowed: bool, +) -> None: + from urllib.parse import parse_qs, urlparse + + from litellm.proxy._experimental.mcp_server import byok_oauth_endpoints, discoverable_endpoints, mcp_server_manager + from litellm.proxy._types import MCPTransport + from litellm.types.mcp_server.mcp_server_manager import MCPOAuthIdentityBinding, MCPServer + + _, signing_key = jwt_oauth_identity + monkeypatch.setenv("LITELLM_SALT_KEY", "authorize-policy-test-salt") + server: Final = MCPServer( + server_id="bound-server", name="bound-server", transport=MCPTransport.http, + auth_type=MCPAuth.oauth2, oauth2_flow="authorization_code", client_id="client", + authorization_url="https://upstream.example.test/authorize", token_url="https://upstream.example.test/token", + oauth_identity_binding=MCPOAuthIdentityBinding( + mode="enforce", issuer="https://upstream.example.test", audiences=["client"], + ), + ) + manager: Final = MagicMock() + # The full user roster permits the server; the presented JWT may have narrower access. + manager.get_allowed_mcp_servers = AsyncMock( + side_effect=lambda auth: [server.server_id] if allowed or auth.mcp_admitted_user_subject else [], + ) + monkeypatch.setattr(mcp_server_manager, "global_mcp_server_manager", manager) + monkeypatch.setattr( # test-quality-ok: session-cookie decoder is the separate authentication boundary; a valid cookie must not override a denied explicit credential + byok_oauth_endpoints, "_user_id_from_session_cookie", lambda request: "jwt-owner", + ) + response: Final = await discoverable_endpoints.authorize_with_server( + request=_token_request({"Authorization": f"Bearer {_oauth_identity_jwt(signing_key)}"}), + mcp_server=server, client_id="client", redirect_uri="http://127.0.0.1:6274/callback", + state="client-state", code_challenge="pkce-challenge", code_challenge_method="S256", + ) + redirect: Final = urlparse(response.headers["location"]) + query: Final = parse_qs(redirect.query) + if allowed: + assert redirect.hostname == "upstream.example.test" + assert query["nonce"] and response.headers.get("set-cookie") + else: + assert redirect.hostname == "127.0.0.1" + assert query["error"] == ["access_denied"] + assert query["state"] == ["client-state"] + assert "set-cookie" not in response.headers From d94b9d117d70e42866948c2986a9ea0c3a4533e6 Mon Sep 17 00:00:00 2001 From: Yuneng Jiang Date: Tue, 15 Sep 2026 19:16:45 -0700 Subject: [PATCH 067/133] docs(e2e): clarify recorded IDs and quota header semantics --- tests/e2e/PROVIDER_CACHE.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/tests/e2e/PROVIDER_CACHE.md b/tests/e2e/PROVIDER_CACHE.md index 280132d0940..efd9eb66daa 100644 --- a/tests/e2e/PROVIDER_CACHE.md +++ b/tests/e2e/PROVIDER_CACHE.md @@ -22,6 +22,12 @@ Do not give cache credentials to candidate deployments. Counter artifacts contai Tests that require real provider timing, limits or state use `@pytest.mark.provider_live`. The marker keeps newly registered models on live routes without weakening their assertions. Ordinary assertion failures still fail E2E. The shared cache does not modify provider response IDs or make the proxy aware of replay +## Recorded response semantics + +Replay preserves the original response ID, usage and end-to-end headers. The proxy can therefore deduplicate repeated provider IDs when storing spend-log rows, just as it does when a live upstream returns the same ID twice. One spend-log row per invocation is not guaranteed for identical recorded responses. Existing spend reconciliation requests use distinct prompt markers and retain their distinct-ID and row-count assertions; accounting tests are not automatically excluded from caching + +Provider remaining-quota headers describe the captured response. Metrics derived from them are historical on a cache hit, not a measurement of current provider capacity. Gateway-generated API-key quota headers are a separate contract. A test of fresh provider quota or timing must use the live-provider policy; replay can still exercise how the proxy processes the recorded headers + ## Qualification `tests/code_coverage_tests/test_provider_cache.py` exercises local HTTP providers and disposable real Redis. CI runs these checks with the existing provider-edge and replay harness tests. These component checks do not establish Buildkite deployment, full-suite cross-build reuse or a genuine 24-hour expiry observation; those require separate runtime evidence From 3dda798c8a24f255219a2808eccbd2f66434f681 Mon Sep 17 00:00:00 2001 From: Yuneng Jiang Date: Tue, 15 Sep 2026 19:28:50 -0700 Subject: [PATCH 068/133] ci(e2e): consolidate cache contracts in filtered CircleCI job --- .circleci/config.yml | 17 ++++- .circleci/scripts/classify_changes.sh | 11 +++- .circleci/scripts/path_filter.sh | 4 +- .github/workflows/test-provider-cache.yml | 63 ------------------- .../test_litellm/test_circleci_path_filter.py | 14 +++++ 5 files changed, 40 insertions(+), 69 deletions(-) delete mode 100644 .github/workflows/test-provider-cache.yml diff --git a/.circleci/config.yml b/.circleci/config.yml index bb4ad0f4019..e2102a9ae91 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -9,7 +9,7 @@ commands: parameters: category: type: enum - enum: ["backend", "client"] + enum: ["backend", "client", "provider-harness"] default: "backend" steps: - run: @@ -2918,19 +2918,30 @@ jobs: provider_replay_harness: docker: - *python312_image + - image: redis@sha256:e2debfb7956fa12c7ddc79d7e645c8cf26b30c99a6e9161ea9bf4171e1668a5f working_directory: ~/project resource_class: medium + environment: + E2E_CACHE_TEST_REDIS_URL: redis://127.0.0.1:6379/0 + E2E_PROVIDER_CACHE: "0" + E2E_FIXTURE_MODE: live steps: + - checkout + - skip_if_unrelated_changes: + category: provider-harness - setup_litellm_test_deps + - wait_for_service: + url: tcp://localhost:6379 - run: - name: Test provider replay harness + name: Test provider capture and replay harness command: | mkdir -p test-results/provider-replay-harness uv run --no-sync pytest -q --noconftest -o addopts= -o pythonpath=tests/e2e -p no:rerunfailures \ --junitxml=test-results/provider-replay-harness/junit.xml \ tests/e2e/test_provider_edge.py tests/e2e/test_fixture_bundle.py \ tests/e2e/test_fixture_canonical.py tests/e2e/test_fixture_mode.py \ - tests/code_coverage_tests/test_provider_replay_harness.py + tests/code_coverage_tests/test_provider_replay_harness.py \ + tests/code_coverage_tests/test_provider_cache.py - store_test_results: path: test-results/provider-replay-harness diff --git a/.circleci/scripts/classify_changes.sh b/.circleci/scripts/classify_changes.sh index 7aa0c3544ee..9dc7b76b23f 100755 --- a/.circleci/scripts/classify_changes.sh +++ b/.circleci/scripts/classify_changes.sh @@ -1,13 +1,19 @@ #!/usr/bin/env bash set -uo pipefail -category="${1:?usage: classify_changes.sh }" +category="${1:?usage: classify_changes.sh }" has_client=false has_backend=false has_ci=false +has_provider_harness=false while IFS= read -r file || [ -n "$file" ]; do [ -n "$file" ] || continue + case "$file" in + tests/e2e/*/*.py) : ;; + tests/e2e/*.py | tests/code_coverage_tests/test_provider_cache.py | tests/code_coverage_tests/test_provider_replay_harness.py | tests/test_litellm/test_circleci_path_filter.py | .circleci/* | pyproject.toml | uv.lock) + has_provider_harness=true ;; + esac case "$file" in ui/* | tests/e2e/ui/*) has_client=true ;; docs/* | *.md | *.mdx) : ;; @@ -17,6 +23,9 @@ while IFS= read -r file || [ -n "$file" ]; do done case "$category" in + provider-harness) + [ "$has_provider_harness" = true ] && echo run || echo skip + ;; backend) [ "$has_backend" = true ] && echo run || echo skip ;; diff --git a/.circleci/scripts/path_filter.sh b/.circleci/scripts/path_filter.sh index dcf64a24399..cdadde732bd 100755 --- a/.circleci/scripts/path_filter.sh +++ b/.circleci/scripts/path_filter.sh @@ -1,7 +1,7 @@ #!/usr/bin/env bash set -uo pipefail -category="${1:?usage: path_filter.sh }" +category="${1:?usage: path_filter.sh }" here="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" run_full() { @@ -36,5 +36,5 @@ if [ "$decision" = run ]; then run_full "$category-relevant changes detected" fi -echo "path-filter[$category]: only unrelated (docs/client) changes detected; halting job as successful" +echo "path-filter[$category]: only unrelated changes detected; halting job as successful" circleci-agent step halt diff --git a/.github/workflows/test-provider-cache.yml b/.github/workflows/test-provider-cache.yml deleted file mode 100644 index 0507f90cf89..00000000000 --- a/.github/workflows/test-provider-cache.yml +++ /dev/null @@ -1,63 +0,0 @@ -name: Provider cache contracts - -on: - pull_request: - paths: - - 'tests/e2e/**' - - 'tests/code_coverage_tests/test_provider_cache.py' - - 'tests/code_coverage_tests/test_provider_replay_harness.py' - - '.github/workflows/test-provider-cache.yml' - - 'pyproject.toml' - - 'uv.lock' - workflow_dispatch: - -permissions: - contents: read - -jobs: - provider-cache: - runs-on: ubuntu-latest - timeout-minutes: 20 - services: - redis: - image: redis@sha256:e2debfb7956fa12c7ddc79d7e645c8cf26b30c99a6e9161ea9bf4171e1668a5f - ports: - - 6379:6379 - options: >- - --health-cmd "redis-cli ping" - --health-interval 5s - --health-timeout 3s - --health-retries 10 - env: - PYTHONDONTWRITEBYTECODE: '1' - PYTHONPATH: tests/e2e - E2E_CACHE_TEST_REDIS_URL: redis://127.0.0.1:6379/0 - E2E_PROVIDER_CACHE: '0' - E2E_FIXTURE_MODE: live - steps: - - uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0 - with: - persist-credentials: false - - uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0 - with: - python-version: '3.13' - - uses: ./.github/actions/setup-uv-with-retries - with: - version: '0.10.9' - - uses: ./.github/actions/cache-cargo-build - - name: Install locked dependencies - run: .github/scripts/uv_sync_with_retries.sh --frozen --group proxy-dev --group e2e-dev - - name: Verify cache and existing edge contracts - run: >- - uv run --no-sync pytest -c /dev/null -p no:cacheprovider - tests/code_coverage_tests/test_provider_cache.py - tests/code_coverage_tests/test_provider_replay_harness.py - tests/e2e/test_provider_edge.py - -q --junitxml=provider-cache-results.xml - - name: Save test results - if: always() - uses: actions/upload-artifact@4cec3d8aa04e39d1a68397de0c4cd6fb9dce8ec1 # v4.6.1 - with: - name: provider-cache-results - path: provider-cache-results.xml - if-no-files-found: warn diff --git a/tests/test_litellm/test_circleci_path_filter.py b/tests/test_litellm/test_circleci_path_filter.py index b427a1a3bd8..af0e932400f 100644 --- a/tests/test_litellm/test_circleci_path_filter.py +++ b/tests/test_litellm/test_circleci_path_filter.py @@ -49,6 +49,20 @@ CI = [".github/workflows/test-litellm-ui-unit.yml"] @pytest.mark.parametrize( "category,changed,expected", [ + ("provider-harness", ["tests/e2e/provider_cache.py"], "run"), + ("provider-harness", ["tests/e2e/conftest.py"], "run"), + ("provider-harness", ["tests/e2e/e2e_http.py"], "run"), + ("provider-harness", ["tests/code_coverage_tests/test_provider_cache.py"], "run"), + ("provider-harness", ["tests/code_coverage_tests/test_provider_replay_harness.py"], "run"), + ("provider-harness", [".circleci/config.yml"], "run"), + ("provider-harness", [".circleci/scripts/classify_changes.sh"], "run"), + ("provider-harness", ["pyproject.toml"], "run"), + ("provider-harness", ["uv.lock"], "run"), + ("provider-harness", ["tests/e2e/PROVIDER_CACHE.md"], "skip"), + ("provider-harness", ["tests/e2e/ui/test_example.py"], "skip"), + ("provider-harness", ["tests/e2e/quota_management/test_quota.py"], "skip"), + ("provider-harness", ["litellm/main.py"], "skip"), + ("provider-harness", ["ui/litellm-dashboard/src/App.tsx"], "skip"), # docs-only: skip everything ("backend", DOCS, "skip"), ("client", DOCS, "skip"), From 39b78128103b1c0a2f703e275ddef60e1c1a98e1 Mon Sep 17 00:00:00 2001 From: Tin Chi Lo Date: Tue, 15 Sep 2026 18:28:21 -0700 Subject: [PATCH 069/133] feat(ui): configure capability and Fuse v2 classifiers --- .../AutoRouters/AutoRoutersPanel.tsx | 4 +- .../components/AutoRouters/autoRouterRows.ts | 2 + ...oRouterClassifierTabs.integration.test.tsx | 75 +++ .../add_model/AutoRouterClassifierTabs.tsx | 58 ++ .../add_model/ClassificationMethodConfig.tsx | 55 +- .../add_model/ComplexityRouterConfig.tsx | 636 +++++++++--------- ...plexityRouterFastMode.integration.test.tsx | 186 ++++- .../add_model/DefaultModelField.tsx | 56 ++ ...ecastClassifierConfig.integration.test.tsx | 224 ++++++ .../add_model/ForecastClassifierConfig.tsx | 433 ++++++++++++ .../add_model/PlanModeOverrideControls.tsx | 44 ++ .../components/add_model/RoutingOptions.tsx | 23 + .../add_model/TierModelEffortRows.tsx | 10 +- .../components/add_model/TierRestrictions.tsx | 12 +- .../add_model/add_auto_router_tab.test.tsx | 108 +++ .../add_model/add_auto_router_tab.tsx | 451 +++++++------ .../build_complexity_router_config.ts | 132 +++- .../classifier_type_transition.test.ts | 80 +++ .../add_model/classifier_type_transition.ts | 50 ++ .../forecast_classifier_config.test.ts | 284 ++++++++ .../add_model/forecast_classifier_config.ts | 188 ++++++ .../src/components/add_model/tier_rows.ts | 11 +- .../add_model/tier_set_actions.test.ts | 29 + .../components/add_model/tier_set_actions.ts | 30 +- ...d_updated_complexity_router_config.test.ts | 13 +- .../edit_auto_router_modal.tsx | 141 ++-- 26 files changed, 2593 insertions(+), 742 deletions(-) create mode 100644 ui/litellm-dashboard/src/components/add_model/AutoRouterClassifierTabs.integration.test.tsx create mode 100644 ui/litellm-dashboard/src/components/add_model/AutoRouterClassifierTabs.tsx create mode 100644 ui/litellm-dashboard/src/components/add_model/DefaultModelField.tsx create mode 100644 ui/litellm-dashboard/src/components/add_model/ForecastClassifierConfig.integration.test.tsx create mode 100644 ui/litellm-dashboard/src/components/add_model/ForecastClassifierConfig.tsx create mode 100644 ui/litellm-dashboard/src/components/add_model/PlanModeOverrideControls.tsx create mode 100644 ui/litellm-dashboard/src/components/add_model/RoutingOptions.tsx create mode 100644 ui/litellm-dashboard/src/components/add_model/classifier_type_transition.test.ts create mode 100644 ui/litellm-dashboard/src/components/add_model/classifier_type_transition.ts create mode 100644 ui/litellm-dashboard/src/components/add_model/forecast_classifier_config.test.ts create mode 100644 ui/litellm-dashboard/src/components/add_model/forecast_classifier_config.ts diff --git a/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/components/AutoRouters/AutoRoutersPanel.tsx b/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/components/AutoRouters/AutoRoutersPanel.tsx index f11b74c939d..1625e0cbfb8 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/components/AutoRouters/AutoRoutersPanel.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/components/AutoRouters/AutoRoutersPanel.tsx @@ -104,8 +104,8 @@ export function AutoRoutersPanel({ Add Auto Router - Routes each request to a model by classifying its complexity. Called like any other model, so clients keep - using a single model name. + Choose a classifier to route each request to a model. Called like any other model, so clients keep using a + single model name. Array.from(new Set(models)); const COMPLEXITY_TYPE_LABELS: Record = { llm: "LLM Classifier", + capability: "Capability", + llm_v2: "Fuse v2", heuristic_first: "Heuristic first", hybrid: "Hybrid", custom: "Custom classifier", diff --git a/ui/litellm-dashboard/src/components/add_model/AutoRouterClassifierTabs.integration.test.tsx b/ui/litellm-dashboard/src/components/add_model/AutoRouterClassifierTabs.integration.test.tsx new file mode 100644 index 00000000000..ac6851349ea --- /dev/null +++ b/ui/litellm-dashboard/src/components/add_model/AutoRouterClassifierTabs.integration.test.tsx @@ -0,0 +1,75 @@ +import React, { useState } from "react"; +import { describe, expect, it, vi } from "vitest"; +import { fireEvent, renderWithProviders, screen } from "../../../tests/test-utils"; +import AutoRouterClassifierTabs from "./AutoRouterClassifierTabs"; +import type { ComplexityRouterConfigValue } from "./ComplexityRouterConfig"; + +const initial: ComplexityRouterConfigValue = { + classifier_type: "llm", + tiers: { SIMPLE: ["efficient"], MEDIUM: [], COMPLEX: [], REASONING: ["capable"] }, +}; + +function Form({ initialValue = initial }: { initialValue?: ComplexityRouterConfigValue }) { + const [value, setValue] = useState(initialValue); + return ( + + {value.classifier_type} + + ); +} + +describe("AutoRouterClassifierTabs", () => { + it.each(["heuristic", "heuristic_v2", "llm", "heuristic_first", "hybrid"] as const)( + "groups %s under Complexity without resetting its configuration", + (classifier_type) => { + const onChange = vi.fn(); + renderWithProviders( + + Existing classifier settings + , + ); + expect(screen.getByRole("tab", { name: "Complexity" })).toHaveAttribute("aria-selected", "true"); + expect(screen.getByRole("tabpanel", { name: "Complexity" })).toHaveTextContent("Existing classifier settings"); + fireEvent.click(screen.getByRole("tab", { name: "Complexity" })); + expect(onChange).not.toHaveBeenCalled(); + }, + ); + + it.each([ + ["capability", "Capability"], + ["llm_v2", "Fuse v2"], + ] as const)("opens saved %s settings and switches back to local Complexity", (classifier_type, label) => { + renderWithProviders(
); + expect(screen.getByRole("tab", { name: label })).toHaveAttribute("aria-selected", "true"); + fireEvent.click(screen.getByRole("tab", { name: "Complexity" })); + expect(screen.getByRole("tab", { name: "Complexity" })).toHaveAttribute("aria-selected", "true"); + expect(screen.getByRole("status", { name: "Classifier type" })).toHaveTextContent("heuristic"); + }); + + it("keeps custom tiers editable under Complexity and explains why forecast tabs are disabled", () => { + const onChange = vi.fn(); + renderWithProviders( + + Custom tiers + , + ); + expect(screen.getByRole("tabpanel", { name: "Complexity" })).toHaveTextContent("Custom tiers"); + for (const name of ["Capability", "Fuse v2"]) { + const tab = screen.getByRole("tab", { name }); + expect(tab).toHaveAttribute("aria-disabled", "true"); + expect(tab).toHaveAccessibleDescription("Restore standard tiers to use Capability or Fuse v2."); + fireEvent.click(tab); + } + expect(onChange).not.toHaveBeenCalled(); + expect(screen.getByText("Restore standard tiers to use Capability or Fuse v2.")).toBeVisible(); + }); +}); diff --git a/ui/litellm-dashboard/src/components/add_model/AutoRouterClassifierTabs.tsx b/ui/litellm-dashboard/src/components/add_model/AutoRouterClassifierTabs.tsx new file mode 100644 index 00000000000..98c0d4aab2f --- /dev/null +++ b/ui/litellm-dashboard/src/components/add_model/AutoRouterClassifierTabs.tsx @@ -0,0 +1,58 @@ +import React, { useId } from "react"; +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; +import { effectiveClassifierType, type ComplexityRouterConfigValue } from "./ComplexityRouterConfig"; +import { transitionClassifierType } from "./classifier_type_transition"; +import { isForecastClassifier } from "./forecast_classifier_config"; + +interface AutoRouterClassifierTabsProps { + value: ComplexityRouterConfigValue; + onChange: (value: ComplexityRouterConfigValue) => void; + children: React.ReactNode; +} + +const AutoRouterClassifierTabs: React.FC = ({ value, onChange, children }) => { + const restrictionId = useId(); + const classifierType = effectiveClassifierType(value); + const selected = isForecastClassifier(classifierType) ? classifierType : "complexity"; + const hasCustomTiers = Boolean(value.custom_tier_set); + + const handleChange = (tab: unknown) => { + if (tab === selected) return; + if (tab === "complexity") { + onChange(transitionClassifierType(value, isForecastClassifier(classifierType) ? "heuristic" : classifierType)); + } else if (!hasCustomTiers && (tab === "capability" || tab === "llm_v2")) { + onChange(transitionClassifierType(value, tab)); + } + }; + + return ( + +

Classifier type

+ + Complexity + + Capability + + + Fuse v2 + + + {hasCustomTiers && ( +

+ Restore standard tiers to use Capability or Fuse v2. +

+ )} + {children} +
+ ); +}; + +export default AutoRouterClassifierTabs; diff --git a/ui/litellm-dashboard/src/components/add_model/ClassificationMethodConfig.tsx b/ui/litellm-dashboard/src/components/add_model/ClassificationMethodConfig.tsx index a6f2e65793a..64b08fc9ed1 100644 --- a/ui/litellm-dashboard/src/components/add_model/ClassificationMethodConfig.tsx +++ b/ui/litellm-dashboard/src/components/add_model/ClassificationMethodConfig.tsx @@ -1,3 +1,4 @@ +import { transitionClassifierType } from "./classifier_type_transition"; import { Info } from "lucide-react"; import { SimpleTooltip } from "@/components/ui/tooltip"; import { MultiSelect } from "@/components/shared/MultiSelect"; @@ -17,7 +18,6 @@ import ClassifierReasoningEffortSelect from "./ClassifierReasoningEffortSelect"; import ClassifierCircuitBreakerConfig from "./ClassifierCircuitBreakerConfig"; import ClassifierVisionConfig from "./ClassifierVisionConfig"; import type { ReasoningEffort } from "./complexity_router_tiers"; -import { nonReasoningTierFields } from "./nonReasoningTierFields"; import { useComplexityScorerDefaults } from "@/app/(dashboard)/hooks/autoRouter/useComplexityScorerDefaults"; import { ClassificationFrequency, @@ -33,12 +33,10 @@ import { DEFAULT_CLASSIFIER_FALLBACK, DEFAULT_CLASSIFIER_TIMEOUT_MS, DEFAULT_CLASSIFICATION_RUBRIC, - NEW_CLASSIFIER_CLASSIFICATION_RUBRIC, ClassificationRubric, effectiveTierLabel, heuristicScoringRole, usesLlmClassifier, - DEFAULT_HEURISTIC_FIRST_MAX_TIER, DEFAULT_HYBRID_BOUNDARY_MARGIN, HEURISTIC_FIRST_MAX_TIER_KEYS, effectiveClassifierType, @@ -263,35 +261,7 @@ const ClassificationMethodConfig: React.FC = ({ const explicitlySupportedClassifierEfforts = effortOptionsByModel[classifierModel]; const handleClassifierTypeChange = (classifierType: ClassifierType) => { - const nextValue: ComplexityRouterConfigValue = { - ...value, - classifier_type: classifierType, - classifier_llm_config: usesLlmClassifier(classifierType) - ? value.classifier_llm_config ?? { - model: "", - timeout_ms: DEFAULT_CLASSIFIER_TIMEOUT_MS, - classification_rubric: NEW_CLASSIFIER_CLASSIFICATION_RUBRIC, - } - : undefined, - classifier_context_window_size: usesLlmClassifier(classifierType) - ? value.classifier_context_window_size ?? DEFAULT_CLASSIFIER_CONTEXT_WINDOW_SIZE - : undefined, - classifier_context_budget_chars: usesLlmClassifier(classifierType) - ? value.classifier_context_budget_chars ?? DEFAULT_CLASSIFIER_CONTEXT_BUDGET_CHARS - : undefined, - classifier_context_include_assistant_turns: usesLlmClassifier(classifierType) - ? value.classifier_context_include_assistant_turns - : undefined, - classifier_fallback: usesLlmClassifier(classifierType) ? value.classifier_fallback : undefined, - heuristic_first_max_tier: - classifierType === "heuristic_first" - ? value.heuristic_first_max_tier ?? DEFAULT_HEURISTIC_FIRST_MAX_TIER - : undefined, - hybrid_boundary_margin: - classifierType === "hybrid" ? value.hybrid_boundary_margin ?? DEFAULT_HYBRID_BOUNDARY_MARGIN : undefined, - ...nonReasoningTierFields(classifierType, value), - }; - onChange(nextValue); + onChange(transitionClassifierType(value, classifierType)); }; const handleHeuristicFirstMaxTierChange = (tier: string) => { @@ -433,27 +403,6 @@ const ClassificationMethodConfig: React.FC = ({ }); }; - if (classifierType === "capability") { - return ( -

- This router uses capability forecasting. Configure its classifier, threshold, and calibration through YAML or - the API. Saving preserves those settings -

- ); - } - - if (classifierType === "llm_v2") { - return ( -
- LLM V2 classifier (experimental) -

- Combines task demands and model capability in one forecast. Its solver profiles and quality allowance are - configured through the API. Saving this router preserves those settings -

-
- ); - } - return ( <> diff --git a/ui/litellm-dashboard/src/components/add_model/ComplexityRouterConfig.tsx b/ui/litellm-dashboard/src/components/add_model/ComplexityRouterConfig.tsx index c6b9a69e76a..4ccabff18b9 100644 --- a/ui/litellm-dashboard/src/components/add_model/ComplexityRouterConfig.tsx +++ b/ui/litellm-dashboard/src/components/add_model/ComplexityRouterConfig.tsx @@ -1,8 +1,11 @@ +import RoutingOptions from "./RoutingOptions"; +import PlanModeOverrideControls from "./PlanModeOverrideControls"; +import ForecastClassifierConfig, { ForecastSolverModels } from "./ForecastClassifierConfig"; +import { isForecastClassifier, type CapabilitySettings, type FuseSettings } from "./forecast_classifier_config"; import { SimpleTooltip } from "@/components/ui/tooltip"; import { MultiSelect } from "@/components/shared/MultiSelect"; -import { SearchSelect } from "@/components/shared/SearchSelect"; +import DefaultModelField from "./DefaultModelField"; import { ChevronRight, Info, Plus, Trash2, X } from "lucide-react"; -import { Switch } from "@/components/ui/switch"; import { AffinityControls } from "./AffinityControls"; import NonReasoningTierToggle from "./NonReasoningTierToggle"; @@ -204,11 +207,6 @@ const rowOrigin = (row: TierRow, editing: boolean): string => { return isBuiltInTierName(row.name) ? "built-in" : "custom"; }; -const defaultModelPlaceholderFor = (derivedDefaultModel: string | undefined, isCustomSet: boolean): string => { - if (derivedDefaultModel) return `Derived from tiers: ${derivedDefaultModel}`; - return isCustomSet ? "Add a model to your fallback tier" : "Add a model to the Simple or Medium tier"; -}; - const builtInTierInfo = (rowId: string): { label: string; description: string; examples: string } | undefined => { const builtIn = ALL_BUILT_IN_TIERS.find((tier) => tier === rowId); return builtIn ? TIER_DESCRIPTIONS[builtIn] : undefined; @@ -376,6 +374,8 @@ export interface ComplexityRouterConfigValue { /** An explicit pin. Unset means the default tracks the tiers - see resolveComplexityDefaultModel. */ default_model?: string; classifier_type: ClassifierType; + capability_classifier_config?: CapabilitySettings; + llm_v2_config?: FuseSettings; classifier_llm_config?: ClassifierLLMConfig; classifier_context_window_size?: number; classifier_context_budget_chars?: number; @@ -535,44 +535,6 @@ export const DEFAULT_HYBRID_BOUNDARY_MARGIN = 0.03; */ export const HEURISTIC_FIRST_MAX_TIER_KEYS = TIER_ORDER.slice(0, -1); -const PlanModeOverrideControls: React.FC<{ - value: ComplexityRouterConfigValue; - onChange: (value: ComplexityRouterConfigValue) => void; - planModeTierOptions: { value: string; label: string }[]; -}> = ({ value, onChange, planModeTierOptions }) => ( - <> -
- - onChange({ - ...value, - plan_mode_min_tier: enabled ? planModeTierOptions.at(-1)?.value : undefined, - }) - } - aria-label="Route plan-mode requests to a minimum tier" - /> - Route plan-mode requests to a minimum tier -
- - Requests from coding agents in plan mode (Claude Code, GitHub Copilot) route to at least this tier. The classifier - still wins when it picks higher, and the override only lasts while plan mode is active. - {planModeTierOptions.length === 0 && " Add models to a tier to enable this."} - - {value.plan_mode_min_tier !== undefined && ( -
- onChange({ ...value, plan_mode_min_tier: tier })} - /> -
- )} - -); - const ComplexityRouterConfig: React.FC = ({ modelInfo, value, @@ -596,6 +558,7 @@ const ComplexityRouterConfig: React.FC = ({ onAutoRouterCompressionChange, showValidationErrors = false, }) => { + const forecast = isForecastClassifier(value.classifier_type); const customTierSet = value.custom_tier_set; const tierRows = activeTierRows(value); const tierRowsError = customTierSet ? getCustomTierRowsError(customTierSet) : null; @@ -605,8 +568,6 @@ const ComplexityRouterConfig: React.FC = ({ value: row.id, label: tierRowLabel(row, value.tier_labels), })); - const derivedDefaultModel = resolveComplexityDefaultModel(value); - const defaultModelPlaceholder = defaultModelPlaceholderFor(derivedDefaultModel, Boolean(customTierSet)); const defaultModel = resolveComplexityDefaultModel(value, value.default_model); const dispatch = (action: TierSetAction) => { @@ -641,298 +602,321 @@ const ComplexityRouterConfig: React.FC = ({ tier_model_params: setTierModelParam(value.tier_model_params, tier, model, change), }); - // Clearing the select drops the key entirely rather than storing "", so an emptied pin reads as - // "track the tiers" everywhere downstream instead of as a blank model name. - const handleDefaultModelChange = (model: string | null | undefined) => { - onChange({ ...value, default_model: model || undefined }); - }; - - const handleTierLabelChange = (tier: keyof ComplexityTiers, label: string) => { - onChange({ - ...value, - tier_labels: { ...value.tier_labels, [tier]: label }, - }); - }; + const handleTierLabelChange = (tier: keyof ComplexityTiers, label: string) => + onChange({ ...value, tier_labels: { ...value.tier_labels, [tier]: label } }); return (
-

Complexity Tier Configuration

- - - +

+ {forecast ? "Solver models" : "Complexity Tier Configuration"} +

+ {!forecast && ( + + + + )}
- - - - - {!customTierSet && ( - - )} - - {tierRows.map((row, index) => { - const tierInfo = builtInTierInfo(row.id); - const label = tierRowLabel(row, value.tier_labels); - const tierMissing = showValidationErrors && row.models.length === 0; - const needsDefinition = Boolean(customTierSet) && !row.definition.trim() && !isBuiltInTierName(row.name); - const definitionMissing = showValidationErrors && needsDefinition; - const showsDisplayName = !customTierSet && !editingTiers; - return ( -
- {index > 0 && } -
- removeTierRow(row.id)} - /> - {tierInfo && !customTierSet && ( - Examples: {tierInfo.examples} - )} - {editingTiers && ( - updateTierRow(row.id, patch)} - /> - )} - {showsDisplayName && tierInfo && ( - - handleTierLabelChange(row.id as keyof ComplexityTiers, event.target.value)} - placeholder={`Display name (default: ${tierInfo.label})`} - aria-label={`Display name for the ${tierInfo.label} tier`} - /> - {value.tier_labels?.[row.id as keyof ComplexityTiers] && ( - - handleTierLabelChange(row.id as keyof ComplexityTiers, "")} - > - - - - )} - - )} - setRowModels(row, models)} - placeholder={`Select model(s) for ${label.toLowerCase()} queries`} - emptyText="No models found" - className={tierMissing ? "w-full border-destructive" : "w-full"} - /> - - handleTierModelParamChange(row.id, model, ["reasoning_effort", effort]) - } - onFastModeChange={(model, enabled) => - handleTierModelParamChange(row.id, model, ["speed", enabled ? "fast" : undefined]) - } - /> - {row.models.length > 1 && ( - - Multiple models selected: the router randomly picks among them per request (or Thompson-samples - within the pool when adaptive routing is on). - - )} - {tierMissing && The {label} tier is required} -
-
- ); - })} - - + + + + ) : ( + <> + - {customTierSet && ( - onChange(setFallbackTier(value, fallbackTierId))} - /> - )} + + + {!customTierSet && ( + + )} - + {tierRows.map((row, index) => { + const tierInfo = builtInTierInfo(row.id); + const label = tierRowLabel(row, value.tier_labels); + const tierMissing = showValidationErrors && row.models.length === 0; + const needsDefinition = + Boolean(customTierSet) && !row.definition.trim() && !isBuiltInTierName(row.name); + const definitionMissing = showValidationErrors && needsDefinition; + const showsDisplayName = !customTierSet && !editingTiers; + return ( +
+ {index > 0 && } +
+ removeTierRow(row.id)} + /> + {tierInfo && !customTierSet && ( + Examples: {tierInfo.examples} + )} + {editingTiers && ( + updateTierRow(row.id, patch)} + /> + )} + {showsDisplayName && tierInfo && ( + + + handleTierLabelChange(row.id as keyof ComplexityTiers, event.target.value) + } + placeholder={`Display name (default: ${tierInfo.label})`} + aria-label={`Display name for the ${tierInfo.label} tier`} + /> + {value.tier_labels?.[row.id as keyof ComplexityTiers] && ( + + handleTierLabelChange(row.id as keyof ComplexityTiers, "")} + > + + + + )} + + )} + setRowModels(row, models)} + placeholder={`Select model(s) for ${label.toLowerCase()} queries`} + emptyText="No models found" + className={tierMissing ? "w-full border-destructive" : "w-full"} + /> + + handleTierModelParamChange(row.id, model, ["reasoning_effort", effort]) + } + onFastModeChange={(model, enabled) => + handleTierModelParamChange(row.id, model, ["speed", enabled ? "fast" : undefined]) + } + /> + {row.models.length > 1 && ( + + Multiple models selected: the router randomly picks among them per request (or + Thompson-samples within the pool when adaptive routing is on). + + )} + {tierMissing && The {label} tier is required} +
+
+ ); + })} -
-
- Default Model - - - -
- - - Used when the tier the request lands in has no model, and when the classifier fails with "Route to - the default model" selected. - -
-
-
+ + {customTierSet && ( + onChange(setFallbackTier(value, fallbackTierId))} + /> + )} +
+
+ + )} + {!forecast && } -
- {[ - { - key: "classifier", - label: Advanced: Classification Method, - children: ( - - ), - }, - { - key: "adaptive", - label: Advanced: Adaptive Routing, - children: ( - - - - ), - }, - { - key: "affinity", - label: Advanced: Affinity, - children: , - }, - { - key: "modality", - label: Advanced: Modality Routing, - children: , - }, - { - key: "plan-mode", - label: Advanced: Plan-Mode Override, - children: ( - - ), - }, - { - key: "context-window", - label: Advanced: Context Window Escalation, - children: , - }, - { - key: "stall-escalation", - label: Advanced: Stalled Task Escalation, - children: ( - - - - ), - }, - { - key: "response", - label: Advanced: Response Format, - children: , - }, - ...(onEscalationKeywordsChange - ? [ - { - key: "escalation", - label: Advanced: Escalation Keywords, - children: ( - - - - ), - }, - ] - : []), - ...(onAutoRouterCompressionChange - ? [ - { - key: "compression", - label: Advanced: Compression, - children: ( - - ), - }, - ] - : []), - ...(onKeywordTierRulesChange || onSemanticMatchingEnabledChange - ? [ - { - key: "keyword-semantic", - label: Advanced: Keyword/Semantic Matching, - children: ( - <> - {onKeywordTierRulesChange && ( - - )} - {onKeywordTierRulesChange && onSemanticMatchingEnabledChange && } - {onSemanticMatchingEnabledChange && ( - - )} - - ), - }, - ] - : []), - ].map(({ key, label, children }) => ( - - - - {label} - - {children} - - ))} -
+ + {forecast && ( + <> + + + + )} +
+ {[ + ...(!forecast + ? [ + { + key: "classifier", + label: Advanced: Classification Method, + children: ( + + ), + }, + ] + : []), + ...(value.classifier_type !== "llm_v2" + ? [ + { + key: "adaptive", + label: Advanced: Adaptive Routing, + children: ( + + + + ), + }, + ] + : []), + { + key: "affinity", + label: Advanced: Affinity, + children: , + }, + { + key: "modality", + label: Advanced: Modality Routing, + children: , + }, + { + key: "plan-mode", + label: Advanced: Plan-Mode Override, + children: ( + + ), + }, + { + key: "context-window", + label: Advanced: Context Window Escalation, + children: , + }, + { + key: "stall-escalation", + label: Advanced: Stalled Task Escalation, + children: ( + + + + ), + }, + { + key: "response", + label: Advanced: Response Format, + children: , + }, + ...(onEscalationKeywordsChange + ? [ + { + key: "escalation", + label: Advanced: Escalation Keywords, + children: ( + + + + ), + }, + ] + : []), + ...(onAutoRouterCompressionChange + ? [ + { + key: "compression", + label: Advanced: Compression, + children: ( + + ), + }, + ] + : []), + ...(onKeywordTierRulesChange || onSemanticMatchingEnabledChange + ? [ + { + key: "keyword-semantic", + label: ( + Advanced: Keyword/Semantic Matching + ), + children: ( + <> + {onKeywordTierRulesChange && ( + + )} + {onKeywordTierRulesChange && onSemanticMatchingEnabledChange && } + {onSemanticMatchingEnabledChange && ( + + )} + + ), + }, + ] + : []), + ].map(({ key, label, children }) => ( + + + + {label} + + {children} + + ))} +
+
); }; diff --git a/ui/litellm-dashboard/src/components/add_model/ComplexityRouterFastMode.integration.test.tsx b/ui/litellm-dashboard/src/components/add_model/ComplexityRouterFastMode.integration.test.tsx index 34d14091bde..810289da79f 100644 --- a/ui/litellm-dashboard/src/components/add_model/ComplexityRouterFastMode.integration.test.tsx +++ b/ui/litellm-dashboard/src/components/add_model/ComplexityRouterFastMode.integration.test.tsx @@ -1,11 +1,12 @@ import userEvent from "@testing-library/user-event"; import React from "react"; import { describe, expect, it, vi } from "vitest"; -import { renderWithProviders, screen } from "../../../tests/test-utils"; +import { renderWithProviders, screen, within } from "../../../tests/test-utils"; import { buildUpdatedComplexityRouterConfig, hydrateComplexityRouterConfig, } from "../edit_auto_router/edit_auto_router_modal"; +import type { KeywordTierRule } from "./KeywordTierRules"; import type { ModelGroup } from "../llm_calls/fetch_models"; import ComplexityRouterConfig, { type ComplexityRouterConfigValue } from "./ComplexityRouterConfig"; @@ -50,8 +51,8 @@ it.each([false, true])("edits and round-trips independent model settings with cu const view = renderWithProviders(editor(initial)); const fast = () => screen.getByRole("switch", { name: `Fast mode for primary in the ${label} tier` }); - expect(screen.getAllByRole("switch", { name: /^Fast mode for/ })).toHaveLength(3); - expect(screen.queryByRole("switch", { name: /^Fast mode for (blocked|missing)/ })).not.toBeInTheDocument(); + expect(screen.getAllByRole("switch", { name: /^Fast mode for/ })).toHaveLength(4); + expect(screen.queryByRole("switch", { name: /^Fast mode for missing/ })).not.toBeInTheDocument(); expect(screen.queryByRole("combobox", { name: /^Reasoning effort for secondary/ })).not.toBeInTheDocument(); expect(screen.getByRole("switch", { name: `Fast mode for secondary in the ${label} tier` })).toBeChecked(); expect(fast()).not.toBeChecked(); @@ -125,19 +126,170 @@ it.each([false, true])("edits and round-trips independent model settings with cu ); }); -describe("Fast mode metadata", () => { - it("offers nothing before model capabilities load and leaves stored speed untouched", () => { - const value: ComplexityRouterConfigValue = { - tiers: { SIMPLE: ["primary"], MEDIUM: [], COMPLEX: [], REASONING: [] }, - classifier_type: "heuristic", - tier_model_params: { SIMPLE: { primary: { speed: "fast" } } }, - }; - const onChange = vi.fn(); - renderWithProviders(); - expect(screen.queryByRole("switch", { name: /^Fast mode for/ })).not.toBeInTheDocument(); - expect(onChange).not.toHaveBeenCalled(); - expect(buildUpdatedComplexityRouterConfig({}, value).tier_model_configs).toEqual({ - SIMPLE: [{ model_name: "primary", litellm_params: { speed: "fast" } }], - }); +it.each(["capability", "llm_v2"] as const)("preserves Fast mode controls for %s solvers", async (classifierType) => { + const user = userEvent.setup(); + const initial: ComplexityRouterConfigValue = { + classifier_type: classifierType, + classifier_llm_config: { model: "primary", timeout_ms: 3000 }, + tiers: { SIMPLE: ["primary"], MEDIUM: [], COMPLEX: [], REASONING: ["blocked"] }, + capability_classifier_config: { efficient_tier: "SIMPLE", capable_tier: "REASONING", base_threshold: 0.7 }, + llm_v2_config: { + efficient_profile: "Small solver", + capable_profile: "Large solver", + harness: "One attempt", + max_quality_gap: 0.05, + }, + tier_model_params: { SIMPLE: { primary: { reasoning_effort: "high", max_tokens: 1024 } } }, + }; + const onChange = vi.fn<(value: ComplexityRouterConfigValue) => void>(); + const editor = (value: ComplexityRouterConfigValue) => ( + + ); + const view = renderWithProviders(editor(initial)); + const fast = () => screen.getByRole("switch", { name: "Fast mode for primary in the Efficient solver tier" }); + expect(screen.getAllByRole("switch", { name: /^Fast mode for/ })).toHaveLength(1); + expect(fast()).not.toBeChecked(); + await user.click(fast()); + const enabled = onChange.mock.lastCall![0]; + expect(enabled.tier_model_params?.SIMPLE.primary).toEqual({ + reasoning_effort: "high", + max_tokens: 1024, + speed: "fast", + }); + const saved = buildUpdatedComplexityRouterConfig({}, enabled); + expect(saved.tier_model_configs).toEqual({ + SIMPLE: [{ model_name: "primary", litellm_params: { reasoning_effort: "high", max_tokens: 1024, speed: "fast" } }], + }); + view.rerender(editor(hydrateComplexityRouterConfig(saved, undefined))); + expect(fast()).toBeChecked(); + await user.click(fast()); + expect(onChange.mock.lastCall![0].tier_model_params?.SIMPLE.primary).toEqual({ + reasoning_effort: "high", + max_tokens: 1024, }); }); + +describe("Fast mode metadata", () => { + it.each(["heuristic", "capability", "llm_v2"] as const)( + "can clear stored Fast mode without current capability metadata for %s", + async (classifier_type) => { + const user = userEvent.setup(); + const value: ComplexityRouterConfigValue = { + tiers: { SIMPLE: ["primary"], MEDIUM: [], COMPLEX: [], REASONING: ["secondary"] }, + classifier_type, + tier_model_params: { SIMPLE: { primary: { speed: "fast", max_tokens: 512 } } }, + }; + const onChange = vi.fn<(value: ComplexityRouterConfigValue) => void>(); + const editor = (current: ComplexityRouterConfigValue, info: ModelGroup[]) => ( + + ); + const view = renderWithProviders(editor(value, [])); + const fast = () => screen.getByRole("switch", { name: /^Fast mode for primary/ }); + expect(fast()).toBeChecked(); + expect(onChange).not.toHaveBeenCalled(); + view.rerender(editor(value, [{ model_group: "primary", supports_fast_mode: false }])); + expect(fast()).toBeChecked(); + await user.click(fast()); + const cleared = onChange.mock.lastCall![0]; + expect(cleared.tier_model_params?.SIMPLE.primary).toEqual({ max_tokens: 512 }); + const saved = buildUpdatedComplexityRouterConfig({}, cleared); + expect(saved.tier_model_configs).toEqual({ + SIMPLE: [{ model_name: "primary", litellm_params: { max_tokens: 512 } }], + }); + view.rerender(editor(hydrateComplexityRouterConfig(saved, undefined), [])); + expect(screen.queryByRole("switch", { name: /^Fast mode for primary/ })).not.toBeInTheDocument(); + view.rerender(editor(cleared, modelInfo)); + expect(fast()).not.toBeChecked(); + }, + ); +}); + +it.each(["MEDIUM", "REASONING"])("clears a legacy Capability pool while reconciling plan floor %s", async (floor) => { + const user = userEvent.setup(); + const stored = { + classifier_type: "capability" as const, + plan_mode_min_tier: floor, + tiers: { SIMPLE: ["primary"], MEDIUM: ["secondary"], COMPLEX: [], REASONING: ["blocked"] }, + tier_model_configs: { MEDIUM: [{ model_name: "secondary", litellm_params: { speed: "fast" } }] }, + }; + const value = hydrateComplexityRouterConfig(stored, undefined); + const onChange = vi.fn<(value: ComplexityRouterConfigValue) => void>(); + renderWithProviders(); + await user.click(screen.getByRole("button", { name: "Advanced routing options" })); + expect(screen.getByRole("switch", { name: "Fast mode for secondary in the Medium routing pool tier" })).toBeChecked(); + expect(onChange).not.toHaveBeenCalled(); + await user.click(screen.getByRole("combobox", { name: "Select medium routing pool models" })); + await user.click(await screen.findByRole("option", { name: "secondary" })); + await user.keyboard("{Escape}"); + const cleared = onChange.mock.lastCall![0]; + expect(cleared.tiers.MEDIUM).toEqual([]); + expect(cleared.plan_mode_min_tier).toBe(floor === "MEDIUM" ? undefined : floor); + expect(cleared.tier_model_params).toBeUndefined(); + expect(buildUpdatedComplexityRouterConfig(stored, cleared).tiers).toEqual({ + SIMPLE: ["primary"], + REASONING: ["blocked"], + }); +}); + +it.each(["capability", "llm_v2"] as const)( + "shows and clears a persisted default model in %s", + async (classifier_type) => { + const user = userEvent.setup(); + const stored = { + classifier_type, + default_model: "legacy-default", + tiers: { SIMPLE: ["primary"], REASONING: ["secondary"] }, + }; + const onChange = vi.fn<(value: ComplexityRouterConfigValue) => void>(); + const editor = (value: ComplexityRouterConfigValue) => ( + + ); + const view = renderWithProviders(editor(hydrateComplexityRouterConfig(stored, undefined))); + await user.click(screen.getByRole("button", { name: "Advanced routing options" })); + const select = () => screen.getByRole("combobox", { name: "Default model" }); + expect(select()).toHaveValue("legacy-default"); + expect(onChange).not.toHaveBeenCalled(); + await user.click(select()); + await user.click(await screen.findByRole("option", { name: "blocked" })); + const changed = onChange.mock.lastCall![0]; + expect(buildUpdatedComplexityRouterConfig(stored, changed).default_model).toBe("blocked"); + view.rerender(editor(changed)); + await user.click( + within(screen.getByRole("group", { name: "Default model configuration" })).getByRole("button", { name: "Clear" }), + ); + const cleared = onChange.mock.lastCall![0]; + expect(cleared.default_model).toBeUndefined(); + const saved = buildUpdatedComplexityRouterConfig(stored, cleared); + expect(saved).not.toHaveProperty("default_model"); + view.rerender(editor(hydrateComplexityRouterConfig(saved, undefined))); + expect(select()).toHaveValue(""); + expect(select()).toHaveAttribute("placeholder", expect.stringContaining("primary")); + }, +); + +it.each(["capability", "llm_v2"] as const)("offers only populated keyword targets for %s", async (classifier_type) => { + const user = userEvent.setup(); + const value: ComplexityRouterConfigValue = { + classifier_type, + tiers: { SIMPLE: ["primary"], MEDIUM: [], COMPLEX: [], REASONING: ["secondary"] }, + }; + const onRulesChange = vi.fn<(rules: KeywordTierRule[]) => void>(); + const editor = (rules: KeywordTierRule[]) => ( + + ); + const view = renderWithProviders(editor([])); + await user.click(screen.getByRole("button", { name: "Advanced routing options" })); + await user.click(screen.getByText("Advanced: Keyword/Semantic Matching")); + await user.click(screen.getByRole("button", { name: "Add keyword rule" })); + const rules = onRulesChange.mock.lastCall![0]; + expect(rules[0].tier).toBe("SIMPLE"); + view.rerender(editor(rules)); + await user.click(screen.getByRole("combobox", { name: "Route keyword rule 1 to tier" })); + expect((await screen.findAllByRole("option")).map((option) => option.textContent)).toEqual(["Simple", "Reasoning"]); +}); diff --git a/ui/litellm-dashboard/src/components/add_model/DefaultModelField.tsx b/ui/litellm-dashboard/src/components/add_model/DefaultModelField.tsx new file mode 100644 index 00000000000..a3ab85f7c13 --- /dev/null +++ b/ui/litellm-dashboard/src/components/add_model/DefaultModelField.tsx @@ -0,0 +1,56 @@ +import React from "react"; +import { Info } from "lucide-react"; +import { SearchSelect } from "@/components/shared/SearchSelect"; +import { SimpleTooltip } from "@/components/ui/tooltip"; +import type { ComplexityRouterConfigValue } from "./ComplexityRouterConfig"; +import { isForecastClassifier } from "./forecast_classifier_config"; +import { resolveComplexityDefaultModel } from "./tier_rows"; + +interface DefaultModelFieldProps { + value: ComplexityRouterConfigValue; + onChange: (value: ComplexityRouterConfigValue) => void; + modelOptions: { value: string; label: string }[]; +} + +const defaultModelPlaceholderFor = (derivedDefaultModel: string | undefined, isCustomSet: boolean): string => { + if (derivedDefaultModel) return `Derived from tiers: ${derivedDefaultModel}`; + return isCustomSet ? "Add a model to your fallback tier" : "Add a model to the Simple or Medium tier"; +}; + +const DefaultModelField = ({ value, onChange, modelOptions }: DefaultModelFieldProps) => { + const defaultModelPlaceholder = defaultModelPlaceholderFor( + resolveComplexityDefaultModel(value), + Boolean(value.custom_tier_set), + ); + // Clearing the select drops the key entirely rather than storing "", so an emptied pin reads as + // "track the tiers" everywhere downstream instead of as a blank model name. + const handleDefaultModelChange = (model: string | null | undefined) => { + onChange({ ...value, default_model: model || undefined }); + }; + + return ( +
+
+ Default Model + + + +
+ + + {isForecastClassifier(value.classifier_type) + ? "Used when routing cannot find a suitable model. Classifier failures route to the capable solver." + : 'Used when the tier the request lands in has no model, and when the classifier fails with "Route to the default model" selected.'} + +
+ ); +}; + +export default DefaultModelField; diff --git a/ui/litellm-dashboard/src/components/add_model/ForecastClassifierConfig.integration.test.tsx b/ui/litellm-dashboard/src/components/add_model/ForecastClassifierConfig.integration.test.tsx new file mode 100644 index 00000000000..c249a63899a --- /dev/null +++ b/ui/litellm-dashboard/src/components/add_model/ForecastClassifierConfig.integration.test.tsx @@ -0,0 +1,224 @@ +import React, { useState } from "react"; +import { describe, expect, it, vi } from "vitest"; +import userEvent from "@testing-library/user-event"; +import { fireEvent, renderWithProviders, screen } from "../../../tests/test-utils"; +import ClassificationMethodConfig from "./ClassificationMethodConfig"; +import AutoRouterClassifierTabs from "./AutoRouterClassifierTabs"; +import ForecastClassifierConfig from "./ForecastClassifierConfig"; +import type { ComplexityRouterConfigValue } from "./ComplexityRouterConfig"; +import { getForecastConfigError, isForecastClassifier } from "./forecast_classifier_config"; +import { buildUpdatedComplexityRouterConfig } from "../edit_auto_router/edit_auto_router_modal"; + +vi.mock("@/components/networking", async (importOriginal) => ({ + ...(await importOriginal()), + getComplexityScorerDefaults: vi.fn(async () => ({ + tier_boundaries: {}, + token_thresholds: {}, + dimension_weights: {}, + })), +})); + +const initial: ComplexityRouterConfigValue = { + classifier_type: "capability", + classifier_llm_config: { model: "judge", timeout_ms: 20000 }, + tiers: { SIMPLE: ["efficient"], MEDIUM: [], COMPLEX: [], REASONING: ["capable"] }, + capability_classifier_config: { efficient_tier: "SIMPLE", capable_tier: "REASONING", base_threshold: 0.7 }, +}; +const fuseInitial: ComplexityRouterConfigValue = { + ...initial, + classifier_type: "llm_v2", + capability_classifier_config: undefined, + adaptive: false, + llm_v2_config: { + efficient_profile: "Small solver", + capable_profile: "Larger solver", + harness: "One attempt", + max_quality_gap: 0.05, + }, +}; +const options = ["judge", "efficient", "capable"].map((model) => ({ value: model, label: model })); + +function Form({ initialValue = initial }: { initialValue?: ComplexityRouterConfigValue }) { + const [value, setValue] = useState(initialValue); + const [saved, setSaved] = useState(""); + return ( + <> + + {isForecastClassifier(value.classifier_type) ? ( + + ) : ( + + )} + + + {saved} + + ); +} + +describe("forecast classifier form", () => { + it("switches a populated standard router to Capability without saving hidden pools or their overrides", () => { + renderWithProviders( + , + ); + fireEvent.click(screen.getByRole("tab", { name: "Capability" })); + fireEvent.change(screen.getByLabelText("Solve probability threshold"), { target: { value: "0.7" } }); + expect(screen.getByRole("button", { name: "Save configuration" })).toBeEnabled(); + fireEvent.click(screen.getByRole("button", { name: "Save configuration" })); + const output = screen.getByRole("status", { name: "Saved configuration" }); + expect(output).toHaveTextContent('"classifier_type":"capability"'); + expect(output).toHaveTextContent('"SIMPLE":["efficient","second-efficient"]'); + expect(output).toHaveTextContent('"REASONING":["capable"]'); + expect(output).toHaveTextContent('"reasoning_effort":"low","speed":"fast","max_tokens":1024'); + expect(output).toHaveTextContent('"reasoning_effort":"high"'); + expect(output).toHaveTextContent('"adaptive":true'); + expect(output).not.toHaveTextContent("leftover-medium"); + expect(output).not.toHaveTextContent("leftover-complex"); + expect(output).not.toHaveTextContent('"plan_mode_min_tier"'); + }); + + it.each(["capability", "llm_v2"] as const)( + "carries non-default solver assignments when switching away from %s", + (source) => { + const pair = { efficient_tier: "MEDIUM", capable_tier: "COMPLEX" }; + const previous: ComplexityRouterConfigValue = { + ...(source === "capability" ? initial : fuseInitial), + tiers: { SIMPLE: [], MEDIUM: ["efficient"], COMPLEX: ["capable"], REASONING: [] }, + capability_classifier_config: + source === "capability" ? { ...initial.capability_classifier_config!, ...pair } : undefined, + llm_v2_config: source === "llm_v2" ? { ...fuseInitial.llm_v2_config!, ...pair } : undefined, + plan_mode_min_tier: "COMPLEX", + tier_model_params: { MEDIUM: { efficient: { max_tokens: 128 } }, COMPLEX: { capable: { speed: "fast" } } }, + }; + renderWithProviders(); + fireEvent.click(screen.getByRole("tab", { name: source === "capability" ? "Fuse v2" : "Capability" })); + if (source === "capability") { + fireEvent.change(screen.getByLabelText("Efficient solver profile"), { target: { value: "Small solver" } }); + fireEvent.change(screen.getByLabelText("Capable solver profile"), { target: { value: "Large solver" } }); + fireEvent.change(screen.getByLabelText("Harness and budget"), { target: { value: "One attempt" } }); + fireEvent.change(screen.getByLabelText("Maximum quality gap"), { target: { value: "0.05" } }); + } else { + fireEvent.change(screen.getByLabelText("Solve probability threshold"), { target: { value: "0.7" } }); + } + expect(screen.getByRole("button", { name: "Save configuration" })).toBeEnabled(); + fireEvent.click(screen.getByRole("button", { name: "Save configuration" })); + const output = screen.getByRole("status", { name: "Saved configuration" }); + expect(output).toHaveTextContent('"efficient_tier":"MEDIUM","capable_tier":"COMPLEX"'); + expect(output).toHaveTextContent('"tiers":{"MEDIUM":["efficient"],"COMPLEX":["capable"]}'); + expect(output).toHaveTextContent('"plan_mode_min_tier":"COMPLEX"'); + expect(output).toHaveTextContent('"max_tokens":128'); + expect(output).toHaveTextContent('"speed":"fast"'); + }, + ); + + it("keeps decimal and negative numbers when entered one character at a time", async () => { + const user = userEvent.setup(); + renderWithProviders(); + const threshold = screen.getByLabelText("Solve probability threshold"); + await user.clear(threshold); + await user.type(threshold, "0.65"); + expect(threshold).toHaveValue(0.65); + await user.click(screen.getByRole("button", { name: "Classifier options" })); + await user.click(screen.getByRole("switch", { name: "Use fitted calibration" })); + await user.type(screen.getByLabelText("Efficient intercept"), "-0.3"); + expect(screen.getByLabelText("Efficient intercept")).toHaveValue(-0.3); + }); + + it.each([ + ["capability", "LLM Classifier"], + ["capability", "Heuristic first"], + ["capability", "Hybrid"], + ["llm_v2", "LLM Classifier"], + ["llm_v2", "Heuristic first"], + ["llm_v2", "Hybrid"], + ] as const)("restores the current rubric when switching %s through Complexity to %s", async (source, target) => { + const user = userEvent.setup(); + renderWithProviders(); + fireEvent.click(screen.getByRole("tab", { name: "Complexity" })); + fireEvent.click(screen.getByRole("radio", { name: new RegExp(`^${target}`) })); + await user.click(screen.getByRole("combobox", { name: "Classifier Model" })); + await user.click(screen.getByRole("option", { name: "judge", exact: true })); + fireEvent.click(screen.getByRole("button", { name: "Save configuration" })); + const output = screen.getByRole("status", { name: "Saved configuration" }); + expect(output).toHaveTextContent('"classification_rubric":"agentic"'); + expect(output).toHaveTextContent('"model":"judge"'); + expect(output).toHaveTextContent('"timeout_ms":3000'); + expect(output).not.toHaveTextContent('"capability_classifier_config"'); + expect(output).not.toHaveTextContent('"llm_v2_config"'); + }); + + it("saves capability threshold edits together with fitted calibration", () => { + renderWithProviders(); + fireEvent.change(screen.getByLabelText("Solve probability threshold"), { target: { value: "0.6" } }); + fireEvent.click(screen.getByRole("button", { name: "Classifier options" })); + fireEvent.click(screen.getByRole("switch", { name: "Use fitted calibration" })); + expect(screen.getByRole("button", { name: "Save configuration" })).toBeDisabled(); + fireEvent.change(screen.getByLabelText("Calibration version"), { target: { value: "eval-a" } }); + fireEvent.change(screen.getByLabelText("Efficient slope"), { target: { value: "1.2" } }); + fireEvent.change(screen.getByLabelText("Efficient intercept"), { target: { value: "-0.3" } }); + fireEvent.click(screen.getByRole("button", { name: "Save configuration" })); + const output = screen.getByRole("status", { name: "Saved configuration" }); + expect(output).toHaveTextContent('"base_threshold":0.6'); + expect(output).toHaveTextContent('"calibration":{"version":"eval-a","slope":1.2,"intercept":-0.3}'); + fireEvent.change(screen.getByLabelText("Solve probability threshold"), { target: { value: "" } }); + expect(screen.getByRole("button", { name: "Save configuration" })).toBeDisabled(); + }); + + it("switches to Fuse, requires solver context, and saves the filled fields", () => { + renderWithProviders(); + fireEvent.click(screen.getByRole("tab", { name: "Fuse v2" })); + expect(screen.queryByLabelText("Solve probability threshold")).not.toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Save configuration" })).toBeDisabled(); + fireEvent.change(screen.getByLabelText("Efficient solver profile"), { + target: { value: "Short reasoning budget" }, + }); + fireEvent.change(screen.getByLabelText("Capable solver profile"), { target: { value: "Larger reasoning budget" } }); + fireEvent.change(screen.getByLabelText("Harness and budget"), { + target: { value: "Shell and test runner, one attempt" }, + }); + fireEvent.change(screen.getByLabelText("Maximum quality gap"), { target: { value: "0.05" } }); + fireEvent.click(screen.getByRole("button", { name: "Save configuration" })); + const output = screen.getByRole("status", { name: "Saved configuration" }); + expect(output).toHaveTextContent('"classifier_type":"llm_v2"'); + expect(output).toHaveTextContent('"efficient_profile":"Short reasoning budget"'); + expect(output).toHaveTextContent('"capable_profile":"Larger reasoning budget"'); + expect(output).toHaveTextContent('"harness":"Shell and test runner, one attempt"'); + expect(output).toHaveTextContent('"max_quality_gap":0.05'); + expect(output).toHaveTextContent('"adaptive":false'); + expect(output).not.toHaveTextContent('"capability_classifier_config"'); + }); +}); diff --git a/ui/litellm-dashboard/src/components/add_model/ForecastClassifierConfig.tsx b/ui/litellm-dashboard/src/components/add_model/ForecastClassifierConfig.tsx new file mode 100644 index 00000000000..b901a509435 --- /dev/null +++ b/ui/litellm-dashboard/src/components/add_model/ForecastClassifierConfig.tsx @@ -0,0 +1,433 @@ +import React from "react"; +import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible"; +import { ChevronRight } from "lucide-react"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { Textarea } from "@/components/ui/textarea"; +import { Switch } from "@/components/ui/switch"; +import { SearchSelect } from "@/components/shared/SearchSelect"; +import { MultiSelect } from "@/components/shared/MultiSelect"; +import { + type ComplexityRouterConfigValue, + type ClassificationFrequency, + classificationFrequency, + withClassificationFrequency, + DEFAULT_CLASSIFIER_TIMEOUT_MS, +} from "./ComplexityRouterConfig"; +import { + forecastTierNames, + forecastModels, + getForecastConfigError, + newCapabilitySettings, + newFuseSettings, + type CapabilitySettings, + type FuseSettings, +} from "./forecast_classifier_config"; +import ClassifierReasoningEffortSelect from "./ClassifierReasoningEffortSelect"; +import ClassifierCircuitBreakerConfig from "./ClassifierCircuitBreakerConfig"; +import ClassifierVisionConfig from "./ClassifierVisionConfig"; +import TierModelEffortRows from "./TierModelEffortRows"; +import { activeTierRows } from "./tier_rows"; +import { setTierModels } from "./tier_set_actions"; +import { tierRowLabel, setTierModelParam, setTierModelReasoningEffort } from "./complexity_router_tiers"; + +interface Props { + value: ComplexityRouterConfigValue; + onChange: (value: ComplexityRouterConfigValue) => void; + modelOptions: { value: string; label: string }[]; + effortOptionsByModel: Record; +} + +const NumberField = ({ + label, + value, + onChange, + min, + max, + step = "any", + help, +}: { + label: string; + value: number; + onChange: (value: number) => void; + min?: number; + max?: number; + step?: number | "any"; + help?: string; +}) => { + const id = React.useId(); + return ( +
+ + onChange(event.target.value === "" ? Number.NaN : Number(event.target.value))} + /> + {help &&

{help}

} +
+ ); +}; + +export const ForecastSolverModels = ({ + value, + onChange, + modelOptions, + effortOptionsByModel, + fastModeByModel, + additionalPoolsOnly = false, +}: Props & { fastModeByModel: Record; additionalPoolsOnly?: boolean }) => { + const id = React.useId(); + const names = forecastTierNames(value); + const additionalRows = + value.classifier_type === "capability" + ? activeTierRows(value) + .filter((row) => !names.includes(row.id) && row.models.length > 0) + .map((row) => ({ tier: row.id, label: `${tierRowLabel(row, value.tier_labels)} routing pool` })) + : []; + const rows = additionalPoolsOnly + ? additionalRows + : names.map((tier, index) => ({ tier, label: index === 0 ? "Efficient solver" : "Capable solver" })); + if (rows.length === 0) return null; + return ( +
+ {rows.map(({ tier, label }) => { + const models = forecastModels(value.tiers, tier); + const setModels = (next: string[]) => onChange(setTierModels(value, tier, next)); + return ( +
+ + {value.classifier_type === "llm_v2" ? ( + setModels(model ? [model] : [])} + /> + ) : ( + + )} + [model, efforts ?? []]), + )} + paramsByModel={value.tier_model_params?.[tier] ?? {}} + fastModeByModel={fastModeByModel} + onFastModeChange={(model, enabled) => + onChange({ + ...value, + tier_model_params: setTierModelParam(value.tier_model_params, tier, model, [ + "speed", + enabled ? "fast" : undefined, + ]), + }) + } + onEffortChange={(model, effort) => + onChange({ + ...value, + tier_model_params: setTierModelReasoningEffort(value.tier_model_params, tier, model, effort), + }) + } + /> +
+ ); + })} + {!additionalPoolsOnly && ( +

+ Invalid forecasts and classifier failures route to the capable solver +

+ )} +
+ ); +}; + +const CalibrationFields = ({ + label, + value, + onChange, + bounded = false, +}: { + label: string; + bounded?: boolean; + value: { slope: number; intercept: number }; + onChange: (value: { slope: number; intercept: number }) => void; +}) => ( +
+ onChange({ ...value, slope })} + /> + onChange({ ...value, intercept })} + /> +
+); + +const emptyCoefficients = () => ({ slope: Number.NaN, intercept: Number.NaN }); + +const ForecastClassifierConfig = ({ value, onChange, modelOptions, effortOptionsByModel }: Props) => { + const id = React.useId(); + const isCapability = value.classifier_type === "capability"; + const capability = value.capability_classifier_config ?? newCapabilitySettings(); + const fuse = value.llm_v2_config ?? newFuseSettings(); + const config = isCapability ? capability : fuse; + const llm = value.classifier_llm_config ?? { model: "", timeout_ms: DEFAULT_CLASSIFIER_TIMEOUT_MS }; + const updateCapability = (next: CapabilitySettings) => onChange({ ...value, capability_classifier_config: next }); + const updateFuse = (next: FuseSettings) => onChange({ ...value, llm_v2_config: next }); + const updateTransport = (patch: { max_output_tokens?: number; response_format?: "json_schema" | "json_object" }) => + isCapability ? updateCapability({ ...capability, ...patch }) : updateFuse({ ...fuse, ...patch }); + const setCalibrationVersion = (version: string) => { + if (isCapability && capability.calibration) + updateCapability({ ...capability, calibration: { ...capability.calibration, version } }); + if (!isCapability && fuse.calibration) updateFuse({ ...fuse, calibration: { ...fuse.calibration, version } }); + }; + const error = getForecastConfigError(value); + return ( +
+

+ {isCapability + ? "Forecasts whether the efficient solver can complete the task using the bundled capability card" + : "Forecasts success for both solvers and selects efficient when the estimated quality gap is within your allowance"} +

+
+ + { + if (model === llm.model) return; + onChange({ ...value, classifier_llm_config: { ...llm, model: model ?? "", reasoning_effort: undefined } }); + }} + /> +
+ {isCapability ? ( + <> + updateCapability({ ...capability, base_threshold })} + /> + + ) : ( + <> + {(["efficient_profile", "capable_profile", "harness"] as const).map((field) => { + const label = { + efficient_profile: "Efficient solver profile", + capable_profile: "Capable solver profile", + harness: "Harness and budget", + }[field]; + return ( +
+ +