agenthub-python 0.3.3__tar.gz → 0.4.1__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.3.3 → agenthub_python-0.4.1}/PKG-INFO +4 -1
  2. {agenthub_python-0.3.3 → agenthub_python-0.4.1}/agenthub/__init__.py +16 -1
  3. {agenthub_python-0.3.3 → agenthub_python-0.4.1}/agenthub/auto_client.py +22 -5
  4. {agenthub_python-0.3.3 → agenthub_python-0.4.1}/agenthub/base_client.py +42 -9
  5. {agenthub_python-0.3.3 → agenthub_python-0.4.1}/agenthub/claude4_6/client.py +20 -7
  6. agenthub_python-0.4.1/agenthub/claude5/__init__.py +18 -0
  7. {agenthub_python-0.3.3/agenthub/claude4_8 → agenthub_python-0.4.1/agenthub/claude5}/client.py +26 -11
  8. {agenthub_python-0.3.3 → agenthub_python-0.4.1}/agenthub/deepseek_v4/client.py +31 -6
  9. agenthub_python-0.4.1/agenthub/errors.py +89 -0
  10. {agenthub_python-0.3.3 → agenthub_python-0.4.1}/agenthub/gemini3/client.py +42 -17
  11. {agenthub_python-0.3.3/agenthub/claude4_8 → agenthub_python-0.4.1/agenthub/gemini3_6}/__init__.py +2 -2
  12. agenthub_python-0.4.1/agenthub/gemini3_6/client.py +458 -0
  13. {agenthub_python-0.3.3 → agenthub_python-0.4.1}/agenthub/glm5_1/client.py +51 -13
  14. agenthub_python-0.4.1/agenthub/glm5_2/__init__.py +18 -0
  15. agenthub_python-0.4.1/agenthub/glm5_2/client.py +410 -0
  16. {agenthub_python-0.3.3 → agenthub_python-0.4.1}/agenthub/gpt5_5/client.py +25 -14
  17. {agenthub_python-0.3.3 → agenthub_python-0.4.1}/agenthub/kimi_k2_6/client.py +54 -14
  18. agenthub_python-0.4.1/agenthub/kimi_k3/__init__.py +18 -0
  19. agenthub_python-0.4.1/agenthub/kimi_k3/client.py +436 -0
  20. {agenthub_python-0.3.3 → agenthub_python-0.4.1}/agenthub/openai/client.py +48 -12
  21. agenthub_python-0.4.1/agenthub/registry.py +564 -0
  22. {agenthub_python-0.3.3 → agenthub_python-0.4.1}/agenthub/types.py +11 -7
  23. {agenthub_python-0.3.3 → agenthub_python-0.4.1}/pyproject.toml +6 -1
  24. {agenthub_python-0.3.3 → agenthub_python-0.4.1}/README.md +0 -0
  25. {agenthub_python-0.3.3 → agenthub_python-0.4.1}/agenthub/abort_signal.py +0 -0
  26. {agenthub_python-0.3.3 → agenthub_python-0.4.1}/agenthub/claude4_6/__init__.py +0 -0
  27. {agenthub_python-0.3.3 → agenthub_python-0.4.1}/agenthub/deepseek_v4/__init__.py +0 -0
  28. {agenthub_python-0.3.3 → agenthub_python-0.4.1}/agenthub/gemini3/__init__.py +0 -0
  29. {agenthub_python-0.3.3 → agenthub_python-0.4.1}/agenthub/glm5_1/__init__.py +0 -0
  30. {agenthub_python-0.3.3 → agenthub_python-0.4.1}/agenthub/gpt5_5/__init__.py +0 -0
  31. {agenthub_python-0.3.3 → agenthub_python-0.4.1}/agenthub/integration/__init__.py +0 -0
  32. {agenthub_python-0.3.3 → agenthub_python-0.4.1}/agenthub/integration/playground.py +0 -0
  33. {agenthub_python-0.3.3 → agenthub_python-0.4.1}/agenthub/integration/tracer.py +0 -0
  34. {agenthub_python-0.3.3 → agenthub_python-0.4.1}/agenthub/kimi_k2_6/__init__.py +0 -0
  35. {agenthub_python-0.3.3 → agenthub_python-0.4.1}/agenthub/openai/__init__.py +0 -0
  36. {agenthub_python-0.3.3 → agenthub_python-0.4.1}/agenthub/openai_embedding/__init__.py +0 -0
  37. {agenthub_python-0.3.3 → agenthub_python-0.4.1}/agenthub/openai_embedding/client.py +0 -0
  38. {agenthub_python-0.3.3 → agenthub_python-0.4.1}/agenthub/utils.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: agenthub-python
