diff --git a/.github/workflows/test-unit.yml b/.github/workflows/test-unit.yml index 2ddf21bca17..48407ae532d 100644 --- a/.github/workflows/test-unit.yml +++ b/.github/workflows/test-unit.yml @@ -236,6 +236,7 @@ jobs: tests/unit/llms/azure_ai tests/unit/llms/base_llm tests/unit/llms/baseten + tests/unit/llms/bigmodel tests/unit/llms/black_forest_labs tests/unit/llms/brave tests/unit/llms/bytez diff --git a/examples/bigmodel.md b/examples/bigmodel.md new file mode 100644 index 00000000000..88a69796ebe --- /dev/null +++ b/examples/bigmodel.md @@ -0,0 +1,63 @@ +# BigModel (Zhipu AI) + +Use `bigmodel/` for the domestic BigModel platform and `zai/` for international Z.AI. Each platform uses its own credentials and default URL + +```yaml +model_list: + - model_name: glm + litellm_params: + model: bigmodel/glm-5.3 + api_key: os.environ/BIGMODEL_API_KEY + api_base: https://open.bigmodel.cn +``` + +One model configuration supports all three native APIs. The provider selects the upstream endpoint from the requested API + +| Proxy endpoint | BigModel endpoint | +| --- | --- | +| `/v1/chat/completions` | `/api/paas/v4/chat/completions` | +| `/v1/responses` | `/api/v1/responses` | +| `/v1/messages` | `/api/anthropic/v1/messages` | + +`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 +import litellm + +chat = litellm.completion( + model="bigmodel/glm-5.3", + messages=[{"role": "user", "content": "Hello"}], + thinking={"type": "enabled"}, + reasoning_effort="low", +) + +response = litellm.responses( + model="bigmodel/glm-5.3", + input="Hello", + reasoning={"effort": "low"}, + store=True, +) + +async def messages(): + return await litellm.anthropic.messages.acreate( + model="bigmodel/glm-5.3", + max_tokens=1024, + messages=[{"role": "user", "content": "Hello"}], + thinking={"type": "adaptive"}, + output_config={"effort": "low"}, + ) +``` + +Each API retains its native response format and streaming events. The native Messages SDK entry point is asynchronous, as is the proxy's `/v1/messages` route + +GLM-5.3, GLM-5.3-Flash, and GLM-5.3-FlashX use always-on thinking. GLM-5.3 supports `low`, `high`, and `max` reasoning effort. Messages preserves native `thinking`, `output_config`, and `reasoning_effort` parameters + +Set `store=True` when creating a Responses request if you need continuation, retrieval, or deletion. Use the response ID returned by LiteLLM. BigModel documents a seven-day retention period and does not support cancel, compact, native WebSocket, or file_search + +The model catalog and packaged backup include GLM-5.3, GLM-5.3-Flash, and GLM-5.3-FlashX. Prices are expressed in USD/token using a static 7.0 CNY/USD conversion, with the source and verification date in each entry's metadata. Use existing `litellm_params` cost fields for contract-specific deployment prices + +API references: [Chat Completions](https://docs.bigmodel.cn/cn/guide/develop/openai/introduction), [Responses](https://docs.bigmodel.cn/cn/guide/develop/responses/introduction), [Messages](https://docs.bigmodel.cn/cn/guide/develop/claude/introduction), and [pricing](https://docs.bigmodel.cn/cn/guide/start/pricing), checked on 2026-10-27 diff --git a/litellm/__init__.py b/litellm/__init__.py index 8dfe5adca1c..a7f99829a9e 100644 --- a/litellm/__init__.py +++ b/litellm/__init__.py @@ -635,6 +635,7 @@ watsonx_models: Set = set() gemini_models: Set = set() xai_models: Set = set() zai_models: Set = set() +bigmodel_models: Final[set[str]] = set() # mutable-ok: provider registry is populated and extended at runtime deepseek_models: Set = set() tencent_models: Set = set() runwayml_models: Set = set() @@ -876,6 +877,8 @@ def _populate_provider_model_sets(model_cost_map: Mapping[str, Mapping[str, obje xai_models.add(key) elif value.get("litellm_provider") == "zai": zai_models.add(key) + elif value.get("litellm_provider") == "bigmodel": + bigmodel_models.add(key) elif value.get("litellm_provider") == "fal_ai": fal_ai_models.add(key) elif value.get("litellm_provider") == "deepseek": @@ -1104,6 +1107,7 @@ model_list = list( | text_completion_inception_models | xai_models | zai_models + | bigmodel_models | fal_ai_models | deepseek_models | modelscope_models @@ -1217,6 +1221,7 @@ def _build_models_by_provider() -> dict: "text-completion-inception": text_completion_inception_models, "xai": xai_models, "zai": zai_models, + "bigmodel": bigmodel_models, "fal_ai": fal_ai_models, "deepseek": deepseek_models, "tencent": tencent_models, diff --git a/litellm/litellm_core_utils/exception_mapping_utils.py b/litellm/litellm_core_utils/exception_mapping_utils.py index 47ec8331c83..703550dd2b5 100644 --- a/litellm/litellm_core_utils/exception_mapping_utils.py +++ b/litellm/litellm_core_utils/exception_mapping_utils.py @@ -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 ( @@ -2762,6 +2763,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, diff --git a/litellm/litellm_core_utils/get_llm_provider_logic.py b/litellm/litellm_core_utils/get_llm_provider_logic.py index 19c1d5c414c..2af44ad0eab 100644 --- a/litellm/litellm_core_utils/get_llm_provider_logic.py +++ b/litellm/litellm_core_utils/get_llm_provider_logic.py @@ -795,6 +795,13 @@ def _get_openai_compatible_provider_info( api_base, dynamic_api_key, ) = litellm.XAIChatConfig().get_openai_compatible_provider_info(api_base, api_key) + elif custom_llm_provider == "bigmodel": + from litellm.llms.bigmodel.common_utils import get_bigmodel_api_key, get_bigmodel_url + + ( + api_base, # rebind-ok: existing provider-resolution pipeline resolves the API base in place + dynamic_api_key, # rebind-ok: existing provider-resolution pipeline resolves the API key in place + ) = get_bigmodel_url(api_base, "chat"), get_bigmodel_api_key(api_key) elif custom_llm_provider == "zai": ( api_base, diff --git a/litellm/llms/base_llm/responses/transformation.py b/litellm/llms/base_llm/responses/transformation.py index 01baeed9bef..7f45c1eba28 100644 --- a/litellm/llms/base_llm/responses/transformation.py +++ b/litellm/llms/base_llm/responses/transformation.py @@ -268,6 +268,9 @@ class BaseResponsesAPIConfig(ABC): """Returns True if litellm should fake a stream for the given model and stream value""" return False + def may_answer_stream_with_json(self) -> bool: + return False + def supports_native_websocket(self) -> bool: """ Returns True if the provider has a native WebSocket endpoint for Responses API. diff --git a/litellm/llms/bigmodel/__init__.py b/litellm/llms/bigmodel/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/litellm/llms/bigmodel/chat/__init__.py b/litellm/llms/bigmodel/chat/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/litellm/llms/bigmodel/chat/transformation.py b/litellm/llms/bigmodel/chat/transformation.py new file mode 100644 index 00000000000..12d5e912c15 --- /dev/null +++ b/litellm/llms/bigmodel/chat/transformation.py @@ -0,0 +1,59 @@ +from collections.abc import Mapping +from typing import Final + +from litellm.llms.zai.chat.transformation import ZAIChatConfig + +from ..common_utils import get_bigmodel_api_key, get_bigmodel_url, is_unsupported_disabled_thinking + +_SUPPORTED_PARAMS: Final = ( + "max_tokens", + "max_completion_tokens", + "stream", + "stream_options", + "temperature", + "top_p", + "stop", + "tools", + "tool_choice", + "response_format", + "thinking", + "reasoning_effort", +) + + +class BigModelChatConfig(ZAIChatConfig): + @property + def custom_llm_provider(self) -> str: + return "bigmodel" + + def get_openai_compatible_provider_info(self, api_base: str | None, api_key: str | None) -> tuple[str, str | None]: + return get_bigmodel_url(api_base, "chat"), get_bigmodel_api_key(api_key) + + def get_supported_openai_params(self, model: str) -> list[str]: # mutable-ok: inherited interface requires a list + return list(_SUPPORTED_PARAMS) + + def map_openai_params( + self, + non_default_params: Mapping[str, object], + optional_params: Mapping[str, object], + model: str, + drop_params: bool, + ) -> dict[str, object]: # mutable-ok: request pipeline adds parameters to the returned body + mapped: Final = { + **optional_params, + **{ + ("max_tokens" if key == "max_completion_tokens" else key): value + for key, value in non_default_params.items() + if key in _SUPPORTED_PARAMS and key != "thinking" + }, + } + if "thinking" not in non_default_params: + return mapped + 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}} diff --git a/litellm/llms/bigmodel/common_utils.py b/litellm/llms/bigmodel/common_utils.py new file mode 100644 index 00000000000..0c9b6da5c8f --- /dev/null +++ b/litellm/llms/bigmodel/common_utils.py @@ -0,0 +1,92 @@ +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", + _CODING_CHAT_PATH, + "/api/paas/v4/chat/completions", + "/api/anthropic/v1/messages", + "/api/anthropic/v1", + "/api/v1/responses", + "/api/paas/v4", + "/api/anthropic", + "/api/v1", + "/api", +) +_ENDPOINT_PATHS: Final = MappingProxyType( + { + "chat": "/api/paas/v4", + "responses": "/api/v1/responses", + "messages": "/api/anthropic/v1/messages", + } +) + + +def get_bigmodel_api_key(api_key: str | None) -> str | None: + return api_key or get_secret_str("BIGMODEL_API_KEY") + + +def get_bigmodel_url(api_base: str | None, endpoint: Literal["chat", "responses", "messages"]) -> str: + resolved_base: Final = api_base or get_secret_str("BIGMODEL_API_BASE") or BIGMODEL_API_BASE + parsed: Final = urlsplit(resolved_base) + path: Final = parsed.path.rstrip("/") + suffix: Final = next((candidate for candidate in _API_PATHS if path.endswith(candidate)), "") + endpoint_path: Final = ( + _CODING_CHAT_PATH if endpoint == "chat" and suffix.startswith(_CODING_CHAT_PATH) else _ENDPOINT_PATHS[endpoint] + ) + 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 diff --git a/litellm/llms/bigmodel/messages/__init__.py b/litellm/llms/bigmodel/messages/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/litellm/llms/bigmodel/messages/transformation.py b/litellm/llms/bigmodel/messages/transformation.py new file mode 100644 index 00000000000..82ce59631f1 --- /dev/null +++ b/litellm/llms/bigmodel/messages/transformation.py @@ -0,0 +1,206 @@ +import json +from collections.abc import AsyncIterator, Mapping, Sequence +from typing import Final + +import httpx +from openai._streaming import ServerSentEvent, SSEDecoder +from pydantic import JsonValue, TypeAdapter, ValidationError + +from litellm.exceptions import AuthenticationError +from litellm.litellm_core_utils.litellm_logging import Logging +from litellm.llms.openai_like.messages.transformation import ( + DEFAULT_ANTHROPIC_API_VERSION, + OpenAILikeAnthropicMessagesConfig, +) +from litellm.types.llms.anthropic_messages.anthropic_response import AnthropicMessagesResponse +from litellm.types.router import GenericLiteLLMParams + +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 = { + **({} if "reasoning_effort" in params else {"effort": "low"}), + **( + _OUTPUT_CONFIG.validate_python(requested_output_config) if isinstance(requested_output_config, dict) else {} + ), + } + return {**params, "thinking": {"type": "adaptive"}, **({"output_config": output_config} if output_config else {})} + + +def _normalize_message(body: Mapping[str, JsonValue]) -> dict[str, JsonValue]: # mutable-ok: native JSON response + usage: Final = body.get("usage") + if not isinstance(usage, dict): + return dict(body) + server_tools: Final = usage.get("server_tool_use") + if not isinstance(server_tools, dict) or "web_fetch_requests" in server_tools: + return dict(body) + normalized: Final[JsonValue] = { + **body, + "usage": {**usage, "server_tool_use": {"web_fetch_requests": 0, **server_tools}}, + } + return normalized + + +class _BigModelMessagesStream(httpx.AsyncByteStream): + def __init__(self, response: httpx.Response) -> None: + self._response = response + + async def __aiter__(self) -> AsyncIterator[bytes]: + async for event in SSEDecoder().aiter_bytes(self._response.aiter_bytes()): + yield _normalize_event(event) + + async def aclose(self) -> None: + await self._response.aclose() + + +def _json_object(data: str) -> Mapping[str, JsonValue] | None: + try: + return _JSON_OBJECT.validate_json(data) + except ValidationError: + return None + + +def _normalized_data(data: str) -> str: + body: Final = _json_object(data) + if body is None: + return data + message: Final = body.get("message") + normalized: Final[JsonValue] = ( + {**body, "message": _normalize_message(message)} + if body.get("type") == "message_start" and isinstance(message, dict) + else _normalize_message(body) + ) + return data if normalized == body else json.dumps(normalized, ensure_ascii=False) + + +def _normalize_event(event: ServerSentEvent) -> bytes: + metadata: Final = "".join( + f"{name}: {value}\n" + for name, value in (("event", event.event), ("id", event.id), ("retry", event.retry)) + if value is not None + ) + data_lines: Final = "".join(f"data: {line}\n" for line in _normalized_data(event.data).split("\n")) + return f"{metadata}{data_lines}\n".encode() + + +class BigModelMessagesConfig(OpenAILikeAnthropicMessagesConfig): + @property + def custom_llm_provider(self) -> str: + return "bigmodel" + + def is_native_messages_route(self) -> bool: + return True + + 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(bigmodel_json_body(raw_response)), + request=raw_response.request, + ) + return super().transform_anthropic_messages_response(model, normalized, logging_obj) + + def get_async_streaming_response_iterator( + self, + model: str, + httpx_response: httpx.Response, + request_body: dict[str, object], # mutable-ok: inherited native stream API takes a request dict + litellm_logging_obj: Logging, + ) -> AsyncIterator[bytes]: + normalized: Final = httpx.Response( + httpx_response.status_code, + headers={ + key: value + for key, value in httpx_response.headers.items() + if key.lower() not in ("content-encoding", "content-length") + }, + stream=_BigModelMessagesStream(httpx_response), + request=httpx_response.request, + ) + return super().get_async_streaming_response_iterator( # pyright: ignore[reportUnknownMemberType, reportUnknownVariableType] # native parent yields bytes but its iterator annotation omits the element type + model, normalized, request_body, litellm_logging_obj + ) + + @staticmethod + def _translate_reasoning_effort_to_anthropic( + model: str, optional_params: Mapping[str, object], max_tokens: int | None, custom_llm_provider: str + ) -> None: + return None + + @staticmethod + def _translate_adaptive_effort_for_non_adaptive_model( + model: str, optional_params: Mapping[str, object], max_tokens: int | None, custom_llm_provider: str + ) -> 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], + model: str, + messages: Sequence[object], + 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]: # mutable-ok: HTTP handler merges forwarded headers + resolved_key: Final = get_bigmodel_api_key(api_key) + if resolved_key is None: + raise AuthenticationError( + message="Set BIGMODEL_API_KEY or pass api_key", llm_provider="bigmodel", model=model + ) + return { + **{key: value for key, value in headers.items() if key.lower() not in ("x-api-key", "authorization")}, + "x-api-key": resolved_key, + "anthropic-version": DEFAULT_ANTHROPIC_API_VERSION, + "content-type": "application/json", + }, api_base + + def get_complete_url( + self, + api_base: str | None, + api_key: str | None, + model: str, + optional_params: Mapping[str, object], + litellm_params: Mapping[str, object], + stream: bool | None = None, + ) -> str: + return get_bigmodel_url(api_base, "messages") diff --git a/litellm/llms/bigmodel/responses/__init__.py b/litellm/llms/bigmodel/responses/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/litellm/llms/bigmodel/responses/transformation.py b/litellm/llms/bigmodel/responses/transformation.py new file mode 100644 index 00000000000..80c53682d16 --- /dev/null +++ b/litellm/llms/bigmodel/responses/transformation.py @@ -0,0 +1,263 @@ +from collections.abc import Mapping +from types import MappingProxyType +from typing import Final + +import httpx +from pydantic import BaseModel, ConfigDict, JsonValue, TypeAdapter, ValidationError +from typing_extensions import Never + +from litellm.exceptions import AuthenticationError, UnsupportedParamsError +from litellm.litellm_core_utils.litellm_logging import Logging +from litellm.llms.base_llm.chat.transformation import BaseLLMException +from litellm.llms.openai.responses.transformation import OpenAIResponsesAPIConfig +from litellm.types.llms.openai import ( + ResponseInputParam, + ResponsesAPIOptionalRequestParams, + ResponsesAPIResponse, + ResponsesAPIStreamingResponse, +) +from litellm.types.router import GenericLiteLLMParams +from litellm.types.utils import LlmProviders + +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 = ( + "input", + "model", + "instructions", + "stream", + "temperature", + "top_p", + "max_output_tokens", + "tools", + "tool_choice", + "parallel_tool_calls", + "include", + "reasoning", + "text", + "prompt_cache_key", + "previous_response_id", + "store", + "extra_headers", + "extra_query", + "extra_body", + "timeout", +) + + +_STATUS_BY_BUSINESS_CODE: Final = MappingProxyType( + { + **dict.fromkeys((1000, 1001, 1003, 1005), 401), + **dict.fromkeys((1210, 1211, 1212, 1213, 1214, 1215, 1221, 1222, 1261, 1301), 400), + 1220: 403, + **dict.fromkeys( + (1113, 1302, 1305, 1308, 1309, 1310, 1311, 1313, 1314, 1315, 1316, 1317, 1318, 1319, 1320, 1321), + 429, + ), + } +) + + +class _RequestedTools(BaseModel): + model_config = ConfigDict(frozen=True, extra="ignore") + + tools: JsonValue = None + tool_choice: JsonValue = None + parallel_tool_calls: JsonValue = None + + +def _requested_tools(logging_obj: Logging) -> _RequestedTools: + try: + return _RequestedTools.model_validate(logging_obj.model_call_details.get("optional_params") or {}) + except ValidationError: + return _RequestedTools() + + +def _envelope_status_code(code: JsonValue) -> int: + if not isinstance(code, int) or isinstance(code, bool): + return 500 + if 400 <= code <= 599: + return code + return _STATUS_BY_BUSINESS_CODE.get(code, 500) + + +def _normalize_output_item(item: JsonValue) -> JsonValue: + if not isinstance(item, dict) or item.get("type") not in ("message", "reasoning"): + return item + content: Final = item.get("content") + normalized: Final[JsonValue] = {"summary": [], **item} if item["type"] == "reasoning" else item + if not isinstance(content, dict): + return normalized + return {**normalized, "content": [content]} + + +def _normalize_response( + body: Mapping[str, JsonValue], + requested: _RequestedTools, +) -> dict[str, JsonValue]: # mutable-ok: JSON serialization boundary + usage: Final = body.get("usage") + output: Final = body.get("output") + tools: Final = body.get("tools") + tool_choice: Final = body.get("tool_choice") + parallel_tool_calls: Final = body.get("parallel_tool_calls") + input_tokens: Final = usage.get("input_tokens") if isinstance(usage, dict) else None + output_tokens: Final = usage.get("output_tokens") if isinstance(usage, dict) else None + normalized_usage: Final = ( + { + "total_tokens": input_tokens + output_tokens, + **usage, + } + if isinstance(usage, dict) and isinstance(input_tokens, int) and isinstance(output_tokens, int) + else usage + ) + normalized: Final[JsonValue] = { + **body, + "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, + **({"usage": normalized_usage} if "usage" in body else {}), + **({"output": [_normalize_output_item(item) for item in output]} if isinstance(output, list) else {}), + } + return normalized + + +def _normalize_stream_part(part: JsonValue) -> JsonValue: + if not isinstance(part, dict): + return part + if part.get("type") == "output_text": + return { + "annotations": [], + "logprobs": None, + **part, + } + if part.get("type") == "reasoning_text": + return {"reasoning": part.get("text", ""), **part} + return part + + +class BigModelResponsesAPIConfig(OpenAIResponsesAPIConfig): + @property + def custom_llm_provider(self) -> LlmProviders: + return LlmProviders.BIGMODEL + + def supports_native_file_search(self) -> bool: + return False + + def supports_native_websocket(self) -> bool: + return False + + def should_fake_stream( + self, model: str | None, stream: bool | None, custom_llm_provider: str | None = None + ) -> bool: + return False + + def may_answer_stream_with_json(self) -> bool: + return True + + def get_supported_openai_params(self, model: str) -> list[str]: # mutable-ok: inherited API requires a list + return list(_SUPPORTED_PARAMS) + + def map_openai_params( + self, + response_api_optional_params: ResponsesAPIOptionalRequestParams, + model: str, + drop_params: bool, + ) -> dict[str, object]: # mutable-ok: request pipeline adds parameters to the returned body + return {key: value for key, value in response_api_optional_params.items() if key in _SUPPORTED_PARAMS} + + def validate_environment( + self, headers: Mapping[str, str], model: str, litellm_params: GenericLiteLLMParams | None + ) -> dict[str, str]: # mutable-ok: HTTP handler merges forwarded headers + resolved_key: Final = get_bigmodel_api_key(litellm_params.api_key if litellm_params else None) + if resolved_key is None: + raise AuthenticationError( + message="Set BIGMODEL_API_KEY or pass api_key", llm_provider="bigmodel", model=model + ) + return { + **headers, + "Authorization": f"Bearer {resolved_key}", + "Content-Type": "application/json", + } + + def get_complete_url(self, api_base: str | None, litellm_params: Mapping[str, object]) -> str: + return get_bigmodel_url(api_base, "responses") + + def transform_response_api_response( + self, model: str, raw_response: httpx.Response, logging_obj: Logging + ) -> ResponsesAPIResponse: + body: Final = bigmodel_json_body(raw_response) + if body.get("success") is False: + 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, + ) + normalized: Final = _normalize_response(body, _requested_tools(logging_obj)) + normalized_response: Final = httpx.Response( + status_code=raw_response.status_code, + headers={ + key: value + for key, value in raw_response.headers.items() + if key.lower() not in ("content-encoding", "content-length") + }, + json=normalized, + request=raw_response.request, + ) + return super().transform_response_api_response(model, normalized_response, logging_obj) + + def transform_get_response_api_response( + self, raw_response: httpx.Response, logging_obj: Logging + ) -> ResponsesAPIResponse: + return self.transform_response_api_response("", raw_response, logging_obj) + + def transform_streaming_response( + self, model: str, parsed_chunk: Mapping[str, object], logging_obj: Logging + ) -> ResponsesAPIStreamingResponse: + chunk: Final = _JSON_OBJECT.validate_python(parsed_chunk) + response: Final = chunk.get("response") + item: Final = chunk.get("item") + normalized: Final[JsonValue] = { + **chunk, + **( + {"response": _normalize_response(response, _requested_tools(logging_obj))} + if isinstance(response, dict) + else {} + ), + **({"item": _normalize_output_item(item)} if "item" in chunk else {}), + } + event_model: Final = self.get_event_model_class(str(normalized.get("type"))) + match chunk.get("type"): + case "response.output_text.done": + return event_model.model_validate({"logprobs": [], **normalized}) + case "response.content_part.done": + return event_model.model_validate({**normalized, "part": _normalize_stream_part(chunk.get("part"))}) + case "error": + error: Final = chunk.get("error") + error_fields: Final = error if isinstance(error, dict) else chunk + normalized_error: Final[JsonValue] = { + **normalized, + "error": {"type": "api_error", **error_fields, "code": error_fields.get("code") or "unknown_error"}, + } + return event_model.model_validate(normalized_error) + case _: + return event_model.model_validate(normalized) + + def transform_cancel_response_api_request( + self, response_id: str, api_base: str, litellm_params: GenericLiteLLMParams, headers: Mapping[str, str] + ) -> Never: + raise UnsupportedParamsError(message="BigModel does not support cancelling responses", status_code=400) + + def transform_compact_response_api_request( + self, + model: str, + input: str | ResponseInputParam, + response_api_optional_request_params: Mapping[str, object], + api_base: str, + litellm_params: GenericLiteLLMParams, + headers: Mapping[str, str], + ) -> Never: + raise UnsupportedParamsError(message="BigModel does not support compacting responses", status_code=400) diff --git a/litellm/llms/custom_httpx/llm_http_handler.py b/litellm/llms/custom_httpx/llm_http_handler.py index afa12b58b29..4e1813e2aee 100644 --- a/litellm/llms/custom_httpx/llm_http_handler.py +++ b/litellm/llms/custom_httpx/llm_http_handler.py @@ -366,6 +366,19 @@ class _SignedBodyKwargs(TypedDict, total=False): json: ReadOnly[dict[str, object]] +def _is_sized_json_body(response: httpx.Response) -> bool: + headers: Final = httpx.Headers(response.headers) + media_types: Final = (value.split(";")[0].strip().lower() for value in headers.get_list("content-type")) + return "content-length" in headers and "application/json" in media_types + + +def _is_json_document(content: bytes) -> bool: + try: + return isinstance(json.loads(content), dict) + except ValueError: + return False + + def _signed_body_kwargs(*, signed_body: bytes | None, data: dict[str, object]) -> _SignedBodyKwargs: if signed_body is not None: return {"data": signed_body} @@ -3024,6 +3037,17 @@ class BaseLLMHTTPHandler: }, ) + replay_as_stream: Final = partial( + MockResponsesAPIStreamingIterator, + model=model, + logging_obj=logging_obj, + responses_api_provider_config=responses_api_provider_config, + litellm_metadata=litellm_metadata, + custom_llm_provider=custom_llm_provider, + request_data=request_context, + call_type=CallTypes.responses.value, + ) + try: if is_stream_request: response = sync_httpx_client.post( @@ -3035,7 +3059,13 @@ class BaseLLMHTTPHandler: ) if caller_requested_stream: if fake_stream is True: - return MockResponsesAPIStreamingIterator( + return replay_as_stream(response=response) + if not ( + responses_api_provider_config.may_answer_stream_with_json() + and _is_sized_json_body(response) + and _is_json_document(response.read()) + ): + return SyncResponsesAPIStreamingIterator( response=response, model=model, logging_obj=logging_obj, @@ -3045,17 +3075,6 @@ class BaseLLMHTTPHandler: request_data=request_context, call_type=CallTypes.responses.value, ) - - return SyncResponsesAPIStreamingIterator( - response=response, - model=model, - logging_obj=logging_obj, - responses_api_provider_config=responses_api_provider_config, - litellm_metadata=litellm_metadata, - custom_llm_provider=custom_llm_provider, - request_data=request_context, - call_type=CallTypes.responses.value, - ) response.read() else: response = sync_httpx_client.post( @@ -3070,6 +3089,9 @@ class BaseLLMHTTPHandler: provider_config=responses_api_provider_config, ) + if is_stream_request and caller_requested_stream: + return replay_as_stream(response=response) + initial_response: Final = responses_api_provider_config.transform_response_api_response( model=model, raw_response=response, @@ -3225,6 +3247,17 @@ class BaseLLMHTTPHandler: }, ) + replay_as_stream: Final = partial( + MockResponsesAPIStreamingIterator, + model=model, + logging_obj=logging_obj, + responses_api_provider_config=responses_api_provider_config, + litellm_metadata=litellm_metadata, + custom_llm_provider=custom_llm_provider, + request_data=request_context, + call_type=CallTypes.responses.value, + ) + try: if is_stream_request: response = await async_httpx_client.post( @@ -3238,7 +3271,13 @@ class BaseLLMHTTPHandler: if caller_requested_stream: if fake_stream is True: - return MockResponsesAPIStreamingIterator( + return replay_as_stream(response=response) + if not ( + responses_api_provider_config.may_answer_stream_with_json() + and _is_sized_json_body(response) + and _is_json_document(await response.aread()) + ): + return ResponsesAPIStreamingIterator( response=response, model=model, logging_obj=logging_obj, @@ -3248,17 +3287,6 @@ class BaseLLMHTTPHandler: request_data=request_context, call_type=CallTypes.responses.value, ) - - return ResponsesAPIStreamingIterator( - response=response, - model=model, - logging_obj=logging_obj, - responses_api_provider_config=responses_api_provider_config, - litellm_metadata=litellm_metadata, - custom_llm_provider=custom_llm_provider, - request_data=request_context, - call_type=CallTypes.responses.value, - ) await response.aread() else: response = await async_httpx_client.post( @@ -3275,6 +3303,9 @@ class BaseLLMHTTPHandler: provider_config=responses_api_provider_config, ) + if is_stream_request and caller_requested_stream: + return replay_as_stream(response=response) + initial_response: Final = responses_api_provider_config.transform_response_api_response( model=model, raw_response=response, diff --git a/litellm/main.py b/litellm/main.py index 41c4302499e..b8ad28c26fe 100644 --- a/litellm/main.py +++ b/litellm/main.py @@ -2672,10 +2672,13 @@ def _complete_custom_openai( # for deepinfra/perplexity/anyscale/groq/friendliai we check in get_llm_provider and pass in the api base from there api_base = _resolve_openai_api_base(api_base) + if custom_llm_provider == "bigmodel" and not api_key: + raise litellm.AuthenticationError( + message="Set BIGMODEL_API_KEY or pass api_key", llm_provider="bigmodel", model=model + ) organization = ( organization - or litellm.organization - or get_secret("OPENAI_ORGANIZATION") + or (None if custom_llm_provider == "bigmodel" else litellm.organization or get_secret("OPENAI_ORGANIZATION")) or None # default - https://github.com/openai/openai-python/blob/284c1799070c723c6a553337134148a7ab088dd8/openai/util.py#L105 ) openai.organization = organization @@ -6299,6 +6302,7 @@ def completion( or custom_llm_provider == "nebius" or custom_llm_provider == "wandb" or custom_llm_provider == "clarifai" + or custom_llm_provider == "bigmodel" or custom_llm_provider in litellm.openai_compatible_providers or JSONProviderRegistry.exists(custom_llm_provider) # JSON-configured providers or "ft:gpt-3.5-turbo" in model # finetune gpt-3.5-turbo diff --git a/litellm/model_prices_and_context_window_backup.json b/litellm/model_prices_and_context_window_backup.json index 1f2bd7d7837..95da1836aac 100644 --- a/litellm/model_prices_and_context_window_backup.json +++ b/litellm/model_prices_and_context_window_backup.json @@ -78461,5 +78461,81 @@ "supports_response_schema": true, "supports_tool_choice": true, "supports_vision": true + }, + "bigmodel/glm-5.3": { + "input_cost_per_token": 1.1428571428571428e-06, + "output_cost_per_token": 4e-06, + "cache_read_input_token_cost": 2.857142857142857e-07, + "litellm_provider": "bigmodel", + "max_input_tokens": 1000000, + "max_output_tokens": 128000, + "mode": "chat", + "source": "https://docs.bigmodel.cn/cn/guide/start/pricing", + "supports_function_calling": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_tool_choice": true, + "thinking_always_on": true, + "metadata": { + "comment": "Official API list price checked 2026-10-27: input 8, output 28, cached input 2 CNY per million tokens; converted at 7.0 CNY/USD. Cache storage is temporarily free and is not encoded." + }, + "supported_endpoints": [ + "/v1/chat/completions", + "/v1/responses", + "/v1/messages" + ], + "supports_native_streaming": true + }, + "bigmodel/glm-5.3-flash": { + "input_cost_per_token": 1.142857142857143e-07, + "output_cost_per_token": 4e-07, + "cache_read_input_token_cost": 3.2857142857142855e-08, + "litellm_provider": "bigmodel", + "max_input_tokens": 1000000, + "max_output_tokens": 128000, + "mode": "chat", + "source": "https://docs.bigmodel.cn/cn/guide/start/pricing", + "supports_function_calling": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_tool_choice": true, + "thinking_always_on": true, + "supports_vision": true, + "supports_video_input": true, + "metadata": { + "comment": "Official API list price checked 2026-10-27: input 0.8, output 2.8, cached input 0.23 CNY per million tokens; converted at 7.0 CNY/USD. Cache storage is temporarily free and is not encoded." + }, + "supported_endpoints": [ + "/v1/chat/completions", + "/v1/responses", + "/v1/messages" + ], + "supports_native_streaming": true + }, + "bigmodel/glm-5.3-flashx": { + "input_cost_per_token": 2.857142857142857e-07, + "output_cost_per_token": 1e-06, + "cache_read_input_token_cost": 8.142857142857142e-08, + "litellm_provider": "bigmodel", + "max_input_tokens": 1000000, + "max_output_tokens": 128000, + "mode": "chat", + "source": "https://docs.bigmodel.cn/cn/guide/start/pricing", + "supports_function_calling": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_tool_choice": true, + "thinking_always_on": true, + "supports_vision": true, + "supports_video_input": true, + "metadata": { + "comment": "Official API list price checked 2026-10-27: input 2, output 7, cached input 0.57 CNY per million tokens; converted at 7.0 CNY/USD. Cache storage is temporarily free and is not encoded." + }, + "supported_endpoints": [ + "/v1/chat/completions", + "/v1/responses", + "/v1/messages" + ], + "supports_native_streaming": true } } diff --git a/litellm/provider_endpoints_support_backup.json b/litellm/provider_endpoints_support_backup.json index d73ac19bae1..a614c117026 100644 --- a/litellm/provider_endpoints_support_backup.json +++ b/litellm/provider_endpoints_support_backup.json @@ -5300,6 +5300,32 @@ "a2a": false, "generateContent": false } + }, + "bigmodel": { + "display_name": "BigModel (Zhipu AI) (`bigmodel`)", + "url": "https://docs.bigmodel.cn/cn/guide/develop/responses/introduction", + "endpoints": { + "chat_completions": true, + "responses": true, + "text_completion": true, + "embeddings": false, + "image_generations": false, + "image_edits": false, + "audio_transcriptions": false, + "audio_speech": false, + "rerank": false, + "ocr": false, + "search": false, + "moderations": false, + "realtime": false, + "video_generations": false, + "batches": false, + "messages": true, + "interactions": true, + "systemone": false, + "a2a": true, + "generateContent": true + } } }, "endpoints": { diff --git a/litellm/proxy/public_endpoints/provider_create_fields.json b/litellm/proxy/public_endpoints/provider_create_fields.json index f2d88702470..423f129a9b4 100644 --- a/litellm/proxy/public_endpoints/provider_create_fields.json +++ b/litellm/proxy/public_endpoints/provider_create_fields.json @@ -4200,5 +4200,33 @@ } ], "default_model_placeholder": "cursor/claude-4-sonnet" + }, + { + "provider": "BIGMODEL", + "provider_display_name": "BigModel (Zhipu AI)", + "litellm_provider": "bigmodel", + "credential_fields": [ + { + "key": "api_key", + "label": "BigModel (Zhipu AI) API Key", + "placeholder": null, + "tooltip": null, + "required": true, + "field_type": "password", + "options": null, + "default_value": null + }, + { + "key": "api_base", + "label": "API Base", + "placeholder": "https://open.bigmodel.cn", + "tooltip": null, + "required": false, + "field_type": "text", + "options": null, + "default_value": "https://open.bigmodel.cn" + } + ], + "default_model_placeholder": "bigmodel/glm-5.3" } ] diff --git a/litellm/types/utils.py b/litellm/types/utils.py index b21bf0e14d4..e4d68a68223 100644 --- a/litellm/types/utils.py +++ b/litellm/types/utils.py @@ -4424,6 +4424,7 @@ class LlmProviders(str, Enum): JINA_AI = "jina_ai" XAI = "xai" ZAI = "zai" + BIGMODEL = "bigmodel" CUSTOM_OPENAI = "custom_openai" TEXT_COMPLETION_OPENAI = "text-completion-openai" COHERE = "cohere" diff --git a/litellm/utils.py b/litellm/utils.py index a231644addf..29552c2667b 100644 --- a/litellm/utils.py +++ b/litellm/utils.py @@ -5641,6 +5641,7 @@ def add_provider_specific_params_to_optional_params( "openai", "azure", "text-completion-openai", + "bigmodel", *litellm.openai_compatible_providers, ] or JSONProviderRegistry.exists(custom_llm_provider) if calls_through_openai_sdk: @@ -9135,6 +9136,7 @@ class ProviderConfigManager: LlmProviders.DATABRICKS: (lambda: litellm.DatabricksConfig(), False), LlmProviders.XAI: (lambda: litellm.XAIChatConfig(), False), LlmProviders.ZAI: (lambda: litellm.ZAIChatConfig(), False), + LlmProviders.BIGMODEL: (ProviderConfigManager._get_bigmodel_chat_config, False), LlmProviders.OPENCODE_GO: (ProviderConfigManager.get_opencode_go_config, True), LlmProviders.LAMBDA_AI: (lambda: litellm.LambdaAIChatConfig(), False), LlmProviders.INCEPTION: (lambda: litellm.InceptionChatConfig(), False), @@ -9327,6 +9329,12 @@ class ProviderConfigManager: return get_bedrock_chat_config(model=model) + @staticmethod + def _get_bigmodel_chat_config() -> BaseConfig: + from litellm.llms.bigmodel.chat.transformation import BigModelChatConfig + + return BigModelChatConfig() + @staticmethod def get_opencode_go_config(model: str) -> BaseConfig: from litellm.llms.opencode.chat.transformation import go_chat_config @@ -9648,6 +9656,10 @@ class ProviderConfigManager: model: str, provider: LlmProviders, ) -> BaseAnthropicMessagesConfig | None: + if provider == LlmProviders.BIGMODEL: + from litellm.llms.bigmodel.messages.transformation import BigModelMessagesConfig + + return BigModelMessagesConfig() if provider == LlmProviders.OPENCODE_GO: from litellm.llms.opencode.common_utils import go_endpoint from litellm.llms.opencode.messages.transformation import OpenCodeGoMessagesConfig @@ -9879,6 +9891,10 @@ class ProviderConfigManager: if provider is None: return None + if provider == LlmProviders.BIGMODEL: + from litellm.llms.bigmodel.responses.transformation import BigModelResponsesAPIConfig + + return BigModelResponsesAPIConfig() if provider == LlmProviders.OPENCODE_GO: from litellm.llms.opencode.common_utils import go_endpoint from litellm.llms.opencode.responses.transformation import OpenCodeGoResponsesConfig diff --git a/model_prices_and_context_window.json b/model_prices_and_context_window.json index 1f2bd7d7837..95da1836aac 100644 --- a/model_prices_and_context_window.json +++ b/model_prices_and_context_window.json @@ -78461,5 +78461,81 @@ "supports_response_schema": true, "supports_tool_choice": true, "supports_vision": true + }, + "bigmodel/glm-5.3": { + "input_cost_per_token": 1.1428571428571428e-06, + "output_cost_per_token": 4e-06, + "cache_read_input_token_cost": 2.857142857142857e-07, + "litellm_provider": "bigmodel", + "max_input_tokens": 1000000, + "max_output_tokens": 128000, + "mode": "chat", + "source": "https://docs.bigmodel.cn/cn/guide/start/pricing", + "supports_function_calling": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_tool_choice": true, + "thinking_always_on": true, + "metadata": { + "comment": "Official API list price checked 2026-10-27: input 8, output 28, cached input 2 CNY per million tokens; converted at 7.0 CNY/USD. Cache storage is temporarily free and is not encoded." + }, + "supported_endpoints": [ + "/v1/chat/completions", + "/v1/responses", + "/v1/messages" + ], + "supports_native_streaming": true + }, + "bigmodel/glm-5.3-flash": { + "input_cost_per_token": 1.142857142857143e-07, + "output_cost_per_token": 4e-07, + "cache_read_input_token_cost": 3.2857142857142855e-08, + "litellm_provider": "bigmodel", + "max_input_tokens": 1000000, + "max_output_tokens": 128000, + "mode": "chat", + "source": "https://docs.bigmodel.cn/cn/guide/start/pricing", + "supports_function_calling": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_tool_choice": true, + "thinking_always_on": true, + "supports_vision": true, + "supports_video_input": true, + "metadata": { + "comment": "Official API list price checked 2026-10-27: input 0.8, output 2.8, cached input 0.23 CNY per million tokens; converted at 7.0 CNY/USD. Cache storage is temporarily free and is not encoded." + }, + "supported_endpoints": [ + "/v1/chat/completions", + "/v1/responses", + "/v1/messages" + ], + "supports_native_streaming": true + }, + "bigmodel/glm-5.3-flashx": { + "input_cost_per_token": 2.857142857142857e-07, + "output_cost_per_token": 1e-06, + "cache_read_input_token_cost": 8.142857142857142e-08, + "litellm_provider": "bigmodel", + "max_input_tokens": 1000000, + "max_output_tokens": 128000, + "mode": "chat", + "source": "https://docs.bigmodel.cn/cn/guide/start/pricing", + "supports_function_calling": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_tool_choice": true, + "thinking_always_on": true, + "supports_vision": true, + "supports_video_input": true, + "metadata": { + "comment": "Official API list price checked 2026-10-27: input 2, output 7, cached input 0.57 CNY per million tokens; converted at 7.0 CNY/USD. Cache storage is temporarily free and is not encoded." + }, + "supported_endpoints": [ + "/v1/chat/completions", + "/v1/responses", + "/v1/messages" + ], + "supports_native_streaming": true } } diff --git a/provider_endpoints_support.json b/provider_endpoints_support.json index d73ac19bae1..a614c117026 100644 --- a/provider_endpoints_support.json +++ b/provider_endpoints_support.json @@ -5300,6 +5300,32 @@ "a2a": false, "generateContent": false } + }, + "bigmodel": { + "display_name": "BigModel (Zhipu AI) (`bigmodel`)", + "url": "https://docs.bigmodel.cn/cn/guide/develop/responses/introduction", + "endpoints": { + "chat_completions": true, + "responses": true, + "text_completion": true, + "embeddings": false, + "image_generations": false, + "image_edits": false, + "audio_transcriptions": false, + "audio_speech": false, + "rerank": false, + "ocr": false, + "search": false, + "moderations": false, + "realtime": false, + "video_generations": false, + "batches": false, + "messages": true, + "interactions": true, + "systemone": false, + "a2a": true, + "generateContent": true + } } }, "endpoints": { diff --git a/tests/unit/llms/bigmodel/__init__.py b/tests/unit/llms/bigmodel/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/tests/unit/llms/bigmodel/test_bigmodel_provider.py b/tests/unit/llms/bigmodel/test_bigmodel_provider.py new file mode 100644 index 00000000000..cdf98c8f9a4 --- /dev/null +++ b/tests/unit/llms/bigmodel/test_bigmodel_provider.py @@ -0,0 +1,1066 @@ +import asyncio +import contextlib +import gzip +import io +import json +from collections.abc import AsyncIterator, Callable, Iterator, Mapping +from functools import partial +from itertools import chain +from typing import Final, Literal + +import httpx +import pytest +import respx +from openai.types.responses import Response as OpenAIResponse +from pydantic import JsonValue + +import litellm +from litellm.llms.bigmodel.chat.transformation import BigModelChatConfig +from litellm.llms.bigmodel.common_utils import get_bigmodel_url +from litellm.llms.bigmodel.messages.transformation import BigModelMessagesConfig +from litellm.llms.bigmodel.responses.transformation import BigModelResponsesAPIConfig +from litellm.types.llms.openai import ResponseAPIUsage, ResponsesAPIResponse +from litellm.types.router import GenericLiteLLMParams +from litellm.types.utils import LlmProviders +from litellm.utils import ProviderConfigManager + +MODEL: Final = "glm-5.3" +BASE: Final = "https://open.bigmodel.cn" +KEY: Final = "test-bigmodel-key" +BASE_PATHS: Final = ( + "", + "/", + "/api", + "/api/paas/v4", + "/api/paas/v4/", + "/api/paas/v4/chat/completions", + "/api/v1", + "/api/v1/responses", + "/api/anthropic", + "/api/anthropic/v1", + "/api/anthropic/v1/messages", +) + + +class _ByteChunks(httpx.AsyncByteStream): + def __init__(self, chunks: tuple[bytes, ...]) -> None: + self._chunks = chunks + self.is_closed = False + + async def __aiter__(self) -> AsyncIterator[bytes]: + for chunk in self._chunks: + yield chunk + + async def aclose(self) -> None: + self.is_closed = True + + +@pytest.fixture(autouse=True) +def bigmodel_environment(monkeypatch: pytest.MonkeyPatch) -> Iterator[None]: + monkeypatch.setenv("BIGMODEL_API_KEY", KEY) + monkeypatch.delenv("BIGMODEL_API_BASE", raising=False) + monkeypatch.setattr(litellm, "disable_aiohttp_transport", True) + yield + + +def response_payload() -> Mapping[str, JsonValue]: + return { + "id": "resp_bigmodel_test", + "created_at": 1, + "object": "response", + "model": MODEL, + "status": "completed", + "parallel_tool_calls": False, + "tools": None, + "tool_choice": None, + "output": [ + {"id": "rs_test", "type": "reasoning", "content": {"type": "reasoning_text", "text": "Thinking"}}, + { + "id": "msg_test", + "type": "message", + "role": "assistant", + "status": "completed", + "content": {"type": "output_text", "text": "Hello", "annotations": []}, + }, + ], + "usage": { + "input_tokens": 20, + "output_tokens": 10, + "input_tokens_details": {"cached_tokens": 5}, + "output_tokens_details": {"reasoning_tokens": 3}, + }, + } + + +def test_provider_resolution_and_all_native_endpoints() -> None: + model, provider, key, base = litellm.get_llm_provider(f"bigmodel/{MODEL}") + assert (model, provider, key, base) == (MODEL, "bigmodel", KEY, f"{BASE}/api/paas/v4") + assert isinstance(ProviderConfigManager.get_provider_chat_config(MODEL, LlmProviders.BIGMODEL), BigModelChatConfig) + assert isinstance( + ProviderConfigManager.get_provider_responses_api_config("bigmodel", MODEL), BigModelResponsesAPIConfig + ) + assert isinstance( + ProviderConfigManager.get_provider_anthropic_messages_config(MODEL, LlmProviders.BIGMODEL), + BigModelMessagesConfig, + ) + assert "bigmodel" in litellm.provider_list + assert litellm.get_llm_provider(f"zai/{MODEL}")[3] == "https://api.z.ai/api/paas/v4" + + +@pytest.mark.parametrize("path", BASE_PATHS) +def test_one_base_selects_each_api_without_duplicate_segments(path: str) -> None: + assert get_bigmodel_url(f"{BASE}{path}", "chat") == f"{BASE}/api/paas/v4" + assert get_bigmodel_url(f"{BASE}{path}", "responses") == f"{BASE}/api/v1/responses" + assert get_bigmodel_url(f"{BASE}{path}", "messages") == f"{BASE}/api/anthropic/v1/messages" + + +@pytest.mark.parametrize( + "path", ("/api/coding/paas/v4", "/api/coding/paas/v4/", "/api/coding/paas/v4/chat/completions") +) +def test_coding_plan_base_keeps_its_chat_path_and_shares_the_other_apis(path: str) -> None: + assert get_bigmodel_url(f"{BASE}{path}", "chat") == f"{BASE}/api/coding/paas/v4" + assert get_bigmodel_url(f"{BASE}{path}", "responses") == f"{BASE}/api/v1/responses" + assert get_bigmodel_url(f"{BASE}{path}", "messages") == f"{BASE}/api/anthropic/v1/messages" + + +def test_custom_base_and_environment_precedence(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("BIGMODEL_API_BASE", "https://env.example/prefix/api/v1") + assert get_bigmodel_url(None, "messages") == "https://env.example/prefix/api/anthropic/v1/messages" + assert ( + get_bigmodel_url("https://override.example/prefix/api/anthropic/", "chat") + == "https://override.example/prefix/api/paas/v4" + ) + assert ( + BigModelResponsesAPIConfig().validate_environment({}, MODEL, GenericLiteLLMParams(api_key="explicit"))[ + "Authorization" + ] + == "Bearer explicit" + ) + assert BigModelResponsesAPIConfig().validate_environment({}, MODEL, None)["Authorization"] == f"Bearer {KEY}" + + +@pytest.mark.parametrize("is_async", (False, True)) +@pytest.mark.asyncio +@respx.mock +async def test_chat_uses_native_endpoint_and_maps_token_limit(is_async: bool) -> 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}, + } + ) + params: Final = { + "model": f"bigmodel/{MODEL}", + "messages": [{"role": "user", "content": "Hello"}], + "api_base": f"{BASE}/api/anthropic", + "max_completion_tokens": 10, + "thinking": {"type": "enabled"}, + "reasoning_effort": "low", + "extra_body": {"clear_thinking": False}, + } + response: Final = await litellm.acompletion(**params) if is_async else litellm.completion(**params) + assert response.choices[0].message.content == "Hello" + request: Final = route.calls.last.request + body: Final = json.loads(request.content) + assert body["model"] == MODEL + assert body["max_tokens"] == 10 + assert "max_completion_tokens" not in body + assert body["thinking"] == {"type": "enabled"} + assert body["reasoning_effort"] == "low" + assert body["clear_thinking"] is False + assert request.headers["authorization"] == f"Bearer {KEY}" + assert route.call_count == 1 + if is_async: + health: Final = await litellm.ahealth_check(params, mode="chat") + assert "error" not in health + assert json.loads(route.calls.last.request.content)["thinking"] == {"type": "enabled"} + + +@respx.mock +def test_chat_does_not_send_the_openai_organization_to_bigmodel(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("OPENAI_ORGANIZATION", "org-openai-only") + 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": 1, "completion_tokens": 1, "total_tokens": 2}, + } + ) + _ = litellm.completion(model=f"bigmodel/{MODEL}", messages=[{"role": "user", "content": "Hello"}]) + assert "openai-organization" not in route.calls.last.request.headers + + +@pytest.mark.parametrize("is_async", (False, True)) +@pytest.mark.asyncio +@respx.mock +async def test_responses_native_body_text_usage_and_continuation(is_async: bool) -> None: + route: Final = respx.post(f"{BASE}/api/v1/responses").respond(json=response_payload()) + params: Final = { + "model": f"bigmodel/{MODEL}", + "input": "Hello", + "api_base": BASE, + "max_output_tokens": 1, + "reasoning": {"effort": "max"}, + } + response: Final = await litellm.aresponses(**params) if is_async else litellm.responses(**params) + assert isinstance(response, ResponsesAPIResponse) + assert response.output_text == "Hello" + assert isinstance(response.usage, ResponseAPIUsage) + assert response.usage.total_tokens == 30 + assert response.usage.input_tokens_details.cached_tokens == 5 + assert response.usage.output_tokens_details.reasoning_tokens == 3 + assert response.tools == [] + assert response.tool_choice == "auto" + OpenAIResponse.model_validate(response.model_dump()) + first_body: Final = json.loads(route.calls.last.request.content) + assert first_body["model"] == MODEL + assert first_body["max_output_tokens"] == 1 + assert first_body["reasoning"] == {"effort": "max"} + assert first_body.get("store") is not True + assert "messages" not in first_body + assert route.calls.last.request.headers["authorization"] == f"Bearer {KEY}" + followup: Final = {**params, "previous_response_id": response.id, "store": True} + if is_async: + await litellm.aresponses(**followup) + else: + litellm.responses(**followup) + assert json.loads(route.calls.last.request.content)["previous_response_id"] == "resp_bigmodel_test" + assert json.loads(route.calls.last.request.content)["store"] is True + + +@pytest.mark.asyncio +@respx.mock +async def test_responses_decodes_compressed_response_once() -> None: + respx.post(f"{BASE}/api/v1/responses").respond( + content=gzip.compress(json.dumps(response_payload()).encode()), + headers={"content-encoding": "gzip", "content-type": "application/json"}, + ) + response: Final = await litellm.aresponses(model=f"bigmodel/{MODEL}", input="Hello") + assert response.output_text == "Hello" + assert response.usage.total_tokens == 30 + + +@pytest.mark.parametrize( + "reasoning_params", + ( + {"thinking": {"type": "adaptive"}, "output_config": {"effort": "low"}}, + {"thinking": {"type": "enabled", "budget_tokens": 1024}}, + {"reasoning_effort": "low"}, + ), +) +def test_messages_preserves_native_reasoning_params(reasoning_params: 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": 2048, **reasoning_params}, + litellm_params=GenericLiteLLMParams(), + headers={}, + ) + 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"}}, + ), + ( + MODEL, + {"reasoning_effort": "high"}, + {"thinking": {"type": "adaptive"}, "reasoning_effort": "high", "output_config": None}, + ), + ("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)) +async def test_messages_forward_native_tools_thinking_and_usage(web_fetch_requests: int | None) -> None: + route: Final = respx.post(f"{BASE}/api/anthropic/v1/messages").respond( + json={ + "id": "msg_test", + "type": "message", + "role": "assistant", + "model": MODEL, + "content": [{"type": "text", "text": "Hello"}], + "stop_reason": "end_turn", + "stop_sequence": None, + "usage": { + "input_tokens": 20, + "output_tokens": 10, + "server_tool_use": { + "web_search_requests": 1, + **({"web_fetch_requests": web_fetch_requests} if web_fetch_requests is not None else {}), + }, + }, + } + ) + tools: Final = [{"name": "weather", "input_schema": {"type": "object", "properties": {}}}] + params: Final = { + "model": f"bigmodel/{MODEL}", + "max_tokens": 100, + "messages": [{"role": "user", "content": "Hello"}], + "tools": tools, + "thinking": {"type": "enabled", "budget_tokens": 50}, + "api_base": f"{BASE}/api/paas/v4", + "provider_specific_header": { + "custom_llm_provider": "bigmodel", + "native_anthropic_messages_only": True, + "extra_headers": {"x-native-message": "preserved"}, + }, + } + response: Final = await litellm.anthropic.messages.acreate(**params) + assert response["content"][0]["text"] == "Hello" + assert response["usage"]["server_tool_use"] == { + "web_search_requests": 1, + "web_fetch_requests": web_fetch_requests or 0, + } + body: Final = json.loads(route.calls.last.request.content) + assert body["model"] == MODEL + assert body["tools"] == tools + assert body["thinking"]["type"] == "enabled" + assert "input" not in body + assert route.calls.last.request.headers["x-api-key"] == KEY + assert route.calls.last.request.headers["x-native-message"] == "preserved" + + +@pytest.mark.parametrize( + "terminal_type,status", + (("response.completed", "completed"), ("response.incomplete", "incomplete")), +) +@pytest.mark.parametrize("include_logprobs", (False, True)) +@pytest.mark.parametrize("content_type", ("text/event-stream", "application/json")) +@pytest.mark.asyncio +@respx.mock +async def test_responses_sse_without_done_preserves_terminal_usage( + terminal_type: str, status: str, include_logprobs: bool, content_type: str +) -> None: + logprobs: Final = [{"token": "Hello", "bytes": [72, 101, 108, 108, 111], "logprob": -0.1, "top_logprobs": []}] + events: Final = ( + { + "type": "response.created", + "sequence_number": 0, + "response": { + **{key: value for key, value in response_payload().items() if key != "parallel_tool_calls"}, + "status": "in_progress", + "output": [], + "usage": None, + }, + }, + { + "type": "response.output_text.delta", + "sequence_number": 1, + "item_id": "msg_test", + "output_index": 0, + "content_index": 0, + "delta": "Hello", + }, + { + "type": "response.content_part.done", + "sequence_number": 2, + "item_id": "msg_test", + "output_index": 0, + "content_index": 0, + "part": {"type": "output_text", "text": "Hello"}, + }, + { + "type": "response.content_part.done", + "sequence_number": 3, + "item_id": "rs_test", + "output_index": 1, + "content_index": 0, + "part": {"type": "reasoning_text", "text": "Thinking"}, + }, + { + "type": "response.output_text.done", + "sequence_number": 4, + "item_id": "msg_test", + "output_index": 0, + "content_index": 0, + "text": "Hello", + **({"logprobs": logprobs} if include_logprobs else {}), + }, + {"type": terminal_type, "sequence_number": 5, "response": {**response_payload(), "status": status}}, + ) + sse: Final = "".join(f"event: {event['type']}\ndata: {json.dumps(event)}\n\n" for event in events) + respx.post(f"{BASE}/api/v1/responses").respond(content=sse, headers={"content-type": content_type}) + stream: Final = await litellm.aresponses(model=f"bigmodel/{MODEL}", input="Hello", stream=True) + chunks: Final = tuple([chunk async for chunk in stream]) + assert chunks[-1].type == terminal_type + assert chunks[-1].response.status == status + 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 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") == ( + "Hello", + "Thinking", + ) + + +@respx.mock +def test_get_response_uses_bigmodel_url_and_normalizes_usage() -> None: + route: Final = respx.post(f"{BASE}/api/v1/responses").respond(json=response_payload()) + response: Final = litellm.responses(model=f"bigmodel/{MODEL}", input="Hello", store=True) + respx.get(f"{BASE}/api/v1/responses/resp_bigmodel_test").respond(json=response_payload()) + retrieved: Final = litellm.get_responses(response_id=response.id) + assert retrieved.output_text == "Hello" + assert retrieved.usage.total_tokens == 30 + assert route.call_count == 1 + + +@pytest.mark.parametrize("provider_api", ("chat", "responses", "messages")) +def test_missing_bigmodel_key_does_not_use_other_provider_credentials( + provider_api: Literal["chat", "responses", "messages"], monkeypatch: pytest.MonkeyPatch +) -> None: + monkeypatch.delenv("BIGMODEL_API_KEY") + monkeypatch.setenv("OPENAI_API_KEY", "unrelated-openai-key") + monkeypatch.setenv("ANTHROPIC_API_KEY", "unrelated-anthropic-key") + call: Final = { + "chat": partial(litellm.completion, model=f"bigmodel/{MODEL}", messages=[{"role": "user", "content": "Hello"}]), + "responses": partial(BigModelResponsesAPIConfig().validate_environment, {}, MODEL, None), + "messages": partial(BigModelMessagesConfig().validate_anthropic_messages_environment, {}, MODEL, (), {}, {}), + }[provider_api] + with pytest.raises(litellm.AuthenticationError, match="BIGMODEL_API_KEY"): + call() + + +def _audio_file() -> io.BytesIO: + audio: Final = io.BytesIO(b"RIFF0000WAVEfmt ") + audio.name = "audio.wav" + return audio + + +@pytest.mark.parametrize( + "call", + ( + partial(litellm.speech, model=f"bigmodel/{MODEL}", input="Hello", voice="tongtong"), + partial(litellm.image_generation, model=f"bigmodel/{MODEL}", prompt="Hello"), + lambda: litellm.transcription(model=f"bigmodel/{MODEL}", file=_audio_file()), + ), + ids=("speech", "image_generation", "transcription"), +) +@respx.mock +def test_openai_routes_bigmodel_does_not_serve_send_no_request( + call: Callable[[], object], monkeypatch: pytest.MonkeyPatch +) -> None: + monkeypatch.setenv("OPENAI_API_KEY", "unrelated-openai-key") + upstream: Final = respx.route().mock(return_value=httpx.Response(200, json={})) + with contextlib.suppress(Exception): + call() + assert not upstream.called + + +@pytest.mark.parametrize( + ("code", "error_class", "status_code"), + ( + (401, litellm.AuthenticationError, 401), + (1113, litellm.RateLimitError, 429), + (1301, litellm.BadRequestError, 400), + (1200, litellm.InternalServerError, 500), + ), +) +@respx.mock +def test_responses_failure_envelope_raises_its_status_instead_of_a_parse_error( + code: int, error_class: type[Exception], status_code: int +) -> None: + respx.post(f"{BASE}/api/v1/responses").respond(json={"code": code, "msg": "Token expired", "success": False}) + with pytest.raises(error_class, match="Token expired") as raised: + litellm.responses(model=f"bigmodel/{MODEL}", input="Hello") + 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( + ("code", "error_class"), + ((1261, litellm.ContextWindowExceededError), (1301, litellm.ContentPolicyViolationError)), +) +@pytest.mark.parametrize("is_async", (False, True)) +@pytest.mark.asyncio +@respx.mock +async def test_streamed_prompt_too_long_and_unsafe_content_raise_the_errors_fallbacks_catch( + code: int, error_class: type[Exception], is_async: bool +) -> None: + respx.post(f"{BASE}/api/v1/responses").respond(json={"code": code, "msg": "rejected by BigModel", "success": False}) + params: Final = {"model": f"bigmodel/{MODEL}", "input": "Hello", "stream": True} + + async def call() -> None: + if is_async: + await litellm.aresponses(**params) + return + litellm.responses(**params) + + with pytest.raises(error_class, match="rejected by BigModel") as raised: + await 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"gateway maintenance", 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: + tools: Final = [{"type": "function", "name": "weather", "parameters": {"type": "object", "properties": {}}}] + completed: Final = { + "type": "response.completed", + "sequence_number": 0, + "response": {**response_payload(), "parallel_tool_calls": None}, + } + route: Final = respx.post(f"{BASE}/api/v1/responses").respond( + **( + {"content": f"event: response.completed\ndata: {json.dumps(completed)}\n\n"} + if stream + else {"json": {**response_payload(), "parallel_tool_calls": None}} + ) + ) + answer: Final = litellm.responses( + model=f"bigmodel/{MODEL}", + input="Hello", + tools=tools, + tool_choice="required", + parallel_tool_calls=True, + include=["reasoning.encrypted_content"], + stream=stream, + ) + response: Final = tuple(answer)[-1].response if stream else answer + validated: Final = OpenAIResponse.model_validate(response.model_dump()) + sent: Final = json.loads(route.calls.last.request.content) + assert sent["parallel_tool_calls"] is True + assert sent["include"] == ["reasoning.encrypted_content"] + assert validated.tools[0].name == "weather" + assert validated.tool_choice == "required" + assert validated.parallel_tool_calls is True + + +@pytest.mark.asyncio +@respx.mock +async def test_responses_stream_answered_with_a_failure_envelope_raises_its_status() -> None: + respx.post(f"{BASE}/api/v1/responses").respond(json={"code": 401, "msg": "Token expired", "success": False}) + with pytest.raises(litellm.AuthenticationError, match="Token expired"): + await litellm.aresponses(model=f"bigmodel/{MODEL}", input="Hello", stream=True) + with pytest.raises(litellm.AuthenticationError, match="Token expired"): + litellm.responses(model=f"bigmodel/{MODEL}", input="Hello", stream=True) + + +@pytest.mark.parametrize("is_async", (False, True)) +@pytest.mark.asyncio +@respx.mock +async def test_responses_stream_answered_with_one_json_response_replays_it_as_events(is_async: bool) -> None: + respx.post(f"{BASE}/api/v1/responses").respond(json=response_payload()) + request: Final = {"model": f"bigmodel/{MODEL}", "input": "Hello", "stream": True} + chunks: Final = ( + tuple([chunk async for chunk in await litellm.aresponses(**request)]) + if is_async + else tuple(litellm.responses(**request)) + ) + assert chunks[-1].type == "response.completed" + assert chunks[-1].response.output_text == "Hello" + assert chunks[-1].response.usage.total_tokens == 30 + + +class _GatedStream(httpx.AsyncByteStream): + def __init__(self, first: bytes, rest: bytes, gate: asyncio.Event) -> None: + self._first = first + self._rest = rest + self._gate = gate + + async def __aiter__(self) -> AsyncIterator[bytes]: + yield self._first + await self._gate.wait() + yield self._rest + + +@pytest.mark.asyncio +@respx.mock +async def test_responses_stream_labeled_json_without_a_length_yields_events_before_it_ends() -> None: + created: Final = { + "type": "response.created", + "sequence_number": 0, + "response": {**response_payload(), "status": "in_progress", "output": [], "usage": None}, + } + completed: Final = {"type": "response.completed", "sequence_number": 1, "response": response_payload()} + gate: Final = asyncio.Event() + respx.post(f"{BASE}/api/v1/responses").mock( + return_value=httpx.Response( + 200, + headers={"content-type": "application/json"}, + stream=_GatedStream( + f"event: response.created\ndata: {json.dumps(created)}\n\n".encode(), + f"event: response.completed\ndata: {json.dumps(completed)}\n\n".encode(), + gate, + ), + ) + ) + + stream: Final = await asyncio.wait_for( + litellm.aresponses(model=f"bigmodel/{MODEL}", input="Hello", stream=True), timeout=10 + ) + first: Final = await asyncio.wait_for(anext(stream), timeout=10) + gate.set() + rest: Final = tuple([chunk async for chunk in stream]) + assert first.type == "response.created" + assert rest[-1].type == "response.completed" + assert rest[-1].response.output_text == "Hello" + + +@pytest.mark.parametrize("event_type", ("response.failed", "error")) +@pytest.mark.asyncio +@respx.mock +async def test_responses_failure_events_raise_instead_of_completing(event_type: str) -> None: + event: Final = ( + {"type": event_type, "sequence_number": 1, "code": "server_error", "message": "Provider failed"} + if event_type == "error" + else { + "type": event_type, + "sequence_number": 1, + "response": { + **response_payload(), + "status": "failed", + "error": {"code": "server_error", "message": "Provider failed"}, + }, + } + ) + respx.post(f"{BASE}/api/v1/responses").respond( + content=f"event: {event_type}\ndata: {json.dumps(event)}\n\n", + headers={"content-type": "text/event-stream"}, + ) + stream: Final = await litellm.aresponses(model=f"bigmodel/{MODEL}", input="Hello", stream=True) + with pytest.raises(litellm.exceptions.MidStreamFallbackError, match="Provider failed"): + await anext(stream) + + +@respx.mock +def test_responses_preserve_tools_multimodal_input_and_existing_usage_total() -> None: + payload: Final = response_payload() + route: Final = respx.post(f"{BASE}/api/v1/responses").respond( + json={**payload, "usage": {**payload["usage"], "total_tokens": 31}} + ) + input_items: Final = [ + { + "role": "user", + "content": [ + {"type": "input_text", "text": "Describe this"}, + {"type": "input_image", "image_url": "https://example.com/image.png"}, + ], + } + ] + tools: Final = [{"type": "function", "name": "weather", "parameters": {"type": "object", "properties": {}}}] + response: Final = litellm.responses( + model=f"bigmodel/{MODEL}", + input=input_items, + tools=tools, + tool_choice="auto", + text={"format": {"type": "json_object"}}, + ) + assert response.usage.total_tokens == 31 + assert response.output[0].content[0].text == "Thinking" + body: Final = json.loads(route.calls.last.request.content) + assert body["input"] == input_items + assert body["tools"] == tools + assert body["text"] == {"format": {"type": "json_object"}} + + +@respx.mock +def test_responses_preserve_explicit_tool_configuration() -> None: + tools: Final = [{"type": "function", "name": "weather", "parameters": {"type": "object"}, "strict": False}] + tool_choice: Final = {"type": "function", "name": "weather"} + reasoning: Final = { + "id": "rs_test", + "type": "reasoning", + "summary": [{"type": "summary_text", "text": "Planning"}], + "content": [{"type": "reasoning_text", "text": "Thinking"}], + } + tool_call: Final = { + "type": "function_call", + "id": "fc_test", + "call_id": "call_test", + "name": "weather", + "arguments": "{}", + "status": "completed", + } + respx.post(f"{BASE}/api/v1/responses").respond( + json={ + **response_payload(), + "output": [reasoning, tool_call], + "tools": tools, + "tool_choice": tool_choice, + "parallel_tool_calls": True, + } + ) + response: Final = litellm.responses(model=f"bigmodel/{MODEL}", input="Hello", tools=tools, tool_choice=tool_choice) + validated: Final = OpenAIResponse.model_validate(response.model_dump()) + assert validated.tools[0].name == "weather" + assert validated.tool_choice.name == "weather" + assert validated.parallel_tool_calls is True + assert validated.output[0].summary[0].text == reasoning["summary"][0]["text"] + assert validated.output[0].content[0].text == reasoning["content"][0]["text"] + assert validated.output[1].model_dump(exclude_none=True) == tool_call + + +@pytest.mark.asyncio +@respx.mock +async def test_router_routes_one_model_configuration_to_three_native_endpoints() -> None: + chat: Final = respx.post(f"{BASE}/api/paas/v4/chat/completions").respond( + json={ + "id": "chat_test", + "created": 1, + "model": MODEL, + "object": "chat.completion", + "choices": [{"index": 0, "message": {"role": "assistant", "content": "Hello"}, "finish_reason": "stop"}], + "usage": {"prompt_tokens": 20, "completion_tokens": 10, "total_tokens": 30}, + } + ) + responses: Final = respx.post(f"{BASE}/api/v1/responses").respond(json=response_payload()) + messages: Final = respx.post(f"{BASE}/api/anthropic/v1/messages").respond( + json={ + "id": "msg_test", + "type": "message", + "role": "assistant", + "model": MODEL, + "content": [{"type": "text", "text": "Hello"}], + "stop_reason": "end_turn", + "stop_sequence": None, + "usage": {"input_tokens": 20, "output_tokens": 10}, + } + ) + router: Final = litellm.Router( + model_list=[ + { + "model_name": "glm-native", + "litellm_params": {"model": f"bigmodel/{MODEL}", "api_key": KEY, "api_base": BASE}, + } + ], + num_retries=0, + ) + chat_result: Final = await router.acompletion(model="glm-native", messages=[{"role": "user", "content": "Hello"}]) + responses_result: Final = await router.aresponses(model="glm-native", input="Hello") + messages_result: Final = await router.aanthropic_messages( + model="glm-native", max_tokens=10, messages=[{"role": "user", "content": "Hello"}] + ) + assert ( + chat_result.choices[0].message.content + == responses_result.output_text + == messages_result["content"][0]["text"] + == "Hello" + ) + assert tuple(route.call_count for route in (chat, responses, messages)) == (1, 1, 1) + assert all(json.loads(route.calls.last.request.content)["model"] == MODEL for route in (chat, responses, messages)) + assert ( + chat.calls.last.request.headers["authorization"] + == responses.calls.last.request.headers["authorization"] + == f"Bearer {KEY}" + ) + assert messages.calls.last.request.headers["x-api-key"] == KEY + + +@pytest.mark.asyncio +@respx.mock +async def test_chat_stream_preserves_deltas_and_final_usage() -> None: + chunks: Final = ( + { + "id": "chat_test", + "object": "chat.completion.chunk", + "created": 1, + "model": MODEL, + "choices": [{"index": 0, "delta": {"reasoning_content": "Thinking"}, "finish_reason": None}], + }, + { + "id": "chat_test", + "object": "chat.completion.chunk", + "created": 1, + "model": MODEL, + "choices": [{"index": 0, "delta": {"role": "assistant", "content": "Hello"}, "finish_reason": None}], + }, + { + "id": "chat_test", + "object": "chat.completion.chunk", + "created": 1, + "model": MODEL, + "choices": [{"index": 0, "delta": {}, "finish_reason": "stop"}], + "usage": {"prompt_tokens": 20, "completion_tokens": 10, "total_tokens": 30}, + }, + ) + respx.post(f"{BASE}/api/paas/v4/chat/completions").respond( + content="".join(f"data: {json.dumps(chunk)}\n\n" for chunk in chunks) + "data: [DONE]\n\n", + headers={"content-type": "text/event-stream"}, + ) + stream: Final = await litellm.acompletion( + model=f"bigmodel/{MODEL}", + messages=[{"role": "user", "content": "Hello"}], + stream=True, + stream_options={"include_usage": True}, + ) + output: Final = tuple([chunk async for chunk in stream]) + assert ( + "".join(choice.delta.content or "" for choice in chain.from_iterable(chunk.choices for chunk in output)) + == "Hello" + ) + assert ( + "".join( + getattr(choice.delta, "reasoning_content", "") or "" + for choice in chain.from_iterable(chunk.choices for chunk in output) + ) + == "Thinking" + ) + assert tuple(chunk.usage.total_tokens for chunk in output if hasattr(chunk, "usage") and chunk.usage) == (30,) + + +@pytest.mark.asyncio +@respx.mock +async def test_messages_stream_preserves_native_events() -> None: + events: Final = ( + { + "type": "message_start", + "message": { + "id": "msg_test", + "type": "message", + "role": "assistant", + "model": MODEL, + "content": [], + "stop_reason": None, + "stop_sequence": None, + "usage": { + "input_tokens": 20, + "output_tokens": 0, + "server_tool_use": {"web_search_requests": 0}, + }, + }, + }, + {"type": "content_block_start", "index": 0, "content_block": {"type": "thinking", "thinking": ""}}, + {"type": "content_block_delta", "index": 0, "delta": {"type": "thinking_delta", "thinking": "先思考"}}, + { + "type": "content_block_delta", + "index": 0, + "delta": {"type": "signature_delta", "signature": "test-signature"}, + }, + {"type": "content_block_stop", "index": 0}, + {"type": "content_block_start", "index": 1, "content_block": {"type": "text", "text": ""}}, + {"type": "content_block_delta", "index": 1, "delta": {"type": "text_delta", "text": "你好 🌏"}}, + {"type": "content_block_stop", "index": 1}, + { + "type": "message_delta", + "delta": {"stop_reason": "end_turn", "stop_sequence": None}, + "usage": {"output_tokens": 10}, + }, + {"type": "message_stop"}, + ) + native_events: Final = tuple( + f"event: {event['type']}\ndata: {json.dumps(event, ensure_ascii=False)}\n\n" for event in events + ) + content: Final = ( + "id: bigmodel-event\nretry: 1000\n" + + native_events[0] + + "event: ping\n\n" + + "".join(native_events[1:]) + + "data: [DONE]\n\n" + ).encode() + upstream: Final = _ByteChunks(tuple(content[index : index + 7] for index in range(0, len(content), 7))) + respx.post(f"{BASE}/api/anthropic/v1/messages").mock( + return_value=httpx.Response( + 200, + stream=upstream, + headers={"content-type": "text/event-stream"}, + ) + ) + stream: Final = await litellm.anthropic.messages.acreate( + model=f"bigmodel/{MODEL}", + max_tokens=100, + messages=[{"role": "user", "content": "Hello"}], + stream=True, + ) + output: Final = tuple([chunk async for chunk in stream]) + lines: Final = tuple(chain.from_iterable(chunk.decode().splitlines() for chunk in output)) + parsed: Final = tuple(json.loads(line.removeprefix("data: ")) for line in lines if line.startswith("data: {")) + assert tuple(chunk["type"] for chunk in parsed) == tuple(event["type"] for event in events) + assert tuple(event["delta"] for event in parsed if event["type"] == "content_block_delta") == ( + {"type": "thinking_delta", "thinking": "先思考"}, + {"type": "signature_delta", "signature": "test-signature"}, + {"type": "text_delta", "text": "你好 🌏"}, + ) + assert parsed[-2]["usage"]["output_tokens"] == 10 + assert parsed[0]["message"]["usage"]["server_tool_use"] == { + "web_search_requests": 0, + "web_fetch_requests": 0, + } + assert tuple(line for line in lines if line.startswith("data: {"))[1:] == tuple( + f"data: {json.dumps(event, ensure_ascii=False)}" for event in events[1:] + ) + assert "event: ping" in lines + assert "data: [DONE]" in lines + assert b"id: bigmodel-event\n" in output[0] + assert b"retry: 1000\n" in output[0] + assert upstream.is_closed + + +@respx.mock +def test_stored_response_lifecycle_reuses_base_and_unwraps_id() -> None: + respx.post(f"{BASE}/api/v1/responses").respond(json=response_payload()) + response: Final = litellm.responses(model=f"bigmodel/{MODEL}", input="Hello", store=True) + listing: Final = respx.get(f"{BASE}/api/v1/responses/resp_bigmodel_test/input_items").respond( + json={"object": "list", "data": [], "has_more": False} + ) + deletion: Final = respx.delete(f"{BASE}/api/v1/responses/resp_bigmodel_test").respond( + json={"id": "resp_bigmodel_test", "object": "response.deleted", "deleted": True} + ) + assert litellm.list_input_items(response_id=response.id)["data"] == [] + assert litellm.delete_responses(response_id=response.id).deleted is True + assert listing.call_count == deletion.call_count == 1 + assert deletion.calls.last.request.headers["authorization"] == f"Bearer {KEY}" + + +@pytest.mark.parametrize("operation", ("cancel", "compact")) +@respx.mock +def test_unsupported_responses_operations_fail_without_an_upstream_request(operation: str) -> None: + call: Final = ( + partial(litellm.cancel_responses, response_id="resp_test", custom_llm_provider="bigmodel") + if operation == "cancel" + else partial(litellm.compact_responses, model=f"bigmodel/{MODEL}", input="Hello") + ) + with pytest.raises(litellm.UnsupportedParamsError, match="BigModel"): + call() + assert respx.calls.call_count == 0 + + +@pytest.mark.parametrize("model", ("glm-5.3", "glm-5.3-flash", "glm-5.3-flashx")) +def test_bigmodel_model_metadata_and_cached_token_costs(model: str) -> None: + entry: Final = litellm.model_cost[f"bigmodel/{model}"] + info: Final = litellm.get_model_info(model=model, custom_llm_provider="bigmodel") + assert info["litellm_provider"] == "bigmodel" + assert info["max_input_tokens"] == entry["max_input_tokens"] + assert info["max_output_tokens"] == entry["max_output_tokens"] + assert info["supports_reasoning"] + assert info["supports_function_calling"] + assert bool(info.get("supports_vision")) == bool(entry.get("supports_vision")) + prompt_cost, output_cost = litellm.cost_per_token( + model=model, + custom_llm_provider="bigmodel", + prompt_tokens=100, + completion_tokens=20, + cache_read_input_tokens=40, + ) + assert prompt_cost == pytest.approx(60 * entry["input_cost_per_token"] + 40 * entry["cache_read_input_token_cost"]) + assert output_cost == pytest.approx(20 * entry["output_cost_per_token"]) + assert prompt_cost > 0 + assert output_cost > 0 diff --git a/tests/unit/llms/custom_httpx/test_llm_http_handler.py b/tests/unit/llms/custom_httpx/test_llm_http_handler.py index f10f5a67b2f..90d1f6772a0 100644 --- a/tests/unit/llms/custom_httpx/test_llm_http_handler.py +++ b/tests/unit/llms/custom_httpx/test_llm_http_handler.py @@ -5784,3 +5784,16 @@ async def test_non_chatgpt_responses_preserves_caller_headers_after_validation( await client.close() else: client.client.close() + + +@pytest.mark.asyncio +async def test_a_stream_answered_with_one_json_body_is_not_replayed_for_a_provider_that_does_not_opt_in( + httpx_transport: None, +) -> None: + with respx.mock(assert_all_called=True) as router: + router.post("https://api.openai.com/v1/responses").respond(json={"error": {"message": "quota exceeded"}}) + stream: Final = await litellm.aresponses( + model="openai/gpt-5.5", input="Hello", api_key="fake-key", stream=True + ) + with pytest.raises(litellm.exceptions.MidStreamFallbackError, match="before any terminal event"): + _ = tuple([event async for event in stream]) diff --git a/tests/unit/proxy/public_endpoints/test_public_endpoints.py b/tests/unit/proxy/public_endpoints/test_public_endpoints.py index d55ce25d878..3e7597e903c 100644 --- a/tests/unit/proxy/public_endpoints/test_public_endpoints.py +++ b/tests/unit/proxy/public_endpoints/test_public_endpoints.py @@ -25,6 +25,26 @@ from litellm.types.proxy.public_endpoints.public_endpoints import ProviderCreate from litellm.types.utils import LlmProviders +def test_bigmodel_fields_require_private_key_and_share_default_base_with_adapter() -> None: + from litellm.llms.bigmodel.common_utils import BIGMODEL_API_BASE + + app: Final = FastAPI() + app.include_router(router) + response: Final = TestClient(app).get("/public/providers/fields") + assert response.status_code == 200 + providers: Final = TypeAdapter(list[ProviderCreateInfo]).validate_python(response.json()) + provider: Final = next(item for item in providers if item.litellm_provider == "bigmodel") + fields: Final = {field.key: field for field in provider.credential_fields} + assert provider.provider_display_name == "BigModel (Zhipu AI)" + assert fields["api_key"].required is True + assert fields["api_key"].field_type == "password" + assert fields["api_base"].required is False + assert fields["api_base"].default_value == BIGMODEL_API_BASE + assert fields["api_base"].placeholder == BIGMODEL_API_BASE + assert provider.default_model_placeholder is not None + assert provider.default_model_placeholder.startswith("bigmodel/") + + def test_fuse_presets_route_serves_the_shared_catalog_without_authentication() -> None: app: Final = FastAPI() app.include_router(router) diff --git a/ui/litellm-dashboard/public/assets/logos/bigmodel.png b/ui/litellm-dashboard/public/assets/logos/bigmodel.png new file mode 100644 index 00000000000..f22030f85b8 Binary files /dev/null and b/ui/litellm-dashboard/public/assets/logos/bigmodel.png differ diff --git a/ui/litellm-dashboard/src/components/model_add/CredentialModal.integration.test.tsx b/ui/litellm-dashboard/src/components/model_add/CredentialModal.integration.test.tsx index c7d939333ec..252724412fb 100644 --- a/ui/litellm-dashboard/src/components/model_add/CredentialModal.integration.test.tsx +++ b/ui/litellm-dashboard/src/components/model_add/CredentialModal.integration.test.tsx @@ -9,9 +9,18 @@ import CredentialModal from "./CredentialModal"; vi.mock("../networking", async () => { const actual = await vi.importActual("../networking"); + const { readFileSync } = await import("node:fs"); + const { resolve } = await import("node:path"); + const metadata = JSON.parse( + readFileSync( + resolve(__dirname, "../../../../../litellm/proxy/public_endpoints/provider_create_fields.json"), + "utf8", + ), + ) as { provider: string }[]; return { ...actual, getProviderCreateMetadata: vi.fn().mockResolvedValue([ + ...metadata.filter((provider) => provider.provider === "BIGMODEL"), { provider: "OpenAI", provider_display_name: Providers.OpenAI, @@ -157,6 +166,39 @@ const chooseProvider = async (user: ReturnType, provider await user.click(options.find((option) => option.textContent === provider) ?? options[0]); }; +describe("CredentialModal with BigModel metadata", () => { + it("requires a masked API key and shows the default base", async () => { + const user = userEvent.setup(); + const onSubmit = renderModal(); + await chooseProvider(user, Providers.BIGMODEL); + expect(await screen.findByLabelText("API Base")).toHaveValue("https://open.bigmodel.cn"); + expect(screen.getByLabelText("BigModel (Zhipu AI) API Key")).toHaveAttribute("type", "password"); + fill("Credential Name:", "bigmodel-key"); + await user.click(screen.getByRole("button", { name: "Add Credential" })); + expect(await screen.findByText("Required")).toBeInTheDocument(); + expect(onSubmit).not.toHaveBeenCalled(); + }); + + it("saves the key and an explicit base for BigModel", async () => { + const user = userEvent.setup(); + const onSubmit = renderModal(); + await chooseProvider(user, Providers.BIGMODEL); + await screen.findByLabelText("BigModel (Zhipu AI) API Key"); + fill("Credential Name:", "bigmodel-key"); + fill("BigModel (Zhipu AI) API Key", "synthetic-bigmodel-key"); + fill("API Base", "https://bigmodel-proxy.example"); + await user.click(screen.getByRole("button", { name: "Add Credential" })); + expect(onSubmit).toHaveBeenCalledWith( + { + credential_name: "bigmodel-key", + custom_llm_provider: "BIGMODEL", + credential_values: { api_key: "synthetic-bigmodel-key", api_base: "https://bigmodel-proxy.example" }, + }, + [], + ); + }); +}); + describe("CredentialModal with Anthropic workload identity federation", () => { it("deletes stored exchange fields when switching to a static token", async () => { const user = userEvent.setup(); diff --git a/ui/litellm-dashboard/src/components/provider_info_helpers.test.tsx b/ui/litellm-dashboard/src/components/provider_info_helpers.test.tsx index 83e80911cff..6bd7f4e0dbb 100644 --- a/ui/litellm-dashboard/src/components/provider_info_helpers.test.tsx +++ b/ui/litellm-dashboard/src/components/provider_info_helpers.test.tsx @@ -20,6 +20,18 @@ const BUNDLED_MODEL_MAP: Record = JSON.pa ); describe("provider_info_helpers", () => { + it("resolves BigModel metadata, name, logo and model placeholder", () => { + const metadata = PROVIDER_CREATE_FIELDS.find((provider) => provider.provider === "BIGMODEL"); + expect(provider_map.BIGMODEL).toBe(metadata?.litellm_provider); + expect(getProviderLogoAndName("bigmodel")).toEqual({ + displayName: "BigModel (Zhipu AI)", + logo: providerLogoMap[Providers.BIGMODEL], + }); + expect(getProviderLogoAndName("BIGMODEL")).toEqual(getProviderLogoAndName("bigmodel")); + expect(providerLogoMap[Providers.BIGMODEL]).toContain("bigmodel"); + expect(getPlaceholder(Providers.BIGMODEL)).toMatch(/^bigmodel\//); + }); + it("maps Microsoft 365 Copilot to its chat model provider and placeholder", () => { expect(provider_map.MICROSOFT_365_COPILOT).toBe("microsoft_365_copilot"); expect(getPlaceholder(Providers.MICROSOFT_365_COPILOT)).toBe("microsoft_365_copilot/chat"); diff --git a/ui/litellm-dashboard/src/components/provider_info_helpers.tsx b/ui/litellm-dashboard/src/components/provider_info_helpers.tsx index bfb578b218a..fc0da6174c4 100644 --- a/ui/litellm-dashboard/src/components/provider_info_helpers.tsx +++ b/ui/litellm-dashboard/src/components/provider_info_helpers.tsx @@ -6,6 +6,7 @@ import anthropicLogo from "../../public/assets/logos/anthropic.svg"; import assemblyaiSmallLogo from "../../public/assets/logos/assemblyai_small.png"; import basetenLogo from "../../public/assets/logos/baseten.svg"; import bedrockLogo from "../../public/assets/logos/bedrock.svg"; +import bigmodelLogo from "../../public/assets/logos/bigmodel.png"; import cerebrasLogo from "../../public/assets/logos/cerebras.svg"; import cloudflareLogo from "../../public/assets/logos/cloudflare.svg"; import cohereLogo from "../../public/assets/logos/cohere.svg"; @@ -197,6 +198,7 @@ export enum Providers { xAI = "xAI", XINFERENCE = "Xinference", ZAI = "Z.AI (Zhipu AI)", + BIGMODEL = "BigModel (Zhipu AI)", ZeroGPU = "ZeroGPU", } @@ -326,6 +328,7 @@ export const provider_map: Record = { xAI: "xai", XINFERENCE: "xinference", ZAI: "zai", + BIGMODEL: "bigmodel", ZeroGPU: "zerogpu", }; @@ -335,6 +338,7 @@ export const resolveLitellmProviderSlug = (provider: string): string => provider_map[provider] ?? provider.toLowerCase(); export const providerLogoMap: Partial> = { + [Providers.BIGMODEL]: bigmodelLogo.src, [Providers.A2A_Agent]: a2aAgentLogo.src, [Providers.AI21]: ai21Logo.src, [Providers.AI21_CHAT]: ai21Logo.src, @@ -499,6 +503,7 @@ const providerPlaceholderMap: Partial> = { [Providers.Voyage]: "voyage/", [Providers.WATSONX]: "watsonx/ibm/granite-3-3-8b-instruct", [Providers.ZAI]: "zai/glm-4.5", + [Providers.BIGMODEL]: "bigmodel/glm-5.3", [Providers.ZeroGPU]: "zerogpu/gpt-oss-120b", };