From b95369572321398dba1397d5b4f7a9a5db393557 Mon Sep 17 00:00:00 2001 From: ZHOUKAILIAN Date: Wed, 9 Sep 2026 10:51:18 +0800 Subject: [PATCH 1/2] fix: use base model params for JSON providers --- litellm/llms/openai_like/dynamic_config.py | 71 +++++++++++-------- litellm/utils.py | 24 +++++-- .../llms/openai_like/test_dynamic_config.py | 65 +++++++++++++++++ 3 files changed, 123 insertions(+), 37 deletions(-) diff --git a/litellm/llms/openai_like/dynamic_config.py b/litellm/llms/openai_like/dynamic_config.py index 19e29bcdcb2..1dd9b69749c 100644 --- a/litellm/llms/openai_like/dynamic_config.py +++ b/litellm/llms/openai_like/dynamic_config.py @@ -3,9 +3,8 @@ Dynamic configuration class generator for JSON-based providers. """ from collections.abc import Coroutine -from typing import Any, Final, Literal, overload +from typing import Any, Final, Literal, Protocol, overload, runtime_checkable -from litellm._logging import verbose_logger from litellm.litellm_core_utils.prompt_templates.common_utils import ( handle_messages_with_content_list_to_str_conversion, ) @@ -17,6 +16,20 @@ from litellm.types.llms.openai import AllMessageValues from .json_loader import SimpleProviderConfig +@runtime_checkable +class BaseModelAwareConfig(Protocol): + supports_base_model_hint: bool + + def map_openai_params( + self, + non_default_params: dict, + optional_params: dict, + model: str, + drop_params: bool, + base_model: str | None = None, + ) -> dict: ... + + def create_config_class(provider: SimpleProviderConfig): """Generate config class dynamically from JSON configuration""" @@ -89,37 +102,31 @@ def create_config_class(provider: SimpleProviderConfig): return api_base - 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.""" + def _get_supported_openai_params_for_model(self, model: str) -> list: from litellm.utils import supports_function_calling, supports_reasoning - supported_params: Final = super().get_supported_openai_params(model=model) + tool_params: Final = ("tools", "tool_choice", "function_call", "functions", "parallel_tool_calls") + params_without_tools: Final = tuple( + param for param in super().get_supported_openai_params(model=model) if param not in tool_params + ) + params_with_tools: Final = tuple(dict.fromkeys((*params_without_tools, *tool_params))) + supported_params: Final = ( + params_with_tools + if supports_function_calling(model=model, custom_llm_provider=provider.slug) + else params_without_tools + ) + if ( + supports_reasoning(model=model, custom_llm_provider=provider.slug) + and "reasoning_effort" not in supported_params + ): + return [*supported_params, "reasoning_effort"] + return list(supported_params) - _supports_fc: Final = supports_function_calling(model=model, custom_llm_provider=provider.slug) - - if not _supports_fc: - tool_params: Final = [ - "tools", - "tool_choice", - "function_call", - "functions", - "parallel_tool_calls", - ] - for param in tool_params: - if param in supported_params: - supported_params.remove(param) - verbose_logger.debug( - "Model %s on provider %s does not support function calling — removed tool-related params from supported params.", - model, - provider.slug, - ) - - _supports_reasoning: Final = supports_reasoning(model=model, custom_llm_provider=provider.slug) - if _supports_reasoning and "reasoning_effort" not in supported_params: - supported_params.append("reasoning_effort") - - return supported_params + def get_supported_openai_params(self, model: str, base_model: str | None = None) -> list: + supported_params: Final = self._get_supported_openai_params_for_model(model) + if not base_model or base_model == model: + return supported_params + return list(dict.fromkeys([*supported_params, *self._get_supported_openai_params_for_model(base_model)])) def map_openai_params( self, @@ -127,10 +134,11 @@ def create_config_class(provider: SimpleProviderConfig): optional_params: dict, model: str, drop_params: bool, + base_model: str | None = None, ) -> dict: """Apply parameter mappings and constraints""" - supported_params: Final = self.get_supported_openai_params(model) + supported_params: Final = self.get_supported_openai_params(model, base_model=base_model) # Apply supported params for param, value in non_default_params.items(): @@ -167,6 +175,7 @@ def create_config_class(provider: SimpleProviderConfig): def custom_llm_provider(self) -> str | None: return provider.slug + JSONProviderConfig.supports_base_model_hint = True return JSONProviderConfig diff --git a/litellm/utils.py b/litellm/utils.py index 36b48d3b8d8..beeb2bad2bf 100644 --- a/litellm/utils.py +++ b/litellm/utils.py @@ -4714,12 +4714,24 @@ def get_optional_params( drop_params=bool(drop_params), ) elif provider_config is not None: - optional_params = provider_config.map_openai_params( - non_default_params=non_default_params, - optional_params=optional_params, - model=model, - drop_params=bool(drop_params), - ) + drop_params_value: Final = bool(drop_params) + from litellm.llms.openai_like.dynamic_config import BaseModelAwareConfig + + if isinstance(provider_config, BaseModelAwareConfig): + optional_params = provider_config.map_openai_params( + non_default_params=non_default_params, + optional_params=optional_params, + model=model, + drop_params=drop_params_value, + base_model=base_model, + ) + else: + optional_params = provider_config.map_openai_params( + non_default_params=non_default_params, + optional_params=optional_params, + model=model, + drop_params=drop_params_value, + ) else: # assume passing in params for openai-like api optional_params = litellm.OpenAILikeChatConfig().map_openai_params( non_default_params=non_default_params, diff --git a/tests/test_litellm/llms/openai_like/test_dynamic_config.py b/tests/test_litellm/llms/openai_like/test_dynamic_config.py index 55e1a1679de..afe931cc23e 100644 --- a/tests/test_litellm/llms/openai_like/test_dynamic_config.py +++ b/tests/test_litellm/llms/openai_like/test_dynamic_config.py @@ -19,6 +19,71 @@ def _isolate_generated_class_cache(): dynamic_config._responses_config_cache.clear() +class TestBaseModelParamSupport: + TOOLS = [ + { + "type": "function", + "function": { + "name": "lookup", + "parameters": { + "type": "object", + "properties": {"query": {"type": "string"}}, + }, + }, + } + ] + + def test_generated_chat_config_uses_base_model_for_supported_params(self, local_model_cost_map): + config = dynamic_config.create_config_class(_provider("publicai", base_class="openai_gpt"))() + + endpoint_params = config.get_supported_openai_params(model="ep-publicai") + assert "tools" not in endpoint_params + assert "reasoning_effort" not in endpoint_params + + instruct_params = config.get_supported_openai_params( + model="ep-publicai", + base_model="publicai/allenai/Olmo-3-7B-Instruct", + ) + assert "tools" in instruct_params + assert "reasoning_effort" not in instruct_params + + thinking_params = config.get_supported_openai_params( + model="ep-publicai", + base_model="publicai/allenai/Olmo-3-7B-Think", + ) + assert "tools" in thinking_params + assert "reasoning_effort" in thinking_params + + def test_get_optional_params_passes_base_model_to_json_provider(self, local_model_cost_map): + from litellm.utils import get_optional_params + + optional_params = get_optional_params( + model="ep-publicai", + custom_llm_provider="publicai", + tools=self.TOOLS, + reasoning_effort="high", + base_model="publicai/allenai/Olmo-3-7B-Think", + drop_params=True, + ) + + assert optional_params["tools"] == self.TOOLS + assert optional_params["reasoning_effort"] == "high" + assert "base_model" not in optional_params + + def test_non_json_provider_does_not_receive_base_model_kwarg(self): + from litellm.utils import get_optional_params + + optional_params = get_optional_params( + model="a2a/test-agent", + custom_llm_provider="a2a", + tools=self.TOOLS, + base_model="publicai/allenai/Olmo-3-7B-Think", + drop_params=True, + ) + + assert "base_model" not in optional_params + + class TestClassCaching: def test_same_slug_returns_the_identical_class_object(self): provider = _provider("cache_same_slug") From ff4db7dbd940015f2c8e88a4b6547dd94f1405f1 Mon Sep 17 00:00:00 2001 From: zhoukailian <2415699291@qq.com> Date: Sun, 13 Sep 2026 22:09:15 +0800 Subject: [PATCH 2/2] fix: preserve JSON provider contracts in typed parameter mapping --- litellm/llms/openai_like/dynamic_config.py | 29 +++++++----- litellm/utils.py | 2 +- .../llms/openai_like/test_dynamic_config.py | 44 +++++++++++++++++-- 3 files changed, 58 insertions(+), 17 deletions(-) diff --git a/litellm/llms/openai_like/dynamic_config.py b/litellm/llms/openai_like/dynamic_config.py index 1dd9b69749c..24ed94b35ba 100644 --- a/litellm/llms/openai_like/dynamic_config.py +++ b/litellm/llms/openai_like/dynamic_config.py @@ -2,7 +2,7 @@ Dynamic configuration class generator for JSON-based providers. """ -from collections.abc import Coroutine +from collections.abc import Coroutine, Mapping from typing import Any, Final, Literal, Protocol, overload, runtime_checkable from litellm.litellm_core_utils.prompt_templates.common_utils import ( @@ -20,14 +20,14 @@ from .json_loader import SimpleProviderConfig class BaseModelAwareConfig(Protocol): supports_base_model_hint: bool - def map_openai_params( + def map_openai_params_with_base_model( self, - non_default_params: dict, - optional_params: dict, + non_default_params: Mapping[str, object], + optional_params: dict[str, object], # mutable-ok: BaseConfig mapping updates the caller-owned output dict model: str, drop_params: bool, base_model: str | None = None, - ) -> dict: ... + ) -> dict[str, object]: ... # mutable-ok: BaseConfig mapping returns the same caller-owned output dict def create_config_class(provider: SimpleProviderConfig): @@ -102,7 +102,7 @@ def create_config_class(provider: SimpleProviderConfig): return api_base - def _get_supported_openai_params_for_model(self, model: str) -> list: + def _get_supported_openai_params_for_model(self, model: str) -> tuple[str, ...]: from litellm.utils import supports_function_calling, supports_reasoning tool_params: Final = ("tools", "tool_choice", "function_call", "functions", "parallel_tool_calls") @@ -119,14 +119,17 @@ def create_config_class(provider: SimpleProviderConfig): supports_reasoning(model=model, custom_llm_provider=provider.slug) and "reasoning_effort" not in supported_params ): - return [*supported_params, "reasoning_effort"] - return list(supported_params) + return (*supported_params, "reasoning_effort") + return supported_params - def get_supported_openai_params(self, model: str, base_model: str | None = None) -> list: + def get_supported_openai_params(self, model: str, base_model: str | None = None) -> list[str]: supported_params: Final = self._get_supported_openai_params_for_model(model) - if not base_model or base_model == model: - return supported_params - return list(dict.fromkeys([*supported_params, *self._get_supported_openai_params_for_model(base_model)])) + combined_params: Final = ( + tuple(dict.fromkeys((*supported_params, *self._get_supported_openai_params_for_model(base_model)))) + if base_model and base_model != model + else supported_params + ) + return list(combined_params) def map_openai_params( self, @@ -171,6 +174,8 @@ def create_config_class(provider: SimpleProviderConfig): return optional_params + map_openai_params_with_base_model: Final = map_openai_params + @property def custom_llm_provider(self) -> str | None: return provider.slug diff --git a/litellm/utils.py b/litellm/utils.py index beeb2bad2bf..a4dd8d9bbae 100644 --- a/litellm/utils.py +++ b/litellm/utils.py @@ -4718,7 +4718,7 @@ def get_optional_params( from litellm.llms.openai_like.dynamic_config import BaseModelAwareConfig if isinstance(provider_config, BaseModelAwareConfig): - optional_params = provider_config.map_openai_params( + optional_params = provider_config.map_openai_params_with_base_model( non_default_params=non_default_params, optional_params=optional_params, model=model, diff --git a/tests/test_litellm/llms/openai_like/test_dynamic_config.py b/tests/test_litellm/llms/openai_like/test_dynamic_config.py index afe931cc23e..95d73d07e30 100644 --- a/tests/test_litellm/llms/openai_like/test_dynamic_config.py +++ b/tests/test_litellm/llms/openai_like/test_dynamic_config.py @@ -1,3 +1,5 @@ +from types import MappingProxyType + import pytest from litellm.llms.openai_like import dynamic_config @@ -33,8 +35,9 @@ class TestBaseModelParamSupport: } ] - def test_generated_chat_config_uses_base_model_for_supported_params(self, local_model_cost_map): - config = dynamic_config.create_config_class(_provider("publicai", base_class="openai_gpt"))() + @pytest.mark.parametrize("base_class", ["openai_gpt", "openai_like"]) + def test_generated_chat_config_uses_base_model_for_supported_params(self, local_model_cost_map, base_class): + config = dynamic_config.create_config_class(_provider("publicai", base_class=base_class))() endpoint_params = config.get_supported_openai_params(model="ep-publicai") assert "tools" not in endpoint_params @@ -54,7 +57,40 @@ class TestBaseModelParamSupport: assert "tools" in thinking_params assert "reasoning_effort" in thinking_params - def test_get_optional_params_passes_base_model_to_json_provider(self, local_model_cost_map): + @pytest.mark.parametrize("base_class", ["openai_gpt", "openai_like"]) + @pytest.mark.parametrize("base_model", [None, "ep-publicai", "publicai/allenai/Olmo-3-7B-Think"]) + def test_supported_params_allow_per_call_extensions_without_leaking( + self, local_model_cost_map, base_class, base_model + ): + config = dynamic_config.create_config_class(_provider("publicai", base_class=base_class))() + supported = config.get_supported_openai_params("ep-publicai", base_model=base_model) + original = tuple(supported) + + supported.extend(["request_specific_param"]) + + assert supported[-1] == "request_specific_param" + assert tuple(config.get_supported_openai_params("ep-publicai", base_model=base_model)) == original + + @pytest.mark.parametrize("base_class", ["openai_gpt", "openai_like"]) + def test_mapping_preserves_caller_owned_output_and_accepts_readonly_input(self, local_model_cost_map, base_class): + config = dynamic_config.create_config_class(_provider("publicai", base_class=base_class))() + non_default_params = MappingProxyType({"tools": self.TOOLS, "reasoning_effort": "high"}) + optional_params = {"temperature": 0.4} + + mapped = config.map_openai_params_with_base_model( + non_default_params=non_default_params, + optional_params=optional_params, + model="ep-publicai", + drop_params=False, + base_model="publicai/allenai/Olmo-3-7B-Think", + ) + + assert mapped is optional_params + assert optional_params == {"temperature": 0.4, "tools": self.TOOLS, "reasoning_effort": "high"} + assert non_default_params == {"tools": self.TOOLS, "reasoning_effort": "high"} + + @pytest.mark.parametrize("drop_params", [True, False]) + def test_get_optional_params_passes_base_model_to_json_provider(self, local_model_cost_map, drop_params): from litellm.utils import get_optional_params optional_params = get_optional_params( @@ -63,7 +99,7 @@ class TestBaseModelParamSupport: tools=self.TOOLS, reasoning_effort="high", base_model="publicai/allenai/Olmo-3-7B-Think", - drop_params=True, + drop_params=drop_params, ) assert optional_params["tools"] == self.TOOLS