From 703b982b77c17fbe0c573502b1734cbb1b6954f0 Mon Sep 17 00:00:00 2001 From: ANISAYAK MITRA <266799942+anisayakmitra-in@users.noreply.github.com> Date: Sun, 25 Oct 2026 01:11:16 +0530 Subject: [PATCH] fix(openai_like): resolve 4-state capability matrix for function calling tools --- litellm/llms/openai_like/dynamic_config.py | 23 ++++++--- .../llms/openai_like/test_dynamic_config.py | 50 +++++++++++++++---- 2 files changed, 57 insertions(+), 16 deletions(-) diff --git a/litellm/llms/openai_like/dynamic_config.py b/litellm/llms/openai_like/dynamic_config.py index 6b40b0ff9d7..bcc2b0ab707 100644 --- a/litellm/llms/openai_like/dynamic_config.py +++ b/litellm/llms/openai_like/dynamic_config.py @@ -110,15 +110,24 @@ def create_config_class(provider: SimpleProviderConfig): def _check_supports_fc() -> bool: if is_mocked: return bool(supports_function_calling(model=model, custom_llm_provider=provider.slug)) + + try: + model_info: Final = get_model_info_helper(model=model, custom_llm_provider=provider.slug) + if model_info and "supports_function_calling" in model_info: + raw_fc: Final = model_info.get("supports_function_calling") + if raw_fc is not None: + # State 3: Model row in catalog explicitly declares capability -> takes precedence + return bool(raw_fc) + except (ModelNotMappedError, KeyError, ValueError): + pass + + # State 4: Provider-wide explicit False applies when model has no explicit declaration if provider.provider_info and provider.provider_info.get("supports_function_calling") is False: return False - try: - # If model is mapped in the catalog, respect its capability declaration - get_model_info_helper(model=model, custom_llm_provider=provider.slug) - return bool(supports_function_calling(model=model, custom_llm_provider=provider.slug)) - except (ModelNotMappedError, KeyError, ValueError): - # Unmapped models on OpenAI-compatible providers default to supporting tools - return True + + # State 1 (unmapped model) & State 2 (mapped model with missing/None declaration): + # default to preserving tool parameters for OpenAI-compatible providers + return True _supports_fc: Final = _check_supports_fc() diff --git a/tests/unit/llms/openai_like/test_dynamic_config.py b/tests/unit/llms/openai_like/test_dynamic_config.py index 3a0e348de55..1701faa6802 100644 --- a/tests/unit/llms/openai_like/test_dynamic_config.py +++ b/tests/unit/llms/openai_like/test_dynamic_config.py @@ -146,23 +146,30 @@ class TestForceStoreFalse: class TestFunctionCallingParams: def test_unmapped_model_preserves_tool_parameters(self) -> None: - """Unmapped or newly added models on OpenAI-compatible providers should default to supporting tools.""" + """State 1: Unmapped model on OpenAI-compatible provider defaults to preserving tool parameters.""" config = create_config_class(_provider("unmapped_fc"))() params = config.get_supported_openai_params("unmapped/custom-model") for tool_param in ("tools", "tool_choice", "function_call", "functions", "parallel_tool_calls"): assert tool_param in params - def test_provider_disabled_function_calling_strips_tool_parameters(self) -> None: - """When provider_info explicitly sets supports_function_calling: False, tool params must be stripped.""" - config = create_config_class( - _provider("provider_disabled", provider_info={"supports_function_calling": False}) - )() - params = config.get_supported_openai_params("any-model") + def test_known_model_missing_capability_field_preserves_tool_parameters( + self, monkeypatch: pytest.MonkeyPatch + ) -> None: + """State 2: Known model in catalog without explicit supports_function_calling preserves tool parameters.""" + import litellm + + monkeypatch.setitem( + litellm.model_cost, + "test_slug/model-without-fc-field", + {"litellm_provider": "test_slug", "mode": "chat"}, + ) + config = create_config_class(_provider("test_slug"))() + params = config.get_supported_openai_params("model-without-fc-field") for tool_param in ("tools", "tool_choice", "function_call", "functions", "parallel_tool_calls"): - assert tool_param not in params + assert tool_param in params def test_model_explicitly_disabled_strips_tool_parameters(self, monkeypatch: pytest.MonkeyPatch) -> None: - """When a model is explicitly mapped with supports_function_calling: False, tool params must be stripped.""" + """State 3: When a model row explicitly declares supports_function_calling: False, tool params are stripped.""" import litellm monkeypatch.setitem( @@ -175,3 +182,28 @@ class TestFunctionCallingParams: for tool_param in ("tools", "tool_choice", "function_call", "functions", "parallel_tool_calls"): assert tool_param not in params + def test_model_explicitly_enabled_overrides_provider_disabled(self, monkeypatch: pytest.MonkeyPatch) -> None: + """State 3 override: Model explicit True overrides provider-wide supports_function_calling: False.""" + import litellm + + monkeypatch.setitem( + litellm.model_cost, + "provider_disabled/enabled-model", + {"supports_function_calling": True}, + ) + config = create_config_class( + _provider("provider_disabled", provider_info={"supports_function_calling": False}) + )() + params = config.get_supported_openai_params("enabled-model") + for tool_param in ("tools", "tool_choice", "function_call", "functions", "parallel_tool_calls"): + assert tool_param in params + + def test_provider_disabled_function_calling_strips_tool_parameters(self) -> None: + """State 4: When provider_info explicitly sets supports_function_calling: False and model has no override.""" + config = create_config_class( + _provider("provider_disabled", provider_info={"supports_function_calling": False}) + )() + params = config.get_supported_openai_params("any-model") + for tool_param in ("tools", "tool_choice", "function_call", "functions", "parallel_tool_calls"): + assert tool_param not in params +