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.
@@ -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,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -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,3 @@
1
+ [parrot.clients]
2
+ z.ai = parrot.clients.zai:ZaiClient
3
+ zai = parrot.clients.zai:ZaiClient
@@ -0,0 +1,2 @@
1
+ ai-parrot>=0.29.0
2
+ zai-sdk>=0.2.3
@@ -0,0 +1,4 @@
1
+ from .client import ZaiClient
2
+ from .models import ZaiModel
3
+
4
+ __all__ = ["ZaiClient", "ZaiModel"]
@@ -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
+ )