feat: add BigModel provider with native Chat, Responses, and Messages APIs

Co-authored-by: Bytechoreographer <Bytechoreographer@users.noreply.github.com>
This commit is contained in:
AaronHowell 2026-10-27 10:13:22 +00:00
parent cfe0100e1f
commit 960d891522
26 changed files with 1542 additions and 0 deletions

View file

@ -235,6 +235,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

61
examples/bigmodel.md Normal file
View file

@ -0,0 +1,61 @@
# 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
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

View file

@ -634,6 +634,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()
@ -873,6 +874,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":
@ -1097,6 +1100,7 @@ model_list = list(
| text_completion_inception_models
| xai_models
| zai_models
| bigmodel_models
| fal_ai_models
| deepseek_models
| modelscope_models
@ -1208,6 +1212,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,

View file

@ -1083,6 +1083,7 @@ openai_compatible_providers: Final[list[str]] = [
"xinference",
"xai",
"zai",
"bigmodel",
"together_ai",
"fireworks_ai",
"empower",

View file

@ -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,

View file

View file

View file

@ -0,0 +1,55 @@
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
_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 API 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
return {
**mapped,
"extra_body": {"thinking": non_default_params["thinking"]},
}

View file

@ -0,0 +1,38 @@
from types import MappingProxyType
from typing import Final, Literal
from urllib.parse import urlsplit, urlunsplit
from litellm.secret_managers.main import get_secret_str
BIGMODEL_API_BASE: Final = "https://open.bigmodel.cn"
_API_PATHS: Final = (
"/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("/")
root: Final = next((path.removesuffix(suffix) for suffix in _API_PATHS if path.endswith(suffix)), path)
return urlunsplit(
(parsed.scheme, parsed.netloc, f"{root}{_ENDPOINT_PATHS[endpoint]}", parsed.query, parsed.fragment)
)

View file

@ -0,0 +1,149 @@
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
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 ..common_utils import get_bigmodel_api_key, get_bigmodel_url
_JSON_OBJECT: Final = TypeAdapter(dict[str, JsonValue])
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 _normalize_event(event: ServerSentEvent) -> bytes:
body: Final = _JSON_OBJECT.validate_json(event.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)
)
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
)
return f"{metadata}data: {json.dumps(normalized)}\n\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_response(
self, model: str, raw_response: httpx.Response, logging_obj: Logging
) -> AnthropicMessagesResponse:
normalized: Final = httpx.Response(
raw_response.status_code,
json=_normalize_message(_JSON_OBJECT.validate_json(raw_response.content)),
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
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")

View file

@ -0,0 +1,204 @@
from collections.abc import Mapping
from typing import Final
import httpx
from pydantic import JsonValue, TypeAdapter
from typing_extensions import Never
from litellm.exceptions import AuthenticationError, UnsupportedParamsError
from litellm.litellm_core_utils.litellm_logging import Logging
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 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",
"reasoning",
"text",
"prompt_cache_key",
"previous_response_id",
"store",
"extra_headers",
"extra_query",
"extra_body",
"timeout",
)
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],
) -> 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")
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] = {
"parallel_tool_calls": False,
**body,
"tools": [] if tools is None else tools,
"tool_choice": "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 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:
normalized: Final = _normalize_response(_JSON_OBJECT.validate_json(raw_response.content))
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)} 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)

View file

@ -2666,6 +2666,10 @@ 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

View file

@ -76005,5 +76005,81 @@
"high",
"max"
]
},
"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
}
}

View file

@ -4144,5 +4144,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"
}
]

View file

@ -4422,6 +4422,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"

View file

@ -9133,6 +9133,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),
@ -9325,6 +9326,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
@ -9645,6 +9652,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
@ -9876,6 +9887,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

View file

@ -76005,5 +76005,81 @@
"high",
"max"
]
},
"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
}
}

View file

@ -3289,6 +3289,23 @@
"rerank": false,
"a2a": false
}
},
"bigmodel": {
"display_name": "BigModel (Zhipu AI) (`bigmodel`)",
"url": "https://docs.bigmodel.cn/cn/guide/develop/responses/introduction",
"endpoints": {
"chat_completions": true,
"messages": true,
"responses": true,
"embeddings": false,
"image_generations": false,
"audio_transcriptions": false,
"audio_speech": false,
"moderations": false,
"batches": false,
"rerank": false,
"a2a": false
}
}
},
"endpoints": {

View file

@ -0,0 +1,725 @@
import gzip
import json
from collections.abc import AsyncIterator, 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.openai_compatible_providers
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"
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"}
@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.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.asyncio
@respx.mock
async def test_responses_sse_without_done_preserves_terminal_usage(
terminal_type: str, status: str, include_logprobs: bool
) -> 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": "text/event-stream"})
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 False
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()
@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"},
)
content: Final = (
"id: bigmodel-event\nretry: 1000\n"
+ "".join(f"event: {event['type']}\ndata: {json.dumps(event, ensure_ascii=False)}\n\n" for event in events)
).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])
parsed: Final = tuple(
json.loads(line.removeprefix("data: "))
for line in chain.from_iterable(chunk.decode().splitlines() for chunk in output)
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 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

View file

@ -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)

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

View file

@ -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<typeof userEvent.setup>, 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();

View file

@ -20,6 +20,18 @@ const BUNDLED_MODEL_MAP: Record<string, { litellm_provider?: string }> = 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");

View file

@ -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)",
}
export const provider_map: Record<string, string> = {
@ -325,6 +327,7 @@ export const provider_map: Record<string, string> = {
xAI: "xai",
XINFERENCE: "xinference",
ZAI: "zai",
BIGMODEL: "bigmodel",
};
const standaloneSubproviderSlugs = new Set<string>(["bedrock_mantle"]);
@ -333,6 +336,7 @@ export const resolveLitellmProviderSlug = (provider: string): string =>
provider_map[provider] ?? provider.toLowerCase();
export const providerLogoMap: Partial<Record<Providers, string>> = {
[Providers.BIGMODEL]: bigmodelLogo.src,
[Providers.A2A_Agent]: a2aAgentLogo.src,
[Providers.AI21]: ai21Logo.src,
[Providers.AI21_CHAT]: ai21Logo.src,
@ -497,6 +501,7 @@ const providerPlaceholderMap: Partial<Record<Providers, string>> = {
[Providers.Voyage]: "voyage/",
[Providers.WATSONX]: "watsonx/ibm/granite-3-3-8b-instruct",
[Providers.ZAI]: "zai/glm-4.5",
[Providers.BIGMODEL]: "bigmodel/glm-5.3",
};
export const getPlaceholder = (selectedProvider: string): string => {