From 9d148c4670d01cd9e879aeb4f8ae96317e05d698 Mon Sep 17 00:00:00 2001 From: privatedeskai Date: Mon, 31 Aug 2026 19:11:20 -0700 Subject: [PATCH] feat(providers): add SAGG as an OpenAI-compatible provider SAGG (https://api.privatedeskai.com) is an OpenAI-compatible LLM inference gateway with automatic multi-provider failover. Adds it as a dynamic JSON-registry provider: base_url + api_key_env in providers.json, plus the required LlmProviders enum entry so it resolves via get_llm_provider() and appears in litellm.provider_list. param_mappings.max_completion_tokens -> max_tokens is required, not optional: SAGG's own request parser only recognizes max_tokens (has no max_completion_tokens field at all), so a caller using the newer OpenAI param name would otherwise have it silently dropped. Verified end-to-end against the live SAGG API, including that the max_completion_tokens mapping is actually enforced server-side (finish_reason: length at the requested cap), not just unit-tested. --- litellm/llms/openai_like/providers.json | 7 + litellm/types/utils.py | 1 + .../llms/openai_like/test_sagg_provider.py | 187 ++++++++++++++++++ 3 files changed, 195 insertions(+) create mode 100644 tests/test_litellm/llms/openai_like/test_sagg_provider.py diff --git a/litellm/llms/openai_like/providers.json b/litellm/llms/openai_like/providers.json index a458a209ea9..7a313a2a773 100644 --- a/litellm/llms/openai_like/providers.json +++ b/litellm/llms/openai_like/providers.json @@ -200,5 +200,12 @@ "temperature_max": 1.99 }, "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" + } } } diff --git a/litellm/types/utils.py b/litellm/types/utils.py index a1b3523442b..a18df800292 100644 --- a/litellm/types/utils.py +++ b/litellm/types/utils.py @@ -3857,6 +3857,7 @@ class LlmProviders(str, Enum): COGNITION = "cognition" SCX_AI = "scx-ai" DARKBLOOM = "darkbloom" + SAGG = "sagg" META = "meta" LITELLM_AGENT = "litellm_agent" CURSOR = "cursor" diff --git a/tests/test_litellm/llms/openai_like/test_sagg_provider.py b/tests/test_litellm/llms/openai_like/test_sagg_provider.py new file mode 100644 index 00000000000..b36b7a61135 --- /dev/null +++ b/tests/test_litellm/llms/openai_like/test_sagg_provider.py @@ -0,0 +1,187 @@ +""" +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 os +import sys +from unittest.mock import MagicMock, patch + +try: + import pytest +except ImportError: + pytest = None + +# Add workspace to path +workspace_path = os.path.abspath(os.path.join(os.path.dirname(__file__), "../../../..")) +sys.path.insert(0, workspace_path) + +import litellm + + +class TestSaggProviderConfig: + """Test SAGG provider configuration""" + + def test_sagg_in_provider_list(self): + """Test that sagg is in the provider list""" + from litellm import LlmProviders + + # Verify sagg is in the enum + assert hasattr(LlmProviders, "SAGG") + assert LlmProviders.SAGG.value == "sagg" + + # Verify it's in the provider list + assert "sagg" in litellm.provider_list + + def test_sagg_json_config_exists(self): + """Test that sagg is configured in providers.json""" + from litellm.llms.openai_like.json_loader import JSONProviderRegistry + + # Verify sagg is loaded + assert JSONProviderRegistry.exists("sagg") + + # Get sagg config + 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): + """Test that provider resolution finds sagg - 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 same 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=None, + ) + + 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): + """Test that sagg can be used in Router configuration""" + from litellm import Router + + # This should not raise "Unsupported provider - sagg" + router = Router( + model_list=[ + { + "model_name": "sagg-deepseek", + "litellm_params": { + "model": "sagg/deepseek-ai/DeepSeek-V4-Flash-0731", + "api_key": "test-key", + }, + } + ] + ) + + # Verify the deployment was created successfully + assert len(router.model_list) == 1 + assert router.model_list[0]["model_name"] == "sagg-deepseek" + + def test_sagg_parameter_mapping(self): + """Test that max_completion_tokens is mapped to max_tokens for + sagg - SAGG's own request parser only recognizes max_tokens + (cmd/gateway/billing.go's chatRequestForBilling struct has no + max_completion_tokens field at all), so a caller sending the + newer OpenAI param name needs this mapping to actually take + effect server-side, not be silently 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 TestSaggIntegration: + """Integration tests for SAGG provider""" + + def test_sagg_completion_basic(self): + """Test basic completion call to SAGG""" + # Skip test if API key not set in environment + if not os.environ.get("SAGG_API_KEY"): + if pytest: + pytest.skip("SAGG_API_KEY not set") + return + + try: + response = litellm.completion( + model="sagg/deepseek-ai/DeepSeek-V4-Flash-0731", + messages=[ + { + "role": "user", + "content": "Say 'test successful' and nothing else", + } + ], + max_tokens=10, + ) + + assert response is not None + assert hasattr(response, "choices") + assert len(response.choices) > 0 + assert hasattr(response.choices[0], "message") + assert hasattr(response.choices[0].message, "content") + assert response.choices[0].message.content is not None + + content = response.choices[0].message.content.lower() + assert len(content) > 0 + + print(f"✓ SAGG completion successful: {response.choices[0].message.content}") + + except Exception as e: + if pytest: + pytest.fail(f"SAGG completion failed: {str(e)}") + else: + raise + + +if __name__ == "__main__": + print("Testing SAGG Provider...") + + test_config = TestSaggProviderConfig() + + print("\n1. Testing provider in list...") + test_config.test_sagg_in_provider_list() + print(" ✓ sagg in provider list") + + print("\n2. Testing JSON config...") + test_config.test_sagg_json_config_exists() + print(" ✓ sagg JSON config loaded") + + print("\n3. Testing provider resolution...") + test_config.test_sagg_provider_resolution() + print(" ✓ Provider resolution works") + + print("\n4. Testing router configuration...") + test_config.test_sagg_router_config() + print(" ✓ Router configuration works") + + print("\n5. Testing parameter mapping...") + test_config.test_sagg_parameter_mapping() + print(" ✓ Parameter mapping works") + + print("\n" + "=" * 50) + print("✓ All configuration tests passed!") + print("=" * 50)