mirror of
https://github.com/BerriAI/litellm.git
synced 2026-11-05 07:37:50 +00:00
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:
parent
2a247465ff
commit
4c6bdbb2ff
7 changed files with 235 additions and 13 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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}}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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],
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue