diff --git a/docs/models/overview.md b/docs/models/overview.md index 36137db6e5..8e7436da79 100644 --- a/docs/models/overview.md +++ b/docs/models/overview.md @@ -10,6 +10,7 @@ Pydantic AI is model-agnostic and has built-in support for multiple model provid * [Cohere](cohere.md) * [Bedrock](bedrock.md) * [Hugging Face](huggingface.md) +* [Zhipu AI](zhipu.md) ## OpenAI-compatible Providers diff --git a/docs/models/zhipu.md b/docs/models/zhipu.md new file mode 100644 index 0000000000..dbd2938f9b --- /dev/null +++ b/docs/models/zhipu.md @@ -0,0 +1,202 @@ +# Zhipu AI + +## Install + +To use Zhipu AI models, you need to either install `pydantic-ai`, or install `pydantic-ai-slim` with the `openai` optional group (since Zhipu AI provides an OpenAI-compatible API): + +```bash +pip/uv-add "pydantic-ai-slim[openai]" +``` + +## Configuration + +To use [Zhipu AI](https://bigmodel.cn/) (智谱AI) through their API, you need to: + +1. Visit [bigmodel.cn](https://bigmodel.cn) and create an account +2. Go to [API Keys management](https://bigmodel.cn/usercenter/proj-mgmt/apikeys) +3. Create a new API key + +## Environment variable + +Once you have the API key, you can set it as an environment variable: + +```bash +export ZHIPU_API_KEY='your-api-key' +``` + +You can then use Zhipu AI models by name: + +```python +from pydantic_ai import Agent + +agent = Agent('zhipu:glm-4.5') +... +``` + +Or initialise the model directly: + +```python +from pydantic_ai import Agent +from pydantic_ai.models.openai import OpenAIChatModel + +model = OpenAIChatModel('glm-4.5', provider='zhipu') +agent = Agent(model) +... +``` + +## `provider` argument + +You can provide a custom `ZhipuProvider` via the `provider` argument: + +```python +from pydantic_ai import Agent +from pydantic_ai.models.openai import OpenAIChatModel +from pydantic_ai.providers.zhipu import ZhipuProvider + +model = OpenAIChatModel( + 'glm-4.5', provider=ZhipuProvider(api_key='your-api-key') +) +agent = Agent(model) +... +``` + +You can also customize the `ZhipuProvider` with a custom `httpx.AsyncClient`: + +```python +from httpx import AsyncClient + +from pydantic_ai import Agent +from pydantic_ai.models.openai import OpenAIChatModel +from pydantic_ai.providers.zhipu import ZhipuProvider + +custom_http_client = AsyncClient(timeout=30) +model = OpenAIChatModel( + 'glm-4.5', + provider=ZhipuProvider(api_key='your-api-key', http_client=custom_http_client), +) +agent = Agent(model) +... +``` + +## Available Models + +Zhipu AI offers several models through their OpenAI-compatible API: + +### GLM-4 Series + +- **`glm-4.6`**: Latest flagship model with 205K context window +- **`glm-4.5`**: High-performance model with 131K context window +- **`glm-4.5-air`**: Balanced performance and cost with 131K context +- **`glm-4.5-flash`**: Fast response model with 131K context + +### Vision Models + +- **`glm-4v-plus`**: Advanced vision understanding model +- **`glm-4v`**: Vision model for image analysis +- **`glm-4.5v`**: Vision-enabled variant with 64K context + +### Specialized Models + +- **`codegeex-4`**: Code generation and understanding model + +## Features + +### Function Calling + +Zhipu AI models support function calling (tool use): + +```python +from pydantic_ai import Agent, RunContext + +agent = Agent( + 'zhipu:glm-4.5', + system_prompt='You are a helpful assistant with access to tools.', +) + +@agent.tool +async def get_weather(ctx: RunContext[None], location: str) -> str: + """Get the weather for a location.""" + return f"The weather in {location} is sunny, 25°C" + +result = await agent.run('What is the weather in Beijing?') +print(result.output) +``` + +### Streaming + +Zhipu AI supports streaming responses: + +```python +from pydantic_ai import Agent + +agent = Agent('zhipu:glm-4.5') + +async with agent.run_stream('Tell me a story') as response: + async for message in response.stream_text(): + print(message, end='', flush=True) +``` + +### Vision Understanding + +Use vision models to analyze images: + +```python +from pydantic_ai import Agent + +agent = Agent('zhipu:glm-4v-plus') + +result = await agent.run( + 'What is in this image?', + message_history=[ + { + 'role': 'user', + 'content': [ + {'type': 'text', 'text': 'Describe this image:'}, + {'type': 'image_url', 'image_url': {'url': 'https://example.com/image.jpg'}} + ] + } + ] +) +print(result.output) +``` + +## Important Notes + +### Temperature Range + +Unlike OpenAI, Zhipu AI requires temperature to be in the range `(0, 1)` (exclusive). Setting `temperature=0` is not supported and will cause an error. + +```python +# This will work +agent = Agent('zhipu:glm-4.5', model_settings={'temperature': 0.1}) + +# This will NOT work with Zhipu AIs +# agent = Agent('zhipu:glm-4.5', model_settings={'temperature': 0}) +``` + +### Strict Mode + +Zhipu AI does not support OpenAI's strict mode for tool definitions. The framework automatically handles this by setting `openai_supports_strict_tool_definition=False` in the model profile. + +## Advanced Features + +### Thinking Mode + +GLM-4.5 and GLM-4.5-Air support a "thinking" mode for complex reasoning tasks. This can be enabled using the `extra_body` parameter: + +```python +from pydantic_ai.models.openai import OpenAIChatModel +from pydantic_ai.providers.zhipu import ZhipuProvider + +model = OpenAIChatModel('glm-4.5', provider=ZhipuProvider(api_key='your-api-key')) + +# Note: Thinking mode requires using the OpenAI client directly +# or passing extra_body through model_settings +``` + +## API Reference + +For more details, see: + +- [ZhipuProvider API Reference][pydantic_ai.providers.zhipu.ZhipuProvider] +- [Zhipu AI Official Documentation](https://docs.bigmodel.cn/) diff --git a/examples/zhipu_example.py b/examples/zhipu_example.py new file mode 100644 index 0000000000..00a9e5bcd2 --- /dev/null +++ b/examples/zhipu_example.py @@ -0,0 +1,33 @@ +"""Example of using Zhipu AI with Pydantic AI. + +This example demonstrates how to use Zhipu AI models with the Pydantic AI framework. + +To run this example, you need to: +1. Install pydantic-ai with openai support: pip install "pydantic-ai-slim[openai]" +2. Set your Zhipu API key: export ZHIPU_API_KEY='your-api-key' +3. Run the script: python examples/zhipu_example.py +""" + +from __future__ import annotations as _annotations + +import asyncio + +from pydantic_ai import Agent + + +async def main(): + """Run a simple example with Zhipu AI.""" + # Create an agent using Zhipu AI's GLM-4.5 model + model_spec = 'zhipu:glm-4.5' + agent = Agent(model_spec, system_prompt='You are a helpful assistant.') + + # Run a simple query + result = await agent.run('What is the capital of China?') + print(f'Response: {result.output}') + # Access the configured model name directly from the agent's model to avoid relying on + # message internals (keeps static typing happy if message schema varies by provider). + print(f'Model used: {model_spec}') + + +if __name__ == '__main__': + asyncio.run(main()) diff --git a/mkdocs.yml b/mkdocs.yml index b35189a8ab..89051b71d7 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -34,6 +34,7 @@ nav: - models/groq.md - models/mistral.md - models/huggingface.md + - models/zhipu.md - Tools & Toolsets: - tools.md - tools-advanced.md diff --git a/pydantic_ai_slim/pydantic_ai/models/__init__.py b/pydantic_ai_slim/pydantic_ai/models/__init__.py index 274ec0989b..187785fb58 100644 --- a/pydantic_ai_slim/pydantic_ai/models/__init__.py +++ b/pydantic_ai_slim/pydantic_ai/models/__init__.py @@ -290,6 +290,14 @@ 'openai:o3-pro-2025-06-10', 'openai:computer-use-preview', 'openai:computer-use-preview-2025-03-11', + 'zhipu:glm-4.6', + 'zhipu:glm-4.5', + 'zhipu:glm-4.5v', + 'zhipu:glm-4.5-air', + 'zhipu:glm-4.5-flash', + 'zhipu:glm-4v', + 'zhipu:glm-4v-plus', + 'zhipu:codegeex-4', 'test', ], ) @@ -691,6 +699,7 @@ def infer_model(model: Model | KnownModelName | str) -> Model: # noqa: C901 'together', 'vercel', 'litellm', + 'zhipu', ): from .openai import OpenAIChatModel diff --git a/pydantic_ai_slim/pydantic_ai/models/openai.py b/pydantic_ai_slim/pydantic_ai/models/openai.py index c3e1c71b49..e0d4662fb9 100644 --- a/pydantic_ai_slim/pydantic_ai/models/openai.py +++ b/pydantic_ai_slim/pydantic_ai/models/openai.py @@ -284,6 +284,7 @@ def __init__( 'together', 'vercel', 'litellm', + 'zhipu', ] | Provider[AsyncOpenAI] = 'openai', profile: ModelProfileSpec | None = None, @@ -312,6 +313,7 @@ def __init__( 'together', 'vercel', 'litellm', + 'zhipu', ] | Provider[AsyncOpenAI] = 'openai', profile: ModelProfileSpec | None = None, @@ -339,6 +341,7 @@ def __init__( 'together', 'vercel', 'litellm', + 'zhipu', ] | Provider[AsyncOpenAI] = 'openai', profile: ModelProfileSpec | None = None, @@ -523,6 +526,16 @@ def _process_response(self, response: chat.ChatCompletion | str) -> ModelRespons if not isinstance(response, chat.ChatCompletion): raise UnexpectedModelBehavior('Invalid response from OpenAI chat completions endpoint, expected JSON data') + # Some OpenAI-compatible providers (currently only Zhipu/Z.ai, see issue #2723) omit the `object` field even + # though the OpenAI schema includes it. We only patch it for that provider to avoid changing validation + # error counts in tests that purposefully feed invalid OpenAI responses (which expect 4 errors, including + # a missing `object`). + if self._provider.name == 'zhipu' and not getattr(response, 'object', None): # pragma: no branch + try: # defensive, in case attribute is read-only in future SDK versions + response.object = 'chat.completion' + except Exception: # pragma: no cover + pass + if response.created: timestamp = number_to_datetime(response.created) else: diff --git a/pydantic_ai_slim/pydantic_ai/profiles/zhipu.py b/pydantic_ai_slim/pydantic_ai/profiles/zhipu.py new file mode 100644 index 0000000000..5372b11019 --- /dev/null +++ b/pydantic_ai_slim/pydantic_ai/profiles/zhipu.py @@ -0,0 +1,41 @@ +from __future__ import annotations as _annotations + +from .openai import OpenAIModelProfile + + +def zhipu_model_profile(model_name: str) -> OpenAIModelProfile: + """Get the model profile for a Zhipu AI model. + + Zhipu AI provides OpenAI-compatible API, so we use OpenAIModelProfile. + + Args: + model_name: The Zhipu model name (e.g., 'glm-4.6', 'glm-4.5', 'glm-4.5-air', 'glm-4.5v'). + + Returns: + Model profile with Zhipu-specific configurations. + """ + # Vision models — docs show vision variants with a trailing `v` like `glm-4.5v` + # Ref: https://docs.bigmodel.cn/cn/guide/develop/openai/introduction + is_vision_model = model_name.startswith(('glm-4.5v', 'glm-4v')) or ( + 'v' in model_name and ('glm-4.5v' in model_name or 'glm-4v' in model_name) + ) + + # Zhipu AI models support JSON schema and object output + # All GLM-4 series models support function calling + supports_tools = model_name.startswith(('glm-4', 'codegeex-4')) + + # Zhipu AI doesn't support temperature=0 (must be in range (0, 1)) + # This is a known difference from OpenAI + openai_unsupported_model_settings = () + + return OpenAIModelProfile( + supports_json_schema_output=supports_tools, + supports_json_object_output=supports_tools, + supports_image_output=is_vision_model, + openai_supports_strict_tool_definition=False, # Zhipu doesn't support strict mode + openai_supports_tool_choice_required=True, + openai_unsupported_model_settings=openai_unsupported_model_settings, + openai_system_prompt_role=None, # Use default 'system' role + openai_chat_supports_web_search=False, + openai_supports_encrypted_reasoning_content=False, + ) diff --git a/pydantic_ai_slim/pydantic_ai/providers/__init__.py b/pydantic_ai_slim/pydantic_ai/providers/__init__.py index b84809ea06..ced62a570a 100644 --- a/pydantic_ai_slim/pydantic_ai/providers/__init__.py +++ b/pydantic_ai_slim/pydantic_ai/providers/__init__.py @@ -142,6 +142,10 @@ def infer_provider_class(provider: str) -> type[Provider[Any]]: # noqa: C901 from .litellm import LiteLLMProvider return LiteLLMProvider + elif provider == 'zhipu': + from .zhipu import ZhipuProvider + + return ZhipuProvider else: # pragma: no cover raise ValueError(f'Unknown provider: {provider}') diff --git a/pydantic_ai_slim/pydantic_ai/providers/zhipu.py b/pydantic_ai_slim/pydantic_ai/providers/zhipu.py new file mode 100644 index 0000000000..80929720af --- /dev/null +++ b/pydantic_ai_slim/pydantic_ai/providers/zhipu.py @@ -0,0 +1,105 @@ +from __future__ import annotations as _annotations + +import os +from typing import Literal, overload + +import httpx + +from pydantic_ai import ModelProfile +from pydantic_ai.exceptions import UserError +from pydantic_ai.models import cached_async_http_client +from pydantic_ai.profiles.zhipu import zhipu_model_profile +from pydantic_ai.providers import Provider + +ZhipuModelName = Literal[ + 'glm-4.5', + 'glm-4.5-air', + 'glm-4.5-flash', + 'glm-4.5v', + 'glm-4.6', + 'glm-4v', + 'glm-4v-plus', + 'codegeex-4', +] + +try: + from openai import AsyncOpenAI +except ImportError as _import_error: # pragma: no cover + raise ImportError( + 'Please install the `openai` package to use the Zhipu provider, ' + 'you can use the `openai` optional group — `pip install "pydantic-ai-slim[openai]"`' + ) from _import_error + + +class ZhipuProvider(Provider[AsyncOpenAI]): + """Provider for Zhipu AI API. + + Zhipu AI provides an OpenAI-compatible API, so this provider uses the OpenAI client + with Zhipu-specific configuration. + """ + + @property + def name(self) -> str: + return 'zhipu' + + @property + def base_url(self) -> str: + return str(self.client.base_url) + + @property + def client(self) -> AsyncOpenAI: + return self._client + + def model_profile(self, model_name: str) -> ModelProfile | None: + return zhipu_model_profile(model_name) + + @overload + def __init__(self, *, openai_client: AsyncOpenAI) -> None: ... + + @overload + def __init__( + self, + *, + api_key: str | None = None, + base_url: str | None = None, + http_client: httpx.AsyncClient | None = None, + ) -> None: ... + + def __init__( + self, + *, + api_key: str | None = None, + base_url: str | None = None, + openai_client: AsyncOpenAI | None = None, + http_client: httpx.AsyncClient | None = None, + ) -> None: + """Create a new Zhipu AI provider. + + Args: + api_key: The API key to use for authentication. If not provided, the `ZHIPU_API_KEY` environment variable + will be used if available. + base_url: The base url for the Zhipu AI requests. If not provided, defaults to Zhipu's base url. + openai_client: An existing `AsyncOpenAI` client to use. If provided, `api_key`, `base_url`, + and `http_client` must be `None`. + http_client: An existing `httpx.AsyncClient` to use for making HTTP requests. + """ + if openai_client is not None: + assert api_key is None, 'Cannot provide both `openai_client` and `api_key`' + assert base_url is None, 'Cannot provide both `openai_client` and `base_url`' + assert http_client is None, 'Cannot provide both `openai_client` and `http_client`' + self._client = openai_client + else: + api_key = api_key or os.getenv('ZHIPU_API_KEY') + base_url = base_url or 'https://open.bigmodel.cn/api/paas/v4/' + + if not api_key: + raise UserError( + 'Set the `ZHIPU_API_KEY` environment variable or pass it via `ZhipuProvider(api_key=...)`' + ' to use the Zhipu provider.' + ) + + if http_client is not None: + self._client = AsyncOpenAI(base_url=base_url, api_key=api_key, http_client=http_client) + else: + http_client = cached_async_http_client(provider='zhipu') + self._client = AsyncOpenAI(base_url=base_url, api_key=api_key, http_client=http_client) diff --git a/tests/models/test_model_names.py b/tests/models/test_model_names.py index b27aa2d8c2..66f71f1553 100644 --- a/tests/models/test_model_names.py +++ b/tests/models/test_model_names.py @@ -22,6 +22,7 @@ from pydantic_ai.models.openai import OpenAIModelName from pydantic_ai.providers.grok import GrokModelName from pydantic_ai.providers.moonshotai import MoonshotAIModelName + from pydantic_ai.providers.zhipu import ZhipuModelName pytestmark = [ pytest.mark.skipif(not imports_successful(), reason='some model package was not installed'), @@ -66,6 +67,7 @@ def get_model_names(model_name_type: Any) -> Iterator[str]: grok_names = [f'grok:{n}' for n in get_model_names(GrokModelName)] groq_names = [f'groq:{n}' for n in get_model_names(GroqModelName)] moonshotai_names = [f'moonshotai:{n}' for n in get_model_names(MoonshotAIModelName)] + zhipu_names = [f'zhipu:{n}' for n in get_model_names(ZhipuModelName)] mistral_names = [f'mistral:{n}' for n in get_model_names(MistralModelName)] openai_names = [f'openai:{n}' for n in get_model_names(OpenAIModelName)] bedrock_names = [f'bedrock:{n}' for n in get_model_names(BedrockModelName)] @@ -83,6 +85,7 @@ def get_model_names(model_name_type: Any) -> Iterator[str]: + groq_names + mistral_names + moonshotai_names + + zhipu_names + openai_names + bedrock_names + deepseek_names diff --git a/tests/providers/test_zhipu.py b/tests/providers/test_zhipu.py new file mode 100644 index 0000000000..ddc199314e --- /dev/null +++ b/tests/providers/test_zhipu.py @@ -0,0 +1,88 @@ +from __future__ import annotations as _annotations + +import pytest + +from pydantic_ai.exceptions import UserError +from pydantic_ai.profiles.openai import OpenAIModelProfile + +from ..conftest import try_import + +with try_import() as imports_successful: + from openai import AsyncOpenAI + + from pydantic_ai.providers.zhipu import ZhipuProvider + +pytestmark = [ + pytest.mark.skipif(not imports_successful(), reason='openai not installed'), +] + + +def test_init_with_api_key(): + """Test ZhipuProvider initialization with API key.""" + provider = ZhipuProvider(api_key='test-api-key') + assert provider.name == 'zhipu' + assert provider.base_url == 'https://open.bigmodel.cn/api/paas/v4/' + assert provider.client.api_key == 'test-api-key' + + +def test_init_with_custom_base_url(): + """Test ZhipuProvider initialization with custom base URL.""" + provider = ZhipuProvider(api_key='test-api-key', base_url='https://custom.url/') + assert provider.base_url == 'https://custom.url/' + + +def test_init_with_env_var(monkeypatch: pytest.MonkeyPatch): + """Test ZhipuProvider initialization with environment variable.""" + monkeypatch.setenv('ZHIPU_API_KEY', 'env-api-key') + provider = ZhipuProvider() + assert provider.client.api_key == 'env-api-key' + + +def test_init_without_api_key(monkeypatch: pytest.MonkeyPatch): + """Test ZhipuProvider initialization without API key raises error.""" + monkeypatch.delenv('ZHIPU_API_KEY', raising=False) + with pytest.raises(UserError, match='Set the `ZHIPU_API_KEY` environment variable'): + ZhipuProvider() + + +def test_init_with_openai_client(): + """Test ZhipuProvider initialization with existing OpenAI client.""" + client = AsyncOpenAI(api_key='test-key', base_url='https://open.bigmodel.cn/api/paas/v4/') + provider = ZhipuProvider(openai_client=client) + assert provider.client is client + assert provider.name == 'zhipu' + + +def test_init_with_openai_client_and_api_key_raises(): + """Test that providing both openai_client and api_key raises an error.""" + client = AsyncOpenAI(api_key='test-key', base_url='https://open.bigmodel.cn/api/paas/v4/') + with pytest.raises(AssertionError, match='Cannot provide both'): + ZhipuProvider(openai_client=client, api_key='another-key') # type: ignore[arg-type] + + +def test_model_profile_glm_4_5(): + """Test model profile for GLM-4.5.""" + provider = ZhipuProvider(api_key='test-key') + profile = provider.model_profile('glm-4.5') + assert profile is not None + assert isinstance(profile, OpenAIModelProfile) + assert profile.supports_json_schema_output is True + assert profile.supports_json_object_output is True + assert profile.supports_image_output is False + assert profile.openai_supports_strict_tool_definition is False + + +def test_model_profile_glm_4v(): + """Test model profile for GLM-4V (vision model).""" + provider = ZhipuProvider(api_key='test-key') + profile = provider.model_profile('glm-4v-plus') + assert profile is not None + assert profile.supports_image_output is True + + +def test_model_profile_codegeex(): + """Test model profile for CodeGeeX.""" + provider = ZhipuProvider(api_key='test-key') + profile = provider.model_profile('codegeex-4') + assert profile is not None + assert profile.supports_json_schema_output is True diff --git a/tests/test_cli.py b/tests/test_cli.py index e95ff09141..04b57b2372 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -148,6 +148,7 @@ def test_list_models(capfd: CaptureFixture[str]): 'moonshotai', 'grok', 'huggingface', + 'zhipu', ) models = {line.strip().split(' ')[0] for line in output[3:]} for provider in providers: