agenthub-python 0.4.5__tar.gz → 0.4.7__tar.gz

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