agenthub-python 0.3.2__tar.gz → 0.4.0__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.
- {agenthub_python-0.3.2 → agenthub_python-0.4.0}/PKG-INFO +4 -1
- {agenthub_python-0.3.2 → agenthub_python-0.4.0}/agenthub/__init__.py +9 -1
- {agenthub_python-0.3.2 → agenthub_python-0.4.0}/agenthub/auto_client.py +11 -5
- {agenthub_python-0.3.2 → agenthub_python-0.4.0}/agenthub/base_client.py +42 -9
- {agenthub_python-0.3.2 → agenthub_python-0.4.0}/agenthub/claude4_6/client.py +17 -6
- agenthub_python-0.4.0/agenthub/claude5/__init__.py +18 -0
- {agenthub_python-0.3.2/agenthub/claude4_8 → agenthub_python-0.4.0/agenthub/claude5}/client.py +20 -9
- {agenthub_python-0.3.2 → agenthub_python-0.4.0}/agenthub/deepseek_v4/client.py +22 -3
- agenthub_python-0.4.0/agenthub/errors.py +75 -0
- {agenthub_python-0.3.2 → agenthub_python-0.4.0}/agenthub/gemini3/client.py +38 -16
- {agenthub_python-0.3.2 → agenthub_python-0.4.0}/agenthub/glm5_1/client.py +45 -11
- {agenthub_python-0.3.2 → agenthub_python-0.4.0}/agenthub/gpt5_5/client.py +19 -12
- {agenthub_python-0.3.2 → agenthub_python-0.4.0}/agenthub/kimi_k2_6/client.py +45 -11
- {agenthub_python-0.3.2 → agenthub_python-0.4.0}/agenthub/openai/client.py +45 -11
- {agenthub_python-0.3.2/agenthub/claude4_8 → agenthub_python-0.4.0/agenthub/openai_embedding}/__init__.py +2 -2
- agenthub_python-0.4.0/agenthub/openai_embedding/client.py +80 -0
- {agenthub_python-0.3.2 → agenthub_python-0.4.0}/agenthub/types.py +11 -7
- {agenthub_python-0.3.2 → agenthub_python-0.4.0}/pyproject.toml +6 -1
- {agenthub_python-0.3.2 → agenthub_python-0.4.0}/README.md +0 -0
- {agenthub_python-0.3.2 → agenthub_python-0.4.0}/agenthub/abort_signal.py +0 -0
- {agenthub_python-0.3.2 → agenthub_python-0.4.0}/agenthub/claude4_6/__init__.py +0 -0
- {agenthub_python-0.3.2 → agenthub_python-0.4.0}/agenthub/deepseek_v4/__init__.py +0 -0
- {agenthub_python-0.3.2 → agenthub_python-0.4.0}/agenthub/gemini3/__init__.py +0 -0
- {agenthub_python-0.3.2 → agenthub_python-0.4.0}/agenthub/glm5_1/__init__.py +0 -0
- {agenthub_python-0.3.2 → agenthub_python-0.4.0}/agenthub/gpt5_5/__init__.py +0 -0
- {agenthub_python-0.3.2 → agenthub_python-0.4.0}/agenthub/integration/__init__.py +0 -0
- {agenthub_python-0.3.2 → agenthub_python-0.4.0}/agenthub/integration/playground.py +0 -0
- {agenthub_python-0.3.2 → agenthub_python-0.4.0}/agenthub/integration/tracer.py +0 -0
- {agenthub_python-0.3.2 → agenthub_python-0.4.0}/agenthub/kimi_k2_6/__init__.py +0 -0
- {agenthub_python-0.3.2 → agenthub_python-0.4.0}/agenthub/openai/__init__.py +0 -0
- {agenthub_python-0.3.2 → agenthub_python-0.4.0}/agenthub/utils.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: agenthub-python
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.4.0
|
|
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,15 @@
|
|
|
13
13
|
# limitations under the License.
|
|
14
14
|
|
|
15
15
|
from .auto_client import AutoLLMClient
|
|
16
|
+
from .errors import AgentHubError, EmptyResponseError, ToolCallArgumentParseError
|
|
16
17
|
from .types import PromptCaching, ThinkingLevel
|
|
17
18
|
|
|
18
19
|
|
|
19
|
-
__all__ = [
|
|
20
|
+
__all__ = [
|
|
21
|
+
"AgentHubError",
|
|
22
|
+
"AutoLLMClient",
|
|
23
|
+
"EmptyResponseError",
|
|
24
|
+
"PromptCaching",
|
|
25
|
+
"ThinkingLevel",
|
|
26
|
+
"ToolCallArgumentParseError",
|
|
27
|
+
]
|
|
@@ -53,10 +53,12 @@ class AutoLLMClient(LLMClient):
|
|
|
53
53
|
from .gemini3 import Gemini3Client
|
|
54
54
|
|
|
55
55
|
return Gemini3Client(model=model, api_key=api_key, base_url=base_url)
|
|
56
|
-
elif "claude" in client_type and (
|
|
57
|
-
|
|
56
|
+
elif "claude" in client_type and (
|
|
57
|
+
"4-7" in client_type or "4-8" in client_type or "-5" in client_type
|
|
58
|
+
): # e.g., claude-opus-4-7
|
|
59
|
+
from .claude5 import Claude5Client
|
|
58
60
|
|
|
59
|
-
return
|
|
61
|
+
return Claude5Client(model=model, api_key=api_key, base_url=base_url)
|
|
60
62
|
elif "claude" in client_type and "4-6" in client_type: # e.g., claude-sonnet-4-6
|
|
61
63
|
from .claude4_6 import Claude4_6Client
|
|
62
64
|
|
|
@@ -77,14 +79,18 @@ class AutoLLMClient(LLMClient):
|
|
|
77
79
|
from .deepseek_v4 import DeepSeekV4Client
|
|
78
80
|
|
|
79
81
|
return DeepSeekV4Client(model=model, api_key=api_key, base_url=base_url)
|
|
80
|
-
elif "openai" in client_type:
|
|
82
|
+
elif "openai" in client_type and "embedding" in client_type:
|
|
83
|
+
from .openai_embedding import OpenaiEmbeddingClient
|
|
84
|
+
|
|
85
|
+
return OpenaiEmbeddingClient(model=model, api_key=api_key, base_url=base_url)
|
|
86
|
+
elif "openai" in client_type and "embedding" not in client_type:
|
|
81
87
|
from .openai import OpenaiClient
|
|
82
88
|
|
|
83
89
|
return OpenaiClient(model=model, api_key=api_key, base_url=base_url)
|
|
84
90
|
else:
|
|
85
91
|
raise ValueError(
|
|
86
92
|
f"{client_type} is not supported. "
|
|
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."
|
|
93
|
+
"Supported client types: gemini-3, claude-5, 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."
|
|
88
94
|
)
|
|
89
95
|
|
|
90
96
|
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
|
|
108
|
-
and
|
|
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
|
|
112
|
-
content_items[-1]["
|
|
113
|
-
elif item["text"] or
|
|
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
|
|
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
|
|
123
|
-
content_items[-1]["
|
|
124
|
-
elif item["thinking"] or
|
|
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 parse_tool_call_arguments
|
|
27
27
|
from ..types import (
|
|
28
28
|
EventType,
|
|
29
29
|
FinishReason,
|
|
@@ -204,10 +204,14 @@ class Claude4_6Client(LLMClient):
|
|
|
204
204
|
content_blocks.append(await self._convert_image_url_to_source(item["image_url"]))
|
|
205
205
|
elif item["type"] == "thinking":
|
|
206
206
|
if item["thinking"] == REDACTED_THINKING:
|
|
207
|
-
content_blocks.append({"type": "redacted_thinking", "data": item["signature"]})
|
|
207
|
+
content_blocks.append({"type": "redacted_thinking", "data": item["fidelity"]["signature"]})
|
|
208
208
|
else:
|
|
209
209
|
content_blocks.append(
|
|
210
|
-
{
|
|
210
|
+
{
|
|
211
|
+
"type": "thinking",
|
|
212
|
+
"thinking": item["thinking"],
|
|
213
|
+
"signature": item["fidelity"]["signature"],
|
|
214
|
+
}
|
|
211
215
|
)
|
|
212
216
|
elif item["type"] == "tool_call":
|
|
213
217
|
content_blocks.append(
|
|
@@ -263,7 +267,9 @@ class Claude4_6Client(LLMClient):
|
|
|
263
267
|
{"type": "partial_tool_call", "name": block.name, "arguments": "", "tool_call_id": block.id}
|
|
264
268
|
)
|
|
265
269
|
elif block.type == "redacted_thinking":
|
|
266
|
-
content_items.append(
|
|
270
|
+
content_items.append(
|
|
271
|
+
{"type": "thinking", "thinking": REDACTED_THINKING, "fidelity": {"signature": block.data}}
|
|
272
|
+
)
|
|
267
273
|
|
|
268
274
|
elif claude_event_type == "content_block_delta":
|
|
269
275
|
event_type = "delta"
|
|
@@ -277,7 +283,7 @@ class Claude4_6Client(LLMClient):
|
|
|
277
283
|
{"type": "partial_tool_call", "name": "", "arguments": delta.partial_json, "tool_call_id": ""}
|
|
278
284
|
)
|
|
279
285
|
elif delta.type == "signature_delta":
|
|
280
|
-
content_items.append({"type": "thinking", "thinking": "", "signature": delta.signature})
|
|
286
|
+
content_items.append({"type": "thinking", "thinking": "", "fidelity": {"signature": delta.signature}})
|
|
281
287
|
|
|
282
288
|
elif claude_event_type == "content_block_stop":
|
|
283
289
|
event_type = "stop"
|
|
@@ -403,7 +409,12 @@ class Claude4_6Client(LLMClient):
|
|
|
403
409
|
{
|
|
404
410
|
"type": "tool_call",
|
|
405
411
|
"name": partial_tool_call["name"],
|
|
406
|
-
"arguments":
|
|
412
|
+
"arguments": parse_tool_call_arguments(
|
|
413
|
+
partial_tool_call["arguments"],
|
|
414
|
+
self.__class__.__name__,
|
|
415
|
+
partial_tool_call["name"],
|
|
416
|
+
partial_tool_call["tool_call_id"],
|
|
417
|
+
),
|
|
407
418
|
"tool_call_id": partial_tool_call["tool_call_id"],
|
|
408
419
|
}
|
|
409
420
|
],
|
|
@@ -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"]
|
{agenthub_python-0.3.2/agenthub/claude4_8 → agenthub_python-0.4.0/agenthub/claude5}/client.py
RENAMED
|
@@ -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 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
|
|
45
|
-
"""Claude
|
|
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
|
|
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")
|
|
@@ -204,10 +204,14 @@ class Claude4_8Client(LLMClient):
|
|
|
204
204
|
content_blocks.append(await self._convert_image_url_to_source(item["image_url"]))
|
|
205
205
|
elif item["type"] == "thinking":
|
|
206
206
|
if item["thinking"] == REDACTED_THINKING:
|
|
207
|
-
content_blocks.append({"type": "redacted_thinking", "data": item["signature"]})
|
|
207
|
+
content_blocks.append({"type": "redacted_thinking", "data": item["fidelity"]["signature"]})
|
|
208
208
|
else:
|
|
209
209
|
content_blocks.append(
|
|
210
|
-
{
|
|
210
|
+
{
|
|
211
|
+
"type": "thinking",
|
|
212
|
+
"thinking": item["thinking"],
|
|
213
|
+
"signature": item["fidelity"]["signature"],
|
|
214
|
+
}
|
|
211
215
|
)
|
|
212
216
|
elif item["type"] == "tool_call":
|
|
213
217
|
content_blocks.append(
|
|
@@ -263,7 +267,9 @@ class Claude4_8Client(LLMClient):
|
|
|
263
267
|
{"type": "partial_tool_call", "name": block.name, "arguments": "", "tool_call_id": block.id}
|
|
264
268
|
)
|
|
265
269
|
elif block.type == "redacted_thinking":
|
|
266
|
-
content_items.append(
|
|
270
|
+
content_items.append(
|
|
271
|
+
{"type": "thinking", "thinking": REDACTED_THINKING, "fidelity": {"signature": block.data}}
|
|
272
|
+
)
|
|
267
273
|
|
|
268
274
|
elif claude_event_type == "content_block_delta":
|
|
269
275
|
event_type = "delta"
|
|
@@ -277,7 +283,7 @@ class Claude4_8Client(LLMClient):
|
|
|
277
283
|
{"type": "partial_tool_call", "name": "", "arguments": delta.partial_json, "tool_call_id": ""}
|
|
278
284
|
)
|
|
279
285
|
elif delta.type == "signature_delta":
|
|
280
|
-
content_items.append({"type": "thinking", "thinking": "", "signature": delta.signature})
|
|
286
|
+
content_items.append({"type": "thinking", "thinking": "", "fidelity": {"signature": delta.signature}})
|
|
281
287
|
|
|
282
288
|
elif claude_event_type == "content_block_stop":
|
|
283
289
|
event_type = "stop"
|
|
@@ -403,7 +409,12 @@ class Claude4_8Client(LLMClient):
|
|
|
403
409
|
{
|
|
404
410
|
"type": "tool_call",
|
|
405
411
|
"name": partial_tool_call["name"],
|
|
406
|
-
"arguments":
|
|
412
|
+
"arguments": parse_tool_call_arguments(
|
|
413
|
+
partial_tool_call["arguments"],
|
|
414
|
+
self.__class__.__name__,
|
|
415
|
+
partial_tool_call["name"],
|
|
416
|
+
partial_tool_call["tool_call_id"],
|
|
417
|
+
),
|
|
407
418
|
"tool_call_id": partial_tool_call["tool_call_id"],
|
|
408
419
|
}
|
|
409
420
|
],
|
|
@@ -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 parse_tool_call_arguments
|
|
23
24
|
from ..types import (
|
|
24
25
|
EventType,
|
|
25
26
|
FinishReason,
|
|
@@ -200,7 +201,15 @@ class DeepSeekV4Client(LLMClient):
|
|
|
200
201
|
|
|
201
202
|
if getattr(delta, "reasoning_content", None):
|
|
202
203
|
event_type = "delta"
|
|
203
|
-
|
|
204
|
+
# record the wire field so a replay through another OpenAI-compatible
|
|
205
|
+
# client reproduces the exact field DeepSeek produced
|
|
206
|
+
content_items.append(
|
|
207
|
+
{
|
|
208
|
+
"type": "thinking",
|
|
209
|
+
"thinking": getattr(delta, "reasoning_content"),
|
|
210
|
+
"fidelity": {"reasoning_field": "reasoning_content"},
|
|
211
|
+
}
|
|
212
|
+
)
|
|
204
213
|
|
|
205
214
|
if delta.content:
|
|
206
215
|
event_type = "delta"
|
|
@@ -289,7 +298,12 @@ class DeepSeekV4Client(LLMClient):
|
|
|
289
298
|
{
|
|
290
299
|
"type": "tool_call",
|
|
291
300
|
"name": partial_tool_call["name"],
|
|
292
|
-
"arguments":
|
|
301
|
+
"arguments": parse_tool_call_arguments(
|
|
302
|
+
partial_tool_call["arguments"],
|
|
303
|
+
self.__class__.__name__,
|
|
304
|
+
partial_tool_call["name"],
|
|
305
|
+
partial_tool_call["tool_call_id"],
|
|
306
|
+
),
|
|
293
307
|
"tool_call_id": partial_tool_call["tool_call_id"],
|
|
294
308
|
}
|
|
295
309
|
],
|
|
@@ -317,7 +331,12 @@ class DeepSeekV4Client(LLMClient):
|
|
|
317
331
|
{
|
|
318
332
|
"type": "tool_call",
|
|
319
333
|
"name": partial_tool_call["name"],
|
|
320
|
-
"arguments":
|
|
334
|
+
"arguments": parse_tool_call_arguments(
|
|
335
|
+
partial_tool_call["arguments"],
|
|
336
|
+
self.__class__.__name__,
|
|
337
|
+
partial_tool_call["name"],
|
|
338
|
+
partial_tool_call["tool_call_id"],
|
|
339
|
+
),
|
|
321
340
|
"tool_call_id": partial_tool_call["tool_call_id"],
|
|
322
341
|
}
|
|
323
342
|
],
|
|
@@ -0,0 +1,75 @@
|
|
|
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 EmptyResponseError(AgentHubError):
|
|
33
|
+
"""Raised when a completed response carries no non-thinking content and no tool calls.
|
|
34
|
+
|
|
35
|
+
Models occasionally finish a turn with thinking output only (reasoning models in
|
|
36
|
+
particular); replaying such an assistant message on the next turn fails with a 400
|
|
37
|
+
error, so the response is rejected as soon as the stream completes.
|
|
38
|
+
"""
|
|
39
|
+
|
|
40
|
+
def __init__(self, client: str, finish_reason: str | None) -> None:
|
|
41
|
+
self.client = client
|
|
42
|
+
self.finish_reason = finish_reason
|
|
43
|
+
super().__init__(f"{client} returned no content other than thinking (finish_reason={finish_reason!r}).")
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class ToolCallArgumentParseError(AgentHubError):
|
|
47
|
+
def __init__(self, client: str, tool_name: str, tool_call_id: str, raw_arguments: str, reason: str) -> None:
|
|
48
|
+
self.client = client
|
|
49
|
+
self.tool_name = tool_name
|
|
50
|
+
self.tool_call_id = tool_call_id
|
|
51
|
+
self.raw_arguments_length = len(raw_arguments)
|
|
52
|
+
self.raw_arguments_preview = _preview_tool_call_arguments(raw_arguments)
|
|
53
|
+
super().__init__(
|
|
54
|
+
f'Invalid streamed tool call arguments from {client} for tool "{tool_name}" '
|
|
55
|
+
f'(tool_call_id="{tool_call_id}", length={self.raw_arguments_length}, '
|
|
56
|
+
f"preview={self.raw_arguments_preview!r}): {reason}"
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def parse_tool_call_arguments(
|
|
61
|
+
raw_arguments: str | None,
|
|
62
|
+
client: str,
|
|
63
|
+
tool_name: str,
|
|
64
|
+
tool_call_id: str,
|
|
65
|
+
) -> dict[str, Any]:
|
|
66
|
+
raw = raw_arguments or "{}"
|
|
67
|
+
try:
|
|
68
|
+
parsed = json.loads(raw)
|
|
69
|
+
except (TypeError, ValueError) as exc:
|
|
70
|
+
raise ToolCallArgumentParseError(client, tool_name, tool_call_id, raw, str(exc)) from exc
|
|
71
|
+
|
|
72
|
+
if not isinstance(parsed, dict):
|
|
73
|
+
raise ToolCallArgumentParseError(client, tool_name, tool_call_id, raw, "Expected a JSON object.")
|
|
74
|
+
|
|
75
|
+
return parsed
|
|
@@ -26,7 +26,9 @@ from google.oauth2 import service_account
|
|
|
26
26
|
|
|
27
27
|
from ..base_client import LLMClient
|
|
28
28
|
from ..types import (
|
|
29
|
+
ContentItem,
|
|
29
30
|
EventType,
|
|
31
|
+
Fidelity,
|
|
30
32
|
FinishReason,
|
|
31
33
|
PartialContentItem,
|
|
32
34
|
PromptCaching,
|
|
@@ -188,6 +190,19 @@ class Gemini3Client(LLMClient):
|
|
|
188
190
|
|
|
189
191
|
return types.GenerateContentConfig(**config_params) if config_params else None
|
|
190
192
|
|
|
193
|
+
@staticmethod
|
|
194
|
+
def _part_fidelity(part: types.Part) -> dict[str, Fidelity]:
|
|
195
|
+
"""Wrap a part's thought signature as a fidelity payload, or nothing when absent."""
|
|
196
|
+
if part.thought_signature is None:
|
|
197
|
+
return {}
|
|
198
|
+
|
|
199
|
+
return {"fidelity": {"signature": part.thought_signature}}
|
|
200
|
+
|
|
201
|
+
@staticmethod
|
|
202
|
+
def _item_thought_signature(item: ContentItem) -> str | bytes | None:
|
|
203
|
+
"""Read the thought signature recorded in an item's fidelity payload."""
|
|
204
|
+
return (item.get("fidelity") or {}).get("signature")
|
|
205
|
+
|
|
191
206
|
async def transform_uni_message_to_model_input(self, messages: list[UniMessage]) -> list[types.Content]:
|
|
192
207
|
"""
|
|
193
208
|
Transform universal message format to Gemini's Content format.
|
|
@@ -204,26 +219,34 @@ class Gemini3Client(LLMClient):
|
|
|
204
219
|
parts = []
|
|
205
220
|
for item in msg["content_items"]:
|
|
206
221
|
if item["type"] == "text":
|
|
207
|
-
parts.append(types.Part(text=item["text"], thought_signature=
|
|
222
|
+
parts.append(types.Part(text=item["text"], thought_signature=self._item_thought_signature(item)))
|
|
208
223
|
elif item["type"] == "image_url":
|
|
209
224
|
image_url = item["image_url"]
|
|
210
225
|
image_data = await self._get_image_bytes_and_mime_type(image_url)
|
|
211
226
|
parts.append(types.Part.from_bytes(**image_data))
|
|
212
227
|
elif item["type"] == "inline_data":
|
|
213
228
|
inline_data = types.Blob(data=item["data"], mime_type=item["mime_type"])
|
|
214
|
-
parts.append(
|
|
229
|
+
parts.append(
|
|
230
|
+
types.Part(inline_data=inline_data, thought_signature=self._item_thought_signature(item))
|
|
231
|
+
)
|
|
215
232
|
elif item["type"] == "thinking":
|
|
216
233
|
parts.append(
|
|
217
|
-
types.Part(
|
|
234
|
+
types.Part(
|
|
235
|
+
text=item["thinking"], thought=True, thought_signature=self._item_thought_signature(item)
|
|
236
|
+
)
|
|
218
237
|
)
|
|
219
238
|
elif item["type"] == "inline_thinking":
|
|
220
239
|
inline_data = types.Blob(data=item["data"], mime_type=item["mime_type"])
|
|
221
240
|
parts.append(
|
|
222
|
-
types.Part(
|
|
241
|
+
types.Part(
|
|
242
|
+
inline_data=inline_data, thought=True, thought_signature=self._item_thought_signature(item)
|
|
243
|
+
)
|
|
223
244
|
)
|
|
224
245
|
elif item["type"] == "tool_call":
|
|
225
246
|
function_call = types.FunctionCall(name=item["name"], args=item["arguments"])
|
|
226
|
-
parts.append(
|
|
247
|
+
parts.append(
|
|
248
|
+
types.Part(function_call=function_call, thought_signature=self._item_thought_signature(item))
|
|
249
|
+
)
|
|
227
250
|
elif item["type"] == "tool_result":
|
|
228
251
|
if "tool_call_id" not in item:
|
|
229
252
|
raise ValueError("tool_call_id is required for tool result.")
|
|
@@ -266,31 +289,30 @@ class Gemini3Client(LLMClient):
|
|
|
266
289
|
usage_metadata: UsageMetadata | None = None
|
|
267
290
|
finish_reason: FinishReason | None = None
|
|
268
291
|
|
|
269
|
-
if
|
|
292
|
+
if model_output.candidates:
|
|
270
293
|
candidate = model_output.candidates[0]
|
|
271
|
-
|
|
294
|
+
content = getattr(candidate, "content", None)
|
|
295
|
+
for part in getattr(content, "parts", None) or []:
|
|
272
296
|
if part.function_call is not None:
|
|
273
297
|
content_items.append(
|
|
274
298
|
{
|
|
275
299
|
"type": "tool_call",
|
|
276
300
|
"name": part.function_call.name,
|
|
277
|
-
"arguments": part.function_call.args,
|
|
301
|
+
"arguments": part.function_call.args or {},
|
|
278
302
|
"tool_call_id": part.function_call.name,
|
|
279
|
-
|
|
303
|
+
**self._part_fidelity(part),
|
|
280
304
|
}
|
|
281
305
|
)
|
|
282
306
|
elif part.thought:
|
|
283
307
|
if part.text is not None:
|
|
284
|
-
content_items.append(
|
|
285
|
-
{"type": "thinking", "thinking": part.text, "signature": part.thought_signature}
|
|
286
|
-
)
|
|
308
|
+
content_items.append({"type": "thinking", "thinking": part.text, **self._part_fidelity(part)})
|
|
287
309
|
elif part.inline_data is not None:
|
|
288
310
|
content_items.append(
|
|
289
311
|
{
|
|
290
312
|
"type": "inline_thinking",
|
|
291
313
|
"data": part.inline_data.data,
|
|
292
314
|
"mime_type": part.inline_data.mime_type,
|
|
293
|
-
|
|
315
|
+
**self._part_fidelity(part),
|
|
294
316
|
}
|
|
295
317
|
)
|
|
296
318
|
elif part.inline_data is not None:
|
|
@@ -299,11 +321,11 @@ class Gemini3Client(LLMClient):
|
|
|
299
321
|
"type": "inline_data",
|
|
300
322
|
"data": part.inline_data.data,
|
|
301
323
|
"mime_type": part.inline_data.mime_type,
|
|
302
|
-
|
|
324
|
+
**self._part_fidelity(part),
|
|
303
325
|
}
|
|
304
326
|
)
|
|
305
327
|
elif part.text is not None:
|
|
306
|
-
content_items.append({"type": "text", "text": part.text,
|
|
328
|
+
content_items.append({"type": "text", "text": part.text, **self._part_fidelity(part)})
|
|
307
329
|
else:
|
|
308
330
|
raise ValueError(f"Unknown output: {part}")
|
|
309
331
|
|
|
@@ -414,7 +436,7 @@ class Gemini3Client(LLMClient):
|
|
|
414
436
|
"name": item["name"],
|
|
415
437
|
"arguments": json.dumps(item["arguments"], ensure_ascii=False),
|
|
416
438
|
"tool_call_id": item["tool_call_id"],
|
|
417
|
-
"
|
|
439
|
+
"fidelity": item.get("fidelity"),
|
|
418
440
|
}
|
|
419
441
|
],
|
|
420
442
|
"usage_metadata": None,
|
|
@@ -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 parse_tool_call_arguments
|
|
23
24
|
from ..types import (
|
|
24
25
|
EventType,
|
|
25
26
|
FinishReason,
|
|
@@ -115,6 +116,7 @@ class GLM5_1Client(LLMClient):
|
|
|
115
116
|
content_parts = [] # may be empty for tool results
|
|
116
117
|
tool_calls = [] # may be empty for no tool calls
|
|
117
118
|
thinking = ""
|
|
119
|
+
thinking_fields: set[str | None] = set()
|
|
118
120
|
for item in msg["content_items"]:
|
|
119
121
|
if item["type"] == "text":
|
|
120
122
|
content_parts.append({"type": "text", "text": item["text"]})
|
|
@@ -122,6 +124,7 @@ class GLM5_1Client(LLMClient):
|
|
|
122
124
|
raise ValueError("GLM-5 does not support image inputs.")
|
|
123
125
|
elif item["type"] == "thinking":
|
|
124
126
|
thinking += item["thinking"]
|
|
127
|
+
thinking_fields.add((item.get("fidelity") or {}).get("reasoning_field"))
|
|
125
128
|
elif item["type"] == "tool_call":
|
|
126
129
|
tool_calls.append(
|
|
127
130
|
{
|
|
@@ -159,8 +162,15 @@ class GLM5_1Client(LLMClient):
|
|
|
159
162
|
message["tool_calls"] = tool_calls
|
|
160
163
|
|
|
161
164
|
if thinking:
|
|
162
|
-
|
|
163
|
-
|
|
165
|
+
# send thinking back through the exact field the upstream produced (recorded
|
|
166
|
+
# in the item fidelity); servers may reject the spelling they did not emit
|
|
167
|
+
if thinking_fields == {"reasoning_content"}:
|
|
168
|
+
message["reasoning_content"] = thinking
|
|
169
|
+
elif thinking_fields == {"reasoning"}:
|
|
170
|
+
message["reasoning"] = thinking
|
|
171
|
+
else:
|
|
172
|
+
message["reasoning_content"] = thinking # vLLM & siliconflow compatibility
|
|
173
|
+
message["reasoning"] = thinking # openrouter compatibility
|
|
164
174
|
|
|
165
175
|
# message may be empty for tool results
|
|
166
176
|
if len(message.keys()) > 1:
|
|
@@ -191,15 +201,29 @@ class GLM5_1Client(LLMClient):
|
|
|
191
201
|
event_type = "delta"
|
|
192
202
|
content_items.append({"type": "text", "text": delta.content})
|
|
193
203
|
|
|
194
|
-
# vLLM & siliconflow
|
|
195
|
-
|
|
204
|
+
# the thinking field name differs by server: vLLM & siliconflow use reasoning_content
|
|
205
|
+
# while openrouter uses reasoning; record the wire field that carried each delta
|
|
206
|
+
# so a replay can reproduce exactly the field the upstream produced
|
|
207
|
+
reasoning_content = getattr(delta, "reasoning_content", None)
|
|
208
|
+
reasoning = getattr(delta, "reasoning", None)
|
|
209
|
+
if reasoning_content and reasoning:
|
|
196
210
|
event_type = "delta"
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
211
|
+
# ambiguous origin: record no fidelity so a replay sends both fields back
|
|
212
|
+
content_items.append({"type": "thinking", "thinking": reasoning_content})
|
|
213
|
+
elif reasoning_content:
|
|
214
|
+
event_type = "delta"
|
|
215
|
+
content_items.append(
|
|
216
|
+
{
|
|
217
|
+
"type": "thinking",
|
|
218
|
+
"thinking": reasoning_content,
|
|
219
|
+
"fidelity": {"reasoning_field": "reasoning_content"},
|
|
220
|
+
}
|
|
221
|
+
)
|
|
222
|
+
elif reasoning:
|
|
201
223
|
event_type = "delta"
|
|
202
|
-
content_items.append(
|
|
224
|
+
content_items.append(
|
|
225
|
+
{"type": "thinking", "thinking": reasoning, "fidelity": {"reasoning_field": "reasoning"}}
|
|
226
|
+
)
|
|
203
227
|
|
|
204
228
|
if delta.tool_calls:
|
|
205
229
|
event_type = "delta"
|
|
@@ -307,7 +331,12 @@ class GLM5_1Client(LLMClient):
|
|
|
307
331
|
{
|
|
308
332
|
"type": "tool_call",
|
|
309
333
|
"name": partial_tool_call["name"],
|
|
310
|
-
"arguments":
|
|
334
|
+
"arguments": parse_tool_call_arguments(
|
|
335
|
+
partial_tool_call["arguments"],
|
|
336
|
+
self.__class__.__name__,
|
|
337
|
+
partial_tool_call["name"],
|
|
338
|
+
partial_tool_call["tool_call_id"],
|
|
339
|
+
),
|
|
311
340
|
"tool_call_id": partial_tool_call["tool_call_id"],
|
|
312
341
|
}
|
|
313
342
|
],
|
|
@@ -335,7 +364,12 @@ class GLM5_1Client(LLMClient):
|
|
|
335
364
|
{
|
|
336
365
|
"type": "tool_call",
|
|
337
366
|
"name": partial_tool_call["name"],
|
|
338
|
-
"arguments":
|
|
367
|
+
"arguments": parse_tool_call_arguments(
|
|
368
|
+
partial_tool_call["arguments"],
|
|
369
|
+
self.__class__.__name__,
|
|
370
|
+
partial_tool_call["name"],
|
|
371
|
+
partial_tool_call["tool_call_id"],
|
|
372
|
+
),
|
|
339
373
|
"tool_call_id": partial_tool_call["tool_call_id"],
|
|
340
374
|
}
|
|
341
375
|
],
|
|
@@ -20,6 +20,7 @@ from openai import AsyncOpenAI
|
|
|
20
20
|
from openai.types.responses import ResponseInputParam, ResponseStreamEvent
|
|
21
21
|
|
|
22
22
|
from ..base_client import LLMClient
|
|
23
|
+
from ..errors import parse_tool_call_arguments
|
|
23
24
|
from ..types import (
|
|
24
25
|
EventType,
|
|
25
26
|
FinishReason,
|
|
@@ -119,12 +120,13 @@ class GPT5_5Client(LLMClient):
|
|
|
119
120
|
|
|
120
121
|
for item in msg["content_items"]:
|
|
121
122
|
if item["type"] == "text":
|
|
122
|
-
|
|
123
|
-
|
|
123
|
+
phase = (item.get("fidelity") or {}).get("phase")
|
|
124
|
+
if msg["role"] == "assistant" and phase: # split different phases
|
|
125
|
+
if last_phase is not None and last_phase != phase and content_items:
|
|
124
126
|
input_list.append({"role": msg["role"], "content": content_items, "phase": last_phase})
|
|
125
127
|
content_items = []
|
|
126
128
|
|
|
127
|
-
last_phase =
|
|
129
|
+
last_phase = phase
|
|
128
130
|
|
|
129
131
|
if msg["role"] == "user":
|
|
130
132
|
content_items.append({"type": "input_text", "text": item["text"]})
|
|
@@ -133,15 +135,15 @@ class GPT5_5Client(LLMClient):
|
|
|
133
135
|
elif item["type"] == "image_url":
|
|
134
136
|
content_items.append({"type": "input_image", "image_url": item["image_url"]})
|
|
135
137
|
elif item["type"] == "thinking":
|
|
136
|
-
|
|
138
|
+
fidelity = item["fidelity"]
|
|
137
139
|
input_list.append(
|
|
138
140
|
{
|
|
139
141
|
"type": "reasoning",
|
|
140
|
-
"id":
|
|
142
|
+
"id": fidelity["id"],
|
|
141
143
|
"summary": [{"type": "summary_text", "text": item["thinking"]}]
|
|
142
144
|
if item["thinking"]
|
|
143
145
|
else [],
|
|
144
|
-
"encrypted_content":
|
|
146
|
+
"encrypted_content": fidelity["encrypted_content"],
|
|
145
147
|
}
|
|
146
148
|
)
|
|
147
149
|
elif item["type"] == "tool_call":
|
|
@@ -216,16 +218,16 @@ class GPT5_5Client(LLMClient):
|
|
|
216
218
|
# adding the following thinking item leads to 400 invalid request error, why?
|
|
217
219
|
# elif model_output.item.type == "reasoning":
|
|
218
220
|
# event_type = "delta"
|
|
219
|
-
#
|
|
221
|
+
# fidelity = {
|
|
220
222
|
# "id": model_output.item.id,
|
|
221
223
|
# "encrypted_content": model_output.item.encrypted_content,
|
|
222
224
|
# }
|
|
223
|
-
# content_items.append({"type": "thinking", "thinking": "", "
|
|
225
|
+
# content_items.append({"type": "thinking", "thinking": "", "fidelity": fidelity})
|
|
224
226
|
elif model_output.item.type == "message":
|
|
225
227
|
if hasattr(model_output.item, "phase"):
|
|
226
228
|
event_type = "delta"
|
|
227
229
|
content_items.append(
|
|
228
|
-
{"type": "text", "text": "", "phase": getattr(model_output.item, "phase", None)}
|
|
230
|
+
{"type": "text", "text": "", "fidelity": {"phase": getattr(model_output.item, "phase", None)}}
|
|
229
231
|
)
|
|
230
232
|
else:
|
|
231
233
|
event_type = "unused"
|
|
@@ -236,11 +238,11 @@ class GPT5_5Client(LLMClient):
|
|
|
236
238
|
# not sure about the signature of openai, need to check
|
|
237
239
|
if model_output.item.type == "reasoning":
|
|
238
240
|
event_type = "delta"
|
|
239
|
-
|
|
241
|
+
fidelity = {
|
|
240
242
|
"id": model_output.item.id,
|
|
241
243
|
"encrypted_content": model_output.item.encrypted_content,
|
|
242
244
|
}
|
|
243
|
-
content_items.append({"type": "thinking", "thinking": "", "
|
|
245
|
+
content_items.append({"type": "thinking", "thinking": "", "fidelity": fidelity})
|
|
244
246
|
else:
|
|
245
247
|
event_type = "unused"
|
|
246
248
|
|
|
@@ -341,7 +343,12 @@ class GPT5_5Client(LLMClient):
|
|
|
341
343
|
{
|
|
342
344
|
"type": "tool_call",
|
|
343
345
|
"name": partial_tool_call["name"],
|
|
344
|
-
"arguments":
|
|
346
|
+
"arguments": parse_tool_call_arguments(
|
|
347
|
+
partial_tool_call["arguments"],
|
|
348
|
+
self.__class__.__name__,
|
|
349
|
+
partial_tool_call["name"],
|
|
350
|
+
partial_tool_call["tool_call_id"],
|
|
351
|
+
),
|
|
345
352
|
"tool_call_id": partial_tool_call["tool_call_id"],
|
|
346
353
|
}
|
|
347
354
|
],
|
|
@@ -23,6 +23,7 @@ from openai import AsyncOpenAI
|
|
|
23
23
|
from openai.types.chat import ChatCompletionChunk, ChatCompletionMessageParam
|
|
24
24
|
|
|
25
25
|
from ..base_client import LLMClient
|
|
26
|
+
from ..errors import parse_tool_call_arguments
|
|
26
27
|
from ..types import (
|
|
27
28
|
EventType,
|
|
28
29
|
FinishReason,
|
|
@@ -143,6 +144,7 @@ class KimiK2_6Client(LLMClient):
|
|
|
143
144
|
content_parts = [] # may be empty for tool results
|
|
144
145
|
tool_calls = [] # may be empty for no tool calls
|
|
145
146
|
thinking = ""
|
|
147
|
+
thinking_fields: set[str | None] = set()
|
|
146
148
|
for item in msg["content_items"]:
|
|
147
149
|
if item["type"] == "text":
|
|
148
150
|
content_parts.append({"type": "text", "text": item["text"]})
|
|
@@ -151,6 +153,7 @@ class KimiK2_6Client(LLMClient):
|
|
|
151
153
|
content_parts.append({"type": "image_url", "image_url": {"url": base64_image}})
|
|
152
154
|
elif item["type"] == "thinking":
|
|
153
155
|
thinking += item["thinking"]
|
|
156
|
+
thinking_fields.add((item.get("fidelity") or {}).get("reasoning_field"))
|
|
154
157
|
elif item["type"] == "tool_call":
|
|
155
158
|
tool_calls.append(
|
|
156
159
|
{
|
|
@@ -196,8 +199,15 @@ class KimiK2_6Client(LLMClient):
|
|
|
196
199
|
message["tool_calls"] = tool_calls
|
|
197
200
|
|
|
198
201
|
if thinking:
|
|
199
|
-
|
|
200
|
-
|
|
202
|
+
# send thinking back through the exact field the upstream produced (recorded
|
|
203
|
+
# in the item fidelity); servers may reject the spelling they did not emit
|
|
204
|
+
if thinking_fields == {"reasoning_content"}:
|
|
205
|
+
message["reasoning_content"] = thinking
|
|
206
|
+
elif thinking_fields == {"reasoning"}:
|
|
207
|
+
message["reasoning"] = thinking
|
|
208
|
+
else:
|
|
209
|
+
message["reasoning_content"] = thinking # vLLM & siliconflow compatibility
|
|
210
|
+
message["reasoning"] = thinking # openrouter compatibility
|
|
201
211
|
|
|
202
212
|
# message may be empty for tool results
|
|
203
213
|
if len(message.keys()) > 1:
|
|
@@ -228,15 +238,29 @@ class KimiK2_6Client(LLMClient):
|
|
|
228
238
|
event_type = "delta"
|
|
229
239
|
content_items.append({"type": "text", "text": delta.content})
|
|
230
240
|
|
|
231
|
-
# vLLM & siliconflow
|
|
232
|
-
|
|
241
|
+
# the thinking field name differs by server: vLLM & siliconflow use reasoning_content
|
|
242
|
+
# while openrouter uses reasoning; record the wire field that carried each delta
|
|
243
|
+
# so a replay can reproduce exactly the field the upstream produced
|
|
244
|
+
reasoning_content = getattr(delta, "reasoning_content", None)
|
|
245
|
+
reasoning = getattr(delta, "reasoning", None)
|
|
246
|
+
if reasoning_content and reasoning:
|
|
233
247
|
event_type = "delta"
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
248
|
+
# ambiguous origin: record no fidelity so a replay sends both fields back
|
|
249
|
+
content_items.append({"type": "thinking", "thinking": reasoning_content})
|
|
250
|
+
elif reasoning_content:
|
|
251
|
+
event_type = "delta"
|
|
252
|
+
content_items.append(
|
|
253
|
+
{
|
|
254
|
+
"type": "thinking",
|
|
255
|
+
"thinking": reasoning_content,
|
|
256
|
+
"fidelity": {"reasoning_field": "reasoning_content"},
|
|
257
|
+
}
|
|
258
|
+
)
|
|
259
|
+
elif reasoning:
|
|
238
260
|
event_type = "delta"
|
|
239
|
-
content_items.append(
|
|
261
|
+
content_items.append(
|
|
262
|
+
{"type": "thinking", "thinking": reasoning, "fidelity": {"reasoning_field": "reasoning"}}
|
|
263
|
+
)
|
|
240
264
|
|
|
241
265
|
if delta.tool_calls:
|
|
242
266
|
event_type = "delta"
|
|
@@ -341,7 +365,12 @@ class KimiK2_6Client(LLMClient):
|
|
|
341
365
|
{
|
|
342
366
|
"type": "tool_call",
|
|
343
367
|
"name": partial_tool_call["name"],
|
|
344
|
-
"arguments":
|
|
368
|
+
"arguments": parse_tool_call_arguments(
|
|
369
|
+
partial_tool_call["arguments"],
|
|
370
|
+
self.__class__.__name__,
|
|
371
|
+
partial_tool_call["name"],
|
|
372
|
+
partial_tool_call["tool_call_id"],
|
|
373
|
+
),
|
|
345
374
|
"tool_call_id": partial_tool_call["tool_call_id"],
|
|
346
375
|
}
|
|
347
376
|
],
|
|
@@ -369,7 +398,12 @@ class KimiK2_6Client(LLMClient):
|
|
|
369
398
|
{
|
|
370
399
|
"type": "tool_call",
|
|
371
400
|
"name": partial_tool_call["name"],
|
|
372
|
-
"arguments":
|
|
401
|
+
"arguments": parse_tool_call_arguments(
|
|
402
|
+
partial_tool_call["arguments"],
|
|
403
|
+
self.__class__.__name__,
|
|
404
|
+
partial_tool_call["name"],
|
|
405
|
+
partial_tool_call["tool_call_id"],
|
|
406
|
+
),
|
|
373
407
|
"tool_call_id": partial_tool_call["tool_call_id"],
|
|
374
408
|
}
|
|
375
409
|
],
|
|
@@ -23,6 +23,7 @@ from openai import AsyncOpenAI
|
|
|
23
23
|
from openai.types.chat import ChatCompletionChunk, ChatCompletionMessageParam
|
|
24
24
|
|
|
25
25
|
from ..base_client import LLMClient
|
|
26
|
+
from ..errors import parse_tool_call_arguments
|
|
26
27
|
from ..types import (
|
|
27
28
|
EventType,
|
|
28
29
|
FinishReason,
|
|
@@ -128,6 +129,7 @@ class OpenaiClient(LLMClient):
|
|
|
128
129
|
content_parts = [] # may be empty for tool results
|
|
129
130
|
tool_calls = [] # may be empty for no tool calls
|
|
130
131
|
thinking = ""
|
|
132
|
+
thinking_fields: set[str | None] = set()
|
|
131
133
|
for item in msg["content_items"]:
|
|
132
134
|
if item["type"] == "text":
|
|
133
135
|
content_parts.append({"type": "text", "text": item["text"]})
|
|
@@ -136,6 +138,7 @@ class OpenaiClient(LLMClient):
|
|
|
136
138
|
content_parts.append({"type": "image_url", "image_url": {"url": base64_image}})
|
|
137
139
|
elif item["type"] == "thinking":
|
|
138
140
|
thinking += item["thinking"]
|
|
141
|
+
thinking_fields.add((item.get("fidelity") or {}).get("reasoning_field"))
|
|
139
142
|
elif item["type"] == "tool_call":
|
|
140
143
|
tool_calls.append(
|
|
141
144
|
{
|
|
@@ -181,8 +184,15 @@ class OpenaiClient(LLMClient):
|
|
|
181
184
|
message["tool_calls"] = tool_calls
|
|
182
185
|
|
|
183
186
|
if thinking:
|
|
184
|
-
|
|
185
|
-
|
|
187
|
+
# send thinking back through the exact field the upstream produced (recorded
|
|
188
|
+
# in the item fidelity); servers may reject the spelling they did not emit
|
|
189
|
+
if thinking_fields == {"reasoning_content"}:
|
|
190
|
+
message["reasoning_content"] = thinking
|
|
191
|
+
elif thinking_fields == {"reasoning"}:
|
|
192
|
+
message["reasoning"] = thinking
|
|
193
|
+
else:
|
|
194
|
+
message["reasoning_content"] = thinking # vLLM & siliconflow compatibility
|
|
195
|
+
message["reasoning"] = thinking # openrouter compatibility
|
|
186
196
|
|
|
187
197
|
# message may be empty for tool results
|
|
188
198
|
if len(message.keys()) > 1:
|
|
@@ -213,15 +223,29 @@ class OpenaiClient(LLMClient):
|
|
|
213
223
|
event_type = "delta"
|
|
214
224
|
content_items.append({"type": "text", "text": delta.content})
|
|
215
225
|
|
|
216
|
-
# vLLM & siliconflow
|
|
217
|
-
|
|
226
|
+
# the thinking field name differs by server: vLLM & siliconflow use reasoning_content
|
|
227
|
+
# while openrouter uses reasoning; record the wire field that carried each delta
|
|
228
|
+
# so a replay can reproduce exactly the field the upstream produced
|
|
229
|
+
reasoning_content = getattr(delta, "reasoning_content", None)
|
|
230
|
+
reasoning = getattr(delta, "reasoning", None)
|
|
231
|
+
if reasoning_content and reasoning:
|
|
218
232
|
event_type = "delta"
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
233
|
+
# ambiguous origin: record no fidelity so a replay sends both fields back
|
|
234
|
+
content_items.append({"type": "thinking", "thinking": reasoning_content})
|
|
235
|
+
elif reasoning_content:
|
|
236
|
+
event_type = "delta"
|
|
237
|
+
content_items.append(
|
|
238
|
+
{
|
|
239
|
+
"type": "thinking",
|
|
240
|
+
"thinking": reasoning_content,
|
|
241
|
+
"fidelity": {"reasoning_field": "reasoning_content"},
|
|
242
|
+
}
|
|
243
|
+
)
|
|
244
|
+
elif reasoning:
|
|
223
245
|
event_type = "delta"
|
|
224
|
-
content_items.append(
|
|
246
|
+
content_items.append(
|
|
247
|
+
{"type": "thinking", "thinking": reasoning, "fidelity": {"reasoning_field": "reasoning"}}
|
|
248
|
+
)
|
|
225
249
|
|
|
226
250
|
if delta.tool_calls:
|
|
227
251
|
for tool_call in delta.tool_calls:
|
|
@@ -325,7 +349,12 @@ class OpenaiClient(LLMClient):
|
|
|
325
349
|
{
|
|
326
350
|
"type": "tool_call",
|
|
327
351
|
"name": partial_tool_call["name"],
|
|
328
|
-
"arguments":
|
|
352
|
+
"arguments": parse_tool_call_arguments(
|
|
353
|
+
partial_tool_call["arguments"],
|
|
354
|
+
self.__class__.__name__,
|
|
355
|
+
partial_tool_call["name"],
|
|
356
|
+
partial_tool_call["tool_call_id"],
|
|
357
|
+
),
|
|
329
358
|
"tool_call_id": partial_tool_call["tool_call_id"],
|
|
330
359
|
}
|
|
331
360
|
],
|
|
@@ -353,7 +382,12 @@ class OpenaiClient(LLMClient):
|
|
|
353
382
|
{
|
|
354
383
|
"type": "tool_call",
|
|
355
384
|
"name": partial_tool_call["name"],
|
|
356
|
-
"arguments":
|
|
385
|
+
"arguments": parse_tool_call_arguments(
|
|
386
|
+
partial_tool_call["arguments"],
|
|
387
|
+
self.__class__.__name__,
|
|
388
|
+
partial_tool_call["name"],
|
|
389
|
+
partial_tool_call["tool_call_id"],
|
|
390
|
+
),
|
|
357
391
|
"tool_call_id": partial_tool_call["tool_call_id"],
|
|
358
392
|
}
|
|
359
393
|
],
|
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
# See the License for the specific language governing permissions and
|
|
13
13
|
# limitations under the License.
|
|
14
14
|
|
|
15
|
-
from .client import
|
|
15
|
+
from .client import OpenaiEmbeddingClient
|
|
16
16
|
|
|
17
17
|
|
|
18
|
-
__all__ = ["
|
|
18
|
+
__all__ = ["OpenaiEmbeddingClient"]
|
|
@@ -0,0 +1,80 @@
|
|
|
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 os
|
|
16
|
+
from typing import Any, AsyncIterator
|
|
17
|
+
|
|
18
|
+
from openai import AsyncOpenAI
|
|
19
|
+
|
|
20
|
+
from ..base_client import LLMClient
|
|
21
|
+
from ..types import UniConfig, UniEvent, UniMessage
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class OpenaiEmbeddingClient(LLMClient):
|
|
25
|
+
"""OpenAI Embeddings-compatible client implementation."""
|
|
26
|
+
|
|
27
|
+
def __init__(self, model: str, api_key: str | None = None, base_url: str | None = None):
|
|
28
|
+
"""Initialize OpenAI-compatible embedding client with model, API key, and base URL."""
|
|
29
|
+
self._model = model
|
|
30
|
+
api_key = api_key or os.getenv("OPENAI_API_KEY")
|
|
31
|
+
base_url = base_url or os.getenv("OPENAI_BASE_URL")
|
|
32
|
+
self._client = AsyncOpenAI(api_key=api_key, base_url=base_url)
|
|
33
|
+
self._history: list[UniMessage] = []
|
|
34
|
+
|
|
35
|
+
def transform_uni_config_to_model_config(self, config: UniConfig) -> dict[str, Any]:
|
|
36
|
+
"""Transform universal configuration to OpenAI Embeddings configuration."""
|
|
37
|
+
params: dict[str, Any] = {"model": self._model}
|
|
38
|
+
embedding_config = config.get("embedding_config") or {}
|
|
39
|
+
if embedding_config.get("dimensions") is not None:
|
|
40
|
+
params["dimensions"] = embedding_config["dimensions"]
|
|
41
|
+
return params
|
|
42
|
+
|
|
43
|
+
def transform_uni_message_to_model_input(self, messages: list[UniMessage]) -> list[str]:
|
|
44
|
+
"""Transform universal messages to OpenAI Embeddings input strings."""
|
|
45
|
+
texts = []
|
|
46
|
+
for msg in messages:
|
|
47
|
+
msg_text = ""
|
|
48
|
+
for item in msg["content_items"]:
|
|
49
|
+
if item["type"] != "text":
|
|
50
|
+
raise ValueError("OpenAI embeddings only support text content items.")
|
|
51
|
+
msg_text += item["text"]
|
|
52
|
+
texts.append(msg_text or " ")
|
|
53
|
+
return texts
|
|
54
|
+
|
|
55
|
+
def transform_model_output_to_uni_event(self, model_output: Any) -> UniEvent:
|
|
56
|
+
"""Transform OpenAI Embeddings response to universal event format."""
|
|
57
|
+
usage = getattr(model_output, "usage", None)
|
|
58
|
+
return {
|
|
59
|
+
"role": "assistant",
|
|
60
|
+
"event_type": "stop",
|
|
61
|
+
"content_items": [{"type": "embedding", "embedding": item.embedding} for item in model_output.data],
|
|
62
|
+
"usage_metadata": {
|
|
63
|
+
"cached_tokens": None,
|
|
64
|
+
"prompt_tokens": usage.prompt_tokens if usage else None,
|
|
65
|
+
"thoughts_tokens": None,
|
|
66
|
+
"response_tokens": None,
|
|
67
|
+
},
|
|
68
|
+
"finish_reason": "stop",
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
async def _streaming_response_internal(
|
|
72
|
+
self,
|
|
73
|
+
messages: list[UniMessage],
|
|
74
|
+
config: UniConfig,
|
|
75
|
+
) -> AsyncIterator[UniEvent]:
|
|
76
|
+
"""Generate embeddings using OpenAI Embeddings-compatible API."""
|
|
77
|
+
params = self.transform_uni_config_to_model_config(config)
|
|
78
|
+
params["input"] = self.transform_uni_message_to_model_input(messages)
|
|
79
|
+
result = await self._client.embeddings.create(**params)
|
|
80
|
+
yield self.transform_model_output_to_uni_event(result)
|
|
@@ -42,12 +42,16 @@ FinishReason = Literal["stop", "length", "tool_call", "unknown"]
|
|
|
42
42
|
AspectRatio = Literal["1:1", "2:3", "3:2", "3:4", "4:3", "9:16", "16:9", "21:9"]
|
|
43
43
|
ImageSize = Literal["1K", "2K"]
|
|
44
44
|
|
|
45
|
+
# Arbitrary JSON-style payload of wire-fidelity data recorded by a client, such as
|
|
46
|
+
# thinking signatures, phase labels, or the upstream reasoning field name. Opaque to
|
|
47
|
+
# consumers: pass it back unchanged so a replay reproduces the original wire message.
|
|
48
|
+
Fidelity = dict[str, Any]
|
|
49
|
+
|
|
45
50
|
|
|
46
51
|
class TextContentItem(TypedDict):
|
|
47
52
|
type: Literal["text"]
|
|
48
53
|
text: str
|
|
49
|
-
|
|
50
|
-
signature: NotRequired[str | bytes]
|
|
54
|
+
fidelity: NotRequired[Fidelity]
|
|
51
55
|
|
|
52
56
|
|
|
53
57
|
class ImageContentItem(TypedDict):
|
|
@@ -59,20 +63,20 @@ class InlineDataContentItem(TypedDict):
|
|
|
59
63
|
type: Literal["inline_data"]
|
|
60
64
|
data: bytes
|
|
61
65
|
mime_type: str
|
|
62
|
-
|
|
66
|
+
fidelity: NotRequired[Fidelity]
|
|
63
67
|
|
|
64
68
|
|
|
65
69
|
class ThinkingContentItem(TypedDict):
|
|
66
70
|
type: Literal["thinking"]
|
|
67
71
|
thinking: str
|
|
68
|
-
|
|
72
|
+
fidelity: NotRequired[Fidelity]
|
|
69
73
|
|
|
70
74
|
|
|
71
75
|
class InlineThinkingContentItem(TypedDict):
|
|
72
76
|
type: Literal["inline_thinking"]
|
|
73
77
|
data: bytes
|
|
74
78
|
mime_type: str
|
|
75
|
-
|
|
79
|
+
fidelity: NotRequired[Fidelity]
|
|
76
80
|
|
|
77
81
|
|
|
78
82
|
class ToolCallContentItem(TypedDict):
|
|
@@ -80,7 +84,7 @@ class ToolCallContentItem(TypedDict):
|
|
|
80
84
|
name: str
|
|
81
85
|
arguments: dict[str, Any]
|
|
82
86
|
tool_call_id: str
|
|
83
|
-
|
|
87
|
+
fidelity: NotRequired[Fidelity]
|
|
84
88
|
|
|
85
89
|
|
|
86
90
|
class PartialToolCallContentItem(TypedDict):
|
|
@@ -88,7 +92,7 @@ class PartialToolCallContentItem(TypedDict):
|
|
|
88
92
|
name: str
|
|
89
93
|
arguments: str
|
|
90
94
|
tool_call_id: str
|
|
91
|
-
|
|
95
|
+
fidelity: NotRequired[Fidelity]
|
|
92
96
|
|
|
93
97
|
|
|
94
98
|
class ToolResultContentItem(TypedDict):
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "agenthub-python"
|
|
3
|
-
version = "0.
|
|
3
|
+
version = "0.4.0"
|
|
4
4
|
description = "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
|
readme = "README.md"
|
|
@@ -11,6 +11,11 @@ authors = [
|
|
|
11
11
|
{name = "PrismShadow"},
|
|
12
12
|
]
|
|
13
13
|
|
|
14
|
+
[project.urls]
|
|
15
|
+
Homepage = "https://github.com/Prism-Shadow/agenthub"
|
|
16
|
+
Repository = "https://github.com/Prism-Shadow/agenthub"
|
|
17
|
+
Issues = "https://github.com/Prism-Shadow/agenthub/issues"
|
|
18
|
+
|
|
14
19
|
[project.optional-dependencies]
|
|
15
20
|
dev = ["httpx[socks]", "pytest>=8.4.2", "pytest-asyncio>=0.23.0", "ruff>=0.14.3", "pillow>=10.0.0"]
|
|
16
21
|
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|