agenthub-python 0.4.5__tar.gz → 0.4.7__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.
- {agenthub_python-0.4.5 → agenthub_python-0.4.7}/PKG-INFO +1 -1
- {agenthub_python-0.4.5 → agenthub_python-0.4.7}/agenthub/base_client.py +20 -0
- agenthub_python-0.4.7/agenthub/deepseek_v4/client.py +371 -0
- {agenthub_python-0.4.5 → agenthub_python-0.4.7}/agenthub/gemini3_7/client.py +60 -27
- {agenthub_python-0.4.5 → agenthub_python-0.4.7}/agenthub/gpt5_6/client.py +10 -0
- {agenthub_python-0.4.5 → agenthub_python-0.4.7}/agenthub/integration/playground.py +192 -25
- {agenthub_python-0.4.5 → agenthub_python-0.4.7}/agenthub/minimax_m3/client.py +6 -0
- {agenthub_python-0.4.5 → agenthub_python-0.4.7}/agenthub/openai_responses/client.py +12 -0
- {agenthub_python-0.4.5 → agenthub_python-0.4.7}/agenthub/registry.py +13 -2
- {agenthub_python-0.4.5 → agenthub_python-0.4.7}/agenthub/types.py +3 -0
- {agenthub_python-0.4.5 → agenthub_python-0.4.7}/pyproject.toml +1 -1
- {agenthub_python-0.4.5 → agenthub_python-0.4.7}/pyproject.toml.orig +1 -1
- agenthub_python-0.4.5/agenthub/deepseek_v4/client.py +0 -391
- {agenthub_python-0.4.5 → agenthub_python-0.4.7}/README.md +0 -0
- {agenthub_python-0.4.5 → agenthub_python-0.4.7}/agenthub/__init__.py +0 -0
- {agenthub_python-0.4.5 → agenthub_python-0.4.7}/agenthub/abort_signal.py +0 -0
- {agenthub_python-0.4.5 → agenthub_python-0.4.7}/agenthub/ant_messages/__init__.py +0 -0
- {agenthub_python-0.4.5 → agenthub_python-0.4.7}/agenthub/ant_messages/client.py +0 -0
- {agenthub_python-0.4.5 → agenthub_python-0.4.7}/agenthub/auto_client.py +0 -0
- {agenthub_python-0.4.5 → agenthub_python-0.4.7}/agenthub/claude5/__init__.py +0 -0
- {agenthub_python-0.4.5 → agenthub_python-0.4.7}/agenthub/claude5/client.py +0 -0
- {agenthub_python-0.4.5 → agenthub_python-0.4.7}/agenthub/deepseek_v4/__init__.py +0 -0
- {agenthub_python-0.4.5 → agenthub_python-0.4.7}/agenthub/errors.py +0 -0
- {agenthub_python-0.4.5 → agenthub_python-0.4.7}/agenthub/gemini3_7/__init__.py +0 -0
- {agenthub_python-0.4.5 → agenthub_python-0.4.7}/agenthub/glm5_3/__init__.py +0 -0
- {agenthub_python-0.4.5 → agenthub_python-0.4.7}/agenthub/glm5_3/client.py +0 -0
- {agenthub_python-0.4.5 → agenthub_python-0.4.7}/agenthub/gpt5_6/__init__.py +0 -0
- {agenthub_python-0.4.5 → agenthub_python-0.4.7}/agenthub/integration/__init__.py +0 -0
- {agenthub_python-0.4.5 → agenthub_python-0.4.7}/agenthub/integration/tracer.py +0 -0
- {agenthub_python-0.4.5 → agenthub_python-0.4.7}/agenthub/kimi_k3/__init__.py +0 -0
- {agenthub_python-0.4.5 → agenthub_python-0.4.7}/agenthub/kimi_k3/client.py +0 -0
- {agenthub_python-0.4.5 → agenthub_python-0.4.7}/agenthub/minimax_m3/__init__.py +0 -0
- {agenthub_python-0.4.5 → agenthub_python-0.4.7}/agenthub/openai_chat/__init__.py +0 -0
- {agenthub_python-0.4.5 → agenthub_python-0.4.7}/agenthub/openai_chat/client.py +0 -0
- {agenthub_python-0.4.5 → agenthub_python-0.4.7}/agenthub/openai_embedding/__init__.py +0 -0
- {agenthub_python-0.4.5 → agenthub_python-0.4.7}/agenthub/openai_embedding/client.py +0 -0
- {agenthub_python-0.4.5 → agenthub_python-0.4.7}/agenthub/openai_responses/__init__.py +0 -0
- {agenthub_python-0.4.5 → agenthub_python-0.4.7}/agenthub/utils.py +0 -0
|
@@ -28,6 +28,7 @@ from .types import (
|
|
|
28
28
|
UniMessage,
|
|
29
29
|
UsageMetadata,
|
|
30
30
|
)
|
|
31
|
+
from .utils import is_debug_enabled
|
|
31
32
|
|
|
32
33
|
|
|
33
34
|
class LLMClient(ABC):
|
|
@@ -139,6 +140,17 @@ class LLMClient(ABC):
|
|
|
139
140
|
elif item["type"] == "partial_tool_call":
|
|
140
141
|
# Skip partial_tool_call items - they should already be converted to tool_call
|
|
141
142
|
pass
|
|
143
|
+
elif item["type"] == "inline_data" and (item.get("mime_type") or "").startswith("audio/"):
|
|
144
|
+
# a spoken response streams as many small audio chunks; the message keeps the
|
|
145
|
+
# whole utterance as one playable item
|
|
146
|
+
if (
|
|
147
|
+
content_items
|
|
148
|
+
and content_items[-1]["type"] == "inline_data"
|
|
149
|
+
and content_items[-1].get("mime_type") == item["mime_type"]
|
|
150
|
+
):
|
|
151
|
+
content_items[-1]["data"] += item["data"]
|
|
152
|
+
else:
|
|
153
|
+
content_items.append(item.copy())
|
|
142
154
|
else:
|
|
143
155
|
content_items.append(item.copy())
|
|
144
156
|
|
|
@@ -254,6 +266,14 @@ class LLMClient(ABC):
|
|
|
254
266
|
finally:
|
|
255
267
|
waiting_for_stream = False
|
|
256
268
|
|
|
269
|
+
if event["event_type"] == "unused":
|
|
270
|
+
# a client marks a wire event it has nothing to emit for as "unused"; that is
|
|
271
|
+
# its own bookkeeping and must not reach a caller
|
|
272
|
+
if is_debug_enabled():
|
|
273
|
+
raise ValueError(f"{self.__class__.__name__} yielded an internal unused event: {event}")
|
|
274
|
+
|
|
275
|
+
continue
|
|
276
|
+
|
|
257
277
|
event["created_at"] = int(time.time() * 1000)
|
|
258
278
|
last_event = event
|
|
259
279
|
events.append(event)
|
|
@@ -0,0 +1,371 @@
|
|
|
1
|
+
# Copyright 2025 Prism Shadow. and/or its affiliates
|
|
2
|
+
#
|
|
3
|
+
# Licensed under the Apache License, Version 2.0 (the "License");
|
|
4
|
+
# you may not use this file except in compliance with the License.
|
|
5
|
+
# You may obtain a copy of the License at
|
|
6
|
+
#
|
|
7
|
+
# http://www.apache.org/licenses/LICENSE-2.0
|
|
8
|
+
#
|
|
9
|
+
# Unless required by applicable law or agreed to in writing, software
|
|
10
|
+
# distributed under the License is distributed on an "AS IS" BASIS,
|
|
11
|
+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
12
|
+
# See the License for the specific language governing permissions and
|
|
13
|
+
# limitations under the License.
|
|
14
|
+
|
|
15
|
+
import json
|
|
16
|
+
import os
|
|
17
|
+
from typing import Any, AsyncIterator
|
|
18
|
+
|
|
19
|
+
from openai import AsyncOpenAI
|
|
20
|
+
from openai.types.responses import ResponseInputParam, ResponseStreamEvent
|
|
21
|
+
|
|
22
|
+
from ..base_client import LLMClient
|
|
23
|
+
from ..errors import UnsupportedParameterError, parse_tool_call_arguments
|
|
24
|
+
from ..types import (
|
|
25
|
+
EventType,
|
|
26
|
+
FinishReason,
|
|
27
|
+
PartialContentItem,
|
|
28
|
+
PromptCaching,
|
|
29
|
+
ThinkingLevel,
|
|
30
|
+
ToolChoice,
|
|
31
|
+
UniConfig,
|
|
32
|
+
UniEvent,
|
|
33
|
+
UniMessage,
|
|
34
|
+
UsageMetadata,
|
|
35
|
+
)
|
|
36
|
+
from ..utils import is_debug_enabled
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class DeepSeekV4Client(LLMClient):
|
|
40
|
+
"""DeepSeek V4-specific LLM client implementation using the OpenAI-compatible Responses API."""
|
|
41
|
+
|
|
42
|
+
def __init__(
|
|
43
|
+
self,
|
|
44
|
+
model: str,
|
|
45
|
+
api_key: str | None = None,
|
|
46
|
+
base_url: str | None = None,
|
|
47
|
+
default_headers: dict[str, str] | None = None,
|
|
48
|
+
):
|
|
49
|
+
"""Initialize DeepSeek client with model, API key, and base URL."""
|
|
50
|
+
self._model = model
|
|
51
|
+
api_key = api_key or os.getenv("DEEPSEEK_API_KEY")
|
|
52
|
+
base_url = base_url or os.getenv("DEEPSEEK_BASE_URL") or "https://api.deepseek.com"
|
|
53
|
+
self._client = AsyncOpenAI(api_key=api_key, base_url=base_url, default_headers=default_headers)
|
|
54
|
+
self._history: list[UniMessage] = []
|
|
55
|
+
|
|
56
|
+
def _convert_thinking_level_to_effort(self, thinking_level: ThinkingLevel) -> str:
|
|
57
|
+
"""Convert ThinkingLevel enum to DeepSeek's reasoning effort.
|
|
58
|
+
|
|
59
|
+
DeepSeek accepts low/high/max and maps medium and xhigh onto high server-side
|
|
60
|
+
(llmsdk_docs/deepseek_v4/docs/thinking-mode.md), so this sends the value the
|
|
61
|
+
server would settle on anyway. Effort "none" is what turns thinking off on this
|
|
62
|
+
endpoint: the Chat Completions `thinking` toggle is ignored here (verified live
|
|
63
|
+
2026-08-21).
|
|
64
|
+
"""
|
|
65
|
+
mapping = {
|
|
66
|
+
ThinkingLevel.NONE: "none",
|
|
67
|
+
ThinkingLevel.LOW: "low",
|
|
68
|
+
ThinkingLevel.MEDIUM: "high",
|
|
69
|
+
ThinkingLevel.HIGH: "high",
|
|
70
|
+
ThinkingLevel.XHIGH: "high",
|
|
71
|
+
ThinkingLevel.MAX: "max",
|
|
72
|
+
}
|
|
73
|
+
return mapping[thinking_level]
|
|
74
|
+
|
|
75
|
+
def _convert_tool_choice(self, tool_choice: ToolChoice) -> str:
|
|
76
|
+
"""Convert ToolChoice to DeepSeek's Responses-compatible tool_choice format."""
|
|
77
|
+
if tool_choice in ["auto", "none"]:
|
|
78
|
+
return tool_choice
|
|
79
|
+
raise UnsupportedParameterError(
|
|
80
|
+
self.__class__.__name__, "tool_choice", "DeepSeek V4 only supports 'auto' and 'none' for tool_choice."
|
|
81
|
+
)
|
|
82
|
+
|
|
83
|
+
def transform_uni_config_to_model_config(self, config: UniConfig) -> dict[str, Any]:
|
|
84
|
+
"""
|
|
85
|
+
Transform universal configuration to DeepSeek-specific configuration.
|
|
86
|
+
|
|
87
|
+
Args:
|
|
88
|
+
config: Universal configuration dict
|
|
89
|
+
|
|
90
|
+
Returns:
|
|
91
|
+
DeepSeek configuration dictionary
|
|
92
|
+
"""
|
|
93
|
+
deepseek_config = {"model": self._model, "store": False}
|
|
94
|
+
|
|
95
|
+
if config.get("system_prompt") is not None:
|
|
96
|
+
deepseek_config["instructions"] = config["system_prompt"]
|
|
97
|
+
|
|
98
|
+
if config.get("max_tokens") is not None:
|
|
99
|
+
deepseek_config["max_output_tokens"] = config["max_tokens"]
|
|
100
|
+
|
|
101
|
+
if config.get("temperature") is not None and config["temperature"] != 1.0:
|
|
102
|
+
raise UnsupportedParameterError(
|
|
103
|
+
self.__class__.__name__, "temperature", "DeepSeek V4 does not support setting temperature."
|
|
104
|
+
)
|
|
105
|
+
|
|
106
|
+
# a thinking summary is accepted but never generated, so the parameter is left out
|
|
107
|
+
if config.get("thinking_level") is not None:
|
|
108
|
+
deepseek_config["reasoning"] = {"effort": self._convert_thinking_level_to_effort(config["thinking_level"])}
|
|
109
|
+
|
|
110
|
+
if config.get("tools") is not None:
|
|
111
|
+
deepseek_config["tools"] = [{"type": "function", **tool} for tool in config["tools"]]
|
|
112
|
+
|
|
113
|
+
if config.get("tool_choice") is not None:
|
|
114
|
+
deepseek_config["tool_choice"] = self._convert_tool_choice(config["tool_choice"])
|
|
115
|
+
|
|
116
|
+
if config.get("fast_mode"):
|
|
117
|
+
raise UnsupportedParameterError(
|
|
118
|
+
self.__class__.__name__, "fast_mode", "DeepSeek V4 does not support fast mode."
|
|
119
|
+
)
|
|
120
|
+
|
|
121
|
+
if config.get("prompt_caching") is not None and config["prompt_caching"] != PromptCaching.ENABLE:
|
|
122
|
+
raise UnsupportedParameterError(
|
|
123
|
+
self.__class__.__name__, "prompt_caching", "prompt_caching must be ENABLE for DeepSeek."
|
|
124
|
+
)
|
|
125
|
+
|
|
126
|
+
return deepseek_config
|
|
127
|
+
|
|
128
|
+
def transform_uni_message_to_model_input(self, messages: list[UniMessage]) -> ResponseInputParam:
|
|
129
|
+
"""
|
|
130
|
+
Transform universal message format to DeepSeek's Responses-compatible input format.
|
|
131
|
+
|
|
132
|
+
Args:
|
|
133
|
+
messages: List of universal message dictionaries
|
|
134
|
+
|
|
135
|
+
Returns:
|
|
136
|
+
List of input items for the Responses API
|
|
137
|
+
"""
|
|
138
|
+
# only a vision model reads image parts; every other DeepSeek model answers from a
|
|
139
|
+
# placeholder instead of failing (llmsdk_docs/deepseek_v4/docs/responses-api.md), so an
|
|
140
|
+
# image is refused here rather than silently dropped
|
|
141
|
+
supports_image = "vision" in self._model.lower()
|
|
142
|
+
input_list: list[ResponseInputParam] = []
|
|
143
|
+
|
|
144
|
+
for msg in messages:
|
|
145
|
+
content_items: list = []
|
|
146
|
+
|
|
147
|
+
for item in msg["content_items"]:
|
|
148
|
+
# anything that is not message content becomes an input item of its own, so the
|
|
149
|
+
# text collected so far is flushed first to keep the original order: DeepSeek
|
|
150
|
+
# merges a function call into the adjacent assistant message and answers a call
|
|
151
|
+
# whose output does not follow it with "No tool output found for tool call"
|
|
152
|
+
# (verified live 2026-08-21)
|
|
153
|
+
if item["type"] not in ("text", "image_url") and content_items:
|
|
154
|
+
input_list.append({"role": msg["role"], "content": content_items})
|
|
155
|
+
content_items = []
|
|
156
|
+
|
|
157
|
+
if item["type"] == "text":
|
|
158
|
+
if msg["role"] == "user":
|
|
159
|
+
content_items.append({"type": "input_text", "text": item["text"]})
|
|
160
|
+
else:
|
|
161
|
+
content_items.append({"type": "output_text", "text": item["text"]})
|
|
162
|
+
elif item["type"] == "image_url":
|
|
163
|
+
if not supports_image:
|
|
164
|
+
raise ValueError(f"DeepSeek {self._model} does not support image inputs.")
|
|
165
|
+
|
|
166
|
+
content_items.append({"type": "input_image", "image_url": item["image_url"]})
|
|
167
|
+
elif item["type"] == "thinking":
|
|
168
|
+
# DeepSeek carries the chain of thought as plain reasoning_text and ignores the
|
|
169
|
+
# summary and encrypted_content channels, so the item is rebuilt from the text
|
|
170
|
+
reasoning = {"type": "reasoning", "summary": []}
|
|
171
|
+
if item["thinking"]:
|
|
172
|
+
reasoning["content"] = [{"type": "reasoning_text", "text": item["thinking"]}]
|
|
173
|
+
|
|
174
|
+
input_list.append(reasoning)
|
|
175
|
+
elif item["type"] == "tool_call":
|
|
176
|
+
input_list.append(
|
|
177
|
+
{
|
|
178
|
+
"type": "function_call",
|
|
179
|
+
"call_id": item["tool_call_id"],
|
|
180
|
+
"name": item["name"],
|
|
181
|
+
"arguments": json.dumps(item["arguments"], ensure_ascii=False),
|
|
182
|
+
}
|
|
183
|
+
)
|
|
184
|
+
elif item["type"] == "tool_result":
|
|
185
|
+
if "tool_call_id" not in item:
|
|
186
|
+
raise ValueError("tool_call_id is required for tool result.")
|
|
187
|
+
|
|
188
|
+
# NOTE: tool results are input items
|
|
189
|
+
tool_result = [{"type": "input_text", "text": item["text"]}]
|
|
190
|
+
if "images" in item:
|
|
191
|
+
if not supports_image:
|
|
192
|
+
raise ValueError(f"DeepSeek {self._model} does not support images in tool results.")
|
|
193
|
+
|
|
194
|
+
for image_url in item["images"]:
|
|
195
|
+
tool_result.append({"type": "input_image", "image_url": image_url})
|
|
196
|
+
|
|
197
|
+
input_list.append(
|
|
198
|
+
{"type": "function_call_output", "call_id": item["tool_call_id"], "output": tool_result}
|
|
199
|
+
)
|
|
200
|
+
else:
|
|
201
|
+
raise ValueError(f"Unknown item: {item}")
|
|
202
|
+
|
|
203
|
+
if content_items:
|
|
204
|
+
input_list.append({"role": msg["role"], "content": content_items})
|
|
205
|
+
|
|
206
|
+
return input_list
|
|
207
|
+
|
|
208
|
+
def transform_model_output_to_uni_event(self, model_output: ResponseStreamEvent) -> UniEvent:
|
|
209
|
+
"""
|
|
210
|
+
Transform DeepSeek streaming event to universal event format.
|
|
211
|
+
|
|
212
|
+
Args:
|
|
213
|
+
model_output: Responses API streaming event
|
|
214
|
+
|
|
215
|
+
Returns:
|
|
216
|
+
Universal event dictionary
|
|
217
|
+
"""
|
|
218
|
+
event_type: EventType | None = None
|
|
219
|
+
content_items: list[PartialContentItem] = []
|
|
220
|
+
usage_metadata: UsageMetadata | None = None
|
|
221
|
+
finish_reason: FinishReason | None = None
|
|
222
|
+
|
|
223
|
+
deepseek_event_type = model_output.type
|
|
224
|
+
if deepseek_event_type == "response.output_text.delta":
|
|
225
|
+
event_type = "delta"
|
|
226
|
+
content_items.append({"type": "text", "text": model_output.delta})
|
|
227
|
+
|
|
228
|
+
elif deepseek_event_type == "response.reasoning_text.delta":
|
|
229
|
+
event_type = "delta"
|
|
230
|
+
content_items.append({"type": "thinking", "thinking": model_output.delta})
|
|
231
|
+
|
|
232
|
+
elif deepseek_event_type == "response.output_item.added":
|
|
233
|
+
if model_output.item.type == "function_call":
|
|
234
|
+
event_type = "start"
|
|
235
|
+
content_items.append(
|
|
236
|
+
{
|
|
237
|
+
"type": "partial_tool_call",
|
|
238
|
+
"name": model_output.item.name,
|
|
239
|
+
"arguments": "",
|
|
240
|
+
"tool_call_id": model_output.item.call_id,
|
|
241
|
+
}
|
|
242
|
+
)
|
|
243
|
+
else:
|
|
244
|
+
event_type = "unused"
|
|
245
|
+
|
|
246
|
+
elif deepseek_event_type == "response.function_call_arguments.delta":
|
|
247
|
+
event_type = "delta"
|
|
248
|
+
content_items.append(
|
|
249
|
+
{"type": "partial_tool_call", "name": "", "arguments": model_output.delta, "tool_call_id": ""}
|
|
250
|
+
)
|
|
251
|
+
|
|
252
|
+
elif deepseek_event_type == "response.function_call_arguments.done":
|
|
253
|
+
event_type = "stop"
|
|
254
|
+
|
|
255
|
+
elif deepseek_event_type in ("response.completed", "response.incomplete"):
|
|
256
|
+
event_type = "stop"
|
|
257
|
+
finish_reason_mapping = {
|
|
258
|
+
"completed": "stop",
|
|
259
|
+
"incomplete": "length",
|
|
260
|
+
}
|
|
261
|
+
finish_reason = finish_reason_mapping.get(model_output.response.status, "unknown")
|
|
262
|
+
|
|
263
|
+
if model_output.response.usage:
|
|
264
|
+
input_details = model_output.response.usage.input_tokens_details
|
|
265
|
+
output_details = model_output.response.usage.output_tokens_details
|
|
266
|
+
cached_tokens = input_details.cached_tokens if input_details else 0
|
|
267
|
+
reasoning_tokens = output_details.reasoning_tokens if output_details else 0
|
|
268
|
+
usage_metadata = {
|
|
269
|
+
"cached_tokens": cached_tokens,
|
|
270
|
+
"prompt_tokens": model_output.response.usage.input_tokens - cached_tokens,
|
|
271
|
+
"thoughts_tokens": reasoning_tokens,
|
|
272
|
+
"response_tokens": model_output.response.usage.output_tokens - reasoning_tokens,
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
elif deepseek_event_type in (
|
|
276
|
+
"response.created",
|
|
277
|
+
"response.in_progress",
|
|
278
|
+
"response.output_item.done",
|
|
279
|
+
"response.output_text.done",
|
|
280
|
+
"response.reasoning_text.done",
|
|
281
|
+
"response.content_part.added",
|
|
282
|
+
"response.content_part.done",
|
|
283
|
+
"keepalive", # gateway heartbeat on long generations; carries no content
|
|
284
|
+
):
|
|
285
|
+
event_type = "unused"
|
|
286
|
+
|
|
287
|
+
elif is_debug_enabled():
|
|
288
|
+
raise ValueError(f"Unknown output: {model_output}")
|
|
289
|
+
|
|
290
|
+
else:
|
|
291
|
+
# a gateway injects its own events (heartbeats, cost tickers) into the stream, and
|
|
292
|
+
# killing a long generation over one costs more than dropping it
|
|
293
|
+
event_type = "unused"
|
|
294
|
+
|
|
295
|
+
return {
|
|
296
|
+
"role": "assistant",
|
|
297
|
+
"event_type": event_type,
|
|
298
|
+
"content_items": content_items,
|
|
299
|
+
"usage_metadata": usage_metadata,
|
|
300
|
+
"finish_reason": finish_reason,
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
async def _streaming_response_internal(
|
|
304
|
+
self,
|
|
305
|
+
messages: list[UniMessage],
|
|
306
|
+
config: UniConfig,
|
|
307
|
+
) -> AsyncIterator[UniEvent]:
|
|
308
|
+
"""Stream generate using DeepSeek's OpenAI-compatible Responses API."""
|
|
309
|
+
# Use unified config conversion
|
|
310
|
+
deepseek_config = self.transform_uni_config_to_model_config(config)
|
|
311
|
+
|
|
312
|
+
# Use unified message conversion
|
|
313
|
+
input_list = self.transform_uni_message_to_model_input(messages)
|
|
314
|
+
|
|
315
|
+
# Stream generate
|
|
316
|
+
partial_tool_call = {}
|
|
317
|
+
stream = await self._client.responses.create(**deepseek_config, input=input_list, stream=True)
|
|
318
|
+
async for event in stream:
|
|
319
|
+
event = self.transform_model_output_to_uni_event(event)
|
|
320
|
+
if event["event_type"] == "start":
|
|
321
|
+
for item in event["content_items"]:
|
|
322
|
+
if item["type"] == "partial_tool_call":
|
|
323
|
+
# initialize partial_tool_call
|
|
324
|
+
partial_tool_call = {
|
|
325
|
+
"name": item["name"],
|
|
326
|
+
"arguments": "",
|
|
327
|
+
"tool_call_id": item["tool_call_id"],
|
|
328
|
+
}
|
|
329
|
+
yield event
|
|
330
|
+
elif event["event_type"] == "delta":
|
|
331
|
+
for item in event["content_items"]:
|
|
332
|
+
if item["type"] == "partial_tool_call":
|
|
333
|
+
# update partial_tool_call
|
|
334
|
+
partial_tool_call["arguments"] += item["arguments"]
|
|
335
|
+
|
|
336
|
+
yield event
|
|
337
|
+
elif event["event_type"] == "stop":
|
|
338
|
+
if "name" in partial_tool_call and "arguments" in partial_tool_call:
|
|
339
|
+
# finish partial_tool_call
|
|
340
|
+
yield {
|
|
341
|
+
"role": "assistant",
|
|
342
|
+
"event_type": "delta",
|
|
343
|
+
"content_items": [
|
|
344
|
+
{
|
|
345
|
+
"type": "tool_call",
|
|
346
|
+
"name": partial_tool_call["name"],
|
|
347
|
+
"arguments": parse_tool_call_arguments(
|
|
348
|
+
partial_tool_call["arguments"],
|
|
349
|
+
self.__class__.__name__,
|
|
350
|
+
partial_tool_call["name"],
|
|
351
|
+
partial_tool_call["tool_call_id"],
|
|
352
|
+
),
|
|
353
|
+
"tool_call_id": partial_tool_call["tool_call_id"],
|
|
354
|
+
}
|
|
355
|
+
],
|
|
356
|
+
"usage_metadata": None,
|
|
357
|
+
"finish_reason": None,
|
|
358
|
+
}
|
|
359
|
+
partial_tool_call = {}
|
|
360
|
+
|
|
361
|
+
if event["finish_reason"] or event["usage_metadata"]:
|
|
362
|
+
yield event
|
|
363
|
+
|
|
364
|
+
async def list_models(self) -> list[str]:
|
|
365
|
+
"""
|
|
366
|
+
List the model ids the configured endpoint serves.
|
|
367
|
+
|
|
368
|
+
Returns:
|
|
369
|
+
list[str]: The model ids, in the order the endpoint returned them.
|
|
370
|
+
"""
|
|
371
|
+
return [model.id async for model in self._client.models.list()]
|
|
@@ -43,6 +43,24 @@ from ..types import (
|
|
|
43
43
|
from ..utils import is_debug_enabled
|
|
44
44
|
|
|
45
45
|
|
|
46
|
+
def _split_function_response_runs(parts: list[types.Part]) -> list[list[types.Part]]:
|
|
47
|
+
"""Split parts into consecutive runs of function_response and other parts.
|
|
48
|
+
|
|
49
|
+
Vertex AI requires function responses to sit in a content of their own (see the call
|
|
50
|
+
site); order is preserved, and a message without function responses — or with nothing
|
|
51
|
+
else — comes back as one run.
|
|
52
|
+
"""
|
|
53
|
+
runs: list[list[types.Part]] = []
|
|
54
|
+
last_is_response: bool | None = None
|
|
55
|
+
for part in parts:
|
|
56
|
+
is_response = part.function_response is not None
|
|
57
|
+
if is_response != last_is_response:
|
|
58
|
+
runs.append([])
|
|
59
|
+
last_is_response = is_response
|
|
60
|
+
runs[-1].append(part)
|
|
61
|
+
return runs if runs else [parts]
|
|
62
|
+
|
|
63
|
+
|
|
46
64
|
class Gemini3_7Client(LLMClient):
|
|
47
65
|
"""Unified client for the Gemini family, named for the newest generation it serves (3.7).
|
|
48
66
|
|
|
@@ -201,12 +219,6 @@ class Gemini3_7Client(LLMClient):
|
|
|
201
219
|
Gemini GenerateContentConfig object or None if no config needed
|
|
202
220
|
"""
|
|
203
221
|
config_params = {}
|
|
204
|
-
if config.get("system_prompt") is not None:
|
|
205
|
-
config_params["system_instruction"] = config["system_prompt"]
|
|
206
|
-
|
|
207
|
-
if config.get("max_tokens") is not None:
|
|
208
|
-
config_params["max_output_tokens"] = config["max_tokens"]
|
|
209
|
-
|
|
210
222
|
if config.get("temperature") is not None:
|
|
211
223
|
raise UnsupportedParameterError(
|
|
212
224
|
self.__class__.__name__,
|
|
@@ -215,22 +227,6 @@ class Gemini3_7Client(LLMClient):
|
|
|
215
227
|
"sampling parameters starting with the 3.6 generation.",
|
|
216
228
|
)
|
|
217
229
|
|
|
218
|
-
# include_thoughts asks for thought summaries, but whether generateContent returns any
|
|
219
|
-
# is model-dependent (llmsdk_docs/gemini3_7/docs/thinking.md)
|
|
220
|
-
thinking_summary = config.get("thinking_summary")
|
|
221
|
-
thinking_level = config.get("thinking_level")
|
|
222
|
-
if thinking_summary is not None or thinking_level is not None:
|
|
223
|
-
config_params["thinking_config"] = types.ThinkingConfig(
|
|
224
|
-
include_thoughts=thinking_summary, thinking_level=self._convert_thinking_level(thinking_level)
|
|
225
|
-
)
|
|
226
|
-
|
|
227
|
-
if config.get("tools") is not None:
|
|
228
|
-
config_params["tools"] = [types.Tool(function_declarations=config["tools"])]
|
|
229
|
-
tool_choice = config.get("tool_choice")
|
|
230
|
-
if tool_choice is not None:
|
|
231
|
-
tool_config = self._convert_tool_choice(tool_choice)
|
|
232
|
-
config_params["tool_config"] = types.ToolConfig(function_calling_config=tool_config)
|
|
233
|
-
|
|
234
230
|
if config.get("fast_mode"):
|
|
235
231
|
raise UnsupportedParameterError(self.__class__.__name__, "fast_mode", "Gemini does not support fast mode.")
|
|
236
232
|
|
|
@@ -239,10 +235,12 @@ class Gemini3_7Client(LLMClient):
|
|
|
239
235
|
self.__class__.__name__, "prompt_caching", "prompt_caching must be ENABLE for Gemini."
|
|
240
236
|
)
|
|
241
237
|
|
|
242
|
-
if config.get("
|
|
243
|
-
config_params["
|
|
238
|
+
if config.get("max_tokens") is not None:
|
|
239
|
+
config_params["max_output_tokens"] = config["max_tokens"]
|
|
244
240
|
|
|
245
|
-
#
|
|
241
|
+
# A TTS model takes the speech settings and nothing else: a system instruction, a
|
|
242
|
+
# thinking config, or a tool declaration each comes back as a 400 (verified live
|
|
243
|
+
# 2026-08-20), so the rest of the universal config never reaches the request.
|
|
246
244
|
if "tts" in self._model.lower():
|
|
247
245
|
config_params["response_modalities"] = ["AUDIO"]
|
|
248
246
|
tts_config = config.get("tts_config") or [{"voice": "Kore"}]
|
|
@@ -277,6 +275,30 @@ class Gemini3_7Client(LLMClient):
|
|
|
277
275
|
)
|
|
278
276
|
)
|
|
279
277
|
|
|
278
|
+
return types.GenerateContentConfig(**config_params)
|
|
279
|
+
|
|
280
|
+
if config.get("system_prompt") is not None:
|
|
281
|
+
config_params["system_instruction"] = config["system_prompt"]
|
|
282
|
+
|
|
283
|
+
# include_thoughts asks for thought summaries, but whether generateContent returns any
|
|
284
|
+
# is model-dependent (llmsdk_docs/gemini3_7/docs/thinking.md)
|
|
285
|
+
thinking_summary = config.get("thinking_summary")
|
|
286
|
+
thinking_level = config.get("thinking_level")
|
|
287
|
+
if thinking_summary is not None or thinking_level is not None:
|
|
288
|
+
config_params["thinking_config"] = types.ThinkingConfig(
|
|
289
|
+
include_thoughts=thinking_summary, thinking_level=self._convert_thinking_level(thinking_level)
|
|
290
|
+
)
|
|
291
|
+
|
|
292
|
+
if config.get("tools") is not None:
|
|
293
|
+
config_params["tools"] = [types.Tool(function_declarations=config["tools"])]
|
|
294
|
+
tool_choice = config.get("tool_choice")
|
|
295
|
+
if tool_choice is not None:
|
|
296
|
+
tool_config = self._convert_tool_choice(tool_choice)
|
|
297
|
+
config_params["tool_config"] = types.ToolConfig(function_calling_config=tool_config)
|
|
298
|
+
|
|
299
|
+
if config.get("image_config") is not None:
|
|
300
|
+
config_params["image_config"] = types.ImageConfig(**config["image_config"])
|
|
301
|
+
|
|
280
302
|
return types.GenerateContentConfig(**config_params) if config_params else None
|
|
281
303
|
|
|
282
304
|
@staticmethod
|
|
@@ -373,7 +395,14 @@ class Gemini3_7Client(LLMClient):
|
|
|
373
395
|
else:
|
|
374
396
|
raise ValueError(f"Unknown item: {item}")
|
|
375
397
|
|
|
376
|
-
|
|
398
|
+
# Vertex AI rejects a content that mixes function_response parts with any other
|
|
399
|
+
# part kind — the request fails with a misleading 400, "Requests ending with a
|
|
400
|
+
# model turn are not supported" (the Gemini API endpoint accepts the mix). Split
|
|
401
|
+
# such a message into consecutive same-role contents: each run of function
|
|
402
|
+
# responses becomes its own content, the surrounding parts keep theirs, and the
|
|
403
|
+
# part order is preserved. Homogeneous messages stay a single content.
|
|
404
|
+
for run_parts in _split_function_response_runs(parts):
|
|
405
|
+
contents.append(types.Content(role=mapping[msg["role"]], parts=run_parts))
|
|
377
406
|
|
|
378
407
|
return contents
|
|
379
408
|
|
|
@@ -514,8 +543,12 @@ class Gemini3_7Client(LLMClient):
|
|
|
514
543
|
# Use unified config conversion
|
|
515
544
|
gemini_config = self.transform_uni_config_to_model_config(config)
|
|
516
545
|
|
|
517
|
-
#
|
|
546
|
+
# A TTS model synthesizes a single text turn: a conversation comes back as "Multiturn chat
|
|
547
|
+
# is not enabled for this model" and an audio part as "Audio input modality is not enabled
|
|
548
|
+
# for this model" (verified live 2026-08-20), so only the newest message is sent and the
|
|
549
|
+
# audio a stateful session records stays out of the request.
|
|
518
550
|
if "tts" in self._model.lower():
|
|
551
|
+
messages = messages[-1:]
|
|
519
552
|
invalid_item = next(
|
|
520
553
|
(item for message in messages for item in message["content_items"] if item["type"] != "text"),
|
|
521
554
|
None,
|
|
@@ -134,6 +134,16 @@ class GPT5_6Client(LLMClient):
|
|
|
134
134
|
last_phase: str | None = None
|
|
135
135
|
|
|
136
136
|
for item in msg["content_items"]:
|
|
137
|
+
# anything that is not message content becomes an input item of its own, so the
|
|
138
|
+
# text collected so far is flushed first to keep the order the model produced
|
|
139
|
+
if item["type"] not in ("text", "image_url") and content_items:
|
|
140
|
+
entry = {"role": msg["role"], "content": content_items}
|
|
141
|
+
if last_phase is not None:
|
|
142
|
+
entry["phase"] = last_phase
|
|
143
|
+
|
|
144
|
+
input_list.append(entry)
|
|
145
|
+
content_items = []
|
|
146
|
+
|
|
137
147
|
if item["type"] == "text":
|
|
138
148
|
phase = (item.get("fidelity") or {}).get("phase")
|
|
139
149
|
if msg["role"] == "assistant" and phase: # split different phases
|