diff --git a/litellm/llms/openai_like/dynamic_config.py b/litellm/llms/openai_like/dynamic_config.py index 31c913d5d4e..eaec5f5fb90 100644 --- a/litellm/llms/openai_like/dynamic_config.py +++ b/litellm/llms/openai_like/dynamic_config.py @@ -94,6 +94,7 @@ def create_config_class(provider: SimpleProviderConfig): from litellm.utils import supports_function_calling, supports_reasoning supported_params = super().get_supported_openai_params(model=model) + supported_params = list(dict.fromkeys([*supported_params, *provider.extra_supported_params])) _supports_fc = supports_function_calling(model=model, custom_llm_provider=provider.slug) diff --git a/litellm/llms/openai_like/json_loader.py b/litellm/llms/openai_like/json_loader.py index 4640bb8a422..4e9e8099c6c 100644 --- a/litellm/llms/openai_like/json_loader.py +++ b/litellm/llms/openai_like/json_loader.py @@ -19,6 +19,7 @@ class SimpleProviderConfig: self.api_base_env = data.get("api_base_env") self.base_class = data.get("base_class", "openai_gpt") self.param_mappings = data.get("param_mappings", {}) + self.extra_supported_params = data.get("extra_supported_params", []) self.constraints = data.get("constraints", {}) self.special_handling = data.get("special_handling", {}) self.supported_endpoints = data.get("supported_endpoints", []) diff --git a/litellm/llms/openai_like/providers.json b/litellm/llms/openai_like/providers.json index 164100d4194..86b02090d0e 100644 --- a/litellm/llms/openai_like/providers.json +++ b/litellm/llms/openai_like/providers.json @@ -183,5 +183,12 @@ "max_completion_tokens": "max_tokens" }, "supported_endpoints": ["/v1/chat/completions", "/v1/responses", "/v1/embeddings"] + }, + "app_nz": { + "base_url": "https://app.nz/v1", + "api_key_env": "APP_NZ_API_KEY", + "api_base_env": "APP_NZ_API_BASE", + "base_class": "openai_gpt", + "extra_supported_params": ["reasoning_effort"] } } diff --git a/provider_endpoints_support.json b/provider_endpoints_support.json index 65db63dc045..1936f6e25d4 100644 --- a/provider_endpoints_support.json +++ b/provider_endpoints_support.json @@ -177,6 +177,24 @@ "interactions": true } }, + "app_nz": { + "display_name": "app.nz (`app_nz`)", + "url": "https://docs.litellm.ai/docs/providers/app_nz", + "endpoints": { + "chat_completions": true, + "messages": true, + "responses": true, + "embeddings": false, + "image_generations": false, + "audio_transcriptions": false, + "audio_speech": false, + "moderations": false, + "batches": false, + "rerank": false, + "a2a": true, + "interactions": true + } + }, "apertis": { "display_name": "Apertis (`apertis`)", "endpoints": { diff --git a/tests/test_litellm/llms/openai_like/test_appnz_provider.py b/tests/test_litellm/llms/openai_like/test_appnz_provider.py new file mode 100644 index 00000000000..df614ddcf3c --- /dev/null +++ b/tests/test_litellm/llms/openai_like/test_appnz_provider.py @@ -0,0 +1,106 @@ +""" +Tests for app.nz provider configuration and resolution. +""" + +import litellm + + +class TestAppNZProviderConfig: + """Test app.nz JSON-configured provider""" + + def test_appnz_json_config_exists(self): + """app_nz is registered in providers.json with the expected shape""" + from litellm.llms.openai_like.json_loader import JSONProviderRegistry + + assert JSONProviderRegistry.exists("app_nz") + + app_nz = JSONProviderRegistry.get("app_nz") + assert app_nz is not None + assert app_nz.base_url == "https://app.nz/v1" + assert app_nz.api_key_env == "APP_NZ_API_KEY" + assert app_nz.api_base_env == "APP_NZ_API_BASE" + assert app_nz.base_class == "openai_gpt" + assert app_nz.extra_supported_params == ["reasoning_effort"] + + def test_appnz_provider_resolution(self): + """Resolving an app_nz/* model returns the default app.nz base URL""" + from litellm.litellm_core_utils.get_llm_provider_logic import get_llm_provider + + model, provider, api_key, api_base = get_llm_provider( + model="app_nz/app/auto", + custom_llm_provider=None, + api_base=None, + api_key=None, + ) + + assert model == "app/auto" + assert provider == "app_nz" + assert api_base == "https://app.nz/v1" + + def test_appnz_api_base_override(self): + """An explicit api_base / api_key overrides the app.nz defaults""" + from litellm.litellm_core_utils.get_llm_provider_logic import get_llm_provider + + model, provider, api_key, api_base = get_llm_provider( + model="app_nz/app/auto-code", + custom_llm_provider=None, + api_base="https://custom.example.com/v1", + api_key="app_live_test", + ) + + assert provider == "app_nz" + assert api_base == "https://custom.example.com/v1" + assert api_key == "app_live_test" + + def test_appnz_dynamic_config(self): + """The generated dynamic config resolves to the app.nz base URL""" + from litellm.llms.openai_like.dynamic_config import create_config_class + from litellm.llms.openai_like.json_loader import JSONProviderRegistry + + provider = JSONProviderRegistry.get("app_nz") + config_class = create_config_class(provider) + config = config_class() + + api_base, _ = config._get_openai_compatible_provider_info(None, None) + assert api_base == "https://app.nz/v1" + + def test_appnz_complete_url_appends_endpoint(self): + """get_complete_url appends the chat completions path to the app.nz base""" + from litellm.llms.openai_like.dynamic_config import create_config_class + from litellm.llms.openai_like.json_loader import JSONProviderRegistry + + provider = JSONProviderRegistry.get("app_nz") + config_class = create_config_class(provider) + config = config_class() + + url = config.get_complete_url( + api_base="https://app.nz/v1", + api_key="app_live_test", + model="app_nz/app/auto", + optional_params={}, + litellm_params={}, + stream=False, + ) + + assert url == "https://app.nz/v1/chat/completions" + + def test_appnz_supports_reasoning_effort(self): + """app.nz forwards its documented reasoning_effort parameter""" + from litellm.llms.openai_like.dynamic_config import create_config_class + from litellm.llms.openai_like.json_loader import JSONProviderRegistry + + provider = JSONProviderRegistry.get("app_nz") + config_class = create_config_class(provider) + config = config_class() + + supported_params = config.get_supported_openai_params(model="app/auto") + assert "reasoning_effort" in supported_params + + optional_params = config.map_openai_params( + non_default_params={"reasoning_effort": "auto"}, + optional_params={}, + model="app/auto", + drop_params=False, + ) + + assert optional_params["reasoning_effort"] == "auto" diff --git a/tests/test_litellm/llms/openai_like/test_json_providers.py b/tests/test_litellm/llms/openai_like/test_json_providers.py index c8743e1809d..0053879ca32 100644 --- a/tests/test_litellm/llms/openai_like/test_json_providers.py +++ b/tests/test_litellm/llms/openai_like/test_json_providers.py @@ -52,9 +52,7 @@ class TestJSONProviderLoader: assert api_base == "https://api.publicai.co/v1" # Test with custom base - api_base, api_key = config._get_openai_compatible_provider_info( - "https://custom.api.com", "test-key" - ) + api_base, api_key = config._get_openai_compatible_provider_info("https://custom.api.com", "test-key") assert api_base == "https://custom.api.com" assert api_key == "test-key" @@ -70,9 +68,7 @@ class TestJSONProviderLoader: # Test parameter mapping optional_params = {} non_default_params = {"max_completion_tokens": 100, "temperature": 0.7} - result = config.map_openai_params( - non_default_params, optional_params, "gpt-4", False - ) + result = config.map_openai_params(non_default_params, optional_params, "gpt-4", False) # max_completion_tokens should be mapped to max_tokens assert "max_tokens" in result @@ -98,6 +94,19 @@ class TestJSONProviderLoader: assert isinstance(supported, list) assert len(supported) > 0 + def test_extra_supported_params(self): + """Test JSON providers can extend supported OpenAI params""" + from litellm.llms.openai_like.dynamic_config import create_config_class + from litellm.llms.openai_like.json_loader import JSONProviderRegistry + + provider = JSONProviderRegistry.get("app_nz") + config_class = create_config_class(provider) + config = config_class() + + supported = config.get_supported_openai_params("app/auto") + + assert "reasoning_effort" in supported + def test_tool_params_excluded_when_function_calling_not_supported(self): """Test that tool-related params are excluded for models that don't support function calling. Regression test for https://github.com/BerriAI/litellm/issues/21125 @@ -121,9 +130,9 @@ class TestJSONProviderLoader: "parallel_tool_calls", ] for param in tool_params: - assert ( - param not in supported - ), f"'{param}' should not be in supported params when function calling is not supported" + assert param not in supported, ( + f"'{param}' should not be in supported params when function calling is not supported" + ) # Non-tool params should still be present assert "temperature" in supported @@ -168,9 +177,7 @@ class TestJSONProviderLoader: from litellm import LlmProviders from litellm.utils import ProviderConfigManager - config = ProviderConfigManager.get_provider_chat_config( - model="gpt-4", provider=LlmProviders.PUBLICAI - ) + config = ProviderConfigManager.get_provider_chat_config(model="gpt-4", provider=LlmProviders.PUBLICAI) assert config is not None assert config.custom_llm_provider == "publicai" @@ -218,9 +225,7 @@ class TestPinstripes: api_base, api_key = config._get_openai_compatible_provider_info(None, None) assert api_base == "https://pinstripes.io/v1" - api_base, api_key = config._get_openai_compatible_provider_info( - "https://custom.pinstripes.io/v1", "test-key" - ) + api_base, api_key = config._get_openai_compatible_provider_info("https://custom.pinstripes.io/v1", "test-key") assert api_base == "https://custom.pinstripes.io/v1" assert api_key == "test-key" @@ -235,9 +240,7 @@ class TestPinstripes: optional_params = {} non_default_params = {"max_completion_tokens": 100, "temperature": 0.7} - result = config.map_openai_params( - non_default_params, optional_params, "ps/glm-4.5-air", False - ) + result = config.map_openai_params(non_default_params, optional_params, "ps/glm-4.5-air", False) assert "max_tokens" in result assert result["max_tokens"] == 100 @@ -282,9 +285,7 @@ class TestDarkbloom: api_base, api_key = config._get_openai_compatible_provider_info(None, None) assert api_base == "https://api.darkbloom.dev/v1" - api_base, api_key = config._get_openai_compatible_provider_info( - "https://custom.darkbloom.dev/v1", "test-key" - ) + api_base, api_key = config._get_openai_compatible_provider_info("https://custom.darkbloom.dev/v1", "test-key") assert api_base == "https://custom.darkbloom.dev/v1" assert api_key == "test-key" @@ -311,17 +312,13 @@ class TestDarkbloom: from litellm import LlmProviders from litellm.utils import ProviderConfigManager - config = ProviderConfigManager.get_provider_chat_config( - model="gemma-4-26b", provider=LlmProviders.DARKBLOOM - ) + config = ProviderConfigManager.get_provider_chat_config(model="gemma-4-26b", provider=LlmProviders.DARKBLOOM) assert config is not None assert config.custom_llm_provider == "darkbloom" def test_darkbloom_model_cost_map(self): - with open( - os.path.join(workspace_path, "model_prices_and_context_window.json") - ) as f: + with open(os.path.join(workspace_path, "model_prices_and_context_window.json")) as f: model_cost = json.load(f) expected_models = { @@ -373,9 +370,7 @@ class TestPublicAIIntegration: content = response.choices[0].message.content.lower() assert len(content) > 0 - print( - f"✓ PublicAI completion successful: {response.choices[0].message.content}" - ) + print(f"✓ PublicAI completion successful: {response.choices[0].message.content}") except Exception as e: if pytest: @@ -403,9 +398,7 @@ class TestPublicAIIntegration: chunks = [] for chunk in response: assert chunk is not None - if hasattr(chunk.choices[0], "delta") and hasattr( - chunk.choices[0].delta, "content" - ): + if hasattr(chunk.choices[0], "delta") and hasattr(chunk.choices[0].delta, "content"): if chunk.choices[0].delta.content: chunks.append(chunk.choices[0].delta.content) @@ -461,9 +454,7 @@ class TestPublicAIIntegration: # Send message with content as list (should be converted to string) response = litellm.completion( model="publicai/swiss-ai/apertus-8b-instruct", - messages=[ - {"role": "user", "content": [{"type": "text", "text": "Say hello"}]} - ], + messages=[{"role": "user", "content": [{"type": "text", "text": "Say hello"}]}], max_tokens=10, )