agenthub-python 0.3.1__tar.gz → 0.3.2__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 (29) hide show
  1. {agenthub_python-0.3.1 → agenthub_python-0.3.2}/PKG-INFO +7 -3
  2. {agenthub_python-0.3.1 → agenthub_python-0.3.2}/README.md +6 -2
  3. agenthub_python-0.3.2/agenthub/abort_signal.py +135 -0
  4. {agenthub_python-0.3.1 → agenthub_python-0.3.2}/agenthub/auto_client.py +27 -12
  5. {agenthub_python-0.3.1 → agenthub_python-0.3.2}/agenthub/base_client.py +68 -7
  6. {agenthub_python-0.3.1 → agenthub_python-0.3.2}/agenthub/claude4_6/client.py +5 -2
  7. agenthub_python-0.3.2/agenthub/claude4_8/__init__.py +18 -0
  8. agenthub_python-0.3.2/agenthub/claude4_8/client.py +429 -0
  9. agenthub_python-0.3.2/agenthub/deepseek_v4/__init__.py +18 -0
  10. agenthub_python-0.3.2/agenthub/deepseek_v4/client.py +337 -0
  11. {agenthub_python-0.3.1 → agenthub_python-0.3.2}/agenthub/gemini3/client.py +41 -0
  12. {agenthub_python-0.3.1/agenthub/qwen3 → agenthub_python-0.3.2/agenthub/glm5_1}/__init__.py +2 -2
  13. {agenthub_python-0.3.1/agenthub/glm5 → agenthub_python-0.3.2/agenthub/glm5_1}/client.py +10 -9
  14. {agenthub_python-0.3.1 → agenthub_python-0.3.2}/agenthub/gpt5_5/client.py +2 -6
  15. agenthub_python-0.3.2/agenthub/integration/playground.py +1192 -0
  16. {agenthub_python-0.3.1 → agenthub_python-0.3.2}/agenthub/integration/tracer.py +49 -9
  17. {agenthub_python-0.3.1/agenthub/kimi_k2_5 → agenthub_python-0.3.2/agenthub/kimi_k2_6}/__init__.py +2 -2
  18. {agenthub_python-0.3.1/agenthub/kimi_k2_5 → agenthub_python-0.3.2/agenthub/kimi_k2_6}/client.py +11 -10
  19. {agenthub_python-0.3.1/agenthub/glm5 → agenthub_python-0.3.2/agenthub/openai}/__init__.py +2 -2
  20. {agenthub_python-0.3.1/agenthub/qwen3 → agenthub_python-0.3.2/agenthub/openai}/client.py +92 -95
  21. {agenthub_python-0.3.1 → agenthub_python-0.3.2}/agenthub/types.py +14 -0
  22. {agenthub_python-0.3.1 → agenthub_python-0.3.2}/pyproject.toml +1 -1
  23. agenthub_python-0.3.1/agenthub/integration/playground.py +0 -762
  24. {agenthub_python-0.3.1 → agenthub_python-0.3.2}/agenthub/__init__.py +0 -0
  25. {agenthub_python-0.3.1 → agenthub_python-0.3.2}/agenthub/claude4_6/__init__.py +0 -0
  26. {agenthub_python-0.3.1 → agenthub_python-0.3.2}/agenthub/gemini3/__init__.py +0 -0
  27. {agenthub_python-0.3.1 → agenthub_python-0.3.2}/agenthub/gpt5_5/__init__.py +0 -0
  28. {agenthub_python-0.3.1 → agenthub_python-0.3.2}/agenthub/integration/__init__.py +0 -0
  29. {agenthub_python-0.3.1 → agenthub_python-0.3.2}/agenthub/utils.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: agenthub-python
3
- Version: 0.3.1
3
+ Version: 0.3.2
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
@@ -48,6 +48,9 @@ client = AutoLLMClient(model="gpt-5.5")
48
48
 
49
49
  # Optionally specify API key (if not using environment variables)
50
50
  client = AutoLLMClient(model="gpt-5.5", api_key="your-openai-api-key")
51
+
52
+ # Use OpenAI Chat Completions-compatible routing explicitly
53
+ client = AutoLLMClient(model="custom-model", client_type="openai")
51
54
  ```
52
55
 
53
56
  The client automatically selects the appropriate client based on the model name.
@@ -211,7 +214,7 @@ async def main():
211
214
 
212
215
  # Execute function and send result back with tool_call_id
213
216
  if tool_call:
214
- result = get_weather(**tool_call["argument"])
217
+ result = get_weather(**tool_call["arguments"])
215
218
 
216
219
  # IMPORTANT: Include tool_call_id in the tool response
217
220
  async for event in client.streaming_response_stateful(
@@ -242,7 +245,7 @@ asyncio.run(main())
242
245
  "content_items": [
243
246
  {"type": "text", "text": "Hello"},
244
247
  {"type": "image_url", "image_url": "https://..."},
245
- {"type": "tool_call", "name": "get_weather", "argument": {"location": "London"}, "tool_call_id": "call_abc123"}
248
+ {"type": "tool_call", "name": "get_weather", "arguments": {"location": "London"}, "tool_call_id": "call_abc123"}
246
249
  ]
247
250
  }
248
251
  ```
@@ -345,3 +348,4 @@ python -m agenthub.integration.playground --host 127.0.0.1 --port 25751
345
348
  ```
346
349
 
347
350
  Then visit `http://127.0.0.1:25751` in your browser to test with the playground.
351
+ The integrated tracer is available at `http://127.0.0.1:25751/tracer/`.
@@ -27,6 +27,9 @@ client = AutoLLMClient(model="gpt-5.5")
27
27
 
28
28
  # Optionally specify API key (if not using environment variables)
29
29
  client = AutoLLMClient(model="gpt-5.5", api_key="your-openai-api-key")
30
+
31
+ # Use OpenAI Chat Completions-compatible routing explicitly
32
+ client = AutoLLMClient(model="custom-model", client_type="openai")
30
33
  ```
31
34
 
32
35
  The client automatically selects the appropriate client based on the model name.
@@ -190,7 +193,7 @@ async def main():
190
193
 
191
194
  # Execute function and send result back with tool_call_id
192
195
  if tool_call:
193
- result = get_weather(**tool_call["argument"])
196
+ result = get_weather(**tool_call["arguments"])
194
197
 
195
198
  # IMPORTANT: Include tool_call_id in the tool response
196
199
  async for event in client.streaming_response_stateful(
@@ -221,7 +224,7 @@ asyncio.run(main())
221
224
  "content_items": [
222
225
  {"type": "text", "text": "Hello"},
223
226
  {"type": "image_url", "image_url": "https://..."},
224
- {"type": "tool_call", "name": "get_weather", "argument": {"location": "London"}, "tool_call_id": "call_abc123"}
227
+ {"type": "tool_call", "name": "get_weather", "arguments": {"location": "London"}, "tool_call_id": "call_abc123"}
225
228
  ]
226
229
  }
227
230
  ```
@@ -324,3 +327,4 @@ python -m agenthub.integration.playground --host 127.0.0.1 --port 25751
324
327
  ```
325
328
 
326
329
  Then visit `http://127.0.0.1:25751` in your browser to test with the playground.
330
+ The integrated tracer is available at `http://127.0.0.1:25751/tracer/`.
@@ -0,0 +1,135 @@
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 asyncio
16
+ import threading
17
+ from contextlib import suppress
18
+ from typing import Any, Awaitable, TypeVar
19
+
20
+
21
+ T = TypeVar("T")
22
+
23
+
24
+ class AbortSignal:
25
+ """Abort signal that can also trigger its own aborted state."""
26
+
27
+ def __init__(self) -> None:
28
+ self._lock = threading.Lock()
29
+ self._aborted = False
30
+ self._reason: Any = None
31
+ self._waiters: set[asyncio.Future[None]] = set()
32
+
33
+ @property
34
+ def aborted(self) -> bool:
35
+ with self._lock:
36
+ return self._aborted
37
+
38
+ @property
39
+ def reason(self) -> Any:
40
+ with self._lock:
41
+ return self._reason
42
+
43
+ def abort(self, reason: Any = None) -> None:
44
+ with self._lock:
45
+ if self._aborted:
46
+ return
47
+
48
+ self._aborted = True
49
+ self._reason = reason
50
+ waiters = tuple(self._waiters)
51
+ self._waiters.clear()
52
+
53
+ for waiter in waiters:
54
+ _notify_waiter(waiter)
55
+
56
+ async def wait(self) -> None:
57
+ loop = asyncio.get_running_loop()
58
+ waiter = loop.create_future()
59
+ with self._lock:
60
+ if self._aborted:
61
+ return
62
+
63
+ self._waiters.add(waiter)
64
+
65
+ try:
66
+ await waiter
67
+ finally:
68
+ with self._lock:
69
+ self._waiters.discard(waiter)
70
+
71
+ def throw_if_aborted(self) -> None:
72
+ with self._lock:
73
+ aborted = self._aborted
74
+ reason = self._reason
75
+
76
+ if aborted:
77
+ raise _cancelled_error(reason)
78
+
79
+
80
+ async def run_with_abort(awaitable: Awaitable[T], signal: AbortSignal) -> T:
81
+ """Run an awaitable and cancel it when the signal is aborted."""
82
+
83
+ task = asyncio.ensure_future(awaitable)
84
+
85
+ if signal.aborted:
86
+ task.cancel(signal.reason)
87
+ with suppress(asyncio.CancelledError):
88
+ await task
89
+ raise _cancelled_error(signal.reason)
90
+
91
+ abort_task = asyncio.create_task(signal.wait())
92
+
93
+ try:
94
+ done, _ = await asyncio.wait((task, abort_task), return_when=asyncio.FIRST_COMPLETED)
95
+ if task in done:
96
+ return await task
97
+
98
+ task.cancel(signal.reason)
99
+ with suppress(asyncio.CancelledError):
100
+ await task
101
+ raise _cancelled_error(signal.reason)
102
+ except asyncio.CancelledError:
103
+ if not task.done():
104
+ task.cancel()
105
+ with suppress(asyncio.CancelledError):
106
+ await task
107
+ raise
108
+ finally:
109
+ if not abort_task.done():
110
+ abort_task.cancel()
111
+ with suppress(asyncio.CancelledError):
112
+ await abort_task
113
+
114
+
115
+ def _set_waiter_result(waiter: asyncio.Future[None]) -> None:
116
+ if not waiter.done():
117
+ waiter.set_result(None)
118
+
119
+
120
+ def _notify_waiter(waiter: asyncio.Future[None]) -> None:
121
+ loop = waiter.get_loop()
122
+ if loop.is_closed():
123
+ return
124
+
125
+ if loop.is_running():
126
+ loop.call_soon_threadsafe(_set_waiter_result, waiter)
127
+ else:
128
+ _set_waiter_result(waiter)
129
+
130
+
131
+ def _cancelled_error(reason: Any) -> asyncio.CancelledError:
132
+ if reason is None:
133
+ return asyncio.CancelledError()
134
+
135
+ return asyncio.CancelledError(reason)
@@ -15,6 +15,7 @@
15
15
  import os
16
16
  from typing import Any, AsyncIterator
17
17
 
18
+ from .abort_signal import AbortSignal
18
19
  from .base_client import LLMClient
19
20
  from .types import UniConfig, UniEvent, UniMessage
20
21
 
@@ -45,11 +46,17 @@ class AutoLLMClient(LLMClient):
45
46
  self, model: str, api_key: str | None = None, base_url: str | None = None, client_type: str | None = None
46
47
  ) -> LLMClient:
47
48
  """Create the appropriate client for the given model."""
48
- client_type = client_type or os.getenv("CLIENT_TYPE", model.lower())
49
- if "gemini-3-" in client_type or "gemini-3.1-" in client_type: # e.g., gemini-3-flash-preview
49
+ client_type = (client_type or os.getenv("CLIENT_TYPE", model)).lower()
50
+ if any(
51
+ prefix in client_type for prefix in ("gemini-3", "gemini-embedding")
52
+ ): # e.g., gemini-3-flash-preview, gemini-embedding-2
50
53
  from .gemini3 import Gemini3Client
51
54
 
52
55
  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
58
+
59
+ return Claude4_8Client(model=model, api_key=api_key, base_url=base_url)
53
60
  elif "claude" in client_type and "4-6" in client_type: # e.g., claude-sonnet-4-6
54
61
  from .claude4_6 import Claude4_6Client
55
62
 
@@ -58,22 +65,26 @@ class AutoLLMClient(LLMClient):
58
65
  from .gpt5_5 import GPT5_5Client
59
66
 
60
67
  return GPT5_5Client(model=model, api_key=api_key, base_url=base_url)
61
- elif "glm-5" in client_type:
62
- from .glm5 import GLM5Client
68
+ elif "glm-5" in client_type or "glm-5.1" in client_type:
69
+ from .glm5_1 import GLM5_1Client
70
+
71
+ return GLM5_1Client(model=model, api_key=api_key, base_url=base_url)
72
+ elif "kimi-k2.5" in client_type or "kimi-k2.6" in client_type:
73
+ from .kimi_k2_6 import KimiK2_6Client
63
74
 
64
- return GLM5Client(model=model, api_key=api_key, base_url=base_url)
65
- elif "kimi-k2.5" in client_type:
66
- from .kimi_k2_5 import KimiK2_5Client
75
+ return KimiK2_6Client(model=model, api_key=api_key, base_url=base_url)
76
+ elif "deepseek-v4" in client_type:
77
+ from .deepseek_v4 import DeepSeekV4Client
67
78
 
68
- return KimiK2_5Client(model=model, api_key=api_key, base_url=base_url)
69
- elif "qwen3" in client_type:
70
- from .qwen3 import Qwen3Client
79
+ return DeepSeekV4Client(model=model, api_key=api_key, base_url=base_url)
80
+ elif "openai" in client_type:
81
+ from .openai import OpenaiClient
71
82
 
72
- return Qwen3Client(model=model, api_key=api_key, base_url=base_url)
83
+ return OpenaiClient(model=model, api_key=api_key, base_url=base_url)
73
84
  else:
74
85
  raise ValueError(
75
86
  f"{client_type} is not supported. "
76
- "Supported client types: gemini-3, claude-4-6, gpt-5.4, gpt-5.5, glm-5, kimi-k2.5, qwen3."
87
+ "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."
77
88
  )
78
89
 
79
90
  def transform_uni_config_to_model_config(self, config: UniConfig) -> Any:
@@ -99,11 +110,13 @@ class AutoLLMClient(LLMClient):
99
110
  self,
100
111
  messages: list[UniMessage],
101
112
  config: UniConfig,
113
+ signal: AbortSignal | None = None,
102
114
  ) -> AsyncIterator[UniEvent]:
103
115
  """Route to underlying client's streaming_response."""
104
116
  async for event in self._client.streaming_response(
105
117
  messages=messages,
106
118
  config=config,
119
+ signal=signal,
107
120
  ):
108
121
  yield event
109
122
 
@@ -111,11 +124,13 @@ class AutoLLMClient(LLMClient):
111
124
  self,
112
125
  message: UniMessage,
113
126
  config: UniConfig,
127
+ signal: AbortSignal | None = None,
114
128
  ) -> AsyncIterator[UniEvent]:
115
129
  """Route to underlying client's streaming_response_stateful."""
116
130
  async for event in self._client.streaming_response_stateful(
117
131
  message=message,
118
132
  config=config,
133
+ signal=signal,
119
134
  ):
120
135
  yield event
121
136
 
@@ -12,11 +12,21 @@
12
12
  # See the License for the specific language governing permissions and
13
13
  # limitations under the License.
14
14
 
15
+ import asyncio
15
16
  import time
16
17
  from abc import ABC, abstractmethod
18
+ from contextlib import suppress
17
19
  from typing import Any, AsyncIterator
18
20
 
19
- from .types import ContentItem, FinishReason, UniConfig, UniEvent, UniMessage, UsageMetadata
21
+ from .abort_signal import AbortSignal
22
+ from .types import (
23
+ ContentItem,
24
+ FinishReason,
25
+ UniConfig,
26
+ UniEvent,
27
+ UniMessage,
28
+ UsageMetadata,
29
+ )
20
30
 
21
31
 
22
32
  class LLMClient(ABC):
@@ -156,6 +166,7 @@ class LLMClient(ABC):
156
166
  self,
157
167
  messages: list[UniMessage],
158
168
  config: UniConfig,
169
+ signal: AbortSignal | None = None,
159
170
  ) -> AsyncIterator[UniEvent]:
