diff --git a/litellm/constants.py b/litellm/constants.py index 9872783bfab..7584281e371 100644 --- a/litellm/constants.py +++ b/litellm/constants.py @@ -806,6 +806,7 @@ openai_compatible_endpoints: Final[list] = [ "https://api.meta.ai/v1", "https://api.cognition.ai/v1", "https://api.scx.ai/v1", + "https://api.upstage.ai/v1", ] @@ -875,6 +876,7 @@ openai_compatible_providers: Final[list] = [ "meta", # Meta Model API (Muse Spark) - JSON-configured provider "cognition", "scx-ai", + "upstage", ] openai_text_completion_compatible_providers: Final[list] = [ # providers that support `/v1/completions` "together_ai", diff --git a/litellm/llms/openai_like/providers.json b/litellm/llms/openai_like/providers.json index a458a209ea9..2a1e705d92f 100644 --- a/litellm/llms/openai_like/providers.json +++ b/litellm/llms/openai_like/providers.json @@ -200,5 +200,10 @@ "temperature_max": 1.99 }, "supported_endpoints": ["/v1/chat/completions"] + }, + "upstage": { + "base_url": "https://api.upstage.ai/v1", + "api_key_env": "UPSTAGE_API_KEY", + "api_base_env": "UPSTAGE_API_BASE" } } diff --git a/litellm/provider_endpoints_support_backup.json b/litellm/provider_endpoints_support_backup.json index ead26ab65c5..e5c62744347 100644 --- a/litellm/provider_endpoints_support_backup.json +++ b/litellm/provider_endpoints_support_backup.json @@ -2172,6 +2172,23 @@ "interactions": true } }, + "upstage": { + "display_name": "Upstage (`upstage`)", + "url": "https://docs.litellm.ai/docs/providers/upstage", + "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, + "a2a": false + } + }, "v0": { "display_name": "V0 (`v0`)", "url": "https://docs.litellm.ai/docs/providers/v0", diff --git a/litellm/types/utils.py b/litellm/types/utils.py index 4bf8289d725..b70f7b25b8b 100644 --- a/litellm/types/utils.py +++ b/litellm/types/utils.py @@ -3847,6 +3847,7 @@ class LlmProviders(str, Enum): PINSTRIPES = "pinstripes" COGNITION = "cognition" SCX_AI = "scx-ai" + UPSTAGE = "upstage" DARKBLOOM = "darkbloom" META = "meta" LITELLM_AGENT = "litellm_agent" diff --git a/provider_endpoints_support.json b/provider_endpoints_support.json index 7c7d508856f..d4dd0849b5b 100644 --- a/provider_endpoints_support.json +++ b/provider_endpoints_support.json @@ -2483,6 +2483,23 @@ "interactions": true } }, + "upstage": { + "display_name": "Upstage (`upstage`)", + "url": "https://docs.litellm.ai/docs/providers/upstage", + "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, + "a2a": false + } + }, "v0": { "display_name": "V0 (`v0`)", "url": "https://docs.litellm.ai/docs/providers/v0", diff --git a/tests/test_litellm/llms/openai_like/test_upstage_provider.py b/tests/test_litellm/llms/openai_like/test_upstage_provider.py new file mode 100644 index 00000000000..94a80cf15a0 --- /dev/null +++ b/tests/test_litellm/llms/openai_like/test_upstage_provider.py @@ -0,0 +1,223 @@ +""" +Tests for the Upstage (Solar) provider configuration and integration. +""" + +import json +from pathlib import Path + +import httpx +import pytest + +import litellm +from litellm import completion + +CHAT_COMPLETION_RESPONSE = { + "id": "chatcmpl-upstage-test", + "object": "chat.completion", + "created": 1787686209, + "model": "solar-pro4-260806", + "choices": [ + { + "index": 0, + "message": {"role": "assistant", "content": "Hello from Solar"}, + "finish_reason": "stop", + } + ], + "usage": {"prompt_tokens": 11, "completion_tokens": 24, "total_tokens": 35}, +} + +STREAMING_RESPONSE = ( + 'data: {"id":"chatcmpl-upstage-stream","object":"chat.completion.chunk","created":1787686266,' + '"model":"solar-pro4-260806","choices":[{"index":0,"delta":{"role":"assistant","content":"Hello"},' + '"finish_reason":null}]}\n\n' + 'data: {"id":"chatcmpl-upstage-stream","object":"chat.completion.chunk","created":1787686266,' + '"model":"solar-pro4-260806","choices":[{"index":0,"delta":{},"finish_reason":"stop"}]}\n\n' + "data: [DONE]\n\n" +) + + +class TestUpstageProviderIdentity: + def test_upstage_is_a_registered_provider(self): + from litellm import LlmProviders + + assert LlmProviders.UPSTAGE.value == "upstage" + assert "upstage" in litellm.provider_list + + def test_upstage_json_config(self): + from litellm.llms.openai_like.json_loader import JSONProviderRegistry + + upstage = JSONProviderRegistry.get("upstage") + assert upstage is not None + assert upstage.base_url == "https://api.upstage.ai/v1" + assert upstage.api_key_env == "UPSTAGE_API_KEY" + assert upstage.api_base_env == "UPSTAGE_API_BASE" + + def test_upstage_in_openai_compatible_providers(self): + from litellm.constants import openai_compatible_providers + + assert "upstage" in openai_compatible_providers + + def test_prefixed_model_resolves_to_upstage_not_openai(self): + from litellm.litellm_core_utils.get_llm_provider_logic import get_llm_provider + + model, provider, _, api_base = get_llm_provider( + model="upstage/solar-pro4", + custom_llm_provider=None, + api_base=None, + api_key=None, + ) + + assert model == "solar-pro4" + assert provider == "upstage" + assert api_base == "https://api.upstage.ai/v1" + + def test_explicit_api_base_and_key_win(self): + from litellm.litellm_core_utils.get_llm_provider_logic import get_llm_provider + + _, provider, api_key, api_base = get_llm_provider( + model="upstage/solar-pro4", + custom_llm_provider=None, + api_base="https://upstage.internal.example/v1", + api_key="sk-test", + ) + + assert provider == "upstage" + assert api_base == "https://upstage.internal.example/v1" + assert api_key == "sk-test" + + def test_api_base_autodetects_upstage(self, monkeypatch: pytest.MonkeyPatch): + from litellm.litellm_core_utils.get_llm_provider_logic import get_llm_provider + + monkeypatch.setenv("UPSTAGE_API_KEY", "up-env-key") + + _, provider, api_key, api_base = get_llm_provider( + model="solar-pro4", + custom_llm_provider=None, + api_base="https://api.upstage.ai/v1", + api_key=None, + ) + + assert provider == "upstage" + assert api_base == "https://api.upstage.ai/v1" + assert api_key == "up-env-key" + + def test_autodetected_api_base_keeps_the_caller_api_key(self, monkeypatch: pytest.MonkeyPatch): + from litellm.litellm_core_utils.get_llm_provider_logic import get_llm_provider + + monkeypatch.setenv("UPSTAGE_API_KEY", "up-env-key") + + _, provider, api_key, _ = get_llm_provider( + model="solar-pro4", + custom_llm_provider=None, + api_base="https://api.upstage.ai/v1", + api_key="up-caller-key", + ) + + assert provider == "upstage" + assert api_key == "up-caller-key" + + def test_env_api_key_is_read_from_upstage_variable(self, monkeypatch: pytest.MonkeyPatch): + from litellm.llms.openai_like.dynamic_config import create_config_class + from litellm.llms.openai_like.json_loader import JSONProviderRegistry + + monkeypatch.delenv("OPENAI_API_KEY", raising=False) + monkeypatch.setenv("UPSTAGE_API_KEY", "up-env-key") + + provider = JSONProviderRegistry.get("upstage") + assert provider is not None + + api_base, api_key = create_config_class(provider)()._get_openai_compatible_provider_info(None, None) + assert api_base == "https://api.upstage.ai/v1" + assert api_key == "up-env-key" + + +class TestUpstageRequestShape: + """A wrong host, a wrong env var, or a leaked `upstage/` prefix fails here and nowhere else.""" + + @pytest.mark.respx() + def test_chat_completion_targets_the_upstage_endpoint(self, respx_mock, monkeypatch: pytest.MonkeyPatch): + monkeypatch.setenv("UPSTAGE_API_KEY", "up-env-key") + + respx_mock.post("https://api.upstage.ai/v1/chat/completions").respond( + json=CHAT_COMPLETION_RESPONSE, status_code=200 + ) + + response = completion( + model="upstage/solar-pro4", + messages=[{"role": "user", "content": "Hello!"}], + ) + + assert len(respx_mock.calls) == 1 + request = respx_mock.calls[0].request + + assert str(request.url) == "https://api.upstage.ai/v1/chat/completions" + assert request.headers["Authorization"] == "Bearer up-env-key" + + body = json.loads(request.content) + assert body["model"] == "solar-pro4" + assert body["messages"] == [{"role": "user", "content": "Hello!"}] + + assert response.choices[0].message.content == "Hello from Solar" + + @pytest.mark.respx() + def test_streaming_reaches_upstage_and_reassembles(self, respx_mock, monkeypatch: pytest.MonkeyPatch): + monkeypatch.setenv("UPSTAGE_API_KEY", "up-env-key") + + respx_mock.post("https://api.upstage.ai/v1/chat/completions").mock( + return_value=httpx.Response( + 200, + headers={"Content-Type": "text/event-stream"}, + content=STREAMING_RESPONSE.encode(), + ) + ) + + chunks = list( + completion( + model="upstage/solar-pro4", + messages=[{"role": "user", "content": "Hello!"}], + stream=True, + ) + ) + + request = respx_mock.calls[0].request + assert str(request.url) == "https://api.upstage.ai/v1/chat/completions" + assert json.loads(request.content)["stream"] is True + + assert "".join(chunk.choices[0].delta.content or "" for chunk in chunks) == "Hello" + + +class TestUpstageParamHandling: + def test_max_completion_tokens_is_not_rewritten(self): + """Upstage takes `max_completion_tokens` natively, so the usual rewrite to `max_tokens` would break it.""" + from litellm.llms.openai_like.dynamic_config import create_config_class + from litellm.llms.openai_like.json_loader import JSONProviderRegistry + + provider = JSONProviderRegistry.get("upstage") + assert provider is not None + + optional_params = create_config_class(provider)().map_openai_params( + non_default_params={"max_completion_tokens": 256}, + optional_params={}, + model="solar-pro4", + drop_params=False, + ) + + assert optional_params["max_completion_tokens"] == 256 + assert "max_tokens" not in optional_params + + +class TestUpstageEndpointSupportMatrix: + @pytest.mark.parametrize( + "matrix_path", + [ + Path(__file__).parents[4] / "provider_endpoints_support.json", + Path(litellm.__file__).parent / "provider_endpoints_support_backup.json", + ], + ) + def test_matrix_declares_chat_completions_only(self, matrix_path: Path): + endpoints = json.loads(matrix_path.read_text())["providers"]["upstage"]["endpoints"] + + assert endpoints["chat_completions"] is True + assert endpoints["responses"] is False + assert endpoints["messages"] is False + assert endpoints["embeddings"] is False