agenthub-python 0.4.7__tar.gz → 0.4.9__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.7 → agenthub_python-0.4.9}/PKG-INFO +1 -1
- {agenthub_python-0.4.7 → agenthub_python-0.4.9}/agenthub/glm5_3/client.py +20 -3
- {agenthub_python-0.4.7 → agenthub_python-0.4.9}/agenthub/gpt5_6/client.py +11 -3
- {agenthub_python-0.4.7 → agenthub_python-0.4.9}/agenthub/integration/playground.py +2 -2
- {agenthub_python-0.4.7 → agenthub_python-0.4.9}/agenthub/openai_chat/client.py +13 -5
- {agenthub_python-0.4.7 → agenthub_python-0.4.9}/agenthub/openai_responses/client.py +11 -3
- {agenthub_python-0.4.7 → agenthub_python-0.4.9}/agenthub/registry.py +11 -0
- agenthub_python-0.4.9/agenthub/utils.py +243 -0
- {agenthub_python-0.4.7 → agenthub_python-0.4.9}/pyproject.toml +1 -1
- {agenthub_python-0.4.7 → agenthub_python-0.4.9}/pyproject.toml.orig +1 -1
- agenthub_python-0.4.7/agenthub/utils.py +0 -51
- {agenthub_python-0.4.7 → agenthub_python-0.4.9}/README.md +0 -0
- {agenthub_python-0.4.7 → agenthub_python-0.4.9}/agenthub/__init__.py +0 -0
- {agenthub_python-0.4.7 → agenthub_python-0.4.9}/agenthub/abort_signal.py +0 -0
- {agenthub_python-0.4.7 → agenthub_python-0.4.9}/agenthub/ant_messages/__init__.py +0 -0
- {agenthub_python-0.4.7 → agenthub_python-0.4.9}/agenthub/ant_messages/client.py +0 -0
- {agenthub_python-0.4.7 → agenthub_python-0.4.9}/agenthub/auto_client.py +0 -0
- {agenthub_python-0.4.7 → agenthub_python-0.4.9}/agenthub/base_client.py +0 -0
- {agenthub_python-0.4.7 → agenthub_python-0.4.9}/agenthub/claude5/__init__.py +0 -0
- {agenthub_python-0.4.7 → agenthub_python-0.4.9}/agenthub/claude5/client.py +0 -0
- {agenthub_python-0.4.7 → agenthub_python-0.4.9}/agenthub/deepseek_v4/__init__.py +0 -0
- {agenthub_python-0.4.7 → agenthub_python-0.4.9}/agenthub/deepseek_v4/client.py +0 -0
- {agenthub_python-0.4.7 → agenthub_python-0.4.9}/agenthub/errors.py +0 -0
- {agenthub_python-0.4.7 → agenthub_python-0.4.9}/agenthub/gemini3_7/__init__.py +0 -0
- {agenthub_python-0.4.7 → agenthub_python-0.4.9}/agenthub/gemini3_7/client.py +0 -0
- {agenthub_python-0.4.7 → agenthub_python-0.4.9}/agenthub/glm5_3/__init__.py +0 -0
- {agenthub_python-0.4.7 → agenthub_python-0.4.9}/agenthub/gpt5_6/__init__.py +0 -0
- {agenthub_python-0.4.7 → agenthub_python-0.4.9}/agenthub/integration/__init__.py +0 -0
- {agenthub_python-0.4.7 → agenthub_python-0.4.9}/agenthub/integration/tracer.py +0 -0
- {agenthub_python-0.4.7 → agenthub_python-0.4.9}/agenthub/kimi_k3/__init__.py +0 -0
- {agenthub_python-0.4.7 → agenthub_python-0.4.9}/agenthub/kimi_k3/client.py +0 -0
- {agenthub_python-0.4.7 → agenthub_python-0.4.9}/agenthub/minimax_m3/__init__.py +0 -0
- {agenthub_python-0.4.7 → agenthub_python-0.4.9}/agenthub/minimax_m3/client.py +0 -0
- {agenthub_python-0.4.7 → agenthub_python-0.4.9}/agenthub/openai_chat/__init__.py +0 -0
- {agenthub_python-0.4.7 → agenthub_python-0.4.9}/agenthub/openai_embedding/__init__.py +0 -0
- {agenthub_python-0.4.7 → agenthub_python-0.4.9}/agenthub/openai_embedding/client.py +0 -0
- {agenthub_python-0.4.7 → agenthub_python-0.4.9}/agenthub/openai_responses/__init__.py +0 -0
- {agenthub_python-0.4.7 → agenthub_python-0.4.9}/agenthub/types.py +0 -0
|
@@ -164,6 +164,12 @@ class GLM5_3Client(LLMClient):
|
|
|
164
164
|
Returns:
|
|
165
165
|
List of OpenAI message dictionaries
|
|
166
166
|
"""
|
|
167
|
+
# glm-5.3-flash is the natively multimodal GLM and the only one that reads image
|
|
168
|
+
# parts (https://docs.z.ai/guides/vlm/glm-5.3-flash); every other GLM answers a request
|
|
169
|
+
# carrying one with an error, so the item is refused here rather than dropped.
|
|
170
|
+
# Provider-hosted ids keep their own casing (e.g. z-ai/glm-5.3-flash), so the version
|
|
171
|
+
# match is case-insensitive.
|
|
172
|
+
supports_image = "glm-5.3-flash" in self._model.lower()
|
|
167
173
|
openai_messages = []
|
|
168
174
|
|
|
169
175
|
for msg in messages:
|
|
@@ -175,7 +181,10 @@ class GLM5_3Client(LLMClient):
|
|
|
175
181
|
if item["type"] == "text":
|
|
176
182
|
content_parts.append({"type": "text", "text": item["text"]})
|
|
177
183
|
elif item["type"] == "image_url":
|
|
178
|
-
|
|
184
|
+
if not supports_image:
|
|
185
|
+
raise ValueError(f"GLM {self._model} does not support image inputs.")
|
|
186
|
+
|
|
187
|
+
content_parts.append({"type": "image_url", "image_url": {"url": item["image_url"]}})
|
|
179
188
|
elif item["type"] == "thinking":
|
|
180
189
|
thinking += item["thinking"]
|
|
181
190
|
thinking_fields.add((item.get("fidelity") or {}).get("reasoning_field"))
|
|
@@ -194,15 +203,23 @@ class GLM5_3Client(LLMClient):
|
|
|
194
203
|
if "tool_call_id" not in item:
|
|
195
204
|
raise ValueError("tool_call_id is required for tool result.")
|
|
196
205
|
|
|
206
|
+
# a tool result without images stays a plain string, the only content shape
|
|
207
|
+
# the Chat Completion schema documents for a tool message
|
|
208
|
+
content = item["text"]
|
|
197
209
|
if "images" in item and item["images"]:
|
|
198
|
-
|
|
210
|
+
if not supports_image:
|
|
211
|
+
raise ValueError(f"GLM {self._model} does not support images in tool results.")
|
|
212
|
+
|
|
213
|
+
content = [{"type": "text", "text": item["text"]}]
|
|
214
|
+
for image_url in item["images"]:
|
|
215
|
+
content.append({"type": "image_url", "image_url": {"url": image_url}})
|
|
199
216
|
|
|
200
217
|
# Tool results are sent as separate messages
|
|
201
218
|
openai_messages.append(
|
|
202
219
|
{
|
|
203
220
|
"role": "tool",
|
|
204
221
|
"tool_call_id": item["tool_call_id"],
|
|
205
|
-
"content":
|
|
222
|
+
"content": content,
|
|
206
223
|
}
|
|
207
224
|
)
|
|
208
225
|
else:
|
|
@@ -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.
|
|
@@ -158,7 +166,7 @@ class GPT5_6Client(LLMClient):
|
|
|
158
166
|
else:
|
|
159
167
|
content_items.append({"type": "output_text", "text": item["text"]})
|
|
160
168
|
elif item["type"] == "image_url":
|
|
161
|
-
content_items.append(
|
|
169
|
+
content_items.append(self._convert_image_url(item["image_url"]))
|
|
162
170
|
elif item["type"] == "thinking":
|
|
163
171
|
# rebuild the reasoning item from the recorded wire fields: the thinking
|
|
164
172
|
# text goes back through the channel that carried it (histories recorded
|
|
@@ -196,7 +204,7 @@ class GPT5_6Client(LLMClient):
|
|
|
196
204
|
tool_result = [{"type": "input_text", "text": item["text"]}]
|
|
197
205
|
if "images" in item:
|
|
198
206
|
for image_url in item["images"]:
|
|
199
|
-
tool_result.append(
|
|
207
|
+
tool_result.append(self._convert_image_url(image_url))
|
|
200
208
|
|
|
201
209
|
input_list.append(
|
|
202
210
|
{"type": "function_call_output", "call_id": item["tool_call_id"], "output": tool_result}
|
|
@@ -225,8 +225,8 @@ def create_chat_app() -> Flask:
|
|
|
225
225
|
<button type="button" role="option" aria-selected="false" class="w-full px-3 py-2 text-left hover:bg-gray-50 focus:bg-gray-50 focus:outline-none" data-combobox-option data-value="claude-sonnet-5" data-label="Claude Sonnet 5" data-description="claude-sonnet-5" onclick="selectComboboxOption('modelCombobox', this)">
|
|
226
226
|
<span class="block truncate text-sm font-medium text-gray-900">Claude Sonnet 5</span>
|
|
227
227
|
</button>
|
|
228
|
-
<button type="button" role="option" aria-selected="false" class="w-full px-3 py-2 text-left hover:bg-gray-50 focus:bg-gray-50 focus:outline-none" data-combobox-option data-value="glm-5.3" data-label="GLM 5.3" data-description="glm-5.3" onclick="selectComboboxOption('modelCombobox', this)">
|
|
229
|
-
<span class="block truncate text-sm font-medium text-gray-900">GLM 5.3</span>
|
|
228
|
+
<button type="button" role="option" aria-selected="false" class="w-full px-3 py-2 text-left hover:bg-gray-50 focus:bg-gray-50 focus:outline-none" data-combobox-option data-value="glm-5.3-flash" data-label="GLM 5.3 Flash" data-description="glm-5.3-flash" onclick="selectComboboxOption('modelCombobox', this)">
|
|
229
|
+
<span class="block truncate text-sm font-medium text-gray-900">GLM 5.3 Flash</span>
|
|
230
230
|
</button>
|
|
231
231
|
<button type="button" role="option" aria-selected="false" class="w-full px-3 py-2 text-left hover:bg-gray-50 focus:bg-gray-50 focus:outline-none" data-combobox-option data-value="kimi-k3" data-label="Kimi K3" data-description="kimi-k3" onclick="selectComboboxOption('modelCombobox', this)">
|
|
232
232
|
<span class="block truncate text-sm font-medium text-gray-900">Kimi K3</span>
|
|
@@ -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(
|
|
@@ -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.
|
|
@@ -156,7 +164,7 @@ class OpenaiResponsesClient(LLMClient):
|
|
|
156
164
|
else:
|
|
157
165
|
content_items.append({"type": "output_text", "text": item["text"]})
|
|
158
166
|
elif item["type"] == "image_url":
|
|
159
|
-
content_items.append(
|
|
167
|
+
content_items.append(self._convert_image_url(item["image_url"]))
|
|
160
168
|
elif item["type"] == "thinking":
|
|
161
169
|
# the wire shape differs by server: OpenAI-style servers stream summaries and
|
|
162
170
|
# demand the summary key back (with encrypted_content preserved), while
|
|
@@ -192,7 +200,7 @@ class OpenaiResponsesClient(LLMClient):
|
|
|
192
200
|
tool_result = [{"type": "input_text", "text": item["text"]}]
|
|
193
201
|
if "images" in item:
|
|
194
202
|
for image_url in item["images"]:
|
|
195
|
-
tool_result.append(
|
|
203
|
+
tool_result.append(self._convert_image_url(image_url))
|
|
196
204
|
|
|
197
205
|
input_list.append(
|
|
198
206
|
{"type": "function_call_output", "call_id": item["tool_call_id"], "output": tool_result}
|
|
@@ -262,6 +262,17 @@ _SUPPORTED_MODELS: list[SupportedModel] = [
|
|
|
262
262
|
"context_window": 1000000,
|
|
263
263
|
"pricing": _usd(1.4, 4.4, cached=0.26),
|
|
264
264
|
},
|
|
265
|
+
{
|
|
266
|
+
"model": "glm-5.3-flash",
|
|
267
|
+
"base_url": _ZAI,
|
|
268
|
+
"client": "glm-5.3",
|
|
269
|
+
"input_modalities": ["Text", "Image"],
|
|
270
|
+
"output_modalities": ["Text"],
|
|
271
|
+
"context_window": 1000000,
|
|
272
|
+
# official list price (verified 2026-08-26); a launch discount halves all three rates
|
|
273
|
+
# through 2026-09-09 (UTC+8)
|
|
274
|
+
"pricing": _usd(0.15, 0.5, cached=0.03),
|
|
275
|
+
},
|
|
265
276
|
{
|
|
266
277
|
"model": "glm-5.2",
|
|
267
278
|
"base_url": _ZAI,
|
|
@@ -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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|