diff --git a/litellm/llms/openai_like/dynamic_config.py b/litellm/llms/openai_like/dynamic_config.py index d7aafe9f06e..efe4bd5340d 100644 --- a/litellm/llms/openai_like/dynamic_config.py +++ b/litellm/llms/openai_like/dynamic_config.py @@ -79,6 +79,17 @@ def create_config_class(provider: SimpleProviderConfig): # Resolve API key resolved_key: Final = api_key or get_secret_str(provider.api_key_env) + if provider.special_handling.get("require_api_key") and not resolved_key: + # Fail closed: without this the caller's OPENAI_API_KEY would be used as a fallback + # further down the dispatch path and sent to this provider. + from litellm.exceptions import AuthenticationError + + raise AuthenticationError( + message=f"{provider.slug}: no API key. Pass api_key or set {provider.api_key_env}.", + llm_provider=provider.slug, + model="", + ) + return resolved_base, resolved_key def get_openai_compatible_provider_info( diff --git a/litellm/llms/openai_like/providers.json b/litellm/llms/openai_like/providers.json index a71a9c3bb9b..83bb5cbe30a 100644 --- a/litellm/llms/openai_like/providers.json +++ b/litellm/llms/openai_like/providers.json @@ -302,5 +302,15 @@ "preserve_cache_control": true }, "supported_endpoints": ["/v1/chat/completions"] + }, + "sagg": { + "base_url": "https://api.privatedeskai.com/v1", + "api_key_env": "SAGG_API_KEY", + "param_mappings": { + "max_completion_tokens": "max_tokens" + }, + "special_handling": { + "require_api_key": true + } } } diff --git a/litellm/types/utils.py b/litellm/types/utils.py index e633e557bb2..0ebe2788bb5 100644 --- a/litellm/types/utils.py +++ b/litellm/types/utils.py @@ -4709,6 +4709,7 @@ class LlmProviders(str, Enum): HOSTINGER_ROUTER = "hostinger_router" ZEROGPU = "zerogpu" DARKBLOOM = "darkbloom" + SAGG = "sagg" META = "meta" SAIL = "sail" REKA = "reka" diff --git a/provider_endpoints_support.json b/provider_endpoints_support.json index 40ea9d15ff4..7663252d8e3 100644 --- a/provider_endpoints_support.json +++ b/provider_endpoints_support.json @@ -3578,6 +3578,22 @@ "generateContent": false } }, + "sagg": { + "display_name": "SAGG (`sagg`)", + "url": "https://docs.litellm.ai/docs/providers/sagg", + "endpoints": { + "chat_completions": true, + "messages": false, + "responses": false, + "embeddings": false, + "image_generations": false, + "audio_transcriptions": false, + "audio_speech": false, + "moderations": false, + "batches": false, + "rerank": false + } + }, "searxng": { "display_name": "SearXNG (`searxng`)", "url": "https://docs.litellm.ai/docs/search/searxng", diff --git a/tests/unit/llms/openai_like/test_sagg_provider.py b/tests/unit/llms/openai_like/test_sagg_provider.py new file mode 100644 index 00000000000..ddbae47cc58 --- /dev/null +++ b/tests/unit/llms/openai_like/test_sagg_provider.py @@ -0,0 +1,108 @@ +""" +Tests for SAGG provider configuration and integration. + +SAGG (https://api.privatedeskai.com) is an OpenAI-compatible LLM +inference gateway with automatic multi-provider failover. +""" + +import litellm + + +class TestSaggProviderConfig: + def test_sagg_in_provider_list(self): + from litellm import LlmProviders + + assert hasattr(LlmProviders, "SAGG") + assert LlmProviders.SAGG.value == "sagg" + assert "sagg" in litellm.provider_list + + def test_sagg_json_config_exists(self): + from litellm.llms.openai_like.json_loader import JSONProviderRegistry + + assert JSONProviderRegistry.exists("sagg") + + sagg = JSONProviderRegistry.get("sagg") + assert sagg is not None + assert sagg.base_url == "https://api.privatedeskai.com/v1" + assert sagg.api_key_env == "SAGG_API_KEY" + assert sagg.param_mappings.get("max_completion_tokens") == "max_tokens" + + def test_sagg_provider_resolution(self): + """The real model id contains its own slash + (deepseek-ai/DeepSeek-V4-Flash-0731), so this also confirms + provider-prefix splitting only splits on the FIRST slash, same + as e.g. pinstripes/ps/glm-4.5-air elsewhere in this registry.""" + from litellm.litellm_core_utils.get_llm_provider_logic import get_llm_provider + + model, provider, api_key, api_base = get_llm_provider( + model="sagg/deepseek-ai/DeepSeek-V4-Flash-0731", + custom_llm_provider=None, + api_base=None, + api_key="test-key", + ) + + assert model == "deepseek-ai/DeepSeek-V4-Flash-0731" + assert provider == "sagg" + assert api_base == "https://api.privatedeskai.com/v1" + + def test_sagg_router_config(self): + from litellm import Router + + router = Router( + model_list=[ + { + "model_name": "sagg-deepseek", + "litellm_params": { + "model": "sagg/deepseek-ai/DeepSeek-V4-Flash-0731", + "api_key": "test-key", + }, + } + ] + ) + + assert len(router.model_list) == 1 + assert router.model_list[0]["model_name"] == "sagg-deepseek" + + def test_sagg_parameter_mapping(self): + """SAGG's own request parser only recognizes max_tokens, so a + caller sending max_completion_tokens needs this mapping to + actually take effect server-side rather than being dropped.""" + from litellm.llms.openai_like.dynamic_config import create_config_class + from litellm.llms.openai_like.json_loader import JSONProviderRegistry + + provider = JSONProviderRegistry.get("sagg") + config_class = create_config_class(provider) + config = config_class() + + optional_params = {} + non_default_params = {"max_completion_tokens": 100, "temperature": 0.7} + result = config.map_openai_params( + non_default_params, optional_params, "deepseek-ai/DeepSeek-V4-Flash-0731", False + ) + + assert "max_tokens" in result + assert result["max_tokens"] == 100 + assert "max_completion_tokens" not in result + assert result["temperature"] == 0.7 + + +class TestSaggRequiresItsOwnKey: + def _config(self): + from litellm.llms.openai_like.dynamic_config import create_config_class + from litellm.llms.openai_like.json_loader import JSONProviderRegistry + + return create_config_class(JSONProviderRegistry.get("sagg"))() + + def test_missing_key_fails_closed_even_with_openai_key_set(self, monkeypatch): + import pytest + + monkeypatch.delenv("SAGG_API_KEY", raising=False) + monkeypatch.setenv("OPENAI_API_KEY", "sk-not-for-sagg") + with pytest.raises(litellm.AuthenticationError): + self._config()._get_openai_compatible_provider_info(None, None) + + def test_explicit_key_and_env_key_still_work(self, monkeypatch): + monkeypatch.delenv("SAGG_API_KEY", raising=False) + assert self._config()._get_openai_compatible_provider_info(None, "k1")[1] == "k1" + monkeypatch.setenv("SAGG_API_KEY", "k2") + assert self._config()._get_openai_compatible_provider_info(None, None)[1] == "k2"