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.
Files changed (38) hide show
  1. {agenthub_python-0.4.7 → agenthub_python-0.4.9}/PKG-INFO +1 -1
  2. {agenthub_python-0.4.7 → agenthub_python-0.4.9}/agenthub/glm5_3/client.py +20 -3
  3. {agenthub_python-0.4.7 → agenthub_python-0.4.9}/agenthub/gpt5_6/client.py +11 -3
  4. {agenthub_python-0.4.7 → agenthub_python-0.4.9}/agenthub/integration/playground.py +2 -2
  5. {agenthub_python-0.4.7 → agenthub_python-0.4.9}/agenthub/openai_chat/client.py +13 -5
  6. {agenthub_python-0.4.7 → agenthub_python-0.4.9}/agenthub/openai_responses/client.py +11 -3
  7. {agenthub_python-0.4.7 → agenthub_python-0.4.9}/agenthub/registry.py +11 -0
  8. agenthub_python-0.4.9/agenthub/utils.py +243 -0
  9. {agenthub_python-0.4.7 → agenthub_python-0.4.9}/pyproject.toml +1 -1
  10. {agenthub_python-0.4.7 → agenthub_python-0.4.9}/pyproject.toml.orig +1 -1
  11. agenthub_python-0.4.7/agenthub/utils.py +0 -51
  12. {agenthub_python-0.4.7 → agenthub_python-0.4.9}/README.md +0 -0
  13. {agenthub_python-0.4.7 → agenthub_python-0.4.9}/agenthub/__init__.py +0 -0
  14. {agenthub_python-0.4.7 → agenthub_python-0.4.9}/agenthub/abort_signal.py +0 -0
  15. {agenthub_python-0.4.7 → agenthub_python-0.4.9}/agenthub/ant_messages/__init__.py +0 -0
  16. {agenthub_python-0.4.7 → agenthub_python-0.4.9}/agenthub/ant_messages/client.py +0 -0
  17. {agenthub_python-0.4.7 → agenthub_python-0.4.9}/agenthub/auto_client.py +0 -0
  18. {agenthub_python-0.4.7 → agenthub_python-0.4.9}/agenthub/base_client.py +0 -0
  19. {agenthub_python-0.4.7 → agenthub_python-0.4.9}/agenthub/claude5/__init__.py +0 -0
  20. {agenthub_python-0.4.7 → agenthub_python-0.4.9}/agenthub/claude5/client.py +0 -0
  21. {agenthub_python-0.4.7 → agenthub_python-0.4.9}/agenthub/deepseek_v4/__init__.py +0 -0
  22. {agenthub_python-0.4.7 → agenthub_python-0.4.9}/agenthub/deepseek_v4/client.py +0 -0
  23. {agenthub_python-0.4.7 → agenthub_python-0.4.9}/agenthub/errors.py +0 -0
  24. {agenthub_python-0.4.7 → agenthub_python-0.4.9}/agenthub/gemini3_7/__init__.py +0 -0
  25. {agenthub_python-0.4.7 → agenthub_python-0.4.9}/agenthub/gemini3_7/client.py +0 -0
  26. {agenthub_python-0.4.7 → agenthub_python-0.4.9}/agenthub/glm5_3/__init__.py +0 -0
  27. {agenthub_python-0.4.7 → agenthub_python-0.4.9}/agenthub/gpt5_6/__init__.py +0 -0
  28. {agenthub_python-0.4.7 → agenthub_python-0.4.9}/agenthub/integration/__init__.py +0 -0
  29. {agenthub_python-0.4.7 → agenthub_python-0.4.9}/agenthub/integration/tracer.py +0 -0
  30. {agenthub_python-0.4.7 → agenthub_python-0.4.9}/agenthub/kimi_k3/__init__.py +0 -0
  31. {agenthub_python-0.4.7 → agenthub_python-0.4.9}/agenthub/kimi_k3/client.py +0 -0
  32. {agenthub_python-0.4.7 → agenthub_python-0.4.9}/agenthub/minimax_m3/__init__.py +0 -0
  33. {agenthub_python-0.4.7 → agenthub_python-0.4.9}/agenthub/minimax_m3/client.py +0 -0
  34. {agenthub_python-0.4.7 → agenthub_python-0.4.9}/agenthub/openai_chat/__init__.py +0 -0
  35. {agenthub_python-0.4.7 → agenthub_python-0.4.9}/agenthub/openai_embedding/__init__.py +0 -0
  36. {agenthub_python-0.4.7 → agenthub_python-0.4.9}/agenthub/openai_embedding/client.py +0 -0
  37. {agenthub_python-0.4.7 → agenthub_python-0.4.9}/agenthub/openai_responses/__init__.py +0 -0
  38. {agenthub_python-0.4.7 → agenthub_python-0.4.9}/agenthub/types.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: agenthub-python
3
- Version: 0.4.7
3
+ Version: 0.4.9
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
@@ -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
- raise ValueError("GLM-5 does not support image inputs.")
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
- raise ValueError("GLM-5 does not support images in tool results.")
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": item["text"],
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({"type": "input_image", "image_url": item["image_url"]})
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({"type": "input_image", "image_url": image_url})
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({"type": "image_url", "image_url": {"url": base64_image}})
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
- base64_image = await self._convert_image_url_to_base64(image_url)
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({"type": "image_url", "image_url": {"url": base64_image}})
183
+ content_parts.append(part)
176
184
  else:
177
- content.append({"type": "image_url", "image_url": {"url": base64_image}})
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({"type": "input_image", "image_url": item["image_url"]})
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({"type": "input_image", "image_url": image_url})
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,6 +1,6 @@
1
1
  [project]
2
2
  name = "agenthub-python"
3
- version = "0.4.7"
3
+ version = "0.4.9"
4
4
  description = "AgentHub is the LLM API Hub for the Agent era, built for high-precision autonomous agents."
5
5
  keywords = [
6
6
  "agent",
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "agenthub-python"
3
- version = "0.4.7"
3
+ version = "0.4.9"
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"
@@ -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")