160
171
  """
161
172
  Generate content in streaming mode (stateless).
@@ -167,6 +178,7 @@ class LLMClient(ABC):
167
178
  Args:
168
179
  messages: List of universal message dictionaries containing conversation history
169
180
  config: Universal configuration dict
181
+ signal: Optional abort signal used to cancel the active request
170
182
 
171
183
  Yields:
172
184
  Universal events from the streaming response
@@ -178,11 +190,58 @@ class LLMClient(ABC):
178
190
 
179
191
  last_event: UniEvent | None = None
180
192
  events = []
181
- async for event in self._streaming_response_internal(messages, config):
182
- event["created_at"] = int(time.time() * 1000)
183
- last_event = event
184
- events.append(event)
185
- yield event
193
+ if signal is not None:
194
+ signal.throw_if_aborted()
195
+
196
+ stream = self._streaming_response_internal(messages, config)
197
+ abort_task: asyncio.Task[None] | None = None
198
+ waiting_for_stream = False
199
+ if signal is not None:
200
+ streaming_task = asyncio.current_task()
201
+ abort_task = asyncio.create_task(signal.wait())
202
+
203
+ def cancel_streaming_task(task: asyncio.Task[None]) -> None:
204
+ if (
205
+ task.cancelled()
206
+ or not signal.aborted
207
+ or not waiting_for_stream
208
+ or streaming_task is None
209
+ or streaming_task.done()
210
+ ):
211
+ return
212
+
213
+ streaming_task.cancel(signal.reason)
214
+
215
+ abort_task.add_done_callback(cancel_streaming_task)
216
+
217
+ try:
218
+ while True:
219
+ try:
220
+ if signal is not None:
221
+ signal.throw_if_aborted()
222
+ waiting_for_stream = True
223
+ signal.throw_if_aborted()
224
+
225
+ event = await anext(stream)
226
+ except StopAsyncIteration:
227
+ break
228
+ except asyncio.CancelledError:
229
+ if signal is not None and signal.aborted:
230
+ signal.throw_if_aborted()
231
+ raise
232
+ finally:
233
+ waiting_for_stream = False
234
+
235
+ event["created_at"] = int(time.time() * 1000)
236
+ last_event = event
237
+ events.append(event)
238
+ yield event
239
+ finally:
240
+ if abort_task is not None and not abort_task.done():
241
+ abort_task.cancel()
242
+ with suppress(asyncio.CancelledError):
243
+ await abort_task
244
+ await stream.aclose()
186
245
 
187
246
  self._validate_last_event(last_event)
188
247
 
@@ -198,6 +257,7 @@ class LLMClient(ABC):
198
257
  self,
199
258
  message: UniMessage,
200
259
  config: UniConfig,
260
+ signal: AbortSignal | None = None,
201
261
  ) -> AsyncIterator[UniEvent]:
202
262
  """
203
263
  Generate content in streaming mode (stateful).
@@ -209,6 +269,7 @@ class LLMClient(ABC):
209
269
  Args:
210
270
  message: Latest universal message dictionary to add to conversation
211
271
  config: Universal configuration dict
272
+ signal: Optional abort signal used to cancel the active request
212
273
 
213
274
  Yields:
214
275
  Universal events from the streaming response
@@ -218,7 +279,7 @@ class LLMClient(ABC):
218
279
 
219
280
  # Collect all events for history
220
281
  events = []
221
- async for event in self.streaming_response(messages=temp_messages, config=config):
282
+ async for event in self.streaming_response(messages=temp_messages, config=config, signal=signal):
222
283
  events.append(event)
223
284
  yield event
224
285
 
@@ -110,6 +110,7 @@ class Claude4_6Client(LLMClient):
110
110
  ThinkingLevel.LOW: {"thinking": {"type": "adaptive"}, "output_config": {"effort": "low"}},
111
111
  ThinkingLevel.MEDIUM: {"thinking": {"type": "adaptive"}, "output_config": {"effort": "medium"}},
112
112
  ThinkingLevel.HIGH: {"thinking": {"type": "adaptive"}, "output_config": {"effort": "high"}},
113
+ ThinkingLevel.XHIGH: {"thinking": {"type": "adaptive"}, "output_config": {"effort": "high"}},
113
114
  }
114
115
  return mapping.get(thinking_level)
115
116
 
@@ -145,7 +146,7 @@ class Claude4_6Client(LLMClient):
145
146
  if config.get("max_tokens") is not None:
146
147
  claude_config["max_tokens"] = config["max_tokens"]
147
148
  else:
148
- claude_config["max_tokens"] = 32768 # Claude requires max_tokens to be specified
149
+ claude_config["max_tokens"] = 64000 # Claude requires max_tokens to be specified
149
150
 
150
151
  if config.get("temperature") is not None:
151
152
  claude_config["temperature"] = config["temperature"]
@@ -373,7 +374,9 @@ class Claude4_6Client(LLMClient):
373
374
  "arguments": "",
374
375
  "tool_call_id": item["tool_call_id"],
375
376
  }
376
- yield event
377
+
378
+ if event["content_items"]:
379
+ yield event
377
380
 
378
381
  if event["usage_metadata"] is not None:
379
382
  # initialize partial_usage
@@ -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 Claude4_8Client
16
+
17
+
18
+ __all__ = ["Claude4_8Client"]