From 0c48826cdc14a30953f55173c1eecdbfc859952d Mon Sep 17 00:00:00 2001 From: Alexsander Hamir Date: Thu, 18 Dec 2025 14:46:09 -0800 Subject: [PATCH] Revert "[Fix] CI/CD - local_testing & mapped tests (#18222)" (#18223) This reverts commit dc7f500c472bf06f956570d5f538fcada0024498. --- .circleci/config.yml | 40 ++++++++++++++++++- litellm/llms/anthropic/chat/handler.py | 9 +---- litellm/llms/anthropic/chat/transformation.py | 25 ++---------- litellm/llms/azure/chat/o_series_handler.py | 23 ----------- litellm/llms/azure/common_utils.py | 16 -------- litellm/llms/azure/completion/handler.py | 33 ++------------- litellm/llms/base_llm/base_utils.py | 25 ++---------- litellm/proxy/db/db_spend_update_writer.py | 23 ++++++++++- .../transformation.py | 10 ++--- .../test_bedrock_image_gen_unit_tests.py | 1 - ...est_anthropic_tool_result_empty_call_id.py | 18 ++++----- tests/local_testing/test_completion.py | 5 +-- 12 files changed, 84 insertions(+), 144 deletions(-) diff --git a/.circleci/config.yml b/.circleci/config.yml index 9f32413031e..7a982d74cbe 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -3912,10 +3912,46 @@ workflows: - publish_to_pypi: requires: - mypy_linting + - local_testing + - build_and_test + - e2e_openai_endpoints - test_bad_database_url + - llm_translation_testing - mcp_testing - google_generate_content_endpoint_testing - + - llm_responses_api_testing + - ocr_testing + - search_testing + - litellm_mapped_tests_proxy + - litellm_mapped_tests_llms + - litellm_mapped_tests_core + - 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 + - db_migration_disable_update_check + - e2e_ui_testing + - 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/litellm/llms/anthropic/chat/handler.py b/litellm/llms/anthropic/chat/handler.py index 66c093a7831..53563ef9b4f 100644 --- a/litellm/llms/anthropic/chat/handler.py +++ b/litellm/llms/anthropic/chat/handler.py @@ -565,13 +565,6 @@ class ModelResponseIterator: # web_search_tool_result blocks also have input_json_delta but should not be treated as tool calls # See: https://github.com/BerriAI/litellm/issues/17254 if self.current_content_block_type in ("tool_use", "server_tool_use"): - # Get partial_json and ensure it's a string (handle None case) - partial_json = content_block["delta"].get("partial_json", "") - if partial_json is None: - partial_json = "" - elif not isinstance(partial_json, str): - partial_json = str(partial_json) - tool_use = cast( ChatCompletionToolCallChunk, { @@ -579,7 +572,7 @@ class ModelResponseIterator: "type": "function", "function": { "name": None, - "arguments": partial_json, + "arguments": content_block["delta"]["partial_json"], }, "index": self.tool_index, }, diff --git a/litellm/llms/anthropic/chat/transformation.py b/litellm/llms/anthropic/chat/transformation.py index 7ceab97cba3..6bdc17f7979 100644 --- a/litellm/llms/anthropic/chat/transformation.py +++ b/litellm/llms/anthropic/chat/transformation.py @@ -1489,26 +1489,7 @@ class AnthropicConfig(AnthropicModelInfo, BaseConfig): ) try: if json_mode_content_str is not None: - # Try to parse JSON, handling cases where there might be extra data - try: - args = json.loads(json_mode_content_str) - except json.JSONDecodeError as e: - # If there's extra data, try to extract just the first valid JSON object - # by finding where the first complete JSON object ends - if "Extra data" in str(e): - # Find the position where the error occurred - error_pos = getattr(e, "pos", None) - if error_pos and error_pos < len(json_mode_content_str): - # Try to parse just the valid portion - try: - args = json.loads(json_mode_content_str[:error_pos]) - except (json.JSONDecodeError, ValueError): - # If that fails, return the original string - return litellm.Message(content=json_mode_content_str) - else: - # For other JSON errors, return the original string - return litellm.Message(content=json_mode_content_str) - + args = json.loads(json_mode_content_str) if ( isinstance(args, dict) and (values := args.get("values")) is not None @@ -1520,9 +1501,9 @@ class AnthropicConfig(AnthropicModelInfo, BaseConfig): # relevant issue: https://github.com/BerriAI/litellm/issues/6741 _message = litellm.Message(content=json.dumps(args)) return _message - except (json.JSONDecodeError, ValueError, TypeError): + except json.JSONDecodeError: # json decode error does occur, return the original tool response str - return litellm.Message(content=json_mode_content_str) if json_mode_content_str else None + return litellm.Message(content=json_mode_content_str) return None def get_error_class( diff --git a/litellm/llms/azure/chat/o_series_handler.py b/litellm/llms/azure/chat/o_series_handler.py index 9f28eacb1c5..d0f5153b0eb 100644 --- a/litellm/llms/azure/chat/o_series_handler.py +++ b/litellm/llms/azure/chat/o_series_handler.py @@ -7,7 +7,6 @@ Written separately to handle faking streaming for o1 and o3 models. from typing import TYPE_CHECKING, Any, Callable, Optional, Union import httpx -from openai import AsyncAzureOpenAI, AzureOpenAI from litellm.types.utils import ModelResponse @@ -19,28 +18,6 @@ if TYPE_CHECKING: class AzureOpenAIO1ChatCompletion(BaseAzureLLM, OpenAIChatCompletion): - def _set_dynamic_params_on_client( - self, - client: Union[AzureOpenAI, AsyncAzureOpenAI], - max_retries: Optional[int] = None, - ): - """ - Set dynamic parameters on an existing client. - - This method overrides BaseAzureLLM._set_dynamic_params_on_client to match - its signature exactly (since BaseAzureLLM is first in MRO). This works with - OpenAIChatCompletion's calls that use keyword arguments (organization parameter - will be ignored as it's not in this signature, which is correct since Azure - clients don't support organization). - - Args: - client: The Azure OpenAI client - max_retries: Maximum number of retries - """ - # Only set max_retries (Azure clients don't support organization parameter) - if max_retries is not None: - client.max_retries = max_retries - def completion( self, model_response: ModelResponse, diff --git a/litellm/llms/azure/common_utils.py b/litellm/llms/azure/common_utils.py index a92f51e5768..85596a628da 100644 --- a/litellm/llms/azure/common_utils.py +++ b/litellm/llms/azure/common_utils.py @@ -402,15 +402,6 @@ def get_azure_ad_token( class BaseAzureLLM(BaseOpenAILLM): - def _set_dynamic_params_on_client( - self, - client: Union[AzureOpenAI, AsyncAzureOpenAI], - max_retries: Optional[int] = None, - ): - """Set dynamic parameters on an existing Azure OpenAI client.""" - if max_retries is not None: - client.max_retries = max_retries - @staticmethod def _try_get_default_azure_credential_provider( scope: str, @@ -486,13 +477,6 @@ class BaseAzureLLM(BaseOpenAILLM): ): # set api_version to version passed by user openai_client._custom_query.setdefault("api-version", api_version) - - # Set dynamic parameters on existing client (e.g., max_retries) - max_retries = litellm_params.get("max_retries") if litellm_params else None - self._set_dynamic_params_on_client( - client=openai_client, - max_retries=max_retries, - ) # save client in-memory cache self.set_cached_openai_client( diff --git a/litellm/llms/azure/completion/handler.py b/litellm/llms/azure/completion/handler.py index 22ac802ef7f..05d5e2f6c68 100644 --- a/litellm/llms/azure/completion/handler.py +++ b/litellm/llms/azure/completion/handler.py @@ -144,17 +144,12 @@ class AzureTextCompletion(BaseAzureLLM): status_code=422, message="max retries must be an int" ) # init AzureOpenAI Client - # Pass max_retries to litellm_params so it gets passed to initialize_azure_sdk_client - litellm_params_with_max_retries = litellm_params.copy() if litellm_params else {} - litellm_params_with_max_retries["max_retries"] = max_retries - if timeout is not None: - litellm_params_with_max_retries["timeout"] = timeout azure_client = self.get_azure_openai_client( api_key=api_key, api_base=api_base, api_version=api_version, client=client, - litellm_params=litellm_params_with_max_retries, + litellm_params=litellm_params, _is_async=False, model=model, ) @@ -218,11 +213,6 @@ class AzureTextCompletion(BaseAzureLLM): try: # init AzureOpenAI Client # setting Azure client - # Pass max_retries to litellm_params so it gets passed to initialize_azure_sdk_client - litellm_params_with_max_retries = litellm_params.copy() if litellm_params else {} - litellm_params_with_max_retries["max_retries"] = max_retries - if timeout is not None: - litellm_params_with_max_retries["timeout"] = timeout azure_client = self.get_azure_openai_client( api_version=api_version, api_base=api_base, @@ -230,7 +220,7 @@ class AzureTextCompletion(BaseAzureLLM): model=model, _is_async=True, client=client, - litellm_params=litellm_params_with_max_retries, + litellm_params=litellm_params, ) if not isinstance(azure_client, AsyncAzureOpenAI): raise AzureOpenAIError( @@ -288,11 +278,6 @@ class AzureTextCompletion(BaseAzureLLM): status_code=422, message="max retries must be an int" ) # init AzureOpenAI Client - # Pass max_retries to litellm_params so it gets passed to initialize_azure_sdk_client - litellm_params_with_max_retries = litellm_params.copy() if litellm_params else {} - litellm_params_with_max_retries["max_retries"] = max_retries - if timeout is not None: - litellm_params_with_max_retries["timeout"] = timeout azure_client = self.get_azure_openai_client( api_version=api_version, api_base=api_base, @@ -300,7 +285,7 @@ class AzureTextCompletion(BaseAzureLLM): model=model, _is_async=False, client=client, - litellm_params=litellm_params_with_max_retries, + litellm_params=litellm_params, ) if not isinstance(azure_client, AzureOpenAI): raise AzureOpenAIError( @@ -345,17 +330,7 @@ class AzureTextCompletion(BaseAzureLLM): litellm_params: dict = {}, ): try: - max_retries = data.pop("max_retries", 2) - if not isinstance(max_retries, int): - raise AzureOpenAIError( - status_code=422, message="max retries must be an int" - ) # init AzureOpenAI Client - # Pass max_retries to litellm_params so it gets passed to initialize_azure_sdk_client - litellm_params_with_max_retries = litellm_params.copy() if litellm_params else {} - litellm_params_with_max_retries["max_retries"] = max_retries - if timeout is not None: - litellm_params_with_max_retries["timeout"] = timeout azure_client = self.get_azure_openai_client( api_version=api_version, api_base=api_base, @@ -363,7 +338,7 @@ class AzureTextCompletion(BaseAzureLLM): model=model, _is_async=True, client=client, - litellm_params=litellm_params_with_max_retries, + litellm_params=litellm_params, ) if not isinstance(azure_client, AsyncAzureOpenAI): raise AzureOpenAIError( diff --git a/litellm/llms/base_llm/base_utils.py b/litellm/llms/base_llm/base_utils.py index a253ab6d307..9172a05e385 100644 --- a/litellm/llms/base_llm/base_utils.py +++ b/litellm/llms/base_llm/base_utils.py @@ -115,26 +115,7 @@ def _convert_tool_response_to_message( json_mode_content_str: Optional[str] = tool_calls[0]["function"].get("arguments") try: if json_mode_content_str is not None: - # Try to parse JSON, handling cases where there might be extra data - try: - args = json.loads(json_mode_content_str) - except json.JSONDecodeError as e: - # If there's extra data, try to extract just the first valid JSON object - # by finding where the first complete JSON object ends - if "Extra data" in str(e): - # Find the position where the error occurred - error_pos = getattr(e, "pos", None) - if error_pos and error_pos < len(json_mode_content_str): - # Try to parse just the valid portion - try: - args = json.loads(json_mode_content_str[:error_pos]) - except (json.JSONDecodeError, ValueError): - # If that fails, return the original string - return Message(content=json_mode_content_str) - else: - # For other JSON errors, return the original string - return Message(content=json_mode_content_str) - + args = json.loads(json_mode_content_str) if isinstance(args, dict) and (values := args.get("values")) is not None: _message = Message(content=json.dumps(values)) return _message @@ -143,9 +124,9 @@ def _convert_tool_response_to_message( # relevant issue: https://github.com/BerriAI/litellm/issues/6741 _message = Message(content=json.dumps(args)) return _message - except (json.JSONDecodeError, ValueError, TypeError): + except json.JSONDecodeError: # json decode error does occur, return the original tool response str - return Message(content=json_mode_content_str) if json_mode_content_str else None + return Message(content=json_mode_content_str) return None diff --git a/litellm/proxy/db/db_spend_update_writer.py b/litellm/proxy/db/db_spend_update_writer.py index da91790b941..5c5cd7c19f7 100644 --- a/litellm/proxy/db/db_spend_update_writer.py +++ b/litellm/proxy/db/db_spend_update_writer.py @@ -13,7 +13,7 @@ import random import time import traceback from datetime import datetime, timedelta -from typing import TYPE_CHECKING, Any, Dict, Literal, Optional, Union, cast, overload +from typing import TYPE_CHECKING, Any, Dict, List, Literal, Optional, Union, cast, overload import litellm from litellm._logging import verbose_proxy_logger @@ -869,6 +869,14 @@ class DBSpendUpdateWriter: team_member_list_transactions is not None and len(team_member_list_transactions.keys()) > 0 ): + # Track which team memberships will be updated for cache invalidation + team_memberships_to_invalidate: List[tuple[str, str]] = [] + for key in team_member_list_transactions.keys(): + # key is "team_id::::user_id::" + team_id = key.split("::")[1] + user_id = key.split("::")[3] + team_memberships_to_invalidate.append((user_id, team_id)) + for i in range(n_retry_times + 1): start_time = time.time() try: @@ -888,6 +896,7 @@ class DBSpendUpdateWriter: where={"team_id": team_id, "user_id": user_id}, data={"spend": {"increment": response_cost}}, ) + # Transaction succeeded, break out of retry loop break except DB_CONNECTION_ERROR_TYPES as e: if ( @@ -904,6 +913,18 @@ class DBSpendUpdateWriter: _raise_failed_update_spend_exception( e=e, start_time=start_time, proxy_logging_obj=proxy_logging_obj ) + + # Invalidate cache for updated team memberships + # This ensures budget checks read fresh spend data from the database + if team_memberships_to_invalidate and proxy_logging_obj is not None: + user_api_key_cache = proxy_logging_obj.call_details.get("user_api_key_cache") + if user_api_key_cache is not None: + for user_id, team_id in team_memberships_to_invalidate: + cache_key = "team_membership:{}:{}".format(user_id, team_id) + await user_api_key_cache.async_delete_cache(key=cache_key) + verbose_proxy_logger.debug( + f"Invalidated team membership cache for user_id={user_id}, team_id={team_id}" + ) ### UPDATE ORG TABLE ### org_list_transactions = db_spend_update_transactions["org_list_transactions"] diff --git a/litellm/responses/litellm_completion_transformation/transformation.py b/litellm/responses/litellm_completion_transformation/transformation.py index 2bc59f563fd..af0847e3e24 100644 --- a/litellm/responses/litellm_completion_transformation/transformation.py +++ b/litellm/responses/litellm_completion_transformation/transformation.py @@ -713,11 +713,11 @@ class LiteLLMCompletionResponsesConfig: """ ChatCompletionToolMessage is used to indicate the output from a tool call """ - call_id = tool_call_output.get("call_id") or "" - # If call_id is missing or empty, create message with empty tool_call_id - # This allows _ensure_tool_results_have_corresponding_tool_calls to try to recover it - # from session messages or previous assistant messages - # Only skip if we're certain it can't be recovered (which we can't know here) + call_id = tool_call_output.get("call_id") + # If call_id is missing or empty, skip this message + # Empty call_id means we can't create a valid tool message + if not call_id: + return [] tool_output_message = ChatCompletionToolMessage( role="tool", diff --git a/tests/image_gen_tests/test_bedrock_image_gen_unit_tests.py b/tests/image_gen_tests/test_bedrock_image_gen_unit_tests.py index 67a5d5dd139..73331547772 100644 --- a/tests/image_gen_tests/test_bedrock_image_gen_unit_tests.py +++ b/tests/image_gen_tests/test_bedrock_image_gen_unit_tests.py @@ -527,7 +527,6 @@ def test_backward_compatibility_regular_nova_model(): assert result["imageGenerationConfig"]["cfg_scale"] == 7 -@pytest.mark.skip(reason="amazon.titan-image-generator-v1 has reached end of life and is no longer available") def test_amazon_titan_image_gen(): from litellm import image_generation diff --git a/tests/llm_responses_api_testing/test_anthropic_tool_result_empty_call_id.py b/tests/llm_responses_api_testing/test_anthropic_tool_result_empty_call_id.py index 7eafd4a7e5b..ba2d325f283 100644 --- a/tests/llm_responses_api_testing/test_anthropic_tool_result_empty_call_id.py +++ b/tests/llm_responses_api_testing/test_anthropic_tool_result_empty_call_id.py @@ -24,31 +24,27 @@ from litellm.responses.litellm_completion_transformation.transformation import ( from litellm.llms.anthropic.chat.transformation import AnthropicConfig -def test_empty_tool_call_id_is_created(): +def test_empty_tool_call_id_is_skipped(): """ - Test that tool messages with empty tool_call_id are created (not skipped) + Test that tool messages with empty tool_call_id are skipped when transforming function_call_output to chat completion messages. - This allows the recovery logic to try to recover the call_id from session messages. """ # Simulate a function_call_output with empty call_id (the bug scenario) tool_call_output_empty = { "type": "function_call_output", - "call_id": "", # Empty call_id - will be recovered later if possible + "call_id": "", # Empty call_id - this causes the issue "output": '{"output":"test output","metadata":{"exit_code":0}}' } - # Transform should create a message with empty tool_call_id (not skip it) - # This allows _ensure_tool_results_have_corresponding_tool_calls to try to recover it + # Transform should return empty list (skip the message) result = LiteLLMCompletionResponsesConfig._transform_responses_api_tool_call_output_to_chat_completion_message( tool_call_output_empty ) - assert len(result) == 1, ( - "Tool messages with empty call_id should be created (not skipped) to allow recovery" + assert result == [], ( + "Tool messages with empty call_id should be skipped, not created" ) - assert result[0].get("role") == "tool", "Should be a tool message" - assert result[0].get("tool_call_id") == "", "Should have empty tool_call_id" - print("[OK] Empty call_id messages are correctly created (for recovery)") + print("[OK] Empty call_id messages are correctly skipped") def test_empty_tool_call_id_in_messages_list_is_removed(): diff --git a/tests/local_testing/test_completion.py b/tests/local_testing/test_completion.py index 585e464d048..d06568c8796 100644 --- a/tests/local_testing/test_completion.py +++ b/tests/local_testing/test_completion.py @@ -1761,10 +1761,7 @@ def test_completion_openai_organization(): ) pytest.fail("Request should have failed - This organization does not exist") except Exception as e: - # OpenAI returns 403 error when organization doesn't match API key - # The error message format may vary, so check for 403 or the organization error - error_str = str(e) - assert "403" in error_str or "organization" in error_str.lower() or "header should match organization for API key" in error_str + assert "header should match organization for API key" in str(e) except Exception as e: print(e)