agenthub-python 0.4.5__py3-none-any.whl → 0.4.7__py3-none-any.whl
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/base_client.py +20 -0
- agenthub/deepseek_v4/client.py +161 -181
- agenthub/gemini3_7/client.py +60 -27
- agenthub/gpt5_6/client.py +10 -0
- agenthub/integration/playground.py +192 -25
- agenthub/minimax_m3/client.py +6 -0
- agenthub/openai_responses/client.py +12 -0
- agenthub/registry.py +13 -2
- agenthub/types.py +3 -0
- {agenthub_python-0.4.5.dist-info → agenthub_python-0.4.7.dist-info}/METADATA +1 -1
- {agenthub_python-0.4.5.dist-info → agenthub_python-0.4.7.dist-info}/RECORD +12 -12
- {agenthub_python-0.4.5.dist-info → agenthub_python-0.4.7.dist-info}/WHEEL +0 -0
agenthub/base_client.py
CHANGED
|
@@ -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)
|
agenthub/deepseek_v4/client.py
CHANGED
|
@@ -17,7 +17,7 @@ import os
|
|
|
17
17
|
from typing import Any, AsyncIterator
|
|
18
18
|
|
|
19
19
|
from openai import AsyncOpenAI
|
|
20
|
-
from openai.types.
|
|
20
|
+
from openai.types.responses import ResponseInputParam, ResponseStreamEvent
|
|
21
21
|
|
|
22
22
|
from ..base_client import LLMClient
|
|
23
23
|
from ..errors import UnsupportedParameterError, parse_tool_call_arguments
|
|
@@ -33,10 +33,11 @@ from ..types import (
|
|
|
33
33
|
UniMessage,
|
|
34
34
|
UsageMetadata,
|
|
35
35
|
)
|
|
36
|
+
from ..utils import is_debug_enabled
|
|
36
37
|
|
|
37
38
|
|
|
38
39
|
class DeepSeekV4Client(LLMClient):
|
|
39
|
-
"""DeepSeek V4-specific LLM client implementation using OpenAI-compatible
|
|
40
|
+
"""DeepSeek V4-specific LLM client implementation using the OpenAI-compatible Responses API."""
|
|
40
41
|
|
|
41
42
|
def __init__(
|
|
42
43
|
self,
|
|
@@ -52,27 +53,17 @@ class DeepSeekV4Client(LLMClient):
|
|
|
52
53
|
self._client = AsyncOpenAI(api_key=api_key, base_url=base_url, default_headers=default_headers)
|
|
53
54
|
self._history: list[UniMessage] = []
|
|
54
55
|
|
|
55
|
-
def
|
|
56
|
-
"""Convert ThinkingLevel enum to DeepSeek's
|
|
57
|
-
mapping = {
|
|
58
|
-
ThinkingLevel.NONE: {"type": "disabled"},
|
|
59
|
-
ThinkingLevel.LOW: {"type": "enabled"},
|
|
60
|
-
ThinkingLevel.MEDIUM: {"type": "enabled"},
|
|
61
|
-
ThinkingLevel.HIGH: {"type": "enabled"},
|
|
62
|
-
ThinkingLevel.XHIGH: {"type": "enabled"},
|
|
63
|
-
ThinkingLevel.MAX: {"type": "enabled"},
|
|
64
|
-
}
|
|
65
|
-
return mapping[thinking_level]
|
|
66
|
-
|
|
67
|
-
def _convert_reasoning_effort(self, thinking_level: ThinkingLevel) -> str | None:
|
|
68
|
-
"""Convert ThinkingLevel enum to DeepSeek's reasoning_effort.
|
|
56
|
+
def _convert_thinking_level_to_effort(self, thinking_level: ThinkingLevel) -> str:
|
|
57
|
+
"""Convert ThinkingLevel enum to DeepSeek's reasoning effort.
|
|
69
58
|
|
|
70
59
|
DeepSeek accepts low/high/max and maps medium and xhigh onto high server-side
|
|
71
60
|
(llmsdk_docs/deepseek_v4/docs/thinking-mode.md), so this sends the value the
|
|
72
|
-
server would settle on anyway.
|
|
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).
|
|
73
64
|
"""
|
|
74
65
|
mapping = {
|
|
75
|
-
ThinkingLevel.NONE:
|
|
66
|
+
ThinkingLevel.NONE: "none",
|
|
76
67
|
ThinkingLevel.LOW: "low",
|
|
77
68
|
ThinkingLevel.MEDIUM: "high",
|
|
78
69
|
ThinkingLevel.HIGH: "high",
|
|
@@ -82,7 +73,7 @@ class DeepSeekV4Client(LLMClient):
|
|
|
82
73
|
return mapping[thinking_level]
|
|
83
74
|
|
|
84
75
|
def _convert_tool_choice(self, tool_choice: ToolChoice) -> str:
|
|
85
|
-
"""Convert ToolChoice to DeepSeek's
|
|
76
|
+
"""Convert ToolChoice to DeepSeek's Responses-compatible tool_choice format."""
|
|
86
77
|
if tool_choice in ["auto", "none"]:
|
|
87
78
|
return tool_choice
|
|
88
79
|
raise UnsupportedParameterError(
|
|
@@ -99,25 +90,25 @@ class DeepSeekV4Client(LLMClient):
|
|
|
99
90
|
Returns:
|
|
100
91
|
DeepSeek configuration dictionary
|
|
101
92
|
"""
|
|
102
|
-
deepseek_config = {"model": self._model, "
|
|
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"]
|
|
103
97
|
|
|
104
98
|
if config.get("max_tokens") is not None:
|
|
105
|
-
deepseek_config["
|
|
99
|
+
deepseek_config["max_output_tokens"] = config["max_tokens"]
|
|
106
100
|
|
|
107
101
|
if config.get("temperature") is not None and config["temperature"] != 1.0:
|
|
108
102
|
raise UnsupportedParameterError(
|
|
109
103
|
self.__class__.__name__, "temperature", "DeepSeek V4 does not support setting temperature."
|
|
110
104
|
)
|
|
111
105
|
|
|
112
|
-
|
|
113
|
-
if thinking_level is not None:
|
|
114
|
-
deepseek_config["
|
|
115
|
-
reasoning_effort = self._convert_reasoning_effort(thinking_level)
|
|
116
|
-
if reasoning_effort is not None:
|
|
117
|
-
deepseek_config["reasoning_effort"] = reasoning_effort
|
|
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"])}
|
|
118
109
|
|
|
119
110
|
if config.get("tools") is not None:
|
|
120
|
-
deepseek_config["tools"] = [{"type": "function",
|
|
111
|
+
deepseek_config["tools"] = [{"type": "function", **tool} for tool in config["tools"]]
|
|
121
112
|
|
|
122
113
|
if config.get("tool_choice") is not None:
|
|
123
114
|
deepseek_config["tool_choice"] = self._convert_tool_choice(config["tool_choice"])
|
|
@@ -134,82 +125,92 @@ class DeepSeekV4Client(LLMClient):
|
|
|
134
125
|
|
|
135
126
|
return deepseek_config
|
|
136
127
|
|
|
137
|
-
def transform_uni_message_to_model_input(self, messages: list[UniMessage]) ->
|
|
128
|
+
def transform_uni_message_to_model_input(self, messages: list[UniMessage]) -> ResponseInputParam:
|
|
138
129
|
"""
|
|
139
|
-
Transform universal message format to DeepSeek's
|
|
130
|
+
Transform universal message format to DeepSeek's Responses-compatible input format.
|
|
140
131
|
|
|
141
132
|
Args:
|
|
142
133
|
messages: List of universal message dictionaries
|
|
143
134
|
|
|
144
135
|
Returns:
|
|
145
|
-
List of
|
|
136
|
+
List of input items for the Responses API
|
|
146
137
|
"""
|
|
147
|
-
|
|
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] = []
|
|
148
143
|
|
|
149
144
|
for msg in messages:
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
thinking = ""
|
|
145
|
+
content_items: list = []
|
|
146
|
+
|
|
153
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
|
+
|
|
154
157
|
if item["type"] == "text":
|
|
155
|
-
|
|
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"]})
|
|
156
162
|
elif item["type"] == "image_url":
|
|
157
|
-
|
|
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"]})
|
|
158
167
|
elif item["type"] == "thinking":
|
|
159
|
-
|
|
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)
|
|
160
175
|
elif item["type"] == "tool_call":
|
|
161
|
-
|
|
176
|
+
input_list.append(
|
|
162
177
|
{
|
|
163
|
-
"
|
|
164
|
-
"
|
|
165
|
-
"
|
|
166
|
-
|
|
167
|
-
"arguments": json.dumps(item["arguments"], ensure_ascii=False),
|
|
168
|
-
},
|
|
178
|
+
"type": "function_call",
|
|
179
|
+
"call_id": item["tool_call_id"],
|
|
180
|
+
"name": item["name"],
|
|
181
|
+
"arguments": json.dumps(item["arguments"], ensure_ascii=False),
|
|
169
182
|
}
|
|
170
183
|
)
|
|
171
184
|
elif item["type"] == "tool_result":
|
|
172
185
|
if "tool_call_id" not in item:
|
|
173
186
|
raise ValueError("tool_call_id is required for tool result.")
|
|
174
187
|
|
|
175
|
-
|
|
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.")
|
|
176
193
|
|
|
177
|
-
|
|
178
|
-
|
|
194
|
+
for image_url in item["images"]:
|
|
195
|
+
tool_result.append({"type": "input_image", "image_url": image_url})
|
|
179
196
|
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
{
|
|
183
|
-
"role": "tool",
|
|
184
|
-
"tool_call_id": item["tool_call_id"],
|
|
185
|
-
"content": content,
|
|
186
|
-
}
|
|
197
|
+
input_list.append(
|
|
198
|
+
{"type": "function_call_output", "call_id": item["tool_call_id"], "output": tool_result}
|
|
187
199
|
)
|
|
188
200
|
else:
|
|
189
|
-
raise ValueError(f"Unknown item
|
|
190
|
-
|
|
191
|
-
message = {"role": msg["role"]}
|
|
192
|
-
if content_parts:
|
|
193
|
-
message["content"] = content_parts
|
|
194
|
-
|
|
195
|
-
if tool_calls:
|
|
196
|
-
message["tool_calls"] = tool_calls
|
|
201
|
+
raise ValueError(f"Unknown item: {item}")
|
|
197
202
|
|
|
198
|
-
if
|
|
199
|
-
|
|
203
|
+
if content_items:
|
|
204
|
+
input_list.append({"role": msg["role"], "content": content_items})
|
|
200
205
|
|
|
201
|
-
|
|
202
|
-
if len(message.keys()) > 1:
|
|
203
|
-
deepseek_messages.append(message)
|
|
206
|
+
return input_list
|
|
204
207
|
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
def transform_model_output_to_uni_event(self, model_output: ChatCompletionChunk) -> UniEvent:
|
|
208
|
+
def transform_model_output_to_uni_event(self, model_output: ResponseStreamEvent) -> UniEvent:
|
|
208
209
|
"""
|
|
209
|
-
Transform DeepSeek streaming
|
|
210
|
+
Transform DeepSeek streaming event to universal event format.
|
|
210
211
|
|
|
211
212
|
Args:
|
|
212
|
-
model_output:
|
|
213
|
+
model_output: Responses API streaming event
|
|
213
214
|
|
|
214
215
|
Returns:
|
|
215
216
|
Universal event dictionary
|
|
@@ -219,64 +220,77 @@ class DeepSeekV4Client(LLMClient):
|
|
|
219
220
|
usage_metadata: UsageMetadata | None = None
|
|
220
221
|
finish_reason: FinishReason | None = None
|
|
221
222
|
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
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})
|
|
227
231
|
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
# client reproduces the exact field DeepSeek produced
|
|
232
|
+
elif deepseek_event_type == "response.output_item.added":
|
|
233
|
+
if model_output.item.type == "function_call":
|
|
234
|
+
event_type = "start"
|
|
232
235
|
content_items.append(
|
|
233
236
|
{
|
|
234
|
-
"type": "
|
|
235
|
-
"
|
|
236
|
-
"
|
|
237
|
+
"type": "partial_tool_call",
|
|
238
|
+
"name": model_output.item.name,
|
|
239
|
+
"arguments": "",
|
|
240
|
+
"tool_call_id": model_output.item.call_id,
|
|
237
241
|
}
|
|
238
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
|
+
)
|
|
239
251
|
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
content_items.append({"type": "text", "text": delta.content})
|
|
252
|
+
elif deepseek_event_type == "response.function_call_arguments.done":
|
|
253
|
+
event_type = "stop"
|
|
243
254
|
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
"
|
|
259
|
-
"
|
|
260
|
-
"
|
|
261
|
-
"
|
|
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,
|
|
262
273
|
}
|
|
263
|
-
finish_reason = finish_reason_mapping.get(choice.finish_reason, "unknown")
|
|
264
274
|
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
#
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
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"
|
|
280
294
|
|
|
281
295
|
return {
|
|
282
296
|
"role": "assistant",
|
|
@@ -291,65 +305,38 @@ class DeepSeekV4Client(LLMClient):
|
|
|
291
305
|
messages: list[UniMessage],
|
|
292
306
|
config: UniConfig,
|
|
293
307
|
) -> AsyncIterator[UniEvent]:
|
|
294
|
-
"""Stream generate using DeepSeek's OpenAI-compatible
|
|
308
|
+
"""Stream generate using DeepSeek's OpenAI-compatible Responses API."""
|
|
309
|
+
# Use unified config conversion
|
|
295
310
|
deepseek_config = self.transform_uni_config_to_model_config(config)
|
|
296
|
-
deepseek_messages = self.transform_uni_message_to_model_input(messages)
|
|
297
|
-
|
|
298
|
-
if config.get("system_prompt"):
|
|
299
|
-
deepseek_messages.insert(0, {"role": "system", "content": config["system_prompt"]})
|
|
300
311
|
|
|
301
|
-
|
|
312
|
+
# Use unified message conversion
|
|
313
|
+
input_list = self.transform_uni_message_to_model_input(messages)
|
|
302
314
|
|
|
315
|
+
# Stream generate
|
|
303
316
|
partial_tool_call = {}
|
|
304
|
-
|
|
305
|
-
async for
|
|
306
|
-
event = self.transform_model_output_to_uni_event(
|
|
307
|
-
|
|
308
|
-
partial_usage["usage_metadata"] = event["usage_metadata"] or partial_usage.get("usage_metadata")
|
|
309
|
-
|
|
310
|
-
if event["event_type"] == "delta":
|
|
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":
|
|
311
321
|
for item in event["content_items"]:
|
|
312
322
|
if item["type"] == "partial_tool_call":
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
"type": "tool_call",
|
|
326
|
-
"name": partial_tool_call["name"],
|
|
327
|
-
"arguments": parse_tool_call_arguments(
|
|
328
|
-
partial_tool_call["arguments"],
|
|
329
|
-
self.__class__.__name__,
|
|
330
|
-
partial_tool_call["name"],
|
|
331
|
-
partial_tool_call["tool_call_id"],
|
|
332
|
-
),
|
|
333
|
-
"tool_call_id": partial_tool_call["tool_call_id"],
|
|
334
|
-
}
|
|
335
|
-
],
|
|
336
|
-
"usage_metadata": None,
|
|
337
|
-
"finish_reason": None,
|
|
338
|
-
}
|
|
339
|
-
partial_tool_call = {
|
|
340
|
-
"name": item["name"],
|
|
341
|
-
"arguments": item["arguments"],
|
|
342
|
-
"tool_call_id": item["tool_call_id"],
|
|
343
|
-
}
|
|
344
|
-
else:
|
|
345
|
-
partial_tool_call["arguments"] += item["arguments"]
|
|
346
|
-
partial_tool_call["tool_call_id"] = (
|
|
347
|
-
item["tool_call_id"] or partial_tool_call["tool_call_id"]
|
|
348
|
-
)
|
|
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"]
|
|
349
335
|
|
|
350
336
|
yield event
|
|
351
337
|
elif event["event_type"] == "stop":
|
|
352
|
-
if partial_tool_call:
|
|
338
|
+
if "name" in partial_tool_call and "arguments" in partial_tool_call:
|
|
339
|
+
# finish partial_tool_call
|
|
353
340
|
yield {
|
|
354
341
|
"role": "assistant",
|
|
355
342
|
"event_type": "delta",
|
|
@@ -371,15 +358,8 @@ class DeepSeekV4Client(LLMClient):
|
|
|
371
358
|
}
|
|
372
359
|
partial_tool_call = {}
|
|
373
360
|
|
|
374
|
-
if
|
|
375
|
-
yield
|
|
376
|
-
"role": "assistant",
|
|
377
|
-
"event_type": "stop",
|
|
378
|
-
"content_items": event["content_items"],
|
|
379
|
-
"usage_metadata": partial_usage["usage_metadata"],
|
|
380
|
-
"finish_reason": partial_usage["finish_reason"],
|
|
381
|
-
}
|
|
382
|
-
partial_usage = {}
|
|
361
|
+
if event["finish_reason"] or event["usage_metadata"]:
|
|
362
|
+
yield event
|
|
383
363
|
|
|
384
364
|
async def list_models(self) -> list[str]:
|
|
385
365
|
"""
|
agenthub/gemini3_7/client.py
CHANGED
|
@@ -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,
|
agenthub/gpt5_6/client.py
CHANGED
|
@@ -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
|
|
@@ -415,13 +415,53 @@ def create_chat_app() -> Flask:
|
|
|
415
415
|
return `${d.getFullYear()}-${pad(d.getMonth()+1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`;
|
|
416
416
|
}
|
|
417
417
|
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
418
|
+
const AUDIO_MIME_TYPES = ['audio/wav', 'audio/x-wav', 'audio/mpeg', 'audio/mp3', 'audio/ogg', 'audio/webm', 'audio/flac', 'audio/aac', 'audio/mp4'];
|
|
419
|
+
|
|
420
|
+
function isAudioMimeType(mimeType) {
|
|
421
|
+
const value = (mimeType || '').toLowerCase();
|
|
422
|
+
return !value || value === 'application/octet-stream' || value.startsWith('audio/');
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
function base64ToBytes(base64) {
|
|
426
|
+
const binary = atob(base64);
|
|
427
|
+
const bytes = new Uint8Array(binary.length);
|
|
421
428
|
for (let i = 0; i < binary.length; i++) {
|
|
422
|
-
|
|
429
|
+
bytes[i] = binary.charCodeAt(i);
|
|
423
430
|
}
|
|
431
|
+
return bytes;
|
|
432
|
+
}
|
|
424
433
|
|
|
434
|
+
function bytesToBase64(bytes) {
|
|
435
|
+
let binary = '';
|
|
436
|
+
const chunkSize = 0x8000;
|
|
437
|
+
for (let i = 0; i < bytes.length; i += chunkSize) {
|
|
438
|
+
binary += String.fromCharCode(...bytes.subarray(i, i + chunkSize));
|
|
439
|
+
}
|
|
440
|
+
return btoa(binary);
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
function concatBase64Chunks(chunks) {
|
|
444
|
+
const parts = chunks.map(base64ToBytes);
|
|
445
|
+
const bytes = new Uint8Array(parts.reduce((total, part) => total + part.length, 0));
|
|
446
|
+
let offset = 0;
|
|
447
|
+
for (const part of parts) {
|
|
448
|
+
bytes.set(part, offset);
|
|
449
|
+
offset += part.length;
|
|
450
|
+
}
|
|
451
|
+
return bytes;
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
function pcmFormatFromMimeType(mimeType) {
|
|
455
|
+
// Gemini TTS labels its raw PCM as "audio/l16; rate=24000; channels=1"
|
|
456
|
+
const params = {};
|
|
457
|
+
for (const parameter of (mimeType || '').split(';').slice(1)) {
|
|
458
|
+
const [key, value] = parameter.split('=');
|
|
459
|
+
params[key.trim()] = Number(value);
|
|
460
|
+
}
|
|
461
|
+
return { sampleRate: params.rate || 24000, channels: params.channels || 1 };
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
function pcmBytesToWavDataUrl(pcmBytes, sampleRate = 24000, channels = 1, bitsPerSample = 16) {
|
|
425
465
|
const header = new ArrayBuffer(44);
|
|
426
466
|
const view = new DataView(header);
|
|
427
467
|
const byteRate = sampleRate * channels * bitsPerSample / 8;
|
|
@@ -450,13 +490,56 @@ def create_chat_app() -> Flask:
|
|
|
450
490
|
const wavBytes = new Uint8Array(44 + pcmBytes.length);
|
|
451
491
|
wavBytes.set(new Uint8Array(header), 0);
|
|
452
492
|
wavBytes.set(pcmBytes, 44);
|
|
493
|
+
return `data:audio/wav;base64,${bytesToBase64(wavBytes)}`;
|
|
494
|
+
}
|
|
453
495
|
|
|
454
|
-
|
|
455
|
-
const
|
|
456
|
-
|
|
457
|
-
|
|
496
|
+
function renderAudioPlayer(mimeType, chunks) {
|
|
497
|
+
const bytes = concatBase64Chunks(chunks);
|
|
498
|
+
if (AUDIO_MIME_TYPES.includes(mimeType)) {
|
|
499
|
+
return `<audio controls preload="metadata" class="max-w-xs"><source src="data:${mimeType};base64,${bytesToBase64(bytes)}" type="${mimeType}"></audio>`;
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
const format = pcmFormatFromMimeType(mimeType);
|
|
503
|
+
const wavDataUrl = pcmBytesToWavDataUrl(bytes, format.sampleRate, format.channels);
|
|
504
|
+
return `<audio controls preload="metadata" class="max-w-xs"><source src="${wavDataUrl}" type="audio/wav"></audio>`;
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
function audioProgressLabel(audioStream) {
|
|
508
|
+
if (AUDIO_MIME_TYPES.includes(audioStream.mimeType)) {
|
|
509
|
+
return `🔊 Receiving audio... ${Math.round(audioStream.bytes / 1024)} KB`;
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
// raw PCM carries no duration, so 16-bit samples turn the byte count into seconds
|
|
513
|
+
const format = pcmFormatFromMimeType(audioStream.mimeType);
|
|
514
|
+
const seconds = audioStream.bytes / (format.sampleRate * format.channels * 2);
|
|
515
|
+
return `🔊 Receiving audio... ${seconds.toFixed(1)}s`;
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
function appendAudioChunk(contentDiv, item, audioStream) {
|
|
519
|
+
if (!audioStream.container) {
|
|
520
|
+
audioStream.mimeType = (item.mime_type || '').toLowerCase();
|
|
521
|
+
audioStream.container = document.createElement('div');
|
|
522
|
+
audioStream.container.className = 'mb-3 text-sm text-gray-500';
|
|
523
|
+
contentDiv.appendChild(audioStream.container);
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
audioStream.chunks.push(item.data);
|
|
527
|
+
audioStream.bytes += Math.floor(item.data.length * 3 / 4);
|
|
528
|
+
audioStream.container.textContent = audioProgressLabel(audioStream);
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
function finalizeAudioStream(audioStream, autoplay = false) {
|
|
532
|
+
if (audioStream.finalized || !audioStream.container) {
|
|
533
|
+
return;
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
audioStream.finalized = true;
|
|
537
|
+
audioStream.container.className = 'mb-3';
|
|
538
|
+
audioStream.container.innerHTML = renderAudioPlayer(audioStream.mimeType, audioStream.chunks);
|
|
539
|
+
if (autoplay) {
|
|
540
|
+
// a browser that blocks autoplay leaves the player sitting there ready to press
|
|
541
|
+
audioStream.container.querySelector('audio').play().catch(() => {});
|
|
458
542
|
}
|
|
459
|
-
return `data:audio/wav;base64,${btoa(wavBinary)}`;
|
|
460
543
|
}
|
|
461
544
|
|
|
462
545
|
function renderInlineData(item) {
|
|
@@ -465,17 +548,7 @@ def create_chat_app() -> Flask:
|
|
|
465
548
|
return `<div class="mb-3"><img src="data:${mimeType || 'image/png'};base64,${item.data}" class="max-w-xs rounded border border-gray-300"></div>`;
|
|
466
549
|
}
|
|
467
550
|
|
|
468
|
-
|
|
469
|
-
const isAudio = !mimeType || mimeType === 'application/octet-stream' || mimeType.startsWith('audio/');
|
|
470
|
-
if (!isAudio) {
|
|
471
|
-
return `<div class="mb-3 rounded border border-gray-300 bg-gray-50 px-3 py-2 text-xs text-gray-600">Inline data: ${escapeHtml(item.mime_type || 'application/octet-stream')}</div>`;
|
|
472
|
-
}
|
|
473
|
-
|
|
474
|
-
const playableMimeType = audioMimeTypes.includes(mimeType);
|
|
475
|
-
const audioSrc = playableMimeType
|
|
476
|
-
? `data:${mimeType || 'application/octet-stream'};base64,${item.data}`
|
|
477
|
-
: pcmBase64ToWavDataUrl(item.data);
|
|
478
|
-
return `<div class="mb-3"><audio controls preload="metadata" class="max-w-xs"><source src="${audioSrc}" type="${playableMimeType ? mimeType : 'audio/wav'}"></audio></div>`;
|
|
551
|
+
return `<div class="mb-3 rounded border border-gray-300 bg-gray-50 px-3 py-2 text-xs text-gray-600">Inline data: ${escapeHtml(item.mime_type || 'application/octet-stream')}</div>`;
|
|
479
552
|
}
|
|
480
553
|
|
|
481
554
|
function renderEmbedding(item) {
|
|
@@ -594,6 +667,8 @@ def create_chat_app() -> Flask:
|
|
|
594
667
|
if (comboboxId === 'modelCombobox') {
|
|
595
668
|
handleModelSelectChange();
|
|
596
669
|
}
|
|
670
|
+
|
|
671
|
+
saveConfig();
|
|
597
672
|
}
|
|
598
673
|
|
|
599
674
|
function handleComboboxKeydown(event, comboboxId) {
|
|
@@ -860,6 +935,85 @@ def create_chat_app() -> Flask:
|
|
|
860
935
|
return config;
|
|
861
936
|
}
|
|
862
937
|
|
|
938
|
+
const CONFIG_STORAGE_KEY = 'agenthub.playground.config';
|
|
939
|
+
// saved as typed rather than as parsed values, so an unfinished JSON edit survives too
|
|
940
|
+
const CONFIG_TEXT_INPUTS = [
|
|
941
|
+
'apiKeyInput', 'baseUrlInput', 'extraHeadersInput', 'systemPromptInput', 'toolsInput', 'traceIdInput'
|
|
942
|
+
];
|
|
943
|
+
const CONFIG_COMBOBOXES = [
|
|
944
|
+
['thinkingLevelCombobox', 'thinkingLevelSelect'],
|
|
945
|
+
['thinkingSummaryCombobox', 'thinkingSummaryCheckbox'],
|
|
946
|
+
['toolChoiceCombobox', 'toolChoiceSelect']
|
|
947
|
+
];
|
|
948
|
+
let restoringConfig = false;
|
|
949
|
+
|
|
950
|
+
function comboboxOption(comboboxId, value) {
|
|
951
|
+
return document.querySelector('#' + comboboxId + ' [data-combobox-option][data-value="' + value + '"]');
|
|
952
|
+
}
|
|
953
|
+
|
|
954
|
+
function saveConfig() {
|
|
955
|
+
if (restoringConfig) {
|
|
956
|
+
return;
|
|
957
|
+
}
|
|
958
|
+
|
|
959
|
+
const saved = { model: getSelectedModel(), client_type: getSelectedClientType() };
|
|
960
|
+
CONFIG_TEXT_INPUTS.forEach((id) => {
|
|
961
|
+
saved[id] = document.getElementById(id).value;
|
|
962
|
+
});
|
|
963
|
+
CONFIG_COMBOBOXES.forEach(([, valueId]) => {
|
|
964
|
+
saved[valueId] = document.getElementById(valueId).value;
|
|
965
|
+
});
|
|
966
|
+
try {
|
|
967
|
+
localStorage.setItem(CONFIG_STORAGE_KEY, JSON.stringify(saved));
|
|
968
|
+
} catch (error) {
|
|
969
|
+
// a browser that refuses storage still runs the playground, just without the memory
|
|
970
|
+
}
|
|
971
|
+
}
|
|
972
|
+
|
|
973
|
+
function restoreConfig() {
|
|
974
|
+
let saved = null;
|
|
975
|
+
try {
|
|
976
|
+
saved = JSON.parse(localStorage.getItem(CONFIG_STORAGE_KEY) || 'null');
|
|
977
|
+
} catch (error) {
|
|
978
|
+
saved = null;
|
|
979
|
+
}
|
|
980
|
+
if (!saved) {
|
|
981
|
+
return;
|
|
982
|
+
}
|
|
983
|
+
|
|
984
|
+
restoringConfig = true;
|
|
985
|
+
try {
|
|
986
|
+
CONFIG_TEXT_INPUTS.forEach((id) => {
|
|
987
|
+
if (typeof saved[id] === 'string') {
|
|
988
|
+
document.getElementById(id).value = saved[id];
|
|
989
|
+
}
|
|
990
|
+
});
|
|
991
|
+
CONFIG_COMBOBOXES.forEach(([comboboxId, valueId]) => {
|
|
992
|
+
const option = comboboxOption(comboboxId, saved[valueId] || '');
|
|
993
|
+
if (option) {
|
|
994
|
+
selectComboboxOption(comboboxId, option);
|
|
995
|
+
}
|
|
996
|
+
});
|
|
997
|
+
|
|
998
|
+
// a model the menu does not list — one that was listed, or typed in — comes back as a custom entry
|
|
999
|
+
const modelOption = saved.model ? comboboxOption('modelCombobox', saved.model) : null;
|
|
1000
|
+
if (modelOption) {
|
|
1001
|
+
selectComboboxOption('modelCombobox', modelOption);
|
|
1002
|
+
} else if (saved.model) {
|
|
1003
|
+
document.getElementById('customModelInput').value = saved.model;
|
|
1004
|
+
selectComboboxOption('modelCombobox', comboboxOption('modelCombobox', '__custom__'));
|
|
1005
|
+
// the custom option focuses its id field on selection, which a page load should not do
|
|
1006
|
+
document.getElementById('customModelInput').blur();
|
|
1007
|
+
}
|
|
1008
|
+
if (saved.client_type) {
|
|
1009
|
+
document.getElementById('customClientTypeInput').value = saved.client_type;
|
|
1010
|
+
handleClientTypeInput();
|
|
1011
|
+
}
|
|
1012
|
+
} finally {
|
|
1013
|
+
restoringConfig = false;
|
|
1014
|
+
}
|
|
1015
|
+
}
|
|
1016
|
+
|
|
863
1017
|
function addMessageCard(role, content, metadata = null, images = [], timestamp = null, tookMs = null) {
|
|
864
1018
|
const container = document.getElementById('messagesContainer');
|
|
865
1019
|
|
|
@@ -973,6 +1127,8 @@ def create_chat_app() -> Flask:
|
|
|
973
1127
|
|
|
974
1128
|
const assistantCard = addMessageCard('assistant', '');
|
|
975
1129
|
const contentDiv = assistantCard.querySelector('.message-content');
|
|
1130
|
+
// a spoken response streams as many small chunks that only play as one clip
|
|
1131
|
+
const audioStream = { mimeType: '', chunks: [], bytes: 0, container: null, finalized: false };
|
|
976
1132
|
|
|
977
1133
|
try {
|
|
978
1134
|
const config = getConfig();
|
|
@@ -1086,10 +1242,14 @@ def create_chat_app() -> Flask:
|
|
|
1086
1242
|
toolResultDiv.innerHTML = `<strong class="text-sm">✅ Tool Result:</strong><br><div class="mt-1 text-xs whitespace-pre-wrap">${escapeHtml(item.text)}</div>`;
|
|
1087
1243
|
contentDiv.appendChild(toolResultDiv);
|
|
1088
1244
|
} else if (item.type === 'inline_data') {
|
|
1089
|
-
|
|
1090
|
-
|
|
1091
|
-
|
|
1092
|
-
|
|
1245
|
+
if (isAudioMimeType(item.mime_type)) {
|
|
1246
|
+
appendAudioChunk(contentDiv, item, audioStream);
|
|
1247
|
+
} else {
|
|
1248
|
+
const inlineDataDiv = document.createElement('div');
|
|
1249
|
+
inlineDataDiv.innerHTML = renderInlineData(item);
|
|
1250
|
+
if (inlineDataDiv.firstChild) {
|
|
1251
|
+
contentDiv.appendChild(inlineDataDiv.firstChild);
|
|
1252
|
+
}
|
|
1093
1253
|
}
|
|
1094
1254
|
} else if (item.type === 'embedding') {
|
|
1095
1255
|
const embeddingDiv = document.createElement('div');
|
|
@@ -1128,6 +1288,8 @@ def create_chat_app() -> Flask:
|
|
|
1128
1288
|
}
|
|
1129
1289
|
}
|
|
1130
1290
|
|
|
1291
|
+
finalizeAudioStream(audioStream, true);
|
|
1292
|
+
|
|
1131
1293
|
if (lastCreatedAt) {
|
|
1132
1294
|
const timestampEl = assistantCard.querySelector('.msg-timestamp');
|
|
1133
1295
|
if (timestampEl) {
|
|
@@ -1156,11 +1318,13 @@ def create_chat_app() -> Flask:
|
|
|
1156
1318
|
metadataHtml += `<div class="flex items-center gap-1">🏁 ${metadata.finish_reason}</div>`;
|
|
1157
1319
|
}
|
|
1158
1320
|
metadataHtml += '</div>';
|
|
1159
|
-
|
|
1321
|
+
// appended rather than re-parsed into the card, which would restart a playing clip
|
|
1322
|
+
assistantCard.insertAdjacentHTML('beforeend', metadataHtml);
|
|
1160
1323
|
}
|
|
1161
1324
|
|
|
1162
1325
|
} catch (error) {
|
|
1163
1326
|
if (error.name === 'AbortError') {
|
|
1327
|
+
finalizeAudioStream(audioStream);
|
|
1164
1328
|
markInterrupted(contentDiv);
|
|
1165
1329
|
} else {
|
|
1166
1330
|
contentDiv.textContent = `Error: ${error.message}`;
|
|
@@ -1218,6 +1382,9 @@ def create_chat_app() -> Flask:
|
|
|
1218
1382
|
textarea.addEventListener('input', resizeMessageInput);
|
|
1219
1383
|
resizeMessageInput();
|
|
1220
1384
|
|
|
1385
|
+
document.getElementById('configPanel').addEventListener('input', saveConfig);
|
|
1386
|
+
restoreConfig();
|
|
1387
|
+
|
|
1221
1388
|
</script>
|
|
1222
1389
|
</body>
|
|
1223
1390
|
</html>
|
agenthub/minimax_m3/client.py
CHANGED
|
@@ -134,6 +134,12 @@ class MiniMaxM3Client(LLMClient):
|
|
|
134
134
|
for message in messages:
|
|
135
135
|
content_items: list[dict[str, Any]] = []
|
|
136
136
|
for item in message["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
|
+
input_list.append({"role": message["role"], "content": content_items})
|
|
141
|
+
content_items = []
|
|
142
|
+
|
|
137
143
|
if item["type"] == "text":
|
|
138
144
|
content_items.append(
|
|
139
145
|
{
|
|
@@ -130,6 +130,18 @@ class OpenaiResponsesClient(LLMClient):
|
|
|
130
130
|
last_phase: str | None = None
|
|
131
131
|
|
|
132
132
|
for item in msg["content_items"]:
|
|
133
|
+
# anything that is not message content becomes an input item of its own, so the
|
|
134
|
+
# text collected so far is flushed first to keep the original order: a server that
|
|
135
|
+
# merges a function call into the adjacent assistant message rejects a call whose
|
|
136
|
+
# output does not follow it (DeepSeek answers "No tool output found for tool call")
|
|
137
|
+
if item["type"] not in ("text", "image_url") and content_items:
|
|
138
|
+
entry = {"role": msg["role"], "content": content_items}
|
|
139
|
+
if last_phase is not None:
|
|
140
|
+
entry["phase"] = last_phase
|
|
141
|
+
|
|
142
|
+
input_list.append(entry)
|
|
143
|
+
content_items = []
|
|
144
|
+
|
|
133
145
|
if item["type"] == "text":
|
|
134
146
|
phase = (item.get("fidelity") or {}).get("phase")
|
|
135
147
|
if msg["role"] == "assistant" and phase: # split different phases
|
agenthub/registry.py
CHANGED
|
@@ -309,6 +309,17 @@ _SUPPORTED_MODELS: list[SupportedModel] = [
|
|
|
309
309
|
# (Beijing 9:00-12:00, 14:00-18:00) are double
|
|
310
310
|
"pricing": _cny(1.5, 4.5, cached=0.05),
|
|
311
311
|
},
|
|
312
|
+
{
|
|
313
|
+
"model": "deepseek-v4-flash-vision-exp",
|
|
314
|
+
"base_url": _DEEPSEEK,
|
|
315
|
+
"client": "deepseek-v4",
|
|
316
|
+
"input_modalities": ["Text", "Image"],
|
|
317
|
+
"output_modalities": ["Text"],
|
|
318
|
+
"context_window": 1000000,
|
|
319
|
+
# priced as deepseek-v4-flash; official off-peak list price (verified 2026-08-21), and
|
|
320
|
+
# peak-hour rates (Beijing 9:00-12:00, 14:00-18:00) are double
|
|
321
|
+
"pricing": _cny(1.5, 4.5, cached=0.05),
|
|
322
|
+
},
|
|
312
323
|
{
|
|
313
324
|
"model": "deepseek-v4-pro",
|
|
314
325
|
"base_url": _DEEPSEEK,
|
|
@@ -550,7 +561,7 @@ _SUPPORTED_MODELS: list[SupportedModel] = [
|
|
|
550
561
|
{
|
|
551
562
|
"model": "deepseek-ai/DeepSeek-V4-Flash",
|
|
552
563
|
"base_url": _SILICONFLOW,
|
|
553
|
-
"client": "
|
|
564
|
+
"client": "openai-chat",
|
|
554
565
|
"input_modalities": ["Text"],
|
|
555
566
|
"output_modalities": ["Text"],
|
|
556
567
|
"context_window": 1000000,
|
|
@@ -559,7 +570,7 @@ _SUPPORTED_MODELS: list[SupportedModel] = [
|
|
|
559
570
|
{
|
|
560
571
|
"model": "deepseek-ai/DeepSeek-V4-Pro",
|
|
561
572
|
"base_url": _SILICONFLOW,
|
|
562
|
-
"client": "
|
|
573
|
+
"client": "openai-chat",
|
|
563
574
|
"input_modalities": ["Text"],
|
|
564
575
|
"output_modalities": ["Text"],
|
|
565
576
|
"context_window": 1000000,
|
agenthub/types.py
CHANGED
|
@@ -38,6 +38,9 @@ class PromptCaching(StrEnum):
|
|
|
38
38
|
# Tool choice can be a literal string or a list of tool names
|
|
39
39
|
ToolChoice = Literal["auto", "required", "none"] | list[str]
|
|
40
40
|
Role = Literal["user", "assistant"]
|
|
41
|
+
# "start" opens an item, "delta" carries content, and "stop" closes the response with usage and
|
|
42
|
+
# a finish reason. "unused" is a client's own marker for a wire event that carries nothing
|
|
43
|
+
# universal, and never leaves the client.
|
|
41
44
|
EventType = Literal["start", "delta", "stop", "unused"]
|
|
42
45
|
FinishReason = Literal["stop", "length", "tool_call", "unknown"]
|
|
43
46
|
AspectRatio = Literal["1:1", "2:3", "3:2", "3:4", "4:3", "9:16", "16:9", "21:9"]
|
|
@@ -3,34 +3,34 @@ agenthub/abort_signal.py,sha256=_j99RBZluBPdHLuRpZMyo15amll_8ZzmLO8fjLwEsaM,3744
|
|
|
3
3
|
agenthub/ant_messages/__init__.py,sha256=wEz05advA7ggsvOYBffat7g8PN9TkOxYRBdeYPuoihY,672
|
|
4
4
|
agenthub/ant_messages/client.py,sha256=Xo5qIKszI2vnEpRb67iV3YZdSL2F_2El5L74gMC3jaA,19098
|
|
5
5
|
agenthub/auto_client.py,sha256=dlY__e1Dr5JAt87rnyxTZFBV6Q7n90IMLjRlf8eW4Y8,9036
|
|
6
|
-
agenthub/base_client.py,sha256=
|
|
6
|
+
agenthub/base_client.py,sha256=K751iiWaFu_umqsYBITHCYuzDiNSoDqDFGxRsfj_NWo,15361
|
|
7
7
|
agenthub/claude5/__init__.py,sha256=FzP_ijkZ67kK6W-VJwRYTswzSCt_YaWbOEcybQ_2FyM,664
|
|
8
8
|
agenthub/claude5/client.py,sha256=S3qY8V8SfUqbC6YaUnbC3kRw7n-aMIiEGRKRpSB102A,21151
|
|
9
9
|
agenthub/deepseek_v4/__init__.py,sha256=MEBBJlx2kicstbG3ncJp1gLpIeTG_fwNh_uZVsc7JcI,670
|
|
10
|
-
agenthub/deepseek_v4/client.py,sha256=
|
|
10
|
+
agenthub/deepseek_v4/client.py,sha256=qo0DWou3z9Rw2-qez7ncqhJVxjF1FX_U7goPtYyIeFg,16115
|
|
11
11
|
agenthub/errors.py,sha256=hBDqqkKx7grNSnUoByzFXc7egLDKCiGWE-ZC_driZjA,3907
|
|
12
12
|
agenthub/gemini3_7/__init__.py,sha256=bMyPUwyiuyGPU0h2q5bqmL4gfwh7FcSeqK_6kLn9WGI,668
|
|
13
|
-
agenthub/gemini3_7/client.py,sha256=
|
|
13
|
+
agenthub/gemini3_7/client.py,sha256=w66GBbII9oTGPwBdhe4gf5gzCkISBEvESZ6cKKcct2c,27450
|
|
14
14
|
agenthub/glm5_3/__init__.py,sha256=DJhHMvliHikxdNA8HLNmxzRXLPASCc6aFJZTfpMX7fg,662
|
|
15
15
|
agenthub/glm5_3/client.py,sha256=gBbSTwfnB-JZQNVopsXFciikN321tQTfVkesBuyDkYc,19853
|
|
16
16
|
agenthub/gpt5_6/__init__.py,sha256=_KgiOi_VqTL82pf9Ks7VM-d_x5-at8v-N1KZDWLVvt8,662
|
|
17
|
-
agenthub/gpt5_6/client.py,sha256=
|
|
17
|
+
agenthub/gpt5_6/client.py,sha256=ZOuJAW3_aqjcTykK6Q1_rCZvpPDb5TLBRiFnWFYkKIc,18072
|
|
18
18
|
agenthub/integration/__init__.py,sha256=tlqmpVlFYm8uyMSa4RFebx5vi0FSS_GcES57JWVeu8c,600
|
|
19
|
-
agenthub/integration/playground.py,sha256=
|
|
19
|
+
agenthub/integration/playground.py,sha256=7PVzhZZH9EPvhR2MhKenqcU_HZvWlPQ7cOpKLRdSTT4,90245
|
|
20
20
|
agenthub/integration/tracer.py,sha256=qAcU8rvnDJMgJHg4niwN9aANwSnxh8MrMaWpKBwLaZo,41963
|
|
21
21
|
agenthub/kimi_k3/__init__.py,sha256=MnoMhqRI6tlaXmQCHEud-2BqAvyxa_7Bxv3X8khfC3g,662
|
|
22
22
|
agenthub/kimi_k3/client.py,sha256=S0P0pxjW3Yir0F_dP6P9E6mqxo74dRXSKWHa_pmB9dA,20905
|
|
23
23
|
agenthub/minimax_m3/__init__.py,sha256=FTykS-0le7AdiU8zLeMjbj4QGBwR0oSIkOICqEFGoaw,668
|
|
24
|
-
agenthub/minimax_m3/client.py,sha256=
|
|
24
|
+
agenthub/minimax_m3/client.py,sha256=PuidXczWc901w3EgTj5DNLMZ8SlAQzbJn0zUP3aWdeY,15013
|
|
25
25
|
agenthub/openai_chat/__init__.py,sha256=6X-4sX0Xy9MqSYVglW4ytt3NMSAzoRM3I_NFKwqOIVQ,670
|
|
26
26
|
agenthub/openai_chat/client.py,sha256=-3g7krZMOrNKUDHoC43LXaE1fXo7VnTXelqrfe7Dfn0,18504
|
|
27
27
|
agenthub/openai_embedding/__init__.py,sha256=t1W8kBvZUXICjDfgjTVxw-uIh5qoneUgmSsGIYan02s,680
|
|
28
28
|
agenthub/openai_embedding/client.py,sha256=j0TM0JgU1ezSLkDFHHMXDOiAaE1NXtkSHTM-R6BmT4Y,4165
|
|
29
29
|
agenthub/openai_responses/__init__.py,sha256=Y0YAAnkRYpIEq62phw5EpTfPt_osFXptujhpwBggM1E,680
|
|
30
|
-
agenthub/openai_responses/client.py,sha256=
|
|
31
|
-
agenthub/registry.py,sha256=
|
|
32
|
-
agenthub/types.py,sha256=
|
|
30
|
+
agenthub/openai_responses/client.py,sha256=rEVw5IpD5w9Te6Ct0HxqBPyxmeS45FuJmbq9CHU80Z4,17645
|
|
31
|
+
agenthub/registry.py,sha256=gh2rPyBI0yp3w92FyE7NmGvJm5luNhHxWk7ZDP8DpY4,22533
|
|
32
|
+
agenthub/types.py,sha256=OUdtKpC2vzr8ZzB1Z3vpnl3RK02w6EL_7tnPosb31jI,5589
|
|
33
33
|
agenthub/utils.py,sha256=nL4vlOZ6S-bZp6SBNvKjAhGiWhdAeJzVegZ2ALN88N0,1848
|
|
34
|
-
agenthub_python-0.4.
|
|
35
|
-
agenthub_python-0.4.
|
|
36
|
-
agenthub_python-0.4.
|
|
34
|
+
agenthub_python-0.4.7.dist-info/WHEEL,sha256=4OL6Foqnnp3xRY5wMkjgc25_i5YJC6dKsC6LPcjqEoU,80
|
|
35
|
+
agenthub_python-0.4.7.dist-info/METADATA,sha256=6CSJXU8PvBQoUWFh_GI8t7l-VOzBEGVCXyvz0RNnWTc,9171
|
|
36
|
+
agenthub_python-0.4.7.dist-info/RECORD,,
|
|
File without changes
|