3
- Version: 0.3.3
3
+ Version: 0.4.1
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
@@ -16,6 +16,9 @@ Requires-Dist: pytest-asyncio>=0.23.0 ; extra == 'dev'
16
16
  Requires-Dist: ruff>=0.14.3 ; extra == 'dev'
17
17
  Requires-Dist: pillow>=10.0.0 ; extra == 'dev'
18
18
  Requires-Python: >=3.11
19
+ Project-URL: Homepage, https://github.com/Prism-Shadow/agenthub
20
+ Project-URL: Repository, https://github.com/Prism-Shadow/agenthub
21
+ Project-URL: Issues, https://github.com/Prism-Shadow/agenthub/issues
19
22
  Provides-Extra: dev
20
23
  Description-Content-Type: text/markdown
21
24
 
@@ -13,7 +13,22 @@
13
13
  # limitations under the License.
14
14
 
15
15
  from .auto_client import AutoLLMClient
16
+ from .errors import AgentHubError, EmptyResponseError, ToolCallArgumentParseError, UnsupportedParameterError
17
+ from .registry import Currency, Modality, ModelPricing, SupportedModel, list_supported_models
16
18
  from .types import PromptCaching, ThinkingLevel
17
19
 
18
20
 
19
- __all__ = ["AutoLLMClient", "PromptCaching", "ThinkingLevel"]
21
+ __all__ = [
22
+ "AgentHubError",
23
+ "AutoLLMClient",
24
+ "Currency",
25
+ "EmptyResponseError",
26
+ "Modality",
27
+ "ModelPricing",
28
+ "PromptCaching",
29
+ "SupportedModel",
30
+ "ThinkingLevel",
31
+ "ToolCallArgumentParseError",
32
+ "UnsupportedParameterError",
33
+ "list_supported_models",
34
+ ]
@@ -47,16 +47,25 @@ class AutoLLMClient(LLMClient):
47
47
  ) -> LLMClient:
48
48
  """Create the appropriate client for the given model."""
49
49
  client_type = (client_type or os.getenv("CLIENT_TYPE", model)).lower()
50
+ # gemini-3.6 must be matched before the broader gemini-3 prefix below
50
51
  if any(
52
+ prefix in client_type for prefix in ("gemini-3.6", "gemini-3.5-flash-lite")
53
+ ): # e.g., gemini-3.6-flash; gemini-3.5-flash-lite shares the sampling-parameter deprecation
54
+ from .gemini3_6 import Gemini3_6Client
55
+
56
+ return Gemini3_6Client(model=model, api_key=api_key, base_url=base_url)
57
+ elif any(
51
58
  prefix in client_type for prefix in ("gemini-3", "gemini-embedding")
52
59
  ): # e.g., gemini-3-flash-preview, gemini-embedding-2
53
60
  from .gemini3 import Gemini3Client
54
61
 
55
62
  return Gemini3Client(model=model, api_key=api_key, base_url=base_url)
56
- elif "claude" in client_type and ("4-7" in client_type or "4-8" in client_type): # e.g., claude-opus-4-7
57
- from .claude4_8 import Claude4_8Client
63
+ elif "claude" in client_type and (
64
+ "4-7" in client_type or "4-8" in client_type or "-5" in client_type
65
+ ): # e.g., claude-opus-4-7
66
+ from .claude5 import Claude5Client
58
67
 
59
- return Claude4_8Client(model=model, api_key=api_key, base_url=base_url)
68
+ return Claude5Client(model=model, api_key=api_key, base_url=base_url)
60
69
  elif "claude" in client_type and "4-6" in client_type: # e.g., claude-sonnet-4-6
61
70
  from .claude4_6 import Claude4_6Client
62
71
 
@@ -65,10 +74,18 @@ class AutoLLMClient(LLMClient):
65
74
  from .gpt5_5 import GPT5_5Client
66
75
 
67
76
  return GPT5_5Client(model=model, api_key=api_key, base_url=base_url)
77
+ elif "glm-5.2" in client_type:
78
+ from .glm5_2 import GLM5_2Client
79
+
80
+ return GLM5_2Client(model=model, api_key=api_key, base_url=base_url)
68
81
  elif "glm-5" in client_type or "glm-5.1" in client_type:
69
82
  from .glm5_1 import GLM5_1Client
70
83
 
71
84
  return GLM5_1Client(model=model, api_key=api_key, base_url=base_url)
85
+ elif "kimi-k3" in client_type:
86
+ from .kimi_k3 import KimiK3Client
87
+
88
+ return KimiK3Client(model=model, api_key=api_key, base_url=base_url)
72
89
  elif "kimi-k2.5" in client_type or "kimi-k2.6" in client_type:
73
90
  from .kimi_k2_6 import KimiK2_6Client
74
91
 
@@ -81,14 +98,14 @@ class AutoLLMClient(LLMClient):
81
98
  from .openai_embedding import OpenaiEmbeddingClient
82
99
 
83
100
  return OpenaiEmbeddingClient(model=model, api_key=api_key, base_url=base_url)
84
- elif "openai" in client_type:
101
+ elif "openai" in client_type and "embedding" not in client_type:
85
102
  from .openai import OpenaiClient
86
103
 
87
104
  return OpenaiClient(model=model, api_key=api_key, base_url=base_url)
88
105
  else:
89
106
  raise ValueError(
90
107
  f"{client_type} is not supported. "
91
- "Supported client types: gemini-3, claude-4-8, claude-4-7, claude-4-6, gpt-5.5, gpt-5.4, glm-5.1, kimi-k2.6, kimi-k2.5, deepseek-v4, openai-embedding, openai."
108
+ "Supported client types: gemini-3.6, gemini-3, claude-5, claude-4-8, claude-4-7, claude-4-6, gpt-5.5, gpt-5.4, glm-5.2, glm-5.1, kimi-k3, kimi-k2.6, kimi-k2.5, deepseek-v4, openai-embedding, openai."
92
109
  )
93
110
 
94
111
  def transform_uni_config_to_model_config(self, config: UniConfig) -> Any:
@@ -19,6 +19,7 @@ from contextlib import suppress
19
19
  from typing import Any, AsyncIterator
20
20
 
21
21
  from .abort_signal import AbortSignal
