ai-parrot-client-zai 0.2.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- ai_parrot_client_zai-0.2.0/PKG-INFO +28 -0
- ai_parrot_client_zai-0.2.0/README.md +17 -0
- ai_parrot_client_zai-0.2.0/pyproject.toml +30 -0
- ai_parrot_client_zai-0.2.0/setup.cfg +4 -0
- ai_parrot_client_zai-0.2.0/src/ai_parrot_client_zai.egg-info/PKG-INFO +28 -0
- ai_parrot_client_zai-0.2.0/src/ai_parrot_client_zai.egg-info/SOURCES.txt +11 -0
- ai_parrot_client_zai-0.2.0/src/ai_parrot_client_zai.egg-info/dependency_links.txt +1 -0
- ai_parrot_client_zai-0.2.0/src/ai_parrot_client_zai.egg-info/entry_points.txt +3 -0
- ai_parrot_client_zai-0.2.0/src/ai_parrot_client_zai.egg-info/requires.txt +2 -0
- ai_parrot_client_zai-0.2.0/src/ai_parrot_client_zai.egg-info/top_level.txt +1 -0
- ai_parrot_client_zai-0.2.0/src/parrot/clients/zai/__init__.py +4 -0
- ai_parrot_client_zai-0.2.0/src/parrot/clients/zai/client.py +1090 -0
- ai_parrot_client_zai-0.2.0/src/parrot/clients/zai/models.py +56 -0
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: ai-parrot-client-zai
|
|
3
|
+
Version: 0.2.0
|
|
4
|
+
Summary: Z.ai (Zhipu) LLM client for AI-Parrot (parrot.clients.zai)
|
|
5
|
+
Author-email: Jesus Lara <jesuslara@phenobarbital.info>
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Requires-Python: >=3.11
|
|
8
|
+
Description-Content-Type: text/markdown
|
|
9
|
+
Requires-Dist: ai-parrot>=0.29.0
|
|
10
|
+
Requires-Dist: zai-sdk>=0.2.3
|
|
11
|
+
|
|
12
|
+
# ai-parrot-client-zai
|
|
13
|
+
|
|
14
|
+
Z.ai (Zhipu) LLM client satellite for
|
|
15
|
+
[AI-Parrot](https://github.com/phenobarbital/ai-parrot).
|
|
16
|
+
|
|
17
|
+
Provides `parrot.clients.zai.ZaiClient` (built on the OpenAI-compatible
|
|
18
|
+
`OpenAIBaseClient`, using the official `zai` SDK behind the scenes).
|
|
19
|
+
Registers itself with `LLMFactory` via the `parrot.clients` entry point
|
|
20
|
+
group — no import of this package is required for core AI-Parrot to know
|
|
21
|
+
it exists once installed.
|
|
22
|
+
|
|
23
|
+
```bash
|
|
24
|
+
uv pip install ai-parrot-client-zai
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
See `sdd/specs/pep-420-llm-clients.spec.md` (FEAT-523) for the extraction
|
|
28
|
+
this package was split from.
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
# ai-parrot-client-zai
|
|
2
|
+
|
|
3
|
+
Z.ai (Zhipu) LLM client satellite for
|
|
4
|
+
[AI-Parrot](https://github.com/phenobarbital/ai-parrot).
|
|
5
|
+
|
|
6
|
+
Provides `parrot.clients.zai.ZaiClient` (built on the OpenAI-compatible
|
|
7
|
+
`OpenAIBaseClient`, using the official `zai` SDK behind the scenes).
|
|
8
|
+
Registers itself with `LLMFactory` via the `parrot.clients` entry point
|
|
9
|
+
group — no import of this package is required for core AI-Parrot to know
|
|
10
|
+
it exists once installed.
|
|
11
|
+
|
|
12
|
+
```bash
|
|
13
|
+
uv pip install ai-parrot-client-zai
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
See `sdd/specs/pep-420-llm-clients.spec.md` (FEAT-523) for the extraction
|
|
17
|
+
this package was split from.
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=77.0.0", "wheel>=0.44.0"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "ai-parrot-client-zai"
|
|
7
|
+
version = "0.2.0"
|
|
8
|
+
description = "Z.ai (Zhipu) LLM client for AI-Parrot (parrot.clients.zai)"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.11"
|
|
11
|
+
license = "MIT"
|
|
12
|
+
authors = [
|
|
13
|
+
{name = "Jesus Lara", email = "jesuslara@phenobarbital.info"}
|
|
14
|
+
]
|
|
15
|
+
dependencies = [
|
|
16
|
+
"ai-parrot>=0.29.0",
|
|
17
|
+
"zai-sdk>=0.2.3",
|
|
18
|
+
]
|
|
19
|
+
|
|
20
|
+
[project.entry-points."parrot.clients"]
|
|
21
|
+
zai = "parrot.clients.zai:ZaiClient"
|
|
22
|
+
"z.ai" = "parrot.clients.zai:ZaiClient"
|
|
23
|
+
|
|
24
|
+
[tool.setuptools.packages.find]
|
|
25
|
+
where = ["src"]
|
|
26
|
+
include = ["parrot*"]
|
|
27
|
+
namespaces = true
|
|
28
|
+
|
|
29
|
+
[tool.uv.sources]
|
|
30
|
+
ai-parrot = { workspace = true }
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: ai-parrot-client-zai
|
|
3
|
+
Version: 0.2.0
|
|
4
|
+
Summary: Z.ai (Zhipu) LLM client for AI-Parrot (parrot.clients.zai)
|
|
5
|
+
Author-email: Jesus Lara <jesuslara@phenobarbital.info>
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Requires-Python: >=3.11
|
|
8
|
+
Description-Content-Type: text/markdown
|
|
9
|
+
Requires-Dist: ai-parrot>=0.29.0
|
|
10
|
+
Requires-Dist: zai-sdk>=0.2.3
|
|
11
|
+
|
|
12
|
+
# ai-parrot-client-zai
|
|
13
|
+
|
|
14
|
+
Z.ai (Zhipu) LLM client satellite for
|
|
15
|
+
[AI-Parrot](https://github.com/phenobarbital/ai-parrot).
|
|
16
|
+
|
|
17
|
+
Provides `parrot.clients.zai.ZaiClient` (built on the OpenAI-compatible
|
|
18
|
+
`OpenAIBaseClient`, using the official `zai` SDK behind the scenes).
|
|
19
|
+
Registers itself with `LLMFactory` via the `parrot.clients` entry point
|
|
20
|
+
group — no import of this package is required for core AI-Parrot to know
|
|
21
|
+
it exists once installed.
|
|
22
|
+
|
|
23
|
+
```bash
|
|
24
|
+
uv pip install ai-parrot-client-zai
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
See `sdd/specs/pep-420-llm-clients.spec.md` (FEAT-523) for the extraction
|
|
28
|
+
this package was split from.
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
README.md
|
|
2
|
+
pyproject.toml
|
|
3
|
+
src/ai_parrot_client_zai.egg-info/PKG-INFO
|
|
4
|
+
src/ai_parrot_client_zai.egg-info/SOURCES.txt
|
|
5
|
+
src/ai_parrot_client_zai.egg-info/dependency_links.txt
|
|
6
|
+
src/ai_parrot_client_zai.egg-info/entry_points.txt
|
|
7
|
+
src/ai_parrot_client_zai.egg-info/requires.txt
|
|
8
|
+
src/ai_parrot_client_zai.egg-info/top_level.txt
|
|
9
|
+
src/parrot/clients/zai/__init__.py
|
|
10
|
+
src/parrot/clients/zai/client.py
|
|
11
|
+
src/parrot/clients/zai/models.py
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
parrot
|
|
@@ -0,0 +1,1090 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import json
|
|
5
|
+
import time
|
|
6
|
+
import uuid
|
|
7
|
+
from dataclasses import is_dataclass
|
|
8
|
+
from enum import Enum
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from typing import Any, AsyncIterator, Dict, List, Optional, Union, Sequence
|
|
11
|
+
|
|
12
|
+
from datamodel.exceptions import ParserError # pylint: disable=E0611 # noqa
|
|
13
|
+
from datamodel.parsers.json import json_decoder # pylint: disable=E0611 # noqa
|
|
14
|
+
from navconfig import config
|
|
15
|
+
|
|
16
|
+
from ...models import AIMessage, CompletionUsage, OutputFormat, StructuredOutputConfig, ToolCall
|
|
17
|
+
from ...models.responses import InvokeResult
|
|
18
|
+
from .models import THINKING_CAPABLE_ZAI_MODELS, ZaiModel
|
|
19
|
+
from ...exceptions import InvokeError
|
|
20
|
+
from ...memory.render import HistoryMessage
|
|
21
|
+
|
|
22
|
+
# FEAT-524: ids are no longer ask() parameters; response metadata reads them
|
|
23
|
+
# from the per-call ContextVars BaseBot binds (FEAT-228).
|
|
24
|
+
from parrot.observability.context import current_session_id, current_user_id
|
|
25
|
+
from ..openai_base import OpenAIBaseClient
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class ZaiClient(OpenAIBaseClient):
|
|
29
|
+
"""Client for Z.ai chat completions using the official ``zai-sdk`` package.
|
|
30
|
+
|
|
31
|
+
FEAT-438 (TASK-2304): rebased onto ``OpenAIBaseClient``. The inherited
|
|
32
|
+
``tool_format = ToolFormat.OPENAI`` is CORRECT and left undeclared here
|
|
33
|
+
— Z.ai's API takes the same ``{"type":"function","function":{...}}``
|
|
34
|
+
wrapper — but it is a non-issue either way: ``ask()``/``ask_stream()``/
|
|
35
|
+
``resume()``/``invoke()`` build tool payloads via this module's own
|
|
36
|
+
``_prepare_zai_tools()`` (kept, never calls the inherited
|
|
37
|
+
``_prepare_tools()``), which never emits ``"strict"`` — so the base's
|
|
38
|
+
OPENAI-gated strict-tools branch (base.py:1435) never applies to any
|
|
39
|
+
real Z.ai request regardless of the declared ``tool_format``.
|
|
40
|
+
|
|
41
|
+
Unlike Groq's ``AsyncGroq``, the official ``zai`` SDK is **synchronous**
|
|
42
|
+
— every wire call wraps ``client.chat.completions.create`` in
|
|
43
|
+
``asyncio.to_thread()`` (see ``_chat_completion`` below, adapted from
|
|
44
|
+
the pre-rebase ``_create_completion``/``_stream_completion`` seams into
|
|
45
|
+
the shared funnel signature from TASK-2298).
|
|
46
|
+
"""
|
|
47
|
+
|
|
48
|
+
client_type: str = "zai"
|
|
49
|
+
client_name: str = "zai"
|
|
50
|
+
|
|
51
|
+
# FEAT-523 folder-convention attributes (read by LLMFactory).
|
|
52
|
+
provider_keys: tuple[str, ...] = ("zai", "z.ai")
|
|
53
|
+
models: type[Enum] = ZaiModel
|
|
54
|
+
model: str = ZaiModel.GLM_5_2.value
|
|
55
|
+
_default_model: str = ZaiModel.GLM_5_2.value
|
|
56
|
+
_lightweight_model: str = ZaiModel.GLM_4_5_FLASH_FREE.value
|
|
57
|
+
_min_cache_tokens: int = 0 # Z.ai does not support explicit prompt caching yet
|
|
58
|
+
|
|
59
|
+
def __init__(
|
|
60
|
+
self,
|
|
61
|
+
api_key: Optional[str] = None,
|
|
62
|
+
base_url: str = "https://api.z.ai/api/paas/v4/",
|
|
63
|
+
timeout: Optional[float] = None,
|
|
64
|
+
max_retries: Optional[int] = None,
|
|
65
|
+
**kwargs: Any,
|
|
66
|
+
) -> None:
|
|
67
|
+
resolved_key = api_key or config.get("ZAI_API_KEY")
|
|
68
|
+
if not resolved_key:
|
|
69
|
+
raise ValueError("ZAI_API_KEY is required. Pass api_key= or set the ZAI_API_KEY environment variable.")
|
|
70
|
+
resolved_base_url = base_url or config.get("ZAI_BASE_URL") or "https://api.z.ai/api/paas/v4/"
|
|
71
|
+
self.timeout = timeout
|
|
72
|
+
self.max_retries = max_retries
|
|
73
|
+
super().__init__(
|
|
74
|
+
api_key=resolved_key,
|
|
75
|
+
base_url=resolved_base_url,
|
|
76
|
+
**kwargs,
|
|
77
|
+
)
|
|
78
|
+
# Re-set after super().__init__ because AbstractClient may overwrite
|
|
79
|
+
# self.api_key during its own initialisation. This mirrors the
|
|
80
|
+
# guard used by NvidiaClient/OpenRouterClient/MoonshotClient/
|
|
81
|
+
# GroqClient.
|
|
82
|
+
self.api_key = resolved_key
|
|
83
|
+
|
|
84
|
+
async def get_client(self) -> Any:
|
|
85
|
+
"""Create the official Z.ai SDK client for the current event loop."""
|
|
86
|
+
from zai import ZaiClient as OfficialZaiClient
|
|
87
|
+
|
|
88
|
+
kwargs: Dict[str, Any] = {
|
|
89
|
+
"api_key": self.api_key,
|
|
90
|
+
"base_url": self.base_url,
|
|
91
|
+
}
|
|
92
|
+
if self.timeout is not None:
|
|
93
|
+
kwargs["timeout"] = self.timeout
|
|
94
|
+
if self.max_retries is not None:
|
|
95
|
+
kwargs["max_retries"] = self.max_retries
|
|
96
|
+
return OfficialZaiClient(**kwargs)
|
|
97
|
+
|
|
98
|
+
def _model_value(self, model: Union[str, ZaiModel, None]) -> str:
|
|
99
|
+
if isinstance(model, ZaiModel):
|
|
100
|
+
return model.value
|
|
101
|
+
return model or self.model or self._default_model
|
|
102
|
+
|
|
103
|
+
def _normalize_content(self, content: Any) -> Any:
|
|
104
|
+
if not isinstance(content, list):
|
|
105
|
+
return content
|
|
106
|
+
|
|
107
|
+
text_parts: List[str] = []
|
|
108
|
+
normalized: List[Dict[str, Any]] = []
|
|
109
|
+
for part in content:
|
|
110
|
+
if not isinstance(part, dict):
|
|
111
|
+
normalized.append(part)
|
|
112
|
+
continue
|
|
113
|
+
if part.get("type") == "text":
|
|
114
|
+
text = part.get("text", "")
|
|
115
|
+
if text:
|
|
116
|
+
text_parts.append(text)
|
|
117
|
+
continue
|
|
118
|
+
normalized.append(part)
|
|
119
|
+
|
|
120
|
+
if normalized:
|
|
121
|
+
return [
|
|
122
|
+
*({"type": "text", "text": text} for text in text_parts),
|
|
123
|
+
*normalized,
|
|
124
|
+
]
|
|
125
|
+
return "\n".join(text_parts)
|
|
126
|
+
|
|
127
|
+
def _normalize_messages(self, messages: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
|
|
128
|
+
normalized: List[Dict[str, Any]] = []
|
|
129
|
+
for message in messages:
|
|
130
|
+
msg = dict(message)
|
|
131
|
+
if "content" in msg:
|
|
132
|
+
msg["content"] = self._normalize_content(msg["content"])
|
|
133
|
+
normalized.append(msg)
|
|
134
|
+
return normalized
|
|
135
|
+
|
|
136
|
+
def _build_zai_messages(
|
|
137
|
+
self,
|
|
138
|
+
prompt: str,
|
|
139
|
+
files: Optional[List[Union[str, Path]]],
|
|
140
|
+
history: Optional[Sequence[HistoryMessage]],
|
|
141
|
+
system_prompt: Optional[Union[str, list]],
|
|
142
|
+
) -> tuple[List[Dict[str, Any]], Optional[str]]:
|
|
143
|
+
"""Compose Z.AI messages: system prompt + history + current turn.
|
|
144
|
+
|
|
145
|
+
Renamed from ``_build_messages`` in FEAT-524 — that name now belongs to
|
|
146
|
+
:meth:`AbstractClient._build_messages`, which this method delegates to
|
|
147
|
+
rather than shadows. Z.AI needs two extras on top of the base result:
|
|
148
|
+
the system prompt as a leading ``system`` message, and content
|
|
149
|
+
normalization.
|
|
150
|
+
|
|
151
|
+
Args:
|
|
152
|
+
prompt: The current user prompt.
|
|
153
|
+
files: Optional attachments for the current turn.
|
|
154
|
+
history: Already-rendered conversation history.
|
|
155
|
+
system_prompt: Raw system prompt to resolve and prepend.
|
|
156
|
+
|
|
157
|
+
Returns:
|
|
158
|
+
``(messages, resolved_system_prompt)``.
|
|
159
|
+
"""
|
|
160
|
+
resolved_system_prompt = self._resolve_system_prompt(system_prompt)
|
|
161
|
+
messages = self._build_messages(prompt, files, history)
|
|
162
|
+
if resolved_system_prompt:
|
|
163
|
+
messages.insert(
|
|
164
|
+
0,
|
|
165
|
+
{
|
|
166
|
+
"role": "system",
|
|
167
|
+
"content": resolved_system_prompt,
|
|
168
|
+
},
|
|
169
|
+
)
|
|
170
|
+
return self._normalize_messages(messages), resolved_system_prompt
|
|
171
|
+
|
|
172
|
+
def _prepare_zai_tools(self) -> List[Dict[str, Any]]:
|
|
173
|
+
tools: List[Dict[str, Any]] = []
|
|
174
|
+
for tool in self.tool_manager.all_tools():
|
|
175
|
+
tool_name = tool.name if hasattr(tool, "name") else tool.__class__.__name__
|
|
176
|
+
if hasattr(tool, "input_schema") and tool.input_schema:
|
|
177
|
+
parameters = tool.input_schema
|
|
178
|
+
elif hasattr(tool, "get_schema"):
|
|
179
|
+
schema = tool.get_schema()
|
|
180
|
+
parameters = schema.get("parameters", schema)
|
|
181
|
+
else:
|
|
182
|
+
parameters = {
|
|
183
|
+
"type": "object",
|
|
184
|
+
"properties": {},
|
|
185
|
+
"additionalProperties": False,
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
tools.append(
|
|
189
|
+
{
|
|
190
|
+
"type": "function",
|
|
191
|
+
"function": {
|
|
192
|
+
"name": tool_name,
|
|
193
|
+
"description": getattr(tool, "description", "") or "",
|
|
194
|
+
"parameters": parameters,
|
|
195
|
+
},
|
|
196
|
+
}
|
|
197
|
+
)
|
|
198
|
+
return tools
|
|
199
|
+
|
|
200
|
+
def _prepare_structured_output_format(self, output_type: Optional[type]) -> Dict[str, Any]:
|
|
201
|
+
if output_type is None:
|
|
202
|
+
return {"response_format": {"type": "json_object"}}
|
|
203
|
+
|
|
204
|
+
if hasattr(output_type, "model_json_schema"):
|
|
205
|
+
schema = output_type.model_json_schema()
|
|
206
|
+
elif hasattr(output_type, "schema"):
|
|
207
|
+
schema = output_type.schema()
|
|
208
|
+
elif is_dataclass(output_type):
|
|
209
|
+
schema = StructuredOutputConfig(output_type=output_type).get_schema()
|
|
210
|
+
else:
|
|
211
|
+
return {"response_format": {"type": "json_object"}}
|
|
212
|
+
|
|
213
|
+
name = getattr(output_type, "__name__", "structured_output")
|
|
214
|
+
return {
|
|
215
|
+
"response_format": {
|
|
216
|
+
"type": "json_schema",
|
|
217
|
+
"json_schema": {
|
|
218
|
+
"name": name.lower(),
|
|
219
|
+
"schema": self._oai_normalize_schema(schema, force_required_all=False),
|
|
220
|
+
},
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
def _thinking_payload(
|
|
225
|
+
self,
|
|
226
|
+
model: str,
|
|
227
|
+
thinking: Optional[Union[bool, str, Dict[str, Any]]],
|
|
228
|
+
deep_thinking: bool,
|
|
229
|
+
) -> Optional[Dict[str, Any]]:
|
|
230
|
+
if thinking is None and not deep_thinking:
|
|
231
|
+
return None
|
|
232
|
+
if model not in THINKING_CAPABLE_ZAI_MODELS:
|
|
233
|
+
self.logger.warning(
|
|
234
|
+
"Z.ai thinking requested for model %s, which is not in the known thinking-capable set.",
|
|
235
|
+
model,
|
|
236
|
+
)
|
|
237
|
+
if isinstance(thinking, dict):
|
|
238
|
+
return thinking
|
|
239
|
+
if isinstance(thinking, str):
|
|
240
|
+
return {"type": thinking}
|
|
241
|
+
enabled = bool(thinking) or deep_thinking
|
|
242
|
+
return {"type": "enabled" if enabled else "disabled"}
|
|
243
|
+
|
|
244
|
+
def _usage_from_response(self, response: Any) -> CompletionUsage:
|
|
245
|
+
usage = getattr(response, "usage", None)
|
|
246
|
+
prompt_details = getattr(usage, "prompt_tokens_details", None)
|
|
247
|
+
completion_details = getattr(usage, "completion_tokens_details", None)
|
|
248
|
+
extra_usage: Dict[str, Any] = {}
|
|
249
|
+
cached_tokens = getattr(prompt_details, "cached_tokens", None)
|
|
250
|
+
reasoning_tokens = getattr(completion_details, "reasoning_tokens", None)
|
|
251
|
+
if cached_tokens is not None:
|
|
252
|
+
extra_usage["cached_tokens"] = cached_tokens
|
|
253
|
+
if reasoning_tokens is not None:
|
|
254
|
+
extra_usage["reasoning_tokens"] = reasoning_tokens
|
|
255
|
+
return CompletionUsage(
|
|
256
|
+
prompt_tokens=getattr(usage, "prompt_tokens", 0),
|
|
257
|
+
completion_tokens=getattr(usage, "completion_tokens", 0),
|
|
258
|
+
total_tokens=getattr(usage, "total_tokens", 0),
|
|
259
|
+
extra_usage=extra_usage,
|
|
260
|
+
)
|
|
261
|
+
|
|
262
|
+
def _response_to_dict(self, response: Any) -> Dict[str, Any]:
|
|
263
|
+
if hasattr(response, "model_dump"):
|
|
264
|
+
return response.model_dump()
|
|
265
|
+
if hasattr(response, "dict"):
|
|
266
|
+
return response.dict()
|
|
267
|
+
if isinstance(response, dict):
|
|
268
|
+
return response
|
|
269
|
+
return getattr(response, "__dict__", {})
|
|
270
|
+
|
|
271
|
+
def _message_to_dict(self, message: Any) -> Dict[str, Any]:
|
|
272
|
+
if hasattr(message, "model_dump"):
|
|
273
|
+
return message.model_dump()
|
|
274
|
+
if hasattr(message, "dict"):
|
|
275
|
+
return message.dict()
|
|
276
|
+
if isinstance(message, dict):
|
|
277
|
+
return message
|
|
278
|
+
return getattr(message, "__dict__", {})
|
|
279
|
+
|
|
280
|
+
def _create_ai_message(
|
|
281
|
+
self,
|
|
282
|
+
*,
|
|
283
|
+
response: Any,
|
|
284
|
+
input_text: str,
|
|
285
|
+
model: str,
|
|
286
|
+
user_id: Optional[str],
|
|
287
|
+
session_id: Optional[str],
|
|
288
|
+
turn_id: str,
|
|
289
|
+
structured_output: Any = None,
|
|
290
|
+
tool_calls: Optional[List[ToolCall]] = None,
|
|
291
|
+
response_time: Optional[float] = None,
|
|
292
|
+
) -> AIMessage:
|
|
293
|
+
choice = response.choices[0]
|
|
294
|
+
message = choice.message
|
|
295
|
+
content = getattr(message, "content", None) or ""
|
|
296
|
+
reasoning_content = getattr(message, "reasoning_content", None)
|
|
297
|
+
usage = self._usage_from_response(response)
|
|
298
|
+
metadata: Dict[str, Any] = {}
|
|
299
|
+
if reasoning_content:
|
|
300
|
+
metadata["reasoning_content"] = reasoning_content
|
|
301
|
+
if usage.extra_usage.get("cached_tokens") is not None:
|
|
302
|
+
metadata["cached_tokens"] = usage.extra_usage["cached_tokens"]
|
|
303
|
+
|
|
304
|
+
return AIMessage(
|
|
305
|
+
input=input_text,
|
|
306
|
+
output=structured_output if structured_output is not None else content,
|
|
307
|
+
response=content,
|
|
308
|
+
is_structured=structured_output is not None,
|
|
309
|
+
structured_output=structured_output,
|
|
310
|
+
model=model,
|
|
311
|
+
provider="zai",
|
|
312
|
+
usage=usage,
|
|
313
|
+
stop_reason=getattr(choice, "finish_reason", None),
|
|
314
|
+
finish_reason=getattr(choice, "finish_reason", None),
|
|
315
|
+
tool_calls=tool_calls or [],
|
|
316
|
+
user_id=user_id,
|
|
317
|
+
session_id=session_id,
|
|
318
|
+
turn_id=turn_id,
|
|
319
|
+
response_time=response_time,
|
|
320
|
+
raw_response=self._response_to_dict(response),
|
|
321
|
+
metadata=metadata,
|
|
322
|
+
)
|
|
323
|
+
|
|
324
|
+
async def _create_completion(self, **request_args: Any) -> Any:
|
|
325
|
+
client = await self._ensure_client()
|
|
326
|
+
return await asyncio.to_thread(client.chat.completions.create, **request_args)
|
|
327
|
+
|
|
328
|
+
async def _chat_completion(
|
|
329
|
+
self, model: str, messages: Any, use_tools: bool = False, stream: bool = False, **kwargs: Any
|
|
330
|
+
) -> Any:
|
|
331
|
+
"""Zai-specific completion funnel (FEAT-438 TASK-2298/2304 seam).
|
|
332
|
+
|
|
333
|
+
The official ``zai`` SDK is synchronous — unlike ``AsyncOpenAI``/
|
|
334
|
+
``AsyncGroq``, there is no async-native call and no ``.parse()``
|
|
335
|
+
shortcut to gate on ``use_tools``. Delegates to the existing
|
|
336
|
+
thread-wrapped helpers unchanged:
|
|
337
|
+
|
|
338
|
+
- Non-streaming: awaits :meth:`_create_completion` directly.
|
|
339
|
+
- Streaming: returns the async-generator produced by
|
|
340
|
+
:meth:`_stream_completion` (itself a single
|
|
341
|
+
``asyncio.to_thread`` call collecting every sync-stream chunk)
|
|
342
|
+
for the caller to iterate — this method does not iterate it
|
|
343
|
+
itself, mirroring ``OpenAIBaseClient._chat_completion``'s
|
|
344
|
+
contract of returning an awaitable-then-iterable stream object.
|
|
345
|
+
|
|
346
|
+
Args:
|
|
347
|
+
model: The resolved model id.
|
|
348
|
+
messages: The chat-completions message list.
|
|
349
|
+
use_tools: Unused for dispatch (no ``.parse()`` alternative
|
|
350
|
+
exists); accepted for interface parity with the base
|
|
351
|
+
funnel seam.
|
|
352
|
+
stream: If ``True``, request a streaming response.
|
|
353
|
+
**kwargs: Additional Z.ai chat-completions request kwargs.
|
|
354
|
+
|
|
355
|
+
Returns:
|
|
356
|
+
The raw Z.ai SDK response object, or (when ``stream=True``)
|
|
357
|
+
an async-iterable of raw chunk objects.
|
|
358
|
+
"""
|
|
359
|
+
request_args: dict[str, Any] = {"model": model, "messages": messages, "stream": stream, **kwargs}
|
|
360
|
+
if stream:
|
|
361
|
+
return self._stream_completion(**request_args)
|
|
362
|
+
return await self._create_completion(**request_args)
|
|
363
|
+
|
|
364
|
+
def _parse_tool_arguments(self, raw_arguments: Any) -> Dict[str, Any]:
|
|
365
|
+
if isinstance(raw_arguments, dict):
|
|
366
|
+
return raw_arguments
|
|
367
|
+
if not raw_arguments:
|
|
368
|
+
return {}
|
|
369
|
+
try:
|
|
370
|
+
return json.loads(raw_arguments)
|
|
371
|
+
except json.JSONDecodeError:
|
|
372
|
+
try:
|
|
373
|
+
return json_decoder(raw_arguments)
|
|
374
|
+
except ParserError:
|
|
375
|
+
return {}
|
|
376
|
+
|
|
377
|
+
async def _run_tool_loop(
|
|
378
|
+
self,
|
|
379
|
+
*,
|
|
380
|
+
messages: List[Dict[str, Any]],
|
|
381
|
+
response: Any,
|
|
382
|
+
request_args: Dict[str, Any],
|
|
383
|
+
max_turns: int = 10,
|
|
384
|
+
) -> tuple[Any, List[ToolCall]]:
|
|
385
|
+
all_tool_calls: List[ToolCall] = []
|
|
386
|
+
result = response.choices[0].message
|
|
387
|
+
turns = 0
|
|
388
|
+
while getattr(result, "tool_calls", None) and turns < max_turns:
|
|
389
|
+
turns += 1
|
|
390
|
+
messages.append(self._message_to_dict(result))
|
|
391
|
+
for provider_tool_call in result.tool_calls:
|
|
392
|
+
function = provider_tool_call.function
|
|
393
|
+
tool_name = function.name
|
|
394
|
+
tool_args = self._parse_tool_arguments(function.arguments)
|
|
395
|
+
tool_call = ToolCall(
|
|
396
|
+
id=provider_tool_call.id,
|
|
397
|
+
name=tool_name,
|
|
398
|
+
arguments=tool_args,
|
|
399
|
+
)
|
|
400
|
+
try:
|
|
401
|
+
started = time.perf_counter()
|
|
402
|
+
tool_result = await self._execute_tool(tool_name, tool_args)
|
|
403
|
+
tool_call.execution_time = time.perf_counter() - started
|
|
404
|
+
tool_call.result = tool_result
|
|
405
|
+
content = json.dumps(tool_result, default=str)
|
|
406
|
+
except Exception as exc:
|
|
407
|
+
tool_call.error = str(exc)
|
|
408
|
+
content = f"Error: {exc}"
|
|
409
|
+
|
|
410
|
+
messages.append(
|
|
411
|
+
{
|
|
412
|
+
"role": "tool",
|
|
413
|
+
"tool_call_id": provider_tool_call.id,
|
|
414
|
+
"name": tool_name,
|
|
415
|
+
"content": content,
|
|
416
|
+
}
|
|
417
|
+
)
|
|
418
|
+
all_tool_calls.append(tool_call)
|
|
419
|
+
|
|
420
|
+
follow_up_args = dict(request_args)
|
|
421
|
+
follow_up_args["messages"] = messages
|
|
422
|
+
response = await self._chat_completion(**follow_up_args, use_tools=True)
|
|
423
|
+
result = response.choices[0].message
|
|
424
|
+
return response, all_tool_calls
|
|
425
|
+
|
|
426
|
+
async def ask(
|
|
427
|
+
self,
|
|
428
|
+
prompt: str,
|
|
429
|
+
model: Union[str, ZaiModel, None] = None,
|
|
430
|
+
max_tokens: Optional[int] = None,
|
|
431
|
+
temperature: float = 0.7,
|
|
432
|
+
top_p: float = 0.9,
|
|
433
|
+
files: Optional[List[Union[str, Path]]] = None,
|
|
434
|
+
system_prompt: Optional[Union[str, list]] = None,
|
|
435
|
+
history: Optional[Sequence[HistoryMessage]] = None,
|
|
436
|
+
structured_output: Union[type, StructuredOutputConfig, None] = None,
|
|
437
|
+
tools: Optional[List[Dict[str, Any]]] = None,
|
|
438
|
+
use_tools: Optional[bool] = None,
|
|
439
|
+
thinking: Optional[Union[bool, str, Dict[str, Any]]] = None,
|
|
440
|
+
deep_thinking: bool = False,
|
|
441
|
+
**_: Any,
|
|
442
|
+
) -> AIMessage:
|
|
443
|
+
"""Send a non-streaming chat request to Z.ai.
|
|
444
|
+
|
|
445
|
+
Args:
|
|
446
|
+
prompt: The user input text.
|
|
447
|
+
model: Z.ai model identifier; defaults to :attr:`_default_model`.
|
|
448
|
+
max_tokens: Maximum completion tokens.
|
|
449
|
+
temperature: Sampling temperature.
|
|
450
|
+
top_p: Top-p nucleus sampling parameter.
|
|
451
|
+
files: Optional file paths to include in the request.
|
|
452
|
+
system_prompt: Optional system prompt string or list of
|
|
453
|
+
CacheableSegments.
|
|
454
|
+
structured_output: Pydantic model or :class:`StructuredOutputConfig`
|
|
455
|
+
for JSON-schema-constrained responses.
|
|
456
|
+
user_id: Optional user identifier for conversation memory.
|
|
457
|
+
session_id: Optional session identifier for conversation memory.
|
|
458
|
+
tools: Additional tool definitions to register for this call.
|
|
459
|
+
use_tools: Override the instance-level ``enable_tools`` flag.
|
|
460
|
+
thinking: Enable chain-of-thought for thinking-capable models.
|
|
461
|
+
deep_thinking: Shorthand to enable thinking on capable models.
|
|
462
|
+
|
|
463
|
+
Returns:
|
|
464
|
+
:class:`AIMessage` with the final response and usage metadata.
|
|
465
|
+
|
|
466
|
+
Raises:
|
|
467
|
+
Exception: Propagates provider errors after emitting a
|
|
468
|
+
``ClientCallFailedEvent``.
|
|
469
|
+
"""
|
|
470
|
+
max_tokens = self._resolve_max_tokens(max_tokens)
|
|
471
|
+
resolved_model = self._model_value(model)
|
|
472
|
+
max_tokens = self._resolve_max_tokens(max_tokens, resolved_model, for_invoke=True)
|
|
473
|
+
turn_id = str(uuid.uuid4())
|
|
474
|
+
started = time.perf_counter()
|
|
475
|
+
messages, resolved_system_prompt = self._build_zai_messages(
|
|
476
|
+
prompt,
|
|
477
|
+
files,
|
|
478
|
+
history,
|
|
479
|
+
system_prompt,
|
|
480
|
+
)
|
|
481
|
+
|
|
482
|
+
_use_tools = use_tools if use_tools is not None else self.enable_tools
|
|
483
|
+
if tools:
|
|
484
|
+
for tool in tools:
|
|
485
|
+
self.register_tool(tool)
|
|
486
|
+
|
|
487
|
+
output_config = self._get_structured_config(structured_output)
|
|
488
|
+
request_args: Dict[str, Any] = {
|
|
489
|
+
"model": resolved_model,
|
|
490
|
+
"messages": messages,
|
|
491
|
+
"max_tokens": max_tokens,
|
|
492
|
+
"temperature": temperature,
|
|
493
|
+
"top_p": top_p,
|
|
494
|
+
"stream": False,
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
if thinking_payload := self._thinking_payload(resolved_model, thinking, deep_thinking):
|
|
498
|
+
request_args["thinking"] = thinking_payload
|
|
499
|
+
|
|
500
|
+
if _use_tools:
|
|
501
|
+
request_args["tools"] = self._prepare_zai_tools()
|
|
502
|
+
request_args["tool_choice"] = "auto"
|
|
503
|
+
elif output_config:
|
|
504
|
+
self._ensure_json_instruction(
|
|
505
|
+
messages,
|
|
506
|
+
"Please respond with a valid JSON object that matches the requested schema.",
|
|
507
|
+
)
|
|
508
|
+
request_args.update(
|
|
509
|
+
self._prepare_structured_output_format(output_config.output_type)
|
|
510
|
+
if output_config.format == OutputFormat.JSON
|
|
511
|
+
else {}
|
|
512
|
+
)
|
|
513
|
+
|
|
514
|
+
# FEAT-176/228: emit before-call lifecycle event
|
|
515
|
+
lc_tc = self._emit_before_call(
|
|
516
|
+
client_name="zai",
|
|
517
|
+
model=resolved_model,
|
|
518
|
+
temperature=temperature,
|
|
519
|
+
system_prompt=resolved_system_prompt,
|
|
520
|
+
has_tools=bool(_use_tools),
|
|
521
|
+
)
|
|
522
|
+
try:
|
|
523
|
+
response = await self._chat_completion(**request_args, use_tools=_use_tools)
|
|
524
|
+
all_tool_calls: List[ToolCall] = []
|
|
525
|
+
if _use_tools:
|
|
526
|
+
response, all_tool_calls = await self._run_tool_loop(
|
|
527
|
+
messages=messages,
|
|
528
|
+
response=response,
|
|
529
|
+
request_args=request_args,
|
|
530
|
+
)
|
|
531
|
+
|
|
532
|
+
content = getattr(response.choices[0].message, "content", None) or ""
|
|
533
|
+
parsed_output = None
|
|
534
|
+
if output_config:
|
|
535
|
+
parsed_output = await self._parse_structured_output(
|
|
536
|
+
content,
|
|
537
|
+
output_config,
|
|
538
|
+
finish_reason=self._extract_finish_reason(response),
|
|
539
|
+
model=resolved_model,
|
|
540
|
+
)
|
|
541
|
+
|
|
542
|
+
response_time = time.perf_counter() - started
|
|
543
|
+
ai_message = self._create_ai_message(
|
|
544
|
+
response=response,
|
|
545
|
+
input_text=prompt,
|
|
546
|
+
model=resolved_model,
|
|
547
|
+
user_id=current_user_id.get(),
|
|
548
|
+
session_id=current_session_id.get(),
|
|
549
|
+
turn_id=turn_id,
|
|
550
|
+
structured_output=parsed_output,
|
|
551
|
+
tool_calls=all_tool_calls,
|
|
552
|
+
response_time=response_time,
|
|
553
|
+
)
|
|
554
|
+
except Exception as exc:
|
|
555
|
+
await self._emit_failed_call(
|
|
556
|
+
lc_tc,
|
|
557
|
+
client_name="zai",
|
|
558
|
+
model=resolved_model,
|
|
559
|
+
duration_ms=(time.perf_counter() - started) * 1000,
|
|
560
|
+
exc=exc,
|
|
561
|
+
)
|
|
562
|
+
raise
|
|
563
|
+
|
|
564
|
+
await self._emit_after_call(
|
|
565
|
+
lc_tc,
|
|
566
|
+
client_name="zai",
|
|
567
|
+
model=resolved_model,
|
|
568
|
+
duration_ms=response_time * 1000,
|
|
569
|
+
input_tokens=ai_message.usage.prompt_tokens,
|
|
570
|
+
output_tokens=ai_message.usage.completion_tokens,
|
|
571
|
+
finish_reason=ai_message.stop_reason,
|
|
572
|
+
)
|
|
573
|
+
# FEAT-524: no memory write — AbstractBot.save_conversation_turn is the single writer.
|
|
574
|
+
return ai_message
|
|
575
|
+
|
|
576
|
+
def _next_stream_item(self, iterator: Any) -> tuple[bool, Any]:
|
|
577
|
+
try:
|
|
578
|
+
return True, next(iterator)
|
|
579
|
+
except StopIteration:
|
|
580
|
+
return False, None
|
|
581
|
+
|
|
582
|
+
def _accumulate_stream_tool_calls(
|
|
583
|
+
self,
|
|
584
|
+
accumulator: Dict[int, Dict[str, Any]],
|
|
585
|
+
tool_call_deltas: Optional[List[Any]],
|
|
586
|
+
) -> None:
|
|
587
|
+
if not tool_call_deltas:
|
|
588
|
+
return
|
|
589
|
+
for delta in tool_call_deltas:
|
|
590
|
+
index = getattr(delta, "index", 0)
|
|
591
|
+
current = accumulator.setdefault(
|
|
592
|
+
index,
|
|
593
|
+
{"id": "", "type": "function", "function": {"name": "", "arguments": ""}},
|
|
594
|
+
)
|
|
595
|
+
if getattr(delta, "id", None):
|
|
596
|
+
current["id"] = delta.id
|
|
597
|
+
if getattr(delta, "type", None):
|
|
598
|
+
current["type"] = delta.type
|
|
599
|
+
function = getattr(delta, "function", None)
|
|
600
|
+
if function is None:
|
|
601
|
+
continue
|
|
602
|
+
if getattr(function, "name", None):
|
|
603
|
+
current["function"]["name"] += function.name
|
|
604
|
+
if getattr(function, "arguments", None):
|
|
605
|
+
current["function"]["arguments"] += function.arguments
|
|
606
|
+
|
|
607
|
+
async def _stream_completion(self, **request_args: Any) -> AsyncIterator[Any]:
|
|
608
|
+
"""Collect all chunks from the synchronous Z.ai stream in a single thread.
|
|
609
|
+
|
|
610
|
+
The ``zai-sdk`` exposes only a synchronous streaming client. A single
|
|
611
|
+
:func:`asyncio.to_thread` call collects *all* chunks so the event loop
|
|
612
|
+
is blocked only once rather than once per token.
|
|
613
|
+
|
|
614
|
+
Args:
|
|
615
|
+
**request_args: Keyword arguments forwarded verbatim to
|
|
616
|
+
``client.chat.completions.create(stream=True, ...)``.
|
|
617
|
+
|
|
618
|
+
Yields:
|
|
619
|
+
Raw chunk objects from the Z.ai streaming response.
|
|
620
|
+
"""
|
|
621
|
+
client = await self._ensure_client()
|
|
622
|
+
|
|
623
|
+
def _collect_all_chunks() -> list:
|
|
624
|
+
return list(client.chat.completions.create(**request_args))
|
|
625
|
+
|
|
626
|
+
chunks = await asyncio.to_thread(_collect_all_chunks)
|
|
627
|
+
for chunk in chunks:
|
|
628
|
+
yield chunk
|
|
629
|
+
|
|
630
|
+
async def ask_stream(
|
|
631
|
+
self,
|
|
632
|
+
prompt: str,
|
|
633
|
+
model: Union[str, ZaiModel, None] = None,
|
|
634
|
+
max_tokens: Optional[int] = None,
|
|
635
|
+
temperature: float = 0.7,
|
|
636
|
+
top_p: float = 0.9,
|
|
637
|
+
files: Optional[List[Union[str, Path]]] = None,
|
|
638
|
+
system_prompt: Optional[Union[str, list]] = None,
|
|
639
|
+
history: Optional[Sequence[HistoryMessage]] = None,
|
|
640
|
+
tools: Optional[List[Dict[str, Any]]] = None,
|
|
641
|
+
use_tools: Optional[bool] = None,
|
|
642
|
+
thinking: Optional[Union[bool, str, Dict[str, Any]]] = None,
|
|
643
|
+
deep_thinking: bool = False,
|
|
644
|
+
stream_reasoning: bool = False,
|
|
645
|
+
**_: Any,
|
|
646
|
+
) -> AsyncIterator[Union[str, AIMessage]]:
|
|
647
|
+
"""Stream a Z.ai response, yielding text chunks followed by an
|
|
648
|
+
:class:`AIMessage` sentinel.
|
|
649
|
+
|
|
650
|
+
Args:
|
|
651
|
+
prompt: The user input text.
|
|
652
|
+
model: Z.ai model identifier; defaults to :attr:`_default_model`.
|
|
653
|
+
max_tokens: Maximum completion tokens.
|
|
654
|
+
temperature: Sampling temperature.
|
|
655
|
+
top_p: Top-p nucleus sampling parameter.
|
|
656
|
+
files: Optional file paths to include in the request.
|
|
657
|
+
system_prompt: Optional system prompt string or list of
|
|
658
|
+
CacheableSegments.
|
|
659
|
+
user_id: Optional user identifier for conversation memory.
|
|
660
|
+
session_id: Optional session identifier for conversation memory.
|
|
661
|
+
tools: Additional tool definitions to register for this call.
|
|
662
|
+
use_tools: Override the instance-level ``enable_tools`` flag.
|
|
663
|
+
thinking: Enable chain-of-thought for thinking-capable models.
|
|
664
|
+
deep_thinking: Shorthand to enable thinking on capable models.
|
|
665
|
+
stream_reasoning: When ``True``, yield reasoning-content chunks as
|
|
666
|
+
they arrive in addition to the final content.
|
|
667
|
+
|
|
668
|
+
Yields:
|
|
669
|
+
``str`` chunks of the response as they arrive, followed by a
|
|
670
|
+
single :class:`AIMessage` sentinel carrying full metadata.
|
|
671
|
+
|
|
672
|
+
Raises:
|
|
673
|
+
Exception: Propagates provider errors after emitting a
|
|
674
|
+
``ClientCallFailedEvent``.
|
|
675
|
+
"""
|
|
676
|
+
max_tokens = self._resolve_max_tokens(max_tokens)
|
|
677
|
+
resolved_model = self._model_value(model)
|
|
678
|
+
turn_id = str(uuid.uuid4())
|
|
679
|
+
started = time.perf_counter()
|
|
680
|
+
messages, resolved_system_prompt = self._build_zai_messages(
|
|
681
|
+
prompt,
|
|
682
|
+
files,
|
|
683
|
+
history,
|
|
684
|
+
system_prompt,
|
|
685
|
+
)
|
|
686
|
+
|
|
687
|
+
_use_tools = use_tools if use_tools is not None else self.enable_tools
|
|
688
|
+
if tools:
|
|
689
|
+
for tool in tools:
|
|
690
|
+
self.register_tool(tool)
|
|
691
|
+
|
|
692
|
+
request_args: Dict[str, Any] = {
|
|
693
|
+
"model": resolved_model,
|
|
694
|
+
"messages": messages,
|
|
695
|
+
"max_tokens": max_tokens,
|
|
696
|
+
"temperature": temperature,
|
|
697
|
+
"top_p": top_p,
|
|
698
|
+
"stream": True,
|
|
699
|
+
}
|
|
700
|
+
if thinking_payload := self._thinking_payload(resolved_model, thinking, deep_thinking):
|
|
701
|
+
request_args["thinking"] = thinking_payload
|
|
702
|
+
if _use_tools:
|
|
703
|
+
request_args["tools"] = self._prepare_zai_tools()
|
|
704
|
+
request_args["tool_choice"] = "auto"
|
|
705
|
+
request_args["tool_stream"] = True
|
|
706
|
+
|
|
707
|
+
# FEAT-176/228: emit before-call lifecycle event
|
|
708
|
+
lc_tc = self._emit_before_call(
|
|
709
|
+
client_name="zai",
|
|
710
|
+
model=resolved_model,
|
|
711
|
+
temperature=temperature,
|
|
712
|
+
system_prompt=resolved_system_prompt,
|
|
713
|
+
has_tools=bool(_use_tools),
|
|
714
|
+
)
|
|
715
|
+
|
|
716
|
+
content_parts: List[str] = []
|
|
717
|
+
reasoning_parts: List[str] = []
|
|
718
|
+
usage = CompletionUsage()
|
|
719
|
+
finish_reason: Optional[str] = None
|
|
720
|
+
last_raw_chunk: Dict[str, Any] = {}
|
|
721
|
+
tool_call_accumulator: Dict[int, Dict[str, Any]] = {}
|
|
722
|
+
|
|
723
|
+
try:
|
|
724
|
+
async for chunk in await self._chat_completion(**request_args, use_tools=_use_tools):
|
|
725
|
+
last_raw_chunk = self._response_to_dict(chunk)
|
|
726
|
+
if getattr(chunk, "usage", None):
|
|
727
|
+
usage = self._usage_from_response(chunk)
|
|
728
|
+
if not getattr(chunk, "choices", None):
|
|
729
|
+
continue
|
|
730
|
+
choice = chunk.choices[0]
|
|
731
|
+
finish_reason = getattr(choice, "finish_reason", None) or finish_reason
|
|
732
|
+
delta = getattr(choice, "delta", None)
|
|
733
|
+
if delta is None:
|
|
734
|
+
continue
|
|
735
|
+
reasoning = getattr(delta, "reasoning_content", None)
|
|
736
|
+
if reasoning:
|
|
737
|
+
reasoning_parts.append(reasoning)
|
|
738
|
+
if stream_reasoning:
|
|
739
|
+
yield reasoning
|
|
740
|
+
content = getattr(delta, "content", None)
|
|
741
|
+
if content:
|
|
742
|
+
content_parts.append(content)
|
|
743
|
+
yield content
|
|
744
|
+
self._accumulate_stream_tool_calls(
|
|
745
|
+
tool_call_accumulator,
|
|
746
|
+
getattr(delta, "tool_calls", None),
|
|
747
|
+
)
|
|
748
|
+
|
|
749
|
+
all_tool_calls: List[ToolCall] = []
|
|
750
|
+
if tool_call_accumulator:
|
|
751
|
+
assistant_tool_calls = [tool_call_accumulator[index] for index in sorted(tool_call_accumulator)]
|
|
752
|
+
messages.append(
|
|
753
|
+
{
|
|
754
|
+
"role": "assistant",
|
|
755
|
+
"content": "".join(content_parts),
|
|
756
|
+
"tool_calls": assistant_tool_calls,
|
|
757
|
+
}
|
|
758
|
+
)
|
|
759
|
+
for provider_tool_call in assistant_tool_calls:
|
|
760
|
+
function = provider_tool_call["function"]
|
|
761
|
+
tool_name = function["name"]
|
|
762
|
+
tool_args = self._parse_tool_arguments(function["arguments"])
|
|
763
|
+
tool_call = ToolCall(
|
|
764
|
+
id=provider_tool_call.get("id") or str(uuid.uuid4()),
|
|
765
|
+
name=tool_name,
|
|
766
|
+
arguments=tool_args,
|
|
767
|
+
)
|
|
768
|
+
try:
|
|
769
|
+
tool_started = time.perf_counter()
|
|
770
|
+
tool_result = await self._execute_tool(tool_name, tool_args)
|
|
771
|
+
tool_call.execution_time = time.perf_counter() - tool_started
|
|
772
|
+
tool_call.result = tool_result
|
|
773
|
+
tool_content = json.dumps(tool_result, default=str)
|
|
774
|
+
except Exception as exc:
|
|
775
|
+
tool_call.error = str(exc)
|
|
776
|
+
tool_content = f"Error: {exc}"
|
|
777
|
+
messages.append(
|
|
778
|
+
{
|
|
779
|
+
"role": "tool",
|
|
780
|
+
"tool_call_id": tool_call.id,
|
|
781
|
+
"name": tool_name,
|
|
782
|
+
"content": tool_content,
|
|
783
|
+
}
|
|
784
|
+
)
|
|
785
|
+
all_tool_calls.append(tool_call)
|
|
786
|
+
|
|
787
|
+
follow_up_args = dict(request_args)
|
|
788
|
+
follow_up_args["messages"] = messages
|
|
789
|
+
follow_up_args.pop("tool_stream", None)
|
|
790
|
+
async for chunk in await self._chat_completion(**follow_up_args, use_tools=True):
|
|
791
|
+
last_raw_chunk = self._response_to_dict(chunk)
|
|
792
|
+
if getattr(chunk, "usage", None):
|
|
793
|
+
usage = self._usage_from_response(chunk)
|
|
794
|
+
if not getattr(chunk, "choices", None):
|
|
795
|
+
continue
|
|
796
|
+
choice = chunk.choices[0]
|
|
797
|
+
finish_reason = getattr(choice, "finish_reason", None) or finish_reason
|
|
798
|
+
delta = getattr(choice, "delta", None)
|
|
799
|
+
if delta is None:
|
|
800
|
+
continue
|
|
801
|
+
content = getattr(delta, "content", None)
|
|
802
|
+
if content:
|
|
803
|
+
content_parts.append(content)
|
|
804
|
+
yield content
|
|
805
|
+
|
|
806
|
+
content_text = "".join(content_parts)
|
|
807
|
+
if not content_text:
|
|
808
|
+
yield ""
|
|
809
|
+
|
|
810
|
+
metadata: Dict[str, Any] = {}
|
|
811
|
+
reasoning_text = "".join(reasoning_parts)
|
|
812
|
+
if reasoning_text:
|
|
813
|
+
metadata["reasoning_content"] = reasoning_text
|
|
814
|
+
if usage.extra_usage.get("cached_tokens") is not None:
|
|
815
|
+
metadata["cached_tokens"] = usage.extra_usage["cached_tokens"]
|
|
816
|
+
|
|
817
|
+
response_time = time.perf_counter() - started
|
|
818
|
+
ai_message = AIMessage(
|
|
819
|
+
input=prompt,
|
|
820
|
+
output=content_text,
|
|
821
|
+
response=content_text,
|
|
822
|
+
model=resolved_model,
|
|
823
|
+
provider="zai",
|
|
824
|
+
usage=usage,
|
|
825
|
+
stop_reason=finish_reason,
|
|
826
|
+
finish_reason=finish_reason,
|
|
827
|
+
tool_calls=all_tool_calls,
|
|
828
|
+
user_id=current_user_id.get(),
|
|
829
|
+
session_id=current_session_id.get(),
|
|
830
|
+
turn_id=turn_id,
|
|
831
|
+
response_time=response_time,
|
|
832
|
+
raw_response=last_raw_chunk,
|
|
833
|
+
metadata=metadata,
|
|
834
|
+
)
|
|
835
|
+
except Exception as exc:
|
|
836
|
+
await self._emit_failed_call(
|
|
837
|
+
lc_tc,
|
|
838
|
+
client_name="zai",
|
|
839
|
+
model=resolved_model,
|
|
840
|
+
duration_ms=(time.perf_counter() - started) * 1000,
|
|
841
|
+
exc=exc,
|
|
842
|
+
)
|
|
843
|
+
raise
|
|
844
|
+
|
|
845
|
+
await self._emit_after_call(
|
|
846
|
+
lc_tc,
|
|
847
|
+
client_name="zai",
|
|
848
|
+
model=resolved_model,
|
|
849
|
+
duration_ms=response_time * 1000,
|
|
850
|
+
input_tokens=usage.prompt_tokens,
|
|
851
|
+
output_tokens=usage.completion_tokens,
|
|
852
|
+
finish_reason=finish_reason,
|
|
853
|
+
)
|
|
854
|
+
# FEAT-524: no memory write — AbstractBot.save_conversation_turn is the single writer.
|
|
855
|
+
yield ai_message
|
|
856
|
+
|
|
857
|
+
async def resume(
|
|
858
|
+
self,
|
|
859
|
+
session_id: str,
|
|
860
|
+
user_input: str,
|
|
861
|
+
state: Dict[str, Any],
|
|
862
|
+
) -> AIMessage:
|
|
863
|
+
"""Resume a suspended ZaiClient execution after a HandoffTool / HITL pause.
|
|
864
|
+
|
|
865
|
+
Injects *user_input* into the suspended message history (as a ``tool``
|
|
866
|
+
role message when ``state["tool_call_id"]`` is present, otherwise as a
|
|
867
|
+
``user`` message) and continues the tool-call loop until a final
|
|
868
|
+
response is produced.
|
|
869
|
+
|
|
870
|
+
Args:
|
|
871
|
+
session_id: Session identifier propagated to any
|
|
872
|
+
:class:`~parrot.core.exceptions.HumanInteractionInterrupt`
|
|
873
|
+
raised inside the loop.
|
|
874
|
+
user_input: User reply to inject as the resumption value.
|
|
875
|
+
state: Suspended execution state. Expected keys:
|
|
876
|
+
|
|
877
|
+
- ``messages`` (``list``): OpenAI-style message dicts.
|
|
878
|
+
- ``tool_call_id`` (``str``, optional): ID of the paused tool
|
|
879
|
+
call. When present *user_input* is injected as a ``tool``
|
|
880
|
+
result; otherwise it is injected as a ``user`` turn.
|
|
881
|
+
- ``model`` / ``agent_name`` (``str``, optional): Model
|
|
882
|
+
override.
|
|
883
|
+
- ``user_id`` (``str``, optional): Propagated to the returned
|
|
884
|
+
:class:`AIMessage`.
|
|
885
|
+
|
|
886
|
+
Returns:
|
|
887
|
+
:class:`AIMessage` with the final assistant response and all tool
|
|
888
|
+
calls executed during resumption.
|
|
889
|
+
|
|
890
|
+
Raises:
|
|
891
|
+
:class:`~parrot.core.exceptions.HumanInteractionInterrupt`:
|
|
892
|
+
Re-raised with updated session context when a tool triggers
|
|
893
|
+
another human-interaction pause.
|
|
894
|
+
"""
|
|
895
|
+
messages: List[Dict[str, Any]] = list(state.get("messages", []))
|
|
896
|
+
tool_call_id: Optional[str] = state.get("tool_call_id")
|
|
897
|
+
resolved_model = self._model_value(state.get("model") or state.get("agent_name"))
|
|
898
|
+
turn_id = str(uuid.uuid4())
|
|
899
|
+
|
|
900
|
+
# Inject the resumption value as a tool result or a new user turn.
|
|
901
|
+
if tool_call_id:
|
|
902
|
+
messages.append(
|
|
903
|
+
{
|
|
904
|
+
"role": "tool",
|
|
905
|
+
"tool_call_id": tool_call_id,
|
|
906
|
+
"name": "handoff_tool",
|
|
907
|
+
"content": user_input,
|
|
908
|
+
}
|
|
909
|
+
)
|
|
910
|
+
else:
|
|
911
|
+
messages.append({"role": "user", "content": user_input})
|
|
912
|
+
|
|
913
|
+
request_args: Dict[str, Any] = {
|
|
914
|
+
"model": resolved_model,
|
|
915
|
+
"messages": messages,
|
|
916
|
+
"max_tokens": 4096,
|
|
917
|
+
"temperature": 0.1,
|
|
918
|
+
"stream": False,
|
|
919
|
+
}
|
|
920
|
+
if self.enable_tools:
|
|
921
|
+
request_args["tools"] = self._prepare_zai_tools()
|
|
922
|
+
request_args["tool_choice"] = "auto"
|
|
923
|
+
|
|
924
|
+
response = await self._chat_completion(**request_args, use_tools=self.enable_tools)
|
|
925
|
+
all_tool_calls: List[ToolCall] = []
|
|
926
|
+
result = response.choices[0].message
|
|
927
|
+
max_turns = 10
|
|
928
|
+
turns = 0
|
|
929
|
+
|
|
930
|
+
while getattr(result, "tool_calls", None) and turns < max_turns:
|
|
931
|
+
turns += 1
|
|
932
|
+
messages.append(self._message_to_dict(result))
|
|
933
|
+
for provider_tc in result.tool_calls:
|
|
934
|
+
fn = provider_tc.function
|
|
935
|
+
tool_name = fn.name
|
|
936
|
+
tool_args = self._parse_tool_arguments(fn.arguments)
|
|
937
|
+
tc = ToolCall(
|
|
938
|
+
id=provider_tc.id,
|
|
939
|
+
name=tool_name,
|
|
940
|
+
arguments=tool_args,
|
|
941
|
+
)
|
|
942
|
+
try:
|
|
943
|
+
started = time.perf_counter()
|
|
944
|
+
tool_result = await self._execute_tool(tool_name, tool_args)
|
|
945
|
+
tc.execution_time = time.perf_counter() - started
|
|
946
|
+
tc.result = tool_result
|
|
947
|
+
content = json.dumps(tool_result, default=str)
|
|
948
|
+
except Exception as exc:
|
|
949
|
+
from parrot.core.exceptions import HumanInteractionInterrupt
|
|
950
|
+
|
|
951
|
+
if isinstance(exc, HumanInteractionInterrupt):
|
|
952
|
+
exc.session_id = session_id
|
|
953
|
+
exc.messages = messages.copy()
|
|
954
|
+
exc.tool_call_id = provider_tc.id
|
|
955
|
+
exc.agent_name = resolved_model
|
|
956
|
+
raise
|
|
957
|
+
tc.error = str(exc)
|
|
958
|
+
content = f"Error: {exc}"
|
|
959
|
+
messages.append(
|
|
960
|
+
{
|
|
961
|
+
"role": "tool",
|
|
962
|
+
"tool_call_id": provider_tc.id,
|
|
963
|
+
"name": tool_name,
|
|
964
|
+
"content": content,
|
|
965
|
+
}
|
|
966
|
+
)
|
|
967
|
+
all_tool_calls.append(tc)
|
|
968
|
+
|
|
969
|
+
follow_up = dict(request_args)
|
|
970
|
+
follow_up["messages"] = messages
|
|
971
|
+
response = await self._chat_completion(**follow_up, use_tools=True)
|
|
972
|
+
result = response.choices[0].message
|
|
973
|
+
|
|
974
|
+
return self._create_ai_message(
|
|
975
|
+
response=response,
|
|
976
|
+
input_text="[Resumed Conversation]",
|
|
977
|
+
model=resolved_model,
|
|
978
|
+
user_id=state.get("user_id"),
|
|
979
|
+
session_id=session_id,
|
|
980
|
+
turn_id=turn_id,
|
|
981
|
+
tool_calls=all_tool_calls,
|
|
982
|
+
)
|
|
983
|
+
|
|
984
|
+
async def invoke(
|
|
985
|
+
self,
|
|
986
|
+
prompt: str,
|
|
987
|
+
*,
|
|
988
|
+
output_type: Optional[type] = None,
|
|
989
|
+
structured_output: Optional[StructuredOutputConfig] = None,
|
|
990
|
+
model: Optional[str] = None,
|
|
991
|
+
system_prompt: Optional[str] = None,
|
|
992
|
+
max_tokens: Optional[int] = None,
|
|
993
|
+
temperature: float = 0.0,
|
|
994
|
+
use_tools: bool = False,
|
|
995
|
+
tools: Optional[list] = None,
|
|
996
|
+
) -> InvokeResult:
|
|
997
|
+
"""Lightweight stateless invocation for ZaiClient.
|
|
998
|
+
|
|
999
|
+
Makes a single ``chat.completions.create`` call without conversation
|
|
1000
|
+
history, retries, or the full prompt-builder overhead. Uses Z.ai's
|
|
1001
|
+
native ``json_schema`` response format for structured output.
|
|
1002
|
+
|
|
1003
|
+
Args:
|
|
1004
|
+
prompt: User prompt.
|
|
1005
|
+
output_type: Pydantic model or dataclass to parse the response
|
|
1006
|
+
into. Mutually exclusive with *structured_output* (the latter
|
|
1007
|
+
wins).
|
|
1008
|
+
structured_output: Full :class:`StructuredOutputConfig`. Takes
|
|
1009
|
+
precedence over *output_type*.
|
|
1010
|
+
model: Model override. Falls back to an explicitly selected
|
|
1011
|
+
:attr:`model`, then :attr:`_lightweight_model`.
|
|
1012
|
+
system_prompt: System prompt override. Falls back to the default
|
|
1013
|
+
:attr:`BASIC_SYSTEM_PROMPT` template.
|
|
1014
|
+
max_tokens: Maximum completion tokens (default ``4096``).
|
|
1015
|
+
temperature: Sampling temperature (default ``0.0`` for
|
|
1016
|
+
deterministic structured extraction).
|
|
1017
|
+
use_tools: If ``True``, inject registered tools into the request.
|
|
1018
|
+
tools: Additional tool definitions to register for this call.
|
|
1019
|
+
|
|
1020
|
+
Returns:
|
|
1021
|
+
:class:`InvokeResult` with ``output``, ``model``, ``usage``, and
|
|
1022
|
+
``raw_response``.
|
|
1023
|
+
|
|
1024
|
+
Raises:
|
|
1025
|
+
:class:`~parrot.exceptions.InvokeError`: On any provider error.
|
|
1026
|
+
"""
|
|
1027
|
+
try:
|
|
1028
|
+
resolved_system = self._resolve_invoke_system_prompt(system_prompt)
|
|
1029
|
+
config = self._build_invoke_structured_config(output_type, structured_output)
|
|
1030
|
+
resolved_model = self._resolve_invoke_model(model)
|
|
1031
|
+
max_tokens = self._resolve_max_tokens(max_tokens, resolved_model, for_invoke=True)
|
|
1032
|
+
|
|
1033
|
+
if tools:
|
|
1034
|
+
for tool_def in tools:
|
|
1035
|
+
self.register_tool(tool_def)
|
|
1036
|
+
|
|
1037
|
+
messages: List[Dict[str, Any]] = [
|
|
1038
|
+
{"role": "system", "content": resolved_system},
|
|
1039
|
+
{"role": "user", "content": prompt},
|
|
1040
|
+
]
|
|
1041
|
+
|
|
1042
|
+
kwargs: Dict[str, Any] = {
|
|
1043
|
+
"model": resolved_model,
|
|
1044
|
+
"messages": messages,
|
|
1045
|
+
"max_tokens": max_tokens,
|
|
1046
|
+
"temperature": temperature,
|
|
1047
|
+
"stream": False,
|
|
1048
|
+
}
|
|
1049
|
+
|
|
1050
|
+
if config:
|
|
1051
|
+
kwargs.update(
|
|
1052
|
+
self._prepare_structured_output_format(config.output_type)
|
|
1053
|
+
if config.format == OutputFormat.JSON
|
|
1054
|
+
else {}
|
|
1055
|
+
)
|
|
1056
|
+
|
|
1057
|
+
if use_tools:
|
|
1058
|
+
tool_defs = self._prepare_zai_tools()
|
|
1059
|
+
if tool_defs:
|
|
1060
|
+
kwargs["tools"] = tool_defs
|
|
1061
|
+
kwargs["tool_choice"] = "auto"
|
|
1062
|
+
|
|
1063
|
+
response = await self._chat_completion(**kwargs, use_tools=use_tools)
|
|
1064
|
+
raw_text = getattr(response.choices[0].message, "content", None) or ""
|
|
1065
|
+
|
|
1066
|
+
output: Any = raw_text
|
|
1067
|
+
if config:
|
|
1068
|
+
# Known-truncated output must not reach a custom parser either.
|
|
1069
|
+
self._raise_if_truncated(self._extract_finish_reason(response), model=resolved_model)
|
|
1070
|
+
if config.custom_parser:
|
|
1071
|
+
output = config.custom_parser(raw_text)
|
|
1072
|
+
else:
|
|
1073
|
+
output = await self._parse_structured_output(
|
|
1074
|
+
raw_text,
|
|
1075
|
+
config,
|
|
1076
|
+
finish_reason=self._extract_finish_reason(response),
|
|
1077
|
+
model=resolved_model,
|
|
1078
|
+
)
|
|
1079
|
+
|
|
1080
|
+
usage = self._usage_from_response(response)
|
|
1081
|
+
return self._build_invoke_result(output, output_type, resolved_model, usage, response)
|
|
1082
|
+
|
|
1083
|
+
except InvokeError:
|
|
1084
|
+
raise
|
|
1085
|
+
except Exception as exc:
|
|
1086
|
+
raise self._handle_invoke_error(exc) from exc
|
|
1087
|
+
|
|
1088
|
+
async def embed(self, *args: Any, **kwargs: Any) -> Any:
|
|
1089
|
+
"""Embeddings are not implemented by this chat client yet."""
|
|
1090
|
+
raise NotImplementedError("ZaiClient embed() is not implemented.")
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
from enum import Enum
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class ZaiModel(str, Enum):
|
|
5
|
+
"""Z.ai GLM chat model identifiers.
|
|
6
|
+
|
|
7
|
+
The ``*_FREE`` variants use Z.ai's documented ``:free`` model suffix for
|
|
8
|
+
free-tier text/vision models.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
GLM_5_2 = "glm-5.2"
|
|
12
|
+
GLM_5_1 = "glm-5.1"
|
|
13
|
+
GLM_5 = "glm-5"
|
|
14
|
+
GLM_5_TURBO = "glm-5-turbo"
|
|
15
|
+
GLM_5V_TURBO = "glm-5v-turbo"
|
|
16
|
+
GLM_4_7 = "glm-4.7"
|
|
17
|
+
GLM_4_7_FLASHX = "glm-4.7-flashx"
|
|
18
|
+
GLM_4_6 = "glm-4.6"
|
|
19
|
+
GLM_4_6V = "glm-4.6v"
|
|
20
|
+
GLM_4_6V_FLASHX = "glm-4.6v-flashx"
|
|
21
|
+
GLM_4_6V_FLASH = "glm-4.6v-flash"
|
|
22
|
+
GLM_4_5 = "glm-4.5"
|
|
23
|
+
GLM_4_5_X = "glm-4.5-x"
|
|
24
|
+
GLM_4_5_AIR = "glm-4.5-air"
|
|
25
|
+
GLM_4_5_AIRX = "glm-4.5-airx"
|
|
26
|
+
GLM_4_5_FLASH = "glm-4.5-flash"
|
|
27
|
+
GLM_4_5V = "glm-4.5v"
|
|
28
|
+
GLM_4_32B_0414_128K = "glm-4-32b-0414-128k"
|
|
29
|
+
|
|
30
|
+
GLM_4_7_FLASH_FREE = "glm-4.7-flash:free"
|
|
31
|
+
GLM_4_5_FLASH_FREE = "glm-4.5-flash:free"
|
|
32
|
+
GLM_4_6V_FLASH_FREE = "glm-4.6v-flash:free"
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
THINKING_CAPABLE_ZAI_MODELS = frozenset(
|
|
36
|
+
{
|
|
37
|
+
ZaiModel.GLM_5_2.value,
|
|
38
|
+
ZaiModel.GLM_5_1.value,
|
|
39
|
+
ZaiModel.GLM_5.value,
|
|
40
|
+
ZaiModel.GLM_5_TURBO.value,
|
|
41
|
+
ZaiModel.GLM_5V_TURBO.value,
|
|
42
|
+
ZaiModel.GLM_4_7.value,
|
|
43
|
+
ZaiModel.GLM_4_6.value,
|
|
44
|
+
ZaiModel.GLM_4_6V.value,
|
|
45
|
+
ZaiModel.GLM_4_6V_FLASHX.value,
|
|
46
|
+
ZaiModel.GLM_4_6V_FLASH.value,
|
|
47
|
+
ZaiModel.GLM_4_5.value,
|
|
48
|
+
ZaiModel.GLM_4_5_X.value,
|
|
49
|
+
ZaiModel.GLM_4_5_AIR.value,
|
|
50
|
+
ZaiModel.GLM_4_5_AIRX.value,
|
|
51
|
+
ZaiModel.GLM_4_5_FLASH.value,
|
|
52
|
+
ZaiModel.GLM_4_5V.value,
|
|
53
|
+
ZaiModel.GLM_4_5_FLASH_FREE.value,
|
|
54
|
+
ZaiModel.GLM_4_6V_FLASH_FREE.value,
|
|
55
|
+
}
|
|
56
|
+
)
|