From be2801a415edb45e82672a65b2ebb09f3d4c4af2 Mon Sep 17 00:00:00 2001 From: mateo-berri <277851410+mateo-berri@users.noreply.github.com> Date: Thu, 23 Apr 2026 12:13:18 -0700 Subject: [PATCH 1/3] feat: add gpt-5.5 to model cost map Add gpt-5.5 entry with pricing from OpenAI flagship page: input $5/1M, cached input $0.50/1M, output $30/1M, 272K context. --- ...odel_prices_and_context_window_backup.json | 36 +++++++++++++++++++ model_prices_and_context_window.json | 36 +++++++++++++++++++ 2 files changed, 72 insertions(+) diff --git a/litellm/model_prices_and_context_window_backup.json b/litellm/model_prices_and_context_window_backup.json index 04b68b8f4e..24fa93127e 100644 --- a/litellm/model_prices_and_context_window_backup.json +++ b/litellm/model_prices_and_context_window_backup.json @@ -19226,6 +19226,42 @@ "supports_xhigh_reasoning_effort": true, "supports_minimal_reasoning_effort": true }, + "gpt-5.5": { + "cache_read_input_token_cost": 5e-07, + "input_cost_per_token": 5e-06, + "litellm_provider": "openai", + "max_input_tokens": 272000, + "max_output_tokens": 128000, + "max_tokens": 128000, + "mode": "chat", + "output_cost_per_token": 3e-05, + "supported_endpoints": [ + "/v1/chat/completions", + "/v1/batch", + "/v1/responses" + ], + "supported_modalities": [ + "text", + "image" + ], + "supported_output_modalities": [ + "text" + ], + "supports_function_calling": true, + "supports_native_streaming": true, + "supports_parallel_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_system_messages": true, + "supports_tool_choice": true, + "supports_service_tier": true, + "supports_vision": true, + "supports_none_reasoning_effort": true, + "supports_xhigh_reasoning_effort": true, + "supports_minimal_reasoning_effort": true + }, "gpt-5.4": { "cache_read_input_token_cost": 2.5e-07, "cache_read_input_token_cost_above_272k_tokens": 5e-07, diff --git a/model_prices_and_context_window.json b/model_prices_and_context_window.json index 04b68b8f4e..24fa93127e 100644 --- a/model_prices_and_context_window.json +++ b/model_prices_and_context_window.json @@ -19226,6 +19226,42 @@ "supports_xhigh_reasoning_effort": true, "supports_minimal_reasoning_effort": true }, + "gpt-5.5": { + "cache_read_input_token_cost": 5e-07, + "input_cost_per_token": 5e-06, + "litellm_provider": "openai", + "max_input_tokens": 272000, + "max_output_tokens": 128000, + "max_tokens": 128000, + "mode": "chat", + "output_cost_per_token": 3e-05, + "supported_endpoints": [ + "/v1/chat/completions", + "/v1/batch", + "/v1/responses" + ], + "supported_modalities": [ + "text", + "image" + ], + "supported_output_modalities": [ + "text" + ], + "supports_function_calling": true, + "supports_native_streaming": true, + "supports_parallel_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_system_messages": true, + "supports_tool_choice": true, + "supports_service_tier": true, + "supports_vision": true, + "supports_none_reasoning_effort": true, + "supports_xhigh_reasoning_effort": true, + "supports_minimal_reasoning_effort": true + }, "gpt-5.4": { "cache_read_input_token_cost": 2.5e-07, "cache_read_input_token_cost_above_272k_tokens": 5e-07, From f4f976f0fe9097f4a819ec3a4db35ee12758af8a Mon Sep 17 00:00:00 2001 From: mateo-berri <277851410+mateo-berri@users.noreply.github.com> Date: Thu, 23 Apr 2026 12:16:09 -0700 Subject: [PATCH 2/3] test: add gpt-5.5 coverage for model cost map and gpt-5 routing - Add gpt-5.5 to GPT5_MODELS parametrized list so both OpenAIGPT5Config and AzureOpenAIGPT5Config routing tests cover the new model. - Add test_generic_cost_per_token_gpt55 verifying the new entry's cost-map values ($5/$0.50/$30 per 1M) and that generic_cost_per_token returns the expected prompt/completion costs. --- .../llm_cost_calc/test_llm_cost_calc_utils.py | 37 +++++ .../llms/openai/test_is_model_gpt_5_model.py | 152 ++++++++++++++++++ 2 files changed, 189 insertions(+) create mode 100644 tests/test_litellm/llms/openai/test_is_model_gpt_5_model.py diff --git a/tests/test_litellm/litellm_core_utils/llm_cost_calc/test_llm_cost_calc_utils.py b/tests/test_litellm/litellm_core_utils/llm_cost_calc/test_llm_cost_calc_utils.py index 91b2c49d2b..7144279ad0 100644 --- a/tests/test_litellm/litellm_core_utils/llm_cost_calc/test_llm_cost_calc_utils.py +++ b/tests/test_litellm/litellm_core_utils/llm_cost_calc/test_llm_cost_calc_utils.py @@ -328,6 +328,43 @@ def test_generic_cost_per_token_gpt54_above_272k_tokens(): assert round(completion_cost, 10) == round(expected_completion, 10) +def test_generic_cost_per_token_gpt55(): + """gpt-5.5: base pricing — $5/1M input, $30/1M output, $0.50/1M cached input.""" + model = "gpt-5.5" + custom_llm_provider = "openai" + os.environ["LITELLM_LOCAL_MODEL_COST_MAP"] = "True" + litellm.model_cost = litellm.get_model_cost_map(url="") + + model_cost_map = litellm.model_cost[model] + + # Sanity-check the map values match OpenAI's published pricing. + assert model_cost_map["input_cost_per_token"] == 5e-6 + assert model_cost_map["output_cost_per_token"] == 3e-5 + assert model_cost_map["cache_read_input_token_cost"] == 5e-7 + assert model_cost_map["litellm_provider"] == "openai" + assert model_cost_map["mode"] == "chat" + assert model_cost_map["max_input_tokens"] == 272000 + + prompt_tokens = 1000 + completion_tokens = 500 + usage = Usage( + prompt_tokens=prompt_tokens, + completion_tokens=completion_tokens, + total_tokens=prompt_tokens + completion_tokens, + ) + prompt_cost, completion_cost = generic_cost_per_token( + model=model, + usage=usage, + custom_llm_provider=custom_llm_provider, + ) + assert round(prompt_cost, 10) == round( + model_cost_map["input_cost_per_token"] * prompt_tokens, 10 + ) + assert round(completion_cost, 10) == round( + model_cost_map["output_cost_per_token"] * completion_tokens, 10 + ) + + def test_generic_cost_per_token_anthropic_prompt_caching(): model = "claude-sonnet-4@20250514" usage = Usage( diff --git a/tests/test_litellm/llms/openai/test_is_model_gpt_5_model.py b/tests/test_litellm/llms/openai/test_is_model_gpt_5_model.py new file mode 100644 index 0000000000..e611d5e6b7 --- /dev/null +++ b/tests/test_litellm/llms/openai/test_is_model_gpt_5_model.py @@ -0,0 +1,152 @@ +""" +Regression tests for is_model_gpt_5_model() in both OpenAI and Azure GPT-5 config +classes. + +Background +---------- +In v1.82.3 a substring check was introduced:: + + return "gpt-5" in model and "gpt-5-chat" not in model + +This inadvertently treated versioned chat models like ``gpt-5.3-chat`` and +``gpt-5.1-chat`` as *non*-GPT-5 models, because the string ``"gpt-5-chat"`` is +a substring of ``"gpt-5.3-chat"``. Those models were then routed through the +regular Azure chat path which does not suppress ``parallel_tool_calls``, causing +Azure to return ``finish_reason="stop"`` together with tool_calls and breaking +n8n AI-agent workflows. + +There are two distinct families: + +* **gpt-5-chat family** (``gpt-5-chat``, ``gpt-5-chat-latest``, + ``gpt-5-chat-2025-08-07``, …) — regular chat models that support ``temperature`` + and ``tool_choice`` but NOT ``reasoning_effort``. Must NOT be on the GPT-5 + reasoning path. + +* **Versioned chat models** (``gpt-5.1-chat``, ``gpt-5.2-chat``, + ``gpt-5.3-chat``, …) — ARE GPT-5 reasoning models and must stay on the GPT-5 + path. + +The fix uses a prefix check (``startswith("gpt-5-chat")``) on the normalised model +name instead of a substring check, which correctly distinguishes the two families. +""" + +import pytest + +from litellm.llms.openai.chat.gpt_5_transformation import OpenAIGPT5Config +from litellm.llms.azure.chat.gpt_5_transformation import AzureOpenAIGPT5Config + +# --------------------------------------------------------------------------- +# Parametrized fixtures +# --------------------------------------------------------------------------- + +# Models that MUST be classified as GPT-5 (routed through GPT-5 reasoning path) +GPT5_MODELS = [ + "gpt-5", + "gpt-5.1", + "gpt-5.2", + "gpt-5.3", + "gpt-5.4", + "gpt-5.5", + "gpt-5.1-chat", # versioned chat — THE KEY REGRESSION CASE + "gpt-5.2-chat", # versioned chat — also a regression case + "gpt-5.3-chat", # versioned chat — THE KEY REGRESSION CASE + "gpt-5.2-chat-latest", # versioned chat with date suffix + "gpt-5.1-codex", + "gpt-5.1-codex-mini", + "gpt-5.1-mini", + "gpt-5-nano", + "gpt-5-mini", + "gpt-5-codex", +] + +# Models that must NOT be classified as GPT-5 (regular chat path) +NON_GPT5_MODELS = [ + "gpt-5-chat", # gpt-5-chat family — regular chat path + "gpt-5-chat-latest", # gpt-5-chat family with alias suffix + "gpt-5-chat-2025-08-07", # gpt-5-chat family with date suffix + "gpt-4", + "gpt-4o", + "gpt-4-turbo", + "gpt-3.5-turbo", + "o1", + "o3", + "o3-mini", +] + + +# --------------------------------------------------------------------------- +# OpenAIGPT5Config +# --------------------------------------------------------------------------- + + +class TestOpenAIGPT5ConfigIsModelGpt5Model: + + @pytest.mark.parametrize("model", GPT5_MODELS) + def test_gpt5_models_are_classified_as_gpt5(self, model: str): + assert OpenAIGPT5Config.is_model_gpt_5_model( + model + ), f"Expected '{model}' to be classified as a GPT-5 model" + + @pytest.mark.parametrize("model", NON_GPT5_MODELS) + def test_non_gpt5_models_are_not_classified_as_gpt5(self, model: str): + assert not OpenAIGPT5Config.is_model_gpt_5_model( + model + ), f"Expected '{model}' NOT to be classified as a GPT-5 model" + + def test_versioned_chat_models_are_not_excluded_by_prefix(self): + """Core regression guard: gpt-5-chat prefix must not match versioned models.""" + versioned_chat_models = ["gpt-5.1-chat", "gpt-5.2-chat", "gpt-5.3-chat"] + for model in versioned_chat_models: + assert OpenAIGPT5Config.is_model_gpt_5_model( + model + ), f"Regression: '{model}' was incorrectly excluded from GPT-5 path" + + def test_gpt5_chat_family_is_excluded(self): + """gpt-5-chat family should stay on the regular chat path.""" + for model in ["gpt-5-chat", "gpt-5-chat-latest", "gpt-5-chat-2025-08-07"]: + assert not OpenAIGPT5Config.is_model_gpt_5_model( + model + ), f"Expected '{model}' (gpt-5-chat family) NOT to be on the GPT-5 path" + + +# --------------------------------------------------------------------------- +# AzureOpenAIGPT5Config +# --------------------------------------------------------------------------- + + +class TestAzureOpenAIGPT5ConfigIsModelGpt5Model: + + @pytest.mark.parametrize("model", GPT5_MODELS) + def test_gpt5_models_are_classified_as_gpt5(self, model: str): + assert AzureOpenAIGPT5Config.is_model_gpt_5_model( + model + ), f"Expected Azure '{model}' to be classified as a GPT-5 model" + + @pytest.mark.parametrize("model", NON_GPT5_MODELS) + def test_non_gpt5_models_are_not_classified_as_gpt5(self, model: str): + assert not AzureOpenAIGPT5Config.is_model_gpt_5_model( + model + ), f"Expected Azure '{model}' NOT to be classified as a GPT-5 model" + + def test_versioned_chat_models_are_not_excluded_by_prefix(self): + """Core regression guard: gpt-5-chat prefix must not match versioned models.""" + versioned_chat_models = ["gpt-5.1-chat", "gpt-5.2-chat", "gpt-5.3-chat"] + for model in versioned_chat_models: + assert AzureOpenAIGPT5Config.is_model_gpt_5_model( + model + ), f"Regression: Azure '{model}' was incorrectly excluded from GPT-5 path" + + def test_gpt5_chat_family_is_excluded(self): + """gpt-5-chat family should stay on the regular chat path.""" + for model in ["gpt-5-chat", "gpt-5-chat-latest", "gpt-5-chat-2025-08-07"]: + assert not AzureOpenAIGPT5Config.is_model_gpt_5_model( + model + ), f"Expected Azure '{model}' (gpt-5-chat family) NOT to be on the GPT-5 path" + + def test_gpt5_series_routing_prefix_is_always_classified_as_gpt5(self): + """Models using the gpt5_series/ manual-routing prefix must always match.""" + series_models = ["gpt5_series/my-deployment", "gpt5_series/prod"] + for model in series_models: + assert AzureOpenAIGPT5Config.is_model_gpt_5_model( + model + ), f"Azure '{model}' with gpt5_series/ prefix should be classified as GPT-5" From 5b9c7be5580ac43a458853a752a3bab6b9a1d016 Mon Sep 17 00:00:00 2001 From: Yuneng Jiang Date: Thu, 23 Apr 2026 15:31:16 -0700 Subject: [PATCH 3/3] test: isolate proxy master_key/prisma_client module globals between tests Sibling tests were mutating litellm.proxy.proxy_server.master_key and prisma_client with raw setattr. Values leaked across tests in the same xdist worker, flipping the auth short-circuit in user_api_key_auth and causing unrelated tests (e.g. test_ui_view_session_spend_logs_pagination) to return 401 instead of 200. Replace raw setattr with monkeypatch in the two offending files and add an autouse conftest fixture that snapshots/restores the known-leaky module globals for every proxy test. --- .../test_claude_code_marketplace.py | 33 +++++------------- tests/test_litellm/proxy/conftest.py | 34 +++++++++++++++++++ .../test_realtime_webrtc_endpoints.py | 8 ++--- 3 files changed, 45 insertions(+), 30 deletions(-) diff --git a/tests/test_litellm/proxy/anthropic_endpoints/test_claude_code_marketplace.py b/tests/test_litellm/proxy/anthropic_endpoints/test_claude_code_marketplace.py index 5511daf1b6..1bdba16612 100644 --- a/tests/test_litellm/proxy/anthropic_endpoints/test_claude_code_marketplace.py +++ b/tests/test_litellm/proxy/anthropic_endpoints/test_claude_code_marketplace.py @@ -65,12 +65,18 @@ _GIT_SUBDIR_SOURCE = { } +@pytest.fixture(autouse=True) +def _patch_proxy_globals(monkeypatch): + """Scope prisma_client/master_key mutations to each test via monkeypatch.""" + monkeypatch.setattr( + litellm.proxy.proxy_server, "prisma_client", _make_mock_prisma() + ) + monkeypatch.setattr(litellm.proxy.proxy_server, "master_key", "sk-1234") + + @pytest.mark.asyncio async def test_register_plugin_git_subdir_success(): """git-subdir with both url and path fields registers successfully.""" - setattr(litellm.proxy.proxy_server, "prisma_client", _make_mock_prisma()) - setattr(litellm.proxy.proxy_server, "master_key", "sk-1234") - request = RegisterPluginRequest( name="my-monorepo-plugin", source=_GIT_SUBDIR_SOURCE ) @@ -86,9 +92,6 @@ async def test_register_plugin_git_subdir_success(): @pytest.mark.asyncio async def test_register_plugin_git_subdir_update(): """Registering the same git-subdir plugin twice returns action=updated.""" - setattr(litellm.proxy.proxy_server, "prisma_client", _make_mock_prisma()) - setattr(litellm.proxy.proxy_server, "master_key", "sk-1234") - request = RegisterPluginRequest( name="my-monorepo-plugin", source=_GIT_SUBDIR_SOURCE, version="1.0.0" ) @@ -106,9 +109,6 @@ async def test_register_plugin_git_subdir_update(): @pytest.mark.asyncio async def test_register_plugin_git_subdir_missing_url(): """git-subdir without url field raises HTTP 400.""" - setattr(litellm.proxy.proxy_server, "prisma_client", _make_mock_prisma()) - setattr(litellm.proxy.proxy_server, "master_key", "sk-1234") - request = RegisterPluginRequest( name="bad-plugin", source={"source": "git-subdir", "path": "plugins/my-plugin"}, @@ -124,9 +124,6 @@ async def test_register_plugin_git_subdir_missing_url(): @pytest.mark.asyncio async def test_register_plugin_git_subdir_empty_url(): """git-subdir with empty url raises HTTP 400.""" - setattr(litellm.proxy.proxy_server, "prisma_client", _make_mock_prisma()) - setattr(litellm.proxy.proxy_server, "master_key", "sk-1234") - request = RegisterPluginRequest( name="bad-plugin", source={"source": "git-subdir", "url": "", "path": "plugins/my-plugin"}, @@ -142,9 +139,6 @@ async def test_register_plugin_git_subdir_empty_url(): @pytest.mark.asyncio async def test_register_plugin_git_subdir_missing_path(): """git-subdir without path field raises HTTP 400.""" - setattr(litellm.proxy.proxy_server, "prisma_client", _make_mock_prisma()) - setattr(litellm.proxy.proxy_server, "master_key", "sk-1234") - request = RegisterPluginRequest( name="bad-plugin", source={"source": "git-subdir", "url": "https://github.com/org/monorepo.git"}, @@ -160,9 +154,6 @@ async def test_register_plugin_git_subdir_missing_path(): @pytest.mark.asyncio async def test_register_plugin_git_subdir_empty_path(): """git-subdir with empty path raises HTTP 400.""" - setattr(litellm.proxy.proxy_server, "prisma_client", _make_mock_prisma()) - setattr(litellm.proxy.proxy_server, "master_key", "sk-1234") - request = RegisterPluginRequest( name="bad-plugin", source={ @@ -182,9 +173,6 @@ async def test_register_plugin_git_subdir_empty_path(): @pytest.mark.asyncio async def test_register_plugin_git_subdir_path_traversal(): """git-subdir with path traversal segments raises HTTP 400.""" - setattr(litellm.proxy.proxy_server, "prisma_client", _make_mock_prisma()) - setattr(litellm.proxy.proxy_server, "master_key", "sk-1234") - for bad_path in [ "../../etc/passwd", "../secrets", @@ -213,9 +201,6 @@ async def test_register_plugin_git_subdir_path_traversal(): @pytest.mark.asyncio async def test_register_plugin_unknown_source_type(): """Unknown source type raises HTTP 400 listing all valid types.""" - setattr(litellm.proxy.proxy_server, "prisma_client", _make_mock_prisma()) - setattr(litellm.proxy.proxy_server, "master_key", "sk-1234") - request = RegisterPluginRequest( name="bad-plugin", source={"source": "ftp", "url": "ftp://example.com/repo"}, diff --git a/tests/test_litellm/proxy/conftest.py b/tests/test_litellm/proxy/conftest.py index aeca28777d..20236ebdf4 100644 --- a/tests/test_litellm/proxy/conftest.py +++ b/tests/test_litellm/proxy/conftest.py @@ -15,6 +15,40 @@ import yaml from fastapi.testclient import TestClient +_PROXY_MODULE_GLOBALS_TO_ISOLATE = ( + "master_key", + "prisma_client", +) + + +@pytest.fixture(autouse=True) +def _isolate_proxy_module_globals(): + """ + Snapshot and restore module-level globals on litellm.proxy.proxy_server + that tests sometimes mutate via raw setattr (not monkeypatch). + + Without this, a leaked value — e.g. master_key set by a sibling test — + flips the auth short-circuit in user_api_key_auth and causes unrelated + tests in the same xdist worker to return 401 instead of 200. + """ + from litellm.proxy import proxy_server + + sentinel = object() + snapshot = { + name: getattr(proxy_server, name, sentinel) + for name in _PROXY_MODULE_GLOBALS_TO_ISOLATE + } + try: + yield + finally: + for name, value in snapshot.items(): + if value is sentinel: + if hasattr(proxy_server, name): + delattr(proxy_server, name) + else: + setattr(proxy_server, name, value) + + def build_cache_config(enable_cache: bool = True) -> Optional[Dict]: """ Build Redis cache configuration from environment variables. diff --git a/tests/test_litellm/proxy/realtime_endpoints/test_realtime_webrtc_endpoints.py b/tests/test_litellm/proxy/realtime_endpoints/test_realtime_webrtc_endpoints.py index e414f975f5..367c89a05f 100644 --- a/tests/test_litellm/proxy/realtime_endpoints/test_realtime_webrtc_endpoints.py +++ b/tests/test_litellm/proxy/realtime_endpoints/test_realtime_webrtc_endpoints.py @@ -113,10 +113,10 @@ def test_decode_realtime_token_payload_ephemeral_key_not_string(): @pytest.fixture -def proxy_app(): +def proxy_app(monkeypatch): from litellm.proxy import proxy_server - proxy_server.master_key = "sk-test-master-key" + monkeypatch.setattr(proxy_server, "master_key", "sk-test-master-key") return proxy_server.app @@ -276,10 +276,6 @@ async def test_realtime_calls_success_with_valid_encrypted_token( mock_pre_call_hook, ): """POST /v1/realtime/calls returns 201 with valid encrypted token from client_secrets.""" - from litellm.proxy import proxy_server - - proxy_server.master_key = "sk-test-master-key" - # Build a valid encrypted token (same format as client_secrets returns) future_expires_at = int(time.time()) + 3600 token_payload = _encode_realtime_token_payload(