mirror of
https://github.com/BerriAI/litellm.git
synced 2026-11-06 07:48:06 +00:00
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:
parent
cfe0100e1f
commit
960d891522
26 changed files with 1542 additions and 0 deletions
1
.github/workflows/test-unit.yml
vendored
1
.github/workflows/test-unit.yml
vendored
|
|
@ -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
61
examples/bigmodel.md
Normal 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
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -1083,6 +1083,7 @@ openai_compatible_providers: Final[list[str]] = [
|
|||
"xinference",
|
||||
"xai",
|
||||
"zai",
|
||||
"bigmodel",
|
||||
"together_ai",
|
||||
"fireworks_ai",
|
||||
"empower",
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
0
litellm/llms/bigmodel/__init__.py
Normal file
0
litellm/llms/bigmodel/__init__.py
Normal file
0
litellm/llms/bigmodel/chat/__init__.py
Normal file
0
litellm/llms/bigmodel/chat/__init__.py
Normal file
55
litellm/llms/bigmodel/chat/transformation.py
Normal file
55
litellm/llms/bigmodel/chat/transformation.py
Normal 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"]},
|
||||
}
|
||||
38
litellm/llms/bigmodel/common_utils.py
Normal file
38
litellm/llms/bigmodel/common_utils.py
Normal 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)
|
||||
)
|
||||
0
litellm/llms/bigmodel/messages/__init__.py
Normal file
0
litellm/llms/bigmodel/messages/__init__.py
Normal file
149
litellm/llms/bigmodel/messages/transformation.py
Normal file
149
litellm/llms/bigmodel/messages/transformation.py
Normal 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")
|
||||
0
litellm/llms/bigmodel/responses/__init__.py
Normal file
0
litellm/llms/bigmodel/responses/__init__.py
Normal file
204
litellm/llms/bigmodel/responses/transformation.py
Normal file
204
litellm/llms/bigmodel/responses/transformation.py
Normal 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)
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
]
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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": {
|
||||
|
|
|
|||
725
tests/unit/llms/bigmodel/test_bigmodel_provider.py
Normal file
725
tests/unit/llms/bigmodel/test_bigmodel_provider.py
Normal 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
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
BIN
ui/litellm-dashboard/public/assets/logos/bigmodel.png
Normal file
BIN
ui/litellm-dashboard/public/assets/logos/bigmodel.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 11 KiB |
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -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 => {
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue