agenthub-python 0.4.4__tar.gz → 0.4.6__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.4 → agenthub_python-0.4.6}/PKG-INFO +2 -1
  2. {agenthub_python-0.4.4 → agenthub_python-0.4.6}/agenthub/__init__.py +8 -1
  3. {agenthub_python-0.4.4 → agenthub_python-0.4.6}/agenthub/ant_messages/client.py +26 -4
  4. {agenthub_python-0.4.4 → agenthub_python-0.4.6}/agenthub/auto_client.py +69 -18
  5. {agenthub_python-0.4.4 → agenthub_python-0.4.6}/agenthub/base_client.py +29 -0
  6. {agenthub_python-0.4.4 → agenthub_python-0.4.6}/agenthub/claude5/client.py +34 -5
  7. agenthub_python-0.4.6/agenthub/deepseek_v4/client.py +371 -0
  8. {agenthub_python-0.4.4 → agenthub_python-0.4.6}/agenthub/errors.py +14 -0
  9. {agenthub_python-0.4.4 → agenthub_python-0.4.6}/agenthub/gemini3_7/client.py +62 -32
  10. {agenthub_python-0.4.4 → agenthub_python-0.4.6}/agenthub/glm5_3/client.py +17 -2
  11. {agenthub_python-0.4.4 → agenthub_python-0.4.6}/agenthub/gpt5_6/client.py +34 -3
  12. {agenthub_python-0.4.4 → agenthub_python-0.4.6}/agenthub/integration/playground.py +441 -70
  13. {agenthub_python-0.4.4 → agenthub_python-0.4.6}/agenthub/kimi_k3/client.py +17 -2
  14. {agenthub_python-0.4.4 → agenthub_python-0.4.6}/agenthub/minimax_m3/client.py +39 -20
  15. {agenthub_python-0.4.4 → agenthub_python-0.4.6}/agenthub/openai_chat/client.py +17 -2
  16. {agenthub_python-0.4.4 → agenthub_python-0.4.6}/agenthub/openai_embedding/client.py +17 -2
  17. {agenthub_python-0.4.4 → agenthub_python-0.4.6}/agenthub/openai_responses/client.py +38 -17
  18. {agenthub_python-0.4.4 → agenthub_python-0.4.6}/agenthub/registry.py +63 -25
  19. {agenthub_python-0.4.4 → agenthub_python-0.4.6}/agenthub/types.py +3 -0
  20. {agenthub_python-0.4.4 → agenthub_python-0.4.6}/agenthub/utils.py +16 -0
  21. {agenthub_python-0.4.4 → agenthub_python-0.4.6}/pyproject.toml +2 -1
  22. {agenthub_python-0.4.4 → agenthub_python-0.4.6}/pyproject.toml.orig +2 -2
  23. agenthub_python-0.4.4/agenthub/deepseek_v4/client.py +0 -376
  24. {agenthub_python-0.4.4 → agenthub_python-0.4.6}/README.md +0 -0
  25. {agenthub_python-0.4.4 → agenthub_python-0.4.6}/agenthub/abort_signal.py +0 -0
  26. {agenthub_python-0.4.4 → agenthub_python-0.4.6}/agenthub/ant_messages/__init__.py +0 -0
  27. {agenthub_python-0.4.4 → agenthub_python-0.4.6}/agenthub/claude5/__init__.py +0 -0
  28. {agenthub_python-0.4.4 → agenthub_python-0.4.6}/agenthub/deepseek_v4/__init__.py +0 -0
  29. {agenthub_python-0.4.4 → agenthub_python-0.4.6}/agenthub/gemini3_7/__init__.py +0 -0
  30. {agenthub_python-0.4.4 → agenthub_python-0.4.6}/agenthub/glm5_3/__init__.py +0 -0
  31. {agenthub_python-0.4.4 → agenthub_python-0.4.6}/agenthub/gpt5_6/__init__.py +0 -0
  32. {agenthub_python-0.4.4 → agenthub_python-0.4.6}/agenthub/integration/__init__.py +0 -0
  33. {agenthub_python-0.4.4 → agenthub_python-0.4.6}/agenthub/integration/tracer.py +0 -0
  34. {agenthub_python-0.4.4 → agenthub_python-0.4.6}/agenthub/kimi_k3/__init__.py +0 -0
  35. {agenthub_python-0.4.4 → agenthub_python-0.4.6}/agenthub/minimax_m3/__init__.py +0 -0
  36. {agenthub_python-0.4.4 → agenthub_python-0.4.6}/agenthub/openai_chat/__init__.py +0 -0
  37. {agenthub_python-0.4.4 → agenthub_python-0.4.6}/agenthub/openai_embedding/__init__.py +0 -0
  38. {agenthub_python-0.4.4 → agenthub_python-0.4.6}/agenthub/openai_responses/__init__.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: agenthub-python
3
- Version: 0.4.4
3
+ Version: 0.4.6
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
@@ -14,6 +14,7 @@ Requires-Dist: httpx[socks] ; extra == 'dev'
14
14
  Requires-Dist: pytest>=8.4.2 ; extra == 'dev'
15
15
  Requires-Dist: pytest-asyncio>=0.23.0 ; extra == 'dev'
16
16
  Requires-Dist: pytest-xdist>=3.6.0 ; extra == 'dev'
17
+ Requires-Dist: pytest-rerunfailures>=15.0 ; extra == 'dev'
17
18
  Requires-Dist: ruff>=0.14.3 ; extra == 'dev'
18
19
  Requires-Dist: pillow>=10.0.0 ; extra == 'dev'
19
20
  Requires-Python: >=3.11
@@ -13,7 +13,13 @@
13
13
  # limitations under the License.
14
14
 
15
15
  from .auto_client import AutoLLMClient
16
- from .errors import AgentHubError, EmptyResponseError, ToolCallArgumentParseError, UnsupportedParameterError
16
+ from .errors import (
17
+ AgentHubError,
18
+ EmptyResponseError,
19
+ ToolCallArgumentParseError,
20
+ UnsupportedOperationError,
21
+ UnsupportedParameterError,
22
+ )
17
23
  from .registry import Currency, Modality, ModelPricing, SupportedModel, list_supported_models
18
24
  from .types import PromptCaching, ThinkingLevel
19
25
 
@@ -29,6 +35,7 @@ __all__ = [
29
35
  "SupportedModel",
30
36
  "ThinkingLevel",
31
37
  "ToolCallArgumentParseError",
38
+ "UnsupportedOperationError",
32
39
  "UnsupportedParameterError",
33
40
  "list_supported_models",
34
41
  ]
@@ -33,7 +33,7 @@ from ..types import (
33
33
  UniMessage,
34
34
  UsageMetadata,
35
35
  )
36
- from ..utils import fix_openrouter_usage_metadata
36
+ from ..utils import fix_openrouter_usage_metadata, is_debug_enabled
37
37
 
38
38
 
39
39
  REDACTED_THINKING = "_REDACTED_THINKING"
@@ -42,14 +42,22 @@ REDACTED_THINKING = "_REDACTED_THINKING"
42
42
  class AntMessagesClient(LLMClient):
43
43
  """Anthropic Messages-compatible client implementation."""
44
44
 
45
- def __init__(self, model: str, api_key: str | None = None, base_url: str | None = None):
45
+ def __init__(
46
+ self,
47
+ model: str,
48
+ api_key: str | None = None,
49
+ base_url: str | None = None,
50
+ default_headers: dict[str, str] | None = None,
51
+ ):
46
52
  """Initialize Anthropic Messages-compatible client with model, API key, and base URL."""
47
53
  self._model = model
48
54
  api_key = api_key or os.getenv("ANTHROPIC_API_KEY")
49
55
  base_url = base_url or os.getenv("ANTHROPIC_BASE_URL")
50
56
  # send the credential through both header conventions: Anthropic and DeepSeek read
51
57
  # x-api-key while gateways such as OpenRouter and Z.AI read Authorization: Bearer
52
- self._client = AsyncAnthropic(api_key=api_key, auth_token=api_key, base_url=base_url)
58
+ self._client = AsyncAnthropic(
59
+ api_key=api_key, auth_token=api_key, base_url=base_url, default_headers=default_headers
60
+ )
53
61
  self._history: list[UniMessage] = []
54
62
 
55
63
  def _convert_image_url_to_source(self, url: str) -> dict[str, Any]:
@@ -306,9 +314,14 @@ class AntMessagesClient(LLMClient):
306
314
  # gateways that relabel it onto another event
307
315
  event_type = "unused"
308
316
 
309
- else:
317
+ elif is_debug_enabled():
310
318
  raise ValueError(f"Unknown output: {model_output}")
311
319
 
320
+ else:
321
+ # a gateway injects its own events (heartbeats, cost tickers) into the stream, and
322
+ # killing a long generation over one costs more than dropping it
323
+ event_type = "unused"
324
+
312
325
  return {
313
326
  "role": "assistant",
314
327
  "event_type": event_type,
@@ -412,3 +425,12 @@ class AntMessagesClient(LLMClient):
412
425
  "finish_reason": event["finish_reason"],
413
426
  }
414
427
  partial_usage = {}
428
+
429
+ async def list_models(self) -> list[str]:
430
+ """
431
+ List the model ids the configured endpoint serves.
432
+
433
+ Returns:
434
+ list[str]: The model ids, in the order the endpoint returned them.
435
+ """
436
+ return [model.id async for model in self._client.models.list()]
@@ -20,6 +20,10 @@ from .base_client import LLMClient
20
20
  from .types import UniConfig, UniEvent, UniMessage
21
21
 
22
22
 
23
+ # The generic protocol clients are named explicitly rather than deduced from a model id.
24
+ _PROTOCOL_CLIENT_TYPES = ("openai-chat", "openai-responses", "ant-messages", "openai-embedding")
25
+
26
+
23
27
  class AutoLLMClient(LLMClient):
24
28
  """
25
29
  Auto-routing LLM client that dispatches to appropriate model-specific client.
@@ -29,7 +33,12 @@ class AutoLLMClient(LLMClient):
29
33
  """
30
34
 
31
35
  def __init__(
32
- self, model: str, api_key: str | None = None, base_url: str | None = None, client_type: str | None = None
36
+ self,
37
+ model: str,
38
+ api_key: str | None = None,
39
+ base_url: str | None = None,
40
+ client_type: str | None = None,
41
+ default_headers: dict[str, str] | None = None,
33
42
  ):
34
43
  """
35
44
  Initialize AutoLLMClient with a specific model.
@@ -39,14 +48,22 @@ class AutoLLMClient(LLMClient):
39
48
  api_key: Optional API key
40
49
  base_url: Optional base URL for API requests
41
50
  client_type: Optional client type override
51
+ default_headers: Optional headers sent with every request, for endpoints that demand their own
42
52
  """
43
- self._client = self._create_client_for_model(model, api_key, base_url, client_type)
53
+ self._client_type = (client_type or os.getenv("CLIENT_TYPE") or model).lower()
54
+ self._client = self._create_client_for_model(model, api_key, base_url, self._client_type, default_headers)
44
55
 
45
- def _create_client_for_model(
46
- self, model: str, api_key: str | None = None, base_url: str | None = None, client_type: str | None = None
47
- ) -> LLMClient:
48
- """Create the appropriate client for the given model."""
49
- client_type = (client_type or os.getenv("CLIENT_TYPE") or model).lower()
56
+ @staticmethod
57
+ def _client_class_for_model(client_type: str) -> type[LLMClient] | None:
58
+ """
59
+ Resolve which client a resolved (lowercased) client type routes to.
60
+
61
+ Args:
62
+ client_type: The resolved client type, which is the model id when none was given.
63
+
64
+ Returns:
65
+ type[LLMClient] | None: The client class, or None when no client claims the type.
66
+ """
50
67
  # every Gemini generation shares the unified client ("gemini-3" also matches the
51
68
  # gemini-3.7/gemini-3.6/gemini-3.5-flash-lite client types)
52
69
  if any(
@@ -54,51 +71,64 @@ class AutoLLMClient(LLMClient):
54
71
  ): # e.g., gemini-3.7-flash, gemini-3-flash-preview, gemini-embedding-2
55
72
  from .gemini3_7 import Gemini3_7Client
56
73
 
57
- return Gemini3_7Client(model=model, api_key=api_key, base_url=base_url)
74
+ return Gemini3_7Client
58
75
  elif "claude" in client_type and (
59
76
  "4-6" in client_type or "4-7" in client_type or "4-8" in client_type or "-5" in client_type
60
77
  ): # the whole Claude 4.6+ series shares the unified client, e.g., claude-sonnet-4-6
61
78
  from .claude5 import Claude5Client
62
79
 
63
- return Claude5Client(model=model, api_key=api_key, base_url=base_url)
80
+ return Claude5Client
64
81
  elif "gpt-5.4" in client_type or "gpt-5.5" in client_type or "gpt-5.6" in client_type: # e.g., gpt-5.6
65
82
  from .gpt5_6 import GPT5_6Client
66
83
 
67
- return GPT5_6Client(model=model, api_key=api_key, base_url=base_url)
84
+ return GPT5_6Client
68
85
  elif "glm-5" in client_type: # the whole GLM series shares the unified client
69
86
  from .glm5_3 import GLM5_3Client
70
87
 
71
- return GLM5_3Client(model=model, api_key=api_key, base_url=base_url)
88
+ return GLM5_3Client
72
89
  elif "kimi-k3" in client_type or "kimi-k2.5" in client_type or "kimi-k2.6" in client_type:
73
90
  # the whole Kimi K2.5+ series shares the unified client
74
91
  from .kimi_k3 import KimiK3Client
75
92
 
76
- return KimiK3Client(model=model, api_key=api_key, base_url=base_url)
93
+ return KimiK3Client
77
94
  elif client_type == "minimax-m3":
78
95
  from .minimax_m3 import MiniMaxM3Client
79
96
 
80
- return MiniMaxM3Client(model=model, api_key=api_key, base_url=base_url)
97
+ return MiniMaxM3Client
81
98
  elif "deepseek-v4" in client_type:
82
99
  from .deepseek_v4 import DeepSeekV4Client
83
100
 
84
- return DeepSeekV4Client(model=model, api_key=api_key, base_url=base_url)
101
+ return DeepSeekV4Client
85
102
  elif "ant-messages" in client_type:
86
103
  from .ant_messages import AntMessagesClient
87
104
 
88
- return AntMessagesClient(model=model, api_key=api_key, base_url=base_url)
105
+ return AntMessagesClient
89
106
  elif "openai-responses" in client_type:
90
107
  from .openai_responses import OpenaiResponsesClient
91
108
 
92
- return OpenaiResponsesClient(model=model, api_key=api_key, base_url=base_url)
109
+ return OpenaiResponsesClient
93
110
  elif "openai" in client_type and "embedding" in client_type:
94
111
  from .openai_embedding import OpenaiEmbeddingClient
95
112
 
96
- return OpenaiEmbeddingClient(model=model, api_key=api_key, base_url=base_url)
113
+ return OpenaiEmbeddingClient
97
114
  elif "openai" in client_type and "embedding" not in client_type: # openai-chat, plus bare "openai" as alias
98
115
  from .openai_chat import OpenaiChatClient
99
116
 
100
- return OpenaiChatClient(model=model, api_key=api_key, base_url=base_url)
117
+ return OpenaiChatClient
101
118
  else:
119
+ return None
120
+
121
+ def _create_client_for_model(
122
+ self,
123
+ model: str,
124
+ api_key: str | None = None,
125
+ base_url: str | None = None,
126
+ client_type: str | None = None,
127
+ default_headers: dict[str, str] | None = None,
128
+ ) -> LLMClient:
129
+ """Create the appropriate client for the given model."""
130
+ client_class = self._client_class_for_model(client_type or model.lower())
131
+ if client_class is None:
102
132
  raise ValueError(
103
133
  f"{client_type} is not supported. "
104
134
  "Supported client types: minimax-m3, gemini-3.7, gemini-3.6, gemini-3, "
@@ -107,6 +137,8 @@ class AutoLLMClient(LLMClient):
107
137
  "openai-embedding, ant-messages, openai-responses, openai-chat."
108
138
  )
109
139
 
140
+ return client_class(model=model, api_key=api_key, base_url=base_url, default_headers=default_headers)
141
+
110
142
  def transform_uni_config_to_model_config(self, config: UniConfig) -> Any:
111
143
  """Delegate to underlying client's transform_uni_config_to_model_config."""
112
144
  return self._client.transform_uni_config_to_model_config(config)
@@ -165,3 +197,22 @@ class AutoLLMClient(LLMClient):
165
197
  def set_history(self, history: list[UniMessage]) -> None:
166
198
  """Set history in the underlying client."""
167
199
  self._client.set_history(history)
200
+
201
+ async def list_models(self) -> list[str]:
202
+ """
203
+ List the model ids the endpoint serves that the routed client can be used for.
204
+
205
+ A protocol client is chosen explicitly and speaks for whatever the endpoint serves, so its
206
+ listing is returned whole. A client deduced from a model id serves only the ids that deduce
207
+ back to it, so a gateway fronting many vendors is filtered down to that client's own models.
208
+
209
+ Returns:
210
+ list[str]: The model ids, in the order the endpoint returned them.
211
+ """
212
+ model_ids = await self._client.list_models()
213
+ protocol_classes = {self._client_class_for_model(name) for name in _PROTOCOL_CLIENT_TYPES}
214
+ if type(self._client) in protocol_classes:
215
+ return model_ids
216
+
217
+ client_class = type(self._client)
218
+ return [model_id for model_id in model_ids if self._client_class_for_model(model_id.lower()) is client_class]
@@ -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
 
@@ -175,6 +187,15 @@ class LLMClient(ABC):
175
187
  """
176
188
  pass
177
189
 
190
+ @abstractmethod
191
+ async def list_models(self) -> list[str]:
192
+ """
193
+ List the model ids the configured endpoint serves.
194
+
195
+ Returns:
196
+ list[str]: The model ids, in the order the endpoint returned them.
197
+ """
198
+
178
199
  async def streaming_response(
179
200
  self,
180
201
  messages: list[UniMessage],
@@ -245,6 +266,14 @@ class LLMClient(ABC):
245
266
  finally:
246
267
  waiting_for_stream = False
247
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
+
248
277
  event["created_at"] = int(time.time() * 1000)
249
278
  last_event = event
250
279
  events.append(event)
@@ -23,7 +23,7 @@ from anthropic import AsyncAnthropic, AsyncAnthropicBedrock
23
23
  from anthropic.types.beta import BetaMessageParam, BetaRawMessageStreamEvent
24
24
 
25
25
  from ..base_client import LLMClient
26
- from ..errors import UnsupportedParameterError, parse_tool_call_arguments
26
+ from ..errors import UnsupportedOperationError, UnsupportedParameterError, parse_tool_call_arguments
27
27
  from ..types import (
28
28
  EventType,
29
29
  FinishReason,
@@ -36,6 +36,7 @@ from ..types import (
36
36
  UniMessage,
37
37
  UsageMetadata,
38
38
  )
39
+ from ..utils import is_debug_enabled
39
40
 
40
41
 
41
42
  REDACTED_THINKING = "_REDACTED_THINKING"
@@ -44,7 +45,13 @@ REDACTED_THINKING = "_REDACTED_THINKING"
44
45
  class Claude5Client(LLMClient):
45
46
  """Claude 5-specific LLM client implementation (also serves Claude 4.6 through 4.8)."""
46
47
 
47
- def __init__(self, model: str, api_key: str | None = None, base_url: str | None = None):
48
+ def __init__(
49
+ self,
50
+ model: str,
51
+ api_key: str | None = None,
52
+ base_url: str | None = None,
53
+ default_headers: dict[str, str] | None = None,
54
+ ):
48
55
  """Initialize Claude 5 client with model and API key."""
49
56
  self._model = model
50
57
  api_key = api_key or os.getenv("ANTHROPIC_API_KEY")
@@ -53,11 +60,14 @@ class Claude5Client(LLMClient):
53
60
  region = base_url.replace("bedrock://", "")
54
61
  access_key, secret_key = api_key.split(",")
55
62
  self._client = AsyncAnthropicBedrock(
56
- aws_secret_key=secret_key, aws_access_key=access_key, aws_region=region
63
+ aws_secret_key=secret_key,
64
+ aws_access_key=access_key,
65
+ aws_region=region,
66
+ default_headers=default_headers,
57
67
  )
58
68
  self._use_bedrock = True
59
69
  else:
60
- self._client = AsyncAnthropic(api_key=api_key, base_url=base_url)
70
+ self._client = AsyncAnthropic(api_key=api_key, base_url=base_url, default_headers=default_headers)
61
71
  self._use_bedrock = False
62
72
 
63
73
  self._history: list[UniMessage] = []
@@ -356,9 +366,14 @@ class Claude5Client(LLMClient):
356
366
  # gateways that relabel it onto another event
357
367
  event_type = "unused"
358
368
 
359
- else:
369
+ elif is_debug_enabled():
360
370
  raise ValueError(f"Unknown output: {model_output}")
361
371
 
372
+ else:
373
+ # a gateway injects its own events (heartbeats, cost tickers) into the stream, and
374
+ # killing a long generation over one costs more than dropping it
375
+ event_type = "unused"
376
+
362
377
  return {
363
378
  "role": "assistant",
364
379
  "event_type": event_type,
@@ -467,3 +482,17 @@ class Claude5Client(LLMClient):
467
482
  "finish_reason": event["finish_reason"],
468
483
  }
469
484
  partial_usage = {}
485
+
486
+ async def list_models(self) -> list[str]:
487
+ """
488
+ List the model ids the configured endpoint serves.
489
+
490
+ Returns:
491
+ list[str]: The model ids, in the order the endpoint returned them.
492
+ """
493
+ if self._use_bedrock:
494
+ raise UnsupportedOperationError(
495
+ self.__class__.__name__, "list_models", "Bedrock does not support listing models."
496
+ )
497
+
498
+ return [model.id async for model in self._client.models.list()]