22
+ from .errors import EmptyResponseError
22
23
  from .types import (
23
24
  ContentItem,
24
25
  FinishReason,
@@ -100,28 +101,40 @@ class LLMClient(ABC):
100
101
  for event in events:
101
102
  # Merge content_items from all events
102
103
  for item in event["content_items"]:
104
+ last_fidelity = (content_items[-1].get("fidelity") or {}) if content_items else {}
105
+ item_fidelity = item.get("fidelity") or {}
103
106
  if item["type"] == "text":
107
+ # a delta announcing a different phase starts a new item; same-phase and
108
+ # phaseless deltas merge until a signature finishes the item
104
109
  if (
105
110
  content_items
106
111
  and content_items[-1]["type"] == "text"
107
- and content_items[-1].get("signature") is None # no signature yet
108
- and item.get("phase") is None # no new phase
112
+ and last_fidelity.get("signature") is None # not finished by a signature yet
113
+ and (
114
+ item_fidelity.get("phase") is None # phaseless deltas continue the item
115
+ or item_fidelity.get("phase") == last_fidelity.get("phase") # same phase merges
116
+ )
109
117
  ):
110
118
  content_items[-1]["text"] += item["text"]
111
- if "signature" in item: # finish the current item if signature is not None
112
- content_items[-1]["signature"] = item["signature"]
113
- elif item["text"] or item.get("phase") is not None: # text or new phase starts an item
119
+ if item_fidelity: # a signature finishes the current item
120
+ content_items[-1]["fidelity"] = {**last_fidelity, **item_fidelity}
121
+ elif item["text"] or item_fidelity.get("phase") is not None: # text or new phase starts an item
114
122
  content_items.append(item.copy())
115
123
  elif item["type"] == "thinking":
124
+ # a new item starts only when the open item's fidelity is non-empty and
125
+ # differs from the incoming delta's; everything else merges into it
116
126
  if (
117
127
  content_items
118
128
  and content_items[-1]["type"] == "thinking"
119
- and content_items[-1].get("signature") is None # no signature yet
129
+ and (
130
+ not last_fidelity # not finished by fidelity yet
131
+ or last_fidelity == item_fidelity # a run of equal fidelity is one item
132
+ )
120
133
  ):
121
134
  content_items[-1]["thinking"] += item["thinking"]
122
- if "signature" in item: # finish the current item if signature is not None
123
- content_items[-1]["signature"] = item["signature"]
124
- elif item["thinking"] or item.get("signature"): # omit empty thinking items
135
+ if item_fidelity: # fidelity finishes the current item
136
+ content_items[-1]["fidelity"] = item_fidelity
137
+ elif item["thinking"] or item_fidelity: # omit empty thinking items
125
138
  content_items.append(item.copy())
126
139
  elif item["type"] == "partial_tool_call":
127
140
  # Skip partial_tool_call items - they should already be converted to tool_call
@@ -244,6 +257,7 @@ class LLMClient(ABC):
244
257
  await stream.aclose()
245
258
 
246
259
  self._validate_last_event(last_event)
260
+ self._validate_non_thinking_output(events)
247
261
 
248
262
  # Save history to file if trace_id is specified
249
263
  if config.get("trace_id") and events:
@@ -312,6 +326,25 @@ class LLMClient(ABC):
312
326
  if last_event["finish_reason"] is None:
313
327
  raise ValueError(f"Last event must carry finish_reason, got: {last_event}")
314
328
 
329
+ def _validate_non_thinking_output(self, events: list[UniEvent]) -> None:
330
+ """Validate that the completed response carries content other than thinking.
331
+
332
+ Replaying a thinking-only assistant message on the next turn fails with a 400
333
+ error, so the response is rejected as soon as the stream completes.
334
+
335
+ Args:
336
+ events: All events yielded by streaming_response
337
+
338
+ Raises:
339
+ EmptyResponseError: If every content item in the response is thinking
340
+ """
341
+ thinking_only = all(
342
+ item["type"] in ("thinking", "inline_thinking") for event in events for item in event["content_items"]
343
+ )
344
+ if thinking_only:
345
+ finish_reason = events[-1]["finish_reason"] if events else None
346
+ raise EmptyResponseError(self.__class__.__name__, finish_reason)
347
+
315
348
  def clear_history(self) -> None:
316
349
  """Clear the message history."""
317
350
  self._history.clear()
@@ -13,7 +13,6 @@
13
13
  # limitations under the License.
14
14
 
15
15
  import base64
16
- import json
17
16
  import mimetypes
18
17
  import os
19
18
  import re
@@ -24,6 +23,7 @@ from anthropic import AsyncAnthropic, AsyncAnthropicBedrock
24
23
  from anthropic.types.beta import BetaMessageParam, BetaRawMessageStreamEvent
25
24
 
26
25
  from ..base_client import LLMClient
26
+ from ..errors import UnsupportedParameterError, parse_tool_call_arguments
27
27
  from ..types import (
28
28
  EventType,
29
29
  FinishReason,
@@ -118,7 +118,9 @@ class Claude4_6Client(LLMClient):
118
118
  """Convert ToolChoice to Claude's tool_choice format."""
119
119
  if isinstance(tool_choice, list):
120
120
  if len(tool_choice) > 1:
121
- raise ValueError("Claude supports only one tool choice.")
121
+ raise UnsupportedParameterError(
122
+ self.__class__.__name__, "tool_choice", "Claude supports only one tool choice."
123
+ )
122
124
 
123
125
  return {"type": "any", "name": tool_choice[0]}
124
126
  elif tool_choice == "none":
@@ -204,10 +206,14 @@ class Claude4_6Client(LLMClient):
204
206
  content_blocks.append(await self._convert_image_url_to_source(item["image_url"]))
205
207
  elif item["type"] == "thinking":
206
208
  if item["thinking"] == REDACTED_THINKING:
207
- content_blocks.append({"type": "redacted_thinking", "data": item["signature"]})
209
+ content_blocks.append({"type": "redacted_thinking", "data": item["fidelity"]["signature"]})
208
210
  else:
209
211
  content_blocks.append(
210
- {"type": "thinking", "thinking": item["thinking"], "signature": item["signature"]}
212
+ {
213
+ "type": "thinking",
214
+ "thinking": item["thinking"],
215
+ "signature": item["fidelity"]["signature"],
216
+ }
211
217
  )
212
218
  elif item["type"] == "tool_call":
213
219
  content_blocks.append(
@@ -263,7 +269,9 @@ class Claude4_6Client(LLMClient):
263
269
  {"type": "partial_tool_call", "name": block.name, "arguments": "", "tool_call_id": block.id}
264
270
  )
265
271
  elif block.type == "redacted_thinking":
266
- content_items.append({"type": "thinking", "thinking": REDACTED_THINKING, "signature": block.data})
272
+ content_items.append(
273
+ {"type": "thinking", "thinking": REDACTED_THINKING, "fidelity": {"signature": block.data}}
274
+ )
267
275
 
268
276
  elif claude_event_type == "content_block_delta":
269
277
  event_type = "delta"
@@ -277,7 +285,7 @@ class Claude4_6Client(LLMClient):
277
285
  {"type": "partial_tool_call", "name": "", "arguments": delta.partial_json, "tool_call_id": ""}
278
286
  )
279
287
  elif delta.type == "signature_delta":
280
- content_items.append({"type": "thinking", "thinking": "", "signature": delta.signature})
288
+ content_items.append({"type": "thinking", "thinking": "", "fidelity": {"signature": delta.signature}})
281
289
 
282
290
  elif claude_event_type == "content_block_stop":
283
291
  event_type = "stop"
@@ -403,7 +411,12 @@ class Claude4_6Client(LLMClient):
403
411
  {
404
412
  "type": "tool_call",
405
413
  "name": partial_tool_call["name"],
406
- "arguments": json.loads(partial_tool_call["arguments"]),
414
+ "arguments": parse_tool_call_arguments(
415
+ partial_tool_call["arguments"],
416
+ self.__class__.__name__,
417
+ partial_tool_call["name"],
418
+ partial_tool_call["tool_call_id"],
419
+ ),
407
420
  "tool_call_id": partial_tool_call["tool_call_id"],
408
421
  }
409
422
  ],
@@ -0,0 +1,18 @@
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
+ from .client import Claude5Client
16
+
17
+
18
+ __all__ = ["Claude5Client"]
@@ -13,7 +13,6 @@
13
13
  # limitations under the License.
14
14
 
15
15
  import base64
16
- import json
17
16
  import mimetypes
18
17
  import os
19
18
  import re
@@ -24,6 +23,7 @@ from anthropic import AsyncAnthropic, AsyncAnthropicBedrock
24
23
  from anthropic.types.beta import BetaMessageParam, BetaRawMessageStreamEvent
25
24
 
26
25
  from ..base_client import LLMClient
26
+ from ..errors import UnsupportedParameterError, parse_tool_call_arguments
27
27
  from ..types import (
28
28
  EventType,
29
29
  FinishReason,
@@ -41,11 +41,11 @@ from ..types import (
41
41
  REDACTED_THINKING = "_REDACTED_THINKING"
42
42
 
43
43
 
44
- class Claude4_8Client(LLMClient):
45
- """Claude 4.8-specific LLM client implementation."""
44
+ class Claude5Client(LLMClient):
45
+ """Claude 5-specific LLM client implementation."""
46
46
 
47
47
  def __init__(self, model: str, api_key: str | None = None, base_url: str | None = None):
48
- """Initialize Claude 4.8 client with model and API key."""
48
+ """Initialize Claude 5 client with model and API key."""
49
49
  self._model = model
50
50
  api_key = api_key or os.getenv("ANTHROPIC_API_KEY")
51
51
  base_url = base_url or os.getenv("ANTHROPIC_BASE_URL")
@@ -118,7 +118,9 @@ class Claude4_8Client(LLMClient):
118
118
  """Convert ToolChoice to Claude's tool_choice format."""
119
119
  if isinstance(tool_choice, list):
120
120
  if len(tool_choice) > 1:
121
- raise ValueError("Claude supports only one tool choice.")
121
+ raise UnsupportedParameterError(
122
+ self.__class__.__name__, "tool_choice", "Claude supports only one tool choice."
123
+ )
122
124
 
123
125
  return {"type": "any", "name": tool_choice[0]}
124
126
  elif tool_choice == "none":
@@ -149,7 +151,9 @@ class Claude4_8Client(LLMClient):
149
151
  claude_config["max_tokens"] = 64000 # Claude requires max_tokens to be specified
150
152
 
151
153
  if config.get("temperature") is not None and config["temperature"] != 1.0:
152
- raise ValueError("Claude 4.8 does not support setting temperature.")
154
+ raise UnsupportedParameterError(
155
+ self.__class__.__name__, "temperature", "Claude 4.8 does not support setting temperature."
156
+ )
153
157
 
154
158
  if config.get("thinking_level") is not None:
155
159
  claude_config.update(self._convert_thinking_level_to_thinking_config(config["thinking_level"]))
@@ -204,10 +208,14 @@ class Claude4_8Client(LLMClient):
204
208
  content_blocks.append(await self._convert_image_url_to_source(item["image_url"]))
205
209
  elif item["type"] == "thinking":
206
210
  if item["thinking"] == REDACTED_THINKING:
207
- content_blocks.append({"type": "redacted_thinking", "data": item["signature"]})
211
+ content_blocks.append({"type": "redacted_thinking", "data": item["fidelity"]["signature"]})
208
212
  else:
209
213
  content_blocks.append(
210
- {"type": "thinking", "thinking": item["thinking"], "signature": item["signature"]}
214
+ {
215
+ "type": "thinking",
216
+ "thinking": item["thinking"],
217
+ "signature": item["fidelity"]["signature"],
218
+ }
211
219
  )
212
220
  elif item["type"] == "tool_call":
213
221
  content_blocks.append(
@@ -263,7 +271,9 @@ class Claude4_8Client(LLMClient):
263
271
  {"type": "partial_tool_call", "name": block.name, "arguments": "", "tool_call_id": block.id}
264
272
  )
265
273
  elif block.type == "redacted_thinking":
266
- content_items.append({"type": "thinking", "thinking": REDACTED_THINKING, "signature": block.data})
274
+ content_items.append(
275
+ {"type": "thinking", "thinking": REDACTED_THINKING, "fidelity": {"signature": block.data}}
276
+ )
267
277
 
268
278
  elif claude_event_type == "content_block_delta":
269
279
  event_type = "delta"
@@ -277,7 +287,7 @@ class Claude4_8Client(LLMClient):
277
287
  {"type": "partial_tool_call", "name": "", "arguments": delta.partial_json, "tool_call_id": ""}
278
288
  )
279
289
  elif delta.type == "signature_delta":
280
- content_items.append({"type": "thinking", "thinking": "", "signature": delta.signature})
290
+ content_items.append({"type": "thinking", "thinking": "", "fidelity": {"signature": delta.signature}})
281
291
 
282
292
  elif claude_event_type == "content_block_stop":
283
293
  event_type = "stop"
@@ -403,7 +413,12 @@ class Claude4_8Client(LLMClient):
403
413
  {
404
414
  "type": "tool_call",
405
415
  "name": partial_tool_call["name"],
406
- "arguments": json.loads(partial_tool_call["arguments"]),
416
+ "arguments": parse_tool_call_arguments(
417
+ partial_tool_call["arguments"],
418
+ self.__class__.__name__,
419
+ partial_tool_call["name"],
420
+ partial_tool_call["tool_call_id"],
421
+ ),
407
422
  "tool_call_id": partial_tool_call["tool_call_id"],
408
423
  }
409
424
  ],
@@ -20,6 +20,7 @@ from openai import AsyncOpenAI
20
20
  from openai.types.chat import ChatCompletionChunk, ChatCompletionMessageParam
21
21
 
22
22
  from ..base_client import LLMClient
23
+ from ..errors import UnsupportedParameterError, parse_tool_call_arguments
23
24
  from ..types import (
24
25
  EventType,
25
26
  FinishReason,
@@ -71,7 +72,9 @@ class DeepSeekV4Client(LLMClient):
71
72
  """Convert ToolChoice to DeepSeek's OpenAI-compatible tool_choice format."""
72
73
  if tool_choice in ["auto", "none"]:
73
74
  return tool_choice
74
- raise ValueError("DeepSeek V4 only supports 'auto' and 'none' for tool_choice.")
75
+ raise UnsupportedParameterError(
76
+ self.__class__.__name__, "tool_choice", "DeepSeek V4 only supports 'auto' and 'none' for tool_choice."
77
+ )
75
78
 
76
79
  def transform_uni_config_to_model_config(self, config: UniConfig) -> dict[str, Any]:
77
80
  """
@@ -89,7 +92,9 @@ class DeepSeekV4Client(LLMClient):
89
92
  deepseek_config["max_tokens"] = config["max_tokens"]
90
93
 
91
94
  if config.get("temperature") is not None and config["temperature"] != 1.0:
92
- raise ValueError("DeepSeek V4 does not support setting temperature.")
95
+ raise UnsupportedParameterError(
96
+ self.__class__.__name__, "temperature", "DeepSeek V4 does not support setting temperature."
97
+ )
93
98
 
94
99
  thinking_level = config.get("thinking_level")
95
100
  if thinking_level is not None:
@@ -105,7 +110,9 @@ class DeepSeekV4Client(LLMClient):
105
110
  deepseek_config["tool_choice"] = self._convert_tool_choice(config["tool_choice"])
106
111
 
107
112
  if config.get("prompt_caching") is not None and config["prompt_caching"] != PromptCaching.ENABLE:
108
- raise ValueError("prompt_caching must be ENABLE for DeepSeek.")
113
+ raise UnsupportedParameterError(
114
+ self.__class__.__name__, "prompt_caching", "prompt_caching must be ENABLE for DeepSeek."
115
+ )
109
116
 
110
117
  return deepseek_config
111
118
 
@@ -200,7 +207,15 @@ class DeepSeekV4Client(LLMClient):
200
207
 
201
208
  if getattr(delta, "reasoning_content", None):
202
209
  event_type = "delta"
203
- content_items.append({"type": "thinking", "thinking": getattr(delta, "reasoning_content")})
210
+ # record the wire field so a replay through another OpenAI-compatible
211
+ # client reproduces the exact field DeepSeek produced
212
+ content_items.append(
213
+ {
214
+ "type": "thinking",
215
+ "thinking": getattr(delta, "reasoning_content"),
216
+ "fidelity": {"reasoning_field": "reasoning_content"},
217
+ }
218
+ )
204
219
 
205
220
  if delta.content:
206
221
  event_type = "delta"
@@ -289,7 +304,12 @@ class DeepSeekV4Client(LLMClient):
289
304
  {
290
305
  "type": "tool_call",
291
306
  "name": partial_tool_call["name"],
292
- "arguments": json.loads(partial_tool_call["arguments"] or "{}"),
307
+ "arguments": parse_tool_call_arguments(
308
+ partial_tool_call["arguments"],
309
+ self.__class__.__name__,
310
+ partial_tool_call["name"],
311
+ partial_tool_call["tool_call_id"],
312
+ ),
293
313
  "tool_call_id": partial_tool_call["tool_call_id"],
294
314
  }
295
315
  ],
@@ -317,7 +337,12 @@ class DeepSeekV4Client(LLMClient):
317
337
  {
318
338
  "type": "tool_call",
319
339
  "name": partial_tool_call["name"],
320
- "arguments": json.loads(partial_tool_call["arguments"] or "{}"),
340
+ "arguments": parse_tool_call_arguments(
341
+ partial_tool_call["arguments"],
342
+ self.__class__.__name__,
343
+ partial_tool_call["name"],
344
+ partial_tool_call["tool_call_id"],
345
+ ),
321
346
  "tool_call_id": partial_tool_call["tool_call_id"],
322
347
  }
323
348
  ],
@@ -0,0 +1,89 @@
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
+ from typing import Any
17
+
18
+
19
+ def _preview_tool_call_arguments(raw: str) -> str:
20
+ max_length = 160
21
+ if len(raw) <= max_length:
22
+ return raw
23
+
24
+ edge_length = 72
25
+ return f"{raw[:edge_length]}...[truncated]...{raw[-edge_length:]}"
26
+
27
+
28
+ class AgentHubError(ValueError):
29
+ """Base class for errors raised by AgentHub clients."""
30
+
31
+
32
+ class UnsupportedParameterError(AgentHubError):
33
+ """Raised when a UniConfig parameter value is not supported by the target model client.
34
+
35
+ Thinking levels never raise this by design: every client maps each ThinkingLevel
36
+ onto the closest level the model supports. Parameters such as temperature and
37
+ tool_choice may reject unsupported values with this error.
38
+ """
39
+
40
+ def __init__(self, client: str, parameter: str, message: str) -> None:
41
+ self.client = client
42
+ self.parameter = parameter
43
+ super().__init__(message)
44
+
45
+
46
+ class EmptyResponseError(AgentHubError):
47
+ """Raised when a completed response carries no non-thinking content and no tool calls.
48
+
49
+ Models occasionally finish a turn with thinking output only (reasoning models in
50
+ particular); replaying such an assistant message on the next turn fails with a 400
51
+ error, so the response is rejected as soon as the stream completes.
52
+ """
53
+
54
+ def __init__(self, client: str, finish_reason: str | None) -> None:
55
+ self.client = client
56
+ self.finish_reason = finish_reason
57
+ super().__init__(f"{client} returned no content other than thinking (finish_reason={finish_reason!r}).")
58
+
59
+
60
+ class ToolCallArgumentParseError(AgentHubError):
61
+ def __init__(self, client: str, tool_name: str, tool_call_id: str, raw_arguments: str, reason: str) -> None:
62
+ self.client = client
63
+ self.tool_name = tool_name
64
+ self.tool_call_id = tool_call_id
65
+ self.raw_arguments_length = len(raw_arguments)
66
+ self.raw_arguments_preview = _preview_tool_call_arguments(raw_arguments)
67
+ super().__init__(
68
+ f'Invalid streamed tool call arguments from {client} for tool "{tool_name}" '
69
+ f'(tool_call_id="{tool_call_id}", length={self.raw_arguments_length}, '
70
+ f"preview={self.raw_arguments_preview!r}): {reason}"
71
+ )
72
+
73
+
74
+ def parse_tool_call_arguments(
75
+ raw_arguments: str | None,
76
+ client: str,
77
+ tool_name: str,
78
+ tool_call_id: str,
79
+ ) -> dict[str, Any]:
80
+ raw = raw_arguments or "{}"
81
+ try:
82
+ parsed = json.loads(raw)
83
+ except (TypeError, ValueError) as exc:
84
+ raise ToolCallArgumentParseError(client, tool_name, tool_call_id, raw, str(exc)) from exc
85
+
86
+ if not isinstance(parsed, dict):
87
+ raise ToolCallArgumentParseError(client, tool_name, tool_call_id, raw, "Expected a JSON object.")
88
+
89
+ return parsed