agenthub-python 0.4.8__tar.gz → 0.4.10__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.4.8 → agenthub_python-0.4.10}/PKG-INFO +1 -1
- {agenthub_python-0.4.8 → agenthub_python-0.4.10}/agenthub/ant_messages/client.py +9 -2
- {agenthub_python-0.4.8 → agenthub_python-0.4.10}/agenthub/auto_client.py +19 -7
- {agenthub_python-0.4.8 → agenthub_python-0.4.10}/agenthub/claude5/client.py +8 -2
- {agenthub_python-0.4.8 → agenthub_python-0.4.10}/agenthub/deepseek_v4/client.py +8 -1
- {agenthub_python-0.4.8/agenthub/gemini3_7 → agenthub_python-0.4.10/agenthub/gemini3_8}/__init__.py +2 -2
- {agenthub_python-0.4.8/agenthub/gemini3_7 → agenthub_python-0.4.10/agenthub/gemini3_8}/client.py +18 -20
- {agenthub_python-0.4.8 → agenthub_python-0.4.10}/agenthub/glm5_3/client.py +14 -14
- {agenthub_python-0.4.8 → agenthub_python-0.4.10}/agenthub/gpt5_6/client.py +17 -5
- {agenthub_python-0.4.8 → agenthub_python-0.4.10}/agenthub/openai_chat/client.py +13 -5
- agenthub_python-0.4.10/agenthub/openai_chat_vllm_adapter/__init__.py +4 -0
- agenthub_python-0.4.10/agenthub/openai_chat_vllm_adapter/client.py +122 -0
- {agenthub_python-0.4.8 → agenthub_python-0.4.10}/agenthub/openai_responses/client.py +16 -3
- {agenthub_python-0.4.8 → agenthub_python-0.4.10}/agenthub/registry.py +22 -8
- agenthub_python-0.4.10/agenthub/utils.py +243 -0
- {agenthub_python-0.4.8 → agenthub_python-0.4.10}/pyproject.toml +1 -1
- {agenthub_python-0.4.8 → agenthub_python-0.4.10}/pyproject.toml.orig +1 -1
- agenthub_python-0.4.8/agenthub/utils.py +0 -51
- {agenthub_python-0.4.8 → agenthub_python-0.4.10}/README.md +0 -0
- {agenthub_python-0.4.8 → agenthub_python-0.4.10}/agenthub/__init__.py +0 -0
- {agenthub_python-0.4.8 → agenthub_python-0.4.10}/agenthub/abort_signal.py +0 -0
- {agenthub_python-0.4.8 → agenthub_python-0.4.10}/agenthub/ant_messages/__init__.py +0 -0
- {agenthub_python-0.4.8 → agenthub_python-0.4.10}/agenthub/base_client.py +0 -0
- {agenthub_python-0.4.8 → agenthub_python-0.4.10}/agenthub/claude5/__init__.py +0 -0
- {agenthub_python-0.4.8 → agenthub_python-0.4.10}/agenthub/deepseek_v4/__init__.py +0 -0
- {agenthub_python-0.4.8 → agenthub_python-0.4.10}/agenthub/errors.py +0 -0
- {agenthub_python-0.4.8 → agenthub_python-0.4.10}/agenthub/glm5_3/__init__.py +0 -0
- {agenthub_python-0.4.8 → agenthub_python-0.4.10}/agenthub/gpt5_6/__init__.py +0 -0
- {agenthub_python-0.4.8 → agenthub_python-0.4.10}/agenthub/integration/__init__.py +0 -0
- {agenthub_python-0.4.8 → agenthub_python-0.4.10}/agenthub/integration/playground.py +0 -0
- {agenthub_python-0.4.8 → agenthub_python-0.4.10}/agenthub/integration/tracer.py +0 -0
- {agenthub_python-0.4.8 → agenthub_python-0.4.10}/agenthub/kimi_k3/__init__.py +0 -0
- {agenthub_python-0.4.8 → agenthub_python-0.4.10}/agenthub/kimi_k3/client.py +0 -0
- {agenthub_python-0.4.8 → agenthub_python-0.4.10}/agenthub/minimax_m3/__init__.py +0 -0
- {agenthub_python-0.4.8 → agenthub_python-0.4.10}/agenthub/minimax_m3/client.py +0 -0
- {agenthub_python-0.4.8 → agenthub_python-0.4.10}/agenthub/openai_chat/__init__.py +0 -0
- {agenthub_python-0.4.8 → agenthub_python-0.4.10}/agenthub/openai_embedding/__init__.py +0 -0
- {agenthub_python-0.4.8 → agenthub_python-0.4.10}/agenthub/openai_embedding/client.py +0 -0
- {agenthub_python-0.4.8 → agenthub_python-0.4.10}/agenthub/openai_responses/__init__.py +0 -0
- {agenthub_python-0.4.8 → agenthub_python-0.4.10}/agenthub/types.py +0 -0
|
@@ -128,8 +128,15 @@ class AntMessagesClient(LLMClient):
|
|
|
128
128
|
|
|
129
129
|
if config.get("thinking_level") is not None:
|
|
130
130
|
ant_config.update(self._convert_thinking_level_to_thinking_config(config["thinking_level"]))
|
|
131
|
-
|
|
132
|
-
|
|
131
|
+
|
|
132
|
+
if config.get("thinking_summary") is not None:
|
|
133
|
+
# display lives on the thinking block, so a summary asked for on its own selects
|
|
134
|
+
# adaptive thinking. A disabled block is the one place it cannot ride along --
|
|
135
|
+
# "thinking.disabled.display: Extra inputs are not permitted" (400, verified live
|
|
136
|
+
# 2026-09-03) -- and thinking_level NONE disables thinking, leaving nothing to show.
|
|
137
|
+
thinking = ant_config.setdefault("thinking", {"type": "adaptive"})
|
|
138
|
+
if thinking["type"] != "disabled":
|
|
139
|
+
thinking["display"] = "summarized" if config["thinking_summary"] else "omitted"
|
|
133
140
|
|
|
134
141
|
# Convert tools to the Messages API tool schema
|
|
135
142
|
if config.get("tools") is not None:
|
|
@@ -21,7 +21,13 @@ from .types import UniConfig, UniEvent, UniMessage
|
|
|
21
21
|
|
|
22
22
|
|
|
23
23
|
# The generic protocol clients are named explicitly rather than deduced from a model id.
|
|
24
|
-
_PROTOCOL_CLIENT_TYPES = (
|
|
24
|
+
_PROTOCOL_CLIENT_TYPES = (
|
|
25
|
+
"openai-chat",
|
|
26
|
+
"openai-chat-vllm-adapter",
|
|
27
|
+
"openai-responses",
|
|
28
|
+
"ant-messages",
|
|
29
|
+
"openai-embedding",
|
|
30
|
+
)
|
|
25
31
|
|
|
26
32
|
|
|
27
33
|
class AutoLLMClient(LLMClient):
|
|
@@ -65,13 +71,13 @@ class AutoLLMClient(LLMClient):
|
|
|
65
71
|
type[LLMClient] | None: The client class, or None when no client claims the type.
|
|
66
72
|
"""
|
|
67
73
|
# every Gemini generation shares the unified client ("gemini-3" also matches the
|
|
68
|
-
# gemini-3.7/gemini-3.6/gemini-3.5-flash-lite client types)
|
|
74
|
+
# gemini-3.8/gemini-3.7/gemini-3.6/gemini-3.5-flash-lite client types)
|
|
69
75
|
if any(
|
|
70
76
|
prefix in client_type for prefix in ("gemini-3", "gemini-embedding")
|
|
71
|
-
): # e.g., gemini-3.
|
|
72
|
-
from .
|
|
77
|
+
): # e.g., gemini-3.8-flash, gemini-3-flash-preview, gemini-embedding-2
|
|
78
|
+
from .gemini3_8 import Gemini3_8Client
|
|
73
79
|
|
|
74
|
-
return
|
|
80
|
+
return Gemini3_8Client
|
|
75
81
|
elif "claude" in client_type and (
|
|
76
82
|
"4-6" in client_type or "4-7" in client_type or "4-8" in client_type or "-5" in client_type
|
|
77
83
|
): # the whole Claude 4.6+ series shares the unified client, e.g., claude-sonnet-4-6
|
|
@@ -99,6 +105,12 @@ class AutoLLMClient(LLMClient):
|
|
|
99
105
|
from .deepseek_v4 import DeepSeekV4Client
|
|
100
106
|
|
|
101
107
|
return DeepSeekV4Client
|
|
108
|
+
elif client_type == "openai-chat-vllm-adapter":
|
|
109
|
+
# exact match: "openai-chat-vllm-adapter" contains "openai", so the substring
|
|
110
|
+
# branches below would otherwise claim it
|
|
111
|
+
from .openai_chat_vllm_adapter import OpenaiChatVllmAdapterClient
|
|
112
|
+
|
|
113
|
+
return OpenaiChatVllmAdapterClient
|
|
102
114
|
elif "ant-messages" in client_type:
|
|
103
115
|
from .ant_messages import AntMessagesClient
|
|
104
116
|
|
|
@@ -131,10 +143,10 @@ class AutoLLMClient(LLMClient):
|
|
|
131
143
|
if client_class is None:
|
|
132
144
|
raise ValueError(
|
|
133
145
|
f"{client_type} is not supported. "
|
|
134
|
-
"Supported client types: minimax-m3, gemini-3.7, gemini-3.6, gemini-3, "
|
|
146
|
+
"Supported client types: minimax-m3, gemini-3.8, gemini-3.7, gemini-3.6, gemini-3, "
|
|
135
147
|
"claude-5, claude-4-8, claude-4-7, claude-4-6, gpt-5.6, gpt-5.5, gpt-5.4, "
|
|
136
148
|
"glm-5.3, glm-5.2, glm-5.1, kimi-k3, kimi-k2.6, kimi-k2.5, deepseek-v4, "
|
|
137
|
-
"openai-embedding, ant-messages, openai-responses, openai-chat."
|
|
149
|
+
"openai-chat-vllm-adapter, openai-embedding, ant-messages, openai-responses, openai-chat."
|
|
138
150
|
)
|
|
139
151
|
|
|
140
152
|
return client_class(model=model, api_key=api_key, base_url=base_url, default_headers=default_headers)
|
|
@@ -176,8 +176,14 @@ class Claude5Client(LLMClient):
|
|
|
176
176
|
|
|
177
177
|
if config.get("thinking_level") is not None:
|
|
178
178
|
claude_config.update(self._convert_thinking_level_to_thinking_config(config["thinking_level"]))
|
|
179
|
-
|
|
180
|
-
|
|
179
|
+
|
|
180
|
+
if config.get("thinking_summary") is not None:
|
|
181
|
+
# display lives on the thinking block, so a summary asked for on its own selects
|
|
182
|
+
# adaptive thinking, which is what this family runs by default anyway; a block
|
|
183
|
+
# carrying display but no output_config is accepted on 4.6 through 5 (verified
|
|
184
|
+
# live 2026-09-03). NONE omits the block, so the request lands on that default.
|
|
185
|
+
thinking = claude_config.setdefault("thinking", {"type": "adaptive"})
|
|
186
|
+
thinking["display"] = "summarized" if config["thinking_summary"] else "omitted"
|
|
181
187
|
|
|
182
188
|
# Convert tools to Claude's tool schema
|
|
183
189
|
if config.get("tools") is not None:
|
|
@@ -103,10 +103,17 @@ class DeepSeekV4Client(LLMClient):
|
|
|
103
103
|
self.__class__.__name__, "temperature", "DeepSeek V4 does not support setting temperature."
|
|
104
104
|
)
|
|
105
105
|
|
|
106
|
-
# a thinking summary is accepted but never generated, so the parameter is left out
|
|
107
106
|
if config.get("thinking_level") is not None:
|
|
108
107
|
deepseek_config["reasoning"] = {"effort": self._convert_thinking_level_to_effort(config["thinking_level"])}
|
|
109
108
|
|
|
109
|
+
if config.get("thinking_summary"):
|
|
110
|
+
# DeepSeek takes reasoning.summary with or without an effort and returns an empty
|
|
111
|
+
# summary list for now (verified live 2026-09-03 on api.deepseek.com and
|
|
112
|
+
# OpenRouter), so the request carries the preference instead of dropping it and
|
|
113
|
+
# picks up summaries as soon as the vendor generates them. False needs no key:
|
|
114
|
+
# the Responses API returns no summary unless one is asked for.
|
|
115
|
+
deepseek_config.setdefault("reasoning", {})["summary"] = "concise"
|
|
116
|
+
|
|
110
117
|
if config.get("tools") is not None:
|
|
111
118
|
deepseek_config["tools"] = [{"type": "function", **tool} for tool in config["tools"]]
|
|
112
119
|
|
{agenthub_python-0.4.8/agenthub/gemini3_7 → agenthub_python-0.4.10/agenthub/gemini3_8}/__init__.py
RENAMED
|
@@ -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 Gemini3_8Client
|
|
16
16
|
|
|
17
17
|
|
|
18
|
-
__all__ = ["
|
|
18
|
+
__all__ = ["Gemini3_8Client"]
|
{agenthub_python-0.4.8/agenthub/gemini3_7 → agenthub_python-0.4.10/agenthub/gemini3_8}/client.py
RENAMED
|
@@ -61,14 +61,14 @@ def _split_function_response_runs(parts: list[types.Part]) -> list[list[types.Pa
|
|
|
61
61
|
return runs if runs else [parts]
|
|
62
62
|
|
|
63
63
|
|
|
64
|
-
class
|
|
65
|
-
"""Unified client for the Gemini family, named for the newest generation it serves (3.
|
|
66
|
-
|
|
67
|
-
It serves every generateContent model generation (3.
|
|
68
|
-
and embedding models
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
64
|
+
class Gemini3_8Client(LLMClient):
|
|
65
|
+
"""Unified client for the Gemini family, named for the newest generation it serves (3.8).
|
|
66
|
+
|
|
67
|
+
It serves every generateContent model generation (3.8 back through 3.x text, image, TTS,
|
|
68
|
+
and embedding models). The API deprecated the temperature/top_p/top_k sampling parameters
|
|
69
|
+
starting with the 3.6 generation (silently ignored today, HTTP 400 in future generations),
|
|
70
|
+
and this client applies that contract to the whole family: temperature is rejected
|
|
71
|
+
everywhere.
|
|
72
72
|
"""
|
|
73
73
|
|
|
74
74
|
def __init__(
|
|
@@ -78,7 +78,7 @@ class Gemini3_7Client(LLMClient):
|
|
|
78
78
|
base_url: str | None = None,
|
|
79
79
|
default_headers: dict[str, str] | None = None,
|
|
80
80
|
):
|
|
81
|
-
"""Initialize Gemini 3.
|
|
81
|
+
"""Initialize Gemini 3.8 client with model and API key."""
|
|
82
82
|
self._model = model
|
|
83
83
|
api_key = api_key or os.getenv("GEMINI_API_KEY")
|
|
84
84
|
base_url = base_url or os.getenv("GEMINI_BASE_URL")
|
|
@@ -139,15 +139,11 @@ class Gemini3_7Client(LLMClient):
|
|
|
139
139
|
)
|
|
140
140
|
|
|
141
141
|
def _supported_thinking_levels(self) -> tuple[types.ThinkingLevel, ...]:
|
|
142
|
-
"""Thinking levels the target model accepts (llmsdk_docs/
|
|
142
|
+
"""Thinking levels the target model accepts (llmsdk_docs/gemini3_8/docs/thinking.md).
|
|
143
143
|
|
|
144
144
|
An empty tuple means the model rejects the thinking_level parameter
|
|
145
145
|
entirely, so it must be omitted from the request.
|
|
146
146
|
"""
|
|
147
|
-
if "gemini-2.5" in self._model:
|
|
148
|
-
# The vendor table claims low/medium/high, but the live API rejects
|
|
149
|
-
# every thinking_level value for the 2.5 series (verified 2026-07-24).
|
|
150
|
-
return ()
|
|
151
147
|
if "-image" in self._model:
|
|
152
148
|
return (types.ThinkingLevel.MINIMAL, types.ThinkingLevel.HIGH)
|
|
153
149
|
if "gemini-3-pro" in self._model:
|
|
@@ -159,8 +155,9 @@ class Gemini3_7Client(LLMClient):
|
|
|
159
155
|
# would have accepted costs a little accuracy, forwarding an
|
|
160
156
|
# unsupported one is a 400).
|
|
161
157
|
return (types.ThinkingLevel.LOW, types.ThinkingLevel.MEDIUM, types.ThinkingLevel.HIGH)
|
|
162
|
-
if "gemini-3.7" in self._model:
|
|
163
|
-
#
|
|
158
|
+
if "gemini-3.7" in self._model or "gemini-3.8" in self._model:
|
|
159
|
+
# Both generations reject "minimal" with a 400 (3.7 verified live 2026-08-13;
|
|
160
|
+
# 3.8 documented at ai.google.dev/gemini-api/docs/latest-model).
|
|
164
161
|
return (types.ThinkingLevel.LOW, types.ThinkingLevel.MEDIUM, types.ThinkingLevel.HIGH)
|
|
165
162
|
return self._GEMINI_LEVEL_ORDER
|
|
166
163
|
|
|
@@ -180,8 +177,9 @@ class Gemini3_7Client(LLMClient):
|
|
|
180
177
|
return None
|
|
181
178
|
supported = self._supported_thinking_levels()
|
|
182
179
|
if not supported:
|
|
183
|
-
#
|
|
184
|
-
#
|
|
180
|
+
# A model that takes no thinking_level at all has nothing to clamp onto, so the
|
|
181
|
+
# parameter is omitted rather than turned into a failed request. thinking_summary
|
|
182
|
+
# is unaffected -- include_thoughts still rides along.
|
|
185
183
|
return None
|
|
186
184
|
if level in supported:
|
|
187
185
|
return level
|
|
@@ -281,7 +279,7 @@ class Gemini3_7Client(LLMClient):
|
|
|
281
279
|
config_params["system_instruction"] = config["system_prompt"]
|
|
282
280
|
|
|
283
281
|
# include_thoughts asks for thought summaries, but whether generateContent returns any
|
|
284
|
-
# is model-dependent (llmsdk_docs/
|
|
282
|
+
# is model-dependent (llmsdk_docs/gemini3_8/docs/thinking.md)
|
|
285
283
|
thinking_summary = config.get("thinking_summary")
|
|
286
284
|
thinking_level = config.get("thinking_level")
|
|
287
285
|
if thinking_summary is not None or thinking_level is not None:
|
|
@@ -570,7 +568,7 @@ class Gemini3_7Client(LLMClient):
|
|
|
570
568
|
|
|
571
569
|
for item in event["content_items"]:
|
|
572
570
|
if item["type"] == "tool_call":
|
|
573
|
-
#
|
|
571
|
+
# the Gemini API does not stream partial tool calls, mock a partial tool call event
|
|
574
572
|
yield {
|
|
575
573
|
"role": "assistant",
|
|
576
574
|
"event_type": "delta",
|
|
@@ -75,9 +75,11 @@ class GLM5_3Client(LLMClient):
|
|
|
75
75
|
|
|
76
76
|
GLM-5.3 accepts only low/high/max and errors on anything else, so the client
|
|
77
77
|
clamps to the closest value; NONE rides on low because 5.3 cannot disable
|
|
78
|
-
thinking.
|
|
79
|
-
(low/medium to high, xhigh to max)
|
|
80
|
-
|
|
78
|
+
thinking. Every earlier generation takes the vocabulary unchanged: 5.2 maps it
|
|
79
|
+
server-side (low/medium to high, xhigh to max), and 5.1 and below accept the
|
|
80
|
+
parameter and ignore it (verified live 2026-09-03 on Z.AI, OpenRouter and
|
|
81
|
+
SiliconFlow), so the level is forwarded there rather than dropped. Outside 5.3
|
|
82
|
+
NONE disables thinking outright, which leaves no effort to send.
|
|
81
83
|
"""
|
|
82
84
|
model = self._model.lower() # provider-hosted ids keep their own casing
|
|
83
85
|
if "glm-5.3" in model:
|
|
@@ -90,17 +92,15 @@ class GLM5_3Client(LLMClient):
|
|
|
90
92
|
ThinkingLevel.MAX: "max",
|
|
91
93
|
}
|
|
92
94
|
return mapping.get(thinking_level)
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
return mapping.get(thinking_level)
|
|
103
|
-
return None
|
|
95
|
+
mapping = {
|
|
96
|
+
ThinkingLevel.NONE: None,
|
|
97
|
+
ThinkingLevel.LOW: "low",
|
|
98
|
+
ThinkingLevel.MEDIUM: "medium",
|
|
99
|
+
ThinkingLevel.HIGH: "high",
|
|
100
|
+
ThinkingLevel.XHIGH: "xhigh",
|
|
101
|
+
ThinkingLevel.MAX: "max",
|
|
102
|
+
}
|
|
103
|
+
return mapping.get(thinking_level)
|
|
104
104
|
|
|
105
105
|
def _convert_tool_choice(self, tool_choice: ToolChoice) -> str:
|
|
106
106
|
"""Convert ToolChoice to OpenAI's tool_choice format."""
|
|
@@ -33,7 +33,7 @@ from ..types import (
|
|
|
33
33
|
UniMessage,
|
|
34
34
|
UsageMetadata,
|
|
35
35
|
)
|
|
36
|
-
from ..utils import is_debug_enabled
|
|
36
|
+
from ..utils import is_debug_enabled, openai_image_detail
|
|
37
37
|
|
|
38
38
|
|
|
39
39
|
class GPT5_6Client(LLMClient):
|
|
@@ -71,6 +71,14 @@ class GPT5_6Client(LLMClient):
|
|
|
71
71
|
return {"mode": "required", "tools": [{"type": "function", "name": name} for name in tool_choice]}
|
|
72
72
|
return tool_choice
|
|
73
73
|
|
|
74
|
+
def _convert_image_url(self, image_url: str) -> dict[str, str]:
|
|
75
|
+
"""Convert an image URL to an input_image item, at the detail the API needs to read it."""
|
|
76
|
+
item = {"type": "input_image", "image_url": image_url}
|
|
77
|
+
if detail := openai_image_detail(self._model, image_url):
|
|
78
|
+
item["detail"] = detail
|
|
79
|
+
|
|
80
|
+
return item
|
|
81
|
+
|
|
74
82
|
def transform_uni_config_to_model_config(self, config: UniConfig) -> dict[str, Any]:
|
|
75
83
|
"""
|
|
76
84
|
Transform universal configuration to OpenAI Responses API configuration.
|
|
@@ -98,8 +106,12 @@ class GPT5_6Client(LLMClient):
|
|
|
98
106
|
|
|
99
107
|
if config.get("thinking_level") is not None:
|
|
100
108
|
openai_config["reasoning"] = {"effort": self._convert_thinking_level_to_effort(config["thinking_level"])}
|
|
101
|
-
|
|
102
|
-
|
|
109
|
+
|
|
110
|
+
if config.get("thinking_summary"):
|
|
111
|
+
# reasoning.summary stands on its own, with or without an effort (verified live
|
|
112
|
+
# 2026-09-03 on the OpenAI and OpenRouter endpoints). False needs no key: the
|
|
113
|
+
# Responses API returns no summary unless one is asked for.
|
|
114
|
+
openai_config.setdefault("reasoning", {})["summary"] = "concise"
|
|
103
115
|
|
|
104
116
|
if config.get("tools") is not None:
|
|
105
117
|
openai_config["tools"] = [{"type": "function", **tool} for tool in config["tools"]]
|
|
@@ -158,7 +170,7 @@ class GPT5_6Client(LLMClient):
|
|
|
158
170
|
else:
|
|
159
171
|
content_items.append({"type": "output_text", "text": item["text"]})
|
|
160
172
|
elif item["type"] == "image_url":
|
|
161
|
-
content_items.append(
|
|
173
|
+
content_items.append(self._convert_image_url(item["image_url"]))
|
|
162
174
|
elif item["type"] == "thinking":
|
|
163
175
|
# rebuild the reasoning item from the recorded wire fields: the thinking
|
|
164
176
|
# text goes back through the channel that carried it (histories recorded
|
|
@@ -196,7 +208,7 @@ class GPT5_6Client(LLMClient):
|
|
|
196
208
|
tool_result = [{"type": "input_text", "text": item["text"]}]
|
|
197
209
|
if "images" in item:
|
|
198
210
|
for image_url in item["images"]:
|
|
199
|
-
tool_result.append(
|
|
211
|
+
tool_result.append(self._convert_image_url(image_url))
|
|
200
212
|
|
|
201
213
|
input_list.append(
|
|
202
214
|
{"type": "function_call_output", "call_id": item["tool_call_id"], "output": tool_result}
|
|
@@ -35,7 +35,7 @@ from ..types import (
|
|
|
35
35
|
UniMessage,
|
|
36
36
|
UsageMetadata,
|
|
37
37
|
)
|
|
38
|
-
from ..utils import fix_openrouter_usage_metadata
|
|
38
|
+
from ..utils import fix_openrouter_usage_metadata, openai_image_detail
|
|
39
39
|
|
|
40
40
|
|
|
41
41
|
class OpenaiChatClient(LLMClient):
|
|
@@ -88,6 +88,14 @@ class OpenaiChatClient(LLMClient):
|
|
|
88
88
|
|
|
89
89
|
return tool_choice
|
|
90
90
|
|
|
91
|
+
def _convert_image_url(self, data_url: str) -> dict[str, Any]:
|
|
92
|
+
"""Convert a fetched image to an image_url part, at the detail the API needs to read it."""
|
|
93
|
+
image_url = {"url": data_url}
|
|
94
|
+
if detail := openai_image_detail(self._model, data_url):
|
|
95
|
+
image_url["detail"] = detail
|
|
96
|
+
|
|
97
|
+
return {"type": "image_url", "image_url": image_url}
|
|
98
|
+
|
|
91
99
|
def transform_uni_config_to_model_config(self, config: UniConfig) -> dict[str, Any]:
|
|
92
100
|
"""
|
|
93
101
|
Transform universal configuration to OpenAI Chat Completions configuration.
|
|
@@ -146,7 +154,7 @@ class OpenaiChatClient(LLMClient):
|
|
|
146
154
|
content_parts.append({"type": "text", "text": item["text"]})
|
|
147
155
|
elif item["type"] == "image_url":
|
|
148
156
|
base64_image = await self._convert_image_url_to_base64(item["image_url"])
|
|
149
|
-
content_parts.append(
|
|
157
|
+
content_parts.append(self._convert_image_url(base64_image))
|
|
150
158
|
elif item["type"] == "thinking":
|
|
151
159
|
thinking += item["thinking"]
|
|
152
160
|
thinking_fields.add((item.get("fidelity") or {}).get("reasoning_field"))
|
|
@@ -169,12 +177,12 @@ class OpenaiChatClient(LLMClient):
|
|
|
169
177
|
|
|
170
178
|
if "images" in item and item["images"]:
|
|
171
179
|
for image_url in item["images"]:
|
|
172
|
-
|
|
180
|
+
part = self._convert_image_url(await self._convert_image_url_to_base64(image_url))
|
|
173
181
|
if "siliconflow.cn" in str(self._client.base_url):
|
|
174
182
|
# siliconflow does not support image_url in tool result
|
|
175
|
-
content_parts.append(
|
|
183
|
+
content_parts.append(part)
|
|
176
184
|
else:
|
|
177
|
-
content.append(
|
|
185
|
+
content.append(part)
|
|
178
186
|
|
|
179
187
|
# Tool results are sent as separate messages
|
|
180
188
|
openai_messages.append(
|
|
@@ -0,0 +1,122 @@
|
|
|
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 typing import Any
|
|
16
|
+
|
|
17
|
+
from ..openai_chat import OpenaiChatClient
|
|
18
|
+
from ..types import ThinkingLevel, UniConfig
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
# vLLM passes chat_template_kwargs straight to the served model's chat template, so the
|
|
22
|
+
# switch that turns thinking on is whatever that template happens to read. Each profile
|
|
23
|
+
# below maps an AgentHub level onto one family's kwargs; an empty mapping means the
|
|
24
|
+
# request carries no chat_template_kwargs at all.
|
|
25
|
+
#
|
|
26
|
+
# The upstream artifacts these profiles are read off, and the clamping those artifacts
|
|
27
|
+
# force, are snapshotted in llmsdk_docs/openai_chat_vllm_adapter/. Update that snapshot
|
|
28
|
+
# whenever a model is added here.
|
|
29
|
+
|
|
30
|
+
# Qwen3 templates read a single enable_thinking boolean and no effort key at all.
|
|
31
|
+
_QWEN3_THINKING: dict[ThinkingLevel, dict[str, Any]] = {
|
|
32
|
+
ThinkingLevel.NONE: {"enable_thinking": False},
|
|
33
|
+
ThinkingLevel.LOW: {"enable_thinking": True},
|
|
34
|
+
ThinkingLevel.MEDIUM: {"enable_thinking": True},
|
|
35
|
+
ThinkingLevel.HIGH: {"enable_thinking": True},
|
|
36
|
+
ThinkingLevel.XHIGH: {"enable_thinking": True},
|
|
37
|
+
ThinkingLevel.MAX: {"enable_thinking": True},
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
# Qwen3.8-27B and Qwen3.8-Flash-Next ship the same chat template, byte for byte, so they
|
|
41
|
+
# share a profile. It keeps enable_thinking as the off switch and takes its adaptive modes
|
|
42
|
+
# as reasoning_effort, validated against low/medium/xhigh, so high and max clamp to xhigh.
|
|
43
|
+
# The template defaults the key to xhigh, so a model on this template that is not sent the
|
|
44
|
+
# key runs every level at full effort.
|
|
45
|
+
_QWEN3_8_THINKING: dict[ThinkingLevel, dict[str, Any]] = {
|
|
46
|
+
ThinkingLevel.NONE: {"enable_thinking": False},
|
|
47
|
+
ThinkingLevel.LOW: {"reasoning_effort": "low"},
|
|
48
|
+
ThinkingLevel.MEDIUM: {"reasoning_effort": "medium"},
|
|
49
|
+
ThinkingLevel.HIGH: {"reasoning_effort": "xhigh"},
|
|
50
|
+
ThinkingLevel.XHIGH: {"reasoning_effort": "xhigh"},
|
|
51
|
+
ThinkingLevel.MAX: {"reasoning_effort": "xhigh"},
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
# DeepSeek V4 publishes no chat template; vLLM reads a thinking flag paired with
|
|
55
|
+
# reasoning_effort, and thinking is off whenever the flag is absent, which is what NONE
|
|
56
|
+
# sends. DeepSeek-V4-Pro and DeepSeek-V4-Flash share an encoding module that asserts
|
|
57
|
+
# reasoning_effort in ['max', None, 'high'], so low is a failed request rather than a
|
|
58
|
+
# weaker answer and high is the lowest value they take. That module then branches on 'max'
|
|
59
|
+
# alone, which means LOW through XHIGH all render the same prompt on these two models.
|
|
60
|
+
_DEEPSEEK_V4_PRO_FLASH_THINKING: dict[ThinkingLevel, dict[str, Any]] = {
|
|
61
|
+
ThinkingLevel.NONE: {},
|
|
62
|
+
ThinkingLevel.LOW: {"thinking": True, "reasoning_effort": "high"},
|
|
63
|
+
ThinkingLevel.MEDIUM: {"thinking": True, "reasoning_effort": "high"},
|
|
64
|
+
ThinkingLevel.HIGH: {"thinking": True, "reasoning_effort": "high"},
|
|
65
|
+
ThinkingLevel.XHIGH: {"thinking": True, "reasoning_effort": "high"},
|
|
66
|
+
ThinkingLevel.MAX: {"thinking": True, "reasoning_effort": "max"},
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
# DeepSeek-V4-Flash-Vision-Exp ships a different copy of that encoding module, one that
|
|
70
|
+
# validates reasoning_effort against a low/high/max table, so it keeps the finer scale;
|
|
71
|
+
# medium and xhigh clamp to high.
|
|
72
|
+
_DEEPSEEK_V4_VISION_EXP_THINKING: dict[ThinkingLevel, dict[str, Any]] = {
|
|
73
|
+
ThinkingLevel.NONE: {},
|
|
74
|
+
ThinkingLevel.LOW: {"thinking": True, "reasoning_effort": "low"},
|
|
75
|
+
ThinkingLevel.MEDIUM: {"thinking": True, "reasoning_effort": "high"},
|
|
76
|
+
ThinkingLevel.HIGH: {"thinking": True, "reasoning_effort": "high"},
|
|
77
|
+
ThinkingLevel.XHIGH: {"thinking": True, "reasoning_effort": "high"},
|
|
78
|
+
ThinkingLevel.MAX: {"thinking": True, "reasoning_effort": "max"},
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
# Keys are matched as substrings of the lowercased model id, so a served id keeps whatever
|
|
82
|
+
# prefix the deployment gave it (Qwen/Qwen3.6-35B-A3B, deepseek-ai/DeepSeek-V4-Pro). The
|
|
83
|
+
# first match wins, so a key that contains another must come first: deepseek-v4-flash is a
|
|
84
|
+
# prefix of deepseek-v4-flash-vision-exp.
|
|
85
|
+
_MODEL_THINKING_PROFILES: tuple[tuple[str, dict[ThinkingLevel, dict[str, Any]]], ...] = (
|
|
86
|
+
("qwen3.8-flash-next", _QWEN3_8_THINKING),
|
|
87
|
+
("qwen3.8-27b", _QWEN3_8_THINKING),
|
|
88
|
+
("qwen3.6-35b-a3b", _QWEN3_THINKING),
|
|
89
|
+
("qwen3.5-0.8b", _QWEN3_THINKING),
|
|
90
|
+
("qwen3.5-9b", _QWEN3_THINKING),
|
|
91
|
+
("deepseek-v4-flash-vision-exp", _DEEPSEEK_V4_VISION_EXP_THINKING),
|
|
92
|
+
("deepseek-v4-pro", _DEEPSEEK_V4_PRO_FLASH_THINKING),
|
|
93
|
+
("deepseek-v4-flash", _DEEPSEEK_V4_PRO_FLASH_THINKING),
|
|
94
|
+
)
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
class OpenaiChatVllmAdapterClient(OpenaiChatClient):
|
|
98
|
+
"""Models served through vLLM's OpenAI-compatible Chat Completions API."""
|
|
99
|
+
|
|
100
|
+
def _thinking_chat_template_kwargs(self, thinking_level: ThinkingLevel) -> dict[str, Any]:
|
|
101
|
+
"""Return the chat_template_kwargs this model's template reads for the level.
|
|
102
|
+
|
|
103
|
+
A model outside the table falls back to Qwen3's enable_thinking, the most
|
|
104
|
+
widespread of the conventions and inert on a template that ignores the key.
|
|
105
|
+
"""
|
|
106
|
+
model = self._model.lower()
|
|
107
|
+
for name, profile in _MODEL_THINKING_PROFILES:
|
|
108
|
+
if name in model:
|
|
109
|
+
return dict(profile[thinking_level])
|
|
110
|
+
|
|
111
|
+
return dict(_QWEN3_THINKING[thinking_level])
|
|
112
|
+
|
|
113
|
+
def transform_uni_config_to_model_config(self, config: UniConfig) -> dict[str, Any]:
|
|
114
|
+
"""Map AgentHub's level onto the thinking switch this model's chat template reads."""
|
|
115
|
+
vllm_config = super().transform_uni_config_to_model_config(config)
|
|
116
|
+
|
|
117
|
+
if config.get("thinking_level") is not None:
|
|
118
|
+
chat_template_kwargs = self._thinking_chat_template_kwargs(config["thinking_level"])
|
|
119
|
+
if chat_template_kwargs:
|
|
120
|
+
vllm_config["chat_template_kwargs"] = chat_template_kwargs
|
|
121
|
+
|
|
122
|
+
return vllm_config
|
|
@@ -33,7 +33,7 @@ from ..types import (
|
|
|
33
33
|
UniMessage,
|
|
34
34
|
UsageMetadata,
|
|
35
35
|
)
|
|
36
|
-
from ..utils import is_debug_enabled
|
|
36
|
+
from ..utils import is_debug_enabled, openai_image_detail
|
|
37
37
|
|
|
38
38
|
|
|
39
39
|
class OpenaiResponsesClient(LLMClient):
|
|
@@ -71,6 +71,14 @@ class OpenaiResponsesClient(LLMClient):
|
|
|
71
71
|
return {"mode": "required", "tools": [{"type": "function", "name": name} for name in tool_choice]}
|
|
72
72
|
return tool_choice
|
|
73
73
|
|
|
74
|
+
def _convert_image_url(self, image_url: str) -> dict[str, str]:
|
|
75
|
+
"""Convert an image URL to an input_image item, at the detail the API needs to read it."""
|
|
76
|
+
item = {"type": "input_image", "image_url": image_url}
|
|
77
|
+
if detail := openai_image_detail(self._model, image_url):
|
|
78
|
+
item["detail"] = detail
|
|
79
|
+
|
|
80
|
+
return item
|
|
81
|
+
|
|
74
82
|
def transform_uni_config_to_model_config(self, config: UniConfig) -> dict[str, Any]:
|
|
75
83
|
"""
|
|
76
84
|
Transform universal configuration to OpenAI Responses-compatible configuration.
|
|
@@ -92,6 +100,11 @@ class OpenaiResponsesClient(LLMClient):
|
|
|
92
100
|
if config.get("temperature") is not None:
|
|
93
101
|
openai_config["temperature"] = config["temperature"]
|
|
94
102
|
|
|
103
|
+
# Unlike the model-specific Responses clients, the summary stays inside this branch:
|
|
104
|
+
# OpenRouter reads a reasoning object carrying no effort as "reasoning disabled" and
|
|
105
|
+
# refuses it on a forced-thinking model -- "Reasoning is mandatory for this endpoint
|
|
106
|
+
# and cannot be disabled" (400, verified live 2026-09-03 with z-ai/glm-5.3) -- so a
|
|
107
|
+
# summary sent on its own would turn a dropped value into a failed request.
|
|
95
108
|
if config.get("thinking_level") is not None:
|
|
96
109
|
openai_config["reasoning"] = {"effort": self._convert_thinking_level_to_effort(config["thinking_level"])}
|
|
97
110
|
if config.get("thinking_summary"):
|
|
@@ -156,7 +169,7 @@ class OpenaiResponsesClient(LLMClient):
|
|
|
156
169
|
else:
|
|
157
170
|
content_items.append({"type": "output_text", "text": item["text"]})
|
|
158
171
|
elif item["type"] == "image_url":
|
|
159
|
-
content_items.append(
|
|
172
|
+
content_items.append(self._convert_image_url(item["image_url"]))
|
|
160
173
|
elif item["type"] == "thinking":
|
|
161
174
|
# the wire shape differs by server: OpenAI-style servers stream summaries and
|
|
162
175
|
# demand the summary key back (with encrypted_content preserved), while
|
|
@@ -192,7 +205,7 @@ class OpenaiResponsesClient(LLMClient):
|
|
|
192
205
|
tool_result = [{"type": "input_text", "text": item["text"]}]
|
|
193
206
|
if "images" in item:
|
|
194
207
|
for image_url in item["images"]:
|
|
195
|
-
tool_result.append(
|
|
208
|
+
tool_result.append(self._convert_image_url(image_url))
|
|
196
209
|
|
|
197
210
|
input_list.append(
|
|
198
211
|
{"type": "function_call_output", "call_id": item["tool_call_id"], "output": tool_result}
|
|
@@ -42,6 +42,9 @@ class SupportedModel(TypedDict):
|
|
|
42
42
|
``AutoLLMClient(model=entry["model"], base_url=entry["base_url"], client_type=entry["client"])``.
|
|
43
43
|
Modalities describe what is usable through that client; ``context_window`` and
|
|
44
44
|
``pricing`` are omitted where the platform publishes no authoritative value.
|
|
45
|
+
|
|
46
|
+
``pricing`` is always the LIST price. A running promotion is deliberately not recorded:
|
|
47
|
+
the registry's job is the catalog price, and applying a promotion is the consumer's.
|
|
45
48
|
"""
|
|
46
49
|
|
|
47
50
|
model: str
|
|
@@ -95,20 +98,31 @@ def _cny(prompt: float, output: float, cached: float | None = None) -> ModelPric
|
|
|
95
98
|
# /models APIs on 2026-07-22, SiliconFlow CNY prices from the vendors' official price lists.
|
|
96
99
|
_SUPPORTED_MODELS: list[SupportedModel] = [
|
|
97
100
|
# official vendor endpoints
|
|
101
|
+
{
|
|
102
|
+
"model": "gemini-3.8-flash",
|
|
103
|
+
"base_url": _GOOGLE,
|
|
104
|
+
"client": "gemini-3.8",
|
|
105
|
+
"input_modalities": ["Text", "Image", "Video", "Audio"],
|
|
106
|
+
"output_modalities": ["Text"],
|
|
107
|
+
"context_window": 1048576,
|
|
108
|
+
# Google runs a launch discount through 2026-12-31 on this row and on the two flash
|
|
109
|
+
# rows below; the list price is stored regardless, because applying a running
|
|
110
|
+
# promotion belongs to the consumer, not to the registry.
|
|
111
|
+
"pricing": _usd(1.5, 7.5, cached=0.15),
|
|
112
|
+
},
|
|
98
113
|
{
|
|
99
114
|
"model": "gemini-3.7-flash",
|
|
100
115
|
"base_url": _GOOGLE,
|
|
101
|
-
"client": "gemini-3.
|
|
116
|
+
"client": "gemini-3.8",
|
|
102
117
|
"input_modalities": ["Text", "Image", "Video", "Audio"],
|
|
103
118
|
"output_modalities": ["Text"],
|
|
104
119
|
"context_window": 1048576,
|
|
105
|
-
# official list price; a launch discount halves all three rates through 2026-12-31
|
|
106
120
|
"pricing": _usd(1.5, 7.5, cached=0.15),
|
|
107
121
|
},
|
|
108
122
|
{
|
|
109
123
|
"model": "gemini-3.6-flash",
|
|
110
124
|
"base_url": _GOOGLE,
|
|
111
|
-
"client": "gemini-3.
|
|
125
|
+
"client": "gemini-3.8",
|
|
112
126
|
"input_modalities": ["Text", "Image", "Video", "Audio"],
|
|
113
127
|
"output_modalities": ["Text"],
|
|
114
128
|
"context_window": 1048576,
|
|
@@ -117,7 +131,7 @@ _SUPPORTED_MODELS: list[SupportedModel] = [
|
|
|
117
131
|
{
|
|
118
132
|
"model": "gemini-3.5-flash-lite",
|
|
119
133
|
"base_url": _GOOGLE,
|
|
120
|
-
"client": "gemini-3.
|
|
134
|
+
"client": "gemini-3.8",
|
|
121
135
|
"input_modalities": ["Text", "Image", "Video", "Audio"],
|
|
122
136
|
"output_modalities": ["Text"],
|
|
123
137
|
"context_window": 1048576,
|
|
@@ -126,7 +140,7 @@ _SUPPORTED_MODELS: list[SupportedModel] = [
|
|
|
126
140
|
{
|
|
127
141
|
"model": "gemini-3.5-flash",
|
|
128
142
|
"base_url": _GOOGLE,
|
|
129
|
-
"client": "gemini-3.
|
|
143
|
+
"client": "gemini-3.8",
|
|
130
144
|
"input_modalities": ["Text", "Image", "Video", "Audio"],
|
|
131
145
|
"output_modalities": ["Text"],
|
|
132
146
|
"context_window": 1048576,
|
|
@@ -135,21 +149,21 @@ _SUPPORTED_MODELS: list[SupportedModel] = [
|
|
|
135
149
|
{
|
|
136
150
|
"model": "gemini-3.1-flash-image",
|
|
137
151
|
"base_url": _GOOGLE,
|
|
138
|
-
"client": "gemini-3.
|
|
152
|
+
"client": "gemini-3.8",
|
|
139
153
|
"input_modalities": ["Text", "Image"],
|
|
140
154
|
"output_modalities": ["Image"],
|
|
141
155
|
},
|
|
142
156
|
{
|
|
143
157
|
"model": "gemini-3.1-flash-tts-preview",
|
|
144
158
|
"base_url": _GOOGLE,
|
|
145
|
-
"client": "gemini-3.
|
|
159
|
+
"client": "gemini-3.8",
|
|
146
160
|
"input_modalities": ["Text"],
|
|
147
161
|
"output_modalities": ["Audio"],
|
|
148
162
|
},
|
|
149
163
|
{
|
|
150
164
|
"model": "gemini-embedding-2",
|
|
151
165
|
"base_url": _GOOGLE,
|
|
152
|
-
"client": "gemini-3.
|
|
166
|
+
"client": "gemini-3.8",
|
|
153
167
|
"input_modalities": ["Text"],
|
|
154
168
|
"output_modalities": ["Embed"],
|
|
155
169
|
},
|
|
@@ -0,0 +1,243 @@
|
|
|
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 base64
|
|
16
|
+
import math
|
|
17
|
+
import os
|
|
18
|
+
import re
|
|
19
|
+
from typing import Literal
|
|
20
|
+
|
|
21
|
+
from .types import UsageMetadata
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def fix_openrouter_usage_metadata(usage_metadata: UsageMetadata, base_url: str) -> UsageMetadata:
|
|
25
|
+
"""
|
|
26
|
+
Fix the usage metadata for OpenRouter.
|
|
27
|
+
|
|
28
|
+
OpenRouter occasionally does not include the reasoning tokens to the completion tokens.
|
|
29
|
+
|
|
30
|
+
Args:
|
|
31
|
+
usage_metadata (UsageMetadata): The usage metadata.
|
|
32
|
+
base_url (str): The API URL.
|
|
33
|
+
|
|
34
|
+
Returns:
|
|
35
|
+
UsageMetadata: The fixed usage metadata.
|
|
36
|
+
"""
|
|
37
|
+
fixed_usage_metadata = usage_metadata.copy()
|
|
38
|
+
if "openrouter.ai" in base_url and fixed_usage_metadata["response_tokens"] < 0:
|
|
39
|
+
fixed_usage_metadata["response_tokens"] += fixed_usage_metadata["thoughts_tokens"] or 0
|
|
40
|
+
|
|
41
|
+
return fixed_usage_metadata
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def is_debug_enabled() -> bool:
|
|
45
|
+
"""
|
|
46
|
+
Whether AGENTHUB_DEBUG asks the clients to fail loudly on output they do not recognize.
|
|
47
|
+
|
|
48
|
+
Streaming clients skip an unrecognized event so that a gateway's own frames cannot kill a
|
|
49
|
+
long generation. The same silence hides a genuinely new provider event, so the guards stay
|
|
50
|
+
one environment variable away.
|
|
51
|
+
|
|
52
|
+
Returns:
|
|
53
|
+
bool: Whether debug mode is on.
|
|
54
|
+
"""
|
|
55
|
+
return os.getenv("AGENTHUB_DEBUG", "").strip().lower() not in ("", "0", "false", "no", "off")
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def image_dimensions(data: bytes) -> tuple[int, int] | None:
|
|
59
|
+
"""
|
|
60
|
+
Read the pixel dimensions from the header of a PNG, JPEG, GIF or WebP image.
|
|
61
|
+
|
|
62
|
+
Only the header is inspected, so the bytes may be a prefix of the file; a prefix that ends
|
|
63
|
+
before the dimensions are reached reads as unrecognized.
|
|
64
|
+
|
|
65
|
+
Args:
|
|
66
|
+
data (bytes): The image bytes, or a prefix of them.
|
|
67
|
+
|
|
68
|
+
Returns:
|
|
69
|
+
tuple[int, int] | None: The width and height, or None when the bytes are not a recognized image.
|
|
70
|
+
"""
|
|
71
|
+
# PNG: an 8-byte signature, then the IHDR chunk with width and height
|
|
72
|
+
if len(data) >= 24 and data[:8] == b"\x89PNG\r\n\x1a\n" and data[12:16] == b"IHDR":
|
|
73
|
+
return int.from_bytes(data[16:20], "big"), int.from_bytes(data[20:24], "big")
|
|
74
|
+
|
|
75
|
+
# GIF: the logical screen size follows the 6-byte signature
|
|
76
|
+
if len(data) >= 10 and data[:6] in (b"GIF87a", b"GIF89a"):
|
|
77
|
+
return int.from_bytes(data[6:8], "little"), int.from_bytes(data[8:10], "little")
|
|
78
|
+
|
|
79
|
+
# WebP: a RIFF container whose first chunk names the bitstream flavour
|
|
80
|
+
if len(data) >= 30 and data[:4] == b"RIFF" and data[8:12] == b"WEBP":
|
|
81
|
+
chunk = data[12:16]
|
|
82
|
+
if chunk == b"VP8 " and data[23:26] == b"\x9d\x01\x2a":
|
|
83
|
+
# lossy: a 3-byte frame tag and the key frame start code precede the size, whose top
|
|
84
|
+
# two bits are a scaling hint
|
|
85
|
+
width = int.from_bytes(data[26:28], "little") & 0x3FFF
|
|
86
|
+
height = int.from_bytes(data[28:30], "little") & 0x3FFF
|
|
87
|
+
return width, height
|
|
88
|
+
if chunk == b"VP8L" and data[20] == 0x2F:
|
|
89
|
+
# lossless: 14 bits of width minus one, then 14 bits of height minus one
|
|
90
|
+
bits = int.from_bytes(data[21:25], "little")
|
|
91
|
+
return (bits & 0x3FFF) + 1, ((bits >> 14) & 0x3FFF) + 1
|
|
92
|
+
if chunk == b"VP8X":
|
|
93
|
+
# extended: the canvas size minus one, 24 bits each, after the flags
|
|
94
|
+
return int.from_bytes(data[24:27], "little") + 1, int.from_bytes(data[27:30], "little") + 1
|
|
95
|
+
return None
|
|
96
|
+
|
|
97
|
+
# JPEG: walk the marker segments to the first frame header (SOFn)
|
|
98
|
+
if len(data) >= 4 and data[:2] == b"\xff\xd8":
|
|
99
|
+
offset = 2
|
|
100
|
+
while offset + 3 < len(data):
|
|
101
|
+
if data[offset] != 0xFF:
|
|
102
|
+
return None
|
|
103
|
+
marker = data[offset + 1]
|
|
104
|
+
if marker == 0xFF: # fill byte ahead of a marker
|
|
105
|
+
offset += 1
|
|
106
|
+
continue
|
|
107
|
+
if marker in (0x01, 0xD8) or 0xD0 <= marker <= 0xD7: # TEM, SOI and RSTn stand alone, without a length
|
|
108
|
+
offset += 2
|
|
109
|
+
continue
|
|
110
|
+
if marker in (0xD9, 0xDA): # end of image, or scan data before any frame header
|
|
111
|
+
return None
|
|
112
|
+
if 0xC0 <= marker <= 0xCF and marker not in (0xC4, 0xC8, 0xCC):
|
|
113
|
+
if offset + 8 >= len(data):
|
|
114
|
+
return None
|
|
115
|
+
# length, precision, then height before width
|
|
116
|
+
height = int.from_bytes(data[offset + 5 : offset + 7], "big")
|
|
117
|
+
width = int.from_bytes(data[offset + 7 : offset + 9], "big")
|
|
118
|
+
return width, height
|
|
119
|
+
offset += 2 + int.from_bytes(data[offset + 2 : offset + 4], "big")
|
|
120
|
+
return None
|
|
121
|
+
|
|
122
|
+
return None
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
# The patch count above which the OpenAI vision API rejects an image instead of resizing it
|
|
126
|
+
# (Images and vision guide, "Choose an image detail level":
|
|
127
|
+
# https://developers.openai.com/api/docs/guides/images-vision).
|
|
128
|
+
_OPENAI_PATCH_LIMIT = 30000
|
|
129
|
+
|
|
130
|
+
# The longest side the OpenAI vision API keeps at `original` detail; a larger image is scaled
|
|
131
|
+
# down to fit it before the patches are counted (the same guide, model sizing table).
|
|
132
|
+
_OPENAI_ORIGINAL_MAX_SIDE = 65535
|
|
133
|
+
|
|
134
|
+
# Base64 characters decoded first: 48 bytes, enough for any PNG, GIF or WebP header. A JPEG's
|
|
135
|
+
# frame header may sit behind metadata segments, so its window grows by the factor below until
|
|
136
|
+
# the header is found or the payload runs out.
|
|
137
|
+
_HEADER_PROBE_CHARS = 64
|
|
138
|
+
_HEADER_WINDOW_GROWTH = 4
|
|
139
|
+
|
|
140
|
+
# Characters outside the base64 alphabet, URL-safe variant included, and the map from that
|
|
141
|
+
# variant back to the standard alphabet.
|
|
142
|
+
_NOT_BASE64 = re.compile(r"[^A-Za-z0-9+/_-]")
|
|
143
|
+
_URL_SAFE_TO_STANDARD = str.maketrans("-_", "+/")
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def _base64_payload_start(data_url: str) -> int:
|
|
147
|
+
"""
|
|
148
|
+
Locate the payload of a base64 data URL.
|
|
149
|
+
|
|
150
|
+
Args:
|
|
151
|
+
data_url (str): The URL.
|
|
152
|
+
|
|
153
|
+
Returns:
|
|
154
|
+
int: The index of the payload's first character, or -1 when the URL is not a base64 data URL.
|
|
155
|
+
"""
|
|
156
|
+
if not data_url.startswith("data:"):
|
|
157
|
+
return -1
|
|
158
|
+
comma = data_url.find(",")
|
|
159
|
+
if comma < 0:
|
|
160
|
+
return -1
|
|
161
|
+
# the token is case-insensitive and may follow a space, as a browser reads it
|
|
162
|
+
params = [param.strip().lower() for param in data_url[5:comma].split(";")]
|
|
163
|
+
return comma + 1 if "base64" in params else -1
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def _decode_base64_prefix(text: str) -> bytes:
|
|
167
|
+
"""
|
|
168
|
+
Decode a prefix of a base64 payload the way Node's Buffer does.
|
|
169
|
+
|
|
170
|
+
Whitespace and other stray characters are skipped, the URL-safe alphabet is accepted, and a
|
|
171
|
+
cut inside a 4-character group or missing padding yields the bytes that are complete, so the
|
|
172
|
+
Python and TypeScript clients measure the same image alike.
|
|
173
|
+
|
|
174
|
+
Args:
|
|
175
|
+
text (str): The leading characters of the payload.
|
|
176
|
+
|
|
177
|
+
Returns:
|
|
178
|
+
bytes: The decoded bytes.
|
|
179
|
+
"""
|
|
180
|
+
chars = _NOT_BASE64.sub("", text).translate(_URL_SAFE_TO_STANDARD)
|
|
181
|
+
if len(chars) % 4 == 1: # a lone trailing character carries no whole byte
|
|
182
|
+
chars = chars[:-1]
|
|
183
|
+
return base64.b64decode(chars + "=" * (-len(chars) % 4))
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def exceeds_openai_patch_limit(image_url: str) -> bool:
|
|
187
|
+
"""
|
|
188
|
+
Whether the OpenAI vision API would reject an image at `original` detail.
|
|
189
|
+
|
|
190
|
+
The API covers an image with 32-pixel patches and rejects one that needs more than 30,000 of
|
|
191
|
+
them after its own resizing; at `original` detail the only resizing is the 65,535-pixel cap
|
|
192
|
+
on either side. Only a base64 data URL can be measured here: an HTTP(S) URL answers False.
|
|
193
|
+
The Responses clients pass such a URL through for the API to fetch; the Chat client fetches
|
|
194
|
+
it into a data URL first, so it measures the fetched bytes.
|
|
195
|
+
|
|
196
|
+
Args:
|
|
197
|
+
image_url (str): The image URL.
|
|
198
|
+
|
|
199
|
+
Returns:
|
|
200
|
+
bool: Whether the API would reject the image.
|
|
201
|
+
"""
|
|
202
|
+
start = _base64_payload_start(image_url)
|
|
203
|
+
if start < 0:
|
|
204
|
+
return False
|
|
205
|
+
# decode a growing prefix rather than the whole payload
|
|
206
|
+
chars = _HEADER_PROBE_CHARS
|
|
207
|
+
while True:
|
|
208
|
+
data = _decode_base64_prefix(image_url[start : start + chars])
|
|
209
|
+
size = image_dimensions(data)
|
|
210
|
+
if size is not None or data[:2] != b"\xff\xd8" or start + chars >= len(image_url):
|
|
211
|
+
break
|
|
212
|
+
chars *= _HEADER_WINDOW_GROWTH
|
|
213
|
+
if size is None:
|
|
214
|
+
return False
|
|
215
|
+
|
|
216
|
+
width, height = size
|
|
217
|
+
longest = max(width, height)
|
|
218
|
+
if longest > _OPENAI_ORIGINAL_MAX_SIDE:
|
|
219
|
+
width = math.floor(width * _OPENAI_ORIGINAL_MAX_SIDE / longest + 0.5)
|
|
220
|
+
height = math.floor(height * _OPENAI_ORIGINAL_MAX_SIDE / longest + 0.5)
|
|
221
|
+
return math.ceil(width / 32) * math.ceil(height / 32) > _OPENAI_PATCH_LIMIT
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
def openai_image_detail(model: str, image_url: str) -> Literal["high"] | None:
|
|
225
|
+
"""
|
|
226
|
+
The `detail` an OpenAI image part needs so that the API reads the image.
|
|
227
|
+
|
|
228
|
+
GPT-5.6 reads the default `auto` detail as `original`, which keeps the image's own
|
|
229
|
+
dimensions and rejects one over 30,000 patches instead of resizing it; `high` has the API
|
|
230
|
+
fit it into 2,500 patches, so the image is read instead of refused. Every other model keeps
|
|
231
|
+
a patch budget at every detail level, so no other model gets the field.
|
|
232
|
+
|
|
233
|
+
Args:
|
|
234
|
+
model (str): The model id the request is sent with.
|
|
235
|
+
image_url (str): The image URL as it goes on the wire.
|
|
236
|
+
|
|
237
|
+
Returns:
|
|
238
|
+
Literal["high"] | None: "high" when the image needs it, otherwise None.
|
|
239
|
+
"""
|
|
240
|
+
if "gpt-5.6" in model.lower() and exceeds_openai_patch_limit(image_url):
|
|
241
|
+
return "high"
|
|
242
|
+
|
|
243
|
+
return None
|
|
@@ -1,51 +0,0 @@
|
|
|
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
|
-
|
|
17
|
-
from .types import UsageMetadata
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
def fix_openrouter_usage_metadata(usage_metadata: UsageMetadata, base_url: str) -> UsageMetadata:
|
|
21
|
-
"""
|
|
22
|
-
Fix the usage metadata for OpenRouter.
|
|
23
|
-
|
|
24
|
-
OpenRouter occasionally does not include the reasoning tokens to the completion tokens.
|
|
25
|
-
|
|
26
|
-
Args:
|
|
27
|
-
usage_metadata (UsageMetadata): The usage metadata.
|
|
28
|
-
base_url (str): The API URL.
|
|
29
|
-
|
|
30
|
-
Returns:
|
|
31
|
-
UsageMetadata: The fixed usage metadata.
|
|
32
|
-
"""
|
|
33
|
-
fixed_usage_metadata = usage_metadata.copy()
|
|
34
|
-
if "openrouter.ai" in base_url and fixed_usage_metadata["response_tokens"] < 0:
|
|
35
|
-
fixed_usage_metadata["response_tokens"] += fixed_usage_metadata["thoughts_tokens"] or 0
|
|
36
|
-
|
|
37
|
-
return fixed_usage_metadata
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
def is_debug_enabled() -> bool:
|
|
41
|
-
"""
|
|
42
|
-
Whether AGENTHUB_DEBUG asks the clients to fail loudly on output they do not recognize.
|
|
43
|
-
|
|
44
|
-
Streaming clients skip an unrecognized event so that a gateway's own frames cannot kill a
|
|
45
|
-
long generation. The same silence hides a genuinely new provider event, so the guards stay
|
|
46
|
-
one environment variable away.
|
|
47
|
-
|
|
48
|
-
Returns:
|
|
49
|
-
bool: Whether debug mode is on.
|
|
50
|
-
"""
|
|
51
|
-
return os.getenv("AGENTHUB_DEBUG", "").strip().lower() not in ("", "0", "false", "no", "off")
|
|
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
|
|
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
|