fix(bigmodel): map disabled thinking to low effort, keep Messages temperature, raise context window and content policy errors, and default parallel_tool_calls to true

This commit is contained in:
mateo-berri 2026-10-28 07:02:36 -07:00
parent 2a247465ff
commit 4c6bdbb2ff
7 changed files with 235 additions and 13 deletions

View file

@ -21,6 +21,8 @@ One model configuration supports all three native APIs. The provider selects the
`api_base` is optional and defaults to `https://open.bigmodel.cn`. Set `BIGMODEL_API_BASE` to override the default, or pass an explicit `api_base` to override the environment value. Existing API-specific base URLs and complete endpoint URLs are normalized, preserving a custom proxy's path prefix
A GLM Coding Plan key uses `https://open.bigmodel.cn/api/coding/paas/v4` as its `api_base`, since Coding Plan and standard keys do not work on each other's endpoints. Chat Completions then calls the Coding Plan endpoint, and Responses and Messages keep the endpoints in the table above
The Admin UI lists BigModel (Zhipu AI), displays its official logo, and provides a required password API Key field and an optional API Base field with the default URL. Authentication uses an explicit `api_key` or `BIGMODEL_API_KEY`
```python

View file

@ -18,6 +18,7 @@ from litellm.litellm_core_utils.bug_report import (
)
from litellm.litellm_core_utils.core_helpers import as_str_mapping, attribute_or_none
from litellm.litellm_core_utils.secret_redaction import redact_string as redact_secret_string
from litellm.llms.bigmodel.common_utils import raise_bigmodel_business_error
from litellm.types.utils import LlmProviders
from ..exceptions import (
@ -2758,6 +2759,8 @@ def exception_type(
exception_provider=exception_provider,
extra_information=extra_information,
)
if custom_llm_provider == "bigmodel":
raise_bigmodel_business_error(model, f"{exception_provider} - {error_str}", original_exception) # pyright: ignore[reportUnknownArgumentType] # exception_type takes untyped parameters
if custom_llm_provider == "openrouter":
_map_openrouter_exception(
model=model,

View file

@ -3,7 +3,7 @@ from typing import Final
from litellm.llms.zai.chat.transformation import ZAIChatConfig
from ..common_utils import get_bigmodel_api_key, get_bigmodel_url
from ..common_utils import get_bigmodel_api_key, get_bigmodel_url, is_unsupported_disabled_thinking
_SUPPORTED_PARAMS: Final = (
"max_tokens",
@ -49,7 +49,11 @@ class BigModelChatConfig(ZAIChatConfig):
}
if "thinking" not in non_default_params:
return mapped
return {
**mapped,
"extra_body": {"thinking": non_default_params["thinking"]},
}
thinking: Final = non_default_params["thinking"]
if is_unsupported_disabled_thinking(model, thinking):
return {
**mapped,
"reasoning_effort": mapped.get("reasoning_effort", "low"),
"extra_body": {"thinking": {"type": "enabled"}},
}
return {**mapped, "extra_body": {"thinking": thinking}}

View file

@ -1,10 +1,20 @@
import re
from collections.abc import Mapping
from types import MappingProxyType
from typing import Final, Literal
from urllib.parse import urlsplit, urlunsplit
import httpx
from pydantic import JsonValue, TypeAdapter, ValidationError
import litellm
from litellm.exceptions import ContentPolicyViolationError, ContextWindowExceededError
from litellm.llms.base_llm.chat.transformation import BaseLLMException
from litellm.secret_managers.main import get_secret_str
BIGMODEL_API_BASE: Final = "https://open.bigmodel.cn"
_JSON_OBJECT: Final = TypeAdapter(dict[str, JsonValue])
_BUSINESS_CODE_IN_ERROR: Final = re.compile(r"""['"]code['"]\s*:\s*['"]?(\d{4})\b""")
_CODING_CHAT_PATH: Final = "/api/coding/paas/v4"
_API_PATHS: Final = (
"/api/coding/paas/v4/chat/completions",
@ -42,3 +52,41 @@ def get_bigmodel_url(api_base: str | None, endpoint: Literal["chat", "responses"
return urlunsplit(
(parsed.scheme, parsed.netloc, f"{path.removesuffix(suffix)}{endpoint_path}", parsed.query, parsed.fragment)
)
def bigmodel_json_body(response: httpx.Response) -> Mapping[str, JsonValue]:
try:
return _JSON_OBJECT.validate_json(response.content)
except ValidationError:
raise BaseLLMException(message=response.text, status_code=response.status_code, headers=response.headers)
def is_unsupported_disabled_thinking(model: str, thinking: object) -> bool:
return thinking == {"type": "disabled"} and litellm.utils.supports_factory(
model=model, custom_llm_provider="bigmodel", key="thinking_always_on"
)
def bigmodel_business_error(
code: JsonValue, message: str, model: str, response: httpx.Response | None
) -> ContextWindowExceededError | ContentPolicyViolationError | None:
match code:
case 1261 | "1261":
return ContextWindowExceededError(message=message, model=model, llm_provider="bigmodel", response=response)
case 1301 | "1301":
return ContentPolicyViolationError(message=message, model=model, llm_provider="bigmodel", response=response)
case _:
return None
def raise_bigmodel_business_error(model: object, error_str: str, original_exception: object) -> None:
found: Final = _BUSINESS_CODE_IN_ERROR.search(error_str)
response: Final = getattr(original_exception, "response", None)
error: Final = bigmodel_business_error(
found.group(1) if found else None,
error_str,
str(model),
response if isinstance(response, httpx.Response) else None,
)
if error is not None:
raise error

View file

@ -13,10 +13,27 @@ from litellm.llms.openai_like.messages.transformation import (
OpenAILikeAnthropicMessagesConfig,
)
from litellm.types.llms.anthropic_messages.anthropic_response import AnthropicMessagesResponse
from litellm.types.router import GenericLiteLLMParams
from ..common_utils import get_bigmodel_api_key, get_bigmodel_url
from ..common_utils import (
bigmodel_json_body,
get_bigmodel_api_key,
get_bigmodel_url,
is_unsupported_disabled_thinking,
)
_JSON_OBJECT: Final = TypeAdapter(dict[str, JsonValue])
_OUTPUT_CONFIG: Final = TypeAdapter(dict[str, object])
def _with_lowest_effort_thinking(
params: Mapping[str, object],
) -> dict[str, object]: # mutable-ok: the parent transform takes a request dict
requested_output_config: Final = params.get("output_config")
output_config: Final = (
_OUTPUT_CONFIG.validate_python(requested_output_config) if isinstance(requested_output_config, dict) else {}
)
return {**params, "thinking": {"type": "adaptive"}, "output_config": {"effort": "low", **output_config}}
def _normalize_message(body: Mapping[str, JsonValue]) -> dict[str, JsonValue]: # mutable-ok: native JSON response
@ -86,12 +103,29 @@ class BigModelMessagesConfig(OpenAILikeAnthropicMessagesConfig):
def should_strip_billing_metadata(self) -> bool:
return True
def transform_anthropic_messages_request(
self,
model: str,
messages: list[dict[str, object]], # mutable-ok: matches dict-typed base signature
anthropic_messages_optional_request_params: dict[str, object], # mutable-ok: matches dict-typed base signature
litellm_params: GenericLiteLLMParams,
headers: dict[str, str], # mutable-ok: matches dict-typed base signature
) -> dict[str, object]: # mutable-ok: matches dict-typed base signature
request_params: Final = (
_with_lowest_effort_thinking(anthropic_messages_optional_request_params)
if is_unsupported_disabled_thinking(model, anthropic_messages_optional_request_params.get("thinking"))
else anthropic_messages_optional_request_params
)
return super().transform_anthropic_messages_request( # pyright: ignore[reportUnknownMemberType, reportUnknownVariableType] # the parent transform returns a bare dict
model, messages, request_params, litellm_params, headers
)
def transform_anthropic_messages_response(
self, model: str, raw_response: httpx.Response, logging_obj: Logging
) -> AnthropicMessagesResponse:
normalized: Final = httpx.Response(
raw_response.status_code,
json=_normalize_message(_JSON_OBJECT.validate_json(raw_response.content)),
json=_normalize_message(bigmodel_json_body(raw_response)),
request=raw_response.request,
)
return super().transform_anthropic_messages_response(model, normalized, logging_obj)
@ -129,6 +163,12 @@ class BigModelMessagesConfig(OpenAILikeAnthropicMessagesConfig):
) -> None:
return None
@staticmethod
def _drop_incompatible_temperature_for_thinking(
model: str, optional_params: Mapping[str, object], custom_llm_provider: str
) -> None:
return None
def validate_anthropic_messages_environment(
self,
headers: Mapping[str, str],

View file

@ -19,7 +19,7 @@ from litellm.types.llms.openai import (
from litellm.types.router import GenericLiteLLMParams
from litellm.types.utils import LlmProviders
from ..common_utils import get_bigmodel_api_key, get_bigmodel_url
from ..common_utils import bigmodel_business_error, bigmodel_json_body, get_bigmodel_api_key, get_bigmodel_url
_JSON_OBJECT: Final = TypeAdapter(dict[str, JsonValue])
_SUPPORTED_PARAMS: Final = (
@ -114,7 +114,7 @@ def _normalize_response(
normalized: Final[JsonValue] = {
**body,
"parallel_tool_calls": (
requested.parallel_tool_calls is True if parallel_tool_calls is None else parallel_tool_calls
requested.parallel_tool_calls is not False if parallel_tool_calls is None else parallel_tool_calls
),
"tools": (requested.tools or []) if tools is None else tools,
"tool_choice": (requested.tool_choice or "auto") if tool_choice is None else tool_choice,
@ -188,10 +188,11 @@ class BigModelResponsesAPIConfig(OpenAIResponsesAPIConfig):
def transform_response_api_response(
self, model: str, raw_response: httpx.Response, logging_obj: Logging
) -> ResponsesAPIResponse:
body: Final = _JSON_OBJECT.validate_json(raw_response.content)
body: Final = bigmodel_json_body(raw_response)
if body.get("success") is False:
raise BaseLLMException(
message=str(body.get("msg") or raw_response.text),
message: Final = str(body.get("msg") or raw_response.text)
raise bigmodel_business_error(body.get("code"), message, model, None) or BaseLLMException(
message=message,
status_code=_envelope_status_code(body.get("code")),
headers=raw_response.headers,
)

View file

@ -266,6 +266,81 @@ def test_messages_preserves_native_reasoning_params(reasoning_params: Mapping[st
assert {key: payload.get(key) for key in reasoning_params} == reasoning_params
@pytest.mark.parametrize(
("model", "extra", "expected"),
(
(MODEL, {}, {"thinking": {"type": "enabled"}, "reasoning_effort": "low"}),
(MODEL, {"reasoning_effort": "high"}, {"thinking": {"type": "enabled"}, "reasoning_effort": "high"}),
("glm-4.7", {}, {"thinking": {"type": "disabled"}, "reasoning_effort": None}),
),
)
@respx.mock
def test_chat_asks_an_always_thinking_model_for_its_lowest_effort_when_thinking_is_disabled(
model: str, extra: Mapping[str, JsonValue], expected: Mapping[str, JsonValue]
) -> None:
route: Final = respx.post(f"{BASE}/api/paas/v4/chat/completions").respond(
json={
"id": "chat_test",
"object": "chat.completion",
"created": 1,
"model": model,
"choices": [{"index": 0, "message": {"role": "assistant", "content": "Hello"}, "finish_reason": "stop"}],
"usage": {"prompt_tokens": 20, "completion_tokens": 10, "total_tokens": 30},
}
)
litellm.completion(
model=f"bigmodel/{model}",
messages=[{"role": "user", "content": "Hello"}],
thinking={"type": "disabled"},
**extra,
)
body: Final = json.loads(route.calls.last.request.content)
assert {key: body.get(key) for key in expected} == expected
@pytest.mark.parametrize(
("model", "extra", "expected"),
(
(MODEL, {}, {"thinking": {"type": "adaptive"}, "output_config": {"effort": "low"}}),
(
MODEL,
{"output_config": {"effort": "high"}},
{"thinking": {"type": "adaptive"}, "output_config": {"effort": "high"}},
),
("glm-4.7", {}, {"thinking": {"type": "disabled"}, "output_config": None}),
),
)
def test_messages_asks_an_always_thinking_model_for_its_lowest_effort_when_thinking_is_disabled(
model: str, extra: Mapping[str, JsonValue], expected: Mapping[str, JsonValue]
) -> None:
payload: Final = BigModelMessagesConfig().transform_anthropic_messages_request(
model=model,
messages=[{"role": "user", "content": "Hello"}],
anthropic_messages_optional_request_params={"max_tokens": 64, "thinking": {"type": "disabled"}, **extra},
litellm_params=GenericLiteLLMParams(),
headers={},
)
assert {key: payload.get(key) for key in expected} == expected
@pytest.mark.parametrize(
"reasoning_params",
({"thinking": {"type": "enabled", "budget_tokens": 1024}}, {"output_config": {"effort": "low"}}),
)
@pytest.mark.parametrize("temperature", (0, 0.2))
def test_messages_keeps_the_callers_temperature_next_to_thinking(
reasoning_params: Mapping[str, JsonValue], temperature: float
) -> None:
payload: Final = BigModelMessagesConfig().transform_anthropic_messages_request(
model=MODEL,
messages=[{"role": "user", "content": "Hello"}],
anthropic_messages_optional_request_params={"max_tokens": 2048, "temperature": temperature, **reasoning_params},
litellm_params=GenericLiteLLMParams(),
headers={},
)
assert payload.get("temperature") == temperature
@pytest.mark.asyncio
@respx.mock
@pytest.mark.parametrize("web_fetch_requests", (None, 2))
@ -385,7 +460,7 @@ async def test_responses_sse_without_done_preserves_terminal_usage(
assert chunks[-1].response.usage.total_tokens == 30
assert chunks[-1].response.output_text == "Hello"
OpenAIResponse.model_validate(chunks[-1].response.model_dump())
assert chunks[0].response.parallel_tool_calls is False
assert chunks[0].response.parallel_tool_calls is True
assert chunks[-2].logprobs == (logprobs if include_logprobs else [])
assert tuple(chunk.delta for chunk in chunks if chunk.type == "response.output_text.delta") == ("Hello",)
assert tuple(chunk.part.text for chunk in chunks if chunk.type == "response.content_part.done") == (
@ -466,6 +541,55 @@ def test_responses_failure_envelope_raises_its_status_instead_of_a_parse_error(
assert getattr(raised.value, "status_code", None) == status_code
@pytest.mark.parametrize(
("code", "error_class"),
((1261, litellm.ContextWindowExceededError), (1301, litellm.ContentPolicyViolationError)),
)
@pytest.mark.parametrize("route", ("chat", "responses"))
@respx.mock
def test_prompt_too_long_and_unsafe_content_raise_the_errors_fallbacks_catch(
code: int, error_class: type[Exception], route: Literal["chat", "responses"]
) -> None:
if route == "chat":
respx.post(f"{BASE}/api/paas/v4/chat/completions").respond(
status_code=400, json={"error": {"code": str(code), "message": "rejected by BigModel"}}
)
else:
respx.post(f"{BASE}/api/v1/responses").respond(
json={"code": code, "msg": "rejected by BigModel", "success": False}
)
call: Final = (
(lambda: litellm.completion(model=f"bigmodel/{MODEL}", messages=[{"role": "user", "content": "Hello"}]))
if route == "chat"
else (lambda: litellm.responses(model=f"bigmodel/{MODEL}", input="Hello"))
)
with pytest.raises(error_class, match="rejected by BigModel") as raised:
call()
assert raised.value.status_code == 400
@pytest.mark.parametrize("route", ("responses", "messages"))
@pytest.mark.asyncio
@respx.mock
async def test_a_non_json_answer_surfaces_its_body_text(route: Literal["responses", "messages"]) -> None:
path: Final = "/api/v1/responses" if route == "responses" else "/api/anthropic/v1/messages"
respx.post(f"{BASE}{path}").respond(
content=b"<html>gateway maintenance</html>", headers={"content-type": "text/html"}
)
call: Final = (
(lambda: litellm.aresponses(model=f"bigmodel/{MODEL}", input="Hello"))
if route == "responses"
else (
lambda: litellm.anthropic.messages.acreate(
model=f"bigmodel/{MODEL}", max_tokens=16, messages=[{"role": "user", "content": "Hello"}]
)
)
)
with pytest.raises(Exception, match="gateway maintenance") as raised:
await call()
assert "validation error" not in str(raised.value)
@pytest.mark.parametrize("stream", (False, True))
@respx.mock
def test_responses_forward_codex_tool_params_and_echo_them_when_the_answer_omits_them(stream: bool) -> None: