From 9ccd983ba7ad1775ef07e8193f62da8273154483 Mon Sep 17 00:00:00 2001 From: ishaan-j-berri Date: Sat, 24 Oct 2026 17:23:44 -0700 Subject: [PATCH 1/3] fix(openai_like): preserve tools without catalog metadata (#48145) --- litellm/llms/openai_like/dynamic_config.py | 9 +- .../llms/openai_like/test_dynamic_config.py | 103 +++++++++++++++++- .../llms/openai_like/test_json_providers.py | 21 ++-- 3 files changed, 121 insertions(+), 12 deletions(-) diff --git a/litellm/llms/openai_like/dynamic_config.py b/litellm/llms/openai_like/dynamic_config.py index 685561f55a5..295e350bbfb 100644 --- a/litellm/llms/openai_like/dynamic_config.py +++ b/litellm/llms/openai_like/dynamic_config.py @@ -99,11 +99,16 @@ def create_config_class(provider: SimpleProviderConfig): def get_supported_openai_params(self, model: str) -> list: """Get supported OpenAI params, excluding tool-related params for models that don't support function calling.""" - from litellm.utils import supports_function_calling, supports_reasoning + from litellm.utils import is_explicitly_disabled_factory, supports_function_calling, supports_reasoning supported_params: Final = super().get_supported_openai_params(model=model) - _supports_fc: Final = supports_function_calling(model=model, custom_llm_provider=provider.slug) + _supports_fc: Final = supports_function_calling(model=model, custom_llm_provider=provider.slug) or ( + provider.provider_info.get("supports_function_calling") is not False + and not is_explicitly_disabled_factory( + model=model, custom_llm_provider=provider.slug, key="supports_function_calling" + ) + ) if not _supports_fc: tool_params: Final = [ diff --git a/tests/unit/llms/openai_like/test_dynamic_config.py b/tests/unit/llms/openai_like/test_dynamic_config.py index de70f98c3f1..e3aecf9af48 100644 --- a/tests/unit/llms/openai_like/test_dynamic_config.py +++ b/tests/unit/llms/openai_like/test_dynamic_config.py @@ -1,8 +1,14 @@ -import pytest +import json +from contextlib import nullcontext +from typing import Final +import pytest +import respx + +import litellm from litellm.llms.openai_like import dynamic_config from litellm.llms.openai_like.dynamic_config import create_responses_config_class -from litellm.llms.openai_like.json_loader import SimpleProviderConfig +from litellm.llms.openai_like.json_loader import JSONProviderRegistry, SimpleProviderConfig from litellm.types.router import GenericLiteLLMParams _BASE = {"base_url": "https://api.example.com/v1", "api_key_env": "EXAMPLE_API_KEY"} @@ -20,7 +26,6 @@ def _isolate_generated_class_cache(): class TestClassCaching: - def test_cache_is_keyed_on_slug_not_on_the_provider_instance(self): first = create_responses_config_class(_provider("cache_by_slug")) second = create_responses_config_class(_provider("cache_by_slug")) @@ -139,3 +144,95 @@ class TestForceStoreFalse: headers={}, ) assert params["store"] is True + + +@pytest.mark.parametrize("is_async", [False, True], ids=["completion", "acompletion"]) +@pytest.mark.parametrize("drop_params", [False, True], ids=["strict", "drop"]) +@pytest.mark.parametrize( + "provider_capability", [None, False, True], ids=["provider-unset", "provider-disabled", "provider-enabled"] +) +@pytest.mark.parametrize( + "model_capability", + ["unknown", "missing", None, False, True], + ids=["unknown", "missing", "null", "disabled", "enabled"], +) +async def test_tool_schema_forwarding_respects_explicit_capabilities( + monkeypatch: pytest.MonkeyPatch, + is_async: bool, + drop_params: bool, + provider_capability: bool | None, + model_capability: str | bool | None, +) -> None: + monkeypatch.setattr(litellm, "disable_aiohttp_transport", True) + model: Final = "z-ai/issue-48145-model" + provider: Final = JSONProviderRegistry.get("nano-gpt") + assert provider is not None + monkeypatch.setattr( + provider, + "provider_info", + {} if provider_capability is None else {"supports_function_calling": provider_capability}, + ) + if model_capability != "unknown": + litellm.register_model( + { + f"nano-gpt/{model}": { + "litellm_provider": "nano-gpt", + "mode": "chat", + **({} if model_capability == "missing" else {"supports_function_calling": model_capability}), + } + }, + persist_across_reloads=False, + add_to_provider_model_sets=False, + ) + enabled: Final = model_capability is True or (model_capability is not False and provider_capability is not False) + tool: Final = { + "type": "function", + "function": { + "name": "get_weather", + "parameters": { + "type": "object", + "properties": {"city": {"type": "string"}}, + "required": ["city"], + }, + }, + } + with respx.mock(assert_all_called=False) as upstream: + route: Final = upstream.post("https://upstream.example/v1/chat/completions").respond( + 200, + json={ + "id": "chatcmpl-tools", + "object": "chat.completion", + "created": 1, + "model": model, + "choices": [{"index": 0, "message": {"role": "assistant", "content": "ok"}, "finish_reason": "stop"}], + }, + ) + request: Final = { + "model": f"nano-gpt/{model}", + "messages": [{"role": "user", "content": "Weather in Paris?"}], + "api_base": "https://upstream.example/v1", + "api_key": "local-placeholder", + "tools": [tool], + "tool_choice": "auto", + "parallel_tool_calls": True, + "drop_params": drop_params, + } + rejects: Final = not enabled and not drop_params + with pytest.raises(litellm.UnsupportedParamsError, match="tools") if rejects else nullcontext(): + if is_async: + await litellm.acompletion(**request) + else: + litellm.completion(**request) + if rejects: + assert not route.called + return + body: Final = json.loads(route.calls.last.request.content) + assert body["model"] == model + if enabled: + assert body["tools"] == [tool] + assert body["tool_choice"] == "auto" + assert body["parallel_tool_calls"] is True + else: + assert "tools" not in body + assert "tool_choice" not in body + assert "parallel_tool_calls" not in body diff --git a/tests/unit/llms/openai_like/test_json_providers.py b/tests/unit/llms/openai_like/test_json_providers.py index 9dbdaa28a76..a445be80904 100644 --- a/tests/unit/llms/openai_like/test_json_providers.py +++ b/tests/unit/llms/openai_like/test_json_providers.py @@ -4,7 +4,8 @@ Tests for JSON-based provider configuration system. import os import sys -from unittest.mock import patch + +from litellm import register_model try: import pytest @@ -105,9 +106,12 @@ class TestJSONProviderLoader: config_class = create_config_class(provider) config = config_class() - # Mock supports_function_calling to return False - with patch("litellm.utils.supports_function_calling", return_value=False): - supported = config.get_supported_openai_params("some-model-without-fc") + register_model( + {"publicai/some-model-without-fc": {"supports_function_calling": False}}, + persist_across_reloads=False, + add_to_provider_model_sets=False, + ) + supported = config.get_supported_openai_params("some-model-without-fc") tool_params = [ "tools", @@ -135,9 +139,12 @@ class TestJSONProviderLoader: config_class = create_config_class(provider) config = config_class() - # Mock supports_function_calling to return True - with patch("litellm.utils.supports_function_calling", return_value=True): - supported = config.get_supported_openai_params("some-model-with-fc") + register_model( + {"publicai/some-model-with-fc": {"supports_function_calling": True}}, + persist_across_reloads=False, + add_to_provider_model_sets=False, + ) + supported = config.get_supported_openai_params("some-model-with-fc") assert "tools" in supported assert "tool_choice" in supported From 560fb9e122002dbad0cc4ad3a22046388be5c3f0 Mon Sep 17 00:00:00 2001 From: ishaan-j-berri Date: Sat, 24 Oct 2026 17:40:24 -0700 Subject: [PATCH 2/3] refactor(openai_like): make tool capability decisions readable (#48145) --- litellm/llms/openai_like/dynamic_config.py | 28 +++++++++++++++------- 1 file changed, 19 insertions(+), 9 deletions(-) diff --git a/litellm/llms/openai_like/dynamic_config.py b/litellm/llms/openai_like/dynamic_config.py index 295e350bbfb..ad252087085 100644 --- a/litellm/llms/openai_like/dynamic_config.py +++ b/litellm/llms/openai_like/dynamic_config.py @@ -96,21 +96,31 @@ def create_config_class(provider: SimpleProviderConfig): return ensure_api_route(api_base, "chat/completions") raise ValueError(f"api_base is required for provider {provider.slug}") + @staticmethod + def _supports_tool_params(model: str) -> bool: + from litellm.utils import is_explicitly_disabled_factory, supports_function_calling + + if supports_function_calling(model=model, custom_llm_provider=provider.slug): + return True + + if provider.provider_info.get("supports_function_calling") is False: + return False + + if is_explicitly_disabled_factory( + model=model, custom_llm_provider=provider.slug, key="supports_function_calling" + ): + return False + + return True + def get_supported_openai_params(self, model: str) -> list: """Get supported OpenAI params, excluding tool-related params for models that don't support function calling.""" - from litellm.utils import is_explicitly_disabled_factory, supports_function_calling, supports_reasoning + from litellm.utils import supports_reasoning supported_params: Final = super().get_supported_openai_params(model=model) - _supports_fc: Final = supports_function_calling(model=model, custom_llm_provider=provider.slug) or ( - provider.provider_info.get("supports_function_calling") is not False - and not is_explicitly_disabled_factory( - model=model, custom_llm_provider=provider.slug, key="supports_function_calling" - ) - ) - - if not _supports_fc: + if not self._supports_tool_params(model): tool_params: Final = [ "tools", "tool_choice", From 639e8ea77694b56c9a0d8c5dfeba0660a7e94578 Mon Sep 17 00:00:00 2001 From: ishaan-j-berri Date: Sat, 24 Oct 2026 17:52:23 -0700 Subject: [PATCH 3/3] refactor(openai_like): trim tool regression coverage (#48145) --- litellm/llms/openai_like/dynamic_config.py | 7 +- .../llms/openai_like/test_dynamic_config.py | 92 +++++++------------ 2 files changed, 33 insertions(+), 66 deletions(-) diff --git a/litellm/llms/openai_like/dynamic_config.py b/litellm/llms/openai_like/dynamic_config.py index ad252087085..4ffb368096d 100644 --- a/litellm/llms/openai_like/dynamic_config.py +++ b/litellm/llms/openai_like/dynamic_config.py @@ -106,12 +106,9 @@ def create_config_class(provider: SimpleProviderConfig): if provider.provider_info.get("supports_function_calling") is False: return False - if is_explicitly_disabled_factory( + return not is_explicitly_disabled_factory( model=model, custom_llm_provider=provider.slug, key="supports_function_calling" - ): - return False - - return True + ) def get_supported_openai_params(self, model: str) -> list: """Get supported OpenAI params, excluding tool-related params for models diff --git a/tests/unit/llms/openai_like/test_dynamic_config.py b/tests/unit/llms/openai_like/test_dynamic_config.py index e3aecf9af48..ea61a1094a6 100644 --- a/tests/unit/llms/openai_like/test_dynamic_config.py +++ b/tests/unit/llms/openai_like/test_dynamic_config.py @@ -1,5 +1,4 @@ import json -from contextlib import nullcontext from typing import Final import pytest @@ -146,22 +145,25 @@ class TestForceStoreFalse: assert params["store"] is True -@pytest.mark.parametrize("is_async", [False, True], ids=["completion", "acompletion"]) -@pytest.mark.parametrize("drop_params", [False, True], ids=["strict", "drop"]) @pytest.mark.parametrize( - "provider_capability", [None, False, True], ids=["provider-unset", "provider-disabled", "provider-enabled"] + "model_info,provider_capability,drop_params,expected_tools", + [ + pytest.param(None, None, False, True, id="unknown-strict"), + pytest.param(None, None, True, True, id="unknown-drop"), + pytest.param({}, None, True, True, id="missing-capability"), + pytest.param({"supports_function_calling": None}, None, True, True, id="null-capability"), + pytest.param({"supports_function_calling": False}, None, True, False, id="model-disabled"), + pytest.param({"supports_function_calling": False}, True, True, False, id="model-overrides-provider"), + pytest.param(None, False, True, False, id="provider-disabled"), + pytest.param({"supports_function_calling": True}, False, True, True, id="model-enabled"), + ], ) -@pytest.mark.parametrize( - "model_capability", - ["unknown", "missing", None, False, True], - ids=["unknown", "missing", "null", "disabled", "enabled"], -) -async def test_tool_schema_forwarding_respects_explicit_capabilities( +async def test_tools_reach_upstream_unless_explicitly_disabled( monkeypatch: pytest.MonkeyPatch, - is_async: bool, - drop_params: bool, + model_info: dict[str, bool | None] | None, provider_capability: bool | None, - model_capability: str | bool | None, + drop_params: bool, + expected_tools: bool, ) -> None: monkeypatch.setattr(litellm, "disable_aiohttp_transport", True) model: Final = "z-ai/issue-48145-model" @@ -172,31 +174,14 @@ async def test_tool_schema_forwarding_respects_explicit_capabilities( "provider_info", {} if provider_capability is None else {"supports_function_calling": provider_capability}, ) - if model_capability != "unknown": + if model_info is not None: litellm.register_model( - { - f"nano-gpt/{model}": { - "litellm_provider": "nano-gpt", - "mode": "chat", - **({} if model_capability == "missing" else {"supports_function_calling": model_capability}), - } - }, + {f"nano-gpt/{model}": {"litellm_provider": "nano-gpt", "mode": "chat", **model_info}}, persist_across_reloads=False, add_to_provider_model_sets=False, ) - enabled: Final = model_capability is True or (model_capability is not False and provider_capability is not False) - tool: Final = { - "type": "function", - "function": { - "name": "get_weather", - "parameters": { - "type": "object", - "properties": {"city": {"type": "string"}}, - "required": ["city"], - }, - }, - } - with respx.mock(assert_all_called=False) as upstream: + tool: Final = {"type": "function", "function": {"name": "get_weather", "parameters": {"type": "object"}}} + with respx.mock() as upstream: route: Final = upstream.post("https://upstream.example/v1/chat/completions").respond( 200, json={ @@ -207,32 +192,17 @@ async def test_tool_schema_forwarding_respects_explicit_capabilities( "choices": [{"index": 0, "message": {"role": "assistant", "content": "ok"}, "finish_reason": "stop"}], }, ) - request: Final = { - "model": f"nano-gpt/{model}", - "messages": [{"role": "user", "content": "Weather in Paris?"}], - "api_base": "https://upstream.example/v1", - "api_key": "local-placeholder", - "tools": [tool], - "tool_choice": "auto", - "parallel_tool_calls": True, - "drop_params": drop_params, - } - rejects: Final = not enabled and not drop_params - with pytest.raises(litellm.UnsupportedParamsError, match="tools") if rejects else nullcontext(): - if is_async: - await litellm.acompletion(**request) - else: - litellm.completion(**request) - if rejects: - assert not route.called - return + await litellm.acompletion( + model=f"nano-gpt/{model}", + messages=[{"role": "user", "content": "Weather in Paris?"}], + api_base="https://upstream.example/v1", + api_key="local-placeholder", + tools=[tool], + tool_choice="auto", + drop_params=drop_params, + ) body: Final = json.loads(route.calls.last.request.content) - assert body["model"] == model - if enabled: + assert ("tools" in body) is expected_tools + assert body.get("tool_choice") == ("auto" if expected_tools else None) + if expected_tools: assert body["tools"] == [tool] - assert body["tool_choice"] == "auto" - assert body["parallel_tool_calls"] is True - else: - assert "tools" not in body - assert "tool_choice" not in body - assert "parallel_tool_calls" not in body