From c96fe090fd2ec0a8dd76f75a71016735feb74c96 Mon Sep 17 00:00:00 2001 From: togear Date: Mon, 7 Sep 2026 18:36:40 +0800 Subject: [PATCH 1/5] feat(zai): native Anthropic Messages and Responses API passthrough --- litellm/llms/zai/messages/__init__.py | 1 + litellm/llms/zai/messages/transformation.py | 85 +++++++++++++++++++ litellm/llms/zai/responses/__init__.py | 1 + litellm/llms/zai/responses/transformation.py | 52 ++++++++++++ litellm/utils.py | 12 +++ ...t_zai_anthropic_messages_transformation.py | 77 +++++++++++++++++ .../zai/test_zai_responses_transformation.py | 57 +++++++++++++ 7 files changed, 285 insertions(+) create mode 100644 litellm/llms/zai/messages/__init__.py create mode 100644 litellm/llms/zai/messages/transformation.py create mode 100644 litellm/llms/zai/responses/__init__.py create mode 100644 litellm/llms/zai/responses/transformation.py create mode 100644 tests/test_litellm/llms/zai/test_zai_anthropic_messages_transformation.py create mode 100644 tests/test_litellm/llms/zai/test_zai_responses_transformation.py diff --git a/litellm/llms/zai/messages/__init__.py b/litellm/llms/zai/messages/__init__.py new file mode 100644 index 00000000000..8b137891791 --- /dev/null +++ b/litellm/llms/zai/messages/__init__.py @@ -0,0 +1 @@ + diff --git a/litellm/llms/zai/messages/transformation.py b/litellm/llms/zai/messages/transformation.py new file mode 100644 index 00000000000..d11a50b5920 --- /dev/null +++ b/litellm/llms/zai/messages/transformation.py @@ -0,0 +1,85 @@ +""" +Z.AI Anthropic-compatible messages transformation config. +""" + +from typing import Any, Final + +import litellm +from litellm.llms.anthropic.experimental_pass_through.messages.transformation import ( + AnthropicMessagesConfig, +) +from litellm.secret_managers.main import get_secret_str + + +class ZAIAnthropicMessagesConfig(AnthropicMessagesConfig): + """ + Z.AI exposes an Anthropic-compatible Messages API at + https://api.z.ai/api/anthropic (see + https://docs.z.ai/guides/llm/glm-5.3). + + The endpoint accepts the native Anthropic Messages conversation shape + and authenticates with the Z.AI API key sent as the Anthropic + ``x-api-key`` header. + """ + + @property + def custom_llm_provider(self) -> str | None: + return "zai" + + @staticmethod + def get_api_key(api_key: str | None = None) -> str | None: + return api_key or get_secret_str("ZAI_API_KEY") or litellm.api_key + + @staticmethod + def get_api_base(api_base: str | None = None) -> str: + return api_base or get_secret_str("ZAI_ANTHROPIC_API_BASE") or "https://api.z.ai/api/anthropic" + + def validate_anthropic_messages_environment( + self, + headers: dict, + model: str, + messages: list[Any], + optional_params: dict, + litellm_params: dict, + api_key: str | None = None, + api_base: str | None = None, + ) -> tuple[dict, str | None]: + dynamic_api_key: Final = self.get_api_key(api_key=api_key) + + if "x-api-key" not in headers and "authorization" not in headers and dynamic_api_key is not None: + headers["x-api-key"] = dynamic_api_key + + if "anthropic-version" not in headers: + headers["anthropic-version"] = "2023-06-01" + if "content-type" not in headers: + headers["content-type"] = "application/json" + + headers = self._update_headers_with_anthropic_beta( + headers=headers, + optional_params=optional_params, + custom_llm_provider=self.custom_llm_provider or "zai", + ) + + return headers, api_base + + def get_complete_url( + self, + api_base: str | None, + api_key: str | None, + model: str, + optional_params: dict, + litellm_params: dict, + stream: bool | None = None, + ) -> str: + base_url = self.get_api_base(api_base=api_base).rstrip("/") + + if base_url.endswith("/v1/messages"): + return base_url + base_url = base_url.removesuffix("/v1/messages") + base_url = base_url.removesuffix("/v1") + base_url = base_url.removesuffix("/beta") + + if not base_url.endswith("/anthropic") and "/anthropic/" not in base_url: + base_url = f"{base_url}/anthropic" + + return f"{base_url}/v1/messages" diff --git a/litellm/llms/zai/responses/__init__.py b/litellm/llms/zai/responses/__init__.py new file mode 100644 index 00000000000..8b137891791 --- /dev/null +++ b/litellm/llms/zai/responses/__init__.py @@ -0,0 +1 @@ + diff --git a/litellm/llms/zai/responses/transformation.py b/litellm/llms/zai/responses/transformation.py new file mode 100644 index 00000000000..fc562bd2cb3 --- /dev/null +++ b/litellm/llms/zai/responses/transformation.py @@ -0,0 +1,52 @@ +""" +Z.AI OpenAI-compatible Responses API transformation config. +""" + +from typing import Final + +import litellm +from litellm.llms.openai.responses.transformation import OpenAIResponsesAPIConfig +from litellm.secret_managers.main import get_secret_str +from litellm.types.router import GenericLiteLLMParams +from litellm.types.utils import LlmProviders + + +class ZAIResponsesAPIConfig(OpenAIResponsesAPIConfig): + """ + Z.AI exposes an OpenAI-compatible Responses API at + https://api.z.ai/api/v1 (see https://docs.z.ai/guides/llm/glm-5.3). + + The endpoint authenticates with the Z.AI API key sent as an + ``Authorization: Bearer`` header. + """ + + @property + def custom_llm_provider(self) -> LlmProviders: + return LlmProviders.ZAI + + def validate_environment( + self, + headers: dict, + model: str, + litellm_params: GenericLiteLLMParams | None, + ) -> dict: + litellm_params = litellm_params or GenericLiteLLMParams() + + api_key: Final = litellm_params.api_key or litellm.api_key or get_secret_str("ZAI_API_KEY") + + headers.setdefault("Content-Type", "application/json") + if api_key is not None: + headers["Authorization"] = f"Bearer {api_key}" + return headers + + def get_complete_url( + self, + api_base: str | None, + litellm_params: dict, + ) -> str: + base_url = api_base or get_secret_str("ZAI_RESPONSES_API_BASE") or "https://api.z.ai/api/v1" + + base_url = base_url.rstrip("/") + if base_url.endswith("/responses"): + return base_url + return f"{base_url}/responses" diff --git a/litellm/utils.py b/litellm/utils.py index 52c1859b525..afcf8770066 100644 --- a/litellm/utils.py +++ b/litellm/utils.py @@ -8482,6 +8482,12 @@ class ProviderConfigManager: ) return TencentAnthropicMessagesConfig() + elif litellm.LlmProviders.ZAI == provider: + from litellm.llms.zai.messages.transformation import ( + ZAIAnthropicMessagesConfig, + ) + + return ZAIAnthropicMessagesConfig() elif litellm.LlmProviders.GITHUB_COPILOT == provider: if "claude" in model_lower: from litellm.llms.github_copilot.messages.transformation import ( @@ -8703,6 +8709,12 @@ class ProviderConfigManager: return litellm.BedrockMantleResponsesAPIConfig( use_openai_path=mantle_base_segment(model, litellm.model_cost) == "openai/v1" ) + elif litellm.LlmProviders.ZAI == provider: + from litellm.llms.zai.responses.transformation import ( + ZAIResponsesAPIConfig, + ) + + return ZAIResponsesAPIConfig() return None @staticmethod diff --git a/tests/test_litellm/llms/zai/test_zai_anthropic_messages_transformation.py b/tests/test_litellm/llms/zai/test_zai_anthropic_messages_transformation.py new file mode 100644 index 00000000000..ef5636e8086 --- /dev/null +++ b/tests/test_litellm/llms/zai/test_zai_anthropic_messages_transformation.py @@ -0,0 +1,77 @@ +import litellm +from litellm.llms.anthropic.experimental_pass_through.messages.transformation import ( + AnthropicMessagesConfig, +) +from litellm.llms.zai.messages.transformation import ZAIAnthropicMessagesConfig +from litellm.utils import ProviderConfigManager + + +def test_zai_provider_uses_anthropic_messages_config(): + config = ProviderConfigManager.get_provider_anthropic_messages_config( + model="glm-5.3", + provider=litellm.LlmProviders.ZAI, + ) + + assert isinstance(config, ZAIAnthropicMessagesConfig) + assert config.custom_llm_provider == "zai" + + +def test_anthropic_provider_keeps_default_config_for_zai_named_model(): + config = ProviderConfigManager.get_provider_anthropic_messages_config( + model="glm-5.3", + provider=litellm.LlmProviders.ANTHROPIC, + ) + + assert isinstance(config, AnthropicMessagesConfig) + assert not isinstance(config, ZAIAnthropicMessagesConfig) + + +def test_zai_anthropic_messages_config_defaults(monkeypatch): + monkeypatch.delenv("ZAI_ANTHROPIC_API_BASE", raising=False) + config = ZAIAnthropicMessagesConfig() + + assert config.custom_llm_provider == "zai" + assert config.get_api_base() == "https://api.z.ai/api/anthropic" + + +def test_zai_anthropic_messages_url_defaults_to_anthropic_endpoint(): + config = ZAIAnthropicMessagesConfig() + + url_cases = { + None: "https://api.z.ai/api/anthropic/v1/messages", + "https://api.z.ai/api/anthropic": "https://api.z.ai/api/anthropic/v1/messages", + "https://api.z.ai/api/anthropic/v1": "https://api.z.ai/api/anthropic/v1/messages", + "https://api.z.ai/api/anthropic/v1/messages": "https://api.z.ai/api/anthropic/v1/messages", + "https://api.z.ai/api": "https://api.z.ai/api/anthropic/v1/messages", + } + + for api_base, expected_url in url_cases.items(): + assert ( + config.get_complete_url( + api_base=api_base, + api_key=None, + model="glm-5.3", + optional_params={}, + litellm_params={}, + ) + == expected_url + ) + + +def test_zai_anthropic_messages_headers_use_zai_key(): + config = ZAIAnthropicMessagesConfig() + + headers, api_base = config.validate_anthropic_messages_environment( + headers={}, + model="glm-5.3", + messages=[], + optional_params={}, + litellm_params={}, + api_key="sk-zai", + api_base="https://example.test/anthropic", + ) + + assert api_base == "https://example.test/anthropic" + assert headers["x-api-key"] == "sk-zai" + assert headers["anthropic-version"] == "2023-06-01" + assert headers["content-type"] == "application/json" diff --git a/tests/test_litellm/llms/zai/test_zai_responses_transformation.py b/tests/test_litellm/llms/zai/test_zai_responses_transformation.py new file mode 100644 index 00000000000..0925ff21a22 --- /dev/null +++ b/tests/test_litellm/llms/zai/test_zai_responses_transformation.py @@ -0,0 +1,57 @@ +import litellm +from litellm.llms.zai.responses.transformation import ZAIResponsesAPIConfig +from litellm.types.router import GenericLiteLLMParams +from litellm.types.utils import LlmProviders +from litellm.utils import ProviderConfigManager + + +def test_zai_provider_uses_responses_api_config(): + config = ProviderConfigManager.get_provider_responses_api_config( + model="glm-5.3", + provider=litellm.LlmProviders.ZAI, + ) + + assert isinstance(config, ZAIResponsesAPIConfig) + assert config.custom_llm_provider == LlmProviders.ZAI + + +def test_zai_responses_url_defaults_to_responses_endpoint(monkeypatch): + monkeypatch.delenv("ZAI_RESPONSES_API_BASE", raising=False) + config = ZAIResponsesAPIConfig() + + url_cases = { + None: "https://api.z.ai/api/v1/responses", + "https://api.z.ai/api/v1": "https://api.z.ai/api/v1/responses", + "https://api.z.ai/api/v1/": "https://api.z.ai/api/v1/responses", + "https://api.z.ai/api/v1/responses": "https://api.z.ai/api/v1/responses", + } + + for api_base, expected_url in url_cases.items(): + assert config.get_complete_url(api_base=api_base, litellm_params={}) == expected_url + + +def test_zai_responses_headers_use_bearer_token(): + config = ZAIResponsesAPIConfig() + litellm_params = GenericLiteLLMParams(api_key="sk-zai") + + headers = config.validate_environment( + headers={}, + model="glm-5.3", + litellm_params=litellm_params, + ) + + assert headers["Authorization"] == "Bearer sk-zai" + assert headers["Content-Type"] == "application/json" + + +def test_zai_responses_headers_fall_back_to_environment_key(monkeypatch): + monkeypatch.setenv("ZAI_API_KEY", "sk-zai-env") + config = ZAIResponsesAPIConfig() + + headers = config.validate_environment( + headers={}, + model="glm-5.3", + litellm_params=GenericLiteLLMParams(), + ) + + assert headers["Authorization"] == "Bearer sk-zai-env" From 92c1eebe5679d4d6118d4489993f3bb9e96c5915 Mon Sep 17 00:00:00 2001 From: togear Date: Mon, 7 Sep 2026 18:52:05 +0800 Subject: [PATCH 2/5] fix(zai): ignore chat-completions api base on responses route --- litellm/llms/zai/responses/transformation.py | 14 ++++++- .../zai/test_zai_responses_transformation.py | 40 +++++++++++++++++++ 2 files changed, 53 insertions(+), 1 deletion(-) diff --git a/litellm/llms/zai/responses/transformation.py b/litellm/llms/zai/responses/transformation.py index fc562bd2cb3..c47b8b5943a 100644 --- a/litellm/llms/zai/responses/transformation.py +++ b/litellm/llms/zai/responses/transformation.py @@ -20,6 +20,8 @@ class ZAIResponsesAPIConfig(OpenAIResponsesAPIConfig): ``Authorization: Bearer`` header. """ + _ZAI_CHAT_API_BASE_SUFFIXES: Final = ("/api/paas/v4", "/api/coding/paas/v4") + @property def custom_llm_provider(self) -> LlmProviders: return LlmProviders.ZAI @@ -44,7 +46,17 @@ class ZAIResponsesAPIConfig(OpenAIResponsesAPIConfig): api_base: str | None, litellm_params: dict, ) -> str: - base_url = api_base or get_secret_str("ZAI_RESPONSES_API_BASE") or "https://api.z.ai/api/v1" + # ``litellm_params.api_base`` can carry the Z.AI chat-completions base + # (``/api/paas/v4``) when the generic provider resolver pre-fills it from + # the chat config. Z.AI serves Responses on a different base, so ignore + # the chat-only bases and use the Responses base instead. + normalized_api_base = (api_base or "").rstrip("/") + chat_base_passed_in: Final = normalized_api_base.endswith(self._ZAI_CHAT_API_BASE_SUFFIXES) + base_url = ( + api_base + if api_base and not chat_base_passed_in + else get_secret_str("ZAI_RESPONSES_API_BASE") or "https://api.z.ai/api/v1" + ) base_url = base_url.rstrip("/") if base_url.endswith("/responses"): diff --git a/tests/test_litellm/llms/zai/test_zai_responses_transformation.py b/tests/test_litellm/llms/zai/test_zai_responses_transformation.py index 0925ff21a22..064bc21d9b5 100644 --- a/tests/test_litellm/llms/zai/test_zai_responses_transformation.py +++ b/tests/test_litellm/llms/zai/test_zai_responses_transformation.py @@ -30,6 +30,46 @@ def test_zai_responses_url_defaults_to_responses_endpoint(monkeypatch): assert config.get_complete_url(api_base=api_base, litellm_params={}) == expected_url +def test_zai_responses_url_ignores_chat_completions_api_base(monkeypatch): + monkeypatch.delenv("ZAI_RESPONSES_API_BASE", raising=False) + config = ZAIResponsesAPIConfig() + + chat_bases = ( + "https://api.z.ai/api/paas/v4", + "https://api.z.ai/api/paas/v4/", + "https://api.z.ai/api/coding/paas/v4", + ) + + for chat_base in chat_bases: + assert config.get_complete_url(api_base=chat_base, litellm_params={}) == "https://api.z.ai/api/v1/responses" + + +def test_zai_responses_url_keeps_custom_api_base(monkeypatch): + monkeypatch.delenv("ZAI_RESPONSES_API_BASE", raising=False) + config = ZAIResponsesAPIConfig() + + assert ( + config.get_complete_url( + api_base="https://gateway.example.com/openai/v1", + litellm_params={}, + ) + == "https://gateway.example.com/openai/v1/responses" + ) + + +def test_zai_responses_url_env_overrides_chat_completions_api_base(monkeypatch): + monkeypatch.setenv("ZAI_RESPONSES_API_BASE", "https://gateway.example.com/responses-root") + config = ZAIResponsesAPIConfig() + + assert ( + config.get_complete_url( + api_base="https://api.z.ai/api/paas/v4", + litellm_params={}, + ) + == "https://gateway.example.com/responses-root/responses" + ) + + def test_zai_responses_headers_use_bearer_token(): config = ZAIResponsesAPIConfig() litellm_params = GenericLiteLLMParams(api_key="sk-zai") From 75ffe2112d71ea0883bc1c297be61f18cbdc5ecd Mon Sep 17 00:00:00 2001 From: togear Date: Mon, 7 Sep 2026 19:15:18 +0800 Subject: [PATCH 3/5] fix(zai): address review and CI feedback for protocol passthrough --- litellm/llms/zai/messages/transformation.py | 21 +++++----- litellm/llms/zai/responses/transformation.py | 14 +++---- ...t_zai_anthropic_messages_transformation.py | 23 ++++++++++- .../zai/test_zai_responses_transformation.py | 40 ++++++++++--------- 4 files changed, 58 insertions(+), 40 deletions(-) diff --git a/litellm/llms/zai/messages/transformation.py b/litellm/llms/zai/messages/transformation.py index d11a50b5920..c802798d2d9 100644 --- a/litellm/llms/zai/messages/transformation.py +++ b/litellm/llms/zai/messages/transformation.py @@ -2,7 +2,7 @@ Z.AI Anthropic-compatible messages transformation config. """ -from typing import Any, Final +from typing import Final import litellm from litellm.llms.anthropic.experimental_pass_through.messages.transformation import ( @@ -32,21 +32,22 @@ class ZAIAnthropicMessagesConfig(AnthropicMessagesConfig): @staticmethod def get_api_base(api_base: str | None = None) -> str: - return api_base or get_secret_str("ZAI_ANTHROPIC_API_BASE") or "https://api.z.ai/api/anthropic" + return api_base or "https://api.z.ai/api/anthropic" def validate_anthropic_messages_environment( self, - headers: dict, + headers: dict[str, str], model: str, - messages: list[Any], - optional_params: dict, - litellm_params: dict, + messages: list[dict[str, object]], + optional_params: dict[str, object], + litellm_params: dict[str, object], api_key: str | None = None, api_base: str | None = None, - ) -> tuple[dict, str | None]: + ) -> tuple[dict[str, str], str | None]: dynamic_api_key: Final = self.get_api_key(api_key=api_key) + header_names: Final = {header_name.lower() for header_name in headers} - if "x-api-key" not in headers and "authorization" not in headers and dynamic_api_key is not None: + if "x-api-key" not in header_names and "authorization" not in header_names and dynamic_api_key is not None: headers["x-api-key"] = dynamic_api_key if "anthropic-version" not in headers: @@ -67,8 +68,8 @@ class ZAIAnthropicMessagesConfig(AnthropicMessagesConfig): api_base: str | None, api_key: str | None, model: str, - optional_params: dict, - litellm_params: dict, + optional_params: dict[str, object], + litellm_params: dict[str, object], stream: bool | None = None, ) -> str: base_url = self.get_api_base(api_base=api_base).rstrip("/") diff --git a/litellm/llms/zai/responses/transformation.py b/litellm/llms/zai/responses/transformation.py index c47b8b5943a..8a00a64ead8 100644 --- a/litellm/llms/zai/responses/transformation.py +++ b/litellm/llms/zai/responses/transformation.py @@ -28,13 +28,13 @@ class ZAIResponsesAPIConfig(OpenAIResponsesAPIConfig): def validate_environment( self, - headers: dict, + headers: dict[str, str], model: str, litellm_params: GenericLiteLLMParams | None, - ) -> dict: + ) -> dict[str, str]: litellm_params = litellm_params or GenericLiteLLMParams() - api_key: Final = litellm_params.api_key or litellm.api_key or get_secret_str("ZAI_API_KEY") + api_key: Final = litellm_params.api_key or get_secret_str("ZAI_API_KEY") or litellm.api_key headers.setdefault("Content-Type", "application/json") if api_key is not None: @@ -44,7 +44,7 @@ class ZAIResponsesAPIConfig(OpenAIResponsesAPIConfig): def get_complete_url( self, api_base: str | None, - litellm_params: dict, + litellm_params: dict[str, object], ) -> str: # ``litellm_params.api_base`` can carry the Z.AI chat-completions base # (``/api/paas/v4``) when the generic provider resolver pre-fills it from @@ -52,11 +52,7 @@ class ZAIResponsesAPIConfig(OpenAIResponsesAPIConfig): # the chat-only bases and use the Responses base instead. normalized_api_base = (api_base or "").rstrip("/") chat_base_passed_in: Final = normalized_api_base.endswith(self._ZAI_CHAT_API_BASE_SUFFIXES) - base_url = ( - api_base - if api_base and not chat_base_passed_in - else get_secret_str("ZAI_RESPONSES_API_BASE") or "https://api.z.ai/api/v1" - ) + base_url = api_base if api_base and not chat_base_passed_in else "https://api.z.ai/api/v1" base_url = base_url.rstrip("/") if base_url.endswith("/responses"): diff --git a/tests/test_litellm/llms/zai/test_zai_anthropic_messages_transformation.py b/tests/test_litellm/llms/zai/test_zai_anthropic_messages_transformation.py index ef5636e8086..cdc6f46e357 100644 --- a/tests/test_litellm/llms/zai/test_zai_anthropic_messages_transformation.py +++ b/tests/test_litellm/llms/zai/test_zai_anthropic_messages_transformation.py @@ -26,8 +26,7 @@ def test_anthropic_provider_keeps_default_config_for_zai_named_model(): assert not isinstance(config, ZAIAnthropicMessagesConfig) -def test_zai_anthropic_messages_config_defaults(monkeypatch): - monkeypatch.delenv("ZAI_ANTHROPIC_API_BASE", raising=False) +def test_zai_anthropic_messages_config_defaults(): config = ZAIAnthropicMessagesConfig() assert config.custom_llm_provider == "zai" @@ -75,3 +74,23 @@ def test_zai_anthropic_messages_headers_use_zai_key(): assert headers["x-api-key"] == "sk-zai" assert headers["anthropic-version"] == "2023-06-01" assert headers["content-type"] == "application/json" + + +def test_zai_anthropic_messages_respects_existing_case_insensitive_auth_headers(): + config = ZAIAnthropicMessagesConfig() + + headers, _ = config.validate_anthropic_messages_environment( + headers={"Authorization": "Bearer caller-token"}, + model="glm-5.3", + messages=[], + optional_params={}, + litellm_params={}, + api_key="sk-zai", + api_base="https://api.z.ai/api/anthropic", + ) + + assert headers == { + "Authorization": "Bearer caller-token", + "anthropic-version": "2023-06-01", + "content-type": "application/json", + } diff --git a/tests/test_litellm/llms/zai/test_zai_responses_transformation.py b/tests/test_litellm/llms/zai/test_zai_responses_transformation.py index 064bc21d9b5..c5f369d2110 100644 --- a/tests/test_litellm/llms/zai/test_zai_responses_transformation.py +++ b/tests/test_litellm/llms/zai/test_zai_responses_transformation.py @@ -15,8 +15,7 @@ def test_zai_provider_uses_responses_api_config(): assert config.custom_llm_provider == LlmProviders.ZAI -def test_zai_responses_url_defaults_to_responses_endpoint(monkeypatch): - monkeypatch.delenv("ZAI_RESPONSES_API_BASE", raising=False) +def test_zai_responses_url_defaults_to_responses_endpoint(): config = ZAIResponsesAPIConfig() url_cases = { @@ -30,8 +29,7 @@ def test_zai_responses_url_defaults_to_responses_endpoint(monkeypatch): assert config.get_complete_url(api_base=api_base, litellm_params={}) == expected_url -def test_zai_responses_url_ignores_chat_completions_api_base(monkeypatch): - monkeypatch.delenv("ZAI_RESPONSES_API_BASE", raising=False) +def test_zai_responses_url_ignores_chat_completions_api_base(): config = ZAIResponsesAPIConfig() chat_bases = ( @@ -44,8 +42,7 @@ def test_zai_responses_url_ignores_chat_completions_api_base(monkeypatch): assert config.get_complete_url(api_base=chat_base, litellm_params={}) == "https://api.z.ai/api/v1/responses" -def test_zai_responses_url_keeps_custom_api_base(monkeypatch): - monkeypatch.delenv("ZAI_RESPONSES_API_BASE", raising=False) +def test_zai_responses_url_keeps_custom_api_base(): config = ZAIResponsesAPIConfig() assert ( @@ -57,19 +54,6 @@ def test_zai_responses_url_keeps_custom_api_base(monkeypatch): ) -def test_zai_responses_url_env_overrides_chat_completions_api_base(monkeypatch): - monkeypatch.setenv("ZAI_RESPONSES_API_BASE", "https://gateway.example.com/responses-root") - config = ZAIResponsesAPIConfig() - - assert ( - config.get_complete_url( - api_base="https://api.z.ai/api/paas/v4", - litellm_params={}, - ) - == "https://gateway.example.com/responses-root/responses" - ) - - def test_zai_responses_headers_use_bearer_token(): config = ZAIResponsesAPIConfig() litellm_params = GenericLiteLLMParams(api_key="sk-zai") @@ -95,3 +79,21 @@ def test_zai_responses_headers_fall_back_to_environment_key(monkeypatch): ) assert headers["Authorization"] == "Bearer sk-zai-env" + + +def test_zai_responses_headers_prefer_zai_key_over_global_key(monkeypatch): + monkeypatch.setenv("ZAI_API_KEY", "sk-zai-env") + original_api_key = litellm.api_key + litellm.api_key = "sk-global-other-provider" + try: + config = ZAIResponsesAPIConfig() + + headers = config.validate_environment( + headers={}, + model="glm-5.3", + litellm_params=GenericLiteLLMParams(), + ) + finally: + litellm.api_key = original_api_key + + assert headers["Authorization"] == "Bearer sk-zai-env" From 1b19dea96dab3a422e57c505cd99e09d63865bde Mon Sep 17 00:00:00 2001 From: togear Date: Mon, 7 Sep 2026 19:32:38 +0800 Subject: [PATCH 4/5] refactor(zai): satisfy type discipline gates on protocol configs --- litellm/llms/zai/messages/transformation.py | 52 +++++++------------- litellm/llms/zai/responses/transformation.py | 30 ++++++----- 2 files changed, 36 insertions(+), 46 deletions(-) diff --git a/litellm/llms/zai/messages/transformation.py b/litellm/llms/zai/messages/transformation.py index c802798d2d9..91ae5ff70cc 100644 --- a/litellm/llms/zai/messages/transformation.py +++ b/litellm/llms/zai/messages/transformation.py @@ -2,6 +2,7 @@ Z.AI Anthropic-compatible messages transformation config. """ +from collections.abc import Mapping from typing import Final import litellm @@ -36,51 +37,36 @@ class ZAIAnthropicMessagesConfig(AnthropicMessagesConfig): def validate_anthropic_messages_environment( self, - headers: dict[str, str], + headers: Mapping[str, str], model: str, - messages: list[dict[str, object]], - optional_params: dict[str, object], - litellm_params: dict[str, object], + messages: list[Mapping[str, object]], # mutable-ok: matches the pass-through handler's message list contract + optional_params: Mapping[str, object], + litellm_params: Mapping[str, object], api_key: str | None = None, api_base: str | None = None, - ) -> tuple[dict[str, str], str | None]: - dynamic_api_key: Final = self.get_api_key(api_key=api_key) - header_names: Final = {header_name.lower() for header_name in headers} - - if "x-api-key" not in header_names and "authorization" not in header_names and dynamic_api_key is not None: - headers["x-api-key"] = dynamic_api_key - - if "anthropic-version" not in headers: - headers["anthropic-version"] = "2023-06-01" - if "content-type" not in headers: - headers["content-type"] = "application/json" - - headers = self._update_headers_with_anthropic_beta( + ) -> tuple[dict[str, str], str | None]: # mutable-ok: the handler owns and mutates the returned headers dict + return super().validate_anthropic_messages_environment( headers=headers, + model=model, + messages=messages, optional_params=optional_params, - custom_llm_provider=self.custom_llm_provider or "zai", + litellm_params=litellm_params, + api_key=self.get_api_key(api_key=api_key), + api_base=api_base, ) - return headers, api_base - def get_complete_url( self, api_base: str | None, api_key: str | None, model: str, - optional_params: dict[str, object], - litellm_params: dict[str, object], + optional_params: Mapping[str, object], + litellm_params: Mapping[str, object], stream: bool | None = None, ) -> str: - base_url = self.get_api_base(api_base=api_base).rstrip("/") + raw_base_url: Final = self.get_api_base(api_base=api_base).rstrip("/") + root_url: Final = raw_base_url.removesuffix("/v1/messages").removesuffix("/v1").removesuffix("/beta") - if base_url.endswith("/v1/messages"): - return base_url - base_url = base_url.removesuffix("/v1/messages") - base_url = base_url.removesuffix("/v1") - base_url = base_url.removesuffix("/beta") - - if not base_url.endswith("/anthropic") and "/anthropic/" not in base_url: - base_url = f"{base_url}/anthropic" - - return f"{base_url}/v1/messages" + if root_url.endswith("/anthropic") or "/anthropic/" in root_url: + return f"{root_url}/v1/messages" + return f"{root_url}/anthropic/v1/messages" diff --git a/litellm/llms/zai/responses/transformation.py b/litellm/llms/zai/responses/transformation.py index 8a00a64ead8..cfcedc2b868 100644 --- a/litellm/llms/zai/responses/transformation.py +++ b/litellm/llms/zai/responses/transformation.py @@ -2,6 +2,7 @@ Z.AI OpenAI-compatible Responses API transformation config. """ +from collections.abc import Mapping from typing import Final import litellm @@ -26,35 +27,38 @@ class ZAIResponsesAPIConfig(OpenAIResponsesAPIConfig): def custom_llm_provider(self) -> LlmProviders: return LlmProviders.ZAI + @staticmethod + def get_api_key(api_key: str | None = None) -> str | None: + return api_key or get_secret_str("ZAI_API_KEY") or litellm.api_key + def validate_environment( self, - headers: dict[str, str], + headers: Mapping[str, str], model: str, litellm_params: GenericLiteLLMParams | None, - ) -> dict[str, str]: - litellm_params = litellm_params or GenericLiteLLMParams() + ) -> dict[str, str]: # mutable-ok: the responses handler owns and mutates the returned headers dict + request_api_key: Final = litellm_params.api_key if litellm_params is not None else None + resolved_params: Final = GenericLiteLLMParams(api_key=self.get_api_key(api_key=request_api_key)) - api_key: Final = litellm_params.api_key or get_secret_str("ZAI_API_KEY") or litellm.api_key - - headers.setdefault("Content-Type", "application/json") - if api_key is not None: - headers["Authorization"] = f"Bearer {api_key}" - return headers + return super().validate_environment( + headers=headers, + model=model, + litellm_params=resolved_params, + ) def get_complete_url( self, api_base: str | None, - litellm_params: dict[str, object], + litellm_params: Mapping[str, object], ) -> str: # ``litellm_params.api_base`` can carry the Z.AI chat-completions base # (``/api/paas/v4``) when the generic provider resolver pre-fills it from # the chat config. Z.AI serves Responses on a different base, so ignore # the chat-only bases and use the Responses base instead. - normalized_api_base = (api_base or "").rstrip("/") + normalized_api_base: Final = (api_base or "").rstrip("/") chat_base_passed_in: Final = normalized_api_base.endswith(self._ZAI_CHAT_API_BASE_SUFFIXES) - base_url = api_base if api_base and not chat_base_passed_in else "https://api.z.ai/api/v1" + base_url: Final = normalized_api_base if api_base and not chat_base_passed_in else "https://api.z.ai/api/v1" - base_url = base_url.rstrip("/") if base_url.endswith("/responses"): return base_url return f"{base_url}/responses" From 234a4b69080aac4836269bedf134e2f6dcccf60d Mon Sep 17 00:00:00 2001 From: togear Date: Mon, 7 Sep 2026 19:39:30 +0800 Subject: [PATCH 5/5] tests(zai): use monkeypatch.setattr for litellm.api_key to satisfy test-quality rules --- .../zai/test_zai_responses_transformation.py | 19 ++++++++----------- 1 file changed, 8 insertions(+), 11 deletions(-) diff --git a/tests/test_litellm/llms/zai/test_zai_responses_transformation.py b/tests/test_litellm/llms/zai/test_zai_responses_transformation.py index c5f369d2110..40f69a77943 100644 --- a/tests/test_litellm/llms/zai/test_zai_responses_transformation.py +++ b/tests/test_litellm/llms/zai/test_zai_responses_transformation.py @@ -83,17 +83,14 @@ def test_zai_responses_headers_fall_back_to_environment_key(monkeypatch): def test_zai_responses_headers_prefer_zai_key_over_global_key(monkeypatch): monkeypatch.setenv("ZAI_API_KEY", "sk-zai-env") - original_api_key = litellm.api_key - litellm.api_key = "sk-global-other-provider" - try: - config = ZAIResponsesAPIConfig() + monkeypatch.setattr(litellm, "api_key", "sk-global-other-provider", raising=False) - headers = config.validate_environment( - headers={}, - model="glm-5.3", - litellm_params=GenericLiteLLMParams(), - ) - finally: - litellm.api_key = original_api_key + config = ZAIResponsesAPIConfig() + + headers = config.validate_environment( + headers={}, + model="glm-5.3", + litellm_params=GenericLiteLLMParams(), + ) assert headers["Authorization"] == "Bearer sk-zai-env"