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 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)
@@ -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.chat import ChatCompletionChunk, ChatCompletionMessageParam
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 Chat Completions."""
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 _convert_thinking_level_to_config(self, thinking_level: ThinkingLevel) -> dict[str, str]:
56
- """Convert ThinkingLevel enum to DeepSeek's thinking configuration."""
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: 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 OpenAI-compatible tool_choice format."""
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, "stream": True, "stream_options": {"include_usage": True}}
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["max_tokens"] = config["max_tokens"]
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
- thinking_level = config.get("thinking_level")
113
- if thinking_level is not None:
114
- deepseek_config["extra_body"] = {"thinking": self._convert_thinking_level_to_config(thinking_level)}
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", "function": tool} for tool in config["tools"]]
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]) -> list[ChatCompletionMessageParam]:
128
+ def transform_uni_message_to_model_input(self, messages: list[UniMessage]) -> ResponseInputParam:
138
129
  """
139
- Transform universal message format to DeepSeek's OpenAI-compatible message format.
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 OpenAI-compatible message dictionaries
136
+ List of input items for the Responses API
146
137
  """
147
- deepseek_messages = []
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
- content_parts = [] # may be empty for tool results
151
- tool_calls = [] # may be empty for no tool calls
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
- content_parts.append({"type": "text", "text": item["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"]})
156
162
  elif item["type"] == "image_url":
157
- raise ValueError("DeepSeek does not support image url inputs.")
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
- thinking += item["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)
160
175
  elif item["type"] == "tool_call":
161
- tool_calls.append(
176
+ input_list.append(
162
177
  {
163
- "id": item["tool_call_id"],
164
- "type": "function",
165
- "function": {
166
- "name": item["name"],
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
- content = [{"type": "text", "text": item["text"]}]
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
- if "images" in item and item["images"]:
178
- raise ValueError("DeepSeek does not support images in tool results.")
194
+ for image_url in item["images"]:
195
+ tool_result.append({"type": "input_image", "image_url": image_url})
179
196
 
180
- # Tool results are sent as separate messages
181
- deepseek_messages.append(
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 type: {item['type']}")
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 thinking:
199
- message["reasoning_content"] = thinking
203
+ if content_items:
204
+ input_list.append({"role": msg["role"], "content": content_items})
200
205
 
201
- # message may be empty for tool results
202
- if len(message.keys()) > 1:
203
- deepseek_messages.append(message)
206
+ return input_list
204
207
 
205
- return deepseek_messages
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 chunk to universal event format.
210
+ Transform DeepSeek streaming event to universal event format.
210
211
 
211
212
  Args:
212
- model_output: OpenAI-compatible streaming chunk
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
- # gateways inject content-free heartbeat chunks on long generations, whose choices
223
- # the SDK leaves as None rather than an empty list
224
- if model_output.choices:
225
- choice = model_output.choices[0]
226
- delta = choice.delta
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
- if getattr(delta, "reasoning_content", None):
229
- event_type = "delta"
230
- # record the wire field so a replay through another OpenAI-compatible
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": "thinking",
235
- "thinking": getattr(delta, "reasoning_content"),
236
- "fidelity": {"reasoning_field": "reasoning_content"},
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
- if delta.content:
241
- event_type = "delta"
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
- if delta.tool_calls:
245
- event_type = "delta"
246
- for tool_call in delta.tool_calls:
247
- content_item: PartialContentItem = {
248
- "type": "partial_tool_call",
249
- "name": tool_call.function.name or "",
250
- "arguments": tool_call.function.arguments or "",
251
- "tool_call_id": tool_call.id or "",
252
- }
253
- content_items.append(content_item)
254
-
255
- if choice.finish_reason:
256
- event_type = event_type or "stop"
257
- finish_reason_mapping = {
258
- "stop": "stop",
259
- "length": "length",
260
- "tool_calls": "tool_call",
261
- "content_filter": "stop",
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
- if model_output.usage:
266
- event_type = event_type or "stop"
267
- completion_token_details = model_output.usage.completion_tokens_details
268
- reasoning_tokens = (
269
- getattr(completion_token_details, "reasoning_tokens", None) if completion_token_details else None
270
- )
271
- response_tokens = model_output.usage.completion_tokens - (reasoning_tokens or 0)
272
-
273
- # usage.prompt_tokens = prompt_cache_hit_tokens + prompt_cache_miss_tokens
274
- usage_metadata = {
275
- "cached_tokens": getattr(model_output.usage, "prompt_cache_hit_tokens", 0),
276
- "prompt_tokens": getattr(model_output.usage, "prompt_cache_miss_tokens", 0),
277
- "thoughts_tokens": reasoning_tokens,
278
- "response_tokens": response_tokens,
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 Chat Completions API."""
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
- stream = await self._client.chat.completions.create(**deepseek_config, messages=deepseek_messages)
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
- partial_usage = {}
305
- async for chunk in stream:
306
- event = self.transform_model_output_to_uni_event(chunk)
307
- partial_usage["finish_reason"] = event["finish_reason"] or partial_usage.get("finish_reason")
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
- if not partial_tool_call:
314
- partial_tool_call = {
315
- "name": item["name"],
316
- "arguments": item["arguments"],
317
- "tool_call_id": item["tool_call_id"],
318
- }
319
- elif item["name"]:
320
- yield {
321
- "role": "assistant",
322
- "event_type": "delta",
323
- "content_items": [
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 partial_usage.get("finish_reason") and partial_usage.get("usage_metadata"):
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
  """
@@ -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("image_config") is not None:
243
- config_params["image_config"] = types.ImageConfig(**config["image_config"])
238
+ if config.get("max_tokens") is not None:
239
+ config_params["max_output_tokens"] = config["max_tokens"]
244
240
 
245
- # tts config
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
- contents.append(types.Content(role=mapping[msg["role"]], parts=parts))
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
- # check if all items are text for tts model
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
- function pcmBase64ToWavDataUrl(pcmBase64, sampleRate = 24000, channels = 1, bitsPerSample = 16) {
419
- const binary = atob(pcmBase64);
420
- const pcmBytes = new Uint8Array(binary.length);
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
- pcmBytes[i] = binary.charCodeAt(i);
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
- let wavBinary = '';
455
- const chunkSize = 0x8000;
456
- for (let i = 0; i < wavBytes.length; i += chunkSize) {
457
- wavBinary += String.fromCharCode(...wavBytes.subarray(i, i + chunkSize));
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
- const audioMimeTypes = ['audio/wav', 'audio/x-wav', 'audio/mpeg', 'audio/mp3', 'audio/ogg', 'audio/webm', 'audio/flac', 'audio/aac', 'audio/mp4'];
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
- const inlineDataDiv = document.createElement('div');
1090
- inlineDataDiv.innerHTML = renderInlineData(item);
1091
- if (inlineDataDiv.firstChild) {
1092
- contentDiv.appendChild(inlineDataDiv.firstChild);
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
- assistantCard.innerHTML += metadataHtml;
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>
@@ -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": "deepseek-v4",
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": "deepseek-v4",
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"]
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: agenthub-python
3
- Version: 0.4.5
3
+ Version: 0.4.7
4
4
  Summary: AgentHub is the LLM API Hub for the Agent era, built for high-precision autonomous agents.
5
5
  Keywords: agent,llm,gemini,claude,gpt
6
6
  Author: PrismShadow
@@ -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=6c4521Q3Ro0TNLHzVSWdhGXZaZL2eHwh4VEhHphnRVA,14263
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=k5zTn1MLH01SRINrVXIhP89JPCqTAthavarVngRC76g,16803
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=lx-mS-q2MWAMKJs3Z6reSScLnM1jaLV_b8y7otOY9sw,25443
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=KPmZ-5ox96CJKZrePSpPlEKj5Ja9tz-Y1xhEHw_DU3I,17541
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=BAyw6rb11WqsGuoVXQu4lbKXbAx_MS0QVp9u4KG1hSY,82308
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=_WkP8B-VOYzXgpO6tGFAVSwQrv1tKk5x3wyzahSR0cQ,14612
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=08frEJaEhBJXvE3nXRiuCvCeaRu5u219RiZu0cvM5dY,16911
31
- agenthub/registry.py,sha256=SYvdOo_9uzeQ_jCxCruyvAH0geFQeC2IL-Fhpca-KDA,22072
32
- agenthub/types.py,sha256=hQapO7gvXHrlj3coh9Qw7OsOHkhXn04ni6GwdPA5xLw,5360
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.5.dist-info/WHEEL,sha256=4OL6Foqnnp3xRY5wMkjgc25_i5YJC6dKsC6LPcjqEoU,80
35
- agenthub_python-0.4.5.dist-info/METADATA,sha256=UYOwsKu0EWt0oLhYH9dbcbNfYoTKn6xkXOVQqjdiHy4,9171
36
- agenthub_python-0.4.5.dist-info/RECORD,,
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,,