From ce16db1a401f949e44ab94887a1cd507d8e4cc25 Mon Sep 17 00:00:00 2001 From: Chesars Date: Sun, 22 Mar 2026 10:12:07 -0300 Subject: [PATCH 01/21] fix: preserve role='assistant' in Azure streaming with include_usage When Azure sends stream_options.include_usage=True, it emits an initial chunk with choices=[] (prompt_filter_results) before the first content chunk. Previously, LiteLLM inflated this empty-choices chunk with a default StreamingChoices, which consumed the sent_first_chunk flag and caused strip_role_from_delta to strip role from the real first chunk. Additionally, the first real chunk with role='assistant' and content='' was discarded by is_chunk_non_empty as "empty". This fix: - Forwards chunks with choices=[] faithfully (no inflated default) - Only marks sent_first_chunk for chunks with real choices - Treats chunks with role in delta as non-empty - Guards choices[0] access in __next__/__anext__ and stream_chunk_builder Fixes #24221 --- .../streaming_chunk_builder_utils.py | 5 +- .../litellm_core_utils/streaming_handler.py | 32 ++-- litellm/main.py | 7 +- .../test_streaming_handler.py | 144 ++++++++++++++++++ 4 files changed, 173 insertions(+), 15 deletions(-) diff --git a/litellm/litellm_core_utils/streaming_chunk_builder_utils.py b/litellm/litellm_core_utils/streaming_chunk_builder_utils.py index 1935372e5d..1e13fb2dcf 100644 --- a/litellm/litellm_core_utils/streaming_chunk_builder_utils.py +++ b/litellm/litellm_core_utils/streaming_chunk_builder_utils.py @@ -119,7 +119,10 @@ class ChunkProcessor: model = ChunkProcessor._get_model_from_chunks(chunks, first_chunk_model) system_fingerprint = chunk.get("system_fingerprint", None) - role = chunk["choices"][0]["delta"]["role"] + first_chunk_with_choices = next( + (c for c in chunks if c.get("choices")), chunk + ) + role = first_chunk_with_choices["choices"][0]["delta"]["role"] finish_reason = "stop" for chunk in chunks: if "choices" in chunk and len(chunk["choices"]) > 0: diff --git a/litellm/litellm_core_utils/streaming_handler.py b/litellm/litellm_core_utils/streaming_handler.py index 67e4fadf63..d9de363d8c 100644 --- a/litellm/litellm_core_utils/streaming_handler.py +++ b/litellm/litellm_core_utils/streaming_handler.py @@ -831,6 +831,11 @@ class CustomStreamWrapper: "annotations" in model_response.choices[0].delta and model_response.choices[0].delta.annotations is not None ) + or ( + not self.sent_first_chunk + and hasattr(model_response.choices[0].delta, "role") + and model_response.choices[0].delta.role is not None + ) ): return True else: @@ -1556,6 +1561,7 @@ class CustomStreamWrapper: self.stream_options is not None and self.stream_options["include_usage"] is True ): + model_response.choices = [] return model_response return ## CHECK FOR TOOL USE @@ -1855,11 +1861,12 @@ class CustomStreamWrapper: response, cache_hit, ) # log response - choice = response.choices[0] - if isinstance(choice, StreamingChoices): - self.response_uptil_now += choice.delta.get("content", "") or "" - else: - self.response_uptil_now += "" + if response.choices: + choice = response.choices[0] + if isinstance(choice, StreamingChoices): + self.response_uptil_now += choice.delta.get("content", "") or "" + else: + self.response_uptil_now += "" self.rules.post_call_rules( input=self.response_uptil_now, model=self.model ) @@ -1867,7 +1874,7 @@ class CustomStreamWrapper: self.chunks.append(response) # Add mcp_list_tools to first chunk if present - if not self.sent_first_chunk: + if not self.sent_first_chunk and response.choices: response = self._add_mcp_list_tools_to_first_chunk(response) self.sent_first_chunk = True @@ -2035,16 +2042,17 @@ class CustomStreamWrapper: completion_start_time=datetime.datetime.now() ) - choice = processed_chunk.choices[0] - if isinstance(choice, StreamingChoices): - self.response_uptil_now += choice.delta.get("content", "") or "" - else: - self.response_uptil_now += "" + if processed_chunk.choices: + choice = processed_chunk.choices[0] + if isinstance(choice, StreamingChoices): + self.response_uptil_now += choice.delta.get("content", "") or "" + else: + self.response_uptil_now += "" self.rules.post_call_rules( input=self.response_uptil_now, model=self.model ) # Add mcp_list_tools to first chunk if present - if not self.sent_first_chunk: + if not self.sent_first_chunk and processed_chunk.choices: processed_chunk = self._add_mcp_list_tools_to_first_chunk( processed_chunk ) diff --git a/litellm/main.py b/litellm/main.py index eace9c630b..aaffc15384 100644 --- a/litellm/main.py +++ b/litellm/main.py @@ -7370,8 +7370,11 @@ def stream_chunk_builder( # noqa: PLR0915 if len(chunks) == 0: return None ## Route to the text completion logic - if isinstance( - chunks[0]["choices"][0], litellm.utils.TextChoices + first_chunk_with_choices = next( + (c for c in chunks if c["choices"]), None + ) + if first_chunk_with_choices is not None and isinstance( + first_chunk_with_choices["choices"][0], litellm.utils.TextChoices ): # route to the text completion logic return stream_chunk_builder_text_completion( chunks=chunks, messages=messages diff --git a/tests/test_litellm/litellm_core_utils/test_streaming_handler.py b/tests/test_litellm/litellm_core_utils/test_streaming_handler.py index 20e064ef8f..12bf51d3dd 100644 --- a/tests/test_litellm/litellm_core_utils/test_streaming_handler.py +++ b/tests/test_litellm/litellm_core_utils/test_streaming_handler.py @@ -1826,3 +1826,147 @@ async def test_custom_stream_wrapper_anext_exhaustion_raises_stop_async_iteratio pass # expected clean termination except RuntimeError as e: pytest.fail(f"PEP 479 regression: StopIteration leaked as RuntimeError: {e}") + + +# Azure streaming chunks that reproduce issue #24221: +# Azure sends an initial chunk with prompt_filter_results and choices=[], +# then a chunk with role='assistant' and content='', then content chunks. +# With stream_options.include_usage=True, the empty-choices chunk was +# forwarded with an inflated default choice, consuming the sent_first_chunk +# flag and causing strip_role_from_delta to strip the role from the real +# first chunk. +_AZURE_CHUNKS_WITH_PROMPT_FILTER = [ + # Chunk 1: prompt_filter_results, no choices (Azure-specific) + ModelResponseStream( + id="chatcmpl-abc123", + created=1742056047, + model=None, + object="chat.completion.chunk", + choices=[], + usage=None, + ), + # Chunk 2: first real chunk with role='assistant' and empty content + ModelResponseStream( + id="chatcmpl-abc123", + created=1742056047, + model=None, + object="chat.completion.chunk", + choices=[ + StreamingChoices( + finish_reason=None, + index=0, + delta=Delta(content="", role="assistant"), + ) + ], + usage=None, + ), + # Chunk 3: content + ModelResponseStream( + id="chatcmpl-abc123", + created=1742056047, + model=None, + object="chat.completion.chunk", + choices=[ + StreamingChoices( + finish_reason=None, + index=0, + delta=Delta(content="Hello!"), + ) + ], + usage=None, + ), + # Chunk 4: finish_reason + ModelResponseStream( + id="chatcmpl-abc123", + created=1742056047, + model=None, + object="chat.completion.chunk", + choices=[ + StreamingChoices( + finish_reason="stop", + index=0, + delta=Delta(), + ) + ], + usage=None, + ), + # Chunk 5: final usage chunk, no choices + ModelResponseStream( + id="chatcmpl-abc123", + created=1742056047, + model=None, + object="chat.completion.chunk", + choices=[], + usage=Usage( + completion_tokens=10, + prompt_tokens=20, + total_tokens=30, + ), + ), +] + + +@pytest.mark.parametrize("sync_mode", [True, False], ids=["sync", "async"]) +@pytest.mark.asyncio +async def test_azure_streaming_role_preserved_with_include_usage(sync_mode: bool): + """ + Regression test for https://github.com/BerriAI/litellm/issues/24221 + + Azure sends an initial chunk with choices=[] (prompt_filter_results) + before the first content chunk. With stream_options.include_usage=True, + this chunk was forwarded with an inflated default choice, which: + 1. Consumed the sent_first_chunk flag + 2. Caused strip_role_from_delta to strip role from the real first chunk + + The fix ensures: + - Chunks with choices=[] are forwarded faithfully (no inflated choices) + - sent_first_chunk is only marked for chunks with real choices + - Chunks with role in delta are not discarded as empty + """ + completion_stream = ModelResponseListIterator( + model_responses=_AZURE_CHUNKS_WITH_PROMPT_FILTER + ) + + response = CustomStreamWrapper( + completion_stream=completion_stream, + model="azure/gpt-5-nano", + custom_llm_provider="azure", + logging_obj=Logging( + model="azure/gpt-5-nano", + messages=[{"role": "user", "content": "Hey"}], + stream=True, + call_type="completion", + start_time=time.time(), + litellm_call_id="12345", + function_id="1245", + ), + stream_options={"include_usage": True}, + ) + + chunks = [] + if sync_mode: + for chunk in response: + chunks.append(chunk) + else: + async for chunk in response: + chunks.append(chunk) + + # The prompt_filter chunk should be forwarded with choices=[] + assert len(chunks[0].choices) == 0, ( + f"Expected prompt_filter chunk with choices=[], got {len(chunks[0].choices)} choices" + ) + + # At least one chunk must have role='assistant' in its delta + has_role = any( + len(c.choices) > 0 + and getattr(c.choices[0].delta, "role", None) == "assistant" + for c in chunks + ) + assert has_role, ( + "No chunk contained role='assistant' in delta (issue #24221). " + "Chunk deltas: " + + str([ + c.choices[0].delta if c.choices else "no choices" + for c in chunks + ]) + ) From 84b8c652e4b90038e28d3233de1e539a4b9ebe8a Mon Sep 17 00:00:00 2001 From: Chesars Date: Sun, 22 Mar 2026 10:33:21 -0300 Subject: [PATCH 02/21] fix: preserve tool_use input args in Anthropic adapter streaming When Gemini sends tool call arguments in the same streaming chunk as a content block transition, the Anthropic adapter discarded the processed_chunk containing the input_json_delta. This caused tool_use blocks to arrive with empty input: {}. Queue the processed_chunk alongside the block transition events when it contains input_json_delta data. Applied to both sync and async paths. Fixes #24134 --- .../adapters/streaming_iterator.py | 28 +-- ...al_pass_through_adapters_transformation.py | 171 ++++++++++++++++++ 2 files changed, 187 insertions(+), 12 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 6bddad09f2..806513f36f 100644 --- a/litellm/llms/anthropic/experimental_pass_through/adapters/streaming_iterator.py +++ b/litellm/llms/anthropic/experimental_pass_through/adapters/streaming_iterator.py @@ -129,8 +129,6 @@ class AnthropicStreamWrapper(AdapterCompletionStreamWrapper): if should_start_new_block and not self.sent_content_block_finish: # Queue the sequence: content_block_stop -> content_block_start - # The trigger chunk itself is not emitted as a delta since the - # content_block_start already carries the relevant information. self.chunk_queue.append( { "type": "content_block_stop", @@ -144,6 +142,14 @@ class AnthropicStreamWrapper(AdapterCompletionStreamWrapper): "content_block": self.current_content_block_start, } ) + # Gemini sends tool args in the same chunk as the block + # transition — queue the delta so it's not lost. + if ( + processed_chunk["type"] == "content_block_delta" + and processed_chunk.get("delta", {}).get("type") == "input_json_delta" + and processed_chunk["delta"].get("partial_json") + ): + self.chunk_queue.append(processed_chunk) self.sent_content_block_finish = False return self.chunk_queue.popleft() @@ -305,18 +311,12 @@ class AnthropicStreamWrapper(AdapterCompletionStreamWrapper): if not self.queued_usage_chunk: if should_start_new_block and not self.sent_content_block_finish: # Queue the sequence: content_block_stop -> content_block_start - # The trigger chunk itself is not emitted as a delta since the - # content_block_start already carries the relevant information. - - # 1. Stop current content block self.chunk_queue.append( { "type": "content_block_stop", "index": max(self.current_content_block_index - 1, 0), } ) - - # 2. Start new content block self.chunk_queue.append( { "type": "content_block_start", @@ -324,11 +324,15 @@ class AnthropicStreamWrapper(AdapterCompletionStreamWrapper): "content_block": self.current_content_block_start, } ) - - # Reset state for new block + # Gemini sends tool args in the same chunk as the block + # transition — queue the delta so it's not lost. + if ( + processed_chunk["type"] == "content_block_delta" + and processed_chunk.get("delta", {}).get("type") == "input_json_delta" + and processed_chunk["delta"].get("partial_json") + ): + self.chunk_queue.append(processed_chunk) self.sent_content_block_finish = False - - # Return the first queued item return self.chunk_queue.popleft() if ( diff --git a/tests/test_litellm/llms/anthropic/experimental_pass_through/adapters/test_anthropic_experimental_pass_through_adapters_transformation.py b/tests/test_litellm/llms/anthropic/experimental_pass_through/adapters/test_anthropic_experimental_pass_through_adapters_transformation.py index ae970e1ff0..3d1a45c6ca 100644 --- a/tests/test_litellm/llms/anthropic/experimental_pass_through/adapters/test_anthropic_experimental_pass_through_adapters_transformation.py +++ b/tests/test_litellm/llms/anthropic/experimental_pass_through/adapters/test_anthropic_experimental_pass_through_adapters_transformation.py @@ -25,6 +25,7 @@ from litellm.types.utils import ( Function, Message, ModelResponse, + ModelResponseStream, StreamingChoices, Usage, ) @@ -2111,3 +2112,173 @@ class TestTranslateAnthropicOutputFormatToOpenAI: assert self.adapter.translate_anthropic_output_format_to_openai("invalid") is None assert self.adapter.translate_anthropic_output_format_to_openai({"type": "text"}) is None assert self.adapter.translate_anthropic_output_format_to_openai({"type": "json_schema"}) is None + + +class TestAnthropicStreamWrapperToolArgs: + """ + Regression test for https://github.com/BerriAI/litellm/issues/24134 + + When Gemini sends tool call args in the same streaming chunk as a content + block transition, the Anthropic adapter was discarding the processed_chunk + containing input_json_delta. This verifies the args are preserved. + """ + + def _parse_sse_events(self, raw_bytes_list): + """Parse SSE bytes from the stream wrapper into event dicts.""" + import json + + events = [] + for raw in raw_bytes_list: + if not isinstance(raw, bytes): + continue + for line in raw.decode("utf-8").strip().split("\n"): + line = line.strip() + if line.startswith("data:"): + line = line[5:].strip() + if not line or line == "[DONE]" or line.startswith("event:"): + continue + try: + events.append(json.loads(line)) + except json.JSONDecodeError: + continue + return events + + def _build_chunks(self): + """Build mock OpenAI-format chunks simulating Gemini tool call response.""" + # Chunk 1: text content + text_chunk = ModelResponseStream( + id="chatcmpl-123", + created=1700000000, + model="gemini-2.0-flash", + object="chat.completion.chunk", + choices=[ + StreamingChoices( + index=0, + delta=Delta(content="Let me check", role="assistant"), + finish_reason=None, + ) + ], + ) + + # Chunk 2: tool call (triggers new content block + carries args) + tool_chunk = ModelResponseStream( + id="chatcmpl-123", + created=1700000000, + model="gemini-2.0-flash", + object="chat.completion.chunk", + choices=[ + StreamingChoices( + index=0, + delta=Delta( + tool_calls=[ + ChatCompletionDeltaToolCall( + id="call_123", + type="function", + function=Function( + name="get_weather", + arguments='{"city": "Tokyo"}', + ), + index=0, + ) + ] + ), + finish_reason=None, + ) + ], + ) + + # Chunk 3: finish + finish_chunk = ModelResponseStream( + id="chatcmpl-123", + created=1700000000, + model="gemini-2.0-flash", + object="chat.completion.chunk", + choices=[ + StreamingChoices( + index=0, + delta=Delta(), + finish_reason="stop", + ) + ], + usage=Usage(prompt_tokens=10, completion_tokens=5, total_tokens=15), + ) + + return [text_chunk, tool_chunk, finish_chunk] + + def _make_stream_wrapper(self, chunks): + from litellm.llms.anthropic.experimental_pass_through.adapters.streaming_iterator import ( + AnthropicStreamWrapper, + ) + + class SimpleIterator: + def __init__(self, items): + self._items = iter(items) + + def __iter__(self): + return self + + def __next__(self): + return next(self._items) + + def __aiter__(self): + return self + + async def __anext__(self): + try: + return next(self._items) + except StopIteration: + raise StopAsyncIteration + + return AnthropicStreamWrapper( + completion_stream=SimpleIterator(chunks), + model="gemini/gemini-2.0-flash", + ) + + def _find_tool_deltas(self, events): + return [ + e for e in events + if isinstance(e, dict) + and e.get("type") == "content_block_delta" + and isinstance(e.get("delta"), dict) + and e["delta"].get("type") == "input_json_delta" + ] + + def test_sync_tool_args_not_dropped(self): + import json + + chunks = self._build_chunks() + wrapper = self._make_stream_wrapper(chunks) + + events = list(wrapper) + tool_deltas = self._find_tool_deltas(events) + + assert len(tool_deltas) > 0, ( + f"No input_json_delta events found (issue #24134). " + f"Event types: {[e.get('type') for e in events if isinstance(e, dict)]}" + ) + + combined = "".join(d["delta"]["partial_json"] for d in tool_deltas) + parsed = json.loads(combined) + assert parsed == {"city": "Tokyo"} + + @pytest.mark.asyncio + async def test_async_tool_args_not_dropped(self): + import json + + chunks = self._build_chunks() + wrapper = self._make_stream_wrapper(chunks) + + events = [] + async for event in wrapper: + events.append(event) + + tool_deltas = self._find_tool_deltas(events) + + assert len(tool_deltas) > 0, ( + f"No input_json_delta events found (issue #24134). " + f"Event types: {[e.get('type') for e in events if isinstance(e, dict)]}" + ) + + combined = "".join(d["delta"]["partial_json"] for d in tool_deltas) + parsed = json.loads(combined) + assert parsed == {"city": "Tokyo"} From e5de1ecd92e251f29321ee80d5cab73017fd5054 Mon Sep 17 00:00:00 2001 From: Chesars Date: Sun, 22 Mar 2026 11:18:50 -0300 Subject: [PATCH 03/21] chore: remove unused _parse_sse_events helper in test --- ...al_pass_through_adapters_transformation.py | 20 ------------------- 1 file changed, 20 deletions(-) diff --git a/tests/test_litellm/llms/anthropic/experimental_pass_through/adapters/test_anthropic_experimental_pass_through_adapters_transformation.py b/tests/test_litellm/llms/anthropic/experimental_pass_through/adapters/test_anthropic_experimental_pass_through_adapters_transformation.py index 3d1a45c6ca..18febf2d1b 100644 --- a/tests/test_litellm/llms/anthropic/experimental_pass_through/adapters/test_anthropic_experimental_pass_through_adapters_transformation.py +++ b/tests/test_litellm/llms/anthropic/experimental_pass_through/adapters/test_anthropic_experimental_pass_through_adapters_transformation.py @@ -2123,26 +2123,6 @@ class TestAnthropicStreamWrapperToolArgs: containing input_json_delta. This verifies the args are preserved. """ - def _parse_sse_events(self, raw_bytes_list): - """Parse SSE bytes from the stream wrapper into event dicts.""" - import json - - events = [] - for raw in raw_bytes_list: - if not isinstance(raw, bytes): - continue - for line in raw.decode("utf-8").strip().split("\n"): - line = line.strip() - if line.startswith("data:"): - line = line[5:].strip() - if not line or line == "[DONE]" or line.startswith("event:"): - continue - try: - events.append(json.loads(line)) - except json.JSONDecodeError: - continue - return events - def _build_chunks(self): """Build mock OpenAI-format chunks simulating Gemini tool call response.""" # Chunk 1: text content From dd7269ee1464507fcbcd20708519ed1763baf5ad Mon Sep 17 00:00:00 2001 From: Chesars Date: Sun, 22 Mar 2026 17:18:40 -0300 Subject: [PATCH 04/21] fix(bedrock): sort assistant content blocks so text precedes toolUse MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When the Responses API converts function_call and message output items into chat completion messages, they can become two consecutive assistant messages. The Bedrock Converse transformer merges these into one, but the merge preserves input order — so if function_call came first, the toolUse block ends up before the text block. Claude models (Sonnet 4, Haiku 3.5+) reject this ordering with: "tool_use ids were found without tool_result blocks immediately after" Add _sort_bedrock_assistant_content_blocks() that reorders content blocks within assistant messages: reasoningContent → text → toolUse. Applied in both sync and async Bedrock Converse transformation paths. Fixes #24361 --- .../prompt_templates/factory.py | 36 +++++ .../test_bedrock_converse_dedup_factory.py | 131 ++++++++++++++++++ 2 files changed, 167 insertions(+) diff --git a/litellm/litellm_core_utils/prompt_templates/factory.py b/litellm/litellm_core_utils/prompt_templates/factory.py index d29ca1649f..22bf4c565c 100644 --- a/litellm/litellm_core_utils/prompt_templates/factory.py +++ b/litellm/litellm_core_utils/prompt_templates/factory.py @@ -4051,6 +4051,36 @@ def _deduplicate_bedrock_tool_content( return _deduplicate_bedrock_content_blocks(tool_content, "toolResult") +def _sort_bedrock_assistant_content_blocks( + blocks: List[BedrockContentBlock], +) -> List[BedrockContentBlock]: + """ + Sort assistant content blocks so that ``text`` blocks appear before + ``toolUse`` blocks. + + Bedrock requires all ``text`` blocks to precede any ``toolUse`` blocks + within an assistant message. When the Responses API converts + function_call items before message items, the resulting ``toolUse`` + blocks can end up before ``text`` blocks, causing Bedrock to reject + the request with a 400 error because the ``toolUse`` → ``toolResult`` + pairing is broken by the intervening ``text`` block. + + Sort order (stable): + 0 - reasoningContent + 1 - text / image / document / video / other non-tool blocks + 2 - toolUse + """ + + def _sort_key(block: BedrockContentBlock) -> int: + if "reasoningContent" in block: + return 0 + if "toolUse" in block: + return 2 + return 1 + + return sorted(blocks, key=_sort_key) + + def _insert_assistant_continue_message( messages: List[BedrockMessageBlock], assistant_continue_message: Optional[ @@ -4642,6 +4672,9 @@ class BedrockConverseMessagesProcessor: assistant_content = _deduplicate_bedrock_content_blocks( assistant_content, "toolUse" ) + assistant_content = _sort_bedrock_assistant_content_blocks( + assistant_content + ) if assistant_content: contents.append( @@ -5007,6 +5040,9 @@ def _bedrock_converse_messages_pt( # noqa: PLR0915 assistant_content = _deduplicate_bedrock_content_blocks( assistant_content, "toolUse" ) + assistant_content = _sort_bedrock_assistant_content_blocks( + assistant_content + ) if assistant_content: contents.append( diff --git a/tests/litellm_core_utils/test_bedrock_converse_dedup_factory.py b/tests/litellm_core_utils/test_bedrock_converse_dedup_factory.py index 5bd0c9993a..da3de22b42 100644 --- a/tests/litellm_core_utils/test_bedrock_converse_dedup_factory.py +++ b/tests/litellm_core_utils/test_bedrock_converse_dedup_factory.py @@ -9,6 +9,7 @@ from litellm.litellm_core_utils.prompt_templates.factory import ( _bedrock_converse_messages_pt, _deduplicate_bedrock_content_blocks, _deduplicate_bedrock_tool_content, + _sort_bedrock_assistant_content_blocks, BedrockConverseMessagesProcessor, ) @@ -445,3 +446,133 @@ def test_bedrock_converse_filters_empty_list_content(): assert len(text_blocks) == 2 assert text_blocks[0]["text"] == "Hello" assert text_blocks[1]["text"] == "World" + + +# --------------------------------------------------------------------------- +# Content block ordering tests (text before toolUse) +# --------------------------------------------------------------------------- + + +def _make_tooluse_before_text_messages(): + """Return messages where the assistant message has a tool_call followed by + a separate assistant message with text content. When merged, the toolUse + block would end up before the text block without sorting.""" + return [ + {"role": "user", "content": "What's the weather?"}, + { + "role": "assistant", + "content": None, + "tool_calls": [ + { + "id": "tooluse_abc123", + "type": "function", + "function": { + "name": "get_weather", + "arguments": '{"location": "Paris"}', + }, + } + ], + }, + { + "role": "assistant", + "content": "Let me check the weather for you.", + }, + { + "role": "tool", + "tool_call_id": "tooluse_abc123", + "content": '{"temp": 22}', + }, + ] + + +def test_sort_bedrock_assistant_content_blocks_text_before_tooluse(): + """Direct unit test: text blocks should come before toolUse blocks.""" + blocks = [ + {"toolUse": {"toolUseId": "id_1", "name": "fn_a", "input": {}}}, + {"text": "thinking..."}, + ] + + result = _sort_bedrock_assistant_content_blocks(blocks) + + assert len(result) == 2 + assert "text" in result[0] + assert "toolUse" in result[1] + + +def test_sort_bedrock_assistant_content_blocks_reasoning_first(): + """reasoningContent blocks should come before text and toolUse.""" + blocks = [ + {"toolUse": {"toolUseId": "id_1", "name": "fn_a", "input": {}}}, + {"text": "thinking..."}, + {"reasoningContent": {"reasoningText": {"text": "reasoning"}}}, + ] + + result = _sort_bedrock_assistant_content_blocks(blocks) + + assert "reasoningContent" in result[0] + assert "text" in result[1] + assert "toolUse" in result[2] + + +def test_sort_bedrock_assistant_content_blocks_preserves_order_when_correct(): + """If blocks are already in the correct order, sorting should not change them.""" + blocks = [ + {"text": "hello"}, + {"toolUse": {"toolUseId": "id_1", "name": "fn_a", "input": {}}}, + {"toolUse": {"toolUseId": "id_2", "name": "fn_b", "input": {}}}, + ] + + result = _sort_bedrock_assistant_content_blocks(blocks) + + assert result == blocks + + +def test_bedrock_converse_sorts_text_before_tooluse_sync(): + """Verify the sync path sorts text blocks before toolUse blocks in + assistant messages.""" + messages = _make_tooluse_before_text_messages() + result = _bedrock_converse_messages_pt(messages, MODEL, PROVIDER) + + assistant_msgs = [msg for msg in result if msg["role"] == "assistant"] + assert len(assistant_msgs) == 1 + + content = assistant_msgs[0]["content"] + text_indices = [i for i, b in enumerate(content) if "text" in b] + tool_indices = [i for i, b in enumerate(content) if "toolUse" in b] + + # All text blocks must come before all toolUse blocks + assert max(text_indices) < min(tool_indices), ( + f"text blocks at {text_indices} should all precede toolUse blocks at {tool_indices}" + ) + + +@pytest.mark.asyncio +async def test_bedrock_converse_sorts_text_before_tooluse_async(): + """Verify the async path sorts text blocks before toolUse blocks in + assistant messages.""" + messages = _make_tooluse_before_text_messages() + result = await BedrockConverseMessagesProcessor._bedrock_converse_messages_pt_async( + messages, MODEL, PROVIDER + ) + + assistant_msgs = [msg for msg in result if msg["role"] == "assistant"] + assert len(assistant_msgs) == 1 + + content = assistant_msgs[0]["content"] + text_indices = [i for i, b in enumerate(content) if "text" in b] + tool_indices = [i for i, b in enumerate(content) if "toolUse" in b] + + assert max(text_indices) < min(tool_indices), ( + f"text blocks at {text_indices} should all precede toolUse blocks at {tool_indices}" + ) + + +@pytest.mark.asyncio +async def test_bedrock_converse_content_ordering_sync_async_parity(): + """Sync and async paths should produce identical content block ordering.""" + messages = _make_tooluse_before_text_messages() + sync_result = _bedrock_converse_messages_pt(messages, MODEL, PROVIDER) + async_result = await BedrockConverseMessagesProcessor._bedrock_converse_messages_pt_async( + messages, MODEL, PROVIDER + ) + assert sync_result == async_result From db0d85eefd369b9a969a6cd43b6ba6d2d7386b87 Mon Sep 17 00:00:00 2001 From: Chesars Date: Sun, 22 Mar 2026 17:41:09 -0300 Subject: [PATCH 05/21] fix(gemini): filter unsupported params from embedding requests The Gemini batch embedding transformation was spreading all optional_params into the request body via **gemini_params. Params like max_tokens (injected by add_provider_specific_params_to_optional_params) would reach the Gemini API and cause a 400 BadRequestError. Extract _filter_embed_params() that maps dimensions/task_type and keeps only the fields Gemini embeddings actually accept (outputDimensionality, taskType, title). Applied to both transform_openai_input_gemini_content and transform_openai_input_gemini_embed_content. This also fixes drop_params: true not preventing the error, since the param was re-injected after the drop_params check. Fixes #24293 --- .../batch_embed_content_transformation.py | 25 ++++++---- .../vertex_ai/test_gemini_batch_embeddings.py | 48 +++++++++++++++++++ 2 files changed, 63 insertions(+), 10 deletions(-) diff --git a/litellm/llms/vertex_ai/gemini_embeddings/batch_embed_content_transformation.py b/litellm/llms/vertex_ai/gemini_embeddings/batch_embed_content_transformation.py index 08831a8215..c1733c7242 100644 --- a/litellm/llms/vertex_ai/gemini_embeddings/batch_embed_content_transformation.py +++ b/litellm/llms/vertex_ai/gemini_embeddings/batch_embed_content_transformation.py @@ -141,6 +141,19 @@ def _is_multimodal_input(input: EmbeddingInput) -> bool: return False +_SUPPORTED_EMBED_PARAMS = {"outputDimensionality", "taskType", "title"} + + +def _filter_embed_params(optional_params: dict) -> dict: + """Map and filter optional_params to only include Gemini embedding fields.""" + gemini_params = optional_params.copy() + if "dimensions" in gemini_params: + gemini_params["outputDimensionality"] = gemini_params.pop("dimensions") + if "task_type" in gemini_params: + gemini_params["taskType"] = gemini_params.pop("task_type") + return {k: v for k, v in gemini_params.items() if k in _SUPPORTED_EMBED_PARAMS} + + def transform_openai_input_gemini_content( input: EmbeddingInput, model: str, optional_params: dict ) -> VertexAIBatchEmbeddingsRequestBody: @@ -149,11 +162,7 @@ def transform_openai_input_gemini_content( """ gemini_model_name = "models/{}".format(model) - gemini_params = optional_params.copy() - if "dimensions" in gemini_params: - gemini_params["outputDimensionality"] = gemini_params.pop("dimensions") - if "task_type" in gemini_params: - gemini_params["taskType"] = gemini_params.pop("task_type") + gemini_params = _filter_embed_params(optional_params) requests: List[EmbedContentRequest] = [] if isinstance(input, str): @@ -195,11 +204,7 @@ def transform_openai_input_gemini_embed_content( """ resolved_files = resolved_files or {} - gemini_params = optional_params.copy() - if "dimensions" in gemini_params: - gemini_params["outputDimensionality"] = gemini_params.pop("dimensions") - if "task_type" in gemini_params: - gemini_params["taskType"] = gemini_params.pop("task_type") + gemini_params = _filter_embed_params(optional_params) input_list = [input] if isinstance(input, str) else input parts: List[PartType] = [] diff --git a/tests/litellm/llms/vertex_ai/test_gemini_batch_embeddings.py b/tests/litellm/llms/vertex_ai/test_gemini_batch_embeddings.py index a8e427d3bc..5f4c7b564c 100644 --- a/tests/litellm/llms/vertex_ai/test_gemini_batch_embeddings.py +++ b/tests/litellm/llms/vertex_ai/test_gemini_batch_embeddings.py @@ -19,6 +19,7 @@ import pytest import litellm from litellm.llms.custom_httpx.http_handler import HTTPHandler from litellm.llms.vertex_ai.gemini_embeddings.batch_embed_content_transformation import ( + _filter_embed_params, _is_multimodal_input, _parse_data_url, process_embed_content_response, @@ -563,3 +564,50 @@ def test_vertex_ai_text_only_embedding_uses_embed_content(): assert data["content"]["parts"][0]["text"] == "Hello, world!" assert len(response.data) == 1 + +# --------------------------------------------------------------------------- +# Unsupported params filtering tests (#24293) +# --------------------------------------------------------------------------- + + +def test_filter_embed_params_drops_unsupported(): + """Unsupported params like max_tokens should be filtered out.""" + result = _filter_embed_params({"dimensions": 768, "max_tokens": 256, "temperature": 0.5}) + assert result == {"outputDimensionality": 768} + + +def test_filter_embed_params_keeps_supported(): + """All supported Gemini embedding params should pass through.""" + result = _filter_embed_params({ + "dimensions": 768, + "task_type": "RETRIEVAL_DOCUMENT", + "title": "My doc", + }) + assert result == { + "outputDimensionality": 768, + "taskType": "RETRIEVAL_DOCUMENT", + "title": "My doc", + } + + +def test_batch_embed_content_drops_max_tokens(): + """max_tokens in optional_params should not appear in the batch request.""" + result = transform_openai_input_gemini_content( + input="test text", + model="text-embedding-004", + optional_params={"max_tokens": 256}, + ) + for request in result["requests"]: + assert "max_tokens" not in request + + +def test_embed_content_drops_max_tokens(): + """max_tokens in optional_params should not appear in the embedContent request.""" + result = transform_openai_input_gemini_embed_content( + input=["test text"], + model="gemini-embedding-001", + optional_params={"max_tokens": 256}, + resolved_files=None, + ) + assert "max_tokens" not in result + From fff83dd8a5a6ff4003a8db4c74e7b0fe951d1381 Mon Sep 17 00:00:00 2001 From: Chesars Date: Sun, 22 Mar 2026 17:51:07 -0300 Subject: [PATCH 06/21] fix(responses-api): apply GPT-5 temperature validation in Responses API The Responses API map_openai_params passed all params through without applying model-specific validation. GPT-5 models (except gpt-5-chat) only accept temperature=1 unless reasoning.effort="none" on models that support it (5.1, 5.2, 5.4). Reuse the existing OpenAIGPT5Config logic from chat completions to validate temperature in the Responses API path. With drop_params=True, unsupported temperature values are silently dropped; without it, UnsupportedParamsError is raised. Fixes #16090 --- .../llms/openai/responses/transformation.py | 35 +++++++- .../llms/openai/test_gpt5_transformation.py | 83 +++++++++++++++++++ 2 files changed, 116 insertions(+), 2 deletions(-) diff --git a/litellm/llms/openai/responses/transformation.py b/litellm/llms/openai/responses/transformation.py index cafb745862..bfcf0959f9 100644 --- a/litellm/llms/openai/responses/transformation.py +++ b/litellm/llms/openai/responses/transformation.py @@ -60,8 +60,39 @@ class OpenAIResponsesAPIConfig(BaseResponsesAPIConfig): model: str, drop_params: bool, ) -> Dict: - """No mapping applied since inputs are in OpenAI spec already""" - return dict(response_api_optional_params) + """No mapping applied since inputs are in OpenAI spec already. + + GPT-5 models have restrictions on temperature (only temperature=1 + is accepted unless reasoning_effort='none' on models that support it). + Apply the same validation used by the chat completions path. + """ + from litellm.llms.openai.chat.gpt_5_transformation import OpenAIGPT5Config + + params = dict(response_api_optional_params) + + if OpenAIGPT5Config.is_model_gpt_5_model(model=model): + temperature = params.get("temperature") + if temperature is not None and temperature != 1: + reasoning = params.get("reasoning") or {} + effort = reasoning.get("effort") if isinstance(reasoning, dict) else None + supports_none = OpenAIGPT5Config._supports_reasoning_effort_level( + model=model, level="none" + ) + if supports_none and (effort == "none" or effort is None): + pass # flexible temperature allowed + elif drop_params or litellm.drop_params: + params.pop("temperature", None) + else: + raise litellm.UnsupportedParamsError( + message=( + "gpt-5 models don't support temperature={}. " + "Only temperature=1 is supported. " + "To drop unsupported params set `litellm.drop_params = True`" + ).format(temperature), + status_code=400, + ) + + return params def transform_responses_api_request( self, diff --git a/tests/test_litellm/llms/openai/test_gpt5_transformation.py b/tests/test_litellm/llms/openai/test_gpt5_transformation.py index aebab33e80..f95dbbe9bf 100644 --- a/tests/test_litellm/llms/openai/test_gpt5_transformation.py +++ b/tests/test_litellm/llms/openai/test_gpt5_transformation.py @@ -1031,3 +1031,86 @@ def test_gpt5_1_logprobs_dropped_with_reasoning_effort(config: OpenAIConfig): assert "logprobs" not in params assert "top_p" not in params assert params["reasoning_effort"] == "high" + + +# --------------------------------------------------------------------------- +# Responses API: GPT-5 temperature validation (#16090) +# --------------------------------------------------------------------------- + +from litellm.llms.openai.responses.transformation import OpenAIResponsesAPIConfig +from litellm.types.llms.openai import ResponsesAPIOptionalRequestParams + + +@pytest.fixture() +def responses_config() -> OpenAIResponsesAPIConfig: + return OpenAIResponsesAPIConfig() + + +def test_responses_gpt5_drop_temperature( + responses_config: OpenAIResponsesAPIConfig, +): + """drop_params=True should silently drop temperature!=1 for gpt-5.""" + params = responses_config.map_openai_params( + response_api_optional_params=ResponsesAPIOptionalRequestParams( + temperature=0.5, + ), + model="gpt-5", + drop_params=True, + ) + assert "temperature" not in params + + +def test_responses_gpt5_reject_temperature( + responses_config: OpenAIResponsesAPIConfig, +): + """Without drop_params, temperature!=1 should raise UnsupportedParamsError.""" + with pytest.raises(litellm.UnsupportedParamsError): + responses_config.map_openai_params( + response_api_optional_params=ResponsesAPIOptionalRequestParams( + temperature=0.5, + ), + model="gpt-5", + drop_params=False, + ) + + +def test_responses_gpt5_allow_temperature_1( + responses_config: OpenAIResponsesAPIConfig, +): + """temperature=1 should always be allowed for gpt-5.""" + params = responses_config.map_openai_params( + response_api_optional_params=ResponsesAPIOptionalRequestParams( + temperature=1, + ), + model="gpt-5", + drop_params=False, + ) + assert params["temperature"] == 1 + + +def test_responses_gpt5_mini_drop_temperature( + responses_config: OpenAIResponsesAPIConfig, +): + """gpt-5-mini should also drop temperature!=1.""" + params = responses_config.map_openai_params( + response_api_optional_params=ResponsesAPIOptionalRequestParams( + temperature=0.7, + ), + model="gpt-5-mini", + drop_params=True, + ) + assert "temperature" not in params + + +def test_responses_gpt5_chat_allow_temperature( + responses_config: OpenAIResponsesAPIConfig, +): + """gpt-5-chat models should allow any temperature (not GPT-5 restricted).""" + params = responses_config.map_openai_params( + response_api_optional_params=ResponsesAPIOptionalRequestParams( + temperature=0.3, + ), + model="gpt-5-chat-latest", + drop_params=False, + ) + assert params["temperature"] == 0.3 From 040c6fe92057739c9605a4871f1ecd4ba3aaa6a6 Mon Sep 17 00:00:00 2001 From: Chesars Date: Sun, 22 Mar 2026 18:00:13 -0300 Subject: [PATCH 07/21] fix(gemini): read web search cost from model_info instead of hardcode The Gemini web search cost calculator hardcoded $0.035 per request, which is only correct for Gemini 2.x models. Gemini 3.x models charge $0.014 per request. Read from search_context_cost_per_query in model_info (same field used by Anthropic, OpenAI, and Perplexity) with fallback to the legacy $0.035 for models not yet updated in the JSON. Also add search_context_cost_per_query to all 25 Gemini models that support web search in model_prices_and_context_window.json. Fixes #24369 --- litellm/llms/gemini/cost_calculator.py | 14 +- model_prices_and_context_window.json | 198 ++++++++++++++---- tests/litellm/llms/gemini/__init__.py | 0 .../llms/gemini/test_cost_calculator.py | 61 ++++++ 4 files changed, 225 insertions(+), 48 deletions(-) create mode 100644 tests/litellm/llms/gemini/__init__.py create mode 100644 tests/litellm/llms/gemini/test_cost_calculator.py diff --git a/litellm/llms/gemini/cost_calculator.py b/litellm/llms/gemini/cost_calculator.py index 45850e0d66..5de6eedfbe 100644 --- a/litellm/llms/gemini/cost_calculator.py +++ b/litellm/llms/gemini/cost_calculator.py @@ -31,11 +31,17 @@ def cost_per_token( def cost_per_web_search_request(usage: "Usage", model_info: "ModelInfo") -> float: """ Calculates the cost per web search request for a given model, prompt tokens, and completion tokens. + + Uses ``search_context_cost_per_query`` from ``model_info`` when available + (keyed by ``search_context_size_medium`` as the default tier). Falls back + to the legacy $0.035 hardcode for models that haven't been updated yet. """ from litellm.types.utils import PromptTokensDetailsWrapper - # cost per web search request - cost_per_web_search_request = 35e-3 + # Resolve per-request cost from model_info, fallback to legacy default + _DEFAULT_COST = 35e-3 + search_costs = model_info.get("search_context_cost_per_query") or {} + _cost = search_costs.get("search_context_size_medium", _DEFAULT_COST) number_of_web_search_requests = 0 # Get number of web search requests @@ -47,10 +53,8 @@ def cost_per_web_search_request(usage: "Usage", model_info: "ModelInfo") -> floa and usage.prompt_tokens_details.web_search_requests is not None ): number_of_web_search_requests = usage.prompt_tokens_details.web_search_requests - else: - number_of_web_search_requests = 0 # Calculate total cost - total_cost = cost_per_web_search_request * number_of_web_search_requests + total_cost = _cost * number_of_web_search_requests return total_cost diff --git a/model_prices_and_context_window.json b/model_prices_and_context_window.json index c53ee943c5..31c1289b0b 100644 --- a/model_prices_and_context_window.json +++ b/model_prices_and_context_window.json @@ -13963,7 +13963,12 @@ "supports_url_context": true, "supports_vision": true, "supports_web_search": true, - "tpm": 8000000 + "tpm": 8000000, + "search_context_cost_per_query": { + "search_context_size_low": 0.035, + "search_context_size_medium": 0.035, + "search_context_size_high": 0.035 + } }, "gemini-2.5-flash-lite-preview-06-17": { "deprecation_date": "2025-11-18", @@ -14552,7 +14557,12 @@ "supports_vision": true, "supports_web_search": true, "tpm": 250000, - "rpm": 10 + "rpm": 10, + "search_context_cost_per_query": { + "search_context_size_low": 0.035, + "search_context_size_medium": 0.035, + "search_context_size_high": 0.035 + } }, "gemini-2.5-computer-use-preview-10-2025": { "input_cost_per_token": 1.25e-06, @@ -14604,17 +14614,14 @@ "uses_embed_content": true }, "vertex_ai/gemini-embedding-2-preview": { - "input_cost_per_audio_per_second": 0.00016, - "input_cost_per_image": 0.00012, - "input_cost_per_token": 2e-07, - "input_cost_per_video_per_second": 0.00079, + "input_cost_per_token": 1.5e-07, "litellm_provider": "vertex_ai", "max_input_tokens": 8192, "max_tokens": 8192, "mode": "embedding", "output_cost_per_token": 0, "output_vector_size": 3072, - "source": "https://cloud.google.com/vertex-ai/generative-ai/pricing", + "source": "https://ai.google.dev/gemini-api/docs/embeddings#multimodal", "supports_multimodal": true, "uses_embed_content": true }, @@ -14631,18 +14638,6 @@ "source": "https://cloud.google.com/vertex-ai/generative-ai/pricing", "uses_embed_content": true }, - "vertex_ai/gemini-embedding-2-preview": { - "input_cost_per_token": 1.5e-07, - "litellm_provider": "vertex_ai", - "max_input_tokens": 8192, - "max_tokens": 8192, - "mode": "embedding", - "output_cost_per_token": 0, - "output_vector_size": 3072, - "source": "https://ai.google.dev/gemini-api/docs/embeddings#multimodal", - "supports_multimodal": true, - "uses_embed_content": true - }, "gemini/gemini-embedding-001": { "input_cost_per_token": 1.5e-07, "litellm_provider": "gemini", @@ -14725,7 +14720,12 @@ "supports_url_context": true, "supports_vision": true, "supports_web_search": true, - "tpm": 10000000 + "tpm": 10000000, + "search_context_cost_per_query": { + "search_context_size_low": 0.035, + "search_context_size_medium": 0.035, + "search_context_size_high": 0.035 + } }, "gemini/gemini-2.0-flash-001": { "cache_read_input_token_cost": 2.5e-08, @@ -14764,7 +14764,12 @@ "supports_tool_choice": true, "supports_vision": true, "supports_web_search": true, - "tpm": 10000000 + "tpm": 10000000, + "search_context_cost_per_query": { + "search_context_size_low": 0.035, + "search_context_size_medium": 0.035, + "search_context_size_high": 0.035 + } }, "gemini/gemini-2.0-flash-lite": { "cache_read_input_token_cost": 1.875e-08, @@ -14801,7 +14806,12 @@ "supports_tool_choice": true, "supports_vision": true, "supports_web_search": true, - "tpm": 4000000 + "tpm": 4000000, + "search_context_cost_per_query": { + "search_context_size_low": 0.035, + "search_context_size_medium": 0.035, + "search_context_size_high": 0.035 + } }, "gemini/gemini-2.5-flash": { "cache_read_input_token_cost": 3e-08, @@ -14848,7 +14858,12 @@ "supports_url_context": true, "supports_vision": true, "supports_web_search": true, - "tpm": 8000000 + "tpm": 8000000, + "search_context_cost_per_query": { + "search_context_size_low": 0.035, + "search_context_size_medium": 0.035, + "search_context_size_high": 0.035 + } }, "gemini/gemini-2.5-flash-image": { "cache_read_input_token_cost": 3e-08, @@ -14898,7 +14913,12 @@ "supports_url_context": true, "supports_vision": true, "supports_web_search": true, - "tpm": 8000000 + "tpm": 8000000, + "search_context_cost_per_query": { + "search_context_size_low": 0.035, + "search_context_size_medium": 0.035, + "search_context_size_high": 0.035 + } }, "gemini/gemini-3-pro-image-preview": { "input_cost_per_image": 0.0011, @@ -14934,7 +14954,12 @@ "supports_response_schema": true, "supports_system_messages": true, "supports_vision": true, - "supports_web_search": true + "supports_web_search": true, + "search_context_cost_per_query": { + "search_context_size_low": 0.014, + "search_context_size_medium": 0.014, + "search_context_size_high": 0.014 + } }, "gemini/gemini-3.1-flash-image-preview": { "input_cost_per_token": 2.5e-07, @@ -14970,7 +14995,12 @@ "supports_response_schema": true, "supports_system_messages": true, "supports_vision": true, - "supports_web_search": true + "supports_web_search": true, + "search_context_cost_per_query": { + "search_context_size_low": 0.014, + "search_context_size_medium": 0.014, + "search_context_size_high": 0.014 + } }, "gemini/deep-research-pro-preview-12-2025": { "input_cost_per_image": 0.0011, @@ -15006,7 +15036,12 @@ "supports_response_schema": true, "supports_system_messages": true, "supports_vision": true, - "supports_web_search": true + "supports_web_search": true, + "search_context_cost_per_query": { + "search_context_size_low": 0.035, + "search_context_size_medium": 0.035, + "search_context_size_high": 0.035 + } }, "gemini/gemini-2.5-flash-lite": { "cache_read_input_token_cost": 1e-08, @@ -15053,7 +15088,12 @@ "supports_url_context": true, "supports_vision": true, "supports_web_search": true, - "tpm": 250000 + "tpm": 250000, + "search_context_cost_per_query": { + "search_context_size_low": 0.035, + "search_context_size_medium": 0.035, + "search_context_size_high": 0.035 + } }, "gemini/gemini-2.5-flash-lite-preview-09-2025": { "cache_read_input_token_cost": 1e-08, @@ -15100,7 +15140,12 @@ "supports_url_context": true, "supports_vision": true, "supports_web_search": true, - "tpm": 250000 + "tpm": 250000, + "search_context_cost_per_query": { + "search_context_size_low": 0.035, + "search_context_size_medium": 0.035, + "search_context_size_high": 0.035 + } }, "gemini/gemini-2.5-flash-preview-09-2025": { "cache_read_input_token_cost": 7.5e-08, @@ -15147,7 +15192,12 @@ "supports_url_context": true, "supports_vision": true, "supports_web_search": true, - "tpm": 250000 + "tpm": 250000, + "search_context_cost_per_query": { + "search_context_size_low": 0.035, + "search_context_size_medium": 0.035, + "search_context_size_high": 0.035 + } }, "gemini/gemini-flash-latest": { "cache_read_input_token_cost": 7.5e-08, @@ -15194,7 +15244,12 @@ "supports_url_context": true, "supports_vision": true, "supports_web_search": true, - "tpm": 250000 + "tpm": 250000, + "search_context_cost_per_query": { + "search_context_size_low": 0.035, + "search_context_size_medium": 0.035, + "search_context_size_high": 0.035 + } }, "gemini/gemini-flash-lite-latest": { "cache_read_input_token_cost": 2.5e-08, @@ -15241,7 +15296,12 @@ "supports_url_context": true, "supports_vision": true, "supports_web_search": true, - "tpm": 250000 + "tpm": 250000, + "search_context_cost_per_query": { + "search_context_size_low": 0.035, + "search_context_size_medium": 0.035, + "search_context_size_high": 0.035 + } }, "gemini/gemini-2.5-flash-lite-preview-06-17": { "deprecation_date": "2025-11-18", @@ -15289,7 +15349,12 @@ "supports_url_context": true, "supports_vision": true, "supports_web_search": true, - "tpm": 250000 + "tpm": 250000, + "search_context_cost_per_query": { + "search_context_size_low": 0.035, + "search_context_size_medium": 0.035, + "search_context_size_high": 0.035 + } }, "gemini/gemini-2.5-flash-preview-tts": { "input_cost_per_token": 3e-07, @@ -15352,7 +15417,12 @@ "supports_video_input": true, "supports_vision": true, "supports_web_search": true, - "tpm": 800000 + "tpm": 800000, + "search_context_cost_per_query": { + "search_context_size_low": 0.035, + "search_context_size_medium": 0.035, + "search_context_size_high": 0.035 + } }, "gemini/gemini-2.5-computer-use-preview-10-2025": { "input_cost_per_token": 1.25e-06, @@ -15440,7 +15510,12 @@ "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, - "supports_service_tier": true + "supports_service_tier": true, + "search_context_cost_per_query": { + "search_context_size_low": 0.014, + "search_context_size_medium": 0.014, + "search_context_size_high": 0.014 + } }, "gemini/gemini-3.1-flash-lite-preview": { "cache_read_input_token_cost": 2.5e-08, @@ -15493,7 +15568,12 @@ "supports_vision": true, "supports_web_search": true, "supports_native_streaming": true, - "tpm": 250000 + "tpm": 250000, + "search_context_cost_per_query": { + "search_context_size_low": 0.014, + "search_context_size_medium": 0.014, + "search_context_size_high": 0.014 + } }, "gemini/gemini-3-flash-preview": { "cache_read_input_token_cost": 5e-08, @@ -15546,7 +15626,12 @@ "input_cost_per_audio_token_priority": 1.8e-06, "output_cost_per_token_priority": 5.4e-06, "cache_read_input_token_cost_priority": 9e-08, - "supports_service_tier": true + "supports_service_tier": true, + "search_context_cost_per_query": { + "search_context_size_low": 0.014, + "search_context_size_medium": 0.014, + "search_context_size_high": 0.014 + } }, "gemini/gemini-3.1-pro-preview": { "cache_read_input_token_cost": 2e-07, @@ -15604,7 +15689,12 @@ "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, - "supports_service_tier": true + "supports_service_tier": true, + "search_context_cost_per_query": { + "search_context_size_low": 0.014, + "search_context_size_medium": 0.014, + "search_context_size_high": 0.014 + } }, "gemini/gemini-3.1-pro-preview-customtools": { "cache_read_input_token_cost": 2e-07, @@ -15662,7 +15752,12 @@ "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, - "supports_service_tier": true + "supports_service_tier": true, + "search_context_cost_per_query": { + "search_context_size_low": 0.014, + "search_context_size_medium": 0.014, + "search_context_size_high": 0.014 + } }, "gemini-3-flash-preview": { "cache_read_input_token_cost": 5e-08, @@ -15750,7 +15845,12 @@ "supports_tool_choice": true, "supports_vision": true, "supports_web_search": true, - "tpm": 10000000 + "tpm": 10000000, + "search_context_cost_per_query": { + "search_context_size_low": 0.035, + "search_context_size_medium": 0.035, + "search_context_size_high": 0.035 + } }, "gemini/gemini-exp-1114": { "input_cost_per_token": 0, @@ -31147,7 +31247,9 @@ "mode": "chat", "output_cost_per_token": 3.2e-06, "source": "https://cloud.google.com/vertex-ai/generative-ai/pricing#glm-models", - "supported_regions": ["global"], + "supported_regions": [ + "global" + ], "supports_function_calling": true, "supports_prompt_caching": true, "supports_reasoning": true, @@ -36756,7 +36858,12 @@ "supports_tool_choice": true, "supports_vision": true, "supports_web_search": true, - "tpm": 4000000 + "tpm": 4000000, + "search_context_cost_per_query": { + "search_context_size_low": 0.035, + "search_context_size_medium": 0.035, + "search_context_size_high": 0.035 + } }, "gemini-2.5-flash-native-audio-latest": { "input_cost_per_audio_token": 1e-06, @@ -37102,7 +37209,12 @@ "supports_video_input": true, "supports_vision": true, "supports_web_search": true, - "tpm": 800000 + "tpm": 800000, + "search_context_cost_per_query": { + "search_context_size_low": 0.035, + "search_context_size_medium": 0.035, + "search_context_size_high": 0.035 + } }, "gemini-exp-1206": { "cache_read_input_token_cost": 3e-08, diff --git a/tests/litellm/llms/gemini/__init__.py b/tests/litellm/llms/gemini/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/tests/litellm/llms/gemini/test_cost_calculator.py b/tests/litellm/llms/gemini/test_cost_calculator.py new file mode 100644 index 0000000000..19a7a83968 --- /dev/null +++ b/tests/litellm/llms/gemini/test_cost_calculator.py @@ -0,0 +1,61 @@ +import os +import sys + +import pytest + +sys.path.insert(0, os.path.abspath("../../../..")) + +from litellm.llms.gemini.cost_calculator import cost_per_web_search_request +from litellm.types.utils import PromptTokensDetailsWrapper, Usage + + +def _make_usage(web_search_requests: int) -> Usage: + return Usage( + prompt_tokens=100, + completion_tokens=50, + total_tokens=150, + prompt_tokens_details=PromptTokensDetailsWrapper( + web_search_requests=web_search_requests, + ), + ) + + +def test_web_search_cost_from_model_info(): + """Cost should come from model_info when search_context_cost_per_query is set.""" + model_info = { + "key": "gemini/gemini-3-flash-preview", + "search_context_cost_per_query": { + "search_context_size_low": 0.014, + "search_context_size_medium": 0.014, + "search_context_size_high": 0.014, + }, + } + cost = cost_per_web_search_request(usage=_make_usage(3), model_info=model_info) + assert cost == pytest.approx(0.014 * 3) + + +def test_web_search_cost_legacy_fallback(): + """Without search_context_cost_per_query, should fallback to $0.035.""" + model_info = {"key": "gemini/gemini-2.0-flash"} + cost = cost_per_web_search_request(usage=_make_usage(2), model_info=model_info) + assert cost == pytest.approx(0.035 * 2) + + +def test_web_search_cost_zero_requests(): + """Zero web search requests should return zero cost.""" + model_info = { + "key": "gemini/gemini-3-flash-preview", + "search_context_cost_per_query": { + "search_context_size_medium": 0.014, + }, + } + cost = cost_per_web_search_request(usage=_make_usage(0), model_info=model_info) + assert cost == 0.0 + + +def test_web_search_cost_no_usage_details(): + """Missing prompt_tokens_details should return zero cost.""" + model_info = {"key": "gemini/gemini-3-flash-preview"} + usage = Usage(prompt_tokens=100, completion_tokens=50, total_tokens=150) + cost = cost_per_web_search_request(usage=usage, model_info=model_info) + assert cost == 0.0 From 4c99f3ddd89b4b53ab25e55fdc45144b0cb30a45 Mon Sep 17 00:00:00 2001 From: Chesars Date: Sun, 22 Mar 2026 18:20:00 -0300 Subject: [PATCH 08/21] fix(gemini): differentiate billing model and extract web search requests - Gemini 2.x charges per grounded prompt (flat $0.035), clamped to 1 regardless of internal query count - Gemini 3.x charges per search query ($0.014 each) - Extract web_search_requests from groundingMetadata in non-streaming responses (parity with streaming path) - Add search_context_cost_per_query to vertex_ai and base Gemini entries - Move tests to tests/test_litellm/ (CI directory) --- litellm/llms/gemini/cost_calculator.py | 28 +- .../vertex_and_google_ai_studio_gemini.py | 9 + ...odel_prices_and_context_window_backup.json | 394 ++++++++++++++---- model_prices_and_context_window.json | 196 +++++++-- tests/litellm/llms/gemini/__init__.py | 0 .../llms/gemini/test_cost_calculator.py | 52 ++- 6 files changed, 551 insertions(+), 128 deletions(-) delete mode 100644 tests/litellm/llms/gemini/__init__.py rename tests/{litellm => test_litellm}/llms/gemini/test_cost_calculator.py (58%) diff --git a/litellm/llms/gemini/cost_calculator.py b/litellm/llms/gemini/cost_calculator.py index 5de6eedfbe..23ec6ad7c1 100644 --- a/litellm/llms/gemini/cost_calculator.py +++ b/litellm/llms/gemini/cost_calculator.py @@ -28,23 +28,32 @@ def cost_per_token( ) +def _is_gemini_3_model(model_info: "ModelInfo") -> bool: + """Check if the model is a Gemini 3.x variant based on its key.""" + key = model_info.get("key", "") + return "gemini-3" in key + + def cost_per_web_search_request(usage: "Usage", model_info: "ModelInfo") -> float: """ - Calculates the cost per web search request for a given model, prompt tokens, and completion tokens. + Calculates the cost of web search (grounding with Google Search). - Uses ``search_context_cost_per_query`` from ``model_info`` when available - (keyed by ``search_context_size_medium`` as the default tier). Falls back - to the legacy $0.035 hardcode for models that haven't been updated yet. + Billing differs by model family: + - Gemini 3.x: charged per individual search query ($0.014 default). + - Gemini 2.x and older: charged per grounded prompt ($0.035 default), + regardless of how many queries were executed internally. + + Reads the per-request cost from ``search_context_cost_per_query`` in + ``model_info`` when available, falling back to $0.035 for models not + yet updated in the pricing JSON. """ from litellm.types.utils import PromptTokensDetailsWrapper - # Resolve per-request cost from model_info, fallback to legacy default _DEFAULT_COST = 35e-3 search_costs = model_info.get("search_context_cost_per_query") or {} _cost = search_costs.get("search_context_size_medium", _DEFAULT_COST) number_of_web_search_requests = 0 - # Get number of web search requests if ( usage is not None and usage.prompt_tokens_details is not None @@ -54,7 +63,8 @@ def cost_per_web_search_request(usage: "Usage", model_info: "ModelInfo") -> floa ): number_of_web_search_requests = usage.prompt_tokens_details.web_search_requests - # Calculate total cost - total_cost = _cost * number_of_web_search_requests + # Gemini 2.x charges per grounded prompt (flat 1), not per query + if number_of_web_search_requests > 0 and not _is_gemini_3_model(model_info): + number_of_web_search_requests = 1 - return total_cost + return _cost * number_of_web_search_requests diff --git a/litellm/llms/vertex_ai/gemini/vertex_and_google_ai_studio_gemini.py b/litellm/llms/vertex_ai/gemini/vertex_and_google_ai_studio_gemini.py index 36f51c5b2f..3013d476ff 100644 --- a/litellm/llms/vertex_ai/gemini/vertex_and_google_ai_studio_gemini.py +++ b/litellm/llms/vertex_ai/gemini/vertex_and_google_ai_studio_gemini.py @@ -2378,6 +2378,15 @@ class VertexGeminiConfig(VertexAIBaseConfig, BaseConfig): usage = VertexGeminiConfig._calculate_usage( completion_response=completion_response ) + + web_search_requests = VertexGeminiConfig._calculate_web_search_requests( + grounding_metadata + ) + if web_search_requests is not None: + cast( + PromptTokensDetailsWrapper, usage.prompt_tokens_details + ).web_search_requests = web_search_requests + setattr(model_response, "usage", usage) ## ADD METADATA TO RESPONSE ## diff --git a/litellm/model_prices_and_context_window_backup.json b/litellm/model_prices_and_context_window_backup.json index c53ee943c5..540ec03476 100644 --- a/litellm/model_prices_and_context_window_backup.json +++ b/litellm/model_prices_and_context_window_backup.json @@ -13383,7 +13383,12 @@ "supports_tool_choice": true, "supports_url_context": true, "supports_vision": true, - "supports_web_search": true + "supports_web_search": true, + "search_context_cost_per_query": { + "search_context_size_low": 0.035, + "search_context_size_medium": 0.035, + "search_context_size_high": 0.035 + } }, "gemini-2.0-flash-001": { "cache_read_input_token_cost": 3.75e-08, @@ -13421,7 +13426,12 @@ "supports_system_messages": true, "supports_tool_choice": true, "supports_vision": true, - "supports_web_search": true + "supports_web_search": true, + "search_context_cost_per_query": { + "search_context_size_low": 0.035, + "search_context_size_medium": 0.035, + "search_context_size_high": 0.035 + } }, "gemini-2.0-flash-lite": { "cache_read_input_token_cost": 1.875e-08, @@ -13457,7 +13467,12 @@ "supports_system_messages": true, "supports_tool_choice": true, "supports_vision": true, - "supports_web_search": true + "supports_web_search": true, + "search_context_cost_per_query": { + "search_context_size_low": 0.035, + "search_context_size_medium": 0.035, + "search_context_size_high": 0.035 + } }, "gemini-2.0-flash-lite-001": { "cache_read_input_token_cost": 1.875e-08, @@ -13493,7 +13508,12 @@ "supports_system_messages": true, "supports_tool_choice": true, "supports_vision": true, - "supports_web_search": true + "supports_web_search": true, + "search_context_cost_per_query": { + "search_context_size_low": 0.035, + "search_context_size_medium": 0.035, + "search_context_size_high": 0.035 + } }, "gemini-2.5-flash": { "cache_read_input_token_cost": 3e-08, @@ -13538,7 +13558,12 @@ "supports_tool_choice": true, "supports_url_context": true, "supports_vision": true, - "supports_web_search": true + "supports_web_search": true, + "search_context_cost_per_query": { + "search_context_size_low": 0.035, + "search_context_size_medium": 0.035, + "search_context_size_high": 0.035 + } }, "gemini-2.5-flash-image": { "cache_read_input_token_cost": 3e-08, @@ -13621,7 +13646,12 @@ "supports_response_schema": true, "supports_system_messages": true, "supports_vision": true, - "supports_web_search": true + "supports_web_search": true, + "search_context_cost_per_query": { + "search_context_size_low": 0.014, + "search_context_size_medium": 0.014, + "search_context_size_high": 0.014 + } }, "gemini-3.1-flash-image-preview": { "input_cost_per_image": 0.00056, @@ -13653,7 +13683,12 @@ "supports_response_schema": true, "supports_system_messages": true, "supports_vision": true, - "supports_web_search": true + "supports_web_search": true, + "search_context_cost_per_query": { + "search_context_size_low": 0.014, + "search_context_size_medium": 0.014, + "search_context_size_high": 0.014 + } }, "gemini-3.1-flash-lite-preview": { "cache_read_input_token_cost": 2.5e-08, @@ -13704,7 +13739,12 @@ "supports_video_input": true, "supports_vision": true, "supports_web_search": true, - "supports_native_streaming": true + "supports_native_streaming": true, + "search_context_cost_per_query": { + "search_context_size_low": 0.014, + "search_context_size_medium": 0.014, + "search_context_size_high": 0.014 + } }, "deep-research-pro-preview-12-2025": { "input_cost_per_image": 0.0011, @@ -13783,7 +13823,12 @@ "supports_tool_choice": true, "supports_url_context": true, "supports_vision": true, - "supports_web_search": true + "supports_web_search": true, + "search_context_cost_per_query": { + "search_context_size_low": 0.035, + "search_context_size_medium": 0.035, + "search_context_size_high": 0.035 + } }, "gemini-2.5-flash-lite-preview-09-2025": { "cache_read_input_token_cost": 1e-08, @@ -13828,7 +13873,12 @@ "supports_tool_choice": true, "supports_url_context": true, "supports_vision": true, - "supports_web_search": true + "supports_web_search": true, + "search_context_cost_per_query": { + "search_context_size_low": 0.035, + "search_context_size_medium": 0.035, + "search_context_size_high": 0.035 + } }, "gemini-2.5-flash-preview-09-2025": { "cache_read_input_token_cost": 7.5e-08, @@ -13873,7 +13923,12 @@ "supports_tool_choice": true, "supports_url_context": true, "supports_vision": true, - "supports_web_search": true + "supports_web_search": true, + "search_context_cost_per_query": { + "search_context_size_low": 0.035, + "search_context_size_medium": 0.035, + "search_context_size_high": 0.035 + } }, "gemini-live-2.5-flash-preview-native-audio-09-2025": { "cache_read_input_token_cost": 7.5e-08, @@ -13917,7 +13972,12 @@ "supports_tool_choice": true, "supports_url_context": true, "supports_vision": true, - "supports_web_search": true + "supports_web_search": true, + "search_context_cost_per_query": { + "search_context_size_low": 0.035, + "search_context_size_medium": 0.035, + "search_context_size_high": 0.035 + } }, "gemini/gemini-live-2.5-flash-preview-native-audio-09-2025": { "cache_read_input_token_cost": 7.5e-08, @@ -13963,7 +14023,12 @@ "supports_url_context": true, "supports_vision": true, "supports_web_search": true, - "tpm": 8000000 + "tpm": 8000000, + "search_context_cost_per_query": { + "search_context_size_low": 0.035, + "search_context_size_medium": 0.035, + "search_context_size_high": 0.035 + } }, "gemini-2.5-flash-lite-preview-06-17": { "deprecation_date": "2025-11-18", @@ -14009,7 +14074,12 @@ "supports_tool_choice": true, "supports_url_context": true, "supports_vision": true, - "supports_web_search": true + "supports_web_search": true, + "search_context_cost_per_query": { + "search_context_size_low": 0.035, + "search_context_size_medium": 0.035, + "search_context_size_high": 0.035 + } }, "gemini-2.5-pro": { "cache_read_input_token_cost": 1.25e-07, @@ -14054,7 +14124,12 @@ "supports_tool_choice": true, "supports_video_input": true, "supports_vision": true, - "supports_web_search": true + "supports_web_search": true, + "search_context_cost_per_query": { + "search_context_size_low": 0.035, + "search_context_size_medium": 0.035, + "search_context_size_high": 0.035 + } }, "gemini-3-pro-preview": { "deprecation_date": "2026-03-26", @@ -14111,7 +14186,12 @@ "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, - "supports_service_tier": true + "supports_service_tier": true, + "search_context_cost_per_query": { + "search_context_size_low": 0.014, + "search_context_size_medium": 0.014, + "search_context_size_high": 0.014 + } }, "gemini-3.1-pro-preview": { "cache_read_input_token_cost": 2e-07, @@ -14169,7 +14249,12 @@ "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, - "supports_service_tier": true + "supports_service_tier": true, + "search_context_cost_per_query": { + "search_context_size_low": 0.014, + "search_context_size_medium": 0.014, + "search_context_size_high": 0.014 + } }, "gemini-3.1-pro-preview-customtools": { "cache_read_input_token_cost": 2e-07, @@ -14220,7 +14305,12 @@ "supports_vision": true, "supports_web_search": true, "supports_url_context": true, - "supports_native_streaming": true + "supports_native_streaming": true, + "search_context_cost_per_query": { + "search_context_size_low": 0.014, + "search_context_size_medium": 0.014, + "search_context_size_high": 0.014 + } }, "vertex_ai/gemini-3-pro-preview": { "cache_read_input_token_cost": 2e-07, @@ -14276,7 +14366,12 @@ "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, - "supports_service_tier": true + "supports_service_tier": true, + "search_context_cost_per_query": { + "search_context_size_low": 0.014, + "search_context_size_medium": 0.014, + "search_context_size_high": 0.014 + } }, "vertex_ai/gemini-3-flash-preview": { "cache_read_input_token_cost": 5e-08, @@ -14325,7 +14420,12 @@ "input_cost_per_audio_token_priority": 1.8e-06, "output_cost_per_token_priority": 5.4e-06, "cache_read_input_token_cost_priority": 9e-08, - "supports_service_tier": true + "supports_service_tier": true, + "search_context_cost_per_query": { + "search_context_size_low": 0.014, + "search_context_size_medium": 0.014, + "search_context_size_high": 0.014 + } }, "vertex_ai/gemini-3.1-pro-preview": { "cache_read_input_token_cost": 2e-07, @@ -14383,7 +14483,12 @@ "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, - "supports_service_tier": true + "supports_service_tier": true, + "search_context_cost_per_query": { + "search_context_size_low": 0.014, + "search_context_size_medium": 0.014, + "search_context_size_high": 0.014 + } }, "vertex_ai/gemini-3.1-pro-preview-customtools": { "cache_read_input_token_cost": 2e-07, @@ -14441,7 +14546,12 @@ "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, - "supports_service_tier": true + "supports_service_tier": true, + "search_context_cost_per_query": { + "search_context_size_low": 0.014, + "search_context_size_medium": 0.014, + "search_context_size_high": 0.014 + } }, "gemini-2.5-pro-preview-tts": { "cache_read_input_token_cost": 1.25e-07, @@ -14477,7 +14587,12 @@ "supports_system_messages": true, "supports_tool_choice": true, "supports_vision": true, - "supports_web_search": true + "supports_web_search": true, + "search_context_cost_per_query": { + "search_context_size_low": 0.035, + "search_context_size_medium": 0.035, + "search_context_size_high": 0.035 + } }, "gemini-robotics-er-1.5-preview": { "cache_read_input_token_cost": 0, @@ -14552,7 +14667,12 @@ "supports_vision": true, "supports_web_search": true, "tpm": 250000, - "rpm": 10 + "rpm": 10, + "search_context_cost_per_query": { + "search_context_size_low": 0.035, + "search_context_size_medium": 0.035, + "search_context_size_high": 0.035 + } }, "gemini-2.5-computer-use-preview-10-2025": { "input_cost_per_token": 1.25e-06, @@ -14604,17 +14724,14 @@ "uses_embed_content": true }, "vertex_ai/gemini-embedding-2-preview": { - "input_cost_per_audio_per_second": 0.00016, - "input_cost_per_image": 0.00012, - "input_cost_per_token": 2e-07, - "input_cost_per_video_per_second": 0.00079, + "input_cost_per_token": 1.5e-07, "litellm_provider": "vertex_ai", "max_input_tokens": 8192, "max_tokens": 8192, "mode": "embedding", "output_cost_per_token": 0, "output_vector_size": 3072, - "source": "https://cloud.google.com/vertex-ai/generative-ai/pricing", + "source": "https://ai.google.dev/gemini-api/docs/embeddings#multimodal", "supports_multimodal": true, "uses_embed_content": true }, @@ -14631,18 +14748,6 @@ "source": "https://cloud.google.com/vertex-ai/generative-ai/pricing", "uses_embed_content": true }, - "vertex_ai/gemini-embedding-2-preview": { - "input_cost_per_token": 1.5e-07, - "litellm_provider": "vertex_ai", - "max_input_tokens": 8192, - "max_tokens": 8192, - "mode": "embedding", - "output_cost_per_token": 0, - "output_vector_size": 3072, - "source": "https://ai.google.dev/gemini-api/docs/embeddings#multimodal", - "supports_multimodal": true, - "uses_embed_content": true - }, "gemini/gemini-embedding-001": { "input_cost_per_token": 1.5e-07, "litellm_provider": "gemini", @@ -14725,7 +14830,12 @@ "supports_url_context": true, "supports_vision": true, "supports_web_search": true, - "tpm": 10000000 + "tpm": 10000000, + "search_context_cost_per_query": { + "search_context_size_low": 0.035, + "search_context_size_medium": 0.035, + "search_context_size_high": 0.035 + } }, "gemini/gemini-2.0-flash-001": { "cache_read_input_token_cost": 2.5e-08, @@ -14764,7 +14874,12 @@ "supports_tool_choice": true, "supports_vision": true, "supports_web_search": true, - "tpm": 10000000 + "tpm": 10000000, + "search_context_cost_per_query": { + "search_context_size_low": 0.035, + "search_context_size_medium": 0.035, + "search_context_size_high": 0.035 + } }, "gemini/gemini-2.0-flash-lite": { "cache_read_input_token_cost": 1.875e-08, @@ -14801,7 +14916,12 @@ "supports_tool_choice": true, "supports_vision": true, "supports_web_search": true, - "tpm": 4000000 + "tpm": 4000000, + "search_context_cost_per_query": { + "search_context_size_low": 0.035, + "search_context_size_medium": 0.035, + "search_context_size_high": 0.035 + } }, "gemini/gemini-2.5-flash": { "cache_read_input_token_cost": 3e-08, @@ -14848,7 +14968,12 @@ "supports_url_context": true, "supports_vision": true, "supports_web_search": true, - "tpm": 8000000 + "tpm": 8000000, + "search_context_cost_per_query": { + "search_context_size_low": 0.035, + "search_context_size_medium": 0.035, + "search_context_size_high": 0.035 + } }, "gemini/gemini-2.5-flash-image": { "cache_read_input_token_cost": 3e-08, @@ -14898,7 +15023,12 @@ "supports_url_context": true, "supports_vision": true, "supports_web_search": true, - "tpm": 8000000 + "tpm": 8000000, + "search_context_cost_per_query": { + "search_context_size_low": 0.035, + "search_context_size_medium": 0.035, + "search_context_size_high": 0.035 + } }, "gemini/gemini-3-pro-image-preview": { "input_cost_per_image": 0.0011, @@ -14934,7 +15064,12 @@ "supports_response_schema": true, "supports_system_messages": true, "supports_vision": true, - "supports_web_search": true + "supports_web_search": true, + "search_context_cost_per_query": { + "search_context_size_low": 0.014, + "search_context_size_medium": 0.014, + "search_context_size_high": 0.014 + } }, "gemini/gemini-3.1-flash-image-preview": { "input_cost_per_token": 2.5e-07, @@ -14970,7 +15105,12 @@ "supports_response_schema": true, "supports_system_messages": true, "supports_vision": true, - "supports_web_search": true + "supports_web_search": true, + "search_context_cost_per_query": { + "search_context_size_low": 0.014, + "search_context_size_medium": 0.014, + "search_context_size_high": 0.014 + } }, "gemini/deep-research-pro-preview-12-2025": { "input_cost_per_image": 0.0011, @@ -15006,7 +15146,12 @@ "supports_response_schema": true, "supports_system_messages": true, "supports_vision": true, - "supports_web_search": true + "supports_web_search": true, + "search_context_cost_per_query": { + "search_context_size_low": 0.035, + "search_context_size_medium": 0.035, + "search_context_size_high": 0.035 + } }, "gemini/gemini-2.5-flash-lite": { "cache_read_input_token_cost": 1e-08, @@ -15053,7 +15198,12 @@ "supports_url_context": true, "supports_vision": true, "supports_web_search": true, - "tpm": 250000 + "tpm": 250000, + "search_context_cost_per_query": { + "search_context_size_low": 0.035, + "search_context_size_medium": 0.035, + "search_context_size_high": 0.035 + } }, "gemini/gemini-2.5-flash-lite-preview-09-2025": { "cache_read_input_token_cost": 1e-08, @@ -15100,7 +15250,12 @@ "supports_url_context": true, "supports_vision": true, "supports_web_search": true, - "tpm": 250000 + "tpm": 250000, + "search_context_cost_per_query": { + "search_context_size_low": 0.035, + "search_context_size_medium": 0.035, + "search_context_size_high": 0.035 + } }, "gemini/gemini-2.5-flash-preview-09-2025": { "cache_read_input_token_cost": 7.5e-08, @@ -15147,7 +15302,12 @@ "supports_url_context": true, "supports_vision": true, "supports_web_search": true, - "tpm": 250000 + "tpm": 250000, + "search_context_cost_per_query": { + "search_context_size_low": 0.035, + "search_context_size_medium": 0.035, + "search_context_size_high": 0.035 + } }, "gemini/gemini-flash-latest": { "cache_read_input_token_cost": 7.5e-08, @@ -15194,7 +15354,12 @@ "supports_url_context": true, "supports_vision": true, "supports_web_search": true, - "tpm": 250000 + "tpm": 250000, + "search_context_cost_per_query": { + "search_context_size_low": 0.035, + "search_context_size_medium": 0.035, + "search_context_size_high": 0.035 + } }, "gemini/gemini-flash-lite-latest": { "cache_read_input_token_cost": 2.5e-08, @@ -15241,7 +15406,12 @@ "supports_url_context": true, "supports_vision": true, "supports_web_search": true, - "tpm": 250000 + "tpm": 250000, + "search_context_cost_per_query": { + "search_context_size_low": 0.035, + "search_context_size_medium": 0.035, + "search_context_size_high": 0.035 + } }, "gemini/gemini-2.5-flash-lite-preview-06-17": { "deprecation_date": "2025-11-18", @@ -15289,7 +15459,12 @@ "supports_url_context": true, "supports_vision": true, "supports_web_search": true, - "tpm": 250000 + "tpm": 250000, + "search_context_cost_per_query": { + "search_context_size_low": 0.035, + "search_context_size_medium": 0.035, + "search_context_size_high": 0.035 + } }, "gemini/gemini-2.5-flash-preview-tts": { "input_cost_per_token": 3e-07, @@ -15352,7 +15527,12 @@ "supports_video_input": true, "supports_vision": true, "supports_web_search": true, - "tpm": 800000 + "tpm": 800000, + "search_context_cost_per_query": { + "search_context_size_low": 0.035, + "search_context_size_medium": 0.035, + "search_context_size_high": 0.035 + } }, "gemini/gemini-2.5-computer-use-preview-10-2025": { "input_cost_per_token": 1.25e-06, @@ -15440,7 +15620,12 @@ "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, - "supports_service_tier": true + "supports_service_tier": true, + "search_context_cost_per_query": { + "search_context_size_low": 0.014, + "search_context_size_medium": 0.014, + "search_context_size_high": 0.014 + } }, "gemini/gemini-3.1-flash-lite-preview": { "cache_read_input_token_cost": 2.5e-08, @@ -15493,7 +15678,12 @@ "supports_vision": true, "supports_web_search": true, "supports_native_streaming": true, - "tpm": 250000 + "tpm": 250000, + "search_context_cost_per_query": { + "search_context_size_low": 0.014, + "search_context_size_medium": 0.014, + "search_context_size_high": 0.014 + } }, "gemini/gemini-3-flash-preview": { "cache_read_input_token_cost": 5e-08, @@ -15546,7 +15736,12 @@ "input_cost_per_audio_token_priority": 1.8e-06, "output_cost_per_token_priority": 5.4e-06, "cache_read_input_token_cost_priority": 9e-08, - "supports_service_tier": true + "supports_service_tier": true, + "search_context_cost_per_query": { + "search_context_size_low": 0.014, + "search_context_size_medium": 0.014, + "search_context_size_high": 0.014 + } }, "gemini/gemini-3.1-pro-preview": { "cache_read_input_token_cost": 2e-07, @@ -15604,7 +15799,12 @@ "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, - "supports_service_tier": true + "supports_service_tier": true, + "search_context_cost_per_query": { + "search_context_size_low": 0.014, + "search_context_size_medium": 0.014, + "search_context_size_high": 0.014 + } }, "gemini/gemini-3.1-pro-preview-customtools": { "cache_read_input_token_cost": 2e-07, @@ -15662,7 +15862,12 @@ "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, - "supports_service_tier": true + "supports_service_tier": true, + "search_context_cost_per_query": { + "search_context_size_low": 0.014, + "search_context_size_medium": 0.014, + "search_context_size_high": 0.014 + } }, "gemini-3-flash-preview": { "cache_read_input_token_cost": 5e-08, @@ -15713,7 +15918,12 @@ "input_cost_per_audio_token_priority": 1.8e-06, "output_cost_per_token_priority": 5.4e-06, "cache_read_input_token_cost_priority": 9e-08, - "supports_service_tier": true + "supports_service_tier": true, + "search_context_cost_per_query": { + "search_context_size_low": 0.014, + "search_context_size_medium": 0.014, + "search_context_size_high": 0.014 + } }, "gemini/gemini-2.5-pro-preview-tts": { "cache_read_input_token_cost": 1.25e-07, @@ -15750,7 +15960,12 @@ "supports_tool_choice": true, "supports_vision": true, "supports_web_search": true, - "tpm": 10000000 + "tpm": 10000000, + "search_context_cost_per_query": { + "search_context_size_low": 0.035, + "search_context_size_medium": 0.035, + "search_context_size_high": 0.035 + } }, "gemini/gemini-exp-1114": { "input_cost_per_token": 0, @@ -30809,7 +31024,12 @@ "supports_video_input": true, "supports_vision": true, "supports_web_search": true, - "supports_native_streaming": true + "supports_native_streaming": true, + "search_context_cost_per_query": { + "search_context_size_low": 0.014, + "search_context_size_medium": 0.014, + "search_context_size_high": 0.014 + } }, "vertex_ai/deep-research-pro-preview-12-2025": { "input_cost_per_image": 0.0011, @@ -31147,7 +31367,9 @@ "mode": "chat", "output_cost_per_token": 3.2e-06, "source": "https://cloud.google.com/vertex-ai/generative-ai/pricing#glm-models", - "supported_regions": ["global"], + "supported_regions": [ + "global" + ], "supports_function_calling": true, "supports_prompt_caching": true, "supports_reasoning": true, @@ -36756,7 +36978,12 @@ "supports_tool_choice": true, "supports_vision": true, "supports_web_search": true, - "tpm": 4000000 + "tpm": 4000000, + "search_context_cost_per_query": { + "search_context_size_low": 0.035, + "search_context_size_medium": 0.035, + "search_context_size_high": 0.035 + } }, "gemini-2.5-flash-native-audio-latest": { "input_cost_per_audio_token": 1e-06, @@ -36963,7 +37190,12 @@ "supports_url_context": true, "supports_vision": true, "supports_web_search": true, - "tpm": 8000000 + "tpm": 8000000, + "search_context_cost_per_query": { + "search_context_size_low": 0.035, + "search_context_size_medium": 0.035, + "search_context_size_high": 0.035 + } }, "gemini-flash-lite-latest": { "cache_read_input_token_cost": 1e-08, @@ -37010,7 +37242,12 @@ "supports_url_context": true, "supports_vision": true, "supports_web_search": true, - "tpm": 250000 + "tpm": 250000, + "search_context_cost_per_query": { + "search_context_size_low": 0.035, + "search_context_size_medium": 0.035, + "search_context_size_high": 0.035 + } }, "gemini-pro-latest": { "cache_read_input_token_cost": 1.25e-07, @@ -37056,7 +37293,12 @@ "supports_video_input": true, "supports_vision": true, "supports_web_search": true, - "tpm": 800000 + "tpm": 800000, + "search_context_cost_per_query": { + "search_context_size_low": 0.035, + "search_context_size_medium": 0.035, + "search_context_size_high": 0.035 + } }, "gemini/gemini-pro-latest": { "cache_read_input_token_cost": 1.25e-07, @@ -37102,7 +37344,12 @@ "supports_video_input": true, "supports_vision": true, "supports_web_search": true, - "tpm": 800000 + "tpm": 800000, + "search_context_cost_per_query": { + "search_context_size_low": 0.035, + "search_context_size_medium": 0.035, + "search_context_size_high": 0.035 + } }, "gemini-exp-1206": { "cache_read_input_token_cost": 3e-08, @@ -37149,7 +37396,12 @@ "supports_url_context": true, "supports_vision": true, "supports_web_search": true, - "tpm": 8000000 + "tpm": 8000000, + "search_context_cost_per_query": { + "search_context_size_low": 0.035, + "search_context_size_medium": 0.035, + "search_context_size_high": 0.035 + } }, "vertex_ai/claude-sonnet-4-6@default": { "cache_creation_input_token_cost": 3.75e-06, diff --git a/model_prices_and_context_window.json b/model_prices_and_context_window.json index 31c1289b0b..540ec03476 100644 --- a/model_prices_and_context_window.json +++ b/model_prices_and_context_window.json @@ -13383,7 +13383,12 @@ "supports_tool_choice": true, "supports_url_context": true, "supports_vision": true, - "supports_web_search": true + "supports_web_search": true, + "search_context_cost_per_query": { + "search_context_size_low": 0.035, + "search_context_size_medium": 0.035, + "search_context_size_high": 0.035 + } }, "gemini-2.0-flash-001": { "cache_read_input_token_cost": 3.75e-08, @@ -13421,7 +13426,12 @@ "supports_system_messages": true, "supports_tool_choice": true, "supports_vision": true, - "supports_web_search": true + "supports_web_search": true, + "search_context_cost_per_query": { + "search_context_size_low": 0.035, + "search_context_size_medium": 0.035, + "search_context_size_high": 0.035 + } }, "gemini-2.0-flash-lite": { "cache_read_input_token_cost": 1.875e-08, @@ -13457,7 +13467,12 @@ "supports_system_messages": true, "supports_tool_choice": true, "supports_vision": true, - "supports_web_search": true + "supports_web_search": true, + "search_context_cost_per_query": { + "search_context_size_low": 0.035, + "search_context_size_medium": 0.035, + "search_context_size_high": 0.035 + } }, "gemini-2.0-flash-lite-001": { "cache_read_input_token_cost": 1.875e-08, @@ -13493,7 +13508,12 @@ "supports_system_messages": true, "supports_tool_choice": true, "supports_vision": true, - "supports_web_search": true + "supports_web_search": true, + "search_context_cost_per_query": { + "search_context_size_low": 0.035, + "search_context_size_medium": 0.035, + "search_context_size_high": 0.035 + } }, "gemini-2.5-flash": { "cache_read_input_token_cost": 3e-08, @@ -13538,7 +13558,12 @@ "supports_tool_choice": true, "supports_url_context": true, "supports_vision": true, - "supports_web_search": true + "supports_web_search": true, + "search_context_cost_per_query": { + "search_context_size_low": 0.035, + "search_context_size_medium": 0.035, + "search_context_size_high": 0.035 + } }, "gemini-2.5-flash-image": { "cache_read_input_token_cost": 3e-08, @@ -13621,7 +13646,12 @@ "supports_response_schema": true, "supports_system_messages": true, "supports_vision": true, - "supports_web_search": true + "supports_web_search": true, + "search_context_cost_per_query": { + "search_context_size_low": 0.014, + "search_context_size_medium": 0.014, + "search_context_size_high": 0.014 + } }, "gemini-3.1-flash-image-preview": { "input_cost_per_image": 0.00056, @@ -13653,7 +13683,12 @@ "supports_response_schema": true, "supports_system_messages": true, "supports_vision": true, - "supports_web_search": true + "supports_web_search": true, + "search_context_cost_per_query": { + "search_context_size_low": 0.014, + "search_context_size_medium": 0.014, + "search_context_size_high": 0.014 + } }, "gemini-3.1-flash-lite-preview": { "cache_read_input_token_cost": 2.5e-08, @@ -13704,7 +13739,12 @@ "supports_video_input": true, "supports_vision": true, "supports_web_search": true, - "supports_native_streaming": true + "supports_native_streaming": true, + "search_context_cost_per_query": { + "search_context_size_low": 0.014, + "search_context_size_medium": 0.014, + "search_context_size_high": 0.014 + } }, "deep-research-pro-preview-12-2025": { "input_cost_per_image": 0.0011, @@ -13783,7 +13823,12 @@ "supports_tool_choice": true, "supports_url_context": true, "supports_vision": true, - "supports_web_search": true + "supports_web_search": true, + "search_context_cost_per_query": { + "search_context_size_low": 0.035, + "search_context_size_medium": 0.035, + "search_context_size_high": 0.035 + } }, "gemini-2.5-flash-lite-preview-09-2025": { "cache_read_input_token_cost": 1e-08, @@ -13828,7 +13873,12 @@ "supports_tool_choice": true, "supports_url_context": true, "supports_vision": true, - "supports_web_search": true + "supports_web_search": true, + "search_context_cost_per_query": { + "search_context_size_low": 0.035, + "search_context_size_medium": 0.035, + "search_context_size_high": 0.035 + } }, "gemini-2.5-flash-preview-09-2025": { "cache_read_input_token_cost": 7.5e-08, @@ -13873,7 +13923,12 @@ "supports_tool_choice": true, "supports_url_context": true, "supports_vision": true, - "supports_web_search": true + "supports_web_search": true, + "search_context_cost_per_query": { + "search_context_size_low": 0.035, + "search_context_size_medium": 0.035, + "search_context_size_high": 0.035 + } }, "gemini-live-2.5-flash-preview-native-audio-09-2025": { "cache_read_input_token_cost": 7.5e-08, @@ -13917,7 +13972,12 @@ "supports_tool_choice": true, "supports_url_context": true, "supports_vision": true, - "supports_web_search": true + "supports_web_search": true, + "search_context_cost_per_query": { + "search_context_size_low": 0.035, + "search_context_size_medium": 0.035, + "search_context_size_high": 0.035 + } }, "gemini/gemini-live-2.5-flash-preview-native-audio-09-2025": { "cache_read_input_token_cost": 7.5e-08, @@ -14014,7 +14074,12 @@ "supports_tool_choice": true, "supports_url_context": true, "supports_vision": true, - "supports_web_search": true + "supports_web_search": true, + "search_context_cost_per_query": { + "search_context_size_low": 0.035, + "search_context_size_medium": 0.035, + "search_context_size_high": 0.035 + } }, "gemini-2.5-pro": { "cache_read_input_token_cost": 1.25e-07, @@ -14059,7 +14124,12 @@ "supports_tool_choice": true, "supports_video_input": true, "supports_vision": true, - "supports_web_search": true + "supports_web_search": true, + "search_context_cost_per_query": { + "search_context_size_low": 0.035, + "search_context_size_medium": 0.035, + "search_context_size_high": 0.035 + } }, "gemini-3-pro-preview": { "deprecation_date": "2026-03-26", @@ -14116,7 +14186,12 @@ "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, - "supports_service_tier": true + "supports_service_tier": true, + "search_context_cost_per_query": { + "search_context_size_low": 0.014, + "search_context_size_medium": 0.014, + "search_context_size_high": 0.014 + } }, "gemini-3.1-pro-preview": { "cache_read_input_token_cost": 2e-07, @@ -14174,7 +14249,12 @@ "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, - "supports_service_tier": true + "supports_service_tier": true, + "search_context_cost_per_query": { + "search_context_size_low": 0.014, + "search_context_size_medium": 0.014, + "search_context_size_high": 0.014 + } }, "gemini-3.1-pro-preview-customtools": { "cache_read_input_token_cost": 2e-07, @@ -14225,7 +14305,12 @@ "supports_vision": true, "supports_web_search": true, "supports_url_context": true, - "supports_native_streaming": true + "supports_native_streaming": true, + "search_context_cost_per_query": { + "search_context_size_low": 0.014, + "search_context_size_medium": 0.014, + "search_context_size_high": 0.014 + } }, "vertex_ai/gemini-3-pro-preview": { "cache_read_input_token_cost": 2e-07, @@ -14281,7 +14366,12 @@ "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, - "supports_service_tier": true + "supports_service_tier": true, + "search_context_cost_per_query": { + "search_context_size_low": 0.014, + "search_context_size_medium": 0.014, + "search_context_size_high": 0.014 + } }, "vertex_ai/gemini-3-flash-preview": { "cache_read_input_token_cost": 5e-08, @@ -14330,7 +14420,12 @@ "input_cost_per_audio_token_priority": 1.8e-06, "output_cost_per_token_priority": 5.4e-06, "cache_read_input_token_cost_priority": 9e-08, - "supports_service_tier": true + "supports_service_tier": true, + "search_context_cost_per_query": { + "search_context_size_low": 0.014, + "search_context_size_medium": 0.014, + "search_context_size_high": 0.014 + } }, "vertex_ai/gemini-3.1-pro-preview": { "cache_read_input_token_cost": 2e-07, @@ -14388,7 +14483,12 @@ "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, - "supports_service_tier": true + "supports_service_tier": true, + "search_context_cost_per_query": { + "search_context_size_low": 0.014, + "search_context_size_medium": 0.014, + "search_context_size_high": 0.014 + } }, "vertex_ai/gemini-3.1-pro-preview-customtools": { "cache_read_input_token_cost": 2e-07, @@ -14446,7 +14546,12 @@ "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, - "supports_service_tier": true + "supports_service_tier": true, + "search_context_cost_per_query": { + "search_context_size_low": 0.014, + "search_context_size_medium": 0.014, + "search_context_size_high": 0.014 + } }, "gemini-2.5-pro-preview-tts": { "cache_read_input_token_cost": 1.25e-07, @@ -14482,7 +14587,12 @@ "supports_system_messages": true, "supports_tool_choice": true, "supports_vision": true, - "supports_web_search": true + "supports_web_search": true, + "search_context_cost_per_query": { + "search_context_size_low": 0.035, + "search_context_size_medium": 0.035, + "search_context_size_high": 0.035 + } }, "gemini-robotics-er-1.5-preview": { "cache_read_input_token_cost": 0, @@ -15808,7 +15918,12 @@ "input_cost_per_audio_token_priority": 1.8e-06, "output_cost_per_token_priority": 5.4e-06, "cache_read_input_token_cost_priority": 9e-08, - "supports_service_tier": true + "supports_service_tier": true, + "search_context_cost_per_query": { + "search_context_size_low": 0.014, + "search_context_size_medium": 0.014, + "search_context_size_high": 0.014 + } }, "gemini/gemini-2.5-pro-preview-tts": { "cache_read_input_token_cost": 1.25e-07, @@ -30909,7 +31024,12 @@ "supports_video_input": true, "supports_vision": true, "supports_web_search": true, - "supports_native_streaming": true + "supports_native_streaming": true, + "search_context_cost_per_query": { + "search_context_size_low": 0.014, + "search_context_size_medium": 0.014, + "search_context_size_high": 0.014 + } }, "vertex_ai/deep-research-pro-preview-12-2025": { "input_cost_per_image": 0.0011, @@ -37070,7 +37190,12 @@ "supports_url_context": true, "supports_vision": true, "supports_web_search": true, - "tpm": 8000000 + "tpm": 8000000, + "search_context_cost_per_query": { + "search_context_size_low": 0.035, + "search_context_size_medium": 0.035, + "search_context_size_high": 0.035 + } }, "gemini-flash-lite-latest": { "cache_read_input_token_cost": 1e-08, @@ -37117,7 +37242,12 @@ "supports_url_context": true, "supports_vision": true, "supports_web_search": true, - "tpm": 250000 + "tpm": 250000, + "search_context_cost_per_query": { + "search_context_size_low": 0.035, + "search_context_size_medium": 0.035, + "search_context_size_high": 0.035 + } }, "gemini-pro-latest": { "cache_read_input_token_cost": 1.25e-07, @@ -37163,7 +37293,12 @@ "supports_video_input": true, "supports_vision": true, "supports_web_search": true, - "tpm": 800000 + "tpm": 800000, + "search_context_cost_per_query": { + "search_context_size_low": 0.035, + "search_context_size_medium": 0.035, + "search_context_size_high": 0.035 + } }, "gemini/gemini-pro-latest": { "cache_read_input_token_cost": 1.25e-07, @@ -37261,7 +37396,12 @@ "supports_url_context": true, "supports_vision": true, "supports_web_search": true, - "tpm": 8000000 + "tpm": 8000000, + "search_context_cost_per_query": { + "search_context_size_low": 0.035, + "search_context_size_medium": 0.035, + "search_context_size_high": 0.035 + } }, "vertex_ai/claude-sonnet-4-6@default": { "cache_creation_input_token_cost": 3.75e-06, diff --git a/tests/litellm/llms/gemini/__init__.py b/tests/litellm/llms/gemini/__init__.py deleted file mode 100644 index e69de29bb2..0000000000 diff --git a/tests/litellm/llms/gemini/test_cost_calculator.py b/tests/test_litellm/llms/gemini/test_cost_calculator.py similarity index 58% rename from tests/litellm/llms/gemini/test_cost_calculator.py rename to tests/test_litellm/llms/gemini/test_cost_calculator.py index 19a7a83968..baa3aab318 100644 --- a/tests/litellm/llms/gemini/test_cost_calculator.py +++ b/tests/test_litellm/llms/gemini/test_cost_calculator.py @@ -1,11 +1,9 @@ -import os -import sys - import pytest -sys.path.insert(0, os.path.abspath("../../../..")) - -from litellm.llms.gemini.cost_calculator import cost_per_web_search_request +from litellm.llms.gemini.cost_calculator import ( + _is_gemini_3_model, + cost_per_web_search_request, +) from litellm.types.utils import PromptTokensDetailsWrapper, Usage @@ -20,8 +18,8 @@ def _make_usage(web_search_requests: int) -> Usage: ) -def test_web_search_cost_from_model_info(): - """Cost should come from model_info when search_context_cost_per_query is set.""" +def test_gemini3_charged_per_query(): + """Gemini 3.x should charge per search query at $0.014.""" model_info = { "key": "gemini/gemini-3-flash-preview", "search_context_cost_per_query": { @@ -34,28 +32,42 @@ def test_web_search_cost_from_model_info(): assert cost == pytest.approx(0.014 * 3) -def test_web_search_cost_legacy_fallback(): - """Without search_context_cost_per_query, should fallback to $0.035.""" - model_info = {"key": "gemini/gemini-2.0-flash"} - cost = cost_per_web_search_request(usage=_make_usage(2), model_info=model_info) - assert cost == pytest.approx(0.035 * 2) - - -def test_web_search_cost_zero_requests(): - """Zero web search requests should return zero cost.""" +def test_gemini2_charged_per_prompt(): + """Gemini 2.x should charge 1 grounded prompt regardless of query count.""" model_info = { - "key": "gemini/gemini-3-flash-preview", + "key": "gemini/gemini-2.5-flash", "search_context_cost_per_query": { - "search_context_size_medium": 0.014, + "search_context_size_medium": 0.035, }, } + cost = cost_per_web_search_request(usage=_make_usage(3), model_info=model_info) + assert cost == pytest.approx(0.035 * 1) + + +def test_legacy_fallback(): + """Without search_context_cost_per_query, should fallback to $0.035 × 1.""" + model_info = {"key": "gemini/gemini-2.0-flash"} + cost = cost_per_web_search_request(usage=_make_usage(2), model_info=model_info) + assert cost == pytest.approx(0.035 * 1) + + +def test_zero_requests(): + """Zero web search requests should return zero cost.""" + model_info = {"key": "gemini/gemini-3-flash-preview"} cost = cost_per_web_search_request(usage=_make_usage(0), model_info=model_info) assert cost == 0.0 -def test_web_search_cost_no_usage_details(): +def test_no_usage_details(): """Missing prompt_tokens_details should return zero cost.""" model_info = {"key": "gemini/gemini-3-flash-preview"} usage = Usage(prompt_tokens=100, completion_tokens=50, total_tokens=150) cost = cost_per_web_search_request(usage=usage, model_info=model_info) assert cost == 0.0 + + +def test_is_gemini_3_model(): + assert _is_gemini_3_model({"key": "gemini/gemini-3-flash-preview"}) + assert _is_gemini_3_model({"key": "gemini/gemini-3.1-pro-preview"}) + assert not _is_gemini_3_model({"key": "gemini/gemini-2.5-flash"}) + assert not _is_gemini_3_model({"key": "gemini/gemini-2.0-flash"}) From f8a9bbd53788cab72f1e26eb97217a20fc1d7111 Mon Sep 17 00:00:00 2001 From: Chesars Date: Sun, 22 Mar 2026 18:22:11 -0300 Subject: [PATCH 09/21] test: add supports_none branch coverage for Responses API GPT-5 temperature Add tests for the gpt-5.1/5.2/5.4 reasoning.effort interaction: - gpt-5.1 with no reasoning allows flexible temperature - gpt-5.1 with effort='high' drops temperature - gpt-5.4 with effort='none' allows flexible temperature --- .../llms/openai/test_gpt5_transformation.py | 45 +++++++++++++++++++ 1 file changed, 45 insertions(+) diff --git a/tests/test_litellm/llms/openai/test_gpt5_transformation.py b/tests/test_litellm/llms/openai/test_gpt5_transformation.py index f95dbbe9bf..6383bfc9e1 100644 --- a/tests/test_litellm/llms/openai/test_gpt5_transformation.py +++ b/tests/test_litellm/llms/openai/test_gpt5_transformation.py @@ -1114,3 +1114,48 @@ def test_responses_gpt5_chat_allow_temperature( drop_params=False, ) assert params["temperature"] == 0.3 + + +def test_responses_gpt51_allow_temperature_no_reasoning( + responses_config: OpenAIResponsesAPIConfig, +): + """gpt-5.1 supports reasoning_effort='none'; no reasoning defaults to 'none', + so temperature should be allowed.""" + params = responses_config.map_openai_params( + response_api_optional_params=ResponsesAPIOptionalRequestParams( + temperature=0.5, + ), + model="gpt-5.1", + drop_params=False, + ) + assert params["temperature"] == 0.5 + + +def test_responses_gpt51_drop_temperature_with_high_effort( + responses_config: OpenAIResponsesAPIConfig, +): + """gpt-5.1 with reasoning.effort='high' should drop temperature!=1.""" + params = responses_config.map_openai_params( + response_api_optional_params=ResponsesAPIOptionalRequestParams( + temperature=0.5, + reasoning={"effort": "high"}, + ), + model="gpt-5.1", + drop_params=True, + ) + assert "temperature" not in params + + +def test_responses_gpt54_allow_temperature_effort_none( + responses_config: OpenAIResponsesAPIConfig, +): + """gpt-5.4 with explicit reasoning.effort='none' should allow temperature.""" + params = responses_config.map_openai_params( + response_api_optional_params=ResponsesAPIOptionalRequestParams( + temperature=0.7, + reasoning={"effort": "none"}, + ), + model="gpt-5.4", + drop_params=False, + ) + assert params["temperature"] == 0.7 From a0d1d22bcfa53f52de5f76ebdafbcde9cfad35c5 Mon Sep 17 00:00:00 2001 From: Chesars Date: Sun, 22 Mar 2026 18:25:01 -0300 Subject: [PATCH 10/21] docs: add Web Search Cost Tracking section Document how each provider bills for web search, the search_context_cost_per_query field in model_prices JSON, how to override pricing via proxy config, and how LiteLLM extracts web_search_requests from each provider's response. --- docs/my-website/docs/completion/web_search.md | 75 +++++++++++++++++++ 1 file changed, 75 insertions(+) diff --git a/docs/my-website/docs/completion/web_search.md b/docs/my-website/docs/completion/web_search.md index 1f5ba2dee4..375e9a6375 100644 --- a/docs/my-website/docs/completion/web_search.md +++ b/docs/my-website/docs/completion/web_search.md @@ -596,3 +596,78 @@ Expected Response + +## Web Search Cost Tracking + +LiteLLM tracks web search costs automatically based on provider-specific billing models. The cost is added on top of the standard token-based pricing. + +### How providers charge for web search + +| Provider | Billing Unit | How it works | +|----------|-------------|--------------| +| **Gemini 3.x** (3-flash, 3-pro, 3.1-*) | Per search query | Each internal search query is billed individually. One prompt may trigger multiple queries. | +| **Gemini 2.x** (2.0-flash, 2.5-flash, 2.5-pro) | Per grounded prompt | Flat fee per API call that uses grounding, regardless of how many queries are executed internally. | +| **OpenAI** (gpt-4o-search, gpt-5-search) | Per search context size | Cost varies by `search_context_size` (`low`, `medium`, `high`). | +| **Anthropic** (Claude with web search) | Per search request | Fixed cost per web search tool invocation. | +| **Perplexity** (sonar, sonar-pro) | Per search context size | Cost varies by `search_context_size`. | + +### Pricing configuration + +Web search costs are defined in `model_prices_and_context_window.json` using the `search_context_cost_per_query` field: + +```json +{ + "gemini/gemini-3-flash-preview": { + "search_context_cost_per_query": { + "search_context_size_low": 0.014, + "search_context_size_medium": 0.014, + "search_context_size_high": 0.014 + } + }, + "gemini/gemini-2.5-flash": { + "search_context_cost_per_query": { + "search_context_size_low": 0.035, + "search_context_size_medium": 0.035, + "search_context_size_high": 0.035 + } + } +} +``` + +You can override these costs in your proxy config using `model_info`: + +```yaml +model_list: + - model_name: gemini-3-flash + litellm_params: + model: gemini/gemini-3-flash-preview + model_info: + search_context_cost_per_query: + search_context_size_low: 0.014 + search_context_size_medium: 0.014 + search_context_size_high: 0.014 +``` + +### How LiteLLM tracks search usage + +The number of web search requests is stored in `usage.prompt_tokens_details.web_search_requests`. LiteLLM extracts this from each provider's response: + +- **Gemini**: Extracted from `groundingMetadata.webSearchQueries` in the response. For Gemini 2.x, clamped to 1 (per-prompt billing). +- **OpenAI**: Reported directly in the usage metadata. +- **Anthropic**: Reported via `server_tool_use.web_search_requests`. +- **xAI**: Mapped from `num_sources_used` in the response. + +```python +response = litellm.completion( + model="gemini/gemini-3-flash-preview", + messages=[{"role": "user", "content": "Latest tech news?"}], + web_search_options={"search_context_size": "medium"}, +) + +# Check web search usage +print(response.usage.prompt_tokens_details.web_search_requests) # e.g., 3 + +# Get total cost (includes token cost + web search cost) +cost = litellm.completion_cost(completion_response=response) +print(f"Total cost: ${cost}") +``` From e82d3f6d2e54941193600cdb0ed993234a5cfb86 Mon Sep 17 00:00:00 2001 From: Chesars Date: Sun, 22 Mar 2026 18:29:38 -0300 Subject: [PATCH 11/21] refactor(gemini): use web_search_billing_unit field instead of hardcoded model name check Replace _is_gemini_3_model() substring check with a web_search_billing_unit field in model_prices JSON: - "per_query": each search query billed individually (Gemini 3.x) - "per_prompt" (default): flat fee per grounded API call (Gemini 2.x) Add web_search_billing_unit to 23 Gemini 3.x model entries. Update docs and tests accordingly. --- docs/my-website/docs/completion/web_search.md | 13 +++- litellm/llms/gemini/cost_calculator.py | 17 ++--- ...odel_prices_and_context_window_backup.json | 69 ++++++++++++------- model_prices_and_context_window.json | 69 ++++++++++++------- .../llms/gemini/test_cost_calculator.py | 32 ++++----- 5 files changed, 121 insertions(+), 79 deletions(-) diff --git a/docs/my-website/docs/completion/web_search.md b/docs/my-website/docs/completion/web_search.md index 375e9a6375..dc8f72025f 100644 --- a/docs/my-website/docs/completion/web_search.md +++ b/docs/my-website/docs/completion/web_search.md @@ -613,11 +613,15 @@ LiteLLM tracks web search costs automatically based on provider-specific billing ### Pricing configuration -Web search costs are defined in `model_prices_and_context_window.json` using the `search_context_cost_per_query` field: +Web search costs are defined in `model_prices_and_context_window.json` using two fields: + +- **`search_context_cost_per_query`**: the cost per billable unit (per search context size tier). +- **`web_search_billing_unit`**: `"per_query"` (each search query is billed individually) or `"per_prompt"` (default — flat fee per API call that uses search). ```json { "gemini/gemini-3-flash-preview": { + "web_search_billing_unit": "per_query", "search_context_cost_per_query": { "search_context_size_low": 0.014, "search_context_size_medium": 0.014, @@ -634,7 +638,11 @@ Web search costs are defined in `model_prices_and_context_window.json` using the } ``` -You can override these costs in your proxy config using `model_info`: +:::info +Models without `web_search_billing_unit` default to `"per_prompt"` — one flat charge per API call that uses web search, regardless of how many internal queries the model executes. +::: + +You can override these in your proxy config using `model_info`: ```yaml model_list: @@ -642,6 +650,7 @@ model_list: litellm_params: model: gemini/gemini-3-flash-preview model_info: + web_search_billing_unit: per_query search_context_cost_per_query: search_context_size_low: 0.014 search_context_size_medium: 0.014 diff --git a/litellm/llms/gemini/cost_calculator.py b/litellm/llms/gemini/cost_calculator.py index 23ec6ad7c1..cd536b8bd3 100644 --- a/litellm/llms/gemini/cost_calculator.py +++ b/litellm/llms/gemini/cost_calculator.py @@ -28,19 +28,13 @@ def cost_per_token( ) -def _is_gemini_3_model(model_info: "ModelInfo") -> bool: - """Check if the model is a Gemini 3.x variant based on its key.""" - key = model_info.get("key", "") - return "gemini-3" in key - - def cost_per_web_search_request(usage: "Usage", model_info: "ModelInfo") -> float: """ Calculates the cost of web search (grounding with Google Search). - Billing differs by model family: - - Gemini 3.x: charged per individual search query ($0.014 default). - - Gemini 2.x and older: charged per grounded prompt ($0.035 default), + Billing mode is determined by ``web_search_billing_unit`` in model_info: + - ``"per_query"``: charged per individual search query (Gemini 3.x). + - ``"per_prompt"`` (default): charged per grounded prompt (Gemini 2.x), regardless of how many queries were executed internally. Reads the per-request cost from ``search_context_cost_per_query`` in @@ -63,8 +57,9 @@ def cost_per_web_search_request(usage: "Usage", model_info: "ModelInfo") -> floa ): number_of_web_search_requests = usage.prompt_tokens_details.web_search_requests - # Gemini 2.x charges per grounded prompt (flat 1), not per query - if number_of_web_search_requests > 0 and not _is_gemini_3_model(model_info): + # per_prompt billing: clamp to 1 (flat fee per grounded API call) + billing_mode = model_info.get("web_search_billing_unit", "per_prompt") + if number_of_web_search_requests > 0 and billing_mode == "per_prompt": number_of_web_search_requests = 1 return _cost * number_of_web_search_requests diff --git a/litellm/model_prices_and_context_window_backup.json b/litellm/model_prices_and_context_window_backup.json index 540ec03476..e0f5658106 100644 --- a/litellm/model_prices_and_context_window_backup.json +++ b/litellm/model_prices_and_context_window_backup.json @@ -13651,7 +13651,8 @@ "search_context_size_low": 0.014, "search_context_size_medium": 0.014, "search_context_size_high": 0.014 - } + }, + "web_search_billing_unit": "per_query" }, "gemini-3.1-flash-image-preview": { "input_cost_per_image": 0.00056, @@ -13688,7 +13689,8 @@ "search_context_size_low": 0.014, "search_context_size_medium": 0.014, "search_context_size_high": 0.014 - } + }, + "web_search_billing_unit": "per_query" }, "gemini-3.1-flash-lite-preview": { "cache_read_input_token_cost": 2.5e-08, @@ -13744,7 +13746,8 @@ "search_context_size_low": 0.014, "search_context_size_medium": 0.014, "search_context_size_high": 0.014 - } + }, + "web_search_billing_unit": "per_query" }, "deep-research-pro-preview-12-2025": { "input_cost_per_image": 0.0011, @@ -14191,7 +14194,8 @@ "search_context_size_low": 0.014, "search_context_size_medium": 0.014, "search_context_size_high": 0.014 - } + }, + "web_search_billing_unit": "per_query" }, "gemini-3.1-pro-preview": { "cache_read_input_token_cost": 2e-07, @@ -14254,7 +14258,8 @@ "search_context_size_low": 0.014, "search_context_size_medium": 0.014, "search_context_size_high": 0.014 - } + }, + "web_search_billing_unit": "per_query" }, "gemini-3.1-pro-preview-customtools": { "cache_read_input_token_cost": 2e-07, @@ -14310,7 +14315,8 @@ "search_context_size_low": 0.014, "search_context_size_medium": 0.014, "search_context_size_high": 0.014 - } + }, + "web_search_billing_unit": "per_query" }, "vertex_ai/gemini-3-pro-preview": { "cache_read_input_token_cost": 2e-07, @@ -14371,7 +14377,8 @@ "search_context_size_low": 0.014, "search_context_size_medium": 0.014, "search_context_size_high": 0.014 - } + }, + "web_search_billing_unit": "per_query" }, "vertex_ai/gemini-3-flash-preview": { "cache_read_input_token_cost": 5e-08, @@ -14425,7 +14432,8 @@ "search_context_size_low": 0.014, "search_context_size_medium": 0.014, "search_context_size_high": 0.014 - } + }, + "web_search_billing_unit": "per_query" }, "vertex_ai/gemini-3.1-pro-preview": { "cache_read_input_token_cost": 2e-07, @@ -14488,7 +14496,8 @@ "search_context_size_low": 0.014, "search_context_size_medium": 0.014, "search_context_size_high": 0.014 - } + }, + "web_search_billing_unit": "per_query" }, "vertex_ai/gemini-3.1-pro-preview-customtools": { "cache_read_input_token_cost": 2e-07, @@ -14551,7 +14560,8 @@ "search_context_size_low": 0.014, "search_context_size_medium": 0.014, "search_context_size_high": 0.014 - } + }, + "web_search_billing_unit": "per_query" }, "gemini-2.5-pro-preview-tts": { "cache_read_input_token_cost": 1.25e-07, @@ -15069,7 +15079,8 @@ "search_context_size_low": 0.014, "search_context_size_medium": 0.014, "search_context_size_high": 0.014 - } + }, + "web_search_billing_unit": "per_query" }, "gemini/gemini-3.1-flash-image-preview": { "input_cost_per_token": 2.5e-07, @@ -15110,7 +15121,8 @@ "search_context_size_low": 0.014, "search_context_size_medium": 0.014, "search_context_size_high": 0.014 - } + }, + "web_search_billing_unit": "per_query" }, "gemini/deep-research-pro-preview-12-2025": { "input_cost_per_image": 0.0011, @@ -15625,7 +15637,8 @@ "search_context_size_low": 0.014, "search_context_size_medium": 0.014, "search_context_size_high": 0.014 - } + }, + "web_search_billing_unit": "per_query" }, "gemini/gemini-3.1-flash-lite-preview": { "cache_read_input_token_cost": 2.5e-08, @@ -15683,7 +15696,8 @@ "search_context_size_low": 0.014, "search_context_size_medium": 0.014, "search_context_size_high": 0.014 - } + }, + "web_search_billing_unit": "per_query" }, "gemini/gemini-3-flash-preview": { "cache_read_input_token_cost": 5e-08, @@ -15741,7 +15755,8 @@ "search_context_size_low": 0.014, "search_context_size_medium": 0.014, "search_context_size_high": 0.014 - } + }, + "web_search_billing_unit": "per_query" }, "gemini/gemini-3.1-pro-preview": { "cache_read_input_token_cost": 2e-07, @@ -15804,7 +15819,8 @@ "search_context_size_low": 0.014, "search_context_size_medium": 0.014, "search_context_size_high": 0.014 - } + }, + "web_search_billing_unit": "per_query" }, "gemini/gemini-3.1-pro-preview-customtools": { "cache_read_input_token_cost": 2e-07, @@ -15867,7 +15883,8 @@ "search_context_size_low": 0.014, "search_context_size_medium": 0.014, "search_context_size_high": 0.014 - } + }, + "web_search_billing_unit": "per_query" }, "gemini-3-flash-preview": { "cache_read_input_token_cost": 5e-08, @@ -15923,7 +15940,8 @@ "search_context_size_low": 0.014, "search_context_size_medium": 0.014, "search_context_size_high": 0.014 - } + }, + "web_search_billing_unit": "per_query" }, "gemini/gemini-2.5-pro-preview-tts": { "cache_read_input_token_cost": 1.25e-07, @@ -24692,7 +24710,8 @@ "supports_tool_choice": true, "supports_video_input": true, "supports_vision": true, - "supports_web_search": true + "supports_web_search": true, + "web_search_billing_unit": "per_query" }, "openrouter/google/gemini-3-flash-preview": { "cache_read_input_token_cost": 5e-08, @@ -24739,7 +24758,8 @@ "supports_url_context": true, "supports_vision": true, "supports_web_search": true, - "tpm": 800000 + "tpm": 800000, + "web_search_billing_unit": "per_query" }, "openrouter/google/gemini-3.1-pro-preview": { "cache_read_input_token_cost": 2e-07, @@ -26286,14 +26306,16 @@ "mode": "responses", "supports_web_search": true, "supports_reasoning": false, - "supports_function_calling": true + "supports_function_calling": true, + "web_search_billing_unit": "per_query" }, "perplexity/google/gemini-3-flash-preview": { "litellm_provider": "perplexity", "mode": "responses", "supports_web_search": true, "supports_reasoning": false, - "supports_function_calling": true + "supports_function_calling": true, + "web_search_billing_unit": "per_query" }, "perplexity/google/gemini-2.5-pro": { "litellm_provider": "perplexity", @@ -31029,7 +31051,8 @@ "search_context_size_low": 0.014, "search_context_size_medium": 0.014, "search_context_size_high": 0.014 - } + }, + "web_search_billing_unit": "per_query" }, "vertex_ai/deep-research-pro-preview-12-2025": { "input_cost_per_image": 0.0011, diff --git a/model_prices_and_context_window.json b/model_prices_and_context_window.json index 540ec03476..e0f5658106 100644 --- a/model_prices_and_context_window.json +++ b/model_prices_and_context_window.json @@ -13651,7 +13651,8 @@ "search_context_size_low": 0.014, "search_context_size_medium": 0.014, "search_context_size_high": 0.014 - } + }, + "web_search_billing_unit": "per_query" }, "gemini-3.1-flash-image-preview": { "input_cost_per_image": 0.00056, @@ -13688,7 +13689,8 @@ "search_context_size_low": 0.014, "search_context_size_medium": 0.014, "search_context_size_high": 0.014 - } + }, + "web_search_billing_unit": "per_query" }, "gemini-3.1-flash-lite-preview": { "cache_read_input_token_cost": 2.5e-08, @@ -13744,7 +13746,8 @@ "search_context_size_low": 0.014, "search_context_size_medium": 0.014, "search_context_size_high": 0.014 - } + }, + "web_search_billing_unit": "per_query" }, "deep-research-pro-preview-12-2025": { "input_cost_per_image": 0.0011, @@ -14191,7 +14194,8 @@ "search_context_size_low": 0.014, "search_context_size_medium": 0.014, "search_context_size_high": 0.014 - } + }, + "web_search_billing_unit": "per_query" }, "gemini-3.1-pro-preview": { "cache_read_input_token_cost": 2e-07, @@ -14254,7 +14258,8 @@ "search_context_size_low": 0.014, "search_context_size_medium": 0.014, "search_context_size_high": 0.014 - } + }, + "web_search_billing_unit": "per_query" }, "gemini-3.1-pro-preview-customtools": { "cache_read_input_token_cost": 2e-07, @@ -14310,7 +14315,8 @@ "search_context_size_low": 0.014, "search_context_size_medium": 0.014, "search_context_size_high": 0.014 - } + }, + "web_search_billing_unit": "per_query" }, "vertex_ai/gemini-3-pro-preview": { "cache_read_input_token_cost": 2e-07, @@ -14371,7 +14377,8 @@ "search_context_size_low": 0.014, "search_context_size_medium": 0.014, "search_context_size_high": 0.014 - } + }, + "web_search_billing_unit": "per_query" }, "vertex_ai/gemini-3-flash-preview": { "cache_read_input_token_cost": 5e-08, @@ -14425,7 +14432,8 @@ "search_context_size_low": 0.014, "search_context_size_medium": 0.014, "search_context_size_high": 0.014 - } + }, + "web_search_billing_unit": "per_query" }, "vertex_ai/gemini-3.1-pro-preview": { "cache_read_input_token_cost": 2e-07, @@ -14488,7 +14496,8 @@ "search_context_size_low": 0.014, "search_context_size_medium": 0.014, "search_context_size_high": 0.014 - } + }, + "web_search_billing_unit": "per_query" }, "vertex_ai/gemini-3.1-pro-preview-customtools": { "cache_read_input_token_cost": 2e-07, @@ -14551,7 +14560,8 @@ "search_context_size_low": 0.014, "search_context_size_medium": 0.014, "search_context_size_high": 0.014 - } + }, + "web_search_billing_unit": "per_query" }, "gemini-2.5-pro-preview-tts": { "cache_read_input_token_cost": 1.25e-07, @@ -15069,7 +15079,8 @@ "search_context_size_low": 0.014, "search_context_size_medium": 0.014, "search_context_size_high": 0.014 - } + }, + "web_search_billing_unit": "per_query" }, "gemini/gemini-3.1-flash-image-preview": { "input_cost_per_token": 2.5e-07, @@ -15110,7 +15121,8 @@ "search_context_size_low": 0.014, "search_context_size_medium": 0.014, "search_context_size_high": 0.014 - } + }, + "web_search_billing_unit": "per_query" }, "gemini/deep-research-pro-preview-12-2025": { "input_cost_per_image": 0.0011, @@ -15625,7 +15637,8 @@ "search_context_size_low": 0.014, "search_context_size_medium": 0.014, "search_context_size_high": 0.014 - } + }, + "web_search_billing_unit": "per_query" }, "gemini/gemini-3.1-flash-lite-preview": { "cache_read_input_token_cost": 2.5e-08, @@ -15683,7 +15696,8 @@ "search_context_size_low": 0.014, "search_context_size_medium": 0.014, "search_context_size_high": 0.014 - } + }, + "web_search_billing_unit": "per_query" }, "gemini/gemini-3-flash-preview": { "cache_read_input_token_cost": 5e-08, @@ -15741,7 +15755,8 @@ "search_context_size_low": 0.014, "search_context_size_medium": 0.014, "search_context_size_high": 0.014 - } + }, + "web_search_billing_unit": "per_query" }, "gemini/gemini-3.1-pro-preview": { "cache_read_input_token_cost": 2e-07, @@ -15804,7 +15819,8 @@ "search_context_size_low": 0.014, "search_context_size_medium": 0.014, "search_context_size_high": 0.014 - } + }, + "web_search_billing_unit": "per_query" }, "gemini/gemini-3.1-pro-preview-customtools": { "cache_read_input_token_cost": 2e-07, @@ -15867,7 +15883,8 @@ "search_context_size_low": 0.014, "search_context_size_medium": 0.014, "search_context_size_high": 0.014 - } + }, + "web_search_billing_unit": "per_query" }, "gemini-3-flash-preview": { "cache_read_input_token_cost": 5e-08, @@ -15923,7 +15940,8 @@ "search_context_size_low": 0.014, "search_context_size_medium": 0.014, "search_context_size_high": 0.014 - } + }, + "web_search_billing_unit": "per_query" }, "gemini/gemini-2.5-pro-preview-tts": { "cache_read_input_token_cost": 1.25e-07, @@ -24692,7 +24710,8 @@ "supports_tool_choice": true, "supports_video_input": true, "supports_vision": true, - "supports_web_search": true + "supports_web_search": true, + "web_search_billing_unit": "per_query" }, "openrouter/google/gemini-3-flash-preview": { "cache_read_input_token_cost": 5e-08, @@ -24739,7 +24758,8 @@ "supports_url_context": true, "supports_vision": true, "supports_web_search": true, - "tpm": 800000 + "tpm": 800000, + "web_search_billing_unit": "per_query" }, "openrouter/google/gemini-3.1-pro-preview": { "cache_read_input_token_cost": 2e-07, @@ -26286,14 +26306,16 @@ "mode": "responses", "supports_web_search": true, "supports_reasoning": false, - "supports_function_calling": true + "supports_function_calling": true, + "web_search_billing_unit": "per_query" }, "perplexity/google/gemini-3-flash-preview": { "litellm_provider": "perplexity", "mode": "responses", "supports_web_search": true, "supports_reasoning": false, - "supports_function_calling": true + "supports_function_calling": true, + "web_search_billing_unit": "per_query" }, "perplexity/google/gemini-2.5-pro": { "litellm_provider": "perplexity", @@ -31029,7 +31051,8 @@ "search_context_size_low": 0.014, "search_context_size_medium": 0.014, "search_context_size_high": 0.014 - } + }, + "web_search_billing_unit": "per_query" }, "vertex_ai/deep-research-pro-preview-12-2025": { "input_cost_per_image": 0.0011, diff --git a/tests/test_litellm/llms/gemini/test_cost_calculator.py b/tests/test_litellm/llms/gemini/test_cost_calculator.py index baa3aab318..9bb83aa7cf 100644 --- a/tests/test_litellm/llms/gemini/test_cost_calculator.py +++ b/tests/test_litellm/llms/gemini/test_cost_calculator.py @@ -1,9 +1,6 @@ import pytest -from litellm.llms.gemini.cost_calculator import ( - _is_gemini_3_model, - cost_per_web_search_request, -) +from litellm.llms.gemini.cost_calculator import cost_per_web_search_request from litellm.types.utils import PromptTokensDetailsWrapper, Usage @@ -18,22 +15,21 @@ def _make_usage(web_search_requests: int) -> Usage: ) -def test_gemini3_charged_per_query(): - """Gemini 3.x should charge per search query at $0.014.""" +def test_per_query_billing(): + """web_search_billing_unit=per_query charges per search query.""" model_info = { "key": "gemini/gemini-3-flash-preview", + "web_search_billing_unit": "per_query", "search_context_cost_per_query": { - "search_context_size_low": 0.014, "search_context_size_medium": 0.014, - "search_context_size_high": 0.014, }, } cost = cost_per_web_search_request(usage=_make_usage(3), model_info=model_info) assert cost == pytest.approx(0.014 * 3) -def test_gemini2_charged_per_prompt(): - """Gemini 2.x should charge 1 grounded prompt regardless of query count.""" +def test_per_prompt_billing(): + """web_search_billing_unit=per_prompt (default) clamps to 1.""" model_info = { "key": "gemini/gemini-2.5-flash", "search_context_cost_per_query": { @@ -44,8 +40,8 @@ def test_gemini2_charged_per_prompt(): assert cost == pytest.approx(0.035 * 1) -def test_legacy_fallback(): - """Without search_context_cost_per_query, should fallback to $0.035 × 1.""" +def test_default_billing_unit_is_per_prompt(): + """Without web_search_billing_unit, defaults to per_prompt (clamp to 1).""" model_info = {"key": "gemini/gemini-2.0-flash"} cost = cost_per_web_search_request(usage=_make_usage(2), model_info=model_info) assert cost == pytest.approx(0.035 * 1) @@ -53,7 +49,10 @@ def test_legacy_fallback(): def test_zero_requests(): """Zero web search requests should return zero cost.""" - model_info = {"key": "gemini/gemini-3-flash-preview"} + model_info = { + "key": "gemini/gemini-3-flash-preview", + "web_search_billing_unit": "per_query", + } cost = cost_per_web_search_request(usage=_make_usage(0), model_info=model_info) assert cost == 0.0 @@ -64,10 +63,3 @@ def test_no_usage_details(): usage = Usage(prompt_tokens=100, completion_tokens=50, total_tokens=150) cost = cost_per_web_search_request(usage=usage, model_info=model_info) assert cost == 0.0 - - -def test_is_gemini_3_model(): - assert _is_gemini_3_model({"key": "gemini/gemini-3-flash-preview"}) - assert _is_gemini_3_model({"key": "gemini/gemini-3.1-pro-preview"}) - assert not _is_gemini_3_model({"key": "gemini/gemini-2.5-flash"}) - assert not _is_gemini_3_model({"key": "gemini/gemini-2.0-flash"}) From b6079018cdb5105bf4859befac5b97326404bbb7 Mon Sep 17 00:00:00 2001 From: Chesars Date: Sun, 22 Mar 2026 18:32:03 -0300 Subject: [PATCH 12/21] docs: clarify web_search_billing_unit applies to Gemini models only --- docs/my-website/docs/completion/web_search.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/my-website/docs/completion/web_search.md b/docs/my-website/docs/completion/web_search.md index dc8f72025f..86a9677861 100644 --- a/docs/my-website/docs/completion/web_search.md +++ b/docs/my-website/docs/completion/web_search.md @@ -616,7 +616,7 @@ LiteLLM tracks web search costs automatically based on provider-specific billing Web search costs are defined in `model_prices_and_context_window.json` using two fields: - **`search_context_cost_per_query`**: the cost per billable unit (per search context size tier). -- **`web_search_billing_unit`**: `"per_query"` (each search query is billed individually) or `"per_prompt"` (default — flat fee per API call that uses search). +- **`web_search_billing_unit`** *(on Gemini models)*: `"per_query"` (each search query is billed individually) or `"per_prompt"` (default — flat fee per API call that uses search). ```json { From 6a466913fc855e1bb3b3ea5a3dbac1db9ab6dae4 Mon Sep 17 00:00:00 2001 From: Chesars Date: Sun, 22 Mar 2026 18:36:13 -0300 Subject: [PATCH 13/21] fix: map Zhipu GLM non-standard finish_reason values MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Zhipu GLM returns non-standard finish_reason values during streaming when inference fails mid-request, causing Pydantic validation crash: - "network_error" (inference interrupted) → map to "stop" - "sensitive" (content policy violation) → map to "content_filter" Fixes #23386 --- litellm/litellm_core_utils/core_helpers.py | 3 +++ .../test_litellm/litellm_core_utils/test_core_helpers.py | 8 ++++++++ 2 files changed, 11 insertions(+) diff --git a/litellm/litellm_core_utils/core_helpers.py b/litellm/litellm_core_utils/core_helpers.py index 22006be21a..0f0d6631ad 100644 --- a/litellm/litellm_core_utils/core_helpers.py +++ b/litellm/litellm_core_utils/core_helpers.py @@ -89,6 +89,9 @@ _FINISH_REASON_MAP: dict[str, OpenAIChatCompletionFinishReason] = { "IMAGE_PROHIBITED_CONTENT": "content_filter", "TOO_MANY_TOOL_CALLS": "stop", "MALFORMED_RESPONSE": "stop", + # Zhipu GLM + "network_error": "stop", + "sensitive": "content_filter", # Bedrock "guardrail_intervened": "content_filter", # OpenAI passthrough diff --git a/tests/test_litellm/litellm_core_utils/test_core_helpers.py b/tests/test_litellm/litellm_core_utils/test_core_helpers.py index 8397fc2224..0519ac6a8f 100644 --- a/tests/test_litellm/litellm_core_utils/test_core_helpers.py +++ b/tests/test_litellm/litellm_core_utils/test_core_helpers.py @@ -126,6 +126,14 @@ class TestMapFinishReasonBedrock: assert map_finish_reason("guardrail_intervened") == "content_filter" +class TestMapFinishReasonZhipu: + def test_network_error(self): + assert map_finish_reason("network_error") == "stop" + + def test_sensitive(self): + assert map_finish_reason("sensitive") == "content_filter" + + class TestMapFinishReasonOpenAIPassthrough: @pytest.mark.parametrize( "reason", ["stop", "length", "tool_calls", "function_call", "content_filter"] From bfee7f0b58f960b7effe86b784cd33b9502a560c Mon Sep 17 00:00:00 2001 From: Chesars Date: Sun, 22 Mar 2026 18:37:34 -0300 Subject: [PATCH 14/21] fix: improve error message for supports-none models with temperature --- litellm/llms/openai/responses/transformation.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/litellm/llms/openai/responses/transformation.py b/litellm/llms/openai/responses/transformation.py index bfcf0959f9..03e09b039d 100644 --- a/litellm/llms/openai/responses/transformation.py +++ b/litellm/llms/openai/responses/transformation.py @@ -87,6 +87,8 @@ class OpenAIResponsesAPIConfig(BaseResponsesAPIConfig): message=( "gpt-5 models don't support temperature={}. " "Only temperature=1 is supported. " + "For models like gpt-5.1/5.4, temperature is supported " + "when reasoning.effort='none' (or not specified). " "To drop unsupported params set `litellm.drop_params = True`" ).format(temperature), status_code=400, From 996d27b156f9ae4a368933011ed88875cc4887d3 Mon Sep 17 00:00:00 2001 From: Chesars Date: Sun, 22 Mar 2026 18:39:12 -0300 Subject: [PATCH 15/21] fix(vertex_ai): delegate web search cost to shared Gemini calculator The vertex_ai cost calculator hardcoded $0.035 and charged for every call with a PromptTokensDetailsWrapper (not just web search calls). Delegate to the shared Gemini calculator which reads pricing and billing unit from model_info, fixing both issues for vertex_ai models. --- .../llms/vertex_ai/gemini/cost_calculator.py | 37 +++++-------------- 1 file changed, 10 insertions(+), 27 deletions(-) diff --git a/litellm/llms/vertex_ai/gemini/cost_calculator.py b/litellm/llms/vertex_ai/gemini/cost_calculator.py index 23977bc917..69a29c16af 100644 --- a/litellm/llms/vertex_ai/gemini/cost_calculator.py +++ b/litellm/llms/vertex_ai/gemini/cost_calculator.py @@ -1,7 +1,8 @@ """ Cost calculator for Vertex AI Gemini. -Used because there are differences in how Google AI Studio and Vertex AI Gemini handle web search requests. +Delegates to the shared Gemini cost calculator which reads pricing and +billing unit from model_info. """ from typing import TYPE_CHECKING @@ -14,32 +15,14 @@ def cost_per_web_search_request(usage: "Usage", model_info: "ModelInfo") -> floa """ Calculate the cost of a web search request for Vertex AI Gemini. - Vertex AI charges $35/1000 prompts, independent of the number of web search requests. + Billing differs by ``web_search_billing_unit`` in ``model_info``: + - ``"per_query"``: charged per individual search query (Gemini 3.x). + - ``"per_prompt"`` (default): charged per grounded prompt (Gemini 2.x). - For a single call, this is $35e-3 USD. - - Args: - usage: The usage object for the web search request. - model_info: The model info for the web search request. - - Returns: - The cost of the web search request. + Delegates to the shared Gemini cost calculator. """ - from litellm.types.utils import PromptTokensDetailsWrapper + from litellm.llms.gemini.cost_calculator import ( + cost_per_web_search_request as _gemini_cost, + ) - # check if usage object has web search requests - cost_per_llm_call_with_web_search = 35e-3 - - makes_web_search_request = False - if ( - usage is not None - and usage.prompt_tokens_details is not None - and isinstance(usage.prompt_tokens_details, PromptTokensDetailsWrapper) - ): - makes_web_search_request = True - - # Calculate total cost - if makes_web_search_request: - return cost_per_llm_call_with_web_search - else: - return 0.0 + return _gemini_cost(usage=usage, model_info=model_info) From 21b9c68d42101e912d860db643610f1a322f0b96 Mon Sep 17 00:00:00 2001 From: Chesars Date: Sun, 22 Mar 2026 18:46:39 -0300 Subject: [PATCH 16/21] fix: remove web_search_billing_unit from OpenRouter/Perplexity entries These providers have their own web search systems and pricing, independent of Google's grounding billing model. --- litellm/model_prices_and_context_window_backup.json | 12 ++++-------- model_prices_and_context_window.json | 12 ++++-------- 2 files changed, 8 insertions(+), 16 deletions(-) diff --git a/litellm/model_prices_and_context_window_backup.json b/litellm/model_prices_and_context_window_backup.json index e0f5658106..32ef8b5c26 100644 --- a/litellm/model_prices_and_context_window_backup.json +++ b/litellm/model_prices_and_context_window_backup.json @@ -24710,8 +24710,7 @@ "supports_tool_choice": true, "supports_video_input": true, "supports_vision": true, - "supports_web_search": true, - "web_search_billing_unit": "per_query" + "supports_web_search": true }, "openrouter/google/gemini-3-flash-preview": { "cache_read_input_token_cost": 5e-08, @@ -24758,8 +24757,7 @@ "supports_url_context": true, "supports_vision": true, "supports_web_search": true, - "tpm": 800000, - "web_search_billing_unit": "per_query" + "tpm": 800000 }, "openrouter/google/gemini-3.1-pro-preview": { "cache_read_input_token_cost": 2e-07, @@ -26306,16 +26304,14 @@ "mode": "responses", "supports_web_search": true, "supports_reasoning": false, - "supports_function_calling": true, - "web_search_billing_unit": "per_query" + "supports_function_calling": true }, "perplexity/google/gemini-3-flash-preview": { "litellm_provider": "perplexity", "mode": "responses", "supports_web_search": true, "supports_reasoning": false, - "supports_function_calling": true, - "web_search_billing_unit": "per_query" + "supports_function_calling": true }, "perplexity/google/gemini-2.5-pro": { "litellm_provider": "perplexity", diff --git a/model_prices_and_context_window.json b/model_prices_and_context_window.json index e0f5658106..32ef8b5c26 100644 --- a/model_prices_and_context_window.json +++ b/model_prices_and_context_window.json @@ -24710,8 +24710,7 @@ "supports_tool_choice": true, "supports_video_input": true, "supports_vision": true, - "supports_web_search": true, - "web_search_billing_unit": "per_query" + "supports_web_search": true }, "openrouter/google/gemini-3-flash-preview": { "cache_read_input_token_cost": 5e-08, @@ -24758,8 +24757,7 @@ "supports_url_context": true, "supports_vision": true, "supports_web_search": true, - "tpm": 800000, - "web_search_billing_unit": "per_query" + "tpm": 800000 }, "openrouter/google/gemini-3.1-pro-preview": { "cache_read_input_token_cost": 2e-07, @@ -26306,16 +26304,14 @@ "mode": "responses", "supports_web_search": true, "supports_reasoning": false, - "supports_function_calling": true, - "web_search_billing_unit": "per_query" + "supports_function_calling": true }, "perplexity/google/gemini-3-flash-preview": { "litellm_provider": "perplexity", "mode": "responses", "supports_web_search": true, "supports_reasoning": false, - "supports_function_calling": true, - "web_search_billing_unit": "per_query" + "supports_function_calling": true }, "perplexity/google/gemini-2.5-pro": { "litellm_provider": "perplexity", From 7c81ee7b4e5f214c20d5dd60ad246680940db0af Mon Sep 17 00:00:00 2001 From: Yuneng Jiang Date: Sun, 5 Apr 2026 01:30:57 -0700 Subject: [PATCH 17/21] chore: fixes --- .circleci/config.yml | 1112 +++-------------- .circleci/requirements.txt | 10 +- .../helm-oci-chart-releaser/action.yml | 36 +- .github/dependabot.yaml | 3 + .github/workflows/_test-unit-base.yml | 96 ++ .../workflows/_test-unit-services-base.yml | 164 +++ .../auto_update_price_and_context_window.yml | 14 +- .github/workflows/check-schema-sync.yml | 58 + .github/workflows/check_duplicate_issues.yml | 7 +- .github/workflows/codeql.yml | 13 +- .github/workflows/codspeed.yml | 8 +- .github/workflows/create-release.yml | 93 ++ .../workflows/create_daily_staging_branch.yml | 16 +- .github/workflows/ghcr_deploy.yml | 444 ------- .github/workflows/ghcr_helm_deploy.yml | 67 - .github/workflows/helm_unit_test.yml | 24 +- .github/workflows/interpret_load_test.py | 139 --- .github/workflows/issue-keyword-labeler.yml | 13 +- .github/workflows/label-component.yml | 2 +- .github/workflows/llm-translation-testing.yml | 58 +- .github/workflows/load_test.yml | 59 - .github/workflows/locustfile.py | 28 - .github/workflows/main.yml | 34 - .github/workflows/publish-migrations.yml | 207 --- .github/workflows/publish_enterprise.yml | 94 -- .github/workflows/publish_proxy_extras.yml | 74 -- .github/workflows/publish_to_pypi.yml | 136 ++ .github/workflows/read_pyproject_version.yml | 17 +- .github/workflows/redeploy_proxy.py | 20 - .github/workflows/regenerate-poetry-lock.yml | 80 -- .github/workflows/reset_stable.yml | 39 - .../workflows/run_llm_translation_tests.py | 0 .github/workflows/run_observatory_tests.yml | 16 +- .github/workflows/scan_duplicate_issues.yml | 7 +- .github/workflows/scorecard.yml | 47 + .github/workflows/simple_pypi_publish.yml | 67 - .github/workflows/stale.yml | 15 +- .github/workflows/sync-schema.yml | 73 ++ .github/workflows/test-linting.yml | 159 +-- .github/workflows/test-litellm-matrix.yml | 64 +- .github/workflows/test-litellm-ui-build.yml | 8 +- .github/workflows/test-litellm.yml | 63 +- .github/workflows/test-mcp.yml | 65 +- .github/workflows/test-model-map.yaml | 9 +- .../test-proxy-e2e-azure-batches.yml | 19 +- .github/workflows/test-unit-caching-redis.yml | 38 + .github/workflows/test-unit-core-utils.yml | 20 + .github/workflows/test-unit-documentation.yml | 67 + .../test-unit-enterprise-routing.yml | 24 + .github/workflows/test-unit-integrations.yml | 20 + .github/workflows/test-unit-llm-providers.yml | 29 + .github/workflows/test-unit-misc.yml | 31 + .github/workflows/test-unit-proxy-auth.yml | 20 + .github/workflows/test-unit-proxy-db.yml | 45 + .../workflows/test-unit-proxy-endpoints.yml | 35 + .github/workflows/test-unit-proxy-infra.yml | 28 + .github/workflows/test-unit-proxy-legacy.yml | 96 ++ .../test-unit-responses-caching-types.yml | 20 + .github/workflows/test-unit-security.yml | 28 + .github/workflows/test_server_root_path.yml | 8 +- .github/workflows/zizmor.yml | 31 + .pre-commit-config.yaml | 40 - .trivyignore | 12 - ci_cd/.grype.yaml | 36 - ci_cd/publish-proxy-extras.sh | 19 - ci_cd/security_scans.sh | 262 ---- docker/build_admin_ui.sh | 15 +- docs/my-website/.trivyignore | 7 - scripts/install.sh | 4 +- ui/litellm-dashboard/.trivyignore | 7 - ui/litellm-dashboard/build_ui.sh | 16 +- ui/litellm-dashboard/build_ui_custom_path.sh | 16 +- 72 files changed, 1824 insertions(+), 2927 deletions(-) create mode 100644 .github/workflows/_test-unit-base.yml create mode 100644 .github/workflows/_test-unit-services-base.yml create mode 100644 .github/workflows/check-schema-sync.yml create mode 100644 .github/workflows/create-release.yml delete mode 100644 .github/workflows/ghcr_deploy.yml delete mode 100644 .github/workflows/ghcr_helm_deploy.yml delete mode 100644 .github/workflows/interpret_load_test.py delete mode 100644 .github/workflows/load_test.yml delete mode 100644 .github/workflows/locustfile.py delete mode 100644 .github/workflows/main.yml delete mode 100644 .github/workflows/publish-migrations.yml delete mode 100644 .github/workflows/publish_enterprise.yml delete mode 100644 .github/workflows/publish_proxy_extras.yml create mode 100644 .github/workflows/publish_to_pypi.yml delete mode 100644 .github/workflows/redeploy_proxy.py delete mode 100644 .github/workflows/regenerate-poetry-lock.yml delete mode 100644 .github/workflows/reset_stable.yml mode change 100755 => 100644 .github/workflows/run_llm_translation_tests.py create mode 100644 .github/workflows/scorecard.yml delete mode 100644 .github/workflows/simple_pypi_publish.yml create mode 100644 .github/workflows/sync-schema.yml create mode 100644 .github/workflows/test-unit-caching-redis.yml create mode 100644 .github/workflows/test-unit-core-utils.yml create mode 100644 .github/workflows/test-unit-documentation.yml create mode 100644 .github/workflows/test-unit-enterprise-routing.yml create mode 100644 .github/workflows/test-unit-integrations.yml create mode 100644 .github/workflows/test-unit-llm-providers.yml create mode 100644 .github/workflows/test-unit-misc.yml create mode 100644 .github/workflows/test-unit-proxy-auth.yml create mode 100644 .github/workflows/test-unit-proxy-db.yml create mode 100644 .github/workflows/test-unit-proxy-endpoints.yml create mode 100644 .github/workflows/test-unit-proxy-infra.yml create mode 100644 .github/workflows/test-unit-proxy-legacy.yml create mode 100644 .github/workflows/test-unit-responses-caching-types.yml create mode 100644 .github/workflows/test-unit-security.yml create mode 100644 .github/workflows/zizmor.yml delete mode 100644 .pre-commit-config.yaml delete mode 100644 .trivyignore delete mode 100644 ci_cd/.grype.yaml delete mode 100644 ci_cd/publish-proxy-extras.sh delete mode 100755 ci_cd/security_scans.sh delete mode 100644 docs/my-website/.trivyignore delete mode 100644 ui/litellm-dashboard/.trivyignore diff --git a/.circleci/config.yml b/.circleci/config.yml index 790efc7986..307247651e 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -39,7 +39,7 @@ commands: # conflicts with transitive dep pins like openai<2 and pydantic>=2.11.5) pip install "pytest-mock==3.12.0" "pytest==7.3.1" "pytest-retry==1.6.3" \ "pytest-asyncio==0.21.1" "respx==0.22.0" "hypercorn==0.17.3" \ - "pydantic==2.11.0" "mcp==1.25.0" "requests-mock>=1.12.1" \ + "pydantic==2.12.5" "mcp==1.26.0" "requests-mock>=1.12.1" \ "responses==0.25.7" "pytest-xdist==3.6.1" "pytest-timeout==2.2.0" \ "pytest-cov==5.0.0" "semantic_router==0.1.10" "fastapi-offline==1.7.3" \ "a2a" "parameterized>=0.9.0" @@ -150,16 +150,16 @@ jobs: python -m pip install --upgrade pip python -m pip install -r .circleci/requirements.txt pip install "pytest==7.3.1" "pytest-retry==1.6.3" "pytest-asyncio==0.21.1" "pytest-cov==5.0.0" \ - "mypy==1.18.2" "google-generativeai==0.3.2" "google-cloud-aiplatform==1.43.0" pyarrow \ - "boto3==1.36.0" "aioboto3==13.4.0" langchain lunary==0.2.5 \ - "azure-identity==1.16.1" "langfuse==2.59.7" "logfire==0.29.0" numpydoc \ + "mypy==1.18.2" "google-generativeai==0.3.2" "google-cloud-aiplatform==1.133.0" pyarrow \ + "boto3==1.42.80" langchain lunary==0.2.5 \ + "azure-identity==1.25.3" "langfuse==2.59.7" "logfire==0.29.0" numpydoc \ traceloop-sdk==0.21.1 openai==1.100.1 prisma==0.11.0 \ "detect_secrets==1.5.0" "respx==0.22.0" fastapi \ - "gunicorn==21.2.0" "aiodynamo==23.10.1" "asyncio==3.4.3" \ - "apscheduler==3.10.4" "PyGithub==1.59.1" argon2-cffi "pytest-mock==3.12.0" \ - python-multipart prometheus-client==0.20.0 "pydantic==2.10.2" \ - "diskcache==5.6.1" "Pillow==10.3.0" "jsonschema==4.22.0" \ - "pytest-xdist==3.6.1" "pytest-timeout==2.2.0" "websockets==13.1.0" + "gunicorn==23.0.0" "aiodynamo==23.10.1" "asyncio==3.4.3" \ + "apscheduler==3.11.2" "PyGithub==1.59.1" argon2-cffi "pytest-mock==3.12.0" \ + python-multipart prometheus-client==0.20.0 "pydantic==2.12.5" \ + "diskcache==5.6.1" "Pillow==12.1.1" "jsonschema==4.23.0" \ + "pytest-xdist==3.6.1" "pytest-timeout==2.2.0" "websockets==15.0.1" pip install semantic_router --no-deps pip install aurelio_sdk --no-deps pip uninstall posthog -y @@ -245,16 +245,16 @@ jobs: python -m pip install --upgrade pip python -m pip install -r .circleci/requirements.txt pip install "pytest==7.3.1" "pytest-retry==1.6.3" "pytest-asyncio==0.21.1" "pytest-cov==5.0.0" \ - "mypy==1.18.2" "google-generativeai==0.3.2" "google-cloud-aiplatform==1.43.0" pyarrow \ - "boto3==1.36.0" "aioboto3==13.4.0" langchain lunary==0.2.5 \ - "azure-identity==1.16.1" "langfuse==2.59.7" "logfire==0.29.0" numpydoc \ + "mypy==1.18.2" "google-generativeai==0.3.2" "google-cloud-aiplatform==1.133.0" pyarrow \ + "boto3==1.42.80" langchain lunary==0.2.5 \ + "azure-identity==1.25.3" "langfuse==2.59.7" "logfire==0.29.0" numpydoc \ traceloop-sdk==0.21.1 openai==1.100.1 prisma==0.11.0 \ "detect_secrets==1.5.0" "respx==0.22.0" fastapi \ - "gunicorn==21.2.0" "aiodynamo==23.10.1" "asyncio==3.4.3" \ - "apscheduler==3.10.4" "PyGithub==1.59.1" argon2-cffi "pytest-mock==3.12.0" \ - python-multipart prometheus-client==0.20.0 "pydantic==2.10.2" \ - "diskcache==5.6.1" "Pillow==10.3.0" "jsonschema==4.22.0" \ - "pytest-xdist==3.6.1" "pytest-timeout==2.2.0" "websockets==13.1.0" + "gunicorn==23.0.0" "aiodynamo==23.10.1" "asyncio==3.4.3" \ + "apscheduler==3.11.2" "PyGithub==1.59.1" argon2-cffi "pytest-mock==3.12.0" \ + python-multipart prometheus-client==0.20.0 "pydantic==2.12.5" \ + "diskcache==5.6.1" "Pillow==12.1.1" "jsonschema==4.23.0" \ + "pytest-xdist==3.6.1" "pytest-timeout==2.2.0" "websockets==15.0.1" pip install semantic_router --no-deps pip install aurelio_sdk --no-deps pip uninstall posthog -y @@ -346,42 +346,41 @@ jobs: pip install "pytest-cov==5.0.0" pip install "mypy==1.18.2" pip install "google-generativeai==0.3.2" - pip install "google-cloud-aiplatform==1.43.0" + pip install "google-cloud-aiplatform==1.133.0" pip install pyarrow - pip install "boto3==1.36.0" - pip install "aioboto3==13.4.0" + pip install "boto3==1.42.80" pip install langchain pip install lunary==0.2.5 - pip install "azure-identity==1.16.1" + pip install "azure-identity==1.25.3" pip install "langfuse==2.59.7" pip install "logfire==0.29.0" pip install numpydoc pip install traceloop-sdk==0.21.1 - pip install opentelemetry-api==1.25.0 - pip install opentelemetry-sdk==1.25.0 - pip install opentelemetry-exporter-otlp==1.25.0 + pip install opentelemetry-api==1.28.0 + pip install opentelemetry-sdk==1.28.0 + pip install opentelemetry-exporter-otlp==1.28.0 pip install openai==1.100.1 pip install prisma==0.11.0 pip install "detect_secrets==1.5.0" - pip install "httpx==0.24.1" + pip install "httpx==0.28.1" pip install "respx==0.22.0" pip install fastapi - pip install "gunicorn==21.2.0" - pip install "anyio==4.2.0" + pip install "gunicorn==23.0.0" + pip install "anyio==4.8.0" pip install "aiodynamo==23.10.1" pip install "asyncio==3.4.3" - pip install "apscheduler==3.10.4" + pip install "apscheduler==3.11.2" pip install "PyGithub==1.59.1" pip install argon2-cffi pip install "pytest-mock==3.12.0" pip install python-multipart pip install google-cloud-aiplatform pip install prometheus-client==0.20.0 - pip install "pydantic==2.10.2" + pip install "pydantic==2.12.5" pip install "diskcache==5.6.1" - pip install "Pillow==10.3.0" - pip install "jsonschema==4.22.0" - pip install "websockets==13.1.0" + pip install "Pillow==12.1.1" + pip install "jsonschema==4.23.0" + pip install "websockets==15.0.1" - setup_litellm_enterprise_pip - save_cache: paths: @@ -406,119 +405,6 @@ jobs: # Store test results - store_test_results: path: test-results - caching_unit_tests: - docker: - - image: cimg/python:3.11 - auth: - username: ${DOCKERHUB_USERNAME} - password: ${DOCKERHUB_PASSWORD} - resource_class: large - working_directory: ~/project - parallelism: 2 - - steps: - - checkout - - setup_google_dns - - run: - name: DNS lookup for Redis host - command: | - sudo apt-get update - sudo apt-get install -y dnsutils - dig redis-19899.c239.us-east-1-2.ec2.redns.redis-cloud.com +short - - run: - name: Show git commit hash - command: | - echo "Git commit hash: $CIRCLE_SHA1" - - - restore_cache: - keys: - - v2-caching-deps-{{ checksum ".circleci/requirements.txt" }} - - v2-caching-deps- - - run: - name: Install Dependencies - command: | - python -m pip install --upgrade pip - python -m pip install -r .circleci/requirements.txt - pip install "pytest==7.3.1" - pip install "pytest-retry==1.6.3" - pip install "pytest-asyncio==0.21.1" - pip install "pytest-cov==5.0.0" - pip install "mypy==1.18.2" - pip install "google-generativeai==0.3.2" - pip install "google-cloud-aiplatform==1.43.0" - pip install pyarrow - pip install "boto3==1.36.0" - pip install "aioboto3==13.4.0" - pip install langchain - pip install lunary==0.2.5 - pip install "azure-identity==1.16.1" - pip install "langfuse==2.59.7" - pip install "logfire==0.29.0" - pip install numpydoc - pip install traceloop-sdk==0.21.1 - pip install opentelemetry-api==1.25.0 - pip install opentelemetry-sdk==1.25.0 - pip install opentelemetry-exporter-otlp==1.25.0 - pip install openai==1.100.1 - pip install prisma==0.11.0 - pip install "detect_secrets==1.5.0" - pip install "httpx==0.24.1" - pip install "respx==0.22.0" - pip install fastapi - pip install "gunicorn==21.2.0" - pip install "anyio==4.2.0" - pip install "aiodynamo==23.10.1" - pip install "asyncio==3.4.3" - pip install "apscheduler==3.10.4" - pip install "PyGithub==1.59.1" - pip install argon2-cffi - pip install "pytest-mock==3.12.0" - pip install python-multipart - pip install google-cloud-aiplatform - pip install prometheus-client==0.20.0 - pip install "pydantic==2.10.2" - pip install "diskcache==5.6.1" - pip install "Pillow==10.3.0" - pip install "jsonschema==4.22.0" - pip install "websockets==13.1.0" - pip install "pytest-xdist==3.6.1" - - setup_litellm_enterprise_pip - - save_cache: - paths: - - /home/circleci/.pyenv/versions - - /home/circleci/.local - key: v2-caching-deps-{{ checksum ".circleci/requirements.txt" }} - - run: - name: Run prisma ./docker/entrypoint.sh - command: | - set +e - chmod +x docker/entrypoint.sh - ./docker/entrypoint.sh - set -e - - # Run pytest and generate JUnit XML report - - run: - name: Run tests - command: | - pwd - ls - mkdir -p test-results - - TEST_FILES=$(circleci tests glob "tests/local_testing/**/test_*.py") - - echo "$TEST_FILES" | circleci tests run \ - --split-by=timings \ - --verbose \ - --command="xargs python -m pytest \ - -v \ - --junitxml=test-results/junit.xml \ - --durations=5 \ - -k 'caching or cache'" - no_output_timeout: 15m - - # Store test results - - store_test_results: - path: test-results auth_ui_unit_tests: docker: - image: cimg/python:3.11 @@ -664,376 +550,6 @@ jobs: # Store test results - store_test_results: path: test-results - litellm_security_tests: - docker: - - image: cimg/python:3.13 - auth: - username: ${DOCKERHUB_USERNAME} - password: ${DOCKERHUB_PASSWORD} - - image: cimg/postgres:14.0 - environment: - POSTGRES_USER: postgres - POSTGRES_PASSWORD: postgres - POSTGRES_DB: circle_test - resource_class: xlarge - working_directory: ~/project - environment: - DATABASE_URL: "postgresql://postgres:postgres@localhost:5432/circle_test" - steps: - - checkout - - setup_google_dns - - run: - name: Show git commit hash - command: | - echo "Git commit hash: $CIRCLE_SHA1" - - setup_remote_docker: - docker_layer_caching: true - - restore_cache: - keys: - - v3-litellm-uv-deps-{{ checksum "requirements.txt" }}-{{ checksum ".circleci/config.yml" }} - - run: - name: Install Dependencies - command: | - python -m pip install --upgrade pip uv - uv pip install --system -r requirements.txt - pip install "pytest==7.3.1" "pytest-retry==1.6.3" "pytest-mock==3.12.0" \ - "pytest-asyncio==0.21.1" "pytest-cov==5.0.0" - - save_cache: - paths: - - ~/.local/lib - - ~/.local/bin - - ~/.cache/uv - key: v3-litellm-uv-deps-{{ checksum "requirements.txt" }}-{{ checksum ".circleci/config.yml" }} - - run: - name: Install dockerize - command: | - wget https://github.com/jwilder/dockerize/releases/download/v0.6.1/dockerize-linux-amd64-v0.6.1.tar.gz - sudo tar -C /usr/local/bin -xzvf dockerize-linux-amd64-v0.6.1.tar.gz - rm dockerize-linux-amd64-v0.6.1.tar.gz - - run: - name: Wait for PostgreSQL to be ready - command: dockerize -wait tcp://localhost:5432 -timeout 1m - - run: - name: Run Security Scans - command: | - chmod +x ci_cd/security_scans.sh - ./ci_cd/security_scans.sh - - run: - name: Run prisma ./docker/entrypoint.sh - command: | - set +e - chmod +x docker/entrypoint.sh - ./docker/entrypoint.sh - set -e - # Run pytest and generate JUnit XML report - - run: - name: Run tests - command: | - python -m pytest tests/proxy_security_tests -v -x --junitxml=test-results/junit.xml --durations=5 - no_output_timeout: 15m - # Store test results - - store_test_results: - path: test-results - # Split proxy unit tests into 3 jobs for faster execution and better debugging - # test_key_generate_prisma runs separately without parallel execution to avoid event loop issues with logging worker - litellm_proxy_unit_testing_key_generation: - docker: - - image: cimg/python:3.11 - auth: - username: ${DOCKERHUB_USERNAME} - password: ${DOCKERHUB_PASSWORD} - working_directory: ~/project - resource_class: medium - steps: - - checkout - - setup_google_dns - - run: - name: Show git commit hash - command: | - echo "Git commit hash: $CIRCLE_SHA1" - - run: - name: Install PostgreSQL - command: | - sudo apt-get update - sudo apt-get install -y postgresql-14 postgresql-contrib-14 - - restore_cache: - keys: - - v1-dependencies-{{ checksum ".circleci/requirements.txt" }} - - run: - name: Install Dependencies - command: | - python -m pip install --upgrade pip - python -m pip install -r .circleci/requirements.txt - pip install "pytest==7.3.1" - pip install "pytest-retry==1.6.3" - pip install "pytest-asyncio==0.21.1" - pip install "pytest-cov==5.0.0" - pip install "pytest-timeout==2.2.0" - pip install "pytest-forked==1.6.0" - pip install "mypy==1.18.2" - pip install "google-generativeai==0.3.2" - pip install "google-cloud-aiplatform==1.43.0" - pip install "google-genai==1.22.0" - pip install pyarrow - pip install "boto3==1.36.0" - pip install "aioboto3==13.4.0" - pip install langchain - pip install lunary==0.2.5 - pip install "azure-identity==1.16.1" - pip install "langfuse==2.59.7" - pip install "logfire==0.29.0" - pip install numpydoc - pip install traceloop-sdk==0.21.1 - pip install opentelemetry-api==1.25.0 - pip install opentelemetry-sdk==1.25.0 - pip install opentelemetry-exporter-otlp==1.25.0 - pip install openai==1.100.1 - pip install prisma==0.11.0 - pip install "detect_secrets==1.5.0" - pip install "httpx==0.24.1" - pip install "respx==0.22.0" - pip install fastapi - pip install "gunicorn==21.2.0" - pip install "anyio==4.2.0" - pip install "aiodynamo==23.10.1" - pip install "asyncio==3.4.3" - pip install "apscheduler==3.10.4" - pip install "PyGithub==1.59.1" - pip install argon2-cffi - pip install "pytest-mock==3.12.0" - pip install python-multipart - pip install google-cloud-aiplatform - pip install prometheus-client==0.20.0 - pip install "pydantic==2.10.2" - pip install "diskcache==5.6.1" - pip install "Pillow==10.3.0" - pip install "jsonschema==4.22.0" - pip install "pytest-postgresql==7.0.1" - pip install "fakeredis==2.28.1" - - setup_litellm_enterprise_pip - - save_cache: - paths: - - ./venv - key: v1-dependencies-{{ checksum ".circleci/requirements.txt" }} - - run: - name: Run prisma ./docker/entrypoint.sh - command: | - set +e - chmod +x docker/entrypoint.sh - ./docker/entrypoint.sh - set -e - - run: - name: Run key generation tests (no parallel execution to avoid event loop issues) - command: | - pwd - ls - # Run without -n flag to avoid pytest-xdist event loop conflicts with logging worker - python -m pytest tests/proxy_unit_tests/test_key_generate_prisma.py --cov=litellm --cov-report=xml --junitxml=test-results/junit-key-generation.xml --durations=10 --timeout=300 -vv --log-cli-level=INFO - no_output_timeout: 15m - - run: - name: Rename the coverage files - command: | - mv coverage.xml litellm_proxy_unit_tests_key_generation_coverage.xml - mv .coverage litellm_proxy_unit_tests_key_generation_coverage - - store_test_results: - path: test-results - - persist_to_workspace: - root: . - paths: - - litellm_proxy_unit_tests_key_generation_coverage.xml - - litellm_proxy_unit_tests_key_generation_coverage - litellm_proxy_unit_testing_part1: - docker: - - image: cimg/python:3.11 - auth: - username: ${DOCKERHUB_USERNAME} - password: ${DOCKERHUB_PASSWORD} - working_directory: ~/project - resource_class: xlarge - steps: - - checkout - - setup_google_dns - - run: - name: Show git commit hash - command: | - echo "Git commit hash: $CIRCLE_SHA1" - - run: - name: Install PostgreSQL - command: | - sudo apt-get update - sudo apt-get install -y postgresql-14 postgresql-contrib-14 - - restore_cache: - keys: - - v1-dependencies-{{ checksum ".circleci/requirements.txt" }} - - run: - name: Install Dependencies - command: | - python -m pip install --upgrade pip - python -m pip install -r .circleci/requirements.txt - pip install "pytest==7.3.1" - pip install "pytest-retry==1.6.3" - pip install "pytest-asyncio==0.21.1" - pip install "pytest-cov==5.0.0" - pip install "pytest-timeout==2.2.0" - pip install "pytest-forked==1.6.0" - pip install "mypy==1.18.2" - pip install "google-generativeai==0.3.2" - pip install "google-cloud-aiplatform==1.43.0" - pip install "google-genai==1.22.0" - pip install pyarrow - pip install "boto3==1.36.0" - pip install "aioboto3==13.4.0" - pip install langchain - pip install lunary==0.2.5 - pip install "azure-identity==1.16.1" - pip install "langfuse==2.59.7" - pip install "logfire==0.29.0" - pip install numpydoc - pip install traceloop-sdk==0.21.1 - pip install opentelemetry-api==1.25.0 - pip install opentelemetry-sdk==1.25.0 - pip install opentelemetry-exporter-otlp==1.25.0 - pip install openai==1.100.1 - pip install prisma==0.11.0 - pip install "detect_secrets==1.5.0" - pip install "httpx==0.24.1" - pip install "respx==0.22.0" - pip install fastapi - pip install "gunicorn==21.2.0" - pip install "anyio==4.2.0" - pip install "aiodynamo==23.10.1" - pip install "asyncio==3.4.3" - pip install "apscheduler==3.10.4" - pip install "PyGithub==1.59.1" - pip install argon2-cffi - pip install "pytest-mock==3.12.0" - pip install python-multipart - pip install google-cloud-aiplatform - pip install prometheus-client==0.20.0 - pip install "pydantic==2.10.2" - pip install "diskcache==5.6.1" - pip install "Pillow==10.3.0" - pip install "jsonschema==4.22.0" - pip install "pytest-postgresql==7.0.1" - pip install "fakeredis==2.28.1" - pip install "pytest-xdist==3.6.1" - - setup_litellm_enterprise_pip - - save_cache: - paths: - - ./venv - key: v1-dependencies-{{ checksum ".circleci/requirements.txt" }} - - run: - name: Run prisma ./docker/entrypoint.sh - command: | - set +e - chmod +x docker/entrypoint.sh - ./docker/entrypoint.sh - set -e - - run: - name: Run proxy unit tests (part 1 - auth checks) - command: | - pwd - ls - python -m pytest tests/proxy_unit_tests/test_auth_checks.py tests/proxy_unit_tests/test_user_api_key_auth.py --junitxml=test-results/junit-part1.xml --durations=10 -n 8 --timeout=300 -v - no_output_timeout: 15m - - store_test_results: - path: test-results - litellm_proxy_unit_testing_part2: - docker: - - image: cimg/python:3.11 - auth: - username: ${DOCKERHUB_USERNAME} - password: ${DOCKERHUB_PASSWORD} - working_directory: ~/project - resource_class: xlarge - steps: - - checkout - - setup_google_dns - - run: - name: Show git commit hash - command: | - echo "Git commit hash: $CIRCLE_SHA1" - - run: - name: Install PostgreSQL - command: | - sudo apt-get update - sudo apt-get install -y postgresql-14 postgresql-contrib-14 - - restore_cache: - keys: - - v1-dependencies-{{ checksum ".circleci/requirements.txt" }} - - run: - name: Install Dependencies - command: | - python -m pip install --upgrade pip - python -m pip install -r .circleci/requirements.txt - pip install "pytest==7.3.1" - pip install "pytest-retry==1.6.3" - pip install "pytest-asyncio==0.21.1" - pip install "pytest-cov==5.0.0" - pip install "pytest-timeout==2.2.0" - pip install "pytest-forked==1.6.0" - pip install "mypy==1.18.2" - pip install "google-generativeai==0.3.2" - pip install "google-cloud-aiplatform==1.43.0" - pip install "google-genai==1.22.0" - pip install pyarrow - pip install "boto3==1.36.0" - pip install "aioboto3==13.4.0" - pip install langchain - pip install lunary==0.2.5 - pip install "azure-identity==1.16.1" - pip install "langfuse==2.59.7" - pip install "logfire==0.29.0" - pip install numpydoc - pip install traceloop-sdk==0.21.1 - pip install opentelemetry-api==1.25.0 - pip install opentelemetry-sdk==1.25.0 - pip install opentelemetry-exporter-otlp==1.25.0 - pip install openai==1.100.1 - pip install prisma==0.11.0 - pip install "detect_secrets==1.5.0" - pip install "httpx==0.24.1" - pip install "respx==0.22.0" - pip install fastapi - pip install "gunicorn==21.2.0" - pip install "anyio==4.2.0" - pip install "aiodynamo==23.10.1" - pip install "asyncio==3.4.3" - pip install "apscheduler==3.10.4" - pip install "PyGithub==1.59.1" - pip install argon2-cffi - pip install "pytest-mock==3.12.0" - pip install python-multipart - pip install google-cloud-aiplatform - pip install prometheus-client==0.20.0 - pip install "pydantic==2.10.2" - pip install "diskcache==5.6.1" - pip install "Pillow==10.3.0" - pip install "jsonschema==4.22.0" - pip install "pytest-postgresql==7.0.1" - pip install "fakeredis==2.28.1" - pip install "pytest-xdist==3.6.1" - - setup_litellm_enterprise_pip - - save_cache: - paths: - - ./venv - key: v1-dependencies-{{ checksum ".circleci/requirements.txt" }} - - run: - name: Run prisma ./docker/entrypoint.sh - command: | - set +e - chmod +x docker/entrypoint.sh - ./docker/entrypoint.sh - set -e - - run: - name: Run proxy unit tests (part 2 - remaining tests) - command: | - pwd - ls - python -m pytest tests/proxy_unit_tests --ignore=tests/proxy_unit_tests/test_key_generate_prisma.py --ignore=tests/proxy_unit_tests/test_auth_checks.py --ignore=tests/proxy_unit_tests/test_user_api_key_auth.py --junitxml=test-results/junit-part2.xml --durations=10 -n 8 --timeout=300 -v - no_output_timeout: 15m - - store_test_results: - path: test-results litellm_assistants_api_testing: # Runs all tests with the "assistants" keyword docker: - image: cimg/python:3.13.1 @@ -1183,8 +699,8 @@ jobs: pip install "pytest-cov==5.0.0" pip install "pytest-asyncio==0.21.1" pip install "respx==0.22.0" - pip install "pydantic==2.11.0" - pip install "mcp==1.25.0" + pip install "pydantic==2.12.5" + pip install "mcp==1.26.0" pip install "pytest-xdist==3.6.1" # Run pytest and generate JUnit XML report - run: @@ -1229,7 +745,7 @@ jobs: pip install "pytest-cov==5.0.0" pip install "pytest-asyncio==0.21.1" pip install "respx==0.22.0" - pip install "pydantic==2.11.0" + pip install "pydantic==2.12.5" pip install "a2a-sdk" # Run pytest and generate JUnit XML report - run: @@ -1274,8 +790,8 @@ jobs: pip install "pytest-cov==5.0.0" pip install "pytest-asyncio==0.21.1" pip install "respx==0.22.0" - pip install "pydantic==2.10.2" - pip install "boto3==1.36.0" + pip install "pydantic==2.12.5" + pip install "boto3==1.42.80" pip install "semantic_router==0.1.10" --no-deps pip install aurelio_sdk pip install "pytest-xdist==3.6.1" @@ -1324,7 +840,7 @@ jobs: pip install "pytest-cov==5.0.0" pip install "pytest-asyncio==0.21.1" pip install "respx==0.22.0" - pip install "pydantic==2.10.2" + pip install "pydantic==2.12.5" # Run pytest and generate JUnit XML report - run: name: Run tests @@ -1507,101 +1023,6 @@ jobs: no_output_timeout: 15m - store_test_results: path: test-results - litellm_mapped_tests_llms: - docker: - - image: cimg/python:3.11 - auth: - username: ${DOCKERHUB_USERNAME} - password: ${DOCKERHUB_PASSWORD} - working_directory: ~/project - resource_class: large - steps: - - setup_litellm_test_deps - - run: - name: Run LLM provider tests - command: | - python -m pytest tests/test_litellm/llms --junitxml=test-results/junit-llms.xml --durations=10 -n 4 --maxfail=5 --timeout=300 -vv --log-cli-level=WARNING - no_output_timeout: 15m - - store_test_results: - path: test-results - litellm_mapped_tests_core: - docker: - - image: cimg/python:3.11 - auth: - username: ${DOCKERHUB_USERNAME} - password: ${DOCKERHUB_PASSWORD} - working_directory: ~/project - resource_class: large - steps: - - setup_litellm_test_deps - - run: - name: Run core tests - command: | - python -m pytest tests/test_litellm --ignore=tests/test_litellm/proxy --ignore=tests/test_litellm/llms --ignore=tests/test_litellm/integrations --ignore=tests/test_litellm/litellm_core_utils --ignore=tests/test_litellm/experimental_mcp_client --junitxml=test-results/junit-core.xml --durations=10 -n 4 --maxfail=5 --timeout=300 -vv --log-cli-level=WARNING - no_output_timeout: 15m - - store_test_results: - path: test-results - litellm_mapped_tests_litellm_core_utils: - docker: - - image: cimg/python:3.11 - auth: - username: ${DOCKERHUB_USERNAME} - password: ${DOCKERHUB_PASSWORD} - working_directory: ~/project - resource_class: large - steps: - - setup_litellm_test_deps - - run: - name: Run litellm_core_utils tests - command: | - python -m pytest tests/test_litellm/litellm_core_utils --junitxml=test-results/junit-litellm-core-utils.xml --durations=10 -n 4 --maxfail=5 --timeout=300 -vv --log-cli-level=WARNING - no_output_timeout: 15m - - store_test_results: - path: test-results - litellm_mapped_tests_mcps: - docker: - - image: cimg/python:3.11 - auth: - username: ${DOCKERHUB_USERNAME} - password: ${DOCKERHUB_PASSWORD} - working_directory: ~/project - resource_class: medium - steps: - - setup_litellm_test_deps - - run: - name: Run MCP client tests - command: | - python -m pytest tests/test_litellm/experimental_mcp_client --cov=litellm --cov-report=xml --junitxml=test-results/junit-mcps.xml --durations=10 -n 2 --maxfail=5 --timeout=300 -vv --log-cli-level=WARNING - no_output_timeout: 15m - - run: - name: Rename the coverage files - command: | - mv coverage.xml litellm_mcps_tests_coverage.xml - mv .coverage litellm_mcps_tests_coverage - - store_test_results: - path: test-results - - persist_to_workspace: - root: . - paths: - - litellm_mcps_tests_coverage.xml - - litellm_mcps_tests_coverage - litellm_mapped_tests_integrations: - docker: - - image: cimg/python:3.11 - auth: - username: ${DOCKERHUB_USERNAME} - password: ${DOCKERHUB_PASSWORD} - working_directory: ~/project - resource_class: large - steps: - - setup_litellm_test_deps - - run: - name: Run integrations tests - command: | - python -m pytest tests/test_litellm/integrations --junitxml=test-results/junit-integrations.xml --durations=10 -n 4 --maxfail=5 --timeout=300 -vv --log-cli-level=WARNING - no_output_timeout: 15m - - store_test_results: - path: test-results litellm_mapped_enterprise_tests: docker: - image: cimg/python:3.11 @@ -1626,8 +1047,8 @@ jobs: pip install "pytest-asyncio==0.21.1" pip install "respx==0.22.0" pip install "hypercorn==0.17.3" - pip install "pydantic==2.11.0" - pip install "mcp==1.25.0" + pip install "pydantic==2.12.5" + pip install "mcp==1.26.0" pip install "requests-mock>=1.12.1" pip install "responses==0.25.7" pip install "pytest-xdist==3.6.1" @@ -1668,7 +1089,7 @@ jobs: pip install "pytest-asyncio==0.21.1" pip install "pytest-cov==5.0.0" pip install "google-generativeai==0.3.2" - pip install "google-cloud-aiplatform==1.43.0" + pip install "google-cloud-aiplatform==1.133.0" pip install "pytest-xdist==3.6.1" # Run pytest and generate JUnit XML report - run: @@ -1715,7 +1136,7 @@ jobs: pip install "pytest-asyncio==0.21.1" pip install "pytest-cov==5.0.0" pip install "google-generativeai==0.3.2" - pip install "google-cloud-aiplatform==1.43.0" + pip install "google-cloud-aiplatform==1.133.0" pip install pytest-mock pip install "pytest-xdist==3.6.1" # Run pytest and generate JUnit XML report @@ -1837,9 +1258,9 @@ jobs: pip install pytest-mock pip install "respx==0.22.0" pip install "google-generativeai==0.3.2" - pip install "google-cloud-aiplatform==1.43.0" + pip install "google-cloud-aiplatform==1.133.0" pip install "mlflow==2.17.2" - pip install "anthropic==0.52.0" + pip install "anthropic==0.54.0" pip install "blockbuster==1.5.24" pip install "pytest-xdist==3.6.1" pip install "pytest-timeout==2.2.0" @@ -1930,11 +1351,11 @@ jobs: pip install aiohttp pip install openai pip install click - pip install "boto3==1.36.0" + pip install "boto3==1.42.80" pip install jinja2 - pip install "tokenizers==0.20.0" + pip install "tokenizers==0.22.2" pip install "uvloop==0.21.0" - pip install "fastuuid==0.12.0" + pip install "fastuuid==0.14.0" pip install jsonschema - setup_litellm_enterprise_pip - run: @@ -1967,7 +1388,7 @@ jobs: pip install "pytest-asyncio==0.21.1" pip install "pytest-cov==5.0.0" pip install "tomli==2.2.1" - pip install "mcp==1.25.0" + pip install "mcp==1.26.0" - run: name: Run tests command: | @@ -2137,6 +1558,25 @@ jobs: pip install "pytest-asyncio==0.21.1" pip install aiohttp pip install apscheduler + - run: + name: Install dockerize + command: | + sudo wget https://github.com/jwilder/dockerize/releases/download/v0.6.1/dockerize-linux-amd64-v0.6.1.tar.gz + sudo tar -C /usr/local/bin -xzvf dockerize-linux-amd64-v0.6.1.tar.gz + sudo rm dockerize-linux-amd64-v0.6.1.tar.gz + - run: + name: Start PostgreSQL Database + command: | + docker run -d \ + --name postgres-db \ + -e POSTGRES_USER=postgres \ + -e POSTGRES_PASSWORD=postgres \ + -e POSTGRES_DB=litellm_test \ + -p 5432:5432 \ + postgres:14 + - run: + name: Wait for PostgreSQL to be ready + command: dockerize -wait tcp://localhost:5432 -timeout 1m - attach_workspace: at: ~/project - run: @@ -2145,29 +1585,41 @@ jobs: zstd -d litellm-docker-database.tar.zst --stdout | docker load docker images | grep litellm-docker-database - run: - name: Run Docker container + name: Seed database with real schema + command: | + docker run -d \ + -p 4001:4000 \ + -e DATABASE_URL="postgresql://postgres:postgres@host.docker.internal:5432/litellm_test" \ + -e LITELLM_MASTER_KEY="sk-1234" \ + --name schema-seed \ + --add-host=host.docker.internal:host-gateway \ + -v $(pwd)/litellm/proxy/example_config_yaml/simple_config.yaml:/app/config.yaml \ + litellm-docker-database:ci \ + --config /app/config.yaml \ + --port 4000 \ + --use_prisma_db_push + - run: + name: Wait for schema seed to complete + command: dockerize -wait http://localhost:4001 -timeout 5m + - run: + name: Stop schema seed container + command: docker stop schema-seed && docker rm schema-seed + - run: + name: Run Docker container with bad schema and disabled updates command: | docker run -d \ -p 4000:4000 \ - -e DATABASE_URL=$PROXY_DATABASE_URL \ + -e DATABASE_URL="postgresql://postgres:postgres@host.docker.internal:5432/litellm_test" \ -e DEFAULT_NUM_WORKERS_LITELLM_PROXY=1 \ -e DISABLE_SCHEMA_UPDATE="True" \ + --name my-app \ + --add-host=host.docker.internal:host-gateway \ -v $(pwd)/litellm/proxy/example_config_yaml/bad_schema.prisma:/app/schema.prisma \ -v $(pwd)/litellm/proxy/example_config_yaml/bad_schema.prisma:/app/litellm/proxy/schema.prisma \ -v $(pwd)/litellm/proxy/example_config_yaml/disable_schema_update.yaml:/app/config.yaml \ - --name my-app \ litellm-docker-database:ci \ --config /app/config.yaml \ --port 4000 - - run: - name: Install curl and dockerize - command: | - sudo apt-get update - sudo apt-get install -y curl - sudo wget https://github.com/jwilder/dockerize/releases/download/v0.6.1/dockerize-linux-amd64-v0.6.1.tar.gz - sudo tar -C /usr/local/bin -xzvf dockerize-linux-amd64-v0.6.1.tar.gz - sudo rm dockerize-linux-amd64-v0.6.1.tar.gz - - run: name: Wait for container to be ready command: dockerize -wait http://localhost:4000 -timeout 1m @@ -2226,10 +1678,9 @@ jobs: pip install "pytest-asyncio==0.21.1" pip install "mypy==1.18.2" pip install "google-generativeai==0.3.2" - pip install "google-cloud-aiplatform==1.43.0" + pip install "google-cloud-aiplatform==1.133.0" pip install pyarrow - pip install "boto3==1.36.0" - pip install "aioboto3==13.4.0" + pip install "boto3==1.42.80" pip install langchain pip install "langfuse>=2.0.0" pip install "logfire==0.29.0" @@ -2237,9 +1688,9 @@ jobs: pip install prisma pip install fastapi pip install jsonschema - pip install "httpx==0.24.1" - pip install "gunicorn==21.2.0" - pip install "anyio==3.7.1" + pip install "httpx==0.28.1" + pip install "gunicorn==23.0.0" + pip install "anyio==4.8.0" pip install "aiodynamo==23.10.1" pip install "asyncio==3.4.3" pip install "PyGithub==1.59.1" @@ -2370,10 +1821,9 @@ jobs: pip install "mypy==1.18.2" pip install "jsonlines==4.0.0" pip install "google-generativeai==0.3.2" - pip install "google-cloud-aiplatform==1.43.0" + pip install "google-cloud-aiplatform==1.133.0" pip install pyarrow - pip install "boto3==1.36.0" - pip install "aioboto3==13.4.0" + pip install "boto3==1.42.80" pip install langchain pip install "langchain_mcp_adapters==0.0.5" pip install "langfuse>=2.0.0" @@ -2382,9 +1832,9 @@ jobs: pip install prisma pip install fastapi pip install jsonschema - pip install "httpx==0.24.1" - pip install "gunicorn==21.2.0" - pip install "anyio==3.7.1" + pip install "httpx==0.28.1" + pip install "gunicorn==23.0.0" + pip install "anyio==4.8.0" pip install "aiodynamo==23.10.1" pip install "asyncio==3.4.3" pip install "PyGithub==1.59.1" @@ -2516,10 +1966,9 @@ jobs: pip install "pytest-asyncio==0.21.1" pip install "mypy==1.18.2" pip install "google-generativeai==0.3.2" - pip install "google-cloud-aiplatform==1.43.0" + pip install "google-cloud-aiplatform==1.133.0" pip install pyarrow - pip install "boto3==1.36.0" - pip install "aioboto3==13.4.0" + pip install "boto3==1.42.80" pip install langchain pip install "langfuse>=2.0.0" pip install "logfire==0.29.0" @@ -2527,9 +1976,9 @@ jobs: pip install prisma pip install fastapi pip install jsonschema - pip install "httpx==0.24.1" - pip install "gunicorn==21.2.0" - pip install "anyio==3.7.1" + pip install "httpx==0.28.1" + pip install "gunicorn==23.0.0" + pip install "anyio==4.8.0" pip install "aiodynamo==23.10.1" pip install "asyncio==3.4.3" pip install "PyGithub==1.59.1" @@ -2575,9 +2024,6 @@ jobs: -e OPENAI_API_KEY=$OPENAI_API_KEY \ -e LITELLM_LICENSE=$LITELLM_LICENSE \ -e OTEL_EXPORTER="in_memory" \ - -e APORIA_API_BASE_2=$APORIA_API_BASE_2 \ - -e APORIA_API_KEY_2=$APORIA_API_KEY_2 \ - -e APORIA_API_BASE_1=$APORIA_API_BASE_1 \ -e AWS_ACCESS_KEY_ID=$AWS_ACCESS_KEY_ID \ -e AWS_SECRET_ACCESS_KEY=$AWS_SECRET_ACCESS_KEY \ -e DEFAULT_NUM_WORKERS_LITELLM_PROXY=1 \ @@ -2585,7 +2031,6 @@ jobs: -e DD_API_KEY=$DD_API_KEY \ -e DD_SITE=$DD_SITE \ -e AWS_REGION_NAME=$AWS_REGION_NAME \ - -e APORIA_API_KEY_1=$APORIA_API_KEY_1 \ -e COHERE_API_KEY=$COHERE_API_KEY \ -e GCS_FLUSH_INTERVAL="1" \ --add-host host.docker.internal:host-gateway \ @@ -3061,6 +2506,20 @@ jobs: name: Build Docker image command: | docker build -t my-app:latest -f docker/build_from_pip/Dockerfile.build_from_pip . + - run: + name: Start PostgreSQL Database + command: | + docker run -d \ + --name postgres-db \ + -e POSTGRES_USER=postgres \ + -e POSTGRES_PASSWORD=postgres \ + -e POSTGRES_DB=circle_test \ + -p 5432:5432 \ + postgres:14 + - run: + name: Wait for PostgreSQL to be ready + command: | + timeout 60s bash -c 'until docker exec postgres-db pg_isready -U postgres -d circle_test; do sleep 2; done' - run: name: Run Docker container # intentionally give bad redis credentials here @@ -3068,7 +2527,7 @@ jobs: command: | docker run -d \ -p 4000:4000 \ - -e DATABASE_URL=$PROXY_DATABASE_URL \ + -e DATABASE_URL=postgresql://postgres:postgres@host.docker.internal:5432/circle_test \ -e REDIS_HOST=$REDIS_HOST \ -e REDIS_PASSWORD=$REDIS_PASSWORD \ -e REDIS_PORT=$REDIS_PORT \ @@ -3076,18 +2535,15 @@ jobs: -e OPENAI_API_KEY=$OPENAI_API_KEY \ -e LITELLM_LICENSE=$LITELLM_LICENSE \ -e OTEL_EXPORTER="in_memory" \ - -e APORIA_API_BASE_2=$APORIA_API_BASE_2 \ - -e APORIA_API_KEY_2=$APORIA_API_KEY_2 \ - -e APORIA_API_BASE_1=$APORIA_API_BASE_1 \ -e AWS_ACCESS_KEY_ID=$AWS_ACCESS_KEY_ID \ -e AWS_SECRET_ACCESS_KEY=$AWS_SECRET_ACCESS_KEY \ -e AWS_REGION_NAME=$AWS_REGION_NAME \ - -e APORIA_API_KEY_1=$APORIA_API_KEY_1 \ -e COHERE_API_KEY=$COHERE_API_KEY \ -e USE_DDTRACE=True \ -e DD_API_KEY=$DD_API_KEY \ -e DD_SITE=$DD_SITE \ -e GCS_FLUSH_INTERVAL="1" \ + --add-host host.docker.internal:host-gateway \ --name my-app \ -v $(pwd)/docker/build_from_pip/litellm_config.yaml:/app/config.yaml \ my-app:latest \ @@ -3118,8 +2574,11 @@ jobs: - run: name: Stop and remove first container command: | - docker stop my-app - docker rm my-app + docker stop my-app || true + docker rm my-app || true + docker stop postgres-db || true + docker rm postgres-db || true + when: always proxy_pass_through_endpoint_tests: machine: image: ubuntu-2204:2023.10.1 @@ -3148,16 +2607,16 @@ jobs: pip install "pytest==7.3.1" pip install "pytest-retry==1.6.3" pip install "pytest-asyncio==0.21.1" - pip install "google-cloud-aiplatform==1.43.0" + pip install "google-cloud-aiplatform==1.133.0" pip install aiohttp pip install "openai==1.100.1" pip install "assemblyai==0.37.0" python -m pip install --upgrade pip - pip install "pydantic==2.10.2" + pip install "pydantic==2.12.5" pip install "pytest==7.3.1" pip install "pytest-mock==3.12.0" pip install "pytest-asyncio==0.21.1" - pip install "boto3==1.36.0" + pip install "boto3==1.42.80" pip install "mypy==1.18.2" pip install pyarrow pip install numpydoc @@ -3165,11 +2624,11 @@ jobs: pip install fastapi pip install jsonschema pip install "httpx==0.27.0" - pip install "anyio==3.7.1" + pip install "anyio==4.8.0" pip install "asyncio==3.4.3" pip install "PyGithub==1.59.1" pip install "google-cloud-aiplatform==1.59.0" - pip install "anthropic==0.52.0" + pip install "anthropic==0.54.0" pip install "langchain_mcp_adapters==0.0.5" pip install "langchain_openai==0.2.1" pip install "langgraph==0.3.18" @@ -3336,7 +2795,7 @@ jobs: conda activate myenv pip install "pytest==7.3.1" pip install "pytest-asyncio==0.21.1" - pip install "boto3==1.36.0" + pip install "boto3==1.42.80" pip install "httpx==0.27.0" pip install "claude-agent-sdk" pip install -r requirements.txt @@ -3543,93 +3002,6 @@ jobs: - codecov/upload: file: ./coverage.xml - publish_to_pypi: - docker: - - image: cimg/python:3.8 - working_directory: ~/project - - environment: - TWINE_USERNAME: __token__ - - steps: - - checkout - - - run: - name: Copy model_prices_and_context_window File to model_prices_and_context_window_backup - command: | - cp model_prices_and_context_window.json litellm/model_prices_and_context_window_backup.json - - - run: - name: Checkout code - command: git checkout $CIRCLE_SHA1 - - # Check if setup.py is modified and publish to PyPI - - run: - name: PyPI publish - command: | - echo "Install TOML package." - python -m pip install toml - VERSION=$(python -c "import toml; print(toml.load('pyproject.toml')['tool']['poetry']['version'])") - PACKAGE_NAME=$(python -c "import toml; print(toml.load('pyproject.toml')['tool']['poetry']['name'])") - if ! pip show -v $PACKAGE_NAME | grep -q "Version: ${VERSION}"; then - echo "pyproject.toml modified" - echo -e "[pypi]\nusername = $PYPI_PUBLISH_USERNAME\npassword = $PYPI_PUBLISH_PASSWORD" > ~/.pypirc - python -m pip install --upgrade pip - pip install build - pip install wheel - pip install --upgrade twine setuptools - rm -rf build dist - - echo "Building package" - python -m build - - echo "Twine upload to dist" - echo "Contents of dist directory:" - ls dist/ - twine upload --verbose dist/* - else - echo "Version ${VERSION} of package is already published on PyPI." - - # Check if corresponding Docker nightly image exists - NIGHTLY_TAG="v${VERSION}-nightly" - echo "Checking for Docker nightly image: litellm/litellm:${NIGHTLY_TAG}" - - # Check Docker Hub for the nightly image - if curl -s "https://hub.docker.com/v2/repositories/litellm/litellm/tags/${NIGHTLY_TAG}" | grep -q "name"; then - echo "Docker nightly image ${NIGHTLY_TAG} exists. This release was already completed successfully." - echo "Skipping PyPI publish and continuing to ensure Docker images are up to date." - circleci step halt - else - echo "ERROR: PyPI package ${VERSION} exists but Docker nightly image ${NIGHTLY_TAG} does not exist!" - echo "This indicates an incomplete release. Please investigate." - exit 1 - fi - fi - - run: - name: Trigger Github Action for new Docker Container + Trigger Load Testing - command: | - echo "Install TOML package." - python3 -m pip install toml - VERSION=$(python3 -c "import toml; print(toml.load('pyproject.toml')['tool']['poetry']['version'])") - echo "LiteLLM Version ${VERSION}" - - # Determine which branch to use for Docker build - if [[ "$CIRCLE_BRANCH" =~ ^litellm_release_day_.* ]]; then - BUILD_BRANCH="$CIRCLE_BRANCH" - echo "Using release branch: $BUILD_BRANCH" - else - BUILD_BRANCH="main" - echo "Using default branch: $BUILD_BRANCH" - fi - - curl -X POST \ - -H "Accept: application/vnd.github.v3+json" \ - -H "Authorization: Bearer $GITHUB_TOKEN" \ - "https://api.github.com/repos/BerriAI/litellm/actions/workflows/ghcr_deploy.yml/dispatches" \ - -d "{\"ref\":\"${BUILD_BRANCH}\", \"inputs\":{\"tag\":\"v${VERSION}-nightly\", \"commit_hash\":\"$CIRCLE_SHA1\"}}" - echo "triggering load testing server for version ${VERSION} and commit ${CIRCLE_SHA1}" - curl -X POST "https://proxyloadtester-production.up.railway.app/start/load/test?version=${VERSION}&commit_hash=${CIRCLE_SHA1}&release_type=nightly" - publish_proxy_extras: docker: - image: cimg/python:3.8 @@ -3861,31 +3233,9 @@ jobs: name: Install Playwright Browsers command: | npx playwright install - - run: - name: Install Neon CLI - command: | - npm i -g neonctl - - run: - name: Create Neon branch - command: | - export EXPIRES_AT=$(date -u -d "+3 hours" +"%Y-%m-%dT%H:%M:%SZ") - echo "Expires at: $EXPIRES_AT" - neon branches create \ - --project-id $NEON_PROJECT_ID \ - --name preview/commit-${CIRCLE_SHA1:0:7}-<< parameters.browser >> \ - --expires-at $EXPIRES_AT \ - --parent br-fancy-paper-ad1olsb3 \ - --api-key $NEON_API_KEY || true - run: name: Run Docker container command: | - E2E_UI_TEST_DATABASE_URL=$(neon connection-string \ - --project-id $NEON_PROJECT_ID \ - --api-key $NEON_API_KEY \ - --branch preview/commit-${CIRCLE_SHA1:0:7}-<< parameters.browser >> \ - --database-name yuneng-trial-db \ - --role neondb_owner) - echo $E2E_UI_TEST_DATABASE_URL docker run -d \ -p 4000:4000 \ -e DATABASE_URL=$E2E_UI_TEST_DATABASE_URL \ @@ -3942,37 +3292,39 @@ jobs: - setup_google_dns - attach_workspace: at: ~/project + - run: + name: Install dockerize + command: | + sudo wget https://github.com/jwilder/dockerize/releases/download/v0.6.1/dockerize-linux-amd64-v0.6.1.tar.gz + sudo tar -C /usr/local/bin -xzvf dockerize-linux-amd64-v0.6.1.tar.gz + sudo rm dockerize-linux-amd64-v0.6.1.tar.gz + - run: + name: Start PostgreSQL Database + command: | + docker run -d \ + --name postgres-db \ + -e POSTGRES_USER=postgres \ + -e POSTGRES_PASSWORD=postgres \ + -e POSTGRES_DB=litellm_schema_sync \ + -p 5432:5432 \ + postgres:14 + - run: + name: Wait for PostgreSQL to be ready + command: dockerize -wait tcp://localhost:5432 -timeout 1m - run: name: Load Docker Database Image command: | zstd -d litellm-docker-database.tar.zst --stdout | docker load docker images | grep litellm-docker-database - run: - name: Install Neon CLI + name: Run schema sync via prisma db push command: | - npm i -g neonctl - - run: - name: Install curl and dockerize - command: | - sudo apt-get update - sudo apt-get install -y curl - sudo wget https://github.com/jwilder/dockerize/releases/download/v0.6.1/dockerize-linux-amd64-v0.6.1.tar.gz - sudo tar -C /usr/local/bin -xzvf dockerize-linux-amd64-v0.6.1.tar.gz - sudo rm dockerize-linux-amd64-v0.6.1.tar.gz - - run: - name: Sync schema on base e2e database - command: | - BASE_DATABASE_URL=$(neon connection-string \ - --project-id $NEON_PROJECT_ID \ - --api-key $NEON_API_KEY \ - --branch br-fancy-paper-ad1olsb3 \ - --database-name yuneng-trial-db \ - --role neondb_owner) docker run -d \ -p 4000:4000 \ - -e DATABASE_URL=$BASE_DATABASE_URL \ + -e DATABASE_URL="postgresql://postgres:postgres@host.docker.internal:5432/litellm_schema_sync" \ -e LITELLM_MASTER_KEY="sk-1234" \ --name schema-sync \ + --add-host=host.docker.internal:host-gateway \ -v $(pwd)/litellm/proxy/example_config_yaml/simple_config.yaml:/app/config.yaml \ litellm-docker-database:ci \ --config /app/config.yaml \ @@ -4089,34 +3441,14 @@ workflows: only: - main - /litellm_.*/ - - caching_unit_tests: - filters: - branches: - only: - main - /litellm_.*/ - - litellm_proxy_unit_testing_key_generation: - filters: - branches: - only: - main - /litellm_.*/ - - litellm_proxy_unit_testing_part1: - filters: - branches: - only: - main - /litellm_.*/ - - litellm_proxy_unit_testing_part2: - filters: - branches: - only: - main - /litellm_.*/ - - litellm_security_tests: - filters: - branches: - only: - main - /litellm_.*/ - litellm_assistants_api_testing: @@ -4170,7 +3502,6 @@ workflows: - main - /litellm_.*/ - prisma_schema_sync: - context: e2e_ui_tests requires: - build_docker_database_image filters: @@ -4178,32 +3509,32 @@ workflows: only: - main - /litellm_.*/ - - e2e_ui_testing: - name: e2e_ui_testing_chromium - browser: chromium - context: e2e_ui_tests - requires: - - ui_build - - build_docker_database_image - - prisma_schema_sync - filters: - branches: - only: - - main - - /litellm_.*/ - - e2e_ui_testing: - name: e2e_ui_testing_firefox - browser: firefox - context: e2e_ui_tests - requires: - - ui_build - - build_docker_database_image - - prisma_schema_sync - filters: - branches: - only: - - main - - /litellm_.*/ + # - e2e_ui_testing: + # name: e2e_ui_testing_chromium + # browser: chromium + # context: e2e_ui_tests + # requires: + # - ui_build + # - build_docker_database_image + # - prisma_schema_sync + # filters: + # branches: + # only: + # - main + # - /litellm_.*/ + # - e2e_ui_testing: + # name: e2e_ui_testing_firefox + # browser: firefox + # context: e2e_ui_tests + # requires: + # - ui_build + # - build_docker_database_image + # - prisma_schema_sync + # filters: + # branches: + # only: + # - main + # - /litellm_.*/ - build_and_test: requires: - build_docker_database_image @@ -4352,34 +3683,14 @@ workflows: only: - main - /litellm_.*/ - - litellm_mapped_tests_llms: - filters: - branches: - only: - main - /litellm_.*/ - - litellm_mapped_tests_core: - filters: - branches: - only: - main - /litellm_.*/ - - litellm_mapped_tests_mcps: - filters: - branches: - only: - main - /litellm_.*/ - - litellm_mapped_tests_integrations: - filters: - branches: - only: - main - /litellm_.*/ - - litellm_mapped_tests_litellm_core_utils: - filters: - branches: - only: - main - /litellm_.*/ - batches_testing: @@ -4429,11 +3740,6 @@ workflows: - search_testing - litellm_mapped_tests_proxy_part1 - litellm_mapped_tests_proxy_part2 - - litellm_mapped_tests_llms - - litellm_mapped_tests_core - - litellm_mapped_tests_mcps - - litellm_mapped_tests_integrations - - litellm_mapped_tests_litellm_core_utils - litellm_mapped_enterprise_tests - batches_testing - litellm_utils_testing @@ -4441,8 +3747,6 @@ workflows: - image_gen_testing - logging_testing - audio_testing - - caching_unit_tests - - litellm_proxy_unit_testing_key_generation - langfuse_logging_unit_tests - local_testing_part1 - local_testing_part2 @@ -4489,59 +3793,3 @@ workflows: only: - main - /litellm_release_day_.*/ - - publish_to_pypi: - requires: - - mypy_linting - - semgrep - - local_testing_part1 - - local_testing_part2 - - build_and_test - - e2e_openai_endpoints - - test_bad_database_url - - llm_translation_testing - - realtime_translation_testing - - mcp_testing - - agent_testing - - google_generate_content_endpoint_testing - - llm_responses_api_testing - - ocr_testing - - search_testing - - litellm_mapped_tests_proxy_part1 - - litellm_mapped_tests_proxy_part2 - - litellm_mapped_tests_llms - - litellm_mapped_tests_core - - litellm_mapped_tests_mcps - - litellm_mapped_tests_integrations - - litellm_mapped_tests_litellm_core_utils - - litellm_mapped_enterprise_tests - - batches_testing - - litellm_utils_testing - - pass_through_unit_testing - - image_gen_testing - - logging_testing - - audio_testing - - litellm_router_testing - - litellm_router_unit_testing - - caching_unit_tests - - langfuse_logging_unit_tests - - litellm_assistants_api_testing - - auth_ui_unit_tests - - ui_unit_tests - - db_migration_disable_update_check - - e2e_ui_testing_chromium - - e2e_ui_testing_firefox - - litellm_proxy_unit_testing_key_generation - - litellm_proxy_unit_testing_part1 - - litellm_proxy_unit_testing_part2 - - litellm_security_tests - - installing_litellm_on_python - - installing_litellm_on_python_3_13 - - proxy_logging_guardrails_model_info_tests - - proxy_spend_accuracy_tests - - proxy_multi_instance_tests - - proxy_store_model_in_db_tests - - proxy_build_from_pip_tests - - proxy_pass_through_endpoint_tests - - check_code_and_doc_quality - - publish_proxy_extras - - guardrails_testing diff --git a/.circleci/requirements.txt b/.circleci/requirements.txt index ab4c399577..be12ab2d0f 100644 --- a/.circleci/requirements.txt +++ b/.circleci/requirements.txt @@ -7,15 +7,15 @@ cohere redis==5.2.1 redisvl==0.4.1 anthropic -orjson==3.10.12 # fast /embedding responses -pydantic==2.11.0 -google-cloud-aiplatform==1.43.0 +orjson==3.10.15 # fast /embedding responses +pydantic==2.12.5 +google-cloud-aiplatform==1.133.0 google-cloud-iam==2.19.1 fastapi-sso==0.16.0 uvloop==0.21.0 -mcp==1.25.0 # for MCP server +mcp==1.26.0 # for MCP server semantic_router==0.1.10 # for auto-routing with litellm -fastuuid==0.12.0 +fastuuid==0.14.0 responses==0.25.7 # for proxy client tests pytest-retry==1.6.3 # for automatic test retries litellm-proxy-extras # for prisma migrations \ No newline at end of file diff --git a/.github/actions/helm-oci-chart-releaser/action.yml b/.github/actions/helm-oci-chart-releaser/action.yml index 1823e26283..454c591d43 100644 --- a/.github/actions/helm-oci-chart-releaser/action.yml +++ b/.github/actions/helm-oci-chart-releaser/action.yml @@ -41,32 +41,54 @@ runs: using: composite steps: - name: Helm | Setup - uses: azure/setup-helm@v4 + uses: azure/setup-helm@1a275c3b69536ee54be43f2070a358922e12c8d4 # v4.3.1 with: version: v3.20.0 - name: Helm | Login shell: bash - run: echo ${{ inputs.registry_password }} | helm registry login -u ${{ inputs.registry_username }} --password-stdin ${{ inputs.registry }} + env: + REGISTRY_PASSWORD: ${{ inputs.registry_password }} + REGISTRY_USERNAME: ${{ inputs.registry_username }} + REGISTRY: ${{ inputs.registry }} + run: echo "$REGISTRY_PASSWORD" | helm registry login -u "$REGISTRY_USERNAME" --password-stdin "$REGISTRY" - name: Helm | Dependency if: inputs.update_dependencies == 'true' shell: bash - run: helm dependency update ${{ inputs.path == null && format('{0}/{1}', 'charts', inputs.name) || inputs.path }} + env: + CHART_PATH: ${{ inputs.path == null && format('{0}/{1}', 'charts', inputs.name) || inputs.path }} + run: helm dependency update "$CHART_PATH" - name: Helm | Package shell: bash - run: helm package ${{ inputs.path == null && format('{0}/{1}', 'charts', inputs.name) || inputs.path }} --version ${{ inputs.tag }} --app-version ${{ inputs.app_version }} + env: + CHART_PATH: ${{ inputs.path == null && format('{0}/{1}', 'charts', inputs.name) || inputs.path }} + TAG: ${{ inputs.tag }} + APP_VERSION: ${{ inputs.app_version }} + run: helm package "$CHART_PATH" --version "$TAG" --app-version "$APP_VERSION" - name: Helm | Push shell: bash - run: helm push ${{ inputs.name }}-${{ inputs.tag }}.tgz oci://${{ inputs.registry }}/${{ inputs.repository }} + env: + NAME: ${{ inputs.name }} + TAG: ${{ inputs.tag }} + REGISTRY: ${{ inputs.registry }} + REPOSITORY: ${{ inputs.repository }} + run: helm push "${NAME}-${TAG}.tgz" "oci://${REGISTRY}/${REPOSITORY}" - name: Helm | Logout shell: bash - run: helm registry logout ${{ inputs.registry }} + env: + REGISTRY: ${{ inputs.registry }} + run: helm registry logout "$REGISTRY" - name: Helm | Output id: output shell: bash - run: echo "image=${{ inputs.registry }}/${{ inputs.repository }}/${{ inputs.name }}:${{ inputs.tag }}" >> $GITHUB_OUTPUT + env: + REGISTRY: ${{ inputs.registry }} + REPOSITORY: ${{ inputs.repository }} + NAME: ${{ inputs.name }} + TAG: ${{ inputs.tag }} + run: echo "image=${REGISTRY}/${REPOSITORY}/${NAME}:${TAG}" >> $GITHUB_OUTPUT diff --git a/.github/dependabot.yaml b/.github/dependabot.yaml index 58e7cfe10d..c49882a8d6 100644 --- a/.github/dependabot.yaml +++ b/.github/dependabot.yaml @@ -4,6 +4,9 @@ updates: directory: "/" schedule: interval: "daily" + cooldown: + default-days: 7 + semver-major-days: 14 groups: github-actions: patterns: diff --git a/.github/workflows/_test-unit-base.yml b/.github/workflows/_test-unit-base.yml new file mode 100644 index 0000000000..f1ae30e67d --- /dev/null +++ b/.github/workflows/_test-unit-base.yml @@ -0,0 +1,96 @@ +name: _Unit Test Base (Reusable) + +on: + workflow_call: + inputs: + test-path: + description: "Pytest path(s) to run" + required: true + type: string + workers: + description: "Number of pytest-xdist workers" + required: false + type: number + default: 2 + reruns: + description: "Number of reruns for flaky tests" + required: false + type: number + default: 2 + timeout-minutes: + description: "Job timeout in minutes" + required: false + type: number + default: 20 + max-failures: + description: "Stop after this many failures" + required: false + type: number + default: 10 + +permissions: + contents: read + +jobs: + run: + name: Run tests + runs-on: ubuntu-latest + timeout-minutes: ${{ inputs.timeout-minutes }} + + steps: + - uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0 + with: + persist-credentials: false + + - name: Set up Python + uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0 + with: + python-version: "3.12" + + - name: Install Poetry + run: pip install 'poetry==2.3.2' + + - name: Cache Poetry dependencies + uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0 + with: + path: | + ~/.cache/pypoetry + ~/.cache/pip + .venv + key: ${{ runner.os }}-poetry-${{ hashFiles('poetry.lock') }} + restore-keys: | + ${{ runner.os }}-poetry- + + - name: Install dependencies + run: | + poetry config virtualenvs.in-project true + poetry install --with dev,proxy-dev --extras "proxy semantic-router" + poetry run pip install google-genai==1.22.0 \ + google-cloud-aiplatform==1.115.0 fastapi-offline==1.7.3 python-multipart==0.0.22 openapi-core==0.23.0 + + - name: Setup litellm-enterprise + run: | + poetry run pip install --force-reinstall --no-deps -e enterprise/ + + - name: Generate Prisma client + env: + PRISMA_BINARY_CACHE_DIR: ${{ runner.temp }}/prisma-cache + run: | + poetry run pip install nodejs-wheel-binaries==24.13.1 + poetry run prisma generate --schema litellm/proxy/schema.prisma + + - name: Run tests + env: + TEST_PATH: ${{ inputs.test-path }} + MAX_FAILURES: ${{ inputs.max-failures }} + WORKERS: ${{ inputs.workers }} + RERUNS: ${{ inputs.reruns }} + run: | + poetry run pytest ${TEST_PATH:?} \ + --tb=short -vv \ + --maxfail="${MAX_FAILURES}" \ + -n "${WORKERS}" \ + --reruns "${RERUNS}" \ + --reruns-delay 1 \ + --dist=loadscope \ + --durations=20 diff --git a/.github/workflows/_test-unit-services-base.yml b/.github/workflows/_test-unit-services-base.yml new file mode 100644 index 0000000000..d53a9e8822 --- /dev/null +++ b/.github/workflows/_test-unit-services-base.yml @@ -0,0 +1,164 @@ +name: _Unit Test Services Base (Reusable) + +on: + workflow_call: + inputs: + test-path: + description: "Pytest path(s) to run" + required: true + type: string + workers: + description: "Number of pytest-xdist workers (0 = no parallelism)" + required: false + type: number + default: 2 + reruns: + description: "Number of reruns for flaky tests" + required: false + type: number + default: 2 + timeout-minutes: + description: "Job timeout in minutes" + required: false + type: number + default: 20 + max-failures: + description: "Stop after this many failures" + required: false + type: number + default: 10 + enable-redis: + description: "Pass Redis Cloud credentials to tests via REDIS_HOST/PORT/PASSWORD env vars" + required: false + type: boolean + default: false + enable-postgres: + description: "Start a local Postgres service container and run Prisma migrations" + required: false + type: boolean + default: false + secrets: + REDIS_HOST: + required: false + REDIS_PORT: + required: false + REDIS_PASSWORD: + required: false + DATABASE_URL: + required: false + POSTGRES_USER: + required: false + POSTGRES_PASSWORD: + required: false + +permissions: + contents: read + +jobs: + run: + name: Run tests + runs-on: ubuntu-latest + timeout-minutes: ${{ inputs.timeout-minutes }} + # Environment is derived from the enable-* flags, not caller-controllable. + # This prevents callers from passing arbitrary environment names to bypass secret scoping. + # Note: Postgres service container always starts (GHA limitation), so any Redis job + # also needs Postgres secrets → uses integration-redis-postgres, not integration-redis. + environment: >- + ${{ + inputs.enable-redis && 'integration-redis-postgres' || + inputs.enable-postgres && 'integration-postgres' || + '' + }} + + services: + postgres: + image: postgres@sha256:705a5d5b5836f3fcba0d02c4d281e6a7dd9ed2dd4078640f08a1e1e9896e097d # postgres:14 + env: + POSTGRES_USER: ${{ secrets.POSTGRES_USER }} + POSTGRES_PASSWORD: ${{ secrets.POSTGRES_PASSWORD }} + POSTGRES_DB: litellm_test + ports: + - 5432:5432 + options: >- + --health-cmd "pg_isready" + --health-interval 10s + --health-timeout 5s + --health-retries 5 + + steps: + - uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0 + with: + persist-credentials: false + + - name: Set up Python + uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0 + with: + python-version: "3.12" + + - name: Install Poetry + run: pip install 'poetry==2.3.2' + + - name: Cache Poetry dependencies + uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0 + with: + path: | + ~/.cache/pypoetry + ~/.cache/pip + .venv + key: ${{ runner.os }}-poetry-services-${{ hashFiles('poetry.lock') }} + restore-keys: | + ${{ runner.os }}-poetry-services- + + - name: Install dependencies + run: | + poetry config virtualenvs.in-project true + poetry install --with dev,proxy-dev --extras "proxy semantic-router" + poetry run pip install google-genai==1.22.0 \ + google-cloud-aiplatform==1.115.0 fastapi-offline==1.7.3 python-multipart==0.0.22 openapi-core==0.23.0 + + - name: Setup litellm-enterprise + run: | + poetry run pip install --force-reinstall --no-deps -e enterprise/ + + - name: Generate Prisma client + env: + PRISMA_BINARY_CACHE_DIR: ${{ runner.temp }}/prisma-cache + run: | + poetry run pip install nodejs-wheel-binaries==24.13.1 + poetry run prisma generate --schema litellm/proxy/schema.prisma + + - name: Run Prisma migrations + if: ${{ inputs.enable-postgres }} + env: + DATABASE_URL: ${{ secrets.DATABASE_URL }} + run: | + poetry run prisma db push --schema litellm/proxy/schema.prisma --accept-data-loss + + - name: Run tests + env: + TEST_PATH: ${{ inputs.test-path }} + MAX_FAILURES: ${{ inputs.max-failures }} + WORKERS: ${{ inputs.workers }} + RERUNS: ${{ inputs.reruns }} + DATABASE_URL: ${{ inputs.enable-postgres && secrets.DATABASE_URL || '' }} + REDIS_HOST: ${{ inputs.enable-redis && secrets.REDIS_HOST || '' }} + REDIS_PORT: ${{ inputs.enable-redis && secrets.REDIS_PORT || '' }} + REDIS_PASSWORD: ${{ inputs.enable-redis && secrets.REDIS_PASSWORD || '' }} + run: | + if [ "${WORKERS}" = "0" ]; then + poetry run pytest ${TEST_PATH:?} \ + --tb=short -vv \ + --maxfail="${MAX_FAILURES}" \ + --reruns "${RERUNS}" \ + --reruns-delay 1 \ + --durations=20 + else + poetry run pytest ${TEST_PATH:?} \ + --tb=short -vv \ + --maxfail="${MAX_FAILURES}" \ + -n "${WORKERS}" \ + --reruns "${RERUNS}" \ + --reruns-delay 1 \ + --dist=loadscope \ + --durations=20 + fi diff --git a/.github/workflows/auto_update_price_and_context_window.yml b/.github/workflows/auto_update_price_and_context_window.yml index 98b9d868e6..60e8993621 100644 --- a/.github/workflows/auto_update_price_and_context_window.yml +++ b/.github/workflows/auto_update_price_and_context_window.yml @@ -2,18 +2,24 @@ name: Updates model_prices_and_context_window.json and Create Pull Request on: schedule: - - cron: "0 0 * * 0" # Run every Sundays at midnight + - cron: "0 0 * * 0" # Run every Sundays at midnight #- cron: "0 0 * * *" # Run daily at midnight +permissions: + contents: write + pull-requests: write + jobs: auto_update_price_and_context_window: if: github.repository == 'BerriAI/litellm' runs-on: ubuntu-latest steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0 + with: + persist-credentials: false - name: Install Dependencies run: | - pip install aiohttp + pip install 'aiohttp==3.13.3' - name: Update JSON Data run: | python ".github/workflows/auto_update_price_and_context_window_file.py" @@ -26,4 +32,4 @@ jobs: --head auto-update-price-and-context-window-$(date +'%Y-%m-%d') \ --base main env: - GH_TOKEN: ${{ secrets.GH_TOKEN }} \ No newline at end of file + GH_TOKEN: ${{ secrets.GH_TOKEN }} diff --git a/.github/workflows/check-schema-sync.yml b/.github/workflows/check-schema-sync.yml new file mode 100644 index 0000000000..0e5e2804e6 --- /dev/null +++ b/.github/workflows/check-schema-sync.yml @@ -0,0 +1,58 @@ +name: Check Schema Sync + +on: + pull_request: + paths: + - 'schema.prisma' + - 'litellm/proxy/schema.prisma' + - 'litellm-proxy-extras/litellm_proxy_extras/schema.prisma' + +permissions: + contents: read + +jobs: + check-sync: + name: Verify schema.prisma copies match root + runs-on: ubuntu-latest + timeout-minutes: 5 + steps: + - name: Checkout PR + uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0 + with: + persist-credentials: false + + - name: Reject symlinked schema files + run: | + for f in schema.prisma litellm/proxy/schema.prisma litellm-proxy-extras/litellm_proxy_extras/schema.prisma; do + if [ -L "$f" ]; then + echo "::error file=$f::$f is a symlink, which is not allowed" + exit 1 + fi + done + + - name: Check all schemas match root + run: | + EXIT=0 + + diff schema.prisma litellm/proxy/schema.prisma || { + echo "::error file=litellm/proxy/schema.prisma::litellm/proxy/schema.prisma differs from root schema.prisma" + EXIT=1 + } + + diff schema.prisma litellm-proxy-extras/litellm_proxy_extras/schema.prisma || { + echo "::error file=litellm-proxy-extras/litellm_proxy_extras/schema.prisma::litellm-proxy-extras/litellm_proxy_extras/schema.prisma differs from root schema.prisma" + EXIT=1 + } + + if [ "$EXIT" -ne 0 ]; then + echo "" + echo "Schema files are out of sync." + echo "The root schema.prisma is the source of truth." + echo "" + echo "To fix, run from the repo root:" + echo " cp schema.prisma litellm/proxy/schema.prisma" + echo " cp schema.prisma litellm-proxy-extras/litellm_proxy_extras/schema.prisma" + exit 1 + fi + + echo "All schema copies are in sync with root." diff --git a/.github/workflows/check_duplicate_issues.yml b/.github/workflows/check_duplicate_issues.yml index 6d11ce573e..289d78880a 100644 --- a/.github/workflows/check_duplicate_issues.yml +++ b/.github/workflows/check_duplicate_issues.yml @@ -12,7 +12,7 @@ jobs: contents: read steps: - name: Check for potential duplicates - uses: wow-actions/potential-duplicates@v1 + uses: wow-actions/potential-duplicates@4d4ea0352e0383859279938e255179dd1dbb67b5 # v1.1.0 with: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} label: potential-duplicate @@ -30,13 +30,14 @@ jobs: - name: Checkout close script if: github.event.action == 'opened' - uses: actions/checkout@v4 + uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0 with: sparse-checkout: .github/scripts + persist-credentials: false - name: Set up Python if: github.event.action == 'opened' - uses: actions/setup-python@v5 + uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0 with: python-version: "3.11" diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index 0b7cce2e4b..e86fca17c7 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -6,8 +6,8 @@ on: pull_request: branches: [main] schedule: - # Run weekly on Sundays at 04:00 UTC - - cron: "0 4 * * 0" + # Run daily at 04:00 UTC + - cron: "0 4 * * *" concurrency: group: ${{ github.workflow }}-${{ github.ref }} @@ -15,6 +15,7 @@ concurrency: jobs: analyze: + if: github.event_name != 'schedule' || github.repository == 'BerriAI/litellm' name: Analyze (${{ matrix.language }}) runs-on: ubuntu-latest timeout-minutes: 30 @@ -37,16 +38,18 @@ jobs: steps: - name: Checkout repository - uses: actions/checkout@v4 + uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0 + with: + persist-credentials: false - name: Initialize CodeQL - uses: github/codeql-action/init@v3 + uses: github/codeql-action/init@ebcb5b36ded6beda4ceefea6a8bc4cc885255bb3 # v3 with: languages: ${{ matrix.language }} build-mode: ${{ matrix.build-mode }} config-file: ./.github/codeql/codeql-config.yml - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@v3 + uses: github/codeql-action/analyze@ebcb5b36ded6beda4ceefea6a8bc4cc885255bb3 # v3 with: category: "/language:${{ matrix.language }}" diff --git a/.github/workflows/codspeed.yml b/.github/workflows/codspeed.yml index 385b95fdaf..52d64addea 100644 --- a/.github/workflows/codspeed.yml +++ b/.github/workflows/codspeed.yml @@ -25,10 +25,12 @@ jobs: timeout-minutes: 15 steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0 + with: + persist-credentials: false - name: Set up Python - uses: actions/setup-python@v5 + uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0 with: python-version: "3.12" @@ -38,7 +40,7 @@ jobs: pip install pytest pytest-codspeed==4.3.0 - name: Run benchmarks - uses: CodSpeedHQ/action@v4 + uses: CodSpeedHQ/action@1c8ae4843586d3ba879736b7f6b7b0c990757fab # v4.12.1 with: mode: simulation run: pytest tests/benchmarks/ --codspeed diff --git a/.github/workflows/create-release.yml b/.github/workflows/create-release.yml new file mode 100644 index 0000000000..2ae01823a9 --- /dev/null +++ b/.github/workflows/create-release.yml @@ -0,0 +1,93 @@ +name: Create Release + +on: + workflow_dispatch: + inputs: + tag: + description: "Release tag (e.g. v1.83.0-stable)" + required: true + type: string + commit_hash: + description: "Full 40-char commit SHA to target" + required: true + type: string + +permissions: {} + +jobs: + release: + name: Create Release + runs-on: ubuntu-latest + permissions: + contents: write + steps: + - name: Validate inputs + env: + TAG: ${{ inputs.tag }} + COMMIT_HASH: ${{ inputs.commit_hash }} + run: | + if ! echo "${COMMIT_HASH}" | grep -qE '^[0-9a-f]{40}$'; then + echo "::error::commit_hash must be a full 40-character commit SHA" + exit 1 + fi + if ! echo "${TAG}" | grep -qE '^v[0-9]+\.[0-9]+\.[0-9]+'; then + echo "::error::tag must start with vX.Y.Z" + exit 1 + fi + + - name: Create release + env: + TAG: ${{ inputs.tag }} + COMMIT_HASH: ${{ inputs.commit_hash }} + uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7.0.1 + with: + script: | + const tag = process.env.TAG; + const commitHash = process.env.COMMIT_HASH; + + const cosignSection = [ + `## Verify Docker Image Signature`, + ``, + `All LiteLLM Docker images are signed with [cosign](https://docs.sigstore.dev/cosign/overview/). To verify the integrity of an image before deploying:`, + ``, + '```bash', + `cosign verify \\`, + ` --key https://raw.githubusercontent.com/BerriAI/litellm/${tag}/cosign.pub \\`, + ` ghcr.io/berriai/litellm:${tag}`, + '```', + ``, + `Expected output:`, + ``, + '```', + `The following checks were performed on each of these signatures:`, + ` - The cosign claims were validated`, + ` - The signatures were verified against the specified public key`, + '```', + ``, + `---`, + ``, + ].join('\n'); + + try { + const response = await github.rest.repos.createRelease({ + draft: true, + generate_release_notes: true, + target_commitish: commitHash, + name: tag, + owner: context.repo.owner, + prerelease: false, + repo: context.repo.repo, + tag_name: tag, + }); + + const updatedBody = cosignSection + (response.data.body ?? ''); + await github.rest.repos.updateRelease({ + owner: context.repo.owner, + repo: context.repo.repo, + release_id: response.data.id, + body: updatedBody, + draft: false, + }); + } catch (error) { + core.setFailed(error.message); + } diff --git a/.github/workflows/create_daily_staging_branch.yml b/.github/workflows/create_daily_staging_branch.yml index 08aebd7d04..424d8de0a4 100644 --- a/.github/workflows/create_daily_staging_branch.yml +++ b/.github/workflows/create_daily_staging_branch.yml @@ -2,18 +2,22 @@ name: Create Daily Staging Branch on: schedule: - - cron: '0 0,12 * * *' # Runs every 12 hours at midnight and noon UTC - workflow_dispatch: # Allow manual trigger + - cron: "0 0,12 * * *" # Runs every 12 hours at midnight and noon UTC + workflow_dispatch: # Allow manual trigger jobs: create-staging-branch: + if: github.repository == 'BerriAI/litellm' runs-on: ubuntu-latest + permissions: + contents: write steps: - name: Checkout repository - uses: actions/checkout@v3 + uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0 with: fetch-depth: 0 + persist-credentials: false - name: Create daily staging branch env: @@ -43,13 +47,17 @@ jobs: fi create-internal-dev-branch: + if: github.repository == 'BerriAI/litellm' runs-on: ubuntu-latest + permissions: + contents: write steps: - name: Checkout repository - uses: actions/checkout@v3 + uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0 with: fetch-depth: 0 + persist-credentials: false - name: Create internal dev branch env: diff --git a/.github/workflows/ghcr_deploy.yml b/.github/workflows/ghcr_deploy.yml deleted file mode 100644 index 344b0ec48e..0000000000 --- a/.github/workflows/ghcr_deploy.yml +++ /dev/null @@ -1,444 +0,0 @@ -# this workflow is triggered by an API call when there is a new PyPI release of LiteLLM -name: Build, Publish LiteLLM Docker Image. New Release -on: - workflow_dispatch: - inputs: - tag: - description: "The tag version you want to build" - required: true - release_type: - description: "The release type you want to build. Can be 'latest', 'stable', 'dev', 'rc'" - type: string - default: "latest" - commit_hash: - description: "Commit hash" - required: true - -# Defines two custom environment variables for the workflow. Used for the Container registry domain, and a name for the Docker image that this workflow builds. -env: - REGISTRY: ghcr.io - IMAGE_NAME: ${{ github.repository }} - CHART_NAME: litellm-helm - -# There is a single job in this workflow. It's configured to run on the latest available version of Ubuntu. -jobs: - # print commit hash, tag, and release type - print: - runs-on: ubuntu-latest - steps: - - run: | - echo "Commit hash: ${{ github.event.inputs.commit_hash }}" - echo "Tag: ${{ github.event.inputs.tag }}" - echo "Release type: ${{ github.event.inputs.release_type }}" - docker-hub-deploy: - if: github.repository == 'BerriAI/litellm' - runs-on: ubuntu-latest - steps: - - - name: Checkout - uses: actions/checkout@v4 - with: - ref: ${{ github.event.inputs.commit_hash }} - - - name: Set up QEMU - uses: docker/setup-qemu-action@v3 - - - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v3 - - - name: Login to Docker Hub - uses: docker/login-action@v3 - with: - username: ${{ secrets.DOCKERHUB_USERNAME }} - password: ${{ secrets.DOCKERHUB_TOKEN }} - - - name: Build and push - uses: docker/build-push-action@v5 - with: - context: . - push: true - tags: litellm/litellm:${{ github.event.inputs.tag || 'latest' }} - - - name: Build and push litellm-database image - uses: docker/build-push-action@v5 - with: - context: . - push: true - file: ./docker/Dockerfile.database - tags: litellm/litellm-database:${{ github.event.inputs.tag || 'latest' }} - - - name: Build and push litellm-spend-logs image - uses: docker/build-push-action@v5 - with: - context: . - push: true - file: ./litellm-js/spend-logs/Dockerfile - tags: litellm/litellm-spend_logs:${{ github.event.inputs.tag || 'latest' }} - - - name: Build and push litellm-non_root image - uses: docker/build-push-action@v5 - with: - context: . - push: true - file: ./docker/Dockerfile.non_root - tags: litellm/litellm-non_root:${{ github.event.inputs.tag || 'latest' }} - build-and-push-image: - runs-on: ubuntu-latest - # Sets the permissions granted to the `GITHUB_TOKEN` for the actions in this job. - permissions: - contents: read - packages: write - steps: - - name: Checkout repository - uses: actions/checkout@v4 - with: - ref: ${{ github.event.inputs.commit_hash }} - # Uses the `docker/login-action` action to log in to the Container registry registry using the account and password that will publish the packages. Once published, the packages are scoped to the account defined here. - - name: Log in to the Container registry - uses: docker/login-action@65b78e6e13532edd9afa3aa52ac7964289d1a9c1 - with: - registry: ${{ env.REGISTRY }} - username: ${{ github.actor }} - password: ${{ secrets.GITHUB_TOKEN }} - # This step uses [docker/metadata-action](https://github.com/docker/metadata-action#about) to extract tags and labels that will be applied to the specified image. The `id` "meta" allows the output of this step to be referenced in a subsequent step. The `images` value provides the base name for the tags and labels. - - name: Extract metadata (tags, labels) for Docker - id: meta - uses: docker/metadata-action@9ec57ed1fcdbf14dcef7dfbe97b2010124a938b7 - with: - images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} - # Configure multi platform Docker builds - - name: Set up QEMU - uses: docker/setup-qemu-action@e0e4588fad221d38ee467c0bffd91115366dc0c5 - - name: Set up Docker Buildx - uses: docker/setup-buildx-action@edfb0fe6204400c56fbfd3feba3fe9ad1adfa345 - # This step uses the `docker/build-push-action` action to build the image, based on your repository's `Dockerfile`. If the build succeeds, it pushes the image to GitHub Packages. - # It uses the `context` parameter to define the build's context as the set of files located in the specified path. For more information, see "[Usage](https://github.com/docker/build-push-action#usage)" in the README of the `docker/build-push-action` repository. - # It uses the `tags` and `labels` parameters to tag and label the image with the output from the "meta" step. - - name: Build and push Docker image - uses: docker/build-push-action@4976231911ebf5f32aad765192d35f942aa48cb8 - with: - context: . - push: true - tags: | - ${{ steps.meta.outputs.tags }}-${{ github.event.inputs.tag || 'latest' }}, - ${{ steps.meta.outputs.tags }}-${{ github.event.inputs.release_type }} - ${{ (github.event.inputs.release_type == 'stable' || github.event.inputs.release_type == 'rc') && format('{0}/berriai/litellm:main-{1}', env.REGISTRY, github.event.inputs.tag) || '' }}, - ${{ github.event.inputs.release_type == 'stable' && format('{0}/berriai/litellm:main-stable', env.REGISTRY) || '' }}, - ${{ (github.event.inputs.release_type == 'stable' || github.event.inputs.release_type == 'rc') && format('{0}/berriai/litellm:{1}', env.REGISTRY, github.event.inputs.tag) || '' }}, - labels: ${{ steps.meta.outputs.labels }} - platforms: local,linux/amd64,linux/arm64,linux/arm64/v8 - - build-and-push-image-ee: - runs-on: ubuntu-latest - permissions: - contents: read - packages: write - steps: - - name: Checkout repository - uses: actions/checkout@v4 - with: - ref: ${{ github.event.inputs.commit_hash }} - - - name: Log in to the Container registry - uses: docker/login-action@65b78e6e13532edd9afa3aa52ac7964289d1a9c1 - with: - registry: ${{ env.REGISTRY }} - username: ${{ github.actor }} - password: ${{ secrets.GITHUB_TOKEN }} - - - name: Extract metadata (tags, labels) for EE Dockerfile - id: meta-ee - uses: docker/metadata-action@9ec57ed1fcdbf14dcef7dfbe97b2010124a938b7 - with: - images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}-ee - # Configure multi platform Docker builds - - name: Set up QEMU - uses: docker/setup-qemu-action@e0e4588fad221d38ee467c0bffd91115366dc0c5 - - name: Set up Docker Buildx - uses: docker/setup-buildx-action@edfb0fe6204400c56fbfd3feba3fe9ad1adfa345 - - - name: Build and push EE Docker image - uses: docker/build-push-action@f2a1d5e99d037542a71f64918e516c093c6f3fc4 - with: - context: . - file: Dockerfile - push: true - tags: | - ${{ steps.meta-ee.outputs.tags }}-${{ github.event.inputs.tag || 'latest' }}, - ${{ steps.meta-ee.outputs.tags }}-${{ github.event.inputs.release_type }} - ${{ (github.event.inputs.release_type == 'stable' || github.event.inputs.release_type == 'rc') && format('{0}/berriai/litellm-ee:main-{1}', env.REGISTRY, github.event.inputs.tag) || '' }}, - ${{ github.event.inputs.release_type == 'stable' && format('{0}/berriai/litellm-ee:main-stable', env.REGISTRY) || '' }} - labels: ${{ steps.meta-ee.outputs.labels }} - platforms: local,linux/amd64,linux/arm64,linux/arm64/v8 - - build-and-push-image-database: - runs-on: ubuntu-latest - permissions: - contents: read - packages: write - steps: - - name: Checkout repository - uses: actions/checkout@v4 - with: - ref: ${{ github.event.inputs.commit_hash }} - - - name: Log in to the Container registry - uses: docker/login-action@65b78e6e13532edd9afa3aa52ac7964289d1a9c1 - with: - registry: ${{ env.REGISTRY }} - username: ${{ github.actor }} - password: ${{ secrets.GITHUB_TOKEN }} - - - name: Extract metadata (tags, labels) for database Dockerfile - id: meta-database - uses: docker/metadata-action@9ec57ed1fcdbf14dcef7dfbe97b2010124a938b7 - with: - images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}-database - # Configure multi platform Docker builds - - name: Set up QEMU - uses: docker/setup-qemu-action@e0e4588fad221d38ee467c0bffd91115366dc0c5 - - name: Set up Docker Buildx - uses: docker/setup-buildx-action@edfb0fe6204400c56fbfd3feba3fe9ad1adfa345 - - - name: Build and push Database Docker image - uses: docker/build-push-action@f2a1d5e99d037542a71f64918e516c093c6f3fc4 - with: - context: . - file: ./docker/Dockerfile.database - push: true - tags: | - ${{ steps.meta-database.outputs.tags }}-${{ github.event.inputs.tag || 'latest' }}, - ${{ steps.meta-database.outputs.tags }}-${{ github.event.inputs.release_type }} - ${{ (github.event.inputs.release_type == 'stable' || github.event.inputs.release_type == 'rc') && format('{0}/berriai/litellm-database:main-{1}', env.REGISTRY, github.event.inputs.tag) || '' }}, - ${{ github.event.inputs.release_type == 'stable' && format('{0}/berriai/litellm-database:main-stable', env.REGISTRY) || '' }} - labels: ${{ steps.meta-database.outputs.labels }} - platforms: local,linux/amd64,linux/arm64,linux/arm64/v8 - - build-and-push-image-non_root: - runs-on: ubuntu-latest - permissions: - contents: read - packages: write - steps: - - name: Checkout repository - uses: actions/checkout@v4 - with: - ref: ${{ github.event.inputs.commit_hash }} - - - name: Log in to the Container registry - uses: docker/login-action@65b78e6e13532edd9afa3aa52ac7964289d1a9c1 - with: - registry: ${{ env.REGISTRY }} - username: ${{ github.actor }} - password: ${{ secrets.GITHUB_TOKEN }} - - - name: Extract metadata (tags, labels) for non_root Dockerfile - id: meta-non_root - uses: docker/metadata-action@9ec57ed1fcdbf14dcef7dfbe97b2010124a938b7 - with: - images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}-non_root - # Configure multi platform Docker builds - - name: Set up QEMU - uses: docker/setup-qemu-action@e0e4588fad221d38ee467c0bffd91115366dc0c5 - - name: Set up Docker Buildx - uses: docker/setup-buildx-action@edfb0fe6204400c56fbfd3feba3fe9ad1adfa345 - - - name: Build and push non_root Docker image - uses: docker/build-push-action@f2a1d5e99d037542a71f64918e516c093c6f3fc4 - with: - context: . - file: ./docker/Dockerfile.non_root - push: true - tags: | - ${{ steps.meta-non_root.outputs.tags }}-${{ github.event.inputs.tag || 'latest' }}, - ${{ steps.meta-non_root.outputs.tags }}-${{ github.event.inputs.release_type }} - ${{ (github.event.inputs.release_type == 'stable' || github.event.inputs.release_type == 'rc') && format('{0}/berriai/litellm-non_root:main-{1}', env.REGISTRY, github.event.inputs.tag) || '' }}, - ${{ github.event.inputs.release_type == 'stable' && format('{0}/berriai/litellm-non_root:main-stable', env.REGISTRY) || '' }} - labels: ${{ steps.meta-non_root.outputs.labels }} - platforms: local,linux/amd64,linux/arm64,linux/arm64/v8 - - build-and-push-image-spend-logs: - runs-on: ubuntu-latest - permissions: - contents: read - packages: write - steps: - - name: Checkout repository - uses: actions/checkout@v4 - with: - ref: ${{ github.event.inputs.commit_hash }} - - - name: Log in to the Container registry - uses: docker/login-action@65b78e6e13532edd9afa3aa52ac7964289d1a9c1 - with: - registry: ${{ env.REGISTRY }} - username: ${{ github.actor }} - password: ${{ secrets.GITHUB_TOKEN }} - - - name: Extract metadata (tags, labels) for spend-logs Dockerfile - id: meta-spend-logs - uses: docker/metadata-action@9ec57ed1fcdbf14dcef7dfbe97b2010124a938b7 - with: - images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}-spend_logs - # Configure multi platform Docker builds - - name: Set up QEMU - uses: docker/setup-qemu-action@e0e4588fad221d38ee467c0bffd91115366dc0c5 - - name: Set up Docker Buildx - uses: docker/setup-buildx-action@edfb0fe6204400c56fbfd3feba3fe9ad1adfa345 - - - name: Build and push Database Docker image - uses: docker/build-push-action@f2a1d5e99d037542a71f64918e516c093c6f3fc4 - with: - context: . - file: ./litellm-js/spend-logs/Dockerfile - push: true - tags: | - ${{ steps.meta-spend-logs.outputs.tags }}-${{ github.event.inputs.tag || 'latest' }}, - ${{ steps.meta-spend-logs.outputs.tags }}-${{ github.event.inputs.release_type }} - ${{ (github.event.inputs.release_type == 'stable' || github.event.inputs.release_type == 'rc') && format('{0}/berriai/litellm-spend_logs:main-{1}', env.REGISTRY, github.event.inputs.tag) || '' }}, - ${{ github.event.inputs.release_type == 'stable' && format('{0}/berriai/litellm-spend_logs:main-stable', env.REGISTRY) || '' }} - platforms: local,linux/amd64,linux/arm64,linux/arm64/v8 - - run-observatory-tests: - if: github.event.inputs.release_type == 'rc' || github.event.inputs.release_type == 'stable' - needs: [docker-hub-deploy] - uses: ./.github/workflows/run_observatory_tests.yml - with: - tag: ${{ github.event.inputs.tag }} - commit_hash: ${{ github.event.inputs.commit_hash }} - secrets: inherit - - build-and-push-helm-chart: - if: github.event.inputs.release_type != 'dev' - needs: [docker-hub-deploy, build-and-push-image, build-and-push-image-database] - runs-on: ubuntu-latest - steps: - - name: Checkout repository - uses: actions/checkout@v4 - with: - fetch-depth: 0 - - - name: Log in to the Container registry - uses: docker/login-action@65b78e6e13532edd9afa3aa52ac7964289d1a9c1 - with: - registry: ${{ env.REGISTRY }} - username: ${{ github.actor }} - password: ${{ secrets.GITHUB_TOKEN }} - - - name: lowercase github.repository_owner - run: | - echo "REPO_OWNER=`echo ${{github.repository_owner}} | tr '[:upper:]' '[:lower:]'`" >>${GITHUB_ENV} - - # Sync Helm chart version with LiteLLM release version (1-1 versioning) - # This allows users to easily map Helm chart versions to LiteLLM versions - # See: https://codefresh.io/docs/docs/ci-cd-guides/helm-best-practices/ - - name: Calculate chart and app versions - id: chart_version - shell: bash - run: | - INPUT_TAG="${{ github.event.inputs.tag }}" - RELEASE_TYPE="${{ github.event.inputs.release_type }}" - - # Chart version = LiteLLM version without 'v' prefix (Helm semver convention) - # v1.81.0 -> 1.81.0, v1.81.0.rc.1 -> 1.81.0.rc.1 - CHART_VERSION="${INPUT_TAG#v}" - - # Add suffix for 'latest' releases (rc already has suffix in tag) - if [ "$RELEASE_TYPE" = "latest" ]; then - CHART_VERSION="${CHART_VERSION}-latest" - fi - - # App version = Docker tag (keeps 'v' prefix to match Docker image tags) - APP_VERSION="${INPUT_TAG}" - - echo "version=${CHART_VERSION}" | tee -a $GITHUB_OUTPUT - echo "app_version=${APP_VERSION}" | tee -a $GITHUB_OUTPUT - - - uses: ./.github/actions/helm-oci-chart-releaser - with: - name: ${{ env.CHART_NAME }} - repository: ${{ env.REPO_OWNER }} - tag: ${{ steps.chart_version.outputs.version }} - app_version: ${{ steps.chart_version.outputs.app_version }} - path: deploy/charts/${{ env.CHART_NAME }} - registry: ${{ env.REGISTRY }} - registry_username: ${{ github.actor }} - registry_password: ${{ secrets.GITHUB_TOKEN }} - update_dependencies: true - - release: - name: "New LiteLLM Release" - needs: [docker-hub-deploy, build-and-push-image, build-and-push-image-database] - permissions: - contents: write - runs-on: "ubuntu-latest" - - steps: - - name: Display version - run: echo "Current version is ${{ github.event.inputs.tag }}" - - name: "Set Release Tag" - run: echo "RELEASE_TAG=${{ github.event.inputs.tag }}" >> $GITHUB_ENV - - name: Display release tag - run: echo "RELEASE_TAG is $RELEASE_TAG" - - name: "Create release" - uses: "actions/github-script@v6" - with: - github-token: "${{ secrets.GITHUB_TOKEN }}" - script: | - const commitHash = "${{ github.event.inputs.commit_hash}}"; - console.log("Commit Hash:", commitHash); // Add this line for debugging - try { - const response = await github.rest.repos.createRelease({ - draft: false, - generate_release_notes: true, - target_commitish: commitHash, - name: process.env.RELEASE_TAG, - owner: context.repo.owner, - prerelease: false, - repo: context.repo.repo, - tag_name: process.env.RELEASE_TAG, - }); - - core.exportVariable('RELEASE_ID', response.data.id); - core.exportVariable('RELEASE_UPLOAD_URL', response.data.upload_url); - } catch (error) { - core.setFailed(error.message); - } - - name: Fetch Release Notes - id: release-notes - uses: actions/github-script@v6 - with: - github-token: "${{ secrets.GITHUB_TOKEN }}" - script: | - try { - const response = await github.rest.repos.getRelease({ - owner: context.repo.owner, - repo: context.repo.repo, - release_id: process.env.RELEASE_ID, - }); - const formattedBody = JSON.stringify(response.data.body).slice(1, -1); - return formattedBody; - } catch (error) { - core.setFailed(error.message); - } - env: - RELEASE_ID: ${{ env.RELEASE_ID }} - - name: Github Releases To Discord - env: - WEBHOOK_URL: ${{ secrets.WEBHOOK_URL }} - REALEASE_TAG: ${{ env.RELEASE_TAG }} - RELEASE_NOTES: ${{ steps.release-notes.outputs.result }} - run: | - curl -H "Content-Type: application/json" -X POST -d '{ - "content": "New LiteLLM release '"${RELEASE_TAG}"'", - "username": "Release Changelog", - "avatar_url": "https://cdn.discordapp.com/avatars/487431320314576937/bd64361e4ba6313d561d54e78c9e7171.png", - "embeds": [ - { - "title": "Changelog for LiteLLM '"${RELEASE_TAG}"'", - "description": "'"${RELEASE_NOTES}"'", - "color": 2105893 - } - ] - }' $WEBHOOK_URL - diff --git a/.github/workflows/ghcr_helm_deploy.yml b/.github/workflows/ghcr_helm_deploy.yml deleted file mode 100644 index 21b2eaafe1..0000000000 --- a/.github/workflows/ghcr_helm_deploy.yml +++ /dev/null @@ -1,67 +0,0 @@ -# Standalone workflow to publish LiteLLM Helm Chart -# Note: The main ghcr_deploy.yml workflow also publishes the Helm chart as part of a full release -name: Build, Publish LiteLLM Helm Chart. New Release -on: - workflow_dispatch: - inputs: - tag: - description: "LiteLLM version tag (e.g., v1.81.0)" - required: true - -# Defines two custom environment variables for the workflow. Used for the Container registry domain, and a name for the Docker image that this workflow builds. -env: - REGISTRY: ghcr.io - IMAGE_NAME: ${{ github.repository }} - REPO_OWNER: ${{github.repository_owner}} - -# There is a single job in this workflow. It's configured to run on the latest available version of Ubuntu. -jobs: - build-and-push-helm-chart: - runs-on: ubuntu-latest - steps: - - name: Checkout repository - uses: actions/checkout@v4 - - - name: Log in to the Container registry - uses: docker/login-action@65b78e6e13532edd9afa3aa52ac7964289d1a9c1 - with: - registry: ${{ env.REGISTRY }} - username: ${{ github.actor }} - password: ${{ secrets.GITHUB_TOKEN }} - - - name: lowercase github.repository_owner - run: | - echo "REPO_OWNER=`echo ${{github.repository_owner}} | tr '[:upper:]' '[:lower:]'`" >>${GITHUB_ENV} - - # Sync Helm chart version with LiteLLM release version (1-1 versioning) - - name: Calculate chart and app versions - id: chart_version - shell: bash - run: | - INPUT_TAG="${{ github.event.inputs.tag }}" - - # Chart version = LiteLLM version without 'v' prefix - # v1.81.0 -> 1.81.0 - CHART_VERSION="${INPUT_TAG#v}" - - # App version = Docker tag (keeps 'v' prefix) - APP_VERSION="${INPUT_TAG}" - - echo "version=${CHART_VERSION}" | tee -a $GITHUB_OUTPUT - echo "app_version=${APP_VERSION}" | tee -a $GITHUB_OUTPUT - - - name: Lint helm chart - run: helm lint deploy/charts/litellm-helm - - - uses: ./.github/actions/helm-oci-chart-releaser - with: - name: litellm-helm - repository: ${{ env.REPO_OWNER }} - tag: ${{ steps.chart_version.outputs.version }} - app_version: ${{ steps.chart_version.outputs.app_version }} - path: deploy/charts/litellm-helm - registry: ${{ env.REGISTRY }} - registry_username: ${{ github.actor }} - registry_password: ${{ secrets.GITHUB_TOKEN }} - update_dependencies: true - diff --git a/.github/workflows/helm_unit_test.yml b/.github/workflows/helm_unit_test.yml index c4b83af70a..06836b1d1c 100644 --- a/.github/workflows/helm_unit_test.yml +++ b/.github/workflows/helm_unit_test.yml @@ -6,22 +6,36 @@ on: branches: - main +permissions: + contents: read + jobs: unit-test: runs-on: ubuntu-latest steps: - name: Checkout - uses: actions/checkout@v2 + uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0 + with: + persist-credentials: false - name: Set up Helm 3.11.1 - uses: azure/setup-helm@v1 + uses: azure/setup-helm@1a275c3b69536ee54be43f2070a358922e12c8d4 # v4.3.1 with: - version: '3.11.1' + version: "3.11.1" - name: Install Helm Unit Test Plugin run: | helm plugin install https://github.com/helm-unittest/helm-unittest --version v0.4.4 + - name: Verify Helm Unit Test Plugin integrity + run: | + EXPECTED_SHA="e251ba198448629678ff2168e1a469249d998155" + PLUGIN_DIR="$(helm env HELM_PLUGINS)/helm-unittest" + ACTUAL_SHA="$(git -C "$PLUGIN_DIR" rev-parse HEAD)" + if [ "$ACTUAL_SHA" != "$EXPECTED_SHA" ]; then + echo "::error::Helm unittest plugin checksum mismatch! Expected $EXPECTED_SHA but got $ACTUAL_SHA" + exit 1 + fi + echo "Helm unittest plugin integrity verified: $ACTUAL_SHA" - name: Run unit tests - run: - helm unittest -f 'tests/*.yaml' deploy/charts/litellm-helm \ No newline at end of file + run: helm unittest -f 'tests/*.yaml' deploy/charts/litellm-helm diff --git a/.github/workflows/interpret_load_test.py b/.github/workflows/interpret_load_test.py deleted file mode 100644 index 348ff300ff..0000000000 --- a/.github/workflows/interpret_load_test.py +++ /dev/null @@ -1,139 +0,0 @@ -import csv -import os -from github import Github - - -def interpret_results(csv_file): - with open(csv_file, newline="") as csvfile: - csvreader = csv.DictReader(csvfile) - rows = list(csvreader) - """ - in this csv reader - - Create 1 new column "Status" - - if a row has a median response time < 300 and an average response time < 300, Status = "Passed ✅" - - if a row has a median response time >= 300 or an average response time >= 300, Status = "Failed ❌" - - Order the table in this order Name, Status, Median Response Time, Average Response Time, Requests/s,Failures/s, Min Response Time, Max Response Time, all other columns - """ - - # Add a new column "Status" - for row in rows: - median_response_time = float( - row["Median Response Time"].strip().rstrip("ms") - ) - average_response_time = float( - row["Average Response Time"].strip().rstrip("s") - ) - - request_count = int(row["Request Count"]) - failure_count = int(row["Failure Count"]) - - failure_percent = round((failure_count / request_count) * 100, 2) - - # Determine status based on conditions - if ( - median_response_time < 300 - and average_response_time < 300 - and failure_percent < 5 - ): - row["Status"] = "Passed ✅" - else: - row["Status"] = "Failed ❌" - - # Construct Markdown table header - markdown_table = "| Name | Status | Median Response Time (ms) | Average Response Time (ms) | Requests/s | Failures/s | Request Count | Failure Count | Min Response Time (ms) | Max Response Time (ms) |" - markdown_table += ( - "\n| --- | --- | --- | --- | --- | --- | --- | --- | --- | --- |" - ) - - # Construct Markdown table rows - for row in rows: - markdown_table += f"\n| {row['Name']} | {row['Status']} | {row['Median Response Time']} | {row['Average Response Time']} | {row['Requests/s']} | {row['Failures/s']} | {row['Request Count']} | {row['Failure Count']} | {row['Min Response Time']} | {row['Max Response Time']} |" - print("markdown table: ", markdown_table) - return markdown_table - - -def _get_docker_run_command_stable_release(release_version): - return f""" -\n\n -## Docker Run LiteLLM Proxy - -``` -docker run \\ --e STORE_MODEL_IN_DB=True \\ --p 4000:4000 \\ -ghcr.io/berriai/litellm:litellm_stable_release_branch-{release_version} -``` - """ - - -def _get_docker_run_command(release_version): - return f""" -\n\n -## Docker Run LiteLLM Proxy - -``` -docker run \\ --e STORE_MODEL_IN_DB=True \\ --p 4000:4000 \\ -ghcr.io/berriai/litellm:main-{release_version} -``` - """ - - -def get_docker_run_command(release_version): - if "stable" in release_version: - return _get_docker_run_command_stable_release(release_version) - else: - return _get_docker_run_command(release_version) - - -if __name__ == "__main__": - return - csv_file = "load_test_stats.csv" # Change this to the path of your CSV file - markdown_table = interpret_results(csv_file) - - # Update release body with interpreted results - github_token = os.getenv("GITHUB_TOKEN") - g = Github(github_token) - repo = g.get_repo( - "BerriAI/litellm" - ) # Replace with your repository's username and name - latest_release = repo.get_latest_release() - print("got latest release: ", latest_release) - print(latest_release.title) - print(latest_release.tag_name) - - release_version = latest_release.title - - print("latest release body: ", latest_release.body) - print("markdown table: ", markdown_table) - - # check if "Load Test LiteLLM Proxy Results" exists - existing_release_body = latest_release.body - if "Load Test LiteLLM Proxy Results" in latest_release.body: - # find the "Load Test LiteLLM Proxy Results" section and delete it - start_index = latest_release.body.find("Load Test LiteLLM Proxy Results") - existing_release_body = latest_release.body[:start_index] - - docker_run_command = get_docker_run_command(release_version) - print("docker run command: ", docker_run_command) - - new_release_body = ( - existing_release_body - + docker_run_command - + "\n\n" - + "### Don't want to maintain your internal proxy? get in touch 🎉" - + "\nHosted Proxy Alpha: https://calendly.com/d/cx9p-5yf-2nm/litellm-introductions" - + "\n\n" - + "## Load Test LiteLLM Proxy Results" - + "\n\n" - + markdown_table - ) - print("new release body: ", new_release_body) - try: - latest_release.update_release( - name=latest_release.tag_name, - message=new_release_body, - ) - except Exception as e: - print(e) diff --git a/.github/workflows/issue-keyword-labeler.yml b/.github/workflows/issue-keyword-labeler.yml index 936f90f747..7e2693209b 100644 --- a/.github/workflows/issue-keyword-labeler.yml +++ b/.github/workflows/issue-keyword-labeler.yml @@ -2,8 +2,8 @@ name: Issue Keyword Labeler on: issues: - types: - - opened + types: + - opened jobs: scan-and-label: @@ -13,7 +13,9 @@ jobs: contents: read steps: - name: Checkout code - uses: actions/checkout@v4 + uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0 + with: + persist-credentials: false - name: Scan for provider keywords id: scan @@ -24,7 +26,7 @@ jobs: - name: Ensure label exists if: steps.scan.outputs.found == 'true' - uses: actions/github-script@v7 + uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b # v7.1.0 with: github-token: ${{ secrets.GITHUB_TOKEN }} script: | @@ -51,7 +53,7 @@ jobs: - name: Add label to the issue if: steps.scan.outputs.found == 'true' - uses: actions/github-script@v7 + uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b # v7.1.0 with: github-token: ${{ secrets.GITHUB_TOKEN }} script: | @@ -61,4 +63,3 @@ jobs: issue_number: context.issue.number, labels: ['llm translation'] }); - diff --git a/.github/workflows/label-component.yml b/.github/workflows/label-component.yml index fd079fce6c..e0c2fa94d8 100644 --- a/.github/workflows/label-component.yml +++ b/.github/workflows/label-component.yml @@ -12,7 +12,7 @@ jobs: issues: write steps: - name: Add component labels - uses: actions/github-script@v7 + uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b # v7.1.0 with: github-token: ${{ secrets.GITHUB_TOKEN }} script: | diff --git a/.github/workflows/llm-translation-testing.yml b/.github/workflows/llm-translation-testing.yml index 7fda37a66d..922013c4b5 100644 --- a/.github/workflows/llm-translation-testing.yml +++ b/.github/workflows/llm-translation-testing.yml @@ -4,38 +4,41 @@ on: workflow_dispatch: inputs: release_candidate_tag: - description: 'Release candidate tag/version' + description: "Release candidate tag/version" required: true type: string push: tags: - - 'v*-rc*' # Triggers on release candidate tags like v1.0.0-rc1 - + - "v*-rc*" # Triggers on release candidate tags like v1.0.0-rc1 + +permissions: + contents: read + jobs: run-llm-translation-tests: runs-on: ubuntu-latest timeout-minutes: 90 - + steps: - name: Checkout code - uses: actions/checkout@v4 + uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0 with: + persist-credentials: false ref: ${{ github.event.inputs.release_candidate_tag || github.ref }} - + - name: Set up Python - uses: actions/setup-python@v5 + uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0 with: - python-version: '3.11' - + python-version: "3.11" + - name: Install Poetry - uses: snok/install-poetry@v1 - with: - version: latest - virtualenvs-create: true - virtualenvs-in-project: true - - - name: Cache Poetry dependencies - uses: actions/cache@v3 + run: | + pip install 'poetry==2.3.2' + poetry config virtualenvs.create true + poetry config virtualenvs.in-project true + + - name: Restore Poetry dependencies cache + uses: actions/cache/restore@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.0.0 with: path: | ~/.cache/pypoetry @@ -43,15 +46,15 @@ jobs: key: ${{ runner.os }}-poetry-${{ hashFiles('**/poetry.lock') }} restore-keys: | ${{ runner.os }}-poetry- - + - name: Install dependencies run: | poetry install --with dev - poetry run pip install pytest-xdist pytest-timeout - + poetry run pip install 'pytest-xdist==3.8.0' 'pytest-timeout==2.4.0' + - name: Create test results directory run: mkdir -p test-results - + - name: Run LLM Translation Tests env: OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }} @@ -61,13 +64,14 @@ jobs: AZURE_API_KEY: ${{ secrets.AZURE_API_KEY }} AZURE_API_BASE: ${{ secrets.AZURE_API_BASE }} AZURE_API_VERSION: ${{ secrets.AZURE_API_VERSION }} - # Add other API keys as needed + RC_TAG: ${{ github.event.inputs.release_candidate_tag || github.ref_name }} + COMMIT_SHA: ${{ github.sha }} run: | python .github/workflows/run_llm_translation_tests.py \ - --tag "${{ github.event.inputs.release_candidate_tag || github.ref_name }}" \ - --commit "${{ github.sha }}" \ + --tag "$RC_TAG" \ + --commit "$COMMIT_SHA" \ || true # Continue even if tests fail - + - name: Display test summary if: always() run: | @@ -79,9 +83,9 @@ jobs: else echo "Warning: Test report was not generated" fi - + - name: Upload test artifacts - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 if: always() with: name: LLM-Translation-Artifact-${{ github.event.inputs.release_candidate_tag || github.ref_name }} diff --git a/.github/workflows/load_test.yml b/.github/workflows/load_test.yml deleted file mode 100644 index cdaffa328c..0000000000 --- a/.github/workflows/load_test.yml +++ /dev/null @@ -1,59 +0,0 @@ -name: Test Locust Load Test - -on: - workflow_run: - workflows: ["Build, Publish LiteLLM Docker Image. New Release"] - types: - - completed - workflow_dispatch: - -jobs: - build: - runs-on: ubuntu-latest - steps: - - name: Checkout - uses: actions/checkout@v1 - - name: Setup Python - uses: actions/setup-python@v2 - with: - python-version: '3.x' - - - name: Install dependencies - run: | - python -m pip install --upgrade pip - pip install PyGithub - - name: re-deploy proxy - run: | - echo "Current working directory: $PWD" - ls - python ".github/workflows/redeploy_proxy.py" - env: - LOAD_TEST_REDEPLOY_URL1: ${{ secrets.LOAD_TEST_REDEPLOY_URL1 }} - LOAD_TEST_REDEPLOY_URL2: ${{ secrets.LOAD_TEST_REDEPLOY_URL2 }} - working-directory: ${{ github.workspace }} - - name: Run Load Test - id: locust_run - uses: BerriAI/locust-github-action@master - with: - LOCUSTFILE: ".github/workflows/locustfile.py" - URL: "https://post-release-load-test-proxy.onrender.com/" - USERS: "20" - RATE: "20" - RUNTIME: "300s" - - name: Process Load Test Stats - run: | - echo "Current working directory: $PWD" - ls - python ".github/workflows/interpret_load_test.py" - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - working-directory: ${{ github.workspace }} - - name: Upload CSV as Asset to Latest Release - uses: xresloader/upload-to-github-release@v1 - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - with: - file: "load_test_stats.csv;load_test.html" - update_latest_release: true - tag_name: "load-test" - overwrite: true \ No newline at end of file diff --git a/.github/workflows/locustfile.py b/.github/workflows/locustfile.py deleted file mode 100644 index 36dbeee9c4..0000000000 --- a/.github/workflows/locustfile.py +++ /dev/null @@ -1,28 +0,0 @@ -from locust import HttpUser, task, between - - -class MyUser(HttpUser): - wait_time = between(1, 5) - - @task - def chat_completion(self): - headers = { - "Content-Type": "application/json", - "Authorization": "Bearer sk-8N1tLOOyH8TIxwOLahhIVg", - # Include any additional headers you may need for authentication, etc. - } - - # Customize the payload with "model" and "messages" keys - payload = { - "model": "fake-openai-endpoint", - "messages": [ - {"role": "system", "content": "You are a chat bot."}, - {"role": "user", "content": "Hello, how are you?"}, - ], - # Add more data as necessary - } - - # Make a POST request to the "chat/completions" endpoint - response = self.client.post("chat/completions", json=payload, headers=headers) - - # Print or log the response if needed diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml deleted file mode 100644 index 23e4a06da9..0000000000 --- a/.github/workflows/main.yml +++ /dev/null @@ -1,34 +0,0 @@ -name: Publish Dev Release to PyPI - -on: - workflow_dispatch: - -jobs: - publish-dev-release: - runs-on: ubuntu-latest - - steps: - - name: Checkout code - uses: actions/checkout@v2 - - - name: Set up Python - uses: actions/setup-python@v2 - with: - python-version: 3.8 # Adjust the Python version as needed - - - name: Install dependencies - run: pip install toml twine - - - name: Read version from pyproject.toml - id: read-version - run: | - version=$(python -c 'import toml; print(toml.load("pyproject.toml")["tool"]["commitizen"]["version"])') - printf "LITELLM_VERSION=%s" "$version" >> $GITHUB_ENV - - - name: Check if version exists on PyPI - id: check-version - run: | - set -e - if twine check --repository-url https://pypi.org/simple/ "litellm==$LITELLM_VERSION" >/dev/null 2>&1; then - echo "Version $LITELLM_VERSION already exists on PyPI. Skipping publish." - diff --git a/.github/workflows/publish-migrations.yml b/.github/workflows/publish-migrations.yml deleted file mode 100644 index a5187cb2f5..0000000000 --- a/.github/workflows/publish-migrations.yml +++ /dev/null @@ -1,207 +0,0 @@ -name: Publish Prisma Migrations - -permissions: - contents: write - pull-requests: write - -on: - push: - paths: - - 'schema.prisma' # Check root schema.prisma - branches: - - main - -jobs: - publish-migrations: - if: github.repository == 'BerriAI/litellm' - runs-on: ubuntu-latest - services: - postgres: - image: postgres:14 - env: - POSTGRES_DB: temp_db - POSTGRES_USER: postgres - POSTGRES_PASSWORD: postgres - ports: - - 5432:5432 - options: >- - --health-cmd pg_isready - --health-interval 10s - --health-timeout 5s - --health-retries 5 - - # Add shadow database service - postgres_shadow: - image: postgres:14 - env: - POSTGRES_DB: shadow_db - POSTGRES_USER: postgres - POSTGRES_PASSWORD: postgres - ports: - - 5433:5432 - options: >- - --health-cmd pg_isready - --health-interval 10s - --health-timeout 5s - --health-retries 5 - - steps: - - uses: actions/checkout@v3 - - - name: Set up Python - uses: actions/setup-python@v4 - with: - python-version: '3.x' - - - name: Install Dependencies - run: | - pip install prisma - pip install python-dotenv - - - name: Generate Initial Migration if None Exists - env: - DATABASE_URL: "postgresql://postgres:postgres@localhost:5432/temp_db" - DIRECT_URL: "postgresql://postgres:postgres@localhost:5432/temp_db" - SHADOW_DATABASE_URL: "postgresql://postgres:postgres@localhost:5433/shadow_db" - run: | - mkdir -p deploy/migrations - echo 'provider = "postgresql"' > deploy/migrations/migration_lock.toml - - if [ -z "$(ls -A deploy/migrations/2* 2>/dev/null)" ]; then - echo "No existing migrations found, creating baseline..." - VERSION=$(date +%Y%m%d%H%M%S) - mkdir -p deploy/migrations/${VERSION}_initial - - echo "Generating initial migration..." - # Save raw output for debugging - prisma migrate diff \ - --from-empty \ - --to-schema-datamodel schema.prisma \ - --shadow-database-url "${SHADOW_DATABASE_URL}" \ - --script > deploy/migrations/${VERSION}_initial/raw_migration.sql - - echo "Raw migration file content:" - cat deploy/migrations/${VERSION}_initial/raw_migration.sql - - echo "Cleaning migration file..." - # Clean the file - sed '/^Installing/d' deploy/migrations/${VERSION}_initial/raw_migration.sql > deploy/migrations/${VERSION}_initial/migration.sql - - # Verify the migration file - if [ ! -s deploy/migrations/${VERSION}_initial/migration.sql ]; then - echo "ERROR: Migration file is empty after cleaning" - echo "Original content was:" - cat deploy/migrations/${VERSION}_initial/raw_migration.sql - exit 1 - fi - - echo "Final migration file content:" - cat deploy/migrations/${VERSION}_initial/migration.sql - - # Verify it starts with SQL - if ! head -n 1 deploy/migrations/${VERSION}_initial/migration.sql | grep -q "^--\|^CREATE\|^ALTER"; then - echo "ERROR: Migration file does not start with SQL command or comment" - echo "First line is:" - head -n 1 deploy/migrations/${VERSION}_initial/migration.sql - echo "Full content is:" - cat deploy/migrations/${VERSION}_initial/migration.sql - exit 1 - fi - - echo "Initial migration generated at $(date -u)" > deploy/migrations/${VERSION}_initial/README.md - fi - - - name: Compare and Generate Migration - if: success() - env: - DATABASE_URL: "postgresql://postgres:postgres@localhost:5432/temp_db" - DIRECT_URL: "postgresql://postgres:postgres@localhost:5432/temp_db" - SHADOW_DATABASE_URL: "postgresql://postgres:postgres@localhost:5433/shadow_db" - run: | - # Create temporary migration workspace - mkdir -p temp_migrations - - # Copy existing migrations (will not fail if directory is empty) - cp -r deploy/migrations/* temp_migrations/ 2>/dev/null || true - - VERSION=$(date +%Y%m%d%H%M%S) - - # Generate diff against existing migrations or empty state - prisma migrate diff \ - --from-migrations temp_migrations \ - --to-schema-datamodel schema.prisma \ - --shadow-database-url "${SHADOW_DATABASE_URL}" \ - --script > temp_migrations/migration_${VERSION}.sql - - # Check if there are actual changes - if [ -s temp_migrations/migration_${VERSION}.sql ]; then - echo "Changes detected, creating new migration" - mkdir -p deploy/migrations/${VERSION}_schema_update - mv temp_migrations/migration_${VERSION}.sql deploy/migrations/${VERSION}_schema_update/migration.sql - echo "Migration generated at $(date -u)" > deploy/migrations/${VERSION}_schema_update/README.md - else - echo "No schema changes detected" - exit 0 - fi - - - name: Verify Migration - if: success() - env: - DATABASE_URL: "postgresql://postgres:postgres@localhost:5432/temp_db" - DIRECT_URL: "postgresql://postgres:postgres@localhost:5432/temp_db" - SHADOW_DATABASE_URL: "postgresql://postgres:postgres@localhost:5433/shadow_db" - run: | - # Create test database - psql "${SHADOW_DATABASE_URL}" -c 'CREATE DATABASE migration_test;' - - # Apply all migrations in order to verify - for migration in deploy/migrations/*/migration.sql; do - echo "Applying migration: $migration" - psql "${SHADOW_DATABASE_URL}" -f $migration - done - - # Add this step before create-pull-request to debug permissions - - name: Check Token Permissions - run: | - echo "Checking token permissions..." - curl -H "Authorization: token ${{ secrets.GITHUB_TOKEN }}" \ - -H "Accept: application/vnd.github.v3+json" \ - https://api.github.com/repos/BerriAI/litellm/collaborators - - echo "\nChecking if token can create PRs..." - curl -H "Authorization: token ${{ secrets.GITHUB_TOKEN }}" \ - -H "Accept: application/vnd.github.v3+json" \ - https://api.github.com/repos/BerriAI/litellm - - # Add this debug step before git push - - name: Debug Changed Files - run: | - echo "Files staged for commit:" - git diff --name-status --staged - - echo "\nAll changed files:" - git status - - - name: Create Pull Request - if: success() - uses: peter-evans/create-pull-request@v5 - with: - token: ${{ secrets.GITHUB_TOKEN }} - commit-message: "chore: update prisma migrations" - title: "Update Prisma Migrations" - body: | - Auto-generated migration based on schema.prisma changes. - - Generated files: - - deploy/migrations/${VERSION}_schema_update/migration.sql - - deploy/migrations/${VERSION}_schema_update/README.md - branch: feat/prisma-migration-${{ env.VERSION }} - base: main - delete-branch: true - - - name: Generate and Save Migrations - run: | - # Only add migration files - git add deploy/migrations/ - git status # Debug what's being committed - git commit -m "chore: update prisma migrations" diff --git a/.github/workflows/publish_enterprise.yml b/.github/workflows/publish_enterprise.yml deleted file mode 100644 index 459a233cb7..0000000000 --- a/.github/workflows/publish_enterprise.yml +++ /dev/null @@ -1,94 +0,0 @@ -name: Publish litellm-enterprise to PyPI - -on: - workflow_dispatch: - inputs: - bump: - description: "Version bump type" - required: true - default: "patch" - type: choice - options: - - patch - - minor - - major - -jobs: - publish: - runs-on: ubuntu-latest - if: github.repository == 'BerriAI/litellm' - permissions: - contents: write - pull-requests: write - defaults: - run: - working-directory: enterprise - - steps: - - uses: actions/checkout@v4 - - - uses: actions/setup-python@v5 - with: - python-version: "3.11" - - - name: Install Poetry - run: pip install poetry - - - name: Bump version - id: bump - run: | - OLD=$(poetry version -s) - poetry version ${{ github.event.inputs.bump }} - NEW=$(poetry version -s) - echo "old=$OLD" >> $GITHUB_OUTPUT - echo "new=$NEW" >> $GITHUB_OUTPUT - - - name: Update version refs in root pyproject.toml and requirements.txt - run: | - OLD=${{ steps.bump.outputs.old }} - NEW=${{ steps.bump.outputs.new }} - sed -i "s/litellm-enterprise = {version = \"${OLD}\"/litellm-enterprise = {version = \"${NEW}\"/" ../pyproject.toml - sed -i "s/litellm-enterprise==${OLD}/litellm-enterprise==${NEW}/" ../requirements.txt - - - name: Update poetry.lock - working-directory: . - run: poetry lock - - - name: Build - run: poetry build - - - name: Commit version bump and create PR - id: create-pr - run: | - git config user.name "github-actions[bot]" - git config user.email "github-actions[bot]@users.noreply.github.com" - cd .. - BRANCH="bump/enterprise-${{ steps.bump.outputs.new }}" - git checkout -b "$BRANCH" - git add enterprise/pyproject.toml pyproject.toml requirements.txt poetry.lock - git commit -m "bump: litellm-enterprise ${{ steps.bump.outputs.old }} → ${{ steps.bump.outputs.new }}" - git push origin "$BRANCH" --force - gh pr create \ - --title "bump: litellm-enterprise ${{ steps.bump.outputs.old }} → ${{ steps.bump.outputs.new }}" \ - --body "Version bump for litellm-enterprise. Merge to update main." \ - --head "$BRANCH" \ - --base main \ - || true - PR_URL=$(gh pr list --head "$BRANCH" --json url -q '.[0].url') - echo "pr_url=$PR_URL" >> $GITHUB_OUTPUT - env: - GH_TOKEN: ${{ github.token }} - - - name: Enable auto-merge - run: | - gh pr merge "${{ steps.create-pr.outputs.pr_url }}" --auto --squash - env: - GH_TOKEN: ${{ github.token }} - - - name: Publish to PyPI - env: - TWINE_USERNAME: __token__ - TWINE_PASSWORD: ${{ secrets.PYPI_ENTERPRISE }} - run: | - pip install twine - twine upload dist/litellm_enterprise-${{ steps.bump.outputs.new }}* diff --git a/.github/workflows/publish_proxy_extras.yml b/.github/workflows/publish_proxy_extras.yml deleted file mode 100644 index fa30b15316..0000000000 --- a/.github/workflows/publish_proxy_extras.yml +++ /dev/null @@ -1,74 +0,0 @@ -name: Publish litellm-proxy-extras to PyPI - -on: - workflow_dispatch: - inputs: - bump: - description: "Version bump type" - required: true - default: "patch" - type: choice - options: - - patch - - minor - - major - -jobs: - publish: - runs-on: ubuntu-latest - if: github.repository == 'BerriAI/litellm' - permissions: - contents: write - defaults: - run: - working-directory: litellm-proxy-extras - - steps: - - uses: actions/checkout@v4 - - - uses: actions/setup-python@v5 - with: - python-version: "3.11" - - - name: Install Poetry - run: pip install poetry - - - name: Bump version - id: bump - run: | - OLD=$(poetry version -s) - poetry version ${{ github.event.inputs.bump }} - NEW=$(poetry version -s) - echo "old=$OLD" >> $GITHUB_OUTPUT - echo "new=$NEW" >> $GITHUB_OUTPUT - - - name: Update version refs in root pyproject.toml and requirements.txt - run: | - OLD=${{ steps.bump.outputs.old }} - NEW=${{ steps.bump.outputs.new }} - sed -i "s/litellm-proxy-extras = {version = \"${OLD}\"/litellm-proxy-extras = {version = \"${NEW}\"/" ../pyproject.toml - sed -i "s/litellm-proxy-extras==${OLD}/litellm-proxy-extras==${NEW}/" ../requirements.txt - - - name: Update poetry.lock - working-directory: . - run: poetry lock - - - name: Build - run: poetry build - - - name: Commit version bump - run: | - git config user.name "github-actions[bot]" - git config user.email "github-actions[bot]@users.noreply.github.com" - cd .. - git add litellm-proxy-extras/pyproject.toml pyproject.toml requirements.txt poetry.lock - git commit -m "bump: litellm-proxy-extras ${{ steps.bump.outputs.old }} → ${{ steps.bump.outputs.new }}" - git push - - - name: Publish to PyPI - env: - TWINE_USERNAME: __token__ - TWINE_PASSWORD: ${{ secrets.PYPI_PUBLISH_PASSWORD }} - run: | - pip install twine - twine upload dist/litellm_proxy_extras-${{ steps.bump.outputs.new }}* diff --git a/.github/workflows/publish_to_pypi.yml b/.github/workflows/publish_to_pypi.yml new file mode 100644 index 0000000000..8f675bb307 --- /dev/null +++ b/.github/workflows/publish_to_pypi.yml @@ -0,0 +1,136 @@ +name: Publish to PyPI + +on: + workflow_dispatch: + +jobs: + preflight-checks: + name: Preflight Checks + runs-on: ubuntu-latest + timeout-minutes: 10 + permissions: + contents: read + # No environment — read-only checks, no approval needed + outputs: + needs_publish: ${{ steps.check-litellm.outputs.needs_publish }} + version: ${{ steps.check-litellm.outputs.version }} + + steps: + - name: Checkout repo + uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0 + + - name: Set up Python + uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0 + with: + python-version: "3.12" + + - name: Check litellm version on PyPI + id: check-litellm + run: | + VERSION=$(grep -m1 '^version' pyproject.toml | sed 's/version = "\(.*\)"/\1/') + echo "version=$VERSION" >> "$GITHUB_OUTPUT" + echo "Checking if litellm $VERSION exists on PyPI..." + + HTTP_STATUS=$(curl -s -o /dev/null -w "%{http_code}" "https://pypi.org/pypi/litellm/$VERSION/json") + if [ "$HTTP_STATUS" = "200" ]; then + echo "litellm $VERSION already exists on PyPI. Skipping publish." + echo "needs_publish=false" >> "$GITHUB_OUTPUT" + else + echo "litellm $VERSION not found on PyPI. Publish needed." + echo "needs_publish=true" >> "$GITHUB_OUTPUT" + fi + + - name: Sanity check proxy-extras version + run: | + # Read pinned version from requirements.txt + REQ_VERSION=$(grep -oP 'litellm-proxy-extras==\K[0-9.]+' requirements.txt) + if [ -z "$REQ_VERSION" ]; then + echo "::error::Could not find litellm-proxy-extras version in requirements.txt" + exit 1 + fi + echo "requirements.txt pins litellm-proxy-extras==$REQ_VERSION" + + # Read pinned version from pyproject.toml dependency + PYPROJECT_VERSION=$(python3 -c " + import re + with open('pyproject.toml') as f: + content = f.read() + match = re.search(r'litellm-proxy-extras\s*=\s*\{version\s*=\s*\"([^\"]+)\"', content) + if match: + print(match.group(1).lstrip('^~>=')) + else: + import sys + print('::error::Could not find litellm-proxy-extras dependency in pyproject.toml', file=sys.stderr) + sys.exit(1) + ") + echo "pyproject.toml pins litellm-proxy-extras version: $PYPROJECT_VERSION" + + # Check that both pinned versions match + if [ "$REQ_VERSION" != "$PYPROJECT_VERSION" ]; then + echo "::error::Version mismatch: requirements.txt has $REQ_VERSION but pyproject.toml has $PYPROJECT_VERSION" + exit 1 + fi + + # Check that the pinned version exists on PyPI + echo "Checking if litellm-proxy-extras $REQ_VERSION exists on PyPI..." + HTTP_STATUS=$(curl -s -o /dev/null -w "%{http_code}" "https://pypi.org/pypi/litellm-proxy-extras/$REQ_VERSION/json") + if [ "$HTTP_STATUS" != "200" ]; then + echo "::error::litellm-proxy-extras $REQ_VERSION is not published on PyPI yet. Publish it before releasing litellm." + exit 1 + fi + echo "litellm-proxy-extras $REQ_VERSION exists on PyPI. Sanity check passed." + + publish-litellm: + name: Publish litellm to PyPI + needs: preflight-checks + if: needs.preflight-checks.outputs.needs_publish == 'true' + runs-on: ubuntu-latest + timeout-minutes: 10 + permissions: + id-token: write + contents: read + environment: pypi-publish + + steps: + - name: Checkout repo + uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0 + + - name: Set up Python + uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0 + with: + python-version: "3.12" + + - name: Copy model prices backup + run: cp model_prices_and_context_window.json litellm/model_prices_and_context_window_backup.json + + - name: Install build tools + run: python -m pip install --upgrade pip build==1.4.2 + + - name: Build package + run: | + rm -rf build dist + python -m build + + - name: Verify build artifacts + env: + EXPECTED_VERSION: ${{ needs.preflight-checks.outputs.version }} + run: | + echo "Contents of dist/:" + ls -la dist/ + # Ensure we have both sdist and wheel + ls dist/*.tar.gz + ls dist/*.whl + # Verify built version matches expected + ls dist/ | grep -q "litellm-${EXPECTED_VERSION}" || { + echo "::error::Built artifacts do not match expected version $EXPECTED_VERSION" + ls dist/ + exit 1 + } + + - name: Validate package metadata + run: | + pip install twine==6.2.0 + twine check dist/* + + - name: Publish to PyPI + uses: pypa/gh-action-pypi-publish@ed0c53931b1dc9bd32cbe73a98c7f6766f8a527e # v1.13.0 diff --git a/.github/workflows/read_pyproject_version.yml b/.github/workflows/read_pyproject_version.yml index 8f6310f935..04b4a38ce1 100644 --- a/.github/workflows/read_pyproject_version.yml +++ b/.github/workflows/read_pyproject_version.yml @@ -3,7 +3,10 @@ name: Read Version from pyproject.toml on: push: branches: - - main # Change this to the default branch of your repository + - main # Change this to the default branch of your repository + +permissions: + contents: read jobs: read-version: @@ -11,20 +14,14 @@ jobs: steps: - name: Checkout code - uses: actions/checkout@v2 - - - name: Set up Python - uses: actions/setup-python@v2 + uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0 with: - python-version: 3.8 # Adjust the Python version as needed - - - name: Install dependencies - run: pip install toml + persist-credentials: false - name: Read version from pyproject.toml id: read-version run: | - version=$(python -c 'import toml; print(toml.load("pyproject.toml")["tool"]["commitizen"]["version"])') + version=$(grep -m1 '^version' pyproject.toml | sed 's/version = "\(.*\)"/\1/') printf "LITELLM_VERSION=%s" "$version" >> $GITHUB_ENV - name: Display version diff --git a/.github/workflows/redeploy_proxy.py b/.github/workflows/redeploy_proxy.py deleted file mode 100644 index ed46bef73a..0000000000 --- a/.github/workflows/redeploy_proxy.py +++ /dev/null @@ -1,20 +0,0 @@ -""" - -redeploy_proxy.py -""" - -import os -import requests -import time - -# send a get request to this endpoint -deploy_hook1 = os.getenv("LOAD_TEST_REDEPLOY_URL1") -response = requests.get(deploy_hook1, timeout=20) - - -deploy_hook2 = os.getenv("LOAD_TEST_REDEPLOY_URL2") -response = requests.get(deploy_hook2, timeout=20) - -print("SENT GET REQUESTS to re-deploy proxy") -print("sleeeping.... for 60s") -time.sleep(60) diff --git a/.github/workflows/regenerate-poetry-lock.yml b/.github/workflows/regenerate-poetry-lock.yml deleted file mode 100644 index c0844f1c70..0000000000 --- a/.github/workflows/regenerate-poetry-lock.yml +++ /dev/null @@ -1,80 +0,0 @@ -name: Regenerate poetry.lock - -# Runs whenever pyproject.toml is merged into main (the most common cause of -# the "pyproject.toml changed significantly since poetry.lock was last generated" -# CI failure). Can also be triggered manually. -on: - push: - branches: - - main - paths: - - pyproject.toml - workflow_dispatch: - -permissions: - contents: write # needed to push the auto/regenerate-poetry-lock-* branch - pull-requests: write # needed to open the PR and enable auto-merge - -jobs: - regenerate-lock: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - - name: Set up Python - uses: actions/setup-python@v5 - with: - python-version: "3.11" - - - name: Install Poetry - run: pip install poetry - - - name: Regenerate poetry.lock - run: poetry lock - - - name: Check whether poetry.lock actually changed - id: diff - run: | - if git diff --quiet poetry.lock; then - echo "changed=false" >> "$GITHUB_OUTPUT" - else - echo "changed=true" >> "$GITHUB_OUTPUT" - fi - - - name: Open PR with the refreshed lock file - if: steps.diff.outputs.changed == 'true' - id: open-pr - run: | - BRANCH="auto/regenerate-poetry-lock-$(date +'%Y%m%d%H%M%S')" - git config user.name "github-actions[bot]" - git config user.email "github-actions[bot]@users.noreply.github.com" - git checkout -b "$BRANCH" - git add poetry.lock - git commit -m "chore: regenerate poetry.lock to match pyproject.toml" - git push -f origin "$BRANCH" - - cat > /tmp/pr-body.md << 'BODY' - Automated regeneration of `poetry.lock` after `pyproject.toml` was updated on `main`. - - Fixes the recurring CI failure: - ``` - pyproject.toml changed significantly since poetry.lock was last generated. - Run `poetry lock` to fix the lock file. - ``` - BODY - - PR_URL=$(gh pr create \ - --title "chore: regenerate poetry.lock to match pyproject.toml" \ - --body-file /tmp/pr-body.md \ - --head "$BRANCH" \ - --base main) - echo "pr_url=$PR_URL" >> "$GITHUB_OUTPUT" - env: - GH_TOKEN: ${{ github.token }} - - - name: Enable auto-merge - if: steps.diff.outputs.changed == 'true' - run: | - gh pr merge "${{ steps.open-pr.outputs.pr_url }}" --auto --squash - env: - GH_TOKEN: ${{ github.token }} diff --git a/.github/workflows/reset_stable.yml b/.github/workflows/reset_stable.yml deleted file mode 100644 index f6fed672d4..0000000000 --- a/.github/workflows/reset_stable.yml +++ /dev/null @@ -1,39 +0,0 @@ -name: Reset litellm_stable branch - -on: - release: - types: [published, created] -jobs: - update-stable-branch: - if: ${{ startsWith(github.event.release.tag_name, 'v') && !endsWith(github.event.release.tag_name, '-stable') }} - runs-on: ubuntu-latest - - steps: - - name: Checkout repository - uses: actions/checkout@v3 - - - name: Reset litellm_stable_release_branch branch to the release commit - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - run: | - # Configure Git user - git config user.name "github-actions[bot]" - git config user.email "github-actions[bot]@users.noreply.github.com" - - # Fetch all branches and tags - git fetch --all - - # Check if the litellm_stable_release_branch branch exists - if git show-ref --verify --quiet refs/remotes/origin/litellm_stable_release_branch; then - echo "litellm_stable_release_branch branch exists." - git checkout litellm_stable_release_branch - else - echo "litellm_stable_release_branch branch does not exist. Creating it." - git checkout -b litellm_stable_release_branch - fi - - # Reset litellm_stable_release_branch branch to the release commit - git reset --hard $GITHUB_SHA - - # Push the updated litellm_stable_release_branch branch - git push origin litellm_stable_release_branch --force diff --git a/.github/workflows/run_llm_translation_tests.py b/.github/workflows/run_llm_translation_tests.py old mode 100755 new mode 100644 diff --git a/.github/workflows/run_observatory_tests.yml b/.github/workflows/run_observatory_tests.yml index d343098ed3..a25b96766d 100644 --- a/.github/workflows/run_observatory_tests.yml +++ b/.github/workflows/run_observatory_tests.yml @@ -33,7 +33,9 @@ jobs: timeout-minutes: 30 steps: - name: Checkout repository - uses: actions/checkout@v4 + uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0 + with: + persist-credentials: false - name: Validate tag input env: @@ -49,11 +51,12 @@ jobs: TAG: ${{ inputs.tag }} AZURE_API_KEY: ${{ secrets.AZURE_API_KEY }} AZURE_API_BASE: ${{ secrets.AZURE_API_BASE }} + WORKSPACE: ${{ github.workspace }} run: | docker run -d \ --name litellm-rc \ -p 4000:4000 \ - -v "${{ github.workspace }}/.github/observatory/litellm_config.yaml:/app/config.yaml" \ + -v "${WORKSPACE}/.github/observatory/litellm_config.yaml:/app/config.yaml" \ -e LITELLM_MASTER_KEY="${LITELLM_MASTER_KEY}" \ -e AZURE_API_KEY="${AZURE_API_KEY}" \ -e AZURE_API_BASE="${AZURE_API_BASE}" \ @@ -77,8 +80,9 @@ jobs: - name: Start cloudflared tunnel run: | - # Install cloudflared + # Install cloudflared (pinned version + checksum) curl -sL https://github.com/cloudflare/cloudflared/releases/download/2025.2.1/cloudflared-linux-amd64 -o /usr/local/bin/cloudflared + echo "afdfadd1ef552e66bffc35246fe30a9bd578356d2d386de95585ccfc432472b8 /usr/local/bin/cloudflared" | sha256sum -c - chmod +x /usr/local/bin/cloudflared # Start a quick tunnel (no account needed) and capture the URL @@ -103,11 +107,11 @@ jobs: - name: Verify tunnel connectivity run: | - echo "Testing tunnel at ${{ env.TUNNEL_URL }}..." + echo "Testing tunnel at ${TUNNEL_URL}..." # Quick tunnels need time for DNS propagation; retry to avoid # transient NXDOMAIN (curl exit code 6) on first attempt. for i in $(seq 1 10); do - if curl -sf "${{ env.TUNNEL_URL }}/health/liveliness" > /dev/null 2>&1; then + if curl -sf "${TUNNEL_URL}/health/liveliness" > /dev/null 2>&1; then echo "Tunnel is working (attempt $i)" exit 0 fi @@ -221,5 +225,5 @@ jobs: - name: Cleanup if: always() run: | - kill "${{ env.CLOUDFLARED_PID }}" 2>/dev/null || true + kill "$CLOUDFLARED_PID" 2>/dev/null || true docker rm -f litellm-rc 2>/dev/null || true diff --git a/.github/workflows/scan_duplicate_issues.yml b/.github/workflows/scan_duplicate_issues.yml index 06e8f453a8..222ff11f30 100644 --- a/.github/workflows/scan_duplicate_issues.yml +++ b/.github/workflows/scan_duplicate_issues.yml @@ -21,14 +21,15 @@ jobs: contents: read steps: - name: Checkout scripts - uses: actions/checkout@v4 + uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0 with: sparse-checkout: .github/scripts + persist-credentials: false - name: Set up Python - uses: actions/setup-python@v5 + uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0 with: - python-version: "3.11" + python-version: "3.13" - name: Scan for duplicate issues env: diff --git a/.github/workflows/scorecard.yml b/.github/workflows/scorecard.yml new file mode 100644 index 0000000000..3a00064c3b --- /dev/null +++ b/.github/workflows/scorecard.yml @@ -0,0 +1,47 @@ +name: Scorecard supply-chain security + +on: + branch_protection_rule: + schedule: + - cron: '27 12 * * 4' + push: + branches: ["main"] + +permissions: read-all + +jobs: + analysis: + name: Scorecard analysis + runs-on: ubuntu-latest + if: github.event.repository.default_branch == github.ref_name + permissions: + security-events: write + id-token: write + # Uncomment for private repos if needed: + # contents: read + # actions: read + + steps: + - name: Checkout code + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + with: + persist-credentials: false + + - name: Run analysis + uses: ossf/scorecard-action@f49aabe0b5af0936a0987cfb85d86b75731b0186 # v2.4.1 + with: + results_file: results.sarif + results_format: sarif + publish_results: true + + - name: Upload artifact + uses: actions/upload-artifact@4cec3d8aa04e39d1a68397de0c4cd6fb9dce8ec1 # v4.6.1 + with: + name: SARIF file + path: results.sarif + retention-days: 5 + + - name: Upload to code scanning + uses: github/codeql-action/upload-sarif@c10b8064de6f491fea524254123dbe5e09572f13 # v4.35.1 + with: + sarif_file: results.sarif diff --git a/.github/workflows/simple_pypi_publish.yml b/.github/workflows/simple_pypi_publish.yml deleted file mode 100644 index e183055681..0000000000 --- a/.github/workflows/simple_pypi_publish.yml +++ /dev/null @@ -1,67 +0,0 @@ -name: Simple PyPI Publish - -on: - workflow_dispatch: - inputs: - version: - description: 'Version to publish (e.g., 1.74.10)' - required: true - type: string - -env: - TWINE_USERNAME: __token__ - -jobs: - publish: - runs-on: ubuntu-latest - if: github.repository == 'BerriAI/litellm' - - steps: - - name: Checkout code - uses: actions/checkout@v4 - - - name: Set up Python - uses: actions/setup-python@v4 - with: - python-version: '3.8' - - - name: Install dependencies - run: | - python -m pip install --upgrade pip - pip install toml build wheel twine - - - name: Update version in pyproject.toml - run: | - python -c " - import toml - - with open('pyproject.toml', 'r') as f: - data = toml.load(f) - - data['tool']['poetry']['version'] = '${{ github.event.inputs.version }}' - - with open('pyproject.toml', 'w') as f: - toml.dump(data, f) - - print(f'Updated version to ${{ github.event.inputs.version }}') - " - - - name: Copy model prices file - run: | - cp model_prices_and_context_window.json litellm/model_prices_and_context_window_backup.json - - - name: Build package - run: | - rm -rf build dist - python -m build - - - name: Publish to PyPI - env: - TWINE_PASSWORD: ${{ secrets.PYPI_PUBLISH_PASSWORD }} - run: | - twine upload dist/* - - - name: Output success - run: | - echo "✅ Successfully published litellm v${{ github.event.inputs.version }} to PyPI" - echo "📦 Package: https://pypi.org/project/litellm/${{ github.event.inputs.version }}/" \ No newline at end of file diff --git a/.github/workflows/stale.yml b/.github/workflows/stale.yml index 5a9b19fc9c..c905bb1231 100644 --- a/.github/workflows/stale.yml +++ b/.github/workflows/stale.yml @@ -2,19 +2,24 @@ name: "Stale Issue Management" on: schedule: - - cron: '0 0 * * *' # Runs daily at midnight UTC + - cron: "0 0 * * *" # Runs daily at midnight UTC workflow_dispatch: +permissions: + issues: write + pull-requests: write + jobs: stale: + if: github.repository == 'BerriAI/litellm' runs-on: ubuntu-latest steps: - - uses: actions/stale@v8 + - uses: actions/stale@1160a2240286f5da8ec72b1c0816ce2481aabf84 # v8 with: repo-token: "${{ secrets.GITHUB_TOKEN }}" stale-issue-message: "This issue has been automatically marked as stale because it has not had recent activity. It will be closed if no further activity occurs." stale-pr-message: "This pull request has been automatically marked as stale because it has not had recent activity. It will be closed if no further activity occurs." - days-before-stale: 90 # Revert to 60 days - days-before-close: 7 # Revert to 7 days + days-before-stale: 90 # Revert to 60 days + days-before-close: 7 # Revert to 7 days stale-issue-label: "stale" - operations-per-run: 1000 \ No newline at end of file + operations-per-run: 1000 diff --git a/.github/workflows/sync-schema.yml b/.github/workflows/sync-schema.yml new file mode 100644 index 0000000000..72a5c56293 --- /dev/null +++ b/.github/workflows/sync-schema.yml @@ -0,0 +1,73 @@ +name: Sync schema.prisma copies + +on: + pull_request: + paths: + - 'schema.prisma' + +# Scoped to ONLY the permissions needed: +# - contents:write to push the sync commit to the PR branch +# - pull-requests:read is implicit (needed to check out the PR) +permissions: + contents: write + +jobs: + sync: + name: Copy root schema to proxy and proxy-extras + runs-on: ubuntu-latest + timeout-minutes: 5 + # Only run on PRs from branches in THIS repo (not forks). + # Fork PRs cannot push back to the head branch with GITHUB_TOKEN, + # and pull_request events from forks have read-only tokens anyway. + # Also reject PRs from branches named after protected branches to + # prevent pushing directly to main/master. + if: >- + github.event.pull_request.head.repo.full_name == github.repository + && github.head_ref != 'main' + && github.head_ref != 'master' + steps: + - name: Checkout PR branch by SHA + uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0 + with: + # Use the merge commit SHA for safety — github.head_ref is an + # attacker-controlled string (the branch name) and could contain + # unusual characters that cause unexpected git behavior. + ref: ${{ github.event.pull_request.head.sha }} + persist-credentials: true # needed for git push + + - name: Reject symlinked schema files + run: | + for f in schema.prisma litellm/proxy/schema.prisma litellm-proxy-extras/litellm_proxy_extras/schema.prisma; do + if [ -L "$f" ]; then + echo "::error file=$f::$f is a symlink, which is not allowed" + exit 1 + fi + done + + - name: Copy root schema to other locations + run: | + cp schema.prisma litellm/proxy/schema.prisma + cp schema.prisma litellm-proxy-extras/litellm_proxy_extras/schema.prisma + + - name: Check for changes + id: diff + run: | + if git diff --quiet -- litellm/proxy/schema.prisma litellm-proxy-extras/litellm_proxy_extras/schema.prisma; then + echo "changed=false" >> "$GITHUB_OUTPUT" + echo "Schemas already in sync. Nothing to do." + else + echo "changed=true" >> "$GITHUB_OUTPUT" + echo "Schema copies need updating." + fi + + - name: Commit synced schemas + if: steps.diff.outputs.changed == 'true' + run: | + # Push to the PR's head branch (need the branch name for git push). + # We checked out by SHA above for safety, so configure the push target explicitly. + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + git checkout -B "$GITHUB_HEAD_REF" + git add -- litellm/proxy/schema.prisma litellm-proxy-extras/litellm_proxy_extras/schema.prisma + git commit -m "chore: sync schema.prisma copies from root" + git push origin "HEAD:$GITHUB_HEAD_REF" diff --git a/.github/workflows/test-linting.yml b/.github/workflows/test-linting.yml index 4cedb8b5ba..5bb85716a1 100644 --- a/.github/workflows/test-linting.yml +++ b/.github/workflows/test-linting.yml @@ -2,7 +2,10 @@ name: LiteLLM Linting on: pull_request: - branches: [ main ] + branches: [main] + +permissions: + contents: read jobs: lint: @@ -10,72 +13,73 @@ jobs: timeout-minutes: 5 steps: - - uses: actions/checkout@v4 - with: - fetch-depth: 0 - clean: true + - uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0 + with: + fetch-depth: 0 + clean: true + persist-credentials: false - - name: Set up Python - uses: actions/setup-python@v4 - with: - python-version: '3.12' + - name: Set up Python + uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0 + with: + python-version: "3.12" - - name: Install Poetry - uses: snok/install-poetry@v1 + - name: Install Poetry + run: pip install 'poetry==2.3.2' - - name: Clean Python cache - run: | - find . -type d -name "__pycache__" -exec rm -rf {} + || true - find . -name "*.pyc" -delete || true + - name: Clean Python cache + run: | + find . -type d -name "__pycache__" -exec rm -rf {} + || true + find . -name "*.pyc" -delete || true - - name: Check poetry.lock is up to date - run: | - poetry check --lock || (echo "❌ poetry.lock is out of sync with pyproject.toml. Run 'poetry lock' locally and commit the result." && exit 1) + - name: Check poetry.lock is up to date + run: | + poetry check --lock || (echo "❌ poetry.lock is out of sync with pyproject.toml. Run 'poetry lock' locally and commit the result." && exit 1) - - name: Install dependencies - run: | - poetry install --with dev + - name: Install dependencies + run: | + poetry install --with dev - - name: Check Black formatting - run: | - cd litellm - poetry run black --check --exclude '/enterprise/' . - cd .. + - name: Check Black formatting + run: | + cd litellm + poetry run black --check --exclude '/enterprise/' . + cd .. - - name: Debug - Check file state - run: | - echo "Current branch:" - git branch --show-current - echo "Last 3 commits:" - git log --oneline -3 - echo "File content around line 43:" - head -50 litellm/litellm_core_utils/custom_logger_registry.py | tail -10 - - - name: Run Ruff linting - run: | - cd litellm - poetry run ruff check . - cd .. + - name: Debug - Check file state + run: | + echo "Current branch:" + git branch --show-current + echo "Last 3 commits:" + git log --oneline -3 + echo "File content around line 43:" + head -50 litellm/litellm_core_utils/custom_logger_registry.py | tail -10 - - name: Print OpenAI version - run: | - poetry run python -c "import openai; print(f'OpenAI version: {openai.__version__}')" + - name: Run Ruff linting + run: | + cd litellm + poetry run ruff check . + cd .. - - name: Run MyPy type checking - run: | - cd litellm - poetry run mypy . - cd .. + - name: Print OpenAI version + run: | + poetry run python -c "import openai; print(f'OpenAI version: {openai.__version__}')" - - name: Check for circular imports - run: | - cd litellm - poetry run python ../tests/documentation_tests/test_circular_imports.py - cd .. + - name: Run MyPy type checking + run: | + cd litellm + poetry run mypy . + cd .. - - name: Check import safety - run: | - poetry run python -c "from litellm import *" || (echo '🚨 import failed, this means you introduced unprotected imports! 🚨'; exit 1) + - name: Check for circular imports + run: | + cd litellm + poetry run python ../tests/documentation_tests/test_circular_imports.py + cd .. + + - name: Check import safety + run: | + poetry run python -c "from litellm import *" || (echo '🚨 import failed, this means you introduced unprotected imports! 🚨'; exit 1) secret-scan: runs-on: ubuntu-latest @@ -84,27 +88,28 @@ jobs: contents: read steps: - - uses: actions/checkout@v4 - with: - fetch-depth: 0 + - uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0 + with: + fetch-depth: 0 + persist-credentials: false - - name: Set up Python - uses: actions/setup-python@v4 - with: - python-version: '3.12' + - name: Set up Python + uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0 + with: + python-version: "3.12" - - name: Run secret scan test - run: | - pip install pytest - pytest tests/litellm/test_no_hardcoded_secrets.py -v + - name: Run secret scan test + run: | + pip install 'pytest==9.0.2' + pytest tests/litellm/test_no_hardcoded_secrets.py -v - - name: Run ggshield secret scan - env: - GITGUARDIAN_API_KEY: ${{ secrets.GITGUARDIAN_API_KEY }} - run: | - if [ -n "$GITGUARDIAN_API_KEY" ]; then - pip install ggshield - ggshield secret scan repo . - else - echo "GITGUARDIAN_API_KEY not set, skipping ggshield scan" - fi + - name: Run ggshield secret scan + env: + GITGUARDIAN_API_KEY: ${{ secrets.GITGUARDIAN_API_KEY }} + run: | + if [ -n "$GITGUARDIAN_API_KEY" ]; then + pip install 'ggshield==1.48.0' + ggshield secret scan repo . + else + echo "GITGUARDIAN_API_KEY not set, skipping ggshield scan" + fi diff --git a/.github/workflows/test-litellm-matrix.yml b/.github/workflows/test-litellm-matrix.yml index d0ac28ab41..dafabe4d83 100644 --- a/.github/workflows/test-litellm-matrix.yml +++ b/.github/workflows/test-litellm-matrix.yml @@ -4,6 +4,9 @@ on: pull_request: branches: [main] +permissions: + contents: read + # Cancel in-progress runs for the same PR concurrency: group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} @@ -12,7 +15,7 @@ concurrency: jobs: test: runs-on: ubuntu-latest - timeout-minutes: 20 # Increased from 15 to 20 + timeout-minutes: 20 # Increased from 15 to 20 strategy: fail-fast: false matrix: @@ -43,7 +46,7 @@ jobs: - name: "integrations" path: "tests/test_litellm/integrations" workers: 2 - reruns: 3 # Integration tests tend to be flakier + reruns: 3 # Integration tests tend to be flakier - name: "core-utils" path: "tests/test_litellm/litellm_core_utils" workers: 2 @@ -117,18 +120,20 @@ jobs: name: test (${{ matrix.test-group.name }}) steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0 + with: + persist-credentials: false - name: Set up Python - uses: actions/setup-python@v5 + uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0 with: python-version: "3.12" - name: Install Poetry - uses: snok/install-poetry@v1 + run: pip install 'poetry==2.3.2' - name: Cache Poetry dependencies - uses: actions/cache@v4 + uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.0.0 with: path: | ~/.cache/pypoetry @@ -144,14 +149,17 @@ jobs: poetry install --with dev,proxy-dev --extras "proxy semantic-router" # pytest-rerunfailures and pytest-xdist are in pyproject.toml dev dependencies poetry run pip install google-genai==1.22.0 \ - google-cloud-aiplatform>=1.38 fastapi-offline==1.7.3 python-multipart==0.0.22 openapi-core + google-cloud-aiplatform==1.115.0 fastapi-offline==1.7.3 python-multipart==0.0.22 openapi-core==0.23.0 - name: Setup litellm-enterprise run: | poetry run pip install --force-reinstall --no-deps -e enterprise/ - name: Generate Prisma client + env: + PRISMA_BINARY_CACHE_DIR: ${{ runner.temp }}/prisma-cache run: | + poetry run pip install nodejs-wheel-binaries==24.13.1 poetry run prisma generate --schema litellm/proxy/schema.prisma - name: Run tests - ${{ matrix.test-group.name }} @@ -163,4 +171,44 @@ jobs: --reruns ${{ matrix.test-group.reruns }} \ --reruns-delay 1 \ --dist=loadscope \ - --durations=20 + --durations=20 \ + --cov=litellm \ + --cov-report=xml:coverage-${{ matrix.test-group.name }}.xml \ + --cov-config=pyproject.toml + + - name: Save coverage report + if: always() + uses: actions/upload-artifact@4cec3d8aa04e39d1a68397de0c4cd6fb9dce8ec1 # v4.6.1 + with: + name: coverage-${{ matrix.test-group.name }} + path: coverage-${{ matrix.test-group.name }}.xml + retention-days: 1 + + upload-coverage: + name: Upload coverage to Codecov + needs: test + if: always() + runs-on: ubuntu-latest + permissions: + contents: read + id-token: write # Required for OIDC tokenless upload + pull-requests: write # Required for Codecov PR comments + + steps: + - name: Checkout code + uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0 + + - name: Download all coverage reports + uses: actions/download-artifact@95815c38cf2ff2164869cbab79da8d1f422bc89e # v4.2.1 + with: + pattern: coverage-* + path: coverage-reports + merge-multiple: true + + - name: Upload to Codecov + uses: codecov/codecov-action@aa56896cf108bd10b5eb883cd1d24196da57f695 # v5.5.4 + with: + use_oidc: true + directory: coverage-reports + root_dir: ${{ github.workspace }} + fail_ci_if_error: false diff --git a/.github/workflows/test-litellm-ui-build.yml b/.github/workflows/test-litellm-ui-build.yml index b0a8b648a4..bef568298e 100644 --- a/.github/workflows/test-litellm-ui-build.yml +++ b/.github/workflows/test-litellm-ui-build.yml @@ -16,17 +16,19 @@ jobs: steps: - name: Checkout repository - uses: actions/checkout@v4 + uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0 + with: + persist-credentials: false - name: Setup Node.js - uses: actions/setup-node@v4 + uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5.0 with: node-version: "20" cache: "npm" cache-dependency-path: ui/litellm-dashboard/package-lock.json - name: Install dependencies - run: npm install + run: npm ci - name: Build run: npm run build diff --git a/.github/workflows/test-litellm.yml b/.github/workflows/test-litellm.yml index 3f8369df92..0c040b3ebe 100644 --- a/.github/workflows/test-litellm.yml +++ b/.github/workflows/test-litellm.yml @@ -4,45 +4,50 @@ name: LiteLLM Mock Tests (folder - tests/test_litellm) # the same tests in parallel across 10 jobs for faster CI times. # Kept for manual debugging only. on: - workflow_dispatch: # Manual trigger only + workflow_dispatch: # Manual trigger only # pull_request: # branches: [ main ] +permissions: + contents: read + jobs: test: runs-on: ubuntu-latest timeout-minutes: 25 steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0 + with: + persist-credentials: false - - name: Thank You Message - run: | - echo "### 🙏 Thank you for contributing to LiteLLM!" >> $GITHUB_STEP_SUMMARY - echo "Your PR is being tested now. We appreciate your help in making LiteLLM better!" >> $GITHUB_STEP_SUMMARY + - name: Thank You Message + run: | + echo "### 🙏 Thank you for contributing to LiteLLM!" >> $GITHUB_STEP_SUMMARY + echo "Your PR is being tested now. We appreciate your help in making LiteLLM better!" >> $GITHUB_STEP_SUMMARY - - name: Set up Python - uses: actions/setup-python@v4 - with: - python-version: '3.12' + - name: Set up Python + uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0 + with: + python-version: "3.12" - - name: Install Poetry - uses: snok/install-poetry@v1 + - name: Install Poetry + run: pip install 'poetry==2.3.2' - - name: Install dependencies - run: | - poetry lock - poetry install --with dev,proxy-dev --extras "proxy semantic-router" - poetry run pip install "pytest-retry==1.6.3" - poetry run pip install pytest-xdist - poetry run pip install "google-genai==1.22.0" - poetry run pip install "google-cloud-aiplatform>=1.38" - poetry run pip install "fastapi-offline==1.7.3" - poetry run pip install "python-multipart>=0.0.20" - poetry run pip install "openapi-core" - - name: Setup litellm-enterprise as local package - run: | - poetry run pip install --force-reinstall --no-deps -e enterprise/ - - name: Run tests - run: | - poetry run pytest tests/test_litellm --tb=short -vv --maxfail=10 -n 4 --durations=50 + - name: Install dependencies + run: | + poetry lock + poetry install --with dev,proxy-dev --extras "proxy semantic-router" + poetry run pip install "pytest-retry==1.6.3" + poetry run pip install 'pytest-xdist==3.8.0' + poetry run pip install "google-genai==1.22.0" + poetry run pip install "google-cloud-aiplatform==1.115.0" + poetry run pip install "fastapi-offline==1.7.3" + poetry run pip install "python-multipart==0.0.22" + poetry run pip install "openapi-core==0.23.0" + - name: Setup litellm-enterprise as local package + run: | + poetry run pip install --force-reinstall --no-deps -e enterprise/ + - name: Run tests + run: | + poetry run pytest tests/test_litellm --tb=short -vv --maxfail=10 -n 4 --durations=50 diff --git a/.github/workflows/test-mcp.yml b/.github/workflows/test-mcp.yml index 2e32aae768..1b228ab76b 100644 --- a/.github/workflows/test-mcp.yml +++ b/.github/workflows/test-mcp.yml @@ -2,7 +2,10 @@ name: LiteLLM MCP Tests (folder - tests/mcp_tests) on: pull_request: - branches: [ main ] + branches: [main] + +permissions: + contents: read jobs: test: @@ -10,38 +13,40 @@ jobs: timeout-minutes: 25 steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0 + with: + persist-credentials: false - - name: Thank You Message - run: | - echo "### 🙏 Thank you for contributing to LiteLLM!" >> $GITHUB_STEP_SUMMARY - echo "Your PR is being tested now. We appreciate your help in making LiteLLM better!" >> $GITHUB_STEP_SUMMARY + - name: Thank You Message + run: | + echo "### 🙏 Thank you for contributing to LiteLLM!" >> $GITHUB_STEP_SUMMARY + echo "Your PR is being tested now. We appreciate your help in making LiteLLM better!" >> $GITHUB_STEP_SUMMARY - - name: Set up Python - uses: actions/setup-python@v4 - with: - python-version: '3.12' + - name: Set up Python + uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0 + with: + python-version: "3.12" - - name: Install Poetry - uses: snok/install-poetry@v1 + - name: Install Poetry + run: pip install 'poetry==2.3.2' - - name: Install dependencies - run: | - poetry lock - poetry install --with dev,proxy-dev --extras "proxy semantic-router" - poetry run pip install "pytest==7.3.1" - poetry run pip install "pytest-retry==1.6.3" - poetry run pip install "pytest-cov==5.0.0" - poetry run pip install "pytest-asyncio==0.21.1" - poetry run pip install "respx==0.22.0" - poetry run pip install "pydantic==2.11.0" - poetry run pip install "mcp==1.25.0" - poetry run pip install pytest-xdist + - name: Install dependencies + run: | + poetry lock + poetry install --with dev,proxy-dev --extras "proxy semantic-router" + poetry run pip install "pytest==7.3.1" + poetry run pip install "pytest-retry==1.6.3" + poetry run pip install "pytest-cov==5.0.0" + poetry run pip install "pytest-asyncio==0.21.1" + poetry run pip install "respx==0.22.0" + poetry run pip install "pydantic==2.11.0" + poetry run pip install "mcp==1.25.0" + poetry run pip install 'pytest-xdist==3.8.0' - - name: Setup litellm-enterprise as local package - run: | - poetry run pip install --force-reinstall --no-deps -e enterprise/ + - name: Setup litellm-enterprise as local package + run: | + poetry run pip install --force-reinstall --no-deps -e enterprise/ - - name: Run MCP tests - run: | - poetry run pytest tests/mcp_tests -x -vv -n 4 --cov=litellm --cov-report=xml --durations=5 + - name: Run MCP tests + run: | + poetry run pytest tests/mcp_tests -x -vv -n 4 --cov=litellm --cov-report=xml --durations=5 diff --git a/.github/workflows/test-model-map.yaml b/.github/workflows/test-model-map.yaml index ae5ac402e2..429f9e1ce0 100644 --- a/.github/workflows/test-model-map.yaml +++ b/.github/workflows/test-model-map.yaml @@ -2,13 +2,18 @@ name: Validate model_prices_and_context_window.json on: pull_request: - branches: [ main ] + branches: [main] + +permissions: + contents: read jobs: validate-model-prices-json: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0 + with: + persist-credentials: false - name: Validate model_prices_and_context_window.json run: | diff --git a/.github/workflows/test-proxy-e2e-azure-batches.yml b/.github/workflows/test-proxy-e2e-azure-batches.yml index 4d74f3db0a..7cbbe0b338 100644 --- a/.github/workflows/test-proxy-e2e-azure-batches.yml +++ b/.github/workflows/test-proxy-e2e-azure-batches.yml @@ -9,6 +9,9 @@ concurrency: group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} cancel-in-progress: true +permissions: + contents: read + jobs: proxy_e2e_azure_batches_tests: runs-on: ubuntu-latest @@ -30,18 +33,20 @@ jobs: --health-retries 5 steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0 + with: + persist-credentials: false - name: Set up Python - uses: actions/setup-python@v5 + uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0 with: python-version: "3.12" - name: Install Poetry - uses: snok/install-poetry@v1 + run: pip install 'poetry==2.3.2' - name: Cache Poetry dependencies - uses: actions/cache@v4 + uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.0.0 with: path: | ~/.cache/pypoetry @@ -56,14 +61,17 @@ jobs: run: | poetry config virtualenvs.in-project true poetry install --with dev,proxy-dev --extras "proxy" - poetry run pip install psycopg2-binary uvicorn fastapi httpx tenacity + poetry run pip install psycopg2-binary==2.9.11 uvicorn==0.42.0 fastapi==0.135.2 httpx==0.28.1 tenacity==9.1.4 - name: Setup litellm-enterprise run: | poetry run pip install --force-reinstall --no-deps -e enterprise/ - name: Generate Prisma client + env: + PRISMA_BINARY_CACHE_DIR: ${{ runner.temp }}/prisma-cache run: | + poetry run pip install nodejs-wheel-binaries==24.13.1 poetry run prisma generate --schema litellm/proxy/schema.prisma - name: Run Prisma migrations @@ -87,4 +95,3 @@ jobs: --tb=short \ --maxfail=3 \ --durations=10 - diff --git a/.github/workflows/test-unit-caching-redis.yml b/.github/workflows/test-unit-caching-redis.yml new file mode 100644 index 0000000000..ca274324f2 --- /dev/null +++ b/.github/workflows/test-unit-caching-redis.yml @@ -0,0 +1,38 @@ +name: "Unit Tests: Caching (Redis)" + +# Uses cloud Redis credentials — only runs on trusted branches, not PRs. +# This prevents external PRs from accessing Redis credentials. +on: + push: + branches: [main, "litellm_*"] + +permissions: + contents: read + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + caching-redis: + uses: ./.github/workflows/_test-unit-services-base.yml + with: + # Redis-only tests that do NOT require provider API keys. + # Tests needing API keys (test_caching.py, test_caching_ssl.py, test_prometheus_service.py, + # test_router_caching.py) are in Phase 3 integration workflows. + test-path: >- + tests/local_testing/test_dual_cache.py + tests/local_testing/test_redis_batch_optimizations.py + tests/local_testing/test_router_utils.py + workers: 2 + reruns: 2 + timeout-minutes: 20 + enable-redis: true + enable-postgres: false + secrets: + REDIS_HOST: ${{ secrets.REDIS_HOST }} + REDIS_PORT: ${{ secrets.REDIS_PORT }} + REDIS_PASSWORD: ${{ secrets.REDIS_PASSWORD }} + DATABASE_URL: ${{ secrets.DATABASE_URL }} + POSTGRES_USER: ${{ secrets.POSTGRES_USER }} + POSTGRES_PASSWORD: ${{ secrets.POSTGRES_PASSWORD }} diff --git a/.github/workflows/test-unit-core-utils.yml b/.github/workflows/test-unit-core-utils.yml new file mode 100644 index 0000000000..2f3698fdf6 --- /dev/null +++ b/.github/workflows/test-unit-core-utils.yml @@ -0,0 +1,20 @@ +name: "Unit Tests: Core Utilities" + +on: + pull_request: + branches: [main] + +permissions: + contents: read + +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + +jobs: + core-utils: + uses: ./.github/workflows/_test-unit-base.yml + with: + test-path: "tests/test_litellm/litellm_core_utils" + workers: 2 + reruns: 1 diff --git a/.github/workflows/test-unit-documentation.yml b/.github/workflows/test-unit-documentation.yml new file mode 100644 index 0000000000..d8b30de684 --- /dev/null +++ b/.github/workflows/test-unit-documentation.yml @@ -0,0 +1,67 @@ +name: "Unit Tests: Documentation Validation" + +on: + pull_request: + branches: [main] + +permissions: + contents: read + +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + +jobs: + documentation: + runs-on: ubuntu-latest + timeout-minutes: 10 + + steps: + - uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0 + with: + persist-credentials: false + + - name: Set up Python + uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0 + with: + python-version: "3.12" + + - name: Install Poetry + run: pip install 'poetry==2.3.2' + + - name: Cache Poetry dependencies + uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0 + with: + path: | + ~/.cache/pypoetry + ~/.cache/pip + .venv + key: ${{ runner.os }}-poetry-${{ hashFiles('poetry.lock') }} + restore-keys: | + ${{ runner.os }}-poetry- + + - name: Install dependencies + run: | + poetry config virtualenvs.in-project true + poetry install --with dev,proxy-dev --extras "proxy semantic-router" + poetry run pip install google-genai==1.22.0 \ + google-cloud-aiplatform==1.115.0 fastapi-offline==1.7.3 python-multipart==0.0.22 openapi-core==0.23.0 + + - name: Setup litellm-enterprise + run: | + poetry run pip install --force-reinstall --no-deps -e enterprise/ + + - name: Generate Prisma client + env: + PRISMA_BINARY_CACHE_DIR: ${{ runner.temp }}/prisma-cache + run: | + poetry run pip install nodejs-wheel-binaries==24.13.1 + poetry run prisma generate --schema litellm/proxy/schema.prisma + + # Run the same documentation tests that CircleCI ran (as direct Python scripts) + - name: Run documentation validation tests + run: | + poetry run python ./tests/documentation_tests/test_env_keys.py + poetry run python ./tests/documentation_tests/test_router_settings.py + poetry run python ./tests/documentation_tests/test_api_docs.py + poetry run python ./tests/documentation_tests/test_circular_imports.py diff --git a/.github/workflows/test-unit-enterprise-routing.yml b/.github/workflows/test-unit-enterprise-routing.yml new file mode 100644 index 0000000000..13ae3efedb --- /dev/null +++ b/.github/workflows/test-unit-enterprise-routing.yml @@ -0,0 +1,24 @@ +name: "Unit Tests: Enterprise, Google GenAI & Routing" + +on: + pull_request: + branches: [main] + +permissions: + contents: read + +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + +jobs: + enterprise-routing: + uses: ./.github/workflows/_test-unit-base.yml + with: + test-path: >- + tests/test_litellm/enterprise + tests/test_litellm/google_genai + tests/test_litellm/router_utils + tests/test_litellm/router_strategy + workers: 2 + reruns: 2 diff --git a/.github/workflows/test-unit-integrations.yml b/.github/workflows/test-unit-integrations.yml new file mode 100644 index 0000000000..2789f99d81 --- /dev/null +++ b/.github/workflows/test-unit-integrations.yml @@ -0,0 +1,20 @@ +name: "Unit Tests: Integrations (Callbacks & Logging)" + +on: + pull_request: + branches: [main] + +permissions: + contents: read + +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + +jobs: + integrations: + uses: ./.github/workflows/_test-unit-base.yml + with: + test-path: "tests/test_litellm/integrations" + workers: 2 + reruns: 3 diff --git a/.github/workflows/test-unit-llm-providers.yml b/.github/workflows/test-unit-llm-providers.yml new file mode 100644 index 0000000000..6c00272b0c --- /dev/null +++ b/.github/workflows/test-unit-llm-providers.yml @@ -0,0 +1,29 @@ +name: "Unit Tests: LLM Provider Transformations" + +on: + pull_request: + branches: [main] + +permissions: + contents: read + +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + +jobs: + vertex-ai: + name: Vertex AI + uses: ./.github/workflows/_test-unit-base.yml + with: + test-path: "tests/test_litellm/llms/vertex_ai" + workers: 1 + reruns: 2 + + other-providers: + name: All Other Providers + uses: ./.github/workflows/_test-unit-base.yml + with: + test-path: "tests/test_litellm/llms --ignore=tests/test_litellm/llms/vertex_ai" + workers: 2 + reruns: 2 diff --git a/.github/workflows/test-unit-misc.yml b/.github/workflows/test-unit-misc.yml new file mode 100644 index 0000000000..9228decd7c --- /dev/null +++ b/.github/workflows/test-unit-misc.yml @@ -0,0 +1,31 @@ +name: "Unit Tests: MCP, Secrets, Containers & Misc" + +on: + pull_request: + branches: [main] + +permissions: + contents: read + +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + +jobs: + misc: + uses: ./.github/workflows/_test-unit-base.yml + with: + test-path: >- + tests/test_litellm/secret_managers + tests/test_litellm/a2a_protocol + tests/test_litellm/anthropic_interface + tests/test_litellm/completion_extras + tests/test_litellm/containers + tests/test_litellm/experimental_mcp_client + tests/test_litellm/images + tests/test_litellm/interactions + tests/test_litellm/passthrough + tests/test_litellm/vector_stores + tests/test_litellm/test_*.py + workers: 2 + reruns: 2 diff --git a/.github/workflows/test-unit-proxy-auth.yml b/.github/workflows/test-unit-proxy-auth.yml new file mode 100644 index 0000000000..e71821db70 --- /dev/null +++ b/.github/workflows/test-unit-proxy-auth.yml @@ -0,0 +1,20 @@ +name: "Unit Tests: Proxy Auth & Key Management" + +on: + pull_request: + branches: [main] + +permissions: + contents: read + +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + +jobs: + proxy-auth: + uses: ./.github/workflows/_test-unit-base.yml + with: + test-path: "tests/test_litellm/proxy/auth tests/test_litellm/proxy/hooks tests/test_litellm/proxy/policy_engine tests/test_litellm/proxy/client" + workers: 2 + reruns: 2 diff --git a/.github/workflows/test-unit-proxy-db.yml b/.github/workflows/test-unit-proxy-db.yml new file mode 100644 index 0000000000..bdfb6efeef --- /dev/null +++ b/.github/workflows/test-unit-proxy-db.yml @@ -0,0 +1,45 @@ +name: "Unit Tests: Proxy DB Operations" + +# Uses DATABASE_URL secret — only runs on trusted branches, not PRs. +on: + push: + branches: [main, "litellm_*"] + +permissions: + contents: read + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + proxy-db: + strategy: + fail-fast: false + matrix: + include: + # Key generation tests must NOT run in parallel (event loop conflicts with logging worker) + - test-group: key-generation + test-path: "tests/proxy_unit_tests/test_key_generate_prisma.py" + workers: 0 + timeout: 30 + - test-group: auth-checks + test-path: "tests/proxy_unit_tests/test_auth_checks.py tests/proxy_unit_tests/test_user_api_key_auth.py" + workers: 8 + timeout: 20 + - test-group: remaining + test-path: "tests/proxy_unit_tests --ignore=tests/proxy_unit_tests/test_key_generate_prisma.py --ignore=tests/proxy_unit_tests/test_auth_checks.py --ignore=tests/proxy_unit_tests/test_user_api_key_auth.py" + workers: 8 + timeout: 20 + uses: ./.github/workflows/_test-unit-services-base.yml + with: + test-path: ${{ matrix.test-path }} + workers: ${{ matrix.workers }} + reruns: 2 + timeout-minutes: ${{ matrix.timeout }} + enable-redis: false + enable-postgres: true + secrets: + DATABASE_URL: ${{ secrets.DATABASE_URL }} + POSTGRES_USER: ${{ secrets.POSTGRES_USER }} + POSTGRES_PASSWORD: ${{ secrets.POSTGRES_PASSWORD }} diff --git a/.github/workflows/test-unit-proxy-endpoints.yml b/.github/workflows/test-unit-proxy-endpoints.yml new file mode 100644 index 0000000000..caff3b3ae0 --- /dev/null +++ b/.github/workflows/test-unit-proxy-endpoints.yml @@ -0,0 +1,35 @@ +name: "Unit Tests: Proxy API Endpoints" + +on: + pull_request: + branches: [main] + +permissions: + contents: read + +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + +jobs: + proxy-endpoints: + uses: ./.github/workflows/_test-unit-base.yml + with: + test-path: >- + tests/test_litellm/proxy/management_endpoints + tests/test_litellm/proxy/guardrails + tests/test_litellm/proxy/management_helpers + tests/test_litellm/proxy/anthropic_endpoints + tests/test_litellm/proxy/google_endpoints + tests/test_litellm/proxy/openai_files_endpoint + tests/test_litellm/proxy/response_api_endpoints + tests/test_litellm/proxy/image_endpoints + tests/test_litellm/proxy/vector_store_endpoints + tests/test_litellm/proxy/agent_endpoints + tests/test_litellm/proxy/discovery_endpoints + tests/test_litellm/proxy/health_endpoints + tests/test_litellm/proxy/public_endpoints + tests/test_litellm/proxy/prompts + tests/test_litellm/proxy/ui_crud_endpoints + workers: 2 + reruns: 2 diff --git a/.github/workflows/test-unit-proxy-infra.yml b/.github/workflows/test-unit-proxy-infra.yml new file mode 100644 index 0000000000..4dfbbe317e --- /dev/null +++ b/.github/workflows/test-unit-proxy-infra.yml @@ -0,0 +1,28 @@ +name: "Unit Tests: Proxy Infrastructure" + +on: + pull_request: + branches: [main] + +permissions: + contents: read + +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + +jobs: + proxy-infra: + uses: ./.github/workflows/_test-unit-base.yml + with: + test-path: >- + tests/test_litellm/proxy/db + tests/test_litellm/proxy/middleware + tests/test_litellm/proxy/spend_tracking + tests/test_litellm/proxy/pass_through_endpoints + tests/test_litellm/proxy/_experimental + tests/test_litellm/proxy/experimental + tests/test_litellm/proxy/common_utils + tests/test_litellm/proxy/test_*.py + workers: 2 + reruns: 2 diff --git a/.github/workflows/test-unit-proxy-legacy.yml b/.github/workflows/test-unit-proxy-legacy.yml new file mode 100644 index 0000000000..a939113726 --- /dev/null +++ b/.github/workflows/test-unit-proxy-legacy.yml @@ -0,0 +1,96 @@ +name: "Unit Tests: Proxy Legacy Tests" + +on: + pull_request: + branches: [main] + +permissions: + contents: read + +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + +jobs: + test: + runs-on: ubuntu-latest + timeout-minutes: 20 + strategy: + fail-fast: false + matrix: + test-group: + - name: "auth-and-jwt" + path: "tests/proxy_unit_tests/test_[a-j]*.py" + - name: "key-generation" + path: "tests/proxy_unit_tests/test_[k-o]*.py" + - name: "proxy-config" + path: "tests/proxy_unit_tests/test_prisma*.py tests/proxy_unit_tests/test_project*.py tests/proxy_unit_tests/test_prompt*.py tests/proxy_unit_tests/test_proxy_[c-r]*.py" + - name: "proxy-server" + path: "tests/proxy_unit_tests/test_proxy_server.py" + - name: "proxy-server-extras" + path: "tests/proxy_unit_tests/test_proxy_server_*.py tests/proxy_unit_tests/test_proxy_setting_guardrails.py" + - name: "proxy-utils" + path: "tests/proxy_unit_tests/test_proxy_utils.py" + - name: "proxy-token-counter" + path: "tests/proxy_unit_tests/test_proxy_token_counter.py" + - name: "proxy-response-and-misc" + path: "tests/proxy_unit_tests/test_[r-t]*.py" + - name: "proxy-user-auth-and-spend" + path: "tests/proxy_unit_tests/test_[u-z]*.py" + + name: ${{ matrix.test-group.name }} + + steps: + - uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0 + with: + persist-credentials: false + + - name: Set up Python + uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0 + with: + python-version: "3.12" + + - name: Install Poetry + run: pip install 'poetry==2.3.2' + + - name: Cache Poetry dependencies + uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0 + with: + path: | + ~/.cache/pypoetry + ~/.cache/pip + .venv + key: ${{ runner.os }}-poetry-${{ hashFiles('poetry.lock') }} + restore-keys: | + ${{ runner.os }}-poetry- + + - name: Install dependencies + run: | + poetry config virtualenvs.in-project true + poetry install --with dev,proxy-dev --extras "proxy semantic-router" + poetry run pip install google-genai==1.22.0 \ + google-cloud-aiplatform==1.115.0 fastapi-offline==1.7.3 python-multipart==0.0.22 openapi-core==0.23.0 + + - name: Setup litellm-enterprise + run: | + poetry run pip install --force-reinstall --no-deps -e enterprise/ + + - name: Generate Prisma client + env: + PRISMA_BINARY_CACHE_DIR: ${{ runner.temp }}/prisma-cache + run: | + poetry run pip install nodejs-wheel-binaries==24.13.1 + poetry run prisma generate --schema litellm/proxy/schema.prisma + + - name: Run tests - ${{ matrix.test-group.name }} + env: + TEST_PATH: ${{ matrix.test-group.path }} + run: | + poetry run pytest ${TEST_PATH} \ + --tb=short -vv \ + --maxfail=10 \ + -n 2 \ + --reruns 1 \ + --reruns-delay 1 \ + --dist=loadscope \ + --durations=20 diff --git a/.github/workflows/test-unit-responses-caching-types.yml b/.github/workflows/test-unit-responses-caching-types.yml new file mode 100644 index 0000000000..7f3acac280 --- /dev/null +++ b/.github/workflows/test-unit-responses-caching-types.yml @@ -0,0 +1,20 @@ +name: "Unit Tests: Responses, Caching & Types" + +on: + pull_request: + branches: [main] + +permissions: + contents: read + +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + +jobs: + responses-caching-types: + uses: ./.github/workflows/_test-unit-base.yml + with: + test-path: "tests/test_litellm/responses tests/test_litellm/caching tests/test_litellm/types" + workers: 2 + reruns: 2 diff --git a/.github/workflows/test-unit-security.yml b/.github/workflows/test-unit-security.yml new file mode 100644 index 0000000000..b38c82b1c2 --- /dev/null +++ b/.github/workflows/test-unit-security.yml @@ -0,0 +1,28 @@ +name: "Unit Tests: Security" + +# Uses DATABASE_URL secret — only runs on trusted branches, not PRs. +on: + push: + branches: [main, "litellm_*"] + +permissions: + contents: read + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + security: + uses: ./.github/workflows/_test-unit-services-base.yml + with: + test-path: "tests/proxy_security_tests/" + workers: 1 + reruns: 2 + timeout-minutes: 20 + enable-redis: false + enable-postgres: true + secrets: + DATABASE_URL: ${{ secrets.DATABASE_URL }} + POSTGRES_USER: ${{ secrets.POSTGRES_USER }} + POSTGRES_PASSWORD: ${{ secrets.POSTGRES_PASSWORD }} diff --git a/.github/workflows/test_server_root_path.yml b/.github/workflows/test_server_root_path.yml index c359e38bff..47636ce8e9 100644 --- a/.github/workflows/test_server_root_path.yml +++ b/.github/workflows/test_server_root_path.yml @@ -17,13 +17,15 @@ jobs: steps: - name: Checkout repository - uses: actions/checkout@v4 + uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0 + with: + persist-credentials: false - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v3 + uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3.12 - name: Build Docker image - uses: docker/build-push-action@v5 + uses: docker/build-push-action@0adf9959216b96bec444f325f1e493d4aa344497 #v6.14 with: context: . file: ./docker/Dockerfile.non_root diff --git a/.github/workflows/zizmor.yml b/.github/workflows/zizmor.yml new file mode 100644 index 0000000000..9a1e899fed --- /dev/null +++ b/.github/workflows/zizmor.yml @@ -0,0 +1,31 @@ +name: GitHub Actions Security Analysis + +on: + push: + branches: [main] + pull_request: + branches: [main] + +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + +permissions: {} + +jobs: + zizmor: + name: zizmor + runs-on: ubuntu-latest + timeout-minutes: 5 + permissions: + security-events: write + contents: read + actions: read + steps: + - name: Checkout repository + uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0 + with: + persist-credentials: false + + - name: Run zizmor + uses: zizmorcore/zizmor-action@71321a20a9ded102f6e9ce5718a2fcec2c4f70d8 # v0.5.2 diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml deleted file mode 100644 index 2bc361bc48..0000000000 --- a/.pre-commit-config.yaml +++ /dev/null @@ -1,40 +0,0 @@ -repos: -- repo: local - hooks: - - id: pyright - name: pyright - entry: pyright - language: system - types: [python] - files: ^(litellm/|litellm_proxy_extras/|enterprise/) - - id: isort - name: isort - entry: isort - language: system - types: [python] - files: (litellm/|litellm_proxy_extras/|enterprise/).*\.py - exclude: ^litellm/__init__.py$ - - id: black - name: black - entry: poetry run black - language: system - types: [python] - files: (litellm/|litellm_proxy_extras/).*\.py -- repo: https://github.com/pycqa/flake8 - rev: 7.0.0 # The version of flake8 to use - hooks: - - id: flake8 - exclude: ^litellm/tests/|^litellm/proxy/tests/|^litellm/tests/test_litellm/|^tests/test_litellm/|^tests/enterprise/ - additional_dependencies: [flake8-print] - files: (litellm/|litellm_proxy_extras/|enterprise/).*\.py -- repo: https://github.com/python-poetry/poetry - rev: 1.8.0 - hooks: - - id: poetry-check - files: ^(pyproject.toml|litellm-proxy-extras/pyproject.toml)$ -- repo: local - hooks: - - id: check-files-match - name: Check if files match - entry: python3 ci_cd/check_files_match.py - language: system \ No newline at end of file diff --git a/.trivyignore b/.trivyignore deleted file mode 100644 index 0d04ecacdb..0000000000 --- a/.trivyignore +++ /dev/null @@ -1,12 +0,0 @@ -# LiteLLM Trivy Ignore File -# CVEs listed here are temporarily allowlisted pending fixes - -# Next.js vulnerabilities in UI dashboard (next@14.2.35) -# Allowlisted: 2026-01-31, 7-day fix timeline -# Fix: Upgrade to Next.js 15.5.10+ or 16.1.5+ - -# HIGH: DoS via request deserialization -GHSA-h25m-26qc-wcjf - -# MEDIUM: Image Optimizer DoS -CVE-2025-59471 diff --git a/ci_cd/.grype.yaml b/ci_cd/.grype.yaml deleted file mode 100644 index b9bc9db58f..0000000000 --- a/ci_cd/.grype.yaml +++ /dev/null @@ -1,36 +0,0 @@ -ignore: - - vulnerability: CVE-2026-22184 - reason: no fixed zlib package is available yet in the Wolfi repositories, so this is ignored temporarily until an upstream release exists - # Wolfi base image: Python 3.13 and Node from apk have no fixed builds in Wolfi yet / not applicable - - vulnerability: CVE-2025-55130 - reason: Node in Wolfi apk; only used for Admin UI build/prisma - - vulnerability: CVE-2025-59465 - reason: Node in Wolfi apk; only used for Admin UI build/prisma - - vulnerability: CVE-2025-55131 - reason: Node in Wolfi apk; only used for Admin UI build/prisma - - vulnerability: CVE-2025-59466 - reason: Node in Wolfi apk; only used for Admin UI build/prisma - - vulnerability: CVE-2026-21637 - reason: Node in Wolfi apk; only used for Admin UI build/prisma - - vulnerability: CVE-2025-55132 - reason: Node in Wolfi apk; only used for Admin UI build/prisma - - vulnerability: GHSA-hx9q-6w63-j58v - reason: orjson dumps recursion; allowlisted - - vulnerability: GHSA-73rr-hh4g-fpgx - reason: diff npm transitive dep; override in package.json, allowlisted - - vulnerability: CVE-2026-0865 - reason: Python 3.13 in Wolfi base; no fixed apk build yet - - vulnerability: CVE-2025-15282 - reason: Python 3.13 in Wolfi base; no fixed apk build yet - - vulnerability: CVE-2026-0672 - reason: Python 3.13 in Wolfi base; no fixed apk build yet - - vulnerability: CVE-2025-15366 - reason: Python 3.13 in Wolfi base; no fixed apk build yet - - vulnerability: CVE-2025-15367 - reason: Python 3.13 in Wolfi base; no fixed apk build yet - - vulnerability: CVE-2025-11468 - reason: Python 3.13 in Wolfi base; no fixed apk build yet - - vulnerability: CVE-2025-12781 - reason: Python 3.13 in Wolfi base; no fixed apk build yet - - vulnerability: CVE-2026-1299 - reason: Python 3.13 in Wolfi base; no fixed apk build yet diff --git a/ci_cd/publish-proxy-extras.sh b/ci_cd/publish-proxy-extras.sh deleted file mode 100644 index 6c83d1f921..0000000000 --- a/ci_cd/publish-proxy-extras.sh +++ /dev/null @@ -1,19 +0,0 @@ -#!/bin/bash - -# Exit on error -set -e - -echo "🚀 Building and publishing litellm-proxy-extras" - -# Navigate to litellm-proxy-extras directory -cd "$(dirname "$0")/../litellm-proxy-extras" - -# Build the package -echo "📦 Building package..." -poetry build - -# Publish to PyPI -echo "🌎 Publishing to PyPI..." -poetry publish - -echo "✅ Done! Package published successfully" \ No newline at end of file diff --git a/ci_cd/security_scans.sh b/ci_cd/security_scans.sh deleted file mode 100755 index 801b700f64..0000000000 --- a/ci_cd/security_scans.sh +++ /dev/null @@ -1,262 +0,0 @@ -#!/bin/bash - -# Security Scans Script for LiteLLM -# This script runs comprehensive security scans including Trivy and Grype - -set -e - -echo "Starting security scans for LiteLLM..." - -# Function to install Trivy and required tools -install_trivy() { - echo "Installing Trivy and required tools..." - sudo apt-get update - sudo apt-get install -y wget apt-transport-https gnupg lsb-release jq curl bsdmainutils - wget -qO - https://aquasecurity.github.io/trivy-repo/deb/public.key | sudo apt-key add - - echo "deb https://aquasecurity.github.io/trivy-repo/deb $(lsb_release -sc) main" | sudo tee -a /etc/apt/sources.list.d/trivy.list - sudo apt-get update - sudo apt-get install trivy - echo "Trivy and required tools installed successfully" -} - -# Function to install Grype -install_grype() { - echo "Installing Grype..." - curl -sSfL https://raw.githubusercontent.com/anchore/grype/main/install.sh | sudo sh -s -- -b /usr/local/bin - echo "Grype installed successfully" -} - -# Function to install ggshield -install_ggshield() { - echo "Installing ggshield..." - pip3 install --upgrade pip - pip3 install ggshield - echo "ggshield installed successfully" -} - -# # Function to run secret detection scans -# run_secret_detection() { -# echo "Running secret detection scans..." - -# if ! command -v ggshield &> /dev/null; then -# install_ggshield -# fi - -# # Check if GITGUARDIAN_API_KEY is set (required for CI/CD) -# if [ -z "$GITGUARDIAN_API_KEY" ]; then -# echo "Warning: GITGUARDIAN_API_KEY environment variable is not set." -# echo "ggshield requires a GitGuardian API key to scan for secrets." -# echo "Please set GITGUARDIAN_API_KEY in your CI/CD environment variables." -# exit 1 -# fi - -# echo "Scanning codebase for secrets..." -# echo "Note: Large codebases may take several minutes due to API rate limits (50 requests/minute on free plan)" -# echo "ggshield will automatically handle rate limits and retry as needed." -# echo "Binary files, cache files, and build artifacts are excluded via .gitguardian.yaml" - -# # Use --recursive for directory scanning and auto-confirm if prompted -# # .gitguardian.yaml will automatically exclude binary files, wheel files, etc. -# # GITGUARDIAN_API_KEY environment variable will be used for authentication -# echo y | ggshield secret scan path . --recursive || { -# echo "" -# echo "==========================================" -# echo "ERROR: Secret Detection Failed" -# echo "==========================================" -# echo "ggshield has detected secrets in the codebase." -# echo "Please review discovered secrets above, revoke any actively used secrets" -# echo "from underlying systems and make changes to inject secrets dynamically at runtime." -# echo "" -# echo "For more information, see: https://docs.gitguardian.com/secrets-detection/" -# echo "==========================================" -# echo "" -# exit 1 -# } - -# echo "Secret detection scans completed successfully" -# } - -# Function to run Trivy scans -run_trivy_scans() { - echo "Running Trivy scans..." - - echo "Scanning LiteLLM Docs..." - trivy fs --ignorefile .trivyignore --scanners vuln --dependency-tree --exit-code 1 --severity HIGH,CRITICAL,MEDIUM ./docs/ - - echo "Scanning LiteLLM UI..." - trivy fs --ignorefile .trivyignore --scanners vuln --dependency-tree --exit-code 1 --severity HIGH,CRITICAL,MEDIUM ./ui/ - - echo "Trivy scans completed successfully" -} - -# Function to build and scan Docker images with Grype -run_grype_scans() { - echo "Running Grype scans..." - - # Temporarily add wheel files to .dockerignore for security scans - echo "Temporarily modifying .dockerignore to exclude problematic wheel files..." - cp .dockerignore .dockerignore.backup 2>/dev/null || touch .dockerignore.backup - echo "/*.whl" >> .dockerignore - - # Build and scan Dockerfile.database - echo "Building and scanning Dockerfile.database..." - docker build --no-cache -t litellm-database:latest -f ./docker/Dockerfile.database . - grype litellm-database:latest --config ci_cd/.grype.yaml --fail-on critical - - # Build and scan main Dockerfile - echo "Building and scanning main Dockerfile..." - docker build --no-cache -t litellm:latest . - grype litellm:latest --config ci_cd/.grype.yaml --fail-on critical - - # Restore original .dockerignore - echo "Restoring original .dockerignore..." - mv .dockerignore.backup .dockerignore - - # Scan the locally built LiteLLM image for vulnerabilities with CVSS >= 4.0 - echo "Scanning locally built LiteLLM image for high-severity vulnerabilities..." - echo "Using locally built image: litellm:latest" - - # Allowlist of CVEs to be ignored in failure threshold/reporting - # - CVE-2025-8869: Not applicable on Python >=3.13 (PEP 706 implemented); pip fallback unused; no OS-level fix - # - GHSA-4xh5-x5gv-qwph: GitHub Security Advisory alias for CVE-2025-8869 - # - GHSA-5j98-mcp5-4vw2: glob CLI command injection via -c/--cmd; glob CLI is not used in the litellm runtime image, - # and the vulnerable versions are pulled in only via OS-level/node tooling outside of our application code - ALLOWED_CVES=( - "CVE-2025-8869" - "GHSA-4xh5-x5gv-qwph" - "CVE-2025-8291" # no fix available as of Oct 11, 2025 - "GHSA-5j98-mcp5-4vw2" - "CVE-2025-13836" # Python 3.13 HTTP response reading OOM/DoS - no fix available in base image - "CVE-2025-12084" # Python 3.13 xml.dom.minidom quadratic algorithm - no fix available in base image - "CVE-2025-60876" # BusyBox wget HTTP request splitting - no fix available in Chainguard Wolfi base image - "CVE-2026-0861" # Wolfi glibc still flagged even on 2.42-r5; upstream patched build unavailable yet - "CVE-2010-4756" # glibc glob DoS - awaiting patched Wolfi glibc build - "CVE-2019-1010022" # glibc stack guard bypass - awaiting patched Wolfi glibc build - "CVE-2019-1010023" # glibc ldd remap issue - awaiting patched Wolfi glibc build - "CVE-2019-1010024" # glibc ASLR mitigation bypass - awaiting patched Wolfi glibc build - "CVE-2019-1010025" # glibc pthread heap address leak - awaiting patched Wolfi glibc build - "CVE-2026-22184" # zlib untgz buffer overflow - untgz unused + no fixed Wolfi build yet - "GHSA-58pv-8j8x-9vj2" # jaraco.context path traversal - setuptools vendored only (v5.3.0), not used in application code (using v6.1.0+) - "GHSA-34x7-hfp2-rc4v" # node-tar hardlink path traversal - not applicable, tar CLI not exposed in application code - "GHSA-r6q2-hw4h-h46w" # node-tar not used by application runtime, Linux-only container, not affect by macOS APFS-specific exploit - "GHSA-8rrh-rw8j-w5fx" # wheel is from chainguard and will be handled by then TODO: Remove this after Chainguard updates the wheel - "CVE-2025-59465" # Node only used for Admin UI build/prisma - "CVE-2025-55131" # Node only used for Admin UI build/prisma - "CVE-2025-59466" # Node only used for Admin UI build/prisma - "CVE-2025-55130" # Node only used for Admin UI build/prisma - "CVE-2025-59467" # Node only used for Admin UI build/prisma - "CVE-2026-21637" # Node only used for Admin UI build/prisma - "CVE-2025-55132" # Node only used for Admin UI build/prisma - "GHSA-hx9q-6w63-j58v" # orjson dumps recursion; allowlisted - "CVE-2025-15281" # No fix available yet - "CVE-2026-0865" # No fix available yet - "CVE-2025-15282" # No fix available yet - "CVE-2026-0672" # No fix available yet - "CVE-2025-15366" # No fix available yet - "CVE-2025-15367" # No fix available yet - "CVE-2025-12781" # No fix available yet - "CVE-2025-11468" # No fix available yet - "CVE-2026-1299" # Python 3.13 email module header injection - not applicable, LiteLLM doesn't use BytesGenerator for email serialization - "CVE-2026-0775" # npm cli incorrect permission assignment - no fix available yet, npm is only used at build/prisma-generate time - "GHSA-3ppc-4f35-3m26" # minimatch ReDoS via repeated wildcards - from nodejs_wheel bundled npm, not used in application runtime code - "GHSA-83g3-92jg-28cx" # tar arbitrary file read/write via hardlink - from nodejs_wheel bundled npm, not used in application runtime code - "CVE-2026-25639" # axios - full fix requires 1.x major version bump; pinned to >=0.30.2 to clear other axios CVEs, upgrade to 1.x in follow-up - "CVE-2026-2297" # Python 3.13 SourcelessFileLoader audit hook bypass - no fix available in base image - "GHSA-qffp-2rhf-9h96" # tar hardlink path traversal - from nodejs_wheel bundled npm, not used in application runtime code - "CVE-2026-2673" # OpenSSL 3.6.1 TLS 1.3 key exchange group negotiation issue - no fix available yet - "CVE-2026-3644" # Python 3.13 vulnerability - no fix available in base image - "CVE-2026-4224" # Python 3.13 Expat parser stack overflow in ElementDeclHandler - no fix available in base image - ) - - # Build JSON array of allowlisted CVE IDs for jq - ALLOWED_IDS_JSON=$(printf '%s\n' "${ALLOWED_CVES[@]}" | jq -R . | jq -s .) - - echo "Checking for vulnerabilities with CVSS score >= 4.0..." - echo "Allowlisted CVEs (ignored in threshold): ${ALLOWED_CVES[*]}" - echo "" - - # Show all high-severity vulnerabilities for transparency - TOTAL_HIGH_SEVERITY=$(grype litellm:latest -o json | jq -r ' - .matches[] - | select(.vulnerability.cvss[]?.metrics.baseScore >= 4.0) - | .vulnerability.id' | wc -l) - - if [ "$TOTAL_HIGH_SEVERITY" -gt 0 ]; then - echo "Total vulnerabilities found with CVSS >= 4.0: $TOTAL_HIGH_SEVERITY" - echo "" - echo "All high-severity vulnerabilities (including allowlisted):" - grype litellm:latest -o json | jq --argjson allow "$ALLOWED_IDS_JSON" -r ' - ["Package", "Version", "Vulnerability ID", "CVSS Score", "Allowlisted"], - (.matches[] - | select(.vulnerability.cvss[]?.metrics.baseScore >= 4.0) - | [.artifact.name, .artifact.version, .vulnerability.id, .vulnerability.cvss[0].metrics.baseScore, (if (.vulnerability.id as $id | $allow | index($id)) then "YES" else "NO" end)]) - | @tsv' | column -t -s $'\t' - echo "" - fi - - HIGH_SEVERITY_COUNT=$(grype litellm:latest -o json | jq --argjson allow "$ALLOWED_IDS_JSON" -r ' - .matches[] - | select(.vulnerability.cvss[]?.metrics.baseScore >= 4.0) - | select((.vulnerability.id as $id | $allow | index($id) | not)) - | .vulnerability.id' | wc -l) - - if [ "$HIGH_SEVERITY_COUNT" -gt 0 ]; then - echo "" - echo "==========================================" - echo "ERROR: Security Scan Failed" - echo "==========================================" - echo "Found $HIGH_SEVERITY_COUNT non-allowlisted vulnerabilities with CVSS score >= 4.0 in litellm:latest" - echo "" - echo "These vulnerabilities are NOT in the allowlist and must be addressed." - echo "Current allowlisted CVEs: ${ALLOWED_CVES[*]}" - echo "" - echo "Detailed vulnerability report:" - echo "" - grype litellm:latest -o json | jq --argjson allow "$ALLOWED_IDS_JSON" -r ' - ["Package", "Version", "Vulnerability ID", "CVSS Score", "Severity", "Fix Version", "Description"], - (.matches[] - | select(.vulnerability.cvss[]?.metrics.baseScore >= 4.0) - | select((.vulnerability.id as $id | $allow | index($id) | not)) - | [.artifact.name, .artifact.version, .vulnerability.id, .vulnerability.cvss[0].metrics.baseScore, .vulnerability.severity, (.vulnerability.fix.versions[0] // "No fix available"), .vulnerability.description]) - | @tsv' | column -t -s $'\t' - echo "" - echo "==========================================" - echo "Action Required:" - echo "==========================================" - echo "1. If a fix is available, update the package to the fixed version" - echo "2. If the vulnerability is not applicable or has no fix:" - echo " - Add the CVE/GHSA ID to ALLOWED_CVES array in ci_cd/security_scans.sh" - echo " - Add a comment explaining why it's safe to ignore" - echo "" - echo "Note: Some vulnerabilities may have multiple IDs (CVE-XXXX and GHSA-XXXX)." - echo "Add all relevant IDs to the allowlist if they refer to the same issue." - echo "==========================================" - echo "" - exit 1 - else - echo "No high-severity vulnerabilities (CVSS >= 4.0) found in litellm:latest" - fi - - echo "Grype scans completed successfully" -} - -# Main execution -main() { - echo "Installing security scanning tools..." - install_trivy - install_grype - - # echo "Running secret detection scans..." - # run_secret_detection - - echo "Running filesystem vulnerability scans..." - run_trivy_scans - - echo "Running Docker image vulnerability scans..." - run_grype_scans - - echo "All security scans completed successfully!" -} - -# Execute main function -main "$@" diff --git a/docker/build_admin_ui.sh b/docker/build_admin_ui.sh index 5373ad0e3d..efb2bac353 100755 --- a/docker/build_admin_ui.sh +++ b/docker/build_admin_ui.sh @@ -40,11 +40,22 @@ else exit 1 fi fi -curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.38.0/install.sh | bash +NVM_VERSION="v0.40.4" +NVM_CHECKSUM="4b7412c49960c7d31e8df72da90c1fb5b8cccb419ac99537b737028d497aba4f" +NVM_SCRIPT=$(mktemp) +trap 'rm -f "$NVM_SCRIPT"' EXIT +curl -fsSL "https://raw.githubusercontent.com/nvm-sh/nvm/${NVM_VERSION}/install.sh" -o "$NVM_SCRIPT" +if command -v sha256sum &>/dev/null; then + echo "${NVM_CHECKSUM} ${NVM_SCRIPT}" | sha256sum -c - +elif command -v shasum &>/dev/null; then + echo "${NVM_CHECKSUM} ${NVM_SCRIPT}" | shasum -a 256 -c - +else + echo "No sha256 tool found; cannot verify nvm checksum"; exit 1 +fi || { echo "nvm checksum verification failed"; exit 1; } +bash "$NVM_SCRIPT" source ~/.nvm/nvm.sh nvm install v18.17.0 nvm use v18.17.0 -npm install -g npm # copy _enterprise.json from this directory to /ui/litellm-dashboard, and rename it to ui_colors.json cp enterprise/enterprise_ui/enterprise_colors.json ui/litellm-dashboard/ui_colors.json diff --git a/docs/my-website/.trivyignore b/docs/my-website/.trivyignore deleted file mode 100644 index 977504f267..0000000000 --- a/docs/my-website/.trivyignore +++ /dev/null @@ -1,7 +0,0 @@ -# js-yaml CVE-2025-64718 -# This vulnerability is not applicable because we've forced js-yaml to version 4.1.1 -# via npm overrides in package.json. Trivy incorrectly reports this based on -# dependency requirements in the lockfile, but the actual installed version is 4.1.1. -# Verified with: npm list js-yaml -CVE-2025-64718 - diff --git a/scripts/install.sh b/scripts/install.sh index b9912287b7..03ae31cd18 100755 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -83,8 +83,8 @@ echo "" header "Installing litellm[proxy]…" echo "" -"$PYTHON_BIN" -m pip install --upgrade "${LITELLM_PACKAGE}" \ - || die "pip install failed. Try manually: $PYTHON_BIN -m pip install '${LITELLM_PACKAGE}'" +"$PYTHON_BIN" -m pip install --only-binary :all: --upgrade "${LITELLM_PACKAGE}" \ + || die "pip install failed. Try manually: $PYTHON_BIN -m pip install --only-binary :all: '${LITELLM_PACKAGE}'" # ── find the litellm binary installed by pip for this Python ─────────────── # sysconfig.get_path('scripts') is where pip puts console scripts — reliable diff --git a/ui/litellm-dashboard/.trivyignore b/ui/litellm-dashboard/.trivyignore deleted file mode 100644 index 977504f267..0000000000 --- a/ui/litellm-dashboard/.trivyignore +++ /dev/null @@ -1,7 +0,0 @@ -# js-yaml CVE-2025-64718 -# This vulnerability is not applicable because we've forced js-yaml to version 4.1.1 -# via npm overrides in package.json. Trivy incorrectly reports this based on -# dependency requirements in the lockfile, but the actual installed version is 4.1.1. -# Verified with: npm list js-yaml -CVE-2025-64718 - diff --git a/ui/litellm-dashboard/build_ui.sh b/ui/litellm-dashboard/build_ui.sh index cd6ec90190..aa346c12ed 100755 --- a/ui/litellm-dashboard/build_ui.sh +++ b/ui/litellm-dashboard/build_ui.sh @@ -2,8 +2,20 @@ # Check if nvm is not installed if ! command -v nvm &> /dev/null; then - # Install nvm - curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.38.0/install.sh | bash + # Install nvm with checksum verification + NVM_VERSION="v0.40.4" + NVM_CHECKSUM="4b7412c49960c7d31e8df72da90c1fb5b8cccb419ac99537b737028d497aba4f" + NVM_SCRIPT=$(mktemp) + trap 'rm -f "$NVM_SCRIPT"' EXIT + curl -fsSL "https://raw.githubusercontent.com/nvm-sh/nvm/${NVM_VERSION}/install.sh" -o "$NVM_SCRIPT" + if command -v sha256sum &>/dev/null; then + echo "${NVM_CHECKSUM} ${NVM_SCRIPT}" | sha256sum -c - + elif command -v shasum &>/dev/null; then + echo "${NVM_CHECKSUM} ${NVM_SCRIPT}" | shasum -a 256 -c - + else + echo "No sha256 tool found; cannot verify nvm checksum"; exit 1 + fi || { echo "nvm checksum verification failed"; exit 1; } + bash "$NVM_SCRIPT" # Source nvm script in the current session export NVM_DIR="$HOME/.nvm" diff --git a/ui/litellm-dashboard/build_ui_custom_path.sh b/ui/litellm-dashboard/build_ui_custom_path.sh index f947f87d3b..a92927f8ea 100755 --- a/ui/litellm-dashboard/build_ui_custom_path.sh +++ b/ui/litellm-dashboard/build_ui_custom_path.sh @@ -12,8 +12,20 @@ UI_BASE_PATH="$1" # Check if nvm is not installed if ! command -v nvm &> /dev/null; then - # Install nvm - curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.38.0/install.sh | bash + # Install nvm with checksum verification + NVM_VERSION="v0.40.4" + NVM_CHECKSUM="4b7412c49960c7d31e8df72da90c1fb5b8cccb419ac99537b737028d497aba4f" + NVM_SCRIPT=$(mktemp) + trap 'rm -f "$NVM_SCRIPT"' EXIT + curl -fsSL "https://raw.githubusercontent.com/nvm-sh/nvm/${NVM_VERSION}/install.sh" -o "$NVM_SCRIPT" + if command -v sha256sum &>/dev/null; then + echo "${NVM_CHECKSUM} ${NVM_SCRIPT}" | sha256sum -c - + elif command -v shasum &>/dev/null; then + echo "${NVM_CHECKSUM} ${NVM_SCRIPT}" | shasum -a 256 -c - + else + echo "No sha256 tool found; cannot verify nvm checksum"; exit 1 + fi || { echo "nvm checksum verification failed"; exit 1; } + bash "$NVM_SCRIPT" # Source nvm script in the current session export NVM_DIR="$HOME/.nvm" From 10bd3ff5d60671c9bff5c208edd5570331cf369a Mon Sep 17 00:00:00 2001 From: Chesars Date: Wed, 15 Apr 2026 23:29:34 -0300 Subject: [PATCH 18/21] Fix three bugs introduced by staging PRs - factory.py: fix _sort_bedrock_assistant_content_blocks to treat cachePoint blocks with the same sort key as toolUse so Python's stable sort keeps each cachePoint paired with its preceding toolUse block (PR #24368) - responses/transformation.py: remove cyclic import of OpenAIGPT5Config inside map_openai_params; add _is_gpt_5_model and _supports_reasoning_effort_none static methods that replicate the same logic without the import cycle. _is_gpt_5_model now also excludes pass-through models from other providers (e.g. perplexity/openai/gpt-5.2) that contain 'gpt-5' in their name but should not be subject to OpenAI GPT-5 temperature restrictions (PR #24371) --- .../prompt_templates/factory.py | 4 +++ .../llms/openai/responses/transformation.py | 31 +++++++++++++++---- 2 files changed, 29 insertions(+), 6 deletions(-) diff --git a/litellm/litellm_core_utils/prompt_templates/factory.py b/litellm/litellm_core_utils/prompt_templates/factory.py index 18a4726e3b..4146689cc0 100644 --- a/litellm/litellm_core_utils/prompt_templates/factory.py +++ b/litellm/litellm_core_utils/prompt_templates/factory.py @@ -4076,6 +4076,10 @@ def _sort_bedrock_assistant_content_blocks( return 0 if "toolUse" in block: return 2 + if "cachePoint" in block: + # cachePoint blocks are paired with their preceding toolUse block. + # Same key as toolUse so Python's stable sort keeps them together. + return 2 return 1 return sorted(blocks, key=_sort_key) diff --git a/litellm/llms/openai/responses/transformation.py b/litellm/llms/openai/responses/transformation.py index 03e09b039d..83e1d6c386 100644 --- a/litellm/llms/openai/responses/transformation.py +++ b/litellm/llms/openai/responses/transformation.py @@ -35,6 +35,29 @@ class OpenAIResponsesAPIConfig(BaseResponsesAPIConfig): def supports_native_file_search(self) -> bool: return True + @staticmethod + def _is_gpt_5_model(model: str) -> bool: + """Return True only for actual OpenAI GPT-5 models. + + Excludes pass-through models from other providers that happen to + reference gpt-5 in their name (e.g. perplexity/openai/gpt-5.2). + """ + parts = model.split("/") + if len(parts) > 1 and parts[0] not in ("openai",): + return False + return "gpt-5" in model and "gpt-5-chat" not in model + + @staticmethod + def _supports_reasoning_effort_none(model: str) -> bool: + """Return True if the model supports reasoning.effort='none'.""" + from litellm.utils import _supports_factory + + return _supports_factory( + model=model, + custom_llm_provider=None, + key="supports_none_reasoning_effort", + ) + def get_supported_openai_params(self, model: str) -> list: """ All OpenAI Responses API params are supported @@ -66,18 +89,14 @@ class OpenAIResponsesAPIConfig(BaseResponsesAPIConfig): is accepted unless reasoning_effort='none' on models that support it). Apply the same validation used by the chat completions path. """ - from litellm.llms.openai.chat.gpt_5_transformation import OpenAIGPT5Config - params = dict(response_api_optional_params) - if OpenAIGPT5Config.is_model_gpt_5_model(model=model): + if self._is_gpt_5_model(model=model): temperature = params.get("temperature") if temperature is not None and temperature != 1: reasoning = params.get("reasoning") or {} effort = reasoning.get("effort") if isinstance(reasoning, dict) else None - supports_none = OpenAIGPT5Config._supports_reasoning_effort_level( - model=model, level="none" - ) + supports_none = self._supports_reasoning_effort_none(model=model) if supports_none and (effort == "none" or effort is None): pass # flexible temperature allowed elif drop_params or litellm.drop_params: From 83d0f28aa858d340af9f6f6381b329dc9cce78f7 Mon Sep 17 00:00:00 2001 From: Sameer Kankute Date: Thu, 16 Apr 2026 19:02:29 +0530 Subject: [PATCH 19/21] Fix tests --- tests/llm_translation/test_gpt4o_audio.py | 4 ++- tests/local_testing/test_completion.py | 8 +++++- tests/local_testing/test_streaming.py | 32 ++++++++++++++--------- 3 files changed, 29 insertions(+), 15 deletions(-) diff --git a/tests/llm_translation/test_gpt4o_audio.py b/tests/llm_translation/test_gpt4o_audio.py index f41dabb666..d60595463b 100644 --- a/tests/llm_translation/test_gpt4o_audio.py +++ b/tests/llm_translation/test_gpt4o_audio.py @@ -34,8 +34,10 @@ async def check_streaming_response(completion): _audio_id = None async for chunk in completion: print(chunk) + if len(chunk.choices) == 0: + continue _choice: StreamingChoices = chunk.choices[0] - if _choice.delta.audio is not None: + if _choice.delta is not None and _choice.delta.audio is not None: if _choice.delta.audio.get("data") is not None: _audio_bytes = _choice.delta.audio["data"] if _choice.delta.audio.get("transcript") is not None: diff --git a/tests/local_testing/test_completion.py b/tests/local_testing/test_completion.py index f18a2b4afb..55ce56adf9 100644 --- a/tests/local_testing/test_completion.py +++ b/tests/local_testing/test_completion.py @@ -1759,8 +1759,14 @@ def test_completion_logprobs_stream(): for chunk in response: # check if atleast one chunk has log probs print(chunk) + if len(chunk.choices) == 0: + continue print(f"chunk.choices[0]: {chunk.choices[0]}") - if "logprobs" in chunk.choices[0]: + if ( + "logprobs" in chunk.choices[0] + and chunk.choices[0].logprobs is not None + and len(chunk.choices[0].logprobs.content) > 0 + ): # assert we got a valid logprob in the choices assert len(chunk.choices[0].logprobs.content[0].top_logprobs) == 3 found_logprob = True diff --git a/tests/local_testing/test_streaming.py b/tests/local_testing/test_streaming.py index 3aed069960..bf374ff857 100644 --- a/tests/local_testing/test_streaming.py +++ b/tests/local_testing/test_streaming.py @@ -831,23 +831,29 @@ def test_completion_mistral_api_mistral_large_function_call_with_streaming(): tool_choice="auto", stream=True, ) - idx = 0 + saw_function_call_chunk = False for chunk in response: print(f"chunk in response: {chunk}") assert chunk._hidden_params["custom_llm_provider"] == "mistral" - if idx == 0: - assert ( - chunk.choices[0].delta.tool_calls[0].function.arguments is not None - ) - assert isinstance( - chunk.choices[0].delta.tool_calls[0].function.arguments, str - ) - validate_first_streaming_function_calling_chunk(chunk=chunk) - elif idx == 1 and chunk.choices[0].finish_reason is None: - validate_second_streaming_function_calling_chunk(chunk=chunk) - elif chunk.choices[0].finish_reason is not None: # last chunk + if len(chunk.choices) == 0: + continue + if chunk.choices[0].finish_reason is not None: # last chunk validate_final_streaming_function_calling_chunk(chunk=chunk) - idx += 1 + break + tool_calls = chunk.choices[0].delta.tool_calls + if tool_calls is None: + continue + assert tool_calls[0].function.arguments is not None + assert isinstance(tool_calls[0].function.arguments, str) + if not saw_function_call_chunk: + if chunk.choices[0].delta.role is not None: + validate_first_streaming_function_calling_chunk(chunk=chunk) + else: + validate_second_streaming_function_calling_chunk(chunk=chunk) + saw_function_call_chunk = True + else: + validate_second_streaming_function_calling_chunk(chunk=chunk) + assert saw_function_call_chunk except litellm.RateLimitError: pass except Exception as e: From 89767a6e43096ea6a6adca43151dcb5f1b2cb87e Mon Sep 17 00:00:00 2001 From: Sameer Kankute Date: Thu, 16 Apr 2026 19:24:08 +0530 Subject: [PATCH 20/21] litellm_staging_03_22_2026 --- tests/test_litellm/test_utils.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/tests/test_litellm/test_utils.py b/tests/test_litellm/test_utils.py index 0acbe90130..9101c886ad 100644 --- a/tests/test_litellm/test_utils.py +++ b/tests/test_litellm/test_utils.py @@ -812,6 +812,10 @@ def test_aaamodel_prices_and_context_window_json_is_valid(): }, "additionalProperties": False, }, + "web_search_billing_unit": { + "type": "string", + "enum": ["per_prompt", "per_query"], + }, "citation_cost_per_token": {"type": "number"}, "supported_modalities": { "type": "array", From e1e5fcb03ecc749fdfefef0ac5884ac2b2d1db9f Mon Sep 17 00:00:00 2001 From: Chesars Date: Wed, 22 Apr 2026 20:09:53 -0300 Subject: [PATCH 21/21] style: apply black formatting --- litellm/litellm_core_utils/prompt_templates/factory.py | 4 +--- .../litellm_core_utils/streaming_chunk_builder_utils.py | 4 +--- litellm/litellm_core_utils/streaming_handler.py | 8 ++++++-- litellm/llms/openai/responses/transformation.py | 4 +++- litellm/main.py | 4 +--- 5 files changed, 12 insertions(+), 12 deletions(-) diff --git a/litellm/litellm_core_utils/prompt_templates/factory.py b/litellm/litellm_core_utils/prompt_templates/factory.py index dc1b6a9a53..decf5080a8 100644 --- a/litellm/litellm_core_utils/prompt_templates/factory.py +++ b/litellm/litellm_core_utils/prompt_templates/factory.py @@ -5044,9 +5044,7 @@ def _bedrock_converse_messages_pt( # noqa: PLR0915 assistant_content = _deduplicate_bedrock_content_blocks( assistant_content, "toolUse" ) - assistant_content = _sort_bedrock_assistant_content_blocks( - assistant_content - ) + assistant_content = _sort_bedrock_assistant_content_blocks(assistant_content) if assistant_content: contents.append( diff --git a/litellm/litellm_core_utils/streaming_chunk_builder_utils.py b/litellm/litellm_core_utils/streaming_chunk_builder_utils.py index b5f72f0f31..fe7c62c384 100644 --- a/litellm/litellm_core_utils/streaming_chunk_builder_utils.py +++ b/litellm/litellm_core_utils/streaming_chunk_builder_utils.py @@ -119,9 +119,7 @@ class ChunkProcessor: model = ChunkProcessor._get_model_from_chunks(chunks, first_chunk_model) system_fingerprint = chunk.get("system_fingerprint", None) - first_chunk_with_choices = next( - (c for c in chunks if c.get("choices")), chunk - ) + first_chunk_with_choices = next((c for c in chunks if c.get("choices")), chunk) role = first_chunk_with_choices["choices"][0]["delta"]["role"] finish_reason = "stop" for chunk in chunks: diff --git a/litellm/litellm_core_utils/streaming_handler.py b/litellm/litellm_core_utils/streaming_handler.py index 1dccdbdfa8..e281b17268 100644 --- a/litellm/litellm_core_utils/streaming_handler.py +++ b/litellm/litellm_core_utils/streaming_handler.py @@ -1872,7 +1872,9 @@ class CustomStreamWrapper: if response.choices: choice = response.choices[0] if isinstance(choice, StreamingChoices): - self.response_uptil_now += choice.delta.get("content", "") or "" + self.response_uptil_now += ( + choice.delta.get("content", "") or "" + ) else: self.response_uptil_now += "" self.rules.post_call_rules( @@ -2053,7 +2055,9 @@ class CustomStreamWrapper: if processed_chunk.choices: choice = processed_chunk.choices[0] if isinstance(choice, StreamingChoices): - self.response_uptil_now += choice.delta.get("content", "") or "" + self.response_uptil_now += ( + choice.delta.get("content", "") or "" + ) else: self.response_uptil_now += "" self.rules.post_call_rules( diff --git a/litellm/llms/openai/responses/transformation.py b/litellm/llms/openai/responses/transformation.py index 83e1d6c386..87c502032c 100644 --- a/litellm/llms/openai/responses/transformation.py +++ b/litellm/llms/openai/responses/transformation.py @@ -95,7 +95,9 @@ class OpenAIResponsesAPIConfig(BaseResponsesAPIConfig): temperature = params.get("temperature") if temperature is not None and temperature != 1: reasoning = params.get("reasoning") or {} - effort = reasoning.get("effort") if isinstance(reasoning, dict) else None + effort = ( + reasoning.get("effort") if isinstance(reasoning, dict) else None + ) supports_none = self._supports_reasoning_effort_none(model=model) if supports_none and (effort == "none" or effort is None): pass # flexible temperature allowed diff --git a/litellm/main.py b/litellm/main.py index d95c38f345..2889825e6e 100644 --- a/litellm/main.py +++ b/litellm/main.py @@ -7389,9 +7389,7 @@ def stream_chunk_builder( # noqa: PLR0915 if len(chunks) == 0: return None ## Route to the text completion logic - first_chunk_with_choices = next( - (c for c in chunks if c["choices"]), None - ) + first_chunk_with_choices = next((c for c in chunks if c["choices"]), None) if first_chunk_with_choices is not None and isinstance( first_chunk_with_choices["choices"][0], litellm.utils.TextChoices ): # route to the text completion logic